Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Mac GPU와 Kubernetes로 LLM 운영 기초 살펴보기

로컬 LLM을 띄울 장비도 없고 사실 관심도 크진 않지만, 인프라 입장에서도 GPU 운영이 점점 중요해지고 있다. 그래서 GPU를 사용한 LLM 운영을 어느 정도 알 필요가 있었다. 실제로 운영한다면 LLM 모델의 성능도 중요하겠지만 나는 LLM 인프라 운영을 좀 배워보고 싶었기에 모델의 성능은 별로 중요하지 않았다.

이는 로컬 LLM을 돌리기 위한 구성이 아니라 간단한 로컬 구성을 통해 실제로 Kubernetes 클러스터에서 LLM을 운영할 때 어떻게 동작하고 어떤 부분을 고려해야 하는지 전체 흐름을 이해하기 위함이다.

LLM 모델 운영을 위한 구성

LLM 모델을 띄우기 위해 간단히 Kubernetes 환경을 구성했다.

Mac 클라이언트의 HTTP 요청이 kiac 클러스터 Linux VM의 TinyLlama Pod로 들어가 llama.cpp, Vulkan, Venus를 거쳐 Mac Studio의 M5 Max GPU에 전달되는 구성도.

로컬 Mac 환경이지만 최대한 현실에 가깝게 Kubernetes 클러스터에서 운영하기 위해 kiac이라는 프로젝트를 운영했다. kiac은 엄청 유명한 프로젝트는 아니지만 Apple의 Container 프레임워크를 사용해서 로컬에서 Kubernetes 클러스터를 운영할 수 있는 프로젝트이지만 이번 GPU 모드에서는 krunkit으로 Linux VM을 실행한다. 실제로 운영하려면 좀 더 세밀하게 검토해야겠지만, GPU 연결이 가능한 Kubernetes 환경만 필요해서 채택했다.

TinyLlama라는 11억 개의 매개 변수를 가진 모델을 양자화한 TinyLlama-1.1B-Chat-v1.0-GGUF를 사용했고, 용량은 668MB 정도다.(이것보다 큰 모델을 써도 가능은 하겠지만 그냥 간단하게 구성하고 싶었다.)

이 TinyLlama 모델을 실행하기 위해 Pod 안에서 LLM 추론 프로그램인 llama.cpp를 실행했다. llama.cpp가 /completion HTTP API를 제공하므로 이를 통해 모델의 응답을 받을 수 있다.

GPU 계산은 Vulkan API를 사용했다. CUDA가 NVIDIA용 컴퓨팅 API라면, Vulkan은 여러 GPU 제조사와 운영체제를 위한 그래픽 및 컴퓨팅 API라고 할 수 있다. macOS에는 Metal 프레임워크가 있지만 Linux Pod에서 바로 사용할 수 없으므로 Vulkan을 이용하고 Venus를 이용해서 Vulkan 요청을 호스트로 통신할 수 있게 한다. 물론 최종적으로 Metal이 사용된다.

필요한 구성이 되어 있는지 확인해 보자.

1$ tools/kiac-lab.sh doctor
2
3⬢ kiac v0.11.0 · Kubernetes in Apple Containers
4 ✓ Apple GPU backend krunkit, vmnet-helper, and the Venus renderer are ready
5kiac v0.11.0
6Client Version: v1.37.0
7Kustomize Version: v5.8.1
8Cluster: inference-gpu
9Project kubeconfig: /Users/outsider/demo/.lab/kiac/kubeconfig

환경 검사가 통과하기 위해서는 아래 의존성들이 필요하다.

  • krunkit 1.3.2 : macOS에서 Linux 가상 머신 실행
  • vmnet-helper 0.13.0 : 가상 머신을 macOS 네트워크에 연결
  • virglrenderer-krun 0.10.4e : Venus를 통해 전달된 GPU 요청을 호스트에서 처리하는 렌더러
  • molten-vk 1.4.2 : Vulkan API를 Apple Metal로 연결

Kubernetes 클러스터 띄우기

아래는 테스트에서 사용할 클러스터의 구체적인 형태이다.

Mac 터미널의 kubectl 요청이 kiac 쿠버네티스 클러스터의 API server로 가고, GPU 워커 노드의 llama-server가 Vulkan과 Venus를 거쳐 M5 Max GPU에서 실행되는 구성도.

이 클러스터에는 control plane 노드 1대와 GPU가 연결된 워커 노드 1대가 있다.

하나씩 살펴보기 전에 일단 클러스터를 띄워보자. 여기서는 k3s를 사용해서 Kubernetes 클러스터를 설정한다.

 1$ tools/kiac-lab.sh up
 2⬢ kiac v0.11.0 · Kubernetes in Apple Containers
 3 ✓ Apple GPU backend krunkit, vmnet-helper, and the Venus renderer are ready
 4⬢ kiac v0.11.0 · Kubernetes in Apple Containers
 5 ✓ Preflight checks for Apple GPU VMs (300ms)
 6 ✓ Preparing verified Fedora GPU image (20.4s)
 7 ✓ Preparing verified K3s artifacts (3.2s)
 8 ✓ Booting 2 krunkit node VM(s) (8.7s)
 9 ✓ Provisioning K3s nodes (15.5s)
