1. 拆开"Strata"这个名字:它到底在解决什么层面的问题
第一次看到"Strata"这个命名,我脑子里蹦出来的第一反应是地质学里的"地层"——一层一层叠上去,每层各司其职,层与层之间有明确的界面。后来把它的架构拆开看,发现这个比喻其实相当贴切:整个推理引擎就是由几个物理上分离、职责上互补的执行层堆叠起来的,最上面是调度与编排,中间是专家池的路由与缓存,最底下是真正吃算力的计算内核,分别落在 GPU 的 CUDA 核和 CPU 的 AVX-512 向量单元上。
这篇东西不是官方文档的翻译,也不是论文导读。我想做的是把"一个混合异构推理引擎为什么非得这么设计"这件事讲透——为什么不能全丢给 GPU,为什么 CPU 的 AVX-512 在这种架构里不是打杂的,专家池(expert pool)到底池化了什么,C++ 那层胶水代码为什么值得单独拿出来说。如果你正在做本地推理部署、多模型服务化,或者单纯对"异构计算怎么协同"这件事好奇,这篇应该能给你一些可以直接借鉴的思路。
先把结论性的判断放在前面:Strata 这类架构的核心矛盾,从来不是"算力够不够",而是算力在时间维度和空间维度上的错配。GPU 的算力是脉冲式的、批量的、怕小请求的;CPU 的算力是持续的、细粒度的、怕大矩阵的;专家池的存在则是为了把"模型参数"这个空间问题转化成"调度命中率"这个时间问题。三者协同的本质,是让每一层只干自己最擅长的那一段,把不擅长的部分尽早甩给下一层。理解了这条主线,后面所有的技术细节都能挂上去。
我在实际接触这类架构时踩过最大的一个坑,就是一开始把它当成"GPU 推理 + CPU 兜底"的简单主备关系。跑起来才发现完全不是——CPU 那条 AVX-512 路径承担的是路由决策、轻量前处理、以及部分专家子集的计算,它和 GPU 是并行流水的关系,不是备份关系。这个认知偏差直接导致我早期做的性能预估全错,后面会详细讲这个教训。
2. 为什么"纯 GPU 推理"在真实负载下会撞墙
2.1 脉冲式算力与持续式请求的根本矛盾
GPU 的设计哲学是"用吞吐换延迟"——你把一大批请求攒起来,一次性喂进去,它能用极高的并行度把单位算力成本压到极低。问题在于真实的服务负载很少是均匀的。我观察过不少实际部署的请求分布,典型的形态是"长尾 + 突发":大部分时间 QPS 不高,偶尔来一波尖峰,尖峰之间还夹杂着大量零散的、batch size 凑不满的小请求。
这种负载形态对纯 GPU 推理是致命的。一个 batch size 为 1 的请求丢进 GPU,SM(流式多处理器)的利用率可能只有个位数百分比,但显存占用、kernel launch 开销、同步等待一样不少。你等于用一台重型卡车去送一个快递包裹,油钱照付,车厢空着。更麻烦的是,GPU 上的 kernel launch 本身有固定开销,几十微秒起步,当单请求的计算量小到和这个开销同量级时,你优化计算本身已经没有意义了,瓶颈在调度。
Strata 把这类"小、碎、急"的请求分流到 CPU 的 AVX-512 路径上,逻辑就在这里。CPU 单核的绝对算力远不如 GPU,但它没有 kernel launch 的概念,一个函数调用下去就开始算,上下文切换成本低,对小请求的"启动税"几乎为零。这不是谁强谁弱的问题,是匹配负载形态的问题。
2.2 显存墙:专家参数放不下的现实约束
如果只是负载形态问题,加大 batch 缓冲、做请求聚合就能缓解一部分。真正让纯 GPU 方案难受的是显存。现代的大模型,尤其是走 MoE(混合专家)路线的模型,参数量动辄几十上百 GB,全部常驻显存不现实。就算用 INT8/INT4 量化压下来,留给 KV Cache 和中间激活的空间也被挤得很紧。
这里有个很多人忽略的细节:MoE 模型的专家参数是稀疏激活的——一次前向只用到少数几个专家,但哪些专家被激活是动态的,取决于路由网络的输出。这意味着你没法像稠密模型那样静态地把参数切分到固定位置,你得有一套机制在运行时把"当前需要的专家"搬到"能算它的地方"。这套机制就是专家池要解决的核心问题,也是 Strata 架构里最值得细看的一层。
我见过一种偷懒的做法:把所有专家都放 CPU 内存,GPU 只做路由和聚合,专家计算全在 CPU 上跑。这样显存是省了,但 CPU 算大矩阵乘法的效率太低,吞吐直接崩掉。Strata 的思路显然更精细——它让专家池成为一个有状态的缓存层,热专家驻留 GPU,冷专家留在 CPU 侧或更慢的存储,按需换入换出。这个设计的精妙之处在于,它把"显存不够"这个硬约束,转化成了"缓存命中率"这个可以优化的软指标。
2.3 一个反直觉的实测观察
说个我自己的实测感受。在一台 GPU 显存刚好卡在临界值的机器上,我对比过两种配置:一种是尽量把更多专家塞进显存、牺牲 KV Cache 空间;另一种是留足 KV Cache、让专家池多做换入换出。直觉上第一种应该更快,因为换入换出有开销。但实测下来,在长上下文场景里第二种反而更稳,因为 KV Cache 不够导致的重复计算和显存碎片,代价比专家换入换出还高。
这个观察直接印证了 Strata 分层设计的合理性:不要试图让任何一层做到"全能",而是让每一层在自己的容量边界内工作,边界之外的事情交给下一层。专家池的存在不是为了消灭换入换出,而是为了让换入换出的代价可控、可预测。
3. CUDA 内核层:它到底该"重"在哪,又该"轻"在哪
3.1 内核粒度的取舍:大 kernel 还是小 kernel
CUDA 内核设计里有个经典的两难:把多个操作融合进一个大 kernel,能减少 launch 开销和中间结果的显存往返,但融合过度会让 kernel 变得臃肿、寄存器压力大、occupancy 下降;拆成多个小 kernel,每个都简单高效,但 launch 开销和中间张量的读写会累积起来。
Strata 在这件事上的处理思路,我理解是按数据依赖的紧密度来切。像 attention 里的 QK^T、softmax、再乘 V 这一串,数据依赖是紧耦合的,中间结果如果落显存再读回来,带宽开销巨大,所以适合融合成一个 fused attention kernel。而像不同专家分支的计算,彼此之间没有数据依赖,完全可以并行发射到不同的 stream 上,没必要硬塞进一个 kernel。
这里有个实操层面的经验:融合 kernel 的时候,寄存器用量是最容易被忽视的隐形杀手。我调过一个 fused kernel,逻辑上很漂亮,但编译出来每个线程用了 80 多个寄存器,occupancy 掉到 25% 以下,实际性能还不如拆开。后来用__launch_bounds__限制寄存器上限,逼编译器 spill 一部分到 local memory,反而因为 occupancy 上去了而变快。这个取舍没有标准答案,得靠 profiler 实测,但方向是明确的:occupancy 和单线程效率之间要找平衡点,不能只看理论 FLOPs。
3.2 显存带宽才是真瓶颈,不是算力
很多人做 GPU 优化时盯着 FLOPS 看,觉得算力利用率上不去就是没优化好。但在推理场景,尤其是 decode 阶段(自回归逐 token 生成),瓶颈几乎永远在显存带宽上,不在算力上。原因很简单:decode 阶段每生成一个 token,都要把整个模型的权重从显存读一遍,计算量却只有一个 token 的量级,算术强度极低。
Strata 的 CUDA 内核层针对这一点做了几件事,我觉得值得借鉴。一是权重读取的向量化,用float4/int4这类宽加载指令,把多次小读取合并成一次大读取,充分利用显存控制器的突发传输能力。二是权重布局的重排,把推理时连续访问的参数在内存里也排成连续的,避免 stride 访问导致的带宽浪费。三是尽可能让权重常驻 L2 或共享内存,对于小到能塞进片上缓存的矩阵,反复从显存读是纯浪费。
提示:判断你的 kernel 是算力瓶颈还是带宽瓶颈,最直接的办法是算算术强度(arithmetic intensity),即每读一字节数据做了多少次浮点运算。如果这个值远低于硬件的算力带宽比,那基本就是带宽瓶颈,优化方向应该是减少数据搬运,而不是优化计算指令。
3.3 多流并发与专家计算的并行发射
专家池里的不同专家,计算上是独立的,这给了 CUDA stream 并发很大的发挥空间。Strata 让不同专家的计算跑在不同的 stream 上,配合 CUDA 的事件机制做同步,这样当一个专家的 kernel 在等显存的时候,另一个专家的 kernel 可以占用计算单元,把流水线填满。
但这里有个坑我得提醒:stream 并发不是开得越多越好。GPU 的 SM 数量是固定的,stream 太多会导致每个 stream 分到的资源被切得很碎,反而增加调度开销和上下文切换。我的经验是,stream 数量控制在 SM 数量的一个合理比例内(比如 1/4 到 1/2),并且要确保每个 stream 上的 kernel 足够"重",否则并发的收益会被调度成本吃掉。另外,stream 之间的同步点要尽量少,每加一个同步点就多一次等待,流水线的气泡就是这么来的。
4. 专家池:把"参数空间问题"翻译成"调度命中率问题"
4.1 池化的本质是缓存,缓存的关键是替换策略
专家池这个名字容易让人以为是"把专家放在一个池子里随便取",其实它的本质是一个带容量约束的缓存系统。池子的容量(能同时驻留多少专家)由显存和内存的可用空间决定,而请求需要哪些专家是动态变化的,所以核心问题就变成了:当需要的专家不在池子里,而池子又满了,该换出谁?
这就是经典的缓存替换问题。Strata 这类架构通常不会只用最简单的 LRU(最近最少使用),因为专家访问的分布有明显的倾斜性——少数几个专家被高频访问,大量专家是长尾。纯 LRU 在倾斜分布下表现一般,因为它只看"最近有没有用",不看"用了多少次"。更合理的做法是 LRU 和 LFU(最不经常使用)的混合,或者用带权重的评分:访问频率高、最近又访问过的专家,优先级最高;长期不用的,哪怕最近碰过一次,也该优先换出。
我在实际调这类系统时发现一个细节:换入换出的粒度很关键。如果按单个专家换,粒度太细,换入换出的次数多,每次的数据搬运量小,带宽利用率低;如果按专家组分批换,粒度太粗,可能换进来一堆用不上的。折中的做法是按"专家簇"换——把访问模式相似的专家聚成簇,以簇为单位做换入换出,既提高了单次搬运的效率,又不至于浪费太多。这个簇的划分可以离线统计访问共现矩阵来做,属于典型的"用离线分析换在线效率"。
4.2 预取:在请求到达之前就把专家搬到位
缓存系统做得再好,换入换出也是有延迟的。如果等请求到了才发现专家不在池子里,那这段等待就是实打实的延迟。Strata 的应对思路是预取——根据路由网络的输出或者历史访问模式,提前把"接下来大概率要用到的专家"搬进池子。
预取的难点在于准确率。预取错了,白搬一趟,还挤占了本来可以给别的专家用的空间;预取太保守,等于没预取。我的经验是,预取要分层:高置信度的预取(比如路由分数明显偏向某几个专家)可以激进一点,直接搬;低置信度的预取可以只做"预热",比如把数据从慢存储读到内存但先不进显存,等确认要用再往显存搬。这样即使预取错了,代价也可控。
还有一个容易被忽略的点:预取要和实际计算重叠。如果预取的数据搬运和当前的计算抢同一份带宽,那预取反而拖慢了当前计算。理想的状态是,预取的搬运发生在计算不饱和的间隙,比如当前 kernel 在等同步的时候。这需要调度层对流水线有比较精细的掌控,也是 C++ 那层胶水代码要干的活之一。
4.3 专家池的状态管理:别让缓存变成"脏数据制造机"
专家池里的专家不是只读的——在某些场景下(比如带 adapter 的微调、或者专家本身的参数会更新),池子里的副本可能和权威存储不一致。这就引出了缓存一致性问题。Strata 的处理方式我理解是写时失效 + 版本号校验:权威存储里的专家参数带版本号,池子里的副本也记着自己是哪个版本,用之前校验一下,版本对不上就重新加载。
这个机制听起来简单,但实际实现里有个坑:版本校验本身有开销,如果每个专家每次用都校验,累积起来不小。合理的做法是批量校验 + 惰性失效,即在一个调度周期开始时统一校验一批,周期内不再重复校验,同时标记出失效的副本,等真正用到时再重新加载。这样把校验开销摊薄到批级别,单次请求的感受不到。
5. CPU AVX-512 路径:它不是"备胎",是流水线的一环
5.1 AVX-512 到底能干什么,不能干什么
先泼盆冷水:AVX-512 不是万能的。它的优势在于宽向量、细粒度、低启动开销的并行计算,512 位宽的寄存器一次能处理 16 个 float32 或 64 个 int8,对于元素级操作、小矩阵乘法、归一化、激活函数这类"数据并行度高但单次数据量不大"的计算非常合适。但它的劣势也很明显:频率墙(跑 AVX-512 时 CPU 往往会降频)、大矩阵乘法效率远不如 GPU、以及不同代际 CPU 对 AVX-512 的支持程度参差不齐(有些型号砍掉了部分指令集)。
所以 Strata 把 AVX-512 路径定位在几个特定环节,我认为是经过深思熟虑的。第一是路由决策——MoE 的路由网络通常是个小矩阵乘法加 softmax,计算量不大但对延迟敏感,放 CPU 上做,省去了 GPU 的 launch 开销和同步等待。第二是轻量前处理——tokenization 之后的 embedding 查表、位置编码、以及一些逐元素的归一化,这些操作数据并行度高、逻辑简单,AVX-512 处理起来很顺手。第三是部分专家子集的计算——当某些专家的计算量小到不值得走 GPU 时,直接在 CPU 上算完,避免数据在 CPU 和 GPU 之间来回搬。
5.2 数据在 CPU 和 GPU 之间的搬运:最贵的往往是"过桥"
异构计算里最容易被低估的成本,是数据在 CPU 内存和 GPU 显存之间的搬运。PCIe 带宽就那么多,一次搬运的延迟可能比计算本身还长。Strata 的协同设计里,一个核心原则就是尽量减少过桥次数——能在一边算完的,就别搬到另一边;必须搬的,就攒够了一起搬。
具体到实现,有几个手法值得说。一是固定内存(pinned memory),用cudaHostAlloc分配的主机内存,DMA 传输时不需要经过页锁定,带宽和延迟都更好。二是异步传输,用cudaMemcpyAsync配合 stream,让传输和计算重叠,而不是传完了再算。三是零拷贝,对于某些只读的小数据,直接让 GPU 通过映射的主机内存访问,省掉显式拷贝。这几个手法单独看都是常识,但组合起来、在正确的时机用,效果差别很大。
我踩过的一个坑是:早期为了图省事,所有中间结果都往显存里放,结果发现大量时间花在搬运上。后来把一部分中间结果留在主机内存,让 CPU 直接接着算,只在真正需要 GPU 的时候才搬,整体延迟降了一大截。这个教训说明,异构协同的关键不是"让数据流动起来",而是"让数据尽量不流动"。
5.3 降频问题与混合负载下的频率管理
AVX-512 有个众所周知的副作用:跑重向量指令时 CPU 会降频,而且降频影响的不只是当前核,某些架构下会影响整个封装内的其他核。这在混合负载场景下很要命——你一边在跑 AVX-512 的专家计算,一边还有别的线程在做调度、做 IO,降频会把这些轻负载也拖慢。
应对思路有几种。一是控制 AVX-512 的使用强度,不是所有计算都值得上 512 位宽,对于数据量小的操作,用 AVX2 甚至标量可能更划算,因为避免了降频。二是把 AVX-512 密集计算集中到特定核,通过线程亲和性绑定,让降频的影响范围可控。三是在调度层做负载均衡,把对延迟敏感的轻任务调度到没跑 AVX-512 的核上。这些手段都不是银弹,得根据实际硬件和负载特点调,但方向是清楚的:AVX-512 要用得克制,把它当精确制导武器,而不是地毯式轰炸。
6. C++ 胶水层:架构里最不性感但最要命的部分
6.1 调度器:决定谁在什么时候干什么
C++ 这一层最核心的职责是调度。它要回答的问题包括:这个请求该走 GPU 还是 CPU?走 GPU 的话,batch 怎么组?专家池里缺的专家什么时候预取?CPU 和 GPU 的计算怎么重叠?这些问题每一个单独看都不难,难的是它们相互耦合,牵一发动全身。
Strata 的调度器我理解是分层决策的:最上层做请求分类(大 batch 走 GPU 批处理,小请求走 CPU 快速路径),中间层做资源分配(显存、stream、CPU 核的配额),最下层做具体的任务下发和同步。这种分层的好处是每层只关心自己那部分状态,复杂度可控;坏处是层与层之间的信息传递有延迟,极端情况下可能出现"上层以为资源够、下层发现不够"的尴尬。缓解办法是让上层持有资源的"预留视图",下发任务前先做一次轻量的可用性检查。
调度器里有个细节特别值得说:batch 的组队策略。把哪些请求组进同一个 batch,直接影响 GPU 的利用率和请求的延迟。组得太激进,小请求要等大请求凑批,延迟飙升;组得太保守,batch 凑不满,GPU 利用率低。我的经验是用带超时的动态组批——请求进来先进队列,队列里攒到一定数量或者最早的那个请求等超时了,就发一批。超时阈值根据负载动态调,负载高时缩短超时(反正请求多,很快能凑满),负载低时适当放宽(避免频繁发小 batch)。
6.2 内存管理:异构内存池的统一视图
CPU 内存和 GPU 显存是两套独立的地址空间,管理不好就是各种泄漏、碎片、越界。Strata 在 C++ 层做了一个统一的内存池抽象,上层代码申请内存时不关心它实际落在哪,由内存池根据大小、用途、访问频率决定分配策略。这个抽象的价值在于,它把"异构内存管理"这个复杂问题收敛到一个模块里,其他模块不用重复处理。
实现上有几个要点。一是池化分配,避免频繁的 malloc/free 和 cudaMalloc/cudaFree,这两者的开销都不小,尤其是 cudaMalloc 还会同步设备。二是按用途分区,权重、激活、KV Cache、临时缓冲各用各的池子,避免相互挤占和碎片化。三是生命周期管理,用 RAII 或者引用计数确保内存该释放时释放,异构环境下最容易出的 bug 就是"以为释放了其实没释放"或者"释放了还在用"。
注意:GPU 显存的碎片化比主机内存严重得多,因为显存的分配粒度更粗,而且没有主机内存那样的页交换机制。长时间运行的服务,如果频繁申请释放不同大小的显存块,很容易出现"总空闲够但最大连续块不够"的情况。对策是尽量用固定大小的块做池化,大块需求走专门的大块池。
6.3 错误处理与降级:异构系统里"部分失败"是常态
单机单卡的系统,出错往往就是整体挂掉,处理逻辑简单。异构系统不一样,CPU 路径可能好好的,GPU 路径挂了;或者某个专家加载失败,但其他专家正常。这种"部分失败"是常态,C++ 层必须能优雅处理。
Strata 的降级策略我理解是按能力降级:GPU 不可用时,把能搬到 CPU 的计算搬过去,虽然慢但能跑;某个专家加载失败时,用备用专家或者退化成稠密计算;显存不足时,主动收缩专家池容量,牺牲命中率保可用性。这些降级路径平时不触发,但一旦触发就是救命的。我的建议是,降级逻辑一定要在开发阶段就主动测试,别等生产环境真出问题了才发现降级路径本身有 bug——那种时候往往是最慌的。
7. 三层协同的实测调优:几个我踩过的坑和验证过的手法
7.1 坑一:把 CPU 路径当"慢速备份",结果延迟反而更高
前面提过这个认知偏差,这里展开说。我最初的设计是:请求优先走 GPU,GPU 忙不过来才溢出到 CPU。听起来合理,实测却发现溢出到 CPU 的请求延迟比预期高很多。排查下来,原因是 CPU 路径平时不跑,相关的缓存(指令缓存、数据缓存、专家参数在主机内存的驻留)都是冷的,第一次跑要各种加载,冷启动开销把"CPU 快"的优势全吃掉了。
修正方案是让 CPU 路径保持"温热":即使 GPU 能处理,也定期让 CPU 路径处理一小部分请求(比如 5%),保持它的缓存和状态是活的。这样真正需要溢出时,CPU 路径能立刻以正常速度接管。这个手法有点像数据库连接池的"保活",思路是相通的——备用路径的价值取决于它被启用时的状态,而不是它理论上的能力。
7.2 坑二:专家池换入换出和计算抢带宽
这个坑很隐蔽。专家池换入换出要走 PCIe,GPU 计算读权重也要走显存带宽,两者虽然不完全是一回事,但在某些环节会相互影响。我遇到的情况是,预取线程在疯狂搬数据,把 PCIe 带宽占满了,导致正常的输入数据传输被拖慢,端到端延迟不降反升。
解决办法是给换入换出设带宽配额,用令牌桶或者类似的限流机制,确保预取不会挤占关键路径的带宽。同时把预取的优先级调低,让它在带宽空闲时才发力。这个调整之后,预取的收益才真正体现出来。教训是:任何"后台优化"都必须有资源上限,否则它会从优化变成干扰。
7.3 手法:用分层 profiler 定位真正的瓶颈
异构系统的性能分析最怕"盲人摸象"——只看 GPU 利用率,或者只看 CPU 占用,都容易得出错误结论。我的做法是分层打点:在请求入口、调度决策、GPU kernel 发射、CPU 计算、数据搬运这几个关键节点都埋上时间戳,跑完之后把时间线拼出来,看时间到底花在哪一段。
这个方法帮我定位过一个问题:GPU 利用率显示 70%,看起来还有余量,但端到端延迟就是下不去。时间线一拼才发现,GPU 那 70% 是"忙"的,但忙的是预取和后台任务,真正处理请求的 kernel 在等 CPU 侧的路由结果,而路由结果又卡在一个锁上。如果没有分层打点,光看 GPU 利用率根本发现不了这个锁竞争。所以我的建议是,异构系统的 profiler 一定要跨层,单层的指标会骗人。
7.4 手法:参数扫描找协同的最优点
三层协同涉及的可调参数很多:batch 大小、专家池容量、预取阈值、CPU/GPU 分流比例、stream 数量、AVX-512 使用强度……这些参数相互影响,手工调很难找到最优点。我的做法是写个简单的参数扫描脚本,在固定的负载回放上跑不同的参数组合,记录端到端延迟和吞吐,画成热力图找最优点。
这里有个经验:别追求全局最优,追求"稳定区间"。参数扫描出来的最优点往往很尖,稍微偏一点性能就掉,这种点在真实负载下不可靠。更实用的是找一片"性能都不错"的平坦区域,取中间值,这样负载波动时性能不会剧烈抖动。工程上,鲁棒性比峰值性能重要得多。
8. 这套架构适合谁,以及落地时的现实建议
把 Strata 这套分层协同的思路拆完,我想说清楚它适合什么场景、不适合什么场景,免得有人照着抄结果水土不服。
适合的场景很明确:负载有长尾、模型大到单卡放不下、对延迟有一定要求但不能无限堆硬件。典型的就是本地化部署的中大型模型服务,或者边缘侧有一定算力但资源受限的环境。这类场景里,纯 GPU 方案要么显存不够,要么小请求延迟难看;纯 CPU 方案吞吐上不去。分层协同正好卡在中间,用复杂度换灵活性。
不适合的场景也得说:如果你的负载非常均匀、batch 能稳定凑满、模型又能完整放进显存,那这套架构的复杂度就是纯负担,老老实实做 GPU 批处理更省心。架构的复杂度必须由真实的约束来"挣得",没有约束硬上复杂架构,是给自己找麻烦。
落地时的几条现实建议。第一,先把监控和 profiler 搭好再优化,异构系统的黑盒程度高,没有观测手段的优化基本是瞎猜。第二,降级路径要当成一等公民来测,别等出事才验证。第三,参数别调太满,留 20% 的余量给突发负载和降级时的性能回退。第四,专家池的替换策略先用简单的,跑起来看访问分布再优化,很多场景下 LRU 加个频率权重就够了,没必要一上来就搞复杂的聚类预取。
最后分享一个我自己的体会:这类异构协同系统,最难的不是把每一层单独做好,而是定义好层与层之间的契约——谁负责什么、数据以什么格式传递、失败时怎么回退。契约清晰了,每层可以独立演进;契约模糊了,改一处崩一片。我在项目里花在梳理接口和状态机上的时间,比花在单个 kernel 优化上的时间还多,但事后看,这部分投入是最值的。