上次在這台 Mac 上跑 Z-Image,用的是 mlx-swift,整個過程沒碰到 Python,當時就覺得夠乾淨了。後來看到 antirez 的 iris.c,發現還可以更極端:整個推論引擎就是一份 C 程式,Flux 2 和 Z-Image 的權重直接讀,沒有框架。
h3.c 是同一套路數搬到影片上,跑的是 MiniMax-H3。文字進去,出來的是帶聲音的影片。我想知道這條路線在影片模型上還撐不撐得住,就在 M5 Pro(48 GB 統一記憶體)上從 clone 開始走了一遍。
結論先講一半:程式那邊順到有點意外,卡住的都在權重。
程式很小,權重很大
整個專案 27,596 行,.c 佔 16,183 行(其中 1,763 行是從 iris.c 帶過來的 linenoise),Objective-C 5,279 行負責 Metal 和 tokenizer 那層,Metal shader 4,332 行全部塞在一個 h3_shaders.metal 裡。測試 7,428 行。
權重是另一個量級。Hugging Face 上的 MiniMaxAI/MiniMax-H3 整包 464 GiB,h3.c 會讀到的部分 196 GiB。所以這個專案的體積分布跟一般 Python 生態的專案剛好相反:程式碼一個下午讀得完,權重要抓好幾個小時。
這台機器的環境
Apple M5 Pro,48 GB 統一記憶體,macOS 26.6.1
clang(Xcode 26.6 帶的,但實際上不需要 Xcode 本體)
FFmpeg 和 FFprobe,
brew install ffmpeg裝好放在PATH上內建 SSD 要留 200 GB 給權重
「不需要 Xcode 本體」這件事值得多講一句。上次跑 Z-Image 的 mlx-swift 版本時,編譯 shader 卡在 Xcode 16 之後被拆成獨立元件的 Metal Toolchain,要另外抓 688 MB。h3.c 是在執行期才編譯 h3_shaders.metal,README 說這是跟著 iris.c 的做法,也明講了「刻意這樣做,就是不想依賴 Xcode 那個選用的離線 Metal toolchain」。這一關直接跳過。
編譯只花 2.6 秒
git clone --depth 1 https://github.com/antirez/h3.c.git
cd h3.c
make -j8
2.6 秒,沒有第三方依賴,連的都是系統 framework:
-framework Foundation -framework Metal
-framework MetalPerformanceShaders -framework MetalPerformanceShadersGraph
-framework Accelerate -licucore -lm
出來兩個東西:h3 執行檔 546 KB,libh3.a 745 KB。沒有 CMake、沒有 pip、沒有 venv、沒有 conda。
編譯選項也開得很緊,-Wall -Wextra -Wpedantic -Wshadow -Wconversion 全開,整份編下來零警告。唯一放寬的是 vendored 進來的 linenoise,Makefile 裡單獨關掉它的兩個警告:
linenoise.o: CFLAGS += -Wno-conversion -Wno-variadic-macro-arguments-omitted
這行旁邊還留了一句註解,說明為什麼不想為了一個終端機編輯器把整個專案的嚴格度降下來。這種細節在 README 裡看不到,但很能說明作者的取捨。
沒有權重也能先跑測試
make test 不需要模型也能跑,缺什麼就 skip 什麼,不會直接爆掉。編 13 個測試執行檔加上跑完,總共 7 秒:
./h3_tests
ok: 1768 checks
skip: MLX toy-block fixtures are not installed
skip: released tokenizer is not installed
skip: MLX Qwen fixture is not installed
./h3_audio_gpu_tests
audio primitive weight norm max abs 5.960464e-08
audio primitive Conv1d max abs 5.960464e-08
audio primitive ConvTranspose1d max abs 2.980232e-08
audio primitive SnakeBeta max abs 1.192093e-07
audio primitive scaled add max abs 0
audio primitive clip max abs 0
ok: native AudioVAE Metal primitives match host references
skip: released AudioVAE weights/fixture are not installed
skip: released audio encoder weights/fixture are not installed
ok: concurrent FFmpeg video/PCM pipes created /tmp/h3-av-mux-test.mp4 (51751 bytes)
skip: released visual encoder weights/fixture are not installed
skip: released reference-video encoder fixture is not installed
skip: released Qwen vision weights/fixture are not installed
skip: released Qwen video-pair fixture is not installed
skip: released multimodal Qwen weights/fixture are not installed
skip: Ref2VA video presentation fixture is not installed
真正跑到的有三塊。
h3_tests 那 1768 個檢查都是 host 端的確定性邏輯:時間軸與畫布尺寸推導、取樣排程、denoiser 重用排程、FL2VA 和 Ref2VA 兩種 checkpoint 的 layout 判讀、safetensors 標頭解析、RNG 與 solver、RGB 縮放、DiT 的 row 轉換,還有 Metal 裝置探測。這些東西不需要權重就能驗,也是最容易改壞的部分。
第二塊是 AudioVAE 的 Metal primitive 對照。它把 GPU kernel 算出來的結果跟 host 的參考實作比最大絕對誤差,上面那些 5.96e-08 是 float32 的 epsilon 等級,等於逐個 kernel 驗算過。有意思的是 scaled add 和 clip 兩項誤差是 0,這種數值上完全一致的結果,看到會安心一點。
第三塊是媒體管線。測試同時開影片和 PCM 兩條 pipe 餵進 FFmpeg,mux 出一個 51751 bytes 的 mp4。h3.c 的設計是生成的 RGB24 影格和 32 kHz 立體聲 PCM 直接走 pipe 進 ffmpeg,中間不落地成未壓縮檔案,所以這條路徑值得單獨測。
那 11 個 skip 分成兩種原因:一種在等官方權重(tokenizer、AudioVAE、visual encoder、Qwen 那幾項),另一種在等 MLX 產生的對照資料。
make parity 在乾淨的 clone 上跑不起來
README 在測試那節寫了兩個指令,make test 和 make parity。第二個我照打,結果是這樣:
./h3_metal_tests misc/fixtures/h3_dit.safetensors
FAIL tests/test_metal.c: misc/fixtures/h3_dit.safetensors: No such file or directory
make: *** [parity] Error 1
測試執行檔編得起來,是跑起來之後找不到餵給它的 fixture。去翻 Makefile,parity 三個指令全部把 misc/fixtures/ 底下的檔案當參數傳進去,而 .gitignore 第一行就把 misc/ 整個排掉了:
# Local models, references, experiments, and generated media.
misc/
MiniMax-H3/
outputs/
也就是說 Metal 對 MLX 的數值 parity 檢查,fixture 要自己用 MLX 跑一遍 toy block 產出來,repo 裡不會有。README 其實有寫「when the ignored MLX fixture is installed under misc/fixtures/」,但兩個指令並排放在一起,很容易以為 clone 完就能跑。make test 遇到同樣的情況會印 skip,make parity 是直接停在 make 的錯誤上。
464 GiB 裡面只有 196 GiB 是我需要的
MiniMaxAI/MiniMax-H3 有 280 個檔案。用 API 撈下來按目錄加總,大致是這樣:
| 目錄 | 大小 | 誰在用 |
|---|---|---|
FL2VA/ |
134.2 GiB | h3.c |
Ref2VA/ |
134.2 GiB | h3.c |
transformer/、transformer_ref/、text_encoder/、vae/ 等扁平目錄 |
約 196 GiB | diffusers |
h3.c 讀的是 FL2VA/ 和 Ref2VA/ 這兩套 pipeline 目錄,扁平的那組是給 diffusers 用的,整組可以跳過。從程式碼裡 grep 出它真正碰的路徑,就這幾條:
FL2VA/transformer/config.json
FL2VA/text_encoder
FL2VA/video_vae/source
FL2VA/audio_vae
FL2VA/tokenizer/tokenizer.json
Ref2VA/transformer/model.safetensors.index.json
Ref2VA/text_encoder
Ref2VA/video_vae/source
Ref2VA/audio_vae
Ref2VA/tokenizer/tokenizer.json
tokenizer
FL2VA 是文字生影片和首尾影格條件那條路徑,Ref2VA 是餵參考圖、參考影片時會換上去的另一份 checkpoint。兩條都要的話帳面上是 268 GiB。
抓之前我先把兩邊的 sha256 撈出來對了一次:
curl -sS "https://huggingface.co/api/models/MiniMaxAI/MiniMax-H3/tree/main/FL2VA/text_encoder"
FL2VA/text_encoder 和 Ref2VA/text_encoder 的 14 個 shard,oid 一個字都沒差;video_vae/source/model.safetensors(10.4 GiB)和 audio_vae/model.safetensors 也是同一個 blob。兩條 pipeline 真正的差別只有 transformer/ 的 13 個 shard,加上一個 719 對 707 bytes 的 model_index.json。
所以實際要下載的是 FL2VA/ 整套 134.2 GiB,加上 Ref2VA/transformer/ 的 61.7 GiB,合計 195.9 GiB,剩下三個目錄用 symlink 指回 FL2VA/ 就好。權重是 mmap 進去的,symlink 對它沒有影響。這樣省下 72 GiB。
hf download 的 –include 把我的 pattern 吃掉了
CLI 先裝起來:
uv tool install huggingface_hub --with hf_xet
我第一版指令是這樣下的,看起來很合理:
hf download MiniMaxAI/MiniMax-H3 --local-dir ~/Models/MiniMax-H3 \
--include "FL2VA/*" "Ref2VA/transformer/*" "Ref2VA/model_index.json" "tokenizer/*"
它沒有報錯,丟了一行 warning 就開始下載:
UserWarning: Ignoring
--includesince filenames have been explicitly set.
--include 收多個值,但 hf download 的位置參數也可以接一串檔名。第一個 pattern 之後那三個被判給位置參數,於是 --include 整組被無視,真正下載的是後面那三個。
我運氣不錯,列表順序剛好讓它去抓 Ref2VA/transformer/,抓的東西還是需要的。但 134 GiB 的 FL2VA/ 被整包跳過了。如果沒去看那行 warning,會以為在下載 196 GiB,實際上只有 62 GiB,而且要等它跑完才會發現少了東西。
用 --dry-run 驗一次就清楚。每個 pattern 各給一個 --include,四組都會列出來;擠在同一個 --include 後面,第二個 pattern 會被當成檔名,然後 404:
Error: File not found in repository.
URL: https://huggingface.co/MiniMaxAI/MiniMax-H3/resolve/main/tokenizer/%2A
正確寫法是重複下 --include:
hf download MiniMaxAI/MiniMax-H3 --local-dir ~/Models/MiniMax-H3 \
--include "FL2VA/*" \
--include "Ref2VA/transformer/*" \
--include "Ref2VA/model_index.json" \
--include "tokenizer/*"
最後那個根目錄的 tokenizer/ 只有 11 MB,但 make test 的 tokenizer 測試是去找 MiniMax-H3/tokenizer/tokenizer.json,順手抓下來可以多解鎖一項測試。
權重放哪裡
下載目標就是最後要用的位置,~/Models/MiniMax-H3:
mkdir -p ~/Models/MiniMax-H3
h3.c 的 .gitignore 已經把 MiniMax-H3/ 排除了,所以可以在 repo 裡放一條 symlink 指過去,README 上那些 -d ./MiniMax-H3 的指令就能原封不動照抄:
cd h3.c
ln -sfn ~/Models/MiniMax-H3 MiniMax-H3
HF_HOME 也要算進空間預算。用 --local-dir 時檔案會直接落在指定目錄,但 Xet 的 chunk 快取是放在 HF_HOME/xet,那是另一份暫存。
下載速度我量了幾次,都是 11 MB/s 上下。中途看到 HF 一直警告沒登入會被降速,登入之後再量還是 11 MB/s,兩條下載並行也一樣是 11 MB/s,所以瓶頸在我這邊的連線,不是 HF 的限速。實際從 20:37 抓到 01:17,196 GiB 花了 4 小時 40 分,平均 12 MB/s。
抓完記得清一下暫存。第一輪被我中止的那些檔案,殘骸留在 .cache/huggingface/download/ 底下,佔了 4.4 GB:
find ~/Models/MiniMax-H3/.cache -name "*.incomplete" -delete
Ref2VA/ 那三個目錄的 symlink 這時候補上:
cd ~/Models/MiniMax-H3/Ref2VA
for d in text_encoder video_vae audio_vae tokenizer processor; do
ln -sfn "../FL2VA/$d" "$d"
done
–info 先確認機器和權重
--info 不會載入權重,只讀 safetensors 的標頭,所以 0.05 秒就回來了:
h3-metal 0.1.0-dev
Device: Apple M5 Pro (applegpu_g17s)
physical memory 48.0 GiB
recommended GPU set 37.4 GiB
max Metal buffer 28.1 GiB
Apple GPU family 10
Metal 4 yes
unified memory yes
Native checkpoint inventory (header-only):
Qwen3-VL encoder 14 files 1058 tensors 62.133 GiB
FL2VA DiT 13 files 535 tensors 61.728 GiB
Ref2VA DiT 13 files 535 tensors 61.728 GiB
video VAE 1 files 560 tensors 9.700 GiB
audio VAE 1 files 1087 tensors 0.564 GiB
這幾行資訊量很高。recommended GPU set 37.4 GiB 是這台機器建議的 GPU 工作集上限,而 README 說 512 見方全駐留的 DiT 儲存大約 36.5 GiB,兩個數字幾乎貼在一起,所以我原本預期 48 GB 的機器會很勉強,甚至要開 --ssd-streaming。實際結果不是這樣,後面會講。
權重清單也對得上前面算的:Qwen3-VL 編碼器 62.133 GiB、兩份 DiT 各 61.728 GiB、video VAE 9.700 GiB、audio VAE 0.564 GiB。
權重到齊後再跑一次測試
make test 這次多了一項:
ok: 1768 checks
skip: MLX toy-block fixtures are not installed
ok: 60 tokenizer checks against released Qwen vocabulary
skip: MLX Qwen fixture is not installed
11 個 skip 只變成 10 個。有了 196 GiB 的權重,只解鎖了 tokenizer 那 60 個檢查,因為其他每一項的條件都是「權重和 fixture 都要在」。想把測試全部跑起來,還是得自己生 MLX fixture。
生第一段影片
先用 README 說的低預算路線,4 步去噪、50 層全開、--reuse 1:
./h3 --profile -d ./MiniMax-H3 \
-p "A red fox walks through fresh snow in a pine forest. Medium tracking shot, natural winter light, realistic fur, soft footsteps and wind." \
--width 512 --height 512 --frames 22 \
--steps 4 --layers 50 --reuse 1 \
-o outputs/fox-4step.mp4
出來的是 512×512、22 影格、0.917 秒的 mp4,h264 加 aac,316 KB。音軌是同一次生成出來的,32 kHz 立體聲,不是後製配上去的。

