Skip to content

Commit 7a27f3c

Browse files
committed
perf(llama-cpp): checkpoint scoring at the caller-declared stable prefix
Hybrid-memory models (LFM2.5 shortconv, Qwen3.5 deltanet — where new small models are headed) cannot rewind their state, so any prompt-cache reuse that needs a rewind falls back to a full re-prefill. For classifier scoring that meant every probe change re-processed the whole option-list prompt: the server's checkpoints were placed reactively (at wherever the previous task happened to diverge), so a checkpoint past the next divergence was erased rather than restored — measured as intermittent 2-10s turns on prompts with a 95%+ common prefix. The classifier now computes the probe-invariant prompt prefix once (the byte-wise common prefix of two synthetic probe renders) and declares its length with every Score request; the server maps it to a token boundary and forces a KV checkpoint exactly there on each score prefill. That checkpoint sits at or before every future divergence under the same option list, so it always survives and always restores — repeat scoring costs probe+candidates regardless of how the probe changes. Also: - prewarm reruns on every option-list registration instead of memoizing per list: with boundary checkpoints a redundant rewarm costs two probe-sized decodes, while skipping one after a slot eviction (three lists sharing fewer slots evict in LRU cascades) silently moves a full re-prefill onto the user's next turn - new llama.cpp backend option rs_seq:N exposes bounded recurrent-state rollback outside speculative decoding; measured impractical for deltanet-scale states (65GB for 64 snapshots on Qwen3.5-4B) but cheap insurance for small-state models - docs: the multi-list recipe (parallel:N + sps:0.5 — the default slot similarity threshold funnels distinct lists onto one slot) Measured on the drone demo (LFM2.5-1.2B scorer, desktop CPU), steady state: every turn 285-421ms including mode switches, vs 2.4s post-switch and intermittent 1.3-2.9s re-prefills before. Assisted-by: Claude:claude-fable-5 Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 86c84bc commit 7a27f3c

12 files changed

Lines changed: 242 additions & 57 deletions

File tree

backend/backend.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,13 @@ message ScoreRequest {
173173
// candidates differ in length and the consumer wants a per-token
174174
// measure comparable across them (PMI-style scoring).
175175
bool length_normalize = 4;
176+
// Byte length of the prompt prefix that stays identical across
177+
// repeated scoring calls (e.g. a classifier's option-list system
178+
// prompt — everything before the per-turn probe text). Backends that
179+
// snapshot state (hybrid/recurrent models cannot rewind otherwise)
180+
// use it to place a reuse point exactly at the boundary, so the next
181+
// call re-processes only the tokens after it. 0 means unknown.
182+
int32 stable_prefix_len = 5;
176183
}
177184

178185
// CandidateScore is one row in the ScoreResponse, matching by index

