ARM 프로세서에서 ASCII 공백 문자와 JSON 구조 문자를 SIMD로 빠르게 분류하는 방법을 살펴보고, SVE2의 match 명령이 기존 NEON 방식보다 더 단순하고 더 빠를 수 있음을 설명합니다.
Daniel Lemire는 소프트웨어 성능 전문가입니다. 그는 전 세계 과학자 상위 2% 안에 들며(Stanford/Elsevier 2025), GitHub에서 가장 많이 팔로우되는 개발자 상위 1000명 중 한 명입니다.
메뉴와 위젯
비즈니스에 도움이 필요하신가요? 비공개 강연, 교육, 컨설팅, 후원 오픈소스 프로젝트를 제공합니다. 연락해 주세요.
저는 광고를 받지 않습니다. 하지만 GitHub에서 제 오픈소스 작업을 후원하실 수 있습니다.
Daniel Lemire는 2004년에 이 블로그를 시작했습니다. 이 블로그에는 게시물 2,362개와 승인된 댓글 16,262개가 있습니다.
Daniel Lemire의 블로그는 가장 인기 있는 블로그 상위 50개 중 하나로 Hacker News에 올라 있습니다. Hacker News는 주요 기술 뉴스 집계 플랫폼입니다.
12,500명이 넘는 이메일 구독자와 함께하세요:
GitHub에서 팔로우: X에서 팔로우: Follow @lemire
(팔로워 30,000명 이상)이 블로그는 telegram에서도 팔로우할 수 있습니다. 검색어:
아카이브

