vLLM의 분리형 서빙 환경에서 특정 조건에서만 발생하던 메모리 증가 현상을 힙 밖(RSS/익명 mmap)까지 추적해 UCX의 mmap 훅이 원인이었음을 밝혀내고, 환경 변수로 우회 및 업스트림 수정으로 이어진 조사 기록.
Engineering
2026년 1월 21일 Mathis Felardos
몇 달 전, 우리 팀은 vLLM에서 의심되는 메모리 누수를 조사했습니다. 처음에는 코드베이스의 상위 레이어 어딘가에 국한된, 쉽게 찾을 수 있는 문제라고 생각했습니다. 하지만 파고들수록 점점 더 복잡해졌습니다. 이 글은 Mistral AI에서 기술 조사와 해결책을 어떻게 만들어 가는지 공유하는 새로운 ‘Engineering Deep Dive’ 시리즈의 첫 번째 이야기입니다.
문제는 우리 프론티어 모델 중 하나로 분리형(disaggregated) 서빙을 사전 프로덕션 테스트하던 중 처음 나타났습니다. 메모리 사용량이 꾸준히 증가했지만, 특정 조건에서만 그랬습니다. 즉 vLLM을 사용하고, Mistral Medium 3.1 모델이며, 그래프 컴파일이 활성화되어 있을 때만 발생했습니다. 크래시나 에러는 없었고, 다만 프로덕션 유사 트래픽에서 시스템 메모리가 분당 400MB씩 느리게 선형 증가했습니다. 몇 시간이 지나면 결국 “out of memory” 상태로 이어졌습니다.
이후 우리는 고수준 Python 도구부터 시작해 커널 레벨 트레이싱까지 단계적으로 내려가며 추적했고, 마침내 진짜 원인을 찾아냈습니다. 아래는 그 과정을 정리한 것으로, 오늘날 소프트웨어에서 의존성 레이어가 숨기고 있는 위험이 무엇인지도 함께 보여줍니다.

