1. 项目背景:为什么要在 MacBook 上折腾这条路线
ComfyUI 社区里最近聊得最多的话题,就是视频模型。我手里这台 32GB 内存的 MacBook Pro,跑起 33B 视频模型来,一直处在一种勉强能用的边缘状态。后来我把 antirez 的 h3.c 封装成了一个 ComfyUI 插件节点,把它接进视频工作流里,整个体验反而一下子顺畅了。这篇文章就是我这次封装和联调过程的工程笔记,适合那些想在 MacBook 上跑本地视频模型、又被内存问题反复折磨的人参考。
1.1 h3.c 是个什么样的存在
antirez 是 Redis 的作者,这个大家都知道。但他在 2024 年底放出的 h3.c,是另一个方向的尝试:他用一个单独的 C 文件,写了一个完整的语言模型推理引擎。所谓完整,是指从加载权重、分词、计算 logits、采样,到输出 token 的整个流程,全部包含在一个 .c 文件里。没有 PyTorch,没有 CUDA,没有几百兆的依赖库,你只要有一个 C 编译器,就能在几乎任何机器上把模型跑起来。
我当时第一反应是:这玩意儿才是真正的极简主义。h3.c 配合他自己训练的小模型,既不是最强的模型,也不是最快的,但它让“本地推理”这件事的门槛降到了极低。你不必理解复杂的神经网络部署工具链,不需要折腾 GPU 驱动,一行编译命令就能看到模型在终端里吐出文字。这种朴素直接的风格,恰恰是个人开发者最需要的。
后来我在 ComfyUI 里折腾视频生成时,脑子里总挂着 h3.c。ComfyUI 本身是个节点式工具,插件机制非常开放,所有自定义节点本质上是 Python 类。如果能把 h3.c 的推理能力封装成一个节点,那 ComfyUI 里所有需要用到文本理解、prompt 优化、动态描述生成的地方,都能多一个轻量级的本地选项。尤其对 MacBook 用户来说,这可能是摆脱大模型内存焦虑的一条实用路径。
1.2 ComfyUI 的节点系统和插件扩展机制
很多刚接触 ComfyUI 的人会把“插件”和“节点”搞混。简单说,一个插件可以包含多个节点;ComfyUI 在工作流画布上加载节点图,每个节点执行一个 Python 函数,节点与节点之间通过类型化端口连线。官方文档称之为 Custom Node,中文社区通常叫“自定义节点”。
写一个自定义节点,核心就是定义一个 Python 类。类方法 INPUT_TYPES 描述输入端口,RETURN_TYPES 描述输出端口,FUNCTION 字段指定实际执行函数名。前端做的所有交互,最终都会被 ComfyUI 解析成对这个函数的调用。所以理论上任何一个 Python 库,只要能在当前环境里 import,就能被封装成 ComfyUI 节点。我当时就是盯着这个特性,决定把 C 语言写的 h3.c 通过 ctypes 拉进 Python 世界,然后再包一层节点壳。
1.3 33B 视频模型的本地痛点到底在哪
现在开源视频模型不少,但真正能上手的还得看算力够不够。MacBook 的 Apple Silicon 因为是统一内存架构,CPU 和 GPU 共享内存,所以反而比普通 PC 更容易跑大模型——不需要独立显存,但也正因为共享,内存一旦不够,整个系统会变得极其卡顿。
33B 视频模型的主要消耗有三块:权重本身、中间激活、以及文本编码器。权重好理解,33B 参数就算用 fp16 也要消耗约 66GB,显然不是 16GB 或 24GB 的 MacBook 能直接吃的;所以实际大家都会用量化版本,比如 4bit 或 8bit,压到接近 20GB 甚至更低。中间激活取决于分辨率和帧数,通常也要几个 GB。第三块往往被低估:许多视频模型的工作流里,文本编码和 prompt 理解会调用一个同样几十亿甚至上百亿参数的大语言模型,这一下子又是好几 GB,甚至还会和视频模型争抢同一块内存。
我在一台 32GB 内存的 MacBook Pro 上跑 33B 视频模型的 4bit 量化版,模型本身加载完后剩余的内存已经不多,可工作流里文本端再加载一个大模型,内存直接爆。所以问题的核心不是视频模型本身,而是“同时让两个大模型常驻”。如果能把文本端换成 h3.c 这种轻量引擎,那就等于把工作流的大模型数量从两个降到一个,内存压力瞬间小一个量级。
2. 封装方式选型:动手前的四个方案对比
在写任何代码之前,我想明白一件事:我到底想让 h3.c 以什么形式活着。如果只是临时在命令行里用,那封装毫无意义;要在 ComfyUI 里稳定跑,就要考虑调用方式、错误隔离、性能损耗。这个选型过程比想象中重要,我做了四个方案的对比。
2.1 方案 A:ctypes 直接加载动态库
ctypes 是 Python 自带的 FFI 模块,专门用来调用 C 动态库。做法是把 h3.c 编译成 .dylib,然后在 Python 里用 ctypes.CDLL 加载。
优点:
- 不引入第三方 Python 包,ComfyUI 环境不会被污染。
- 调用流程在同一个进程内完成,没有进程间通信开销。
- 可以直接传递字符串,返回一个 Python bytes,很方便。
缺点:
- C 和 Python 之间的内存管理容易出问题。比如 C 函数返回 malloc 出来的 char*,忘记释放就会内存泄漏;释放两次就会直接崩溃。
- 需要手动指定 argtypes 和 restype,类型错了非常难查。
但 ComfyUI 环境下,自定义节点本来就是跑在同一个 Python 进程里的,ctypes 这种方案是最“原生”的做法。
2.2 方案 B:CFFI 声明式封装
CFFI 在很多方面比 ctypes 优雅。它可以直接把我们想要调用的 C 原型写在 Python 字符串里,然后它负责生成绑定代码,处理类型转换也更自动。对于 h3.c 这种接口简单的项目,CFFI 确实是个好选择。
不过我在 MacBook 上踩过 CFFI 和 Homebrew Python 的兼容坑,编译出来的绑定模块偶尔会出现符号找不到。这种问题在纯标准库方案里几乎不会出现。为了不折腾,我选择了更保守的路线。如果你的设备环境非常干净,CFFI 完全值得一试;但对多数 ComfyUI 用户来说,少一个依赖就少一个坑。
2.3 方案 C:子进程调用命令行程序
这是最容易想到,也最省事的方案。把 h3.c 编译成命令行工具,在节点里用 subprocess 调用,把 prompt 作为参数传进去,再从标准输出读结果。
优点非常突出:完全隔离,哪怕 C 层崩了,最多就是这次调用失败,不会带崩整个 ComfyUI。缺点也很致命:
- 每次生成都要新建一个进程,模型加载和初始化全部重来一遍,在 h3.c 这种小模型上可能还能忍受,但一旦模型权重变大,性能非常难看。
- 进程通信需要处理各种诡异的环境变量、工作目录问题,ComfyUI 的启动方式一换,你的调用路径可能就失效了。
这个方案适合做“最优先保通”的版本,但不适合做产品级的插件。我最后没有用,因为它和 ctypes 方案的性能差距实在太大。
2.4 方案 D:自制 socket / HTTP 服务
还有一种做法是写一个小服务,Python 通过 localhost 端口和 C 进程通信。这样的好处是可以把 C 层独立部署,坏处是插件安装和启动流程变得很复杂,用户要先起一个服务才能用节点。对一个个人项目来说,这属于过度设计。如果你的模型非常大,需要把 C 进程常驻或者跑在另一台机器上,这个方案才有价值。
2.5 最终选择:ctypes + 稳定的 C API 层
排除之后,我最终选择 ctypes,而且从第一天就决定在 C 侧加一个稳定的 C API 层。为什么不用 raw h3.c 的 main()?因为 main() 是为命令行设计的,它把模型加载、交互、输出全部混在一起,Python 侧很难复用。我要的是一个单独的函数,输入 prompt 和环境参数,输出字符串。这个 API 层让我把“模型怎么做推理”和“外部怎么调用”彻底解耦。
这个决定后来被证明非常正确。h3.c 上游有一次调整了命令行交互逻辑,但我的 C API 没变,集成测试全部通过,我几乎没花时间迁移。封装开源项目时,永远要记得给外部调用留一个独立的、稳定的接口,而不是直接裸调内部逻辑。
3. 核心实现:把 h3.c 变成 ComfyUI 节点的三块拼图
3.1 给 h3.c 补一个稳定的 C API
这一步我是这样操作的。新建一个 h3_api.c 和 h3_api.h,在 API 里只暴露四个函数:创建句柄、销毁句柄、生成文本、释放字符串。
// h3_api.h #ifndef H3_API_H #define H3_API_H typedef struct H3Handle H3Handle; H3Handle* h3_create(const char* weights_path); void h3_destroy(H3Handle* handle); char* h3_generate(H3Handle* handle, const char* prompt, int max_tokens, double temperature, double top_p, long long seed); void h3_free_string(char* s); #endifh3_create 内部做三件事:分配结构体、加载权重、初始化分词器。h3_generate 负责把 prompt 切分成 token、跑前向、采样、拼接最终文本。h3_free_string 用来释放 h3_generate 返回的堆内存。
这里有两个细节值得讲。返回字符串一定要用 malloc 而不是指向静态缓冲区。很多人喜欢返回 static char[],这在单线程场景下挺方便,但 ComfyUI 的节点可能被多次调用,static 缓冲区被覆盖后,Python 侧拿到的数据就乱了。我一开始用 static 缓冲区,结果第二次调用时第一个节点的结果也变了,排查半天才意识到是共享缓冲区的问题。改成 malloc 之后,每个调用都有独立内存,配合 h3_free_string 释放,干净利落。
另外,h3_generate 内部要加一个互斥锁。ComfyUI 的并发执行是真实存在的,两个工作流回路可能同时跑到同一个节点函数,这时候如果两个线程同时进入 h3_generate 里的推理循环,轻则结果错乱,重则直接段错误。所以在句柄里放一个 pthread_mutex,进入生成函数时 lock,退出时 unlock,这是最基本的防护。
3.2 Python 侧桥接和内存安全
Python 侧用 ctypes 的标准写法如下。我把这段代码完整贴出来,因为它里面几个坑都是实战踩出来的:
import ctypes from pathlib import Path class H3Bridge: def __init__(self, dylib_path: str): self.lib = ctypes.CDLL(str(dylib_path)) self.lib.h3_create.restype = ctypes.c_void_p self.lib.h3_create.argtypes = [ctypes.c_char_p] self.lib.h3_destroy.argtypes = [ctypes.c_void_p] self.lib.h3_generate.restype = ctypes.c_void_p self.lib.h3_generate.argtypes = [ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_longlong, ] self.lib.h3_free_string.argtypes = [ctypes.c_void_p] self._handle = None def load(self, weights_path: str) -> None: self._handle = self.lib.h3_create(weights_path.encode("utf-8")) if not self._handle: raise RuntimeError(f"Failed to load weights: {weights_path}") def generate(self, prompt: str, max_tokens: int = 128, temperature: float = 0.8, top_p: float = 0.95, seed: int = 42) -> str: raw = self.lib.h3_generate( self._handle, prompt.encode("utf-8"), max_tokens, temperature, top_p, seed, ) if not raw: return "" try: return ctypes.cast(raw, ctypes.c_char_p).value.decode("utf-8") finally: self.lib.h3_free_string(raw) def close(self) -> None: if self._handle: self.lib.h3_destroy(self._handle) self._handle = None这里最关键的坑是 restype 的设置。如果你把 h3_generate 的 restype 直接设成 c_char_p,ctypes 会把返回的指针转换成 Python bytes,而且认为自己拥有这块内存。可实际上我们的 C 函数返回的是 malloc 出来的字符串,预期由调用方手动 free。一旦 ctypes 又接管、我们又在 finally 里调用 h3_free_string,就会 double-free,程序直接崩。
解决办法就是像上面这样:restype 用 c_void_p,拿到原始指针后手动 cast 成 c_char_p。这样 ctypes 不拥有内存,我们可以在拿到 bytes 后明确调用 h3_free_string 释放。这是一种“谁分配谁释放”的朴素原则,很多从 C 转 Python 的人容易忽略。
3.3 ComfyUI 节点的标准骨架
桥接层完成后,ComfyUI 节点本身是很薄的。下面是我在 custom_nodes/h3_comfyui/nodes.py 里的核心代码:
class H3TextNode: @classmethod def INPUT_TYPES(cls): return { "required": { "prompt": ("STRING", {"multiline": True}), "seed": ("INT", {"default": 42, "min": 0, "max": 0xFFFFFFFF}), "max_tokens": ("INT", {"default": 128, "min": 1, "max": 2048}), "temperature": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 2.0}), "top_p": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0}), } } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("text",) FUNCTION = "generate_text" CATEGORY = "LLM" def generate_text(self, prompt, seed, max_tokens, temperature, top_p): bridge = get_global_bridge() text = bridge.generate( prompt=prompt, max_tokens=max_tokens, temperature=temperature, top_p=top_p, seed=seed, ) return (text,)ComfyUI 对节点类的执行函数返回格式有要求:返回值必须和 RETURN_TYPES 一一对应。这里的 RETURN_TYPES 是 ("STRING",),所以 generate_text 必须返回一个元组,第一个元素是文本字符串。
关于 get_global_bridge(),我用了模块级缓存。加载模型权重很慢,如果每个节点实例都从零加载,跑一次工作流就会反复等待。我的缓存用权重路径作为 key,同一个权重只初始化一次。这样做还有一个好处:多个 H3 节点连在同一个工作流里时,它们共享同一个 C 模型句柄,内存占用不会随节点数量线性增长。这个设计对 ComfyUI 这类节点图工具尤其重要,因为一个复杂视频工作流里可能有好几个用到文本生成的地方。
3.4 MacBook 上的编译参数
Apple Silicon 上编译动态库,我用的命令是:
clang -O3 -shared -fPIC -march=armv8.5-a -o h3_api.dylib h3_api.c h3.c-O3 开启最高优化,-shared 表示生成动态库,-fPIC 生成位置无关代码,-march=armv8.5-a 是 Apple M 系列芯片可以安全启用的指令集级别。如果遇到编译指令不支持,按你的 CPU 型号降级,比如 -march=armv8.3-a,或者直接用默认参数,性能只是略差,不影响功能。
一个很容易忽略的问题:动态库的架构必须和 ComfyUI 的 Python 进程一致。你可以在终端里输入python3 -c "import platform; print(platform.machine())"查看当前解释器的架构。如果 Python 是 arm64,而你的 dylib 是 x86_64 编译出来的,加载时会报 incompatible architecture。我当时就是混用了 Homebrew 和系统 Python,折腾了半小时。
把编译好的 h3_api.dylib 和权重文件放在自定义节点目录下的 models 文件夹里,然后在节点模块里用绝对路径引用。别用相对路径,因为 ComfyUI 的当前工作目录会随启动方式变化,相对路径极其不可靠。
4. 和 33B 视频模型的工作流串接:从节点到成片
4.1 整体数据流设计
节点封装完成后,事情只做了一半。真正让我兴奋的是把它放进视频生成工作流。我的工作流结构大概是:文本输入 -> H3 节点(扩写和改写)-> 视频模型 -> VAE 解码 -> 输出视频。
H3 在这里承担的是“文本理解+生成”的活。原始输入可能只是一句话:“一只猫在窗台上看雨”。H3 会把它扩写成带镜头、光线、动作描述的分镜文本,然后这个文本交给 33B 视频模型作为 prompt。
这里的关键是分工:H3 做它擅长的文本再创作,33B 视频模型只负责把文本变成画面。原来的工作流如果用大 LLM 做文本端,两个大模型同时常驻内存;现在的方案把文本端换成了不到 1GB 的轻量引擎,内存压力完全不在一个量级。对于那些本地跑视频模型总报内存不足的朋友,这个思路可以直接复制。
4.2 内存分摊与释放策略
虽然 H3 很小,但也不是零成本。我最开始接入时,加载的是一个比较大的权重版本,光模型加载完,内存已经少了一大块,结果视频模型一加载又卡死。后来换成量化版本,权重降到 0.8GB 左右,问题才解决。所以说,轻量引擎也要注意别选过大的权重。
第二个策略是“用完就丢”。ComfyUI 默认会在内存里缓存所有被使用过的模型,这不适合视频生成这种长时间任务。我在工作流里加了一个内存清理节点,放在视频模型之前:先把 H3 模型卸载,再释放 VAE、CLIP 等临时缓存。这样 33B 视频模型加载时,系统内存里只有它一个大家伙,非常从容。
我算过一次账:32GB 机器上,H3 量化版占 0.8GB,33B 视频模型 4bit 量化后约 18GB,中间激活预留约 6GB,剩下的给系统和 ComfyUI,刚好够。如果多挂一个 LLM,立刻超载。所以“只保留一个重组件”是我在 MacBook 上跑视频工作流的最核心原则。
4.3 实测性能数据
下面是我在一台 M2 Pro(12 核 CPU、16 核 GPU、32GB 统一内存)上的实际数据。测试输入是“黄昏时分的海边,一个孩子拿着风筝在沙滩上奔跑”,输出的视频是 24 帧、512x320 分辨率。
| 环节 | 耗时 | 峰值内存 |
|---|---|---|
| H3 文本扩写(128 token) | 约 1.2 秒 | 0.8GB |
| 33B 视频模型加载(4bit 量化) | 约 30 秒 | 18.5GB |
| 视频生成(24 帧,512x320) | 约 3 分 20 秒 | 24GB |
| VAE 解码与输出 | 约 4 秒 | 12GB |
对比很明显:H3 阶段几乎不构成瓶颈,真正耗时的还是视频生成本身。这也说明,这个方案不是牺牲性能换内存,而是把本该属于视频模型的资源还给了它。整个工作流跑下来,再也没有出现系统级卡死。
4.4 提示词模板和参数调优
H3 是小模型,理解力有限,所以 prompt 模板必须给足约束。我用的是:
请把下面的中文描述改写成适合视频生成的英文分镜,保留原意,补充镜头、光线、动作描述,输出不超过三行: {input}实测下来,这个模板的效果稳定,生成的文本不会太长,也不会偏离原意。max_tokens 我固定为 128,温度 0.7,top_p 0.9。温度太高会输出一堆随机词,太低又容易重复。视频模型对文本的要求是“信息密度高”,不是“辞藻华丽”,所以 H3 这种直接输出描述的风格反而很合适。
一个小技巧:H3 输出之后,我用一个简单的文本清洗节点,把多余的换行和引号删掉,再传给视频模型。这个小动作能减少视频模型在某些边缘情况下对格式的敏感反应。
5. 踩坑实录与排查速查表
5.1 崩溃:ctypes 指针类型不匹配
这个坑我在第 3 章里已经详细说过:h3_generate 的 restype 不能直接设成 c_char_p,否则 Python 和 C 层同时释放同一段内存,double-free 直接崩。如果你在日志里看到 pointer being freed was not allocated 或者 segmentation fault,第一个要怀疑的就是这类资源所有权问题。做法就是上面代码里那样:restype 用 c_void_p,拿到指针后手动 cast 再释放。
5.2 随机性:seed 不生效,或者第二次运行输出变了
如果你的 H3 节点第一次运行输出是 A,第二次运行同样参数输出变成 B,大概率是底层的随机数状态在作祟。有些 C 实现会把 RNG 放在全局静态变量里,每次调用都基于上次状态继续;ComfyUI 重新执行工作流时,RNG 没有被重置,所以 seed 形同虚设。
我的解决思路是:把 RNG 状态放进 H3Handle 结构体,每次 h3_create 时用 seed 初始化,每次 h3_generate 时根据传入的 seed 重置。这样每个句柄都是独立的,固定 seed 得到固定输出才能成立。验证方法很简单:在 C 侧写一个测试程序,固定 seed 连续生成 5 次,看结果是否一致。
5.3 权重格式不匹配
h3.c 使用自定义的权重格式,和 Hugging Face 生态的 safetensors 不是一回事。我第一次直接把一个 safetensors 权重喂进去,加载到一半就报 unexpected end of file。后来我写了一个转换脚本,把模型权重转成 h3.c 需要的二进制布局,再放到 models 目录,问题才解决。这个坑很隐蔽,因为启动时不会立刻报错,有时要到第一次生成才崩溃。
如果你也要接入其他来源的模型,记得把转换脚本留在插件目录里,同时在 README 里写清楚权重的原始来源。这个项目半年后再看,你会发现这些文档是救命稻草。
5.4 MacBook 上内存不足:系统卡死而不是直接报错
Linux 上内存不足通常会触发 OOM,进程被杀,但 macOS 的行为不一样,它会先疯狂使用 swap,然后整机卡到几乎无法响应。我第一次跑视频模型时,看到活动监视器里内存压力变成红色,鼠标已经拖不动了,心里只有一个念头:完了。
后来我总结了一套流程,保证不再踩这个雷:
- 先加载 H3,生成文本 prompt,然后卸载 H3。
- 清理 ComfyUI 里所有非必要缓存。
- 再加载 33B 视频模型。
- 视频生成过程中不开任何重型应用,浏览器标签都尽量少开。
这个顺序反了,或者省略其中一步,内存压力就会飙升。这不算技术难点,但确实是经验。
5.5 常见问题速查表
| 现象 | 主要原因 | 处理办法 |
|---|---|---|
| 动态库加载失败 | CPU 架构不匹配 | 保证 Python 与 dylib 同为 arm64 |
| 首次运行正常,二次输出变了 | RNG 状态被共享 | 将 RNG 放进句柄,每个实例独立 |
| ComfyUI 启动时 import 错误 | ctypes.CDLL 找不到 .dylib | 用绝对路径加载,不用 CWD |
| 视频模型加载时系统卡死 | 内存峰值超过统一内存总量 | 先卸载非必要模型,使用量化权重 |
| H3 输出大量套话 | 小模型指令理解弱 | 固定 prompt 模板,限制 max_tokens |
| 权重文件解析失败 | 权重格式不兼容 | 使用转换脚本转成 h3.c 格式 |
| 调用 h3_generate 时崩溃 | double-free 或指针类型错误 | restype 用 c_void_p,手动释放字符串 |
5.6 一个容易忽略的权限坑
还有一个坑,和代码无关。我把 .dylib 放在项目目录里,ComfyUI 是通过符号链接启动的,最终动态库的路径会落到系统受保护目录下。macOS 的隐私保护机制导致程序访问受限目录时报 Operation not permitted,而不是常见的 file not found。我当时查了半天架构和路径问题,最后发现只是目录权限。解决办法是把插件目录放到用户目录的常规位置,保持整个项目在可控的访问范围内。
这段经历给我的教训是:在 macOS 上做 C/Python 桥接,先确认路径和权限是不是正常,再去看架构和 ABI 问题。按这个顺序排查,很多诡异问题都能快速定位。
最后再分享一点个人感受。h3.c 本身不是什么惊艳的模型引擎,但它的极简设计让我重新理解了“把任务拆细”的价值。在 MacBook 这类资源有限的设备上,用轻量组件处理轻量环节,把重量级资源留给真正需要的环节,远比盲目塞进一个大模型更有效。这个 ComfyUI 节点现在已经被我作为日常视频工作流的固定组件,后续我还会继续给它加上更多采样参数和权重切换能力。如果你也在 MacBook 上折腾本地视频生成,希望能从这篇笔记里找到几条少走弯路的线索。