Investigations¶
Write-ups of work that was done to answer a question and where the answer wasn't a code change that lives in the repo. Specifically:
- Optimizations that were considered, measured, and dropped (no signal / not worth the complexity).
- Designs that were prototyped and rejected.
- "I think we should do X" proposals where the analysis ruled out X (or scoped X to "later, when Y is true").
The point is to save the next person's time. If you find yourself reaching for the same idea, the entry tells you:
- It was already considered.
- What measurement / argument shut it down.
- Under what conditions it might become worth re-opening.
If you re-investigate anyway, update the entry with the new data — don't open a parallel doc.
What does NOT go here¶
- Active bugs / unresolved problems →
KNOWN_ISSUES.md(local) or a GitHub issue. - Problems with a known fix or workaround →
docs/troubleshooting/. - Designs that were prototyped and shipped → the design doc lives
with the subsystem (e.g.
docs/dfx/<feature>.md). - Architectural decisions that constrain future code → if/when we adopt ADRs, those would live elsewhere; this folder is for things we didn't do.
File naming¶
YYYY-MM-<short-slug>.md — e.g. 2026-06-chip-swimlane-defer-wmb.md.
The date is the month the investigation was done so entries sort
chronologically and stale ones are easy to spot.
Template¶
# <Title — what was proposed, in one line>
**Date**: YYYY-MM-DD
**Verdict**: dropped / deferred-pending-X / superseded-by-Y
## Question
Brief statement of the proposal. Why it might be a good idea — the
intuition that would make a future contributor reach for the same
change.
## What was tried
Concrete actions. Commands, files touched, measurement setup. Enough
that someone can reproduce the measurement, not enough to retell the
whole codebase.
## Result
The numbers, the diff size, the bug found — whatever the actual output
of the investigation was.
## Why not (now)
The decision. Tie it to a specific signal in the result, not just
preference.
## When to reconsider
The condition under which this becomes worth re-opening. "If workload X
shows >Y µs in profile" / "after Z lands" / "if hardware changes such
that ...".
## References
- PRs, commits, issue links.
- Related rules (`.claude/rules/...`) or docs that informed the
decision.
Index¶
Newest first.
-
2026-09 — Rebuilding the orchestrator's host views every run instead of caching them per allocation — measured & dropped: the per-allocation view cache #2205 prescribes is needed, and the reason is a term #1848 could not see. #1848 read
halHostRegister/halHostUnregisteras essentially all per-byte (~6.7 ms/GiB) by inferring from the size asymmetry of one aggregate point, and its own text says the per-call split was never measured. Sweeping size at fixed call count finds a ~5.2 µs floor per register/unregister pair that is flat from 64 B to 4 KiB, alongside a 7.0 ms/GiB slope above ~1 MiB that does confirm #1848 for large buffers — both readings are right in their own regime. Register is per mapped page, and the page size is the allocation's, so 256 KiB on ordinary pages (64 pages, 11.34 µs) costs more than 4 MiB on huge pages (2 pages, 3.43 µs); that non-monotonicity is the mechanism, not noise, and it is #1531's page-configuration sensitivity again. A realisticpaged_attentionbind reads two control tensors for 24 µs/run, ~4% of a 0.6 ms warmchip.run, against a decision rule of 10 µs fixed before the number was seen. Two details beyond the ratio: the floor means cost tracks the number of accessed tensors rather than their size, so a 32-byte control tensor still costs 5.2 µs; and the slope makes #2205's stated trigger a cliff — rebuild would charge 1.77 ms/run for a 256 MiB orchestrator-accessed tensor, three times the whole run. Caching also removes a crossover nobody wants to own as policy: below ~3 accesses, per-accessrtMemcpy(~1.5 µs) beats establishing a mapping at all. The three arguments for rebuilding that hold regardless of cost — invalidation safety, the accessed set changing between runs, and pinned page-table entries — survive as design constraints on the cache rather than as reasons to skip it -
2026-09 — Widening the bounded WAIT-reduction bitmap past BL=64 — BL=64 shipped (#2009 / issue #1376), BL=128 and BL=256 deferred, and the larger result: BL=64 is end-to-end neutral on both production workloads — but amended 2026-09-07, that is a property of those graphs, not of the mechanism: both are near-chains (per-task fanin degree ≤ 1.5 across the whole benchmark corpus, and two of its cases have zero redundant edges), so there is almost nothing for reduction to remove. Under a degree-16 graph (
sliding_window_deps, 15,865 WAIT edges of which BL=64 removes 100%) the same code is −20.47%Orchand −2.59%Effective, andOrchfalls rather than rises becausewire_fanin_taskis charged to that span — ~26 ns saved per removed edge against a per-submit bitmap cost an order of magnitude smaller. Widening only moves one of the two production workloads — Qwen3-14B decode removes 1 of 40 redundant edges at every window because 39 of them are cross-ring long edges outside BL=256 too, while DeepSeek-V4 FLASH decode goes 10,065 → 20,214 of 21,698 (2.0x) — againstWaitReachEntrygrowing 16 → 40 B per slot (1 → 2.5 MiB) and a 1-word shift-merge becoming 4-word on the AICPU submit path for every task. But the same campaign shows the removals buy no time to begin with: 10 pinned-die rounds put every row inside the ±1.1% run-to-run band, which is the expected result because bounded reduction preserves WAIT reachability exactly, so no task's earliest start time moves. Instrumentation says why so little is left to make cheaper — ~90% of the 9,495 cleared edges point at producers alreadyCHIP_TASK_COMPLETEDat wiring, which take thecompleted_faninbranch and never allocate a dep-pool entry, so the real saving is 990 of 19,114 entries (−5.2%) and −4.2% peak occupancy, not on the critical path of a ~30 ms AICore-bound step. Consequences worth carrying forward: the simulator'sestimated_dep_pool_entries_removedis an edge-count upper bound that overstates the runtime saving ~10x (its edge counts are good —droppredicted exactly, total within 6%); and two DeepSeek-V4 aggregations produce wrong answers — keeping decode step 0 reports −37.8% from a single 7x launch-skew sample whose spike sits entirely inSched, and the first step's slow rank cannot separate the arms at all (rank-sum U=5 against a critical value of 1), so only the fast rank is a clean first-step signal - 2026-09 — Sizing the host-log queue: the claim budget is not the constraint — measured & dropped:
kProducerClaimAttempts = 1024is not the knob it looks like. A producer wins its MPSC slot on the first attempt 57–78% of the time and the worst count across every shape tried is 86, so the bound has ~12× headroom; a bound of 16 would have turned 787 successful writes (0.06%) at 64 threads into drops for a worst-case CPU saving of ~1.6 µs against ~100 µs that never occurs. The constraint on loss iskQueueCapacity— the writer's drain rate — and every loss lands inqueue_fullat 4, 16 and 64 threads. Structural, not incidental: a full queue exits ondifference < 0before spending any attempt, soqueue_fullandclaim_exhaustedare nearly mutually exclusive. Records the first wrong conclusion too — a saturating benchmark measures the early-exit path and barely visits the claim loop, so theclaim_exhausted == 0it reports says nothing about the attempt distribution below the bound; pacing the producers is what puts the claim path under test. Also records two properties of this implementation that item 6 does not require and nothing tests: head-of-line blocking (a producer preempted between claiming position P and publishing it parks the writer on P, so later published records cannot drain and other producers start dropping — the writer sleeping rather than spinning is tested, the queue not backing up is not) and fairness (the losses fall on the slowest producers, the opposite of the useful bias for diagnostics). The 45–94% drop rates quoted come from deliberately pathological unpaced workloads and are not representative; what they establish is that the queue absorbs bursts, not sustained overload -
2026-08 — hbg:
rt_set_tensor_data's consumer-wait cannot observe device progress — fixed, found while retiring host-orchestration leftovers (#2068).wait_for_tensor_ready(wait_for_consumers=true)spun on the host mirror'scompleted_watermark, which only the device advances — in its own copy — so the comparison was-1 < last_consumer_local_id, unconditionally true, and the wait could only end at the 15 sTENSOR_DATA_TIMEOUT_MS. No subset worked: a producer completed inline on the host was not an exception, because the seed is the task's own id rather than-1, so even a producer with no consumers stalled (an earlier draft of #2068's description claimed that exception — it is wrong). The producer half was different and did have a working case, since it readstask_state, whichalloc_tensorssets host-side. Left open at the time because two readings fit the code and implied opposite fixes — delete the wait as meaningless under host orchestration, or add the D2H it is missing. Resolved by the first: both halves are deleted and scalar access now rejects a tensor with a producer (owner_task_idor an overlapping TensorMap entry) withINVALID_ARGSat the call. The producer half went the same way rather than keeping its one working case, since a runtime allocation's buffer is uninitialized and has no host view either.last_consumer_local_idandcompleted_watermarkexisted only for that wait and are gone with it, which also takesupdate_completed_watermark()'s per-completion CAS prefix walk out of the scheduler's completion path -
2026-08 — The host-orchestration phase tail is page faults, not the code in the phase — root cause of the two shapes on every hbg host swimlane: 447 of the 449
record_node(nowrecord_in_graph_task) calls above 10 µs took a minor fault, 19% of calls carry 79% of the phase, and a fault costs 14–33 µs here against 1.7 µs off-tree because the process's ownmmap/munmapholdsmmap_lockagainst every faulting thread — three 64 MiB unmaps in an off-tree reproducer recreate the whole distribution. The fault count is deterministic (1063/1065/1168 per two orchestrations) and drops to 29 when glibc keeps freed memory; the cost per fault varies 2.4× between runs of the same binary, which is the measurement noise that hid this for fourteen iterations. Refutes THP (PR_SET_THP_DISABLEleaves the count unchanged), preemption, and node shape; records why the tunables are not a fix (argsregresses, and the probe build's −53% is the probe amplifying its own subject). Amended 2026-08-23: recording the Definition image into the retained upload staging (8 × ~126 KB per dsv4 bind, previously a vector per recording) movedgraph_upload's faults 38 → 1 per bind but lefthost_orch's count unresolvable in both directions, because a freed 126 KB block is reused without re-faulting — size against glibc's mmap and trim thresholds, not byte count, decides what shows up in this tail. Amended 2026-08-25: retaining the 82 MB SM mirror on the runner (one buffer per pipeline slot instead of one per bind) removes anmmap+munmapof that size per bind —hblkhdstops returning to its pre-bind value on 6 of 6 binds, in both arms of two interleaved repetitions — and shows that a retained buffer must be handed over uninitialized: the first implementation usedstd::vector::resize, whose value-initialization faulted in all 20132 pages of the window on each rank's cold bind (~20k minflt against ~1100) and left the whole 82 MB resident.host_orch's warm-bind fault count resolves in neither direction (base [1160, 1256] over eight binds, retained [181, 1268]), since the mirror is ~6 THP faults of a ~1200-fault bind; an earlier attribution of a warm-bind rise to glibc's dynamic mmap threshold is retracted there. That amendment also closes the entry's "Where a fix would go" list — items 1–3 shipped as #1981, item 4 as #1988 plus #2013, and item 3's flat-region form as #2015 — and records that none of them reached the ~1100 faults the submitting thread takes per bind, which is what is left. Amended again the same day: the claim in that amendment that pre-sizing the recorder's node and tensor storage would only help a cold bind is retracted — a slot-creation counter shows warm dsv4 binds still creating 1336 node slots of 1679, because #1981's retention is per thread while the pool hands bodies out through one shared FIFO. Reserving each node's own buffer to the cap makes it exactly one page and is worse than main (minflt 1070 → 2540); packing every body's tensors into one never-grown bump region is what helps (record_nodewarm min 1702/3423 → 1239/1563 µs). Amended 2026-08-25 (last): the count × price framing this entry opened with is refuted by two arms pointing opposite ways — glibc keeping freed memory removes 86% of the faults and buys no time, while #2015 removes 6% and buys 29–43%. The count is not a lever; only the price is, and the in-treemmap_lockwriter that sets it ismprotect, which glibc uses to open a non-main arena (26 calls per bind in that band; those arenas only grow,MADV_DONTNEEDthere is 0). Every earlier strace here tracedmadvise/mmap/munmap/brkand notmprotect, which is why the in-tree source of the exclusion went unfound for three rounds. Consequence: the residual ~1100 resident-page re-faults per bind — survived #1981, #1988, #2013, #2015, mechanism undetermined — showed no measurable latency change when the tunable arm removed 86% of them, so they are not currently established as a performance defect; userspace tools are exhausted (mincoreand pagemap both report presence, not writability), so pricing them at all needsbpftraceonhandle_mm_fault. Corrected 2026-08-26: they are a warm-up cost and they end — atf40cacf30,host_orch's per-bindminfltruns 989, 983 (cold), then 114, 173, 54, 3, 13, 13, 11, 8, and reaches 0 by the sixth bind on three independent runs. Every per-bind count in this entry came from three-to-six-round runs divided by the bind count, so each averaged two cold binds and three or four still-decaying ones under a steady-state label; the quantity being divided was never per-bind. Nothing "survived" the four changes, and the mechanism left undetermined for three rounds turned out not to need determining. Control plane at that commit: 0.529 ms min / 0.570 median over 8 warm binds (host_orch0.341/0.364). The reusable half of this — that dropping the cold bind does not reach the steady state — is now a trap in hbg-bind-phases.md. Also carries the per-site fault table (two of its top three sites were deleted by #2019 and #2015 within days — keep the method, not the numbers) and the three tooling traps that each produced a wrong conclusion first - 2026-08 — hbg: per-block Graph Definitions and cross-layer reuse — adopted at seven Definitions covering all 43 layers, after #1929 replaced the single recording slot the first attempt measured against (it demoted a Graph whose key differed from the in-flight recording's: 79 of 82 intended submissions recorded, host tasks rose 1131 → 1486). Now host submissions 1131 → 129,
host_orch−44% andsm_h2d−85%, butgraph_upload+207% for seven images instead of one, so the control plane nets −17% at the per-phase floor and nothing at the median — a predictable cost traded for a lower floor and a 133%-wide spread that depends on seven recording threads getting CPU. Keeps the structural map that made the reuse provable (367 kernels → 169 classes / 132 by code alone; which blocks can share a Definition and why the hash-routed MoE cannot), the two indices and the last layer's differenthc_postdestination, and the arithmetic that a recorded node costs about what a submitted task costs so break-even sits near three occurrences - 2026-08 — hbg: uploading Graph Definitions once as shared device objects — cut the per-replay 130 KB Definition re-serialization (image build 931→24 µs, orch total −54%), and confirmed the H2D stage is latency- not bandwidth-bound. Amended 2026-08-18: a
--rounds 3split shows 12.19 of the residual 12.9 ms is the one-timertMalloc+memsetof 40 execution-storage blocks (~53 MB), not per-call latency — real per-call is ~17 µs, so batching the reference submissions is worth ≤0.6 ms, and 88% of this change's own cold-start gain (−1.879 ms in that split) came from execution storage shrinking rather than from the byte reduction it targeted. Amended 2026-08-25: the one-time verify gate this change introduced no longer hashes anything —content_hash,verify_stateand the whole-image zero-fill are removed, sincegraph_definition_arrayplusbind_graph_topologyalready bound every device-side read, so the three ~1 MB passes per dsv4 bind bought no safety the structural checks did not - 2026-07 — Why qwen3_14b_decode held a device for 406 s, and the four answers that were wrong — root cause was torch thread oversubscription in goldens, not any of the big things: the reference walks 3584 tiny slice ops per layer and torch sizes its pool from the core count, so a 320-core host paid 6.35 s/layer against 1.05 s at 4. Capping to 8 (#1601) cut the golden 359 s → ~40 s and let the case rejoin the sweep. Records the measured decomposition (compile 59 s / fixture 13 s / golden 359 s / device tens of ms), how to split queue wait from card-held work via the
npu-locktimestamps, and why golden caching, vectorisation, a nightly split andskip_goldenwere each dropped - 2026-07 —
sync_startdrain retry ABA across reusable barrier state — reproduced #1455 deterministically by suspending an old-attempt scheduler between the ack barrier and election; retained a generation-tagged O(log N) tree with fixed thread-0 coordination after packed-atomic, no-root-broadcast, and rotating-coordinator experiments; removed the invasive runtime hook after hook-free UT/ST coverage was established - 2026-07 — Host worker dispatch latency: where the remaining ~50 µs goes — measured & dropped: after #1499 a
Worker.run()costs 43.3 µs fixed + 8.08 µs per task, so the "94% of latency is our runtime, not the IPC" headline is an artifact of benchmarking a single task — at 256 tasks/run the fixed cost is 2%. Also settles #1498 against itself for latency: a pipe wake measures 4.4 µs one way against a 3.5 µs bare fork+shm round trip, so blocking adds more than the whole IPC floor to every dispatch; only the CPU-while-idle argument survives - 2026-07 — Containing A2/A3 SDMA stream teardown after an AICore fault — CANN exposes no remote-channel retirement fence: even device-confirmed stream frees followed by a soft-reset success that did not complete context teardown can add minutes to a later ordinary fault. Contained by making SDMA an explicit per-Worker opt-in (
enable_sdma) so only opt-in Workers carry the slow teardown and ordinary Workers keep fast recovery; full closure needs CANN runtime + driver changes (#1425) - 2026-07 — AICore-side arg fill for ALL dispatches (not just the gated path) — measured & rejected for the ready path: making the AICore fill its own
args[]for every task adds ~1.0 µs to each task'sreceive→startsetup (paged_attention_unroll: 349 ns → 1356 ns), because a ready task has no idle doorbell gate to hide the fill. Offload is a win only onnot_ready(early-dispatch), where the AICore already spins at the gate; shipped design keeps the AICPU filling ready tasks (#1328) - 2026-07 — chip swimlane AICore: switch-overhead source + FIN-early reorder & ACK-gate — measured: the ~0.8 µs inter-task switch is the record write-back
dcci(record,OUT)+dsb(~0.5 µs) + payload setup (~0.28 µs), inherent and not reducible by moving FIN; the WAIT gap (p99 ~700 µs) dominates decode. Shipped: sampleend_timeafter an early FIN, and an AICPU ACK-gate on buffer rotation (release the old buffer only when AICore ACKs the new buffer's first task) to close the FIN-before-record boundary race the reorder introduced - 2026-07 — Removing LocalReadyBuffer exposed a missing dcci in EP dispatch — RESOLVED in #1245: local-buffer removal changed dispatch timing and unmasked a latent kernel bug (dispatch never dcci'd
recv_count_outto HBM → local_expert read count=0 → all-zero output); fixed with a one-line dcci in the example kernel - 2026-06 — Gating the two residual profiling enable() calls on the orch/scheduler hot path — gated under existing
SIMPLER_DFX; magnitude unmeasured, no new macro - 2026-06 — Replacing COND with GM+dcci for AICore→AICPU notification
- 2026-06 — Letting AICore directly read or write the SPR MMIO window
- 2026-06 — PA-unroll 207001: an op-timeout-window issue fixed by #1035, not a launch-order bug
- 2026-06 — Cross-task batched publish: hoist wmb across distinct tasks in one pop — also carries the root cause + fix for the
spmd_sync_start_stress507018 drain-barrier hang - 2026-06 — AICore first-task cold-start: pre-warm dispatch path
- 2026-06 — a5 AICore op-timeout poisons the shared L2 worker (cascade)
- 2026-06 — a5 AICPU filter gate: Scenario B fail-fast guard not added
- 2026-06 — Sanitizer rollout scope: macOS, TSAN gating, LSan
- 2026-06 — chip swimlane: defer per-task wmb to rotation