[로컬 LLM 구축기 - 2부] 요청 하나가 답변이 되기까지
[로컬 LLM 구축기 - 1부] 외장 GPU 없이도 된다고요?이번 포스팅은 로컬 LLM 구축기의 첫 번째 시리즈입니다. 용도가 애매했던 미니 PC를 중고로 내놓으려다가 로컬 LLM 구동 가능성을 확인한 후 구축
tech-recipe.tistory.com
지난 포스팅에서는 요청 하나가 답변이 되기까지의 과정을 자세하게 따라가며 여러 가지 개념과 정의를 이해하는 시간을 가져보았습니다. 이를 통해 "느리게" 답변이 생성되는 현상도 "미니 PC의 성능이 낮아서 그렇다"라고 단순하게만 볼것이 아니라, 어느 구간에서 병목이 생기는지(모델을 로딩하는 구간, 입력을 처리하는 Prefill 구간, 답변을 생성하는 Decode 구간을 분리해서 분석)를 가늠해볼 수 있는 이론적인 토대를 닦았습니다.
또한, 실제로 모델은 처리해야 하는 토큰의 길이에 따라 성능 거동 양상이 다르게 나타납니다. 1부 말미에 짧은 질문은 겨우 24 토큰이었지만, kagent가 보내는 요청은 시스템 프롬프트와 도구 정의, 이전 대화와 도구 실행 결과가 모두 합쳐져 4.4만 ~ 4.8만 토큰에 달했습니다. 이를 처리하는데에만 6분 가까이 걸린 구간도 있었고, 대화가 길어지면서 GPU가 멈춰버리는 현상도 발생했습니다. 어느 구간에서 시간을 많이 소비했는지, 어디가 GPU를 멈추게 했는지 등을 추적하기 위해서는 로딩/Prefill/Decode 구간을 분리해서 병목을 찾아내야 합니다.
이번 포스팅에서는 모델이 처리해야하는 토큰의 크기에 따라 각 구간의 성능이 어떻게 변화하는지를 살펴보고, 제가 구성한 로컬 환경에 맞는 최적의 값을 찾기 위해 마지막 한방울까지 쥐어짜는 극한의 튜닝을 위한 테스트와 그 결과를 다뤄보고자 합니다.
| 항목 | 값 |
| 하드웨어 | GMKtec K8 Plus(Ryzen 7 8845HS, Radenon 780M, DDR5-5600 64GB) |
| OS / 커널 | Ubuntu 26.04 LTS / 7.0.0-31 |
| 추론 서버 | Ollama 0.34.1 (Vulkan, Mesa RADV) |
| 모델 | qwen3.6:35b-a3b(Q4_K_M), 컨텍스트 262,144 |
1. 신뢰할 수 있는 수치를 위한 측정 방법과 원칙
튜닝에 대해서 이야기하기 전에 측정 방법부터 살펴보겠습니다. 설정을 바꾸고 "성능이 향상되었다"라고 결론을 내기 위해서는 측정 결과를 믿을 수 있어야 합니다. 처음 로컬 LLM을 다뤄보다 보니 측정 결과를 몇 번 잘못 해석하는 실수가 있었어서 이 부분부터 잡고 가겠습니다.
1.1 운영 로그 비교의 함정
처음에는 kagent가 보낸 요청들의 로그를 보고 설정 간 성능을 비교했습니다. 뒤에 다룰 num_batch라는 설정을 2048에서 512로 변경하고, 입력 길이가 비슷한 두 요청을 보냈더니 아래와 같은 결과를 얻었습니다.
# num_batch == 2048, 입력 길이 약 2만 8천 토큰
2,048토큰 처리에 24.0초 소요 → 약 85 tokens/s
# num_batch == 512, 입력 길이 약 2만 9천 토큰
512토큰 처리에 2.16초 소요 → 약 237 tokens/s
입력 길이가 비슷한데 설정을 하나 바꾼것이 약 2.8배 정도 빨라진 것처럼 보였습니다. 그러나 num_batch를 조절하면서 같은 프롬프트(32,703 토큰)로 모델을 매번 새로 돌려가면서 Prefill 속도를 측정하니 아래와 같은 결과를 얻을 수 있었습니다.
| num_batch | 128 | 256 | 512 | 1024 | 2048 |
| Prefill(tokens/s) | 139 | 179 | 204 | 207 | 188 |
로그와 실제 측정이 이렇게 차이가 많이 나는 이유는 앞의 두 로그의 경우, 세션에서의 캐시 상태나 주변 요소가 모두 다른 상황에서 나온 값이기 때문으로, 이후에는 운영 로그를 기준으로 하는 것이 아닌, 측정 방법에 원칙을 정하고 그에 맞추어 측정하였습니다.
1.2 측정의 원칙
| 원칙 | 이유 |
| 같은 프롬프트 사용 | 입력 길이와 내용이 모델의 속도에 영향을 줌 |
| 캐시 무효화 + 모델 재로드 기준 | Prefix Caching에 따른 결과 왜곡을 방지 |
| 같은 시간대 및 유휴 상태 기준 | 다른 요청이 중간에 끼어드는 것 방지 |
| 재부팅간 상태 기준 | 부팅마다 약간의 차이가 있을 수 있음 |
위의 원칙을 코드로 옮긴 측정 스크립트의 대략적인 골자는 아래와 같습니다. Ollama API를 직접 호출하고, 응답에 포함되어 있는 prompt_eval_count, prompt_eval_duration, eval_count, eval_duration으로 속도를 계산했습니다.
# Prefill: 약 4,760토큰짜리 프롬프트 앞에 매번 다른 무작위 단어 12개(nonce)를 붙여 캐시를 무효화
x = call(prompt=nonce + " " + body, options={"temperature": 0, "num_predict": 1, "seed": 42})
prefill = x["prompt_eval_count"] / (x["prompt_eval_duration"] / 1e9)
# Decode: 짧은 고정 프롬프트로 171토큰 생성 (temperature 0이라 매번 같은 답)
x = call(prompt="Count slowly from one to sixty, one number per line.",
options={"temperature": 0, "num_predict": 200, "seed": 42})
decode = x["eval_count"] / (x["eval_duration"] / 1e9)
# 로드: 모델을 내렸다가(keep_alive=0) 다시 올리며 load_duration을 잰다
call(prompt="", keep_alive=0)
x = call(prompt="hi", options={"num_predict": 1}, keep_alive=-1)
load = x["load_duration"] / 1e9
측정 전에는 GPU가 유휴 상태인지도 확인합니다. 현재 설정에서 슬롯이 하나라, 측정 중에 다른 요청이 들어오면 측정에 영향을 주기 때문입니다.
# GPU 사용률이 0인지 확인
cat /sys/class/drm/card*/device/gpu_busy_percent
0
1.3 nonce를 붙이는 이유
같은 프롬프트를 nonce 없이 두 번 보내면 두 번째 Prefill이 약 39,000 tokens/s로 측정됩니다. 이는 2부에서 설명한 Prefix Chacing의 결과입니다. 캐시가 맞으면 Prefill을 거의 하지 않는 것과 같습니다. 자세한 내용은 이번 포스팅 마지막에 다시 한번 언급하겠습니다.
2. 로딩 - 첫 요청에 모델을 메모리로 불러오는 시간 6초 ~ 7초
세 가지(로딩/Prefill/Decode)중 가장 간단한 것부터 보겠습니다. 모델을 GPU 메모리에(우리의 경우에는 시스템 메모리 중 GTT) 올리는 시간은 약 6초 ~ 7초 정도였습니다. 1부에서 OLLAMA_KEEP_ALIVE=-1로 한번 올린 모델을 내리지 않도록 설정해서 모델을 최초 로딩하는 경우가 아니면 그 비용이 거의 0인 구간입니다.
이번 포스팅의 경우에는 단일 모델을 가지고 하는 테스트이고, 모델의 크기도 크지 않아 그 시간이 크게 의미가 없으나, 만약 큰 모델 여러가지를 서빙하는 환경이라면 이 부분도 고려해 볼 가치가 있겠습니다. 다만, 우리의 경우에는 로딩 구간보다 Prefill 구간이 문제였습니다.
3. Prefill - 입력을 처리하는 시간
3.1 GPU 담당 구간
2부에서 Prefill은 이미 주어진 입력 토큰들을 한꺼번에 모델에 통과시는 단계라고 정리했습니다. 수백 개의 토큰을 묶어서 계산하기 때문에, 메모리에서 가중치를 한 번 읽어 와 여러 토큰에 재사용할 수 있습니다. 그래서 이 구간에서는 메모리 대역폭보다 연산 장치(GPU 또는 CPU)의 계산 능력이 속도를 좌우하게 됩니다. 테스트 환경에서 GPU를 사용하는 경우와 CPU를 사용하는 경우(num_gpu: 0)을 비교해 보면 아래와 같은 결과를 확인할 수 있습니다.
# 요청 옵션으로 GPU에 올릴 레이어 수를 0으로 주면 CPU만 쓰는 러너가 다시 뜬다
curl -s localhost:11434/api/generate -d '{
"model": "qwen3.6:35b-a3b", "prompt": "hi", "stream": false,
"options": {"num_gpu": 0}
}'
# 러너 인자에 -ngl 0이 들어갔는지 확인 (나머지 인자는 GPU일 때와 같다)
$ tr '\0' ' ' < /proc/$(pgrep -x llama-server)/cmdline
... --flash-attn auto -b 512 -ub 512 -ngl 0 --context-shift --keep 4
| 테스트 항목 | GPU | CPU | 차이 |
| Prefill, 입력 토큰 4.8k | 351.4 tokens/s | 105.3 tokens/s | 3.3배 |
| Prefill, 입력 토큰 28.5k | 235.0 tokens/s | 73.8 tokens/s | 3.2배 |
Prefill은 입력 토큰의 크기와 관계없이 GPU가 CPU 대비 3배 넘게 빨랐습니다. 단, 절대적인 속도는 입력 토큰의 크기가 클수록 느려지는 모습을 보입니다. 입력 크기에 따른 상관관계는 바로 다음 챕터에서 더 자세하게 확인할 수 있습니다.
3.2 입력 토큰이 클수록 느려지는 속도
1부의 마지막 질문처럼 입력 토큰이 작을때는 Prefill이 금방 끝나게 됩니다. 하지만 kagent의 요청처럼 입력 토큰이 크면 그 양상이 완전히 달라집니다. Attention은 새로 들어온 토큰마다 앞의 모든 토큰을 참조해야 하기 때문에, 입력이 길수록 토큰 하나를 처리하는 비용이 커집니다. 아래는 같은 조건에서 입력의 길이만 바꿔 가며 측정한 결과입니다.(이번 측정에도 매번 앞에 무작위 단어를 붙여 캐시를 무효화하였습니다.)
| 입력 토큰 크기 | Prefill (tokens/s) | Prefill에 걸린 시간 |
| 4,763 | 351.4 | 14초 |
| 28,493 | 235.0 | 212초 |
| 42,717 | 189.4 | 226초 |
| 66,443 | 139.4 | 447초 |
| 94,908 | 104.9 | 905초 |
| 118,622 | 84.7 | 1,400초 |

