ai technology

GPU는 놀고 있었다: 멈추지 않는 Video Retrieval 파이프라인 만들기

Junyoung Park · 2026-08-25 · 9 min

Video Retrieval 시스템을 만들면서 처음에는 embedding 모델이 가장 큰 고민이었다. 어떤 단위로 영상을 자를지, 한 장면에서 몇 프레임을 뽑을지, temporal 정보를 어떻게 보존할지 같은 문제들이다. 모델이 충분히 커지면 추론이 전체 속도를 결정할 거라고도 생각했다.

실제로 돌려보니 GPU는 억울한 상황이었다. 추론은 이미 끝났는데 다음 batch가 오지 않았다. CPU는 영상을 디코딩하고 있었고 GPU는 그 프레임을 기다리며 쉬었다. 모델을 더 빠르게 만들어도 별 소용이 없는 종류의 병목이었다.

CPU가 비디오 프레임을 하나씩 나르는 동안 GPU가 기다리는 디코딩 병목 화이트보드 그림
GPU가 느린 줄 알았는데 프레임 배달을 기다리고 있었다.

그 뒤로 scene split을 GPU decoding으로 옮겼고, 잘못된 영상 하나가 API 전체를 멈추지 않도록 decoder를 별도 process에 가뒀다. heartbeat가 끊기면 해당 worker를 종료하고 CPU decoding으로 이어서 처리했다. 처리 속도는 기존 6~7배속에서 15~20배속까지 올라갔다.

속도만 놓고 보면 GPU decoding이 주인공이다. 하지만 수십만 시간의 아카이브를 실제로 끝까지 처리하게 만든 것은 process isolation과 fallback이었다.

비디오 모델에는 시간축이 하나 더 있다

이미지 모델의 입력은 보통 B×C×H×WB \times C \times H \times W 형태다. 비디오 모델에는 temporal 축 TT가 추가된다.

B×C×T×H×WB \times C \times T \times H \times W

전처리 구현에 따라 B×T×C×H×WB \times T \times C \times H \times W로 먼저 batch를 만들고 모델 안에서 축을 바꾸기도 한다. 내가 만든 Video Indexer도 scene마다 8개 프레임을 뽑아 B×T×C×H×WB \times T \times C \times H \times W로 쌓은 뒤, 모델에 들어가기 직전에 B×C×T×H×WB \times C \times T \times H \times W로 permute한다.

문제는 이 TT를 채우는 일이다. 이미지 한 장은 파일을 한 번 읽으면 되지만 비디오는 먼저 어느 구간을 한 장면으로 볼지 정해야 한다. scene boundary를 찾고 각 scene에서 다시 8개 프레임을 골라야 한다.

초기 파이프라인은 꽤 정직했다.

video
  └─ scene split
       └─ split range마다 frame extraction
            └─ batch inference

scene split은 연속된 프레임을 HSV로 바꾼 뒤 Hue, Saturation, Value의 차이를 계산한다. 변화량이 threshold를 넘으면 새 scene으로 본다. 너무 짧은 scene은 막고, 너무 긴 scene은 강제로 자를 수 있게 min_scene_lenmax_scene_len도 두었다.

장면 경계를 미리 알 수 있다면 필요한 프레임만 읽으면 된다. 하지만 경계를 찾으려면 영상을 앞에서부터 봐야 한다. scene split에서 한 번 디코딩하고, 경계가 정해진 뒤 모델 입력을 만들려고 다시 프레임을 읽게 된다. 알고리즘의 순서 자체가 중복 디코딩과 random seek를 만들고 있었다.

모델을 고치기 전에 feeding을 고쳤다

프로파일링했을 때 GPU utilization은 기대보다 낮았다. GPU memory에도 여유가 있었고 batch inference 자체도 충분히 빨랐다. 문제는 batch 사이의 빈 시간이었다. CPU decoder가 다음 프레임을 준비할 때까지 GPU가 기다렸다.

여기서 모델 최적화를 더 하는 것은 방향이 달랐다. 주방이 빠른데 식재료가 안 들어오는 상황에서 칼을 더 좋은 것으로 바꾸는 셈이다.

