Skip to content

Commit a590901

Browse files
committed
perf(llama-cpp): score all candidates in one batched decode
One scoring call is now a single SERVER_TASK_TYPE_SCORE task: the slot decodes the shared prefix (prompt + longest common candidate token prefix) once, then forks one sequence per candidate off it (metadata-only for the unified KV cache, copy-on-write for recurrent state) and decodes every candidate's unique tail in one llama_decode. Previously each candidate was its own task that restored the boundary checkpoint and re-decoded its full tail sequentially, paying per-candidate task and decode overhead. The context reserves SERVER_SCORE_FORK_SEQS extra sequence ids (and recurrent-state cells) beyond the parallel slots via the new common_params::n_seq_score_forks. Forking requires the unified KV cache (already this backend's default) since per-sequence streams would shrink n_ctx_seq; an explicit kv_unified:false disables forking and Score calls that need it fail cleanly. Candidates beyond the fork/output budget decode in successive chunks. Wire contract and scores are unchanged: per-token logprobs are stitched from the shared region and the forked tails. Verified bitwise deterministic call-to-call and independent of candidate order (no cross-fork leakage via equal-length candidate swap); ranking matches the per-candidate implementation on the drone battery (winner softmax 0.99996 vs 0.99997), and >16-candidate chunking, prefix-of-another and empty candidates all pass. Measured on a desktop CPU: warm /api/score calls 0.52s -> 0.23s; warm realtime classifier turns 196-303ms. The 9-candidate drone turn decodes ~17 unique tail tokens in one batch instead of nine sequential ~220ms checkpoint-restore tasks. Assisted-by: Claude:claude-fable-5 Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent d8e7e27 commit a590901

3 files changed

Lines changed: 432 additions & 126 deletions

File tree

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

Lines changed: 139 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,6 +1330,14 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
13301330
}
13311331
}
13321332

