🏠

AIE Graph System 技术深度解析

概述:这个模块解决什么问题?

想象你正在设计一个高性能计算系统,需要模拟1280个粒子在三维空间中的相互作用——这就是经典的N体问题(N-body problem)。每个粒子都受到其他所有粒子的引力影响,计算复杂度为\(O(N^2)\)。当\(N=1280\)时,单次迭代就需要超过160万次两两相互作用计算。

核心挑战:如何在AMD Versal AI Engine的异构架构上高效地并行化这个问题?

aie_graph_system模块是08-n-body-simulator教程中x10设计的核心组件,它展示了一种空间并行化的解决方案:将40个AI Engine内核组织成10个计算单元(Compute Units),每个单元处理32个"i"粒子与全部1280个"j"粒子的相互作用。这种设计不是简单的代码并行化,而是对算法进行架构感知的重新编排——就像把一条装配线拆分成多条并行的子生产线,每条线独立处理一部分产品,但共享原材料供应。

该模块的核心价值在于展示了**数据包交换(Packet Switching)**在AI Engine阵列中的应用:通过PL(Programmable Logic)侧的packet_senderpacket_receiver内核,实现多路数据流的动态路由,从而在有限的物理连接上支持更多的逻辑通道。


心智模型:理解这个系统的关键抽象

类比:分布式工厂的生产调度

把这个系统想象成一个分布式工厂

  • 原材料仓库(DDR内存):存储所有粒子的位置、速度、质量数据
  • 配送中心(mm2s_mp PL内核):从仓库取货,通过传送带分发给各个车间
  • 分拣机器人(packet_sender PL内核):给每批货物贴上目的地标签,分发到10条不同的生产线
  • 生产车间(nbodySubsystem AI Engine图):10个并行车间,每个有4台机器(AI Engine内核),每台处理8个粒子
  • 收集机器人(packet_receiver PL内核):从各车间收集成品,按类型分类
  • 入库中心(s2mm_mp PL内核):将成品送回仓库

核心抽象层

┌─────────────────────────────────────────────────────────────┐
│                    nbodySystem (顶层图)                      │
│  ┌─────────┐  ┌─────────┐        ┌─────────┐  ┌─────────┐   │
│  │in_i[0]  │  │in_i[1]  │  ...   │in_i[9]  │  │  in_j   │   │
│  │(PLIO)   │  │(PLIO)   │        │(PLIO)   │  │ (PLIO)  │   │
│  └────┬────┘  └────┬────┘        └────┬────┘  └────┬────┘   │
│       │            │                  │            │         │
│       ▼            ▼                  ▼            ▼         │
│  ┌──────────────────────────────────────────────────────┐   │
│  │           nbodySubsystem[0..9] (10个实例)              │   │
│  │  每个子系统 = 4个AI Engine内核 + 包分割/合并逻辑        │   │
│  └──────────────────────────────────────────────────────┘   │
│       │            │                  │                      │
│       ▼            ▼                  ▼                      │
│  ┌─────────┐  ┌─────────┐        ┌─────────┐                 │
│  │out_i[0] │  │out_i[1] │  ...   │out_i[9] │                 │
│  │(PLIO)   │  │(PLIO)   │        │(PLIO)   │                 │
│  └─────────┘  └─────────┘        └─────────┘                 │
└─────────────────────────────────────────────────────────────┘
  1. PLIO(Programmable Logic I/O):AI Engine与PL之间的边界接口,类似于工厂的装卸码头
  2. nbodySubsystem:可复用的计算单元模板,使用C++模板参数实现空间布局的静态配置
  3. pktsplit/pktmerge:AI Engine提供的包级数据路由原语,实现单通道到多通道的动态分发
  4. kernel:实际的计算单元,执行N体物理模拟的核心算法

架构与数据流

系统拓扑图

flowchart TB subgraph Host["Host (ARM/x86)"] H[main_xrt.cpp
XRT Runtime控制] end subgraph PL["Programmable Logic (FPGA Fabric)"] M2S[mm2s_mp
Memory-to-Stream] PS[packet_sender
10路包分发] PR[packet_receiver
10→4路聚合] S2M[s2mm_mp
Stream-to-Memory] end subgraph AIE["AI Engine Array (40 kernels)"] subgraph NS0["nbodySubsystem 0-4 (Row 0)"] K0[nbody_kernel x4] T0[transmit_new_i x4] end subgraph NS1["nbodySubsystem 5-9 (Row 4)"] K1[nbody_kernel x4] T1[transmit_new_i x4] end end DDR[(DDR Memory)] H -->|XRT API| M2S H -->|XRT API| PS H -->|XRT API| PR H -->|XRT API| S2M DDR <-->|AXI-MM| M2S M2S -->|axis_pkt| PS PS -->|axis_pkt tx0-tx9| AIE AIE -->|axis_pkt rx0-rx9| PR PR -->|axis_pkt tx0-tx3| S2M S2M <-->|AXI-MM| DDR

