🏠

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加一次矩阵转置。模块将这个计算过程分解为三个明确的阶段:

  1. 行方向FFT:对输入矩阵的每一行执行1D-FFT
  2. 中间转置:将行FFT的输出重新组织为列优先格式
  3. 列方向FFT:对转置后的矩阵再次执行1D-FFT

在实际的硬件实现中,转置操作被巧妙地融入了数据搬运器的逻辑中——它不是显式的内存重排,而是通过改变数据读取模式来实现的。这种"隐式转置"策略避免了额外的内存访问开销。


架构设计与数据流

系统架构图

flowchart TB subgraph Host["Host Application (ARM A72)"] main["main()"] datamover_cls["datamover class"] fft2d_graph_cls["fft2d_hostapp_graph class
(仅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_khdldma_hls_rhdlxrtPLKernelOpenExclusive()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*4fftCols_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_rowfftRowwriteOut_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_rowsfft_cols 之间没有直接的FIFO连接(它们通过外部DMA循环数据)。如果DATAFLOW分析器认为这两个调用应该通过内部FIFO连接,而实际上数据流向外部,可能导致调度错误。

缓解措施:当前实现通过注释掉的迭代循环(见 fft_2d.cpp 末尾)表明开发者意识到了这个问题,选择了让DMA控制迭代,而非在HLS内核内部循环。

5. 并发实例的资源竞争

FFT2D_INSTS > 1 时,多个DMA实例和AIE图实例共享同一个AIE阵列和PL资源。

必须检查的配置

  • 每个实例的PLIO端口名称必须唯一(代码中使用 fftRows_grInsts*2fftCols_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/

相关模块


总结

fft2d_aie_vs_hls 模块是一个精心设计的架构对比实验平台。它的价值不仅在于实现了2D-FFT算法,更在于提供了一套公平的、可量化的方法来评估AIE和HLS两种异构计算路径。

对于新加入团队的开发者,理解这个模块的关键是把握其**"控制变量法"的设计哲学**:保持接口一致、保持测试激励一致、保持评估标准一致,从而确保观察到的性能差异真正反映底层架构的特性。在实际工作中,当你面临"应该用AIE还是HLS实现某个功能"的决策时,这个模块的方法论可以直接复用。

On this page