PyTorch · Fundamentos
Tensores y datos: el vocabulario que ya usás sin pensarlo
Después de HF y fast.ai, ya construiste redes, entrenaste con LoRA, corriste inferencia con vLLM — todo sobre PyTorch, sin mirarlo de frente. Esta página fija el vocabulario oficial de la pieza más básica: el tensor y cómo entra la data al modelo.
Antes de leerfast.ai L3 (opcional): qué es un modelo antes de qué es un tensor
🎯 Al terminar vas a poder
- Explicar qué agrega un tensor sobre un
ndarray(device y autograd) y leershape,dtypeydevicede cualquier tensor sin adivinar. - Elegir un dtype con criterio — cuántos bytes cuesta cada valor, por qué bf16 desplazó a fp16 en entrenamiento y dónde entran int8, fp8 y fp4.
- Predecir el shape resultante de una operación con broadcasting, y saber cuándo
viewfalla yreshapeno. - Escribir un
Datasety uncollate_fnpropios que armen batches de secuencias de largo variable con padding. - Diagnosticar los errores clásicos de device, dtype y memoria compartida a partir del mensaje de error.
1. El tensor: un ndarray que sabe usar GPU y derivarse
La documentación oficial es directa: un tensor es "una estructura de datos especializada, muy similar a
arrays y matrices" — básicamente un ndarray de NumPy con dos poderes extra: puede vivir en
GPU (o cualquier accelerator: CUDA, MPS, XPU) y está optimizado para diferenciación automática.
Se puede crear de varias formas equivalentes:
x_data = torch.tensor([[1, 2],[3, 4]]) # directo desde datos
x_np = torch.from_numpy(np_array) # desde un array de NumPy
x_ones = torch.ones_like(x_data) # conserva shape/dtype de otro tensor
rand_tensor = torch.rand((2,3)) # shape explícito
Tres atributos definen a cualquier tensor: .shape, .dtype y .device
(dónde vive físicamente — CPU por defecto). Mover un tensor a GPU es explícito y nunca automático:
if torch.accelerator.is_available():
tensor = tensor.to(torch.accelerator.current_accelerator())
.to() es la forma general: acepta un device ("cuda", "cuda:1",
"mps", "cpu"), un dtype, o los dos a la vez, y devuelve un tensor
nuevo — el original no cambia, así que x.to("cuda") sin asignar no hace nada útil.
.cuda() es un atajo histórico equivalente a .to("cuda"); funciona, pero ata el
código a NVIDIA y no permite combinar dtype y device en una sola copia:
x = x.to("cuda") # cambia device
x = x.to(torch.bfloat16) # cambia dtype (copia)
x = x.to("cuda", torch.bfloat16, non_blocking=True) # las dos cosas en una copia
x = x.cuda() # == x.to("cuda"); solo NVIDIA
📐 Fundamento matemático: Storage, Strides y direccionamiento lineal
En la arquitectura interna de PyTorch, un torch.Tensor es una vista matemática ligera sobre un bloque contiguo de memoria lineal unidimensional denominado Storage.
Para un tensor de dimensión con forma , vector de saltos lineales (strides) y desplazamiento inicial :
En la disposición contigua estándar en memoria C (*row-major*), los strides satisfacen de forma decreciente:
Operaciones como transpose() o permute() no mueven un solo byte de memoria: se ejecutan en simplemente intercambiando los elementos del vector . Sin embargo, un tensor transpuesto deja de ser contiguo porque su stride ya no satisface la propiedad del producto acumulado. Por ello, t.view() falla (exige contigüidad estricta para mapear la memoria plana), mientras que t.contiguous() fuerza una reasignación física en memoria en .
La API cubre más de 1200 operaciones — indexado estilo NumPy, torch.cat para concatenar,
@/matmul para producto matricial, */mul para producto
elemento a elemento. Vale la pena memorizar un solo hecho: tensores en CPU y arrays de NumPy
pueden compartir la misma memoria subyacente — t.numpy() y
torch.from_numpy(n) no copian datos, así que modificar uno modifica el otro. Es la razón por
la que convertir entre ambos formatos es prácticamente gratis, y también una fuente clásica de bugs
sutiles cuando no se espera ese acoplamiento.
n = np.ones(3)
t = torch.from_numpy(n)
t.add_(1) # modifica el tensor... y el array
print(n)
[2. 2. 2.]
Dos límites de ese puente que conviene saber de memoria: .numpy() solo funciona sobre tensores
en CPU (en GPU: .cpu().numpy()) y que no requieran gradiente (con
requires_grad=True: .detach().numpy()); y NumPy no tiene bfloat16, así que un
tensor bf16 hay que pasarlo a .float() antes de convertirlo. Si querés una copia
independiente en vez de memoria compartida, t.clone() o np.array(t).
2. Los dtypes que importan hoy
El tutorial oficial pasa por dtype en una línea. En 2026 es la decisión que más condiciona
cuánto modelo entra en tu GPU y a qué velocidad corre — el bytes-por-parámetro es el número que
multiplicás por el tamaño del modelo para saber si entra. Estos son los que te vas a cruzar:
| dtype | Bytes / valor | Bits exponente / mantisa | Para qué se usa |
|---|---|---|---|
torch.float32 (fp32) | 4 | 8 / 23 | El default. Master weights del optimizador, acumulaciones, capas sensibles (norms, softmax de atención). Un modelo de 7B ocupa ~28 GB solo en pesos. |
torch.bfloat16 (bf16) | 2 | 8 / 7 | El estándar para entrenar e inferir transformers hoy: mismo rango que fp32 (no hay overflow) con menos precisión. No necesita loss scaling. Requiere Ampere (RTX 30xx / A100) o más nuevo. |
torch.float16 (fp16) | 2 | 5 / 10 | Más precisión que bf16 pero rango chico (máximo ~65 504): los gradientes pequeños se van a cero y las activaciones grandes desbordan. Se usa con GradScaler en GPUs anteriores a Ampere y en muchas rutas de inferencia. |
torch.int8 | 1 | entero | Cuantización de pesos (y a veces activaciones) para inferencia: bitsandbytes LLM.int8, SmoothQuant, GPTQ/AWQ en variantes de 4 y 8 bits. Necesita un factor de escala por tensor, canal o bloque. |
torch.float8_e4m3fn / float8_e5m2 | 1 | 4 / 3 y 5 / 2 | FP8 en Hopper (H100) y Blackwell. E4M3 para pesos y activaciones (más precisión), E5M2 para gradientes (más rango). Se usa con escalado por tensor o por bloque (Transformer Engine, torchao). |
| FP4 (E2M1, NVFP4 / MXFP4) | 0,5 | 2 / 1 | Blackwell (RTX 50xx, B200): cuatro bits por valor con un factor de escala por bloque de 16 valores (NVFP4, escala en fp8) o 32 (MXFP4). Dos valores por byte; PyTorch reciente expone un dtype empaquetado y los kernels viven en torchao, TensorRT-LLM y vLLM. |
La regla práctica: entrenar en bf16 con master weights en fp32 (eso hace
torch.autocast, lo ves en la página 3),
inferir en el dtype más chico que no rompa la calidad — bf16 si tenés memoria, int8 o
fp8 si querés throughput, fp4 si el hardware lo tiene. La pérdida de precisión no es gratis: cada paso
hacia abajo hay que medirlo con un eval, no asumirlo.
🔧 En tu proyecto
Tu pipeline NVFP4 con Gemma-4 sobre la RTX 5090 es exactamente la última fila de la tabla: 0,5 bytes
por peso contra los 2 de bf16 — cuatro veces menos memoria para los pesos, con los factores de escala
por bloque de 16 como único costo extra. El detalle de que los scale se guarden en
float8_e4m3fn y no en fp16 es lo que hace que NVFP4 conserve mejor la calidad que un FP4
"pelado" — y es el tipo de decisión que solo se entiende leyendo la tabla de arriba fila por fila.
Cambiar de dtype es una copia, no un cast gratis, y las operaciones exigen que sus operandos coincidan:
multiplicar una matriz fp32 por un vector fp16 da RuntimeError: expected scalar type Float but
found Half. La excepción son los escalares de Python y las promociones "seguras" (int con float,
por ejemplo), que PyTorch resuelve con sus reglas de type promotion.
w = torch.randn(4, 4) # float32 por default
x = torch.randn(4, dtype=torch.float16)
w @ x # RuntimeError: expected scalar type Float but found Half
w @ x.to(w.dtype) # ok
x.element_size() * x.numel() # bytes que ocupa: 2 * 4 = 8
3. Shapes: broadcasting, view/reshape, cat/stack
Casi todos los bugs de un forward pass son de shapes. Tres mecanismos explican el 90 % de lo que ves en código ajeno.
Broadcasting en tres reglas
- Se alinean los shapes desde la derecha (la última dimensión con la última).
- Dos dimensiones son compatibles si son iguales, o si una de las dos es 1, o si a uno de los tensores le falta esa dimensión (se trata como 1).
- El resultado toma, en cada posición, el máximo de las dos — y el tensor con 1 se "estira" virtualmente, sin copiar memoria.
a = torch.arange(3).reshape(3, 1) # shape (3, 1)
b = torch.arange(4).reshape(1, 4) # shape (1, 4)
(a + b).shape # (3, 4): tabla de sumas
x = torch.randn(8, 128) # batch de 8 vectores
mu = x.mean(dim=1, keepdim=True) # (8, 1) — keepdim conserva la dim para broadcastear
(x - mu).shape # (8, 128): resta la media de cada fila
torch.randn(8, 128) + torch.randn(8) # RuntimeError: (8,128) vs (8) → alinea 128 con 8
El último caso es el error típico: querías restar un valor por fila y PyTorch alineó desde la derecha.
La solución es darle la forma (8, 1) con keepdim=True, .unsqueeze(1)
o [:, None]. Cuando una máscara de atención, un bias o un factor de escala "no entra", el
diagnóstico empieza por escribir los dos shapes alineados a la derecha.
1 se estira gratis; una dimensión que falta se trata como 1; dos números distintos y ninguno 1 es el error. El tercer caso es el bug típico: querías un valor por fila y PyTorch alineó el 8 con el 128.view vs reshape vs contiguous()
Un tensor es un puntero a memoria más strides (cuántos elementos saltar por cada dimensión).
t.T, permute, transpose y el slicing con paso no mueven datos: solo
cambian los strides, y el tensor deja de ser contiguo. view exige contigüidad porque
se limita a reinterpretar la misma memoria sin copiar; reshape devuelve una view si puede y
una copia si no; contiguous() hace la copia explícitamente.
x = torch.randn(4, 6)
x.view(24) # ok: comparte memoria
y = x.t() # (6, 4), no contiguo
y.is_contiguous() # False
y.view(24) # RuntimeError: view size is not compatible with input tensor's size and stride...
y.reshape(24) # ok: copia por detrás
y.contiguous().view(24) # ok: la misma copia, explícita
Por eso en el código de atención vas a ver siempre .transpose(1, 2).contiguous().view(B, T, C)
al volver de "cabezas separadas" a "embedding plano": el transpose rompió la contigüidad y
view no perdona. Usar reshape ahí es correcto y más corto — la única razón para
preferir view es que falla ruidosamente cuando esperabas no copiar.
view exige y reshape perdona. Transponer no mueve datos, solo cambia cuánto hay que saltar en memoria por cada dimensión; view se limita a reinterpretar esa memoria y falla si el orden pedido no existe físicamente. reshape hace la copia por vos; contiguous() la hace explícita.torch.cat vs torch.stack
cat concatena a lo largo de una dimensión que ya existe; stack
apila tensores del mismo shape creando una dimensión nueva.
a, b = torch.zeros(2, 3), torch.ones(2, 3)
torch.cat([a, b], dim=0).shape # (4, 3): más filas
torch.cat([a, b], dim=1).shape # (2, 6): más columnas
torch.stack([a, b], dim=0).shape # (2, 2, 3): dimensión nueva adelante
torch.stack([a, b], dim=0).equal(torch.cat([a[None], b[None]])) # True: stack == unsqueeze + cat
Regla mnemotécnica: el collate por defecto de DataLoader hace stack
(N ítems de shape S → batch de shape (N, S)); la KV cache de un LLM hace cat (los K y V del
token nuevo se pegan a los anteriores en la dimensión de secuencia).
4. Operaciones in-place: el detalle que rompe autograd
Las operaciones que terminan en guion bajo (x.add_(5), x.copy_(y),
x.t_()) modifican el tensor en el lugar en vez de crear uno nuevo — ahorran memoria, pero la
documentación oficial es explícita: su uso está desaconsejado, porque pueden causar una
pérdida inmediata del historial necesario para calcular derivadas. Si un tensor participa en el grafo de
autograd y se sobreescribe in-place, PyTorch puede no tener forma de reconstruir el valor que necesitaba
para el backward pass — el error clásico es RuntimeError: a leaf Variable that requires grad is
being used in an in-place operation, o peor, un gradiente silenciosamente incorrecto.
w = torch.randn(3, requires_grad=True)
w.mul_(0.5) # RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.
with torch.no_grad(): # así lo hacen los optimizadores: fuera del grafo
w.mul_(0.5)
y = w * 2
y.sigmoid_() # permitido (y no es hoja)... pero
y.sum().backward() # RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation
El segundo error es el sutil: sigmoid necesita su propia salida para calcular la derivada, y la
sobreescribiste. PyTorch lo detecta con un contador de versión por tensor y corta la ejecución en vez de
devolver un gradiente incorrecto. Dónde sí se usan in-place sin culpa: en el step() de los
optimizadores (bajo no_grad), en zero_grad(), en máscaras
(masked_fill_) sobre tensores que no requieren gradiente, y en buffers de inferencia como
una KV cache preasignada.
5. Dataset: tres métodos, cualquier fuente de datos
torch.utils.data.Dataset es deliberadamente minimalista: cualquier clase que implemente
__init__, __len__ y __getitem__ ya es un Dataset válido, sin
heredar nada más complejo.
class CustomImageDataset(Dataset):
def __init__(self, annotations_file, img_dir, transform=None):
self.img_labels = pd.read_csv(annotations_file)
self.img_dir = img_dir
self.transform = transform
def __len__(self): return len(self.img_labels)
def __getitem__(self, idx):
img_path = os.path.join(self.img_dir, self.img_labels.iloc[idx, 0])
image = decode_image(img_path)
label = self.img_labels.iloc[idx, 1]
if self.transform: image = self.transform(image)
return image, label
El diseño es intencional: desacoplar el código de datos del código de entrenamiento. Un Dataset no sabe
nada de batches, GPUs, ni epochs — solo sabe cuántos ítems tiene y cómo devolver el ítem i.
Esta simplicidad es la razón por la que casi cualquier fuente de datos (un CSV, una carpeta de imágenes,
un dataset del Hub de HF, un stream remoto) puede envolverse en la misma interfaz de tres métodos.
Dos consecuencias prácticas de esa interfaz. Primera: __getitem__ corre una vez por
ítem, en cada epoch, así que lo caro (tokenizar, decodificar JPEG, abrir archivos) conviene
hacerlo ahí — en paralelo, con los workers del DataLoader — y no en __init__, salvo que el
dataset entero entre en RAM. Segunda: como cada ítem se pide por índice, la interfaz asume acceso aleatorio;
para streams sin largo conocido (un archivo de 200 GB de texto, una cola de Kafka) existe
IterableDataset, que solo implementa __iter__ y renuncia al shuffle
automático. Los datasets de HF ofrecen las dos variantes (streaming=True da la
iterable).
class TokenizedText(Dataset):
def __init__(self, texts, labels, tokenizer, max_len=256):
self.texts, self.labels, self.tok, self.max_len = texts, labels, tokenizer, max_len
def __len__(self): return len(self.texts)
def __getitem__(self, i):
ids = self.tok(self.texts[i], truncation=True, max_length=self.max_len)["input_ids"]
return torch.tensor(ids), torch.tensor(self.labels[i]) # largo variable por ítem
6. DataLoader: batching, shuffling y paralelismo gratis
DataLoader envuelve un Dataset y resuelve todo lo que un training loop necesita
y un Dataset deliberadamente no sabe hacer: agrupar en mini-batches, barajar en cada epoch, y usar
multiprocessing para que la carga de datos no bloquee la GPU esperando el próximo batch.
train_dataloader = DataLoader(training_data, batch_size=64, shuffle=True)
train_features, train_labels = next(iter(train_dataloader))
# train_features.shape → [64, 1, 28, 28] — el batch ya armado
Iterar el DataLoader da tuplas (features, labels) ya agrupadas en el tamaño de
batch pedido — el mismo patrón exacto que ya usaste, sin necesariamente haberlo escrito a mano, en
Trainer de HF y en el Learner de fastai/miniai: ambos son, por dentro, un
DataLoader de PyTorch con una capa de conveniencia encima.
Los argumentos que cambian la velocidad
| Argumento | Qué hace | Valor razonable |
|---|---|---|
num_workers | Procesos que ejecutan __getitem__ en paralelo, por fuera del proceso principal. Con 0, la carga corre en el mismo hilo que el training loop y la GPU espera. | 4 a 8 por GPU; nunca más que los cores físicos. Si la GPU muestra utilización baja e intermitente, subilo. |
pin_memory | Copia cada batch a memoria page-locked del host, lo que permite transferencias a GPU asíncronas (.to(device, non_blocking=True)). | True siempre que entrenes en GPU. En CPU no cambia nada. |
persistent_workers | No mata los workers al terminar cada epoch (evita rearmar el pool y reimportar todo). | True cuando num_workers > 0 y hay más de un epoch. |
prefetch_factor | Batches que cada worker prepara por adelantado. | El default (2) suele alcanzar; subilo si los ítems son muy irregulares en costo. |
drop_last | Descarta el último batch incompleto. | True en entrenamiento si usás BatchNorm o torch.compile con shapes estáticos. |
collate_fn | Función que convierte la lista de ítems en un batch. El default hace torch.stack — y por eso falla con largos distintos. | Custom cuando los ítems no tienen el mismo shape (texto, audio, grafos). |
collate_fn: padding de secuencias de largo variable
Con el TokenizedText de la sección anterior, el collate por defecto explota:
RuntimeError: stack expects each tensor to be equal size. La solución es darle al DataLoader
una función que rellene cada batch hasta el largo del ítem más largo de ese batch — no del
dataset entero — y devuelva la máscara que indica qué posiciones son reales:
from torch.nn.utils.rnn import pad_sequence
def collate_pad(batch, pad_id=0):
ids, labels = zip(*batch) # lista de tensores 1D, lista de escalares
input_ids = pad_sequence(ids, batch_first=True, padding_value=pad_id) # (B, L_max del batch)
attention_mask = (input_ids != pad_id).long()
return {"input_ids": input_ids,
"attention_mask": attention_mask,
"labels": torch.stack(labels)}
loader = DataLoader(ds, batch_size=16, shuffle=True, collate_fn=collate_pad,
num_workers=4, pin_memory=True, persistent_workers=True)
batch = next(iter(loader))
batch["input_ids"].shape, batch["attention_mask"].sum(1) # (16, L_max), largos reales
Esto es, línea por línea, lo que hace DataCollatorWithPadding de Transformers en el
Capítulo 2 de HF — dynamic
padding: rellenar por batch en vez de a un largo fijo global ahorra cómputo porque la mayoría de los
batches no contienen la secuencia más larga del dataset. Un refinamiento habitual es ordenar los ítems
por largo antes de agrupar (bucketing), para que cada batch tenga poco padding; el
group_by_length=True del Trainer hace exactamente eso con un sampler.
Dataset deliberadamente no sabe hacer lo hace el DataLoader alrededor: barajar, paralelizar, agrupar y mover. La única pieza que hay que escribir para texto es el collate_fn, porque apilar exige el mismo shape y las secuencias no lo tienen; rellenar hasta el máximo del batch es el padding dinámico.🔧 Es literalmente la misma pieza que ya viste tres veces
El TextDataLoaders/ImageDataLoaders de fastai (Parte 1 y 2), los
DataLoaders de miniai y los Dataset/DataLoader de
Hugging Face para tokenizar y batchear texto en el
Capítulo 5 de HF son, en su base,
exactamente esta misma pareja de clases de PyTorch — cada framework agrega su propia capa de
conveniencia arriba (integración con datasets del Hub, transforms automáticos, collation
específica), pero el contrato de fondo (__getitem__ + batching + shuffle) es idéntico en
los tres.
⚠️ Cuidado
Con num_workers > 0, PyTorch lanza subprocesos. En Windows y macOS (y en muchos
notebooks) el método de arranque es spawn: cada worker reimporta el script principal, así
que si la creación del DataLoader no está bajo if __name__ == "__main__": cada worker
intenta crear su propio DataLoader y el proceso se cuelga o falla con un error de
RuntimeError: An attempt has been made to start a new process before the current process has
finished its bootstrapping phase. En Linux (fork) el mismo código funciona sin la guarda, lo
que hace que el bug parezca "de la máquina" y no del código.
7. Errores comunes
Casi todos los errores de esta página vienen con un mensaje bastante explícito — el trabajo es reconocerlos a primera vista.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu! | El modelo está en GPU y el batch (o una máscara, un bias creado a mano) sigue en CPU. Muy común con tensores creados con torch.zeros(...) dentro del forward sin device=. | Mové el batch en el loop (X = X.to(device)) y creá tensores nuevos con device=x.device o con torch.zeros_like(x). |
RuntimeError: expected scalar type Float but found Half (o BFloat16) | Operación entre tensores de dtypes distintos: pesos en fp32 y entrada en fp16, o un modelo en bf16 con una capa nueva en fp32. | Unificá con .to(other.dtype); en entrenamiento mixto usá torch.autocast en vez de castear a mano (§2). |
RuntimeError: a leaf Variable that requires grad is being used in an in-place operation. | Operación con guion bajo sobre un parámetro (hoja con requires_grad). | Envolvé en torch.no_grad() si es una actualización manual; si es parte del forward, usá la versión no in-place (§4). |
RuntimeError: view size is not compatible with input tensor's size and stride... | view sobre un tensor no contiguo (después de transpose, permute, .T o un slice con paso). | reshape, o .contiguous().view(...) si querés que la copia sea explícita (§3). |
RuntimeError: The size of tensor a (128) must match the size of tensor b (8) at non-singleton dimension 1 | Broadcasting alineó desde la derecha algo que vos pensabas por la izquierda. | Escribí los dos shapes alineados a la derecha; agregá la dimensión faltante con unsqueeze/keepdim=True (§3). |
| Los datos de entrada "cambian solos" entre epochs; una normalización aplicada dos veces | El array de NumPy y el tensor comparten memoria (from_numpy/.numpy()) y una operación in-place sobre uno modificó al otro. | torch.tensor(arr) o t.clone() cuando necesitás independencia; tratá el puente NumPy↔Torch como una vista, no como una copia (§1). |
RuntimeError: stack expects each tensor to be equal size | Collate por defecto sobre ítems de largo variable. | collate_fn con pad_sequence + máscara (§6). |
| El DataLoader se cuelga o el script se relanza en loop en Windows / macOS / notebook | num_workers > 0 con start method spawn y sin guarda if __name__ == "__main__":. | Poné el código de entrenamiento bajo la guarda, o num_workers=0 para debuggear (§6). |
TypeError: can't convert cuda:0 device type tensor to numpy / Can't call numpy() on Tensor that requires grad | .numpy() sobre un tensor en GPU o con historial de autograd. | t.detach().cpu().numpy() — en ese orden (§1). |
8. Resumen
- Un tensor es un ndarray con dos poderes extra: puede vivir en un accelerator y soporta diferenciación automática.
.to()devuelve un tensor nuevo y sirve para device y dtype a la vez;.cuda()es el atajo viejo, solo NVIDIA. - Tensores en CPU y arrays de NumPy comparten memoria — convertir entre ambos es gratis, pero acopla los dos objetos;
.detach().cpu().numpy()es la secuencia segura. - El dtype es la decisión de memoria: fp32 (4 bytes) para master weights, bf16 (2) para entrenar e inferir, int8/fp8 (1) y fp4 (0,5) para inferencia cuantizada — cada escalón hay que medirlo.
- Broadcasting alinea desde la derecha;
viewexige contigüidad yreshapecopia si hace falta;catune en una dimensión existente ystackcrea una nueva. - Las operaciones in-place (sufijo
_) ahorran memoria pero pueden romper el historial que necesita autograd — evitarlas quirúrgicamente, no por default. - Dataset son solo tres métodos (
__init__,__len__,__getitem__); DataLoader agrega batching, shuffle y paralelismo encima, sin que el Dataset sepa nada de eso.num_workers,pin_memoryy uncollate_fncon padding dinámico son las tres perillas que más importan. - fastai, miniai y HF Transformers construyen sus propias abstracciones de datos directamente sobre estas dos clases — no las reemplazan, las envuelven.
9. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. Un modelo de 8 000 millones de parámetros, ¿cuánta memoria ocupan sus pesos en fp32, bf16, int8 y fp4?
Multiplicá parámetros por bytes-por-valor: 8B × 4 = 32 GB en fp32; × 2 = 16 GB en bf16; × 1 = 8 GB en int8; × 0,5 = 4 GB en fp4 (más los factores de escala, que son una fracción chica). Por eso un 8B no entra en una GPU de 24 GB en fp32 pero sí en bf16, y en fp4 dejás lugar para KV cache y batch. (§2)
2. ¿Por qué bf16 no necesita loss scaling y fp16 sí?
bf16 tiene 8 bits de exponente, los mismos que fp32, así que cubre el mismo rango numérico: los gradientes chicos no se van a cero por underflow. fp16 tiene 5 bits de exponente y su rango es mucho más chico; el GradScaler multiplica la loss para que los gradientes queden en zona representable y los divide antes del step. El precio de bf16 es menos mantisa (7 bits contra 10), que en la práctica el entrenamiento tolera. (§2)
3. ¿Qué shape tiene torch.randn(5, 1, 3) + torch.randn(4, 1)? ¿Y torch.randn(5, 3) + torch.randn(5)?
Primero: alineando desde la derecha, (5, 1, 3) contra (—, 4, 1) → 3 vs 1 ok, 1 vs 4 ok, 5 vs nada ok → resultado (5, 4, 3). Segundo: (5, 3) contra (—, 5) → 3 vs 5 no son iguales ni ninguno es 1 → RuntimeError. Para restar algo por fila necesitás (5, 1). (§3)
4. ¿Qué pasa si hacés x.t().view(-1)? ¿Y con reshape?
t() solo cambia los strides, el tensor deja de ser contiguo, y view falla con "view size is not compatible with input tensor's size and stride". reshape(-1) funciona porque detecta que no puede dar una view y hace una copia. Si querés controlar cuándo copiás, contiguous() primero. (§3)
5. Tenés 16 secuencias tokenizadas de largos entre 12 y 200 tokens. ¿Qué hace el collate por defecto y qué tenés que escribir en su lugar?
El default llama torch.stack y falla porque los tensores no tienen el mismo largo. Escribís un collate_fn que use pad_sequence(batch_first=True, padding_value=pad_id) para rellenar hasta 200 (el máximo de ese batch, no del dataset) y devuelva además attention_mask = input_ids != pad_id para que el modelo ignore el relleno. Es el dynamic padding de DataCollatorWithPadding. (§6)
6. ¿Por qué x = torch.from_numpy(arr); x.mul_(255) puede arruinar tu pipeline de preprocesamiento?
Porque from_numpy comparte memoria con arr: la multiplicación in-place también modificó el array original, que quizás otro código (o el próximo epoch) vuelve a leer creyendo que está sin escalar. Usá torch.tensor(arr) (copia) o una operación no in-place. (§1, §4)
10. Para practicar
✏️ Ejercicio 1 — medir cuánto pesa cada dtype
Creá el mismo tensor en varios dtypes y verificá la columna "bytes por valor" de la tabla con element_size(). Corre en CPU; fp8 solo necesita PyTorch 2.1 o más nuevo para existir como dtype (no hace falta GPU para crearlo).
import torch
x = torch.randn(1_000_000)
for dt in [torch.float32, torch.bfloat16, torch.float16, torch.int8, torch.float8_e4m3fn]:
y = x.to(dt)
mb = y.element_size() * y.numel() / 1e6
err = (y.float() - x).abs().max().item() if dt.is_floating_point else float("nan")
print(f"{str(dt):22s} {y.element_size()} B/valor {mb:5.2f} MB error máx {err:.4f}")
Pista / qué esperar
4, 2, 2, 1, 1 bytes. El error máximo crece a medida que baja la mantisa: bf16 debería mostrar un error mayor que fp16 sobre valores del orden de 1 (menos bits de mantisa), y fp8 mucho mayor. Si querés ver el otro lado de la moneda, probá con x = torch.randn(1_000_000) * 1e5: fp16 se va a inf y bf16 no.
✏️ Ejercicio 2 — un DataLoader con padding dinámico, de punta a punta
Sin tokenizer real: simulá secuencias de largo aleatorio, armá el Dataset y el collate_fn de §6, y comprobá que el padding de cada batch es el máximo de ese batch. Después medí cuántos tokens de relleno ahorra ordenar por largo.
import torch
from torch.utils.data import Dataset, DataLoader
from torch.nn.utils.rnn import pad_sequence
class FakeText(Dataset):
def __init__(self, n=512, seed=0):
g = torch.Generator().manual_seed(seed)
self.lens = torch.randint(8, 200, (n,), generator=g)
def __len__(self): return len(self.lens)
def __getitem__(self, i):
return torch.randint(1, 1000, (int(self.lens[i]),)), torch.tensor(i % 2)
def collate_pad(batch):
ids, labels = zip(*batch)
x = pad_sequence(ids, batch_first=True, padding_value=0)
return x, (x != 0), torch.stack(labels)
def padding_total(loader):
return sum((~mask).sum().item() for _, mask, _ in loader)
ds = FakeText()
shuffled = DataLoader(ds, batch_size=32, shuffle=True, collate_fn=collate_pad)
orden = ds.lens.argsort().tolist() # bucketing casero: índices por largo
bucketed = DataLoader(ds, batch_size=32, sampler=orden, collate_fn=collate_pad)
x, mask, y = next(iter(shuffled))
print(x.shape, mask.sum(1)[:5])
print("padding con shuffle:", padding_total(shuffled))
print("padding con bucketing:", padding_total(bucketed))
Pista / qué esperar
x.shape debería ser (32, L) con L igual al largo máximo de ese batch (menor que 200 casi siempre), y mask.sum(1) devuelve los largos reales. El padding total con bucketing tiene que ser varias veces menor que con shuffle — esa diferencia es cómputo que el modelo no gasta en tokens vacíos. Si agregás num_workers=2 y estás en Windows/macOS, recordá la guarda if __name__ == "__main__":.
✏️ Ejercicio 3 — provocar y leer los cuatro errores
Escribí, uno por uno, código que dispare: (a) el error de devices distintos (si no tenés GPU, usá "meta" como segundo device: torch.randn(3, device="meta")), (b) el de dtypes mezclados, (c) el de view sobre no contiguo, (d) el de in-place sobre hoja. Copiá el mensaje exacto de cada uno en un archivo de notas: la próxima vez que lo veas en un stack trace de 200 líneas de Transformers, lo vas a reconocer.
Pista / qué esperar
Los cuatro mensajes están citados textualmente en la tabla de §7. Fijate que el de dtypes nombra los tipos con los nombres internos (Float, Half, BFloat16), no los de torch..
11. Fuentes y para seguir
- Tensors — Learn the Basics — el tutorial original de esta página; leelo para ver los ejemplos de indexado y las operaciones básicas que acá se dan por sabidas.
- Datasets & DataLoaders — Learn the Basics — el ejemplo completo de FashionMNIST con
CustomImageDataset, con visualización de batches. - Broadcasting semantics — la especificación formal de las tres reglas de §3, con los casos borde de in-place y broadcasting.
- torch.Tensor — dtypes — la tabla completa de dtypes disponibles, incluidos los fp8 y los empaquetados de baja precisión; consultala cuando dudes del nombre exacto.
- torch.utils.data — la referencia de
DataLoader: samplers,IterableDataset, los detalles de multiprocessing por plataforma y la nota sobrepin_memory. - torchao — la librería oficial de cuantización y baja precisión (int8, fp8, fp4/NVFP4) sobre PyTorch; el lugar donde mirar cuando quieras pasar de la tabla de §2 a código que corre.