引言
手写 RTL 描述硬件虽然精确,但开发效率低、迭代慢:一个矩阵乘加速器从架构设计到时序收敛,可能需要数周。高层次综合(HLS)的思路是——用 C/C++ 描述算法行为,让工具自动推导出流水线化的硬件结构,把工程师从「手写状态机、手工排流水」中解放出来。
HLS 的价值不在于「不用懂硬件」,而恰恰相反:不懂硬件的人用 HLS 写出来的代码性能会差一到两个数量级。工具默认生成的硬件非常保守(一个循环一个周期、数组映射到单端口 BRAM),要获得高性能,必须用 pragma 明确告诉工具「这个循环要展开」「这个数组要分区」「这两个循环要并行执行」。这些 pragma 背后的每个决策,都对应着 RTL 层面的具体结构。
本文按「流程 → 优化手段 → 诊断 → 实战」的顺序展开。前两节讲 HLS 的定位与基本流程,中间六节逐个拆解核心优化 pragma,然后讲 II 违例诊断和报告解读,最后用一个矩阵乘内核完整演示优化路径。理解本文需要 FPGA 时序约束与收敛 中的流水线概念,也需要 FPGA 加速器:矩阵乘与 CNN 中的算术强度模型作为背景。
目录
- HLS 的定位与适用边界
- Vitis HLS 开发流程
- 接口综合:pragma HLS INTERFACE
- 流水线:pragma HLS PIPELINE
- 循环展开:pragma HLS UNROLL
- 数组分区:pragma HLS ARRAY_PARTITION
- 数据流:pragma HLS DATAFLOW
- 数据类型:ap_int 与 ap_fixed
- II 违例与循环依赖诊断
- 报告解读与性能估算
- HLS 与手写 RTL 的取舍
- 实战:矩阵乘内核优化路径
1. HLS 的定位与适用边界
HLS 把 C/C++ 代码综合成 RTL,但它不是「编译器」,而是一个架构探索工具。它适合的场景:
- 数据流密集、结构规整的算法:卷积、矩阵乘、FFT、滤波器、图像处理。
- 需要快速迭代的算法研究:参数变了重新综合即可,不用重写 RTL。
- 需要 C 参考模型的场景:同一份代码既是硬件又是验证用的黄金模型。
不适合的场景:
- 复杂控制逻辑:状态机、总线协议、仲裁器——手写 RTL 更可控。
- 对面积/时序极致敏感的设计:HLS 生成的代码通常比手写 RTL 大 20%~50%。
- 需要精确控制复位、时钟域的设计:HLS 的抽象层次太高,做不到。
一个务实的判断:HLS 用于数据通路,RTL 用于控制通路。实际项目中常见的组合是 HLS 生成计算内核,用 RTL 包一层 AXI 接口和调度逻辑。
2. Vitis HLS 开发流程
典型流程:
# 1. 写 C++ 内核 + testbench,先用 g++ 验证算法正确性
g++ -o sim tb.cpp gemm.cpp && ./sim
# 2. 用 vitis_hls 做 C 仿真(验证 HLS 语义下行为一致)
vitis_hls -f run_hls.tcl
# 3. C 综合(synthesis):生成 RTL 与性能/面积报告
# 4. C/RTL 协同仿真(cosim):验证生成的 RTL 与 C 行为一致
# 5. 导出 IP,在 Vivado 里集成
run_hls.tcl 的骨架:
open_project gemm_prj
set_top gemm
add_files gemm.cpp
add_files -tb tb_gemm.cpp
open_solution "solution1" -flow_target vivado
set_part {xc7z020clg400-1}
create_clock -period 10 -name default # 100MHz
csim_design # C 仿真
csynth_design # C 综合 → 生成 RTL 与报告
cosim_design # C/RTL 协同仿真
export_design -format ip_catalog # 导出 IP
关键报告文件:
| 文件 | 内容 |
|---|---|
csynth.rpt | 资源占用(LUT/FF/DSP/BRAM)、时钟周期估计 |
*_schedule.rpt | 调度结果:每个操作在第几拍执行 |
*_loops.rpt | 循环信息:迭代次数、II、流水线深度 |
*_dataflow.rpt | 数据流通道的分组与并行情况 |
3. 接口综合:pragma HLS INTERFACE
接口 pragma 决定模块的端口形态,直接影响它在系统中的连接方式。不写 INTERFACE pragma 时,工具会把数组参数综合成 memory 接口、标量参数综合成独立端口,通常不是你要的。
void gemm(int A[8][8], int B[8][8], int C[8][8]) {
#pragma HLS INTERFACE m_axi port=A depth=64 offset=slave bundle=gmem0
#pragma HLS INTERFACE m_axi port=B depth=64 offset=slave bundle=gmem1
#pragma HLS INTERFACE m_axi port=C depth=64 offset=slave bundle=gmem2
#pragma HLS INTERFACE s_axilite port=return bundle=ctrl
// ...
}
常用接口类型:
| 类型 | 含义 | 适用 |
|---|---|---|
m_axi | AXI4 主接口,可突发读写外部 DDR | 大数组、高带宽 |
s_axilite | AXI4-Lite 从接口,寄存器映射 | 控制寄存器、标量参数 |
axis | AXI4-Stream,流式数据 | 流水线数据流、视频流 |
ap_memory | 简单 BRAM 接口 | 片上小数组 |
ap_none / ap_vld / ap_hs | 简单握手 | 无握手/带有效/带握手 |
depth 参数必须准确(数组元素个数),bundle 把多个端口打包到同一个 AXI 接口以节省资源,offset=slave 让地址通过寄存器配置。
4. 流水线:pragma HLS PIPELINE
这是 HLS 最重要的优化。默认情况下,一个循环的 N 次迭代串行执行,每次迭代内的操作也要多个周期。PIPELINE 让迭代重叠执行,目标是把启动间隔(Initiation Interval, II)降到 1——即每个时钟周期启动一次迭代。
void add_stream(int in[N], int out[N]) {
for (int i = 0; i < N; i++) {
#pragma HLS PIPELINE II=1
out[i] = in[i] + 1;
}
}
PIPELINE II=1 的效果:原本需要 N × (延迟) 个周期,现在约 N + 延迟 个周期,吞吐量提升接近延迟倍数。代价是面积增加(需要复制运算单元、寄存器)和布线压力。
两种流水线写法:
// 写法 1:pragma 写在循环体内,流水线化这个循环
for (int i = 0; i < N; i++) {
#pragma HLS PIPELINE
body(i);
}
// 写法 2:pragma 写在函数内,流水线化整个函数(跨循环)
void top(...) {
#pragma HLS PIPELINE
step1();
step2();
}
II 不是总能降到 1。常见的 II 违例来源:数组访问端口冲突、跨迭代依赖、资源不足。
5. 循环展开:pragma HLS UNROLL
UNROLL 把循环的多次迭代复制成并行硬件。它是提高并行度的主要手段,也是 II 违例的常用解法。
// 完全展开:8 次迭代变成 8 份并行硬件
for (int i = 0; i < 8; i++) {
#pragma HLS UNROLL
sum += a[i] * b[i];
}
// 部分展开:factor=2,每次并行 2 个
for (int i = 0; i < 16; i++) {
#pragma HLS UNROLL factor=2
y[i] = x[i] + x[i+1];
}
展开的收益与代价:
| 展开因子 | 并行度 | DSP 占用 | 时序压力 |
|---|---|---|---|
| 1(不展开) | 1 | 1 | 低 |
| 4 | 4 | 4 | 中 |
| 8 | 8 | 8 | 高 |
| 完全展开 | N | N | 很高,易违例 |
部分展开是工程上的甜蜜点:先按资源上限选因子,再根据时序报告调整。一个 8×8 的矩阵乘完全展开需要 64 个乘法器,如果 DSP 只有 240 个,那么同时跑多个内核就会资源不足。
6. 数组分区:pragma HLS ARRAY_PARTITION
HLS 默认把数组映射到单端口 BRAM(或双端口)。单端口 BRAM 每个周期只能读写一次,当多个并行迭代要访问同一个数组时就会产生端口冲突,导致 II 上升。数组分区是解决这个问题的核心手段。
void conv(int in[64], int w[8], int out[64]) {
#pragma HLS ARRAY_PARTITION variable=w complete // 完全拆成独立寄存器
#pragma HLS ARRAY_PARTITION variable=in cyclic factor=4 dim=1
for (int i = 0; i < 64; i++) {
#pragma HLS PIPELINE II=1
int acc = 0;
for (int k = 0; k < 8; k++)
acc += in[i+k] * w[k];
out[i] = acc;
}
}
三种分区方式:
| 方式 | 做法 | 适用 |
|---|---|---|
complete | 每个元素一个寄存器 | 小数组(<32 元素),要完全并行访问 |
block factor=N | 连续 N 个元素一块 | 顺序访问,N 个并行读端口 |
cyclic factor=N | 每隔 N 个取一个成块 | 交错访问,适合滑动窗口 |
分区的代价是 BRAM 数量增加(每块分区占用一个独立 BRAM 端口)或寄存器数量激增(complete 分区)。complete 只适合极小的数组,大数组用 complete 会耗尽触发器。
7. 数据流:pragma HLS DATAFLOW
DATAFLOW 让多个函数或循环并发执行,通过 FIFO 传递数据。它实现的是「流水线式并行」——生产者写 FIFO,消费者读 FIFO,两者同时工作。
void pipeline(int in[N], int out[N]) {
int tmp1[N], tmp2[N];
#pragma HLS DATAFLOW
stage1(in, tmp1); // 生产者
stage2(tmp1, tmp2); // 消费者(与 stage1 并发)
stage3(tmp2, out); // 消费者(与 stage2 并发)
}
关键规则:
- DATAFLOW 内的模块必须通过数组/FIFO 通信,不能用标量直接传递(会破坏并发性)。
- 数组会被自动推断成 FIFO 或 ping-pong buffer:顺序访问推断成 FIFO,随机访问推断成 ping-pong。
- 各 stage 的吞吐量要匹配:如果 stage2 的 II 是 3 而 stage1 是 1,整体吞吐受限于 stage2。
- 数据流与流水线的区别:PIPELINE 是「同一段逻辑的多次迭代重叠」,DATAFLOW 是「不同逻辑块同时工作」。两者可以组合——外层用 DATAFLOW 让多个循环并发,内层各循环再用 PIPELINE 提高迭代吞吐。
8. 数据类型:ap_int 与 ap_fixed
HLS 支持任意精度整数 ap_int<W> 和定点数 ap_fixed<W,I>。用它们代替 int 能大幅节省资源——一个 int(32 位)乘法器在 DSP 上要占满一个 25×18 单元,而 ap_int<8> 乘法只需极小的逻辑。
#include <ap_int.h>
#include <ap_fixed.h>
void filter(ap_int<12> x[64], ap_fixed<16,4> coef[8], ap_fixed<20,6> y[64]) {
#pragma HLS PIPELINE II=1
for (int i = 0; i < 64; i++) {
ap_fixed<20,6> acc = 0;
for (int k = 0; k < 8; k++)
acc += (ap_fixed<20,6>)x[i+k] * coef[k];
y[i] = acc;
}
}
定点数格式 ap_fixed<W,I> 中,W 是总位宽,I 是整数位宽(含符号位),小数位宽为 W-I。选型方法:
- 用浮点模型跑一遍,统计每个变量的动态范围。
- 整数位宽按最大值确定(留 1~2 位余量)。
- 小数位宽按精度要求确定:每增加 1 位,量化信噪比提升约 6dB。
- 用定点模型重跑,验证精度损失可接受。
位宽不是越宽越好:ap_int<33> 会从「一个 DSP」变成「两个 DSP 拼接」,资源翻倍。位宽选择要卡在硬核边界上。
9. II 违例与循环依赖诊断
当 II=1 无法达成时,*_loops.rpt 会给出具体原因。常见三类:
(1)资源冲突:多个并行访问同一个数组端口。解法是数组分区。
* Loop: conv_line_loop
Issue: Memory Port Dependence
Array: in (port 1) -- 2 reads in same cycle
→ 用 ARRAY_PARTITION variable=in cyclic factor=2 解决
(2)循环携带依赖(loop-carried dependency):后一次迭代依赖前一次的结果,无法重叠。
// 累加器依赖:sum = sum + a[i] 形成跨迭代依赖链,II 必然 > 1
// 解法:拆成多个独立累加器并行累加,最后合并
int s0=0, s1=0, s2=0, s3=0;
for (int i = 0; i < N; i += 4) {
#pragma HLS PIPELINE II=1
s0 += a[i]; s1 += a[i+1]; s2 += a[i+2]; s3 += a[i+3];
}
sum = s0 + s1 + s2 + s3;
(3)操作延迟过长:单个操作的延迟超过 II。如浮点除法延迟几十个周期,无法在 II=1 的流水线里完成。解法是用查找表近似、改用定点、或放宽 II。
10. 报告解读与性能估算
从 csynth.rpt 和 *_loops.rpt 可以估算性能:
性能估算三步:
1. 找最外层流水线的循环,看它的 II 和迭代次数
2. 总周期数 ≈ II × 迭代次数 + 流水线填充深度
3. 吞吐量 = 每周期处理的数据量 × 时钟频率
示例:conv 内核,输出 64 个点,内层循环 8 次乘加
- 内层完全展开 → 8 个乘法器并行
- 外层 PIPELINE II=1 → 每周期出 1 个输出点
- 时钟 200MHz → 吞吐 = 200M 点/s
- 资源:8 个 DSP + 若干 LUT
资源报告要重点看三列:BRAM_18K、DSP48E、FF/LUT。如果 BRAM 或 DSP 超了,说明并行度过高;如果 LUT 超了而 DSP 没用满,说明该用 DSP 的地方(乘法)被综合成了逻辑。
11. HLS 与手写 RTL 的取舍
| 维度 | HLS | 手写 RTL |
|---|---|---|
| 开发速度 | 快(天级) | 慢(周级) |
| 面积效率 | 比手写大 20%~50% | 最优 |
| 时序可控性 | 间接(靠 pragma) | 完全可控 |
| 算法迭代 | 改 C 重综合 | 改架构重写 |
| 验证 | 可复用 C 参考模型 | 需独立写 TB |
| 适合 | 数据通路、算法密集 | 控制逻辑、接口协议 |
混合策略是最务实的:核心计算内核用 HLS(快速迭代、易于调参),外围的控制、接口、DMA 用 RTL(精确控制、面积小)。两者通过 AXI 接口连接。
一个常见的误区是「用 HLS 就不用懂硬件」。实际上,写出高性能 HLS 代码需要理解:数组如何映射到存储、循环如何展开成并行硬件、依赖如何限制流水线。这些正是 RTL 工程师的核心知识。
12. 实战:矩阵乘内核优化路径
以一个 8×8 的定点矩阵乘为例,演示从朴素实现到高性能的优化路径。
第 0 版:朴素实现
void gemm(ap_int<16> A[8][8], ap_int<16> B[8][8], ap_int<32> C[8][8]) {
for (int i = 0; i < 8; i++)
for (int j = 0; j < 8; j++) {
ap_int<32> acc = 0;
for (int k = 0; k < 8; k++)
acc += A[i][k] * B[k][j];
C[i][j] = acc;
}
}
性能:内层循环 8 次串行,每行约 8×8 = 64 个周期,总共 8×8×8 = 512 个周期,只用 1 个乘法器。太慢。
第 1 版:内层完全展开 + 流水线
for (int i = 0; i < 8; i++)
for (int j = 0; j < 8; j++) {
#pragma HLS PIPELINE II=1
#pragma HLS ARRAY_PARTITION variable=B complete dim=2 // B 的列可并行读
ap_int<32> acc = 0;
for (int k = 0; k < 8; k++) { // 展开后 8 个乘法器并行
#pragma HLS UNROLL
acc += A[i][k] * B[k][j];
}
C[i][j] = acc;
}
改进:8 个乘法器并行,每周期出一个输出元素,总周期约 64 + 流水线深度。但 A 和 B 的访问仍有端口冲突——B[k][j] 对固定的 j、变化的 k 需要 8 个不同的 B 元素。
第 2 版:数组完全分区 + 输出并行
void gemm_opt(ap_int<16> A[8][8], ap_int<16> B[8][8], ap_int<32> C[8][8]) {
#pragma HLS ARRAY_PARTITION variable=A complete dim=2
#pragma HLS ARRAY_PARTITION variable=B complete dim=1
#pragma HLS ARRAY_PARTITION variable=C complete dim=2
for (int i = 0; i < 8; i++) {
#pragma HLS PIPELINE II=1
for (int j = 0; j < 8; j++) {
#pragma HLS UNROLL
ap_int<32> acc = 0;
for (int k = 0; k < 8; k++)
acc += A[i][k] * B[k][j];
C[i][j] = acc;
}
}
}
现在整个 8×8 输出在一个周期内全部算出,需要 64 个乘法器。总周期约 8(外层迭代)+ 流水线深度。吞吐提升 64 倍,代价是 64 个 DSP。
第 3 版:接口与数据流
如果 A、B 来自外部 DDR,需要加上 m_axi 接口,并用 DATAFLOW 把「加载 A」「加载 B」「计算」「写回 C」拆成并发的 stage,让加载与计算重叠,避免计算单元空等数据。
优化的通用路径是:先展开内层循环提高并行度 → 分区数组消除端口冲突 → 加流水线重叠迭代 → 用 DATAFLOW 让数据搬运与计算重叠 → 最后用定点类型和位宽裁剪省资源。每一步都用报告验证收益,避免盲目堆 pragma。
权衡取舍
| 决策点 | 选项 A | 选项 B |
|---|---|---|
| 开发方式 | HLS:快、面积大 | 手写 RTL:慢、面积优 |
| 并行度 | 完全展开:吞吐高、DSP 多 | 部分展开:平衡、可控 |
| 数组 | complete 分区:无冲突、耗 FF | BRAM:省 FF、有端口冲突 |
| 类型 | float:精度高、资源贵 | ap_fixed:省资源、需精度分析 |
| 结构 | PIPELINE:迭代重叠 | DATAFLOW:模块并发 |
| 接口 | m_axi:高带宽、复杂 | ap_memory:简单、带宽低 |
常见坑清单
- 不写 INTERFACE pragma:数组被综合成默认 memory 接口,无法接入 AXI 总线。
- depth 参数填错:
m_axi的 depth 小于实际数组大小,工具生成的地址逻辑错误,访问越界。 - 大数组用 complete 分区:数组元素全映射到触发器,FF 瞬间耗尽,应改用 block/cyclic 分区。
- II 违例不查报告:盲目加 pragma,真正原因是数组端口冲突或循环依赖,加再多 UNROLL 也没用。
- 用 int 做定点运算:32 位乘法器浪费 DSP,改用
ap_int<W>精确匹配位宽。 - 位宽卡在 DSP 边界之外:
ap_int<33>比ap_int<32>多占一个 DSP,位宽要卡在硬核边界。 - DATAFLOW 里用标量传参:破坏并发性,stage 退化成串行,必须用数组或 FIFO。
- 忘记 C/RTL 协同仿真:C 仿真通过不代表生成的 RTL 正确,cosim 是必做步骤。
- 时钟约束与综合目标不一致:HLS 里
create_clock -period 10但集成时跑 200MHz,时序必违例。 - 算法精度未验证:直接上定点、未用浮点参考对比,精度损失到板上才发现。
小结
HLS 的本质是用 pragma 做架构决策。工具只提供机制,决策权在工程师手里:并行度多高、数组怎么分区、流水线怎么排、数据流怎么切——每一个 pragma 都对应硬件上的一个具体结构,也都对应资源与时序的权衡。把 HLS 当成「自动生成硬件」的黑盒,写出来的设计性能必然平庸。
优化的通用路径可以记成一条链:展开 → 分区 → 流水 → 数据流 → 定点裁剪。每一步都要用报告验证收益,因为加 pragma 并非总是正向的——并行度提升会带来布线拥塞和时序压力,超过某个点后频率下降会抵消并行度收益。
下一步建议阅读 FPGA 加速器:矩阵乘与 CNN ,把这里的优化手段放到完整的加速器架构(tiling、行缓冲、脉动阵列)中理解;或者进入 RISC-V 工具链与裸机开发 ,看加速器如何被 CPU 通过驱动调用起来。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。