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 escribeminigpt.gguf-lite.load.py— script que leeminigpt.gguf-litede 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 dictschemesdice 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_litey luegoread_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.savede PyTorch). - Calcula el sobrecoste de bytes del header GGUF + descriptores de tensor.
Bloque E — interpreta en README.md¶
Tres preguntas:
- ¿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). - ¿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.
- ¿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 detorch.savepara 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
ggmlreal. Nuestro formato tiene forma GGUF pero simplificado; está pedagógicamente conectado con GGUF, no bit-exacto.
Condiciones de parada¶
Terminado cuando:
- Writer y reader implementados; los tests en
tests/test_gguf_io.pypasan. - Error máximo absoluto del round-trip de modelo completo <
1e-3por capa. - 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. README.mdresponde las tres preguntas.
Trampas¶
- El modelo recargado tiene shapes equivocadas. ¿Escribiste
dimsen el orden correcto (PyTorch es row-major, el peso denn.Lineares(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_groupdebe 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.