












前几天看到一个挺夸张的结果:
FreeToken 在一台只有 8GB 显存的 RTX 4060 Laptop 上运行 Qwen3.6-35B-A3B,解码速度达到了 39.3 tok/s。
第一反应肯定是:35B 模型只算 4bit 权重,理论上也要 17.5GB 左右,8GB 显存怎么可能放得下?
后来翻了下 FreeToken 的论文、源码,又对比了下 llama.cpp,发现这个问题里最容易混淆的是两个完全不同的概念:
FreeToken 并没有把 35B 参数凭空塞进 8GB 显存里。它做的是把完整的 MoE 专家权重留在内存,把 GPU 显存变成一个会不断换入、换出的专家缓存,再让 CPU 和 GPU 一起处理缓存没有命中的专家。
先把结论用一句话说出来:
MoE 让“大模型每次只用一小部分参数”成为可能,FreeToken 解决的则是“这一小部分参数怎样及时出现在最合适的计算设备上”。
这篇就从这里展开。
之前讨论的是 Qwen3.8-27B,但是 FreeToken 论文里 8GB 显存运行 35B 的案例其实是:
1 | Qwen3.6-35B-A3B |
这两个模型虽然数字接近,结构却完全不同。
| 模型 | 结构 | 总参数 | 每个 token 激活的参数 |
|---|---|---|---|
| Qwen3.8-27B | Dense | 27B | 接近全部 27B |
| Qwen3.6-35B-A3B | MoE | 35B | 约 3B |
Qwen3.8-27B 的官方模型卡明确写的是 Dense 模型。它有 64 层,每层的 FFN 都需要参与计算,没有“256 个专家只挑 8 个”这回事。
Qwen3.6-35B-A3B 则有 40 个 MoE 层,每层包含 256 个路由专家。一个 token 只会选择其中 8 个,再加上 1 个始终参与计算的共享专家。
名字里的:
1 | 35B-A3B |
A3B 只是在说一次前向计算经过了多少参数,并不是模型文件只有 3B,也不是内存只需要保存 3B。
Dense 像是一整栋楼只有一条通道,每次都要从头走到尾。MoE 则像每层都有很多条支路,路由器会根据当前 token 选择其中几条。
这带来了两个结果:
MoE 解决的是计算稀疏,并没有自动解决存储问题。FreeToken 真正处理的就是后半个问题。
先做一个很粗略的计算。假设不考虑量化元数据、对齐和少量高精度权重:
| 权重精度 | 每个参数占用 | 35B 参数的理论大小 |
|---|---|---|
| BF16 / FP16 | 2 bytes | 70GB |
| FP8 / INT8 | 1 byte | 35GB |
| 4bit | 0.5 byte | 17.5GB |
所以即使用 4bit,完整模型也明显超过 8GB。实际运行时还要额外留出:
因此“8GB 运行 35B”绝对不能理解为“35B 的所有权重都常驻 8GB 显存”。
论文中的 4060 Laptop 机器实际有:
1 | GPU: RTX 4060 Laptop,8GB VRAM |
NVFP4 把专家权重压缩到了接近 4bit,完整专家池主要放在 32GiB 系统内存中。显存里放的是:
所以这其实是一个三级存储问题:
1 | 磁盘:保存模型文件 |
显存不再是模型的完整仓库,而是最快的一层缓存。
这跟操作系统的虚拟内存、CPU Cache 有一点像:完整数据在容量更大的慢速介质里,当前最可能使用的数据留在小而快的介质里。
不过模型推理有一个特别麻烦的地方:下一步会用到哪个专家,是运行到路由器之后才知道的。所以光有“缓存”两个字还不够,还需要解决命中、换入、并发和调度的问题。
FreeToken 把一个 MoE 模型拆成两部分:
注意力层、Gated DeltaNet、路由器、共享专家等不会像路由专家那样从几百个里面选几个。它们每次都会使用,所以尽量常驻 GPU。
所有层的全部专家都保留在 Host RAM 中。这一份是完整权重,也是最终兜底:
因此 GPU 缓存大小只影响速度,不影响模型能不能算出正确结果。即使缓存是冷的,Host RAM 中仍然有完整专家。
FreeToken 没有给每一层固定切一块显存,而是把专家缓存做成跨层共享的 slot pool。
一个 slot 保存的是某一层某一个专家需要的全部权重:
1 | (layer_id, expert_id) -> gpu_slot |
以 Qwen3.6-35B-A3B 为例:
1 | 40 层 × 每层 256 个专家 |
显存不需要有 10240 个 slot。假设当前只能放 600 个,就由所有层共同竞争这 600 个位置。最近使用过的专家留下,长时间没用的专家被淘汰。
这比“每层固定留 15 个专家”灵活,因为不同层、不同会话的热点分布并不平均。
大模型推理通常分成两个阶段:
1 | Prefill:一次处理完整输入,得到第一个输出 token |
这两个阶段虽然都经过 MoE 层,访问专家的模式却完全相反。先看 Decode。
假设某一层有 256 个专家,当前 token 被路由到了 8 个专家:
1 | [17, 42, 58, 91, 103, 144, 201, 233] |
FreeToken 会先查询每个专家是否在 GPU slot 中。
源码中的核心映射可以简化成:
1 |
|
一次查询之后会得到两组:
1 | 命中 H:已经在 GPU 中,可以立即计算 |
命中的部分很好处理。真正有意思的是未命中。
传统想法通常有两个极端:
1 | 方案 A:未命中专家全部通过 PCIe 搬到 GPU |
FreeToken 选择了第三种:
1 | 一部分搬到 GPU |
具体执行顺序大致是:
1 | 1. 路由器给出逻辑 expert id |
对应到源码,python/freetoken/layers/moe.py 中的 _decode_hybrid() 做的正是这件事。它先提交 CPU task,再调用 copy_missing() 和 GPU expert GEMM,最后:
1 | return gpu_routed + cpu_routed |
为什么可以直接相加?
一个标准 MoE 层的结果本来就是多个专家输出的加权和:
1 | y = g1 × E1(x) + g2 × E2(x) + ... + gk × Ek(x) |
把专家集合拆成 GPU 集合和 CPU 集合,只是改变了计算地点:
1 | y_gpu = GPU 上专家的加权和 |
FreeToken 会保证一条路由只由一边计算一次。源码中,交给 CPU 的专家在 GPU 路径里权重会被置零,交给 GPU 的专家在 CPU 路径里会被标记成 -1 并跳过。
所以这里不是删掉几个专家,也不是近似计算。除了模型本身使用的量化精度,CPU/GPU 分流不会额外改变 MoE 的数学定义。
现在还剩一个问题:假设有 4 个专家没有命中,到底应该搬几个?
如果全部搬运,CPU 可能在旁边闲着。如果全部交给 CPU,PCIe 和 GPU 又会闲着。最合适的比例跟机器有关。
FreeToken 用两个实际测出来的带宽做决定:
1 | B_P:内存通过 PCIe 向 GPU 搬专家的有效带宽 |
注意,PCIe DMA 和 CPU 计算都要从同一份内存中读专家权重。PCIe 正在以 B_P 读数据时,能留给 CPU 的大约是:
1 | B_R = B_H - B_P |
假设每个专家大小都是 S,两个并发分支的耗时近似为:
1 | GPU 搬运分支:T_fill ≈ q × S / B_P |
两个任务是并行的,总时间取较慢的那个。要让硬件利用得最充分,就尽量让它们同时结束:
1 | q × S / B_P ≈ (m - q) × S / (B_H - B_P) |
两边约掉 S,整理一下:
1 | q* ≈ m × B_P / B_H |
这就是论文中的 q* policy。
这个推导默认 B_H > B_P,也就是 PCIe 占满以后还有一部分 Host 带宽能留给 CPU。实际执行时还会把 q 取整并限制在合法范围。对于 m > 0 的情况,可以把它理解成:
1 | q = clamp(round(q*), 1, m) |
当 B_H 接近或者低于 B_P 时,CPU 已经没有值得利用的剩余带宽,策略就退化成 q = m,把 miss 都送去 GPU;至少保留一次 fill,也能让冷缓存继续升温。
用论文里 4060 Laptop 的实测数据算一次:
1 | B_P = 11.8 GB/s |
取整之后就是:
1 | 1 个专家通过 PCIe 搬进 GPU |
这里有一个挺反直觉的地方:CPU 并不是因为算力特别强才参与,而是因为 Decode 的 batch 通常很小,专家 GEMV 更容易受内存带宽限制。CPU 计算那 3 个专家,实际上是在利用 PCIe 没有吃掉的剩余内存带宽。
如果换到另一台机器:
q* 会接近 m,更适合多搬到 GPUq* 分流所以 FreeToken 不靠显卡型号表拍脑袋。它提供:
1 | ft bench bw |
这个命令使用真实的专家搬运和 CPU MoE kernel 测量带宽,并把结果写到:
1 | ~/.cache/freetoken/benchbw.json |
ft serve --moe-backend auto 会根据这份 profile 决定继续使用 offload,还是升级为 CPU/GPU 并行的 hybrid。
如果每个 token 选中的专家完全随机,刚放进 GPU 的专家下一步就再也不用,那么缓存没有太大价值。
实际路由通常存在短期局部性。相邻 token 处在同一段语义、同一种语言或同一段代码中,路由到的专家经常重叠:
1 | token t: [17, 42, 58, 91, 103, 144, 201, 233] |
第二个 token 中就有多个专家可以直接命中。
FreeToken 使用全局 LRU:
1 | 命中:更新 usage |
源码里的实现还多做了一步:命中判断、miss 去重、q 的计算、victim 选择和 ID 改写都在 GPU kernel 中完成。
原因是 CUDA Graph 喜欢固定的控制流和固定形状。如果每一层都把 expert id 拷回 CPU,让 Python 决定淘汰谁,再通知 GPU,光同步就会非常昂贵。
FreeToken 的做法是:
1 | 控制流保持固定 |
工作缓冲区大小固定,再用一个有效数量屏蔽没有使用的部分。这样动态 LRU 仍然可以被放进静态捕获的 CUDA Graph。
论文给出的路由回放结果也能看到这个差别。在相同缓存容量下,Qwen3.6 的 Decode miss rate:
1 | FreeToken 全局 LRU:16% |
这个数字不是说 LRU 永远都会命中 84%,而是说明在论文的四类 agent workload 和指定缓存容量下,token 之间确实存在可以利用的路由局部性。
看完 Decode 很容易产生一个想法:Prefill 也让选中的专家按需进入缓存不就行了?
问题在于 Prefill 不是一次只处理一个 token,而是一次处理几千个 token。
对单个 token 来说:
1 | 256 个专家中只选 8 个 |
但是一批 8192 个 token 各选 8 个,取所有路由结果的并集后,几乎每个专家都会被碰到:
1 | token 1 -> 8 个专家 |
也就是说,MoE 在 Decode 阶段是稀疏工作集,到了长 Prompt 的 Prefill 阶段却接近稠密工作集。
如果还逐个 miss、逐个搬运,会产生很多细碎传输,GPU 也会不断等 PCIe。
FreeToken 因此给 Prefill 设计了另一条路径:完整层双缓冲。
它从全局专家 slot pool 中借出能容纳两层完整专家的空间:
1 | Buffer A:GPU 正在计算第 l 层 |
第 l 层算完后,两块 buffer 交换角色:
1 | Buffer B:计算第 l + 1 层 |
因为搬的是下一层完整专家,甚至不需要等下一层路由结果出来就可以提前开始传输。理想情况下,一层的计算时间被下一层的搬运时间完全覆盖。
这也解释了为什么至少要有两层专家大小的缓存才能开启 overlap。源码中 OffloadMoeCache 会直接检查:
1 | cache_size >= 2 × num_experts |
空间不足时不会硬撑,而是回退到按需 Prefill。
论文在 RTX 5090 上关闭双缓冲后,4K、8K、16K Prompt 的 Prefill 吞吐分别下降了 19%、25% 和 26%。Prompt 越长,完整专家池的搬运越无法忽略。
普通聊天通常只是在历史后面追加内容,前缀 KV Cache 很容易复用。
Coding Agent 不一样。一次工具调用之后,框架可能会:
Qwen3.6 这类混合架构不仅有完整注意力,还有 Gated DeltaNet 这样的递归状态。KV Cache 可以按 token 保存,递归层却把前缀压缩成一个不断演进的 state。
如果中间一块上下文被改了,修改点后面的 state 都失效。检查点又很少的话,只能从很早的位置重新 Prefill。
FreeToken 的做法是把有限的 recurrent-state checkpoint 放在更可能存活的语义边界:
1 | 一轮对话结束 |
Agent 框架通常也是按这些完整区块删除内容,所以边界之前的前缀更可能保持不变。恢复时:
1 | 找到仍然有效的最深语义锚点 |
这部分跟专家缓存解决的不是同一个问题:
| 缓存 | 缓存的东西 | 主要减少什么 |
|---|---|---|
| 专家缓存 | MoE 权重 | Decode 时的 PCIe 搬运和 CPU 计算 |
| KV / 状态缓存 | Prompt 的计算结果 | 多轮 Agent 中重复 Prefill |
FreeToken 把它们放进同一个 serving engine,是因为本地 Agent 的总等待时间同时受两边影响。只有 Decode 跑得快,但每次工具调用后重新 Prefill 一分钟,体验仍然不行。
刚启动模型时上下文很短,可以把更多剩余显存用作专家缓存。对话越来越长后,KV Cache 会持续增长,而专家工作集没有同比增长。
静态切分很容易出现:
1 | 专家缓存很大,但 KV Cache 不够 |
FreeToken 把这两块都看成运行时资源。在 scheduler 没有待处理 Prefill、没有进行中的 Decode 时,到达一个 safe point,才执行 cache rebuild:
1 | 暂停接收新的计算批次 |
Host RAM 中的完整专家池不需要重新加载,所以不必重启进程或者再次读取整个模型。
可以通过:
1 | ft ctl cache |
查看和调整缓存。调整不是发生在某个 token 计算到一半的时候,而是排队等到 scheduler 完全空闲的安全点。
模型文件里的专家权重未必正好是运行时最方便的排列。FreeToken 会把不同模型的专家整理成少数几种 expert bank,并把:
1 | flat_id = layer_id × num_experts + expert_id |
作为每个 bank 的第一维。
同一个 flat_id 在 gate_up、down、scale 等 bank 中对应同一个完整专家。这样 GPU slot cache 和 CPU executor 共用一套逻辑 ID,不需要理解每种模型原始 checkpoint 的布局。
FTW 是 FreeToken 的 fast weight format。它不是新的量化算法,主要作用是预先把权重转换成运行时最终布局。
传统加载过程可能是:
1 | 申请巨大空缓冲区 |
FreeToken 的路径是:
1 | 从磁盘直接读入最终 Host 布局 |
Pinned memory,也就是 page-locked memory,不会被操作系统随便换出,CUDA 可以对它进行更稳定的异步 DMA。但提前 pin 一个巨大的空缓冲区会触发页面分配和清零,随后又马上被模型数据覆盖,白做了一遍内存写入。
FTW 避免了这部分启动开销。GPU 专家缓存也不需要专门 warmup,第一次请求从冷缓存开始,沿用普通 miss 路径自然升温。
ft checkpoint 是可选步骤,FreeToken 也能直接读取支持的 Hugging Face checkpoint:
1 | ft checkpoint \ |
把论文和代码对照起来,大致是下面这些位置:
| 源码 | 主要职责 |
|---|---|
python/freetoken/moe/offload_cache.py | 全局专家 slot、LRU 映射、专家 bank、Prefill 双缓冲 |
python/freetoken/moe/offload_kernels.py | GPU 上做 hit/miss 分类、q 计算、victim 选择和 ID 改写 |
python/freetoken/layers/moe.py | 串起路由、CPU 提交、PCIe copy、GPU GEMM 和结果合并 |
python/freetoken/moe/cpu_executor.py | 固定 CPU worker、SIMD 和量化专家计算 |
python/freetoken/moe/benchbw.py | 测量真实 CPU MoE 与 PCIe 搬运带宽 |
python/freetoken/engine/cache_budget.py | 在专家 slot 和 KV page 之间计算显存预算 |
python/freetoken/scheduler/scheduler.py | 在 idle safe point 执行运行时 cache rebuild |
其中最核心的 Decode 伪代码其实不复杂:
1 | routes = router(hidden_states) |
真正难的是让 lookup、动态 miss 数、异步拷贝、CPU worker 和 CUDA Graph 同时成立,而且不能每层都把控制权交回 Python。
两者最表面的相同点都是:
1 | 显存不够,就让一部分权重留在内存 |
但“哪一部分留在内存”和“运行时怎么使用它”差别很大。
以一个 64 层 Dense 模型为例:
1 | 第 0~39 层:CPU |
模型加载完成后,层的位置基本就固定了。每生成一个 token,仍然要依次经过全部 64 层:
1 | CPU 计算前 40 层 |
这里通常移动的是层与层之间较小的激活,不是每个 token 都把完整层权重搬来搬去。
这种方式非常适合 Dense 模型,因为 Dense 没有路由器,任何一层的 FFN 权重每个 token 都会使用。也就谈不上“缓存最近用到的专家”。
当前 llama.cpp 的参数是:
1 | -ngl N |
现在也可以使用 auto 或 all,让运行时自动拟合显存。
如果模型是 MoE,当前 llama.cpp 还支持:
1 | -cmoe |
把全部 MoE 专家权重留在 CPU;或者:
1 | -ncmoe N |
只把前 N 个 MoE 层的专家留在 CPU。
这已经比单纯的 -ngl 更接近 FreeToken:注意力等非专家部分可以继续在 GPU,而巨大的专家权重留在内存。
llama.cpp 当前的 scheduler 还能根据路由 ID,只处理被选中的 expert sub-row,而不是无条件复制所有专家。不过截至 2026 年 8 月 23 日,官方 master 的这条路径没有 FreeToken 那样跨 token 持久存在的 expert -> slot 映射。被选中的专家可以在一次 forward 中搬到 GPU tensor 的原始偏移位置,但下一次相同专家仍可能再次搬运。
llama.cpp 社区已经有 persistent expert cache 的 RFC 和 PR,例如 #20757 和 #26824,说明这个方向也正在快速演进。这里对比的是本文日期的官方 CLI 和 master 行为,不应该理解成 llama.cpp 永远不会有专家缓存。
FreeToken 的主要单位不是“第几层放在哪”,而是:
1 | 当前这个 (layer, expert) 应该在哪里执行 |
| 对比项 | llama.cpp -ngl | llama.cpp --cpu-moe | FreeToken hybrid |
|---|---|---|---|
| 主要对象 | 完整层 | MoE 专家 tensor | 单个 (layer, expert) |
| 放置时机 | 加载时静态决定 | 加载时决定专家在 CPU | 每个 Decode step 动态决定 |
| GPU 专家驻留 | 不适用 | 官方 master 无跨 step slot cache | 全局 LRU 持久缓存 |
| Cache miss | 不适用 | CPU 计算或按路由搬运 | 一部分搬 GPU,一部分 CPU 计算 |
| CPU/GPU 比例 | 由层数决定 | 由 CPU MoE 层数等配置决定 | q* 根据实测带宽决定 |
| Prefill | CPU/GPU 按层执行 | 后端相关的专家处理 | 完整层双缓冲隐藏 PCIe |
| Agent 状态复用 | KV/Context checkpoint | KV/Context checkpoint | KV + 语义 recurrent-state anchor |
| 格式和平台 | GGUF,CPU/CUDA/Metal/Vulkan 等 | 同左 | 当前开源 CLI 主要是 Linux x86_64 + NVIDIA CUDA |
所以它们不是简单的“一个能卸载,一个不能卸载”。
llama.cpp 是通用、本地部署范围很广的推理引擎,Dense 模型、Apple Silicon 和各种后端都是它的优势。FreeToken 则把目标收得更窄,专门围绕“显存装不下完整 MoE 专家池”设计整个 serving stack。
回到最开始容易串在一起的问题。
Qwen3.8-27B 是 Dense 模型:
1 | 没有 256 选 8 的路由专家 |
27B 做 4bit 的理论权重大小约为:
1 | 27B × 0.5 byte = 13.5GB |
再加量化元数据、KV Cache 和运行时开销,8GB 4060 无法让它全量常驻显存。
这时更合适的思路是:
1 | GGUF 低比特量化 |
例如:
1 | llama-server \ |
具体能放多少层取决于 GGUF 大小、上下文长度、KV 精度、系统当前可用显存和 llama.cpp 版本。-ngl auto 比照搬别人机器上的固定层数更稳妥。
FreeToken 当前文档虽然支持 Qwen3.6-27B Dense,但 Dense 模型的 auto 会解析为 fused,也就是要求权重常驻 GPU,并不会启用 MoE offload/hybrid 这套专家机制。当前支持列表也还没有列出刚发布的 Qwen3.8-27B。
因此:
1 | 8GB 4060 + Qwen3.6-35B-A3B MoE |
两个模型参数接近,但运行路径不能混着解释。
当前开源 CLI 的要求是:
1 | Linux x86_64 |
所以它目前不是 M3 Pro 上 llama.cpp/Metal 的替代品。Mac 仍然更适合 llama.cpp 或 MLX。
安装:
1 | uv venv |
先测一次这台机器的 CPU/PCIe 带宽:
1 | ft bench bw --dtype nvfp4 |
然后启动论文中 8GB 机器使用的 NVFP4 模型:
1 | ft serve --model nvidia/Qwen3.6-35B-A3B-NVFP4 |
只提供 --model 也可以,dtype、attention backend、MoE backend、专家缓存和 KV 容量会根据 checkpoint、显存和带宽 profile 自动推导。
想明确观察模式,可以分别测试:
1 |
|
运行后可以查看:
1 | ft ctl stats |
对比时不要只测一句 hello。至少分别看:
短 Prompt、冷缓存和长时间 Agent 会话测到的是三种不同状态。
看到 39.3 tok/s 很容易只记住“8GB 显存”,但这套方案还有几个前提。
显存需求降低不等于总内存需求消失。论文里的 4060 Laptop 有 32GiB 内存,并且使用 NVFP4。只有 16GB 系统内存时,同一模型可能会进入 swap,速度会急剧下降,甚至无法稳定加载。
Dense 模型每个 token 都要读完整 FFN,没有小而稳定的专家工作集。FreeToken 的核心专家缓存不会把 27B Dense 变成 3B active。
需要同时看:
同一张 4060 放在不同笔记本里,内存和 PCIe 配置不同,q* 和速度也会不同。
这是论文在指定 4060 Laptop、NVFP4 模型和 coding-agent workload 下测得的 Decode 吞吐。它不等于:
刚启动时专家缓存是空的。如果话题、语言或者任务突然变化,路由热点也可能变化,短时间 miss 会增加。LRU 利用的是局部性,不是消灭所有 miss。
现在再看“8GB 显存运行 35B”,可以把它拆成五步:
1 | 1. Qwen3.6-35B-A3B 是 MoE,每个 token 只激活约 3B |
llama.cpp 的 -ngl 主要解决的是“完整层怎样静态分布到 CPU 和 GPU”,--cpu-moe 则进一步允许专家留在 CPU。FreeToken 在这个基础问题上继续往前走,把专家当成随 token 路由变化的动态工作集,并围绕它设计缓存、带宽分流、Prefill 和运行时显存管理。
所以 FreeToken 真正有意思的地方并不是一个神奇的“8GB 跑 35B”数字,而是它换了一个看待本地机器的方式:
1 | GPU 不是唯一计算设备 |
但这条路成立的关键仍然是 MoE 的稀疏性。换成 Qwen3.8-27B 这样的 Dense 模型,还是要老老实实回到量化、层卸载和内存带宽这几个问题上。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。