AIE Graph and Kernels 模块深度解析
概述:这个模块解决了什么问题?
想象你正在设计一条工业流水线,但这条流水线不是处理物理产品,而是处理高速数字信号。你的客户要求:在极低的延迟下完成插值、削峰和分类三种操作。传统的 CPU 或 GPU 方案无法满足硬实时约束,而纯 FPGA 方案又难以实现复杂的算法。
这就是 Versal ACAP(自适应计算加速平台)诞生的背景。aie_graph_and_kernels 模块展示了如何将 AMD AI Engine(AIE)阵列与可编程逻辑(PL)无缝集成,构建一个完整的数据处理管道。它不仅仅是一个示例代码,更是理解 Versal 异构架构编程范式的关键入口。
核心设计洞察在于:将计算密集型、规则化的信号处理任务卸载到 AIE 的 SIMD 矢量单元,同时用 PL 处理数据搬运和系统接口。这种分工让 AIE 专注于它最擅长的——高吞吐量的矢量运算,而 PL 则负责灵活的 I/O 和系统级协调。
心智模型:如何理解这个系统?
类比:交响乐团的协作
把这个系统想象成一个交响乐团:
- AI Engine 阵列 = 弦乐组 + 管乐组(专业演奏家,各自精通特定乐器/算法)
- PL 内核(mm2s/s2mm) = 舞台工作人员(负责把乐谱搬上台、把演出录音搬下台)
- Host 应用 = 指挥(决定何时开始、何时结束、验证演出质量)
- ADF Graph = 乐谱(定义谁和谁配合、按什么顺序演奏)
每个 AIE kernel 就像一个专门训练过的演奏家——fir_27t_sym_hb_2i 是"插值专家",polar_clip 是"削峰专家",classifier 是"分类专家"。他们通过"流通道"(stream)传递音符(数据),形成一条无间断的流水线。
核心抽象层
┌─────────────────────────────────────────────────────────────┐
│ Host Application │
│ (XRT Runtime - xrt::graph API) │
├─────────────────────────────────────────────────────────────┤
│ ADF Graph (clipgraph) │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
│ PLIO │Interpolator│──▶│polar_clip│──▶│classifier│──▶ PLIO │
│ in │ (hb27_2i) │ │ │ │ │ out │
│ └─────────┘ └──────────┘ └──────────┘ │
├─────────────────────────────────────────────────────────────┤
│ PL Kernels (Data Movers) │
│ mm2s (Memory-to-Stream) s2mm (Stream-to-Memory) │
│ DMA Read DMA Write │
└─────────────────────────────────────────────────────────────┘
架构详解
数据流全景图
XRT Runtime] end subgraph PL["Programmable Logic (PL)"] M[mm2s
DMA Source] S[s2mm
DMA Sink] end subgraph AIE["AI Engine Array"] G[clipgraph
ADF Graph] I[interpolator
fir_27t_sym_hb_2i] P[polar_clip] C[classifier] end H -->|xrt::bo
Buffer Object| M M -->|AXI4-Stream
ap_axis<32>| G G --> I --> P --> C C -->|AXI4-Stream| S S -->|xrt::bo| H
组件职责分解
1. ADF Graph (graph.h / graph.cpp)
这是整个系统的"蓝图"。clipped 类继承自 adf::graph,定义了三个核心要素:
Kernel 实例化:
interpolator = adf::kernel::create(fir_27t_sym_hb_2i);
clip = adf::kernel::create(polar_clip);
classify = adf::kernel::create(classifier);
这里的关键设计是延迟绑定——kernel 函数在编译时声明,但实际映射到 AIE 物理核心是在链接阶段由工具链完成的。adf::source() 告诉编译器去哪里找实现文件。
PLIO 接口:
in = adf::input_plio::create("DataIn1", adf::plio_32_bits, "data/input.txt");
out = adf::output_plio::create("DataOut1", adf::plio_32_bits, "data/output.txt");
PLIO(Processor Logic Interface)是 AIE 与 PL 之间的桥梁。plio_32_bits 指定了 32-bit 数据宽度,这与 PL 侧的 ap_axis<32> 相匹配。命名约定至关重要——DataIn1 和 DataOut1 必须与 system.cfg 中的连接声明一致。
数据流连接:
connect(in.out[0], interpolator.in[0]);
connect(interpolator.out[0], clip.in[0]);
connect(clip.out[0], classify.in[0]);
connect(classify.out[0], out.in[0]);
这些 connect 调用建立了 kernel 间的数据依赖关系。注意从 interpolator 到 polar_clip 的连接——前者使用 buffer 接口,后者使用 stream 接口,ADF 运行时会自动插入必要的缓冲/转换逻辑。
运行时配置:
runtime<ratio>(interpolator) = 0.8;
这行代码声明该 kernel 占用 AIE 核心的 80% 计算资源。剩余 20% 可用于其他 kernel 或留给调度开销。这是一种静态分区策略,在编译时就确定了资源分配。
2. AIE Kernels
Interpolator (hb27_2i.cc)
这是一个 27-tap 半带 FIR 插值滤波器,2 倍上采样。核心算法利用了半带滤波器的对称特性——一半的系数为零,可以利用对称性减少乘法次数。
关键实现细节:
// 系数存储使用 chess_storage 属性,确保 16-byte 对齐
static int16_t chess_storage(%chess_alignof(v16int16)) coeffs_27_i [INTERPOLATOR27_COEFFICIENTS];
// 输入缓冲区声明包含 margin,用于 FIR 的历史样本
input_buffer<cint16, adf::extents<adf::inherited_extent>, adf::margin<INTERPOLATOR27_COEFFICIENTS>> & __restrict cb_input
__restrict 关键字告诉编译器该指针不会别名,允许激进的矢量化优化。margin 参数为 FIR 滤波器提供了必要的历史样本缓冲区。
矢量运算核心:
v8cacc48 acc0 = undef_v8cacc48();
acc0 = upd_hi(acc0, mul4(sbuff, 10, 0x3210, 1, coe, 8, 0x0000, 1));
acc0 = upd_lo(acc0, mul4_sym(sbuff, 7, 0x3210, 1, 12, coe, 4, 0x0000, 1));
这里使用了 AIE 特有的 intrinsics:mul4 执行 4-lane 复数乘法,mul4_sym 利用系数对称性同时计算两个对称位置的乘积。0x3210 是 shuffle pattern,控制数据重排以匹配 SIMD 布局。
Polar Clip (polar_clip.cpp)
实现 CFR(Crest Factor Reduction)算法的核心——检测并削平信号峰值。
CORDIC 算法:
void cos_sin_mag(int x_I, int x_Q, int* magout, int* cos_fixed, int* sin_fixed)
使用 6 步 CORDIC 迭代计算幅度和相位。选择 CORDIC 而非直接平方根的原因是:AIE 没有硬件除法/开方单元,CORDIC 只用移位和加法就能收敛到结果。
阈值判断:
if(mag_sq > CFR_THRESHOLD * CFR_THRESHOLD) {
res_real = (int32_t)cs_fixed_real * (magout - CFR_THRESHOLD);
res_imag = (int32_t)cs_fixed_imag * (magout - CFR_THRESHOLD);
}
比较的是幅度平方与阈值平方,避免昂贵的开方运算。这是典型的精度换性能权衡。
Classifier (classifiers/classify.cc)
最简单的 kernel——根据复数样本的象限输出 0-3 的分类标签。
if (sample.real >= 0) {
if (sample.imag > 0) *OutIter++ = 0;
else *OutIter++ = 1;
} else {
if (sample.imag > 0) *OutIter++ = 2;
else *OutIter++ = 3;
}
虽然简单,但它展示了流式接口的使用——input_stream_cint16 和 output_buffer<int32> 的组合,说明 AIE kernel 可以混合使用 stream 和 buffer 接口。
3. PL Kernels (mm2s.cpp / s2mm.cpp)
这两个 HLS 内核充当 DMA 引擎,负责 DDR 内存与 AIE 之间的数据传输。
mm2s (Memory-to-Stream):
#pragma HLS INTERFACE m_axi port=mem offset=slave bundle=gmem
#pragma HLS interface axis port=s
#pragma HLS PIPELINE II=1
m_axi接口:连接到 DDR 内存,支持突发传输axis接口:AXI4-Stream,连接到 AIEPIPELINE II=1:每周期输出一个样本,达到最大吞吐
关键设计决策:使用 ap_int<32> 而非 cint16 作为内存接口类型。这是因为 PL 侧不解释数据语义,只是字节搬运工。数据类型的解释发生在 AIE 侧。
4. System Configuration (system.cfg)
[connectivity]
nk=mm2s:1:mm2s
nk=s2mm:1:s2mm
sc=mm2s.s:ai_engine_0.DataIn1
sc=ai_engine_0.DataOut1:s2mm.s
这是 Vitis 链接阶段的配置文件,定义了:
nk(num kernels):每种 kernel 的实例数量sc(stream connections):PL kernel 端口与 AIE graph 端口的连接
命名匹配至关重要:DataIn1 必须匹配 graph.h 中 input_plio::create() 的第一个参数,mm2s.s 必须匹配 HLS kernel 的端口名(默认 .s 表示 AXI-Stream 输出)。
5. Host Application (host.cpp)
使用 XRT(Xilinx Runtime)API 控制整个系统。
执行流程:
// 1. 加载 xclbin(包含编译后的 AIE 程序和 PL bitstream)
auto xclbin_uuid = device.load_xclbin(xclbinFile);
// 2. 创建 Buffer Object,建立主机内存与设备内存的映射
auto in_bohdl = xrt::bo(device, sizeIn * sizeof(int16_t) * 2, 0, 0);
// 3. 启动 PL kernels(DMA 先启动,等待数据)
auto mm2s_rhdl = mm2s_khdl(in_bohdl, nullptr, sizeIn);
auto s2mm_rhdl = s2mm_khdl(out_bohdl, nullptr, sizeOut);
// 4. 启动 AIE graph
auto cghdl = xrt::graph(device, xclbin_uuid, "clipgraph");
cghdl.run(1);
cghdl.end();
// 5. 等待 DMA 完成
mm2s_rhdl.wait();
s2mm_rhdl.wait();
微妙的时序关系:PL kernels 必须在 AIE graph 之前启动,因为 AIE 一旦开始运行就会立即尝试读写 PLIO 端口。如果 PL 侧还没准备好,会导致死锁。
设计权衡与决策分析
1. Buffer vs Stream 接口选择
| Kernel | 输入接口 | 输出接口 | 理由 |
|---|---|---|---|
| interpolator | input_buffer |
隐式 buffer | FIR 需要随机访问历史样本,buffer 提供缓存友好性 |
| polar_clip | input_stream_cint16 |
output_stream_cint16 |
纯流水线处理,样本间无依赖,stream 降低延迟 |
| classifier | input_stream_cint16 |
output_buffer<int32> |
展示混合接口;buffer 输出便于 host 读取 |
权衡:Buffer 接口允许随机访问和重用数据,但需要更多片上内存;Stream 接口节省内存、延迟更低,但要求严格的生产者-消费者同步。
2. 为什么有两个 interpolator 实现?
目录中有 hb27_2i.cc(intrinsics 版本)和 hb27_2i_API.cc(AIE API 版本):
- Intrinsics 版本:直接使用底层 SIMD 指令(
mul4,mac4_sym等),最大化控制但可读性差 - AIE API 版本:使用
aie::sliding_mul_sym_ops等高级抽象,代码更简洁,但可能牺牲少许性能
这是性能与可维护性的经典权衡。教程同时展示两者,让开发者根据场景选择。
3. 静态 vs 动态调度
runtime<ratio>(interpolator) = 0.8;
ADF 采用静态分时调度——在编译时就确定每个 kernel 占用 AIE 核心的时间片比例。这简化了运行时复杂度,但也意味着:
- 无法动态适应负载变化
- 如果实际执行时间超过分配的 80%,会出现调度冲突
替代方案是使用 async RTP(Run-Time Parameter)重新配置,但这会增加复杂性和延迟。
4. 仿真 vs 硬件执行的双态设计
#if defined(__AIESIM__) || defined(__X86SIM__)
int main(int argc, char ** argv) {
clipgraph.init();
clipgraph.run(4);
clipgraph.end();
return 0;
}
#endif
graph.cpp 中的这段代码只在仿真时编译。硬件执行时使用 host.cpp 中的 XRT 流程。这种条件编译允许同一代码库支持:
- x86sim:快速功能验证(在 x86 上模拟 AIE 行为)
- aiesim:周期精确仿真(模拟实际 AIE 时序)
- hw/hw_emu:真实硬件或硬件仿真
代价是增加了代码复杂度,需要维护两条执行路径。
新贡献者必读:陷阱与注意事项
1. 数据类型对齐陷阱
// 错误:未对齐的数组可能导致 SIMD load 失败
int16_t coeffs[16]; // 可能只 2-byte 对齐
// 正确:强制 16-byte 对齐
chess_storage(%chess_alignof(v16int16)) int16_t coeffs[16];
AIE 的 128-bit SIMD 单元要求数据 16-byte 对齐。未对齐访问不会崩溃,但会显著降低性能(硬件需要多次访问拼接数据)。
2. Margin 大小计算
#define INTERPOLATOR27_INPUT_MARGIN (16*4)
FIR 滤波器的 margin 必须至少等于 (tap_count - 1) * sample_size。对于 27-tap 复数 FIR(每个样本 4 bytes),最小 margin 是 26 * 4 = 104 bytes。这里使用 16*4=64 可能是因为利用了半带滤波器的零系数特性减少了实际需要的历史样本。
修改 tap 数量时必须同步更新 margin,否则会导致边界样本计算错误。
3. 缓冲区大小契约
// host.cpp
int sizeIn = SAMPLES/2; // 128 samples
int sizeOut = SAMPLES; // 256 samples
// include.h
#define INTERPOLATOR27_INPUT_SAMPLES 128
#define INTERPOLATOR27_OUTPUT_SAMPLES (INTERPOLATOR27_INPUT_SAMPLES * 2)
Host 分配的缓冲区大小必须与 AIE kernel 期望的 dimensions() 声明一致。这种契约没有运行时检查,不匹配会导致静默的数据损坏或挂起。
4. PLIO 命名一致性
三处必须完全匹配:
graph.h:input_plio::create("DataIn1", ...)system.cfg:sc=mm2s.s:ai_engine_0.DataIn1host.cpp:xrt::graph(device, uuid, "clipgraph")—— graph 实例名
任何不匹配都会导致链接错误或运行时连接失败。
5. 并发启动顺序
// 危险:先启动 graph
clipgraph.run(1);
// 后启动 DMA —— 如果 AIE 立即尝试读数据,DMA 还没准备好!
mm2s_khdl(...);
正确的顺序是:
- 准备所有 buffer(sync to device)
- 启动 PL kernels(它们会等待 AIE 握手)
- 启动 AIE graph
- 等待所有完成
6. HLS Dataflow 缺失的影响
PL kernels(mm2s/s2mm)没有使用 #pragma HLS DATAFLOW,这意味着:
- 读内存和写 stream 是串行的(对单循环来说没问题)
- 无法重叠多个迭代的执行
对于更高吞吐的需求,可以考虑展开循环或添加 DATAFLOW,但这会增加资源使用。
扩展指南
添加新的处理阶段
假设要在 polar_clip 和 classifier 之间添加一个增益控制 stage:
- 实现 kernel(
kernels/gain_control.cpp):
void gain_control(input_stream_cint16* in, output_stream_cint16* out) {
for (int i = 0; i < SAMPLES; i++) {
cint16 s = readincr(in);
s.real = (s.real * GAIN) >> 15;
s.imag = (s.imag * GAIN) >> 15;
writeincr(out, s);
}
}
-
更新
kernels.h:添加函数声明 -
更新
graph.h:
kernel gain;
gain = adf::kernel::create(gain_control);
adf::source(gain) = "kernels/gain_control.cpp";
// 断开原有连接,插入新节点
connect(clip.out[0], gain.in[0]);
connect(gain.out[0], classify.in[0]);
runtime<ratio>(gain) = 0.5;
- 重新编译 xclbin
调整吞吐量
如果需要处理更高的采样率,可以:
- 增加
runtime<ratio>接近 1.0(单核极限) - 使用
adf::kernel::create_array()创建多核并行版本 - 优化 kernel 内部循环的 II(Initiation Interval)
相关模块
- versal_integration_data_movers —— 更复杂的 DMA 集成模式
- normalization_v1_performance_flow —— 性能优化方法论
- debug_emulation_and_performance_analysis —— 调试和分析工具使用
总结
aie_graph_and_kernels 模块是理解 Versal AIE 编程的"Hello World"。它展示的核心模式——Graph 定义拓扑、Kernel 实现算法、PL 处理 I/O、Host 协调执行——是所有复杂 AIE 设计的基础。
关键 takeaway:
- 接口即契约——PLIO 命名、缓冲区大小、数据类型必须在全栈保持一致
- 静态配置为主——资源分配、连接关系在编译时确定,换取运行时确定性
- 矢量化为王——AIE 的性能来自 SIMD,代码必须围绕 128-bit 矢量操作设计
- 分层验证——x86sim → aiesim → hw_emu → hw 的渐进式验证流程