TL;DR: RL asincrónico arrastra un secreto sucio: en cada step, el trainer tiene que enviar el modelo completo al motor de inferencia. Para un modelo de 7B parámetros en bf16, eso son 14 GB. Para un checkpoint frontier de 1 billón de parámetros (1T), el orden es un terabyte. Por step. Resulta que no hace falta. Entre dos pasos consecutivos del optimizador RL, aproximadamente el 99% de los pesos bf16 son bit-idénticos (y nunca menos del 98% en el peor caso). El delta real es diminuto.
Hugging Face landeó un PR en TRL que codifica solo los elementos que cambiaron como un archivo safetensors disperso, lo sube a un Hugging Face Bucket y le dice a vLLM que lo descargue. En Qwen3-0,6B, el payload por step baja de 1,2 GB a entre 20 y 35 MB.
La frutilla: el equipo corrió un entrenamiento totalmente desagregado en el que el trainer estaba en una máquina, vLLM vivía en un Hugging Face Space, el entorno Wordle en otro Space, y los pesos fluían a través de un único bucket del Hub. Sin cluster compartido, sin RDMA, sin VPN.
El problema del terabyte
Toda librería de RL asincrónico, sin importar cómo escriba "actor model" o de qué color pinte su backend NCCL, termina chocando contra la misma raíz: sincronización de pesos.
El motor de inferencia habla la política del step N. El trainer recién terminó el step N+1. Los pesos frescos tienen que viajar de un lado al otro antes de que el motor de inferencia comience a derivar irrecuperablemente fuera de política. Una transferencia bloqueante es cómputo idle desperdiciado de GPUs que no generan tokens. Con un camino de delta disperso, ese tiempo se colapsa a segundos.
Fireworks puso un número memorable en su post Frontier RL Is Cheaper Than You Think: para un checkpoint frontier de 1T parámetros en fp8, un snapshot completo son 1024 GiB. Pero el delta promedio medido entre checkpoints adyacentes son 20,3 GiB, es decir 1,98% del modelo. Más del 98% de los pesos en bf16 quedan bit-equivalentes entre checkpoints consecutivos.
Composer 2 de Cursor cuenta una historia paralela. Corren entrenamiento e inferencia en regiones distintas y los unen con un bucket S3 compartido (palabras exactas), donde el trainer sube diffs comprimidos cada training step.
¿Por qué los pesos bf16 en RL son casi siempre dispersos?
La afirmación "el 98% de los pesos no cambia" suena sospechosamente a uno de esos números que funcionan en la demo y se desarman en la realidad. No lo es. Se deriva de cómo funciona la aritmética bf16 a los learning rates que usa RL.
Un número bf16 tiene 7 bits de mantisa. Entre dos potencias consecutivas de dos hay exactamente 128 valores representables, así que el espaciado entre números bf16 adyacentes alrededor de |w| es aproximadamente |w|/128. Una actualización queda absorbida por el casting a bf16 cuando cae por debajo de la mitad de ese espaciado, es decir cuando |Δw| < |w|/256.
A un learning rate típico de RL de 3×10⁻⁶, la actualización a un peso individual es del orden de 3×10⁻⁶. Para la mayoría de los pesos, |w| ronda 10⁻² a 10⁻¹. El umbral |w|/256 a esa magnitud queda alrededor de 4×10⁻⁵ a 4×10⁻⁴, que es mayor que la actualización. El optimizador susurra, y bf16 no escucha.
Mediciones empíricas en Qwen2.5 (0,5B/1,5B/7B), Llama-3.2-3B y Gemma-3-4B confirman una dispersión por step de ~99% con desviación estándar de 0,2% a 0,4% sobre 400 pasos. El peor step se mantiene sobre 98%.
¿Qué es un Hub Bucket?
Un Bucket es un tipo de repo en el Hub diseñado para almacenamiento de objetos de alta frecuencia. Sin ceremonia de commit, sin workflow de PR, sin manías de LFS. La interfaz Python son dos funciones:
from huggingface_hub import batch_bucket_files, download_bucket_files
batch_bucket_files("my-org/wordle-deltas",
add=[(buffer, "deltas/step_000042.safetensors")])
download_bucket_files("my-org/wordle-deltas",
files=[("deltas/step_000042.safetensors", local_path)])Por debajo, los buckets corren sobre Xet, la capa de almacenamiento del Hub con content-defined chunking. Xet mira cada archivo subido, lo corta en chunks según el contenido real (no por offsets fijos) y deduplica contra todo lo ya existente en el bucket.
La arquitectura: tres cajas y un sustrato
- Trainer. Donde sea. Una GPU, ocho GPUs, un laptop con una H100 por USB. Es dueño de los pesos, corre el optimizador y emite deltas dispersos.
- HF Bucket. Un solo repo con dos prefijos:
anchors/para snapshots completos ocasionales ydeltas/para los parches dispersos intermedios. - Servidor de rollout vLLM. Donde sea, y crucialmente no necesariamente donde está el trainer. Descarga del bucket, aplica el delta y sirve rollouts.
- Environment. Cuelga del rollout server por HTTP, function calls, lo que hable el entorno.
La propiedad clave: el trainer y el servidor de rollout nunca se hablan directamente sobre pesos. Intercambian un POST diminuto con {"repo_id": ..., "filename": ...}. La transferencia de bytes ocurre entre cada lado y el bucket, en paralelo, sin malla de red compartida.
El protocolo en cuatro piezas
El sistema usa safetensors como formato sobre el cable. Hay dos tipos de archivos en el bucket. Los anchors son checkpoints normales: un tensor por parámetro, pesos bf16 completos, escritos cada N syncs (default N=10). Los deltas son la parte interesante: para cada parámetro que cambió, guardan dos entradas, un tensor int32 plano con los índices de los elementos y un tensor bf16 con los valores en esos índices.
Async RL acaba de volverse mucho más barato.




