fft2d_aie_vs_hls 模块技术深度解析
概述:这个模块解决什么问题?
想象你正在处理一张高分辨率图像,需要对每个像素进行频域分析——这就是二维快速傅里叶变换(2D-FFT)的典型应用场景。在AMD Versal自适应SoC平台上,你有两种截然不同的计算资源可用:AI Engine(AIE)向量处理器阵列和传统的可编程逻辑(PL)+ DSP引擎。这个模块的核心使命是回答一个关键的设计问题:对于2D-FFT这种计算密集型任务,应该选择哪种实现路径?
这个模块并非简单的算法实现,而是一个方法论对比框架。它提供了两套并行但结构相似的实现:一套基于AIE的向量计算能力,另一套基于HLS(高层次综合)生成的PL/DSP逻辑。通过保持相同的数据流接口、相同的测试激励和相同的性能评估标准,开发者可以客观比较两种架构在吞吐量、延迟、资源占用和功耗方面的差异。
为什么需要这样的设计?因为在异构计算时代,"最快"或"最省资源"往往不是唯一考量。AIE提供的是高度并行的向量吞吐能力,适合数据流规整、计算密集的任务;而PL/HLS方案则提供更灵活的流水线控制和更精细的时序优化空间。这个模块让你能够在真实硬件上验证假设,而不是仅凭纸面规格做决策。
核心抽象与心智模型
"双轨制工厂"类比
将这个模块想象成一个生产2D-FFT结果的工厂,它有两条完全独立但功能相同的生产线:
┌─────────────────────────────────────────────────────────────┐
│ 2D-FFT 处理工厂 │
├──────────────────────────┬──────────────────────────────────┤
│ AIE 生产线 (向量阵列) │ HLS 生产线 (FPGA逻辑) │
├──────────────────────────┼──────────────────────────────────┤
│ 行方向FFT → 转置 → 列方向FFT │ 行方向FFT → 转置 → 列方向FFT │
│ (由AIE内核图编排) │ (由HLS DATAFLOW调度) │
└──────────────────────────┴──────────────────────────────────┘
两条生产线共享同一个"装卸码头"(数据搬运器 dma_hls),使用相同的运输协议(128位AXI4-Stream),接受相同的订单参数(矩阵尺寸、迭代次数)。这种设计确保了性能对比的公平性——任何测量差异都源于底层计算架构的本质区别,而非接口或数据格式的差异。
数据流的核心抽象
2D-FFT的计算本质上是两次1D-FFT加一次矩阵转置。模块将这个计算过程分解为三个明确的阶段:
- 行方向FFT:对输入矩阵的每一行执行1D-FFT
- 中间转置:将行FFT的输出重新组织为列优先格式
- 列方向FFT:对转置后的矩阵再次执行1D-FFT
在实际的硬件实现中,转置操作被巧妙地融入了数据搬运器的逻辑中——它不是显式的内存重排,而是通过改变数据读取模式来实现的。这种"隐式转置"策略避免了额外的内存访问开销。
架构设计与数据流
系统架构图
(仅AIE版本)"] end subgraph PL["Programmable Logic (PL)"] dma["dma_hls Kernel
数据生成/校验"] end subgraph Compute["Compute Engines"] direction_AIE["AIE Implementation"] direction_HLS["HLS Implementation"] subgraph AIE_Graph["AIE Graph (fft2d_graph)"] row_fft["FFTrows_graph
行方向FFT"] col_fft["FFTcols_graph
列方向FFT"] end subgraph HLS_Kernel["HLS Kernel (fft_2d)"] fft_row["fft_rows()
行方向FFT"] fft_col["fft_cols()
列方向FFT"] end end main --> datamover_cls main --> fft2d_graph_cls datamover_cls -->|XRT API| dma fft2d_graph_cls -->|xrtGraphRun| AIE_Graph dma -->|AXI4-Stream| row_fft row_fft -->|AXI4-Stream| dma dma -->|AXI4-Stream| col_fft col_fft -->|AXI4-Stream| dma dma -->|AXI4-Stream| fft_row fft_row -->|AXI4-Stream| dma dma -->|AXI4-Stream| fft_col fft_col -->|AXI4-Stream| dma
关键组件职责
1. datamover 类(主机端)
这是主机应用程序与硬件交互的主要接口。它封装了XRT(Xilinx Runtime)API调用,管理PL端数据搬运器内核的生命周期。
class datamover {
xrtKernelHandle dma_hls_khdl; // 内核句柄(所有权归此类)
xrtRunHandle dma_hls_rhdl; // 运行句柄(所有权归此类)
uint32_t instance_errCnt; // 从硬件读取的错误计数
public:
void init(xrtDeviceHandle dhdl, const axlf *top, char insts, int16_t iterCnt);
void run(void);
void waitTo_complete(void);
void golden_check(uint32_t *errCnt, char insts);
void close(void);
};
设计意图:
- RAII原则的变体:虽然该类没有显式析构函数,但它要求调用者按
init()→run()→waitTo_complete()→golden_check()→close()的顺序调用方法。这种显式生命周期管理在硬件加速场景中更为常见,因为资源释放的时机往往需要与硬件状态同步。 - 错误聚合:每个实例维护自己的
instance_errCnt,但最终汇总到主机的errCnt指针中。这种设计支持多实例并行测试。
内存所有权:
dma_hls_khdl和dma_hls_rhdl由xrtPLKernelOpenExclusive()和xrtRunOpen()分配,必须由xrtKernelClose()和xrtRunClose()释放。init()方法中的top参数(指向xclbin头部)是借用引用,不获取所有权。
2. fft2d_hostapp_graph 类(仅AIE版本)
这是AIE实现特有的主机控制类,负责管理AIE图的初始化和执行。
class fft2d_hostapp_graph {
xrtGraphHandle fft2d_graph_gr; // AIE图句柄
public:
int init(xrtDeviceHandle dhdl, const axlf *top, char insts);
int run(int16_t graph_iter_cnt);
void close(void);
};
关键设计决策:
- 迭代次数计算:注意到
graph_iter_cnt = iterCnt * MAT_ROWS,这是因为AIE图以行为单位处理数据,而主机以整个矩阵为单位指定迭代次数。 - 错误处理:使用返回码(
int)而非异常来表示初始化失败,这与底层C风格XRT API保持一致。
3. AIE图定义(graph.h / graph.cpp)
AIE实现使用Vitis DSPLib提供的 fft_ifft_dit_1ch_graph 作为基础构建块:
class FFTrows_graph : public graph {
input_plio row_in; // PLIO输入端口
output_plio row_out; // PLIO输出端口
public:
FFTrows_graph() {
// 实例化DSPLib FFT图模板
dsplib::fft::dit_1ch::fft_ifft_dit_1ch_graph<
FFT_2D_TT_DATA, // 数据类型:cint16 或 cfloat
FFT_2D_TT_TWIDDLE, // 旋转因子类型
FFT_ROW_TP_POINT_SIZE, // FFT点数(MAT_COLS)
FFT_2D_TP_FFT_NIFFT, // FFT/IFFT选择
FFT_2D_TP_SHIFT, // 输出移位
FFT_2D_TP_ROW_CASC_LEN, // 级联长度
FFT_2D_TP_DYN_PT_SIZE, // 动态点大小使能
FFT_ROW_TP_WINDOW_VSIZE // 窗口大小
> FFTrow_gr;
// 设置内核利用率(80%)
runtime<ratio>(*FFTrow_gr.getKernels()) = 0.8;
// 物理位置约束(关键!)
location<graph>(*this) = area_group({{aie_tile, ...}, {shim_tile, ...}});
// 创建PLIO端口并连接
row_in = input_plio::create(...);
row_out = output_plio::create(...);
connect<window<FFT_ROW_WINDOW_BUFF_SIZE>>(row_in.out[0], FFTrow_gr.in[0]);
connect<window<FFT_ROW_WINDOW_BUFF_SIZE>>(FFTrow_gr.out[0], row_out.in[0]);
}
};
物理布局约束的重要性:
代码中的 location<graph>() 调用是AIE设计的关键。它明确指定了AIE tiles的物理位置和SHIM tiles(用于PL-AIE接口)的位置。这种显式布局对于大型设计至关重要,因为:
- 它确保多个FFT实例不会争夺相同的硬件资源
- 它控制数据流在AIE阵列中的路由路径,影响时序收敛
- 对于10实例的cfloat配置(1024×2048点),代码使用了特殊的宽区域布局(
fftCols_grInsts*4到fftCols_grInsts*4+3)
4. HLS内核(fft_2d.cpp)
HLS实现使用 hls::fft IP核,通过DATAFLOW指令实现流水线并行:
void fft_2d(...) {
#pragma HLS DATAFLOW
// 行方向处理
fft_rows(strmFFTrows_inp, strmFFTrows_out);
// 列方向处理
fft_cols(strmFFTcols_inp, strmFFTcols_out);
}
void fft_rows(...) {
LOOP_FFT_ROWS:for(int i = 0; i < MAT_ROWS; ++i) {
#pragma HLS DATAFLOW
cmpxDataIn rows_in[MAT_COLS];
cmpxDataOut rows_out[MAT_COLS];
#pragma HLS STREAM variable=rows_in depth=1024
#pragma HLS STREAM variable=rows_out depth=1024
readIn_row(strm_inp, rows_in);
fftRow(directionStub, rows_in, rows_out, &ovfloStub);
writeOut_row(strm_out, rows_out);
}
}
DATAFLOW指令的作用:
#pragma HLS DATAFLOW 告诉综合工具将函数调用展开为并行执行的进程,通过FIFO通道通信。在这个设计中,readIn_row、fftRow 和 writeOut_row 形成一个三级流水线,使得当第N行正在进行FFT计算时,第N+1行可以被读取,第N-1行可以被写出。
5. 数据搬运器内核(dma_hls.cpp)
数据搬运器是整个系统的"测试仪器",它有三个核心职责:
// 阶段1:生成脉冲输入(第一行为1,其余为0)
void mm2s0(hls::stream<ap_axiu<128,0,0,0>> &strmOut_to_rowiseFFT, ...);
// 阶段2:验证行FFT输出 + 生成列FFT输入(转置后的脉冲)
void dmaHls_rowsToCols(...);
// 阶段3:验证列FFT输出(应全为1)
void s2mm1(...);
测试策略的智慧: 使用脉冲输入(impulse)作为测试向量是经过精心选择的,因为:
- 脉冲的2D-FFT结果是全1矩阵,易于验证
- 行FFT后第一行应为全1,其余为0
- 列FFT后整个矩阵应为全1
- 任何非零错误计数都直接指示硬件故障
数据流追踪:一次完整的事务
让我们追踪一个测试用例的完整生命周期(以AIE版本为例):
1. 主机初始化阶段
// 加载xclbin到设备
auto xclbin = load_xclbin(dhdl, xclbinFilename);
auto top = reinterpret_cast<const axlf*>(xclbin.data());
// 计算迭代次数
// iterCnt是用户指定的矩阵迭代次数
// graph_itercnt需要转换为"行迭代次数"
graph_itercnt = iterCnt * MAT_ROWS;
// 初始化数据搬运器(可能有多个实例)
for(int i = 0; i < FFT2D_INSTS; ++i) {
dmaHls[i].init(dhdl, top, i, iterCnt);
}
// 初始化AIE图
fft2d_gr.init(dhdl, top, 0);
关键转换:MAT_SIZE_128b = MAT_SIZE / 4(cint16)或 / 2(cfloat)。这是因为128位总线可以并行传输4个cint16样本(每个32位)或2个cfloat样本(每个64位)。
2. 启动阶段
// 先启动AIE图(它会等待数据到达)
fft2d_gr.run(graph_itercnt);
// 再启动数据搬运器(产生实际数据流)
for(int i = 0; i < FFT2D_INSTS; ++i) {
dmaHls[i].run();
}
顺序的重要性:必须先启动AIE图,因为它会阻塞等待输入数据。如果先启动DMA,数据可能在AIE准备好之前到达,导致死锁或数据丢失。
3. 数据传输阶段
数据在硬件中的流动路径:
┌─────────────┐ AXI4-Stream ┌─────────────────┐
│ dma_hls │ ──────────────────> │ FFTrows_graph │
│ (mm2s0) │ 脉冲输入数据 │ (行方向FFT) │
└─────────────┘ └─────────────────┘
│
│ AXI4-Stream
▼
┌─────────────────┐
│ dma_hls │
│(dmaHls_rowsToCols)│
│ 验证+转置+转发 │
└─────────────────┘
│
│ AXI4-Stream
▼
┌─────────────────┐
│ FFTcols_graph │
│ (列方向FFT) │
└─────────────────┘
│
│ AXI4-Stream
▼
┌─────────────────┐
│ dma_hls │
│ (s2mm1) │
│ 最终输出验证 │
└─────────────────┘
4. 完成与验证阶段
// 等待所有DMA实例完成
for(int i = 0; i < FFT2D_INSTS; ++i) {
dmaHls[i].waitTo_complete();
}
// 收集错误计数
uint32_t errCnt = 0;
for(int i = 0; i < FFT2D_INSTS; ++i) {
dmaHls[i].golden_check(&errCnt, i);
}
// 清理资源
fft2d_gr.close();
for(int i = 0; i < FFT2D_INSTS; ++i) {
dmaHls[i].close();
}
错误读取机制:golden_check() 通过 xrtKernelReadRegister(dma_hls_khdl, 0x10, &instance_errCnt) 从HLS内核的 ap_return 寄存器读取错误计数。这要求内核在HLS中被标记为 return 接口。
设计决策与权衡
1. AIE vs HLS:架构选择的权衡
| 维度 | AIE实现 | HLS实现 |
|---|---|---|
| 计算单元 | 20个向量核心(实际计算)+ 52个tiles(存储/连接) | 180个DSP slices |
| 控制复杂度 | 高(需要图编排、物理布局约束) | 中(DATAFLOW自动调度) |
| 扩展性 | 易(增加实例只需更多tiles) | 难(容易出现时序收敛问题) |
| 延迟 | ~3537 μs(1024×2048,10实例) | ~4211 μs |
| 功耗效率 | 1134 MSPS/Watt | 920 MSPS/Watt |
| 资源占用 | 11K FF, 3K LUT | 88K FF, 56K LUT, 250 BRAM |
为什么选择两种实现? 这不是冗余,而是方法论验证。AIE是Versal平台的新特性,许多开发者对其能力和限制缺乏直观认识。通过并行的HLS实现,团队可以:
- 验证AIE结果的正确性(黄金参考)
- 量化AIE带来的实际收益(不仅仅是理论峰值)
- 建立未来项目的决策依据
2. 数据类型支持的编译时选择
#if FFT_2D_DT == 0 // cint16
#define INP_DATA 0X00010001
#define GOLDEN_DATA 0X0001000100010001
#elif FFT_2D_DT == 1 // cfloat
#define INP_DATA 0x3fc000003fc00000 // 1.5 in IEEE 754
#define GOLDEN_DATA 0x3fc000003fc00000
#endif
权衡:使用预处理器条件编译而非模板或运行时选择,是因为:
- HLS友好:HLS编译器需要在编译时知道数据宽度以生成正确的RTL
- 零开销:避免运行时类型检查的开销
- 构建系统简单:Makefile可以通过
-DFFT_2D_DT=0轻松切换
代价是代码重复(cint16和cfloat有独立的代码路径)和可维护性挑战。
3. 隐式转置 vs 显式转置
模块没有独立的"转置"内核,而是在 dmaHls_rowsToCols 中通过改变数据生成模式实现:
// 行FFT输出期望:第一行全1,其余全0
// 列FFT输入生成:每行的第一个元素为1,形成对角线模式
if(i == idx) {
fftCol_inp.data = INP_DATA;
idx += rows; // 跳过rows个元素,形成列方向脉冲
}
优势:
- 消除显式内存缓冲和索引计算
- 减少片上内存需求
- 提高有效带宽(数据不经过DDR)
局限:
- 仅适用于特定的测试模式(脉冲输入)
- 真实应用中可能需要显式转置缓冲区
4. 单实例 vs 多实例的资源分配策略
AIE版本根据实例数量和配置选择不同的物理布局策略:
#if FFT_2D_DT==1
if(FFT_COL_TP_POINT_SIZE >= 1024 && FFT_2D_DT==1 && FFT2D_INSTS==10) {
// 大点数、浮点、多实例:使用宽区域布局
location<graph>(*this) = area_group({{aie_tile,fftCols_grInsts*4, 0,fftCols_grInsts*4+3, 7}, ...});
}
#else
// 整数类型:使用紧凑布局
location<graph>(*this) = area_group({{aie_tile, 5 + fftCols_grInsts*2 , 0, 2*fftCols_grInsts+6, 2}, ...});
#endif
设计洞察: cfloat类型的1024点FFT需要更多的AIE tiles(用于存储旋转因子和中间结果)。10实例配置需要特别宽的布局(每实例4列tiles),以避免资源冲突和路由拥塞。
新贡献者必读:陷阱与注意事项
1. 128位对齐的数据尺寸计算
这是最常见的错误来源。所有通过AXI4-Stream传输的尺寸都必须是128位的倍数:
// 错误:直接使用矩阵尺寸
int matSz = MAT_ROWS * MAT_COLS; // 可能不是128位对齐!
// 正确:根据数据类型调整
#if FFT_2D_DT == 0 // cint16: 16位实部 + 16位虚部 = 32位/样本
#define MAT_SIZE_128b (MAT_SIZE / 4) // 128/32 = 4样本/周期
#elif FFT_2D_DT == 1 // cfloat: 32位实部 + 32位虚部 = 64位/样本
#define MAT_SIZE_128b (MAT_SIZE / 2) // 128/64 = 2样本/周期
#endif
后果:如果尺寸计算错误,DMA会传输错误数量的数据,导致AIE图挂起(等待更多数据)或产生错误结果(数据截断)。
2. AIE图迭代次数的缩放
// 主机指定的iterCnt是"矩阵迭代次数"
int16_t iterCnt = ...; // 例如:16表示处理16个完整矩阵
// AIE图需要的是"行迭代次数"
int16_t graph_itercnt = iterCnt * MAT_ROWS; // 16 * 1024 = 16384
为什么需要这种转换?
AIE图以行为粒度工作,每次 graph.run() 调用处理一行数据。主机以矩阵为粒度思考。如果不进行这种转换,AIE图会在处理完第一行后就停止,而DMA还在发送剩余数据,导致系统死锁。
3. XRT句柄的生命周期管理
// 危险:未检查返回值
xrtRunSetArg(dma_hls_rhdl, 4, MAT_SIZE_128b); // 如果失败,静默继续
// 安全:检查返回值(尽管当前代码没有充分处理)
int rval = xrtRunSetArg(dma_hls_rhdl, 4, MAT_SIZE_128b);
if (rval != 0) {
// 错误处理
}
更严重的问题:load_xclbin 函数在失败时抛出异常,但 main() 中没有try-catch块。这意味着任何初始化失败都会导致程序异常终止,可能留下未关闭的XRT句柄。
4. HLS DATAFLOW的死锁风险
HLS版本的 fft_2d 函数使用DATAFLOW指令:
void fft_2d(...) {
#pragma HLS DATAFLOW
fft_rows(...);
fft_cols(...);
}
潜在问题:fft_rows 和 fft_cols 之间没有直接的FIFO连接(它们通过外部DMA循环数据)。如果DATAFLOW分析器认为这两个调用应该通过内部FIFO连接,而实际上数据流向外部,可能导致调度错误。
缓解措施:当前实现通过注释掉的迭代循环(见 fft_2d.cpp 末尾)表明开发者意识到了这个问题,选择了让DMA控制迭代,而非在HLS内核内部循环。
5. 并发实例的资源竞争
当 FFT2D_INSTS > 1 时,多个DMA实例和AIE图实例共享同一个AIE阵列和PL资源。
必须检查的配置:
- 每个实例的PLIO端口名称必须唯一(代码中使用
fftRows_grInsts*2和fftCols_grInsts*2+1生成唯一ID) - 物理位置约束必须确保实例不重叠(
location<graph>()中的坐标计算) - DMA的AXI4-Stream端口必须在系统配置文件中正确连接到对应的AIE端口
6. 无限运行模式的处理
HLS版本的DMA支持无限运行模式(iterCnt = -1):
// 如果iterCnt是-1,保持无限运行
if(iterCnt == -2) { // 注意:减1后-1变成-2
iterCnt = -1;
}
陷阱:这个逻辑只在HLS版本的 dma_hls.cpp 中实现,AIE版本的 fft_2d_aie_app.cpp 虽然有相关代码(if (iterCnt == -1) graph_itercnt = iterCnt;),但AIE图的 run(-1) 行为可能与预期不同。在使用无限模式前,务必验证AIE图的持续运行能力。
依赖关系
本模块依赖的外部组件
| 依赖 | 用途 | 链接 |
|---|---|---|
fft_ifft_dit_1ch_graph.hpp |
AIE FFT计算内核(DSPLib) | Vitis Libraries |
adf.h |
AIE图定义基础头文件 | Vitis AIE工具链 |
experimental/xrt_aie.h |
XRT AIE运行时API | XRT库 |
hls_fft.h |
HLS FFT IP核 | Vitis HLS |
dma_hls |
数据搬运器内核(PL) | 同目录 pl_src/ |
相关模块
- prime_factor_fft_decomposition_graphs:更复杂的FFT分解策略
- fft2d_aie_vs_hls_scaling_and_system_configs:多实例系统配置细节
- prime_factor_fft_hls_kernels:HLS FFT内核的深入分析
总结
fft2d_aie_vs_hls 模块是一个精心设计的架构对比实验平台。它的价值不仅在于实现了2D-FFT算法,更在于提供了一套公平的、可量化的方法来评估AIE和HLS两种异构计算路径。
对于新加入团队的开发者,理解这个模块的关键是把握其**"控制变量法"的设计哲学**:保持接口一致、保持测试激励一致、保持评估标准一致,从而确保观察到的性能差异真正反映底层架构的特性。在实际工作中,当你面临"应该用AIE还是HLS实现某个功能"的决策时,这个模块的方法论可以直接复用。