Skip to content

Commit 2ea9667

Browse files
committed
Preserve concurrent prompt captures and avoid duplicate snapshots
1 parent f8de70a commit 2ea9667

14 files changed

Lines changed: 329 additions & 105 deletions

File tree

‎examples/llm_server/cpp/module_worker_main.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ DEFINE_int32(
4242
DEFINE_bool(
4343
prefix_capture_prompt_end,
4444
false,
45-
"Retain each request's prompt end in the bounded prefix cache");
45+
"Best-effort retention of each request's prompt end in the bounded prefix cache");
4646
DEFINE_int32(max_session_tokens, 2048, "Maximum retained tokens per session");
4747
DEFINE_int32(max_decode_sequences, 2, "Decode slots in one packed forward");
4848
DEFINE_int32(
@@ -62,7 +62,7 @@ int main(int argc, char** argv) {
6262
std::cerr
6363
<< "--pte and --tokenizer are required; capacity limits must be positive"
6464
"; prefix-cache limits must be nonnegative; "
65-
"checkpoints require prefix_cache_entries > 0\n";
65+
"--prefix_checkpoint_interval and --prefix_capture_prompt_end require --prefix_cache_entries > 0\n";
6666
return 1;
6767
}
6868
executorch::extension::llm::serving::ServingRuntimeConfig config;

‎examples/llm_server/python/README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,16 @@ limit; choose a value supported by the export. Capacity defaults are
6666
`--prefix-cache-entries 0`. With prefix caching enabled, `--prefix-capture-slots 0`
6767
(the default) automatically allows one pending prompt capture per admitted request,
6868
bounded by `min(max_sessions, max_inflight_requests)`. A positive value overrides
69-
this allowance. With checkpoints enabled, automatic sizing uses at most two rows:
69+
this allowance. Add `--prefix-capture-prompt-end` to retain later request boundaries
70+
as well as initial prompts; it requires `--prefix-cache-entries > 0`. This mode
71+
keeps the same concurrent capture allowance and skips already-retained prompt ends.
72+
Capture is best effort: occupied transient rows can prevent a snapshot.
73+
With periodic checkpoints enabled, automatic sizing uses at most two rows:
7074
checkpoint collection is serialized, but one replacement can overlap. Explicit
7175
values above two add unused row capacity in that mode.
76+
Combining `--prefix-checkpoint-interval` and `--prefix-capture-prompt-end` requests
77+
both periodic and exact-end snapshots through the shared checkpoint lane. A pending
78+
checkpoint can prevent capture at a later boundary, including the prompt end.
7279
Logical sessions, packed decode width, and in-flight request capacity are independent
7380
limits. Python concurrency follows advertised request capacity, not the single
7481
native delivery thread. Python reads native async streams directly through bounded

‎examples/llm_server/python/serve.py‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,21 @@ def _validate_limits(
5555
parser.error(f"--prefix-cache-entries must be between 0 and {_MAX_INT32}")
5656
if not 0 <= args.prefix_checkpoint_interval <= _MAX_INT32:
5757
parser.error(f"--prefix-checkpoint-interval must be between 0 and {_MAX_INT32}")
58-
if (args.prefix_checkpoint_interval or prefix_capture_prompt_end) and not args.prefix_cache_entries:
59-
parser.error("--prefix-checkpoint-interval requires --prefix-cache-entries > 0")
58+
if (
59+
args.prefix_checkpoint_interval or prefix_capture_prompt_end
60+
) and not args.prefix_cache_entries:
61+
flag = (
62+
"--prefix-capture-prompt-end"
63+
if prefix_capture_prompt_end
64+
else "--prefix-checkpoint-interval"
65+
)
66+
parser.error(f"{flag} requires --prefix-cache-entries > 0")
6067
if not 0 <= prefix_capture_slots <= _MAX_INT32:
6168
parser.error(f"--prefix-capture-slots must be between 0 and {_MAX_INT32}")
6269
# Mirrors ServingRuntimeConfig::resolved_prefix_captures for row validation.
6370
if prefix_capture_slots == 0:
6471
prefix_capture_slots = min(args.max_sessions, args.max_inflight_requests)
65-
if args.prefix_checkpoint_interval or prefix_capture_prompt_end:
72+
if args.prefix_checkpoint_interval:
6673
prefix_capture_slots = min(prefix_capture_slots, 2)
6774
if not args.prefix_cache_entries:
6875
prefix_capture_slots = 0
@@ -108,8 +115,9 @@ def _parse_args(argv=None):
108115
parser.add_argument("--max-inflight-requests", type=int, default=64)
109116
parser.add_argument("--prefix-cache-entries", type=int, default=0)
110117
parser.add_argument(
111-
"--prefix-capture-prompt-end", action="store_true",
112-
help="Retain each request's prompt end in the bounded prefix cache",
118+
"--prefix-capture-prompt-end",
119+
action="store_true",
120+
help="Best-effort retention of each request's prompt end in the bounded prefix cache",
113121
)
114122
parser.add_argument(
115123
"--prefix-capture-slots",
@@ -121,7 +129,7 @@ def _parse_args(argv=None):
121129
"--prefix-checkpoint-interval",
122130
type=int,
123131
default=0,
124-
help="Checkpoint interval in tokens; 0: initial prompt only. Uses global LRU.",
132+
help="Periodic checkpoint interval in tokens; 0 disables periodic capture.",
125133
)
126134
parser.add_argument(
127135
"--api-dialect", choices=("openai", "openrouter"), default="openai"
@@ -136,7 +144,12 @@ def _parse_args(argv=None):
136144
parser.add_argument("--port", type=int, default=8000)
137145
args = parser.parse_args(argv)
138146

139-
_validate_limits(parser, args, prefix_capture_slots=args.prefix_capture_slots)
147+
_validate_limits(
148+
parser,
149+
args,
150+
prefix_capture_slots=args.prefix_capture_slots,
151+
prefix_capture_prompt_end=args.prefix_capture_prompt_end,
152+
)
140153

141154
args.worker_bin = os.path.expanduser(args.worker_bin)
142155
if shutil.which(args.worker_bin) is None:
@@ -212,7 +225,11 @@ async def serving_factory():
212225
if args.prefix_checkpoint_interval
213226
else []
214227
)
215-
+ (["--prefix_capture_prompt_end=true"] if args.prefix_capture_prompt_end else []),
228+
+ (
229+
["--prefix_capture_prompt_end=true"]
230+
if args.prefix_capture_prompt_end
231+
else []
232+
),
216233
stderr=stderr,
217234
)
218235
runtime = None

‎examples/llm_server/python/tests/test_serve_launcher.py‎

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -167,30 +167,7 @@ def test_prompt_end_capture_reaches_worker_and_requires_cache(argv, launch):
167167
)
168168

