From 6e7f56e829c9c12174294b8c73d502c33a95f682 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 5 Oct 2026 11:56:41 +0700 Subject: [PATCH 1/2] fix(cost-sweep): give nextpnr an XDC it can open, with real package pins tnf-cost-sweep has been green on main while no arm was ever placed and routed. Two harness defects, one behind the other: - The per-arm XDC (W928) was written to the runner's /tmp, which the container cannot see. Run 37263184079 put e4m9 in routing-pending on "failed to open XDC file", twelve attempts in seconds. The XDC now lives under build/tnfcost/, seen as /work/build/tnfcost/. - With only IOSTANDARD set, nextpnr-xilinx auto-placed an output on a GTP pad: "No Bel named OPAD_X0Y11/IOB33/OUTBUF", placing failed (run 34448747524, branch tnf-publication-readiness). The new tools/sweep_pin_xdc.py pins every port bit to a general-purpose IOB from the image's own package_pins.csv, clk on an MRCC P pin, bank 34 (the AX7203 DDR3 bank) excluded. Selftest 13/13; on a locally synthesized e3m33 it pins 123 pads with no pin used twice. An arm whose log never reaches the router (no "Routing.." or "Running router2") is now recorded as harness-failure and fails the job, so a sweep cannot be green with nothing routed. Routing failures proper stay routing-pending and do not fail it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tnf-cost-sweep.yml | 48 +++++-- tools/sweep_pin_xdc.py | 198 +++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 tools/sweep_pin_xdc.py diff --git a/.github/workflows/tnf-cost-sweep.yml b/.github/workflows/tnf-cost-sweep.yml index 278c96c859..c6ff8f016a 100644 --- a/.github/workflows/tnf-cost-sweep.yml +++ b/.github/workflows/tnf-cost-sweep.yml @@ -39,7 +39,10 @@ name: TNF cost sweep (E_t, M) # and the routing-limit risk is real -- the takum-class cells already fail # routing on this fabric with -flatten. Arms that fail routing are recorded as # [routing-pending], with the reason line and the nextpnr log attached, never as -# zero and never as a defect. +# zero and never as a defect. An arm whose nextpnr never reached the router is +# not a routing result at all: it is recorded as harness-failure and fails the +# job, because until 2026-10-05 exactly that kept this workflow green with no +# arm ever routed. on: workflow_dispatch: @@ -156,11 +159,16 @@ jobs: run: | mkdir -p build/tnfcost/chipdb docker run --rm -v "$PWD:/work" -w /work regymm/openxc7@sha256:eced1cdd4727549f2d983328e0cf170fb6f6f67d87f19b2bf24365163368c70c bash -c "cd /nextpnr-xilinx && python3 xilinx/python/bbaexport.py --device xc7a200tfbg484-2 --bba /work/build/tnfcost/chipdb/xc7a200tfbg484-2.bba && bbasm -l /work/build/tnfcost/chipdb/xc7a200tfbg484-2.bba /work/build/tnfcost/chipdb/xc7a200tfbg484-2.bin" + - name: Package pins from the pinned image + run: | + docker run --rm -v "$PWD:/work" -w /work regymm/openxc7@sha256:eced1cdd4727549f2d983328e0cf170fb6f6f67d87f19b2bf24365163368c70c cp /nextpnr-xilinx/xilinx/external/prjxray-db/artix7/xc7a200tfbg484-2/package_pins.csv /work/build/tnfcost/package_pins.csv + python3 tools/sweep_pin_xdc.py --selftest - name: Place and route the Q1/Q2 arms run: | ARMS="${{ github.event.inputs.pnr_arms }}" [ -z "$ARMS" ] && ARMS="$PNR_DEFAULT" mkdir -p metrics + HARNESS_FAILED="" for TOP in $(echo "$ARMS" | tr ',' ' '); do CLEAN=0 for CFG in "heap router1" "heap router2" "sa router2"; do @@ -168,15 +176,24 @@ jobs: # W928: corona_decode_ax7203.xdc constrains a DIFFERENT top's # ports -- every arm died on "port clk of type PAD has no # IOSTANDARD" and the catch-all called it a fabric limit. Each - # arm now gets an XDC generated from its own yosys json: every - # top-level port gets IOSTANDARD LVCMOS33, indexed and base - # forms both, pads auto-placed (Fmax needs no LOCs). - python3 -c "$(printf 'import json, sys\ntop = sys.argv[1]\nd = json.load(open(\"build/tnfcost/%%s.json\" %% top))\nfor n, m in d[\"modules\"][top][\"ports\"].items():\n b = m.get(\"bits\", [])\n if len(b) <= 1:\n print(\"set_property IOSTANDARD LVCMOS33 [get_ports %%s]\" %% n)\n else:\n for k in range(len(b)):\n print(\"set_property IOSTANDARD LVCMOS33 [get_ports {%%s[%%d]}]\" %% (n, k))\n')" "$TOP" > /tmp/${TOP}.xdc + # arm now gets an XDC generated from its own yosys json. + # Two later defects, both fixed 2026-10-05 (tools/sweep_pin_xdc.py + # carries the detail): + # - the XDC was written to the runner's /tmp, which the container + # cannot see: run 37263184079 put e4m9 in routing-pending on + # "failed to open XDC file" and the workflow stayed green. No + # main run had routed an arm since W928. It now lives under + # build/, which the container sees as /work/build. + # - IOSTANDARD with the pads auto-placed let nextpnr put an output + # on a GTP pad (run 34448747524, "No Bel named + # OPAD_X0Y11/IOB33/OUTBUF"). Every port bit now gets a + # PACKAGE_PIN on a general-purpose IOB, clk on an MRCC P pin. + python3 tools/sweep_pin_xdc.py build/tnfcost/${TOP}.json "$TOP" build/tnfcost/package_pins.csv build/tnfcost/${TOP}.xdc for s in 1 2 3 4; do rm -f build/tnfcost/${TOP}_routed.json docker run --rm -v "$PWD:/work" -w /work regymm/openxc7@sha256:eced1cdd4727549f2d983328e0cf170fb6f6f67d87f19b2bf24365163368c70c timeout --signal=KILL 1800 nextpnr-xilinx \ --chipdb /work/build/tnfcost/chipdb/xc7a200tfbg484-2.bin \ - --xdc /tmp/${TOP}.xdc \ + --xdc /work/build/tnfcost/${TOP}.xdc \ --json /work/build/tnfcost/${TOP}.json \ --write /work/build/tnfcost/${TOP}_routed.json \ --freq 50.0 --seed $s --placer $P --router $R --timing-allow-fail > /tmp/np_${TOP}.log 2>&1 || true @@ -192,13 +209,22 @@ jobs: # line, infrastructure failures were indistinguishable from # fabric limits. Both now ship with the artefact. REASON=$(grep -m1 -E "ERROR|error:|Segmentation|Killed|failed" /tmp/np_${TOP}.log | cut -c1-200) + # A run that never reached the router cannot be a routing limit: + # the XDC, the pins or the placer stopped it, and that is the + # harness. Those fail the job -- a green sweep with no routed arm + # is what hid both 2026-10-05 defects. + reached_router=false + grep -qE "Info: Routing\.\.|Info: Running router2" /tmp/np_${TOP}.log && reached_router=true + STATUS=harness-failure + if [ "$reached_router" = true ]; then STATUS=routing-pending; fi { echo "top=${TOP}" - echo "status=routing-pending" + echo "status=${STATUS}" echo "reason=${REASON:-no-error-line-found}" } | tee metrics/${TOP}.pnr.txt cp /tmp/np_${TOP}.log metrics/${TOP}.nextpnr.log - echo "::warning::${TOP} routing-pending: ${REASON:-see log}" + echo "::warning::${TOP} ${STATUS}: ${REASON:-see log}" + if [ "$reached_router" = false ]; then HARNESS_FAILED="${HARNESS_FAILED} ${TOP}"; fi continue fi { @@ -219,6 +245,12 @@ jobs: } | tee metrics/${TOP}.pnr.txt cp /tmp/np_${TOP}.log metrics/${TOP}.nextpnr.log done + harness_broken=false + [ -n "${HARNESS_FAILED}" ] && harness_broken=true + if [ "$harness_broken" = true ]; then + echo "::error::nextpnr never reached the router for:${HARNESS_FAILED}. That is the harness, not the fabric; metrics/*.nextpnr.log has the reason." + exit 1 + fi - uses: actions/upload-artifact@v4 if: always() with: {name: tnfcost-pnr, path: metrics} diff --git a/tools/sweep_pin_xdc.py b/tools/sweep_pin_xdc.py new file mode 100644 index 0000000000..0377e87b9a --- /dev/null +++ b/tools/sweep_pin_xdc.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Pin every port of a sweep top to a real IOB site, so nextpnr-xilinx can place it. + +tnf-cost-sweep never routed an arm on main. Two harness defects stood in front of +the fabric, one behind the other: + + 1. The per-arm XDC (W928) was written to the runner's /tmp and handed to a + container that sees only $PWD as /work: "failed to open XDC file", twelve + attempts in seconds, routing-pending (run 37263184079, 2026-10-05). + 2. An XDC with IOSTANDARD only and no package pins, the other half of W928, + was tried on branch tnf-publication-readiness (76f341cb3, run 34448747524, + image eced1cdd). With the sites left to it, nextpnr-xilinx put an output on + a GTP pad: "No Bel named 'OPAD_X0Y11/IOB33/OUTBUF'", placing failed. + +So each port bit gets a PACKAGE_PIN from the part's own package_pins.csv (the +one inside the toolchain image), restricted to general-purpose IOB sites. A +port named clk goes on the P side of a multi-region clock-capable pair, so it +can reach a global buffer. Bank 34 is left out: on the AX7203 it is the DDR3 +bank at 1.5 V. These runs stop at a routed json and never make a bitstream, but +a constraint file that would be wrong on the board is not worth keeping either. + +The pins are not the board's, so an Fmax from these runs is a CI-synth number +for comparing arms with each other, never a board number. + +usage: sweep_pin_xdc.py + sweep_pin_xdc.py --selftest +""" +import csv +import io +import json +import pathlib +import sys +import tempfile + +# Order the banks are filled in. 34 is absent on purpose (see above). +BANKS = ("15", "14", "16", "13", "35") +CLOCK_PORTS = ("clk",) + + +def pins_by_preference(rows): + """General-purpose IOB pins in the allowed banks, in a fixed order.""" + usable = [] + for r in rows: + is_iob = r["site"].startswith("IOB_") + allowed = r["bank"] in BANKS + if is_iob and allowed: + usable.append(r) + usable.sort(key=lambda r: (BANKS.index(r["bank"]), r["site"])) + return usable + + +def is_clock_pin(r): + """IO_L12P_T1_MRCC_15: multi-region clock-capable, and the P side of its pair.""" + fields = r["pin_function"].split("_") + mrcc = "MRCC" in fields + p_side = len(fields) > 1 and fields[1].endswith("P") + return mrcc and p_side + + +def port_bits(mod): + out = [] + for name, port in mod.get("ports", {}).items(): + width = len(port.get("bits", [])) + single = width == 1 + for i in range(width): + out.append((name, name if single else f"{name}[{i}]")) + return out + + +def build(mod, rows): + """Return (lines, error). error is None on success.""" + pins = pins_by_preference(rows) + clocks = [r for r in pins if is_clock_pin(r)] + taken = set() + lines = [] + bits = port_bits(mod) + no_ports = not bits + if no_ports: + return [], "the top exposes no ports" + for name, pad in bits: + wants_clock = name in CLOCK_PORTS + pool = clocks if wants_clock else pins + free = [r for r in pool if r["pin"] not in taken] + exhausted = not free + if exhausted: + kind = "clock-capable" if wants_clock else "general-purpose" + return [], f"ran out of {kind} pins at {pad} ({len(bits)} port bits, {len(pins)} pins)" + pick = free[0] + taken.add(pick["pin"]) + lines.append(f"set_property PACKAGE_PIN {pick['pin']} [get_ports {{{pad}}}]") + lines.append(f"set_property IOSTANDARD LVCMOS33 [get_ports {{{pad}}}]") + return lines, None + + +def pick_module(d, top): + mods = d.get("modules", {}) + mod = mods.get(top) + missing = mod is None + if missing: + return None, f"{top} is not a module in the json ({len(mods)} modules)" + return mod, None + + +def main(argv): + src, top, csv_path, out = argv + mod, err = pick_module(json.loads(pathlib.Path(src).read_text()), top) + failed = err is not None + if failed: + print(err, file=sys.stderr) + return 2 + rows = list(csv.DictReader(io.StringIO(pathlib.Path(csv_path).read_text()))) + lines, err = build(mod, rows) + failed = err is not None + if failed: + print(f"{top}: {err}", file=sys.stderr) + return 2 + pathlib.Path(out).write_text("\n".join(lines) + "\n") + print(f"{top}: {len(lines) // 2} pads pinned to IOB sites in banks {','.join(BANKS)}") + return 0 + + +# A 7-pin package: a GTP pad, a bank-34 clock pin, two clock pins in bank 15 (N +# then P), two plain pins in bank 15, one plain pin in bank 14. +FIXTURE_CSV = """pin,bank,site,tile,pin_function +A4,216,OPAD_X0Y8,GTP_CHANNEL_0_MID_LEFT_X103Y214,MGTPTXN0_216 +R4,34,IOB_X1Y124,RIOB33_X105Y124,IO_L13P_T2_MRCC_34 +H19,15,IOB_X0Y175,LIOB33_X0Y175,IO_L12N_T1_MRCC_15 +J19,15,IOB_X0Y176,LIOB33_X0Y175,IO_L12P_T1_MRCC_15 +A13,15,IOB_X0Y150,LIOB33_X0Y149,IO_L1P_T0_15 +A14,15,IOB_X0Y149,LIOB33_X0Y149,IO_L1N_T0_15 +B1,14,IOB_X0Y100,LIOB33_X0Y99,IO_L1P_T0_14 +""" + + +def selftest(): + rows = list(csv.DictReader(io.StringIO(FIXTURE_CSV))) + fails = [] + + def expect(label, ok): + bad = not ok + if bad: + fails.append(label) + + mod = {"ports": {"clk": {"bits": [2]}, "in_a": {"bits": [3, 4]}, "y": {"bits": [5]}}} + lines, err = build(mod, rows) + text = "\n".join(lines) + expect("builds", err is None) + expect("clk on the P side of an MRCC pair in an allowed bank", "PACKAGE_PIN J19 [get_ports {clk}]" in text) + expect("never the N side for clk", "H19 [get_ports {clk}]" not in text) + expect("never bank 34", "R4" not in text) + expect("never a GTP pad", "A4" not in text) + expect("bus bits indexed", "[get_ports {in_a[1]}]" in text) + expect("scalar not indexed", "[get_ports {y}]" in text) + expect("one IOSTANDARD per pad", text.count("IOSTANDARD LVCMOS33") == 4) + expect("no pin used twice", len({l.split()[2] for l in lines if "PACKAGE_PIN" in l}) == 4) + + wide = {"ports": {"d": {"bits": list(range(6))}}} + _, err = build(wide, rows) + expect("too many bits is an error, not a short file", err is not None and "ran out" in err) + + two_clocks = {"ports": {"clk": {"bits": [1, 2]}}} + _, err = build(two_clocks, rows) + expect("a second clock bit with no P pin left is an error", err is not None and "clock-capable" in err) + + _, err = build({"ports": {}}, rows) + expect("no ports is an error", err is not None) + + _, err = pick_module({"modules": {"other": {}}}, "top") + expect("a missing top is an error", err is not None) + + with tempfile.TemporaryDirectory() as t: + tp = pathlib.Path(t) + (tp / "d.json").write_text(json.dumps({"modules": {"top": mod}})) + (tp / "p.csv").write_text(FIXTURE_CSV) + rc = main([str(tp / "d.json"), "top", str(tp / "p.csv"), str(tp / "o.xdc")]) + wrote = (tp / "o.xdc").exists() + written = (tp / "o.xdc").read_text() if wrote else "" + expect("main writes the file", rc == 0 and written == text + "\n") + rc = main([str(tp / "d.json"), "absent", str(tp / "p.csv"), str(tp / "n.xdc")]) + expect("main fails on a missing top and writes nothing", rc == 2 and not (tp / "n.xdc").exists()) + + total = 13 + for f in fails: + print(f"FAIL {f}") + print(f"sweep_pin_xdc selftest: {total - len(fails)}/{total}") + passed = not fails + return 0 if passed else 1 + + +if __name__ == "__main__": + want_selftest = sys.argv[1:] == ["--selftest"] + usage_ok = len(sys.argv) == 5 + if want_selftest: + sys.exit(selftest()) + if not usage_ok: + print(__doc__.strip().splitlines()[-2], file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1:])) From cf77fd2e3ab82850a490c73904da0181f4758d9e Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 5 Oct 2026 12:25:15 +0700 Subject: [PATCH 2/2] fix(cost-sweep): record the critical-path delay instead of an empty wns The wns field grepped for 'Max delay|worst slack|Slack' and caught the two "Info: Slack histogram:" headers, so every routed arm recorded "wns=Info: Slack histogram:;Info: Slack histogram:;" and no number (run 37266625622, all 5 arms). nextpnr-xilinx prints no worst-slack line. Record the post-route critical path's logic/routing split ("11.2 ns logic, 44.5 ns routing" for e4m29), which is what the Fmax line is computed from. Nothing reads wns: refit_cost_model.py reads lc, status, luts and fmax only. The comment above it named corona_decode_ax7203.xdc; the sweep writes its own XDC with pins and IOSTANDARD and no create_clock. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tnf-cost-sweep.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tnf-cost-sweep.yml b/.github/workflows/tnf-cost-sweep.yml index c6ff8f016a..8c7443a8ae 100644 --- a/.github/workflows/tnf-cost-sweep.yml +++ b/.github/workflows/tnf-cost-sweep.yml @@ -234,12 +234,13 @@ jobs: echo "luts=$(grep -E 'SLICE_LUTX' /tmp/np_${TOP}.log | tail -1 | grep -oE '[0-9]+/' | head -1 | tr -d /)" echo "ffs=$(grep -E 'SLICE_FFX' /tmp/np_${TOP}.log | tail -1 | grep -oE '[0-9]+/' | head -1 | tr -d /)" echo "fmax=$(grep -E 'Max frequency for clock' /tmp/np_${TOP}.log | tail -1 | sed 's/^.*: //')" - # Worst slack is reported against the nextpnr --freq target below, - # NOT against an XDC create_clock: corona_decode_ax7203.xdc carries - # pin constraints only. Recording the target with the slack is what - # makes the number readable. + # Fmax is judged against the nextpnr --freq target below, NOT an + # XDC create_clock: the sweep XDC carries pins and IOSTANDARD only. + # nextpnr-xilinx prints no worst-slack line, so none is recorded; + # crit_delay is the logic/routing split of the post-route critical + # path, which is what the Fmax line is computed from. echo "target_freq_mhz=50.0" - echo "wns=$(grep -E 'Max delay|worst slack|Slack' /tmp/np_${TOP}.log | tail -3 | tr '\n' ';')" + echo "crit_delay=$(grep -E 'ns logic, .* ns routing' /tmp/np_${TOP}.log | tail -1 | sed 's/^Info: //')" echo "crit_path=$(grep -E 'Critical path report|critical path' -A2 /tmp/np_${TOP}.log | tail -1)" echo "log_sha256=$(sha256sum /tmp/np_${TOP}.log | cut -d' ' -f1)" } | tee metrics/${TOP}.pnr.txt