10 ✓ Installing registry trust (0s)
11 ✓ Initializing K3s control plane (3.5s)
12 ✓ Joining 1 K3s worker(s) (800ms)
13 ✓ Waiting for nodes to be Ready (5s)
14 ✓ Discovering real Apple GPU nodes (600ms)
15 ✓ Installing Apple GPU device plugin (47.2s)
16 ✓ Waiting for schedulable GPU capacity (200ms)
17   resource kiac.dev/gpu is ready on 1 node(s); CUDA/NVML are not emulated
18 ✓ Labeling primary addon node (200ms)
19 ✓ Installing LoadBalancer (kiac-lb) (500ms)
20 ✓ Installing edge proxy (large upload fix) (1.7s)
21 ✓ Writing kubeconfig (100ms)
22
23K3s cluster "inference-gpu" with 1 real Apple GPU worker(s) is ready in 1m48s.
24   context kiac-inference-gpu merged into /Users/outsider/demo/.lab/kiac/kubeconfig
25  kubectl get nodes -L kiac.dev/gpu.present,kiac.dev/gpu.api
26NAME                               STATUS   ROLES           AGE   VERSION
27kiac-inference-gpu-control-plane   Ready    control-plane   56s   v1.37.0+k3s1
28kiac-inference-gpu-gpu-1           Ready    <none>          53s   v1.37.0+k3s1

앞에서 얘기한 대로 control plane 노드와 GPU 워커 노드가 각각 1대씩 떠 있는 걸 볼 수 있다. 다음은 kiac의 클러스터 설정 파일이다.

 1# cluster.yaml
 2name: inference-gpu
 3distro: k3s
 4k8sVersion: "v1.37.0-k3s1"
 5workers: 0
 6gpuWorkers: 1
 7gpuImage: fedora-44
 8gpuDiskSize: 20G
 9gpuResourceDriver: device-plugin
10cpus: "4"
11memory: 6G
12cpMemory: 3G
13wait: 12m
14addons:
15  metrics: true
16  storage: true
17  loadBalancer: true
18  edgeProxy: true
19  observability: false
20  gateway: false

gpuResourceDriver: device-plugin로 되어 있는데 Device Plugins는 GPU, NIC, FPGA 등 특별한 하드웨어 리소스를 kubelet에 알려주는 역할을 한다. 간단히 말하면 이 노드에 GPU가 있음을 알려, 이후 스케줄링할 때 해당 정보를 사용할 수 있게 한다. kiac이 이 설정을 읽고 device-plugin을 DaemonSet으로 노드에 자동으로 띄운다.

1$ tools/kiac-lab.sh k get daemonset kiac-gpu-device-plugin -n kube-system
2NAME                     DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR               AGE
3kiac-gpu-device-plugin   1         1         1       1            1           kiac.dev/gpu.present=true   42m

tools/kiac-lab.sh k 명령어는 이 kiac 클러스터의 KubeConfig를 읽어서 kubectl 을 사용하는 명령어이다. (그냥 kubectl이라고 생각하면 된다.) 실행 결과를 보면 데몬셋이 정상적으로 실행된 걸 볼 수 있다.

1$ tools/kiac-lab.sh k get nodes \
2  -o 'custom-columns=NAME:.metadata.name,GPU:.status.allocatable.kiac\.dev/gpu'
3NAME                               GPU
4kiac-inference-gpu-control-plane   <none>
5kiac-inference-gpu-gpu-1           1

노드에 사용할 수 있는 GPU를 조회한 결과인데 당연하게도 GPU 노드에만 1개의 GPU가 등록된걸 확인할 수 있다.

GPU가 잘 동작하는지 확인하기 위해 Pod을 하나 띄워서 확인해보자.

 1tools/kiac-lab.sh gpu-check
 2
 3namespace/llm-lab created
 4pod/kiac-gpu-vulkan created
 5pod/kiac-gpu-vulkan condition met
 6pod "kiac-gpu-vulkan" deleted from llm-lab namespace
 7'DISPLAY' environment variable not set... skipping surface info
 8==========
 9VULKANINFO
10==========
11
12Vulkan Instance Version: 1.4.313
13
14
15Instance Extensions: count = 24
16-------------------------------
17VK_EXT_acquire_drm_display             : extension revision 1
18VK_EXT_acquire_xlib_display            : extension revision 1
19VK_EXT_debug_report                    : extension revision 10
20VK_EXT_debug_utils                     : extension revision 2
21VK_EXT_direct_mode_display             : extension revision 1
22VK_EXT_display_surface_counter         : extension revision 1
23VK_EXT_headless_surface                : extension revision 1
24VK_EXT_surface_maintenance1            : extension revision 1
25VK_EXT_swapchain_colorspace            : extension revision 5
26VK_KHR_device_group_creation           : extension revision 1
27VK_KHR_display                         : extension revision 23
28VK_KHR_external_fence_capabilities     : extension revision 1
29VK_KHR_external_memory_capabilities    : extension revision 1
30VK_KHR_external_semaphore_capabilities : extension revision 1
31VK_KHR_get_display_properties2         : extension revision 1
32VK_KHR_get_physical_device_properties2 : extension revision 2
33VK_KHR_get_surface_capabilities2       : extension revision 1
34VK_KHR_portability_enumeration         : extension revision 1
35VK_KHR_surface                         : extension revision 25
36VK_KHR_surface_protected_capabilities  : extension revision 1
37VK_KHR_wayland_surface                 : extension revision 6
38VK_KHR_xcb_surface                     : extension revision 6
39VK_KHR_xlib_surface                    : extension revision 6
40VK_LUNARG_direct_driver_loading        : extension revision 1
41
42Instance Layers: count = 1
43--------------------------
44VK_LAYER_MESA_device_select Linux device selection layer 1.4.303  version 1
45
46Devices:
47========
48GPU0:
49        apiVersion         = 1.2.0
50        driverVersion      = 25.0.7
51        vendorID           = 0x106b
52        deviceID           = 0x1b00020a
53        deviceType         = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU
54        deviceName         = Virtio-GPU Venus (Apple M5 Max)
55        driverID           = DRIVER_ID_MESA_VENUS
56        driverName         = venus
57        driverInfo         = Mesa 25.0.7
58        conformanceVersion = 1.3.0.0
59        deviceUUID         = 12dccf05-1a03-530c-e9da-0173a9e152e9
60        driverUUID         = fc7dc653-7fb1-9cc7-8b7b-490d663cf7c5
61GPU1:
62        apiVersion         = 1.4.305
63        driverVersion      = 0.0.1
64        vendorID           = 0x10005
65        deviceID           = 0x0000
66        deviceType         = PHYSICAL_DEVICE_TYPE_CPU
67        deviceName         = llvmpipe (LLVM 20.1.7, 128 bits)
68        driverID           = DRIVER_ID_MESA_LLVMPIPE
69        driverName         = llvmpipe
70        driverInfo         = Mesa 25.0.7 (LLVM 20.1.7)
71        conformanceVersion = 1.3.1.1
72        deviceUUID         = 6d657361-3235-2e30-2e37-000000000000
73        driverUUID         = 6c6c766d-7069-7065-5555-494400000000
74KIAC_REAL_APPLE_GPU_OK

