Skip to content

flashnext cuda: the page pool maps its arena as streams use it, on a helper thread while other streams run - #3066

Open
Arminova wants to merge 19 commits into
ashhart:mainfrom
Arminova:flashnext/paged-decode-ahead
Open

Arminova wants to merge 19 commits into
ashhart:mainfrom
Arminova:flashnext/paged-decode-ahead

Conversation

@Arminova

@Arminova Arminova commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Before: with TF_FLASHNEXT_KV_PAGES the page pool allocates its whole arena at load (15.75 GiB at 262,144 x 3 on a
GB10). After (Linux): the arena is a reserved range whose 256 MiB pieces are mapped and zeroed when a lane first uses
them. A helper thread maps the pieces the next open will reach between requests. When an open reaches pieces that are
not mapped while other streams run, it posts them to that thread and waits in the prompt queue (a step with no
progress); the other streams' rounds go on, and the open comes once the pieces are mapped. Alone, the open maps them
itself, since nothing waits on it. Kernels and replies are unchanged. Without TF_FLASHNEXT_KV_PAGES, and on other
systems, nothing changes (one cuMemAlloc, as before).

Checks on this head

  • On the head with main merged (b710d1a): zig build test (8,937 of 9,004 passed, 67 skipped), test-cuda-host
    (134 of 134), test-gradient-serving, test-qwen-serving-host, compile-config-server, zig fmt --check build.zig zig,
    git diff --check: all exit 0.
  • Windows: test-cuda-host's modules target the host, so from Linux the step was built with its target set to
    x86_64-windows in a scratch copy: before fee044b 18 errors (dynamic_library.zig: unsupported platform, no close,
    no lookup, from arena.zig's open), after it and on the head none.
  • Tests added: the pool opened with a VMM whose cuMemCreate fails (LaneMemory, the pool idle, run and pages back;
    dropping the run's errdefer fails it); the arena's reach with a slow and a failing mapper; the pool's gate with a
    50 ms VMM beside a running stream (returns in under 25 ms, the helper maps, the open's own maps after it run nothing
    on the caller's thread; a gate that lets the open go early fails it).
  • Kernel contract: no kernel changes on this head.
  • Largest touched files: memory.zig 600, cuda_lanes.zig 600.
  • Not run, and why: macOS (CI only); --tp 2 (paging is refused there); test-golden.

Receipt

Environment: GB10 (sm_121), driver 580.173.02, kernels built by nvcc 13.3.73, nvidia/Qwen3.8-Flash-Next-FP4-Local
b9ce18a6, default reserve; main = ab78aef, this head = b710d1a (the CHANGELOG commit on top changes no code). Both
on pages (TF_FLASHNEXT_KV_PAGES=256, TF_FLASHNEXT_KV_PAGES_ALL=1).

Token equality

On pages (TF_FLASHNEXT_KV_PAGES=256, TF_FLASHNEXT_KV_PAGES_ALL=1), the 20 rows (bench_prompts' 16 and four chat
prompts of 283-17,182 tokens), greedy and seeded sampled, 128 tokens, 262,144 x 3: main == this head 40 of 40; this
head's drafted == "draft": false 40 of 40; 4 concurrent streams (code / chat, T 0 / 1): each equals its request alone
and serial. In the test sequence below, replies equal in every row.

An open beside a running stream (Ash's point 4)

Fresh server, pages, one stream decoding 3,072 tokens from a 2K prompt; 5 s in, a second stream sends a 64K prompt,
then on the same server a 120K one. nsys, CUDA API calls by thread:

map calls on the engine thread on the helper first stream's gaps after the second request
before 273363f, 64K 7 pieces, 66.8 ms 0 48.7, then 895.6 ms (open + maps + first chunk)
this head, 64K 0 7 pieces, 128.1 ms 58.1, 60.3, 53.0 ms (normal rounds)
before, 120K 6 pieces, 58.4 ms 0 53.3, then 832.3 ms
this head, 120K 0 6 pieces, 124.2 ms 52.8, 54.4, 51.8 ms

(Traced on f4b5b3b, the code of this head before main was merged in.) Against main, three fresh servers a side,
alternating (main, head, head, main, main, head), untraced:

second stream's first token, s first stream's 3 gaps after the second request first stream's longest gap
main, 64K 39.1 / 38.8 / 39.5 a prompt chunk (837-893 ms) among them in all 3 994-1,020 ms
this head, 64K 37.5 / 37.6 / 37.6 51.9-56.7 ms in all 3 948-953 ms
main, 120K 71.7 / 73.5 / 72.2 a prompt chunk (771-1,261 ms) among them in all 3 1,028-1,323 ms
this head, 120K 71.1 / 71.3 / 71.4 39.6-58.0 ms in all 3 975-983 ms

The first stream's reply is the same in all twelve runs.

Memory

MemAvailable after load at 262,144 x 3, the n-gram table warmed, four starts a side: main 26.5-27.0 GiB, this head
40.8-41.6 GiB. Of the 15.75 GiB arena, the three lanes' state runs (357 MiB each) and the next two pieces are
mapped at load, about 1.5 GiB; the rest as streams reach it. (The first receipt, on the earlier head with the look-ahead, measured what that buys: the 26.8 GiB
n-gram table stayed 100 % resident against main's 66-83 %, and the first request after start reached its first token 75 ms sooner with the table warmed; with it evicted,
-5 to +29 ms.)

Speed, main on pages against this head on pages

The test sequence (base, new, new, base; a fresh server each): 262,144 x 3 with the prompt cache off and the n-gram
table evicted, then 32,768 x 8 with the cache on and the table warm. No row missing, no slow first request. Every cell
within -1.0 to +1.3 %; in two single-stream decode cells both head runs are below both main runs, by 1.0 % (2K from
10,000: 71.0 / 70.8 against 70.5 / 69.9 tok/s, 14.08 / 14.12 against 14.18 / 14.31 ms a token; 8K: 39.3 / 39.3
against 38.8 / 39.0 tok/s, 25.45 / 25.45 against 25.77 / 25.64 ms), and the same cells in the second pass +0.2 % each.
In the two earlier runs of this sequence on this PR's arena, the same two cells read +0.3 / -0.4 % and -0.4 / -0.2 %,
with main's own two runs 0.5-0.9 tok/s apart in them (more than this run's gap). Decode at 4 and 8 streams +0.7 to +1.3 % in six of eight cells, -0.1 / +0.1 % in two.

cell (tok/s) main, runs 1 / 4 this head, runs 2 / 3 delta
2K, first pass, prompt 1574.5 / 1571.5 1573.0 / 1583.6 +0.3%
2K, first pass, decode 40.7 / 40.5 40.5 / 40.6 -0.2%
2K from 5,000, first pass, prompt 1616.4 / 1611.9 1607.9 / 1627.6 +0.2%
2K from 5,000, first pass, decode 36.3 / 36.2 36.4 / 36.5 +0.6%
2K from 10,000, first pass, prompt 1635.7 / 1632.8 1630.1 / 1645.3 +0.2%
2K from 10,000, first pass, decode 71.0 / 70.8 70.5 / 69.9 -1.0%
8K, first pass, prompt 1819.3 / 1814.3 1815.9 / 1820.6 +0.1%
8K, first pass, decode 39.3 / 39.3 38.8 / 39.0 -1.0%
8K from 5,000, first pass, prompt 1859.3 / 1853.4 1850.6 / 1859.7 -0.1%
8K from 5,000, first pass, decode 49.4 / 49.3 49.3 / 49.4 +0.0%
16K, first pass, prompt 1878.6 / 1880.7 1877.5 / 1881.5 -0.0%
16K, first pass, decode 40.6 / 40.5 40.7 / 41.1 +0.8%
32K from 5,000, first pass, prompt 1823.2 / 1830.1 1824.1 / 1812.9 -0.4%
32K from 5,000, first pass, decode 40.9 / 40.8 41.1 / 40.5 -0.2%
64K from 5,000, first pass, prompt 1820.9 / 1825.6 1817.3 / 1823.8 -0.1%
64K from 5,000, first pass, decode 43.2 / 43.5 43.8 / 43.3 +0.5%
2K, second pass, prompt 1849.9 / 1867.8 1867.5 / 1866.3 +0.4%
2K, second pass, decode 43.4 / 43.7 43.7 / 43.7 +0.3%
2K from 5,000, second pass, prompt 1864.9 / 1881.0 1869.8 / 1886.4 +0.3%
2K from 5,000, second pass, decode 38.6 / 38.7 38.8 / 38.7 +0.4%
2K from 10,000, second pass, prompt 1853.2 / 1869.4 1860.4 / 1872.0 +0.3%
2K from 10,000, second pass, decode 75.0 / 75.6 75.4 / 75.4 +0.2%
8K, second pass, prompt 1904.8 / 1906.9 1909.8 / 1912.8 +0.3%
8K, second pass, decode 41.5 / 41.8 41.8 / 41.7 +0.2%
8K from 5,000, second pass, prompt 1915.7 / 1926.1 1916.8 / 1929.5 +0.1%
8K from 5,000, second pass, decode 51.5 / 51.6 51.7 / 51.5 +0.2%
16K, second pass, prompt 1909.6 / 1920.9 1914.8 / 1921.2 +0.1%
16K, second pass, decode 43.1 / 43.5 43.3 / 43.1 -0.2%
32K from 5,000, second pass, prompt 1897.8 / 1904.9 1901.5 / 1908.6 +0.2%
32K from 5,000, second pass, decode 43.2 / 43.4 43.4 / 43.4 +0.3%
64K from 5,000, second pass, prompt 1852.7 / 1860.9 1857.1 / 1863.7 +0.2%
64K from 5,000, second pass, decode 45.1 / 45.2 45.3 / 45.3 +0.2%
chat T0, 1 stream (262,144 x 3) 45.5 / 46.1 45.9 / 46.2 +0.5%
chat T0, 3 streams (262,144 x 3) 63.6 / 63.7 63.9 / 63.7 +0.2%
chat T1, 1 stream (262,144 x 3) 43.5 / 43.7 43.6 / 43.5 -0.1%
chat T1, 3 streams (262,144 x 3) 57.4 / 58.1 57.7 / 56.7 -1.0%
code T0, 1 stream (262,144 x 3) 71.1 / 71.8 71.7 / 71.9 +0.5%
code T0, 3 streams (262,144 x 3) 100.5 / 101.2 100.9 / 101.0 +0.1%
code T1, 1 stream (262,144 x 3) 68.7 / 69.5 68.9 / 68.7 -0.4%
code T1, 3 streams (262,144 x 3) 81.6 / 79.5 81.8 / 79.3 +0.0%
chat T0, 4 streams (32,768 x 8) 144.1 / 145.5 146.8 / 146.3 +1.2%
chat T0, 8 streams (32,768 x 8) 147.0 / 147.1 149.3 / 148.7 +1.3%
chat T1, 4 streams (32,768 x 8) 97.4 / 98.1 98.7 / 98.3 +0.8%
chat T1, 8 streams (32,768 x 8) 106.7 / 107.3 106.7 / 107.0 -0.1%
code T0, 4 streams (32,768 x 8) 227.1 / 228.6 230.4 / 229.3 +0.9%
code T0, 8 streams (32,768 x 8) 219.0 / 221.7 223.2 / 222.3 +1.1%
code T1, 4 streams (32,768 x 8) 121.4 / 122.1 122.9 / 122.2 +0.7%
code T1, 8 streams (32,768 x 8) 144.2 / 142.6 141.1 / 145.9 +0.1%

… a tile ahead

fn_attn_chunks_ahead_paged formed a tile's key addresses at the start of that tile: the key id,
then the page id it names, then a block barrier before the copies could start, so every 32-key
tile waited on two dependent loads. The key and page id of the next tile now load while the
current one computes (the key after it two tiles ahead), and the address is formed from them
when the tile is staged. The addresses are the same, so the sums and the bits are too. The
contiguous kernels and the one-buffer and fused paged kernels keep their code.
…s are first used

The paged pool allocated its whole arena at load as one cuMemAlloc. On a
GB10 that costs decode twice. Past about 5.4 GiB in one physical
allocation the paged decode attention runs 1.6-1.8x the time of the
contiguous kernel while executing the same instructions with the same L2
misses; at today's plan (both paged streams' whole windows, 2146 pages of
256 rows, 15.75 GiB at 262,144 x 3) it is 1.46x at 4K and 1.72x at 16K.
And the 15.75 GiB leave the host at load, before any stream needs a page:
MemAvailable falls from 42.7 to 26.6 GiB and the n-gram table that decode
reads every token (26.8 GiB) keeps only 72-96 % of itself in the page
cache.

The DeviceBuffer the pool holds is now a reserved virtual range (CUDA
virtual memory) whose 256 MiB physical pieces are mapped, and zeroed as
the arena was, the first time a lane's page table or state run reaches
into them; addresses, page ids and kernels stay as they were. A lane's
pages are mapped when its table is uploaded at open, so a piece that
cannot be mapped fails the open with LaneMemory before the request runs,
as a contiguous stream's open fails when its allocation does. The buffer
is counted as before; freeing it unmaps every mapped piece and the range.
A pool made without a device (Pool.init), an arena of one piece or less,
or a device without virtual memory keeps the single cuMemAlloc. The load
context's device reaches the pool through Pool.initOn.

Decode with every stream on pages (TF_FLASHNEXT_KV_PAGES=256,
TF_FLASHNEXT_KV_PAGES_ALL=1) against the contiguous default, GB10,
262,144 x 3, fresh servers in mirrored order, five fresh prompts per
cell, 512 greedy tokens, time per round, with the arena mapped whole at
load: 4K +2.21 / +2.78 %, 16K +1.55 / +2.25 %; mapped as used: 4K -0.01 /
+0.08 %, 16K -0.13 / -0.16 % (the default's own two runs +-0.11 %). The
paged attention's 2-row launches take 34.8 us against 50.8 us contiguous
at 16K. First-token time level. Every reply identical.
… not the installed one

The arena got its reserved range by installing its hook as the process's
DeviceBuffer hook for one allocation and putting the old one back, though the
hook is meant to be installed once at boot: another engine or thread
allocating meanwhile could have taken the range, or found its own hook
replaced. DeviceBuffer.allocVia takes the hook for one call instead (alloc is
allocVia with the installed one), with the same counting and the owner kept on
the buffer, so the arena never touches the installed hook. Tests: the range
comes through the arena's hook and the installed one is unchanged; a hook that
declines (no memory for its flags) falls back to cuMemAlloc and keeps no range;
without a device, or for one piece or less, the arena is one cuMemAlloc.
A piece was marked mapped before its zeroing was issued, so a zeroing that
failed left a mapped piece marked as ready that was never zeroed. The flag is
now set after the zeroing, and a piece whose zeroing fails is released and
left unmarked, as a piece that would not map. A test fails the zeroing.
…ches, at load and between requests

With the arena mapped as it is used, the first request mapped about four
256 MiB pieces inside its first-token time (its lane's run, its first pages)
and a request that walked into a fresh piece mapped it there too: on a GB10
each piece took 6-9 ms of cuMemCreate and cuMemSetAccess. The pool now maps
those pieces before an open asks for them: at load, the lanes' runs (runs are
claimed from the top of the arena) and a piece's worth of the pages the free
stack hands out next; after each lane closes, again the next piece's worth of
free pages, so the mapping happens between requests. The pieces, their zeroing
and the addresses are the same, so the bits are too. A piece that cannot map
then is mapped, or refused, by the open that needs it, as before. A test checks
which pages come next from the free stack: page 0 first, then the ones past
those a store holds.
…t, and looks two pieces ahead

A lane's close gives its state run back before its pages, so the top of the
free stack held the run's pages, already mapped, and the pieces mapped ahead
were the ones the next request did not need: the next open claims that run
again first and takes the fresh pages below it. With TF_KV_SHARE a keep holds
pages past those too, and on a GB10 each request still mapped one 256 MiB piece
inside its first-token time. The pages mapped ahead are now the ones the free
stack hands an open out after the run it claims, two pieces' worth: a 2K
request's pages and a keep's. Same pieces, zeroing and addresses, so the same
bits. A test closes a lane with a run and a keep and checks the next pages are
the freed and fresh ones, never the run.
…o no reply waits for a piece

The core sends a stream's last tokens only after its close, and the close
mapped the pieces the next open reaches on the engine thread. With TF_KV_SHARE
a keep holds the pages it used, so the free pages past them are fresh and a
request's last token waited for a 256 MiB piece to be created, mapped and
zeroed. The arena now hands those pieces to one thread with the load context
current, which maps them and zeroes each on its own stream before marking it.
An ensure that needs a piece the thread has in hand waits for it, one still
queued it maps itself, and one the thread failed to map goes back unmarked and
maps on the caller as before. The load's map-ahead stays on the caller. Same
pieces, zeroing and addresses, so the same bits. Host tests: a posted piece
maps off the caller's thread and is waited for, never mapped twice; a piece the
thread fails to map is mapped by the caller.
test-gradient-serving pins the bytes of the serving kernels and launch
wrappers. This branch changes four of them: flashnext_attn.cu (page ids
loaded a tile ahead), memory.zig (DeviceBuffer.allocVia), and
cuda_lanes.zig / cuda_lanes_load.zig (the page pool's arena opens with the
load's device and io). Each row takes its file's new hash, with the change
named beside it.
@ashhart

ashhart commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thanks @Arminova. This is careful work, and the receipt is thorough: 40 of 40 token hashes in each of the four comparisons, concurrent equal to solo, and the slow cells weighed against what two equal builds would show.

What I checked:

  • The kernel reads the same pages. In attn_chunks (zig/kernels/cuda/flashnext_attn.cu:280-289 and :322), each load writes key_ids and key_at for the tile it is given. k1 is that tile's key, p1 its page id, and page_at (flashnext_pages.cuh:15) forms the address page_row did. The look-ahead only reads IDS entries below n and table entries for keys below n (or entry 0), and a value read past the last tile is never used. Only fn_attn_chunks_ahead_paged (:545) takes this path, and :277 keeps FUSE out. So replies can't change from it.
  • A failed map. A stream's pages are all taken at open (cuda_kv_lanes.zig:208), so the arena only maps inside open, never mid-decode. A map that fails returns LaneMemory, the errdefers give back the run and the lease, and the lane host requeues the request, or fails it as out of memory when nothing else is running. That matches what the description says.
  • No fault. Kernels read pages only through the table. Upload.write uploads it after its pieces are mapped (:296), and mapStates maps the run (:227) before clearSeq writes the states.
  • Default path. Without TF_FLASHNEXT_KV_PAGES the pool isn't built, and DeviceBuffer.alloc is allocVia with the installed hook, as before.

What needs to change:

  1. One change per PR. This is two independent changes: the page-id look-ahead in the paged kernel, and the arena mapped as it is used. Please split them, each with its own receipt. The tables here measure both together. On pages, decode is within ±0.1 ms a token, so the kernel half's own gain isn't shown yet. Please show what it buys alone (paged decode at 32K-64K, or the kernel's own time), since it costs two registers.
  2. zig-windows fails on this head, from this change. Arena.alloc (cuda_kv_arena.zig:251) calls cuda.arena.Vmm.open, which opens libcuda.so.1 through std.DynLib (zig/src/cuda/arena.zig:241). The Windows test build can't compile that (dynamic_library.zig:22: unsupported platform), and main's Windows build never reached it. Please keep the pieces path to Linux at comptime, with one cuMemAlloc elsewhere, as today.
  3. The kernel's records. The description says CUDA 13.0.88 compiles the kernel to 79 registers on sm_121, and the limits gate passed "as recorded". But the 13.0 row (flashnext_pages_pending.zig:9) records 72 for sm_120 and sm_121, and 79 sits in the 13.3 row (nvcc_133_records.zig:93). The description also says 13.3 wasn't run. Please check the two rows aren't swapped, and say which compiler produced each. If you have no nvcc 13.3, leave main's 13.3 row as it is and say so: docs/kernel-contract.md keeps a 13.0 number out of a later release's row.
  4. Maps on the engine thread. A piece first reached by an open is mapped and zeroed on the engine thread: cuMemCreate, cuMemMap and cuMemSetAccess for 256 MiB, a 256 MiB memset on the engine stream, then the synchronize in Upload.write (cuda_kv_lanes.zig:298). Ahead.ensure can also wait on maps (cuda_kv_arena.zig:143) while the helper maps another piece. Other streams' rounds wait behind that open, and the nsys trace covers one stream only. Please post, main on pages against this branch, a fresh server with one stream decoding while a second opens a long prompt into unmapped pieces: the first stream's longest gap between tokens.
  5. A test at the pool. The arena tests drive Lazy and Ahead with a fake mapper. Please add one that opens a lane through Pool.open with a mapper that fails, and checks that open returns LaneMemory and leaves the pool idle, with the run and pages back.
  6. The CHANGELOG line gives the first request 75 ms sooner and about 5% faster, which is the warmed n-gram case. The speed section shows the first 2K request after a start at -5 to +29 ms with the table evicted. Please keep the line to the memory left free at load, or give both numbers with their conditions.

Once these are met, we'll run main against the branch on Qwen3.8-Flash-Next NVFP4 on a GB10.

  • Synthena (Ash's Agent)

…age-id look-ahead leaves this change

The look-ahead in fn_attn_chunks_ahead_paged (0fd55aa) is a change of its own; it is undone
here, with its two register records and its serving pin back to main's, so this change is the
page pool's arena alone. The kernel's code, records and pin row equal main 6c5cef8's.
…where it is one cuMemAlloc, as before

The pieces open libcuda.so.1 through std.DynLib, which has no open on Windows, so the Windows
build stopped at Arena.alloc. The pieces path is now compiled for Linux only; on other systems
the arena is one cuMemAlloc, as it was before this change.
…fails: LaneMemory, the pool idle

Pool.open with an arena whose VMM refuses cuMemCreate returns LaneMemory; afterwards no lane is
used, the pool's counts equal the ones before the open, and a lane, its pages and a run fit
again. Arena.Pieces is public for it.
…thread to map its pieces

An open whose run or pages reached pieces not mapped yet mapped them on the engine thread, so
every running stream's round waited for it (about 9.5 ms a piece). Now, while other streams run,
the first prompt step posts those pieces to the arena's thread and returns without progress; the
other streams' rounds go on between steps, and the open comes once the pieces are mapped. Alone,
the open maps them itself as before, since nothing waits on it. A piece the thread failed to map
is mapped by the open itself, so a failure still ends as LaneMemory, not as a wait.
…ode-ahead

# Conflicts:
#	zig/tests/fixtures/gradient-serving.tsv
@Arminova Arminova changed the title flashnext cuda: paged decode loads page ids a tile ahead, and the page pool maps its arena as pages are used flashnext cuda: the page pool maps its arena as streams use it, on a helper thread while other streams run Nov 13, 2026
@Arminova

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. All six points are in; the branch has new commits on top (no force-push):

  1. One change per PR. This PR is now the arena alone: 478152bea undoes the page-id look-ahead, so
    flashnext_attn.cu, flashnext_pages.cuh, both register records and the kernel's serving pin are main's again.
    What the look-ahead buys alone (main against main plus only that commit, pages on, one stream, a fresh server each
    run, mirrored order): with graphs off and a kernel trace of one 64K-token request (512 tokens, 2,924 calls), the
    kernel takes 46.4 against 49.8 us a call (-7 %) and 112.2 against 121.6 us (-8 %) in runs that otherwise match; its
    whole time is ~0.3 % of decode, so ~0.02 ms a token. Decode with graphs on, four runs a side, 1,024 tokens: 32K
    21.81 against 21.90 ms a token (runs 21.54-22.08 against 21.55-22.05), 64K 19.60 against 19.74 (19.37-19.85
    against 19.34-19.99). That is not a gain worth two registers, so the look-ahead is out of this PR and we are not
    sending it separately.

  2. zig-windows. fee044bfb compiles the pieces path on Linux only (comptime); elsewhere the arena is one
    cuMemAlloc, as before. test-cuda-host builds its tests for the host, so to check it from Linux I pointed that
    step's target at x86_64-windows in a scratch copy: the head before the fix gives the CI's errors (18 in that build:
    dynamic_library.zig unsupported platform / no close / no lookup, from arena.zig's open), the fix and the
    head none. Linux, on the head with main merged: zig build test, test-cuda-host, test-gradient-serving,
    test-qwen-serving-host, compile-config-server, zig fmt --check and git diff --check exit 0; each new commit compiles
    and passes zig fmt on its own.

  3. The records. The description was wrong: the kernel was compiled in our build image by nvcc 13.3.73, not by
    the host's 13.0.88, so 79 was 13.3's number. With the look-ahead out, both rows are main's again.

  4. Maps on the engine thread. Main on pages against this branch, a fresh server each, two servers a side in
    mirrored order: one stream decodes 3,072 tokens from a 2K prompt; 5 s in, a second stream opens a 64K prompt (on this
    branch, into pieces not mapped yet), then the same again on the same server (those pieces now mapped). The first
    stream's reply is the same in all eight runs. Its gaps between tokens, ms:

    longest gap holding the second open, first run same, second run before the second stream, p50 / p99
    main 938.9-943.9 893.8 / 839.6 767.2 / 772.2 48.4-50.9 / 72.5-77.2
    this branch 931.9-942.3 852.9 / 841.7 773.2 / 774.8 48.3-49.5 / 72.5-75.2

    The longest gaps are the 64K prompt's chunks, on both. But a trace of the same test on this branch showed what you
    described: on a fresh server the 64K open mapped 7 pieces on the engine thread (66.8 ms in cuMemCreate / cuMemMap /
    cuMemSetAccess), and a 120K open after it 6 more (58.4 ms); here they hid inside
    the first prompt chunk, but every round waited on them. 273363f77 removes that: when other streams are running and
    an open would reach pieces that are not mapped, it posts them to the arena's thread and the open waits in the fill
    queue (a fill step with no progress) while the other streams' rounds go on; it opens once they are mapped. Alone,
    the open maps them itself as before (nothing waits on it). A piece the thread cannot map is mapped by the open
    itself, so a failure still ends as LaneMemory. The same traced test on the new head: no map call on the engine
    thread at either open; the arena's thread maps the 7 and 6 pieces (128 and 124 ms there); the first stream's gaps right after the second stream's request 58, 60,
    53 ms (normal rounds), against one 896 ms gap that held the open before. Against main, three fresh servers a side
    in alternating order (main, branch, branch, main, main, branch), the same test with a 64K and then a 120K second
    stream: the second stream's first token came 37.5-37.6 s on this branch against 38.8-39.5 s on main (64K) and
    71.1-71.4 against 71.7-73.5 s (120K); the first stream's three gaps right after the second request were 40-58 ms
    on this branch in all six runs, while on main a prompt chunk (770-1,261 ms) fell inside them in all six; its
    longest gap 948-983 ms against 994-1,323 ms. Its reply was the same in all twelve.

  5. A test at the pool. 5cffcbbf9: Pool.open with a VMM whose cuMemCreate fails returns LaneMemory; afterwards no
    lane is used, the pool's counts equal the ones before the open, and a lane, its pages and a run fit again. Removing
    the run's errdefer in open makes it fail (at the counts check). For point 4's change, two more: at the arena
    (a slow, then a failing mapper: posted at once, mapped by the thread; a piece it failed comes back to the caller),
    and at the pool (a 50 ms VMM: beside a running stream the gate returns in under 25 ms and the thread maps; the
    open's maps after it run nothing on the caller's thread); a gate that lets the open go early makes it fail.

  6. The CHANGELOG line now gives only the memory left free at load, measured on this head: about 14 GiB
    (MemAvailable 40.8-41.6 GiB against 26.5-27.0). The first description's 42.5-43.0 GiB came from an earlier
    commit of this branch, before the load mapped the three lanes' state runs and the next two pieces; corrected.

Main ab78aef is merged in (b710d1a83; one conflict, the cuda_lanes.zig pin row next to #1953's, resolved to the
merged file's hash with both notes); the serving pins pass on it.

Full receipt on that head (b710d1a83; same code as the head with the CHANGELOG commit), main ab78aef against it,
both on pages:

  • Your test sequence (base, new, new, base; a fresh server each; 262,144 x 3 with the prompt cache off and the
    n-gram table evicted, then 32,768 x 8 with the cache on and the table warm): replies equal in every row, no row
    missing, no slow first request. Every cell within -1.0 to +1.3 %. In two single-stream decode cells both branch
    runs are below both main runs, by 1.0 % (2K from 10,000: 71.0 / 70.8 against 70.5 / 69.9 tok/s; 8K:
    39.3 / 39.3 against 38.8 / 39.0); the same two cells in the second pass +0.2 % each, and in the two
    earlier runs of this sequence on this PR's arena +0.3 / -0.4 % and -0.4 / -0.2 %, with main's own two runs 0.5-0.9
    tok/s apart there. Decode at 4 and 8 streams
    +0.7 to +1.3 % in six of the eight cells, -0.1 / +0.1 % in the other two.
  • Token equality on pages, 20 rows greedy and sampled, 128 tokens: main == this head 40 of 40; drafted ==
    "draft": false 40 of 40; 4 concurrent streams (code / chat, T 0 / 1) each equal to the request alone and serial.
  • Memory after load at 262,144 x 3 (four starts a side, the table warmed): MemAvailable 40.8-41.6 GiB on this
    head against 26.5-27.0 on main. Of the 15.75 GiB arena, the three lanes' state runs and the next two pieces are
    mapped at load (about 1.5 GiB); the rest as streams reach it.

@ashhart

ashhart commented Nov 14, 2026

Copy link
Copy Markdown
Owner

Thanks @Arminova. Removing the kernel look-ahead, keeping pieces Linux-only, correcting the compiler account, adding the failed-open test, moving shared-open maps off the engine thread and narrowing the CHANGELOG answer all six points. The added arena/pool tests pass.

Four new lean findings remain in three comment blocks:

  1. zig/src/families/flashnext/cuda_kv_arena.zig:124: rewrite the two-line comment as one line of at most 120 columns.
  2. zig/src/families/flashnext/cuda_kv_init_test.zig:286: shorten this comment to one line too.
  3. zig/src/families/flashnext/cuda_kv_lanes.zig:198: replace the three-line comment with one short line; it accounts for two findings.

That should bring lean from 345 back to main's 341. Afterward, we'll check the resolved serving pins and schedule the final GB10 memory/concurrent-open run.

  • Synthena (Ash's Agent)

This branch has not been deployed

No deployments
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