Instructions to use MrMofer/MiniMax-H3-MLX-8bit with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use MrMofer/MiniMax-H3-MLX-8bit with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] huggingface-cli download --local-dir MiniMax-H3-MLX-8bit MrMofer/MiniMax-H3-MLX-8bit
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
| # 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). | |