Vulkan 정보를 확인할 수 있고 아래 Devices를 보면 GPU0, GPU1이 잡힌걸 볼 수 있다. GPU0에는 deviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU와 deviceName = Virtio-GPU Venus (Apple M5 Max)라고 나와 있어 원하는 대로 GPU가 잡힌 걸 확인할 수 있다. GPU1은 deviceType = PHYSICAL_DEVICE_TYPE_CPU라고 나와 있어 실제로는 CPU인 걸 알 수 있다.

이 확인은 아래의 POD을 사용해서 테스트한 결과이다.

 1# gpu-check.yaml
 2
 3# Run this before the model Deployment, then delete it to release the GPU.
 4# Source: https://github.com/saiyam1814/kiac/blob/v0.11.0/examples/gpu-vulkan.yaml
 5apiVersion: v1
 6kind: Pod
 7metadata:
 8  name: kiac-gpu-vulkan
 9  namespace: llm-lab
10  labels:
11    app.kubernetes.io/name: kiac-gpu-vulkan
12spec:
13  restartPolicy: Never
14  automountServiceAccountToken: false
15  runtimeClassName: nvidia
16  nodeSelector:
17    kiac.dev/gpu.present: "true"
18  tolerations:
19    - key: kiac.dev/gpu
20      operator: Equal
21      value: "true"
22      effect: NoSchedule
23  containers:
24    - name: vulkaninfo
25      image: quay.io/slopezpa/fedora-vgpu@sha256:d1fd35583d1c72bc7380bb977d5d51e70d70c77a2279a0412c2e6ac462f61f16
26      imagePullPolicy: IfNotPresent
27      command: [/bin/bash, -euo, pipefail, -c]
28      args:
29        - |
30          vulkaninfo --summary 2>&1 | tee /tmp/vulkaninfo
31          grep -q 'deviceName.*Virtio-GPU Venus' /tmp/vulkaninfo
32          echo KIAC_REAL_APPLE_GPU_OK
33      env:
34        - name: XDG_RUNTIME_DIR
35          value: /tmp
36      resources:
37        requests:
38          cpu: 100m
39          memory: 128Mi
40          kiac.dev/gpu: "1"
41        limits:
42          memory: 512Mi
43          kiac.dev/gpu: "1"

여기서 nodeSelector와 tolerations로 GPU 노드를 선택하게 하고 kiac.dev/gpu: "1" 처럼 요청해서 GPU 1장을 할당받게 만들었다.

이제 실제로 사용할 모델을 배포해 보자.

1$ tools/kiac-lab.sh deploy
2namespace/llm-lab unchanged
3configmap/llm-config created
4service/tinyllama created
5persistentvolumeclaim/llm-models created
6deployment.apps/tinyllama created
7Waiting for deployment "tinyllama" rollout to finish: 0 of 1 updated replicas are available...
8deployment "tinyllama" successfully rolled out
1$ tools/kiac-lab.sh k get pods,pvc -n llm-lab
2NAME                             READY   STATUS    RESTARTS   AGE
3pod/tinyllama-7468cf47b8-rdbzp   1/1     Running   0          5m41s
4
5NAME                               STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
6persistentvolumeclaim/llm-models   Bound    pvc-d334de48-d09c-4961-80e5-a7faae8dd09c   4Gi        RWO            local-path     <unset>                 5m41s

Deployment를 이용해서 tinyllama Pod이 실행되었고 모델 파일을 저장하는 PVC를 생성했다.(아래는 설명에 필요한 부분만 표시했다.)

 1# deployment.yaml
 2apiVersion: apps/v1
 3kind: Deployment
 4spec:
 5  template:
 6    spec:
 7      initContainers:
 8        - name: model-download
 9          image: quay.io/slopezpa/fedora-vgpu-llama@sha256:f58a677fd617e5a6d0b7f558fd137d63a57aaac70293956ef09a16ec69e210d4
