Skip to content

perf(hip): the decode tail issues a row's loads before its branches - #2247

Merged
ashhart merged 5 commits into
ashhart:mainfrom
AbrahamCete:perf/hip-decode-tail-loads
Nov 6, 2026
Merged

ashhart merged 5 commits into
ashhart:mainfrom
AbrahamCete:perf/hip-decode-tail-loads

Conversation

@AbrahamCete

@AbrahamCete AbrahamCete commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

tf_tail (zig/kernels/hip/decode/decode.hip) is the decode round's residual add and RMS norm in one launch, and after a MoE layer it also sums the slots' fp32 rows. It chose fp16 or bf16 and checked the row's end at every load, so each load sat in its own basic block and waited before the next one issued. A MoE tail also waited once per slot.

The tail now runs as template instances, one per activation kind and one each with and without slots. A thread loads four elements at once, with any index past the row clamped to the row's last element; only the stores and the sum of squares check the row's end. A MoE tail loads eight slots' rows before it sums them. On gfx1151 the kernel takes half the time and the served gain is small (receipt below).

Each element's slots are summed in slot order with the same roundings, and the sum of squares and the block reduction run in the same order. So every output byte is unchanged for inputs without NaN. One exception: with the kind fixed at compile time, the fp16 instance adds in fp16. It gives the same values, but an fp16 NaN may come out with another sign or payload. Kind 1 (fp16) is served only on gfx1030, and only NaN activations reach this.

Two test changes come with it:

Checks on this head

  • Head commit: 1cbe0df, on main 0311c92. It merges cleanly onto current main.
  • Lean checker, python3 tools/zig/lean_check.py: 425 on the base and 425 on this head, 0 new (tools/zig/lean_new.py).
  • Tests run, with the command and pass, fail and skip counts:
    • zig build hip-kernel-test --summary all: 136/140 passed, 4 skipped, 0 failed.
      • tail_pin holds the digest main's kernel writes, and the branch's kernel writes the same one.
      • A planted change that sums the slot groups last-first fails it, as does one that pairs an element with the next element's norm weight.
    • zig build test -Dhip=true --summary all in a clean git archive tree: 587/587 steps, 7446/7508 tests passed, 62 skipped.
    • zig build test-golden --summary all: 5/5 steps.
    • tf-qwen35-test check --only invariants: 42/0/5 on the 27B, 46/0/1 on the 35B-A3B.
    • zig fmt --check and git diff --check: clean.
  • CI: green on 1cbe0df, all 7 checks (zig, zig-windows, zig-macos-dev-tools, fmt, release-paths, and benchmark-clients on Ubuntu and Windows).
  • Not run, and why:
    • gfx1030, gfx1100 and gfx1201, because I have no card. The offline limits below check their records.
    • The fp16 instance runs on gfx1151 in tail_pin, which passes kind 1 to the launcher.

