직접 만든 언어 Mere의 SIMD 지원 — 2026년 9월 시점의 실측값과 고찰

다섯 개의 백엔드를 가진 작은 자작 언어에 SIMD를 넣은 3주의 기록. 경계 검사를 루프 앞으로 끌어올려 clang이 벡터화하게 하고, 128비트 타입을 두 개 더하고, 스스로 만든 CPU에 벡터 명령을 더했다. 무엇이 효과가 있었고 무엇이 없었는지, 그리고 그것을 결정하는 네 가지 성질.

meresimdperformancecompilersbenchmarksrisc-vwebassembly

Mere는 내가 나 자신을 위해 쓰고 있는 작은 언어다. 인터프리터와 네 개의 컴파일 백엔드(C, LLVM IR, WebAssembly 텍스트, 그리고 어셈블러를 거치지 않고 직접 내보내는 RISC-V 기계어)를 가지고 있고, 그 RISC-V 출력을 실행하는 에뮬레이터도 Mere로 쓰여 있다. 2026년 9월, 이 언어에 한 번도 던져지지 않았던 질문을 던졌다. SIMD는 이런 언어에 무엇을 사줄 수 있는가, 그리고 어디에서. 이 글은 그 답을 숫자로 쓴 것이며, 프로젝트를 따라오지 않은 사람을 향해 쓰였다. 모든 숫자는 공개 저장소(merelang/merebenchmarks/)에서 재현할 수 있고, 불리한 숫자도 유리한 숫자와 같은 표에 실려 있다.

첫 번째 측정은 SIMD 이야기가 아니었다

가장 알기 쉬운 벤치마크는 axpy다. double 벡터 두 개에 대해 c[i] = c[i] + alpha * a[i], 200만 요소, 100 pass. Mere는 C로 컴파일해 clang에 넘기고, clang은 -O2에서 자동 벡터화를 하므로, 순진한 기대값은 손으로 쓴 C와의 동률이었다. 실측은 2.8배 느렸다.

실제로 실행된 복사본의 어셈블리를 읽었다. 함수 이름으로 찾은 쪽은 호출되지 않는 래퍼였고, main에 인라인된 복사본이 진짜였다. 거기에는 벡터 명령이 하나도 없었다. clang의 진단이 이유를 말해 주고 있었다. “루프에 조기 탈출이 세 개 있다”, “배열 경계를 확정할 수 없다”. 그 탈출이란 Mere의 경계 검사였다. vec_getvec_set은 인덱스를 검사하고, 실패하면 돌아오지 않는 호출로 루프를 빠져나간다. 한 반복에 세 개. 게다가 그 호출 뒤에서는 그것이 길이나 데이터를 바꾸지 않았음을 증명할 수 없으므로, 벡터의 길이와 데이터 포인터를 매번 다시 읽는다.

다음으로, 내보내진 C를 손으로 고쳤다. 검사를 루프 밖으로 빼고, 데이터 포인터를 지역 변수로 받는다. 그것만으로 clang은 벡터화했고, 시간은 손으로 쓴 C와 일치했다. 재귀를 단순한 for로 고쳐 쓰기만 해서는 아무것도 바뀌지 않았다. 즉 2.8배의 대부분은 경계 검사의 “형태”였고, SIMD 자체의 기여는 그중 1.3~1.4배 분량이었다. 여기서 작업의 순서가 정해졌다. 먼저 형태를 고친다, 모든 백엔드에서. 명시적인 레인은 그 뒤에 판단한다.

경로 1: 범위를 루프 앞에서 한 번만 검사한다

경계 검사의 versioning(v0.1.420)은 AST에 대한 패스이므로, 어느 백엔드 앞에서도 실행된다. Mere의 루프를 쓰는 방식,

let rec axpy = fn (i: int) ->
  if i == n then ()
  else
    let _ = vec_set c i (f_add (vec_get c i) (f_mul alpha (vec_get a i))) in
    axpy (i + 1);

에 대해, 접근을 검사 없는 쌍둥이로 바꾼 형제 axpy__rvfast를 더하고, 호출 지점 axpy 0

if 0 >= 0 && 0 <= n && n - 1 + 1 <= vec_len c && n - 1 + 1 <= vec_len a
then axpy__rvfast 0 else axpy 0