端到端数据流详解

1. 输入阶段:分层数据分发

Host (main_xrt.cpp)
    │
    ▼
xrtBOSync(XCL_BO_SYNC_BO_TO_DEVICE)  // 同步输入数据到DDR
    │
    ▼
mm2s_mp (PL Kernel)
    ├── Stream s0 ──► packet_sender.rx  (i粒子数据:10路分包)
    └── Stream s1 ──► ai_engine_0.in_j  (j粒子数据:广播到所有AIE)

mm2s_mp是一个多端口DMA引擎,它将DDR中的数据转换为AXI-Stream格式。注意这里的不对称性

  • in_i数据(目标粒子)需要被分发给10个独立的计算单元 → 经过packet_sender进行包路由
  • in_j数据(源粒子)被广播到所有AI Engine → 直接连接到AIE阵列

这种设计反映了N体问题的数据访问模式:每个计算单元处理不同的"i"粒子子集,但都需要访问相同的"j"粒子全集。

2. 包路由阶段:动态通道分配

// packet_sender.cpp 核心逻辑
for (int cu = 0; cu < NUM_CU; cu++) {        // 10个计算单元
    for (int h = 0; h < PACKET_NUM; h++) {   // 每个单元4个包
        header = generateHeader(PKTTYPE, ID); // 生成包头(含目标ID)
        tx[cu].write(header);                  // 发送包头
        
        for (int i = 0; i < PACKET_LEN; i++) {
            #pragma HLS PIPELINE II=1
            x = rx.read();                     // 从上游读取数据
            tx[cu].write(x);                   // 发送到对应AIE通道
        }
    }
}

packet_sender就像一个智能分拣机:它读取连续的数据流,根据配置的头信息将其分发到10个不同的输出通道。每个包头包含一个5位的ID字段(header(4,0)),AI Engine内部的pktsplit根据这个ID将数据路由到正确的内核。

3. AI Engine计算阶段:四级流水线

nbodySubsystem (每个实例)
    │
    ├── data_in[0] ──► pktsplit ──► nbody_kernel[0..3].in[0]  (i粒子)
    │                                  │
    └── data_in[1] ───────────────────► nbody_kernel[0..3].in[1]  (j粒子,广播)
                                         │
                                         ▼
                              ┌─────────────────────┐
                              │  Step 1: 读取i粒子   │
                              │  Step 2: 预计算新位置 │
                              │  Step 3: 计算加速度  │ ← LOOP_COUNT_J次迭代
                              │  Step 4: 输出结果    │
                              └─────────────────────┘
                                         │
                                         ▼
                              transmit_new_i_kernel[0..3]
                                         │
                                         ▼
                                    pktmerge
                                         │
                                     data_out[0]

nbody.cc中的核心算法分为四个步骤:

  1. Step 1:从input_buffer读取32个i粒子的完整状态(7个float:x,y,z,vx,vy,vz,m)
  2. Step 2:基于当前速度和 timestep 预计算新位置(与j粒子无关,可提前计算)
  3. Step 3:核心计算循环——对每个j粒子批次(32个j粒子),计算它们对所有i粒子的引力贡献
    • LOOP_COUNT_J = NUM_PARTICLES / NUM_J = 1280 / 32 = 40次迭代
    • 每次迭代使用input_async_buffer获取新的j粒子数据
  4. Step 4:将新位置、更新后的速度和质量写入output_buffer

4. 输出聚合阶段:多对一路由

AIE输出 (10路)
    │
    ├── out_i0 ──► packet_receiver.rx0
    ├── out_i1 ──► packet_receiver.rx1
    ...
    └── out_i9 ──► packet_receiver.rx9
    
packet_receiver内部:
    读取包头 → 解析ID → 根据ID映射到tx0-tx3中的一个
    
输出:
    ├── tx0 ──► s2mm_mp.s0 ──► DDR Bank 0
    ├── tx1 ──► s2mm_mp.s1 ──► DDR Bank 1
    ├── tx2 ──► s2mm_mp.s2 ──► DDR Bank 2
    └── tx3 ──► s2mm_mp.s3 ──► DDR Bank 3

