🏠

channelizer_vitis_system_kernels 模块深度解析

一句话概括

本模块是 Versal ACAP 信道化器系统的"数据高速公路收费站" —— 它负责在片外 LPDDR 内存与片上 AIE-ML 计算阵列之间建立高吞吐量的数据通道,解决"外部存储器带宽远低于 AIE 计算需求"的瓶颈问题。通过精心设计的 HLS 数据搬运内核(DMA Source/Sink),它将连续的内存访问转换为符合 AIE 多路并行流接口的数据格式,同时通过 DATAFLOW 流水线实现数据传输与计算的完全重叠。


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

核心矛盾:内存墙与计算需求的鸿沟

在信道化器(Channelizer)这类高性能信号处理系统中,存在一个根本性的架构挑战:

  • AIE-ML 的计算能力:以 cint16@2 Gspscint32@2 Gsps 的速率处理数据
  • LPDDR4 的实际带宽:远低于上述需求,且存在高延迟、非确定性访问特性
  • 数据格式不匹配:AIE 期望的是多路并行的流式数据(polyphase order),而 DDR 中存储的是线性排列的样本

如果直接将 AIE 连接到 DDR,要么无法满足吞吐量要求,要么会因等待数据而浪费大量计算周期。

设计洞察:分层存储 + 格式转换 + 流水线重叠

解决方案借鉴了计算机体系结构中的经典思想:用容量换速度,用并行换吞吐。具体而言:

  1. PL 端缓存层:在可编程逻辑(PL)中部署 URAM/BRAM 作为乒乓缓冲区,将 DDR 的高延迟突发访问转换为低延迟的片上访问
  2. 格式重排:将 DDR 中的线性样本顺序转换为 AIE 所需的 polyphase 顺序(多相滤波器组要求的交错格式)
  3. 双缓冲流水线:使用 #pragma HLS DATAFLOW 让"加载下一批数据"和"发送当前批次"完全并行,隐藏 DDR 访问延迟

可以把整个系统想象成一个现代化的物流中心

  • DDR 是远在郊区的大型仓库(容量大、访问慢)
  • PL 端的 URAM 是市区的分拣中心(容量小、访问快)
  • DMA Source 是进货流程:卡车从仓库批量拉货 → 在分拣中心按目的地重新打包 → 通过多条传送带并行送出
  • DMA Sink 是出货流程:多条传送带接收货物 → 在分拣中心合并 → 卡车批量运回仓库

架构全景

flowchart LR subgraph External["External Memory (LPDDR)"] MEM[(Memory)] end subgraph PL["Programmable Logic (PL)"] direction TB SRC[channelizer_dma_src_wrapper
2 streams @ 128-bit] SNK[channelizer_dma_snk_wrapper
4 streams @ 128-bit] SPLIT0[channelizer_split0
1x8 split] SPLIT1[channelizer_split1
1x8 split] MERGE8x4[channelizer_merge_8x4
8x4 merge] end subgraph AIE["AI Engine Array"] FIR[firbank_graph
Polyphase Filter Bank] IFFT[ifft4096_2d_graph
2D IFFT] end MEM <-->|m_axi
burst access| SRC SRC -->|axis
sig_o_0| SPLIT0 SRC -->|axis
sig_o_1| SPLIT1 SPLIT0 -->|8 streams| FIR SPLIT1 -->|8 streams| FIR FIR -->|8 streams| MERGE8x4 IFFT -->|4 streams| MERGE8x4 MERGE8x4 -->|axis
4 streams| SNK SNK <-->|m_axi
burst access| MEM

关键组件角色

组件 类型 角色定位 核心职责
channelizer_dma_src_wrapper HLS Kernel 数据入口网关 从 LPDDR 读取原始样本,重排为 polyphase 格式,通过 2 路 AXI-Stream 输出
channelizer_dma_snk_wrapper HLS Kernel 数据出口网关 从 4 路 AXI-Stream 接收处理结果,选择性捕获指定循环迭代的数据,写回 LPDDR
channelizer_split0/1 HLS Kernel 流分发器 将 2 路 128-bit 流拆分为 16 路(每路 8 路),匹配 FIR 滤波器组的并行度
channelizer_merge_8x4 HLS Kernel 流聚合器 将 FIR 输出的 8 路与 IFFT 输出的 4 路合并为 4 路,适配 DMA Sink 的输入宽度