표의 Prefill 속도는 입력 전체를 처음부터 처리했을 때의 평균입니다. 입력이 2.8만 토큰에서 6.6만 토큰으로 두 배 조금 넘게 늘자 속도는 235 tokens/s에서 139 tokens/s로 약 0.6배가 되었고, 4.3만 토큰에서 9.5만 토큰으로 늘 때는 약 0.55배가 되었습니다. 입력 토큰 크기가 2배가 되면 Prefill 속도는 대략 1/2가 되는 양상을 보였습니다. 약 11.9만 토콘을 캐시 없이 처음부터 처리하면 현재 제가 가진 미니 PC로는 23분이나 걸리게 됩니다.
3.3 num_batch란 무엇인가?
1부에 ollama show로 모델 정보를 확인했을 때 Paramaters 목록에 num_batch 512가 있었습니다. 이것은 Prefill 때 입력을 몇 토큰씩 묶어 GPU에 넣을지를 나타내는 값을 의미합니다. 배치 숫자가 클수록 GPU의 연산 유닛을 동시에 더 많이 쓰게 되고 따라서 GPU가 한 번에 처리해야 하는 계산량도 커집니다.
# Modelfile
FROM qwen3.6:35b-a3b
PARAMETER num_batch 512
# 같은 이름으로 다시 만들면 기존 태그에 파라미터가 더해진다
docker exec -i ollama ollama create qwen3.6:35b-a3b -f - < Modelfile
Ollama에서는 이 값을 서버 전체에 적용하는 환경 변수가 없어서, 위 예시처럼 모델 태그에 직접 넣어주어야 합니다. 다만 이렇게 넣은 값은 ollama pull로 모델을 다시 받으면 사라지므로 주의가 필요합니다.
num_batch의 값을 통해 배치 크기를 바꿔 가며 작은 토큰 입력과 큰 토큰 입력에 대해 각각 Prefill 속도를 측정해 보았습니다. 결과는 아래 표와 같습니다.
| 배치 크기 | Prefill, 입력 토큰 4.8k (tokens/s) | Prefill, 입력 토큰 28.5k (tokens/s) |
| 128 | 233.3 | 188.3 |
| 256 | 298.4 | 218.9 |
| 512 | 352.9 | 237.4 |
| 1024 | 384.0 | 227.5 |
| 2048 | 397.2 | 213.5 |