1333+
// Score-task suffix forking: reserve seq ids (and recurrent-state cells)
1334+
// beyond the slots so one scoring call decodes all candidate tails in a
1335+
// single batch (SERVER_TASK_TYPE_SCORE, patches/). Requires the unified
1336+
// KV cache — with per-sequence streams the extra ids would shrink every
1337+
// sequence's context to n_ctx / n_seq_max. Decided after both option
1338+
// passes so an explicit kv_unified:false wins and disables forking.
1339+
params.n_seq_score_forks = params.kv_unified ? SERVER_SCORE_FORK_SEQS : 0;
1340+
13331341
// Terminate/pad the override vectors only after BOTH the named-option loop
13341342
// and the generic passthrough (common_params_parse above) have pushed their
13351343
// real entries, so back() is the null sentinel the model loader asserts on.
@@ -2865,13 +2873,16 @@ class BackendServiceImpl final : public backend::Backend::Service {
28652873
// Score returns the model's joint log-probability of each candidate
28662874
// continuation given a shared prompt.
28672875
//
2868-
// Scoring runs as SERVER_TASK_TYPE_SCORE tasks through the slot loop
2869-
// (added by patches/ on top of upstream server-context), so it is safe
2870-
// to interleave with generation on the same process and it reuses any
2871-
// KV prefix the slot already holds: the shared prompt across
2872-
// candidates, and for chat workloads the conversation prefix across
2873-
// turns. Only the last shared-prompt token plus the candidate tokens
2874-
// are decoded per candidate once the prefix is cached.
2876+
// Scoring runs as a single SERVER_TASK_TYPE_SCORE task through the
2877+
// slot loop (added by patches/ on top of upstream server-context), so
2878+
// it is safe to interleave with generation on the same process and it
2879+
// reuses any KV prefix the slot already holds across turns. The task
2880+
// decodes the shared prefix (prompt + longest common candidate token
2881+
// prefix) once on the slot's sequence; every candidate's unique tail
2882+
// then rides its own forked sequence and all tails are decoded
2883+
// together in one batch, so a warm scoring call costs roughly one
2884+
// forward pass over the new prompt tokens plus one batched pass over
2885+
// the candidate tails.
28752886
grpc::Status Score(ServerContext* context, const backend::ScoreRequest* request, backend::ScoreResponse* response) override {
28762887
auto auth = checkAuth(context);
28772888
if (!auth.ok()) return auth;
@@ -2893,73 +2904,109 @@ class BackendServiceImpl final : public backend::Backend::Service {
28932904

28942905
// Per candidate: full prompt+candidate token list and the
28952906
// divergence point, kept for piece rendering and empty-candidate
2896-
// handling after the tasks come back.
2907+
// handling after the task comes back.
28972908
std::vector<std::vector<llama_token>> cand_tokens(request->candidates_size());
28982909
std::vector<int32_t> cand_divergence(request->candidates_size(), 0);
28992910

2900-
auto rd = ctx_server.get_response_reader();
2901-
bool posted_tasks = false;
2902-
{
2903-
std::vector<server_task> tasks;
2904-
for (int ci = 0; ci < request->candidates_size(); ci++) {
2905-
const std::string & candidate_text = request->candidates(ci);
2906-
2907-
// Re-tokenize prompt + candidate as a single string. BPE
2908-
// merges across the boundary can shift the tokenization
2909-
// versus tokenize(prompt) ++ tokenize(candidate), so we
2910-
// find the divergence point against prompt_tokens.
2911-
std::vector<llama_token> full_tokens = common_tokenize(vocab, prompt + candidate_text, /*add_special=*/true, /*parse_special=*/true);
2912-
int32_t divergence = prompt_len;
2913-
const int32_t min_len = std::min<int32_t>(prompt_len, (int32_t) full_tokens.size());
2914-
for (int32_t i = 0; i < min_len; i++) {
2915-
if (prompt_tokens[i] != full_tokens[i]) {
2916-
divergence = i;
2917-
break;
2918-
}
2919-
}
2920-
divergence = std::min<int32_t>(divergence, (int32_t) full_tokens.size());
2911+
// candidates that actually have tokens to score
2912+
std::vector<int32_t> included;
29212913

2922-
const int32_t cand_len = (int32_t) full_tokens.size() - divergence;
2923-
if (cand_len > 0 && divergence < 1) {
2924-
// Need at least one prior token (typically BOS) to
2925-
// predict the first candidate token's logit. Tokeniser
2926-
// models without BOS + an empty prompt fall in here.
2927-
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
2928-
"Score: prompt produced no leading tokens; need at least one (e.g. BOS) to predict candidate");
2929-
}
2930-
if (cand_len > SERVER_SCORE_MAX_CAND_TOKENS) {
2931-
// The context reserves logits outputs for at most this many
2932-
// candidate tokens per slot (server_n_outputs_max).
2933-
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
2934-
"Score: candidate " + std::to_string(ci) + " is " + std::to_string(cand_len) +
2935-
" tokens; the maximum is " + std::to_string(SERVER_SCORE_MAX_CAND_TOKENS));
2914+
for (int ci = 0; ci < request->candidates_size(); ci++) {
2915+
const std::string & candidate_text = request->candidates(ci);
2916+
2917+
// Re-tokenize prompt + candidate as a single string. BPE
2918+
// merges across the boundary can shift the tokenization
2919+
// versus tokenize(prompt) ++ tokenize(candidate), so we
2920+
// find the divergence point against prompt_tokens.
2921+
std::vector<llama_token> full_tokens = common_tokenize(vocab, prompt + candidate_text, /*add_special=*/true, /*parse_special=*/true);
2922+
int32_t divergence = prompt_len;
2923+
const int32_t min_len = std::min<int32_t>(prompt_len, (int32_t) full_tokens.size());
2924+
for (int32_t i = 0; i < min_len; i++) {
2925+
if (prompt_tokens[i] != full_tokens[i]) {
2926+
divergence = i;
2927+
break;
29362928
}
2929+
}
2930+
divergence = std::min<int32_t>(divergence, (int32_t) full_tokens.size());
2931+
2932+
const int32_t cand_len = (int32_t) full_tokens.size() - divergence;
2933+
if (cand_len > 0 && divergence < 1) {
2934+
// Need at least one prior token (typically BOS) to
2935+
// predict the first candidate token's logit. Tokeniser
2936+
// models without BOS + an empty prompt fall in here.
2937+
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
2938+
"Score: prompt produced no leading tokens; need at least one (e.g. BOS) to predict candidate");
2939+
}
2940+
if (cand_len > SERVER_SCORE_MAX_CAND_TOKENS) {
2941+
// The context reserves logits outputs for at most this many
2942+
// candidate tokens per slot (server_n_outputs_max).
2943+
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
2944+
"Score: candidate " + std::to_string(ci) + " is " + std::to_string(cand_len) +
2945+
" tokens; the maximum is " + std::to_string(SERVER_SCORE_MAX_CAND_TOKENS));
2946+
}
29372947

2938-
cand_divergence[ci] = divergence;
2939-
cand_tokens[ci] = std::move(full_tokens);
2948+
cand_divergence[ci] = divergence;
2949+
cand_tokens[ci] = std::move(full_tokens);
29402950

2941-
if (cand_len <= 0) {
2942-
continue; // no tokens to score; reported as 0.0 below
2951+
if (cand_len > 0) {
2952+
included.push_back(ci);
2953+
}
2954+
}
2955+
2956+
auto rd = ctx_server.get_response_reader();
2957+
bool posted_task = false;
2958+
2959+
// Shared prefix bounds, needed again when stitching the results:
2960+
// n_shared is the longest common token prefix of the scored
2961+
// candidates, n_score_prompt the earliest divergence from the
2962+
// bare prompt (scored logprobs start there).
2963+
int32_t n_shared = 0;
2964+
int32_t n_score_prompt = 0;
2965+
2966+
if (!included.empty()) {
2967+
const auto & first = cand_tokens[included[0]];
2968+
2969+
// the common prefix of a set is the shortest common prefix
2970+
// against any fixed member
2971+
n_shared = (int32_t) first.size();
2972+
for (int32_t ci : included) {
2973+
const auto & ft = cand_tokens[ci];
2974+
const int32_t lim = std::min<int32_t>(n_shared, (int32_t) ft.size());
2975+
int32_t match = 0;
2976+
while (match < lim && ft[match] == first[match]) {
2977+
match++;
29432978
}
2979+
n_shared = match;
2980+
}
29442981

2945-
server_task task(SERVER_TASK_TYPE_SCORE);
2946-
task.id = rd.queue_tasks.get_new_id();
2947-
task.index = (size_t) ci;
2948-
task.tokens = server_tokens(cand_tokens[ci], false);
2949-
task.n_score_prompt = divergence;
2950-
tasks.push_back(std::move(task));
2982+
// below its divergence every candidate equals the prompt
2983+
// tokens, so n_score_prompt <= n_shared always holds
2984+
n_score_prompt = cand_divergence[included[0]];
2985+
for (int32_t ci : included) {
2986+
n_score_prompt = std::min(n_score_prompt, cand_divergence[ci]);
29512987
}
29522988

2953-
if (!tasks.empty()) {
2954-
rd.post_tasks(std::move(tasks));
2955-
posted_tasks = true;
2989+
server_task task(SERVER_TASK_TYPE_SCORE);
2990+
task.id = rd.queue_tasks.get_new_id();
2991+
task.index = 0;
2992+
task.tokens = server_tokens(llama_tokens(first.begin(), first.begin() + n_shared), false);
2993+
task.n_score_prompt = n_score_prompt;
2994+
task.score_suffixes.reserve(included.size());
2995+
for (int32_t ci : included) {
2996+
task.score_suffixes.emplace_back(cand_tokens[ci].begin() + n_shared, cand_tokens[ci].end());
29562997
}
2998+
2999+
std::vector<server_task> tasks;
3000+
tasks.push_back(std::move(task));
3001+
rd.post_tasks(std::move(tasks));
3002+
posted_task = true;
29573003
}
29583004

2959-
// Wait for the per-candidate logprob vectors. Context overflow and
2960-
// decode failures surface here as task errors.
2961-
std::vector<std::vector<float>> logprobs(request->candidates_size());
2962-
if (posted_tasks) {
3005+
// Wait for the shared-prefix and per-candidate logprob vectors.
3006+
// Context overflow and decode failures surface here as task errors.
3007+
std::vector<float> shared_logprobs;
3008+
std::vector<std::vector<float>> cand_logprobs;
3009+
if (posted_task) {
29633010
auto all_results = rd.wait_for_all([&context]() { return context->IsCancelled(); });
29643011
if (all_results.is_terminated) {
29653012
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
@@ -2968,18 +3015,21 @@ class BackendServiceImpl final : public backend::Backend::Service {
29683015
return grpc::Status(grpc::StatusCode::INTERNAL,
29693016
all_results.error->to_json().value("message", "Error in receiving score results"));
29703017
}
2971-
for (auto & res : all_results.results) {
2972-
auto * score_res = dynamic_cast<server_task_result_score*>(res.get());
2973-
if (score_res == nullptr) {
2974-
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for score task");
2975-
}
2976-
if (score_res->index >= logprobs.size()) {
2977-
return grpc::Status(grpc::StatusCode::INTERNAL, "score result index out of range");
2978-
}
2979-
logprobs[score_res->index] = std::move(score_res->logprobs);
3018+
if (all_results.results.size() != 1) {
3019+
return grpc::Status(grpc::StatusCode::INTERNAL, "expected a single score result");
3020+
}
3021+
auto * score_res = dynamic_cast<server_task_result_score*>(all_results.results[0].get());
3022+
if (score_res == nullptr) {
3023+
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for score task");
3024+
}
3025+
shared_logprobs = std::move(score_res->shared_logprobs);
3026+
cand_logprobs = std::move(score_res->cand_logprobs);
3027+
if (cand_logprobs.size() != included.size()) {
3028+
return grpc::Status(grpc::StatusCode::INTERNAL, "score result candidate count mismatch");
29803029
}
29813030
}
29823031

3032+
size_t inc = 0; // index into included / cand_logprobs
29833033
for (int ci = 0; ci < request->candidates_size(); ci++) {
29843034
const int32_t divergence = cand_divergence[ci];
29853035
const int32_t cand_len = (int32_t) cand_tokens[ci].size() - divergence;
@@ -2994,7 +3044,26 @@ class BackendServiceImpl final : public backend::Backend::Service {
29943044
continue;
29953045
}
29963046

2997-
const auto & lp = logprobs[ci];
3047+
// Stitch the candidate's scored logprobs back together: the
3048+
// stretch inside the shared prefix (identical for every
3049+
// candidate) followed by its forked suffix. Suffix entries
3050+
// before the candidate's own divergence are prompt tokens
3051+
// decoded only as context — not scored.
3052+
std::vector<float> lp;
3053+
lp.reserve(cand_len);
3054+
for (int32_t t = divergence; t < n_shared; t++) {
3055+
const int32_t idx = t - n_score_prompt;
3056+
if (idx < 0 || idx >= (int32_t) shared_logprobs.size()) {
3057+
return grpc::Status(grpc::StatusCode::INTERNAL,
3058+
"Score: shared logprob index out of range for candidate " + std::to_string(ci));
3059+
}
3060+
lp.push_back(shared_logprobs[idx]);
3061+
}
3062+
const auto & sfx_lp = cand_logprobs[inc++];
3063+
for (int32_t j = std::max(0, divergence - n_shared); j < (int32_t) sfx_lp.size(); j++) {
3064+
lp.push_back(sfx_lp[j]);
3065+
}
3066+
29983067
if ((int32_t) lp.size() != cand_len) {
29993068
return grpc::Status(grpc::StatusCode::INTERNAL,
30003069
"Score: result for candidate " + std::to_string(ci) + " is missing token logprobs");

0 commit comments

Comments
 (0)