[Rust] Hello World 크기 최적화: C++만큼 줄여보기
Rust는 바이너리 크기가 큰 편은 아니지만, 경우에 따라서는 상대적으로 크다고 느낄 수도 있다.
특히 C/C++에 비해서는 확실히 기본 바이너리 크기가 큰 편이다. 기본 몇백k 정도.
디스크가 제일 저렴한 요즘 시대에 바이너리 크기가 문제가 되는 경우는 별로 없지만, 임베디드를 비롯한 일부 각박한 영역에서는 여전히 문제의 소지가 있다.
여기서는 Hello World 산출물을 C++과 비교하면서 Rust의 바이너리 크기를 줄이는 방법들을 단계적으로 시도해보겠다.
Rust의 바이너리가 큰 이유
Rust는 C/C++에 비해서 안정성을 위해 코드에 들어가는 추가 hidden flow들이 많은 편이다.
예를 들어 panic에 대한 특수 처리도 크고, 편의성을 위해 들어가는 것들이 C에 비해 많다.
대부분 옵션 조절을 통해 제어 가능한 부분이긴 하나, C 수준으로 줄이는 것은 좀 어렵다.
정적으로 링크하는 것을 선호하는 생태계 분위기도 바이너리 크기 증가에 한몫을 한다. C/C++ 환경은 라이브러리를 동적으로 링크하는 경향이 많다.
그리고 Rust 컴파일러 자체가, 바이너리 크기보다는 실행 성능에 집중하는 경향이 있기도 하다.
게다가 Rust의 std 기본 기능들 자체가 libc 원본 함수들에 비하면 코드크기가 좀 크기도 하다.
C++의 Hello World
일단 C++ 환경에서 바이너리 크기가 얼마나 나오는지 확인해보자.
환경은 x86, arch linux다.


헬로월드 코드만 간단하게 짜서 컴파일을 해봤다.

clang으로 컴파일했을 때는 O3와 Os 모두 15k가 나왔다.


GCC도 마찬가지다. 똑같이 15k 정도가 나왔다.

그러면 Rust는 어느 정도로 나올까?
Rust의 Hello World
Rust로도 헬로월드를 짜서 돌려보자.


그냥 대충 컴파일해서 실행해보면, 비정상적일 정도로 바이너리가 크게 나온다. 하는 것도 없는데 메가 단위다.
이건 디버그 모드로 컴파일해서 그렇다.
Try 1: release 컴파일 (효과 있음)
일단 가장 먼저 시도할 것은, 릴리즈 모드로 컴파일해서 디버그 처리를 위한 불필요한 코드를 실행파일에서 전부 날리도록 하는 것이다.

Try 2: 사이즈 최적화 옵션 (효과 없음)
Rust에는 Os와 동등한 사이즈 우선 최적화 레벨이 있다.
opt-level = "z"
이렇게 주면 되는데, 사실 이런 작고 단순한 사용사례에서는 별 차이가 없다.
gcc/clang에서 O3와 Os의 크기가 차이가 없었던 것처럼.
이 경우에는 크기가 줄지 않았다. - 물론 실제 사용사례에서는 의미있게 줄어든다.
Try 3: Link Time Optimization (효과 있음)
또 하나의 방법은, 링크타임 최적화를 통해서, 개별 object 파일 단위로만 하던 최적화를 최종 링크 단위에서도 하도록 하는 것이다.
이러면 확실하지가 않아서 보류하던 최적화를 더 공격적으로 시도하고, 미사용 코드를 더 많이 제거할 수 있다.
lto = true

실제로 돌려보면, 70k 정도 크기가 줄어든 것을 확인할 수 있을 것이다.

Try 4: 모든 심볼 제거하기 (효과 있음)
Rust 컴파일러는 release 모드로 컴파일하더라도, 최소한의 디버깅과 문제 추적을 위해 심볼을 좀 남겨둔다.
이걸 다 제거하려면 strip=true 옵션을 줄 수 있다.
strip = true

그러면 미약하지만 8k 정도 더 줄어든 것을 확인할 수 있다.

