前几天看到 Cua 团队 8 月 11 日发的那篇东西,看完第一反应是:这不就是我们这行最典型的“黑盒骗法”吗——不是骗人,是骗程序。macOS 虚机里的 llama.cpp 跑得慢,不是 GPU 不行,是它问错了问题,然后被诚实地骗了。今天不调库,我们把这套骗术的最小骨架搓出来,搓完你就明白那 11 倍加速是怎么来的。
先给最糙的直觉。虚拟机里的程序不知道自己 GPU 多能干,它得去问 Metal:“你这设备是什么 family?线程组内存给多少?”Metal 老老实实回答一个保守值。llama.cpp 一看:“哦,你这么弱,那我挑个怂的内核跑吧。”于是 GPU 明明跑得动猛内核,程序自己先怂了。垫片干的事就一件:插在中间,把两个回答改掉。
我们来搓个最简版。用纯 Python 模拟“设备回答问题”和“程序按回答挑内核”这两半:
class FakeVMDevice:
def __init__(self):
self.family = 1007 # 虚机里 Metal 保守报的等级
self.max_tg_mem = 32 * 1024 # 32 KB
class KernelPicker:
def __init__(self, dev):
self.dev = dev
def pick(self):
# 这一行干这件事:按能力回答挑内核
if self.dev.family >= 1009 and self.dev.max_tg_mem >= 64 * 1024:
return "fast_gpu_kernel"
return "cpu_fallback"
dev = FakeVMDevice()
print(dev.family, dev.max_tg_mem, "->", KernelPicker(dev).pick())
跑一下,看看出来啥:
1007 32768 -> cpu_fallback
看见没,设备一个字没撒谎,程序就自己掉进慢路径了。现在搓垫片——所谓垫片,就是进程内拦一层,把那两个回答换成体面值:
class Shim:
def __init__(self, dev):
self.dev = dev
def family(self):
return max(self.dev.family, 1009) # 提到 Apple family 9
def max_tg_mem(self):
return max(self.dev.max_tg_mem, 64 * 1024) # 32KB -> 64KB
shim = Shim(dev)
class ShimmedPicker(KernelPicker):
def pick(self):
if shim.family() >= 1009 and shim.max_tg_mem() >= 64 * 1024:
return "fast_gpu_kernel"
return "cpu_fallback"
print(shim.family(), shim.max_tg_mem(), "->", ShimmedPicker(dev).pick())
1009 65536 -> fast_gpu_kernel
改动就两个返回值,一行不多。真实那个垫片也是这么朴素:把 family 等级提到 1009,线程组内存从 32 KB 提到 64 KB,别的什么都不碰——不改内核、不做 GPU 直通,所有负载还是走 Apple Virtualization.framework 的原生 GPU 路径。它只是让虚机里的进程“以为自己在一台更好的机器上”,于是敢挑那个本来就能跑的猛内核。
效果是真能吓一跳的。M1 Ultra 上跑 TinyLlama 1.1B,提示处理直接 11.08 倍,冲到裸机的 98.25%;token 生成 16.36 倍,到裸机 72.06%。换 Gemma 4 12B QAT Q4_0,提示处理 7.20 倍到裸机 99.59%,生成 14.54 倍到 94.82%——这个数字我是真没料到,虚机里跑到裸机 99.59%,说明慢的从来不是 GPU,是那个自报家门的回答。30B 的 Muse Glimmer 在 64 GiB 虚机里也有 7.55 / 8.87 倍。
有意思的反例是 MLX-LM:垫片开了跟没开一样,比率 1.005 和 0.993。为什么?因为 MLX 在没改的虚机里本来就跑得快——它挑内核的逻辑没被那两个保守回答卡住。这反过来坐实了整个诊断:慢的病灶不在虚拟化层,在“按能力回答挑路径”这一步。同一个虚机,一个框架被卡死,一个框架毫发无伤,差异全在客户端怎么用那两个返回值。这种反例,比十组加速数字都更能说明机制。
现在回头说我们这个糙版本糙在哪。真实垫片拦的是 Metal 的私有 API 行为——注意“私有”和“版本敏感”这两个词,这是整件事最脆的地方。Apple 哪次系统更新顺手改了这些行为,垫片就废了,而且每个主机和 guest 的组合都得单独验一遍。我们上面那个 Shim 类是拦在自己的代码里,想怎么改怎么改;真实的垫片是拦在别人的进程和系统的 Metal 实现之间,人家没承诺过这两个行为永远不变。骗程序容易,难的是骗得久。
还有一点我们这版完全没碰:为什么偏偏是这两个值?family 9 和 64 KB 不是拍脑袋的,是 llama.cpp 那些猛内核的准入门槛——差一点就整个不给你用,多很多也没额外好处。垫片的作者得先读懂下游内核的挑选逻辑,才知道改哪两个数最划算。这跟调参一个道理:知道往哪拧,比拧本身值钱。
所以你看,这玩意儿拆开就这么点东西:程序问能力、按回答挑路径,垫片把回答改体面,程序就敢跑猛内核。十几行 Python 能把骨架演完,真实实现的全部难度在“怎么在别人进程里拦住那两个问题”和“拦完之后别把别的弄坏”。
边界照例划清楚:上面这个 Shim 是用来懂的,不是用来跑真活的。真想在自家虚机里提速,去看 Cua 他们那份东西,别照着我这个类去 Hook Metal——那两头都是真枪实弹的私有接口。至于“进程内拦截”这套手艺还能干别的什么,比如拦截别的能力查询看看别的框架怂在哪,那个我们下次搓。
