Studio: Fix embedded MTP performance under partial GPU offload - #8875
Conversation
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1bae92e67e
ℹ️ 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".
| use_fit | ||
| and _spec_canon == "auto" | ||
| and self._nextn_predict_layers |
There was a problem hiding this comment.
Preserve the sub-3B ngram fallback during partial offload
For an embedded Hybrid Mamba model below the 3B MTP threshold that genuinely requires --fit on (for example, a 2B model on a very small GPU), this flag causes _build_speculative_flags to return --spec-type none before reaching its existing _mtp_too_small branch. Auto previously substitutes ngram-mod for these models because embedded MTP is slower while ngram speculation can still help, so the new partial-offload path should defer to that policy rather than disabling all speculation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one. The ordering is as you describe, but ngram speculation is not the cheap substitute on a Hybrid Mamba target that it is elsewhere. common_params_speculative::need_n_rs_seq() returns draft.n_max only for DRAFT_MTP, DRAFT_EAGLE3, DRAFT_DFLASH and DRAFT_DSPARK, so every ngram type leaves n_rs_seq at 0. common_context_can_seq_rm then fails its partial seq_rm probe, since llama_memory_recurrent::seq_rm rejects a rollback above n_rs_seq, and classifies the target COMMON_CONTEXT_SEQ_RM_TYPE_FULL. In that state server-context.cpp takes a host-side recurrent-state checkpoint on every draft round. Substituting ngram-mod here would trade a measured MTP regression for an unmeasured per-step state copy, so Auto stands down instead.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 410c09c871
ℹ️ 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".
| recurrent = self._mamba_recurrent_state_bytes(n_parallel) | ||
| if key_len is not None and val_len is not None: | ||
| v_width = n_kv * val_len if flash_attn else self._max_kv_value_width(val_len) | ||
| return int(n_attn * total_cells * (n_kv * key_len * bpe_k + v_width * bpe_v)) | ||
| return ( | ||
| int(n_attn * total_cells * (n_kv * key_len * bpe_k + v_width * bpe_v)) | ||
| + recurrent |
There was a problem hiding this comment.
Skip recurrent VRAM when KV offload is disabled
For a Hybrid Mamba launch with --no-kv-offload (or LLAMA_ARG_KV_OFFLOAD=false), llama.cpp places the cache, including recurrent state, in host memory, but this newly added term is always included in _kv_bytes; the placement callers never pass the parsed KV-offload policy into this estimator. At four slots this adds about 598.5 MiB of nonexistent GPU usage before the rollback reserve, which can unnecessarily reduce context or force a partially CPU-offloaded launch. Thread the effective KV-offload setting through the placement estimate or omit recurrent state from the GPU budget when it is disabled.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one. _estimate_kv_cache_bytes has never modeled --no-kv-offload: the only consumer of _kv_offload_from_args is the pipelining helper, so the attention KV in every path is already budgeted as GPU-resident under -nkvo, and at 128K that term is 8,790 MiB against the 598.5 MiB you name. The new recurrent term is consistent with the rest of the estimator rather than a new gap, and it errs in the direction this code picks on purpose: over-reserving costs context, under-reserving OOMs. Threading the KV-offload policy through the estimate means touching every path and every caller, which belongs in its own change.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e657092c36
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| # Auto's Hybrid Mamba partial-offload stand-down engaged nothing, so the | ||
| # types above cannot see it -- yet the depth is what priced the rollback | ||
| # copies that made the placement partial, so a change can re-enable MTP. | ||
| or self._spec_fallback_reason in ("runtime_error", "mtp_partial_offload") | ||
| ) | ||
| and intent.spec_draft_n_max is not None | ||
| and intent.spec_draft_n_max != (compared_draft_n_max or 0) |
There was a problem hiding this comment.
Reload when the draft-depth override is cleared
If Auto stood down at an explicit draft depth (for example 3) and the user resets the setting to the platform default (None, which is 2 on GPU), this condition skips the comparison because it requires the new intent value to be non-null. Apply therefore deduplicates against the partial-offload fallback and never reruns placement, even though the lower default rollback reserve may allow full offload and re-enable MTP.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one. None on the request means "platform default, matches any backend", which is the contract test_already_in_target_state_matches_when_draft_n_max_unset pins, and the request shape cannot tell "the user cleared it" from "the field was omitted". Dropping the non-null guard makes the comparison None != (compared or 0), which is true for a backend already at the platform default, so every Apply that omits the field would relaunch a healthy server. The same guard covers engaged draft-mtp on main, so this is not specific to the stand-down.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 33c0ee827d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| flags.extend(["--spec-type", "none"]) | ||
| self._speculative_type = "none" |
There was a problem hiding this comment.
Reprice Auto context after standing MTP down
When Auto context sizing initially finds a fully offloaded placement with MTP but a pass-through layer override later makes the effective launch partial (for example, Auto memory mode plus --gpu-layers 42), the earlier _fit_context_to_vram calculation may already have reduced effective_ctx to accommodate the draft KV and rollback reserve. This branch then removes MTP without rerunning that calculation, so the final partial, non-speculative server is launched with a context cap sized for memory it no longer allocates. Decide this stand-down before context fitting or reprice the context without the MTP reserve.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The ordering is real, the fit does run before the stand-down, but I measured the consequence and could not find it. Driving the load twice, once as head does it and once with the MTP reserve forced to zero, the launched -c is identical in every configuration I could build, including the Auto plus --gpu-layers 42 case you name:
| free VRAM | reserve the fit was charged | -c at head | -c with no reserve |
|---|---|---|---|
| 12288 MiB | 1372 MiB | 44800 | 44800 |
| 24576 MiB | 2117 MiB | 235520 | 235520 |
Same for --fit on with an automatic context (4096 either way, the sub-fit floor) and with an explicit 65536 (honored either way). The reserve reaches the fit, but it is not what sets the launched context in these cases, so there is no context to give back. Repricing would mean re-running the fit after the command is assembled, which I am not doing for a cost I cannot measure.
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 07256f6cc9
ℹ️ 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".
| # gpu_indices counts too: an explicit pick the VRAM probe could | ||
| # not enumerate still pins the child to those devices, so the | ||
| # launch offloads to them whatever the probe returned. | ||
| and (_detected_gpus or gpu_indices) |
There was a problem hiding this comment.
Honor explicit GPU placement when the probe is empty
When _get_gpu_memory returns []—for example, after a failed custom Vulkan probe—a user can still select a working GPU through pass-through --device Vulkan0 and request partial offload with --gpu-layers 42. The child then has a fixed partial GPU placement, but this guard is false because neither _detected_gpus nor Studio's gpu_indices is populated, so _partially_offloads_layers is never consulted and Auto still emits draft-mtp, retaining the slow configuration this fallback is intended to prevent. Treat an effective explicit non-CPU device selection as GPU evidence here as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in b126194. This is the other side of the CPU-device check from the earlier round, and it was a hole: a probe that returns nothing is not evidence of no GPU when the extras point the child at one. The guard now also accepts an effective non-CPU --device over the same argv and env view _device_selection_is_cpu reads, so the two answers cannot disagree.
Covered by test_a_hand_pinned_device_is_gpu_evidence_when_the_probe_found_none (--device Vulkan0 --gpu-layers 42 with an empty probe). It fails without the change, and the two sibling guards, no GPU at all and a CPU device selection, still pass alongside it.
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b126194700
ℹ️ 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".
| // what the placement IS rather than that the model could not fit: a Manual | ||
| // layer count is a partial placement the user picked, on a card that may | ||
| // have room for all of it, and there the useful remedy is more layers. | ||
| return "Only part of this model is on the GPU, and MTP's extra state costs more there than the drafting wins back, so Auto turned it off for this load. Put every layer on the GPU to get it back, or choose MTP in Settings to force it at this placement."; |
There was a problem hiding this comment.
Describe the speculative placement rather than the final one
When Auto selected --fit on only because the MTP rollback reserve prevented full offload, embedded_mtp_partial_offload disables MTP before the child starts; llama.cpp then fits without that reserve and can place every layer on the GPU. In that VRAM boundary case this message incorrectly reports that only part of the running model is on the GPU and recommends a placement it already has. Phrase this as the placement MTP would require, or distinguish automatic-fit fallback from a fixed partial layer count.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and fixed in 4a8e94f. The partial verdict is priced with the MTP rollback reserve still in it, so on the fit path llama.cpp can place every layer on the GPU once this branch turns MTP off, and the old wording then described a placement the load does not have while recommending the one it already has. It now says what MTP would require instead, which stays true there and at a fixed partial layer count. The source-copy test in studio/frontend/tests/spec-fallback-partial-offload-copy.test.ts now rejects unconditional final-state claims.
| # emptied above), so its --fit on is the default this | ||
| # function starts at, not a finding that the model does | ||
| # not fit. Only Auto's fitter carries that evidence. | ||
| fit_implies_partial = gpu_memory_mode != "manual", |
There was a problem hiding this comment.
Require a real planner verdict before treating fit as partial
When the VRAM probe returns no devices but the user supplies only --device Vulkan0 (or an explicit gpu_ids pin), _detected_gpus is empty and the placement planner never ran; --fit on is merely the default fallback. The device pin nevertheless satisfies the GPU-evidence guard, and this argument makes that unproven fit count as partial, so Auto disables MTP even when the selected GPU is large enough for full offload. Allow the explicit device to prove that a GPU exists, but require actual planner evidence before interpreting --fit on as partial; concrete partial layer counts can still stand down MTP independently.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and fixed in 11a495a. use_fit starts True at its declaration, every placement-planner branch is gated on a non-empty gpus, and the except path restores True having priced nothing, so an unfitted --fit on could mean nobody looked. I reproduced four routes where Auto stood MTP down with no verdict behind it: empty probe plus a hand-pinned device (a Metal Mac on --device Metal0, or Linux and Windows after a failed Vulkan probe), the same with an explicit gpu_ids pin, _select_gpus raising with _detected_gpus already populated, and a pass-through --fit on winning last after the planner had positively proved full offload.
The fix records the verdict where the planner returns it (_placement_verdict_partial) rather than inferring it from the flag. A concrete --gpu-layers count is untouched, so the reported Qwen3.8-27B case still stands MTP down. Tests in studio/backend/tests/test_mtp_partial_offload_evidence.py cover the platform and accelerator product.
block_count includes the trailing NextN/MTP blocks and llama_hparams::n_layer() subtracts them, but the target KV cache walks n_layer_all and drops blocks only through an optional per-arch filter (llama-kv-cache.cpp:100 and :169). That filter exists for the hybrids at llama-model.cpp:2289, for glm-dsa/deepseek32 at :2129 and for step35/hy_v3/mimo2 at :2356. deepseek2, glm4, glm4moe, bailingmoe2, cohere2moe and exaone4 get filter == nullptr, so their MTP block does take target KV and subtracting it under-reserves by a whole layer -- about 2% on GLM-4.5-Air, whose converter writes both the inflated block_count and the key. Fail closed on the architecture instead, since over-reserving costs context while under-reserving OOMs the load. A hybrid recurrent header answers yes without the list: every hybrid llama.cpp takes a nextn key from is filtered, and the recurrent half is sized on n_layer() regardless, so an unreleased arch still gets the saving this branch was written for. A nextn equal to block_count (gemma4-assistant) no longer collapses the estimate to the max(1, ...) floor.
use_fit starts True at its declaration, every placement-planner branch is gated
on a non-empty gpus, and the except path restores True having priced nothing, so
an unfitted --fit on means "nobody looked" at least as often as it means "it
does not fit". Reading it as a partial placement whenever the mode is Auto stood
MTP down on three hosts where nothing had shown the placement to be partial:
- an empty VRAM probe with a hand-pinned device, which the GPU-evidence guard
accepts by design -- a Metal Mac on --device Metal0, or Linux and Windows
after a failed Vulkan probe on --device Vulkan0;
- the same with an explicit gpu_ids pin;
- GPUs enumerated and _select_gpus raising, where _detected_gpus is already
populated so a device-list check would not catch it either.
A fourth kept the stand-down after the planner had positively proved full
offload, when a pass-through --fit on won last.
Record the verdict where the planner returns it instead. This is the distinction
_partially_offloads_layers already draws for Manual mode, applied to Auto
whenever the planner did not actually run. A concrete --gpu-layers count is
untouched: it is independent evidence inside the helper and still stands MTP
down, empty probe or not, which is what the reported Qwen3.8-27B case relies on.
The partial verdict is priced with MTP's rollback reserve still in it, so on the fit path llama.cpp can put every layer on the GPU once this branch turns MTP off. "Only part of this model is on the GPU" then describes a placement the load does not have and recommends one it already has. Say what MTP would require instead, which stays true there and at a fixed partial layer count.
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a58bdeecd5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if requested is not None and requested > 0: | ||
| # llama.cpp needs a count above block_count to include the output | ||
| # layer, matching _offloads_every_layer. | ||
| return requested <= n_layers |
There was a problem hiding this comment.
Use the target layer count at the full-offload boundary
For embedded NextN models, self.n_layers is the GGUF block_count, including the MTP blocks, while llama.cpp applies --gpu-layers against hparams.n_layer(), which subtracts those blocks. Thus, for the 65-block Qwen example with one NextN block, --gpu-layers 65 already offloads all 64 target layers plus the output, but this comparison classifies it as partial and Auto unnecessarily emits --spec-type none. Compare against the effective target-layer count after subtracting nextn_predict_layers.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one. llama.cpp applies --gpu-layers against hparams.n_layer_all, the raw GGUF block_count with the MTP blocks still in it, not against hparams.n_layer().
llama-model.cpp:1347 computes i_gpu_start = max(n_layer_all + 1 - n_gpu_layers, 0), :1348 caps act_gpu_layers at n_layer_all + 1, and :1740 expands the -1 default to hparams.n_layer_all + 1 with the comment 'plus 1 for the output layer'. n_layer() does not appear in the placement computation at all. On the 65-block Qwen example, --gpu-layers 65 gives i_gpu_start = 66 - 65 = 1, so layer 0 is assigned to cpu_dev and a real target layer stays in host RAM. That is partial offload, and 66 is the full boundary, which is what the existing requested <= n_layers comparison already encodes.
Subtracting nextn here would also move the same boundary in _offloads_every_layer, which shares it deliberately, and that one feeds the Model Memory gate: Studio would believe a NextN model was fully GPU resident and skip page-locking weights that are actually host resident.
| # _device_selection_is_cpu reads for the CPU answer: a failed | ||
| # probe is not evidence of no GPU when the child is pointed at | ||
| # one by hand. | ||
| and (_detected_gpus or gpu_indices or _spec_extras_pick_a_gpu) |
There was a problem hiding this comment.
Preserve MTP when the architecture gate forces CPU
On a ROCm launch where the kernel-coverage gate sets _arch_gate_forced_cpu, pass-through arguments such as --device ROCm0 --gpu-layers 42 still satisfy this GPU-evidence condition and are classified as partial offload. The later _arch_gate_forced_cpu branch masks every GPU from the child, so the actual launch is CPU-only, but Auto has already emitted --spec-type none instead of retaining the CPU MTP policy. Exclude forced-CPU placements from this stand-down decision.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one, the argv case does not reach a running server. _arch_gate_forced_cpu is only set when gpu_ids is falsy and the VRAM probe came back empty, so _detected_gpus and gpu_indices are both empty and the only GPU-evidence disjunct that can fire is _spec_extras_pick_a_gpu, which needs a hand-named device.
Take that literally, --device ROCm0 --gpu-layers 42. Nothing on this path strips --device from argv: the forced-CPU branch only drops the split-mode and tensor-split flags, and every _strip_device_extra_args call on the launch path is gated on gpu_ids, which is falsy here. So the child gets --device ROCm0 while _emit_child_gpu_visibility masks every device, and llama.cpp throws at argument parsing, common/arg.cpp:1130 'invalid device: ROCm0'. The server never starts, so what --spec-type it was given does not matter.
The one variant that does yield a working CPU-only launch needs LLAMA_ARG_DEVICE exported into Studio's own environment, on a ROCm host whose build has kernels for none of its GPUs, plus a concrete --gpu-layers, and _emit_child_gpu_visibility clears that same variable a few lines later specifically so the child does not abort. The cost there is a speculative-decoding stand-down on a misconfigured environment, not a wrong placement, so I would rather leave the guard reading what the launch was actually told.
…dules sys.modules.setdefault makes this stub process-wide: whichever test module is imported first wins it, and utils/prebuilt/freshness_flow calls structlog.get_logger at import time. A bare module therefore fails that import for every module imported after this one on a runner without the real package, which is why test_probe_server_capabilities_gates_known_broken_dspark_prebuilt failed whenever it ran in the same pytest process as this file and passed alone. test_llama_cpp_placement already stubs it this way.
|
Ran this across a few more surfaces and pushed four commits. Summary of what changed and what the evidence covers. Commits
Cross-platform CIRun on a staging repo rather than the org queue, at
ubuntu and macos were red before Worth being precise about what that Playwright job is and is not. It boots Studio and drives it under Chromium on a CPU-only runner with no PyTorch, so it shows the build serves and the UI loads. It is not a before and after of the notice copy, and it could not be: reaching On the eight red checks hereThey are jobs cancelled at about 30 minutes, not assertion failures. The Backend CI and Chat UI logs end in Local coverage behind the first commitAdded studio/backend/tests/test_nextn_target_kv_policy.py, which drives the target-KV nextn policy through real parsed GGUF headers for the 19 architectures that can carry the key, and test_mtp_partial_offload_evidence.py, which covers the platform and accelerator product plus the placement-evidence cases. I also ran the whole studio/backend suite at the merge base and at my head and diffed it test by test: same failure set both sides, 164 additional passes, no new failures. |
|
Screenshotted the notice from two isolated installs, merge base One correction to how I first framed this. At the merge base there is no Control, so the pair is comparable. GPU Memory Manual and GPU Layers 16 are identical on both sides, as is the Extra Arguments box, so the placement is not what moved: Setup:
One thing worth weighingAt this placement the measurement does not support the stand-down. Seven paired repeats per side, decode tok/s:
Both sides went bimodal because another tenant was on the card, so treat the medians with suspicion and read the best-of-7 as the least contended sample. Either statistic gives the same direction on the counting prompt: keeping MTP was faster there, and prose was a wash. I think that is structural rather than noise. In Manual mode the layer count is pinned, so turning MTP off frees the rollback reserve but does not move a layer back onto the GPU. The load gives up the drafter and gets nothing for it, which is a different situation from Auto's fitter, where the reserve is exactly what was displacing layers. The copy also tells the user to give the GPU room for every layer, which at a fixed Manual count they have already decided against. Stated plainly so it is not overread: this is a synthetic Manual partial placement on a card with plenty of spare VRAM, not the reported Qwen3.8-27B case at about 12 GiB free, where the reserve genuinely displaces layers and your 3.11 to 12.06 numbers stand. I could not reproduce that case cheaply here and did not try to approximate it. Your own fixed-placement measurement (1.90 against 1.81) was taken under the VRAM squeeze; the gap between that and this is what makes me think the Manual arm may want a different rule from the fit arm. |
|
@codex review |
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
|
Note on the red checks, since they are the first thing anyone will look at here: none of them come from this branch. Backend CI, 20 failures. I pulled the failing-test list from this PR's job and from the latest main run at
None of these touch llama.cpp placement, KV estimation, or the frontend copy this PR changes. Unsloth UI CI / Chat UI Tests, cancelled. This job is over its 30 minute budget repo wide, not on this branch specifically. Every run on main for the past several days ends the same way, including at
I re-ran it once on this head to confirm, and it hit the same cap. The cancellation lands on whichever step happens to be running at the 30 minute mark rather than on one consistent test, which reads as the job not fitting in its budget rather than anything hanging. pip scan-packages :: hf-stack was already red before any of the commits I pushed. All of this looks worth splitting out on its own, but it is not something this PR should absorb. |
|
Following up on the throughput caveat I posted earlier. I have better numbers now and they point the other way, so I would rather correct that than leave it standing. I tested whether the MTP layer could simply be offloaded to system RAM instead of disabled, keeping the speedup rather than surrendering it. Mechanically that works: The numbers say not to do it. Qwen3.5-2B-MTP, llama-server, 3 prompts x 256 tokens: The middle two are exactly the choice this PR is making, and they free the same VRAM: The control is the part that generalises. Offloading the MTP layer costs 37.5 percent. Offloading an ordinary layer of the same size costs 22.9 percent. The MTP head runs on every draft step, roughly 2.4 times per accepted token, so it is the worst layer in the model to put behind PCIe. That is structural to what MTP does, not a property of one machine. So the stand-down this PR implements looks like the right call, and my earlier counter-example does not generalise the way I implied: it was measured at fixed Manual placements where every layer was already resident, which is not the regime this policy governs. Caveat on these numbers too, in fairness: this was a B200 with 183 GB, so placement was forced explicitly rather than reproducing a genuinely VRAM-constrained fit. The absolute figures are not consumer representative. The mechanism suggests the gap widens on smaller cards, but I have not measured that and am not claiming it. |