核心组件深度剖析

1. DMA Source: channelizer_dma_src_wrapper

功能定位

这是整个系统的数据入口闸门,负责将外部内存中的原始 IQ 样本注入 AIE 计算流水线。它的设计必须同时满足三个苛刻约束:

  1. 带宽约束:支持 cint16@2 Gsps 的总吞吐量
  2. 格式约束:将线性样本序列转换为 polyphase 顺序(用于多相滤波器组)
  3. 时序约束:通过双缓冲流水线隐藏 DDR 访问延迟

类型系统与常量定义

namespace channelizer_dma_src {
  static constexpr unsigned NSTREAM = 2;        // 2 路并行输出流
  static constexpr unsigned NCHAN = 4096;       // 信道化器通道数
  static constexpr unsigned NTRANSFORMS = 4;    // 每次处理的变换块数
  static constexpr unsigned DEPTH = NTRANSFORMS*NCHAN/NSTREAM/4;  // 缓冲区深度
  static constexpr unsigned NBITS = 128;        // PLIO 总线位宽 @ 312.5 MHz
  typedef ap_uint<NBITS> TT_DATA;               // 4 个 cint16 打包在一起
  typedef ap_uint<NBITS/4> TT_SAMPLE;           // 单个 cint16
  typedef hls::stream<TT_DATA> TT_STREAM;
}

关键设计决策:为什么 NSTREAM=2

信道化器需要处理 cint16@2 Gsps 的数据率。AIE-ML 的 PLIO 接口运行在 312.5 MHz,位宽 128-bit(可容纳 4 个 cint16)。因此单条流的吞吐量为:

\[\text{Throughput}_{\text{single}} = 312.5 \text{ MHz} \times 4 \text{ samples} = 1.25 \text{ Gsps}\]

要达到 2 Gsps,需要 \(2 / 1.25 = 1.6\) 条流,向上取整为 2 条流。这是一个典型的面积-带宽权衡:增加流数量会消耗更多 PL 资源,但能满足吞吐量需求。

内部流水线:load_buffer + transmit

void channelizer_dma_src_wrapper(TT_DATA mem[DEPTH*NSTREAM], int loop_cnt, TT_STREAM sig_o[NSTREAM])
{
#pragma HLS DATAFLOW

  TT_SAMPLE buff[NSTREAM][DEPTH*4];  // 内部双缓冲
#pragma HLS array_partition variable=buff dim=1
#pragma HLS bind_storage variable=buff type=RAM_T2P impl=uram
#pragma HLS dependence variable=buff type=intra false

  load_buffer(mem, buff);      // 阶段 1: DDR → URAM
  transmit(buff, sig_o, loop_cnt);  // 阶段 2: URAM → AXI-Stream
}

#pragma HLS DATAFLOW 是本内核的灵魂所在。它告诉 HLS 工具:

  • load_buffertransmit 是两个独立的流水线阶段
  • transmit 在处理第 N 批数据时,load_buffer 可以同时加载第 N+1 批
  • 两者通过 buff 进行生产者-消费者通信

这实现了**双缓冲(double buffering)**效果,DDR 访问延迟被完全隐藏在流水线中。

load_buffer: 格式重排的核心

void load_buffer(TT_DATA mem[DEPTH*NSTREAM], TT_SAMPLE (&buff)[NSTREAM][DEPTH*4])
{
LOAD_SAMP: for (int mm=0, dd=0, ss=0; mm < DEPTH*NSTREAM; mm++) {
#pragma HLS pipeline II=1
    TT_SAMPLE val0, val1, val2, val3;
    (val3,val2,val1,val0) = mem[mm];           // 从 DDR 读取 4 个样本
    buff[ss  ][dd  ] = val0;                   // 分配到 stream 0
    buff[ss+1][dd  ] = val1;                   // 分配到 stream 1
    buff[ss  ][dd+1] = val2;                   // 下一轮 stream 0
    buff[ss+1][dd+1] = val3;                   // 下一轮 stream 1
    dd += 2;
  }
}

