
API에서 GPU까지, 4주 차: 모델 파라미터가 메모리에서 실제로 차지하는 비용
요약
모델 파라미터의 개념과 이것이 실제 메모리 점유에 미치는 영향을 설명합니다. 파라미터, 가중치, 편향의 차이를 정의하고 모델 크기에 따른 메모리 계산 원리를 다룹니다.
핵심 포인트
- 파라미터는 모델 내부에 저장된 학습된 숫자이며, 모델 크기(예: 7B)를 결정함
- 가중치(weight)와 편향(bias)을 합친 것이 전체 파라미터 메모리 용량임
- 토큰은 데이터의 조각이며, 파라미터는 모델 파일에 저장된 학습된 값임
- 텐서, 행렬, 레이어 등 모델 내부 구조를 구성하는 핵심 용어 정리
8단계 중 1단계: 로컬 모델을 편안하게 실행하기. 32주 중 4주 차.
사람들은 마치 모두가 그 의미를 알고 있다는 듯이 "7B", "14B", "70B"라는 말을 아무렇지 않게 내뱉습니다.
오랫동안 저도 사실 그 의미를 잘 모른 채 고개를 끄덕여 왔습니다. 이번 주에는 그 숫자들을 구체화해 보려 합니다. 이번 과정을 마치면 모델 크기와 정밀도(precision)를 보고 가중치(weights)가 얼마나 많은 메모리를 필요로 하는지 말할 수 있고, 왜 실행 중인 모델은 그보다 더 많은 메모리가 필요한지도 설명할 수 있게 될 것입니다.
이번 주는 1단계의 마지막 주입니다. 1주 차에는 기계에 대해 배웠고, 2주 차에는 Ollama를 통해 모델을 실행했으며, 3주 차에는 Hugging Face에서 모델 파일을 읽어보았습니다. 이제 3주 차에 보았던 파라미터(parameter) 개수를 DGX Spark의 실제 메모리와 연결해 보겠습니다.
계산기 부분은 Python이 있는 곳이라면 어디에서든 실행됩니다. 메모리 측정은 이전 주와 동일한 설정인 ssh spark를 통해 Spark 위에서 실행됩니다.
파라미터란 실제로 무엇인가
**파라미터 (parameter)**는 모델 내부에서 학습된 단일 숫자입니다. 훈련(Training) 과정은 모델이 텍스트를 잘 예측할 때까지 이 수십억 개의 숫자들을 조정합니다. 모델이 "3B"라고 불린다면, 이는 약 30억 개의 이러한 숫자를 가지고 있다는 뜻입니다.
잠깐, 파라미터가 토큰(token)과 같은 것인가요? 아닙니다. **토큰 (token)**은 모델로 들어가거나 모델에서 나오는 텍스트의 조각입니다. 토큰은 요청(request) 및 응답(response) 데이터이며, 파라미터는 모델 파일에 저장된 학습된 숫자입니다.
| 토큰 (Token) | 파라미터 (Parameter) | |
|---|---|---|
| 정의 | 입력되거나 생성된 텍스트의 조각 | 학습된 숫자 |
| ... |
하지만 이들은 서로 연결되어 있습니다. 토크나이저(tokenizer)는 각 토큰을 ID로 변환합니다. 모델은 학습된 파라미터를 사용하여 해당 ID를 내부 값으로 변환하고, 이를 처리하여 다음 토큰을 예측합니다. 따라서 3B 모델은 30억 개의 토큰이 아니라 약 30억 개의 파라미터를 가집니다. 모델이 처리하는 모든 토큰에 대해 동일한 파라미터가 재사용됩니다.
파라미터는 포괄적인 용어입니다. 그 아래에는 두 가지 종류가 있습니다:
- **가중치 (weight)**는 입력 값에 곱해집니다. 대부분의 파라미터는 가중치입니다.
- **편향 (bias)**은 곱셈 후에 더해지는 또 다른 학습된 값입니다. 모델은 보통 가중치 항목보다 편향 항목을 훨씬 적게 가집니다.
보고된 파라미터 (parameter) 수는 이 두 가지를 모두 포함합니다. 따라서 이 포스트에서 제가 "가중치 메모리 (weight memory)"라고 말할 때는 가중치 (weights)와 편향 (biases)을 모두 합친 모든 파라미터의 저장 공간을 의미합니다. 이 수치가 메모리 계산을 이끄는 핵심입니다.
모델 내부를 들여다보면 몇 가지 용어가 더 등장하는데, 이를 쉬운 용어로 설명하면 다음과 같습니다:
- **텐서 (tensor)**는 숫자들의 블록입니다. 단일 숫자는 스칼라 (scalar), 리스트는 벡터 (vector), 격자는 행렬 (matrix)이며, 텐서는 이들 모두를 일컫는 일반적인 명칭입니다.
- **행렬 (matrix)**은 숫자로 이루어진 2차원 격자로, 가중치 블록에서 가장 흔한 형태입니다.
- **레이어 (layer)**는 하나의 처리 단계입니다. 모델은 많은 레이어를 쌓아 올리며, 각 레이어는 자신만의 텐서를 보유합니다.
- **형태 (shape)**는 2048 x 2048과 같이 각 차원에 따른 텐서의 크기를 의미합니다.
이번 주에는 이러한 내부 구조를 자세히 다룰 필요는 없습니다. 5주 차에는 PyTorch에서 텐서를 직접 다루고, 11주 차에는 레이어를 설명할 것입니다. 여기서는 단지 "우리가 곧 측정할 숫자들"을 지칭하는 용어로만 사용합니다.
3주 차에 저는 Hugging Face API에서 파라미터 수를 읽어왔습니다. 여기서는 이 포스트의 뒷부분에서 가중치 인덱스에 사용할 것과 동일한 커밋(commit)으로 API 요청을 고정하겠습니다:
$ MODEL=Qwen/Qwen2.5-3B-Instruct
$ REV=aa8e72537993ba99e69dfaafa59ed015b17504d1
$ curl -s "https://huggingface.co/api/models/$MODEL/revision/$REV" | jq '{
...
따라서 이 모델은 3,085,938,688개의 파라미터를 가지고 있으며, 그 각각은 BF16 형식으로 저장됩니다. 이 두 번째 사실이 메모리 문제의 나머지 절반을 차지합니다.
정밀도 (Precision): 각 숫자가 차지하는 바이트 수
파라미터는 숫자이며, 숫자는 저장이 필요합니다. **정밀도 (Precision)**는 각 숫자를 저장하는 데 사용되는 형식입니다. 이 형식은 모든 파라미터가 몇 비트(bits), 즉 결과적으로 몇 바이트(bytes)를 차지할지를 결정합니다.
이 짧은 약어들은 명확히 설명할 가치가 있습니다. FP는 부동 소수점 (floating point)을 의미하며, 소수점 숫자를 위한 형식입니다. BF16은 bfloat16을 의미하며, 16비트 부동 소수점 (floating-point) 형식입니다. INT는 정수 (integer)를 의미합니다. 3주 차 설정에서 torch_dtype: bfloat16을 보셨을 것입니다. 다음은 일반적인 형식들이 파라미터당 차지하는 비용입니다:
| 정밀도 (Precision) | 비트 (Bits) | 바이트 (Bytes) |
|---|---|---|
| FP32 | 32 | 4 |
| ... |
FP32는 숫자당 4바이트를 사용하는 32비트 기준점입니다. FP16과 BF16은 모두 16비트 형식이므로 둘 다 2바이트를 사용합니다. INT8과 INT4는 양자화 (quantization)를 통해 생성되는 저비트 (low-bit) 형식이며, 이는 본 시리즈의 훨씬 뒤 단계(14주 차에서 16주 차)에서 다룰 내용입니다. INT4의 0.5바이트는 평균값입니다. 즉, 두 개의 4비트 값이 하나의 바이트에 패킹(pack)됩니다. 지금 당장 중요한 것은 바이트 (bytes) 열뿐입니다.
숫자를 다루기 전, GB 대 GiB
메모리 수치는 두 가지 단위로 제공되며, 이를 혼용하면 혼란을 야기하므로 미리 한 번 정의해 두겠습니다.
- GB는 십진수입니다: 바이트를 1,000,000,000으로 나눕니다.
- GiB는 이진수입니다: 바이트를 1024로 세 번 나눕니다 (1024 x 1024 x 1024).
계산기는 두 가지를 모두 출력합니다. Ollama는 나중에 자체적으로 반올림된 GB 라벨을 출력하므로, 이는 바이트 단위로 정확한 비교가 아닌 대략적인 런타임 보고입니다.
가중치 메모리 공식
두 가지 사실을 결합하면 가중치 메모리는 간단한 곱셈이 됩니다:
가중치 바이트 (weight bytes) = 파라미터 수 (parameter count) × $\frac{8\text{ bits}}{\text{파라미터당 비트 (bits per parameter)}}$
8로 나누면 비트 (bits)를 바이트 (bytes)로 변환할 수 있습니다. 이것이 공식의 전부입니다. 4주 차 결과물은 이 공식을 적용하는 작은 계산기이므로, 매번 수동으로 산술 계산을 다시 하지는 않습니다.
다음은 전체 스크립트입니다:
#!/usr/bin/env python3
"""파라미터 수와 정밀도를 바탕으로 모델 가중치 메모리를 추정합니다.
...
먼저 로드맵 예시를 실행해 보겠습니다: INT4 형식의 700억 파라미터 모델입니다.
$ python3 public/week-04-parameters-memory/model_memory.py \
--parameters 70 --precision int4
parameter_count 70,000,000,000
...
각 파라미터가 0.5바이트(half a byte)인 700억(70 billion) 개의 파라미터는 35 GB의 가중치(weights)가 됩니다. 동일한 70B 모델을 BF16 정밀도로 사용할 경우 그 네 배인 약 140 GB가 됩니다. 이는 대략 130 GiB이며, 런타임 메모리가 추가되기 전인 Spark의 121 GiB 풀보다 이미 더 큽니다. 이것이 바로 거대 모델을 로컬에서 실행하려 하기 전에 양자화 (quantized)를 자주 수행하는 이유입니다.
실제 파일과 공식 대조하기
공식은 현실과 일치할 때만 신뢰할 수 있습니다. 저는 정확한 Qwen 파라미터 수와 실제 BF16 정밀도를 사용하여 계산기를 실행했습니다:
$ python3 public/week-04-parameters-memory/model_memory.py \
--count 3085938688 --precision bf16
parameter_count 3,085,938,688
...
계산기는 6,171,877,376 바이트라고 말합니다. 이제 3주 차에서 사용한 고정된 리비전(pinned revision)을 통해 직접 읽어온 모델 자체의 Safetensors 인덱스를 확인해 보겠습니다:
$ MODEL=Qwen/Qwen2.5-3B-Instruct
$ REV=aa8e72537993ba99e69dfaafa59ed015b17504d1
$ curl -sL "https://huggingface.co/$MODEL/raw/$REV/model.safetensors.index.json" \
...
동일합니다. 이 공식은 추측이 아닙니다. 균일한 정밀도(uniform precision)의 경우, 인덱스가 보고하는 텐서 데이터(tensor-data) 바이트 수와 정확히 일치합니다. 이 총합은 파라미터 페이로드(parameter payload)이며, 헤더를 포함한 파일 전체는 아니지만, 가중치 계산을 위해서는 제가 원하는 수치입니다.
실행 중인 모델에 가중치 이상의 메모리가 필요한 이유
지금까지 다룬 것은 디스크에 저장된 모델인 가중치 저장(weight storage)에 관한 것이었습니다. 실제로 작업을 수행하는 모델은 그보다 더 많은 메모리가 필요합니다. 추가 메모리는 크게 세 부분으로 나뉩니다:
- **활성화 메모리 (Activation memory)**는 요청을 처리하는 동안 생성되는 임시 숫자들입니다. 모델이 실행됨에 따라 생성되었다가 사라집니다.
- **KV-캐시 메모리 (KV-cache memory)**는 이전 토큰들로부터 저장된 중간 데이터를 보관하여, 모델이 새로운 토큰마다 이를 다시 계산하지 않도록 합니다. 이는 컨텍스트 길이(context length)에 따라 증가합니다. KV 캐시는 18주 차 주제이며, 여기서는 그것이 존재하고 메모리를 소모한다는 사실만 확인하면 됩니다.
- **프레임워크 오버헤드 (Framework overhead)**는 런타임 자체의 작업 메모리 및 버퍼입니다.
정확한 파일 체크(exact-file check)에는 BF16 Qwen이 사용되었습니다. 이번 런타임 실험에서는 2주 차에 이미 사용했던 Ollama의 양자화된 llama3.2:3b 모델로 전환합니다. 이 모델의 2.0 GB 패키지(package), 2.6 GB 로드(load), 4.1 GB 로드 값은 하나의 비교군에 속하며, Qwen의 6.17 GB BF16 가중치(weights)와 비교해서는 안 됩니다.
Ollama는 모델의 로드된 크기(loaded size)를 보고하므로, 이를 2.0 GB 패키지 크기와 비교할 수 있습니다. 4,096-토큰(token) 로드 시에는 런타임에 패키지 이상의 메모리가 필요하므로 2.0 GB를 초과할 것으로 예상합니다. 16,384-토큰 로드 시에는 더 많은 KV 캐시(KV-cache) 용량을 예약하기 때문에 크기가 다시 더 커질 것으로 예상합니다. ollama ps는 전체 합계를 보고하므로, 이 실험을 통해 변화량은 보여줄 수 있지만 KV 캐시에 할당된 정확한 바이트(byte) 수를 지정할 수는 없습니다.
Spark에 한 번 연결한 다음, 각 체크를 직접 실행합니다:
$ ssh spark
$ ollama list | awk 'NR == 1 || $1 == "llama3.2:3b"'
NAME ID SIZE MODIFIED
...
다음으로 기존에 로드된 복사본을 언로드(unload)하고, 4,096-토큰 컨텍스트(context)를 포함한 짧은 요청을 하나 보낸 뒤 로드된 모델을 검사합니다. API의 HTTP 200은 별도의 응답이 없는 요청이 완료되었음을 확인해 줍니다:
$ ollama stop llama3.2:3b >/dev/null 2>&1 || true
$ curl -fsS -o /dev/null -w 'HTTP %{http_code}\n' \
http://localhost:11434/api/generate \
...
16,384-토큰 컨텍스트로 동일한 요청을 반복합니다:
$ ollama stop llama3.2:3b >/dev/null 2>&1 || true
$ curl -fsS -o /dev/null -w 'HTTP %{http_code}\n' \
http://localhost:11434/api/generate \
...
Ollama는 패키지 크기를 2.0 GB로 나열합니다. 4,096-토큰 컨텍스트로 로드했을 때는 2.6 GB를 보고합니다. 16,384-토큰 컨텍스트로 로드했을 때는 4.1 GB를 보고합니다. 모델 자체는 변하지 않았습니다. 설정된 컨텍스트 용량만 변경되었으며, 보고된 로드 크기는 1.5 GB 증가했습니다. KV 캐시(KV cache)는 컨텍스트와 함께 증가하므로 이것이 예상되는 주요 원인이지만, ollama ps는 하나의 합계만을 보고하며 KV 캐시, 활성화(activations), 프레임워크 버퍼(framework buffers)를 나누어 표시하지 않습니다.
이것이 실질적인 교훈입니다. 가중치 메모리(weight memory)는 최저치(floor)일 뿐, 전체가 아닙니다. 런타임 메모리(Runtime memory)는 더 크며, 컨텍스트 길이(context length)는 이를 증가시키는 설정 중 하나입니다.
결과 (Results)
4주 차의 내용을 한곳에 모았습니다:
| 항목 | 검증된 값 |
|---|---|
| 공식 | 파라미터(parameters) × 비트(bits) / 8 |
| ... |
놀라웠던 점
Safetensors 인덱스와 정확한 바이트(byte)가 공식과 일치하는 것을 확인했을 때 매우 만족스러웠습니다. 파라미터 수와 정밀도(precision)를 알고 나면, 가중치 메모리는 정말로 단순한 곱셈에 불과합니다.
컨텍스트 효과(context effect)가 가장 유용했습니다. 2주 차부터 KV 캐시(KV cache)가 존재한다는 것은 알고 있었지만, 컨텍스트를 높이는 것만으로 2.0 GB 패키지가 4.1 GB의 로드된 메모리를 보고하는 것을 직접 확인하니 매우 구체적으로 다가왔습니다.
실수 및 문제 해결 (Mistakes and troubleshooting)
GB와 GiB를 구분하는 데에도 주의를 기울여야 했습니다. 계산기는 두 단위를 모두 출력하고, Ollama는 자체적으로 반올림된 GB 레이블을 출력하기 때문에, 잘못된 것들을 비교하지 않도록 단위를 명확히 표시해 두었습니다.
프로덕션에 미치는 영향 (Production implications)
가중치 메모리는 모델이 적재 가능한지를 결정하는 최저치를 설정합니다. 용량 계획(capacity planning)을 세울 때 저는 다음과 같이 하겠습니다:
- 파라미터 수와 정밀도를 통해 가중치를 추정하고, 양자화된(quantized) 모델의 경우 실제 파일과 대조하여 확인합니다.
- 실제 프롬프트 길이와 워크로드(workload)를 사용하여 전체 런타임 메모리를 측정합니다. KV 캐시와 활성화(activations)는 파라미터 수가 아니라 모델이 실제로 어떻게 사용되는지에 따라 달라지기 때문입니다.
- Spark의 121 GiB 통합 풀(unified pool)을 모델, 런타임, 그리고 시스템의 다른 모든 요소가 공유하는 자원으로 취급합니다.
공식은 가중치가 들어갈 수 있는지를 알려줍니다. 런타임 측정은 작동 중인 모델이 들어갈 수 있는지를 알려줍니다. 하드웨어를 예약하기 전에 이 두 가지 모두가 중요합니다.
다음에 배울 내용
5주 차에는 PyTorch 텐서(tensors)를 직접 다룹니다. CPU와 GPU에 텐서를 할당하고, FP32, FP16, BF16을 비교하며, 할당된 메모리가 어떻게 변하는지 관찰할 것입니다. 이를 통해 이번 주의 바이트 계산을 실행 중인 Python 프로세스 내부에서 측정할 수 있는 것으로 바꿀 것입니다.
14주 차에서 16주 차에는 양자화(quantization)를 제대로 다루며, 여기서 Q4_K_M 혼합 형식과 그 실제 비트 비용에 대해 상세히 설명될 것입니다.
직접 실행해보기 (Run it yourself)
공개된 4주 차 실습(lab)에는 계산기, 직접 실행 가능한 런타임 명령(runtime commands), 캡처된 결과, 관찰 사항 및 문제 해결(troubleshooting) 노트가 포함되어 있습니다.2 양자화된 파일(quantized-file) 노트에는 전체 명령과 출력 결과가 포함된 선택 사항인 Q4_K_M 저장 용량 조사 내용이 담겨 있습니다.3
선택적 심화 학습: 왜 Q4는 디스크에서 4비트보다 더 많은 용량을 사용하는가
간결한 공식은 모든 파라미터(parameter)가 동일한 수의 비트(bit)를 사용한다고 가정합니다. 양자화된 파일(quantized file)은 여러 텐서 형식(tensor formats)을 혼합하고 추가적인 스케일 데이터(scale data)를 저장할 수 있으므로, "4비트"라고 표시된 모델이라도 디스크 상에서는 파라미터당 4비트보다 더 많은 용량을 사용할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기