☰
AI芯片软硬件协同设计:编译器、量化与性能调优实战
2026/10/20 14:19:31 网站建设 项目流程

如果你刚把一个 AI 加速器的架构定完,RTL 也仿得差不多,觉得自己终于可以喘口气了,那我劝你别高兴太早。真正让一块 AI 芯片从“能跑”变成“跑得快、跑得准、跑得省”的,往往不是流片回来的那坨硬件,而是压在它上面的那一大摞软件。这正好是我们这个“AI 芯片的软硬件设计”系列的第五篇,我打算把话题从架构设计和 RTL 验证往上一层抬,集中聊聊模型落到芯片上之后,编译器、量化、联合验证和性能调优这些软硬交织的环节到底是怎么配合的。

这篇文章更像是一份实战过程记录,不是教科书式地讲“AI 芯片有哪些组件”,而是讲我这几轮项目里踩过的坑、反复验证过的流程,以及在设计软硬件接口时真正起作用的那些决策。适合正在做 AI 芯片软件栈的工程师、做加速器验证的同学,也适合那些已经写完硬件、但还没想明白怎么让模型在芯片上高效跑起来的人。

1. 从“能跑”到“跑尽”:AI 芯片软件栈的边界到底画在哪里

先聊一个容易被低估的问题:软件栈到底要从哪一层开始算起。

很多人一提 AI 芯片软件,第一反应就是编译器。实际上,编译器只是中间那段最显眼的骨架。一个完整的 AI 芯片软件栈,往下要碰到驱动和固件,往上要碰到模型格式和图优化,左右还要跟量化工具、精度仿真、性能分析器打交道。如果这个边界没画清楚,后面每个环节都会互相扯皮。

1.1 芯片规格里的“算力”和用户能感知到的“算力”是两回事

芯片 Datasheet 上写的 TOPS 往往是理论峰值,按乘累加单元的数量和最高时钟算出来的。但一个真实模型跑上去,你最终得到的是实际帧率或吞吐,这两者之间的差距,几乎全部由软件栈决定。我举个例子你就明白了:

假设一颗芯片有 512 个 MAC 单元,主频 1GHz,不做任何折算,INT8 理论算力是 1 TOPS。听起来不低。但如果某个卷积层的输入特征图是 128 通道,输出是 256 通道,kernel 是 3x3,而片上 SRAM 只够放四分之一的特征图,那么数据搬运次数会直接翻几倍。搬运一多,MAC 单元就经常空转等数据。最后算下来,端到端利用率能有个 30% 到 40% 都算不错了。

这里面的关键矛盾是:硬件设计者习惯把存储划分、总线带宽、计算阵列当成独立参数去优化,但真实模型是一个数据流程序,它的瓶颈不在某一个单元,而在数据路径上最堵的那个环节。软件栈的任务,就是把计算图重新切分、排布,让数据尽可能留在片上,让 MAC 单元尽可能不停顿。

1.2 软件栈的分层和每一层必须回答的问题

在我参与的某款加速器项目里,我们一开始把软件栈分成四层,后来发现四层不够,因为总有一些决策必须跨层协作。最终我们用五层:

  • 模型接入层:负责解析 ONNX、自家格式或 PyTorch 导出的图,做算子融合和结构调整。
  • 图优化层:负责把 BatchNorm 折叠进卷积、把残差连接改写成更利于流水调度的形式、把大算子拆成子图。
  • 算子映射层:这是最核心的一层,负责把每个算子映射到底层硬件原语上,决定 tiling 方式、数据布局、循环顺序。
  • 运行时和驱动层:负责任务下发、DMA 搬运、同步管理、多核调度。
  • 性能分析层:负责采指令计数、访存带宽、MAC 利用率,反哺前三层做优化。

每一层都有一个必答题。模型接入层要回答“图能不能被成功转换”,图优化层要回答“转换之后有没有让硬件更好做”,算子映射层要回答“每个循环块放到片上之后会不会溢出”,运行时层要回答“两个核之间如何不互相踩踏”。如果哪一层只关心自己的正确性,最后拼起来一定会有各种诡异问题。

