server: --pipeline-groups, run the slots over several contexts of one model - #187
server: --pipeline-groups, run the slots over several contexts of one model#187danielhanchen wants to merge 18 commits into
Conversation
A layer split over two nodes is a two-stage pipeline that a single llama_context feeds one batch at a time, so each stage sits idle while the other one computes. With --pipeline-groups N the server creates N llama_contexts from the one model, partitions its slots between them and gives each group its own batch and its own decode thread, so there are N batches in flight and both stages have work. Each context is created with n_seq_max = n_parallel / N and n_ctx = n_ctx / N, so the per-slot context and the total KV memory are unchanged. Slots are partitioned contiguously and carry the sequence id they use inside their own context. Slot selection for a new task still runs over all slots, so prompt cache similarity, the slot endpoints and the KV prefix reuse behave exactly as before. The model weights, the task queue, the results queue and the HTTP layer are shared. Task processing pauses the decode loops for the moment it looks at the slots. Speculative decoding, multimodal and idle sleeping are refused with N > 1 rather than half supported. With the default N = 1 there is one context, one batch and one update loop on the main thread, no locks and no extra threads.
- the unlock around llama_decode is now RAII, so a throwing decode cannot leave a group marked busy (which would wedge every later task) nor return to the error handler without the engine lock - n_cmpl is rejected when it exceeds the slots of one group, instead of being deferred forever: the child slots take their KV from the parent, so they have to live in the parent's context - refuse --control-vector with more than one group, common_init_from_params only applies it to the context it creates - the queued prompt stats and the empty batch kill switch move into the group, they were shared counters flushed per group - post_decode uses the group's context, and the detokenize calls in the result path use the slot's own context - free the contexts already created if a later one fails, and do not index groups[0] when no model is loaded
…onnection One socket is cached per endpoint and is therefore shared by every backend of that endpoint, including the backends of different llama_contexts. A message is written as three unlocked send_data calls, so two contexts interleave their command streams and the server sees a malformed request within seconds. Make a whole message atomic on the wire, and hand the responses out in request order with a ticket, so a thread waiting for its response does not hold the send lock and the other contexts can keep submitting. last_graph_uid was kept per endpoint device while the graph it refers to is stored by the server per connection, and it was read and written without a lock, so two contexts on one connection could make RPC_CMD_GRAPH_RECOMPUTE re-run the other one's graph. Track it per connection and device and check it under the send lock. server: pause only the group that owns the slot a task touches process_single_task stopped every pipeline group for every task and waited for all the in-flight decodes. Holding the engine is already enough to keep the slot state stable, so only wait for the group whose context the task touches: the owning group for completions, cancel, control and slot save / restore / erase, every group for --cache-idle-slots and SET_LORA, none for metrics, /slots and get-lora.
…s own engine lock At 32 concurrent clients on a two-node layer split, post_decode cost 4.3 ms per slot with --pipeline-groups 2 against 0.85 ms per slot with one context, and it sits on each group's critical path between llama_synchronize and the next submit. Per-group timings show it is common_sampler_sample: 0.60 ms per row with one context, 3.6 to 4.6 ms per row with two, because the candidate array of a 248320-token vocabulary is about 4 MB of memory traffic per row and with a second group the pass runs against the other group's GPU work instead of in the gap when both GPUs are idle. - each group now samples its rows over a small worker pool. The rows are independent, so the tokens are the ones the serial pass would have produced; greedy output is byte-identical at N = 1, 2 and 4. The thread budget is divided by the number of groups, so a pipeline-groups run is not simply given more CPU. LLAMA_SERVER_SAMPLE_THREADS=1 turns it off. - the engine lock is now per group, so the host path of one group no longer excludes the other's. Measured on the pair this is worth nothing on its own (74.7 against 73.5 to 75.3 tok/s), which is reported as a falsification, but it is what the feature is supposed to guarantee and it is needed before the sampling pool can overlap anything. - get_available_slot() called prompt_save / prompt_load, which read and write the slot's sequence KV, before the guard waited for the owning group's decode. With --cache-ram 0 the cache is null so it never fired; with the cache on it is a live race against a running decode. The cache update now happens after the wait. - server_metrics is no longer written from several group threads at once, and each group only counts its own slots instead of every slot of the server. - LLAMA_SERVER_PIPE_PROF=1 prints the per-group host path every five seconds. That is how the cost above was found. README: document that a layer split must list the RPC device first and the local device last, so the output layer and its logits stay local.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8de4320163
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| // strip the server-only --pipeline-groups before the common parser sees it |
There was a problem hiding this comment.
Preserve pipeline groups when spawning router children
When the server starts in router mode, stripping this option before server_models receives argc/argv removes it from the base_preset built in server-models.cpp:407-415; that preset is what gets merged into every child configuration at server-models.cpp:548-552. Consequently, llama-server --pipeline-groups 2 accepts the flag on the router, but each spawned model process starts without it and uses the default of 1, so the new feature silently has no effect in this supported mode. Preserve the setting in the child arguments or otherwise propagate it across the process boundary.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 55b01c2: the flag is stripped from argv before server_models builds base_preset, so it never reached a child. It is now exported as LLAMA_ARG_PIPELINE_GROUPS, which the child reads when the flag is absent, via common_set_env so Windows is covered.
A common_speculative and its draft (or MTP) context are bound to one target context, so with --pipeline-groups > 1 the server refused every drafter. The speculative state now lives in struct server_group: every group creates its own draft / MTP context against its own target context, sizes its own common_speculative for the group's sequences and addresses it by the slot's sequence id inside the group. The draft batches go through the group's draft context on the group's decode thread; the task-queue yields around the drafter are only taken on the single-group main-thread path, like llama_decode already does. Slot save / restore, checkpoints and the prompt cache carry the draft state per slot as before. With one group seq_id == id and the path is the same as before, spelled through groups[0]. With --model-draft the sidecar model is loaded once per group. validate_pipeline_groups no longer refuses --model-draft / --spec-type.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a1dd7c5e8d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (ret == 0 && has_output) { | ||
| prof_timer ts(&grp.prof.t_sync, prof_on); | ||
| llama_synchronize(ctx_tgt); |
There was a problem hiding this comment.
Keep no-output decodes busy until synchronization
When a prompt is split into batches, intermediate batches commonly have has_output == false, so this skips llama_synchronize() even though llama_decode() is asynchronous. The decode_window destructor then marks the group non-busy, allowing the task thread to acquire its guard and modify that context—for example by launching another slot, updating the prompt cache, or handling a slot operation—while the batch is still executing. Keep the group busy until the asynchronous work is synchronized before exposing the context to task processing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 55b01c2: the multi-group path now synchronizes whenever the decode returns 0, not only when has_output. ~decode_window was clearing busy while the decode was still in flight, and every intermediate chunk of a split prompt has has_output false, so this was the common case rather than a corner.
| if (params_base.cache_idle_slots) { | ||
| // this walks every slot of every group | ||
| guard.wait_for_all(); |
There was a problem hiding this comment.
Reacquire released group locks before waiting for all
With --pipeline-groups > 1 --cache-idle-slots, the earlier guard.wait_for(slot->id_group) releases every other group's lock, but this subsequent wait_for_all() assumes all entries in lks are still owned and never reacquires them. If another group is busy, it passes an unlocked unique_lock to condition_variable::wait; even when the predicate skips that call, the following cache loop reads and clears the other groups' slots and contexts without excluding their worker threads. This can race or fail as soon as a new task starts while another group is decoding.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 55b01c2: wait_for_all now releases what is left and retakes every lock in group order before waiting, so it never waits on an unowned unique_lock and the cache_idle_slots loop that walks every group's slots actually holds their locks. The constructor's ordering is kept so two guards still cannot deadlock.
| per_tk(pr.t_sampl), per_tk(pr.t_sampl_par), per_tk(pr.t_piece), per_tk(pr.t_proc), | ||
| per_tk(pr.t_send), per_tk(pr.t_post)); | ||
|
|
||
| pr = server_group_prof(); |
There was a problem hiding this comment.
Synchronize profiler snapshots across groups
When LLAMA_SERVER_PIPE_PROF=1 and multiple groups are active, the reporting group reads and resets every group's plain server_group_prof fields here while the other workers concurrently update those fields, including during their unlocked decode windows. The atomic report timestamp only selects a reporter; it does not protect these counters, so profiling introduces C++ data races and can emit corrupted measurements. Snapshot or reset each group's counters under appropriate synchronization.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 55b01c2: each group now reports and resets only its own counters, under its own lock, with a per-group window. The shared atomic only elected a reporter, which then read and cleared the other groups' plain counters while their workers were still writing them. Per group costs nothing on the hot path, unlike making the counters atomic.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d98d90dd8f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| grp.pool.run((int) to_sample.size(), [&](int i) { | ||
| server_slot * slot = to_sample[i]; | ||
| try { | ||
| slot->pre_sampled = common_sampler_sample(slot->smpl.get(), slot->ctx_tgt, slot->i_batch - off); |
There was a problem hiding this comment.
Avoid concurrent access to the shared llama context
With the default sampling pool enabled and at least two ordinary sampling rows in one group, these workers call common_sampler_sample() concurrently on the same llama_context. The preparatory llama_get_logits_ith() does not make that safe: each sampler call invokes context getters whose llama_context::output_reorder() unconditionally executes output_swaps.clear() (src/llama-context.cpp:2222-2287), so the workers concurrently mutate the same vector and introduce a C++ data race on every such decode. Serialize the context getter phase or provide each worker immutable row data rather than passing the shared context into parallel calls.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 55b01c2 by removing the parallel sampling pool. The problem is wider than output_swaps: common_sampler_sample opens with llama_synchronize, which does non-atomic read-modify-writes on t_eval_us, n_eval and n_queued_tokens, and set_logits then re-enters the context through six more getters. Making that safe means duplicating a large slice of the context API for an optimisation with no recorded measurement, so the sequential path stands on its own. Re-measuring the row sweep against the 1.77x record is the remaining follow-up.
- propagate --pipeline-groups to router children through LLAMA_ARG_PIPELINE_GROUPS, the flag is stripped from argv before server_models builds the child preset - synchronize a multi-group decode even with no output, so ~decode_window does not clear busy while the decode is still in flight - wait_for_all() retakes the locks wait_for() released, in group order; waiting on an unowned unique_lock is undefined and the cache loop touches every group's slots - report and reset the profiler counters per group under that group's own lock - drop the parallel sampling pool: every worker re-entered the shared llama_context through llama_synchronize and six getters, which cannot be made safe without duplicating that API
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 55b01c2926
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| cparams.n_ctx = llama_n_ctx(ctx_tgt); | ||
| cparams.n_seq_max = llama_n_seq_max(ctx_tgt); | ||
|
|
||
| groups[g]->ctx = llama_init_from_model(model_tgt, cparams); |
There was a problem hiding this comment.
Attach persistent thread pools to every pipeline context
When --pipeline-groups > 1 is used with CPU or partially offloaded computation, contexts after group 0 are created directly with llama_init_from_model() and never receive the common_threadpools::init() performed for the first context by common_init_from_params(). Their CPU backend therefore creates and destroys a disposable thread pool for every graph execution (ggml_graph_compute()'s null-threadpool fallback), adding substantial per-token overhead and ignoring configured affinity, priority, polling, and strict-CPU settings. Give each additional context a persistent, correctly configured thread pool with a lifetime at least as long as the context.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in be0bbf9. Correct: only group 0's context gets the pool from common_init_from_params(), so later groups fell back to a throwaway pool per graph and silently ignored --cpu-mask, --prio, --poll and --cpu-strict. Silently discarding an explicit user setting is worth fixing even though the cost is small at -ngl 99, since CPU and partially offloaded runs are where those flags actually get set.
The pool is owned by server_group and declared after ctx, so it is destroyed by groups.clear() after the llama_free() above it, on the normal and the error path alike. Verified one pool at 1 group, which is unchanged, and one per group at 2.
| - Each group samples its own rows over a small worker pool, because a serial pass over the slots | ||
| costs tens of milliseconds per step between the decode and the next submit and, at `N > 1`, | ||
| competes for memory bandwidth with the other group's GPU work. The total number of sampling | ||
| threads is the same however many groups there are; `LLAMA_SERVER_SAMPLE_THREADS=1` turns the | ||
| pool off. The sampled tokens do not depend on how the pass is scheduled. |
There was a problem hiding this comment.
Remove the nonexistent sampling-pool controls
The final implementation samples rows serially in post_decode() and contains no LLAMA_SERVER_SAMPLE_THREADS lookup or sampling worker pool, so users following this documentation cannot enable, disable, or size the advertised pool and the stated performance explanation no longer describes the shipped code. Either restore a safe implementation or update this section and its benchmark expectations to match the serial sampling path.
Useful? React with 👍 / 👎.
| if (n_groups > 1) { | ||
| // 1/N of the sequences and of the context each, so per-slot context and total KV hold | ||
| params_ctx.n_parallel = n_seq_per_group; | ||
| params_ctx.n_ctx = params_base.n_ctx / n_groups; |
There was a problem hiding this comment.
Fit memory for all pipeline contexts
When the default parameter fitter is active near a device's memory limit, dividing n_ctx before common_init_from_params() makes common_fit_params() budget KV and compute memory for only one C/N context. Model placement is then fixed, after which the code allocates N such contexts (and potentially N speculative contexts), so the fitter can select a placement that fits its estimate but fails while creating a later group. Account for every group's context allocations during fitting rather than presenting only one group's cost.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0b039f8, and this was the most serious item in the round: on a 121.69 GiB node the failure mode is an out-of-memory rather than a slowdown.
I tried your framing first, fitting the undivided n_ctx, and measured that it is not sufficient. With common_get_device_memory_data on a 1.5B, the KV tracks n_ctx (112, 224 and 448 MiB at 4k, 8k and 16k) but the compute buffer does not (536 MiB flat across that whole range). Fitting the full n_ctx therefore budgets the KV correctly and still leaves n_groups - 1 compute buffers unaccounted for.
So the extra contexts go into the fitter's per-device margin instead, which covers both terms. Weights are not counted for the target model or for an MTP draft, which share them; only a separate --model-draft adds weights per group. Gated on n_groups > 1 and params.fit_params: verified that --pipeline-groups 1 reserves nothing, that -fit off reserves nothing, and that the reservation scales as expected (175 MiB for one extra context at 2 groups, 356 MiB for three at 4 groups, lower per context because n_ctx/N is smaller). The amount is logged per device so it can be audited.
| std::vector<server_slot *> child_slots = get_free_slots(n_child_tasks, slot->id, slot->id_group); | ||
| if (child_slots.size() < n_child_tasks) { | ||
| SRV_DBG("not enough free slots for child tasks, n_free = %zu, n_children = %zu, defer task, id_task = %d\n", child_slots.size(), n_child_tasks, id_task); | ||
| queue_tasks.defer(std::move(task)); |
There was a problem hiding this comment.
Select a group with enough slots for child completions
For an automatically scheduled n > 1 completion, the parent slot is selected globally before checking whether its pipeline group has enough free child slots. If prompt similarity repeatedly selects a cached slot in a partly occupied group, this branch defers the request even when another group has enough idle slots, and each retry selects the same cached slot again until that group's unrelated generation finishes. Filter candidate parent slots by same-group capacity, or retry selection in another eligible group before deferring.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one. It is a scheduling inefficiency rather than a correctness defect: the request is deferred and retried, and it makes progress as soon as the unrelated generation in that group finishes, so nothing is dropped or corrupted. Worth revisiting if it ever shows up as measurable head-of-line blocking.
| // parsed here, not in common/arg.cpp: everything it changes lives under tools/server | ||
| static int g_pipeline_groups = 1; | ||
|
|
||
| static void server_take_pipeline_groups(int & argc, char ** argv) { |
There was a problem hiding this comment.
Register the new option in generated CLI help
Because --pipeline-groups is consumed before common_params_parse() and has no registered common_params_option, llama-server --help cannot list this newly supported public flag or its environment variable. Users relying on the executable's canonical option reference therefore cannot discover its syntax or constraints; register a server-scoped parser/help entry while preserving any router propagation needed after parsing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one. --help is generated from common_params_option, and registering the flag there is what the design deliberately avoids: it must be consumed before common_params_parse, which rejects unknown options, and it changes nothing outside tools/server. The flag and its environment variable are documented in tools/server/README.md, so this is a discoverability gap rather than incorrect behaviour.
There was a problem hiding this comment.
Done in 8d97fcc. To be clear about why, since I turned this down in an earlier round: the earlier rejection is superseded by a new argument rather than reversed on the old one.
What I rejected then was the discoverability case, that a user typing --help should see the option. That is still not on its own a reason to move parsing, and I would turn it down again on those terms. What has changed is that a correctness problem surfaced in the pre-scan itself while working the -- bug in the previous commit. Bounding the scan at a bare -- protects operands after the separator, but before the separator a pre-scan has no option table and so cannot tell an option from the value of a preceding option. A literal --pipeline-groups passed as some other option's value was consumed and stripped from argv. That is not a discoverability complaint; it is the pre-scan reading arguments that are not its own, and no amount of care inside a hand-written scan fixes it, because the information needed to fix it is exactly the option table the scan runs before.
So the option is now registered normally. n_pipeline_groups joins common_params, the option is declared for the server example with set_env("LLAMA_ARG_PIPELINE_GROUPS"), and the >= 1 check moved into the parser, where a bad value reports through the usual error path instead of exit(1):
--pipeline-groups N run the server slots over N independent contexts of one model, so one
group decodes while another is between steps (default: 1)
(env: LLAMA_ARG_PIPELINE_GROUPS)
$ llama-server --pipeline-groups 0
error while handling argument "--pipeline-groups": error: --pipeline-groups must be >= 1
The environment path matters beyond convenience: it is how router mode reaches its children, which are spawned rather than given a command line, and registering the option gets that documented in --help rather than only in the README. The README paragraph describing the pre-scan limitation is gone, since the limitation is gone with it.
| // from here the slot's context is touched, so its group must finish its decode | ||
| guard.wait_for(slot->id_group); |
There was a problem hiding this comment.
Restrict failed parent-task cleanup to the locked group
When a multi-completion launch fails after this wait—for example because a child has an invalid sampler or aLoRA configuration—launch_slots_with_parent_task() invokes release_slots(), which scans every server slot and reads state and task. wait_for() has released every other group's lock, so their workers can mutate those fields concurrently, creating a C++ data race while handling an otherwise recoverable invalid request. Since all parent and child slots are now guaranteed to belong to one group, limit cleanup to that group's slots or retain the necessary locks.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in d1d1a12. Correct, and it is the same family as the wait_for_all item from the last round: that fix covered wait_for_all but not this scan. Since the parent and its children are guaranteed to share a group, release_slots now iterates only that group's slots.
…orrect the sampling docs release_slots() scanned every server slot while wait_for() had released the other groups' locks, so it read state and task while their workers were writing them. The parent and its children are guaranteed to share a group, so the scan is now limited to that group. The README still advertised the sampling worker pool and LLAMA_SERVER_SAMPLE_THREADS, neither of which exists any more.
…he process environment Writing it with common_set_env was not safe on Windows. server_models builds child_env from get_environment(), which reads the Win32 block via GetEnvironmentStringsW, while common_set_env writes the CRT copy via _putenv_s. CRT to Win32 propagation is not documented, so the value could have been invisible to exactly the code that needed it, on Windows only. It is now pushed onto child_env next to LLAMA_SERVER_ROUTER_PORT, which is the pattern already used there and is platform independent. The child still reads LLAMA_ARG_PIPELINE_GROUPS through getenv, which is correct on every platform because the CRT copy is populated from the inherited environment at startup.
Review round: what was broken, what changed, and what it cannot breakFive items from the first round and two from the second are fixed. Each one below is stated as the 1. Router children silently ran with one groupBefore: This went through a wrong version first. It originally wrote the variable with Simulated on Linux, one axis per run, each verified by whether extra contexts were created:
The empty-string row matters for portability: 2. A decode was left in flight while the group advertised itself idleBefore: 3.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 70f76359ef
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (const int n_pg = server_get_pipeline_groups(); n_pg > 1) { | ||
| child_env.push_back("LLAMA_ARG_PIPELINE_GROUPS=" + std::to_string(n_pg)); | ||
| } |
There was a problem hiding this comment.
Replace inherited pipeline-group values for router children
When the router inherits LLAMA_ARG_PIPELINE_GROUPS=2 but is explicitly started with --pipeline-groups 1, this condition adds nothing, so base_env still passes 2 to every child and the CLI override is lost. Conflicting values above 1 are also appended as duplicate environment entries; on POSIX, getenv() returns the first inherited entry. Fresh evidence in the propagation fix is that child_env copies base_env unchanged before conditionally appending the resolved value. Remove any inherited entry and always insert the router's resolved setting.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c790aa4. Confirmed both directions you describe. base_env is a copy of the router process environment (get_environment over environ), so appending could not override an inherited entry, since execve keeps duplicates and getenv returns the first, and an explicit --pipeline-groups 1 appended nothing at all. The inherited entry is now erased and the resolved value always set, including 1.
The concurrency fixes, under ThreadSanitizerArgument is not evidence for a race, and neither is one clean run. These were checked with TSan, Two builds,
The two that disappear are exactly the fixed code: One group's decode thread writing a slot while the task thread reads The remaining warnings are identical in both builds and are all in What this run does not prove. It is Reproducer: The two remaining items: both genuine, neither fixedFit memory for all pipeline contexts. Confirmed. I have not fixed this and have not reacted to it. There are two candidate fixes: raise Persistent thread pools for contexts after group 0. Also confirmed. Neither has been dismissed. I would rather leave both visibly open than mark them agreed without a |
common_init_from_params fits ONE context and fixes model placement from that estimate, but n_groups - 1 further contexts are created afterwards with llama_init_from_model(), once placement can no longer change. Near a device limit the fitter could therefore choose a placement that fits its own estimate and fail when a later group's context is created. Fitting the undivided n_ctx instead would not have been enough. Measured with common_get_device_memory_data on a 1.5B: the KV tracks n_ctx (112, 224 and 448 MiB at 4k, 8k and 16k) but the compute buffer does not (536 MiB across that whole range), so that approach fixes the KV and still leaves n_groups - 1 compute buffers unbudgeted. The extra contexts are added to the fitter's per-device margin instead, which covers both terms. Weights are not counted for the target model or an MTP draft, which share them; only a separate --model-draft adds weights per group. Gated on n_groups > 1 and params.fit_params, so a single group and a pinned placement are untouched.
Only group 0's context gets the pool built by common_init_from_params(). Groups made with llama_init_from_model() had none, so their CPU backend built a throwaway pool for every graph and silently ignored --cpu-mask, --prio, --poll and --cpu-strict. Small at -ngl 99, but CPU and partially offloaded runs are exactly where those flags are set. The pool is owned by server_group and declared after ctx, so it is destroyed by groups.clear() after the llama_free() above it, on both the normal and the error path.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: be0bbf9791
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (count_weights) { | ||
| per_group += mem[i].model; | ||
| } | ||
| params_ctx.fit_params_target[i] += n_extra * per_group; |
There was a problem hiding this comment.
Map draft reservations by device identity
When --device-draft orders the devices differently from the target model, this positional update charges each draft allocation to the wrong target-device margin. For example, target devices CUDA0,CUDA1 with draft devices CUDA1,CUDA0 swap the added model/context/compute costs, even though the normal fitter maps its extra model by matching ggml_backend_dev_t; near the memory limit, --fit --pipeline-groups 2 can therefore approve a placement and then fail while creating the second draft model/context. Map each entry in devs back to the target model's device identity before updating fit_params_target.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 64e05a2. You are right, and the code even said so: my comment claimed the memory data and the margins share a device order, which I asserted rather than checked. The draft reservation now matches on ggml_backend_dev_t the way common_params_fit_impl maps its extra model.
There was a second misindexing in the same loop that this found. common_get_device_memory_data returns one entry per device plus a host entry at the back, while margins are built over the devices alone (common/fit.cpp, margins is filled over nd), so running the loop to mem.size() charged host memory to a device margin. Only devices are charged now, except when there is no device at all, where the single margin is the host one.
| spec_init = common_speculative_init_from_params(params_dft, model_tgt, ctx_tgt); | ||
| model_dft = spec_init->model(); | ||
| ctx_dft = spec_init->context(); | ||
| grp.spec_init = common_speculative_init_from_params(params_dft, model_tgt, grp.ctx); |
There was a problem hiding this comment.
Attach thread pools to per-group draft contexts
With --pipeline-groups > 1 and draft/MTP speculation that performs CPU work, every newly created draft context here lacks a persistent common_threadpools: common_speculative_init_result owns only the model and context and never calls llama_attach_threadpool. The fresh evidence after fixing target contexts is that only groups[g]->ctx receives a pool at lines 1242-1243, while these additional draft contexts still use disposable fallback pools and ignore draft CPU affinity, priority, polling, and strict-CPU settings.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 7bd64bb. Each group now owns a common_threadpools for its draft context, declared before spec_init so it is destroyed after the context it is attached to.
One detail worth stating: it is initialised from params_dft, not params_ctx, so the draft cpu params are the ones that take effect. This also gives the draft context a pool when there is only one group, which upstream never attached.
common_speculative_init_result owns only the model and the context and never calls llama_attach_threadpool, so every per-group draft context fell back to a throwaway pool per graph and ignored the draft --cpu-mask, --prio, --poll and --cpu-strict settings. Each group now owns one, built from params_dft rather than params_ctx so the draft cpu params are the ones that take effect, and declared before spec_init so it is destroyed after the context it is attached to.
The reservation loop indexed the memory data and the fit margins as if they shared a device order. Two things break that. --device-draft can order the draft model's devices differently from the target model's, and common_params_fit_impl maps its own extra model by matching ggml_backend_dev_t for exactly that reason, so the draft reservation matches on device identity now. By position it billed a draft allocation to another device's margin, which can approve a placement that then fails while creating the second draft context. common_get_device_memory_data also returns one entry per device plus a host entry at the back, while the margins are built over the devices alone, so the old loop charged host memory to a device margin. Only the devices are charged now, except when there is no device at all, where the single margin is the host one.
base_env is a copy of the router's own environment, so an inherited LLAMA_ARG_PIPELINE_GROUPS reached every child untouched. Appending could not override it, because execve keeps duplicate entries and getenv returns the first, and an explicit --pipeline-groups 1 appended nothing at all, so the command line lost to the environment in both directions. The inherited entry is now removed and the resolved value always set.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c790aa4bb3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 1/N of the sequences and of the context each, so per-slot context and total KV hold | ||
| params_ctx.n_parallel = n_seq_per_group; | ||
| params_ctx.n_ctx = params_base.n_ctx / n_groups; |
There was a problem hiding this comment.
Recompute output limits after partitioning the slots
When --pipeline-groups > 1 is used for ordinary generation or speculation, params_ctx.n_parallel is reduced here but n_outputs_max still contains the limit calculated from the server-wide parallelism at the start of load_model(). llama_context::reserve() uses that unchanged ceiling when reserving the prompt-processing graph, so every context reserves compute buffers for up to all P output rows even though its group can produce only P/N rows (or the corresponding per-sequence speculative multiple). With large vocabularies or high --parallel, this needlessly multiplies compute memory and can force --fit to reduce offload or reject a configuration that would fit. Recompute the output limits from params_ctx after dividing n_parallel.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Accepted, and it fits the pattern of the other two memory items: the group split changes what each context needs but the reservation and the reserve path were both still working from the server-wide numbers.
Not fixed yet and no thumbs-up. Taking it together with the output buffer item, since both change what a single group actually reserves and both should be validated against a real near-limit fit rather than by reading the code. The near-limit test for this PR has not run yet, which is the honest reason none of these three has a commit against it.
| size_t per_group = md.context + md.compute; | ||
| if (count_weights) { | ||
| per_group += md.model; | ||
| } | ||
| params_ctx.fit_params_target[id] += n_extra * per_group; |
There was a problem hiding this comment.
Reserve each additional context's output buffer
With the default fitter active near the memory limit, this reservation includes only the context and scheduler compute values returned by common_get_device_memory_data(). That API derives its result from llama_context::memory_breakdown(), which does not include the separately allocated buf_output; nevertheless every context created at line 1237 allocates its own output buffer. The omitted cost is proportional to vocabulary size and output rows, and becomes substantially larger with backend sampling, so fitting can approve placement and then fail while allocating a later pipeline context. Include the extra groups' output-buffer cost in the reservation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed. llama_context::memory_breakdown() sums model, memory and scheduler compute only; buf_output is allocated separately in output_reserve() through ggml_backend_buft_alloc_buffer and never appears in the breakdown, so common_get_device_memory_data cannot see it and my reservation does not include it.
Accepted, not fixed yet, so no thumbs-up until there is a commit. Worth noting for whoever picks it up that the near-limit test that would actually demonstrate this has not run yet, so the fix should be validated against a real fit rather than by inspection.
| for (size_t id = 0; id < devs_tgt.size(); ++id) { | ||
| if (devs[j] == devs_tgt[id]) { | ||
| charge(id, mem[j]); | ||
| break; |
There was a problem hiding this comment.
Account for draft-only devices when reserving memory
When --device-draft contains a device that the target model does not use, this identity-matching loop finds no devs_tgt entry and silently drops that draft device's reservation. For example, a target on CUDA0 and a sidecar draft on CUDA1 receives no reservation for the N-1 additional draft models and contexts loaded for pipeline groups; if CUDA1 fits one draft but not all of them, --fit still approves the configuration and a later group fails during initialization. Handle draft-only devices explicitly rather than reserving only the intersection of the target and draft device sets.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Accepted. This is a direct consequence of the identity matching I added in 64e05a2, and I had already noted the intersection-only behaviour without treating it as a defect, which was the wrong call: silently dropping a draft-only device is exactly the failure mode --fit exists to prevent.
One structural point that shapes the fix. fit_params_target is indexed by the target model device list that the fitter builds, so a device the target does not use has no margin entry to charge, and common_params_fit_impl has the same limitation for its extra model. So a fix that claims to budget CUDA1 in your example would have to extend the fitter device list, not just this loop.
Not fixed yet, so no thumbs-up. My intent is to charge it where a margin exists and warn explicitly where none does, so the case stops being silent even where it cannot be budgeted, and to say so rather than imply full coverage.
| // 1/N of the sequences and of the context each, so per-slot context and total KV hold | ||
| params_ctx.n_parallel = n_seq_per_group; | ||
| params_ctx.n_ctx = params_base.n_ctx / n_groups; |
There was a problem hiding this comment.
Preserve per-sequence capacity with unified KV
When --kv-unified is enabled, dividing n_ctx by the group count reduces the maximum context available to any individual request from C to C/N: llama_context sets n_ctx_seq = n_ctx for unified KV, rather than dividing it by n_seq_max. This is especially likely with pipeline groups because automatic --parallel enables unified KV, and the README also recommends it for throughput; a long request that fits with one group can therefore be truncated or fail with context exhaustion after enabling two groups. Either retain C for every unified-KV context (and account for the additional memory) or explicitly expose and validate the reduced per-request limit instead of claiming that per-slot context is unchanged.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed in the source, and this is the most serious of the four. src/llama-context.cpp sets n_ctx_seq = n_ctx in the unified branch and only divides by n_seq_max in the non-unified one, so dividing n_ctx by the group count does cut the maximum single-request context from C to C/N under --kv-unified.
It also lands squarely on how this is actually being run: the pair measurements for this PR use --kv-unified, so the configuration I have been quoting numbers for is exactly the one where a long request silently loses half its context.
Accepted, not fixed yet, so no thumbs-up from me until there is a commit. Of your two options I intend to take the second, expose and validate the reduced per-request limit, rather than retaining C per context: retaining C would multiply the unified KV allocation by N, which is the opposite of what the fit reservation work in this PR is trying to control. The README claim that per-slot context is unchanged is wrong for unified KV and will be corrected with it.
There was a problem hiding this comment.
Following up on my previous reply, because working the arithmetic out properly changed my answer. I said I would take your second option, expose the reduced per-request limit. I now think that is the wrong choice, and I want to say so rather than quietly do something else.
The two KV modes are not merely different here, they are opposites, and that is what makes this a design question rather than a doc fix. This PR divides both n_ctx and n_parallel by the group count. Non-unified then gives n_ctx_seq = (C/N) / (P/N) = C/P, exactly what it was, so the README claim that per-slot context is unchanged is correct in that mode. Unified sets n_ctx_seq = n_ctx directly, so the same division gives C/N and the claim is false. The division is precisely right in one mode and precisely wrong in the other.
That matters because under unified KV, -c is the per-request ceiling: it is the number the user set and the only one they can observe. Halving it is not reducing a limit, it is redefining the flag. Documenting that would make the surprise findable but would still ship a flag whose meaning depends on an unrelated flag.
So: do not divide n_ctx when kv_unified is set. Give every group the full C, add the extra contexts KV to the fit reservation, and let the fitter arbitrate. My earlier objection to this was that it multiplies unified KV by N, which is true and is a real cost, roughly C worth of KV becoming N times C where the divided form stays at C. But that cost is exactly the kind of thing the reservation work in this PR exists to account for, and if it does not fit, the fitter reduces n_ctx and the user sees a smaller C they can act on. A visibly smaller C beats a silently halved one.
Two consequences I will carry with it: the reservation has to include this, which ties it to the output-buffer item you filed alongside, and the README sentence needs to state the per-request ceiling per mode instead of claiming it is unchanged in general. A startup line reporting the per-request context and the total is worth having in both modes regardless of which way this lands.
Flagging one thing I cannot yet support with evidence: the N-times-KV cost under unified is arithmetic, not a measurement, and the near-limit fit test that would show whether the fitter handles it gracefully still has not run. I would rather land this after that test than before it.
There was a problem hiding this comment.
Fixed in 8989e08, taking the option I argued for in the follow-up rather than the one I first said: n_ctx is no longer divided when kv_unified is set, so each group is created with the full C.
The split-KV path is unchanged, because there the division is correct: n_ctx_seq = n_ctx / n_seq_max, so dividing n_ctx and n_parallel together leaves the per-request context at C/P. Only the unified path, where n_ctx_seq = n_ctx, was being harmed by it.
Two things came with it. The multiple-of-N requirement on --ctx-size now applies only to the split path, since only that path divides. And the README claim that the per-slot context is unchanged, which was true for split KV and false for unified, now states both modes and says plainly that unified costs N times the KV.
That memory cost is real and is not hidden: reserve_extra_group_memory measures each extra context and charges it to the fit margins, so a configuration that no longer fits comes back as an n_ctx the fitter lowered and reported. I want to be clear that this composition is arithmetic rather than something I have watched happen at a real limit yet; the near-limit fit test is still queued behind other work on the pair, and it is the thing that would confirm the fitter degrades gracefully here rather than failing late.
Dividing n_ctx by the group count is correct for one KV mode and wrong for the other, and it was being done for both. llama_context derives n_ctx_seq = n_ctx / n_seq_max when the KV is split, so dividing n_ctx and n_parallel together leaves the context one request may use at C/P, unchanged, which is the intent. With --kv-unified it sets n_ctx_seq = n_ctx, so the same division cuts what a single request may use from C to C/N. That is not a tighter limit, it is a different meaning for -c decided by an unrelated flag, and nothing reports it: the first sign is a long request being truncated. Each group now keeps the full n_ctx under unified KV. That costs N times the unified KV, which reserve_extra_group_memory() already measures and charges to the fit margins like any other per-group cost, so a configuration that no longer fits comes back as an n_ctx the fitter lowered and reported rather than a silent halving. The multiple-of-N check on --ctx-size now applies only to the split-KV path, since only that path divides. The README claimed the per-slot context was unchanged, which was true for split KV and false for unified; it now states both modes and the memory cost.
-c is what every request should be able to reach. Splitting the server into pipeline groups is an internal arrangement and should not redefine it, so n_ctx is no longer divided in either KV mode and every group is built with the full C. The multiple-of-N constraint on --ctx-size goes with it, since nothing divides any more. Per-slot context does not shrink anywhere. Under unified KV the per-request context is n_ctx, so a request can now reach C rather than C/N. Under split KV it is n_ctx / n_seq_max, and n_seq_max per group is P/N, so a slot gets C*N/P where the fully divided form gave C/P: N times more, not the same. Nothing that used to fit stops fitting. The cost is aggregate KV of roughly N*C rather than C. That is what reserve_extra_group_memory() accounts for: it measures each extra context and charges it to the fit margins, so a configuration that does not fit comes back as an n_ctx the fitter lowered and reported, rather than the server quietly serving less than was asked for.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 50c051472c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| context, rather than equal to it. That is included in the parameter fit, so if it does not fit, | ||
| the fitter lowers `n_ctx` and reports it rather than the server quietly serving less than asked. |
There was a problem hiding this comment.
Do not promise automatic context shrinking
With --pipeline-groups > 1, this fallback cannot occur: validate_pipeline_groups() requires an explicit nonzero context size (server-context.cpp:1563-1565), while the fitter enables context reduction only when cparams->n_ctx == 0 and explicitly leaves user-set sizes unchanged (common/fit.cpp:197,453-455). If the multiplied KV allocation exceeds memory, fitting may change model placement or loading may fail, but it will not lower and report n_ctx; correct this guidance or implement the advertised fallback.
Useful? React with 👍 / 👎.
The pre-scan treated any argv element equal to --pipeline-groups as the flag, including one that was the value of a preceding option, and removed it from argv, so a user passing that string as a value lost it. The scan now stops at a bare --, leaving the separator and every operand after it alone. That is as far as a pre-scan can go: it runs before the option table exists, so before the separator it still cannot tell an option from the value of a preceding option. The residual case and the fact that registering the option with the parser would remove it are both written down in the README rather than left for the next reader to rediscover. The README also now documents LLAMA_ARG_PIPELINE_GROUPS, which was already honoured but undocumented, and states that an explicit flag beats it.
The option was read by a hand-written pre-scan over argv that ran before the
common parser, because the parser would otherwise reject an unknown option. The
previous commit bounded that scan at a bare "--", which narrows the bug without
closing it: before the separator a pre-scan cannot tell an option from the value
of a preceding option, so a literal --pipeline-groups passed as some other
option's value was still consumed and removed.
Registering the option removes the pre-scan and the class of bug with it.
n_pipeline_groups joins common_params, the option is declared for the server
example with set_env("LLAMA_ARG_PIPELINE_GROUPS"), and the >= 1 check moves into
the parser where a bad value now reports through the normal error path instead
of exit(1).
It also gains the things a registered option gets for free: an entry in --help,
the environment variable listed there rather than only in the README, and the
usual precedence of an explicit flag over the environment.
Re-measurement 2026-09-09: 1.68x, not 1.77x, on tonight's machinesRe-confirmed from a clean slate at this head ( Conditions. Qwen3.8-27B UD-Q4_K_XL layer split over one
Every cell served all 256 requests with Pooled: 195.63 against 116.34, ratio 1.682x at ntg 256, individual two-group legs at 1.634x, Against the recorded legs: the one-context arm is within 1.9 percent (116.34 against 118.63) What was eliminated, so that the remaining explanation is the credible one:
What is left is machine state. This block ran from 00:35 to 02:16 after eight hours of Two cells were discarded and are in no mean: one where the thermal guard saw the peer at 90 C I am not editing the headline yet. A re-measurement on a genuinely rested pair, both arms inside |
Adds
--pipeline-groups Ntollama-server: the slots are run overNindependentllama_contextobjects created from the same model, each with its own batch, its own samplingand its own decode thread. The model weights, the task queue, the results queue and the HTTP
layer are shared. With
N = 1nothing changes.The point is a layer split across two machines. With one context the split is a two-stage
pipeline fed one batch at a time, so each stage is idle while the other computes. With two
groups there are two batches in flight.
Result
Two DGX Sparks (GB10, 111.9 Gb/s per rail), Qwen3.8-27B-UD-Q4_K_XL, layer split over one
ggml-rpc-serveron the peer,-c 16384 --parallel 32 --cache-ram 0 -fa on -ngl 99 -t 6,32 concurrent clients, 64 requests, npp 128 / ntg 256, best of two repeats, all cells inside one
clock state (2386 to 2402 MHz local, 2433 to 2463 MHz peer, no thermal cap markers).
CUDA0,RPC0CUDA0,RPC0CUDA0,RPC0RPC0,CUDA0RPC0,CUDA0RPC0,CUDA0--backend-samplingRPC0,CUDA0--backend-sampling1.32x over one context on the same split, at 76 to 79 percent GPU busy per node against 43 to 47
percent. Two things had to be true at once:
-sm layerthe devices are filled inthe order they are listed, so the last device holds the output layer. List the RPC device
first and the local one last (
--device RPC0,CUDA0): the logits are then produced locally,which removes a
n_vocab * n_rows * 4byte return from every decode step (31.8 MB per step at32 rows on this 248320-token vocabulary) and lets the sampler read them out of local memory.
Slot count sweep,
RPC0,CUDA0,--cache-ram 0,-csized so every slot keeps 512 tokens:What the cost was, and how it was found
LLAMA_SERVER_PIPE_PROF=1(added here) prints, per group and every five seconds, how long aniteration spends waiting for the engine, in
pre_decode, inllama_decode, inllama_synchronizeand inpost_decode, and splitspost_decodeper token into sampling,detokenization, stop-string handling and the result queue. On the pair, per group iteration:
pre_decodellama_decodellama_synchronizepost_decodecommon_sampler_samplecommon_token_to_pieceprocess_token(stop strings, streaming)queue_results.sendSampling one row builds a candidate array over a 248320-token vocabulary, about 4 MB of memory
traffic. With one context that pass runs in the gap where both GPUs are idle and gets the full
memory bandwidth of the node. With two groups it runs against the other group's GPU work on the
same LPDDR5X and costs six to seven times more per row, while sitting on each group's critical
path between the synchronize and the next submit. Every other candidate was measured and
falsified: the results queue and the task queue are three orders of magnitude too small, and the
engine lock was never contended (0.00 ms in every window; forcing the old single shared host lock
back on with an A/B switch gave 74.7 against 73.5 to 75.3 tok/s).
Changes
--pipeline-groups N, parsed intools/serverbecause it only means anything for the server.Slots are partitioned contiguously; each context gets
n_ctx / Nandn_seq_max = P / N, sothe per-slot context and the total KV over all groups are what the user asked for. Slot
selection still runs over all slots, so prompt-cache similarity and the slot save / restore
endpoints behave exactly as before.
N > 1is refused together with speculative decoding,multimodal, control vectors and
--sleep-idle.own row of the logits), so the tokens are the ones the serial pass would have produced. The
thread budget is divided by the number of groups, so a pipeline-groups run is not simply given
more CPU than the single-context run.
LLAMA_SERVER_SAMPLE_THREADS=1turns it off.is worth nothing on its own on this hardware and is reported as such, but it is what the
feature is supposed to guarantee.
group. This is what fixed TTFT (29 s to 7 s at 32 concurrent).
get_available_slot()calledprompt_save/prompt_load, which read and write the slot'ssequence KV, before the guard waited for the owning group's decode to finish. With
--cache-ram 0the cache is null so it never fired; with the cache on it is a live raceagainst a running decode and it matches the 29 s TTFT and the abort seen while developing this.
The cache update now happens after the wait.
server_metricsis no longer written from several group threads at once, and each group countsits own slots instead of every slot of the server (which double counted
n_busy_slots).shared by every backend of that endpoint, and a message was three unlocked writes, so two
contexts interleaved their command streams and
--pipeline-groups 2aborted with "Remote RPCserver crashed or returned malformed response" within seconds. Responses are handed out in
request order by a ticket, so a waiter does not hold the send lock.
last_graph_uidmoved tothe connection and is checked under that lock, which closes a hazard where GRAPH_RECOMPUTE
could re-run the other context's graph.
Correctness
Per-group speculative decoding (the section below), on top of the proofs already listed here:
base,
--pipeline-groups 1and--pipeline-groups 2on this branch, md5e154ffeace8e6d57298e1963f16529b5for all three.--parallel 8: groups 1 and groups 2 give the same sequential greedymd5 with MTP (
4dae418e8227bddb0fc0e93ab11a9e67) and the same without it(
43e68a9c6ffafce623dd8089192cdfc3); all eight slots of both groups serve requests and/metricsreports drafted and accepted tokens in both MTP arms.tools/server/tests/unit/test_speculative.py(--model-draftsidecar, context shift,context-not-exceeded, parallel requests): 6 passed with one group, 6 passed with two.
test_basic.py,test_completion.py,test_ctx_shift.py,test_slot_save.py: 59 passed,1 skipped, 1 failed in both arms, the failure being a preset whose model is not in the offline
cache. Slot save / restore and context shift therefore also run over two groups.
N = 1,N = 2andN = 4over a CPU-only two-RPC split,five prompts, md5
177dc61e0703eba3bdaf7bf1131f0458, same as the unmodified binary.tools/server/testsrun serially against this build and against the unmodified branch head onthe same machine: 325 passed, 233 failed, 6 skipped, 9 errors on both, with byte-identical
failure name sets. The failures are pre-existing in that environment (the cached test models do
not match), not introduced here.
--pipeline-groups 2with the sampling pool on and off gives the same test results.Speculative decoding per group
--pipeline-groups Nnow combines with speculative decoding (--spec-type draft-mtpwith the MTPhead inside the GGUF, and
--model-draftwith a sidecar draft model). Each group owns its ownspeculative state:
llama_context, created bycommon_speculative_init_from_paramsagainst thegroup's target context, so
ctx_otherand the next-token embedding hooks point at that context;common_speculative, sized for the group'sP/Nsequences and addressed by the slot'ssequence id inside the group (
slot.seq_id, which isslot.idwith one group);llama_decodealready does; the two task-queue yields around the drafter are only taken on thesingle-group main-thread path.
Slot save / restore, context checkpoints (the drafter state is stored and restored with the
checkpoint) and the prompt cache behave as before, per slot. With
N = 1the code path is the sameas before, spelled through
groups[0]. With--model-draftthe sidecar model is loaded once pergroup.
validate_pipeline_groupsno longer refuses drafters;--mmproj,--control-vectorand--sleep-idle-secondsare still refused withN > 1.Two DGX Sparks, Qwen3.8-27B-UD-Q4_K_XL, layer split over one
ggml-rpc-serveron the peer(
--rpc peer:50055 --device RPC0,CUDA0 -sm layer),-c 16384 --parallel 32 --cache-ram 0 -fa on -ngl 99 -t 6, real-text prompts, npp 128 / ntg 256, 2 requests per client. MTP is--spec-type draft-mtp --spec-draft-n-max 3off the head inside the GGUF. Two passes with the armorder reversed, because the SM clock decays through a window; the cells below all ran with both
GPUs at 2390 to 2400 MHz and no cap marker (the three cells that were capped or straddled a cap
stage are listed after the table, and each of them has a clean repeat).
At 32 users the combination is the best cell measured: 1.38x over one context without speculation,
1.17x over one context with MTP and 1.16x over two groups without it. At 8 users it is not: one
context with MTP is faster (95.5 against 77.1), because two groups halve the rows per group and the
draft head is at its most valuable when the batch is small and the step is memory bound. The
crossover is between 8 and 32 rows, which is the same crossover the
--pipeline-groupsresultitself has.
Cells with a clock caveat, each superseded by the clean repeat in the table: one group without MTP
in the first pass ran at 1828 MHz (53.6 / 95.5), two groups without MTP in the reversed pass at
1690 MHz (51.5 / 108.7), two groups with MTP in the reversed pass straddled a cap stage
(92.0 / 128.3). Every 32-user cell in every arm, including the two without speculation, had one
request of 64 return no tokens; it is present in the arms this PR does not touch, so it is not a
property of per-group speculation.
Known limits
--cache-ram 0with a layer split. The RAM prompt cache moves a whole slot state onevery slot handover, and on a split most of that state lives on the remote node, so it crosses
the wire. At 32 concurrent clients on the pair, with the cache at its default: one group 75.9
tok/s and 33 s median TTFT (against 99.7 and 7.5 with
--cache-ram 0); two groups 6.9 tok/swith 14 of 64 requests timing out, because the handover runs on the single task thread and the
other group starves while it does. This is a property of the cache on a split, and one group is
already badly hurt by it, but two groups make it much worse and it is not fixed here.
HTTP response; the server log records no error, so the truncation is on the HTTP path at 256
concurrent streams, and the node crossed 80 C during that cell. That row is reported with its
error count and is not a clean measurement. 32, 64 and 128 slots are clean.
Update 2026-09-07: the flag is worth a good deal more than the figures above
The best number in this description is 1.38x at 32 users with two groups and MTP. That was measured before three launch settings were understood, and with all three the same mechanism reaches 1.77x: 209.5 tok/s against 118.6 for one context on the same split. Qwen3.8-27B UD-Q4_K_XL, two nodes,
--device RPC0,CUDA0 -sm layer, both GPUs pinned at 1690 MHz.What the earlier cells were missing:
--kv-unified. Worth 23 to 30 percent at 32 rows on a two group arm and nothing at all on one context, so it never showed up in a single context comparison.--tensor-split 0.5,0.5. Without it llama.cpp splits by free memory at load time, so the layer boundary moves between runs and two cells are not comparable. Five boundaries were measured and the even one wins.Two other things worth recording for anyone reading the numbers above. Speculation does not compose with this at high concurrency:
--spec-draft-n-max 3is the worst of the three depths at every row count on a split, and above 64 rows speculation of any depth loses (212.72 tok/s off against 164.06 at n=1 and 132.24 at n=3 at 128 rows). Acceptance is a property of the depth rather than the rows, 0.87 / 0.78 / 0.69 at depths 1 / 2 / 3, so a deeper draft accepts strictly more tokens per step and still loses throughput. And more groups is not better: four groups is 10.3 percent slower than two and three groups 11.0 percent slower, because the bottleneck GPU is already 91.55 percent busy on CUDA event accounting at two groups and what is left is scheduling inside prompt batches, not idle waiting for a neighbour.Cost, stated honestly: at 128 rows the TTFT of a saturating burst is 18.1 s median and 27.2 s p99, against 4.6 and 5.6 at 32 rows.
--parallelshould be sized to the expected load. A server built for 128 rows and driven at 32 gives 110.3 tok/s against 143.3, so oversizing loses 23 percent of throughput and 38 percent of median TTFT at the same time.Correction 2026-09-08: quote the mean, not the best leg
The 1.814x above was the best of three legs. Re-measured with the original harness on the same binaries, two further legs read 1.789x and 1.665x, with server-side token counters agreeing with the client to 0.01 percent and resident memory matching to the megabyte, so the layer boundary was identical. Pooling all five legs gives a two-group arm of 197.2 to 215.8 tok/s, mean 209.47, against a one-context arm of 118.42 to 118.96, mean 118.63.
So the honest headline for this flag is 1.77x, range 1.67 to 1.81 over five legs, on a saturating 128-row burst whose leg-to-leg spread is 9 percent. The mechanism and the magnitude are unchanged; only the statistic was too flattering.
Two things confirmed while settling this. The comment-reduction pass on this branch is inert: stripping comments with
gcc -fpreprocessed -dD -E, all five touched files are token-identical betweena1dd7c5e8andd98d90dd8, and the current head measures 1.759x in the same clock state, inside the leg scatter. And generation length is a real axis worth stating: the same leg reads 1.789x at ntg 256 and 1.714x at ntg 128, because the aggregate ratio is diluted by prefill. The decode-only TPOT ratio is 1.947x.Scope, relative to the base branch
This PR targets
master, and against that base it changes 7 files, 1197 insertions and 231 deletions.Stated explicitly because a reviewer diffs against the base, not against the point in a stack a
branch was cut from, and a scope claim measured from the wrong reference is exactly the sentence a
reviewer trusts instead of checking.