Skip to content

lanes: a lone stream holds drafts only while its drafted rounds measure faster - #1382

Closed
BlivionIaG wants to merge 10 commits into
ashhart:mainfrom
BlivionIaG:series/lanes-lone-guard
Closed

BlivionIaG wants to merge 10 commits into
ashhart:mainfrom
BlivionIaG:series/lanes-lone-guard

Conversation

@BlivionIaG

@BlivionIaG BlivionIaG commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Branch series/lanes-lone-guard, head 6539b72be: the lone-guard commit, the long-context change and a merge of the #1379 fix, on series/lanes-chances. Sits on #1381. Part of #1374.

What this changes

With granted depths and the shared-round guard on a timed engine, a lone stream's rounds of held drafts are measured
in stretches of 16 against plain rounds' tokens a ms (kept by sampling mode and context band, copy rounds left out).
Drafting goes on while it beats plain by 5%; else the stream runs plain rounds and measures drafting again after 16
plain rounds, doubling to 512. Off unless the drafter sets both Facts.chances and Facts.shared_guard. Nothing on
main does; it lands with the LFM2 DSpark drafter PR.

Receipts at this head

  • Carries the lanes: a shared round drafts only while drafting measures faster (Model.shared_guard) #1379 timed fix and a change to the lone guard for long contexts: a loss is counted on a stretch's own rate; after two losses in a row the plain wait grows until a probe costs at most 3% of plain time; a band remembers its last losing verdict, so the next stream in that band starts plain, and any win clears it. No flag and no environment switch. zig build test: 500 of 500 steps passed; lean 433, the same as the base's; two new tests.

  • One stream, cold prompts, drafter against none, M5 Ultra, three replies each (decode tok/s change): 130,816 tokens -11.6%, -15.5%, -5.6% before and -9.0%, -0.7%, -0.7% after; 100,000 tokens -5.0%, -12.8%, -0.6% before and -5.1%, -4.0%, -0.8% after; 65,536 tokens +4.6%, -6.5%, -5.0% before and +4.5%, -4.9%, -1.7% after; 16,384 tokens -0.8%, +4.2%, +7.7% before and -0.9%, +5.4%, +7.6% after; 4,096 tokens -2.2%, +22.7%, +24.6% both. Short prompts are unchanged (one stream code +25% and chat +21% before and after). The first request after a server start still loses 5% to 9% at 64k and above.

  • Why drafting loses at long context: see LFM2.5-24B-A2B on Metal: drafts with LiquidAI's DSpark drafter (proposal and gate) #1374 (the released drafter's acceptance falls with context length; our GPU drafts equal the fp32 reference to 130,816 tokens).

  • Test that fails before the change (the lone guard off in the engine): 263 pass, 1 fail, "a lone stream whose
    drafted rounds lose runs plain rounds and probes now and then" (guarded.holds * 3 < guarded.rounds). With it:
    264/264.

A/B on current main (other families and builds)

M4 Pro, LFM2.5-24B-A2B 4-bit, no drafter, 4 streams, a different prompt on each, greedy 256 tokens, 4 alternated rounds, a fresh server per cell, a quiet machine (load under 2, no Zig build), every build merged with main 8c36359d5; aggregate tok/s by round:

