ps는 0.31GB라고 했다. 실제로는 20GB였다 — MLX 로컬 LLM의 보이지 않는 메모리

MLX로컬LLMAppleSilicon성능측정트러블슈팅

어느 날 맥미니가 기어가기 시작했습니다. 여유 메모리는 80MB, 메모리 압축기가 30GB를 붙잡고 있었고, 스왑 7GB를 다 쓴 상태였습니다.

범인을 찾으려고 ps를 열었습니다. 로컬 LLM 서버는 이렇게 나왔습니다.

$ ps -o rss= -p 86000
0.31 GB

0.31GB. 문제 없어 보입니다. 그래서 저는 하루를 엉뚱한 데서 썼습니다.

같은 프로세스를 제대로 재면 이렇습니다.

$ footprint -p 86000 | grep phys_footprint
    phys_footprint:      20 GB
    phys_footprint_peak: 30 GB

64배 차이입니다.

환경

이 서버는 공시·뉴스 분류의 2단을 담당합니다. 지금까지 25,871건을 처리했고 API 비용은 $0.00 입니다. 앞 글에서 쓴 3단 라우팅의 중간 단계입니다.

오진 1: RSS를 봤다

ps가 보여주는 RSS(Resident Set Size)는 프로세스가 붙잡고 있는 물리 페이지입니다. 리눅스에서 20년 쓴 상식이고, 대부분의 경우 맞습니다.

Apple Silicon의 통합 메모리에서는 맞지 않습니다. MLX는 텐서를 Metal 버퍼로 잡습니다. CPU와 GPU가 같은 물리 메모리를 공유하기 때문에 이 할당은 프로세스의 RSS 회계에 잡히지 않습니다. 그래서 20GB를 쓰는 프로세스가 0.31GB로 보입니다.

VSZ는 더 심합니다.

$ ps -o rss=,vsz= -p 86000
RSS : 0.31 GB
VSZ : 435.1 GB     ← 이 기계의 물리 메모리는 64GB입니다

435GB. 기계에 달린 메모리의 6.8배입니다. 가상 주소 공간 예약이라 아무 의미가 없습니다.

표준 도구 두 개가 각각 64배 과소, 6.8배 과대로 틀렸습니다. 그리고 둘 다 아무 경고도 하지 않습니다.

맞는 도구는 두 개입니다.

footprint -p <pid>                                  # phys_footprint 항목
top -l 1 -o mem -stats pid,command,mem              # MEM 열

둘은 같은 값을 보여줍니다. topMEM은 RSS가 아니라 footprint 계열입니다. 저는 ps가 익숙해서 ps를 먼저 열었고, 그게 하루를 날린 이유였습니다.

오진 2: wired 메모리를 추론 중에 쟀다

도구를 고친 뒤에도 한 번 더 틀렸습니다.

vm_stat의 wired(고정) 메모리가 54GB로 나왔습니다. “커널이 뭔가를 붙들고 있다”로 판단하고 그쪽을 파기 시작했습니다.

문제는 제가 그걸 추론이 돌아가는 중에 쟀다는 것입니다.

유휴 상태    wired  ~6.5GB
추론 중      wired  ~32GB

GPU가 텐서를 쓰는 동안 그 페이지는 wired가 됩니다. 추론이 끝나면 풀립니다. 즉 추론 중의 wired는 원인이 아니라 정상 동작입니다. 로컬 LLM 서버는 요청이 오면 수십 초씩 GPU를 물고 있으니, 아무 때나 재면 높은 값이 나올 확률이 높습니다.

부하가 걸린 상태에서 잰 값은 기준선이 아닙니다. 유휴 시점 값과 나란히 놓기 전에는 어떤 수치도 “비정상”이라고 부를 수 없습니다.

진짜 범인: 상한 없는 KV 캐시

제대로 재기 시작하자 답이 나왔습니다.

mlx_lm.server는 프롬프트 캐시(KV 캐시)를 들고 있습니다. 같은 접두부로 시작하는 요청이 오면 그 부분의 계산을 건너뛰기 위한 것입니다. 문제는 기본 설정에 상한이 없다는 점이었습니다.

캐시 보유량:  10 시퀀스 = 15.54GB
모델 가중치:  18.6GiB
────────────────────────────
footprint:    40GB

여기에 Docker VM 8.1GB와 임베딩 서버 5GB를 더하면 53GB입니다. 64GB 기계에서 나머지가 무너지는 게 당연했습니다.

그리고 이 캐시는 가동 시간에 따라 자랍니다. 새로운 접두부를 만날 때마다 슬롯이 채워지기 때문에, 재시작 직후에는 모델 크기에 가깝고 며칠 돌면서 부풀어 오릅니다. 배포 직후 점검에서 아무 문제가 없어 보이는 이유가 이것입니다 — 이 버그는 재시작할 때마다 증거를 지웁니다.

수정: plist 두 줄

LaunchAgent에 상한을 걸었습니다.

<key>ProgramArguments</key>
<array>
  <string>/opt/homebrew/bin/mlx_lm.server</string>
  <string>--model</string>
  <string>/Users/jys/models/exaone-3.5-32b-4bit</string>
  <string>--port</string><string>8080</string>
  <string>--trust-remote-code</string>
  <string>--prompt-cache-size</string><string>2</string>          <!-- 시퀀스 2개 -->
  <string>--prompt-cache-bytes</string><string>4294967296</string> <!-- 4GiB -->
</array>

결과입니다.

항목
캐시 보유 10 seq / 15.54GB 2 seq / 최대 3.23GB
프로세스 footprint 40GB 24GB
메모리 압축기 30.19GB 0.76GB
여유 메모리 0.08GB 5.32GB
분류 1건 지연 19.7초 19.7초

함정: launchctl kickstart -k는 plist를 다시 읽지 않는다

여기서 시간을 한 번 더 버렸습니다.

plist를 고치고 launchctl kickstart -k gui/$(id -u)/com.marketriskradar.mlxserver로 재시작했는데, 새 인자가 적용되지 않았습니다. kickstart이미 로드된 잡 정의로 프로세스를 다시 띄웁니다. 파일을 다시 읽지 않습니다.

plist 내용을 바꿨다면 언로드 후 재로드해야 합니다.

launchctl bootout   gui/$(id -u)/com.marketriskradar.mlxserver
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.marketriskradar.mlxserver.plist

# 반영 확인 — 로그가 아니라 실제 프로세스 인자를 본다
ps -p $(pgrep -f mlx_lm.server) -o command=

마지막 줄이 중요합니다. “재시작했다”는 사실은 설정이 적용됐다는 증거가 아닙니다. 적용됐는지는 프로세스 인자에 물어봐야 합니다.

한 가지 더. 스왑에 밀린 상태에서 재시작하면 18.6GiB를 디스크에서 다시 읽습니다. 제 기계에서 6분 걸렸습니다. 그동안 분류가 멈추므로, 배치 스케줄을 피해서 해야 합니다.

반전 1: 캐시는 애초에 쓸모가 없었다

표에서 제일 이상한 줄은 마지막입니다. 지연이 그대로입니다.

15GB를 회수했는데 아무것도 느려지지 않았다면, 그 15GB는 원래 일을 하고 있지 않았다는 뜻입니다.

이유는 워크로드의 모양에 있었습니다. 이 서버의 소비자는 배치 하나뿐이고, 요청은 공통 시스템 프롬프트 + 매번 다른 공시 본문입니다. 캐시가 걸리는 건 공통 접두부뿐이고, 그건 시퀀스 하나면 충분합니다. 서로 다른 사용자 10명이 각자 대화를 이어가는 상황을 가정한 기본값이 단일 소비자 배치 파이프라인에 그대로 얹혀 있었던 겁니다.

기본값이 나쁜 게 아닙니다. 제 워크로드가 그 기본값이 상정한 워크로드가 아니었을 뿐입니다. 그리고 저는 그 차이를 확인하지 않고 5개월을 돌렸습니다.

반전 2: 그래서 빨라졌냐면, 아니었다

메모리를 되찾고 나서 진짜 궁금한 건 이거였습니다. “이제 빨라졌나?”

먼저 지금 속도를 쟀습니다. 300토큰 생성을 두 번 돌린 결과입니다.

in=32  out=300  time=34.00s  →  8.82 tok/s
in=32  out=300  time=33.83s  →  8.87 tok/s

8.85 tok/s. 체감상 느립니다. 그런데 이게 느린 건지 판단하려면 이 기계에서 물리적으로 가능한 최대값을 알아야 합니다.

자기회귀 생성은 토큰 하나를 만들 때마다 모델 가중치 전체를 메모리에서 읽습니다. 그래서 상한은 연산량이 아니라 메모리 대역폭이 정합니다.

M4 Pro 메모리 대역폭      273 GB/s
모델 가중치                20.00 GB
──────────────────────────────────
이론상 최대   273 / 20.00 ≈ 13.65 tok/s
실측                        8.85 tok/s   (이론값의 65%)

대역폭 스펙은 최대치이고 실제 달성률은 그보다 낮습니다. 커널 실행 간격, 어텐션 계산, 샘플링 오버헤드가 다 그 사이에 들어갑니다. 65%는 이 급의 모델에서 정상 범위입니다.

그리고 메모리 압박이 극심하던 시점에도 분류 1건은 23.8초였습니다. 정상 상태 19.7초 대비 열화가 17~18% 였습니다.

정리하면 이렇습니다.

“느리다”의 원인은 메모리 압박이 아니라 모델이 이 기계에 비해 크다는 것이었습니다. 압박을 완전히 없애서 얻을 수 있는 최대치가 18%였습니다.

이걸 먼저 계산했다면 메모리 문제를 성능 문제로 오인하지 않았을 겁니다. 두 개는 별개였고, 고쳐야 할 건 메모리 쪽이었습니다. 성능은 고칠 게 없었습니다 — 더 작은 모델을 쓰거나 더 넓은 기계를 사는 것 외에는.

지금 상태

수정 후 나흘, 프로세스 기동 후 2일 17시간 시점입니다.

$ footprint -p 86000 | grep phys_footprint
    phys_footprint:      20 GB
    phys_footprint_peak: 30 GB

정상 구간 20GB, 최고 30GB. 상한이 걸려 있으니 가동 시간이 쌓여도 40GB로 돌아가지 않습니다. 같은 시점에 메모리 압축기는 1.3GB, 시스템 여유 메모리는 56%입니다.

peak가 30GB인 건 추론 중 일시 할당 때문입니다. 이건 상한과 무관하게 워크로드가 끝나면 풀립니다.

남는 것

측정 도구가 플랫폼을 안다고 가정하지 마세요. psvm_stat은 잘못된 답을 조용히 줍니다. Apple Silicon + MLX 조합에서는 footprinttop -o mem만 믿을 수 있습니다. 도구가 틀렸다는 걸 모르면, 그 다음의 모든 추론이 틀립니다.

기준선을 계산한 뒤에 최적화하세요. 대역폭 나누기 모델 크기는 종이 한 장이면 끝나는 계산입니다. 그걸 먼저 했다면 “느린 게 정상”이라는 걸 하루 먼저 알았을 겁니다. 최적화 작업의 첫 단계는 코드가 아니라 상한이 어디인지 아는 것입니다.

기본값은 누군가의 워크로드에 맞춰진 값입니다. 그게 내 워크로드일 이유는 없습니다. 캐시 10개는 여러 사용자의 대화를 상정한 값이었고, 제 파이프라인은 그 반대편에 있었습니다. 15GB를 5개월 동안 버린 이유가 그것뿐입니다.

그리고 “적용됐다”를 확인하세요. 재시작 명령이 성공했다는 건 설정이 반영됐다는 뜻이 아닙니다. 프로세스에 직접 물어보는 한 줄이 그 사이를 메웁니다.


이 글의 메모리·속도 수치는 다음 명령으로 직접 잰 값입니다. 같은 조합(Apple Silicon + mlx_lm.server)을 쓴다면 그대로 재현됩니다.

footprint -p $(pgrep -f mlx_lm.server) | grep phys_footprint
ps -o rss=,vsz= -p $(pgrep -f mlx_lm.server)
top -l 1 -o mem -n 5 -stats pid,command,mem
find <모델 디렉터> -name '*.safetensors' -exec stat -f '%z' {} \; | awk '{s+=$1} END {print s}'