Skip to content

MI355 GPT-OSS-120B: fix six deadlocks and two silent MFMA hazards; recover the latency cost; add perplexity harness - #6

Merged
sangeeta0201 merged 25 commits into
ROCm:amd_mi355_gpt_oss120bfrom
sangeeta0201:amd_mi355_gpt_oss120b
Aug 5, 2026
Merged

MI355 GPT-OSS-120B: fix six deadlocks and two silent MFMA hazards; recover the latency cost; add perplexity harness#6
sangeeta0201 merged 25 commits into
ROCm:amd_mi355_gpt_oss120bfrom
sangeeta0201:amd_mi355_gpt_oss120b

Conversation

@sangeeta0201

@sangeeta0201 sangeeta0201 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Correctness, performance, and measurement work on the MPK GPT-OSS-120B path for
MI350/MI355 (gfx950). 24 commits on top of c33065f.

Six deadlocks and two silent numeric-corruption bugs fixed, at no latency
cost.
The deadlock fixes themselves cost 2.19 → 2.46 ms/iter; the perf commits
here recover that, ending at 2.16. Net this is roughly latency-neutral — the
value is the correctness, not a speedup.

Correctness

Six hangs, each reproduced and root-caused:

  • Split-KV chunk deadlock — chunk count did not scale with sequence length,
    so workers waited on chunks that were never dispatched.
  • Fused-layer QKV epoch barrier — "+1" counter snapshots read unordered
    against their producers.
  • Degenerate MoE W13→W2 barrier.
  • MoE W13→W2 false sharing — the L2 atomic arrival counter shared a cache
    line with the write-through release flags.
  • Missing early-clobber on multi-load inline asm ("=v""=&v"). One
    root cause behind both a deadlock and a nil-address GPU fault: the compiler
    reused an output register as an input address across the asm block.
  • Stale TaskDesc pointer slots in the multi-layer loop.

Two silent gfx950 scaled-MFMA pipeline hazards: no hang, no fault, no NaN —
roughly 1 iteration in 23 had GEMM results off by 0.4–6%. Liveness checks cannot
catch this class; found by diffing against a serialized reference. Covered by
tests/standalone/test_mfma_pipeline_hazards.hip.

Also: fences where routing and attention data crossed a barrier without
ordering, and host-side validation of the multi-layer pointer tables, which
names the exact null slot behind a nil-address fault (that fault otherwise has
no usable backtrace).

Performance

Two causes, bisected one commit at a time — neither is the widened barrier, so
part of what looked like a barrier regression was instrumentation that landed
alongside it:

  1. Always-on debug instrumentation. The MPK_WS_* breadcrumbs had no
    #ifdef, only a runtime null check, at ~30 sites in the two hottest headers
    — several inside spin loops, storing to pinned host memory. Now behind
    MPK_WORKER_STATE.
  2. Over-scoped fences. A correct fix used agent scope, but the barrier it
    guards is per-XCD, and on MI300/MI350 all CUs in an XCD share one L2. Agent
    scope emitted buffer_wbl2 sc1 (a full L2→HBM flush) per chunk worker per
    layer, and the merger then buffer_inv sc1'd that same L2 — discarding lines
    its own XCD had just written. Scoping to the XCD keeps the ordering and drops
    the flush. Fence lowering was verified against real gfx950 ISA.

Barrier arrival carries the happens-before ordering these counters depend on;
the wait does not. Narrowing who blocks is safe, narrowing who arrives is not
— unless the expected values stop being snapshots, which the deterministic
layer index makes true.

Perplexity

The LM head reduces logits to an argmax in registers and never writes them to
HBM, so the MPK path had no way to measure output quality. Adds an opt-in
PPL_MODE logits sink (off by default, so the serving path is unchanged), a
512–32768 sweep harness, and a CI test. Prefill is already teacher forcing —
the device writeback is guarded by step + j + 1 >= prompt_len — so a
prefill-only run scores a document with no change to the megakernel loop.

Perplexity at seq 512 has a wide run-to-run spread under MXFP4 (94.3–112.5
observed), so single-run differences are not meaningful.

Verification

  • seq 32768 (32,766 iterations), 8192, 4096 — no NaN, no fault, coherent output.
  • CI longest_common_block=18 against the Torch reference (threshold 8) on every
    commit in the series.
  • Greedy-token agreement against Torch across the MFMA hazard fix: 1/4 exact
    matches before, 4/4 after.

sangeeta0201 and others added 25 commits August 5, 2026 21:17
Attention chunks in the fused full-layer gang task are claimed by xcd_rank,
but Phase 3 was nested inside the Phase 1 QKV guard
(`xcd_rank < total_qkv_tiles_per_xcd`, =10 for GPT-OSS 120B). With
NUM_KV_CHUNKS > 10 only 10 workers ever reached the chunk barrier, so the
`(s_chunk_prev % NUM_KV_CHUNKS) == NUM_KV_CHUNKS-1` merge condition never
fired and the megakernel deadlocked. Since the host selected 16 chunks for
seq > 1024, every run above 1k hung. Worker supply is set by the rank guards,
not by grid_dim.

Un-nest Phase 3/4/5 from the QKV guard so all workers_per_xcd (30) workers can
serve chunks. Workers that skip Phase 1 also skip its epoch wait, so they get
a catch-up wait before reading K/V, and qkv_epoch_expected is now snapshotted
before any worker can bump the epoch.

With the barrier fixed, scale chunks with sequence length instead of the fixed
8/16 split: per-chunk attention work is proportional to seqlen/NUM_KV_CHUNKS,
so holding it constant keeps decode latency flat. Empty chunks already stamp
LSE=-inf, so over-provisioning at short seqlen is safe. Add a [CFG] line and a
guard that rejects a chunk count above workers-per-XCD.

Wall-clock ms/iter (serial, GPU 0), before -> after:

  1024   2.295 -> 2.295
  2048    hang -> 2.334
  4096    hang -> 2.464
  8192    hang -> 2.582
  16384   hang -> 2.722
  32768   hang -> 3.040
  49152   hang -> 3.401
  65536   hang -> 3.714

1.6x growth across a 64x sequence increase, with coherent output throughout.

Note: the `Decode: avg ms/iter` diagnostic under-reports at long sequences --
FWDPASS_LOG_MAX (persistent_kernel.cuh) caps the device timing buffer at 8192
entries, so beyond 8k it averages only the first 8117 iterations. The figures
above are wall-clock, covering every iteration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… workers