backend/cpp/llama-cpp/grpc-server.cpp

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,20 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
690690
// If conversion fails, keep default value (0)
691691
}
692692
}
693+
} else if (!strcmp(optname, "n_rs_seq") || !strcmp(optname, "rs_seq")) {
694+
// Recurrent-state rollback snapshots per sequence. Hybrid models
695+
// (deltanet/conv layers) cannot rewind their state, so without
696+
// snapshots any prompt-cache reuse that needs a rewind — e.g. a
697+
// score task whose probe changed under a stable option-list
698+
// prefix — falls back to a full re-prefill. Costs recurrent-state
699+
// memory x (1 + N) per sequence; unsupported archs clamp to 0.
700+
if (optval != NULL) {
701+
try {
702+
params.n_rs_seq = std::stoi(optval_str);
703+
} catch (const std::exception& e) {
704+
// If conversion fails, keep default value (0)
705+
}
706+
}
693707
} else if (!strcmp(optname, "slot_prompt_similarity") || !strcmp(optname, "sps")) {
694708
if (optval != NULL) {
695709
try {
@@ -2999,11 +3013,31 @@ class BackendServiceImpl final : public backend::Backend::Service {
29993013
n_score_prompt = std::min(n_score_prompt, cand_divergence[ci]);
30003014
}
30013015

3016+
// Map the caller's stable-prefix byte length onto a token
3017+
// index: the last prompt token that ends at or before the
3018+
// boundary. A checkpoint forced there survives every future
3019+
// probe under the same option list, which is what keeps
3020+
// repeat scoring cheap on models that cannot rewind state.
3021+
int32_t n_stable_prompt = 0;
3022+
if (request->stable_prefix_len() > 0) {
3023+
size_t consumed = 0;
3024+
for (int32_t ti = 0; ti < n_score_prompt; ti++) {
3025+
const size_t piece_len = common_token_to_piece(vocab, prompt_tokens[ti]).size();
3026+
// BOS and other zero-length specials consume no prompt bytes
3027+
if (consumed + piece_len > (size_t) request->stable_prefix_len()) {
3028+
break;
3029+
}
3030+
consumed += piece_len;
3031+
n_stable_prompt = ti + 1;
3032+
}
3033+
}
3034+
30023035
server_task task(SERVER_TASK_TYPE_SCORE);
30033036
task.id = rd.queue_tasks.get_new_id();
30043037
task.index = 0;
30053038
task.tokens = server_tokens(llama_tokens(first.begin(), first.begin() + n_shared), false);
30063039
task.n_score_prompt = n_score_prompt;
3040+
task.n_stable_prompt = n_stable_prompt;
30073041
task.score_suffixes.reserve(included.size());
30083042
for (int32_t ci : included) {
30093043
task.score_suffixes.emplace_back(cand_tokens[ci].begin() + n_shared, cand_tokens[ci].end());

backend/cpp/llama-cpp/patches/0001-add-server-task-type-score.patch

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,45 @@
11
diff --git a/common/common.cpp b/common/common.cpp
2-
index 8f13217..fc0a164 100644
2+
index 8f13217..fc584e1 100644
33
--- a/common/common.cpp
44
+++ b/common/common.cpp
5-
@@ -1591,7 +1591,9 @@ struct llama_context_params common_context_params_to_llama(const common_params &
5+
@@ -1591,8 +1591,10 @@ struct llama_context_params common_context_params_to_llama(const common_params &
66
auto cparams = llama_context_default_params();
77

88
cparams.n_ctx = params.n_ctx;
99
- cparams.n_seq_max = params.n_parallel;
10+
- cparams.n_rs_seq = params.speculative.need_n_rs_seq();
1011
+ // score-task forks need seq ids (and recurrent-state cells) of their
1112
+ // own beyond the parallel slots
1213
+ cparams.n_seq_max = params.n_parallel + params.n_seq_score_forks;
13-
cparams.n_rs_seq = params.speculative.need_n_rs_seq();
14+
+ cparams.n_rs_seq = std::max(params.speculative.need_n_rs_seq(), (uint32_t) std::max(0, params.n_rs_seq));
1415
cparams.n_outputs_max = std::max(params.n_outputs_max, 0);
1516
cparams.n_batch = params.n_batch;
17+
cparams.n_ubatch = params.n_ubatch;
1618
diff --git a/common/common.h b/common/common.h
17-
index 1535317..8cdd664 100644
19+
index bffc176..e313bd6 100644
1820
--- a/common/common.h
1921
+++ b/common/common.h
20-
@@ -454,6 +454,8 @@ struct common_params {
22+
@@ -455,6 +455,9 @@ struct common_params {
2123
int32_t n_keep = 0; // number of tokens to keep from initial prompt
2224
int32_t n_chunks = -1; // max number of chunks to process (-1 = unlimited)
2325
int32_t n_parallel = 1; // number of parallel sequences to decode
2426
+ int32_t n_seq_score_forks = 0; // extra seq ids beyond n_parallel, reserved for server score-task forks
27+
+ int32_t n_rs_seq = 0; // recurrent-state rollback snapshots per seq (hybrid models cannot rewind without them; lets score tasks reuse a cached prompt across probe changes)
2528
+ bool score_enabled = false; // reserve server resources for the Score task type
2629
int32_t n_sequences = 1; // number of sequences to decode
2730
int32_t n_outputs_max = 0; // max outputs in a batch (0 = n_batch)
2831
int32_t grp_attn_n = 1; // group-attention factor
32+
diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt
33+
index 780df32..1d2fe8f 100644
34+
--- a/tools/CMakeLists.txt
35+
+++ b/tools/CMakeLists.txt
36+
@@ -41,3 +41,4 @@ else()
37+
add_subdirectory(fit-params)
38+
add_subdirectory(results)
39+
endif()
40+
+add_subdirectory(grpc-server)
2941
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
30-
index 98d0cca..ac9bcac 100644
42+
index 715477e..de5bed8 100644
3143
--- a/tools/server/server-context.cpp
3244
+++ b/tools/server/server-context.cpp
3345
@@ -49,7 +49,16 @@ static uint32_t server_n_outputs_max(const common_params & params) {
@@ -48,7 +60,7 @@ index 98d0cca..ac9bcac 100644
4860

4961
return std::max<uint32_t>(1, std::min<uint64_t>(n_batch, n_outputs));
5062
}
51-
@@ -202,6 +206,26 @@ struct server_slot {
63+
@@ -202,6 +211,26 @@ struct server_slot {
5264

5365
std::vector<completion_token_output> generated_token_probs;
5466

@@ -75,7 +87,7 @@ index 98d0cca..ac9bcac 100644
7587
bool has_next_token = true;
7688
bool has_new_line = false;
7789
bool truncated = false;
78-
@@ -317,6 +341,10 @@ struct server_slot {
90+
@@ -311,6 +340,10 @@ struct server_slot {
7991
}
8092
generated_tokens.clear();
8193
generated_token_probs.clear();
@@ -86,7 +98,7 @@ index 98d0cca..ac9bcac 100644
8698
json_schema = json();
8799

88100
// clear speculative decoding stats
89-
@@ -2208,6 +2236,229 @@ private:
101+
@@ -2205,6 +2238,229 @@ private:
90102
queue_results.send(std::move(res));
91103
}
92104

@@ -316,17 +328,17 @@ index 98d0cca..ac9bcac 100644
316328
//
317329
// Functions to process the task
318330
//
319-
@@ -2324,6 +2575,7 @@ private:
331+
@@ -2341,6 +2597,7 @@ private:
320332
case SERVER_TASK_TYPE_INFILL:
321333
case SERVER_TASK_TYPE_EMBEDDING:
322334
case SERVER_TASK_TYPE_RERANK:
323335
+ case SERVER_TASK_TYPE_SCORE:
324336
{
325337
// special case: if input is provided via CLI, tokenize it first
326338
// otherwise, no need to tokenize as it's already done inside the HTTP thread
327-
@@ -2796,6 +3048,13 @@ private:
339+
@@ -2832,6 +3089,13 @@ private:
340+
break; // stop any further processing
328341
}
329-
330342
}
331343
+
332344
+ try {
@@ -338,7 +350,7 @@ index 98d0cca..ac9bcac 100644
338350
}
339351

340352
void pre_decode() {
341-
@@ -3118,6 +3377,16 @@ private:
353+
@@ -3154,6 +3418,16 @@ private:
342354
n_past = std::min(n_past, slot.alora_invocation_start - 1);
343355
}
344356

@@ -355,7 +367,7 @@ index 98d0cca..ac9bcac 100644
355367
const auto n_cache_reuse = slot.task->params.n_cache_reuse;
356368

357369
const bool can_cache_reuse =
358-
@@ -3359,8 +3628,12 @@ private:
370+
@@ -3395,8 +3669,12 @@ private:
359371

360372
bool do_checkpoint = params_base.n_ctx_checkpoints > 0;
361373

@@ -370,7 +382,7 @@ index 98d0cca..ac9bcac 100644
370382

371383
// make a checkpoint of the parts of the memory that cannot be rolled back.
372384
// checkpoints are created only if:
373-
@@ -3427,10 +3700,17 @@ private:
385+
@@ -3463,10 +3741,17 @@ private:
374386
// embedding requires all tokens in the batch to be output;
375387
// MTP also wants logits at every prompt position so the
376388
// streaming hook can mirror t_h_nextn into ctx_dft.
@@ -389,7 +401,7 @@ index 98d0cca..ac9bcac 100644
389401
slot.prompt.tokens.push_back(cur_tok);
390402

391403
slot.n_prompt_tokens_processed++;
392-
@@ -3445,6 +3725,25 @@ private:
404+
@@ -3481,6 +3766,32 @@ private:
393405
}
394406
}
395407

@@ -399,8 +411,15 @@ index 98d0cca..ac9bcac 100644
399411
+ // point where this task diverged from the previous cache: after a
400412
+ // forced re-prefill a checkpoint there serves the next scoring call
401413
+ // over the same stable prefix (e.g. a classifier's option list).
414+
+ // The caller-declared stable-prefix boundary is the strongest of
415+
+ // these: a checkpoint there is at or before every future task's
416+
+ // divergence within the same option list, so it always survives
417+
+ // and always restores.
402418
+ if (do_checkpoint && slot.task->type == SERVER_TASK_TYPE_SCORE &&
403419
+ (slot.prompt.n_tokens() == slot.task->n_score_prompt - 1 ||
420+
+ (slot.task->n_stable_prompt > 0 &&
421+
+ slot.prompt.n_tokens() == slot.task->n_stable_prompt &&
422+
+ slot.prompt.n_tokens() < slot.task->n_score_prompt - 1) ||
404423
+ (slot.prompt.n_tokens() == slot.score_divergence &&
405424
+ slot.prompt.n_tokens() < slot.task->n_score_prompt - 1))) {
406425
+ bool have_ckpt = false;
@@ -415,7 +434,7 @@ index 98d0cca..ac9bcac 100644
415434
// process the last few tokens of the prompt separately in order to allow for a checkpoint to be created.
416435
// create checkpoints that many tokens before the end of the prompt:
417436
// - 4 + n_ubatch
418-
@@ -3477,6 +3776,13 @@ private:
437+
@@ -3513,6 +3824,15 @@ private:
419438
const bool is_user_start = spans.is_user_start(n_tokens_start);
420439
const bool is_last_user_message = n_tokens_start == last_user_pos;
421440

@@ -424,12 +443,14 @@ index 98d0cca..ac9bcac 100644
424443
+ // and every candidate / next scoring call would re-process the prompt
425444
+ const bool is_score_boundary = slot.task->type == SERVER_TASK_TYPE_SCORE &&
426445
+ (n_tokens_start == slot.task->n_score_prompt - 1 ||
446+
+ (slot.task->n_stable_prompt > 0 &&
447+
+ n_tokens_start == slot.task->n_stable_prompt) ||
427448
+ n_tokens_start == slot.score_divergence);
428449
+
429450
// entire prompt has been processed
430451
if (slot.prompt.n_tokens() == slot.task->n_tokens()) {
431452
slot.state = SLOT_STATE_DONE_PROMPT;
432-
@@ -3492,8 +3798,8 @@ private:
453+
@@ -3528,8 +3848,8 @@ private:
433454
slot.init_sampler();
434455
} else {
435456
// skip ordinary mid-prompt checkpoints, unless the batch starts a user
@@ -440,18 +461,20 @@ index 98d0cca..ac9bcac 100644
440461
do_checkpoint = false;
441462
}
442463
}
443-
@@ -3510,8 +3816,8 @@ private:
464+
@@ -3546,10 +3866,10 @@ private:
444465
// do not checkpoint after mtmd chunks
445466
do_checkpoint = do_checkpoint && !has_mtmd;
446467

447468
- // no need to create checkpoints that are too close together, unless it's the last user message
448-
- do_checkpoint = do_checkpoint && (slot.prompt.checkpoints.empty() || is_last_user_message || n_tokens_start > slot.prompt.checkpoints.back().n_tokens + params_base.checkpoint_min_step);
449469
+ // no need to create checkpoints that are too close together, unless it's the last user message or the score boundary
450-
+ do_checkpoint = do_checkpoint && (slot.prompt.checkpoints.empty() || is_last_user_message || is_score_boundary || n_tokens_start > slot.prompt.checkpoints.back().n_tokens + params_base.checkpoint_min_step);
470+
do_checkpoint = do_checkpoint && (
471+
slot.prompt.checkpoints.empty() ||
472+
- is_last_user_message || near_prompt_end ||
473+
+ is_last_user_message || near_prompt_end || is_score_boundary ||
474+
n_tokens_start > slot.prompt.checkpoints.back().n_tokens + params_base.checkpoint_min_step);
451475
SLT_DBG(slot, "main/do_checkpoint = %s, pos_min = %d, pos_max = %d\n", do_checkpoint ? "yes" : "no", pos_min, pos_max);
452476

453-
// note: we create the checkpoint before calling llama_decode(), so the current batch is not
454-
@@ -3683,6 +3989,13 @@ private:
477+
@@ -3703,6 +4023,13 @@ private:
455478
}
456479
}
457480

@@ -465,7 +488,7 @@ index 98d0cca..ac9bcac 100644
465488
if (!is_inside_view(slot.i_batch)) {
466489
// the required token not in this sub-batch, skip
467490
return;
468-
@@ -3704,6 +4017,25 @@ private:
491+
@@ -3724,6 +4051,25 @@ private:
469492
return;
470493
}
471494

@@ -492,7 +515,7 @@ index 98d0cca..ac9bcac 100644
492515

493516
// prompt evaluated for next-token prediction
494517
diff --git a/tools/server/server-task.h b/tools/server/server-task.h
495-
index dc6b2da..935ead1 100644
518+
index c3eea2e..fb3c178 100644
496519
--- a/tools/server/server-task.h
497520
+++ b/tools/server/server-task.h
498521
@@ -13,10 +13,25 @@
@@ -521,7 +544,7 @@ index dc6b2da..935ead1 100644
521544
SERVER_TASK_TYPE_INFILL,
522545
SERVER_TASK_TYPE_CANCEL,
523546
SERVER_TASK_TYPE_CONTROL,
524-
@@ -153,6 +168,13 @@ struct server_task {
547+
@@ -153,6 +168,18 @@ struct server_task {
525548
task_params params;
526549
server_tokens tokens;
527550

@@ -531,19 +554,24 @@ index dc6b2da..935ead1 100644
531554
+ // tokens beyond the shared prefix ride a forked sequence.
532555
+ int32_t n_score_prompt = 0;
533556
+ std::vector<llama_tokens> score_suffixes;
557+
+ // token index where the caller-declared stable prompt prefix ends
558+
+ // (0 = no hint): the option-list system prompt that repeats across
559+
+ // scoring calls. A context checkpoint is forced there so models that
560+
+ // cannot rewind state re-process only the per-call tail next time.
561+
+ int32_t n_stable_prompt = 0;
534562
+
535563
// only used by CLI, this allow tokenizing CLI inputs on server side
536564
// we need this because mtmd_context and vocab are not accessible outside of server_context
537565
bool cli = false;
538-
@@ -197,6 +219,7 @@ struct server_task {
566+
@@ -197,6 +224,7 @@ struct server_task {
539567
switch (type) {
540568
case SERVER_TASK_TYPE_COMPLETION:
541569
case SERVER_TASK_TYPE_INFILL:
542570
+ case SERVER_TASK_TYPE_SCORE:
543571
return true;
544572
default:
545573
return false;
546-
@@ -494,6 +517,25 @@ struct server_task_result_rerank : server_task_result {
574+
@@ -494,6 +522,25 @@ struct server_task_result_rerank : server_task_result {
547575
virtual json to_json() override;
548576
};
549577

core/application/router_factories.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ type lazyScorer struct {
4646
modelName string
4747
}
4848

49-
func (l *lazyScorer) Score(ctx context.Context, prompt string, candidates []string) ([]backend.CandidateScore, error) {
49+
func (l *lazyScorer) Score(ctx context.Context, prompt string, stablePrefixLen int, candidates []string) ([]backend.CandidateScore, error) {
5050
cfg := l.app.adapterConfig(l.modelName)
5151
if cfg == nil {
5252
return nil, fmt.Errorf("scorer: model %q no longer available", l.modelName)
5353
}
54-
return backend.NewScorer(l.app.modelLoader, *cfg, l.app.applicationConfig).Score(ctx, prompt, candidates)
54+
return backend.NewScorer(l.app.modelLoader, *cfg, l.app.applicationConfig).Score(ctx, prompt, stablePrefixLen, candidates)
5555
}
5656

5757
// TokenCounter returns a func so the middleware's literal field type accepts

core/application/router_factories_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ var _ = Describe("router_factories lazy config resolution", func() {
109109
Expect(lazy.modelName).To(Equal("score-test"))
110110

111111
removeCfg("score-test")
112-
_, err := sc.Score(context.Background(), "prompt", []string{"a"})
112+
_, err := sc.Score(context.Background(), "prompt", 0, []string{"a"})
113113
Expect(err).To(HaveOccurred())
114114
Expect(err.Error()).To(ContainSubstring("no longer available"))
115115
})

core/backend/score.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ type ScoreOptions struct {
2323
// token count. Useful when comparing candidates of different
2424
// lengths — without it, longer candidates score lower by default.
2525
LengthNormalize bool
26+
// StablePrefixLen is the byte length of the prompt prefix that stays
27+
// identical across repeated scoring calls (0 = unknown); forwarded to
28+
// the backend as a state-reuse boundary hint.
29+
StablePrefixLen int
2630
}
2731

2832
// CandidateScore is the per-candidate result. Mirrors pb.CandidateScore
@@ -42,9 +46,13 @@ type TokenLogProb struct {
4246
// Scorer evaluates a model's joint log-probability of each candidate
4347
// continuation given a shared prompt. Implemented by NewScorer over a
4448
// model-loaded backend; the router's score classifier consumes this
45-
// for multi-label policy selection.
49+
// for multi-label policy selection. stablePrefixLen is the byte length
50+
// of the prompt prefix that stays identical across calls (0 = unknown)
51+
// — backends use it to place a state-reuse point at the boundary, which
52+
// is what keeps repeat scoring fast on models that cannot rewind
53+
// (hybrid/recurrent architectures).
4654
type Scorer interface {
47-
Score(ctx context.Context, prompt string, candidates []string) ([]CandidateScore, error)
55+
Score(ctx context.Context, prompt string, stablePrefixLen int, candidates []string) ([]CandidateScore, error)
4856
}
4957

5058
// NewScorer binds (loader, modelConfig, appConfig) into a Scorer. The
@@ -61,8 +69,8 @@ type modelScorer struct {
6169
appConfig *config.ApplicationConfig
6270
}
6371

64-
func (m *modelScorer) Score(ctx context.Context, prompt string, candidates []string) ([]CandidateScore, error) {
65-
fn, err := ModelScore(prompt, candidates, ScoreOptions{LengthNormalize: true}, m.loader, m.modelConfig, m.appConfig)
72+
func (m *modelScorer) Score(ctx context.Context, prompt string, stablePrefixLen int, candidates []string) ([]CandidateScore, error) {
73+
fn, err := ModelScore(prompt, candidates, ScoreOptions{LengthNormalize: true, StablePrefixLen: stablePrefixLen}, m.loader, m.modelConfig, m.appConfig)
6674
if err != nil {
6775
return nil, err
6876
}
@@ -102,6 +110,7 @@ func ModelScore(prompt string, candidates []string, opts ScoreOptions, loader *m
102110
Candidates: candidates,
103111
IncludeTokenLogprobs: opts.IncludeTokenLogprobs,
104112
LengthNormalize: opts.LengthNormalize,
113+
StablePrefixLen: int32(opts.StablePrefixLen),
105114
})
106115
results := scoreResponseToCandidates(resp, opts.IncludeTokenLogprobs)
107116
if appConfig.EnableTracing {

0 commit comments

Comments
 (0)