Summary
Fix the partial-offload performance reported in Qwen3.8-27B-GGUF discussion #18, where Studio produced about 3.5 token/s with UD-IQ2_M and default settings.
The embedded MTP head follows the main model's placement. At four slots, the target needs 598.5 MiB of recurrent state, while MTP with draft max 2 needs 1,795.5 MiB because llama.cpp retains two rollback copies. This forced 15 additional layers onto CPU at 4K context and made MTP 73.5% slower than ordinary decoding.
The pre-fix command was reproduced with the same model, binary, four-slot placement, and VRAM constraint. The patched Studio and direct llama-server rows used the same three uncached prompts and both consumed 10,562 MiB of GPU memory. At 64K, patched Studio also matched direct llama-server at 4.24 versus 4.21 token/s and 10,632 MiB each.
Holding placement fixed at 42 / 66 layers also measured 1.90 token/s without speculation versus 1.81 token/s with MTP across three paired prompts. Auto therefore disables embedded MTP for fixed partial layer counts too, while an explicit MTP selection still overrides the policy.
Fix
ssm.group_countand account for Hybrid Mamba recurrent state and MTP rollback copies.--spec-type nonefor both--fit onand fixed GPU-layer counts.Memory validation
Studio's corrected target and MTP context estimates match llama.cpp's detailed allocation logs across the tested range:
Validation also includes 1,066 focused backend tests, Ruff, and clean diff checks.