Receipt

  • Environment:
    • Machine: Ryzen AI Max+ 395 (gfx1151), Ubuntu 26.04, Zig 0.17.0.
    • Builds and served runs: hipcc with HIP 7.1.52801 and Ubuntu clang 21.1.8.
    • Contract and its limits: ROCm 10.1's hipcc (HIP 7.16.26385).
    • Traces: rocprofv3 from ROCm 7.13.
    • Checkpoints: Qwen3.8-27B-MLX-4bit and Qwen3.6-35B-A3B-MLX-4bit-MTP.
  • Paths and platforms the change reaches:
    • tf_tail is launched only by the qwen3_5 family's HIP lane rounds (forward/window.zig): addRms after attention and moeTail after a MoE layer. So it reaches the decode and verify rounds of that family.
    • The prompt path uses tf_add_rms and is untouched. Metal and CUDA are untouched.
    • I ran gfx1151 only.
  • Exactness:
    • 64-step decode logits are byte-identical to main's on both checkpoints, on the rebased head against main 0311c92.
    • Served token_sha is equal in every cell below: 27B code 2b28aa2108de, chat a06fab348fbe; 35B-A3B code 9f2923ac14d0, chat a8c28b8297b4.
    • bench_concurrent --alone --serial: alone and serial equal in every cell, 0 failed.
    • A differential probe (not committed) ran main's kernel and this one on 4032 seeded launches. It covered kinds 1 and 2, slots 0 to 17, widths 1 to 8192, 1 to 16 rows, and denormals, infinities and NaN. Every non-NaN byte is equal; the only differences are fp16 NaN signs and payloads, as above.
  • Decode speed before and after:
    • tf_tail under rocprofv3, tf-qwen35-test logits --decode, 64 steps:

      main this PR
      35B-A3B 33.48 ms (4.00% of kernel time), mean 6.64 us 16.52 ms (2.01%), mean 3.28 us
      27B 95.51 ms (2.01%), mean 11.84 us 50.48 ms (1.07%), mean 6.26 us

      On the 35B-A3B, two alternated rounds put the tail after attention at 4.68 -> 2.78 us and the MoE tail (9 slots) at 8.57 -> 3.73 us.

    • Decode tok/s, tf-qwen35-test check --only speed, which times the last of three runs: one stream generating 256 tokens, and four streams generating 128 each. Main 0311c92 then this head, built from clean trees and alternated B1 C1 B2 C2:

      1 stream 4 streams
      35B-A3B, drafts off 80.8, 81.0 -> 82.9, 83.3 (+2.7%) 150.3, 149.9 -> 152.6, 152.1 (+1.5%)
      35B-A3B, MTP drafts on 104.9, 105.2 -> 109.9, 107.1 (+3.3%) 142.8, 141.3 -> 143.8, 145.8 (+1.9%)
      27B 14.2, 14.1 -> 14.3, 14.3 (+1.1%) 43.8, 43.8 -> 44.1, 44.1 (+0.7%)

      A 35B-A3B decode round at one stream drops from 12.4 to 12.1 ms, in line with the kernel timing above. The 27B checkpoint has no draft head, so with drafts on it still decodes one token a round and reads within 0.3 tok/s of these.

    • Served: one fresh server a run, --parallel 4 --context 4096 --prompt-cache-gib 0, alternated B1 C1 B2 C2, bench_concurrent --tokens 256 --reps 3 --temperatures 0 --levels 1,4 --alone --serial. Aggregate tok/s, main then this PR. These ran on main 249e33b before the rebase; the kernel is the same commit's.

      x1 x4
      35B-A3B code 116.6, 117.1 -> 116.8, 117.9 (+0.4%) 237.0, 235.0 -> 239.6, 236.7 (+0.6%)
      35B-A3B chat 94.6, 95.1 -> 96.8, 97.0 (+2.2%) 199.5, 194.3 -> 196.8, 197.9 (+0.2%)
      27B code 14.0, 14.0 -> 14.1, 14.1 (+0.7%) 44.3, 44.1 -> 44.5, 44.2 (+0.3%)
      27B chat 14.3, 14.3 -> 14.4, 14.4 (+0.7%) 43.6, 43.6 -> 44.1, 44.0 (+1.0%)
  • Prompt speed before and after: n/a. The prompt path uses tf_add_rms, not tf_tail. The same check --only speed runs give the same prefill tok/s at 13, 2048 and 8192 tokens on both builds, within run-to-run noise.
  • Licence of any new weights or drafter: n/a.
Kernel contract, only when a GPU kernel is new or changed
  • Kernels registered with the contract, and the kernel each one replaces:
    • tf_tail, in two modes: a row of the activation kind, and nine fp32 slots (zig/tests/hip/contract_tail.zig).
    • It is not bit-equal to the launches it merges (tf_add and tf_rms reduce in another order), so it states no replaces and meets the float64 gates on its own.
    • Its gate-5 record is in hip_records_tail.zig, filled from ROCm 10.1's hip-kernel-contract-limits RECORD line.
    • Main's kernel passes the same registration's gates 1-4.
  • A planted control for each new kernel, and each one fails as planted: it adds no control of its own, as contract_add_rms.zig does not. The harness's 11 controls go red as planted.
  • Each backend's SUMMARY line and exit status:
    • hip-kernel-contract on gfx1151: SUMMARY gfx1151 95 kernels: 95 pass gates 1-4, 0 fail, 2 known findings; 11 controls: 11 red as planted, 0 missed; 6 limit problems (exit 1 for the limit problems). Main 0311c92 reads SUMMARY gfx1151 93 kernels: 93 pass gates 1-4, 0 fail, 2 known findings; 11 controls: 11 red as planted, 0 missed; 6 limit problems.
    • The tail's lines: shapes within the float64 bound at worst 0.655 (plain) and 0.429 (slots) of it, five repeats byte-identical, non-finite classes as float64, and sums within bound where fp16 sums would miss by 85x and 51x.
    • hip-kernel-contract-limits: FAIL kernel-contract-limits: 22 problems, the same 22 as main. tf_tail reads record current on gfx1030, gfx1100, gfx1151 and gfx1201: 125 VGPRs on gfx1030 and 120 on the others, 58 SGPRs, 32,832 LDS bytes.
  • Chips I could not run: gfx1030, gfx1100 and gfx1201, whose records come from the offline limits.

