做AI加速卡软件栈的人,基本都绕不开ops-math这类数学算子库。它看着不起眼,却在每一次模型推理、每一次张量运算里被反复调用;而让这套算子在不同芯片之间跨平台适配,背后真正起作用的,不是某个神奇编译器,而是我们亲手搭起来的那层硬件抽象层(HAL)。这篇文章不聊上层框架怎么调度Tensor,就聊底层这层HAL怎么设计、怎么划分边界、怎么和数学算子协同工作,以及我在实际适配过程中踩过的一些不太好搜到答案的坑。对于正在做算子库跨平台工作、或者准备把旧算子栈从单芯片迁到多芯片的团队,这些经验应该能少走不少弯路。
1. 为什么一套ops-math算子库会栽在“换芯片”上
1.1 一段真实的工作复盘:复制粘贴型适配是怎么失控的
我们最早的算子库只面向一颗自研AI芯片,ops-math里像elementwise、reduce、softmax、矩阵乘这类数学算子,都是对着那颗芯片的工具链手写内核。运行得挺好,直到第二颗低功耗芯片立项,问题来了。第一批适配任务分下去,大家的做法高度一致:把现有内核源码拷一份,改改头文件、换换内存申请接口、调整下线程配置,能跑过用例就算适配完成。
一开始确实快,几周就交付了一批。但半年后,团队已经开始为维护付出代价。一个很典型的例子:LeakyReLU这种简单算子,我们在三四个后端里各有一份内核文件。数学逻辑完全一样,但每份代码里都掺进了对应芯片的访存对齐要求、并行度配置、甚至不同的尾部处理方式。原本一致的数学行为,慢慢变得不收敛了——同一份输入,在不同后端上算出来的结果,误差分布都不一样。更麻烦的是,上游想优化某个公共数学路径时,得同时改好几个副本,任何一份漏改,整个库的行为就又分叉一次。
真正让我下决心搭硬件抽象层,是在一次统计后:ops-math里大约一百个数学算子,按当时的做法移植到四个后端,意味着要维护近四百份内核代码,而其中至少一半是重复的数学语义加互不兼容的硬件细节。这时候才意识到,问题不在某个算子写得不好,而是一开始就让数学实现和硬件资源管理彻底纠缠在了一起。
1.2 四类无法回避的硬件差异
在动手设计HAL之前,我把当时遇到的硬件差异归成了四类。这四类差异是任何做跨平台算子库都躲不开的,也是HAL必须“吞掉”的东西。
第一类是执行模型差异。CPU的后端期望我用SIMD向量指令,一条指令处理一批数据;GPU那类后端期望我按线程束的方式组织计算;而自研AI芯片的张量核期望我把计算表达成块状矩阵乘的形式。同一个数学表达式,在不同执行模型下,落地路径完全不同。第二类是存储层次差异。CPU有缓存行,GPU有共享内存和全局内存的概念,AI芯片可能有多级片上缓冲,甚至要求显式数据搬移。一个简单的“读两个Tensor算加法”,在不同后端上要处理的访存逻辑差别很大。第三类是并行调度差异,CPU靠线程池,GPU靠线程块网格,AI芯片可能靠核组与流水阶段。如果算子代码里写死“开256个线程”,换个后端这数字的含义就全变了。第四类是数值语义差异,低精度浮点的舍入规则、非规格化数的处理方式、是否有快速数学指令,不同芯片可能给出不同答案。HAL要解决的核心问题,就是把算子的数学语义和这四类差异彻底隔离。
1.3 HAL在软件栈里的边界:不是再包一层框架
我见过不少团队一上来就想把HAL做成一个大而全的运行时,连算子调度的拓扑、任务依赖图都想塞进去。这个方向很容易越界。我们最后守住的边界很简单:HAL只做四件事——暴露设备句柄、管理内存对象、提供内核执行入口、提供同步原语。算子的数学语义、形状推导、类型推导、公共校验逻辑都留在ops-math库自身,不放进HAL。
打个比方,HAL就是墙上的插座标准,数学算子只认识零线火线地线,至于背后的电厂是火电水电还是核电,算子完全不关心。每个芯片后端要做的就是做一个符合插座标准的插头,把自家硬件的指令、内存、调度方式全部藏在插头里。这样一来,新来一块芯片,我们研发的核心工作从“改写所有算子”变成了“只写一个后端适配层”,算子库本身不需要跟着变。
2. HAL接口的四个切面:设备、内存、任务、调度
2.1 设备抽象:从裸设备到统一句柄
HAL最底下的一层是设备抽象。每个后端在启动时,把自己注册进HAL,暴露一个统一的DeviceHandle。这个句柄背后是什么硬件、什么驱动协议,算子和上层框架都不需要知道。
struct HalDevice { BackendType type; int32_t device_id; const char* name; void (*init)(const DeviceConfig& cfg); void (*destroy)(HalDevice* dev); HalDeviceProps props; }; struct HalDeviceProps { size_t max_memory_size; size_t alignment; // 该后端要求的内存对齐粒度 size_t sram_size; // 片上缓冲/共享内存大小 int32_t num_parallel_units; // 并行计算单元数量 bool support_fp16; bool support_bf16; bool support_fast_math; // 是否有近似计算指令 };初始化时,后端把自身能力通过HalDeviceProps上报给HAL。这些能力字段不是摆设,后面所有算子的分块、对齐、精度策略都要参考它们。我特别想提醒的是alignment这一项,很多跨平台问题就是对齐粒度不一致引发的,HAL里越早把这个字段统一暴露,后续踩坑越少。
2.2 内存抽象:不管它叫DDR还是HBM,算子眼里只有HalBuffer
不同芯片的内存管理差异非常大。有的芯片内存是统一编址,直接用普通分配接口还行;有的芯片则分设备内存和主机内存,数据来回拷贝要显式控制,甚至还有驻留内存的概念,用来固定某块物理内存供拷贝通道使用。如果算子直接调用各后端的分配函数,代码里会散落大量条件编译。
我的做法是让HAL提供一类HalBuffer对象,统一承载“分配、拷贝、释放、重塑”这几件事:
HalBuffer* HalAllocBuffer(HalDevice* dev, size_t bytes, HalMemKind kind); void HalFreeBuffer(HalBuffer* buf); void HalCopyBuffer(HalBuffer* dst, const HalBuffer* src, size_t bytes, HalStream* stream);算子在写逻辑时,不需要知道当前这份数据到底在GPU显存还是AI芯片的DDR里,它只需要拿到HalBuffer,然后交给后端实现去处理真实的搬移。异步拷贝接口也很关键,很多加速芯片都有DMA能力,把拷贝挂到HalStream上,可以让计算和拷贝重叠起来。这一层不做,跨平台就只能停留在“能跑”的水平,做不成“跑得快”。
2.3 内核执行抽象:KernelTask与一次dispatch
有了设备和内存,接下来要把“执行一个数学算子”这件事统一起来。我不要求所有后端用同一种内核语言,也不要求它们暴露同样粒度的线程接口。我定义了KernelTask,算子的数学逻辑全部体现在这个任务描述里:
struct KernelTask { const char* op_name; // 例如 "exp","softmax","matmul" HalBuffer** inputs; int32_t num_inputs; HalBuffer** outputs; int32_t num_outputs; float* scalar_params; int32_t num_scalar_params; HalStream* stream; };ops-math算子库负责把“y = exp(x)”翻译成一个KernelTask,然后调用HalDispatchKernel。后端适配层拿到这个任务后,自己决定用什么内核实现、开多少线程、怎么分块。这样算子库和硬件之间的依赖就变成了单向的:算子只会主动dispatch,不会反过来被某个后端的实现细节绑架。
当时我们内部还讨论过要不要把Tensor的形状信息直接放进KernelTask,后来决定不放。形状信息由HAL在dispatch前通过额外查询接口获得,因为有的后端在内部维护了自己的内存布局,直接传Tensor结构会把后端的自由度限制死。这个细节看似无关紧要,后来证明极大降低了大算子对小微分块的适配成本。
2.4 调度抽象:算子要描述并行维度,而不是告诉后端开多少线程
HAL设计里最容易出问题的是调度接口。很多团队会把“线程数”和“块数”直接暴露给算子,写起来是挺直观,但换后端就废了。我采用的方式是让算子在下发任务时附带一个ParallelHint,也就是“并行性声明”,而不是具体的并行配置。
ParallelHint会描述三件事:这个算子在哪个维度上天然可并行、可并行的粒度是什么、是否存在多阶段归约。比如elementwise算子,天然是“N个独立元素”级别的并行;reduce算子,则是“每行一个归约子任务,跨块归约还要再合一次”;矩阵乘是“按输出块拆分,块内可累加”。后端拿到这些声明后,自己映射到线程、线程块、核组或流水线。
有个实际经验:调度接口做成声明式之后,算子代码里再也看不到“根据线程ID访问数据”这种写法了。所有并行细节都下沉到后端内核模板里,算子的数学描述变得非常干净,也让单元测试更容易构造,因为你不再需要虚拟出一个特定芯片的线程模型来跑逻辑。
3. 从向量核到张量核:两个典型后端的落地对比
3.1 CPU向量后端:SIMD宽度、循环展开与尾部处理
CPU后端是HAL设计里最好的试金石,因为它的向量化语义很典型。以exp算子为例,数学上就是逐元素做指数运算。如果直接写一个for循环,编译器能自动向量化一部分,但性能和手工控制的差距仍然明显。HAL在CPU后端提供了向量宽度查询和循环展开提示,让算子可以按后端实际支持的SIMD宽度来规划每轮处理多少个元素。
int vec_width = HalQueryVecWidth(cpu_dev); // 可能是8、16、32 int main_len = (n / vec_width) * vec_width; for (int i = 0; i < main_len; i += vec_width) { VecType v = VecLoad(&x[i]); VecType r = VecExp(v); VecStore(&y[i], r); } // 尾部元素用标量处理,避免越界 for (int i = main_len; i < n; i++) { y[i] = std::exp(x[i]); }这段代码看起来简单,但“用query到的宽度而不是硬编码”是跨平台的关键。不同CPU型号的SIMD宽度不一样,硬编码8,在支持32宽的机器上就只能用出四分之一算力。尾部元素用标量循环处理,虽然慢一点,但保证了任何长度下都不会越界。后来在GPU后端和AI芯片后端,我们复用了这套“主向量化循环+标量尾部漏斗”的模式,只是把VecType替换成了对应硬件的向量类型。
3.2 张量后端:tiling、搬运与张量核
到了AI芯片这一类后端,情况就不一样了。它对算子的要求不是“每轮处理多少元素”,而是“以多大的块送入张量核”。以矩阵乘为例,如果算子能把一个矩阵乘描述成“输出分成若干个MxN小块,每块内通过K维累加”,后端就能直接映射到张量核指令。这个“块”多大,不同芯片完全不同,但数学语义完全一致。
HAL在这里做了一件很重要的事:定义了TilingHint,让算子只表达“可切分的维度”和“累加方向”,实际的块大小由后端在调度时决定。具体来说,算子下发任务时可以告诉HAL“这个矩阵乘输出维度是MxN,归约维度是K,可以按二维网格切分”,后端再根据片上缓冲大小计算合适的M块、N块和K块。这一层抽象前期做起来麻烦,但让矩阵乘、卷积、Gemm类算子在新芯片上的适配工作从“重写整个内核”变成了“配置Tiling参数”。
3.3 同一份softmax的两种路线
softmax这个算子最能说明“数学相同、落地路径不同”。它的计算链是:先求每行的最大值,再算指数和,再做归一化。CPU后端可以很自然地按行循环,把整行数据读进寄存器或向量单元,用两遍或一遍遍历完成。AI芯片后端则往往希望做online softmax,一遍遍历同时更新最大值和指数和,减少片上搬移。
难点在于online softmax需要保存中间状态,跨后端的算法骨架可以共用,但中间状态放在哪、什么时候同步,必须由后端来定。HAL通过提供ShmBuffer和事件同步原语解决了这个问题:算子库写online softmax的公共逻辑,涉及中间缓冲区分配和同步的部分,全部走HAL接口。这样,同一份softmax算法描述,在CPU后端表现为循环融合,在张量核后端表现为分块流水,而ops-math库本身不需要为每个后端单独维护一版softmax。
4. 性能可移植性:比功能不对更值钱的命中
4.1 Tiling参数注册表:把启发式算法从内核代码里赶出去
跨平台适配最容易出现的一种“软失败”是:功能全对,但在新芯片上性能差得离谱。原因往往是不恰当的分块参数被硬编码进了内核。我们后来建立了一个Tiling注册表,把每个后端的分块策略注册为独立的propagator,而不再藏在算子函数体里。
每个后端在注册时会提供一个回调,描述“给定算子类型、Tensor形状、片上缓存大小,应该用多大分块”。ops-math库在dispatch前调用这个回调,得到一份TilingConfig,再传给内核模板。这样,为某颗芯片调优分块参数时,改的是注册表里的配置,而不是算子的数学实现。在接入第四块芯片时,这个机制带来的收益特别明显:上一个后端调好的tiling参数不会影响新后端的初始运行,各自归各自调节。
4.2 访存模型:HAL如何帮算子决定内存访问顺序
后端之间的访存特性差异巨大。有的芯片连续读取很快,跨步读取会掉几个数量级;有的芯片有内存控制器偏好,数据按特定pattern摆放效率更高。HAL在后端props里提供了访存偏好描述,包括是否支持非对齐访问、跨步访问的惩罚因子、建议的内存访问粒度。
算子库在做Tensor遍历时,可以基于这些信息调整维度的循环顺序。一个很简单的例子:对二维矩阵做元素级求和,在偏好连续访问的芯片上,让最内层循环沿着最连续的内存方向走;在支持多维数据搬运的芯片上,则干脆让后端去做维度折叠。这套机制我们最开始只给了两个字段,后来逐渐演进成一个小的访存模型,许多算子的性能问题在写代码之前就能通过静态决策规避。
4.3 最优内核与回退内核:宁可慢生产,不要错
实际的算子库里,一个数学算子在不同后端往往不止一个内核实现。有的后端有专用硬件路径,性能高;有的后端只能用通用内核跑,功能正确但不快。HAL提供了一个KernelSelector,在每次dispatch时做两层查询:先查有无针对当前硬件的最优内核,没有再回退到通用内核。
这个设计让引入新芯片的第一阶段变得非常从容——先把通用内核跑通,保证正确性;后端的专用路径逐步补齐,性能慢慢追上来。与这个机制配套的还有一件必须做的事:性能回归断言。每次提交时,HAL层的profile工具会记录关键算子在不同后端上的耗时,超过基准一定比例就拦截合入。否则,某个后端很容易在不知不觉间从“最优内核”退化到“回退内核”,而CI里全是绿灯。
5. 跨平台适配中值得记下的三个坑
5.1 同一个“FP16”,不同后端有两副面孔
FP16在不同芯片上的行为差异比很多人以为的大得多。CPU上可能根本没有原生FP16计算单元,完全靠软件模拟,速度慢且容易因为中间计算精度不足而出现明显偏差;GPU那类后端有硬件支持,但舍入模式和FMA融合策略与AI芯片的张量核又不一致。最典型的问题是softmax在FP16下计算指数时容易溢出,因为FP16的表示范围对exp的结果非常不友好。
HAL解决这个问题的方式是提供数值能力描述:后端上报是否支持FP16快速路径、是否建议做防溢出预处理。ops-math库读到这个信息后,在FP16后端自动把softmax的实现切换成“先减最大值再算指数”的稳定版本,而在FP32后端则保持原有实现。跨平台不能假设“同一种数据类型在任何芯片上语义一样”,这正是HAL要兜底的事情。
5.2 对齐要求不一致导致的“幽灵越界”
我们曾经在某个后端上遇到过一个非常诡异的问题:同一个对Tensor做逐元素运算的算子,输入长度是1000,按32字节对齐的主循环处理了前992个元素,剩下8个元素用标量循环兜底。在A芯片上跑得好好的,换到B芯片就偶发报错,而且只在特定shape下出现。查到最后,是B芯片要求64字节对齐,而主循环仍然按32字节宽度走,最后一个向量化访问跨过了边界,读到了别的位置。
所以在HAL的Props里,alignment字段必须被所有内核模板作为最高优先级约束。我们在统一的向量化循环模板里,把向量宽度设成min(query_vec_width, alignment / elem_size),强制保证每次向量访问都不会跨过后端允许的对齐边界。从那以后这个类型的bug基本绝迹。这个教训的通用性很强:跨平台代码里的“边界条件”,永远要和对应芯片的“地址约束”一起考虑,不能只看数据长度。
5.3 注册表命名冲突:当两个后端都叫“exp”
算子注册表是另一个容易埋雷的地方。早期我们的注册表只用算子名做索引,比如元素级exp、softmax、layernorm。后来加第二个后端时,新后端也往同一个表里注册同名算子,结果某些设备上dispatch时匹配到了注册顺序里靠前的旧后端内核,行为全乱。排查过程相当难受,因为现象不是崩溃,而是数值不符合预期。
最后把注册表的索引改成了(BackendType, op_name)联合键,并且在注册时做重名校验,发现相同op_name出现在不同后端但设备类型判断有歧义时直接报错而不是静默覆盖。查询时,HAL还会根据设备句柄的类型自动过滤掉其他后端的注册项。这件事告诉我们:算子注册表是全局共享资源,任何“先到先得”的索引方式在跨平台场景里都是隐患,必须把后端维度纳入索引。
6. 验证体系与合入门禁:靠“对拍”成不了事
6.1 数据对拍:超过“形状一致”的对比方法
跨平台算子适配的验证,最朴素的做法是新后端算一遍、老后端算一遍,对比一下结果。但简单的np.allclose式比较很容易漏掉数值语义问题。我们把对比分成几个层次:最大绝对误差、平均相对误差、针对特定分布(如靠近零值、靠近饱和区)的抽样误差,以及NaN/Inf行为。
以exp算子为例,输入在[-80, 80]区间,不同后端的差异主要集中在绝对值很大的地方,用全局最大绝对误差能抓到。但对于softmax这类算子,输出值很小且数量级差异大,绝对误差容易失真,我们会额外算KL散度和余弦相似度。还有一个经验是:对拍用例的输入分布要尽量贴近真实模型里的数据分布,而不是纯随机均匀分布,否则很多低概率数值路径根本覆盖不到。
6.2 误差阈值不是拍脑袋定的
阈值设置是我们一开始最随意、后来最谨慎的一件事。不同数学算子、不同输入范围、甚至不同后端组合,阈值都不应该是一刀切。我们的做法是,每个算子在内核实现旁边声明一个ErrSpec,描述该算子的主要误差来源。聚合类算子如sum、reduce,误差随数据长度累积,用相对误差更合理;exp、sin这类逐点函数,误差更依赖输入值域,用绝对误差更直观。
对于FP32和FP16双后端并存的情况,阈值还会根据后端的数值能力描述自动放宽。代码里写死1e-5是简单,但第30个算子接入时你就会发现,这个阈值要么太严天天误报,要么太松漏掉真问题。一定得让每个算子带着自己的精度说明进HAL,而不是靠全局常量。
6.3 CI设备矩阵:让每一次提交都跑完所有后端
HAL层做出来之后,验证体系可以做成非常标准化的设备矩阵。我们内部在持续集成里维护了三类环境:模拟器后端、参考CPU后端、真实加速卡后端。模拟器跑起来快,还能开启更严格的越界检查和同步检查,适合做每次提交的门禁;参考CPU后端用同一套数据和误差对比逻辑,给精度兜底;真实加速卡后端跑关键算子的性能回归,防止性能悄悄劣化。
每类环境都跑同一套算子测试集,后端只需要提供一个测试适配器接入HAL,ops-math库完全复用同一套测试用例。这套体系跑通后,新后端接入的周期肉眼可见地缩短。我个人印象最深的一次,是接入一块新芯片时,从拿到工具链到算子库全量用例通过,只花了两周,而以前同样的工作量至少按两个月估。
如果让我对后来者提一个建议,就是别在一开始追求完美的抽象。先把两个后端同时跑起来,让三到五个典型算子完成适配,再回头看哪里该抽HAL接口,哪里该留后端自由度。我第一版HAL就是在一周内连夜重写的,因为当时把调度细节也抽象了进去,结果接口绕得不行;后来删掉那层声明式映射,反而清晰了许多。硬件抽象层不是越厚越好,它只需要刚好隔开数学语义和硬件细节——多一分,算子实现变得别扭;少一分,跨平台适配就还是复制粘贴的节奏。