Skip to content

feat(cache): persist GDN checkpoints as SSD sidecars - #2569

Merged
jundot merged 2 commits into
jundot:mainfrom
hojin12312:agent/gdn-ssd-sidecar-checkpoints
Aug 10, 2026
Merged

feat(cache): persist GDN checkpoints as SSD sidecars#2569
jundot merged 2 commits into
jundot:mainfrom
hojin12312:agent/gdn-ssd-sidecar-checkpoints

Conversation

@hojin12312

@hojin12312 hojin12312 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

feat(cache): persist GDN recurrent state as bounded SSD sidecars

Published as jundot/omlx#2569. Current head: 4c6bb6d1. Status: Ready for review, mergeable, with Python 3.11/3.12/3.13 CI passing. The historical PR_DRAFT.md filename is retained as the local source for the published PR body.

Summary

This PR separates non-sliceable GDN recurrent state from the sliceable prefix-cache payload and persists the recurrent state as durable, per-boundary SSD sidecars.

The goal is to make long-context Qwen3.6 GDN serving practical on unified-memory Macs without retaining one full recurrent state in RAM for every historical boundary. On restore, oMLX materializes only the newest compatible GDN checkpoint needed for the selected KV prefix. Older checkpoints remain on SSD and can be selected if a request rewinds.

The implementation is opt-in and backward-compatible. With the feature disabled, the existing cache layout and restore path remain available.

Background

This is a follow-up to #1887, which identified the cross-turn ArraysCache/GDN tip chain as a distinct source of hot-cache growth. Increasing the GDN block size reduces the number of intra-request boundaries, but it does not by itself prevent historical recurrent-state tips from accumulating across turns.

It is also related to #2465, where a long DeepSeek V4 request showed severe memory growth while many boundary snapshots were being persisted after the response had already completed. The common issue is that an SSD cache write can still require a large in-memory staging lifetime when the full recurrent payload is retained with every boundary.

Qwen3.6-27B is currently one of the most capable local models for this workload, and the Qwen team has officially announced Qwen3.8. That announcement is a practical reason to make the long-context cache path more memory-efficient before the next model generation becomes a common local-serving target.

This PR does not claim Qwen3.8 validation, KDA validation, or general validation of every future Qwen model. The measured model is Qwen3.6-27B-8bit-mtp on Apple Silicon.

Design

The cache is split into two coordinated layers:

  1. Sliceable attention KV and other ordinary cache state continue to use the existing paged SSD block format.
  2. GDN ArraysCache/SizedArraysCache recurrent state is extracted into an opaque sidecar for the same full block boundary.

Each sidecar is addressed by the source block identity and a cache-layout signature. Sidecars live in a separate _gdn_sidecars namespace, are written through a temporary staging file, and are atomically promoted into the durable index. The index is rebuilt from file metadata at startup without eagerly loading every recurrent tensor.

Restore is deliberately lazy:

longest contiguous KV prefix
        -> newest compatible GDN sidecar
        -> materialize one recurrent checkpoint
        -> continue from the matched endpoint

Historical GDN checkpoints are not all hydrated into the hot cache. If the newest sidecar is absent, corrupt, incompatible, or deliberately removed, restore walks back to the nearest valid full-block checkpoint. If no valid checkpoint is available, the request follows the existing safe miss/re-prefill path.

Main cache blocks and GDN sidecars participate in the same SSD budget and LRU accounting. This prevents the sidecar namespace from becoming an unbounded second cache. The scheduler also bounds request-local sidecar staging by bytes and applies backpressure before an unbounded in-memory fallback can form.

Internal cache statistics track sidecar count and bytes, checkpoint loads, the selected restore endpoint, and walkbacks. Boundary staging also records its byte high-water mark and backpressure time for benchmark diagnostics.

Safety and fallback behavior

  • The feature is disabled by default; existing deployments keep the legacy behavior unless explicitly enabled.
  • A sidecar is accepted only when its model/cache signature and source block identity match the request.
  • Temporary or partially written files are not indexed as valid checkpoints.
  • A missing or unreadable sidecar never produces a stale GDN state. The restore path walks back to an older valid checkpoint or falls through to the normal re-prefill/miss behavior.
  • The SSD index is rebuilt by scanning metadata, so server restart does not require loading the complete historical GDN chain into RAM.
  • The pending writer queue is byte-bounded. If the asynchronous path cannot accept more data, the scheduler applies bounded backpressure or uses the existing synchronous/error fallback; it does not retain an unlimited collection of full snapshots.
  • The global SSD limit covers both ordinary blocks and sidecars, so sidecar eviction is governed by the same capacity policy.
  • The implementation preserves the legacy format and restore path for models/layouts that do not support the split GDN representation.