Grafana에서 보고 싶지 않은 종류의 우상향 그래프
처음에는 표준적인 트러블슈팅 경로를 따랐습니다. 더 작은 모델에서, 프로덕션 최적화를 덜 켠 상태로 문제를 재현해 누수의 근원을 격리하려 했습니다. 하지만 다양한 설정과 모델을 시도해도 다른 구성에서는 재현되지 않았습니다. 오류는 NIXL을 사용하는 Prefill/Decode 분리형 구성에서만 나타났습니다.
Prefill/Decode(P/D) 분리가 본 이야기의 핵심이므로, 이 추론(inference) 설정이 어떻게 동작하는지 상위 수준 메커니즘을 간단히 설명하겠습니다. P/D 분리형은 하나의 쿼리 처리를 서로 다른 인스턴스가 담당하는 두 단계로 나눕니다.
max_tokens=1 로 설정하고 KV Transfer 메타데이터는 빈 집합으로 설정). 이 단계에서 요청의 KVCache를 계산합니다.누수는 이 분리형 구성의 디코드 측에서만 관찰되었고, 이는 NIXL을 통한 KV Cache 전송이 누수의 근본 원인일 가능성을 강하게 시사했습니다. 우리 구성에서 NIXL은 **UCX(Unified Communication X)**에 의존합니다. UCX는 분산 시스템에서 데이터 교환을 위해 설계된 고성능 통신 라이브러리입니다. UCX는 Infiniband를 포함한 다양한 기술 위에서 최적화된 데이터 전송을 가능하게 합니다. Infiniband는 HPC 및 데이터센터에서 흔히 쓰이는 저지연·고대역폭 인터커넥트 기술입니다.
P/D 분리형 서빙 배포 개요.
이후 조사에서는 이 셋업을 유지한 채, Python 메모리 프로파일링 도구로 누수 원인을 특정하려 했습니다.
Memray와 Guppy 3를 시도했지만, 둘 다 누수를 보여주지 않았고 관찰 가능한 범위에서는 모든 것이 정상처럼 보였습니다. GDB를 사용하려 하자 전체 프로세스가 크래시가 났습니다. 또한 vLLM 셋업은 Valgrind 같은 도구로 돌리기엔 너무 무거워서, 실사용이 불가능할 정도로 느려지거나 아예 사용이 어려웠습니다.
더 강력한 도구가 필요하다는 것은 분명했지만, 그 전에 이 누수가 다른 사람에게도 재현되는지 확인하고 싶었습니다. 그래서 vLLM 팀에 GitHub 이슈를 열어 논의했고, 우리만 겪는 문제가 아니라는 점을 확인하며 심층 조사가 필요하다는 결론에 도달했습니다.
무슨 일이 벌어지고 있는지 더 잘 추적하기 위해 Heaptrack을 사용했습니다. Heaptrack은 malloc 또는 free 같은 메모리 연산을 오버라이드하고, 스택 트레이스와 함께 이벤트를 기록하는 메모리 프로파일러입니다.
Heaptrack의 작성자인 Milian Wolf는 도구 시작에 도움이 되는 훌륭한 입문 블로그 글을 써 두었습니다. 과정은 2단계입니다. 먼저 트레이싱을 켠 상태로 프로그램을 실행한 다음, 덤프를 해석합니다.
vLLM의 워커 프로세스에 국한된 할당을 추적하기 위해, vLLM 실행 시 LD_PRELOAD 를 libheaptrack_preload.so 로 설정했습니다. 이렇게 하면 이 라이브러리가 다른 어떤 것보다 먼저 로드되어 메모리 할당 함수 동작을 오버라이드하고, 덤프 데이터를 제공합니다.
그 다음 heaptrack_interpret 로 이 데이터를 시각화할 수 있었습니다.
bash$ git clone https://github.com/KDE/heaptrack.git $ cd heaptrack && mkdir build && cd build && cmake .. && sudo make install # Setting LD_PRELOAD=/path/to/libheaptrack_preload.so creates a temporary file named heaptrack.<pid>, here the pid is 2028233 $ /usr/local/lib/heaptrack/libexec/heaptrack_interpret < heaptrack.2028233 | gzip > heaptrack.vllm.2028233.gz
Heaptrack은 함수 레벨까지 내려가는 상세하고 인터랙티브한 힙 할당 그래프를 제공합니다. 모든 malloc/free 를 추적할 수 있고, 메모리 사용량을 명확히 분해해 보여줍니다.

Heaptrack으로 본 vLLM 워커의 메모리 사용량
이 시점에서 이런 의문이 들 수 있습니다. “메모리 누수는 어디 있지?” 실제로 눈에 띄는 메모리 증가는 NIXL의 지연 초기화(lazy initialization) 때문뿐이었습니다.
이 셋업에서 실제로 누수가 발생하는지 검증하기 위해, vLLM 벤치마크를 돌리고 heaptrack_interpret 로 두 개의 스냅샷(처음과 거의 끝)을 만들었습니다. 힙 메모리는 안정적이었지만, 다음 섹션에서 다룰 피크 상주 메모리(RSS) 는 두 스냅샷 사이에 차이가 있었습니다. 이 차이는 Heaptrack의 요약 탭에서 확인할 수 있었습니다.

