Files
Niko1221 8c0301484e Conversation cache: skip a restore only when a card would have to create its copy stream and has no room for it (#2409)
Problem. #2408 / PR #2409: restoring a large parked conversation on a layer split ended the server with a CUDA out of memory
on a later card. The PR checks the free VRAM of every card against --vram-reserve-mib before a restore. Its own review
points out that a loaded card is always below that reserve, so the check would skip nearly every restore on a split (and
on one GPU). It is not ported.

What a restore allocates. conversation_snapshot_restore writes into the session's existing buffers (pools(st), the GDN
state, the indexer tails); refresh_residency only launches kv_stream_reset or copies into the existing ring. The elastic K/V
growth (kvg_ensure) allocates, but only on one GPU. The one thing a restore can create on a card is the calling thread's
private copy stream for it (conversation_copy.hpp private_stream), which it takes from the pool of finished threads when
there is one (#3350). A failed restore is fatal by contract (the session may be half written). So the need is one stream
and one event, and only for a card whose stream does not exist yet (a snapshot loaded from disk, or a restore on a thread
that never copied on that card).

Change. conv_cache.hpp: kRestoreFirstUseMib (64) and restore_fits(free, stream_ready, n, need): a restore is refused only
for a card that has no copy stream yet and less than 64 MiB free. conversation_copy_stream_ready(device) says whether a
card has one. generate.cpp: before the restore, each card of the run is checked; a snapshot that would not fit goes back to
the cache and the prompt is read again, with a log line. A card that has its stream is never refused, however full.
No sample (a failed cudaMemGetInfo) means the restore goes ahead as it did.

Tests. conv_cache_test: the predicate (cards with a stream are admitted at any free size; the edge at the need; no
sample / no cards / no need). conversation_copy_streams_test: copy_stream_ready before and after the first copy, in the pool
after a thread ends, and the measured cost of a card's first copy stream and event (printed; the test requires under
16 MiB, so 64 MiB leaves a wide margin). conversation_snapshot_test: after the first restore, six more restores of two
images leave the free VRAM where it was.

Not covered. The pure predicate and the generate.cpp code build on z440 (CUDA 13.0); the GPU tests are listed in the stream
notes. No model run: gates/2409-restore-guard.sh (a held-VRAM start on a layer split) shows the skip line and an
unchanged answer on the second turn.
2026-10-27 02:02:46 +01:00

122 lines
7.2 KiB
C++

