Skip to content

Selene host-tier eviction: unified host memory, local spill on pressure, optional max age - #141

Merged
lukealonso merged 18 commits into
NVIDIA:mainfrom
yatesdr:selene-eviction
Nov 2, 2026
Merged

lukealonso merged 18 commits into
NVIDIA:mainfrom
yatesdr:selene-eviction

Conversation

@yatesdr

@yatesdr yatesdr commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

What

This adds eviction and backpressure for Selene's host tiers. It stops checkpoints being lost between the GPU and host memory under pressure, replaces the fixed row and page slabs with one pool of equal slots plus an automatic intake, adds an optional maximum record age, and makes local mode write to disk only when memory needs the room.

It also contains two fixes found while validating on a disk-heavy workload: record files are preallocated so direct writes overlap, and a parked record's ancestors are written to disk before it.

Changes

  1. One pool of equal host slots.
    • Host memory was two fixed slab classes (a quarter for rows, the rest for pages), so a row-heavy model filled its rows while most page memory sat empty.
    • Now there is one mapping of page-sized slots. A row takes as many slots as it needs, laid out per rank exactly as pages are, so one resolver serves split, registered and pool addresses.
    • The on-disk record format and checksum values are unchanged, so existing records recover. A record written by the previous two-rank format is covered by a test.
    • The memory tier and the disk tier's staging now share this one pool. The separate fixed staging pool is gone.
  2. One host-memory setting, plus an automatic intake.
    • The setting is the warm-tier size, and 0 is allowed.
    • On top of it, an intake buffer receives GPU publications. It is sized at startup from the layout and the selected concurrency (room for two rows per active request and two transfer windows). It is shrunk to fit the host budget, never refused.
    • Allocation keeps the intake free by evicting the coldest clean records on demand, so staging never waits on a warm tier full of durable copies.
    • The coldest dirty records are written to disk (or retired to the pool) once free plus clean slots fall below two intakes. At most about two intakes of the coldest records are written ahead of need, so a large-memory host does not write warm records early.
    • This replaces the earlier headroom rule, the memory-only pool retirement rule, and the rows-against-concurrency warning. Startup now warns once, only when the intake had to shrink, and says how much memory it needs.
    • Every store gets at least two windows of intake.
    • Behaviour change: a disk-only instance now reserves a larger intake (about 0.5-8 GB of host memory depending on model and concurrency, where it used 0.2-0.7 GB).
  3. Every tier combination works. Warm or none, disk or none, pool or none. Pool-only allocates no local pool. Multi-host with a warm tier is still refused. With nothing configured the cache stays disabled.
  4. Local mode spills on pressure.
    • It no longer writes every record through to disk at publish. It uses the parked/spill path that pool mode already uses.
    • The opt-in --prefix-cache-drain-on-shutdown now covers local mode.
    • Behaviour change: by default, a stop or crash loses records that were only in memory, the same rule as pool mode.
  5. Optional maximum record age.
    • Set with --prefix-cache-max-age DURATION, [prefix_cache] max_age, or the manager's --max-age. It is off by default, and off means no extra work.
    • Records unused for that long are dropped from memory, disk and the manager store. The sweep runs at most once a minute, oldest first, for at most 64 records, and stops at the first fresh leaf.
    • After a restart, disk records that were reused but never rewritten can expire up to one max age early. That costs a recompute, never a wrong answer.
  6. Metrics: host_cache_expired_total{tier}, a reason label on retry_abandoned, and slot metrics (slots in use, slots total, intake slots) in place of the row gauge.
  7. Restore cost estimate. A fetch's parallel disk reads are now timed as one transfer. Before, each read was timed separately, so a restore looked about twice as expensive as it was and could be refused as slower than prefill.
  8. Preallocate each record file.
    • Record files were written with O_DIRECT into a fresh, never-sized temporary file, so every write extended it. On ext4 an extending direct write holds the inode lock exclusively, which serialized the disk ring's chunks at queue depth 1.
    • On a host whose record NVMe occasionally loses a completion interrupt (the kernel recovers after its 30 s I/O timeout), one lost interrupt froze the whole disk pipeline and publications were lost.
    • Create now allocates the record's final length with fallocate (Linux only; a filesystem without it behaves as before). Every direct write is then an overwrite, which runs under the shared lock, so writes overlap. The temporary is removed if preallocation fails.
  9. Write a parked record's ancestors to disk before it.
    • Parked records spill coldest first, so a hot shared ancestor (the root every conversation shares) was never written while its children were. The disk held ready children under an unwritten parent, and a republish at that key was retried until its GPU snapshot was evicted and counted as abandoned.
    • Spill now writes the root-most ancestor that has not committed instead of the record, and the record waits for it. Ancestors started this way count toward the pressure target and the in-flight cap.
    • The shutdown drain starts the ancestor and waits for it, or gives the record up if the ancestor cannot start. A parent that fails twice, or whose file fails to commit, drops its parked children.
  10. Test-only: directory-lock acquisition in tests retries a lock briefly inherited by another test's child process. One shared helper replaces a duplicate retry.

