Skip to content

GLM-5.3-Flash on CUDA keeps conversation states on disk, every rank's part (--learn-conversations) - #1411

Open
wojo wants to merge 7 commits into
ashhart:mainfrom
wojo:up-glm-disk
Open

wojo wants to merge 7 commits into
ashhart:mainfrom
wojo:up-glm-disk

Conversation

@wojo

@wojo wojo commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1312.

GLM-5.3-Flash on CUDA now keeps conversation states on disk through the rows store, with --learn-conversations.
After an eviction or a restart, the next turn reads the state back instead of prefilling the whole conversation again.
At TP>1 every rank's part goes to rank 0's disk (option (a) on #1312), so a state is read back on every rank or on
none of them.

Commits

jschmied's grid commit, which this PR carried first, landed with #1358 as 9330cc8, so the branch no longer carries it.

  1. GLM on CUDA: a kept state's disk image on one rank (families/glm/cuda_disk_image.zig). It has the image's
    parts at a position, maps each image byte to its place in the snapshot lease, and copies between host and lease.
    The image is the snapshot plan's bytes in their order, so a disk restore writes exactly the bytes a RAM restore
    reads.
  2. GLM on CUDA: --learn-conversations keeps every rank's part of a kept state on disk.
    • cuda_disk_ranks.zig adds one new follow step kind, disk, with ops blank, put, take, seal and
      prefixes. Rank 0's engine thread runs every op, as it does every other step. The bytes travel as the step's
      ids, a link buffer at a time.
    • cuda_snapshots.zig adds blankAs and seal. A blank state is refused by restore until it is sealed.
    • native/cuda_glm_disk.zig is the rows.Image adapter. The store's image is rank 0's image followed by each
      other rank's KDA prefix, the only part a rank holds alone. The latent rows, rings, pooled keys, head and tail are
      the same bytes on every rank.
    • Startup check: the identity probe takes each other rank's shared bytes and compares them with rank 0's. It
      refuses to open the store (RanksDiffer) if they differ, so the "same bytes" premise is checked at every start,
      not assumed.
    • The identity covers the checkpoint, kernels, device and driver, TP world, capacity, ring rows, tail, head, kv8,
      drafter, every rank's prefix size and the probe's hash, through main's identityOf (from cuda qwen3_5: --learn-conversations keeps the 27B's and 35B's states on disk (#840) #1358).
    • The refusal is lifted for GLM, and docs/api.md is updated.
  3. rows: a pool that reuses spare storage without trimming it reads a disk state into that storage.
    • The problem: GLM's kept states live in preallocated snapshot pages that it reuses (reuses) but cannot trim.
      The restore selection counted those spare pages as fixed, and the kept states as not reclaimable, so no disk
      read ever fit.
    • The fix: for such a family, the room for a read is its spare storage plus what evicting unborrowed states frees.
      A read is ready once reuses says the spare storage takes the state, which is the question a keep's admission
      already asks.
    • Families with trim, or without reused storage, are unchanged. The existing pooled test passes as before.
  4. rows: a pool that also trims reads a disk state only within its budget, as before. Commit 3 let ready
    return early whenever reuses said yes, which also reached Flash Next and Qwen3.8-27B on Metal (they define both
    reuses and trim). The early return now needs trim == null, as rows_select.capacity already did, so a trim
    family's ready is main's again.
    • Nothing else in the branch changes a trim family. capacity was already main's for them, and the branch no
      longer changes rows_store or rows_write.
    • New test: a family with both trim and reuses, whose disk read takes new storage (as Qwen3.8-27B's disk blank
      does) and whose reuses answers yes after an eviction. With the gate it trims and stays within budget (held 205
      plus spare 25 of 240). Without it the read skips the trim (205 plus 120 of 240) and the test fails.
  5. GLM on CUDA: a disk read's seal refuses ring bases that do not hold the state's rows. ashhart suggested this
    check on this PR. An index ring based at base holds the rows from base up to len: the state's position for
    the main ring, the head's rows for the head ring. The image keeps at most ring_rows of each ring. seal now
    requires base <= len and len - base <= ring_rows for both rings. An image whose words (the bases and lengths it
    stores) fail is refused with DiskWords before the blank state changes, on the same path as the existing head and
    copy-bit checks.
    • No kept state trips it. Before every pass the ring rebases if the pass would run past base + ring_rows, so
      between passes, where states are kept, both bounds hold.
    • Both bounds are reached, so both must be inclusive. A state kept at 4,096 rows has base 0 after two 2,048-row
      passes, exactly ring_rows back. Without drafts the head's base and rows are both 0.
  6. rows: the grid test runs on both writers. ashhart asked for this on this PR. GLM's store writes on main's I/O
    worker, while the grid test wrote on the test's thread (sync_writes). It now runs on each, drained by
    write.drain. With startsBelow dropped on the worker's path only, the worker case fails (rows_test.zig:277,
    the state's starts) and the sync case passes.
  7. GLM on CUDA: --spill-gib and --checkpoint-slots are refused. Since cuda: serve --spill-gib and --checkpoint-slots for Nemotron, the Qwen hybrids and Flash Next (#840, split from #1359) #1413 CUDA serves both flags, and
    --learn-conversations admits GLM, so on this base --learn-conversations --spill-gib (or --checkpoint-slots)
    opened GLM and ignored the flag. GLM's store writes every rank's part between passes and stages nothing at eviction.
    GLM now refuses both by name, with tests (with the refusal removed, the --learn-conversations --spill-gib case
    fails at cuda_refusal.zig:217). Its store's spill stays off: an eviction would gather on rank 0 mid-pass while the
    other ranks wait in the gate's all-reduce. docs/api.md now lists every CUDA family that keeps states on disk.

Receipts: two GB10s at TP=2

Setup:

Step Result
Start The probe passes on both ranks: the shared bytes are equal. The store opens with 2 ranks' parts.
Turn 2, resumed from RAM 18,432 of 18,753 tokens cached. token_sha bd5a8cc0e246.
Written on main's I/O worker, staged between turn 1's rounds The 16,384-token state: 539 MiB (its own 352 MiB plus 187 MiB of rows it shares) in 1,463 ms. The 18,432-token state was skipped (StagingFull) while that one was in flight; see Main's staging permit below.
Two 55,939-token fillers evict A's states, then turn 2 Read from disk: the 16,384-token state, 539 MiB in 446 ms. First token 2.6 s (15.3 s without the disk tier). Same token_sha. Turn 2 also kept the 18,432-token state, which was written this time (8 new blocks, 377 MiB).
A evicted again, then turn 2 sent while a 55,942-token request prefills Read ahead on main's read-ahead thread: the 18,432-token state's files, 564 MiB in 441 ms (rank 0 logs read ahead; the scatter and rank 1's part follow on the engine thread, untimed). Turn 2's own prefill 0.59 s; its first token 3.8 s, 2.5 s of it queued beside the other request. Same token_sha, and the other request finished.
Restart (both ranks), same root, then turn 2 The store finds its 7 states (4,744 MiB). Read from disk: 564 MiB in 499 ms. First token 0.99 s. Same token_sha.
Every .st payload damaged, then restart a disk state failed its check (RowsHash). The turn prefills (15.3 s), same token_sha, server keeps serving.
Every .blk damaged, then restart a disk block failed its check (RowsHash). The turn prefills (15.7 s), same token_sha.
Fresh server: --prompt-cache-gib 0, no disk The turn prefills (15.3 s), same token_sha: a disk read gives the reply a fresh pass gives.
Commit 5's seal check All five disk reads in the run passed it: the three above, the read-ahead one included, and the fillers' two 53,248-token reads (998 MiB in 855 and 670 ms). Rank 0 logs a read only after its seal succeeds, and rank 1's logs have no errors.
Rank 1's log No errors in any of the five starts.

Main's staging permit and GLM's state sizes

ashhart pointed this out on this PR. On main a save is staged whole between rounds, rank 1's take steps included,
under a 1 GiB permit (staging_bytes). A save that doesn't fit is skipped (imprint: saving N tokens skipped (StagingFull)) and not retried.

GLM's states on this pair (TP=2, --context 131072, MTP drafts), from the run above:

  • A state's own file is about 335 MiB plus 1 KiB a token: 352 MiB at 16,384 tokens, 392 MiB at 55,296. That is each
    rank's KDA prefix, the index rings (capped) and the MTP head's caches; the 1 KiB a token is the head's caches.
  • Its blocks add about 11.7 KiB a token: 187 MiB at 16,384 tokens, 631 MiB at 55,296.

What follows:

  • A conversation's first save fits up to about 54k tokens, as ashhart estimated. A 53,248-token first save (998
    MiB) was written three times here; at 55,296 tokens the logged sizes add up to about 1,023 MiB before metadata,
    within a MiB or so of the permit. Past that the first save is skipped, and so is every later save of that
    conversation, since its blocks never reach disk.
  • Two saves in flight count together, and blocks still being written count twice. Each prompt here keeps two
    states 2,048 tokens apart. The second was staged while the first was still being written, and was skipped all 5
    times both were new (A's 18,432 twice; the 55,296 of both fillers and of the 55,942-token request). Its charge also
    counted the blocks the first was still writing, since the store learns of them only when the first is published:
    A's 18,432-token save was charged 564 MiB, of which 377 MiB was new, so the pair needed 1,103 MiB where 916 MiB
    would have fit. A's 18,432-token state reached disk on its next turn (8 new blocks, 377 MiB). Until then the read
    after eviction resumed from 16,384 tokens: first token 2.6 s, against 0.9-1.0 s with both states on disk.
  • Past about 670k tokens a state's own file alone exceeds 1 GiB (about 1.05 KiB a token on top of 335 MiB,
    extrapolated from a 131,072 context, not run), so at a 1M window no save of a longer conversation fits.
  • From the code, not seen in this run: with --spill-gib off, as for GLM, the stop's flush also stages the next save
    without waiting for the worker, so saves still queued at a stop can be skipped the same way.
  • TENSORFOLD_ROWS_SYNC=1 selects main's synchronous writer, which has no staging permit; the earlier runs used it.

Host tests (Zig 0.17.0, an M4 Mac)

  • On the head (a2066d2, on main dd0a9cc): test-rows-host, test-cuda-host, test-learn-recovery,
    test-rows-prefetch-host, test-snapshot-index-host, test-glm-snapshots-host, test-glm-expiry,
    test-lane-api-host, test-cuda-disk-open-host and test-golden pass: 295/295 steps, 4,132/4,146 tests (14
    skipped). zig fmt --check and git diff --check are clean, and the Linux aarch64 build passes.
  • The new GLM disk-image tests:
    • the store's image routes every byte to one rank, once;
    • blank and seal, including restore refusing an unsealed state, seal refusing a head past its verify window, a copy
      bit no layer has, or ring bases that do not hold the state's rows (commit 5), and seal setting the plan's length.
  • Commit 3's rows test fails without it ("expected .disk, found .ram") and passes with it.
  • Commit 4's test fails without its gate and passes with it.
  • Commit 5's cases fail without the check. The last one (a state with no head rows, as without drafts) would also fail
    if either bound were strict or if the main ring were checked against the head's rows.
  • Commit 6's worker case fails at rows_test.zig:277 with startsBelow dropped on the worker's path only; the sync
    case passes.
  • Commit 7's refusal test fails at cuda_refusal.zig:217 with GLM's refusal removed.
  • Before the rebase, zig build test failed only in the 55 Metal library builds, on base and branch alike (no Metal
    toolchain on this Mac). Not rerun since.
  • python3 tools/zig/lean_check.py: 425 problems on main and 425 on the branch (adds 0).

Not covered yet

@jschmied: your grid commit landed with #1358, thank you. The native side still opens GLM's image and store explicitly,
beside keptDisk. Moving GLM onto kept_disk needs engine_api in the glm_cuda module, the prefix gather moved into
probe, and commit 7's spill refusal kept by name (or a spill opt-out on the declaration), so I'd do it as a follow-up.

By Robert Wojciechowski (wojo on GitHub, @rwojo on X)

jschmied added a commit to jschmied/TensorFold that referenced this pull request Oct 26, 2026
…on disk

The CUDA Qwen hybrids refused --learn-conversations (cuda_refusal.zig). Their
kept states are already host copies (cuda_reuse.zig): the recurrences and
convolution tails, then each attention layer's key and value rows by head, in
bf16 or fp8 with its scales. cuda_disk.zig lays that copy out as the rows
store's image: 16 bytes of words (position, rotary shift, cache format), the
recurrent state as the state's own part, and every head's rows as row parts, so
256-token blocks are shared between states and conversations as on Nemotron.
A copy read back is a new host copy, restored as any other; one whose words
name another position or format is refused.

native/cuda_disk.zig's identity is split from Nemotron's probe, and a family
that declares `kept_disk` (image, probe, facts) attaches the same store: the
checkpoint, kernels, device, driver, the cache format and drafter taps, and a
probe state's hash at the first chunk cut. Nemotron's identity bytes are
unchanged; identityOf is public, as ashhart#1411 (GLM-5.3-Flash) factors it, and a
module that opens the store itself (several ranks' parts) declares attach and
is called in place of the probe. The payoff rule, the caps and the write-behind
are the core's.

A state whose prompt holds images stays in RAM (its image identities are not on
disk). The qwen35 module imports the engine_api module the server links
(build/cuda.zig), as the disk image names the rows store's types.

Co-authored-by: gilby <880013+gilby@users.noreply.github.com>
@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @wojo. This is a careful port:

  • The image follows the snapshot plan byte for byte.
  • A blank state can't be restored before its seal.
  • Every rank's part sits in rank 0's files, so a read is all-or-nothing.
  • The start probe checks the "same bytes on every rank" premise instead of assuming it.
  • Nemotron's disk identity is unchanged by the identityOf refactor: the same bytes are hashed in the same order.

On main b30d5b9d3 it passes fmt, lean (no new findings), zig build test and the GLM host steps, and the Linux
CUDA cross-compiles build. It applies cleanly. Next to #1404 and #1423 the only conflict is the test-import list in
glm/cuda.zig.

One change before we run it. rows_read.ready (rows_read.zig:139) now returns early whenever a family has
reuses. Flash Next and Qwen3.8-27B on Metal define both reuses and trim, so their disk-read admission changes
too, while the description says families with trim are unchanged. Could you gate it on trim == null, as
rows_select.capacity is, or add a test showing a trim family's read still stays within budget?

After that we'll run it on our GB10 pair with NVFP4:

  • eviction and read-back;
  • restart;
  • damaged .st and .blk files;
  • a fresh-server twin with the same token_sha.

Two notes:

jschmied added a commit to jschmied/TensorFold that referenced this pull request Oct 27, 2026
…on disk

The CUDA Qwen hybrids refused --learn-conversations (cuda_refusal.zig). Their
kept states are already host copies (cuda_reuse.zig): the recurrences and
convolution tails, then each attention layer's key and value rows by head, in
bf16 or fp8 with its scales. cuda_disk.zig lays that copy out as the rows
store's image: 16 bytes of words (position, rotary shift, cache format), the
recurrent state as the state's own part, and every head's rows as row parts, so
256-token blocks are shared between states and conversations as on Nemotron.
A copy read back is a new host copy, restored as any other; one whose words
name another position or format is refused.

native/cuda_disk.zig's identity is split from Nemotron's probe, and a family
that declares `kept_disk` (image, probe, facts) attaches the same store: the
checkpoint, kernels, device, driver, the cache format and drafter taps, and a
probe state's hash at the first chunk cut. Nemotron's identity bytes are
unchanged; identityOf is public, as ashhart#1411 (GLM-5.3-Flash) factors it, and a
module that opens the store itself (several ranks' parts) declares attach and
is called in place of the probe. The payoff rule, the caps and the write-behind
are the core's.

A state whose prompt holds images stays in RAM (its image identities are not on
disk). The qwen35 module imports the engine_api module the server links
(build/cuda.zig), as the disk image names the rows store's types.

Co-authored-by: gilby <880013+gilby@users.noreply.github.com>
@wojo

wojo commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. I pushed c2b3583: ready's early return now needs trim == null, as rows_select.capacity already does. A trim family's disk-read admission is main's again.

  • The rest of the branch. I checked every place it consults reuses.
    • capacity was already main's for trim families. The added reuses != null can't change a condition where trim != null already holds.
    • rows_store and rows_write don't consult reuses, and only the GLM CUDA store reaches their grid path.
    • So "families with trim are unchanged" now holds for the whole branch.
  • The test. It uses a family with both trim and reuses, whose disk read takes new storage and whose reuses answers yes after an eviction.
    • That is Qwen3.8-27B's case: its disk blank allocates a new Metal buffer. Before the gate, its read could leave untrimmed spare storage over the budget. Flash Next's blank takes from its pool, so it would have stayed within the budget.
    • With the gate, the read trims and stays within the budget. Without it, the test fails (held 205 + spare 120, budget 240).
  • Host tests on c2b3583: test-cuda-host 258/258 steps and test-learn-recovery pass. zig fmt is clean, and lean_check.py reports 429 before and after.

The seal check you suggested is written but not pushed yet. seal refuses words whose main ring base is past the state's position or more than a ring's rows back, and does the same for the head ring against its length. It is 7 lines in its own commit, and its test fails without it. It runs on every disk read, though, so I'd like it to pass our GB10 pair first. I'll comment here when it's pushed. It only adds a refusal, so a run you start before then covers the rest.

Noted on #1358: if it lands first, the first commit drops out.

@wojo

wojo commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

@ashhart I pushed the seal check you suggested, as 0d0084b.

  • The check. seal now refuses words whose main ring base is past the state's position or more than ring_rows
    behind it, and the same for the head ring's base against the head's rows. It returns DiskWords before the blank
    state changes, on the same path as the existing head and copy-bit checks.

  • Why no real state trips it. Before every pass the ring rebases if the pass would run past base + ring_rows,
    so between passes, where states are kept, both bounds hold. Both bounds are reached. A state kept at 4,096 rows has
    base 0 after two 2,048-row passes, exactly ring_rows back. Without drafts, the head's base and rows are both 0.

  • Test. The new cases fail without the check. The last one seals a state with no head rows, so it would also
    fail if either bound were strict or if the main ring were checked against the head's rows.

  • GB10 pair. I reran the table's cases on the whole branch with this check, using a build of 0d0084b from
    before six test lines were added.

    • The disk reads took 408 ms after eviction and 452 ms after a restart, with the first token at 0.92 and 0.96 s.
      Both passed the new check: rank 0 logs a read only after its seal succeeds.
    • Damaged .st and .blk files were refused by RowsHash, and the turn prefilled.
    • Every turn 2, the fresh-server twin included, gave the same token_sha bd5a8cc0e246.

    The table in the description is now this run.

  • Host. test-cuda-host 258/258 steps, zig fmt --check clean, lean_check.py 429 before and after.

One thing that predates this branch: a refusal inside scatter leaves rows_read.one() through a bare try
(zig/src/core/rows_read.zig line 56 on main), not through bad(). Such a refusal isn't counted, logged or
forgotten, so the next recall that picks the state reads it again.

On main that path already carries Nemotron's SnapshotSize from its CUDA scatter. On this branch it also carries
the new check, and the head, copy-bit and SnapshotSize refusals that commit 3 added to seal. Corrupt words fail
RowsHash first, so only a writer bug would get there.

If you'd like those refusals to forget the state the way a failed file check does, I can do that in a follow-up and
leave transient scatter errors, such as a follower link failing, as they are.

jschmied added a commit to jschmied/TensorFold that referenced this pull request Oct 27, 2026
…on disk

The CUDA Qwen hybrids refused --learn-conversations (cuda_refusal.zig). Their
kept states are already host copies (cuda_reuse.zig): the recurrences and
convolution tails, then each attention layer's key and value rows by head, in
bf16 or fp8 with its scales. cuda_disk.zig lays that copy out as the rows
store's image: 16 bytes of words (position, rotary shift, cache format), the
recurrent state as the state's own part, and every head's rows as row parts, so
256-token blocks are shared between states and conversations as on Nemotron.
A copy read back is a new host copy, restored as any other; one whose words
name another position or format is refused.

native/cuda_disk.zig's identity is split from Nemotron's probe, and a family
that declares `kept_disk` (image, probe, facts) attaches the same store: the
checkpoint, kernels, device, driver, the cache format and drafter taps, and a
probe state's hash at the first chunk cut. Nemotron's identity bytes are
unchanged; identityOf is public, as ashhart#1411 (GLM-5.3-Flash) factors it, and a
module that opens the store itself (several ranks' parts) declares attach and
is called in place of the probe. The payoff rule, the caps and the write-behind
are the core's.

A state whose prompt holds images stays in RAM (its image identities are not on
disk). The qwen35 module imports the engine_api module the server links
(build/cuda.zig), as the disk image names the rows store's types.

Co-authored-by: gilby <880013+gilby@users.noreply.github.com>
@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @wojo, both asks are in, and both go red without their fix. Without the trim == null gate (zig/src/core/rows_read.zig:140), the new test fails at rows_pool_select_test.zig:196. Without the ring check (cuda_snapshots.zig:84-85), cuda_disk_image_test.zig:262 fails.

Main has moved since, and the branch no longer merges:

  1. zig/src/core/rows_write.zig conflicts. The write-behind (afa4fa3) moved queued's body to rows_write_sync.zig:41-46, and both writers take their starts from that queue, so startsBelow belongs there now. With that one move, tried locally, the merge passes fmt, lean, test-learn-recovery, the rows steps, test-cuda-host and the Linux cross-compiles.
  2. Your grid test calls options(), which on main writes synchronously (rows_test.zig:18), but GLM's store uses the I/O worker. Please run the test on both writers, so that losing startsBelow on the worker's path goes red.
  3. On main a state is staged whole, between rounds (rows_write.zig:220-258). For GLM that includes the other ranks' take steps. A save with more new bytes than the 1 GiB staging permit (rows_store.zig:66) is skipped. By your receipt's sizes, that means a first save past roughly 54k tokens at TP=2. Please update the description and the receipt after rebasing.

Agreed on handling the bare-try scatter refusal in a follow-up.

After the rebase we'll run it on our GB10 pair with NVFP4. We'll cover the planned cells: eviction and read-back, restart, damaged files and the fresh twin. We'll add a conversation that returns while another stream decodes (read ahead), decode speed while a state stages, and a 60k-token conversation's first save.

@wojo

wojo commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

@ashhart Rebased onto main (5897e0b) and force-pushed as 96a156d (was 0d0084b).

  1. The conflict. Moved as you described: commit 1 now calls startsBelow in rows_write_sync.queued, where both
    writers take their starts. It matches jschmied's own rebase of it on cuda qwen3_5: --learn-conversations keeps the 27B's and 35B's states on disk (#840) #1358 (a721549) line for line. Through
    commit 6, the rebased tree is identical to a merge of main with that one move, which is the build that ran.
  2. The grid test on both writers. New commit 7 runs it with sync_writes and on the I/O worker, drained by
    write.drain. With startsBelow dropped on the worker's path only, the worker case fails (rows_test.zig:277,
    the state's starts) and the sync case passes.
  3. The staging permit. The description now has a section on it, with this pair's sizes. A first save fits up to
    about 54k tokens, as you estimated (a 53,248-token first save, 998 MiB, was written three times). Two more findings
    are below.

GB10 pair, EXL3 at TP=2 (a local merge with commit 6's tree; commit 7 is test-only). Every case passes, with
token_sha bd5a8cc0e246 on every turn 2:

  • reads after eviction (446 ms) and after a restart (499 ms, first token 0.99 s);
  • a new case for your read-ahead: A's turn 2 sent while a 55,942-token request prefills. Its files were read on the
    read-ahead thread (564 MiB in 441 ms, logged read ahead), then sealed and scattered on the engine thread;
  • damaged .st and .blk files refused by RowsHash, and the fresh twin.

All five disk reads in the run passed the seal check (the three above and the fillers' two). The description's table
is this run.

Two saves in flight. Each prompt here keeps two states, 2,048 tokens apart (16,384 and 18,432 for conversation A;
53,248 and 55,296 for the 55,939-token fillers). The write step staged the second while the first was still in
flight, so the two together counted against the 1 GiB permit, and the second was skipped with StagingFull. That
happened all 5 times both saves of a pair were new. A skip is final, so A's 18,432-token state reached disk only on its
next turn (8 new blocks, 377 MiB). Until then, a read after eviction resumed from the 16,384-token state, with the
first token at 2.6 s, where the synchronous writer of the earlier runs had both states and gave 0.9-1.0 s.

Blocks in flight count twice. newBytes counts every block not in r.blocks, and r.blocks learns the first
save's blocks only when it is published. So A's 18,432-token save was charged 564 MiB, 187 MiB of it the first save's
blocks still being written: the pair needed 1,103 MiB where 916 MiB was new. Staged, it would also have gathered and
written those 64 blocks a second time.

Both fixes belong in main's write path, so I haven't made them here: count a block that is still being written once
(the worker already answers wanted), which alone lets A's pair fit; and let a save that fits the permit on its own
wait in the queue until the worker frees room, skipping only a save larger than the permit (the fillers' pairs, 998 +
416 MiB, need this one). I can do both in a follow-up with tests, or here if you prefer. Until then
TENSORFOLD_ROWS_SYNC=1 gives GLM the synchronous writer the earlier runs used.

Host. On the rebased head, the rows, CUDA, learn-recovery, read-ahead, snapshot-index, GLM snapshot, GLM expiry and
lane-API host steps: 281/281 steps, 4,227/4,242 tests (15 skipped). zig fmt --check clean; lean_check.py 428 on
main and on the branch.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @wojo for the rebase, and for the careful write-up of what the staging permit does to GLM's states. All three asks are in:

  • rows_write_sync.queued now calls startsBelow (zig/src/core/rows_write_sync.zig:42), and both writers take their starts from it.
  • The grid test runs on the test's thread and on the I/O worker (rows_test.zig:294-300), so a worker path that loses the grid's starts now fails at :277.
  • The description covers the permit, with this pair's sizes.

On a merge with current main, these all pass: zig fmt, the lean check (no new findings), test-rows-host, test-learn-recovery, test-glm-snapshots-host, test-cuda-host and the Linux cross-compiles.

On your question: please send both write-path fixes as a follow-up PR on main, not here. You're right that they belong in main's write path. Every family whose disk states go through the I/O worker shares that path, so each fix needs its own test that fails before it:

  • a block still being written is counted and gathered once (rows_write.zig:270);
  • a save that fits the permit on its own waits for room instead of being skipped.

The second also changes when the engine stages, so it needs a decode-speed cell of its own. This PR doesn't depend on either fix: GLM's disk states are opt-in, and a skipped save costs a later first token, never a different one.

Next we'll run it on our GB10 pair with NVFP4:

  • eviction and read-back, restart, damaged .st and .blk files, and the fresh twin;
  • a conversation that returns while another stream decodes;
  • decode speed while a state stages;
  • a 60k-token conversation's first save.

We'll also check that a Nemotron disk state written by main still reads back after the identityOf refactor.

ashhart pushed a commit that referenced this pull request Oct 28, 2026
…on disk

The CUDA Qwen hybrids refused --learn-conversations (cuda_refusal.zig). Their
kept states are already host copies (cuda_reuse.zig): the recurrences and
convolution tails, then each attention layer's key and value rows by head, in
bf16 or fp8 with its scales. cuda_disk.zig lays that copy out as the rows
store's image: 16 bytes of words (position, rotary shift, cache format), the
recurrent state as the state's own part, and every head's rows as row parts, so
256-token blocks are shared between states and conversations as on Nemotron.
A copy read back is a new host copy, restored as any other; one whose words
name another position or format is refused.

native/cuda_disk.zig's identity is split from Nemotron's probe, and a family
that declares `kept_disk` (image, probe, facts) attaches the same store: the
checkpoint, kernels, device, driver, the cache format and drafter taps, and a
probe state's hash at the first chunk cut. Nemotron's identity bytes are
unchanged; identityOf is public, as #1411 (GLM-5.3-Flash) factors it, and a
module that opens the store itself (several ranks' parts) declares attach and
is called in place of the probe. The payoff rule, the caps and the write-behind
are the core's.

A state whose prompt holds images stays in RAM (its image identities are not on
disk). The qwen35 module imports the engine_api module the server links
(build/cuda.zig), as the disk image names the rows store's types.

Co-authored-by: gilby <880013+gilby@users.noreply.github.com>
@ashhart

ashhart commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks @wojo. #1358, #1359 and #1413 have now landed. #1358 brings your first commit, @jschmied's rows change, onto main as 9330cc8, as you expected. Between them they also change native/cuda.zig, native/cuda_disk.zig, native/cuda_refusal.zig, core/rows_store.zig, core/rows_test.zig and docs/api.md, which this branch touches too, so it no longer merges.

Could you rebase onto current main? The first commit should drop out. Nothing else is needed from you. Once it's rebased, we'll run the cells we listed on our GB10 pair.

@wojo

wojo commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

@ashhart Rebased onto main (1b9a987) and force-pushed as fbea020 (was 96a156d). The first commit dropped out:
main's 9330cc8 is the same patch.

  • The conflict was in commit 2's native/cuda.zig and cuda_refusal.zig, against cuda qwen3_5: --learn-conversations keeps the 27B's and 35B's states on disk (#840) #1358's keptDisk. GLM's image
    and store are still opened explicitly, beside keptDisk's branch, and the --learn-conversations refusal now
    exempts Nemotron, GLM and the kept_disk families. Its identityOf split dropped out too: main's, from cuda qwen3_5: --learn-conversations keeps the 27B's and 35B's states on disk (#840) #1358, is the
    same code. The other five commits carry the same patches as before.
  • One new commit (7). Since cuda: serve --spill-gib and --checkpoint-slots for Nemotron, the Qwen hybrids and Flash Next (#840, split from #1359) #1413 CUDA serves --spill-gib and --checkpoint-slots, so on this base
    --learn-conversations --spill-gib (or --checkpoint-slots) opened GLM and ignored the flag. GLM now refuses both
    by name. Its store's spill stays off, because an eviction would gather on rank 0 mid-pass while the other ranks wait
    in the gate's all-reduce. The docs sentence now lists every CUDA family that keeps states on disk.
  • Host. The rows, CUDA, learn-recovery, read-ahead, snapshot-index, GLM snapshot, GLM expiry, lane-API and CUDA
    disk-open steps pass (284/284 steps on the head), zig fmt --check is clean, lean_check.py is 426 on main and on
    the branch, and the Linux aarch64 build passes. With GLM's new refusal removed, its test fails at
    cuda_refusal.zig:217.
  • Not rerun on our pair: the description's table is from the previous base.

The two write-path fixes will come as their own PR on main, as you asked. Moving GLM onto kept_disk needs engine_api
in the glm_cuda module, the prefix gather moved into probe, and commit 7's spill refusal kept by name (or a spill
opt-out on the declaration), so I've left it for a follow-up.

wojo added 7 commits October 28, 2026 13:40
…re each byte sits in its lease

The rows store's view of a GLM kept state, without the store: the words (format,
position, head rows, ring bases, KDA copy bits), the KDA prefix, each DSA layer's
latent rows, scales and whole pools by position, its rings and pools in progress
as the state's own, the head's caches at a whole verify window (rows past the
head's own read as zeros), then the tail. Copies run a page of the lease at a
time. Nothing is wired to --learn-conversations yet.
…state on disk

Rank 0's engine thread leases, fills, reads and seals each rank's copy through the follow link
(cuda_disk_ranks: blank, put, take, seal, prefixes). The store's image is rank 0's image followed by
each other rank's prefix, the one part a rank holds alone; the rest are the same bytes on every rank,
checked at start by the identity probe. A blank state is refused by restore until its seal.
…sk state into that storage

A family whose kept states live in preallocated storage it reuses (GLM's snapshot pages on CUDA) and
cannot trim never had a disk read chosen: the selection counted its spare storage as fixed and its
kept states as not reclaimable, so no state ever fit. Such a family's room for a read is now its
spare storage plus what evicting unborrowed states frees, and a read is ready once the family says
its spare storage takes the state, as a keep's admission already asks. Families with trim, or
without reused storage, are unchanged.
…t, as before

The disk read's early admission for a family whose spare storage takes the state now applies only
to a family without trim, as the selection's capacity already does. A family with trim, such as
Flash Next's or Qwen3.8-27B's pools on Metal, makes room for a read by trimming its spare storage
and evicting, exactly as it did before, and a new test pins that its read stays within the budget
when its reuses answers yes.
…he state's rows

An index ring based at `base` holds the rows from `base` up to the state's position, and the image keeps
at most a ring's rows of each. The seal now checks the words' main base against the state's position and
the head's base against its rows, so an image whose words name a ring that starts past them, or too far
back to be held, is refused before the blank state is made whole.
…/O worker GLM's store uses

The write-behind gave GLM's store its own I/O worker, while the grid test wrote on the test's thread
(sync_writes). It now runs on each, drained by write.drain, so a worker path that loses the grid's
chunk starts fails the test.
…rn-conversations alone keeps its states

Since ashhart#1413 CUDA serves --spill-gib and --checkpoint-slots, and --learn-conversations admits GLM, so
--learn-conversations --spill-gib (or --checkpoint-slots) opened GLM and ignored the flag: its disk store
writes every rank's part between passes and stages nothing at eviction. GLM now refuses both by name.
Its store's spill stays off, since an eviction would gather on rank 0 mid-pass while the other ranks
wait in the gate's all-reduce. docs/api.md lists every CUDA family that keeps states on disk.
@wojo

wojo commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

@ashhart #1404 and #1424 landed after my rebase, so #1411 conflicted again. It's rebased onto main (dd0a9cc5e) as a2066d29a. The only conflict was the test imports in families/glm/cuda.zig, where both sides added a line; both are kept. The seven commits are unchanged otherwise. Host: 295/295 steps with test-golden and the CUDA disk-open step, zig fmt --check and git diff --check clean, lean_check.py 425 on main and on the branch, and the Linux aarch64 build passes. If your GB10 run is already on fbea020a2, these are the same patches.

The two write-path fixes you asked for are #1548 (with a TP=2 decode cell), and the scatter refusal is #1543.

@ashhart

ashhart commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Hi @wojo, a status update on #1411.

We read the full diff. With --learn-conversations off, nothing changes: the new follow step goes at the end of
the enum, and the default path is the same. We found nothing that blocks landing once the device run passes.

The run is still owed for two reasons. Every cell needs two GB10s at once (TP=2). And main refuses
--learn-conversations for GLM on CUDA, so it can't be an A/B with the flag on both sides: we'll run the flag-on
cells on this head (eviction and read-back, restart, damaged files, read-ahead, the 60k first save), and the
flag-off cells against main. We'll also check that a Nemotron disk state written by main still reads back here.

Nothing is needed from you right now. Thanks for splitting the rows-store fixes out into #1548.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants