File size: 15,585 Bytes
166a35f
dd7a16d
166a35f
dd7a16d
 
 
 
166a35f
 
 
 
 
 
dd7a16d
166a35f
dd7a16d
166a35f
dd7a16d
166a35f
 
 
 
dd7a16d
166a35f
dd7a16d
 
 
 
 
 
166a35f
 
 
 
 
dd7a16d
166a35f
dd7a16d
166a35f
dd7a16d
166a35f
dd7a16d
166a35f
 
dd7a16d
166a35f
dd7a16d
 
 
166a35f
 
 
 
 
 
 
 
 
 
 
 
dd7a16d
166a35f
 
 
 
 
 
 
 
 
 
 
 
dd7a16d
166a35f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd7a16d
166a35f
 
 
 
 
dd7a16d
166a35f
 
dd7a16d
166a35f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd7a16d
166a35f
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# PATCHES.md β€” MiniMax-H3 MLX 8-bit pipeline modifications (complete port)

All modifications relative to upstream `PipeNetwork/minimax-h3-mlx` base commit `b2f7e4d2b7861cefe68b75e4b59ab81cc4e7c318` (2026-08-10). The first committed patch is `video-lab-8bit-fixes` `7210b93e6df86bf9c7206091c9542b6983c10c30` (2026-08-17, two files); fixes S8/S9/S10 extend the same branch in the working tree (dit.py two-phase, pipeline release + TeaCache hook, text_encoder processor+scatter, teacache.py, generate.py flags, processor/ + vision shards). Everything else is byte-identical to the base commit.

| | |
|---|---|
| Base commit | `b2f7e4d2b7861cefe68b75e4b59ab81cc4e7c318` |
| Branch | `video-lab-8bit-fixes` (+ working-tree S8/S9/S10 on `video-lab`) |
| Patch commit (committed) | `7210b93e6df86bf9c7206091c9542b6983c10c30` |
| Date (committed) | 2026-08-17 |
| Working-tree S8/S9/S10 | 2026-08-18–2026-08-19 (pipeline.py, dit.py extension, teacache.py, text_encoder.py B1/B2, generate.py, processor/ + vision shards) |
| Validated with | Real generations incl. Turbo LoRA merges + I2V encode checks (see README Validation report + tmp/S9_teacache_spike_RESULT.md) |
| License | Port: Apache-2.0 (PipeNetwork). Weights: MiniMax H3 Community License (`LICENSE`). Turbo LoRA: Apache-2.0 (larryvrh). |

---

## (a) `minimax_h3_mlx/dit.py` β€” DiT attention QKV blocked layout + TeaCache two-phase hooks

**File:** `minimax_h3_mlx/dit.py` (~20 lines β†’ ~230 lines added vs base)

**QKV blocked layout (S7, committed 7210b93, lines ~12–17 docstring + lines 153–165 in `Attention.__call__`):**
- Raw-checkpoint `attn.qkv_proj` rows are **blocked** `[q | k | v]` β€” three contiguous thirds of width `heads * head_dim` β€” not per-head interleaved `[h0: q,k,v][h1: q,k,v]...`. The old code reshaped to `(B, S, heads, 3, head_dim)` and indexed `qkv[...,0]` which scrambled heads. Fix splits and reshapes each third independently (same layout `mlx-serve` consumes via `splitEqual(qkv,3)`; video VAE `to_qkv` remains per-head interleaved β€” different codebase, different convention):
  ```python
  # minimax_h3_mlx/dit.py:160-164 (Attention)
  qkv = self.qkv_proj(x)
  q, k, v = mx.split(qkv, 3, axis=-1)
  q = q.reshape(B, S, self.heads, self.head_dim)
  k = k.reshape(B, S, self.heads, self.head_dim)
  v = v.reshape(B, S, self.heads, self.head_dim)
  ```
- Module docstring (lines 12–17, 157–158) now documents the blocked layout and the VAE contrast.

**TeaCache two-phase forward (S9, working-tree, lines ~335–510):**
- New helpers `TYPE_CHECKING` import of `ModulationCache`, `_resolve_cache_layer` (lines 347–354), `_prepare_stages` (355–391, shared input projections + token_refiner + packed buffer + temb + adaln indices so the prefix graph is built exactly once), `_run_blocks` (393–409, iterates `blocks[i]` with optional `ModulationCache.get(i)`), `_finish_heads` (411–423, `final_layer.norm_out` + `video_out`/`audio_out` heads).
- Public two-phase entry points: `forward_to_cache_layer` (425–464, partial forward up to and including `cache_layer` β€” default last block `len(blocks)-1` β€” returning `(hidden, ctx)` where `hidden` is pre-final-norm residual after that block and `ctx` carries rotary/temb/adaln/layer bookkeeping) and `finish_from_cache_layer` (466–487, completes from cached `hidden`/`ctx` without recomputing prefix, returns `(video_velocity, audio_velocity)`). `__call__` (489–510) now delegates to `_prepare_stages`/`_run_blocks`/`_finish_heads` and gains `return_hidden` for probing. Saving per skip = suffix `blocks[layer+1..49]` + `final_layer.norm_out` + both heads β€” with default last block only heads+norm are saved (~<5% wall); moving to block 40 saves ~20% blocks per skip (see S9 spike result: 0/7 skips at last block, no wall saving).

---

## (b) `minimax_h3_mlx/text_encoder.py` β€” 8-bit quantized loader + processor fallback (B1) + positional scatter (B2)

**File:** `minimax_h3_mlx/text_encoder.py` (+84 lines vs base, ~6 β†’ ~17 imports)

**8-bit quantized loader (S7, committed 7210b93, lines ~64–110 + ~143–180):**
- Detects MLX affine 8-bit pack by presence of `quant_config.json` (line 71: `self.quantized = (model_dir / "quant_config.json").exists()`). Before loading, quantizes the module tree so packed U32/scales/biases key 1:1 β€” same recipe `load.py` replays from `quant_config.json`:
  ```python
  # lines 90-92
  nn.quantize(self.language, group_size=64, bits=8,
              class_predicate=lambda _path, m: isinstance(m, nn.Linear))
  ```
- Vision tower: if `load_vision=True` and `vision_quant_config.json` exists, replays its recorded `quantized` paths (89 tensors) with same group/bits (lines 98–110):
  ```python
  with open(model_dir / "vision_quant_config.json") as fh: vq = json.load(fh)
  paths = set(vq.get("quantized", ()))
  nn.quantize(self.vision, group_size=vq.get("group_size",64), bits=vq.get("bits",8),
              class_predicate=lambda _p, m: isinstance(m, nn.Linear) and _p in paths)
  ```
- Quantized shards loaded raw (no `astype(dtype)`, lines 166–170: `buckets[bucket][path] = tensor` when `self.quantized` else `tensor.astype(dtype)`), preserving packed integers. Only dense path casts.
- Serve pack omits `model.norm` (never evaluated β€” H3 conditions pre-norm); loader fabricates zeros like dense converter (lines 176–180):
  ```python
  buckets["language"]["norm.weight"] = mx.zeros(tuple(module.norm.weight.shape), dtype=mx.bfloat16)
  ```

**Processor property with torch-free fallback (B1, S10, lines 207–233):**
- `@property processor` (lines 207–233) tries `AutoProcessor.from_pretrained(processor_dir)` (full Qwen3VLProcessor with torch video/image sub-processors). On `ImportError` (no torch/torchvision; `Qwen3VLVideoProcessor` hard-requires them, and transformers 5.15 `AutoImageProcessor` is itself gated), falls back to PIL-only `Qwen2VLImageProcessorPil`:
  ```python
  try: self._processor = AutoProcessor.from_pretrained(processor_dir)
  except Exception:
      try: image_processor = AutoImageProcessor.from_pretrained(processor_dir)
      except Exception:
          from transformers.models.qwen2_vl.image_processing_pil_qwen2_vl import Qwen2VLImageProcessorPil
          image_processor = Qwen2VLImageProcessorPil.from_pretrained(processor_dir)
      self._processor = SimpleNamespace(image_processor=image_processor)
  ```
  The facade only ever reads `.image_processor` (`build_request` line 251), tokenizer comes from separate `tokenizer` property (lines 197–205), so image-only processor is sufficient for I2V.

**Encode positional scatter fix (B2, S10, lines 324–344):**
- `encode()` (lines 306–344) previously did `mx.where(image_mask[...,None], hidden[None], inputs_embeds)` which mis-broadcasts `(1,3096,1)` vs `(1,3072,5120)` because vision hidden rows are compact while image pads sit at arbitrary positions in the longer token row β€” a **positional scatter**, not a broadcast.
- Fix validates `hidden.shape[0] == num_image` (lines 330–334), then does positional REPLACE scatter via `scatter_add` (mlx 0.32 has no `nonzero`/`index_put`, only `at[].add`):
  ```python
  pos_np = np.nonzero(np.array(image_mask))[0]   # line 339 (mlx has no nonzero, use numpy)
  pos = mx.array(pos_np)                         # line 340
  target = inputs_embeds[0]                      # line 341
  inputs_embeds = target.at[pos].add(hidden.astype(target.dtype) - target[pos])  # line 342
  inputs_embeds = inputs_embeds[None]            # line 343
  ```
  Validated with `tmp/s10_validate_encode.py` (1,3096,5120) finite, 3074 video-tagged rows (start+3072 pads+end). `build_request` (lines 237–277) tags whole vision block as `TAG_VIDEO` (not text) β€” the DiT AdaLN key.

