문제: 워커마다 웨이트를 통째로 복제한다
추론 서버를 스케일 아웃할 때 흔히 프로세스를 여러 개 띄운다. gunicorn/uvicorn 워커 4개를 올리면 각 워커가 모델을 load_state_dict로 독립 로드하므로, 7B 모델(약 14GB, fp16) 기준 CPU/GPU 메모리가 4배로 늘어난다. 실제로는 완전히 동일한 읽기 전용 텐서를 4벌 들고 있는 셈이다. 배치 크기를 키우지도 못하고 메모리만 잡아먹는 이 중복이 OOM과 낮은 GPU 활용률의 주범이다.
공유는 세 층위로 나눠 접근한다
웨이트 공유는 하나의 기법이 아니라 저장 위치별 전략이다. CPU 상주 웨이트는 파일 매핑, GPU 상주 웨이트는 IPC 핸들, 파인튜닝 변형은 어댑터 분리로 접근한다. 층위를 섞어 쓰면 대부분의 서빙 구성을 커버할 수 있다.
| 층위 | 기법 | 공유 단위 | 주 절감 대상 |
|---|---|---|---|
| CPU | mmap + COW | 페이지 | RSS 메모리 |
| GPU | CUDA IPC | 디바이스 버퍼 | VRAM |
| 변형 | LoRA 어댑터 | 베이스+델타 | VRAM(다중 모델) |
CPU 웨이트는 mmap으로 공유한다
safetensors는 파일을 mmap으로 열어 페이지 캐시를 여러 프로세스가 공유하게 한다. 프리포크(fork) 모델에서 부모가 먼저 로드한 뒤 워커를 fork하면, 리눅스의 copy-on-write 덕분에 읽기 전용 웨이트 페이지는 물리 메모리 1벌만 유지된다.
import os
from safetensors.torch import load_file
# 부모 프로세스에서 먼저 로드 (mmap 기반)
WEIGHTS = load_file("model.safetensors", device="cpu")
def worker(idx):
pid = os.fork()
if pid == 0: # 자식: WEIGHTS 페이지를 COW로 공유
run_inference(WEIGHTS)
os._exit(0)
for i in range(4):
worker(i)
핵심은 자식에서 웨이트를 수정하지 않는 것이다. in-place 연산이 페이지를 건드리면 그 페이지만 복제되어 절감 효과가 깨진다.
GPU 웨이트는 CUDA IPC로 공유한다
VRAM은 fork COW가 통하지 않는다. 대신 한 프로세스가 웨이트를 올린 뒤 CUDA IPC 핸들을 다른 프로세스에 전달하면 같은 디바이스 버퍼를 참조할 수 있다. PyTorch는 이를 감싼 공유 스토리지를 제공한다.
import torch, torch.multiprocessing as mp
def producer(q):
w = torch.load("w.pt", map_location="cuda:0")
for name, t in w.items():
t.share_memory_() # IPC 핸들로 노출
q.put(w) # 텐서 메타데이터만 직렬화 전송
torch.cuda.synchronize()
input() # 소유자는 살아 있어야 함
def consumer(q):
w = q.get() # 동일 VRAM 버퍼 매핑
logits = forward(w, batch)
if __name__ == "__main__":
mp.set_start_method("spawn")
q = mp.Queue()
mp.Process(target=producer, args=(q,)).start()
mp.Process(target=consumer, args=(q,)).start()
vLLM이나 TGI를 쓴다면 이 계층은 프레임워크가 처리한다. 텐서 병렬(TP)은 웨이트를 분할해 GPU마다 다른 조각을 두고, 하나의 엔진 프로세스가 연속 배칭으로 요청을 모아 처리하므로 프로세스별 복제 문제 자체가 사라진다. 프로세스 다중화보다 이 방식이 대개 더 낫다.
변형 모델은 어댑터만 분리한다
고객사별·태스크별로 파인튜닝한 모델을 여러 개 서빙해야 한다면, 각 풀 모델을 올리는 대신 베이스 웨이트 1벌을 공유하고 LoRA 델타만 교체한다. 델타는 수십 MB 수준이라 수십 개 어댑터를 한 GPU에서 핫스왑할 수 있다.
from peft import PeftModel
base = load_base("Qwen2.5-7B") # VRAM에 1벌만 상주
adapters = {"cs": "lora/cs", "sales": "lora/sales"}
model = PeftModel.from_pretrained(base, adapters["cs"], adapter_name="cs")
model.load_adapter(adapters["sales"], adapter_name="sales")
def infer(tenant, x):
model.set_adapter(tenant) # 델타만 전환, 베이스 재사용
return model(x)
주의점: 수명주기와 동시성
공유에는 대가가 따른다. IPC로 노출한 버퍼는 생산자 프로세스가 살아 있는 동안만 유효하다. 소유자가 죽으면 소비자는 잘못된 메모리를 참조하므로, 소유자를 감독 프로세스로 고정하고 헬스체크로 보호해야 한다. 또한 공유 웨이트는 읽기 전용 계약이 전제다. 여러 워커가 같은 텐서에 접근할 때 KV 캐시나 중간 버퍼는 반드시 프로세스별로 분리하고, 웨이트 자체에는 in-place 연산을 넣지 않는다. NUMA 환경에서는 mmap 공유 페이지가 원격 노드에 놓이면 지연이 늘 수 있으니 numactl로 로컬 배치를 확인한다.
정리
메모리 절감의 출발점은 "왜 같은 웨이트를 N벌 들고 있는가"를 묻는 것이다. CPU는 mmap+COW, GPU는 IPC 또는 텐서 병렬 엔진, 다중 변형은 어댑터 분리로 각각 대응하면 동일 하드웨어에서 더 큰 배치와 더 많은 모델을 수용할 수 있다. 대부분의 경우 프로세스를 여러 개 복제하기보다, 웨이트를 공유하는 단일 엔진에 연속 배칭을 맡기는 편이 운영과 효율 양쪽에서 유리하다.