Skip to content

Commit 5911e92

Browse files
add post '이진 탐색보다 빠르게 (You can beat the binary search)'
Signed-off-by: jonghoonpark <dev@jonghoonpark.com>
1 parent 875b448 commit 5911e92

1 file changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
---
2+
layout: post
3+
title: "이진 탐색보다 빠르게 (You can beat the binary search)"
4+
description: "Daniel Lemire의 글을 번역한 포스트. SIMD 명령어와 사분 탐색(quaternary search)을 결합하여 정렬된 배열에서 이진 탐색보다 빠른 검색을 수행하는 SIMD Quad 알고리즘을 소개한다."
5+
categories: ["개발"]
6+
tags: [binary search, SIMD, 알고리즘, 성능 최적화, 번역]
7+
date: 2026-05-16 18:00:00 +0900
8+
toc: true
9+
math: true
10+
---
11+
12+
> 이 글은 Daniel Lemire의 [You can beat the binary search](https://lemire.me/blog/2026/04/27/you-can-beat-the-binary-search/)를 요약 번역한 글이다.
13+
14+
## 배경
15+
16+
정렬된 배열에서 값을 찾는 가장 대표적인 알고리즘은 이진 탐색(binary search)이다. 하지만 현대 프로세서의 병렬 처리 능력을 활용하면 이진 탐색보다 더 빠른 검색이 가능하다.
17+
18+
Lemire는 [Roaring Bitmap](https://roaringbitmap.org/) 포맷에서 사용하는 16비트 정수 정렬 배열(1~4096개 원소)을 대상으로, SIMD와 사분 탐색(quaternary search)을 결합한 **SIMD Quad** 알고리즘을 제안한다.
19+
20+
---
21+
22+
## 핵심 아이디어
23+
24+
두 가지 관찰에서 출발한다.
25+
26+
### 1. 데이터 병렬성 (SIMD)
27+
28+
SIMD(Single Instruction, Multiple Data)는 하나의 명령어로 여러 데이터를 동시에 처리하는 방식이다. 예를 들어, 128비트 SIMD 레지스터에 16비트 정수 8개를 채우면 한 번의 비교 명령으로 8개 값을 동시에 검사할 수 있다. ARM NEON과 x64 SSE 모두 이를 지원한다.
29+
30+
8개의 값을 한 번에 비교 가능하므로, 8개 미만의 원소를 다루는 탐색은 불필요하다.
31+
32+
### 2. 메모리 수준 병렬성 (Quaternary Search)
33+
34+
최근 프로세서는 메모리 수준 병렬성(memory-level parallelism)이 뛰어나서 여러 메모리 위치를 동시에 로드할 수 있다. 이진 탐색은 이전 비교 결과가 나와야 다음 위치를 알 수 있어 순차 대기가 발생한다.
35+
36+
**사분(quaternary) 탐색**은 이름 그대로 배열을 **4등분**하여 3개의 피벗을 동시에 비교한다 (binary가 2등분이라면 quaternary는 4등분). 3곳의 메모리를 동시에 요청하더라도 현대 프로세서의 파이프라인이 이를 병렬로 처리하므로, 3번 읽는 비용이 1번 읽는 비용과 거의 같다. 한 번의 반복으로 탐색 범위를 1/2이 아닌 약 1/4로 줄일 수 있다.
37+
38+
---
39+
40+
## SIMD Quad 알고리즘
41+
42+
알고리즘의 동작 방식은 다음과 같다.
43+
44+
1. **원소가 16개 미만**이면 선형 탐색 수행
45+
2. 배열을 **16개씩 블록**으로 나눔
46+
3. 블록 경계값을 대상으로 **사분 탐색(quaternary search)** 수행 → 후보 블록 결정
47+
4. 후보 블록 내에서 **SIMD 명령어로 16개 원소를 동시 비교**
48+
5. 나머지(블록에 포함되지 않는 꼬리 원소)는 선형 탐색
49+
50+
### 소스코드
51+
52+
```c
53+
bool simd_quad(const uint16_t *carr, int32_t cardinality,
54+
uint16_t pos) {
55+
constexpr int32_t gap = 16;
56+
if (cardinality < gap) {
57+
for (int32_t j = 0; j < cardinality; j++) {
58+
if (carr[j] == pos) return true;
59+
}
60+
return false;
61+
}
62+
int32_t num_blocks = cardinality / gap;
63+
int32_t base = 0;
64+
int32_t n = num_blocks;
65+
while (n > 3) {
66+
int32_t quarter = n >> 2;
67+
68+
int32_t k1 = carr[(base + quarter + 1) * gap - 1];
69+
int32_t k2 = carr[(base + 2 * quarter + 1) * gap - 1];
70+
int32_t k3 = carr[(base + 3 * quarter + 1) * gap - 1];
71+
72+
int32_t c1 = (k1 < pos);
73+
int32_t c2 = (k2 < pos);
74+
int32_t c3 = (k3 < pos);
75+
76+
base += (c1 + c2 + c3) * quarter;
77+
n -= 3 * quarter;
78+
}
79+
while (n > 1) {
80+
int32_t half = n >> 1;
81+
base = (carr[(base + half + 1) * gap - 1] < pos)
82+
? base + half : base;
83+
n -= half;
84+
}
85+
int32_t lo = (carr[(base + 1) * gap - 1] < pos)
86+
? base + 1 : base;
87+
88+
if (lo < num_blocks) {
89+
const uint16_t *blk = carr + lo * gap;
90+
#ifdef __ARM_NEON
91+
uint16x8_t needle = vdupq_n_u16(pos);
92+
uint16x8_t v0 = vld1q_u16(blk);
93+
uint16x8_t v1 = vld1q_u16(blk + 8);
94+
uint16x8_t hit = vorrq_u16(vceqq_u16(v0, needle),
95+
vceqq_u16(v1, needle));
96+
return vmaxvq_u16(hit) != 0;
97+
#else
98+
__m128i needle = _mm_set1_epi16((short)pos);
99+
__m128i v0 = _mm_loadu_si128((const __m128i *)blk);
100+
__m128i v1 = _mm_loadu_si128((const __m128i *)(blk + 8));
101+
__m128i hit = _mm_or_si128(_mm_cmpeq_epi16(v0, needle),
102+
_mm_cmpeq_epi16(v1, needle));
103+
return _mm_movemask_epi8(hit) != 0;
104+
#endif
105+
}
106+
107+
for (int32_t j = num_blocks * gap; j < cardinality; j++) {
108+
uint16_t v = carr[j];
109+
if (v >= pos) return (v == pos);
110+
}
111+
return false;
112+
}
113+
```
114+
115+
코드를 살펴보면:
116+
117+
- quaternary search 부분에서 3개의 피벗(`k1`, `k2`, `k3`)을 동시에 읽어 비교한다. 브랜치 없이 `c1 + c2 + c3`로 다음 탐색 범위를 결정한다.
118+
- SIMD 부분에서는 16개 원소를 두 번의 128비트 로드로 가져와 동시에 비교한다.
119+
120+
### 예시
121+
122+
256개 원소(0~255)가 있는 정렬 배열에서 `150`을 찾는다고 하자.
123+
124+
**Step 1: 블록 분할**
125+
126+
16개씩 나누면 16개 블록이 생긴다.
127+
128+
```
129+
블록0: [ 0, 1, 2, ..., 15] ← 경계값: 15
130+
블록1: [ 16, 17, 18, ..., 31] ← 경계값: 31
131+
블록2: [ 32, 33, 34, ..., 47] ← 경계값: 47
132+
...
133+
블록9: [144,145,146, ..., 159] ← 경계값: 159
134+
...
135+
블록15: [240,241,242, ..., 255] ← 경계값: 255
136+
```
137+
138+
**Step 2: Quaternary Search (사분 탐색)**
139+
140+
`num_blocks=16`, `base=0`, `n=16`으로 시작한다.
141+
142+
**1회차** (`n=16`, `quarter = 16 >> 2 = 4`):
143+
144+
16개 블록을 4등분하는 경계 지점에서 3개의 피벗을 브랜치(조건문) 없이 **동시에** 읽어 비교한다:
145+
146+
```
147+
[구간] |-- 1분면 --|-- 2분면 --|-- 3분면 --|-- 4분면 --|
148+
[블록] 0 1 2 3 | 4 5 6 7 | 8 9 10 11 | 12 13 14 15
149+
↑ k1 ↑ k2 ↑ k3
150+
(블록3 끝) (블록7 끝) (블록11 끝)
151+
```
152+
153+
- $k_1$ = `carr[79]` (블록3 경계값) = 79 → $79 < 150$ ? → Yes (c1 = 1)
154+
- $k_2$ = `carr[143]` (블록7 경계값) = 143 → $143 < 150$ ? → Yes (c2 = 1)
155+
- $k_3$ = `carr[207]` (블록11 경계값) = 207 → $207 < 150$ ? → No (c3 = 0)
156+
157+
$$\text{base} \leftarrow \text{base} + (1 + 1 + 0) \times 4 = 8$$
158+
159+
$$n \leftarrow 16 - (3 \times 4) = 4$$
160+
161+
→ 16개 블록에서 4개 블록(블록8~11)으로 범위가 줄었다.
162+
163+
**2회차** (`n=4`, `quarter = 4 >> 2 = 1`):
164+
165+
남은 4개 블록을 다시 4등분한다:
166+
167+
```
168+
[블록] 8 | 9 | 10 | 11
169+
↑ k1 ↑ k2 ↑ k3
170+
(블록8 끝) (블록9 끝) (블록10 끝)
171+
```
172+
173+
- $k_1$ = `carr[159]` (블록8 경계값) = 159 → $159 < 150$ ? → No (c1 = 0)
174+
- $k_2$ = `carr[175]` (블록9 경계값) = 175 → $175 < 150$ ? → No (c2 = 0)
175+
- $k_3$ = `carr[191]` (블록10 경계값) = 191 → $191 < 150$ ? → No (c3 = 0)
176+
177+
$$\text{base} \leftarrow 8 + (0+0+0) \times 1 = 8, \quad n \leftarrow 4 - 3 = 1$$
178+
179+
→ while 루프 종료.
180+
181+
이후 `lo` 보정: `carr[(8+1)*16 - 1] = carr[143] = 143 < 150` → `lo = 9` → **블록9**가 최종 후보.
182+
183+
**Step 3: SIMD 비교 (블록9 내부)**
184+
185+
최종 후보인 블록9의 16개 원소 `[144, 145, ..., 159]`를 128비트 레지스터 크기($16\text{bit} \times 8$개)에 맞춰 두 번에 나눠 로드한 뒤 일괄 비교한다:
186+
187+
```
188+
[상위 8개 원소 비교 (v0)]
189+
v0: [ 144, 145, 146, 147, 148, 149, 150, 151 ]
190+
needle: [ 150, 150, 150, 150, 150, 150, 150, 150 ]
191+
-------------------------------------------------
192+
결과0: [ 0, 0, 0, 0, 0, 0, FF, 0 ] ← 150 발견!
193+
194+
[하위 8개 원소 비교 (v1)]
195+
v1: [ 152, 153, 154, 155, 156, 157, 158, 159 ]
196+
needle: [ 150, 150, 150, 150, 150, 150, 150, 150 ]
197+
-------------------------------------------------
198+
결과1: [ 0, 0, 0, 0, 0, 0, 0, 0 ]
199+
```
200+
201+
두 벡터 결과(결과0, 결과1)를 OR 연산으로 병합한 뒤, 단 하나의 비트라도 세팅되어 있다면(`vmaxvq` 또는 `_mm_movemask` 이용) 즉시 `true`를 반환한다.
202+
203+
**정리**
204+
205+
이진 탐색이라면 log₂(256) = 8번의 순차적 비교가 필요하다. SIMD Quad는 사분 탐색 2회(각 3개 피벗 동시 비교) + SIMD 로드/비교 2회로 끝난다. 사분 탐색이 반복될수록 매번 범위를 약 1/4로 줄여나가는 것을 확인할 수 있다.
206+
207+
## 벤치마크 결과
208+
209+
2~4096개 원소의 정렬 배열 100,000개를 생성하고, 각 크기에 대해 1000만 번의 쿼리를 수행했다. Cold cache(매번 다른 배열 접근)와 warm cache(같은 배열을 100회 연속 접근) 두 시나리오를 테스트했다.
210+
211+
### Intel Emerald Rapids / GCC
212+
213+
| 시나리오 | SIMD Quad vs Binary Search |
214+
| ---------- | ----------------------------------------------- |
215+
| Warm cache | **2배 이상** 빠름 |
216+
| Cold cache | 일관되게 빠름 (quaternary의 메모리 병렬성 효과) |
217+
218+
### Apple M4 / LLVM
219+
220+
| 시나리오 | SIMD Quad vs Binary Search |
221+
| ---------- | ------------------------------- |
222+
| Cold cache | **2배 이상** 빠름 |
223+
| Warm cache | 빠르지만 차이가 상대적으로 작음 |
224+
225+
Quaternary vs Binary(둘 다 SIMD 적용) 비교에서는:
226+
227+
- Apple 플랫폼: quaternary의 추가 이점이 미미함
228+
- Intel 플랫폼: 큰 배열의 cold cache에서 quaternary가 유의미한 개선을 보임
229+
230+
## 원문의 마무리
231+
232+
> 교과서적인 이진 탐색은 괜찮은 알고리즘이지만, 실질적으로 의미 있는 수준으로 더 잘할 수 있다. 표준 알고리즘은 이렇게 많은 병렬성을 가진 컴퓨터를 위해 설계된 것이 아니었다.
233+
234+
소스 코드: [github.com/lemire/Code-used-on-Daniel-Lemire-s-blog](https://github.com/lemire/Code-used-on-Daniel-Lemire-s-blog/tree/master/2026/04/26/benchmark)
235+
236+
---
237+
238+
## 내 마무리
239+
240+
현대 프로세서의 SIMD와 메모리 수준 병렬성을 활용하여, 단순한 알고리즘 개선으로 성능 향상을 얻어낸 점이 인상적이다.
241+
242+
사실 이 글을 번역하게 된 계기는 OpenJDK에 위 방식으로 탐색을 개선하자는 의견이 올라왔기 때문이다. 개선을 진행해보고, 또 글을 남겨보겠다.

0 commit comments

Comments
 (0)