1.3 为什么说软硬件接口定义比算法实现更早启动

我和硬件团队协作了很多轮之后,一个特别深的体会是:软件栈设计不能等 RTL 冻结之后才开始。比如 DMA 描述符里要不要支持二维搬移,这直接决定编译器能不能高效处理卷积的 padding和行对齐。如果不支持二维搬移,编译器就得手动把数据拆成一维碎片,代码复杂,性能也差。

又比如计算阵列的累加器位宽。有些硬件团队为了省面积,把 INT8 卷积的累加器只做到 32bit。但编译器在融合 BatchNorm 时,如果想把缩放因子并进卷积权重里,可能产生稍微大一点的中间累加值。一旦累加器溢出,精度问题会非常隐蔽,而且表面上看像是量化误差。这种问题在 RTL 仿真阶段根本测不出来,必须跑到真实模型上才会爆发。

所以我们后来定了一个规矩:任何硬件 IP 的微架构改动,都要同步给软件栈负责人一份“数据路径行为说明”,包括中间精度、累加位宽、内存对齐要求、流水线节拍。软件层根据这个说明反推可优化的边界,硬件层根据软件的实际需求再调设计。听起来像是在增加沟通成本,但实测下来,这反而是整个项目里减少返工最有效的手段。

2. 把计算图翻译成硬件动作:编译器链路各层的取舍

编译器是软件栈里最显眼的角色,但很多第一次接触 AI 芯片编译器的人,会把“编译器”理解成“把模型翻译成指令的工具”。这么理解不算错,但会漏掉真正困难的点:指令生成只是结果,中间那无数个等价的翻译路径,才决定了性能差距。

2.1 前端和后端的分工,必须由硬件特性来划分

前端主要做图级别的处理。比如算子融合,把 Conv+BN+ReLU 拧成一个整体。这看起来纯粹是算法层面的优化,但它跟硬件强相关。如果硬件支持“卷积计算的同时做非线性激活”,融合才有收益;如果硬件不支持,编译器就得在融合之后再插入额外的激活指令,那融合就变成了一个纯前端动作,后段照样要拆开。

我们内部管这种优化叫“带硬件约束的图重写”。写前端优化 Pass 的人,不能只盯着 ONNX 图和数学等价性,他还得知道后端的指令集。比如某些加速器把激活函数做成查找表,那么 ReLU、Sigmoid、GELU 的处理方式完全不一样。ReLU 可以直接在输出端口用符号判断完成,GELU 则可能需要查表加插值,后端给的代价不一样,前端融合的收益也不一样。

2.2 后端 tiling 的真实算法:片上容量、访存带宽和计算延迟的三方博弈

后端的核心工作之一,是把一个大算子拆成能在片上 SRAM 里放得下的小块,这步叫 tiling。很多人觉得 tiling 就是“分块”,但选多大的块、按哪个维度分,背后是一笔需要算清楚的账。

拿卷积举例。假设原始算子是 [N=1, C=128, H=56, W=56],卷积核 3x3,输出通道 256。你要决定的是:一次放到片上做完整计算的数据块,到底包含多少输入通道、多少输出通道、多少个空间位置。

这个决策受三个因素限制:

  • 片上 SRAM 容量;
  • 总线的访存带宽;
  • 计算阵列的利用率和流水线深度。

我通常会让编译器先做一次“快速估宽”:

  1. 先选一个输出 tile,比如输出通道 32、空间位置 14x14;
  2. 反推需要的输入通道数和输入 tile 大小;
  3. 检查“输入 tile + 权重 tile + 输出 tile + 中间累加结果”是否超过片上可用 SRAM;
  4. 如果超了,就把输出 tile 的空间尺寸缩小一半,重新算;
  5. 如果不超,再估算搬运时间和计算时间的比值,判断会不会因为搬运太慢导致 MAC 空转。

伪代码大致长这样:

