返回文章列表

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 的原因。

源码:BetterThanAny/tensor-sysy

先说明项目边界

TensorSysY 参考了两个已有项目,但没有把它们直接拼成一个仓库:

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 中的 OpValue 保存张量类型、 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 标记 naivetiledcublas

我保留前两个占位 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 参考实现比较:

  1. LIR 解释器;
  2. CPU adapter 生成的程序;
  3. 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 环境有效,不能直接拿来和 其他显卡比较。

目前仍未完成的部分

项目现在保留了几个明确的空缺:

  • ViewPermute 在 IR 中预留了枚举,但 verifier 仍会拒绝;
  • const-foldlayout-lowering 仍是占位 pass;
  • 没有完整的 fusion pipeline;
  • GitHub Actions 只覆盖 CPU 路径,GPU 测试和 benchmark 需要本地 CUDA 环境;
  • scheduler 使用固定阈值,没有搜索、profile 或自动调优。

这些限制会直接写在 README 和测试条件里。对这个项目来说,保留一个可检查的 未实现边界,比把占位模块描述成完整能力更重要。

回头看这次实现

TensorSysY 最终完成的是一条规模不大但可以实际运行的编译链:源码经过前端、 HIR 和 LIR 后,既能解释执行,也能生成 CPU/CUDA 程序,并通过独立数值参考和 有限范围的性能 baseline 做回归。

这次实现中最有用的经验比较具体:

  • 源码位置和诊断最好在 IR 稳定前加入,否则后续每一层都要补接口;
  • runtime adapter 应与 IR 分开,外部算子库不应反向决定前端表示;
  • 数值测试需要独立参考,不能只比较两条共享大量实现的内部路径;
  • 性能门禁的覆盖范围取决于测量稳定性,不取决于表格里能放多少组数据。

项目还有不少空白,但现有边界是清楚的,也能通过源码、测试和 benchmark 文档逐项 核对。对我而言,这比继续增加几个没有完整验证的算子更有价值。