本教程延续上一篇文章,继续实现深度学习推理中的三大核心层:全连接层、池化层与ReLU激活函数,同时初步介绍如何借助Vivado HLS完成硬件电路生成。以下是各层的实现细节与解析。
1. 全连接层实现详解
全连接层的计算流程非常直观:输入向量x与权重矩阵weight相乘,然后加上偏置向量bias。下图清晰演示了这一运算过程:

实现代码
void linear(const float *x, const float* weight, const float* bias,
int64_t in_features, int64_t out_features, float *y) {
for (int64_t i = 0; i < out_features; ++i) {
float sum = 0.f;
for (int64_t j = 0; j < in_features; ++j) {
sum += x[j] * weight[i * in_features + j];
}
y[i] = sum + bias[i];
}
}
接口定义与内存布局
为确保后续可直接对接PyTorch模型参数,本实现将内存布局与PyTorch保持一致。具体接口如下:
- 输入
x: 输入特征向量,shape = (in_features)weight: 权重矩阵,shape = (out_features, in_features)bias: 偏置向量,shape = (out_features)
- 输出
y: 输出向量,shape = (out_features)
- 参数
in_features: 输入维度out_features: 输出维度
性能与内存考量
全连接层内部的操作次数为out_channels × in_channels,在典型配置下通常远小于卷积层。但权重参数所需的内存空间却显著大于卷积层。
以卷积层为例,其权重形状为(out_channels, in_channels, ksize, ksize),而全连接层的权重形状则为(out_features, in_features)。从卷积层过渡到全连接层时,通常in_features = channels × width × height。由于width和height远大于卷积核尺寸ksize,全连接层的参数内存占用往往远超卷积层。
FPGA芯片内部集成大量SRAM缓冲区,非常适合于处理大数据量访问和高复用性场景。尽管单次全连接层的权重数据复用率较低,但在视频处理等持续推理任务中,由于需要多次执行全连接运算,SRAM的缓冲能力便成为明显优势。
注意: 在边缘设备中部署小型FPGA时,可能遇到SRAM容量不足而需访问外部DRAM的情况。若外部内存带宽充足,可直接访问;否则,建议在训练后对模型进行剪枝或量化,以降低参数规模。
2. 池化层实现
池化层的主要功能是缩小输入特征图的空间尺寸。本教程采用2×2最大池化(MaxPooling),即对输入图像中每个2×2区域取最大值,作为输出图像对应位置的像素值。过程如下图所示:

池化操作独立作用于每个通道,因此输入通道数与输出通道数保持一致。
实现代码
void maxpool2d(const float *x, int32_t width, int32_t height, int32_t channels, int32_t stride, float *y) {
for (int ch = 0; ch < channels; ++ch) {
for (int32_t h = 0; h < height; h += stride) {
for (int32_t w = 0; w < width; w += stride) {
float maxval = -FLT_MAX;
for (int bh = 0; bh < stride; ++bh) {
for (int bw = 0; bw < stride; ++bw) {
maxval = std::max(maxval, x[(ch * height + h + bh) * width + w + bw]);
}
}
y[(ch * (height / stride) + (h / stride)) * (width / stride) + w / stride] = maxval;
}
}
}
}
接口参数说明
- 输入
x: 输入图像数据,shape = (channels, height, width)
- 输出
y: 输出图像数据,shape = (channels, height/stride, width/stride)
- 参数
width: 图像宽度height: 图像高度stride: 缩减倍数(本例中为 2)
提示: 当前实现未包含边缘填充处理,因此要求输入图像的宽度和高度均可被步长stride整除。若实际图片不满足此条件,请在调用前手动添加填充或修改函数以支持边缘处理。
3. ReLU激活函数实现
ReLU(修正线性单元)函数极为简洁:将所有负数值置为零。实现代码如下:
void relu(const float *x, int64_t size, float *y) {
for (int64_t i = 0; i < size; ++i) {
y[i] = std::max(x[i], .0f);
}
}
由于每个元素的计算相互独立,x与y的内存布局无需特殊规定,仅需确保两者长度相同即可。
4. 硬件电路生成与性能分析
上述各层代码已逐层验证,输出与libtorch一致,并通过了Vivado HLS的RTL仿真。接下来简要说明实际生成硬件电路的关键注意事项。
将函数适配为HLS可综合形式
直接将原始的linear函数导入Vivado HLS会引发错误。须将输入输出改为固定大小的数组指针,并将in_features和out_features设为常量值。示例如下:
static const std::size_t kMaxSize = 65536;
void linear_hls(const float x[kMaxSize], const float weight[kMaxSize],
const float bias[kMaxSize], float y[kMaxSize]) {
dnnk::linear(x, weight, bias, 7*7*8, 32, y);
}
此处in_features=392(即7×7×8),out_features=32。将循环边界固定为常量,可防止HLS因循环次数可变而生成低效电路。
综合报告关键指标解读

综合报告中的Timing → Summary显示工作时钟周期为5.00 ns(对应200 MHz)。重点关注Latency → Summary:该全连接层单次计算的总延迟为0.566 ms。
在Latency → Detail → Loop部分,列出了每个循环的迭代延迟(Iteration Latency)与循环次数(Trip Count)。例如,外层循环Loop 1(out_features循环)包含内层循环Loop 1.1(in_features循环)。内层循环执行sum += x[j] * weight[i * in_features + j]需9个时钟周期。
借助HLS的Schedule Viewer可更精细地观察各操作的时间分布:从第2到第10周期,依次为加载x与权重(2周期)、乘法fmul(3周期)、加法fadd(4周期),共计9个周期。

性能优化方向(供参考)
通过插入#pragma HLS pipeline等指令可大幅提高性能,具体包括:
- 流水线技术:将迭代延迟降至最低1个周期
- 并行化处理:减少循环次数甚至消除循环嵌套
本系列教程聚焦于FPGA推理实现,暂不深入上述优化。感兴趣的用户可参考下列资源:
该函数的接口示意图如下(数组接口如x_等对应于FPGA上支持单周期读写的BRAM/Distributed RAM):

常见问题解答
Q1: 全连接层参数内存为何通常大于卷积层?
卷积层通过空间局部性(小卷积核)有效压缩参数量,而全连接层的每个输出神经元与所有输入神经元全连接,因此权重矩阵规模为out_features × in_features,极为庞大。尤其是在从卷积层过渡到全连接层时,in_features包含了各通道的宽高乘积,导致参数数量急剧上升。
Q2: HLS中为何必须将in_features和out_features设为常量?
Vivado HLS在综合时,若循环边界为变量,会生成对应控制逻辑,但可能降低电路性能(例如使用可变长度计数器)。将边界固定为常量,可使HLS生成更高效的固定循环展开电路,从而节省资源并提高运行速度。
Q3: 池化层对输入图像尺寸有何限制?
本教程实现的maxpool2d未包含边缘填充功能,因此输入图像的宽度和高度必须能被步长stride整除(例如stride=2时,宽高需为偶数)。若遇到无法整除的情况,可在调用前手动进行填充,或修改函数代码增加边界处理逻辑。
总结与展望
本文实现了全连接层、2×2最大池化层以及ReLU激活函数,并验证了各层与libtorch的输出一致性。同时,我们学习了如何将C代码适配为HLS可综合形式,并解析了综合报告中的关键性能指标。在下一篇文章中,我们将组合这些层构建完整的推理网络,并在FPGA上运行MNIST数据集进行验证。
