CPU 뼛속까지 써먹고, Milvus 사이징 기준을 만들기까지

CPU 뼛속까지 써먹고, Milvus 사이징 기준을 만들기까지

장재영
2026년 10월 12일

안녕하세요. 토스증권 Data Infra 팀 장재영입니다.

토스증권에서는 벡터 검색 워크로드를 지원하기 위해 Milvus 기반의 검색 환경을 구성했는데요. 실제 서비스에 적용하기 전에, 요구하는 검색 품질과 응답 속도를 만족하려면 어느 정도의 물리 서버 자원이 필요한지 확인해야 했습니다.

서버가 부족하면 필요한 처리량과 응답 속도를 맞추기 어렵고, 과하게 준비하면 그만큼 비용이 늘어납니다. 토스증권은 온프레미스 중심으로 인프라를 운영하고 있어, 서비스를 올리기 전에 필요한 CPU와 메모리 규모를 가늠해야 했습니다.

그런데 같은 벡터 검색이라도 워크로드마다 조건이 달랐습니다. 사용하는 임베딩 모델에 따라 벡터 차원이 달랐고, 검색할 결과의 개수와 품질 목표, 데이터의 갱신 빈도도 달랐습니다. 공개 벤치마크의 QPS만으로는 이 조건들이 우리가 가지고 있는 장비에서 어떤 처리량과 지연시간을 보일지 알 수 없었어요. 서비스 요구사항과 모델의 사양, 인덱스 설정에 나뉘어 있는 정보를 모으고, 실제 자원 사용과 연결해 이해해야 했습니다.

저희는 인덱스와 검색 설정을 바꿔 가며 품질과 처리량을 비교했습니다. 검색 중 CPU와 메모리가 어떻게 쓰이는지도 들여다봤는데요. CPU와 메모리 배치를 조정하자 같은 서버에서 검색 품질을 유지하며 처리량을 41% 높일 수 있었습니다. 여기에 검색 설정과 벡터 차원, 서버 수를 바꿔 가며 측정한 결과를 더해 사이징 기준을 만들었습니다. 이번 글에서는 검색 조건을 정하는 것부터 CPU 내부를 살펴보고 워크로드에 필요한 서버 대수를 계산하기까지, 저희가 직접 확인한 내용을 공유하려고 합니다.

실험은 다음 순서로 진행했습니다.

그림 1. 검색 품질과 비교 조건을 정하고, CPU·메모리 배치를 개선한 뒤 서버 규모를 산정하는 과정


검색을 비교하기 전에

벡터 검색의 품질과 처리 성능

우리가 SQL에서 =나 LIKE로 검색할 때는 값이 일치하거나 문자열 패턴에 맞는 데이터를 찾습니다. 제품 설명에서 ‘배터리’라는 단어를 찾는다면, 그 단어가 없는 설명은 검색되지 않죠. 그런데 ‘배터리가 오래가는 휴대폰’을 찾는 사람에게는 ‘충전 없이 하루 종일 쓰는 스마트폰’이라는 설명도 유용한 결과일 수 있습니다.

그림 2. 텍스트·이미지·음성을 임베딩 모델로 변환한 벡터. 배열에 들어 있는 숫자의 개수가 차원입니다.

이렇게 표현이 달라도 의미가 비슷한 데이터를 찾는 데 벡터 검색을 활용할 수 있습니다. 임베딩 모델은 텍스트나 이미지, 음성을 숫자 배열인 벡터로 변환하고, 비슷하다고 학습한 데이터를 벡터 공간에서 가까이 배치합니다. 이 배열에 들어 있는 숫자의 개수를 차원이라고 부릅니다.

검색할 때는 검색어도 벡터로 변환합니다. 그리고 저장된 데이터의 벡터와 거리나 유사도를 비교해 가까운 결과부터 찾습니다. VectorDB는 이러한 검색을 위해 벡터를 식별자·메타데이터와 함께 저장하고 관리합니다.

그림 3. 검색어와 제품 설명을 벡터로 표현한 예시. 표현이 달라도 의미가 비슷한 데이터는 벡터 공간에서 가까이 놓일 수 있습니다. 실제 측정값이 아닌 개념도입니다.

저장된 모든 벡터와 거리를 비교하면 가장 가까운 결과를 정확히 구할 수 있습니다. 그런데 데이터가 수억 건이라면 검색할 때마다 전부 비교하기는 부담스럽겠죠. 그래서 모든 데이터를 확인하는 대신 탐색할 후보를 줄이는 ANN(Approximate Nearest Neighbor, 근사 최근접 이웃 검색)을 사용합니다. 그만큼 계산은 줄어들지만, 전수 비교였다면 찾았을 결과를 일부 놓칠 수 있어요.