Behaviour changes

  • Local mode no longer writes at publish; by default a stop or crash loses records held only in memory. Use --prefix-cache-drain-on-shutdown to keep them.
  • Disk-only instances reserve a larger host intake.
  • Max age is new and off by default.
  • Startup warns once, only if the intake had to shrink.
  • A restart without the shutdown drain restores the same as before; no restart benefit is claimed for the ancestor fix.

Testing

DeepSeek V4.1 Flash, TP4, on an SM120 host with 4x RTX PRO 6000. Bursts are 3k-token conversations, 32 concurrent, 128 per run. "Abandoned" means publications lost between the GPU and the host.

Test Result
Earlier version of this PR (before the two fixes), 8G burst lost publications in 5/8 runs (up to 115); main 0/6
Same version, disk-only lost in 6/6 (up to 590); main 1/6 (45)
With preallocation, disk-only 8/8 runs: 0 abandoned, 128/128 restored; 7 of them hit an NVMe completion timeout
Same window, build without preallocation, disk-only 3/4 runs lost (376, 69, 350)
With preallocation, 8G burst 6/6: 0 lost during the burst
With ancestor-first spill, 8G burst and disk-only 4/4 and 3/3: 0 abandoned in burst and probe phases, 128/128 restored (before: 16-19 harmless duplicate inflight abandons per burst run)
Restart without the shutdown drain 62 restored / 65 partial / 1 recomputed, same as without the fix
Combined build (all six open Selene PRs on main): smoke tool calls with thinking 5/5 single and 8/8 parallel, arithmetic 20/20, 25.5k-token repeat 1.68 s to 0.06 s, host-cache hit after restart
Combined build: 8G burst x2, disk-only x1 0 abandoned, 128/128 restored
Unified host memory, 20-minute Qwen soak 0 errors, flat RSS
Restore oracle (memory, disk, pool), DeepSeek and Qwen all pass
Qwen 4G and 16G bursts 0 abandoned (main lost 4 in one 4G run)
Qwen throughput, 1-16 clients equal or higher than main

The slot pool, intake and soak results were measured on the revision before the two fixes; the fixes change only record-file creation, spill order, the shutdown drain and the drop of a failed parent's parked children. The disk-only and 8G burst rows above were rerun on the final revision.

  • Unit tests (Linux): selene 366 pass. They cover multi-piece rows across the tier matrix, a record written by the previous format, the intake and floors, preallocated length, a cold child waiting for its parked parent, the drain when an ancestor cannot start, and a parent that fails twice. helios passes. selene-manager passes except two kill-process multi-host tests that need a non-container host environment.
  • The upstream page_only_nodes_supply_pages_but_never_match test passes unmodified.

Notes

  • Drop counts and restore behaviour depend on the restore cost estimate. The separate PR Selene: let a slow transfer-rate estimate recover without new samples #155 (rate-estimate recovery) fixes a sticky slow-disk estimate that can refuse restores after one slow read.
  • The NVMe completion timeouts come from the test host's NVMe path (they continued after a drive firmware update, so the drive, platform or driver is not yet known). Preallocation makes the pipeline tolerate them rather than removing them.
  • Rows become several pieces (for example 23 for DeepSeek, 135 for Qwen TP1), and a row smaller than a slot uses one whole slot. This is documented in the prefix-cache docs.

Keep the upstream quarter split for row and page slabs. Remove concurrency
from slab sizing and preparation; the existing row warning alone consumes
selected concurrency and fires when allocated rows fall below it.