여기가 정말 특이한 구간인데, 입력 토큰이 작은 경우(짧은 입력)에는 배치가 클수록 Prefill 속도가 빨랐고, 입력 토큰이 큰 경우(긴 입력)에는 512가 가장 빨랐고, 2048은 오히려 10% 정도 느려졌습니다. 왜 이런 현상이 일어나는지는 아직 특정하지 못했습니다.
어쨌든, 로컬 LLM 모델을 어떤 작업에 쓰느냐에 따라 배치 크기를 적절히 조절해서 사용하는 것이 유리해 보였습니다. kagent와 같이 대부분의 요청이 수만 토큰 단위인 경우에는 배치 크기를 512로 하는 것이 유리할 것이고, 요청의 크기가 작은 다른 용도로 사용한다면 배치 크기를 키우는 것이 성능상 더 유리해 보였습니다.
3.4 GPU 워치독 - 커널 드라이버의 안전장치
kagent로 테스트를 진행하면서, 한 세션에서 대화가 길어지면 GPU가 멈추고 요청이 실패하는 일이 반복되었습니다. 원인은 amdgpu 커널 드라이버의 워치독(watchdog)이었습니다. 워치독은 GPU에 보낸 작업 하나가 정해진 시간(기본값 2초) 안에 끝나지 않으면, GPU가 멈췄다고 판단하고 리셋합니다. GPU는 앞 작업이 끝나야 다음 작업을 처리하기 때문에, 작업 하나가 끝나지 않으면 그 GPU를 쓰는 모든 작업이 멈추게 됩니다. 그런데 드라이버는 오래 걸리는 작업과 멈춘 작업을 구별할 수 없어서, 시간만 보고 판단합니다.
여기서 재미있는 점은 앞에서 본 num_batch와 입력 길이의 관계가 다시 등장한다는 것입니다. kagent 요청이 실패한 것은 입력이 약 4만 6천 토큰이고 num_batch가 2048일 때였습니다. 이때 GPU에서는 Prefill의 Attention 계산 하나가 2초 넘게 걸리고 있었고, 워치독은 이를 GPU가 멈춘 것으로 판단해 리셋했습니다.
Prefill에서는 입력을 num_batch 만큼씩 조각으로 나누어 처리합니다. 조각 하나가 레이어를 하나씩 통과할 때마다 여러 개의 GPU 커널(GPU에서 실행되는 작은 계산 프로그램)이 GPU에 들어갑니다. 이 중 Attention 커널은 조각의 토큰마다 앞에 쌓인 토큰 전부를 참조하기 때문에, 걸리는 시간이 배치 크기와 입력 길이에 모두 비례합니다.
배치 2048로는 입력 길이 4만 토큰 부근이 한계였는데, 당시 kagent의 요청은 4만 4천 ~ 4만 8천 토큰이었습니다. 그래서 배치를 512로 줄였습니다. 전체 계산량은 같지만 한 번에 넣는 양이 1/4이 되니, 커널 시간도 1/4로 줄어듭니다.
배치 2048, 입력 길이 46,000 → 약 2.3초 ← 기본 한도 2초 초과
배치 512, 입력 길이 46,000 → 약 0.58초 ← 커널 처리 시간 약 1/4로 감소
그런데 컨텍스트 상한을 131,072에서 262,144로 올리면서 문제가 다시 생겼습니다. 배치 512에 한도 2초로는 입력 16만 토큰까지만 버틸 수 있고, 상한 인 26만 토큰까지 차면 커널 하나의 처리 시간이 약 3.3초 걸립니다. 대응책은 두 가지로, 배치를 256으로 더 줄이거나 워치독의 한도를 늘리는 것입니다. 앞선 num_batch의 테스트 결과에 따라 큰 입력 토큰에서 가장 효율이 좋은 512를 유지하기 위해서 한도를 10초로 늘리는 선택을 했습니다.
# /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="amd_iommu=off ttm.pages_limit=12582912 amdgpu.lockup_timeout=10000"
# 재부팅 후 확인
$ cat /sys/module/amdgpu/parameters/lockup_timeout
10000
| 배치 크기 | 워치독 한도 2초 최대 입력 토큰 수 | 워치독 한도 10초 최대 입력 토큰 수 |
| 2048 | 40,000 | 200,000 |
| 512 | 160,000 | 800,000 |
| 256 | 320,000 | 1,600,000 |
이를 통해 배치 512로 컨텍스트 상한(262,144)의 세배 이상까지 버틸 수 있게 되었습니다. 대신 GPU가 정말로 멈춘 경우 복구까지 10초가 걸린다는 문제가 있으나, 헤드리스 서버이므로 감수할만한 리스크라 판단하였습니다.
3.5 IOMMU off의 실제 효과
1부에서 BIOS에서 IOMMU를 끄는 세팅을 했기 때문에 실제로 이 설정이 켜졌을 때와 꺼졌을 때 어떤 차이가 나는지도 궁금했습니다. 그래서 BIOS 설정에서 IOMMU를 켜고, 커널 파라미터를 바꿔가면서 IOMMU on / off 설정에 대한 측정을 하였습니다. 부팅의 효과를 고려하여 각 설정을 교차로 하여 IOMMU off 3회, on 2회 측정한 결과는 아래와 같습니다.
| 부팅 | 조건 | Prefill (tokens/s) | Decode (tokens/s) | 로드 (초) |
| A1 | IOMMU off | 348.830 | 39.523 | 5.920 |
| B1 | IOMMU on | 335.057 | 38.850 | 5.986 |
| A2 | IOMMU off | 349.406 | 39.649 | 5.973 |
| B2 | IOMMU on | 334.492 | 38.871 | 5.982 |
| A3 | IOMMU off | 349.837 | 39.571 | 5.941 |

