|
| 1 | +//! Head-internal MTP C4 PoC — the decisive accept-lever test for dense Qwen. |
| 2 | +//! |
| 3 | +//! All offline hidden correctors (diagonal, Procrustes) and the frozen-hidden |
| 4 | +//! lm_head C4 are DEAD: the head's softmax is insensitive to hidden-L2, and a |
| 5 | +//! logit re-aim of a frozen post-block hidden can't generalize. The ONE untested |
| 6 | +//! lever is a LoRA trained INSIDE the block (on eh_proj/mtp.fc), so gradients |
| 7 | +//! reshape the hidden through the head's own attention + MLP against the trunk's |
| 8 | +//! token (`Qwen35MtpBlock::forward_train_lora`). Feasibility (autograd flows |
| 9 | +//! through quantized_matmul + fast::sdpa) proven by `mtp_autograd_probe`. |
| 10 | +//! |
| 11 | +//! This runs the REAL 27B trunk + MTP head: collect k=0 (embed, h_pre, trunk- |
| 12 | +//! target) calib over a greedy decode, train the eh_proj LoRA, report in-sample |
| 13 | +//! accept before/after. DECISION GATE: if accept doesn't move → the single head |
| 14 | +//! is the ceiling (fold). If it moves → next: held-out generalization test. |
| 15 | +//! |
| 16 | +//! Run (36GB Mac, ~20GB peak): |
| 17 | +//! LUMEN_MLX_BACKEND=native \ |
| 18 | +//! cargo run --release -p lumen-mlx --features mlx-native \ |
| 19 | +//! --example bench_mtp_headinternal_poc |
| 20 | +
|
| 21 | +use anyhow::{Result, anyhow}; |
| 22 | +use lumen_mlx::{ |
| 23 | + HiTrainCfg, MlxBackend, MtpLoadQuant, MtpLoraPos, MtpMlpConfig, MtpMoeConfig, Qwen35MtpDims, |
| 24 | + load_block_from_hf, |
| 25 | +}; |
| 26 | + |
| 27 | +fn env_usize(k: &str, d: usize) -> usize { |
| 28 | + std::env::var(k) |
| 29 | + .ok() |
| 30 | + .and_then(|s| s.parse().ok()) |
| 31 | + .unwrap_or(d) |
| 32 | +} |
| 33 | + |
| 34 | +fn env_f32(k: &str, d: f32) -> f32 { |
| 35 | + std::env::var(k) |
| 36 | + .ok() |
| 37 | + .and_then(|s| s.parse().ok()) |
| 38 | + .unwrap_or(d) |
| 39 | +} |
| 40 | + |
| 41 | +fn main() -> Result<()> { |
| 42 | + unsafe { |
| 43 | + if std::env::var("LUMEN_MLX_BACKEND").is_err() { |
| 44 | + std::env::set_var("LUMEN_MLX_BACKEND", "native"); |
| 45 | + } |
| 46 | + } |
| 47 | + let model_id = std::env::var("MODEL_ID").unwrap_or_else(|_| { |
| 48 | + format!( |
| 49 | + "{}/models/Qwen3.6-27B-MTPLX-Speed", |
| 50 | + std::env::var("HOME").unwrap_or_default() |
| 51 | + ) |
| 52 | + }); |
| 53 | + // The 27B MTP head lives in `<model>/mtp/weights.safetensors` → the loader's |
| 54 | + // sidecar path. hf_path defaults to the model dir itself. |
| 55 | + let hf_dir = std::env::var("LUMEN_QWEN35_HF_ORIGINAL").unwrap_or_else(|_| model_id.clone()); |
| 56 | + let hf_path = std::path::PathBuf::from(&hf_dir); |
| 57 | + |
| 58 | + let calib_gen = env_usize("CALIB_GEN", 512); |
| 59 | + let rank = env_usize("RANK", 8); |
| 60 | + let steps = env_usize("STEPS", 120); |
| 61 | + let lr: f32 = std::env::var("LR") |
| 62 | + .ok() |
| 63 | + .and_then(|v| v.parse().ok()) |
| 64 | + .unwrap_or(0.02); |
| 65 | + let k = env_usize("K", 1); |
| 66 | + let weight_decay = env_f32("WD", 0.0); |
| 67 | + let kl = std::env::var("HI_OBJECTIVE") |
| 68 | + .map(|v| v.eq_ignore_ascii_case("kl")) |
| 69 | + .unwrap_or(false); |
| 70 | + let topk = env_usize("HI_TOPK", 16); |
| 71 | + // HI_POSITIONS: comma list of {eh,q,k,v,o,gate,up,down}. Default = eh only. |
| 72 | + let positions: Vec<MtpLoraPos> = std::env::var("HI_POSITIONS") |
| 73 | + .ok() |
| 74 | + .map(|s| { |
| 75 | + s.split(',') |
| 76 | + .filter_map(|p| MtpLoraPos::parse(p)) |
| 77 | + .collect::<Vec<_>>() |
| 78 | + }) |
| 79 | + .filter(|v| !v.is_empty()) |
| 80 | + .unwrap_or_else(|| vec![MtpLoraPos::Eh]); |
| 81 | + |
| 82 | + // ── config-driven dims (mirrors bench_qwen35_mtp_realprompt) ── |
| 83 | + let cfg_path = std::path::Path::new(&model_id).join("config.json"); |
| 84 | + let cfg: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&cfg_path)?)?; |
| 85 | + let tc = cfg.get("text_config").unwrap_or(&cfg); |
| 86 | + let gi = |k: &str| tc.get(k).and_then(|v| v.as_u64()).map(|v| v as usize); |
| 87 | + let gf = |k: &str| tc.get(k).and_then(|v| v.as_f64()); |
| 88 | + let rp = tc.get("rope_parameters"); |
| 89 | + let grp = |k: &str| rp.and_then(|r| r.get(k)).and_then(|v| v.as_f64()); |
| 90 | + let hidden = gi("hidden_size").ok_or_else(|| anyhow!("config missing hidden_size"))?; |
| 91 | + let head_dim = gi("head_dim").unwrap_or(128); |
| 92 | + let partial = gf("partial_rotary_factor") |
| 93 | + .or_else(|| grp("partial_rotary_factor")) |
| 94 | + .unwrap_or(0.25); |
| 95 | + let n_experts = gi("num_experts").unwrap_or(0); |
| 96 | + let dims = Qwen35MtpDims { |
| 97 | + hidden_size: hidden, |
| 98 | + num_attention_heads: gi("num_attention_heads").unwrap_or(32), |
| 99 | + num_key_value_heads: gi("num_key_value_heads").unwrap_or(8), |
| 100 | + head_dim, |
| 101 | + rope_theta: gf("rope_theta") |
| 102 | + .or_else(|| grp("rope_theta")) |
| 103 | + .unwrap_or(1.0e7) as f32, |
| 104 | + rope_dim: (head_dim as f64 * partial).round() as usize, |
| 105 | + rms_norm_eps: gf("rms_norm_eps").unwrap_or(1e-6) as f32, |
| 106 | + attn_output_gate: true, |
| 107 | + }; |
| 108 | + let mlp_cfg = if n_experts > 0 { |
| 109 | + MtpMlpConfig::Moe(MtpMoeConfig { |
| 110 | + num_experts: n_experts as i32, |
| 111 | + num_experts_per_tok: gi("num_experts_per_tok").unwrap_or(8) as i32, |
| 112 | + moe_intermediate_size: gi("moe_intermediate_size").unwrap_or(512), |
| 113 | + shared_expert_intermediate_size: gi("shared_expert_intermediate_size").unwrap_or(512), |
| 114 | + norm_topk_prob: true, |
| 115 | + }) |
| 116 | + } else { |
| 117 | + MtpMlpConfig::Dense { |
| 118 | + intermediate_size: gi("intermediate_size") |
| 119 | + .ok_or_else(|| anyhow!("dense config missing intermediate_size"))?, |
| 120 | + } |
| 121 | + }; |
| 122 | + println!( |
| 123 | + "=== head-internal MTP C4 PoC ===\nmodel: {model_id}\nconfig: hidden={hidden} heads={} kv={} head_dim={head_dim} mlp={}", |
| 124 | + dims.num_attention_heads, |
| 125 | + dims.num_key_value_heads, |
| 126 | + if n_experts > 0 { "MoE" } else { "Dense" } |
| 127 | + ); |
| 128 | + let pos_str = positions |
| 129 | + .iter() |
| 130 | + .map(|p| p.as_str()) |
| 131 | + .collect::<Vec<_>>() |
| 132 | + .join("+"); |
| 133 | + println!( |
| 134 | + "calib_gen={calib_gen} rank={rank} steps={steps} lr={lr} wd={weight_decay} K={k} positions={pos_str} objective={} topk={topk}", |
| 135 | + if kl { "kl" } else { "ce" } |
| 136 | + ); |
| 137 | + |
| 138 | + let mut backend = MlxBackend::load(&model_id)?; |
| 139 | + |
| 140 | + // Diverse calib corpus so the LoRA learns a generalizable correction (not |
| 141 | + // one prompt's tokens). The train/held-out split is a sequential 80/20 tail, |
| 142 | + // so with N prompts (rows contiguous per prompt) the held-out 20% is the |
| 143 | + // LAST ~N/5 *entire* prompts — a genuine CROSS-PROMPT generalization test |
| 144 | + // (no intra-prompt leakage). Keep this list large + topically diverse. |
| 145 | + // Encoded before the mutable qwen borrow. |
| 146 | + const TOPICS: &[&str] = &[ |
| 147 | + "<|im_start|>user\nExplain how a hash map achieves average O(1) lookup and what causes collisions.<|im_end|>\n<|im_start|>assistant\n", |
| 148 | + "<|im_start|>user\nDescribe how photosynthesis converts sunlight into chemical energy.<|im_end|>\n<|im_start|>assistant\n", |
| 149 | + "<|im_start|>user\nWrite a short story about a lighthouse keeper who finds a message in a bottle.<|im_end|>\n<|im_start|>assistant\n", |
| 150 | + "<|im_start|>user\nExplain the difference between TCP and UDP and when each is preferred.<|im_end|>\n<|im_start|>assistant\n", |
| 151 | + "<|im_start|>user\nHow does a transformer use self-attention to process sequences?<|im_end|>\n<|im_start|>assistant\n", |
| 152 | + "<|im_start|>user\nSummarize the causes of the fall of the Roman Empire.<|im_end|>\n<|im_start|>assistant\n", |
| 153 | + "<|im_start|>user\nExplain compound interest and its effect on long-term savings.<|im_end|>\n<|im_start|>assistant\n", |
| 154 | + "<|im_start|>user\nDescribe how vaccines train the immune system to recognize pathogens.<|im_end|>\n<|im_start|>assistant\n", |
| 155 | + "<|im_start|>user\nWrite a Python function that merges two sorted lists into one sorted list.<|im_end|>\n<|im_start|>assistant\n", |
| 156 | + "<|im_start|>user\nExplain what a database index is and the tradeoffs of adding one.<|im_end|>\n<|im_start|>assistant\n", |
| 157 | + "<|im_start|>user\nDescribe the water cycle and the role of evaporation and condensation.<|im_end|>\n<|im_start|>assistant\n", |
| 158 | + "<|im_start|>user\nWhat is the difference between supervised and unsupervised learning?<|im_end|>\n<|im_start|>assistant\n", |
| 159 | + "<|im_start|>user\nExplain how garbage collection works in a managed runtime.<|im_end|>\n<|im_start|>assistant\n", |
| 160 | + "<|im_start|>user\nDescribe the causes and effects of inflation in an economy.<|im_end|>\n<|im_start|>assistant\n", |
| 161 | + "<|im_start|>user\nWrite a haiku about the changing of the seasons.<|im_end|>\n<|im_start|>assistant\n", |
| 162 | + "<|im_start|>user\nExplain how DNS resolves a domain name to an IP address.<|im_end|>\n<|im_start|>assistant\n", |
| 163 | + "<|im_start|>user\nDescribe how a four-stroke internal combustion engine works.<|im_end|>\n<|im_start|>assistant\n", |
| 164 | + "<|im_start|>user\nExplain the concept of recursion with a simple example.<|im_end|>\n<|im_start|>assistant\n", |
| 165 | + "<|im_start|>user\nSummarize the key ideas of plate tectonics.<|im_end|>\n<|im_start|>assistant\n", |
| 166 | + "<|im_start|>user\nWhat are the main differences between HTTP/1.1 and HTTP/2?<|im_end|>\n<|im_start|>assistant\n", |
| 167 | + "<|im_start|>user\nExplain how a binary search tree keeps lookups logarithmic.<|im_end|>\n<|im_start|>assistant\n", |
| 168 | + "<|im_start|>user\nDescribe the role of mitochondria in a cell.<|im_end|>\n<|im_start|>assistant\n", |
| 169 | + "<|im_start|>user\nExplain the tradeoffs between optimistic and pessimistic locking.<|im_end|>\n<|im_start|>assistant\n", |
| 170 | + "<|im_start|>user\nWrite a brief explanation of how rainbows form.<|im_end|>\n<|im_start|>assistant\n", |
| 171 | + ]; |
| 172 | + let calib_prompts: Vec<Vec<u32>> = TOPICS |
| 173 | + .iter() |
| 174 | + .map(|t| backend.encode(t)) |
| 175 | + .collect::<Result<_>>()?; |
| 176 | + |
| 177 | + let qwen = backend |
| 178 | + .as_qwen35_mut() |
| 179 | + .ok_or_else(|| anyhow!("not Qwen3.5 family"))?; |
| 180 | + let block = load_block_from_hf( |
| 181 | + &hf_path, |
| 182 | + dims, |
| 183 | + mlp_cfg, |
| 184 | + MtpLoadQuant::Affine4 { group_size: 64 }, |
| 185 | + )?; |
| 186 | + qwen.enable_qwen35_mtp(block)?; |
| 187 | + if !qwen.qwen35_mtp_enabled() { |
| 188 | + return Err(anyhow!("mtp enable failed")); |
| 189 | + } |
| 190 | + println!("trunk + mtp head loaded."); |
| 191 | + |
| 192 | + // Collect k=0 (embed, h_pre, trunk-target) calib over a greedy decode. |
| 193 | + qwen.qwen35_enable_mtp_hi_calib()?; |
| 194 | + let per = (calib_gen / calib_prompts.len()).max(8); |
| 195 | + for (i, cp) in calib_prompts.iter().enumerate() { |
| 196 | + let seq: u64 = 5000 + i as u64; |
| 197 | + let (mut last, _) = qwen.prefill(seq, cp)?; |
| 198 | + let mut emitted = 0usize; |
| 199 | + while emitted < per { |
| 200 | + let out = qwen.qwen35_mtp_step(seq, last, k, 0.0, 1.0)?; |
| 201 | + emitted += out.committed.len(); |
| 202 | + last = *out.committed.last().expect("commit non-empty"); |
| 203 | + } |
| 204 | + qwen.remove_seq(seq)?; |
| 205 | + eprintln!( |
| 206 | + " calib prompt {}/{}: ~{emitted} cycles", |
| 207 | + i + 1, |
| 208 | + calib_prompts.len() |
| 209 | + ); |
| 210 | + } |
| 211 | + |
| 212 | + // Train the head-internal LoRA(s) + report the gate. |
| 213 | + let cfg = HiTrainCfg { |
| 214 | + rank, |
| 215 | + steps, |
| 216 | + lr, |
| 217 | + weight_decay, |
| 218 | + positions, |
| 219 | + kl, |
| 220 | + topk, |
| 221 | + seed: env_usize("SEED", 1234) as u64, |
| 222 | + }; |
| 223 | + // `after` is the HONEST stable tail-mean (not the selection-biased max — see |
| 224 | + // train_mtp_headinternal). 1σ of the held-out estimate ≈ sqrt(p(1-p)/n_ho); |
| 225 | + // require Δ ≥ ~2σ to claim a real (non-noise) cross-prompt lift. With |
| 226 | + // n_ho≈90 and p≈0.75, 2σ ≈ 0.09 → the bar is deliberately strict. |
| 227 | + let (before, after) = qwen.qwen35_train_mtp_headinternal(cfg)?; |
| 228 | + let delta = after - before; |
| 229 | + println!("\n════════════════════════════════════════════"); |
| 230 | + println!(" HELD-OUT accept (stable tail-mean): {before:.3} → {after:.3} (Δ {delta:+.3})"); |
| 231 | + if delta >= 0.05 { |
| 232 | + println!( |
| 233 | + " ✓ GENERALIZES — stable cross-prompt lift clear of noise. Worth productionizing." |
| 234 | + ); |
| 235 | + } else if delta > 0.0 { |
| 236 | + println!( |
| 237 | + " ~ MARGINAL — within ~1-2σ of held-out noise; not a reliable lift (see σ line above)." |
| 238 | + ); |
| 239 | + } else { |
| 240 | + println!(" ✗ NO LIFT — tail-mean ≤ baseline (overfit / collapse). Fold, like lm_head-C4."); |
| 241 | + } |
| 242 | + println!("════════════════════════════════════════════"); |
| 243 | + Ok(()) |
| 244 | +} |
0 commit comments