Home M5 맥북에서 로컬 Qwen3.8-27B, MLX가 GGUF보다 빠를까? — 실측 A/B 벤치 후기
Post
Cancel

M5 맥북에서 로컬 Qwen3.8-27B, MLX가 GGUF보다 빠를까? — 실측 A/B 벤치 후기

안녕하세요! 이번 글에서는 제 맥북(Apple M5, 32GB)에 로컬 LLM을 세우다 마주친 한 가지 질문을 실측으로 파헤쳐 본 이야기를 풀어보려 합니다.

질문은 이랬어요.

“Apple Silicon에선 MLX가 llama.cpp보다 몇 배 빠르다던데, 그럼 GGUF 대신 MLX로 갈아타면 더 낫지 않을까?”

인터넷 벤치를 보면 MLX가 llama.cpp 대비 decode 3배, M5의 Neural Accelerator 덕에 time-to-first-token은 4배까지 빠르다는 수치가 돌아다닙니다. 게다가 Ollama마저 백엔드를 MLX로 갈아탔다니, 솔깃할 수밖에 없죠. 그래서 똑같은 모델·똑같은 하드웨어·똑같은 워크로드로 GGUF와 MLX를 나란히 세워 A/B를 돌려봤습니다.

결론부터 말하면 — 저는 MLX로 갈아타는 데 실패했습니다. 😭 정확히는, MLX가 세 번의 벤치 중 두 번을 타임아웃으로 죽였어요. 왜 그랬는지가 이 글의 핵심입니다. 그리고 그 이유는 “MLX가 느려서”가 아니었습니다.

이 글의 모든 수치는 제 로컬 환경(M5, 32GB, macOS 26.2) 한정 실측입니다. 벤더 벤치가 아니라, “내 맥에서 실제로 어느 쪽이 쓸 만한가”를 재본 기록에 가깝습니다.


1. 무대: 로컬에 27B uncensored 모델 세우기

먼저 대상 모델. 요즘 화제가 된 Qwen3.8-27Babliterated(거절 제거) 빌드를 골랐습니다. 보안 리서치용으로 로컬에서 굴리기엔 “거절 안 함 + 데이터 유출 없음 + 무료”라는 조합이 매력적이거든요. 같은 소스에서 두 포맷이 나와 있어서 비교에 딱이었습니다.

  • GGUF (llama.cpp용): JonathanColetti/Qwen3.8-27B-Uncensored-GGUF, IQ4_XS (15.3GB)
  • MLX (Apple Silicon용): orcarouter/Qwen3.8-27B-Uncensored-MLX, 4-bit (16.1GB)

둘 다 4bit급이라 가중치 크기는 비슷합니다. 아키텍처는 qwen3_5 — Gated DeltaNet(linear) + full attention을 섞은 하이브리드 어텐션에 vision tower까지 달린 물건이에요. 이 “하이브리드 + dense 27B”라는 성격이 나중에 결과를 가르는 복선이 됩니다.

abliteration에 대해 하나 짚고 넘어가면 — 거절을 없앤다고 능력이 늘지는 않습니다. 연구들이 공통으로 말하는 결론은 “willing ≠ able”이에요. 시도를 더 할 뿐, 정답률은 그대로거나 오히려 소폭 떨어집니다. 이 글은 능력이 아니라 추론 성능(속도/안정성) 비교라 이 부분은 여기까지만.


2. Claude Code가 아니라 Qwen Code를 쓴 이유

로컬 모델을 코딩 에이전트에 붙이려는데, 여기서 첫 갈림길이 있었습니다.

  • Claude Code는 Anthropic의 /v1/messages 규격으로 말합니다. 로컬 서버(OpenAI 규격)에 붙이려면 변환 프록시(LiteLLM 등)가 하나 더 필요해요.
  • Qwen Code는 OpenAI 프로토콜을 네이티브로 말합니다. 로컬 서버에 바로 붙습니다.
1
2
Claude Code:  claude → [LiteLLM] → 로컬서버      (프로세스 2개)
Qwen Code:    qwen  ───────────→ 로컬서버        (프로세스 1개)

로컬 Qwen 모델을 굴릴 거면 Qwen Code 쪽이 자연스러워서, ~/.qwen/.env 세 줄로 끝냈습니다.

1
2
3
OPENAI_API_KEY=local-only
OPENAI_BASE_URL=http://127.0.0.1:8080/v1
OPENAI_MODEL=<모델 id>

이게 벤치에서도 중요한 포인트가 됩니다 — 합성 프롬프트가 아니라 Qwen Code가 실제로 쏘는 프롬프트로 재야 현실적이니까요. 뒤에서 보겠지만 Qwen Code는 시스템 프롬프트 + 툴 정의만으로 매 턴 3만 토큰을 실어 보냅니다.


3. 첫 벽: 32GB에서 27B를 굴리는 건 KV 캐시 싸움이다

llama.cpp로 먼저 세팅하면서 배운 게 있는데, 32GB에서 27B의 진짜 병목은 가중치가 아니라 KV 캐시라는 점입니다.

