Skip to content

DeepSeek-V4.1 on Metal: opt-in shared context pool, TF_DSV41_POOL_TOKENS (stacked on #2983) - #3278

Draft
gilby wants to merge 27 commits into
ashhart:mainfrom
gilby:dsv41-metal-shared-pool
Draft

gilby wants to merge 27 commits into
ashhart:mainfrom
gilby:dsv41-metal-shared-pool

Conversation

@gilby

@gilby gilby commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

DeepSeek-V4.1 on Metal: the new opt-in setting TF_DSV41_POOL_TOKENS lets all slots share one pool of positions for the compressed latents and index keys, instead of each slot holding --context positions. With --parallel 4 --context 1048576 TF_DSV41_POOL_TOKENS=1048576 the four slots share 1,048,576 positions and any one request may still use the whole window, so the server holds one 1M request or four 256K ones in 8.6 GB less GPU memory. Unset (the default) the server allocates and logs exactly as on the base.

How it works:

  • Each KV source has one latent buffer and one index-key buffer for all slots, in blocks of 256 positions. A request takes a contiguous run and its slot binds the buffers at the run's first row (a buffer offset). The kernels see the same contiguous per-stream rows, so no kernel changes and a row's bits do not depend on where it lives.
  • A request reserves its prompt, its reply limit as the server enforces it (max_tokens, or the server's default of 4096 when the client omits it, plus --thinking-reserve when thinking) and 16 rows of drafts, cut to the window. While too few positions are free it returns LaneMemory, so LaneHost requeues it at the head and admits nothing new until a stream ends.
  • Runs are placed first fit. When the free blocks are split, live runs slide down in start order between rounds (a host copy, no GPU work in flight), so a request waits only while too few blocks are free in all.
  • Blocks a request wrote are zeroed before the next run reads them, so a run reads what a freshly cleared slot read.
  • The window ring, carry, DSpark taps and prompt tail stay per slot. Pooled bytes are 2,730 a position at the release's shapes.
  • At load the setting is refused by name below 256 and above --parallel x --context, and when the pool and slot rings pass the GPU working set. The window is cut to the pool when the pool is smaller. The startup line names the pool's positions, bytes a position and MiB.

Checks on this head

  • Head commit: 0b285e817. Every receipt below was measured at 03b4cc564; the two commits after it change the CHANGELOG line and no code, so git diff 03b4cc564..0b285e817 touches CHANGELOG.md only.
  • Lean checker, python3 tools/zig/lean_check.py, on the base b471c9479 and on this head: 345 problems on both, 0 new (tools/zig/lean_new.py). The 345 are inherited.
  • Tests run, with the command and pass, fail and skip counts:
    • zig build test-dsv41-metal-host (new step, no device): 32 pass, 0 fail, 0 skip. The same package tests on the base, run through the same step added to a copy of the base tree, are 13 pass: the allocator, setting, byte-level move and alignment tests are new and cannot build on the base because pool.zig does not exist there.
    • zig build test-dsv41-metal-pool (new step, device; not part of test-dsv41-metal-all): 4 PASS lines on M5 Ultra, 512 GB and 4 on M3 Ultra, 512 GB at this head, and the build reports 5 tests passed, 0 failed, 0 skipped (M3 Ultra log). The 4 lines: 5 requests over 8 blocks with runs sliding down and a full pool waiting, 0 replies differ from unpooled serial; 4 requests of 3 blocks over 8 through LaneHost, 0 differ; slot 19 of 20 takes a run of 2 blocks sized by its max new tokens (2 expected, 6 the whole window); with the prompt cache on, 0 of 6 replies differ from serial, runs slid down and a kept state restored into a new run. The M5 Ultra log in the gist shows the same 4 PASS lines; its exit status was not recorded.
    • zig build test-dsv41-metal-all (device): 1,138 PASS lines and no failure on this head and 1,138 on the base, on both chips.
    • zig fmt --check build.zig zig and git diff --check b471c9479..03b4cc564: pass.
    • zig build test test-golden on a development Mac: the same single failure on the base and on this head, dashboard_test "dashboard page polls the public stats snapshot without private details" (failed without output; outside the DeepSeek Metal package, so inherited here). Neither run printed a build summary, so no suite-wide pass count is quoted; CI on the pushed head decides.
  • CI: to be read on the pushed head once it has run; I will classify each failure in a comment.
  • Not run, and why: the full suite's counts (above); CUDA paths (the change is in families/deepseek_v41/metal only); other Apple chips (M1 to M4, M5 Max); a prompt-cache run at the pooled capacity beyond the device test and the 45K resume below.

Receipt

  • Main against branch: tables below, base b471c9479 against this head 03b4cc564, same machine, checkpoint and flags on each chip. The base is the head dsv41 metal: decode experts decode an expert's weights once for all of its rows (stacked on #1720, #2973) #2983 had when this was built.
  • Boxes it reaches: DeepSeek-V4.1 on Metal only (families/deepseek_v41/metal). CUDA's use of TF_DSV41_POOL_TOKENS (its paged pool) is untouched.
  • New switch? TF_DSV41_POOL_TOKENS: already declared for CUDA in platform/environment.zig (scope dsv41, u64), read through that registry on Metal. Default unset or blank: each slot holds --context positions as before. Accepted: a whole number from 256 through --parallel x --context; any other text stops the start with the setting's name (refusal texts below). Documented in docs/environment.md (the row now covers Metal); the CHANGELOG Unreleased line is quoted at the end.
  • Environment: this head 03b4cc564 and base b471c9479; Zig 0.17.0 (.zig-version); the Metal sources are compiled at load by the system toolchain, as on the base; Mac Studio M5 Ultra, 512 GB (macOS 27.2.1 (26B131), Metal compiler 383.1) and Mac Studio M3 Ultra, 512 GB (macOS 26.6.2 (25G83), Metal compiler 373.7), the OS and compiler lines as recorded in the earlier receipts on this stack (these logs do not repeat them; Xcode is not recorded); checkpoint deepseek-ai/DeepSeek-V4.1-Flash-1017 revision 1bae0e1088993b9b82de08f59de5be5a34e42170, release weights as published, with its Engram tables; server --context 1048576 --parallel 4 --prompt-cache-gib 0 (--prompt-cache-gib 8 only in the resume test), --vision --no-update-check, TF_EXPERIMENTAL=deepseek, one fresh server per cell. Server binaries (sha256 prefix): M5 Ultra this head 44c2dc372d97, base 58850a7193f7 (and 730ee1cd7f6d for 655a62ef5); M3 Ultra this head 33ad0738dce0, base 4c65c90c6a57. Commands and drivers are in the gist.
  • Which head each result was measured at. At 03b4cc564 on both chips: startup, exactness, cold prompts, the resume test, both speed series and the device tests; on M5 Ultra also the capacity cells (900K, 4 x 250K, staggered, 1.04M); on M3 Ultra the scaled-down capacity cells. At the previous head 655a62ef5, on M5 Ultra only: the 10-server cold-prompt alternation at 32K and 64K and the two-waves-of-four-200K and no-max_tokens cells. The three commits between them are per-slot max-new sizing (a1a56ee84, which changes nothing below the seventeenth slot, so four slots take the same path), byte-level host tests plus a device-test case (bab88ced7; its only non-test change makes two pool helpers pub) and docs (03b4cc564). The 655a62ef5 cells are quoted as measured there and were not re-run.
  • Paths and platforms the change reaches, which I ran and which I could not: prefill (whole), decode windows alone and with other slots, drafts (DSpark on in every server), prompt-cache save and restore (the resume test and the device test with the cache on), the startup line and load refusals, slot sharing under FIFO waiting and compaction. Ran on M5 Ultra, 512 GB and M3 Ultra, 512 GB. Not run: other Apple chips, prompts above 1.04M tokens, more than four slots on a real server (the slot-19-of-20 case is the device test above).
  • Exactness, from tools/bench_concurrent.py --alone --serial and token_sha256 (all token hashes compared across base, this head unset and this head pooled; failed requests and --alone / --serial mismatches counted over every run):
    • bench_concurrent --tokens 256 --reps 3 --temperatures 0,1 --levels 1,4 --alone --serial on M5 Ultra and the same with --tokens 128 on M3 Ultra: base, unset and pooled, 8 cells (code and chat, T 0 and 1, 1 and 4 streams). The 60 hashes of each arm are equal across the three arms in all 8 cells on both chips; 0 failed, 0 --alone and 0 --serial mismatches.
    • Cold first request at 2,048, 8,192 and 32,768 tokens: token_sha256 equal across base, unset and pooled on both chips (table below).
    • Resumed equals fresh at about 45K tokens, both chips: a 3-turn conversation with --prompt-cache-gib 8 (turn 3 resumed from 30,202 cached tokens on M5 Ultra and 30,219 on M3 Ultra) against the same final prompt sent cold to a fresh server. The reply hashes are equal (M5 Ultra 1b66eb0a7bb16026, M3 Ultra da47341947d2eed7), unset and pooled give the same hashes in all three turns, and every reply holds the right needle.
    • Staggered refill: the two 150K replies (M5 Ultra) and the two 60K replies (M3 Ultra) generated while a larger request waited and entered equal the same prompts run alone (reply sha256 equal). The server logs no line when runs move; the direct evidence that runs slid down is the device test (runs slid down: true, twice).
  • Main against this branch, same machine and checkpoint, posted before asking for a run on our machines: below, from my own machines; the base is first and last in every row.
  • Decode speed before and after, from tools/bench_openai.py --tokens 256 --reps 3 --temperatures 0,1 and tools/bench_concurrent.py --tokens 128 --reps 3 --temperatures 0,1 --alone --serial at 1 to 4 streams (M5 Ultra) or 1 and 4 streams (M3 Ultra). Two series on each chip: A, TF_DSV41_POOL_TOKENS unset; B, pooled with 1,048,576. Per series: one discarded run, then base, this, this, base, each on a fresh server per piece (M5 Ultra) or a fresh server per arm (M3 Ultra); a cell starts at Nominal thermal pressure after a settle and its end level is recorded. Noise rule of DeepSeek-V4.1 on Metal: decode and prompt speed on M3 Ultra and M5 Ultra, token-exact (gate and order for the drafts) #2984: slower only when both this runs are under both base runs and the mean gap exceeds 1% (faster is the mirror image; for first-token times the directions swap). Columns are tok/s (bench_openai decode median, bench_concurrent aggregate) or seconds for the cold rows; "this / base" is the mean over the mean.
    • Result: 0 cells slower of 70 in the four series. Decode and aggregate cells (M5 Ultra 20 a series) are 0.998 to 1.001 (unset) and 0.998 to 1.001 (pooled) of base; M3 Ultra (12 a series) 0.998 to 1.001 and 0.999 to 1.009. Every token hash is equal and every --alone / --serial count is 0 in every cell.
    • Two cells carry a "faster" verdict and neither is a gain: M5 Ultra series A cold 8K (base 1 read 10.44 s, an outlier; base 2 read 9.43 s, equal to both this runs at 9.42 s) and series B cold 32K (32.51 and 32.64 s for base against 31.87 and 31.46 s; the run-to-run spread of this cell is about 2%, see the 10-server alternation below). M3 Ultra series B has the largest decode ratios, 4 streams T 0 chat 1.009 and T 1 chat 1.008, both within noise by the rule (gap under 1%); I do not claim a gain.
    • Thermal end levels are in the last column. Every kept cell ended Nominal or Moderate on both chips and none was discarded for thermal reasons (the M5 Ultra driver accepted a cell whatever its end level and none ended Heavy). The only Heavy readings in this receipt are the M3 Ultra 32K cold first tries (discarded and re-run), two M5 Ultra 64K cold servers, one M5 Ultra no-max_tokens cell and one M3 Ultra reference cell, each named where it appears.

M5 Ultra, 512 GB, series A (unset against base):

cell base 1 this 1 this 2 base 2 this / base (mean over mean) verdict end level (b1/t1/t2/b2)
bench_openai fibonacci-raw, T 0 76.3 76.2 76.2 76.2 1.000 within noise Nom/Nom/Nom/Nom
bench_openai gpu-chat-no-think, T 0 60.1 60.0 60.1 60.0 1.000 within noise Nom/Nom/Nom/Nom
bench_openai fibonacci-raw, T 1 72.1 72.0 72.0 72.2 0.998 within noise Nom/Nom/Nom/Nom
bench_openai gpu-chat-no-think, T 1 60.1 60.2 60.1 60.1 1.000 within noise Nom/Nom/Nom/Nom
1 stream, T 0, code 65.3 65.2 65.3 65.3 0.999 within noise Mod/Mod/Mod/Mod
2 streams, T 0, code 95.3 95.0 95.2 95.3 0.998 within noise Mod/Mod/Mod/Mod
3 streams, T 0, code 98.4 98.4 98.4 98.4 1.000 within noise Mod/Mod/Mod/Mod
4 streams, T 0, code 116.7 116.7 116.7 116.6 1.000 within noise Mod/Mod/Mod/Mod
1 stream, T 0, chat 59.8 59.7 59.7 59.7 0.999 within noise Mod/Mod/Mod/Mod
2 streams, T 0, chat 88.3 88.1 88.1 88.1 0.999 within noise Mod/Mod/Mod/Mod
3 streams, T 0, chat 95.2 95.1 95.2 95.2 0.999 within noise Mod/Mod/Mod/Mod
4 streams, T 0, chat 111.6 111.7 111.7 111.6 1.001 within noise Mod/Mod/Mod/Mod
1 stream, T 1, code 74.9 74.8 74.8 75.0 0.998 within noise Mod/Mod/Mod/Mod
2 streams, T 1, code 74.5 74.4 74.4 74.5 0.999 within noise Mod/Mod/Mod/Mod
3 streams, T 1, code 80.2 80.1 80.1 80.2 0.999 within noise Mod/Mod/Mod/Mod
4 streams, T 1, code 90.6 90.4 90.3 90.4 0.998 within noise Mod/Mod/Mod/Mod
1 stream, T 1, chat 66.4 66.2 66.3 66.3 0.998 within noise Mod/Mod/Mod/Mod
2 streams, T 1, chat 78.1 77.9 78.0 78.0 0.999 within noise Mod/Mod/Mod/Mod
3 streams, T 1, chat 79.1 79.0 79.1 79.1 0.999 within noise Mod/Mod/Mod/Mod
4 streams, T 1, chat 89.4 89.4 89.5 89.5 1.000 within noise Mod/Mod/Mod/Mod
cold 8,192-token prompt, first token (s) 10.44 9.42 9.42 9.43 0.948 faster Nom/Nom/Nom/Nom
cold 32,768-token prompt, first token (s) 31.53 31.59 31.49 31.69 0.998 within noise Nom/Nom/Nom/Nom

M5 Ultra, 512 GB, series B (pooled against base):

cell base 1 this 1 this 2 base 2 this / base (mean over mean) verdict end level (b1/t1/t2/b2)
bench_openai fibonacci-raw, T 0 76.2 76.1 76.2 76.2 0.999 within noise Nom/Nom/Nom/Nom
bench_openai gpu-chat-no-think, T 0 60.1 59.9 59.9 60.0 0.998 within noise Nom/Nom/Nom/Nom
bench_openai fibonacci-raw, T 1 72.1 72.0 72.3 72.1 1.001 within noise Nom/Nom/Nom/Nom
bench_openai gpu-chat-no-think, T 1 60.0 60.0 60.2 60.1 1.001 within noise Nom/Nom/Nom/Nom
1 stream, T 0, code 65.2 65.3 65.3 65.3 1.001 within noise Mod/Mod/Mod/Mod
2 streams, T 0, code 95.2 95.2 95.1 95.3 0.999 within noise Mod/Mod/Mod/Mod
3 streams, T 0, code 98.4 98.5 98.5 98.4 1.001 within noise Mod/Mod/Mod/Mod
4 streams, T 0, code 116.7 116.7 116.8 116.8 1.000 within noise Mod/Mod/Mod/Mod
1 stream, T 0, chat 59.8 59.8 59.7 59.9 0.998 within noise Mod/Mod/Mod/Mod
2 streams, T 0, chat 88.2 88.2 88.1 88.3 0.999 within noise Mod/Mod/Mod/Mod
3 streams, T 0, chat 95.2 95.2 95.3 95.2 1.001 within noise Mod/Mod/Mod/Mod
4 streams, T 0, chat 111.7 111.6 111.7 111.7 1.000 within noise Mod/Mod/Mod/Mod
1 stream, T 1, code 74.9 74.9 74.9 74.9 1.000 within noise Mod/Mod/Mod/Mod
2 streams, T 1, code 74.5 74.5 74.5 74.5 1.000 within noise Mod/Mod/Mod/Mod
3 streams, T 1, code 80.2 80.2 80.2 80.2 1.000 within noise Mod/Mod/Mod/Mod
4 streams, T 1, code 90.5 90.2 90.5 90.4 0.999 within noise Mod/Mod/Mod/Mod
1 stream, T 1, chat 66.3 66.4 66.5 66.6 1.000 within noise Mod/Mod/Mod/Mod
2 streams, T 1, chat 78.0 78.1 78.0 78.1 1.000 within noise Mod/Mod/Mod/Mod
3 streams, T 1, chat 79.2 79.1 79.2 79.1 1.000 within noise Mod/Mod/Mod/Mod
4 streams, T 1, chat 89.5 89.5 89.5 89.5 1.000 within noise Mod/Mod/Mod/Mod
cold 8,192-token prompt, first token (s) 9.83 10.31 10.04 10.10 1.021 within noise Nom/Nom/Nom/Nom
cold 32,768-token prompt, first token (s) 32.51 31.87 31.46 32.64 0.972 faster Nom/Nom/Nom/Nom

M3 Ultra, 512 GB, series A (unset against base):

cell base 1 this 1 this 2 base 2 this / base (mean over mean) verdict end level (b1/t1/t2/b2)
bench_openai fibonacci-raw, T 0 57.5 57.5 57.5 57.6 1.000 within noise Mod/Mod/Mod/Mod
bench_openai gpu-chat-no-think, T 0 46.1 46.0 46.0 46.0 0.999 within noise Mod/Mod/Mod/Mod
bench_openai fibonacci-raw, T 1 54.9 54.9 54.9 55.0 0.999 within noise Mod/Mod/Mod/Mod
bench_openai gpu-chat-no-think, T 1 46.5 46.5 46.4 46.7 0.998 within noise Mod/Mod/Mod/Mod
1 stream, T 0, code 49.6 49.6 49.6 49.6 1.000 within noise Mod/Nom/Nom/Nom
1 stream, T 0, chat 45.9 45.9 45.9 45.9 1.000 within noise Mod/Nom/Nom/Nom
1 stream, T 1, code 55.1 55.2 55.2 55.2 1.001 within noise Mod/Nom/Nom/Nom
1 stream, T 1, chat 50.2 50.2 50.2 50.2 1.000 within noise Mod/Nom/Nom/Nom
4 streams, T 0, code 86.4 86.4 86.5 86.5 1.000 within noise Mod/Mod/Mod/Mod
4 streams, T 0, chat 81.1 81.1 81.1 81.1 1.000 within noise Mod/Mod/Mod/Mod
4 streams, T 1, code 66.0 66.1 66.0 66.0 1.001 within noise Mod/Mod/Mod/Mod
4 streams, T 1, chat 64.1 64.0 64.0 64.1 0.998 within noise Mod/Mod/Mod/Mod
cold 8,192-token prompt, first token (s) 16.08 16.10 16.06 16.17 0.997 within noise Nom/Nom/Nom/Nom

M3 Ultra, 512 GB, series B (pooled against base):

cell base 1 this 1 this 2 base 2 this / base (mean over mean) verdict end level (b1/t1/t2/b2)
bench_openai fibonacci-raw, T 0 57.6 57.6 57.5 57.6 1.000 within noise Mod/Mod/Mod/Mod
bench_openai gpu-chat-no-think, T 0 46.0 46.0 46.0 46.0 1.000 within noise Mod/Mod/Mod/Mod
bench_openai fibonacci-raw, T 1 55.1 55.0 55.0 54.9 0.999 within noise Mod/Mod/Mod/Mod
bench_openai gpu-chat-no-think, T 1 46.4 46.4 46.5 46.4 1.000 within noise Mod/Mod/Mod/Mod
1 stream, T 0, code 49.5 49.5 49.7 49.6 1.001 within noise Mod/Nom/Nom/Nom
1 stream, T 0, chat 45.9 45.8 45.9 45.9 0.999 within noise Mod/Nom/Nom/Nom
1 stream, T 1, code 55.1 55.2 55.3 55.2 1.002 within noise Mod/Nom/Nom/Nom
1 stream, T 1, chat 50.2 50.2 50.3 50.3 1.000 within noise Mod/Nom/Nom/Nom
4 streams, T 0, code 86.5 87.0 87.0 86.9 1.003 within noise Mod/Mod/Mod/Mod
4 streams, T 0, chat 81.1 81.7 81.6 80.8 1.009 within noise Mod/Mod/Mod/Mod
4 streams, T 1, code 66.1 66.2 66.2 66.1 1.002 within noise Mod/Mod/Mod/Mod
4 streams, T 1, chat 63.9 64.5 64.5 64.1 1.008 within noise Mod/Mod/Mod/Mod
cold 8,192-token prompt, first token (s) 16.12 16.09 16.17 16.21 0.998 within noise Nom/Nom/Nom/Nom

End level key: Nom = Nominal, Mod = Moderate, Heavy = Heavy. The cold rows are the measured request on a fresh server after one warm-up request (M5 Ultra: a different prompt of the same length; M3 Ultra: a 2,048-token prompt), both cold with --prompt-cache-gib 0: cache state cold and 0 cached tokens in every measured request. The token hashes in them are equal across all four runs.

  • Prompt speed before and after: the cold first request's ttft_s from tools/bench_cache.py --require-cold --tokens 64 --repeats 1, one run per arm (single runs: no verdict is drawn from them; the series above carry the verdicts):
chip prompt tokens base this, unset this, pooled token_sha (all three) end level (base/unset/pooled)
M5 Ultra 2,048 5.11 5.07 5.08 330cd07dd3cf Nom/Nom/Nom
M5 Ultra 8,192 11.04 11.99 11.06 b2da1b2b327d Nom/Nom/Nom
M5 Ultra 32,768 34.09 34.19 34.07 7cfb5e60de47 Nom/Nom/Nom
M3 Ultra 2,048 7.90 7.91 7.95 330cd07dd3cf Nom/Nom/Nom
M3 Ultra 8,192 16.02 15.98 15.97 b2da1b2b327d Nom/Nom/Nom
M3 Ultra 32,768 44.40 44.37 44.38 0e3f2ebade35 Mod/Mod/Mod

On M3 Ultra the 32,768-token cells ended Heavy on the first try, were discarded and re-run once; the second runs ended Moderate and are the ones shown (the discarded first tries are in the gist). Longer cold prompts, M5 Ultra at the earlier head 655a62ef5: 10 fresh servers in the order base, pooled, unset, pooled, base, unset, pooled, base, unset, pooled, the first token of the measured cold request, one hash across all ten:

prompt tokens server order (arm, first token in s, end level) base mean (range) unset mean (range) pooled mean (range) unset / base pooled / base verdict
32,768 1 base 32.15 Nom; 2 pool 31.46 Nom; 3 unset 32.29 Nom; 4 pool 31.45 Nom; 5 base 31.66 Nom; 6 unset 32.40 Nom; 7 pool 31.46 Nom; 8 base 31.68 Nom; 9 unset 31.72 Nom; 10 pool 33.07 Nom 31.83 (31.66 to 32.15) 32.14 (31.72 to 32.40) 31.86 (31.45 to 33.07) 1.010 1.001 not slower, not faster
65,536 1 base 61.78 Heavy; 2 pool 61.37 Heavy; 3 unset 61.44 Nom; 4 pool 64.02 Nom; 5 base 63.44 Nom; 6 unset 63.51 Mod; 7 pool 61.31 Nom; 8 base 63.57 Nom; 9 unset 61.73 Nom; 10 pool 61.78 Nom 62.93 (61.78 to 63.57) 62.23 (61.44 to 63.51) 62.12 (61.31 to 64.02) 0.989 0.987 not slower, not faster

The mean gaps are +0.97% (unset) and +0.10% (pooled) at 32K and -1.12% and -1.29% at 64K, so the rule gives not slower for both. The first two 64K servers ended Heavy and are kept (one request each).

  • Capacity (4 slots, pool 1,048,576), M5 Ultra, 512 GB, at 03b4cc564:
scenario (M5 Ultra, 512 GB, head 03b4cc564) requests result first token (s) peak GPU allocation end level
one ~900K needle alone 1 x 899,784 tokens, max_tokens 100 correct 1017.0 323.58 GB Mod
four ~250K needles sent 0.5 s apart 249,761 + 250,008 + 250,407 + 249,798 tokens 4 of 4 correct 241.6 / 481.0 / 719.4 / 959.3 323.58 GB Mod
staggered: 300K (max_tokens 100) and two 150K (max_tokens 2000, a long numbered-list reply) at once, then a 500K sent when the 300K finished 299,963 / 149,952 / 149,956 / 499,985 tokens 4 of 4 correct; the two 150K replies equal the same prompts run one at a time (reply sha256 equal) 297.0 / 438.1 / 578.4 / 509.5 323.58 GB Mod
the same two 150K prompts alone, one after the other (reference for the row above) 149,952 / 149,956 tokens correct 143.2 / 141.6 323.55 GB Mod
one ~1.04M prompt, no max_tokens (the server default of 4096 applies) 1,039,965 tokens correct 1213.7 323.66 GB Mod

At 655a62ef5:

scenario (M5 Ultra, 512 GB, head 655a62ef5) requests result peak GPU allocation end level
two waves of four ~200K prompts (max_tokens 100); the second wave sent when the first finished 8 x about 200K (199,951 to 200,003) 8 of 8 correct; first-wave first tokens 194, 383, 569, 757 s, second wave 189, 378, 572, 760 s 323.60 GB Mod
two ~100K prompts, no max_tokens 99,970 / 99,972 tokens 2 of 2 correct 323.27 GB Heavy

M3 Ultra, 512 GB, scaled down (its capacity cells were cut for time, and the four-prompt case was not run there):

scenario (M3 Ultra, 512 GB, head 03b4cc564, scaled down) requests result first token (s) peak GPU allocation end level
150K (max_tokens 100) and two 60K (max_tokens 1500) at once, then a 250K sent when the 150K finished 149,961 / 60,455 / 60,407 / 249,963 tokens 4 of 4 correct; the two 60K replies equal the same prompts run alone (reply sha256 equal) 194.0 / 284.8 / 388.2 / 464.0 323.22 GB Mod
the two 60K prompts alone (reference) 60,455 / 60,407 tokens correct 78.5 / 78.6 322.71 GB Heavy

The 900K and 1.04M requests fit a pool that is a quarter of what four whole windows take. The four 250K prompts all answer correctly because requests prefill one at a time as before, so the later ones wait in line.

  • GPU allocation after load (startup ioreg "Alloc system memory"), this head: M5 Ultra base 331,405,393,920 B, unset 331,405,410,304 B, pooled 322,817,785,856 B (8,587,608,064 B less; (4 x 1,048,576 - 1,048,830) positions x 2,730 B agrees to within 0.5 MB). M3 Ultra, read when the server came up: base 330,621,255,680 B, unset 330,621,272,064 B, pooled 322,033,614,848 B (8,587,640,832 B less); read 20 s later: 330,621,272,064 B, 330,705,911,808 B and 322,537,652,224 B. Peak allocation during the capacity cells is in the tables above (pooled, 323.2 to 323.7 GB). M5 Ultra phys_footprint after load: 31 GB base, 31 GB unset, 23 GB pooled.
  • Startup line, unset byte-identical to base: the server logs of the base and the unset arm are identical line for line except the load time, on both chips (the logs are in the gist). Unset and base: info: dsv41 metal: 40 layers, 41 shards (283238 MiB mapped, 7789 MiB copied), 4 slots of 1048576 positions, DSpark on. Pooled: info: dsv41 metal: 40 layers, 41 shards (283238 MiB mapped, 7789 MiB copied), 4 slots sharing a pool of 1048830 positions (TF_DSV41_POOL_TOKENS=1048576; a request up to 1048576; 2730 bytes a position, 2730 MiB), DSpark on.
  • Refusals (M3 Ultra, exit 1 before any allocation): error: dsv41 metal: TF_DSV41_POOL_TOKENS=100 is less than one block of 256 positions; give at least 256, or unset it for a context window a seat and error: dsv41 metal: TF_DSV41_POOL_TOKENS=4194305 is more than --parallel 4 x --context 1048576 = 4194304 positions; every seat would hold its own window: lower it or unset it, each followed by tensorfold: the DeepSeek-V4.1 Metal engine cannot load <checkpoint path> (BadPoolTokens). The working-set refusal has host-test coverage only.
  • Licence of any new weights or drafter, or "n/a": n/a (no new weights or drafter).

Known limits:

  • A request reserves its prompt plus max_tokens plus 16 rows. A client that omits max_tokens gets the server's default of 4096 (plus the thinking reserve when thinking) and reserves that much; the 1.04M request and the two 100K requests in the capacity tables were sent without max_tokens.
  • Requests prefill one at a time, as before this change.
  • Compaction is a host copy between rounds; its cost was not measured separately.
  • The staggered-refill case ran correctly on both chips, but the server logs no line when runs move, so the device test's runs slid down: true is the direct evidence that runs move under live requests.
  • M3 Ultra capacity cells were scaled down (150K, two 60K and 250K); the four-prompt case was not run there.

CHANGELOG, Unreleased, as it stands in the diff: "DeepSeek-V4.1 on Metal: TF_DSV41_POOL_TOKENS lets the slots share one pool of positions instead of --context each, so four slots can share 1,048,576 positions while any one request still uses up to --context; a request holds its prompt, max_tokens and one window of drafts, waits while too few positions are free, and its tokens equal an unpooled run; unset, memory and startup lines are as before."

Kernel contract, only when a GPU kernel is new or changed

No GPU kernel is new or changed, so there is no contract record to post. zig/kernels has the same git tree on the base and this head (git rev-parse b471c9479:zig/kernels 03b4cc564:zig/kernels prints a9cd42ebfbf9a852ce4ef6d50ea1c35381877b10 for both). The DeepSeek Metal libraries are compiled at load from that unchanged source text with unchanged options, so the compiled kernels are the same. Only the buffer offsets passed at bind time differ, which every encoder already passed.

Checklist

  • The title and description match the head commit, including the receipt and the stack.

  • New tests fail before the change (they do not build on the base: pool.zig does not exist there) and pass after it. The device step needs a Metal device and is its own named step, not part of the default zig build test.

  • Comments and docstrings are one line. No measurements or history in the source.

  • No personal data, machine names, internal hosts or local paths in the diff or its commits. No AI attribution lines.

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.

  • Drafted output equals the base's (token hashes equal in every cell with DSpark on), a resumed prompt equals a fresh one, and concurrent equals solo (--alone --serial counts 0).

  • No precision traded for speed. No sums change: kernels, their inputs and their order are unchanged; only where a row lives in a buffer differs.

  • Decode and prompt processing are no slower than main at one stream and the widest admitted (0 slower cells of 70 in four series, plus cold prompts to 64K); drafts are on in every cell, so no drafter-off comparison applies.

Raw receipts (JSON, server logs, test logs, drivers) for both chips:

M5 Ultra part 1 (with the file list) https://gist.github.com/gilby/a991a53176c009468f9cda435b303d40 , M5 Ultra part 2 https://gist.github.com/gilby/798dc2033168266f5e1940de7782769b , M3 Ultra https://gist.github.com/gilby/065a78a4757ee72203f052c22a7c9d85 . Empty files named in the list are left out.

gilby added 27 commits November 12, 2026 01:30
…over Metal

target.zig is the lanes' verify target: a slot's prompt, its windows of rows for several slots in one launch,
keeps and rewinds, and kept prompt states (the prompt cache) whose resumed rows the reply reports as cached_tokens.
boot.zig loads a release checkpoint folder and entry.zig serves it as tensorfold-dsv41 (dsv41-metal-serve).
Tests serve the tiny model through LaneHost: concurrent equals solo equals serial, drafted equals draft:false,
cache hits equal fresh runs, seeds and cancels.
…for the next

A shared round with the plain switch at it asked no drafts and ran no head
pass (core Rule.plain_streams, threaded from the family's resolved setting:
0 on CUDA, so its rounds are unchanged), and the family's speculation no
longer launches a pass for a round that wide (armSpec/speculate plainRound).
…ers per row and dispatch class

Six synthetic layers of the release's widths (one of each attention kind, few experts) are timed at
1-16 rows of one slot and at 4 slots x 1 row, each dispatch alone and the layer on the serving
encoder, and split into FP8 rows, experts, attention and glue, with a 40-layer round weighted by
kind. timing_real's report takes a launch (context and spans) so both harnesses share it.
…x tile, bit-equal to the row kernel

Launches of 6 to 16 rows run the dense FP8 projections on fp8_tile.metal: 8 or 16 columns a
threadgroup, K split over up to 16 simdgroups in aligned power-of-two runs of groups, each 32-wide
group summed from zero in four 8-wide MMA steps in k order (the prompt tile's group root), the
groups' roots and the slices' roots in the same zero-padded pair tree the row kernel uses. Every
row's bits are the row kernel's one row a launch; 1-5 rows keep the row kernel (chosen by row
count only), since below 6 rows it reads the weights faster.

Contract: six registrations (f32, bf16, a forced 16-column form, one simdgroup, the row kernel one
row a launch as replaced baseline, the release wkv fixture), four planted controls, gate-5 records
with M1/M2 limits, and an equality probe over every model FP8 shape at 1-16 rows, every form, the
16/17 seam and a batch with NaN and Inf planted. Device tests: the layer's kernel by row count, and
four slots of two rows each in one launch against each alone.
…f its rows, bit-equal to one row a launch

The decode gate/up and down for an expert's slot of 2-16 rows ran Kimi's four-row passes: two rows
paid for four rows of arithmetic, five rows for two passes of four, each rereading the weights. The
new tf_dsv41_xp_upc_rows and tf_dsv41_xp_down_rows decode each 32-group of an expert's MXFP4
weights once per pass and apply it to every row in it (passes of 2, 4, 6 or 8 rows for gate/up, 2
or 4 for down, rows past the slot repeating its last row unwritten), reading the activations
straight from device memory. Each row's chain is the one-row kernel's: 32 products in k order from
zero, then its group's scale, then the same tree and epilogue, so a row's bits are the same alone
or with any other rows and routing. Slots of one row keep tf_dsv41_xp_upc_one/down_one unchanged.

Contract: registrations with every row on the same six experts (gate/up coded and down, gates 1-4
against the oracle), one planted control a kernel (rows reading their pass's first row, red at
shapes), gate-5 records for both launches, and a probe holding a launch's act, out and combine
output bit-equal to each row launched alone at 1-16 rows on the same, disjoint and random experts,
with NaN and Inf planted, plus its own control. Device test: the same equality at 2-16 rows.
…tion blocks a seat

TF_DSV41_POOL_TOKENS is checked against --parallel and --context and cuts the per-request window to the pool. A run is placed first fit; when the free blocks are split the live runs slide down in start order, so a request waits only while too few blocks are free in all. Blocks a request wrote are zeroed before the next run reads them. Host tests cover fit, wait, refuse, reuse, sliding, dirty blocks and the setting; test-dsv41-metal-host runs the package's host checks without a device run.
…the shared pool's runs

With a pool set, each KV source has one latent buffer and one index-key buffer for every slot, and a slot binds them at its run's first row: the kernels see the same contiguous per-stream rows at a buffer offset, so no kernel changes. A prompt takes a run of its ids, its max new tokens and one window of drafts (admit), or LaneMemory while too few positions are free; the run goes back on release or a failed prompt. The window ring, carry, taps and prompt tail stay per slot. Unset, every slot allocates and binds as before.
…, a working-set check, the startup line

The load reads the setting, refuses it by name when it is out of range, cuts --context to the pool when the pool is smaller, and refuses a pool whose buffers and slot rings pass the GPU's working set before any of them is allocated. The startup line names the pool's positions, bytes a position and MiB; unset it is the old line, which a host test checks. test-dsv41-metal-pool (a device step of its own) drives five requests over eight blocks so live runs slide down and a full pool waits, then four requests through LaneHost that cannot all fit, each reply against unpooled serial decode.
… not the first sixteen

A slot past the sixteenth reserved the whole window because its admit had nowhere to go. The target now keeps one entry a slot; test-dsv41-metal-pool checks slot 19 of 20 takes a run sized by its max new tokens.
…ored after runs slid down

Host tests apply each plan's moves and zeroes to a byte buffer with the device side's own copy and zero: a run overlapping its own destination slides down intact, a moved seat's writes are counted from its new place, a refused take changes no run or dirty block, and 4,000 random takes and gives keep every live seat's bytes while each new run reads zeros. test-dsv41-metal-pool gains the prompt cache: runs slide under live requests and a kept state restored into a new run, all replies equal serial.
…es, including its default and thinking reserve
@ashhart

ashhart commented Nov 14, 2026

Copy link
Copy Markdown
Owner

Thanks @gilby. The default-off pool, compaction and cached-restore checks look sound. All 32 host checks pass, with no new lean findings. This remains stacked on #2983; the pool code applies over current main. We will run the device check on our side for pool pressure, moved-run restore and late slots before landing. No action needed from you.

  • 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