🏠

farrow_optimization_stage_2_graph 技术深度解析

30秒概述

想象你正在设计一个高精度音频采样率转换器——原始音频以44.1kHz录制,但播放设备需要48kHz。直接线性插值会引入混叠和相位失真,而传统的多相FIR滤波器又需要大量系数存储。Farrow滤波器提供了一种优雅的解决方案:它使用一组固定的低阶多项式内核对输入信号进行插值,通过一个时变参数(延迟量)即可动态调整输出相位。本模块farrow_optimization_stage_2_graph是Xilinx AIE(AI Engine)实现Farrow滤波器的第二阶段优化版本,它不仅包含核心的插值计算逻辑,更重要的是提供了一个标准化的测试封装层——通过dut_graph类将内部算法与外部I/O解耦,支持在仿真环境(使用文件输入)和真实硬件(使用PLIO接口)之间无缝切换。这是从“能跑”到“好用”的关键一步。


1. 问题空间与设计动机

1.1 Farrow滤波器的工程挑战

在数字信号处理中,任意速率转换是一个经典难题:

  • 固定采样率转换器(如整数倍上/下采样)结构简单,但无法处理非整数倍或时变的转换比
  • 多相滤波器组需要预计算大量滤波器系数,存储开销随精度要求指数增长
  • 直接插值方法计算简单但频谱特性差,难以满足通信级应用要求

Farrow结构的核心洞察是:将时变插值滤波器的系数表示为延迟参数的显式多项式。这样,我们只需存储多项式系数(数量很少),实时计算出当前延迟对应的滤波器抽头权重,再应用到输入信号上。这大大降低了存储需求,同时保持了优秀的频谱特性。

1.2 为什么需要这个模块?

本模块解决的是一个工程集成问题,而非算法问题:

  1. 算法验证与部署的鸿沟:在AIE上验证算法时,我们通常希望从文件读取测试向量;但部署到真实硬件时,数据来自可编程逻辑(PL)侧的DMA或AXI-Stream接口
  2. 可测试性:需要一个统一的DUT(Device Under Test)封装,使得同一套测试向量能用于功能验证、性能分析和系统集成
  3. 教程渐进性:作为优化教程的第二阶段,它需要展示如何在保持功能正确的前提下,为性能优化做准备(如优化数据布局、减少内存访问冲突)

1.3 与教程系列的关系

本模块位于优化演进路径的中间节点:

[farrow_baseline_graph](farrow_baseline_graph.md) → 
[farrow_optimization_stage_1_graph](farrow_optimization_stage_1_graph.md) → 
**farrow_optimization_stage_2_graph** → 
[farrow_final_implementation_graph](farrow_final_implementation_graph.md)

每一阶段都在前一阶段基础上进行特定维度的优化(如内存访问模式、核间通信延迟、吞吐率等),同时保持外部接口不变。这种接口稳定、内部演进的策略是硬件加速设计中的最佳实践。


2. 架构设计与数据流

2.1 架构概览