으로 다시 쓴다. 가드가 인덱스 범위 전체를 한 번에 검사한다. 루프 본문에 탈출은 남지 않고, clang은 벡터화하며, 인터프리터를 포함한 모든 백엔드가 요소별 검사를 생략한다. 조건은 예상대로다. 첫 인수가 인덱스인 꼬리 재귀 함수, 리터럴 보폭, 인덱스와 불변인 상한의 비교로 된 exit, 인덱스가 인덱스 자체이거나 인덱스에 대해 단조(양 끝점으로 범위가 눌러짐), 컨테이너가 루프 밖에서 바인딩되어 있을 것, 본문이 길이를 바꿀 수 없고 바꿀 수 있는 것을 호출하지 않을 것. 다시 쓰는 것은 step 쪽만이다. exit 쪽의 검사는 남긴다. 거기서의 접근은 v[n], 끝의 하나 다음을 읽기 때문인데, 이것은 v0.1.420에서 출하한 건전성의 구멍으로, exit 쪽 요소를 읽는 프로그램이 C에서는 46을 출력하고 인터프리터에서는 실패한다는 형태로 v0.1.427에서 찾았다. 이 패스의 게이트는 지금 모든 프로그램을 패스 ON과 OFF, 네 백엔드로 실행하여 같은 출력을 요구한다.

사전에는 알 수 없었던 설계 지점이 두 개. 첫 판은 dispatcher 함수였다. axpy 자체가 if guard then fast else slow가 된다. 이것은 LLVM과 Wasm의 람다 리프팅을 깨뜨렸다. dispatcher는 스스로는 캡처 변수를 쓰지 않으므로, fast 쪽에 그것을 넘기는 리프팅이 되지 않는다(전이적 캡처). 호출 지점에서 분기하고, 원래 함수를 slow로 그대로 남기면, 새 캡처는 필요 없다. 다른 하나는, 톱레벨 루프가 자기 이름으로 자기 plan을 가려 버려서 main이 fast를 한 번도 호출하지 않아 시간이 움직이지 않았던 버그로, 이것도 main의 어셈블리(실행되는 복사본)에서 호출 대상이 slow임을 알 수 있었다.

결과. python3 benchmarks/run.py <row> --reps 3, macOS/arm64, Apple clang 21, C 행은 같은 clang의 -O2.

Mere 이전 Mere 이후 손으로 쓴 C
axpy C의 2.8배 83 ms, pass 부분은 양쪽 50 ms로 동률 73 ms
bytecount(버퍼 안의 한 바이트를 센다) 23 ms 21 ms 21 ms
matmul 512 144 ms 144 ms 132 ms
crc32 43 ms 43 ms 45 ms

axpy에 남는 10 ms는, C 행이 malloc한 배열에 쓰는 곳을 Mere는 vec_push로 벡터를 만들어 가는 분량이다. matmul이 움직이지 않는 것은 안쪽이 부동소수점 리덕션이어서, fast-math 없이는 clang이 덧셈 순서를 지키기 때문. crc32가 움직이지 않는 것은 루프가 데이터로 인덱스를 정하는 테이블 참조이기 때문. 둘 다 뒤에서 말할 네 가지 성질로 설명이 된다. 여기서 말하고 싶은 것은, 검사를 바깥으로 끌어올리는 것은 무료이고 어디서나 효과가 있지만, 그것이 무엇을 사주는지는 루프에 달려 있다는 것이다.

경로 2: 레인을 직접 쓰기 위한 128비트 타입 두 개

자동 경로는 WebAssembly에는 아무것도 해주지 않는다. WAT는 쓴 그대로 조립해 실행하며, 뒤에 최적화기가 없다. clang이 꿰뚫어 볼 수 없는 루프에도 아무것도 해주지 않는다. 그래서 Mere에 값 타입이 두 개 늘었다(v0.1.422~425). f64x2는 double 두 개, u8x16은 바이트 16개. 보통의 값이고, 연산은 모두 builtin이다. f64x2_add a b, u8x16_swizzle table idx, u8x16_shift_in prev cur k. 전부 24개이며, 빠르게 만들고 싶은 프로그램을 한 편 쓰고 그것이 필요로 하는 것을 열거해서 정했다.

