Rust 1.97.0(2026-07-09)で、シンボルマングリングのデフォルトが legacy から v0 に変わりました。
この記事は「バックトレースやツールの表示が実際どう変わるのか」「バイナリはどれだけ大きくなるのか」を、1.96 / 1.97 の実バイナリで測った記録です。
対象読者は、Rust バイナリを本番で動かしていて、panic やプロファイル結果を読む機会がある人に役立てばと。
1.97 で良くなるのは、ジェネリクスの具体型がシンボル名に残るようになることです。
その分シンボル文字列は +13〜32% 増えますが、リンク時間は測っても差が出ませんでした。
注意点が 1 つだけあって、release のデフォルト設定ではそもそもシンボルがバイナリに残りません。
設定を変えずに 1.97 へ上げても、nm の結果は no symbols のままです。
何が変わったのか
v0 マングリング自体は 1.59 から opt-in で使えました。
8 年かけてようやくデフォルトになりました。
見分け方は単純で、legacy は _ZN、v0 は _R で始まります。
実物を見た方が早いので、次のコードを両方のバージョンでビルドしました。
use std::collections::HashMap; // 検証用に HashMap を使う
#[inline(never)] // インライン化させず、シンボルとして必ず残す
fn lookup<'a, K: std::hash::Hash + Eq, V>(map: &'a HashMap<K, V>, key: &K) -> Option<&'a V> {
map.get(key) // キーに対応する値への参照を返すだけ
}
fn main() {
// lookup を <String, Vec<u32>> でインスタンス化させるための型
let mut m: HashMap<String, Vec<u32>> = HashMap::new();
m.insert("a".into(), vec![1, 2]); // 適当な値を 1 件入れる
println!("{:?}", lookup(&m, &"a".to_string())); // ここで単相化が起きる
}
nm で lookup のシンボルを取り出すとこうなります。
# 1.96.0(legacy)
_ZN11mangle_demo6lookup17hbb07f546efb3e6fbE
# 1.97.1(v0)
_RINvCsT2VuYQ1sEf_11mangle_demo6lookupNtNtCscdodAO9FK5_5alloc6string6StringINtNtBD_3vec3VecmEEB2_
legacy に残っているのは lookup という名前とハッシュだけ。
<String, Vec<u32>> でインスタンス化されたという情報は、ここからは復元できません。
v0 の方は 6String、3Vec、m(u32 の 1 文字符号)が読み取れます。
demangle を通すと差がはっきり出ます。
$ nm -C target/release/mangle-demo | grep lookup
# 1.96: mangle_demo::lookup
# 1.97: mangle_demo::lookup::<alloc::string::String, alloc::vec::Vec<u32>>
なお、標準ライブラリは 1.96 の時点ですでに v0 でビルドされていました。
今回変わったのはユーザークレート側です。
v0 の符号化仕様そのもの(backref 圧縮や Punycode)はこの記事では扱いません。
詳細は RFC 2603 に譲ります。
検証環境
- WSL2(kernel 6.6.87.2)/ Ubuntu 24.04.4 / Intel Core i7-14700F
- rustc 1.96.0 / 1.97.1 / 1.97.0-nightly(2026-04-29, c935696dd)
- GNU binutils 2.42(nm / addr2line / c++filt)/ rustfilt
バックトレースはこう変わる
panic するジェネリック関数を用意して比べます。
#[inline(never)] // インライン化させず、シンボルとして必ず残す
fn parse_pair<T: std::str::FromStr>(s: &str) -> (T, T) // "a,b" 形式を (T, T) にパースする
where
T::Err: std::fmt::Debug, // unwrap で表示するため Debug を要求
{
let mut it = s.split(','); // カンマで分割
let a = it.next().unwrap().parse::<T>().unwrap(); // 1 要素目をパース
let b = it.next().unwrap().parse::<T>().unwrap(); // 2 要素目をパース(無ければここで panic)
(a, b) // 2 つ揃ったら返す
}
fn main() {
println!("{:?}", parse_pair::<u64>("1")); // 2 要素目がなく b 側の unwrap で panic
}
RUST_BACKTRACE=1 で実行して、該当フレームだけ抜き出すとこうです。
# 1.96.0
5: mangle_demo::parse_pair
# 1.97.1
5: mangle_demo::parse_pair::<u64>
parse_pair::<u64> — どの型で落ちたか、最初から書いてあります。
RUST_BACKTRACE=full にすると、legacy は parse_pair::he54dddcca06249cd とハッシュ付き、v0 は mangle_demo[cdf0d36d2cd29192]::parse_pair::<u64> とクレートの disambiguator 付き。
closure の表記も {{closure}} から {closure#0} に変わって、複数 closure の区別がつくようになりました。
実プロジェクトだともっと効きます。
ripgrep 15.2.0 を 1.96 / 1.97 でビルドして、同じ関数のシンボルを見比べると:
# 1.96: どのインスタンスか分からない
grep_searcher::searcher::core::Core<M,S>::count_lines
# 1.97: 具体型まで書いてある(長いので型引数の一部を ... で省略)
<grep_searcher::searcher::core::Core<&&grep_regex::matcher::RegexMatcher,
&mut grep_printer::json::JSONSink<..., termcolor::Buffer>>>::count_lines
legacy が持っているのは定義時のパラメータ名 <M,S> だけ。
モノモーフィゼーション起因の性能問題(特定のインスタンスだけ遅い・コードサイズが増える)を追うとき、この差は初動の速さに直結します。
手元のツールは v0 を読めるのか
公式リリースノートは「古いデバッガ・プロファイラでは demangle に失敗しうる」と注意しています。
Ubuntu 24.04 の標準構成で試した結果は全勝でした。
nm -C、objdump --demangle、addr2line -f -C、c++filt、rustfilt、どれも v0 を正しく復元します(binutils 2.42)。
c++filt だけはクレートの disambiguator まで出す冗長形式でした。
perf は未検証です。
この検証環境(WSL・sudo なし)には perf を入れられませんでした。
【要実測:Linux 実機か sudo の使える環境で perf record → perf report を実行し、v0 シンボルが demangle されるか確認。perf の v0 対応パッチは 2025-02 に入ったばかりのため、distro のバージョン次第で rustfilt を通す運用になる可能性が高い】
サイズはどれだけ増えるのか
公式の説明は「v0 はシンボルが長くなり、サイズとリンク時間に軽微な影響がある」。
その「軽微」を数字にします。
コンパイラの世代差を混ぜたくないので、同一の nightly(1.97.0-nightly)でマングリングだけを切り替えました。
legacy の明示指定は nightly 限定で、将来消える予定のフラグです。
# マングリングだけ legacy に切り替えてビルド(この指定は nightly 限定)
RUSTFLAGS="-Csymbol-mangling-version=legacy -Zunstable-options" cargo +nightly build --release
測るのは 2 つ。
ジェネリック関数を 300 インスタンス化する合成クレートと、ripgrep 15.2.0 です。
どちらも strip = "none" でシンボルを残しています。
| 計測対象 | legacy | v0 | 差分 |
|---|---|---|---|
| 合成クレート .strtab | 181,502 B | 238,910 B | +31.6% |
| 合成クレート バイナリ全体 | 4,896,408 B | 4,953,832 B | +1.2% |
| ripgrep .strtab | 1,216,395 B | 1,376,470 B | +13.2% |
| ripgrep .debug_str | 7,163,333 B | 9,011,208 B | +25.8% |
| ripgrep .text | 2,752,766 B | 2,750,430 B | −0.1% |
| ripgrep バイナリ全体 | 28,590,360 B | 30,572,184 B | +6.9% |
ここで数字が合いません。
ripgrep のバイナリは 28.6MB → 30.6MB と 2MB 近く増えたのに、.strtab の増分は 160KB しかない。
残りはどこかと readelf -SW で両方のバイナリを比べると、.debug_str が +1.85MB でした。
ripgrep は release で debug = 1 にしているので、DWARF の linkage name にもマングル名が入って、そちらが増えていたわけです。
.text は実質同一。
コード生成には影響しません。
シンボルを strip して配布する構成なら、増分はゼロです。
意外だったのは最長シンボルです。
legacy 804 文字に対して v0 は 609 文字と、ここだけ逆転します。
v0 は平均こそ長い(合成クレートで 68.5 → 90.6 文字)ものの、一度出た path を参照で済ませる backref があるので、深いネストの最悪ケースはむしろ短い。
リンク時間は touch → 再ビルドを各 5 回実行して、v0 が 1154〜1183ms、legacy が 1159〜1183ms。
誤差の範囲です。
落とし穴 1:release デフォルトではそもそもシンボルが残らない
最初に nm を実行したら no symbols。
ここで数分固まりました。
readelf -SW で見ると .symtab ごと無い。
cargo は debug=0 の release ビルドで -C strip=debuginfo を自動で渡していて、この環境ではこれで .symtab まで消えます(rustc を直接実行しても再現)。
つまり素の release ビルドでは、v0 の良さもサイズ増も成果物に現れません。
実測や運用でシンボルを使うなら strip = "none" か debug = 1 の明示が前提。
プロファイルを読むなら、私は ripgrep に倣って debug = 1 にします。
落とし穴 2:インライン化されたフレームは v0 でも消える
#[inline(always)] の呼び出し連鎖の先で panic させると、dev ビルドでは level3::<u32> まで型付きで出ます。
release(debug=0)では main しか表示されなくなります。
v0 が改善するのはシンボルテーブル経由の名前解決だけで、インライン化で消えたフレームは debuginfo がないと復元できません。
ここを混同すると「1.97 に上げたのにバックトレースが読めない」と誤診します。
1.97 beta にはバックトレースがハッシュ表示になる回帰(#157432)がありましたが、stable 1.97.1 では再現しませんでした。
ここまでの切り分けを 1 枚にまとめるとこうなります。
おまけ:リンカ警告の可視化に早速引っかかった
1.97 のもう一つの変更「リンカの stderr を警告として表示」には、この検証の最中に自分が引っかかりました。
warning: linker stderr: ignoring deprecated linker optimization setting '1'
= note: `#[warn(linker_messages)]` on by default
この警告、1.96 まではリンカが出力しても rustc が表示せずに捨てていました。
[lints.rust] linker_messages = "allow" で元に戻せますが、私は出したままにします。
というか、リンカに deprecated な設定が渡っていたこと自体、この警告で初めて知りました…。
所感
v0 のデフォルト化は地味です。
ただ、障害調査の初動は確実に一段速くなる。
Core<M,S> としか出なかったフレームに具体型が入るだけで、当たりを付けるための再現ビルドが 1 回減ります。
シンボル +13〜32% は、シンボルを残すと決めた時点で織り込むコストの範囲でしょう。

