IO 与流适配模块 (io_and_stream_adaptation) 深度解析
一句话概括
这个模块是 MUSIC 算法流水线中的"数据格式转换器" —— 它将来自 PL (Programmable Logic) 的 AXI-Stream 流式数据转换为 AIE (AI Engine) 内部处理所需的缓冲区块格式,解决两种不同数据访问语义之间的阻抗不匹配问题。可以把它想象成机场的值机柜台:旅客(数据)以连续不断的流到达,但飞机(AIE 计算核心)需要按批次、按座位号(内存布局)装载乘客。
目录
问题空间与设计洞察
为什么需要这个模块?
在 Versal ACAP 架构中,数据从外部世界进入 AIE 阵列需要跨越一道"语义鸿沟":
| 维度 | PL 侧 (AXI-Stream) | AIE 侧 (Buffer) |
|---|---|---|
| 访问模式 | 流式、顺序、无边界 | 随机访问、块状、有界 |
| 数据宽度 | 64/128/256 bits (可配置) | 512-bit SIMD 向量操作 |
| 时序特性 | 数据驱动、可能突发 | 计算驱动、期望稳定供应 |
| 内存视图 | 无地址概念 | 需要线性或分块地址布局 |
MUSIC 算法的特殊挑战:
MUSIC (Multiple Signal Classification) 算法用于 DOA (Direction of Arrival) 估计,其输入是一个 \(M \times N\) 的复数矩阵(ROW=128, COL=8,即 128×8 的 cfloat 矩阵)。这个矩阵需要经过 QR 分解 → SVD → DOA 扫描等多个阶段处理。
问题在于:PL 侧通常以行优先或通道优先的方式流式输出数据,而 AIE 侧的 QR 分解等内核期望特定的数据布局以便高效进行向量运算。如果没有适配层,每个下游内核都需要处理复杂的数据重排逻辑,导致代码重复和性能损失。
设计洞察:在源头解决问题
与其让每个下游内核都处理数据格式转换,不如在数据进入 AIE 阵列的第一站就完成统一的格式适配。这就是 io_adapter 的核心设计思想 —— 一次转换,全局受益。
具体来说,该模块执行以下关键转换:
- 双流到单缓冲区的合并:将两个独立的 256-bit 流(
sig_i_0和sig_i_1)交错写入一个连续的缓冲区 - 向量对齐:确保输出数据以 512-bit (8 个
cfloat) 向量边界对齐,便于下游 SIMD 操作 - 容量匹配:总输出大小为
ROW * COL = 1024个cfloat,恰好容纳整个输入矩阵
心智模型与核心抽象
类比:装配线的进料机器人
想象一条汽车装配线:
- PL 侧就像两条并行的传送带,源源不断地运送零部件(数据),每个传送带每次运送 256-bit 的包裹
- IO_Adapter 就像一个智能进料机器人,它从两条传送带上交替抓取包裹,将它们整齐地码放到一个大型托盘(缓冲区)上
- 托盘规格是经过精心设计的:每个格子装 512-bit (8 个零部件),总共 1024 个格子,正好装满一辆车的全部零件
- 下游工位(QR 分解、SVD 等)可以直接从这个托盘上批量取用零件,无需关心零件最初是如何运来的
核心抽象
┌─────────────────────────────────────────────────────────────┐
│ IO_Adapter 抽象模型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Input Stream A (256-bit) ──┐ │
│ ├──→ [交错合并] ──→ Output Buffer│
│ Input Stream B (256-bit) ──┘ (512-bit × 512) │
│ │
│ 关键参数: │
│ • IO_BUFFER_BUS_WIDTH = 256 bits (输入总线宽度) │
│ • NUM_ELEMENTS = 8 (每向量 cfloat 数量) │
│ • 循环次数 = (ROW × COL) / (2 × NUM_ELEMENTS) = 64 │
│ │
└─────────────────────────────────────────────────────────────┘
数据布局可视化
输入数据在两个流上的分布(假设简化场景):
Stream 0: [a0, a1, a2, a3, a4, a5, a6, a7] [a8, a9, ...] ...
Stream 1: [b0, b1, b2, b3, b4, b5, b6, b7] [b8, b9, ...] ...
↓ ↓
输出缓冲区:
[ a0-a7 | b0-b7 | a8-a15 | b8-b15 | ... ]
512bit 512bit 512bit 512bit
这种交错布局 (interleaved layout) 使得后续处理可以同时访问两个通道的相关数据,提高 SIMD 利用率。
架构与数据流
整体架构图
input_plio"] PLIO1["PLIO_i_1
input_plio"] PLIO_OUT["PLIO_o
output_plio"] end subgraph IO_Adapter_Module["IO Adapter 模块 (本模块)"] DUT["dut_graph"] IO_GRAPH["io_adapter_graph"] KERNEL["IO_Adapter::run()"] end subgraph Downstream["下游处理链 (MUSIC Pipeline)"] QRD["qrd_graph
QR 分解"] SVD["svd_graph
奇异值分解"] DOA["doa_graph
DOA 估计"] SCANNER["scanner_graph
谱峰扫描"] FINDER["finder_graph
角度查找"] end PLIO0 -->|"sig_i[0]"| DUT PLIO1 -->|"sig_i[1]"| DUT DUT -->|"sig_i[0/1]"| IO_GRAPH IO_GRAPH -->|"流接口"| KERNEL KERNEL -->|"sig_o (buffer)"| IO_GRAPH IO_GRAPH -->|"sig_o"| DUT DUT -->|"sig_o"| PLIO_OUT %% 注意:实际连接中 io_adapter 的输出连接到 qrd %% 这里为了清晰展示模块边界而简化 style IO_Adapter_Module fill:#e1f5fe
注意:上述架构图展示了
dut_graph作为测试封装层的角色。在实际 MUSIC 系统中,io_adapter的输出直接连接到qrd,如 music_graph.h 所示。
完整 MUSIC 流水线数据流
MM2S DMA"] -->|"双通道流
256-bit each"| IO_ADAPT["io_adapter
流→缓冲转换"] IO_ADAPT -->|"交错缓冲区
1024 cfloat"| QRD["qrd
QR 分解"] QRD -->|"R 矩阵"| SVD["svd
SVD 分解"] SVD -->|"噪声子空间"| DOA["doa
DOA 计算"] DOA -->|"谱值"| SCANNER["scanner
局部峰值"] SCANNER -->|"候选峰值"| FINDER["finder
精确角度"] FINDER -->|"最终角度估计"| PL_OUT["PL 输出
S2MM DMA"] style IO_ADAPT fill:#fff3e0,stroke:#ff9800,stroke-width:2px
关键数据流路径
1. 初始化阶段 (dut_graph 构造函数)
// 1. 创建 PLIO 端口 - 定义与 PL 世界的物理接口
sig_i[0] = adf::input_plio::create("PLIO_i_0", adf::plio_64_bits, "data/sig_i_0.txt");
sig_i[1] = adf::input_plio::create("PLIO_i_1", adf::plio_64_bits, "data/sig_i_1.txt");
sig_o = adf::output_plio::create("PLIO_o", adf::plio_64_bits, "data/sig_o.txt");
// 2. 建立数据通路 - 将 PLIO 连接到 io_adapter
adf::connect<>(sig_i[0].out[0], io_adapter.sig_i[0]);
adf::connect<>(sig_i[1].out[0], io_adapter.sig_i[1]);
adf::connect<>(io_adapter.sig_o, sig_o.in[0]);
// 3. 指定物理位置 - 确保资源可预测性
static constexpr unsigned IO_ADAPTER_COL_START = 11;
adf::location<graph>(*this) = adf::bounding_box(IO_ADAPTER_COL_START, ROW_0,
IO_ADAPTER_COL_START, ROW_0);
2. 运行时阶段 (IO_Adapter::run)
// 核心转换循环 - 每次处理 8+8=16 个 cfloat
for (unsigned i = 0; i < (ROW * COL) / (2 * NUM_ELEMENTS); ++i) {
// 从 Stream A 读取 8 个 cfloat (256 bits)
vec_0.insert(0, readincr_v<NUM_ELEMENTS, aie_stream_resource_in::a>(sig_i_0));
*outIterator++ = vec_0; // 写入缓冲区前半
// 从 Stream B 读取 8 个 cfloat (256 bits)
vec_1.insert(0, readincr_v<NUM_ELEMENTS, aie_stream_resource_in::b>(sig_i_1));
*outIterator++ = vec_1; // 写入缓冲区后半
}
// 总计:64 次迭代 × 16 cfloat = 1024 cfloat = ROW × COL
组件深度剖析
1. dut_graph - 顶层测试封装
文件: io_adapter_app.cpp
角色定位:这是模块的测试入口和集成封装。在独立测试场景中,它提供完整的端到端环境;在系统集成时,这部分被 music_graph 取代。
class dut_graph : public adf::graph {
public:
io_adapter_graph io_adapter; // 核心功能实例
std::array<adf::input_plio,2> sig_i; // PL 输入接口数组
adf::output_plio sig_o; // PL 输出接口
// ...
};
关键设计决策:
| 方面 | 选择 | 理由 |
|---|---|---|
| 组合 vs 继承 | 组合 (io_adapter_graph 成员) |
保持关注点分离,允许独立测试子图 |
| PLIO 位宽 | 64 bits (物理) | 与仿真数据文件格式匹配;实际硬件可能不同 |
| 数据文件 | "data/sig_i_0.txt" 等 |
相对路径,便于移植和版本控制 |
物理约束注释掉的代码说明:
// 这些行被注释掉,表示当前使用自动布局
// adf::location<adf::kernel>(io_adapter.io_adapter_kernel) = adf::tile(IO_ADAPTER_COL_START, ROW_0);
// adf::location<adf::stack>(io_adapter.io_adapter_kernel) = ...
保留这些注释是为了展示如何手动绑定内核到特定 AIE tile。这在资源紧张或需要精确时序控制时很有用。目前使用 bounding_box 约束已足够。
2. io_adapter_graph - 功能子图
文件: io_adapter_graph.h
角色定位:这是模块的核心功能单元,封装了单个数据处理阶段的完整逻辑。
class io_adapter_graph : public adf::graph {
public:
adf::kernel io_adapter_kernel; // 计算内核
std::array<adf::input_port,2> sig_i; // 流输入端口
adf::output_port sig_o; // 缓冲输出端口
// ...
};
ADF 框架交互详解:
// 1. 内核创建 - 使用模板实例化 C++ 类为 AIE 内核
io_adapter_kernel = adf::kernel::create_object<IO_Adapter>();
// 2. 运行时比例 - 90% 的 AIE 周期分配给此内核
// 这意味着内核可以连续运行,不会饥饿
adf::runtime<ratio>(io_adapter_kernel) = 0.9;
// 3. 源码关联 - 告诉编译器哪个 .cpp 文件包含实现
adf::source(io_adapter_kernel) = "io_adapter.cpp";
// 4. 端口连接 - 建立数据通路拓扑
adf::connect(sig_i[0], io_adapter_kernel.in[0]); // 流 → 内核输入 0
adf::connect(sig_i[1], io_adapter_kernel.in[1]); // 流 → 内核输入 1
adf::connect(io_adapter_kernel.out[0], sig_o); // 内核输出 → 缓冲区
// 5. 维度注解 - 仅适用于缓冲接口
// 告知编译器输出缓冲区的大小,用于内存分配和 DMA 配置
adf::dimensions(io_adapter_kernel.out[0]) = {ROW * COL}; // 1024 元素
为什么输入维度被注释掉?
// adf::dimensions(io_adapter_kernel.in[0]) = {ROW * COL}; // dimensions N/A for streaming interface
流接口 (input_stream) 是无界的、数据驱动的,不需要也不支持维度注解。只有缓冲接口 (output_buffer) 需要显式声明大小,因为编译器需要为其分配物理内存。
3. IO_Adapter - 计算内核
文件: io_adapter.h, io_adapter.cpp
角色定位:这是实际的计算逻辑,在 AIE 硬件上执行。
头文件接口定义
class IO_Adapter {
public:
IO_Adapter(void) { }; // 默认构造,无状态
// 核心处理函数 - 由 ADF 运行时调用
void run(input_stream<cfloat> * __restrict sig_i_0,
input_stream<cfloat> * __restrict sig_i_1,
adf::output_buffer<cfloat, adf::extents<adf::inherited_extent>> & __restrict out);
// 注册函数 - 使 ADF 能够发现此方法
static void registerKernelClass(void) {
REGISTER_FUNCTION(IO_Adapter::run);
};
};
关键字解析:
| 关键字/属性 | 含义 | 重要性 |
|---|---|---|
__restrict |
指针别名隔离承诺 | 允许编译器激进优化,假设指针不重叠 |
adf::output_buffer<...> |
类型安全的缓冲输出 | 替代原始指针,携带维度元信息 |
adf::inherited_extent |
继承维度的占位符 | 实际维度在 graph 层通过 dimensions() 指定 |
REGISTER_FUNCTION |
ADF 宏,暴露方法给元数据系统 | 必需,否则内核不可见 |
实现细节
void IO_Adapter::run(input_stream<cfloat> * __restrict sig_i_0,
input_stream<cfloat> * __restrict sig_i_1,
adf::output_buffer<cfloat, adf::extents<adf::inherited_extent>> & __restrict out) {
// 编译期常量 - 完全展开,零运行时开销
static constexpr unsigned IO_BUFFER_BUS_WIDTH = 256; // PL 总线宽度
static constexpr unsigned NUM_ELEMENTS = IO_BUFFER_BUS_WIDTH / (sizeof(cfloat) * 8); // = 8
// 创建向量化迭代器 - 每次递增移动 8 个元素
auto outIterator = aie::begin_vector<NUM_ELEMENTS>(out);
// 向量寄存器 - 利用 AIE 的 512-bit SIMD 能力
aie::vector<cfloat, NUM_ELEMENTS> vec_0, vec_1;
// 主循环 - 64 次迭代处理 1024 个元素
for (unsigned i = 0; i < (ROW * COL) / (2 * NUM_ELEMENTS); ++i)
chess_prepare_for_pipelining // 提示:准备软件流水线
chess_flatten_loop // 提示:展开循环减少分支
{
// 使用特定的流资源 (a/b) 允许并行读取
vec_0.insert(0, readincr_v<NUM_ELEMENTS, aie_stream_resource_in::a>(sig_i_0));
*outIterator++ = vec_0;
vec_1.insert(0, readincr_v<NUM_ELEMENTS, aie_stream_resource_in::b>(sig_i_1));
*outIterator++ = vec_1;
}
}
性能关键代码分析:
-
chess_prepare_for_pipelining:向 AIE 编译器 (Chess) 发出提示,表示此循环适合软件流水线优化。编译器会尝试重叠不同迭代的指令,隐藏延迟。 -
chess_flatten_loop:建议编译器进行循环展开,减少循环控制开销。对于固定小循环计数 (64 次),这可能完全展开为直线代码。 -
aie_stream_resource_in::a/b:显式指定使用不同的流读取资源,允许两个流的读取操作并行执行,最大化内存带宽利用率。 -
vec.insert(0, ...):向量插入操作。aie::vector是不可变的值类型,insert创建新向量而非修改原向量。这符合 AIE 的 VLIW/SIMD 执行模型。
设计权衡与决策
1. 流接口 vs 缓冲接口的选择
决策:输入使用流 (input_stream),输出使用缓冲 (output_buffer)。
权衡分析:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 流→缓冲 (当前) | 自然匹配 PL 数据源;输出可被多个消费者随机访问 | 需要额外的缓冲内存 | 数据转换、格式重整 |
| 流→流 | 低延迟,无中间缓冲 | 下游必须立即消费;难以并行 | 直通处理、低延迟链 |
| 缓冲→缓冲 | 统一接口;易于组合 | 需要额外的 DMA 设置;延迟增加 | 纯 AIE 内部处理 |
| 缓冲→流 | 可从内存预取数据 | 与 PL 源不匹配 | 数据生成器模式 |
为何当前选择最优:
PL 侧通常使用 DMA 引擎 (MM2S) 产生流式数据,这与 input_stream 天然匹配。而 MUSIC 算法的第一个阶段 (QR 分解) 需要多次遍历输入矩阵,缓冲接口提供了必要的随机访问能力和数据持久性。
2. 双通道交错布局 vs 平面布局
决策:将两个输入流的数据交错存储到输出缓冲区。
平面布局(备选方案):
[ a0-a511 | b0-b511 ] // 先存所有 A,再存所有 B
交错布局(当前方案):
[ a0-a7 | b0-b7 | a8-a15 | b8-b15 | ... ] // A/B 交替
选择交错的理由:
- SIMD 友好:QR 分解等操作经常需要同时访问对应位置的 A/B 样本,交错布局使它们在内存中相邻,可用单次 512-bit 加载获取
- 缓存效率:空间局部性更好,减少缓存未命中
- 流水线友好:数据预取器能更好地预测访问模式
代价:稍微复杂的索引计算,但在 AIE 的向量操作中这是透明的。
3. 编译期常量 vs 运行时参数
决策:所有关键参数 (ROW, COL, IO_BUFFER_BUS_WIDTH) 都是 static constexpr。
权衡:
- 灵活性代价:无法在不重新编译的情况下改变矩阵大小或总线宽度
- 性能收益:编译器可以完全展开循环、内联所有计算、消除边界检查
为何接受此权衡:
MUSIC 算法的参数在系统设计阶段就已确定,且对性能极度敏感。AIE 内核的编译时间远小于 FPGA 比特流生成时间,重新编译是可接受的。
4. 显式位置约束 vs 自动布局
决策:仅使用 bounding_box 粗粒度约束,注释掉了精细的 tile 级绑定。
考量因素:
| 因素 | 自动布局 | 显式绑定 |
|---|---|---|
| 开发速度 | ✅ 快 | ❌ 慢,需要反复迭代 |
| 资源利用率 | ⚠️ 可能次优 | ✅ 可精确控制 |
| 时序确定性 | ⚠️ 可能变化 | ✅ 固定不变 |
| 可维护性 | ✅ 代码简洁 | ❌ 硬编码坐标易过时 |
| 调试难度 | ⚠️ 问题难定位 | ✅ 行为可预测 |
当前策略:在开发/验证阶段使用自动布局快速迭代;在量产前根据时序报告决定是否添加显式约束。
依赖关系分析
本模块依赖的外部组件
io_and_stream_adaptation
├── 直接依赖
│ ├── music_parameters.h # 维度常量 (ROW, COL, ROW_0 等)
│ ├── adf.h # AMD AI Engine 数据流框架
│ ├── aie_api/aie.hpp # AIE 硬件抽象 API
│ └── aie_api/aie_adf.hpp # ADF 集成扩展
│
└── 间接依赖 (通过 ADF 框架)
├── libadf.a # ADF 运行时库
└── chess 编译器 # AIE 专用编译器
依赖本模块的上游组件
根据模块树分析:
-
pipeline_orchestration - 流水线编排层
- 负责协调包括 io_adapter 在内的所有阶段
-
subspace_decomposition_kernels - 子空间分解内核
- 特别是
qrd_graph,直接消费io_adapter的输出
- 特别是
-
music_graph.h - 完整 MUSIC 图
- 顶层集成点,连接所有子图
数据契约
输入契约 (PL → io_adapter):
| 属性 | 要求 | 验证方式 |
|---|---|---|
| 数据类型 | cfloat (64-bit 复数) |
静态类型检查 |
| 总线宽度 | 256 bits (每流) | PLIO 配置匹配 |
| 数据总量 | 每帧 512 个 cfloat (每流) |
隐式,由循环计数保证 |
| 传输顺序 | 连续流,无间隙 | 协议层保证 |
输出契约 (io_adapter → qrd):
| 属性 | 保证 | 消费方假设 |
|---|---|---|
| 数据类型 | cfloat |
匹配 |
| 布局 | 双通道交错 | QR 分解需知晓此布局 |
| 大小 | ROW * COL = 1024 元素 |
用于缓冲区分配 |
| 对齐 | 512-bit 向量对齐 | SIMD 操作必需 |
使用指南与示例
独立测试场景
// io_adapter_app.cpp - 标准测试流程
#include "io_adapter_graph.h"
int main(void) {
// 1. 实例化图
dut_graph aie_dut;
// 2. 初始化 - 分配内存、配置 DMA、加载微码
aie_dut.init();
// 3. 运行 - 执行 4 帧处理
// 每帧 = 1024 cfloat 输入 → 1024 cfloat 输出
aie_dut.run(4);
// 4. 结束 - 刷新缓冲区、释放资源
aie_dut.end();
return 0;
}
集成到完整 MUSIC 系统
// music_graph.h - 生产环境用法
#include "io_adapter_graph.h"
#include "qrd_graph.h"
// ... 其他子图
class music_graph : public adf::graph {
public:
io_adapter_graph io_adapter; // 本模块
qrd_graph qrd; // 下游消费者
// ...
music_graph(void) {
// 直接连接,无需中间 PLIO
adf::connect<>(sig_i[0], io_adapter.sig_i[0]);
adf::connect<>(sig_i[1], io_adapter.sig_i[1]);
adf::connect<>(io_adapter.sig_o, qrd.sig_i); // 关键连接!
// ...
}
};
仿真数据准备
输入数据文件格式 (data/sig_i_0.txt):
// 每行一个 cfloat: 实部 虚部
1.0 0.0
0.707 0.707
0.0 1.0
-0.707 0.707
// ... 共 512 行 (一帧)
生成测试数据的 Python 片段:
import numpy as np
# 生成 128x8 复数矩阵,分成两个 128x4 部分分别存入两个文件
matrix = np.random.randn(128, 8) + 1j * np.random.randn(128, 8)
# 展平并按流顺序写入
with open('sig_i_0.txt', 'w') as f:
for val in matrix[:, :4].flatten():
f.write(f"{val.real:.6f} {val.imag:.6f}\n")
with open('sig_i_1.txt', 'w') as f:
for val in matrix[:, 4:].flatten():
f.write(f"{val.real:.6f} {val.imag:.6f}\n")
边缘情况与注意事项
1. 数据量不匹配
风险:如果 PL 发送的数据量少于或多于预期 (ROW * COL / 2 每流),会导致:
- 不足:内核阻塞在
readincr_v,流水线停滞 - 过量:多余数据留在流 FIFO 中,污染下一帧
缓解措施:
- PL DMA 配置必须与 AIE 期望严格一致
- 使用 TLAST 信号标记帧边界(当前实现未显式使用,依赖计数)
2. 时序压力
风险:runtime<ratio>(io_adapter_kernel) = 0.9 意味着内核占用 90% 的周期。如果输入数据到达过快,可能导致:
- 流 FIFO 溢出
- 背压传播到 PL 侧
监控指标:
- 仿真时的 stall 周期计数
- 硬件运行时的 FIFO 水位
3. 内存对齐假设
隐含假设:aie::begin_vector<NUM_ELEMENTS> 要求输出缓冲区至少 512-bit 对齐。
违反后果:未定义行为,可能导致 SIMD 加载异常或静默数据损坏。
保证方式:ADF 框架自动分配满足对齐要求的缓冲区。
4. 浮点精度
注意:cfloat 是单精度复数 (32-bit 实部 + 32-bit 虚部)。MUSIC 算法涉及大量累加操作,需注意:
- 数值稳定性(QR 分解的旋转选择)
- 与 MATLAB/Python 双精度参考结果的误差容忍度
5. 并发访问限制
重要:IO_Adapter 类是无状态的,但 run 方法不是可重入的:
- 同一内核实例不能并行执行多个
run调用 - ADF 框架保证单线程调度,用户无需额外同步
6. 编译器特定扩展
代码使用了 Chess 编译器特有的 pragma/hints:
chess_prepare_for_pipelining
chess_flatten_loop
移植风险:如果未来迁移到其他 AIE 编译器(如 LLVM-based),这些提示可能需要调整。
参考链接
相关模块文档
- pipeline_orchestration - 流水线编排层,调用本模块
- subspace_decomposition_kernels - 子空间分解,消费本模块输出
- spatial_spectrum_search_and_peak_finding - 谱峰搜索
- doa_estimation_output_stage - DOA 估计输出
父模块
- music_direction_of_arrival_pipeline - MUSIC DOA 估计完整流水线
外部参考
总结
io_and_stream_adaptation 模块在 MUSIC 算法流水线中扮演着数据语义桥梁的关键角色。它解决了 PL 流式世界与 AIE 缓冲世界之间的根本差异,通过精心设计的交错布局为后续处理阶段创造了最优的数据访问模式。
理解这个模块的关键在于把握以下几点:
- 它是转换器,不是处理器 —— 不进行数学运算,只改变数据形态
- 布局即契约 —— 交错布局是向后传递的关键假设,修改此处必须同步修改下游
- 性能源于静态化 —— 编译期常量和向量化是实现高吞吐量的基础
- 简单即可靠 —— 无状态设计、单一职责、清晰的输入输出契约
对于新加入团队的开发者,建议从修改仿真数据文件开始,观察输出变化,逐步理解数据流的全貌。