The fused full-layer gang task (type 216) deadlocked intermittently on
GPT-OSS 120B / MI350 -- 4 hangs in 7 runs at seq 1024, chunks=8 --
independent of chunk count and sequence length. Nothing printed after
"[HOST_DBG] Starting poll loop".

Three barrier targets are snapshotted at task entry as "read_counter + 1".
That is only valid if the worker reads before this layer's producer bumps
the counter. qkv_epoch_expected was safe: its bump is gated on every
participant arriving. attn_release_expected and routing_expected were not --
their producers are the merge worker and the TopK completer, not the
consumer set, so nothing ordered a consumer's read against them.

All 36 layers run inside one task (the ml loop in persistent_kernel.cuh)
with only a per-block __syncthreads between layers, so workers skew freely,
bounded only by barriers they mutually arrive at. Phase 2's participant
count was max(total_qkv_tiles_per_xcd, NUM_KV_CHUNKS) -- 10 or 16 of 30. A
worker above that rank arrived at nothing before Phase 7b; it only waited.
It could therefore fall a full layer behind, read an already-bumped counter,
and wait for a bump that layer never produces, while the layer that would
produce it waited on that worker's MoE tiles.

Widen Phase 2 to all workers_per_xcd. It becomes the single point every
worker on the XCD passes through, pinning them all to the same layer before
either producer can run, which makes both "+1" snapshots well-defined. Safe
because the dispatcher gives this task exactly workers_per_xcd tiles per XCD
(gang_task_tiles_per_xcd = params[28]; dispatch_count = min(n_tile_count,
workers_per_xcd)), one per worker, so every rank arrives exactly once.
Workers with no QKV tile arrive immediately and add no latency.

Validation, strictly serial on one GPU:
  seq 1024 chunks=8:  4 hangs / 7 runs  ->  0 hangs / 10 runs
  seq 4096 chunks=16: 0 hangs / 4 runs
Latency unchanged: 2.34 ms at 1k (was 2.32), 2.47 at 4k, 2.56 at 8k.

Not fixed here: a separate pre-existing "Memory access fault ... on address
(nil)" crash (~1 in 6), distinguishable by the process aborting during the
first decode iteration rather than hanging silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found while auditing the fused-layer path:

1. layer_idx was stored as 0 into the LDS slot on every task entry and
   never advanced, so the MoE W13->W2 barrier's release value
   (layer_idx + 1) was permanently 1. Its d_barrier is monotonic and
   never reset, so once layer 0 wrote 1 the barrier was already
   satisfied for every subsequent layer: W2 workers stopped waiting for
   their own layer's W13 and could read swiglu_out before it was
   written. Tile ordering usually hid this, but nothing enforced it.

   Both live callers now publish a counter that actually advances once
   per layer and is never reset: qkv_epoch_expected in the full-layer
   fused task (216), and the routing epoch in the oproj+topk+moe fused
   task (217), which never initialized the slot at all.

2. FWDPASS_LOG_MAX = 8192 silently truncated the device-clock timing
   ring. Because per-iteration latency grows with sequence length, a
   16k run reported the average of its cheapest 8k iterations. Added
   untruncated aggregates (total_ns / total_iters / dropped) printed as
   [FWD_PASS_TOTAL], and a host-side NOTE when samples were dropped.

Verified at 16384: iters=16382 dropped=8191, all-iteration average
2.663 ms vs 2.280 ms reported by the truncated ring. 1024 unchanged
(5/5 clean, 2.326-2.373 ms).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The multi-layer fused path reloads task_desc->input_ptrs/output_ptrs
from ml_input_table/ml_output_table for every layer past the first, and
dereferences every slot it reloads. A null slot there surfaces only as
"Memory access fault ... on address (nil)", which aborts the process
with no usable wavefront state -- device printf output is lost on
abort, and the GPU coredump ROCm writes carries no register state.

Check the tables on the host instead, where a bad slot can be named by
(layer, xcd, index). Runs at graph-build time only.

Current result on this model: 0 null inputs, 0 null outputs, so this
rules the tables out as the source of the nil-address fault rather than
fixing it. That fault is still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nil-address memory fault aborts the process, and every ordinary
diagnostic channel is unavailable on that path:

  - rocgdb perturbs timing enough to hide the fault entirely (7/7 clean
    against a ~1-in-6 base rate), so running under the debugger is not
    an option.
  - Device printf output is lost on abort. Faulting logs contain no
    [PC_INIT] or [WORKER_XCD] lines at all.
  - The GPU coredump ROCm writes (~34 GB) carries no wavefront state:
    no active dispatches, all registers <unavailable>.

This adds an opt-in tripwire (MPK_NIL_TRIPWIRE=1) that records, per
worker, the layer / phase / tile / base pointer it last reached. The
buffer is pinned host memory mapped for device access, so writes land
in host RAM as they happen and survive the abort.

Two details worth keeping:

  - Only plain stores to each worker's own slots. A first version
    scanned all 35 pointers per layer and cost 4.5x (10.67 vs 2.33 ms),
    which would have hidden the race exactly like rocgdb. The cheap
    version measures 2.47 ms at 8k, matching baseline.
  - A 100ms disk snapshot thread, not just a SIGABRT handler. The ROCm
    runtime installs its own SIGABRT disposition after ours, so on a
    real fault our handler never runs (observed). The file survives
    regardless of who wins that race.

The fault has been reproduced with the tripwire armed, so it does not
suppress the bug. Still unfixed; this is the instrument, not the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing tripwire only resolved to "inside _execute_gang_task", which
is an entire fused layer. At that resolution a worker that faulted is
indistinguishable from one merely parked at a barrier, so the breadcrumbs
could not say where the nil address came from.

Widen the per-worker slot count 4 -> 8 and add:

  - MPK_TW_SUB markers through the 8 phases of the fused layer kernel
    (QKV, barriers, attention, merge, cross-XCD, O-proj/TopK, MoE, exit).
  - Markers inside the MoE kernel, which is where the fault landed. These
    hang off the existing MOE_DBG_SUBPHASE no-op hook, plus two new
    pre-decode markers (1000/1001) covering the routing-mask read itself.
    aux packs the decoded tile identity -- global_tile, expert_idx,
    expert_id, num_activated_experts, is_w2 -- because every address the
    kernel computes derives from those.
  - The decode iteration (pc_iter) at layer entry. The host only sees that
    it died within a second of launch, which at ~2.4ms/iter is anywhere in
    the first few hundred; the first catch was at iteration 7903 of 8192.