Heaptrack에서 관찰된 피크 RSS 차이: 벤치마크 전(1)과 후(2)
즉 누수는 힙 밖에서 발생하고 있었고, Heaptrack이 분석하는 메모리 범주에 속하지 않았습니다. 힙 밖의 할당을 추적할 수 있는 다른 도구로 바꿔야 했습니다.
왜 Heaptrack이 누수를 감지하지 못했는지 이해하려면, RSS(Resident Set Size) 가 실제로 무엇을 포함하는지부터 정리해야 합니다. RSS는 프로세스 메모리 중 RAM에 올라와 있는 부분을 뜻하며, 단지 힙만 포함하는 것이 아닙니다. 구체적으로 RSS에는 다음이 포함됩니다.
sbrk/brk 시스템 콜로 관리되며, 이는 프로그램 브레이크 주소(힙 세그먼트 끝을 가리키는 포인터)를 조정/설정합니다.mmap 시스템 콜로 직접 할당되는 메모리 영역입니다. 커스텀 할당자나 malloc 이 큰 블록을 할당할 때 사용되기도 합니다. 익명 매핑의 주소는 보통 힙 주소 공간과 스택 주소 공간 사이, 이른바 메모리 매핑 세그먼트에 위치합니다.malloc 은 작은 할당에 sbrk 를 사용할 수도 있지만, 현대 구현에서는 더 유연하고(또한 설정에 따라 2MB/1GB의 huge page 할당 가능) 익명 mmap 을 선호하는 경우가 많습니다.
Heaptrack은 glibc의 malloc/free 만 훅킹합니다. 즉 malloc 이 직접 할당한 전통적 힙과 익명 매핑은 추적할 수 있지만, glibc 제어 밖에서 직접 mmap 하거나 다른 시스템 레벨 메커니즘으로 할당된 메모리는 놓칩니다.
다행히도 여기서 끝은 아닙니다. Linux의 /proc 파일시스템은 커널 API 역할을 하는 특수 폴더로, 실행 중인 프로세스와 상호작용할 수 있는 가상 인터페이스를 제공하며 다음과 같은 프로세스 세부 정보를 실시간으로 제공합니다.
/proc/<pid>/fd: 프로세스가 열고 있는 파일 디스크립터 목록/proc/<pid>/maps: 힙, 스택, 공유 라이브러리, 익명 매핑 등을 포함한 메모리 영역 맵/proc/<pid>/ 에서 ls 하면 사용 가능한 항목 확인 가능)조사를 이어가기 위해, /proc/<pid>/maps 를 읽어 사람이 보기 쉬운 형태로 보여주는 pmap 명령을 사용했습니다. 시간에 따른 메모리 영역 변화를 추적하는 것이 목표였으므로 다음을 실행했습니다.
bash$ watch -n 1 "pmap -X $pid | (head -n 2 && tail -n +3 | sort -k7 -nr)"
이 명령은 지정 PID에 대해 매초 pmap 을 실행해 확장 메모리 정보를 표시하고, 헤더를 건너뛴 뒤, 메모리 크기 기준으로 정렬해 가장 큰 영역에 집중할 수 있게 합니다.
이 명령으로 흥미로운 패턴을 관찰했습니다. 일부 익명 메모리 매핑만 시간이 지날수록 커졌고, 그 시작 주소가 계속 바뀌었습니다. 시간이 흐르며 할당 크기는 매우 커졌지만 대부분의 다른 매핑은 변하지 않았습니다.
우리 pmap 명령의 출력에서 RSS 크기 기준으로 정렬된 메모리 페이지 목록은 수상한 할당을 쉽게 드러냈습니다. 주황색 점은 예시를 강조합니다.
이 동작은 mremap 의 전형적인 특징입니다. mremap 은 기존 메모리 영역을 free 없이 크기 변경 또는 재배치하는 시스템 콜입니다. 힙 내부에서 동작하며 glibc 메모리 관리에 의존하는 realloc 과 달리, mremap 은 더 낮은 레벨에서 동작하며 커스텀 할당자/라이브러리/수동 메모리 관리 코드가 메모리 레이아웃을 동적으로 조정하는 데 사용됩니다.
또 다른 가능성은 munmap 후 mmap 을 반복하는 사이클입니다. 메모리를 해제하고 다시 할당하지만, 단편화(fragmentation), 커스텀 할당자 내 누수, 잘못된 리사이징 로직 등의 이유로 총 사용량이 계속 증가할 수 있습니다. 우리 경우에는 주소가 바뀌며 크기가 커지는 현상이 강하게 나타났고, 이는 메모리가 재할당되지만 제대로 해제되지 않는다는 신호였습니다.
이것이 누수가 힙이 아니라, 적절한 해제 없이 리사이즈되는 익명 메모리 영역에서 일어난다는 첫 번째 확실한 단서였습니다.
조사는 원시 mmap 또는 mremap 호출로 범위를 좁혔지만, 어느 쪽이 원인인지 확인해야 했습니다. 첫 시도로 Heaptrack이 하지 못한 일을 하려고 LD_PRELOAD 와 함께, 모든 mmap/mremap 호출을 로깅하는 작은 C 라이브러리를 사용해 인터셉트하려 했습니다. 하지만 이 접근에는 한계가 있었습니다. 모든 mmap/mremap 이 glibc를 거치는 것은 아닙니다. 커스텀 훅이 일부 할당을 보긴 했지만, pmap 출력에서 누수로 보이던 주소들과 일치하지 않았습니다. LD_PRELOAD 훅에 잡히지 않은 채 누수 영역은 계속 커졌습니다. 이는 해당 할당이 syscall을 직접 호출하거나, 다른 훅킹 메커니즘이 개입했을 가능성을 시사했습니다.
전체 그림을 얻기 위해 BPFtrace 를 사용했습니다. BPFtrace는 시스템 콜과 커널 이벤트를 실시간으로 트레이싱하는 도구로, Linux 커널의 eBPF 가상 머신을 기반으로 합니다. eBPF는 트레이스포인트나 프로브에 붙는 가볍고 사전 검증된 바이트코드를 실행해 안전하고 오버헤드가 낮은 분석을 가능하게 합니다. BPFtrace는 커널 안정성을 해치지 않으면서도, 비인가 접근이나 리소스 남용 같은 이상 행위를 감지하는 일부 Kubernetes 도구에서도 사용됩니다.
strace 도 고려했지만, PTRACE 기반이라 이 단계에서 이슈를 분석하기엔 너무 느렸습니다. 대신 glibc를 거치지 않는 호출까지 포함해 모든 mmap 과 mremap 호출의 인자와 스택 트레이스를 로깅하는 BPFtrace 스크립트를 작성했습니다. 다음은 Le Chat의 도움을 받아 작성한 스크립트입니다.
bashtracepoint:syscalls:sys_enter_mmap /pid == (uint64)$1/ { printf("Stack trace:\n%s\n", ustack(perf)); printf("PID/TID: %d %d | ", pid, tid); printf("ENTER mmap(addr=%p, len=%d, prot=%d, flags=%d, fd=%d, off=%d)\n", args->addr, args->len, args->prot, args->flags, args->fd, args->off); } tracepoint:syscalls:sys_exit_mmap /pid == (uint64)$1/ { printf("PID/TID: %d %d | ", pid, tid); printf("EXIT mmap: ret=%p\n", args->ret); } tracepoint:syscalls:sys_enter_munmap /pid == (uint64)$1/ { printf("PID/TID: %d %d | ", pid, tid); printf("ENTER munmap(addr=%p, len=%d)\n", args->addr, args->len); } tracepoint:syscalls:sys_exit_munmap /pid == (uint64)$1/ { printf("PID/TID: %d %d | ", pid, tid); printf("EXIT munmap: ret=%d\n", args->ret); } tracepoint:syscalls:sys_enter_mremap /pid == (uint64)$1/ { printf("PID/TID: %d %d | mremap", pid, tid); printf("old_addr=%p, old_len=%d, new_len=%d, flags=%d, new_addr=%p)\n", args->addr, args->old_len, args->new_len, args->flags, args->new_addr); } tracepoint:syscalls:sys_exit_mremap /pid == (uint64)$1/ { printf("PID/TID: %d %d | ", pid, tid); printf("EXIT mremap: ret=%p\n", args->ret); }
pmap 기준으로 누수가 발생하던 vLLM 워커 프로세스 PID(아래의 $pid)를 넣어, 루트 권한으로 다음처럼 실행했습니다.
textbpftrace /host/script_bpftrace.txt $pid > out_$pid.txt
요약하면, 이 스크립트는:
mmap, munmap, mremap 시스템 콜이 커널에 진입할 때 트레이싱ustack) 출력예시 출력은 다음과 같습니다.
bashStack trace: 7ffff7d6b88d syscall+29 (/usr/lib/x86_64-linux-gnu/libc.so.6) PID/TID: 441359 441359 | ENTER mmap(addr=(nil), len=151552, prot=3, flags=34, fd=-1, off=0) PID/TID: 441359 441359 | EXIT mmap: ret=0x7fd8a78ee000
BPFtrace 스크립트 출력 일부. syscall+29 가 중요합니다.
이 시점에서 수집한 정보를 정리해 보면:
pmap 으로 의심스러운 증가 할당과 그 베이스 주소를 확인했습니다.mremap 이 아니라 mmap 호출로 얻어진 것임을 알았습니다. 계속 커지는 할당이면 mremap 이 유력하다고 생각했기에 의외였습니다.syscall+29 즉 glibc의 raw syscall wrapper에서 시작되고 있었습니다. 이는 syscall(SYS_mmap, ...) 같은 API로 raw syscall을 수행하게 해주는 코드입니다.큰 진전이었지만 추가 조사가 필요했습니다. BPFtrace가 보여주는 것은 시스템 콜 자체의 유저 스택 트레이스(ustack)였는데, 실제로는 유저 콜스택의 첫 요소만 나오고 전체 컨텍스트(그 이전 호출자들)는 얻을 수 없었습니다. 우리는 mmap 이 어디서 호출되었는지는 봤지만, 그 전 호출자들을 볼 수 없었습니다. 이는 원인 지점을 특정하는 데 필수였기 때문에, 왜 전체 스택이 나오지 않는지 이해하려 했고, 처음에는 프레임 포인터가 비활성화된 것이 원인이라고 추정해 그 방향으로 조사했습니다.
프레임 포인터는 함수 호출의 반환 주소를 레지스터나 메모리에 저장해, 도구들이 전체 콜스택을 복원할 수 있게 해주는 기능입니다. 과거에는 약간의 오버헤드 때문에 많은 라이브러리에서 최적화 옵션으로 끄는 경우가 많았지만, 오늘날에는 디버깅 이점이 성능상의 미미한 이득보다 크기 때문에 최신 배포판들이 다시 켜는 추세입니다.
하지만 Ubuntu 22.04 LTS에서 프레임 포인터가 켜져 있는 Ubuntu 24.04 LTS로 바꿔도 충분하지 않았습니다. 이는 프레임 포인터를 끈 채로 빌드된 어떤 최적화된 Python 의존성이 범인일 수 있음을 시사했습니다.
이 상황에서 고민이 깊어졌습니다. 어떤 Python 패키지가 표준 라이브러리 호출을 우회해 이런 direct syscall을 하겠는가? 이 단계에서 가능한 용의자는 두 가지였습니다.
두 용의자 중 누구인지 더 깊이 파려면 다른 접근이 필요했습니다. 이때 GDB 자동화로 전환했습니다.
조사 초기에 GDB 사용이 어려웠던 이유는 간단합니다. GDB는 프로세스 전체에 붙습니다. vLLM의 메인 프로세스에 GDB를 붙이면 모든 워커가 멈춰 실시간으로 누수를 관찰할 수 없었습니다. 게다가 누수는 크래시를 일으키지 않았고, 프로세스가 너무 커서 메모리 덤프도 비현실적이었습니다.
하지만 BPFtrace 로그를 보니, 누수 mmap 호출이 항상 같은 주소에서 발생한다는 사실을 알게 됐습니다. 앞서 말했듯 이 주소는 mmap 자체가 아니라 glibc의 syscall thin-wrapper 안에 있었습니다. 이 함수는 glibc의 일반적인 mmap 래퍼를 우회하므로, 이전에 LD_PRELOAD 훅이 놓친 이유가 설명됩니다. glibc의 syscall 을 LD_PRELOAD 로 인터셉트하려 했지만, 이상하게도 그것도 동작하지 않았습니다… 동적으로 추적할 수단이 거의 사라진 셈이었습니다.
누수 호출이 항상 같은 syscall 인스트럭션에서 나왔기 때문에, 우리는 GDB를 자동화해 그 특정 주소가 실행될 때만 브레이크하도록 만들 수 있었습니다. 방법은 다음과 같습니다.
syscall 주소에 조건부 브레이크포인트를 걸어, 시스템 콜 번호가 SYS_mmap 인 경우에만 트리거되도록 함.mmap 시스템 콜의 종료 지점에서 잠시 멈춰 반환값(할당된 주소)을 확인하고 전체 스택 트레이스를 출력.pmap 모니터링과 대조, 누수 영역과 일치하는지 확인.다음은 gdb -x gdb_script.txt 로 실행할 수 있는 스크립트입니다.
bash# Attach to the process attach 2199304 # Open a log file for output set logging file syscall_output.txt set logging on # Set a conditional breakpoint on syscall for rdi == 9 (mmap) break syscall if $rdi == 9 # Commands for the syscall breakpoint commands silent # Set a temporary breakpoint at the return point tbreak *0x00007ffff7d9525d # Commands for the temporary breakpoint commands $bpnum + 1 silent set $ret_val = $rax bt printf "Syscall returned: rax = 0x%012lx\n", $ret_val continue end continue end # Run the process continue
이 접근은 두 가지 큰 이점을 줬습니다.
pmap 출력과 교차 검증해, 누수 영역과의 일치 여부를 확인할 수 있었습니다.즉 GDB를 문제 지점에서만 잠깐 멈추는, 표적형·비침투적 관찰자로 만든 셈입니다. 필요한 정보를 모두 출력한 뒤 계속 실행시키는 방식이었습니다. 덕분에 mmap 호출과 pmap 에서 보던 증가하는 익명 영역을 연결할 수 있었습니다.
첫 번째 스택 트레이스는 Python( #5)이 UCX( #4)를 통해 mmap 을 호출하고 있음을 보여줬습니다. 정상적인 상황이라면 Python은 glibc의 mmap 을 직접 호출해야 하므로 예상 밖이었습니다.
bash#0 syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:29 #1 0x00007ffc61759ac2 in ucm_orig_mmap_syscall () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0 #2 0x00007ffc61753bd1 in ?? () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0 #3 0x00007ffc61753e3b in ucm_event_dispatch () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0 #4 0x00007ffc61754009 in ucm_mmap () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0 #5 0x0000000000674ac0 in _PyMem_ArenaAlloc (_unused_ctx=<optimized out>, size=<optimized out>) at ../Objects/obmalloc.c:138 ...
더 혼란스러웠던 것은 두 번째 스택 트레이스였습니다. Python( #8)이 UCX( #7)를 통해 munmap 을 호출하고 있는데, 그 과정에서 mmap 할당( #1)이 트리거되고 있었습니다.
bash#0 syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38 #1 0x00007ffc60f58ac2 in ucm_orig_mmap_syscall () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0 #2 0x00007ffc60fae47c in ?? () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucs-311e600f.so.0.0.0 #3 0x00007ffc60f9b9c4 in ucs_mpool_grow () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucs-311e600f.so.0.0.0 #4 0x00007ffc60f9bbf5 in ucs_mpool_get_grow () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucs-311e600f.so.0.0.0 #5 0x00007ffc60fafe2c in ?? () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucs-311e600f.so.0.0.0 #6 0x00007ffc60f52e3b in ucm_event_dispatch () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0 #7 0x00007ffc60f5313b in ucm_munmap () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0 #8 0x0000000000607d15 in _PyThreadState_PopFrame (tstate=0xba6ac8 <_PyRuntime+459656>, frame=<optimized out>) at ../Python/pystate.c:2992 ...
이는 munmap 이 메모리를 해제해야 하는데, UCX의 UCM(UCX의 메모리 관리 모듈) 내에서 munmap 중에 mmap 이 호출되는 것이므로 비정상적으로 보였습니다. 이는 UCX의 메모리 풀 관리에서 무언가가 잘못되고 있음을 시사했습니다.
이 결과를 vLLM 팀과 공유했고, 그들은 이를 검증하며 이해를 더 정교하게 다듬는 데 도움을 주었습니다. 함께 조사한 결과, UCX는 Infiniband를 위한 메모리 최적화(전송을 위한 데이터 사전 캐싱, Registration Cache 또는 RCache)를 위해 mmap 훅킹 메커니즘을 사용한다는 사실을 발견했습니다. Infiniband에서는 하드웨어 레벨 메모리 등록이 필요해 메모리 관리 비용이 비싸기 때문입니다.
하지만 이 메커니즘은 기본적으로 UCX/Infiniband 관련 호출만이 아니라 모든 mmap 호출을 가로챕니다. 이 광범위한 인터셉트가 우리가 이전에 LD_PRELOAD 기반 mmap 훅으로 추적하려 했을 때 실패한 이유를 설명합니다. UCX는 mmap/munmap 같은 함수를 호출하기 위해 애플리케이션이 사용하는 GOT(Global Offset Table) 엔트리를 동적으로 패치합니다. 그래서 우리의 훅을 완전히 우회하고 있었습니다. 또한 이 훅킹 메커니즘은 Valgrind가 감지되면 자동으로 비활성화되는데, 이는 우리가 Valgrind로 더 깊게 분석하려 했을 때 사용이 어려웠던 이유이기도 합니다.
GOT는 동적 링커가 동적 링크 라이브러리의 함수 호출을 해석(resolve)하기 위해 사용하는 자료구조입니다. 프로그램이 시작되면 동적 링커가 공유 라이브러리의 mmap 같은 함수 실제 주소를 GOT에 채웁니다.
런타임에 GOT를 수정하는 것은 일반적으로 나쁜 관행으로 여겨집니다. 불안정성을 유발할 수 있고, 디버깅을 어렵게 만들며, 프로그램이나 다른 라이브러리의 가정(assumption)을 깨뜨릴 수 있기 때문입니다. 하지만 UCX는 타당한 이유로 이를 수행합니다. UCX는 RCache를 관리하기 위해 이를 사용합니다. RCache는 “등록된(registered)” 혹은 “고정된(pinned)” 메모리를 추적합니다. 이는 가상→물리 주소 매핑이 고정되도록 메모리를 제자리에 핀(pin)해 두는 것으로, 네트워크 어댑터가 CPU 개입 없이 네트워크 패브릭과 RAM 사이에서 직접 데이터를 전송할 수 있게 해줍니다. 성능에 매우 중요하지만, 등록 메모리는 제한된 자원이어서 신중한 관리가 필요합니다.
mmap 훅킹 메커니즘이 작동 중임을 알게 되었을 때, 오히려 좋은 소식이었습니다. UCX_MEM_MMAP_HOOK_MODE=none 환경 변수를 설정하면 이 동작을 완전히 끌 수 있었기 때문입니다. 실제로 이 설정은 동작을 비활성화했고, 성능에 영향을 주지 않으면서 메모리 누수가 해결되었습니다. mmap 훅은 다양한 메모리 청크를 RDMA로 보낼 때 유용하지만, vLLM 사용 사례에서는 하나의 크고 연속적인 메모리 영역(vLLM KVCache Manager 메모리 전체)만 다루면 됩니다. NIXL은 전송을 위해 그 메모리를 한 번만 등록하면 충분했습니다. 따라서 vLLM 사용 사례에서 훅을 비활성화하는 것은 안전했고, 분리형 서빙 성능에도 부정적 영향이 없었습니다.
UCX 팀과 논의한 결과, UCX는 munmap 호출 시 메모리를 즉시 해제하지 않는다는 사실을 알게 되었습니다. 대신 해당 영역을 나중에 정리하기 위한 invalidation queue로 옮깁니다. 이 큐는 UCX의 메모리 풀에 의해 관리되며, 필요에 따라 더 많은 엔트리를 담기 위해 동적으로 확장됩니다. 그 결과, 메모리 영역이 해제되지 않은 채 누적되었고, 커지는 큐를 수용하기 위한 추가 할당이 필요해져 munmap 중에 mmap 이 호출되는 현상이 설명되었습니다. 다른 해결책으로는 UCX_RCACHE_MAX_UNRELEASED=1024(기본값은 inf) 환경 변수로 큐에 쌓일 수 있는 미해제 영역 수를 제한해, 임계치에 도달하면 UCX가 정리를 강제하도록 할 수 있습니다.
문제는 애초에 이런 일이 발생하지 말았어야 한다는 점입니다. NIXL과 vLLM은 실제로 ucp_worker_progress() 함수를 호출하고 있었고, 이는 메모리 풀 정리를 트리거해야 합니다. 왜 이 특정 엣지 케이스에서 트리거되지 않았는지는 아직 명확하지 않습니다. 하지만 UCX_RCACHE_MAX_UNRELEASED 의 기본값을 무한대로 두는 것은 올바르지 않다는 점을 보여줬습니다. UCX와 NIXL 팀은 향후 NIXL 릴리스에서 이 동작을 변경하기로 했고(PR), 그 사이 우리는 커뮤니티가 같은 누수를 겪지 않도록 vLLM 저장소에 수정 PR을 머지했습니다.
조사를 간단히 정리하면:
pmap 으로 계속 증가하는 RSS 할당과 그에 대응하는 베이스 포인터를 확인했습니다.mmap 호출에서 비롯됨을 알아냈습니다. 최선을 다했지만 전체 스택 트레이스를 수집하지는 못했고(이를 통해 범인 호출 지점을 정확히 특정할 수 있었을 텐데), 대신 고도로 최적화된 패키지가 syscall을 직접 수행하고 있다고 추정하게 되었습니다.UCX_MEM_MMAP_HOOK_MODE=none 설정이 문제를 해결했습니다. 이 조사는 vLLM 이슈에서 논의되었고, 커뮤니티를 위한 패치도 머지했습니다.현대 소프트웨어 스택은 수많은 의존성 레이어 위에 구축되며, 각 레이어는 복잡성과 잠재적 실패 지점을 추가합니다. 이런 추상화는 개발 생산성을 크게 높여 주지만, 스택 아래쪽에서 발생하는 문제로부터 완전히 보호해 주지는 못합니다. 그래서 디버깅 시 깊이 파고들 준비가 필요합니다. 다만 이런 환경에서 그것은 좀처럼 단순하지 않습니다. 특히 성능 최적화가 미묘한 엣지 케이스를 만들 때는 더 그렇습니다. UCX는 이를 잘 보여주는 사례입니다. 성능을 우선하는 설계 덕분에 mmap 호출을 가로채는 방식이 존재하는데, 이것이 추적하기 어려운 위험을 만들 수 있습니다. 이번 경험은 깊게 얽힌 시스템에서 문제를 진단하는 일이 얼마나 어려운지 다시 한 번 보여줬습니다.
또한 이번 조사는 성능에 민감한 의존성을 다룰 때 투명성과 협업이 얼마나 중요한지도 보여줍니다. 이 동작을 확인하고 해결하는 과정에서 함께해 준 vLLM, NIXL, UCX 팀과의 협업에 감사드립니다. 그들의 전문성이 해결에 결정적이었으며, 앞으로도 함께 일하기를 기대합니다.
이 이슈 해결에 도움을 준 아래 분들께도 감사를 전합니다.
이런 도전 과제를 함께 해결하고 싶다면, Mistral AI에 합류해 AI 인프라의 미래를 함께 만들어 주세요. 우리는 최첨단 프로젝트에서 협업할 재능 있는 엔지니어와 연구자를 항상 찾고 있습니다.
Try le ChatBuild on AI StudioTalk to an expert
Mistral AI © 2026
About usOur customersCareersContact us
AI solutionsPartnersResearchDocumentation
StudioLe ChatCodeMistral Compute
Terms of servicePrivacy policyPrivacy choicesData processing agreementLegal noticeBrand
en Mistral AI © 2026