---

## (c) `minimax_h3_mlx/pipeline.py` β€” `release_text_encoder` headroom (S8/S8b) + TeaCache hook (S9) + checkpoint resume

**File:** `minimax_h3_mlx/pipeline.py` (+~205 lines vs base)

**Release text encoder (S8/S8b, lines 59–73 `__init__`, 77–118 `from_pretrained`, 228–331 `__call__`, 470–532 `_release_text_encoder_now`):**
- `MiniMaxH3Pipeline.__init__` gains `release_text_encoder: bool` (line 66, stored as `self._release_text_encoder` line 73). `from_pretrained` threads it (lines 84, 118).
- In `__call__` (lines 328–331), right after `prompt_embeds` built (line 1, text conditioning + vision rows already encoded, step-4 noise already drawn β€” trajectory fixed), opt-in `release_text_encoder or self._release_text_encoder` drops the encoder:
  ```python
  if (release_text_encoder or self._release_text_encoder) and getattr(self, "text_encoder", None) is not None:
      self._release_text_encoder_now()
  ```
- `_release_text_encoder_now` (lines 470–532) drops `self.text_encoder` (`del` + `gc.collect()` pattern), sizes correctly via `mlx.utils.tree_flatten` (lines 484,496,510 β€” previous `module.parameters().values()` summed dicts not arrays β†’ 0.0 GB), calls `mx.clear_cache()` to return unified Metal memory (line 528), logs `released text encoder after conditioning (freeing ~N GB)` (line 532). Second call is no-op (line 471 guard). Safe: inner estimator errors just leave freed-size as "(sizing indeterminado)" while delete still happens. Measured: frees ~27.5 GB / ~22 GB resident before denoise loop; pipeline then only touches DiT+VAEs.
- CLI: `scripts/generate.py` `--release-encoder` (line 56) β†’ `pipeline(release_text_encoder=args.release_encoder)` (line 96).
- Checkpoint resume (bonus, lines 371–389): `resume_from`/`checkpoint_dir` floats inside the loop (not a pipeline-level patch for HF, but present in `pipeline.py` working tree).

**TeaCache hook (S9, lines 43, 228–230, 342–410):**
- Imports `TeaCacheConfig/TeaCacheController` (line 43), `__call__` gains `teacache`, `teacache_config`, `teacache_layer` (lines 228–230 docstring 244–252). Controller instantiated when `teacache=True` (lines 342–350, resolves `tc_layer = num_layers-1 if None else int`, validates range). Per-step two-phase: `hidden, ctx = dit.forward_to_cache_layer(..., cache_layer=tc_layer)` β†’ `mx.eval(hidden)` β†’ `skip = controller.decide(i, hidden)` β†’ on skip reuse `cached_preds = (video_pred, audio_pred)` joint (lines 391–399), else `finish_from_cache_layer` and cache preds (lines 406–410). Log: `step N/T teacache skip (rel_l1=..., thresh=...)` (lines 398–401). Default last-block hook measured **0/7 skips** at 768x448 turbo8 (see teacache.py below β†’ OFF recommended).

**Other:** `load.py` one-line import fix for quantized path (line 1 added), `scripts/generate.py` adds `--teacache*`/`--release-encoder` flags (lines 45–56, 85–96).

---

## (d) `minimax_h3_mlx/teacache.py` β€” two-phase TeaCache controller (S9, new file)

**File:** `minimax_h3_mlx/teacache.py` (11 KB, new β€” 223 lines)

