
做深度学习加速器的人几乎绕不开矩阵乘法。无论是多头注意力里的QK^T还是卷积层里被重排成GEMM的卷积运算最终的计算主体都是矩阵乘法。这几年来我一直在做加速器的架构设计和FPGA原型验证脉动阵列、混合精度这两个概念从论文里的图变成能跑的硬件中间踩过不少坑。这篇文章想把这几年踩过的坑、验证过的策略以及硬件设计里的真实取舍一次性讲清楚适合正在做加速器架构选型、做FPGA原型验证或者刚接触芯片设计但想快速建立全局认知的工程师参考。1. 矩阵乘法加速器要解决的三个现实问题1.1 算力天花板理论FLOPs离实际效率有多远矩阵乘法也就是GEMM写成公式就是 C[M][N] A[M][K] * B[K][N]。对硬件来说GEMM最诱人的地方在于它高度规整、可并行化MNK个乘法彼此之间没有数据依赖理论上可以无限扩展并行度。但理论上可以无限并行这句话恰恰是硬件工程师最警惕的。我见过不少刚接触加速器设计的同学第一版架构就是堆一大片MAC阵列觉得只要乘法器够多算力就能上去。结果往往是面积、功耗双双爆炸实际吞吐率可能只有峰值的二分之一甚至三分之一。问题通常出在两个地方数据没喂进去或者部分和累加回写的路径设计不合理。真正决定GEMM能跑多快的不是乘法器数量而是数据带宽和累加器的组织方式。举个例子一个16x16的PE阵列每周期能完成256次乘累加理论吞吐并不夸张但如果片外带宽只有32字节/周期那么每周期只能喂进去几个FP32数据阵列的利用率会低到让人怀疑人生。所以设计矩阵乘法加速器首先要回答的不是算力要多高而是数据从哪里来、如何复用、部分和如何管理。1.2 访存墙为什么矩阵乘法最终被带宽卡脖子要理解访存墙先得理解一个指标计算强度arithmetic intensity单位是FLOPs/Byte。它表示每个字节的数据能支撑多少次运算。GEMM里如果矩阵完全从片外读取那么A、B矩阵各读一次C矩阵写回一次总的数据搬运量是(MK KN MN)乘以数据位宽再除以GEMM的总FLOPs2MNK。当M、N、K都很大时计算强度其实低得可怜往往只有个位数。芯片的Roofline模型告诉我们当计算强度低于某个拐点时性能会被内存带宽锁死这时再加MAC数量毫无意义。所以所有高效的GEMM实现都离不开分块tiling策略把大矩阵切成适合放入片上SRAM的块让一个数据块被反复复用后再换下一块。比如对M4096、K4096的矩阵乘法如果直接把A矩阵全部读进来大约有64MB按FP32算片上根本放不下但切片成256x256的小块一个块只有256KBSRAM完全能容纳而且这个块会在计算对应输出块时被反复读取。这里我有一个很强烈的建议阵列尺寸和SRAM容量一定要放在一起设计而不是先定阵列再想着配多大缓存。两者是联动的——PE越多每个周期吃进去的数据越多SRAM就需要越大否则再大的阵列也是空转。1.3 精度冗余FP32不是所有场景都需要第三个现实问题是精度。深度学习里很多算子对数值精度其实相当钝感这是近十年大家逐步验证出来的结论权重和激活的表示需求往往不需要FP32那么富裕的尾数。与此同时FP16乘法在通用芯片上通常能给出两倍于FP32的吞吐INT8甚至可以到四倍以上。对硬件加速器来说数据和计算都降到低精度存储和带宽需求也会等比下降计算强度问题会得到明显缓解。但降精度不是简单截断。FP16存在指数位不够的问题BF16尾数又太短FP8虽然香但对scale策略要求极高。这就需要在设计加速器时把混合精度当成一个一等公民来对待精度怎么分配、乘法器用什么格式、累加器保留多宽、scale怎么做都要在架构阶段明确下来。等到芯片回来再发现精度不行那就不是改个软件参数能救回来的事了。2. 脉动阵列的设计核心数据流与数据复用2.1 三种经典数据流OS/WS/IS怎么选脉动阵列的核心思想是数据在规则排列的PE之间流动每个PE只做本地的乘累加避免每次都从远处取数据。我的理解是它本质上是用数据搬移的流水线化换取了访存的局部性。具体实现时数据流有三种主流方案输出固定Output StationaryOS部分和保存在PE内不动A和B矩阵的数据从相邻PE流过。部分和不需要频繁读写适合输出矩阵较大、且中间结果需要反复累加的场景。权重固定Weight StationaryWS权重被预加载进PE输入数据从阵列中流过部分和从底部收集。权重是复用率最高的数据把它钉在PE里最划算特别适合权重要重复使用很多轮的Transformer推理场景。输入固定Input StationaryIS输入激活固定在PE里权重在阵列里流动。如果输入数据复用率更高这个方案更有利但在常见的线性层里用得相对少。实际选型时一定要看算子特点。推理场景权重几乎不变WS就很有优势训练场景需要频繁更新权重、同时有前向和反向两个方向的计算OS往往更适合因为它不用在阵列里反复装载和清空权重。我自己设计过一个4x4的小阵列原型三种数据流都跑过差别很明显WS在推理单batch场景下最容易把PE利用率跑满而OS在需要频繁切换矩阵tile时会省掉很多指令开销。2.2 PE阵列规模、片上存储与tiling闭环PE阵列规模的选定不能只盯着峰值吞吐。面积、功耗、时钟频率、片上SRAM容量、tiling策略是一个闭环。假设每个PE是一个FP16乘法器加FP32累加器再加上若干寄存器面积大概在0.002到0.005平方毫米之间不同工艺差异大。一个16x16阵列就是256个PE如果主频跑到1GHzFP16峰值约512 GFLOPs功耗可能几瓦到十几瓦。想堆到128x128面积和功耗会触及红线同时片上SRAM得做到好几MB才喂得饱。另一方面tiling的粒度必须和阵列尺寸匹配如果PE阵列是32x32而某个GEMM的M维度是48那么一次tile只能覆盖32行剩下的16行要重新调度一遍。这种边界碎片会让利用率下降。我在设计时通常会做一组模拟给定一批真实模型里的GEMM维度分布遍历不同的阵列尺寸、SRAM容量和tiling策略画出利用率热力图再决定最终参数。这一步特别值得花时间因为流片后没法改FPGA原型上验证起来也费时费力。2.3 与GPU Tensor Core思路的异同对比提到脉动阵列免不了和NVIDIA的Tensor Core对比。我的看法是两者在用大乘法阵列换吞吐这个方向上是一致的但组织方式完全不同。Tensor Core本质上是指令驱动的SIMD风格矩阵单元一个warp的线程通过一条指令触发16x16x16的矩阵乘累加数据从寄存器文件或共享内存加载非常依赖软件编译器、CUTLASS去排布数据。脉动阵列则是数据流驱动的数据以固定节奏从一个PE流到下一个PE控制逻辑简单得多但对数据流的形状有严格限制灵活性相对低。如果做个通俗类比Tensor Core像一个可按指令调度的强力计算组脉动阵列像一条数据流管道数据沿着管道流过去计算自然而然完成。这个差异直接决定了后续混合精度优化策略Tensor Core的精度模式由指令决定脉动阵列则需要更底层的数据通路设计来支持多种精度。3. 混合精度计算收益从哪来风险藏在哪3.1 精度格式选择FP16/BF16/FP8的边界先看一张我自己整理的对比表格式指数位尾数位最大有限值最小正常值主要应用FP32823~3.4e38~1.2e-38主精度/累加FP1651065504~6.1e-5混合精度训练/推理BF1687~3.4e38~1.2e-38大模型训练FP8 E4M343448~6.1e-5推理/部分训练FP8 E5M25257344~6.1e-5训练梯度/低尾数需求场景FP16最麻烦的地方是动态范围小。最大只能表示65504在Transformer里attention的logits经常上几千甚至几万一旦上溢结果直接变inf后续softmax全成NaN。BF16的指数位和FP32一样范围够大但尾数只有7位很多小数值会被直接抹掉只相当于约三位有效十进制数字。FP8的出现把低精度又推了一步E4M3尾数多一点、适合激活和权重E5M2指数多一点、适合梯度因为梯度的动态范围通常更大。我的理解是精度格式的选择本质上是动态范围和有效数字之间做权衡动态范围不够会溢出有效数字不够会损失精度。这也是为什么很多硬件设计里乘法器用低精度累加器却坚持用FP32或更高位宽——乘法结果可以稍微牺牲精度累加必须保住足够大的动态范围和累积精度。3.2 真正决定精度的其实是累加器接着说一个很容易被忽视的点累加器。GEMM的一次输出是K个数乘累加的结果K在Transformer里通常是64、128、512甚至更大。如果累加器也用FP16那几百次连续累加必然产生严重的舍入误差积累最后几位有效数字全是错的。正确做法是低精度乘法、高精度累加乘法器输入用FP16、BF16或INT8部分和在PE内部用FP32甚至INT32累加最后需要输出时才转回目标精度。这里还要注意累加顺序。FP32虽然比FP16好得多但它也不是无限精度的。当K比较大时按从小到大顺序累加误差最低但脉动阵列天然有确定的累加顺序——它按数据流方向逐步累加。所以设计时不要简单复用普通MAC的RTL逻辑最好使用加法树配合理想的归约顺序或者至少要评估阵列自然顺序带来的误差是否在可接受范围内。我在实践中发现Kahan求和这类补偿算法在软件仿真里能把误差再压一个数量级但在硬件里实现Kahan的成本偏高更多时候靠加宽累加位宽和设计合理的归约顺序来解决。3.3 量化误差、Overflow与Loss Scaling混合精度计算最隐蔽的风险是溢出和量化误差。FP16训练必须配合loss scaling这已经几乎是共识了。Loss scaling的原理很直观梯度在反向传播中经常非常小可能远小于FP16的最小正常值6.1e-5一旦落到这个范围以下就直接被刷新成0。解决方法是把loss整体放大比如放大8倍、256倍、1024倍反向传播时梯度也等比放大进入FP16可表示的范围等更新权重之前再把梯度缩小回去。但scale参数不是越大越好太大又可能让激活或梯度上溢。实践中常见的是动态loss scaling每次迭代检查有没有inf或nan出现有就减小scale过一段时间没异常就适度放大。这个机制在BF16下其实不太需要因为BF16的范围和FP32相同这也是大模型训练普遍转向BF16的原因。而FP8训练时不仅要loss scaling还常常需要对梯度做per-tensor甚至per-row的scale复杂度进一步上升。量化误差还有一个来源是数据分布不均。比如LayerNorm或BatchNorm之后的数据基本集中在0附近但偶尔有少数大值。用per-tensor统计出的scale去量化大值会迫使scale很大导致小值量化后全部变成0。解决办法是per-channel或per-row量化让每个通道有自己独立的scale。做硬件时这意味着需要支持多个scale值的同步转换可能还要在数据通路里加一点统计单元这些都是实打实的硬件成本。4. 脉动阵列混合精度的完整落地思路4.1 算子级精度分配策略把两个主题结合起来之后真实模型部署里不能对着整个网络无脑设一个精度而是需要做算子级精度分配矩阵乘法/卷积用低精度乘法FP16/BF16/FP8/INT8高精度累加FP32。Softmax、LayerNorm、Attention Score这类动态范围大、涉及指数或除法的算子保留FP32或用查找表加定点近似不建议强行量化到FP8。Embedding和最后的分类头Embedding通常是查表不占算力分类头如果只有几层也没必要量化。残差连接融合scale、add这些操作尽量在FP32域完成避免多次截断引入误差叠加。我在做精度敏感度分析时会逐个把网络里某些层替换成低精度然后跑验证集看指标掉多少。优先把计算量最大且精度不敏感的层先降下来这样收益最大。比如Transformer里QK^T和attention softmax常常是关键瓶颈但真正计算密集的是之后的投影矩阵乘法后者却对精度更宽容这个现象有点反直觉但也正好说明静态地给所有层分配同一精度的做法确实会损失不少潜在收益。4.2 硬件数据路径如何做可变位宽要在脉动阵列上真正支持混合精度硬件必须从一开始就留好可变位宽的数据通路数据转换单元从片上SRAM读出的数据要能支持FP32、FP16、BF16、INT8、FP8多种格式的转换。转换不是简单截断涉及舍入模式选择、溢出处理通常选择round-to-nearest-even溢出时做饱和截断而不是回绕。打包与解包当数据位宽只有16位时可以在同一个SRAM里打包两个数据8位则可以打包四个这能显著提升突发读取效率。但要注意对齐打包后跨行读取会有额外开销最好在tiling阶段就保证对齐。乘法器可配置性一个FP16乘法器由两个FP8乘法器分时复用这种位宽拆解设计可以节省面积但控制逻辑会更复杂。我试过用FP16乘法器切成两个FP8乘法路径面积节省约三成但时序收敛和验证工作量明显增加。如果只考虑推理场景可以做得更激进把整个数据通路都按INT8设计只在最后一层累加用INT32精度不敏感任务完全够用。但训练场景必须保留FP32累加和动态loss scaling的路径这两者在架构上会增加不少寄存器资源和控制复杂度。4.3 部署流程先仿真后综合再上板把一套软硬件协同方案跑通我习惯分四步走Python数值仿真用PyTorch或NumPy模拟混合精度行为确认精度策略本身是否可行。这一步不涉及硬件细节但能最快暴露算法层面的风险。C或SystemC周期级仿真建立脉动阵列的周期级模型评估吞吐和利用率。这里能提前发现算法很美好但对硬件不可实现的问题。RTL实现与仿真把核心模块写成Verilog或SystemVerilog用约束随机验证或UVM跑测试。FPGA原型验证上板实测。FPGA频率远低于ASIC但能验证数据通路和控制逻辑的正确性。我最深的体会是第一步千万不要省很多人在RTL阶段才发现混合精度误差不可接受回头去改训练策略整个项目等于推倒重来成本极高。5. 实测踩坑记录与调优心得5.1 FP16训练Transformer时梯度消失第一个坑是在一个GPT类小模型训练里踩到的。开启FP16后loss早期下降很快但到一定阶段突然不降了甚至直接变成NaN。查了几天最后锁定在反向传播的梯度上attention的logits在FP16下超过65504变成infsoftmax输出变成NaN梯度自然全乱。后来加了动态loss scaling才稳定下来。这件事给我的教训是开启混合精度之前一定要先在关键节点打印数据分布特别是attention的logits、softmax前后的方差。不要一上来就完全信任框架的自动混合精度功能它不保证你的特殊网络不出问题。5.2 累加器位宽不足引发精度随机抖动第二个坑发生在自研脉动阵列验证时。为了省资源我把累加器从FP32砍成了FP16加部分位宽想着误差大点也没事。结果模型精度在多次重复运行之间来回抖动有时候同一份数据两次推理结果差出1%以上训练还特别容易发散。后来把累加器恢复成FP32问题立刻消失。硬件设计里累加位宽是最不值得省的地方。省下来的一点面积后期调试成本远高于收益尤其是当你要面对客户或者要发论文时精度不稳定带来的解释成本更高。5.3 为了利用率过度分块反而降低效率第三个坑是tiling策略。刚开始我追求PE利用率把tile切得很细想让每个PE都尽量忙碌。结果发现细粒度tiling带来了大量循环开销和地址计算片外访问次数虽然少了但控制复杂度上升实际加速比反而下降。后来我改为粗粒度分块加边缘批处理策略主体计算用大tile跑满利用率边缘的小块用一个统一的边界处理引擎来处理。这样PE利用率从82%提升到了92%整体访存效率反而更高。高性能计算里有个常识计算量越大固定开销摊薄得越小分块粒度也一样过度优化反而得不偿失。5.4 性能建模与快速验证环境最后说一下工具链。我自己搭建了一套快速验证环境Python脚本生成测试矩阵和期望输出通过一个轻量的周期级模拟器运行脉动阵列配置输出每个PE在每一拍的忙碌或空闲状态再统计利用率、访存流量和总周期数。建议从一开始就建立性能回归的习惯每次改架构参数都要跑一组标准benchmark至少包含GEMM、卷积、attention这几类常见算子。不然架构改着改着性能就悄悄退化光靠直觉根本查不出来。最后分享一个我自己的体会矩阵乘法加速器、脉动阵列、混合精度这三件事在课本里是分开讲的但在真实工程里是一体的。精度策略决定了乘法器和累加器的位宽位宽决定了数据通路的带宽和SRAM容量SRAM和阵列规模又反过来决定了tiling和数据流选择。如果当初我能在一开始就把三件事放在同一张设计空间表里联合优化而不是先定数据流、再单独讨论精度至少能省出一个月的迭代周期。再给刚入坑的工程师一个建议不管是做ASIC还是FPGA第一版设计别追求堆料16x16或32x32的阵列就足够验证绝大多数问题。先把数据流跑对、精度策略验证清楚再谈大规模和商业化。这个领域最大的风险不是算力不够而是架构定型后发现精度或访存出了结构性错误那时候再改硬件代价极高。