首頁/人工智慧

h3.c 安裝與測試

2026年08月18日 人工智慧 h3.c MiniMax-H3 antirez Apple Silicon Metal C 影片生成

上次在這台 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 行原始碼、2.6 秒編譯完的 546 KB 執行檔;右邊是 196 GiB 權重

整個專案 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 testmake 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"

text_encoder、video_vae、audio_vae 在兩條 pipeline 是同一個 blob,只有 transformer 不同

FL2VA/text_encoderRef2VA/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 --include since 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 立體聲,不是後製配上去的。

4 步去噪的第 0、10、21 影格

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

20 步平衡預設的第 0、10、21 影格

毛髮的層次和背景那幾棵樹差得很明顯,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 的內建空間再開始抓。