기본으로 띄우면 llama.cpp가 컨텍스트를 자동으로 139,776까지 잡는데, 이러면 KV 캐시가 fp16 기준 수 GB씩 붇어서 스왑으로 밀려납니다. 그래서 두 가지를 걸었어요.

  • -ctk q8_0 -ctv q8_0KV 캐시를 8bit로 양자화 (메모리 절반)
  • --parallel 1 — 슬롯을 하나로 묶어 프롬프트 캐시 재사용률을 살림

특히 프롬프트 캐시가 결정적이었습니다. Qwen Code는 매 턴 앞부분(시스템 프롬프트 + 툴)이 동일한데, 이걸 캐시하면 3만 토큰짜리 prefill을 매번 다시 할 필요가 없어요. 실제로 슬롯 4개(기본값)일 땐 요청이 빈 슬롯으로 흩어지며 캐시가 깨졌고, --parallel 1로 묶으니 f_sim_best = 1.000(100% 재사용)이 찍혔습니다.

1
2
3
4
llama serve -hf JonathanColetti/Qwen3.8-27B-Uncensored-GGUF:IQ4_XS \
  --host 127.0.0.1 --port 8080 --jinja --api-key local-only \
  --parallel 1 -c 65536 -ctk q8_0 -ctv q8_0 \
  -b 4096 -ub 2048 -fa on -cram 3072

--jinja는 모델 내장 chat template을 써서 툴 콜링을 살리는 옵션인데, 에이전트 기능이 여기 걸려 있어서 필수입니다. (get_weather({"city":"Seoul"}) 같은 툴 콜이 정상으로 나오는 걸 확인했어요.)

여기까지가 GGUF 기준선입니다. 이제 진짜 궁금했던 것 — MLX로 갈아타면 이걸 이길까?


4. “MLX가 3배 빠르다”는 벤치의 함정

MLX를 밀어붙이기 전에, 인용되던 벤치들을 다시 뜯어봤습니다. 그리고 두 가지 함정을 발견했어요.

함정 ① 그 벤치들은 죄다 MoE 모델이었다. “MLX 130 tok/s vs Ollama 43 tok/s, 3배!”의 주인공은 Qwen3-Coder-30B-A3B 같은 MoE(활성 3B) 모델입니다. 우리 모델은 27B dense — 매 토큰마다 27B를 전부 태우니 활성 파라미터가 9배 많아요. 저 3배가 그대로 재현될 리 없습니다.

함정 ② “40K 넘으면 MLX 우위가 사라진다”는 글도 있었다. 정확히 제가 Qwen Code로 돌리던 컨텍스트 구간(3~5만 토큰)입니다.

그래도 M5에는 진짜 이점이 하나 있었습니다 — Apple이 M5의 각 GPU 코어에 MLX 연산 패턴 전용 Neural Accelerator를 넣었다는 것. prefill(time-to-first-token)에서 llama.cpp의 Metal 백엔드가 못 따라오는 4배 우위가 여기서 나온다고 하죠. 제 병목이 정확히 prefill이었으니, 이건 직접 재볼 가치가 있었습니다.

그리고 세팅하다 하나 더 알게 된 결정적 사실 — mlx_lm serverKV 캐시 양자화를 지원하지 않습니다. --kv-bitsgenerate 전용이라 서버에 넘기면 그냥 죽어요. 즉 MLX 서버는 fp16 KV 고정입니다. llama.cpp가 q8_0으로 아낀 메모리를, MLX는 못 아낍니다. (이 한 줄이 결국 승부를 갈랐습니다.)


5. A/B 벤치 설계

공정성을 위해 최대한 조건을 맞췄습니다.

항목 GGUF (llama.cpp) MLX (mlx_lm)
가중치 IQ4_XS (4.25bpw) 4bit
KV 캐시 q8_0 (8bit) fp16 (강제)
프롬프트 캐시 -cram 3072 (3GB) --prompt-cache-bytes 2GB
툴/템플릿 --jinja 기본 chat template
부하 <동일> Qwen Code 실프롬프트 <동일>

측정은 wall-clock을 두 엔진 공통 기준으로 삼았습니다. 프롬프트는 qwen -p "Reply with only: BENCHMARK" 한 줄로 고정 — 사소한 응답이지만, Qwen Code가 32,552 토큰짜리 시스템 프롬프트 + 툴 정의를 통째로 실어 보내므로 prefill 부하는 충분합니다. 각 엔진마다 콜드 1회 + 웜 2회를 돌렸어요.

두 모델(각 15GB+)을 32GB에 동시에 못 올리니, GGUF를 다 돌리고 → 서버 종료 → 메모리 회수 확인 → MLX 기동 순으로 순차 진행했습니다.

방법론 한계 하나는 미리 고백합니다. qwen -p는 단일 추론이 아니라 에이전트 루프라, 회차마다 컨텍스트가 조금씩 다르게 자랍니다(9.7k → 12.7k → 13.7k). 그래서 콜드/웜 절대값엔 노이즈가 있어요. 다만 양쪽에 동일하게 적용되므로 엔진 간 비교(성공률·상대속도)는 유효합니다.


