卷积的循环展开:挖掘并行化的金矿
各位同学,今天我们来聊聊卷积运算里最核心的一个话题——循环展开。说白了,就是怎么把那些嵌套的for循环拆开,让硬件能并行干活。
我在做AI加速器的时候,遇到过不少这样的情况:算法工程师写出来的卷积代码,在CPU上跑得挺顺,但一放到FPGA上就慢得像蜗牛。为什么?因为软件思维和硬件思维完全是两码事。软件喜欢串行,硬件喜欢并行。你想想看,FPGA里有成千上万个逻辑单元,你让它们排队干活,那不是浪费吗?
卷积的嵌套循环结构
先看一个标准的二维卷积。假设输入是HxW,卷积核是KxK,输出是OxO。伪代码长这样:
for (row = 0; row < O; row++) {
for (col = 0; col < O; col++) {
for (i = 0; i < K; i++) {
for (j = 0; j < K; j++) {
output[row][col] +=
input[row+i][col+j] * kernel[i][j];
}
}
}
}
嗯,四层嵌套循环。最内层是乘累加操作,外面三层是遍历。这种结构在软件里没问题,但在硬件里,每一层循环都意味着一个串行瓶颈。
核心问题:循环层数越多,并行度越低。每多一层循环,硬件利用率就掉一截。
寻找并行化机会
我个人习惯,拿到一个卷积算法,先画个数据流图。看看哪些操作是独立的,哪些是有依赖的。我给大家画个SVG图,直观感受一下:
从图上你能看到,数据流有三个关键节点:输入、卷积核、输出。并行化的机会就藏在这些节点之间的连接里。
四种展开策略
我给大家总结一下,卷积的循环展开主要有四种方式。每种方式适合不同的场景,咱们一个一个说。
| 展开策略 | 展开对象 | 并行度 | 适用场景 |
|---|---|---|---|
| 输出通道展开 | 最外层循环(输出通道) | 高(通道数可到256/512) | 多输出通道卷积 |
| 输入通道展开 | 第二层循环(输入通道) | 中(通道数可到32/64) | 深度可分离卷积 |
| 空间位置展开 | 第三层循环(行/列) | 低(受限于窗口大小) | 小卷积核(3x3/5x5) |
| 卷积核展开 | 最内层循环(KxK) | 极低(K通常为3/5) | 所有卷积的基础 |
我的经验:实际项目中,很少只用一种展开方式。通常是组合使用。比如输出通道展开+卷积核展开,这样既能利用高并行度,又能保证数据复用。
输出通道展开:最直接的并行化
输出通道展开,说白了就是把不同输出通道的卷积核放到不同的PE(处理单元)上同时算。每个PE只负责一个输出通道,互不干扰。
我曾经在一个项目里,需要做256个输出通道的卷积。如果串行算,256个通道一个一个来,延迟高得吓人。后来我把输出通道展开成16组,每组16个通道并行算。结果呢?吞吐量提升了16倍,资源只增加了不到2倍。
// 输出通道展开伪代码
for (row = 0; row < O; row++) {
for (col = 0; col < O; col++) {
// 16个输出通道并行计算
#pragma unroll 16
for (out_ch = 0; out_ch < 16; out_ch++) {
for (i = 0; i < K; i++) {
for (j = 0; j < K; j++) {
output[out_ch][row][col] +=
input[row+i][col+j] *
kernel[out_ch][i][j];
}
}
}
}
}
注意:输出通道展开虽然并行度高,但会消耗大量DSP和BRAM资源。我曾经因为展开太多,导致FPGA布线失败。后来学乖了,先做资源评估,再决定展开因子。
输入通道展开:数据复用的艺术
输入通道展开的思路不一样。它把多个输入通道的数据同时喂给同一个PE,让PE一次处理多个通道的乘累加。这样做的好处是,输入数据可以被多个输出通道共享。
你想想看,如果输入有32个通道,卷积核是3x3。每个输出像素需要计算32x3x3=288次乘累加。如果输入通道展开成8组,每组4个通道并行,那一次就能算4x3x3=36次乘累加。效率提升很明显。
// 输入通道展开伪代码
for (row = 0; row < O; row++) {
for (col = 0; col < O; col++) {
for (out_ch = 0; out_ch < C_out; out_ch++) {
// 4个输入通道并行
#pragma unroll 4
for (in_ch = 0; in_ch < 4; in_ch++) {
for (i = 0; i < K; i++) {
for (j = 0; j < K; j++) {
output[out_ch][row][col] +=
input[in_ch][row+i][col+j] *
kernel[out_ch][in_ch][i][j];
}
}
}
}
}
}
空间位置展开:窗口内的并行
空间位置展开,是针对卷积窗口内部的。比如3x3的卷积核,窗口内有9个像素点。如果把这9个点并行计算,那一个时钟周期就能完成一个窗口的乘累加。
我记得有一次做图像处理,要求实时性特别高。我用空间位置展开,把3x3窗口内的9个乘法器全部并行。结果延迟从9个时钟周期降到了1个时钟周期。当然,代价是DSP资源用了9倍。
关键点:空间位置展开最适合小卷积核。如果是7x7或更大的核,资源消耗会急剧增加。我一般建议3x3用全展开,5x5用部分展开。
组合展开:实战中的选择
说了这么多,你可能要问:到底怎么选?嗯,这个问题没有标准答案。我给大家一个实战中的决策流程:
- 先看资源预算:DSP有多少?BRAM有多少?逻辑单元有多少?
- 再看性能目标:需要多少吞吐量?延迟要求是多少?
- 然后选展开方式:资源多选输出通道展开,资源少选输入通道展开或空间展开。
- 最后做仿真验证:别光看理论,跑一下仿真看看实际效果。
我个人的习惯是,先做一个最小系统,跑通功能。然后逐步增加展开因子,每次增加后都做一次时序分析。这样能及时发现瓶颈,避免一次性展开太多导致失败。
避坑指南:我曾经在一个项目里,为了追求极致性能,把输出通道展开到了64路。结果布线时发现,数据路径太长,时序根本跑不满。后来我改成32路展开+流水线插入,才解决问题。所以,展开不是越多越好,要结合时序约束来权衡。
好了,关于卷积的循环展开,今天就聊到这里。记住一句话:循环展开的本质,是用面积换速度。你展开得越多,并行度越高,但资源消耗也越大。找到那个平衡点,才是硬件工程师的真本事。
核心总结:卷积的嵌套循环结构,天然存在四种并行化机会——输出通道、输入通道、空间位置、卷积核。选择合适的展开策略,结合资源约束和性能目标,才能设计出高效的卷积加速器。