Skip to content

Add hy_v3 (Tencent Hy3) native MTP backend#142

Open
PhilipJohnBasile wants to merge 6 commits into
youssofal:mainfrom
PhilipJohnBasile:hy-v3-mtp-backend
Open

Add hy_v3 (Tencent Hy3) native MTP backend#142
PhilipJohnBasile wants to merge 6 commits into
youssofal:mainfrom
PhilipJohnBasile:hy-v3-mtp-backend

Conversation

@PhilipJohnBasile

Copy link
Copy Markdown

What

Adds the hy_v3 (Tencent Hy3) native MTP backend — closes the recognized-backend-pending gap for hy-v3-mtp (#141).

  • mtplx/backends/hy_v3_mtp.py — facade on the glm_mtp pattern (contract-gated load, health metadata, drafting via the shared sampler in generation.py)
  • mtplx/hy_v3_mtp_patch.pyis_hy_v3_mtp_config + inject_hy_v3_mtp_support. hy_v3's mlx-lm MTP revision loads the head natively, so injection installs the runtime surface (mtp_forward / mtp_update_cache / make_mtp_cache, return_hidden on the trunk forward) as a subclass wrapper — no weight rewriting. Guards: raises clearly on AR-only exports; rejects non-embedding_hidden concat order and non-pre_norm hidden variants (Hy3's fixed semantics); depth 1 only.
  • mtplx/runtime.py dispatch, import-smoke registration, registry upgrade to experimental-native-contract-gated, pending-markers test updated.

Verification

  • Applies on the v2.0.1 tag; tests/test_artifacts.py 76/76 pass with the change.
  • The full runtime surface (forward+hidden, draft with/without hidden, cache update, make_mtp_cache, guard rejections) exercised against a tiny randomly-initialized hy_v3 model — shapes and guards verified.

