不成对 UTF-16 代理的 SIMD 就地修复近 9 倍,已进入 Chromium

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 M4V8 标量2.22.212.0
Apple M4NEON18.916.30.9
Xeon Gold 6338V8 标量1.21.213.0
同上 Ice Lake AVX-512simdutf7.57.40.4
同上 Haswell AVX2simdutf7.87.60.8
同上 Westmere SSEsimdutf5.85.62.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 M4Xeon Gold 6338
Node.js 24.13.02.9 GiB/s2.0 GiB/s
Node.js 25.5.016 GiB/s11 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 对不上,引用吞吐时以表格为准。

术语

原文与代码

社区讨论

全部论文解读