RSS
AI 자동화

GGUF와 safetensors의 차이: Q4_K_M 같은 이름 읽는 법

작성자
듀오랩스 대표·26분 읽기

Hugging Face에서 Qwen/Qwen2.5-7B-Instruct를 열면 파일이 14개 있습니다. config.json, tokenizer.json, vocab.json, merges.txt가 있고, model-00001-of-00004.safetensors부터 model-00004-of-00004.safetensors까지 네 조각과 그 조각들의 목차인 model.safetensors.index.json이 있습니다.

같은 회사가 올린 Qwen/Qwen2.5-7B-Instruct-GGUF를 열면 풍경이 다릅니다. config.json도 tokenizer.json도 없습니다. 대신 qwen2.5-7b-instruct-q4_k_m-00001-of-00002.gguf, qwen2.5-7b-instruct-q8_0-00001-of-00003.gguf처럼 이름에 암호 같은 꼬리가 붙은 파일이 줄지어 있습니다.

모델은 같은데 저장소가 왜 둘일까요. 그리고 토크나이저 파일 없이 GGUF 쪽은 어떻게 돌아갈까요. 이 두 질문을 풀면 파일 이름의 Q4_K_M도 저절로 읽힙니다.

같은 모델, 두 저장소가 말하는 실행 환경의 차이

흔히 이렇게 이해합니다. 모델 파일은 확장자만 다를 뿐 안에 든 가중치는 같고, 어느 실행기에서든 형식만 맞춰 주면 열린다고요. zip과 tar처럼 포장지만 다른 것으로 보는 셈입니다.

이 그림으로는 위 두 저장소가 설명되지 않습니다. safetensors 저장소는 파일 하나만 받아서는 아무것도 못 합니다. GGUF 저장소에는 설정 파일이 없는데도 파일 하나로 대화가 됩니다. 담긴 숫자의 정밀도도 다릅니다. safetensors 쪽은 BF16 원본이고, GGUF 쪽은 fp16 하나를 빼면 전부 2비트에서 8비트 사이로 줄인 사본입니다.

저는 형식을 포장지가 아니라 실행기에 대한 가정으로 읽어야 한다고 봅니다. safetensors는 "텐서를 안전하게 담는 상자이고, 모델 구조와 토크나이저는 파이썬 라이브러리가 따로 안다"는 가정 위에 있습니다. GGUF는 "외부 라이브러리 없는 C/C++ 실행기가 파일 하나만 보고 모델을 띄운다"는 가정 위에 있습니다. 가정이 다르니 담는 것이 다르고, 담는 것이 다르니 서로 바꿔 끼울 수 없습니다.

safetensors의 출발점, pickle의 임의 코드 실행

safetensors가 나오기 전 PyTorch 가중치는 torch.save()로 만든 파일로 오가는 일이 많았습니다. PyTorch 직렬화 문서는 이런 파일에 관례상 .pt나 .pth 확장자를 붙인다고 적고, torch.save()와 torch.load()가 기본으로 파이썬 pickle을 쓴다고 밝힙니다.

pickle은 데이터만 담는 형식이 아닙니다. 객체를 되살리는 방법, 즉 어떤 함수를 불러 객체를 만들지까지 담습니다. 그래서 파이썬 공식 문서의 pickle 페이지는 첫머리에 이렇게 경고합니다.

The pickle module is not secure. Only unpickle data you trust.

인터넷에서 받은 모델 파일을 여는 순간 그 안에 심어 둔 코드가 돈다는 뜻입니다. 모델 허브처럼 누구나 올리는 곳에서는 치명적인 성질입니다. safetensors README는 이 라이브러리의 존재 이유를 한 문장으로 적어 두었습니다. PyTorch가 기본으로 쓰는 pickle을 쓰지 않아도 되게 하는 것이라고요.

PyTorch 쪽도 손을 놓고 있지는 않았습니다. torch.load 문서를 보면 지금 시그니처의 기본값이 weights_only=True이고, 직렬화 문서는 2.6 버전부터 pickle_module을 따로 넘기지 않으면 이 값이 기본이라고 적습니다. weights_only=True는 텐서와 기본 타입을 되살리는 데 필요한 함수만 허용합니다. 다만 문서 표현은 "원격 코드 실행 공격면을 좁힌다(narrows)"입니다. 없앤다고 하지 않았습니다. 형식 자체에 실행할 코드가 들어갈 자리가 없는 safetensors와는 결이 다른 대책입니다.