이번 실험에서 사용한 HNSW(Hierarchical Navigable Small World)도 ANN 인덱스 중 하나입니다. 가까운 벡터를 여러 계층의 그래프로 연결하고, 성긴 상위 계층에서 검색 범위를 좁힌 뒤 모든 벡터가 있는 최하위 계층(Layer 0)에서 가까운 이웃을 탐색합니다.

그림 4. HNSW의 계층별 탐색 경로. 상위 계층에서 범위를 좁힌 뒤 Layer 0에서 이웃을 탐색합니다. 출처: Malkov & Yashunin, Figure 1.[1]

ANN에서는 가까운 결과를 일부 놓칠 수 있기 때문에, 빨리 찾았다는 것만으로 검색을 평가하기는 어렵습니다. Milvus를 포함한 VectorDB의 ANN 검색에서 검색 품질과 처리 성능을 함께 보는 이유입니다.

정답을 잘 찾더라도 응답이 느리거나 필요한 트래픽을 처리하지 못하면 서비스에서 쓰기 어렵습니다. 반대로 빠르게 처리하더라도 가까운 벡터를 많이 놓친다면 검색 결과를 쓰기 어렵고요. 저희에게는 둘 다 필요한 조건이었습니다.

HNSW의 ef 설정에서 이 관계를 볼 수 있는데요. ef는 검색 후보의 범위를 조절하는 값입니다. 높이면 더 넓게 탐색하므로 대체로 recall이 좋아지지만, 시간과 연산도 더 필요해 처리량은 낮아질 수 있습니다. ef를 낮춰 얻은 높은 QPS라면, 그때 recall이 얼마나 떨어졌는지도 봐야 하는 거죠.

그래서 인덱스와 검색 설정을 비교할 때 사용할 최소 품질 기준을 정했습니다. 이번 실험에서는 recall@10 0.95 이상을 만족하는 설정에서 QPS와 지연시간을 비교했습니다. 설정을 바꿔 QPS가 높아졌더라도 검색 품질이 함께 떨어졌다면 같은 조건의 개선으로 보지 않았습니다.

그림 5. recall@10 계산 예시. 정답 10개 중 9개를 찾았으므로 recall은 0.9입니다.

Milvus를 선택한 이유

초기에는 Milvus와 Qdrant, Weaviate를 함께 검토했습니다. 그 과정에서 중요하게 본 것은 아키텍처였습니다. Milvus는 여러 노드가 검색을 나누어 처리하는 MPP(Massively Parallel Processing) 구조인데요. 검색과 데이터 적재, 인덱스 빌드를 담당하는 컴포넌트의 역할이 명확하게 나뉘어 있습니다. 요청은 Proxy가 받고, Coordinator가 작업을 조율하며, 워커가 실제 작업을 처리합니다.

워커도 맡는 일에 따라 나뉩니다. Milvus는 데이터를 세그먼트(segment)라는 단위로 나누어 관리하는데요. 새 데이터가 추가되는 세그먼트를 growing, 더 이상 새 데이터를 추가하지 않도록 닫은 세그먼트를 sealed라고 부릅니다.

Milvus 2.6에서는 실시간 적재와 growing 세그먼트의 검색은 StreamingNode, sealed 세그먼트의 검색은 QueryNode, 인덱스 빌드와 compaction은 DataNode가 담당합니다. 클러스터 모드에서는 이 컴포넌트들을 독립적으로 배포하고 확장할 수 있어, 검색·적재·인덱스 빌드에 필요한 자원을 각각 조정할 수 있습니다.[2]

그림 6. Milvus 2.6 아키텍처. 실시간 적재·growing 데이터 검색은 StreamingNode, sealed 데이터 검색은 QueryNode, 인덱스 빌드·compaction은 DataNode가 담당합니다.[2]

검색 조건에 맞춰 인덱스와 하드웨어를 선택할 수 있는지도 중요하게 봤는데요. Milvus는 HNSW, IVF, DISKANN 등 다양한 인덱스 알고리즘을 지원하고, GPU_CAGRA처럼 GPU로 검색을 처리하는 인덱스도 제공합니다.[3][4]

Milvus·Qdrant·Weaviate를 비교한 결과, 컴포넌트별로 자원을 조정할 수 있고, 다양한 인덱스와 GPU 검색을 함께 지원하는 Milvus를 선택했습니다. 서비스마다 검색 조건이 달라도, 같은 플랫폼 안에서 그 조건에 맞는 인덱스와 하드웨어 구성을 검토할 수 있었습니다.