- **Intent:** feature-caching (ByteDance/ali-vilab line) β€” each step runs partial forward to a probe hidden, compares against previous step's feature, reuses previous `(video, audio)` velocity if similar. Joint reuse (both modalities from one forward must be reused together). Noise/keyframe sampling untouched β†’ seed still fixes trajectory.
- **`TeaCacheConfig` (lines 40–71):** `rel_l1_thresh=0.2` (default, ref 0.15 for 10s 544x960), `metric='rel_l1'|'cosine'`, `start_at_step=3`, `compute_last_step=True` (protects first 3 and last step), `cache_type='avg'|'last'`. `__post_init__` validates.
- **Pure helpers (lines 87–134):** `relative_l1_distance` (mean|curr-prev|/mean|prev|, lines 87–100), `cosine_similarity` (lines 103–113), `teacache_gate` (1-min(dist,1) for rel_l1 so higher=more similar, lines 115–124), `teacache_should_skip` (lines 126–134: `rel_l1 <= thresh` or `cosine >= thresh`).
- **`TeaCacheController` (lines 148–242):** stateful per-run (`total_steps`, `cached_feature`, `computed/skipped`, `by_step`). `decide(step_index, feature)` (lines 171–210): `must_compute` if `step==0` or `step<start` or `cache is None` or `last step` β†’ prime cache; else compute `metric_value` (cosine or rel_l1 distance), `skip = metric >=/<= thresh`, smoothing `avg` folds current into cache as `(prev+curr)/2` on skip, else replaces. Stats: `last_gate` (similarity-domain), `last_metric` (raw distance/similarity for honest log), `metric_name`, `to_stats()`.
- **Pipeline integration:** see (c) above; `dit.py` hooks provide the probe feature. **Measurement (M4 Max, 768x448 turbo8, 7 forwards):** default last-block `thresh 0.2` β†’ 0 skips (+9.7% overhead), `0.35` β†’ 1/7 skip (rel_l1 0.297) still no wall saving (partial forward already 49/50 blocks, only `final_layer.norm_out`+heads saved). `tmp/S9_teacache_spike_RESULT.md` β€” **verdict: TeaCache OFF by default**; aggressive needs earlier hook `layer 40` + `thresh 0.25–0.35` for double-digit % (trades quality).

---

## (e) `processor/` + vision shards (S10 I2V)

**Files added to checkpoint (not code patches but data required for I2V):**
- `processor/` (7 files, ~11.6 MB): `preprocessor_config.json` (390 B), `video_preprocessor_config.json` (385 B), `chat_template.json` (5.5 KB), `tokenizer_config.json` (11 KB), `tokenizer.json` (6.7 MB), `vocab.json` (2.6 MB), `merges.txt` (1.6 MB) β€” copied from `models/minimax-h3-ckpt/processor/` (`Qwen3VLProcessor`, `Qwen2VLImageProcessorFast` + `Qwen3VLVideoProcessor` configs; runtime uses PIL fallback per (b) B1).
- `text_encoder/model-00005-of-00008-vision.safetensors` (291,980,081 B), `model-00006-of-00008-vision.safetensors` (341,371,441 B), `model-00007-of-00008-vision.safetensors` (129,267,789 B) β€” 529 `model.visual.*` tensors (vision tower: depth 27, hidden 1152, patch 16, merge 2, out 5120). Built by `scripts/rebuild_h3_text_encoder_vision_8bit.py` which transposes `patch_embed.proj.weight` from torch `(O,C,kD,kH,kW)` to MLX channels-last and quantizes 89 paths.
- `text_encoder/vision_quant_config.json` (2,461 B, 89 quantized paths: `blocks.*.attn.qkv/proj`, `blocks.*.mlp.linear_fc1`, `merger.*`, `deepstack_merger_list.*`).

**Why this matters:** without `processor/` the pipeline cannot build `pixel_values`/`image_grid_thw` (I2V falls back to T2V). Without vision shards `text_encoder.encode(prompt, images)` raises `load_vision=False` or missing-tensor error. With both, `encode()` (b-B2) scatters 3072 vision patches into `inputs_embeds` and hits the DiT as `TAG_VIDEO` rows, then `pipeline._encode_keyframes` patches conditioning latents β€” FL2VA keyframe path (anchors `first`/`last`) validated end-to-end (see README `## Image-to-Video`).

---

## Other minor port fixes

- `minimax_h3_mlx/load.py`: quantization structure replay for DiT (already in committed 7210b93) β€” one-line import guard retained.
- `scripts/generate.py`: flags `--teacache`, `--teacache-thresh` (default 0.2), `--teacache-metric` (rel_l1/cosine), `--teacache-layer`, `--teacache-start` (default 3), `--release-encoder` (all S8/S9).
- `tests/test_release_text_encoder.py`, `tests/test_teacache.py`: unit/mocked tests for (c)/(d) (not shipped in HF package, kept in repo).

## Reproducing the diff

```bash
git -C models/minimax-h3-mlx diff b2f7e4d2 -- minimax_h3_mlx/dit.py minimax_h3_mlx/text_encoder.py minimax_h3_mlx/pipeline.py minimax_h3_mlx/teacache.py scripts/generate.py
# committed two-file patch:
git -C models/minimax-h3-mlx show 7210b93 --stat
```

## License

- **Weights**: MiniMax H3 Community License (included as `LICENSE`) β€” not open source; territorial exclusions apply (EU/UK/KR/US excluded per mlx-serve pack note).
- **Port code**: Apache-2.0 (PipeNetwork/minimax-h3-mlx).
- **Turbo LoRA**: Apache-2.0 (larryvrh/MiniMax-H3-Turbo-Lora).