Skip to content

English · Español

Lab 03 — Export tipo GGUF y round-trip

Objetivo: escribir a mano un export binario tipo GGUF de MiniGPT, recargarlo y verificar que los pesos dequantizados coinciden con el fake-quant de PyTorch dentro de 1e-3.

Tiempo estimado: 4–6 horas.

Prerrequisito: labs 00–02 commiteados; cuantización INT4 por-grupo funcionando en src/miniquant/quantize.py.


Lo que produces

Un directorio experiments/26-gguf-export/ que contiene:

  • export.py — script que escribe minigpt.gguf-lite.
  • load.py — script que lee minigpt.gguf-lite de vuelta a una estructura de módulo PyTorch.
  • verify.py — script que ejecuta forward sobre entradas idénticas en la ruta original (PyTorch fake-quant) y la recargada; reporta error máximo absoluto por capa.
  • manifest.json.
  • README.md — interpretación.

También commiteas src/miniquant/gguf_io.py (lectura+escritura).

El formato (GGUF-lite simplificado)

GGUF (el formato que usa llama.cpp) es un contenedor binario. La especificación completa está en el repo ggerganov/ggml. Nuestra versión simplificada captura la estructura sin las etiquetas legacy:

HEADER:
  magic         u32   = 0x474C4654  ('GLFT' = "GGUF-LiTe")
  version       u32   = 1
  n_tensors     u32
  metadata_len  u32   = number of bytes in metadata KV
METADATA:
  metadata_len bytes of key=value strings (utf-8), newline-separated
TENSOR DESCRIPTORS (repeated n_tensors times):
  name_len      u16
  name          name_len bytes (utf-8)
  n_dims        u8
  dims          n_dims × u32
  dtype         u8     (0=F32, 1=F16, 2=Q8_per_channel, 3=Q4_per_group_64)
  offset        u64    (offset into TENSOR DATA section)
TENSOR DATA:
  (concatenated, each tensor's bytes per its dtype)

Para Q8_per_channel y Q4_per_group_64, el layout de datos del tensor es:

Q8_per_channel:
  scales:  out × f16
  values:  out × in × i8
Q4_per_group_64:
  scales:  out × (in / 64) × f16
  values:  out × in / 2 × u8    (two 4-bit values packed per byte; low nibble is index 0)

El empaque de 4 bits: nibble inferior = peso de índice par (4-bit con signo, complemento a dos, rango [-8, 7]); nibble superior = peso de índice impar.

TODOs

Bloque A — implementa el writer

  • src/miniquant/gguf_io.py: write_gguf_lite(path: str, model: nn.Module, schemes: dict[str, str]). El dict schemes dice qué cuantización usar por nombre de parámetro (p. ej. {"layers.0.mlp.fc1.weight": "q4_per_group_64"}).
  • Recorre el state_dict; para cada tensor, elige su dtype según el mapa de schemes; cuantiza si hace falta; escribe el descriptor y encola los datos.
  • Empaqueta los pesos INT4 dos-por-byte. Cuidado: índice par → nibble inferior, impar → nibble superior. Usa bit-shifts, no aritmética.

Bloque B — implementa el reader

  • read_gguf_lite(path: str) -> dict[str, Tensor]. Devuelve un dict que mapea nombre de tensor → tensor FP32 dequantizado.
  • Para cada descriptor de tensor, busca el offset, lee el número correcto de bytes, dequantiza según el dtype.
  • Unpack INT4: nibble inferior → índice par; reinterpreta como 4-bit con signo (resta 16 si ≥ 8).

Bloque C — verifica el round-trip

  • Ejecuta write_gguf_lite y luego read_gguf_lite; compara el resultado con la salida fake-quant original de PyTorch al nivel de pesos FP32 dequantizados. El error máximo absoluto por tensor debería ser ≤ 1e-6 (solo redondeo del almacenamiento FP16 de la escala).
  • Ejecuta un forward completo de MiniGPT con una entrada fija en ambos: modelo cuantizado original en PyTorch, y un modelo reconstruido desde los pesos recargados. El error máximo absoluto de activaciones por capa debería ser ≤ 1e-3.

Bloque D — mide el tamaño

  • Bytes en disco del archivo GGUF-lite.
  • Compara contra un pickle naïf del mismo modelo (baseline torch.save de PyTorch).
  • Calcula el sobrecoste de bytes del header GGUF + descriptores de tensor.

Bloque E — interpreta en README.md

Tres preguntas:

  1. ¿Cuál es el ahorro real de bytes vs torch.save(model.state_dict())? Espera ~6–8× para esquemas INT4 (el ahorro 4× de pesos + header amortizado).
  2. ¿Dónde se va la mayor parte del archivo? Suma bytes por dtype. El mayor contribuyente debería ser los pesos Q4, no escalas ni metadatos.
  3. ¿Por qué INT4 no da una reducción limpia de 8× vs FP32? Identifica los sobrecostes: escalas (FP16), padding por alineamiento, el header, las partes no-cuantizadas (embeddings, layer-norms).

Restricciones

  • Little-endian. El x86_64 de Borja es little-endian; anótalo en el comentario de magic-version pero no escribas código de byte-swap salvo que se pida.
  • Nada de pickle, nada de torch.save para el formato cuantizado. El punto entero es que puedas leer esto desde cualquier lenguaje (C, Rust, Zig) que pueda parsear un binario plano.
  • Sin dependencia del ggml real. Nuestro formato tiene forma GGUF pero simplificado; está pedagógicamente conectado con GGUF, no bit-exacto.

Condiciones de parada

Terminado cuando:

  1. Writer y reader implementados; los tests en tests/test_gguf_io.py pasan.
  2. Error máximo absoluto del round-trip de modelo completo < 1e-3 por capa.
  3. Tamaño de archivo ~3× más pequeño que torch.save(model.state_dict()) para esquemas INT8; ~6× más pequeño para INT4.
  4. README.md responde las tres preguntas.

Trampas

  • El modelo recargado tiene shapes equivocadas. ¿Escribiste dims en el orden correcto (PyTorch es row-major, el peso de nn.Linear es (out, in))? Documéntalo explícitamente en el header.
  • El unpack INT4 devuelve el signo equivocado. Complemento a dos de 4 bits: los valores 8..15 son negativos. Usa int8(nibble) - 16 if nibble >= 8 else int8(nibble).
  • Las escalas por-grupo no encajan tras recargar. El reshape en quantize_symmetric_per_group debe coincidir con el reshape inverso en la ruta de dequant. Testea sobre un tensor juguete (4, 8) antes de escalar al modelo real.
  • Offsets de header equivocados. Calcula el offset de datos tras escribir todos los descriptores; no lo pre-comprometas a un offset.

Stretch goal — compatibilidad GGUF real

Si hay tiempo, cambia el valor de magic y el enum de dtype para que coincidan con la spec real de GGUF de llama.cpp, y prueba a cargar vía llama-cli. No se evalúa; es un ejercicio "a ver si funciona".

Cuándo consultar solutions/

Tras cumplidas las cuatro condiciones de parada. solutions/03-gguf-export-ref.md (apertura de fase) recorre el bit-packing con cuidado.


Fin de los labs de Fase 26. A continuación escribe PHASE_26_REPORT.md.