Expose committed session cloning and build prefix reuse above it - #22812
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22812
Note: Links to docs will display an error until the docs builds have been completed. ⏳ No Failures, 183 PendingAs of commit 59d22ca with merge base 026ca3f ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
This PR needs a
|
cd2b290 to
aa4d4aa
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved MLX logit drift, allocation risk, and cache-hit metric distortion require changes and human review.
Pull request overview
Adds an opt-in, bounded prefix cache for off-graph batched LLM sessions, reusing immutable snapshots and replaying the final requested token for logits.
Changes:
- Adds longest-prefix lookup, LRU eviction, cloning, and retention handling.
- Integrates cache admission, publication, replay, capacity, lifecycle, and metrics.
- Adds MLX support, examples, documentation, tests, and CI coverage.
File summaries
| File | Reviewed changes and final notes |
|---|---|
extension/llm/cache/test/prefix_cache_test.cpp |
Tests prefix lookup, LRU policy, retention, and snapshot ownership. |
extension/llm/cache/test/prefix_cache_attention_test.cpp |
Tests attention correctness across cache layouts. |
extension/llm/cache/test/CMakeLists.txt |
Registers shared cache tests. |
extension/llm/cache/prefix_cache.h |
Implements bounded prefix snapshots and lookup. |
extension/llm/cache/cache.h |
Defines the backend-independent clone contract. |
extension/llm/batching/test/runner_test.cpp |
Tests reuse, replay, accounting, cancellation, and shutdown. |
extension/llm/batching/runner.h |
Documents opening-prompt reuse semantics. |
extension/llm/batching/runner.cpp |
Adds cache admission, publication, replay, and metrics. Moderate (1 vote): prefill throughput should exclude cached tokens or include restoration time. |
extension/llm/batching/README.md |
Documents prefix-cache configuration and behavior. |
extension/llm/batching/module_executor.h |
Exposes prefix-cache configuration and hooks. |
extension/llm/batching/module_executor.cpp |
Integrates cache construction and capacity sizing. Critical (1 vote): validate sequence limits before allocation to avoid OOM instead of returning InvalidArgument. |
extension/llm/batching/metrics.h |
Adds cached-token metrics. |
extension/llm/batching/metrics.cpp |
Reports cached-token metrics. |
extension/llm/batching/executor.h |
Adds optional executor prefix hooks. |
backends/mlx/test/mlx_prefix_cache_test.cpp |
Tests MLX cache reuse and attention. Moderate (1 vote): add a production-model regression or gate caching until reported logit drift is explained. |
backends/mlx/test/CMakeLists.txt |
Registers MLX prefix-cache tests. |
backends/mlx/examples/llm/run_llm_batched.cpp |
Adds cache flags and repeated prompt rounds. |
backends/mlx/examples/llm/README.md |
Documents MLX usage and smoke tests. |
.github/workflows/mlx.yml |
Adds expanded MLX cache test coverage. |
Review details
Suppressed comments (2)
backends/mlx/test/mlx_prefix_cache_test.cpp:114
- This oracle only exercises a hand-written two-layer attention model. The PR's own MLX validation reports cold-versus-warm logit mismatches on the real model even in replay controls without cloning, so this assertion can pass while the production cache changes logits. Please add a production-model regression that isolates the drift, or keep the cache path gated until that numerical discrepancy is explained.
EXPECT_TRUE(allclose(got, expected, 1e-2f));
extension/llm/batching/runner.cpp:1285
- Recording the cache hit here makes
GenerationMetrics::prefill_tokens_per_sec()use the fulln_prompt_tokens, including these cached tokens, whileprefill_span_us()starts at the first executed batch and therefore excludes prefix restoration. A large cache hit will consequently report cached work as prefill throughput (and can make the rate arbitrarily high). Compute this rate from the uncached prompt tokens, or add a separate metric whose denominator includes restoration time.
request.generation.m.n_cached_prompt_tokens = *reused;
- Files reviewed: 19/19 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Could we expose cloning as a general session operation and build prefix caching on top of that, rather than adding reuse_prefix() and cache_prefix() to Executor? Concretely, Session::clone_async(upto) would route through the runner to Executor::clone(source, upto), with ModuleExecutor delegating to BatchControl::seq_clone(). The result would be a new independently writable session containing the committed prefix, with backend retention checks and resource limits determining whether cloning succeeds. The prefix cache would then own retained snapshot sessions and the token-matching/LRU policy. A lookup would find the longest usable match and return a clone plus the matched-token count, leaving at least the final prompt token to be forwarded. On a miss, the caller opens a fresh session; on eviction, the cache releases its retained session. Thoughts? |
aa4d4aa to
a2e5e93
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved correctness, ordering, and test deadlock issues block approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
extension/llm/batching/runner.cpp:780
- A clone enqueued from an output callback is not processed before the next forward:
process_pending_commands_()runs only at the top ofrun_(), while a successfulexecute_one_batch_()immediately continues around that call. The continuation can therefore advance/wrap the source before thisCloneCommandruns, causing prompt snapshots to capture too late or be refused by sliding-window retention, contrary to the documented callback ordering. Drain clone/close commands between the completed forward and scheduling its continuation, or otherwise establish that ordering.
[this](CloneCommand& clone) { process_command_(std::move(clone)); },
- Files reviewed: 18/18 changed files
- Comments generated: 2
- Review effort level: Lite
| result.handle = result.session->generate_async( | ||
| std::move(suffix), |
There was a problem hiding this comment.
The numerical investigation at ddf92b40d1 now isolates MLX arithmetic differences in the 23-token BF16/FP16 selected-logits, batched-sequence reproducer. All traced/untraced and archived output checks pass byte-for-byte. The first difference is Q projection (op 35) on identical input and weights. Isolated attention replay reproduces the larger first-layer discrepancy: rounding QK scores and softmax probabilities to the model dtype reconstructs every unfused output value in both dtypes. Matched clone/original replay remains byte-exact.
A native control processing the entire prompt one token per forward gives byte-identical cold, split, original replay, and clone replay logits: all 12 pairwise comparisons pass across BF16/FP16, over 262,144 logits per row. All 32 main/preparation schedules and cache/work counters are checked, including reuse of 22 tokens and fresh execution at position 22. Ordinary multi-token prefill still reproduces the original 0.75 BF16 / 0.078125 FP16 maximum error. This supports schedule-dependent numerical drift; no prefix-policy or clone-state defect is demonstrated by this repro.
Uniform low-precision attention, FP32 attention, and FP32 attention plus projections were also tested; none resolves the ordinary cold/warm tolerance failure. This does not invalidate the local attention diagnosis, but full-model error growth is not completely traced. Serial prefill is a diagnostic control, not a production workaround.
The broader validation remains 251 native tests, 16 smoke groups, 88 cache/work checks, and 44 cold full/selected comparisons passing, with 72/88 ordinary cold/warm initial-logit comparisons outside the unchanged tolerances. The matching-schedule result covers one short prompt and one initial prediction; it is not a general numerical-equivalence claim. Non-greedy sampling remains untested and prefix reuse remains disabled by default (--prefix_cache_entries=0). The PR summary now records these findings.
a2e5e93 to
f57ca6b
Compare
ddf92b4 to
816bd2f
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
A moderate close-versus-executor-failure lifecycle issue remains, and the broad backend/MLX changes warrant human review.
Review details
Suppressed comments (1)
extension/llm/batching/runner.cpp:760
- The new
!status->opencheck is only reached byreap_cancelled_between calls toexecute_one_batch_. If a session is destroyed while its in-flight executor call returnsfalse(or produces an incomplete output),execute_one_batch_completes the generation asFailedand poisons the session before this check runs, even thoughSessiondestruction promises cancellation. Reclassify closed sessions before the failure/malformed-output completion paths asCancelled(and avoid poisoning them), and add a close-vs-executor-failure regression test.
if (generation &&
(generation->state->cancelled.load() ||
!entry.second.status->open.load(std::memory_order_acquire))) {
- Files reviewed: 19/19 changed files
- Comments generated: 0 new
- Review effort level: Lite
| std::move(config), | ||
| [emitter](const batching::GenerationUpdate& update) { | ||
| [emitter, retain_prefix, &result]( | ||
| const batching::GenerationUpdate& update) { |
There was a problem hiding this comment.
Since correct snapshot capture depends on requesting a clone from the first output callback, could PrefixCache provide a small helper for that pattern? Ideally it would wrap the generation callback, request the clone once without blocking, and expose a caller-thread collection/insertion step. That would keep users from having to reproduce the timing, future ownership, and failure-handling logic in each integration.
There was a problem hiding this comment.
Implemented in 59d22ca57d:
auto capture = prefix_cache.capture_prompt(session, prompt_tokens);
auto generation = session.generate_async(
suffix, config, capture.wrap(on_update));
generation.wait();
capture.collect();The wrapper requests one clone before the first nonempty user callback, without waiting. Collection inserts best-effort on the caller thread. Capture binds to the original session ID and status, so moving or reassigning the source wrapper is safe; it does not own the source or delay closure. The MLX example and docs use this flow.
All 214 shared tests pass, including 14 capture cases and three source-lifetime regressions. All five native targets and the standalone MLX runner build; lint passes. GPU execution was not repeated. The header also states lookup's one-boundary-per-snapshot behavior and the MLX cold/warm parity caveat, including the lack of a greedy-token guarantee.
816bd2f to
dfe955e
Compare
|
@claude Review this PR |
|
Claude finished @mergennachin's task in 6m 8s —— View job PR Review: Expose committed session cloning and build prefix reuseI reviewed the current tree (post- Architecture — the refactor holds upMoving prefix reuse out of
Remaining issues1. 2. 3. MLX warm/cold logit drift is the material risk — currently mitigated only by "disabled by default" Smaller notes
VerdictNo blocking bug in the exposed cloning path or the prefix policy on the current tree; the lifecycle and clone-contract reasoning is sound and matches the tests you describe. Item 1 is a doc/impl mismatch worth tightening, item 2 is a lifetime footgun worth pinning down in the contract, and item 3 is the real risk to flag for anyone who turns the cache on — the disabled-by-default + greedy-only posture is appropriate for landing this as experimental. Since @metascroy has already approved, these are refinements rather than merge blockers. --- • branch |
Expose Session::clone_async(upto) for independently writable committed prefixes. A caller-owned PrefixCache handles token matching and LRU eviction. Its capture_prompt helper requests a clone before the first user callback and collects it on the caller thread. Capture binds to session identity across moves without owning or delaying closure of the source. Lookup tries each snapshot at its longest matching boundary; refused snapshots are skipped. The MLX example forwards the final prompt token for fresh logits. Cache factory registration stays unchanged. Review the Session/Runner clone lifecycle first, then ModuleExecutor and backend clone contracts, PrefixCache policy/capture, and MLX integration. Test plan: 214 shared C++ tests pass, including 14 capture regressions for timing, reuse, failure paths, handle lifetime, and moved/reassigned/destroyed sources. All five native targets and the standalone MLX batched runner build; lint passes. Prior validation at ddf92b4 passed 49 MLX tests and Gemma cache/work checks across BF16/FP16, full/selected logits, both layouts, and wrapped prompts. Ordinary cold/warm logits still exceed tolerance; matched one-token schedules are exact. The header recommends opt-in greedy experiments until non-greedy sampling is validated, without promising greedy parity. GPU execution was not repeated for this lifetime change. Authored with OpenAI Codex.
dfe955e to
59d22ca
Compare
|
@claude Re-review now |
|
Claude finished @mergennachin's task in 1m 47s —— View job Re-review: prefix cache capture refactorI re-read the current tree (commit Prior items — statusItem 2 (dangling Item 3 (MLX cold/warm parity caveat visible to integrators) — resolved. The equivalence caveat is now in the Item 1 (lookup "longest usable match" doc/impl gap) — resolved by doc, as I offered. Rather than implementing per-snapshot boundary backoff, the contract was softened to match the code: "Each snapshot is tried once at its longest match; refusal skips it without retrying shorter boundaries" ( Spot checks on the new capture path
Remaining note (unchanged, non-blocking)The MLX warm/cold logit drift (72/88 ordinary cases outside tolerance at VerdictAll three items from my prior review are addressed in this revision, and the open Copilot comments (lookup backoff, the --- • branch |
There was a problem hiding this comment.
🟡 Changes recommended
The unresolved critical exception-handling and moderate capacity findings block approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
backends/mlx/examples/llm/README.md:216
- The required
cmpcommands contradict the warning above that MLX cold/warm parity is not guaranteed: valid prefix reuse can change logits and generated text, including under greedy sampling, so this smoke test can fail despite correct cache behavior. Please describe these comparisons as optional diagnostics and make the request-report reuse checks the actual success criterion.
The generated text should agree across all four runs. In the cached run's
request report, generation 0 should have zero cached prompt tokens and generation 1
should reuse the prompt except for its final token, which is replayed for
fresh logits. Repeat with `--cache_kind batched-cell` to check the alternative
backends/mlx/examples/llm/run_llm_batched.cpp:367
snapshot_slotsis the maximum number of snapshots this example can ever retain (min(prompts.size(), prefix_cache_entries)), but this reservation adds the full configured entry count. With one prompt and, for example, 63 entries, the example asks the cache factory for 65 resident sessions even though it can create at most one retained snapshot; a bounded layout such asCellCacheis rejected at its 64-sequence limit and the extra capacity is never usable. Reserve the actual possible retained-slot count here (or otherwise account for the prompt set) instead ofFLAGS_prefix_cache_entries.
const auto resident_sessions = static_cast<std::uint64_t>(prompts.size()) +
FLAGS_prefix_cache_entries + snapshot_slots;
- Files reviewed: 19/19 changed files
- Comments generated: 1
- Review effort level: Lite
| const auto seq_id = ctl_->seq_clone(it->second.seq_id, upto); | ||
| return seq_id ? publish_session(*seq_id, upto) : std::nullopt; | ||
| #if ET_HAS_EXCEPTIONS | ||
| } catch (const std::bad_alloc&) { |
Expose
Session::clone_async(upto)for independently writable committed prefixes. A caller-ownedPrefixCachehandles token matching and LRU eviction;capture_prompt(...).wrap(...)requests the prompt clone before the first user callback, and caller-threadcollect()inserts it after generation. Capture binds to session identity and survives moves without retaining source ownership. Lookup tries each snapshot only at its longest matching boundary. The MLX example uses this helper and forwards the final prompt token for fresh logits. The cache factory API stays unchanged; session limits are checked through the constructed cache'sBatchControl::max_seqs().Review the Session/Runner lifecycle first, then ModuleExecutor/backend clone contracts, prefix policy, and MLX integration.
214 shared native tests pass on Apple M1 Pro, including 14 capture tests for timing, reuse, failures, handle lifetime, and moved/reassigned/destroyed sources. All five native targets and the standalone MLX batched runner build; lint passes. GPU execution was not repeated for this lifetime change; the following GPU/model results are from
ddf92b40d1.All 49 MLX tests pass. Gemma 3 1B validation covers BF16/FP16, full/selected logits, both layouts, physical ring wrap, branches, closure, and concurrency: 16 smoke groups, 88 cache/work checks, and 44 cold export comparisons pass. Warm repeats forward 1 of 23/1280 prompt tokens. Normal cold/warm initial logits exceed tolerance in 72/88 cases. The public header recommends opt-in greedy experiments until non-greedy sampling is validated; even greedy token parity is not guaranteed. Clone controls with matching schedule/geometry are byte-exact. A 23-token control also gives byte-exact cold/split/replay/clone logits in both dtypes with one-token prefill. Isolated probes identify shape-dependent MLX projection and attention arithmetic; precision promotions did not resolve the normal cold/warm gap. The four existing exports were reused and hash-verified.
Authored with OpenAI Codex.