g_tw_dev is published by the worker loop because task kernels take pointer
arrays rather than the RuntimeConfig and cannot reach config.tripwire.
Every block stores the same value, so the race is benign.

Cost is ~2% (2.399 vs ~2.35 ms/iter at 1024). That margin matters: rocgdb
perturbs this fault enough to hide it entirely (7/7 clean vs ~1-in-6), and
an earlier tripwire that scanned all 35 pointers per layer cost 4.5x and
would have done the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The TopK worker publishes routing_ready via st_wt (write-through, bypassing
L2) to wake MoE workers on all 8 XCDs. Immediately before, all 256 threads
of that block wrote active_expert_ids and routing_indices with ordinary
st_wt stores.

Only a __syncthreads separated the two. That orders the stores within the
block; it says nothing about when they become visible to another XCD's L2.
So the release flag can reach HBM ahead of the routing data it advertises,
and a remote MoE worker can pass the Phase 7b barrier and read a stale
active_expert_ids / routing_indices.

The contract was already documented in two places and simply not
implemented: the comment here refers to a threadfence_gpu that was never in
this function, and gang_oproj_topk_moe_fused_mi300.cuh states "TopK worker
wrote per-XCD flags via st_wt after threadfence_gpu".

Impact is silent numerical corruption, not a crash. A stale read yields
wrong values that are all still in range: the count at [NUM_EXPERTS] is the
compile-time constant k=4 on every layer, expert ids stay in [0,128), and
route_val stays in [0,4]. So a token can be routed through a previous
layer's expert -- wrong output, no fault.

That last point is worth being explicit about, because this fix was found
while hunting the nil-address fault and it does NOT explain it. Every
address the MoE kernel derives from the routing data stays in bounds even
when the data is stale. The nil-address fault remains unexplained.

Costs one fence per layer: 2.441 vs 2.399 ms/iter at 1024 (~1.7%).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ml loop rewrites task_desc->input_ptrs / output_ptrs in place for each
layer, but copied only the first 24 inputs and 11 outputs -- sized for the
plain fused-layer kernel. The LM-head variant
(TASK_GANG_FULL_LAYER_WITH_LMHEAD_FUSED_MI300) reads input_ptrs[24..27] and
output_ptrs[12]: the final norm weight, the LM-head MXFP4 weight, its bias,
and the argmax output slot.

With FUSE_TAIL=1 those five slots would never be refreshed past layer 0, so
every later layer would dereference layer 0's pointers -- and TaskDesc is
reused shared memory, so a slot the plain variant never writes holds
whatever the previous task left there.

Latent in the default configuration: FUSE_TAIL defaults to 0, so all 36
layers build as type 216 and index 24+ is never read. It fires the moment
tail fusion is turned on.

Both the host table build and the device refresh now key off
MAX_INPUTS_PER_TASK / MAX_OUTPUTS_PER_TASK. The two widths must agree --
they are the stride of the same table -- so deriving both from the TaskDesc
capacity keeps them correct for any future variant. The allocation already
derives from the same constants and scales automatically.

Found while auditing every unbounded pointer the fused path can dereference,
during the nil-address fault hunt. Not the cause of that fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-chunk attention workers write their o_acc/lse_acc partials with
ordinary stores, then arrive at the chunk barrier; the last arrival runs
merge_splitkv_ck_fmha over all NUM_KV_CHUNKS slots. Nothing made those
partials visible to the merging worker.

atom_add_release_gpu_s32 orders the arriving thread's own prior writes,
but tid 0 is not the thread that wrote most of the chunk's partials --
the other 255 threads did, and __syncthreads is a block-execution
barrier, not a cross-CU visibility barrier. The merging worker is a
different block, so it could read a partially-written or stale slot.

The failure is silent numerical corruption rather than a clean crash:
merge weights each chunk by exp2(lse - m_global), so a torn lse that
lands large drives every other chunk's weight to zero, and one that
lands as uninitialized bits can be NaN and poison the whole attention
output.

Seq-len dependence comes from the empty-chunk path. At 512 tokens
ntiles=32 and 14 of 30 chunks exit early without writing, so the window
is narrow; at 32k ntiles=2048 gives all 30 chunks real work to write, so
it is open on every chunk of every layer.

Producer side: s_waitcnt + __syncthreads + threadfence_gpu before the
arrival, so all 256 threads' stores are flushed. Consumer side: acquire
fence + buffer_inv before merge, so it reads what they wrote rather than
this CU's stale vL1 lines.

Also fix the worker-state dump, which could not localize a hang: ws[3]
was set to 11 once at gang entry and never updated, so with all 36
layers running inside one task every worker reported phase=11 for the
whole run regardless of which layer it was stuck in. Publish 40000+ml
per layer and decode it in the host dump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
arrival counter and the write-through release flags

The 32k runs wedged intermittently with every worker parked at a barrier
short by exactly one arrival. Barrier-watch instrumentation caught the
state: 237 workers waiting at the cross-XCD attention barrier and the QKV
epoch barrier, all at the same epoch, held up by 3 stragglers a full layer
behind, parked at the MoE W13->W2 barrier for two different experts.

The aux capture named the mechanism. For each stuck expert the arrival
counter was an exact multiple of W13_TILES -- every producer had arrived
and the release had therefore fired -- yet the eight per-XCD release slots
of that same expert held *different* epoch values. One producer writes all
eight in one loop, so a spread across them is impossible if the writes
survived.

They did not survive. The release fan-out uses st_wt (sc0 sc1), which
bypasses L2 and lands in HBM, while the arrival counter is an ordinary
L2-resident atomic read-modify-write. Both sat in the same 64-byte line:
HIER_STRIDE was 16 int32 per *expert*, with slots [0..7] the releases and
[8] the counter. An L2 writeback of that line, still holding the old
release values, lands on top of the fresh write-through data and reverts
releases that already happened. The W2 workers for that expert then wait
forever on a release that did fire.

The fix gives every slot its own cache line: 10 lines of 16 int32 per
expert, releases at [x*16] and the counter at [8*16]. This is the layout
the sibling barrier in gang_linear_mxfp4_res_bias_rmsnorm_topk_mi300.cuh
has always used -- only the MoE barrier packed everything into one line.
The moe_fused_barrier tensor grows from 16*E to 160*E int32 (8KB for
E=128), and MOE_BAR_* replaces the local HIER_STRIDE constants so producer
and consumer cannot drift apart again.