헤더와 바이트 버퍼뿐인 safetensors의 구조

safetensors README에 적힌 형식은 짧습니다. 파일 맨 앞 8바이트가 헤더 길이를 담은 부호 없는 64비트 정수(리틀 엔디언)이고, 그 길이만큼 JSON 헤더가 오고, 나머지는 전부 바이트 버퍼입니다. 헤더는 텐서 이름마다 dtype, shape, data_offsets를 적은 사전입니다. __metadata__라는 특별한 키가 하나 허용되는데, 값은 문자열에서 문자열로 가는 맵이어야 하고 임의의 JSON은 받지 않습니다.

이 단순함이 곧 안전장치입니다. 헤더 크기는 100MB로 제한돼 거대한 JSON으로 파서를 괴롭힐 수 없고, 텐서들의 주소는 서로 겹치지 않으며 버퍼에 빈 구멍이 있어서도 안 됩니다. README는 이 규칙이 한 파일이 두 형식으로 동시에 해석되는 폴리글롯 파일을 막는다고 설명합니다.

헤더에 오프셋이 다 적혀 있으니 텐서 하나만 골라 읽을 수 있습니다. README는 이를 지연 로딩이라 부르고, BLOOM을 GPU 8장에 올리는 시간이 일반 PyTorch 가중치로 10분이던 것이 이 형식으로 45초가 됐다고 적었습니다. 제로 카피라는 표현에는 README 스스로 단서를 붙여 둡니다. ML에서 진짜 제로 카피인 형식은 없고, CPU에서 파일이 이미 캐시에 있을 때만 그렇다고요. GPU로는 언제나 한 번 복사가 일어납니다. 그래서 저는 이 단서를 빼고 "safetensors는 제로 카피"라고만 옮긴 설명은 절반만 맞다고 봅니다.

눈여겨볼 것은 여기에 없는 것입니다. 층이 몇 개인지, 어텐션 헤드가 몇 개인지, 어떤 토크나이저를 쓰는지가 형식 어디에도 정해져 있지 않습니다. __metadata__에 문자열을 넣을 수는 있지만 무엇을 넣어야 하는지는 명세가 정하지 않습니다. safetensors는 처음부터 텐서 상자로 설계됐고, 모델이 되려면 상자 바깥의 무언가가 필요합니다.

폴더 전체가 하나의 모델인 Hugging Face 저장소

그 바깥의 무언가가 처음에 본 14개 파일입니다. config.json이 아키텍처와 층 수, 숨은 차원 같은 하이퍼파라미터를 담고, tokenizer.json과 vocab.json, merges.txt가 토크나이저를 담습니다. 대화 형식을 정하는 채팅 템플릿은 이 저장소의 경우 tokenizer_config.json 안에 Jinja 문자열로 들어 있습니다.

가중치는 네 조각입니다. 목차 파일인 model.safetensors.index.json을 열면 weight_map에 텐서 339개가 어느 조각에 있는지 적혀 있습니다. lm_head.weight는 4번 조각에, model.embed_tokens.weight는 1번 조각에 있는 식입니다. 같은 파일의 total_size는 15,231,233,024바이트입니다. Hub API가 알려 주는 파라미터 수 7,615,616,512개에 BF16 한 개당 2바이트를 곱하면 정확히 이 값이 나옵니다. 양자화하지 않은 원본이라는 뜻입니다.

이 폴더를 해석하는 것은 파일이 아니라 라이브러리입니다. transformers가 config.json의 아키텍처 이름을 보고 자기 안의 모델 클래스를 고른 뒤, 그 클래스가 기대하는 텐서 이름에 맞춰 safetensors 조각들을 채웁니다. 모델의 계산 그래프는 파일에 없고 파이썬 코드에 있습니다. 그래서 새 아키텍처가 나오면 파일 형식은 그대로인데 라이브러리를 올려야 하는 일이 생깁니다.