Checklist

  • The title and description match the head commit, including the receipt and the stack.

  • New tests fail before the change, pass after it, and skip cleanly without their dependency. (tail_pin pins bytes the change must keep. Planted changes to the slot order and to the norm-weight pairing fail it, and it skips with the digest printed on a chip without one.)

  • Comments and docstrings are one line. No measurements or history in the source.

  • No personal data, machine names, internal hosts or local paths in the diff or its commits. No AI attribution lines.

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.

  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (The bytes are unchanged: decode logits, served token_sha, alone and serial equal.)

  • No precision traded for speed. If the bits change, the description says which sums change and why. (No sum changes. The only bits that can change are an fp16 NaN's sign or payload, as above.)

  • Decode and prompt processing are no slower than main at one stream and the widest admitted; with --drafter, no slower than without it, greedy and sampled. (Served cells above, with the 35B-A3B's MTP drafts on; the prompt path is untouched.)

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @AbrahamCete. Pinning main's tail bytes before changing the kernel, and planting two wrong orders against it, makes byte-equal easy to trust, and registering tf_tail with the contract closes a gap. Ready to land after the cut. If the description doesn't already have them, please add main-against-branch decode tok/s for the 27B and the 35B-A3B beside the kernel timing.

@AbrahamCete

Copy link
Copy Markdown
Contributor Author

@ashhart, done: the receipt now has main-against-branch decode tok/s from tf-qwen35-test check --only speed, right under the kernel timing (main 0311c92 against this head, alternated twice). At one stream the 35B-A3B goes from 80.9 to 83.1 tok/s (+2.7%) and the 27B from 14.15 to 14.3 (+1.1%). At four streams the gains are +1.5% and +0.7%. Prefill is unchanged. The head is still 1cbe0df.

@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @AbrahamCete, the decode numbers are what we wanted beside the kernel timing. Ready, queued for after the release cut.

@ashhart
ashhart force-pushed the perf/hip-decode-tail-loads branch from 1cbe0df to d38c100 Compare November 6, 2026 06:25
@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

@AbrahamCete, #2247 conflicted with tonight's main in zig/src/core/contract/hip_records_families.zig. The joint schema's 10 HIP records landed in the meantime, so the counts both sides changed moved. I rebased it onto main and set them to main's plus your one tail record: 193 records in all, and 171 outside all. zig build test-kernel-contract passes, 198 of 198. Your commits are otherwise unchanged and keep your authorship. It's back in the landing queue.

@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

/atc exchange 34d76bc8277b45b07c725da75e6d4b83841c17084e635a6344c638b6e95e7141

AbrahamCete and others added 5 commits November 6, 2026 16:46
The tail's test bounds its error against float64, so a change in its sum order or roundings could pass it. The new
test hashes tf_tail's residual and norm in fp16 and bf16 over plain and slot-summed rows, a verify window, ragged,
unweighted and the widest rows, and cases whose slots cancel, with three and with nine slots, so that the order of the
slot sum shows; gfx1151's digest is pinned.
tf_tail chose fp16 or bf16 and checked the row's end at every load, so each load sat in its own basic block and
waited before the next one issued; a MoE tail also waited once a slot. The tail now runs at one activation kind and
with or without slots as template instances, a thread loads four elements at once with the indices past the row
clamped to its last, and a MoE tail loads eight slots' rows before it sums them. Every element's slots are summed in
slot order with the same roundings and the sum of squares is taken in the same order, so every output byte is
unchanged for inputs without NaN. With the kind fixed, the fp16 instance adds in fp16: the same values, but an fp16
NaN may come out with another sign or payload.
tf_tail, the decode tail that adds the residual (a row of the activation
kind, or a MoE layer's slots' fp32 rows times their weights, summed in slot
order) and writes the RMS norm after it, had no contract entry or gate-5
record. CONTRIBUTING requires one for a changed kernel, and this branch
changes how it loads its rows.

contract_tail.zig registers both forms, plain and nine slots, against a
float64 reference at the served widths (Qwen3.6-35B-A3B's 2,048, the 27B's
5,120 and the widest row of 8,192), decode rows and windows, and the ragged
edges of a pass over the row. It is not bit-equal to the launches it merges
(their sums reduce in another order), so it declares no replacement and
meets the float64 bound on its own; its non-finite plants and gate 4 follow
tf_add_rms's. Gate 5 records its usage from ROCm 10.1's hipcc on gfx1030,
gfx1100, gfx1151 and gfx1201, and the coverage list names it.
@ashhart
ashhart force-pushed the perf/hip-decode-tail-loads branch from d38c100 to 88b720b Compare November 6, 2026 16:46
@ashhart
ashhart merged commit fa4ea09 into ashhart:main Nov 6, 2026
7 checks passed
@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thank you @AbrahamCete. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes.

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.

2 participants