그 프로그램은 Keiser와 Lemire 방식의 UTF-8 검증기다. 16바이트를 한 step에, 현재와 직전 바이트의 니블로 세 개의 16요소 테이블을 찾아 and하여 레인별 오류 마스크를 만든다. 연속 바이트의 누락은 포화 뺄셈으로 잡는다. 이것은 자동 벡터화기가 만들 수 없는 전형으로, 스칼라 검증기는 상태 기계이고 상태 기계는 사슬이기 때문이다. 표현은 백엔드마다, C는 clang의 vector_size(16), LLVM IR은 <16 x i8>, Wasm은 v128, RISC-V는 V 확장(후술). 이식 불가능한 연산은 swizzle 하나뿐으로, x86의 pshufb, arm64의 tbl, Wasm의 i8x16.swizzle, RISC-V의 vrgather를, Mere는 Wasm의 의미(15를 넘는 인덱스는 0)로 고정했다.

스칼라 Mere 레인이 있는 Mere 스칼라 C
utf8valid(4 MiB, 20 pass) 72 ms 29 ms 54 ms
axpy_simd(명시적 f64x2) 자동 벡터화된 axpy, 0.08 s 0.12 s 0.07 s

검증기가 정당화다. 스칼라 Mere의 2.5배, 스칼라 C의 1.9배, 약 4 GB/s. C 행은 의도적으로 스칼라이며, intrinsic을 쓰지 않는 C 프로그래머가 쓰는 것으로 해 두었다. 그래서 비교는 “같은 언어에서 레인 있음 대 없음”과 “레인이 있는 Mere 대 레인이 없는 C”가 된다. axpy_simd는 패배이고, 의도적으로 표에 실었다. 사람이 한 step에 두 레인으로 쓰는 곳을, clang은 자동 벡터화한 루프를 여덟 레인 상당으로 펼친다. 그래서 명시적인 f64x2는 아무것도 쓰지 않는 것보다 느리다. 그 벤치마크의 manifest에는 그렇게 쓰여 있다.

Wasm에는 한 가지 더 말할 것이 있고, 그것은 시간이 아니라 메모리 이야기다. 함수 호출을 넘거나 데이터 구조에 들어가는 SIMD 값은 16바이트 박스로 힙에 놓이고, Wasm 백엔드는 메모리를 회수하지 않는다. 첫 판은 중간값을 모두 박스로 만들어서, 긴 검증은 입력 64 KiB에서 메모리를 다 썼다. 식 안에서는 언박스로, v128 지역 변수에 두도록 하여(v0.1.429), 이것은 256 KiB로, axpy_simd는 20,000 요소에서 50,000 요소로 움직였다. 하지만 u8x16을 다음 반복으로 넘기는 루프는 지금도 반복마다 할당한다. step을 region 블록으로 감싸도 구원되지 않는다. 넘기는 값은 region에서 복사되어 나오고, 그 복사가 할당 그 자체이기 때문이다. 이것은 SIMD가 아니라 메모리 모델의 한계이며, 그렇게 써서 남겨 두었다.

경로 3: 언어가 스스로 쓴 CPU 위의 벡터 명령

Mere의 다섯 번째 백엔드는 RV32IM / RV64IM 기계어를 내보내고, 그것이 실행되는 기계는 Mere로 쓴 에뮬레이터 memu다. 어느 쪽에도 벡터 확장은 없었다. 여기에 SIMD를 넣는다는 것은 양쪽에 RVV 1.0을 더하는 것이며, 첫 결정은 “올바르다”의 정의였다. -cpu rv32,v=true,vlen=128의 QEMU를 외부 기준으로 삼았다. 명령 서브셋의 Python 모델, memu, QEMU를 같은 15개의 directed 프로그램과 200개의 fuzz 프로그램으로 실행하여 모든 레지스터의 일치를 요구했다. 처음에는 일치하지 않았다. fuzzer는, memu가 사양에서는 예약(reserved)인 “vd가 소스와 겹치는 vrgather“를 태연히 계산하고 있는 것을 찾아냈다. 지금은 QEMU와 같이 트랩한다. QEMU는 또, 프로그램이 mstatus.VS를 세우기 전까지 벡터 명령을 일절 실행하지 않았고, memu에는 그 상태가 없었다. 기준을 읽기 전까지 그것은 행(hang)으로 보이는 타임아웃이었다.

서브셋은 UTF-8 검증기가 VLEN=128, e8에서 필요로 하는 것. vsetivli, vle8/vse8, 정수와 마스크 연산, vrgather, vslideup/vslidedown(둘로 shift_in), vredor, 확장 vwredsumu. f64x2는 이 백엔드에서는 거부한다. 기계에 부동소수점 하드웨어가 없고, 언어의 float 라이브러리는 소프트웨어이기 때문이다.

