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_sender和packet_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) │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
- PLIO(Programmable Logic I/O):AI Engine与PL之间的边界接口,类似于工厂的装卸码头
- nbodySubsystem:可复用的计算单元模板,使用C++模板参数实现空间布局的静态配置
- pktsplit/pktmerge:AI Engine提供的包级数据路由原语,实现单通道到多通道的动态分发
- kernel:实际的计算单元,执行N体物理模拟的核心算法
架构与数据流
系统拓扑图
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中的核心算法分为四个步骤:
- Step 1:从
input_buffer读取32个i粒子的完整状态(7个float:x,y,z,vx,vy,vz,m) - Step 2:基于当前速度和 timestep 预计算新位置(与j粒子无关,可提前计算)
- Step 3:核心计算循环——对每个j粒子批次(32个j粒子),计算它们对所有i粒子的引力贡献
LOOP_COUNT_J = NUM_PARTICLES / NUM_J = 1280 / 32 = 40次迭代- 每次迭代使用
input_async_buffer获取新的j粒子数据
- 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粒子)
关键机制:
-
包分割(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]); -
运行时比例(runtime
) :指定每个内核使用的处理器周期比例runtime<ratio>(nbody_kernel[row]) = 0.7; // 70%算力用于计算 runtime<ratio>(transmit_new_i_kernel[row]) = 0.1; // 10%算力用于数据传输 -
物理位置约束(location
) :明确指定每个内核在AIE阵列中的坐标location<kernel>(nbody_kernel[row]) = tile(COL_OFFSET, ROW_OFFSET+row); -
窗口大小(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=224和PACKET_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::create、connect等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中的实例化和连接逻辑
扩展点
-
增加计算单元:复制
nbody_inst0-9的模式,添加更多实例- 需要更新
NUM_INSTANCES宏 - 需要更新
packet_sender/packet_receiver的端口数
- 需要更新
-
替换计算内核:保持
nbodySubsystem的连接结构,替换nbody.cc的实现- 新内核必须遵守相同的
input_buffer/output_buffer接口 - 窗口大小(
WINDOW_SIZE_I等)可能需要调整
- 新内核必须遵守相同的
-
启用绝对缓冲区约束:取消注释
#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%余量
超额后果:编译器报错或运行时调度不确定。
参考链接
- 父模块:x10_design_packetized_pl_aie_integration — 了解整体系统集成
- 相关模块:pl_kernels_packet_sender — PL侧包发送器详情
- 相关模块:pl_kernels_packet_receiver — PL侧包接收器详情
- 基础版本:baseline_full_system_packet_connectivity — 简化版系统设计