def choose_tile(out_channels_total, in_channels_total, spatial_size, sram_limit): out_tile = out_channels_total spatial_tile = spatial_size while True: in_tile = estimate_input_channels_needed(spatial_tile) weight_tile = out_tile * in_tile * 3 * 3 in_data_tile = in_tile * spatial_tile out_data_tile = out_tile * spatial_tile total = in_data_tile + weight_tile + out_data_tile if total <= sram_limit: return out_tile, spatial_tile spatial_tile = halve_spatial(spatial_tile)

这里每一步都牵扯到实际的数据类型尺寸。比如 INT8 的输入和权重各占 1 字节,但累加结果如果是 INT32,那输出 tile 占用的空间会猛增。所以累加器精度不仅影响数值正确性,还直接影响 tiling 策略,甚至可能因为输出 tile 太大,导致编译器被迫选一个计算效率更低的循环顺序。

2.3 数据布局:编译器里最容易被忽略、也最影响带宽的环节

架构师在看 RTL 时,通常关注计算阵列的排布,比如 16x16 的 MAC 阵列。但对编译器来说,怎么把数据从 DRAM 搬进片上 SRAM,再从 SRAM 喂给 MAC 阵列,才是更麻烦的事。

硬件通常要求数据按特定对齐方式访问,比如 32 字节对齐,或者按通道维度连续存放。如果编译器不重排数据布局,卷积的一个 3x3 窗口访问会跨多行、多通道,DMA 要发出大量短突发请求,带宽利用率会惨不忍睹。

所以我们一般会在后端做“布局转换(layout transform)”。比如把 NCHW 格式转成 NHWC,或者转成硬件更喜欢的通道分块格式。这一步在语义上是完全透明的,但会引入额外的数据搬运开销。编译器要判断的是:为了后续所有层的效率提升,这次布局转换值不值。

我踩过的一个坑是:为了追求“零拷贝”,我们一开始坚持保持模型的原始数据布局,结果卷积层之间反复跨行访问,带宽利用率只有两成。后来果断插入布局转换,虽然第一层搬运多花了几个毫秒,但后面十几层卷积的速度直接提升了一倍多。做这种决策,不能只看单次操作的开销,要看整条数据链路上的累计收益。

3. 精度与速度的拉锯:量化配置里那些容易被忽略的隐藏开关

很多团队把量化当成“模型压到 INT8”,好像只要把浮点权重变成整数,精度不掉就行。但放到软硬件协同设计的视角里,量化远不止是数值变化,它会影响算子融合、内存占用、流水线调度,甚至影响编译器是否敢做某些激进优化。

3.1 对称量化和非对称量化:不只是数学选择,也是硬件寻址选择

先看一个最基础的选择:对称量化还是非对称量化。

对称量化通常把浮点数值范围映射到 [-127, 127] 或 [-128, 127],零点为 0。非对称量化则允许零点和缩放因子都不为 0,能更精确地表示偏态分布的数据。

从数学角度看,非对称量化当然更灵活。但硬件实现上,非对称量化意味着每次乘加前可能需要额外处理 zero-point 的偏移,要么多一次加法,要么多一次查表。对某些加速器来说,zero-point 非零的数据在 DMA 搬运时也要多算偏移,导致地址计算更复杂。

我见过一个项目,硬件只实现了对称量化,因为 RTL 团队想省掉零点的修正逻辑。结果模型里某些层的激活值整体偏正,直接对称量化后,量化噪声大得离谱。后来软件团队设计了“输入预处理偏置补偿”:把激活先整体减去一个常数,变成近似对称分布,然后量化,再在后面的计算里把常数补偿回来。这种方案本质上是软件层弥补硬件缺陷,坑在于补偿时机一旦错位,误差会一路累积下去。

表格里整理一下常见的取舍:

方案优点硬件代价软件补偿难度
直接对称量化实现简单,地址计算快低低
非对称量化精度更高,适合偏态分布需要 zero-point 运算低
输入偏置补偿在不改硬件前提下近似非对称只是多一次变换高,时机易错
按通道量化权重每通道独立缩放,精度好需要每通道保存 scale中,融合时要处理