다음과 같은 문제를 생각해 봅시다. 문자열이 주어졌을 때, 모든 ASCII 공백 문자(\t, \n, \r, 그리고 공백)와 JSON에서 중요한 몇몇 문자(:, ,, [, ], {, })를 매칭해야 합니다. JSON은 웹 서비스에서 사용되는 텍스트 기반 데이터 형식입니다. 간단한 JSON 문서는 다음과 같습니다.
{
"name": "Alice",
"age": 30,
"email": "[email protected]",
"tags": ["developer", "python", "open-source"],
"active": true
}
우리는 이 문제를 SIMD(single-instruction-multiple-data) 명령으로 해결하고자 합니다. 이 명령을 사용하면 16바이트 블록 하나를 또 다른 16바이트 블록과 한 번의 명령으로 비교할 수 있습니다.
이 문제는 빠른 simdjson JSON 라이브러리에서 JSON 문서를 인덱싱할 때 나타나는 부분 문제입니다. 우리는 이 작업을 _벡터화 분류_라고 부릅니다. 같은 기법은 DNS 레코드를 파싱할 때 등에도 사용합니다. 실제 simdjson 라이브러리에서는 문자열과 따옴표도 처리해야 하므로 더 복잡해집니다.
제가 문자들을 ‘매칭한다’고 할 때 무엇을 뜻하는지 정의할 필요가 있습니다. 제 경우에는 64바이트 블록마다 64비트 마스크 두 개를 얻으면 충분합니다. 하나는 공백용이고, 다른 하나는 중요한 문자용입니다. 예를 들어 16바이트 변형을 생각해 봅시다.
{"name": "Ali" }
1000000100000001 // 중요한 문자
0000000010000010 // 공백
즉, 이진 형식의 숫자 0b1000000100000001과 0b0000000010000010을 돌려받고 싶습니다(십진수로는 33025와 130입니다).
ARM 프로세서에서 사용 가능한 전통적인 SIMD 명령(NEON)을 이용해 이를 수행하는 방법은 Langdale과 Lemire (2019)를 참고하시기 바랍니다. 핵심 아이디어는 테이블 기반의 분기 없는 분류기입니다. 각 바이트를 하위 니블과 상위 니블로 나누고, SIMD 테이블 조회를 이용해 각 니블을 비트마스크로 대응시킨 다음, 두 마스크를 비트 AND로 결합하여 해당 바이트가 목표 집합(공백 또는 JSON 구조 문자)에 속하는지 결정합니다. 이렇게 하면 문자마다 여러 개의 개별 동등 비교를 수행하지 않아도 됩니다.
이제는 최신 ARM 프로세서에서 더 나은 방법이 있습니다.
NEON의 128비트 버전은 2011년에 ARMv8-A 아키텍처(AArch64)와 함께 도입되었습니다. Apple이 중요한 역할을 했고, 처음으로 iPhone 5S의 Apple A7 칩에서 사용되었습니다. 모든 64비트 ARM 프로세서는 NEON을 지원한다고 기대할 수 있어 편리합니다. (32비트 ARM 프로세서도 있지만, 주로 임베디드 시스템에 쓰이고 주류 컴퓨팅에는 거의 쓰이지 않습니다.)
ARM NEON은 훌륭하지만 오래되었습니다. x64(AMD와 Intel) 프로세서에서 제공되는 AVX-512 명령 집합과는 비교가 되지 않습니다. AVX-512 명령은 더 넓은 레지스터를 지원할 뿐 아니라(ARM NEON의 16바이트에 비해 64바이트), 더 강력한 명령도 제공합니다.
하지만 ARM에는 다른 선택지가 있습니다. 바로 Scalable Vector Extension(SVE)와 그 후속인 SVE2입니다. SVE는 2016년에 처음 도입되었지만, 실제로 접근할 수 있게 된 것은 2022년이 되어서였습니다. 제가 처음 접근할 수 있었던 것은 Amazon Graviton 3에 사용된 Neoverse V1 아키텍처였습니다. 곧이어 Neoverse V2와 N2 아키텍처와 함께 SVE2가 나왔습니다. 오늘날에는 쉽게 사용할 수 있습니다. AWS의 Graviton4, Azure의 Microsoft Cobalt 100, Google Cloud의 Google Axion(및 더 새로운 Google Cloud ARM CPU), NVIDIA Grace CPU, 그리고 Qualcomm, MediaTek, Samsung의 여러 칩에서 지원됩니다. 제가 일부러 언급하지 않은 곳이 어디인지 보이시나요? Apple입니다. 이유는 분명하지 않지만, Apple은 아직 SVE2를 채택하지 않았습니다.
저는 SVE/SVE2에 대해 복잡한 감정을 가지고 있습니다. RISC-V와 마찬가지로, 이는 ARM NEON과 x64 SIMD가 사용하는 고정 길이 레지스터 크기(16바이트, 32바이트, 64바이트) 접근법과 결별합니다. 즉, 레지스터 폭을 모른 채 코드를 작성해야 한다는 뜻입니다.
이는 칩 제조사에게는 편리합니다. 시장에 더 잘 맞도록 레지스터 크기를 조정할 수 있는 선택권을 주기 때문입니다. 하지만 이 접근은 성공하지 못한 것처럼 보입니다. Amazon의 Graviton 3 프로세서는 256비트 레지스터를 가졌지만, 그 이후의 범용 칩은 모두 128비트 레지스터를 사용하고 있습니다.
좋은 점도 있습니다. SVE/SVE2는 AVX-512와 비슷한 마스크를 제공하므로, 레지스터의 일부만 사용해서 데이터를 로드하고 처리할 수 있습니다. 이는 입력 길이가 레지스터 크기의 배수가 아닐 때 이전 SIMD 명령 집합이 안고 있던 오랜 문제를 해결합니다. SVE/SVE2와 AVX-512는 둘 다 꼬리 처리(tail handling)를 더 깔끔하게 만들 수 있습니다. 레지스터의 일부만 대상으로 연산할 수 있으면 영리한 최적화도 가능해집니다. 안타깝게도 SVE/SVE2는 AVX-512와 달리 마스크를 일반 목적 레지스터와 효율적으로 주고받는 기능을 제공하지 않습니다. 그리고 이는 가변 길이 레지스터라는 설계의 직접적인 결과입니다. 따라서 레지스터가 항상 128비트이고 16바이트를 담고 있더라도, 명령 집합은 마스크가 16비트 워드 안에 들어간다고 가정할 수 없습니다.
저는 SVE/SVE2에 대해 비관적이었지만, 이것이 ARM NEON과 상호 운용되도록 설계되었다는 사실을 알고 나서 생각이 달라졌습니다. 즉, ARM NEON 코드와 함께 SVE/SVE2 명령을 사용할 수 있습니다. 특히 SVE/SVE2 레지스터가 ARM NEON 레지스터와 같은 크기(16바이트)라는 것을 알고 있을 때 매우 잘 맞습니다.
제가 하는 작업에서 중요한 SVE2 명령은 두 가지입니다. match와 nmatch입니다. 8비트 버전에서 이 명령이 하는 일은 다음과 같습니다. 각각 최대 16바이트를 담은 두 벡터 a와 b가 주어졌을 때, match는 a[i]가 b 안의 어느 하나의 바이트와라도 같은 모든 위치 i에 대해 프레디킷 비트를 true로 설정합니다. 다시 말해, b는 작은 조회 집합처럼 작동하고, match는 a의 모든 바이트에 대해 동시에 집합 포함 여부를 검사합니다. nmatch 명령은 논리적 여집합입니다. 즉, a[i]가 b 안의 어떤 바이트와도 매칭되지 않는 위치마다 프레디킷 비트를 true로 설정합니다. 따라서 단 하나의 명령이, 그렇지 않았다면 필요했을 여러 차례의 동등 비교와 OR 축약을 대체합니다. 아래 코드에서 op_chars는 JSON 구조 문자 8개를 담고 있고 ws_chars는 공백 문자 4개를 담고 있습니다. 16바이트 청크 d0에 대해 svmatch_u8를 한 번 호출하면, 해당 입력 바이트가 구조 문자일 때 정확히 true 비트가 서는 프레디킷을 얻습니다. 코드는 SVE2 인트린식을 사용합니다. 즉, 컴파일러가 제공하는 C/C++ 함수로 CPU SIMD 명령에 거의 일대일로 대응되기 때문에, 어셈블리를 직접 쓰지 않고도 어셈블리에 가까운 수준의 제어를 얻을 수 있습니다.
// : , [ ] { }
uint8_t op_chars_data[16] = {
0x3a, 0x2c, 0x5b, 0x5d, 0x7b, 0x7d, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
};
// \t \n \r ' '
uint8_t ws_chars_data[16] = {
0x09, 0x0a, 0x0d, 0x20, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
};
// SIMD 레지스터에 문자 로드
svuint8_t op_chars = svld1_u8(svptrue_b8(), op_chars_data);
svuint8_t ws_chars = svld1_u8(svptrue_b8(), ws_chars_data);
// 데이터 로드
// const char * input = ...
svbool_t pg = svptrue_pat_b8(SV_VL16);
svuint8_t d = svld1_u8(pg, input);
// 매칭
svbool_t op = svmatch_u8(pg, d, op_chars);
svbool_t ws = svmatch_u8(pg, d, ws_chars);
이 코드 조각에서 svuint8_t는 부호 없는 8비트 레인(바이트)을 담는 SVE 벡터 타입입니다. svbool_t는 SVE 프레디킷(마스크) 타입입니다. svptrue_b8()는 모든 8비트 레인이 활성화된 프레디킷을 만들고, svld1_u8(pg, ptr)는 프레디킷 pg를 사용해 실제로 어떤 레인을 읽을지 결정하면서 메모리에서 바이트를 SVE 벡터로 로드합니다.
여기까지 주의 깊게 보셨다면, 제 코드가 문자 집합에 0을 포함하고 있기 때문에 약간 잘못되었다는 점을 눈치채셨을 수도 있습니다. 하지만 입력에 0 바이트가 없다고 가정하면 괜찮습니다. 실제로는 문자 중 하나를 반복해서 넣거나, 입력에 나타나지 않을 것으로 예상되는 가짜 문자를 사용할 수 있습니다(예를 들어 유효한 UTF-8 문자열에는 등장할 수 없는 바이트 값 0xFF).
표준 SVE/SVE2에서 op와 ws는 정수 마스크가 아니라 프레디킷입니다. 실용적인 요령은 각 프레디킷을 바이트로 구체화하는 것입니다(true이면 0xFF, false이면 0x00). 예를 들어 svdup_n_u8_z를 사용할 수 있습니다.
svuint8_t opm = svdup_n_u8_z(op, 0xFF);
svuint8_t wsm = svdup_n_u8_z(ws, 0xFF);
SVE 벡터가 128비트일 때, 이 바이트 벡터는 svget_neonq_u8를 통해 자연스럽게 NEON uint8x16_t로 대응되며, 거기서부터 NEON 연산(마스킹과 쌍별 덧셈)을 이용해 스칼라 비트마스크를 효율적으로 만들 수 있습니다. 이를 16바이트 청크 네 개에 대해 반복하면, 64바이트 블록에 필요한 64비트 마스크 두 개를 얻을 수 있습니다.
그렇다면 순수 NEON 코드와 비교하면 어떨까요? 저는 64바이트 블록을 처리하는 루틴을 서로 다른 컴파일러로 컴파일했습니다.
| method | GCC 16 | LLVM clang 20 |
|---|---|---|
| simdjson (NEON) | 69 | 66 |
| SVE/SVE2 (new!) | 42 | 52 |
흥미롭게도 GCC 16은 순수 NEON 코드에도 SVE 명령을 채택합니다. 이는 SVE/SVE2를 대상으로 삼아 오래된 NEON 코드를 다시 컴파일하는 것만으로도 이점이 있을 수 있음을 시사합니다.
두 컴파일러를 모두 벤치마크로 시험해 보고 싶었지만, AWS Graviton 4에서 빠르게 벤치마크를 돌리고 싶었습니다. 또한 GCC 16을 소스에서 직접 컴파일하고 싶지도 않았습니다. 그래서 AWS가 제공하는 이미지에서 바로 사용할 수 있었던 LLVM clang 20만 사용했습니다(RedHat 10을 골랐습니다).
AWS Graviton 4 프로세서는 Neoverse V2 프로세서입니다. Google도 자사 클라우드에서 자체 Neoverse V2 프로세서를 사용합니다. 제 테스트에서는 2.8 GHz로 동작했습니다.
제 벤치마크는 1 MiB의 무작위 문자열을 생성하고, 각 문자의 위치를 나타내는 비트맵을 계산합니다. GitHub에서 확인할 수 있습니다. 결과는 다음과 같습니다.
| method | GB/s | instructions/byte | instructions/cycle |
|---|---|---|---|
| simdjson (NEON) | 11.4 | 0.94 | 3.5 |
| SVE/SVE2 (new!) | 14.4 | 0.67 | 3.8 |
따라서 SVE/SVE2 접근법은 동등한 NEON 방식보다 약 25% 더 빠르고, 명령 수는 30% 더 적게 사용합니다. 그리고 이는 어떤 종류의 화려한 최적화도 하지 않은 상태의 결과입니다. 중요한 점은 match 명령 덕분에 코드가 비교적 단순하다는 것입니다.
SVE2의 match 함수가 ARM 프로세서에서 문자를 매칭하는 가장 빠른 방법일지도 모릅니다.
공로: 이 글은 GitHub 사용자 liuyang-664의 스케치에서 영감을 받았습니다.
Langdale, G., & Lemire, D. (2019). 초당 기가바이트 단위의 JSON 파싱. The VLDB Journal, 28(6), 941-960.
Koekkoek, J., & Lemire, D. (2025). 초당 수백만 개의 DNS 레코드 파싱. Software: Practice and Experience, 55(4), 778-788.
Lemire, D. (2025). ARM 프로세서에서 초당 수십 기가바이트 속도로 HTML 스캔하기. Software: Practice and Experience, 55(7), 1256-1265.
Daniel Lemire, "ARM 프로세서에서 문자를 매칭하는 가장 빠른 방법?," in Daniel Lemire의 블로그, April 19, 2026, https://lemire.me/blog/2026/04/19/the-fastest-way-to-match-characters-on-arm-processors/.
퀘벡 대학교(TELUQ)의 컴퓨터 과학 교수입니다. Daniel Lemire의 모든 글 보기
게시일 April 19, 2026작성자 Daniel Lemire분류
이메일 주소는 공개되지 않습니다.
댓글 *
이름 *
이메일 *
웹사이트
Δ
이 블로그는 이메일 구독도 가능합니다(비상업적, 광고 없음, 주간 이메일)
코드를 올리려면 tohtml 같은 도구로 서식을 맞추는 것을 고려해 보세요.