AI 芯片的软硬件设计(8):访存墙与软硬件接口的协同博弈
很多团队拿到一块新的 AI 加速芯片,第一眼都盯着峰值算力:TOPS 多少、TFLOPs 多少、是不是又翻了一倍。但我这几年的经验是,真正决定一个模型跑得快不快的,往往不是算力,而是访存路径。算力就像洗碗机的转速,带宽就像厨房的水管——水管粗度不够,转速再快也是空转。这个系列聊到第 8 篇,我想集中讲一讲我在多个 AI 芯片项目里的核心感受:算力堆高不难,难的是让软件真正把数据喂给计算单元,也就是“访存墙”和软硬件接口的协同设计。这篇文章会覆盖内存带宽瓶颈的建模原理、加速器侧的数据流设计、算子库与编译器的适配逻辑,以及一个从 18% 硬件利用率优化到 47% 的真实案例。
如果你是刚入行的芯片验证工程师、做 AI 框架或算子库的软件开发者,或者是准备做硬件选型的算法团队,这篇文章应该能帮你少走不少弯路。我会尽量把原理讲清楚,也把我在实际项目中踩过的坑和排查思路写出来,而不是只给一个“性能调优秘籍”。
1. 访存墙:为什么算力堆得再高也跑不出标称性能
1.1 用 Roofline 模型给“墙”画个边界
我们常听到“内存墙”这个词,但到底墙有多高,绝大多数人没有量化概念。我最常用的是Roofline 模型:把硬件的峰值算力P(比如 256 TOPS)和峰值带宽B(比如 4 TB/s)放在一起画两条线,两线的交点对应的“操作强度” I = P / B。对上面这个例子,I = 256e12 / 4e12 = 64,意思是每个字节的数据搬进计算单元时,至少要提供 64 次有效运算,计算单元才能跑满。如果模型某个算子的操作强度只有 20,那无论峰值算力多高,实际性能都被带宽封顶在20 × 4TB/s = 80 TOPS,利用率只有 31%。
这个模型极好用,因为它在写代码之前就能告诉你优化方向:是提高数据复用、降低搬运量,还是换一种数据布局。我记得第一次用这个模型分析一个卷积网络时,结论非常扎眼:模型里很多小卷积层的操作强度只有 10~15,就算计算阵列利用率是 100%,整机也就能发挥出两三成的峰值算力。
我个人的习惯是,拿到硬件规格书先不急着跑 AI 模型,而是先用一组微基准测出真实的可持续带宽——注意,是“可持续”,不是 HBM 理论峰值。实测里经常遇到的情况是,连续读能跑得很高,但一旦随机访问或带跨步就掉一半甚至更多。这直接决定了后面算子库开发时该用什么样的数据组织和搬运策略。
1.2 数据搬运次数:软硬件设计都要回答的“题眼”
访存墙的本质,是数据搬运次数太多。每个卷积算子,输入特征图、权重、中间结果都要从 DRAM 搬进片上存储;如果每次计算前都去 DRAM 取数,那带宽再高也经不起折腾。解决思路只有一个:把数据留在片上,尽量多次复用。
但这“多次复用”到底该在硬件上做还是软件上做?不同芯片设计流派给出完全不同的答案。有些加速器硬件里集成了专用数据流调度器,编译器只需要指出“这是卷积”,硬件自己决定怎么搬数据;还有些加速器走的是“软件定义搬运”的路线,编译器把数据切块、缓冲、双缓冲全部显式表达,硬件只是忠实地执行 DMA 命令。前者的优点是编程简单,缺点是遇到非标准卷积或新算子时,硬件调度器跟不上;后者的优点是灵活,缺点是对编译器要求极高,一个切块参数不对,性能就可能断崖下跌。
我在自己做的一块模拟加速器项目上选择了折中方案:硬件提供灵活的片上缓冲区描述符和 DMA 引擎,软件通过描述符来控制数据流。这样做的好处是,卷积这类高频算子可以被算子库压榨到极致;当一个新结构出现时,软件也能通过组合原子操作来适配,不需要等下一代芯片流片。
2. 硬件侧设计:数据流架构与片上存储给软件留的“接口语义”
2.1 数据流拓扑不是选一个字母那么简单
加速器中最经典的问题之一是选数据流方向:权重固定(Weight Stationary)、输入固定(Input Stationary)、输出固定(Output Stationary),还有行流水(Row Stationary)。如果只看论文,感觉只是排列组合;真做起来才会意识到,这个选择直接决定了软件侧该怎么描述计算,以及片上存储该怎么分区。
我以卷积为例说明。假设输入通道是 128,输出通道是 128,卷积核是 3×3。如果选权重固定,那么权重一旦从 DRAM 搬到片上缓冲区,就要被尽量多的输入像素复用;这时候输入特征的搬运方式必须是“一行一行推送”,且每一行的水平复用窗口要覆盖 3 个卷积点。软件侧需要计算的切块参数就不仅是 NCHW 的 batch、channel、height、width,还有“沿宽度方向一个 tile 内的输出像素数”,这个参数决定权重被复用的次数。
如果选输入固定,思路反过来:输入特征大部分时间待在片上,权重以流形式进入。这种方式对权重大小和通道数异常敏感,适合权重较小或可压缩的稀疏模型。而输出固定最容易被编译器建模,因为输出矩阵的累加结果一直留在片上寄存器里,但它对片上累加空间的容量要求很高。我的实际经验是,没有哪个方案在所有场景都最优。高性能算子库最后都要做成“多数据流模板并存,根据 shape 自动选择”。
所以硬件团队在做微架构时,千万别把数据流方向用硬连线焊死,否则软件再怎么优化也只能戴着镣铐跳舞。一个可配置的数据流控制器,哪怕只是支持“权重固定/输入固定”两种模式,都能给后面的算子优化留下巨大空间。
2.2 片上存储层次与 DMA 描述符:软硬件真正的“握手面”
片上存储怎么分层,是我在所有芯片项目里最看重的一个设计点。常见做法是:最靠近计算阵列的是寄存器文件和 L0 缓冲区,接下来是一块比较大的 L1 SRAM(或者叫 shared memory),再往外才到 DRAM。有些芯片还有 L2 缓存,专门用来承接算子间复用和中间结果驻留。
这里的接口语义要非常明确。以 DMA 描述符为例,我建议至少包含以下字段:
| 字段 | 含义 | 设计注意点 |
|---|---|---|
| src_address / dst_address | 源与目标地址 | 必须支持任意对齐,否则软件要填 pad |
| tensor_shape | 搬运的张量维度 | 至少支持到 4D,否则复杂 layout 要拆多条命令 |
| stride 数组 | 各维度的 stride | 这是处理 NCHW/NHWC/自定义布局的关键 |
| 是否触发中断 | 搬运完成同步方式 | 尽量用完成队列而不是逐次中断 |
| 优先级 | 与其他 DMA 的仲裁关系 | 低优先级搬运应可被抢占 |
这些字段看起来是硬件细节,但在软硬件协同的项目中,这就是软件工程师眼里的“硬件 API”。我见过一个项目,硬件为了省寄存器,把 stride 的粒度限制成固定倍数,结果软件层不得不把所有张量 pad 成对齐形状,浪费了大量片上空间和搬运带宽。教训是:硬件上每做一个“简化”,都要跟软件团队一起过一遍完整算子映射流程,而不是只看寄存器面积。
2.3 双缓冲与同步原语:算得慢可以忍,等数据不能忍
数据流设计里我特别想强调“双缓冲”。简单说,就是 DMA 在往缓冲区 B 搬运下一块数据的同时,计算单元在处理缓冲区 A 中的数据;两块缓冲区交替使用,搬运和计算重叠,把访存延迟藏起来。做得好,硬件利用率能上来一大截;做不好,所谓的“双缓冲”只会在同步点频繁卡住。
问题往往出在同步原语上。硬件如果只提供全局同步栅栏(barrier),那一个计算单元先算完,也得等所有单元都到齐才能进入下一轮,这种“木桶效应”会让细粒度的双缓冲形同虚设。更合理的做法是提供基于描述符的依赖链:DMA 命令可以显式指定“等上一组计算命令完成后再启动”,不同计算核心之间的同步画成有向无环图。软件映射阶段把每一轮迭代的依赖关系提前算好,硬件按顺序执行而不是靠轮询等待。
我在模拟项目里试过两种方案,用纯栅栏同步时,小算子重叠率只有 40%;改成描述符依赖链后,同样代码的重叠率到了 75% 以上。虽然增加了硬件设计复杂度,但收益非常明显。
3. 软件侧适配:算子库、编译器与运行时如何吃透硬件特性
3.1 算子库承担的是“硬件模板化”的脏活
说到 AI 芯片软件栈,很多人会高估编译器的能力,低估算子库的价值。实际开发中,卷积、矩阵乘、归一化这类高频算子,几乎都是手写加自动调优出来的。编译器负责把模型翻译成计算图,但最终落到加速器上的内核,往往是从算子库里挑出来的模板。
写算子库的时候,最核心的动作是把数据流模板固化成代码:对内做循环切分、向量化、乒乓缓冲,对外暴露统一接口。一个典型的卷积算子模板,切分逻辑大概长这样:
// 伪代码:卷积算子模板的切分流程 void conv2d_tile(Input, Weight, Output, tile_params) { for (int n = 0; n < tile_params.block_n; n++) { for (int oc = 0; oc < tile_params.block_oc; oc++) { // Load weight tile to SRAM (double-buffered) dma_load(weight_tile[oc], src_weight + offset, sync_flag); for (int oh = 0; oh < tile_params.tile_h; oh++) { // Load input tile row to SRAM dma_load(input_tile[n][oh], src_input + in_offset, sync_flag); // Compute mac array compute_mac(input_tile, weight_tile, output_acc, tile_params); } } } }但模板化最大的坑是“参数组合爆炸”。block_oc 取 16 还是 32,tile_h 取 4 还是 8,不同张量形状下性能能差出好几倍。我现在的做法是引入自动调优框架:把模板参数范围定义好,用一个短时基准测试在一组随机 shape 下搜索,再把搜索结果固化成本地算子缓存。这套流程不能完全替代人工经验,但至少能把“玄学调参”变成“半自动化找最优区”。
3.2 编译器眼中的硬件抽象:从循环变换到张量布局
编译器要真正用好硬件特性,它眼中的“硬件”得是一个干净的模型。我常用的是“分层抽象”:最底层是一组类汇编指令(LOADA、STOREA、MMA、SYNC 等),中间层是带形状的循环结构,最上层才接 AI 框架的计算图。每次硬件微架构调整,都尽量只改底层指令的语义,不让上层感知。
循环变换是编译器发挥威力的第一站。同一个卷积,可以拆成 NCHW、NHWC、NC1HWC0 等不同布局;不同布局直接决定了访存的连续性和向量化宽度。我建议算法和编译团队早点在“图优化阶段”就把布局转换算子插入进去,而不是让每个算子各自做 layout 猜测。否则就会看到一种很常见的性能事故:前一个算子输出 NCHW,后一个算子只能高效处理 NHWC,中间被迫插入一个昂贵的 transpose,搬运量直接翻倍。
算子融合是另一件性价比极高的事。卷积后面通常跟着偏置加、ReLU、甚至 BatchNorm。如果逐个算子都写回 DRAM,再读出来做下一次计算,带宽成本能占到整体延迟的一半以上。融合的思路是把这些操作合并成一个内核,中间结果只存在于片上寄存器或 L0 缓冲。我见过不少团队在这块嫌麻烦,觉得“让硬件缓存兜着就够了”,但结果是 L2 缓存命中率飘忽不定,远不如显式融合稳定。
3.3 运行时调度的隐性成本:Launcher、量化缩放和负载均衡
很多人把性能问题都归到算子和编译,其实运行时(Runtime)也会吃掉大量性能。我印象很深的一次排查:一个很小的全连接层在加速器上跑了 12 微秒,但软件侧 CPU 提交任务、等待完成通知的开销占了 8 微秒。算力再快,也架不住“启动指令比执行指令还慢”。
这种启动开销的解法有几个方向。一是批量提交:把多个算子的描述符一次性写入命令队列,而不是每算子调用一次驱动。二是减少中断:用完成队列累积状态,计算单元跑完一组算子在队列里写一个标记,CPU 批量轮询而不是处处同步。三是量化缩放处理:很多量化模型的 per-channel 缩放因子需要实现为逐元素乘法,如果运行时把这个操作当作独立算子下发,又会多一次访存;正确做法是把它融合进卷积的 epilogue 里。
负载不均衡也是被低估的问题。用固定 tile 划分输出通道时,不同通道组的数据分布差异会导致某些计算核心先干完活等着别人。解决思路是动态调度或持久化线程块。可在很多 AI 加速器上,硬件原生并不支持动态领取任务,这种情况下软件只能靠更小的静态 tile 来平滑负载,但小 tile 又意味着更多的重复搬运——这个平衡点只能靠实测。
4. 一个真实的调优案例:从 18% 的硬件利用率到 47%
4.1 基线 Profiling:先确认瓶颈到底在哪
我在一个模拟项目上做过一个典型的三层卷积网络,使用一块自行设计的 AI 加速器,峰值算力 128 TOPS,片上有 2MB SRAM,外部带宽标称 1.2TB/s。第一版算子库写完后,我们用硬件计数器跑了一遍“卷积层-01”和“卷积层-02”,结果非常不理想:卷积层-01 的硬件利用率只有 18%,卷积层-02 更低,才 11%。官方标称 128 TOPS,跑起来跟个玩具似的。
排查的第一件事,就是用 Roofline 算理论边界,看看到底是“墙”还是“人祸”。逐层统计 DRAM 读写流量后发现,卷积层-01 实际搬运数据量是同形状下理论最小搬运量的 2.8 倍。问题不在硬件,而是软件实现把大量中间结果一遍遍写进 DRAM,完全没有利用好片上 2MB SRAM。
4.2 关键优化动作与实测结果
我按“先访存、后计算、再同步”的顺序做了几轮优化,每一轮都记录芯片硬件计数器的输出:
| 轮次 | 优化动作 | 算子平均利用率 | 备注 |
|---|---|---|---|
| 基线 | 直译式映射,无分块 | 18% | DRAM 流量巨大 |
| 第 1 轮 | 引入 tile 切分 + 片上累积 | 26% | 中间结果不再反复读写 DRAM |
| 第 2 轮 | 双缓冲 + 描述符依赖链 | 35% | 计算与 DMA 重叠度提升 |
| 第 3 轮 | 权重重排(Weight Repacking) | 42% | 消除权重读取的跨步访问 |
| 第 4 轮 | 算子融合(Conv+BN+ReLU) | 47% | 再降一轮 DRAM 流量 |
每一轮的收益都不像“锦上添花”,而是层层递进。做第 1 轮切块时,最大的阻力不是代码,而是怎么设计片上缓冲区的分配。2MB SRAM 要同时装输入 tile、权重 tile 和输出累加缓冲,我花了整整两天调 tile 尺寸,最后发现瓶颈竟然是整数倍对齐问题——tile 高度选 5 的时候,硬件矩阵单元的边界利用率比选 8 低了三成。
第 3 轮的权重重排,是我特别想强调的一个细节。原始权重布局是按输出通道顺序连续存储,卷积计算时每个输出通道需要读取三行权重,跨步很大,带宽利用率极低。我们把权重原地重排成“卷积核行优先”的格式,算法表现完全没变,但 DMA 的跨步访问变成了连续访问,带宽效率直接涨了 20%。这类布局优化,在文档里往往只写一句“优化了数据布局”,但实际效果比很多花哨的高级 API 都来得大。
4.3 剩下的 53% 去哪了:对高利用率要保持敬畏
调完 47%,团队里有人问:能不能再往上拉一拉?我们把硬件计数器全开,继续往下抠。结果发现,剩下的一半时间里,大约有 25% 被片上 SRAM 的 bank 冲突吃掉——不同计算核同时访问同一 bank 的不同地址,发生流水线停顿;还有 20% 是因为模型里参数量和 tile 尺寸不匹配,导致权重反复被换入换出;真正算得贼快的部分,其实只有一半。
所以你现在应该明白,47% 的利用率在真实 AI 芯片上已经算很健康了。业界很多号称数百 TOPS 的芯片,跑真实模型能到 35% 就算调得很不错。官方标称的峰值 TOPS 可以在最理想条件下测出来,但真实网络结构千变万化,数据复用机会、权重规模、算子形状都不同,利用率很难逼近理论值。对“98% 利用率”这种数字要保持警惕,要么是测试条件极特殊,要么是统计口径有水分。
5. 性能剖析与全链路 Debug:先测后调的实操方法
5.1 从硬件计数器到运行时 Trace:三条信息缺一条都不行
我调试 AI 芯片性能问题时,依赖三类数据,缺一条都容易误判:
- 第一类是计算侧数据:各计算核心的实际运行周期、空闲周期、停顿周期。这能看出负载均衡和同步等待情况。
- 第二类是访存侧数据:DRAM 读写字节数、DMA 命令数量、bank 冲突次数、总线占用率。这能看出带宽和布局问题。
- 第三类是软件侧数据:CPU 提交任务的耗时、驱动调用次数、等待同步的时间。这是最容易漏掉的,很多“硬件利用率低”的假象,其实是 CPU 启动路径太慢。
这三类数据要能在时间轴对齐。光看平均数字不够,我经常会把一个算子的执行时间画成一条流程瀑布:CPU 提交用了多久、DMA 搬运用了多久、计算阵列真实计算用了多久、中间等待了多久。这个瀑布图一出,瓶颈立刻现形。有一次我以为算子是访存瓶颈,结果是 CPU 写命令队列时加锁太频繁,一个算子要交 30 次小命令,光锁等待就占了 60% 延迟。
5.2 典型定位流程:先算边界,再看数据,最后看微结构
我推荐一套可复现的排查流程。第一步永远是用 Roofline 模型算理论边界,明确这个算子在当前硬件上到底有没有“可能”达到 80% 以上。如果理论边界就是 50%,那后面所有努力都是在 50% 这个上限里求最优,别浪费情绪。第二步看 DRAM 流量:实际搬运量除以理论最小搬运量,这个比值超过 1.5,说明布局或切块有问题;接近 1.0,再去看计算单元的微结构停顿。第三步才是计算/同步细节:bank 冲突、写后读依赖、同步栅栏粒度。
这套流程看着简单,但我在实际项目里见过太多人一上来就盯着汇编级微操作优化,忙了大半天,最后发现瓶颈在更上层——类似“指令集映射根本不合理”这种问题。逆着水流找源头,永远比在漩涡里打转高效。
5.3 一个容易踩的坑:极简基准测试不能代表真实模型
很多人喜欢用一个大的矩阵乘作为基准,跑出很高的利用率,然后宣布芯片性能优秀。这个测试本身没问题,但它严重偏向“高复用、规则形状、大矩阵”的场景。真实模型里大量算子的形状都不是这种规则大块,而是千奇百怪:1×1 卷积、3×3 深度卷积、动态形状的注意力矩阵。一个加速器的大矩阵乘可能跑得飞快,但换到深度卷积时,数据复用系数可能只有矩阵乘的五分之一。
我自己就会准备一套“形状压缩测试集”,里面不放任何标准大模型,而是几十个形状各异的算子宏。每次芯片小改版本,都先跑这套测试集,看哪些算子退化得厉害。这套做法能从设计早期就暴露软硬件接口的不匹配,比等模型集成完再全线调试要省一个量级的时间。
6. 下一代的软硬件协同方向:从能效比到可编程性的再平衡
6.1 小芯片、统一内存与近存计算的工程现实
很多下一代方案都在做小芯片(chiplet)设计,把计算芯粒、内存芯粒、IO 芯粒分开制造再封装在一起。好处是良率和灵活性,但对软硬件协同设计提出了新问题:跨芯粒访问的带宽和延迟远高于单芯粒内,编译器必须知道哪些数据应该待在哪个芯粒的本地存储里。软件侧的抽象层次会从“内存”上升到“存储域”,这对算子库和运行时都是不小的改动。
存内计算和近存计算也是常被提起的方向。但说实话,我在评估这类架构时,第一反应不是看它省了多少搬运功耗,而是问:软件接口怎么定义?如果算力埋在存储阵列里,编译器要如何描述矩阵乘法的“原位计算”?当前工具链对这类架构的抽象程度还不够成熟,什么时候编译器和算子库能像今天控制 SRAM 这样精细控制存算阵列,这类芯片才真正到了大爆发的时候。否则就会变成“硬件很美好,软件不感冒”的尴尬状态。
6.2 稀疏性与结构化:让软件不再为一个零值付全价
稀疏性是 AI 芯片绕不开的话题。现实模型中大量权重和激活都是零,如果硬件对零值也一视同仁地搬运和计算,能耗和带宽都白花了。但“稀疏”这件事,硬件和软件必须商量好颗粒度。细粒度标量稀疏利用率最高,但索引开销巨大,压缩格式五花八门;结构化稀疏(比如 2:4 稀疏——每四个连续值里最多两个非零)则让硬件可以用固定模式跳过零值,控制逻辑简单很多。
我在软硬件协同上倾向于先支持结构化稀疏。原因是编译器好映射:权重做一次离线重排,把非零值紧凑排列,运行时无需动态判断每个元素。激活稀疏更难,因为它依赖输入数据,需要在计算过程中跳过无效块,对硬件调度器的要求高出一截。但无论哪种稀疏,最重要的原则是一致的:别让软件为一个零值付出完整代价。如果硬件做不到这一点,那软件还不如花精力把数据压缩在片上多放几层。
6.3 可编程性的再平衡:硬件是“工具”,不是“立法者”
做软硬件协同设计时间长了,我有一个越来越强烈的观点:硬件团队不应该把自己当成“立法者”,规定软件必须怎么做事,而应该把自己当成“工具制造者”,把高效的原语和安全的默认值提供给软件,并保留足够的控制权由软件按需使用。
一套好的加速器指令集,应该像乐高积木一样由少量可组合的原子操作构成,而不是封装一堆“大而全”的专用算子。芯片的专用指令永远只能覆盖当时的模型热点,但模型在快速演进——注意力机制、混合专家、多模态大模型,每一轮都会冒出新的算子需求。专用硬件如果做得很深但很窄,很快就过时;做得宽而薄,把通用计算、高效访存和灵活数据流组合好,反而能在好几年里保持竞争力。
我个人的体会是,软硬件协同设计的“协同”二字,最终落在两个团队的沟通质量和抽象层次的选择上。硬件要理解软件的痛点是数据流,软件要理解硬件的边界是物理资源。一个团队如果能坐在一起,把“算子→数据流→存储→同步”这条映射链路捋通,比各自闷头定义一套新架构要有效得多。
最后再分享一个实操经验:拿到一块新芯片,别急着点灯、跑 Hello World、跑模型,先写一个微基准,把片上 SRAM 的各类切块边界和 DMA 带宽响应测清楚。边界数据就像地图上的等高线,有了它,后面无论做算子库还是编译器,都不至于在设计空间里瞎撞。踩过几次坑之后,我才真正理解那句话——AI 芯片的性能不是设计出来的,是软硬件一起“榨”出来的。