flowchart TB subgraph PL["Programmable Logic (PL) Side"] DMA_SRC["DMA Source / File I/O"] DMA_SNK["DMA Sink / File I/O"] end subgraph AIE["AIE Array (ADF Graph)"] subgraph DUT["dut_graph (This Module)"] SIG_I["sig_i[0]
input_plio"] DEL_I["del_i[0]
input_plio"] SIG_O["sig_o[0]
output_plio"] FARROW["farrow
farrow_graph"] end end subgraph Core["Core Computation"] KERNELS["AIE Kernels
(Polyphase + MAC)"] end DMA_SRC -->|"stream"| SIG_I DMA_SRC -->|"stream"| DEL_I SIG_I -->|"connect"| FARROW DEL_I -->|"connect"| FARROW FARROW -->|"connect"| SIG_O SIG_O -->|"stream"| DMA_SNK FARROW -.->|"internal graph"| KERNELS

2.2 核心抽象:DUT Graph 模式

本模块采用了测试封装模式(DUT Wrapper Pattern)。想象汽车碰撞测试:真正的安全气囊算法在"车身"内部(farrow_graph),而这个模块是碰撞测试台架——它提供标准化的夹具(PLIO接口)、测量点(信号通路)和测试条件(文件I/O或真实数据)。

dut_graph 的职责边界

  • 不实现算法:核心Farrow滤波逻辑委托给内部的farrow对象
  • 管理I/O生命周期:创建和配置PLIO端口,根据编译目标选择数据源
  • 提供连接拓扑:将外部PLIO与内部算法的端口按数据流连接

2.3 数据流端到端追踪

让我们跟随一个数据包从进入到离开的完整旅程:

路径1:仿真模式(AIE_SIM_ONLY已定义)

  1. 入口sig_i[0]del_i[0]在构造时绑定到文本文件(data/sig_i.txtdata/del_i_optimized.txt
  2. 创建:调用input_plio::create()工厂方法,指定64位PLIO宽度
  3. 连接:通过connect<stream>模板将PLIO输出端口(out[0])连接到farrow对象的输入端口
  4. 计算:ADF运行时调度farrow_graph内部的AIE核执行多项式插值和滤波
  5. 出口farrow的输出连接到sig_o[0],最终写入data/sig_o.txt

路径2:硬件部署模式(AIE_SIM_ONLY未定义)

  1. 入口:PLIO端口创建时不绑定文件,成为"悬空"接口
  2. 链接阶段:Vitis链接器将PLIO映射到物理的AIE-PL接口(AXI-Stream或DMA通道)
  3. 运行时:数据由外部PL逻辑(通常是DMA引擎或HLS核)驱动
  4. 其余流程:与仿真模式相同

这种双模态设计允许开发者使用同一套图定义,在仿真阶段快速验证算法,在部署阶段无缝切换到硬件集成。


3. 组件深度解析

3.1 dut_graph

class dut_graph : public graph {
  farrow_graph farrow;  // 核心算法实例
public:
  std::array<input_plio, 1> sig_i;   // 信号输入端口数组
  std::array<input_plio, 1> del_i;   // 延迟参数输入端口数组  
  std::array<output_plio, 1> sig_o;  // 滤波结果输出端口数组
  
  dut_graph(void);
};

设计意图与模式

继承自graph:这是ADF(Adaptive DataFlow)编程模型的基类。通过继承,我们获得:

  • ADF运行时的图管理能力(init(), run(), end()
  • 核间连接和调度的声明式表达
  • 与Vitis工具的元数据集成

成员对象farrow:使用组合而非继承来包含核心算法。这很关键:

  • farrow_graph可能包含多个内核和子图,有自己的内部拓扑
  • 通过组合,我们保持关注点分离——dut_graph只关心"如何接入",farrow_graph只关心"如何计算"

std::array 而非裸数组:虽然大小为1(当前设计),但使用std::array带来:

  • 值语义和RAII(自动析构管理)
  • 边界检查(.at()方法)
  • 为未来扩展预留空间(如多通道Farrow滤波器)

3.2 构造函数实现分析

dut_graph(void)
{
#ifdef AIE_SIM_ONLY
    // 仿真模式:绑定到文件
    sig_i[0] = input_plio::create("PLIO_i_0", plio_64_bits, "data/sig_i.txt");
    del_i[0] = input_plio::create("PLIO_i_1", plio_64_bits, "data/del_i_optimized.txt");
    sig_o[0] = output_plio::create("PLIO_o_0", plio_64_bits, "data/sig_o.txt");
#else
    // 硬件模式:悬空接口,由链接器连接
    sig_i[0] = input_plio::create("PLIO_i_0", plio_64_bits);
    del_i[0] = input_plio::create("PLIO_i_1", plio_64_bits);
    sig_o[0] = output_plio::create("PLIO_o_0", plio_64_bits);
#endif
    // 流连接拓扑
    connect<stream>(sig_i[0].out[0], farrow.sig_i[0]);
    connect<stream>(del_i[0].out[0], farrow.del_i[0]);
    connect<stream>(farrow.sig_o[0], sig_o[0].in[0]);
}

条件编译策略(AIE_SIM_ONLY

为什么选择编译期而非运行期切换?

  1. 零运行时开销:硬件部署版本完全不包含文件I/O代码路径,避免任何性能损耗
  2. 工具链差异:仿真器和实际硬件的初始化路径差异很大,运行时判断会增加不必要的分支复杂度
  3. 部署安全性:防止在硬件上意外启用文件I/O(可能阻塞或无意义)

权衡:需要维护两套编译配置,但这是嵌入式/加速计算的常规做法。

PLIO创建模式

input_plio::create()是一个静态工厂方法,采用具名构造模式:

  • 第一个参数是逻辑名称,在Vitis分析工具和波形调试器中可见
  • plio_64_bits指定数据总线宽度,影响吞吐率计算和物理接口选择
  • 第三个参数(仿真模式)指定输入数据文件路径

注意命名约定PLIO_i_0PLIO_i_1PLIO_o_0遵循输入在前、输出在后,索引连续的模式,便于工具自动识别和连接。

流连接语义

connect<stream>(source, destination) 建立单向数据流通道

  • 类型安全:编译器检查端口类型兼容性(输出端口连输入端口)
  • 非阻塞语义:默认使用FIFO缓冲,生产者和消费者可以异步执行
  • 背压机制:当FIFO满时,写入端会自动阻塞(由硬件保证)

连接拓扑解析

[sig_i[0].out[0]] --stream--> [farrow.sig_i[0]]
[del_i[0].out[0]] --stream--> [farrow.del_i[0]]  
[farrow.sig_o[0]] --stream--> [sig_o[0].in[0]]

这形成了一个两入一出的计算图,符合Farrow滤波器的接口契约:输入信号 + 延迟控制 → 插值结果。

3.3 全局实例与主函数

dut_graph aie_dut;

int main(void)
{
  aie_dut.init();
  aie_dut.run(4);
  aie_dut.end();
  return 0;
}

全局图实例

在ADF模型中,图通常作为全局单例实例化。这是因为:

  • 图的拓扑需要在编译期静态确定(为了生成配置数据)
  • ADF运行时需要在main()之前完成资源分配和元数据注册
  • 大多数AIE设计只包含一个顶层图

生命周期管理

  1. 静态初始化aie_dut构造时,所有PLIO端口和内部连接被定义,但不分配AIE核资源
  2. init():ADF运行时加载图配置,初始化AIE核,建立PL-PLIO物理连接
  3. run(4):启动图执行,参数4通常表示运行4次图迭代或处理4个数据块(具体语义取决于farrow_graph内部的run_time配置)
  4. end():优雅关闭,刷新输出FIFO,释放资源

为什么是run(4)

这里的4很可能是为了:

  • 预热流水线(前几次迭代填充管道)
  • 处理多个数据块以获得稳定的性能测量
  • 与特定的测试向量长度匹配(sig_i.txt中的样本数)

在实际部署中,这个值会更大或使用run()(无限运行直到显式停止)。


4. 依赖关系与架构角色

4.1 直接依赖

依赖 类型 作用
farrow_graph.h 内部头文件 定义核心算法图farrow_graph
adf.h 框架 Xilinx ADF编程模型(graph, input_plio, output_plio, connect等)
Vitis_Platform_Creation.Feature_Tutorials.03_Vitis_Export_To_Vivado.Makefile.graph 构建系统 编译规则和平台配置

4.2 架构角色定位

在更大的Prime Factor FFT PipelineChannelizer系统中,本模块扮演可替换计算单元的角色:

[上游数据准备] → [dut_graph (Farrow Stage 2)] → [下游数据处理]
                        ↓
               [可替换为 Stage 1 或 Final 版本]

通过保持dut_graph的接口(PLIO名称、位宽、端口数量)不变,系统可以热插拔不同优化阶段的实现,进行A/B测试或逐步升级。

4.3 与farrow_graph的契约

dut_graph假设farrow_graph提供以下接口:

class farrow_graph {
public:
    port_array<input, 1> sig_i;   // 信号输入端口
    port_array<input, 1> del_i;   // 延迟输入端口  
    port_array<output, 1> sig_o;  // 信号输出端口
};

耦合点:如果farrow_graph改变端口数量或类型,dut_graph的连接代码必须同步更新。这是一个紧耦合设计,但在教程上下文中是可接受的,因为两者是协同演进的。


5. 设计决策与权衡

5.1 组合 vs 继承:为何选择组合?

决策farrow_graph作为成员对象而非基类。

考量

  • 继承:如果farrow_graph已经是graph的子类,多重继承会引入菱形继承风险;且dut_graph的功能是"包装"而非"是一种"Farrow滤波器
  • 组合:更清晰的分层,dut_graph可以添加自己的预处理/后处理逻辑,而无需修改farrow_graph

代价:需要显式转发连接,代码稍冗长。

5.2 数组大小为1:过度设计还是前瞻性?

决策:使用std::array<T, 1>而非裸对象input_plio sig_i

考量

  • 当前只需要单通道,但Farrow滤波器常用于多相抽取/插值多通道并行处理
  • 数组接口允许未来扩展为std::array<input_plio, N>,而无需改变调用代码(只需改模板参数和连接循环)
  • 在AIE设计中,数据并行扩展是常见需求(通过SSR——Super Sample Rate技术)

权衡:当前代码中[0]索引略显冗余,但这是为未来灵活性支付的小额"语法税"。

5.3 条件编译的粒度

决策:整个构造函数体内使用#ifdef,而非封装条件到PLIO工厂方法中。

替代方案

// 可能的替代设计
const char* sig_file = AIE_SIM_ONLY ? "data/sig_i.txt" : nullptr;
sig_i[0] = input_plio::create("PLIO_i_0", plio_64_bits, sig_file);

为何选择当前方案

  • 清晰性:两种模式的差异一目了然,便于审计
  • 优化机会:编译器可以为硬件模式完全消除字符串常量和文件相关代码
  • 工具链兼容性:某些交叉编译器对nullptr重载的处理可能与仿真器不同,显式分离避免潜在问题

5.4 PLIO位宽选择:64位

决策:使用plio_64_bits而非32位或128位。

考量

  • Farrow滤波器通常处理的是复数样本(I/Q数据),每个样本64位(32位实部+32位虚部)
  • 64位是AIE核的自然操作宽度,DMA传输效率高
  • 如果是实数处理,64位允许每个事务传输两个样本,提高吞吐率

与吞吐率的关系:PLIO位宽 × PL时钟频率 = 理论峰值带宽。64位 @ 300MHz = 19.2Gbps,足以支持多路高清信号处理。


6. C++工程实践分析

6.1 内存所有权模型

本模块遵循栈分配优先策略:

  • farrowfarrow_graph对象直接作为类成员存储,而非指针。ADF框架管理其内部资源,但容器dut_graph拥有其生命周期
  • sig_i, del_i, sig_ostd::array存储input_plio/output_plio对象。PLIO对象内部可能持有动态资源(如文件句柄),但通过RAII自动释放
  • 无裸指针:零显式new/delete,降低内存泄漏风险

重要契约farrow_graph的内部内核缓冲区通常由ADF运行时静态分配,大小在设计时确定。调用者无需(也无法)动态管理这些内存。

6.2 对象生命周期

// 全局作用域 - 静态存储期
dut_graph aie_dut;  // 程序启动时构造

int main(void) {
    aie_dut.init();  // 运行时初始化
    // ... 执行 ...
    aie_dut.end();   // 运行时清理
    return 0;
}
// 程序退出时析构

RAII原则:虽然使用了init()/end()显式生命周期管理(ADF模型要求),但对象的创建和销毁仍然遵循RAII。如果main()中发生异常,aie_dut的析构函数会被调用,确保资源不泄露。

6.3 错误处理策略

显式缺失:代码中没有try-catch块、错误码返回或assert检查。

原因分析

  1. ADF框架层处理graph::init()等方法的失败通常通过异常向上传播,或者在嵌入式环境中直接终止程序(硬件配置失败无法恢复)
  2. 静态保证:许多错误(如端口类型不匹配)在编译期由ADF元数据检查捕获
  3. 契约编程:假设输入文件存在且格式正确,这是测试环境的责任

对贡献者的警示:在farrow_graph内部添加内核时,需遵循ADF的错误处理约定——内核代码通常使用assert或显式边界检查,因为在AIE核上异常处理开销过高。

6.4 线程安全与并发模型

单线程设计dut_graph本身不管理并发。ADF运行时在底层将图展开为多内核并行执行,但从图的视角看,这是声明式并发——开发者定义拓扑,运行时调度执行。

无共享状态:通过connect<stream>建立的通信是无锁消息传递,避免共享内存的同步复杂性。这是AIE架构的核心优势:每个内核有自己的本地内存,通过显式流通信。


7. 使用模式与配置

7.1 典型编译流程

# 仿真模式编译
make -f Makefile.graph AIE_SIM_ONLY=1

# 硬件模式编译  
make -f Makefile.graph

AIE_SIM_ONLY宏的定义通常通过:

  • Makefile中的CXXFLAGS += -DAIE_SIM_ONLY
  • 或在代码中由IDE/工具链预定义

7.2 输入数据文件格式

在仿真模式下,文本文件需遵循ADF PLIO格式:

// data/sig_i.txt 示例(十六进制复数样本)
0x12345678ABCDEFF0  // 样本1:实部+虚部各32位
0x0F0E0D0C0B0A0908  // 样本2
...

关键细节

  • 每个64位值对应一个PL事务
  • 数据必须预先对齐到预期的采样率/数据类型
  • del_i_optimized.txt中的延迟参数通常为归一化的小数(0.0到1.0之间),以定点格式表示

7.3 与Vitis IDE集成

在Vitis统一 IDE 中,本文件通常作为顶层图文件

  1. 创建AIE应用项目时选择此farrow_app.cpp
  2. 工具自动解析dut_graph定义生成数据流图可视化
  3. 仿真配置中指定Working Directory包含data/子目录

8. 边缘情况与注意事项

8.1 常见陷阱

陷阱1:PLIO命名冲突

// 错误:名称与现有接口冲突
sig_i[0] = input_plio::create("PLIO_i_0", ...);  // 如果farrow_graph内部也用了这个名字?

规避:ADF要求顶层PLIO名称在系统中唯一。如果farrow_graph也声明PLIO(不太可能,它是内部图),确保命名空间隔离。

陷阱2:数据文件路径

// 编译时正确,但运行时失败
sig_i[0] = input_plio::create(..., "data/sig_i.txt");

问题:工作目录不是预期的位置,导致文件未找到。 解决方案:使用绝对路径,或确保仿真配置的工作目录正确。

陷阱3:流连接方向

// 编译错误:端口方向不匹配
connect<stream>(farrow.sig_i[0], sig_i[0].out[0]);  // 反向了!

connect<stream>要求第一个参数是源(outputout端口),第二个是目的地(inputin端口)。

8.2 性能调优提示

  1. PLIO位宽与吞吐率匹配:如果内核处理速度超过64位PLIO带宽,会成为瓶颈。考虑升级到plio_128_bits或多PLIO并行。

  2. 缓冲深度:如果farrow_graph内部存在数据率不匹配(如突发式生产/消费),可能需要增加connect的FIFO深度:

    connect<stream, 8>(...);  // 显式指定8个样本的缓冲深度
    
  3. AIE核放置:在farrow_graph内部(本模块不可见),使用location_constraint指定核在AIE阵列中的物理位置,减少路由延迟。

8.3 调试技巧

仿真波形分析

  • 在AIE仿真器生成的波形中,查找PLIO_i_0PLIO_i_1信号
  • 验证输入数据是否按时到达(检查tvalidtready握手)
  • 检查sig_o的输出延迟,确认流水线深度符合预期

数据对比

  • data/sig_o.txt与参考实现(如MATLAB或Python的scipy.signal.resample)对比
  • 注意量化效应:AIE使用定点运算,与浮点参考存在微小差异

9. 演进路径与扩展点

9.1 向Stage 3演进

本模块的优化重点可能是:

  • 内存访问模式del_i_optimized.txt文件名暗示延迟参数已针对某种访问模式优化(如突发传输、双缓冲)
  • 核间并行度:将Farrow的多相分支拆分到多个AIE核并行执行

9.2 自定义扩展

如果需要修改此模块(如添加第三个控制端口):

  1. 更新端口数组

    std::array<input_plio, 1> gain_i;  // 新增增益控制
    
  2. 在构造函数中创建和连接

    gain_i[0] = input_plio::create("PLIO_i_2", plio_64_bits, "data/gain_i.txt");
    connect<stream>(gain_i[0].out[0], farrow.gain_i[0]);
    
  3. 确保farrow_graph暴露对应端口

9.3 与PL侧的集成

在真实系统中,此图的输入通常连接到:


10. 参考与关联文档


11. 总结:给新贡献者的建议

当你需要修改或调试这个模块时,请记住:

  1. 这是封装层,不是算法层:如果发现滤波结果不对,问题大概率在farrow_graph内部,而非本文件。本文件只负责"数据运送到正确位置"。

  2. 编译宏是你的朋友:遇到"文件未找到"错误时,首先检查是否正确定义了AIE_SIM_ONLY

  3. 保持接口稳定:如果你需要添加新功能,优先扩展farrow_graph的端口并在此连接,而非直接在此添加逻辑——保持dut_graph的"薄封装"特性。

  4. 关注数据格式:64位PLIO意味着你的测试数据文件必须严格遵循格式规范。一个字节偏移会导致整个数据流错位。

  5. 利用工具链:Vitis的AIE编译器会生成详细的连接报告(graph.json),在仿真前检查它,确认PLIO_i_0等端口确实连接到了预期的物理位置。

这个模块体现了硬件加速设计中的一个核心原则:清晰的分层和稳定的接口比内部的巧妙优化更重要dut_graph可能看起来只是简单的"粘合代码",但正是这种粘合使得复杂的Farrow算法能够被系统地测试、集成和迭代优化。

On this page