이 글에서는 그중 CPU 기반 검색을 다룹니다. 적재와 인덱스 빌드가 끝난 데이터를 대상으로 처리량과 지연시간을 측정했으며, sealed 데이터의 검색을 담당하는 QueryNode의 CPU·메모리 사용에 집중했습니다.[2]


CPU가 벡터를 기다리는 시간

워크로드 조건과 인덱스 비교

여러 서비스의 벡터 검색 요구사항을 모아 보니, 필요한 처리량뿐 아니라 벡터 차원과 결과 수, 데이터 갱신 빈도도 제각각이었습니다. 예를 들어 1024차원 벡터에서 상위 10개를 찾는 검색도 있었고, 128차원 벡터에서 수백 개의 결과를 가져오면서 데이터를 자주 갱신하는 검색도 있었습니다. 같은 QPS를 요구하더라도 읽어야 할 데이터의 양과 쓰기 부하가 달라, 필요한 자원도 각각 살펴봐야 했습니다. 이 글에서는 이러한 요구사항 가운데 1억 건의 고차원 벡터에서 top-10을 검색하고, 300 QPS 이상의 처리량을 확보하는 케이스를 예로 설명하겠습니다.

데이터가 이 정도 규모가 되면 메모리 사용량도 중요한 선택 기준이 됩니다. 1024차원 벡터 1억 건을 float32로 저장하면, 벡터 값만으로 약 410GB가 필요합니다. HNSW처럼 벡터와 그래프를 메모리에 올리는 인덱스라면 연결 정보를 저장할 공간도 더 필요하고요. 저희는 300 QPS를 처리할 수 있는지와 함께, 그 처리량을 확보하는 데 CPU와 메모리가 얼마나 필요한지도 살펴봤습니다.

실험에는 VectorDBBench에서 제공하는 LAION-100M 데이터셋을 사용했습니다. 768차원 벡터 1억 건과 검색용 벡터, 정답 집합이 함께 제공되어 같은 데이터로 품질과 처리량을 반복해서 비교할 수 있었습니다.[11] 전용 서버 3대에 데이터를 적재하고, recall@10 0.95 이상을 비교할 설정의 품질 기준으로 잡았습니다.

이 실험은 인덱스와 자원 설정을 비교해 초기 서버 규모를 잡을 기준을 만드는 과정입니다. 실제 요구사항의 1024차원과는 차이가 있고, 벡터의 분포나 질의가 달라져도 recall과 처리량은 달라질 수 있습니다. 따라서 이 데이터에서 목표를 충족한 구성을 찾은 뒤에는, 실제 서비스의 데이터와 검색 조건으로 recall과 QPS, 지연시간을 다시 확인해야 합니다.

항목
실험 조건
서버
Intel Xeon Silver 4516Y+ × 2소켓, 총 3대
서버당 CPU·메모리
물리 코어 48개 / 논리 코어 96개 / 512GB
QueryNode 할당량
48 vCPU / 256GiB — 후속 CPU·NUMA 실험 기준
데이터셋
VectorDBBench의 LAION-100M / 1억 건 / 768차원
검색 목표
top-10 / recall@10 ≥ 0.95 / 300 QPS 이상을 목표로 비교

메모리를 적게 쓰면서 목표 처리량을 낼 수 있을지 보기 위해, SSD를 활용하는 DISKANN과 벡터 값을 압축하는 IVF_SQ8·HNSW_SQ8을 검토했습니다. 압축하지 않은 HNSW도 함께 측정했습니다.[3]

앞서 그림 4에서 본 HNSW가 그래프의 연결을 따라 후보를 찾는다면, IVF는 벡터를 군집으로 나누고 검색 벡터와 가까운 군집부터 탐색합니다.[5] DISKANN은 SSD에 저장한 그래프와 메모리의 압축된 벡터를 함께 활용하므로, 메모리 사용량뿐 아니라 검색 중 발생하는 디스크 읽기도 고려해야 합니다.[12]

IVF_SQ8과 HNSW_SQ8의 SQ8은 8비트 Scalar Quantization을 뜻합니다. 벡터의 각 성분을 float32의 4바이트 대신 1바이트로 저장하는 방식인데요. 값을 256개 단계로 표현하므로 원래 값과 오차가 생기고, 검색 결과의 순서도 달라질 수 있습니다.[10]

예를 들어 768차원 벡터 하나의 성분 값을 저장하는 데 필요한 공간은 3,072바이트에서 768바이트로 줄어듭니다. 벡터 외에 탐색에 필요한 인덱스 구조와 메타데이터도 메모리를 사용하므로, 전체 사용량은 별도로 측정해야 합니다. 이 글의 HNSW_SQ8은 Milvus의 HNSW_SQ에 sq_type: SQ8을 지정하고, 후보를 높은 정밀도로 다시 계산하는 refine 기능은 끈 구성입니다.

