🏠

conv2d_w1 模块技术深度解析

一句话概括

conv2d_w1 是 MNIST 卷积神经网络的第一层卷积实现,专门处理 \(28\times28\) 像素的输入图像,使用 \(3\times3\) 卷积核生成 16 个输出通道。它展示了如何在 Versal AI Engine-ML (AIE-ML) 上利用 Memory Tile 进行数据重排、使用 bfloat16 低精度计算、以及通过精心设计的向量化循环实现高效的 2D 卷积。


问题空间:为什么需要这个模块?

背景:MNIST 手写数字识别

MNIST 是一个经典的计算机视觉任务:识别 \(28\times28\) 像素的手写数字(0-9)。一个典型的卷积神经网络(CNN)会依次堆叠多个层:

输入图像 (28×28×1) 
    ↓
[Conv2D + ReLU] → 输出 (26×26×16)  ← 这就是 conv2d_w1 的工作
    ↓
[MaxPooling]    → 输出 (13×13×16)
    ↓
[Conv2D]        → 输出 (11×11×32)
    ↓
...更多层...
    ↓
[Dense 分类层]   → 10 个数字类别概率

核心挑战

在 AIE-ML 上实现第一层卷积面临几个独特挑战:

  1. 数据布局不匹配:原始图像是逐行存储的(row-major),但卷积计算需要同时访问相邻的 3 行数据(垂直方向的滑动窗口)
  2. 零填充(Zero Padding):为了保持输出尺寸或控制边界行为,需要在图像右侧填充 4 个零像素(将 28 列扩展到 32 列,便于 SIMD 对齐)
  3. 权重预加载:卷积核权重(\(3\times3\times16=144\) 个,加上 16 个 bias)需要在计算开始前一次性加载,并在多次迭代中复用
  4. 向量化效率:AIE-ML 的向量单元一次处理 16 个 bfloat16 数据,需要精心设计循环结构以最大化利用率

设计洞察

解决这些问题的关键洞察是:引入 Memory Tile 作为数据转换层。Memory Tile 是 Versal 架构中的硬件缓冲,可以在数据进入 AIE 核心之前完成复杂的重排和填充操作。这就像是机场的值机柜台——在乘客(数据)进入安检(AIE 核心计算)之前,先完成行李整理(数据重排)和证件检查(格式验证)。


架构概览

数据流图

