Skip to content

lfm2_moe CUDA: attention pages from the shared KV pool, and the prompt cache (stacked on #1492) - #1503

Draft
jschmied wants to merge 12 commits into
ashhart:mainfrom
jschmied:lfm2-cuda-pool
Draft

jschmied wants to merge 12 commits into
ashhart:mainfrom
jschmied:lfm2-cuda-pool

Conversation

@jschmied

@jschmied jschmied commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1488. Stacked on #1492 (now on main), and only the last four commits are new here. It stays a draft until #1492 lands, then I'll rebase onto main.

What

LFM2.5-24B-A2B on CUDA, step 2: attention pages from the shared CUDA KV pool, and the prompt cache.

  • 92fc4f0eb Pool pages. The 10 attention layers' K/V live in pages of the core kv_pool (512 positions, 10 MiB a page), Dory's shape.
    • A stream leases pages for prompt + reply + 16 at its prompt pass, waits when the pool is short, and returns them at release.
    • forward refuses rows past a slot's pages.
    • The kernels already read through a page table, so no kernel or build file changes, and SASS is unchanged by construction.
    • TENSORFOLD_LFM2_KV_PAGES=0 keeps lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels) #1492's fixed pages per stream.
  • 6ef5d8401 Prompt cache. A kept state is the slot's conv states plus K/V rows [0, at) of the 10 layers.
    • It is copied into snapshot pages allocated once, inside the prompt-cache budget, as GLM's cuda_snapshot_pages.zig does. A keep leases pages, a restore copies them back into the new stream's leased pages, and a drop returns the lease: no device allocation, and no free behind a stream wait.
    • Copies run page run by page run, with consecutive pages coalesced.
  • cb823583b Keeps add no prompt pass. A chat mark inside a prompt chunk used to end the chunk there, and every extra pass reads the weights again. A cold 10k chat turn has two marks in its last chunk, so it ran 12 passes instead of 10 (nsys: 1,920 prompt GEMM launches against lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels) #1492's 1,600).
    • Now up to four marks inside a chunk are tapped. The conv kernel runs once more on each mark's last row alone, writing the same y and that row's conv states into a tap buffer, and the keep copies those with the K/V rows before the mark. The kernel is unchanged.
  • 0506d9c0b Docs. The recipe says streams lease pool pages and the cache keeps chat states.

7 files, +647 / −43 against #1492.

Verification (GB10, real weights, served, greedy and sampled)

  • Resumed == fresh: 8 chat conversations × 4 turns, including sampled and drafted ones. 24 of 32 requests hit the cache (440 to 6,243 tokens cached). Every reply equals cache off, fixed pages and lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels) #1492, and a restart gives the same replies.
  • Drafted == plain, concurrent == solo: pass with pooled and with fixed pages, 8 short and 8 long prompts.
  • Pool pressure: on a 40-page pool (TENSORFOLD_CUDA_MEMORY_LIMIT_GB=13.6) every reply matches. The 8 long concurrent requests take 7.39 s instead of 6.42 s while they wait for pages.
  • Eviction: a 0.1 GiB cache gives 6 evictions and 1 hit, all matching the no-cache replies.
  • Accounting: after every drain the pool reads promised 0, free = total (264 pages, or 40 under pressure). Host tests cover the layout, plan, lease map, snapshot page runs and the chunk cut.
  • lean_check: 0 new against lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels) #1492's head. zig fmt --check zig and zig build test pass.