这里的关键设计是10→4的汇聚:10个AIE计算单元的输出被聚合到4个S2MM通道。这种设计允许:

  • 更灵活的DDR访问模式(4个独立bank)
  • 减少主机侧需要管理的缓冲区数量

核心组件深度解析

1. nbodySystem —— 顶层图定义

template <int COL_OFFSET>
class nbodySystem : public adf::graph {
    input_plio in_i[NUM_INSTANCES];   // 10个输入PLIO(i粒子)
    input_plio in_j;                   // 1个广播输入PLIO(j粒子)
    output_plio out_i[NUM_INSTANCES];  // 10个输出PLIO
    
    // 10个nbodySubsystem实例,分布在两行(ROW 0和ROW 4)
    nbodySubsystem<COL_OFFSET+0, 0> nbody_inst0;  // Col 0, Row 0
    ...
    nbodySubsystem<COL_OFFSET+4, 0> nbody_inst4;  // Col 4, Row 0
    nbodySubsystem<COL_OFFSET+0, 4> nbody_inst5;  // Col 0, Row 4
    ...
    nbodySubsystem<COL_OFFSET+4, 4> nbody_inst9;  // Col 4, Row 4
};

设计意图

  • 使用模板参数实现编译时的空间布局配置,COL_OFFSET允许整个系统在AIE阵列上水平滑动
  • 两行布局(Row 0和Row 4)利用了AIE阵列的物理结构,中间留出空间用于其他逻辑或布线
  • 每个nbodySubsystem占据一列(Column),4个内核垂直排列在该列内

2. nbodySubsystem —— 可复用计算单元

template <int COL_OFFSET, int ROW_OFFSET>
class nbodySubsystem : public adf::graph {
private:
    kernel nbody_kernel[NUM_ENGINES_PER_COL];           // 4个计算内核
    kernel transmit_new_i_kernel[NUM_ENGINES_PER_COL];  // 4个传输内核
    pktsplit<NUM_ENGINES_PER_COL> pkt_split_i;          // 1→4包分割
    pktmerge<NUM_ENGINES_PER_COL> pkt_merge_i;          // 4→1包合并
    parameter global_particles_i[NUM_ENGINES_PER_COL];  // 全局参数(i粒子初始值)
    parameter global_particles_i_new[NUM_ENGINES_PER_COL];
    parameter global_particles_j[NUM_ENGINES_PER_COL];  // 全局参数(j粒子)

关键机制

  1. 包分割(pktsplit):将输入的单一数据流根据包头ID动态分发到4个内核

    pkt_split_i = pktsplit<NUM_ENGINES_PER_COL>::create();
    connect(data_in[0], pkt_split_i.in[0]);
    connect(pkt_split_i.out[row], nbody_kernel[row].in[0]);
    