아래는 후보별로 검색 파라미터와 CPU·메모리 할당량을 조정하며 얻은 결과 중 recall@10이 0.95 이상인 설정입니다. 각 구성에서 목표 처리량에 얼마나 도달했는지 비교한 것으로, 인덱스만 바꿨을 때의 차이를 나타내지는 않습니다.

인덱스와 검색 설정
recall@10
QPS — 동시성 10
plain HNSW · ef16
0.950
192
DISKANN · search_list 40
0.991
55
IVF_SQ8 · nprobe 32
0.957
26
HNSW_SQ8 · ef48
0.954
316

측정한 구성에서 DISKANN·IVF_SQ8과 압축하지 않은 HNSW는 recall 기준을 충족했지만 300 QPS에는 미치지 못했습니다. 특히 HNSW는 데이터 한 벌을 검색하는 replica 하나에 약 335GB의 메모리를 사용했습니다. 이만큼의 메모리를 확보하고도 필요한 처리량은 더 높여야 했던 거죠.

HNSW_SQ8은 recall@10 0.954에서 316 QPS를 기록했고, replica당 메모리는 약 114GB였습니다. 이번 데이터와 검색 조건에서는 품질·처리량 목표를 충족하면서 메모리 부담도 줄일 수 있었기 때문에, 이 구성을 바탕으로 최적화를 이어갔습니다.

서버 자원을 충분히 활용하지 못한 상태에서 사이징하면, 필요한 서버 수를 과하게 잡을 수 있습니다. 그래서 인덱스와 데이터를 고정한 뒤, 같은 서버에서 검색 부하를 늘리며 처리량이 어디까지 올라가는지 살펴봤습니다.

CPU 사용률로 설명되지 않는 처리량

동시 요청을 늘려도 처리량은 쉽게 늘지 않았습니다. 검색 조건과 서버 자원을 고정한 채 동시 요청 수만 바꿨는데요. 아래는 인덱스 비교와 별도로 측정한 HNSW_SQ8·ef48, 서버 3대 합산 결과로, 뒤에서 다룰 CPU·메모리 배치 변경 전의 기준값입니다.

동시 요청 수
처리량
10
357 QPS
90
405 QPS
300
468 QPS

동시 요청을 10개에서 300개로 늘렸지만, QPS는 357에서 468로 약 31% 증가하는 데 그쳤습니다. 이 결과만으로는 CPU의 계산 능력이 한계에 도달한 것인지, 계산할 데이터를 기다리는 시간이 긴 것인지 알 수 없었어요.

CPU 사용률도 이 둘을 구분해 주지는 못합니다. 실행 중인 스레드가 메모리에서 데이터를 기다리는 동안에도 CPU 사용 시간은 쌓일 수 있기 때문입니다. 사용률이 높다고 그 시간 내내 계산이 진행되는 것은 아닌 거죠.[6]

IPC(Instructions Per Cycle)는 CPU가 한 사이클에 평균 몇 개의 명령을 완료했는지를 나타냅니다. 사이클은 CPU 클록의 한 주기를 뜻하는데요. 예를 들어 100사이클 동안 명령 200개를 완료했다면 IPC는 2입니다. CPU는 한 사이클에 여러 명령을 완료할 수도 있고, 필요한 데이터를 기다리는 등의 이유로 하나도 완료하지 못할 수도 있습니다.

그림 7. 같은 4사이클 동안 명령 8개를 완료하면 IPC는 2, 2개를 완료하면 0.5입니다. 실제 측정값이 아닌 계산 예시입니다.

측정에는 CPU의 성능 모니터링 장치(PMU)가 제공하는 하드웨어 이벤트를 컨테이너 단위로 수집하는 exporter를 만들어 사용했습니다.[13]

검색 부하 중 전용 노드에서 perf stat -a로 확인한 IPC는 0.35~0.38, 약 0.37이었습니다. 평균적으로 100사이클 동안 약 37개의 명령을 완료했다는 뜻입니다.

물론 IPC가 낮다고 곧바로 메모리가 원인이라고 할 수는 없습니다. 명령의 종류나 의존 관계, 분기 예측 실패도 IPC에 영향을 줍니다.[6] 저희는 HNSW가 벡터를 읽고 거리를 계산하는 과정을 따라가며, 어디에서 기다릴 수 있는지 살펴봤습니다.

