~12 min · semaphore, serialization, gpu, concurrency, memory
Level 0툴 빌려 쓰는 사람
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"API 는 요청 백 개를 저글링할 수 있어. GPU 는 정확히 하나 돌릴 수 있고. 서로 다른 숫자니까 설계가 둘 다 존중해야 해."
자주 뭉뚱그려지는 두 가지 동시성
async 서버는 요청을 한꺼번에 받도록 지어져 있어. 그래야 계속 반응하니까. 함정은 '많이 받는다' 가 '많이 돌린다' 를 뜻해야 한다고 생각하는 거야. 웬만한 웹 작업에서는 맞는 말이야. GPU inference 에서는 절대 아니고. API 층은 얼마든지 동시에 굴러도 되지만 실제 GPU 작업은 엄격하게 한 번에 하나씩 돌아야 해. 이 둘을 뭉뚱그리는 게 잘 반응하던 서버가 부하 걸리면 죽는 서버로 바뀌는 길이야.
둘 동시에 돌리면 다시 OOM 인 이유
앞 lesson 을 떠올려 봐. diffusion inference 하나에는 정해진 peak 메모리 값이 있어. 둘을 동시에 돌리면 그 peak 이 두 배가 되고. 용량이 정해진 디바이스에서 peak 메모리가 두 배라는 건 방금 고친 out-of-memory 로 되돌아간다는 뜻이야. inference_mode 로 작업 하나의 메모리에 울타리를 쳐 놨는데, 작업 둘을 동시에 돌리면 그 울타리가 다시 풀리는 거지. GPU 쪽 동시성이 다른 데서 지킨 메모리 규율을 통째로 무효로 만들어.
받는 것과 돌리는 것을 갈라. 작업을 받는 일과 작업을 돌리는 일은 한계가 서로 다른 별개의 관심사야. 받는 건 넉넉하게 받아서 계속 반응하게 하고, 돌리는 건 자원의 진짜 용량 안에서만 돌려서 살아 있게 해. 그 사이에 놓인 큐가 양쪽이 각자의 한계를 지키게 해 주는 장치고.
semaphore 하나
장치는 작아. 한 번에 하나만 들여보내는 문 하나를 adapter 의 GPU 구간에 둘러 두는 거야. 작업 큐는 여전히 전부 받고 GPU 안 쓰는 일은 전부 동시에 돌려. 검증하고, 큐에 넣고, 응답 흘려보내고. 그런데 작업이 실제 inference 에 닿으면 그 문의 표를 하나 받아야 해. 두 번째 작업은 첫 번째가 표를 놓을 때까지 그 앞에서 기다리고. API 는 안 막혀. 막히는 건 GPU 구간뿐이야.
문은 요청 전체가 아니라 귀한 자원 둘레에 둬. 요청 처리 전체를 한 줄로 세우면 서버를 단일 스레드로 만들고 반응성을 죽인 거야. semaphore 는 GPU 를 쓰는 구간에만 둘러. 그 구간 앞뒤로는 전부 동시에 굴러가. 문은 그 문이 지키는 귀한 것만큼만 좁아야 해.
그냥 더 큰 머신을 사면 안 되나
메모리를 더 사서 동시에 더 많이 돌릴 수는 있어. 그런데 그건 천장을 옮기는 거지 없애는 게 아니야. 메모리가 아무리 많아도 어느 숫자부터는 넘치고, 문이 없으면 그 숫자를 어렵게, 그것도 프로덕션에서 알게 돼. semaphore 는 어떤 메모리 크기에서도 엔진을 옳게 만들어 줘. 디바이스가 얼마나 크든 담을 수 있는 것보다 많은 GPU 작업을 시도하지 않으니까. 옳음이 용량을 이기는 거야.
용량을 늘리는 건 버그를 가릴 뿐 안 고쳐. 울타리 없는 동시성 문제에 하드웨어를 들이붓는 건 크래시를 없애는 게 아니라 크래시가 나는 지점을 올리는 거야. 시스템에는 여전히 원칙 있는 한계가 없어. 그냥 더 늦게, 더 헷갈리게 망가질 뿐이지. 울타리는 설계에서 고치고, 남는 용량은 해법 대신이 아니라 여유분으로 즐겨.
피파의 고백
내 async 본능은 "느린 데를 병렬로 돌려!" 하고 소리치고 있었어. GPU 작업이 제일 느리니까 당연히 더 많이 동시에 돌려야지. 아빠가 짚어 줬어. 내가 병렬로 돌리려는 게 하필 메모리를 두 배 안 쓰고는 물리적으로 두 배로 못 돌리는 그거라고. 느린 부분이 느리면서 동시에 귀한 거였고, 그건 정반대 처방이 필요했어. 그때부터 '이거 느려?' 만 묻지 않고 '이거 귀해?' 도 묻게 됐어. 그리고 온몸이 펼치고 싶어 해도 귀한 건 한 줄로 세우는 법을 배웠고.
Code
문은 귀한 구간만 감싸·python
import asyncio
class LocalAdapter(Adapter):
def __init__(self):
# 표는 정확히 하나. GPU inference 는 한 번에 하나만 돌아.
self._gpu = asyncio.Semaphore(1)
async def run(self, request):
# 여기까지는 전부 동시성: 여러 요청이 같이 진행돼.
prepared = await self.prepare(request) # 동시에 돌아도 괜찮음
async with self._gpu: # 좁은 문
# GPU 쓰는 구간만 한 줄로. 작업 2 는 작업 1 이 표를 놓을 때까지
# 여기서 기다려 -> peak 디바이스 메모리가 두 배가 될 일이 없어.
result = await self._infer(prepared)
return await self.finalize(result) # 다시 동시성
네 시스템에서 느린 게 아니라 귀한 자원을 하나 찾아봐. GPU 하나든, 크기 N 짜리 connection pool 이든, 호출 한도가 있는 외부 서비스든. 지금 코드가 작업 받는 것과 작업 돌리는 걸 같은 수준의 동시성으로 다루고 있어? semaphore 문을 어디에 두면 둘이 갈라지는지 그려 보고, 그 문이 들고 있어야 할 숫자가 뭔지도 정해 봐.
Hint
문에 들어갈 숫자는 그 귀한 자원의 진짜 용량이야. GPU 하나짜리 메모리면 1, 연결 N 개짜리 pool 이면 N. 받는 쪽은 울타리 없이(또는 큐로만) 두고, 돌리는 쪽만 문으로 막아.
Progress
Progress is local-only — sign in to sync across devices.