메뉴
HN
Hacker News 17일 전

맥 스튜디오에서 대규모 AI 모델 구동을 위한 3가지 버그 수정

IMP
7/10
핵심 요약

M3 맥 스튜디오 얼트라 환경에서 5만 토큰 이상의 긴 대화 컨텍스트를 처리할 때 첫 토큰 생성까지 수 분이 걸리던 치명적인 지연 문제를 해결한 사례입니다. 저자는 DS4 Flash 모델에서 지연 시간이 더 짧은 Qwen 3.5 122B로 교체하고, 하이브리드 어텐션 구조로 인한 캐시 메모리 누수 등 서빙 스택의 버그 3가지를 직접 수정하여 로컬 환경에서도 실사용이 가능한 에이전트 코딩 환경을 구축했습니다. 로컬 LLM 최적화 및 Mac 하드웨어 활용에 관심 있는 개발자들에게 매우 유용한 기술적 인사이트를 제공합니다.

번역된 본문

목차

5만 토큰짜리 대화에서 후속 질문을 던지면 첫 번째 토큰이 나타나기까지 3~5분이 걸렸다. 전체 답변이 아니라 첫 토큰이다. 이건 챗봇이 아니라 배치 작업(batch job)이며, AI가 생각하는 동안 커피나 한 잔 하러 가야 하는 수준이다. 맥 스튜디오 성능을 극대화하는 데 광팬인 열두 명의 다른 긱(geek)들에게 좋은 소식을 가져왔다. 좋은 소식이라 함은, 여러분이 고생하지 않도록 내가 3주 동안 캐시 누수(cache leak)를 디버깅했다는 뜻이다.

이 모든 것은 소비자용 하드웨어에서 대규모 모델을 구동하는 훌륭한 실험인 antirez/ds4에서 시작되었다. 두 달 전, 나는 충동적으로 M3 맥 스튜디오 얼트라를 샀다. RAM 가격이 오르면 곧 96GB 구성을 구하기 어려워질 것 같았기 때문이다(스포일러: 실제로 그렇게 되었다). 그리고 육아 휴직 중에 로컬 추론 스택을 깊이 파고들고 싶었다.

목표는 단순했다. 단일 머신에서 최첨단 모델을 구동하고, 계속 준비된 상태(warm)로 유지하며, 문맥을 실제로 이해하는 대화형 AI를 갖는 것이었다. 하지만 생각보다 더 복잡했다. 모델은 통합 메모리(unified memory)에 들어맞았고 제대로 된 답변을 내놓았다. 하지만 매일 사용해 본 몇 주 후, 나는 방향을 완전히 틀었다: DS4 Flash를 버리고 Qwen 3.5 122B로 전환한 것이다.

이후 두 가지 별개의 일이 일어났는데, 이 둘을 구분해서 보는 것이 중요하다. 모델 교체는 속도와 적합성에 대한 결정이었다. 새 모델을 실제로 사용 가능하게 만든 것은 내 서빙 스택에서 발견된 세 가지 버그를 수정한 일이었으며, 이는 내가 어떤 모델을 구동하느냐와는 전혀 상관이 없었다.

모델을 교체한 이유 # DS4 Flash는 정말 훌륭한 모델이며 antirez의 스택은 걸작이다. 다만 내가 하는 작업에는 맞지 않는 모델이었을 뿐이다. 이는 결함에 대한 이야기가 아니라 적합성에 대한 이야기다.

나의 사용 사례는 긴 문맥의 에이전트 코딩이다. 모델이 수천 토큰에 달하는 대화, 코드, 도구 호출 결과를 유지하고, 내가 흐름을 유지할 수 있도록 거의 즉각적인 응답이 필요한 페어 프로그래밍이다. 이 특정 워크플로우에서는 사전 채우기(prefill) 지연 시간이 결정적인 걸림돌이었다. 5만 토큰을 넘기면 간단한 후속 질문에 첫 토큰이 나오기까지 3~5분이 걸렸고, 차 한 잔을 기다리게 만드는 모델과는 페어 프로그래밍을 할 수 없다. 모델이 답을 낼 때쯤이면 나는 이미 다음 문제로 넘어가 있었다.

이것은 DS4가 절대적인 의미에서 느리다는 뜻이 아니라, 내 하드웨어에서 해당 설정이 긴 문맥의 사전 채우기를 처리하는 방식과 내 워크플로우의 요구 사이에 불일치가 있었다는 뜻이다.

Qwen 3.5 122B는 내가 신경 쓰는 모든 면에서 M3 얼트라에 더 잘 맞아 보였다:

