1. 从一次显卡适配的折腾说起
前段时间我在一台混装显卡的机器上部署推理服务,机器上同时有一块 Intel 核显和一块 NVIDIA 独显,本来想着把模型跑在独显上就完事了,结果启动的时候各种报错,日志里反复出现设备识别、后端选择、算子回退之类的提示。折腾了大半天才把服务拉起来,过程中我翻了不少 vLLM 的源码和 issue,才慢慢理解了一个挺有意思的设计问题:为什么 vLLM 在适配新 GPU 的时候,一边把旧的抽象层拆掉,一边又要重新造一套可移植层出来。
这个问题乍一看很矛盾。拆抽象通常意味着"这东西太厚了、太慢了、太不灵活了",那按理说应该往更贴近硬件的方向走,怎么反而又加了一层?但如果你真的读过 vLLM 近几个版本的代码演进,尤其是 EngineCore、Scheduler、Executor 这条链路,以及 Flat Model、torch.compile 这些新东西的引入,就会发现这套操作背后有一套很清晰的逻辑。它拆的不是"抽象"本身,而是拆掉了那种为了兼容而兼容、把不同硬件硬塞进同一个模具的旧抽象;它新建的可移植层,解决的是另一个维度的问题——让上层调度和模型逻辑不再被具体硬件绑死,同时又不牺牲新硬件的能力释放。
这篇文章我想把这件事讲透。适合谁看?如果你正在做推理框架的二次开发、要给国产卡或者新架构做适配、或者单纯好奇 vLLM 内部到底怎么组织 EngineCore 和 Scheduler 的交互,那这篇应该能给你一些参考。我会尽量用从业者之间聊天的口吻,把原理、取舍、实操细节和踩过的坑都摊开讲,不堆术语,也不搞那种"综上所述"的总结腔。
先说结论方向:vLLM 拆旧抽象,是因为旧抽象把"硬件差异"和"框架逻辑"耦合在了一起,导致每接一款新卡都要动核心代码;再造可移植层,是为了把"硬件能力描述"和"执行策略选择"解耦,让新 GPU 的接入变成填配置、写后端,而不是改调度器。这两件事方向相反,目标一致——让框架既能跑得广,又能跑得快。
2. 旧抽象到底拆掉了什么
2.1 旧抽象的历史包袱:一切为了"统一"
要理解拆什么,得先知道旧抽象是怎么来的。vLLM 早期为了快速支持多种后端,采用了一种比较"大一统"的设计思路:把注意力计算、KV Cache 管理、采样这些核心环节,抽象成一套相对固定的接口,然后针对不同硬件写不同的实现。这套思路在只有 CUDA 一种主流后端的时候没什么问题,因为大家硬件特性差不多,抽象层带来的开销可以接受。
但问题在于,当你要接入的硬件越来越多——不同厂商的加速卡、不同架构的 GPU、甚至同一厂商不同代际的产品——这套"统一接口"就开始变形了。为了兼容某个硬件的特殊能力,接口上要加参数;为了绕开另一个硬件的限制,实现里要加分支。久而久之,这个抽象层变成了一个巨大的条件判断集合,每加一款硬件,核心路径上就多几个 if-else。
我在读代码的时候印象特别深,某些算子实现里针对不同设备的分支能写好几层,读起来非常痛苦。更麻烦的是,这些分支往往散落在调度、执行、内存管理各个模块里,你想搞清楚"这块卡到底走了哪条路径",得把整个调用链跟一遍。这就是典型的抽象泄漏——本来想用抽象屏蔽差异,结果差异从抽象层的缝隙里全漏出来了。
2.2 拆掉的具体对象:硬编码的设备分支与耦合的执行路径
那么 vLLM 具体拆掉了哪些东西?根据我自己的观察和社区讨论,主要集中在这么几块。
第一块是硬编码的设备判断。旧代码里到处能看到类似"如果是某类设备就走这条路径"的逻辑,这些判断散落在模型层、执行层、内存层。拆掉它们的意思是,把这些判断从核心逻辑里挪出去,变成可配置、可插拔的策略。
第二块是耦合的执行路径。以前 Scheduler 在做调度决策的时候,会隐式地假设某种执行模型,比如假设所有请求都走同一种批处理方式、假设 KV Cache 的布局是固定的。当新硬件有更高效的批处理方式或者不同的内存布局时,这种假设就成了枷锁。拆掉它,就是让 Scheduler 只关心"要调度什么",不关心"底层怎么执行"。
第三块是为兼容而生的中间层。有些抽象层存在的唯一理由就是"让 A 硬件和 B 硬件看起来一样",但它们既不提升性能,也不简化逻辑,反而增加了维护成本。这类层是优先被清理的对象。
注意:拆抽象不等于删代码。很多情况下是把逻辑从核心路径挪到边缘,从编译期挪到运行期,从硬编码挪到配置。看起来代码量没少,但耦合度降下来了。
2.3 为什么"拆"是必须的:新 GPU 接入的成本账
我算过一笔账。假设每接入一款新 GPU,因为旧抽象的存在,你需要改动核心调度逻辑、修改内存管理、调整算子实现,平均涉及十几个文件、上千行改动,还要跑完整的回归测试。这个成本对于框架维护者来说是灾难性的,因为硬件迭代速度远快于框架迭代速度。
拆掉旧抽象之后,接入新硬件的改动被收敛到少数几个后端文件里,核心逻辑基本不动。这就是"拆"的直接收益——把变化点隔离出来。软件工程里有个老原则叫"识别变化点并封装它",vLLM 这波操作本质上就是在做这件事,只不过它面对的变化点是硬件多样性。
还有一层考虑是性能。旧抽象为了通用性,往往会在热路径上引入额外的函数调用、类型转换、内存拷贝。对于推理这种对延迟极度敏感的场景,这些开销累积起来很可观。拆掉它们,让热路径更直接,是实打实的性能收益。社区里有人反馈某些版本升级后性能波动,其实很多时候就是在做这类重构,短期可能有回退,长期是划算的。
3. 可移植层为什么又要重新造
3.1 可移植层要解决的真问题:能力描述而非接口统一
拆完旧抽象,问题来了:如果什么都不抽象,那每款硬件都写一套完整实现,维护成本不是更高吗?这就是可移植层存在的意义。但关键在于,新的可移植层和旧的抽象层,解决的是不同的问题。
旧抽象试图统一"接口"——让所有硬件看起来一样,调用方不用关心底层。新可移植层试图统一"能力描述"——它不假装硬件一样,而是把每款硬件的能力、限制、偏好明确地描述出来,让上层根据这些描述做决策。
打个比方。旧抽象像是给所有人发同一套制服,不管高矮胖瘦都得穿,穿不下的就改制服。新可移植层像是给每个人量体裁衣,但裁缝用的尺子和布料标准是统一的。前者追求"看起来一样",后者追求"描述得清楚"。
这个区别很关键。因为硬件的差异是客观存在的,你没法真的让它们一样。强行统一只会导致抽象泄漏。而把差异显式描述出来,上层就能针对性地做优化——比如某款卡擅长处理长序列,那调度时就优先把长请求分给它;某款卡显存带宽高,那就多缓存一些 KV。
3.2 Flat Model 与 torch.compile 在其中的角色
说到可移植层,就绕不开 Flat Model 和 torch.compile 这两个东西。它们在这套设计里扮演的角色,我理解是这样的。
Flat Model 的核心思路是把模型的计算图"拍平",减少嵌套层次,让编译器更容易做全局优化。传统的模型实现往往是层层嵌套的模块调用,每层都有自己的逻辑,编译器很难跨层优化。拍平之后,整个前向计算变成一张相对扁平的计算图,优化空间大很多。
torch.compile 则是把这套拍平的计算图编译成针对特定硬件优化的代码。它的价值在于,你写一份模型逻辑,它能针对不同后端生成不同的优化代码。这就天然契合可移植层的需求——上层逻辑统一,底层代码因硬件而异,中间的翻译工作交给编译器。
我实测下来的感受是,torch.compile 在推理场景下的收益因模型和硬件而异。有些模型能拿到明显的加速,有些则提升有限,甚至因为编译开销导致首次推理变慢。所以它不是银弹,但在可移植层里作为一个"可选的优化通道"是合理的——用不用、怎么用,交给上层根据硬件能力决定。
3.3 可移植层的边界:它不做什么
理解一个设计,有时候看它"不做什么"比看它"做什么"更重要。vLLM 这套可移植层,我观察下来有几个明确的边界。
它不负责调度决策。调度是 Scheduler 的事,可移植层只提供硬件能力信息,不替 Scheduler 做决定。这样职责清晰,Scheduler 的逻辑不会被硬件细节污染。
它不强行抹平性能差异。如果某款硬件就是比另一款慢,可移植层不会假装它们一样快,而是如实描述,让上层自己权衡。这避免了"为了统一而牺牲性能"的老问题。
它不绑定具体实现。可移植层定义的是能力和接口的契约,具体怎么实现是各后端的事。这意味着新硬件接入时,只要满足契约,实现方式可以自由发挥。
这三条边界,恰好对应了旧抽象的三个毛病:越权调度、强行统一、绑定实现。新设计是有针对性地在纠偏。
4. EngineCore、Scheduler、Executor 的交互怎么变了
4.1 三者职责的重新划分
要理解可移植层怎么落地,得看 EngineCore、Scheduler、Executor 这三者的交互。这是 vLLM 推理链路的核心。
EngineCore 是引擎的核心,负责管理整个推理生命周期,包括请求的接收、状态的维护、结果的返回。Scheduler 负责决定每一步哪些请求参与计算、怎么组批。Executor 负责实际执行计算,把调度结果变成硬件上的运算。
在旧设计里,这三者的边界比较模糊。Scheduler 有时候会直接感知底层执行细节,Executor 也会反过来影响调度决策。这种耦合导致换硬件时,三边都得动。
新设计里,职责被重新划清:Scheduler 只关心逻辑层面的调度,比如优先级、公平性、显存预算;Executor 只关心怎么把给定的批次高效执行;EngineCore 负责协调,并在需要时查询可移植层提供的硬件能力。这样换硬件时,主要改 Executor 和可移植层的后端实现,Scheduler 基本不动。
4.2 调度器如何做到"硬件无关"
Scheduler 要做到硬件无关,关键在于它决策所依赖的信息被抽象成了统一的描述。比如显存预算,以前可能直接读某个硬件的显存接口,现在通过可移植层拿到一个标准化的"可用显存"数值。再比如批处理大小,以前可能硬编码某个上限,现在根据硬件能力动态计算。
我举个具体的例子。假设有两款卡,A 卡显存大但算力一般,B 卡算力强但显存小。旧设计下,Scheduler 可能得写两套逻辑分别适配。新设计下,可移植层告诉 Scheduler:A 卡可用显存 X、算力 Y,B 卡可用显存 X'、算力 Y'。Scheduler 用同一套算法,根据这些数值算出各自的批处理策略。逻辑统一,结果因硬件而异。
这就是"硬件无关"的真正含义——不是忽略硬件差异,而是把差异变成数据,让算法去处理数据,而不是让代码去处理差异。
4.3 执行器如何承接可移植层的能力
Executor 是可移植层能力的直接消费者。它从可移植层拿到硬件能力描述,然后决定用哪种执行策略。比如某款硬件支持某种高效的注意力算子,Executor 就优先调用;不支持就回退到通用实现。
这里有个设计上的取舍:回退路径要不要保留?我的看法是必须保留,但要有明确的触发条件和日志。因为硬件能力探测可能出错,或者某些边界情况下高效路径不适用,没有回退就会直接崩。但回退不能是静默的,否则你永远不知道自己在跑慢路径。
我在实际部署时就遇到过,某款卡理论上支持某个优化算子,但因为驱动版本问题实际不可用,框架静默回退了,性能差了一大截,查了好久才发现。后来我养成了习惯,启动后先看日志里有没有回退提示,有的话优先排查。
5. 新 GPU 接入的实操路径
5.1 接入前的准备工作
如果你要给一款新 GPU 做 vLLM 适配,动手之前有几件事得先做。
第一,搞清楚这款硬件的计算能力画像。包括算力峰值、显存容量和带宽、支持的算子类型、编程模型(是类 CUDA 还是别的)、驱动和工具链的成熟度。这些信息决定了你能用哪些优化路径。
第二,确认软件栈的兼容性。PyTorch 能不能跑、torch.compile 支不支持这个后端、有没有现成的算子库。如果 PyTorch 都不支持,那适配工作量会大很多,得先解决基础问题。
第三,评估社区现状。这款硬件有没有人已经在做 vLLM 适配?有没有相关的 issue 或 PR?站在别人的肩膀上能省很多事。我一般会先搜一遍,看看有没有踩过同样坑的人。
提示:接入新硬件前,先用一个最小可用的推理脚本验证基础链路能不能跑通,别一上来就上完整框架,否则出问题很难定位是硬件、驱动还是框架的锅。
5.2 可移植层后端的实现要点
可移植层后端的实现,核心是满足契约。契约定义了哪些能力必须提供、哪些可选、接口长什么样。实现时要注意几点。
能力探测要准确且保守。宁可少报能力,也不要虚报。虚报会导致上层走了不支持的路径然后崩溃,少报只是性能差一点,但稳定。我见过为了追求性能虚报能力的实现,结果在边界情况下频繁出错,得不偿失。
接口实现要幂等且无副作用。可移植层的接口会被上层频繁调用,如果实现里有状态或者副作用,很容易出问题。尽量做成纯查询。
错误处理要明确。能力不支持时,要返回明确的"不支持"信号,而不是抛异常或者返回一个看起来正常但实际错误的值。上层需要根据这个信号做决策。
5.3 从零到跑通的完整流程
我把接入流程整理成了一张表,方便对照。
| 阶段 | 主要工作 | 关键产出 | 常见坑 |
|---|---|---|---|
| 环境验证 | 驱动、工具链、PyTorch 基础测试 | 能跑通的最小推理脚本 | 驱动版本不匹配 |
| 能力探测 | 编写可移植层后端的能力查询 | 硬件能力描述 | 虚报能力导致崩溃 |
| 算子适配 | 实现或对接核心算子 | 可用的注意力、采样实现 | 数值精度不一致 |
| 执行器对接 | 让 Executor 能调用新后端 | 端到端推理跑通 | 回退路径静默 |
| 性能调优 | 批处理、内存、编译优化 | 可接受的性能指标 | 过度优化导致不稳定 |
| 回归测试 | 跑完整测试集 | 稳定性验证 | 边界情况覆盖不足 |
每一步我都建议单独验证,别跳步。尤其是能力探测和算子适配,这两步出问题最难查。
5.4 性能验证与回退策略
跑通之后就是性能验证。这里我要强调一个容易被忽略的点:建立基线。你得知道这款硬件在"最朴素实现"下的性能是多少,才能判断优化有没有效果。我一般会先用通用实现跑一遍,记录延迟和吞吐,然后再逐步启用优化路径,对比提升。
回退策略的设计也很关键。我的做法是分三级:一级是算子级回退,某个算子不支持就用通用实现;二级是路径级回退,整条优化路径不可用就退回通用路径;三级是全局回退,整个后端有问题就禁用。每一级回退都要有日志,方便排查。
6. 踩过的坑与排查实录
6.1 设备识别与后端选择的典型问题
设备识别问题是我遇到最多的。混装显卡的机器上,框架可能识别错设备,或者把请求发到了不该发的卡上。有一次我的服务莫名其妙跑在了核显上,性能惨不忍睹,查了半天才发现是设备枚举顺序的问题。
排查这类问题的思路是:先确认框架看到的设备列表对不对,再确认它选中的设备是不是你期望的。很多框架支持通过环境变量指定设备,实在搞不定就显式指定,别依赖自动选择。
后端选择的问题类似。有时候框架探测到的后端能力和实际不符,导致选了错误的执行路径。这时候要看能力探测的日志,对比实际硬件规格,找出差异点。
6.2 编译与算子回退的隐蔽故障
torch.compile 相关的故障比较隐蔽,因为它可能在编译期就把问题掩盖了。我遇到过一次,某个算子在编译后数值精度出了问题,但因为是静默的,跑出来的结果只是略微偏差,不仔细对比根本发现不了。
这类问题的排查方法是:关掉编译,用 eager 模式跑一遍,对比结果。如果 eager 正常、编译异常,那就是编译的问题。然后再逐步缩小范围,定位到具体是哪个算子、哪个优化 pass 出的问题。
算子回退的隐蔽性在于它往往是静默的。我的建议是,在开发阶段把回退日志级别调高,确保任何回退都能看到。生产环境可以调低,但要有监控。
6.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 服务启动即崩 | 设备识别失败 | 看设备枚举日志 | 显式指定设备 |
| 性能远低于预期 | 静默回退到通用路径 | 查回退日志 | 修复优化路径或接受回退 |
| 结果数值偏差 | 编译优化引入误差 | eager 对比 | 禁用问题算子编译 |
| 显存溢出 | 能力探测虚报显存 | 对比实际显存 | 修正能力描述 |
| 批处理效率低 | 调度未感知硬件特性 | 查调度日志 | 完善能力描述 |
| 首次推理极慢 | 编译开销 | 看编译耗时 | 预热或关闭编译 |
6.4 几个我自己的避坑心得
第一个心得:别信文档,信日志。文档往往滞后于代码,尤其是快速迭代的项目。日志才是真相。我养成了启动后先扫一遍日志的习惯,很多问题在日志里都有线索。
第二个心得:小步验证,别一次改太多。接入新硬件时,一次只改一个变量,改完就验证。这样出问题能快速定位。我见过有人一次性改了一堆配置,结果出问题后完全不知道是哪个改动导致的。
第三个心得:保留一个已知可用的配置作为对照。调优过程中,随时能退回这个配置,避免越调越乱。这个配置最好记录下来,包括所有环境变量和参数。
第四个心得:关注社区动态。vLLM 迭代很快,很多你遇到的问题别人可能已经解决了。定期看看 issue 和 PR,能省很多时间。尤其是新硬件适配这种场景,社区的力量很重要。
7. 这套设计对后续扩展意味着什么
7.1 新硬件接入的边际成本在下降
从工程角度看,这套"拆旧抽象、造可移植层"的设计,最大的价值是让新硬件接入的边际成本下降。以前接一款新卡可能要几周,现在如果硬件本身和已有后端相似,可能几天就能跑通。这个成本下降对于硬件快速迭代的当下,意义很大。
我自己的体感是,最近几次给新设备做适配,工作量确实比一年前小了不少。核心逻辑不用动,主要精力花在能力探测和算子适配上。而且因为职责清晰,出问题也容易定位。
7.2 对国产加速卡适配的参考价值
这套设计对国产加速卡适配也有参考价值。国产卡的一个普遍问题是软件栈成熟度参差不齐,有的算子库完善,有的还在建设中。可移植层的设计允许你"有多少能力用多少能力",不要求一步到位。先跑通基础路径,再逐步启用优化,这个渐进式的适配路径很实用。
另外,能力描述这套机制,也方便国产卡厂商自己来写后端。厂商最了解自己的硬件,让他们按契约提供能力描述和实现,比框架维护者去猜要靠谱得多。
7.3 我个人的一些判断
最后说点我个人的判断。这套设计方向是对的,但落地过程中肯定还有不少细节要磨。比如能力描述的粒度怎么定,太粗了没法做精细优化,太细了维护成本高。再比如回退策略怎么设计才能既稳定又不掩盖问题,这些都是需要持续打磨的。
我在实际使用中的体会是,这套设计对使用者提出了更高的要求——你得理解硬件能力描述的含义,才能用好它。以前那种"无脑跑"的时代过去了,现在想拿到好性能,得懂点底层。这未必是坏事,但对团队的技术能力是个考验。
如果你正在做相关的工作,我的建议是先把这套交互链路吃透,再动手改。理解设计意图比记住接口重要得多。遇到问题多查日志、多对比、多小步验证,基本都能解决。这个领域变化快,保持学习的心态比什么都重要。