Repository navigation
metal: the Qwen3.8 MTP sidecar opens beside the model and runs one draft step - #1144
Conversation
…aft step generate_metal --mtp SIDECAR opens an MLX MTP sidecar (one draft layer: the embedding and hidden norms, fc, a dense attention layer and the shared head) beside the main model, as a 65th layer outside the plan with its own buffers and KV cache, so a session without it keeps its plain program: - Session::open_mtp reads the sidecar's safetensors and repacks it to A4; a sidecar of another model is refused by name (hidden, vocab, tensor set, quant, shape). BF16 vectors are read byte-wise, aligned or not. - Session::draft_step runs embed, mtp_join, fc, the layer and the head in one command buffer, joining the hidden row before or after the output norm (--mtp-hidden pre|post). - mtp_join (norm.metal): each half normed by its own gain, the hidden half first through the output norm when post; the kernels gate's mtp_join, mtp_join_nan and mtp_join_refused clauses hold it to the composed norms' bits, with three mutants. - --mtp-check DIR/FAMILY holds the draft logits to tools/mlx-logits.py mtp-dump's reference (band 10 · 2.8e-4 · the row's largest logit) and --draft-step-times N times the step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjHkkntc2L1xRN21xB39mj
…r spread does not end it check-recipes read 820 to 1215 s on green runs and was killed at 1230 s on #1144 by the 1200 s default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjHkkoJGJqgEEVx9yv9K5d
There was a problem hiding this comment.
Verdict: MUST_FIX: the dump reader truncates token IDs; the change also duplicates existing helpers and adds unused API and fields.
Must fix
1. Refuse token IDs that do not fit u32
crates/metal/src/arch/qwen3moe/mtp_check.rs:111 casts parsed u64 values to u32. In --mtp-check, changing a valid dump's ID 5 to 4294967301 leaves its float checksum valid; read returns 5, and check feeds it to draft_step (line 244) and step (line 327). Verification can pass for a different sequence than the header names.
Reject overflow before verification. Add an overflow-ID refusal test. Replace list:
fn list(v: &str) -> Result<Vec<u64>, String> {
if v.is_empty() { return Ok(Vec::new()); }
v.split(',')
.map(|x| x.parse::<u32>().map(u64::from).map_err(|e| format!("{x:?}: {e}")))
.collect()
}2. Reuse the affine tensor normalizer (R14)
crates/metal/src/arch/qwen3moe/session/mtp.rs:443 repeats crates/hf/src/modelopt.rs:1804's affine packing. Its public normalize entry also widens BF16 vectors and rejects non-finite scales and biases.
Reuse that owner before the copies diverge. Replace Sidecar::part; delete a4_blocks, vector, drawn and the two repacking tests:
fn part(&self, name: &str) -> Result<Part<'static>, SessionError> {
use hf::modelopt::{normalize, Plain, Use};
use hf::quant_config::{Format, Placed};
let quant = self.st.entry(&format!("{name}.scales")).is_some();
let weight = if quant { format!("{name}.weight") } else { name.to_owned() };
let placed = Placed {
format: if quant { Format::MlxAffine { bits: 4, group: GROUP } } else { Format::Bf16 },
companions: if quant {
vec![format!("{name}.scales"), format!("{name}.biases")]
} else { vec![] },
};
let normal = normalize(
|s| self.st.raw(s), &[(&weight, &placed)], Use::Matrix(Plain::F32),
).map_err(|e| mtp_refuse(e.to_string()))?;
let info = TensorInfo {
name: name.to_owned(), dims: normal.dims,
ty: if quant { GgmlType::MLX_A4 } else { GgmlType::F32 },
offset: 0, nbytes: normal.bytes.len() as u64,
};
Ok(Part::made(info, normal.bytes, None))
}3. Reuse the existing buffer and checksum helpers (R14)
crates/metal/src/arch/qwen3moe/session/mtp.rs:873 and its nested all repeat crates/metal/src/arch/qwen3moe/arena.rs:781 and crates/metal/src/arch/qwen3moe/ops.rs:87. The checksum at crates/metal/src/arch/qwen3moe/mtp_check.rs:51 repeats crates/metal/src/bin/gate_metal_qwen35moe_e2e/mlx.rs:61.
Share these owners before the copies diverge. Expose the existing buffer helpers:
pub(super) fn bytes_of<T: Elem>(b: &MetalBuffer<T>) -> u64 {
bytes_of_len::<T>(b.pages().len().get())
}pub(super) fn all<T: Elem>(b: &MetalBuffer<T>) -> Result<Window<'_, T, MetalDevice>, DeviceError> {
b.window(Elems(0), b.len())
}Delete mtp.rs's bytes_of and nested all; add:
use super::super::arena::bytes_of;
use super::super::ops::all;Keep the checksum in mtp_check.rs as:
pub fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}Delete the gate's checksum definition and import it:
use bloomery_metal::arch::qwen3moe::mtp_check::fnv1a;4. Remove unused queries and stored fields
crates/metal/src/arch/qwen3moe/session/mtp.rs:685's has_mtp and line 847's mtp_bytes have no callers. Only mtp_bytes reads Mtp.weight_bytes and Mtp.kv_bytes. crates/metal/src/arch/qwen3moe/mtp_check.rs:68's Dump.target and Dump.picks are never read.
Remove these before callers depend on them: delete both query methods, both stored counters, both Dump fields and their initializer entries. Keep open_mtp's returned totals and the reader's pick-list validation.
Checked
Duplicates: read both sides of the affine, byte-count, window and checksum copies; existing owners can replace them without new helpers.
Complexity: inspected reader validation and sidecar loading/dispatch; no finding rests on complexity alone.
Silent failure: followed truncated IDs through the reader to both step consumers.
Tests: reader assertions cover checksum and length refusals; no overflow-ID assertion exists. Inline replacement is valid Rust on an added line.
Gates and exclusions: no escape-catalog pattern or ledger lesson applies; this is a full review.
Notes
Metal gates were not rerun during validation.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
| return Ok(Vec::new()); | ||
| } | ||
| v.split(',') | ||
| .map(|x| x.parse::<u64>().map_err(|e| format!("{x:?}: {e}"))) |
There was a problem hiding this comment.
Reject integers above u32::MAX before the reader can truncate token IDs.
| .map(|x| x.parse::<u64>().map_err(|e| format!("{x:?}: {e}"))) | |
| .map(|x| x.parse::<u32>().map(u64::from).map_err(|e| format!("{x:?}: {e}"))) |
…its dump reader refuses ids past u32 The review's four must-fix findings on the sidecar: - mtp_check's reader parsed u64 and cast to u32, so an id of 4294967301 read as 5 under a valid checksum. It now refuses an id, position, target, pre or post value that does not fit u32, by name. - Sidecar::part repacked MLX affine 4-bit and BF16 tensors itself. It now calls hf::modelopt::normalize, as the session's own folder does. Byte-equal on valid input: a 4-bit weight, and an aligned and an unaligned BF16 vector. A non-finite scale, bias or vector value, which the copy accepted, is now refused by name. - mtp.rs's bytes_of and nested all are the arena's and ops' (now pub(super)). The e2e gate imports mtp_check's fnv1a instead of its own copy. - has_mtp, mtp_bytes, Mtp.weight_bytes/kv_bytes and Dump.target/picks had no readers and are gone. open_mtp still returns the totals generate_metal prints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjHkkntc2L1xRN21xB39mj
There was a problem hiding this comment.
Since the last review:
- Token ID overflow: fixed. Parsing now rejects values outside u32; the reader test covers overflowing IDs and picks.
- Affine packing duplication: fixed. The sidecar delegates to
hf::modelopt::normalize. - Buffer and checksum duplication: fixed. Both callers reuse the existing owners.
- Unused queries and fields: fixed. The queries, stored counters and unused dump fields are removed.
Verdict: CLEAN: all four earlier findings are fixed, and the new commits introduce no must-fix defect.
Checked
Duplicates: 3 new test/helper definitions checked, none duplicated. All 3 dryer pairs read: two context-specific error constructors and one clause-table shape; none warrants another owner.
Complexity: crapper's read, Sidecar::open, open_mtp and encode_draft gain no branches in this delta. Its other 9 flagged functions are unchanged and outside this re-review's scope.
Silent failure: 3 production paths followed. Overflowing IDs return a field-named error before either step consumes them. Invalid affine planes and non-finite vectors return tensor-named errors through open_mtp. Two ignored test-directory cleanup results do not affect verification.
Tests: static mutation check of all 3 changed tests. Restoring the truncating parse at mtp_check.rs:82 makes the overflow assertions fail. Swapping companions at session/mtp.rs:431 fails the block-byte assertions. Restoring unchecked byte copying in Sidecar::part fails the non-finite refusal assertions.
Gates: no band or production clause moved. The replacement tests exercise the Part adapter; the shared normalizer retains its packing and malformed-plane coverage.
Escape catalog: no C1–C4 pattern matches.
Adversarial checks: overflow IDs, non-finite planes, unaligned BF16 vectors and missing companions are blocked. The shared window helper preserves the draft's buffer ranges; no launches or copies are added.
Notes
Metal tests and gates were not rerun in this environment. The PR supplies Mac gate output.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
What and why
generate_metal --mtp SIDECARopens an MLX MTP sidecar beside the main model. The sidecar is one draft layer: the embedding and hidden norms,fc, a dense attention layer and the shared head. It is a 65th layer outside the plan, with its own buffers and KV cache, so a session without it keeps its plain program.Session::draft_stepruns embed,mtp_join,fc, the layer and the head in one command buffer.--mtp-checkholds its logits totools/mlx-logits.py mtp-dump's reference. This is step R3 of Metal MTP. The decode loop does not call the draft yet. R4 plugs it into the shared draft layer (runtimeVerify/Draft, #1135) once that layer is lifted.Release notes: internal — the Metal backend can load a Qwen3.8 MTP sidecar and check its draft step; decoding does not use it yet.
Code shape
mtp_joinis the one new kernel. The draft policy is not copied fromcrates/app; R4 uses the lifted common owner.mtp_join_nan). Bad geometry is refused by name (mtp_join_refused).10 · Δ ·the row's largest logit. Δ = 2.8e-4 comes from the BF16 rounding model the e2e gate's bands use, and the derivation is inmtp_check.rs's module doc.cargo fmt,just mac-metal-lintclean.Duplication found or removed: none.
What I ran
just affected origin/mainselects 0 box gates. Every changed file is incrates/metal, plustools/mlx-logits.py,docs/mac.mdandCargo.lock.Second Mac (M1 Pro, macOS 26), on this branch's head rebased on main 0d6f89c:
Plain-decode compare against main (
tools/metal-compare.sh 0d6f89cb8 --here <this tree>, second Mac):headnorm_rowshas its own source unchanged.mtp_joinwas appended to the samenorm.metal, and only the library's metadata sections moved. The device code is equal bytext_sha256.arena.rs,in_order), so their allocation order, and therefore the trace's#N, can differ between any two runs. A follow-up makes the trace name a weight buffer independently of the load order, so this compare can return identical (rc 0).After the first review (b52222d)
The review's four must-fix findings are fixed.
4294967301read as 5 under a valid checksum. The reader test covers4294967301andpre 4294967296. FAIL-first: with the old cast, the id case reads Ok.Sidecar::partuses the checkpoint owner. It now callshf::modelopt::normalize, as the session's own folder does.part.bytes_of,allandfnv1aeach had a second copy; each copy was checked equal to its owner first. The e2e gate importsmtp_check::fnv1a.has_mtp,mtp_bytes, the twoMtpcounters andDump.target/Dump.pickshad no readers.Device re-run on b52222d (second Mac). Every line equals the run above, including the worst errors, so the sidecar's bytes did not move:
The compare as a proof, after #1146
#1146 made the trace's buffer numbering independent of the parallel load. This branch was rebased locally on main 5591bcd, which includes #1146, and compared on the second Mac:
Both traces are identical. The plain decode's dispatch is unchanged by this PR; it adds one pipeline.
AI use: Claude Code wrote this change (a Sonnet round, reviewed and run on the device by the lead session).
🤖 Generated with Claude Code
https://claude.ai/code/session_01CjHkkntc2L1xRN21xB39mj