🏠

DMA Sink Egress Pipeline (DMA 输出流水线)

一句话概括

dma_sink_egress_pipeline 是整个 64K-点 IFFT 系统的"数据出口闸门"——它负责将 AI Engine 处理完成的 5 路并行数据流收集、重组,并以正确的时序写入 LPDDR 内存。想象它是一个高速分拣中心:5 条传送带同时送来包裹(数据),它必须按特定顺序整理后,一次性装入卡车(DDR 突发传输)。


问题空间与设计动机

我们面临什么挑战?

在 Versal ACAP 架构中实现 64K-点 IFFT @ 2 Gsps 时,系统需要解决一个经典的数据流瓶颈问题:

  1. AI Engine 的高吞吐输出:后端 AI Engine 以 5 路并行 AXI-Stream 输出数据,每路速率约 400 Msps,合计 2 Gsps
  2. DDR 的突发访问特性:LPDDR4 擅长大块连续读写,对零散小事务效率极低
  3. 数据重排需求:AI Engine 输出的数据是按列组织(column-major)的,但 DDR 存储需要行优先(row-major)布局以便主机读取

为什么不用简单方案?

方案 A:直接让 AI Engine 写 DDR

  • ❌ AI Engine 没有直接的 DDR 控制器接口
  • ❌ PL (Programmable Logic) 更适合处理复杂的地址生成和突发调度

方案 B:单路串行收集

  • ❌ 无法维持 2 Gsps 的总吞吐量
  • ❌ 会引入严重的背压(back-pressure)到 AI Engine

最终选择:多路并行收集 + 片上缓冲 + 突发写入

  • ✅ 5 路 AXI-Stream 同时接收,匹配 AI Engine 输出带宽
  • ✅ 使用 URAM 作为乒乓缓冲,解耦接收和发送时序
  • ✅ 向 DDR 发起大宽度突发传输,最大化内存带宽利用率

核心抽象与心智模型

ifft_dma_snk 想象成...

一个智能仓库的分拣系统

  • 5 个进货口 (sig_i[0..4]): 对应 5 路 AI Engine 输出流
  • 5 个货架区 (buff[5][...]): 每个进货口有独立的存储区域,避免争抢
  • 循环扫描器 (capture_streams): 按固定节奏从各进货口取货,暂存货架
  • 出货调度员 (read_buffer): 按客户要求的顺序(行优先)从货架取货,打包发往目的地
  • 选货开关 (loop_sel): 允许只提取某一轮循环的数据(用于调试/验证)

关键数据结构映射

┌─────────────────────────────────────────────────────────────┐
│                     260 x 260 数据矩阵                        │
│  (256x256 有效数据 + 4 行/列零填充)                           │
├─────────────────────────────────────────────────────────────┤
│  Stream 0 负责列: 0, 5, 10, ...  (模 5 同余的列)              │
│  Stream 1 负责列: 1, 6, 11, ...                               │
│  Stream 2 负责列: 2, 7, 12, ...                               │
│  Stream 3 负责列: 3, 8, 13, ...                               │
│  Stream 4 负责列: 4, 9, 14, ...                               │
└─────────────────────────────────────────────────────────────┘
         ↓ 按列接收 (Column-major order)
    capture_streams()
         ↓ 存储到 5-bank URAM 缓冲区
    buff[NSTREAM][DEPTH*DEPTH/NSTREAM]
         ↓ 按行读取 (Row-major order)
    read_buffer()
         ↓ 突发写入 DDR
    mem[NFFT/2]

架构详解

模块层次结构

dma_sink_egress_pipeline/
├── HLS Kernel: ifft_dma_snk_wrapper
│   ├── capture_streams()   # 阶段 1: 流数据捕获
│   └── read_buffer()       # 阶段 2: 缓冲区读出
├── HLS Config: hls.cfg     # 综合配置与约束
└── System Integration: vitis/system.cfg
    └── dma_snk instance    # 系统集成实例

数据流图