  2. 运行时比例(runtime:指定每个内核使用的处理器周期比例

    runtime<ratio>(nbody_kernel[row]) = 0.7;           // 70%算力用于计算
    runtime<ratio>(transmit_new_i_kernel[row]) = 0.1;  // 10%算力用于数据传输
    
  3. 物理位置约束(location:明确指定每个内核在AIE阵列中的坐标

    location<kernel>(nbody_kernel[row]) = tile(COL_OFFSET, ROW_OFFSET+row);
    
  4. 窗口大小(dimensions):声明缓冲区的数据量,用于流量控制和资源分配

    dimensions(nbody_kernel[row].in[0]) = {WINDOW_SIZE_I};  // 224 floats
    dimensions(nbody_kernel[row].in[1]) = {WINDOW_SIZE_J};  // 128 floats
    

3. nbody 内核 —— 核心计算逻辑

这是整个系统中计算最密集的部分,值得逐段分析:

内存布局与向量化

// particles_i布局:[x×32][y×32][z×32][vx×32][vy×32][vz×32][m×32] = 224 floats
float particles_i[(NUM_I*7)];
float particles_i_new[(NUM_I*3)];  // 只存储新的x,y,z
float particles_j[(NUM_J*4)];      // [x×32][y×32][z×32][m×32] = 128 floats

数据以**Structure of Arrays(SoA)**形式存储,而非Array of Structures(AoS)。这是SIMD优化的关键:当计算32个粒子的X坐标时,数据在内存中是连续的,可以使用vector<float,8>一次加载8个float。

核心计算循环

for (int j = 0; j < LOOP_COUNT_J; j++) {  // 40次迭代,每次处理32个j粒子
    w_input_j.acquire();
    // ... 读取j粒子到particles_j ...
    w_input_j.release();
    
    for (int i = 0; i < NUM_I; i++) {     // 对32个i粒子逐个计算
        // Step 3.2.1: 计算dx, dy, dz(向量化)
        for (int jj = 0; jj < NUM_J_VECTORS; jj++)  // 4次向量迭代
            chess_prepare_for_pipelining {
            const vector<float,8> rx = sub(*xj_v8, ts_sf2_i[POS_I_X]);
            // ...
        }
        
        // Step 3.2.2: 计算L2距离和反平方根(向量化)
        for (int jj = 0; jj < NUM_J_VECTORS; jj++) chess_flatten_loop {
            vector<float,8> l2dist = mac(aie::accum<accfloat,8>(sf2), *dx, *dx);
            // ...
            // 手动实现的invsqrt(AIE没有硬件rsqrt指令)
            for (unsigned h = 0; h < 8; ++h) {
                const float e = acc.get(7-h);
                const float r = invsqrt(e);  // 软件近似
                ret.push(r);
            }
        }
        
        // Step 3.2.3: 累加加速度(向量化+归约)
        // ... 复杂的shuffle/transpose操作实现水平求和 ...
    }
}

关键优化

  • chess_prepare_for_pipelining:提示编译器展开循环以实现流水线
  • chess_flatten_loop:将嵌套循环展平,增加指令级并行
  • __restrict关键字:承诺指针不别名,允许激进的向量化

异步缓冲区(async_buffer)的使用

void nbody(
    input_buffer<float> & __restrict w_input_i,        // 同步输入
    input_async_buffer<float> & __restrict w_input_j,  // 异步输入
    output_buffer<float> & __restrict w_output_i
)

input_async_buffer是关键的双缓冲机制:当一个内核正在处理第\(j\)批j粒子时,DMA引擎可以在后台预取第\(j+1\)批。这隐藏了内存延迟,使计算流水线保持满负荷。

4. packet_sender / packet_receiver —— PL侧包路由

这两个HLS内核实现了时分复用的多路数据分发:

包头格式(32位)

Bit[4:0]   : ID(目标标识符)
Bit[11:5]  : 保留
Bit[14:12] : Packet Type
Bit[15]    : 保留
Bit[20:16] : Source Row(-1表示未指定)
Bit[27:21] : Source Column(-1表示未指定)
Bit[30:28] : 保留
Bit[31]    : 奇偶校验位

为什么需要包交换?

AI Engine阵列的物理连接是有限的。如果没有包交换,每个AIE计算单元需要独立的物理通道连接到PL侧,这会迅速耗尽宝贵的布线资源。包交换允许:

  • 逻辑通道数 >> 物理通道数
  • 动态路由:同一物理通道在不同时刻承载不同逻辑流的数据
  • 可扩展性:增加计算单元不需要增加物理连接

设计决策与权衡

1. 空间并行 vs 时间并行

选择:使用10个并行的nbodySubsystem实例,每个处理32个i粒子

替代方案:单个AIE内核顺序处理所有320个i粒子(10×32),利用时间流水线

权衡分析

维度 空间并行(当前设计) 时间串行
吞吐量 高(40个内核并行) 低(单核顺序)
资源占用 40个AIE tiles 1个AIE tile
数据局部性 j粒子需要在10个单元间广播 j粒子只需加载一次
控制复杂度 需要包路由逻辑 简单线性流
可扩展性 增加单元需更多PL资源 固定资源

决策理由:N体问题是计算密集型\(O(N^2)\)),而非内存密集型。40倍的内核并行带来的加速远超广播开销的成本。

2. 同步 vs 异步j粒子输入

选择input_async_buffer用于j粒子,input_buffer用于i粒子

理由

  • i粒子是一次性加载的(32个粒子×7个属性=224 floats),数据量小且访问模式规则
  • j粒子需要循环加载40次(1280粒子 / 32每批),使用异步缓冲可以重叠计算与通信

3. 包路由的粒度

选择:在PL侧进行粗粒度包路由(整个224-word包),AIE侧进行细粒度分发(8-word向量)

理由

  • PL侧适合处理粗粒度、不规则的路由决策(基于包头ID)
  • AIE侧适合处理细粒度、规则的数据并行(SIMD向量运算)
  • 这种分层保持了各自的优势域

4. 数据类型:float vs int

观察:注意到particles_i使用float,但data_i在host侧使用int存储

// host_sw/host/nbody.cpp
int data_int = reinterpret_cast<int &>(data_float);  // float→int的bitwise转换

理由:这是一种无损传输技巧。AI Engine的PLIO接口在某些配置下对整数类型更高效,而IEEE 754 float的bitwise表示可以直接作为int传输,在AIE端reinterpret回float。


依赖关系与接口契约

上游依赖(调用本模块)

组件 角色 契约
pl_kernels_packet_sender 数据注入 必须按照PACKET_LEN=224PACKET_NUM=4的格式发送数据,包头包含有效的ID字段
pl_kernels_packet_receiver 数据收集 期望接收带包头的数据流,将根据ID路由到4个输出通道
baseline_full_system_packet_connectivity 系统集成 提供完整的connectivity配置,确保数据流正确连接

下游依赖(本模块调用)

组件 角色 契约
aie_api/aie.hpp 向量运算 使用aie::vector<float,8>aie::mac等 intrinsic
adf.h 图构建 继承adf::graph,使用kernel::createconnect等API

数据契约

输入数据格式(in_i)

Offset 0-31:   X坐标(32个float,每粒子一个)
Offset 32-63:  Y坐标
Offset 64-95:  Z坐标
Offset 96-127: X速度
Offset 128-159: Y速度
Offset 160-191: Z速度
Offset 192-223: 质量
Total: 224 floats per packet

输入数据格式(in_j)

Offset 0-31:   X坐标
Offset 32-63:  Y坐标
Offset 64-95:  Z坐标
Offset 96-127: 质量
Total: 128 floats per batch,共40 batches

输出数据格式(out_i)

与输入in_i格式相同(224 floats),但数值已更新为新时间步的状态。


使用指南与扩展点

基本用法

// 创建图实例(指定列偏移)
#define COL_OFFSET 0
nbodySystem<COL_OFFSET> myGraph;

// 在仿真环境中运行
int main(void) {
    myGraph.init();    // 初始化图
    myGraph.run(1);    // 运行1次迭代
    myGraph.end();     // 结束并清理
    return 0;
}

配置参数修改

关键参数在include.h中定义:

#define NUM_ENGINES_PER_COL  4    // 每列内核数(受AIE列高度限制)
#define NUM_I                32   // 每内核处理的i粒子数
#define NUM_PARTICLES        1280 // 总j粒子数
#define NUM_J                32   // 每批j粒子数

修改这些参数的影响

  • 增加NUM_I:增加每内核计算量,可能提高利用率,但需要更大的本地内存
  • 增加NUM_PARTICLES:增加总计算量,需要调整host侧的输入数据生成
  • 修改NUM_ENGINES_PER_COL必须同步修改nbodySystem中的实例化和连接逻辑

扩展点

  1. 增加计算单元:复制nbody_inst0-9的模式,添加更多实例

    • 需要更新NUM_INSTANCES
    • 需要更新packet_sender/packet_receiver的端口数
  2. 替换计算内核:保持nbodySubsystem的连接结构,替换nbody.cc的实现

    • 新内核必须遵守相同的input_buffer/output_buffer接口
    • 窗口大小(WINDOW_SIZE_I等)可能需要调整
  3. 启用绝对缓冲区约束:取消注释#define ABS_BUFFER_CONSTRAINTS

    • 这将强制指定每个缓冲区的具体bank位置
    • 用于解决特定的布线或时序问题

边缘情况与注意事项

1. 包ID冲突

风险:如果packet_sender发送的包ID与AIE侧pktsplit的期望不匹配,数据将被错误路由。

缓解packet_ids_c.h由AIE编译器自动生成,包含从PLIO名称派生的ID。确保PL侧和AIE侧使用同一版本的此文件。

2. 死锁风险

场景:如果packet_receiver的输出通道(tx0-tx3)被阻塞,AIE侧的pktmerge将无法写入,进而导致nbody_kernel阻塞在w_output_i写入上,最终使整个流水线停滞。

检测:在仿真环境中监控pktmerge的缓冲区水位;在硬件中使用Vitis Analyzer检查stall信号。

3. 浮点精度差异

现象:AI Engine的invsqrt使用软件近似(Newton-Raphson迭代),与host侧标准库的1/sqrt()可能存在微小差异。

验证main_xrt.cpp中的golden数据检查使用bitwise XOR比较,允许一定的误差容忍度。

4. 内存对齐要求

约束particles_i等数组使用chess_storage(%chess_alignof(v8float))对齐到8-float(32字节)边界。

违规后果:未对齐的向量加载将导致未定义行为或性能急剧下降。

5. 运行时比例总和

约束:同一tile上的所有内核的runtime<ratio>之和不应超过1.0(100%)。

runtime<ratio>(nbody_kernel[row]) = 0.7;           // 70%
runtime<ratio>(transmit_new_i_kernel[row]) = 0.1;  // 10%
// 合计: 80%,留有20%余量

超额后果:编译器报错或运行时调度不确定。


参考链接

On this page