IOMMU off는 확실히 효과가 있었습니다. 특히 Prefill에서 약 4% 정도 유의미한 차이를 보였고, Decode도 약 1.8% 정도의 차이를 보였습니다. 단, 관련해서 외국 자료에서는 IOMMU on / off가 약 10% 정도 성능 차이가 난다는 실험 결과가 있었는데, 저의 환경에서는 그에는 미치지 못하는 이유로는 MoE 모델의 특징 때문이라 추정하고 있습니다. MoE 라우터가 Prefill시 매 레이어마다 256개 전문가 중 8개를 골라 읽는 과정에서 메모리 곳곳을 흩어져 읽게 되고 변환 캐시가 자주 빗나가기 때문이 아닌가 합니다. 어쨌든, 최종적으로는 BIOS에서 IOMMU를 끄고, 커널 파라미터도 그대로 끄는 것으로 남겨 두었습니다.
3.6 ROCm vs Vulkan
2부에서 AMD에는 ROCm이라는 GPU 연산 스택이 있지만 GPU 모델에 따라 지원 범위가 다르다고 짧게 언급했었습니다. 실제로 780M은 ROCm의 공식 지원 목록에 없기 때문에 Vulkan을 사용하는 것이 정석이나, 외국 자료를 통해 780M을 비슷한 세대의 지원 GPU로 속여 ROCm으로도 돌릴 수 있다는 것을 알아냈습니다. 이 둘을 비교해 보니 ROCm 쪽이 조금 더 좋은 결과를 보이기는 했습니다.
# ROCm으로 돌릴 때 docker-compose.yml에서 달라지는 부분
image: ollama/ollama:rocm # 실제 운영에서는 버전과 다이제스트로 고정
devices:
- /dev/dri:/dev/dri
- /dev/kfd:/dev/kfd # ROCm 연산 장치
environment:
- HSA_OVERRIDE_GFX_VERSION=11.0.0 # gfx1103을 gfx1100으로 속인다
# OLLAMA_VULKAN=1은 뺀다
| 항목 | Vulkan 0.34.1 | ROCm 0.34.1 |
| Prefill 4.8k | 351.0 | 353.1 |
| Prefill 28.5k | 234.0 | 257.6 |
| Prefill 42.7k | 188.4 | 219.7 |
| Decode (4.8k 입력) | 39.4 | 40.0 |
| Decode (28.5k 입력) | 26.4 | 26.7 |
| Decode (42.7k 입력) | 23.9 | 24.3 |
| 모델 로드 (초) | 7.19 | 8.21 |
위 표에서 알 수 있듯 긴 입력의 Prefill에서 ROCm이 훨씬 더 빨랐습니다. Vulkan은 입력 컨텍스트가 클수록 속도가 더 빠르게 떨어지는 반면, ROCm은 그 감소량이 덜한 것을 확인할 수 있습니다. 다만 여기에서는 Vulkan을 사용하기로 했습니다. 안정성 면에서 특정 ROCm 버전은 제대로 작동하지 않는 것을 확인했기 때문입니다.
4. Decode - 여전한 메모리 대역폭의 한계
4.1 메모리 대역폭 실측
# 배열 2억 개(× 8바이트 × 3개 = 약 4.8GB)로 캐시의 영향을 없애고, 20회 중 최고값을 본다
gcc -O3 -march=native -fopenmp -DSTREAM_ARRAY_SIZE=200000000 -DNTIMES=20 -mcmodel=medium stream.c -o stream
OMP_NUM_THREADS=4 ./stream
| 스레드 | Copy | Triad |
| 1 | 48.3 GB/s | 38.1 GB/s |
| 4 | 62.6 GB/s | 44.6 GB/s |
| 8 | 61.2 GB/s | 43.3 GB/s |
| 16 | 59.6 GB/s | 43.2 GB/s |