HNSW는 검색 벡터와 후보 벡터가 얼마나 가까운지 반복해서 계산합니다. 거리나 유사도를 구하는 방식에 따라 계산은 달라지지만, 각 성분에 비슷한 연산을 반복한다는 점은 같습니다. 내적이라면 두 벡터의 같은 위치에 있는 값을 곱하고, 그 결과를 모두 더하는 식입니다.

이런 반복 계산에 유용한 것이 SIMD(Single Instruction, Multiple Data)입니다. 말 그대로 하나의 명령으로 여러 데이터에 같은 연산을 적용하는 방식인데요. SIMD 명령 하나는 여러 데이터를 처리하므로, IPC가 높고 낮은 것만으로 검색 처리량을 비교할 수는 없습니다.

이 계산은 Milvus 내부의 검색 라이브러리가 실행합니다. Faiss 등을 통합한 벡터 검색 엔진인 Knowhere는 CPU가 지원하는 명령어에 맞춰 SIMD 구현을 자동으로 선택합니다.[15]

이번 실험에 사용한 전용 서버의 Intel Xeon Silver 4516Y+는 AVX-512를 지원합니다.[14] AVX-512는 512비트 폭의 SIMD 연산을 지원하며, 이 폭에는 32비트 실수(float32) 16개가 들어갑니다. 덧셈을 예로 들면 다음과 같습니다.[7]

방식
덧셈 명령 하나가 처리하는 float32 성분 수
스칼라 연산
1개
AVX-512의 512비트 SIMD 연산
16개

SIMD로 이 반복 연산을 묶어 처리하면 거리 계산에 필요한 명령 수를 줄일 수 있습니다.

여기서 512는 임베딩 차원이 아니라 CPU가 한 번에 다루는 비트 폭입니다. 위 표는 SIMD의 동작을 설명하기 위한 float32 예시이며, SQ8의 실제 연산 방식은 자료형과 커널 구현에 따라 달라집니다.

다만 SIMD로 여러 값을 한 번에 계산할 수 있어도, 계산할 벡터는 먼저 메모리에서 읽어 와야 합니다. HNSW는 그래프를 따라 검색 후보를 찾기 때문에, 다음에 읽을 벡터가 탐색 결과에 따라 달라지는데요. 배열을 순서대로 읽을 때처럼 다음 주소를 미리 예상하기 어렵습니다. 필요한 벡터가 캐시에 없다면 DRAM에서 가져와야 하고요.

그림 8. HNSW 탐색 중 후보 벡터를 읽고 거리를 계산하는 과정

이때는 단위 시간에 옮길 수 있는 데이터 양인 대역폭뿐 아니라, 요청한 데이터가 도착하기까지 걸리는 접근 지연도 봐야 합니다. 대역폭에 여유가 있어도 필요한 벡터가 아직 도착하지 않았다면, 그 벡터의 거리 계산은 기다려야 하는 거죠.[6]

검색 중 관측한 낮은 IPC가 벡터를 읽는 지연과 관련이 있는지 확인하기 위해, 검색을 실행하는 CPU와 벡터가 저장된 메모리가 각각 어느 소켓에 있는지 살펴봤습니다.

NUMA 배치

토스증권은 온프레미스 서버에 주로 Intel Xeon을 사용하고 있습니다. 이번 실험도 기존 서버 환경을 기준으로 Milvus에 필요한 자원을 가늠하기 위해 진행했는데요. 실험에 사용한 서버는 CPU가 두 개 들어가는 2소켓 구성으로, 각 소켓에 물리 코어 24개·논리 코어 48개가 있고 메모리도 따로 연결돼 있습니다. 같은 서버 안의 CPU와 메모리라도, 실제로는 어느 소켓에 배치됐는지에 따라 데이터를 읽는 경로가 달라지는 거죠.

그림 9. 2소켓 서버용 마더보드의 예시. 두 CPU의 방열판 주변으로 메모리 슬롯이 배치돼 있습니다. 실험에 사용한 장비와는 다른 모델입니다. 사진: Phiarc / Wikimedia Commons, CC BY-SA 4.0.

그림 10. 2소켓 서버의 CPU·메모리 연결 개념도. 다른 소켓에 연결된 RAM을 읽을 때는 UPI와 상대 CPU의 메모리 컨트롤러를 거칩니다. 설명을 위해 직접 작성했으며, 내부 구조는 단순화했습니다.

각 CPU 소켓에는 독립된 메모리 컨트롤러와 RAM이 연결돼 있습니다. 같은 메모리 읽기라도 위치에 따라 경로가 달라집니다.

  • local access — 같은 소켓에 연결된 메모리 읽기
  • remote access — 소켓 간 연결인 UPI를 거쳐 반대 소켓의 메모리 읽기