이 구조는 학습과 미세 조정에 잘 맞습니다. 원본 정밀도가 그대로 있고, 텐서 단위로 읽고 쓸 수 있고, 여러 GPU에 나눠 올리기 쉽습니다. vLLM 같은 GPU 서빙 엔진이 이 폴더를 그대로 받는 것도 같은 이유입니다.

실행에 필요한 것을 한 파일에 넣은 GGUF

GGUF는 llama.cpp의 바탕 라이브러리인 ggml 프로젝트의 형식입니다. GGUF 명세는 첫 문단에서 GGML 계열 실행기로 추론하기 위한 형식이라고 밝히고, 모델은 보통 PyTorch 같은 프레임워크에서 만든 뒤 GGUF로 변환해서 쓴다고 적습니다. 출발점부터 학습용이 아니라 배포용이라는 뜻입니다.

명세가 내건 목표 중 첫째가 단일 파일 배포입니다. 추가 정보를 위해 바깥 파일이 필요하지 않아야 하고, 모델을 띄우는 데 필요한 모든 정보가 파일 안에 있어야 한다고 적었습니다. 그 결과 GGUF에는 safetensors 폴더가 여러 파일로 나눠 들던 것이 키-값 메타데이터로 들어갑니다. general.architecture가 아키텍처를, llama.context_length 같은 키가 하이퍼파라미터를, tokenizer.ggml.tokens와 tokenizer.ggml.merges가 어휘와 병합 규칙을, tokenizer.chat_template이 채팅 템플릿을 담습니다. Qwen의 GGUF 저장소에 tokenizer.json이 없던 이유가 이것입니다.

파일 구조도 명세에 그대로 있습니다. 맨 앞 4바이트가 GGUF라는 매직 넘버이고, 형식 버전(현재 명세는 3), 텐서 개수, 메타데이터 개수, 메타데이터 키-값들, 텐서 정보 목록, 그리고 텐서 데이터가 이어집니다. 텐서 데이터의 시작 위치는 general.alignment의 배수여야 하고, 이 값은 8의 배수여야 하며, 적혀 있지 않으면 32로 봅니다. 이 정렬은 파일을 mmap으로 바로 메모리에 비추기 위한 것입니다. 명세는 mmap 호환을 목표 목록에 따로 적어 두었습니다.

GGUF 이전에도 GGML, GGMF, GGJT라는 형식이 있었고 GGUF는 그 후속입니다. 명세의 「Historical State of Affairs」 절이 옛 형식의 문제를 정리해 두었는데, 하이퍼파라미터가 이름 없는 값의 목록이라 하나를 더하거나 빼면 기존 파일이 깨졌고, 파일만 보고는 어떤 아키텍처인지조차 알 수 없었습니다. GGUF는 이것을 이름 붙은 키-값으로 바꿨습니다. 같은 절에서 다른 형식을 쓰지 않은 이유도 밝히는데, C 환경에서 외부 의존성이 늘어나는 것, 4비트 양자화 지원이 약한 것, 어휘를 품지 못하는 것과 함께 "모델이 디렉터리냐 파일이냐에 대한 기존 문화적 기대"를 꼽았습니다. 저는 이 마지막 항목이 두 형식의 차이를 가장 정확하게 짚는 문장이라고 봅니다.

Ollama가 이 GGUF 파일을 받아 돌리는 실행기라는 점, 그리고 Hugging Face가 모델 저장소라는 점은 허깅페이스와 Ollama의 차이: 모델 저장소와 실행기에서 다뤘습니다. 여기서는 파일 안쪽, 특히 이름에 붙은 꼬리를 더 들여다보겠습니다.

Q4_K_M을 세 조각으로 읽는 방법

GGUF 명세에는 파일 이름 규칙도 있습니다. <BaseName><SizeLabel><FineTune><Version><Encoding><Type><Shard>.gguf 순서이고 각 칸은 하이픈으로 나눕니다. Q4_K_M은 이 중 Encoding 칸, 즉 가중치를 어떻게 부호화했는지를 적는 자리입니다. 명세의 예시인 Grok-100B-v1.0-Q4_0-00003-of-00009.gguf라면 Grok이라는 이름, 1,000억 파라미터, 버전 1.0, Q4_0 부호화, 9조각 중 3번째로 읽힙니다.

