Versal Integration Data Movers 模块深度解析
概述:为什么需要这个模块?
想象你正在设计一个现代化的工厂流水线。AI Engine(AIE)是流水线上的精密加工设备,能够高速处理数据;可编程逻辑(PL)是连接各个设备的传送带系统;而处理系统(PS)则是控制整个工厂的指挥中心。versal_integration_data_movers 模块解决的核心问题是:如何让这三个不同"世界"高效地交换数据?
在 Versal 自适应 SoC 架构中,数据需要在三个异构域之间流动:
- PS(处理系统):运行 Linux 和主机应用,管理整体流程
- PL(可编程逻辑):提供高带宽数据传输和预处理能力
- AIE(AI 引擎):执行高性能信号处理和 ML 计算
这些域之间的数据接口协议完全不同:PS 使用内存映射(DDR),PL 使用 AXI 流式接口,AIE 则使用窗口(window)和流(stream)接口。如果没有专门的数据搬运机制,开发者需要手动处理复杂的协议转换和时序同步。
本模块提供了一个完整的参考设计,展示了如何通过 HLS 编写的 PL 数据搬运核(Data Movers)实现 PS ↔ PL ↔ AIE 的无缝数据流。它就像一座精心设计的桥梁,让数据能够在三个域之间自由流动,同时保持高吞吐量和低延迟。
核心概念与心智模型
类比:邮局分拣系统
理解这个数据搬运系统的最佳方式是将其比作一个现代化的邮局分拣系统:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 邮局分拣系统类比 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 寄件人 (Host/PS) │
│ │ │
│ ▼ │
│ ┌──────────────┐ 邮件袋 (Buffer Object) │
│ │ 打包信件 │ ────────────────────────────────┐ │
│ └──────────────┘ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 收件卡车 (MM2S - Memory Map to Stream) │ │
│ │ • 从仓库取出整袋邮件 (读取 DDR) │ │
│ │ • 拆分成单个信件放到传送带上 (转换为 AXI Stream) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 自动化分拣中心 (AI Engine Graph) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ 初步分类 │───▶│ 精细处理 │───▶│ 最终归类 │ │ │
│ │ │(Interpolator)│ │(Polar Clip) │ │(Classifier) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ Window Stream Window │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 发件卡车 (S2MM - Stream to Memory Map) │ │
│ │ • 从传送带收集处理后的信件 (接收 AXI Stream) │ │
│ │ • 打包成袋存入仓库 (写入 DDR) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 收件人 (验证结果) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
在这个类比中:
- MM2S(Memory Map to Stream):像收件卡车,从 DDR "仓库" 批量取货,拆分成流式数据送上传送带
- AIE Graph:像自动化分拣中心,多个处理单元串联工作
- S2MM(Stream to Memory Map):像发件卡车,从传送带收集处理后的物品,打包存回 DDR "仓库"
关键抽象层
┌─────────────────────────────────────────────────────────────────────────────┐
│ 软件抽象层 (Host Application) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ xrt::device │ │ xrt::bo │ │ xrt::kernel │ │ xrt::graph │ │
│ │ 设备句柄 │ │ 缓冲区对象 │ │ PL 内核句柄 │ │ AIE 图句柄 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────┤
│ XRT 运行时层 │
├─────────────────────────────────────────────────────────────────────────────┤
│ 硬件抽象层 (XCLBIN) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PL Kernels (HLS) │ AI Engine Graph │ │
│ │ ┌─────────┐ ┌─────────┐ │ ┌─────────┐ ┌─────────┐ ┌────────┐│ │
│ │ │ mm2s │ │ s2mm │ │ │ interp │ │ clip │ │classify││ │
│ │ │(HLS IP) │ │(HLS IP) │ │ │(AIE ker)│ │(AIE ker)│ │(AIE ker││ │
│ │ └────┬────┘ └────┬────┘ │ └────┬────┘ └───┬─────┘ └───┬────┘│ │
│ │ │ │ │ │ │ │ │ │
│ │ AXI4-MM AXI4-MM │ Window Stream Window │ │
│ │ (DDR) (DDR) │ │ │
│ └───────┬────────────┬───────────┴──────────┬──────────┬───────────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ AXI4-Stream Interconnect │ │
│ │ mm2s.s ──────────────────────────────▶ ai_engine_0.DataIn1 │ │
│ │ ai_engine_0.DataOut1 ────────────────▶ s2mm.s │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
架构详解
系统拓扑
XRT Host Application] end subgraph PL["PL (可编程逻辑)"] MM2S[mm2s
Memory Map to Stream
HLS Kernel] S2MM[s2mm
Stream to Memory Map
HLS Kernel] end subgraph AIE["AI Engine Array"] INTERP[fir_27t_sym_hb_2i
Interpolator Kernel
Window Interface] CLIP[polar_clip
Polar Clip Kernel
Stream Interface] CLASS[classifier
Classifier Kernel
Window Interface] end DDR[(DDR Memory)] HOST <-->|xrt::bo
Sync TO/FROM Device| DDR HOST -->|xrt::kernel
run()| MM2S HOST -->|xrt::graph
run(1)| INTERP HOST -->|xrt::kernel
run()| S2MM DDR <-->|AXI4-MM
m_axi interface| MM2S MM2S -->|AXI4-Stream
axis interface| INTERP INTERP -->|Window| CLIP CLIP -->|Stream| CLASS CLASS -->|Window| S2MM S2MM <-->|AXI4-MM
m_axi interface| DDR HOST -.->|wait()| MM2S HOST -.->|wait()| S2MM
组件职责
| 组件 | 类型 | 职责 | 接口类型 |
|---|---|---|---|
mm2s |
HLS Kernel | 从 DDR 读取数据,转换为 AXI Stream 发送到 AIE | m_axi (输入), axis (输出) |
fir_27t_sym_hb_2i |
AIE Kernel | 27-tap 半带插值滤波器,2x 上采样 | Window (输入/输出) |
polar_clip |
AIE Kernel | 计算复数幅度,超过阈值时裁剪 | Stream (输入/输出) |
classifier |
AIE Kernel | 根据象限分类复数样本 | Stream (输入), Window (输出) |
s2mm |
HLS Kernel | 从 AIE 接收 AXI Stream,写入 DDR | axis (输入), m_axi (输出) |
数据流分析
端到端数据流追踪
让我们追踪一次完整的数据传输周期:
阶段 1: 主机准备输入数据
─────────────────────────────────────────────────────────
host.cpp:
auto in_bohdl = xrt::bo(device, sizeIn * sizeof(int16_t) * 2, 0, 0);
auto in_bomapped = in_bohdl.map<uint32_t*>();
memcpy(in_bomapped, cint16Input, sizeIn * sizeof(int16_t) * 2);
in_bohdl.sync(XCL_BO_SYNC_BO_TO_DEVICE);
→ 数据从主机内存复制到 device buffer
→ sync() 触发 DMA 将数据从主机内存传输到 DDR
阶段 2: 启动 MM2S 数据搬运
─────────────────────────────────────────────────────────
host.cpp:
auto mm2s_khdl = xrt::kernel(device, xclbin_uuid, "mm2s");
auto mm2s_rhdl = mm2s_khdl(in_bohdl, nullptr, sizeIn);
→ XRT 配置 MM2S kernel,传入 DDR 缓冲区地址和大小
→ MM2S 开始从 DDR 读取数据
阶段 3: MM2S 内部处理 (pl_kernels/mm2s.cpp)
─────────────────────────────────────────────────────────
void mm2s(ap_int<32>* mem, hls::stream<ap_axis<32,0,0,0>>& s, int size) {
#pragma HLS INTERFACE m_axi port=mem offset=slave bundle=gmem
#pragma HLS interface axis port=s
for(int i = 0; i < size; i++) {
#pragma HLS PIPELINE II=1
ap_axis<32,0,0,0> x;
x.data = mem[i]; // 从 DDR 读取 32-bit
s.write(x); // 写入 AXI Stream
}
}
→ 每个时钟周期 (II=1) 从 DDR 读取一个 32-bit 字
→ 通过 AXI4-Stream 接口发送给 AIE
→ 吞吐量: 1 sample/cycle @ 300MHz = 300 MSamples/s
阶段 4: AIE 图处理 (aie/graph.h)
─────────────────────────────────────────────────────────
connect(in.out[0], interpolator.in[0]); // PLIO → Interpolator
connect(interpolator.out[0], clip.in[0]); // Window → Stream
connect(clip.out[0], classify.in[0]); // Stream → Stream
connect(classify.out[0], out.in[0]); // Classifier → PLIO
数据路径:
Input (128 cint16 samples)
↓ [Window, 128 samples]
Interpolator: 2x 上采样 → 256 cint16 samples
↓ [Stream]
Polar Clip: 幅度裁剪
↓ [Stream]
Classifier: 象限分类 → 256 int32 outputs
↓ [Window]
Output
阶段 5: S2MM 接收数据
─────────────────────────────────────────────────────────
void s2mm(ap_int<32>* mem, hls::stream<ap_axis<32,0,0,0>>& s, int size) {
for(int i = 0; i < size; i++) {
#pragma HLS PIPELINE II=1
ap_axis<32,0,0,0> x = s.read(); // 从 AXI Stream 读取
mem[i] = x.data; // 写入 DDR
}
}
→ 从 AXI Stream 接收处理后的数据
→ 写入 DDR 缓冲区
阶段 6: 主机检索结果
─────────────────────────────────────────────────────────
s2mm_rhdl.wait(); // 等待 S2MM 完成
out_bohdl.sync(XCL_BO_SYNC_BO_FROM_DEVICE); // 从 DDR 读回主机
→ 与 golden 数据比较验证正确性
数据格式转换
┌─────────────────────────────────────────────────────────────────────────────┐
│ 数据格式演进 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ DDR (PS/PL 视角) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 连续内存布局: int16_t cint16Input[2048] │ │
│ │ 实部/虚部交错: [real0, imag0, real1, imag1, ...] │ │
│ │ 总大小: 256 cint16 = 512 int16_t = 1024 bytes │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ MM2S AXI4-Stream (32-bit 字) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ap_axis<32,0,0,0>: {data: 32-bit, 无 sideband} │ │
│ │ 每个 32-bit 包含一个 cint16 (16-bit real + 16-bit imag) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ AIE Interpolator Input (Window) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ input_buffer<cint16, margin<16>> │ │
│ │ 128 cint16 samples + 16 sample margin (用于 FIR 历史数据) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ AIE Interpolator Output (Window) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ output_buffer<cint16> │ │
│ │ 256 cint16 samples (2x 上采样) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Polar Clip (Stream) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ input_stream_cint16 / output_stream_cint16 │ │
│ │ 逐样本流式处理 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Classifier Output (Window) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ output_buffer<int32> │ │
│ │ 256 int32 分类结果 (0, 1, 2, 3 表示象限) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ S2MM AXI4-Stream → DDR │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 32-bit stream words → 连续 DDR 内存 │ │
│ │ int golden[256] 期望结果 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
关键设计决策与权衡
1. 接口选择:Window vs Stream
设计决策:在 AIE 图中混合使用 Window 和 Stream 接口
// graph.h 中的连接定义
connect(in.out[0], interpolator.in[0]); // Window - 批量数据
connect(interpolator.out[0], clip.in[0]); // Stream - 流式处理
connect(clip.out[0], classify.in[0]); // Stream - 流式处理
connect(classify.out[0], out.in[0]); // Window - 批量输出
为什么选择这种混合方式?
| 接口类型 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| Window | 支持随机访问、适合 FIR 等需要历史数据的算法、可利用 DMA 突发传输 | 需要更大缓冲区、延迟较高 | FIR 滤波器、需要 margin 的算法 |
| Stream | 低延迟、资源占用少、自然的流水线风格 | 只能顺序访问、无法回看 | 逐样本处理、简单变换 |
具体选择理由:
- Interpolator 使用 Window:FIR 滤波器需要访问历史样本(margin=16),Window 接口允许通过
window_readincr滑动访问 - Polar Clip 使用 Stream:纯逐样本处理,只需要当前样本的幅度计算,Stream 更轻量
- Classifier 输入 Stream、输出 Window:输入是流式数据,但输出需要累积到一定数量后批量写回
2. HLS 数据搬运核的设计
设计决策:使用简单的循环+流水线,而非复杂的数据流架构
// mm2s.cpp - 简化但高效的设计
for(int i = 0; i < size; i++) {
#pragma HLS PIPELINE II=1
ap_axis<32,0,0,0> x;
x.data = mem[i];
s.write(x);
}
为什么不用 DATAFLOW?
这个设计选择了极简主义路线:
- 功能单一明确:只做一件事——把数据从 DDR 搬到 Stream
- 资源占用最小:没有中间缓冲、没有并行路径
- 时序可控:II=1 保证确定性时序,便于系统集成
- 易于调试:线性代码流,问题定位简单
代价是:没有利用 burst 传输优化 DRAM 带宽,对于超大数据集可能不是最优。但对于教程演示和中等规模数据,这是正确的权衡。
3. AIE Kernel 的并行化策略
Interpolator 的向量化设计:
// hb27_2i.cc
constexpr unsigned Lanes=8, Points=16; // 8 路并行,每次处理 16 个 tap
using mul_ops = aie::sliding_mul_ops<Lanes, Points, ...>;
// 每次迭代处理 8 个输出样本
acc0 = mul_ops::mul(coe, 0, sbuff, 3);
acc1 = center_ops::mul(coe, 28, sbuff, 10);
设计洞察:
- 使用 AIE-ML 的
sliding_mul_opsintrinsic 实现单周期 8x16 MAC 运算 - 双 datapath 设计:一个处理对称 FIR,一个处理中心 tap
interleave_zip合并两个 accumulator 的结果
这是计算密集型优化的典范——用专用指令集榨干 SIMD 单元的算力。
4. 同步策略
设计决策:显式的 wait() 同步,而非事件驱动
// host.cpp 中的同步序列
cghdl.run(1); // 启动 AIE 图
cghdl.end(); // 标记图结束
mm2s_rhdl.wait(); // 等待 MM2S 完成
s2mm_rhdl.wait(); // 等待 S2MM 完成
为什么这样设计?
这是一种保守但可靠的同步策略:
- AIE 图先启动,准备好接收数据
- PL kernels 随后启动,开始推送/拉取数据
- 显式 wait() 确保所有数据都处理完毕
代价是可能存在一些空闲等待,但对于确定性验证场景,这种简单性是值得的。
配置文件解析
system.cfg - 系统连接描述
[connectivity]
nk=mm2s:1:mm2s # 实例化 1 个 mm2s,命名为 mm2s
nk=s2mm:1:s2mm # 实例化 1 个 s2mm,命名为 s2mm
sc=mm2s.s:ai_engine_0.DataIn1 # mm2s 的 stream 端口 → AIE 输入
sc=ai_engine_0.DataOut1:s2mm.s # AIE 输出 → s2mm 的 stream 端口
这是 Vitis 链接阶段的蓝图,告诉工具如何连接各个 IP 核。
HLS 配置 (mm2s.cfg / s2mm.cfg)
[hls]
flow_target=vitis
syn.file=mm2s.cpp
syn.top=mm2s # 顶层函数名
package.ip.name=mm2s # 生成的 IP 名
package.output.format=xo # 输出 .xo 文件(Vitis 扩展对象)
新贡献者注意事项
常见陷阱
1. 缓冲区大小不匹配
// include.h
#define INTERPOLATOR27_INPUT_SAMPLES 128
// NOTE: THIS AMOUNT MUST AGREE WITH THE INPUT_SAMPLES IN HOST.CPP
危险:如果修改了 AIE 侧的样本数但忘记更新 host.cpp 的 SAMPLES 宏,会导致数据截断或越界。
建议:始终检查以下一致性:
include.h中的*_SAMPLES定义host.cpp中的SAMPLES宏data.h中的输入数据数组大小
2. AXI Stream 的 sideband 信号
// mm2s.cpp
ap_axis<32, 0, 0, 0> x; // 32-bit data, 0-bit keep/strb/last/user
模板参数含义:ap_axis<DATA_WIDTH, USER_WIDTH, ID_WIDTH, DEST_WIDTH>
这里全部设为 0 表示最简化的流接口。如果需要 packet 边界检测(TLAST),需要增加相应宽度。
3. XRT 缓冲区对齐
// host.h 中的 aligned_allocator
template <typename T>
struct aligned_allocator {
T* allocate(std::size_t num) {
void* ptr = nullptr;
if (posix_memalign(&ptr, 4096, num*sizeof(T))) // 4KB 对齐!
throw std::bad_alloc();
return reinterpret_cast<T*>(ptr);
}
};
DMA 传输要求缓冲区 4KB 对齐。使用标准 malloc/new 可能导致 XRT 报错或性能下降。
4. AIE Kernel 的 runtime ratio
// graph.h
runtime<ratio>(interpolator) = 0.8;
runtime<ratio>(clip) = 0.8;
runtime<ratio>(classify) = 0.8;
runtime<ratio> 表示该 kernel 占用 AIE tile 的计算资源比例。值为 0.8 意味着:
- 该 tile 80% 的时间运行此 kernel
- 剩余 20% 可用于其他 kernel 或 DMA 传输
如果设置过高(如 1.0),可能导致 DMA 无法及时搬运数据,造成死锁。
调试技巧
使用 Vitis Analyzer
# 查看编译结果
vitis_analyzer -a ./Work/graph.aiecompile_summary
# 查看仿真结果
vitis_analyzer -a ./aiesimulator_output/default.aierun_summary
重点关注:
- Graph View:验证连接关系是否符合预期
- Array View:检查 kernel 在物理 tile 上的布局
- DMA Analysis:确认 DMA 通道分配是否合理
硬件仿真波形
make run_hw_emu
在 Vivado Simulator 中观察:
mm2s.s端口是否有数据输出s2mm.s端口是否接收到预期数据- AXI Stream 的 valid/ready 握手是否正常
与其他模块的关系
上游依赖
本模块是一个自包含的教程,但其设计模式被多个生产级模块复用:
| 相关模块 | 关系 | 说明 |
|---|---|---|
| prime_factor_fft_hls_kernels | 设计模式参考 | FFT 系统中的 DMA 数据搬运采用相同架构 |
| channelizer_hls_stream_and_dma_kernels | 扩展应用 | 多通道系统中使用类似的 S2MM/MM2S 模式 |
下游使用者
本模块作为教学参考,为以下模块提供基础:
- 任何需要 PS-PL-AIE 数据流的系统设计
- 自定义数据搬运核的开发起点
总结
versal_integration_data_movers 模块是理解 Versal 异构计算的入门钥匙。它的价值不在于代码的复杂性,而在于展示了如何将三个不同计算域(PS、PL、AIE)无缝集成。
核心要点回顾:
- 数据搬运是异构系统的命脉 —— MM2S/S2MM 这样的数据搬运核是连接不同世界的桥梁
- 接口选择反映算法需求 —— Window 适合批量处理和历史数据访问,Stream 适合流水线式逐样本处理
- 简单即美 —— 这个模块故意保持简洁,让每个组件的职责清晰明确
- 配置即代码 —— system.cfg 中的连接描述决定了系统的物理拓扑
对于新加入团队的工程师,建议按以下顺序深入:
- 通读
host.cpp,理解 XRT API 的使用模式 - 研究
mm2s.cpp和s2mm.cpp,掌握 HLS 数据搬运核的基本结构 - 分析
graph.h,理解 AIE 图的连接和配置 - 阅读 AIE kernel 源码(尤其是
hb27_2i.cc),学习 AIE 向量化编程 - 动手修改参数(如样本数),观察对整个系统的影响
这个模块就像一本活教材——它不仅告诉你"怎么做",更重要的是展示了"为什么这样做"。