Repository navigation
[Engine] serve: the RAM gate releases the heap and stops evicting when it cannot help (rebased #1394) - #2253
alanthinker wants to merge 5 commits into
Conversation
…ot help
The physical-RAM admission gate of the parking path (generate.cpp) evicts parked
conversations one at a time and re-reads the host's free RAM after each, on the
assumption that dropping a conversation is visible to /proc/meminfo at once. That
assumption is wrong for the allocation this cache actually makes.
ConversationBuffer is a list of 16 MiB segments (segment_bytes) and each segment is
one operator new. glibc's mmap threshold is dynamic: the first generation of
segments is mmap'd and munmap() returns it, but freeing an mmap'd block larger than
the threshold raises the threshold to that size (up to 32 MiB), so every later
segment comes from the main heap instead. Freeing one of those only moves the bytes
onto the allocator's free list: unless the chunk reaches the top of the heap, RSS -
and therefore MemAvailable - does not move at all.
Reproduced on this machine (a stand-alone test that parks and drops 8 x 16 MiB
segments in two generations):
gen2 parked (8 x 16 MiB) : RSS 129 MiB
gen2: evict ONE mid-heap : RSS 129 MiB <- the reading does not move
gen2: after malloc_trim(0) : RSS 113 MiB <- only now is it visible
So the loop can evict conversation after conversation while the figure never moves,
and in the worst case empty the cache and still refuse the snapshot - worse than the
refusal it was meant to remove, and it throws away the prompt read that built it.
This is Arthur031221's Niko1221#1394, rebased onto the current main:
- conversation_release_freed_memory() (malloc_trim(0) under __GLIBC__, a no-op
elsewhere) is called before the first re-read and after every eviction, so a
dropped conversation shows up in the next reading.
- admit_ram() gives up before evicting anything when the missing bytes exceed
evictable_bytes(), so a shortfall the parked conversations cannot cover no longer
empties the cache for nothing, and it re-checks the figure after each reading so
losing telemetry ends the loop.
Adapted to what main has since gained: the reading stays the memory guard's
(sample_host_memory(): MemAvailable, the cgroup limits above the engine,
--memory-limit-mib) and the refusal line keeps ram_note(); evictable_bytes() counts
only the entries evict_oldest() may actually take, i.e. those not holding a pinned
shared prefix (Niko1221#879).
Tests are Niko1221#1394's: a simulated host that returns half, a fifth, or none of what was
dropped, another process taking RAM between evictions, a host already under the
floor, an empty cache, and no telemetry at all. (136 check() sites here against the
109 in main's current gate.)
conversation_cache_test: 4276 checks passed
conversation_memory_test: 24 checks passed
(-DSTRATA_BUILD_CONVERSATION_TESTS=ON)
Supersedes Niko1221#1394 (unmergeable against main now that main has the pinned prefixes,
sample_host_memory() and --session-save-reclaim).
…p and stops evicting when it cannot help (rebased Niko1221#1394) Source: Niko1221#2253 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…up robustness, conversation cache memory) Serve: Niko1221#2290 Niko1221#2284 Niko1221#2285 Niko1221#2170 Niko1221#2140 Niko1221#2278. Setup: Niko1221#2161 Niko1221#2287. Engine: Niko1221#2253 Niko1221#2265 Niko1221#2247 Niko1221#2217 Niko1221#2243. New opt-in engine flags: --session-state-codec (enabled in the local configs), --conversation-cache-park-gain and --drop-file-cache (left off: no measured benefit on the RX 7600 XT box). Bench: bench/results/upstream-prs-2026-10-13. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Small consistency point from reading the branch: the comment above |
|
Good catch - fixed in f09887f: the |
|
Thanks. The glibc mmap-threshold finding is a useful one, but it is a fix for the RAM gate rather than a speed change, so it is outside this round. We would take it as a bug fix. |
|
I hit this bug independently on a single RTX 3090 box and tested this PR there, against unpatched v0.1.44.1 and against a
@Niko1221, since you'd take this as a bug fix, I hope the numbers help. Setup
Unpatched v0.1.44.1: the bug, every runSame box, same sequence, on two different days. Run 1:
Run 2 (the same-day control for this PR): at 250K the gate refused twice in the ladder. In the soak, the decisive park was admitted only after evicting 3 conversations ( This PR: results
|
| Run | Trims | Median | Max | Total |
|---|---|---|---|---|
| 1 | 14 | 111.1 ms | 417.0 ms (returned 5,357 MiB) | 2,402 ms |
| 2 | 12 | 114.5 ms | 481.9 ms (returned 5,330 MiB) | 2,379 ms |
| 3 | 13 | 111.2 ms | 434.0 ms (returned 5,588 MiB) | 2,396 ms |
- The time tracks the bytes returned: 40 ms for 0.5 GiB, about 110 ms for 1.4 GiB, 210-230 ms for 2.6-2.8 GiB, 330-480 ms for 4-5.5 GiB.
- The largest trims came in the 250K part of the ladder, where each park frees about 4.5 GiB of segments.
- In the soak (parks at up to 214K), trims returned 0.5-0.8 GiB in 37-67 ms.
- For scale: a 214K-token re-read costs about 90 s here, and a park 0.1-0.6 s.
All trims of run 1
malloc_trim #1 took 40.1 ms (returned memory); RSS 55904 -> 55403 MiB (500 MiB released); heap before: 5880 MiB, 538 MiB of it free
malloc_trim #2 took 75.3 ms (returned memory); RSS 57386 -> 56434 MiB (952 MiB released); heap before: 7613 MiB, 1004 MiB of it free
malloc_trim #3 took 111.6 ms (returned memory); RSS 56322 -> 54940 MiB (1382 MiB released); heap before: 7613 MiB, 2385 MiB of it free
malloc_trim #4 took 111.1 ms (returned memory); RSS 57400 -> 55986 MiB (1414 MiB released); heap before: 7843 MiB, 1457 MiB of it free
malloc_trim #5 took 169.7 ms (returned memory); RSS 55650 -> 53450 MiB (2199 MiB released); heap before: 7843 MiB, 3656 MiB of it free
malloc_trim #6 took 212.0 ms (returned memory); RSS 56131 -> 53455 MiB (2676 MiB released); heap before: 7620 MiB, 2756 MiB of it free
malloc_trim #7 took 417.0 ms (returned memory); RSS 56277 -> 50919 MiB (5357 MiB released); heap before: 7619 MiB, 5404 MiB of it free
malloc_trim #8 took 362.1 ms (returned memory); RSS 55501 -> 50924 MiB (4577 MiB released); heap before: 7623 MiB, 5295 MiB of it free
malloc_trim #9 took 365.0 ms (returned memory); RSS 55507 -> 50924 MiB (4583 MiB released); heap before: 7624 MiB, 5296 MiB of it free
malloc_trim #10 took 365.0 ms (returned memory); RSS 55492 -> 50921 MiB (4571 MiB released); heap before: 7624 MiB, 5299 MiB of it free
malloc_trim #11 took 48.9 ms (returned memory); RSS 56999 -> 56393 MiB (606 MiB released); heap before: 8380 MiB, 647 MiB of it free
malloc_trim #12 took 39.8 ms (returned memory); RSS 57261 -> 56776 MiB (485 MiB released); heap before: 8700 MiB, 550 MiB of it free
malloc_trim #13 took 36.9 ms (returned memory); RSS 57653 -> 57190 MiB (463 MiB released); heap before: 9378 MiB, 534 MiB of it free
malloc_trim #14 took 47.1 ms (returned memory); RSS 57815 -> 57232 MiB (584 MiB released); heap before: 9652 MiB, 644 MiB of it free
Side effects worth knowing
- The heap stays large between gates. The trim runs only when a reading comes out short, so between parks the freed segments stay in the heap: up to 8.9 GiB here, against 2.1 GiB when every segment is its own mapping.
- In the last minute of the soak, MemAvailable was about 1.1 GB lower than with the 4 MiB threshold (3.5-3.9 GB vs 4.6-4.9 GB).
- The gate's decisions are right; other programs on the machine just see less free RAM in between.
- Long prompts read about 2% slower right after a trim. In all three runs, the first 120K prompt (before any trim) read at the unpatched speed: 2,481 / 2,499 / 2,481 tok/s, against 2,474-2,508 unpatched. The 120K prompts after the first trims read at 2,417-2,449, and the 250K medians were 2,286-2,291 (unpatched: 2,341).
- My reading: the trim hands the heap's free pages back, so the next prompt's large buffers fault fresh pages in. That's the same thing that makes a fixed low threshold cost prefill everywhere, here paid only after a trim.
- In the soak, the main turns' total prompt time matched unpatched (100.0-100.2 s vs 100.9 s).
A residual case: the post-capture floor check (1 of 3 runs)
In run 1, while parking the 214K main conversation at the end of the soak:
strata serve: conversation cache: malloc_trim #14 took 47.1 ms (returned memory); RSS 57815 -> 57232 MiB (584 MiB released); heap before: 9652 MiB, 644 MiB of it free
strata serve: conversation cache: skip parking (RAM floor after capture: 3072 MiB needed, 3011 MiB available, source meminfo)
What happened:
- the gate admitted the snapshot after one trim, with no eviction and all four slots still parked (7.59 GB);
- the snapshot was captured;
- then the separate check after the capture found free RAM 61 MiB under the floor, and dropped the freshly captured snapshot. A next main turn would have re-read 214K tokens.
Why the other two runs didn't hit it: in both, an eviction at the previous park had left three conversations parked (6.71 GB), so the 214K capture had about 0.9 GB more room.
My reading: the gate passed with almost no margin, and the capture then took slightly more than the gate allowed for (or something else used RAM in between; the gate's additional figure isn't logged when it admits without evicting, so I can't tell which). This check never fired in 11 other runs of this sequence, unpatched or with a fixed threshold of 4-32 MiB.
Ideas, untested:
- Release once before the post-capture reading too, and read again before refusing. I built this as a test-only change (the third run above). Its reading never came out short in that run, so it never acted: no harm, but no evidence either.
- Make the post-capture check evict the oldest other parked conversation (with a release) instead of discarding the new snapshot, as the gate already does. This handles the overshoot whatever its cause, and keeps the newest snapshot.
- A small safety margin at the gate.
A workaround for anyone waiting
Until a fix is released, one line in the run config's env block (the server passes it to the engine) avoids the gate failure:
"GLIBC_TUNABLES": "glibc.malloc.mmap_threshold=4194304"- Thresholds tested (same sequence, one run each):
- 4 MiB and 8 MiB fix it.
- 16 MiB fixes it only partly. The smaller pieces (each buffer's last segment, and the 64 KiB → 16 MiB growing pieces of re-parks) still go to the heap, and glibc serves later 16 MiB requests from that free heap memory before it ever consults the threshold.
- 32 MiB and unset fail.
- Cost: about 4-9% prefill at 4K, about 3% at 32K, about 2-3% at 120-250K. The engine's own 8-16 MiB working buffers lose heap reuse. Decode is unaffected.
- This PR avoids that cost on short prompts.
Not tested
HIP and SYCL builds, Windows, macOS, layer split, --batch, --session-save-reclaim, and models other than Qwen3.8-Flash-Next IQ3_S. One machine, CUDA 13.0 on Linux only.
The test-only timing patch (on top of this PR; abridged: the #include <chrono> and a glibc ≥ 2.33 guard around mallinfo2() are left out)
void conversation_release_freed_memory() {
#if defined(__GLIBC__)
- malloc_trim(0);
+ static std::atomic<unsigned> trims{0};
+ auto rss_mib = [] {
+ long pages = 0, resident = 0;
+ if (FILE* f = std::fopen("/proc/self/statm", "r")) {
+ if (std::fscanf(f, "%ld %ld", &pages, &resident) != 2) resident = 0;
+ std::fclose(f);
+ }
+ return (double) resident * 4096.0 / (1024.0 * 1024.0);
+ };
+ const struct mallinfo2 mi = mallinfo2();
+ const double heap_mib = (double) mi.arena / (1024.0 * 1024.0), free_mib = (double) mi.fordblks / (1024.0 * 1024.0);
+ const double before = rss_mib();
+ const auto t0 = std::chrono::steady_clock::now();
+ const int released = malloc_trim(0);
+ const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
+ const double after = rss_mib();
+ std::fprintf(stderr, "strata serve: conversation cache: malloc_trim #%u took %.1f ms (returned %s); RSS %.0f -> %.0f MiB "
+ "(%.0f MiB released); heap before: %.0f MiB, %.0f MiB of it free\n",
+ ++trims, ms, released ? "memory" : "nothing", before, after, before - after, heap_mib, free_mib);
#endif
}I analyzed all the reports with my own bloodshot human eyes and guided the agent in this exploration. I used Claude (Anthropic) to help me run these measurements and co-write this report. This work started with me looking for a good parking strategy for Strata that works well for agentic coding workflows (the OpenCode harness), within the hardware constraints described above. All runs, logs and numbers come from my machine.
|
Thanks for the pull request. We're resetting the tracker for 0.2.0-beta. There are more than 1,300 open issues and pull requests, too many for us to review properly, so we're closing everything that isn't being worked on right now. Closed here does not mean rejected. To reopen it takes about two minutes:
The rules are in CONTRIBUTING.md. |
…ot help
The physical-RAM admission gate of the parking path (generate.cpp) evicts parked
conversations one at a time and re-reads the host's free RAM after each, on the
assumption that dropping a conversation is visible to /proc/meminfo at once. That
assumption is wrong for the allocation this cache actually makes.
ConversationBuffer is a list of 16 MiB segments (segment_bytes) and each segment is
one operator new. glibc's mmap threshold is dynamic: the first generation of
segments is mmap'd and munmap() returns it, but freeing an mmap'd block larger than
the threshold raises the threshold to that size (up to 32 MiB), so every later
segment comes from the main heap instead. Freeing one of those only moves the bytes
onto the allocator's free list: unless the chunk reaches the top of the heap, RSS -
and therefore MemAvailable - does not move at all.
Reproduced on this machine (a stand-alone test that parks and drops 8 x 16 MiB
segments in two generations):
gen2 parked (8 x 16 MiB) : RSS 129 MiB
gen2: evict ONE mid-heap : RSS 129 MiB <- the reading does not move
gen2: after malloc_trim(0) : RSS 113 MiB <- only now is it visible
So the loop can evict conversation after conversation while the figure never moves,
and in the worst case empty the cache and still refuse the snapshot - worse than the
refusal it was meant to remove, and it throws away the prompt read that built it.
This is Arthur031221's Niko1221#1394, rebased onto the current main:
- conversation_release_freed_memory() (malloc_trim(0) under __GLIBC__, a no-op
elsewhere) is called before the first re-read and after every eviction, so a
dropped conversation shows up in the next reading.
- admit_ram() gives up before evicting anything when the missing bytes exceed
evictable_bytes(), so a shortfall the parked conversations cannot cover no longer
empties the cache for nothing, and it re-checks the figure after each reading so
losing telemetry ends the loop.
Adapted to what main has since gained: the reading stays the memory guard's
(sample_host_memory(): MemAvailable, the cgroup limits above the engine,
--memory-limit-mib) and the refusal line keeps ram_note(); evictable_bytes() counts
only the entries evict_oldest() may actually take, i.e. those not holding a pinned
shared prefix (Niko1221#879).
Tests are Niko1221#1394's: a simulated host that returns half, a fifth, or none of what was
dropped, another process taking RAM between evictions, a host already under the
floor, an empty cache, and no telemetry at all. (136 check() sites here against the
109 in main's current gate.)
conversation_cache_test: 4276 checks passed
conversation_memory_test: 24 checks passed
(-DSTRATA_BUILD_CONVERSATION_TESTS=ON)
Supersedes Niko1221#1394 (unmergeable against main now that main has the pinned prefixes,
sample_host_memory() and --session-save-reclaim).
|
/reopen |
|
The check passed, but GitHub would not reopen it (Validation Failed: ). If the branch was deleted, restore it or open a new pull request and link this one. |
|
/reopen |
|
Reopened, thank you. A maintainer will pick it up from here. If it conflicts with main, rebase it first; that speeds up review. |
Problem and result
The physical-RAM gate of the parking path assumes an evicted parked conversation is visible to
/proc/meminfobefore the next check reads it. That assumption is wrong for the allocations this cache actually makes:ConversationBufferis a list of 16 MiB segments, and glibc's mmap threshold is dynamic - the first generation of segments is mmap'd, freeing one raises the threshold past 16 MiB (capped at 32 MiB), and every later segment comes from the main heap. Freeing one of those only moves the bytes onto the allocator's free list: RSS, and with itMemAvailable, does not move.On today's
main(0.2.0-beta) the gate added for #1347 evicts parked conversations one at a time and re-reads the figure after each. In steady state the figure never moves, so the loop can empty the whole cache and still refuse the snapshot - worse than the refusal it was meant to remove: every parked conversation is lost and every later request re-reads its whole prompt (measured in #1347: 270k tokens, 96 s, 32 times in one session).After this change a release (
malloc_trim(0)) runs before the first re-read and after every eviction, so a dropped conversation shows up in the next reading; and where the missing bytes exceed what the parked conversations could give back, the gate gives up before evicting anything.Related discussion
#1347 (the issue, reproduced on an RTX 4080 SUPER with 91 GB RAM), #1394 (the original PR by @Arthur031221, unmergeable after #879 / #1601 / #1573 landed), and this thread:
Approach
conversation_release_freed_memory()(malloc_trim(0)under__GLIBC__, a no-op elsewhere) is called before the first re-read and after every eviction.ConversationCache::admit_ram()gives up before evicting anything when the missing bytes exceedevictable_bytes()- whatevict_oldest()may actually take, i.e. entries not holding a pinned shared prefix - and re-checks the figure after each reading, so losing telemetry ends the loop.sample_host_memory():MemAvailable, the cgroup limits above the engine,--memory-limit-mib) and the refusal line keepsram_note().Validation
Rebased on
mainate88ee39d(0.2.0-beta, engine 0.1.45.3); branch tip3dbc9aca. Built and tested by me on the serving box from #1347: Linux, RTX 4080 SUPER (32 GB), 94 GB RAM, CUDA 13.2, sm_89.Passed:
cmake -B build -DSTRATA_BUILD_CONVERSATION_TESTS=ON -DSTRATA_ENABLE_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=89 && cmake --build build --target conversation_cache_test conversation_memory_testconversation_cache_test: 4829 checks passedconversation_memory_test: 24 checks passedsession_save_reclaim_test119 checks,conversation_file_test201 checks,conversation_split_failure_testpassedstratacompiles (CUDA 13.2, sm_89)Failed: none.
Not run: running the rebuilt 0.2.0-beta engine on the GPU (this box still serves on the deployed 0.1.x build; the binary compiled, it was not started); HIP, SYCL and Metal builds.
Separate from my checks: @alfre2v ran this branch (at
ab963c96+f09887f6) end-to-end on an RTX 3090 / 64 GB box, results in Related discussion; their soak numbers are theirs, not mine.Limits
malloc_trim(0)returns what has coalesced to an arena's top; a mid-heap segment that cannot coalesce stays invisible even after the release. The loop still converges: further evictions eventually coalesce, andevictable_bytes()bounds the case where even emptying the cache could not help.--session-save-reclaim(serve: reclaim host caches and wait for SAVE memory admission #1573) keeps the same assumption between its steps; left alone here to keep this PR to the parking gate.