graph LR subgraph "AI Engine Array" AIE0["PLIO_back_o_0"] AIE1["PLIO_back_o_1"] AIE2["PLIO_back_o_2"] AIE3["PLIO_back_o_3"] AIE4["PLIO_back_o_4"] end subgraph "PL Kernel: ifft_dma_snk_wrapper" CAP["capture_streams()
#pragma HLS DATAFLOW"] BUF[("buff[5][13520]
URAM T2P")] READ["read_buffer()"] end subgraph "Memory Subsystem" DDR["LPDDR
AXI4-Full"] end AIE0 -->|axis| CAP AIE1 -->|axis| CAP AIE2 -->|axis| CAP AIE3 -->|axis| CAP AIE4 -->|axis| CAP CAP --> BUF BUF --> READ READ -->|m_axi
burst write| DDR

时序流水线视图

DMA Sink 采用双阶段流水线设计,capture_streamsread_buffer 通过 #pragma HLS DATAFLOW 实现重叠执行:

  • 阶段 1 (Capture): 从 5 路 AXI-Stream 接收数据,存入 URAM 缓冲区
  • 阶段 2 (Read): 从 URAM 按行优先读取,写入 DDR

当前迭代的读取与下一次迭代的捕获可以重叠执行,最大化吞吐量。


核心组件深度解析

1. capture_streams() —— 流数据捕获

职责: 从 5 路 AXI-Stream 接收数据,按列组织存储到内部缓冲区

函数签名:

void capture_streams( 
    TT_SAMPLE (&buff)[NSTREAM][DEPTH*DEPTH/NSTREAM], 
    TT_STREAM sig_i[NSTREAM],
    const int& loop_sel, 
    const int& loop_cnt 
);

关键设计决策:

决策 实现 原因
三级嵌套循环 llccrrss 外层控制迭代轮次,中层遍历列块,内层处理行和流
II=1 流水线 #pragma HLS pipeline II=1 每周期从每路流读取 2 个样本,维持全速吞吐
条件存储 if (ll == loop_sel) 支持选择性捕获,便于调试和验证
双样本打包 (val1, val0) 128-bit 总线携带两个 cint32 样本,提升带宽效率

地址计算逻辑:

// 输入: 按列到达 (Column-major)
// cc: 列块索引 (0 ~ DEPTH/NSTREAM-1)
// rr: 行索引 (0 ~ DEPTH/2-1)
// ss: 流索引 (0 ~ NSTREAM-1)
// addr: 线性缓冲区地址,每次递增 2 (因为一次读 2 个样本)
addr = addr + 2;

注意: 代码中的注释说 "Incoming samples arriving down columns",但实际循环结构是 cc 在外、rr 在内,这意味着数据实际上是按行块优先到达的。这是与 transpose 模块配合的设计约定。

2. read_buffer() —— 缓冲区读出

职责: 从内部缓冲区按行优先顺序读取,打包成 DDR 突发写入格式

函数签名:

void read_buffer( 
    TT_DATA mem[NFFT/2], 
    TT_SAMPLE (&buff)[NSTREAM][DEPTH*DEPTH/NSTREAM] 
);

关键设计决策:

决策 实现 原因
行优先遍历 rr 外层,cc 内层 匹配主机期望的数据布局
跨 bank 交织读取 ss0, ss1 交替 实现无冲突的并行 bank 访问
动态地址计算 addr0, addr1 更新 处理 5-bank 非 2 的幂次方的复杂映射
II=1 流水线 #pragma HLS PIPELINE II=1 每周期输出一个 128-bit 字到 DDR

地址计算复杂度:

// 由于 NSTREAM=5 不是 2 的幂,地址计算需要模运算
ss1 = (ss0 + 1) % NSTREAM;           // 下一个 stream
addr1 = (ss0 == NSTREAM-1) ? addr0 + DEPTH : addr0;  // 跨 bank 边界处理

// 每读完一对样本后的更新
if (ss0 == NSTREAM-1 || ss1 == NSTREAM-1) {
    addr0 = addr0 + DEPTH;           // 跳到下一行的起始
}
ss0 = (ss0 + 2) % NSTREAM;           // 步进 2,因为是成对读取

3. ifft_dma_snk_wrapper() —— 顶层封装

接口定义:

void ifft_dma_snk_wrapper(
    TT_DATA mem[NFFT/2],      // m_axi: DDR 目标地址
    int loop_sel,             // s_axilite: 选择捕获哪一轮迭代
    int loop_cnt,             // s_axilite: 总迭代次数
    TT_STREAM sig_i[NSTREAM]  // axis: 5 路输入流
);

HLS 接口约束:

#pragma HLS interface m_axi      port=mem         bundle=gmem    offset=slave   depth=NFFT/2
#pragma HLS interface axis       port=sig_i
#pragma HLS interface s_axilite  port=loop_sel    bundle=control
#pragma HLS interface s_axilite  port=loop_cnt    bundle=control
#pragma HLS interface s_axilite  port=mem         bundle=control
#pragma HLS interface s_axilite  port=return      bundle=control
#pragma HLS DATAFLOW

存储资源:

TT_SAMPLE buff[NSTREAM][DEPTH*DEPTH/NSTREAM];
// = 5 × (260 × 260 / 5) = 5 × 13520 = 67600 个样本
// = 67600 × 64 bit = 5.4 Mbit ≈ 85 URAM blocks (assuming 64Kb each)
#pragma HLS array_partition variable=buff dim=1    // 完全分区第 1 维
#pragma HLS bind_storage variable=buff type=RAM_T2P impl=uram  // 真双端口 URAM

为何选择 URAM T2P?

  • URAM: 比 BRAM 容量大(64Kb vs 18Kb),适合大缓冲区
  • T2P (True Dual Port): 支持同时读写不同地址,DATAFLOW 流水线必需
  • array_partition dim=1: 5 个 bank 完全独立,消除访问冲突

关键设计权衡

1. 零填充 (Zero Padding) 的取舍

背景: 256 不能被 5 整除,但为了 5 路并行处理...

选项 实现 利弊
A: 严格 256x256 不均匀分配 256 行到 5 个流 ❌ 地址计算极其复杂,容易产生 bank 冲突
B: 零填充到 260x260 每流处理 52 行 (260/5=52) ✅ 均匀分配,简化地址生成;❌ 浪费约 3% 存储和带宽

选择 B,因为硬件设计的简洁性远比 3% 的效率损失更有价值。

2. 单缓冲 vs 乒乓缓冲

方案 资源 性能 适用场景
单缓冲 50% URAM 捕获和读取必须串行 低吞吐、资源受限
乒乓缓冲 100% URAM 完全流水线化 高吞吐、实时性要求

本设计实际使用单缓冲 + DATAFLOW,依赖 HLS 自动推断的乒乓行为(通过函数级并行实现)。严格来说,这不是硬件乒乓,而是软件流水线的重叠执行。

3. 流控策略

假设: AI Engine 和 PL 之间的流控由 AXI-Stream 的 TREADY/TVALID 握手自动处理

风险: 如果 DDR 写入慢于 AI Engine 输出,背压会传递到 AI Engine

缓解:

  • 确保 read_buffer 的 throughput ≥ capture_streams
  • 两者都是 II=1,且 DDR 突发写入带宽充足

带宽计算澄清:

  • 每帧数据量: NFFT/2 = 32768TT_DATA 字,每个 128-bit
  • 总数据量: 32768 × 128 = 4 Mb = 0.5 MB 每帧
  • 帧时间 (2 Gsps): 32768 / 2G = 16.384 μs
  • 所需 DDR 带宽: 0.5 MB / 16.384 μs ≈ 30.5 MB/s

这个带宽远低于 DDR4 的能力,因此不会成为瓶颈。

4. loop_sel 的设计意图

这个参数看似多余——为什么不总是捕获所有数据?

用途:

  • 验证模式: 在仿真中选择特定迭代检查中间结果
  • 调试支持: 隔离问题到特定数据帧
  • 部分重配置: 理论上支持只更新部分输出(虽然当前设计未充分利用)

与系统其他部分的协作

上游依赖

AI Engine Back-End (ifft256p4)
    ↓ PLIO_back_o_[0-4]
    ↓ AXI-Stream @ 312.5 MHz
ifft_dma_snk_wrapper

下游连接

ifft_dma_snk_wrapper
    ↓ m_axi (gmem bundle)
    ↓ AXI4-Full burst
LPDDR Controller
    ↓
Host CPU (PS)

横向关联

模块 关系 说明
dma_source_ingress_pipeline 对称角色 负责数据输入,结构与 sink 类似但方向相反
transpose_compute_stage 数据生产者 Transpose 的输出是 Sink 的输入
host.cpp 控制者 配置 loop_selloop_cnt,启动 DMA

新贡献者必读:陷阱与注意事项

⚠️ 常见错误

  1. 误解数据到达顺序

    • 注释说 "down columns",但看循环结构是行块优先
    • 建议: 始终以实际仿真波形为准,不要只看注释
  2. 忽视 EXTRA 的影响

    • DEPTH = 260 不是 256,缓冲区大小计算要包含零填充
    • 错误: 256 * 256 / 5
    • 正确: 260 * 260 / 5 = 13520
  3. 地址计算的整数溢出

    // 危险: addr 可能超出 int 范围
    int addr = rr * DEPTH + cc;  // 如果 rr=255, cc=255, DEPTH=260
                                 // 255 * 260 + 255 = 66555 < 2^31 (安全)
                                 // 但对于更大的 FFT 尺寸要小心
    
  4. 混淆 loop_selloop_cnt

    • loop_cnt: 总共运行多少次迭代
    • loop_sel: 只保存第几次迭代的数据(0-indexed)
    • 如果 loop_sel >= loop_cnt,不会保存任何有效数据

🔧 调试技巧

  1. 使用 C-Simulation 验证地址计算

    cd hls/ifft_dma_snk
    make csim
    
  2. 检查波形中的握手信号

    • 关注 sig_i_*_TVALIDsig_i_*_TREADY
    • 如果 TREADY 长期为低,说明背压传递到 AI Engine
  3. 验证 DDR 写入地址

    • 在 hw_emu 中检查 m_axi_gmem_AWADDR 是否单调递增
    • 突发长度应该是连续的

📋 修改检查清单

如果你需要修改这个模块:

  • [ ] 常量变更 (NSTREAM, EXTRA, NFFT_1D) 需要在头文件和 system.cfg 中同步
  • [ ] 地址计算逻辑修改后,用 MATLAB 模型验证数据布局
  • [ ] 缓冲区大小变化时,检查 URAM 资源是否足够
  • [ ] 接口协议变化时,更新 system.cfg 的 connectivity 部分
  • [ ] 测试bench 的 golden 数据需要重新生成

参考文档

On this page