Try 5: codegen 단위 줄이기 (효과 없음)
Rust는 기본적으로 스레드를 여러개 띄워서 병렬 컴파일을 수행하는데, 이 때문에 서로 영역을 침범하지 못해서 최적화를 소극적으로 하는 부분이 생길 수 있다.
다음과 같이 옵션을 주면, 컴파일은 느려지지만 최적화는 더 끌어올릴 수 있다.
codegen-units = 1
하지만 이건 실제 사용사례에서는 유의미하지만, 헬로월드 수준의 작은 프로그램에서는 효과가 없다.
이 경우에는 의미가 없다.
Try 6: panic 처리 제거 (불가능)
panic은 안정성을 위해 추가된, C에는 없는 기능 단위다.
이것도 꽤 크기를 많이 먹을 수도 있는데, 다음과 같이 옵션을 주면 panic을 받지 않고 그냥 터지도록 할 수 있다.
panic = "abort"
하지만 이건 사실 헬로월드 수준에서는 효과가 없다.
이건 사실 바이너리 크기보다는 성능에 조금 더 유의미한 영향을 주는 옵션이다.
이걸 abort로 준다고 해서, Rust만의 panic 처리 구조가 완전히 날라가는건 또 아니기 때문이다.
현재 시점에서 간단하게 panic 관련 코드를 완전히 제거하는 것은 거의 불가능하다.
Try 7: no_main 사용 (효과 있음)
그 다음에 시도해볼만한 방법은, Rust의 main을 사용하지 않고 C 식으로 main을 export해서 암시적으로 진입점으로 쓰게끔 컴파일을 시키는 것이다.
#![no_main]
#[unsafe(no_mangle)]
pub extern "C" fn main(_argc: i32, _argv: *const *const u8) -> i32 {
println!("Hello, World!");
0 // 성공적인 종료
}

그러면 또 조금 더 크기가 줄어든 것을 볼 수 있다. 8k가 줄었다.

Try 8: std 사용 제거 (효과 큼)
사실, Rust의 std 함수가 일반적인 clib보다 코드 크기가 비대한 측면도 있다.
특히 println!의 경우에는 core::fmt라는 함수를 내부적으로 쓰는데, 이게 코드 크기가 좀 크다.
println!를 제거하고, libc 모듈을 통해 C 함수를 직접 호출하도록 재구성해보자.
#![no_main]
#[unsafe(no_mangle)]
pub extern "C" fn main(_argc: i32, _argv: *const *const u8) -> i32 {
const HELLO: &'static str = "Hello, World!\n\0";
unsafe {
libc::printf(HELLO.as_ptr() as *const _);
}
0 // 성공적인 종료
}
그러면, 놀랍게도 14k로 아주 크게 줄어들었다. C++의 15k보다 줄어든 수치다.
Try 9: PGO (효과 없음)
PGO를 시도해본다면 좀 더 적극적인 최적화를 기대해볼만도 하다.
실제 실행 기록에 의해서 더 확실한 Dead code 제거 등이 가능하기 때문이다.
사용법 참조
https://blog.naver.com/sssang97/222888877448
그래서 프로파일 수집해서
# Arch
sudo pacman -Sy llvm
rustup component add llvm-tools-previewRUSTFLAGS="-Cprofile-generate=./target/pgo-data" cargo run --release


한번 말아봤는데
llvm-profdata merge -o ./target/pgo-data/merged.profdata ./target/pgo-dataRUSTFLAGS="-Cprofile-use=$(pwd)/target/pgo-data/merged.profdata" cargo build --release
딱히 크기가 줄지는 않았다.
충분히 코드가 크다면 줄긴 주는데, 헬로월드 수준에서는 줄일 건덕지가 없나보다.
내가 볼떄 Linux에서는 이 정도가 한계인거같다.
참조
https://www.reddit.com/r/rust/comments/1g3q6rg/why_are_rust_binaries_so_large/
https://github.com/johnthagen/min-sized-rust
https://kerkour.com/optimize-rust-binary-size