STREAM으로 잰 대역폭은 약 62GB/s로, 이론값의 70% 정도이며 1부에서 예측한 값보다 낮은 수치입니다. STREAM 측정값으로 Decode 속도의 상한을 계산해 보면 다음과 같습니다.
토큰 하나를 만들 때 읽는 양 ≈ 활성 파라미터 3.6B × 약 0.6바이트(Q4_K_M) ≈ 2.1 ~ 2.2GB
실측 대역폭 약 62GB/s ÷ 2.2GB ≈ 초당 약 28회 읽기
하지만 측정 스크립트로 잰 Decode는 약 39.4 tokens/s로, 계산한 상한보다 높았습니다.
4.2 추측 디코딩(MTP)을 통한 물리적 한계 극복
1부의 ollama show 출력에는 draft_num_predict 2라는 파라미터도 있었습니다. 이는 모델이 MTP(Multi-Token Prediction)라는 추측 디코딩을 지원한다는 의미입니다. Ollama 문서에 따르면 draft_num_predict를 0으로 두면 추측 디코딩이 꺼집니다. 켰을 때와 껐을 때를 비교한 결과는 아래 표와 같습니다.
# 요청 옵션으로 초안 개수를 0으로 주면, 러너가 추측 디코딩 없이 다시 뜬다
curl -s localhost:11434/api/generate -d '{
"model": "qwen3.6:35b-a3b", "prompt": "hi", "stream": false,
"options": {"draft_num_predict": 0}
}'
# 러너 인자에서 --spec-* 옵션이 빠졌는지 확인
$ tr '\0' ' ' < /proc/$(pgrep -x llama-server)/cmdline
... --mmproj /root/.ollama/models/blobs/sha256-… --flash-attn auto -b 512 -ub 512 --context-shift --keep 4
| 항목 | MTP 켬 | MTP 끔 | 차이 |
| Decode, 짧은 입력 | 39.4 | 28.4 | +39% |
| Decode, 28.5k 입력 | 27.4 | 22.5 | +22% |
| Prefill, 4.8k | 351.4 | 367.7 | -4% |
| Prefill, 28.5k | 235.0 | 250.0 | -6% |
| Prefill, 42.7k | 189.4 | 203.3 | -7% |

