开了 simd,ARM 上什么都没变
主线位置:SIMD 只出现在两处,块主线第一遍里找特殊字节的
iterate_special_bytes(一行一行),和输出主线里的escape_html/escape_html_body_text(转义)。两处的实现和 Rosetta 下的测量在那两页和设计取舍里,本页只讲「开了 feature 却没生效」怎么发生、怎么查。
flowchart TB
f["features simd"] --> a{"x86_64"}
a -->|否| sc["两处都是标量"]
a -->|是| h{"开了 html"}
h -->|否| fp["只有第一遍 SIMD"]
h -->|是| both["第一遍和转义"]
both --> rt{"运行时 SSSE3"}
fp --> rt
rt -->|否或不足16字节| sc2["退回标量"]
rt -->|是| simd["16 字节一组"]
实验 crate:exp-pulldown-cmark/pitb-simd(默认 feature,自带 simd = ["pulldown-cmark/simd"])、pitb-readme(照 README:default-features = false, features = ["simd"])、pitb-simdfix 和 pitb-simdtarget(两种修法)。M2 Max、macOS 27、rustc 1.99.0,release;x86_64 用 --target x86_64-apple-darwin 编,在 Rosetta 下运行。脚本是 pitb-simd/check.sh,输出在 pitb-simd/out.txt。
现象
Cargo.toml 里给 pulldown-cmark 加上 features = ["simd"],在 Apple Silicon 上 cargo build --release。没有警告,没有报错,程序照常跑。看不出它生效了没有。
把同一个程序(解析一段带 "、&、<b> 的文字再 push_html)编六份,数二进制里留下来的 simd 模块函数,再比较输出:
arm-nosimd __text=380220 simd fns: (none)
arm-simd __text=380156 simd fns: (none)
x86-nosimd __text=429680 simd fns: (none)
x86-simd __text=440144 simd fns: pulldown_cmark_escape::simd::compute_mask pulldown_cmark_escape::simd::escape_html pulldown_cmark::firstpass::simd::process_mask
arm-readme __text=358140 simd fns: (none)
x86-readme __text=414240 simd fns: pulldown_cmark::firstpass::simd::process_mask
arm-nosimd output md5 cdada3adadc572936d9ceb433c7c06a0
arm-simd output md5 cdada3adadc572936d9ceb433c7c06a0
x86-nosimd output md5 cdada3adadc572936d9ceb433c7c06a0
x86-simd output md5 cdada3adadc572936d9ceb433c7c06a0
- aarch64:开不开一样。 两份都没有任何
simd模块的符号,代码段只差 64 字节。 - x86_64:开了才有。 多出约 10 KB 代码,第一遍和转义两边的 SIMD 函数都在(列出的是没被内联、留下了符号的几个)。
- 照 README 写(
*-readme):x86_64 上只剩第一遍那一半。pulldown_cmark_escape::simd不见了。 - 四份的输出完全相同。SIMD 只换扫描方式,不换结果。
原因
一、simd 的每一处 cfg 都同时要求架构。 firstpass.rs 里 create_lut、iterate_special_bytes 和 mod simd 都是同一个条件:
#[cfg(all(target_arch = "x86_64", feature = "simd"))]
{
simd::iterate_special_bytes(lut, bytes, ix, callback)
}
#[cfg(not(all(target_arch = "x86_64", feature = "simd")))]
{
scalar_iterate_special_bytes(lut, bytes, ix, callback)
}
escape crate 的 escape_html、escape_html_body_text 和它的 mod simd 也一样。在 aarch64 上条件为假,编译器看到的就是标量版本。没有 compile_error!,也没有 NEON 版本,所以不报错、也不加速。
feature 在非 x86_64 上唯一改变的是 lib.rs 第 77 行:
#![cfg_attr(not(feature = "simd"), forbid(unsafe_code))]
这个条件只看 feature、不看架构。aarch64 上开了 simd,crate 就不再 forbid(unsafe_code),虽然此时整个 crate 里一行 unsafe 也没编进去。
二、simd 传给 escape crate 用的是 ?。 Cargo.toml 的写法是:
[features]
default = ["getopts", "html"]
simd = ["pulldown-cmark-escape?/simd"]
html = ["pulldown-cmark-escape"]
? 的意思是「只有这个可选依赖已经被别的 feature 拉进来,才把 simd 传给它」。README「Build options」一节给的命令是 cargo add pulldown-cmark --no-default-features --features=simd,--no-default-features 把 html 也关了(下一页关掉默认 feature 后 html 没了),escape crate 根本不在依赖图里,simd 只剩第一遍那一半。cargo tree 看得很清楚:
pitb-readme v0.1.0 (~/exp-pulldown-cmark/pitb-readme) []
└── pulldown-cmark v0.13.5 [simd]
├── bitflags v2.13.2 []
├── memchr v2.8.3 [alloc,default,std]
└── unicase v2.10.0 []
这个程序也不能调 push_html,它只能数事件。README 那一节同一段的原话是「SIMD accelerated scanners are available for the x64 platform from version 0.5 onwards」,平台是写了的;关掉 html 这件事没写。
三、x86_64 上还有运行时条件。 编进去之后,每次调用还要过 is_x86_feature_detected!("ssse3") 和「至少 16 字节」两道检查,不满足就退回标量(转义)。在 Rosetta 下测,长串少特殊字符时快,整条流水线没有变快,引号多的正文还慢了 4 倍;这些是 Rosetta 上的相对值,见那一页和设计取舍。
forbid(unsafe_code) 最初的写法,一开 simd 就编不过5edabdf「Deny unsafe code unless the SIMD feature is enabled」(2020-09-23,直接提交到主分支,没有 PR)给 src/lib.rs 加的是两行:先 #![forbid(unsafe_code)],再 #![cfg_attr(feature = "simd", allow(unsafe_code))]。forbid 的意思就是后面不许再放宽,照搬这两行到一个空 crate,--features simd 时今天的 rustc 报:
error[E0453]: allow(unsafe_code) incompatible with previous forbid
--> src/lib.rs:3:37
|
2 | #![forbid(unsafe_code)]
| ----------- `forbid` level set here
3 | #![cfg_attr(feature = "simd", allow(unsafe_code))]
| ^^^^^^^^^^^ overruled by previous forbid
十个多月后,4d5094b「Fix unsafe_code lint control logic to work with simd」(2021-08-10,同样没有 PR)把两行合成现在这一行 cfg_attr(not(feature = "simd"), forbid(unsafe_code))。两个提交都在 0.8.0(2020-09-01)之后、0.9.0(2021-12-22)之前,首次发布都在 0.9.0,坏掉的那一版没有发布过。修好的这一行只看 feature,于是留下了上面说的那点差别:ARM 上开 simd 什么也不加速,却解除了 forbid。
修法
ARM 上:不用开。 它不会变快。如果你在意 forbid(unsafe_code) 这层保证,开了反而少一层。
x86_64 上要 SIMD 又不要 getopts:把 html 写回来。
pulldown-cmark = { version = "0.13.5", default-features = false, features = ["html", "simd"] }
pitb-simdfix 的 cargo tree,escape crate 回来了,simd 也传了过去:
pitb-simdfix v0.1.0 (~/exp-pulldown-cmark/pitb-simdfix) []
└── pulldown-cmark v0.13.5 [html,pulldown-cmark-escape,simd]
├── bitflags v2.13.2 []
├── memchr v2.8.3 [alloc,default,std]
├── pulldown-cmark-escape v0.11.0 [simd]
└── unicase v2.10.0 []
它的 x86_64 二进制里 simd 模块的函数数是 3,和 x86-simd 一样。
同一份代码要在两种机器上编:按目标架构开。 Cargo 允许在 [target.'cfg(...)'.dependencies] 里给同一个依赖追加 feature(2021 edition 默认的 resolver 2 只在为该目标编译时合并它):
[dependencies]
pulldown-cmark = { version = "=0.13.5", default-features = false, features = ["html"] }
[target.'cfg(target_arch = "x86_64")'.dependencies]
pulldown-cmark = { version = "=0.13.5", default-features = false, features = ["simd"] }
pitb-simdtarget 分别对两个目标看 feature:
== aarch64-apple-darwin
pitb-simdtarget v0.1.0 (~/exp-pulldown-cmark/pitb-simdtarget) []
└── pulldown-cmark v0.13.5 [html,pulldown-cmark-escape]
...
== x86_64-apple-darwin
pitb-simdtarget v0.1.0 (~/exp-pulldown-cmark/pitb-simdtarget) []
└── pulldown-cmark v0.13.5 [html,pulldown-cmark-escape,simd]
...
├── pulldown-cmark-escape v0.11.0 [simd]
两个二进制里 simd 模块的函数数分别是 0 和 3。ARM 上保住了 forbid(unsafe_code),x86_64 上两处 SIMD 都在。
开不开、开了值不值,最终要在部署用的那种 x86_64 机器上用自己的文档量一次;本卷只有 Rosetta 的相对数。
怎么检查
- 看 feature 有没有真的传到:
cargo tree -e normal -f '{p} [{f}]' --target <三元组>。pulldown-cmark那一行要有simd,下面pulldown-cmark-escape那一行也要有simd;后者整行不在,说明html被关了。 - 看二进制:
nm <二进制> | grep 4simd(Rust 的 v0 符号里模块名前面带长度)。x86_64 开全了,能看到firstpass::simd和pulldown_cmark_escape::simd;什么都没有,就是没编进去。 - 看平台: aarch64(Apple Silicon、Graviton 等)上这个 feature 本来就不生效。CI 在 ARM 上、生产在 x86_64 上时,两边跑的是两套扫描代码,性能数字不能互相代替。
小结
simd的每一处cfg都是all(target_arch = "x86_64", feature = "simd")。ARM 上开了照样编过,二进制里没有 SIMD 函数,输出也一样;唯一的变化是 crate 不再forbid(unsafe_code)。simd用pulldown-cmark-escape?/simd传给转义 crate。README 的--no-default-features --features=simd关掉了html,转义那一半 SIMD 和push_html一起没了。- 要 SIMD 又不要
getopts,写default-features = false, features = ["html", "simd"];跨平台用[target.'cfg(target_arch = "x86_64")'.dependencies]只给 x86_64 开。 - 用
cargo tree -f '{p} [{f}]'看 feature,用nm | grep 4simd看二进制。