True2456's picture
Upload folder using huggingface_hub
319c386 verified
|
Raw
History Blame Contribute Delete
21.8 kB

Step-3.7-Flash REAP: Measured Findings

Model: Step-3.7-Flash — StepFun 198B VLM (196B language backbone + 1.8B ViT), 45 decoder layers (dense 0–2, MoE 3–44 = 42 layers), 288 experts/layer, top-k=8, 1 shared expert, sigmoid router with e_score_correction_bias, SwiGLU, RMSNorm. 375 GB BF16 on disk. Experts = 97% of parameters.

Hardware: M5 Max, 128 GiB unified memory (iogpu.wired_limit_mb = 115 GiB).

Scope: Everything below is measured, not asserted. Where a conclusion was later overturned by better data, both the wrong and corrected versions are shown — several "obvious" fixes measured worse than what they replaced.


TL;DR — the three measured effects, ranked

# Finding Magnitude Status
1 Vision blindness — text-only saliency can't see vision experts 9.42% of vision saliency mass sits on pruned experts; verdict SEPARATE (ρ=0.381) ⚠️ unaddressed
2 Layer-adaptive allocation — uniform per-layer keep counts are suboptimal 5.3% less discarded mass at p15, free from existing data available, unbuilt
3 Truncation fix — 384-token head truncation dropped answers 1.7% of experts changed in the final plan ✅ done, minor

The vision issue is the largest open problem by a wide margin.


1. The reap decision: 15% (keep 245/288)

Discarded router-saliency mass by rung (5k prompts; 1k and 5k agreed to within 0.05%, so saliency stabilises by ~1k prompts):

Reap Keep/288 Discarded mass (mean / worst layer) 4-bit size
10% 260 3.0% / 4.0% ~89 GB
15% 245 5.0% / 6.8% ~84 GB
20% 231 7.1% / 9.6% ~80 GB
25% 216 9.6% / 12.9% ~75 GB

This model resists pruning. Every expert fires (zero dead experts), saliency is flat, and prune sets are layer-local (Jaccard ~0.12 across depth — only ~4 experts prunable everywhere). Unlike models where 25–50% comes free, capacity here is real.

Rationale for 15%: it's the only rung with empirical validation (a prior 158B ≈ 15% build worked well); cost accelerates past it; and reaping is not the size lever — quantization is. 15%→20% saves ~4 GB while nearly doubling worst-layer loss.

Raw REAP scores grow ~140× with depth (mean 0.17 at layer 3 → 24.0 at layer 44), so scores are not comparable across layers without normalization.


2. Vision blindness ⚠️ — the biggest open issue

The mechanism (verified in source)

Image features are _masked_scatter'd into inputs_embeds (step3p7.py:105-111) and flow through the same 42 MoE layers being pruned. There is no separate "vision MoE" — image tokens are routed by the same routers to the same experts.

But the collector builds embeddings via text.embed_tokens(tokens) only (collect_step3p7.py:312) — it never calls vision_model or get_input_embeddings. So experts specialising in image tokens receive zero activation during saliency collection, score ~0, and are pruned first.

Net effect: the pipeline carefully preserves the vision encoder at BF16 while deleting the experts that consume its output.

The experiment

A dedicated vision-only saliency pass (reap_stream/collect_vision_saliency.py) routed 300 images through the real vision path — processor → pixel_valuesget_input_embeddings → merged embeds → the same block-streaming loop. Calibration was deliberately mixed: 200 ChartQA (synthetic plots) + 100 VQAv2 (natural photos), because charts alone are a narrow slice of "vision" and would risk a falsely reassuring result. Runtime: 1302 s.

Verdict thresholds were pre-committed before seeing data (MIXED if ρ>0.75 and overlap>0.6; SEPARATE if ρ<0.4 or overlap<0.35).

Result: SEPARATE

Metric Value
Mean rank correlation (vision vs text) 0.381
Mean top-25% expert overlap 0.478
Vision-top experts inside p15 prune set 3.1 per layer (max 9)
Vision saliency mass on pruned experts 9.42% (max 13.40%)

