x10_design_packetized_pl_aie_integration 模块技术深度解析
概述:为什么需要这个模块?
想象你正在运营一个大型物流分拣中心。你有10条并行的高速传送带(AIE计算单元),每条传送带都需要接收来自上游的包裹(粒子数据),处理后再将结果送出。问题在于:如何高效地将大量包裹分发到正确的传送带上,又如何将处理后的包裹重新汇集?
这就是 x10_design_packetized_pl_aie_integration 模块要解决的核心问题——在Versal ACAP架构中实现PL(可编程逻辑)与AIE(AI引擎)之间的高效、可扩展数据流调度。该模块是N体仿真器教程的进阶实现,展示了如何通过**包交换(packet switching)**机制,将单个AIE设计实例扩展到10个并行计算单元(Compute Units),实现性能的线性扩展。
核心挑战
- 数据分发瓶颈:传统的一对一连接方式在扩展时会消耗大量的PL-AIE物理连接资源
- 路由复杂性:多个计算单元需要动态地将数据路由到正确的目的地
- 同步与对齐:确保10个并行单元的输入输出时序一致
- 可扩展性:从1个单元扩展到10个单元时,保持代码和架构的一致性
心智模型:把这个系统想象成什么?
类比:智能邮政分拣系统
┌─────────────────────────────────────────────────────────────────────────────┐
│ 智能邮政分拣系统类比 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ [中央仓库] [分拣中心A] [10个区域邮局] [汇集中心] │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ mm2s_mp │──────▶│ packet_ │───────▶│ AIE │───────▶│ packet_ │ │
│ │ (DMA) │ │ sender │ │ nbody×10 │ │ receiver │ │
│ └─────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ ▲ │ │
│ │ │ │ │
│ └────────────────────┘ ▼ │
│ ┌──────────┐ │
│ │ s2mm_mp │ │
│ │ (DMA) │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
mm2s_mp:中央仓库的发货员,负责从内存读取包裹并发送出去packet_sender:智能分拣机器人,根据包裹标签(packet header)将包裹分发到10个不同的区域邮局- AIE nbody×10:10个并行的区域邮局,每个都有4个工作人员(4个AIE核)协作处理包裹
packet_receiver:汇集中心的分类员,从10个邮局收集处理后的包裹,按目的地重新打包s2mm_mp:最终入库员,将汇集的包裹写回内存
核心抽象层
| 层级 | 组件 | 职责 |
|---|---|---|
| Host层 | main_xrt.cpp, nbody.cpp |
设备管理、数据准备、结果验证 |
| PL数据搬运层 | mm2s_mp, s2mm_mp |
DDR ↔ PL之间的DMA数据传输 |
| 包路由层 | packet_sender, packet_receiver |
基于包头的动态路由与分发 |
| AIE计算层 | nbodySystem Graph, nbodySubsystem |
10×4=40个AIE核的并行N体计算 |
| Kernel层 | nbody.cc, transmit_new_i.cc |
单个AIE核的粒子力学计算 |
架构详解与数据流
整体架构图
详细数据流追踪
1. 输入数据流(Host → AIE)
Step 1: Host准备数据
┌─────────────────────────────────────────────────────────────┐
│ main_xrt.cpp │
│ - 分配XRT缓冲区 (xrtBOAlloc) │
│ - 填充粒子数据 (generateDataI/generateDataJ) │
│ - 同步到设备 (xrtBOSync XCL_BO_SYNC_BO_TO_DEVICE) │
└─────────────────────────────────────────────────────────────┘
│
▼
Step 2: DMA发起传输
┌─────────────────────────────────────────────────────────────┐
│ mm2s_mp (Memory to Stream Multi-Port) │
│ - 从DDR读取数据 │
│ - 通过AXI4-Stream发送到packet_sender │
│ - s0端口: 粒子i数据, s1端口: 粒子j数据 │
└─────────────────────────────────────────────────────────────┘
│
▼
Step 3: 包分发
┌─────────────────────────────────────────────────────────────┐
│ packet_sender │
│ - 接收连续数据流 (rx端口) │
│ - 为每个CU生成包头 (generateHeader) │
│ - 轮询分发到10个输出流 (tx0-tx9) │
│ - 每个包包含224个32位字 │
└─────────────────────────────────────────────────────────────┘
│
▼
Step 4: AIE接收与拆分
┌─────────────────────────────────────────────────────────────┐
│ nbodySystem / pktsplit │
│ - 每个tx端口连接到一个input_plio │
│ - pktsplit<4>将输入拆分到4个AIE核 │
│ - 内部使用window缓冲进行数据对齐 │
└─────────────────────────────────────────────────────────────┘
2. 计算数据流(AIE内部)
┌─────────────────────────────────────────────────────────────┐
│ nbodySubsystem<COL, ROW> 模板结构 │
│ │
│ data_in[0] ──▶ pktsplit ──┬──▶ nbody_kernel[0] ──▶ │
│ ├──▶ nbody_kernel[1] ──▶ │
│ ├──▶ nbody_kernel[2] ──▶ │
│ └──▶ nbody_kernel[3] ──▶ │
│ │
│ data_in[1] ───────────────┬──▶ (broadcast到所有kernel) │
│ │ │
│ transmit_new_i[0..3] ◀────┘ │
│ │ │
│ └──▶ pktmerge ──▶ data_out[0] │
└─────────────────────────────────────────────────────────────┘
关键参数:
- NUM_ENGINES_PER_COL = 4 (每列4个AIE核)
- WINDOW_SIZE_I = 224 (每个窗口224个float)
- WINDOW_SIZE_J = 128 (广播数据的窗口大小)
- runtime<ratio> = 0.7 (nbody核占用70%的周期)
3. 输出数据流(AIE → Host)
Step 1: AIE结果汇集
┌─────────────────────────────────────────────────────────────┐
│ pktmerge<NUM_ENGINES_PER_COL> │
│ - 收集4个transmit_new_i核的输出 │
│ - 通过output_plio发送到PL │
└─────────────────────────────────────────────────────────────┘
│
▼
Step 2: 包收集与重组
┌─────────────────────────────────────────────────────────────┐
│ packet_receiver │
│ - 从10个rx端口接收数据 (rx0-rx9) │
│ - 解析包头获取packet ID │
│ - 根据ID映射到4个输出通道 (tx0-tx3) │
│ - 每个tx对应一个s2mm的sink端口 │
└─────────────────────────────────────────────────────────────┘
│
▼
Step 3: DMA写回
┌─────────────────────────────────────────────────────────────┐
│ s2mm_mp (Stream to Memory Multi-Port) │
│ - 4个独立sink端口 (s0-s3) │
│ - 将stream数据写入DDR │
└─────────────────────────────────────────────────────────────┘
│
▼
Step 4: Host验证
┌─────────────────────────────────────────────────────────────┐
│ main_xrt.cpp │
│ - 同步回主机 (xrtBOSync XCL_BO_SYNC_BO_FROM_DEVICE) │
│ - 与golden数据进行比对 │
│ - 统计错误数量 │
└─────────────────────────────────────────────────────────────┘
核心组件详解
1. Packet Sender (packet_sender.cpp/hpp)
设计意图:解决"一对多"的数据分发问题。当需要将相同的数据源分发给多个AIE计算单元时,直接在conn.cfg中建立10条独立的stream_connect会非常冗余且难以维护。
核心机制:
// 关键参数定义 (packet_sender.hpp)
#define NUM_CU 10 // 10个计算单元
#define PACKET_NUM 4 // 每个CU处理4个包
#define PACKET_LEN 224 // 每个包224个字
#define BURST_SIZE PACKET_LEN * PACKET_NUM
// 包头发送阶段
for (int cu=0; cu<NUM_CU; cu++){
for (int h = 0; h < PACKET_NUM; h++) {
unsigned int ID=packet_ids[h]; // 从编译生成的头文件获取
ap_uint<32> header=generateHeader(PKTTYPE,ID);
// 根据cu选择对应的tx端口写入
switch(cu){ case 0: tx0.write(tmp); break; ... }
}
}
// 数据负载阶段
for(int i = 0; i < PACKET_LEN; i++) {
#pragma HLS PIPELINE II=1 // 每个周期处理一个字
x=rx.read();
x.last = (i==PACKET_LEN-1); // 标记包的最后一个字
// 同样根据cu选择输出端口
}
为什么用switch-case而不是数组索引?
HLS工具在处理hls::stream数组时,可能无法正确推断每个stream的独立接口属性。显式的switch-case确保了:
- 每个tx端口被综合为独立的AXI4-Stream接口
- 不会出现端口复用导致的时序冲突
- 便于单独约束每个端口的FIFO深度
2. Packet Receiver (packet_receiver.cpp/hpp)
设计意图:解决"多对少"的数据汇集问题。10个AIE输出需要汇集到4个DMA sink端口。
路由逻辑:
// 包头解析 - 提取packet ID
unsigned int getPacketId(ap_uint<32> header) {
ap_uint<32> ID=0;
ID(4,0)=header(4,0); // 低5位是ID
return ID;
}
// 主循环中的两级switch
for (int cu =0; cu < NUM_CU; cu++){
for (int h = 0; h < PACKET_NUM; h++) {
// Level 1: 从哪个rx端口读取 (由外层cu循环决定)
switch(cu){ case 0: tmp=rx0.read(); break; ... }
unsigned int ID=getPacketId(tmp.data);
unsigned int channel=packet_ids[ID]; // 查表得到目标通道
// Level 2: 写入哪个tx端口 (由解析出的channel决定)
for(int i = 0; i < PACKET_LEN; i++) {
switch(channel){ case 0: tx0.write(x); break; ... }
}
}
}
关键洞察:这里的packet_ids数组实现了静态路由表的功能。虽然查找是动态的(基于包头内容),但路由规则是在编译时确定的,平衡了灵活性和硬件效率。
3. AIE Graph架构 (nbody_x4_x10.h/cpp)
模板化设计:
template <int COL_OFFSET>
class nbodySystem : public adf::graph {
// 10个nbodySubsystem实例,分布在:
// - 第0行: COL_OFFSET+0 到 COL_OFFSET+4
// - 第4行: COL_OFFSET+0 到 COL_OFFSET+4
};
这种设计的优势:
- 位置无关性:通过
COL_OFFSET模板参数,可以轻松移动整个系统在AIE阵列中的位置 - 可扩展性:修改
NUM_INSTANCES即可增减计算单元数量 - 布局可控:显式指定
tile(COL, ROW)确保物理布局符合预期
子系统内部结构 (nbody_subsystem.h):
template <int COL_OFFSET, int ROW_OFFSET>
class nbodySubsystem : public adf::graph {
pktsplit<NUM_ENGINES_PER_COL> pkt_split_i; // 1进4出
pktmerge<NUM_ENGINES_PER_COL> pkt_merge_i; // 4进1出
kernel nbody_kernel[NUM_ENGINES_PER_COL]; // 计算核
kernel transmit_new_i_kernel[NUM_ENGINES_PER_COL]; // 输出格式化核
// 参数数组 - 连接到每个核的私有存储
parameter global_particles_i[NUM_ENGINES_PER_COL];
parameter global_particles_j[NUM_ENGINES_PER_COL];
parameter global_particles_i_new[NUM_ENGINES_PER_COL];
};
4. N-body计算核 (nbody.cc)
算法流程:经典的N体问题求解,分为4个步骤:
Step 1/4: 读取输入i (位置、速度、质量)
- 从input_buffer读取到本地particles_i数组
- 使用vector<8>进行SIMD加载
Step 2/4: 预计算新位置
- pos_new = pos + vel * timestep
- 独立于加速度计算,提前执行隐藏延迟
Step 3/4: 计算加速度 (三重嵌套循环的核心)
for each j batch (LOOP_COUNT_J = 40):
- 异步获取j粒子数据 (w_input_j.acquire)
for each i particle (NUM_I = 32):
3.2.1: 计算dx, dy, dz (向量减法)
3.2.2: 计算L2距离和反平方根 (invsqrt)
3.2.3: 累加加速度贡献 (reduction tree)
- 释放j缓冲区 (w_input_j.release)
Step 4/4: 输出结果
- 发送新位置 (x, y, z)
- 发送更新后的速度 (vx, vy, vz)
- 发送质量 (m)
性能优化要点:
chess_prepare_for_pipelining:指导编译器进行循环流水线化chess_flatten_loop:展开内层循环以提高指令级并行__restrict关键字:告知编译器指针不重叠,允许激进的向量化aie::vector<float,8>:利用AIE的256-bit SIMD能力
系统设计权衡分析
1. 包交换 vs 直连
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 直连 (x1_design) | 简单、低延迟、易调试 | 扩展性差,N个CU需要N倍连接 | 小规模原型验证 |
| 包交换 (x10_design) | 可扩展、连接数恒定、灵活路由 | 需要额外包头开销、复杂度高 | 大规模并行部署 |
为什么选择包交换?
在Versal架构中,PL与AIE之间的物理连接资源是有限的。如果采用直连方式扩展到10个CU,需要:
- 输入侧:10条独立的stream_connect
- 输出侧:10条独立的stream_connect
而包交换方案只需:
- 输入侧:1个mm2s_mp → packet_sender → 10路分发(在PL内部完成)
- 输出侧:10路汇集 → packet_receiver → 4个s2mm端口
这大大节省了宝贵的PL-AIE边界资源。
2. 静态路由 vs 动态路由
当前实现采用的是编译时静态路由:
packet_ids数组在编译时确定- 路由决策在运行时仅涉及简单的数组查表
为什么不使用完全动态路由?
- 硬件成本:动态路由需要更复杂的仲裁逻辑
- 确定性:静态路由保证了时序的可预测性
- 足够灵活:对于N体仿真这类数据并行应用,路由模式是固定的
3. 10个CU × 4核 vs 其他配置
当前配置:NUM_CU=10, NUM_ENGINES_PER_COL=4
这意味着总共使用了40个AIE核。这个选择的考量:
- 10个CU:充分利用Versal VC1902的AIE阵列宽度(50列)
- 每CU 4核:平衡了并行度与单核复杂度,4是AIE列内通信效率较高的数字
- 总行数:占用了第0行和第4行,中间留出空间给其他逻辑或未来扩展
关键设计决策与代码约定
1. 包格式设计
// generateHeader函数生成的32位包头
ap_uint<32> header=0;
header(4,0)=ID; // [4:0] Packet ID (0-31)
header(11,5)=0; // [11:5] Reserved
header(14,12)=pktType; // [14:12] Packet Type
header[15]=0; // [15] Reserved
header(20,16)=-1; // [20:16] Source Row (broadcast)
header(27,21)=-1; // [27:21] Source Col (broadcast)
header(30,28)=0; // [30:28] Reserved
header[31]=parity; // [31] Parity bit (XOR of bits 0-30)
设计考虑:
- ID字段足够大(5位)支持最多32个不同的包类型
- 奇偶校验位用于基本的完整性检查
- 源地址设为-1表示广播/未知源
2. HLS接口策略
// packet_sender的接口声明
#pragma HLS interface axis port=rx
#pragma HLS interface axis port=tx0
...
#pragma HLS interface axis port=tx9
#pragma HLS interface s_axilite port=return bundle=control
- axis接口:所有数据端口使用AXI4-Stream协议,适合流式数据处理
- s_axilite接口:控制接口用于内核启动/停止,不影响数据路径时序
- 无m_axi接口:该内核不涉及DDR直接访问,纯stream处理
3. AIE数据类型约定
// include.h中的关键定义
#define NUM_I 32 // 每个kernel处理的i粒子数
#define NUM_J 32 // 每个batch的j粒子数
#define NUM_PARTICLES 1280 // 总粒子数
#define LOOP_COUNT_J 40 // NUM_PARTICLES/NUM_J
#define WINDOW_SIZE_I NUM_I*7 // x,y,z,vx,vy,vz,m = 7 floats per particle
#define WINDOW_SIZE_J NUM_J*4 // x,y,z,m = 4 floats per particle
这些参数决定了:
- 每个AIE核处理32个粒子的完整N体交互
- 需要40次迭代处理全部1280个j粒子
- 输入窗口大小为224个float(32×7)
新贡献者需要注意的陷阱
1. 包头生成与解析的不对称性
// sender端:ID来自packet_ids数组
unsigned int ID=packet_ids[h]; // h是循环索引
// receiver端:ID从包头解析,再查表得到channel
unsigned int ID=getPacketId(tmp.data);
unsigned int channel=packet_ids[ID];
陷阱:sender和receiver使用的packet_ids含义不同!
- Sender:
packet_ids[h]给出第h个包应该使用的ID - Receiver:
packet_ids[ID]将接收到的ID映射到输出channel
2. HLS PIPELINE的II=1假设
for(int i = 0; i < PACKET_LEN; i++) {
#pragma HLS PIPELINE II=1
x=rx.read();
// ...
}
代码假设可以以每个周期一个字的速率处理,但如果下游阻塞(如AIE来不及消费),实际II可能大于1。在cosim中要特别关注这一点。
3. AIE Graph的tile位置硬编码
location<kernel>(nbody_kernel[row]) = tile(COL_OFFSET,ROW_OFFSET+row);
虽然使用了模板参数,但实际的行列位置在编译时就确定了。如果AIE阵列布局改变(如切换到不同器件),需要重新验证:
- 所有tile是否在有效范围内
- PLIO连接是否仍然可达
- 时序约束是否满足
4. 浮点精度与golden数据
// main_xrt.cpp中的数据检查
diff = golden_data_k[k][(cu*window_size_i_out)+i] ^ *(host_out_i_k[k]+(cu*window_size_i_out)+i);
使用的是按位比较(XOR),这意味着:
- 任何微小的浮点精度差异都会导致测试失败
- 如果修改了计算顺序或优化级别,可能需要重新生成golden数据
- 建议在生产环境中改用容差比较(epsilon-based comparison)
5. 并发启动顺序
// 注意启动顺序:先启动receiver/sink,再启动sender/source
xrtRunHandle packet_receiver_rhdl = xrtKernelRun(packet_receiver_khdl);
xrtRunHandle s2mm_mp_rhdl = xrtKernelRun(s2mm_mp_khdl, ...);
// ...
xrtRunHandle m2s_ij_rhdl = xrtKernelRun(m2s_ij_khdl, ...);
xrtRunHandle packet_sender_rhdl = xrtKernelRun(packet_sender_khdl);
原因:避免数据丢失。如果sender先启动而receiver还没准备好,可能导致死锁或数据丢失。
扩展与修改指南
扩展到更多CU(如x20_design)
- 修改packet_sender/receiver:增加tx/rx端口数量,扩展switch-case
- 更新nbody_x4_x10.h:增加
nbody_inst10到nbody_inst19,调整行列位置 - 更新conn.cfg:添加新的stream_connect条目
- 更新host_sw:调整
NUM_CU宏和相关缓冲区分配
修改包大小
如果需要改变PACKET_LEN:
- 同步修改
packet_sender.hpp和packet_receiver.hpp - 确保与AIE端的
WINDOW_SIZE_I一致 - 重新运行C simulation和Cosimulation验证
添加新的数据类型
当前只支持32位浮点。如果要添加定点或其他类型:
- 修改
axis_pkt的typedef - 更新
generateHeader以支持类型标识 - 在AIE kernel中添加类型转换逻辑
子模块文档
本文档聚焦于系统级架构和集成模式。如需深入了解各组件的实现细节,请参阅以下子模块文档:
| 子模块 | 核心文件 | 说明 |
|---|---|---|
| pl_kernels_packet_sender | packet_sender.cpp/hpp |
HLS实现的包分发器,含包头生成逻辑 |
| pl_kernels_packet_receiver | packet_receiver.cpp/hpp |
HLS实现的包收集器,含路由解析逻辑 |
| aie_graph_system | nbody_x4_x10.h/cpp, nbody_subsystem.h |
AIE Graph模板架构与pktsplit/pktmerge用法 |
| aie_kernels_nbody | nbody.cc, transmit_new_i.cc |
AIE核的N体计算实现与向量化优化 |
| host_control | main_xrt.cpp, nbody.cpp |
Host端XRT控制流程与数据管理 |
| system_connectivity | conn.cfg |
系统级连接配置与stream_connect定义 |
相关模块(同级参考)
- x1_design_packetized_pl_aie_integration - 单实例基准版本
- baseline_full_system_packet_connectivity - 基础包连接示例
- baseline_pl_packet_kernels - 基础PL包处理核
总结
x10_design_packetized_pl_aie_integration模块展示了Versal ACAP架构下大规模异构并行计算的设计范式:
- 分层解耦:Host/PL/AIE各司其职,通过标准接口交互
- 包交换抽象:用软件定义的路由替代硬件连线,实现灵活扩展
- 模板化设计:C++模板实现位置无关和参数化配置
- 数据流驱动:整个系统围绕数据流动设计,而非控制流
理解这个模块的关键在于把握数据如何在时间和空间两个维度上流动——时间上通过pipeline和async buffer重叠计算与通信,空间上通过pktsplit/pktmerge和packet sender/receiver实现并行扩展。