Configuration used for the benchmark

The candidate profile used the following effective settings:

{
  "cache.gdn_ssd_split_enabled": true,
  "cache.gdn_ssd_pending_max_size": "512MB",
  "cache.ssd_cache_max_size": "128GB",
  "cache.hot_cache_max_size": "24GB",
  "model.max_context_window": 262144,
  "model.arrays_cache_tip_retention": 0,
  "model.turboquant_kv_enabled": false,
  "model.mtp_enabled": true
}

The legacy comparison profile used the same model, context limit, KV precision, MTP setting, SSD limit, hot-cache limit, and request payload, with gdn_ssd_split_enabled=false and the previous arrays_cache_tip_retention=16 behavior.

The measurements below were collected on an Apple M5 Max with 128 GiB unified memory, running macOS 26.5.2 (25F84), arm64, and oMLX 0.5.7. Each repeated cold profile used a fresh isolated base path and cache, one request at a time, on port 18150. The operational server on port 8080 was not used for the benchmark.

Validation completed

Unit and integration coverage

  • The focused GDN storage, sidecar index, prefix restore, scheduler, and settings suite passed: 634/634 tests.
  • The broader cache-type, hybrid-cache, paged-cache, and prefix-cache compatibility suite passed: 384/384 tests.
  • GitHub Actions passed the repository unit suite on Python 3.11, 3.12, and 3.13.
  • Coverage includes final-boundary publication, deduplicated-sidecar repair, malformed-sidecar walkback, exact-once pending-byte accounting, atomic replacement rollback, startup indexing, no-follow deletion under symlink swaps, shared main/sidecar LRU accounting, reset/abort synchronization, signature mismatch, and bounded staging.
  • A local 8,074-test run completed with 7,984 passed, 85 skipped, and five numerical MTP parity failures. The same five tests failed with identical values on the pristine pre-hardening commit, so they are not regressions from this change; the clean GitHub runners passed.

Review hardening after the initial draft

The final review found and fixed several lifecycle cases that were not covered by the first implementation: the scheduler's latest boundary is now committed even though it is intentionally absent from the intermediate-snapshot map; a readable but malformed newest sidecar walks back instead of aborting reconstruction; and a deduplicated KV block recreates an independently evicted sidecar before reuse.

The storage path now coalesces deterministic duplicate boundaries, retains pending-byte reservations until the owning raw buffer is actually released, detaches promotion files outside request/session cleanup, protects an existing checkpoint during failed replacement, and restores conservative byte accounting whenever safe deletion is refused. Sidecar deletion uses no-follow directory descriptors, commit timestamps survive restart LRU reconstruction, and reset/abort cleanup waits for asynchronous cache-store ownership to end. Regression tests cover each of these paths.

173K exact replay

The same 173,008-token Pi coding-session payload was replayed with temperature=0, streaming enabled, and max_tokens=32.

Case Prompt Cached Uncached PP (tok/s) TG (tok/s) TTFT (s) Wall (s) GDN restore
Split, cold 173,008 0 173,008 288.69 16.79 601.64 603.53 84 sidecars created
Split, warm replay 173,008 172,032 976 21,728.73 14.06 8.23 10.50 1 load, 0 walkbacks, endpoint 172,032
Split, model reload 173,008 172,032 976 26,038.88 17.42 9.00 10.83 1 load, 0 walkbacks
Split, server restart 173,008 172,032 976 26,389.13 17.39 9.47 11.30 1 load, 0 walkbacks

The cold split run created 84 GDN sidecars totaling 12,932,773,056 bytes. The warm replay returned the same response fingerprint as the cold run, proving that the exact prefix was restored rather than recomputed. The selected GDN sidecar load was approximately 0.30 ms in the warm replay.

Rewind probes also selected the expected full-block endpoints without a full re-prefill:

Rewind point Prompt Cached endpoint Uncached Walkbacks
Message 14 75,777 75,776 1 0
Message 24 128,056 126,976 1,080 0
Message 28 159,600 157,696 1,904 0

For a fault-injection run in which the newest full-block sidecar was made unavailable, the request completed with endpoint 169,984, 3,024 uncached tokens, and one GDN walkback. This demonstrates the intended safe degradation behavior.

256K cold run

The 256K cold run used a 262,112-token prompt, which leaves room below the 262,144-token model limit for the request protocol and generated output.

