🏠

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)   │    │           │  │  │
│  │  └─────────────┘    └─────────────┘    └───────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

架构与数据流

系统架构图

flowchart TB subgraph PL["Programmable Logic"] PL_IN["PLIO Input Channels N_IO equals 16"] PL_OUT["PLIO Output Channels N_IO equals 16"] end subgraph MT["Memory Tiles"] IN_BUF["Input Shared Buffers in_mem"] OUT_BUF["Output Shared Buffers out_mem"] end subgraph AIE["AIE-ML Compute Array"] KERNEL["AIE-ML Kernels fft1k_kernel N_KERS equals 64"] end PL_IN -->|write access with tiling| IN_BUF IN_BUF -->|read access with tiling| KERNEL KERNEL -->|write access| OUT_BUF OUT_BUF -->|read access| PL_OUT

数据流详解

阶段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_graphDevice 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)

演进过程

  1. 初始设计fft1k_128_graph.h):仅使用IO_ILV = 4

    • 64个内核,每个处理2路信号
  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):

  1. 修改POINTS定义
  2. 重新生成旋转因子表(使用support/twiddles/中的脚本)
  3. 调整FFT级数和每级的vectorization参数:
    // 512 = 4^4 * 2,可能需要混合基或改为1024并补零
    

如果你想改变实例数量(例如从128改为64):

  1. 修改N_INST
  2. 检查N_ION_KERS的自动计算是否仍满足约束
  3. 验证内存瓦片的容量是否足够

调试技巧

问题:仿真结果不正确

检查清单:

  1. 旋转因子是否正确生成?(检查fft1k_single_twiddles.h中的值)
  2. Tiling配置的维度是否与缓冲区维度匹配?
  3. REPSKER_ILV的乘积是否能整除N_INST
  4. 输入测试向量的格式是否正确?(应为文本格式的复数对)

问题:性能不达标

诊断步骤:

  1. 检查runtime<ratio>(k_kernel[i]) = 0.9——内核是否有足够的周期预算
  2. 查看AIE仿真器的trace,确认是否存在内存访问冲突
  3. 尝试调整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能被REPSIO_ILVPLIO_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>约束,分散热点缓冲区


参考文档

On this page