Featured image of post 同一个 if,有人写 2 个操作,有人写 15 个:差别不在算法,在你顺手写下的那行声明

同一个 if,有人写 2 个操作,有人写 15 个:差别不在算法,在你顺手写下的那行声明

Handwired 被说成把 JS 变成神经网络,可它的文档第一句就是没有任何工具能把现有 JS 源码转成程序。这篇把它当成一份可清点的数值契约来读:同一个比较为什么有时 2 个操作、有时 15 个,为什么把循环改写成树在 CPU 上常常更慢,以及这个作者自己在生产里退回了什么。

你写完一段 JavaScript 判断,跑得挺好。有人跟你说,把它换成神经网络,结果逐位一样,还能求导。

你点开仓库,标题写着 Turn your regular JavaScript code into a neural net。

往下滚两屏,文档第一句是:No tool converts existing JavaScript source into a program。

没有工具能把现有 JS 源码转成程序。它不做翻译,你得自己用它的原语,把那段判断手写一遍。

真动手之后最疼的那件事在这儿:同一个 if,为什么有时候只花 2 个操作,有时候要花 15 个,而且它不报错,只是变慢。

同一个比较,成本差 7 倍,而结果一位不差

先把最容易误解的地方钉住。它不编译你的代码,它给你四个原语:affine 做加权和,reglu 做 left×max(0,gate),boolean 把分数舍成 0/1,indexRead 读表。

你用普通 JS 调这些原语,跑一次,生成一个 Circuit。执行的时候,就是读一个 Float32Array,算加乘和 ReLU。

一个 (a > 4) ? 10 : 12 进去,出来是 max(0,a-4) - max(0,a-5),两个分支都算,再拿一个 bit 选。

选中那条分支本身也要钱。条件在 0..1 之间时,emitSelect 每个数 4 个操作、3 层。

真正的账在那个 bit 上。同一个 a >= b,emitCompare 会按你给的声明自动挑实现,挑到哪条,代价差一大截。

表里最便宜的一行是整数 bit:两个操作数都声明在同一个 grid 上,每个部分和不超过 2^24 个 grid 步长,>= 就是 2 个操作、2 层。=== 3 个操作。

没有 grid,退到符号级联。它把差值拆符号位,逐级乘 2 的幂再 clamp,直到任何非零差值(低到 2^-149)都被抬到至少 1。0 到 16,384 这个范围里,>= 是 15 个操作、14 层,=== 28 个。

同一个 FP32 比较的三条实现路径与代价。整数 bit 是 2 个操作 2 层,符号级联要 15 个操作 14 层,boolean atom 只要 1 个操作但依赖保留 FP32 求值。数据来自 docs/expressions.md 与 docs/execution-model.md 的成本表。

还有一条更便宜的:boolean atom。(x + 8,388,609) - 8,388,609,一个操作就把 0..1 的分数在 0.5 处舍成 0/1。

但它只在执行器保留 FP32 存储求值时成立。哪天编译器把这个加常数再减常数化简回 x,bit 就错了。

两条路返回的位完全一样。所以这类问题不看结果,看清单——同一个 if 花 2 个操作还是 15 个,取决于你在 assumeGrid 里声明了什么。

建完之后调 circuit.roundingPoints(),按 kind 数一遍 'integer bit' 和 'sign cascade' 各有几条,才知道自己站在哪一边。

作者自己在 HandwiredOS 的窗口命中测试里踩过这个。每个窗口四条边,声明都在 grid 上时每条边 2 个操作。

坐标范围一放大到 ±65,536 px,标题栏右边缘的距离就超过 2^24 个 grid 步长。那一条自动变成符号级联,每个窗口多 13 个操作。geometry 完全不给 grid 时,每条边多 13 到 14 个。

而 dock 项的宽度是除以 3 得来的,落在任何 grid 之外,只能改用 boolean atom。

到这里就能看出这套东西卖的到底是什么了。它卖的是结果和你那份 JS 引用实现逐位相同,不是省下来的开发时间。为了这个,它把每一处浮点判定都摊开来给你数。

声明是你签的字,它不会替你验

