Skip to content

spark25 CUDA: the full layers' caches in pages of the core pool, every stream leasing; sliding layers keep their rings - #1653

Merged
ashhart merged 8 commits into
ashhart:mainfrom
jschmied:paged-spark25
Oct 31, 2026
Merged

ashhart merged 8 commits into
ashhart:mainfrom
jschmied:paged-spark25

Conversation

@jschmied

Copy link
Copy Markdown
Contributor

Builds on #1618's paged kernels. Spark2.5's full-attention layers (every fourth, 9 of 36) take their K/V from pages of the core pool, every stream leasing as it grows; the sliding layers keep their rings. TENSORFOLD_SPARK25_KV_PAGES=0 keeps the linear caches.

  • Pages of 256 positions. One arena, per full layer a K region and then a V region. Plan and leasing as in gemma4 CUDA: the global layers' caches in pages of the core pool, every stream leasing; sliding layers keep their rings #1619: Loaded.layout with engine_paged, attach and fits; a stream's fixed bytes are its sliding rings and its table row. Pages are promised at the prompt pass, taken as rows reach them and returned at dropLane; forward refuses rows past a slot's pages (PastPages).
  • Writes: the rows kernel writes kr/vr as before, and tf_wrows_put copies them into the pages.
  • Reads: decode through the whole table (tf_wrows_part_256_pg/merge_256_pg, already on main). Prompt rows go through the slot's row via new tf_wrows_prompt_256_p32_pg, the _pg twin of prompt::kernel<256, true, NONE, true> (head dim 256, P32).
  • Kept states: copies; snap.each walks a paged layer through the slot's pages.
  • build gives slot 0 a first page, so a forward before attach has its rows. attach drops the captured decode graphs.
  • Two wrapper changes in window_attention.zig: paged prompt rows take pages of whole 32-key tiles at head dim 256 too, and tf_wrows_put places rows in pages of any position count at 256. Spark2.5 itself serves pages of 256 and does not need them. The contract's pages-of-32 case does.

Checks (GB10, nvcc 13.0, on main f350cd3)

  • SASS of window_pages.cu against main: 45 functions over sm_80/86/89/120/121, 0 changed, 5 added (the new kernel), 0 unstable.
  • Record of the new kernel, measured with nvcc -cubin -O3 and cuobjdump -res-usage: 251/252/252 registers on sm_80/86/89 and 254 on sm_120/121. kernel-contract-limits over the 5-SM fatbins shows it current.
  • Kernel contract (run before the rebase, on d33dcfc + cuda: tree attention and head dim 512's window rows read keys from a page table (_pg kernels), SASS of the others unchanged #1618): tf_wrows_prompt_256_p32_pg, 16/4 heads, over pages of 256 and 32, passes repeat, shapes, sums and nonfinite. The control (first page id a later page's) goes red at shapes. As noted on cuda: tree attention and head dim 512's window rows read keys from a page table (_pg kernels), SASS of the others unchanged #1618, the runner's SUMMARY line is not usable: main itself stops at unknown image name: fp8_experts, and the FAIL names before it are the same 220 here and on main.
  • tf-cuda-test spark25-pages MODEL TEXT 64: linear vs paged at context 8192 with two lanes, the pool exactly the two widest requests' pages, shuffled. Replies and last-row logits are byte-equal, 15/15, over three prompts, a sampled reply, shared rounds and a state kept at 1,000 rows and resumed (1,000 rows both ways). With TENSORFOLD_SPARK25_KV_PAGES=0 the paged arm opens linear caches, and the check says so.
  • zig build test test-kernel-contract test-golden test-server-security test-pull-security fuzz-cortheon, git diff --check: pass; lean 0 new.

