프로시저 매크로 생태계에서 syn과 serde가 빌드 시간을 어떻게 악화시키는지, 선언적 매크로·수동 토큰 처리·unsynn을 비교 벤치마크로 분석합니다. cold/warm 빌드 모두에서의 영향, 대규모 프로젝트에서 syn이 크리티컬 패스가 되는 이유, 그리고 fargo 도구로 "가상 속도 향상"을 시뮬레이션해 빌드 그래프를 압축해 보는 방법을 다룹니다.
URL: https://fasterthanli.me/articles/the-virtue-of-unsynn
Title: The virtue of unsynn
May 29, 2025 21 min #rust
이 글에는 보너스 코드 저장소가 딸려 있습니다.
접근하려면 로그인해 주세요:
소문에 대해 한마디 --------------------- facet에 대한 레딧 스레드(제가 Rust의 리플렉션을 시도해 본 프로젝트 — 조금 이른 감이 있었지만, 어쨌든 고양이는 자루에서 나왔고, 이제 얘기해 봅시다!)에서 소문이 돌고 있습니다.
소문이란, 팟캐스터/유튜버 fasterthanlime인 제가 serde, 수많은 사랑을 받으며 Rust의 성공에 크게 기여한 직렬화/역직렬화 프레임워크를 죽이려 한다는 건데요, 그 소문에 대해 한 마디 하자면…
전부 다, 백 퍼센트, 사실입니다.
기다려. 내가 간다 serde.
네가 예상치 못할 때 갈 거야. 때가 올 때까지 넌 다시는 편히 잠들지 못할 거다. 내가 간다는 걸 알 테니까. 죽이러. 너를.
농담이야. 대부분은.
그런데… Rust 크레이트를 진짜로 죽일 수는 없어요. 찔러도 피가 나오지 않거든요. 그리고 제가 잘 아는 게 하나 있는데, 제가 진짜로, 적극적으로 죽이려고 하는 크레이트가 하나 있어요. 바로 syn, free of syn 운동과 함께:
자, 벌써부터… 왜?
아, 사실 아주 간단해요 — 보세요:
build-times-test on main
❯ ./hyperfine.sh
Benchmark 1: facet@0.8
Time (mean ± σ): 1.241 s ± 0.003 s [User: 3.108 s, System: 0.483 s]
Range (min … max): 1.236 s … 1.244 s 10 runs
Benchmark 2: syn@2
Time (mean ± σ): 2.679 s ± 0.035 s [User: 14.323 s, System: 0.418 s]
Range (min … max): 2.643 s … 2.742 s 10 runs
Benchmark 3: syn@1
Time (mean ± σ): 2.885 s ± 0.077 s [User: 14.440 s, System: 0.492 s]
Range (min … max): 2.780 s … 3.001 s 10 runs
Summary
facet@0.8 ran
2.16 ± 0.03 times faster than syn@2
2.32 ± 0.06 times faster than syn@1
우선, 우리가 뭘 비교하고 있는지 전혀 모르겠는데요.
그 얘기는 다시 하죠.
그리고 둘째로… 저 숫자들, 그렇게 나빠 보이지 않는데요?
가장 느린 실행도 3초는 안 넘었잖아요.
M4 Pro에서 그 정도면, 그래야죠, 안 그러면 제가 애플에 거금을 날린 게 되니까요.
하지만 README에 그냥 “3초 미만”이라고 적고 끝낼 수는 없죠.
아니면 적더라도, 적어도 동시성 1로, cargo에 -j1을 넘겨서 하세요:
build-times-test on main
❯ ./hyperfine.sh -j1
Benchmark 1: facet@0.8
Time (mean ± σ): 3.225 s ± 0.035 s [User: 2.904 s, System: 0.469 s]
Range (min … max): 3.169 s … 3.283 s 10 runs
Benchmark 2: syn@2
Time (mean ± σ): 12.200 s ± 0.106 s [User: 11.902 s, System: 0.348 s]
Range (min … max): 12.092 s … 12.361 s 10 runs
Benchmark 3: syn@1
Time (mean ± σ): 12.162 s ± 0.028 s [User: 11.877 s, System: 0.389 s]
Range (min … max): 12.140 s … 12.206 s 10 runs
Summary
facet@0.8 ran
3.77 ± 0.04 times faster than syn@1
3.78 ± 0.05 times faster than syn@2
이 열두 초 동안, 저는 홍차 한 잔과 성찰의 시간을 가졌습니다.
내가 지금 뭐 하는 거지? 왜 이 글을 쓰고 있지? 내 30대를 정말 이렇게 보내고 싶은 걸까—딩 아! 빌드 끝났다.
그 -j1 빌드는 대부분 사람들이 CI에서 보게 될 결과에 대한 좋은 프록시예요. 예컨대 GitHub Actions 무료 플랜에서요(2025년 4월 17일 기준 측정):
Benchmark 1: facet@0.8
Time (mean ± σ): 2.768 s ± 0.030 s [User: 6.621 s, System: 0.837 s]
Range (min … max): 2.721 s … 2.816 s 10 runs
Benchmark 2: syn@2
Time (mean ± σ): 9.352 s ± 0.039 s [User: 29.859 s, System: 0.647 s]
Range (min … max): 9.287 s … 9.424 s 10 runs
Benchmark 3: syn@1
Time (mean ± σ): 9.302 s ± 0.031 s [User: 30.056 s, System: 0.742 s]
Range (min … max): 9.257 s … 9.346 s 10 runs
Summary
facet@0.8 ran
3.36 ± 0.04 times faster than syn@1
3.38 ± 0.04 times faster than syn@2
좋아, 그런데… -j1이면 뭐든 느리잖아.
아냐 아냐, 곰. 그건 -j1이 아니야. 이게 -j1이지:
Benchmark 1: facet@0.8
Time (mean ± σ): 5.726 s ± 0.013 s [User: 5.044 s, System: 0.707 s]
Range (min … max): 5.704 s … 5.746 s 10 runs
Benchmark 2: syn@2
Time (mean ± σ): 21.348 s ± 0.047 s [User: 20.857 s, System: 0.540 s]
Range (min … max): 21.253 s … 21.426 s 10 runs
Benchmark 3: syn@1
Time (mean ± σ): 21.585 s ± 0.075 s [User: 21.025 s, System: 0.606 s]
Range (min … max): 21.492 s … 21.719 s 10 runs
Summary
facet@0.8 ran
3.73 ± 0.01 times faster than syn@2
3.77 ± 0.02 times faster than syn@1
오. 오, 그건… 훨씬 더 심하네.
아아, 모든 건 다 상황 나름이지! 근데 우리가 지금 뭘 빌드하고 있는 건데? 들인 비용 대비 얼마나 이득을 보지?
사과와 핵잠수함을 비교하기 ---------------------------------------
보세요, facet은 syn과 동등하지 않아요. 조금도! 사과랑… 핵잠수함을 비교하는 격이죠.
그리고 말씀드리자면, 핵잠수함을 찔러도… 피가 나지는 않습니다.
syn은 Rust 코드를 파싱하게 해 줍니다!
이 코드 조각은 자기 자신을 파싱할 수 있어요! (syn에 full, extra-traits 기능을 켠 상태):
fn main() {
eprintln!("{:?}", syn::parse_file(include_str!("main.rs")).unwrap());
}
ouroboros on main [+] via 🦀 v1.86.0
❯ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/ouroboros`
File { shebang: None, attrs: [], items: [Item::Fn { attrs: [], vis: Visibility::Inherited, sig: Signature { constness: None, asyncness: None, unsafety: None, abi: None, fn_token: Fn, ident: Ident(main), generics: Generics { lt_token: None, params: [], gt_token: None, where_clause: None }, paren_token: Paren, inputs: [], variadic: None, output: ReturnType::Default }, block: Block { brace_token: Brace, stmts: [Stmt::Macro { attrs: [], mac: Macro { path: Path { leading_colon: None, segments: [PathSegment { ident: Ident(eprintln), arguments: PathArguments::None }] }, bang_token: Not, delimiter: MacroDelimiter::Paren(Paren), tokens: TokenStream [Literal { lit: "{:?}" }, Punct { char: ',', spacing: Alone }, Ident { sym: syn }, Punct { char: ':', spacing: Joint }, Punct { char: ':', spacing: Alone }, Ident { sym: parse_file }, Group { delimiter: Parenthesis, stream: TokenStream [Ident { sym: include_str }, Punct { char: '!', spacing: Alone }, Group { delimiter: Parenthesis, stream: TokenStream [Literal { lit: "main.rs" }] }] }, Punct { char: '.', spacing: Alone }, Ident { sym: unwrap }, Group { delimiter: Parenthesis, stream: TokenStream [] }] }, semi_token: Some(Semi) }] } }] }
만약 목표가 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!")
}
}
ouroboros on main [!] via 🦀 v1.86.0
❯ cargo run
Compiling ouroboros v0.1.0 (/Users/amos/bearcove/ouroboros)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Running `target/debug/ouroboros`
Function name: main
Hello, world!
이는 C처럼 전처리기에서가 아니라, 컴파일 타임에 토큰 단위로 동작합니다. 그리고 본문에 뭐가 들어 있는지는 신경 쓰지 않아도 된다는 점을 보셨을 거예요 — 파서는 “괄호로 구분됨”이라는 개념을 내장하고 있어요.
하지만 여전히 함수 키워드, 이름, 인자에 대해 신경 써야 했고… 현재 매크로는 꽤 제한적입니다. 가시성 한 줄만 추가해도 깨져요:
print_fn_name! {
pub fn main() {
println!("Hello, world!")
}
}
ouroboros on main [!] via 🦀 v1.86.0
❯ cargo c
Checking ouroboros v0.1.0 (/Users/amos/bearcove/ouroboros)
error: no rules expected keyword `pub`
--> src/main.rs:11:5
|
1 | macro_rules! print_fn_name {
| -------------------------- when calling this macro
...
11 | pub fn main() {
| ^^^ no rules expected this token in macro call
|
note: while trying to match keyword `fn`
--> src/main.rs:2:6
|
2 | (fn $name:ident ($($args:tt),*) { $($body:tt)* }) => {
| ^^
✂️
물론 매크로를 고칠 수는 있죠:
ouroboros on main [!] via 🦀 v1.86.0
❯ jj diff
Modified regular file src/main.rs:
1 1: macro_rules! print_fn_name {
2 2: ($vis:vis fn $name:ident ($($args:tt),*) { $($body:tt)* }) => {
3 3: $vis fn $name($($args),*) {
4 4: println!("Function name: {}", stringify!($name));
5 5: $($body)*
6 6: }
...
다음 Rust 문법 요소가 나올 때까지만요.
하지만 syn으로 프로시저 매크로 크레이트를 쓰면 그 문제가 없어요! 문법 전체를 파싱해 주니까요!
❯ cargo new --lib ouroboros-proc-macro
Creating library `ouroboros-proc-macro` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
Cargo.toml에 다음을 추가합시다:
[lib]
proc-macro = true
cargo add로 syn@2 -F full과 quote를 추가하고, 같은 일을 해 봅시다:
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)
}
앞서 만지던 ouroboros 크레이트에서 이를 의존성으로 추가할 수 있어요:
ouroboros on main [!+] via 🦀 v1.86.0
❯ cargo add --path ../ouroboros-proc-macro
Adding ouroboros-proc-macro (local) to dependencies
Locking 1 package to latest Rust 1.86.0 compatible version
Adding ouroboros-proc-macro v0.1.0 (/Users/amos/bearcove/ouroboros-proc-macro)
호출부도 훨씬 깔끔해졌죠!
use ouroboros_proc_macro::print_fn_name;
#[print_fn_name]
pub fn main() {
println!("Hello, world!")
}
ouroboros on main [!+] via 🦀 v1.86.0
❯ cargo r
Compiling ouroboros-proc-macro v0.1.0 (/Users/amos/bearcove/ouroboros-proc-macro)
Compiling ouroboros v0.1.0 (/Users/amos/bearcove/ouroboros)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
Running `target/debug/ouroboros`
Function name: main
Hello, world!
그런데 대가가 뭐죠?? 알아봅시다.
매크로 확장 --------------- 우선 한 가지는 분명히 할게요: 이 두 매크로의 확장 결과는 완전히 같습니다.
직접 확인하고 싶다면 불안정 컴파일러 옵션 -Zunpretty=expanded를 쓰면 됩니다.
다음은 프로시저 매크로의 결과예요:
ouroboros on main via 🦀 v1.86.0
❯ cargo +nightly rustc -- -Zunpretty=expanded | rustfmt | bat -p -l Rust
Compiling ouroboros v0.1.0 (/Users/amos/bearcove/ouroboros)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2024::*;
#[macro_use]
extern crate std;
use ouroboros_proc_macro::print_fn_name;
pub fn main() {
{
::std::io::_print(format_args!("Function name: {0}\n", "main"));
};
{
{
::std::io::_print(format_args!("Hello, world!\n"));
}
}
}
여기서는 rustfmt와 bat을 통해 파이프했습니다. cargo-expand가 하는 것과 거의 같습니다.
후자는 syn 기반이라 rustfmt가 처리하지 못하는 경우도 다루지만, 뭐… 의존성이 하나 더 늘어나죠.
이 스택이 cargo-expand와 눈에 띄게 다른 점 하나는, 라이트 모드 터미널을 제대로 처리한다는 겁니다.
그리고 다음은 선언적 매크로의 결과예요:
ouroboros on main [!] via 🦀 v1.86.0
❯ cargo +nightly rustc -- -Zunpretty=expanded | rustfmt | bat -p -l Rust
Compiling ouroboros v0.1.0 (/Users/amos/bearcove/ouroboros)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2024::*;
#[macro_use]
extern crate std;
macro_rules! print_fn_name {
($vis:vis fn $name:ident($($args:tt),*) { $($body:tt)* }) =>
{
$vis fn $name($($args),*)
{ println!("Function name: {}", stringify!($name)); $($body)* }
};
}
pub fn main() {
{
::std::io::_print(format_args!("Function name: {0}\n", "main"));
};
{
::std::io::_print(format_args!("Hello, world!\n"));
}
}
유일한 차이는 선언적 매크로의… 선언을 볼 수 있다는 거예요.
그럼 뭐가 그렇게 다르죠? 인체공학성, 즉 사용 편의입니다.
syn은 _전부_를 파싱합니다.
매크로를 함수의 전체 본문을 그대로 출력하는 것으로 바꿔 보면, 확실히 드러나요:
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_block = &input.block;
let input_str = format!("{:#?}", input);
let expanded = quote! {
#fn_vis #fn_sig {
println!("{}", #input_str);
#fn_block
}
};
TokenStream::from(expanded)
}
ouroboros on main via 🦀 v1.86.0
❯ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/ouroboros`
[
Stmt::Expr(
Expr::Macro {
attrs: [],
mac: Macro {
path: Path {
leading_colon: None,
segments: [
PathSegment {
ident: Ident {
ident: "println",
span: #0 bytes(79..86),
},
arguments: PathArguments::None,
},
],
},
bang_token: Not,
delimiter: MacroDelimiter::Paren(
Paren,
),
tokens: TokenStream [
Literal {
kind: Str,
symbol: "Hello, world!",
suffix: None,
span: #0 bytes(88..103),
},
],
},
},
None,
),
]
Hello, world!
syn 타입의 Debug 구현은 색을 출력하지 않습니다. 이 출력은 가독성을 위해 GPT-4.1을 통해 다음 프롬프트로 색상을 입혔습니다:
add styling with `i class=` for each of these.
pick colors for bits of syntax and stick to them.
…지금까지 LLM을 쓴 것 중 가장 마음에 드는 용례 중 하나예요. 서로 다른 문자열 리터럴에 다른 색을 입혔더라고요. 뭐, 지시가 애매했나 보죠.
이 경우, 매크로 호출이 있음을 볼 수 있습니다. 매크로의 경로는 단순히 println이라는 식별자예요. 그 다음에는 매크로를 호출하는 느낌표(!)가 있고, 괄호로 둘러싸여 있습니다. (중괄호로도 매크로 호출을 둘러쌀 수 있어요.) 그리고 매크로에 전달된 리터럴 토큰 스트림이 있는데, 위치 정보가 포함된 문자열 리터럴 "Hello, world!"입니다.
멋집니다. 정말 멋져요. 17살 때부터 컴파일러를 만지작거리던 저에게는 특히 흥분되는 일이에요. 음, 17년이나 됐네요. 휴! 그게… 휴. 좋아요.
빌드 타임, 다시 ------------------ 그런데 빌드 시간 면에서는 어떨까요?
측정해 봅시다! 같은 방법론으로요:
ouroboros-family on main [!]
❯ ./hyperfine.sh
Benchmark 1: decl
Time (mean ± σ): 111.1 ms ± 2.1 ms [User: 95.0 ms, System: 80.8 ms]
Range (min … max): 108.2 ms … 115.1 ms 10 runs
Benchmark 2: syn
Time (mean ± σ): 1.524 s ± 0.030 s [User: 2.139 s, System: 0.373 s]
Range (min … max): 1.489 s … 1.594 s 10 runs
Summary
decl ran
13.71 ± 0.37 times faster than syn
여기서는 격차가 훨씬 더 크게 벌어집니다. --release를 붙여도 프로시저 매크로에는 영향이 없으니 아무 변화가 없고요.
프로시저 매크로를 최적화하고 싶다면, .config/cargo.toml에 다음을 추가할 수 있습니다:
# Set the settings for build scripts and proc-macros.
[profile.dev.build-override]
opt-level = 3
그리고 적용됐는지 여부는 바로 알 수 있어요, 왜냐하면…
ouroboros-family on main [✘+?]
❯ ./hyperfine.sh
Benchmark 1: decl
Time (mean ± σ): 112.6 ms ± 2.1 ms [User: 93.6 ms, System: 81.9 ms]
Range (min … max): 110.4 ms … 117.1 ms 10 runs
Benchmark 2: syn
Time (mean ± σ): 4.140 s ± 0.070 s [User: 17.079 s, System: 0.647 s]
Range (min … max): 4.066 s … 4.315 s 10 runs
Summary
decl ran
36.76 ± 0.93 times faster than syn
네. 그렇죠. 격차가 더 벌어졌습니다.
그리고 이쯤에서 사람들은 읽기를 멈추고 둘 중 하나를 댓글로 달아요:
먼저 프로시저 매크로 얘기를 하고, 그 다음 콜드 vs 웜 빌드를 얘기하겠습니다.
무거운 의존성 없이도 프로시저 매크로를 쓸 수 있음을 보여드리고 싶어요. 편의성은 떨어질 수 있지만, 가능합니다.
사실, 아예 어떤 의존성도 필요치 않아요:
// 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. Pass through tokens until "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. Next must be the function name identifier
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. Pass through everything up to (and including) the function body { ... }
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()
}
입에 쫙쫙 붙진 않죠, 인정합니다. 하지만… 동작합니다! 그리고 빠릅니다!
ouroboros-family on main [!+⇡]
❯ ./hyperfine.sh
Benchmark 1: decl
Time (mean ± σ): 108.5 ms ± 1.8 ms [User: 91.5 ms, System: 77.7 ms]
Range (min … max): 106.0 ms … 111.3 ms 10 runs
Benchmark 2: manual
Time (mean ± σ): 205.8 ms ± 1.3 ms [User: 223.5 ms, System: 216.7 ms]
Range (min … max): 204.1 ms … 208.6 ms 10 runs
Benchmark 3: syn
Time (mean ± σ): 1.500 s ± 0.035 s [User: 2.096 s, System: 0.369 s]
Range (min … max): 1.462 s … 1.564 s 10 runs
Summary
decl ran
1.90 ± 0.03 times faster than manual
13.82 ± 0.40 times faster than syn
하지만, 앞서 말했듯 인체공학성이 충분하지는 않아요. 그 둘의 중간쯤 되는 게 있다면. 뭔가… unsynn처럼요.
Unsynn? 독일어 ‘Unsinn’(헛소리)처럼요?
와, 노골적인 설명이네, 맞아요, unsynn입니다.
아주 상쾌하게 단순해요.
먼저, 크레이트의 전부를 임포트하고 키워드를 하나 정의합니다. 왜냐면, fn 키워드가 필요할 거니까요:
use unsynn::*;
keyword! {
KFn = "fn";
}
keyword!는 선언적 매크로입니다 — 궁금한 분들을 위해 확장을 보여드리면:
#[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,
alloc::__export::must_use({
let res = alloc::fmt::format(alloc::__export::format_args!(
"keyword {:?} expected, got {:?} at {:?}",
"fn",
ident.as_str(),
ident.span().start()
));
res
}),
)
}
})
}
}
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"
}
}
그리고 나서, unsynn! 매크로 안에서 우리가 파싱하고 싶은 걸 선언합니다:
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는 훔쳐보기(peek)로 “일치하지 않음”을 확인합니다. 실제로 토큰을 소비하지 않고, 단지 “그게 아님”만 보장합니다.KFn은 방금 정의한 것 — fn 키워드입니다 — 문자열 리터럴이 아니라, 그 단어 자체예요. 원한다면 새로운 키워드를 만들어도 되죠, 왜 안 되겠어요.TokenTree는 아무 토큰이 아니라, 토큰들의 한 그루(트리)입니다. 예컨대 괄호로 둘러싸인 표현식 전체는 하나의 TokenTree죠.보시다시피, 우리가 필요로 하지 않는 것들은 실제로 파싱하지 않습니다. fn 키워드를 만날 때까지 건너뛰고, 식별자를 하나 받고, 본문을 만날 때까지 다시 건너뛴 다음, 본문을 받습니다.
unsynn! 매크로에서, struct는 “것들의 순서열”(이름 있는 필드를 가진 Cons<...> 같은 것)이고, enum은 “대안들”입니다. Option<T>도 됩니다!
이걸 각각 이름을 가진 struct로 정의하는 이유는 이들에 대해 quote::ToTokens를 구현할 수 있도록 하려는 거예요!
잠깐, 아직도 quote를 쓰고 있어?
네. unsynn과 quote는 proc_macro2 의존성을 공유합니다. 일종의 불가피한 필요악이죠.
proc_macro API는 현재 프로시저 매크로가 아닌 크레이트에서는 사용할 수 없어서, 단위 테스트 등을 쓰려면 어떤 형태로든 추상화 레이어가 필요합니다.
프로시저 매크로가 아닌 크레이트에서도 proc_macro API를 사용할 수 있게 하려는 트래킹 이슈가 있으며, 이 글을 쓰는 시점에는 누군가 인수할 PR이 열려 있습니다.
그렇기 때문에 엔트리 포인트에서 이런 변환을 합니다:
#[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 버전으로 변환하고 — 그 다음 FunctionDecl로 파싱합니다.
여기서는 에러 복구가 없고, 파싱도 꽤 단순합니다. 하지만 우리의 요구 사항이 단순하니, 적어도 테스트 함수에 대해서는 잘 동작합니다!
파싱 직후 약간의 구조분해를 해서, quote! 호출에 각 필드를 집어넣어 생성된 토큰 스트림에 보간합니다. 이때 연관된 span 정보도 유지됩니다.
하지만 이건 quote::ToTokens를 구현한 타입에만 통하므로, 다음 세 가지 구현이 필요합니다:
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 트레이트가 있는데, 사실상 같은 겁니다.
이제, 이렇게 묻지 않으면 바보겠죠: 컴파일 타임은 어떻게 나오나요? syn보다 일을 덜 하지만, proc_macro API를 수동으로 다루는 것보다는 실용적이니… 대가는 뭘까요?
다시 콜드 빌드 시간을 봅시다:
ouroboros-family on main [!]
❯ ./hyperfine.sh
Benchmark 1: decl
Time (mean ± σ): 166.2 ms ± 4.0 ms [User: 166.3 ms, System: 129.2 ms]
Range (min … max): 161.0 ms … 176.2 ms 17 runs
Benchmark 2: manual
Time (mean ± σ): 267.9 ms ± 3.0 ms [User: 299.8 ms, System: 283.6 ms]
Range (min … max): 263.6 ms … 273.1 ms 10 runs
Benchmark 3: syn,
Time (mean ± σ): 1.574 s ± 0.004 s [User: 2.185 s, System: 0.434 s]
Range (min … max): 1.567 s … 1.582 s 10 runs
Benchmark 4: unsynn
Time (mean ± σ): 718.0 ms ± 1.9 ms [User: 1032.1 ms, System: 473.3 ms]
Range (min … max): 714.3 ms … 721.5 ms 10 runs
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,
syn보다 가벼운 건 부인할 수 없습니다. 덜 하는 일이기도 하고요. 그게 바로 의도이기도 합니다.
웜 빌드 -----------
그런데 또 이럴 수 있겠죠. 콜드 빌드 시간은 아무도 신경 안 쓴다고요. 모두가 캐싱을 잘 설정했고! 모두가 cargo-binstall을 쓰고 필요한 건 미리 빌드되어 있고, 그리고, 그리고… 좋아요 — 인정합니다.
웜 빌드 시간을 봅시다.
제 요점을 분명히 하기 위해, 이제 main의 본문은 아래의 블록 코드를 100번 반복한 것으로 구성됩니다:
이건 문자 그대로 AI 슬랍(slop)입니다. GPT-4.1에게 헛소리 코드를 만들어 달라고 하고, 더 깊이 중첩해 달라고 했어요. 고마워요, GPT-4.1!
{
fn print_nested<T: std::fmt::Debug>(val: &T) {
println!("Nested value: {:?}", val);
}
let mut num = 42;
num += 9;
let drizzle: bool = false;
let _x = if drizzle {
let mut extra = 100;
for i in 0..2 {
extra += i;
}
extra - 1
} else {
100
} ;
let cheese = "cheddar";
let y: Vec<i32> = vec![2,4,8,16,32,64];
for i in 0..3 {
let doubles: Vec<_> = (0..=i).map(|j| j * 2).collect();
print_nested(&doubles);
println!("banana{}", i);
}
let qwerty = ('a', 3, "xyz", false, 7.81);
for _ in 0..2 {
let _temp = 'z';
let nest = Some(vec![_temp; 2]);
if let Some(chars) = nest {
for c in chars {
print_nested(&c);
}
}
}
if num % 3 == 1 {
println!("Wobble!");
} else {
if num > 40 {
let check = Some(num * 2);
if let Some(val) = check {
print_nested(&val);
}
}
}
match cheese {
"cheddar" => {
println!("cheese type 1");
let cheese_types = vec!["swiss", "brie", "cheddar"];
for (i, c) in cheese_types.iter().enumerate() {
if c == &cheese {
print_nested(&i);
}
}
},
_ => println!("other cheese")
}
let strange: Option<&str> = Some("ghost cat");
if let Some(ghost) = strange {
println!("Boo says {}!", ghost);
let deep = Some(Some(vec![ghost; 1]));
if let Some(Some(v)) = deep {
print_nested(&v);
}
}
let prickle = [1,2,3,4,5];
fn print_vector<T: std::fmt::Display>(v: &[T]) {
for item in v {
println!("{}", item);
}
}
{
print_vector(&prickle);
}
let mut llama = 0;
while llama < 5 {
let condition = (llama % 2 == 0, llama >= 3);
match condition {
(true, true) => print_nested(&llama),
(true, false) => (),
(false, _) => (),
}
llama += 1;
}
fn tangerine<T: Default + Copy>() -> (T, i32) { (T::default(), 99) }
let _wumpus = tangerine::<u8>();
let _unused = &mut num;
let nonsense = |a: &str, b: i32, c: i32| format!("Nonsense{}{}{}", a, b, c);
println!("{}", nonsense(cheese, num, _x));
let _ = format!("{}{}", drizzle, y.len());
{
let bubble = 2.71f64;
println!("{}", bubble);
let levels = vec![vec![bubble]];
for l in &levels {
for n in l {
print_nested(n);
}
}
}
}
보시다시피, syn을 나쁘게 보이게 하려는 제 계략은 대성공입니다:
ouroboros-family on main [!]
❯ ./hyperfine.sh
Benchmark 1: decl
Time (mean ± σ): 197.7 ms ± 3.6 ms [User: 159.7 ms, System: 251.0 ms]
Range (min … max): 191.7 ms … 204.6 ms 14 runs
Benchmark 2: manual
Time (mean ± σ): 204.0 ms ± 7.6 ms [User: 166.8 ms, System: 241.9 ms]
Range (min … max): 195.2 ms … 228.4 ms 14 runs
Benchmark 3: syn,
Time (mean ± σ): 304.8 ms ± 3.1 ms [User: 267.2 ms, System: 249.2 ms]
Range (min … max): 299.8 ms … 310.5 ms 10 runs
Benchmark 4: unsynn
Time (mean ± σ): 208.6 ms ± 3.8 ms [User: 169.9 ms, System: 252.9 ms]
Range (min … max): 203.0 ms … 215.9 ms 14 runs
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,
측정 방식은, main.rs 파일의 타임스탬프를 건드린 다음 cargo build를 다시 실행하는 겁니다.
지금 이 글을 쓰는 시점이 2025년 5월이고, cargo의 checksum-freshness 옵션은 아직 불안정하므로, 파일의 수정 시간을 바꾸는 것만으로 cargo가 리빌드를 트리거하기에 충분합니다.
의존성 자체는 다시 빌드되지 않으니, syn을 다시 빌드하는 비용을 치르는 건 아니지만, Rust 컴파일러가 이미 그 동일한 토큰 스트림을 토큰화하고 그 위에 자신의 AST를 만든 다음인데도, syn이 함수 전체 본문을 토큰 스트림에서 다시 파싱하는 걸 기다리게 됩니다.
그래서 이 벤치마크에서, 그 모든 걸 파싱하고 컴파일하는 데 대략 200ms가 든다는 걸 볼 수 있어요. 그리고 선언적 매크로 호출은 무료라고 가정합시다. 이미 빌드된 프로시저 매크로를 호출하는 데는 대략 10ms 정도 걸린다고 쳐요.
그리고 1만1천 줄에 달하는 AI 슬랍을 syn이 파싱하는 데는 약 100ms가 걸립니다.
이게 제가 syn을 좋아하지 않는 진짜 이유입니다.
아니, 좋아하긴 해요. 매혹적이죠. 프로시저 매크로를 쓰기에 훌륭한 도구입니다. 하지만 덜 할 수는 없어요. 당신의 필요가 훨씬 소박하더라도, 항상 Rust의 전체 AST(추상 구문 트리)를 파싱합니다.
syn 자체가 다소 크다는 건 괜찮아요. 제가 고안하는 대안들도 시간이 지나면 결국 더 커질 거고, 한 번 컴파일하는 고정 비용이 생길 겁니다.
하지만 현재로서는 프로시저 매크로 호출이 캐시되지 않고, 앞으로도 캐시될지 불분명합니다. 그래서 많은 코드를 파싱하고 많은 코드를 생성하는 프로시저 매크로는, cargo가 뭔가 바뀌었을 수도 있다고 생각하는 모든 컴파일에서 — 실제로는 안 바뀌었더라도! — 그 일을 매번 반복합니다.
그래서 가능한 한 일을 적게 하는 프로시저 매크로가 중요합니다. 그래야 콜드/웜 빌드 모두에서 컴파일 타임이, 지금 syn과 serde 때문에 문제가 되는 것만큼 커지지 않거든요.
관점을 넓혀 보기 ----------------------------- 마지막으로 할 일이 하나 남았네요. 아직 우리는 대규모 프로젝트에서 느린 빌드의 원인이 syn이라고 실제로 입증하지 못했습니다. 마이크로 프로젝트에서는 해 봤지만, 규모가 커져도 중요한가요?
제가 내부적으로 쓰는 도구 beardist의 의존 그래프를 보면, syn이 무려 여덟 번이나 등장합니다!
beardist on main via 🦀 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을 걷어내려면 직렬화, 인자 파싱을 대체해야 하고, url 크레이트를 통해 syn에 의존하는 reqwest도 없애야 합니다. 엄청난 양의 작업이 되겠죠.
다행히 요령이 하나 있어요: 먼저 모든 크레이트 빌드 시간이 두 배로 늘어나는 빌드를 한 번 돌리고, 그다음에는 syn을 제외한 나머지 모든 크레이트만 두 배로 느리게 만드는 빌드를 한 번 더 돌리는 겁니다.
그 둘 사이에는 가상의 속도 향상이 일어나는데, 인과 프로파일러 coz에서 배운 기법입니다.
[PDF] COZ: Finding Code that Counts with Causal Profiling
절대적 타이밍에는 의존할 수 없고, 그것들을 보여 주는 건 무의미하겠지만, 이제 빌드 그래프에 “이 크레이트를 두 배 빨리 빌드하라”라는 마법 체크박스가 생겼고, cargo의 실제 스케줄러를 사용하여 빌드가 어떻게 달라질지 미리 볼 수 있습니다.
예를 들어, 제 도구의 빌드 시간의 상당 부분을 차지하는 jiff는 콜드 빌드의 크리티컬 패스에 있지 않습니다: 빨라져도 실제로 얻는 게 없어요.
Make jiff build twice as fast Minimum duration: 1.62s Shown: 32/232 units
total
beardist
reqwest
icu_properties
serde
encoding_rs
clap_derive
hyper
openssl
backtrace
serde_derive
zerovec-derive
yoke-derive
zerofrom-derive
ignore
rayon
h2
clap_builder
rustix
jiff
object
gimli
zerocopy
tracing-subscriber
regex-automata
openssl-sys
futures-util
tokio
syn
regex-syntax
aho-corasick
http
cc
마찬가지로, tokio를 두 배 빠르게 만들어도 큰 차이가 나지 않습니다:
Make tokio build twice as fast Minimum duration: 1.61s Shown: 33/232 units
total
beardist
reqwest
serde
icu_properties
hyper-util
encoding_rs
clap_derive
openssl
hyper
backtrace
serde_derive
zerovec-derive
yoke-derive
zerofrom-derive
clap_builder
ignore
rayon
rustix
h2
jiff
object
gimli
zerocopy
regex-automata
tracing-subscriber
openssl-sys
tokio
futures-util
syn
regex-syntax
http
aho-corasick
cc
여기저기 흔들리긴 해도, 총합은 같습니다.
심지어 이 특정 프로젝트에서는, serde_derive를 마법처럼 더 빠르게 만들어도 측정 가능한 효과가 없어요:
(이를 위해서는 JavaScript가 필요합니다)
하지만 syn을 두 배 빠르게 만들면, 전체 그래프가 아름답게 압축됩니다:
Make syn build twice as fast Minimum duration: 1.64s Shown: 38/232 units
total
beardist
reqwest
serde_json
serde
icu_properties
icu_locid_transform
hyper-util
zerovec
encoding_rs
openssl
clap_derive
hyper
backtrace
serde_derive
zerovec-derive
yoke-derive
zerofrom-derive
rayon
ignore
rustix
clap_builder
h2
jiff
object
gimli
zerocopy
tracing-subscriber
regex-automata
openssl-sys
futures-util
tokio
syn
regex-syntax
serde
aho-corasick
http
serde
cc
serde_derive와 clap_derive 같은 프로시저 매크로 의존성들이 그 디펜던트들과 함께 왼쪽으로 이동하고, 전체 빌드 시간은 무려 10% 줄어듭니다.
그리고 재밌는 게 뭔지 아세요? 저는 이걸 정확히 보여 주는 도구 — 이 그래프 압축, syn이 크리티컬 패스에 있음을 시각화하는 도구 — 를 만들기 전에 이 글/영상을 전부 썼어요. 왜냐하면 너무 확신했거든요!
잠깐, 확신이 없었던 거야?
있었지! 가끔은, 오만이 통합니다.
도구 -------
월 5유로 이상의 후원자라면, 제 블로그의 추가 콘텐츠 섹션에서 방금 보여 드린 것과 같은 상대적 속도 향상 빌드 그래프를 만드는 fargo를 다운로드할 수 있습니다.
이건 cargo와 rustc를 감싸는 비교적 단순한 래퍼로, 아티팩트 알림을 청취한 뒤 인위적으로 지연을 걸고, cargo의 HTML 타이밍 파일들을 JSON 페이로드로 변환합니다. 그 JSON은 제가 보여 드린 것 같은 시각화를 보여 주는 Svelte 5 컴포넌트가 소비합니다.
완전히 오프라인으로 동작하니, 전용 코드베이스에서도 돌리고, 상사에게 “거 봐요, 제가 뭐랬어요!”라고 말할 수 있어요. 그러면 제게도 알려 주세요 — 제 이메일은 웹사이트에 있고, 여러분의 이야기를 듣고 싶습니다.
이 글이 6개월 후 모두에게 공개될 때 제가 fargo를 오픈소스로 공개하는 걸 깜빡했다면, 꼬집어 주세요. 바로 공개하겠습니다.
또, 제게 5유로를 내고 싶지 않다면, 여러분만의 Fargo를 만드셔도 됩니다! 그리 어렵지 않고, 재밌는 연습이에요.
오늘은 여기까지. 다음에 또 만나요, 건강하세요!
이 글에는 보너스 코드 저장소가 딸려 있습니다.
접근하려면 로그인해 주세요:
제 스폰서분들께 감사합니다:
가능하시다면, 감당 가능한 티어로 이 작업을 후원해 주세요:
브론즈 티어* 보너스 콘텐츠 접근 (Rust 코드베이스 등)
당신을 위한 또 다른 글: