Wasmtime/Cranelift에 함수 인라이닝을 도입한 배경, 설계(병렬 컴파일과 결정성 유지), SCC 기반 스케줄링, 그리고 초기 성능 결과를 설명한다.
참고: 이 글은 Bytecode Alliance 블로그에도 교차 게시했습니다.
함수 인라이닝은 가장 중요한 컴파일러 최적화 중 하나입니다. 직접적인 효과 때문이라기보다, 인라이닝이 후속 최적화를 가능하게 해 주기 때문입니다. 예를 들어, 원래는 알 수 없던 함수 매개변수 값이 어떤 상수 인자에 묶였다는 사실이 드러나면, 조건 분기가 무조건 분기로 바뀌고, 그 결과 해당 함수가 항상 같은 값을 반환한다는 사실이 노출될 수도 있습니다. 인라이닝은 현대 컴파일러 최적화의 촉매제입니다.
Wasmtime는 안전성과 빠른 Wasm 실행에 초점을 맞춘 WebAssembly 런타임입니다. 하지만 속도에 집중하면서도 Wasmtime는 역사적으로 최적화 컴파일러 백엔드인 Cranelift에서 인라이닝을 수행하지 않기로 선택해 왔습니다. 이 놀라운 결정에는 두 가지 이유가 있었습니다. 첫째, Cranelift는 함수 단위(per-function) 컴파일러로 설계되어 Wasmtime가 Wasm 모듈의 모든 함수를 병렬로 컴파일할 수 있게 합니다. 인라이닝은 절차 간(inter-procedural) 최적화이며 함수 컴파일 사이의 동기화를 필요로 하는데, 그 동기화가 병렬성을 떨어뜨립니다. 둘째, Wasm 모듈은 일반적으로 LLVM 같은 최적화 도구체인에 의해 생성되며, 그 과정에서 이미 유익한 인라이닝이 대부분 수행됩니다. 따라서 모듈에 남아 있는 호출은 인라이닝으로 이득을 보지 못하는 경우가 많습니다. 예컨대 [[unlikely]]로 표시된 느린 경로에 있거나, 피호출 함수가 #[inline(never)]로 주석 처리되어 있을 수 있습니다. 하지만 WebAssembly의 컴포넌트 모델은 이 계산을 바꿉니다.
컴포넌트 모델에서는 개발자가 서로 다른 도구체인으로 생성된 여러 Wasm 모듈을 하나의 프로그램으로 조합할 수 있습니다. 이 도구체인들은 자신이 만든 모듈 내부로 제한된 로컬 호출 그래프만 볼 수 있었고, 모듈 간 호출이나 퓨즈드 어댑터 함수 정의를 볼 수 없었습니다. 따라서 그런 함수들에 대한 호출을 인라인할 기회가 없었습니다. 최종적으로 완전한 호출 그래프와 함수 정의를 손에 쥐는 것은 Wasm 런타임의 컴파일러뿐이며, 오직 그것만이 그 기회를 갖습니다.
그래서 우리는 Wasmtime와 Cranelift에 함수 인라이닝을 구현했습니다. 초기 구현은 Wasmtime 36 버전에 들어갔지만, 기본값으로는 꺼져 있고 아직 다듬는 중입니다. -C inlining=y 커맨드라인 플래그나 wasmtime::Config::compiler_inlining 메서드로 시험해 볼 수 있습니다. 이 글의 나머지 부분에서는 함수 인라이닝을 더 자세히 설명하고, 구현의 내부와 설계 선택의 근거를 파고든 다음, 초기 성능 결과를 살펴봅니다.
함수 인라이닝은 함수 f에 대한 호출을 f의 본문 복사본으로 대체하는 컴파일러 최적화입니다. 이는 함수 호출 오버헤드(호출자 저장 레지스터의 스필, 호출 프레임 설정 등)를 제거하며, 그 자체로도 이득이 될 수 있습니다. 하지만 인라이닝의 주된 이점은 간접적입니다. 호출 지점의 문맥 안에서 f의 본문이 후속 최적화될 수 있게 해 줍니다. 이 문맥은 중요합니다. 이전에는 알 수 없었던 매개변수의 값이 상수 인자에 바인딩될 수 있고, 그 사실을 최적화기에 노출하면 큰 규모의 코드 정리가 연쇄적으로 일어날 수 있습니다.
다음 예제를 보겠습니다. 함수 g가 함수 f를 호출합니다:
fn f(x: u32) -> bool {
return x < u32::MAX / 2;
}
fn g() -> u32 {
let a = 42;
if f(a) {
return a;
} else {
return 0;
}
}
f 호출을 인라인한 뒤 함수 g는 대략 이렇게 보입니다:
fn g() -> u32 {
let a = 42;
let x = a;
let f_result = x < u32::MAX / 2;
if f_result {
return a;
} else {
return 0;
}
}
이제 f_result를 정의하는 전체 부분식은 상수 값들에만 의존하므로, 최적화기는 그 부분식을 이미 알려진 값으로 대체할 수 있습니다:
fn g() -> u32 {
let a = 42;
let f_result = true;
if f_result {
return a;
} else {
return 0;
}
}
이로써 if-else 조건이 실제로는 무조건적으로 참 분기(then)로 제어를 넘긴다는 사실이 드러나고, g는 다음처럼 단순화될 수 있습니다:
fn g() -> u32 {
let a = 42;
return a;
}
개별적으로 보면 f를 인라인하는 것은 미미한 변환이었습니다. 하지만 전체적으로 보면, 인라이닝은 이후의 수많은 단순화를 열어 주었고, 결국 g가 런타임에 아무 것도 계산하지 않고 상수 값을 반환하게 만들었습니다.
Cranelift의 컴파일 단위는 단일 함수이며, Wasmtime는 이를 활용해 Wasm 모듈의 각 함수를 병렬로 컴파일하여 멀티코어 시스템에서 컴파일 시간을 단축합니다. 하지만 특정 호출 지점에서 함수를 인라인하려면 그 함수의 정의가 필요합니다. 이는 병렬성을 해치는 동기화나, 혹은 함수 본문을 추가로 읽기 전용 복사본으로 유지하는 등의 타협을 암시합니다. 그래서 구현의 첫 번째 목표는 가능한 한 많은 병렬성을 유지하는 것이었습니다.
또한 Cranelift는 주로 Wasmtime 개발자들이 Wasmtime를 위해 개발하지만, Wasmtime와는 독립적입니다. Cranelift는 재사용 가능한 라이브러리이며 예를 들어 Rust 프로젝트에서 rustc의 대체 백엔드로 재사용되기도 합니다. 그런데 실제로 인라이닝의 큰 부분은 “언제 인라이닝이 유익할 가능성이 큰가”를 결정하는 휴리스틱이며, 이 휴리스틱은 도메인에 따라 달라질 수 있습니다. Wasmtime는 일반적으로 대부분의 호출을 아웃오브라인으로 남겨 두고 모듈 간 호출만 인라인하고 싶어합니다. 반면 rustc는 Iterator 조합자 같은 것들을 녹여 없애기 위해 훨씬 공격적인 전략이 필요합니다. 그래서 두 번째 구현 목표는 “함수 호출을 어떻게 인라인하는가”와 “그 호출을 인라인할지 여부를 어떻게 결정하는가”를 분리하는 것이었습니다.
이 목표들은 계층화된 설계로 이어졌습니다. Cranelift에는 선택적으로 사용할 수 있는 인라이닝 패스가 있지만, Cranelift를 임베드하는 쪽(예: Wasmtime)이 콜백을 제공해야 합니다. 인라이닝 패스는 각 호출 지점에 대해 콜백을 호출하고, 콜백은 “호출을 그대로 둔다” 또는 “여기 함수 본문이 있으니 호출을 이것으로 대체하라” 중 하나의 명령을 돌려줍니다. Cranelift는 인라이닝 변환 자체를 담당하고, 임베더는 인라이닝 여부 결정과(필요하다면) 함수 본문을 가져오는 일(그에 필요한 동기화 포함)을 담당합니다.
인라이닝 변환의 기계적 과정—인자를 매개변수에 연결하고, 값을 리네임하며, 명령과 기본 블록을 호출자에 복사하는 것—은 말 그대로 기계적인 일입니다. Cranelift의 IR은 다양한 엔티티에 대해 아레나(arena)를 광범위하게 사용하며, 우리는 먼저 피호출자의 아레나들을 호출자의 아레나들 뒤에 덧붙입니다. 이때 피호출자의 아레나 인덱스를 호출자의 새 인덱스로 바꾸면서 엔티티 참조를 리네임합니다. 그다음 피호출자의 블록 레이아웃을 호출자에 복사하고, 원래 call 명령을 호출자에 인라인된 피호출자 엔트리 블록으로의 jump로 바꿉니다. Cranelift는 phi 노드 대신 블록 파라미터를 사용하므로 호출 인자들은 단순히 jump 인자가 됩니다. 마지막으로 피호출자의 각 명령을 호출자에 번역합니다. 이는 값 정의를 값 사용보다 먼저 처리해 명령 피연산자 재작성(rewriting)을 단순화하기 위해 전위 순회(pre-order traversal)로 수행됩니다. Wasmtime의 컴파일 오케스트레이션 변경이 더 흥미롭습니다.
다음 의사코드는 Cranelift에 인라이닝 패스가 생기기 전과, 인라이닝이 비활성화되어 있을 때의 Wasmtime 컴파일 오케스트레이션을 설명합니다:
// 각 함수를 병렬로 컴파일.
let objects = parallel map for func in wasm.functions {
compile(func)
};
// 함수들을 하나의 실행 가능 메모리 영역으로 합치고,
// 함수 참조를 PC-상대 오프셋에 매핑해 재배치(relocation)를 해결.
return link(objects)
이 과정을 Cranelift 인라이닝 패스를 사용하도록 순진하게 업데이트하면 대략 이렇게 될 수 있습니다:
// 선택적으로, 인라이닝 전 최적화를 병렬로 수행.
parallel for func in wasm.functions {
pre_optimize(func);
}
// 인라이닝은 순차적으로 수행.
for func in wasm.functions {
func.inline(|f| if should_inline(f) {
Some(wasm.functions[f])
} else {
None
})
}
// 그리고 이전과 동일하게 진행.
let objects = parallel map for func in wasm.functions {
compile(func)
};
return link(objects)
인라이닝이 병렬이 아니라 순차적으로 수행되는 점이 아쉽습니다. 하지만 이 루프를 병렬로 만들기 위해 각 함수의 인라이닝 패스를 별도 스레드에서 돌린다고 가정해 보면, 어떤 피호출 함수의 전이적 호출(transitive calls)이 이미 인라인되어 있을 수도 있고 아닐 수도 있는데, 이는 스케줄러 마음대로가 됩니다. 그 결과 출력이 비결정적(non-deterministic)이 되며, 우리의 컴파일은 결정적이어야 하므로 불가능합니다.1 그런데 어떤 함수에 대해 전이적 인라이닝이 이미 수행되었는지 여부는 또 다른 문제를 일으킵니다.
이 순진한 접근에서는 인라이닝을 한 겹으로 제한하거나, 아니면 인라이닝 작업을 중복할 위험이 있습니다. 즉, f를 g, h, i에 인라인할 때마다 매번 e를 f에 다시 인라인하게 될 수 있습니다. 이는 wasm.functions 리스트에서 f가 g보다 앞에 있을 수도 뒤에 있을 수도 있기 때문입니다. 우리는 f가 이미 e를 포함하고 그에 맞게 최적화되어 있기를 원합니다. 그래야 f의 모든 호출자가 f를 인라인할 때 동일한 작업을 반복하지 않아도 됩니다.
이는 호출 그래프에 따라 함수를 위상 정렬(topological sort)하여, 다른 함수를 호출하지 않는 리프 함수에서 시작해(leaf functions) 호출되지 않는 루트 함수(보통 main 및 최상위 export 함수)로 올라가는 바텀업 방식으로 인라인해야 함을 시사합니다. 위상 정렬이 주어지면 f를 g에 인라인할 때 (a) f가 이미 자기 자신의 인라이닝을 끝냈거나, (b) f와 g가 사이클에 참여한다는 것을 알 수 있습니다. (a)는 이상적입니다. 작업이 이미 끝났으므로 반복하지 않습니다. (b)에서 사이클을 발견한다는 것은 f와 g가 상호 재귀라는 뜻입니다. 일반적으로 재귀 호출은 완전히 인라인할 수 없으므로(일반적으로 루프를 완전히 언롤할 수 없는 것과 마찬가지) 우리는 이런 호출은 인라인을 피할 것입니다.2 그래서 위상 정렬은 작업 반복을 피하지만, 인라이닝 단계 자체는 여전히 순차적입니다.
우리가 제안한 위상 정렬의 핵심에는 “호출자보다 피호출자를 먼저 방문하는” 호출 그래프 순회가 있습니다. 인라이닝을 병렬화하려면, 호출 그래프를 순회하면서 각 호출자 함수가 아직 인라인되지 않은 피호출자를 몇 개나 기다리고 있는지 추적하는 방식을 상상할 수 있습니다. 그런 다음 해당 카운트가 0인 함수들(즉, 먼저 인라인되어야 할 다른 함수가 없는 함수들)을 하나의 레이어(layer)로 묶어 병렬로 처리합니다. 다음으로 그 함수들의 호출자 카운트를 감소시키고, 준비된 다음 레이어를 수집하는 일을 모든 함수가 처리될 때까지 반복합니다.
let call_graph = CallGraph::new(wasm.functions);
let counts = { f: call_graph.num_callees_of(f) for f in wasm.functions };
let layer = [ f for f in wasm.functions if counts[f] == 0 ];
while layer is not empty {
parallel for func in layer {
func.inline(...);
}
let next_layer = [];
for func in layer {
for caller in call_graph.callers_of(func) {
counts[caller] -= 1;
if counts[caller] == 0 {
next_layer.push(caller)
}
}
}
layer = next_layer;
}
이 알고리즘은 가용 병렬성을 활용하고, 위상 정렬이 했던 것과 같은 의존성 기반 스케줄링을 통해 작업 반복도 피합니다. 하지만 결함이 있습니다. 호출 그래프에서 재귀 사이클을 만나면 종료하지 못합니다. 예컨대 함수 f가 g를 호출하고 g도 f를 호출하면, 둘 다 서로가 먼저 처리되기를 기다리므로 어느 쪽도 레이어에 스케줄되지 않습니다. 이 문제를 피하는 한 가지 방법은 사이클을 회피하는 것입니다.
그래프의 노드들을 서로소(disjoint) 집합으로 분할하되, 각 집합이 그 안의 모든 노드가 서로 도달 가능한(reachable) 노드들을 모두 포함하도록 하면, 그 그래프의 강결합 컴포넌트 (SCC)를 얻습니다. 어떤 노드가 사이클에 참여하지 않으면 그 노드는 자기 자신만 포함하는 단일 SCC에 속합니다. 반면 사이클의 구성원들은 서로 도달 가능하므로 모두 같은 SCC로 묶입니다.
다음 예제에서 점선 상자는 그래프의 SCC들을 나타냅니다:
같은 SCC 내부의 노드들 사이의 간선은 무시하고, SCC 간 간선만 고려하면 그래프의 _응축(condensation)_을 얻습니다. 응축은 항상 비순환입니다. 원래 그래프의 사이클이 SCC들 내부로 “숨겨졌기” 때문입니다.
앞선 예제의 응축은 다음과 같습니다:
우리는 병렬 인라이닝 알고리즘을 강결합 컴포넌트 단위로 동작하도록 바꿀 수 있고, 그러면 모든 사이클이 제거되었기 때문에 올바르게 종료하게 됩니다. 먼저 호출 그래프의 SCC를 구하고, 역(또는 전치) 응축을 만듭니다. 여기서는 a→b 간선을 b→a로 뒤집습니다. 이는 우리가 어떤 함수 f의 “피호출자”가 아니라 “호출자”를 질의할 것이기 때문입니다. 역응축에 대한 기존 이름은 제가 알지 못합니다. 그래서 Chris Fallin의 기막힌 제안에 따라, 저는 이를 _증발(evaporation)_이라고 부르기로 했습니다. 그 이후 알고리즘은 대체로 이전과 동일하지만, 함수 대신 SCC 기준으로 카운트와 레이어를 추적합니다.
let call_graph = CallGraph::new(wasm.functions);
let components = StronglyConnectedComponents::new(call_graph);
let evaoporation = Evaporation::new(components);
let counts = { c: evaporation.num_callees_of(c) for c in components };
let layer = [ c for c in components if counts[c] == 0 ];
while layer is not empty {
parallel for func in scc in layer {
func.inline(...);
}
let next_layer = [];
for scc in layer {
for caller_scc in evaporation.callers_of(scc) {
counts[caller_scc] -= 1;
if counts[caller_scc] == 0 {
next_layer.push(caller_scc);
}
}
}
layer = next_layer;
}
이것이 Wasmtime에서 사용하는 알고리즘입니다. 몇몇 데이터 구조를 다듬거나 루프를 합치는 등 사소한 수정이 있긴 합니다. 병렬 인라이닝 이후에도 컴파일러 파이프라인의 나머지 단계는 각 함수에 대해 병렬로 계속 진행되어 링크되지 않은 머신 코드를 산출합니다. 마지막으로 우리는 이전과 마찬가지로 그것들을 모두 링크해 재배치를 해결합니다.
남은 구현 세부사항은 휴리스틱뿐이지만, 이미 언급한 것에서 크게 더할 말은 없습니다. Wasmtime는 같은 Wasm 모듈 내 호출을 인라인하지 않기를 선호하지만, 모듈 간 호출은 인라이닝을 고려해야 한다는 강한 힌트입니다. 그 외에 현재 휴리스틱은 매우 순진하여 호출자와 피호출자의 코드 크기만 고려합니다. 여기에는 개선 여지가 아주 많고, 사람들이 인라이너를 사용하기 시작하면 필요에 따라 개선할 계획입니다. 예를 들어 현재 휴리스틱에서 고려하지 않지만 고려해야 할 수도 있는 것들은 다음과 같습니다:
인라이닝을 켰을 때(또는 켜지 않았을 때) 얻는 속도 향상은 프로그램마다 달라질 것입니다. 여기에는 몇 가지 합성(synthetic) 벤치마크가 있습니다.
먼저 가능한 가장 단순한 경우를 살펴봅시다. 루프 안에서 빈 함수를 모듈 간 호출하는 경우입니다:
(component
;; 하나의 모듈을 정의하고, 빈 함수 `f`를 export.
(core module $M
(func (export "f")
nop
)
)
;; 다른 모듈을 정의하고 `f`를 import한 다음,
;; 루프에서 `f`를 호출하는 함수를 export.
(core module $N
(import "m" "f" (func $f))
(func (export "g") (param $counter i32)
(loop $loop
;; counter가 0이면 반환.
(if (i32.eq (local.get $counter) (i32.const 0))
(then (return)))
;; 모듈 간 호출.
(call $f)
;; counter를 감소시키고 다음 루프 반복으로.
(local.set $counter (i32.sub (local.get $counter)
(i32.const 1)))
(br $loop))
)
)
;; 모듈을 인스턴스화하고 링크.
(core instance $m (instantiate $M))
(core instance $n (instantiate $N (with "m" (instance $m))))
;; 루프 함수를 lift해서 export.
(func (export "g") (param "n" u32)
(canon lift (core func $n "g"))
)
)
이를 wasmtime compile과 wasmtime objdump 명령으로 컴파일된 머신 코드를 확인할 수 있습니다. 루프 함수를 중심으로 보겠습니다. 인라이닝이 없으면 예상대로 호출을 감싼 루프를 볼 수 있습니다:
00000020 wasm[1]::function[1]:
;; 함수 프롤로그.
20: pushq %rbp
21: movq %rsp, %rbp
;; 스택 오버플로 검사.
24: movq 8(%rdi), %r10
28: movq 0x10(%r10), %r10
2c: addq $0x30, %r10
30: cmpq %rsp, %r10
33: ja 0x89
;; 이 함수의 스택 프레임을 할당하고,
;; callee-save 레지스터를 저장하며,
;; 일부 레지스터를 셔플.
39: subq $0x20, %rsp
3d: movq %rbx, (%rsp)
41: movq %r14, 8(%rsp)
46: movq %r15, 0x10(%rsp)
4b: movq 0x40(%rdi), %rbx
4f: movq %rdi, %r15
52: movq %rdx, %r14
;; 루프 시작.
;;
;; 카운터가 0인지 테스트하고, 0이면 빠져나감.
55: testl %r14d, %r14d
58: je 0x72
;; 모듈 간 호출.
5e: movq %r15, %rsi
61: movq %rbx, %rdi
64: callq 0
;; 카운터 감소.
69: subl $1, %r14d
;; 다음 반복으로.
6d: jmp 0x55
;; 함수 에필로그: callee-save 레지스터 복구,
;; 스택 프레임 해제.
72: movq (%rsp), %rbx
76: movq 8(%rsp), %r14
7b: movq 0x10(%rsp), %r15
80: addq $0x20, %rsp
84: movq %rbp, %rsp
87: popq %rbp
88: retq
;; 아웃오브라인 트랩.
89: ud2
╰─╼ trap: StackOverflow
인라이닝을 활성화하면 M::f가 N::g 안으로 인라인됩니다. N::g가 리프 함수가 되더라도, Wasmtime는 항상 프레임 포인터를 활성화하므로 프롤로그에서 push %rbp 등을 수행하고 에필로그에서 이를 팝합니다. 하지만 ABI 인자 레지스터로 값을 셔플하거나 스택 공간을 할당할 필요가 없어지므로 명시적인 스택 검사도 필요 없고, 나머지 코드 대부분도 사라집니다. 남는 것은 카운터를 0까지 감소시키는 루프뿐입니다:3
00000020 wasm[1]::function[1]:
;; 함수 프롤로그.
20: pushq %rbp
21: movq %rsp, %rbp
;; 루프.
24: testl %edx, %edx
26: je 0x34
2c: subl $1, %edx
2f: jmp 0x24
;; 함수 에필로그.
34: movq %rbp, %rsp
37: popq %rbp
38: retq
이 가장 단순한 예제에서는 각 루프 본문에 들어 있는 명령 수 차이를 세어볼 수 있습니다:
N::g에 7개, M::f에 5개 — 프레임 포인터 푸시 2개, 팝 2개, 리턴 1개)하지만 hyperfine으로 빠르게(그리고 대충) 벤치마킹해서 인라인 버전이 정말 더 빠른지 확인해 보겠습니다. 이는 Wasm 실행 시간만 측정하는 게 아니라 Wasmtime 프로세스 스폰, 디스크에서 코드 로드 등도 함께 측정합니다. 하지만 반복 횟수를 크게 잡으면 우리 목적에는 충분합니다:
$ hyperfine \
"wasmtime run --allow-precompiled -Cinlining=n --invoke 'g(100000000)' no-inline.cwasm" \
"wasmtime run --allow-precompiled -Cinlining=y --invoke 'g(100000000)' yes-inline.cwasm"
Benchmark 1: wasmtime run --allow-precompiled -Cinlining=n --invoke 'g(100000000)' no-inline.cwasm
Time (mean ± σ): 138.2 ms ± 9.6 ms [User: 132.7 ms, System: 6.7 ms]
Range (min … max): 128.7 ms … 167.7 ms 19 runs
Benchmark 2: wasmtime run --allow-precompiled -Cinlining=y --invoke 'g(100000000)' yes-inline.cwasm
Time (mean ± σ): 37.5 ms ± 1.1 ms [User: 33.0 ms, System: 5.8 ms]
Range (min … max): 35.7 ms … 40.8 ms 77 runs
Summary
'wasmtime run --allow-precompiled -Cinlining=y --invoke 'g(100000000)' yes-inline.cwasm' ran
3.69 ± 0.28 times faster than 'wasmtime run --allow-precompiled -Cinlining=n --invoke 'g(100000000)' no-inline.cwasm'
빈 함수 호출만 반복하는 Wasm을 측정한 뒤, 함수 호출 오버헤드를 제거한 뒤 다시 측정했더니 큰 속도 향상이 나왔습니다. 당연히 그렇지 않으면 실망스러웠겠죠. 하지만 조금 더 현실적인 벤치마크도 해 보겠습니다.
벤치마킹할 때 자주 사용하는 프로그램 중 하나는 pulldown-cmark 마크다운 라이브러리를 감싼 작은 래퍼입니다. 이 프로그램은 CommonMark 명세(마크다운으로 작성되어 있음)를 파싱하고 HTML로 렌더링합니다. 이는 Real World™ 코드가 Real World™ 입력을 대상으로 Real World™ 사용 사례를 수행하는 것이며, 좋은 벤치마킹이 매우 어렵다는 점을 감안하더라도 우리 코퍼스에 넣기 꽤 괜찮은 후보입니다. 다만 문제가 하나 있습니다. 인라이너가 정상적으로 활성화되려면 컴포넌트를 사용하고 모듈 간 호출이 있어야 하는데, 이 프로그램은 그렇지 않습니다. 하지만 이런 컴포넌트 조합은 비교적 새롭기 때문에 그런 벤치마크 코퍼스가 아직 충분하지 않습니다. 따라서 pulldown-cmark 프로그램을 계속 사용하되, 조금 우회적인 방식으로 인라이너의 효과를 측정해 봅시다.
Wasmtime에는 모듈 내 호출 인라이닝을 활성화하는 튜너블이 있고4, rustc와 LLVM에는 인라이닝을 비활성화하는 튜너블이 있습니다.5 따라서, 비슷하지만 컴포넌트화가 광범위해 모듈 간 호출이 많은 프로그램에서 우리 인라이너가 열어줄 수 있는 속도 향상을 대략 추정하기 위해 다음을 수행할 수 있습니다:
Rust 소스 코드를 Wasm으로 컴파일할 때 인라이닝을 비활성화
결과 Wasm 바이너리를 Wasmtime로 네이티브 코드로 두 번 컴파일: 한 번은 인라이닝 비활성화, 한 번은 모듈 내 호출 인라이닝 활성화
두 컴파일 결과의 실행 속도를 비교
Siteglass(내부 벤치마킹 인프라/툴링)로 이 실험을 수행하면 다음 결과가 나옵니다:
execution :: instructions-retired :: pulldown-cmark.wasm
Δ = 7329995.35 ± 2.47 (confidence = 99%)
with-inlining is 1.26x to 1.26x faster than without-inlining!
[35729153 35729164.72 35729173] without-inlining
[28399156 28399169.37 28399179] with-inlining
Wasmtime와 Cranelift에 함수 인라이너가 생겼습니다!
-C
inlining=y
커맨드라인 플래그나 wasmtime::Config::compiler_inlining 메서드로 시험해 보세요. 여러 코어 모듈을 포함하는 Wasm 컴포넌트를 실행할 때 버그가 있거나 속도 향상을 보았다면 알려 주세요.
초기 초안을 읽고 귀중한 피드백을 준 Chris Fallin과 Graydon Hoare에게 감사드립니다. 남아 있는 오류가 있다면 모두 제 책임입니다.
결정적 컴파일은 여러 이점을 제공합니다. 테스트가 쉬워지고, 디버깅이 쉬워지며, 빌드가 바이트 단위로 재현 가능해지고, 점진적 컴파일과 미세 단위 캐싱에서도 좋은 동작을 보입니다 등등…↩
참고로, 이는 상호 재귀 호출의 연쇄(a가 b를 호출하고 b가 c를 호출하고 c가 a를 호출)를 하나의 자기 재귀 호출(abc가 abc를 호출)로 접는 것을 여전히 허용합니다. 실제 구현은 추가 병렬성을 선호해 실제로는 하지 않지만, 이론적으로는 가능합니다.↩
Cranelift는 현재 부작용이 없는 루프를 제거할 수 없고, 일반적으로 미드엔드에서 제어 흐름을 거의 건드리지 않습니다. 우리는 수년에 걸쳐 제어 흐름 중심(control-flow-y) 최적화를 Cranelift 미드엔드 아키텍처에 어떻게 가장 잘 끼워 넣을지 여러 논의를 해 왔지만, (a) LLVM이 Wasm을 생성할 때 이미 이런 종류의 일을 많이 했고, (b) 미드 레벨 IR에서 머신별 IR로 내릴 때 분기 폴딩을 일부 수행하기 때문에, 실제 Real World™ Wasm 프로그램에서 매우 유익할 것이라고 보지는 못했습니다. 인라이닝 이후 이런 일이 더 자주 나타난다면 언젠가 다시 검토할 수도 있습니다.↩
-C cranelift-wasmtime-inlining-intra-module=yes↩
-Cllvm-args=--inline-threshold=0, -Cllvm-args=--inlinehint-threshold=0, -Zinline-mir=no↩