Published on

PyTorch→GGUF 변환 실패 - 양자화·텐서명 해결

Authors

서빙용 로컬 LLM 파이프라인에서 PyTorch 체크포인트를 GGUF로 변환하려다 실패하는 케이스는 생각보다 패턴이 뚜렷합니다. 특히 llama.cpp 계열 툴체인에서 많이 겪는 문제는 크게 두 갈래입니다.

  • 양자화(quantization) 단계에서의 실패: q4_k_m 같은 스킴 적용 중 텐서 dtype, shape, 메모리 정렬, 지원 연산 불일치로 중단
  • 텐서명(tensor name) 불일치: 모델이 기대하는 키 이름과 실제 체크포인트 state_dict 키가 다르거나, 레이어 인덱싱 규칙이 달라 매핑이 깨짐

이 글은 “왜 실패하는지”를 로그에서 빠르게 분류하고, 변환 전에 PyTorch 체크포인트를 정리하거나 변환 스크립트에 매핑 규칙을 추가해 끝까지 GGUF를 뽑아내는 방법을 다룹니다.

참고로 로컬 LLM이 메모리에서 터지는 문제는 변환 이슈와는 별개지만, 변환 후 실행 단계에서 OOM이 자주 이어집니다. 실행 메모리 최적화는 별도 글인 Transformers 로컬 LLM OOM 해결 - 4bit+오프로딩도 함께 보시면 흐름이 매끈합니다.

변환 파이프라인을 먼저 고정하기

PyTorch→GGUF는 보통 아래 흐름 중 하나입니다.

  1. PyTorch(.bin/.pt/.safetensors) → (필요 시) HF Transformers 형태로 정규화
  2. HF 모델을 llama.cpp 변환 스크립트로 GGUF 생성
  3. 생성된 GGUFquantize 바이너리로 양자화(Q4_K_M, Q5_K_M, Q8_0 등)

문제는 2, 3 단계에서 각각 다른 유형의 에러가 터진다는 점입니다. 그래서 로그를 보고 어느 단계에서 죽는지부터 분리해야 합니다.

  • 변환 스크립트에서 죽으면: 텐서명, shape, config 불일치 가능성이 큼
  • quantize에서 죽으면: dtype, 지원 스킴, 특정 텐서 예외 처리 문제 가능성이 큼

버전 불일치가 만드는 “가짜” 오류

llama.cpp는 GGUF 포맷과 텐서명 규칙이 빠르게 변합니다. 즉, 같은 모델이라도 llama.cpp 커밋 시점에 따라

  • 요구하는 키 이름
  • RoPE 스케일링 메타데이터
  • GQA 관련 헤더 필드

등이 달라져 “텐서명 불일치”처럼 보이는 오류가 발생할 수 있습니다.

가장 먼저 할 일은 llama.cpp를 최신으로 맞추거나, 최소한 모델이 만들어진 시점과 비슷한 커밋으로 맞추는 것입니다.

git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
git pull
make -j

증상 1: 텐서명 불일치로 변환이 중단될 때

대표적인 로그 패턴은 다음과 비슷합니다.

  • KeyError: ... 형태로 특정 텐서를 못 찾음
  • unexpected key(s) in state_dict 또는 missing key(s)
  • 특정 블록만 반복적으로 누락

이 문제는 대부분 체크포인트가 표준 HF 네이밍 규칙과 다르거나, 학습 프레임워크가 키에 접두사를 붙였기 때문입니다.

1) 가장 흔한 원인: 접두사(prefix) 문제

예를 들어 학습 시 DataParallel 또는 래퍼가 붙으면 키가 module.로 시작합니다.

  • 기대: model.layers.0.self_attn.q_proj.weight
  • 실제: module.model.layers.0.self_attn.q_proj.weight

이 경우 변환 전에 prefix를 제거해 저장하면 끝납니다.

import torch

src = "pytorch_model.bin"
dst = "pytorch_model.stripped.bin"

sd = torch.load(src, map_location="cpu")
if "state_dict" in sd:
    sd = sd["state_dict"]

new_sd = {}
for k, v in sd.items():
    nk = k
    if nk.startswith("module."):
        nk = nk[len("module."):]
    new_sd[nk] = v

torch.save(new_sd, dst)
print("saved:", dst, "keys:", len(new_sd))

2) 모델 아키텍처 차이: 레이어 명명 규칙이 다름