Q4_K_M은 세 조각으로 읽습니다. Q4는 가중치 하나를 대략 4비트로 줄였다는 뜻입니다. K는 K-quants라는 방식을 썼다는 뜻입니다. M은 텐서마다 정밀도를 어떻게 섞었는지를 가리킵니다. 하나씩 보겠습니다.

양자화의 기본 발상은 가중치를 작은 묶음(블록)으로 나누고, 블록마다 배율 하나를 따로 둔 뒤 각 가중치는 그 배율에 곱할 작은 정수로만 저장하는 것입니다. 가장 오래된 Q4_0의 정의가 ggml 소스에 있습니다. 블록 하나가 가중치 32개이고, 16비트 배율 하나(2바이트)와 4비트 정수 32개(16바이트)로 이뤄집니다. 18바이트로 가중치 32개를 담으니 가중치당 4.5비트입니다. 이름은 4비트인데 실제로는 배율 몫까지 0.5비트가 더 듭니다. Q8_0도 같은 틀에 8비트 정수를 넣어 34바이트에 32개, 가중치당 8.5비트가 됩니다. llama.cpp quantize README의 측정표에서 Q8_0이 8.5008비트로 찍힌 것과 맞습니다.

K-quants는 llama.cpp PR #1684에서 들어왔습니다. 핵심은 블록 위에 블록을 하나 더 두는 것입니다. 가중치 256개(QK_K)를 슈퍼블록으로 묶고, 그 안의 작은 블록들의 배율을 다시 양자화해 저장합니다. PR 설명에 따르면 Q4_K는 가중치 32개짜리 블록 8개를 슈퍼블록 하나로 묶고, 블록마다 배율과 최솟값을 6비트로 줄여 담아 가중치당 4.5비트가 됩니다. 같은 4.5비트인데 Q4_0에는 없던 블록별 최솟값이 생겼으니, 같은 비트로 더 많은 정보를 담는 셈입니다. 같은 PR이 밝힌 다른 형식들은 Q2_K 2.5625비트, Q3_K 3.4375비트, Q5_K 5.5비트, Q6_K 6.5625비트입니다.

IQ로 시작하는 형식(IQ2_XXS, IQ3_S, IQ4_XS 등)은 i-quants라는 그다음 세대입니다. llama.cpp의 quantize.cpp는 IQ4_XS를 "4.25 bpw non-linear quantization"으로 설명하고, Hugging Face Hub의 GGUF 문서는 이 계열의 가중치가 슈퍼블록 배율과 중요도 행렬(importance matrix)로 구해진다고 적습니다. 중요도 행렬은 표본 텍스트를 돌려 어떤 가중치가 결과에 크게 기여하는지 잰 값이고, quantize README는 적절한 imatrix 파일로 정확도 손실을 줄일 수 있다고 안내합니다. i-quants의 내부 부호표가 어떻게 생겼는지는 이 글에서 다룰 깊이를 넘어서 남겨 둡니다.

나머지 이름은 간단합니다. F16과 BF16은 양자화하지 않은 16비트 부동소수점이고, 둘의 차이는 지수와 가수에 비트를 어떻게 나누느냐입니다. Hugging Face 원본이 BF16이면 GGUF로 옮길 때도 BF16으로 두는 것이 정보 손실이 없습니다.

_S, _M, _L이 정하는 텐서별 배합

Q4_K와 Q4_K_M은 다른 것을 가리킵니다. 앞의 것은 텐서 하나의 부호화 방식이고, 뒤의 것은 모델 파일 전체의 배합입니다. 모든 텐서가 양자화에 똑같이 민감하지는 않아서, 일부 텐서에는 더 높은 정밀도를 주고 나머지는 낮게 두는 것이 접미사의 역할입니다.

PR #1684의 설명은 이렇습니다. Q4_K_S는 모든 텐서에 Q4_K를 씁니다. Q4_K_M은 attention.wv와 feed_forward.w2 텐서의 절반에 Q6_K를 쓰고 나머지는 Q4_K를 씁니다. Q3_K_M과 Q3_K_L도 같은 식으로, 몇몇 텐서를 각각 Q4_K, Q5_K로 올립니다. 그리고 모든 변형이 output.weight에는 6비트를 씁니다. 그러니 S, M, L은 작게, 중간, 크게 정도로 읽으면 되고, 글자가 커질수록 높은 정밀도를 받는 텐서가 늘어납니다.