build round 1 round 2 round 3 round 4
main 193.4 194.1 193.3 193.2
held (#1378) 194.8 192.9 193.5 193.4
guard (#1379) 193.2 193.4 193.5 193.3
chances (#1381) 193.1 193.3 193.4 193.3
lone guard (#1382) 193.3 194.0 193.2 193.0

Every build is within 0.5% of main and every reply token-equal to main's. An earlier single-round run showed #1378 at -6% and #1379 at -4%; four rounds showed nothing, so those were session effects.

M5 Ultra, Qwen3.8-27B + DFlash2, 4 streams, distinct prompts, greedy 256 tokens, 6 rotated rounds, 3 reps a cell, token_sha equal across all four binaries. main2 is 8c36359d5 built a second time in a separate worktree. Median of the 3 reps, tok/s:

round (order) main main2 held lane (6539b72be)
1 (main, main2, held, lane) 321.6 318.8 322.9 318.6
2 (lane, held, main2, main) 317.1 319.7 323.2 318.5
3 (main2, main, lane, held) 327.2 318.3 323.6 328.5
4 (held, lane, main, main2) 326.4 319.4 324.8 319.2
5 (main, main2, held, lane) 322.5 318.8 320.2 318.5
6 (lane, held, main2, main) 317.4 318.3 322.6 321.6
median of round medians 322.0 318.8 (-1.0%) 323.1 (+0.3%) 318.9 (-1.0%)

1 stream: 296.0, 296.5, 296.0 and 295.8. The two builds of identical source differ as much as the lane head does, and the spread inside a cell is 304 to 333, so the gap follows the build or the session, not the code. An earlier session had reported 1.8% to 2.7% lower at this cell; I did not reproduce it.

M5 Ultra, main 8c36359d5 against the lane head 6539b72be, distinct prompts, greedy 256 tokens, fresh server per cell, 3 alternated rounds, token_sha equal in every cell and every alone and serial check equal (0 failed): Qwen3.8-27B + DFlash2 at 1 stream 296.1 against 296.4 tok/s; Qwen3.8-27B without a drafter 47.6 against 47.5 (1 stream) and 172.7 against 172.6 (4 streams); Qwen3.5-2B at 4 streams 431.8, 431.1, 434.4 against 433.3, 432.5, 433.2 (1 stream too noisy to read, 230 to 278 inside a cell); Nemotron-3.5-Lightning at 1 stream 532.7 against 531.6 and at 4 streams 682.6, 663.1, 675.3 against 660.1, 664.0, 669.1 (pooled -1.6%, the main-first rounds were the high ones). M4 Pro, Nemotron-3.5-Lightning, 3 clean rounds: 1 stream within 0.4% and 4 streams within 1.3% of main, ranges overlapping. Only the LFM2 DSpark drafter sets shared_guard, chances, lone_guard and held_on_target, so for every other family these heads add branches that are not taken.

Not measured

  • M1 and M2 Macs. Nothing here depends on chip-specific kernels (this PR changes no kernel).
  • CUDA and HIP at run time: only host tests and compile checks. Linux CI is run by the PR checks.

Related PRs

Sits on #1381, which sits on #1379. The DSpark scheduled PR (#1392) sits on it.

engine.zig is 592 lines and the shared-round guard needs room in it. pipelinedRound and landInflight move
unchanged to pipelined.zig as round and land; Engine.familyRound and Result become public for them. No
behaviour change.
…el.shared_guard)

A drafter whose hold is a pass of its own costs a shared round more than the depth rule's head steps price:
the rule treats the rest of a round's time as overhead that plain rounds pay too. Facts.shared_guard (and
Model.shared_guard for a backend) lets the engine compare the two modes directly.

shared_guard.zig keeps, for each stream count, committed tokens over the round's wall time in plain stretches
(no budgets, so no holds) and in drafted stretches. It starts plain, measures plain rounds for 16 rounds and
drafted ones for 4, runs the faster mode for 512 rounds and doubles that, up to 64 times, while the same mode
wins again; then it measures the other mode again. Drafted rounds must beat plain ones by 5%. A mode's first
round still carries the other mode's drafts or holds and is not measured. An entry is allocated once a stream
count; later rounds take no allocation.

A plain stretch changes only how many drafts a window carries, never what the target draws, so replies stay
the same tokens. Without the flag the engine never consults the guard.

Tests: the guard's choice and re-measures, ever rarer re-measures of a losing mode, its skipped first rounds,
no allocation after a stream count's first round, and replies equal to one-token decode with the guard on and
off.
… stream joining one holds no drafts

With the shared-round guard on, a plain round still verified the lane core's context and Recall copies, which
are drafts too, priced from a prior; and a stream joining other streams held its first drafts even while the
guard ran plain rounds at that stream count, a hold pass for each join.

The engine now marks a shared round plain when the guard runs that stream count plain, and copyProposal
proposes nothing for it, so the guard's plain mode is the drafts-off round. Open.firstDepth gives no drafts to
a stream that joins live streams while the guard runs their count plus one plain. Both are behind
Model.shared_guard; without it plain_round stays false and firstDepth is unchanged.

Tests: a guarded plain round proposes no copies; a stream joining plain-guarded rounds holds no first drafts
and, without the flag, holds them as before.
… holds follow the grants

A drafter that gives each held chain draft's chance (Drafter.VTable.chances) may set Facts.chances. Drafted.facts
then sets Model.draft_probabilities and Model.granted_depth, so the window allocator prices each stream's verify
rows by those chances, as it does a head's node chances. With Facts.confidence_floor it instead verifies the
drafts at least that likely (a fixed window when 0).

With granted_depth a stream's next hold is as deep as its last grant plus a slack: one more draft when the grant
took every held draft, none when it stopped short of them (Stream.grant_short), and after a grant of none a probe
of draft_slack drafts at the depth rule's probe interval. Facts.slack sets one slack for all three. A shared
round's granted depths are cut so the drafting streams' blocks fit one hold pass of batch_rows. When the
shared-round guard starts a drafted stretch, its first round grants every stream one draft, so the stretch
measures drafted rounds rather than plain ones left by grants of none.

All of it is behind Facts.chances and Model.granted_depth, off by default; windows.allocate records
grant_short only for granted depths.

Tests: chances price the chain's rows and drafted replies equal one-token decode alone and at 1, 4 and 8 shared
streams, with the scheduled verify ragged across streams and verifying fewer rows than a fixed window; the facts
reach the lane config; granted depths, their slacks and the one-pass cut; holds as wide as the grants.
…py, and holds kept only while they pay

The depth rule asks one head draft for a stream with a copy ahead, and a shared round's granted holds were priced
only by their verify rows. A drafter whose hold runs as a pass of its own (Facts.copy_first) pays that pass for
the one draft, and the copy round then drops it; Model.copy_first now asks it for none.

A drafter may also give its hold pass's timed cost by blocks and depth (Facts.hold_costs, priced by
hold_price.zig). With granted depths a shared round then keeps granted holds, most expected drafts a row first,
only while each raises the next round's expected tokens a ms over its verify rows and the pass; a probe after a
grant of none keeps its hold. A stream's last grant records the drafts it expected to keep (Stream.grant_gain).

Both are off by default (copy_first false, hold_costs empty).

Tests: with copy_first no hold is asked for a copy round and replies equal one-token decode at 1 and 3 streams;
a hold's cost between and past its timed passes; granted holds kept while they pay, none for a dear pass, and a
probe's hold kept.
…re faster

With granted depths a lone stream's verify rows are priced over the shared costs and the one-stream overhead,
which is measured on drafted rounds and so holds the drafter's hold time; nothing compared a lone drafted round's
tokens a ms with a plain round's.

lone_guard.zig measures each lone stream's rounds of held drafts in stretches of 16 against plain rounds' tokens
a ms, kept from stream to stream by sampling mode and context band and taken once in a plain stretch when
unknown; copy rounds are left out of both. Drafting goes on while it beats plain by 5%; else the stream runs
plain rounds and measures drafting again after 16 plain rounds, doubling to 512 while plain keeps winning. A
drafted stretch's first hold drafts one and its slack, and a mode's first round is not measured.

It runs only with granted depths and the shared-round guard on a timed engine (Facts.chances and
Facts.shared_guard); shared rounds keep their own guard. Off by default.

Tests: the guard's verdicts, rarer probes and kept plain rate; through the round loop a lone stream whose
drafted rounds lose runs mostly plain and one whose drafted rounds win keeps drafting, replies equal to
one-token decode.

@ashhart ashhart left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @BlivionIaG. Its lone guard correctly respects `timed`, but it depends on #1379's shared guard, which does not, and on the plain-stretch policy decision. This stack also needs alternating A/B on current main for every family the shared lane core serves.

…ule does not depend on the clock

The guard planned, observed and chose with the wall clock even when timed was false. Round planning, joining
a shared round and observation now skip it in that mode. A test runs the same three streams on a fast and a
slow fake clock and checks equal holds and replies.
…xt stream, and probes less often

A loss is counted on the stretch's own rate. After two losses in a row the plain wait grows until a probe costs
at most 3% of the plain time. The band keeps its last losing verdict, so the next stream in that band starts plain.
Any winning stretch clears it.
@BlivionIaG

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. The #1379 fix is merged in, so the guards respect timed (6539b72be). This head also changes the lone guard for long contexts: a loss is counted on a stretch's own rate, the wait before a probe grows until a probe costs at most 3% of plain time, and a band remembers its last losing verdict. One stream, cold 130,816-token prompts, drafter against none: -11.6%, -15.5%, -5.6% before; -9.0%, -0.7%, -0.7% after. The first request after a server start still loses 5% to 9% at 64k and above.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @BlivionIaG, and thank you for the long-context numbers; they make the problem clear.

We will not take the lone guard, for the same reason as #1379: it is a second mechanism that switches a lone stream's
drafting off for stretches, by context band, beside the pricing that already decides how much to verify. The project
rule treats a drafts-off fallback as a bug to fix rather than a policy (docs/recipes/SKILL.md).

The pricing route is already there. With #1381, a grant of none holds nothing except a probe every wait rounds
(grantDepth with plain_guard), and main prices a lone stream's windows from its own measured rounds per context
band (lanes/lone_costs.zig, switched on by Model.lone_cost_tile, with calibrations carried to the next stream in
the band). In #1374 you note that the load-time cost table does not see that long contexts cost more; that is the
part to fix, by letting the lone pricing see measured round and hold times, so the allocator itself stops verifying
where drafts do not pay. Separately, at 65,536 and 130,816 tokens the released drafter's acceptance falls to 0.15 and
0.07 while each hold still costs a full pass, so a cheaper hold at long context would help most.

Please close this PR, or rework it into that pricing; nothing else in the stack should depend on lone_guard.zig.

@ashhart ashhart closed this Oct 27, 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