// include/strata/program/conv_cache.hpp - the conversation cache's retention policy: which checkpoint
// leaves when the cache is over its slot budget.
//
// The cache holds conversation checkpoints: snapshots of the running state (the GDN recurrence, the PLE
// history, the QSA indexer tails - `ConvCheckpoint` in generate.cpp) that a request resumes its prompt
// from instead of reading those tokens again. The request path keeps only the checkpoints whose tokens
// are a prefix of the current prompt - any other checkpoint's positional cells have been overwritten,
// because the KV cache is one arena that holds one branch of history at a time - so at any moment the
// retained checkpoints are a CHAIN: sorted by length, each a prefix of the next. That is a radix cache's
// tree collapsed onto the one branch of history the session can hold.
//
// The chain's root is therefore the deepest point every request so far has shared - in practice the end
// of the system prompt, which every new chat of the same client mounts through - and it is exactly the
// node a radix cache keeps alive while its leaves rotate. First-in-first-out kept it only by accident of
// being first: after `prompt_cache` newer checkpoints it was gone, and the next chat read the whole
// prefix again at prefill speed. So:
//
// * the root is PINNED: dropping it frees one slot's ~118 MB and costs every future conversation that
// shares the prefix a full re-read of it (a 30K system prompt is ~30 s at ~1,000 tok/s);
// * every other slot rotates by least recent use. A checkpoint's stamp advances when it is created,
// when a new checkpoint lands on its length, and when a request mounts through it.
//
// With a budget below two slots there is no room to keep root and leaf apart, so the pin switches off and
// the oldest checkpoint leaves - the previous behaviour, one slot = the newest point only.
#pragma once
#include <cstddef>
#include <cstdint>
namespace strata::program::conv_cache {
/// The index in `stamps` of the chain item to drop once the chain holds more than `cap` items. `stamps`
/// are the items' last-use stamps; the caller owns the chain and erases the returned index. Pure and
/// deterministic so conv_cache_test.cpp can walk the scenarios by hand.
inline size_t eviction_victim(const uint64_t* stamps, size_t n, int64_t cap) {
if (cap < 2 || n < 2) return 0; // no room for root and leaf: the pin is off, the oldest leaves
size_t v = 1; // the root (0) is pinned; least recent use among the rest
for (size_t i = 2; i < n; ++i)
if (stamps[i] < stamps[v]) v = i;
return v;
}
/// --prompt-cache-tail: the extra checkpoint near the prompt's end (`tail[i]`) is the first to go - it serves
/// only a branch of the last request - unless it is the newest item (the one just saved: `n > 1` and the newest
/// stamp). With no such item this is the plain policy. Never the root.
inline size_t eviction_victim(const uint64_t* stamps, size_t n, int64_t cap, const bool* tail) {
if (tail != nullptr && n >= 2 && cap >= 2) {
size_t newest = 0;
for (size_t i = 1; i < n; ++i)
if (stamps[i] > stamps[newest]) newest = i;
for (size_t i = 1; i < n; ++i)
if (tail[i] && i != newest) return i;
}
return eviction_victim(stamps, n, cap);
}
/// The pin=N shared prefixes (`pinned[i]`): they are never the victim, whatever their stamp - the queries that branch
/// from one keep coming back to it, and a least-recently-used rule would drop it while they still do. The root and
/// the tail rule are as above. When every other item is pinned too, the policy falls back to the oldest pinned one:
/// the cache still keeps to its `cap` (the caller limits how many pins it takes, so this is a backstop).
inline size_t eviction_victim(const uint64_t* stamps, size_t n, int64_t cap, const bool* tail, const bool* pinned) {
if (pinned == nullptr) return eviction_victim(stamps, n, cap, tail);
if (cap < 2 || n < 2) return 0;
size_t newest = 0;
for (size_t i = 1; i < n; ++i)
if (stamps[i] > stamps[newest]) newest = i;
if (tail != nullptr)
for (size_t i = 1; i < n; ++i)
if (tail[i] && !pinned[i] && i != newest) return i;
size_t v = n; // the least recently used among the unpinned non-root items
for (size_t i = 1; i < n; ++i)
if (!pinned[i] && (v == n || stamps[i] < stamps[v])) v = i;
if (v != n) return v;
v = 1; // all pinned: the oldest of them (never the root, index 0)
for (size_t i = 2; i < n; ++i)
if (stamps[i] < stamps[v]) v = i;
return v;
}
/// #2409: what a conversation restore can allocate on a card. It writes into session buffers that already exist; the one
/// thing it may create is the calling thread's private copy stream for that card (conversation_copy.hpp), which it takes
/// from the pool of finished threads when there is one, and the first launch of the two small residency kernels loads
/// their code. A stream and an event cost well under 2 MiB where it was measured (conversation_copy_streams_test prints
/// it); 64 MiB covers both many times over and is far below the reserves (--vram-reserve-mib 700), so the check can only
/// fire on a card that is really full.
constexpr uint64_t kRestoreFirstUseMib = 64;
/// Whether a parked snapshot can be restored without a first-use allocation failing: every card for which the restore
/// would have to create its copy stream (`stream_ready[i]` false) must have `need_bytes` free. A card that already has a
/// stream needs nothing, so it never refuses - unlike a floor on `--vram-reserve-mib`, which a loaded card is always under
/// (#2409's review). A missing sample (`free_bytes` null) does not refuse.
inline bool restore_fits(const uint64_t* free_bytes, const uint8_t* stream_ready, size_t n, uint64_t need_bytes) {
if (free_bytes == nullptr || stream_ready == nullptr || need_bytes == 0) return true;
for (size_t i = 0; i < n; ++i)
if (!stream_ready[i] && free_bytes[i] < need_bytes) return false;
return true;
}
/// --prompt-cache-spread: the item whose removal leaves the smallest gap for its distance from the newest, so the kept
/// points thin out geometrically towards the root and an edit d tokens before the end reads about d again. `lens`:
/// the chain's token counts. Never the root, the newest or a pinned item; the tail rule first, as above.
inline size_t spread_victim(const int64_t* lens, const uint64_t* stamps, size_t n, int64_t cap, const bool* tail,
const bool* pinned) {
if (n >= 3 && cap >= 2) {
auto candidate = [&](size_t i) { return !(pinned && pinned[i]); };
if (tail != nullptr)
for (size_t i = 1; i + 1 < n; ++i)
if (tail[i] && candidate(i)) return i;
size_t v = n;
double best = 0;
for (size_t i = 1; i + 1 < n; ++i) {
if (!candidate(i)) continue;
const double gap = double(lens[i + 1] - lens[i - 1]) / double(lens[n - 1] - lens[i - 1]);
if (v == n || gap < best) { v = i; best = gap; }
}
if (v != n) return v;
}
return eviction_victim(stamps, n, cap, tail, pinned);
}
} // namespace strata::program::conv_cache