이 배합 때문에 파일 이름의 숫자와 실제 비트 수가 어긋납니다. quantize README가 Llama-3.1-8B로 잰 표에서 Q4_K_S는 가중치당 4.6672비트, Q4_K_M은 4.8944비트, Q5_K_S는 5.5704비트, Q5_K_M은 5.7036비트입니다. Q4_K 자체는 4.5비트인데 Q4_K_M 파일 전체는 5비트에 가깝습니다. 같은 README는 Llama 3.1 8B가 원본 32.1GB에서 Q4_K_M으로 4.9GB가 된다고 적었습니다.

여기서부터는 제 확신이 줄어드는 부분입니다. PR #1684는 2023년의 설명이고, 그 뒤 k-quants 조정 같은 PR이 여러 번 들어왔습니다. 지금 llama.cpp가 Q4_K_M을 고를 때 정확히 어떤 텐서를 올리는지는 PR 설명이 아니라 현재 소스의 판단 로직이 정합니다. 게다가 GGUF 명세는 Encoding 칸에 대해 "내용과 타입 배합, 배치는 사용자 코드가 정하며 프로젝트마다 다를 수 있다"고 적어 두었습니다. 파일 이름은 사람이 읽으라고 붙인 꼬리표이지 내용을 보증하는 계약이 아닙니다. 확실히 알고 싶다면 Hugging Face의 GGUF 뷰어처럼 텐서별 타입을 직접 보여 주는 도구로 파일을 여는 것이 맞습니다.

GPU 서빙 엔진을 위한 safetensors 쪽 양자화

양자화가 GGUF만의 것은 아닙니다. safetensors 세계에도 GPTQ, AWQ, bitsandbytes, FP8 같은 양자화가 있고, transformers의 양자화 개요가 이들을 한 표에 비교해 둡니다. Qwen이 올린 Qwen2.5-7B-Instruct-GPTQ-Int4를 열면 이 방식이 보입니다. 파일 구성은 원본 저장소와 거의 같고 safetensors 조각이 넷에서 둘로 줄었을 뿐입니다. 달라진 곳은 config.json입니다. quantization_config 항목에 "quant_method": "gptq", "bits": 4, "group_size": 128이 들어 있습니다. 가중치 128개씩 묶어 배율을 둔다는 뜻이니 발상은 GGUF의 블록과 같습니다. 폴더 구조는 그대로 두고 상자 속 숫자와 설정만 바꾸는 방식입니다.

차이는 겨냥하는 실행기에 있습니다. 개요 표의 FP8 두 방식(FBGEMM_FP8, FINEGRAINED_FP8)은 CPU와 Apple Silicon(Metal) 칸이 비어 있고 CUDA GPU에만 표시가 있습니다. GGUF 행은 CPU, CUDA, Metal에 모두 표시가 있습니다. vLLM 문서의 양자화 목록에도 AWQ, GPTQ, bitsandbytes 항목이 따로 있습니다. 표가 모든 것을 말해 주지는 않습니다. GPTQ 행도 Metal을 지원한다고 표시돼 있습니다. 그래도 저는 이 구분을 "같은 4비트라도 GPU 서버에서 여러 요청을 동시에 처리할 것인가, 개인 기기 한 대의 메모리에 모델을 맞춰 넣을 것인가"의 차이로 이해합니다.

한 방향으로만 흐르는 변환

Hugging Face 모델을 GGUF로 만드는 길은 llama.cpp가 공식으로 냅니다. quantize README는 두 단계로 설명합니다. 먼저 저장소 최상위의 convert_hf_to_gguf.py로 원본 폴더를 고정밀 GGUF로 바꿉니다.

python convert_hf_to_gguf.py --outfile model-bf16.gguf --outtype bf16 ./Qwen2.5-7B-Instruct

그다음 llama-quantize로 원하는 형식으로 줄입니다.

./build/bin/llama-quantize model-bf16.gguf model-Q4_K_M.gguf Q4_K_M

