Studio: Fix embedded MTP performance under partial GPU offload by oobabooga · Pull Request #8875 · unslothai/unsloth · GitHub
Skip to content

Studio: Fix embedded MTP performance under partial GPU offload - #8875

Merged
danielhanchen merged 23 commits into
unslothai:mainfrom
oobabooga:fix/studio-mtp-offload-parity
Aug 16, 2026
Merged

Studio: Fix embedded MTP performance under partial GPU offload#8875
danielhanchen merged 23 commits into
unslothai:mainfrom
oobabooga:fix/studio-mtp-offload-parity

Conversation

@oobabooga

@oobabooga oobabooga commented Aug 14, 2026

Copy link
Copy Markdown
Member

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.

4K context, about 12 GiB free VRAM Speculation GPU layers Decode speed
Before, Auto emitted embedded MTP MTP, draft max 2 42 / 66 3.11 token/s
After, patched Studio Auto None 57 / 66 12.06 token/s
Direct llama-server reference None 57 / 66 11.32 token/s

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

  • Parse ssm.group_count and account for Hybrid Mamba recurrent state and MTP rollback copies.
  • Exclude the embedded MTP block from target KV accounting. The GGUF contains 65 total blocks, but llama.cpp builds a 64-layer target and a separate one-layer draft context.
  • When Auto resolves to embedded Hybrid Mamba MTP and the effective layer policy is partial offload, emit --spec-type none for both --fit on and fixed GPU-layer counts.
  • Preserve MTP when explicitly selected, fully GPU-resident, or CPU-only.

Memory validation

Studio's corrected target and MTP context estimates match llama.cpp's detailed allocation logs across the tested range:

Context Target, Studio / llama.cpp With MTP, Studio / llama.cpp
4K 854.5 / 854.5 MiB 2,067.5 / 2,067.5 MiB
16K 1,622.5 / 1,622.5 MiB 2,883.5 / 2,883.5 MiB
64K 4,694.5 / 4,694.5 MiB 6,147.5 / 6,147.5 MiB
128K 8,790.5 / 8,790.5 MiB 10,499.5 / 10,499.5 MiB

Validation also includes 1,066 focused backend tests, Ruff, and clean diff checks.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +15323 to +15325
use_fit
and _spec_canon == "auto"
and self._nextn_predict_layers

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +7593 to +7598
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +4552 to 4558
# 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +17689 to +17690
flags.extend(["--spec-type", "none"])
self._speculative_type = "none"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@oobabooga

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@oobabooga

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@oobabooga

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@oobabooga

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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.";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

danielhanchen and others added 4 commits August 15, 2026 14:01
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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
…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.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 15, 2026
@danielhanchen

Copy link
Copy Markdown
Member

Ran this across a few more surfaces and pushed four commits. Summary of what changed and what the evidence covers.

Commits

  • 4d726c27 subtract the MTP blocks from target KV only where llama.cpp keeps them out of it. n_layer() subtracts nextn everywhere (llama-hparams.cpp:297), but the target cache walks n_layer_all (llama-kv-cache.cpp:100) and drops blocks only through a per-arch filter (:169), installed for the hybrids at llama-model.cpp:2289, glm-dsa/deepseek32 at :2129 and step35/hy_v3/mimo2 at :2356. deepseek2, glm4, glm4moe, bailingmoe2, cohere2moe and exaone4 get filter == nullptr, so their MTP block does take target KV. GLM-4.5-Air is the concrete one, since conversion/glm.py writes both the inflated block_count and the key, and it was losing a whole layer of reserve. A nextn equal to block_count (gemma4-assistant) also no longer collapses the estimate to the max(1, ...) floor. A hybrid recurrent header still gets the subtraction without being named, so the Qwen3.5 saving is unaffected.
  • 11a495ac require a real planner verdict before reading --fit on as partial (the thread above).
  • 4a8e94fc frontend copy (the thread above).
  • d4f01ee7 give the structlog test stub a get_logger. This one is not yours: sys.modules.setdefault makes that stub process-wide, so on a runner without the real package it broke freshness_flow's import for every module imported after test_kv_cache_estimation, and test_probe_server_capabilities_gates_known_broken_dspark_prebuilt failed whenever the two ran in one pytest process and passed alone. I reproduced it on 203007d before any of my commits. test_llama_cpp_placement already stubs it this way.

