Skip to content

server: reuse checkpoint state buffers from a bounded pool - #201

Draft
danielhanchen wants to merge 2 commits into
masterfrom
perf/checkpoint-buffer-pool
Draft

server: reuse checkpoint state buffers from a bounded pool#201
danielhanchen wants to merge 2 commits into
masterfrom
perf/checkpoint-buffer-pool

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Sep 6, 2026

Copy link
Copy Markdown
Member

A 19% cut in p90 time to first token on a hybrid model at 32 slots, with throughput unchanged
to slightly up.
The cost removed is in prefill, on the critical path for TTFT; the decode path
never touches it. Host work per prefill iteration falls from 556.7 ms to 129.7 ms.


A context checkpoint of a hybrid or recurrent model holds the whole non-rollbackable sequence
state. For a 27B qwen35 at 16k context that is 149 MiB, and llama-server allocates and
frees one per prompt. Any allocation that size comes straight from mmap() and goes straight
back on free(), so the first write to a fresh buffer faults in every one of its pages.

That first write is data_tgt.resize() in common_prompt_checkpoint::update_tgt(), and on a
DGX Spark serving 32 slots it is 43.2 ms of the 54.9 ms a checkpoint costs, which is
454.8 of the 556.7 ms of a prefill iteration. create_checkpoint is 82% of the prefill batch
build, and the prefill batch build is the single longest host stall in the server. It is a time
to first token cost: the decode path never touches it.

This hands the buffer to a bounded pool instead of to the allocator. The pages stay mapped and
resident, so the next checkpoint reuses them.

The fill is load bearing, and is not removed here

The obvious change is to stop zero filling a buffer that llama_state_seq_get_data_ext()
overwrites immediately. It was tried, and it is wrong. With a CUDA target context the copy into
pageable host memory that is not yet resident runs about 140x slower, 6.5 ms against 917 ms
for 149 MiB, for a 24% throughput regression and triple the TTFT. On the CPU backend the same
change is neutral, so a CPU-only measurement would have shipped it.

This PR does not remove the fill. It makes it disappear by making it unnecessary: on a pool hit
the buffer already has the right size, so resize() is a no-op over memory that is already
mapped, resident and dirty, which is the state the fill existed to produce.

Per-phase table

Qwen3.8-27B-UD-Q4_K_XL, one DGX Spark, no RPC, --parallel 32, 32 concurrent, npp 128 ntg 256,
--cache-ram 0. Four traced cells, every one of them inside a single thermal-cap window at
1690 MHz, cross-checked against the thermal guard's own log. Prefill iterations:

resize, us/call state copy, us/call per checkpoint, us batch build, ms/iter
master 42342 12943 55293 568.3
master 44155 10250 54414 545.1
this PR 13636 6840 20481 127.1
this PR 14605 5600 20210 132.4

54.85 ms to 20.35 ms per checkpoint, and 556.7 ms to 129.7 ms of host work per prefill
iteration, minus 77%.
create_checkpoint alone goes from 454.8 to 129.4 ms per iteration.
The residual 14 ms of resize is the cold first wave: a miss still pays the full 43 ms, a hit
pays nothing, and about a third of the checkpoints in a cell are misses.

Decode iterations are untouched: post_decode 6.86 and 6.96 ms/iter on master against
6.89 with the pool, decode batch build about 10 us/call either way.

Time to first token

This is where the change is visible to a user. Two independent brackets, base / new / base with
one server load per arm, every arm inside one capped window at 1690 MHz:

27B, 32 slots base new base
TTFT p90, ms 9624.9 7943.9 9537.9
TTFT p90, ms 9856.2 7687.1 9589.5
TTFT p99, ms 9636.1 7954.1 9548.4
TTFT p99, ms 9866.0 7699.1 9599.8
TTFT median, ms 5629.1 4308.0 8379.9
TTFT median, ms 8615.5 5901.7 5821.1
tok/s 97.50 101.17 97.70
tok/s 97.56 100.87 94.11

TTFT p90 and p99 both fall 19%. Throughput at 32 slots is +3%, and at 8 and 1 slots it is
inside the bracket, which is what one checkpoint per prompt predicts: those cells are not prefill
bound.

A third bracket on the same branch: 99.01 / 100.80 / -- tok/s, TTFT p90 9730.7 to 7966.6 ms,
minus 18.1%. Three brackets, base p90 9538 to 9856 ms and new p90 7687 to 7967 ms.

A second hybrid, Qwen3.5-4B, A/B/A with the GPU clocks pinned by the harness for the whole run:

4B, 32 slots base new base
tok/s 366.00 369.50 375.53
TTFT median, ms 1403.2 1122.6 1241.4
TTFT p90, ms 2393.3 1544.1 2303.2
TTFT p99, ms 2422.1 1719.5 2321.9
at 8 slots, TTFT median, ms 570.2 426.8 556.3
at 8 slots, TTFT p90, ms 616.8 520.8 576.1

TTFT p90 falls 34% at 32 slots and 13% at 8, with throughput inside the bracket. The smaller
model has a smaller checkpoint, so the whole of the win shows up as latency.

