2026-08-26
柏林 Zuse 所与魁北克大学给出一套 SIMD 算法,把不成对的 UTF-16 代理就地换成 U+FFFD。Apple M4 上达到 18.9 GB/s,大约是 V8 原标量路径的 9 倍,Ice Lake 上 7.5 GB/s。该内核已进入 Chromium 系浏览器和 Node.js 25 的 toWellFormed。
JavaScript、Java、Windows 内部字符串都是 UTF-16。基本多文种平面(BMP, U+0000–U+FFFF)里的字符一个 16 位码元就能装下;平面以外的 emoji、生僻字要拆成一对 surrogate:高代理 U+D800–U+DBFF 后面必须紧跟低代理 U+DC00–U+DFFF。对不上的孤立代理,就是畸形 UTF-16。
畸形串来自截断、错误转码、恶意输入。CPython 3.1 到 3.3 的 UTF-16 解码器碰到孤立代理后没更新缓冲区边界,读出了内存,登记为 CVE-2012-2135。标准补救是把不成对的代理换成替换符 U+FFFD。ECMAScript 为此加了 String.prototype.toWellFormed。V8 原来走标量循环,一次看一个码元。大串上这就是实打实的带宽税。
Zuse Institute Berlin 的 Clausecker 与 TELUQ 的 Lemire 把这段循环改成 SIMD,一次处理至少 8 个 16 位码元。难点在 surrogate 对会骑在向量边界上。
每轮加载两块错开一个码元的向量。较早那块叫 lookback,只查高代理;较晚那块叫 block,只查低代理。两块做逐元素 XOR:全零说明当前块配对正确,直接拷走,就地模式连写都可以省。XOR 非零才进慢路径,分别标出「高代理后面不是低代理」和「低代理前面不是高代理」,用 blend 换成 U+FFFD。
三个选择撑着快路径。用两块重叠向量,不在一块里移位,迭代之间没有循环携带依赖,乱序执行可以叠多轮。每轮两次 load,不从上轮切片,因为多数 CPU 每周期能退好几条 SIMD load,算术指令更多,operational intensity(算术逻辑指令相对 load/store 的比例)够高。路径带分支:绝大多数 UTF-16 是合法的,先做廉价的「有没有错」探测,错了再定位。
短于一个向量加一个元素的输入退回标量。开头的孤立低代理、末尾的孤立高代理也要标量补一刀。算法幂等,尾巴用一次对齐到末尾的重叠迭代收掉。
x64 上 SSE2 与 AVX2 按通用流程走。AVX-512 换成 mask 寄存器,一次 32 个码元(64 字节),非法位置用 masked store 直接写。ARM 没做 SVE,装机量太少,走 NEON。NEON 没有 pmovmskb 那种把比较结果收成 bitmask 的指令,查「向量是否全零」要用 vmaxvq 再搬到通用寄存器,延迟高。对策是一次处理 4 个 16 码元块,拼成 64 个码元再检查。另外用 LD2 把 UTF-16 拆成高低字节,只留高字节就能判断是不是 surrogate,等于把元素宽度从 16 位收到 8 位。
C++ 实现进了开源库 simdutf,对照基线是 V8 当时的标量实现。输入最多 100 万码元,合法 surrogate 对固定 0.1%,非法代理取 0% 或 0.1%。每组 100 次,取最好时间,误差大约 1%。
| 平台 | 实现 | 0% 非法 (GB/s) | 0.1% 非法 (GB/s) | 指令/字节 |
| Apple M4 | V8 标量 | 2.2 | 2.2 | 12.0 |
| Apple M4 | NEON | 18.9 | 16.3 | 0.9 |
| Xeon Gold 6338 | V8 标量 | 1.2 | 1.2 | 13.0 |
| 同上 Ice Lake AVX-512 | simdutf | 7.5 | 7.4 | 0.4 |
| 同上 Haswell AVX2 | simdutf | 7.8 | 7.6 | 0.8 |
| 同上 Westmere SSE | simdutf | 5.8 | 5.6 | 2.0 |
M4 上 NEON 路径大约是 V8 标量的 9 倍,指令密度从每字节 12 条掉到 0.9。Ice Lake 的 AVX-512 内核更省指令,每字节 0.4 条,吞吐 7.5 GB/s,却略低于同机 Haswell AVX2 内核的 7.8 GB/s。正文把 Ice Lake 写成「最高吞吐」,表格对不上。
函数已经进 Node.js 25。toWellFormed 用 1024 字符随机串(5% 低代理、5% 高代理、其余 ASCII)测:
| 运行时 | Apple M4 | Xeon Gold 6338 |
| Node.js 24.13.0 | 2.9 GiB/s | 2.0 GiB/s |
| Node.js 25.5.0 | 16 GiB/s | 11 GiB/s |
大约 5 倍。JS 层开销吃掉一部分 C++ 端的 9 倍。同一条串在 Apple M4 上,Chrome 144 到 16 GiB/s,Firefox 147 是 2.7,Safari 18.6 是 1.0。Chromium 系已经吃进这条路径。
Unicode 对 U+FFFD 替换没有新语义。贡献在工程:把浏览器和 Node 每天都在跑的 toWellFormed 从标量循环换成接近内存带宽的向量核,而且就地、无分配、可 copy。
要修畸形 UTF-16,直接用 simdutf。V8、Chromium、Node 25 已经接上,业务代码调用 str.toWellFormed() 就会走到这条路径。Firefox 和 Safari 还停在标量量级;同一条 1024 字符串,Chrome 144 在 M4 上是 Firefox 147 的约 6 倍、Safari 18.6 的 16 倍。
对做 tokenizer、JSON、日志管道的人,这篇更接近一份可抄的 SIMD 设计笔记:重叠 lookback、快路径探测、NEON 上用 LD2 丢掉低字节。应用面窄,落地已经完成。同一作者组此前做过 UTF-8 校验和 UTF 转码,这条线是增量,部署面不是。
论文没有单独的局限节。能看到的边界都写在方法里:短串退标量;首尾孤立代理要手工处理;故意做成有分支,合法输入才是快路径。M4 上 0.1% 非法就把吞吐从 18.9 GB/s 打到 16.3,非法比例再高会更差,论文没测。
评测输入是合成随机串,不是 JS 堆里的真实字符串分布,也没有跟 ICU 或其他 SIMD 库对照。SVE 直接放弃。simdutf 还支持 RISC-V、LoongArch,论文只报了 M4 和一台 2019 年的 Ice Lake Xeon。
结论写「最高八倍」,实验叙述写「近 9 倍」,M4 的 18.9/2.2 约 8.6 倍。Ice Lake「最高吞吐」和表格里 Haswell 7.8 > 7.5 对不上,引用吞吐时以表格为准。