Also in this commit, the instrumentation that found it:

- Barrier watch (MPK_WS_WAIT_BEGIN/TICK): records observed vs expected per
  spinning worker, which separates "the producer never arrived" from "the
  waiter expects an epoch that will never be reached".
- MPK_WS_MARK: straight-line progress marks for code that is not a spin
  loop. Packed into a single store -- the four-store version cost 2.3x
  (6.99 vs 3.01 ms/token) because the buffer is pinned host memory and
  every store is a PCIe write, far past the point where the instrument
  perturbs the race it is meant to catch.
- MPK_WS_WAIT_AUX: barrier-specific values, used here for the arrival
  counter and the agreement across the eight release slots.
- The host dump now gates every watch read on the worker's live phase. The
  watch slots are last-write-wins and are never cleared on exit, so the
  first capture printed 56 "blockers" that were all idle at the scheduler
  while the one worker actually inside the layer went unprinted.

Smoke: 2048 tokens, correct output, 3.428 ms/token -- unchanged against
the 3.415 ms instrumented baseline, so the wider barrier costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root cause of the intermittent long-sequence hang.

The FP8 quant of the SwiGLU output issues four NT loads from one asm
volatile block with plain "=v" outputs. Because the block contains four
separate instructions, the register allocator is free to place an output
on top of an input it considers dead after the block -- and it does:

    v_lshl_add_u64 v[6:7],  v[4:5], 0, 16
    v_lshl_add_u64 v[8:9],  v[4:5], 0, 32
    v_lshl_add_u64 v[10:11],v[4:5], 0, 48
    global_load_dwordx4 v[4:7],   v[4:5],  off sc0 sc1 nt
    global_load_dwordx4 v[8:11],  v[6:7],  off sc0 sc1 nt
    global_load_dwordx4 v[12:15], v[8:9],  off sc0 sc1 nt
    global_load_dwordx4 v[16:19], v[10:11],off sc0 sc1 nt

Load 1's destination v[4:7] overwrites load 2's address v[6:7], and
load 2's destination v[8:11] overwrites the addresses of loads 3 and 4.
Loads 2-4 therefore address memory using whatever activation data the
earlier loads returned. Marking the outputs early-clobber ("=&v") makes
the allocator keep them disjoint; the addresses then live in v[20:27].

This single defect explains both observed failure modes. A garbage
address that faults gives the nil-address crash; one that never
completes leaves the wave parked on the following s_waitcnt vmcnt(0)
forever, which deadlocks the __syncthreads at the end of the quant and
through it the entire block -- and through the block, every worker
waiting on the layer barriers.