최첨단에 가까운 완전한 로컬 구동. API 호출 없음, 속도 제한 없음, 데이터 유출 없음. 독점 시스템에 필적하는 122B 모델이 완전히 오프라인으로 구동됨. 얼트라의 대역폭에 가장 적합한 활성 파라미터 크기. M3 얼트라의 메모리 대역폭은 매우 크다. 약 10B의 활성 파라미터를 가진 122B MoE 모델은 GPU가 컴퓨팅 유닛에 데이터를 끊김 없이 공급할 수 있는 최적의 조건(sweet spot)에 위치한다. 도구 호출과 추론의 좋은 균형. 복잡한 코드 로직을 처리할 만큼 충분한 추론 깊이, 신뢰할 수 있는 함수 호출과 에이전트 오케스트레이션을 위한 견고함. 통합 KV 캐시에 딱 맞는 크기. 96GB의 통합 메모리를 사용하면 저비트(low bit-width)로 양자화된 122B 모델은 SSD 기반의 깊은 KV 캐시를 위한 충분한 여유 공간을 남긴다. 이는 스와핑 없이 긴 문맥을 유지하기 위한 핵심이다. 내가 찾을 수 있었던 가장 근접한 서빙 스택은 rapid-mlx였다. 하지만 이것은 하이브리드 어텐션(hybrid attention)에서 다른 방향을 취하고 있었다. 이러한 차이를 고려하여, 나는 이를 포크(fork)했다. 그 결과가 바로 GitHub에 공개된 qMLX이다. 이 포크는 하이브리드 어텐션에 특화되어 있다(이에 대해서는 글 마지막에 더 자세히 다룬다).

진짜 작업: 세 가지 버그 죽이기 # 모델은 메모리에 잘 들어맞았지만, 모델 자체와는 무관한 이유들로 인해 바로 사용할 수 없었다. 바로 모든 후속 메시지가 전체 대화를 처음부터 다시 처리한다는 것이었다. 13만 토큰짜리 문맥에서는 모델이 단 한 단어를 내뱉기까지 몇 분을 기다려야 한다.

원인은 GatedDeltaNet (SSM) 레이어와 밀집 어텐션(dense attention)이 혼합된 하이브리드 어텐션 아키텍처에 있었다. SSM 레이어의 순환 상태(recurrent state)는 이전 위치로 되감기거나 다듬을 수 없으므로, 메모리 누수를 피하기 위해 인메모리 캐시는 해당 레이어를 포함하는 모든 항목을 버리게 된다. 이를 위해...