Speed (GB10, against #1492, alternating runs)

#1492 this PR
cold chat turn that keeps two states, 10,144 tokens (TTFT, 4 alternating rounds × 6) 1.211 s 1.216 s
warm turn, 10,246 tokens with 10,141 cached (TTFT) 1.24 s 0.10 s
plain decode, 1 / 4 / 8 streams (tok/s) 86.5 / 191.7 / 298.7 86.5 / 192.4 / 300.2

I re-measured only the cold turn this round. The warm turn and decode rows are from the previous head, and nothing on their path changed.

The remaining 5 ms on the cold turn is the two 200 MB state copies. A device-to-device copy runs at about 115 GB/s here, so each takes about 1.8 ms.

Not in this PR

  • The background warm pass that lets turn 2 resume (warm_quiet): its own PR.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied. Leasing from the core pool and keeping states at chat marks is the shape we want, and checking the census after every drain is a nice touch.

With #1492's commits on main, both of your commits apply cleanly. zig fmt, test-lfm2-moe-cuda-host, test-kv-pool, test-cuda-host and the Linux cross-compile pass, and the lean check adds nothing beyond #1492's one finding.

One thing must change before this leaves draft. A cold chat turn that keeps a state is 50 ms slower than on #1492 (1.27 s against 1.22 s), and the prompt cache is on by default. Each kept state is a new device allocation (zig/src/families/lfm2_moe/cuda_snap.zig:56, about 200 MB at 10k tokens). Dropping one waits for the stream and then frees it (:70-71). GLM keeps its states in pages allocated once, inside the prompt-cache budget, so a keep or a restore makes no allocation (zig/src/families/glm/cuda_snapshot_pages.zig). Please do the same. Then measure the cold turn against #1492 again, in alternating runs; it should be level.

To your questions:

  1. Pages by default is fine here. Dory's are opt-in because turning them on changed existing deployments. LFM2 has no CUDA deployment yet, and paging is exact and flat on decode.
  2. A copied state is fine, in preallocated pages as above.
  3. The warm pass belongs in its own PR.
  4. Yes, please rebase onto main once lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels) #1492 has.

Then we'll serve it on a GB10:

The shared-window chunk and merge bodies move to paged_attn_segs.cuh, included twice by nemotron_attention.cu:
Nemotron's head-128 kernels keep their names and SASS, and head 64 adds tf_paged_attn_chunk_segs_64 and
tf_paged_attn_merge_segs_64. prefill_attention.cu instantiates pattn_kernel<64, 8, 4, 8> over paged caches.
sassid on sm_80/86/89/120/121: 0 functions changed, 15 added.
lfm2_moe.cu holds what the shared operators do not: the short conv over each stream's state with every window
row's snapshot, the commit of an accepted row's conv state, and the router (fp32 weights, top-4 of sigmoid + bias,
ties to the lower id, a NaN ranked as -inf). Each has a planted control. Launch records cover these kernels and
the head-64 paged attention on nvcc 13.0, with one 13.2 override.
The MLX 4-bit checkpoint (embedding at 4, 6 or 8 bits) on the shared affine-4 projections and experts, with
prompt chunks of 1024 rows, one-row decode and copy-drafted windows over --parallel streams. The ten attention
layers keep their keys and values in 512-position pages, and each stream has its own conv state. There is no
drafter and no prompt cache yet.
…ount

The gated short conv over each stream's state and the conv-state commit move out of lfm2_moe.cuh into
liquid_conv.cuh, templated on the tap count, so LFM3 (7 taps) can take the same bodies as LFM2.5 (3).
lfm2_moe.cu keeps the router and instantiates the 3-tap pair as tf_liquid_conv_3 and tf_liquid_commit_3;
the controls become tf_ctl_liquid_conv_3 and tf_ctl_liquid_commit_3. cuobjdump -sass on sm_80/86/89/120/121,
nvcc 13.0 and 13.2: the moved bodies are identical under their new names, every other function unchanged.
… against float64

tf_paged_attn_chunk_segs_64 with tf_paged_attn_merge_segs_64 and pattn_kernel<64, 8, 4, 8> run over shuffled
512-key pages, decode rows over up to eight streams a launch, and are held head by head to contract_attn's float64
bound. Two controls in contract_controls.cu read the values from the neighbouring lane's dims (decode) and from the
neighbouring 8 dims (prompt rows). To build them from the same bodies, nemotron_attention.cu's page and tile helpers
move to paged_attn.cuh and prefill_attention.cu's to prefill_attention.cuh; every existing function's SASS is
unchanged on sm_80/86/89/120/121 under nvcc 13.0 and 13.2.
… of every top-1 miss

ref64.py dequantizes the MLX 4-bit weights exactly and runs the forward in float64; --bf16 adds the engine's bf16 storage points. It checks the CUDA engine's greedy picks on teacher-forced prompt rows and on reply rows; REF64.md lists each miss with the prompt, the position, both tokens and both margins.
The recipe gets a CUDA section (build, serve, revision, GB10 receipt, licence), the README row names one NVIDIA GPU beside the Mac, and the contract's coverage lists LFM2.5's CUDA kernels.
…m leasing

