요약
고속 링커 mold가 C++ 코드를 Rust로 새로 작성한 첫 버전 3.0.0을 냈습니다.
mold는 컴파일된 코드 조각을 묶어 실행 파일을 만드는 리눅스용 링커입니다. 개발진은 2.42.1 릴리스 노트에서 Rust 전환을 예고했고, 이번이 그 첫 릴리스입니다.
왜 중요한가
- 명령줄 옵션, 지원 아키텍처, 출력이 2.42.1과 같아서 기존 사용자는 설정을 바꾸지 않고 옮겨 갈 수 있습니다.
- 3.x의 목표는 GNU ld와의 남은 호환 차이, 특히 링커 스크립트 지원을 메워 배포판 기본 링커가 되는 것입니다.
- 2.42.1이 C++ 버전의 마지막 릴리스여서 C++ 판은 더 이상 나오지 않습니다.
핵심 내용
- 링크 속도는 2.42.1과 비슷하고, 전 대상 테스트와 Gentoo 전체 패키지 빌드에서 회귀가 없었다고 밝혔습니다.
- 빌드 도구가 CMake에서 Cargo로 바뀌었고, Rust 1.95 이상과 C 컴파일러가 필요합니다.
- oneTBB 의존성을 없애고 메모리 할당기 mimalloc을 정적으로 링크합니다.
- 손상된 입력 파일을 만나면 세그폴트(잘못된 메모리 접근으로 인한 강제 종료) 대신 통제된 패닉으로 멈춥니다.
- 버전 스크립트를 쓴 정적 실행 파일의 충돌, 비결정적 출력 같은 버그와 아키텍처별 문제도 고쳤습니다.
HN 반응
- 소스 재현 빌드를 고집하는 한 배포판 관리자는 Rust보다 먼저 빌드할 수 없게 돼 C++ 판을 포크해야 한다고 아쉬워했습니다.
- 이전 바이너리를 캐시하거나 LLD를 쓰라는 반론에, 그는 Rust 빌드가 전체 시간의 80%라 이점이 사라진다고 답했습니다.
제가 공동으로 관리하는 리눅스 배포판은 Rust 자체를 부트스트랩할 때 mold를 부트스트랩용 링커로 씁니다. 버전별로 길게 이어지는 빌드 체인에서 -몇 시간-을 아꼈습니다. mold가 C로 되어 있어서 아주 이른 단계에서 빌드해 배포판 전체의 기본 링커로 쓸 수 있었고, 덕분에 모든 곳에서 빌드 속도 이득을 봤습니다.
이제 안타깝게도 C 버전을 포크해서 mold2라는 이름으로 영원히 유지보수해야 합니다.
Rust가 모든 문제에 맞는 도구는 아닙니다.
다른 댓글 작성자분들처럼 저도 캐시된 바이너리로는 왜 충분하지 않은지 이해가 안 됩니다. mold3 바이너리를 먼저 빌드해서 캐시해 두고 다시는 빌드하지 않으면 안 되나요? 배포판의 밀폐성(hermeticity)이나 빌드 출처(provenance) 보장과 관련이 있을 것 같은데요.
제가 더 잘 이해할 수 있게 참고할 만한 자료가 있을까요? 배포판 문서나 검색해 볼 만한 이슈 트래커 같은 것이요.
https://stagex.tools
간단히 말해, 배포판 전체를 어느 커밋에서든 항상 한 번에 빌드합니다. 이전 릴리스의 캐시된 바이너리는 그것 자체와 그 공급망의 어떤 것도 바뀌지 않았을 때만 믿고 쓸 수 있습니다. rustc는 거의 모든 것에 의존하기 때문에, mold3는 트리 전체에서 가장 비싼 빌드인 Rust에 쓰기에는 너무 늦게 빌드됩니다.
재귀적 의존성은 우리 위협 모델을 깨뜨리므로 Rust 도구로는 Rust를 부트스트랩할 수 없습니다. 우리는 Rust를 LLVM으로, LLVM을 gcc로, gcc를 tinycc로, tinycc를 M2Planet으로 부트스트랩하고, 이렇게 거슬러 올라가면 186바이트짜리 기계어 코드에 이릅니다.
stagex에서 Rust 패키지를 받는 사람은 누구든 그 패키지 직전까지 트리 전체를 빌드해서 같은 해시를 얻을 수 있어야 합니다. 바이너리 의존성이 전혀 없으니 메인테이너를 신뢰할 필요도 없어집니다.
그 위협 모델이 정확히 뭡니까?
stagex의 한 버전을 부트스트랩할 수 있다면(그래서 그 버전의 mold를 신뢰할 수 있다면), 그걸로 stagex의 다른 버전을 빌드하지 못할 이유가 없다고 봅니다.
말씀하신 대로 옛 버전의 공급망에서 바뀐 것이 없으니 캐시된 바이너리를 믿고 쓸 수 있습니다. 고정(pin)되어 있고, 그걸 빌드할 수만 있으면 다 괜찮은 거죠.
위협 모델은 어떤 한 사람이나 한 컴퓨터도 믿지 않는다는 것입니다.
우리를 믿지 않아도 되려면, 소스 코드만 있는 우리 저장소를 클론해서 처음부터 빌드해 우리가 릴리스한 바이너리 해시까지 도달할 수 있어야 합니다. 그 검증에 걸리는 시간이 짧을수록 더 많은 사람을 설득해 직접 해 보게 하고, 이상적으로는 일치하는 해시에 서명해서 공개하게 할 수 있습니다. 직접 해 보는 사람이 많아질수록, 메인테이너인 우리가 아무도 모르게 백도어를 심을 위험이 줄어듭니다.
mold는 대부분의 사람이 처음부터 검증하는 데 쓰는 시간에서 몇 시간을 덜어내 주는 도구였습니다. mold3를 얻으려고 Rust까지 가는 트리 전체를 빌드하고, 그걸로 트리 전체를 두 번째로 다시 빌드하게 만든다면 트리 전체 검증 시간을 최소화한다는 목표에 정면으로 어긋납니다.
이건 전적으로 스스로 자초한 일이잖아요!
본인들이 내린 결정은 차치하고, 이 문제에는 Rust가 분명히 맞는 도구예요.
자초한 이유는 가능한 한 많은 사람을 공급망 공격으로부터 지키고 싶어서입니다. 이건 우리가 풀어야 하는 아주 현실적인 엔지니어링 문제이고, 우리 작업물에 의존하는 위험도 높은 최종 사용자들이 있습니다.
인기 있는 리눅스 배포판 대부분은 공급망 공격이 자기들을 노리지 않기를 바라고 기도하는 입장을 취합니다. AI 이후 시대에 그게 잘 풀릴지 저는 확신이 서지 않습니다. 물론 제가 틀리길 바라기는 하지만요.
Rust를 링크할 때 LLD를 쓰면 안 되나요? 어차피 Rust를 빌드하려면 LLVM이 있어야 하니, 의존성이 늘어나는 것도 아니잖아요.
우리는 LLVM 링커인 lld로 가능한 한 가장 이른 시점에 mold를 링크하고, 그 이후로는 mold만 씁니다. 우리는 LLVM 네이티브 배포판이라서 gcc 대신 LLVM을 바로 빌드해 트리 전체를 빌드하는 시스템 컴파일러로 씁니다.
LLD로도 트리 전체를 문제없이 빌드할 수 있고 예전에는 실제로 그렇게 했지만, mold보다 훨씬 느려서 mold로 바꿨습니다.
mold를 쓰기 전에 python, openssl 등 Rust의 모든 의존성을 먼저 빌드해야 한다면 트리 전체 빌드에서 얻는 속도 이득 대부분이 사라집니다. Rust까지 가는 경로가 이미 트리 전체 빌드 시간의 80% 정도를 차지하거든요.
우리 배포판은 트리의 모든 릴리스 커밋에 대해 소스에서 독립적으로 검증된 100% 결정론적 빌드를 의무로 삼기 때문에, 릴리스마다 트리 전체를 여러 번 빌드해야 합니다.
초기 툴체인 부트스트랩에는 계속 mold2를 쓰고, 그 이후 모든 것을 컴파일하는 데 쓸 "최종" 툴체인 묶음의 일부로 mold3를 Rust로 빌드하면 안 되나요? 완벽하진 않지만, 재현 가능한 부트스트래핑 흐름에서 다른 많은 구성요소가 돌아가는 방식과 같습니다. GCC 10을 부트스트랩하려고 GCC 4.4 같은 걸 쓰는 것처럼요. 귀찮긴 해도 한 번만 하면 되고, 그다음부터는 그걸로 더 새로운 구성요소를 계속 부트스트랩하면 됩니다. 컴파일 시간 단축에는 도움이 안 되겠지만요.
Rust가 트리 전체 거의 모두에 의존한다는 사실만 아니었다면 동의했을 겁니다. 말씀하신 대로 하면 트리 전체 검증 시간이 6시간쯤에서 12시간쯤으로 늘어나서, 애초에 mold가 가져다준 이득이 전부 사라집니다.
맞아요. 제 전문 분야는 아니지만, 그냥 빌드 과정 앞쪽에서 LLD로 Rust를 빌드하고, 그다음 mold를 빌드하고, 나머지를 전부 빌드하면 안 되나요?
그러면 Rust 빌드만 느려지고 나머지는 그대로일 텐데요. 게다가 복잡한 다른 프로젝트의 포크를 유지보수하지 않아도 되고요.
Rust는 python, perl, musl, openssl을 필요로 하고, 트리 전체를 빌드하는 실제 소요 시간의 80%를 차지합니다.
Rust는 어느 리눅스 배포판에서든 부트스트랩하는 데 가장 비싼 것입니다.
그리고 속도를 높이기 위해 mold가 가장 필요한 것이 바로 Rust입니다.
누군가 Rust 자체를 빌드하는 데 필요한 요구 조건을 줄이고 부트스트래핑을 쉽게 만들어 주면 좋겠네요.
저도 그렇게 되길 바랍니다. 이 부분에서 본받을 최고 기준은 Go입니다. 「Reflections on Trusting Trust」를 쓴 켄 톰프슨이 관여한 걸 생각하면 놀랍지도 않죠.
안타깝게도 Rust는 자기들 스스로도 전체 소스 부트스트랩이 되는 결정론적 빌드를 하지 않습니다. 릴리스 전략이 전적으로 신용에 맡기는 방식이에요. 한 대의 컴퓨터나 한 사람이 절대 침해당해 rustup으로 모두가 내려받는 바이너리에 악성코드가 주입되는 일은 없을 거라는 그들의 철석같은 약속을 다들 믿는 거죠.
오늘날 우리가 쓰는 이 어이없이 긴 부트스트랩 경로가 존재하는 유일한 이유는 취미로 작업하는 독립 해커 mutabah의 노고 덕분입니다.
Rust 팀은 메모리 안전성만 중요한 보안 문제로 여기고 공급망 공격은 범위 밖으로 보는 것 같습니다.
mold가 더 빠르니까요. 대규모 프로젝트를 빌드할 때는 링킹이 상당한 병목입니다.
근데 지금은 Rust 부트스트랩에 대해서만 얘기하고 있잖아요.
문제 제기는 부트스트래핑에 대한 거고, 거기서 속도는 별로 중요하지 않습니다.
우리에게는 속도가 극도로 중요합니다. Rust의 의존성 중 하나라도 바꿀 때마다 다시 부트스트랩해야 하는데, Rust는 툴체인 트리에서 빌드 비용이 큰 거의 모든 것에 의존하거든요.
지난번 재빌드에서 나온 mold 바이너리를 쓰고, 새 바이너리가 달라졌을 때만 다시 빌드하면 안 되나요?
요즘 Rust 부트스트래핑에 대해 생각해 보고 있었는데요. 빌림 검사기(borrow checker)가 없는 Rust 컴파일러는 비교적 쉽게 만들 수 있지 않을까요?
소스 코드에 문제가 없다고 가정하면(Rust 컴파일러가 빌드된 뒤에 나중에 검사할 수 있으니까요), 그 부분은 빼 두고 .rs에서 실행 코드까지 가는 가장 짧은 경로(C 변환이든 인터프리터든)를 택할 수 있을 겁니다.
이미 있습니다. https://github.com/thepowersgang/mrustc 입니다.
재현 가능하고 밀폐된 빌드에 좋은 캐싱 계층까지 갖추면 도움이 될 것 같은데요. 그러면 같은 작업을 계속 반복하지 않아도 되잖아요.
그러면 가장 최근에 빌드한 버전으로 mold와 다음 버전의 Rust를 빌드하면 되고요.
우리 트리는 완전히 재현 가능하고 밀폐되어 있습니다. 사실 이걸 100% 하는 배포판은 우리뿐입니다.
문제는 Rust의 의존성 중 무엇이든, 즉 python이든 perl이든 musl이든 openssl이든 LLVM 스택이든 LLVM 스택의 의존성이든 바꾸면 Rust의 해시가 달라질 수 있다는 점입니다.
의존성이 하나라도 바뀌면 그에 딸린 것을 전부 다시 빌드해야 하고, 우리는 그걸 아주 자주 해야 합니다. 바로 그 지점에서 mold가 시간을 엄청나게 아껴 줬습니다.
그래서 최소한 stage2 컴파일러까지 빌드하고, stage0는 가장 최근에 미리 빌드해 둔 것을 쓰는 겁니다.
stage1은 stage0가 어떤 버전이든 그 버전을 기반으로 하겠지만, stage2는 다른 어떤 stage0로 빌드한 것과도 동일해야 합니다. 그러면 전체 체인을 매번 다시 빌드하지 않아도 됩니다.
그리고 가능하면 FDO를 적용해 stage3까지 빌드하세요. 괜찮은 테스트 코퍼스만 있으면 속도를 20% 더 끌어올리기도 쉽습니다.
재빌드할 때마다 시간을 아끼려고 sccache 같은 걸 쓰는 분산 캐시라도 갖추고 있나요?
stage3에 Rust를 넣으면, 사람들이 stage3를 재현하고 검증하는 데 걸리는 시간이 시중에서 가장 빠른 192코어 CPU로도 6시간 늘어납니다. 일반 소비자용 16코어 CPU를 쓰는 사람이라면 며칠이 더 걸리고요.
우리 목표는 사람들이 트리 전체를 소스에서 가능한 한 가장 짧은 시간에 재현할 수 있게 해서, 아무도 우리를 믿지 않아도 되게 하고, 그래서 가능한 한 많은 사람이 직접 해 보도록 장려하고, 그렇게 해서 우리가 아무도 모르게 공급망 공격을 주입할 수단을 갖지 못하게 하는 것입니다.
신규 사용자가 스스로를 부트스트랩하고, 이후 모든 릴리스를 부트스트랩하고 서명하는 서버를 원격 증명(remote attestation) 증거와 함께 돌릴 수 있게 하는 것이 말 그대로 목표 중 하나입니다. 그 첫 빌드 구간이 짧을수록 좋고, 그래서 초기 부트스트랩이 가능한 언어로 작성된 빠른 링커 같은 것이 그렇게 중요합니다.
그 배포판에서 Rust 빌드는 다 합쳐 얼마나 걸리나요? 컴파일러가 20% 빠르다면(제 LLVM Clang 결과 기준입니다) 본전을 찾는 데 "고작" 빌드 시간 30시간이면 되고, 거기서(배포판 전체는 아니더라도) 돌아가는 Rust 빌드가 충분히 많을 테니 해 볼 만하다고 확신합니다.
어쨌든 사람들이 stage2나 stage3 바이너리를 쓰더라도 결국 같은 바이너리를 얻게 됩니다. 호환되어야 하는 같은 패키지의 대체 버전일 뿐이니까요.
그리고 192코어 CPU를 쓰는 건 좀 부질없어 보입니다. 작업이 얼마나 병렬화되는지 살펴보셨나요? 빌드 하나에 192코어 머신을 통째로 묶어 두지 않고, 전체 플릿이 더 많은 코어를 쓸 수 있게 해 주는 괜찮은 원격 워커 솔루션이 있지 않나요? 지금 방식은 제 눈에는 효율적으로 보이지 않습니다.
필요성이 충분히 타당하다면 #[no_mangle]이나 extern C 지원을 추가해 달라고 요청해 볼 만하지 않을까요? 릴리스가 아주 갓 나왔으니까요. 그게 문제의 원인이라면요. 아니면 어떤 의존성이 문제인가요?
여기서 필요한 건 부트스트래핑입니다. mold가 Rust로 쓰여 있으면 컴파일 자체를 할 수 없습니다.
Rust도 Rust로 쓰여 있잖아요. 그래도 아주 처음부터 완전히 부트스트랩하려는 거라면, 무슨 문제인지는 알겠습니다.