Case Prompt PP (tok/s) TG (tok/s) RSS Backpressure Warm replay
Split, cold 262,112 187.84 22.08 34.09 GiB 0.24 ms 260,096 tokens cached

The cold request created 127 GDN sidecars totaling 19,553,121,168 bytes. The immediate warm replay restored endpoint 260,096 with 2,016 uncached tokens, one GDN checkpoint load, zero walkbacks, and a 0.532 ms checkpoint-load latency. Warm effective PP was 18,286.93 tok/s and the response fingerprint matched the cold request. The reported RSS is the process resident-set peak during the cold-prefill interval and should not be confused with SSD sidecar capacity.

A fresh server process then indexed the existing cache and replayed the same 262,112-token prompt. It restored endpoint 260,096 from the durable cache, left only 2,016 tokens to prefill, loaded one GDN checkpoint with zero walkbacks, and completed with effective PP 15,406.30 tok/s, TG 34.58 tok/s, TTFT 21.36 s, and wall time 21.59 s. The checkpoint load took 3.045 ms. All 127 sidecars were indexed after restart, and the response fingerprint matched the original cold and warm responses. This closes the post-restart durability check at the full tested context length.

Repeated A/B speed and memory table

The following controlled A/B alternated three fresh Split profiles and three fresh Legacy profiles. Every run used the identical 173,008-token payload, temperature=0, streaming, and a 512-token decode limit. Values are medians with the observed min-max range in parentheses.

Metric Split median (min-max) Legacy median (min-max) Split delta
PP (tok/s) 262.46 (260.67-284.59) 269.03 (267.60-272.07) -2.44%
TG (tok/s) 9.96 (9.94-12.16) 9.85 (9.84-10.87) +1.12%
Prompt evaluation (s) 659.17 (607.91-663.70) 643.09 (635.89-646.53) +2.50%
TTFT (s) 661.53 (612.10-666.12) 645.50 (638.25-648.90) +2.48%
Wall time (s) 712.93 (654.19-717.65) 696.02 (690.26-697.48) +2.43%
Peak process RSS (GiB) 44.590 (44.556-44.646) 56.596 (56.585-56.621) -21.21% (-12.006 GiB)

Split passes the predefined performance criterion: median PP and TG are not worse than Legacy by more than 5%. The memory effect is stable across all three repetitions: Split stayed within a 0.09 GiB RSS range, Legacy within 0.04 GiB, and the median reduction was 12.006 GiB. Every run reported zero cache errors and zero SSD write drops. Split sidecar staging peaked at 153,944,064 bytes, with no more than 0.156 ms recorded backpressure.

Four of the six runs produced the same response fingerprint, including cross-profile adjacent runs. The first Split and first Legacy observations produced different fingerprints despite temperature=0. This does not correlate with the cache layout and is consistent with native-MTP execution not being bitwise deterministic, but a dedicated MTP-off control would be required to prove that cause. The image and 256K cold/warm/restart checks did produce matching fingerprints.

These measurements do not establish that SSD sidecar persistence is universally faster than recomputing GDN state. They establish the narrower claim that bounded sidecar persistence caused no material PP/TG regression in this repeated workload while substantially reducing peak RSS.

Actual-image VLM cold/warm smoke

A deterministic one-pixel PNG was attached to the first six messages of the same Pi session, producing a 14,117-token multimodal prompt. The cold request completed without image-processing or cache errors and created six GDN sidecars.

Case Cached Uncached PP (tok/s) TG (tok/s) TTFT (s) Wall (s) GDN restore
Image cold 0 14,117 820.15 27.08 19.44 21.80 6 sidecars created
Image warm 12,288 1,829 5,058.65 26.17 2.82 5.26 1 load, 0 walkbacks, endpoint 12,288

The warm checkpoint load took 0.317 ms, both requests returned the same response fingerprint, and the profile reported zero cache errors and zero SSD write drops. This confirms that the split checkpoint path remains usable when the serving request includes actual image input.

