C vs Rust vs Mojo, 이긴 쪽은 셋 다 쓰는 LLVM
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
error: 'fn' has been removed; use 'def' instead
Mojo 를 설치하고 처음 받은 문장입니다. 제가 쓴 코드는 fn main(): 한 줄이었습니다.
Mojo 얘기를 듣고 확인해보기로 한 게 어제였습니다. C보다 빠르고 Rust 만큼 안전하다는 소개를 봤고, 만든 사람이 Chris Lattner 라는 것까지 듣고 나니 그냥 넘기기가 어려웠습니다. LLVM 을 만든 사람이 새 언어를 만들었다는 건 그 자체로 근거가 되는 이력입니다. 2023년 5월에 공개됐고 1.0 은 올해 8월 11일에 나왔습니다. 3년 3개월이 걸린 셈입니다.
그래서 속도를 재보려고 설치했는데 hello world 가 컴파일되지 않았습니다. fn 은 지난 3년간 이 언어를 상징한 키워드입니다. Mojo 를 소개하는 글이든 튜토리얼이든 대부분 fn main(): 으로 시작하는데 그게 1.0 에서 없어졌습니다. 왜 없앴는지는 changelog 를 다 훑어봤는데 찾지 못했습니다.
일단 def 로 바꾸고 속도부터 쟀습니다.

