8GB显存如何跑35B:深入理解FreeToken的专家缓存与CPU-GPU协同
前几天看到一个挺夸张的结果:
FreeToken 在一台只有 8GB 显存的 RTX 4060 Laptop 上运行 Qwen3.6-35B-A3B,解码速度达到了 39.3 tok/s。
第一反应肯定是:35B 模型只算 4bit 权重,理论上也要 17.5GB 左右,8GB 显存怎么可能放得下?
后来翻了下 FreeToken 的论文、源码,又对比了下 llama.cpp,发现这个问题里最容易混淆的是两个完全不同的概念:
- 模型能不能全部放进显存
- 生成一个 token 时,到底需要计算多少参数
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
2
335B-A3B
│ └── 每个 token 大约激活 3B 参数
└────── 模型一共仍然有 35B 参数
A3B 只是在说一次前向计算经过了多少参数,并不是模型文件只有 3B,也不是内存只需要保存 3B。
Dense 像是一整栋楼只有一条通道,每次都要从头走到尾。MoE 则像每层都有很多条支路,路由器会根据当前 token 选择其中几条。
这带来了两个结果:
- 计算量可以接近一个 3B 模型
- 完整的 35B 权重仍然必须放在某个地方
MoE 解决的是计算稀疏,并没有自动解决存储问题。FreeToken 真正处理的就是后半个问题。
35B 模型的内存账应该怎么算
先做一个很粗略的计算。假设不考虑量化元数据、对齐和少量高精度权重:
| 权重精度 | 每个参数占用 | 35B 参数的理论大小 |
|---|---|---|
| BF16 / FP16 | 2 bytes | 70GB |
| FP8 / INT8 | 1 byte | 35GB |
| 4bit | 0.5 byte | 17.5GB |
所以即使用 4bit,完整模型也明显超过 8GB。实际运行时还要额外留出:
- KV Cache
- 中间激活
- CUDA Graph 和算子工作区
- 视觉编码器或者其他非专家权重
- 桌面环境占用的显存
因此“8GB 运行 35B”绝对不能理解为“35B 的所有权重都常驻 8GB 显存”。
论文中的 4060 Laptop 机器实际有:1
2
3
4GPU: RTX 4060 Laptop,8GB VRAM
内存: 32GiB LPDDR5
PCIe: 4.0 x8
模型: Qwen3.6-35B-A3B NVFP4
NVFP4 把专家权重压缩到了接近 4bit,完整专家池主要放在 32GiB 系统内存中。显存里放的是:
- 注意力、路由器、归一化等非专家权重
- KV Cache 和运行时工作区
- 一部分最近使用的专家
所以这其实是一个三级存储问题:1
2
3
4
5磁盘:保存模型文件
↓ 启动时读取
内存:保存完整专家池,是权重的 source of truth
↓ 按需搬运
显存:保存非专家权重、KV Cache 和热点专家
显存不再是模型的完整仓库,而是最快的一层缓存。
这跟操作系统的虚拟内存、CPU Cache 有一点像:完整数据在容量更大的慢速介质里,当前最可能使用的数据留在小而快的介质里。
不过模型推理有一个特别麻烦的地方:下一步会用到哪个专家,是运行到路由器之后才知道的。所以光有“缓存”两个字还不够,还需要解决命中、换入、并发和调度的问题。
FreeToken 的基本结构
FreeToken 把一个 MoE 模型拆成两部分:
非专家权重常驻 GPU
注意力层、Gated DeltaNet、路由器、共享专家等不会像路由专家那样从几百个里面选几个。它们每次都会使用,所以尽量常驻 GPU。
完整路由专家池常驻内存
所有层的全部专家都保留在 Host RAM 中。这一份是完整权重,也是最终兜底:
- GPU 缓存命中,直接在 GPU 上算
- GPU 缓存未命中,可以把专家搬进显存
- 如果不值得搬,也可以直接由 CPU 计算
因此 GPU 缓存大小只影响速度,不影响模型能不能算出正确结果。即使缓存是冷的,Host RAM 中仍然有完整专家。
剩余显存组成全局专家缓存
FreeToken 没有给每一层固定切一块显存,而是把专家缓存做成跨层共享的 slot pool。
一个 slot 保存的是某一层某一个专家需要的全部权重:1
(layer_id, expert_id) -> gpu_slot
以 Qwen3.6-35B-A3B 为例:1
240 层 × 每层 256 个专家
= 10240 个不同的 (layer, expert)
显存不需要有 10240 个 slot。假设当前只能放 600 个,就由所有层共同竞争这 600 个位置。最近使用过的专家留下,长时间没用的专家被淘汰。
这比“每层固定留 15 个专家”灵活,因为不同层、不同会话的热点分布并不平均。
生成阶段:专家缓存到底怎么工作
大模型推理通常分成两个阶段:1
2Prefill:一次处理完整输入,得到第一个输出 token
Decode:之后每轮只新增一个 token
这两个阶段虽然都经过 MoE 层,访问专家的模式却完全相反。先看 Decode。
假设某一层有 256 个专家,当前 token 被路由到了 8 个专家:1
[17, 42, 58, 91, 103, 144, 201, 233]
FreeToken 会先查询每个专家是否在 GPU slot 中。
源码中的核心映射可以简化成:1
2
3
4
5
6
7
8# 正向表:某层某专家当前在哪个 GPU slot
slot_for_id[layer_id][expert_id] = slot_id
# 反向表:某个 slot 当前装的是谁
id_of_slot[slot_id] = layer_id * num_experts + expert_id
# LRU 时间戳
usage[slot_id] = current_step
一次查询之后会得到两组:1
2命中 H:已经在 GPU 中,可以立即计算
未命中 M:仍然只在内存中
命中的部分很好处理。真正有意思的是未命中。
传统想法通常有两个极端:1
2方案 A:未命中专家全部通过 PCIe 搬到 GPU
方案 B:未命中专家全部留在内存,由 CPU 计算
FreeToken 选择了第三种:1
2一部分搬到 GPU
另一部分同时交给 CPU
具体执行顺序大致是:1
2
3
4
5
6
7
8
91. 路由器给出逻辑 expert id
2. 在全局 LRU 缓存中查找 slot
3. 命中的专家直接改写为物理 slot id
4. 为需要搬运的 miss 选择 LRU victim
5. 先提交 CPU 专家计算
6. 同时通过 PCIe 把另一部分 miss 搬进 GPU slot
7. GPU 计算“原有 hit + 新填入的专家”
8. 等待 CPU 部分完成
9. 把 CPU 和 GPU 的部分结果相加
对应到源码,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
2
3
4y_gpu = GPU 上专家的加权和
y_cpu = CPU 上专家的加权和
y = y_gpu + y_cpu
FreeToken 会保证一条路由只由一边计算一次。源码中,交给 CPU 的专家在 GPU 路径里权重会被置零,交给 GPU 的专家在 CPU 路径里会被标记成 -1 并跳过。
所以这里不是删掉几个专家,也不是近似计算。除了模型本身使用的量化精度,CPU/GPU 分流不会额外改变 MoE 的数学定义。
q*:为什么不是一半给 CPU、一半给 GPU
现在还剩一个问题:假设有 4 个专家没有命中,到底应该搬几个?
如果全部搬运,CPU 可能在旁边闲着。如果全部交给 CPU,PCIe 和 GPU 又会闲着。最合适的比例跟机器有关。
FreeToken 用两个实际测出来的带宽做决定:1
2
3
4B_P:内存通过 PCIe 向 GPU 搬专家的有效带宽
B_H:CPU 直接读取并计算专家时的有效内存带宽
m:这一层没有命中的专家数量
q:其中选择搬入 GPU 的数量
注意,PCIe DMA 和 CPU 计算都要从同一份内存中读专家权重。PCIe 正在以 B_P 读数据时,能留给 CPU 的大约是:1
B_R = B_H - B_P
假设每个专家大小都是 S,两个并发分支的耗时近似为:1
2GPU 搬运分支:T_fill ≈ q × S / B_P
CPU 计算分支:T_cpu ≈ (m - q) × S / (B_H - 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
2
3
4
5
6B_P = 11.8 GB/s
B_H = 47.5 GB/s
m = 4
q* ≈ 4 × 11.8 / 47.5
≈ 0.99
取整之后就是:1
21 个专家通过 PCIe 搬进 GPU
3 个专家直接由 CPU 计算
这里有一个挺反直觉的地方:CPU 并不是因为算力特别强才参与,而是因为 Decode 的 batch 通常很小,专家 GEMV 更容易受内存带宽限制。CPU 计算那 3 个专家,实际上是在利用 PCIe 没有吃掉的剩余内存带宽。
如果换到另一台机器:
- PCIe 很快、CPU 内存带宽不高,
q*会接近m,更适合多搬到 GPU - 内存带宽明显高于 PCIe,CPU 可以承接更多 miss
- 专家本来就在缓存里,则根本不进入
q*分流
所以 FreeToken 不靠显卡型号表拍脑袋。它提供:1
ft bench bw
这个命令使用真实的专家搬运和 CPU MoE kernel 测量带宽,并把结果写到:1
~/.cache/freetoken/benchbw.json
ft serve --moe-backend auto 会根据这份 profile 决定继续使用 offload,还是升级为 CPU/GPU 并行的 hybrid。
为什么 LRU 专家缓存会有效
如果每个 token 选中的专家完全随机,刚放进 GPU 的专家下一步就再也不用,那么缓存没有太大价值。
实际路由通常存在短期局部性。相邻 token 处在同一段语义、同一种语言或同一段代码中,路由到的专家经常重叠:1
2
3token t: [17, 42, 58, 91, 103, 144, 201, 233]
token t + 1: [17, 42, 61, 91, 103, 144, 205, 233]
↑ ↑ ↑ ↑ ↑ ↑
第二个 token 中就有多个专家可以直接命中。
FreeToken 使用全局 LRU:1
2
3
4命中:更新 usage
未命中:找最久没有使用的 slot
换入:更新正向表和反向表
执行:把逻辑 expert id 改写为 slot id
源码里的实现还多做了一步:命中判断、miss 去重、q 的计算、victim 选择和 ID 改写都在 GPU kernel 中完成。
原因是 CUDA Graph 喜欢固定的控制流和固定形状。如果每一层都把 expert id 拷回 CPU,让 Python 决定淘汰谁,再通知 GPU,光同步就会非常昂贵。
FreeToken 的做法是:1
2控制流保持固定
变化的命中数、miss 数和 victim 都作为 GPU 上的数据
工作缓冲区大小固定,再用一个有效数量屏蔽没有使用的部分。这样动态 LRU 仍然可以被放进静态捕获的 CUDA Graph。
论文给出的路由回放结果也能看到这个差别。在相同缓存容量下,Qwen3.6 的 Decode miss rate:1
2
3FreeToken 全局 LRU:16%
KTransformers 预填充时更新的静态放置:41%
llama.cpp 路由无关的静态切分:62%
这个数字不是说 LRU 永远都会命中 84%,而是说明在论文的四类 agent workload 和指定缓存容量下,token 之间确实存在可以利用的路由局部性。
Prefill 为什么不能也按需加载
看完 Decode 很容易产生一个想法:Prefill 也让选中的专家按需进入缓存不就行了?
问题在于 Prefill 不是一次只处理一个 token,而是一次处理几千个 token。
对单个 token 来说:1
256 个专家中只选 8 个
但是一批 8192 个 token 各选 8 个,取所有路由结果的并集后,几乎每个专家都会被碰到:1
2
3
4
5
6token 1 -> 8 个专家
token 2 -> 另外一些专家
...
token 8192 -> 另外一些专家
所有 token 的并集 -> 接近完整 256 个专家
也就是说,MoE 在 Decode 阶段是稀疏工作集,到了长 Prompt 的 Prefill 阶段却接近稠密工作集。
如果还逐个 miss、逐个搬运,会产生很多细碎传输,GPU 也会不断等 PCIe。
FreeToken 因此给 Prefill 设计了另一条路径:完整层双缓冲。
它从全局专家 slot pool 中借出能容纳两层完整专家的空间:1
2Buffer A:GPU 正在计算第 l 层
Buffer B:PCIe 同时传输第 l + 1 层
第 l 层算完后,两块 buffer 交换角色:1
2Buffer B:计算第 l + 1 层
Buffer A:传输第 l + 2 层
因为搬的是下一层完整专家,甚至不需要等下一层路由结果出来就可以提前开始传输。理想情况下,一层的计算时间被下一层的搬运时间完全覆盖。
这也解释了为什么至少要有两层专家大小的缓存才能开启 overlap。源码中 OffloadMoeCache 会直接检查:1
cache_size >= 2 × num_experts
空间不足时不会硬撑,而是回退到按需 Prefill。
论文在 RTX 5090 上关闭双缓冲后,4K、8K、16K Prompt 的 Prefill 吞吐分别下降了 19%、25% 和 26%。Prompt 越长,完整专家池的搬运越无法忽略。
Agent 场景里还有一种重复:上下文被改写
普通聊天通常只是在历史后面追加内容,前缀 KV Cache 很容易复用。
Coding Agent 不一样。一次工具调用之后,框架可能会:
- 删除旧的 thinking
- 用占位符替换太老的工具输出
- 截断历史 observation
- 保留前半段,再改写中间某一块
Qwen3.6 这类混合架构不仅有完整注意力,还有 Gated DeltaNet 这样的递归状态。KV Cache 可以按 token 保存,递归层却把前缀压缩成一个不断演进的 state。
如果中间一块上下文被改了,修改点后面的 state 都失效。检查点又很少的话,只能从很早的位置重新 Prefill。
FreeToken 的做法是把有限的 recurrent-state checkpoint 放在更可能存活的语义边界:1
2
3
4一轮对话结束
thinking 结束
tool call 开始/结束
tool output 开始/结束
Agent 框架通常也是按这些完整区块删除内容,所以边界之前的前缀更可能保持不变。恢复时:1
2
3找到仍然有效的最深语义锚点
恢复对应的 KV / recurrent state
只重新计算变化后的后缀
这部分跟专家缓存解决的不是同一个问题:
| 缓存 | 缓存的东西 | 主要减少什么 |
|---|---|---|
| 专家缓存 | MoE 权重 | Decode 时的 PCIe 搬运和 CPU 计算 |
| KV / 状态缓存 | Prompt 的计算结果 | 多轮 Agent 中重复 Prefill |
FreeToken 把它们放进同一个 serving engine,是因为本地 Agent 的总等待时间同时受两边影响。只有 Decode 跑得快,但每次工具调用后重新 Prefill 一分钟,体验仍然不行。
显存还要在专家缓存和 KV Cache 之间动态分配
刚启动模型时上下文很短,可以把更多剩余显存用作专家缓存。对话越来越长后,KV Cache 会持续增长,而专家工作集没有同比增长。
静态切分很容易出现:1
2
3专家缓存很大,但 KV Cache 不够
或者
KV 预留太多,专家缓存命中率很低
FreeToken 把这两块都看成运行时资源。在 scheduler 没有待处理 Prefill、没有进行中的 Decode 时,到达一个 safe point,才执行 cache rebuild:1
2
3
4
5暂停接收新的计算批次
释放旧 CUDA Graph
重新分配专家 slot 和 KV page
重新捕获 CUDA Graph
继续服务
Host RAM 中的完整专家池不需要重新加载,所以不必重启进程或者再次读取整个模型。
可以通过:1
2ft ctl cache
ft ctl cache --moe 512 --kv 16k
查看和调整缓存。调整不是发生在某个 token 计算到一半的时候,而是排队等到 scheduler 完全空闲的安全点。
FTW 为什么能缩短启动时间
模型文件里的专家权重未必正好是运行时最方便的排列。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
2
3
4
5申请巨大空缓冲区
把内存页 pin 住并清零
读取 checkpoint
重新排列专家
复制到最终位置
FreeToken 的路径是:1
2从磁盘直接读入最终 Host 布局
数据填充完成后再 pin
Pinned memory,也就是 page-locked memory,不会被操作系统随便换出,CUDA 可以对它进行更稳定的异步 DMA。但提前 pin 一个巨大的空缓冲区会触发页面分配和清零,随后又马上被模型数据覆盖,白做了一遍内存写入。
FTW 避免了这部分启动开销。GPU 专家缓存也不需要专门 warmup,第一次请求从冷缓存开始,沿用普通 miss 路径自然升温。
ft checkpoint 是可选步骤,FreeToken 也能直接读取支持的 Hugging Face checkpoint:1
2
3
4ft checkpoint \
--model ~/models/Qwen3.6-35B-A3B \
--out ~/models/Qwen3.6-35B-A3B-FTW \
--moe-backend offload
从源码看这些模块怎么对应
把论文和代码对照起来,大致是下面这些位置:
| 源码 | 主要职责 |
|---|---|
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
2
3
4
5
6
7
8
9
10
11
12routes = router(hidden_states)
hits, misses = gpu_cache.lookup(routes)
to_gpu, to_cpu = split_by_bandwidth(misses)
cpu_task = cpu_executor.submit(to_cpu)
gpu_cache.fill(to_gpu)
gpu_output = gpu_experts(hits + to_gpu)
cpu_output = cpu_task.wait()
return gpu_output + cpu_output
真正难的是让 lookup、动态 miss 数、异步拷贝、CPU worker 和 CUDA Graph 同时成立,而且不能每层都把控制权交回 Python。
跟 llama.cpp 的部分卸载有什么区别
两者最表面的相同点都是:1
显存不够,就让一部分权重留在内存
但“哪一部分留在内存”和“运行时怎么使用它”差别很大。
llama.cpp 的 -ngl 是静态层卸载
以一个 64 层 Dense 模型为例:1
2第 0~39 层:CPU
第 40~63 层:GPU
模型加载完成后,层的位置基本就固定了。每生成一个 token,仍然要依次经过全部 64 层:1
2
3CPU 计算前 40 层
传递中间激活
GPU 计算后 24 层
这里通常移动的是层与层之间较小的激活,不是每个 token 都把完整层权重搬来搬去。
这种方式非常适合 Dense 模型,因为 Dense 没有路由器,任何一层的 FFN 权重每个 token 都会使用。也就谈不上“缓存最近用到的专家”。
当前 llama.cpp 的参数是:1
2
3-ngl N
--gpu-layers N
--n-gpu-layers N
现在也可以使用 auto 或 all,让运行时自动拟合显存。
llama.cpp 也有专门的 MoE CPU 放置
如果模型是 MoE,当前 llama.cpp 还支持:1
2-cmoe
--cpu-moe
把全部 MoE 专家权重留在 CPU;或者:1
2-ncmoe N
--n-cpu-moe 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 优化的是动态专家工作集
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 在 4060 上应该用哪一种
回到最开始容易串在一起的问题。
Qwen3.8-27B 是 Dense 模型:1
2没有 256 选 8 的路由专家
每个 token 都要经过每层完整 FFN
27B 做 4bit 的理论权重大小约为:1
27B × 0.5 byte = 13.5GB
再加量化元数据、KV Cache 和运行时开销,8GB 4060 无法让它全量常驻显存。
这时更合适的思路是:1
2
3
4GGUF 低比特量化
+ llama.cpp
+ 尽可能多的静态 GPU layer offload
+ 剩余层在 CPU / RAM 中执行
例如:1
2
3
4llama-server \
-m Qwen3.8-27B-Q3_K_M.gguf \
-ngl auto \
-c 8192
具体能放多少层取决于 GGUF 大小、上下文长度、KV 精度、系统当前可用显存和 llama.cpp 版本。-ngl auto 比照搬别人机器上的固定层数更稳妥。
FreeToken 当前文档虽然支持 Qwen3.6-27B Dense,但 Dense 模型的 auto 会解析为 fused,也就是要求权重常驻 GPU,并不会启用 MoE offload/hybrid 这套专家机制。当前支持列表也还没有列出刚发布的 Qwen3.8-27B。
因此:1
2
3
4
58GB 4060 + Qwen3.6-35B-A3B MoE
-> FreeToken 的专家缓存和 hybrid 很有意义
8GB 4060 + Qwen3.8-27B Dense
-> 更适合 llama.cpp 的量化和静态层卸载
两个模型参数接近,但运行路径不能混着解释。
实际怎么跑 FreeToken
当前开源 CLI 的要求是:1
2
3
4
5Linux x86_64
NVIDIA GPU
driver r580+
CUDA 13
Python 3.10+
所以它目前不是 M3 Pro 上 llama.cpp/Metal 的替代品。Mac 仍然更适合 llama.cpp 或 MLX。
安装:1
2
3uv venv
source .venv/bin/activate
uv pip install "freetoken[accel]"
先测一次这台机器的 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
2
3
4
5
6
7
8
9
10
11
12
13
14# 专家在内存,miss 全部搬到 GPU
ft serve \
--model nvidia/Qwen3.6-35B-A3B-NVFP4 \
--moe-backend offload
# 专家在内存,miss 由 CPU 处理
ft serve \
--model nvidia/Qwen3.6-35B-A3B-NVFP4 \
--moe-backend cpu
# GPU cache hit + PCIe fill + CPU overflow 并行
ft serve \
--model nvidia/Qwen3.6-35B-A3B-NVFP4 \
--moe-backend hybrid
运行后可以查看:1
2ft ctl stats
ft ctl cache
对比时不要只测一句 hello。至少分别看:
- 冷启动后的第一轮
- 长 Prompt 的 TTFT
- 连续生成几百 token 后的 Decode
- 多轮工具调用后是否反复 Prefill
短 Prompt、冷缓存和长时间 Agent 会话测到的是三种不同状态。
这套方案的限制
看到 39.3 tok/s 很容易只记住“8GB 显存”,但这套方案还有几个前提。
内存仍然要装得下完整专家池
显存需求降低不等于总内存需求消失。论文里的 4060 Laptop 有 32GiB 内存,并且使用 NVFP4。只有 16GB 系统内存时,同一模型可能会进入 swap,速度会急剧下降,甚至无法稳定加载。
主要适用于 MoE
Dense 模型每个 token 都要读完整 FFN,没有小而稳定的专家工作集。FreeToken 的核心专家缓存不会把 27B Dense 变成 3B active。
性能受整机而不是只受 GPU 决定
需要同时看:
- Host RAM 带宽
- PCIe 代际和通道数
- CPU SIMD 能力
- 物理核心数
- 显存能留下多少专家 slot
- 实际路由的局部性
同一张 4060 放在不同笔记本里,内存和 PCIe 配置不同,q* 和速度也会不同。
39.3 tok/s 不是所有请求的固定速度
这是论文在指定 4060 Laptop、NVFP4 模型和 coding-agent workload 下测得的 Decode 吞吐。它不等于:
- 冷启动速度
- Prefill 速度
- 超长上下文下的平均响应速度
- 任意量化、任意机器都能得到的速度
缓存也有冷启动和工作集突变
刚启动时专家缓存是空的。如果话题、语言或者任务突然变化,路由热点也可能变化,短时间 miss 会增加。LRU 利用的是局部性,不是消灭所有 miss。
最后总结
现在再看“8GB 显存运行 35B”,可以把它拆成五步:1
2
3
4
51. Qwen3.6-35B-A3B 是 MoE,每个 token 只激活约 3B
2. 完整的量化专家池放在 32GiB Host RAM,不是硬塞进 8GB VRAM
3. 剩余显存作为跨层共享的 LRU 专家 slot cache
4. Decode miss 按 q* 分给 PCIe+GPU 和 CPU,两条路径并发并精确合并
5. Prefill 改用完整层双缓冲,并通过语义状态缓存减少 Agent 重算
llama.cpp 的 -ngl 主要解决的是“完整层怎样静态分布到 CPU 和 GPU”,--cpu-moe 则进一步允许专家留在 CPU。FreeToken 在这个基础问题上继续往前走,把专家当成随 token 路由变化的动态工作集,并围绕它设计缓存、带宽分流、Prefill 和运行时显存管理。
所以 FreeToken 真正有意思的地方并不是一个神奇的“8GB 跑 35B”数字,而是它换了一个看待本地机器的方式:1
2
3GPU 不是唯一计算设备
显存也不是唯一模型内存
CPU、RAM、PCIe 和 GPU 可以组成一台统一的推理机器
但这条路成立的关键仍然是 MoE 的稀疏性。换成 Qwen3.8-27B 这样的 Dense 模型,还是要老老实实回到量化、层卸载和内存带宽这几个问题上。