那次命中测试里最值得抄走的不是数字,是那句边界:assumeGrid、assumeRange 是调用方的承诺,不是证明。

builder 信你。你写了个假 grid,整数 bit 就变回小数,而且程序一句警告都不给。

它连梯度都按你说的话走。整数 bit 只在差值落在 -1 到 1 之间时给梯度,也就是 -1/grid 这么大。文档的原话是,这种情况只在你那条 grid 声明是假的时候才会发生。

所以这条链上有三道各自的闸,缺一道都不行。

  • 声明阶段:assumeGrid / assumeRange 写下去。
  • 构建阶段:validateGrids、validateIndexReads 走一遍。
  • 修完之后:roundingPoints() 数 kind。

第二道闸尤其别跳。README 里有个特别好用的反例。

一段从 circuit.constant(0) 开始累加的写法,因为常量 0 不强加 grid,把它拿去比较就会吃到 15 个操作的符号级联。把累加改从第一项起,同样的比较变回 2 个操作的整数 bit。

代码看着几乎一样,成本差七倍。从常量 0 起头这个习惯,很多人生来就有。

这套设计里最值钱的不是那四个原语,是这三步连在一起:声明,量代价,再用验证函数兜住声明。缺了最后一步,前面写的东西就是一份没人签字的合同。

把循环改成树以后,CPU 上反而更慢

现在说那个最反直觉的地方。

神经网络没法提前退出。一个 findIndex 在普通 JS 里找到第一个命中就 break,在这里必须一次算完全部候选。

教科书给的解法是把循环改成树,log n 层,看起来漂亮。作者把这套改写试了一遍。结论是看情况,而且常常跟直觉相反。

他给的例子是文本流里的行底判定,条件沿项目单调翻转。计数法 3n+3 个操作、3 层。64 行时是 195 个操作。用 emitFirstActive 建树,447 个操作、34 层。

单调的时候,计数版更快。

换成任意分布,比如重叠窗口的命中,结论倒过来:树 316 个操作、31 层,链 127 个操作、127 层。作者在 patterns.md 的开头把这条讲得比数字更直接:CPU 上树经常比循环慢,所以下面好几个配方都写着「保留这个循环」。

为什么?因为 CPU 执行器计费跟操作数走,不跟层数走。

层本身也有钱,大约每 1,000 层 10 µs,花在拷贝每层读的值和写的结果上。

同样 16,384 个二项和,64 层 55 µs,1,024 层 65 µs,16,384 层 210 µs。这是 Bun 1.4.3 在 M5 Max 上的数,跑的是 bun lab/runtime-cost/layers.ts。

「减了操作、加了层」这个交换,只有在执行器按层计价的地方才划算。

CPU 上不是。所以 Knuth-Plass 那个最优断行程序,树形比链形慢 1.7 到 7.7 倍。作者自己在结论里也是这句:CPU 上树经常比循环慢,所以好几个配方都写着「保留这个循环」。你换掉的是操作数,加的是层费,而执行器只按操作数收钱。

顺带一个藏得更深的坑。emitArgmax 在近乎平局时有个 8.9e-8 的宽度带,树可能选中的候选和循环不同。需要精确的时候换 emitHighestScoreExact。

作者自己退回过:文本布局搬出程序,省了 4.5 倍

一个项目最该看的地方,是作者自己往回走了哪几步。

Notes 一开始把文本布局整个放进程序里算。

那版是 20,381 个活操作,一次求值 133 µs。

后来他换成浏览器原生的 <textarea>,程序掉到 4,251 个活操作,一次求值 25.1 到 25.3 µs,快了大约 4.5 倍。页面加载那个长任务从 114 到 117 ms 降到 57 ms。

还有一次 GPU 整体化的尝试。HandwiredOS 搬到 WebGPU,一次求值 2.2 ms,CPU 上是 81 µs,慢了 27 倍。因为在一张表里,shader 的语句是一条接一条顺序跑的。

那个 6.4 MB 的 shader 在 Metal 里 28 分钟没编译完。

容量也是写死的。Photos 为了省大约 300 个操作,用 2 个 spring 代替 12 个。代价是半秒内「关→开另一张→再关」时,第一张会跳回格子。