同樣的 prompt 換成 README 的平衡預設,20 步、45 層、--reuse 2:

毛髮的層次和背景那幾棵樹差得很明顯,4 步版本的樹是糊成一片的。20 步這張連雪面的起伏都有。
聲音也有差,而且差得比我預期多。用 volumedetect 量兩支的音軌:
| 平均音量 | 峰值 | |
|---|---|---|
| 4 步 | -52.2 dB | -34.0 dB |
| 20 步 | -36.1 dB | -21.4 dB |
兩支都不是靜音,但 4 步那條音軌幾乎聽不到東西。步數砍到 4 的時候,畫面已經有模有樣,音訊那邊還沒長出來。這只是用工具量的音量,我沒有仔細聽內容。
--profile 把每個階段的時間拆開來,兩輪並排:
| 階段 | 4 步、50 層、reuse 1 | 20 步、45 層、reuse 2 |
|---|---|---|
| tokenizer 加 Qwen 文字編碼 | 5.2 秒 | 4.7 秒 |
| DiT 載入 | 16.2 秒 | 14.0 秒 |
| GPU 去噪 | 6.3 秒 | 16.0 秒 |
| audio VAE 解碼 | 0.3 秒 | 0.3 秒 |
| video VAE 解碼 | 8.7 秒 | 9.1 秒 |
| 整支程式 | 37.5 秒 | 44.8 秒 |
| 峰值記憶體足跡 | 18.5 GiB | 17.0 GiB |
20 步那輪的去噪 16.0 秒,README 在 M5 Max 上量到 15 至 17 秒,這台 M5 Pro 幾乎一樣;4 步那輪 6.3 秒,M5 Max 是 3.5 秒,差了一倍。
讀權重還是佔掉一半上下:文字編碼加 DiT 載入,4 步那輪是 37.5 秒裡的 21.4 秒,20 步那輪是 44.8 秒裡的 18.7 秒。DiT 載入的 14.0 秒裡有 13.3 秒是 wait,也就是在等 I/O。碟本身不慢,我量的循序讀取是這樣:
dd if=~/Models/MiniMax-H3/FL2VA/transformer/model-00001-of-00013.safetensors of=/dev/null bs=1m
5227812968 bytes transferred in 0.398305 secs (13125150244 bytes/sec)
13.1 GB/s。量這種數字要挑沒被讀過的檔案,剛讀過的再讀一次會拿到 31.7 GB/s,那是 page cache 不是碟。61.7 GiB 的 DiT 除以 14.0 秒等於 4.4 GB/s,離碟的上限還有距離,所以這 14 秒不是純粹被碟卡住,權重解讀和搬進 GPU 也佔了一部分。
48 GB 夠不夠?夠,而且差得還遠
這是我原本最擔心的一段。--info 說建議 GPU 工作集 37.4 GiB,README 說 512 見方全駐留大約 36.5 GiB,看起來只剩一點餘裕。
實際跑完的結果差很多:
h3 自己記的峰值張量儲存:18.6 GiB(4 步)、17.1 GiB(20 步)
作業系統看到的峰值記憶體足跡:18.5 GiB、17.0 GiB
最大常駐集只有 9.5 GiB
過程中系統可用記憶體最低 44%,swap 用量從 635 MB 變成 627 MB,等於沒有動
原因翻程式碼就看得到。h3_gpu.m 裡權重是 mmap 進來,再直接包成 Metal buffer:
void *mapping = mmap(NULL, map_bytes, PROT_READ | PROT_WRITE,
MAP_PRIVATE, descriptor, (off_t)aligned_offset);
...
newBufferWithBytesNoCopy:values length:MAX(bytes, (size_t)1)
沒有另外複製一份到匿名記憶體,所以那 37 GiB 對系統來說是檔案映射,隨時可以回收。README 也有一節在講這個,說 M5 上這樣做會讓模型「file-backed/reclaimable」。文字編碼那階段更明顯:峰值只有 3.638 GiB,累計配置卻有 46.865 GiB,代表權重是一路讀一路丟。
所以 --ssd-streaming 在這台機器上不需要開。README 把它定位成明確的記憶體換速度,代價是 512 見方慢 84%,48 GB 的 M5 Pro 沒有必要付這個代價。
互動模式我沒能自動化
每跑一次都要重付二十秒上下的載入,所以正常用法應該是開一個互動 session,讓權重留在記憶體裡連續改 prompt。README 說重複同一個 prompt 會連文字條件一起沿用,只重跑去噪。
這段我沒量到。想用管線把 prompt 餵進去,程式讀到 EOF 就結束了,只跑完文字編碼就退場:
h3: conditioning cache miss; stored exact BF16
改用 script -q /dev/null 開一個 pty 也不行,卡在 linenoise 向終端查詢游標位置的 [6n,等不到回應就停在那裡。這是終端編輯器很正常的行為,只是不適合這樣自動化。要量這個數字得手動開一個 session 坐在前面打字,我就先跳過了。從 profile 的分解推,省下的是二十秒上下的載入,剩下的去噪加解碼,4 步大約 15 秒、20 步大約 25 秒。
兩個會提早擋下來的參數
畫布尺寸和上限是在載入權重之前就檢查的,打錯會立刻停下來,不用等它把權重讀完:
$ ./h3 -d ./MiniMax-H3 -p "test" --width 500 --height 500
h3: width and height must be multiples of 32 and at least 32
$ ./h3 -d ./MiniMax-H3 -p "test" --width 1344 --height 1344
h3: canvas exceeds the released 768*1344 pixel limit
影格數不是這樣處理的。README 說 H3 出的是 24 fps,影格數會往上對齊到 5 + 17n,所以 22、39、56、107、243 這些是剛好的數字,中間的值會被進位。我丟 --frames 30 進去它照樣開始跑,沒有先抱怨,所以要嘛記住那串數字,要嘛用 --seconds N 讓它自己算。
值不值得
程式這邊沒什麼可挑的。2.6 秒編完、零依賴、測試在沒有權重的情況下就能跑一輪、--info 0.05 秒把機器和 checkpoint 都列清楚,這些細節省掉的時間比想像中多。48 GB 的 M5 Pro 也不是勉強能跑,是還有一半餘裕。
麻煩的是權重。196 GiB 抓了 4 小時 40 分,抓之前還得先搞清楚哪些目錄是自己需要的,不然會多抓 268 GiB 的完整兩套,或者更慘,照著 repo 全抓 464 GiB。
跑起來之後速度可以接受。一輪 512 見方、22 影格的完整生成,4 步是 37.5 秒,20 步是 44.8 秒,其中一半左右還是在把 62 GiB 的權重讀進來。要連續調 prompt 的話,開一個互動 session 讓權重留在記憶體裡,差別會更明顯。
要我推薦誰試,大概是這樣:想在 Mac 上跑影片生成、又不想維護 Python 環境的人,這條路現在真的走得通。先清出 200 GB 的內建空間再開始抓。