Floor MemoryStore's page headroom at its largest record, sharing the accessor
between composite spills and memory-only pool retirement. Poll memory-only
stores through the existing retire_for path before another publication needs
the slabs. Cover both paths with CPU large-record regressions. Keep GPU
retry records unpinned and document early spills for near-capacity records.

Production net: +8 lines, excluding tests; all-file net: +92 lines.

Validation: CI-exact locked release no-run builds for Selene and manager with
test-support, Helios server/runtime-package lib and Flare; both RDMA checks;
and the Selene and manager suites pass. The manager suite was rerun with
4 GiB of container shared memory after exhausting the default 64 MiB. One
client fixture failed to start its manager and passed on an isolated retry.
GPU serving and performance comparisons were not run.

Design-Signed-Off-By: yatesdr; design-review=1c01
Local and pooled composite stores park memory copies and use the existing
headroom spill path. Shutdown uses the existing opt-in drain once and preserves
committed files. Default stops and crashes lose DRAM-only records; memory copies
remain until their spills finish. Non-pressure write failures receive one retry.
Delete the unused TierStore::cascade hook, forwarder and callers.
Production net: -21 lines, excluding tests; all-file net: -41 lines.

Branch validation: CI-exact locked release no-run builds for Selene and manager
with test-support, Helios server/runtime-package lib and Flare pass. Both RDMA
test checks pass. Full suites ran once: Selene 350 passed; manager 301 passed.
GPU serving and latency comparisons were not run.

Design-Signed-Off-By: yatesdr; design-review=1c01
Instance and manager max age default off. Enabled expiry refreshes last use
through the existing ancestor walk without changing ancestor LRU positions.
Preserve ordinary per-key LRU order and the manager's store-wide access counter.
Duplicate publication refreshes last use only in memory, disk, both composite
Present paths and the manager, matching their unchanged LRU order at the base.
Composite lookup touches only the disk index, as before.
Each index selects at most 64 expired leaves per minute, stopping at the first
fresh leaf; the manager sweeps every namespace. Reuse cascading removal,
unlinking and memory release. Fetch completion refreshes ancestry once.

Remove timestamp jobs, completion paths, extra pins, the timestamp job kind,
mtime rank relay, modified state and stamp_due rate limiting. The ancestor walk
no longer needs a callback. Recovery retains the existing file-mtime scan.
After a restart, disk records reused but never rewritten can expire up to one
max age early: recomputation, never wrong output. Document this for both stores.
Production net: +194 lines, excluding tests; all-file net: +602 lines.

Branch validation: CI-exact locked release no-run builds for Selene and manager
with test-support, Helios server/runtime-package lib and Flare pass. Both RDMA
test checks pass. Full suites ran once: Selene 350 passed; manager 301 passed.
Coverage includes ancestry, bounded expiry, two manager namespaces, resident
memory copies of disk records, unchanged hit mtimes and mtime recovery.
GPU serving and latency comparisons were not run.

Design-Signed-Off-By: yatesdr; design-review=1c01
Keep flare_host_cache_retry_abandoned_total and add a bounded reason label.
Carry the retry cause through Deferred events and the existing retry list;
include terminal oversize and I/O losses. Extend the existing metrics paths
with per-tier expiry and actual row allocation/capacity. The manager exposes
its expiry totals through metrics_text. Count expired removals only as expiry,
keeping pressure evictions separate through the existing accounting path.
Production net: +44 lines, excluding tests; all-file net: +109 lines.
Cumulative production net: +232 lines, excluding tests.

Branch validation: CI-exact locked release no-run builds for Selene and manager
with test-support, Helios server/runtime-package lib and Flare pass. Both RDMA
test checks pass. Full suites ran once: Selene 350 passed; manager 301 passed.
GPU serving and latency comparisons were not run.

Design-Signed-Off-By: yatesdr; design-review=1c01
Record each fetch's parallel disk bytes over the wall time from the first
read start to the last completion in each wave, using the existing rate
estimator. Reset the timing fields after sampling so a later wave does not
sample the same bytes again. Remove per-node timestamps that priced a parallel
chain at one read's bandwidth. Cover two concurrent record reads and their
combined bytes per interval.