이처럼 메모리의 위치에 따라 접근 시간이 달라지는 구조를 NUMA(Non-Uniform Memory Access)라고 합니다. 반대 소켓의 메모리를 읽을 때는 요청과 데이터가 소켓 사이를 오가야 해서 더 오래 걸릴 수 있습니다. HNSW가 필요한 벡터를 반대 소켓에서 가져온다면, 거리 계산을 시작하기 전까지 기다리는 시간도 길어질 수 있겠죠.

그런데 쿠버네티스에 컨테이너가 사용할 CPU와 메모리를 요청하는 것만으로는 이 위치가 정해지지 않습니다. 기본 설정에서는 CPU를 양쪽 소켓에서 사용할 수도 있고, 실행 중인 CPU와 메모리 페이지가 서로 다른 소켓에 있을 수도 있어요. 요청한 자원량을 받았더라도 어디에 배치됐는지는 별개의 문제였던 거죠.

그림 11. NUMA 배치 전의 DRAM 읽기 경로. 이 서버는 소켓 하나가 NUMA node 하나에 해당하며, 코어와 메모리 페이지 수는 축약했습니다.[8]

실제 검색 중 local·remote DRAM 읽기를 구분하는 PMU 이벤트를 측정해 봤습니다. 세 노드의 remote 비율은 48.9%, 49.5%, 49.6%였는데요. 관측한 DRAM 읽기의 거의 절반이 반대 소켓으로 향하고 있었습니다.

CPU 사용률에서는 이 차이가 보이지 않았습니다. QueryNode는 CPU를 쓰고 있었지만, 필요한 데이터를 상당 부분 반대 소켓에서 가져오고 있었던 겁니다. CPU와 메모리를 가까이 배치하면 이 접근을 줄일 수 있겠다고 봤습니다.

그림 12. 검색 부하 중 30초간 수집한 perf 결과. 세 노드의 remote DRAM 비율은 각각 48.9%, 49.5%, 49.6%였습니다.

반대 소켓의 메모리를 읽는 일을 줄이기 위해, QueryNode가 사용하는 CPU와 인덱스 메모리를 같은 NUMA node에 배치했습니다. 할당한 CPU·메모리의 양과 검색 조건은 유지하고, 배치 변경 전후의 QPS와 DRAM 접근 비율을 비교했습니다.

그림 13. NUMA 배치 후의 DRAM 읽기 경로. QueryNode의 실행 CPU와 인덱스 메모리를 NUMA node 0에 모았으며, 반대 소켓과 UPI 연결은 그대로입니다.
cpuManagerPolicy: static
memoryManagerPolicy: Static
topologyManagerPolicy: single-numa-node
reservedSystemCPUs: "24-25,72-73"

CPU만 고정해도 이미 다른 소켓에 놓인 메모리를 계속 읽을 수 있으므로, CPU와 메모리 배치를 함께 제어해야 했습니다. 이를 위해 kubelet의 세 정책을 조합했습니다.[9]

  • CPU Manager — 조건을 만족하는 Guaranteed Pod에 exclusive CPU 할당
  • Memory Manager — 메모리의 NUMA 배치 관리
  • Topology Manager — CPU와 메모리를 하나의 NUMA node에 함께 할당할 수 있는지 판단

QueryNode의 CPU 요청은 정수로 지정하고 QoS는 Guaranteed로 맞췄습니다. 한 소켓의 CPU를 모두 사용할 수 있도록 시스템 CPU는 반대 소켓에 예약했습니다. 위 YAML은 핵심 설정만 발췌한 것이며, CPU 번호는 이 서버의 topology를 기준으로 정한 값입니다.

노드를 한 대씩 drain하고 설정을 바꾼 뒤 QueryNode를 재배치했습니다. 적용 후에는 Mems_allowed_list와 numastat, remote DRAM 지표로 실제 배치를 확인했습니다.

NUMA 배치 전후의 처리량과 지연시간

ef48에서 recall 0.9541을 유지하며, QPS가 405에서 572로 증가했습니다. 아래 QPS는 서버 3대 합산, 동시성 90에서 비교한 값입니다.

NUMA 배치 전후에는 PMU exporter의 컨테이너 지표와 노드 단위 perf 측정값을 검색 처리량·지연시간과 함께 비교했습니다.

지표
변경 전
변경 후
remote DRAM 비율
약 50%
0.8%
IPC
0.37
0.55
QPS @ ef32
433
712 (+64%)
QPS @ ef48
405
572 (+41%)
검색 p99 — 측정 구간 최댓값
3.94s
2.04s (−48%)
AVX-512 FP 명령 retire rate
19.4B events/s
24.0B events/s (+24%)
recall@10
0.9541
0.9541
NUMA 전후 비교는 동일 조건의 405→572 QPS를 기준으로 합니다. 앞의 인덱스 비교에서 측정한 316 QPS와는 별도 실험입니다.

