@@ -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