What this deliberately does NOT claim

  • Not live-tested against the 295B checkpoint — blocked on hy_v3 reaching the pinned mlx-lm (Add Hy3 ml-explore/mlx-lm#1211 + the MTP-surface follow-up). Draft PR until then.
  • No runtime contract fixtures yet — I'll run mtplx bench/exactness on an M5 Max 128 GB against the real checkpoint as soon as the model loads, and post receipts here.

Architecture facts encoded

One appended NextN layer (depth 1) with its own 192-expert MoE (sigmoid top-8 + expert bias); draft input eh_proj(concat[enorm(embedding), hnorm(pre-final-norm hidden)]); shared embeddings/head. Loader note for whenever the model class lands: MLX-converted checkpoints carry MTP weights in a model-mtp.safetensors sidecar named mtp.* (not model.layers.80.*).

Motivation measurement (why MTPLX's batched verify matters for this model): mlx-lm's per-token draft→verify loop runs Hy3 MTP at exact output parity but ~13.7× slower than AR (0.54 vs 7.4 tok/s warm, M5 Max). Receipts: https://github.com/PhilipJohnBasile/hy3-demolition-mlx

Hy3 registers as recognized-backend-pending; this wires the actual backend:
facade, hy_v3_mtp_patch (config detection + injection), runtime dispatch,
import smoke, and registry upgrade to experimental-native-contract-gated.

Because the mlx-lm hy_v3 MTP revision exposes the head natively
(predict_next_tokens + return_hidden_states), injection binds the existing
surface instead of grafting weights. Draft depth 1; verification stays in
generation.py. Depends on hy_v3 landing in the pinned mlx-lm
(ml-explore/mlx-lm#1211 + MTP follow-up); runtime contract fixtures to be
measured on Apple Silicon before promotion past experimental.
…audit)

Adversarial audit of the initial version found 3 blocking bugs:
- injector guards rejected the post_norm hidden_variant that
  runtime.draft_mtp resolves from the bare MTPContract default -> every
  draft call raised. Now accept-and-ignore like glm/step3p5 (Hy3 has one
  native wiring).
- validate_mtp_support requires model.mtp.layers; native MTPBlock exposes
  .layer -> added a .layers alias (child-module container, not a copy).
- registry: hy-v3-mtp was absent from SUPPORTED_ARCH_IDS and the
  appended-layer runtime gate -> added to both.
- depth>=2 now reuses the single NextN layer instead of raising.
Adds tests/test_hy_v3_mtp_backend.py (4 tests incl the post_norm path);
artifact suite still 76/76.
@PhilipJohnBasile

Copy link
Copy Markdown
Author

Pushed a fix commit after an adversarial self-audit of the initial version found three blocking bugs — worth flagging since they'd have crashed on your first load attempt:

  1. Contract-default guard mismatch (the critical one): my injector guards rejected hidden_variant="post_norm", but runtime.draft_mtp resolves None/auto/contract to contract.hidden_variant, which is "post_norm" for a bare MTPContract() — so every draft call raised ValueError. Now the backend accept-and-ignores the variant/concat strings like glm_mtp_patch and step3p5_mtp_patch do (Hy3 has exactly one native draft wiring, so the string is informational).
  2. validate_mtp_support failure: it requires model.mtp.layers to be truthy; the native mlx-lm MTPBlock exposes its decoder as .layer. Added a .layers alias (list wrapping the single layer — a child-module container, not a weight copy).
  3. Registry gaps: hy-v3-mtp was in the catalog but absent from SUPPORTED_ARCH_IDS and the appended-layer branch of _passes_family_runtime_gate — added to both.

Also: depth≥2 now reuses the single NextN layer instead of raising, and dropped a dead contract.note() call.

Added tests/test_hy_v3_mtp_backend.py (4 tests, incl. one that reproduces the exact post_norm crash path through the real contract default). Artifact suite still 76/76 on the v2.0.1 tag.

Caveats unchanged: still not live-tested against the 295B (blocked on hy_v3 reaching your mlx-lm pin), still draft. But the load path is now correct against your runtime's actual call sites, not just my reading of them.

@davidtai

davidtai commented Jul 8, 2026

Copy link
Copy Markdown

Looking forward to this.

@PhilipJohnBasile

Copy link
Copy Markdown
Author

@davidtai thanks — good timing, there's real progress to show, and it sharpens why this backend matters.

Two verified MLX Hy3 artifacts are now public — the actual targets this backend would serve, both running on stock mlx_lm today (AR-only view, num_nextn_predict_layers=0), receipts for every claim in the source repo:

Source + build scripts + receipts: https://github.com/PhilipJohnBasile/hy3-demolition-mlx

Why the MTPLX backend matters — measured, not hand-waved. On an M5 Max 128 GB, mlx-lm's per-token self-speculative MTP loop runs Hy3 at 1.59 tok/s vs 7.43 tok/s AR — 4.7× slower, with exact output parity (receipt: hy3_path_benchmark.json). The NextN heads are correct; the per-token draft→verify loop with cache trims is the bottleneck. That is exactly what MTPLX's batched verify is engineered to fix.

Independent cross-validation that the approach works on this hardware. A sibling project forged its own MTP artifact from Qwen3.6-35B-A3B and served it on MTPLX at 24.7 concurrent turns/min — a tie with MTPLX's own optimized build, same M-series class machine, with better quality. Same runtime family; batched verify rescues MTP. So this isn't speculative — the backend should do for hy_v3 what it already does for Qwen.

PR status (unchanged and ready): the backend is staged and audit-hardened — 76/76 artifact tests on the v2.0.1 tag, the three blocking bugs from my self-audit fixed with regression coverage (tests/test_hy_v3_mtp_backend.py, incl. one reproducing the exact post_norm contract-default crash path). Still honestly draft: not live-tested against the 295B until hy_v3 reaches your mlx-lm pin (ml-explore/mlx-lm#1211 + the MTP surface). The moment it loads I'll run bench + exactness fixtures on the M5 Max and post receipts here.

Happy to walk through the design doc or pair on the integration whenever it's useful. Everything's Apache-2.0.

@PhilipJohnBasile

Copy link
Copy Markdown
Author

Concrete data point from your own tooling, @davidtai / @youssofal: I ran mtplx inspect (release v2.0.1) against the MTP-equipped Hy3 base checkpoint, and the release already recognizes the family — it's only the runtime backend that's missing:

compatibility.recognized:            true
compatibility.mtp_supported:         "recognized"
compatibility.message:  "HY V3 MTP markers recognized, but MTPLX does not
                          yet have a native MLX runtime backend for this family."

So the arch-detection half is done on your side; PR #142 supplies exactly the missing piece — the hy_v3 MLX runtime backend the message points at. (For completeness: my two public fused artifacts, lite-v1 and reap25, inspect as tier: no-MTP — they're AR-only daily-drivers by design; the MTP path is the base checkpoint's NextN sidecar, which is what #142 targets.)

Full mtplx inspect --json receipts are committed in the source repo under eval/receipts/mtplx_inspect_*.json. Happy to forge an MTP-equipped fused artifact for you to test against the moment the backend + the mlx-lm pin line up.

Philip John Basile and others added 2 commits July 8, 2026 15:49
Extends the MTP runtime to load qwen3_5_mtp exports (issue youssofal#147): forge probe
already classifies them qwen3-next-mtp, but the load path had no handler
(ModuleNotFoundError: qwen3_5_mtp). Two-part fix mirroring the step3p5/hy_v3
backends:

- Trunk: the MTP export differs from the AR export only in the top-level
  model_type string (qwen3_5_mtp vs qwen3_5_moe); text_config is identical. A
  sys.modules shim aliases mlx_lm.models.qwen3_5_mtp -> qwen3_5_moe so the
  trunk loads natively.
- Head: one appended NextN predictor (pre_fc_norm_embedding/hidden -> fc ->
  one full-attention qwen3_5.DecoderLayer -> norm -> shared lm_head), grafted
  and wrapped with the mtp_forward/mtp_update_cache/make_mtp_cache surface.

Verified: module tree matches the checkpoint's 46 mtp.* tensors with exact
coverage, loaded strict=True (real Qwen3.6-35B-A3B MTP export). Hermetic unit
tests cover detection/shim/remap. Draft-acceptance runtime contract
(hidden_variant/concat_order) pending a hardware measurement, same maturity as
the hy_v3 backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJenYJzwFH5NTvNc76tTgY
…ified

GPU bring-up on Qwen3.6-35B-A3B (M5 Max) surfaced two issues, both fixed:

1. Trunk double-shift: mlx-lm's qwen3_5 sanitize shifts trunk norms +1.0 when
   mtp.* keys are present. Our export already stores final-convention norms, so
   this double-shifted them (norm mean 0.965 -> 1.965) and the trunk generated
   gibberish. The trunk-load shim now strips mtp.* before sanitize, keying the
   shift only off the (correct) unsanitized-conv1d signal. Trunk now generates
   coherently (Paris/Tokyo).
2. return_hidden path: the wrapper now exposes the pre-final-norm residual by
   temporarily swapping the text model's norm for identity (avoids re-running
   the hybrid linear/full attention loop); logits are bit-identical to the
   plain forward (max|diff| = 0.0).

Verified end-to-end: loads, coherent trunk, MTP head grafts, ~90% 1-step greedy
draft acceptance on a structured smoke (not the 3% donkey band) — confirms
pre-norm + [embedding, hidden] wiring. Unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJenYJzwFH5NTvNc76tTgY
@PhilipJohnBasile

Copy link
Copy Markdown
Author

Pushed an extension: qwen3_5_mtp (Qwen3.5/3.6-MoE) native MTP backend — this also resolves #147 (forge probe recognized qwen3_5_mtp, but the load path had no handler).

Two-part fix, mirroring the step3p5/hy_v3 backends:

  • Trunk: the MTP export differs from the AR export only in the top-level model_type string (qwen3_5_mtp vs qwen3_5_moe) — text_config is identical. A sys.modules shim loads the trunk as qwen3_5_moe. (The shim also strips mtp.* before sanitize — mlx-lm's qwen3_5 sanitize shifts trunk norms +1.0 when mtp weights are present, which double-shifts an already-final-convention export into gibberish. GPU bring-up caught this.)
  • Head: one appended NextN predictor (pre_fc_norm_embedding/hiddenfc → one full-attention qwen3_5.DecoderLayernorm → shared lm_head), grafted with the standard mtp_forward/mtp_update_cache/make_mtp_cache surface.

Hardware-verified on Qwen3.6-35B-A3B (M5 Max 128 GB):

  • 46 mtp.* tensors load with exact coverage (strict=True)
  • trunk generates coherently (Paris/Tokyo); return_hidden logits bit-identical to the plain forward (max|Δ| = 0.0)
  • ~90% 1-step greedy draft acceptance on a structured smoke — well clear of the ~3% "donkey band", confirming the pre_norm + [embedding, hidden] wiring

Hermetic unit tests added (detection/shim/remap). A full acceptance sweep across diverse text is the remaining production-tuning step. Happy to iterate on review.

@PhilipJohnBasile

Copy link
Copy Markdown
Author

@youssofal please push :)

@PhilipJohnBasile

Copy link
Copy Markdown
Author

Ran this branch's hy_v3_mtp_patch.py / backends/hy_v3_mtp.py end-to-end against a real, shipped hy_v3 checkpoint on hardware (hy3-demolition-mlx-reap25-v1-mtp, 87GB, M5 Max 128GB — receipts: https://github.com/PhilipJohnBasile/hy3-demolition-mlx). Found the backend logic itself is sound, but there's a real bug plus several pieces of plumbing missing before mtplx forge build / mtplx tune fully support it. Details in case useful before merge:

1. _MTPLXHyV3Model has no make_cache(). hy_v3 doesn't define one natively (unlike the hybrid-attention Qwen backends), and MTPLXRuntime.make_cache() calls model.make_cache() directly rather than going through mlx_lm.models.cache.make_prompt_cache's fallback. Crashes with AttributeError: '_MTPLXHyV3Model' object has no attribute 'make_cache'. Fix — build the default per-layer KVCache list directly (calling make_prompt_cache(self) recurses, since it dispatches back to this same method):

def make_cache(self):
    from mlx_lm.models.cache import KVCache
    return [KVCache() for _ in self.model.layers]

2. Registry never leaves recognized-backend-pending once a real backend exists. backends/registry.py's "hy-v3-mtp" entry has support_level/runtime_compatibility hardcoded to "recognized-backend-pending" — matches the mtplx inspect output referenced on ml-explore/mlx-lm#1211 ("recognized-backend-pending... the backend PR is staged"). With this branch's backend actually present, flipping to the same shape as the other native-contract-gated entries unblocks it:

support_level="experimental-native-contract-gated",
runtime_compatibility="native-contract-gated",
can_run_verified=True,

runtime.py's load() also needs the dispatch branch added (it currently has no is_hy_v3_mtp_config case, silently falling to the generic inject_mtp_support):

elif is_hy_v3_mtp_config(config):
    mtp_enabled = inject_hy_v3_mtp_support(model, path, config, contract)

3. mtplx tune doesn't know hy_v3 exists as a family. tune_policy_for_model / model_family_from_inspection in backends/descriptors.py only recognize qwen3_5/qwen3_6/gemma4/step/deepseek/glm — anything else reports model_family: "unknown" and the hardcoded "Tune is supported for Qwen 3.5, Qwen 3.6, and Gemma 4 MTPLX models only." message. Needs an hy_v3 branch in both.

4. The family marker gate reuses DeepSeek's naming convention, which doesn't match hy_v3's real layout. _passes_appended_layer_gate looks for markers under mtp.layers.{idx}.*. hy_v3's actual weight keys (checked directly against the shipped checkpoint's model.safetensors.index.json) are flat under mtp.: mtp.enorm.weight, mtp.hnorm.weight, mtp.eh_proj.weight, mtp.final_layernorm.weight, mtp.layer.* — no per-index nesting, no shared_head.*. Needs its own gate function rather than reuse.

5. Real gap, not a quick fix: mtplx tune's depth-sweep path assumes hybrid attention. Once 1–4 are addressed, AR baseline runs clean through mtplx tune, but D1/D2/D3 all fail identically:

AttributeError: 'HYV3Model' object has no attribute 'fa_idx'

generate_mtpkrt.forward_ar_captureforward_with_gdn_capture (gdn_capture.py) indexes the cache via inner.fa_idx — a "full-attention-layer-index" concept specific to Qwen 3.5/3.6's hybrid linear/full-attention layout. hy_v3 is uniform full-attention MoE with no such concept. This capture path needs a non-hybrid branch (or a dedicated hy_v3 forward-capture) rather than a workaround — didn't attempt to fake fa_idx, since a wrong cache-slot index there could silently produce incorrect depth-sweep numbers rather than an honest failure.

Happy to share the exact patched files or re-run against #1 in this list's diff if useful — everything above was verified against the real checkpoint, not synthetic.

PhilipJohnBasile and others added 2 commits July 9, 2026 15:58
- _MTPLXHyV3Model had no make_cache(); hy_v3's plain causal-attention trunk
  doesn't define one natively (unlike the hybrid-attention Qwen backends),
  and MTPLXRuntime.make_cache() calls model.make_cache() directly rather
  than going through mlx_lm's make_prompt_cache fallback. Crashed with
  AttributeError on any forge/tune run. Fixed by building the default
  per-layer KVCache list directly (calling make_prompt_cache(self) here
  would recurse back into this same method).

- _passes_family_runtime_gate routed hy-v3-mtp through
  _passes_appended_layer_gate, which expects DeepSeek/GLM/Step's
  mtp.layers.{idx}.* nesting. Hy3's real MTP block is flat under mtp.
  (mtp.enorm.weight, mtp.hnorm.weight, mtp.eh_proj.weight,
  mtp.final_layernorm.weight, mtp.layer.*), so the gate never actually
  matched. Added a dedicated _passes_hy_v3_gate.

Both verified end-to-end against a real 87GB hy3-demolition-mlx-reap25-v1-mtp
checkpoint on an M5 Max: MTP contract calibration now passes (previously
failed at both points). mtplx tune's D1-D3 depth sweep still fails
separately (forward_with_gdn_capture assumes Qwen's hybrid linear/full
attention fa_idx, which plain hy_v3 doesn't have) -- documented on PR youssofal#142,
intentionally not force-fixed here since a wrong cache-slot guess there
could silently produce incorrect benchmark numbers rather than an honest
failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJenYJzwFH5NTvNc76tTgY
forward_ar_capture unconditionally delegated to forward_with_gdn_capture
(gdn_capture.py), which is built for hybrid-attention architectures
(Qwen3.5/3.6, qwen3_next): it indexes cache[inner.fa_idx] / cache[inner.
ssm_idx] and branches per-layer on layer.is_linear to route GDN (gated-
delta-net) layers through capture kernels. hy_v3 (HYV3Model) is plain
uniform causal attention -- no hybrid layers, no fa_idx/ssm_idx, nothing
to capture -- so every MTP depth-sweep candidate crashed with
AttributeError: 'HYV3Model' object has no attribute 'fa_idx'.

Fix: branch on whether the trunk actually defines fa_idx/ssm_idx. When it
doesn't, use the plain forward_ar path and return an empty captures dict
instead of GDN tensors -- commit_captured_prefix already handles an empty
captures dict correctly by trimming the standard KV cache directly, which
is the right prefix-commit behavior for pure-attention layers. No fa_idx
value is guessed or hardcoded anywhere. The hybrid-attention path is
untouched: models that do define fa_idx/ssm_idx fall through to
forward_with_gdn_capture exactly as before.

Verified end-to-end against the real 87GB hy3-demolition-mlx-reap25-v1-mtp
checkpoint on an M5 Max: D1/D2/D3 all complete with no crash (previously
100% reproducible). Also verified via synthetic unit tests that a fake
hybrid model (with fa_idx/ssm_idx set) still routes into the untouched
original gdn_capture path.

This does NOT mean hy_v3 MTP is fully validated end-to-end -- two separate,
pre-existing issues surfaced during real-hardware testing that this change
does not touch or fix:
  1. D1/D2 generation hit a genuine repetition-loop quality failure
     (a repeated 8-gram detected mid-generation); D3 alone passed the
     quality check. Acceptance rates were also low (11% -> ~1-2% across
     positions), well under what we've measured on other MTP backends.
     Possibly a hidden_variant/concat_order contract mismatch specific to
     hy_v3 -- unconfirmed, needs its own investigation.
  2. The AR-baseline candidate subprocess in the depth-sweep produced no
     output file and no traceback (not an OOM, not visible in system
     logs) -- so multiplier_vs_ar and the resulting no_mtp_depth_beat_ar
     verdict are artifacts of a missing baseline comparison, not evidence
     MTP underperforms AR. This candidate never calls forward_ar_capture
     at all, so it's unrelated to this fix.

Both documented on PR youssofal#142 as separate follow-up items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJenYJzwFH5NTvNc76tTgY
@PhilipJohnBasile

Copy link
Copy Markdown
Author

Pushed a fix (cda4605) for the fa_idx crash mentioned in my earlier comment on this PR. Full picture, including two separate issues it surfaced but does not fix:

Fixed: forward_ar_capture crashed on any non-hybrid-attention model. It unconditionally delegated to forward_with_gdn_capture (gdn_capture.py), which indexes cache[inner.fa_idx] / cache[inner.ssm_idx] and branches per-layer on layer.is_linear — built for Qwen 3.5/3.6's hybrid linear/full-attention layout. hy_v3 is plain uniform causal attention with no hybrid layers, so every mtplx tune MTP-depth candidate (D1/D2/D3) crashed with AttributeError: 'HYV3Model' object has no attribute 'fa_idx'.

Fix branches on whether the trunk actually defines fa_idx/ssm_idx; if not, it uses the plain forward_ar path and returns an empty captures dict (which commit_captured_prefix already handles correctly — falls through to trimming the standard KV cache directly, the right behavior for pure-attention layers). No fa_idx value guessed or hardcoded anywhere — didn't want to risk silently-wrong depth-sweep numbers. The hybrid path is completely untouched (verified with a synthetic fake-hybrid-model unit test that it still routes into the original gdn_capture code unchanged).

Verified on real hardware: ran the full mtplx forge build reproduce against the actual 87GB hy3-demolition-mlx-reap25-v1-mtp checkpoint. D1/D2/D3 all completed with no crash — previously 100% reproducible failure.

But two separate, pre-existing issues surfaced once the crash was out of the way — flagging clearly rather than glossing over them:

  1. Real quality problem on D1/D2. Generated text is on-topic (legitimate Python code continuation, not garbage), but hit a genuine repetition-loop failure — the model got stuck restating the same function signature/docstring 4–5 times verbatim. D3 alone passed the quality check. Acceptance rates were also low across all depths (~11% at position 0, dropping to ~1–2% deeper) — well under what we measured on a different (Qwen) MTP backend earlier this week. Possibly a hidden_variant/concat_order contract mismatch specific to hy_v3's MTP head, or a deeper drafter-quality issue — genuinely unconfirmed, needs separate investigation. Not something this fix touches.

  2. The AR-baseline candidate in the depth-sweep silently produces no output — no crash, no traceback, no OOM signal in the system log, just an empty result file. That candidate never calls forward_ar_capture at all, so it's unrelated to this fix, but it means multiplier_vs_ar: 0.0 and the resulting no_mtp_depth_beat_ar verdict are artifacts of a missing baseline comparison, not real evidence MTP underperforms AR on this model. Worth knowing before drawing conclusions from that verdict field.

Happy to dig into either of those as follow-ups if useful — wanted to get the crash fix in first since it's clean, isolated, and blocking any real signal at all.

@PhilipJohnBasile

Copy link
Copy Markdown
Author

Follow-up with the confirmed numbers on the two issues flagged in my last comment — ran a clean re-test (fresh calibration + full AR/D1/D2/D3 sweep, valid AR baseline this time) to get a real comparison rather than leaving it as "unconfirmed."

AR-baseline candidate: the earlier silent failure (returncode: -9, no output) didn't reproduce on a clean re-run — looks like it was a transient SIGKILL from memory pressure during the calibration→tune handoff (other processes were competing for memory on the test machine at that moment), not a code bug. Re-running with headroom gave a clean AR result.

Full depth-sweep, with a valid AR baseline this time:

depth tok/s vs AR quality_passed acceptance
AR (0) 8.31 1.0×
D1 8.32 1.001× (break-even) ❌ (repetition loop) 11.2%
D2 5.12 0.616× (38% slower) ❌ (repetition loop) 11.0% / 1.8%
D3 6.02 0.725× (27% slower) 10.1% / 1.7% / 0.2%

MTPLX's own verdict message: "MTP measured faster, but the quality gate rejected the fast depth. AR 8.31 tok/s; best MTP D1 8.32 tok/s."

This confirms the repetition-loop is a real MTP-specific defect, not a general checkpoint/prompt quirk — same checkpoint, same prompt, same settings; AR alone passes quality cleanly, only the MTP-drafted depths loop. Given calibrate.json already reported calibration_status: "no_agreement_signal" / best_agreement: 0.0 at every probed depth, that's the most likely root cause — the contract calibration isn't converging on a working drafter configuration for hy_v3, not anything touched by the fa_idx/make_cache fixes.

Net result across two consistent runs: there's no depth where hy_v3 MTP is both fast and quality-clean — the depth that's competitive on speed (D1) fails quality, the depth that passes quality (D3) is 27% slower than AR. Given this matches an independent finding on Hy3's native MTP path too (measured 4.7-13.6x slower in our own testing, unrelated codebase), I'm not planning to chase the calibration-convergence issue further myself — flagging it clearly here in case it's useful signal for anyone who does, since the fa_idx fix at least makes it possible to measure this now, which it wasn't before.

@PhilipJohnBasile

Copy link
Copy Markdown
Author

Cross-link in case useful: there's a parallel investigation of Hy3 MTP on the mainline-track native self-speculative path (not this backend's contract/drafter approach) — ml-explore/mlx-lm#1485 (comment). Their finding: the checkpoint's aggressive quantization (2-3 bit experts in both target and drafter) is the likely driver of low acceptance, not the algorithm — plus a real bug where the old implementation silently forced greedy decoding regardless of requested temperature, understating acceptance for any real (temp>0) usage.

That greedy-forcing bug doesn't apply here (this backend's failure mode is calibration_status: no_agreement_signal — the contract never converges, a different mechanism than a sampling bug), but the checkpoint-quantization angle may be worth keeping in mind: this MTPLX run and their PR both used the same w2q3exp (2-3 bit expert) release.

@PhilipJohnBasile

Copy link
Copy Markdown
Author

Aside from the fa_idx work above — a user of this backend (via the mtplx CLI/app) reported the CLI feeling ~10x faster than the GUI, so I ran a controlled comparison in case it's useful signal for anyone chasing serving-path overhead.

Setup: Qwen3.6-35B-A3B-Mimosa-MTP-4bit (a small, fully mtplx-native model — verified-native per mtplx inspect), single-shot 64-token completions, same prompt, cold-ish server start each time (M5 Max 128GB).

path config tok/s vs CLI
mtplx ask (CLI, in-process) sustained, MTP on 82.7 1.0x
HTTP /v1/chat/completions (what the GUI's MTPLXChatClient hits) sustained, MTP on 76.8 0.93x
HTTP sustained, MTP off (AR) 56.9 0.69x
HTTP stable profile, MTP on 43.4 0.52x
HTTP turbo profile, MTP on 36.6 0.44x

Couldn't reproduce a 10x gap. The CLI-vs-server protocol overhead is ~7% — expected, since ChatClient.swift posts to the same local daemon ask calls internally (confirmed by reading MTPLXChatClient.swift, not just inferring it). MTP-on-vs-off is ~1.35x here; profile choice (stable/sustained/turbo) spans up to ~2.1x on a single request, though that's likely inflated for turbo specifically since it's documented as the verify-kernel-compiled fast path and a one-shot request doesn't amortize the compile cost — worth re-checking turbo over a longer/warm run before trusting that number.

Given mtplx start's onboarding asks "mode" (Sustained/Turbo/Sustained Max/Burst) and "where" (Web/CLI/Pi/...) as two independent questions, and defaults fan_mode to default (unboosted) unless a max-fan mode is explicitly chosen — my best guess is the reported 10x came from two separate onboarding sessions landing on different profile/fan choices, not the GUI vs CLI surface itself. But I couldn't fully confirm that without the original reporter's exact config, and I didn't test: the native SwiftUI app's actual request pipeline (only raw HTTP, which the app itself also uses per its own source), or thermal throttling over a genuinely long/sustained session (my tests were all short one-shot bursts). Flagging in case someone with more surface area on the scheduler/thermal code wants to chase it further.

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