DecordVideoReader에 CPU와 GPU context를 지원하고 get_batch로 여러 프레임을 한 번에 가져올 수 있다. 다만 PyPI에서 받는 package는 CPU build이므로 NVDEC를 쓰려면 CUDA 옵션을 켜고 source build가 필요했다. 사용하던 FFmpeg 6.x와 CUDA 환경에 맞게 일부 source compatibility도 직접 수정했다.

GPU worker 안에는 두 개의 VideoReader를 유지했다. 하나는 프레임을 순서대로 보면서 HSV 차이를 계산하고, 다른 하나는 scene range가 정해지는 즉시 모델 입력용 8프레임을 get_batch로 가져온다. scene의 시작과 끝을 8개 구간으로 나누고 각 구간의 midpoint를 고르는 방식이라 scene 길이가 달라도 temporal dimension은 일정하다.

def linspace_midpoints(start, end, n=8):
    bins = np.linspace(start, end + 1, n + 1, dtype=int)
    return [
        (int(bins[i]) + int(bins[i + 1]) - 1) // 2
        for i in range(n)
    ]

scene이 하나 생길 때마다 모델을 호출하지는 않았다. 여러 scene을 chunk로 모은 뒤 한 번에 tensor로 쌓아 inference로 넘겼다. decoder를 scene마다 새로 열지 않고 한 video를 처리하는 동안 worker를 유지했기 때문에 초기화와 seek 비용도 줄었다.

이 변경 뒤 프레임 공급 속도는 CPU decoding 대비 대략 2~3배 빨라졌다. end-to-end 분석 속도는 6~7배속에서 15~20배속 수준으로 올라갔다. 단순히 decoder 하나만 교체해서 3배가 된 것은 아니다. 중복해서 열던 decoding 경로를 합치고 scene batch가 만들어지는 즉시 inference로 넘긴 효과가 함께 들어간 결과다.

코덱을 맞췄는데 왜 멈출까

속도가 올라가자 그전에는 눈에 잘 띄지 않던 문제가 보이기 시작했다.

입력에는 유튜브 영상 같은 디지털 콘텐츠도 있었고 오래전에 방송된 저화질 영상도 있었다. 최근 제작된 고해상도 방송 영상도 섞여 있었다. 모두 아카이빙 과정에서 기본적인 transcoding을 거쳤다. codec이 같고 resolution도 어느 정도 정리되어 있으니 decoder 역시 비슷하게 동작할 거라고 생각했다.

그 믿음은 오래가지 않았다.

어떤 영상은 정상적으로 분석되다가 특정 frame에서 Decord가 멈췄다. exception이 발생하지도 않았고 process가 죽지도 않았다. 호출이 돌아오지 않았다. codec, resolution, 제작 시기 중 어느 하나로 묶이는 규칙도 찾지 못했다.

같은 H.264라고 해도 bitstream까지 같은 것은 아니다. profile과 level, GOP 구조, timestamp, reference frame, 손상된 packet 등 decoder가 만나는 조건은 훨씬 많다. 파일 앞부분이 정상이라는 사실도 후반부의 모든 frame을 보증하지 않는다.

API 관점에서 exception보다 침묵이 더 무서웠다. try/except는 함수가 제어권을 돌려줘야 동작한다. native decoder 안에서 멈춘 호출은 Python 쪽으로 exception을 보내지 않는다. thread로 분리해본 적도 있지만 멈춘 native call만 안전하게 없앨 방법이 없었고, 작업이 쌓이면서 zombie가 늘어났다. 아주 보고 싶지 않은 종류의 collection이었다.

멈춘 native decoder를 try except로 처리할 때와 process terminate로 격리했을 때를 비교한 수채화풍 2컷 밈
exception은 잡을 수 있다. 돌아오지 않는 호출은 잡을 기회부터 없다.

죽일 수 있는 경계를 만든다

결국 decoder를 별도 process로 옮겼다. multiprocessing을 쓴 이유는 동시 처리량을 늘리기 위해서가 아니었다. 멈춘 decoder만 확실하게 종료할 수 있는 경계가 필요했다.

