Handwired:把普通 JavaScript 代码「接线」成神经网络——免训练、精确、完全可微

如果一个网页应用不需要 if-else、不需要循环、也不需要提前训练模型,而是把「判断」和「分支」全部编译成一堆矩阵乘法,会发生什么?Handwired 就是拿来回答这个问题的项目:它把普通的 JavaScript 逻辑「接线」(hand-wire)成一张神经网络,然后同时跑在 JS 和 WebGPU 上。

它的 README 只有一句话:Turn your regular JavaScript code into a neural net; no training, exact, yet also fully differentiable!(把你平常写的 JavaScript 变成神经网络;不用训练、结果精确、还完全可微。)

项目地址:github.com/chenglou/handwired | 在线 Demo:chenglou.me/handwired

这是什么

Handwired 是由 chenglou(React 早期核心贡献者、reason-react 与 BS 工具链作者)开源的一个实验性运行时,MIT 协议,TypeScript 编写,2026 年 10 月 15 日建仓,几天内就拿到了 1500+ Star。

它想做的事很反直觉:不要训练。作者把程序本身写成一个「固定长度的算术操作列表」——只有加权求和(weighted sum)和 ReLU 门控(reglu)两类原语,全部作用在 FP32 浮点上。你写的普通 JavaScript 不是被当成代码执行,而是被当作构造代码(builder code)跑一次,把每一步运算「追加」进一个叫 Circuit 的电路里。等构造结束,这个程序就是一个巨大的矩阵乘法,权重是作者手写的,不是学出来的。

换句话说:普通 JavaScript 决定「算什么」,神经网络负责「怎么算」。

它到底解决什么问题

表面看这像个炫技玩具,但背后有三类真实的工程动机:

  • GPU 级并行,不用改写法。普通 JS 是单线程、顺序执行的。一旦编译成矩阵乘法,「对 10 万个文档跑同一段逻辑」就变成一次 matmul,天然吃满 GPU。项目里的 JSON 搜索 Demo 每个按键都要重新搜索 100,000 条 JSON 文档,在 Apple M5 Max + Chrome 上单次 11~15 ms 就能出结果。
  • 可微性免费赠送。整套程序只由 matmul + ReLU 构成,所以整段程序对输入天然可导。你可以「解冻」权重去训练它,看看会变成什么;也可以把一个写死的程序接在通用神经网络后面做端到端训练(作者的猜想:nanoGPT 能不能既写莎士比亚、又懂你这个特定 UI?)。
  • 结果精确。因为权重是手写的,不是拟合出来的,所以判定(比较、分支)可以被构造成精确的 0/1 位,与 JavaScript 原生比较在全部声明值域上逐位一致——不是「近似对」,是「真的对」。

外加一个很实际的好处:既然程序只是几个最小原语,那么算子融合、稀疏化、重排这些传统 GPU 优化手法就有极大的发挥空间。

怎么做到的:三大技术支柱

1. 把 if-else 变成 ReLU 算术

判断一个数是否大于 4,本来要用比较运算符。Handwired 用两个 ReLU 之差来构造这个「位」:

const a = 5
// Math.max(0, x) 就是 ReLU。对于整数 a,above4 在 a > 4 时为 1,否则为 0
const above4 = Math.max(0, a - 4) - Math.max(0, a - 5)
// 两个分支都算出来,再由 above4 选一个
const result = above4 * 10 + (1 - above4) * 12

这就是整座大厦的地基:所有分支都变成「两边都算 + 加权选择」,所有条件都变成精确的 0/1 位。手算一遍就能验证两条路径结果完全一致。

2. 源表 + 构造代码:没有运行期分支和循环

程序运行时读一个 Float32Array,叫源表(source table),表里每个位置是一个单元(cell)。所有状态、输入、事件位都声明在表里,围绕每次求值(evaluation)定义生命周期:

