Farrow 滤波器最终实现图 (farrow_final_implementation_graph)
概述:这个模块解决了什么问题?
想象你正在录制一段音频,但录音设备的采样时钟与播放设备略有不同——这会导致音调轻微偏移或出现恼人的"咔嗒"声。在数字信号处理中,这种采样率转换问题无处不在:软件无线电需要处理不同带宽的信号流,多通道音频系统需要同步异构数据源,雷达系统需要在不同脉冲重复频率之间切换。
传统方法使用多级插值/抽取滤波器组,但这会带来显著的延迟和硬件资源开销。Farrow 结构提供了一种优雅的替代方案:它通过一组固定的子滤波器配合一个可变的分数延迟参数 \(\mu\),实现了连续可调的采样率转换。数学上,输出样本是四个子滤波器输出的多项式组合:
本模块是 Farrow 滤波器设计的最终优化版本,代表了从初始原型到生产级实现的完整演进路径的终点。它解决的核心问题是:如何在 AMD Versal AI Engine 的严苛约束下(有限的片上内存、严格的时序要求、向量化的数据路径),实现一个高性能、低延迟、资源高效的分数延迟滤波器。
与初始版本相比,最终版本将原本臃肿的单核实现拆分为两个专用内核,通过流水线并行化显著提升了吞吐量;同时利用 AIE 的滑动乘法指令 (sliding_mul_sym_xy_ops) 和精心编排的数据布局,将计算效率推向硬件极限。
架构设计与数据流
整体架构图
分层设计思想
这个实现采用了三层嵌套架构,每一层都有明确的职责边界:
-
dut_graph(设备-under-test 图):最外层包装器,负责与外部世界(PL 逻辑、仿真环境)的接口适配。它创建了三个 PLIO(Programmable Logic I/O)端口,并在仿真模式下自动绑定到文本文件数据源。这一层的存在使得核心计算图可以独立于具体的系统集成方式进行测试和复用。 -
farrow_graph(核心计算图):真正的算法实现层,包含两个协作的内核实例。它将 Farrow 算法的两个计算阶段——子滤波和多项式求值——映射到独立的 AIE 内核,通过显式的缓冲区连接实现流水线并行。 -
内核层(
farrow_kernel1/farrow_kernel2):最底层的向量化实现,直接与 AIE 的 SIMD 指令集交互。这里每一个循环迭代都经过手工优化,以最大化向量单元的利用率。
数据流详解
数据在这个系统中沿着一条清晰的流水线流动:
阶段一:子滤波(farrow_kernel1)
- 输入信号
sig_i以cint16类型、每次 8 个样本的向量形式进入 - 单个 16 抽头 FIR 系数数组
f_taps被巧妙地复用于四个子滤波器——通过不同的起始偏移量(0, 4, 8, 12)和对称/反对称乘法模式,从同一组系数中提取出 \(f_3, f_2, f_1, f_0\) 四个子滤波器的响应 - 每个时钟周期,内核并行计算四个子滤波器的输出,产生
y3,y2,y1,y0四个输出缓冲区 - 关键技巧:
sliding_mul_sym_xy_ops模板利用系数的对称性,将 16 次乘法减少为 8 次,这是 AIE 特有的优化
阶段二:多项式求值(farrow_kernel2)
- 延迟参数
del_i(即 \(\mu\))以int32格式输入,在内部被转换为int16进行定点运算 - 采用Horner 法则的变体进行多项式求值,但通过三次遍历实现:
- 第一次遍历:计算 \(z_2 = y_2 + \mu \cdot y_3\)
- 第二次遍历:计算 \(z_1 = y_1 + \mu \cdot z_2\)
- 第三次遍历:计算 \(sig\_o = y_0 + \mu \cdot z_1\)
- 这种分解允许每次遍历使用相同的 MAC(乘累加)操作模式,便于向量化
核心组件深度解析
dut_graph 类
位于 farrow_app.cpp,这是系统的入口点和集成边界。
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;
// ...
};
设计意图:将平台相关的 I/O 配置与算法实现解耦。注意到 #ifdef AIE_SIM_ONLY 条件编译块——在仿真模式下,PLIO 端口自动绑定到文本文件(data/sig_i.txt, data/del_i_optimized.txt);而在实际硬件部署时,这些端口保持未绑定状态,由系统集成层通过 Vitis 配置进行连接。
关键决策:使用 std::array 包装单个端口看似冗余,但这是一种前瞻性设计——它为未来扩展为多通道处理预留了空间,同时保持接口的一致性。
farrow_graph 类
位于 farrow_graph.h,这是算法拓扑的定义者。
class farrow_graph : public graph {
kernel farrow_kernel1_ins;
kernel farrow_kernel2_ins;
// ...
farrow_graph(void) {
farrow_kernel1_ins = kernel::create_object<farrow_kernel1>();
runtime<ratio>(farrow_kernel1_ins) = 0.9;
// ...
connect<> (farrow_kernel1_ins.out[0], farrow_kernel2_ins.in[1]);
// ... 共 4 条内部连接
}
};
关键配置参数:
runtime<ratio>(kernel) = 0.9:指示调度器为该内核分配 90% 的 AIE 周期。这是性能调优的重要杠杆——如果 II(Initiation Interval)分析显示瓶颈,可以尝试降低此值以腾出周期给数据移动。location<stack>(kernel) = location<kernel>(kernel):将栈放置在紧邻内核代码的内存区域,减少访问延迟。
连接拓扑的演变:对比 farrow_initial 的单核设计,最终版本将 1 个内核拆分为 2 个,内部连接从简单的输入-输出变为复杂的 4 对 4 扇出。这种拆分不是随意的——它基于对数据依赖关系的精确分析:子滤波阶段只依赖输入信号,多项式阶段只依赖子滤波结果,两者天然可并行。
farrow_kernel1 类
位于 farrow_kernel1.h/cpp,负责向量化 FIR 滤波。
类型系统设计:
typedef cint16 TT_SIG; // 信号类型:16-bit 复数
typedef cacc48 TT_ACC; // 累加器类型:48-bit 复数(防止溢出)
static constexpr unsigned DNSHIFT = 14; // 定点归一化移位
这种类型层次反映了 AIE 编程的核心挑战:在精度、动态范围和硬件资源之间取得平衡。cacc48 累加器提供了足够的位宽来容纳 16 次乘法的累加结果,最终的 DNSHIFT=14 将结果归一化回 16-bit 范围。
系数打包的艺术:
alignas(32) static constexpr int16 f_taps[16] = {
206,-1264,6606,-14835, // f3 系数(反转后)
906,-3543,10352,-7628, // f2 系数(反转后)
-51,316,-1652,20093, // f1 系数(反转后)
-226,886,-2588,10099 // f0 系数(反转后)
};
注意注释中的 "flip(...,end-3:end)"——原始 MATLAB 设计使用了更长的滤波器,但在硬件实现中被截断为最后 4 个有效系数。这种截断是经过仔细权衡的:更少的抽头意味着更低的延迟和更高的时钟频率,但需要接受略微下降的阻带衰减。
滑动窗口的状态管理:
aie::vector<cint16,16> v_buff;
v_buff.insert(1, aie::load_v<8>(f_state)); // 加载前一次迭代的尾部
// ... 处理 ...
*(v8cint16*)f_state = v_buff.extract<8>(1); // 保存当前迭代的尾部
这里体现了重叠相加法的硬件实现精髓。v_buff 是一个 16 元素的向量寄存器,其中位置 0-7 存放新输入样本,位置 8-15 存放历史状态。每次迭代处理 8 个新样本,但利用滑动窗口机制保持了 FIR 滤波器所需的 16 点上下文。
farrow_kernel2 类
位于 farrow_kernel2.h/cpp,负责多项式求值和结果组合。
双缓冲策略:
alignas(32) TT_SIG z[BUFFER_SIZE]; // 中间结果缓冲区
这个缓冲区是理解内核行为的关键。由于 AIE 的内存架构限制,内核不能同时读取和写入同一个缓冲区而不引入气泡(bubble)。因此,kernel2 采用了一种生产者-消费者分离的策略:
- 第一遍遍历:从
y2/y3读取,写入z(作为z2) - 第二遍遍历:从
y1/z读取,再次写入z(作为z1,覆盖z2) - 第三遍遍历:从
y0/z读取,直接输出到sig_o
这种设计牺牲了部分内存带宽(需要三次遍历),换取了计算的规则性和可向量化性。
延迟参数的向量化处理:
del = aie::vector_cast<int16>(*p_del_i++); *p_del_i++;
注意这里的 *p_del_i++ 被调用了两次——这是因为 del_i 以 int32 格式存储(每样本 4 字节),而我们需要将其转换为 int16 向量(8 元素)。每次向量加载获取 4 个 int32(128 位),转换为 8 个 int16 后,我们实际上消耗了 2 个 int32 的有效载荷,因此需要跳过下一个 int32。
演进路径与设计权衡
理解这个最终版本的最佳方式,是追溯它的演进历程。项目目录中的三个变体(farrow_initial → farrow_optimize1 → farrow_optimize2 → farrow_final)记录了工程师们面临的约束和做出的选择。
阶段对比
| 特性 | farrow_initial | farrow_optimize1 | farrow_optimize2 | farrow_final |
|---|---|---|---|---|
| 内核数量 | 1 | 1 | 1 | 2 |
| 系数存储 | 4×8 独立数组 | 1×16 合并数组 | 1×16 合并数组 | 1×16 合并数组 |
| 状态变量 | 4×8 独立状态 | 1×8 共享状态 | 1×8 共享状态 | 1×8 共享状态 |
| 中间结果 | 寄存器内联 | 寄存器内联 | 乒乓缓冲区 | 跨核流式传输 |
| 多项式求值 | 单循环内完成 | 单循环内完成 | 三循环分离 | 专用内核 |
关键设计决策
决策 1:系数合并 vs 独立存储
初始版本为四个子滤波器维护了独立的系数数组 f3_taps, f2_taps, f1_taps, f0_taps,每个 8 元素,后补零到 8 元素以适配向量宽度。优化版本将它们打包到一个 16 元素数组中,通过偏移量访问。
- 权衡:合并减少了数据缓存占用,提高了局部性;但需要更复杂的索引计算(偏移量 0, 4, 8, 12)。
- 结果:在 AIE 上,这种合并显著提升了性能,因为 16 元素向量加载比多次 8 元素加载更高效。
决策 2:单核融合 vs 双核分离
optimize2 版本尝试在单核内通过乒乓缓冲区分离滤波和求值阶段,但最终版本彻底拆分为两个内核。
- 权衡:单核方案避免了核间通信开销,但受限于单个 AIE 的计算能力;双核方案引入了流式传输延迟,但实现了真正的流水线并行。
- 结果:对于目标吞吐量(由
runtime<ratio>=0.9暗示),双核方案提供了更好的扩展性——如果需求增长,可以进一步实例化更多farrow_graph副本进行空间并行。
决策 3:缓冲区大小固定为 1024
所有版本都使用 BUFFER_SIZE = 1024。
- 权衡:更大的缓冲区可以提高数据复用率,减少 DMA 事务开销;但更小的缓冲区降低了延迟,提高了响应性。
- 隐含假设:1024 样本 ≈ 在典型采样率下约毫秒级的延迟,这对于目标应用(可能是基带处理)是可接受的。如果需要亚毫秒级延迟,需要重构为流式(stream-based)而非缓冲(buffer-based)接口。
依赖关系与集成契约
上游调用者
dut_graph 本身不直接被业务代码调用,而是通过 AIE 编译器的静态实例化机制集成:
// farrow_app.cpp
dut_graph aie_dut; // 全局实例
int main(void) {
aie_dut.init();
aie_dut.run(4); // 运行 4 次迭代
aie_dut.end();
return 0;
}
在完整的 Vitis 系统中,这段 main() 函数通常被替换为 PS(Processing System)端的控制代码,通过 libadf.a 提供的 API 进行图的生命周期管理。
下游依赖
| 依赖项 | 用途 | 替代影响 |
|---|---|---|
<adf.h> |
AMD 数据流图运行时 | 无法替代,这是 AIE 编程的基础 |
<aie_api/aie.hpp> |
AIE 向量指令 C++ 封装 | 可使用底层 chess 内联汇编,但会丧失可移植性 |
<aie_api/utils.hpp> |
工具函数(如 set_rounding) |
可自行实现,但需确保行为一致 |
数据契约
输入信号 sig_i:
- 格式:二进制补码 16-bit 复数(实部/虚部交错)
- 范围:建议保持在 [-8192, 8191] 以内,为滤波增益留出 4-bit 余量(\(2^{14}/2 = 8192\))
- 连续性:AIE 期望连续的数据流,任何间隙都会导致流水线气泡
延迟参数 del_i:
- 格式:32-bit 有符号整数,表示 Q14 定点小数(即实际值 =
del_i / 16384) - 范围:理论上 \([-1, 1)\),即 \([-16384, 16383]\),超出此范围可能导致非预期的分数延迟行为
- 变化率:Farrow 结构假设 \(\mu\) 是慢变化的,如果
del_i每个样本剧烈跳变,频谱特性会恶化
输出信号 sig_o:
- 格式:与输入相同的 16-bit 复数
- 延迟:相对于输入,有固定的群延迟(由 FIR 抽头数和流水线深度决定)
新贡献者须知:陷阱与最佳实践
常见错误
1. 忽略 __restrict 关键字
内核函数参数都标记了 __restrict,这是向编译器承诺指针不会别名(alias)。如果违反此承诺(例如,通过重叠的缓冲区调用内核),行为是未定义的——可能表现为静默的数据损坏,而非崩溃。
2. 修改系数后未重新验证
f_taps 数组的值来自 MATLAB 浮点设计,经过量化、反转、截断。如果尝试修改这些值:
- 必须保持对称/反对称属性,否则
sliding_mul_sym_xy_ops会产生错误结果 - 必须重新运行
check_sim_output.m验证频谱掩模符合性
3. 混淆 BUFFER_SIZE 与实际处理量
注意循环条件:for (unsigned rr=0; rr < BUFFER_SIZE/16; rr++)。每次迭代处理 16 个样本(两个 8 向量),因此实际的样本处理量是声明缓冲区大小的 1/16。如果错误地认为循环会处理全部 1024 个样本,可能会误解吞吐量计算。
调试技巧
使用 Vitis Analyzer:
编译后生成的 Work/ 目录包含丰富的分析数据。重点关注:
- Array View:检查
y3,y2,y1,y0缓冲区的物理内存布局,确认没有 bank 冲突 - Trace View:观察两个内核的执行时间线,理想情况下应该看到重叠的执行区间(流水线并行)
循环 II 分析:
Makefile 中的 get_II 目标提取循环的 Initiation Interval:
make get_II
# 期望输出接近 1,表示每个周期启动一次迭代
如果 II 大于 1,表明存在资源冲突或数据依赖,需要检查:
- 向量加载/存储地址是否对齐(
alignas(32)是必须的) - 累加器数据依赖链是否过长
扩展指南
添加第五个多项式阶数: 当前实现是 3 阶多项式(\(y_0\) 到 \(y_3\))。如果要扩展到 4 阶:
- 在
farrow_kernel1中添加y4输出缓冲区 - 扩展
f_taps到 20 元素(或调整现有系数的复用策略) - 在
farrow_kernel2中添加第四遍遍历 - 更新
farrow_graph的连接拓扑
多通道扩展: 当前设计是单通道的。要支持 N 通道:
- 选项 A(空间并行):实例化 N 个
farrow_graph,每个处理一个通道——简单但资源密集 - 选项 B(时间复用):增加缓冲区大小到 N×1024,在内核中添加通道循环——节省内核资源但增加延迟
相关模块
- farrow_baseline_graph:初始单核实现,适合理解基础算法
- farrow_optimization_stage_1_graph:系数合并优化版本
- farrow_optimization_stage_2_graph:单核内的乒乓缓冲优化
- Vitis_Platform_Creation.Feature_Tutorials.03_Vitis_Export_To_Vivado:了解如何将本图集成到完整 Vivado 系统
总结
farrow_final_implementation_graph 是一个经过充分优化的生产级 AIE 实现,它展示了如何将理论上的 Farrow 滤波器算法映射到 AMD Versal 的向量处理器阵列。其核心设计智慧在于:
- 计算与通信的显式分离:通过
farrow_graph的两级流水线,将计算密集型任务(FIR)与数据密集型任务(多项式求值)分配到不同内核,最大化并行度 - 硬件原语的充分利用:
sliding_mul_sym_xy_ops等专用指令的使用,将算法复杂度从 \(O(N \cdot M)\) 降低到接近 \(O(N)\) - 渐进式优化路径:从初始版本到最终版本的演进记录了每一个设计决策的动机,为新功能开发提供了参考范式
对于新加入团队的工程师,建议的阅读顺序是:先理解 farrow_initial 的算法原理,再对比本版本的架构差异,最后深入内核代码体会向量化技巧。