The capture that pinned it: with per-wave masks recorded on both sides
of the barrier, the stalled worker held wave exit mask 0xf (all four
waves cleared the W13->W2 barrier) against quant sync mask 0xe (wave 0
never reached the quant's __syncthreads), frozen identically across all
345 dumps.

Same defect fixed in the four other multi-load asm blocks that had it
(a second copy of the 4x NT load, two W2 epilogue prefetches, one OProj
prefetch). A scan of every asm block in persistent_kernel with two or
more loads and non-early-clobber vgpr outputs now comes back empty.

Also fixed here, found while reading the same function: the amax
cross-lane reduction assumed NSUBBLOCKS is a multiple of 4. It is 90 for
the W2 path (INTERMEDIATE_SIZE 2880 / 32), so the tail super-block
shuffled from two lanes whose loop condition had failed and whose amax
register was never written, giving a block scale derived from garbage.
The partner index is now clamped to the last sub-block that ran.

Instrumentation: per-wave exit/arrival masks for thread-divergent polls,
which is what made the block-split visible at all -- every previous
tracer was tid-0 only, and tid 0 was on the cleared side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two register hazards in software-pipelined
v_mfma_scale_f32_16x16x128_f8f6f4 loops corrupt results silently -- no
hang, no fault, no NaN. Each perturbs roughly one iteration out of a
22-iteration accumulation, giving 0.4-6% GEMM error that sits near the
FP4 quantization noise floor. Both are present in production while the
model emits fluent text at full speed.

Hazard 1 (intermittent, ~17-22% of launches): the loop issues iteration
N+1's ds_read into the same VGPRs iteration N's MFMA reads as sources,
relying on the read landing late. That is a WAR race -- lgkmcnt tracks
when LDS data reaches the VGPR, not when the MFMA finished sampling its
operands, and the ISA exposes no counter for the latter. Fix is two
disjoint operand banks, ping-ponged.

Hazard 2 (deterministic): "s_nop 7; s_nop 0" is a 9-clock gap before
v_accvgpr_read_b32, but the scaled MFMA is a 32-cycle op on CDNA4. The
9-clock tail is correct for a 4-pass MFMA and appears to be a leftover
from smaller 16x16x16 f16 code. Fix is s_nop 15 x2.

The test builds reference (no overlap), fixed (banked + 32clk) and
broken (historical) schedules, and requires the first two to be
bit-exact. The broken variant is retained so the test can prove it still
detects the hazard, and warns if it ever stops reproducing -- that means
the test went blind, not that the hardware got safer.

Note for anyone porting the fix: with an even iteration count (22 in
production) the ping-pong loop exits on a bank-1 iteration, so the
trailing MFMA must read bank 0. A single-tail port silently
reintroduces the same class of error. Both exit paths are spelled out
in mfma_fixed.

Detection only; the production loops in
gang_rmsnorm_linear_mxfp4_bias_mi300.cuh and
gang_moe_fused_mxfp4_mi300.cuh are not yet repaired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PIPELINED_QKV (gang_rmsnorm_linear_mxfp4_bias_mi300.cuh) and PIPELINED_W13_T0
/ W13_T1 / W2_T0 (gang_moe_fused_mxfp4_mi300.cuh) each carried two register
hazards that corrupt results with no hang, no fault and no NaN.

Hazard 1 -- WAR race on MFMA sources. The loop issued iteration N+1's ds_read
into the same VGPRs iteration N's MFMA reads, relying on the read landing
"late". lgkmcnt tracks when LDS data reaches the VGPR, not when the MFMA
finished sampling its sources; the ISA exposes no counter for the latter, and
a 16x16x128 MFMA streams operands over the op's duration rather than latching
at issue. When LDS returns fast the op sees mixed-iteration operands.
Intermittent, ~17-22% of launches. The tell was that the B-scale alone was
double-buffered through a staging register while the other four operands were
overwritten in place.

  Fix: two disjoint operand banks, ping-pong. While the MFMA consumes bank X
  the prefetch writes bank 1-X, so no register is ever both a live MFMA source
  and an in-flight LDS destination. Bank 1 (v18, v19, v26-v29, v32-v39) added
  to the clobber lists.

Hazard 2 -- AccVGPR read before retirement. "s_nop 7; s_nop 0" is 9 clocks
(s_nop N waits N+1) before v_accvgpr_read_b32 of a 32-cycle op. Deterministic,
wrong every launch. That tail is the documented wait for a 4-pass MFMA and
looks carried over from 16x16x16 f16 code.

  Fix: "s_nop 15" x2 == 32 clocks. Measured threshold is 11 clocks (10 fails
  300/300, 11 passes 300/300), corroborated by LLVM's hazard recognizer, which
  emits exactly s_nop 7; s_nop 2 for the intrinsic-based MFMAs in
  gang_linear_mxfp4_res_bias_rmsnorm_topk_kernel. Inline asm bypasses that
  recognizer, which is why the hand-written tail sat two clocks short. We pad
  to 32 anyway: the threshold is undocumented and the tail runs once per GEMM.

Ping-pong makes exit parity load-bearing, so both tails are emitted. All four
loops run 23 iterations (REDUCTION_SIZE 2944 / K_PER_MFMA 128), which exits via
the bank-1 half with final operands in bank 0; a single-tail port would
silently reintroduce the bug at the other parity.

Why this survived: each hazard perturbs one iteration out of 23, bounding GEMM
error at 0.4-6% -- at the FP4 quantization noise floor, so greedy argmax over
~200k logits usually picks the same token. Every check applied for weeks was a
liveness check. A kernel that runs and emits fluent text is not evidence of
numerical correctness.

Verified in emitted ISA for all four loops (disjoint banks, both tails present)
and against the regression test at both parities:

  ITERS=23  reference 0/19200  fixed 0/19200  broken 19200/19200 (72.04%)
  ITERS=22  reference 0/19200  fixed 0/19200  broken 19200/19200 (19.34%)

Demo runs clean: RC=0, 3.428 ms/token at 2048 seq len, coherent output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until now the only correctness signal for the megakernel was greedy-token
agreement against a Torch reference -- a thresholded, single-prompt proxy. It
says the two paths agree or they don't, with nothing in between, so a numerical
regression that bends the distribution without flipping the argmax is invisible
to it. This adds a continuous, corpus-wide number.

Two things were missing. The first was logits: the LM head keeps the logit row
in registers and reduces it to a (max_val, abs_idx) pair, deliberately, because
that removes a 393 KB write plus read every step. The second was teacher
forcing -- and that one turned out to already exist. prepare_next_batch
(persistent_kernel.cuh:692) only copies a sampled token into tokens[] when
step + j + 1 >= prompt_len, so inside the prompt the kernel consumes whatever
the host placed there. Prefill IS teacher forcing. Loading a corpus as one long
prompt and running prefill-only scores every position against the reference
prefix with no per-step host round-trip and no change to the megakernel loop.

So only the logits sink is new code:

  gang_rmsnorm_linear_mxfp4_bias_argmax_mi300.cuh gains an optional
  logits_out_ptr plus the step to write. The store reuses the abs_idx and
  bias-added val the argmax path already computed, and is guarded on the
  pointer being non-null, so the serving path is untouched -- measured 2.437
  ms/iter with PPL_MODE unset against a 2.424-2.431 baseline.

  The sink is attached with input_map (-1,-1,-1), NOT the (1,-1,-1) the argmax
  partials use. A non-negative map partitions that dim across grid_dim.x and
  runtime.cc pre-shifts each block's base pointer by bid.x * vocab/8; since the
  kernel indexes by the absolute vocab column, a pre-shifted base double-counts
  the offset. Getting this wrong wrote alternating 25152-column stripes from
  blocks 0-3 and ran blocks 4-7 off the end of the row, which scored 64.5
  against a Torch 8.15 while looking like a plausible accuracy result.

  Both places that record the input/output split -- task_register.cc and
  graph.cc's task_config -- now derive num_outputs from bgraph.operators.size()
  instead of hardcoding 2, so runtime.cc:207's assert holds for both shapes.

PPL_MODE=1 in demo.py wires it up: load a WikiText-2 slice, write the ids into
tokens[], set max_seq_length so the prefill-completion stop fires exactly at the
end of the slice, then cross-entropy on the host against the corpus targets.
Logits land in f32, not bf16 -- bf16's ~0.4% relative precision is the same
order as the GEMM error being measured. The softmax is sliced to
config.vocab_size because the buffer is padded 201088 -> 201216 and the pad
columns must stay out of the denominator. PPL_MODE rejects FUSE_TAIL=1 (the
fused tail returns before it would reach the sink) and any
--max-num-batched-tokens but 1 (the LM head GEMM consumes one row per
iteration).

The demo also self-checks the sink rather than trusting it: it reconstructs
each of the 240 workers' owned column sets from the sink and compares against
argmax_part_value/argmax_part_index. 0 index mismatches, 0 value mismatches,
and sink_argmax == the token the kernel emitted -- so the sink is a faithful
copy of what the in-register reduce saw, and any MPK-vs-Torch gap is accuracy,
not instrumentation.

512-token WikiText-2 slice: MPK 91.84 vs Torch 36.04, ratio 2.55, with the two
paths picking the same argmax at 54.2% of positions. The test gates on a
ceiling and a ratio, not an exact value, because MXFP4 decode is not
bit-deterministic; both gates were verified to fail when tightened.

One trap worth naming, since it cost real debugging time: "top-1" is two
different numbers here. Agreement with Torch's argmax (54.2%) and accuracy
against the corpus targets (MPK 28.2%, Torch 39.5%) are both natural things to
call top-1, and comparing one against the other across runs reads as a
contradiction that isn't there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sink-coverage diagnostics allocated whole-tensor temporaries: `== 0.0` on
[n, 201088] is a 6 GB bool at 32k and `.float()` on it is another 25 GB, on top
of the 25 GB sink. That is an OOM in the instrumentation rather than in the
thing being measured, so both now run in the same CH-sized chunks the scoring
loop uses.

run_gpt_oss_ppl_sweep.sh scores a WikiText-2 prefix at each length and
summarize_ppl_sweep.py tabulates the dumps. The sweep does not use -e: the
Torch reference OOMs partway up and a single length failing must not take the
rest of the run with it.

The summarizer reports a fixed-prefix column, and that turned out to be the
whole point. Full-slice perplexity climbs steeply with length -- but it climbs
on the *Torch reference* too (36.0 -> 139.1 from 512 to 1024), which no kernel
change can explain. It is the corpus: WikiText-2 past position ~512 is a dense
run of proper nouns averaging 6.27 nats against 3.58 for the first 511 tokens.
Scoring every run over the same 511 positions removes it. MPK then reads
93.3 / 101.5 / 91.6 / 107.3 / 88.5 / 96.8 / 93.0 across 512 -> 32768 -- flat,
and inside the 92-100 spread three runs at 512 alone produced. Perplexity does
not degrade with context out to 32k.

Latency 2.46 -> 3.70 ms/iter for 64x the context. KV chunks saturate at 30
(workers per XCD) from 4096 on, so past that each chunk just covers more KV.

Both sink self-checks hold at every length including 32k: 0 index and 0 value
mismatches against all 240 per-worker argmax pairs. Zero columns are isolated
singletons (763 of 32767x201088 = 1.2e-7, first at row 88 column 97897) --
logits that genuinely round to 0.0f, not columns the kernel skipped.

The Torch reference only reaches 1024. Its attention materializes a [64, n, n]
f32 score matrix (modeling_gpt_oss.py:172) and the bf16 model already holds
~249 of the 252 GB visible, so 2048 OOMs. Long rows are MPK-only by necessity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sweep compared MPK against a reference running weights the megakernel does
not run. MPK quantizes the LM head, QKV and O-proj -- all bf16 in the
checkpoint -- down to MXFP4; the reference kept all three in bf16. (The MoE
experts are natively MXFP4 in both, so they were never part of the gap.) The
2.5x perplexity ratio I reported therefore charged weight quantization to the
kernel.

PPL_MXFP4_MATCH=1 round-trips all three weight classes in the reference through
the same quantizer, generalizing the existing head-only PPL_MXFP4_HEAD. The
round-trip is verified to mutate weights ~9% and layer outputs ~10%, and to be
shape-preserving at every shape used here, so the matched run is a real forward
pass and not a no-op.

Note USE_FP16_ACT (demo.py:1535) only covers QKV, not O-proj or the head, so it
is not a substitute. USE_FP8_ACT, exported by all three CI scripts, is read
nowhere in the tree at all -- dead, and worth deleting separately.

On 1023 tokens the matched reference scores 100.06 against bf16's 139.08. That
direction is not physical -- quantization cannot add information -- and Torch
is bit-deterministic here (139.0814 twice), so it is not run noise but a
small-sample artifact: MXFP4 error landing favorably on one WikiText-2 article.
It means the absolute matched number should not be quoted as "the" reference
until it is averaged over a much larger corpus.

What does survive is the per-position comparison, which does not depend on
which slice is easier. MPK is worse than the matched reference at the median
position (+0.86 nats) by more than it is worse than bf16 (+0.57), and its
predictive entropy is higher than both (6.27 vs 5.52 vs 4.89). So matching
weight precision does NOT close the gap -- MPK is flatter and worse than a
reference handed exactly the same coarse weights, and the remaining difference
is kernel arithmetic, not quantization.

Also corrects a claim in the previous commit: the accuracy decline across the
sweep (27% -> 11%) was the corpus, not context length. Over the same 511
positions accuracy is flat at 26-28% from 512 to 32768, matching the flat
fixed-prefix perplexity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--max-layers set a local num_layers that bounded only the MPK task graph.
GptOssModel.forward iterates self.layers unconditionally, so the Torch path
kept running all 36 layers and every MPK-vs-Torch comparison under
--max-layers was comparing an N-layer kernel against a 36-layer reference.
That is not a small skew: it reported logit correlation of 0.076 at one layer,
which reads as "the kernel is completely wrong on a single layer". With the
reference actually truncated the same number reads 0.991.

Truncating model.model.layers alongside num_layers makes the flag mean what it
says on both paths.

PPL_DUMP_LOGITS/PPL_DUMP_ROWS dump raw logit rows from either path. Derived
metrics can only say two distributions differ; the raw vectors say how, and a
scale error, a constant offset and unstructured noise are three different bugs
that look identical after a softmax.

Used together these localize the MPK-vs-Torch gap, and the answer is that
there is no defect to fix:

  - per-layer divergence from a precision-matched reference is 0.077 and error
    grows as sqrt(depth) (0.0766 / 0.0789 / 0.0736 at 1 / 4 / 12 layers), the
    signature of independent rounding accumulating, not a systematic fault
  - the logit error is zero-mean (bias/noise < 0.2 at most positions), so no
    scale, index or masking error
  - best-fit scale between MPK and Torch logit vectors is 1.0
  - one MXFP4 GEMM costs 11.5% output error on its own -- more than MPK's
    whole-layer divergence, so the kernel sits below the noise floor of the
    format it runs in
  - the arithmetic is MXFP4 weights x FP8 E4M3 activations on
    v_mfma_scale_f32_16x16x128_f8f6f4 with f32 accumulate. Measured on a
    K=3072 GEMM with this tree's own quantizers, FP8 activations contribute
    0.026 relative error against MXFP4 weights' 0.210 -- 8x smaller, and the
    two together give 0.212, so the activation format is not what separates
    the paths
  - MoE routing picks identical experts in identical order, and post-attention
    RMSNorm matches to max abs diff 0.000000, so the discrete decisions agree
  - no discontinuity at the sliding-window boundary (row 127 vs 140), and
    correlation plateaus at 0.97-0.99 out to 512 rather than compounding

So the earlier "MPK is worse than a matched reference" claim was measuring
sqrt-depth rounding divergence between two equally-valid orderings, not a
correctness bug. Both paths land the same distance from an unquantized model;
they simply round differently, and at 36 layers that separates the argmax about
half the time on high-entropy text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The GEMM arithmetic is not selectable. Every MI300 kernel quantizes its
activations with _gang_wave_parallel_fp8_quant (FP8 E4M3, E8M0 per-128-element
block scale) and feeds v_mfma_scale_f32_16x16x128_f8f6f4 against MXFP4 weights,
accumulating in f32. The alternatives were removed from the kernels, but three
pieces of their scaffolding outlived them.

USE_FP16_ACT was the live one, and it was a bug. demo.py still honored it,
packing raw bf16 into the workgroup buffer via pack_bf16_workgroup and handing
it to the kernel through the same mxfp4_weight= parameter as the quantized
tensor. The only guards that would have made a kernel decode those bytes as
bf16 -- USE_BF16_NATIVE_WEIGHTS and USE_BF16_ACTIVATIONS -- are not defined
anywhere: not in persistent_kernel.py's JIT flag list, not in CMake, nowhere.
So USE_FP16_ACT=1 fed bf16 to the MXFP4 unpacker. The bf16 buffer is larger
than the MXFP4 one it replaced (368640 vs 97920 bytes per QKV workgroup), so
there was no OOB read and no fault: it decoded bf16 bit patterns as E2M1
nibbles and produced confident garbage. Silent wrong answers, no crash.

USE_FP8_ACT was harmless but misleading: exported by all three CI scripts and
read by nothing, and documented in the demo README as a togglable "benchmark
config" for behavior that is now unconditional.

USE_FP4_ACTIVATIONS in gang_full_layer_fused_mi300.cuh selected a half-width
O-proj LDS staging area. Never defined, so the FP8 sizing always won; inlined
with a comment naming the format instead.

Verified: demo runs clean at 2.463 ms/iter (baseline 2.42-2.47), and the 512-
token perplexity dump is 97.7 with the sink self-check passing on all 240
workers -- inside the known run-to-run MXFP4 spread (81.2% argmax agreement
against the committed baseline matches the 81-83% cross-run determinism of
this kernel).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… arrival

f1fa720 ("widen QKV epoch barrier to all workers") fixed a real intermittent
deadlock but cost ~0.27ms/iter (2.19 -> 2.46). Bisected by header-only revert
onto the current tree; every other candidate (278bb7d, 4b393d8, d14b40b)
measured inside noise.

The cost is not the wait. Device timers (MPK_DEVICE_TIMING=1) put qkv_bar at
~2.5us -- blocking at Phase 2 is cheap. The actual mechanism is a lost
prefetch overlap: before f1fa720, ranks >= NUM_KV_CHUNKS skipped Phase 2
entirely and ran ahead to Phase 6, where the O-proj weight buffer_load_lds is
issued *before* the attn_release poll precisely so the DMA overlaps that
spin-wait. Widening the barrier pinned those workers at Phase 2 and serialized
their DMA behind QKV, x36 layers.

Split arrival from waiting. All workers_per_xcd still arrive -- arrival is what
carries the ordering the deadlock fix depends on, and both properties hold on
arrival alone:

  1. the bump is still gated on all arrivals, and every poller still snapshots
     before arriving, so no bump can land between a poller's snapshot and its
     arrival;
  2. a non-polling worker still cannot reach Phase 6/7b before arriving, and
     the epoch cannot bump until it does, so its attn_release / routing_ready
     "+1" snapshots still happen-before their producers.

Only ranks < NUM_KV_CHUNKS -- the workers that actually read this layer's QKV
output -- block on the epoch.

Also fixes a pre-existing build break: the O-proj/TopK call passed a _ts_base
that was declared nowhere, so MPK_DEVICE_TIMING=1 did not compile. Nothing
reads those slots (the [FUSED_PHASE] printf derives everything from
_fused_t0.._fused_t4), and the sibling task omits the argument, so it is now
nullptr. This is what unblocked the attribution above.

Measured, GPT-OSS 120B bs=1 seq 512:
  before 2.461 / after 2.383, 2.386, 2.394, 2.400 ms/iter
  seq 4096 stress: 6 runs x 4094 iters, no hang or fault
  perplexity 92.1 / 95.7 / 99.4 vs baseline 94.3 / 95.1 / 112.5 -- inside the
    baseline MXFP4 run-to-run spread, and tighter
  sink/argmax self-check: 0 index, 0 value mismatches over 240 workers
  run_ci_tests_gpt_oss.sh: pass (longest_common_block=15, need >= 8)

Recovers ~1/4 of the gap; the remaining ~0.24ms is not yet explained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fused-layer task computed its three barrier release values by
snapshotting "current counter + 1" at task entry. That is only valid if
the reader is ordered before this layer's producer, and nothing
guaranteed it: all 36 layers run in one task (the ml loop in
persistent_kernel.cuh) with only a per-block __syncthreads between them,
so workers skew freely across layer boundaries. A worker a full layer
behind could read an already-bumped counter and then wait for a bump
this layer never produces.

f1fa720 worked around this by making every worker on the XCD arrive at
the Phase 2 barrier, which supplied the missing ordering. It also cost
2.19 -> 2.46 ms/iter; 2c1071c recovered most of that by splitting
arrival from waiting.

Publish (pc_iter - 1) * ml_num_layers + ml into the free int32 of the
n_tile union member and derive all three expected values from it. All
three counters start at 0, bump once per layer, and are never reset, so
the value each reaches is a pure function of the layer index -- every
worker computes it identically with no shared read and no arrival
requirement. The Phase 2 participant set narrows back to the workers
that actually need the barrier.

This is a correctness change. Its latency effect measured neutral
(2.366-2.404 vs 2.386 before), which falsifies the Phase 6 attribution
that motivated the narrowing -- MPK_DEVICE_TIMING=1 cannot localize the
remaining ~0.2ms, because ~147k printfs inflate iterations to ~56ms and
xcd_barrier's median then moves opposite to real latency.

Verified: 12,282 iterations at seq 4096 across 3 runs, no hang or fault;
CI longest_common_block=18 (need >= 8); perplexity 97.9/100.9/98.7,
inside the 94.3-112.5 baseline spread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MPK_WS_* phase/barrier breadcrumbs were always-on, guarded only by a
runtime `g_ws_dev != nullptr` check. g_ws_dev is pinned *host* memory, so
each store is a PCIe write, and there are ~30 sites across the two
hottest task headers -- several inside spin loops, where the store sits
on the poll path.

Put them behind a compile-time flag, matching the MPK_NIL_TRIPWIRE
convention, and add the MPK_WORKER_STATE env var so the dump stays
reachable: it is how the fused-layer deadlocks were attributed.

2.386 -> 2.321 ms/iter at seq 512. The 11 inline
`precomp_dbg_worker_state != nullptr` sites in the dispatch loop were
gated the same way but measured neutral -- the compiler had already
hoisted that check.

This also explains part of a measurement I had been chasing. The 2.19
figure for "reverted f1fa720" came from restoring the whole fused header
to f1fa720~1, which predates beac230 and so stripped these breadcrumbs
too. Some of what I attributed to the Phase 2 barrier was always this.

Verified: CI longest_common_block=18 (need >= 8); MPK_WORKER_STATE=1
still compiles and runs (9.79 ms/iter, showing what the PCIe stores cost
when the buffer is live).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
db48239 fixed a real race -- chunk workers write o_acc/lse_acc partials
with ordinary stores, and nothing made them visible to the merging
worker -- but fixed it at agent scope. The barrier it guards is per-XCD
(chunk_barrier[xcd_id * 16]), so producers and consumer always share one
32MB L2, and agent scope pays for cross-XCD coherence that no consumer
here can observe.

On gfx950 (ISA verified):
  release agent  -> buffer_wbl2 sc1 ; s_waitcnt vmcnt(0)
  acquire agent  -> buffer_inv sc1
  either at workgroup scope -> no instruction at all

So the old sequence flushed the whole L2 to HBM on every chunk worker on
every layer, then had the merger invalidate that same L2 -- discarding
lines this XCD's own chunk workers had just written and forcing a re-read
from HBM. mpk_atoms.cuh already documents why that is unnecessary: "all
CUs within an XCD share the same 32MB L2 ... No buffer_wbl2 required".

Keep exactly the part that carries the guarantee:
  producer: s_waitcnt vmcnt(0), retiring all 256 threads' stores into L2
            before tid 0's release atomic
  consumer: buffer_inv (no sc1), dropping stale per-CU vL1 lines

Workgroup scope cannot substitute -- it emits nothing, and the consumer
is a different block.

2.321 -> 2.175 ms/iter at seq 512. For reference, deleting the fences
outright (incorrect) measured 2.161, so nearly all the cost was the L2
traffic rather than the ordering.

Verified in the regime where this race is actually reachable. db48239
notes the window is narrow at 512 (14 of 30 chunks exit early) and open
on every chunk of every layer at 32k:
  seq 32768: 32,766 iters, no NaN/inf, no fault
  seq 8192:  8,190 iters, coherent output, no NaN/inf
  CI: longest_common_block=18 (need >= 8)

Also drops the explicit s_waitcnt vmcnt(0) that db48239 placed before the
__syncthreads: the fence after it already drained, so it was a second
redundant stall of all 256 threads ahead of the barrier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c9d1e94 gated the MPK_WS_* call sites but left the pointer publication at
kernel entry unconditional -- a store plus a __syncthreads per worker per
launch, for a pointer nothing reads when MPK_WORKER_STATE is off. Wrap it
in the same #ifdef the tripwire publication directly above already uses.

2.175 -> 2.155 ms/iter at seq 512.

Swept the rest of the device path while here; everything else is already
gated and needs no change:
  - MPK_TW_SUB / config.tripwire sites  -> MPK_NIL_TRIPWIRE
  - MOE_DBG_ENTRY / MOE_DBG_SUBPHASE    -> MPK_NIL_TRIPWIRE
  - g_subphase_scratch timestamps       -> MPK_ENABLE_MOE_SUBPHASE, or #if 0
  - [FUSED_PHASE] printfs               -> MPK_ENABLE_DEVICE_TASK_TIMING
  - the MPK_WS_ON(config) dispatch sites -> compile to (false), body deleted
  - the host-side hipHostMalloc          -> already env-gated, nullptr default

Verified: CI longest_common_block=18 (need >= 8); MPK_WORKER_STATE=1 still
prints "worker-state tracing ON (240 workers)" and runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three prints fired on every default run and nothing parsed them:

  [FWD_PASS_CURVE]              10 lines/run
  [MPK] XCD templates uploaded   1 line/run
  [MPK] ML table validation      1 line/run

The decile curve was a scaling diagnostic that [FWD_PASS_TOTAL] and the
per-iteration [FWD_PASS] lines already cover between them; it read the
same decimated ring, so it added no information the log did not have.
The template-upload line reported a value the caller already knows.

The ML table validation loop stays -- it names the exact slot behind a
nil-address GPU fault, which has no usable backtrace otherwise, and the
per-slot ML_NULL_IN/ML_NULL_OUT prints only fire when a slot is actually
null. Only the summary is now conditional on null_in || null_out: a
healthy graph printed "0 null inputs, 0 null outputs" every run, which
trains the reader to skip the line that matters.

These all fire once at terminate or at startup, not per iteration, so
this is a log-cleanliness change and not a latency one. Measured to
confirm: 2.164 ms/iter at seq 512, unchanged from 2.155.

g_fwdpass_stride/_count, max_tpw and total_template_entries all remain
live after the removals -- no orphaned state, no unused-variable warnings.

Also .gitignore the artifacts these runs leave behind: outputs/, the
tests/standalone binaries built by build.sh, and hipcc -save-temps
intermediates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Clang format CI job checks whole files, so any file we edited had to
come out clean under the repo's .clang-format at version 15 (matching
.github/workflows/code-format.yml). Ran clang-format 15.0.7 locally --
same version the action pins -- and applied its own output verbatim.