6. 결과

회차 GGUF (llama.cpp) MLX (mlx_lm)
콜드 521s ✅ 523s ✅
776s ✅ 900s+ 타임아웃
웜2 756s ✅ 900s+ 타임아웃
3회 완주 3 / 3 1 / 3

한눈에 봐도 GGUF의 승리입니다. 그런데 흥미로운 건 콜드가 사실상 동률(521 vs 523초)이라는 점 — MLX가 느려서 진 게 아니라는 얘기죠. 웜 회차에서 MLX가 무너진 이유를 로그로 파봤습니다.


7. 왜 MLX가 무너졌나 — 범인은 속도가 아니라 메모리

7-1. M5의 “4배 prefill”은 이 모델엔 없었다

콜드 523 vs 521초. 웹 벤치가 약속한 4배 우위는 나타나지 않았습니다. §4의 함정 ①이 그대로 적중한 거예요. 4배는 MoE(활성 3B) 이야기고, dense 27B에서 매 토큰 27B를 다 태우는 연산량 앞에선 M5 가속기의 이점이 희석됩니다. self-description처럼, 벤치 수치도 “어떤 모델로 쟀는지”를 안 보면 배신당합니다.

7-2. fp16 KV 캐시 → 메모리 고갈 → 스왑 → 타임아웃

웜 회차에서 MLX 서버 메모리를 봤더니:

1
2
free: 0.17 GB
swap: 15,044 MB 사용 (거의 만공)

§4에서 발견한 그 한 줄 — MLX 서버는 KV를 fp16으로만 잡는다 — 이 여기서 터졌습니다. 여기에 프롬프트 캐시가 5.9GB까지 팽창하면서, 32GB가 가중치 + fp16 KV + 캐시를 못 버티고 스왑에 빠졌어요. 그리고 웜 호출은 캐시 상태를 메모리에 올리는 지점(mx.eval([c.state for c in self.prompt_cache]))에서 스왑에 걸려 900초 타임아웃으로 죽었습니다.

GGUF는 같은 상황에서 q8_0 KV로 메모리를 절반만 쓰니 안정적으로 3/3을 완주했고요.

7-3. 캐시 축출의 악순환

메모리가 없으니 MLX 프롬프트 캐시가 계속 쫓겨났습니다(5.9GB → 2.25GB → 3.35GB 반복). 축출된 프리픽스는 다시 처리해야 하고, 그 재처리가 또 메모리를 밀어붙이는 악순환. 웜이 콜드보다 느려진 게 아니라, 웜에서 아예 앞으로 못 나갔습니다.


8. 결론

정리하면, “Apple Silicon이니 MLX로 갈아타면 빠르다”는 통념은 제 환경에선 사실이 아니었습니다. 오히려 반대였어요.

  • 콜드는 동률(521 vs 523초). M5의 MLX 가속은 실재하지만, dense 27B에선 그 4배 우위가 재현되지 않습니다. MoE 벤치 수치를 dense에 그대로 대입하면 안 돼요.
  • 승부를 가른 건 속도가 아니라 KV 캐시 양자화였습니다. mlx_lm server는 fp16 KV 고정이라, 32GB에서 메모리 고갈 → 스왑 → 웜 회차 타임아웃(2/3 실패). llama.cpp는 q8_0 KV로 여유를 만들어 3/3 완주.
  • MLX 자체가 나쁜 게 아니라, “dense 27B + 32GB + fp16 KV 서버”라는 조합이 안 맞습니다. MLX가 이기려면 (a) 64GB+ 램이거나, (b) MoE 모델이거나, (c) KV 양자화를 지원하는 다른 서버 구현이 필요합니다.

그래서 저는 GGUF(llama.cpp) IQ4_XS를 그대로 유지하기로 했습니다. 성능은 동률, 안정성은 압승, 무엇보다 32GB에서 지속 가능한 유일한 조합이었으니까요.

이번 검증에서 제일 크게 배운 건, 예전 모델 라우팅 추적 글에서 얻은 교훈과 똑같았습니다 — “남의 벤치를 믿지 말고 내 환경에서 직접 재라.” MLX가 3배 빠르다던 그 숫자도, 알고 보니 나와 다른 모델·다른 램의 이야기였으니까요. 진실은 언제나 내 맥의 freeswap에 있습니다. 😉

읽어주셔서 감사합니다. 다음 글에서 또 만나요! 👋

이 벤치는 특정 스냅샷(mlx_lm 0.31.3, llama.cpp 0.2.0, Qwen Code) 기준이며, M5 32GB 한정 실측입니다. 프레임워크 버전·램·모델 종류(dense vs MoE)가 바뀌면 결론은 얼마든지 뒤집힐 수 있습니다. 특히 MLX 서버가 향후 KV 양자화를 지원하면 이 글의 §7-2는 무효가 됩니다.

This post is licensed under CC BY 4.0 by the author.

CVE-2026-64777 — apple/container BuildFSSync 경로 컨테인먼트 우회로 호스트 파일 유출 (Claude Code SAST 세션 회고)

-