나의 의료 AI가 스캔당 6.4초가 걸렸던 이유와 이를 3.1초로 단축한 방법
요약
의료 AI 플랫폼 ThoraxNet의 추론 속도를 6.4초에서 3.1초로 단축한 최적화 과정을 다룹니다. 모델 자체의 문제 대신 Monte Carlo Dropout을 구현할 때 발생한 순차적 순전파 오버헤드를 발견하고, 이를 배치 처리 방식으로 개선하여 성능을 높였습니다.
핵심 포인트
- 추측 대신 계측 도구를 사용하여 실제 병목 지점 파악
- Monte Carlo Dropout의 순차적 실행이 성능 저하의 원인임을 확인
- 단일 이미지 반복 대신 배치 크기를 키운 타일링 기법으로 최적화
- 하드웨어의 호출 오버헤드를 줄이는 것이 성능 개선의 핵심
저는 ThoraxNet이라는 흉부 X-ray 진단 플랫폼을 구축했습니다. 이 플랫폼은 단일 이미지에서 14가지 흉부 병변을 탐지하고, Monte Carlo Dropout을 사용하여 신뢰도를 보고하며, 각 예측을 유도한 영역 위에 GradCAM 히트맵을 그리고, LLM을 통해 구조화된 방사선 보고서를 작성합니다.
모델을 구축하는 것은 흥미로운 부분이었습니다. 하지만 이것이 이 서비스가 제품처럼 느껴질지 여부를 결정하는 부분은 아니었습니다. 그것은 하나의 매력적이지 않은 숫자, 즉 사용자가 "분석" 버튼을 누른 후 얼마나 오래 기다리는가에 달려 있었습니다.
이 포스트는 어떻게 그 대기 시간을 6.4초에서 3.1초로 줄였는지, 왜 솔직한 답변이 "10배가 아니라 약 2배"인지, 그리고 제가 정상 경로(happy path)를 신뢰하는 것을 멈추고 로그를 읽었기 때문에 발견할 수 있었던 두 가지 버그에 대해 다룹니다.
라이브 데모: https://thorax-tho.vercel.app
코드: https://github.com/Sowaiba-01/ThoraxNet
문제: 느린 요청에 감싸인 좋은 모델
모델은 잘 작동했습니다. 데모도 잘 작동했습니다. 하지만 모든 스캔에 약 6.5초가 소요되었고, 6초 동안 돌아가는 스피너(spinner)는 그 아래의 결과물이 아무리 좋아도 무엇이든 고장 난 것처럼 느껴지게 만듭니다.
저의 첫 번째 본능은 대부분의 사람들이 갖는 것과 같은 잘못된 본능이었습니다. "Vision Transformer의 순전파(forward pass)가 병목 지점임이 틀림없으니, 모델을 최적화하자." 저는 그런 직감을 쫓다가 성능 저하(regression)를 일으킨 경험이 충분히 많았기에, 측정하기 전까지는 그 직감이 아무런 가치가 없다는 것을 알고 있었습니다.
그래서 모델 코드를 한 줄도 바꾸기 전에, 저는 파이프라인에 계측 도구를 설치하여 각 단계가 실제로 얼마나 걸리는지 보고하도록 했고, 모든 응답에 그 세부 내역을 반환하도록 했습니다:
"stage_timings_ms": {
"preprocess": 16,
"mc_dropout": 2868,
...
결과는 제가 예상했던 것과 달랐으며, 이것이 바로 측정을 먼저 하는 것이 중요한 이유입니다. Transformer를 통한 단일 순전파(forward pass)는 약 15밀리초(ms)였습니다. 모델은 결코 문제가 아니었습니다. Monte Carlo Dropout이 문제였으며, 그것이 서버 측 예산의 거의 전부를 잡아먹고 있었습니다.
근본적인 원인: 20번의 순차적 순전파(forward passes)
Monte Carlo Dropout은 드롭아웃을 활성화한 상태로 모델을 여러 번 실행하여 불확실성을 추정하고, 그 예측값들이 얼마나 움직이는지를 측정합니다. 제가 처음 구현했던 방식은 당연하게도 다음과 같았습니다:
samples = []
for _ in range(n_samples): # n_samples = 20
logits = model(x) # 이미지 하나, 배치 크기 1
...
배치 크기가 하나인 순차적인 순전파를 20번 수행하는 것입니다. 매번의 실행마다 전체 호출 오버헤드(per call overhead)가 발생하며, 하드웨어는 계산을 하는 시간보다 실행(launch) 대기하는 시간을 대부분 보내게 됩니다.
해결책은 20번 따로 요청하는 것을 멈추고 한 번에 요청하는 것입니다. 단일 이미지를 배치 크기가 20인 배치로 타일링하여 하나의 순전파를 수행합니다:
tiled = x.repeat(n_samples, 1, 1, 1) # (20, 3, 224, 224)
logits = model(tiled) # 단 한 번의 순전파
probs = torch.sigmoid(logits).view(n_samples, batch, -1)
...
정확성 주장이 이해할 가치가 있는 부분이며, 좋은 면접관이 질문할 만한 정확한 문제입니다. 이 20개의 복사본들이 동일하지 않다고요? 아닙니다. 드롭아웃은 배치 내의 모든 요소에 대해 새로운 마스크를 샘플링합니다. 따라서 타일링된 20개의 복사본 각각은 독립적인 드롭아웃 마스크를 받게 되며, 이는 추정기가 필요로 하는 정확히 20개의 독립적인 확률적(stochastic) 샘플을 의미합니다. 통계는 동일하지만, 실행 횟수는 20번 대신 한 번입니다.
저는 그 논리를 믿음만으로 신뢰하고 싶지 않았기에, 이전의 순차적 버전과 새로운 배치 버전을 각각 400번씩 실행하여 평균값이 Monte Carlo 오차 범위 내에서 일치하는지 확인하는 테스트를 작성했습니다:
assert torch.allclose(batched_mean, sequential_mean, atol=0.05)
이 테스트가
| 지표 (Metric) | 이전 (v1.0.0) | 이후 (v1.1.0) | 개선 사항 |
|---|---|---|---|
| p50 지연 시간 (latency) | 6,387 ms | 3,127 ms | 1.9배 빠름 |
| ... |
모델 가중치(weights)나 정확도(accuracy)의 변화 없이 모든 백분위수(percentile)가 대략 2배씩 개선되었습니다. 이는 순수하게 모델이 실행되는 방식의 변화입니다.
두 가지 작은 변경 사항이 나머지 요청 경로(request path)를 정리했습니다. LLM 보고서 호출(LLM report call)을 임계 경로(critical path)에서 분리하여 워커 스레드(worker thread)로 이동시켰기 때문에, 사용자는 보고서 생성을 기다리지 않고도 병리 결과(pathology result)를 받을 수 있습니다. 그리고 매 요청마다 재계산되던 GradCAM 히트맵(heatmaps)은 이제 이미지와 클래스별로 캐싱(cached)됩니다.
솔직한 부분: 왜 10배가 아니라 2배인가
이 지점은 거짓말하기 쉬운 부분이며, 거짓말을 했다가는 결국 채용 제안(offer)을 놓치게 될 수도 있는 부분입니다.
GPU에서는 정확히 이와 같은 변경이 종종 10배에 가까운 성능 향상을 가져옵니다. 전체적인 이득은 20개의 작은 커널 실행(kernel launches) 사이에서 유휴 상태(idle)로 머물러 있던 가속기(accelerator)를 계속 사용하게 함으로써 얻어집니다.
ThoraxNet은 2개의 가상 코어를 가진 무료 티어 CPU 호스트에서 실행됩니다. 그곳에서는 회복할 수 있는 유휴 병렬성(idle parallelism)이 훨씬 적습니다. 따라서 동일한 코드 변경이 약 10배가 아닌 약 2배의 성능 향상을 제공합니다.
제목에 "10배 더 빠름"이라고 적을 수도 있었고, 대부분의 독자는 확인조차 하지 않았을 것입니다. 하지만 기술 면접(technical interview)에서 살아남는 숫자는 당신이 설명할 수 있는 숫자입니다. 솔직한 버전은 다음과 같습니다: CPU에서 2배. 왜냐하면 배치 처리(batching)를 통한 이득은 회복할 수 있는 유휴 병렬성의 양에 의해 제한되며, CPU에서의 몬테카를로 드롭아웃(Monte Carlo Dropout)이 여전히 지배적인 비용이기 때문입니다. 따라서 다음 단계의 실질적인 레버(lever)는 더 많은 배칭이 아니라 GPU 추론(inference) 또는 양자화(quantization)입니다. 이 문장은 제가 방어할 수 없는 더 큰 숫자보다 더 가치가 있습니다.
티켓이 아니라 전체 경로를 읽어서 찾아낸 두 가지 버그
프로파일링(Profiling)을 통해 HTTP 호출부터 응답까지의 전체 요청 경로(request path)를 읽어야만 했습니다. 그 과정에서 지연 시간(latency)과는 전혀 상관없는 두 가지 문제점이 드러났습니다.
GradCAM은 전혀 작동하지 않고 있었습니다. 라우트 핸들러(route handler)는 실제로는 어디에도 할당되지 않은 파이프라인의 속성을 읽고 있었습니다. 추론(inference) 코드는 히트맵(heatmap)을 로컬 변수에 생성한 뒤, 함수가 반환될 때 이를 버려버렸습니다. 모든 히트맵 요청은 404 오류로 돌아왔습니다. 어떤 에러 로그도 남지 않았고, 프론트엔드(frontend)는 단순히 빈 패널을 렌더링했을 뿐입니다. 따라서 어떤 코드 경로에서도 예외가 발생하지 않았기에, 이 상태로 배포되어 고장 난 채 방치되었습니다. 저는 이를 수정하고, 오버레이(overlay)가 기록되지 않으면 실패하는 회귀 테스트(regression test)를 통해 고정했습니다. 조용한 버그는 시끄러운 테스트를 불러오기 때문입니다.
모든 방사선 보고서가 조용히 실패하고 있었습니다. 배포 로그를 모니터링하던 중 다음과 같은 메시지가 반복되는 것을 발견했습니다.
The model `llama3-70b-8192` has been decommissioned and is no longer supported.
LLM 제공업체는 이미 몇 달 전에 해당 모델을 폐기했습니다. 모든 보고서 요청은 400 오류를 반환하며 템플릿으로 폴백(fallback)되고 있었습니다. 즉, 사용자는 생성된 보고서 대신 정해진 텍스트를 받고 있었지만, 폴백 덕분에 겉보기에는 문제가 없어 보여 아무도 눈치채지 못했습니다. 해결 방법은 현재 모델을 가리키도록 한 줄을 수정하는 것과, 다음 모델 폐기 시 코드 수정이 아닌 설정 변경만으로 대응할 수 있도록 환경 변수(environment variable)를 추가하는 것이었습니다.
이 두 가지 모두 제 작업 목록에는 없던 것들이었습니다. 두 문제 모두 제가 정상 경로(happy path)를 신뢰하는 것을 멈추고, 서버가 실제로 무엇을 하고 있는지 직접 읽어보았기에 발견할 수 있었습니다.
다음에 할 일
Monte Carlo Dropout은 여전히 약 2.8초를 차지하며 지배적입니다. 배치 처리(Batching)는 손쉬운 승리였습니다. 남은 레버(levers)는 요청 경로(request path)에서 영리하게 굴기보다는 하드웨어에 대해 정직해지는 것입니다.
20번의 패스(pass)가 발생하는 요청당 비용을 줄이기 위해 GPU를 사용하거나 양자화 추론(quantized inference)을 도입하는 것입니다. 내보내기(export) 및 평가(evaluation) 스크립트는 이미 작성되어 있으며, 정확도 차이(accuracy delta) 테이블을 만드는 것이 제가 아직 해야 할 숙제입니다.
GradCAM 세션 저장소를 위한 공유 캐시(shared cache)를 구축하는 것입니다. 현재는 프로세스 메모리(process memory)에 저장되어 있어, 재시작하거나 두 번째 복제본(replica)을 생성하면 유지되지 않기 때문입니다.
교훈
모델은 15밀리초(ms)였습니다. 제품은 6.4초였습니다. 전체적인 격차는 모델 자체가 아니라 모델이 호출되는 방식에 있었으며, 제가 아무것도 건드리기 전에 측정하고 티켓(ticket) 대신 로그(log)를 읽었기 때문에 이를 발견할 수 있었습니다.
화려하지 않은 작업이 진짜 작업입니다. 먼저 프로파일링(Profiling)을 하고, 테스트로 동등성을 증명하며, 10배라고 부풀리는 대신 2배라고 정직하게 말하는 습관은 누군가 실제로 당신의 코드를 읽을 때 빛을 발하는 습관입니다.
이 내용이 유용했다면, GitHub에서 저를 팔로우하여 이와 같은 더 많은 글을 확인하세요: https://github.com/Sowaiba-01
전체 프로젝트는 오픈 소스입니다. 포크(Fork)하고, 이를 기반으로 구축해 보세요. 만약 이 프로젝트가 도움이 되었거나 단순히 멋지다고 생각하신다면, 스타(star)를 눌러주는 것은 큰 힘이 됩니다: https://github.com/Sowaiba-01/ThoraxNet
ThoraxNet은 연구용으로만 사용되어야 합니다. 이는 FDA 승인을 받지 않았으며, 영상의학 전문의(radiologist)를 대체할 수 없습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기