声明一次求值前后举例
constant(value, range)初始写入一次,之后保持不变12px 的边距
input(range, {initial, grid})求值前由 JS 写入,并保留视口宽度
inputBit(initial)精确为 0 或 1 的输入settleCheck
event()事件到达时由 JS 置 1,程序求值后清 0一次指针按下
state(range, {initial, grid})由程序自己算出滑块的速度
stateBit(initial)精确为 0 或 1 的状态开关是否打开

注意 range 和 grid 不是注释,而是契约:每次求值都会校验,越界即抛错;grid 则是「值一定是 2 的幂的倍数」的承诺,直接决定一次比较要花多少运算。

而构造代码里没有循环和分支——只有一行行追加运算。比如实现「全部位都为 1」和「取反」:

// JS: trackLeft = viewportWidth / 2 - trackWidth / 2
const trackLeft = circuit.affine([[cells.viewportWidth, 0.5]], -layout.trackWidth / 2)

// JS: bits.every(bit => bit === 1),等价于 relu(所有位之和 - (个数 - 1))
const all = (...bits) => circuit.reglu([], 1, bits.map(bit => [bit, 1]), 1 - bits.length)

// JS: !bit
const not = bit => circuit.affine([[bit, -1]], 1)

// JS: if (down) armed = insideTrack(down)
const armedAfterDown = emitSelect(circuit, cells.down.active, cells.armed, insideTrack(cells.down))

3. 不能循环,那动画怎么办?

这是我觉得最漂亮的一手。程序不能在运行期循环「未知次数」——但弹簧动画的步数取决于距上次求值过了多少毫秒,这正是未知次数。

解法:一次 4ms 的弹簧步进是「一个 2×2 矩阵作用在(位移, 速度)上」,n 步就是该矩阵的 n 次幂。于是宿主(host,即包在程序外面的那些 JS)负责数步数,把矩阵的 n 次幂的 4 个数字写进 springMatrix 单元,程序只需要作用一次。「循环」就这样变成了「一次查表 + 一次乘法」。

更进一步:只有在应用事件、结算检查(settle check)、snap 这几类求值时才写回状态,普通帧的求值跑在副本上。这意味着存储的状态只取决于事件,而与帧率无关——同一份拖拽轨迹,在 60Hz 和 144Hz 屏幕上得到的状态序列是同一份。

三个 Demo:它到底能跑成什么样

Mini OS(HandwiredOS):一整个桌面系统 = 一个程序

一个含三个窗口(显示器 / 便签 / 相册)和 Dock 的迷你桌面,由同一个编译好的程序驱动:焦点、层叠、拖拽、缩放、三个窗口控制按钮、关闭到 Dock、从 Dock 重新打开、全部弹簧动画、相册布局,以及「显示器」窗口里那份对程序自身单元/运算/输出的实时列表。

它的规模是:

指标数值
源单元(source cells)234
活跃运算(live operations)4,646
层(layers)130
存储权重(stored weights)9,909
测试8 个文件 127 项,约 9 秒

其中有个细节很能说明设计取向:窗口内容区在事件改变目标尺寸时立刻就按目标尺寸布局,只有边框在动画,所以内容每次目标变化只布局一次,而不是每个中间尺寸都排一遍。

JSON Search:每个按键搜索 10 万个文档

页面上生成 100,000 条约 300 字符的合法 JSON 文档(package manifest、用户记录、日志事件、订单、地图要素、传感器读数……),每次按键都重新全量搜索一遍。关键点:一份文档跑一份 GPU 代码是浪费的(一次往返就要 0.6ms),但一份文档不读别的文档的值,所以「一万份文档」可以塞进一次 pass。

更狠的是:GPU 代码是在任何 pattern 存在之前就写好的。搜索程序按「最多 8 / 16 / 32 个状态」准备了三个尺寸,页面加载时就把三份 shader 全部编译好——所以每次按键都零编译等待,也没有任何 pattern 会被编进 GPU 代码里。