mandelbrot 을 네 언어로 돌리니 C·Rust·Mojo 가 나란히 멈췄습니다
같은 알고리즘을 C, Rust, Mojo, CPython 네 개로 썼습니다. mandelbrot 집합을 1000×1000 격자에서 최대 1000회까지 반복하는 코드입니다. Modular 가 자기 벤치마크로 쓴 것과 같은 문제인데, SIMD 도 병렬화도 없는 스칼라 단일 스레드로 통일했습니다. 언어를 바꿔서 얻는 것만 보고 싶었습니다.
Mojo 쪽 반복문은 이렇게 생겼습니다.
def mandel(c_re: Float64, c_im: Float64, max_iter: Int) -> Int:
var z_re: Float64 = 0.0
var z_im: Float64 = 0.0
var n: Int = 0
while n < max_iter:
var re2 = z_re * z_re
var im2 = z_im * z_im
if re2 + im2 > 4.0:
break
z_im = 2.0 * z_re * z_im + c_im
z_re = re2 - im2 + c_re
n += 1
return n
C 는 clang -O2, Rust 는 rustc -O, Mojo 는 mojo build -O3 로 빌드했습니다. 각각 다섯 번씩 돌린 결과가 이렇습니다.
m_c : 0.33 0.32 0.32 0.32 0.32
m_rs : 0.33 0.33 0.33 0.33 0.33
m_mojo : 0.32 0.32 0.32 0.32 0.32
C 가 0.32초, Rust 가 0.33초, Mojo 가 0.32초입니다. 편차가 거의 없어서 다섯 번 돌린 값이 그대로 반복됐습니다. CPython 3.12.5 로 같은 코드를 돌리면 8.90초가 나옵니다.
컴파일 언어 셋 사이에는 차이가 없고 파이썬 대비로는 28배입니다. Modular 마케팅에서 본 35,000배와는 세 자리가 다릅니다. 그쪽 코드에는 SIMD 와 병렬화, 타입 특수화가 다 들어가 있었을 겁니다. 그건 언어를 바꿔서 얻은 게 아니라 벡터 연산과 코어를 더 쓴 결과입니다.
빌드 시간도 재봤는데 mojo build 가 0.905초였습니다. 산출물은 38K 입니다. 같은 프로그램이 C 에서 33K, Rust 에서 431K 로 나왔습니다.
처음 세운 질문에는 답이 나왔습니다. C보다 빠르지는 않고 C만큼 빠릅니다. 그런데 답을 얻고 나니 다른 게 걸렸습니다.
checksum 이 갈린 원인은 fmadd 하나였습니다
네 언어가 같은 계산을 하는지 보려고 반복 횟수 총합을 출력하게 해뒀습니다. 그 값이 갈렸습니다.
C (기본) : checksum: 172812500
Rust : checksum: 172812923
Mojo : checksum: 172812500
CPython : checksum: 172812923
Mojo 와 C 가 172812500, Rust 와 CPython 이 172812923 입니다. 423 차이입니다. 같은 알고리즘인데 답이 두 갈래로 나왔고, 컴파일 언어 중 파이썬과 같은 값을 낸 건 Rust 뿐이었습니다.
부동소수점이니 연산 순서 문제일 것 같았습니다. 코드에 2.0 * z_re * z_im + c_im 이 있는데, 곱셈과 덧셈을 하나로 합치는 fused multiply-add 를 쓰면 중간 반올림이 한 번 사라집니다. 그러면 경계에 있는 픽셀에서 발산 판정이 걸리는 반복 횟수가 하나씩 어긋납니다.
확인은 간단했습니다. C 를 -ffp-contract=off 로 다시 빌드하면 됩니다.
C (-ffp-contract=off) : checksum: 172812923
C (기본) : checksum: 172812500
Rust 값과 정확히 일치합니다. 어셈블리에서 fmadd 명령을 세보니 C 기본 빌드에 한 개, off 빌드에 0개, Rust 에 0개, Mojo 에 한 개였습니다.
그래서 Mojo 는 clang 의 기본값 쪽에 서 있습니다. 부동소수점 결합을 허용해서 정확도를 조금 내주고 명령 하나를 아끼는 선택입니다. Rust 는 반대편에서 소스에 적힌 연산 순서를 지킵니다.
재미있는 건 그 선택이 속도를 바꾸지 않았다는 겁니다. -ffp-contract=off 로 빌드한 C 도 0.33초였습니다. 명령 하나 차이는 이 코드에서 측정 가능한 이득이 아니었습니다. 그런데 답은 달라졌습니다.
벤치마크 글에서 「같은 알고리즘」이라는 말을 그냥 넘기곤 했는데, 같은 알고리즘이 같은 계산은 아니었습니다. checksum 을 찍어뒀으니 눈에 보였고 안 찍었으면 몰랐을 겁니다.
mojo 바이너리에서 Mojo 심볼을 찾지 못했습니다
세 언어가 왜 같은 속도를 냈는지가 남았습니다. clang 은 LLVM 자체이고 rustc 는 LLVM 을 백엔드로 씁니다. Mojo 도 MLIR 위에 서 있다고 알려져 있으니 결국 세 언어의 기계어를 같은 최적화기가 만듭니다. 확인해보려고 설치된 컴파일러를 열었습니다.
.venv/bin/mojo 는 파이썬 래퍼 셸 스크립트였고 실물은 다른 자리에 있었습니다.
.venv/lib/python3.12/site-packages/modular/bin/mojo
Mach-O 64-bit executable arm64, 118M
여기에 LLVM 관련 문자열이 21,680개, MLIR 관련 문자열이 2,481개 들어 있습니다. 버전은 LLVM 24.0.0git 입니다. 최적화 패스 이름도 그대로 박혀 있어서 instcombine, licm, loop-unroll, loop-vectorize, slp-vectorizer, sroa 를 확인했습니다. 전부 LLVM 의 패스 이름입니다.
그런데 심볼을 세다가 예상 못 한 걸 봤습니다.
심볼 총계 : 585
C++ 맹글링(_Z) 심볼 : 191
Mojo 맹글링($) 심볼 : 0
Mojo 로 쓴 코드의 흔적이 없습니다. 남아 있는 191개는 전부 C++ 이름이고, 풀어보면 이런 것들입니다.
M::getGlobalContextMutex()
M::setCurrentMaxContextPointer(M::Context*)
M::AsyncRT::TCMallocGlobals::tc_new(unsigned long, unsigned long)
M::AsyncRT::getVirtualDeviceAPI()
opentelemetry::v1::context::RuntimeContext::GetStorage()::context
링크된 라이브러리 목록 맨 앞에 /usr/lib/libc++.1.dylib 이 있습니다. C++ 표준 라이브러리입니다. Mojo 로만 쓰인 프로그램이라면 필요 없는 의존입니다.
한 가지는 짚어두겠습니다. 이 바이너리는 심볼을 지운 릴리스 빌드입니다. 118MB 에 심볼이 585개뿐인 게 그 증거입니다. 그래서 「Mojo 심볼 0개」는 결정적 증거가 아니라 정황이고, 지워졌을 수도 있습니다.
그게 걸려서 대조군을 만들었습니다. 앞에서 고쳐 쓴 hello world 를 mojo build 로 빌드하면 38K 바이너리가 나옵니다. 여기 심볼을 세보니 Mojo 맹글링이 7개 있었습니다.
::SIMDLength(1)],::SourceLocation)
scalar<index>],stack_buffer_bytes=4096,string.mut`2x1=false
scalar<index>]]
Mojo 컴파일러가 만든 바이너리에는 Mojo 이름이 남습니다. 38K 짜리에도 일곱 개가 남았는데 118MB 본체에는 0개입니다. 스트립을 감안하더라도 이 대비는 한쪽으로 기울어 있습니다.
librustc_driver 에는 Rust 심볼이 그대로 남아 있습니다
같은 방법을 rustc 에 써봤습니다. 이 컴퓨터에 있는 게 1.89.0 입니다.
처음에 which rustc 로 나온 경로를 재는 실수를 했습니다. 그건 rustup 프록시였습니다. 실제 컴파일러는 toolchain 디렉터리 안에 있고 본체는 dylib 쪽입니다.
librustc_driver-05edb5a3925fcee9.dylib 177M
Rust 맹글링 심볼 : 3,425
C++ 맹글링 심볼 : 98,098
Rust 심볼이 3,425개인데 C++ 심볼이 98,098개입니다. 처음 이 숫자를 봤을 때는 제 논지가 뒤집히는 줄 알았습니다. rustc 도 C++ 이 압도적으로 많으니까요.
그런데 그 C++ 이 무엇인지를 보면 얘기가 달라집니다. rustc 가 링크한 LLVM 이 C++ 로 쓰인 코드입니다. rustc 는 파서와 타입 검사기, 빌림 검사기를 Rust 로 쓰고 기계어 생성은 LLVM 에 넘깁니다.
Go 도 재봤습니다. 1.26.7 의 compile 은 23MB 이고 cmd/compile 경로를 담은 심볼이 19,348개 있습니다. Go 는 LLVM 을 아예 쓰지 않습니다.
그러니까 세 컴파일러가 각자 다른 자리에 있습니다. Go 는 컴파일러 전체가 Go 이고, rustc 는 프론트엔드가 Rust 이고 백엔드는 C++ LLVM 입니다. Mojo 는 프론트엔드도 C++ 입니다. 자기 언어로 쓰인 부분이 아직 없습니다.
셀프 호스팅을 언어의 통과 의례처럼 여기는 이유가 여기 있습니다. 자기 언어로 자기 컴파일러를 쓰려면 그 언어가 10만 줄 규모의 실제 프로그램을 감당해야 하고, 감당한다는 걸 보여주는 방법 중에 이만큼 확실한 게 별로 없습니다. Rust 는 1.0 전에 했고 Go 는 1.5 에서 했습니다.
소스를 볼 수도 없습니다. PyPI 패키지의 라이선스가 LicenseRef-MAX-Platform-Software-License 이고, 오픈소스로 공개된 건 표준 라이브러리와 MAX 쪽입니다. Modular 는 컴파일러에 대해 "We aren't accepting contributions to the Mojo compiler yet" 이라고 적어뒀습니다. 그래서 제가 잰 심볼이 정황에 그칩니다. 소스가 열려 있으면 셀 필요도 없는 일입니다.
Mojo 소스에 __mlir_op 를 그대로 적을 수 있습니다
여기까지 재고 나서 이걸 약점으로 볼 수 있나 싶어졌습니다. Mojo 소스 파일에 MLIR 연산을 직접 적어봤습니다.
def main():
var x = __mlir_op.`index.constant`[value = __mlir_attr.`7 : index`]()
print(Int(x))
Int(x) 변환에서 에러가 났는데, __mlir_op.index.constant 와 __mlir_attr 자체는 파서를 통과했습니다. Mojo 는 MLIR 을 감추지 않습니다. __mlir_op 와 __mlir_attr 이 언어 문법에 들어 있어서 컴파일러 중간 표현을 소스에 직접 적을 수 있습니다.
이게 우연이 아닐 겁니다. MLIR 은 Chris Lattner 가 Google 에 있을 때 시작한 프로젝트이고, LLVM 도 같은 사람이 만들었습니다. Mojo 는 그 두 개 위에 얹은 프론트엔드로 설계된 언어라는 뜻입니다. 표준 라이브러리의 SIMD 도 이 통로로 하드웨어 벡터 타입에 직접 닿습니다. GPU 커널을 같은 언어로 쓰겠다는 목표를 생각하면 MLIR 을 감추는 게 오히려 손해입니다.
그렇게 보면 컴파일러가 C++ 인 것도 이상한 선택이 아닙니다. 얹으려는 기반이 C++ 로 쓰인 MLIR 인데, 그걸 쓰려면 C++ 로 쓰는 게 제일 빠릅니다. 3년 만에 1.0 까지 온 속도도 그 선택에서 나왔을 겁니다.
다만 그러면 성능 주장의 주어가 바뀝니다. Mojo 가 C만큼 빠른 건 Mojo 가 잘 만들어서가 아니고, C 를 빠르게 만드는 그 최적화기를 같이 쓰기 때문입니다. 제 mandelbrot 루프를 처리한 패스는 clang 이 쓰는 것과 같은 코드입니다.
바이너리를 뒤지다가 https://telemetry.modular.com:443 도 봤습니다. opentelemetry 심볼이 링크돼 있던 이유입니다. 무엇을 보내는지는 확인하지 않았습니다.
SIMD 를 손으로 넣은 Mojo 는 세 언어 밑으로 내려갔습니다
MLIR 통로로 하드웨어 벡터 타입에 닿는다고 썼는데, 그러면 그걸 써서 재보는 게 맞습니다. 앞의 mandelbrot 은 스칼라로 통일해뒀으니 같은 문제를 벡터로 다시 썼습니다.
폭부터 확인했습니다.
f64 SIMD width: 2
arm64 의 NEON 이 128비트라서 배정도 실수 두 개가 한 번에 들어갑니다. 픽셀을 두 개씩 묶으면 이론상 두 배까지 기대할 수 있습니다.
그런데 벡터끼리 크기를 비교하려고 < 를 썼더니 거부됐습니다.
constraint failed: Strict inequality is only defined for `Scalar`s;
did you mean to use `SIMD.lt(...)`?
벡터에는 부등호를 못 씁니다. 요소별 비교는 lt 나 le 를 불러야 하고 결과는 요소마다 참과 거짓이 들어 있는 마스크입니다. 그 마스크로 select 를 걸어 아직 발산하지 않은 자리만 갱신하고, reduce_or 로 전부 발산했는지 보고 반복을 끊습니다.
var m = (r2 + i2).le(four)
if not m.reduce_or():
break
zi = m.select(2.0 * zr * zi + ci, zi)
zr = m.select(r2 - i2 + cr, zr)
n = m.select(n + one, n)
처음엔 불편했는데 쓰다 보니 이유가 보였습니다. 벡터 비교를 참과 거짓 하나로 뭉개면 어느 요소가 걸렸는지가 사라집니다. 그걸 조용히 해주지 않고 이름을 다르게 붙여서 손으로 고르게 만든 겁니다.
결과가 이렇게 나왔습니다.
m_mojo : 0.32 0.32 0.32 0.32 0.32
m_simd : 0.22 0.23 0.22 0.22 0.22
m_c : 0.32 0.31 0.32 0.32 0.32
0.32초에서 0.22초로 내려갔습니다. 1.45배입니다. checksum 은 172812500 으로 스칼라 Mojo 와 C 기본 빌드의 값과 정확히 같았습니다. 벡터로 다시 쓰면서 계산이 달라지지 않았다는 뜻입니다.
어셈블리도 봤습니다. NEON 에서 배정도 두 개를 다루는 명령에는 .2d 가 붙는데, SIMD 버전에 여섯 개 있고 스칼라 Mojo 와 C 에는 0개였습니다.
그러니까 clang -O2 는 이 루프를 벡터화하지 못했습니다. mandelbrot 은 픽셀마다 반복 횟수가 달라서 자동 벡터화기가 손대기 어려운 모양입니다. 벡터로 만들려면 마스크로 발산한 자리를 골라내야 하고, 그건 컴파일러가 알아서 해주는 범위를 넘습니다.
이론상 두 배인데 1.45배에 그친 이유는 파보지 않았습니다. reduce_or 로 매 반복마다 마스크를 검사하는 비용이 있고, 두 픽셀 중 하나가 먼저 발산해도 나머지가 남아 있으면 계속 돌아야 합니다.
C 에도 NEON 인트린식이 있고 같은 마스크 기법을 손으로 쓸 수 있습니다. 그러면 비슷한 이득이 날 텐데 그건 재보지 않았습니다.
그래서 이번에 잰 두 숫자가 각각 다른 얘기를 합니다. 언어를 C 에서 Mojo 로 바꿔서 얻은 건 0이었고, 그 언어가 주는 벡터 문법을 써서 얻은 건 1.45배였습니다. 아홉 줄짜리 반복문을 고치는 동안 헤더를 찾아본 적이 없습니다.
이 코드를 쓰면서 경고도 하나 받았습니다.
warning: 'alias' is deprecated; use 'comptime'
alias 도 이름이 바뀌는 중입니다.
1.0.0 changelog 전문에 fn 은 나오지 않습니다
거부당한 fn 으로 돌아가겠습니다.
컴파일러는 분명하게 말합니다. 'fn' has been removed. 그런데 1.0.0 changelog 를 내려받아 태그를 걷어내고 56,983자 전문을 검색했는데 fn 이 0회입니다. inout 도 0회, f-string 도 0회입니다. 3년간 이 언어의 얼굴이던 키워드가 사라졌는데 그 이야기가 릴리스 노트에 없습니다.
같은 changelog 에는 이런 문장이 있습니다.
most core language features are stable, meaning they won't be removed
or changed in ways that breaks source compatibility
이 문장 옆에서 fn 이 없어졌습니다. 어느 쪽이 맞는지를 따지려는 건 아닙니다. 아마 fn 은 이 문장이 말하는 core language feature 목록에 안 들어갔을 겁니다. 그런데 밖에서 보면 구분이 안 됩니다. 인터넷에 있는 Mojo 예제 코드는 대부분 fn 으로 시작하고, 그게 지금 전부 컴파일되지 않습니다.
없어진 게 fn 만은 아닙니다. 파이썬 코드를 그대로 넣어보려고 이런 걸 써봤습니다.
def main():
var lst = [10, 20, 30]
print(lst[-1])
constraint failed: negative indexing is not supported,
use e.g. `x[len(x) - 1]`
lst[-1] 이 컴파일 에러입니다. 표준 라이브러리 array.mojo 673번째 줄에 제약으로 박혀 있고, changelog 를 보니 1.0.0b1 에서 없앴습니다. 이해는 갑니다. 음수 인덱스를 처리하려면 길이를 알아야 하고, 모르는 자리에서는 런타임 검사가 붙습니다. 그 비용을 안 내기로 한 겁니다.
다만 파이썬 슈퍼셋을 목표로 한 언어에서 lst[-1] 이 안 된다는 건 목표를 한 칸 옮겼다는 뜻입니다. f-string 도 아직 없고 변수에는 var 를 붙여야 합니다. 제가 처음 쓴 여섯 줄짜리 파이썬 코드에서 그대로 통과한 건 for 반복문과 len() 정도였습니다.
소유권 표기도 통째로 바뀌었습니다. 키워드를 하나씩 넣어보면서 확인했는데, owned 와 inout, borrowed 는 파서에서 거부되고 ref, mut, read, out 이 통과합니다. out 은 초기화 검사까지 붙어서 값을 안 넣으면 use of uninitialized value 가 나옵니다. 그런데 changelog 에는 read 도 imm 으로 바꾼다고 적혀 있습니다. 타입 이름도 여럿 바뀌어서 InlineArray 가 Array 로, StringSlice 가 StringSpan 으로, ImplicitlyDestructible 이 Deinitable 로 갔습니다.
1.0 이라는 숫자를 보고 언어가 굳었다고 읽었는데, 실제로는 굳히는 작업을 이제 시작한다는 표시였습니다. changelog 도 그렇게 적어뒀습니다. "During the 1.x timeframe, changes should mostly be additive. We may still make breaking changes, but they'll be managed with care." 파괴적 변경을 계속 하겠다는 문장을 1.0 릴리스 노트에 넣은 겁니다.
origin 검사는 dangling reference 를 실제로 잡았습니다
여기까지 쓰고 보니 깎기만 한 것 같은데, 안전성 쪽은 얘기가 다릅니다. 처음 들었던 「Rust 만큼 안전하다」를 확인해보려고 지역 변수 참조를 반환하는 함수를 썼습니다.
def make_ref() -> ref [origin_of()] Int:
var local = 7
return local
error: cannot return reference with incompatible origin:
'origin_of(local)' vs 'ImmUntrackedOrigin'
잡았습니다. origin 이 Rust 의 lifetime 에 대응하는 개념이고 실물로 작동합니다. 에러 메시지에 지역 변수 이름까지 들어가 있어서 어디가 문제인지 바로 보입니다.
changelog 를 보면 이 부분에 1.0 작업이 꽤 들어갔습니다. UnsafePointer 의 origin 이 조용히 UnsafeAnyOrigin 으로 넓어지던 암묵 변환을 없앴고, memset 같은 함수 이름 앞에는 unsafe_ 를 붙였습니다. 안전하지 않은 자리를 이름과 타입으로 드러내는 방향입니다.
안전성을 표현하는 키워드가 1.0 에서 한 번 바뀌었고 또 바뀔 예정이라는 것만 걸립니다.
이번에 잰 건 mandelbrot 하나이고 벡터 폭도 2 짜리입니다. 병렬화와 GPU 커널은 손대지 않았습니다. Mojo 가 실제로 겨냥하는 자리가 그쪽이라 거기서 다시 재면 숫자가 또 달라질 겁니다. 코어를 열 개 쓰면 열 배에 가까운 게 나올 텐데, 그건 Mojo 가 빠르다는 근거가 아니라 코어를 열 개 썼다는 근거입니다. 이번에 35,000배가 28배로 줄어든 것도 같은 이유였습니다.
지금 정도로 보고 있는 건, 이 언어를 볼 때 「빠르다」를 근거로 삼기는 어렵겠다는 것입니다. 스칼라 코드가 빠른 건 LLVM 이 하는 일이고 C 와 Rust 도 같은 것을 쓰고 있습니다. Mojo 가 다른 자리는 그 아래 있는 벡터 명령과 MLIR 에 파이썬처럼 생긴 문법으로 닿게 해준다는 점입니다. 0.32초를 0.22초로 만든 게 그 문법이었고, 같은 일을 C 에서 하려면 인트린식 이름을 찾아야 합니다. 그게 이 언어를 쓸 이유라면 이유인데, 「C보다 빠른 언어」와는 다른 문장입니다.
컴파일러를 언제 자기 언어로 옮기는지는 계속 볼 만한 지표라고 생각합니다. 그때가 되면 이 언어가 큰 프로그램을 감당한다는 걸 소스로 보여주는 셈이니까요. 표준 라이브러리는 이미 Mojo 로 쓰여 있습니다. 설치된 자리에는 std.mojoc 3.0MB 하나로 컴파일돼 들어와 있지만 에러 메시지에 stdlib/std/collections/array.mojo:673 처럼 원본 경로가 그대로 찍히고, 그쪽 소스는 공개돼 있습니다. 못 하는 게 아니라 컴파일러를 아직 안 옮긴 겁니다.
fn 을 왜 없앴는지는 아직 모릅니다. changelog 에는 없었고, 더 찾아보지는 않았습니다.
댓글
댓글 쓰기