Add hy_v3 (Tencent Hy3) native MTP backend#142
Conversation
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.
|
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:
Also: depth≥2 now reuses the single NextN layer instead of raising, and dropped a dead Added 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. |
|
Looking forward to this. |
|
@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
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: Independent cross-validation that the approach works on this hardware. A sibling project forged its own MTP artifact from 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 ( Happy to walk through the design doc or pair on the integration whenever it's useful. Everything's Apache-2.0. |
|
Concrete data point from your own tooling, @davidtai / @youssofal: I ran So the arch-detection half is done on your side; PR #142 supplies exactly the missing piece — the Full |
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
|
Pushed an extension: Two-part fix, mirroring the step3p5/hy_v3 backends:
Hardware-verified on Qwen3.6-35B-A3B (M5 Max 128 GB):
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. |
|
@youssofal please push :) |
|
Ran this branch's 1. def make_cache(self):
from mlx_lm.models.cache import KVCache
return [KVCache() for _ in self.model.layers]2. Registry never leaves support_level="experimental-native-contract-gated",
runtime_compatibility="native-contract-gated",
can_run_verified=True,
elif is_hy_v3_mtp_config(config):
mtp_enabled = inject_hy_v3_mtp_support(model, path, config, contract)3. 4. The family marker gate reuses DeepSeek's naming convention, which doesn't match hy_v3's real layout. 5. Real gap, not a quick fix:
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. |
- _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
|
Pushed a fix ( Fixed: Fix branches on whether the trunk actually defines Verified on real hardware: ran the full But two separate, pre-existing issues surfaced once the crash was out of the way — flagging clearly rather than glossing over them:
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. |
|
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 ( Full depth-sweep, with a valid AR baseline this time:
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 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 |
|
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 |
|
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:
Couldn't reproduce a 10x gap. The CLI-vs-server protocol overhead is ~7% — expected, since Given |
What
Adds the
hy_v3(Tencent Hy3) native MTP backend — closes therecognized-backend-pendinggap forhy-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 ingeneration.py)mtplx/hy_v3_mtp_patch.py—is_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_hiddenon the trunk forward) as a subclass wrapper — no weight rewriting. Guards: raises clearly on AR-only exports; rejects non-embedding_hiddenconcat order and non-pre_normhidden variants (Hy3's fixed semantics); depth 1 only.mtplx/runtime.pydispatch, import-smoke registration, registry upgrade toexperimental-native-contract-gated, pending-markers test updated.Verification
v2.0.1tag;tests/test_artifacts.py76/76 pass with the change.make_mtp_cache, guard rejections) exercised against a tiny randomly-initialized hy_v3 model — shapes and guards verified.What this deliberately does NOT claim
hy_v3reaching the pinned mlx-lm (Add Hy3 ml-explore/mlx-lm#1211 + the MTP-surface follow-up). Draft PR until then.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 amodel-mtp.safetensorssidecar namedmtp.*(notmodel.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