Correlation is lowest in early layers (~0.16–0.33, layers 3–14) and rises to ~0.36–0.54 through mid and late layers. The effect is roughly uniform across depth — it is not concentrated anywhere in particular.

Correction — an error in earlier analysis. An initial run of this comparison used the partial checkpoint, where layers 36–44 had reap_count = 0 (not yet collected). Correlating against all-zero text saliency produced meaningless near-zero/negative values (e.g. layer 37: −0.145, layer 40: −0.115), which were mistakenly reported as a real "late-layer collapse" and called the most interesting finding. On complete data those layers are normal (layer 37: 0.457, layer 40: 0.435, layer 44: 0.537). There is no late-layer collapse. The headline verdict (SEPARATE) and the 9.4% mass figure were unaffected — both were robust across partial and complete data.

Options (neither built)

  1. Protect vision-heavy experts — union the top vision-saliency experts per layer into the keep set before applying the ratio cut. Cheap; slightly raises expert count.
  2. Combine explicitly via Contrastive Skill-Shield (Theorem 6 in frontier-moe-pruning-theorems.md), treating vision as the target domain: S_CS(e) = S̃_text(e) + α·max(0, S̃_vision(e) − S̃_text(e)). z-score normalization is required — vision came from 300 images vs text's 2500 prompts, so raw magnitudes aren't comparable.

Keeping the two saliency maps separate and combining explicitly is preferable to mixing vision into the calibration set: mixing makes the blend ratio an implicit hyperparameter and destroys the ability to see which modality wanted which expert.


3. Calibration data defects (both real, both fixed)

3a. Truncation dropped the answer

The collector did tokenizer.encode(text)[:384]. Share of prompts whose ASSISTANT: response starts beyond the token budget:

Category beyond 384 beyond 1024
reasoning_math 0.4% ~0%
coding 8.8% 2%
tool_use 41% 1%
general_instruction 96.3% 35%
agentic 96.4% 64%

Token distribution: median 798, p90 5195, p99 6380, max 10,182. Agentic prompts (system prompt + tool schemas + file contents) are enormous — no front-truncation budget reaches their answers.

Fidelity measured against full-length ground truth (not assumed):

Window / mode Jaccard vs full Spearman vs full
384 head (original) 0.783 0.938
1024 head 0.834 0.960
1024 tail 0.694 ← worst 0.917
1024 headtail 0.858 ← best 0.977

tail — the initially proposed fix — measured WORST. Most routing statistics come from processing the long context, not the short answer; tail truncation discards that context. Tested before shipping; the intuition was wrong.

_truncate() now supports head|tail|headtail via --truncation.

3b. Multimodal rows were misaligned

build_calib_mix.py:186 paired local PNGs (sorted by filename) against a freshly re-loaded HF ChartQA dataset by raw index, silently desyncing image and Q&A. Verified on two samples (an antiretroviral-therapy chart paired with a "positive view peak" question; a Trump-approval chart paired with an online-classes question). All 500 multimodal rows suspect.

  • Excluded from DWQ via artifacts/dwq-targets/exclude_indices.json (105 of 1500).
  • Fixed properly by scripts/build_multimodal_calib_fixed.py / build_multimodal_calib_mixed.py, which read image + query + answer from the same dataset row in one pass so they cannot desync.

Note: the misalignment was irrelevant to REAP saliency (no images were ever fed), but genuinely harmful to DWQ, where the text-only teacher was asked to predict chart answers it could not see.


4. Result of the corrected saliency re-run

Re-ran with --max-tokens 1024 --truncation headtail --max-samples 2500 (1.40 h, 42 layers, nested_ok: true) → artifacts/step37-1024-headtail/.

Plan diff, new p15 vs original p15:

Metric Value
Experts changed per layer 4.3 of 245 (1.7%)
Range 2–10
Total slots changed 180 across 42 layers
Prune-set Jaccard (old vs new) 0.821

~98% of the pruning decision was identical. The truncation flaw was real and correctly diagnosed, but its downstream effect on the plan is small — expert ranking is robust to this input-distribution shift. Changes cluster in early layers (layer 3: 9 changed) and are smallest late (layers 36–44: ~3), consistent with early layers being more sensitive to surface/formatting features.

