토큰 도착 속도는 동일했지만 렌더링이 따라가지 못했다: 로컬 LLM 스트리밍에서 측정된 90배의 디스플레이 지연 (및 Ollama가 이를
요약
로컬 LLM IDE 개발 과정에서, 토큰 도착 속도와 별개로 UI 렌더링 지연이 심각한 문제를 야기함을 발견했습니다. Ollama의 특정 버전에서는 모든 CPU 코어를 사용했을 때 디스플레이 지연 시간이 최대 90배까지 악화되는 현상이 측정되었습니다. 이는 생성 속도가 아닌, 사용자 경험(UX) 관점에서의 성능 문제임을 보여줍니다.
핵심 포인트
- 토큰 도착 속도와 UI 렌더링 지연은 별개의 문제이다.
- 모든 코어 할당 시 디스플레이 지연이 심각하게 발생했다 (P95 기준).
- Ollama 업데이트를 통해 이 격차가 크게 개선되었으며, 스레드 제한 설정의 필요성을 제기한다.
저는 로컬-LLM 코딩 IDE를 만들었고, 이상한 점을 발견했습니다. 모델에 모든 CPU 코어를 할당했을 때 앱이 더 느리게 느껴졌습니다. 토큰은 같은 속도로 도착했지만, UI가 뒤처지면서 지연되었습니다. 그래서 저는 이를 측정했고, 검증 도중 새로운 Ollama 릴리스가 모든 것을 바꿔놓았습니다.
요약(TL;DR) — Ollama 0.21.0에서 6개 코어 전체로 추론을 실행하는 것과 4개 코어로 실행하는 것은 동일한 토큰 속도를 보였지만, 디스플레이 지연은 90배 나빴고 (P95 181ms → 2ms), UI 클릭 응답도 6배 더 나빴습니다 (929ms → 158ms). Ollama 0.40.2에서는 실행기(runner) 내부가 변경되면서 그 격차가 거의 사라졌습니다. 그럼에도 불구하고 저는 스레드 제한 설정(thread-cap setting)을 구현했는데, 그 이유를 설명합니다.
제가 실제로 측정하고 싶었던 것
생성 속도가 아니었습니다. API tok/s는 측정하기 쉽지만, 제 질문은 **'API가 반환한 내용이 화면에 얼마나 빨리 표시되는가?'**였습니다.
가설: 모든 코어를 할당하면 LLM이 CPU를 포화시키고, 렌더러(renderer)가 부족해져서 들어오는 토큰을 제때 그릴 수 없습니다. 코어 수가 적으면 생성 속도는 거의 동일하지만 디스플레이는 부드럽게 유지되므로 느낌상 더 빠릅니다.
'느낌'을 정량화하기 위해, 저는 클럭(clock)들을 분리했습니다:
- 와이어 도착 시간 (Wire arrival time) — IDE와 Ollama 타임스탬프 사이의 얇은 프록시로, 모든 NDJSON 청크마다 측정됩니다. 렌더러가 부족한 상황에서도 정확합니다.
- DOM 렌더 시간 (DOM render time) — MutationObserver가 시간에 따른 스트리밍 텍스트 길이를 기록하여, 청크별 도착→그리기 지연을 제공합니다.
- 이벤트 루프 지연 (Event-loop delay) — 100ms setTimeout의 드리프트(drift)입니다.
- 유효 FPS (Effective FPS) — requestAnimationFrame 호출 횟수입니다.
- UI 클릭 측정 (UI click probe) — 생성 중에 실제 UI 버튼을 클릭하고 응답 시간을 측정합니다.
- 고정 조건 (Fixed conditions) — temperature 0, fixed seed, num_predict 600, think: false.
환경: Ryzen 5 4500U (6 논리 코어), CPU 추론, Windows. '모든 코어'와 '4개 코어'를 비교했으며, 60초 동안의 중앙값(median)을 측정했습니다. 사용된 도구는 repo의 e2e/bench-threads.mjs (Playwright + measurement proxy)입니다.
파트 1: Ollama 0.21.0 — 속도는 같지만, 눈에 보이지 않는 지연은 90배 더 심했다
| Metric | All 6 cores | 4 cores |
|---|---|---|
| Wire-arrival speed (≈ generation) | 4.6 chars/s | 4.7 chars/s |
| ... | ||
| 세 가지 주요 시사점: |
- 생성 속도는 동일했습니다 — 디코딩(decode) 과정은 메모리 대역폭 제한(memory-bandwidth bound)이므로, 스레드를 6개에서 4개로 줄여도 토큰 생산 속도가 느려지지 않았습니다.
- 디스플레이 지연 시간(Display lag)은 P95 기준으로 약 90배 차이가 났습니다 — 모든 코어에서 토큰이 나타나기까지 최대 반 초를 기다렸습니다. 스트리밍 출력에 "지연" 현상이 발생합니다.
- One UI 클릭 시간이 929ms 대 158ms (~6배) — 이는 "LLM을 실행하는 동안에도 가벼운 작업을 할 수 있는지"의 간접적인 지표입니다.
이전 엔진에서는 러너(runner)가 생성 과정 중 약 5.7개의 코어를 지속적으로 점유했습니다. "4코어가 더 빠르다"는 것은 착각이 아니었습니다 — 디스플레이 파이프라인에 실제 지연으로 존재했습니다.
파트 2: Ollama 0.40.2 — 업그레이드 후 전제가 바뀌다
게시하기 전에 '0.21.0 버전은 너무 오래된 것 아닌가?'라는 생각이 들어 0.40.2 버전으로 다시 실행해 보았습니다. 격차가 거의 사라졌습니다:
| Metric | num_thread=6 | num_thread=4 |
|---|---|---|
| Wire-arrival speed | 7.6 chars/s | 7.5 chars/s |
| ... | ||
| 9.6GB 모델에서도 같은 현상이 나타났습니다 (lagP95: 5ms 대 3ms). 러너 프로세스를 검사하여 그 이유를 확인했습니다: |
| Ollama 0.21.0 | Ollama 0.40.2 | |
|---|---|---|
| Runner | ollama runner (구 엔진) | llama-server (llama.cpp) |
| ... |
- 기본값이 변경되었습니다: 이제 지정하지 않으면 기본적으로 3개의 스레드를 사용하며, 애초에 모든 코어를 점유하지 않습니다.
- 명시적으로 num_thread=6을 설정해도 포화되지 않습니다: 디코딩은 메모리 제한적(memory-bound)이며, 새 엔진의 스레드는 회전하는 대신 단순히 대기합니다.
- 여유 공간이 생겼습니다: 항상 1.5~2개의 여분의 코어가 있어 렌더러/OS/다른 앱들이 선점할 수 있습니다. UI 기아 현상이 발생하지 않습니다.
- 프리필(Prefill)도 동일하게 작동합니다 — 프롬프트 처리 중에도 UI 응답 시간이 90–180ms를 유지했습니다.
"모든 코어를 사용하면 버벅거린다"는 경험은 이전 엔진 설계의 실제 속성이었으며, 새 엔진이 내부적으로 이를 수정했습니다.
그렇다면 스레드 제한 설정은 쓸모가 없을까요? 그렇지 않습니다.
설정을 유지해야 하는 두 가지 이유가 있습니다:
- 튜닝이 아닌 보장(Guarantee, not tuning). 엔진 내부 구조는 버전마다 변경됩니다. 명시되지 않은 기본값은 한 릴리스에서 6개에서 3개로 이동했습니다.
num_thread는 장치에서 실행되는 버전, 모델 또는 기타 무엇에도 관계없이 고정된 명시적 상한선입니다. - API 접근성(API reach). 이 옵션은 Ollama의 네이티브
/api/chat에서만 존재합니다.
보너스 발견: "사고 과정(thinking)" 스위치
측정하는 동안, 저는 모델(qwen3.5:4b, 사고 과정을 거치는 모델)이 단순한 "hello"에 답하기 전에 긴 추론 과정(입력 분석, 인사말 후보 생성, 스타일 다듬기 등)을 생성한다는 것을 발견했습니다. 이 추적 과정만으로도 일반 PC에서 수십 초에서 몇 분이 소요됩니다. Ollama의 네이티브 API에는 think 플래그가 있습니다. think:false로 설정하면 이를 억제할 수 있습니다 (검증됨: 저희 테스트에서는 사전 콘텐츠 청크(pre-content chunks)가 전혀 없었습니다). 실질적인 로컬 LLM 속도를 위해서는 이 스위치가 쓰레드 제한만큼 효과적입니다.
이것이 도구 차별화로 이어지는 지점
여기가 이 기능을 제품 기능으로 만든 부분입니다: 이러한 옵션은 Ollama의 네이티브 /api/chat에서만 존재합니다.
| 원하는 것 | Ollama /api/chat | OpenAI 호환 /v1 |
|---|---|---|
| CPU 코어 제한 | options.num_thread | 전달할 방법 없음 |
| ... |
대부분의 VS Code 확장 에이전트는 "로컬 LLM용 CPU 코어 수"를 설정으로 노출하지 않습니다. OpenAI 호환 클라이언트가 사용될 경우, 이를 전송할 채널 자체가 없습니다. 로컬 LLM을 실행하면서 호스트 PC를 다운시키지 않으려면, 네이티브 API의 옵션에 접근하는 설계가 필요합니다.
저는 제가 유지 관리하는 무료 독립형 Electron AI 코딩 IDE인 Teaspoon IDE v1.2.0에서 이를 "CPU Threads" 및 "Thinking" 설정으로 구현했으며, IDE의 Ollama 경로는 /api/chat로 이동했습니다. 이 IDE는 BYOK(Bring Your Own Gemini key) 방식이거나 Ollama를 통한 완전 오프라인 사용이 가능하며, FSL-1.1-MIT 하에 소스 코드가 공개되어 있습니다.
제품 페이지: https://cuculhart.com/teaspoon.en.html
교훈
- 토큰당 초(tok/s)는 경험의 절반만을 설명한다. — 생성 속도는 동일했지만, 부족한 렌더링이 측정된 90배의 지연을 발생시켜 체감 속도를 변화시켰다.
- '모든 것을 사용하라'가 항상 최적은 아니다. — 그리고 '얼마나 많이 사용할지'는 버전별로 변경되는 엔진 내부 구조에 따라 달라진다.
- 최신 환경에서 재검증하라. — 새 릴리스에서 재테스트한 결과, 이 현상이 엔진 의존적이라는 것이 밝혀져 글이 약해지는 대신 더 강력해졌다.
- UI 반응성은 측정 가능하다. — 와이어 분할 시간(split wire time)과 DOM 시간을 나누어 측정함으로써 '느리게 느껴진다'는 것이 숫자가 된다.
- 로컬 LLM 기능은 API 도달 범위에 의해 제한된다. — 호환성 API의 공통분모에 국한된 디자인으로는
num_thread나think를 구현할 수 없다.
방법론: e2e/bench-threads.mjs를 활용하고, 원시 데이터는 e2e/bench-threads-results.json에서 가져왔다 — 다음 명령어로 재현했다: BENCH_THREADS=6,4 BENCH_RUNS=3 BENCH_MODEL=qwen3.5:4b node e2e/bench-threads.mjs. n=2–3을 단일 Ryzen 5 4500U에서 수행했으므로, 정확한 수치는 단일 머신 데이터로 간주하고; 측정 방법론이 재사용 가능한 부분이다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기