2026년 9월의 Ruby와 SIMD — 어디에 들어 있고, 일본어 텍스트는 무엇을 지불하는가
조사 메모. Ruby에는 JSON 확장과 파서에 SIMD가 있지만, 파일이나 소켓에서 온 문자열이 반드시 통과하는 인코딩 스캔에는 없다. 이 머신에서 4 MiB의 일본어 텍스트를 읽으면 스캔에 15 ms가 들고, 같은 크기의 ASCII에서는 사실상 0이었다. 어떻게 확인했는지, 고치려면 무엇이 필요한지.
나 자신을 위해 쓰고 있는 언어에 3주를 들여 SIMD를 넣고, “레인이 보답받는 것은 어떤 형태인가”라는 규칙을 얻었다. 다음에 떠오르는 질문은, 그 규칙이 “실제로 쓰고 있는 언어”에 대해 무언가 말해 주는가였다. 그래서 CRuby를 조사했다. 무엇이 이미 SIMD를 쓰고 있고, 무엇이 쓰지 않으며, 할 가치가 남아 있는 것이 있는가.
이것은 제안이 아니라 조사 메모다. 아래의 모든 것은 출하되는 바이너리에서 확인했거나 한 대의 머신에서 측정한 것이며, 그 머신은 명시한다. 불확실한 곳은 불확실하다고 쓴다.
Ruby에 이미 들어 있는 것
소스를 grep하는 것만으로는 부족하다. #if는 코드를 빌드에서 빼낼 수 있기 때문이다. 그래서
출하되는 공유 라이브러리를 디스어셈블하여 “실제로 벡터 명령을 포함하는 함수는 무엇인가”를
물었다. Apple silicon의 디스어셈블러는 NEON을 “배열을 니모닉 쪽에 붙인” 표기(add.2d,
tbl.16b)로 쓴다. 이것은 grep을 쓰기 전에 알아 둘 가치가 있다. 나의 첫 시도는 ARM 매뉴얼의
표기에 맞춰 버렸고, 2만 개의 벡터 명령을 가진 라이브러리에 대해 “0개”라고 보고했다.
| 위치 | Ruby 4.0.6(arm64)에서의 상태 |
|---|---|
json 확장 |
NEON과 SSE2. 독립된 ext/json/simd/simd.h를 가진다. 출하되는 generator.bundle과 parser.bundle 양쪽에 벡터 명령이 있다 |
prism(파서) |
master에는 있다. strpbrk와 식별자 스캔을 니블 색인 테이블로 16바이트/step. 2026-03-10에 들어갔다. 출하되는 4.0.6 라이브러리에는 하나도 없으므로, 지금으로서는 4.1 개발판의 것 |
digest |
최근 각 플랫폼용 SIMD 지원이 들어갔다 |
String#count / #delete / #tr / #length |
벡터 명령은 있지만, clang이 평범한 루프를 자동 벡터화한 결과이며 누군가 레인을 쓴 것이 아니다 |
cgi/escape, erb/escape |
벡터 명령은 0 |
string.c의 인코딩 스캔 |
벡터 명령은 0. ASCII 연속은 워드 기법으로 8바이트씩 건너뛰고, 그 외에는 한 글자씩 |
트래커를 보면 한 가지 더 알 수 있는 것이 있고, 이것은 “누락”이 아니라 설계상의 입장이다.
CRuby의 인코딩 관련 최근 작업은 스캔을 빠르게 하는 쪽이 아니라 피하는 쪽으로 가 있다.
결과를 문자열에 캐시하고, slice와 연결을 통해 전파하고, 접근자를 인라인화한다. SIMD에 의한
문자열 비교 제안(Feature #21706)은 있지만 그 pull request는 1월에 닫혔다. pack에 SIMD
hex 디코딩을 넣는 pull request는 지금도 열려 있다.
측정
스캔을 피하는 전략은 바이트열이 새것이 아닌 한 유효하다. 소켓에서 온 바이트, 파일에서 읽은 바이트, 압축 해제기에서 나온 바이트에는 캐시된 답이 없다. 누군가는 볼 수밖에 없다. 그래서 그 경우를 측정했다.
require 'benchmark'; require 'tempfile'
def ms(label, reps)
yield
t = Benchmark.realtime { reps.times { yield } }
printf("%-50s %7.2f ms\n", label, t * 1000 / reps)
end
N = 4 * 1024 * 1024
def mk(n, pat)
s = (pat * (n / pat.bytesize + 2)).b[0, n]
s.force_encoding("UTF-8").scrub("").b[0, n].force_encoding("UTF-8")
end
cjk = mk(N, "日本語だけの文章です。改行もあります。\n")
asc = mk(N, "plain ascii sentences here, with newlines too.\n")
f1 = Tempfile.new(['ja', '.txt']); f1.binmode; f1.write(cjk); f1.flush
f2 = Tempfile.new(['en', '.txt']); f2.binmode; f2.write(asc); f2.flush
ms("File.binread (Japanese bytes, no encoding work)", 20) { File.binread(f1.path) }
ms("File.read UTF-8 (Japanese)", 20) { File.read(f1.path, encoding: "UTF-8") }
ms("File.binread (ASCII bytes)", 20) { File.binread(f2.path) }
ms("File.read UTF-8 (ASCII)", 20) { File.read(f2.path, encoding: "UTF-8") }
cjk.valid_encoding?
ms("String#length, coderange already known (Japanese)", 20) { cjk.length }
ms("String#length, forces a scan (Japanese)", 20) { cjk.b.force_encoding("UTF-8").length }
ms("String#valid_encoding?, forces a scan (ASCII)", 20) { asc.b.force_encoding("UTF-8").valid_encoding? }
Apple silicon Mac, 페이지 캐시가 따뜻한 상태, 4 MiB 파일. Ruby 3.4.9와 4.0.6에서 같은 숫자가 나왔다.
| 일본어 | ASCII | |
|---|---|---|
File.binread(바이트열, 인코딩 처리 없음) |
1.3 ms | 1.7 ms |
File.read를 UTF-8로 |
16.2 ms | 1.7 ms |
| 그 차이 | 14.8 ms | 없음 |
String#length(답이 캐시됨) |
0.21 ms | — |
String#length(스캔이 필요) |
15.2 ms | — |
String#valid_encoding?(스캔이 필요) |
15.5 ms | 0.00 ms |
4메가바이트의 일본어 텍스트를 읽으면 같은 양의 영어를 읽는 것보다 12배가 걸리고, 그 차이는 통째로 바이트열을 한 번 스캔하는 몫이다. 처리량으로 바꾸면 일본어에서 약 280 MB/s, ASCII에서 약 24 GB/s.
믿기 전에 확인이 두 개 필요하다. 첫째, 스캔이 정말로 실행되고 있을 것. 입력을 1/4로 하면
시간도 1/4가 되어야 하고 실제로 그랬다(4 MiB에서 306 MB/s, 1 MiB에서 309 MB/s). 둘째, 이
벤치마크의 첫 판은 아무것도 측정하고 있지 않았다. String#b는 버퍼를 복사하지 않고
공유하며, 순수 ASCII에서는 인코딩 태그가 바뀌어도 캐시된 답이 무효화되지 않는다. 즉 내가
측정하고 있다고 생각한 “스캔”은 20 GB/s로 달리는 아무 일도 하지 않는 처리였다. 있을 수 없는
숫자는, 계측기가 “측정하고 있지 않습니다”라고 말하고 있는 모습이다.
왜 느린가
스캔은 string.c의 20줄이다.
if (rb_enc_asciicompat(enc)) {
p = search_nonascii(p, e);
if (!p) return ENC_CODERANGE_7BIT;
for (;;) {
int ret = rb_enc_precise_mbclen(p, e, enc);
if (!MBCLEN_CHARFOUND_P(ret)) return ENC_CODERANGE_BROKEN;
p += MBCLEN_CHARFOUND_LEN(ret);
if (p == e) break;
p = search_nonascii(p, e);
if (!p) break;
}
}
search_nonascii가 워드 기법이고, 이것은 정말로 빠르다. 순수 ASCII 문자열이라면 메모리
대역폭으로 달리고 스캔은 사실상 무료가 된다. 문제는 for 루프 쪽으로, 여기는
rb_enc_precise_mbclen을 한 글자당 한 번, 인코딩 객체의 함수 테이블을 통해 호출한다.
간접 호출과 몇 개의 분기가 들어가고, 컴파일러는 그 너머를 내다볼 수 없다. 그것이 한 글자마다,
전부가 글자인 텍스트에 대해 일어난다.
규모감을 위해 말하면, 같은 검증을 하는 스칼라 C 상태 기계는 이 머신에서 약 1.5 GB/s, 내가 내 언어를 위해 쓴 SIMD 판(16바이트/step, 니블 색인 테이블 3장, Keiser–Lemire 배치)은 2.9~4.2 GB/s로 달린다. Ruby는 0.28 GB/s다. 레인을 꺼내기 전의, 스칼라 C와의 차이만으로 이미 5배가 있다.
할 가치가 있어 보이는 것
다른 프로젝트에서 얻은 규칙은 이랬다. 레인이 보답받는 것은, 반복 사이의 종속성을 국소화할 수 있고, 인덱스가 연속이고, 테이블이 16 엔트리에 들어가고, 연산이 정수와 비트이고, 본문이 메모리 병목이 되지 않을 만큼 밀도가 있을 때다. UTF-8 검증은 그 전부를 만족한다. 거의 교과서적인 예이며, 게다가 Ruby 자신의 파서가 이 형태의 문제를 위한 도구를 저장소 안에 이미 가지고 있다.
단계는 둘로 나눌 수 있고, 1단계에 SIMD는 필요 없다.
- UTF-8의 경우를 인라인화한다. 한 글자마다의 간접 호출을, 압도적으로 많은 인코딩인 UTF-8에 특화된 상태 기계로 바꾼다. 스칼라 C의 1.5 GB/s라는 숫자가 시사하는 것은, 평범한 코드 그대로 5배 정도를 얻을 수 있다는 것이다.
- 그 위에서 벡터화한다. 니블 색인 테이블, 16바이트/step, NEON과 SSSE3 경로와 스칼라
폴백. 거기서 다시 2~3배. 플랫폼 검출은 저장소 안에 이미 둘이나 있다
(
ext/json/simd/simd.h,prism/compiler/accel.h).
그 아래로, 같은 훑기에서 발견된 작은 후보가 둘. CGI.escapeHTML은 374 MB/s이고 확장
바이너리에 벡터 명령이 없다. HTML을 그리는 사람에게는 뜨거운 경로의 바이트 분류 문제다.
pack("H*")의 hex 디코딩은 305 MB/s로 내가 측정한 것 중 가장 느린 행이지만, 이쪽은 이미
pull request가 열려 있다.
그만큼 도움이 되는 것은 건드리지 않아도 되는 것의 목록이다. 측정이 “이미 끝났다”고 말하고
있다. 순수 ASCII 스캔은 메모리 대역폭. coderange가 이미 알려진 String#length는 4메가바이트에
0.21 ms. String#count, #upcase, #tr은 1~2.2 GB/s이며, clang이 부탁받지도 않고 벡터화한
결과다. 여기에 노력을 부어도 아무것도 돌아오지 않는다.
이 메모가 말하고 있지 않은 것
한 대의 머신 이야기다. Apple silicon, macOS, NEON. 다른 컴파일러의 x86-64 서버라면 자동 벡터화도 메모리 계통도 다르고, 특히 ASCII의 빠른 경로는 대역폭에 가까운 만큼 움직일 것이다. 그리고 CRuby가 잘못된 선택을 했다는 주장도 아니다. 애초에 생략할 수 있는 스캔을 캐시로 생략하는 편이, 스캔을 빠르게 하는 것보다 강하다. 최근의 커밋들은 그 전략을 따르고 있다. 이 측정이 말하고 있는 것은, 캐시가 도울 수 없는 곳은 어디이고 거기서 무엇을 지불하고 있는가, 그것뿐이다.
일본어·중국어·한국어를 다룬다면 알아 둘 가치가 있는 곳이라고 생각한다. 새 바이트열로 도착하는 문자열은 모두 이것을 지불하고 있고, 그 대금은 영어로 쓰인 벤치마크에는 전혀 나타나지 않는다.