10          volumeMounts:
11            - name: model
12              mountPath: /models
13      containers:
14        - name: llama-server
15          image: quay.io/slopezpa/fedora-vgpu-llama@sha256:f58a677fd617e5a6d0b7f558fd137d63a57aaac70293956ef09a16ec69e210d4
16          command: [/usr/bin/llama-server]
17          volumeMounts:
18            - name: model
19              mountPath: /models
20              readOnly: true
21      volumes:
22        - name: model
23          persistentVolumeClaim:
24            claimName: llm-models

Mac에서 port-forward로 kiac 클러스터의 TinyLlama Pod에 요청하고, llama.cpp가 Vulkan·Venus·MoltenVK를 거쳐 M5 Max GPU로 계산하는 구성도.

이 Pod에는 initContainers가 있어서 모델이 없으면 모델을 다운로드 받아서 마운트된 PVC에 저장한다. 모델이 저장되고 나면 llama-server 컨테이너가 같은 PVC를 마운트해서 llama.cpp로 모델을 실행한다. PVC를 둔 이유는 Pod을 재시작할 때 기존 모델 파일이 있으면 다시 다운로드하지 않고 바로 모델을 띄우기 위해서다.

모델이 잘 떴는지 확인해 보자.

 1$ tools/kiac-lab.sh k logs deployment/tinyllama -n llm-lab -c llama-server
 2ggml_vulkan: Found 1 Vulkan devices:
 3ggml_vulkan: 0 = Virtio-GPU Venus (Apple M5 Max) (venus) | uma: 1 | fp16: 1 | warp size: 32 | shared memory: 32768 | int dot: 0 | matrix cores: none
 4system info: n_threads = 2, n_threads_batch = 2, total_threads = 4
 5
 6main: binding port with default address family
 7main: HTTP server is listening, hostname: 0.0.0.0, port: 8080, http threads: 3
 8main: loading model
 9srv    load_model: loading model '/models/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf'
10llama_model_load_from_file_impl: using device Vulkan0 (Virtio-GPU Venus (Apple M5 Max)) - 131072 MiB free
11llama_model_loader: loaded meta data with 23 key-value pairs and 201 tensors from /models/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf (version GGUF V3 (latest))
12llama_model_loader: Dumping metadata keys/values. Note: KV overrides do not apply in this output.
13llama_model_loader: - kv   0:                       general.architecture str              = llama
14llama_model_loader: - kv   1:                               general.name str              = tinyllama_tinyllama-1.1b-chat-v1.0
15llama_model_loader: - kv   2:                       llama.context_length u32              = 2048
16llama_model_loader: - kv   3:                     llama.embedding_length u32              = 2048
17print_info: arch             = llama
18print_info: n_ctx_train      = 2048
19print_info: model type       = 1B
20print_info: model params     = 1.10 B
21print_info: general.name     = tinyllama_tinyllama-1.1b-chat-v1.0
22print_info: max token length = 48
23load_tensors: loading model tensors, this can take a while... (mmap = true)
24load_tensors: offloading 22 repeating layers to GPU
25load_tensors: offloading output layer to GPU
26load_tensors: offloaded 23/23 layers to GPU
27load_tensors:      Vulkan0 model buffer size =   601.02 MiB
28load_tensors:   CPU_Mapped model buffer size =    35.16 MiB
29....................................................................................
30llama_context: constructing llama_context
31llama_context: n_seq_max     = 1
32llama_context: n_ctx         = 2048
33llama_context: n_ctx_per_seq = 2048
34llama_context: n_batch       = 2048
35llama_context: n_ubatch      = 512
36llama_context: causal_attn   = 1
37llama_context: flash_attn    = 0
38llama_context: freq_base     = 10000.0
39llama_context: freq_scale    = 1
40llama_context: Vulkan_Host  output buffer size =     0.12 MiB
41llama_kv_cache_unified:    Vulkan0 KV buffer size =    44.00 MiB
42llama_kv_cache_unified: size =   44.00 MiB (  2048 cells,  22 layers,  1 seqs), K (f16):   22.00 MiB, V (f16):   22.00 MiB

로그는 길지만 Found 1 Vulkan devices와 Virtio-GPU Venus (Apple M5 Max) (venus)에서 GPU가 인식된 걸 확인할 수 있다. offloaded 23/23 layers to GPU는 모델의 텐서와 가중치가 GPU에서 계산되도록 배치되었음을 보여 준다. KV 캐시는 44MiB로 설정되었다.

tinyllama에 요청 보내기

앞에서 설명하진 않았지만, 모델을 띄울 때 Service를 연결해 Pod이 요청을 받을 수 있도록 설정해 두었다. 로컬에서 이 서비스에 요청을 보내기 위해 포트 포워딩을 한다.

1$ tools/kiac-lab.sh port-forward
2
3Forwarding from 127.0.0.1:18080 -> 8080