The change is larger than the lines we wrote. clang-format's comment
alignment and argument packing are context-sensitive, so inserting a
block can pull adjacent untouched lines into a different alignment
group; 37 of the reformatted lines exist verbatim upstream and were
clean there. Nothing to do about that short of not formatting the file.

Verified no semantics changed: comparing comment-stripped, whitespace-
stripped token streams before and after, every file is token-identical
except one declaration-specifier reorder that clang-format performed
itself --

  -static volatile bool g_tw_snap_stop = false;
  +static bool volatile g_tw_snap_stop = false;

which is equivalent in C++ (decl-specifiers are order-independent).

tests/standalone/test_mfma_pipeline_hazards.hip is ours and is
reformatted here too. Because it is a regression test whose inline-asm
constraint lists got re-wrapped, it was rebuilt and rerun rather than
assumed: 19200/19200 blocks still differ for the known-broken schedule
(worst rel err 73.39%), fixed and reference still bit-exact. It still
detects the hazard it exists to detect.

The four pre-existing tests/standalone/test_ck_*.hip and
test_mfma_simple.hip files are left alone. They violate the same check
at upstream HEAD (61-90 warnings each) and predate this branch;
reformatting them here would bury this diff in unrelated churn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sangeeta0201 sangeeta0201 self-assigned this Aug 5, 2026
@sangeeta0201
sangeeta0201 merged commit 7fcc8f2 into ROCm:amd_mi355_gpt_oss120b Aug 5, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant