本文是对 The virtue of unsynn的整理与翻译
内容结构概览
markdown
一、传言与真相:我真的想杀掉 serde 吗
- facet 项目引发的 Reddit 讨论
- 澄清:不是 serde,而是 syn
- "free of syn" 运动的由来
二、syn 有多慢:用数字说话
- M4 Pro 上的冷构建基准测试(facet vs syn@1 vs syn@2)
- 单线程 -j1 模拟 CI 环境:3x vs 12x,差距扩大到约 4 倍
- GitHub Actions 免费层实测数据
三、先搞清楚我们在比较什么:苹果 vs 核潜艇
- syn 是什么:解析完整 Rust AST 的库
- syn 能做什么:用一段代码解析自己
- Rust 的声明式宏(macro_rules!)能做什么,边界在哪里
四、声明式宏 vs proc macro:从代码到构建时间
- 用 print_fn_name 做对比示例
- 声明式宏的局限性(pub fn 就能让它崩溃)
- proc macro + syn 版本的实现
- 宏展开结果对比:两者完全相同
- 用 -Zunpretty=expanded 验证展开结果
五、构建时间差距有多大(微项目层面)
- 冷构建:decl 111ms vs syn 1524ms,差 13.71 倍
- 开启 opt-level=3 后:差距扩大到 36.76 倍
- 两种常见反驳:proc macro 本来就慢 / 谁关心冷构建
六、不用 syn 也能写 proc macro:纯手工版
- 直接操作 TokenStream 的完整实现代码
- 性能:manual 205ms,仍比 syn 快 6.8 倍
- 代价:人体工程学很差
七、unsynn:介于手工和 syn 之间的中间地带
- unsynn 是什么(名字来自德语 unsinn,意为胡说八道)
- keyword! 宏的用法与展开详情
- unsynn! 宏中的核心组合子:Many / Cons / Except
- 完整的 proc macro 实现代码
- ToTokens 实现为什么需要手写
- 为什么还需要 quote 和 proc_macro2(可测试性问题)
八、unsynn 的构建时间
- 冷构建四路对比:decl 166ms / manual 267ms / unsynn 718ms / syn 1574ms
- unsynn 比 syn 快 2.2 倍,比声明式宏慢 4.3 倍
九、热构建才是真正的战场
- 用一万一千行 AI 生成的"垃圾代码"填满函数体
- 热构建测试:decl≈197ms / manual≈204ms / unsynn≈208ms / syn≈304ms
- syn 的真正问题:每次编译都要重新解析完整 AST,而 proc macro 调用结果不会被缓存
- 100ms 被 syn 额外消耗,每次 cargo 认为有变化就要重付
十、宏观视角:syn 在真实项目里的影响
- beardist 工具中 syn 出现 8 次的依赖树
- 用因果剖析(coz)技术分析 syn 对关键路径的影响
- jiff 不在关键路径上,使它更快没有意义
- 魔法加速 syn 可以减少约 10% 的构建时间
- 指向 facet 项目的背景
十一、小结
一、传言与真相
Reddit 上关于 facet(作者正在开发的 Rust 反射框架)的讨论线程里,开始流传一个传言:fasterthanlime 想要干掉 serde------Rust 社区最受欢迎的序列化/反序列化框架,为 Rust 的成功做出了巨大贡献。
作者的回应是:
那是绝对的、百分之百的真实。
然后他马上说:
开玩笑的。大部分是。
因为你没法真的"杀掉"一个 Rust crate。如果你扎它一下,它不会流血------作者对此深有体会,因为他正在积极尝试干掉的其实是另一个 crate:syn ,并为此发起了 free of syn 运动。
二、syn 到底有多慢
作者先拿出一组数据,但故意不解释在对比什么:
M4 Pro,并行构建(默认):
sql
Benchmark 1: facet@0.x
Time (mean ± σ): 1.241 s ± 0.003 s
Range (min ... max): 1.236 s ... 1.244 s 10 runs
Benchmark 2: syn@2
Time (mean ± σ): 2.679 s ± 0.035 s
Range (min ... max): 2.643 s ... 2.742 s 10 runs
Benchmark 3: syn@1
Time (mean ± σ): 2.885 s ± 0.077 s
Range (min ... max): 2.780 s ... 3.001 s 10 runs
Summary
facet@0.x ran
2.16 ± 0.03 times faster than syn@2
2.32 ± 0.06 times faster than syn@1
在 M4 Pro 这样的高端机器上,最慢的跑完不到三秒,看起来还好。
但如果用 -j1(单线程,模拟 CI 环境):
sql
Benchmark 1: facet@0.x
Time (mean ± σ): 3.225 s ± 0.035 s
Benchmark 2: syn@2
Time (mean ± σ): 12.200 s ± 0.106 s
Benchmark 3: syn@1
Time (mean ± σ): 12.162 s ± 0.028 s
Summary
facet@0.x ran
3.77 ± 0.04 times faster than syn@1
3.78 ± 0.05 times faster than syn@2
12 秒。这段时间里,作者说他有时间泡好一杯红茶,思考人生。
GitHub Actions 免费层实测(2025 年 4 月 17 日):
sql
Benchmark 1: facet@0.x
Time (mean ± σ): 2.768 s ± 0.030 s
Benchmark 2: syn@2
Time (mean ± σ): 9.352 s ± 0.039 s
Benchmark 3: syn@1
Time (mean ± σ): 9.302 s ± 0.031 s
Summary
facet@0.x ran
3.36 ± 0.04 times faster than syn@1
3.38 ± 0.04 times faster than syn@2
但等等,facet 和 syn 根本不是同一类东西,对比它们的构建时间意义是什么?
三、苹果与核潜艇
作者承认:facet 和 syn 不等价。就像在对比苹果和核潜艇。
那 syn 到底是什么?
syn 是一个 Rust 代码解析库,它可以把 Rust 源代码解析成完整的抽象语法树(AST)。
下面这段代码可以解析它自己:
rust
fn main() {
eprintln!("{:?}", syn::parse_file(include_str!("main.rs")).unwrap());
}
运行后输出的是整个文件的完整 AST 表示,包含每一个 token 的位置信息:
less
File { shebang: None, attrs: [], items: [Item::Fn { attrs: [], vis: Visibility::Inherited,
sig: Signature { constness: None, asyncness: None, unsafety: None, ...
这在编写 proc macro(过程宏)时非常有用,因为你可以完整地访问和操作 Rust 代码的所有语法结构。
四、声明式宏能做什么,边界在哪里
Rust 有两种宏:声明式宏 (macro_rules!)和过程宏(proc macro)。
用声明式宏写一个"打印函数名"的宏:
rust
macro_rules! print_fn_name {
(fn $name:ident ($($args:tt),*) { $($body:tt)* }) => {
fn $name($($args),*) {
println!("Function name: {}", stringify!($name));
$($body)*
}
};
}
print_fn_name! {
fn main() {
println!("Hello, world!")
}
}
输出:
javascript
Function name: main
Hello, world!
它在编译期操作 token,而不是像 C 的预处理器那样在文本层面工作。括号分组的概念是内置的,不用担心括号的内容。
但声明式宏的局限性马上出现------只要加一个 pub:
rust
print_fn_name! {
pub fn main() { // 这行有问题
println!("Hello, world!")
}
}
rust
error: no rules expected keyword `pub`
--> src/main.rs:11:5
note: while trying to match keyword `fn`
可以修复,在模式里加 $vis:vis:
rust
macro_rules! print_fn_name {
($vis:vis fn $name:ident ($($args:tt),*) { $($body:tt)* }) => {
$vis fn $name($($args),*) {
println!("Function name: {}", stringify!($name));
$($body)*
}
};
}
但你会不断遇到下一个语法细节,不断修补。
用 syn 写 proc macro,就没有这个问题,它帮你解析完所有的 Rust 语法。
五、用 syn 写 proc macro
创建一个 proc macro crate:
bash
cargo new --lib ouroboros-proc-macro
Cargo.toml 中添加:
toml
[lib]
proc-macro = true
加入依赖 syn@2 -F full 和 quote,然后实现宏:
rust
use proc_macro::TokenStream;
use quote::quote;
use syn::parse_macro_input;
#[proc_macro_attribute]
pub fn print_fn_name(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input: syn::ItemFn = parse_macro_input!(item);
let fn_vis = &input.vis;
let fn_sig = &input.sig;
let fn_name = &input.sig.ident;
let fn_block = &input.block;
let expanded = quote! {
#fn_vis #fn_sig {
println!("Function name: {}", stringify!(#fn_name));
#fn_block
}
};
TokenStream::from(expanded)
}
调用侧变得简洁:
rust
use ouroboros_proc_macro::print_fn_name;
#[print_fn_name]
pub fn main() {
println!("Hello, world!")
}
宏展开结果验证
用 -Zunpretty=expanded 可以查看两种宏的展开结果,它们完全相同:
proc macro 版展开:
rust
pub fn main() {
{
::std::io::_print(format_args!("Function name: {0}\n", "main"));
};
{
{
::std::io::_print(format_args!("Hello, world!\n"));
}
}
}
声明式宏版展开:
rust
macro_rules! print_fn_name { /* ... */ }
pub fn main() {
{
::std::io::_print(format_args!("Function name: {0}\n", "main"));
};
{
::std::io::_print(format_args!("Hello, world!\n"));
}
}
唯一的区别是声明式宏版本还保留了宏的定义本身。
两者的本质区别不是功能,而是人体工程学 。syn 帮你解析了整个 Rust AST,你不用操心任何语法细节。
六、构建时间差距:冷构建(微项目)
先做基准测试,比较四种方案的冷构建时间:
less
Benchmark 1: decl(声明式宏)
Time (mean ± σ): 111.1 ms ± 2.1 ms
Benchmark 2: syn(proc macro + syn)
Time (mean ± σ): 1.524 s ± 0.030 s
Summary
decl ran
13.71 ± 0.37 times faster than syn
声明式宏比 syn proc macro 快 13.71 倍。
如果给 proc macro 开启优化(在 .config/cargo.toml 加):
toml
[profile.dev.build-override]
opt-level = 3
结果是......更糟:
less
Benchmark 1: decl
Time (mean ± σ): 112.6 ms ± 2.1 ms
Benchmark 2: syn
Time (mean ± σ): 4.140 s ± 0.070 s
Summary
decl ran
36.76 ± 0.93 times faster than syn
差距扩大到 36.76 倍 。优化的是 syn 本身的代码,让 syn 跑得更快了,但编译 syn 本身花的时间也更长了。
七、两种反驳与作者的回应
面对这些数据,文章预测了两种常见反驳:
反驳一:proc macro 本来就贵,这不是 syn 的问题。
作者的回应:不对,可以不用 syn 也能写 proc macro。见下节。
反驳二:谁关心冷构建?大家都做了缓存,热构建才是日常。
作者的回应:热构建同样有问题。见后续章节。
八、不用 syn 也能写 proc macro:纯手工版
直接操作 proc_macro 的原始 API:
rust
// in `ouroboros-manual-macro/src/lib.rs`
use proc_macro::{Delimiter, Group, Ident, Literal, Punct, Spacing, Span, TokenStream, TokenTree};
#[proc_macro_attribute]
pub fn print_fn_name(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut tokens = item.into_iter();
let mut output = Vec::new();
// 1. 把 token 传过去,直到遇到 "fn"
for token in &mut tokens {
let is_fn = matches!(&token, TokenTree::Ident(ident) if ident.to_string() == "fn");
output.push(token.clone());
if is_fn {
break;
}
}
// 2. 下一个必须是函数名标识符
let fn_name_ident = match tokens.next() {
Some(TokenTree::Ident(ident)) => ident,
_ => panic!("Expected function name after fn"),
};
let fn_name_str = fn_name_ident.to_string();
output.push(TokenTree::Ident(fn_name_ident.clone()));
// 3. 继续传,直到遇到函数体 { ... }
for token in tokens {
if let TokenTree::Group(group) = &token {
if group.delimiter() == Delimiter::Brace {
output.push(TokenTree::Group(Group::new(
Delimiter::Brace,
TokenStream::from_iter(
[
TokenTree::Ident(Ident::new("println", Span::call_site())),
TokenTree::Punct(Punct::new('!', Spacing::Alone)),
TokenTree::Group(Group::new(
Delimiter::Parenthesis,
TokenStream::from_iter([TokenTree::Literal(Literal::string(
&format!("Function name: {fn_name_str}"),
))]),
)),
TokenTree::Punct(Punct::new(';', Spacing::Alone)),
]
.into_iter()
.chain(group.stream()),
),
)));
continue;
}
}
output.push(token);
}
output.into_iter().collect()
}
很繁琐,但能用,而且快:
less
Benchmark 1: decl
Time (mean ± σ): 108.5 ms ± 1.8 ms
Benchmark 2: manual(手工版 proc macro)
Time (mean ± σ): 205.8 ms ± 1.3 ms
Benchmark 3: syn
Time (mean ± σ): 1.500 s ± 0.035 s
Summary
decl ran
1.90 ± 0.03 times faster than manual
13.82 ± 0.40 times faster than syn
手工版比声明式宏慢 1.9 倍,但比 syn 快 7.3 倍。
问题是人体工程学太差了。如果只有这两个极端(声明式宏 vs 直接操作 TokenStream),选择会很难。
这就是 unsynn 出现的原因。
九、unsynn:介于手工和 syn 之间
unsynn 这个名字来自德语 "unsinn",意为"胡说八道/无稽之谈"。
它的思路很清晰:只解析你需要的部分,不解析你不需要的部分。
第一步:定义关键字
rust
use unsynn::*;
keyword! {
KFn = "fn";
}
keyword! 是一个声明式宏,展开后生成:
rust
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct KFn;
impl unsynn::Parser for KFn {
fn parser(tokens: &mut unsynn::TokenIter) -> Result<Self> {
use unsynn::Parse;
unsynn::CachedIdent::parse_with(tokens, |ident, tokens| {
if ident == "fn" {
Ok(KFn)
} else {
unsynn::Error::other::<KFn>(
tokens,
format!(
"keyword {:?} expected, got {:?} at {:?}",
"fn",
ident.as_str(),
ident.span().start()
),
)
}
})
}
}
impl unsynn::ToTokens for KFn {
fn to_tokens(&self, tokens: &mut TokenStream) {
unsynn::Ident::new("fn", unsynn::Span::call_site()).to_tokens(tokens);
}
}
impl AsRef<str> for KFn {
fn as_ref(&self) -> &str {
&"fn"
}
}
第二步:声明要解析的结构
rust
unsynn! {
struct UntilFn {
items: Many<Cons<Except<KFn>, TokenTree>>,
}
struct UntilBody {
items: Many<Cons<Except<BraceGroup>, TokenTree>>,
}
struct Body {
items: BraceGroup,
}
struct FunctionDecl {
until_fn: UntilFn, _fn: KFn, name: Ident,
until_body: UntilBody, body: Body
}
}
核心组合子说明:
Many:一个或多个,类似正则表达式中的+Cons:这个,然后那个------两件事依次出现Except:向前看,确保某个东西不匹配。不消耗 token,只做检查KFn:刚定义的fn关键字------不是字符串字面量,是真正的关键字 tokenTokenTree:不只是单个 token,而是一整棵树。任何括号括起来的表达式都是一个TokenTree
关键设计思路:我们不解析不需要解析的东西 。我们只是跳过 token,直到看到 fn 关键字,然后拿到标识符,跳过直到函数体,拿到函数体。
在 unsynn! 宏里:
- struct = "一系列东西的序列"(类似带命名字段的
Cons<...>) - enum = "替代选项"
Option<T>也可以用
第三步:proc macro 入口
rust
#[proc_macro_attribute]
pub fn print_fn_name(
_attr: proc_macro::TokenStream,
item: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
let item = TokenStream::from(item);
let mut i = item.to_token_iter();
let fdecl = i.parse::<FunctionDecl>().unwrap();
let FunctionDecl {
until_fn,
_fn,
name,
until_body,
body,
} = fdecl;
let fmt_string = format!("Function name: {}", name);
quote::quote! {
#until_fn fn #name #until_body {
println!(#fmt_string);
#body
}
}
.into()
}
首先把输入 TokenStream 从 proc_macro 版本转换为 proc_macro2 版本(这是因为 proc_macro API 目前只对 proc macro crate 可用,如果想写单元测试需要抽象层),然后解析为 FunctionDecl。
解构之后,用 quote! 把各个字段插值到生成的 token stream 中,同时保留 span 信息。
为了让 quote! 能插值,还需要为自定义类型实现 quote::ToTokens:
rust
impl quote::ToTokens for UntilFn {
fn to_tokens(&self, tokens: &mut unsynn::TokenStream) {
self.items.to_tokens(tokens)
}
}
impl quote::ToTokens for UntilBody {
fn to_tokens(&self, tokens: &mut unsynn::TokenStream) {
self.items.to_tokens(tokens)
}
}
impl quote::ToTokens for Body {
fn to_tokens(&self, tokens: &mut unsynn::TokenStream) {
tokens.extend(self.items.0.stream())
}
}
基本上是把实现转发给已有的实现,因为 unsynn 自带 ToTokens trait,和 quote::ToTokens 本质上是同一个东西。
为什么还需要 quote 和 proc_macro2
unsynn 和 quote 共享 proc_macro2 依赖,这是一种"必要之恶"。proc_macro API 目前还不能用于非 proc macro crate,所以如果想写单元测试,就需要某种抽象层。这个问题有 tracking issue 正在推进,但在文章写作时还有 PR 等待有人认领。
十、unsynn 的冷构建时间
四路对比:
less
Benchmark 1: decl
Time (mean ± σ): 166.2 ms ± 4.0 ms
Benchmark 2: manual
Time (mean ± σ): 267.9 ms ± 3.0 ms
Benchmark 3: syn
Time (mean ± σ): 1.574 s ± 0.004 s
Benchmark 4: unsynn
Time (mean ± σ): 718.0 ms ± 1.9 ms
Summary
decl ran
1.61 ± 0.04 times faster than manual
4.32 ± 0.10 times faster than unsynn
9.47 ± 0.23 times faster than syn
unsynn 比 syn 快 2.19 倍,比声明式宏慢 4.32 倍。
它比 syn 轻,也比 syn 做的事少------而后者本来就是重点。
十一、热构建才是真正的战场
很多人的反驳:"我不在乎冷构建,我有缓存,热构建才是日常。"
好,测热构建。
作者用 GPT-4.1 生成了大量嵌套的"垃圾代码",然后把同一个块复制 100 次,填满 main 函数体(约一万一千行代码)。
热构建的测量方式:touch main.rs,然后 cargo build。依赖本身不重新构建,但 proc macro 会重新处理整个函数体。
less
Benchmark 1: decl
Time (mean ± σ): 197.7 ms ± 3.6 ms
Benchmark 2: manual
Time (mean ± σ): 204.0 ms ± 7.6 ms
Benchmark 3: syn
Time (mean ± σ): 304.8 ms ± 3.1 ms
Benchmark 4: unsynn
Time (mean ± σ): 208.6 ms ± 3.8 ms
Summary
decl ran
1.03 ± 0.04 times faster than manual
1.05 ± 0.03 times faster than unsynn
1.54 ± 0.03 times faster than syn
200ms 是解析编译这些代码的基本成本(假设声明式宏几乎免费)。调用一个已构建好的 proc macro 大约需要 10ms。syn 解析一万一千行"AI 垃圾代码"额外消耗了约 100ms。
这才是作者不喜欢 syn 的真正原因
syn 不允许你"少做一点"。它总是解析完整的 Rust AST,即使你的需求远比这要简单。
而且,目前 proc macro 调用的结果不会被缓存 。只要 cargo 认为某个文件可能发生了变化,就会重新运行所有 proc macro。这意味着:任何一个使用了 syn 的 proc macro,每次 cargo 认为需要重新编译时,都要重新解析所有代码,生成所有代码,无论实际内容有没有变化。
这就是为什么我们需要 proc macro 尽可能少做工作,而不是把 syn 的完整 AST 解析带到每一次编译循环里。
十二、syn 在真实项目中的影响规模
微项目的测试说明了原理,但真实项目里 syn 的影响更大。
作者的内部工具 beardist 的依赖树里,syn 出现了 8 次:
scss
beardist on main via Rust v1.86.0
❯ cargo tree -i syn --depth 1
syn v2.0.100
├── clap_derive v4.5.32 (proc-macro)
├── displaydoc v0.2.5 (proc-macro)
├── icu_provider_macros v1.5.0 (proc-macro)
├── serde_derive v1.0.219 (proc-macro)
├── synstructure v0.13.1
├── yoke-derive v0.7.5 (proc-macro)
├── zerofrom-derive v0.1.6 (proc-macro)
└── zerovec-derive v0.10.3 (proc-macro)
要把 syn 从这个依赖树里移除,意味着需要替换序列化框架、命令行解析、reqwest(通过 url crate 依赖 syn),工作量巨大。
用因果剖析(Causal Profiling)量化影响
直接测量 syn 的影响不容易,但可以用 coz 因果剖析器的技术来模拟:
- 先运行一次构建,其中每个 crate 的构建时间都人为加倍
- 再运行一次,其中除 syn 之外的每个 crate 构建时间加倍
两次构建之间的差异,就是"假如 syn 构建速度翻倍会带来多大改进"的虚拟加速。
这个技术来自因果剖析论文 COZ: Finding Code that Counts with Causal Profiling,核心思路是:通过人为减慢其他所有东西来模拟某个组件被加速的效果。
测试结果中几个有意思的发现:
jiff虽然占了构建时间的相当一部分,但它不在关键路径上------让它更快对冷构建没有任何帮助,因为它会和其他事情并行完成serde_derive在这个特定项目里,即使魔法加速,也没有可测量的效果- syn 相关的 proc macro 依赖 (
serde_derive、clap_derive等)移到更早完成后,整体构建时间减少了约 10%
这是真实项目中 syn 对关键路径的量化影响。
十三、小结
这篇文章的完整论证链条:
ruby
proc macro 使用 syn 解析 Rust 代码
↓
syn 解析完整 AST,即使你只需要其中很小的一部分
↓
proc macro 调用结果不被缓存
↓
每次 cargo 认为需要重新编译,syn 就要重新工作一遍
↓
在微项目上:冷构建慢 10--37 倍
↓
热构建:额外 100ms 每次
↓
在真实项目中:syn 在关键路径上,减少 10% 构建时间
替代方案的取舍对比:
| 方案 | 冷构建 | 热构建 | 人体工程学 |
|---|---|---|---|
| 声明式宏 | 最快(~111ms) | 最快 | 受限,难以处理复杂语法 |
| 手工 proc macro | 较快(~205ms) | 快 | 很差,大量模板代码 |
| unsynn | 中等(~718ms) | 快 | 较好,只解析需要的 |
| syn | 最慢(~1574ms) | 最慢(+100ms) | 最好,完整 AST 访问 |
作者的立场:
syn 是一个令人叹为观止的工具,它解析整个 Rust AST 的能力是真实的工程成就。但它不允许你"少做一点",而且 proc macro 缺乏缓存机制,让这个成本在每次编译中都要重新付出。
unsynn 是在"完整 AST 解析"和"直接操作原始 TokenStream"之间的中间地带:只描述你需要解析的结构,让其他部分透明地通过。
而 "free of syn" 运动,则是在推动整个 Rust 生态重新审视:当你只需要提取一个函数名、一个字段列表、一个枚举变体时,是否真的需要引入一个能解析整个 Rust 语言的完整解析器?
参考链接
- 原文:fasterthanli.me/articles/th...
- 配套视频(YouTube):youtu.be/YtbUzIQw-so
- free of syn 运动:github.com/fasterthanl...
- unsynn crate:docs.rs/unsynn
- facet(作者的 Rust 反射框架):github.com/facet-rs/fa...
- COZ 因果剖析论文:arxiv.org/pdf/1608.03...
- proc_macro API 开放给非 proc macro crate 的 tracking issue:github.com/rust-lang/r...