Repository navigation
nemotron metal: size the Mamba pool to active streams and budget the prompt cache from wired footprint - #1111
Open
jishnuvenugopal wants to merge 2 commits into
Conversation
…prompt cache from wired footprint Pool.init and Scratch.init allocated their Metal buffers without touching the pages, so when the window-cost table was read from disk the process footprint at cache_fit.budget was 3.5 GiB below what the first forward wired, while a fresh timing run wired those pages first and left 0.0 GiB for the prompt cache. backend.Metal.init also sized Pool.init for rows + streams + 1 Mamba slots (67 slots at 2 streams, 3.10 GiB) when each active stream holds at most two slots plus slot 0 and a spare. Size Pool.init to 2 * o.streams + 3 Mamba slots and o.streams + 2 replay ids, zero-fill Pool.init and Scratch.init buffers on creation so their pages enter the process footprint before cache_fit runs, and pass the lazy per-stream Cache.bytes total to cache_fit.budgetBeside.
…back to the pool queueFn and verifyFn took a fresh Mamba slot from the pool and only handed it to the stream's cache through hold() after the command buffer landed, so an error in between (a later window refused, grammar bits refused, a failed command buffer) left the slot out of the pool for the life of the process while the lane host failed the round and served on. The pool now holds one spare beyond what every active stream needs, so each such round removes headroom a later lone round needs. Return the slot with errdefer in queueFn, and in verifyFn for every window whose slot is already in segs; stage a window's grammar bits before its slot is taken so a refused mask has nothing to return.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thank you, @ashhart, for
cache_fit.budgetBeside(zig/src/native/cache_fit.zig), which Gemma 4 and the 27B already use for the memory theirstreams take on first use, and for 1.1.1's change that gives the 27B's DeltaNet rollback scratch only to the slots that need it, the same idea this
applies to Nemotron's Mamba states. Thank you, @akol1, for the
/memoryendpoint (zig/src/server/routes.zig) that reports the process footprint andits peak beside the prompt cache: it made the gap between the footprint at startup and after the first request directly visible.
What this changes
nemotron.backend.Metal.init(zig/src/families/nemotron/backend.zig) sizedstate.Poolforrows + o.streams + 1Mamba slots, 67 at two streams(3.10 GiB), although a stream holds at most two (its state and its pending forward) and a lone GPU round a third. It now takes
state.poolSlots(o.streams),2 * streams + 3, ando.streams + 2replay ids instead ofo.streams + 4(only a stream's cache takes one); atest fills a pool to that worst case and shows the next slot is the last. With one spare
left,
queueFnandverifyFnnow return a fresh slot witherrdeferwhen the round fails after taking it (before, the slot stayed out of the poolfor the life of the process while the lane host failed the round and served on), as
snapshot.restorealready does.Pool.initandScratch.init(state.zig) zero-fill their buffers, so their pages are in the process footprint beforecache_fitreads it whether thewindow-cost table was timed or read from disk.
native/metal.zigthen callscache_fit.budgetBesidewith the KV and window buffers each laneallocates on its first prompt (
Cache.bytesat the full window, checked againstCache.initby a test), as Gemma 4 and the 27B do. So a serverwithout
--context(the 262,144-token window) now reserves 1.75 GiB a lane for those buffers: on a 32 GB Mac it keeps no prompt cache, and anexplicit
--prompt-cache-gibstops at startup with the cap message (--prompt-cache-over-capstill overrides it).Verified outcome
timing table it planned 1.7 GiB, then the first request added 4.0 GiB and the server passed the 22.4 GiB (70%) limit by 1.25 GiB. On a launch
that had to time the table it saw 22.2 GiB already in use and kept no prompt cache at all.
counted before the cache is planned, and the memory each stream will take on first use is counted beside it.
the launch that times the table keeps a 0.7 GiB prompt cache instead of none. A saved-table launch plans 0.9 GiB instead of 1.7, because 1.7
was never available, and a server left at the model's full context window plans none, because two full-length streams would need that memory.
Replies are token for token the same as before, and startup, decode speed and prompt speed are unchanged.
Receipt
Environment: branch
fix/nemotron-prompt-cache-budgetonmainat d1018b3 (1.1.1), Zig 0.17.0, Apple M4 (32 GB, limit 22.4 GiB), macOS 27.0.1,TensorFold/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-MLX-4bitat d9d758fb.serve --parallel 2 --context 16384, default prompt-cache budget, eachlaunch with its own
HOME. The table and exactness runs below were taken on d46d17a (1.1.0) with load averages 1.0 to 2.3, then repeated onceper row on d1018b3 (load 1.6 to 2.6): the same budgets, the same peaks to two decimals (23.66, 22.89, 20.87, 20.78 GiB) and the same token hashes.
Memory and prompt cache,
bench/p2_memory.py(/memoryfootprint, prompt-cache log), four launches per build: saved table (kept) and an emptyHOMEthat times a table (fresh), alternated, each pair repeated and identical to two decimals. GiB:Loaded in 11.1 to 11.5 s (kept) and 39.9 to 40.8 s (fresh) on both builds.
Without
--context(262,144-token window,--parallel 2, saved table, d1018b3): main plans 1.7 GiB (18.7 GiB in use once loaded); this branchplans 0.0 GiB (20.2 GiB in use once loaded and 3.5 GiB its streams take on first use). With
--prompt-cache-gib 1, main starts with 1.0 GiB andthis branch exits with the cap message. The 0.9 GiB more in use once loaded is the two attention scratch sets (the backend's and the MTP head's)
whose split-K partials are sized by the window, now zero-filled: about 0.5 GiB each at this window and 0.03 GiB at 16,384.
Exactness:
bench_openai.py --tokens 256fibonacci-rawc91869ce1808and gpu-chat-no-think5ef050084ac3at--parallel1, 2 and 4, the samehashes as main.
bench_concurrent.py --levels 1,N --alone --serialat temperatures 0 and 1.0: 0 unequal, 0 failed at all three. Resumedprompts,
bench/p2_restore.py(a 5,974-token prompt: cold, repeat, N at once, N-1 arriving while another stream decodes) on this branch and on main at--parallel1, 2 and 4: 5,968 tokens cached on every resume, every reply equal between the builds including a 160-token reply decoding besidethe resumes, 0 errors and no slot or replay-id error in any server log.
tf-train-checkon the 30B checkpoint (streams = 2, 7 Mamba slots vs 67 onmain): loss
12.34732(with gradients12.34732) and every gradient norm and finite-difference ratio across all 11 checked layers (51down to0)equal to main (
ok: worst ratio off 1 by 0.102).Decode speed before and after,
bench_openai.py --tokens 256 --reps 2 --temperatures 0atserve --parallel 2 --context 16384, four alternatedlaunches (this branch, main, this branch, main) on one shared window-cost table: fibonacci-raw
48.92, 47.58tok/s (TTFT0.123, 0.126s) on thisbranch vs
48.77, 46.48tok/s (TTFT0.123, 0.130s) on main; gpu-chat-no-think48.25, 46.29tok/s (TTFT0.315, 0.327s) on this branch vs47.77, 47.56tok/s (TTFT0.320, 0.322s) on main. Prompt speed: n/a, the prompt pass is unchanged.Peak footprint is in the table above. 0 leak lines in every server log.
Tests run, uncached, on this branch and on main at d1018b3:
zig fmt --check build.zig zig,git diff --check origin/main,zig build -Dcpu=apple_m1,zig build test -Dcpu=apple_m1(4934/4955 passed, 21 skipped; main 4927/4948, 21 skipped),zig build test-kernel-contract(65/65),zig build test-golden(306/319 and 26/26 equal, 0 unexpected differences, as on main),zig build fuzz-cortheon(11/11),flashnext_batch_serve_test.py(8 OK),cross-builds
-Dtarget=x86_64-linux-gnu -Dcpu=x86_64_v3and-Dtarget=aarch64-linux-gnu,zig build native -Dcpu=apple_m1 -Dversion=1.0.0, andlean_new.py(444 problems, 444 on the base, 0 new). The 7 more tests are the 3 new ones and 4 existing ones the macOS test binary now reachesthrough
backend.zig(split-K partitions follow MLX's rule,tree tables: depths and ancestors by depth,a tree's stepped lanes by depth, and each lane's ranks from its parent's step,a state's bytes: fixed Mamba states, then a key and value row per KV head and attention layer a token),all passing. New, each failing before its change:
a pool of poolSlots holds every stream's state and pending forward, a lone round's third, and one spare(error.StateSlotsFullwhenpoolSlotsis2 * streams + 2);Cache.bytes matches the Metal buffers Cache.init allocates(expected 2816, found 4198144without the KV and MTP terms);a verify refused after a window took its Mamba slot gives the slot back to the pool(
backend_slots_test.zig, a backend with no weights;expected 6, found 5without theerrdefers, and its grammar case alone fails the same waywhen the slot is taken before the grammar bits are staged). They need a Metal device: they run in the macOS test binary and skip without one, and
the ubuntu and Windows jobs do not build the Nemotron Metal family.
Not run, and why: M1 to M3, M5 and CUDA. The change is in the Nemotron Metal backend and the Metal host. Other memory sizes: the fix is arithmetic on
what is wired, but the numbers above are one 32 GB machine.
queueFn'serrdeferhas no test: the only call after its slot that can fail is thecommand buffer's submit, which needs the loaded model.
Checklist
"draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (Above.)SUMMARYline is in the receipt.(n/a: no kernel is added or changed.)