Hello, Mojo🔥
이 대화형 노트북을 통해 Mojo를 소개해드리게 되어 기쁩니다!
Mojo는 파이썬의 상위 집합으로 설계되었기 때문에 많은 언어 특징과
함수가 동일합니다. 예를 들어, Mojo의 "hello world" 프로그램은 파이썬과
파이썬에서와 똑같습니다:
print("Hello Mojo!")
나중에 설명하겠지만, 기존 Python 패키지를 가져와서 익숙한 방식으로 사용할 수도 있습니다.
하지만 Mojo는 파이썬을 기반으로 수많은 강력한 기능을 제공합니다, 이 노트북에서는 그 기능에 집중하겠습니다.
분명히 말씀드리지만, 이 가이드는 프로그래밍 언어에 대한 일반적인 입문서가 아닙니다. 언어에 대한 전통적인 입문서가 아닙니다. 이 노트북은 여러분이 이미 파이썬과 일부 시스템에 익숙하다고 가정합니다. 프로그래밍 개념에 이미 익숙하다고 가정하고 있습니다.
이 실행 가능한 노트북은 실제로 Mojo 프로그래밍 매뉴얼을 기반으로 하지만, 저희는 여러분이 모조를 가지고 노는 데 집중할 수 있도록 많은 설명을 단순화했습니다. 코드에 집중할 수 있도록 설명을 간소화했습니다. 주제에 대해 더 자세히 알고 싶으시다면 전체 매뉴얼을 참조하세요.
이제 시작해보자!
참고: 이러한 노트북을 실행하는 클라우드 환경은 그다지 강력하지 않으며 사용 가능한 vCPU 코어 수는 다를 수 있습니다. 그러나 Matmul.ipynb 노트북에서 볼 수 있듯이 Mojo의 상대적 성능은 Python에 비해 상당히 뛰어납니다.
기본 시스템 프로그래밍 확장
Python은 기본적으로 시스템 프로그래밍을 지원하지 않으므로 Mojo에서 시스템 프로그래밍을 수행하는 방법은 다음과 같습니다.
let 및 var 선언
함수 내에서 이름에 값을 할당할 수 있으며, 파이썬에서처럼 함수 범위 변수를 암시적으로 생성합니다. 이는 코드를 작성하는 데 매우 역동적이고 의식이 적은 방법을 제공하지만, 두 가지 이유에서 문제가 되기도 합니다:
- 시스템 프로그래머는 종종 값이 불변이라고 선언하고 싶어합니다.
- 과제에서 변수 이름을 잘못 입력하면 오류가 발생할 수 있습니다.
이를 지원하기 위해 Mojo는 범위가 지정된 새로운 런타임 값(let은 불변, var는 변경 가능)을 도입하는 let 및 var 선언을 지원합니다. 이러한 값은 어휘 범위를 사용하며 이름 섀도잉을 지원합니다:
def your_function(a, b):
let c = a
# Uncomment to see an error:
# c = b # error: c is immutable
if c != b:
let d = b
print(d)
your_function(2, 3)
let 및 var 선언은 유형 지정자, 패턴 및 후기 초기화도 지원합니다:
def your_function():
let x: Int = 42
let y: F64 = 17.0
let z: F32
if x != 0:
z = 1.0
else:
z = foo()
print(z)
def foo() -> F32:
return 3.14
your_function()
구조체 유형
최신 시스템 프로그래밍에는 저수준 데이터 레이아웃 제어, 방향성 없는 필드 액세스 및 기타 틈새 트릭을 기반으로 고수준의 안전한 추상화를 구축할 수 있는 기능이 필요합니다. Mojo는 구조체 타입으로 이러한 기능을 제공합니다.
구조체 타입은 여러 면에서 클래스와 유사합니다. 하지만 클래스가 동적 디스패치, 동적 메서드 스위즐링, 동적으로 바인딩된 인스턴스 프로퍼티로 매우 동적인 반면, 구조체는 정적이고 컴파일 시 바인딩되며 암시적으로 간접적이고 참조 카운트되는 대신 컨테이너에 인라인됩니다.
다음은 구조체에 대한 간단한 정의입니다:
struct MyPair:
var first: Int
var second: Int
# We use 'fn' instead of 'def' here - we'll explain that soon
fn __init__(self&, first: Int, second: Int):
self.first = first
self.second = second
fn __lt__(self, rhs: MyPair) -> Bool:
return self.first < rhs.first or
(self.first == rhs.first and
self.second < rhs.second)
클래스와 비교했을 때 가장 큰 차이점은 구조체의 모든 인스턴스 프로퍼티는 var 또는 let 선언으로 명시적으로 선언해야 한다는 점입니다. 이를 통해 Mojo 컴파일러는 방향성이나 기타 오버헤드 없이 메모리에서 프로퍼티 값을 정확하게 레이아웃하고 액세스할 수 있습니다.
구조체 필드는 정적으로 바인딩되며, 딕셔너리 인디렉션으로 조회되지 않습니다. 따라서 런타임에 메서드를 삭제하거나 재할당할 수 없습니다. 따라서 Mojo 컴파일러는 보장된 정적 디스패치를 수행하고, 필드에 대한 보장된 정적 액세스를 사용하며, 방향이나 기타 오버헤드 없이 구조체를 스택 프레임 또는 이를 사용하는 인클로징 타입에 인라인 처리할 수 있습니다.
강력한 유형 검사
파이썬에서와 마찬가지로 동적 타입을 사용할 수 있지만, Mojo를 사용하면 프로그램에서 강력한 타입 검사를 사용할 수도 있습니다.
강력한 타입 검사를 사용하는 주요 방법 중 하나는 Mojo의 구조체 타입을 사용하는 것입니다. Mojo의 구조체 정의는 컴파일 타임에 바인딩된 이름을 정의하며, 유형 컨텍스트에서 해당 이름에 대한 참조는 정의되는 값에 대한 강력한 사양으로 취급됩니다. 예를 들어 위에 표시된 MyPair 구조체를 사용하는 다음 코드를 살펴봅시다:
def pairTest() -> Bool:
let p = MyPair(1, 2)
# Uncomment to see an error:
# return p < 4 # gives a compile time error
return True
첫 번째 반환문의 주석 처리를 해제하고 실행하면 4를 MyPair로 변환할 수 없다는 컴파일 타임 오류가 발생하며, 이는 (MyPair 정의에서) __lt__의 RHS가 요구하는 값입니다.
Overloaded functions & methods
또한 파이썬과 마찬가지로 인수 유형을 지정하지 않고도 Mojo에서 함수를 정의할 수 있으며 Mojo가 데이터 유형을 유추하도록 할 수 있습니다. 하지만 유형 안전성을 보장하고 싶을 때 Mojo는 오버로드된 함수와 메서드에 대한 완벽한 지원도 제공합니다.
기본적으로 이름은 같지만 인수가 다른 여러 함수를 정의할 수 있습니다. 이는 C++, Java, Swift 등 많은 언어에서 볼 수 있는 일반적인 기능입니다.
예를 들어 보겠습니다:
struct Complex:
var re: F32
var im: F32
fn __init__(self&, x: F32):
"""Construct a complex number given a real number."""
self.re = x
self.im = 0.0
fn __init__(self&, r: F32, i: F32):
"""Construct a complex number given its real and imaginary components."""
self.re = r
self.im = i
모듈 함수, 클래스 또는 구조체의 메서드 등 원하는 곳 어디에서나 오버로드를 구현할 수 있습니다.
Mojo는 결과 유형에만 오버로드를 지원하지 않으며, 유형 추론에 결과 유형이나 컨텍스트 유형 정보를 사용하지 않기 때문에 작업을 간단하고 빠르며 예측 가능하게 유지합니다. Mojo의 타입 검사기는 정의상 간단하고 빠르기 때문에 "표현식이 너무 복잡합니다"라는 오류가 발생하지 않습니다.
fn definitions
위의 확장은 저수준 프로그래밍을 제공하고 추상화 기능을 제공하는 초석이지만, 많은 시스템 프로그래머는 Mojo의 def가 제공하는 것보다 더 많은 제어와 예측 가능성을 선호합니다. 요약하자면, def는 인수가 변경 가능하고, 지역 변수는 처음 사용할 때 암시적으로 선언되며, 범위 지정이 강제되지 않는 등 매우 동적이고 유연하며 일반적으로 Python과 호환되도록 정의되었습니다. 이는 고급 프로그래밍 및 스크립팅에 적합하지만 시스템 프로그래밍에 항상 좋은 것은 아닙니다. 이를 보완하기 위해 Mojo는 def에 대한 "엄격한 모드"와 같은 fn 선언을 제공합니다.
fn과 def는 인터페이스 수준에서 항상 상호 교환할 수 있으며, fn이 제공하지 못하는 것은 def가 제공할 수 없습니다(또는 그 반대도 마찬가지). 차이점은 fn은 몸체 내부에서 더 제한적이고 제어된다는 점입니다(또는 현학적이며 엄격하다는 점). 특히, fn은 def에 비해 여러 가지 제한이 있습니다:
- 인자 값은 기본적으로 함수 본문에서 변경 가능(var처럼)이 아닌 불변(let처럼)으로 설정됩니다. 이렇게 하면 우발적인 돌연변이를 포착하고 복사할 수 없는 유형을 인자로 사용할 수 있습니다.
- 인자 값에는 타입 지정이 필요하므로(메서드에서 self는 제외) 실수로 타입 지정이 누락된 경우를 포착할 수 있습니다. 마찬가지로 반환 유형 지정자가 누락되면 알 수 없는 반환 유형 대신 None을 반환하는 것으로 해석됩니다. 두 가지 모두 명시적으로 객체를 반환하도록 선언할 수 있으므로 원하는 경우 def의 동작을 선택할 수 있습니다.
- 지역 변수의 암시적 선언은 비활성화되어 있으므로 모든 지역 변수를 선언해야 합니다. 이렇게 하면 이름 오타가 잡히고 'let' 및 'var'가 제공하는 범위와 일치합니다.
- 둘 다 예외 발생을 지원하지만, 예외 발생은 함수 인자 목록 뒤에 있는 'fn'에 함수 효과를 발생시키는 'raise' 함수를 명시적으로 선언해야 합니다.
The __copyinit__ and __moveinit__ special methods
Mojo는 C++ 및 Swift와 같은 언어에서 볼 수 있는 완전한 '값 의미론'을 지원하며, @value 데코레이터(프로그래밍 매뉴얼에 자세히 설명되어 있습니다)를 사용하여 간단한 필드 집합을 매우 쉽게 정의할 수 있습니다.
고급 사용 사례를 위해 Mojo를 사용하면 파이썬의 기존 __init__ 특수 메서드를 사용하여 사용자 정의 생성자, 기존 __del__ 특수 메서드를 사용하여 사용자 정의 소멸자, 새로운 __copyinit__ 및 __moveinit__ 특수 메서드를 사용하여 사용자 정의 복사 및 이동 생성자를 정의할 수 있습니다.
이러한 저수준 사용자 정의 후크는 수동 메모리 관리와 같은 저수준 시스템 프로그래밍을 수행할 때 유용할 수 있습니다. 예를 들어, 데이터가 생성될 때 메모리를 할당하고 값이 소멸될 때 소멸해야 하는 힙 배열 유형을 생각해 보세요:
from Pointer import Pointer
from IO import print_no_newline
struct HeapArray:
var data: Pointer[Int]
var size: Int
var cap: Int
# StringRef has a data + length field
fn __init__(self&):
self.cap = 16
self.size = 0
self.data = Pointer[Int].alloc(self.cap)
fn __init__(self&, size: Int, val: Int):
self.cap = size * 2
self.size = size
self.data = Pointer[Int].alloc(self.cap)
for i in range(self.size):
self.data.store(i, val)
fn __del__(owned self):
self.data.free()
fn dump(self):
print_no_newline("[")
for i in range(self.size):
if i > 0:
print_no_newline(", ")
print_no_newline(self.data.load(i))
print("]")
이 배열 유형은 저수준 함수를 사용하여 구현되어 작동 방식의 간단한 예를 보여줍니다. 하지만 실제로 사용해 보면 놀랄 수도 있습니다:
var a = HeapArray(3, 1)
a.dump() # Should print [1, 1, 1]
# Uncomment to see an error:
# var b = a # ERROR: Vector doesn't implement __copyinit__
var b = HeapArray(4, 2)
b.dump() # Should print [2, 2, 2, 2]
a.dump() # Should print [1, 1, 1]
컴파일러가 배열의 복사본을 만들 수 없습니다: HeapArray에는 (저수준 C 포인터에 해당하는) Pointer 인스턴스가 포함되어 있는데, Mojo는 "포인터가 무엇을 의미하는지" 또는 "어떻게 복사하는지"를 알 수 없습니다. 이것이 바로 애플리케이션 수준 프로그래머가 배열이나 슬라이스와 같은 상위 수준 유형을 사용해야 하는 이유 중 하나입니다! 더 일반적으로 원자 번호와 같은 일부 타입은 클래스 인스턴스처럼 주소가 신원을 제공하기 때문에 복사나 이동이 전혀 불가능합니다.
이 경우 배열을 복사할 수 있기를 원하며, 이를 위해 일반적으로 다음과 같이 보이는 __copyinit__ 특수 메서드를 구현합니다:
struct HeapArray:
var data: Pointer[Int]
var size: Int
var cap: Int
# StringRef has a data + length field
fn __init__(self&):
self.cap = 16
self.size = 0
self.data = Pointer[Int].alloc(self.cap)
fn __init__(self&, size: Int, val: Int):
self.cap = size * 2
self.size = size
self.data = Pointer[Int].alloc(self.cap)
for i in range(self.size):
self.data.store(i, val)
fn __copyinit__(self&, other: Self):
self.cap = other.cap
self.size = other.size
self.data = Pointer[Int].alloc(self.cap)
for i in range(self.size):
self.data.store(i, other.data.load(i))
fn __del__(owned self):
self.data.free()
fn dump(self):
print_no_newline("[")
for i in range(self.size):
if i > 0:
print_no_newline(", ")
print_no_newline(self.data.load(i))
print("]")
이 구현을 사용하면 위의 코드가 올바르게 작동하고 b = a 복사본은 자체 수명과 데이터를 가진 논리적으로 구별되는 배열 인스턴스를 생성합니다. 또한 Mojo는 __moveinit__ 메서드를 지원하여 Rust 스타일의 이동(수명이 끝나면 값을 가져가는)과 C++ 스타일의 이동(값의 내용은 제거되지만 소멸자는 계속 실행되는)을 모두 허용하고 사용자 정의 이동 로직을 정의할 수 있습니다. 자세한 내용은 "값 수명 주기" 문서를 참조하세요.
var a = HeapArray(3, 1)
a.dump() # Should print [1, 1, 1]
# This is no longer an error:
# Uncomment to see an error:
var b = a # ERROR: Vector doesn't implement __copyinit__
# var b = HeapArray(4, 2)
b.dump() # Should print [2, 2, 2, 2]
a.dump() # Should print [1, 1, 1]
Mojo는 유형을 복사 가능, 이동 전용, 이동 불가능으로 설정하는 기능을 포함하여 값의 수명에 대한 완전한 제어 기능을 제공합니다. 이는 최소한 값을 이동할 수 있어야 하는 Swift나 Rust와 같은 언어보다 더 많은 제어 기능을 제공합니다. 복사본을 생성하지 않고 어떻게 기존 값을 __copyinit__ 메서드에 전달할 수 있는지 궁금하다면 아래의 "차용" 인수 규칙 섹션을 참조하세요.
파라미터화: 컴파일 시간 메타프로그래밍
Mojo는 구문 분석, 의미 분석, IR 생성 후 대상별 코드로 낮추기 전에 컴파일러에 별도의 컴파일 단계로 내장된 전체 컴파일 타임 메타프로그래밍 기능을 지원합니다. 메타프로그래밍은 런타임 프로그램에 메타프로그래밍과 동일한 호스트 언어를 사용하며, 예측 가능한 방식으로 이러한 프로그램을 표현하고 평가하기 위해 MLIR을 활용합니다.
몇 가지 간단한 예를 살펴보겠습니다.
매개변수화된 유형 및 함수 정의
Mojo 구조체와 함수는 각각 매개변수화될 수 있지만, 예시를 통해 왜 우리가 관심을 갖는지 이해할 수 있습니다. 스칼라 데이터 유형의 여러 인스턴스를 보유하는 하드웨어의 저수준 벡터 레지스터를 나타내는 "SIMD" 유형을 살펴봅시다. 요즘 하드웨어 가속기는 이색적인 데이터 유형을 지원하고 있으며, 512비트 이상의 SIMD 벡터를 가진 CPU와 함께 작동하는 경우도 드물지 않습니다. 하드웨어는 매우 다양하지만(SSE, AVX-512, NEON, SVE, RVV 등 많은 브랜드 포함), 숫자 및 ML 커널 개발자가 일반적으로 사용하는 연산이 많기 때문에 이러한 유형은 Mojo 프로그래머에게 노출됩니다.
다음은 Mojo 표준 라이브러리에서 매우 단순화되고 축소된 버전의 SIMD API입니다. 이 예제에서는 HeapArray를 사용하여 SIMD 데이터를 저장하고 루프를 사용하여 유형에 대한 기본 연산을 구현합니다. 이는 데모를 위해 원하는 SIMD 유형 동작을 모방하기 위한 것입니다. 실제 Stdlib 구현은 MLIR을 직접 사용하는 Mojo의 기능을 통해 액세스되는 실제 SIMD 명령어로 뒷받침됩니다("고급 Mojo 기능" 섹션에서 해당 주제에 대한 자세한 내용을 참조하세요).
from List import VariadicList
struct MySIMD[size: Int]:
var value: HeapArray
# Create a new SIMD from a number of scalars
fn __init__(self&, *elems: Int):
self.value = HeapArray(size, 0)
let elems_list = VariadicList(elems)
for i in range(elems_list.__len__()):
self[i] = elems_list[i]
fn __copyinit__(self&, other: MySIMD[size]):
self.value = other.value
fn __getitem__(self, i: Int) -> Int:
return self.value.data.load(i)
fn __setitem__(self, i: Int, val: Int):
return self.value.data.store(i, val)
# Fill a SIMD with a duplicated scalar value.
fn splat(self, x: Int) -> Self:
for i in range(size):
self[i] = x
return self
# Many standard operators are supported.
fn __add__(self, rhs: MySIMD[size]) -> MySIMD[size]:
let result = MySIMD[size]()
for i in range(size):
result[i] = self[i] + rhs[i]
return result
fn __sub__(self, rhs: Self) -> Self:
let result = MySIMD[size]()
for i in range(size):
result[i] = self[i] - rhs[i]
return result
fn concat[rhs_size: Int](self, rhs: MySIMD[rhs_size]) -> MySIMD[size + rhs_size]:
let result = MySIMD[size + rhs_size]()
for i in range(size):
result[i] = self[i]
for j in range(rhs_size):
result[size + j] = rhs[j]
return result
fn dump(self):
self.value.dump()
Mojo의 매개변수는 확장된 버전의 PEP695 구문을 사용하여 대괄호 안에 선언됩니다. 매개변수의 이름과 유형은 Mojo 프로그램에서 일반 값과 같지만, 대상 프로그램에서 런타임이 아닌 컴파일 타임에 평가됩니다. 런타임 프로그램에서는 매개변수가 런타임 프로그램에서 필요하기 전에 컴파일 타임에 확인되므로 런타임 프로그램에서 매개변수 값을 사용할 수 있지만 컴파일 타임 매개변수 표현식은 런타임 값을 사용하지 않을 수 있습니다.
위의 예제에는 두 개의 선언된 매개변수가 있습니다. MySIMD 구조체는 size 매개변수로 매개변수화되고 concat 메서드는 rhs_size 매개변수로 추가로 매개변수화됩니다. MySIMD는 매개변수화된 유형이므로 자체 인수의 유형은 매개변수를 전달하며, 전체 유형 이름은 MySIMD[size]입니다. 반환 유형 _add__에서 볼 수 있듯이 이를 출력하는 것도 항상 유효하지만, 장황할 수 있으므로 __sub__ 예제에서와 같이 Self 유형(PEP673의)을 사용하는 것이 좋습니다.
Mojo Stdlib에서 제공하는 실제 SIMD 유형도 요소의 데이터 유형에 따라 매개변수화됩니다.
매개변수화된 유형 및 함수 사용
크기는 SIMD 벡터의 요소 수를 지정하며, 아래 예시는 이 유형을 어떻게 사용할 수 있는지 보여줍니다:
# Make a vector of 4 elements.
let a = MySIMD[4](1, 2, 3, 4)
# Make a vector of 4 elements and splat a scalar value into it.
let b = MySIMD[4]().splat(100)
# Add them together and print the result
let c = a + b
c.dump()
# Make a vector of 2 elements.
let d = MySIMD[2](10, 20)
# Make a vector of 2 elements.
let e = MySIMD[2](70, 50)
let f = d.concat[2](e)
f.dump()
# Uncomment to see the error:
# let x = a + e # ERROR: Operation MySIMD[4]+MySIMD[2] is not defined
let y = f + a
y.dump()
concat 메서드에는 두 번째 SIMD 벡터의 크기를 나타내는 추가 매개변수가 필요하며, 이는 concat 호출을 매개변수화하여 처리합니다. 우리의 장난감 SIMD 유형은 구체적인 유형(Int)의 사용을 보여 주지만, 매개변수의 주요한 힘은 매개변수 알고리즘과 유형을 정의할 수 있는 능력에서 비롯됩니다. 예를 들어 길이와 DType에 구애받지 않는 매개변수 알고리즘을 정의하는 것은 매우 쉽습니다:
from DType import DType
from Math import sqrt
fn rsqrt[width: Int, dt: DType](x: SIMD[dt, width]) -> SIMD[dt, width]:
return 1 / sqrt(x)
모조 컴파일러는 매개변수를 사용한 유형 추론에 대해 상당히 똑똑합니다. 이 함수는 매개변수를 지정하지 않고도 매개변수 sqrt(x) 함수를 호출할 수 있으며, 컴파일러는 사용자가 sqrt[width,type](x)를 명시적으로 작성한 것처럼 해당 매개변수를 유추합니다. 또한 rsqrt는 첫 번째 매개변수를 width로 정의하기로 선택했지만 SIMD 유형은 아무런 문제 없이 size로 이름을 지정합니다.
매개변수 표현식은 Mojo 코드일 뿐입니다.
모든 매개변수와 매개변수 표현식은 런타임 프로그램과 동일한 타입 시스템을 사용하여 입력됩니다: Int와 DType은 Mojo 표준 라이브러리에서 구조체로 구현됩니다. 매개변수는 런타임 프로그램과 마찬가지로 컴파일 시 연산자, 함수 호출 등과 함께 표현식을 사용할 수 있도록 지원하는 매우 강력한 기능입니다. 이를 통해 많은 '종속 유형' 기능을 사용할 수 있습니다. 예를 들어, 위의 예제에서와 같이 두 개의 SIMD 벡터를 연결하는 도우미 함수를 정의할 수 있습니다:
fn concat[len1: Int, len2: Int](lhs: MySIMD[len1], rhs: MySIMD[len2]) -> MySIMD[len1+len2]:
let result = MySIMD[len1 + len2]()
for i in range(len1):
result[i] = lhs[i]
for j in range(len2):
result[len1 + j] = rhs[j]
return result
let a = MySIMD[2](1, 2)
let x = concat[2,2](a, a)
x.dump()
결과 길이는 입력 벡터 길이의 합이며, 간단한 + 연산으로 표현할 수 있다는 점에 유의하세요. 더 복잡한 예시를 보려면 표준 라이브러리의 SIMD.shuffle 메서드를 살펴보세요. 이 메서드는 두 개의 입력 SIMD 값과 벡터 셔플 마스크를 목록으로 받고 셔플 마스크의 길이와 일치하는 SIMD를 반환합니다.
강력한 컴파일 타임 프로그래밍
간단한 표현식도 유용하지만, 때로는 제어 흐름이 있는 명령형 컴파일 타임 로직을 작성하고 싶을 때가 있습니다. 예를 들어 수학 모듈의 isclose 함수는 정수에 대해서는 정확한 등식을 사용하지만 부동 소수점에는 근접 비교를 사용합니다. 벡터의 모든 요소를 재귀적으로 스칼라로 합산하는 "트리 축소" 알고리즘의 예와 같이 컴파일 시간 재귀를 수행할 수도 있습니다:
fn slice[new_size: Int, size: Int](x: MySIMD[size], offset: Int) -> MySIMD[new_size]:
let result = MySIMD[new_size]()
for i in range(new_size):
result[i] = x[i + offset]
return result
fn reduce_add[size: Int](x: MySIMD[size]) -> Int:
@parameter
if size == 1:
return x[0]
elif size == 2:
return x[0] + x[1]
# Extract the top/bottom halves, add them, sum the elements.
alias half_size = size // 2
let lhs = slice[half_size, size](x, 0)
let rhs = slice[half_size, size](x, half_size)
return reduce_add[half_size](lhs + rhs)
let x = MySIMD[4](1, 2, 3, 4)
x.dump()
print("Elements sum:", reduce_add[4](x))
이 기능은 컴파일 시 실행되는 if 문인 @parameter if 기능을 사용합니다. 조건이 유효한 매개변수 표현식이어야 하며, if의 라이브 브랜치만 프로그램에 컴파일되도록 합니다.
모조 타입은 매개변수 표현식일 뿐입니다.
지금까지 타입 내에서 매개변수 표현식을 사용하는 방법을 살펴봤지만, 파이썬과 모조 모두에서 타입 어노테이션은 그 자체로 임의의 표현식이 될 수 있습니다. Mojo의 타입에는 특별한 메타타입 타입이 있어 타입 매개변수 알고리즘과 함수를 정의할 수 있습니다. 예를 들어, 임의의 타입의 엘리먼트를 지원하도록 HeapArray 구조체를 확장할 수 있습니다:
struct Array[Type: AnyType]:
var data: Pointer[Type]
var size: Int
var cap: Int
# StringRef has a data + length field
fn __init__(self&):
self.cap = 16
self.size = 0
self.data = Pointer[Type].alloc(self.cap)
fn __init__(self&, size: Int, value: Type):
self.cap = size * 2
self.size = size
self.data = Pointer[Type].alloc(self.cap)
for i in range(self.size):
self.data.store(i, value)
fn __copyinit__(self&, other: Self):
self.cap = other.cap
self.size = other.size
self.data = Pointer[Type].alloc(self.cap)
for i in range(self.size):
self.data.store(i, other.data.load(i))
fn __getitem__(self, i: Int) -> Type:
return self.data.load(i)
fn __setitem__(self, i: Int, value: Type):
return self.data.store(i, value)
fn __del__(owned self):
self.data.free()
var v = Array[F32](4, 3.14)
print(v[0], v[1], v[2], v[3])
유형 매개변수가 값 인수의 공식 유형과 __getitem__ 함수의 반환 유형으로 사용되고 있음을 알 수 있습니다. 매개변수를 사용하면 배열 유형이 다양한 사용 사례에 따라 다른 API를 제공할 수 있습니다. 이 외에도 고급 사용 사례의 이점을 누릴 수 있는 많은 사례가 있습니다. 예를 들어 병렬 처리 라이브러리에서는 컨텍스트에서 값을 입력받아 클로저를 N회 병렬로 실행하는 parallelForEachN 알고리즘을 정의합니다. 이 값은 모든 유형이 될 수 있습니다:
fn parallelize[func: fn (Int) -> None](num_work_items: Int):
# Not actually parallel: see the 'Functional' module for real implementation.
for i in range(num_work_items):
func(i)
이것이 중요한 또 다른 예는 이질적인 유형 목록에 대해 알고리즘이나 데이터 구조를 정의해야 할 수 있는 가변 제네릭의 경우입니다:
#struct Tuple[*ElementTys: AnyType]:
# var _storage : ElementTys
을 추가하여 표준 라이브러리에서 튜플(및 함수 같은 관련 유형)을 완전히 정의할 수 있게 됩니다. 아직 구현되지는 않았지만 곧 구현될 예정입니다.
alias: 명명된 매개변수 표현식
컴파일 시간 값에 이름을 지정하는 것은 매우 흔한 일입니다. var는 런타임 값을 정의하고 let은 런타임 상수를 정의하는 반면, 컴파일 시간 임시 값을 정의하는 방법이 필요합니다. 이를 위해 Mojo는 별칭 선언을 사용합니다. 예를 들어, DType 구조체는 다음과 같이 열거자에 별칭을 사용하여 간단한 열거형을 구현합니다(실제 내부 구현 내용은 약간 다릅니다):
struct dtype:
alias invalid = 0
alias bool = 1
alias si8 = 2
alias ui8 = 3
alias si16 = 4
alias ui16 = 5
alias f32 = 15
이를 통해 클라이언트는 DType.f32를 매개변수 표현식(물론 런타임 값으로도 작동)으로 자연스럽게 사용할 수 있습니다.
유형은 별칭의 또 다른 일반적인 용도입니다. 유형은 컴파일 타임 표현식일 뿐이므로 이와 같은 작업을 수행할 수 있으면 매우 편리합니다:
alias F16 = SIMD[DType.f16, 1]
alias UI8 = SIMD[DType.ui8, 1]
var x : F16 # F16 works like a "typedef"
var 및 let과 마찬가지로 별칭은 범위를 따르며 예상대로 함수 내에서 로컬 별칭을 사용할 수 있습니다.
자동 튜닝 및 적응형 편집
일반적인 머신의 벡터 길이는 데이터 유형에 따라 다르며 bfloat16과 같은 일부 데이터 유형은 모든 구현에서 완벽하게 지원되지 않기 때문에 벡터 길이조차 관리하기 어려울 수 있습니다. Mojo는 표준 라이브러리에 자동 조정 기능을 제공하여 도움을 줍니다. 예를 들어 데이터 버퍼에 벡터 길이에 구애받지 않는 알고리즘을 작성하려는 경우 다음과 같이 작성할 수 있습니다:
from Autotune import autotune
from Pointer import DTypePointer
from Functional import vectorize
fn buffer_elementwise_add[
dt: DType
](lhs: DTypePointer[dt], rhs: DTypePointer[dt], result: DTypePointer[dt], N: Int):
"""Perform elementwise addition of N elements in RHS and LHS and store
the result in RESULT.
"""
@parameter
fn add_simd[size: Int](idx: Int):
let lhs_simd = lhs.simd_load[size](idx)
let rhs_simd = rhs.simd_load[size](idx)
result.simd_store[size](idx, lhs_simd + rhs_simd)
# Pick vector length for this dtype and hardware
alias vector_len = autotune(1, 4, 8, 16, 32)
# Use it as the vectorization length
vectorize[vector_len, add_simd](N)
이제 평소처럼 함수를 호출할 수 있습니다:
let N = 32
let a = DTypePointer[DType.f32].alloc(N)
let b = DTypePointer[DType.f32].alloc(N)
let res = DTypePointer[DType.f32].alloc(N)
# Initialize arrays with some values
for i in range(N):
a.store(i, 2.0)
b.store(i, 40.0)
res.store(i, -1)
buffer_elementwise_add[DType.f32](a, b, res, N)
print(a.load(10), b.load(10), res.load(10))
이 코드의 인스턴스를 컴파일할 때 Mojo는 이 알고리즘의 컴파일을 포크하고 대상 하드웨어에서 실제로 가장 잘 작동하는 값을 측정하여 사용할 값을 결정합니다. 이 알고리즘은 벡터_len 표현식의 다양한 값을 평가하고 사용자 정의 성능 평가기에 따라 가장 빠른 값을 선택합니다. 각 옵션을 개별적으로 측정하고 평가하기 때문에, 예를 들어 F32의 경우 SI8과 다른 벡터 길이를 선택할 수 있습니다. 함수와 유형도 매개변수 표현식이기 때문에 이 간단한 기능은 단순한 정수 상수를 뛰어넘는 매우 강력한 기능입니다.
자동 튜닝은 본질적으로 기하급수적인 기술로, Mojo 컴파일러 스택의 내부 구현 세부 사항(특히 MLIR, 통합 캐싱 및 컴파일 배포)의 이점을 활용합니다. 또한 이 기능은 파워유저를 위한 기능이며 시간이 지남에 따라 지속적인 개발과 반복이 필요합니다.
위의 예제에서는 성능 평가기 함수를 정의하지 않았고 컴파일러가 사용 가능한 구현 중 하나를 선택했습니다. 하지만 다른 노트북에서 이를 수행하는 방법에 대해 자세히 알아보려면 "행렬 곱셈" 및 "Mojo의 빠른 멤셋"을 확인해 보시기 바랍니다.
인수 전달 제어 및 메모리 소유권
파이썬과 모조 모두 언어의 많은 부분이 함수 호출을 중심으로 이루어지며, (겉으로 보기에) 내장된 기능의 대부분은 표준 라이브러리에서 "던더" 메서드를 통해 구현됩니다. Mojo는 가장 기본적인 것들(예: 정수 및 객체 유형 자체)을 표준 라이브러리에 넣음으로써 Python보다 한 단계 더 나아갑니다.
인수 규칙이 중요한 이유
파이썬에서 모든 기본 값은 객체에 대한 참조이며, 파이썬 프로그래머는 일반적으로 프로그래밍 모델을 모든 것이 참조 의미로 이루어진 것으로 생각합니다. 그러나 CPython 또는 머신 수준에서는 포인터를 복사하고 참조 횟수를 조정하여 참조 자체가 실제로 복사본으로 전달된다는 것을 알 수 있습니다.
반면에 Mojo는 값 복사, 참조 앨리어싱, 변형을 완벽하게 제어할 수 있습니다.
By-참조 인자
값에 대한 변경 가능한 참조를 전달하는 것과 변경 불가능한 참조를 전달하는 것의 간단한 경우부터 살펴보겠습니다. 이미 알고 있듯이 fn에 전달되는 인수는 기본적으로 변경 불가능합니다:struct MyInt:
var value: Int
fn __init__(self&, v: Int):
self.value = v
fn __copyinit__(self&, other: MyInt):
self.value = other.value
# self and rhs are both immutable in __add__.
fn __add__(self, rhs: MyInt) -> MyInt:
return MyInt(self.value + rhs.value)
# ... but this cannot work for __iadd__
# Uncomment to see the error:
#fn __iadd__(self, rhs: Int):
# self = self + rhs # ERROR: cannot assign to self!
여기서 문제는 __iadd__가 정수의 내부 상태를 변경해야 한다는 것입니다. Mojo의 해결책은 인수 이름(이 경우 자체)에 & 마커를 사용하여 인수가 "참조로" 전달되도록 선언하는 것입니다:
struct MyInt:
var value: Int
fn __init__(self&, v: Int):
self.value = v
fn __copyinit__(self&, other: MyInt):
self.value = other.value
# self and rhs are both immutable in __add__.
fn __add__(self, rhs: MyInt) -> MyInt:
return MyInt(self.value + rhs.value)
# ... now this works:
fn __iadd__(self&, rhs: Int):
self = self + rhs # OK
이 인수는 참조로 전달되기 때문에 호출자에서 자체 인수를 변경할 수 있으며, 호출자가 배열 첨자와 같이 사소하지 않은 계산을 통해 액세스하는 경우에도 호출자에게 변경 사항이 표시됩니다:
var x = 42
x += 1
print(x) # prints 43 of course
var a = Array[Int](16, 0)
a[4] = 7
a[4] += 1
print(a[4]) # Prints 8
let y = x
# Uncomment to see the error:
# y += 1 # ERROR: Cannot mutate 'let' value
Mojo는 임시 버퍼에 __getitem__을 호출한 다음 호출 후 __setitem__을 저장하여 배열 요소의 제자리 변형을 구현합니다. 불변 값에 대한 변경 가능한 참조를 형성할 수 없기 때문에 let 값의 변경은 실패합니다. 마찬가지로 컴파일러는 __getitem__을 구현하지만 __setitem__을 구현하지 않는 경우 by-ref 인수와 함께 첨자를 사용하려는 시도를 거부합니다.
Mojo에서 self는 특별한 것이 아니며, 여러 개의 다른 참조 인수를 가질 수 있습니다. 예를 들어 다음과 같이 스왑 함수를 정의하고 사용할 수 있습니다:
fn swap(lhs&: Int, rhs&: Int):
let tmp = lhs
lhs = rhs
rhs = tmp
var x = 42
var y = 12
print(x, y) # Prints 42, 12
swap(x, y)
print(x, y) # Prints 12, 42
“Borrowed” 인수 규칙
이제 참조 인자 전달이 어떻게 작동하는지 알았으니, 값 인자 전달이 어떻게 작동하는지, 그리고 이것이 복사 생성자를 구현하는 __copyinit__ 메서드와 어떻게 상호작용하는지 궁금할 것입니다. Mojo에서 함수에 인수를 전달하는 기본 규칙은 "빌린" 인수 규칙을 사용하여 전달하는 것입니다. 원하는 경우 이 규칙을 명시적으로 지정할 수 있습니다:
# A type that is so expensive to copy around we don't even have a
# __copyinit__ method.
struct SomethingBig:
var id_number: Int
var huge: Array[Int]
fn __init__(self&, id: Int):
self.huge = Array[Int](1000, 0)
self.id_number = id
# self is passed by-reference for mutation as described above.
fn set_id(self&, number: Int):
self.id_number = number
# Arguments like self are passed as borrowed by default.
fn print_id(self): # Same as: fn print_id(borrowed self):
print(self.id_number)
fn use_something_big(borrowed a: SomethingBig, b: SomethingBig):
"""'a' and 'b' are passed the same, because 'borrowed' is the default."""
a.print_id()
b.print_id()
let a = SomethingBig(10)
let b = SomethingBig(20)
use_something_big(a, b)
이 기본값은 메서드의 자체 인수를 포함하여 모든 인자에 균일하게 적용됩니다. 차용된 규칙은 값을 복사하는 대신 호출자의 컨텍스트에서 값에 대한 변경 불가능한 참조를 전달합니다. 인수를 전달할 때 복사 생성자와 소멸자를 호출할 필요가 없기 때문에 큰 값을 전달할 때나 참조 카운트 포인터와 같은 값비싼 값을 전달할 때 훨씬 더 효율적입니다(Python/Mojo 클래스의 기본값). 위의 코드를 기반으로 좀 더 정교한 예제를 만들어 보겠습니다:
fn try_something_big():
# Big thing sits on the stack: after we construct it it cannot be
# moved or copied.
let big = SomethingBig(30)
# We still want to do useful things with it though!
big.print_id()
# Do other things with it.
use_something_big(big, big)
try_something_big()
기본 인수 규칙을 차용했기 때문에 기본적으로 올바른 작업을 수행하는 매우 간단하고 논리적인 코드를 얻을 수 있습니다. 예를 들어 print_id 메서드를 호출하기 위해 또는 use_something_big을 호출할 때 SomethingBig을 모두 복사하거나 이동하고 싶지 않습니다.
차용 규칙은 다른 언어와 유사하며 선례가 있습니다. 예를 들어, 차용된 인수 규칙은 C++에서 const&로 인수를 전달하는 것과 몇 가지 면에서 유사합니다. 이렇게 하면 값의 복사본을 피할 수 있고 호출자의 가변성을 비활성화할 수 있습니다. 하지만 차용된 규칙은 C++의 const&와 두 가지 중요한 점에서 다릅니다:
- Mojo 컴파일러는 변경 불가능한 참조가 남아 있을 때 코드가 값에 대한 변경 가능한 참조를 동적으로 형성하는 것을 방지하고 동일한 값에 대해 여러 개의 변경 가능한 참조를 갖지 못하도록 하는 차용 검사기(Rust와 유사)를 구현합니다. 위의 use_something_big 호출처럼 여러 번 차용하는 것은 허용되지만, 변경 가능한 참조로 무언가를 전달하면서 동시에 차용하는 것은 불가능합니다. (토도: 현재 활성화되지 않음).
- Int, Float, SIMD와 같은 작은 값은 추가 인다이렉션을 거치지 않고 머신 레지스터에서 직접 전달됩니다(@register_passable 데코레이터로 선언되기 때문입니다. 아래 참조). 이는 C++ 및 Rust와 같은 언어와 비교할 때 상당한 성능 향상이며, 이러한 최적화를 모든 호출 사이트에서 유형에 대한 선언적 방식으로 전환합니다.
Rust는 또 다른 중요한 언어이며 Mojo와 Rust 차용 검사기는 동일한 배타성 불변성을 적용합니다. Rust와 Mojo의 주요 차이점은 호출자 측에서 차용을 통해 전달할 때 인장이 필요하지 않고, 작은 값을 전달할 때 Mojo가 더 효율적이며, 차용을 통해 값을 전달하는 대신 기본적으로 값을 이동한다는 점입니다. 이러한 정책과 구문 결정 덕분에 Mojo는 틀림없이 더 사용하기 쉬운 프로그래밍 모델을 제공할 수 있습니다.
“Owned” 인수 규칙
예를 들어 고유 포인터와 같은 이동 전용 유형으로 작업하는 경우를 생각해 보세요:
# This is not really a unique pointer, we just model its behavior here:
struct UniquePointer:
var ptr: Int
fn __init__(self&, ptr: Int):
self.ptr = ptr
fn __moveinit__(self&, owned existing: Self):
self.ptr = existing.ptr
fn __del__(owned self):
self.ptr = 0
복사를 시도하면 오류가 올바르게 발생합니다:
let p = UniquePointer(100)
# Uncomment to see the error:
# let q = p # ERROR: value of type 'UniquePointer' cannot be copied into its destination
fn use_ptr(borrowed p: UniquePointer):
print("use_ptr")
print(p.ptr)
fn take_ptr(owned p: UniquePointer):
print("take_ptr")
print(p.ptr)
fn work_with_unique_ptrs():
let p = UniquePointer(100)
use_ptr(p) # Perfectly fine to pass to borrowing function.
use_ptr(p)
take_ptr(p^) # Pass ownership of the `p` value to another function.
# Uncomment to see an error:
# use_ptr(p) # ERROR: p is no longer valid here!
work_with_unique_ptrs()
차용 규칙을 사용하면 특별한 절차 없이 고유 포인터로 쉽게 작업할 수 있지만, 언젠가는 소유권을 다른 함수로 이전하고 싶을 수 있습니다. 이것이 바로 ^ 연산자가 하는 일입니다.
이동 가능 타입의 경우 ^ 연산자는 값 바인딩의 수명을 종료하고 값을 다른 것(이 경우 take_ptr 함수)으로 전송합니다. 이를 지원하기 위해 함수를 소유 인수를 받는 것으로 정의할 수 있습니다(예: take_ptr을 다음과 같이 정의):
예를 들어, 소멸자나 이동 이니셜라이저에서 소유 규칙을 볼 수 있습니다. 예를 들어, HeapArray는 __del__ 메서드에서 이 규칙을 사용했는데, 이는 값을 파괴하거나 그 일부를 훔치려면 값을 소유해야 하기 때문입니다!
파괴하거나 부품을 훔치려면 값을 소유해야 하기 때문입니다!
@register_passable struct decorator
위에서 설명한 것처럼 값으로 작업하는 기본 기본 모델은 값이 메모리에 존재하여 정체성을 가지며, 이는 함수와 간접적으로 전달된다는 것을 의미합니다(즉, 기계 수준에서 '참조로' 전달됨). 이는 이동할 수 없는 타입에 적합하며, 큰 객체나 복사 작업이 많은 사물에 대해 안전한 기본값으로 사용하기에 좋습니다. 그러나 단일 정수나 부동 소수점 숫자와 같은 작은 것에는 정말 비효율적입니다!
이 문제를 해결하기 위해 Mojo는 @register_passable 데코레이터를 사용하여 구조체가 메모리를 통과하는 대신 레지스터에서 전달되도록 선택할 수 있도록 합니다. 이 데코레이터는 표준 라이브러리에서 Int와 같은 타입에서 볼 수 있습니다:
@register_passable("trivial")
struct MyInt:
var value: Int
fn __init__(value: Int) -> Self:
return Self {value: value}
let x = MyInt(10)
기본 @register_passable 데코레이터는 타입의 기본 동작을 변경하지 않습니다. 복사 가능하려면 여전히 __copyinit__ 메서드가 있어야 하고, 여전히 __init__ 및 __del__ 메서드가 있을 수 있습니다. 이 데코레이터의 주요 효과는 내부 구현 세부 사항에 있습니다: 레지스터_패스가능 타입은 일반적으로 머신 레지스터에서 전달됩니다(물론 기본 아키텍처의 세부 사항에 따라 달라질 수 있습니다).
이 데코레이터의 효과는 일반적인 Mojo 프로그래머가 관찰할 수 있는 몇 가지에 불과합니다:
- @register_passable types are not being able to hold instances of types that are not themselves @register_passable.
- instances of @register_passable types do not have predictable identity, and so the ‘self’ pointer is not stable/predictable (e.g. in hash tables).
- @register_passable arguments and result are exposed to C and C++ directly, instead of being passed by-pointer.
- The __init__ and __copyinit__ methods of this type are implicitly static (like __new__ in Python) and return its result by-value instead of taking self&.
이 데코레이터는 핵심 표준 라이브러리 유형에 널리 사용될 것으로 예상되지만 일반적인 애플리케이션 수준 코드에서는 무시해도 안전합니다.
위의 MyInt 예제는 실제로 이 데코레이터의 "사소한" 변형을 사용합니다. 위에서 설명한 대로 전달 규칙을 변경하지만 생성자와 소멸자의 복사 및 이동도 허용하지 않습니다(모두 사소하게 합성).이 데코레이터는 핵심 표준 라이브러리 유형에 널리 사용될 것으로 예상되지만 일반적인 애플리케이션 수준 코드에서는 무시해도 안전합니다.
위의 MyInt 예제는 실제로 이 데코레이터의 "사소한" 변형을 사용합니다. 위에서 설명한 대로 전달 규칙을 변경하지만 생성자와 소멸자의 복사 및 이동도 허용하지 않습니다(모두 사소하게 합성).
Advanced Mojo features
이 섹션에서는 표준 라이브러리의 최하위 레벨을 빌드하는 데 중요한 파워 유저 기능에 대해 설명합니다. 이 수준의 스택에는 컴파일러 내부에 대한 경험이 있어야 효과적으로 이해하고 활용할 수 있는 좁은 기능들이 있습니다.
@always_inline decorator
고성능 커널을 구현하려면 컴파일러가 코드에 적용하는 최적화를 제어하는 것이 중요한 경우가 많습니다. 필요한 최적화를 활성화하고 원하지 않는 최적화를 비활성화할 수 있어야 합니다. 기존 컴파일러는 일반적으로 다양한 휴리스틱을 사용하여 특정 최적화를 적용할지 여부를 결정합니다(예: 호출을 인라인 처리할지 여부 또는 루프를 언롤링할지 여부). 이는 보통 괜찮은 기준선을 제공하지만 예측하기 어려운 경우가 많습니다. 그래서 Mojo는 컴파일러 최적화를 완벽하게 제어할 수 있는 특별한 데코레이터를 도입했습니다.
첫 번째로 보여드릴 데코레이터는 @always_inline입니다. 이 데코레이터는 함수에 사용되며 함수가 호출될 때 컴파일러가 이 함수를 항상 인라인 처리하도록 지시합니다.
@always_inline
fn foo(x: Int, y: Int) -> Int:
return x + y
fn bar(z: Int):
let r = foo(z, z) # This call will be inlined
향후에는 컴파일러가 함수를 인라인 처리하지 못하도록 하는 반대 데코레이터와 루프 언롤링과 같은 다른 최적화를 제어할 수 있는 유사한 데코레이터도 도입할 예정입니다.
Direct access to MLIR
Mojo는 MLIR 위에 구축되었을 뿐만 아니라 MLIR에 액세스할 수 있는 방법도 제공합니다. 이를 통해 모든 하드웨어 타겟과 통합할 수 있으며 Mojo 컴파일러가 생성하는 코드가 우리가 원하는 것과 정확히 일치하는지 확인할 수 있습니다. 이는 컴파일러에 의존하지 않고 하드웨어별 기능을 직접 활용하고자 할 때 매우 중요합니다.
예를 들어 이 기능은 SIMD 유형 구현을 뒷받침하는 데 사용됩니다. 이 기능에 대해 더 자세히 알고 싶으시다면, BoolMLIR 노트북을 통해 미리 맛보실 수 있습니다.