English · Español
01 — Referencias, mutación y el GIL¶
Tres conceptos de Python que parecen "ya los sé" pero que se vuelven importantes cuando trabajas con tensores: (1) toda variable en Python es una etiqueta sobre un objeto, no el objeto en sí; (2) mutar un objeto compartido cambia todas sus etiquetas; (3) el GIL te impide paralelizar Python puro, pero las llamadas a C (incluyendo NumPy) lo liberan — por eso
numpy.einsumpuede usar todos tus núcleos.
Python es solo referencias¶
En Python, no hay "valores" a nivel de lenguaje — solo referencias a objetos. Cada variable es una etiqueta enlazada a un objeto en algún lugar del heap. La asignación reenlaza etiquetas; nunca copia.
a = [1, 2, 3] # `a` etiqueta un nuevo objeto lista
b = a # `b` etiqueta el MISMO objeto lista que `a`
b.append(4)
print(a) # [1, 2, 3, 4] ← mutación a través de `b` visible vía `a`
a is b es True. No son dos listas; son dos nombres para una lista.
Esto es fundamental, y la mayoría de programadores con experiencia lo saben para list, dict, etc. La razón por la que aparece aquí es que los arrays de NumPy heredan esto, y los objetos Tensor en minigrad también lo harán:
import numpy as np
x = np.arange(5) # x etiqueta un array
y = x # y etiqueta el MISMO array
y[0] = 99
print(x) # [99 1 2 3 4]
Pero también:
z = x[1:4] # z es una VISTA: un objeto array distinto, pero comparte el mismo buffer subyacente
z[0] = 42
print(x) # [99 42 2 3 4]
z is x es False. Son objetos ndarray distintos. Pero z.base is x es True, y las escrituras a través de z mutan el buffer de x porque el buffer está compartido. Este es el sustrato para el material de strides y vistas de la §2.
Cuándo muerde esto en código de IA¶
Un patrón común en los bucles de entrenamiento:
weights = model.parameters() # devuelve una lista de referencias
saved_weights = weights # NO es un backup — misma lista, mismos tensores
optimizer.step() # muta los datos de cada parámetro IN PLACE
# `saved_weights` son ahora los pesos posteriores al step. El estado previo se perdió.
El arreglo es copy.deepcopy(weights) o [w.clone() for w in weights] — según tu librería de tensores, y según si quieres copiar también los metadatos de autograd. La Fase 8/9 revisita esto.
La mutación es acción a distancia¶
El ejemplo anterior generaliza: cualquier objeto pasado a una función puede ser mutado por esa función, y la persona que llama no puede saber por la firma si la mutación ocurre.
def normalize_in_place(arr):
arr -= arr.mean() # muta el array de quien llama
arr /= arr.std()
# no devuelve nada
def normalize_pure(arr):
return (arr - arr.mean()) / arr.std() # array nuevo, el de quien llama queda intacto
minigrad seguirá la convención funcional (BLUEPRINT de la Fase 8). Cada op devuelve un Tensor nuevo; ninguna op muta sus entradas. Esto es más voraz en memoria que el enfoque mixto de PyTorch, pero deja el DAG de autograd inambiguo: un Tensor nace de un cómputo forward y nunca cambia.
PyTorch mismo tiene variantes funcionales (F.relu(x)) e in-place (x.relu_()). Las in-place llevan un guion bajo final. Cuando llegues a la Fase 25 (internos de PyTorch), fíjate en cómo loss.backward() es in-place (muta .grad en cada parámetro) mientras que F.softmax es funcional.
Identidad, igualdad, hash¶
Tres conceptos distintos:
a is b— mismo objeto en memoria (CPython: mismoid()).a == b— los valores comparan iguales (llama a__eq__).hash(a) == hash(b)— usado por la pertenencia adict/set.
Para arrays de NumPy, a == b devuelve un array booleano, no un escalar. Para comprobar la igualdad elemento a elemento de dos arrays, usa np.array_equal(a, b). Para identidad, a is b.
numpy.ndarray es no hasheable (mutable). No puedes meter arrays en un set ni usarlos como claves de dict. Las tuplas de shapes de array pueden ser claves; los arrays en sí no.
Tensor en minigrad también será no hasheable por defecto — misma razón.
El Global Interpreter Lock, desmitificado¶
El GIL es el lock que asegura que solo se ejecuta una instrucción de bytecode de Python a la vez por proceso. Existe porque el recolector de basura por conteo de referencias de CPython no es thread-safe sin él.
Tres consecuencias:
1. El código Python puro CPU-bound no escala a varios núcleos¶
def square_sum(n):
return sum(i * i for i in range(n))
# Ejecutar esto en 8 hilos vía threading.Thread: ~sin aceleración.
# Ejecutarlo en 8 procesos vía multiprocessing: ~8x aceleración.
Esta es la queja canónica de "Python no hace threading". Es cierto para Python puro.
2. NumPy libera el GIL dentro de las llamadas C¶
import numpy as np
a = np.random.randn(10_000_000)
b = a @ a.T # mientras NumPy hace esta multiplicación en C, el GIL está LIBERADO
Mientras @ se ejecuta en C, otro hilo de Python puede correr. Por esto los data loaders multihilo son rápidos en PyTorch — los hilos del loader hacen I/O de archivo y decodificación con NumPy (ambos liberan el GIL), y el hilo de entrenamiento corre código Python concurrentemente.
La regla completa: cualquier función implementada en una extensión C que llame explícitamente a Py_BEGIN_ALLOW_THREADS libera el GIL mientras dure. Los kernels de cómputo de NumPy lo hacen; muchas utilidades más pequeñas no (el sobrecoste de liberar/readquirir no merece la pena para ops cortas).
3. El GIL no protege tus objetos de race conditions¶
Dos hilos que incrementan un contador int compartido vía counter += 1 aún pueden tener un race — esa sentencia se compila a varias instrucciones de bytecode, y el GIL puede cambiar entre ellas. Usa threading.Lock o queue.Queue.
Para minigrad, esto importa en la Fase 18 cuando montemos un data loader. El hilo de entrenamiento lee tensores; el hilo del loader escribe en una cola. La cola en sí es thread-safe (tiene su propio lock); los tensores dentro deberían ser inmutables desde la perspectiva del productor una vez encolados.
4. Python 3.13+ y builds "no-GIL"¶
Free-threading CPython (PEP 703) está aterrizando experimentalmente. Lo tocaremos solo si es relevante para la Fase 35. El modelo mental sigue siendo correcto: NumPy libera el GIL, tu código Python no (a menos que optes por builds no-GIL).
Un pequeño ejemplo trabajado¶
import numpy as np
class Counter:
def __init__(self):
self.value = 0
def __iadd__(self, other):
self.value += other
return self
c1 = Counter()
c2 = c1
c1 += 5 # ¿`c1` reenlazado? ¿o `c1.value` mutado?
print(c2.value) # 5 — comparten el mismo objeto Counter; __iadd__ lo mutó
c3 = Counter()
c4 = c3
c3 = c3 + 5 # Espera, Counter no tiene __add__. ¿TypeError? Digamos que sí lo tuviera:
# entonces c3 se reenlaza a un Counter nuevo; c4 sigue etiquetando el viejo
La conclusión: x += y y x = x + y se comportan de forma distinta para objetos mutables. Lo mismo aplica a tensor += other_tensor en autograd: si tensor es un parámetro hoja, la suma in-place cambia su .data y deja el grafo de autograd en un estado definido; la suma fuera de lugar crea un tensor nuevo con un nodo distinto del grafo.
La Fase 8 resolverá esto haciendo que Tensor.__iadd__ lance NotImplementedError — solo funcional. La Fase 25 (internos de PyTorch) mostrará cómo PyTorch maneja la misma cuestión.
Escollos que conviene fijar¶
- Mutabilidad del argumento por defecto.
def f(x, history=[])compartehistoryentre todas las llamadas. Usahistory=None+history = history or []dentro. list(d.keys())para mutar durante la iteración. Modificar undictmientras se itera lanzaRuntimeError. Envuélvelo conlist(...)para hacer una instantánea.- Coste de
copy.deepcopy. Deepcopy recorre referencias; para unTensorcuyodataes un array de 100 MB, deepcopy reserva un nuevo array de 100 MB. Consideraciones de Fase 18. np.array(some_list_of_tensors). NumPy intentará hacer un array de objetos (lento, roto). Apila connp.stack([t.data for t in tensors])en su lugar.threadingfrente amultiprocessing. Para código pesado en NumPy:threadingestá bien (GIL liberado en C). Para cómputo Python puro:multiprocessing. Para la mayoría de data loaders de ML:multiprocessing(porque picklear tensores cruza fronteras de proceso limpiamente vía memoria compartida o arrow).
Recapitulación en un párrafo¶
Las variables de Python son etiquetas, no valores; la asignación reenlaza, nunca copia. La mutación a través de una etiqueta es visible a través de todas las etiquetas que apuntan al mismo objeto — este es el sustrato para las vistas de NumPy y para las decisiones de diseño de in-place frente a funcional más adelante. El GIL serializa el bytecode de Python pero es liberado por los kernels C de NumPy, lo que hace viable la carga de datos multihilo. Interiorizar estos tres puntos elimina una clase entera de bugs del tipo "¡pero si lo había copiado!" que de otro modo aflorarían en la Fase 8 cuando los objetos Tensor empiecen a compartir arrays subyacentes a través de vistas.
Siguiente: 02-strides-and-broadcasting.md