对比项时间
本项目(GPU),单次搜索11 ~ 15 ms
原生 RegExp,普通 pattern8 ~ 28 ms(如 \d\d\d\d-\d\d-\d\d 用 9ms,error 用 21ms)
原生 RegExp,病态回溯 ([a-z]+)+x2.1 秒
本项目(CPU 回退),最小程序13 秒

页面很诚实地同时展示两边的时间——谁也不是永远更快。这点我挺欣赏的。

Learned Gallery:用程序自己的梯度训练出来的布局

第三个 Demo 是一个照片瀑布流布局,计算它的那个小网络就嵌在程序内部,而它的系数是用程序自己的梯度训练出来的——从「免训练」到「可训练」这一步,正是前面那条可微性带来的。

怎么用

先装 Bun(仓库在 Bun 1.4.3 上测试过),然后:

git clone https://github.com/chenglou/handwired.git
cd handwired
bun install

bun start          # Demo 站点:http://127.0.0.1:3000/
                   #   /         落地页
                   #   /os/      Mini OS
                   #   /learned-gallery/
                   #   /json-search/
bun run start:template   # 模板 App(一个开关),http://localhost:3000/
bun test                 # 全部测试,约 3 分钟
bun test tests/runtime   # 只跑 runtime,约 2 秒
bun run check            # 类型检查 + lint,约 3 秒
bun run site:build       # 构建静态站点到 site/

如果不想本地跑,直接开在线 Demo 也能玩:chenglou.me/handwired。

想自己写一个 App,从 template/ 开始——一个「松手才生效」的开关(和原生开关一样:按下 arm,在轨道上松手才翻转,滑块用弹簧弹过去),并且配有一份纯 JavaScript 写的参照实现,逐帧对拍。文档阅读顺序:

  1. tutorial.md — 走一遍模板:声明单元、构造代码、宿主循环、DOM 写入、测试。
  2. expressions.md — 单个表达式(如 a >= b、table[index]),难点在于 FP32 下结果精确。
  3. patterns.md — 循环(findIndex、items.sort)怎么变成计数、树、表或「对所有元素做同一份工作」。
  4. behavior-choices.md — 会影响用户体验的取舍,比如「动画中点击命中的是元素要去的位置,不是它画在屏幕上的位置」。
  5. execution-model.md — 参考手册,四类指令、各自的舍入方式、每个构造函数的契约。

值得注意的工程细节

  • 类型系统挡住一类错。Wire 是「带类型的数字」,所以 emitCompare(circuit, cells.down.x, '>=', 614) 会被类型检查拒绝,必须写成 circuit.constant(614)。
  • 坐标网格即性能。指针坐标声明在 1/64 px 网格上、窗口几何也在 1/64 px 网格上,于是每条边比较都退化成一次「精确整数位」,只要 2 次运算;没有 grid 时要 15 次。两种写法返回同样的位,差别只体现在程序体积上。
  • 测试用「种子随机程序」对拍。tests/runtime/ 把随机生成的程序同时跑在朴素参照求值器和编译版求值器上比结果。
  • 副作用写得很克制。Notes 窗口里就是一个原生 <textarea>:编辑、选词、换行、滚动都交给浏览器,程序只计算「文字周围那个窗口」的矩形和弹簧,一个字符都不持有。

小结

Handwired 不是一个能直接上生产的框架——它更像是一个精心构造的思想实验,而且实验做得很扎实:有 127 项测试的桌面系统、有 184 项测试的 GPU 搜索、有和原生 RegExp 的诚实对比、有和纯 JS 参照实现的逐帧对拍。

它真正有意思的地方在于重新提问:如果一个用户界面程序天然就是一张可微的矩阵乘法图,那么「写代码」和「训练模型」的边界到底在哪?作者没有给答案,但把这扇门推开了一条缝,还顺手把 GPU 并行的收益装了进去。

项目地址:github.com/chenglou/handwired | 在线 Demo:chenglou.me/handwired | 教程:docs/tutorial.md

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享