You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Under concurrent tool-calling load the engine dies within ~10-20 minutes. The proximate
symptom is SWA slot accounting, but the root cause is a duplicate-page double-free in the
full KV free list: free_slots persistently holds the same pages twice, so _allocate hands
one page to two owners.
AssertionError: SWA-slot leak/double-free: free(3868) + tree(8742) != capacity(26231)
File "freetoken/scheduler/cache.py", line 557, in check_integrity
File "freetoken/scheduler/scheduler.py", line 144, in run_when_idle
File "freetoken/scheduler/io.py", line 82, in _recv_msg_single_rank
File "freetoken/scheduler/scheduler.py", line 211, in overlap_loop
Backend supervisor: backend worker freetoken-TP0-scheduler exited
Backend worker is gone and cannot be restarted; stopping the API server
Evidence
Four probes added to a 0.1.2 tree (diagnostic only, ~30 lines), one 7-minute soak of concurrent
tool-calling traffic:
probe
what it counts
result
DIAG_DUPFREE
duplicate pages present in free_slots at allocation time
3598 events
DIAG_ORPHAN
alloc_swa overwriting a still-live full_to_swa_index_mapping entry
71
DIAG_UNPAIRED
CacheManager._free releasing a still-swa-mapped page
Exactly 2125 pages are in the free list twice, persistently. That accounts for every observed
failure:
two owners for one page → the second alloc_swa overwrites the first owner's swa mapping, and
that swa slot becomes neither free-listed nor tree-owned → free + tree < cap
(free(3868) + tree(8742) != capacity(26231))
a page counted by both a live request and the tree → tree > cap
(free(1406) + tree(27992) != capacity(26229))
Concurrency is required because two owners only collide when two requests are in flight. A
single sequential session runs indefinitely: 748 requests, 553 tool calls, 14 minutes, zero
errors, engine healthy.
Also observed with a smaller window pool (--swa-full-tokens-ratio 0.07, 9176 tokens): the same
workload dies sooner and differently, RuntimeError: SWA pool exhausted: need 1669, have 1306
from alloc_swa, because a small pool runs out before check_integrity notices the mismatch.
Same bug, earlier symptom. (#202 is this presentation.)
Load that reproduces it
4 concurrent multi-turn tool-calling conversations, each: ~7.7k-token system prompt, tools
offered, 4 turns, tool results fed back, max_tokens 160. Ordinary agent traffic. Roughly 275
requests / 180 tool calls before the crash; the dup-free counter is non-zero within about two
minutes.
Paths ruled out, with citations
Recorded so this ground does not need re-covering:
CacheManager._free (scheduler/cache.py) never releases a still-mapped page — 0 hits.
Neither does lazy_free_region's closure, which shadows self._free for the whole of _process_last_data — 0 hits. Note a probe on the method alone misses this path entirely.
SWARadixCache.evict_full correctly appends live-swa leaves to swa_indices
(if not node.swa_tombstone: swa.append(node.value)).
_cascade_swa_tombstone_leaves only walks nodes already swa_tombstone, whose swa slots were
returned when they were tombstoned, so appending their full KV to kv alone is right.
SWAEvictResult.swa_indices does hold full-pool indices, matching what free_swa expects —
no unit mismatch.
That leaves the producer of the duplicates: some path returns a page to free_slots that is
still owned (by a request row or by the tree), or returns it twice. The comments around _process_last_data and _free_req_resources show the hazard is already known in the
chunked-prefill/abort overlap ("cache_req double-frees the prior chunk", "double-freeing its
table_idx ... handing the same slots to two later requests"), which looks like the most likely
neighbourhood.
Suggested guard
A cheap invariant that fires in ~2 minutes under this load, long before the SWA assert, and
points straight at the allocation-side symptom:
in _allocate, or in check_integrity which already runs when idle.
Robustness points independent of the root cause
Each of these would downgrade this class of bug from an outage to a diagnostic, and they are
worth fixing whether or not the double-free is found quickly. (Folded in from #202, closed as a
duplicate of this issue.)
check_integrity failing terminates the worker. The assert is a diagnostic; dying on it
converts a memory-accounting bug into an outage. Logging, or quarantining the affected slots,
would keep the server serving.
alloc_swa raising on exhaustion is fatal rather than throttling. Everywhere else the
scheduler treats capacity as a scheduling constraint — a request waits for KV pages. Window
slots are the one pool whose exhaustion is an exception, so a transient shortfall of 363 slots
ended the process (RuntimeError: SWA pool exhausted: need 1669, have 1306). Deferring the
batch, or refusing the request with a 503, would keep the server up.
The advertised window-pool floor is not concurrency-aware./v1/cache/status reported the
pool as resizable down to 8777 tokens and --swa-full-tokens-ratio 0.07 resolved to 9176 —
both accepted at startup without complaint, though 4 concurrent sequences each need a live
trailing window plus the retain gap, and prefill needs its own slots on top. A floor
accounting for max_running_requests * (window + _SWA_RETAIN_GAP) plus the prefill chunk
would reject such a configuration at startup rather than crashing 9 minutes into production
traffic.
Restart the backend worker on death, or fail /health immediately once it is gone. At
present the frontend keeps answering /health and /v1/models for a while afterwards, so an
operator sees a healthy port and hanging requests.
Summary
Under concurrent tool-calling load the engine dies within ~10-20 minutes. The proximate
symptom is SWA slot accounting, but the root cause is a duplicate-page double-free in the
full KV free list:
free_slotspersistently holds the same pages twice, so_allocatehandsone page to two owners.
Evidence
Four probes added to a 0.1.2 tree (diagnostic only, ~30 lines), one 7-minute soak of concurrent
tool-calling traffic:
DIAG_DUPFREEfree_slotsat allocation timeDIAG_ORPHANalloc_swaoverwriting a still-livefull_to_swa_index_mappingentryDIAG_UNPAIREDCacheManager._freereleasing a still-swa-mapped pageDIAG_LAZYlazy_free_region's closureThe duplicate counts are stable and specific:
Exactly 2125 pages are in the free list twice, persistently. That accounts for every observed
failure:
alloc_swaoverwrites the first owner's swa mapping, andthat swa slot becomes neither free-listed nor tree-owned →
free + tree < cap(
free(3868) + tree(8742) != capacity(26231))tree > cap(
free(1406) + tree(27992) != capacity(26229))Concurrency is required because two owners only collide when two requests are in flight. A
single sequential session runs indefinitely: 748 requests, 553 tool calls, 14 minutes, zero
errors, engine healthy.
Also observed with a smaller window pool (
--swa-full-tokens-ratio 0.07, 9176 tokens): the sameworkload dies sooner and differently,
RuntimeError: SWA pool exhausted: need 1669, have 1306from
alloc_swa, because a small pool runs out beforecheck_integritynotices the mismatch.Same bug, earlier symptom. (#202 is this presentation.)
Load that reproduces it
4 concurrent multi-turn tool-calling conversations, each: ~7.7k-token system prompt, tools
offered, 4 turns, tool results fed back,
max_tokens160. Ordinary agent traffic. Roughly 275requests / 180 tool calls before the crash; the dup-free counter is non-zero within about two
minutes.
Paths ruled out, with citations
Recorded so this ground does not need re-covering:
CacheManager._free(scheduler/cache.py) never releases a still-mapped page — 0 hits.lazy_free_region's closure, which shadowsself._freefor the whole of_process_last_data— 0 hits. Note a probe on the method alone misses this path entirely.SWARadixCache.evict_fullcorrectly appends live-swa leaves toswa_indices(
if not node.swa_tombstone: swa.append(node.value))._cascade_swa_tombstone_leavesonly walks nodes alreadyswa_tombstone, whose swa slots werereturned when they were tombstoned, so appending their full KV to
kvalone is right.SWAEvictResult.swa_indicesdoes hold full-pool indices, matching whatfree_swaexpects —no unit mismatch.
That leaves the producer of the duplicates: some path returns a page to
free_slotsthat isstill owned (by a request row or by the tree), or returns it twice. The comments around
_process_last_dataand_free_req_resourcesshow the hazard is already known in thechunked-prefill/abort overlap ("cache_req double-frees the prior chunk", "double-freeing its
table_idx ... handing the same slots to two later requests"), which looks like the most likely
neighbourhood.
Suggested guard
A cheap invariant that fires in ~2 minutes under this load, long before the SWA assert, and
points straight at the allocation-side symptom:
in
_allocate, or incheck_integritywhich already runs when idle.Robustness points independent of the root cause
Each of these would downgrade this class of bug from an outage to a diagnostic, and they are
worth fixing whether or not the double-free is found quickly. (Folded in from #202, closed as a
duplicate of this issue.)
check_integrityfailing terminates the worker. The assert is a diagnostic; dying on itconverts a memory-accounting bug into an outage. Logging, or quarantining the affected slots,
would keep the server serving.
alloc_swaraising on exhaustion is fatal rather than throttling. Everywhere else thescheduler treats capacity as a scheduling constraint — a request waits for KV pages. Window
slots are the one pool whose exhaustion is an exception, so a transient shortfall of 363 slots
ended the process (
RuntimeError: SWA pool exhausted: need 1669, have 1306). Deferring thebatch, or refusing the request with a 503, would keep the server up.
/v1/cache/statusreported thepool as resizable down to 8777 tokens and
--swa-full-tokens-ratio 0.07resolved to 9176 —both accepted at startup without complaint, though 4 concurrent sequences each need a live
trailing window plus the retain gap, and prefill needs its own slots on top. A floor
accounting for
max_running_requests * (window + _SWA_RETAIN_GAP)plus the prefill chunkwould reject such a configuration at startup rather than crashing 9 minutes into production
traffic.
/healthimmediately once it is gone. Atpresent the frontend keeps answering
/healthand/v1/modelsfor a while afterwards, so anoperator sees a healthy port and hanging requests.
Environment
freetoken_kernel_cache-0.1.2+cu130)unsloth/gemma-4-26B-A4B-it-qat-GGUF(Q4_0), window 1024, 25 SWA / 5 full layers--kv-reserve-tokens 131072 --kv-cache-dtype q8_0 --max-running-requests 4 --swa-full-tokens-ratio 0.2slot accounting, and the dup-free counter is non-zero on stock defaults too.
Happy to run a candidate fix or a more targeted probe on this hardware — reproduction is
reliable in under 10 minutes.