LLaMA 계열이라고 해도 구현체에 따라 다음이 달라질 수 있습니다.

  • self_attn.q_proj vs attention.wq
  • mlp.gate_proj vs feed_forward.w1
  • layernorm 위치 및 이름

이건 단순 prefix 제거로 해결이 안 되고, 키 리네이밍 규칙이 필요합니다. 가장 안전한 방식은 “현재 키 목록”과 “기대 키 목록”을 비교해 매핑 테이블을 만드는 것입니다.

키 목록 덤프해서 차이 확인

import torch
from collections import Counter

sd = torch.load("pytorch_model.stripped.bin", map_location="cpu")
keys = list(sd.keys())

print("num_keys:", len(keys))
print("sample:")
for k in keys[:30]:
    print(" ", k)

# 상위 prefix 통계
prefixes = [".".join(k.split(".")[:3]) for k in keys]
print("top prefixes:")
for p, c in Counter(prefixes).most_common(20):
    print(p, c)

이 결과를 바탕으로 변환 스크립트에서 요구하는 키와 맞추거나, 변환 전에 아예 state_dict를 재작성합니다.

예시: attention 네이밍을 LLaMA 스타일로 치환

아래 코드는 예시입니다. 실제로는 모델별로 매핑이 달라서, replace 규칙을 최소 단위로 쪼개 검증하는 게 좋습니다.

import torch

sd = torch.load("pytorch_model.stripped.bin", map_location="cpu")

rules = [
    ("attention.wq.", "self_attn.q_proj."),
    ("attention.wk.", "self_attn.k_proj."),
    ("attention.wv.", "self_attn.v_proj."),
    ("attention.wo.", "self_attn.o_proj."),
    ("feed_forward.w1.", "mlp.gate_proj."),
    ("feed_forward.w2.", "mlp.down_proj."),
    ("feed_forward.w3.", "mlp.up_proj."),
]

new_sd = {}
for k, v in sd.items():
    nk = k
    for a, b in rules:
        nk = nk.replace(a, b)
    new_sd[nk] = v

torch.save(new_sd, "pytorch_model.renamed.bin")
print("done")

3) config.json 불일치: 변환기가 메타를 못 맞춤

GGUF는 텐서뿐 아니라 모델 메타데이터도 중요합니다. 특히 아래 값이 실제 가중치와 불일치하면, 변환 중 또는 로딩 중 문제가 납니다.

  • num_hidden_layers
  • hidden_size
  • num_attention_heads
  • num_key_value_heads (GQA)
  • rope_theta, RoPE scaling 관련 필드

체크포인트만 있고 config.json이 부정확하면, 최소한 위 값은 가중치 shape에서 역추정해 맞춰야 합니다.

증상 2: 양자화 단계에서 실패할 때

GGUF는 만들어졌는데 quantize 단계에서 죽는 경우가 있습니다. 이때는 텐서명보다는 다음을 의심합니다.

  • 특정 텐서가 양자화 대상이 아니어야 하는데 포함됨
  • 지원하지 않는 dtype 또는 비정상 shape
  • 너무 오래된 quantize 바이너리 사용

1) 양자화 스킴과 모델 타입 매칭

모든 모델이 모든 양자화 스킴에서 잘 동작하는 건 아닙니다. 특히 MoE, 비표준 블록, 또는 특수 레이어가 들어간 모델은

  • Q4_K_M에서 실패하지만 Q8_0에서는 성공
  • 혹은 Q5_K_M에서만 성공

같은 편차가 생깁니다.

실전에서는 아래 순서로 “안전하게” 좁혀가는 게 좋습니다.

  1. 먼저 Q8_0로 성공 여부 확인
  2. 성공하면 Q6_K 또는 Q5_K_M로 다운
  3. 마지막에 Q4_K_M 같은 공격적인 스킴 시도
# 예시: fp16 GGUF를 Q8_0로
./quantize model.fp16.gguf model.q8_0.gguf q8_0

# 성공 후 더 줄이기
./quantize model.fp16.gguf model.q5_k_m.gguf q5_k_m

2) quantize가 특정 텐서에서 죽을 때: 예외 텐서 분리

일부 텐서는 양자화하면 안 되거나(예: 스케일/바이어스 계열), 특정 방식으로만 처리해야 합니다. 최신 llama.cpp는 이런 예외를 많이 내장했지만, 모델이 비표준이면 여전히 실패합니다.

이때 접근은 두 가지입니다.

  • llama.cpp 최신화로 예외 규칙을 얻는다
  • 변환 단계에서 텐서를 “표준 이름”으로 맞춰 예외 규칙에 걸리게 한다