3.2 校准数据集和离群值:精度问题的隐形根源

量化之前要做校准,也就是选一批数据统计激活值的分布。这个环节看起来很简单,但它直接决定最终的 scale。

有一次我们碰到一个很奇怪的现象:某两个模型结构几乎一样,只有残差分支的层数不同,一个量化后精度正常,另一个却掉了三个多点。后来追到底,发现问题出在校准数据集上。那个差模型里有一条计算路径只有极少数的样本会产生很大的激活值,属于离群值。普通校准方法用一个比较大的百分位去截断,把离群值削掉了,表面上分布很稳,但真正跑推理时,只要输入一碰上那类样本,误差就会突然放大。

后来我们改成双阶段校准:第一阶段先用 99.99% 分位值做预量化,第二阶段把那些超过阈值的样本单独统计,看它们的误差贡献是否集中在某些层。如果误差贡献大,就手动调低那一层的量化范围,或者把那层保留为 FP16。这种做法本质上是把“量化范围”当成一个可调参数,而不是机械地统计最小值最大值。

3.3 量化对编译优化的连锁反应:一个例子就够了

量化和编译结合的问题,比较典型的是 BatchNorm 折叠。

浮点模型里,Conv 后面常常跟着 BatchNorm。编译器在浮点域里可以把 BN 的 scale 和 shift 折进卷积权重里,这是一步很标准的优化。但到了量化域,这步折叠就没那么简单了。

因为折叠之后,卷积的权重和偏置变了,原本的量化 scale 可能无法直接套用。如果后端没有做好“重量化(requantization)”处理,折叠反而可能让误差变大。我们遇到过一次,编译器把 BN 折叠后,某个通道的激活值整体偏移,导致 ReLU 后的特征分布变化,最终模型输出质量下降。

解决方式说穿了也不复杂:在做 BN 折叠之前,先检查硬件累加器位宽能不能容纳放大后的权重;fold 过后,重新计算激活的 scale,并插入 requantize 节点。但真正难的是把这些规则写进编译器 Pass 里,并且能够在不同网络结构之间自动适配。每次新增一种复杂结构,都要回头检查这个 Pass 有没有把后端的数值边界考虑清楚。

4. 让编译结果和硅片行为对齐:软硬件联合调试的完整套路

软件栈做得再完善,也总会遇到跑模型时结果不对的情况。这时候最忌讳的就是软件和硬件互相甩锅。软件说“我的图优化在数学上是正确的”,硬件说“我的 RTL 仿真通过了”,但端到端结果就是不对。要解决这类问题,你得有一套软硬件联合调试的固定招数。

4.1 先把问题分层定位,别一上来就翻 RTL

我自己的习惯是,遇到输出不对,先做分类。按现象分,大致有四类:

现象优先怀疑对象第一步验证手段
只有某个算子的输出整体偏一个固定比例量化 scale 或布局转换错误dump 该层输出和参考输出,算逐元素比值
输出部分正确部分乱码tiling 或内存越界把 tile 调到最小,再看是否恢复正常
多个核协同后结果错乱同步或内存屏障缺失先跑单核,再逐步加核验证
误差时有时无,换输入就变未初始化 SRAM 或复位不干净反复跑同一个输入,观察是否稳定复现

这张表不是什么标准规范,但它在我们的项目里非常管用。很多时候,只要把问题分类清楚,就基本能猜到是哪个层的问题。

4.2 最小化编译选项:逐层剥离优化,找到第一次出错的变换

有一次我们遇到一个很头疼的问题:整个模型前几层都对,到了第 7 层开始错,而且输出数值像是被截断了,不是完全乱码。一开始怀疑是量化溢出,但检查了 scale 和累加位宽,都在范围内。

后来我们用了“最小化编译选项”的方法:把编译器里所有激进优化全部关掉。关闭算子融合,关闭循环重排,关闭布局转换,强制按最简单的一对一方式生成指令。结果模型完全正确。这说明问题一定出在某个优化 Pass 上。