Validation: CI-exact locked release no-run builds for Selene and manager with
test-support, Helios server/runtime-package lib and Flare; both RDMA checks;
and the Selene and manager suites pass. The manager suite was rerun with
4 GiB of container shared memory after exhausting the default 64 MiB. One
client fixture failed to start its manager and passed on an isolated retry.
GPU serving and performance comparisons were not run.

Design-Signed-Off-By: yatesdr; design-review=1c01
Move the existing disk-store reopen retry into one shared test helper and use
it at every test-side directory-lock acquisition. Parallel subprocesses can
briefly inherit another test's flock between fork and exec. Retry only
WouldBlock, sleeping 1 ms between attempts for up to 5 seconds; return every
other error immediately. Production acquisitions stay unchanged.
Production net: 0 lines; all-file net: +12 lines.

Validation: locked release no-run builds for Selene and manager with test-support,
Helios server/runtime-package lib and Flare pass, as do both RDMA test checks.
Before this test-only change, full suites passed once: Selene 350, manager 301.
After it, all ten full Selene runs with test-support passed: 350 tests each,
3,500 total, zero failures. The changed manager and Helios test callers compile.
GPU tests and serving comparisons were not run.

Design-Signed-Off-By: yatesdr; design-review=1c01
Host memory was two fixed slab classes, a quarter for rows and the rest for
pages, so a row-heavy model filled its rows while most page memory sat
empty. Rows now occupy as many page-sized slots as they need, laid out per
rank in each rank's share of the slot exactly as pages are, so one resolver
serves split, registered and pool addresses. CPU readers walk a row's
pieces in record order, and the row checksum streams over them, so the
on-disk record format and checksum values are unchanged and existing
records recover. Compaction may take the coldest single-child interior
row. The ratio sizing and per-class minimums are removed.

Design-Signed-Off-By: yatesdr; design-review=1c01
The disk tier kept its own fixed staging pool and copier mapping beside the
memory pool. Both now draw slots from one mapping and one registration; only
the free list sits behind a lock, so address lookups on the copy path take
none. Memory and disk keep independent copiers and the existing close order,
and memory maintenance never waits on staging. A copier that fails to close
leaves the mapping leaked rather than reused.

Design-Signed-Off-By: yatesdr; design-review=1c01
Count a record's slots, row pieces included, wherever memory is spilled or
retired, and keep one slot count for headroom, retirement and held demand.
Allocate and release a record's slots in one call under one lock, resolve
slot addresses and per-record slot counts in one place each, and carry the
warm-tier size in the prepared plan instead of a pool flag. Composites
require staging to share the memory pool. A failed copier close now keeps the
mapping by forgetting one handle. Per-rank parts no longer carry a separate
region type, and unreachable guards and a dead parameter are removed.

Design-Signed-Off-By: yatesdr; design-review=1c01
Host memory is the warm tier plus an intake sized at startup from the layout
and the selected concurrency: room for two rows per active request and two
transfer windows, shrunk to fit the host budget rather than refused. Memory
allocation keeps the intake free by evicting the coldest clean records on
demand, so staging never waits on a warm tier full of durable copies, and the
coldest dirty records are written once free plus clean slots fall below two
intakes. Clean slots are counted as records change state, not by scanning.
The fixed staging pool and its constants are removed.

Design-Signed-Off-By: yatesdr; design-review=1c01
Cover warm or no warm, disk or no disk, and pool or no pool: each publishes,
evicts, restores byte-identically and drains. Multi-host tests check that a
peer stages through the host intake with multi-piece rows, that a warm tier
is refused, that pool-only allocates no local pool, and that nothing
configured stays disabled. The tier dispatch itself needed no change.

Design-Signed-Off-By: yatesdr; design-review=1c01
A store built with no intake never reached the clean floor, so a pooled
memory tier filled without retiring records to the manager. Memory and
staged stores now raise the intake to two full transfer windows in one place,
and test fixtures size their pools through it. Retirement keeps its single
rule.

Design-Signed-Off-By: yatesdr; design-review=1c01
With one slot class the rows-against-concurrency warning no longer means
anything. Startup now warns once, and only when the host budget forced the
intake below its requested size, naming the memory it needs.

Design-Signed-Off-By: yatesdr; design-review=1c01
Host-cache metrics report slots used, slots total and the intake instead of
row capacity. The help text and prefix-cache documentation describe one
warm-tier setting (0 allowed), the automatic intake, every tier combination,
the cost of multi-piece rows and the larger intake that disk-only instances
now reserve.

