Rust と C 間の LTO（リンク時最適化）

# 動機

Rust で C のライブラリを使うとき、最適化されているのか気になったので調べてみました。

# TL;DR
https://doc.rust-lang.org/rustc/linker-plugin-lto.html

# 環境

- Windows11 24H2
- WSL2
- Debian 12
- rustc 1.83.0

# 実装

## Rust だけのコード

### add_one 関数

まずの Rust だけのコード書きます。ビルドした成果物を objdump したときに見やすいように`#![no_std]`、`#![no_main]`を付けています。

```rust:src/main.rs
#![no_std]
#![no_main]

fn add_one(value: i32) -> i32 {
    value + 1
}

#[no_mangle]
fn main() -> i32 {
    add_one(41)
}

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}
```

ビルドできるように libc にリンクします。

```rust:build.rs
fn main() {
    println!("cargo:rustc-link-lib=c");
}
```

Cargo.toml はこんな感じ。

```toml:Cargo.toml
[package]
name = "mytest"
version = "0.1.0"
edition = "2021"

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"
```

実行。

```sh
$ cargo run --release
   Compiling mytest v0.1.0 (/home/benki/rust/mytest)
    Finished `release` profile [optimized] target(s) in 0.35s
     Running `target/release/mytest`
$ echo $?
42
```

はい、ちゃんと動いてます。objdump して main 関数を確認します。

```sh
$ objdump -d target/release/mytest

(中略)

0000000000001130 <main>:
    1130:       b8 2a 00 00 00          mov    $0x2a,%eax
    1135:       c3                      ret
```

最適化によって add_one 関数は消え去ってしまって、eax レジスタに 42 が代入されて、そのままリターンしています。

### add_one クレート

次は add_one 関数を別クレートにしてみます。最適化されるでしょうか？

```sh
$ cargo new add_one --lib
```

```rust:add_one/src/lib.rs
#![no_std]

pub fn add_one(value: i32) -> i32 {
    value + 1
}
```

前述の Cargo.toml に add_one クレートを追加します。

```diff_toml:Cargo.toml
[package]
name = "mytest"
version = "0.1.0"
edition = "2021"

+ [dependencies]
+ add_one = { path = "../add_one" }

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"
```

main.rs も書き換えます。

```diff_rust:src/main.rs
#![no_std]
#![no_main]

- fn add_one(value: i32) -> i32 {
-     value + 1
- }
+ use add_one::add_one;

#[no_mangle]
fn main() -> i32 {
    add_one(41)
}

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}
```

別クレートにしてもばっちり最適化されています。（そりゃそうだ）

```sh
$ cargo run --release
   Compiling mytest v0.1.0 (/home/benki/rust/mytest)
    Finished `release` profile [optimized] target(s) in 0.42s
     Running `target/release/mytest`
$ echo $?
42
$ objdump -d target/release/mytest

（中略）

0000000000001130 <main>:
    1130:       b8 2a 00 00 00          mov    $0x2a,%eax
    1135:       c3                      ret
```

## Rust と C の混在

次に C の関数を呼ぶように変更します。まず C のコードを書きます。

```c:src/add_one.c
int add_one(int value) {
    return value + 1;
}
```

コンパイルしてライブラリにします。

```sh
$ cc -c src/add_one.c -o add_one.o -O2
$ ar -r libadd_one.a add_one.o
```

この C ライブラリの関数を Rust から呼びます。

```diff_rust:src/main.rs
#![no_std]
#![no_main]

+ extern "C" {
+     fn add_one(value: i32) -> i32;
+ }

#[no_mangle]
fn main() -> i32 {
+     unsafe { add_one(41) }
}

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}
```

libadd_one.a をリンクします。

```diff_rust:build.rs
fn main() {
+    // リンクするライブラリのある場所をリンカに教える
+    println!("cargo:rustc-link-search=.");
+    // リンクするライブラリをリンカに教える
+    println!("cargo:rustc-link-lib=add_one");
    println!("cargo:rustc-link-lib=c");
}
```

予想通りですが最適化されません。（add_one 関数内で add 命令じゃなくて lea 命令が使われるのですね。おもしろいなぁ）

```sh
$ cargo run --release
   Compiling mytest v0.1.0 (/home/benki/rust/mytest)
    Finished `release` profile [optimized] target(s) in 0.36s
     Running `target/release/mytest`
$ echo $?
42
$ objdump -d target/release/mytest

（中略）

0000000000001130 <main>:
    1130:       bf 29 00 00 00          mov    $0x29,%edi
    1135:       ff 25 95 2e 00 00       jmp    *0x2e95(%rip)
    113b:       0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

0000000000001140 <add_one>:
    1140:       8d 47 01                lea    0x1(%rdi),%eax
    1143:       c3                      ret
```

gcc ではダメっぽいので clang を使います。Debian 12 で`sudo apt install clang`したらバージョン 14 が入りました。clang でコンパイルしてライブラリにします。

```sh
$ clang -flto=thin -c src/add_one.c -o add_one.o -O2
$ llvm-ar -r libadd_one.a add_one.o
```

rustc に C のコードも LTO するように伝えます。

```sh
$ RUSTFLAGS="-Clinker-plugin-lto -Clinker=clang -Clink-arg=-fuse-ld=lld" cargo run --release
```

コンパイルエラーが出ました。Rust 1.83.0 は LLVM 19 を使っていて、clang が LLVM 14 なので中間コードに互換性がないって感じでしょうか？

```sh
   Compiling mytest v0.1.0 (/home/benki/rust/mytest)
error: linking with `clang` failed: exit status: 1
  |

（中略）

  = note: ld.lld: error:（略）Opaque pointers are only supported in -opaque-pointers mode (Producer: 'LLVM19.1.1-rust-1.83.0-stable' Reader: 'LLVM 14.0.0')
          clang: error: linker command failed with exit code 1 (use -v to see invocation)

error: could not compile `mytest` (build script) due to 1 previous error
```

仕方がないので LLVM 19 をインストールします。（ストレージを 8.2G 消費します。）

```sh
$ curl -LO https://github.com/llvm/llvm-project/releases/download/llvmorg-19.1.0/LLVM-19.1.0-Linux-X64.tar.xz
$ tar -xJvf LLVM-19.1.0-Linux-X64.tar.xz
```

`LLVM-19.1.0-Linux-X64/bin`にパスを通して clang 19 で C のコードをコンパイルします。

```sh
$ clang -flto=thin -c src/add_one.c -o add_one.o -O2
$ llvm-ar -r libadd_one.a add_one.o
```

今度はビルドに成功しました。

```sh
$ RUSTFLAGS="-Clinker-plugin-lto -Clinker=clang -Clink-arg=-fuse-ld=lld" cargo run --release
   Compiling mytest v0.1.0 (/home/benki/rust/mytest)
    Finished `release` profile [optimized] target(s) in 1.79s
     Running `target/release/mytest`
$ echo $?
42
```

最適化されているか確認します。

```sh
$ objdump -d target/release/mytest

（中略）

0000000000001690 <main>:
    1690:       b8 2a 00 00 00          mov    $0x2a,%eax
    1695:       c3                      ret
```

やったぜ。

# まとめ

よいお年をお迎えください。