표의 ef32 결과는 recall 0.95에 조금 못 미치는 참고 조건으로, NUMA 배치 전후의 차이를 함께 비교하기 위해 제시했습니다.

표의 p99는 대시보드에서 관측한 측정 구간의 최댓값이며, AVX-512 값은 평균 PMU event rate입니다. FLOPS로 환산한 연산량은 아닙니다.

서버 수와 CPU 할당량은 그대로였는데, remote DRAM 비율은 약 50%에서 0.8%로 낮아졌습니다. IPC도 0.37에서 0.55로 높아졌고, 같은 recall에서 QPS는 41% 증가했습니다. CPU와 데이터의 위치를 바꾼 것만으로 같은 자원에서 더 많은 검색을 처리한 셈입니다.

AVX-512 FP 명령 retire rate도 높아졌습니다. CPU 자체의 연산 능력이 바뀐 것은 아니었는데요. remote DRAM 비율이 줄고 IPC와 QPS가 함께 높아진 것을 보면, 데이터를 기다리는 시간이 줄면서 기존 CPU에서 더 많은 명령을 처리하게 됐다고 해석할 수 있었습니다.

앞서 적용한 SQ8과 NUMA 배치는 둘 다 벡터를 읽는 과정에 영향을 줍니다. SQ8은 벡터를 작게 저장해 읽을 데이터의 양을 줄이고, NUMA 배치는 그 데이터를 가까운 메모리에서 가져오게 합니다. 인덱스 설정과 서버 설정을 따로 봤다면 놓치기 쉬운 부분이었어요.

그림 14. NUMA는 읽는 위치를, SQ8은 벡터 표현 크기를 바꿉니다. 4분의 1은 벡터 성분의 크기이며, 전체 인덱스 크기의 감소율은 아닙니다.

처음부터 CPU 사용률만 봤다면 메모리가 어느 소켓에 있는지까지 살펴보기는 어려웠을 겁니다. HNSW가 벡터를 읽는 방식, PMU 지표, 서버의 NUMA 구조를 함께 보면서 배치를 바꿔 볼 이유가 생겼습니다. 그리고 QPS가 오른 뒤에도 recall이 유지되는지 확인해야 했고요. 검색 알고리즘부터 쿠버네티스와 하드웨어까지 이어서 봐야 하는 작업이었습니다.


벤치마크, 그다음

같은 서버에서 처리량을 높였지만, 새로운 서비스가 들어올 때 필요한 서버 수를 답하려면 기준이 더 필요했습니다. 벡터 차원과 목표 recall이 달라지면 서버 한 대가 처리할 수 있는 검색량도 달라지기 때문입니다. 그래서 최적화한 구성을 바탕으로, 검색 조건에 따라 필요한 자원을 가늠할 수 있도록 측정값을 정리했습니다.

ef별 recall과 처리량

ef는 클라이언트가 검색 요청마다 조정할 수 있는 값입니다.[10] 따라서 서버 규모를 가늠하려면 클라이언트의 검색 설정에 따라 처리량이 얼마나 달라지는지도 알아야 했습니다. 같은 서버에서 ef를 바꿔 측정해 보니, 아래 두 설정에서는 recall과 처리량이 이렇게 달라졌습니다.

검색 설정
recall@10
노드당 QPS
ef48
0.954
190
ef128
0.962
99

ef48은 앞선 최적화 실험에서 변경 전후를 비교하기 위해 고정한 조건입니다. 실제 사이징에는 클라이언트가 사용할 ef에서 목표 recall을 만족하는지, 그때 처리량은 얼마인지를 함께 확인해야 합니다. 저희가 정리하려던 기준도 특정 ef를 쓰라는 규칙이 아니라, 검색 설정이 달라지면 필요한 자원이 어떻게 달라지는지 알기 위해서였어요.

서버 수·벡터 차원별 처리량

노드당 처리량으로 필요한 서버 수를 계산하려면, 서버를 늘렸을 때 처리량도 그만큼 늘어나는지 확인해야 했습니다. replica 하나를 기준 구성으로 두고 서버와 동시 요청 수를 함께 늘려 보니, 측정한 1~3대에서는 서버 수에 거의 비례해 처리량이 늘었습니다.

벡터 차원의 영향도 확인했습니다. 원본 LAION 벡터와 질의 벡터에 작은 난수 값을 덧붙여 차원을 늘리고, 나머지 검색 조건은 고정했습니다. 이때 recall은 원본 이웃 관계가 얼마나 유지되는지 확인하는 보조 지표로 사용했습니다.