Use the new plan going forward (it is more faithful and already paid for), but it is not a meaningful quality upgrade on its own.


5. Layer-adaptive expert allocation (measured, unbuilt)

Current plans keep a uniform 245 experts in every layer. Allocating the same total budget non-uniformly — greedily, by fractional mass cost per layer since raw scores aren't cross-comparable — measurably reduces total discarded saliency:

Rung Uniform loss Adaptive loss Improvement Adaptive keep range
p15 2.1107 1.9996 5.3% 190–276 (mean 245)
p25 4.0473 3.9442 2.5% 150–262 (mean 216)

Computed from existing saliency — no new collection needed. Caveats: discarded mass is a proxy, not a quality measurement; and adaptive allocation breaks the nesting property (p10 ⊂ p15 ⊂ p20 ⊂ p25) that the launcher currently verifies.


6. Memory: the biggest operational lesson

MLX under-reports GPU memory by >2×

The collector logged peak_mb = 46506 (~46 GB) while the process actually held 109–110 GB:

footprint <pid>
  109 GB   IOAccelerator (graphics)   [all DIRTY, 0 reclaimable]
  phys_footprint: 110 GB

Always use footprint <pid>, never mx.get_peak_memory(). Trusting MLX's number allowed two crashes to happen unseen. (Note: pgrep -f run_mac_bf16 also matches the zsh wrapper — filter for the real Python PID.)

Failure modes observed

  1. OS-level hard reboot — the whole machine went down mid-run.
  2. GPU watchdog timeout[METAL] Command buffer execution failed: Caused GPU Timeout Error. Memory pressure stalls GPU ops until macOS kills the buffer.

Leading indicator for both: vm_stat compressor > 40 GB and swap expanding. Healthy = compressor ≈ 0. Do not watch "free RAM" — macOS always reports ~0.

Root cause and fix

Not layers_at_once, not prompt count. MLX's allocator hoards freed buffers, and mx.clear_cache() was only called once per layer — so 2500 per-prompt forwards accumulated every freed intermediate.

for i in range(len(hidden)):
    hidden[i] = _run_layer(layer, hidden[i], sliding_window)
    if (i + 1) % _CACHE_EVERY == 0:      # _CACHE_EVERY = 200
        mx.clear_cache()
Before After
IOAccelerator 109 GB 34 GB
Compressor 70 GB 0.1 GB
Swap used 9.6 GB 553 MB

~3× reduction at zero cost to sample count, layers, or plan quality. Also fixed: hidden = [_run_layer(...) for h in hidden] materialised an entire second ~21 GB list before releasing the old one — now updates in place.

With this fix, the text (≈48 GB) and vision (≈14 GB) passes ran concurrently with compressor at only 1.0 GB.

Sizing reference

  • MoE layer weights: 9.06 GB each (288 × 3 proj × 4096×1280 × BF16)
  • Hidden states: n_prompts × max_tokens × 4096 × 2 bytes (2500×1024 ⇒ ~21 GB)
  • iogpu.wired_limit_mb is already 115 GiB; raising it further starves the OS and causes the hard-reboot failure mode.

7. DWQ: built, works, value unproven

Pipeline (mlx_vlm-native, since mlx_lm only knows step3p5, not step3p7):

  • Phase 1 dwq_collect_targets.py — streams the BF16 teacher, caches top-128 logits per prompt (377 MB for 1500 prompts, ~32 min). Full-vocab would be ~495 GB and is unnecessary: LM distributions are peaked, top-128 captures >99.9% of mass.
  • Phase 2 dwq_train_student.py — 92 GB student resident, trains only affine quant scales, checkpoints the small (~5.3 GB) trainable tensors with auto-resume.

