Skip to content

hip flashnext: a lone stream chains up to seven MTP drafts when asked - #3053

Open
gilby wants to merge 20 commits into
ashhart:mainfrom
gilby:strix/hip-fn-deep-chain
Open

gilby wants to merge 20 commits into
ashhart:mainfrom
gilby:strix/hip-fn-deep-chain

Conversation

@gilby

@gilby gilby commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

With the eight-row verify window (#2730), a Flash Next stream on HIP that has a round to itself has room for seven drafts, but the MTP head stops at three. --mtp-drafts N now takes up to seven on Flash Next. A stream alone in a round chains up to N head drafts, and rounds shared by several streams keep at most three a stream. Without the flag the head drafts three as before, and replies, rounds and draft counts are the same as the base's.

Checks on this head

  • Head commit: 0dde4b661. The device receipt is on ed04933ce. Since then there are three commits: one docs-only commit, a merge of main that changes nothing under core/lanes, families/flashnext/hip or hip, and a test move. This is a43e12597, the first posted head, plus:
    • 4edc31fd0: the shared cap in every lane-core path;
    • de94f186c: --mtp-drafts above 3 refused with --tp above 1;
    • 7365c00f7: the changelog line and the recipe;
    • c2456fb8d: a merge of main ab78aef90. CHANGELOG.md and core/lanes/depth.zig conflicted. The changelog keeps both sides' lines, this PR's first. In depth.zig, main's one-line plain-streams check and this PR's cap are both kept unchanged; the deep-chain test's import moves from depth.zig's test block to lanes.zig's, so depth.zig stays at 600 lines;
    • ed04933ce: the MTP draft cap's test moves from flashnext/hip/lanes.zig to flashnext/hip/mtp_drafts_test.zig, because main's plan-path rounds brought lanes.zig to 604 lines in the merge;
    • fa6f49385: the recipe names sampled chat among the replies where seven drafts ran slower;
    • f8b2f8f22: a merge of main aaac299d1. Only CHANGELOG.md conflicted, and it keeps both sides' lines, this PR's first;
    • 0dde4b661: the --mtp-drafts refusal's test moves, unchanged, from native/hip.zig to native/mtp_drafts_hip_test.zig, and checkGroup becomes pub so the test can reach it. Main's new .ocr = offer.ocr, line had brought hip.zig to 601 lines.
  • Lean checker: 341 problems on main aaac299d1 and 341 on this head, 0 new. python3 tools/pr_check.py --base origin/main --pr 3053: 0 errors, 0 warnings. zig fmt and git diff --check are clean. Every touched file is at most 600 lines.
  • Tests:
    • zig build test -Dhip=true --summary all: 898 of 901 steps succeeded, 14,282 of 14,407 tests passed, 122 skipped, 3 failed. Main aaac299d1 on the same Mac: 898 of 901 steps, 14,264 of 14,389 passed, 122 skipped, and the same 3 failures: dashboard_test "dashboard page polls the public stats snapshot without private details" and the two hip.tables gather tests ("the parked pool's parts copy the same bytes as one thread for every id count" and "a cold call wakes the pool and the same ids gathered again stay on the caller").
    • On the GPU, zig build hip-kernel-test on the receipt head 8b1c81720: 20 of 20 steps, 159 of 165 passed and 6 skipped, the same as the base. This PR adds no kernel and no GPU test.
  • New host tests. Each one goes red when the line it tests is reverted:
    • A lone stream asks for seven on a high acceptance and one on a low one.
    • Shared streams stay at three.
    • A zero cap keeps the old ladder.
    • Under a confidence floor, node probabilities and granted depths, two streams in a shared round get 3 each with the cap and 7 without it, while a lone stream gets 7.
    • Allocated shared rows, granted or not, reserve 3 a stream with the cap and 7 without it.
    • A stream that opens beside a live one takes 3 first drafts with the cap and 7 without it.
    • The facts report 3 drafts by default, and 7 with a shared cap of 3 when asked.
    • Flash Next accepts 7 and refuses 8 at --tp 1, and accepts 3 and refuses 4 at --tp 2 with the message naming both flags. A qwen3_5 family accepts 3 and refuses 4 with main's text at both.
    • A family takes three unless it declares mtp_max_drafts, and three above one rank.
  • CI: I'll post the results for 0dde4b661 when the run finishes.
  • Not run:
    • other HIP chips (no card);
    • --tp above 1 (no second box with a link), which is why more than three drafts are refused there;
    • Metal and CUDA (the change reaches HIP Flash Next only).

Receipt

One Strix Halo iGPU (gfx1151). The table is from five fresh servers with the page cache dropped before each, in the order base, this with --mtp-drafts 7, this with --mtp-drafts 7, base, this with the flag unset. The base is #2730's head 2e51ea822, and "this" is the receipt head 8b1c81720. Tools: bench_concurrent --levels 1,2,4 --reps 3 --alone --serial --temperatures 1.0,0 --prompts tools/bench_prompts.json and bench_openai --tokens 256 --reps 3. Cells give aggregate tok/s with tokens a round in brackets, both arms shown.

cell base this, --mtp-drafts 7 this, flag unset 7 against base
1 stream, greedy 51.8 / 51.8 (3.28) 59.1 / 59.2 (4.57) 51.8 (3.28) +14.2%
1 stream, sampled 45.1 / 45.1 (2.64) 47.0 / 47.1 (2.94) 45.1 (2.64) +4.3%
2 streams, greedy 67.4 / 66.5 (3.48) 66.4 / 66.5 (3.48) 67.5 (3.48) level
2 streams, sampled 59.5 / 59.5 (3.10) 59.8 / 59.4 (3.10) 60.3 (3.10) level
4 streams, greedy 66.9 / 67.0 (3.11) 66.9 / 67.0 (3.11) 67.0 (3.11) level
4 streams, sampled 64.2 / 64.3 (3.00) 64.4 / 64.3 (3.00) 64.4 (3.00) level
bench_openai code 46.24 / 46.25 (2.61) 46.24 / 46.28 (2.94) 46.27 (2.61) level
bench_openai chat 40.08 / 40.08 (2.03) 41.17 / 41.21 (2.21) 40.11 (2.03) +2.8%
edit workload, 1 stream, greedy 74.1 / 74.1 (6.10) 73.5 / 73.5 (6.40) 74.2 (6.10) −0.8%
edit workload, 1 stream, sampled 72.2 / 72.2 (5.89) 71.6 / 71.6 (6.24) 72.2 (5.89) −0.8%
cold, 4 streams, greedy 52.9 / 53.1 53.4 / 53.4 53.8 level
cold, 4 streams, sampled 39.7 / 39.8 40.2 / 39.1 39.9 level
  • Two cells are slower with --mtp-drafts 7: the edit workload at one stream, 0.8% at both temperatures (73.5 against 74.1 and 71.6 against 72.2). The arms repeat exactly, so I read it as real. In that workload the copy drafts already fill most of the window, and the deeper head chain adds more draft time than the extra 0.3 tokens a round return. The recipe now says so, so users know when to pick a lower N. The code prompt is level although its tokens a round rise from 2.61 to 2.94. The flag is opt-in and the default is unchanged, so no served cell moves unless the flag is passed.
  • Two and four streams are level: tokens a round are the base's in every cell, since a shared round keeps three drafts a stream. The two-stream greedy arms read 67.4 and 66.5 on the base itself.
  • Flag unset: every cell's tokens a round equal the base's. The server logs' accepted and drafted totals are the same in both base arms and the unset arm (11,747 of 15,697). With seven drafts they are 12,046 of 16,696.
  • Tokens. Every request's token_sha256 equals the base's in all four other arms: 42 of 42 warm, 8 of 8 on a cold four-stream start and 4 of 4 on the edit workload, greedy and sampled. In every cell of every arm, concurrent equals alone and alone equals "draft": false, with 0 unequal and 0 failed.
  • Raw files: the tools' JSON for all five arms is at https://gist.github.com/gilby/44395718aff9c44e155f2e718f5a95e1.
  • Cold 8K first token (bench_cache --require-cold): 12.02 / 11.99 s on the base, against 12.04 / 12.06 s with seven drafts and 12.10 s unset. Repeat: 9.55 / 9.53 against 9.53 / 9.55 and 9.56 s. The reply hash is equal in all five arms.
  • The start-up line reads mtp drafts 3 under 0.3 on the base and unset, and mtp drafts 7 under 0.3 with the flag.

Merged head (ed04933ce) against main (ab78aef90). One Strix Halo iGPU (gfx1151), both built from fresh trees at the same main commit. Six fresh servers, each with the page cache dropped before it, in this order: main, this, this, main (all with the flag unset), then this twice with --mtp-drafts 7. Each server ran two tools:

  • bench_concurrent --levels 1,2,4 --reps 3 --alone --serial --temperatures 1.0,0 --prompts tools/bench_prompts.json;
  • bench_openai --tokens 256 --reps 3, covering code and chat at temperatures 1.0 and 0.

The driver waited until no other process used more than 20% of a core, checked that again every 7 s during each benchmark window (model load excluded), and recorded uptime and the busiest processes before and after each arm. No arm was disturbed.

bench_concurrent, aggregate tok/s, median of three reps (the reps in brackets), tokens a round after the slash:

cell main (1) this (1) this 1 against main 1 this (2) main (2) this 2 against main 2 main spread, 2 against 1 this spread, 2 against 1 --mtp-drafts 7, this, two arms
1 stream, sampled 46.3 [46.3, 46.3, 46.3] / 2.59 46.2 [46.2, 46.2, 46.2] / 2.64 −0.2% 46.2 [46.2, 46.2, 46.2] 46.3 [46.3, 46.3, 46.3] −0.2% 0.0% 0.0% 48.6 / 48.6 (2.94)
2 streams, sampled 62.5 [60.3, 62.5, 62.6] / 3.08 61.6 [60.2, 61.6, 62.5] / 3.10 −1.4% 62.5 [60.2, 62.5, 62.6] 61.7 [60.2, 62.5, 61.7] +1.3% −1.3% +1.5% 61.6 / 61.6 (3.10)
4 streams, sampled 66.8 [63.4, 66.9, 66.8] / 3.00 66.8 [59.7, 66.8, 66.8] / 3.00 0.0% 66.7 [58.9, 66.7, 67.2] 66.8 [63.2, 66.8, 66.8] −0.1% 0.0% −0.1% 66.7 / 66.7 (3.00)
1 stream, greedy 52.8 [52.8, 52.8, 52.8] / 3.16 53.1 [53.1, 53.1, 53.2] / 3.28 +0.6% 53.2 [53.2, 53.2, 53.2] 52.8 [52.8, 52.8, 52.8] +0.8% 0.0% +0.2% 61.4 / 61.4 (4.57)
2 streams, greedy 69.7 [69.7, 70.0, 68.9] / 3.48 68.8 [68.6, 70.0, 68.8] / 3.48 −1.3% 68.8 [68.6, 68.8, 68.8] 68.8 [68.6, 68.9, 68.8] 0.0% −1.3% 0.0% 70.0 / 69.3 (3.48)
4 streams, greedy 69.4 [68.7, 69.5, 69.4] / 3.11 69.4 [68.3, 69.4, 69.4] / 3.11 0.0% 69.3 [68.4, 69.3, 69.4] 69.4 [68.5, 69.7, 69.4] −0.1% 0.0% −0.1% 69.4 / 69.4 (3.11)

bench_openai, decode tok/s (median of three), unset arms in the order main, this, this, main, then the two --mtp-drafts 7 arms:

prompt main / this / this / main --mtp-drafts 7
code, sampled 48.22 / 48.19 / 48.21 / 48.19 47.86 / 47.70
chat, sampled 37.74 / 37.78 / 37.64 / 37.55 36.44 / 37.31
code, greedy 47.20 / 47.17 / 47.18 / 47.18 47.64 / 47.66
chat, greedy 41.35 / 41.30 / 41.31 / 41.33 42.54 / 42.55
  • Level. No cell is more than 1% slower than main in both pairs. The two cells past 1% in one pair, two streams sampled (−1.4% and then +1.3%) and two streams greedy (−1.3% and then 0.0%), sit inside main's own spread between its two arms (−1.3% in both). bench_openai agrees within 0.4% in every row.

  • Lone stream: this branch has hip flashnext: an eight-row verify window, so a lone stream's copies land up to seven tokens a round #2730's eight-row window, so a lone stream's copy drafts grow past three and tokens a round rise: 3.16 to 3.28 greedy and 2.59 to 2.64 sampled. Greedy is 0.6% to 0.8% faster than main and sampled is 0.2% slower in both pairs. That is inside the 1% gate, and the sampled cell's own reps do not vary.

  • Tokens. With the flag unset, every request's token_sha256 equals main's in all four arms: 126 of 126 fields (14 distinct requests, with their alone and "draft": false runs). They also equal the earlier unset arms of 7365c00f7. bench_openai's greedy replies have the same hash in every arm, with seven drafts too. The two --mtp-drafts 7 arms equal each other and the earlier seven-draft arms, 126 of 126. Concurrent equals alone and alone equals "draft": false in every cell of all six arms, with 0 unequal and 0 failed. The start-up line reads mtp drafts 3 under 0.3 unset and mtp drafts 7 under 0.3 with the flag.

  • Seven drafts, bench_openai sampled chat is slower than three: medians 36.44 and 37.31 against 37.55 to 37.78 unset (0.6% to 3.5% slower), on the same seeded replies (every rep's token_sha is equal in all six arms); the second rep is about 4% slower in both arms. Chat greedy is +2.9%, code greedy +1.0%, code sampled −0.7% to −1.1%. The flag is opt-in and the default path is unchanged; the recipe now names this case.

  • Raw files for the merged head: https://gist.github.com/gilby/f1284f25978e7190af42f4014be918c3

This head (7365c00f7). One Strix Halo iGPU (gfx1151). Four fresh servers, all with --mtp-drafts 7 and the page cache dropped before each, in the order: this head, the receipt head 8b1c81720 as a same-box control, this head, the control. Each ran bench_concurrent --levels 1,2,4 --reps 3 --alone --serial --temperatures 1.0,0 --prompts tools/bench_prompts.json, and the first one also ran three staggered-start runs. The driver waited until no other process used more than 20% of a core, logged any that did during the run, and reran that arm; uptime and the busiest processes were recorded before and after each arm. A first single arm run earlier in the day was disturbed by other work on the box and is not used.

Aggregate tok/s, median of three reps (the reps in brackets); tokens a round are the same in all four arms.

cell this (1) control (2) 1 against 2 this (3) control (4) 3 against 4 control spread, 4 against 2 this spread, 3 against 1 tokens a round
1 stream, sampled 47.0 [47.0, 47.0, 47.0] 47.1 [47.1, 47.1, 47.1] −0.2% 47.0 [47.1, 47.0, 47.0] 47.0 [47.0, 47.0, 47.0] 0.0% −0.2% 0.0% 2.94
2 streams, sampled 59.3 [56.2, 59.3, 59.3] 59.4 [56.4, 60.0, 59.4] −0.2% 59.3 [56.9, 59.3, 60.0] 60.0 [56.2, 60.0, 60.0] −1.2% +1.0% 0.0% 3.10
4 streams, sampled 64.0 [53.3, 64.0, 64.2] 64.2 [54.7, 64.2, 64.4] −0.3% 64.0 [53.4, 64.0, 64.1] 63.8 [47.4, 63.8, 64.3] +0.3% −0.6% 0.0% 3.00
1 stream, greedy 59.1 [59.1, 59.1, 59.1] 59.1 [59.2, 59.1, 59.1] 0.0% 59.1 [59.1, 59.1, 59.1] 59.1 [59.1, 59.1, 59.2] 0.0% 0.0% 0.0% 4.57
2 streams, greedy 66.4 [65.7, 66.4, 66.4] 66.4 [65.8, 66.4, 67.3] 0.0% 66.4 [65.7, 67.3, 66.4] 66.5 [66.5, 66.4, 67.3] −0.2% +0.2% 0.0% 3.48
4 streams, greedy 66.9 [65.1, 66.9, 66.9] 66.9 [64.9, 67.3, 66.9] 0.0% 66.9 [64.9, 67.0, 66.9] 67.1 [64.8, 67.1, 67.1] −0.3% +0.3% 0.0% 3.11
  • Level: the largest gap is −1.2% in the second pair's two-stream sampled cell, where the first pair reads −0.2% and the two control arms differ by 1.0%; every rep of that cell lies inside the other arms' reps.
  • No token changes: all four arms give the same token_sha256 for every request, its alone run and its "draft": false run (126 of 126 fields in each arm), and they equal the earlier receipt's --mtp-drafts 7 arms. Concurrent equals alone and alone equals "draft": false in every cell of every arm, with 0 unequal and 0 failed. The start-up line reads mtp drafts 7 under 0.3 in all four.
  • A stream joined mid-reply (--stagger-ms, request A quicksort-raw, request B rust-reverse-words, 256 tokens each, sampled with fixed seeds and greedy, three reps each, --mtp-drafts 7, first arm above):
stagger A's tokens when B started (sampled / greedy) concurrent = alone alone = "draft": false
500 ms 9 / 10 of 256 6 of 6 / 6 of 6 2 of 2 / 2 of 2
2000 ms 59 / 70 6 of 6 / 6 of 6 2 of 2 / 2 of 2
3500 ms 123 / 151 6 of 6 / 6 of 6 2 of 2 / 2 of 2

Both replies' token_sha256 are the same at every join point, at both temperatures and in every rep.

This head with the flag unset, the default path, which the review commits reach (the depth budgets, the allocated shared rows and a stream's first drafts). Four more fresh servers, interleaved the same way (this, control, this, control), no --mtp-drafts, the same bench_concurrent command, and the three staggered starts on the first; the same quiet-box rule, with the disturbance check over the benchmark window. Aggregate tok/s, median of three reps (the reps in brackets).

cell this (1) control (2) 1 against 2 this (3) control (4) 3 against 4 control spread, 4 against 2 this spread, 3 against 1 tokens a round
1 stream, sampled 45.1 [45.1, 45.1, 45.1] 45.0 [45.0, 45.0, 45.0] +0.2% 45.0 [45.0, 45.0, 45.0] 45.1 [45.0, 45.1, 45.1] −0.2% +0.2% −0.2% 2.64
2 streams, sampled 60.1 [56.6, 60.1, 60.2] 57.5 [46.9, 57.5, 59.8] +4.5% 59.3 [56.4, 59.9, 59.3] 59.4 [56.4, 59.4, 59.4] −0.2% +3.3% −1.3% 3.10
4 streams, sampled 64.3 [57.1, 64.4, 64.3] 63.4 [40.1, 63.4, 63.7] +1.4% 63.9 [54.5, 63.9, 64.1] 63.6 [49.1, 63.6, 64.4] +0.5% +0.3% −0.6% 3.00
1 stream, greedy 51.8 [51.8, 51.8, 51.8] 51.7 [51.7, 51.6, 51.7] +0.2% 51.7 [51.7, 51.7, 51.7] 51.7 [51.8, 51.7, 51.7] 0.0% 0.0% −0.2% 3.28
2 streams, greedy 66.4 [66.1, 67.4, 66.4] 66.5 [66.5, 66.9, 66.0] −0.2% 66.2 [65.8, 66.3, 66.2] 67.3 [66.0, 67.3, 67.3] −1.6% +1.2% −0.3% 3.48
4 streams, greedy 66.9 [65.7, 66.9, 67.0] 66.4 [64.9, 66.4, 66.4] +0.8% 66.7 [65.3, 66.7, 67.0] 66.9 [65.7, 67.1, 66.9] −0.3% +0.8% −0.3% 3.11
  • The largest gap against the control is −1.6% in the second pair's two-stream greedy cell. The first pair reads −0.2% there, the two control arms differ by 1.2%, and the reps of this head's two arms (65.8 to 67.4) overlap the control arms' (66.0 to 67.3). It is the only cell that reads slower in both pairs. Tokens a round are the same in all four arms.
  • All four arms give the same token_sha256 for every request, its alone run and its "draft": false run (126 of 126 fields in each arm), and they equal the base's and the earlier unset arm's in the receipt above. Concurrent equals alone and alone equals "draft": false in every cell of every arm, with 0 unequal and 0 failed. The start-up line reads mtp drafts 3 under 0.3 in all four.
  • The three staggered starts on the first unset arm give the same hashes as with seven drafts, 36 of 36 concurrent = alone and 12 of 12 alone = "draft": false, with A at 9 / 59 / 119 tokens sampled and 10 / 67 / 135 greedy.
  • Raw files for this head: https://gist.github.com/gilby/81ce6c0ba31110257ad233304f0ce3cc

With #2697's 0.8 confidence floor the deeper chain pays more. This is an earlier run on a second box of the same kind, on this change's first commit merged with #2728 and #2697: 14 fresh servers, base and this interleaved, the same tools. The base has three arms; the others have two arms or one.

arm code chat 1 stream sampled 1 stream greedy 2 streams sampled 2 streams greedy 4 streams sampled 4 streams greedy
base 50.1–50.2 (2.75) 43.2–43.3 (2.13) 46.2–46.3 (2.54) 53.2 (3.20) 62.6–63.4 68.8–70.0 66.0–68.5 70.3–71.0
this, flag unset 50.2 (2.75) 43.2–43.3 (2.13) 46.3 (2.54) 53.2–53.3 (3.20) 62.9–63.0 70.0–70.1 69.3 70.9–71.0
--mtp-drafts 7 53.6 (3.28) 43.9–44.0 (2.23) 49.1–49.2 (2.81) 61.4–61.5 (4.13) 62.7–63.5 68.9–69.4 67.8–69.6 70.5–70.7
--mtp-drafts 6 52.1 43.7 49.8 59.6 63.0 69.1 69.2 71.0
--mtp-drafts 5 52.2 43.8 47.0 54.9 62.8 69.8 69.4 70.9
--mtp-drafts 4 49.6 42.9 47.1 54.0 63.1 69.2 69.3 71.8
  • With seven drafts on that stack: code +6.8%, chat +1.6%, one stream sampled +6.3% and greedy +15.6%. Two and four streams stay inside the base's own spread. All 68 request hashes equal the base's in every arm.

  • --mtp-drafts 4 is 1.2% slower than that base on the code prompt (49.6 against 50.1–50.2) and 0.7% slower on chat. 5, 6 and 7 are faster in every one-stream cell there. The edit workload was not run on that stack.

  • New switch? None. --mtp-drafts keeps its meaning and gains the range 4 to 7 on Flash Next HIP at --tp 1. The default is unchanged (3), and unset is identical to the base, with a test.

  • Boxes it reaches: every HIP device serving Flash Next, and only when --mtp-drafts above 3 is passed at --tp 1.

  • Shared code: core/lanes/depth.zig, config.zig, shared_drafts.zig and open.zig are the shared lane core. Every new bound is behind shared_mtp_cap, which only Flash Next's HIP facts set. Host tests pin that a zero cap changes nothing in each path. core/lanes/engine.zig sat at the 600-line cap, so the first commit folds one unrelated three-line loop there into one line to make room for the new argument; say if you prefer room made another way.

  • Environment: Zig 0.17.0, zig build native-hip -Dhip=true -Dhip-arch=gfx1151 -Doptimize=ReleaseFast, system hipcc (HIP 7.1, clang 21), Linux 7.0, unigilby/Qwen3.8-Flash-Next-oQ4e-HIP at revision 5a0fb414, --context 131072 --parallel 4 --prompt-cache-gib 0.

  • Licence of new weights: n/a.

gilby added 12 commits November 10, 2026 03:23
# Conflicts:
#	CHANGELOG.md
--mtp-drafts N now takes up to seven, the verify window's eight rows less the pending one. A stream that is alone in a round chains up to N drafts; rounds shared by several streams keep at most three a stream. Without the flag the head drafts three as before.
…ther family keeps the refusal past three

The --mtp-drafts limit is a family's mtp_max_drafts, three when it declares none, so Flash Next is refused past seven and the other HIP families past three as before.
@gilby

gilby commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

CI is green on a43e12597: 8 checks passed (fmt, zig, zig-windows, zig-macos-dev-tools, pytest, release-paths, both benchmark-clients) and the two nightly host jobs were skipped.

@ashhart

ashhart commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thanks @gilby. Letting a lone stream fill the rows #2730 opens up, while shared rounds keep three drafts a stream, is a tidy follow-up. The receipt is complete: base against this head on the same box, and with the flag unset, every cell matches the base, including tokens a round and the server's drafted and accepted totals. Every token_sha256 matches across all five arms.

We checked the default path. With --mtp-drafts unset, most_drafts is 3, so the new top of the lone ladder (zig/src/core/lanes/depth.zig:155) and the shared cap (depth.zig:323) both give 3, as on the base. Every other family leaves shared_mtp_cap at 0. The lean checker reads 345 on main and on this head, as you found, and CI is green on a43e12597.

This lands after #2730, which is queued. Once that's in, please rebase so the diff holds only this change's commits.

Asks

  1. The cap holds in one branch of Rule.budgets only. It applies in the allocation path (depth.zig:323). The confidence_floor branch (depth.zig:302), the node_probabilities branch (depth.zig:306) and the granted_depth path (zig/src/core/lanes/shared_drafts.zig:85) call headDepth with no budget, so they size from most_drafts and never see the cap. Flash Next on HIP sets none of these today, so nothing breaks now. But shared_mtp_cap is a core field, and a family that sets it alongside one of them would chain seven in shared rounds. Please apply the cap in those paths too, or have Config.init refuse the combination, with a test.
  2. A stream joined mid-reply. bench_concurrent starts its streams together. The new case is a lone stream that has chained seven drafts when a second request arrives. Please run one with --mtp-drafts 7 where the second request starts partway through the first reply, greedy and sampled. Both replies' hashes should equal their solo runs.
  3. --tp above 1. Flash Next on HIP serves more than one rank, and this wasn't run there. Please post a TP run with --mtp-drafts 7 and equal hashes. If you can't, refuse --mtp-drafts above 3 with --tp above 1 for now (zig/src/native/hip.zig:66).
  4. Changelog and recipe. The change reaches every HIP device serving Flash Next, not only gfx1151, so please word the changelog line that way. In the recipe, please also say that copy-heavy edit text ran 0.8% slower with seven on your box, so users know when to pick a lower N.

Optional, and fine as a follow-up: the lone ladder now jumps from 2 straight to most_drafts on the first draft's rate alone (depth.zig:155). Pricing depths 4 to 7 from the stream's own rates at those depths might win back the edit cell.

The one-line fold in engine.zig to make room is fine. depth.zig, zig/src/families/flashnext/hip/lanes.zig and zig/src/native/hip.zig each sit at exactly 600 lines on this head, and other open PRs touch them. Whichever lands next will need to make room.

  • Synthena (Ash's Agent)

@gilby

gilby commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Three new commits on top of a43e12597, no rebase: 4edc31fd0 (the cap in every path), de94f186c (the --tp refusal) and 7365c00f7 (changelog and recipe). Point by point:

1. The shared cap in every path. I applied the cap in those paths rather than refusing the combination in Config.init, because each one is a single @min and no path needed a refusal. 4edc31fd0:

  • Rule.budgets works out the cap once at its top (depth.zig:302). The confidence_floor branch (:304) and the node_probabilities branch (:309, granted or not) clamp headDepth's result to it, and the allocated branch (:323) uses the same value. A lone stream never goes through budgets, so it still chains up to most_drafts. With a zero cap the bound is most_drafts, which headDepth never goes past, so nothing changes.
  • shared_drafts.zig:36: the per-stream bound that every allocated shared round goes through now includes shared_mtp_cap. The granted depths from costLimits (:85) reach the round only as one argument of that same @min, so this one line caps the granted_depth path and the ungranted allocated path. Clamping :85 as well would change nothing a test can see, so I left it as it was.
  • I found one more path while checking: open.zig:148, a stream's first drafts, which calls headDepth with no budget under node probabilities. When another stream is already live, the first round of the new stream is a shared one, so its first drafts are capped too (open.zig:149).
  • New host tests in core/lanes/deep_chain_test.zig. Each one checks cap 3 and cap 0:
    • Under a confidence floor, under node probabilities, and under granted depths, two streams sharing a round get exactly 3 each with the cap and 7 each without it, and a lone stream gets 7.
    • shared_drafts.budgets, granted and ungranted, reserves 3 a stream with the cap and 7 without it.
    • firstDepth gives 7 when the stream opens alone. With a live stream beside it, it gives 3 with the cap and 7 without it.
  • I reverted each changed line on its own (depth.zig:304, :309, :323, shared_drafts.zig:36, open.zig:149), and each revert turns exactly one test red. Reverting :323 turns the existing "streams that share a round chain at most the shared cap each" test red.
  • Flash Next on HIP sets none of draft_confidence, draft_probabilities or allocate_shared_drafts. These lines therefore change nothing it runs, and the device run below shows equal tokens.

2. A stream that joins mid-reply. tools/bench_concurrent.py already has --stagger-ms: request i starts i × that many ms after request i − 1. So I used it and did not write a new driver: --levels 2 --reps 3 --alone --serial --temperatures 1.0,0 --prompts tools/bench_prompts.json --stagger-ms S with S = 500, 2000 and 3500. Request A is quicksort-raw and request B is rust-reverse-words, 256 tokens each, seeded at temperature 1.0 (seed 1234 and 1235). The join point is how many tokens A had streamed when B was sent, worked out from A's first and last token times.

One Strix Halo iGPU (gfx1151), a fresh server with the page cache dropped, this head with --mtp-drafts 7:

stagger temperature A's tokens when B started concurrent = alone alone = "draft": false
500 ms 1.0 9 of 256 6 of 6 2 of 2
500 ms 0 10 6 of 6 2 of 2
2000 ms 1.0 59 6 of 6 2 of 2
2000 ms 0 70 6 of 6 2 of 2
3500 ms 1.0 123 6 of 6 2 of 2
3500 ms 0 151 6 of 6 2 of 2

Both replies keep the same token_sha256 at every join point, at both temperatures and in every rep (A 104038954540… / B 6048466cb30f… sampled, A 4d05b3a9aca7… / B bdb60a42e39e… greedy), and each one equals its solo run and its "draft": false run. 0 unequal, 0 failed. In a late join, A can enter its first shared round still holding up to seven drafts that it chained while it was alone. Those drafts are verified inside the shared window, and its later rounds are capped at three. The same three joins with the flag unset give the same hashes: 36 of 36 concurrent = alone and 12 of 12 alone = "draft": false, with A at 9 / 59 / 119 tokens sampled and 10 / 67 / 135 greedy.

3. --tp above 1. de94f186c: hip_families.mtpMost now takes the world size and returns the family's mtp_max_drafts only at --tp 1, and three above that. Flash Next with --tp 2 --mtp-drafts 4 is refused before any load with --mtp-drafts 4: the HIP engine drafts at most 3 with --tp above 1. Every other family's message is main's, at every --tp. The host test in native/hip.zig covers four cases: Flash Next accepts 7 at tp 1 and refuses 8; Flash Next accepts 3 at tp 2 and refuses 4 with the message that names both flags; qwen3_5 accepts 3 and refuses 4 with main's text at tp 1 and tp 2. The hip_families test adds the tp 2 cases. If I remove and tp == 1, both tests go red. native/hip.zig stays at 600 lines, because the check stays on its one line.

4. Changelog and recipe. 7365c00f7: the line now reads "Qwen3.8-Flash-Next on HIP: … on every HIP device that serves Flash Next …" and says that --tp above 1 keeps three. The Flash Next recipe now says that on copy-heavy edit text, where copied drafts already fill most of the window, seven ran 0.8% slower than three on one Strix Halo iGPU (gfx1151), so that kind of work should pick a lower N.

No token changes and level speed on this head. Four fresh servers on one Strix Halo iGPU (gfx1151), all with --mtp-drafts 7 and the page cache dropped first, alternating this head and the receipt head 8b1c81720 as a same-box control (this, control, this, control). Each ran bench_concurrent --levels 1,2,4 --reps 3 --alone --serial --temperatures 1.0,0 --prompts tools/bench_prompts.json. The driver waited for the box to be quiet and recorded the load before and after each arm. A first single arm I ran earlier in the day was disturbed by other work on the box, and I do not use it. Aggregate tok/s, median of three reps; tokens a round are the same in all four arms.

cell this (1) control (2) 1 against 2 this (3) control (4) 3 against 4 control spread, 4 against 2
1 stream, sampled 47.0 47.1 −0.2% 47.0 47.0 0.0% −0.2%
2 streams, sampled 59.3 59.4 −0.2% 59.3 60.0 −1.2% +1.0%
4 streams, sampled 64.0 64.2 −0.3% 64.0 63.8 +0.3% −0.6%
1 stream, greedy 59.1 59.1 0.0% 59.1 59.1 0.0% 0.0%
2 streams, greedy 66.4 66.4 0.0% 66.4 66.5 −0.2% +0.2%
4 streams, greedy 66.9 66.9 0.0% 66.9 67.1 −0.3% +0.3%
  • The largest gap is −1.2% in the second pair's two-stream sampled cell. The first pair reads −0.2% there, the two control arms differ by 1.0%, and the reps overlap (this 56.9 / 59.3 / 60.0 against control 56.2 / 60.0 / 60.0).
  • All four arms give the same token_sha256 for every request, its alone run and its "draft": false run (126 of 126 fields in each arm, 14 distinct requests), and they equal the earlier receipt's --mtp-drafts 7 arms. Concurrent = alone and alone = "draft": false in every cell, with 0 unequal and 0 failed. The start-up line reads mtp drafts 7 under 0.3 in all four.

The default path (flag unset) on this head. The review commits change code the default path runs, so I ran four more fresh servers the same way with no --mtp-drafts: this, control, this, control, aggregate tok/s, median of three reps (the reps in brackets).

cell this (1) control (2) 1 against 2 this (3) control (4) 3 against 4 control spread, 4 against 2 this spread, 3 against 1 tokens a round
1 stream, sampled 45.1 [45.1, 45.1, 45.1] 45.0 [45.0, 45.0, 45.0] +0.2% 45.0 [45.0, 45.0, 45.0] 45.1 [45.0, 45.1, 45.1] −0.2% +0.2% −0.2% 2.64
2 streams, sampled 60.1 [56.6, 60.1, 60.2] 57.5 [46.9, 57.5, 59.8] +4.5% 59.3 [56.4, 59.9, 59.3] 59.4 [56.4, 59.4, 59.4] −0.2% +3.3% −1.3% 3.10
4 streams, sampled 64.3 [57.1, 64.4, 64.3] 63.4 [40.1, 63.4, 63.7] +1.4% 63.9 [54.5, 63.9, 64.1] 63.6 [49.1, 63.6, 64.4] +0.5% +0.3% −0.6% 3.00
1 stream, greedy 51.8 [51.8, 51.8, 51.8] 51.7 [51.7, 51.6, 51.7] +0.2% 51.7 [51.7, 51.7, 51.7] 51.7 [51.8, 51.7, 51.7] 0.0% 0.0% −0.2% 3.28
2 streams, greedy 66.4 [66.1, 67.4, 66.4] 66.5 [66.5, 66.9, 66.0] −0.2% 66.2 [65.8, 66.3, 66.2] 67.3 [66.0, 67.3, 67.3] −1.6% +1.2% −0.3% 3.48
4 streams, greedy 66.9 [65.7, 66.9, 67.0] 66.4 [64.9, 66.4, 66.4] +0.8% 66.7 [65.3, 66.7, 67.0] 66.9 [65.7, 67.1, 66.9] −0.3% +0.8% −0.3% 3.11
  • The largest gap against the control is −1.6% in the second pair's two-stream greedy cell. The first pair reads −0.2% there, the two control arms differ by 1.2%, and the reps of this head's two arms (65.8 to 67.4) overlap the control arms' (66.0 to 67.3). It is the only cell that reads slower in both pairs. Tokens a round are the same in all four arms.
  • All four arms give the same token_sha256 for every request, its alone run and its "draft": false run (126 of 126 fields in each arm), and they equal the base's and the earlier unset arm's, from the PR's receipt. Concurrent equals alone and alone equals "draft": false in every cell of every arm, with 0 unequal and 0 failed. The start-up line reads mtp drafts 3 under 0.3 in all four.

Tests: zig build test -Dhip=true --summary all gives 893 of 895 steps succeeded and 13,686 of 13,800 tests passed, with 113 skipped and 1 failed. The failure is dashboard_test "dashboard page polls the public stats snapshot without private details", which fails the same way on main. The lean checker reports 345 problems on the base and 345 on this head. python3 tools/pr_check.py --base origin/main --pr 3053: 0 errors, 0 warnings. zig fmt is clean, and every touched file is at most 600 lines (depth.zig and native/hip.zig are at 600). Raw JSON, server start-up lines and the run scripts: https://gist.github.com/gilby/81ce6c0ba31110257ad233304f0ce3cc

Conflict with main. Main has since changed four lines of zig/src/core/lanes/depth.zig (c713807eb), which conflict with this branch, so CI will not start on these commits. Everything above was measured on 7365c00f7 as pushed. I'll merge main into the branch to clear it (no rebase, as before), keep depth.zig within 600 lines, and rerun the tests and this receipt on the merged head; the rebase onto main after #2730 lands stays as you asked.

On the optional item (pricing depths 4 to 7 from the stream's own rates): I have not built it. Flash Next on HIP times no window costs, so depth takes the ladder at :155, which reads only the first draft's rate. The priced loop below it already weighs each depth's chain chance from the stream's own rates against the round's cost. The smallest version would therefore time the window widths and the head step at load, give them to Flash Next's facts, and scope them to --mtp-drafts above 3 so the default path keeps the ladder. That is about a day of code plus a full receipt. I would expect it to win back at most the 0.8% of the edit cell, with an uncertain point or two in the sampled one-stream cells, so I'd rather leave it for a follow-up PR if you want it.

@gilby

gilby commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

I merged main ab78aef90 into the branch so CI can start. The measured head is ed04933ce; there was no rebase. One docs-only commit sits on top of it, fa6f49385 (a recipe sentence, see the last point below), so the pushed head is fa6f49385.

What conflicted and how I resolved it (c2456fb8d, "Merge main into the deep MTP chain branch"):

  • CHANGELOG.md: I kept both sides' Unreleased lines, this PR's first.
  • zig/src/core/lanes/depth.zig: c713807eb puts the plain-streams check on one line at the top of Rule.budgets, and this PR adds the shared cap there.
    • I kept main's two lines as they are and put the cap's line after them, so both sides behave exactly as before.
    • To keep the file at 600 lines, the import of deep_chain_test.zig moves from depth.zig's test block to the test block in core/lanes/lanes.zig. No code changes.
  • ed04933ce: main's plan-path rounds (hip flashnext: verify windows run their DeltaNet from the round's plan, with no state snapshot a row #2728) merged cleanly into families/flashnext/hip/lanes.zig but left it at 604 lines.
    • This PR's test of the MTP draft cap moves, unchanged, to families/flashnext/hip/mtp_drafts_test.zig, referenced from families/flashnext/hip.zig's test block.
    • lanes.zig is now 593 lines.

#2728 is in main; #2730, the base of this stack, is not. Under core/lanes and families/flashnext/hip the merge brought in:

Checks on ed04933ce:

  • zig fmt and git diff --check are clean.
  • python3 tools/pr_check.py --base origin/main --pr 3053: 0 errors, 0 warnings.
  • Lean checker: 341 problems on main and 341 on this head, 0 new.
  • zig build test -Dhip=true --summary all: 898 of 901 steps succeeded, 14,062 of 14,181 tests passed, 116 skipped, 3 failed.
  • Main ab78aef90 on the same Mac: 898 of 901 steps succeeded, 14,044 of 14,163 tests passed, 116 skipped, and the same 3 tests failed:
    • dashboard_test "dashboard page polls the public stats snapshot without private details";
    • hip.tables "the parked pool's parts copy the same bytes as one thread for every id count";
    • hip.tables "a cold call wakes the pool and the same ids gathered again stay on the caller".
  • Each of this PR's tests still goes red when its line is reverted:
    • depth.zig:305, :310 and :324;
    • shared_drafts.zig:36;
    • open.zig:149;
    • hip_families.zig:48;
    • families/flashnext/hip/lanes.zig:24 (mtp_max_drafts), which turns the moved test and the native/hip.zig refusal test red.

Merged head (ed04933ce) against main (ab78aef90). One Strix Halo iGPU (gfx1151), both built from fresh trees at the same main commit. Six fresh servers, each with the page cache dropped before it, in this order: main, this, this, main (all with the flag unset), then this twice with --mtp-drafts 7. Each server ran two tools:

  • bench_concurrent --levels 1,2,4 --reps 3 --alone --serial --temperatures 1.0,0 --prompts tools/bench_prompts.json;
  • bench_openai --tokens 256 --reps 3, covering code and chat at temperatures 1.0 and 0.

The driver waited until no other process used more than 20% of a core, checked that again every 7 s during each benchmark window (model load excluded), and recorded uptime and the busiest processes before and after each arm. No arm was disturbed.

bench_concurrent, aggregate tok/s, median of three reps (the reps in brackets), tokens a round after the slash:

cell main (1) this (1) this 1 against main 1 this (2) main (2) this 2 against main 2 main spread, 2 against 1 this spread, 2 against 1 --mtp-drafts 7, this, two arms
1 stream, sampled 46.3 [46.3, 46.3, 46.3] / 2.59 46.2 [46.2, 46.2, 46.2] / 2.64 −0.2% 46.2 [46.2, 46.2, 46.2] 46.3 [46.3, 46.3, 46.3] −0.2% 0.0% 0.0% 48.6 / 48.6 (2.94)
2 streams, sampled 62.5 [60.3, 62.5, 62.6] / 3.08 61.6 [60.2, 61.6, 62.5] / 3.10 −1.4% 62.5 [60.2, 62.5, 62.6] 61.7 [60.2, 62.5, 61.7] +1.3% −1.3% +1.5% 61.6 / 61.6 (3.10)
4 streams, sampled 66.8 [63.4, 66.9, 66.8] / 3.00 66.8 [59.7, 66.8, 66.8] / 3.00 0.0% 66.7 [58.9, 66.7, 67.2] 66.8 [63.2, 66.8, 66.8] −0.1% 0.0% −0.1% 66.7 / 66.7 (3.00)
1 stream, greedy 52.8 [52.8, 52.8, 52.8] / 3.16 53.1 [53.1, 53.1, 53.2] / 3.28 +0.6% 53.2 [53.2, 53.2, 53.2] 52.8 [52.8, 52.8, 52.8] +0.8% 0.0% +0.2% 61.4 / 61.4 (4.57)
2 streams, greedy 69.7 [69.7, 70.0, 68.9] / 3.48 68.8 [68.6, 70.0, 68.8] / 3.48 −1.3% 68.8 [68.6, 68.8, 68.8] 68.8 [68.6, 68.9, 68.8] 0.0% −1.3% 0.0% 70.0 / 69.3 (3.48)
4 streams, greedy 69.4 [68.7, 69.5, 69.4] / 3.11 69.4 [68.3, 69.4, 69.4] / 3.11 0.0% 69.3 [68.4, 69.3, 69.4] 69.4 [68.5, 69.7, 69.4] −0.1% 0.0% −0.1% 69.4 / 69.4 (3.11)

bench_openai, decode tok/s (median of three), unset arms in the order main, this, this, main, then the two --mtp-drafts 7 arms:

prompt main / this / this / main --mtp-drafts 7
code, sampled 48.22 / 48.19 / 48.21 / 48.19 47.86 / 47.70
chat, sampled 37.74 / 37.78 / 37.64 / 37.55 36.44 / 37.31
code, greedy 47.20 / 47.17 / 47.18 / 47.18 47.64 / 47.66
chat, greedy 41.35 / 41.30 / 41.31 / 41.33 42.54 / 42.55
  • Level. No cell is more than 1% slower than main in both pairs. The two cells past 1% in one pair, two streams sampled (−1.4% and then +1.3%) and two streams greedy (−1.3% and then 0.0%), sit inside main's own spread between its two arms (−1.3% in both). bench_openai agrees within 0.4% in every row.
  • Lone stream: this branch has hip flashnext: an eight-row verify window, so a lone stream's copies land up to seven tokens a round #2730's eight-row window, so a lone stream's copy drafts grow past three and tokens a round rise: 3.16 to 3.28 greedy and 2.59 to 2.64 sampled. Greedy is 0.6% to 0.8% faster than main and sampled is 0.2% slower in both pairs. That is inside the 1% gate, and the sampled cell's own reps do not vary.
  • Tokens. With the flag unset, every request's token_sha256 equals main's in all four arms: 126 of 126 fields (14 distinct requests, with their alone and "draft": false runs). They also equal the earlier unset arms of 7365c00f7. bench_openai's greedy replies have the same hash in every arm, with seven drafts too. The two --mtp-drafts 7 arms equal each other and the earlier seven-draft arms, 126 of 126. Concurrent equals alone and alone equals "draft": false in every cell of all six arms, with 0 unequal and 0 failed. The start-up line reads mtp drafts 3 under 0.3 unset and mtp drafts 7 under 0.3 with the flag.
  • Seven drafts, bench_openai sampled chat is slower than three. Both seven-draft arms read below the unset arms (medians 36.44 and 37.31 against 37.55 to 37.78, so 0.6% to 3.5% slower). The replies are the same ones: the tool seeds each rep (1234, 1235, 1236) and every rep's token_sha is equal in all six arms. Per rep, seven against three reads 39.2 against 39.2, 34.4 and 34.6 against 35.9 to 36.1 (about 4% slower, in both arms), and 36.4 and 37.3 against 37.6 to 37.8. The other three rows go the other way or are level: chat greedy +2.9%, code greedy +1.0%, code sampled −0.7% to −1.1%, and the one-stream cells of bench_concurrent are +5% sampled and +16% greedy. So with the flag, a lone sampled chat stream whose deep drafts mostly miss pays for the wider window. The flag is opt-in and the default path is unchanged; fa6f49385 adds this case to the recipe's sentence on when to pick a lower N. This is the cell your optional item (pricing depths 4 to 7 from the stream's own rates) would address, and I'd take it as the follow-up's bar: no bench_openai row slower with seven than with three.

Raw JSON, server start-up lines, the load records and the driver: https://gist.github.com/gilby/f1284f25978e7190af42f4014be918c3

@gilby

gilby commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

I merged main aaac299d1 into the branch (f8b2f8f22) so CI can start. Only CHANGELOG.md conflicted; it keeps both sides' lines, with this PR's first.

Main's new .ocr = offer.ocr, line brought native/hip.zig to 601 lines. 0dde4b661 moves this PR's --mtp-drafts refusal test, unchanged, to native/mtp_drafts_hip_test.zig and makes checkGroup pub so the test can reach it. The test still goes red when and tp == 1 is reverted in hip_families.zig.

On head 0dde4b661:

  • zig build test -Dhip=true --summary all: 898 of 901 steps succeeded, 14,282 of 14,407 tests passed, 122 skipped, 3 failed.
  • Main aaac299d1: the same 3 failures (dashboard_test and the two hip.tables gather tests).
  • Lean checker: 341 problems on main and 341 on this head.
  • pr_check: clean.

Since ab78aef90, main changed nothing under core/lanes, families/flashnext/hip or hip, so the device receipt on ed04933ce still applies.

@gilby

gilby commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

CI on 0dde4b661: all checks that ran passed (fmt, zig, zig-windows, zig-macos-dev-tools, pytest, release-paths, and benchmark-clients on Ubuntu and Windows); host-nightly-linux and host-nightly-macos were skipped, as they are for pull requests. Main has added changelog lines since, so GitHub shows a conflict again; it is in CHANGELOG.md only.

@ashhart

ashhart commented Nov 14, 2026

Copy link
Copy Markdown
Owner

Thanks @gilby. The cap now covers every shared path, including a stream's first drafts beside a live one. The staggered-join runs, TP refusal, test moves and disclosed slower seven-draft cases answer the four substantive asks. Deeper cost pricing can stay a follow-up.

The one remaining item is a resolved net diff after #2730 lands, containing only this change and preserving the resolution that moved the test import and kept modules within 600 lines. The separate commit patches omit those resolutions, so we haven't verified the final merged tree yet.

  • Synthena (Ash's Agent)

This branch has not been deployed

No deployments
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