Mere로 만든 RV32 코어 위의 검증기(64 KiB, 20 pass) 시간
스칼라 2.75 s
RVV 1.85 s
RVV, 값을 박스가 아니라 벡터 레지스터에 둔다(v0.1.430) 1.6~1.7 s

마지막 행은 Wasm 언박스화의 RISC-V 판이다. 식의 트리는 v1v7에서 평가하여 루트에서 한 번만 박스로 만들고, 연산의 피연산자로만 쓰이는 let 바인딩의 u8x16은, 마지막 사용 전에 호출이 실행되지 않는 한 v8v15에 산다. 호출 대상의 자기 벡터 코드가 그것을 덮어쓰기 때문이다. 검증기의 리스팅에서 박스로의 store는 43에서 16이 되었다. 남는 16은 재귀 호출로 다음 반복에 넘기는 값이다. 에뮬레이터는 해석 실행이므로, 이 10%는 명령 수의 차이다.

레인이 보답받는지를 결정하는 것

측정한 일곱 행은 모두 루프의 네 가지 성질에서 나온다.

반복 사이의 종속성. SIMD는 같은 연산을 독립된 요소에 동시에 행한다. c[i] = c[i] + alpha * a[i] 는 i에 대해 독립이다. 상태 기계 state[i+1] = f(state[i], byte[i])는 사슬이고, 사슬을 레인으로 쪼개는 벡터화기는 없다. 명시적 판의 검증기는 상태 기계를 레인에 올린 것이 아니다. 다른 알고리즘으로, 각 바이트를 직전 바이트와의 조합에서 작은 테이블로 분류하므로, 각 레인은 이웃에만 의존한다. u8x16_shift_in은 이전 블록의 끝 바이트를 지금 블록으로 가져오기 위해 있다. “벡터화할 수 있다”는 것은 “종속성을 국소화했다”는 것이며, 그 형태를 찾는 것은 컴파일러가 아니라 프로그래머의 일이다.

인덱스를 만드는 방법. 연속된 인덱스는 벡터 로드 한 번이다. 데이터로 정해지는 인덱스, crc32table[b]는 gather이며, 대부분의 하드웨어에서는 레인별 로드가 된다. crc32가 움직이지 않았던 이유가 이것이다. swizzle은 검증기의 테이블을 성립시키는 예외로, 16 엔트리 이내의 테이블이라면 레지스터 안의 gather를 한 명령으로 한다.

연산의 종류. 정수와 비트 연산은 결합적이어서, 컴파일러는 레인을 넘어 순서를 바꿀 수 있다. 부동소수점 덧셈은 결합적이 아니므로, fast-math가 없으면 리덕션은 직렬 그대로다. 그것이 matmul. 분기는 마스크와 select로 고칠 수 있을 때만 벡터화되고, 데이터 종속의 조기 탈출은 되지 않는다. 컴파일러 자신의 경계 검사가 그 탈출 세 개였다는 것이, 경로 1에서 끌어올리기까지의 이야기다.

레인 수와 연산 밀도. 얻을 수 있는 배율의 상한은 레인 수이며, 바이트라면 16, double이라면 2. 메모리 병목의 루프는 먼저 대역폭의 천장에 닿는다. axpy는 곱셈-덧셈 한 번에 두 로드 한 스토어여서, SIMD의 기여는 2.8배 차이 중 1.3~1.4배 분량이었다. 검증기는 바이트당 십수 번의 참조와 비트 연산을 하므로, 16 레인이 2.5배가 되었다. axpy_simd가 지는 것은, 사람의 두 레인이 컴파일러가 펼친 여덟 레인을 만나기 때문이다.

효과 있음 효과 없음
반복이 독립, 또는 종속성을 이웃 몇 바이트로 국소화할 수 있다 이전 반복의 결과가 다음 반복의 입력
연속된 인덱스, 16 엔트리 이내의 테이블 큰 테이블로의 데이터 종속 인덱스
정수·비트 연산, 마스크로 쓸 수 있는 분기 순서 고정의 부동소수점 리덕션, 데이터 종속의 조기 탈출
바이트 레인, 바이트당 연산이 많다 두 레인, 메모리 병목, 루프 안의 할당이나 호출

