Skip to content

lanes: an external drafter's chain chances price verify rows; holds follow the grants and pay for their pass - #1381

Open
BlivionIaG wants to merge 7 commits into
ashhart:mainfrom
BlivionIaG:series/lanes-chances
Open

BlivionIaG wants to merge 7 commits into
ashhart:mainfrom
BlivionIaG:series/lanes-chances

Conversation

@BlivionIaG

@BlivionIaG BlivionIaG commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Branch series/lanes-chances, head 91b0cf2a5: 2 commits on series/lanes-guard and a merge of the #1379 fix. Sits on the guard PR. Part of #1374.

What this changes

  • Facts.chances (with Drafter.VTable.chances): the window allocator prices each stream's verify rows by the
    drafter's chain chances, as it prices a head's node chances. Facts.confidence_floor gives a fixed window instead.
  • Model.granted_depth: the next hold is the last grant plus a slack (one when the grant took every held draft,
    none when it stopped short, a probe after a grant of none). Facts.slack sets one slack. Granted depths are cut
    so the drafting streams' blocks fit one hold pass. The guard's drafted stretch starts from a grant of one.
  • Facts.copy_first: no hold for a stream whose next round is a copy.
  • Facts.hold_costs (hold_price.zig): a shared round keeps granted holds only while they raise the next round's
    expected tokens a ms over their verify rows and the hold pass.

All off by default. windows.allocate records the new grant fields only with granted depths. Nothing on main sets
these facts; it lands with the LFM2 DSpark drafter PR (its confidence head is the chances).

Receipts at this head

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.

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 #1379. Merges cleanly with #1378 and #1380. #1382 sits on this PR.

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.

@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 own chain/grant/hold pricing is opt-in and fine, but it inherits #1379's `timed = false` and plain-stretch issues. 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.
@BlivionIaG

BlivionIaG commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. The #1379 timed = false fix is merged into this branch (91b0cf2a5). The plain-stretch question is in #1374, Question 1. Chances head against main, M4 Pro, LFM2 4 streams, 4 alternated rounds: within 0.5% of main, tokens equal.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @BlivionIaG. The pricing here is the lane work we want: the window allocator taking each stream's verify rows
from the drafter's own chain chances, the next hold following the grant, copy_first, and payHolds weighing the hold
pass against the expected tokens. It is per-round pricing in the lane core, and it is off for every other family.

What blocks it is its base. The branch sits on #1379, which we are not taking (see there), and it reaches into the
guard (Guard.draftsFresh, and the guarded budgets in engine.zig). Please rebase your two commits onto current main
(b30d5b9d3) without #1379: drop draftsFresh and the guard branch, and keep granted_depth, chances,
copy_first and hold_costs.

Smaller points:

  • hold_price.atDepth says it interpolates between the two nearest timed depths, but it takes the two smallest.
    DSpark times two depths per stream count, so nothing is wrong today; please pick the two depths around depth, or
    say "the two smallest" in the comment.
  • payHolds allocates its order array from r.gpa on every shared round; a fixed buffer on the Rule (at most
    batch_rows entries) would avoid it.
  • On a device, please report how often holds are kept at 4, 8 and 16 streams with the DSpark drafter, and drafted
    against plain tok/s at each. Where pricing keeps few holds, the drafter is not paying at that width, and that is the
    next thing to work on.

The A/B in the description ran on main 8c36359d5; we will repeat it on current main after the rebase.

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