Chris Lattner의 새로운 독점 고수준 시스템 프로그래밍 언어 Mojo를 Python, Cython, PyPy와 비교해 보며, 겉보기와 달리 Python과의 호환성이 제한적임을 확인하고 Python 상호운용 및 성능을 간단히 측정한다.
Mojo는 Chris Lattner가 만든 새로운 독점(proprietary) 고수준 시스템 프로그래밍 언어다. Mojo의 홈페이지에는 “Pythonic”하다고 적혀 있다. 홈페이지의 한 사용자 인용문에는 “Mojo는 Python++이다. 완성되면 Python 언어의 엄격한 상위집합(strict superset)이 될 것이다.”라고 되어 있다. 그리고 홈페이지에서는 Python 상호운용성(interoperability)도 이야기한다.
나는 프로그래밍 언어 덕후이기도 하고 Python 스크립트를 많이 쓰는 사람이기도 해서, 한동안 Mojo를 직접 써 보고 싶었다. 나는 이것이 PyPy나 Cython처럼 Python 코드를 작성하고 몇몇 Python 라이브러리를 import할 수 있으며, 런타임 자체에 직접 의존하는 일(예: FFI 호출)을 하지 않는 한 기존 코드가 대체로 동작하는 종류의 것이라고 생각했다.
Ubuntu 24.04 머신에서 아래를 설치하고, 한번 살펴보자.
$ sudo apt-get install -y python3-pip cython3 pypy3 hyperfine
$ pip3 install --break-system-packages mojo
$ python3 --version
Python 3.12.3
$ cython3 --version
Cython version 3.0.8
$ pypy3 --version
Python 3.9.18 (7.3.15+dfsg-1build3, Apr 01 2024, 03:12:48)
[PyPy 7.3.15 with GCC 13.2.0]
$ mojo --version
Mojo 0.26.1.0 (156d3ac6)
Python과 Mojo는 문법 수준에서 비슷한 점이 많고, Mojo의 목표가 언젠가 Python의 상위집합이 되는 것일 수는 있겠지만, 나는 현실적으로 “기본적으로 Python과 호환되지 않는다”고 말하겠다. 이런 점이 나만 헷갈렸던 것은 아닌 것 같은데, 예를 들어 현재 Mojo의 Wikipedia 페이지에는 Python처럼 생긴 sub 함수 예제가 있는데 실제로는 컴파일이 되지 않는다. (아마 이전 버전의 Mojo에서는 컴파일되었을지도 모른다.)
$ cat foo.mojo
def sub(x, y):
res = x - y
return res
def main():
sub(3, 1)
$ mojo foo.mojo
/tmp/trymojo/foo.mojo:1:9: error: argument type must be specified
def sub(x, y):
^
/tmp/trymojo/foo.mojo:1:12: error: argument type must be specified
def sub(x, y):
^
/usr/local/bin/mojo: error: failed to parse the provided Mojo source module
나는 Mojo가 TypeScript나 OCaml처럼 타입이 대체로 선택 사항일 거라고 가정했다. 그리고 Wikipedia 페이지도 그런 인상을 어느 정도 준다. 하지만 실제로는 인자와 반환값에 타입이 필수다. 컴파일되는 foo.mojo 버전은 다음과 같다.
$ cat foo.mojo
def sub(x: Int, y: Int) -> Int:
res = x - y
return res
def main():
print(sub(3, 1))
$ mojo foo.mojo
2
Python으로 간단한 정적 사이트 생성기를 하나 작성해서, 그중 얼마나 많은 부분이 Mojo와 호환되는지 보자. 이름은 build.py로 하겠다.
import os
import pathlib
import shutil
TEMPLATES_DIR = "templates"
TEMPLATE_EXTENSION = ".tmpl.html"
OUT_DIR = "docs"
SITENAME = "My blog"
# Clean up out directory.
try:
shutil.rmtree(OUT_DIR)
except Exception as e:
pass
pathlib.Path(OUT_DIR).mkdir(parents=True)
# Iterate over templates to write them out.
for fname in os.listdir(TEMPLATES_DIR):
if not fname.endswith(TEMPLATE_EXTENSION):
continue
with open(os.path.join(TEMPLATES_DIR, fname)) as f:
tmpl = f.read()
outpath = os.path.join(OUT_DIR, fname[:-len(TEMPLATE_EXTENSION)] + ".html")
with open(outpath, "w") as f:
# Render template with variables substituted.
f.write(tmpl.format(sitename=SITENAME))
print(f"Wrote {outpath}")
templates/index.tmpl.html을 만든다:
$ mkdir templates
$ echo "<h1>{sitename}</h1>
Hello world!" > templates/index.tmpl.html
그리고 Python 스크립트를 실행한다.
$ python3 build.py
Wrote docs/index.html
그리고 생성된 결과를 확인한다.
$ cat docs/index.html
<h1>My blog</h1>
Hello world!
Cython으로도 같은 걸 해 보자.
$ cython3 --embed build.py
$ gcc -o build build.c $(python3-config --includes --ldflags --embed)
$ ./build
Wrote docs/index.html
$ cat docs/index.html
<h1>My blog</h1>
Hello world!
PyPy로도 같은 걸 해 보자.
$ pypy3 build.py
Wrote docs/index.html
$ cat docs/index.html
<h1>My blog</h1>
Hello world!
좋다. 이제 Mojo가 이것을 어떻게 실행하는지 보자.
$ mojo build.py
/usr/local/bin/mojo: error: no such command 'build.py'
좋아, 문제 없다. mojo --help를 보면 mojo run build.py를 할 수 있을지도 모른다.
$ mojo run build.py
/usr/local/bin/mojo: error: cannot open 'build.py', since it does not appear to be a Mojo file (it does not end in '.mojo' or '.🔥')
좋아, 그러면 먼저 이름을 바꾸지 않고는 기존 Python 파일을 Mojo에서 직접 실행할 수 없다. 이름을 바꿔 보자.
$ cp build.py build.mojo
$ mojo run build.mojo
/tmp/trymojo/build.mojo:5:1: error: expressions are not supported at the file scope
TEMPLATES_DIR = "templates"
^
/tmp/trymojo/build.mojo:6:1: error: expressions are not supported at the file scope
TEMPLATE_EXTENSION = ".tmpl.html"
^
/tmp/trymojo/build.mojo:7:1: error: expressions are not supported at the file scope
OUT_DIR = "docs"
^
/tmp/trymojo/build.mojo:8:1: error: expressions are not supported at the file scope
SITENAME = "My blog"
^
/tmp/trymojo/build.mojo:11:1: error: 'try' must be contained in a function but is contained in a file scope.
try:
^
/tmp/trymojo/build.mojo:12:3: error: expressions are not supported at the file scope
shutil.rmtree(OUT_DIR)
^
/tmp/trymojo/build.mojo:13:18: error: expected ':' after 'except'
except Exception as e:
^
/tmp/trymojo/build.mojo:3:8: error: unable to locate module 'shutil'
import shutil
^
/usr/local/bin/mojo: error: failed to parse the provided Mojo source module
그러면 Python과의 차이점이 두 가지 더 드러난다. 표준 라이브러리 패키지 shutil이 없고, 최상위(top-level) 표현식을 둘 수 없다.
이 차이들을 우회하도록 build.mojo를 다시 작성해 보자. 모든 코드를 main 함수 안에 넣고, 평소 Python스럽게 if __name__ == “__main__” 방식으로 그 함수를 호출하겠다. 또한 shutil은 그냥 빼고, 사용자가 디렉터리를 지우고 만드는 일을 직접 한다고 가정하겠다.
import os
import pathlib
def main():
TEMPLATES_DIR = "templates"
TEMPLATE_EXTENSION = ".tmpl.html"
OUT_DIR = "docs"
SITENAME = "My blog"
# Assume that OUT_DIR is an empty, existing directory.
# Iterate over templates to write them out.
for fname in os.listdir(TEMPLATES_DIR):
if not fname.endswith(TEMPLATE_EXTENSION):
continue
with open(os.path.join(TEMPLATES_DIR, fname)) as f:
tmpl = f.read()
outpath = os.path.join(OUT_DIR, fname[:-len(TEMPLATE_EXTENSION)] + ".html")
with open(outpath, "w") as f:
# Render template with variables substituted.
f.write(tmpl.format(sitename=SITENAME))
print(f"Wrote {outpath}")
if __name__ == "__main__":
main()
실행해 보자.
$ mojo run build.mojo
/tmp/trymojo/build.mojo:27:4: error: use of unknown declaration '__name__'
if __name__ == "__main__":
^~~~~~~~
/tmp/trymojo/build.mojo:17:10: error: invalid call to 'open': missing 1 required positional argument: 'mode'
with open(os.path.join(TEMPLATES_DIR, fname)) as f:
^~~~
/tmp/trymojo/build.mojo:1:1: note: function declared here
import os
^
/usr/local/bin/mojo: error: failed to parse the provided Mojo source module
좋아, __name__ 체크와는 호환되지 않는다. 대신 그냥 main()을 정의하면 Mojo가 알아서 호출해 준다. 그리고 open() 호출에는 mode 플래그(”r”)를 추가해야 하는 것 같다.
import os
import pathlib
def main():
TEMPLATES_DIR = "templates"
TEMPLATE_EXTENSION = ".tmpl.html"
OUT_DIR = "docs"
SITENAME = "My blog"
# Assume that OUT_DIR is an empty, existing directory.
# Iterate over templates to write them out.
for fname in os.listdir(TEMPLATES_DIR):
if not fname.endswith(TEMPLATE_EXTENSION):
continue
with open(os.path.join(TEMPLATES_DIR, fname), "r") as f:
tmpl = f.read()
outpath = os.path.join(OUT_DIR, fname[:-len(TEMPLATE_EXTENSION)] + ".html")
with open(outpath, "w") as f:
# Render template with variables substituted.
f.write(tmpl.format(sitename=SITENAME))
print(f"Wrote {outpath}")
그리고 실행한다.
$ mojo run build.mojo
/tmp/trymojo/build.mojo:23:19: error: invalid call to 'format': unknown keyword argument: 'sitename'
f.write(tmpl.format(sitename=SITENAME))
~~~~^~~~~~~
/tmp/trymojo/build.mojo:1:1: note: function declared here
import os
^
/tmp/trymojo/build.mojo:25:12: error: expected ')' in call argument list
print(f"Wrote {outpath}")
^
/usr/local/bin/mojo: error: failed to parse the provided Mojo source module
아, Mojo는 아직 이름 있는 format 인자(named format arguments)를 지원하지 않는다. 그리고 f-string도 지원하지 않는 것처럼 보인다.
이 시점에서, 이름 있는 format 인자가 정적 사이트 생성기의 핵심이었기 때문에 더 시도하고 싶지 않다.
하지만 Mojo가 기존 Python 코드를 쉽게 import해서 실행할 수 있게 해 주는 것은 사실이다. 이를 위해 build.mojo를 완전히 다시 작성해 보자.
from std.python import Python
def main():
Python.add_to_path(".")
Python.import_module("build")
그리고 실행한다.
$ mojo run build.mojo
Wrote docs/index.html
이건 실행 시간이 꽤 오래 걸렸고, 저렴한 DigitalOcean 인스턴스에서는 완전히 실패하기도 했다(메모리를 8GiB로 올리기 전까지 JIT session error: Cannot allocate memory가 떴다). 하지만 결국에는 동작했다!
실행에 오래 걸리는 문제를 처리하기 위해, build 명령으로 바이너리를 만들면 되지 않을까 생각했다.
$ mojo build build.mojo
$ ./build
#0 0x00007e5c0c1cb78b (/usr/local/lib/python3.12/dist-packages/modular/lib/libKGENCompilerRTShared.so+0x3cb78b)
#1 0x00007e5c0c1c93c6 (/usr/local/lib/python3.12/dist-packages/modular/lib/libKGENCompilerRTShared.so+0x3c93c6)
#2 0x00007e5c0c1cc397 (/usr/local/lib/python3.12/dist-packages/modular/lib/libKGENCompilerRTShared.so+0x3cc397)
#3 0x00007e5c0ba45330 (/lib/x86_64-linux-gnu/libc.so.6+0x45330)
#4 0x00006101a47c18bc std::python::_cpython::CPython::__init__() build.mojo:0:0
#5 0x00006101a47c4325 std::python::python::Python::import_module(::String$)_closure_0 build.mojo:0:0
#6 0x00007e5c0c0f8db6 KGEN_CompilerRT_GetOrCreateGlobalIndexed (/usr/local/lib/python3.12/dist-packages/modular/lib/libKGENCompilerRTShared.so+0x2f8db6)
#7 0x00006101a47c4450 std::python::python::Python::import_module(::String$) build.mojo:0:0
#8 0x00006101a47be3ab main (/tmp/build+0x13ab)
#9 0x00007e5c0ba2a1ca (/lib/x86_64-linux-gnu/libc.so.6+0x2a1ca)
#10 0x00007e5c0ba2a28b __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28b)
#11 0x00006101a47be255 _start (/tmp/build+0x1255)
Illegal instruction (core dumped)
하지만 이 mojo build 과정의 동작은 mojo run과 충분히 달라 보였고, 8GiB 메모리를 가진 저렴한 DigitalOcean 머신에서는 끝내 동작하게 만들지 못했다. 그래서 DigitalOcean의 “best-in-class processors”를 약속하는 CPU 최적화 인스턴스로 바꿨다.
$ mojo build build.mojo
$ ./build
Wrote docs/index.html
이제는 잘 된다!
어쨌든 중요한 점은, Mojo에서 build.py를 import할 때 “Mojo가 Python 지원을 재구현”하고 있는 게 아니라는 것이다. 문서에 따르면 “Python 코드는 표준 Python 인터프리터(CPython)에서 실행된다.”
그럼 Python, Cython, PyPy, Mojo를 한데 모아 보자. 그리고 가능한 한 컴파일 단계를 분리하겠다(내가 알기로 PyPy는 별도의 컴파일 단계가 없다).
$ # First build the Cython binary.
$ cython3 --embed build.py
$ gcc -o buildcython build.c $(python3-config --includes --ldflags --embed)
$ # Next build the Mojo binary.
$ mojo build build.mojo -o buildmojo
$ # Last the Python bytecode.
$ python3 -m py_compile build.py
$ hyperfine \
"python3 __pycache__/build.cpython-312.pyc" \
"./buildmojo" \
"./buildcython" \
"pypy3 build.py"
Benchmark 1: python3 __pycache__/build.cpython-312.pyc
Time (mean ± σ): 35.0 ms ± 1.8 ms [User: 27.5 ms, System: 7.3 ms]
Range (min … max): 33.0 ms … 48.3 ms 81 runs
Warning: Statistical outliers were detected. Consider re-running this benchmark on a quiet system without any interferences from other programs. It might
help to use the '--warmup' or '--prepare' options.
Benchmark 2: ./buildmojo
Time (mean ± σ): 101.5 ms ± 1.7 ms [User: 75.1 ms, System: 23.3 ms]
Range (min … max): 98.7 ms … 105.1 ms 29 runs
Benchmark 3: ./buildcython
Time (mean ± σ): 39.3 ms ± 1.5 ms [User: 32.1 ms, System: 7.1 ms]
Range (min … max): 37.1 ms … 43.4 ms 70 runs
Benchmark 4: pypy3 build.py
Time (mean ± σ): 78.5 ms ± 1.3 ms [User: 55.6 ms, System: 22.6 ms]
Range (min … max): 76.6 ms … 83.1 ms 37 runs
Summary
python3 __pycache__/build.cpython-312.pyc ran
1.12 ± 0.07 times faster than ./buildcython
2.25 ± 0.12 times faster than pypy3 build.py
2.90 ± 0.16 times faster than ./buildmojo
그리고 빌드와 실행 단계를 합치면 다음과 같다.
$ hyperfine --prepare "rm -rf __pycache__ buildcython build build.c" \
"python3 build.py" \
"mojo run build.mojo" \
"cython3 --embed build.py; gcc -o buildcython build.c -I/usr/include/python3.12 -I/usr/include/python3.12 -L/usr/lib/python3.12/config-3.12-x86_64-linux-gnu -L/usr/lib/x86_64-linux-gnu -lpython3.12 -ldl -lm; ./buildcython" \
"pypy3 build.py"
Benchmark 1: python3 build.py
Time (mean ± σ): 35.9 ms ± 1.3 ms [User: 28.3 ms, System: 7.5 ms]
Range (min … max): 33.5 ms … 39.3 ms 80 runs
Benchmark 2: mojo run build.mojo
Time (mean ± σ): 865.2 ms ± 12.7 ms [User: 706.4 ms, System: 108.5 ms]
Range (min … max): 851.1 ms … 885.0 ms 10 runs
Benchmark 3: cython3 --embed build.py; gcc -o buildcython build.c -I/usr/include/python3.12 -I/usr/include/python3.12 -L/usr/lib/python3.12/config-3.12-x86
_64-linux-gnu -L/usr/lib/x86_64-linux-gnu -lpython3.12 -ldl -lm; ./buildcython
Time (mean ± σ): 912.1 ms ± 8.6 ms [User: 766.6 ms, System: 144.5 ms]
Range (min … max): 894.4 ms … 920.0 ms 10 runs
Benchmark 4: pypy3 build.py
Time (mean ± σ): 77.0 ms ± 1.5 ms [User: 53.9 ms, System: 22.9 ms]
Range (min … max): 74.7 ms … 82.4 ms 37 runs
Summary
python3 build.py ran
2.14 ± 0.09 times faster than pypy3 build.py
24.10 ± 0.96 times faster than mojo run build.mojo
25.41 ± 0.97 times faster than cython3 --embed build.py; gcc -o buildcython build.c -I/usr/include/python3.12 -I/usr/include/python3.12 -L/usr/lib/python3.12/config-3.12-x86_64-linux-gnu -L/usr/lib/x86_64-linux-gnu -lpython3.12 -ldl -lm; ./buildcython
이 스크립트는 아주 작고, Mojo가 겨냥하는 수학 연산 위주의 작업을 대표하지 않는다. 이 글이 보여 주는 것은 기껏해야 Mojo가 Python의 사소한 대체재가 아니라는 점이다. Mojo는 PyPy와 Cython이 해결하려고 했던 문제들 중 _일부_를 다루고 있다. 하지만 총 $380M을 조달한 뒤라는 점을 고려하면, 어쨌든 PyPy보다 훨씬 더 좋은 자금력을 갖춘 것처럼 보인다. (Cython의 자금 조달 상황은 쉽게 찾을 수 없었다.)
Mojo 언어는 초기 단계(처음 공개된 것은 2023년)이지만 유망하다. 일급(first-class) Python FFI (이 글에서는 살펴보지 않았다)는 매력적이다. 또한 Mojo 팀은 언젠가 언어를 오픈 소스로 전환할 의도가 있음을 시사했다. 표준 라이브러리는 이미 오픈 소스다. 특히 시간이 지나며 Python과의 호환성을 더 높이는 것이 목표라면, 기여할 거리를 쉽게 찾을 수 있는 흥미로운 언어 발전 단계다.