Blockers hit (all real, all fixed)

  1. GatherQMM::vjp error — MoE routing indices come from argpartition (non-differentiable) but the quantized gather-matmul VJP tried to differentiate them. Fix: mx.stop_gradient(indices) at the SwitchGLU boundary.
  2. Recompilation every step — variable prompt lengths → new graph each step (45–80 s/step). Fix: pad all to max_tokens, mask the loss.
  3. Adam optimizer state OOM — 5.3 B trainable scale params × 2 momentum buffers ≈ +59 GB. Fix: --scales-only + --optimizer sgd → stable 109 GB, ~45 s/step.
  4. Gradient accumulation made things WORSE — every variant (fp32, +gc/clear_cache, fp16) raised memory (115–130 GB) and tripled step time. Abandoned.
  5. Divergence at lr=1e-4 — loss climbed 2–5× above the untrained baseline and did not recover. Diagnosed with diag_specific_kl.py (trained loss vs untouched-student baseline on the same prompts). Fixed with lr=1e-6, then 1e-7.

Why its value is questionable

The untrained student's baseline KL vs the teacher was already 0.06–1.4 — low. There is little gap left for DWQ to close, and it was never shown to beat noise. Effort is better spent on calibration quality (§2, §3) than DWQ tuning.


8. Quantization format

Format scales biases DWQ-trainable? group_size
affine 4-bit float32 float32 ✅ yes 64
nvfp4 uint8 (FP8 microscales) none ❌ no must be 16
  • nvfp4 is the better raw 4-bit format but stock DWQ cannot train its uint8 scales.
  • nvfp4 requires no calibration data at all, which given §3 is a real advantage.
  • Vision tower left unquantized (BF16) — the deploy model keeps full vision.
  • LM Studio runs step3p7 + affine; DWQ is invisible to the runtime.

Built student: models/Step-3.7-p15-4bit — 92 GB, 245 experts, 4.632 bpw, via scripts/build_student.py (fused apply+quantize, never writes the ~316 GB reaped-BF16 intermediate). Smoke-tested: loads, generates coherently, 58 tok/s. Coherent with zero recovery, corroborating the 15% choice.


9. Router architecture (relevant to proposed "Router Bias Surgery")

corrected_scores = scores + router_bias          # SELECTION only
topk_indices = argpartition(-corrected_scores, kth=top_k-1)[..., :top_k]
topk_weights = take_along_axis(scores, topk_indices)   # weights use UNBIASED scores
topk_weights = topk_weights / sum(topk_weights)        # norm_expert_weight: True

Three consequences:

  1. No routing void. argpartition runs over the 245 survivors, so every token still gets exactly 8 experts. A rank-9 kept expert is promoted automatically.
  2. Mass is already conserved by norm_expert_weight: True.
  3. Relative ordering among survivors is unchanged — each keeps its original bias.

router_bias is a DeepSeek-style aux-loss-free load-balancing term; load imbalance costs training throughput, not inference quality (no capacity limits at inference). Proposals to "absorb probability mass" via bias adjustment therefore address a problem this architecture already handles.


10. Untested proposals — status

Idea Assessment
REAM (merging instead of pruning) Genuinely well-matched: this model is prune-resistant, and REAM consumes REAP scores so existing saliency is reusable. Biggest build; unvalidated here.
DPP-REAP (diversity selection) Math is correct. But the co-occurrence kernel is ~36× under-sampled off-diagonal (2.8% vs 0.077% of tokens), so dividing by total_tokens makes it diagonally dominant → degenerates back to REAP. Fix: normalize by co-occurrence count. Naive greedy is also ~2.6×10¹¹ flops/layer; incremental Cholesky reduces it to ~1.7×10⁷.
Router Bias Surgery Addresses a non-problem — see §9.
RMSNorm residual projection saliency The strongest untested idea. RMSNorm's Jacobian contains P⊥ₓ = I − xxᵀ/‖x‖², so expert output parallel to the residual stream is rescaled away and should not count toward saliency. Cheap, drop-in, independent.
Layer-adaptive allocation Already measured: 5.3% at p15 (§5).

Cheapest decisive next experiment

Add co-occurrence tracking to LayerSaliency and measure off-diagonal mass of the correlation matrix. If C_{i,j} ≈ 0, experts are already orthogonal and both DPP and REAM are ruled out for ~40 min of machine time.