这段代码执行了关键的polyphase 重排

  • 输入 DDR 数据是线性顺序:sample 0, 1, 2, 3, 4, 5, 6, 7...
  • 输出到 buffer 时被交错分配到多个流:stream0 获得 samples 0,2,4,6...,stream1 获得 samples 1,3,5,7...
  • 这种交错正是多相滤波器组(polyphase filter bank)所要求的输入格式

内存布局示意

DDR 线性存储: [S0,S1,S2,S3] [S4,S5,S6,S7] [S8,S9,S10,S11] ...
                    ↓ load_buffer 重排
Buffer[0][]:  S0, S2, S4, S6, S8, S10...  (stream 0)
Buffer[1][]:  S1, S3, S5, S7, S9, S11...  (stream 1)

transmit: 流式输出

void transmit(TT_SAMPLE (&buff)[NSTREAM][DEPTH*4], TT_STREAM sig_o[NSTREAM], const int& loop_cnt)
{
REPEAT: for (int ll=0; ll < loop_cnt; ll++) {     // 重复多次传输
RUN_DEPTH: for (int cc=0, dd=0; cc < DEPTH; cc++) {
#pragma HLS PIPELINE II=1
    for (int ss=0; ss < NSTREAM; ss++) {
      TT_SAMPLE val0 = buff[ss][dd  ];
      TT_SAMPLE val1 = buff[ss][dd+1];
      TT_SAMPLE val2 = buff[ss][dd+2];
      TT_SAMPLE val3 = buff[ss][dd+3];
      sig_o[ss].write((val3,val2,val1,val0));     // 打包为 128-bit 输出
    }
    dd += 4;
  }
}

注意这里的打包方向(val3,val2,val1,val0)。HLS 的 ap_uint 拼接遵循高位在左的约定,即 val3 占据最高 32-bit,val0 占据最低 32-bit。这与 AIE 端的预期一致。


2. DMA Sink: channelizer_dma_snk_wrapper

功能定位

这是系统的数据出口闸门,负责将 AIE 处理完成的结果捕获回外部内存。相比 Source,Sink 面临不同的挑战:

  1. 多源汇聚:需要同时接收来自 FIR 滤波器组和 IFFT 模块的输出(共 4 路流)
  2. 选择性捕获:支持 loop_sel 参数,只保存指定迭代周期的数据(用于调试/验证)
  3. 格式逆变换:将 AIE 输出的 polyphase 顺序还原为适合主机分析的线性顺序

类型系统对比

namespace channelizer_dma_snk {
  static constexpr unsigned NSTREAM = 4;        // 4 路并行输入流(vs Source 的 2 路)
  static constexpr unsigned NFFT_1D = 64;       // 2D IFFT 的第一维大小
  static constexpr unsigned NFFT = NFFT_1D*NFFT_1D;  // 4096 点 IFFT
  static constexpr unsigned NTRANSFORMS = 4;
  static constexpr unsigned DEPTH = NFFT*NTRANSFORMS/NSTREAM/2;  // 每个流处理的样本数
  static constexpr unsigned NBITS = 128;
  typedef ap_uint<NBITS> TT_DATA;               // 2 个 cint32 打包(vs Source 的 4 个 cint16)
  typedef ap_uint<NBITS/2> TT_SAMPLE;           // 单个 cint32
  typedef hls::stream<TT_DATA> TT_STREAM;
}

关键差异分析

参数 Source Sink 原因
NSTREAM 2 4 Sink 需接收 FIR(8路→经merge后为部分) + IFFT(4路) 的合并输出
TT_DATA 内容 4 x cint16 2 x cint32 IFFT 输出精度更高,需要 cint32
DEPTH 计算 /4 (4 samples/word) /2 (2 samples/word) 数据类型宽度不同

选择性捕获机制

void capture_streams(TT_SAMPLE (&buff)[NSTREAM][2*DEPTH], TT_STREAM sig_i[NSTREAM],
                     const int& loop_sel, const int& loop_cnt)
{
CAPTURE: for (int ll=0; ll < loop_cnt; ll++) {
#pragma HLS LOOP_TRIPCOUNT min=1 max=8
  for (int cc=0, addr=0; cc < DEPTH; cc++) {
#pragma HLS pipeline II=1
    for (int ss=0; ss < NSTREAM; ss++) {
      (val1, val0) = sig_i[ss].read();
      if (ll == loop_sel) {              // 关键:只保存选定的循环迭代
        buff[ss][addr+0] = val0;
        buff[ss][addr+1] = val1;
      }
    }
    addr = addr + 2;
  }
}

loop_sel 参数的设计意图:

  • 在硬件仿真或调试阶段,可能只需要检查某一次迭代的结果
  • 避免保存全部数据占用过多内存和时间
  • 这是一个调试友好型设计,在生产环境中可以设置为捕获所有迭代

缓冲区读出的交织模式

void read_buffer(TT_DATA mem[DEPTH], TT_SAMPLE (&buff)[NSTREAM][2*DEPTH])
{
  for (int rr=0, mm=0; rr < 2*DEPTH; rr+=2) {
    int addr0 = rr;
    int ss0 = 0;
    for (int cc=0; cc < NSTREAM; cc++) {
#pragma HLS PIPELINE II=1
      TT_SAMPLE val0 = buff[ss0  ][addr0];
      TT_SAMPLE val1 = buff[ss0+1][addr0];
      mem[mm++] = (val1, val0);
      ss0 += 2;
      if (ss0 == NSTREAM) {
        addr0++;
        ss0 = 0;
      }
    }
  }
}

这段代码执行了复杂的矩阵转置式读出

  • Buffer 是按流索引组织的:buff[stream][sample]
  • DDR 输出需要按时间顺序组织,同时保持流的交错
  • 读出模式类似于对一个小矩阵做转置操作

系统连接拓扑

system.cfg 中的连接定义

# HLS PL Kernels 实例化
nk = channelizer_dma_src_wrapper:1:dma_src
nk = channelizer_dma_snk_wrapper:1:dma_snk

# DDR 到 DMA Source 的内存映射连接
sp=dma_src.mem:LPDDR

# DMA Source 到 Split 内核的流连接
sc = dma_src.sig_o_0:channelizer_split0.sig_i
sc = dma_src.sig_o_1:channelizer_split1.sig_i

# Merge 内核到 DMA Sink 的流连接
sc = channelizer_merge_8x4.sig_o_0:dma_snk.sig_i_0
sc = channelizer_merge_8x4.sig_o_1:dma_snk.sig_i_1
sc = channelizer_merge_8x4.sig_o_2:dma_snk.sig_i_2
sc = channelizer_merge_8x4.sig_o_3:dma_snk.sig_i_3

# DMA Sink 到 DDR 的内存映射连接
sp=dma_snk.mem:LPDDR

连接语义解读

  • nk = <kernel>:<count>:<instance_name>:实例化内核,count=1 表示单实例
  • sp = <instance>.<port>:<memory_bank>:内存映射端口绑定到特定内存库
  • sc = <src>.<port>:<dst>.<port>:AXI-Stream 点对点连接

时钟域统一

id=2:dma_src.ap_clk,channelizer_split0.ap_clk,...,dma_snk.ap_clk

所有相关内核共享同一个时钟 ID(2),确保:

  • 跨内核的 AXI-Stream 接口无需异步 FIFO
  • 时序收敛由 Vivado 统一处理
  • 简化了时序约束和调试

设计决策与权衡

1. 为什么选择 URAM 而非 BRAM?

#pragma HLS bind_storage variable=buff type=RAM_T2P impl=uram
存储器类型 容量/块 特点 适用场景
BRAM 36Kb 低延迟、高频率、资源丰富 小容量、高频访问
URAM 288Kb (8x BRAM) 高密度、稍高延迟 大容量缓冲区

决策理由

  • Source 的 buffer 大小为 2 × (DEPTH×4) × 2 bytes ≈ 64KB,需要约 18 个 BRAM 或 2-3 个 URAM
  • URAM 节省了宝贵的 BRAM 资源,留给其他更需要低延迟的逻辑
  • 双端口(T2P)配置允许 load_buffertransmit 同时访问不同地址

2. 为什么 Source 和 Sink 的流数量不一致?

这是数据类型演变的结果:

Input:  cint16 @ 2 Gsps → 需要 2 路 128-bit 流 (4 cint16/stream word)
Processing:
  - FIR Filter Bank: 工作在 cint16,输出仍是 cint16
  - 2D IFFT: 内部累加需要更高精度,输出升级为 cint32
Output: cint32 @ 2 Gsps → 需要 4 路 128-bit 流 (2 cint32/stream word)

中间的 merge_8x4 内核承担了流数量缩减的职责:将 FIR 的 8 路输出与 IFFT 的 4 路输出合并为 4 路,匹配 Sink 的输入宽度。

3. 为什么使用 hls::stream_of_blocks 而非普通 hls::stream

查看 split_1x16merge_8x4 的实现,它们使用了 Vitis HLS 的高级特性:

// split_1x16.cpp
void consumer(TT_STREAM& sig_i, hls::stream_of_blocks<TT_BLOCK>& ss) {
  hls::write_lock<TT_BLOCK> WL(ss);   // 获取写锁,操作整个 block
  // ...
}

stream_of_blocks 提供了:

  • 原子性访问:通过 write_lock/read_lock 确保对整个数据块的独占访问
  • 更高效的数据移动:block 级别的传输减少了细粒度流操作的 overhead
  • 自然的 ping-pong 行为:适合 DATAFLOW 区域的生产者-消费者同步

4. Vivado 实现策略的选择

prop=run.impl_1.steps.opt_design.args.directive=Explore
prop=run.impl_1.steps.place_design.args.directive=Explore
prop=run.impl_1.{steps.place_design.args.MORE OPTIONS}={-net_delay_weight low}
prop=run.impl_1.steps.phys_opt_design.args.directive=AggressiveExplore
prop=run.impl_1.steps.route_design.args.directive=NoTimingRelaxation

这些设置反映了设计者对该模块时序的保守态度

  • Explore 指令:花更多时间寻找更优的布局方案
  • -net_delay_weight low:优先考虑线长而非单纯的延迟
  • AggressiveExplore:积极的物理优化,修复时序违例
  • NoTimingRelaxation:不允许布线器放宽时序约束

原因:312.5 MHz 对于 PL 逻辑来说是一个相对激进的频率,加上 AXI-Stream 接口的严格握手要求,需要全力以赴保证时序收敛。


数据流完整追踪

让我们跟随一个 IQ 样本从进入系统到离开的全过程:

Ingress 路径(Source → AIE)

1. Host 准备数据
   在 LPDDR 中填充线性排列的 cint16 样本数组
   
2. dma_src.load_buffer()
   - 发起 m_axi 突发读请求,每次读取 128-bit(4 个 cint16)
   - 将样本重排为 polyphase 顺序写入 URAM
   - Stream 0: samples 0,2,4,6...  Stream 1: samples 1,3,5,7...
   
3. dma_src.transmit()
   - 从 URAM 读取 4 个样本,打包为 128-bit
   - 通过 axis 接口发送到 split 内核
   - II=1,每周期输出 2 个 128-bit word
   
4. channelizer_split0/1
   - 每路输入拆分为 8 路输出
   - 总共 16 路流进入 FIR 滤波器组
   
5. firbank_graph
   - 16 路并行处理,每路处理自己的 polyphase 分支
   - 输出仍为 16 路 cint16 流

Egress 路径(AIE → Sink)

1. ifft4096_2d_graph
   - 接收 FIR 的中间结果
   - 执行 2D 4096 点 IFFT,输出升级为 cint32
   - 输出 4 路 cint32 流
   
2. channelizer_merge_8x4
   - 接收 FIR 的 8 路输出和 IFFT 的 4 路输出
   - 重新组织后合并为 4 路输出
   - 匹配 dma_snk 的 4 路输入
   
3. dma_snk.capture_streams()
   - 从 4 路 axis 接口读取数据
   - 根据 loop_sel 决定是否保存到 URAM
   - 支持循环多次,只捕获指定迭代
   
4. dma_snk.read_buffer()
   - 将 URAM 中的数据按正确顺序读出
   - 打包为 128-bit(2 cint32)word
   - 通过 m_axi 写回 LPDDR
   
5. Host 读取结果
   从 LPDDR 读取处理完成的频谱数据

新贡献者须知

常见陷阱

  1. 数据类型混淆

    // 错误:假设 TT_DATA 包含固定数量的样本
    TT_DATA word = mem[idx];
    auto sample = word.range(31,0);  // 危险!实际位宽取决于配置
    
    // 正确:使用头文件中定义的 TT_SAMPLE
    TT_SAMPLE sample = word;  // 依赖操作符重载和类型定义
    
  2. 忽略 polyphase 顺序

    • 直接修改 load_bufferread_buffer 的重排逻辑会破坏与 AIE 端的契约
    • 任何修改都需要同步更新 AIE 图(graph)的配置
  3. LOOP_TRIPCOUNT 与实际不符

    #pragma HLS LOOP_TRIPCOUNT min=1 max=8
    

    这只是给 HLS 综合工具的提示,不影响实际硬件行为。但如果与实际运行时的 loop_cnt 差异过大,会导致性能估计不准确。

  4. DATAFLOW 区域的依赖违规

    // 危险:两个函数同时读写同一数组的不同部分
    funcA(buff, ...);  // 写 buff[0..N/2]
    funcB(buff, ...);  // 写 buff[N/2..N]
    // HLS 可能无法识别这是安全的,导致串行化
    
    // 解决:显式标记 independence
    #pragma HLS dependence variable=buff type=intra false
    

调试建议

  1. C/RTL 协同仿真:使用 tb_wrapper.cpp 中的测试平台验证功能正确性
  2. 数据比对:利用 gen_vectors.m 生成的黄金参考数据进行比对
  3. 波形检查:关注 AXI-Stream 的 TVALID/TREADY 握手,确保没有死锁
  4. 性能分析:检查实现的 II 是否达到目标,FIFO 深度是否足够防止溢出

扩展点

如需修改数据格式或吞吐量:

  1. 调整 NSTREAM:修改头文件中的常量,同步更新 system.cfg 中的连接
  2. 改变样本精度:如从 cint16 改为 cint32,需要更新 TT_SAMPLE 定义和打包/解包逻辑
  3. 增加缓冲深度:调整 NTRANSFORMSDEPTH,注意 URAM 容量限制

相关模块


总结

channelizer_vitis_system_kernels 模块是 Versal ACAP 信道化器系统的数据基础设施层。它不执行信号处理算法本身,而是通过精心设计的 HLS 内核解决了"如何让数据高效进出 AIE"这一工程难题。

其核心设计智慧在于:

  • 分层存储策略:DDR → URAM → Stream,每层解决不同的问题
  • 格式自适应:自动完成线性顺序与 polyphase 顺序的转换
  • 流水线并行:DATAFLOW 实现计算与传输的完美重叠
  • 模块化接口:清晰的 AXI-Stream 和 AXI-MM 边界,便于系统集成

理解这个模块,就理解了如何在 Versal 平台上构建高吞吐量的数据通路——这是从"算法原型"走向"生产级实现"的关键一步。

On this page