Design-Signed-Off-By: yatesdr; design-review=1c01
A memory-only store refused a record as Oversize only when it exceeded the
warm slots, but allocation also keeps the intake free, so a record between
the two retried forever. Fit and shortfall are now one pair of functions,
used by every caller, with one evictable predicate. Chain retention and spill
selection no longer scan every record when nothing is short. The intake is
fixed when the mapping is built. Forwarding accessors and a dead row branch
go, helios resolves only its own rank's row pieces, and slot wording replaces
the old slab terms. Tests cover three-slot rows across the tier matrix and a
two-rank record written by the previous format, and the large-pool property
test no longer starves concurrent tests.

Design-Signed-Off-By: yatesdr; design-review=1c01
Record files are written O_DIRECT into a fresh temporary file that was never
sized, so every write extended it. On ext4 an extending direct write holds
the inode lock exclusively until it completes, which serialized the disk
ring's chunks at queue depth one; one lost NVMe completion interrupt then
froze the whole pipeline for the kernel's 30 s I/O timeout. Create now
allocates the record's final length with fallocate (Linux only; a filesystem
without it behaves as before). With the blocks allocated every direct write
is an overwrite, which ext4 runs under the shared lock, so the writes
overlap; a sparse set_len would not help. If preallocation fails, the
temporary is removed before the error is returned. The commit path never
relied on the temporary's size: the final write is the state row at the end
of that length, and the renamed file keeps it.

Validation: selene unit tests pass (362), including a new check that a
created file has its final length. On a host whose record NVMe loses
completion interrupts, DeepSeek 3k c32 disk-only: the unfixed build abandoned
376, 0, 69 and 350 publications in four runs with up to 63 writers parked on
the inode lock; the fixed build abandoned none in eight runs (restored
128/128 each), including seven runs that hit an NVMe completion timeout,
with no writer parked on the lock (mean write depth about 5.5).

Design-Signed-Off-By: yatesdr; design-review=1c01
Invariant: a record's file is written only after its parent's file has
committed, so no record becomes ready on disk under an unwritten parent.

Parked records spill to disk only under memory pressure, coldest first. A
hot shared ancestor (the root every conversation shares) is never the
coldest, so it stayed parked for the whole run while its children were
written. The disk then held ready children whose chain was incomplete.
Symptom: a republished checkpoint at such a key got Retry(Inflight) and was
retried until its GPU snapshot was evicted, then counted as
retry_abandoned{inflight}. One key was retried 305 times, and a burst run
counted 16-19 retry_abandoned{inflight} with the content still held.

spill() now writes the root-most ancestor that has not committed instead of
the record, and returns the key whose write it started (None when nothing
started). The record waits until that file has committed. spill_parked
counts writes in flight as before and adds the started key's slots, so
ancestors started for a cold child count toward the pressure target and the
in-flight cap (MAX_SPILLS). store_status and the lookup predicate are
unchanged.

Shutdown drain: when the record's ancestor is not yet written, the drain
starts the ancestor and waits for it with its existing in-flight wait. If
the ancestor cannot start (no reservation, write refused), the record is
given up and counted as skipped rather than retried until the timeout.

A parked record whose final write fails (the second failure), or whose
file fails to commit, now also drops the parked records under it, since
their parent will never reach disk.

Validation: selene unit tests pass (366) on cn4, including four new tests
(a cold child waits for its parked parent, then is complete and a republish
returns Present; ten cold children with hot parents start exactly
MAX_SPILLS parent writes and no child; the drain returns promptly when the
ancestor cannot start; a parent that fails twice takes its parked child
with it). The drain and parent-failure tests fail without the change.
DeepSeek 3k c32 n=128 on 4 GPUs: 7 of 7 runs abandoned 0 in the burst and
probe phases and restored 128/128. No restart benefit is claimed: a restart
without the shutdown drain restored the same 62 full / 65 partial / 1
recomputed on this build and on the unfixed one.

Design-Signed-Off-By: yatesdr; design-review=1c01
@yatesdr yatesdr changed the title Selene host-tier eviction: headroom, local spill on pressure, optional max age Selene host-tier eviction: unified host memory, local spill on pressure, optional max age Nov 1, 2026
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.

2 participants