Note: co-occurrence is inherently pairwise; everything currently stored (reap, freq, gate_sum, reap_count) is a per-expert marginal. It cannot be recovered from existing artifacts — a new pass is required. But 5k is unnecessary: at 1000 prompts each pair averages ~694 co-occurrences (threshold is 5), ample for a yes/no redundancy gate. ~40 min vs ~3 h.


10b. Perplexity evaluation — two methodology bugs found

Building the first non-proxy measurement (reap_stream/eval_ppl_streamed.py) surfaced two bugs that would have made every PPL number meaningless. Both were caught by noticing an implausible result, not by the code failing.

Initial smoke test on 6 held-out prompts gave agentic PPL = 388 — absurd for a frontier model, while reasoning_math scored a healthy 3.30. That split was the clue.

Category Original + head trunc + raw text (correct)
agentic 388.41 220.56 10.75
coding 42.30 42.30 9.13
reasoning_math 3.30 3.17 2.55
OVERALL 42.94 33.22 6.13

Bug 1 — headtail truncation is wrong for perplexity. It's correct for saliency (sampling both task setup and answer gives representative routing statistics), but for perplexity it splices first-512 onto last-512, creating a hard discontinuity mid-sequence. Tokens after that seam are genuinely unpredictable, inflating NLL for reasons unrelated to model quality. Agentic prompts (5000–10000 tokens) are the most truncated, hence worst affected; reasoning_math mostly fits under 1024 and was untouched. PPL eval now defaults to head (contiguous prefix).

Bug 2 — chat-template double-wrapping (inherited from _tokenize_prompts). The calib rows already carry their own SYSTEM:/USER:/ASSISTANT: structure, but apply_chat_template wrapped the whole thing again as a single user turn:

<|begin_of_sentence|><|im_start|>user\nSYSTEM:\nYou are a helpful assistant...

The model receives SYSTEM: as literal text inside a user message — a structure it never saw in training. This is why agentic (elaborate embedded SYSTEM blocks + function schemas) and coding suffered most, while plain USER:/ASSISTANT: reasoning_math barely moved. Fixing it gave a 36× improvement on agentic. PPL eval now feeds raw text; --chat-template opts back in.

Does this invalidate the saliency work? No. Every saliency run used identical treatment, so comparisons between them (old vs new plan, text vs vision) remain valid — it's a level shift, not a differential one. It would only have corrupted perplexity, which is exactly where it was caught.

Eval protocol: 500 held-out prompts from rows 5000+ (never used in calibration — saliency used the first 2500, DWQ the first 1500), multimodal excluded (misaligned and imageless, unanswerable as text), head truncation at 1024 tokens, raw text, with per-category breakdown — an aggregate number can hide "coding fine, agentic degraded 5%".

Measured BF16 streaming cost: 0.86 s fixed/prompt + 1.13 ms/token17 min for 500 prompts. Resident quantized models are far faster (1–2 min, no per-layer disk reads).


11. Method lessons

  • footprint <pid>, not MLX counters. MLX under-reported by >2×.
  • Compressor + swap are the crash predictors, not free RAM.
  • MLX hoards freed buffersmx.clear_cache() inside hot loops, not just at phase boundaries. Single highest-leverage memory fix found.
  • List comprehensions over big tensors double peak memory — update in place.
  • Never compare against a partial checkpoint. Zero-filled layers silently produce meaningless correlations that look like real structure (§2 correction).
  • Pair multimodal data in one pass. Index-based pairing across separate loads desyncs silently.
  • Test the fix, not just the problem. tail truncation and gradient accumulation both sounded right and both measured worse. Every fix that survived here was validated against ground truth first.
  • An implausible number is a bug report. Agentic PPL of 388 wasn't a finding about the model — it was two stacked methodology bugs (§10b). Sanity-check magnitudes against what the quantity should look like before interpreting it.
  • The right setting is task-dependent. headtail is correct for saliency and wrong for perplexity; the same knob flips depending on whether you're sampling routing statistics or modelling a contiguous sequence.
  • Most numbers here are still proxies. Perplexity (§10b) is the first non-proxy measurement. Real task benchmarks (SWE-bench, Terminal-Bench — what this model is actually built for) remain unrun.