Skip to content

Commit a499cb9

Browse files
committed
Bound GPU snapshot rows by memory, not a fixed count
Snapshot capacity was fixed at eight rows per concurrent request (256 at concurrency 32) regardless of memory. On a long agent conversation the GPU therefore evicted 94% of the checkpoints it created while 55-73% of its KV pool sat free, and the host store refilled what the GPU dropped. Capacity is now whatever the borrowable KV backing can alias, which the planner already computes and caps by memory and virtual extent; rows and request pages keep yielding to each other through the existing pressure eviction. The per-request capture budget stays at most eight, so prefill cuts are unchanged. No package re-export is needed. The design docs (core design, runtime package, CLI) describe the memory-bounded capacity. Validation: upstream CI's Linux commands: helios the same 1190 passed / 7 failed as main (main's DeepSeek tokenizer-fixture defect), selene and selene-manager all passed. Tests: capacity grows with memory, the per-request budget stays eight, cached rows yield backing to admission. CPU measurements at 256 vs 1,350 rows: blocked snapshot search 7.2 vs 80.5 us, median leaf eviction 2.0 vs 13.6 us, reclaiming 128 rows 0.49 vs 1.98 ms. Not yet run on a GPU. Design-Signed-Off-By: yatesdr; design-review=1c01
1 parent 3eac22b commit a499cb9

8 files changed

Lines changed: 99 additions & 37 deletions

File tree

‎docs/cli-design.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,9 +1117,9 @@ value in preference to that recorded reserve.
11171117

11181118
The GPU prefix cache is derived. It holds model states at instruction,
11191119
prompt, and answer boundaries so that later requests repeating a prefix skip
1120-
recomputing it. A finished exchange can retain its prompt and answer states, so the
1121-
engine exposes up to eight snapshot slots per concurrent request, about four
1122-
recent exchanges each. Snapshot slots share physical backing with KV pages and
1120+
recomputing it. Immutable snapshot capacity is bounded by borrowable KV backing
1121+
and the prepared virtual extent. The per-request capture budget is capped at
1122+
eight. Snapshot slots share physical backing with KV pages and
11231123
acquire it only when retaining a checkpoint. Unpinned checkpoints are evicted
11241124
to admit requests. If active requests or transfers own all suitable backing,
11251125
the engine skips optional checkpoint retention. There is no dedicated snapshot
@@ -1189,7 +1189,7 @@ every rank and have no configuration-file key. See
11891189
| Tensor-parallel size | Count of `--gpus`, or the smallest recipe-supported size whose estimate fits the first GPUs with enough free memory |
11901190
| Context limit | The smaller of the prepared context ceiling and loaded KV capacity after per-request draft-page storage; admission also checks available shared capacity |
11911191
| KV pages | Remaining supply after the runtime memory limit or package reserve, engine headroom, weights, graphs, scratch, and execution rows; bounded by `--kv-cache-limit` and prepared virtual extents. Retained snapshot rows borrow page backing; the load display reports total KV capacity |
1192-
| Prefix cache | Up to eight snapshot slots per `--max-concurrency` request, acquired on demand from shared KV backing and evicted under pressure |
1192+
| Prefix cache | Immutable snapshot capacity bounded by borrowable KV backing and the prepared virtual extent, acquired on demand and evicted under pressure; per-request capture budget capped at eight |
11931193
| Live state | One null row plus the recipe's execution rows per active request slot; GLM verification commits into one live row |
11941194
| Decode graph capacities | 1 through `--max-concurrency`, rounded to the recipe's captured set |
11951195
| Image feature arena | Prepared image concurrency times per-request feature capacity and embedding width; slots remain owned through request retirement |

‎docs/core-design.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,10 +1395,10 @@ to earlier tokens cannot reuse a checkpoint for different tokens.
13951395

13961396
At admission, the scheduler budgets newly captured cache snapshots from the
13971397
loaded snapshot capacity divided by configured maximum concurrency, rounded
1398-
down. A nonempty pool permits at least one capture per request; an empty pool
1399-
permits none. The budget reserves a terminal capture and, when generation can
1400-
continue beyond the prompt and the budget permits, a prompt capture. A single
1401-
output token shares the prompt and terminal endpoint.
1398+
down and capped at eight. A nonempty pool permits at least one capture per
1399+
request; an empty pool permits none. The budget reserves a terminal capture and,
1400+
when generation can continue beyond the prompt and the budget permits, a prompt
1401+
capture. A single output token shares the prompt and terminal endpoint.
14021402

14031403
The remaining budget selects message checkpoints within the uncached prompt
14041404
suffix. If all eligible boundaries fit, retain them all. Otherwise, place token

‎docs/prefix-cache-offload.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ message is offered while the request still pins it; cancellation or error before
179179
that capture offers it at retirement. GPU captures, prefill cuts, and retention
180180
are unchanged; deferred points can be lost to GPU
181181
eviction before reuse, and are not announced to the pool until offered.
182+
Compaction also offers a deferred branch point as its heir and refreshes
183+
descendant offers with their new parent.
182184
`Queued` holds one GPU pin until `Stored` or `Deferred`; `Present`
183185
requires a committed local copy. `Retry` accepts no copy. The worker retains only
184186
checkpoint IDs for retries after store progress and discards IDs that have left