다른 터미널을 열어서 접속이 되는지 확인한다.(http://127.0.0.1:18080/props로 요청을 보내면 모델 설정을 확인할 수 있다.)

1$ curl http://127.0.0.1:18080/health
2{"status":"ok"}

아래 테스트는 모델에 The help desk opens at 9 AM and closes at 6 PM, Monday through Friday. Using only these facts, state the opening hours in one sentence.라는 프롬프트를 보내는 스크립트다. 헬프데스크의 운영 시간을 알려주고 한 문장으로 설명하라는 내용이다. SSE(Server-Sent Events)로 받은 답변을 정리해 JSON 응답으로 반환한다.

 1$ tools/kiac-lab.sh smoke --output results/my-kiac-first.json
 2
 3{
 4  "file": "results/my-kiac-first.json",
 5  "route": {
 6    "base_url": "http://127.0.0.1:18080",
 7    "service": "tinyllama",
 8    "namespace": "llm-lab",
 9    "endpoint": "/completion",
10    "client_path": "localhost port-forward"
11  },
12  "model": "tinyllama",
13  "server": {
14    "model_path": "/models/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf",
15    "total_slots": 1,
16    "context_per_slot": 2048
17  },
18  "request": {
19    "prompt": "The help desk opens at 9 AM and closes at 6 PM, Monday through Friday. Using only these facts, state the opening hours in one sentence.",
20    "max_tokens": 96,
21    "cache_prompt": true,
22    "endpoint": "/completion",
23    "chat_template": "TinyLlama Chat v1.0"
24  },
25  "measurement": {
26    "first_text_chunk_seconds": 0.35803308291360736,
27    "client_total_seconds": 0.533821249846369,
28    "server_prompt_tokens_total": 69,
29    "server_prompt_eval_count": 69,
30    "server_prompt_eval_seconds": 0.355113,
31    "server_output_tokens": 20,
32    "server_decode_seconds": 0.17602099999999998,
33    "server_decode_tokens_per_second": 113.62280636969453,
34    "done_reason": "eos"
35  },
36  "response": "The Help Desk is open from 9 AM to 6 PM, Monday through Friday."
37}

위 응답은 전체 JSON에서 중요한 데이터로 축약한 버전이다. 실제로는 더 많은 데이터가 내려온다. 마지막 response를 보면 응답이 제대로 온 걸 확인할 수 있고, measurement에서는 처리에 걸린 시간을 볼 수 있다.

  • first_text_chunk_seconds: 요청을 보내고 첫 텍스트를 받기까지 걸린 시간
  • client_total_seconds: 요청 전송부터 스트림 종료까지 클라이언트가 기다린 전체 시간
  • server_prompt_tokens_total: 입력 프롬프트 전체 토큰 수(Prefill)
  • server_prompt_eval_count: 이번 요청에서 실제로 계산한 입력 토큰 수(Prefill)
  • server_prompt_eval_seconds: 입력 처리에 걸린 시간(Prefill)
  • server_output_tokens: 서버가 생성한 출력 토큰 수(Decode)
  • server_decode_seconds: 서버가 출력 토큰을 생성하는데 걸린 시간(Decode)
  • server_decode_tokens_per_second: 생성 구간의 초당 토큰 수(Decode)

LLM에서 텍스트 생성은 크게 Prefill과 Decode로 나뉜다. Prefill은 입력 프롬프트를 처리해서 이후 생성에 필요한 상태를 준비하는 단계이고 Decode는 그 상태에서 응답 토큰을 하나씩 생성하는 단계인데 이 측정값을 통해서 어디서 시간이 걸리는지 볼 수 있다.

운영환경에서의 고려사항

여기서는 Pod 1개로 간단히 테스트해보았지만 실제로 운영한다면 고려할 내용이 많이 있다. 이 부분은 직접 테스트한 건 아니고 개념만 간단히 찾아본 것이다.

기본적으로 GPU는 1장, 2장 이런식으로 정수로 할당을 받는다. CPU처럼 0.1 GPU, 1.5 GPU 이런 식으로 할당할 수 없기 때문에 실 운영에서는 성능뿐 아니라 효율성도 같이 고려해야 한다. Tensor Parallelism(TP)은 여러 GPU가 나누어 계산하는 기법이다. 가장 많이 사용되는 서빙 엔진인 vLLM에서도 분산 서빙을 지원한다. NVIDIA의 MIG(Multi-Instance GPU)는 GPU를 정해진 크기의 별도 인스턴스로 나누어서 사용할 수 있고 Time-Slicing기법은 GPU를 돌아가면서 사용할 수 있게 지원한다.

여기서는 Pod에 바로 요청을 보냈지만, 실제 환경에서는 다른 서비스나 로드 밸런서가 앞단에 놓이는 등 구성이 더 복잡하다. 따라서 요청 처리에 필요한 재시도와 타임아웃 설정도 신경 써야 한다. 여기서는 PVC를 이용해서 모델을 다시 다운로드 받지 않도록 했지만 운영에서 쓰는 모델은 훨씬 용량이 크기 때문에 Pod 교체시 새로 뜨는 시간 등도 고려해야 한다.

Prefill과 KV 캐시

LLM은 Prefill 단계에서 입력을 처리하고 그 계산 결과를 KV 캐시에 저장한다. 이 캐시는 Decode 단계에서 사용하고 Prefix Caching을 통해 공통된 입력 앞부분이 있으면 이어진 요청에서도 이 캐시를 사용하게 된다. KV 캐시는 별도로 깊이 다룰 주제이므로 여기서는 이정도 개념만 이해하고 넘어간다.

이를 위해 모델에 5번 연속으로 요청을 보낸 뒤 종료하도록 구성한 Job을 띄워 보자.

1$ tools/kiac-lab.sh bench | tee results/my-kiac-job.json
2configmap/kiac-client created
3job.batch/llm-bench created
4job.batch/llm-bench condition met

요청 결과가 5개 나오지만 쉽게 비교하기 위해 아래 스크립트로 결과를 확인해보자.

 1$ python3 - <<'PY'
 2import json
 3with open("results/my-kiac-job.json") as f:
 4    result = json.load(f)
 5print("요청\t전체 입력\t이번 처리\t입력 처리(ms)")
 6for request in result["requests"]:
 7    m = request["measurement"]
 8    print(request["sample"], m["server_prompt_tokens_total"],
 9          m["server_prompt_eval_count"],
10          round(m["server_prompt_eval_seconds"] * 1000, 2), sep="\t")
11PY
12
13요청    전체 입력       이번 처리       입력 처리(ms)
141      69            69            75.3
152      69            1             6.08
163      69            1             6.07
174      69            1             6.04
185      69            1             6.19

첫 번째 요청에서는 토큰 69개를 처리하는 데 75ms가 걸렸지만, 두 번째부터는 캐시가 적용되어 토큰 1개만 처리한 걸 볼 수 있다.

이번에는 입력 길이에 따라 어떻게 달라지는지 확인하기 위해 짧은 프롬프트와 긴 프롬프트를 준비했다.

kiac-helpdesk.txt:

1The help desk opens at 9 AM and closes at 6 PM, Monday through Friday.
2Password recovery requires the employee's registered email address.
3No other support hours or recovery methods are stated in this document.
4
5Using only the document above, state the help desk opening hours in one sentence.

kiac-helpdesk-long.txt:

 1This is a fictional help desk document for comparing short and long model inputs.
 2
 3The help desk opens at 9 AM and closes at 6 PM, Monday through Friday.
 4Password recovery requires the employee's registered email address.
 5No other support hours or recovery methods are stated in this document.
 6
 7Support requests are organized by topic so an operator can find the right
 8instructions. Each request should describe the affected service and the visible
 9symptom. An operator should use the information in this document and avoid
10inventing a procedure when the requested procedure is not listed.
11
12For a login problem, the request should state whether the account name is
13recognized and whether the sign-in screen shows a message. A request can describe
14the message without including a password. A description of a problem is useful
15even when the person submitting it does not know the cause.
16
17For a document access problem, the request should name the document and describe
18the access being requested. The operator can check the recorded document owner.
19Being able to view one document does not establish permission to edit another
20document. An answer should distinguish the reported symptom from an assumption.
21
22For a device problem, a request can identify the device category and describe
23what changed before the problem started. A recent change can help an operator
24choose which question to ask first, but it does not prove the cause. Instructions
25for changing device settings are outside the scope of this document.
26
27For an application problem, the request should identify the application and the
28action that failed. A description can include the sequence of actions immediately
29before the failure. The operator should keep the explanation concise and avoid
30adding an unsupported promise about a solution or a completion time.
31
32The document is a reference for the answer, not a list of actions for the model
33to perform. It contains no real accounts, customer information, credentials, or
34connection details. The opening hours near the beginning are the only opening
35hours specified here. The additional paragraphs describe how requests are written
36and do not change those hours.
37
38Using only the document above, state the help desk opening hours in one sentence.

이 두가지 요청을 보내면서 출력 토큰에 대한 설정인 --max-tokens를 64로 똑같이 제한했다.

1$ tools/kiac-lab.sh smoke --prompt-file examples/kiac-helpdesk.txt \
2  --max-tokens 64 --output results/my-input-short.json
3
4$ tools/kiac-lab.sh smoke --prompt-file examples/kiac-helpdesk-long.txt \
5  --max-tokens 64 --output results/my-input-long.json

두 JSON 결과를 비교해보자.

 1python3 tools/kiac_compare.py \
 2  results/my-input-short.json results/my-input-long.json
 3
 4항목              짧은 입력     긴 입력
 5출력 상한(토큰)      64         64
 6전체 입력(토큰)      102        499
 7이번 처리(토큰)      56         474
 8재처리 생략(토큰)     46         25
 9입력 처리(ms)       162.73     140.43
10첫 텍스트(ms)       165.47     143.78
11생성 시간(ms)       318.93     192.61
12전체 시간(ms)       484.24     336.35
13실제 출력(토큰)      53         32
14종료 이유           eos        eos
15
16재처리 생략은 잘리지 않은 입력의 전체 토큰 수−이번 처리량으로 계산한 값입니다.
17
18짧은 입력 답변 (results/my-input-short.json)
19The help desk at [COMPANY NAME] opens at 9 AM and closes at 6 PM, Monday through Friday. Password recovery requires the employee's registered email address. No other support hours or recovery methods are mentioned in the document.
20
21긴 입력 답변 (results/my-input-long.json)
22The help desk is open from 9 AM to 6 PM, Monday through Friday, and is staffed by a concise, helpful assistant.

두 요청 모두 일부 KV 캐시가 사용되었고 짧은 입력에서 오히려 시간이 더 걸렸는데 큰 차이는 아니므로 여기서는 큰 의미가 없다. 전체 생성 시간은 짧은 입력이 더 긴 응답을 만들었으므로 전체 시간은 더 길어졌다.

앞에서 모델을 설정할 때 ConfigMap을 통해 다음과 같이 설정했다.

1CTX_SIZE: "2048"
2N_PARALLEL: "1"

컨텍스트 크기는 한 번에 유지할 수 있는 토큰 수이며 KV 캐시와 직접 연결된다. 이 컨텍스트에는 입력 형식을 감싸는 chat template과 출력 토큰도 포함된다. N_PARALLEL이 1이므로 요청 슬롯은 1개이고, 한 번에 요청 하나만 처리할 수 있다. llama.cpp는 컨텍스트를 슬롯 수로 나누기 때문에 N_PARALLEL을 2로 늘리면 슬롯당 1,024 토큰이 된다.

CTX_SIZE를 4,096 토큰으로 늘려보자.

 1$ tools/kiac-lab.sh k patch configmap llm-config -n llm-lab \
 2  --type merge -p '{"data":{"CTX_SIZE":"4096"}}' &&
 3tools/kiac-lab.sh k rollout restart deployment/tinyllama -n llm-lab &&
 4tools/kiac-lab.sh k rollout status deployment/tinyllama -n llm-lab --timeout=10m &&
 5tools/kiac-lab.sh k logs deployment/tinyllama -n llm-lab -c llama-server | \
 6  grep -E 'n_ctx(_per_seq)?[[:space:]]*=|KV buffer size'
 7configmap/llm-config patched
 8deployment.apps/tinyllama restarted
 9Waiting for deployment "tinyllama" rollout to finish: 0 out of 1 new replicas have been updated...
10Waiting for deployment "tinyllama" rollout to finish: 0 out of 1 new replicas have been updated...
11Waiting for deployment "tinyllama" rollout to finish: 0 out of 1 new replicas have been updated...
12Waiting for deployment "tinyllama" rollout to finish: 0 of 1 updated replicas are available...
13deployment "tinyllama" successfully rolled out
14llama_context: n_ctx         = 4096
15llama_context: n_ctx_per_seq = 4096
16llama_kv_cache_unified:    Vulkan0 KV buffer size =    88.00 MiB

마지막 결과에서 확인할 수 있다. KV 버퍼 크기가 기존 44MiB에서 88MiB로 정확히 2배가 된 걸 볼 수 있다. 이는 컨텍스트를 더 많이 유지하려면 그만큼 KV 메모리가 더 필요하다는 것을 알 수 있다.

운영환경에서의 고려사항

여기서는 Vulkan을 기준으로 메모리를 확인했으므로, 실제 운영할 때는 NVIDIA CUDA나 vLLM에서 직접 확인해야 한다. 사용 패턴에 맞는 효율적인 메모리 구성을 찾아야 하며, 이를 위한 최적화 기법도 여러 가지가 있다.

vLLM은 KV 캐시를 작은 크기의 블록으로 나누어 Attention을 계산하는 PagedAttention을 이용해 KV block pool을 지원한다.

APC(Automatic Prefix Caching)로 같은 토큰의 접두사로 계산 결과를 재사용하고 Preemption으로 진행중인 요청을 잠시 중단하고 KV 캐시 공간을 다른 요청이 사용하게 해서 부족한 메모리를 확보하는 방법도 고려할 수 있다.

실제 운영할 때는 여러 Pod에 모델이 떠있을 것이므로 로컬 캐시로 설정된 KV 캐시는 해당 Pod에서만 사용할 수 있다. 운좋게 캐시가 있는 Pod으로 요청이 가면 빠르게 처리되겠지만 다른 Pod으로 가면 다시 KV 캐시를 만드느라고 시간이 걸릴 수 있다. llm-d나 Kubernetes Gateway API Inference Extension에서 캐시가 있는 팟으로 라우팅하는 EPP(Endpoint Picker) 혹은 Prefix-Cache Aware Routing을 사용해서 KV 캐시의 히트율을 높일 수 있다.

메모리 특히 VRAM은 매우 비싼 자원이므로 LMCache를 사용해서 KV를 CPU 메모리나 디스크, 원격 저장 계층에 저장하고 가져오게 할 수도 있고 최근 ValKey에서도 KV 캐시로 사용해서 성능을 높인 케이스도 소개되었다.

Decode

Decode는 입력 컨텍스트와 이미 생성한 내용을 바탕으로 다음 토큰을 하나씩 생성해서 답변을 만드는 과정이다.

동작을 확인하기 위해 출력 토큰에 대한 설정인 --max-tokens을 다르게 설정해서 2개의 요청을 보내보자.

1$ tools/kiac-lab.sh smoke --prompt-file examples/kiac-helpdesk.txt \
2  --max-tokens 8 --output results/my-answer-8.json
3
4$ tools/kiac-lab.sh smoke --prompt-file examples/kiac-helpdesk.txt \
5  --max-tokens 64 --output results/my-answer-64.json
 1$ python3 tools/kiac_compare.py \
 2  results/my-answer-8.json results/my-answer-64.json \
 3  --labels '상한 8토큰' '상한 64토큰'
 4항목               상한 8토큰    상한 64토큰
 5출력 상한(토큰)      8           64
 6전체 입력(토큰)      102         102
 7이번 처리(토큰)      102         1
 8재처리 생략(토큰)     0           101
 9입력 처리(ms)       78.63       29.61
10첫 텍스트(ms)       81.12       32.24
11생성 시간(ms)       45.56       398.68
12전체 시간(ms)       126.62      430.73
13실제 출력(토큰)      8           53
14종료 이유           limit       eos
15
16재처리 생략은 잘리지 않은 입력의 전체 토큰 수−이번 처리량으로 계산한 값입니다.
17
18상한 8토큰 답변 (results/my-answer-8.json)
19The help desk at [COMPAN
20
21상한 64토큰 답변 (results/my-answer-64.json)
22The help desk at [COMPANY NAME] opens at 9 AM and closes at 6 PM, Monday through Friday. Password recovery requires the employee's registered email address. No other support hours or recovery methods are mentioned in the document.

위 결과에서 보듯이 --max-tokens을 아주 짧게 주었으므로 토큰을 생성하다가 끊긴 걸 볼 수 있고 종료 이유도 eos가 아니라 limit로 출력되었다. 출력 토큰이 짧으면 당연히 시간도 줄어들겠지만 답변을 제대로 완성하지 못하면 의미가 없다.

앞에서 얘기한 대로 지금 모델은 슬롯이 1개로 설정되어 있어서 요청을 한 번에 한 개만 처리할 수 있다.

1$ tools/kiac-lab.sh load --samples 5 --concurrency 1 \
2  --output results/my-load-1.json
3
4$ tools/kiac-lab.sh load --samples 5 --concurrency 2 \
5  --output results/my-load-2.json

이 부하 테스트에서는 요청을 총 5번 보낸다. 첫 번째 테스트에서는 1개씩, 두 번째 테스트에서는 동시에 2개씩 보낸다.

 1python3 tools/kiac_compare.py \
 2  results/my-load-1.json results/my-load-2.json \
 3  --labels '동시성 1' '동시성 2'
 4
 5항목                동시성 1   동시성 2
 6서버 실행 슬롯        1         1
 7클라이언트 동시성      1         2
 8전체 요청 수          5         5
 9출력 상한(토큰)       64        64
10성공 요청            5         5
11실패 요청            0         0
12요청 처리량(요청/초)   7.27      5.26
13생성 처리량(토큰/초)   145.35    105.17
14첫 텍스트 p95(ms)    73.57     202.29
15전체 p95(ms)        191.78    387.52
16
17동시성 1 요청별 결과 (results/my-load-1.json)
18요청  HTTP  이번 처리(토큰)  첫 텍스트(ms)  전체(ms)  출력(토큰)  종료
191     200   23           73.57        191.78   20       eos
202     200   1            8.52         122.03   20       eos
213     200   1            8.36         122.44   20       eos
224     200   1            8.33         128.40   20       eos
235     200   1            8.38         122.80   20       eos
24
25동시성 2 요청별 결과 (results/my-load-2.json)
26요청  HTTP  이번 처리(토큰)  첫 텍스트(ms)  전체(ms)  출력(토큰)  종료
271     200   1            199.45       372.46   20       eos
282     200   1            32.64        191.11   20       eos
293     200   1            190.82       371.62   20       eos
304     200   1            199.73       383.24   20       eos
315     200   1            202.29       387.52   20       eos

이 결과를 보면 동시성이 2일 때 처리가 밀려 응답 시간이 크게 늘어난 걸 확인할 수 있다. 실제 운영할 때는 메트릭으로 확인해야겠지만 이는 처리가 오래걸린 것이 아니라 대기하면서 응답이 지연된 것이다.

운영환경에서의 고려사항

성능을 최적화하려면 Prefill과 Decode를 모두 최적화해야 한다.

Decode할 때는 요청의 응답을 배치로 생성한다. Continuous batching을 사용하면 A, B, C를 처리하다가 A가 완료되면 D를 배치에 넣어 GPU를 최대한 활용할 수 있다. 긴 입력의 Prefill 처리로 Decode가 밀리는 걸 막기 위해 Chunked prefill로 Prefill을 나누어 처리하며 Decode를 이어 갈 수도 있다.

llm-d의 Disaggregated Serving처럼 Prefill용 GPU pool과 Decode GPU pool을 나누어서 처리하는 방법도 있지만 이 경우에는 해당 요청의 KV 상태를 전송하는 계층이 필요해진다. KServe의 LLMInferenceService를 사용하면 CRD로 이러한 pool을 관리할 수 있다.

그 외에도 작은 모델이나 규칙으로 다음 토큰을 제안하고 원래 모델이 이를 검증해 채택하는 Speculative Decoding을 사용할 수 있다. 구조화된 출력(structured outputs)을 보장하려면 XGrammar로 JSON 스키마에 맞게 토큰 선택을 제약하고, 생성된 JSON을 파싱해 필드를 검사할 수 있다.

정리하며

실제 운영할 때는 고려해야 할 부분이 많이 있다.

대표적으로 요청 후 첫 토큰을 받기까지의 시간인 TTFT(Time to First Token)를 봐야 한다. 최적화를 하려면 토큰 생성 사이에 걸리는 시간인 ITL(Inter-Token Latency) 같은 상세 지표도 봐야 한다. 미리 정해둔 SLO를 만족하며 완료된 처리량을 추적하는 Goodput도 봐야한다. 사용 패턴에 따라 다르지만, Pod이 재시작되면 KV 캐시가 비워져 있으므로 대표적인 패턴이 있다면 그에 맞춰 웜업해 속도를 높이는 것도 검토할 수 있다.

일반적인 API 서버는 팟을 교체할 때 몇 초에서 십여 초 정도면 Graceful shutdown에 충분한 경우가 많다. LLM은 그렇지 않고 응답 시간을 보장하기도 어려워 terminationGracePeriodSeconds 같은 값을 적절히 정하기가 쉽지 않다.

각 주제를 하나씩 봐도 공부할 게 많지만, 이번 테스트로 전체적인 맥락은 어느 정도 이해하게 되었다.

이 글에서 사용한 스크립트와 클러스터 설정은 kiac-lab-tinyllama에 올려두었다.

Valid HTML5 Valid CSS WCAG 2.1 AA tested