169169

170-
@pytest.mark.parametrize(
171-
"sessions,override,expected", [(8, 0, 2), (1, 0, 1), (8, 3, 3)]
172-
)
173-
def test_prompt_end_capture_bounds_automatic_rows(argv, sessions, override, expected):
174-
args = serve._parse_args(
175-
argv
176-
+ [
177-
"--prefix-cache-entries",
178-
"3",
179-
"--prefix-capture-prompt-end",
180-
"--max-sessions",
181-
str(sessions),
182-
"--prefix-capture-slots",
183-
str(override),
184-
]
185-
)
186-
assert (
187-
serve._validate_limits(
188-
Mock(), args, prefix_capture_slots=override, prefix_capture_prompt_end=True
189-
)
190-
== expected
191-
)
192-
193-
170+
@pytest.mark.parametrize("prompt_end", [False, True])
194171
@pytest.mark.parametrize(
195172
"sessions,requests,interval,override,expected",
196173
[
@@ -203,7 +180,7 @@ def test_prompt_end_capture_bounds_automatic_rows(argv, sessions, override, expe
203180
],
204181
)
205182
def test_capture_capacity_validation_and_worker_override(
206-
argv, launch, sessions, requests, interval, override, expected
183+
argv, launch, sessions, requests, interval, override, expected, prompt_end
207184
):
208185
serve.main(
209186
argv
@@ -219,9 +196,11 @@ def test_capture_capacity_validation_and_worker_override(
219196
"--prefix-capture-slots",
220197
str(override),
221198
]
199+
+ (["--prefix-capture-prompt-end"] if prompt_end else [])
222200
)
223201
command = launch.stages["spawn"].call_args.args[0]
224202
assert command[command.index("--prefix_capture_slots") + 1] == str(override)
203+
assert ("--prefix_capture_prompt_end=true" in command) == prompt_end
225204
args = SimpleNamespace(
226205
max_context=1024,
227206
max_sessions=sessions,
@@ -232,7 +211,13 @@ def test_capture_capacity_validation_and_worker_override(
232211
port=8000,
233212
)
234213
assert (
235-
serve._validate_limits(Mock(), args, prefix_capture_slots=override) == expected
214+
serve._validate_limits(
215+
Mock(),
216+
args,
217+
prefix_capture_slots=override,
218+
prefix_capture_prompt_end=prompt_end,
219+
)
220+
== expected
236221
)
237222

238223

‎examples/llm_server/spec/README.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,15 +387,21 @@ batched cache via `cache::kind::kBatched`, derives scheduler width from
387387
`preferred_batch_tokens()`, and constructs
388388
one `ServingRuntime`. It does not preload `forward`, run token-step loops, or use
389389
Glimmer's legacy session implementation. Prefix caching is disabled by default.
390-
`--prefix_cache_entries=N` enables creation-only reuse for both greedy and
390+
`--prefix_cache_entries=N` enables retained-prefix reuse for both greedy and
391391
sampled requests. With `--prefix_capture_slots=C`, it adds `N + C` physical rows
392392
for retained snapshots and transient captures; logical `--max_sessions` remains
393393
unchanged. The default `--prefix_capture_slots=0` resolves `C` automatically to
394394
`min(max_sessions, max_inflight_requests)`, so overlapping requests can each capture
395-
their initial prompt. With checkpoints enabled, automatic sizing uses at most two
395+
their initial prompt. `--prefix_capture_prompt_end=true` additionally requests
396+
continuation and replacement snapshots with the same concurrent allowance.
397+
It requires nonzero cache capacity and skips exact prompt ends already retained.
398+
With periodic checkpoints enabled, automatic sizing uses at most two
396399
rows because checkpoint collection remains serialized and one replacement can
397400
overlap. A positive value overrides automatic sizing; values above two add unused
398401
row capacity in checkpoint mode. Disabled prefix caching reserves no capture rows.
402+
Capture is best effort. Periodic checkpoints and prompt ends requested together
403+
share a serialized checkpoint lane; a pending clone can cause another boundary to
404+
be skipped. A C++ `prefix_checkpoint_policy` overrides both capture flags.
399405

400406
With an existing MLX-enabled ExecuTorch installation and matching gflags package,
401407
configure the MLX LLM examples and build `llm_worker`. Launch with `--pte`,

‎examples/llm_server/spec/batching.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,20 @@ speculative drafting/verification remain inside the runner and executor.
3333

3434
`--prefix-checkpoint-interval N` captures prompt snapshots at absolute multiples
3535
of N and at the initial prompt end. It requires `--prefix-cache-entries > 0`.
36-
Zero (the default) captures only the initial prompt. Capture is best effort.
37-
Retention uses global LRU; early checkpoints are not protected.
36+
Zero (the default) disables periodic capture. With prefix caching enabled and no
37+
other capture options, only initial prompts are retained.
38+
`--prefix-capture-prompt-end` also requests snapshots of continuation and replacement
39+
prompt ends. Alone, it preserves per-request capture concurrency; combined with an
40+
interval, it shares the serialized checkpoint lane. Capture is best effort: an
41+
occupied row or checkpoint lane can skip a boundary. Already-retained prompt ends
42+
are not cloned again. Retention uses global LRU; early checkpoints are not protected.
3843

3944
C++ applications can customize two independent policies in
4045
[`ServingRuntimeConfig`](../../../extension/llm/serving/serving_runtime.h):
4146

42-
- `prefix_checkpoint_policy` chooses capture positions and optional application metadata.
47+
- `prefix_checkpoint_policy` chooses capture positions and optional application metadata,
48+
overriding both `prefix_checkpoint_interval` and `prefix_capture_prompt_end`.
49+
This C++ callback is not a launcher option.
4350
- `prefix_cache_retention` chooses which snapshots to retain, including early anchors.
4451

4552
The [retention interface and default LRU policy](../../../extension/llm/batching/prefix_cache_retention.h)

‎examples/models/muse-glimmer/BATCHING_SERVING.md‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -224,19 +224,25 @@ apply. Text/image prefix snapshots are opt-in via `--prefix-cache-entries`
224224

225225
## Session Behavior
226226

227-
Prefix caching retains the initial prompt by default. Add
228-
`--prefix-capture-prompt-end` with `--prefix-cache-entries` to also retain the
229-
prompt boundary of subsequent requests, including tool-result continuations
230-
and replacements. Repeated or branched requests can then reuse those snapshots.
231-
New tool-result text still needs prefill. This is best-effort capture within the
232-
same bounded cache and transient-row limits, with global LRU retention; it can
233-
evict earlier prompts. Benchmark capture cost and memory for your workload
234-
before enabling it. It also works with `--prefix-checkpoint-interval` and adds
235-
the exact prompt end when it falls between periodic checkpoints. A pending
236-
checkpoint or another request can occupy the capture lane and skip a boundary;
237-
use prompt-end capture without periodic checkpoints when turn reuse is the
238-
priority. Automatic transient-row sizing is capped at two in either mode. The C++
239-
`prefix_checkpoint_policy`, when supplied, overrides both options.
227+
Prefix caching is disabled unless `--prefix-cache-entries` is nonzero. When
228+
configured without other capture options, it retains initial prompts. Add
229+
`--prefix-capture-prompt-end` to request snapshots of subsequent boundaries,
230+
including tool-result continuations and replacements. Repeated or branched requests
231+
can reuse those snapshots; new tool-result text still needs prefill. Exact prompt
232+
ends already in the cache are not cloned again.
233+
234+
Prompt-end-only capture preserves per-request capture concurrency and the default
235+
transient-row budget. All snapshots share the bounded cache and global LRU, so new
236+
boundaries can evict earlier prompts. Capture is best effort: concurrent captures
237+
or session replacements can occupy the available rows. Measure capture overhead
238+
and reuse for your workload before enabling it.
239+
240+
With `--prefix-checkpoint-interval`, periodic points and the exact prompt end use
241+
one serialized checkpoint lane. A pending checkpoint can cause a later boundary,
242+
including the prompt end, to be skipped. Automatic transient-row sizing is capped
243+
at two in periodic mode; prompt-end-only mode retains the ordinary concurrency
244+
budget. Use prompt-end capture without periodic checkpoints when turn reuse is the
245+
priority.
240246

241247
- Named sessions persist by default. `--evict-idle-sessions` allows the server to
242248
close the least recently used idle session when logical session slots are exhausted, clearing
@@ -334,7 +340,8 @@ python -m executorch.examples.models.muse_glimmer.serving.serve_batching \
334340
`model.pte`, or `--pos-embed-path`. Every resident session reserves `--max-context` cells of the
335341
pool: requests, `--prefix-cache-entries` snapshots, and the resolved number of
336342
capture slots when snapshots are enabled. `--prefix-capture-slots 0` (the default)
337-
matches request concurrency, capped at two when checkpoints are enabled; a
343+
matches request concurrency, including prompt-end-only mode; periodic checkpoints
344+
cap the automatic count at two; a
338345
positive value sets an explicit limit. Their total reservation must leave one
339346
cell for padded writes within the exported `--max-cells`. More capture slots
340347
reduce the context available per session in the native worker's automatic

‎examples/models/muse-glimmer/runtime/runners/batching_bootstrap.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ DEFINE_int32(
4747
DEFINE_bool(
4848
prefix_capture_prompt_end,
4949
false,
50-
"Retain each request's prompt end in the bounded prefix cache");
50+
"Best-effort retention of each request's prompt end in the bounded prefix cache");
5151
DEFINE_uint64(max_image_bytes, 20 * 1024 * 1024, "Maximum encoded image bytes");
5252
DEFINE_uint64(
5353
max_input_frame_bytes,

‎examples/models/muse-glimmer/serving/serve_batching.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def _parse_args(argv=None):
9090
parser.add_argument(
9191
"--prefix-capture-prompt-end",
9292
action="store_true",
93-
help="Retain each request's prompt end in the bounded prefix cache",
93+
help="Best-effort retention of each request's prompt end in the bounded prefix cache",
9494
)
9595
parser.add_argument(
9696
"--prefix-checkpoint-interval",

‎extension/llm/batching/prefix_cache.h‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,16 @@ inline std::vector<Position> periodic_prefill_breakpoints(
4848
Position interval,
4949
bool include_end = false) {
5050
std::vector<Position> positions;
51-
if (start < 0 || end <= start || interval <= 0) {
51+
if (start < 0 || end <= start || interval < 0) {
5252
return positions;
5353
}
54-
for (std::int64_t next =
55-
(static_cast<std::int64_t>(start) / interval + 1) * interval;
56-
next <= end;
57-
next += interval) {
58-
positions.push_back(static_cast<Position>(next));
54+
if (interval > 0) {
55+
for (std::int64_t next =
56+
(static_cast<std::int64_t>(start) / interval + 1) * interval;
57+
next <= end;
58+
next += interval) {
59+
positions.push_back(static_cast<Position>(next));
60+
}
5961
}
6062
if (include_end && (positions.empty() || positions.back() != end)) {
6163
positions.push_back(end);
@@ -348,6 +350,15 @@ class ET_EXPERIMENTAL PrefixCache {
348350
return lookup_identity(copy_tokens(request));
349351
}
350352

353+
// Exact retained identity, without cloning or refreshing recency.
354+
bool contains_identity(const PrefixIdentity& request) const {
355+
return std::any_of(
356+
entries_.begin(), entries_.end(), [&](const Entry& entry) {
357+
return entry.tokens.size() == request.size() &&
358+
common_prefix(entry.tokens, request) == request.size();
359+
});
360+
}
361+
351362
std::optional<PrefixMatch> lookup_identity(const PrefixIdentity& request) {
352363
auto match = find_identity(request);
353364
if (match) {

0 commit comments

Comments
 (0)