Text rows 硬上限定在 16,384 行。超限时的行为要你自己显式给:snap、carry、报告、丢弃,四选一。不给兜底,超限就是算错,而且没有输出提示。

我这些数字一个都没有复现。这台机器没有 M5 Max,也没有 Bun 1.4.3,全部引自仓库的 lab/status.md。

作者自己也写明,这些微秒值随 load average 变。同一份代码在 19 个标签页和 4 个标签页下会进不同的「快模式」,只能做相对比较。

把这些放一起,判据其实很清楚。

该搬进去的,是离散决策密集、每个数都能声明清楚范围的那类逻辑:命中测试、可见性标志、几个数之间的门槛判断、几百个候选里的选择。**判据只有一条:这个数你能不能对调用方说出它的 grid。**说不出来,搬进去就是给自己埋一条不报错的减速带。

别搬的是另外三类。每个框架都要算出整块布局的活,比如文本排版。单张大表要上 GPU 的活,因为它的语句会一条接一条顺序跑。输入可能带 Infinity 还能落进未选分支的活,因为 0 × Infinity 是 NaN。

还有一条最容易被忽略的:它的精确是「对 FP32 操作数精确」,不是对 float64 的 JS 精确。

宽度除以 244,再乘 1,732 减 24,FP32 的倒数先舍入了一步,得到 6.9999995。比 7 小一格。于是 columns >= 7 在它这里算出 0,在你那段 float64 的 JS 里算出 1。

这种差不是 bug。是两套数在打架。

先花二十分钟,数一遍你那段逻辑里的符号级联

真要用,第一个动作不是搬业务逻辑,是在仓库里跑一遍成本报告,看你的程序站在哪一边。

1
2
3
bun lab/runtime-cost/report.ts      # 单次求值、帧、构建各花多少
bun lab/runtime-cost/layers.ts      # 层数本身的钱
bun test tests/runtime              # 先把原语行为对齐

然后挑你代码里最热的那条判断,按这个形状写一遍:

1
2
3
4
5
// JS: const wide = width > 4 ? 10 : 12
// 两个分支都算,靠 1 个 bit 选
const c = circuit.assumeGrid(width, 1 / 64);            // 声明:承诺 width 落在 1/64 的格子上
const bit = emitCompare(circuit, width, '>', circuit.constant(4));
const wide = emitSelect(circuit, bit, circuit.constant(12), circuit.constant(10));

写完数一眼:

1
2
3
const kinds = {};
for (const p of circuit.roundingPoints()) kinds[p.kind] = (kinds[p.kind] || 0) + 1;
console.log(kinds);   // 期望看到 integer bit;满屏 sign cascade 说明有声明没落到 grid 上

排查的顺序也顺手记一下。

结果对但是慢了五六倍,先数 sign cascade,八成是某处 assumeGrid 没写或者写错了,尤其别从 circuit.constant(0) 起头累加。

平局附近和 JS 结果对不上,查 emitArgmax 那段 8.9e-8 的宽度带。搬到 GPU 上结果变了,查 boolean atom 是不是被 shader 化简掉了。

至于要不要现在接进生产,我的看法是别急。

这是一个单作者、825 次提交、9 个 open issue、最后推送停在 10 月 17 日的项目。作者把它当研究在做,status.md 里记着大半年的回滚和推翻。它是这个方向目前最好的一份工程记录,但适合读,不适合押。

真正值得现在拿走的也不是代码。

是那三个动作本身:把一个判定写成可以清点的形状,量出它到底花几个操作,再用一个验证函数把这件事从「我觉得我声明对了」变成「我验过」。

这套动作不依赖 Handwired。你手上任何一个把浮点判断摊开写的地方,都能照这个顺序做一遍:先写下你对这个数范围的承诺,再数它实际花了几个操作,最后用测试把承诺焊死。

三步里最容易被跳过的是第三步,而被跳过的也正是唯一能兜住前两步的那一步。

你现在那段逻辑里,有几条热判断是别人给你的 grid 声明在做主,而从来没被数过?