Skip to content

Commit 6f7cf0d

Browse files
committed
fix(realtime): align classifier cache guidance
Document the single-score prewarm behavior and clean the vendored score patch formatting. Assisted-by: Codex:gpt-5 Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 7a27f3c commit 6f7cf0d

3 files changed

Lines changed: 31 additions & 32 deletions

File tree

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

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ index 8f13217..fc584e1 100644
44
+++ b/common/common.cpp
55
@@ -1591,8 +1591,10 @@ struct llama_context_params common_context_params_to_llama(const common_params &
66
auto cparams = llama_context_default_params();
7-
7+
88
cparams.n_ctx = params.n_ctx;
99
- cparams.n_seq_max = params.n_parallel;
1010
- cparams.n_rs_seq = params.speculative.need_n_rs_seq();
@@ -43,9 +43,9 @@ index 715477e..de5bed8 100644
4343
--- a/tools/server/server-context.cpp
4444
+++ b/tools/server/server-context.cpp
4545
@@ -49,7 +49,16 @@ static uint32_t server_n_outputs_max(const common_params & params) {
46-
46+
4747
const uint32_t n_outputs_per_seq = 1 + common_speculative_n_max(&params.speculative);
48-
48+
4949
- const uint64_t n_outputs = (uint64_t) params.n_parallel * n_outputs_per_seq;
5050
+ // score tasks (SERVER_TASK_TYPE_SCORE) output logits for every candidate
5151
+ // token, so reserve room for a bounded candidate tail per parallel slot
@@ -57,13 +57,13 @@ index 715477e..de5bed8 100644
5757
+ const uint32_t n_outputs_score_seq = 1 + SERVER_SCORE_MAX_CAND_TOKENS;
5858
+
5959
+ const uint64_t n_outputs = (uint64_t) params.n_parallel * std::max(n_outputs_per_seq, n_outputs_score_seq);
60-
60+
6161
return std::max<uint32_t>(1, std::min<uint64_t>(n_batch, n_outputs));
6262
}
6363
@@ -202,6 +211,26 @@ struct server_slot {
64-
64+
6565
std::vector<completion_token_output> generated_token_probs;
66-
66+
6767
+ // SERVER_TASK_TYPE_SCORE: shared-prefix token logprobs harvested
6868
+ // incrementally across batch views (NaN = not yet produced)
6969
+ std::vector<float> score_logprobs;
@@ -96,12 +96,12 @@ index 715477e..de5bed8 100644
9696
+ score_suffix_pending = false;
9797
+ score_divergence = -1;
9898
json_schema = json();
99-
99+
100100
// clear speculative decoding stats
101101
@@ -2205,6 +2238,229 @@ private:
102102
queue_results.send(std::move(res));
103103
}
104-
104+
105105
+ // log(sum(exp(logits))) with max-subtraction for stability — the
106106
+ // log_softmax denominator shared by every token read from one output
107107
+ static double score_log_denom(const float * logits, int32_t n_vocab) {
@@ -348,12 +348,12 @@ index 715477e..de5bed8 100644
348348
+ abort_all_slots("update_score_suffixes() failed: " + std::string(e.what()));
349349
+ }
350350
}
351-
351+
352352
void pre_decode() {
353353
@@ -3154,6 +3418,16 @@ private:
354354
n_past = std::min(n_past, slot.alora_invocation_start - 1);
355355
}
356-
356+
357357
+ // score tasks need the logits that predict the first candidate
358358
+ // token, so the last shared-prompt token must be (re-)decoded
359359
+ // even when the cache already covers it
@@ -365,12 +365,12 @@ index 715477e..de5bed8 100644
365365
+ }
366366
+
367367
const auto n_cache_reuse = slot.task->params.n_cache_reuse;
368-
368+
369369
const bool can_cache_reuse =
370370
@@ -3395,8 +3669,12 @@ private:
371-
371+
372372
bool do_checkpoint = params_base.n_ctx_checkpoints > 0;
373-
373+
374374
- // make checkpoints only for completion tasks
375375
- do_checkpoint = do_checkpoint && slot.task->type == SERVER_TASK_TYPE_COMPLETION;
376376
+ // make checkpoints for completion tasks, and for score tasks at the
@@ -379,7 +379,7 @@ index 715477e..de5bed8 100644
379379
+ // prompt for every candidate of a scoring call
380380
+ do_checkpoint = do_checkpoint && (slot.task->type == SERVER_TASK_TYPE_COMPLETION ||
381381
+ slot.task->type == SERVER_TASK_TYPE_SCORE);
382-
382+
383383
// make a checkpoint of the parts of the memory that cannot be rolled back.
384384
// checkpoints are created only if:
385385
@@ -3463,10 +3741,17 @@ private:
@@ -399,12 +399,12 @@ index 715477e..de5bed8 100644
399399
- slot.need_embd());
400400
+ slot.need_embd() || need_score_logit);
401401
slot.prompt.tokens.push_back(cur_tok);
402-
402+
403403
slot.n_prompt_tokens_processed++;
404404
@@ -3481,6 +3766,32 @@ private:
405405
}
406406
}
407-
407+
408408
+ // score tasks: break at the shared-prompt boundary so the checkpoint
409409
+ // below lands exactly there — the other candidates of the same
410410
+ // scoring call re-process only their own tokens. Also break at the
@@ -437,7 +437,7 @@ index 715477e..de5bed8 100644
437437
@@ -3513,6 +3824,15 @@ private:
438438
const bool is_user_start = spans.is_user_start(n_tokens_start);
439439
const bool is_last_user_message = n_tokens_start == last_user_pos;
440-
440+
441441
+ // a batch starting at the score boundary or divergence point must
442442
+ // always checkpoint — min-step spacing would otherwise suppress it
443443
+ // and every candidate / next scoring call would re-process the prompt
@@ -464,7 +464,7 @@ index 715477e..de5bed8 100644
464464
@@ -3546,10 +3866,10 @@ private:
465465
// do not checkpoint after mtmd chunks
466466
do_checkpoint = do_checkpoint && !has_mtmd;
467-
467+
468468
- // no need to create checkpoints that are too close together, unless it's the last user message
469469
+ // no need to create checkpoints that are too close together, unless it's the last user message or the score boundary
470470
do_checkpoint = do_checkpoint && (
@@ -473,11 +473,11 @@ index 715477e..de5bed8 100644
473473
+ is_last_user_message || near_prompt_end || is_score_boundary ||
474474
n_tokens_start > slot.prompt.checkpoints.back().n_tokens + params_base.checkpoint_min_step);
475475
SLT_DBG(slot, "main/do_checkpoint = %s, pos_min = %d, pos_max = %d\n", do_checkpoint ? "yes" : "no", pos_min, pos_max);
476-
476+
477477
@@ -3703,6 +4023,13 @@ private:
478478
}
479479
}
480-
480+
481481
+ // score slots harvest logprobs from every view that contains
482482
+ // their outputs, not just the one holding the final token
483483
+ if (slot.task && slot.task->type == SERVER_TASK_TYPE_SCORE &&
@@ -491,7 +491,7 @@ index 715477e..de5bed8 100644
491491
@@ -3724,6 +4051,25 @@ private:
492492
return;
493493
}
494-
494+
495495
+ if (slot.task->type == SERVER_TASK_TYPE_SCORE) {
496496
+ // shared-prefix logprobs (and every candidate's first
497497
+ // suffix logprob) were accumulated per view above;
@@ -512,16 +512,16 @@ index 715477e..de5bed8 100644
512512
+ }
513513
+
514514
GGML_ASSERT(slot.task->need_sampling());
515-
515+
516516
// prompt evaluated for next-token prediction
517517
diff --git a/tools/server/server-task.h b/tools/server/server-task.h
518518
index c3eea2e..fb3c178 100644
519519
--- a/tools/server/server-task.h
520520
+++ b/tools/server/server-task.h
521521
@@ -13,10 +13,25 @@
522-
522+
523523
using json = nlohmann::ordered_json;
524-
524+
525525
+// SERVER_TASK_TYPE_SCORE emits one logits output per candidate token (plus
526526
+// the forced last-token output), and the context's output budget
527527
+// (n_outputs_max) is reserved up front — so candidate length must be
@@ -547,7 +547,7 @@ index c3eea2e..fb3c178 100644
547547
@@ -153,6 +168,18 @@ struct server_task {
548548
task_params params;
549549
server_tokens tokens;
550-
550+
551551
+ // used by SERVER_TASK_TYPE_SCORE: `tokens` holds the shared prefix
552552
+ // (prompt + longest common candidate token prefix) and logprobs are
553553
+ // returned for its tokens from n_score_prompt onward. Each candidate's
@@ -574,7 +574,7 @@ index c3eea2e..fb3c178 100644
574574
@@ -494,6 +522,25 @@ struct server_task_result_rerank : server_task_result {
575575
virtual json to_json() override;
576576
};
577-
577+
578578
+struct server_task_result_score : server_task_result {
579579
+ // log P(token | prefix) for the shared-prefix tokens after
580580
+ // n_score_prompt, in order; NaN marks positions the decode never

core/http/endpoints/openai/realtime_model.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -507,11 +507,10 @@ func (m *wrappedModel) classifierFor(options []types.ClassifierOption, normaliza
507507

508508
// PrewarmClassifier primes the scoring backend's prompt cache for a newly
509509
// registered option list so the first real turns don't pay the prefill.
510-
// Two throwaway scores with distinct probes run back to back: the first
511-
// prefills the new option-list prompt, and the second — diverging exactly
512-
// where the per-turn probe text starts — leaves the backend a rewind point
513-
// (a KV checkpoint on hybrid/recurrent models, which cannot rewind
514-
// arbitrarily) at the stable-prefix boundary every subsequent turn reuses.
510+
// One throwaway score prefills the new option-list prompt and declares the
511+
// per-turn probe boundary, leaving the backend a rewind point (a KV
512+
// checkpoint on hybrid/recurrent models, which cannot rewind arbitrarily)
513+
// at the stable prefix every subsequent turn reuses.
515514
// Best-effort: errors are logged, never surfaced.
516515
func (m *wrappedModel) PrewarmClassifier(ctx context.Context, options []types.ClassifierOption, normalization string) {
517516
classifier, err := m.classifierFor(options, normalization)

docs/content/features/openai-realtime.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ The option's spoken `reply` can reference the same placeholders — `reply: "Goi
246246
247247
Slot declarations (and hints) are appended to the option's description in the shared system prompt, so they also inform scoring and cost no extra per-turn tokens. The `localai.classifier.result` event carries the final `arguments` and a `fill_latency_ms`. On an inference failure the slots' defaults apply (and template the reply); if any slot lacks a default the response fails (or falls through to generation with `fallback.mode: generate`). Slot filling requires `completion` in the scoring model's `known_usecases` alongside `score`.
248248

249-
Registering an option list (pipeline seed or `session.update`) prewarms the scoring prompt in the background: two throwaway scores prefill the option-list prompt and plant a state checkpoint exactly at the per-turn probe boundary. Every scoring call also declares that boundary (the probe-invariant prompt prefix) to the backend, which checkpoints there on each prefill — on hybrid/recurrent models (which cannot rewind their state arbitrarily, only restore checkpoints) this is what keeps every turn at probe-size cost instead of a full option-list re-prefill. A client that swaps option lists at runtime (e.g. voice-switched command modes) pays nothing on the first turn after a swap: the rewarm hides behind the acknowledgement reply, and it runs on every registration deliberately — if the list's slot was evicted, the rewarm is exactly the re-prefill the next turn would otherwise pay in the foreground. Swapping between several lists? Give the scoring model a slot per list and make the prefix routing selective: `options: [parallel:3, sps:0.5]` (the default slot-similarity threshold of 0.1 funnels different lists onto one slot — they share enough prompt structure to clear it).
249+
Registering an option list (pipeline seed or `session.update`) prewarms the scoring prompt in the background: one throwaway score prefills the option-list prompt and plants a state checkpoint exactly at the per-turn probe boundary. Every scoring call also declares that boundary (the probe-invariant prompt prefix) to the backend, which checkpoints there on each prefill — on hybrid/recurrent models (which cannot rewind their state arbitrarily, only restore checkpoints) this is what keeps every turn at probe-size cost instead of a full option-list re-prefill. A client that swaps option lists at runtime (e.g. voice-switched command modes) pays nothing on the first turn after a swap: the rewarm hides behind the acknowledgement reply, and it runs on every registration deliberately — if the list's slot was evicted, the rewarm is exactly the re-prefill the next turn would otherwise pay in the foreground. Swapping between several lists? Give the scoring model a slot per list and make the prefix routing selective: `options: [parallel:3, sps:0.5]` (the default slot-similarity threshold of 0.1 funnels different lists onto one slot — they share enough prompt structure to clear it).
250250

251251
The concrete scoring model must declare `score` in `known_usecases`. A single llama.cpp model can serve ordinary inference and classification concurrently by declaring multiple use cases, for example `known_usecases: [chat, completion, score]`; LocalAI reserves the scoring slots only when `score` is present. Scoring also requires the unified KV cache, which is enabled by default, so a score-enabled model cannot set `kv_unified:false`.
252252

0 commit comments

Comments
 (0)