1. 一张 12GB 显卡放 125B 模型:先算账再见真章
有段时间,一个朋友找过来,说想在一张 12GB 显存的消费级显卡上跑 125B 参数的模型。我第一反应是“你是不是把单位写错了”,确认之后第二反应是:这事儿其实不是没解,但绝对不能按常规思路来。后来我搭了一套类似标题里 Strata 的方案——把整台机器当成四层存储来规划,用调度器在推理过程中按需把权重、KV Cache、激活值一层一层“喂”给 GPU,最终确实跑起来了,速度虽然谈不上快,但可用。
这篇文章就是这套折腾的完整记录。我不讲云端大集群,不讨论多卡并行,只说单机单卡、显存远远不够时怎么让大模型真正跑起来。适合两类人看:一是手里只有一张小显存显卡、想跑大模型推理的,二是对显存卸载、分层存储这类机制感兴趣的工程师。如果你以为“显存不够就堆量化”,那你很可能一半路还没走完,另一半路在本文里。
1.1 125B 到底有多重:权重、KV Cache 与激活值的三重挤压
先算一笔最粗的账。125B 参数,如果直接用 FP16 存,每个参数占 2 字节,总权重就是 250GB。12GB 显存连二十分之一都放不下。量化到 INT8,每参数 1 字节,125GB,依然差一个数量级;就算量化到 INT4,每参数 0.5 字节,也还有 62.5GB。换句话说,光靠压缩位宽,这条路走不到目的地。
但真正的难点不是权重本身。推理过程里还有两个被严重低估的开销:KV Cache 和激活值。
以 KV Cache 为例,假设这个 125B 模型有 100 层,每层 80 个头、每个头维度 128 维,存 K 和 V 两份,上下文长度到 4096,半精度存储。那么单层的 KV 大约是:2(K/V 两份)× 80(头数)× 128(头维度)× 2(字节数)× 4096(序列长度)≈ 0.33GB。听起来还能接受,但乘上 100 层,直接到 33GB 量级。如果你的 batch 稍微提到 2、4,这个数字还得成倍往上翻。
激活值在前向推理单层里通常没这么夸张,但层数一多,又不能一股脑全放显存,必须做到“算完就释放、用时再激活”。这一下就把显存管理从“加载一次模型”变成了“持续不断的资源调度”。
把这三笔开销放在一起,结论很清楚:
| 开销项 | 规模估算 | 说明 |
|---|---|---|
| 125B 权重 FP16 | 约 250GB | 原始大小 |
| 125B 权重 INT8 | 约 125GB | 中等量化 |
| 125B 权重 INT4 | 约 62.5GB | 深度量化,仍需分层 |
| KV Cache 100 层 × seq 4096 | 约 33GB 量级 | 和权重同级,甚至更大 |
| 激活值峰值 | 数百 MB 到数 GB | 随序列和 batch 波动 |
看到这张表你就明白,12GB 显存连“KV Cache 全驻留”都做不到,更别说把权重全部塞进去。所以真正的问题不是“怎么把模型放进显存”,而是“怎么让 GPU 在需要某个层的时候,那一层恰好已经在显存里等着了”。
1.2 传统卸载方案为何撑不住
有人会说,显存不够就用 CPU 内存扛。把模型整个加载到内存里,推理时逐层把权重从内存拷进显存,算完一层再拷下一层。这个思路没有错,但实测下来速度非常惨。原因在于 GPU 计算本身可能只需要几十毫秒处理一层,而权重从内存到显存走 PCIe 的传输却要几百毫秒甚至更久,GPU 长期处于“干等数据”的状态,利用率极低。
也有人用更简单的方案:模型文件直接映射在磁盘上,推理引擎按需读取某一层。这类做法解决了“模型装不下”的问题,但通常没有存储层级的整体意识——读过的层用完就丢,下一次轮到它时又要重新从磁盘加载,完全没有“预取下一层”“保留热点层”的调度逻辑。模型层数一旦过百,I/O 等待就成了绝对瓶颈。
Strata 的思路和这两条路都有本质区别:它把一台机器显式拆成四个存储层级——GPU 显存、CPU 内存、NVMe SSD、远端或高密冷存——然后像操作系统管理内存页一样去管理模型块。每一层放什么、什么时候搬到哪儿,由一个调度引擎统一决定。这个视角一旦建立,你就不再纠结“模型能不能完整装入”,而是关心“带宽预算够不够、预取窗口怎么设、热点层怎么留”。
2. Strata 的四层存储:把一台机器当成一个有深度的池子
2.1 四层存储的容量、带宽与职责
Strata 把存储抽象成四层,每一层的容量、速度和角色都完全不同:
| 存储层级 | 载体 | 典型容量 | 典型带宽 | 延迟量级 | 主要职责 |
|---|---|---|---|---|---|
| L0 | GPU 显存 | 12GB 左右 | 1.5TB/s 级别 | 几十纳秒 | 计算工作集:驻留最热层、当前 KV 窗口、临时缓冲 |
| L1 | CPU 内存 | 64GB~128GB | 几十 GB/s | 百纳秒级 | 二级缓冲:预取中转站、冷 KV、混合精度副本 |
| L2 | NVMe SSD | 1TB~2TB | 3GB/s~7GB/s | 百微秒级 | 权重主存储、量化模型文件、检查点 |
| L3 | 高密冷存/远端对象存储 | 数 TB 以上 | 几百 MB/s | 秒级 | 冷备、历史会话、多份量化副本 |
打个比方:L0 是你面前的桌面,顺手就能拿到;L1 是书桌旁边的书架,站起来就能取;L2 是家里的储物间,要走路过去翻;L3 是外面租的仓库,来回一趟要半天。Strata 做的事情就是雇一个极其聪明的管理员,你刚把手里的 A 文件放下,他已经把 B 文件放到了桌面边缘。
这里有一个很多新手会忽略的关键点:四个层级之间的带宽差距是数量级的,不是百分比差距。L0 和 L1 之间是 PCIe 级别的几十 GB/s,L1 和 L2 之间是 NVMe 的几 GB/s,L2 和 L3 之间又掉一两个数量级。因此,调度的本质是在“带宽不等式”里做预算:如果生成一个 token 需要流式经过若干层权重,那这些层从下级存储搬运到 L0 的总带宽开销,必须小于生成一个 token 的时限内可用的带宽额度。超预算,系统就会“卡死等数据”,再聪明的调度也无法挽回。
2.2 统一存储池与逐层交换的块分配
Strata 在实现上先做了一个“统一可寻址存储池”。所有层权重不再以“文件→内存→显存”这种大块搬运方式管理,而是把每层切成若干个固定大小的块,每个块拥有全局地址、大小、当前所在层级、访问计数等元数据。推理引擎只看得到一层抽象虚拟地址空间,不关心某个块此刻到底在 SSD 还是内存里。
这个设计带来的好处非常实际。首先是细粒度:如果整层一起搬运,移动一次可能要一两个 GB,很容易造成显存瞬间紧张;切成块之后,可以有选择地把高频访问的块留在 L0,低频的块留在 L1,互不干扰。其次是便于淘汰和预取:操作系统换页时可以精准替换某一个页,而不用整段搬家,Strata 也一样。
简化后的分配和晋升逻辑大概长这样:
class Block: def __init__(self, addr, size): self.addr = addr self.size = size self.level = 3 # 0=GPU, 1=RAM, 2=SSD, 3=cold self.access_bit = 0 self.resident = False def promote(block, target_level): cur = block.level while cur > target_level: move_one_level(block, cur - 1) cur -= 1 def touch(block): block.access_bit += 1 if block.level > 0: promote(block, 0)块大小的选取需要权衡。我实测下来,权重块用 8MB 到 16MB 比较合适,KV Cache 块更倾向于按序列 chunk 来切,而不是按固定字节数。如果块切得太小,页表条目数量会爆炸式增长,每次调度决策都要遍历海量条目;如果切得太大,搬运动量很笨重,局部性优势又发挥不出来。这里没有标准答案,取决于你的模型层大小和显存容量。
2.3 冷热分层:不是所有层都值得留在显存
把这个 125B 模型展开成 100 层,你会发现层与层之间的“待遇”天然就不一样。
模型最后面的若干层直接决定下一个 token 的输出分布,逻辑上最强相关,它们必须拥有最高精度和最稳定的驻留位置。我会把后 20 层放 L0,并且准备一份 FP16 的副本,保证输出质量不因为量化漂移而崩。中间大部分层是主干计算,放在 L1,用 INT4/INT8 混合量化,既省空间又能保证预取速度。开头若干层是 embedding 和浅层特征提取,对精度的敏感度相对低,可以直接放在 L2,按需换入,反正它们只在最开始出现一次,后续推理基本不会再访问。
实际的冷热分布还得通过 profile 来修正,不能拍脑袋。运行前期可以用一小段文本做 warm-up,统计每个块被 touch 的次数、每次被 swap 的间隔、和前后层之间的依赖强度,然后把统计结果反馈给驻留策略。这个过程类似操作系统里“工作集模型”的估计——你不需要精确知道每个块未来的访问时间,只需要一个足够好的近似。
这种冷热分层还有一个额外的好处:显存里 12GB 的预算可以拆成三块——热层驻留区、KV Cache 工作窗口、以及临时交换缓冲。如果热层只占 6GB,KV 窗口占 4GB,剩下 2GB 留给调度器做换入换出的临时落脚点,那整个系统就有了缓冲余地,不会因为某一次预取稍微越界就直接 OOM。
3. 调度引擎:面向注意力轨迹的预取与淘汰
3.1 静态执行计划:让预取快一步
模型推理和操作系统的通用内存管理有很大区别:推理过程中计算图是静态的、执行顺序是确定的。你不需要预测未来,未来就明明白白写在前向传播的代码里——先 embedding,再一层一层 forward,每层内部先 attention 后 MLP。既然执行轨迹已知,预取策略就可以从“被动缓存”升级为“主动搬运”。
我在调度器里维护一个静态执行计划表,按模型层序排列。当 GPU 正在计算第 N 层时,调度器可以预先把第 N+1、N+2 层的块从 L2 提升到 L1,甚至直接推进到 L0 的空闲区域。这里有个核心超参数叫预取窗口,也就是“提前预备未来多少层”。
预取窗口太小,GPU 算完当前层后还要等权重;窗口太大,又会把 L1 的带宽吃干。我的经验是从“当前层平均计算耗时 × 2 / 单层从 L1 到 L0 的传输耗时”来估算初始值。举个例子:某层计算要 60ms,单层权重传输要 30ms,那预取窗口大概设成 4 层左右。再根据实际运行时的耗时波动动态调整,如果观察到 GPU 空闲率上升,就适当放宽窗口;如果 L1 的 DMA 带宽利用率超过 80%,就收紧。
3.2 淘汰策略:别再傻傻按 LRU
预取是“怎么搬进来”的问题,淘汰是“怎么请出去”的问题,后者其实更麻烦。很多人一上来就套 LRU——最近最少使用,访问位最低的块先踢走。听起来合理,但长 token 序列的推理场景恰恰会让 LRU 失效。
原因在于:不同层的访问频率差异很大,但同一批热层可能在一个周期内被反复 touch。如果淘汰策略只看“最近是否被用过”,那中间层就很容易出现一种抖动——刚被用过的层被踢出,下一轮又需要,只好又换回来,来回搬运白白消耗带宽,生成速度直接掉一半。
我在日志里见过非常典型的抖动模式:一个层地址的块在相邻两个采样周期内,连续出现“驱逐→重取→再驱逐”。后来我把淘汰策略改成组合判断,不再单纯看时间:
- 定期采样每个块的访问位,折算成衰减后的热度值;
- 淘汰时优先选择“访问位为 0 且与下一次预计使用距离最远”的块;
- 对权重块和 KV 块使用不同策略:权重块的惰性更强,被 touch 了就别急着挪;KV 块要考虑“重新计算前缀”的代价,如果淘汰后重建 KV 的开销大于保留成本,就算它看起来很久没访问也坚决不踢。
这个“重建代价”在长文本续写中尤其重要。上下文越长的对话,前面轮次产生的 KV Cache 越有价值,因为重算一遍前缀的耗时可能是当前 token 生成时间的很多倍。Strata 给 KV 块设置了一个驻留优先级的额外标记,只有 LRU 算出的淘汰收益超过重建代价时才允许移除。
3.3 上下文窗口的分层:KV Cache 也要搬
权重分层已经是共识,但 KV Cache 的分层很多人没想过。前面算过,40 万 token 级别的长上下文下,KV Cache 的体积可以轻松超过权重。12GB 显存里如果只给 KV 留 4GB,那一个 batch=1、seq=4096 的 100 层模型就已经装不下完整 KV 了。
Strata 的处理方式是把 KV Cache 按序列位置切成多个 chunk,每个 chunk 的 K/V 单独管理。最近的 chunk 放 L0,这是注意力计算最热的区域;往前推几个 chunk 放 L1,再早的放 L2。注意力计算时,模型不能只盯着当前 chunk,它需要把历史 KV chunk 也拿来参与 softmax。但如果你把历史 KV 全部搬回显存做完整计算,显存立刻爆炸。
这里配合了一个类似分块注意力的技巧:分两步计算注意力分数——第一步在高带宽的 L0 上处理当前 chunk 和邻近 chunk,第二步从 L1/L2 读更早的 K/V,在线算出一部分的点积分数,立刻做一个局部的 softmax 累加重心,再叠加回完整 softmax。这么做的好处是,历史 KV chunk 从一个几 GB 的 tensor 变成了若干个只包含中间分数的、几十 MB 的小缓冲区,峰值显存被压得很稳。
这套机制本质上是把“上下文窗口”也当成了一层虚拟存储。显存只保留最近的工作集,更早的历史放到速度更慢但容量更大的层级里。长文本生成时,你会发现系统吞吐不是线性衰减的,而是像水闸一样,每跨过一个 chunk 边界才出现一个小幅顿挫。
4. 跑通 125B 的真实数字与调参记录
4.1 测试环境与基础配置
我在一个模拟的大模型推理实验环境里完整验证了这套方案。硬件是一张 12GB 的消费级显卡,配 8 核 32 线程 CPU、128GB DDR5 内存、2TB NVMe SSD。模型是一个 125B 参数的 100 层结构,头数 80,头维度 128,上下文长度设置为 4096。以下数字来自中等负载下的反复测试,能够给你一个量级上的参考。
核心配置如下:
| 配置项 | 数值 |
|---|---|
| 模型参数量 | 125B / 100 层 / 80 头 / head_dim 128 |
| 量化策略 | 全模型 INT4,末 10 层 FP16,中间若干层 INT8 混合 |
| 存储布局 | L0 驻留后 20 层 + KV 最近窗口;L1 放中间 70 层;L2 放前 10 层 + 检查点 |
| KV 分块 | 序列按 1024 长度切 chunk,最近 chunk 在 L0,前 3 个 chunk 在 L1,更早的在 L2 |
| 预取窗口 | 设置为当前层计算耗时的 2 倍左右 |
| 显存预算 | 热层 6GB / KV 窗口 4GB / 交换缓冲 2GB |
这套配置有一个特点:不是“所有层都量化到最低位宽”,而是做了不对称量化。最后 10 层保留了 FP16 副本,显存多花了大概 2GB,但换来了输出质量的稳定。如果你只追求跑通不在乎长文本漂移,可以把 FP16 层压缩到 INT8,显存预算会更宽裕,生成速度也会小幅提升。
4.2 性能数据与资源占用
实测下来,batch=1 时单 token 生成延迟大约在 5.5 秒左右;把 batch 提到 4,生成 4 个 token 的总耗时约 9 秒,折合单 token 约 2.2 秒。这个数字看起来不算惊艳,但考虑到原始权重 250GB、显存只有 12GB,能从“完全装不下”到“可交互速度”,已经是在约束条件下能做到的上限附近了。
整条数据链路大约是:每层权重被换入显存平均要 0.18 秒,但由于预取窗口的掩护,GPU 每算一层的等待时间被压缩到了约 35 毫秒。L1 的 RAM 带宽峰值跑到 45GB/s 左右,没有完全打满,留了余量给突发预取。L2 的 SSD 读取量每个 token 大约在 7GB,主要来自前 10 层的反复换入,以及部分中间层在热层集合调整时的加载。
有一个容易被忽视的规律:batch=1 时,权重搬运的时间几乎就是全部时间,计算本身反而被掩盖了;batch 提高之后,搬运成本被多个 token 共享,边际延迟才真正体现 GPU 的计算能力。所以在这种分层卸载的架构里,想提升吞吐,优先考虑提高 batch,而不是单纯优化单 token 的延迟。
4.3 与手动卸载方案的差距分析
为了确认这套方案不是白折腾,我把三种做法放在同一台机器上做了对照:
| 方式 | 单 token 延迟实测参考 | 瓶颈特征 |
|---|---|---|
| 全模型驻留 CPU 内存,逐层搬运 | 约 1 token / 分钟 | CPU 内存带宽和 PCIe 传输双重瓶颈 |
| 磁盘按需映射,层用后即弃 | 约 0.2 token/s | I/O 等待占大头,GPU 大量空闲 |
| Strata 四层调度 | 约 0.18 token/s | 带宽预算受控,长序列稳定性好 |
单纯看数字,Strata 似乎没有比“磁盘按需映射”快多少,但差距体现在长文本和并发上。按需映射方案跑到 50 个 token 之后速度会衰退,因为每次都要重新读入已经访问过的层;Strata 因为有热层驻留和预取机制,速度曲线更平稳,续写到几百 token 也不会明显劣化。
5. 踩坑记录:预取打满带宽、页表膨胀和显存碎片
5.1 第 50 个 token 变慢:一个典型的缓存驱逐问题完整排查
最让我头疼的一个问题出现在跑长文本时。刚开始生成速度稳定在 5.5 秒一个 token,到第 50 个 token 左右突然掉到 11 秒,还偶发显存不足错误。
我先没有怀疑显存总量,而是翻调度日志,发现淘汰次数最密集的层集中在第 45 到第 55 层。这些层既不是最热的末层,也不是最冷的前层,而是每个 token 都会经过一次的“普通层”。按道理它们应该很热,可它们被淘汰的频率却异常高。
排查链路是这样的:
- 先检查淘汰日志,发现同一地址的块在两个采样周期内连续出现“驱逐→重新预取→再驱逐”,这是典型的抖动特征。
- 给所有块增加一个“next_use_distance”字段,记录距离下一次被访问的预估值。分析后确认,被反复驱逐的块距离下一次使用并不远,单纯被 LRU 的短期热度迷惑了。
- 把淘汰策略改为“访问位 + 距离惩罚 + 重建代价”的组合判断,抖动立刻消失,生成速度回到 5.5 秒。
这个坑特别值得记下来,因为它不是 Strata 独有的问题,是所有缓存系统遇到局部性访问模式时都会撞上的“颠簸”。排查时一定要看日志里的重复驱逐记录,而不是只盯着容量占用。
5.2 预取大队列把内存吞吐吃干净
起初我为了追求“GPU 永远不等数据”,把预取窗口直接拉到未来 10 层。结果系统反而变慢了,而且 GPU 利用率下降到更低。日志显示瓶颈不在显存,而是在 CPU 内存侧——预取队列同时把十几层权重从 SSD 拉到 RAM,L1 的写带宽被全部占满,真正要搬到显存的块在队列里排队,反而被堵住了。
解决方法是把预取拆成两个阶段:L2→L1 的后台预取用低优先级,只在系统空闲时跑;L1→L0 的前台搬运用高优先级,由 GPU 运算完成事件触发。同时把预取窗口从 10 层收敛到大约 2 到 4 层,观察 L1 带宽利用率稳定在 60% 到 80% 之间。这个区间既保证预取不落后,又给真正的显存搬运留出通道。
5.3 显存碎片与 OOM:12GB 看似够用是因为碎片在偷容量
最后踩的坑是显存看起来够用,跑一段时间后却突然 OOM。排除了火焰般的问题后,我定位到是显存碎片化在作祟。
反复换入换出会让显存里出现大量大小不一的空洞。权重块是 16MB 的大块,KV chunk 却只有几 MB,搬运几次之后,显存里满是小碎片,一个大块权重找不到连续空间落脚,就触发了 OOM。这个现象很像文件系统碎片化,只是发生在显存里。
我给出的解法是三层:
- 在显存侧建立一个预分配内存池,分成两个桶:大块权重桶和小块 KV 桶,避免两类对象互相插空;
- 所有块强制按 2MB 或 4MB 对齐,减少空洞;
- 如果内存池仍然紧张,就把驻留热层从最后 20 层缩到最后 16 层,把腾出的空间留给交换缓冲。
做了这三件事之后,OOM 频率变成零,长文本跑到数千 token 都很稳。
这套四层存储调度方案,我前后折腾了几个月。最大的感受是:显存不够时,把模型“塞”进显卡的核心不在量化,而在调度。量化只是把存储容量这个硬约束松了松,真正让系统跑起来的是“哪一层放哪一层、什么时候搬、什么时候扔”的决策质量。STRATA 多级存储里,本质上是把操作系统那套“多级存储、按需调页、工作集估计”的思想,平移到了模型推理场景里。如果你手里也只有一张 12GB 的卡,我的建议是先花一天时间画出模型的冷热分布——弄清楚你的热层在哪、KV Cache 的重建代价高不高,再动手做量化。顺序反了,你会和我一样多踩几个坑。