‎docs/runtime-package-design.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,8 +584,8 @@ the recipe's execution rows per request. KV pages receive the remaining budget,
584584
within their virtual maximum. The planner accounts for mapping alignment and
585585
requires at least 8,192 cached tokens per active request.
586586

587-
Up to eight immutable snapshot slots per active request share physical backing
588-
with KV pages. At load, CUDA virtual mappings expose the same allocation chunks
587+
Immutable snapshot capacity is bounded by borrowable KV backing and the prepared
588+
virtual extent. At load, CUDA virtual mappings expose the same allocation chunks
589589
through the page planes and the snapshot part of the continuation range. Small
590590
interleaved runs of dense page planes keep each snapshot's conflicting page range
591591
close to its byte cost. Planes whose allocation granules span more pages than a

‎helios/src/runtime_package/memory.rs‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ fn capacities(
294294
if minimum_pages == 0 {
295295
return Err(invalid("KV cache limit leaves no physical pages"));
296296
}
297-
let mut cached = active * 8;
297+
let mut cached = u64::MAX;
298298
let mut maximum_pages = page_limit.map_or(u64::MAX, |limit| limit.maximum);
299299
for (rank, (package, &budget)) in packages.iter().zip(available).enumerate() {
300300
let rows: Vec<_> = package
@@ -1343,14 +1343,29 @@ mod tests {
13431343
}
13441344
}
13451345

1346+
#[test]
1347+
fn snapshot_capacity_grows_with_borrowable_kv_backing() {
1348+
let packages = [package(65536, 65536, 100000)];
1349+
let mut previous = 0;
1350+
for budget in [64 << 20, 128 << 20, 512 << 20] {
1351+
let (rows, pages, snapshots) =
1352+
capacities(&packages, &[budget], 4, 1, 64, None).unwrap();
1353+
assert_eq!(rows, 5 + snapshots);
1354+
assert_eq!(snapshots, pages.min(4096 - 5));
1355+
assert!(snapshots > previous.max(4 * 8));
1356+
previous = snapshots;
1357+
}
1358+
}
1359+
13461360
#[test]
13471361
fn lower_concurrency_and_verification_move_physical_budget_to_kv() {
13481362
let p = [package(100 << 20, 2 << 20, 100000)];
13491363
let budget = [80 << 30];
13501364
let full = capacities(&p, &budget, 32, 6, 64, None).unwrap();
13511365
let reduced = capacities(&p, &budget, 4, 6, 64, None).unwrap();
13521366
let target_only = capacities(&p, &budget, 4, 1, 64, None).unwrap();
1353-
assert!(full.0 > reduced.0 && reduced.0 > target_only.0);
1367+
assert!(full.0 - full.2 > reduced.0 - reduced.2);
1368+
assert!(reduced.0 - reduced.2 > target_only.0 - target_only.2);
13541369
assert!(full.1 < reduced.1 && reduced.1 < target_only.1);
13551370
assert_eq!(reduced.0, 1 + 4 * 6 + reduced.2);
13561371
for (rows, pages, snapshots) in [full, reduced, target_only] {

‎helios/src/scheduler.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1569,7 +1569,7 @@ impl RequestScheduler {
15691569
let checkpoint_budget = if self.limits.snapshots == 0 {
15701570
0
15711571
} else {
1572-
(self.limits.snapshots as usize / self.limits.max_active).max(1)
1572+
(self.limits.snapshots as usize / self.limits.max_active).clamp(1, 8)
15731573
};
15741574
let endpoints = 1 + usize::from(spec.max_new_tokens > 1 && consumed < spec.prompt.len() as u64);
15751575
let message_boundaries = checkpoint_boundaries(

‎helios/src/scheduler/boundary_tests.rs‎

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,27 @@ fn checkpoint_targets_follow_token_distance_and_exclude_reused_prefixes() {
149149
assert!(checkpoint_boundaries(vec![10], 0, 100, 0).is_empty());
150150
}
151151

152+
#[test]
153+
fn larger_snapshot_pools_keep_the_same_eight_capture_prefill_cuts() {
154+
let mut previous = None;
155+
for snapshots in [32, 1024] {
156+
let mut s = scheduler(snapshots, false);
157+
let mut request = spec(None, 700, 3);
158+
request.message_boundaries = (1..700).collect();
159+
let handle = s.admit(request).unwrap();
160+
let boundaries = s.request(handle).unwrap().message_boundaries.clone();
161+
assert_eq!(boundaries.len(), 6);
162+
if let Some(previous) = previous {
163+
assert_eq!(boundaries, previous);
164+
}
165+
previous = Some(boundaries);
166+
finish(&mut s, handle);
167+
assert_eq!(s.stats().message_checkpoints, 6);
168+
assert_eq!(s.stats().prompt_checkpoints, 1);
169+
assert_eq!(s.stats().snapshot_publications, 8);
170+
}
171+
}
172+
152173
#[test]
153174
fn small_snapshot_shares_prioritize_endpoints_and_allow_completion() {
154175
for snapshots in [0, 1, 2, 4, 8, 12] {
@@ -544,30 +565,32 @@ fn every_message_endpoint_can_restore_after_a_changed_following_turn() {
544565
let endpoints = vec![1, 7, 63, 64, 65, 129, 300, 514, 700];
545566
for page_tokens in [64, 128, 256, 2048] {
546567
for mtp in [false, true] {
547-
let mut s = scheduler_with_concurrency(16, mtp, page_tokens, 1);
548-
let mut request = spec(None, 901, 3);
549-
request.message_boundaries = endpoints.clone();
550-
let handle = s.admit(request).unwrap();
551-
for &end in &endpoints {
552-
stop_at_capture(&mut s, handle, end as u64);
553-
assert_eq!(s.request(handle).unwrap().generated_tokens, 0);
554-
run_step(&mut s);
555-
assert_eq!(s.request(handle).unwrap().prefix_tokens, end as u64);
556-
}
557-
finish(&mut s, handle);
558-
assert_eq!(s.stats().message_checkpoints, endpoints.len() as u64);
559-
for &end in &endpoints {
560-
let mut branch = spec(None, end + 10, 1);
561-
branch.prompt[end..].fill(1000);
562-
branch.message_boundaries = endpoints.iter().copied().filter(|&n| n <= end).collect();
563-
let branch = s.admit(branch).unwrap();
564-
assert_eq!(s.request(branch).unwrap().reused_prompt_tokens(), end as u64);
565-
assert_eq!(run_step(&mut s).kind, GraphKind::Restore);
566-
let step = s.plan().unwrap();
567-
assert_eq!(step.kind, GraphKind::Prefill);
568-
assert_eq!(step.lanes[0].start_position, end as u64);
569-
assert_eq!(step.lanes[0].tokens, 10);
570-
s.cancel(branch).unwrap();
568+
for endpoints in endpoints.chunks(6) {
569+
let mut s = scheduler_with_concurrency(16, mtp, page_tokens, 1);
570+
let mut request = spec(None, 901, 3);
571+
request.message_boundaries = endpoints.to_vec();
572+
let handle = s.admit(request).unwrap();
573+
for &end in endpoints {
574+
stop_at_capture(&mut s, handle, end as u64);
575+
assert_eq!(s.request(handle).unwrap().generated_tokens, 0);
576+
run_step(&mut s);
577+
assert_eq!(s.request(handle).unwrap().prefix_tokens, end as u64);
578+
}
579+
finish(&mut s, handle);
580+
assert_eq!(s.stats().message_checkpoints, endpoints.len() as u64);
581+
for &end in endpoints {
582+
let mut branch = spec(None, end + 10, 1);
583+
branch.prompt[end..].fill(1000);
584+
branch.message_boundaries = endpoints.iter().copied().filter(|&n| n <= end).collect();
585+
let branch = s.admit(branch).unwrap();
586+
assert_eq!(s.request(branch).unwrap().reused_prompt_tokens(), end as u64);
587+
assert_eq!(run_step(&mut s).kind, GraphKind::Restore);
588+
let step = s.plan().unwrap();
589+
assert_eq!(step.kind, GraphKind::Prefill);
590+
assert_eq!(step.lanes[0].start_position, end as u64);
591+
assert_eq!(step.lanes[0].tokens, 10);
592+
s.cancel(branch).unwrap();
593+
}
571594
}
572595
}
573596
}

‎helios/src/scheduler/sharing/tests.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,28 @@ fn publish(
4646
.id()
4747
}
4848

49+
#[test]
50+
fn cached_rows_beyond_eight_per_request_yield_backing_to_admission() {
51+
let mut s = scheduler(1, 64, 64);
52+
let aliases: Vec<_> = (0..64).map(|i| 63 - i..64 - i).collect();
53+
s.share_snapshot_pages(&aliases).unwrap();
54+
let h = history(1);
55+
for end in 1..=48 {
56+
publish(&mut s, &h, None, end, &[], 0);
57+
}
58+
assert_eq!(s.stats().snapshot_backing_pages, 48);
59+
assert_eq!(s.free_page_count(), 16);
60+
let mut request = spec(1984, 1);
61+
request.prompt.fill(999);
62+
let handle = s.admit(request).unwrap();
63+
assert_eq!(s.request(handle).unwrap().reused_prompt_tokens(), 0);
64+
assert_eq!(s.request(handle).unwrap().reserved_pages.len(), 32);
65+
assert_eq!(s.stats().snapshot_backing_pages, 32);
66+
assert_eq!(s.stats().snapshot_evictions, 16);
67+
s.cancel(handle).unwrap();
68+
assert_eq!(s.free_page_count(), 32);
69+
}
70+
4971
#[test]
5072
fn compaction_and_leaf_eviction_are_reported_with_the_heir() {
5173
let mut s = scheduler(2, 16, 4);

0 commit comments

Comments
 (0)