거기서 나오는 실무 규칙. 요소별 정수 루프는 보통으로 쓰고, 자동 경로에 C와 동률을 내게 한다. 부동소수점의 요소별 루프도 같은 경로로 충분하다. 부동소수점의 리덕션은 어느 경로에서도 움직이지 않는다. u8x16에 손을 뻗는 것은, 바이트 처리 루프가 느리고, 또한 직전 바이트에 대한 종속성을 이웃 바이트 조합의 테이블 참조로 다시 쓸 수 있을 때. UTF-8 검증, JSON의 구조 스캔, base64. 손으로 쓴 f64x2가 보답받는 것은, 자동 벡터화기가 루프를 전혀 볼 수 없는 곳뿐이다.

계측기가 나에게 한 일

3주 중 컴파일보다 측정에 쓴 시간이 더 길었고, 그 함정은 숫자보다 독자에게 도움이 된다.

  • 올바른 “이름”의 함수에서 벡터 명령을 셌더니, 호출되지 않는 래퍼를 세고 있었다. 실행되는 복사본은 main에 인라인된 쪽이다. main에서 호출을 따라간다.
  • clang의 플래그를 몇 개 넣은 셸 변수를 zsh가 단어 분리하지 않아, clang은 -Rpass=regex ...라는 한 개의 플래그를 받고 진단을 0줄 출력했다. 그것은 “벡터화되지 않았다”로 읽혔다.
  • zsh의 multios에서 2>&1 >file | head는 컴파일러의 출력을 head로 흘리고, head가 먼저 닫혀 컴파일러를 SIGPIPE로 죽였다. 중간에 잘린 C는 한 시간 동안 코드 생성 버그로 보였다.
  • POSIX 셸은 VAR=x shell_function의 대입을 함수 뒤에도 유지하므로, 게이트의 “패스 OFF” 설정이 이후의 모든 emit에 적용되어, 게이트는 “아무것도 plan되지 않았다”를 녹색으로 보고했다.
  • Rosetta는 정렬되지 않은 SSE 로드에서 fault를 내지 않는다. 정렬을 쓰지 않은 store <16 x i8>는 16을 가정하고, 힙은 8바이트 경계의 박스를 넘긴다. 진짜 x86에서 그것은 movaps이고 크래시다. arm64에서 통과하고, Apple silicon 위의 x86-64 Linux 컨테이너에서 통과하고, CI runner에서만 떨어졌다. 수정은 emit의 마지막 한 줄, 메모리를 지나는 모든 벡터 load/store에 align 8을 붙이는 것, 그리고 맨 것이 남아 있지 않다는 테스트.
  • 이 어느 것보다 앞서, CI는 3일 동안 빨간색이었고, 아무도 이유를 보지 않았다. 내보낸 C가 uintptr_t를 쓰고, arm64에서는 <arm_neon.h><stdint.h>를 함께 가져오지만, x86-64의 스칼라 분기는 가져오지 않는다. C를 컴파일하는 모든 게이트가 Linux에서 떨어지고, 빌드 매트릭스는 내보낸 C를 컴파일하지 않으므로 녹색이었다. 빨간 CI는 첫 실패의 하류에 있는 게이트를 전부 숨긴다. 위의 Linux 한정 버그 두 개는 녹색으로 되돌린 뒤에야 찾을 수 있었고, versioning 패스의 이차 fixpoint도, 스케일링 게이트가 허공을 향해 보고하고 있던 것이었다.

남아 있는 것

Wasm과 RISC-V의 명시적 SIMD를 묶고 있는 것은 명령 집합이 아니라 메모리 모델이다. 회수가 없는 한, 반복을 넘어 넘기는 값은 반복마다의 할당이 된다. LLVM 백엔드에는 파일을 바이트열로 읽는 lowering이 없어, UTF-8 행은 거기서는 실행되지 않는다. RISC-V의 벡터 서브셋은 e8, LMUL=1만으로, 언어 안에 아직 넓은 요소 폭을 요구하는 것이 없기 때문이다. 첫 사용자가 없는 기능은 범용인 척하는 일반화가 되므로, 그것을 필요로 하는 프로그램이 나타나기까지 열어 둔다.

숫자가 뒷받침하는 한 문장의 요약. 경계 검사를 바깥으로 끌어올리면 자동 벡터화는 무료이고, 효과가 있는 곳에서는 모두 이긴다. 명시적인 레인이 보답받는 것은 벡터화기가 루프를 만들 수 없는 곳만이며, 만들 수 있는 곳에서는 진다.

← Back to Notes