板卡还在路上,评审节点却已经定死,这大概是做 AI Engine 开发最常遇到的局面。手头只有一台笔记本、一套装好的工具链,要交付的却是一个横跨 PS 与 AI Engine-ML 阵列的数据通路工程。这时候真正能救场的不是等硬件,而是把“脱机开发”这条链路彻底打通:用不同层级的仿真模型顶替没有到货的板卡,把工程构建、启动镜像、数据搬移、结果校验这些环节在纯软件环境里先跑一遍。VERSAL AIE-ML 的工程构建与 QEMU 协同仿真,说的就是这件事——它不是把硬件仿真一遍就完事,而是三套仿真器各管一段、互相咬合的一套方法论。
这篇文章面向两类人:一是刚接触 AIE-ML、还没摸过真板子,想先把 graph 和 kernel 写对的工程师;二是手里有工程但一直被“仿真能跑、上板就崩”折磨,想搞清楚仿真结论边界的老手。下面按我实际踩过的顺序展开:先分清三层仿真各自能证明什么,再讲工程骨架怎么搭,然后把 PS 侧环境离线凑齐,接着处理数据通路的对齐问题,最后给一条完整的排查链路和一份可信度清单。凡是文中出现的路径、器件型号、平台名,都用占位符表示,你按自己的实际环境替换即可。
1. 三层仿真各管一段,混着用必然翻车
很多人第一次接触 AIE-ML 的仿真,下意识的反应是“找个仿真器把整个系统跑起来”。这个想法从一开始就不成立。AI Engine-ML 的开发栈里,仿真不是一个东西,而是三个层次分明的工具,它们的时间开销、覆盖范围、能得出的结论完全不同。把它们的职责搞混,是后续所有混乱的源头。
1.1 x86simulator:只回答“算得对不对”
x86simulator 的工作方式非常朴素:把 AI Engine 的 kernel 源码当成普通 C++ 代码,链接一套 x86 版本的aie::API 实现,编译成宿主机上的可执行程序。整个 AI Engine 阵列、DMA、FIFO 全都不存在,kernel 就是被当作一个函数调用。
它的价值在于极快的迭代速度。一帧 256 个采样点的增益运算,x86simulator 跑完可能是毫秒级,你可以一秒钟改十次参数、跑十次对比。这个阶段唯一要盯住的是数值逻辑:边界点处理对不对、定点转浮点有没有溢出、循环展开后索引有没有算错、特殊值(0、负最大值、NaN)进来会不会跑飞。
我一般会在这个阶段做一件事:把 kernel 里所有可能的分支路径都构造一组输入打一遍,用宿主机上的参考实现(就是一段最朴素的标量 for 循环)做逐点比对,误差控制在 1e-6 或者定点的 1 LSB 以内。这一步做扎实,后面两层仿真里 90% 的“结果不对”都能直接排除算法嫌疑,省下大量时间。
要注意 x86simulator 有个坑:aie::命名空间下有些 intrinsic 在 x86 后端只有“近似实现”,比如某些向量归约、饱和运算,行为和真实 AIE 的指令不完全一致。如果你在这些地方卡精度,别死磕,直接跳到 aiesimulator 去验证。
1.2 aiesimulator:能回答“跑得动吗”
aiesimulator 用的是 AI Engine 的周期级模型,包含每个 tile 的指令执行、DMA 通道的调度、FIFO 的握手、锁的竞争。跑出来的不再是一个结果,而是一份带时间戳的轨迹。
这一层最典型的用途是看吞吐和资源。举个我印象很深的例子:一个把两路 256 点实数做逐点乘加的图,x86sim 里 0.3 毫秒跑完,我以为万事大吉。换到 aiesimulator,报告显示两个 kernel 被塞进了同一个 tile,runtime ratio 都是默认的 1.0,结果 DMA 通道互相等,实际吞吐只有预期的四成。把location<kernel>显式分开、把 ratio 调到 0.5 附近,吞吐立刻恢复正常。
aiesimulator 的耗时是分钟级的,一帧几万点的数据跑十几分钟很常见。所以它不适合做功能回归,适合做定点验证:改完 placement、改完 buffer 深度、改完 plio 宽度之后,跑一次确认性能曲线没塌。
1.3 QEMU 加 hw_emu:能回答“软件栈通不通”
这是标题里最容易被误解的一层。先把话说透:QEMU 本身不模拟 AI Engine 阵列,也不模拟你的 PL 逻辑。它是一个 ARM 指令级模拟器,跑的是 PS 侧的 Cortex-A 核,能启动 U-Boot、加载 Linux、挂载根文件系统、执行你的 host 程序。你把 xclbin 加载语句扔给它,它只会告诉你设备节点不存在。
真正的协同发生在 hw_emu 模式下:QEMU 顶替 PS,PL 逻辑交给 XSIM,AI Engine 交给周期模型,三者由工具链在链接阶段生成的桥接层串起来。所以“QEMU 协同仿真”这个说法,准确理解应该是“以 QEMU 为 PS 载体的 hw_emu 流程”。
| 层次 | 典型入口 | 单帧耗时量级 | 能证明的事 | 完全不能证明的事 |
|---|---|---|---|---|
| x86simulator | aiecompiler --target=x86sim | 毫秒到秒 | 算法数值正确性、边界处理 | 时序、DMA、资源占用 |
| aiesimulator | aiecompiler --target=hw后跑aiesimulator | 分钟 | tile 占用、DMA 争用、吞吐 | PS 软件逻辑、驱动、启动链 |
| QEMU + hw_emu | v++ --target hw_emu后跑 host | 十几分钟到小时 | host 代码、XRT 调用、启动流程 | 真实带宽、功耗、散热 |
看清这张表,你就知道为什么必须分层:拿 x86sim 的结果去论证“实时性达标”是自欺欺人,拿 hw_emu 的跑分去论证“带宽够用”同样不靠谱。每一层给出的结论,只能在该层的范围内成立。
2. 工程骨架:graph、kernel 与platform 的咬合关系
AIE-ML 工程的目录结构看起来自由,其实有很强的隐含约束。graph 里写的每个路径、每个端口尺寸、每个 placement,都会在编译期被逐条校验。骨架搭歪了,后面每一层仿真都会以奇怪的方式报错,而且报错信息往往指向错误的位置。
2.1 一份能撑住三层仿真的目录布局
我习惯用下面这套布局,好处是三层仿真的中间产物天然隔离,不会互相污染:
proj/ ├── src/ │ ├── graph.cpp │ ├── kernels.h │ └── kernels/ │ └── gain_kernel.cpp ├── host/ │ └── host.cpp ├── data/ │ ├── input.txt │ └── golden.txt ├── cfg/ │ └── link.cfg ├── scripts/ │ ├── run_x86sim.sh │ ├── run_aiesim.sh │ └── run_hwemu.sh └── build/ ├── aie_work/ └── package/关键点在于build/aie_work单独成目录。aiecompiler 会在工作目录下生成Work/、aiesimulator_output/、x86simulator_output/一堆东西,如果直接在工作目录根下编译,跑过一轮 aiesim 之后再跑 hw_emu,很容易因为残留的旧产物出现“明明改了代码结果没变”的幻觉。我吃过这个亏,一次排查了两个小时,最后发现是链接了上一版的libadf.a。
2.2 graph.cpp 里最容易写错的四个地方
先看一份能用的 graph:
#include <adf.h> #include "kernels.h" using namespace adf; class GainGraph : public graph { public: kernel k_gain; input_plio in0; output_plio out0; GainGraph() { k_gain = kernel::create(gain_kernel); // 源文件路径相对编译时的工作目录或 -include 路径 source(k_gain) = "kernels/gain_kernel.cpp"; runtime<ratio>(k_gain) = 0.6; stack_size(k_gain) = 2048; // 端口尺寸必须与 kernel 的 extents 一致 in0 = input_plio::create("in0", plio_32_bits, "data/input.txt"); out0 = output_plio::create("out0", plio_32_bits, "data/output.txt"); connect<stream>(in0.out[0], k_gain.in[0]); connect<stream>(k_gain.out[0], out0.in[0]); } }; GainGraph mygraph;第一处坑是source()的路径。它不接受绝对路径的“便利写法”之外的任何模糊表达,写错就是一句no rule to make target,而且不会告诉你它到底在哪个目录下找。我的做法是永远用相对于编译工作目录的相对路径,并且把-include ./src显式带上。
第二处坑是runtime<ratio>。它的默认值往往是 1.0,含义是“这个 kernel 独占整个 tile 的执行时间”。同一 tile 放两个 kernel、两个都是 1.0,编译能过,仿真能跑,但 DMA 会互相抢,吞吐掉到一半以下还不报错。多 kernel 共 tile 时按实测占用调,一般给 0.4 到 0.7 之间比较稳妥。
第三处坑是端口尺寸。plio_32_bits配合 kernel 里extents<256>的 buffer,隐含的是一次传输 256 个 32 位字。如果 kernel 写的是extents<128>,链接期会报尺寸不匹配,但这个报错出现在链接阶段而不是编译阶段,第一次遇到会以为是平台问题。
第四处坑是 placement。location<kernel>(k_gain) = tile(0,0)在 aiesimulator 下很好用,但阵列尺寸取决于你选的平台。写了个tile(3,5)结果平台只有 2 列,编译直接拒掉。我现在的习惯是先在脚本里导出平台的阵列尺寸,再决定 placement,别凭记忆写。
2.3 plio 和 gmio 怎么选:一个决定后面所有调试成本的取舍
这两个接口的选择,直接影响你后面在 host 侧怎么写代码、在仿真里怎么看数据。
plio走的是 PL 与 AI Engine 之间的 AXI-Stream 通路。它的特点是流式、低延迟、由 PL 侧的 DMA 驱动。在 aiesimulator 里,input_plio::create可以直接绑定一个文件作为激励源,仿真结束把结果写到另一个文件,非常适合做“输入一段数据,看输出对不对”的闭环验证。代价是它依赖 PL 侧逻辑,在纯 QEMU 环境里跑不通。
gmio走的是 AI Engine 自带的 DMA 直接访存,绕过 PL。它的优势是不依赖 PL 逻辑,host 侧只要把数据放到约定的 DDR 地址就能被 AIE 读到。缺点是你得自己管地址对齐和 cache 一致性,而这恰恰是新手最容易翻车的地方。
| 对比项 | plio | gmio |
|---|---|---|
| 通路 | 经 PL 的 AXI-Stream | AIE DMA 直接访存 |
| 依赖 PL 逻辑 | 是 | 否 |
| 适合场景 | 流式、低延迟、连续帧 | 大批量、非规则访问、块传输 |
| 仿真激励 | 直接绑定文件,最省事 | 需在 host 或 graph 里写数据 |
| 主要风险 | PL 侧 DMA 配置错误 | 地址对齐、cache 未刷新 |
我的建议是:功能验证阶段优先用 plio,因为绑文件做激励太方便了,能把算法闭环先跑通;等要验证 host 侧完整链路时再切到 gmio,因为 gmio 更接近真实部署形态。两套接口在同一个 graph 里可以共存,切换成本主要是 host 代码和 buffer 管理。
2.4 编译命令与产物解读
新版本工具链推荐用统一入口v++:
# 编译 AI Engine 图(以 hw 为目标,供后续链接和仿真) v++ -c --mode aie \ --target hw \ --platform ${PLATFORM_REPO_PATHS}/<your_platform>/<your_platform>.xpfm \ --include ./src \ --work_dir ./build/aie_work \ -o ./build/libadf.a \ ./src/graph.cpp 2>&1 | tee ./build/aie_compile.log老版本用aiecompiler,命令等价但参数名略不同:
aiecompiler \ --target=hw \ --platform ${PLATFORM_REPO_PATHS}/<your_platform>/<your_platform>.xpfm \ -include ./src \ -workdir ./build/aie_work \ ./src/graph.cpp 2>&1 | tee ./build/aie_compile.log产物里最重要的三个:libadf.a是图和 kernel 的归档,后续链接必须带上;Work/目录是 aiesimulator 的输入包;aiesimulator_output/里会有profile_summary.csv和timeline.csv,前者给你每个 tile 的占用率和 DMA 统计,后者是可视化轨迹的时间轴数据。
我每次编译完都会扫一眼profile_summary.csv里 runtime ratio 那一列。如果某个 kernel 的实际占用远低于你设的 ratio,说明你给它留的资源过多,可以往同一个 tile 里再塞点东西;如果实际占用接近甚至超过设定值,那这个 kernel 就是瓶颈,要么拆逻辑要么换 tile。这个动作花不了两分钟,但能避免很多“为什么吞吐上不去”的困惑。
3. PS 侧环境:把根文件系统和启动镜像离线凑齐
AIE 侧的图编译通了,接下来要解决的是“让一个 ARM 程序在 QEMU 里跑起来”。这一步的麻烦程度经常被低估——因为它牵扯到内核配置、设备树、根文件系统、启动加载器一整套东西,而且每一步的错误表现都很隐晦。
3.1 PetaLinux 配置里必须动的那几项
离线构建 PS 环境的常规路径是用 PetaLinux 生成内核、设备树和根文件系统。默认配置能跑,但有几个点必须改,否则 QEMU 里会出现各种莫名其妙的挂起。
第一是根文件系统的格式。QEMU 挂载 SD 镜像最省事的方式是把 rootfs 做成 ext4 并挂到/dev/mmcblk0p2,用rootwait等待设备就绪。如果做成 initramfs,虽然启动更快,但每次改 host 程序都要重新打包整个镜像,迭代成本高得离谱。我前期用 ext4,后期定型了才考虑换 initramfs。
第二是串口控制台。QEMU 的 Versal 虚拟平台默认把串口映射到ttyAMA0,启动参数里必须写成console=ttyAMA0,并且在 QEMU 命令行里用-serial mon:stdio或-nographic接管。这两处只要有一处不匹配,表现就是启动到一半卡住,屏幕上什么都不打印,你会以为是内核崩了。
第三是设备树里的保留内存和访问路径。如果后续要用 hw_emu 加载 xclbin,设备树里需要有对应的保留内存区域和设备节点。手工改 dts 很容易漏字段,我的做法是让工具链根据平台自动生成基础 dts,再在system-user.dtsi里做增量覆盖,绝不直接改生成文件。
第四是关掉一切用不上的东西。QEMU 里的资源受限,内核里挂一堆用不上的驱动、文件系统、网络协议,启动时间能翻倍。我在离线阶段会把 NFS、蓝牙、无线、大部分 USB 驱动全部裁掉,只留串口、SD/MMC、ext4、tmpfs。启动时间从八十多秒压到二十秒出头,调试效率天差地别。
3.2 启动镜像的组装与 bif 写法
启动镜像用 bootgen 打包,输入是一份 bif 描述文件。加速平台上的结构大致长这样:
the_ROM_image: { image { { type=bootimage, file=design.pdi } { type=bootloader, file=versal_fsbl.elf } { type=pmcdata, load=0xF2000000, file=pmc_cdo.bin } { core=a72-0, exception_level=el-3, file=bl31.elf } { core=a72-0, exception_level=el-2, file=u-boot.elf } } }打包命令:
bootgen -arch versal -image boot.bif -o BOOT.BIN -w on几个实际经验:-w on一定要加,否则已存在的输出文件会直接报错退出;bif 里各段的顺序不能乱,bootloader 必须在 bootimage 之后、其他核之前;如果你的 PDI 是完整设计生成的,体积会到几十兆,QEMU 加载会明显变慢,离线阶段可以考虑先用一个最小 PDI 占位,等功能验证完再换完整的。
这里有个容易被忽略的点:纯 QEMU 环境下,PDI 里的 PL 配置并不会真正生效。QEMU 会“看起来”完成了加载,但 PL 逻辑不存在。所以如果你的数据通路依赖 PL 侧的 DMA,纯 QEMU 里必然跑不通。这也是为什么功能验证要先走 plio 加 aiesimulator,而不是一上来就指望 QEMU。
3.3 QEMU 启动命令逐项拆解
一条能用的启动命令大概是这样:
qemu-system-aarch64 \ -M xlnx-versal-virt \ -m 4G \ -smp 2 \ -kernel ./build/package/Image \ -drive file=./build/package/rootfs.ext4,format=raw,if=sd \ -append "console=ttyAMA0 root=/dev/mmcblk0p2 rw rootwait earlycon" \ -nographic \ -serial mon:stdio-M xlnx-versal-virt指定虚拟平台,不同版本的 QEMU 对这台机器的支持程度不一样,串口、SD、内存布局的细节可能有差异。-m 4G给的内存越大启动越慢,离线调试 2G 到 4G 够用。-smp 2模拟双核 A72,如果你想验证多线程 host 程序的竞争行为,可以调到 4,但注意 QEMU 的多核调度和真机差异很大,别拿这个结论去论证锁的开销。
-append里的每一项都有讲究:rootwait少了会在根设备还没就绪时尝试挂载然后 panic;earlycon少了则内核早期的打印全部丢失,一旦卡在启动初期你将完全无从下手;rw少了根文件系统只读,你连临时写日志都不行。这一行我建议直接抄,别自己精简。
进到系统之后,第一件事不是跑程序,而是确认环境:
uname -a ls /dev/mmcblk* cat /proc/cmdline dmesg | head -50如果/proc/cmdline和你在-append里写的不一致,说明 QEMU 用了内置的设备树覆盖了你的参数,这时候要么加-dtb显式指定,要么检查版本差异。这一步我见过太多次“参数明明写了却不生效”的情况,全都是设备树来源问题。
4. 数据通路对齐:host 和 AIE 之间最容易断的那几根线
图能编译、系统能启动,剩下的就是让数据真正流动起来。这一段是整条链路里最费时间的,因为错误表现极其模糊——程序不报错、也不崩溃,就是结果全零或者全是上一个版本的值。
4.1 host 侧调用链的正确姿势
host 程序的核心是三步:打开设备、加载 xclbin、拿到 graph 句柄并运行。用 XRT 的 C++ 接口大致是这样:
#include <xrt/xrt_device.h> #include <xrt/xrt_bo.h> #include <xrt/xrt_graph.h> auto device = xrt::device(0); auto uuid = device.load_xclbin("design.xclbin"); auto gh = xrt::graph(device, uuid, "GainGraph"); constexpr size_t N = 256 * 1024; auto bo_in = xrt::bo(device, N * sizeof(float), xrt::bo::flags::normal, 0); auto bo_out = xrt::bo(device, N * sizeof(float), xrt::bo::flags::normal, 0); // 填充输入并同步到设备侧 std::memcpy(bo_in.map(), input_data.data(), N * sizeof(float)); bo_in.sync(XCL_BO_SYNC_BO_TO_DEVICE); gh.run(1); gh.wait(); gh.wait(2000); // 单位毫秒 bo_out.sync(XCL_BO_SYNC_BO_FROM_DEVICE);load_xclbin在纯 QEMU 下必然失败,因为没有可用的加速器设备节点。所以这段代码的正经验证环境是 hw_emu。跑之前要做两件事:用emconfigutil生成emconfig.json,以及设置环境变量XCL_EMULATION_MODE=hw_emu。少了emconfig.json,XRT 会告诉你找不到设备;少了环境变量,它会真的去尝试打开物理设备。
emconfigutil --platform ${PLATFORM_REPO_PATHS}/<your_platform>/<your_platform>.xpfm --nd 1 export XCL_EMULATION_MODE=hw_emu ./host_exe design.xclbin4.2 地址对齐与 cache 一致性:两个必踩的坑
第一个坑是地址对齐。AI Engine 的 DMA 对访问地址有对齐要求,实践中把 buffer 起始地址和每次传输的长度都对齐到 64 字节最保险。我见过有人为了省内存,把偏移写成base + 13,仿真里碰巧能跑,换一组数据就全乱。xrt::bo 分配出来的内存本身是按页对齐的,问题往往出在你手动加偏移的那一刻。
第二个坑是 cache。PS 是带 cache 的,DMA 是不看 cache 的。你往bo_in.map()里写数据之后如果不 sync,DMA 读到的可能是内存里的旧值;同理,DMA 写完输出之后你不 sync 回来,读到的就是脏数据。这两个方向的 sync 缺一不可,而且顺序不能错:先写再 TO_DEVICE,先 FROM_DEVICE 再读。
有一条经验值得记下来:hw_emu 下对 cache 的模拟并不完整,缺 sync 有时候也能跑出正确结果。这会让很多人以为自己的代码是对的,一上板就全线崩溃。所以我在 host 代码里给自己定了个死规矩——只要 buffer 要过 DMA,sync 一律显式写,哪怕仿真跑到没问题也不删。
4.3 结果全零时的三分法排查
结果不对的时候不要瞎试,用三分法切:数据、通路、时序。
先确认数据到没到 AIE。在 graph 里临时插一个printf或者把 kernel 第一个元素直接透传出去,看 host 读到的是不是原始输入值。如果读到的是全零,说明数据根本没进 AIE,问题在 plio/gmio 的绑定或 host 侧的 sync;如果读到的是旧数据,说明传输发生了但缓冲区没刷新,问题在缓存或者 graph 未真正运行。
再确认 graph 有没有真的 run。在 hw_emu 下gh.run(1)是异步的,如果你紧接着就读输出,可能读的是空的。加一个gh.wait()或者std::this_thread::sleep_for,差别立竿见影。我在第一次用这套 API 时就栽在这里,找了半天原因,其实就是一个 wait 没写。
最后确认 kernel 真的被执行。看aiesimulator_output/里的 trace,或者简单点在 kernel 开头写一个全局计数器、结束后打印。这一层验证能排除掉“图跑起来了但某个 kernel 压根没被调度”的情况——多 kernel 共用 tile 且 ratio 设置不当时,确实会出现类似现象。
4.4 从仿真数据反推真实带宽的正确方法
仿真里能看到带宽数字,但直接拿它当真实性能会吃大亏。比较可靠的做法是用仿真数据做相对比较而不是绝对预测。
比如你有两个版本的数据通路设计,A 方案在 aiesimulator 里 DMA 等待占比 35%,B 方案 12%,那可以确定 B 明显优于 A,这个结论在真机上大概率也成立。但如果 B 方案报告“平均带宽 6.2 GB/s”,你直接拿这个数去论证 DDR 能扛住,就非常危险了——仿真模型里 DDR 的竞争、刷新、行冲突都做了简化。
我给自己的规矩是:仿真只用来做方案选型和瓶颈定位,绝对数值一律乘以 0.6 到 0.7 的折扣系数做保守估计,真正要定参数必须上板实测。这条规矩帮我避免了好几次把余量算得太紧、后面被迫重新设计的窘境。
5. 一条完整的排查链路:从 QEMU 起不来到结果偏差
下面是我这半年踩过的三类典型问题,按发生顺序排。每一类都给出完整链路,你遇到类似现象时可以照着走一遍。
5.1 现象:QEMU 启动卡在等待根设备
第一次遇到的时候屏幕上只有最后一行Waiting for root device /dev/mmcblk0p2...,然后就再也不动了。这个现象的原因可能有四五种,按下面的顺序查,基本能在十分钟内定位。
第一步看 SD 设备有没有被识别。在卡住的画面敲回车,如果还有 shell 响应(有些配置下救援 shell 会起来),执行ls /dev/mmcblk*。什么条目都没有,说明 QEMU 的 SD 控制器没把镜像当成块设备挂上,检查-drive的if=sd有没有写错,format=raw有没有和实际镜像格式对上。
第二步看分区表。把 rootfs.ext4 挂到宿主机上fdisk -l看一眼,确认确实有第二个分区。很多人的镜像只有一个分区,/dev/mmcblk0p2自然不存在。这种情况要么重新做双分区镜像,要么把启动参数改成/dev/mmcblk0p1。
第三步看文件系统类型。镜像如果是 ext4,内核配置里必须有CONFIG_EXT4_FS=y。裁内核的时候很容易把它顺手裁掉,尤其是用极小配置模板起步的时候。
第四步看rootwait和rootdelay。QEMU 里 SD 设备枚举比真机慢,加个rootdelay=5往往就解决了。
| 症状 | 最可能原因 | 验证手段 | 修复动作 |
|---|---|---|---|
| 完全没有 mmcblk 设备 | -drive参数错误 | 检查 if/format 字段 | 改为if=sd,format=raw |
| 有 mmcblk0 无 mmcblk0p2 | 镜像是单分区 | 宿主机fdisk -l | 重做双分区镜像或改 p1 |
| 报 unknown filesystem | 内核缺 ext4 支持 | grep EXT4 .config | 打开CONFIG_EXT4_FS |
| 设备出现但挂载超时 | 枚举慢于挂载 | 加rootdelay=5 | 增加等待时间 |
5.2 现象:xclbin 加载失败,报找不到设备
这个坑几乎每个从纯 QEMU 转向 hw_emu 的人都会踩。报错信息通常是Failed to find XRT device或者No devices found。
根因很直接:纯 QEMU 的虚拟平台里没有加速器设备节点,/dev/zocl之类的路径不存在。所以首先要确认的是“我现在到底在哪个模式下”。检查echo $XCL_EMULATION_MODE,如果没有输出,说明你以为在跑 hw_emu,实际上在跑纯 QEMU 的 Linux 镜像。
第二件事是确认emconfig.json在当前工作目录。XRT 在 hw_emu 模式下会读这个文件来定位模拟设备,读不到就退化成找真实设备,然后失败。生成它只需要一条命令,但经常被忘在文档的角落里。
第三件事是确认 xclbin 的 target 匹配。用--target hw链接出来的 xclbin 在 hw_emu 下加载会报版本或目标不匹配,必须用--target hw_emu重新链接:
v++ -l --target hw_emu \ --platform ${PLATFORM_REPO_PATHS}/<your_platform>/<your_platform>.xpfm \ --config ./cfg/link.cfg \ -o ./build/design.xclbin \ ./build/*.xo ./build/libadf.a这一条我建议直接写进脚本里,并且把 hw 和 hw_emu 分别输出到不同目录,避免手滑用错。
5.3 现象:hw_emu 跑起来了,但输出和 aiesimulator 对不上
这是最折磨人的一类,因为两边看起来都对,就是数字不一样。我的排查顺序是这样的。
先确认输入完全一致。aiesimulator 用的是 graph 里input_plio::create绑定的那个文件,hw_emu 用的是 host 里 memcpy 进去的那份数据。这两份数据只要有一个字节不同,后面全是白费功夫。我的做法是用md5sum对一下,一眼就能看出来。
再确认激发的次数。gh.run(1)在 hw_emu 下的语义和 aiesimulator 里跑一遍不完全等价,尤其是涉及多次迭代的图时,容易出现少跑或多跑。用一个带计数器的 kernel,把实际执行次数打出来对比。
最后确认数据类型的位宽解释。AIE 里int32、float、cint16这些类型的字节序和打包方式在 host 侧必须严格对应。我遇到过一次,host 里用的是std::vector<float>,graph 里写的是plio_32_bits,看起来都是 32 位没问题,但 kernel 里按定点解释,结果差了 2 的 16 次方。这种错误在两端各自看都合理,只有对齐起来才暴露。
6. 仿真结论的可信边界:哪些能带走,哪些必须上板
写完前面这些,最后必须谈一个容易被忽略的问题:你到底能从这套离线流程里带走多少东西。把仿真结论的边界说清楚,比多跑几次仿真更有价值。
6.1 可以放心带走的结论
功能正确性是最可靠的。kernel 的算法逻辑、边界处理、特殊值行为,在 x86simulator 和 aiesimulator 里验证过之后,到真机上基本不会变。底层执行的是同一段代码,只是执行单元不同。
host 侧的控制流同样可靠。参数解析、文件读写、异常分支、多 buffer 轮转的状态机,在 hw_emu 里跑通之后,换到真机大概率也一样。QEMU 对 ARM 指令的模拟是相当忠实的。
接口使用方式也一样。plio 和 gmio 的参数配置、XRT 的 API 调用顺序、graph 的启动停止时序,这些属于“用法”层面的东西,仿真里对了,真机上也对。
6.2 绝对不能带走的结论
绝对性能数字不能带走。周期级模型里对内存系统的建模是简化的,DDR 的实际带宽、行冲突、刷新开销、与 PS 侧其他主设备的竞争,全都没有充分体现。aiesimulator 报告里那个漂亮的吞吐数字,上板打个六折是常态。
功耗和温度不能带走。这两个指标完全依赖物理实现,仿真里一点信息都没有。
多核并发下的时序行为不能带走。QEMU 的 SMP 调度和真实硬件的 cache 一致性协议、内存屏障开销差异巨大,你在 QEMU 里验证“两线程同时写不会错”,不代表真机上不需要屏障。
PL 侧逻辑的行为不能带走。纯 QEMU 里 PL 是空壳,hw_emu 里 PL 由 XSIM 模拟,时序上是理想化的,真实布线延迟、时钟域跨越、复位释放顺序,都要上板才能确认。
6.3 上板前我自己的检查清单
每次从仿真阶段切换到真板,我都会过一遍下面这些项,缺一项都不走:
- kernel 的
runtime<ratio>是否按 aiesimulator 实测调过,而不是留默认值 - 所有跨 DMA 的 buffer 是否都显式写了双向 sync
- buffer 的起始地址和长度是否都对齐到 64 字节
- graph 的 placement 是否在目标平台的阵列尺寸范围内
- xclbin 的 target 是否已从 hw_emu 切回 hw 并重新链接
- 启动镜像里的 PDI 是否换成了完整设计版本
- 根文件系统里是否已经装好运行时需要的库
- 日志输出是否做了分级,避免上板后因为打印太多拖慢主流程
这套清单看起来啰嗦,但每一条都对应一次我真实踩过的坑。尤其是最后一条,曾经因为 debug 级别的打印没关,一帧数据多花了四十多毫秒,排查了半天以为是算法慢,结果是串口输出把 CPU 占满了。
最后再分享一个我觉得挺有用的小习惯:给每一层仿真都保留一份带时间戳的日志和产物快照,命名规则统一成阶段_日期_版本。当你三天后突然发现结果不对,想回到“上次还是对的”那个状态时,这份快照就是唯一的救命稻草。我现在的做法是每次跑出可用的结果就tar一份build/和aiesimulator_output/,单个包也就几十兆,存着不心疼,用起来真香。