앞 글까지의 실험은 전부 문서를 한 번 인코딩해 두고 그 위에서 순위를 셌습니다. 인코딩 자체는 실험마다 1~2분씩 걸리는 고정비였고, 그 시간을 줄이는 손잡이로 가장 먼저 떠오르는 것이 배치 크기입니다. 이 글은 그 손잡이를 돌려 봅니다. CPU에서 모델을 돌리는 일반적인 사정은 CPU만으로 LLM을 돌린다는 것이 맡으므로, 여기서는 임베딩 인코딩의 배치 크기 하나만 흔듭니다.
통념은 「배치를 키우면 처리량이 오르다가 어디선가 멈춘다」입니다. 계획도 그 멈추는 무릎을 찾으려 했습니다. 무릎은 생각보다 훨씬 왼쪽에 있었습니다. 4스레드에서는 배치 8이 꼭대기였고 그보다 키우면 처리량이 도로 떨어졌습니다. 스레드를 하나로 묶으면 배치가 처리량을 아예 올리지 못했습니다. 그러는 동안 메모리는 배치에 정비례해서, 배치 256에서는 모델을 올려 둔 프로세스 하나만큼을 더 먹었습니다.
실험 설계
배치 크기
SentenceTransformer.encode는 문서 목록을 받아 정해진 개수씩 묶어 모델에 넣습니다. 한 번에 넣는 개수가 배치 크기이고, 따로 적지 않으면 32입니다. 묶음 안의 문서는 길이가 달라도 한 텐서로 들어가야 하므로 가장 긴 문서에 맞춰 뒤를 빈 토큰으로 채웁니다. 이 채움이 패딩이고, 채운 자리도 계산은 똑같이 합니다.
라이브러리는 패딩을 줄이려고 문서를 길이순으로 줄 세운 뒤 앞에서부터 묶습니다. 그래서 배치가 작으면 비슷한 길이끼리 묶여 패딩이 거의 없고, 배치가 커질수록 한 묶음 안의 길이 차이가 벌어져 패딩이 늘어납니다. 출력의 pad% 열은 문서를 토큰 수로 줄 세웠다고 보고 센 패딩 비율입니다. 라이브러리는 실제로는 글자 수로 줄 세우므로 실제 패딩은 이 값과 같거나 조금 큽니다.
재는 법
재료는 CPU만으로 세우는 검색 실험대의 두 코퍼스입니다. 영어는 scifact 문서를 all-MiniLM-L6-v2(6층, 최대 256토큰)로, 한국어는 KorQuAD 문단에 passage: 를 붙여 multilingual-e5-small(12층, 최대 512토큰)로 인코딩합니다. 각 코퍼스에서 시드 0으로 256편을 뽑아 씁니다.
- 처리량은 256편을 인코딩하는 데 걸린 시간으로 나눈 문서/초입니다. 같은 프로세스 안에서 두 번 재서 둘 다 싣고, 판정에는 빠른 쪽을 씁니다. 배치 8짜리 예열을 한 번 돌린 뒤에 잽니다.
- 메모리는 계획대로
resource.getrusage(RUSAGE_SELF).ru_maxrss로 잽니다. 이 값은 프로세스가 지금까지 가장 많이 쓴 상주 메모리라 한 번 오르면 내려오지 않습니다. 그래서 배치 크기마다 새 프로세스를 띄우고, 모델을 올리고 예열한 뒤의 값(base MB)과 인코딩을 마친 뒤의 값의 차이(+peak MB)를 셉니다. - 꺾이는 지점은 계획이 정한 「처리량 증가율이 배치 두 배당 5% 아래로 떨어지는 첫 배치」입니다. 배치를 네 배씩 올리므로
per x2열은 직전 배치 대비 배율을 두 배 단위로 환산한 값입니다.
계획은 스레드를 1로 묶어 배치 효과만 남기라고 했습니다. 그대로 재되, 실제로는 대개 여러 코어로 돌리므로 4스레드도 함께 쟀습니다.
규모를 줄인 자리
계획은 배치를 512까지 올리고 두 코퍼스를 1스레드로 재는 것이었습니다. 예비 실행에서 KorQuAD 문단 512개를 1스레드·배치 1로 인코딩하는 데만 1분 반 가까이 걸렸고, 배치 다섯 개를 두 번씩 재면 그것 하나로 15분이 넘습니다. 그래서 세 가지를 줄였습니다.
- 문서를 256편으로 줄이고 배치 상한도 256으로 내렸습니다. 배치 256은 256편 전체를 한 묶음에 넣는 것입니다.
- KorQuAD는 4스레드로만 쟀습니다. 1스레드 판정은 scifact 쪽이 맡습니다.
- 세 조합을 따로 돌려 하나하나를 5분 안에 넣었습니다(3분 45초 · 1분 49초 · 4분 12초).
재현과 출력
재현 블록
pip install torch sentence-transformers datasets numpy
import sys, time, resource, subprocess, numpy as np
MODELS = {"scifact": "sentence-transformers/all-MiniLM-L6-v2", "korquad": "intfloat/multilingual-e5-small"}
BATCHES, N, REPS = (1, 8, 32, 128, 256), 256, 2
def docs_of(corpus):
from datasets import load_dataset
if corpus == "scifact":
d = [(x["title"] + " " + x["text"]).strip() for x in load_dataset("BeIR/scifact", "corpus")["corpus"]]
else:
d = ["passage: " + p for p in sorted(set(load_dataset("KorQuAD/squad_kor_v1")["validation"]["context"]))]
return [d[i] for i in np.random.default_rng(0).permutation(len(d))[:N]]
def worker(corpus, threads, bs):
import torch
torch.set_num_threads(threads); torch.manual_seed(0)
from sentence_transformers import SentenceTransformer
docs, m = docs_of(corpus), SentenceTransformer(MODELS[corpus])
m.encode(docs[:8], batch_size=8) # warm-up
base, thr = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, []
for _ in range(REPS):
t = time.perf_counter(); m.encode(docs, batch_size=bs, show_progress_bar=False); thr.append(N / (time.perf_counter() - t))
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print(" ".join(f"{x:.2f}" for x in thr), f"{base / 1024:.1f} {(peak - base) / 1024:.1f}")
if len(sys.argv) > 3:
worker(sys.argv[1], int(sys.argv[2]), int(sys.argv[3])); sys.exit()
corpus, threads = sys.argv[1], int(sys.argv[2]); t0 = time.perf_counter()
from transformers import AutoTokenizer
tk = AutoTokenizer.from_pretrained(MODELS[corpus]); ml = 256 if corpus == "scifact" else 512
L = np.sort([min(len(tk(d)["input_ids"]), ml) for d in docs_of(corpus)])[::-1]
print(f"== {corpus} ({MODELS[corpus]}) docs={N} threads={threads} tokens: median={np.median(L):.0f} max={L.max()}")
print(f"{'batch':>5} {'pad%':>6} {'docs/s runs':>16} {'best':>7} {'per x2':>8} {'vs bs=1':>8} {'base MB':>8} {'+peak MB':>9}")
prev = first = None
for bs in BATCHES:
pad = sum(L[i] * len(L[i:i + bs]) for i in range(0, N, bs)) / L.sum() - 1
r = subprocess.run([sys.executable, __file__, corpus, str(threads), str(bs)], capture_output=True, text=True).stdout.split()
thr = [float(x) for x in r[:REPS]]; best = max(thr); first = first or best
step = f"{(best / prev) ** (1 / np.log2(bs / pb)) - 1:+8.1%}" if prev else f"{'':>8}"
print(f"{bs:>5} {pad:6.1%} {' '.join(f'{x:7.2f}' for x in thr):>16} {best:7.2f} {step} {best / first:7.2f}x {float(r[-2]):8.1f} {float(r[-1]):9.1f}")
prev, pb = best, bs
print(f"total {time.perf_counter() - t0:.0f}s")
python3 batch.py scifact 1
python3 batch.py scifact 4
python3 batch.py korquad 4
인자 둘은 코퍼스와 스레드 수입니다. 스크립트는 배치마다 자기 자신을 새 프로세스로 다시 불러(인자 셋) 그 안에서 재고, 바깥 프로세스는 결과를 모아 표로 찍습니다.
실제 출력
== scifact (sentence-transformers/all-MiniLM-L6-v2) docs=256 threads=1 tokens: median=256 max=256
batch pad% docs/s runs best per x2 vs bs=1 base MB +peak MB
1 0.0% 17.69 17.55 17.69 1.00x 1086.4 0.1
8 0.9% 17.31 17.68 17.68 -0.0% 1.00x 1083.1 7.5
32 2.9% 16.00 15.62 16.00 -4.9% 0.90x 1089.1 179.5
128 7.8% 13.89 13.91 13.91 -6.8% 0.79x 1081.6 553.6
256 7.8% 13.14 13.77 13.77 -1.0% 0.78x 1083.6 1041.6
total 224s
== scifact (sentence-transformers/all-MiniLM-L6-v2) docs=256 threads=4 tokens: median=256 max=256
batch pad% docs/s runs best per x2 vs bs=1 base MB +peak MB
1 0.0% 43.72 45.73 45.73 1.00x 1090.3 0.0
8 0.9% 56.55 53.89 56.55 +7.3% 1.24x 1084.5 8.3
32 2.9% 52.44 54.22 54.22 -2.1% 1.19x 1086.9 199.5
128 7.8% 43.31 44.59 44.59 -9.3% 0.98x 1094.9 543.2
256 7.8% 44.65 45.46 45.46 +2.0% 0.99x 1087.7 1057.5
total 108s
== korquad (intfloat/multilingual-e5-small) docs=256 threads=4 tokens: median=272 max=512
batch pad% docs/s runs best per x2 vs bs=1 base MB +peak MB
1 0.0% 20.64 20.58 20.64 1.00x 1781.7 0.0
8 1.7% 23.08 24.27 24.27 +5.5% 1.18x 1781.8 61.6
32 6.1% 18.57 20.08 20.08 -9.0% 0.97x 1780.9 378.7
128 35.0% 12.03 11.64 12.03 -22.6% 0.58x 1773.8 1053.4
256 76.3% 8.61 8.93 8.93 -25.8% 0.43x 1775.2 2144.3
total 251s
stderr로 나오는 Hugging Face 경고와, KorQuAD 문단 일부가 512토큰을 넘는다는 토크나이저 경고는 뺐습니다. 넘는 문단은 인코딩할 때 512토큰에서 잘립니다.
두 번째 스윕
같은 스크립트를 같은 기계에서 이보다 먼저 한 번 더 돌렸습니다. 그때의 빠른 쪽 처리량을 배치 1 대비 배율로 나란히 둡니다.
| 조합 | 실행 | 배치 8 | 배치 32 | 배치 128 | 배치 256 |
|---|---|---|---|---|---|
| scifact 1스레드 | 위 출력 | 1.00x | 0.90x | 0.79x | 0.78x |
| scifact 1스레드 | 먼저 한 실행 | 0.99x | 0.88x | 0.76x | 0.76x |
| scifact 4스레드 | 위 출력 | 1.24x | 1.19x | 0.98x | 0.99x |
| scifact 4스레드 | 먼저 한 실행 | 1.17x | 1.14x | 0.95x | 0.99x |
| KorQuAD 4스레드 | 위 출력 | 1.18x | 0.97x | 0.58x | 0.43x |
| KorQuAD 4스레드 | 먼저 한 실행 | 1.16x | 0.98x | 0.69x | 0.49x |
배율은 실행 사이에 많게는 0.11 흔들렸습니다. 그래도 모양은 여섯 줄이 다 같습니다. 4스레드에서는 배치 8이 꼭대기이고, 1스레드에서는 배치 1과 8이 같으며, 어디서든 배치 128부터는 배치 1보다 느리거나 같습니다. 메모리와 패딩 열은 두 실행이 몇 MB 안에서 같았습니다.
처리량
한 스레드
스레드 하나에서는 배치가 처리량을 하나도 올리지 못했습니다. 배치 1이 17.69문서/초, 배치 8이 17.68문서/초로 같고, 그 뒤로는 내려가기만 해서 배치 256에서는 배치 1의 0.78배입니다. 계획의 판정으로 적으면 꺾이는 지점이 배치 8, 곧 첫 걸음입니다. 키울 이유가 처음부터 없었다는 뜻입니다.
이 결과는 문서 하나가 이미 큰 계산이라는 것과 맞아떨어집니다. scifact 문서는 토큰 수 중앙값이 256으로 거의 전부가 모델 최대 길이에서 잘리고, 그러면 한 문서만 넣어도 층마다 256 × 384 크기의 행렬곱이 돕니다. 코어 하나를 채우기에 이미 충분한 크기라, 문서를 더 묶어도 코어가 더 빨리 돌 여지가 없습니다. 이것은 해석이고 이 실험이 따로 확인한 것은 아닙니다.
네 스레드
4스레드에서는 배치가 조금 듭니다. scifact는 배치 8에서 1.24배(먼저 한 실행 1.17배), KorQuAD는 1.18배(1.16배)였습니다. 두 배당 증가율로 환산하면 +7.3%와 +5.5%라 계획의 5% 문턱을 넘습니다. 그런데 바로 다음 배치 32에서 두 코퍼스 모두 음수로 돌아섭니다(−2.1%·−9.0%). 꺾이는 지점이 배치 32이고, 꼭대기는 배치 8입니다.
스레드가 넷이면 한 문서의 행렬곱을 네 코어가 나눠 맡으므로 코어 하나에 돌아가는 몫이 작아집니다. 배치 8은 그 몫을 다시 키워 코어를 채우는 정도의 크기였던 것으로 보입니다. 1스레드에서 배치 효과가 없었던 것과 함께 놓으면, CPU에서 배치가 버는 것은 코어를 채우는 만큼까지이고 그 이상은 없습니다.
패딩
배치를 키우면 왜 도로 느려지는지는 코퍼스마다 사정이 다릅니다. KorQuAD는 패딩이 다 설명합니다. 문단 길이가 중앙값 272토큰에서 최대 512토큰까지 퍼져 있어서, 배치 128이면 패딩이 35.0%, 256편을 한 묶음에 넣으면 76.3%입니다. 가장 긴 문단 하나에 맞춰 나머지 255편을 512토큰으로 채우니 실제 문서보다 1.76배의 토큰을 계산합니다. 다만 패딩만으로 계산한 손해(배치 256에서 1/1.76 = 0.57배, 배치 128에서 1/1.35 = 0.74배)보다 실제 처리량이 더 떨어졌습니다(0.430.49배, 0.580.69배). 패딩이 손해의 큰 몫이지만 전부는 아니고, 나머지는 아래 scifact에서 보는 것과 같은 종류의 손해로 보입니다.
scifact는 패딩으로 설명되지 않습니다. 거의 모든 문서가 256토큰에서 잘려 길이가 같으므로 배치 256에서도 패딩이 7.8%뿐인데, 처리량은 1스레드에서 배치 1 대비 2224%, 4스레드에서 배치 8 대비 1520% 떨어졌습니다. 패딩 말고 다른 무언가가 큰 배치를 느리게 만든다는 뜻입니다. 아래에서 볼 중간 텐서 메모리가 1GB까지 자라는 것과 관련이 있을 것으로 짐작하지만, 이 실험은 그 원인을 가르지 않았습니다.
메모리
배치와 상주 메모리
메모리는 처리량과 달리 배치에 정확히 따라 자랐습니다.
| 배치 | scifact 1스레드 | scifact 4스레드 | KorQuAD 4스레드 |
|---|---|---|---|
| 1 | +0.1MB | +0.0MB | +0.0MB |
| 8 | +7.5MB | +8.3MB | +61.6MB |
| 32 | +179.5MB | +199.5MB | +378.7MB |
| 128 | +553.6MB | +543.2MB | +1,053.4MB |
| 256 | +1,041.6MB | +1,057.5MB | +2,144.3MB |
배치 128과 256에서는 문서 한 편당 scifact 약 4MB, KorQuAD 약 8MB로, 배치를 두 배로 하면 메모리도 거의 정확히 두 배가 됩니다(배치 32는 한 편당 5.6MB·11.8MB로 조금 더 큽니다). KorQuAD 쪽이 두 배인 것은 모델 층이 6층에서 12층으로, 최대 길이가 256토큰에서 512토큰으로 두 배이기 때문으로 보입니다. 스레드 수는 메모리를 거의 바꾸지 않았습니다.
배치 256의 증가분을 기준선과 견주면 크기가 실감 납니다. scifact는 모델·데이터셋을 올려 둔 프로세스가 약 1,085MB인데 인코딩 중에 1,042MB를 더 먹어 거의 두 배가 됐고, KorQuAD는 약 1,778MB 위에 2,144MB를 더 먹었습니다. 처리량은 배치 1의 0.43~0.78배로 떨어진 채로 말입니다.
기본값 32
encode의 기본 배치는 32이고, 따로 고치지 않으면 다들 그것으로 돕니다. 4스레드에서 배치 8과 견주면 그 기본값의 값이 보입니다.
- scifact: 배치 32가 배치 8보다 2.5
4% 느리고(51.24 대 52.53, 54.22 대 56.55), 메모리는 1524배를 씁니다(+198MB 안팎 대 +8~13MB). - KorQuAD: 배치 32가 16
17% 느리고(19.00 대 22.57, 20.08 대 24.27), 메모리는 6배를 씁니다(+377379MB 대 +62~63MB).
이 조합들에서는 기본값을 32에서 8로 내리는 것이 처리량과 메모리 둘 다에서 이득이었습니다. 1스레드라면 8과 1이 같으니 메모리가 가장 적은 1이 맞습니다. GPU에서 배치를 크게 묶을수록 빨라지는 이야기와는 정반대이고, 그 이야기는 연속 배칭처럼 GPU의 사정입니다.
결정 규칙
세 규칙
- CPU 4스레드에서는 배치 8로 인코딩한다. 두 코퍼스 모두 배치 8이 꼭대기였고(배치 1 대비 1.16
1.24배), 기본값 32보다 빠르면서 메모리는 624분의 1이었습니다. - 1스레드(또는 코어 하나에 프로세스 하나씩 나눠 돌리는 구성)에서는 배치를 키우지 않는다. 배치 1과 8의 처리량이 같았고 그 뒤로는 내려가기만 했습니다.
- 길이가 들쭉날쭉한 문서를 큰 배치로 묶지 않는다. KorQuAD처럼 최대 길이가 중앙값의 두 배 가까운 코퍼스에서는 배치 256의 패딩이 76%였고 처리량이 절반 아래로 떨어졌습니다. 메모리 예산은 「문서 한 편당 4~8MB × 배치」로 잡으면 이 실험의 값과 맞습니다.
이 규칙은 앞선 실험 노트들의 스크립트에도 그대로 걸립니다. 실험대 이후의 글들은 KorQuAD 문단을 배치 32로, scifact 문서를 배치 64로 인코딩해 왔고, 앞 글을 비롯한 여러 글이 4스레드로 돌았습니다. 이 글의 KorQuAD 4스레드 숫자대로라면 그런 인코딩은 배치 8보다 1617% 느리게 돌았던 셈입니다. 순위는 배치 크기와 무관하므로 그 글들의 결론은 바뀌지 않지만, 인코딩에 걸린 12분은 줄일 여지가 있었습니다. 배치 64는 이 글이 재지 않은 값이라 거기서 얼마를 잃었는지는 적지 않습니다.
꺾이는 지점
한 줄로 줄이면 이렇습니다 — 4스레드에서 배치 1→8은 처리량 +1624%를 메모리 862MB로 사는 공짜 구간이고, 배치 8을 넘기는 순간부터는 처리량이 오히려 줄면서 메모리만 문서당 4~8MB씩 붙습니다. 1스레드에서는 공짜 구간 자체가 없습니다.
한계와 측정 환경
한계
- 작은 인코더 둘(6층·12층)만 쟀습니다. 층이 더 깊거나 폭이 넓은 모델에서는 문서 하나의 계산이 더 커서 꼭대기가 배치 8보다 더 왼쪽에 올 수도, 4스레드를 더 늘리면 오른쪽으로 밀릴 수도 있습니다. 8스레드 이상은 재지 않았습니다.
- 문서는 256편, 배치는 256까지입니다. 계획의 배치 512는 재지 않았습니다. 처리량이 이미 떨어지는 쪽이라 결론이 바뀔 것 같지는 않지만, 그 자리의 메모리는 확인하지 않았습니다.
- KorQuAD는 1스레드로 재지 않았습니다. 1스레드에서 배치 효과가 없다는 결론은 scifact 하나에서 나온 것입니다.
- 처리량은 같은 프로세스 안의 두 번과 별도의 두 스윕으로만 흔들림을 봤습니다. 실행 사이 배율이 많게는 0.11 흔들렸으므로, 배치 8과 32처럼 5% 안쪽으로 붙은 차이는 순서가 바뀔 수 있습니다. 결론은 그보다 큰 차이(배치 128·256의 손해, 1스레드의 무효과)에만 기댑니다.
- scifact에서 큰 배치가 느려지는 원인은 패딩이 아니라는 것까지만 확인했고, 무엇인지는 가르지 않았습니다.
- 절대 처리량(문서/초)은 이 4코어 Xeon의 값입니다. 결론으로 쓴 것은 같은 기계 안의 배율입니다.
측정 환경
| 항목 | 값 |
|---|---|
| OS | Linux 6.18.44 x86_64 (glibc 2.39) |
| CPU | Intel Xeon @ 2.10GHz, 4코어 (torch.set_num_threads 1 또는 4) |
| Python | 3.11.17 |
| 패키지 | torch 2.14.1, sentence-transformers 6.1.0, transformers 5.19.0, datasets 5.1.0, numpy 2.4.6 |
| 모델 | sentence-transformers/all-MiniLM-L6-v2 (리비전 1110a243), intfloat/multilingual-e5-small (리비전 614241f6) |
| 데이터 | BEIR scifact (b3b53356), KorQuAD squad_kor_v1 검증 분할 (01aad238), 각 256편 (시드 0) |
| 측정일 | 2026-10-08 |
| 실행 시간 | 3분 45초 (scifact 1스레드) · 1분 49초 (scifact 4스레드) · 4분 12초 (KorQuAD 4스레드) |
위 출력은 패키지를 새로 깐 빈 가상환경에서 돌린 것이고, 「두 번째 스윕」의 값은 그보다 먼저 다른 가상환경에서 같은 스크립트로 돌린 것입니다.
읽어주셔서 감사합니다. 😊

