用 cargo bloat 查出 1.2MB 的泛型实例化膨胀

项目背景:Rust 1.79,x86_64-unknown-linux-gnu,release 带 strip=true 和 lto=“thin”,最终二进制 3.8MB。用户反馈冷启动慢,我用 cargo bloat --release --crates 看了下,发现两个业务 crate 的函数段合计 1.2MB,比预想高一个量级。

做法是先按 crate 排序,再钻到函数级:

cargo install cargo-bloat --version 0.11.1
cargo bloat --release --crates -n 20
cargo bloat --release --filter my_proto --inlines -n 30

结果很集中:serde_json::de::from_trait 和 core::fmt::write 的若干个 monomorphization 占了大头。追下去发现一个泛型函数 fn parse<T: DeserializeOwned>(s: &str) -> Result<T>,被 14 个不同的消息类型实例化,每个实例都把整套 serde 反序列化逻辑内联了一遍。改成运行时分发:

enum Msg { A(A), B(B), /* ... */ }

fn parse(s: &str) -> Result<Msg, Error> {
    let head: Head = serde_json::from_str(s)?;
    match head.kind {
        Kind::A => Ok(Msg::A(serde_json::from_str(s)?)),
        // ...
    }
}

代价是每个分支多一次完整解析(先解析 Head 再解析整体),我把 Head 字段收窄到 kind 一个字段,实测单条消息多花约 0.6µs,但二进制降到 2.9MB,函数段少了 620KB。

为什么有效:Rust 的泛型是单态化,每个具体类型生成一份机器码,--inlines 看到的就是展开后的结果。LTO 和 opt-level=“z” 只能压缩,不能消除语义上不同的实例。运行时分发把 N 份代码变成 1 份加一个 match,本质是拿一点运行时开销换代码体积。

会失效的地方:如果 T 的实例数只有两三个,改运行时分发反而更慢更大;如果解析在热路径上被调用百万次,那 0.6µs 会变成秒级。我自己踩的坑是第一次改完忘了把 #[inline] 去掉,测量结果没变化,白折腾一轮。另外 cargo bloat 依赖符号信息,strip 之后要加 --profile release 的 debug 或临时关掉 strip 才能看到函数名。

你们项目里有没有那种“只被三五个类型用,但每个都拖进一大坨依赖”的泛型入口?换成 trait object 还是枚举分发,实际收益差多少?

0
赞同度 100% · 0 条评论 · 5 阅读

0 条评论

支持 Markdown · 回复会实时推送给对方

还没有评论,写下第一条。