Skip to content

perf(hip): wide stream rounds reuse cached weights, and a chunk's code pairs are made once - #1694

Open
AbrahamCete wants to merge 7 commits into
ashhart:mainfrom
AbrahamCete:perf/hip-stream-wide-rounds
Open

AbrahamCete wants to merge 7 commits into
ashhart:mainfrom
AbrahamCete:perf/hip-stream-wide-rounds

Conversation

@AbrahamCete

@AbrahamCete AbrahamCete commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

A lane round of more than four rows ran the stream tile (zig/kernels/hip/tiles/stream.hpp) as ceil(rows / 4) row blocks on the grid's y. Each later row block read every weight from DRAM again, and each row of a block made the column's code pairs again.

The changes:

  • perf(hip): interleave a stream tile's row blocks so wide rounds reuse cached weights
    • The row blocks of one column block now run next to each other on the grid's x, so the later ones find its weights in cache.
    • launches/stream_layout.zig is the one place that lays a round's row blocks on the grid, and StreamSides moves there with it. Kernels.stream_interleave = false switches back to the reference layout on y, which the tests compare against.
    • Rounds of 1 to 4 rows are one row block and launch as before.
  • perf(hip): stream tile makes a chunk's code pairs once and takes 4-bit BF16 pairs from bytes
    • Each column's 16 code pairs are made once a chunk. Every row of the block then dots them in turn, in the order a row alone takes.
    • On the BF16 build a 4-bit word becomes two byte-per-code words, v_cvt_f32_ubyteN makes each code's float in one op, and v_perm_b32 packs the top halves. The 4x4 tile's inner loop drops from 1149 to 852 instructions on gfx1151.
  • Each row's arithmetic and summation order are unchanged, so a row writes the same bytes alone and in a round.
  • Tried and dropped: naming the layout in a StreamSides field read by stream_tile. On gfx1151 it doubled the stream kernels' VGPRs (4x4: 140 -> 256) and cost about 10% on wide verify rounds.

This is the stream-tile work from question 4 of #1563, proposed on its own because it does not depend on DFlash2.

Overlap with open pull requests (atc overlap):