Limitations and remaining work

  • The repeated A/B has three observations per profile. It reports spread but is not a multi-machine statistical study.
  • Decode was capped at 512 tokens for the repeated 173K A/B and 8 tokens for the 256K run. The 173K result includes a useful decode-throughput sample, but this remains primarily a long-prefill/cache benchmark.
  • The 256K request was text-only. Image-input correctness was checked separately at 14K, not at 256K, so no 256K image-heavy memory claim is made here.
  • Exact-prefix persistence currently fails closed when split-GDN is enabled; ordinary full-block prefix caching remains available. A terminal-sidecar implementation is needed before exact-prefix storage can be enabled safely for this layout.
  • No Qwen3.8 benchmark was run, and no KDA-specific validation was run. The cache code is intended to be layout-aware and extensible, but those model families need separate validation.
  • SSD bandwidth, filesystem behavior, thermal throttling, and sidecar eviction behavior need repeated measurements on more than one Apple Silicon configuration.
  • The focused and broader cache compatibility suites passed, and the repository unit suite passed on all three supported Python versions in GitHub Actions. Real-model coverage outside the measured Qwen3.6 path remains desirable before generalizing the feature beyond its current opt-in scope.
  • Sidecar persistence increases SSD writes and consumes cache capacity. Operators should choose the SSD limit and hot-cache limit for their device rather than assuming the benchmark values are universal.

Scope, changed files, and ownership

The implementation scope is the GDN split persistence/restore path, its bounded staging and accounting, configuration propagation, and focused regression coverage. The changed production files are:

  • omlx/cache/paged_ssd_cache.py: durable GDN sidecar metadata/index, atomic commit, startup scan, shared budget/LRU, and sidecar statistics.
  • omlx/cache/prefix_cache.py: split-layout detection, lazy compatible-sidecar restore, endpoint selection, walkback, and restore diagnostics.
  • omlx/cache/boundary_snapshot_store.py and omlx/scheduler.py: boundary extraction, bounded staging/backpressure, persistence dispatch, and safe fallback wiring.
  • omlx/cache/type_registry.py, omlx/config.py, and omlx/settings.py: cache-type recognition and opt-in configuration propagation.
  • tests/test_prefix_cache_gdn_split.py, tests/test_gdn_sidecar_index.py, tests/test_boundary_snapshot_store.py, tests/test_settings.py, and focused scheduler coverage: regression coverage.

Merge checklist

  • Focused unit and integration tests for sidecar commit, restore, rollback, and walkback.
  • 173K cold/warm/reload/restart/rewind smoke coverage.
  • Missing-sidecar fault-injection fallback.
  • 256K cold prefill measurement: 262,112 tokens, PP 187.84 tok/s, RSS 34.09 GiB, backpressure 0.24 ms.
  • 256K warm replay: endpoint 260,096, residual 2,016, 0.532 ms sidecar load.
  • 256K post-restart replay: endpoint 260,096, residual 2,016, 3.045 ms sidecar load, matching response fingerprint.
  • Repeated 173K A/B: three fresh runs per profile, medians and min-max ranges reported.
  • 512-token decode comparison and actual-image VLM cold/warm smoke.
  • Final lifecycle/security review and focused cache-compatibility regression suites.
  • GitHub Actions unit suite on Python 3.11, 3.12, and 3.13.
  • Qwen3.8 and KDA validation, if/when those model paths are available.

@hojin12312
hojin12312 marked this pull request as ready for review August 9, 2026 12:32
@hojin12312

Copy link
Copy Markdown
Contributor Author

Hi — I’m proposing this as an opt-in approach to keep long-context GDN prefix caching memory-bounded while preserving durable rewind and restart hits. The implementation, failure behavior, and measured 173K/256K results are documented in the PR body. The final lifecycle/security review is included, and the unit suite passes on Python 3.11, 3.12, and 3.13. I’d especially appreciate feedback on the split cache format, shared LRU/accounting model, and whether this is a suitable direction for upstream. I’m happy to narrow or restructure the change if maintainers would prefer a different integration shape.

@jundot
jundot merged commit 267d543 into jundot:main Aug 10, 2026
3 checks passed
@jundot

jundot commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Thanks for the thorough work here, the validation depth made this review much easier. I went through the split store/restore paths, the sidecar lifecycle, and the flag-off surface against the #1423/#2465 history, and the design holds up well, so I merged this as is. The inline write on queue saturation closes the counter pinning case from #1423 and bounds the staging RAM growth behind #2465, which I consider the right call.

One gap surfaced in my local smoke (Qwen3.5-0.8B hybrid, block size 2048). The sidecar signature hashes raw layer class names, so a request resumed from a warm hit extracts SizedArraysCache and commits its checkpoints under a different digest directory than the ArraysCache one restore derives from block metadata, which left a repaired sidecar invisible after restart and would quietly shrink multi-turn coverage toward the last cold-stored boundary. The fallback stayed safe and outputs matched throughout, so this is a coverage gap rather than a correctness bug. I fixed it in ded2bbe by routing the sidecar signature inputs through the existing _canonicalize_layer_cache_types on both the commit and restore paths, and re-verified that the delete/walkback/restart sequence now recovers the full endpoint.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants