mlx_lm.server를 며칠 돌리면 맥이 기어가는데 ps는 서버가 메모리를 거의 안 쓴다고 합니다.
여유 메모리 80MB, 메모리 압축기 30GB, 스왑 7GB를 다 쓴 상태에서 ps -o rss=는 0.31GB였습니다. 같은 프로세스를 footprint로 재면 20GB, 64배 차이입니다.
$ ps -o rss= -p 86000
0.31 GB
$ footprint -p 86000 | grep phys_footprint
phys_footprint: 20 GB
phys_footprint_peak: 30 GB
원인은 둘입니다.
① ps 가 못 보는 메모리가 있다
Apple Silicon은 CPU와 GPU가 같은 메모리 풀에 직접 접근하는데, MLX가 잡는 Metal 버퍼는 RSS 회계에 안 잡혀 ps가 틀린 답을 줍니다.
② KV 캐시에 상한이 없다
mlx_lm.server의 프롬프트 캐시에 기본 상한이 없어 가동 시간에 따라 모델 크기만큼 자랍니다.
고치는 것은 서버 인자 두 줄입니다.
--prompt-cache-size 2 # 시퀀스 2개
--prompt-cache-bytes 4294967296 # 4GiB
그중 한 줄은 지금도 상한을 강제하지 못할 수 있습니다(아래).
상한을 건 뒤 footprint는 40GB에서 24GB로, 압축기는 30GB에서 0.76GB로 내려왔고 분류 1건 지연은 19.7초 그대로였습니다.
아래는 왜 ps가 틀리는지, 캐시가 어떻게 자라는지, 재시작에서 밟은 함정, 그리고 “그래서 빨라졌나”에 대한 답입니다.
환경
- Mac mini, Apple M4 Pro (CPU 8P+4E, GPU 16코어), 통합 메모리 64GB, macOS 26.5
mlx_lm.server0.31.1, 모델 EXAONE-3.5-32B 4bit (가중치 파일 합계 20.00GB = 18.63GiB)- 같은 기계에서 Docker VM(8.1GB)과 임베딩 서버(5.0GB)가 함께 돕니다
- 이 서버는 공시와 뉴스를 분류하는 파이프라인의 로컬 LLM 단계입니다. 지금까지 25,871건을 처리했고 API 비용은 $0.00 입니다. 비용 이야기는 LLM API 비용 줄이기에 있습니다
가중치 20.00GB가 아래 계산에 계속 쓰입니다.
ps 는 왜 틀린 답을 줄까?
ps가 보여주는 RSS(Resident Set Size)는 프로세스가 붙잡고 있는 물리 페이지입니다. 리눅스에서 20년 쓴 상식이고 대부분의 경우 맞습니다.
Apple Silicon의 통합 메모리에서는 맞지 않습니다.
MLX는 텐서를 Metal 버퍼로 잡고, CPU와 GPU가 같은 메모리 풀을 쓰기 때문에 이 할당은 프로세스의 RSS 회계에 잡히지 않습니다. 앞은 MLX 문서에 있고 뒤는 문서에 없는 제 실측입니다.
그래서 20GB를 쓰는 프로세스가 0.31GB로 보입니다.
VSZ는 반대로 435GB, 기계에 달린 메모리의 6.8배를 가리키는데 가상 주소 공간 예약이라 아무 의미가 없습니다.
표준 도구 두 개가 각각 64배 과소, 6.8배 과대로 틀렸고 둘 다 아무 경고도 하지 않습니다.
맞는 도구는 두 개입니다.
footprint -p <pid> # phys_footprint 항목
top -l 1 -o mem -stats pid,command,mem # MEM 열
둘은 같은 값을 보여줍니다. top의 MEM은 RSS가 아닌 footprint 계열입니다.
저는 ps가 익숙해서 ps를 먼저 열었고, 그게 하루를 날린 이유였습니다.
도구를 고친 뒤에도 한 번 더 틀렸습니다
vm_stat의 wired(고정) 메모리가 54GB로 나와 “커널이 뭔가를 붙들고 있다”로 판단하고 그쪽을 파기 시작했는데, 그걸 추론이 돌아가는 중에 쟀던 것이 문제였습니다.
GPU가 텐서를 쓰는 동안 그 페이지는 wired가 되고 추론이 끝나면 풀립니다(유휴 약 6.5GB, 추론 중 약 32GB). 로컬 LLM 서버는 요청이 오면 수십 초씩 GPU를 물고 있으니 아무 때나 재면 높은 값이 나올 확률이 높습니다.
부하가 걸린 상태에서 잰 값은 기준선이 아니고, 유휴 시점 값과 나란히 놓기 전에는 어떤 수치도 “비정상”이라고 부를 수 없습니다.
KV 캐시는 어떻게 자라나?
mlx_lm.server는 프롬프트 캐시(KV 캐시)를 들고 있습니다. 같은 접두부로 시작하는 요청이 오면 그 부분의 계산을 건너뛰기 위한 것입니다.
문제는 기본 설정에 상한이 없다는 점이었습니다.
캐시 보유량: 10 시퀀스 = 15.54GB
모델 가중치: 18.6GiB
────────────────────────────
footprint: 40GB
여기에 Docker VM 8.1GB와 임베딩 서버 5GB를 더하면 53GB입니다. 64GB 기계에서 나머지가 무너지는 게 당연했습니다.
그리고 이 캐시는 가동 시간에 따라 자랍니다. 새로운 접두부를 만날 때마다 슬롯이 채워지기 때문에, 재시작 직후에는 모델 크기에 가깝고 며칠 돌면서 부풀어 오릅니다.
배포 직후 점검에서 아무 문제가 없어 보이는 이유가 이것입니다.
이 버그는 재시작할 때마다 증거를 지웁니다.
LaunchAgent의 서버 인자에 상한을 걸었습니다.
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/mlx_lm.server</string>
<string>--model</string>
<string>/Users/you/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초 |
표에서 제일 이상한 줄은 마지막입니다.
15GB를 회수했는데 아무것도 느려지지 않았다면 그 15GB는 원래 일을 하고 있지 않았다는 뜻입니다.
이 서버의 소비자는 배치 하나뿐이고 요청은 공통 시스템 프롬프트 + 매번 다른 공시 본문이라, 캐시가 걸리는 건 공통 접두부뿐이고 그건 시퀀스 하나면 충분합니다.
서로 다른 사용자 10명이 각자 대화를 이어가는 상황을 가정한 기본값이 단일 소비자 배치 파이프라인에 그대로 얹혀 있었던 겁니다. 기본값이 나쁜 게 아니라 제 워크로드가 그 기본값이 상정한 워크로드가 아니었을 뿐이고, 저는 그 차이를 확인하지 않고 5개월을 돌렸습니다.
⚠️ 그런데 두 플래그 중 어느 쪽이 일했을까요? 이 실측으로는 가릴 수 없습니다. 캐시는 시퀀스 2개에서 멈췄고 그때 총량이 3.23GB라 4GiB 상한에는 닿지도 않았으니, 표의 변화는 시퀀스 상한만으로 설명됩니다. 게다가 mlx-lm 쪽에는 --prompt-cache-bytes를 파싱하고도 LRU 캐시에 바이트 상한을 넘기지 않아 강제되지 않는다는 PR이 열려 있습니다(2026-09-27 기준 미머지). 바이트 상한은 걸어 두되, 그것이 걸린다고 믿지 않는 편이 안전합니다.
재시작했는데 왜 설정이 그대로일까?
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분 걸렸고 그동안 분류가 멈추므로 배치 스케줄을 피해서 해야 합니다.
그래서 빨라졌을까?
메모리를 되찾고 나서 궁금한 건 하나였습니다. 먼저 지금 속도를 쟀습니다. 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는 정상 구간 20GB, 최고 30GB입니다. 상한이 걸려 있으니 가동 시간이 쌓여도 40GB로 돌아가지 않고, 같은 시점에 메모리 압축기는 1.3GB, 시스템 여유 메모리는 56%입니다. peak 30GB는 추론 중 일시 할당이라 상한과 무관하게 워크로드가 끝나면 풀립니다.
남는 것
측정 도구가 플랫폼을 안다고 가정하지 마세요. ps와 vm_stat은 잘못된 답을 조용히 줍니다. Apple Silicon + MLX 조합에서는 footprint와 top -o mem만 믿을 수 있습니다. 도구가 틀렸다는 걸 모르면, 그 다음의 모든 추론이 틀립니다.
기준선을 계산한 뒤에 최적화하세요. 대역폭 나누기 모델 크기는 종이 한 장이면 끝나는 계산입니다. 그걸 먼저 했다면 “느린 게 정상”이라는 걸 하루 먼저 알았을 겁니다. 최적화 작업의 첫 단계는 코드가 아니라 상한이 어디인지 아는 것입니다.
기본값은 누군가의 워크로드에 맞춰진 값입니다. 그게 내 워크로드일 이유는 없습니다. 캐시 10개는 여러 사용자의 대화를 상정한 값이었고, 제 파이프라인은 그 반대편에 있었습니다. 15GB를 5개월 동안 버린 이유가 그것뿐입니다.
“적용됐다”를 확인하세요. 재시작 명령이 성공했다는 건 설정이 반영됐다는 뜻이 아닙니다. 프로세스에 직접 물어보는 한 줄이 그 사이를 메웁니다.
자주 묻는 질문
캐시 시퀀스를 몇 개로 잡아야 하나요
동시에 이어지는 대화가 몇 개인지로 정합니다. 제 파이프라인은 공통 시스템 프롬프트 하나에 본문만 바뀌므로 2개로 충분했습니다. 여러 사용자가 각자 대화를 이어가는 서비스라면 기본값 쪽이 맞습니다.
footprint 와 top 중 어느 쪽을 보나요
같은 값이 나오므로 편한 쪽을 쓰면 됩니다. 저는 프로세스 하나를 볼 때 footprint, 기계 전체를 볼 때 top -o mem 을 씁니다.
peak 이 30GB 로 찍히는데 상한이 안 걸린 건가요
추론 중 일시 할당이라 워크로드가 끝나면 풀립니다. 상한이 걸리는 대상은 캐시 보유량이고, peak 은 그것과 별개입니다. 정상 구간이 20GB 로 유지되는지를 보면 됩니다.
더 작은 모델로 바꾸면 빨라지나요
산술적으로는 그렇습니다. 상한이 대역폭 나누기 가중치 크기이므로 가중치가 절반이면 이론 상한이 두 배가 됩니다. 다만 분류 품질이 함께 바뀌므로 속도만 보고 고를 수는 없습니다.
참고 자료
- MLX — Unified Memory 는 CPU 와 GPU 가 같은 메모리 풀에 직접 접근한다는 근거입니다. 그 할당이 RSS 회계에 안 잡힌다는 것은 이 문서에 없는 제 실측입니다.
- mlx-lm PR #1392 는
--prompt-cache-bytes가 파싱되고도 LRU 캐시에 전달되지 않는다는 보고입니다(2026-09-27 기준 미머지).
부록: 재현
이 글의 메모리와 속도 수치는 다음 명령으로 직접 잰 값입니다. 같은 조합(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}'
이 글에서 고친 것 1개
- 2026-09-27 — 통합 메모리 구조에 MLX 문서를 달고, RSS 회계에 안 잡힌다는 부분은 문서에 없는 실측이라고 갈랐습니다. 그리고 캐시 상한 두 줄 중
--prompt-cache-bytes가 실제로 강제되는지 확인할 수 없다는 것을 추가했습니다. 업스트림에 그 플래그가 LRU 캐시에 전달되지 않는다는 PR이 열려 있고, 제 실측도 시퀀스 상한만으로 설명됩니다. 처음 발행본은 두 줄이 함께 일한 것처럼 적었습니다.
이 사이트의 수치는 주 1회 DB와 다시 대조하고, 판단이 바뀌면 지우지 않고 글 안에 덧붙입니다. 갱신은 RSS로 받을 수 있습니다.