일부 차원에서 측정한 결과로 더 높은 차원의 처리량을 예측하고, 실제 측정값과 비교했습니다. 두 값이 가까워 실험한 범위에서는 초기 사이징에 활용할 수 있겠다고 봤습니다. 이 실험에서는 차원이 두 배가 됐을 때 처리량은 약 3분의 1로 줄었습니다. 필요한 서버 수를 계산할 때 이 감소 폭도 반영해야 했습니다.

이는 같은 데이터의 차원을 늘린 실험이므로, 다른 임베딩 모델이 만든 벡터까지 같은 처리량을 낸다고 볼 수는 없습니다.

그림 15. 서버 수·벡터 차원별 합산 QPS. 왼쪽은 서버 수에 맞춰 동시성도 늘렸고, 오른쪽은 3대·동시성 90으로 측정했습니다. 1536차원은 예측 후 실측한 값입니다.

서버 대수 산정

앞서 측정한 결과를 바탕으로, 검색 요구사항을 넣으면 필요한 서버 규모를 계산하는 시뮬레이터를 만들었습니다. 벡터의 차원과 건수, 클라이언트의 검색 설정, 목표 처리량을 자원 요구량으로 연결한 것입니다.

먼저 실험한 구성을 바탕으로 검색 설정과 벡터 차원에 따른 노드당 처리량을 추정하고, 목표 QPS를 처리하는 데 필요한 서버 수를 계산했습니다. 클라이언트가 검색 설정을 바꾸거나 다른 차원의 벡터를 사용하면, 그 조건을 반영해 필요한 규모를 다시 가늠할 수 있도록 했습니다.

그림 16. 벡터 차원·건수·recall·peak QPS로 계산한 서버 규모 시뮬레이션 예시. 화면의 서버 대수와 금액은 입력 조건에 따른 추정치입니다.

메모리도 함께 계산했습니다. HNSW_SQ8의 벡터와 그래프 인덱스, 스칼라 필드를 올리는 데 필요한 공간에 실행 중 사용할 여유 메모리를 더했습니다. 그리고 목표 QPS를 처리하는 데 필요한 서버 수와 데이터를 메모리에 올리는 데 필요한 서버 수 중 큰 쪽을 선택하도록 했습니다.

그림 17. QPS와 메모리로 각각 계산한 서버 대수 중 큰 값을 선택합니다. 지연시간 목표와 장애·점검 시 여유 용량은 별도로 확인합니다.

이렇게 산정한 구성에서 실제 서비스 데이터로 recall과 QPS, 지연시간을 확인하고, 장애·점검에 대비한 여유 용량을 반영해 운영 규모를 정합니다.

이제 새로운 검색을 준비할 때, 필요한 서버 수를 측정 근거와 함께 제시할 수 있게 됐습니다. 서비스 팀이 가져온 검색 조건이 자원 사용에 어떤 영향을 주는지, 요구사항을 바꾸면 서버가 얼마나 더 필요한지를 함께 검토할 기준이 생긴 것입니다.


마치며

Milvus를 준비하면서 알고 싶었던 것은 필요한 서버가 몇 대인지였습니다. 그런데 서버 대수를 계산하려면 검색 조건부터 알아야 했습니다. 어떤 모델로 만든 벡터인지, 결과가 몇 개 필요한지, recall은 어느 정도여야 하는지, 데이터는 얼마나 자주 바뀌는지가 모두 자원 사용에 영향을 주기 때문입니다.

이번에는 고차원 벡터의 top-10 검색을 대상으로 인덱스를 비교하고, QueryNode가 CPU와 메모리를 어떻게 쓰는지 확인했습니다. HNSW의 검색 방식과 NUMA 구조를 함께 살펴본 덕분에, 같은 recall과 자원 할당량을 유지하면서 검색 처리량을 높일 수 있었습니다.

필요한 정보가 한곳에 모여 있지는 않았습니다. 목표 검색 품질은 서비스 개발 조직과 함께 정해야 했고, 벡터 차원은 임베딩 모델에 따라 달랐습니다. 실제 검색 비용을 알려면 인덱스의 동작을 이해해야 했고, 처리량 개선을 위해 쿠버네티스 설정과 CPU 내부까지 내려가게 됐습니다. 어느 한쪽만 보고서는 설명하기 어려운 문제였어요.

이 글이 벡터 검색을 준비하는 분들께, 검색 조건과 인덱스, 서버 자원을 함께 살펴보고 자신의 환경에 맞는 기준을 만드는 데 도움이 되었으면 합니다.


참고 자료

뉴스레터가 발행되면
이메일로 알려드릴게요
구독하기