Receipt

  • Environment: Ryzen AI Max+ 395 (gfx1151), Ubuntu 26.04, Zig 0.17.0, Ubuntu clang 21 hipcc (ROCm 7.13 from TheRock for the contract limits), checkpoints Qwen3.8-27B-MLX-4bit and Qwen3.6-35B-A3B-MLX-4bit-MTP.

  • Exactness:

    • New GPU test kernel_tests/affine/layout.zig. Plain, relu2, SwiGLU pair, top-2 routed, routed with an item shorter than the launch's rows, and grouped products run at rounds of 5 to 32 rows in both layouts, and it requires equal bytes. A planted swap of the column and row block indices fails it.
    • New GPU test kernel_tests/affine/golden.zig. Plain, gate|up pair and top-2 routed launches of 1 to 16 rows must hash to the digest the stream tile wrote when its code pairs came from stream_pair, before the second change. The digest is recorded per architecture, gfx1151 for now. On another chip the test prints the digest it computed and skips, so a run there can report it.
    • Served bytes: token_sha is the same before and after in every run. 27B: code 2b28aa2108de, chat a06fab348fbe. 35B-A3B MTP: code 9f2923ac14d0, chat a8c28b8297b4.
    • tools/bench_concurrent.py --alone --serial: all 48 cells of the speed runs below, over both checkpoints, report alone and serial equal, and 0 failed.
    • tf-qwen35-test check: 27B --only invariants 42/0/5 and 35B-A3B 46/0/1, the same as main.
  • Speed, Qwen3.8-27B. Server --context 4096 --parallel 16 --prompt-cache-gib 0, runs alternated B1 C1 B2 C2. bench_concurrent --tokens 256 --reps 3 --temperatures 0 --levels 1,4,8,16 --alone --serial, aggregate tok/s:

    streams code main code branch change chat main chat branch change
    1 13.2, 13.3 13.7, 13.7 +3.4% 13.6, 13.6 14.0, 14.1 +3.3%
    4 43.7, 44.0 45.8, 45.7 +4.3% 43.2, 43.4 45.4, 45.4 +4.8%
    8 47.6, 47.6 62.0, 62.3 +30.6% 46.4, 46.4 60.2, 60.0 +29.5%
    16 50.8, 50.9 74.8, 75.0 +47.3% 49.1, 49.3 71.7, 71.7 +45.7%
    • tools/bench_openai.py --tokens 256 --reps 3 --temperatures 0, median tok/s: fibonacci-raw 13.30, 13.22 before and 13.72, 13.73 after; gpu-chat-no-think 13.62, 13.60 before and 14.04, 13.98 after.
  • Speed, Qwen3.6-35B-A3B MTP. Server --parallel 4, same alternation, --levels 1,4.

    • One stream: code 112.7, 112.5 -> 120.5, 120.5 (+7.1%); chat 83.2, 76.6 -> 88.6, 89.0 (+11%). bench_openai: fibonacci-raw 112.6, 112.2 -> 120.4, 120.2; gpu-chat-no-think 83.2, 76.6 -> 86.7, 88.2.
    • Four streams: slower in code on average, the same in chat, and both sides are bimodal. Over 11 servers on main and 12 on the branch (--levels 4, 3 or 5 reps each):
      • code: mean 225.0 -> 214.8 (-4.5%), median 227.0 -> 235.1 (+3.6%);
      • chat: mean 191.3 -> 191.4;
      • servers whose code median fell under 215 tok/s: 1 of 11 on main, 6 of 12 on the branch.
    • The slow mode is the lane core drafting deeper with lower acceptance. In the logs of one pair of servers, a fast request took 115 rounds with 140 of 177-184 drafts accepted. A slow one took 86-106 rounds with 150-169 of 236-262 accepted, and 5.0-5.6 s instead of 4.4-4.5 s.
    • The bytes are the same in both modes, and a mode usually holds for a whole server process. main falls into it too, and two local experiments, neither part of this PR, did not remove it:
      • with the lanes' wall-clock feedback off (Config.timed = false), each process stays in one mode;
      • with allocate_shared_drafts on in HIP's lane facts, it gets more frequent on both sides, with 10 of 15 code reps slow.
    • So the kernel is faster in either mode, but its cheaper wide rounds tip the lane core's depth choice into the slow mode more often. I'll open a separate issue with the data. I'd rather not tune the lane core's pricing in a kernel PR, but I can if you prefer.
  • Kernel contract:

    • main: SUMMARY gfx1151 49 kernels: 49 pass gates 1-4, 0 fail, 0 known findings; 5 controls: 5 red as planted, 0 missed; 126 limit problems.
    • Branch: the same with 129 limit problems. The change moves the VGPR count of all 112 stream-tile launches against hip_records_stream.zig. 109 of them already differed from their records on main with this toolchain; the other three (plain 5-bit 2x8, relu2 pair 4-bit 2x8, SwiGLU 8-bit 4x2) matched and now differ. None is a limit violation: 128 threads, as at run time, and no scratch.
    • With ROCm 7.13 hipcc: 220 limit problems on main and 473 on the branch. The 253 new ones are the stream-tile launches, about 111 a chip on gfx1030, gfx1100, gfx1151 and gfx1201, now reported record stale.
    • Toolchain: hipcc with HIP 7.1.52801 and Ubuntu clang 21.1.8 for the gates above. Following the answer on Proposal: DFlash2 on HIP for Qwen3.8-27B (gfx1151: 2.0-2.5x one stream, same tokens) #1563, I left every existing record untouched. This PR adds no kernel symbol, so it needs no new record. Re-deriving the records with your pinned hipcc will pick up the new stream-tile counts. cc @BlivionIaG, who recorded these launches.
  • Tests run:

    • hip-kernel-test (binary run directly): 109 passed, 0 failed, 0 skipped (main has 106; the new ones are affine.layout, affine.golden and the grid test in launches/stream_layout.zig). On the rebased tip: 110 passed, 1 skipped, 0 failed; the skip is main's Flash-Next MTP wide-norm test.
    • test-qwen35-hip-host, hip-host-test, check-native-hip-host: exit 0.
    • tools/zig/lean_check.py: 425 problems on main and on this branch, none new.
    • zig fmt --check clean on the changed Zig files.
  • Not run, and why:

    • gfx1100, RDNA2 and gfx1201: I have no card. The layout change is the same on every chip. The byte path for code pairs is on the 4-bit BF16 build only; other widths and the FP16 build keep stream_pair, now once a chunk.
    • Other HIP families' checkpoints: I have none locally. Their stream launches go through the same tile, and the layout and golden tests cover plain, pair, routed and grouped products.
    • Metal and CUDA: untouched.

Checklist

  • 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. (Bytes unchanged: the layout and golden tests, served token_sha, and bench_concurrent alone and serial equal; tf-qwen35-test check matches main.)
  • No precision traded for speed. If the bits change, the description says which sums change and why. (The bits do not change.)
  • A new or changed GPU kernel is registered with the kernel contract (docs/kernel-contract.md); each backend's SUMMARY line is in the receipt. (No new symbol; the changed launches' register counts differ from their records, as above.)
  • Prompt processing is no slower than the last release. (The prompt path is untouched.)
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not. (gfx1151 only.)
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. (Both new tests pin bytes the change must keep rather than a behaviour it adds; the layout test fails on a planted swap of the column and row block indices.)
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines.

@AbrahamCete

Copy link
Copy Markdown
Contributor Author

/atc exchange a4436cc5af37c0bc75f72d0c8ffe6b6378e346fad48e70287735aae0dd0fa826

… cached weights

A lane round of more than four rows ran the stream tile as ceil(rows / 4) row
blocks on the grid's y, so each later row block read every weight from DRAM
again. The row blocks of one column block now run next to each other on x and
the later ones find its weights in cache. Each row's arithmetic is unchanged,
so a row still writes the same bytes alone and in a round.

