2026년 9월의 Ruby와 SIMD — 어디에 들어 있고, 일본어 텍스트는 무엇을 지불하는가

조사 메모. Ruby에는 JSON 확장과 파서에 SIMD가 있지만, 파일이나 소켓에서 온 문자열이 반드시 통과하는 인코딩 스캔에는 없다. 이 머신에서 4 MiB의 일본어 텍스트를 읽으면 스캔에 15 ms가 들고, 같은 크기의 ASCII에서는 사실상 0이었다. 어떻게 확인했는지, 고치려면 무엇이 필요한지.

rubysimdperformanceunicodebenchmarkscruby

나 자신을 위해 쓰고 있는 언어에 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.bundleparser.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는 필요 없다.

  1. UTF-8의 경우를 인라인화한다. 한 글자마다의 간접 호출을, 압도적으로 많은 인코딩인 UTF-8에 특화된 상태 기계로 바꾼다. 스칼라 C의 1.5 GB/s라는 숫자가 시사하는 것은, 평범한 코드 그대로 5배 정도를 얻을 수 있다는 것이다.
  2. 그 위에서 벡터화한다. 니블 색인 테이블, 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가 잘못된 선택을 했다는 주장도 아니다. 애초에 생략할 수 있는 스캔을 캐시로 생략하는 편이, 스캔을 빠르게 하는 것보다 강하다. 최근의 커밋들은 그 전략을 따르고 있다. 이 측정이 말하고 있는 것은, 캐시가 도울 수 없는 곳은 어디이고 거기서 무엇을 지불하고 있는가, 그것뿐이다.

일본어·중국어·한국어를 다룬다면 알아 둘 가치가 있는 곳이라고 생각한다. 새 바이트열로 도착하는 문자열은 모두 이것을 지불하고 있고, 그 대금은 영어로 쓰인 벤치마크에는 전혀 나타나지 않는다.

← Back to Notes