원문 보기
원문 보기 (영어)
Table of Contents A follow-up question on a 50,000 token conversation took three to five minutes before the first token appeared. Not the full answer. The first token. That is not a chatbot, it is a batch job, and you go and make a cup of coffee while it thinks. To the dozen other geeks obsessed with maximising your Mac Studio, I come with good tidings. By good tidings I mean I spent three weeks debugging a cache leak so you don’t have to. This started with antirez/ds4 , a brilliant experiment in running large models on consumer hardware. Two months ago I impulse-bought an M3 Mac Studio Ultra. I feared RAM prices would make the 96GB configs unattainable soon (spoiler: they did), and I really wanted to dive into local inference stacks whilst I am on parental leave. The goal was simple: run a frontier model on a single machine, keep it warm, and have a conversational AI that actually understands context. It turned out to be more complicated than that. The model fits in unified memory and answers coherently, But after weeks of daily use I made a hard pivot: I dropped DS4 Flash and switched to Qwen 3.5 122B. Two separate things then happened, and it is worth keeping them apart. The model swap was a speed-and-fit decision. Making the new model actually usable meant fixing three bugs in my own serving stack that had nothing to do with which model I ran. Why I switched models # DS4 Flash is a genuinely good model and antirez’s stack is a brilliant piece of work. It just was not the right model for what I do. This is a fit story, not a fault story. My use case is long-context agentic coding: pair programming where the model holds thousands of tokens of conversation, code, and tool output, and I need near-instant turns to stay in the flow. For that specific workflow the prefill latency was the dealbreaker. Past 50k tokens a simple follow-up took three to five minutes before the first token appeared, and you cannot pair program with a model that makes you wait for a cup of tea. By the time it caught up I had already moved on to the next problem. That is not DS4 being slow in some absolute sense, it is a mismatch between how that setup handles long-context prefill on my hardware and what my workflow needs. Qwen 3.5 122B looked like a better fit for the M3 Ultra on every axis I cared about: Near frontier, fully local. No API calls, no rate limits, no data leaving the machine. A 122B model that rivals proprietary systems, running entirely offline. The right active-param size for the Ultra’s bandwidth. The M3 Ultra’s memory bandwidth is large. A 122B MoE with roughly 10B active params sits in the sweet spot where the GPU can feed the compute units without stalling. A good balance of tool calling and reasoning. Enough reasoning depth for complex code logic, robust enough for reliable function calling and agent orchestration. The right size for a unified KV cache. With 96GB of unified memory, a 122B model at low bit-width leaves just enough headroom for a deep SSD-backed KV cache, which is the whole game for long-context retention without swapping. The closest serving stack I could find was rapid-mlx . But it went a different direction on hybrid attention. Given that divergence, I forked it instead. The result is qMLX , available on GitHub . The fork is specialized for hybrid attention (more on that at the end). The real work: killing three bugs # The model fits, but it was unusable out of the box for a different set of reasons that had nothing to do with the model itself: every follow-up message reprocessed the entire conversation from scratch. On a 130k-token context that is a multi-minute wait before the model emits a single word. The cause is the hybrid attention architecture, a mix of GatedDeltaNet (SSM) layers and dense attention. The recurrent state in the SSM layers cannot be rewound or trimmed to an earlier position, so to avoid a memory leak the in-memory cache drops any entry containing those layers. For this model the in-memory prefix cache misses every single time. In a normal window I measured zero in-memory hits against 109 disk hits. The only thing keeping the model warm is the disk cache: checkpoint the attention KV to SSD, restore it on the next turn. Disk restore is not a fallback here, it is the entire cache. And it kept breaking, in three separate ways, each hiding behind the last. Bug one: a timestamp in the system prompt # KV reuse is byte-exact. If the prompt changes even slightly, the match fails at the first difference and everything after it recomputes. The agent framework was stamping a unique message ID into the system prompt on every turn. That unique value, near the top of a 130k-token prompt, meant the prompt was never byte-stable. Turn two differed from turn one within the first few hundred tokens. The cached agent got thrown out, the whole system prompt rebuilt, and the match diverged early. Every turn, cold. The fix was to delete the line. The message ID was decorative, nothing read it back, and the agent already had the ID through the per-turn user message. Nothing unique-per-turn belongs in the cached prefix. Put it in the part of the prompt that is supposed to change. Bug two: the reply that never happened # System prompt fixed, and it held for a while, then broke again deeper in the conversation. When you send a message whilst the model is still replying, the agent interrupts the run. Correct behaviour. But on the interrupt path it broke out of the loop without saving the reply it had already streamed. The inference server had already decoded those tokens into its KV cache, but the history was missing the assistant turn. Divergence, deep in the body, cold fill. I proved it against the database: four of my messages stored back to back with no assistant turn between them, and a reply that had visibly streamed to the screen simply absent from history. It was never written. The fix persists the streamed reply on the interrupt path before breaking, the same recovery the code already did for a dropped network stream. As a bonus, the agent stops forgetting things it said. The general rule: if a generation’s tokens can reach the server’s cache, that generation has to be committed to history on every exit path, including the messy ones. Bug three: poison in the checkpoint store # After the first two fixes the cache held warm for turn after turn, then dropped cold exactly once on any turn that used a tool or got interrupted, then recovered. Two writers touched the checkpoint store. One wrote the real thing: a checkpoint keyed to the prompt, the one the next turn restores from. The other was a background hook writing a full checkpoint every 256 generated tokens, with no token key attached, so it could never be matched or restored. Dead weight. And it counted against the disk cap. A long tool-using turn generates a lot of tokens, which triggers a lot of those junk writes, which pushed the store over its cap, and the eviction policy dropped oldest-first, taking the one good checkpoint down with the junk. On disk at the time: 27GB of unmatchable bodies in a single directory, crowding out the checkpoints that mattered. The fix teaches eviction to drop the unmatchable checkpoints first, and gates the junk writer off entirely when restore is on. The good checkpoint survives, the next turn restores it, and the cold fills stop. Where it landed # Same conversation that used to cold-fill 30k tokens on every turn, after the fixes, growing from 31k to 57k tokens: uid=58 HIT cached=53267 prefill=670 uid=59 HIT cached=54009 prefill=33 uid=60 HIT cached=54113 prefill=1671 uid=61 HIT cached=55867 prefill=45 uid=62 HIT cached=55996 prefill=1869 Every turn restores the prior context and prefills only the new message. Sub-second where it used to be minutes. The checkpoint directory settled into a clean state, every checkpoint matchable, no junk. The cache does not just help, it changes the shape of the problem. Here is