Mojo의 목표와 설계 철학을 소개하고, Rust와의 차이점—기본 차용(borrow) 의미론, Pin 없이도 안전한 이동, MLIR 기반 컴파일러 이점, SIMD 친화적 사용성, 조기 소멸, 꼬리 호출 최적화 등—을 예제와 벤치마크로 설명합니다.
Mojo는 MLIR 컴파일러 기술 위에 구축되어 있습니다. 이는 Rust가 낮춰가는 LLVM의 진화형입니다. 이를 통해 개발자는 서로 다른 CPU 아키텍처에 최적화된 코드를 작성하고, 동일한 인체공학적(ergonomic) 프로그래밍 모델로 네이티브 GPU 커널을 컴파일하고 실행할 수 있습니다. Mojo의 언어적 목표는 Python 개발자들이 익숙한 환경에서 시작할 수 있게 하면서, 하드웨어의 물리적 한계까지 성능을 끌어올릴 수 있도록 코드를 최적화하는 새로운 요령을 배우게 하는 것입니다. Mojo의 가장 큰 장점은 Rust 같은 언어의 인체공학 및 메모리 안전성 발전을 흡수하여, GPU 프로그래밍에도 검증된 개념을 그대로 적용할 수 있게 한다는 점입니다.
Discord에 처음 참여하는 사용자가 흔히 묻는 질문은 “Mojo는 x 언어보다 얼마나 빠른가요?”입니다. 벤치마크 구현에는 고려할 점이 많기 때문에, 단 하나의 벤치마크로 “x 언어가 y 언어보다 더 빠르다”고 단정할 수는 없습니다. 더 나은 질문은 “Mojo가 x와 비교해 얼마나 적은 오버헤드를 도입하나요?”입니다. Mojo의 주요 목표 중 하나는 Python 개발자에게 친숙한 사용성을 유지하면서도, 하드웨어를 물리 한계까지 밀어붙일 수 있게 하는 것입니다.
Python 같은 동적 언어와 달리, 컴파일된 언어는 힙 객체 할당, 참조 카운팅, 주기적 가비지 컬렉션 같은 불필요한 CPU 명령을 제거할 수 있습니다. Mojo는 C++, Rust, Swift, Zig 같은 언어에서 얻은 교훈과 모범 사례를 바탕으로, 이러한 오버헤드 없이 머신에 직접 접근할 수 있게 합니다.
Mojo와 Rust 모두 저수준의 최적화를 가능하게 하지만, 예를 들어 Rust에서는 차용 검사기(borrow checker)와 씨름하지 않기 위해 모든 것을 Arc, Mutex, Box 등으로 감쌀 수 있습니다. 애플리케이션 코드를 작성할 때는 큰 영향이 없을 수 있지만, 라이브러리나 성능 민감 코드에서는 그 오버헤드가 빠르게 누적될 수 있습니다. 오버헤드를 얼마나 줄이고 성능을 얼마나 최적화할지는 전적으로 프로그래머의 선택입니다.
Mojo는 사용성과 성능의 균형을 높이기 위해 다음과 같은 설계 결정을 내렸습니다.
Rust를 처음 배우는 사용자가 맞닥뜨리는 초기 함정 중 하나는, 함수 인자가 기본적으로 객체를 이동(move)하여 받는다는 것입니다. 즉, 어떤 값을 함수에 전달한 뒤 다시 사용하려 하면 컴파일러 오류가 납니다:
Rust
fn bar(foo: String){
println!("{foo}");
}
fn main(){
let foo = String::from("bar");
bar(foo);
dbg!(foo);
}
출력
5 | let foo = String::from("bar");
| --- move occurs because `foo` has type `String`, which does not implement the `Copy` trait
6 | bar(foo);
| --- value moved here
7 | dbg!(foo);
| ^^^^^^^^^ value used here after move
_dbg!_가 있는 줄에서 컴파일 오류가 발생합니다. _foo_를 bar 함수로 이동시켰기 때문에 다시 사용할 수 없습니다. Rust에서 move는 _String_의 포인터, 크기(size), 용량(capacity)에 대한 memcpy가 수행됨을 의미할 수도 있습니다. 이 memcpy는 많은 경우 LLVM이 최적화로 없애 주지만, 항상 그런 것은 아니며 Rust/LLVM 컴파일러 동작을 잘 알지 못하면 예측하기 어렵습니다.
이를 해결하는 방법은 _foo_를 &str 또는 _String_이 자동 역참조할 수 있는 유사 차용 타입으로 만드는 것입니다:
Rust
fn bar(foo: &str){
println!("{foo}");
}
fn main(){
let foo = String::from("foo");
bar(&foo);
dbg!(foo);
}
출력
foo
Mojo는 표준 사용 사례에 대해 이 개념을 단순화합니다:
Mojo
# foo는 기본적으로 변경 불가능한 참조입니다
fn bar(foo: String):
pass
fn main():
var foo = String("foo")
bar(foo)
print(foo)
출력
foo
Mojo의 인자는 기본적으로 차용됩니다. 이는 Rust에 비해 학습 시 훨씬 더 친절할 뿐 아니라, 묵시적 memcpy 가능성이 전혀 없기 때문에 더 효율적입니다. Rust에 가까운 동작을 원한다면, 인자를 _owned_로 바꿀 수 있습니다:
Mojo
fn bar(owned foo: String):
foo += "bar"
fn main():
var foo = String("foo")
bar(foo)
print(foo)
출력
foo
이 코드는 여전히 동작합니다! _String_이 복사 생성자를 구현하고 있기 때문에, _bar_로 이동시키면서 뒤에 사본을 남길 수 있습니다. 내부적으로는 최대 효율을 위해 여전히 참조로 전달하며, _foo_가 변경될 때에만 복사를 수행합니다.
Rust의 기본처럼 객체를 이동시키고 소유권을 잃는 동작에 완전히 동의하려면, ^ 전달(transfer) 연산자를 사용해야 합니다:
Mojo
fn bar(owned foo: String):
foo += "bar" # 고유 소유 값은 변경해도 괜찮습니다
fn main():
var foo = String("foo")
bar(foo^)
print(foo) # 오류: 위에서 전달되어 foo는 초기화 해제 상태(uninit)
이제 이동 후 _foo_를 사용하려 하면 마침내 컴파일러 오류가 발생합니다. Mojo에서는 차용 검사기와 싸우려면 훨씬 더 많은 노력을 해야 합니다! 이는 더 나은 기본 동작입니다. 더 효율적일 뿐 아니라, Python 같은 동적 프로그래밍 배경을 가진 엔지니어들이 막히지 않습니다. 기본값으로 기대한 동작을 얻으면서 가능한 최상의 성능을 확보합니다.
Rust에서 자기 참조(self-referential) 구조체가 자신의 멤버를 가리키는 경우, 객체가 이동하면 그 데이터가 무효화될 수 있습니다. 메모리의 예전 위치를 가리키게 되기 때문입니다. 이는 특히 비동기 Rust의 일부 영역에서 복잡성을 급격히 높입니다. future가 상태를 저장하기 위해 자기 참조적이어야 하므로, 이동되지 않도록 보장하기 위해 _Self_를 _Pin_으로 감싸야 합니다. Mojo에서는 주소를 가진 객체를 이동하더라도 자기 참조 필드를 업데이트할 수 있습니다. 따라서 비동기 컨텍스트에서도 _self.foo_는 여전히 메모리의 올바른 객체 위치를 가리킵니다.
_Pin_의 함의를 따라가는 여정을 담은 멋진 블로그 글 pin and suffering이 있습니다. 이는 모지션(Mojician) 🪄이 결코 마주치지 않을 복잡성입니다.
업데이트: 핵심 Rust async 기여자 중 한 분이 _Pin_이 어떻게 탄생했는지: https://without.boats/blog/pin, 그리고 이를 어떻게 개선하려는지: https://without.boats/blog/pinned-places 에서 설명합니다.
Rust는 2006년에 시작되었고 Swift는 2010년에 시작되었으며, 두 언어 모두 주로 LLVM IR 위에 구축되었습니다. Mojo는 2022년에 시작되었고 MLIR 위에 구축되었는데, 이는 Rust가 사용하는 LLVM IR 접근법보다 더 현대적인 “차세대” 컴파일러 스택입니다. 여기에는 사연이 있습니다. 우리 CEO Chris Lattner는 2000년 12월 대학 시절에 LLVM을 시작했고, 그 진화와 개발을 거치며 많은 것을 배웠습니다. 이후 그는 Google에서 TPU 및 기타 AI 가속기 프로젝트를 지원하기 위해 MLIR 개발을 주도했으며, LLVM IR에서 얻은 교훈을 바탕으로 다음 단계를 만들었습니다. 이에 대해서는 2019년 발표에 자세히 설명되어 있습니다.
Mojo는 MLIR의 모든 발전을 활용하는 첫 번째 프로그래밍 언어입니다. 더 최적화된 CPU 코드 생성을 제공할 뿐 아니라, GPU 및 기타 가속기를 지원하고, Rust보다 훨씬 빠른 컴파일 시간도 제공합니다. 이는 현재 다른 어떤 언어도 제공하지 못하는 이점이며, AI와 컴파일러 애호가들이 Mojo 🔥에 열광하는 이유입니다. 이들은 이국적인 하드웨어를 위한 멋진 추상화를 구축할 수 있고, 그보다 덜 전문화된 엔지니어들도 Pythonic한 문법으로 이를 활용할 수 있습니다.
CPU에는 SIMD(Single Instruction, Multiple Data)로 알려진, 동시에 여러 데이터 비트를 처리하는 특수 레지스터와 명령이 있습니다. 하지만 이 코드를 작성하는 경험은 역사적으로 매우 난해하고 사용하기 어려웠습니다. 이러한 특수 명령은 수년 전부터 존재했지만, 대부분의 코드는 여전히 이를 활용해 최적화되어 있지 않습니다. 누군가 복잡성을 이겨내고 이식 가능한 SIMD 최적화 알고리즘을 작성하면, 예컨대 simd_json처럼 경쟁자를 압도합니다.
Mojo의 프리미티브는 태생적으로 SIMD 우선(SIMD-first)으로 설계되었습니다. _UInt8_은 사실상 SIMD[DType.uint8, 1] 즉, 원소 1개의 SIMD입니다. 이렇게 표현하더라도 성능 오버헤드는 없지만, 프로그래머가 SIMD 최적화를 손쉽게 활용할 수 있게 해줍니다. 예를 들어 텍스트를 64바이트 블록으로 나눠 _SIMD[DType.uint8, 64]_로 표현한 뒤, 개행 문자 하나와 비교하여 모든 개행의 인덱스를 찾을 수 있습니다. 머신의 SIMD 레지스터가 한 번에 512비트 데이터를 연산할 수 있으므로, 이러한 연산의 성능이 64배 향상됩니다!
더 단순한 예로 _SIMD[DType.float64, 8](2, 4, 6, 8, 16, 32, 64, 128)_이 있다면, _Float64(2)_를 곱해 주기만 해도 대다수 머신에서 각 원소를 개별적으로 곱하는 것보다 8배 빠르게 동작합니다.
LLVM(따라서 Rust)에도 자동 벡터화 최적화 패스가 있지만, 프로그래머가 의도를 정확히 표현했을 때의 성능 수준에는 결코 도달할 수 없습니다. LLVM은 SIMD를 위해 메모리 레이아웃이나 그 밖의 중요한 세부 사항을 바꿀 수 없기 때문입니다. Mojo는 처음부터 SIMD를 적극 활용하도록 설계되었으며, SIMD 최적화 코드는 일반 코드와 매우 유사한 느낌으로 작성할 수 있습니다.
Rust는 C++의 RAII(Resource Acquisition is Initialization)에서 영감을 받았습니다. 이는 객체가 스코프를 벗어나면 애플리케이션 개발자가 메모리 해제를 걱정할 필요가 없고, 언어가 이를 처리한다는 뜻입니다. 이는 매우 훌륭한 패러다임으로, 가비지 컬렉터의 성능 저하 없이 동적 언어의 사용성을 얻을 수 있습니다.
Mojo는 여기서 한 발 더 나아갑니다. 스코프의 끝을 기다리지 않고, 객체가 마지막으로 사용되는 시점에 메모리를 해제합니다. 이는 AI 분야에서 특히 유리합니다. 객체를 더 일찍 해제하면 GPU 텐서를 더 일찍 할당 해제할 수 있어, 더 큰 모델을 GPU RAM에 적재할 수 있기 때문입니다. 이는 Mojo만의 독특한 장점으로, 프로그래머가 신경 쓰지 않아도 최선의 결과를 얻습니다. Rust의 차용 검사기는 원래 소멸자 동작과 맞추기 위해 모든 것의 수명을 스코프 끝까지 연장했는데, 이는 사용자에게 다소 혼란스러운 결과를 낳았습니다. Rust는 비어휘 수명(Non-Lexical Lifetimes)으로 이를 단순화했습니다. Mojo는 조기 소멸 덕분에 이러한 단순화를 자연스럽게 얻으며, 실제로 객체가 파괴되는 방식과 일치하므로 혼란스러운 엣지 케이스가 없습니다.
또 다른 오버헤드는 Rust에서 _Drop_이 동작하는 방식입니다. Rust는 런타임에 객체를 드롭해야 하는지 추적하기 위해 드롭 플래그(Drop Flags)를 사용합니다. Rust는 경우에 따라 이를 최적화로 없앨 수 있지만, Mojo는 원천적으로 이를 정의에서 배제하여 모든 경우에 오버헤드를 제거합니다.
Mojo는 조기 소멸을 지원하므로, MLIR과 LLVM이 꼬리 호출 최적화를 더 효과적으로 수행할 수 있습니다. 아래 예시는 두 언어에서 힙에 할당된 동적 벡터를 사용하는 재귀 함수를 비교합니다. 차이를 보여 주기 위해 가능한 최소한의 코드 줄로 구성한 단순 예시입니다.
먼저 _cargo new rust_를 실행하고 _./rust/src/main.rs_를 다음과 같이 수정하세요:
./rust/src/main.rs
fn recursive(x: usize){
if x == 0 {
return;
}
let mut stuff = Vec::with_capacity(x);
for i in 0..x {
stuff.push(i);
}
recursive(x - 1)
}
fn main() {
recursive(50_000);
}
그런 다음 다음을 실행합니다:
Bash
cd rust
cargo build --release
cd target/release
hyperfine ./rust
이 결과는 M2 Mac에서의 측정입니다:
출력
Benchmark 1: ./rust
Time (mean ± σ): 2.119 s ± 0.031 s [User: 1.183 s, System: 0.785 s]
Range (min … max): 2.081 s … 2.172 s 10 runs
같은 폴더에 단일 파일로 Mojo 버전을 만들어 _mojo.mojo_라고 저장할 수 있습니다:
./mojo.mojo
fn recursive(x: Int):
if x == 0:
return
var stuff = List[Int](x)
for i in range(x):
stuff.append(i)
recursive(x - 1)
fn main():
recursive(50_000)
그런 다음 다음을 실행합니다:
Bash
mojo build mojo.mojo
hyperfine ./mojo
컴파일러는 적절한 시점에 소멸자를 호출해야 하며, Rust에서는 값이 스코프를 벗어날 때 호출됩니다. 재귀 함수에서 _Vec_에는 각 함수 호출 후 실행해야 하는 소멸자가 있습니다. 이는 꼬리 호출 최적화를 위해 요구되는 방식대로 함수의 스택 프레임을 버리거나 덮어쓸 수 없음을 의미합니다. Mojo는 조기 소멸을 수행하므로 이러한 제약을 받지 않고, 힙에 할당된 객체가 있어도 TCO를 더 효율적으로 적용할 수 있습니다.
_valgrind --tool=massif_로 두 프로그램을 프로파일링하면 이러한 동작을 더 잘 이해할 수 있습니다. 이 실험을 위해 Linux 클라우드 인스턴스로 옮겼고, 그 결과 Rust의 평균 시간이 9.067초(최대 할당 메모리 10 GB), Mojo는 1.189초(최대 할당 메모리 1.5 MB)였습니다! 앞서 언급했듯 AI 애플리케이션에서 메모리는 중요한 자원이며, 조기 소멸은 프로그래머가 고민하지 않아도 최적의 동작을 보장합니다.
위의 벤치마크를 직접 실행해 보세요. 아직 Mojo 🔥가 없다면, 여기에서 설치할 수 있습니다!
Modular는 모두 Rust를 사랑하고 많은 영감을 받았습니다. Rust의 툴링은 훌륭하고, 현재 시스템 프로그래밍 언어 중에서도 상위권의 높은 사용성을 자랑합니다. 하지만 AI 분야에서는 느린 컴파일 시간, 그리고 연구자들이 훨씬 어려운 언어를 처음부터 배울 유인이 부족하다는 문제점이 있습니다. 우리는 Python/C++/Rust/Swift/Julia 등도 사랑하지만, 업계가 10년 넘게 이 기술들을 개선해 왔음에도, Mojo가 상징하는 새로운 출발만이 오랜 문제들에 실질적 변화를 줄 수 있다고 믿습니다.
Mojo는 이미 시스템 엔지니어에게 최적의 성능을 제공합니다. 동시에 Python 프로그래머가 기대하는 모든 동적 기능을 구현해 나가고 있습니다. GPU 프로그래밍의 메모리 안전성, 혹은 메모리 안전성과 고성능을 더 쉽게 누릴 수 있는 언어가 궁금하다면 Mojo를 여기서 직접 사용해 보세요. 저희는 또한 Mojo의 가능성을 세상에 보여 주는 킬러 앱으로 MAX도 만들었습니다!
Mojo 커뮤니티에서 여러분을 만나고 싶습니다. 시작을 위한 링크는 다음과 같습니다: