api_based_fft_multi_instance_graph 模块深度解析
概述:这个模块解决什么问题?
想象你正在设计一个雷达信号处理系统,需要同时处理128路独立的信号流,每路信号以125 MSa/s的速率采样。这相当于每秒要处理160亿个样本,总带宽高达64 GB/s。传统的做法是使用可编程逻辑(PL)中的大量DSP切片和BRAM来实现FFT运算,但这会消耗大量的功耗和芯片面积。
api_based_fft_multi_instance_graph 模块展示了一种更优雅的解决方案:它利用AIE-ML(AI Engine ML)阵列的计算能力和内存瓦片(Memory Tiles)的可编程数据路由功能,通过空间-时间交织技术,将128路信号的FFT计算高效地映射到数量远少于128的AIE-ML核心上。这种设计的核心洞察是:与其为每路信号分配一个专用计算单元,不如让一个计算单元以足够高的吞吐量串行处理多路信号,同时利用内存瓦片的灵活寻址能力来管理数据的并行流入和流出。
该模块实现了1024点FFT的高吞吐量计算,采用基-4(radix-4)Cooley-Tukey算法的Stockham变体,通过AIE API进行向量化实现。它是Vitis-Tutorials生态系统中AIE-ML设计教程的重要组成部分,展示了如何构建生产级的多实例信号处理流水线。
心智模型:理解这个系统的正确方式
类比:高速公路收费站系统
将这个系统想象成一个智能高速公路收费站网络:
- 128路输入信号 = 128条并行的车道,每条车道有车辆(样本)以固定速率驶入
- PLIO接口 = 收费站的入口闸机,负责将多条车道的车辆合并到较少的物理通道中
- 内存瓦片(Shared Buffer) = 大型停车场,按照特定规则暂存和组织车辆
- AIE-ML核心(Kernel) = 收费亭,处理(FFT变换)车辆
- Tiling配置 = 停车场的调度算法,决定车辆如何停放、如何被取出
关键的设计智慧在于:通过精心设计的"停车规则"(tiling),我们可以让少数收费亭高效地服务大量车道,而不需要为每条车道建造一个收费亭。
核心抽象层次
┌─────────────────────────────────────────────────────────────┐
│ dut_graph (顶层图) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ fft1k_128_graph (计算图) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ │
│ │ │ PLIO Input │───▶│ Shared │───▶│ AIE-ML │ │ │
│ │ │ (N_IO个) │ │ Buffer │ │ Kernel │ │ │
│ │ │ │ │ (in_mem) │ │ (N_KERS个)│ │ │
│ │ └─────────────┘ └─────────────┘ └─────┬─────┘ │ │
│ │ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────▼─────┐ │ │
│ │ │ PLIO Output │◀───│ Shared │◀───│ AIE-ML │ │ │
│ │ │ (N_IO个) │ │ Buffer │ │ Kernel │ │ │
│ │ │ │ │ (out_mem) │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └───────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
架构与数据流
系统架构图
数据流详解
阶段1:PL到内存瓦片的数据注入
输入数据从可编程逻辑通过PLIO接口进入系统。dut_graph类创建了N_IO个输入PLIO通道(在基础配置中为16个):
// 来自 fft1k_128_graph.cpp
din[i] = input_plio::create(plio_i, plio_64_bits, file_i);
connect(din[i].out[0], fft_graph.din[i]);
每个PLIO通道以64位宽度运行,每次传输2个cint16样本(PLIO_WIDTH = 2)。由于IO_ILV = 4,这意味着每个PLIO通道实际上承载了4路交错信号的样本。
阶段2:内存瓦片的写访问模式
数据进入shared_buffer时,write_access的tiling配置决定了数据如何在内存中组织:
// 来自 fft1k_128_graph.h
write_access(in_mem[i].in[0]) =
tiling({
.buffer_dimension = {PLIO_WIDTH, IO_ILV, POINTS}, // {2, 4, 1024}
.tiling_dimension = {PLIO_WIDTH, IO_ILV, POINTS}, // 完整写入
.offset = {0, 0, 0},
.tile_traversal = {
{.dimension=0, .stride=PLIO_WIDTH, .wrap=1}, // 维度0: 批次
{.dimension=1, .stride=IO_ILV, .wrap=1}, // 维度1: 交错
{.dimension=2, .stride=POINTS, .wrap=1} // 维度2: FFT点数
}
});
这个3D缓冲区可以看作是一个2×4×1024的数组,其中:
- 维度0(2):每个时钟周期传输的样本批次
- 维度1(4):4路交错的信号实例
- 维度2(1024):每路信号的FFT点数
阶段3:内核读取与计算
AIE-ML内核通过read_access以不同的tiling模式读取数据:
read_access(in_mem[i].out[cur]) =
tiling({
.buffer_dimension = {PLIO_WIDTH, IO_ILV, POINTS},
.tiling_dimension = {1, 1, POINTS}, // 每次读取1路信号的1024点
.offset = {REPS*j, k, 0}, // 根据内核索引偏移
.tile_traversal = {
{.dimension=2, .stride=POINTS, .wrap=1}, // 先读完所有点
{.dimension=0, .stride=1, .wrap=REPS}, // 再处理批次内
{.dimension=1, .stride=1, .wrap=1}
}
});
这里的关键是遍历顺序的差异:PLIO按"批次→交错→点数"的顺序写入,而内核按"点数→批次→交错"的顺序读取。这种差异使得单个内核能够连续处理完整的1024点FFT,而不需要在不同信号间频繁切换。
阶段4:FFT计算(内核内部)
fft1k_kernel实现了5级基-4 FFT(因为\(1024 = 4^5\)):
// 来自 fft1k_single_kernel.cpp
for (int i=0; i < REPEAT; i++) {
chess_prepare_for_pipelining
chess_loop_range(REPEAT,)
{
aie::fft_dit_r4_stage<256>(in_data, tw0_1, tw0_0, tw0_2, N, SHIFT_TW, SHIFT_DT, INVERSE, out_data);
aie::fft_dit_r4_stage<64>(out_data, tw1_1, tw1_0, tw1_2, N, SHIFT_TW, SHIFT_DT, INVERSE, in_data);
aie::fft_dit_r4_stage<16>(in_data, tw2_1, tw2_0, tw2_2, N, SHIFT_TW, SHIFT_DT, INVERSE, out_data);
aie::fft_dit_r4_stage<4>(out_data, tw3_1, tw3_0, tw3_2, N, SHIFT_TW, SHIFT_DT, INVERSE, in_data);
aie::fft_dit_r4_stage<1>(in_data, tw4_1, tw4_0, tw4_2, N, SHIFT_TW, SHIFT_DT, INVERSE, out_data);
ibuff += N;
obuff += N;
}
}
注意奇数级数(5级)的巧妙之处:由于级数为奇数,输入输出缓冲区的ping-pong切换天然地完成了中间结果的存储,无需额外的临时缓冲区。如果级数为偶数,则需要一个辅助缓冲区来保存最后一级的中间结果。
阶段5:输出回流
计算完成后,数据沿着对称的路径返回:内核写入输出共享缓冲区 → 输出PLIO读取 → 返回可编程逻辑。
核心组件深度解析
1. dut_graph 类(测试包装器)
文件: fft1k_128_graph.cpp
这是系统的顶层入口,负责将fft1k_128_graph连接到PLIO接口,并提供仿真/测试环境。
class dut_graph : public graph {
public:
input_plio din[N_IO]; // N_IO个输入PLIO
output_plio dout[N_IO]; // N_IO个输出PLIO
fft1k_128_graph fft_graph; // 实际的FFT计算图
dut_graph(void) {
for(int i=0; i<N_IO; i++){
// 创建PLIO,关联到验证文件
din[i] = input_plio::create(plio_i, plio_64_bits, file_i);
dout[i] = output_plio::create(plio_o, plio_64_bits, file_o);
// 连接PLIO到计算图
connect(din[i].out[0], fft_graph.din[i]);
connect(fft_graph.dout[i], dout[i].in[0]);
}
}
};
设计意图:
dut_graph是Device Under Test的缩写,表明这是一个测试平台包装器- 它将底层的计算图与具体的I/O实现解耦,使得
fft1k_128_graph可以被复用到不同的系统集成场景中 - 验证文件路径(
src/verif_i_128/PLIO_i[...].txt)表明这是用于x86仿真的测试向量
2. fft1k_128_graph 类(基础版本)
文件: fft1k_128_graph.h
这是核心的计算图定义,展示了AIE-ML内存瓦片的基本用法。
关键参数推导
#define N_INST 128 // 总信号实例数
#define PLIO_WIDTH 2 // 64位PLIO = 2个cint16样本
#define IO_ILV 4 // I/O交错因子
#define N_KERS N_INST/REPS // 64个内核 (128/2)
#define N_IO N_INST/(IO_ILV*PLIO_WIDTH) // 16个PLIO通道 (128/(4*2))
资源分配逻辑:
- 128路信号需要处理
- 每个内核通过
REPS = 2批处理2路信号 → 需要64个内核 - 每4路信号通过
IO_ILV = 4交错到1个PLIO通道 - 每个PLIO通道承载
PLIO_WIDTH = 2个样本/周期 - 因此需要
128/(4*2) = 16个PLIO通道
内存瓦片配置
in_mem[i] = shared_buffer<cint16>::create(
{PLIO_WIDTH, IO_ILV, POINTS}, // 维度: {2, 4, 1024}
1, // 生产者数量 (PLIO)
int(PLIO_WIDTH*IO_ILV/REPS) // 消费者数量 (8个内核读取端口)
);
num_buffers(in_mem[i]) = 2; // Ping-pong缓冲实现流水线
为什么需要Ping-pong缓冲:当PLIO正在向缓冲区A写入下一帧数据时,内核可以从缓冲区B读取当前帧数据进行计算。这使得数据传输和计算可以并行进行,消除空闲等待。
3. fft1k_128_graph 类(优化版本)
文件: fft1k_128_new_graph.h
这是经过优化的版本,引入了额外的内核级交错因子KER_ILV。
新增参数
#define KER_ILV 4 // 内核级交错因子
#define N_KERS N_INST/(REPS*KER_ILV) // 16个内核 (128/(2*4))
#define MAX_BUF 3 // 缓冲区位置约束
优化效果:
- 内核数量从64减少到16(4倍减少)
- 每个内核现在处理
REPS * KER_ILV = 8路信号 - 缓冲区维度扩展到4D:
{PLIO_WIDTH, IO_ILV/KER_ILV, KER_ILV, POINTS}={2, 1, 4, 1024}
位置约束(Location Constraints)
if(MAX_BUF != 0){
if(i%MAX_BUF>0){
location<buffer>(in_mem[i]) = location<buffer>(in_mem[i-1]) + relative_offset({.col_offset = 0, .row_offset = 0});
location<buffer>(out_mem[i]) = location<buffer>(out_mem[i-1]) + relative_offset({.col_offset = 0, .row_offset = 0});
}
location<buffer>(out_mem[i]) = location<buffer>(in_mem[i]) + relative_offset({.col_offset = 0, .row_offset = 0});
}
这段代码控制内存瓦片的物理布局:
MAX_BUF = 3表示每3个缓冲区尝试放置在同一内存瓦片上- 输入和输出缓冲区成对放置,减少路由延迟
- 这些约束帮助编译器生成更高效的物理实现
4. fft1k_kernel 类
文件: fft1k_single_kernel.h, fft1k_single_kernel.cpp
这是实际的FFT计算内核,使用AIE API实现向量化计算。
模板参数设计
typedef cint16 TT_DATA;
typedef cint16 TT_TWID;
static constexpr unsigned N = POINTS; // 1024点
static constexpr unsigned SHIFT_TW = 15; // 旋转因子右移位数
static constexpr unsigned SHIFT_DT = 15; // 数据右移位数
static constexpr bool INVERSE = false; // 正向FFT
static constexpr unsigned REPEAT = REPS; // 批处理数量
static constexpr unsigned BUF_SIZE = N * REPEAT;// 缓冲区大小
定点数缩放策略:
SHIFT_TW = 15:旋转因子使用Q15格式(16位有符号数表示-1到0.9999的范围)SHIFT_DT = 15:每级FFT后数据右移15位,防止定点溢出- 5级FFT总共需要约75位的动态范围,实际应用中需要根据信号特性调整
旋转因子布局
alignas(aie::vector_decl_align) static constexpr TT_TWID tw0_0[1] = TWID0_0;
alignas(aie::vector_decl_align) static constexpr TT_TWID tw1_0[4] = TWID1_0;
alignas(aie::vector_decl_align) static constexpr TT_TWID tw2_0[16] = TWID2_0;
alignas(aie::vector_decl_align) static constexpr TT_TWID tw3_0[64] = TWID3_0;
alignas(aie::vector_decl_align) static constexpr TT_TWID tw4_0[256] = TWID4_0;
旋转因子的数量遵循基-4 FFT的规律:第n级有\(4^{n-1}\)个旋转因子(第一级除外,因为只有DC分量)。alignas(aie::vector_decl_align)确保数据对齐到AIE向量单元要求的边界,以实现高效的SIMD加载。
运行时初始化
fft1k_kernel::fft1k_kernel(void)
{
aie::set_rounding(aie::rounding_mode::positive_inf);
aie::set_saturation(aie::saturation_mode::saturate);
}
构造函数设置了定点运算的舍入和饱和模式:
- Positive infinity rounding:向正无穷舍入,在信号处理中通常能提供更好的SNR
- Saturation mode:溢出时饱和到最大/最小值,而不是回绕,避免灾难性的数值错误
依赖关系分析
上游依赖(谁调用本模块)
| 依赖项 | 关系类型 | 说明 |
|---|---|---|
| Vitis Platform Creation Tutorials Makefile | 构建依赖 | 提供AIE图的编译和链接基础设施 |
| x86 Simulator / AIE Simulator | 运行时依赖 | 执行main()函数中的init(), run(), end()序列 |
下游依赖(本模块调用谁)
| 依赖项 | 关系类型 | 说明 |
|---|---|---|
adf.h / adf/new_frontend/adf.h |
核心框架 | AMD AI Engine开发框架头文件 |
aie_api/aie.hpp |
计算库 | AIE API,提供fft_dit_r4_stage等向量化原语 |
fft1k_single_twiddles.h |
数据依赖 | 预计算的旋转因子表 |
模块在系统中的角色
┌─────────────────────────────────────────────────────────────────┐
│ 系统层次结构 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 4: 应用层 (Host Application) │
│ - 配置DMA传输 │
│ - 启动/停止图执行 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 3: 系统集成层 (System Integration) │
│ - PL DMA kernels │
│ - 中断处理 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 2: 图编排层 ← dut_graph (本模块顶层) │
│ - PLIO连接 │
│ - 测试向量注入 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 1: 计算图层 ← fft1k_128_graph (本模块核心) │
│ - 内存瓦片配置 │
│ - 内核实例化 │
│ - 数据路由 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 0: 内核层 ← fft1k_kernel (本模块计算核心) │
│ - FFT算法实现 │
│ - 向量化计算 │
└─────────────────────────────────────────────────────────────────┘
设计决策与权衡
决策1:基-4 vs 基-2 FFT
选择:使用基-4算法,5级完成1024点FFT
权衡分析:
| 方案 | 级数 | 乘法次数 | 控制开销 | 结论 |
|---|---|---|---|---|
| 基-2 | 10级 | 更多 | 更高 | ❌ 更多API调用开销 |
| 基-4 | 5级 | 较少 | 较低 | ✅ 更优的选择 |
| 混合基 | 可变 | 最少 | 中等 | ⚠️ 复杂度较高 |
深层原因:AIE API的调用本身有固定开销,减少级数能显著提升效率。此外,基-4算法有更多"平凡乘法"(乘以1或-1),进一步减少了实际计算量。
决策2:奇数级数的刻意选择
观察:代码注释中提到// Unused if odd number of stages
设计洞察:Stockham FFT是非原地算法,通常需要临时缓冲区存储中间结果。但当级数为奇数时,输入和输出缓冲区的ping-pong切换天然形成正确的数据流:
级数=5(奇数):
Stage 0: in_buf → out_buf
Stage 1: out_buf → in_buf
Stage 2: in_buf → out_buf
Stage 3: out_buf → in_buf
Stage 4: in_buf → out_buf ✓ 最终结果在out_buf
级数=4(偶数):
Stage 0: in_buf → out_buf
Stage 1: out_buf → temp_buf ← 需要额外缓冲区!
Stage 2: temp_buf → in_buf
Stage 3: in_buf → out_buf ✓
这种设计节省了宝贵的本地内存,使得更多的内存可以用于批处理(REPS)。
决策3:两级交错(IO_ILV + KER_ILV)
演进过程:
-
初始设计(
fft1k_128_graph.h):仅使用IO_ILV = 4- 64个内核,每个处理2路信号
-
优化设计(
fft1k_128_new_graph.h):增加KER_ILV = 4- 16个内核,每个处理8路信号
权衡:
- ✅ 优势:内核数量减少75%,显著降低功耗和面积
- ⚠️ 代价:单个内核利用率提高,可能接近100%占用
- ⚠️ 代价:更高的内存带宽需求,需要更精细的缓冲区管理
决策4:内存所有权与RAII
模式观察:代码中没有显式的new/delete,而是依赖AIE框架的资源管理:
// 不是原始指针,而是框架管理的对象
kernel k_kernel[N_KERS];
shared_buffer<cint16> in_mem[N_IO], out_mem[N_IO];
设计理由:
- AIE图在编译时静态确定资源分配,不需要运行时动态内存管理
shared_buffer::create()返回的是句柄而非原始指针,生命周期由图对象管理- 这种设计避免了内存泄漏和悬空指针问题,符合嵌入式系统的确定性要求
使用指南与最佳实践
修改参数时的注意事项
如果你想改变FFT点数(例如从1024改为512):
- 修改
POINTS定义 - 重新生成旋转因子表(使用
support/twiddles/中的脚本) - 调整FFT级数和每级的vectorization参数:
// 512 = 4^4 * 2,可能需要混合基或改为1024并补零
如果你想改变实例数量(例如从128改为64):
- 修改
N_INST - 检查
N_IO和N_KERS的自动计算是否仍满足约束 - 验证内存瓦片的容量是否足够
调试技巧
问题:仿真结果不正确
检查清单:
- 旋转因子是否正确生成?(检查
fft1k_single_twiddles.h中的值) - Tiling配置的维度是否与缓冲区维度匹配?
REPS和KER_ILV的乘积是否能整除N_INST?- 输入测试向量的格式是否正确?(应为文本格式的复数对)
问题:性能不达标
诊断步骤:
- 检查
runtime<ratio>(k_kernel[i]) = 0.9——内核是否有足够的周期预算 - 查看AIE仿真器的trace,确认是否存在内存访问冲突
- 尝试调整
MAX_BUF参数,优化内存瓦片的物理布局
扩展方向
添加窗口函数:
可以在fft1k_kernel::run()的开头添加逐点乘法,实现Hanning/Hamming窗:
void run(...) {
// 添加窗函数
for (int n = 0; n < N; n++) {
ibuff[n] = aie::mul(ibuff[n], window_coeffs[n]);
}
// ... 原有FFT代码
}
支持逆FFT:
修改INVERSE模板参数为true,并交换输入输出的读写模式。
边缘情况与陷阱
1. 整数除法的隐含假设
#define N_KERS N_INST/REPS
#define N_IO N_INST/(IO_ILV*PLIO_WIDTH)
风险:这些宏没有括号保护,如果在复杂表达式中使用可能导致优先级错误。
建议:始终确保N_INST能被REPS、IO_ILV和PLIO_WIDTH整除,否则会出现未定义行为。
2. 缓冲区越界访问
connect(in_mem[i].out[cur], k_kernel[int(i*(PLIO_WIDTH*IO_ILV/REPS)+cur)].in[0]);
风险:cur的循环范围和内核索引计算必须严格匹配。如果MAX_BUF设置不当导致某些连接失败,可能在编译期不报错,但在运行时出现死锁。
3. 定点溢出的静默失败
症状:大信号输入时输出出现伪影
原因:SHIFT_DT = 15意味着每级除以32768。对于接近满幅度的输入,5级累积的衰减可能不够。
解决方案:根据输入信号的统计特性调整SHIFT_DT,或在输入端添加预缩放。
4. 内存瓦片的bank冲突
症状:性能比理论值低20-30%
原因:多个内核同时访问同一内存bank
诊断:使用AIE仿真器的array view检查内存访问模式
缓解:调整location<buffer>约束,分散热点缓冲区