On gfx1151 (Radeon 8060S), Qwen3.8-27B MLX 4-bit, alternated A/B against main:
verify of 8 rows 154 -> 124 ms, 16 rows 283 -> 208 ms; 1 to 4 rows and plain
decode unchanged. Qwen3.6-35B-A3B MLX 4-bit MTP community-v1 unchanged.
…nce layout

The interleave was an implicit contract between the stream launch and
stream_tile. stream_layout.zig is now the one place that lays a round's row
blocks on the grid (StreamSides moves there with it), Kernels.stream_interleave
switches back to the reference layout on y, and a GPU test runs plain,
relu2, swiglu pair, routed and grouped products at rounds of 5 to 32 rows in
both layouts and requires equal bytes. A planted swap of the column and row
block indices fails it.

Naming the layout in a StreamSides field read by stream_tile was tried and
dropped: on gfx1151 it doubled the stream kernels' VGPRs (4x4: 140 -> 256)
and cost about 10% on wide verify rounds.
…erleave

Each row goes to both experts (x_div 2), so every expert holds the whole
round and the routed case reaches the interleaved grid from 5 rows; each
round now asserts it spans more than one row block.
…t BF16 pairs from bytes

Each column's 16 code pairs are made once a chunk, then every row of the
block dots them in turn, in the order a row alone takes, so the bytes stay.
On the BF16 build a 4-bit word becomes two byte-per-code words and
v_cvt_f32_ubyteN makes each code's float in one op; v_perm_b32 packs the
top halves. The 4x4 tile's inner loop drops from 1149 to 852 instructions
on gfx1151.

gfx1151, A/B alternated twice of the previous commit's build against the same
stream.hpp change on a local experiment build (1, 2 and 4 rows a block):
Qwen3.8-27B verify 9-16 rows 204 -> 179 ms, decode 13.2 -> 13.6 tok/s;
Qwen3.6-35B-A3B decode 71.4 -> 76.1 tok/s, MTP 95.9 -> 101.9 tok/s.
…tream_pair made

Plain, gate|up pair and top-2 routed launches of 1 to 16 rows hash to the
digest the stream tile wrote before the previous commit, when its code pairs
came from stream_pair.
…'s rows

The second expert takes only the first half of the rows, so its row blocks past them return early in both the interleaved and the reference layout.
The golden test held one digest for every chip with BF16 activations. Its
bytes also depend on each chip's BF16 dot and activation rounding, so another
architecture could fail it with a correct byte path. The digest is now
recorded per architecture, with gfx1151's as the first entry. On an
architecture without one, the test prints the digest it computed and skips.
@AbrahamCete
AbrahamCete force-pushed the perf/hip-stream-wide-rounds branch from 675c33b to f69c292 Compare October 31, 2026 21:48
@AbrahamCete

Copy link
Copy Markdown
Contributor Author

/atc exchange 64237b5932fbc98e1afc246183edcbe674e9b656736e7d9077294b9084dfa330

@ashhart

ashhart commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Thanks @AbrahamCete, the 27B's +30% to +47% at 8 and 16 streams with the same bytes is a real win, and the layout and golden tests are what we'd want. One thing blocks landing: on the 35B-A3B at 4 streams, code's mean falls 4.5%, because the cheaper wide rounds tip the lane core into its deeper, lower-acceptance mode more often (6 of 12 servers against 1 of 11). We can't land a default that's slower on average for a served model. Please open the issue you mention with the data. Then either a fix where the lane core prices the cheaper rounds so it doesn't choose the slow depth, measured on the 35B-A3B at 4 streams, or a way to keep 4-stream MoE rounds at main's speed. Happy for the pricing fix to be a separate PR that this one waits on. We'll re-derive the stream-tile records with our pinned hipcc when it lands.

@AbrahamCete

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. The issue with the data is #1695: the bench_concurrent JSON and server log of all 32 Qwen3.6-35B-A3B runs behind the numbers here, and the scripts that ran them, are in the gist linked there.

The fix there is the pricing change you describe. HIP timed its shared rounds at one stream and scaled totals past 16 rows linearly, so the cheaper wide rounds made deeper drafts look cheap. The change times them at the served stream count and at the row buckets real rounds pad to. A preliminary run on main 94b90c9 gave 0 of 6 servers in the slow mode against 3 of 6, and +7.6% in code at 4 streams, with tokens equal.

I'm measuring it now on current main, with and without this PR, over the same number of servers. Then I'll open it as a separate PR that this one waits on.

@AbrahamCete

Copy link
Copy Markdown
Contributor Author

Thanks for landing #1830, @ashhart. GitHub closed this one through #1830, because #1830's body says "the pricing fix #1694 waits on" and GitHub reads "fix #1694" as a closing keyword. That was my wording, sorry. This PR was waiting on #1830, so could you reopen it? I'll then rebase it onto main and post the Qwen3.6-35B-A3B numbers at 4 streams with #1830 in, alongside the 27B results at 8 and 16 streams. An earlier run of this PR with the pricing fix on 372af1e gave a code mean of 241.2 tok/s across 6 servers, against 222.4 on main, with tokens equal.

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Reopening: GitHub closed this by mistake through #1830's wording, as you pointed out. Sorry about that.

@ashhart ashhart reopened this Nov 3, 2026
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