Curso / PyTorch / Tensores y datos
PyTorch · 1 de 5

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 leer shape, dtype y device de 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 view falla y reshape no.
  • Escribir un Dataset y un collate_fn propios 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 D con forma s=(s0,s1,…,sD-1), vector de saltos lineales (strides) σ=(σ0,σ1,…,σD-1) y desplazamiento inicial offset:

Index1D(i0,i1,…,iD-1)=offset+∑d=0D-1id·σd

En la disposición contigua estándar en memoria C (*row-major*), los strides satisfacen de forma decreciente:

σd=∏k=d+1D-1sk,con σD-1=1

Operaciones como transpose() o permute() no mueven un solo byte de memoria: se ejecutan en O(1) 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 O(N).

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:

dtypeBytes / valorBits exponente / mantisaPara qué se usa
torch.float32 (fp32)48 / 23El 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)28 / 7El 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)25 / 10Má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.int81enteroCuantizació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_e5m214 / 3 y 5 / 2FP8 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,52 / 1Blackwell (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

  1. Se alinean los shapes desde la derecha (la última dimensión con la última).
  2. 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).
  3. 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.

Broadcasting: los shapes se alinean desde la derecha, y de ahí sale el resultado o el error Tres casos con los shapes escritos alineados a la derecha. (3, 1) más (1, 4): cada 1 se estira contra el otro y da (3, 4). (8, 128) más (8, 1): el 1 se estira a 128 y resta la media de cada fila. (8, 128) más (8,): la regla alinea 128 con 8, no son iguales ni ninguno es 1, y falla; había que darle forma (8, 1). LAS TRES REGLAS, APLICADAS DE ATRÁS HACIA ADELANTE a 1 3 b 4 1 a + b 4 3 3 vs 1 → 3 1 vs 4 → 4 regla 2: un 1 es compatible con todo regla 3: se estira sin copiar (stride 0) tabla de sumas 3×4 a 128 8 b 1 8 a + b 128 8 8 = 8 128 vs 1 → 128 x - x.mean(dim=1, keepdim=True) el (8, 1) se estira a lo ancho resta la media de cada fila a 128 8 b 8 a + b ✗ falta → 1 128 vs 8 ✗ torch.randn(8, 128) + torch.randn(8) regla 1 alinea 128 con 8: RuntimeError querías (8, 1): unsqueeze(1) o [:, None] alineación desde la derecha: la última dimensión con la última · dos dims son compatibles si son iguales o una vale 1 una dimensión que falta se trata como 1 · el resultado toma el máximo en cada posición, y el 1 se estira sin copiar memoria
El diagnóstico de cualquier error de shapes empieza por escribir los dos shapes alineados a la derecha, como acá. Un 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.

Un tensor es memoria más strides: por qué view falla después de transpose y reshape no Arriba, 24 floats contiguos en memoria. A la izquierda, x de shape (4, 6) con strides (6, 1): cada fila es un bloque seguido y view(24) funciona. A la derecha, x.t() con shape (6, 4) y strides (1, 6): ningún dato se movió, leer una fila implica saltar de a 6, y view(24) falla; reshape(24) o contiguous().view(24) devuelven una copia en orden. MEMORIA · 24 floats contiguos 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 x = randn(4, 6) · strides (6, 1) avanzar una fila salta 6, una columna 1 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 x.view(24): reinterpreta la misma memoria, en orden · ok y = x.t() · shape (6, 4) · strides (1, 6) no se movió ningún dato: solo cambiaron los strides 0 6 12 18 1 7 13 19 2 8 14 20 3 9 15 21 4 10 16 22 5 11 17 23 y.is_contiguous() → False: leer la «fila» 0 es saltar de a 6 en memoria y.view(24) RuntimeError: view size is not compatible… view no copia, y esta memoria no está en el orden pedido y.reshape(24) devuelve una copia contigua · ok y.contiguous().view(24) la misma copia, explícita · ok t.T, permute, transpose y el slicing con paso solo cambian strides; por eso en atención vas a ver siempre .transpose(1, 2).contiguous().view(B, T, C) al volver de «cabezas separadas» a «embedding plano»
Lo que 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

ArgumentoQué haceValor razonable
num_workersProcesos 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_memoryCopia 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_workersNo 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_factorBatches que cada worker prepara por adelantado.El default (2) suele alcanzar; subilo si los ítems son muy irregulares en costo.
drop_lastDescarta el último batch incompleto.True en entrenamiento si usás BatchNorm o torch.compile con shapes estáticos.
collate_fnFunció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.

De un Dataset a un batch: sampler, workers en paralelo, collate con padding dinámico y viaje a la GPU El Dataset entrega ítems de a uno; el sampler decide el orden; cuatro workers ejecutan __getitem__ en paralelo y devuelven secuencias de largos 12, 7, 15 y 9. El collate por defecto apila con torch.stack y falla con largos distintos; collate_pad rellena hasta el máximo de ese batch, 15, y produce la máscara. El batch pasa por memoria pinned a la GPU mientras se prepara el siguiente. DATASET · __len__ y __getitem__(i), un ítem por vez Dataset n ítems, de a uno sampler shuffle: índices w0 len 12 w1 len 7 w2 len 15 w3 len 9 num_workers=4: cada worker corre __getitem__ en su proceso; la GPU no espera COLLATE_FN · de una lista de ítems a un batch default: torch.stack exige el mismo shape en todos stack expects each tensor to be equal size collate_pad: pad_sequence(ids, batch_first=True) → (B, L_max del batch) L_max = 15, de este batch, no del dataset attention_mask = (input_ids != pad_id) BATCH · pin_memory=True → .to(device, non_blocking=True) el tensor (B, L_max) viaja a la GPU mientras los workers preparan el batch siguiente (prefetch_factor=2) persistent_workers=True evita rearmar el pool en cada epoch · es línea por línea DataCollatorWithPadding de Transformers el padding se paga por batch, no por dataset; ordenar por largo lo reduce todavía más
Todo lo que un 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íntomaCausa probableQué 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 1Broadcasting 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 vecesEl 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 sizeCollate 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 / notebooknum_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

  1. 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.
  2. 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.
  3. 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.
  4. Broadcasting alinea desde la derecha; view exige contigüidad y reshape copia si hace falta; cat une en una dimensión existente y stack crea una nueva.
  5. Las operaciones in-place (sufijo _) ahorran memoria pero pueden romper el historial que necesita autograd — evitarlas quirúrgicamente, no por default.
  6. 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_memory y un collate_fn con padding dinámico son las tres perillas que más importan.
  7. 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 sobre pin_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.