Skip to content

Commit 3eac22b

Browse files
committed
Offer the host store only checkpoints a request may resume
Every published checkpoint was copied to the host store at publish time. On a growing conversation about four checkpoints are published per turn and the next request resumes at one: a message checkpoint is shadowed a few tokens later by its own prompt checkpoint, and a reply-end checkpoint is rarely resumed by clients that re-render the reply. The scheduler now defers the host offer (never the GPU capture) of a message checkpoint whose own prompt checkpoint follows less than one page later, and of terminal checkpoints. A deferred checkpoint is offered when a request resumes exactly at it (it is still on the GPU and pinned by that request), or, for a message checkpoint whose prompt capture could not get a snapshot, at that point while the request still pins it. A preempted request's terminal checkpoint counts as resumed, and a deferred message checkpoint is offered when its request is cancelled or fails. The parent reported for a publication is the nearest offered ancestor, with the tail pages since it. When the GPU compacts an offered checkpoint whose heir was not offered, the pool hears the first offered descendant as the heir, so announced chains stay connected; a deferred checkpoint that became a branch point is offered as the heir. Captures, prefill cuts, GPU retention and eviction order are unchanged; the rule uses only page size and the selected message boundaries. Validation: upstream CI's Linux commands: selene with test-support 389 passed, selene-manager all passed, helios the same 1187 passed / 7 failed as main (main's DeepSeek tokenizer-fixture defect), scheduler tests all pass. Tests cover deferral at page boundaries, offer on reuse, the capture-failure and preemption paths, parent and tail reporting, compaction with a deferred heir in pool mode, and output lengths 1 and 3. Independent review; findings addressed. Not yet run on a GPU. Design-Signed-Off-By: yatesdr; design-review=1c01
1 parent 5b31640 commit 3eac22b

7 files changed

Lines changed: 397 additions & 101 deletions

File tree

‎docs/prefix-cache-offload.md‎

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ defines configuration precedence.
2828

2929
## 1. Scope and configurations
3030

31-
- Published prefix checkpoints at instruction, prompt, and terminal endpoints
32-
are copied to the first host tier when published in both local and pool modes.
31+
- Offered GPU checkpoints are copied to the first host tier in local and pool modes.
3332
A later matching request restores a retained checkpoint the GPU no longer holds.
3433
- Live request state, in-flight speculative rounds, and anything not published
3534
as a checkpoint are never offloaded.
@@ -171,18 +170,26 @@ pub trait HostStore {
171170
}
172171
```
173172

174-
The worker calls `store` for each newly published checkpoint, without waiting for
175-
GPU pressure. `Queued` holds one GPU pin until `Stored` or `Deferred`; `Present`
173+
The worker calls `store` for each offered checkpoint, without waiting for GPU
174+
pressure. The scheduler defers a message checkpoint whose own planned prompt
175+
checkpoint is its direct child less than one physical page later, and terminal
176+
checkpoints, until a request resumes at that exact checkpoint. A preempted
177+
terminal counts as resumed. If its prompt capture cannot obtain a snapshot, the
178+
message is offered while the request still pins it; cancellation or error before
179+
that capture offers it at retirement. GPU captures, prefill cuts, and retention
180+
are unchanged; deferred points can be lost to GPU
181+
eviction before reuse, and are not announced to the pool until offered.
182+
`Queued` holds one GPU pin until `Stored` or `Deferred`; `Present`
176183
requires a committed local copy. `Retry` accepts no copy. The worker retains only
177184
checkpoint IDs for retries after store progress and discards IDs that have left
178185
the GPU cache. `Deferred` releases the copy pin and schedules the same retry when
179186
a queued publication cannot obtain host capacity. A failed transfer releases its
180187
pin without retrying. GPU eviction uses the existing LRU, leaf and compaction
181188
rules. `fetch` copies a looked-up chain into the ticket's page IDs and state slot.
182189
Submissions never wait; `poll` drains completions and failures.
183-
A `PublishedCheckpoint` names the GPU checkpoint, its key, its parent's key,
184-
its bucket digest, every page of
185-
its chain from the root with the count that belongs to the checkpoint itself,
190+
A `PublishedCheckpoint` names the GPU checkpoint, its key, its nearest offered
191+
ancestor's key, its bucket digest, every page of
192+
its chain from the root with the tail count since that offered ancestor,
186193
and the state slot of its row. `MemoryStore`, `DiskStore`, and `CompositeStore`
187194
implement the trait for serving, and a test double implements it for worker tests. The
188195
worker holds `Option<Box<dyn HostStore>>`.
@@ -566,12 +573,10 @@ API on this class of host and is the reference for call patterns.
566573
caller for cancellation and re-admission.
567574
- Cancelling a waiter removes it from the ticket. The ticket completes
568575
regardless, because the restored checkpoint is useful on its own.
569-
- `take_publications()` returns every checkpoint that became GPU-ready since
570-
the previous call, each once, with its id, key, parent key, bucket digest,
571-
the page ids of its whole chain with the count belonging to the checkpoint,
572-
and the row's state slot from `snapshot_slot`. Checkpoints created by
573-
`publish_boundary`, `retire`, and `finish_inbound` enter the list when the
574-
completed fence makes them ready.
576+
- `take_publications()` returns GPU-ready checkpoints offered since the previous
577+
call, each once, with their id, key, nearest offered ancestor's key, bucket
578+
digest, whole page chain, tail count since that ancestor, and row state slot.
579+
Deferred checkpoints enter the list on reuse or the fallback above.
575580
- `SchedulerStats` gains `host_lookups`, `host_hits`, `host_reused_tokens`,
576581
and `inbound_waiting`.
577582
- `release_after_shutdown` drops tickets.
@@ -629,7 +634,7 @@ the store and its transfer threads.
629634

630635
Policy:
631636

632-
- Outbound: copy each published checkpoint into the first host tier. In local
637+
- Outbound: copy each offered checkpoint into the first host tier. In local
633638
composite mode, spill to disk; in pool mode, move records down the
634639
eviction cascade only when the preceding tier needs room. Only queued copies
635640
pin GPU checkpoints. A capacity refusal waits for host progress before retry;
@@ -1008,7 +1013,7 @@ same completion events the store produces.
10081013
Local mode uses the same parked/spill path and LRU unlink at the end of its
10091014
chain, with the same completed-maintenance budget accounting. Only end-of-chain eviction,
10101015
expiry, infeasible footprints (`Oversize`) and terminal disk I/O errors discard data.
1011-
GPU publication announces the GPU holding and starts its first host copy;
1016+
A host offer announces the GPU holding and starts its first host copy;
10121017
GPU eviction sends the existing notice and never starts a late copy.
10131018
- **Retention metrics.** `flare_host_cache_held_total{tier}` counts record
10141019
holding events. `flare_host_cache_dropped_total{tier,reason}` counts local

‎helios/src/cache.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,16 @@ impl PrefixCheckpointCache {
224224
})
225225
}
226226

227+
pub(crate) fn single_child(&self, id: CheckpointId) -> Option<CheckpointId> {
228+
if self.nodes.get(&id)?.child_count != 1 {
229+
return None;
230+
}
231+
self.nodes
232+
.values()
233+
.find(|node| node.parent == Some(id))
234+
.map(|node| node.id)
235+
}
236+
227237
/// The GPU-ready checkpoint published under `prefix`, if any.
228238
pub fn ready_by_prefix(&self, prefix: &PrefixKey) -> Option<CheckpointId> {
229239
let id = *self.prefixes.get(prefix)?;

‎helios/src/scheduler.rs‎

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,10 @@ pub struct LiveRequest {
283283
}
284284

285285
impl LiveRequest {
286+
fn plans_prompt_capture(&self) -> bool {
287+
self.checkpoint_budget >= 2 && self.max_new_tokens > 1
288+
}
289+
286290
fn prefill_kind(&self) -> GraphKind {
287291
if self.prompt_logprobs { GraphKind::ScoredPrefill } else { GraphKind::Prefill }
288292
}
@@ -681,6 +685,7 @@ pub struct RequestScheduler {
681685
tickets: HashMap<PrefixKey, Ticket>,
682686
track_publications: bool,
683687
publications: Vec<CheckpointId>,
688+
offered_publications: BTreeSet<CheckpointId>,
684689
track_evictions: bool,
685690
evictions: Vec<EvictedCheckpoint>,
686691
evictions_lost: bool,
@@ -757,6 +762,7 @@ impl RequestScheduler {
757762
tickets: HashMap::new(),
758763
track_publications: false,
759764
publications: Vec::new(),
765+
offered_publications: BTreeSet::new(),
760766
track_evictions: false,
761767
evictions: Vec::new(),
762768
evictions_lost: false,
@@ -1627,6 +1633,11 @@ impl RequestScheduler {
16271633
self.stats.cache_lookups += u64::from(!spec.prompt_logprobs);
16281634
self.stats.cache_hits += u64::from(hit);
16291635
self.stats.reused_tokens += reused;
1636+
if inbound.is_none()
1637+
&& let Some(id) = lookup.checkpoint
1638+
{
1639+
self.record_publication(id);
1640+
}
16301641
Ok(Admitted { handle, ticket })
16311642
}
16321643

@@ -1686,8 +1697,8 @@ impl RequestScheduler {
16861697
}
16871698
if matches!(result, PublishResult::Created { .. }) {
16881699
self.stats.snapshot_publications += 1;
1689-
self.record_publication(id);
16901700
}
1701+
self.record_publication(id);
16911702
let snapshot = self.cache.checkpoint(id)?.snapshot();
16921703
let shared_pages = self.cache.page_chain(id)?;
16931704
for handle in ticket.waiters {
@@ -1720,8 +1731,8 @@ impl RequestScheduler {
17201731
self.track_publications = true;
17211732
}
17221733

1723-
/// Checkpoints that became GPU-ready since tracking was enabled or the
1724-
/// previous call, in that order and each once, omitting retired checkpoints.
1734+
/// GPU-ready checkpoints offered since the previous call, each once and
1735+
/// omitting retired checkpoints. Deferred checkpoints are offered on reuse.
17251736
pub fn take_publications(&mut self) -> Vec<PublishedCheckpoint> {
17261737
std::mem::take(&mut self.publications)
17271738
.into_iter()
@@ -1731,23 +1742,32 @@ impl RequestScheduler {
17311742

17321743
pub(crate) fn publication(&self, id: CheckpointId) -> Option<PublishedCheckpoint> {
17331744
let checkpoint = self.cache.checkpoint(id).ok()?;
1734-
let parent = match checkpoint.parent() {
1735-
Some(parent) => Some(self.cache.checkpoint(parent).ok()?.prefix()),
1736-
None => None,
1737-
};
1745+
let mut ancestor = checkpoint.parent();
1746+
let mut parent = None;
1747+
while let Some(ancestor_id) = ancestor {
1748+
let node = self.cache.checkpoint(ancestor_id).ok()?;
1749+
if self.offered_publications.contains(&ancestor_id) {
1750+
parent = Some(node.prefix());
1751+
break;
1752+
}
1753+
ancestor = node.parent();
1754+
}
1755+
let page_tokens = self.capabilities.page_tokens as u64;
17381756
Some(PublishedCheckpoint {
17391757
id,
17401758
key: checkpoint.prefix(),
17411759
parent,
17421760
bucket: checkpoint.bucket(),
17431761
chain_pages: self.cache.page_chain(id).ok()?,
1744-
tail_pages: checkpoint.tail_pages().len(),
1762+
tail_pages: (checkpoint.prefix().consumed_tokens / page_tokens
1763+
- parent.map_or(0, |key| key.consumed_tokens / page_tokens))
1764+
as usize,
17451765
slot: self.snapshot_slot(checkpoint.snapshot()),
17461766
})
17471767
}
17481768

17491769
fn record_publication(&mut self, id: CheckpointId) {
1750-
if !self.track_publications {
1770+
if !self.track_publications || !self.offered_publications.insert(id) {
17511771
return;
17521772
}
17531773
// Bound queued notifications when the store delays draining them.
@@ -1760,6 +1780,7 @@ impl RequestScheduler {
17601780

17611781
fn apply_completion(&mut self, update: CompletionUpdate) {
17621782
self.stats.snapshot_publications += update.published.len() as u64;
1783+
// Boundary and terminal captures publish already ready, so deferral is decided there.
17631784
for id in update.published {
17641785
self.record_publication(id);
17651786
}
@@ -2547,9 +2568,8 @@ impl RequestScheduler {
25472568
.or_else(|| {
25482569
(endpoint == request.prompt_tokens as u64
25492570
&& endpoint > request.prefix_tokens
2550-
&& request.checkpoint_budget >= 2
2551-
&& request.max_new_tokens > 1)
2552-
.then_some(BoundaryKind::Prompt)
2571+
&& request.plans_prompt_capture())
2572+
.then_some(BoundaryKind::Prompt)
25532573
});
25542574
if let Some(kind) = capture {
25552575
captures.push((lane.request, Some(kind), endpoint, request.phase));
@@ -2606,6 +2626,12 @@ impl RequestScheduler {
26062626
} else {
26072627
self.retain_snapshot()?
26082628
};
2629+
if snapshot.is_none()
2630+
&& matches!(kind, Some(BoundaryKind::Prompt))
2631+
&& let Some(prefix) = self.request(handle)?.prefix
2632+
{
2633+
self.record_publication(prefix);
2634+
}
26092635
let request = self.request_mut(handle)?;
26102636
if let Some(snapshot) = snapshot {
26112637
if let Some(kind) = kind {
@@ -2656,12 +2682,20 @@ impl RequestScheduler {
26562682
ready_after: request.last_use,
26572683
};
26582684
let history = request.history.clone();
2685+
// Only the last selected message can be the prompt's direct parent.
2686+
let defer = matches!(capture.kind, BoundaryKind::Message)
2687+
&& request.message_boundaries.last() == Some(&(capture.consumed_tokens as usize))
2688+
&& request.prompt_tokens - (capture.consumed_tokens as usize)
2689+
< self.capabilities.page_tokens
2690+
&& request.plans_prompt_capture();
26592691
let result = self.cache.publish(&history, &proposal)?;
26602692
if let PublishResult::Created { id, ready } = result {
26612693
if !ready {
26622694
return Err("completed boundary copy was not GPU-ready".into());
26632695
}
2664-
self.record_publication(id);
2696+
if !defer {
2697+
self.record_publication(id);
2698+
}
26652699
self.cache.pin(id)?;
26662700
if let Some(parent) = self.request(handle)?.prefix {
26672701
self.cache.unpin(parent)?;
@@ -2790,18 +2824,28 @@ impl RequestScheduler {
27902824
let mut request = self.slots[handle.slot as usize].request.take().unwrap();
27912825
let mut snapshot = request.terminal_snapshot;
27922826
if matches!(result, Some(PublishResult::Created { .. })) {
2793-
if let Some(PublishResult::Created { id, ready: true }) = result {
2827+
if let Some(PublishResult::Created { ready: true, .. }) = result {
27942828
self.stats.snapshot_publications += 1;
2795-
self.record_publication(id);
27962829
}
27972830
request
27982831
.private_pages
27992832
.drain(..publication.as_ref().unwrap().tail_pages.len());
28002833
snapshot = None;
28012834
}
28022835
if let Some(prefix) = request.prefix {
2836+
if !publish || request.error_code.is_some() {
2837+
self.record_publication(prefix);
2838+
}
28032839
self.cache.unpin(prefix)?;
28042840
}
2841+
if request.preempted
2842+
&& let Some(
2843+
PublishResult::Created { id, ready: true }
2844+
| PublishResult::Existing { id, ready: true },
2845+
) = result
2846+
{
2847+
self.record_publication(id);
2848+
}
28052849
self.slots[handle.slot as usize].retiring = true;
28062850
request.private_pages.extend(request.reserved_pages);
28072851
self.retired.push(RetiredRequest {

0 commit comments

Comments
 (0)