diff --git a/scratchv/backend/kernels/DEVELOPMENT.md b/scratchv/backend/kernels/DEVELOPMENT.md index 10b61fe7..bd93ab74 100644 --- a/scratchv/backend/kernels/DEVELOPMENT.md +++ b/scratchv/backend/kernels/DEVELOPMENT.md @@ -186,6 +186,40 @@ RISC-V 有一个可选的「压缩指令」扩展(C 扩展),能让一部 (`norvc` = no RVC,不许用压缩指令。)第 1 章会给你完整的文件模板。 +#### ⚠️ 热循环对齐:先确认三种代价 + +希望降低"指令未命中"时,常见的做法是给热循环加 `.balign 64`(把循环对齐到 64 字节边界)。 +**这一做法需要克制:它有三种代价,其中一种会直接导致整题 0 分。** + +**本机实测**(平台同款参数:`--target=riscv32-linux-gnu -march=rv32imf -mabi=ilp32`, +链接用 `-fuse-ld=lld -Wl,--no-relax`): + +| 写法 | `R_RISCV_ALIGN` | 填充是什么 | 后果 | +|---|---:|---|---| +| 文件里已写 `.option norvc`,再在 `.text` 里 `.balign 64` | **0** | 4 字节 `nop`(`13 00 00 00`);实测填了 14 条 = 56 字节 | 不违反红线,但**每条都执行** | +| 文件里**没有** `.option norvc`,同上 | **1** | 交给链接器填(可能是 2 字节 `c.nop`) | 若填成 2 字节则**整题 0 分** | +| `.text` 里 `.skip` / `.2byte` | 0 | 直接产生非指令字节 | **整题 0 分**(判据是地址,不是重定位) | +| `.data` / `.bss` 里 `.balign 64` | 0 | 数据对齐,不产生指令 | **合法**,还可能降 `d_miss`(§6.4) | + +**各项说明:** + +- 平台的编译参数固定为 `rv32imf`(没有 C 扩展),而产物里一旦出现 2 字节指令就整题 0 分 —— + 因此实际上只能写 `.option norvc`。两者叠加后,表中第一行即为默认情形: + **`.balign` 通常不会直接导致 0 分**,其代价是十余条只增加指令数、不产生计算的 `nop`。 +- 第二行的风险最大:**没先写 `norvc` 时,填充尺寸最终由链接器决定**(这就是 + `R_RISCV_ALIGN` 的含义)。你的源码里看不出它最后填的是 2 字节还是 4 字节 —— 换工具链、 + 换参数即可能变化,而 2 字节即违反红线一。 +- 第三行与 `norvc` 无关:`.skip` / `.2byte` 写入多少字节就产生多少字节,必然导致整题 0 分。 + +**至于"手动填 4 字节 nop"这个替代方案,也有两个问题:** + +1. **填充会被执行**:本机实测那 14 条 `nop` 就在 `addi` 和 `ret` 之间的顺序路径上, + **全部计入指令数**(56 字节 = 14 条); +2. **可能完全无效**:填充没把热循环推过块边界时,`i_miss` 一行都不降,指令数却实际增加。 + +> **规则:修改代码布局之前先计算行数下限(§6.5)。** 下限已经达到时,任何对齐填充都是净损失。 +> 确需实施时,只接受"计算过代价、且 `.option norvc` 在 `.balign` 之前"的写法。 + ### 0.5 成本怎么算:三个数字 平台衡量你的内核"贵不贵",用的是这一个数: @@ -237,6 +271,9 @@ cost = 指令数 + 15 × (数据缓存未命中次数 + 指令缓存未命中次 1. **超过基准不给额外分。** 领先两倍和领先 0.1% 是同一个分数。 2. **`-O0` 参考解不是计分基准**(它只是平台上的一份快照)。真基准是**所有队伍里最小的那个 cost**。 3. **分数会随别人变强而下降**,所以"先提交"本身有价值。 +4. **"更差的提交"和"无效的提交"不是一回事。** 榜单按题取历史最好 ⇒ 交一版更慢但**有效**的提交不掉分; + 而"编译失败 / 2 字节指令 / 越界写"是**无效提交**,那是 0 分,会盖掉你原来的成绩。 + ⇒ 没有把握的新技术可以先行试验(例如 §5.4 的 FMA),**前提是先过第 8 章的门禁**。 **所以你的目标不是"越快越好",是"每个数据点都追到当前榜首"。** @@ -458,6 +495,53 @@ float32 和定点完全一样。第 2 章的框架就是把这句话变成数据 ## 第 2 章 · 把手工的东西变成代码 +### 2.0 先选一条生成路径:指令由谁生成 + +在构建框架之前,需要先回答一个更基本的问题:**提交的 `.s` 中的指令由谁写出?** +同样是 4×4 分块,生成方式不同,与下限的距离可以相差数倍。 + +| 代号 | 路径 | 指令来源 | 控制力 | 适用场景 | +|---|---|---|---|---| +| **R1** | 手写汇编(含 `.macro` / `.rept` 展开) | 你 | **最高**:每条指令、每字节体积都可控 | 补充边角情形与小 N 直线路径 | +| **R2** | 脚本 / 模板生成器 | 你的 Python 脚本 | 高,**且是唯一能批量扫参数的路线** | 提升排名的主力 | +| **R3** | C/C++ + `clang -S` | 编译器 | 中:地址计算与展开策略不受控 | 仅作参照 | +| **R4** | 手写 LLVM IR → `llc` | 你写 IR,后端出汇编 | 中高 | 比 R3 更贴近机器,但调试差 | +| **R5** | 自研框架(就是第 2、3 章要造的) | 框架 | 高,但受框架表达力限制 | **最快获得"可提交的基线"** | +| **X0** | 生成后改写层(窥孔 / 布局 / 对齐) | 脚本或手改 | 横切:对上面每一路都适用 | 收尾 | + +**这张表的用法如下:** + +- **R1~R5 解决"指令如何生成",X0 解决"生成之后如何修改"。** 它们不是互斥的。 + 实际顺序是 **R5 起底 → R2 重写 → R1 补丁 → X0 收尾**:先用现有实现获得真实反馈, + 再用生成器获得结构收益,最后用 X0 微调 `d_miss` / `i_miss`。 +- **下限由指令集决定,与路径无关**(add 4 条/元素、reduesum 2 条/元素、matmul 1.81 条/MAC —— 4×4 带 FMA)。 + 路径之间的差别只有三个:**与下限的距离、布局的可控性、迭代成本。** + +**本教程从 R5 开始的原因**:需要一个"可提交的基线"。没有基线,所有优化都只能是猜测, +也无法判断"本次改动是否有效"。 + +**不直接使用 `-O3` 的原因**(R3):并非"编译器能力不足",而是三条结构性原因: + +| 原因 | 说明 | +|---|---| +| **目标函数不同** | 它最小化"估计的周期数",不是 `instr + 15×miss`。为了少一次访存,它愿意多几条指令 | +| **信息不足** | 它不知道 L1 是 32KB/4-way/64B,不知道一次 miss 值 15 条指令,也不知道"必须全 4 字节" | +| **不做全局布局** | 段对齐、内核体位置、冷热分离这些"跨函数/跨段"的决定,编译器不会替你做 | + +另一套产物上实测过:`-O3` 在 add 上差 **1.94×**、在 matmul 上差 **3.72×**—— +而且无法通过调整若干 flag 弥补。 + +**几种已被否定的路径**(可避免重复尝试): + +| 路径 | 原预期 | 否定原因 | +|---|---|---| +| 超优化器(自动搜索"最少指令") | 自动搜索最优 | 它只优化指令数,看不见 `i_miss` 的 15 倍权重 ⇒ **适应度函数不对** | +| 按 N 逐个特化 10 版 | 每点最优 | 收益仅为几个百分点,且数据点更换后完全失效(§0.6) | +| 只执行 `clang -c` 就提交 | 速度快 | 分支越界只有链接期才暴露(第 8 章) | + +> 注:本节的距离数字来自另一套产物,只用来说明"路径之间有量级差"。 +> **现有产物与下限的距离必须自行测量(§4.0)。** + ### 2.1 为什么要框架 如果你只做一道题,第 1 章的写法就够了,**不需要任何框架**。 @@ -815,7 +899,7 @@ def _generic(target, dtype) -> list[str]: ] ``` -> **§3 的 matmul 到这里就结束了。** 最终的 `bodies/matmul.py` 还多两样东西: +> **第 3 章的 matmul 到这里就结束了。** 最终的 `bodies/matmul.py` 还多两样东西: > **`_blocked()`**(寄存器分块,§6.3)和**派发用的 `build()`**(按 `blocking` 参数 > 选走哪条路)。本文档的每一段代码都在仓库里,**以仓库为准** —— 附录 B 有完整清单。 @@ -907,7 +991,7 @@ matmul q16 invalid 得分=0.0 ← 0/10,全部数值不符 "乘完直接累加"(对 f32 对,对 q16 错)。 修法见 §2.3:把 `mul` 换成 **`mac`(一次乘加的整条指令序列)**。 -改完之后六题全过(见 §3.7)。 +改完之后六题全过(见 §3.6)。 ### 3.5 题册与组装 @@ -955,12 +1039,13 @@ from scratchv.backend.kernels.dtypes import POLICIES from scratchv.backend.kernels.target import TARGETS -def build_program(problem: str) -> str: +def build_program(problem: str, unroll: int | None = None) -> str: body_name, dtype_name, target_name = PROBLEMS[problem] body = BODIES[body_name] dtype = POLICIES[dtype_name] target = TARGETS[target_name] - return '\n'.join(body.build(target, dtype)) + # unroll 省略时用 body 的默认值(1 = 不展开);第 5 章会把实测值记进 UNROLL 表 + return '\n'.join(body.build(target, dtype, unroll or 1)) ``` 新建 `scratchv/backend/kernels/__main__.py`: @@ -982,7 +1067,9 @@ from scratchv.backend.kernels.pipeline import build_program def main(argv=None) -> int: ap = argparse.ArgumentParser(description='ScratchV 算子内核生成器') ap.add_argument('--problem', help='题名,见 --list') - ap.add_argument('-o', '--output', help='输出 .s 路径(默认打印到屏幕)') + ap.add_argument('-o', '--output', help='输出 .s 路径') + ap.add_argument('--unroll', type=int, default=None, + help='展开因子(2 的幂);省略则用默认值') ap.add_argument('--list', action='store_true', help='列出所有题名') args = ap.parse_args(argv) @@ -994,7 +1081,7 @@ def main(argv=None) -> int: if not args.problem: ap.error('要么给 --problem,要么给 --list') - text = build_program(args.problem) + text = build_program(args.problem, args.unroll) if args.output: with open(args.output, 'w', encoding='utf-8') as f: f.write(text) @@ -1008,6 +1095,9 @@ if __name__ == '__main__': raise SystemExit(main()) ``` +> **仓库里的实际版本**多两点:省略 `-o` 时写到当前目录的 `<题名>.s`(与 gcc/clang 同一约定), +> 要打到屏幕必须显式写 `-o -`。`--unroll` 也已经在里面了(§5.1 会用到)。 + ### 3.6 跑通六题 ```bash @@ -1100,6 +1190,84 @@ q16 是 `mul`+`srai 16`+`add`(3 条)。只给"乘"这一个助记符,q16 ## 第 4 章 · 诊断:知道自己在哪 +### 4.0 先算下限:否则无从判断"还差多少" + +诊断的第一步不是查看自身数据,而是**先计算该题的理论下限**。没有下限, +"还能再快一点"就会成为没有终点的循环。 + +**三种下限,三种算法:** + +| 下限 | 怎么算 | 谁决定 | 能不能突破 | +|---|---|---|---| +| **指令条数下限** | 每个元素 / 每 MAC **不得不做**的操作条数 | 指令集(有没有 FMA、有没有 SIMD) | 不能,只能逼近 | +| **强制未命中下限** | 必须访问的**字节数 ÷ 64** | 数据规模 | 不能 | +| **行数下限**(`i_miss` 的下限) | 热路径代码字节数 ÷ 64 | 代码体积 | 不能,除非改结构 | + +**三题的下限**(`rv32imf`:一次 `flw` 只读取 1 个 float,没有 SIMD): + +| 题 | 指令条数下限 | 怎么来的 | +|---|---|---| +| add | **4 条/元素** | 读 A、读 B、加、写 C,一条都少不了 | +| reducesum | **2 条/元素** | 读、加(输出只有 1 次,摊薄到看不见) | +| matmul | **m + (2mr+nr+1)/(mr·nr) 条/MAC** | m 条乘加(带 FMA 是 1、不带是 2)+ 摊薄后的装载与指针 | + +**matmul 一行需要注意:下限并非单一数值,而是随分块大小变化的一组数值。** + +| 块 mr×nr | 不带 FMA | 带 FMA | +|---|---:|---:| +| 1×1(不分块) | 6.00 | 5.00 | +| 2×2 | 3.75 | 2.75 | +| **4×4** | **2.81** | **1.81** | +| 4×5 | 2.70 | 1.70 | +| 8×8 | 2.39 | 1.39 | + +(8×8 一行仅用于展示趋势:它需要 81 个寄存器,本题无法容纳 —— 可行性见 §6.3 的表。 +同理,4×5 之后的各行均为"寄存器数量不受限"前提下的下限。) + +(1×1 不带 FMA 为 6.00,再加上 `k++` 与 `bne` 正好 **8 条** —— 与 §3.3 统计的 +内循环完全一致。**标量三重循环代价高,原因在于每个 MAC 都要重新读取两个数并推进两个指针。**) + +**以 4×4 代入验证**(§4.4 会统计出这个 47 条的内循环): + +``` +载入 4 个 A + 4 个 B 8 条 +乘加 16 × (fmul + fadd) 32 条 +4 个 A 游标 +4、B 加行步长 5 条 +k++、bne 2 条 +──────────────────────────────── +合计 47 条 → 47/16 = 2.94 条/MAC + +下限(不含 k 控制) = 2 + (2×4+4+1)/16 = 2.81 +加上 k 控制 = 2.81 + 2/16 = 2.94 ← 对上了 +``` + +**2.94 的含义是:不带 FMA 的 4×4 已经达到下限。** 若要继续降低,只有两条路径: +换更大的块(§6.3)、换乘加指令(§5.4)。**因此必须先计算下限,再考虑优化——否则很容易误以为 +"47 条里还有 5 条能省",而那 5 条无法省掉。** + +> 整题实测是 **2.98** 条/MAC,比内循环的 2.94 多 0.04。那 0.04 不是误差: +> 每个 4×4 块还要 16 条清零 + 16 条写回 + 十几条准备,摊到这 256 个块的 262,144 次乘加上, +> 正好 0.04~0.05。**内循环 + 每块固定开销 = 整题,两者一致,该模型才成立。** + +**然后计算两个比值:** + +``` +比值 = 你每元素/MAC 的指令数 ÷ 下限 # 现状 ÷ 下限 +占比 = 15 × (d_miss + i_miss) ÷ cost +``` + +| 判据 | 结论 | +|---|---| +| 比值 **< 1.05** | **这题不必再优化**,时间转到比值大的题上 | +| 比值 1.05 ~ 1.3 | 还有 5%~30%,值得做,但要先算清差在哪一项 | +| 比值 **> 1.5** | 结构性差距:先怀疑"走错路径了"(整题回退、没分块、没合并乘加) | +| 占比 **> 20%** | 先修未命中(对齐 / 循环顺序 / 布局) | +| 占比 **< 5%** | 修未命中几乎不可见,不要改动 | + +**一个实际判断**:三题里 add / reducesum 的比值都是 1.03,matmul 是 1.06(按不带 FMA 的下限)—— +**但按带 FMA 的下限是 1.64**。同一份产物,更换下限后,结论即从"已经达到"变为"仍有 64% 空间"。 +这正是 §5.4 的必要性所在。**下限选择错误,后续结论均不成立。** + ### 4.1 只看三个数字 | 数字 | 含义 | 什么时候它是瓶颈 | @@ -1206,7 +1374,7 @@ cnn_entry: 47 ÷ 16 = 2.94 条/MAC ``` -**这个数要和"该算法的地板"比。** 地板怎么估:一次 MAC 至少要一条乘、一条加(2 条), +**这个数要和"该算法的下限"比。** 下限怎么估:一次 MAC 至少要一条乘、一条加(2 条), 再加上"取数摊到每个 MAC 上的部分"。**分块越大,取数摊得越薄**——这就是 §6.3 的全部动机。 #### 第四步:追寄存器 —— 找 bug 全靠这一步 @@ -1250,11 +1418,11 @@ miss_cost = 15 × (d_miss + i_miss) 占比 = miss_cost / cost ├─ 占比 < 20% ──【指令数主导】(多数情况) │ │ -│ ├─ 每元素/MAC 的指令数到地板了吗? +│ ├─ 每元素/MAC 的指令数到下限了吗? │ │ ├─ 到了 → 换算法/换分块(§6.3) │ │ └─ 没到 → 先摊循环开销(§5.1 的展开) │ │ -│ └─ 地板是多少(估法见下) +│ └─ 下限是多少(估法见下) │ └─ 占比 > 20% ──【未命中主导】 │ @@ -1264,13 +1432,8 @@ miss_cost = 15 × (d_miss + i_miss) 占比 = miss_cost / cost └─ 远高于强制 → 才轮到缓存分块 / 改循环顺序(§6.4) ``` -**地板怎么估**: - -| 题 | 每元素/MAC 的地板 | 怎么来的 | -|---|---|---| -| add | **4 条/元素** | 读 A、读 B、加、写 C | -| reducesum | **2 条/元素** | 读、加 | -| matmul | **2 + (MR+NR)/(MR·NR)** | 2 条乘加,取数摊到 MR·NR 次 | +**下限怎么估**:见 §4.0 的表,公式是 `m + (2mr+nr+1)/(mr·nr)`(`m` = 1 带 FMA、2 不带)。 +**它随分块大小变化**——报"离下限还有 X%"时,必须同时说清按哪个块、带不带 FMA,否则这个数没有意义。 **强制未命中怎么算**: @@ -1297,17 +1460,17 @@ miss_cost = 15 × (d_miss + i_miss) 占比 = miss_cost / cost 1. 某内核 N=1024:指令数 5000,数据未命中 300,指令未命中 20。它的 cost 是多少? 2. 上题里,指令数和未命中各占 cost 的百分之多少?该先优化哪个? 3. 怎么在 `.s` 里一眼找到热循环? -4. `reducesum` 实测每元素 2.07 条指令(8481 条 ÷ 4096 元素),地板是 2 条。还该继续压吗? +4. `reducesum` 实测每元素 2.07 条指令(8481 条 ÷ 4096 元素),下限是 2 条。还该继续压吗? 5. 某 matmul 在 N=64 的 d_miss 是 800,三个矩阵共 48KB。要不要做缓存分块? 答案: 1) 5000 + 15×(300+20) = 9800。 2) 指令数占 51%,未命中占 49%——两边都差不多,先做第 5 章(改一个函数三题都受益)。 3) **找最内层的"向后跳"**:跳转目标的行号在自己上面。 -4) **不值得。** 2.07 vs 地板 2 只差 3%,差的那点是循环开销,展开到 32 之后已经摊得很薄; +4) **不值得。** 2.07 vs 下限 2 只差 3%,差的那点是循环开销,展开到 32 之后已经摊得很薄; 再往下要动的是"读"和"加"本身,压不动了。 5) **不需要。** 强制未命中 = 48KB ÷ 64 = **768 行**,实测 800 —— 只高 4%, - 属于边界不对齐,**已经到顶**。要做也得先量出"远高于 768"才值得。 + 属于边界不对齐,**已经达到下限**。要做也得先量出"远高于 768"才值得。 --- @@ -1402,9 +1565,9 @@ def unrolled_body(dtype, unroll: int) -> list[str]: ——而平台的规模是保密的(第 0.6 节)。**尾循环必须单独测**:比赛的数据点全是 64 的倍数, 它永远不执行 ⇒ **不测就等于没写**。测法见 §5.3。 -**`unroll` 取多大**:不是越大越好。两个力在拉锯: +**`unroll` 的取值**:并非越大越好,存在两方面的权衡: -| 力 | 展开越大 | +| 因素 | 展开越大 | |---|---| | 省下循环开销 | 越省(每元素从 9 降到趋近 4) | | 指令缓存 | 循环体占的行越多,取指未命中越多 | @@ -1591,7 +1754,7 @@ for n in [3, 7, 70, 103, 4095, 4097, 5000]: # 含 N **这一节的规矩:凡是"比赛永远不会走到"的代码路径,都要自己造输入去测。** > 尾循环只是最常见的一个——边界条件(N=0、N=1)、回退路径、异常分支,都是同一类。 +### 5.4 技巧三:一次乘加一条指令(FMA) + +**先给出结论**:`rv32imf` 里有一条指令能**一次完成"乘 + 加"**,而本文档到目前为止的乘加 +都是**两条**(`fmul.s` 然后 `fadd.s`)。在 matmul 上,另一套产物的实测是 +**2.98 → 1.98 条/MAC(−33.5%,10 点合计 −32.6%)**——除了分块(§6.3), +没有第二处改动有这个量级。 + +**为什么是 33% 而不是 50%**:一次 MAC 里除了"乘 + 加",还有装载和指针推进。 +两条乘加变成一条,只省掉"乘加"那部分。 + +| 每条 MAC | 4×4 分块下的下限 | +|---|---:| +| 两条(`fmul.s` + `fadd.s`,本文档现状) | 2 + 13/16 = **2.81** | +| 一条(`fmadd.s`:rd = rs1×rs2 + rs3) | 1 + 13/16 = **1.81** | + +**为什么它不破坏正确性**:数学上 `acc = acc + a*b` 就是"乘加"。FMA 只是把中间乘积 +**保留全精度**再相加,比"先舍入再相加"更准。所以它不会让结果变差,只会和两条的版本 +差在最后一位 —— **正好落在容差比对的范围内**(§2.3 的 `comparison='tolerance'`)。 + +**前提(缺一不可):** + +| 前提 | 说明 | +|---|---| +| 题目是**浮点**、且用容差比对 | 定点题逐位比对,**不允许**任何舍入变化,所以 q16 用不了 FMA | +| `-march` 里有 `f` | `rv32im` 没有浮点指令,谈不上 FMA | +| 反汇编闸门放行这条指令 | 平台的硬判据是"每条 4 字节",FMA 是 4 字节;但**白名单式的实现可能按助记符误判** | + +> **最后一条决定了它的用法:当成一次实验,不要当成定论。** 先按 §0.7 第 4 条想清楚 +> 风险(无效提交会盖掉成绩),再提交一版进行试验,**用真实结果裁决**,不要靠猜。 + +**实现方式**:本框架里"乘加长什么样"被收在一个字段里(§2.3 的 `mac`),它是一串模板。 +换 FMA 就是**换这个字段的一个取值**,`bodies/` 一行都不用改。 +这正是 §2.3 那条"policy 的粒度要落在语义单元上"的回报 —— **如果当初把 `fmul.s` +硬编码在 body 里,这次改动要动三道题。** + +> ⚠️ **本节只讲原理,不贴可复制的代码。** 原因:仓库里 `dtypes.py` 的 f32 乘加模板 +> **目前仍是 `fmul.s + fadd.s`(两条)**。直接复制"已修改的版本",会误以为仓库 +> 已经到 1.81 条/MAC —— 它没有。**这一行需要自行修改、重新生成并测量。** + +**定点题的处理**:q16 没有 FMA 指令。它对应的"1 条换 2 条"是另一个技巧 +(把定标折进高位乘法,§2.3 的教训里提过),**同样是换 `mac` 字段的取值**, +不是换抽象层。**先定对粒度,两种数值类型就都只是"换一个取值"。** + +**此外还需计算总代价**:FMA 只降指令数,不动 `d_miss`(甚至因为代码变短,`i_miss` 可能略降)。 +所以它的收益就是字面那 33%,**直接减在 cost 上**,不需要权衡。 + ### ✅ 检查点 1. add 展开 32 次后,每元素多少条指令? 2. 为什么 `unroll` 不能无限大? 3. 平台的 10 个数据点会走到尾循环吗?为什么这一问重要? 4. `flw ft1, 4096(a0)` 里的 4096 是什么意思? +5. 4×4 分块的 matmul,带上 FMA 之后下限是多少?为什么不可能是 1 条/MAC? 答案: 1) `(32×4 + 5) ÷ 32 = 4.16` 条。实测总指令数从 137927 降到 63419。 @@ -1612,6 +1822,9 @@ for n in [3, 7, 70, 103, 4095, 4097, 5000]: # 含 N §5.1 那个展开循环里,`beqz t2, .Ltail` 和 `beqz a2, .Lret` 就是同一件事—— > **"余数为 0"这条路径必须显式处理,不能指望循环体自己不跑。** -**分块多大**:受**寄存器数量**限制。 +**分块尺寸**:受**寄存器数量**限制。 ``` 累加器数(MR×NR) + 保留的一行 A(MR) + 保留的一行 B(NR) + 乘积落点(1) ≤ 数据寄存器数 @@ -1811,6 +2027,23 @@ bnez TMP, .Lgeneric # N 不是粒度的倍数 → 整题回退(这 (乘积落点那一个是从第 3 个坑里学来的,见下。) +**分块尺寸的计算**(f32 有 32 个浮点寄存器;"数据寄存器"一列已含 `+1` 的乘积落点): + +| 块 | 条/MAC(不带 FMA) | 条/MAC(带 FMA) | 数据寄存器 | 可行 | +|---|---:|---:|---:|---| +| 2×8 | 2.81 | 1.81 | 16+2+8+1 = 27 | ✓ 余 5 | +| **4×4** | **2.81** | **1.81** | 16+4+4+1 = **25** | ✓ 余 7 | +| 4×5 | 2.70 | 1.70 | 20+4+5+1 = 30 | ✓ 余 2,**接近上限** | +| 4×6 / 6×4 | 2.63 / 2.71 | 1.63 / 1.71 | 24+4+6+1 = 35 | ✗ | +| 4×8 | 2.53 | 1.53 | 32+4+8+1 = 45 | ✗ | + +**读法**:摊销系数随分块增大单调改善,但**寄存器数量是硬约束**。4×4 是"寄存器数量刚好足够、 +且已经达到 1.81"的取值;4×5 只能再降低 0.11 条/MAC,代价是只剩 2 个空闲寄存器 —— +**地址、计数与临时值共需约 20 个整数寄存器,一旦不足即发生 spill(一次 spill = 2 条指令 + +占用栈行),所获收益会被立即抵消。** + +**因此"进一步增大"并非无代价的方向。** 判据还是总代价:`Δinstr + 15×Δ(d+i) < 0`。 + **⚠️ 浮点题的约束和整数题不一样**:浮点的累加器放在**浮点寄存器组** (f0–f31,**32 个**),指针放在**整数寄存器组**(约 23 个)。**两组独立**, 所以浮点能放更大的块: @@ -2051,7 +2284,48 @@ N=64 时三个矩阵 48KB,**装不下 32KB 的一级缓存**,于是反复去 **773 ≈ 768,已经接近最优。没有洞可补。** -**而且实测确认了:做完 6.3 的 (4,4) 分块之后,未命中仍然是 773** —— 一点没涨。 +**为什么是 773 而不是 768?——"相位"** + +"强制未命中 = 字节数 ÷ 64"这个算法,只在数组**从 64 字节边界开始**时才成立。 +起点只要不是 64 的倍数,就会首尾各多跨一行: + +``` +起点 %64 == 0 ,长度 256B → 覆盖 4 行 +起点 %64 == 52,长度 256B → 覆盖 5 行 (多出来的那行只用掉 12 字节) +``` + +正确算法是**按地址算行集合**,不是"字节数 ÷ 64 向上取整": + +``` +lines(addr, bytes) = { addr//64 … (addr+bytes-1)//64 } +d_miss = | lines(A) ∪ lines(B) ∪ lines(C) | + 常数项 +``` + +再算一遍 add 的 N=4096:三个数组各 16KB,`48KB ÷ 64 = 768` 行,实测 **773**。 +多出来的 5 行就是**三个数组各自的相位**(每个多跨 1~2 行)加上 wrapper 自己碰的行。 +**所以 773 是"接近下限",不是"优化不够"。** + +**一个能省 1 行的动作(选做)**:既然多余的行来自相位,就把相位改成 0 —— +在**数据段**抬高对齐: + +```asm +.data +.balign 64 +.word 0 # 8 字节占位:把 .data 的 sh_addralign 抬到 64 +.bss +.balign 64 +.space 1 +``` + +(**只对数据段有效**。`.text` 里同样写法会触发 §0.4 那条 0 分红线。) + +效果是输入基址变成 64 的倍数,每个数据点少 1 行 = 少 15。但有三条前提: + +- 你交的 `.s` 里**有**可对齐的数据段(本仓库生成的产物没有 `.data`,要加就自己加); +- 加完**必须逐点重量** —— 段布局整体平移,其他数据点可能反而变差; +- 它属于"生成后改写"(§2.0 的 X0),**不是**算法改动,不要期望它带来 5% 以上的收益。 + +**顺带确认一件事**:做完 §6.3 的 (4,4) 分块之后,未命中仍然是 773 —— 一点没涨。 可以算一下为什么:这份分块的循环顺序是 i-j-k(k 最内),每个 (i,j) 块的 工作集是「A 的 4 行 × 整行 + B 的整列 × 4」——对 N=64 只有 2KB,离 32KB 很远。 @@ -2065,15 +2339,67 @@ N=64 时三个矩阵 48KB,**装不下 32KB 的一级缓存**,于是反复去 > **这一条是本文档里最值钱的经验**:同一个优化技术,在一种实现下是必需的, > 在另一种实现下是无用功。**判据不是"这个技术好不好",是"我的未命中比强制值高多少"。** +### 6.5 指令未命中:代码布局这一维 + +前面几节动的都是"执行多少条指令"。这一节动的是**取指令**: +`i_miss` = **被执行到的、不同的 64B 行数**。 + +**首先需要明确:它不是"取指次数"。** 热循环执行一百万次,只要占 5 行,`i_miss` 就是 5。所以: + +``` +i_miss 的下限 ≈ 热路径(含所有会被执行到的分支)的代码字节数 ÷ 64 +``` + +**这就是为什么 §4.2 的表中 `i_miss` 一直是 12**:代码没变,取指行为就不变 —— **它和 N 无关。** + +**但它并非可以忽略的小量。** 小 N 时 `15×i_miss` 能占 cost 的 45%:N=4 的 matmul(小 N 直线化的产物)只有 138 条指令, +11 行 `i_miss` 就值 165 —— **比指令本身还贵**。 + +**三个可调整的方面**(按收益排序): + +| 手法 | 做法 | 典型效果 | +|---|---|---| +| **合并重复内核体** | 两份只差"用哪个寄存器 / 哪个偏移"的循环体,让它们**跳进同一段**,用分支先把参数算好 | 通常可省 4~8 行,是最有效的方法 | +| **冷热分离** | 回退实现(`.Lgeneric`)、边角路径挪到 `.text` 末尾,不要夹在热路径中间 | 热路径少跨行;冷代码很少执行,其占用行几乎不影响代价 | +| **热循环对齐** | 仅当下限仍有余量、且计算过代价时才实施,**且必须用 4 字节填充** | 风险高(§0.4),先算下限 | + +**"合并"的具体含义**:以 matmul 为例,主块的 4×4 和内边缘的 4×w 只差"循环几列"。 +若为 w=1/2/3 各写一份,就是三份几乎相同的 40 条指令 —— **120 条 = 2 行**。 +合成一份、把 w 变成运行时计数的代价是几条指令,**通常立刻值得做**。 + +**判据仍为下式:** + +``` +Δinstr + 15 × Δ(d_miss + i_miss) < 0 ? +``` + +两个方向相反的例子: + +| 改动 | Δinstr | Δi_miss | 总代价 | 结论 | +|---|---:|---:|---:|---| +| 展开从 32 降到 30(想省 1 行) | **+46** | −1 | **+31** | 净损失 | +| 合并两份内核体 | 0 | −8 | **−120** | 净收益 | + +> **第一条是本节的重点**:为了"省 1 行"去动展开因子,往往要付 40 多条指令 —— +> **15 条的收益根本不够填**。所以"代码短一点总是好的"在这里是错的: +> **只有当你能省整行、且代价小于 15 条时,布局才值得动。** + ### ✅ 检查点 1. matmul 区间是 `[4, 64]`,全区间覆盖要生成多少份?为什么说这几乎免费? 2. 4×4 分块之后每条 MAC 多少指令? 3. 为什么"做完分块要再量一次未命中"? +4. 你的热路径一共 500 字节,`i_miss` 实测 12 行。还剩多少空间? +5. 为了省 1 行 `i_miss`,你把展开因子从 32 降到 30,指令多了 46 条。是否值得? 答案:1) 61 份。因为没执行到的代码不计入 cost。 -2) `2 + (4+4)/16 = 2.5` 条。3) 因为分块会改变工作集大小,可能让缓存开始抖动 +2) 下限 `2 + (2×4+4+1)/16 = 2.81` 条;实测内循环 47 条 ÷ 16 = **2.94**, +多出的 0.13 是 `k++` 和 `bne`。 +3) 因为分块会改变工作集大小,可能让缓存开始抖动 ——而这不是"优化不够",是"优化引入了新问题"。 +4) 下限 ≈ `500 ÷ 64 ≈ 8` 行,所以理论还能省 4 行 = 60。但能否实现取决于布局, +且每省 1 行最多只值 15 条指令 —— 先计算总代价再修改。 +5) **不值得。** `+46 − 15×1 = +31`。**省整行**的收益是 15,而这条改动付了 46。 --- @@ -2108,12 +2434,65 @@ N=64 时三个矩阵 48KB,**装不下 32KB 的一级缓存**,于是反复去 ⚠️ **别只用 `measure.py`**:它和平台同链路,但**不跑指令集检查**。 它测出来的好数字,线上可能直接 0 分。提交前一律用评测器复核。 -**自查清单**(改完逐条对): +### 8.1 门禁清单:每条一个命令、一个期望输出 + +"三关"是判据,下面这张表是**动作**。提交前逐条执行,**每条都有期望输出**—— +看到数字对不上就停下,不要靠"应该没问题"。 + +| # | 检查 | 期望 | 怎么做 | +|---|---|---|---| +| 1 | 完整链接通过 | 退出码 `0` | 用平台同款 `clang ... -fuse-ld=lld` 链 wrapper + 你的 `.s` | +| 2 | 每条指令都是 4 字节 | `0` | 下面 ① | +| 3 | 没有对齐重定位 | `0` | 下面 ② | +| 4 | 官方数据点全对 | 全部 `accepted` | `/tmp/eval.py` | +| 5 | 边角 N 全对 | 全对 | 自己造输入(§5.3):`0, 1, 2, 3, 5, 7, 13, 63, 65` | +| 6 | 保护区没被写 | 无"保护区被破坏" | 评测器自动查三段 guard(§0.2) | +| 7 | 四项齐全 | 等式成立 | 下面 ③ | +| 8 | 逐点不比上一版差 | 每点 `Δcost ≤ 0` | 两版各执行一次,逐点相减 | +| 9 | 改过生成器就重生成 | 三题全部重新执行 1~8 | **框架修好 ≠ 产物修好** | + +**① 找起始地址不是 4 倍数的指令**(判据:每条指令的起始地址必须是 4 的倍数) + +```bash +llvm-objdump -d --mattr=+m,+f,+c x.elf \ + | grep -oE '^[[:space:]]*[0-9a-f]+:' | tr -d ' :' \ + | python3 -c 'import sys; print(sum(1 for a in sys.stdin.read().split() if int(a, 16) % 4))' +# 期望输出:0 +``` + +> 为什么不 `grep 'c\.'`:压缩指令在反汇编里不一定带 `c.` 前缀 —— 实测两条 `c.mv` / `c.addi` +> 显示成的是 `mv` / `addi`(取决于工具链版本),**地址才是硬判据**。 + +> ⚠️ **这个数不是精确条数**:连续的 2 字节指令,只有起始地址不落在 4 的倍数上的那几条能被抓到 +> (实测两条相邻的压缩指令只报 `1`)。但作为"有没有"的判据足够 —— **非 0 就是不合格**。 + +**② 查对齐重定位**(`.text` 里写了 `.balign`、又没先写 `.option norvc` 时会命中,§0.4) + +```bash +clang --target=riscv32-linux-gnu -march=rv32imf -mabi=ilp32 -c x.s -o x.o +llvm-readelf -r x.o | grep -c R_RISCV_ALIGN +# 期望输出:0 +``` + +非 `0` 不代表一定超限,它代表**你的代码布局有一部分是链接期决定的**——换环境就会漂。 + +**③ 复算 cost**(对每个数据点) + +```python +assert cost == instr + 15 * (d_miss + i_miss) +``` + +> 第 9 条最容易漏:**改了 `kernels/` 里的代码,不会自动更新你手上那份 `.s`。** +> 每次改框架都要重新生成三题产物并逐点回归——否则清单上的对勾只对框架成立, +> 对你要交的文件不成立。 + +### 8.2 框架侧自查(改生成器时逐条对) - [ ] `grep -rnE '\b(flw|fsw|lw|sw|fadd\.s|fmul\.s)\b' scratchv/backend/kernels/bodies/` 无输出 - [ ] 寄存器容量从 `TargetDesc` 取,没有写死的数字 - [ ] `unroll` 这类实测量,换 target 后重新量过 - [ ] 现有全部题的产物**逐字节不变**(除非你故意改了那一道) +- [ ] 改过框架代码 → **三题全部重新生成**并逐点回归 - [ ] 三题全过 --- @@ -2139,6 +2518,10 @@ N=64 时三个矩阵 48KB,**装不下 32KB 的一级缓存**,于是反复去 | **cost** | `指令数 + 15 × 未命中总数`。平台衡量你贵不贵的唯一标准 | | **容差比对** | 浮点结果不要求逐位相同,只要落在 `rtol`/`atol` 范围内 | | **Q16.16** | 定点格式:用 int32 表示"真实值 × 65536",高 16 位整数部分、低 16 位小数部分 | +| **FMA(`fmadd.s`)** | 一条指令完成"乘 + 加":rd = rs1×rs2 + rs3。浮点题的"1 条换 2 条"(§5.4),**定点题没有** | +| **下限**(即理论下限) | 指令集与数据规模决定的最小代价。分三种:指令条数下限 / 强制未命中下限 / 行数下限(即 `i_miss` 的下限,§4.0) | +| **相位** | 数组起点 `% 64` 的值。它决定这个数组跨几行、`d_miss` 为什么会多 1(§6.4) | +| **`R_RISCV_ALIGN`** | 对齐重定位。链接器会为它插入 2 字节填充 ⇒ 违反"必须 4 字节"的红线(§0.4) | ---