Cross-platform CI

Run on a staging repo rather than the org queue, at d4f01ee7:

workflow result run
ubuntu-latest pass 31890960153
macos-14 pass 31890960161
windows-latest pass 31890960183
studio-playwright pass 31890960156

ubuntu and macos were red before d4f01ee7 on exactly the stub-ordering failure above, which is what made me go looking for it.

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 mtp_partial_offload needs an embedded Hybrid Mamba MTP head on a genuinely partial GPU placement, which that runner cannot produce. The step also runs with || true, so read it as a smoke, not a gate.

On the eight red checks here

They are jobs cancelled at about 30 minutes, not assertion failures. The Backend CI and Chat UI logs end in The operation was canceled, and the UI one reports [banner] 1432 checks, 0 failed just before it. That is queue congestion.

Local coverage behind the first commit

Added 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.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@danielhanchen

Copy link
Copy Markdown
Member

Screenshotted the notice from two isolated installs, merge base 203007d1 against head d4f01ee7. Both sides are install.sh --local builds from their own checkout, so each serves its own bundle.

One correction to how I first framed this. At the merge base there is no default: copy to photograph either: mtp_partial_offload does not exist at 203007d1 at all, so no load there can produce that reason. The honest BEFORE is no notice, with Auto engaging the embedded MTP head at the partial placement and the UI saying nothing about it.

notice

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:

settings

Setup: unsloth/Qwen3.5-4B-MTP-GGUF Q4_K_M, speculative Auto, GPU memory Manual at 16 of 33 blocks. A concrete layer count is independent evidence inside _partially_offloads_layers, so it reaches the stand-down without needing a planner verdict. BEFORE's argv was --gpu-layers 16 --fit off --spec-type draft-mtp --spec-draft-n-max 2, so MTP really did engage. 4B rather than something smaller because Auto drops an embedded head below 3B for the size alone, which would have made BEFORE silent for an unrelated reason.

BEFORE AFTER
spec_fallback_reason null mtp_partial_offload
speculative_type / drafter auto / mtp auto / mtp
gpu_memory_mode / gpu_layers manual / 16 manual / 16
notice shown no yes

One thing worth weighing

At this placement the measurement does not support the stand-down. Seven paired repeats per side, decode tok/s:

prompt BEFORE (MTP) AFTER (stood down)
counting, drafter friendly median 57.0, best 65.1 median 22.1, best 43.8
free prose median 20.6, best 40.1 median 25.7, best 42.3

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.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: d4f01ee708

ℹ️ 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".

@danielhanchen

Copy link
Copy Markdown
Member

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 6f443b5cc, and diffed them. Identical, all 20, same test names both sides:

  • youtube_router import error
  • context_length AttributeError
  • stream-slot TimeoutErrors
  • stt asserting 409 == 499

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 203007d1, which is this PR's own merge base:

run sha duration step it was in when the clock ran out
31831855191 (main) 203007d19 30m19s IME + multilingual paste regression
31859283409 (main) 6f443b5cc 30m23s Install Playwright browsers
31890947495 (this PR) d4f01ee70 30m20s model-picker per-model-config

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.

@danielhanchen

Copy link
Copy Markdown
Member

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: -ot "blk\.32\.=CPU" matches the whole MTP block including the nextn.* tensors (the override uses std::regex_search, so the pattern is unanchored) and it composes with --spec-type draft-mtp. The server loads, MTP stays genuinely enabled from host RAM, acceptance is unchanged.

The numbers say not to do it. Qwen3.5-2B-MTP, llama-server, 3 prompts x 256 tokens:

placement tok/s
all GPU, MTP on 344.45
all GPU, MTP off 387.90
MTP layer on CPU via -ot, MTP on 221.50

The middle two are exactly the choice this PR is making, and they free the same VRAM: TENSOR_SKIP in the qwen35 loader means an MTP-off load genuinely does not allocate the layer-32 weights, rather than allocating them and leaving them idle.

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.

@danielhanchen
danielhanchen merged commit 160922d into unslothai:main Aug 16, 2026
49 of 57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants