Durable KV prefix cache: agent prefixes survive eviction, restart, and cold nodes - #1228
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis change adds adaptive shared-prefix recording, complete cache identities, mmap-backed disk retention, miss tracking, dense KV archival, disk restoration fallbacks, runtime configuration, and non-first-stage activation-frame validation. ChangesKV prefix retention
Estimated code review effort: 5 (Critical) | ~120 minutes Mergeability Score: 🟡 Moderate · up to The opt-in persistent KV cache currently has concrete correctness and availability risks: stale metadata can accumulate without bound, and some archives can be reported as successful while becoming unusable after restart; relative cache directories can also receive an incorrect disk budget. These issues should be fixed or explicitly accepted before merging. Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
This pull request is currently a draft. Reviews will not take place until the PR is marked as ready for review. |
|
@i386 review please when you get a chance. Two things I'd most like your eyes on:
Draft because the multi-node / split-topology / agent-harness validation from AGENTS.md isn't done, and the only live numbers are from a 0.5B dense model on one box. The disk tier is off by default ( |
|
Followed up on the "surely this saves a ton on a big model" question — it does, and my first numbers undersold it badly. Re-measured on Qwen3-8B Q4_K_M (layer package / staged) with a ~12.4k-token agent prompt, cold control = same binary against an empty cache dir:
The archived page is 0.96 GB for 12288 tokens. The detail I think is the actual result: the post-restart number (8.85s) is identical to the cross-session number (8.85s). Restoring a ~1 GB page via mmap costs the same whether this process computed it or a previous one did. That is the plan's ratio argument holding — restore is bounded by bytes, prefill is superlinear in tokens, so the bigger the reusable bulk the better this looks. The 0.5B numbers I led with earlier (0.31s → 0.25s) are the worst case, not the representative one. I've kept them in the PR for honesty but they were misleading as the headline. Caveat unchanged: this is single-node. Split topologies are the interesting open question, since per-stage pages are smaller but every stage has to hit for the pipeline to benefit — one cold stage negates the rest. |
Splits now covered — with one honest gapAgreed splits are where the payoff is, so I dug in. Findings: The hard problem I expected wasn't there. I assumed per-stage restore would need a new negotiation protocol (stage 0 restores N tokens, stage 1 must agree or attention goes wrong). It already exists: What I added: disk restore in A real bug the measurement caught. My first archive heuristic stored the lowest ladder candidate. For a 2129-token prompt that's a 256-token page — 12% of the prefill, pure noise. Safety. Promoted an undocumented invariant to an explicit assertion: a non-first stage must execute its full token range. Suffix-only execution is legal only on the stage owning layer 0 — anywhere else the next stage attends over a prefix it never received and emits plausible but wrong tokens. Previously this was caught only as a side effect of a payload-size check in the encoder. Now named, asserted, and tested both ways. Measured, 2-node loopback split (0.5B, ~2.1k tokens)
Cross-session split reuse works. Restart reuse does not pay off yet. The archive index shows why: stage 0 archives its full ladder including the 2048 bulk, the downstream stage archives only 512. Because of the veto, one stage's shallow archive negates the other's good one. I'd rather flag that than quote the flattering cross-session number alone.
MoENot a special case. MoE families ( Still unmeasured: large MoE on a real multi-machine split, which is where the solo 8B result (2.44×) suggests the payoff should be biggest. |
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/skippy-server/src/binary_transport/binary_kv.rs (1)
726-757: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick winArchive prefixes when the stage has no downstream peer.
Line 726 gates the archive operation on
config.downstream.is_some()andoutput. A terminal binary stage still records resident prefixes, but it never writes its selectedarchive_candidateto disk. This removes restart reuse for that stage.Move lines 746-757 after the activation-recording conditional. Keep activation recording conditional, but archive whenever
archive_candidate.take()returns a candidate.Proposed fix
- if config.downstream.is_some() - && let Some(output) = output - { + if config.downstream.is_some() + && let Some(output) = output + { // activation recording - if let Some(identity) = archive_candidate.take() { - let mut runtime = runtime.lock().expect("runtime lock poisoned"); - if let Ok(true) = kv.archive_dense_prefix(&mut runtime, session_id, &identity) { - attrs.insert( - "skippy.kv.archived_tokens".to_string(), - json!(identity.identity.token_count), - ); - } - } + } + if let Some(identity) = archive_candidate.take() { + let mut runtime = runtime.lock().expect("runtime lock poisoned"); + if let Ok(true) = kv.archive_dense_prefix(&mut runtime, session_id, &identity) { + attrs.insert( + "skippy.kv.archived_tokens".to_string(), + json!(identity.identity.token_count), + ); + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/skippy-server/src/binary_transport/binary_kv.rs` around lines 726 - 757, Move the archive_candidate handling block out of the config.downstream/output conditional so it runs for terminal stages as well. Keep record_resident_activation and add_binary_activation_records conditional, but always call archive_dense_prefix when archive_candidate.take() yields a candidate and preserve the existing archived-token attribute update.
🧹 Nitpick comments (9)
crates/skippy-cache/src/lib.rs (1)
10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDrop the new crate-root re-exports and import from the owning modules.
disk_tierandmiss_reasonare alreadypub, so consumers can useskippy_cache::disk_tier::PrefixDiskTierandskippy_cache::miss_reason::PrefixMissReasondirectly. The added crate-root aliases create a second public path for the same types.♻️ Proposed change
pub use config::{PrefixCandidatePolicy, ResidentCacheConfig}; -pub use disk_tier::{DiskLoad, DiskTierStats, PrefixDiskTier}; pub use exact_state::{ ExactStateCache, ExactStateCacheStats, ExactStateLookup, ExactStateRecordOutcome, }; pub use identity::{ NATIVE_KV_DTYPE, NATIVE_KV_RUNTIME_ABI_VERSION, PrefixIdentity, activation_page_id, prefix_hash, prefix_hash_with_namespace, prefix_identity, prefix_identity_with_namespace, }; -pub use miss_reason::{PrefixGapBucket, PrefixMissReason, PrefixMissStats, PrefixMissTracker};Update the importing call sites in
crates/skippy-serveraccordingly.As per coding guidelines: "Minimize crate-root re-exports. New code should import from the owning module directly, and transitional compatibility re-exports should be removed after call sites migrate."
Also applies to: 18-18
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/skippy-cache/src/lib.rs` at line 10, Remove the crate-root re-exports for disk-tier and miss-reason types from lib.rs, then update skippy-server call sites to import PrefixDiskTier, DiskLoad, DiskTierStats, and PrefixMissReason through skippy_cache::disk_tier and skippy_cache::miss_reason. Preserve existing usage while eliminating the duplicate public paths.Source: Coding guidelines
crates/skippy-cache/src/identity.rs (1)
73-76: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winDevice identity uses
backend_deviceonly, notstable_id.
backend_deviceis an index-based label such asCUDA0. On a machine with more than one GPU model, the device behindCUDA0can change between runs after a driver or enumeration change. Persisted pages then restore under a different device than the one that produced them.StageDevice::stable_idis available and is the field that survives re-enumeration. Consider hashingstable_idwhen it is present, with the absent case tagged distinctly.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/skippy-cache/src/identity.rs` around lines 73 - 76, Update the selected-device branch in the identity hashing logic to hash StageDevice::stable_id instead of backend_device, preserving the no-selected-device marker for None and using a distinct fallback marker when stable_id is absent. Ensure device identities remain stable across re-enumeration.crates/skippy-cache/src/config.rs (1)
213-275: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueConsider treating the two mandatory slots as part of the pinned budget check.
pinnedstarts at the sum of the exact and near-tail lengths, so those two slots always bypassmax_resident_tokens_hint. On a small pool the ladder can therefore pin far more than the budget, asladder_is_bounded_by_the_resident_token_budgetshows (8000 + 7936 against a 4096 hint). The current behavior is intentional and documented, so this is only a design note: if the hint is meant as a hard ceiling, drop the near-tail slot when it alone exceeds the budget.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/skippy-cache/src/config.rs` around lines 213 - 275, Apply max_resident_tokens_hint to the mandatory near-tail selection in the ladder-building logic: retain the exact-length slot, but only add near_tail when the combined pinned length stays within the budget, dropping it when it alone would exceed the hint. Keep the existing unconstrained behavior when the hint is zero and preserve later candidate checks.crates/skippy-cache/Cargo.toml (1)
18-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse workspace versions for
libcandtempfile; gatelibcto unix.
crates/skippy-cache/Cargo.tomlkeeps local version pins while the root usesmemmap2,serde, andserde_jsonfrom[workspace.dependencies].libc.workspace = truerequires addinglibcto[workspace.dependencies], then moving it to[target.'cfg(unix)'.dependencies]becauselibcis only used incrates/skippy-cache/src/disk_tier.rsbehind#[cfg(unix)].tempfileis also only used incrates/skippy-cachetest/dev paths, so it can use sharedtempfile.workspace = truefrom[workspace.dependencies]if present before application.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/skippy-cache/Cargo.toml` around lines 18 - 24, The dependency declarations in skippy-cache should use workspace versions: add libc and ensure tempfile is available in workspace dependencies, replace their local version pins with workspace references, and move libc under target cfg(unix) dependencies to match its cfg-gated use in disk_tier.rs. Keep tempfile as a workspace dev-dependency.crates/skippy-cache/src/disk_tier.rs (1)
431-445: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winRe-verify the checksum once per mapping, not on every load.
loadhashes every component on every call, including when the mapping is already present inself.mappings. The mapping is only established after a full verification, and entries are never modified in place, so repeated hashing of a live mapping adds no safety.The cost is proportional to payload size on the restore hot path. The plan records a 0.96 GB archived page (
docs/skippy/KV_RETENTION_PLAN.md, Line 493), so each repeat hit pays a full-payload BLAKE3 pass over ~1 GB.Track verified page ids alongside
mappingsand skip the hash when the mapping was already verified in this process.♻️ Proposed refactor sketch
- let mmap = match self.mappings.get(page_id) { - Some(mmap) => mmap.clone(), + let (mmap, already_verified) = match self.mappings.get(page_id) { + Some(mmap) => (mmap.clone(), true), None => {let mmap = Arc::new(mmap); self.mappings.insert(page_id.to_string(), mmap.clone()); - mmap + (mmap, false) } }; let mut parts = Vec::with_capacity(entry.components.len()); for component in &entry.components { let bytes = CacheBytes::mapped(mmap.clone(), component.offset, component.len)?; - // Verify before handing bytes to the runtime. This faults the - // pages in, which a restore is about to do anyway. - let actual = blake3::hash(bytes.as_cow()?.as_ref()).to_hex().to_string(); - if actual != component.checksum { - self.quarantine(page_id); - self.stats.corrupt_entries = self.stats.corrupt_entries.saturating_add(1); - return Err(anyhow!( - "KV disk entry failed checksum verification for page {page_id}" - )); + // Verify once per mapping, before the bytes first reach the + // runtime. Entries are published by atomic rename and never + // modified in place, so a live mapping stays verified. + if !already_verified { + let actual = blake3::hash(bytes.as_cow()?.as_ref()).to_hex().to_string(); + if actual != component.checksum { + self.quarantine(page_id); + self.stats.corrupt_entries = self.stats.corrupt_entries.saturating_add(1); + return Err(anyhow!( + "KV disk entry failed checksum verification for page {page_id}" + )); + } } parts.push(bytes); }Note that
corrupted_entry_is_rejected_and_quarantined(Line 652) already clearstier.mappingsbefore reloading, so it keeps passing.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/skippy-cache/src/disk_tier.rs` around lines 431 - 445, Track page IDs whose mappings have completed checksum verification alongside self.mappings, and in load skip per-component hashing for already verified mappings. Mark a page verified only after every component passes validation; preserve quarantine, corruption stats, and error behavior on failure, and clear the verification state whenever the corresponding mapping is removed or reset.docs/skippy/KV_RETENTION_PLAN.md (1)
264-268: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueStale line reference.
as_cow()now lives atpayload/bytes.rs:103-137after this change. The cited rangepayload/bytes.rs:60-79points at other code.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/skippy/KV_RETENTION_PLAN.md` around lines 264 - 268, Update the `CacheBytes::as_cow()` reference in the deduped-blocks discussion to point to its current location at `payload/bytes.rs:103-137`, leaving the surrounding explanation unchanged.crates/skippy-cache/tests/agentic_retention.rs (1)
336-382: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winAdd coverage for the new
ResidentKvArchivepayload kind.This test archives with
ExactStatePayloadKind::KvRecurrentand two components, while its doc comment describes the dense-archive scenario. TheResidentKvArchivekind added incrates/skippy-cache/src/payload/mod.rshas no test in the reviewed files, so its one-component contract and itsfrom_disk_componentsreconstruction are unverified.A test that stores one component under
ResidentKvArchiveand reads it back would also surface thekind()asymmetry raised oncrates/skippy-cache/src/payload/mod.rs: the restored payload reportsKvRecurrent, notResidentKvArchive.💚 Proposed test
/// A dense archive carries attention KV only. It must round-trip under its /// own payload kind, and the restored payload must not present a fabricated /// recurrent component as real state. #[test] fn dense_archive_round_trips_under_its_own_payload_kind() { let dir = tempfile::tempdir().unwrap(); let kv_bytes = vec![0x7Eu8; 16 * 1024]; { let disk = PrefixDiskTier::open(dir.path(), 64 << 20).unwrap(); let mut cache = ExactStateCache::<()>::new(4, 0).with_disk_tier(disk); assert!(cache.store_on_disk( "dense-a", 2048, ExactStatePayloadKind::ResidentKvArchive, &[&kv_bytes], (), )); } let disk = PrefixDiskTier::open(dir.path(), 64 << 20).unwrap(); let mut cache = ExactStateCache::<()>::new(4, 0).with_disk_tier(disk); let restored = cache .lookup_disk_only("dense-a", ExactStatePayloadKind::ResidentKvArchive) .unwrap() .expect("dense archive should be importable after a restart"); assert_eq!( restored.payload.kv_bytes().unwrap().unwrap().as_ref(), &kv_bytes[..] ); // A dense archive has no recurrent state. Reading one back must not // succeed with an empty buffer that a caller could import as real state. assert!(restored.payload.recurrent_state_bytes().is_err()); }The final assertion fails against the current implementation. That is the defect raised on
crates/skippy-cache/src/payload/mod.rs.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/skippy-cache/tests/agentic_retention.rs` around lines 336 - 382, Add a test alongside archived_kv_page_metadata_survives_a_restart named dense_archive_round_trips_under_its_own_payload_kind that stores one component using ExactStatePayloadKind::ResidentKvArchive, restores it with lookup_disk_only, and verifies the KV bytes round-trip. Assert that the restored payload reports ResidentKvArchive through kind() and that recurrent_state_bytes() returns an error, confirming no fabricated recurrent component is exposed.crates/skippy-cache/src/miss_reason.rs (1)
155-170: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a test for re-eviction of a page that already has a tombstone.
Line 167 removes the stale
by_sequencekey whentombstones.insertreplaces an existing entry. Without that lineby_sequencewould retain a dangling sequence, andtrim_to_capacitywould then drop a page id that no longer maps to that sequence, silently shrinking the table below its bound. No current test exercises the replacement path.💚 Proposed test
/// Re-evicting a page must not leave a dangling insertion-order key, or /// trimming drops the wrong entry and the table shrinks below its bound. #[test] fn re_eviction_replaces_the_tombstone_without_leaking_order_keys() { let mut tracker = PrefixMissTracker::new(4); tracker.note_evicted("page-a", 64, 0); tracker.note_evicted("page-a", 128, 10); assert_eq!(tracker.stats().tombstones, 1); assert_eq!(tracker.by_sequence.len(), 1); // The second eviction wins, so the gap is measured from it. assert_eq!( tracker.note_miss("page-a", 20), PrefixMissReason::EvictedRecently ); assert_eq!(tracker.stats().evicted_miss_tokens, 128); assert_eq!( tracker.stats().evicted_misses_in(PrefixGapBucket::UnderMinute), 1 ); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/skippy-cache/src/miss_reason.rs` around lines 155 - 170, Add a unit test for PrefixMissTracker::note_evicted covering re-eviction of the same page, asserting the replacement leaves one tombstone and one by_sequence entry, and that note_miss uses the newer token count and eviction timestamp while reporting EvictedRecently.crates/skippy-server/src/kv_integration/mod.rs (1)
21-22: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winImport
ArchiveCandidatefrom its owning module.Line 22 adds a new crate-root re-export. Make
dense_diskvisible within the crate and update callers to importcrate::kv_integration::dense_disk::ArchiveCandidatedirectly. As per coding guidelines, “Minimize crate-root re-exports. New code should import from the owning module directly.”🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/skippy-server/src/kv_integration/mod.rs` around lines 21 - 22, Remove the crate-root re-export of ArchiveCandidate in kv_integration and expose the dense_disk module within the crate as needed. Update all callers to import ArchiveCandidate directly from crate::kv_integration::dense_disk, preserving existing behavior while minimizing crate-root re-exports.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/skippy-cache/src/disk_tier.rs`:
- Around line 123-129: Move the size-bounded, mmap-backed store documentation
from the doc comment immediately before DirectoryLock to PrefixDiskTier, leaving
DirectoryLock documented only with its exclusive advisory lock behavior. Ensure
PrefixDiskTier has the complete tier description directly before its
declaration.
- Around line 132-151: Update DirectoryLock::acquire to provide mutual exclusion
on non-Unix targets before PrefixDiskTier::open can activate the tier: use a
platform-specific exclusive lock such as Windows LockFileEx or
exclusive-share-mode file access, and return an error when ownership cannot be
acquired. If cross-platform locking is not supported, gate the disk tier so it
declines on those targets instead of returning Ok after merely creating
owner.lock.
In `@crates/skippy-cache/src/exact_state.rs`:
- Around line 384-404: Update demote_to_disk to persist entry.extra with the
disk entry so KvRecurrent pages remain importable after restart, propagating the
necessary serialization bounds through remove_entry, evict_until_within_limits,
and record. Update lookup_with_disk to prefer persisted metadata from
disk_extras or load.extra before invoking the fallback closure, and make
store_on_disk treat metadata serialization failure as an unsuccessful archive
rather than storing None. Add a restart round-trip test for a demoted
KvRecurrent entry and its descriptor, alongside
archived_kv_page_metadata_survives_a_restart.
- Around line 114-171: Update lookup_with_disk so the initial RAM probe does not
classify a miss immediately; preserve the RAM hit behavior, but defer miss
classification until after the disk tier is consulted. Record a miss only when
both RAM and disk fail, and record a hit for a disk-served result without also
incrementing miss statistics. Leave lookup unchanged so
miss_reasons_distinguish_eviction_from_never_seen retains its existing behavior.
In `@crates/skippy-cache/src/identity.rs`:
- Around line 98-127: Update the disk-tier setup around open_disk_tier and
update_weight_identity so every disk-backed weight identity includes a resolved
content digest. Prefer gating disk-tier use when neither source_model_sha256 nor
manifest_sha256 is available; otherwise add a reliable fallback based on the
resolved model path’s file size and modification time, ensuring byte changes
cannot reuse the same alias while preserving existing digest-based identities.
In `@crates/skippy-cache/src/payload/mod.rs`:
- Around line 61-64: Correct the doc comment for disk_components to state that
it returns an error when a component cannot be borrowed contiguously, replacing
the inaccurate reference to None while preserving the documented component
ordering.
- Around line 88-116: Add a distinct ExactStatePayload::ResidentKvArchive
variant and update from_disk_components to construct it directly from the KV
component. Update recurrent_state_bytes(), is_mapped(), and serialization/kind
selection for this variant so it has no recurrent state, reports mapping based
on its KV bytes, and is persisted as ResidentKvArchive rather than KvRecurrent.
In `@crates/skippy-server/src/binary_transport/forwarding.rs`:
- Around line 381-397: Update first_stage_may_emit_a_short_activation_frame so
incoming.token_count is greater than 1 while preserving the one-token output
frame, ensuring the test exercises the first-stage short-activation exception.
In `@crates/skippy-server/src/frontend/prefix_cache.rs`:
- Around line 540-547: Update the archive selection around the
identities/records iteration to construct an ArchiveCandidate from only
successfully stored resident records, rather than unconditionally using
identities.last(). After the loop, archive the selected candidate while
preserving the existing runtime lock and error handling.
In `@docs/skippy/KV_RETENTION_PLAN.md`:
- Around line 86-89: Update docs/skippy/KV_RETENTION_PLAN.md at lines 86-89 and
the W7 bullet at line 343 to remove claims that topology_id is part of the
prefix hash or page identity, while retaining the layer-range invalidation
point. In crates/skippy-cache/src/disk_tier.rs lines 27-35, revise the “Safety
of reuse across restarts” module documentation to remove topology from the
identity components.
- Around line 468-471: Update the finding in the retention plan so it documents
the final ArchiveCandidate heuristic: select the longest shareable prefix that
excludes the request’s own tail. Retain the lowest-candidate approach only as an
intermediate attempt that was later identified as incorrect, and align the
wording with the split-serving section.
---
Outside diff comments:
In `@crates/skippy-server/src/binary_transport/binary_kv.rs`:
- Around line 726-757: Move the archive_candidate handling block out of the
config.downstream/output conditional so it runs for terminal stages as well.
Keep record_resident_activation and add_binary_activation_records conditional,
but always call archive_dense_prefix when archive_candidate.take() yields a
candidate and preserve the existing archived-token attribute update.
---
Nitpick comments:
In `@crates/skippy-cache/Cargo.toml`:
- Around line 18-24: The dependency declarations in skippy-cache should use
workspace versions: add libc and ensure tempfile is available in workspace
dependencies, replace their local version pins with workspace references, and
move libc under target cfg(unix) dependencies to match its cfg-gated use in
disk_tier.rs. Keep tempfile as a workspace dev-dependency.
In `@crates/skippy-cache/src/config.rs`:
- Around line 213-275: Apply max_resident_tokens_hint to the mandatory near-tail
selection in the ladder-building logic: retain the exact-length slot, but only
add near_tail when the combined pinned length stays within the budget, dropping
it when it alone would exceed the hint. Keep the existing unconstrained behavior
when the hint is zero and preserve later candidate checks.
In `@crates/skippy-cache/src/disk_tier.rs`:
- Around line 431-445: Track page IDs whose mappings have completed checksum
verification alongside self.mappings, and in load skip per-component hashing for
already verified mappings. Mark a page verified only after every component
passes validation; preserve quarantine, corruption stats, and error behavior on
failure, and clear the verification state whenever the corresponding mapping is
removed or reset.
In `@crates/skippy-cache/src/identity.rs`:
- Around line 73-76: Update the selected-device branch in the identity hashing
logic to hash StageDevice::stable_id instead of backend_device, preserving the
no-selected-device marker for None and using a distinct fallback marker when
stable_id is absent. Ensure device identities remain stable across
re-enumeration.
In `@crates/skippy-cache/src/lib.rs`:
- Line 10: Remove the crate-root re-exports for disk-tier and miss-reason types
from lib.rs, then update skippy-server call sites to import PrefixDiskTier,
DiskLoad, DiskTierStats, and PrefixMissReason through skippy_cache::disk_tier
and skippy_cache::miss_reason. Preserve existing usage while eliminating the
duplicate public paths.
In `@crates/skippy-cache/src/miss_reason.rs`:
- Around line 155-170: Add a unit test for PrefixMissTracker::note_evicted
covering re-eviction of the same page, asserting the replacement leaves one
tombstone and one by_sequence entry, and that note_miss uses the newer token
count and eviction timestamp while reporting EvictedRecently.
In `@crates/skippy-cache/tests/agentic_retention.rs`:
- Around line 336-382: Add a test alongside
archived_kv_page_metadata_survives_a_restart named
dense_archive_round_trips_under_its_own_payload_kind that stores one component
using ExactStatePayloadKind::ResidentKvArchive, restores it with
lookup_disk_only, and verifies the KV bytes round-trip. Assert that the restored
payload reports ResidentKvArchive through kind() and that
recurrent_state_bytes() returns an error, confirming no fabricated recurrent
component is exposed.
In `@crates/skippy-server/src/kv_integration/mod.rs`:
- Around line 21-22: Remove the crate-root re-export of ArchiveCandidate in
kv_integration and expose the dense_disk module within the crate as needed.
Update all callers to import ArchiveCandidate directly from
crate::kv_integration::dense_disk, preserving existing behavior while minimizing
crate-root re-exports.
In `@docs/skippy/KV_RETENTION_PLAN.md`:
- Around line 264-268: Update the `CacheBytes::as_cow()` reference in the
deduped-blocks discussion to point to its current location at
`payload/bytes.rs:103-137`, leaving the surrounding explanation unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: d41d3c2d-c5ab-4c24-a6b5-aaf5bcd1e670
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (24)
Cargo.tomlcrates/mesh-llm-host-runtime/src/inference/skippy/family_policy.rscrates/skippy-cache/Cargo.tomlcrates/skippy-cache/src/config.rscrates/skippy-cache/src/disk_tier.rscrates/skippy-cache/src/exact_state.rscrates/skippy-cache/src/identity.rscrates/skippy-cache/src/lib.rscrates/skippy-cache/src/miss_reason.rscrates/skippy-cache/src/payload/blob_store.rscrates/skippy-cache/src/payload/bytes.rscrates/skippy-cache/src/payload/mod.rscrates/skippy-cache/tests/agentic_retention.rscrates/skippy-runtime/src/types.rscrates/skippy-server/src/binary_transport/binary_kv.rscrates/skippy-server/src/binary_transport/forwarding.rscrates/skippy-server/src/frontend/local_generation/token_generation.rscrates/skippy-server/src/frontend/prefix_cache.rscrates/skippy-server/src/kv_integration/config.rscrates/skippy-server/src/kv_integration/dense_disk.rscrates/skippy-server/src/kv_integration/exact_state.rscrates/skippy-server/src/kv_integration/identity.rscrates/skippy-server/src/kv_integration/mod.rsdocs/skippy/KV_RETENTION_PLAN.md
Status after two independent expert reviewsRan two reviews in parallel: one on split/staged correctness, one on the disk tier and cache identity. Split correctness — cleanAll five questions came back safe:
Disk tier — five real issues, all fixed
Two review claims I checked and rejected: Re-measured after hardening (solo 0.5B, ~2.1k tokens)
Two-node split smoke passes. Full suite green: skippy-cache 93, skippy-server 395, skippy-runtime 69, host-runtime 1932. Confidence
@i386 — the split gap above is the main thing I'd value a second opinion on. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/skippy-server/src/kv_integration/config.rs`:
- Around line 86-108: Update the disk-tier eligibility check around the
manifest_sha256, source_model_sha256, and package_ref fields to require at least
one non-empty, valid SHA-256 digest in manifest_sha256 or source_model_sha256.
Do not treat package_ref alone as sufficient, and retain the existing
disabled-tier message and return behavior when no validated digest is available.
In `@docs/skippy/KV_RETENTION_PLAN.md`:
- Around line 591-593: Update the disk-retention enablement gate to require a
validated immutable content digest, such as manifest_sha256 or
source_model_sha256, before opening the tier. Do not accept package_ref by
presence alone; if it is the only identity, derive its content digest and
validate it before allowing retention, otherwise fail closed with the existing
explanatory error path.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: c3a4266e-01b0-4c5c-ae8f-defc59877c26
📒 Files selected for processing (5)
crates/skippy-cache/src/disk_tier.rscrates/skippy-server/src/binary_transport/binary_kv.rscrates/skippy-server/src/kv_integration/config.rscrates/skippy-server/src/kv_integration/dense_disk.rsdocs/skippy/KV_RETENTION_PLAN.md
💤 Files with no reviewable changes (1)
- crates/skippy-server/src/binary_transport/binary_kv.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- crates/skippy-server/src/kv_integration/dense_disk.rs
- crates/skippy-cache/src/disk_tier.rs
|
Review findings:
The SHA-256 concern is not new to this PR: source-model SHA-256 computation and its sidecar cache already exist on the base. The new hot-path concern is the repeated full-page BLAKE3 verification. Local validation passed: skippy-cache 93 unit + 7 integration tests, and 19 targeted skippy-server tests. |
|
@michaelneale thanks for the follow-up. My read of the current state:
I also found three additional issues in the current head: stage-0 full-prefill can still select the smallest candidate, demoted recurrent KV entries lose their descriptor, and every disk hit re-hashes the full mapped payload. Could you confirm the intended disposition for these remaining items—especially the digest gate, stage-0 candidate selection, and split restart gap—before merge? |
Your bandwidth point is the biggest thing in this issue — and I can now show whyI chased all three. The second one changes how I'd frame the whole feature. 1. Prefix commonality — agreed, and it's a retention-time argumentA fleet on one agent sends near-identical system prompt + tool schemas forever. High hit frequency, full-cold-prefill value per hit. The goal you describe — never warm up again — is exactly the disk tier's job: survive eviction, restart, and redeploy. Disk "can't hurt" is right, and it's cheap per node on splits because per-stage pages are smaller. 2. Prefill bandwidth — this is the findingYou're right that it's ~3 GB between stages, and it turns out a chain restore removes essentially all of it.
Per boundary, per request. That reframes things: prefill compute parallelizes across stages, boundary transfer does not. On a thin or shared link the bandwidth saving may dominate the compute saving — and it is completely invisible in the single-node wall-clock numbers I've been quoting. My loopback benchmarks are the worst possible place to observe your most valuable effect. Corollary: the all-or-nothing veto is more expensive than I credited. One stage missing doesn't just lose that stage's compute — it forces full activation traffic across every boundary for the whole prompt. That raises the priority of the stage-1 gap below. 3. Pre-seeding — a consequence of work already done, not new machineryThe tier is already a content-addressed, checksummed, identity-anchored page store, so seeding is a write into it. What makes it plausible now is that identity no longer contains Root cause of the stage-1 gapFound it while looking at this. Not fixed in this PR — it's a behavioural change to the record path and I'd rather land it separately with its own evidence. Documented in |
Yes — and it already works, no new machinery needed. Measured.Your instinct was right. Sending the canonical prefix through the node once as an ordinary request populates the disk tier, because recording happens on the normal serving path. Solo: measuredSeed run = the system prompt with no user turn. Then restart the process (cold RAM, warm disk) and send a request with a tail the seed never saw:
The seed run archived a single 2048-token page — the shared bulk — and an unrelated later session hit it. That's the "never warm up again" property you described: the node is useful on its first ever real request. Why no new code was needed: identity contains no Durability is the part that makes it worth doing: the seed survives eviction and restart, so you pay the warmup once per node, not once per process. Splits: no gain yet, and I found the exact causeSame experiment on 2 nodes: 1.27s seeded vs ~1.30s cold. Nothing. Per-stage archives show why: 512 is exactly Root cause: That gate is correct for its original job — don't re-record what you just restored into the resident cache — and wrong for the archive, which wants the shared bulk specifically. I've deliberately not fixed it here. It's a behaviour change on the record path, it needs per-stage archive depth as its evidence, and this PR is already large. It's the clear next piece of work, and it unblocks both split retention and split seeding — which, given the 2048–4096× bandwidth effect, is where the real payoff is. So: seeding is real and works today, solo only. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/skippy/KV_RETENTION_PLAN.md`:
- Around line 696-699: Update the fenced code block containing the stage 0 and
stage 1 ladder output to specify the text language identifier (`text`) on its
opening fence, preserving the block contents unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 3b7c1295-3183-44a3-b06b-5245349a5561
📒 Files selected for processing (1)
docs/skippy/KV_RETENTION_PLAN.md
i386
left a comment
There was a problem hiding this comment.
Re-review of 42a9eb4001ec27095f5faac1753ef21de074c189:
- All 11 CodeRabbit findings from the prior review are addressed and their threads are resolved, including the outdated threads.
- Disk-hit accounting now records one outcome per lookup, so recoverable-miss metrics no longer count a disk hit as a RAM miss.
- Resident dense-KV archives now retain their own payload kind and cannot be misread as empty recurrent state.
- Stage-0 archiving selects the longest successfully resident shareable candidate, and content-addressed disk retention now requires a valid 64-hex SHA-256 digest.
- Documentation and the first-stage forwarding regression test are updated.
Validation on the pushed head: cargo fmt --all --check, cargo check -p skippy-cache, cargo check -p skippy-server, Clippy for both touched crates with warnings denied, 98 cache unit tests, 7 cache retention integration tests, 412 server unit tests, and just build all pass. No new actionable findings in this re-review. GitHub’s platform/runtime and Clippy jobs are still pending because this PR remains draft.
|
@micspiral could you think more carefully about the disk-cache lifecycle/design here? I traced the current behavior:
This is bounded and internally consistent, but it raises a design question: should retention have an explicit age/TTL or maintenance policy, and should archive admission account more directly for write cost, restore cost, and model churn? In particular, if the workload stops writing, old entries remain indefinitely within the byte budget. Would appreciate your view on whether this lifecycle is the right policy before we consider it settled. |
|
@micspiral on the binary packing: the current layout is efficient for a same-platform local cache. Payload components are concatenated into one contiguous The index integers are endian-neutral because they are JSON text. The As part of a versioned page schema, I suggest an explicit magic/header with schema version and byte order—probably enforce little-endian and reject unsupported byte order—plus runtime/architecture payload identifiers. |
|
The page payload is already binary; only the metadata index is JSON. A binary index could reduce startup parsing and repeated full-index serialization, but it would not speed normal cache hits: the index is parsed only when the tier opens, while lookups use in-memory metadata and The likely improvement is a versioned
I’d keep a JSON diagnostic/export tool, but switch the runtime index to binary if profiling shows startup or index rewrites matter. The page |
|
@micspiral I agree with the direction here. For the current design, the We should add a documented file-format spec in Markdown and on the website covering the directory layout, index/page schemas, lifecycle and eviction, atomic writes, checksums, versioning, endian assumptions, and corruption/compatibility behavior. Agent guidance should require the implementation, tests, canonical Markdown spec, and website copy to stay synchronized whenever the format changes. One important release detail: please reset the on-disk |
…ormat Cache hits now say which tier served them, archive attempts report why they did or did not happen, and the disk tier's own counters leave the process. Before this, a disk tier that had silently stopped storing anything -- full budget, every write failing, every entry quarantined -- was indistinguishable in telemetry from one that was simply never probed. Adds docs/skippy/KV_DISK_TIER_FORMAT.md as the normative on-disk format spec (layout, index schema, lifecycle, atomicity, integrity, corruption behaviour, identity, versioning) and a user-facing website page covering how to enable the cache, when it declines to enable, and how to clear it. Also resets DISK_TIER_FORMAT_VERSION to 1 -- the intermediate bumps were in-development churn on an unreleased format -- and closes the payload-kind test gap: RecurrentOnly had no disk round-trip at all, and only one of the six cross-kind rejection pairs was covered. Validation: cargo test -p skippy-cache -p skippy-server (521 passed), cargo clippy -p skippy-cache -p skippy-server --all-targets -D warnings, cargo check -p mesh-llm, cargo fmt --all --check. Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Running the previous commit on a live 8B node showed the disk-tier counters never appeared: they were attached via KvStageIntegration::attrs(), which only the binary transport calls. The single-node dense OpenAI path builds its attribute maps per decision, so on the configuration most users run the new observability emitted nothing at all. Disk-tier counters are now attached to dense disk hits and archive decisions directly, and hit_source is emitted on the dense disk-hit and binary-transport hit paths as well as the exact-state one. Verified live, Qwen3-8B Q4_K_M layer package, ~8.4k-token agent prompt, 8 GiB tier, across a genuine process restart: cold 7.57s, cached_tokens=0 warm, same prefix new tail 0.62s, cached_tokens=8320 first request after restart 0.62s, cached_tokens=8320 and the attributes are present and correct in the emitted events -- notably disk_verifications=1 with verifications_skipped rising on the repeat load, and disk_verify_ms falling 280.9ms -> 0.007ms, which is the first-load-only verification behaviour actually being observable rather than asserted. Validation: cargo test -p skippy-cache -p skippy-server (521 passed), clippy -D warnings on both, cargo fmt --all --check, release host build, plus the live run above. Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
The existing split measurement was two processes on one host sharing a disk and a GPU. That validates the cross-stage agreement protocol but not the parts only a real network exercises: page identity agreeing across two independently configured hosts, restore negotiation over real QUIC latency, and each node owning its own cache directory on its own storage. Repeated on an M4 Pro and a Mac mini over LAN (13-14ms direct QUIC), Qwen3-8B Q4_K_M layer package, stage 0 layers 0-22 on one machine and stage 1 layers 22-36 on the other: cold, both caches empty 12.45s, cached_tokens=0 cross-session, same prefix new tail 1.49s, cached_tokens=4096 first request after restarting both nodes 1.72s, cached_tokens=4096 Both nodes persisted format_version 1 indexes independently and reloaded them into a freshly negotiated topology, so the restored page ids agreed across hosts. Stage 1 additionally hit budget eviction (disk_evictions 0 -> 3 -> 5 against its 2 GiB share) while still serving hits, which the loopback run never reached. Records the two reproduction traps as well: the split only plans when both nodes are genuinely too small (--max-vram 5 let one node serve all 36 layers solo; --max-vram 4 at the native 40960 context could not plan at all), and the join must be directed at the node whose advertised addresses are reachable. Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
…M caps The previous split validation reached a two-node split by lowering --max-vram until the planner happened to split. That is the wrong mechanism and it hid a real failure: at --max-vram 5 one node silently took all 36 layers and served solo while still presenting as a healthy two-node mesh. Equal artificial caps can also flip coordinator election, since the coordinator is whichever participant advertises the most VRAM. mesh-llm already supports doing this deterministically, documented in docs/SKIPPY_SPLITS.md: --split forces staged serving even when the model fits locally, and --split-topology-lock pins exact nodes and layer ranges fail-closed. Re-ran the LAN validation under a lock -- stage 0 layers 0-32 on the M4 Pro, stage 1 layers 32-36 on the mini, --ctx-size 8192, verified through /api/runtime/stages rather than /v1/models: cold, both caches empty 5.48s, cached_tokens=0 cross-session, same prefix new tail 1.39s, cached_tokens=4096 first request after restarting both nodes 1.48s, cached_tokens=4096 Both nodes persisted format_version 1 indexes independently (stage 0 one entry, stage 1 a 34-entry ladder) and restored into a freshly negotiated topology. The plan doc now records the lock recipe and the traps: stage 0 must be the would-be coordinator, full endpoint ids are required because both lab machines advertise the same hostname, manifest_sha256 is the digest of the model-package.json bytes, and /api/runtime/stages is the only authoritative check that two distinct endpoints own disjoint layer ranges. Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Page files are raw runtime memory, so interpreting them depends on the CPU architecture, native byte order, and pointer width of the process that wrote them. None of those appeared in the page identity. The identity hash deliberately encodes its own integers as little-endian, which is correct for a portable identifier but means two hosts of different native endianness computed the *same* page id for the same tokens. In the default machine-local cache directory that is harmless. It stops being harmless once a directory is shared or copied: SKIPPY_KV_DISK_TIER_DIR accepts any path, the stage directory key holds only model and stage shape, and backend_device does not separate an x86_64 CUDA host from an aarch64 CUDA host, nor two CPU-only hosts that both record <no-selected-device>. The checksums would confirm the copied bytes arrived intact and the runtime would import them as native -- a silent misread rather than a detected error. update_platform_identity now hashes ARCH, an explicit endianness tag, and pointer width, so a page from another platform is a miss and never a wrong hit, and several platforms can share a directory without quarantining each other. This is preferred over a magic header in the .kvp file: it needs no change to component offsets or file sizing, and mismatches become different ids rather than mutual quarantine. No format-version bump: version 1 has not shipped, so no on-disk directory claiming it exists in any build a user could have run. The spec now states that the bump rule applies from the first released version onward. Also documents why retention is LRU-under-byte-budget with no TTL. Entries are content-bound, so age alone does not make one wrong; they become unreachable rather than incorrect when the model or config changes, and idle entries consume no more than their existing allowance. The real gap is stage directories abandoned on a model change, which wants a base-directory quota rather than expiry inside active caches. Validation: cargo test -p skippy-cache -p skippy-server (522 passed), clippy -D warnings on both, cargo check -p mesh-llm, cargo fmt --all --check. Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Two things, both found by running a mixture-of-experts model end to end. Disk budget is now a first-class flag. The tier could only be reached by exporting SKIPPY_KV_DISK_TIER_MIB, which is not a surface users can be expected to find. --kv-cache-disk <GB|auto> and --kv-cache-disk-dir <PATH> now configure it directly; the environment variables remain the underlying mechanism and take precedence, so containers and systemd units are unaffected. A budget that would round to zero MiB is rejected loudly rather than silently disabling the tier while looking enabled. Sliding-window attention models are now declined instead of failing forever. Serving gemma-4-26B-A4B, every archive attempt returned failed_export with the native reason 'runtime memory type is not supported for native KV pages'. The cause is not MoE: experts sit in the feed-forward layers and carry no state between tokens. It is that llama.cpp backs Gemma with llama_memory_hybrid_iswa, which holds two caches -- a full-context base cache for the non-SWA layers and a window-bounded cache for the SWA layers. For an N-token prefix the correct state is 0..N on the base layers but only the visible suffix on the SWA layers, and one page with one token range cannot express that. The runtime is right to refuse: exporting the base cache alone would produce a page that silently omits every SWA layer, and importing it would advance n_past over state that was never restored. That is a permanent property of the stage, not a failure of a request, so it is now latched: the first attempt disables archiving for that stage and reports skipped_unsupported_memory, and later prefills report skipped_tier_disabled and cost nothing. Gemma keeps ResidentKv, because a sequence copy duplicates both caches, so in-process reuse is untouched -- only the disk tier is out of reach. Supporting SWA on disk needs a composite base-plus-suffix page and is separate work; adding the ISWA types to the export path without it would be unsafe. Export failures also carry the native reason now. Err(_) discarded it, so 'the export failed' was only marginally better than the silent bool it replaced. Diagnosing this took a rebuild purely to see the message. Note that Gemma shows no prefix reuse on origin/main either, verified by building ff5e79f and running the same probe. Pre-existing, not a regression. Validated live: Qwen3-8B via --kv-cache-disk 8 with no env vars set, 5.16s cold -> 0.47s warm, 6272 cached tokens, format_version 1 index written to the flag-specified directory; gemma-4-26B-A4B emits exactly one skipped_unsupported_memory then skipped_tier_disabled. cargo test: 1938 host-runtime, 424 skippy-server, 110 skippy-cache. Clippy -D warnings clean on all four crates, cargo fmt --all --check. Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
The format spec described the composite base+SWA page as 'a separate piece of work' without saying where that work lives. Name the issues (#1264 for the composite page, #1265 for Inkling, which needs the composite page and the recurrent component together) so a reader hitting skipped_unsupported_memory can find the plan rather than rediscovering it. Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
The record ladder charged its unconditional exact and near-tail slots against the resident token budget before considering any shared rung. Those two are the longest candidates by construction, so on every context below roughly 48k they consumed the budget outright and the first shared rung was unaffordable — collapsing the shipped default back to [exact, near-tail], the pre-ladder behaviour. For a 12288-token agent prompt at ctx 8192/16384/32768 the ladder recorded only lengths inside the request's own tail, which no other session ever asks for. Cross-session and cross-restart sharing were therefore unreachable on default configuration even though lookup probes all the way down to min_tokens. Charge the budget against the shared rungs only. The mandatory slots are already committed by the time the budget is consulted, so gating the cheap low rungs on them protects nothing. Adds a regression test over the composed shipped config (n_ctx -> max_entries -> record_limit -> resident budget) rather than hand-picked policy values, since the defect only appeared in that composition. Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Five findings from the review round: - disk_extras kept a descriptor for every page ever written. The tier removes entries on its own schedule (LRU eviction, quarantine) and those paths cannot see this map, so a long-lived process churning the tier accumulated dead descriptors without bound. Prune after each operation that can remove an entry. - A relative SKIPPY_KV_DISK_TIER_DIR measured free space against the root filesystem. Walking a relative path's ancestors bottoms out at the empty path, and falling through to / sized the budget against the wrong disk whenever the working directory is a separate mount. Resolve relative paths to the working directory instead. - The disk-miss telemetry event dropped the standard OpenAI attributes, so it could not be joined with the hit and error events on session or model - exactly the attribution needed to compute a hit rate. - The KV layout documentation ran into the platform-identity block with no separating blank line, so rustdoc rendered the layout rationale on update_platform_identity and left update_layout_identity undocumented. - Docs: tag the directory-layout fence as text, separate the ISWA families from dense attention in the family table (Gemma 3/4 appear as dense but have retention declined), date the superseded "split topologies unmeasured" claim, and correct the archive-status text to say it is telemetry rather than a log line. Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
378b919 to
19f3d63
Compare
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com> Signed-off-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
|
note disk tier is not off by default I believe |
Closes #1226 (partially — W0/W2/W2b/W4 plus a dense bridge; W1/W3/W5/W6/W7 not done).
What you get
A prefix computed once survives the process that computed it. With the opt-in
disk tier, the first request after a node restart mmaps the KV page a previous
process wrote instead of prefilling it again.
Measured on an M4 Max, release host plus Metal native runtime built from this
branch, Qwen3-8B Q4_K_M layer package,
--ctx-size 16384, 8 GiB tier, ~8.6k-tokenagent prompt. Cold runs start from an empty cache directory.
The restart case is the result to look at: 13.6x, reproduced across two
separate verified restarts (0.58s and 0.58s). 8576 of 8609 tokens were restored
from disk.
On the cross-session rows. They hit, but modestly — a few hundred to a few
thousand tokens of an 8.6k prompt, worth a fraction of a second. An earlier
revision of this description claimed 12.65s saved cross-session; that number
came from a configuration I could not reproduce at the shipped defaults, and it
has been removed rather than restated. The durable, reproducible win here is the
restart case, and the honest summary is that this PR makes prefixes survive
time on a node — not that it makes cross-session sharing dramatically faster
within one process.
Verifying a restart actually happened matters. During this measurement a
pkillsilently failed to match, the replacement process died onAddress already in use, and the "post-restart" request was served by theoriginal process — which manufactures a perfect-looking cache hit. Every restart
number above is from a kill verified by an empty
lsof -ti:9337before the newprocess started.
The record ladder
Cross-session sharing already worked —
prefix_hash_with_namespacecontains nosession_id. It was recording that was throttled: a policy that always keepsthe full length meant an 8000-token prompt recorded only
[8000, 7936], both inthe request's own tail, the least shareable part of it. Lookup probed dozens of
lengths including 2048; nothing was ever stored there.
The ladder keeps the exact length and the near-tail candidate (same-session
continuation) and reaches down into the shared system-prompt region.
A second defect in that ladder was found and fixed while validating this
branch, and it is worth reading before reviewing the policy. The ladder charged
its two unconditional slots — exact and near-tail, the longest candidates by
construction — against the resident token budget before considering any shared
rung. For a 12288-token prompt those two pin ~24k tokens, more than the entire
budget on any context below roughly 48k, so the first shared rung was
unaffordable and the loop exited immediately. Composed over the shipped config
chain (
n_ctx→max_entries→record_limit→ resident budget):Only a 131k context recorded anything shareable beforehand. The budget now
governs the optional rungs only; the mandatory slots are already committed by
the time it is consulted.
record_candidate_token_countshas a regression testover the composed shipped configuration, because every hand-picked-policy test
missed this.
This fix is not load-bearing for any number in this PR, and I want that on the
record. A live A/B against
SKIPPY_KV_CACHE_SHARED_RECORD_LIMIT=2(the oldeffective behaviour) was identical on every row within noise — cold 7.88s vs
7.89s, restart 0.58s vs 0.60s, both tiers ending with one on-disk entry. The
archive selector caps one page per request, so ladder depth never reaches disk,
and the resident-path cross-session hits occur identically at limit 2. The fix
makes recorded behaviour match documented intent; it does not produce a measured
speedup here.
Two findings worth your attention
1.
topology_idmade persistence impossible — and removing it removed a safety net.Local serving derives it as
topology-mesh-skippy-{unix_nanos}, so it is unique per process. While it was hashed, every restart produced freshpage_ids and no persistent cache could ever be read back. Removing it is required for any disk tier.But it was also, accidentally, the thing guaranteeing no stale page could ever be misread.
model_idis a display name, not a content digest — two runs can serve genuinely different tensors under the same alias (different quant, repacked layer package, GGUF swapped under the same path). So identity now hashesmanifest_sha256,source_model_sha256,package_refandload_modeexplicitly. This is the change I'd most like a second opinion on, because the failure mode is silent numerical corruption that persists across restarts, not a crash.2. Archiving the longest candidate is useless. When I capped archiving to one page per request, I initially picked the longest — which is the request's own tail, which nothing else ever asks for. Cross-restart hits vanished until it was changed to the lowest (most shareable) candidate. Caught by measurement, not by tests.
Safety
The tier's failure mode is wrong numbers, not a crash, so:
Default path
The disk tier is off unless
SKIPPY_KV_DISK_TIER_MIB/SKIPPY_KV_DISK_TIERis set. With it off, the only behaviour changes are the identity hash and the ladder depth.Architecture
skippy-cache/src/disk_tier.rs— mmap-backed store: atomic rename, checksums, LRU, directory lock, orphan reclaim.CacheBytesRepr::Mappedborrows a mapped range instead of allocating. Block-deduping on disk was rejected:as_cow()on aBlockspayload concatenates the whole thing, which for a multi-GB page is gigabytes of copying immediately before the runtime copies again.skippy-server/src/kv_integration/dense_disk.rs— dense (ResidentKv) families have no serialized form, so without this bridge the tier would help only hybrid/recurrent models and do nothing for llama/qwen/gemma. Archiving happens at record time, not eviction: eviction runs on the decode hot path, and deferring it would mean the llama.cpp sequence cannot drop until the export completes (use-after-drop, or a cell leak re-triggering the 502 wedgemax_resident_tokensexists to prevent).min_tokens-sized entries, which the two longest slots violate badly.Protocol
Node-local only. No wire-format, gossip, or plugin-protocol changes. Skippy ABI unchanged (0.1.35). Disk format is versioned and self-invalidating.
RuntimeKvPageDescgains serde derives — plain data mirror, layout unaffected.Validation
cargo fmt --all --checkclean; clippy-D warningsclean on skippy-cache, skippy-server, skippy-runtime, mesh-llm-host-runtime, mesh-llm. Tests: skippy-cache 105 + 7 integration, skippy-server 442, mesh-llm-host-runtime 1932, skippy-runtime 69 — all passing. Full CI green on the rebased head.Live verification on a real dense model as described above, including a genuine process restart against a warm cache directory.
Two-node split serving (validated)
Measured on a 2-node loopback split, Qwen3-8B Q4_K_M layer package, ~16.9k-token agent prompt, 10 GB VRAM budget and an 8 GiB disk tier per stage:
Restart reuse across a split now works — a 20x win. An earlier 0.5B run showed no restart gain because the downstream stage archived only its 512-token floor candidate and the all-or-nothing veto cancelled stage 0's deeper archive. Both stages now archive the 16768-token shared bulk:
Fixed by
e1c4af42(stopmin_record_tokensstarving the archive selector on a warm restore; archive on every stage, not only stages with a downstream) andb32dec97(drop the resident-admission gate on stage-0 full prefill, which declined every rung of a prompt overmax_resident_tokens).Two physical machines over LAN (validated)
The table above is a loopback split. Repeated across an M4 Pro and a Mac mini on a LAN (13-14ms direct QUIC), Qwen3-8B Q4_K_M layer package,
--max-vram 4 --ctx-size 16384per node, 8 GiB tier per node, stage 0 layers 0-22 on one machine and stage 1 layers 22-36 on the other:Both nodes independently persisted
format_version: 1indexes and reloaded them into a freshly negotiated topology, so restored page ids agreed across hosts. Stage 1 also exercised budget eviction (disk_evictions0 → 3 → 5 against its 2 GiB share) while still serving hits — a path the loopback run never reached.Re-run under a pinned topology (
--split --split-topology-lock, stage 0 layers 0-32 on the M4 Pro, stage 1 layers 32-36 on the mini,--ctx-size 8192), verified viaGET /api/runtime/stagesrather than/v1/models:This replaces an earlier run that reached a split by tuning
--max-vram, which is unreliable — at--max-vram 5one node silently served all 36 layers solo while still looking like a healthy two-node mesh.docs/skippy/KV_RETENTION_PLAN.mdnow records the lock recipe and the traps.Agent harness (validated)
AGENTS.mdrequires a harness run for changes on this surface. Goose was runagainst the local proxy with tool calling, on the same node and tier as above.
model=auto, file-creation task with tool callshello.txtwrittenmodel=<explicit model id>, shell tool callmodel=auto, after a verified node restarttool_calls+role=toolreplyThe disk tier stayed healthy across the harness: four archived entries
(4992/5120/5632/4992 tokens), 1.5 GB, no corruption or quarantine events. A
5708-token harness prompt returned
cached_tokens=5632at 1.80s after a restart,against 5.78s cold — the restart win holds under a real agent workload, not just
scripted requests.
model=meshwas not exercised: on a single-node mesh the proxy returnsmodel 'mesh' not found (no local or remote host serving this model), which iscorrect behaviour for that topology rather than a defect. It is covered by the
two-node runs above.
Not validated: WAN split, very large MoE models, OpenCode and Pi harnesses.
Observability, tests, and format spec (review round 2)
Follow-up to @i386's review, in
f320fa27:skippy.exact_cache.hit_source(ram|disk).archive_dense_prefixpreviously returnedResult<bool>, so "policy declined" and "the archive failed" collapsed into the same silentfalse— and two of the four call sites discarded it withlet _ =. It now returns an explicit outcome and every site reportsskippy.kv.archive_status(archived/skipped_too_short/skipped_already_archived/skipped_tier_disabled/failed_export/failed_write/failed_error), plusarchive_bytes,archive_export_ms,archive_write_ms.disk_demotions,disk_promotions,disk_evictions,disk_corrupt_entries,disk_verifications,disk_verifications_skipped,disk_entries,disk_bytes,disk_max_bytes,disk_tier_enabled. Without these, a tier that has silently stopped storing anything is indistinguishable from one that is never probed.RecurrentOnlyhad no disk round-trip at all; it now has demote → restart → restore. Cross-kind rejection went from 1 of 6 pairs to all 6, plus a test that a kind mismatch keeps the entry quarantined against a later correct lookup.docs/skippy/KV_DISK_TIER_FORMAT.md— directory layout, index schema, lifecycle, atomic-write and crash-recovery contract, both checksummed regions, full corruption-response table, identity contract, versioning rules. The JSON index is retained deliberately: metadata only, read once at startup, entry counts in the hundreds. A binary index should follow a profile, not precede one.website/src/docs/pages/kv-disk-cache.mdplus a nav entry: how to enable, when it declines to enable and why, safety model, how to clear it.DISK_TIER_FORMAT_VERSIONreset 3 → 1 before landing.Open questions for review
manifest_sha256+source_model_sha256+package_ref+load_mode) sufficient? Should the tier refuse to open when both digests are absent?n_gpu_layersand backend device are hashed conservatively, but auto-offload can shift between runs on the same host — that will silently self-invalidate the tier. Right tradeoff?Summary by CodeRabbit
New Features
Improvements
Bug Fixes
Review round 3
Architecture and byte order are now bound into page identity. Reviewer asked for explicit byte-order and architecture identifiers.
update_layout_identityhashed cache dtypes, flash-attn, GPU layer split, andbackend_device— but not CPU arch, endianness, or pointer width. The identity hash deliberately encodes its own integers little-endian (correct for a portable id), which meant two hosts of different native endianness computed the same page id for the same tokens.Harmless in the default machine-local dir; not harmless once a dir is shared or copied, because
SKIPPY_KV_DISK_TIER_DIRtakes any path, the stage directory key holds only model/stage shape, andbackend_devicedoes not separate x86_64 CUDA from aarch64 CUDA, nor two CPU-only hosts both recording<no-selected-device>. Checksums would confirm the bytes arrived intact and the runtime would import them as native — a silent misread.update_platform_identitynow hashesARCH+ endianness tag + pointer width, so a foreign page is a miss, never a wrong hit.Chosen over a
.kvpmagic header: no change to component offsets or file sizing, and mismatches become different ids rather than mutual quarantine between platforms sharing a directory. No format bump — v1 has not shipped, so no on-disk directory claiming it exists in any build a user could have run; the spec now says the bump rule applies from the first released version onward.Retention policy documented, no TTL added. Entries are content-bound: age alone does not make one wrong, and they become unreachable rather than incorrect when model or config changes. Idle entries consume no more than their existing allowance, so expiry only forfeits future hits. A TTL would be a privacy/hygiene control, not correctness. The real gap is different and now recorded: stage directories abandoned on a
model_idor stage-shape change fall outside any tier subsequently opened, which wants a base-directory quota rather than expiry inside active caches.Rebased onto main (13 commits, including the regenerated llama.cpp patch queue). That bumped the Skippy ABI to 0.1.38, so all earlier live measurements were re-run against a freshly built native runtime at the merged head
85f0060d: 7.57s cold → 0.62s after a genuine process restart,cached_tokens=8320,hit_source":"disk". Full CI is green on the merged head.