CUDA가 포함된 subprocess는 spawn context로 시작했다. PyTorch 문서도 CUDA runtime을 fork한 child process에서 다시 사용할 때 생기는 poison fork 문제를 피하려면 spawn이나 forkserver를 사용하라고 안내한다. 새 interpreter와 CUDA context를 만드는 비용은 들지만 부모 process의 runtime 상태를 어설프게 복제하는 것보다 안전했다.

메인 process와 GPU worker는 command queue와 response queue로 통신한다. worker는 처음에 FPS와 frame count를 보내고, scan 중에는 현재 frame을 heartbeat로 알린다. scene boundary가 생기면 range를 보내며 get_batch 요청이 끝나면 완료 메시지를 돌려준다.

GPU worker를 watchdog이 감시하고 문제가 생기면 CPU fallback으로 우회하는 화이트보드 구조도
decoder가 멈춰도 파이프라인 전체가 같이 멈출 필요는 없다.

worker가 살아 있는지만 확인해서는 부족하다. 이번 문제는 process가 살아 있는 상태로 멈추는 hanging이었기 때문이다. 메인 process는 마지막 heartbeat 시각을 기록하고 scan_watchdog_sec보다 오래 소식이 없으면 worker를 종료한다. frame batch를 가져오는 get_batch에도 별도의 timeout을 걸었다.

모델 inference 중에는 watchdog을 잠시 멈춘다. decoder가 아니라 모델이 GPU를 사용하는 시간을 hanging으로 오해하지 않게 하려는 조치다. timeout을 넣을 때는 몇 초를 기다릴지만큼이나 지금 누구의 시간을 재고 있는지가 중요했다.

공유 메모리는 zero-copy가 아니다

process를 나누면 큰 frame tensor를 어떻게 전달할지가 남는다. Queue에 NumPy array를 그대로 넣으면 serialization과 copy 비용이 생긴다. scene마다 8프레임, 여러 scene을 한 chunk로 묶는 구조에서는 무시하기 어려운 크기다.

여기에는 Python의 multiprocessing.shared_memory를 사용했다. 이 module은 Python 3.8부터 표준 library에 포함되었고, 서로 다른 process가 같은 CPU memory block에 붙을 수 있게 한다. worker가 정해진 shape의 shared memory에 frame batch를 쓰면 메인 process는 같은 buffer를 NumPy view로 열어 CUDA tensor로 복사한다.

이 부분은 표현을 조심할 필요가 있다. 현재 구조는 GPU memory에서 embedding model까지 곧바로 이어지는 zero-copy가 아니다.

Decord GPU decode
  → CPU shared memory
  → main process의 CUDA tensor
  → embedding model

GPU에서 CPU로 내려왔다가 다시 GPU로 올라간다. 그럼에도 Queue serialization보다 비용을 줄일 수 있었고 process isolation도 유지할 수 있었다. 이번 단계의 우선순위는 가장 순수한 memory path가 아니라 멈춘 decoder를 회수하면서 충분한 처리 속도를 얻는 것이었다.

CUDA IPC나 DLPack을 이용해 device memory 전달을 더 줄일 여지는 남아 있다. 다만 copy 하나를 없애는 동안 failure ownership과 memory lifetime이 다시 복잡해질 수 있다. 운영 환경에서는 가장 짧은 data path가 언제나 가장 싼 구조는 아니었다.

GPU가 멈추면 다음 frame부터 CPU로 간다

watchdog이 timeout을 감지하면 메인 process는 GPU worker를 terminate하고 join한다. 여기서 영상을 처음부터 다시 처리하면 이미 만든 embedding과 시간이 모두 버려진다. 그래서 마지막으로 정상 처리한 scene의 끝 frame을 계속 기록했다.