Speed, paged vs TENSORFOLD_SPARK25_KV_PAGES=0, Spark-X2.6-4B MLX 8-bit, --parallel 4 --context 16384 (on main d33dcfc before the rebase; same harness as #1619: fresh server per arm, three alternating rounds, 256 greedy tokens, two prompts per case; min / median / max):

paged linear
c=1, 2k prompt: TTFT s 0.41 / 0.45 / 0.49 0.41 / 0.45 / 0.50
c=1, 2k prompt: decode tok/s 46.6 / 54.2 / 61.8 46.6 / 54.2 / 62.0
c=1, 9k prompt: TTFT s 1.71 / 1.81 / 1.90 1.70 / 1.80 / 1.91
c=1, 9k prompt: decode tok/s 42.6 / 48.3 / 54.1 42.8 / 48.4 / 54.2
c=4, 9k prompts: tok/s total 67.7 / 68.3 / 69.5 67.9 / 68.7 / 69.7

Paged and linear agree within 0.6 %, inside the arms' spread (the range is the two prompts).

@ashhart

ashhart commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied. We ran this on a GB10 (sm_121) against main, with Spark-X2.6-4B MLX 8-bit, fresh servers per arm and alternating base/new rounds. A Dory run on a second GB10 was also clean.

Tokens are equal to main (0 of 16 prompt-test rows differ), and decode and the stream sweep are flat (1, 4 and 8 streams within 0.2%). But cold prefill on the longer prompts is slower, and every run of this PR came in under every run of main:

prompt main pp tok/s this PR
512 5,790.7 5,804.6 (+0.2%)
2,048 5,806.0 5,782.5 (-0.4%)
9,216 5,351.4 5,301.1 (-0.9%)
15,000 4,983.8 4,914.8 (-1.4%)

The loss grows with the prompt's length, so it looks like the paged full-attention prompt path: reading committed keys through the page table on each prompt chunk, in place of the contiguous cache. We don't land a change that slows a default path, so we're parking this for now. A prompt path that reads the stream's own pages contiguously while it fills (or a per-chunk page lookup hoisted out of the inner loop) should close it. Then we'll rerun the same cells. The memory win is real, and we'd like this in once prefill is level.

@jschmied

Copy link
Copy Markdown
Contributor Author

/atc exchange b5b665b442fe766a80ed676116b7ab74e0bfc7c86b511dd5a5cdc383d3d5132e

@jschmied

Copy link
Copy Markdown
Contributor Author

Thanks for the numbers. It reproduced here on the GB10 (same model, --parallel 4 --context 16384, prompt cache off, fresh server per arm, alternating): main ahead of this PR at every length, -0.4 % at 2,048 and -0.8 % at 9,216 and 15,000.

It was the paged prompt kernel. Under nsys, two 15,000-token prompts spent 1,082 ms in tf_wrows_prompt_256_p32_pg against 1,003 ms in main's tf_wrows_prompt_256_p32 on the same 585 full-layer launches (+7.9 %). tf_wrows_put added 4.6 ms. Everything else was flat. Each staged tile divided its first key by the page size, then loaded its page id from the table before it could issue its copies. That load sat right after a barrier, twice per tile.

The fix is one commit, 67cd827, in window_rows.cuh's prompt stage. Tiles are staged in order, so the paged path now steps its base by one tile each time and does a table lookup only when it crosses into a new page. That next page's id was loaded one page earlier. The copies use the linear kernel's address form at that base. Timing the kernel alone over a whole prompt in 512-row chunks, paged against linear (15 reps, medians): before, +6.9 to +9.2 %; after, -1.2 to -1.8 % at 2,048, 9,216 and 15,000, whatever the page order (descending as the pool hands them out, ascending, shuffled).

Server A/B after the fix, same cells (min / median / max of the per-server medians, 3 rounds):

prompt main pp tok/s this PR, fixed (67cd827) this PR before the fix (346d009)
2,048 6,102.4 / 6,129.4 / 6,131.3 6,107.2 / 6,175.3 / 6,188.1 (+0.7 %) 6,095.2 / 6,162.7 / 6,189.5 (+0.5 %)
9,216 5,612.7 / 5,631.4 / 5,640.2 5,672.4 / 5,681.8 / 5,713.9 (+0.9 %) 5,560.3 / 5,645.0 / 5,650.9 (+0.2 %)
15,000 5,213.7 / 5,240.1 / 5,241.4 5,255.0 / 5,263.2 / 5,279.0 (+0.4 %) 5,142.7 / 5,213.9 / 5,222.2 (-0.5 %)

Paired by round, the fix against main is -0.4, +1.2 and +1.0 % at 2,048. At 9,216 it is +0.6, +1.5 and +1.2 %, and at 15,000 +0.3, +0.7 and +1.0 %. That leaves only the first round at 2,048 below main. The old head was noisier this session than in the repro (from -1.9 to +0.9 % per round).

Tokens: the 8-token greedy replies are equal across all three arms in all 9 cells.

Checks on the fix:

  • SASS (cuda_sass_identity.py, 346d009 against the fix): 20 functions changed over sm_80/86/89/120/121, which are the four paged prompt kernels (tf_wrows_prompt_256_pg, _256_p32_pg, _512_pg, _512_p32_pg). window_rows.cu and contract_controls are unchanged, so main's own kernels compile the same as before. Decode (_part_/_merge_*_pg) and tf_wrows_put are unchanged too.
  • Records: the four launches' registers re-measured with nvcc -cubin -O3 + cuobjdump -res-usage on all five SMs and updated in window_rows_records.zig.
  • Kernel contract, GPU: all 48 window_pages gates pass, with the same lines as before the fix. The paged-prompt controls (first page id a later page's, at 256 P32, 256 bf16 and 512) go red.
  • tf-cuda-test spark25-pages: PASS, and replies and logits are byte-equal linear against paged. With TENSORFOLD_SPARK25_KV_PAGES=0 it says the caches are linear, as before. dory-pages L=2 (the bf16 paged prompt kernel): PASS.
  • zig build test test-kernel-contract test-golden test-server-security test-pull-security fuzz-cortheon and git diff --check: pass. Lean: 0 new.

The A/B summary is on the review exchange as evidence, along with a benchmark request on 67cd827. Could you rerun your cells?

@ashhart

ashhart commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied, the prompt fix works. We re-ran your new head on a GB10 (sm_121) against main with Spark-X2.6-4B MLX 8-bit, fresh servers per arm, alternating base, new, new, base.

  • Tokens: equal to main (0 of 16 prompt-test rows differ; every swept stream equal to its lone and serial runs).
  • Prefill: level now, within 0.2% from 512 to 15,000 tokens. Decode after each prompt is level too.
  • Sweep at 1 and 4 streams: level (+0.1%, -0.1%).
  • Sweep at 8 streams: slightly slower, and consistently so. Every cell's new runs sit under both main runs:
8 streams, aggregate tok/s main (run 1, run 4) this PR (run 2, run 3)
code, T 1.0 304.3, 304.6 304.1, 303.7
chat, T 1.0 330.5, 331.1 329.9, 329.8
code, greedy 314.4, 315.2 313.8, 313.6
chat, greedy 320.3, 321.1 319.8, 319.5

That's about -0.3%. It's small, but it's in every cell and it's on by default, and our rule is no default slowdowns. So we'll hold this until the 8-stream sweep is level. Our guess is the paged decode reads (tf_wrows_part_256_pg through the table) once eight streams share a round, but you'll see it faster under nsys than we can guess. If you find it, push and we'll re-run the same cells straight away.

@jschmied

Copy link
Copy Markdown
Contributor Author

Thanks. It reproduced here too: -0.6 to -0.75 % at 8 streams in every cell (3 alternating rounds), with tokens equal.

It was the paged decode merge, not the reads. nsys's node-level graph trace serialises the graph and hides it. Measured that way, tf_wrows_part_256_pg comes out 12 % faster than tf_wrows_part_256 on the same launches, and the step looks level. At the graph level (median over the 1,384 decode graphs of one sweep), the step was longer:

  • main 22,180 us; 67cd827 22,266 us (+0.4 %);
  • 67cd827 with TENSORFOLD_SPARK25_KV_PAGES=0: 22,169 us;
  • 67cd827 with the linear merge kernel swapped in (timing only): 22,185 us.

tf_wrows_merge_256_pg (9 a step) built its float64 replay's lambda over the pages on the fast path. That gave it 40 registers and a 104-byte stack against 64 and 48, ten stack stores in the prologue against five, and the page-size division hoisted into the entry block.

The fix is 7c0a80b, a new commit. In the paged merge, a non-finite head is replayed in a __noinline__ helper, merge_pg_odd in window_pages.cu. The linear merge and every kernel in window_rows.cu compile to the same SASS. In a second session, the decode graph went to 22,105 us against main's 22,184 (the old head: 22,298).

8 streams after the fix (aggregate tok/s, per server in round order, ABBA):

8 streams main this PR, 7c0a80b
code, T 1.0 334.2, 324.3, 331.1 334.2, 333.4, 332.3
chat, T 1.0 343.2, 332.4, 340.3 343.4, 342.2, 341.4
code, greedy 324.5, 320.1, 322.0 324.4, 323.3, 322.8
chat, greedy 331.6, 327.3, 328.9 331.9, 330.7, 330.0

Paired rounds 1 and 3 are +0.0 to +0.3 %. Main's round 2 came in low. Greedy replies are equal across arms.

Checks:

  • SASS (67cd827 against the fix): 10 functions changed, tf_wrows_merge_256_pg and tf_wrows_merge_512_pg over the five SMs. window_rows and contract_controls are unchanged.
  • Records: both merges' registers re-measured on the five SMs and updated.
  • Kernel contract: all 48 window_pages gates pass, including nonfinite, which runs the replay. The paged decode controls (swapped tables, at 256 and 512) go red.
  • spark25-pages and dory-pages L=2: PASS.
  • The no-GPU CI set and git diff --check: pass. Lean: 0 new.

The A/B summary and a benchmark request on 7c0a80b are on the review exchange.

jschmied and others added 8 commits October 31, 2026 15:28
… page table (_pg twin), SASS of the others unchanged

window_pages.cu instantiates prompt::kernel<256, true, NONE, true> as tf_wrows_prompt_256_p32_pg, the
twin Spark2.5's full-attention prompt rows need (head dim 256, P32). loadPages fills Paged.prompt[1] at
256 as it does at 512. The record is measured: nvcc -cubin -O3 per sm 80/86/89/120/121, cuobjdump
-res-usage (static without the 1 KB reserve).
…y stream leasing; sliding layers keep their rings

Spark2.5's full-attention layers (every fourth: 9 of 36) take their K/V from pages of the core pool, pages of
256 positions; the sliding layers keep their rings. TENSORFOLD_SPARK25_KV_PAGES=0 keeps the linear caches.
One arena: per full layer a K region, then a V region. Loaded.layout with engine_paged, attach and fits
exported as Gemma 4's; a stream's fixed bytes are its sliding rings and table row. Pages are promised at
the prompt pass, taken as rows reach them and returned at dropLane; forward refuses rows past a slot's
pages (PastPages). The rows kernel writes kr/vr as before and tf_wrows_put copies them into the pages.
Reads: decode through the whole table, prompt rows through the slot's row (tf_wrows_prompt_256_p32_pg).
Kept states stay copies; snap.each walks a paged layer through the slot's pages. Kernels.load calls
loadPages, build gives slot 0 a first page so a forward before attach has its rows, and attach drops the
captured decode graphs (graph_gen).

tf-cuda-test spark25-pages MODEL TEXT N: linear vs paged at context 8192, two lanes, the pool exactly the
two widest requests' pages and shuffled; replies and last-row logits byte-equal over three prompts, a
sampled reply, shared rounds and a state kept at 1,000 and resumed.

On Spark-X2.6-4B MLX 8-bit (GB10), 64 tokens: 15/15 same, resumed rows 1000 both ways. With
TENSORFOLD_SPARK25_KV_PAGES=0 the paged arm opens linear caches and the check says so.
…iles at head dim 256 too (the chunk rule is the decode's)
… time, the next page's id loaded a page early
… a noinline helper, so its fast path builds no page lookup
@ashhart
ashhart merged commit d2ad5a7 into ashhart:main Oct 31, 2026
9 checks passed
@ashhart

ashhart commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Thank you @jschmied. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes.

@ashhart

ashhart commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied! Spark 2.5's full-attention K/V in core pool pages on CUDA is in 1.2.1: https://github.com/ashhart/TensorFold/releases/tag/v1.2.1

devopsfortunato added a commit to devopsfortunato/TensorFold that referenced this pull request Nov 6, 2026
…and Nemotron's Metal paging

The switch registry grows by four rows (ashhart#1660's next group, all measured identical):
TENSORFOLD_DORY_KV_PAGES (opt-in, ashhart#1284), TENSORFOLD_GEMMA4_KV_PAGES and
TENSORFOLD_SPARK25_KV_PAGES (default-on, exact 0 opts out, ashhart#1619, ashhart#1653), and
TF_NEMOTRON_METAL_KV_PAGES, the first metal-backend row (exact 1, ashhart#1537). A new
.not_zero syntax covers Dory's on-unless-0 reader; the 0-opt-out rows reuse
report_values. The four readers go through the registry's environment-first
helper, so an unset variable and every set value behave byte for byte as before.
devopsfortunato added a commit to devopsfortunato/TensorFold that referenced this pull request Nov 6, 2026
…and Nemotron's Metal paging

The switch registry grows by four rows (ashhart#1660's next group, all measured identical):
TENSORFOLD_DORY_KV_PAGES (opt-in, ashhart#1284), TENSORFOLD_GEMMA4_KV_PAGES and
TENSORFOLD_SPARK25_KV_PAGES (default-on, exact 0 opts out, ashhart#1619, ashhart#1653), and
TF_NEMOTRON_METAL_KV_PAGES, the first metal-backend row (exact 1, ashhart#1537). A new
.not_zero syntax covers Dory's on-unless-0 reader; the 0-opt-out rows reuse
report_values. The four readers go through the registry's environment-first
helper, so an unset variable and every set value behave byte for byte as before.
devopsfortunato added a commit to devopsfortunato/TensorFold that referenced this pull request Nov 6, 2026
…and Nemotron's Metal paging

The switch registry grows by four rows (ashhart#1660's next group, all measured identical):
TENSORFOLD_DORY_KV_PAGES (opt-in, ashhart#1284), TENSORFOLD_GEMMA4_KV_PAGES and
TENSORFOLD_SPARK25_KV_PAGES (default-on, exact 0 opts out, ashhart#1619, ashhart#1653), and
TF_NEMOTRON_METAL_KV_PAGES, the first metal-backend row (exact 1, ashhart#1537). A new
.not_zero syntax covers Dory's on-unless-0 reader; the 0-opt-out rows reuse
report_values. The four readers go through the registry's environment-first
helper, so an unset variable and every set value behave byte for byte as before.
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