Neutrality where checkpoints are not created

Two bracketed controls, all arms in one capped state.

  • 27B with -ctxcp 0, same model, checkpoints off: 101.10 / 100.78 / 101.21 tok/s, TTFT p90
    7157 / 7163 / 7176 ms.
  • qwen2 arch, no recurrent state and no SWA, so checkpoints are never created: 1197.6 /
    1274.5 / 1217.7 tok/s at 32 slots, 555.9 / 593.6 / 561.7 at 8, 115.6 / 113.8 / 114.2 at 1.

Nothing below MIN_BUFFER_BYTES (32 MiB, the allocator's mmap threshold) is ever pooled, so for
a model whose checkpoints are small the pool is never entered at all: an SWA tinygemma3 makes
0.356 MiB checkpoints and the pool declines every one of them.

Memory policy

Documented on common_state_buffer_pool. A byte cap of 1/16 of total host memory, a count cap of
64 buffers, a 32 MiB size floor, an eviction rule that displaces the smallest pooled buffer and
only one smaller than the buffer coming in, and trim() from the task queue's idle wait and from
the prompt cache's out-of-memory recovery. Every cap declines the buffer and lets it be freed,
which is exactly master's behaviour, so a machine that cannot afford the pool degrades to today
rather than to something worse.

Worst case extra resident memory is the byte cap, 7.59 GiB on a 121 GiB machine. The measured
case is far below it, because the pool only ever receives buffers the process had just freed and
hands them straight back out: high water mark 17 buffers, 2.5 GiB, and the server's VmHWM is
6.4738 GB with the pool against 6.4677 GB without it
, a rise of about 6 MB.

A negative result, measured and dropped

A fourth commit wrote the recycled buffer once before the state copy, on the theory that the
device-to-host copy is fastest into host pages the CPU wrote last. It was dropped. Measured
A/B/A with all three cells inside one capped window, microseconds per create_checkpoint:

resize state copy total
pool, buffer handed over untouched 13636 6840 20481
pool, buffer written once first 16279 5550 21835
pool, buffer handed over untouched 14605 5600 20210

The pass costs 2.2 ms and saves 0.7. The effect is real but does not pay for itself, and whole
cell throughput at 32 slots was 100.32 / 102.27 / 102.96 tok/s, inside the bracket.

The numbers that originally justified it came from two cells that were not in the same clock
state. The useful lesson is not "check the clock": it is that a two-cell A against B has nothing
in it that can disagree with itself, so any difference it shows is indistinguishable from drift.
Every comparison above is A/B/A, and every cell in it is cross-referenced against the machine's
thermal guard log to confirm all three arms were in one clock state.

Correctness

Greedy, temperature 0, top_k 1, seed 42, cache_prompt false, one slot and one request in
flight so the batch composition is fixed, md5 of the concatenated output:

27B hybrid on CUDA, base / base control / new   e2515bd5d500bc6aa47a695daa843b0c   identical
4B  hybrid on CUDA, base / new                  4b557d7ef96866230689ecde84326274   identical
4B  hybrid on CPU,  base / base control / new   323b77244db12566f2bef5b9a4ef5016   identical
SWA (tinygemma3) on CPU, base / base ctl / new  0ad209cedd9d41ef94c39ca7ccf8d5dd   identical

tests/test-recurrent-state-rollback on the 4B hybrid produces output byte identical to master,
including the same pre-existing dirty-ctx mismatch.

The single-slot harness is used deliberately. With several requests decoded in one batch these
models are not run to run reproducible: base against base produced three different md5s from one
binary, so a concurrent harness is not usable as a correctness control.

A context checkpoint of a hybrid or recurrent model holds the whole non-rollbackable
sequence state, hundreds of MiB, and llama-server allocates and frees one per prompt.
An allocation that size always comes from mmap() and always goes back on free, so the
first write to a fresh buffer faults in every page. In update_tgt() that is the
data_tgt.resize() zero fill, and on a DGX Spark serving a 27B hybrid at 32 slots it is
43.7 ms of the 50.2 ms a checkpoint costs, 288 of the 392 ms of a prefill iteration.

Hand the buffer to a bounded pool instead of to the allocator and the pages stay mapped
and resident, so the next checkpoint reuses them and pays neither the faults nor the
fill.

Note the fill is load bearing and must not simply be dropped: with a CUDA target
context, llama_state_seq_get_data_ext() copying into pageable host memory that is not
yet resident measures ~140x slower than into memory that is (6.5 ms against 917 ms for
149 MiB). On the CPU backend the same change is neutral, so this has to be measured on
a GPU. The pool removes the fill by making it unnecessary, not by skipping it.

The memory policy is in the comment on common_state_buffer_pool: a byte cap derived from
host memory, a count cap, a size floor below which pooling saves nothing, and a trim on
idle so the server does not hold the memory when it is not serving. Every cap degrades
to today's behaviour rather than to something worse.
Review follow-ups on the pool, all of them about the policy rather than the reuse:

- The byte cap read min(total/16, free/4) of host memory. Every non-Windows host reports
  free == total for the CPU device ("free system memory is ill-defined, assume all of it
  is free"), so the free term never bound and the stated guarantee did not exist. It is
  now a plain fraction of total, which is what it always was, said honestly.
- A failed host memory query arrives as a huge total, not as zero: sysconf(_SC_PHYS_PAGES)
  returning -1 is multiplied out with no error check. That made the byte cap useless
  exactly where the pool most needed to keep nothing. Bounded.
- The destructor is noexcept and put() can throw. server_prompt_cache::alloc() recovers
  from bad_alloc by destroying cached prompts, so a throw there would have turned a cache
  shrink into a terminate(). Wrapped.
- trim() was called from update_slots()'s all-idle branch, which is not reached while the
  queue is empty: update_slots() only runs after a task. Moved to the task queue's idle
  wait, which is the loop that actually runs when nothing is happening, and called with a
  zero timeout from the prompt cache's out-of-memory recovery so pooled bytes are always
  reclaimable under allocation pressure.
- put() declined when full instead of evicting. For a model whose checkpoints grow through
  a prompt that wedges the pool full of buffers no later request can use, holding the
  memory at a zero hit rate. It now displaces the smallest pooled buffer, and only one
  smaller than itself, so a pool of equal or larger buffers still declines.
- get() dropped the caller's undersized buffer on a hit; it is offered back instead.
- A buffer the caller already owns and that is already large enough is a reuse, and the
  cheapest kind, but it was counted as a miss.

No change to what is reused or when. Same md5 from the strict single-slot greedy harness,
and test-recurrent-state-rollback produces output identical to master.
@danielhanchen
danielhanchen force-pushed the perf/checkpoint-buffer-pool branch from bddcff0 to 468cf0b Compare September 6, 2026 23:09
@danielhanchen

Copy link
Copy Markdown
Member Author

Third-party evidence for this PR: the pool was composed onto the pipeline-groups branch and measured on the two-Spark layer split at 128 concurrent rows, which is a much harsher operating point than the single-node 32-slot cells this PR was measured at. It composes, and the honest claim is narrower than the single-node one.

Setup, so the arms are comparable: the two commits here were cherry-picked onto feature/rpc-trace (which already merges feature/pipeline-groups), one conflict, in update_slots's all-idle branch where pipeline groups changed metrics_flush_idle() to metrics_flush_idle(grp). The pool was then put behind an environment variable so both arms are the same binary and differ by nothing else. Qwen3.8-27B UD-Q4_K_XL, --kv-unified --cache-ram 0 -fa on --device RPC0,CUDA0 -sm layer --tensor-split 0.5,0.5 --pipeline-groups 2 --parallel 128 -c 65536, npp 128 / ntg 256, both nodes pinned at 300,1700 MHz for the whole block with no thermal-guard transition in any cell, bracketed A/B/A/B/A:

128 rows, two groups, two nodes pool ON (5 legs) pool OFF (2 legs) change
tok/s 213.68, 208.80, 211.48, 211.45, 210.22 = 211.13 208.63, 200.91 = 204.77 +3.1 %, legs overlap, no claim
TTFT p99 26.34, 26.81, 27.05, 27.49, 28.52 = 27.24 s 31.79, 31.91 = 31.85 s -14.5 %, legs do not overlap
TTFT p90 25.54, 26.00, 26.19, 26.56, 27.35 = 26.33 s 27.21, 28.13 = 27.67 s -4.8 %
TTFT median 21.08, 16.94, 19.53, 16.60, 16.39 = 18.11 s 21.79, 19.79 = 20.79 s -12.9 %

The p99 separation is clean: every ON leg sits below every OFF leg with a 3.3 s gap. The throughput arms overlap (208.80 ON against 208.63 OFF), so nothing should be claimed there.

Why the effect is smaller here than the 19 percent p90 you measured at 32 slots on one node, and it is not a contradiction. A trace of the same configuration says where the time goes. Over a 313 s window, of the 8.45 points the bottleneck GPU spends idle, only 1.13 are with neither GPU busy, and inside the prompt-batch iterations server/batch_build accounts for 3.74 points of that phase. At 128 rows TTFT is dominated by 128 prompts queueing for the same slots, not by the checkpoint. The pool removes host time, and it does; it cannot remove a queue. At 32 slots the queue is short enough that the checkpoint is a much larger share of the wait, which is exactly the regime your numbers are from.

Two things worth taking from this into the PR:

  1. Nothing in the pool interacts badly with pipeline groups, RPC, or a layer split. Five ON legs across two and a half hours, no error, no memory growth: resident bytes were 20926 and 20439 MiB on the two nodes in every cell with the pool on and the same with it off.
  2. The p99 result is worth stating separately from the p90 one, because at high slot counts p99 is where this shows and p90 is not.

Caveat, stated because the bracket is uneven: five ON legs against two OFF legs. A third OFF leg would make the throughput question answerable rather than merely unclaimed.

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.

1 participant