Rust in Everything
基于 pulldown-cmark 0.13.5

开了 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 上的相对值,见那一页和设计取舍。

📝NOTE
冷知识: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 看二进制。

💬 关联讨论

发起讨论
加载中...