README는 이 두 단계를 나누어 두는 데 이유를 붙입니다. --allow-requantize 옵션 설명에 이미 양자화된 텐서를 다시 양자화하면 16비트나 32비트에서 바로 하는 것보다 품질이 크게 떨어질 수 있다는 경고가 있습니다. 그래서 정석은 언제나 원본 정밀도에서 한 번에 내려가는 것입니다. Q8_0 파일을 받아 Q4_K_M을 만드는 것은 권하는 길이 아닙니다.

반대 방향은 어떨까요. transformers는 gguf_file 인자로 GGUF를 읽을 수 있습니다. 그런데 transformers GGUF 문서를 보면, 압축된 채로 계산하는 경로는 Qwen3.5 계열에만 있고 나머지 아키텍처는 기존 로더를 거쳐 항상 역양자화한다고 적혀 있습니다. 4비트로 줄인 값을 부동소수점으로 다시 펼친다는 뜻이고, 줄일 때 버린 정보는 돌아오지 않습니다. 같은 개요 표에서 GGUF 행은 PEFT 미세 조정과 transformers로의 직렬화가 둘 다 지원하지 않는다고 표시돼 있습니다.

vLLM도 GGUF를 받기는 합니다. 다만 vLLM 문서는 GGUF 지원이 "매우 실험적이고 최적화가 덜 됐다"고 경고하고, 지금은 별도 플러그인을 설치해야 한다고 안내합니다. 토크나이저 안내가 특히 그렇습니다. GGUF에서 토크나이저를 변환하는 일이 느리고 불안정하니 원본 모델의 토크나이저를 따로 지정하라고 권합니다. 파일 하나에 다 넣은 GGUF를 받아 놓고, safetensors 세계의 실행기는 결국 바깥 파일을 다시 찾는 셈입니다. 형식이 실행기에 대한 가정이라는 말이 여기서 가장 또렷하게 보입니다.

반대로 Ollama의 모델 가져오기 문서는 Modelfile의 FROM에 safetensors 폴더를 가리키는 길과 GGUF 파일을 가리키는 길을 둘 다 적어 둡니다. 다만 GGUF는 들여올 때 양자화하지 않으니 llama.cpp의 llama-quantize로 미리 줄여 오라고 합니다. safetensors 폴더를 들여올 때 내부에서 무엇을 어떻게 바꾸는지는 이 문서가 밝히지 않아서, 저도 단정하지 않겠습니다.

제가 형식을 고르는 기준

저라면 실행할 장소부터 정합니다. 노트북, Mac, CPU 서버, 소비자용 GPU 한 장처럼 메모리가 빠듯한 곳에서 한 사람이 쓸 모델을 돌린다면 GGUF를 고릅니다. 파일 하나로 옮겨지고, 이름만 보고 대략의 크기를 가늠할 수 있고, llama.cpp 계열 실행기는 그 파일 하나만 있으면 됩니다. 처음 고를 때는 Q4_K_M에서 시작하는 편이 무난하다고 봅니다. llama.cpp의 quantize README가 사용법 예시로 드는 형식도 Q4_K_M입니다.

GPU 서버에서 vLLM 같은 엔진으로 여러 사용자의 요청을 받을 거라면 safetensors 폴더를 그대로 쓰고, 줄여야 한다면 그 엔진이 잘 다루는 GPTQ, AWQ, FP8 쪽을 고릅니다. 학습이나 미세 조정이 조금이라도 계획에 있다면 고민할 것 없이 원본 safetensors입니다.

어느 쪽을 고르든 원본은 지우지 않습니다. GGUF는 실행을 위한 사본이지 원본의 대체물이 아닙니다. 지금 llama.cpp에서 돌리려고 만든 Q4_K_M 파일로는 내일 미세 조정을 시작할 수 없고, 더 나은 양자화 방식이 나왔을 때 다시 만들 재료도 되지 못합니다. 변환은 Hugging Face에서 GGUF로 흐르는 한 방향이라고 생각하고, 원본 폴더는 언제든 다시 변환할 재료로 남겨 둡니다.

품질과 속도가 양자화 단계마다 실제로 얼마나 갈리는지는 이 글이 다루지 않았습니다. 그것은 모델마다, 기기마다 재 봐야 하는 문제이고, 파일 이름은 그 측정을 어디서 시작할지 알려 줄 뿐입니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.