我们再逐个打开优化。打开第一个融合 Pass,正确;打开第二个重排 Pass,正确;打开第三个布局转换 Pass,错误复现。于是我们把目光锁到布局转换上。一查才发现,布局转换的 Pass 在计算某个张量的 stride 时,没有考虑硬件内存对齐的补丁长度,导致搬运的起始地址偏了几字节。这个问题在单算子测试里不会出现,因为单算子输入输出大小刚好对齐;到了真实网络里,上一层输出的形状不满足对齐要求,就爆出来了。

这种问题在 RTL 仿真里很难发现,因为仿真环境里通常只跑固定的小向量,很少真正模拟编译器生成的一整段复杂指令流。所以编译器的“可复现性”非常重要,最好每个优化 Pass 都支持独立的开关,并且能记录下“启用了哪些 Pass、按什么顺序启用”。这样一旦出问题,就可以快速二分定位。

4.3 中间结果采样:让模型梯度消失的差不多的“指针”

联合调试时,中间结果采样是我必做的一步。具体做法是:

  1. 在参考实现(比如 PyTorch 或浮点算子库)里,把每一层输出都导出成二进制文件。
  2. 在芯片或 RTL 模拟器上,让编译器插入“debug store”指令,把对应层的输出也导出来。
  3. 写一个比较脚本,按元素对比,统计最大绝对误差、平均误差、错误位置。

听起来很机械,但真正做起来有讲究。第一个讲究是,最好在同一个数据布局下对比。如果芯片上数据是 NHWC,参考实现是 NCHW,你得在比较脚本里先做转换,否则你看到的差异不是数值误差,而是布局误解。

第二个讲究是,不要只看最终输出。模型中间的激活值才是诊断关键。我们经常发现,最终输出看起来是对的,其实中间某层已经跑偏了,只是后面几层把它校正过来了。这种“隐性误差”一旦在某些输入上失去校正能力,输出就会突然崩掉。所以最好在多个固定输入上做中间层采样,建立一条“误差传播曲线”。

4.4 硬件症状和编译器症状的区分:一个反例

还有一回,我们遇到一个特别奇怪的现象:同样的编译结果,在 RTL 仿真里跑是对的,在 FPGA 原型上跑却是错的。软件团队说编译器没问题,硬件团队说 RTL 验证都过了。

后来我们查了很久,最后发现是数据总线上的一个去抖逻辑在特定温度下偶尔打拍错误,导致 DMA 搬运的数据缺了字节。这本质上是一个硬件时序问题,但它在行为上表现为“编译出的指令偶尔会得到错误结果”,极具迷惑性。

从那以后,我们要求测试团队在硬件原型上跑模型时,必须配合一个“运行复现记录”工具。每次出错,自动记录当前输入数据、指令序列编号、DMA 搬运地址和现场错误寄存器。只有把软件执行的上下文和硬件的物理行为绑定起来看,才能快速区分到底是软件逻辑错,还是硬件时序错。

5. 性能不是玄学:用可复现的流程把板卡的真实算力挤出来

验证正确性的下一步,就是打磨性能。性能调优在整个软硬件设计里是最容易变成“玄学”的环节——有人说这样调快,有人说那样调更快,最后谁也说不清。我的观点是,性能优化必须建立在可复现的测量和清晰的决策模型上。

5.1 先判断一个算子是访存受限还是计算受限

AI 芯片上的算子基本都是数据密集型。但“数据密集”也有两种:一种是计算量很大,MAC 阵列忙不过来;另一种是数据量很大,总线带宽是瓶颈。这两者的优化方向完全相反。

有一个粗略的经验公式:

估计执行时间 ≈ max(计算时间, 访存时间) 计算时间 = 总乘累加次数 / MAC 阵列峰值 访存时间 = 总搬运字节数 / 有效访存带宽