The kernels already read keys and values through a slot's page table, so the pool changes only what the
table holds and how large the arena is; no kernel changes. A page is 512 positions of the ten attention
layers' keys and values (10 MiB, 20 KiB a token); the conv states stay a fixed part a slot (240 KiB).

- Loaded.layout with engine_paged, as Dory's: the plan sets a window of pages aside for the first stream;
  each stream leases its prompt, its reply and one verify window at its prompt pass and gives them back
  when it ends. A request that cannot lease waits (LaneMemory); fits is the host's admission.
- Model.attach makes the arena and the tables and conv states of the slots the plan names; forward refuses
  rows past a slot's pages. The idle server logs the pool's census.
- TENSORFOLD_LFM2_KV_PAGES=0 keeps every stream's own window of pages, made at open.

Served on the GB10 at --parallel 8, 16k: drafted == plain and concurrent == solo, also with 8 long prompts
on a 52-page pool where the last waits for pages; the replies equal the fixed pages' ones.
…the pages' rows through its position

A state at `at` is the slot's conv states and its keys and values at positions [0, at) of the ten attention
layers: one copy a run of consecutive whole pages, then the last page's rows a layer's keys and values. It is
copied behind the chunk that ends at the mark and restored into the new stream's leased pages before its next
chunk, so a later turn's pass starts at its history. Lookahead 0, as the other headless families.

States live in 2 MiB snapshot pages allocated once at startup inside the prompt-cache budget (`snap_ready`), as
GLM's: a keep or a restore leases or reads pages and allocates nothing, and a dropped state's pages go back to the
pool at once (every copy that reads or writes them is queued on the backend's one stream). The store counts held
and spare pages through `snap_charged` / `snap_spare` / `snap_reuses`.
…dds no pass

A chat mark inside a prompt chunk ended the chunk there, and every extra prompt pass reads the weights once more: a
cold 10k chat turn, whose two marks sit in its last chunk, ran 12 passes instead of 10. Now up to four marks inside a
chunk are tapped instead: the conv kernel runs once more on each mark's last row alone, writing the same y and that
row's conv states into the model's tap buffer, and the keep copies those states with the K/V rows before the mark.
The pass goes on to its grid line; a fifth mark still ends it. The kernel is unchanged.
@jschmied

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. Kept states now live in pages allocated once, and the cold turn is level with #1492. The branch is rebased onto the new #1492; the head is 0506d9c0b.

  • Preallocated pages (6ef5d8401): as in GLM's cuda_snapshot_pages.zig, snap_ready allocates one arena of 2 MiB snapshot pages inside the prompt-cache budget. A keep leases pages from it and copies into them run by run, a restore copies back, and a drop returns the lease. Keeps and restores allocate nothing, and there's no free behind a stream wait. snap_charged/snap_spare report held and spare pages to the store.
  • The 50 ms wasn't the allocation. With pages preallocated the cold turn was still 65 ms slower, and with keeps refused it was level. A device-to-device copy of a 200 MB state takes 1.8 ms on the GB10. nsys showed the cost was prompt passes: the turn's two chat marks sit in its last chunk and each one ended the chunk there, so it ran 12 passes instead of 10, and every pass reads the weights.
    • cb823583b taps up to four marks inside a chunk instead. The conv kernel runs once more on each mark's last row alone, writing the same y and that row's conv states into a tap buffer, and the keep copies those with the K/V rows before the mark. The kernel is unchanged.
  • Cold turn against lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels) #1492, 10,144 tokens, keeping two states, 4 alternating rounds of 6: 1.211 s on lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels) #1492 and 1.216 s here. The 5 ms left is the two copies.
  • Served, real weights:
    • resumed == fresh over 8 chats × 4 turns (24/32 hits), equal to cache off and to fixed pages;
    • drafted == plain and concurrent == solo with pages on and off;
    • a 40-page pool under pressure and a 0.1 GiB cache (6 evictions, 1 hit) both match;
    • token hashes are unchanged across a restart.
  • The warm pass stays out of this PR.

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