GPU 경로에 문제가 생기면 OpenCV가 last_end_idx + 1부터 scene scan을 이어받는다. CPU에서 frame을 디코딩한 뒤 HSV 계산과 embedding inference에 필요한 tensor는 다시 GPU로 보낸다. 이름은 CPU fallback이지만 모든 연산을 CPU로 내리는 것은 아니다. decoder만 더 보수적인 구현으로 바꾸는 경로에 가깝다.

0 ───────────── last successful scene │ remaining frames ───────── end
             GPU decode                │      CPU decode

대부분의 영상은 빠른 GPU path로 끝난다. 이상한 영상만 느린 경로의 비용을 지불한다. 아카이브 전체를 가장 느린 decoder에 맞추지 않아도 되고, 한 입력 때문에 운영자가 container를 찾아 재실행할 필요도 없다.

이 fallback을 붙이면서 availability에 대한 생각도 조금 바뀌었다. 실패하지 않는 component를 찾는 것보다, 실패한 component가 어디까지 영향을 미치게 할지를 정하는 편이 현실적이었다.

API 앞단에서도 빠르게 걸러낸다

모든 문제를 runtime fallback에 맡기지는 않았다. API는 분석을 시작하기 전에 ffprobe로 video stream과 codec을 확인한다. 이어서 FFmpeg로 앞부분 10초를 실제 decoding하며 흔한 NAL unit 오류와 빈 frame을 검사한다.

이 검사는 값싼 filter다. 영상 후반부의 손상이나 특정 frame에서만 발생하는 hanging까지 찾아주지는 못한다. 그래서 integrity check가 통과해도 worker watchdog은 그대로 필요하다.

실패 사유도 한 종류로 뭉개지 않았다. download 실패, empty upload, integrity 실패, decoder 초기화 실패, scene 미생성, model 처리 오류를 별도의 error code로 반환한다. 수십만 시간의 batch에서 failed 한 단어는 별 도움이 되지 않는다. 어느 단계에서 얼마나 자주 실패하는지가 다음 개선 작업을 정한다.

API 동시성은 semaphore로 제한했다. request를 많이 받는다고 처리량이 그대로 늘지는 않는다. 각 request가 CUDA context와 decoder와 GPU memory를 점유하면 어느 순간부터는 context switching과 memory pressure만 늘어난다. 빠른 단일 함수와 안정적인 service throughput은 다른 문제였다.

20배속보다 중요했던 것

기존 6~7배속 파이프라인과 GPU decoding 기반 15~20배속 파이프라인을 비교한 화이트보드 그림
빠른 차선 옆에도 fallback pit은 남겨두었다.

최종 구조에서 영상 분석 속도는 평균적으로 약 20배속에 가까웠다. 입력 영상, resolution, scene 전환 빈도와 batch size에 따라 15~20배속 사이를 오갔다. 이 수치는 영상 재생 시간 대비 end-to-end 처리 시간으로 본 내부 측정값이다.

성능 수치를 다시 측정한다면 전체 재생 시간만 적어서는 부족하다. codec과 resolution 분포, FPS, scene 수, batch size, GPU와 CPU 사양을 함께 남겨야 한다. fallback이 발생한 영상 수와 전체 frame 중 CPU로 처리한 비율도 필요하다. 그래야 20배속이라는 숫자가 가장 잘 나온 한 편의 기록이 아니라 다음 시스템과 비교할 수 있는 기준이 된다.

그래도 이 작업에서 가장 만족스러웠던 장면은 benchmark 숫자가 아니었다. 한 worker의 heartbeat가 끊기고, watchdog이 해당 process를 닫고, 잠시 뒤 CPU path에서 다음 scene이 계속 나오는 로그였다.

Video Retrieval에서는 모델의 정확도와 embedding 품질이 물론 중요하다. 수십만 시간의 영상을 다룰 때는 한 가지 질문이 더 붙는다.

이상한 영상 하나를 만났을 때도 전체 작업이 내일 아침까지 계속 돌고 있는가.

GPU decoding이 속도를 만들었다면 process isolation과 fallback은 그 속도를 운영 가능한 처리량으로 바꿨다. 꽤 돌아가는 길처럼 보였지만, 전체 migration을 끝내는 데에는 그 길이 더 빨랐다.