즉, 양자화 실패가 사실상 “텐서명 불일치의 변형”인 경우도 많습니다.

3) dtype 문제: bf16, fp32 혼재

PyTorch 체크포인트가 bf16fp32가 섞여 있거나, 일부 텐서가 fp32로 남아 있으면 변환 또는 양자화에서 예상치 못한 경로를 탈 수 있습니다.

변환 전, 전체를 fp16 또는 bf16으로 통일하는 것이 안전합니다.

import torch

sd = torch.load("pytorch_model.renamed.bin", map_location="cpu")

def to_fp16(t):
    if not torch.is_floating_point(t):
        return t
    return t.to(dtype=torch.float16)

new_sd = {k: to_fp16(v) for k, v in sd.items()}
torch.save(new_sd, "pytorch_model.fp16.bin")
print("converted to fp16")

변환 성공률을 올리는 체크리스트

여기부터는 “한 번에 성공”을 위한 실전 체크리스트입니다.

1) 변환 전 점검

  • 체크포인트가 state_dict 단일 딕셔너리인지 확인
  • 키에 module. 같은 prefix가 붙었는지 확인
  • config.json이 실제 모델과 일치하는지 확인
  • float 텐서 dtype이 섞였는지 확인

2) 변환 중 점검

  • 실패 로그에서 “없는 키”가 어떤 규칙으로 반복되는지 본다
  • 특정 블록(예: layers.0, layers.1)만 누락이면 인덱싱 규칙 차이를 의심
  • attention/MLP 네이밍이 완전히 다르면 매핑 테이블이 필요

3) 양자화 단계 점검

  • 먼저 Q8_0로 성공시키고, 그 다음 더 작은 스킴을 시도
  • 실패 시 llama.cpp 최신화 후 재시도
  • 그래도 실패하면 텐서명이 표준 예외 규칙에 걸리는지(예: norm, embed, output) 확인

재현 가능한 디버깅 루틴(권장)

변환 실패는 “한 번에 다 고치려다” 더 꼬입니다. 아래처럼 단계별로 산출물을 남기면 원인 추적이 빨라집니다.

  1. pytorch_model.bin 원본 보관
  2. prefix 제거 후 pytorch_model.stripped.bin
  3. 키 매핑 후 pytorch_model.renamed.bin
  4. dtype 통일 후 pytorch_model.fp16.bin
  5. GGUF(fp16) 생성
  6. Q8_0 양자화 성공
  7. 목표 스킴으로 다운그레이드

이 루틴은 장애 대응 관점에서도 유용합니다. 원인 추적을 단계적으로 쪼개는 방식은 인프라 문제에서도 동일하게 통합니다. 예를 들어 장애 분석 흐름을 잡는 감각은 systemd 서비스가 계속 재시작될 때 원인 추적법 같은 글의 접근과도 유사합니다.

FAQ: 자주 묻는 함정

GGUF는 만들었는데 로딩 시 크래시가 납니다

대개 메타데이터 불일치(헤더 필드) 또는 토크나이저 파일 불일치입니다.

  • tokenizer.model 또는 tokenizer.json이 모델과 맞는지
  • bos/eos 토큰 ID가 맞는지
  • rope_scaling 관련 설정이 런타임과 맞는지

를 확인하세요.

같은 모델인데 어떤 사람은 되는데 나는 안 됩니다

대부분 아래 둘 중 하나입니다.

  • llama.cpp 커밋 차이
  • 체크포인트가 미묘하게 다른 변형(예: merged, lora merged, vocab 변경)

버전을 고정하고, 체크포인트 해시를 비교해 동일 파일인지부터 확인하는 게 빠릅니다.

마무리

PyTorch→GGUF 변환 실패는 겉보기엔 복잡하지만, 실제로는

  • 텐서명 매핑 문제
  • 메타데이터(config) 불일치
  • 양자화 스킴과 텐서 예외 처리 문제

세 축으로 정리됩니다. 핵심은 로그를 보고 어느 축인지 빠르게 분류한 뒤, 체크포인트를 “표준 형태”로 정리해 변환기가 기대하는 입력으로 맞추는 것입니다.

다음 액션으로는

  • Q8_0로 먼저 성공시키기
  • 키 덤프 후 prefix 제거 및 규칙 기반 리네이밍
  • float dtype 통일

이 3가지만 적용해도 성공률이 크게 올라갑니다.