Skip to content

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

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

server: reuse checkpoint state buffers from a bounded pool#201
danielhanchen wants to merge 6 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.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-09T04:46:09.564664Z 1db6cb3 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 375237a8ce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread common/common.cpp
Comment on lines +2276 to +2277
// of total, not free: every non-Windows host reports free == total for the CPU device
return mem_total / 16;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the pool by the container memory limit

In a Linux container whose cgroup limit is substantially smaller than host RAM, this cap is based on host-wide physical memory: the CPU backend implementation in ggml/src/ggml-cpu/ggml-cpu.cpp obtains mem_total from sysconf(_SC_PHYS_PAGES) without consulting cgroups. For example, a 4 GiB pod on a 256 GiB host can therefore retain up to 16 GiB of resident checkpoint buffers, allowing the cgroup OOM killer to terminate the server before the advertised bound or idle trimming helps. The cap needs to incorporate the process/container memory limit when one is present.

Useful? React with 👍 / 👎.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 041849375d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// limit_size does not count pooled bytes, so both trims are needed: the first releases
// what was already pooled, the second the buffers update() just evicted, which the
// checkpoint destructors hand to the pool rather than to the allocator.
common_state_buffer_pool::instance().trim(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry allocation after releasing pooled buffers

When memory is tight and the cache-entry removals above have just returned large checkpoint buffers to the new pool, these buffers can be the reason resize() fails even though the same allocation would have succeeded once they were freed. This trim releases them, but the function then reduces the cache limit and returns without retrying, so an otherwise recoverable prompt-cache save is discarded and may permanently shrink the cache (potentially to one byte when size() is now zero). Retry the allocation after trimming before taking the existing failure path.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 1db6cb3588

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member Author

What this changes, in four questions

1. Before the PR, what happened?

Every sequence-state checkpoint allocated a fresh multi-hundred-MiB buffer and freed it again, one per prompt. At that size glibc goes straight to mmap() and returns the pages on free, so the first write to each new buffer faulted in every page. In llama-server that showed up as a zero fill inside update_tgt() before the state copy could start, repeated for every checkpoint of every prompt.

2. After the PR, what happens?

Freed checkpoint buffers go to a bounded process-wide pool instead of the allocator, so the pages stay mapped, resident and dirty and the next checkpoint of the same size reuses them. Retention is capped by bytes (1/16 of host memory) and by count, a full pool only accepts a buffer by displacing a smaller one, and trim() releases everything when the server goes idle or enters sleep.

3. Is this a real issue or a fake one?

Real, and describable as a sequence a user hits. Serve any hybrid or recurrent model with prompt caching on. Each prompt writes a checkpoint of hundreds of MiB; each one is a fresh mmap(), so each one pays a full page-fault storm plus the zero fill before any useful work. It is not an edge case, it is every prompt on that class of model.

Two related defects found during review and fixed here as well:

  • The std::bad_alloc recovery in server_prompt_cache::alloc trimmed the pool before update(), but update() evicts cached prompts whose destructors hand their buffers back to the pool, and pooled bytes are not counted against limit_size. The recovery therefore returned with most of the memory it meant to reclaim still held. There is now a second trim after update().
  • should_sleep() preempts the idle-window trim whenever --sleep-idle-seconds is at or under the trim's own window, so the pool stayed fully resident for the whole sleep, which is exactly when the server reports the memory released. Sleep entry now trims unconditionally.

4. If merged, does it break anything?

This code is not RPC-gated and has no two-node path, so it is live on every llama-server, including a single GPU, a single machine with several GPUs, and CPU-only. That was the bar it was checked against:

  • Model agnostic. Nothing keys on an architecture or a checkpoint. The only thresholds are a 32 MiB size floor, chosen because below the allocator's mmap threshold there is no fault storm to avoid, and a byte cap derived as a fraction of host memory, so the pool is proportionate to whatever machine it runs on.
  • Degrades to today's behaviour. Over any cap the buffer is simply freed, which is what happens without the pool. If host memory cannot be determined the pool keeps nothing at all.
  • No new peak. The pool only ever receives buffers the process had already made resident and just freed, so for a steady workload live plus pooled is the count the process already peaked at.
  • The zero fill is deliberately not deleted. With a CUDA target context, llama_state_seq_get_data_ext() copying into pageable host memory that is not yet resident runs about 140x slower than into memory that is. The pool removes the fill by making it unnecessary, not by skipping it, and that is recorded in the header so nobody removes it later.

Verified: -DGGML_CUDA=OFF -DGGML_RPC=OFF CPU build of llama-server clean, with ccache. Not executed on macOS or Windows; the change is portable C++ with no platform-specific calls, and the one host-memory query goes through the existing ggml CPU backend rather than a new syscall.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 1db6cb3588

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member Author

Simulation against the merge base, fbf9abcc7

Host-side throughout, so no GPU lock and no RPC. CPU backend, single device, one slot.

Model choice is part of the test. The pool only engages for a model that creates context
checkpoints, and only above the 32 MiB floor. Qwen3.5-4B-UD-Q4_K_XL is recurrent
(llama_memory_recurrent: CPU RS buffer size = 50.25 MiB, n_swa = 0), so its checkpoints are
50.251 MiB and the pool engages; every run below creates 14 checkpoints, from the server's
own trace. The first attempt at this, same model but with the default 8192-token
--checkpoint-min-step, created 0 checkpoints and would have measured nothing at all, so
-cms 0 is used.

std::bad_alloc is injected deliberately, not waited for: an LD_PRELOAD shim replaces
operator new and throws once for the first allocation at or above 48 MiB after an arm file
appears. Self-tested both ways before use. Every run confirms alloc recovery entered: 1.

Every run refuses to start unless the build directory's recorded source tree, the md5 of
common.cpp/common.h/server-queue.cpp/server-task.cpp in it, and the output label all agree
with a manifest written beforehand. That is needed here because the two control trees are
1db6cb358 with a patch applied and report the same --version string.

The two trim(0) calls in server_prompt_cache::alloc() are load bearing

limit_size and server_prompt_cache::size() count only what is in states. A buffer handed to
the pool by ~common_prompt_checkpoint has left states and is still resident, so the bad_alloc
recovery halves limit_size, calls update(), and frees far less than master did.

Server RSS across the injected failure:

tree before after reclaimed pool held after
fbf9abcc7 master, no pool 4592792 kB 3909692 kB 683100 kB n/a
head 1db6cb358 4592216 kB 3909084 kB 683132 kB 0.000 MiB
head minus only those two trims 4592704 kB 4218340 kB 374364 kB 301.503 MiB

Without them the recovery loses 308768 kB, exactly the 6 pooled checkpoints of 50.251 MiB, and
the server's own log confirms 301.503 MiB still held against 0.000 MiB with them. With them it
matches master to within 32 kB out of 683 MB.

The isolated version of the same case, in the unit harness: destroying 12 cached checkpoints of
150 MiB reclaims 0 kB, and the subsequent trim(0) reclaims 1843248 kB.

The idle/sleep trim is load bearing too

First measured in steady state with --cache-ram 8192, where the pool's high water mark is 2
buffers
because the same buffer is recycled (12 of 14 gets are hits). The trim has nothing to
release and head differs from head-without-the-trim by 428 kB. Recorded rather than discarded: that
is a real configuration in which the flip cannot show.

With --cache-ram 300 so cached prompts are evicted and their checkpoints reach the pool, both arms
reach an identical 502.506 MiB held, hwm 12, from each server's own log:

tree RSS at idle_start RSS after sleep entered
head 4195524 kB 460132 kB
head minus both trims in server_queue::start_loop 4195992 kB 975200 kB

Difference 515068 kB = 503.0 MiB, matching the pool's own accounting to 0.5 MiB. Without it the
server sleeps still holding half a gigabyte of pooled buffers at the moment it advertises the memory
as released.

The pre-hardening pool wedges, and head fixes it

ee6555ac7 against 1db6cb358, same 43-assertion harness:

case ee6555ac7 1db6cb358
full pool of 64 small buffers, then a 200 MiB buffer refused accepted, one smaller evicted
40 growing checkpoints hwm 64, 0 evictions hwm 40, 1 eviction
n_hwm after trim() preserved, 64 -> 64 reset to 0, 4 -> 0

Latent use-after-destruction, confirmed by AddressSanitizer

instance() is a function-local static, so it is destroyed in reverse order of completion of
construction. A common_prompt_checkpoint with static storage duration constructed before it would
be destroyed after it and call put() on a destroyed object. [basic.start.term]/5 makes that
undefined outright, and the destructor's try { } catch (...) { } cannot help, because it is not
an exception.

Reproduced under ASan, as a pair, because whether it fires depends on one thing:

pool at exit result
empty exit 0, 0 ASan reports
holding 96 MiB exit 1, heap-use-after-free
ERROR: AddressSanitizer: heap-use-after-free
READ of size 8 at 0x503000002c20 thread T0
  #13 common_state_buffer_pool::put(std::vector<unsigned char>&&)  common/common.cpp:2364
  #14 common_prompt_checkpoint::~common_prompt_checkpoint()        common/common.cpp:2406
  #15 __run_exit_handlers                                          stdlib/exit.c:108

common.cpp:2364 is free_bufs.push_back(std::move(src)). ~vector deallocates and leaves its
pointers dangling; an empty pool is the case that hides it, because trim()'s shrink_to_fit()
nulls them and the same push_back merely allocates afresh.

This is not reachable today. Every checkpoint holder in the tree has automatic storage
duration, including server_context ctx_server in main() at tools/server/server.cpp:170, so it
is destroyed before static destruction begins. It is a constraint the PR silently creates for
anyone who later gives a checkpoint static storage duration. A leaky singleton
(static auto * p = new common_state_buffer_pool; return *p;) removes it for one deliberate leak.

Everything else checks out

43 of 43 assertions pass on head, under ASan and under TSan. Under TSan, 0 data races and 0
lock-order inversions
across 8 concurrent workers doing 1666 get/put cycles with a ninth
thread calling trim(0) every millisecond; each worker tags the first, middle and last byte of its
buffer and reads them back, which would catch a buffer handed to two owners. The only TSan findings
are three instances of the exit-time destruction-order issue above.

  • Cap is machine-proportionate, not Spark-tuned. cap_bytes 7.606 GiB against /proc/meminfo
    MemTotal 121.689 GiB, a ratio of 0.0625, read independently of the code under test. Derived at
    runtime from sysconf(_SC_PHYS_PAGES); the > (1ull<<50) guard turns a container's -1 into
    cap_bytes = 0, and put()'s cap > cap_bytes then declines everything, which is master.
  • The 32 MiB floor is exactly right. glibc's DEFAULT_MMAP_THRESHOLD_MAX is
    4*1024*1024*sizeof(long), 32 MiB on LP64, and the dynamic threshold is only grown under a
    <= DEFAULT_MMAP_THRESHOLD_MAX test, so it can never exceed it.
  • Eviction terminates in every case, honours both caps, declines an over-cap buffer without
    disturbing the pool, and best fit never returns an undersized buffer.
  • Moves survived the user-declared destructor: move constructible, move assignable, nothrow
    move constructible, the move steals the pointer rather than copying, and a std::list::splice
    does not move data.
  • lock.unlock() in server_queue::start_loop is safe. The unique_lock is declared inside
    the inner while, so it is destroyed at the end of the iteration and reconstructed at the top of
    the next.
  • Trim really unmaps. 8 x 150 MiB held simultaneously and touched: RSS 75024 -> 1303856 kB,
    trim(60 s) correctly a no-op, trim(0) back to 75024 kB.

KV cache and prefix cache

The correctness table in the body uses cache_prompt: false. These runs use cache_prompt: true, temperature 0, top_k 1, seed 42, one slot and one request in flight, seven distinct
prompts, on a recurrent model that both writes and reloads checkpoints:

reply 1 2 3 4 5 6 7
master 101effe7 64f25d29 6c0ac344 d55410f8 7dab2b66 a8440b7d 3caf7343
head 101effe7 64f25d29 6c0ac344 d55410f8 7dab2b66 a8440b7d 3caf7343
head minus the OOM trims 101effe7 64f25d29 6c0ac344 d55410f8 7dab2b66 a8440b7d 3caf7343

All identical. Reply 7 is the one issued while the bad_alloc fires, so the recovery path is inside
the compared output. Handing a checkpoint a buffer that still contains the previous checkpoint's
bytes is safe because update_tgt/update_dft overwrite the whole buffer and abort if
llama_state_seq_get_data_ext does not fill it.

Two smaller notes

  • trim() sets n_hwm = 0, so the hwm the server logs understates the peak after any idle period
    or OOM recovery. ee6555ac7 did not do this.
  • A monotonically growing checkpoint gets 0 pool hits out of 40 and leaves the pool holding
    4440 MiB in 40 buffers, because it never fits anything already pooled and every previous buffer
    is retained. Both caps hold and a trim reclaims it all, but the worst case is nearer the byte cap
    than the "high water mark 17 buffers, 2.5 GiB" in the body suggests; that number is one workload,
    and the cap is what actually bounds it.

…tructor

common_state_buffer_pool::instance() returned a function-local static, which is destroyed in
reverse order of completion of construction. A common_prompt_checkpoint with static storage
duration constructed before it would therefore be destroyed after it, and ~common_prompt_checkpoint
would call put() on a destroyed object. [basic.start.term]/5 makes that undefined outright, and the
try/catch in the destructor does not help, because it is not an exception.

Reproduced under AddressSanitizer with a namespace-scope checkpoint, as a pair, because whether it
fires depends only on whether the pool still holds a buffer when it is destroyed:

  pool at exit    result
  empty           exit 0, 0 ASan reports
  holding 96 MiB  exit 1, heap-use-after-free

  ERROR: AddressSanitizer: heap-use-after-free
  READ of size 8 thread T0
    #13 common_state_buffer_pool::put(std::vector<unsigned char>&&)  common/common.cpp:2364
    #14 common_prompt_checkpoint::~common_prompt_checkpoint()        common/common.cpp:2406
    #15 __run_exit_handlers                                          stdlib/exit.c:108

common.cpp:2364 is free_bufs.push_back(). ~vector deallocates and leaves its pointers dangling; an
empty pool is the case that hides it, because trim()'s shrink_to_fit() nulls them and the same
push_back merely allocates afresh.

This is latent today, not live: every checkpoint holder in the tree has automatic storage duration,
including server_context ctx_server in main(). It is a constraint this feature silently creates for
anyone who later gives a checkpoint static storage duration, and a leaky singleton removes it for
the cost of one deliberate leak of a process-wide object at exit.

After this commit the same ASan run is clean, 0 reports, with 43 of 43 pool assertions still
passing under both ASan and ThreadSanitizer (0 data races, 0 lock-order inversions).
@danielhanchen

Copy link
Copy Markdown
Member Author

Pushed 00ce29bd7: common_state_buffer_pool::instance() now returns a deliberately leaked
singleton. One file, +9 -2.

A function-local static is destroyed in reverse order of completion of construction, so a
common_prompt_checkpoint with static storage duration would be destroyed after the pool and its
destructor would call put() on a destroyed object. Reproduced under ASan as a pair, since it fires
only when the pool still holds a buffer at exit:

pool at exit at 1db6cb358 at 00ce29bd7
empty exit 0, 0 reports exit 0, 0 reports
holding 96 MiB exit 1, heap-use-after-free exit 0, 0 reports

43 of 43 pool assertions still pass, under ASan and under TSan, with 0 data races and 0 lock-order
inversions across 8 concurrent workers and a racing trimmer.

This was latent rather than live, since every checkpoint holder in the tree has automatic storage
duration including server_context ctx_server in main(), so the change removes a constraint the
feature would otherwise have created silently for anyone who later gives a checkpoint static
storage duration. It costs one leak of a process-wide object at exit.

Nothing else in the PR is touched, and the trims measured in the comment above are unaffected.
prepush_gate.py --skip-agpl passed bare, exit 0, and the push was chained behind it with &&.
The simulation harness is not in the commit.

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