flowchart LR subgraph PL["PL (Programmable Logic)"] IFM["ifm_i.txt
输入特征图"] WTS["wts_i.txt
权重数据"] OFM["ofm_o.txt
输出特征图"] end subgraph AIE["AIE Array"] subgraph Graph["conv2d_w1_graph"] PLIO_I["input_plio
PLIO_i"] PLIO_W["input_plio
PLIO_w"] PLIO_O["output_plio
PLIO_o"] subgraph SubGraph["内部组件"] WTS_INIT["wts_init_graph
权重预加载器"] MT["shared_buffer MT
Memory Tile
(数据重排+零填充)"] KERNEL["kernel kk
conv2d_w1
(核心计算)"] end end end IFM -->|"64-bit PLIO"| PLIO_I WTS -->|"64-bit PLIO"| PLIO_W PLIO_I -->|"ifm_i"| MT PLIO_W -->|"wts_i"| WTS_INIT WTS_INIT -->|"160 bfloat16"| KERNEL MT -->|"32×28 bfloat16"| KERNEL KERNEL -->|"32×26×16 bfloat16"| PLIO_O PLIO_O -->|"64-bit PLIO"| OFM

组件角色说明

组件 类型 角色描述
dut_graph Top-level Graph 测试平台封装,连接 PLIO 与内部子图,提供仿真入口
conv2d_w1_graph Sub-graph 第一层卷积的完整实现,包含 Memory Tile、权重初始化器和计算核
wts_init_graph Utility Graph 权重预加载子图,将流式输入转换为异步缓冲区,供计算核复用
shared_buffer MT Memory Tile 硬件缓冲区,执行写入 tiling(线性到块)和读取 tiling(带零填充的重排)
conv2d_w1 Kernel AIE 核心上的计算单元,执行实际的 3×3 卷积 + ReLU 激活

核心组件深度解析

1. dut_graph —— 测试平台封装

文件: conv2d_w1_app.cpp

这是模块的最外层包装,用于独立仿真和测试。它的职责很纯粹:建立 PL 与 AIE 之间的数据通道

class dut_graph : public graph {
public:
  conv2d_w1_graph dut;           // 被测设备(Device Under Test)
  input_plio   ifm_i;            // 输入特征图接口
  input_plio   wts_i;            // 权重输入接口
  output_plio  ofm_o;            // 输出特征图接口

  dut_graph( void )
  {
    // 创建 PLIO 端口,指定 64-bit 位宽和测试数据文件
    ifm_i = input_plio::create("PLIO_i", plio_64_bits, "data/ifm_i.txt");
    wts_i = input_plio::create("PLIO_w", plio_64_bits, "data/wts_i.txt");
    ofm_o = output_plio::create("PLIO_o", plio_64_bits, "data/ofm_o.txt");

    // 连接到内部子图
    connect<>(wts_i.out[0], dut.wts_i);
    connect<>(ifm_i.out[0], dut.ifm_i);
    connect<>(dut.ofm_o,    ofm_o.in[0]);
  }
};

设计要点

  • plio_64_bits: 选择 64 位位宽是因为 bfloat16 是 16 位,每次传输 4 个元素,平衡带宽与资源消耗
  • 文本数据文件: 仿真时从 data/*.txt 读取输入,输出写入 data/ofm_o.txt,便于验证
  • 单一职责: dut_graph 不做任何计算逻辑,只负责 I/O 粘合

main 函数的生命周期

int main(void) {
  aie_dut.init();      // 初始化所有内核和缓冲区
  aie_dut.run(1);      // 运行 1 次迭代(处理 4 张图像)
  aie_dut.end();       // 清理资源
  return 0;
}

注意注释中的提示:1 iteration = 4 images。这是因为 repetition_count(kk) = 4 在内核中设置了每次启动处理 4 张图片。


2. conv2d_w1_graph —— 第一层卷积图

文件: conv2d_w1_graph.h

这是模块的核心架构定义,展示了 AIE-ML 编程的关键抽象:数据流图(Dataflow Graph)

2.1 端口与成员声明

class conv2d_w1_graph : public graph {
public:
  input_port  ifm_i;     // 输入特征图端口
  input_port  wts_i;     // 权重输入端口
  output_port ofm_o;     // 输出特征图端口

  kernel kk;                                    // 计算内核
  shared_buffer<bfloat16> MT;                   // Memory Tile 缓冲区
  wts_init_graph<bfloat16, 160> weights;        // 权重预加载子图

为什么是 160?

权重总数 = $3\times3\text{ (卷积核)} \times 16\text{ (输出通道)} + 16\text{ (bias)} = 144 + 16 = 160$ 个 bfloat16。

2.2 内核配置

kk = kernel::create_object<conv2d_w1>();
source(kk) = "conv2d_w1.cpp";         // 源代码文件
runtime<ratio>(kk) = 0.9;              // 占用 90% 的 AIE 周期
repetition_count(kk) = 4;              // 每次启动处理 4 张图像

runtime(kk) = 0.9 的含义:

  • AIE 核心以固定频率运行(通常 1 GHz)
  • ratio = 0.9 表示该内核可以占用 90% 的周期,剩余 10% 留给其他任务或 DMA 传输
  • 这是一个性能调优参数,如果设置过高可能导致数据饥饿,过低则浪费算力

2.3 Memory Tile 的 Tiling 配置(核心设计)

这是整个模块最精妙的部分。Memory Tile 支持写入 tiling(数据如何进入缓冲区)和读取 tiling(数据如何从缓冲区读出):

写入 Tiling(bdw - buffer dimension write)

tiling_parameters bdw = { 
  .buffer_dimension = {28*28*4},      // 总缓冲区大小:28×28×4 张图
  .tiling_dimension = {2},             // 每次写入 2 个元素
  .offset = {0},
  .tile_traversal = {{.dimension=0, .stride=2, .wrap=28*28*2}}  // 线性遍历
};

想象成:把 4 张 MNIST 图像(每张 28×28=784 个像素)按顺序平铺成一维数组,每次写入 2 个像素

读取 Tiling(bdr - buffer dimension read)

tiling_parameters bdr = { 
  .buffer_dimension = {28, 28, 4},     // 逻辑形状:高 28,宽 28,4 张图
  .tiling_dimension = {32, 28, 1},     // 每次读取:高 32 行,宽 28 列,1 张图
  .offset = {0, 0, 0},
  .tile_traversal = {
    {.dimension=0, .stride=32, .wrap=1},   // 第 0 维:每次跳 32 行,不重复
    {.dimension=1, .stride=28, .wrap=1},   // 第 1 维:每次跳 28 列,不重复
    {.dimension=2, .stride=1,  .wrap=4}    // 第 2 维:逐张处理 4 张图
  },
  .boundary_dimension = {28, 28, 4}     // 实际有效数据边界
};

关键洞察tiling_dimension = {32, 28, 1} 比实际的 {28, 28, 1} 多了 4 行!这就是零填充的实现方式——当读取超出 28 行边界时,Memory Tile 自动返回 0。

类比理解

想象一个 28 层的书架(行),每层放 28 本书(列),共 4 个这样的书架( batch)。你想每次取出一个"切片"来做计算,但计算需要 32 层的高度。于是你告诉图书管理员(Memory Tile):"给我 32 层,但从第 29 层开始的位置就当作空(0)"。图书管理员会自动处理这个请求,无需你手动填充。

2.4 缓冲区创建与连接

// 创建共享缓冲区:大小为 4×28×28 个 bfloat16,1 个写入者,1 个读取者
MT = shared_buffer<bfloat16>::create({4*28*28}, 1, 1);
write_access(MT.in[0]) = tiling(bdw);   // 应用写入 tiling
read_access(MT.out[0]) = tiling(bdr);   // 应用读取 tiling
repetition_count(MT) = 1;               // Memory Tile 每轮迭代执行一次

2.5 完整的连接拓扑

connect<>(wts_i,          weights.wts_i );           // 外部权重 → 预加载器输入
connect<>(weights.wts_o,  kk.in[1] );                // 预加载器输出 → 内核权重输入
connect<>(ifm_i,          MT.in[0] );                // 外部图像 → Memory Tile
connect<>(MT.out[0],      kk.in[0] );                // Memory Tile → 内核数据输入
connect<>(kk.out[0],      ofm_o );                   // 内核输出 → 外部

dimensions(kk.in[1])  = {160};                       // 权重维度
dimensions(kk.in[0])  = {32*28};                     // 输入:32 行 × 28 列(含填充)
dimensions(kk.out[0]) = {32*26*16};                  // 输出:32 行 × 26 列 × 16 通道

single_buffer(kk.in[1]);                             // 权重使用单缓冲区(节省内存)

输出为什么是 32×26×16?

  • 卷积核 3×3,步长 1,无 padding(valid 模式):\(28 - 3 + 1 = 26\)
  • 但我们有 32 行的输入(含 4 行零填充),所以输出也是 32 行
  • 实际上只有前 26 行是有效卷积结果,后 6 行会被后续处理忽略或作为 padding
  • 16 是输出通道数

3. conv2d_w1 —— 卷积计算内核

文件: conv2d_w1.h, conv2d_w1.cpp

这是实际运行在 AIE 核心上的计算代码,使用 AIE API 进行向量化编程。

3.1 类定义与构造函数

class conv2d_w1 {
public:
  unsigned fsm_state;    // FSM 状态:0=初始,1=权重已加载

  conv2d_w1(void);
  void grab_wts(input_async_buffer<bfloat16>& wts_i);
  void passthru(input_buffer<bfloat16>& ifm_i, output_buffer<bfloat16>& ofm_o);
  void filter_3x3(input_buffer<bfloat16>& ifm_i, 
                  input_async_buffer<bfloat16>& wts_i,
                  output_buffer<bfloat16>& ofm_o);
  void run(input_buffer<bfloat16>& ifm_i,
           input_async_buffer<bfloat16>& wts_i,
           output_buffer<bfloat16>& ofm_o);
  
  static void registerKernelClass(void) {
    REGISTER_FUNCTION(conv2d_w1::run);
  }
};

构造函数设置了 AIE 的数值模式:

conv2d_w1::conv2d_w1(void) {
  aie::set_rounding(aie::rounding_mode::symmetric_inf);    // 对称舍入到无穷
  aie::set_saturation(aie::saturation_mode::saturate);     // 饱和而非溢出
  fsm_state = 0;
}
  • symmetric_inf: 舍入模式,例如 1.5 → 2, -1.5 → -2
  • saturate: 数值溢出时钳制到最大/最小值,而不是回绕

3.2 权重获取(grab_wts)

void conv2d_w1::grab_wts(input_async_buffer<bfloat16>& wts_i) {
  if (fsm_state == 0) {
    wts_i.acquire();      // 获取异步缓冲区的所有权
    fsm_state = 1;        // 标记为已加载
  }
}

为什么需要 FSM?

权重只需要在第一次运行时加载,之后可以复用。fsm_state 确保 acquire() 只调用一次。异步缓冲区(async_buffer)允许生产者(wts_init_graph)和消费者(conv2d_w1)以不同速率工作。

3.3 核心卷积算法(filter_3x3)

这是代码中最复杂的部分,展示了 AIE 向量化的精髓:

void conv2d_w1::filter_3x3(input_buffer<bfloat16>& ifm_i,
                           input_async_buffer<bfloat16>& wts_i,
                           output_buffer<bfloat16>& ofm_o) {
  // 缓冲区声明
  std::array<aie::vector<bfloat16, 32>, 3> buffd;   // 3 行输入数据缓冲
  std::array<aie::vector<bfloat16, 32>, 3> buffw;   // 3 个权重缓冲
  std::array<aie::accum<accfloat, 16>, 3> acc;      // 3 个累加器
  aie::accum<accfloat, 16> bias;                     // bias 项

数据缓冲策略

  • buffd[0/1/2] 分别存储卷积所需的 3 行输入数据
  • 每行 32 个元素(bfloat16),但只使用前 28 个,后 4 个是零填充
  • 使用 32 而非 28 是为了 SIMD 对齐(16 的倍数)

权重布局

buffw[0] = aie::zeros<bfloat16, 32>();  // 必须清零高 16 位
buffw[1] = aie::zeros<bfloat16, 32>();
buffw[2] = aie::zeros<bfloat16, 32>();

bias.from_vector(aie::load_v<16>(wts_i.data() + 144));  // 最后 16 个是 bias

卷积核权重排列(共 144 个 = 9×16):

wts[0..15]   = w0 for output channel 0..15
wts[16..31]  = w1 for output channel 0..15
...
wts[128..143] = w8 for output channel 0..15
wts[144..159] = bias for output channel 0..15

主循环结构

for (unsigned rr = 0; rr < 26; rr++)
  chess_prepare_for_pipelining    // 提示编译器准备流水线
{
  // 读取 3 行输入数据(每行拆分为两个 16 元素向量)
  buffd[0].insert(0, *(itrA + 0));  buffd[0].insert(1, *(itrB + 1));
  buffd[1].insert(0, *(itrA + 2));  buffd[1].insert(1, *(itrB + 3));
  buffd[2].insert(0, *(itrA + 4));  buffd[2].insert(1, *(itrB + 5));

chess_prepare_for_pipelining 是 Xilinx 特定的编译器指令,告诉 AIE 编译器优化循环流水线。

内层卷积计算(展开为 3 次迭代,对应 3×3 核的 9 个权重):

for (unsigned cc = 0; cc < 26; cc++)
  chess_prepare_for_pipelining
{
  // 第 1 次:w0, w1, w2(第一行)
  buffw[0].insert(0, *(itapA + 0));
  buffw[1].insert(0, *(itapB + 1));
  buffw[2].insert(0, *(itapA + 2));
  acc[0] = mul_elem_16_2(aie::broadcast<bfloat16, 32>(buffd[0].get(cc)), buffw[0]);
  acc[1] = mul_elem_16_2(aie::broadcast<bfloat16, 32>(buffd[0].get(cc + 1)), buffw[1]);
  acc[2] = mul_elem_16_2(aie::broadcast<bfloat16, 32>(buffd[0].get(cc + 2)), buffw[2]);
  
  // 第 2 次:w3, w4, w5(第二行)
  // ... mac_elem_16_2(乘累加)
  
  // 第 3 次:w6, w7, w8(第三行)
  // ... mac_elem_16_2

关键 AIE API 解释

  • mul_elem_16_2: 16 路并行乘法,每个 lane 计算两个元素的乘积(即 32 个 bfloat16 输入产生 16 个结果)
  • mac_elem_16_2: 乘累加(Multiply-Accumulate)
  • aie::broadcast<bfloat16, 32>(value): 将一个标量广播到 32 元素向量的所有位置
  • buffd[0].get(cc): 从数据缓冲中提取第 cc 个元素

为什么这样设计?

想象你在计算一个输出像素,它需要 3×3=9 次乘加。但 AIE 一次可以并行计算 16 个输出通道的同一个位置。所以对于每个输入像素位置,我们用 broadcast 将其值复制 32 次(实际使用低 16 位),然后与 16 个通道的对应权重相乘。

ReLU 激活与输出

// Add bias and RELU:
acc[0] = aie::add(aie::add(acc[0], acc[2]), aie::add(acc[1], bias));
*itw++ = aie::max(acc[0].to_vector<bfloat16>(), bfloat16(0));
  • 将 3 个累加器的结果相加(对应 3×3 核的 9 次乘加的聚合)
  • 加上 bias
  • ReLU: max(x, 0)

零填充输出

// Add zero-padding for pixels from 26 to 32:
*itw++ = aie::zeros<bfloat16, 16>();
*itw++ = aie::zeros<bfloat16, 16>();
// ... 共 6 次,输出 32 列(26 有效 + 6 零填充)

3.4 Run 方法

void conv2d_w1::run(input_buffer<bfloat16>& ifm_i,
                    input_async_buffer<bfloat16>& wts_i,
                    output_buffer<bfloat16>& ofm_o) {
  grab_wts(wts_i);           // 首次运行时加载权重
  filter_3x3(ifm_i, wts_i, ofm_o);  // 执行卷积
}

4. wts_init_graph / wts_init —— 权重预加载

文件: ../wts_init/wts_init_graph.h, ../wts_init/wts_init.h, ../wts_init/wts_init.cpp

这是一个可复用的模板化子图,用于将流式输入的权重转换为异步缓冲区。

4.1 图定义(wts_init_graph.h)

template<class T, unsigned SIZE>
class wts_init_graph : public graph {
public:
  kernel kk;
  input_port wts_i;
  output_port wts_o;

  wts_init_graph(void) {
    kk = kernel::create_object<wts_init<T, SIZE>>();
    source(kk) = "wts_init.cpp";
    runtime<ratio>(kk) = 0.9;

    connect<>(wts_i, kk.in[0]);
    connect<>(kk.out[0], wts_o);
    dimensions(kk.out[0]) = {SIZE};
  }
};

4.2 内核实现(wts_init.cpp)

template<class T, unsigned SIZE>
void wts_init<T, SIZE>::run(input_stream<T>* sig_i, output_async_buffer<T>& sig_o) {
  aie::vector<T, 16> vec;
  if (state == 0) {
    sig_o.acquire();
    auto itw = aie::begin_vector<16>(sig_o);
    for (unsigned ii = 0; ii < SIZE / 16; ii++)
      chess_prepare_for_pipelining
    {
      vec.insert(0, readincr_v<8>(sig_i));   // 从流读取 8 个
      vec.insert(1, readincr_v<8>(sig_i));   // 再读取 8 个
      *itw++ = vec;                          // 写入异步缓冲区
    }
    sig_o.release();
    state = 1;
  }
  X86SIM_TERMINATE_CURRENT_THREAD;   // x86 仿真时终止线程
}

为什么需要这个?

在 AIE 中,输入数据通常以流(stream)形式到达,但卷积计算需要随机访问权重(通过 wts_i.data())。wts_init 将流转换为异步缓冲区,使得 conv2d_w1 可以像访问数组一样访问权重。


数据流追踪:端到端的一次迭代

让我们追踪处理一张 MNIST 图像(实际代码一次处理 4 张)的完整流程:

阶段 1: PL 数据注入(由 dut_graph 的 PLIO 处理)
├─ ifm_i.txt 中的 28×28=784 个 bfloat16 像素
├─ wts_i.txt 中的 160 个 bfloat16 权重
└─ 通过 64-bit PLIO 接口进入 AIE Array

阶段 2: 权重预加载(wts_init_graph)
├─ 权重以流形式进入 wts_init 内核
├─ 内核将 160 个元素打包成 10 个 16 元素向量
├─ 写入异步缓冲区(acquire → 写入 → release)
└─ 此后缓冲区可被 conv2d_w1 反复访问

阶段 3: 输入数据重排(Memory Tile MT)
├─ 写入 tiling: 4 张图像的像素线性存入缓冲区
│   └─ 地址映射: image[i][row][col] → buffer[i×784 + row×28 + col]
├─ 读取 tiling: 按 32×28 的块读出,带零填充
│   └─ 行 0-27: 实际图像数据
│   └─ 行 28-31: Memory Tile 自动返回 0
└─ 输出到 conv2d_w1 的 ifm_i 端口

阶段 4: 卷积计算(conv2d_w1::filter_3x3)
├─ 首次调用: grab_wts() 获取权重缓冲区
├─ 外层循环: 26 行输出(rr = 0..25)
│   ├─ 读取 3 行输入到 buffd[0/1/2](滑动窗口)
│   ├─ 内层循环: 26 列输出(cc = 0..25)
│   │   ├─ 展开 3×3 卷积为 3 次 mac_elem_16_2 调用
│   │   ├─ 每次处理 16 个输出通道的同一位置
│   │   ├─ 累加 9 次乘加结果 + bias
│   │   └─ ReLU 激活,写入输出缓冲区
│   └─ 输出 6 列零填充(达到 32 列)
└─ 输出形状: 32 行 × 32 列 × 16 通道(实际有效: 26×26×16)

阶段 5: 输出回传
└─ 32×26×16=13312 个 bfloat16 通过 PLIO 写入 ofm_o.txt

设计决策与权衡

1. Memory Tile vs. 内核内零填充

选择的方案:使用 Memory Tile 进行零填充

// Memory Tile 配置中隐含的零填充
.boundary_dimension = {28, 28, 4}  // 实际数据边界
.tiling_dimension = {32, 28, 1}    // 读取时扩展到 32

替代方案:在内核中手动填充

// 假设的方案(未采用)
for (int i = 0; i < 28; i++)
  for (int j = 0; j < 28; j++)
    data[i][j] = input[i*28+j];
// 手动清零
for (int i = 28; i < 32; i++)
  for (int j = 0; j < 28; j++)
    data[i][j] = 0;

为什么选择 Memory Tile?

因素 Memory Tile 方案 内核内填充方案
AIE 核心利用率 高(只做计算) 低(额外循环开销)
内存带宽 减少 4/28 ≈ 14% 无效传输 需要显式写入 0
代码复杂度 tiling 配置复杂 内核逻辑复杂
灵活性 修改 tiling 即可调整填充 需要重新编译内核

权衡:增加了架构层面的复杂性(需要理解 tiling 参数),换取了计算核心的纯粹性和效率。

2. bfloat16 精度选择

shared_buffer<bfloat16> MT;  // 16-bit 浮点

为什么选择 bfloat16?

  • 范围: 与 float32 相同的指数位(8 bit),适合深度学习中的梯度动态范围
  • 精度: 尾数位较少(7 bit vs 23 bit),但 CNN 第一层对精度不敏感
  • 硬件支持: AIE-ML 原生支持 bfloat16 向量运算,峰值算力比 int8 更高
  • 内存占用: 相比 float32 减半,允许更大的 batch size 或更复杂的模型

风险:如果训练时使用 float32,推理转为 bfloat16 可能有轻微精度损失,但对于 MNIST 这种简单任务完全可以接受。

3. 同步 vs. 异步权重传输

// 输入特征图:同步缓冲区
input_buffer<bfloat16>& ifm_i

// 权重:异步缓冲区  
input_async_buffer<bfloat16>& wts_i

设计理由

  • ifm_i(同步):每轮迭代都需要新的图像数据,生产者-消费者同步推进
  • wts_i(异步):权重只需加载一次,之后复用多轮。异步允许 wts_init 在首次 release() 后退出,而 conv2d_w1 继续访问缓冲区

潜在问题:如果 repetition_count 设置错误,可能导致权重缓冲区过早释放或被覆盖。

4. 单缓冲区 vs. 双缓冲区

single_buffer(kk.in[1]);  // 权重使用单缓冲区
// 注意:ifm_i 没有 single_buffer 调用,默认可能是双缓冲

权衡

  • 单缓冲:节省内存,但消费者必须等待生产者完成
  • 双缓冲:允许生产者和消费者并行,但需要 2 倍内存

对于权重(只写一次、读多次),单缓冲是显然的选择。对于特征图数据,可能使用双缓冲来隐藏延迟。


新贡献者须知:陷阱与注意事项

1. Tiling 参数的调试噩梦

常见问题:修改了 buffer_dimension 但忘记同步修改 tile_traversal,导致数据错位。

// 错误示例(假设的)
tiling_parameters bdr = { 
  .buffer_dimension = {28, 28, 4},  // 改了这里
  .tiling_dimension = {32, 28, 1},
  // 忘记修改 tile_traversal 的 wrap 值!
  .tile_traversal = {{.dimension=0, .stride=32, .wrap=1},  // 应该根据新维度调整
                     {.dimension=1, .stride=28, .wrap=1},
                     {.dimension=2, .stride=1,  .wrap=4}}
};

调试建议

  • 使用 passthru() 方法(已提供在代码中)绕过卷积,直接检查 Memory Tile 的输出
  • 启用 AIE 仿真器的波形跟踪,检查数据在端口上的时序

2. 权重索引越界

bias.from_vector(aie::load_v<16>(wts_i.data() + 144));  // 偏移 144

如果 wts_init_graph 的模板参数不是 <bfloat16, 160>,而是更小或更大,这里会发生静默越界。

防御措施

// 建议在头文件中定义常量
constexpr unsigned CONV2D_W1_WTS_SIZE = 3*3*16 + 16;  // 144 + 16 = 160

3. 迭代次数与 repetition_count 的匹配

// conv2d_w1_graph.h
repetition_count(kk) = 4;      // 内核每启动一次处理 4 张图
repetition_count(MT) = 1;      // Memory Tile 每启动一次

// conv2d_w1_app.cpp
aie_dut.run(1);                // 1 次图级迭代

总处理量 = 图迭代次数 × 内核 repetition = \(1 \times 4 = 4\) 张图像。

如果修改了其中一个但没有同步修改,会导致数据不匹配或挂起。

4. 32 位对齐陷阱

// 注释中明确提到
// Note: <bfloat16> is 16-bit but memory tile requires 32-bit alignment:

即使 bfloat16 是 16 位,Memory Tile 的某些操作可能需要 32 位(2 个元素)对齐。tiling_dimension = {2}stride=2 确保了这一点。

5. Chess 编译器指令的副作用

chess_prepare_for_pipelining

这不是标准 C++,而是 Xilinx 特定的编译器指令。它在 x86 仿真时无害,但在实际 AIE 编译时会影响调度。如果循环内有条件分支或函数调用,流水线可能被打破。

6. 输出零填充的隐藏含义

// filter_3x3 末尾的零填充
*itw++ = aie::zeros<bfloat16, 16>();  // 共 6 次

这产生了 32 列输出,但只有前 26 列是有效卷积结果。下游模块(如 max_pooling2d_w2)需要知道这一点,或者自己再做一次裁剪。


构建与仿真

Makefile 关键目标

# 编译生成 libadf.a(AIE 归档文件)
make compile

# x86 功能仿真(快速验证逻辑正确性)
make x86com && make x86sim

# AIE 周期精确仿真(验证时序和性能)
make sim

# 带性能分析的仿真
make profile

# 清理所有生成文件
make clean

配置文件(aie.cfg)

[aie]
kernel-linting=true        # 启用内核代码检查
xlopt=1                    # 优化级别
verbose=true               # 详细输出
pl-freq=625                # PL 接口频率 625 MHz
Xmapper=BufferOptLevel9    # 缓冲区映射优化级别

相关模块链接

  • 同级卷积变体: conv2d_w3(第二层,3×3 核,32 输入通道→32 输出通道)、conv2d_w5(第三层,更复杂的并行策略)
  • 池化层: max_pooling2d_w2(通常接在 conv2d_w1 之后)
  • 全连接层: dense_w7(网络末端分类器)
  • 完整网络: mnist(所有层的集成)
  • 权重工具: wts_init(本模块依赖的权重预加载子图)

总结

conv2d_w1 模块是理解 Versal AIE-ML 编程范式的绝佳案例。它展示了:

  1. 分层架构:PLIO → Graph → Memory Tile → Kernel 的清晰层次
  2. 数据流优化:通过 Memory Tile 的 tiling 能力解决数据布局不匹配
  3. 向量化计算:利用 AIE API 实现高效的 SIMD 卷积
  4. 资源管理:异步缓冲区、单/双缓冲策略、runtime ratio 调优

对于新加入团队的工程师,建议按照以下顺序深入:

  1. 先跑通仿真make x86all 验证环境搭建正确
  2. 阅读数据流:理解 conv2d_w1_graph.h 的连接拓扑
  3. 分析 tiling 配置:修改参数观察仿真输出变化
  4. 深入内核代码:逐行理解 filter_3x3 的向量化技巧
  5. 对比其他层:查看 conv2d_w3/conv2d_w5 如何处理更复杂的场景
On this page