2026年4月18日
从 SysY 到 TensorSysY:一条小型张量编译链的实现记录
记录 TensorSysY 从张量语法、HIR/LIR、解释器和代码生成,到 CPU/CUDA runtime adapter、数值验证与性能回归测试的实现过程,也说明项目目前仍然保留的边界。
TensorSysY 是我用来练习编译器和 GPU runtime 衔接的一个小项目。它在 SysY 语法上增加了 tensor 类型和几个常用算子,经过 AST、HIR、LIR 后,可以生成 C++ 或 CUDA 代码,再链接到对应的 runtime adapter。
我最初想验证的不是某个 kernel 能跑多快,而是能不能把下面这条链路完整接起来:
.tsy 源码
→ AST
→ HIR
→ LIR
→ 解释执行 / C++ 代码生成 / CUDA 代码生成
→ runtime adapter
→ 本地可执行文件
项目按 W0–W11 分阶段完成。本文不逐项复述计划,而是记录实现过程中几个实际影响 比较大的部分:前端兼容、IR 和诊断、runtime 边界、数值验证,以及后来收窄 benchmark baseline 的原因。
先说明项目边界
TensorSysY 参考了两个已有项目,但没有把它们直接拼成一个仓库:
sysy-compiler提供了 SysY 语法和前端实现上的参考;mini-llm-engine的 CPU 算子可以通过 adapter 接入。
CUDA 路径没有复用 mini-llm-engine 的 CUDA 实现。后者的接口和数据类型与
TensorSysY 当时的 FP32 执行路径并不匹配,所以我在项目内实现了 FP32 CUDA
kernel,并在矩阵乘法中加入 cuBLAS 版本。
这也决定了项目的定位:它是一个教学和实验用途的小型编译链,不是完整的张量 编译器,更不是训练或推理框架。当前实现只覆盖少量算子、静态 shape 和比较简单的 执行模型。
前端:在保留 SysY 的同时加入 tensor
前端继续使用 flex/bison。新增的类型和内建算子包括:
tensor<f32>[M, N]
@matmul
@add
@softmax
@rmsnorm
@transpose
@relu
这里最容易出问题的是兼容性。tensor 和各个内建算子不能破坏原有 SysY
程序的解析,因此新语法只在明确的类型位置和 @ 前缀后生效。旧语法回归与
张量语法的正、反例测试也分开保留。
我在搭前端骨架时就加入了 SourceLocation 和统一的 diagnostic engine。
之后从 parser、shape verifier 到 codegen 的错误都通过同一套接口报告,而不是在
中间层用 assert 直接终止。例如矩阵乘法的内维不一致时,编译器会返回带行列
位置的诊断。
shape 规则保持得比较严格。对于 [M, K] × [K, N] 之外的布局,编译器不会
为了让运算继续而自动插入转置。转置意味着真实的数据移动或新的 view 语义,
不适合由 verifier 静默决定。
HIR、LIR 和解释器
AST 之后先下降到 SSA 风格的 HIR。HIR 中的 Op 和 Value 保存张量类型、
shape 和源码位置,verifier 在这一层检查算子参数和结果类型。
LIR 更接近执行端,主要描述 buffer 和 primitive call。它没有继续做成通用的 循环 IR,而是保持在这个项目所需的最小粒度。这样做的好处是代码生成和 adapter 比较直接,代价是它目前不适合表达复杂的循环变换。
LIR 解释器是这条链路里的参考实现。它不追求性能,主要用于在不经过 C++ 或 CUDA 代码生成的情况下检查 lowering 结果。生成的本地程序出现数值差异时,可以先判断 问题是在 IR 之前,还是在 codegen/runtime 一侧。
PassManager 也采用了简单实现:HIR pass 和 LIR pass 各自按顺序注册,并且可以用
--disable-pass=<name> 单独关闭。当前 O1 pipeline 是:
HIR: verify → const-fold → dce → verify-post
LIR: layout-lowering → schedule-cuda
这个列表容易让人误以为所有 pass 都已完整实现,实际并不是:
const-fold目前是占位 pass。HIR 还没有可供折叠的 tensor 常量;layout-lowering也仍是占位实现,没有自动插入 layout 转换;- DCE 采用保守策略;
schedule-cuda会根据矩阵 shape 为 matmul 标记naive、tiled或cublas。
我保留前两个占位 pass,是为了先固定 pipeline 和测试接口,但它们不应被算作已经 完成的优化能力。
CPU 路径:先把完整链路跑通
CPU adapter 是编译器 IR 与外部算子实现之间的边界。它负责把 LIR 中的 buffer、
shape 和 primitive call 转成 runtime 调用,HIR 和 LIR 不直接依赖
mini-llm-engine 的函数签名。
adapter 中明确约定了几件事:
- matmul 输入按
[M, K] × [K, N]解释; - softmax 沿最后一维计算;
- rmsnorm 沿 hidden dimension 计算;
- 不匹配的 layout 返回错误,不在 adapter 内临时生成隐式副本。
CPU 路径完成后,examples/mlp.tsy 可以经过编译器生成 C++,链接 adapter 并运行。
测试同时保留解释器输出和 NumPy 参考,避免只检查“程序成功退出”。
这里把 adapter 单独隔开很有必要。如果以后替换 CPU 算子库,变化主要集中在 runtime 层;如果让 HIR 直接携带某个库的参数约定,前端、lowering 和 codegen 都会跟着变。
CUDA 路径:本地 kernel、cuBLAS 和调度
CUDA adapter 与 CPU adapter 使用相同的 LIR 调用约定,但实现位于项目内部。 add、softmax、rmsnorm、transpose 和 ReLU 使用 FP32 CUDA kernel,matmul 提供 三种实现:
naive:用于小规模或不满足 tiled 条件的简单路径;tiled:用于满足对齐要求的矩阵;cublas:用于其余较大的矩阵乘法。
schedule-cuda 根据 shape 写入 variant 属性,emit-lir --opt=O1 可以直接看到
选择结果。这个 pass 只是一个基于阈值的规则选择器,不包含 autotuning。
runtime 在把结果从 device 拷回 host 前保留显式同步点。同步复制本身已经提供了 顺序保证,但显式同步能让计时范围更清楚,也避免以后改成异步复制时遗漏依赖。
CUDA 在 CMake 配置阶段是可选能力。没有 CUDA compiler 的机器仍然可以构建和 运行 CPU 测试;CUDA adapter、CUDA 示例和 GPU benchmark 只在环境满足时注册。
用一个 toy transformer block 检查多算子链路
单算子测试通过后,我增加了 examples/transformer_block.tsy。它是一个简化的
单头 transformer block,包含 rmsnorm、attention、residual 和带 ReLU 的 FFN。
这个例子的作用是覆盖多个算子连续执行时的 buffer、shape 和代码生成问题,不用于
说明模型效果。
E2E 测试使用确定性输入,并把三条执行路径与同一个 NumPy 参考实现比较:
- LIR 解释器;
- CPU adapter 生成的程序;
- CUDA adapter 生成的程序(仅在 CUDA 环境可用时)。
测试通过 TSY_PYTHON_EXECUTABLE 指定 Python 解释器。pytest 和 NumPy 不可用时,
对应的 E2E 测试不会注册;因此不能只看 ctest 的退出码,还需要确认实际注册了
哪些测试。
为什么 benchmark 最后只保留三行
W11 最初准备记录 18 行 baseline,覆盖多组 matmul shape、三个 kernel variant 和 transformer block。第一次连续采样后,较小 shape 和端到端 workload 的波动 明显超出了预设阈值。
当前仓库记录的噪声范围如下:
| 测试项 | 观察到的跨轮波动 | 处理方式 |
|---|---|---|
| matmul 1024³,三个 variant | 小于 5% | 纳入 baseline |
| matmul 512³ | 约 15–25% | 不作为 gate |
| sub-ms shape | 约 40–200% | 不作为 gate |
| transformer block(CUDA) | 结果呈双峰,偶有 5 ms 以上尖峰 | 不作为 gate |
测试环境是 RTX 3080 Laptop GPU、WSL2 和共享使用的笔记本系统。对于 sub-ms 任务,launch overhead、功耗状态和系统负载足以覆盖代码本身的变化。如果强行把 这些数据设成硬门禁,得到的主要是误报。
最终 baseline 只保留
matmul 1024×1024×1024 × {naive, tiled, cublas} 三行。相对 baseline
慢 5% 产生 warning,慢 10% 判定为 failure。其余 shape 仍会运行并输出结果,
但只用于人工观察,不参与回归判定。
具体的硬件条件、基线数值和更新规则记录在
docs/benchmarks/baseline.md。
baseline 只对记录中的 RTX 3080 Laptop GPU 与 WSL2 环境有效,不能直接拿来和
其他显卡比较。
目前仍未完成的部分
项目现在保留了几个明确的空缺:
View和Permute在 IR 中预留了枚举,但 verifier 仍会拒绝;const-fold和layout-lowering仍是占位 pass;- 没有完整的 fusion pipeline;
- GitHub Actions 只覆盖 CPU 路径,GPU 测试和 benchmark 需要本地 CUDA 环境;
- scheduler 使用固定阈值,没有搜索、profile 或自动调优。
这些限制会直接写在 README 和测试条件里。对这个项目来说,保留一个可检查的 未实现边界,比把占位模块描述成完整能力更重要。
回头看这次实现
TensorSysY 最终完成的是一条规模不大但可以实际运行的编译链:源码经过前端、 HIR 和 LIR 后,既能解释执行,也能生成 CPU/CUDA 程序,并通过独立数值参考和 有限范围的性能 baseline 做回归。
这次实现中最有用的经验比较具体:
- 源码位置和诊断最好在 IR 稳定前加入,否则后续每一层都要补接口;
- runtime adapter 应与 IR 分开,外部算子库不应反向决定前端表示;
- 数值测试需要独立参考,不能只比较两条共享大量实现的内部路径;
- 性能门禁的覆盖范围取决于测量稳定性,不取决于表格里能放多少组数据。
项目还有不少空白,但现有边界是清楚的,也能通过源码、测试和 benchmark 文档逐项 核对。对我而言,这比继续增加几个没有完整验证的算子更有价值。