引言
用 FPGA 做 AI 加速曾是热门话题,如今虽然 GPU 主导了训练市场,但 FPGA 在低延迟推理、功耗敏感、算法多变的场景仍然有独特价值:网络交换里的包分类、金融交易的风控计算、雷达信号处理、医疗影像的实时推理。这些场景的共同点是:批量小(batch=1)、延迟要求苛刻、功耗预算紧张——恰好是 GPU 不擅长的领域。
FPGA 加速器设计的核心矛盾是计算与访存的匹配。FPGA 上可以堆几百个 DSP 做并行乘法,但如果数据喂不上来,算力就是闲置的。理解这一点需要先算清楚算术强度(每字节访存能支撑多少次运算),再决定用什么结构做数据复用。这是所有加速器设计的起点,与 GPU 内核性能优化 里的 roofline 分析是同一套方法论。
本文按「方法论 → 结构 → 实现 → 集成」的顺序展开。前三节讲数据复用与脉动阵列(这是矩阵乘加速的核心),中间讲卷积的特殊结构(行缓冲、im2col)与量化,然后讲数据流架构与 HLS 实现,最后讲性能建模、与 GPU 的取舍以及部署集成。
目录
- 加速器设计方法论与 roofline
- 矩阵乘的数据复用与分块
- 脉动阵列结构
- 卷积的实现:im2col 与直接卷积
- 行缓冲与滑动窗口
- 定点量化与精度控制
- 三种数据流架构
- 用 HLS 实现 GEMM 内核
- 片上存储层次与带宽匹配
- 性能建模与瓶颈分析
- FPGA 与 GPU 的取舍
- 部署与系统集成
1. 加速器设计方法论与 roofline
任何加速器设计的第一步都是回答:瓶颈在计算还是在访存?
算术强度 AI = 总浮点运算次数 / 总访存字节数 (FLOP/Byte)
峰值算力 P = f × N_mac × 2 (GFLOPS)
带宽需求 B = P / AI (GB/s)
判断:
若 B > 可用带宽 → 访存受限(memory bound)
若 B < 可用带宽 → 计算受限(compute bound)
例:8×8 脉动阵列,f = 200MHz
P = 200e6 × 64 × 2 = 25.6 GFLOPS
若 AI = 4 FLOP/Byte → B = 6.4 GB/s(DDR 可满足)
若 AI = 1 FLOP/Byte → B = 25.6 GB/s(超过 DDR 单通道能力)
矩阵乘的天生优势是 AI 高:一个 N×N 的矩阵乘有 2N³ 次运算、3N² 次数据加载,AI ≈ 2N/3。N=64 时 AI ≈ 43,远超带宽需求。但前提是数据能被复用——如果每次乘加都从 DDR 读数据,AI 就退化成 1。
所以加速器设计的核心工作就是通过片上存储和结构设计提高数据复用率。
2. 矩阵乘的数据复用与分块
矩阵乘 C = A × B 有三种数据复用机会:
| 复用类型 | 说明 | 复用次数 |
|---|---|---|
| A 的行复用 | A[i][k] 被所有 j 使用 | N 次 |
| B 的列复用 | B[k][j] 被所有 i 使用 | N 次 |
| C 的累加复用 | C[i][j] 累加 K 次 | K 次 |
分块(tiling) 是把大矩阵切成能放进片上存储的小块,逐块计算:
朴素三重循环(无复用):for i,j,k: C[i][j] += A[i][k]*B[k][j]
→ 每次乘加都访问 A 和 B,AI ≈ 0.5
分块后(T×T 块):
for ii,jj,kk: load A_block[ii][kk], B_block[kk][jj] 到片上
for i,j,k: C[i][j] += A[i][k]*B[k][j] # 数据在片上
→ 块内数据全在片上,AI 提升 T 倍
片上存储的需求:三个 T×T 块需要 3 × T² × 4 字节(float32)。T=32 时约 12KB,T=64 时约 48KB。FPGA 的 BRAM 总量通常是几百 KB 到几 MB,所以 T 的选择受片上存储限制。
3. 脉动阵列结构
脉动阵列(systolic array) 是矩阵乘加速的经典结构,源自 Google TPU。它的核心思想是让数据在计算单元之间流动,而不是让计算单元去访问存储。
4×4 脉动阵列(每个 PE 做一个乘加):
B 从上方流入(每次错开一拍)
b0 b1 b2 b3
↓ ↓ ↓ ↓
a0→[PE][PE][PE][PE]→
a1→[PE][PE][PE][PE]→
a2→[PE][PE][PE][PE]→
a3→[PE][PE][PE][PE]→
↓ ↓ ↓ ↓
A 从左流入 C 从下方流出(对角波前)
每个 PE:C += A_in × B_in; A_out = A_in; B_out = B_in
数据沿行列方向流动,每个数据被复用 N 次
关键特性:
- 数据复用极高:每个 A 元素被 N 个 PE 使用,每个 B 元素也被 N 个 PE 使用。AI 提升 N 倍。
- 控制极简:没有复杂的地址生成,数据靠移位流动。
- 权重固定(weight-stationary):TPU 的做法是把 B 矩阵预加载到 PE 里,A 流入计算。
- 规模与资源成正比:N×N 阵列需要 N² 个乘法器,N=32 时 1024 个乘法器(FPGA 上要用 DSP,大 FPGA 有几千个)。
// 单个 PE:乘加 + 数据直通
module pe (
input wire clk,
input wire [15:0] a_in, b_in,
output reg [15:0] a_out, b_out,
output reg [31:0] acc
);
always @(posedge clk) begin
a_out <= a_in; // 数据直通到右侧 PE
b_out <= b_in; // 数据直通到下方 PE
acc <= acc + a_in * b_in; // 乘加(映射到 DSP48)
end
endmodule
注意 acc 的位宽要足够:16 位 × 16 位 = 32 位,累加 K 次需要额外 log2(K) 位余量。
4. 卷积的实现:im2col 与直接卷积
卷积的计算模式与矩阵乘高度相似,两种主流实现方式:
(1)im2col + GEMM:把卷积的每个滑动窗口展开成一列,把卷积变成矩阵乘。
输入 5×5,卷积核 3×3,stride=1 → 输出 3×3
im2col:每个 3×3 窗口展开成一列(共 9 列),变成 [9×9] × [9×1] 的矩阵乘
[a00 a01 a02 ...] [w00]
[a01 a02 a03 ...] × [w01] = 输出
优点:直接复用成熟的 GEMM 加速器
缺点:数据膨胀(每个元素被复制 9 次),存储与带宽开销大
(2)直接卷积:用滑动窗口直接在特征图上滑动计算,配合行缓冲避免重复读。
直接卷积的数据流(3×3 核,输出一个点需要 9 个输入):
行缓冲 1: ────────→ [w00 w01 w02]
行缓冲 2: ────────→ [w10 w11 w12] → 累加 → 输出
行缓冲 3: ────────→ [w20 w21 w22]
每来一个新像素,窗口右移一格,只需读 1 个新像素(其余来自缓冲)
| 维度 | im2col + GEMM | 直接卷积 |
|---|---|---|
| 数据膨胀 | 9×(3×3 核) | 无 |
| 片上存储 | 需要存展开后的矩阵 | 只需行缓冲 |
| 复用成熟度 | 高(复用 GEMM 加速器) | 需专门设计 |
| 适合 | 大 batch、大通道数 | 小 batch、大特征图 |
5. 行缓冲与滑动窗口
行缓冲是直接卷积的核心结构,它把「每个输出点读 9 个输入」降为「每个输入点读一次」。
// 3 行行缓冲:每行存特征图的一行,新像素流入时整行移位
module line_buffer #(parameter WIDTH = 640) (
input wire clk, valid_in,
input wire [7:0] pixel_in,
output wire [7:0] p0, p1, p2 // 当前窗口的三行像素
);
reg [7:0] lb0 [0:WIDTH-1], lb1 [0:WIDTH-1], lb2 [0:WIDTH-1];
reg [$clog2(WIDTH)-1:0] wr_ptr;
always @(posedge clk)
if (valid_in) begin
lb0[wr_ptr] <= pixel_in; // 最新行写入缓冲 0
lb1[wr_ptr] <= lb0[wr_ptr]; // 旧行依次下移
lb2[wr_ptr] <= lb1[wr_ptr];
wr_ptr <= wr_ptr + 1'b1;
end
assign p0 = lb0[wr_ptr-2]; // 窗口对齐
assign p1 = lb1[wr_ptr-2];
assign p2 = lb2[wr_ptr-2];
endmodule
行缓冲的存储需求:3 行 × 图像宽度 × 像素位宽。对 640×480 的灰度图,3 × 640 × 8 bit = 1.9KB,用 BRAM 即可。
流式处理的好处:整个 CNN 可以做成流水线——卷积层 1 的输出直接喂给卷积层 2,中间结果不落 DDR。这对降低延迟和功耗至关重要。代价是各层的数据率必须匹配(慢的层会拖慢整个流水线)。
6. 定点量化与精度控制
FPGA 上做浮点运算代价高昂(一个 FP32 乘法占用大量 LUT/DSP),定点量化是必选项。
量化方案对比:
FP32 : 32 位,无需量化,资源占用 100%
FP16 : 16 位,精度足够推理,资源约 50%
INT16/INT8 : 8~16 位定点,资源约 10~25%,需要量化校准
INT4/二值 : 极致压缩,精度损失明显,需专门训练
经验值(CNN 推理):
INT8 量化后精度损失通常 < 1%(top-1 accuracy)
INT4 需要量化感知训练(QAT)才能保持精度
量化流程:
- 统计动态范围:用校准数据集跑一遍 FP32 模型,统计每层激活值和权重的范围。
- 确定缩放因子:
scale = max_abs / (2^(bits-1) - 1),量化值 =round(x / scale)。 - 逐层量化:权重用对称量化,激活值可用非对称量化(含零点偏移)。
- 精度验证:量化后模型与原模型对比,误差超阈值则调整某些层为更高精度。
# 对称量化的核心公式
def quantize_symmetric(x, bits=8):
scale = np.max(np.abs(x)) / (2**(bits-1) - 1)
q = np.clip(np.round(x / scale), -(2**(bits-1)), 2**(bits-1)-1)
return q.astype(np.int8), scale
def dequantize(q, scale):
return q.astype(np.float32) * scale
累加器的位宽要留足余量:INT8 × INT8 = INT16,累加 K 次需要 16 + log2(K) 位。K=512 时约 25 位,用 32 位累加器比较安全。
7. 三种数据流架构
数据流架构决定「什么数据留在片上、什么数据流动」:
| 架构 | 驻留数据 | 流动数据 | 优点 | 适合 |
|---|---|---|---|---|
| 权重固定(WS) | 权重 | 激活值 | 权重复用高、功耗低 | 权重大的层 |
| 输出固定(OS) | 部分和 | 权重与激活 | 累加器复用高 | 输出通道多的层 |
| 行固定(RS) | 激活行 | 权重 | 无部分和搬移、延迟低 | 卷积层 |
权重固定(Weight Stationary) 是 TPU 的选择:把卷积核加载到 PE 阵列,激活值流过。适合权重占主导的网络(如全连接层)。
行固定(Row Stationary) 是 Eyeriss 提出的方案:让一行激活值驻留在 PE 阵列中,权重和部分和按需流动。它的优势是最小化数据搬移能耗——而数据搬移(尤其是 DDR 访问)是加速器功耗的主要来源。
能耗对比(45nm 工艺,相对值):整数加法 1× / 整数乘法 3×
/ SRAM 读 5× / DRAM 读 200×
→ 结论:优化访存比优化计算重要得多
这张表解释了为什么数据流架构如此关键:一次 DDR 访问的能耗相当于 200 次加法。把数据留在片上、减少搬移,是低功耗加速器的第一原则。
8. 用 HLS 实现 GEMM 内核
把前面的分块思想用 HLS 实现:
#define T 16 // 分块大小
void gemm_tiled(ap_int<8> A[N][N], ap_int<8> B[N][N], ap_int<32> C[N][N]) {
#pragma HLS ARRAY_PARTITION variable=A cyclic factor=T dim=2
#pragma HLS ARRAY_PARTITION variable=B cyclic factor=T dim=1
ap_int<32> C_local[T][T]; // 片上累加缓冲
#pragma HLS ARRAY_PARTITION variable=C_local complete dim=0
for (int ii = 0; ii < N; ii += T)
for (int jj = 0; jj < N; jj += T) {
for (int i = 0; i < T; i++)
for (int j = 0; j < T; j++) {
#pragma HLS PIPELINE II=1
ap_int<32> acc = 0;
for (int k = 0; k < T; k++) // 沿 K 维累加,完全展开
#pragma HLS UNROLL
acc += A[ii+i][jj+k] * B[jj+k][jj+j];
C_local[i][j] = acc;
}
for (int i = 0; i < T; i++) // 写回
for (int j = 0; j < T; j++)
#pragma HLS PIPELINE II=1
C[ii+i][jj+j] = C_local[i][j];
}
}
关键设计点:C_local 用 complete 分区(T² 个累加器全部并行),A/B 用 cyclic 分区(每周期可并行读 T 个元素),内层 K 循环完全展开(T 个乘法器并行)。T=16 时需要 256 个乘法器,在中等规模 FPGA 上可行。
9. 片上存储层次与带宽匹配
FPGA 的存储层次与 CPU 类似,但需要手工管理:
| 层次 | 容量 | 带宽 | 延迟 | 实现 |
|---|---|---|---|---|
| 寄存器 | 极 KB | 极高 | 0 | FF |
| 分布式 RAM | 几十 KB | 高 | 低 | LUT |
| BRAM/URAM | 几 MB | 中高 | 中 | 硬核 |
| DDR | GB | 低 | 高(~100ns) | 外部 |
带宽匹配的原则是每一层都要能喂饱下一层:
片上侧:256 个乘法器每周期需要 256 个 A 元素 + 256 个 B 元素
一块 36Kb BRAM 每周期可读 72 位 → 256×8/72 ≈ 29 个 BRAM 端口
→ 结论:需要多块 BRAM 并行 + 数组分区
DDR 侧:块级数据用 AXI 突发传输(一次传 256 拍)
→ 双缓冲(ping-pong):计算块 i 时预取块 i+1
双缓冲(ping-pong buffer) 是隐藏 DDR 延迟的标准手法:两块片上缓冲,一块供计算使用,另一块同时在从 DDR 加载。计算与加载重叠,DDR 延迟被完全隐藏。
10. 性能建模与瓶颈分析
加速器的性能可以用三个数字刻画:
计算时间 T_compute = 总 MAC 数 / (并行度 × 频率)
访存时间 T_memory = 总访存字节 / 有效带宽
总时间 T_total = max(T_compute, T_memory) + 启动开销
例:N=512 的矩阵乘,16×16 阵列,200MHz,DDR 有效带宽 6 GB/s
MAC 总数 = 512³ = 1.34e8
T_compute = 1.34e8 / (256 × 200e6) = 2.6 ms
访存字节 = 3 × 512² × 1(INT8)= 786 KB
T_memory = 786e3 / 6e9 = 0.13 ms
→ 计算受限,T_total ≈ 2.6 ms(理论峰值 51.5 GOPS)
如果实测远低于理论值,按这个顺序排查:
- 阵列利用率:边界处理(N 不是 T 的整数倍)导致的部分空闲。
- 流水线气泡:块之间的填充/排空开销,块越小占比越高。
- DDR 带宽不足:突发长度不够、访问不连续导致有效带宽远低于峰值。
- 时序降频:实际频率低于目标,按比例损失性能;地址生成与状态机切换也占用周期。
11. FPGA 与 GPU 的取舍
| 维度 | FPGA | GPU |
|---|---|---|
| 峰值算力 | 低(几十 GFLOPS ~ 几 TFLOPS) | 高(几十 ~ 上千 TFLOPS) |
| 延迟 | 极低(无 kernel 启动开销,可做到微秒级) | 较高(kernel 启动 + 批量) |
| 功耗 | 低(10~75W 典型) | 高(150~700W) |
| 批量适应 | batch=1 也高效 | 需要大 batch 才能打满 |
| 灵活性 | 可重构,支持自定义数据类型 | 固定架构 |
| 开发难度 | 高(RTL/HLS) | 低(CUDA/PyTorch) |
| 生态 | 弱 | 极强 |
选择原则:
- 批量大、追求峰值算力、算法成熟 → GPU。
- 批量小(batch=1)、延迟敏感(微秒级)、功耗受限 → FPGA。
- 算法频繁变化 → GPU(改代码即可);算法固定且要极致能效 → FPGA/ASIC。
一个具体的对比:网络包分类在 FPGA 上能做到线速(100Gbps)且延迟 < 1μs,而 GPU 的 kernel 启动开销就超过 5μs。这是 FPGA 不可替代的场景。
12. 部署与系统集成
加速器要真正用起来,需要完整的软硬件栈:
应用层 :Python/C++ 调用
↓
驱动层 :/dev/accel 字符设备,mmap 寄存器
↓
运行时 :DMA 描述符管理、中断处理、任务队列
↓
硬件层 :AXI 接口 + 加速器内核 + DMA
↓
内存 :DDR(输入/输出张量)
集成的关键接口有三类:AXI4-Lite 用于寄存器配置(启动、参数、状态),AXI4 或 AXI4-Stream 作为数据通路(DMA 搬运张量),中断线用于任务完成通知。
// 驱动侧:典型的加速器调用流程
int accel_run(int fd, void *in, void *out, size_t bytes) {
dma_map(fd, in, bytes, DMA_TO_DEVICE); // 映射并 flush 缓存
dma_map(fd, out, bytes, DMA_FROM_DEVICE);
write_reg(fd, REG_SRC, virt_to_phys(in)); // 配置地址与长度
write_reg(fd, REG_DST, virt_to_phys(out));
write_reg(fd, REG_LEN, bytes);
write_reg(fd, REG_CTRL, CTRL_START); // 启动
wait_for_irq(fd); // 等中断
write_reg(fd, REG_CTRL, CTRL_CLEAR); // 清状态
return 0;
}
缓存一致性是软件侧最容易出错的地方:DMA 直接访问物理内存,绕过 CPU 缓存。如果 CPU 刚写过输入数据,必须先 flush 缓存;DMA 写完输出后,CPU 必须先 invalidate 缓存才能读到新数据。
权衡取舍
| 决策点 | 选项 A | 选项 B |
|---|---|---|
| 卷积实现 | im2col + GEMM:复用成熟 | 直接卷积:省存储、省带宽 |
| 数据流 | 权重固定:权重复用高 | 行固定:搬移能耗最低 |
| 数据精度 | FP16:无需量化、资源贵 | INT8:省资源、需校准 |
| 分块大小 | 大块:复用高、片上存储紧张 | 小块:存储省、DDR 访问多 |
| 缓冲策略 | 双缓冲:隐藏延迟、占双倍存储 | 单缓冲:省存储、有气泡 |
| 实现方式 | HLS:快、面积大 | 手写 RTL:面积优、周期长 |
常见坑清单
- 忽略算术强度:堆了几百个乘法器却喂不上数据,实际算力只有峰值的一小部分。
- 数组未分区导致 II 上升:多个并行访问同一 BRAM 端口,II 从 1 退化,性能损失过半。
- 累加器位宽不足:INT8 累加 512 次溢出,结果静默错误,需 16 + log2(K) 位。
- DDR 访问不连续:每次访问跨行导致有效带宽降到峰值的 20%,必须用连续突发。
- 未做双缓冲:DDR 延迟(~100ns)暴露在关键路径上,计算单元空等。
- 量化校准集不具代表性:校准数据分布与真实数据不符,量化后精度骤降。
- 行缓冲边界处理缺失:图像边缘的滑动窗口越界,需 padding 或边界特判。
- 忘记 flush/invalidate 缓存:DMA 与 CPU 看到不一致的数据,产生随机错误。
- 时序违例导致降频:目标 200MHz 实际只能跑 150MHz,性能直接损失 25%。
- 块边界处理开销:N 不是分块大小的整数倍时,补齐逻辑可能吃掉大部分收益;功耗估算也常忽略 DDR 访问(占比可能超过 50%)。
小结
FPGA 加速器设计的主线是用数据复用换取算力。矩阵乘和卷积天生有高复用潜力,但要把它变成实际性能,需要分块(让数据进片上)、脉动阵列(让数据在 PE 间流动)、行缓冲(让滑动窗口不重复读)、双缓冲(隐藏 DDR 延迟)这一整套结构设计。
量化与数据流架构决定了能效上限。记住那张能耗表——一次 DDR 访问相当于 200 次加法——就能理解为什么所有低功耗加速器都在拼命减少数据搬移。选择 FPGA 还是 GPU,本质上是选择「极致延迟与能效」还是「峰值算力与生态」。
下一步建议阅读 芯片设计流程与 EDA 工具链 ,了解这些加速器如何从 RTL 走向实际的芯片;或者回到 高层次综合 HLS 与流水线 ,深入 HLS 的优化手段细节。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。