如果一个算子的“访存时间”明显大于“计算时间”,那就是访存受限。这时候你再怎么优化计算阵列的利用率都没用,重点应该放在减小搬运数据量上。比如重新选择 tiling 形状,让输入数据在片上多复用几次;或者用更紧凑的数据布局。

反过来,如果是计算受限,那重点就变成让 MAC 单元尽量连续工作,避免流水线气泡。

我们之前有个算子,优化前访存时间是计算时间的 1.8 倍,典型的访存受限。后来把输入通道维度的 tiling 从 64 改成 128,输入数据被重复搬运的次数减半,访存时间直接降了 40%。MAC 利用率没动,算子整体却快了很多。

5.2 双缓冲和流水线重叠:让 DMA 和计算并行

说到访存优化,最常用的手段就是双缓冲。简单来说,就是片上准备两块 buffer,一块让 DMA 往里搬下一个 tile 的数据,另一块让计算阵列处理当前 tile。这样搬运和计算就重叠起来了。

但双缓冲也不是银弹。它要求编译器能把“搬下一块数据”和“算当前数据”拆成两个独立的任务,并且确保计算不会提前踩到还没搬完的数据。这又回到同步机制的问题。

我们在做多核调度时,最容易出错的点就在双缓冲的同步上。一个核在下一块数据还没到位时就开算,结果读进来的还是上一块的数据,最终结果就会乱七八糟。为了对付这个问题,运行时层设计了一套“栅栏”机制,每个核在关键节点必须等待 DMA 完成信号,再开始计算。

代价是,栅栏太多会让流水线停摆。所以这里要平衡:不是每个循环都插栅栏,而是每隔几个 tile 插一次,靠“计算时间 > DMA 时间”的余量来保证数据到位。这种调优需要反复实验,但一旦调好,性能提升非常明显。

5.3 多核切分:按通道切还是按空间切

多核并行时,常见的两种切分方式是按输出通道切分和按空间位置切分。

按输出通道切分的优点是每个输出位置只存在一个核的计算结果里,不需要跨核合并。但如果输出通道数比核数少,或者通道间计算量不均衡,负载就会偏斜。

按空间位置切分的好处是天然可以让多个核独立处理不同区域,但遇到卷积分界时,会有重复的输入数据加载,也可能需要处理 halo 区域的同步。

我一般会让编译器同时生成两种切分方案,然后在真实模型上跑一小段,比较实际吞吐。纸上谈兵很难判断,因为同一个算子在内存布局、数据复用率不同的情况下,表现可能完全颠倒。实测永远是最准的。

5.4 性能计数器:不是可选项,是刚需

最后说一个有点“反面教材”的体会:性能计数器一定要从一开始就有。

我们有一版加速器的性能计数器只统计了“MAC 总周期数”和“总线总访问次数”,没有统计“DMA 等待周期”。结果调优时,我们经常看到一个算子计算时间并不高,但整个推理时间很慢。后来推断出等待时间可能很长,但没有任何硬证据。直到下一版加了 DMA 等待计数器,才确认很多时间都浪费在搬完上一块数据、但下一块 DMA 还没发起的状态上。

如果芯片还在设计阶段,我强烈建议在架构里预留足够的性能计数寄存器。不要只关注最终的帧率,要能拆出计算周期、访存周期、同步等待周期、指令发射停顿周期。没有这些数据,软件栈优化基本靠猜。

结尾:回到我自己常犯的一个误区

写到最后,想分享一个我常犯的误区:总觉得性能不够是因为硬件算力不够。但过去几个项目告诉我,绝大多数时候,瓶颈都出在“软件没有把硬件的脾气摸透”上。

编译器要理解硬件的限制,量化工具要知道累加器的脾气,验证团队要能精确区分软件症状和硬件症状,性能调优则要建立数据说话的决策模型。这些环节单独看都不起眼,但它们拼在一起,才决定了一块 AI 芯片是不是真的能打。

如果你也在做类似的事,建议先从软件栈和硬件的接口定义上入手,别急着把编译器写完美。先把边界定清楚,后面所有层都会好做很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询