MTP를 끄자 짧은 입력의 Decode는 28.4 tokens/s로 떨어져 앞서 STREAm 측정값으로 계산한 초당 약 28 tokens/s와 거의 같은 값을 나타냅니다. 이를 상회하는 약 11 tokens/s는 MTP 덕분이라는 것을 알 수 있었습니다.
4.3 입력 토큰이 크면 Decode도 느려진다
| 입력 토큰 크기 | Decode (tokens/s) |
| 28,493 | 27.4 |
| 42,717 | 22.0 |
| 66,443 | 20.3 |
| 94,908 | 16.9 |
| 118,622 | 14.3 |
입력이 클수록 Decode도 느려지는 양상을 보였습니다. 토큰 하나를 만들 때마다 모델 가중치와 함께 그동안 쌓인 KV Cache도 읽어야 하기 때문으로, 약 11.9만 토큰일 때 KV Cache가 약 2.4GB로 토큰 하나에 읽는 모델 가중치(약 2.2GB)와 비슷한 양이됩니다. 그래서 대략 2.8만 토큰에 비해 절반 정도 속도가 나오는 것을 확인할 수 있습니다.
5. 정리
5.1 같은 스크립트로 측정한 결과
| 측정 대상 | 조건 | Prefill, 4.8k (tokens/s) | Decode (tokens/s) | 로드 (초) |
| IOMMU 실험 | IOMMU off, 부팅 3회 | 349.4 | 39.6 | 5.9 |
| IOMMU on, 부팅 2회 | 334.8 | 38.9 | 6.0 | |
| 백엔드 비교 | Vulkan 0.34.1 | 351.0 | 39.4 | 7.2 |
| ROCm 0.34.1 | 353.1 | 40.0 | 8.2 |
5.2 설정별 효과
| 설정 | 측정한 효과 | 영향 구간 |
| GPU 사용(GTT 48GB) | CPU 대비 Prefill 3.2배 ~ 3.3배, Decode 최대 5.2배 | Prefill, Decode |
| IOMMU off | Prefill + 4.2%, Decode + 1.8% | Prefill, Decode |
| num_batch 512 | 긴 입력에서 가장 빠른 Prefill | Prefill |
| 워치독 10초 | GPU 멈춤 방지 | Prefill |
| MTP(draft_num_predict 2) | Decode + 22% ~ 39%, Prefill -4% ~ -7% | Decode, Prefill |
| Vulkan유지(ROCm 대신) | 큰 입력 토큰에 Prefill + 10% ~ 17% 포기 | Prefill |
5.3 최종 구성
# BIOS
UMA Frame Buffer Size = 1G, IOMMU = Disabled, SVM = Disabled, EXPO 5600, 전력 모드 Performance
# 커널 (/etc/default/grub)
GRUB_CMDLINE_LINUX_DEFAULT="amd_iommu=off ttm.pages_limit=12582912 amdgpu.lockup_timeout=10000"
# docker-compose.yml (ollama 서비스)
image: ollama/ollama:0.34.1@sha256:... # 버전과 다이제스트로 고정
environment:
- OLLAMA_VULKAN=1
- OLLAMA_IGPU_ENABLE=1
- OLLAMA_CONTEXT_LENGTH=262144
- OLLAMA_KEEP_ALIVE=-1
# 모델 태그 (Modelfile)
PARAMETER num_batch 512
# 부팅 시 모델 프리로드: systemd oneshot 유닛
6. 마치며
이번 포스팅에서는 "느리다"를 로딩/Prefill/Decode의 세 가지로 구간으로 나누오, 각각 무엇을 측정하고 튜닝했는지를 정리해 보았습니다. Prefill에서는 GPU 사용, 배치 크기, IOMMU, 백앤드 선택에 따라 몇 %에서 수십 % 까지 차이가 났습니다. Decode는 메모리 대역폭의 한계가 절대적이라는 것을 확인할 수 있었습니다. 또한, 직접 적지는 않았으나 테스트 간에 캐시 적중에 의한 Prefill 속도의 극적인 향상도 한번 더 확인할 수 있었습니다.
다음 시간에는 그럼에도 불구하고 kagent가 로컬 LLM으로 보낸 요청에서 캐시가 한 번도 맞지 않았던 것에 대한 원인을 추적한 과정과, 그 끝에 오픈소스 PR까지 가게 된 이야기를 정리해 보도록 하겠습니다.
긴 글 읽어주셔서 감사합니다.
'Study > CS & AI' 카테고리의 다른 글
| [로컬 LLM 구축기 - 2부] 요청 하나가 답변이 되기까지 (0) | 2026.09.15 |
|---|---|
| [로컬 LLM 구축기 - 1부] 외장 GPU 없이도 된다고요? (0) | 2026.09.06 |
| [Cilium Study] 8주차 - 네트워크를 넘어 커널까지: Cilium Security와 Tetragon (0) | 2025.09.07 |
| [Cilium Study] 7주차 - 대규모 클러스터를 위한 Kubernetes와 Cilium 성능 분석 및 튜닝 (1) | 2025.08.30 |
| [Cilium Study] 6주차 - Sidecar를 넘어, eBPF로 구현하는 서비스 메시 (5) | 2025.08.23 |



































































