SenseiTeoría desde cero0/21

3 · La computadora por dentroLección 1 de 5

Programa, proceso e hilo

Program, process and thread

Qué corre de verdad dentro de una máquina cuando «corre» el gateway o Triton: procesos con su propia memoria, hilos que se turnan los núcleos, señales para pedirles que terminen y los números con los que mueren, como el 137 de un OOMKilled.

⏱ 60 min lo esencial · 77 min todo13 conceptos nuevos2 calculadoras10 ejerciciosAPI
Secciones15
  1. 01Programa y proceso
  2. 02Compilar o interpretar
  3. 03Hilos y turnos
  4. 04Concurrencia y paralelismo
  5. 05Procesos o hilos para servir un modelo extra
  6. 06Señales: avisarle algo a un proceso
  7. 07Códigos de salida: cómo termina un proceso
  8. 08OOM: cuando se acaba la memoria
  9. 09El apagado ordenado, segundo a segundo extra
  10. 10Errores comunes
  11. 11En la vida real extra
  12. 12Ejercicios
  13. 13Lo que sigue
  14. 14Palabras que vas a oír
  15. 15Chequeo
La pregunta

¿Qué pasa dentro de la máquina cuando «corre» el gateway de C01 o Triton, y por qué a veces un pod se reinicia solo?

Antes de esta lección: El camino completo.

En la Ronda vas a leer frases como «el gateway corre en 3 pods», «uvicorn --workers 4 son 4 procesos» u «OOMKilled (exit code 137)». Parecen jerga, pero cada una describe algo concreto que pasa dentro de una máquina.

Un ejemplo. Un pod es la unidad que arranca Kubernetes, el orquestador de C01: uno o más contenedores que corren juntos en la misma máquina. Una mañana le pides a Kubernetes la lista de pods de C01 (el comando es kubectl get pods) y ves esto:

text
NAME                      READY   STATUS    RESTARTS     AGE
gateway-6c9f7d8b5-4xkqp   1/1     Running   3 (2m ago)   5h
gateway-6c9f7d8b5-9hd2s   1/1     Running   0            5h
gateway-6c9f7d8b5-tq8wz   1/1     Running   0            5h
triton-7b6d9c4f8-q2m7z    1/1     Running   0            2d
triton-7b6d9c4f8-x5bnc    1/1     Running   0            2d

Nadie desplegó nada, y uno de los gateways se reinició 3 veces (la columna RESTARTS) en las 5 horas que lleva vivo su pod (AGE), la última hace 2 minutos. En sus logs, los mensajes que va dejando el programa sobre lo que hace, no hay un solo error. Al terminar esta lección vas a poder explicar qué se reinició, quién lo mató, por qué no dejó rastro y qué número dejó al morir. Para eso hace falta separar tres palabras que se usan como si fueran lo mismo: programa, proceso e hilo.

01

Programa y proceso

Esencial

Program and process

Un programa (program) es un archivo, o un grupo de archivos, con instrucciones para una computadora. El gateway de C01 es una carpeta con archivos de Python; Triton, el servidor que corre el modelo en la GPU, es un archivo llamado tritonserver. En el disco no hacen nada, como una receta guardada en un cajón.

Un proceso (process) es un programa corriendo. Quien lo arranca es el kernel, el corazón del sistema operativo (Windows, macOS o Linux, el de los servidores de C01): la parte que manda sobre la máquina. Al arrancar un programa, el kernel le arma un lugar para trabajar:

  • Memoria propia. Un pedazo de la RAM (la memoria principal, donde viven los datos mientras se usan) que ningún otro proceso puede tocar. Ahí van su código, sus datos y, en el gateway, cada foto mientras se decodifica.
  • Un número: el PID. El PID (process ID) identifica al proceso mientras vive. Con ese número se le habla; por ejemplo, para pedirle que termine.
  • Al menos un hilo. Un hilo (thread) es una línea de ejecución: el punto del código por donde va el programa. Todo proceso nace con uno y puede crear más.
  • Archivos abiertos. Todo lo que lee y escribe, que el kernel maneja igual que a un archivo: sus logs, el puerto por donde le llegan las requests (un número que dice a qué programa de la máquina va cada una; en el gateway, el 8080) y su conexión con Triton.
  • Su entorno. Las variables de entorno, ajustes que recibe al arrancar (en el gateway, PREPROCESS_CONCURRENCY=2: cuántas fotos decodifica a la vez), y el usuario a nombre del cual corre, que decide qué archivos puede tocar.
El proceso del gateway de C01, por dentro. Arriba, su nombre: python, el intérprete, corriendo uvicorn y app.py, con PID 1 porque es el programa principal de su contenedor. En medio, su memoria, que es solo suya: el código (python, uvicorn, app.py y Pillow), los datos que comparten sus hilos (las fotos a medio decodificar y el cliente de Triton) y tres pilas, una por hilo. Debajo, tres de sus hilos: uno atiende las requests y dos decodifican fotos; cada uno usa su propia pila y comparte todo lo demás. Al final, sus archivos abiertos (la entrada, la salida y los errores, el puerto 8080 y la conexión con Triton) y su entorno (PREPROCESS_CONCURRENCY=2 y el usuario número 10001). UN PROCESO POR DENTRO python: el gateway de C01 PID 1 el intérprete, corriendo uvicorn y app.py PID 1 porque es el principal de su contenedor MEMORIA: SOLO DE ESTE PROCESO Código: python, uvicorn, app.py, Pillow Datos que comparten los hilos fotos a medio decodificar, cliente de Triton Pila 1 en qué va Pila 2 en qué va Pila 3 en qué va Hilo 1 requests Hilo 2 decodifica Hilo 3 decodifica Cada hilo tiene su pila; todo lo demás lo comparten. ARCHIVOS ABIERTOS la entrada, la salida y los errores (los logs) el puerto 8080, por donde llegan las requests la conexión con Triton ENTORNO PREPROCESS_CONCURRENCY=2 usuario número 10001
El proceso del gateway de C01, por dentro, con tres de sus hilos. Su memoria es solo suya. Los hilos comparten casi todo; cada uno tiene su propia pila, donde anota en qué va.

Del mismo programa pueden correr muchos procesos a la vez. Si abres dos terminales (las ventanas donde se escriben comandos en vez de usar el mouse) y en cada una corres python3, hay dos procesos de un solo programa. El gateway de C01 corre de 3 a 12 copias, según el tráfico, y cada copia es un proceso aparte: una foto que decodifica una copia no existe para las demás.

Este código arranca el mismo programa tres veces. Cada corrida da otros PIDs, así que no los imprime: solo los compara.

python
import os, subprocess, sys

yo = os.getpid()                         # el PID de este proceso: cambia en cada corrida
hijo = "import os; print(os.getpid(), os.getppid())"   # un programa que dice su PID y el de su padre

pids = []
for vez in (1, 2, 3):                    # el mismo programa, arrancado 3 veces
    salida = subprocess.run([sys.executable, "-c", hijo], capture_output=True, text=True).stdout
    pid, padre = map(int, salida.split())
    pids.append(pid)
    print(f"proceso {vez}: ¿su padre soy yo? {padre == yo} · ¿tiene mi PID? {pid == yo}")

print("¿los tres tienen PIDs distintos?", len(set(pids)) == 3)
Salida
proceso 1: ¿su padre soy yo? True · ¿tiene mi PID? False
proceso 2: ¿su padre soy yo? True · ¿tiene mi PID? False
proceso 3: ¿su padre soy yo? True · ¿tiene mi PID? False
¿los tres tienen PIDs distintos? True

subprocess.run arranca otro programa y espera a que termine; sys.executable es el programa de Python que estás usando, y -c le pasa el código como texto. Cada vuelta crea un proceso nuevo, con su propio PID, cuyo padre es el proceso que lo arrancó.

El PID, el padre y el PID 1

El kernel reparte los PIDs en orden; al llegar al tope vuelve a empezar desde abajo y reusa los de procesos que ya terminaron. Todo proceso tiene un padre, el que lo arrancó, y conoce su número (el PPID, parent process ID). Así, los procesos forman un árbol, como uno genealógico. En la raíz está el PID 1, el primer proceso que arranca la máquina: en Linux suele ser systemd; en una Mac, launchd. Un contenedor tiene su propia numeración: adentro, el programa principal (el servidor) es el PID 1, aunque por fuera la máquina le dé otro número. Ese lugar tiene reglas especiales con las señales (sección 6).

Para verlos, en una terminal están ps y top; en una Mac, el Monitor de Actividad; en Windows, el Administrador de tareas. En la laptop donde se escribió esta lección (una Apple M4 con 10 núcleos) corrían ≈ 440 procesos con ≈ 2,800 hilos.

Para ir más hondoCómo nace un proceso: fork y exec

En Linux y en macOS, un proceso nuevo nace en dos pasos. Primero, el padre se copia a sí mismo con fork: sale un hijo idéntico, con otro PID. Después, el hijo cambia su programa por otro con exec: carga el archivo nuevo y empieza desde su primera instrucción, con el mismo PID. Así arranca cada comando que escribes en la terminal. La copia de fork es barata, porque la memoria no se copia de verdad hasta que uno de los dos la cambia (copy-on-write, «copiar al escribir»). Windows lo hace en un solo paso, con CreateProcess.

02

Compilar o interpretar

Esencial

Compiling or interpreting

La CPU no entiende Python ni C++, dos lenguajes de programación. Entiende instrucciones de máquina: números que codifican órdenes muy simples, como «suma estos dos números», «trae este dato de la memoria» o «salta a otra instrucción si esto es cero». Cada familia de procesadores tiene su propio juego de instrucciones, su arquitectura: x86-64 en los Intel y AMD de la mayoría de los servidores (como las máquinas c6i de AWS donde corre el gateway), y arm64 en las Mac con chip M, en los teléfonos y en los procesadores Graviton de AWS.

Hay dos formas de llevar un programa, escrito por una persona, hasta esas instrucciones.

Compilar: traducir antes de correr

Compilar (compile) es traducir el programa completo a instrucciones de máquina antes de correrlo, una sola vez, con otro programa llamado compilador. Lo que sale es un binario ejecutable: un archivo de instrucciones que el kernel puede cargar y arrancar directo. Se le dice binario porque no es texto para personas, sino los bits que lee la CPU.

Triton está escrito en C++ y se compila al binario tritonserver; NVIDIA lo entrega ya compilado en su imagen de contenedor (el paquete del que se arranca cada contenedor), y la imagen de C01 se arma encima de ella. Docker y Kubernetes están escritos en Go, otro lenguaje que se compila, y el propio Python, en C: también son binarios.

Un binario sirve para una arquitectura y un sistema operativo. Uno de Linux para x86-64 no corre en un nodo arm64 (un nodo es cada máquina donde Kubernetes corre pods), ni uno para arm64 en un nodo x86-64: el contenedor muere con exec format error. La Ronda (M05) cuenta cuándo pasa: alguien construye la imagen en su Mac con chip M, que es arm64, y la sube a nodos x86-64. «El sistema operativo» lo abre a fondo.

Interpretar: leer y ejecutar al mismo tiempo

Python, normalmente, no se compila a instrucciones de máquina. Lo corre un intérprete (interpreter): un programa que lee tu código y lo va ejecutando. El más usado es CPython, el que se instala desde python.org. CPython primero traduce cada archivo .py a bytecode: instrucciones sencillas inventadas para el propio intérprete, no para la CPU. Después las ejecuta una por una, con un ciclo escrito en C que repite «toma la siguiente instrucción y hazla». Para no traducir de nuevo en cada arranque, guarda el bytecode de los módulos que importa (los archivos que trae con import) en una carpeta __pycache__, si puede escribir ahí. El módulo dis te deja ver ese bytecode:

python
import dis, time

def precio_con_iva(precio):
    return precio * 1.16

dis.dis(precio_con_iva)                  # el bytecode que ejecuta CPython

inicio = time.perf_counter()             # un cronómetro: anota el momento en que empieza
total = 0
for i in range(5_000_000):               # cada vuelta son varias instrucciones de bytecode
    total += i
con_for = time.perf_counter() - inicio

inicio = time.perf_counter()
total_c = sum(range(5_000_000))          # lo mismo, pero el ciclo corre en código de C
con_sum = time.perf_counter() - inicio
print("¿mismo total?", total == total_c, "· ¿sum() terminó antes?", con_sum < con_for)
Salida
  3           RESUME                   0

  4           LOAD_FAST_BORROW         0 (precio)
              LOAD_CONST               0 (1.16)
              BINARY_OP                5 (*)
              RETURN_VALUE
¿mismo total? True · ¿sum() terminó antes? True

RESUME marca que empieza la función, LOAD_FAST_BORROW trae la variable precio, LOAD_CONST trae el 1.16, BINARY_OP 5 multiplica y RETURN_VALUE regresa el resultado. Los números de la izquierda son las líneas del archivo: la 3 es el def y la 4, el return. Es la salida de Python 3.14: en otra versión, algunos nombres cambian. La última línea compara un for de Python con sum(), que suma con un ciclo escrito en C.

Ir instrucción por instrucción es el precio de interpretar: por cada instrucción de bytecode, el ciclo del intérprete ejecuta muchas instrucciones de máquina. Un for de Python puro suele ser decenas de veces más lento que el mismo ciclo compilado en C. Por eso el trabajo pesado de ML no lo hace Python: lo hacen librerías compiladas, como NumPy, PyTorch y Pillow, a las que Python solo les da órdenes.

Ya puedes responder la primera mitad de la pregunta de la lección. Cuando «corre el gateway», el proceso que existe es python, el binario del intérprete, ejecutando el bytecode de uvicorn (el servidor web de Python que recibe las requests) y de app.py, el código del gateway. Cuando corre Triton, el proceso es el binario tritonserver. En la lista de procesos, nada se llama «gateway».

De un archivo a un proceso: Triton y el gateway de C01 · Toca cada caja para ver qué es, o usa «Recorrer» para seguir a Triton, que se compila antes, y al gateway, que se interpreta al correr.
  • python y app.py · intérprete y código
    Dos archivos. python es el intérprete: un binario compilado desde C. app.py, junto con el código de uvicorn, es texto: el programa del gateway, que el intérprete lee al arrancar.
  • Código de Triton · C++: texto
    El código fuente de Triton, escrito en C++: texto que una persona puede leer y que la CPU no entiende.
  • Compilador · una vez, antes
    El compilador traduce el programa completo a instrucciones de máquina, una sola vez, antes de correrlo: eso es compilar. NVIDIA ya lo hizo; su imagen de contenedor trae a Triton compilado, y la de C01 parte de ella.
  • tritonserver · binario ejecutable
    El binario ejecutable: instrucciones de máquina para Linux en x86-64. En una máquina arm64 no correría, y el contenedor moriría con exec format error.
  • Proceso python · corre el bytecode
    Un proceso del binario python. Ejecuta el bytecode de app.py y de uvicorn, instrucción por instrucción, en los núcleos de la CPU. Lo pesado, como decodificar la foto, lo hacen librerías compiladas, como Pillow.
  • Kernel · crea los procesos
    El kernel, el corazón del sistema operativo. Carga el programa en la memoria y crea el proceso: le da su PID, su memoria propia y un primer hilo. Después lo turna en la CPU con todos los demás. Aquí va uno solo para simplificar: en C01, el gateway y Triton corren en máquinas distintas, y cada máquina tiene su propio kernel.
  • Proceso de Triton · PID, memoria, hilos
    El proceso de Triton: el binario tritonserver corriendo, con su PID, su memoria y muchos hilos, que reciben requests, arman los batches y corren el modelo.
  • GPU T4 · corre el engine
    La GPU T4. Triton carga ahí el engine del modelo, que TensorRT compiló para esa GPU, y le manda los batches de fotos.
Para ir más hondoPor qué la lista dice python y no uvicorn

El gateway arranca con el comando uvicorn app:app, pero uvicorn no es un binario: es un archivo de texto cuya primera línea (#! y la ruta de python, su dirección en el disco) le dice al kernel con qué programa abrirlo. Por eso ps aux y docker top (que lista los procesos de un contenedor) muestran al gateway como python …/uvicorn app:app.

Para ir más hondoCompilar mientras corre, y compilar modelos

Desde la 3.13, CPython trae además un JIT (just-in-time, «justo a tiempo») experimental, apagado por omisión, que traduce partes del bytecode a instrucciones de máquina mientras el programa corre.

La palabra se usa también con modelos. TensorRT, la herramienta de NVIDIA, «compila» el modelo de C01 en un engine: un archivo hecho para un tipo de GPU (la T4) y una versión de TensorRT. Si no coinciden, Triton no puede cargarlo y termina al arrancar (sección 8). torch.compile hace algo parecido con un modelo de PyTorch, mientras el programa corre.

03

Hilos y turnos

Esencial

Threads and turns

Un hilo, la línea de ejecución de la sección 1, tiene dos cosas propias: el punto del código por donde va y su pila (stack), una libreta donde anota en qué función está y las variables de esa función. Todo lo demás lo comparte con los otros hilos de su proceso: el resto de la memoria, los archivos abiertos y el PID.

¿Para qué varios hilos? Para hacer varias cosas a la vez sin copiar la memoria. El gateway de C01 tiene un hilo que atiende las requests que llegan y un threadpool (grupo de hilos) (un grupo fijo de hilos que esperan trabajo) que decodifica fotos, dos a la vez. Triton, por dentro, tiene hilos que reciben requests, hilos que arman los batches y hilos que corren el modelo.

Este código hace la misma tarea en un hilo y en un proceso hijo. La tarea agrega una foto a una lista:

python
import threading, multiprocessing

fotos = []                               # una lista en la memoria de este proceso

def decodificar(nombre):
    fotos.append(nombre)                 # escribe en la lista que tiene a la mano

# Sin este if, el hijo volvería a correr lo de abajo: para armarlo, Python vuelve
# a leer este archivo (en Windows, en macOS y, desde Python 3.14, también en Linux).
if __name__ == "__main__":
    hilo = threading.Thread(target=decodificar, args=("tenis.jpg",))
    hilo.start(); hilo.join()            # arranca el hilo y espera a que termine
    print("después del hilo:", fotos)

    hijo = multiprocessing.Process(target=decodificar, args=("botas.jpg",))
    hijo.start(); hijo.join()            # lo mismo, en un proceso aparte
    print("después del proceso hijo:", fotos)
    print("código de salida del hijo:", hijo.exitcode)
Salida
después del hilo: ['tenis.jpg']
después del proceso hijo: ['tenis.jpg']
código de salida del hijo: 0

El hilo escribió en la misma memoria que el programa principal, y la lista lo muestra. El proceso hijo escribió en su copia, y la lista del padre ni se enteró. El código de salida es el número con el que terminó el hijo: 0 quiere decir que todo salió bien (sección 7).

El planificador reparte los turnos

En una máquina hay muchos más hilos que núcleos. Un núcleo (core) es una de las unidades de la CPU que ejecutan instrucciones, y cada uno corre un hilo a la vez (muchos de servidor corren dos: lo verás en «CPU y GPU»). En la laptop de la sección 1 había ≈ 2,800 hilos para 10 núcleos: 280 por núcleo.

Quien decide qué hilo corre en cada núcleo, y por cuánto tiempo, es el planificador (scheduler) del kernel. Cada hilo está en uno de tres estados:

  • Corriendo: en un núcleo, ejecutando instrucciones.
  • Listo: podría correr, y espera su turno.
  • Bloqueado: espera algo que no es la CPU, como una respuesta por la red, un dato del disco o el final de un sleep (una pausa que pide el propio programa). No gasta CPU.

Casi todos los 2,800 hilos estaban bloqueados: esperando una tecla, un mensaje o un reloj.

Cuando el planificador cambia de hilo en un núcleo, hace un cambio de contexto (context switch). Guarda el estado del hilo que sale: en qué instrucción iba y los valores que tenía a la mano en sus registros (unas cuantas casillas de memoria rapidísima dentro de cada núcleo). Después carga el estado del que entra. Pasa cuando se acaba el turno de un hilo, cuando se bloquea o cuando despierta otro más urgente. Cada cambio toma del orden de microsegundos: es poco, pero miles por segundo se notan.

Para ir más hondoCuánto cuesta cambiar de hilo
Lo que cuesta cambiar de hiloLa cuenta
Un cambio de contextodel orden de microsegundos
supuesto
≈ 3 µs
Supón un núcleo con 1,000 cambios por segundo
1,000 × 3 µs
3 ms por segundo
En proporción
3 ms ÷ 1,000 ms
0.3 % del núcleo
Con 20,000 cambios por segundomiles de hilos que despiertan y se duermen
20,000 × 3 µs
60 ms: el 6 %
Resultado
Cada cambio es diminuto, pero miles por segundo se notan. Además, el hilo que entra encuentra la caché (la memoria rapidísima junto a cada núcleo) con los datos del anterior, y llenarla de nuevo puede costar más que el cambio mismo.

Compartir memoria tiene un precio

Compartir memoria es rápido, pero dos hilos pueden pisarse. Supón que dos hilos cuentan las fotos que procesan en la misma variable, con contador += 1. Parece una sola orden, y son tres pasos: leer el contador, sumarle 1 y escribirlo. Si el planificador cambia de hilo a la mitad:

PasoHilo AHilo BEl contador queda en
1lee 4141
2lee 4141
3escribe 41 + 142
4escribe 41 + 142

Pasaron dos fotos, y el contador subió una. Es una condición de carrera (race condition): el resultado depende de quién llega primero, y casi nunca falla en las pruebas, porque el cambio de hilo tiene que caer justo entre la lectura y la escritura. La defensa es un candado (lock): solo el hilo que lo tiene puede tocar el contador, y los demás esperan, bloqueados. En Python, threading.Lock().

Los procesos no tienen ese problema entre ellos, porque no comparten memoria. Lo pagan de otra forma: si necesitan los mismos datos, cada uno carga su copia. La sección extra 5 lo cuenta con un modelo en la GPU.

04

Concurrencia y paralelismo

Esencial

Concurrency and parallelism

Dos palabras que se confunden mucho, y la Ronda usa las dos:

  • Concurrencia (concurrency): varias tareas en curso al mismo tiempo. Ya empezaron y no han terminado, aunque en cada instante avance solo una.
  • Paralelismo (parallelism): varias tareas avanzando en el mismo instante, cada una en su propio núcleo. Necesita hardware: con un solo núcleo, dos tareas nunca avanzan en el mismo instante.

Rob Pike, uno de los creadores del lenguaje Go, lo resumió en una charla de 2012: la concurrencia es lidiar con muchas cosas a la vez; el paralelismo, hacer muchas cosas a la vez.

Cuatro fotos, uno o cuatro núcleos

Supón cuatro fotos que piden 40 ms de CPU cada una para decodificarse. Es trabajo de pura CPU: no esperan nada.

Cuatro fotos, A, B, C y D, que piden 40 milisegundos de CPU cada una, hechas de tres maneras sobre la misma escala de 0 a 160 milisegundos. Arriba, un núcleo las hace una tras otra: salen a los 40, 80, 120 y 160 milisegundos, con un promedio de 100, y hay 3 cambios de contexto. En medio, un núcleo las turna en pedazos de 10 milisegundos, A, B, C, D, A, B, C, D y así: salen a los 130, 140, 150 y 160 milisegundos, con un promedio de 145, y hay 15 cambios de contexto. Abajo, cuatro núcleos hacen una foto cada uno al mismo tiempo: las cuatro salen a los 40 milisegundos. 4 FOTOS DE 40 MS DE CPU CADA UNA Un núcleo, una tras otra A B C D salen a los 40, 80, 120 y 160 ms · promedio: 100 ms Un núcleo, por turnos de 10 ms A B C D A B C D A B C D A B C D salen a los 130, 140, 150 y 160 ms · promedio: 145 ms cada raya es un cambio de contexto: 15 aquí, 3 arriba Cuatro núcleos, en paralelo A núcleo 1 B núcleo 2 C núcleo 3 D núcleo 4 salen las cuatro a los 40 ms · promedio: 40 ms 0 40 80 120 160 TIEMPO, EN MS
Las mismas cuatro fotos de tres maneras. Arriba, un núcleo las hace una tras otra. En medio, un núcleo las turna cada 10 ms, y cada raya es un cambio de contexto. Abajo, cuatro núcleos las hacen al mismo tiempo.
Cuándo sale cada foto, en promedioLa cuenta
Un núcleo, una tras otra
(40 + 80 + 120 + 160) ÷ 4
100 ms
Un núcleo, por turnos de 10 msconcurrencia: A acaba su cuarto turno a los 130 ms, B a los 140…
(130 + 140 + 150 + 160) ÷ 4
145 ms
Cuatro núcleosparalelismo
(40 + 40 + 40 + 40) ÷ 4
40 ms
Resultado
Con trabajo de pura CPU, turnarse no acelera nada: la última foto sale igual a los 160 ms, y el promedio empeora. Solo más núcleos bajan el tiempo.

Cuando hay esperas, la concurrencia sí ayuda

Ahora una request del gateway: ≈ 6 ms de CPU para preparar la foto y ≈ 10 ms esperando la respuesta de Triton (un número redondo: en la Ronda, ese tramo va de ≈ 5 a 12 ms). Mientras espera, el hilo está bloqueado y el núcleo queda libre.

Cuatro requests del gateway en un solo núcleo, sobre una escala de 0 a 64 milisegundos. Cada una usa la CPU 6 milisegundos y después espera 10 milisegundos la respuesta de Triton. Arriba, una tras otra: la siguiente no empieza hasta que la anterior termina, y la cuarta sale a los 64 milisegundos; el núcleo trabaja solo 24 de esos 64. Abajo, concurrentes: cuando una empieza a esperar, la siguiente usa la CPU, y salen a los 16, 22, 28 y 34 milisegundos. La CPU sigue haciendo una sola cosa a la vez. 4 REQUESTS: 6 MS DE CPU, 10 DE ESPERA Una tras otra, en un núcleo 1 espera 2 3 4 la cuarta sale a los 64 ms el núcleo trabaja solo 24 de esos 64 ms Concurrentes, en el mismo núcleo 1 16 ms 2 22 ms 3 28 ms 4 34 ms la cuarta sale a los 34 ms y la CPU sigue haciendo una cosa a la vez usa la CPU (6 ms) espera a Triton (10 ms): el núcleo queda libre 0 16 32 48 64 TIEMPO, EN MS
Cuatro requests del gateway en un solo núcleo. Arriba, una tras otra: el núcleo pasa casi todo el tiempo esperando. Abajo, concurrentes: mientras una espera a Triton, la siguiente usa la CPU.

Una tras otra, cada request ocupa 6 + 10 = 16 ms y la cuarta sale a los 4 × 16 = 64 ms. Concurrentes, el núcleo prepara una cada 6 ms y cada una sale 10 ms después: a los 16, 22, 28 y 34 ms, con el mismo núcleo. Es la idea que vas a encontrar en toda la Ronda: la concurrencia aprovecha las esperas; el paralelismo, los núcleos. I/O (de input/output, entrada y salida) es todo lo que entra y sale de un programa: la red, el disco. Al trabajo que pasa casi todo el tiempo esperando I/O se le dice I/O-bound, y al que pasa casi todo el tiempo calculando, CPU-bound.

El mismo experimento en Python, con cuatro tareas que esperan y cuatro que calculan, una tras otra y en 4 hilos. Los tiempos cambian según la máquina, así que el código solo imprime cuántas veces más rápido fue con hilos.

python
import threading, time

def esperar():                           # como esperar la respuesta de Triton: el hilo se bloquea
    time.sleep(0.2)

def calcular():                          # pura CPU, en Python
    total = 0
    for i in range(2_000_000):
        total += i * i

def medir(tarea, en_hilos):              # el mejor de 3 intentos, por si la máquina está ocupada
    mejor = float("inf")
    for _ in range(3):
        inicio = time.perf_counter()
        if en_hilos:
            grupo = [threading.Thread(target=tarea) for _ in range(4)]
            for h in grupo: h.start()
            for h in grupo: h.join()
        else:
            for _ in range(4): tarea()
        mejor = min(mejor, time.perf_counter() - inicio)
    return mejor

for tarea in (esperar, calcular):
    rapidez = medir(tarea, False) / medir(tarea, True)
    print(f"{tarea.__name__:<9} rapidez con 4 hilos: ×{rapidez:.0f}")
Salida
esperar   rapidez con 4 hilos: ×4
calcular  rapidez con 4 hilos: ×1

Esperar en 4 hilos fue 4 veces más rápido. Calcular, igual que una tras otra, aunque la laptop donde se corrió tiene 10 núcleos. La razón es el GIL (global interpreter lock): un candado de CPython que deja ejecutar Python a un solo hilo a la vez. Un hilo bloqueado lo suelta, por eso esperar no estorba. NumPy, PyTorch y Pillow también lo sueltan en buena parte de su trabajo en C, y por eso los dos hilos del gateway sí decodifican en paralelo. Desde Python 3.13 existe una versión sin GIL (free-threaded), todavía opcional; con ella, el segundo número sube. La lección «Hacer varias cosas a la vez» cuenta el GIL a fondo.

Concurrencia y núcleos: cuántas requests por segundoCalculadora

Cada request del gateway usa la CPU un rato y espera a Triton otro rato. Mueve cuántas requests hay en vuelo a la vez (las que ya empezaron y no han terminado) y cuántos núcleos hay. Arranca con los números de C01 y una sola request a la vez.

Requests por segundo62.5
Tope por las que están en vueloen vuelo × 1,000 ÷ (CPU + espera)62.5
Tope por la CPUnúcleos × 1,000 ÷ CPU de cada request166.7
Uso de la CPU37.5 %
Lo que tarda cada requestsi pasa de CPU + espera, la request hace fila esperando su turno16.0 ms
En vuelo para llenar los núcleos3

Es el caso ideal. En la vida real, cuando la CPU se acerca al 100 %, las requests empiezan a hacer fila antes, y la latencia sube más rápido. Pon la espera en 0, como un trabajo de pura CPU: más requests en vuelo ya no suben nada, solo más núcleos.

Arranca con una sola request en vuelo: el núcleo pasa 10 de cada 16 ms esperando. Sube las requests en vuelo a 3 y después a 8: ¿qué pasa con las requests por segundo y con lo que tarda cada una?

Con 1 en vuelo salen 1,000 ÷ 16 = 62.5 requests por segundo. Con 3, la cuenta daría 3 × 62.5 = 187.5, pero un núcleo no da más de 1,000 ÷ 6 ≈ 167: es su tope. Con 8 ya no salen más, y las de sobra esperan turno: si salen 167 por segundo y hay 8 en vuelo, cada una tarda 8 ÷ 167 ≈ 0.048 s, 48 ms en vez de 16. Pasado el tope, solo más núcleos suben el número.

05

Procesos o hilos para servir un modelo

Extra

Processes or threads to serve a model

Un servidor de modelos puede atender varias requests a la vez de dos maneras: con varios hilos dentro de un proceso o con varios procesos. Por fuera se parecen; en memoria cuestan muy distinto.

uvicorn --workers: cada worker es un proceso

Uvicorn tiene la opción --workers. Cada worker (trabajador) es una copia completa del servidor en un proceso aparte, con su propia memoria, y un proceso padre los vigila y reemplaza al que muere. Si la app carga un modelo en la GPU, cada worker carga su copia de los pesos y abre su propio contexto de CUDA (CUDA es la plataforma de NVIDIA para programar sus GPUs, y el contexto, lo que cada proceso prepara para usarla, que ocupa su propio pedazo de la memoria de la GPU).

uvicorn --workers 4 con el modelo en la GPULa cuenta
Pesos de la ResNet-50 de C01 en FP32
24.5 M × 4 B
≈ 0.1 GB
Contexto de CUDA, por proceso
estimación de M04
≈ 0.3 a 1 GB
4 workers en una T4 de 16 GB
4 × (0.1 + 0.3 a 1 GB)
≈ 1.6 a 4.4 GB
Pesos de Llama 3 8B en BF16
8.03 mil millones × 2 B
≈ 16.1 GB
2 workers con ese modelo
2 × 16.06 GB
≈ 32 GB
Resultado
Con la ResNet-50, los workers desperdician memoria, pero caben. Con un LLM de 8B, ni dos copias entran en una L4 de 24 GB. Y en los dos casos, cada worker atiende sus requests por su lado: se turnan la GPU sin juntar batches entre ellos.

Por eso C01 sigue una regla: un proceso por pod, muchos pods. El escalado lo hace Kubernetes con réplicas, copias del pod. Varios workers dentro de un pod esconden cuánta CPU usa cada proceso, complican las métricas y pueden dejar un pod «sano» con un worker colgado (M05).

Triton: un proceso con muchas copias adentro

Triton atiende todo desde un solo proceso de C++. Si le pides dos instancias de un modelo en la misma GPU (instance_group), son dos copias del modelo dentro de ese proceso, que corren en paralelo en hilos distintos. La excepción es su backend de Python, el que corre código de Python para preparar o terminar una predicción: ahí cada instancia es un proceso aparte, justo para que cada una tenga su propio GIL (M07).

Más hilos o más procesos: lo que midió la Ronda

En M08, la Ronda midió una ResNet-50 en la CPU de una laptop, con 4 núcleos, ONNX Runtime (un programa que corre modelos exportados a ONNX, un formato de archivo para modelos), FP32 y una foto por request:

ConfiguraciónFotos por segundoCada request tarda
1 proceso con 4 hilos≈ 61≈ 16.5 ms
2 procesos con 2 hilos≈ 72≈ 28 ms
4 procesos con 1 hilo≈ 86≈ 46 ms

Con 4 hilos en un proceso, los 4 núcleos trabajan en la misma foto, que sale rápido. Con 4 procesos, cada núcleo trabaja en una foto distinta: salen más fotos por segundo, pero cada una tarda casi el triple. Las dos son paralelismo, dentro de una request o entre requests. Cuál conviene lo decide el presupuesto de latencia, cuánto puede tardar cada request: con margen, más procesos; con el presupuesto justo, más hilos por request.

Las cuentas cuadran: un proceso que tarda 46 ms por foto hace 1,000 ÷ 46 ≈ 21.7 fotos por segundo, y cuatro de ellos, ≈ 87.

Varios procesos en una GPU

Una GPU también se reparte entre procesos. Con time-slicing (repartir el tiempo en rebanadas), la GPU se turna entre ellos, con cambios de contexto y sin garantías de cuánto le toca a cada uno. Con MPS (Multi-Process Service, de NVIDIA), los procesos corren a la vez en la GPU, pero un error fatal de uno puede afectar a los demás (M04).

06

Señales: avisarle algo a un proceso

Esencial

Signals: telling a process something

Un proceso corre a su ritmo, ocupado en lo suyo. ¿Cómo se le pide que termine? Con una señal (signal): un aviso corto, identificado con un número, que el kernel le entrega a un proceso. La puede pedir otro proceso (la terminal, Docker, Kubernetes) o mandarla el propio kernel.

Al recibir una señal, el proceso tiene tres opciones: correr una función suya para atenderla, su manejador (signal handler); ignorarla, o dejar que pase la acción por omisión, que casi siempre es terminar. Las que vas a ver:

SeñalNúmeroQuién la mandaQué quiere decir¿Se puede atender?
SIGINT2La terminal, cuando aprietas Ctrl+C«Interrumpe lo que haces»Sí: Python la convierte en KeyboardInterrupt
SIGTERM15kill, docker stop, Kubernetes«Por favor, termina»: el aviso amableSí
SIGKILL9kill -9; Docker y Kubernetes al vencer el plazo; el kernel sin memoriaNo es un aviso: el kernel quita al proceso de en medioNo
SIGSEGV11El kernel, cuando el proceso toca memoria que no es suyaUn error grave de programación (bug), casi siempre en código de C o C++Se puede, pero casi nunca para seguir

SIGKILL ni siquiera le llega al proceso: el kernel lo termina sin preguntarle. No corre ningún manejador, no se cierran las conexiones con cuidado y no se escribe nada más. Lo que estaba a medias se pierde. Por eso hay dos: SIGTERM le da al proceso la oportunidad de cerrar bien, y SIGKILL existe para cuando no la aprovecha.

Desde una terminal se mandan con kill: kill 4821 le manda SIGTERM al proceso con PID 4821; kill -9 4821, SIGKILL, y kill -l lista todas las señales.

Así se ve un manejador de SIGTERM en Python. El programa se manda la señal a sí mismo, como haría Kubernetes. Usa señales de Unix, la familia de sistemas operativos de la que vienen Linux y macOS: corre en esos dos; en Windows, dentro de WSL (el Linux que Windows puede instalar).

python
import os, signal, sys

def al_recibir_sigterm(numero, marco):   # el manejador: corre al llegar la señal (marco dice dónde iba; no se usa)
    print(f"llegó la señal {numero}: dejo de aceptar trabajo, termino lo que tengo y salgo")
    sys.exit(0)                          # sale con código 0: todo en orden

signal.signal(signal.SIGTERM, al_recibir_sigterm)   # «si llega SIGTERM, corre esta función»
print("SIGINT es", int(signal.SIGINT), "· SIGKILL es", int(signal.SIGKILL), "· SIGTERM es", int(signal.SIGTERM))
os.kill(os.getpid(), signal.SIGTERM)     # me la mando a mí mismo, como haría Kubernetes
print("esto ya no se imprime")
Salida
SIGINT es 2 · SIGKILL es 9 · SIGTERM es 15
llegó la señal 15: dejo de aceptar trabajo, termino lo que tengo y salgo

Un servidor bien hecho hace eso al recibir SIGTERM: deja de aceptar requests nuevas, termina las que tiene en curso, cierra sus conexiones (la del gateway con Triton, por ejemplo) y sale. Uvicorn y Triton ya lo traen.

La regla del PID 1

Dentro de un contenedor, el PID 1 tiene una regla especial: el kernel no le aplica la acción por omisión, salvo la de un SIGKILL que llegue de fuera del contenedor. Es una protección: si el PID 1 muere, mueren con él todos los procesos del contenedor. Si el PID 1 no tiene un manejador de SIGTERM, la señal se ignora. Por eso el servidor tiene que ser el PID 1 y manejar SIGTERM, o tener delante un init: un programa chiquito que hace de PID 1 y le pasa las señales.

En el Dockerfile (la receta con la que se arma la imagen del contenedor), la línea CMD dice qué comando arranca el contenedor. Para que el servidor sea el PID 1, se escribe en forma exec, como lista: CMD ["uvicorn", "app:app"]. En forma shell, CMD uvicorn app:app, el PID 1 es /bin/sh, una shell: el programa que lee comandos escritos como texto, el que trabaja detrás de una terminal. Esa shell no le pasa la señal a uvicorn. La sección extra 9 cuenta qué pasa entonces.

El apagado ordenado, en cuatro pasos

Cada vez que C01 publica una versión nueva o quita un pod que sobra, Kubernetes lo apaga en orden. Quien hace el trabajo en cada máquina es el kubelet, el programa de Kubernetes que arranca, vigila y detiene los contenedores de esa máquina.

  1. 1

    Sale del balanceo

    Kubernetes marca el pod como «terminando» y lo saca del reparto de requests entre los pods: ya no le manda nuevas. Tarda unos segundos en enterarse todo el mundo.

  2. 2

    Una espera: el preStop

    Un sleep corto que Kubernetes corre dentro del contenedor antes de avisarle (10 s en Triton, 15 s en el gateway), para atender lo que todavía llegue mientras se propaga la baja.

  3. 3

    SIGTERM

    Le llega al PID 1. El servidor deja de aceptar y termina lo que tiene en curso, a lo que se le dice drenar: Triton, en hasta 30 s; el gateway, en hasta 20.

  4. 4

    SIGKILL, si se pasa del plazo

    Si sigue vivo al vencer el plazo de gracia, que se cuenta desde el paso 1 (terminationGracePeriodSeconds: 30 s si nadie lo cambia; en C01, 60 para Triton y 45 para el gateway), el kubelet lo mata con SIGKILL.

07

Códigos de salida: cómo termina un proceso

Esencial

Exit codes: how a process ends

Cuando un proceso termina, le deja a su padre un número de 0 a 255, que cabe en un byte: su código de salida (exit code). Es lo que lee la terminal, Docker o el kubelet para saber cómo le fue.

  • 0: todo salió bien.
  • 1 a 125: algo falló, y qué significa cada número lo decide cada programa. Por costumbre, 1 es un error cualquiera (Python sale con 1 si una excepción, su forma de avisar un error, no se atrapa con try), y 2, que lo usaron mal.
  • 126 y 127: los pone la shell de la terminal: el archivo no se pudo ejecutar (126) o el comando no existe (127).
  • 128 + n: lo mató la señal n. 130 es Ctrl+C; 137, SIGKILL; 139, SIGSEGV, y 143, SIGTERM. Es una convención: casi ningún programa sale con esos números por su cuenta.
Los 256 códigos de salida posibles, de 0 a 255, en una barra. El 0 es que todo salió bien. Del 1 al 125 son errores que define cada programa. El 126 y el 127 los pone la terminal. Del 128 al 255, la mitad de arriba, quiere decir que una señal mató al proceso: 128 más el número de la señal. Ejemplos: 0, todo salió bien; 1, una excepción sin atrapar; 2, argumentos equivocados; 127, la terminal no encontró el comando; 130, 128 más 2, SIGINT o Ctrl+C; 137, 128 más 9, SIGKILL, por un OOM o un plazo vencido; 139, 128 más 11, SIGSEGV; 143, 128 más 15, SIGTERM, el aviso amable. Abajo, el 137 en bits: 1000 1001. El primer bit vale 128 y dice que lo mató una señal; los otros siete valen 8 más 1, que es 9: SIGKILL. EL CÓDIGO DE SALIDA: UN BYTE, DE 0 A 255 1 a 125: el programa 128 a 255: una señal 0 128 255 0 todo salió bien 1 error: una excepción sin atrapar 2 argumentos equivocados 127 la terminal no encontró el comando 130 128 + 2: SIGINT, Ctrl+C 137 128 + 9: SIGKILL (OOM o plazo) 139 128 + 11: SIGSEGV, tocó memoria ajena 143 128 + 15: SIGTERM, el aviso amable El 137 en bits 1 0 0 0 1 0 0 1 128 64 32 16 8 4 2 1 El primero vale 128: «lo mató una señal». Los otros siete dicen cuál: 8 + 1 = 9, SIGKILL.
Los 256 códigos de salida posibles. La mitad de arriba es de las señales: 137 es 128 + 9. En bits, el primero dice «lo mató una señal» y los otros siete dicen cuál.
Leer un código de salida mayor que 128La cuenta
Kubernetes dice
Exit Code
137
Le quitas 128
137 − 128
9
La señal 9
kill -l 9
SIGKILL
Otro: 143
143 − 128
15: SIGTERM
Otro: 130
130 − 128
2: SIGINT
Resultado
137 quiere decir «lo mataron con SIGKILL», no «falló tu código». Quién lo mató se averigua aparte: el kernel por falta de memoria, o el kubelet al vencer un plazo.

El código cabe en un byte, así que da la vuelta, como el overflow de un uint8 que viste en «Cómo guarda números una computadora»: sys.exit(256) sale con 0, y sys.exit(300), con 44 (300 − 256). Por eso los programas usan solo del 0 al 255.

Para ir más hondoCompruébalo en Python y en la terminal

Este código arranca seis programas cortos que terminan de seis maneras distintas y lee el código de cada uno. También usa señales de Unix.

python
import subprocess, sys

matar = "import os, signal, time; os.kill(os.getpid(), signal.{}); time.sleep(1)"
casos = [
    ("termina bien", "x = 1 + 1"),
    ("excepción sin atrapar", "1 / 0"),
    ("sys.exit(3)", "import sys; sys.exit(3)"),
    ("Ctrl+C (SIGINT)", matar.format("SIGINT")),
    ("SIGTERM sin manejador", matar.format("SIGTERM")),
    ("SIGKILL", matar.format("SIGKILL")),
]
for nombre, codigo in casos:
    rc = subprocess.run([sys.executable, "-c", codigo], capture_output=True).returncode
    terminal = 128 - rc if rc < 0 else rc   # Python da −n si lo mató la señal n; la terminal, 128 + n
    print(f"{nombre:<22} Python: {rc:>3} · terminal: {terminal}")
Salida
termina bien           Python:   0 · terminal: 0
excepción sin atrapar  Python:   1 · terminal: 1
sys.exit(3)            Python:   3 · terminal: 3
Ctrl+C (SIGINT)        Python:  -2 · terminal: 130
SIGTERM sin manejador  Python: -15 · terminal: 143
SIGKILL                Python:  -9 · terminal: 137

Python dice −9 donde la terminal dice 137: cuando a un proceso hijo lo mata una señal, subprocess da el número de la señal en negativo. La terminal, Docker y Kubernetes usan 128 + n. Fíjate en el Ctrl+C: Python convierte el SIGINT en KeyboardInterrupt y, si nadie lo atrapa, sale como si la señal lo hubiera matado, para que la terminal vea el 130.

Para verlo tú, en una terminal de Mac o de Linux, echo $? muestra el código del último comando. Pruébalo pidiéndole a ls (el comando que lista lo que hay en una carpeta) una carpeta que no existe: da 1 en una Mac y 2 en Linux, porque son dos programas ls distintos y cada uno decide sus números. En Docker, docker ps -a muestra el código junto a cada contenedor que terminó, como «Exited (137)».

Para ir más hondoQuién decide con el código de salida

Mucho software decide con este número. En la integración continua (CI, el sistema que corre las pruebas cada vez que alguien sube código, como GitHub Actions), un paso que sale con algo distinto de 0 marca el trabajo como fallido. Y en Kubernetes, un Job, una tarea que corre y termina, como un reentrenamiento, cuenta como exitoso solo si su proceso sale con 0.

Cómo se ve en Kubernetes

kubectl describe pod (el comando que muestra todo sobre un pod) dice cómo terminó la última vez cada contenedor:

text
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137

Las combinaciones que más vas a ver:

  • 0: terminó bien; por ejemplo, después de atender un SIGTERM.
  • 1: el programa falló solo, como una excepción al arrancar.
  • 137 con Reason OOMKilled: se pasó de su memoria (la sección que sigue).
  • 137 sin OOMKilled: alguien le mandó SIGKILL; casi siempre, el kubelet al vencer el plazo de gracia.
  • 143: lo terminó un SIGTERM. Puede ser un apagado limpio (con un init delante, uvicorn atiende el aviso, cierra en orden y al final sale como si la señal lo hubiera terminado) o un proceso sin manejador que murió en el acto.
08

OOM: cuando se acaba la memoria

Esencial

OOM: when memory runs out

La RAM de una máquina tiene un tamaño fijo. Si los procesos piden más, el kernel tiene que liberar memoria como sea. Antes intenta otras salidas, que verás en «Memoria, caché y disco»; en Linux, el último recurso es el OOM killer (out of memory, sin memoria): elige un proceso, casi siempre el que más usa, y lo mata con SIGKILL. A quedarse sin memoria se le dice OOM.

Tres cosas lo hacen traicionero:

  1. No hay excepción. El proceso no se entera, porque SIGKILL no se puede atender. No hay traceback, el mensaje de Python que dice en qué línea falló.
  2. Se pierden los últimos logs. Si su salida no va a una pantalla, Python junta los mensajes y los escribe de a muchos; con SIGKILL, lo juntado se pierde, justo las líneas que explicaban qué pasó. Por eso los contenedores de Python llevan PYTHONUNBUFFERED=1, que los escribe al instante (M05).
  3. El código es 137, el mismo de cualquier SIGKILL.

En un contenedor, el límite es tuyo

Un contenedor tiene su propio tope de memoria: en Kubernetes, el limits.memory de cada contenedor. El kernel lo hace cumplir con un cgroup (su mecanismo para ponerle topes de CPU y memoria a un grupo de procesos). Si el proceso pasa su límite, el kernel lo mata aunque a la máquina le sobre memoria. Kubernetes lo reporta como OOMKilled, código 137, y lo vuelve a arrancar. En C01, el contenedor de Triton tiene un límite de 6 GiB de RAM (el modelo vive en la GPU), y el del gateway, de 1 GiB.

Los PNG gigantes del gateway de C01

Una foto ocupa mucho más decodificada que en el archivo. En el gateway de C01, un PNG de 40 megapíxeles llega a ≈ 320 MB (medido): Pillow guarda cada píxel en 4 bytes (40 M × 4 B = 160 MB) y hace copias al enderezar la foto y al convertirla a RGB.

¿Cuántos PNG de 40 MP caben a la vez en el gateway?La cuenta
Límite del contenedor
1 GiB = 1,073,741,824 B
≈ 1,074 MB
Memoria base del procesoPython, las librerías y el código
estimación de C01
≈ 80 MB
Archivos de las requests en vueloacepta hasta 16, de hasta 10 MB (M03)
16 × 10 MB
160 MB
Lo que queda para decodificar
1,074 − 80 − 160
≈ 834 MB
Fotos a la vez
834 ÷ 320
2.6: caben 2
Con 3 a la vezse pasa por ≈ 126 MB
80 + 160 + 3 × 320
1,200 MB: no cabe
Resultado
Tres PNG gigantes al mismo tiempo, y el kernel mata al gateway: OOMKilled. Por eso C01 limita a 2 los decodes simultáneos por pod (PREPROCESS_CONCURRENCY=2) y rechaza las fotos de más de 40 MP antes de abrirlas.
¿Cabe, o lo mata el OOM killer?Calculadora

El pico de memoria de un proceso que decodifica fotos: su base, los archivos de las requests que está atendiendo y lo que ocupa cada foto mientras se decodifica. Si pasa el límite del contenedor, el kernel lo mata (OOMKilled, código 137). Arranca con el gateway de C01.

El límite1 MiB = 1.048576 MB1.07 GB
Memoria en el peor momentobase + archivos en vuelo + fotos × memoria de cada una880 MB
Parte del límite que usamás de 100 %: OOMKilled82.0 %
Margensi es negativo, el kernel lo mata194 MB
Fotos a la vez que caben2

Con 3 fotos a la vez, 80 + 160 + 3 × 320 = 1,200 MB pasan los ≈ 1,074 MB del límite. El kernel no espera a que termine la foto: mata al proceso con SIGKILL, sin excepción ni traceback, y el kubelet lo reinicia.

Arranca con C01: 2 fotos a la vez, 880 MB, el 82 % del límite. Pon 3 para ver el OOM, o sube el límite a 2048 MiB y mira cuántas caben.

Lo que no es un OOMKilled

Quedarse sin memoria de la GPU no es un OOMKilled. Si la VRAM no alcanza, PyTorch lanza una excepción (CUDA out of memory) dentro del proceso, que el programa ve y puede atrapar. El pod no se reinicia por eso, a menos que el programa termine.

Para ir más hondoMemoryError tampoco es un OOM

MemoryError, un error de Python que suena parecido, es otra cosa. Sale cuando pides memoria y el sistema te dice que no en ese momento, así que el programa se entera y puede seguir:

python
try:
    enorme = bytearray(10**15)           # pedir 1 PB de un jalón: un millón de GB
except MemoryError:
    print("MemoryError: el sistema dijo que no, y Python se enteró a tiempo")
print("el proceso sigue vivo")
Salida
MemoryError: el sistema dijo que no, y Python se enteró a tiempo
el proceso sigue vivo

El OOM killer actúa en otro momento. Linux casi siempre dice que sí cuando un proceso pide memoria, y la cuenta llega cuando la usa de verdad; si entonces no alcanza, ya no hay a quién avisarle. Detrás de eso está la memoria virtual, que verás en «Memoria, caché y disco».

Por qué un pod se reinicia solo

Un pod «se reinicia solo» cuando el proceso principal de su contenedor termina y el kubelet lo vuelve a arrancar en el mismo pod: RESTARTS sube 1. La liveness probe es una pregunta que el kubelet le hace al proceso cada pocos segundos, «¿sigues vivo?»; si falla varias veces seguidas (3, si nadie lo cambia), lo reinicia. CrashLoopBackOff es el estado de un contenedor que se cae una y otra vez, y que el kubelet reintenta esperando cada vez más: 10 s, 20 s, 40 s… hasta 5 minutos.

CausaQuién lo terminaCómoCódigoLo que ves
Se pasó de memoriaEl kernelSIGKILL137OOMKilled
Falló al arrancar, o una excepciónEl propio programaSale solo1, u otroError; si se repite, CrashLoopBackOff
Se colgóEl kubelet, porque falló la livenessSIGTERM y, al vencer el plazo, SIGKILL137, o 0 si alcanzó a cerrarLiveness probe failed
Una versión nuevaKubernetes lo reemplaza: no es un reinicioSIGTERM y apagado ordenado0Un pod nuevo, con RESTARTS en 0
Por qué se reinicia un pod · Toca cada caja para ver qué hace, o usa «Recorrer» para seguir cuatro historias: un OOM, un engine que no carga, un proceso colgado y una versión nueva.
  • Versión nueva · un rollout
    Un rollout cambia los pods de una versión por los de la nueva, de a poco. No reinicia nada: crea pods nuevos y apaga los viejos con un apagado ordenado.
  • kubelet · arranca y vigila
    El programa de Kubernetes que corre en cada máquina. Arranca los contenedores, les pregunta si siguen vivos, les manda SIGTERM y SIGKILL, y lee el código de salida de cada uno cuando terminan.
  • CrashLoopBackOff · 10 s, 20 s… 5 min
    Si un contenedor se cae una y otra vez, el kubelet espera cada vez más antes de reintentar: 10 s, 20 s, 40 s… hasta 5 minutos. Si llega a correr 10 minutos sin problemas, la espera vuelve a empezar desde 10 s.
  • Kernel · el OOM killer
    El kernel hace cumplir el límite de memoria del contenedor, con un cgroup. Si el proceso se pasa, el OOM killer lo mata con SIGKILL: OOMKilled, código 137.
  • Proceso PID 1 · Triton o uvicorn
    El proceso principal del contenedor: tritonserver, o python con uvicorn. Si termina, por la razón que sea, el kubelet lo vuelve a arrancar en el mismo pod: un proceso nuevo, que otra vez es el PID 1 de su contenedor.
  • Liveness probe · ¿sigues vivo?
    La pregunta que el kubelet le hace al proceso cada 10 s. Si falla 3 veces seguidas (los valores por omisión), lo reinicia. Solo debe preguntar por el proceso, nunca por otro servicio: si no, una falla de Triton reinicia gateways sanos.

¿Y el gateway de la tabla del principio? Tres reinicios en 5 horas, sin deploys ni errores en los logs. kubectl describe pod dice OOMKilled y 137: el kernel lo mató por memoria. Lo que se reinició fue el proceso, no el pod: el kubelet arrancó otro python en el mismo pod. No dejó rastro porque SIGKILL no se puede atender: ni excepción ni últimos logs. Como los topes de C01 (2 fotos a la vez, de hasta 40 MP) dejan el peor momento en 880 MB, la primera sospecha es una fuga de memoria (memory leak): el proceso guarda cosas que nunca suelta y crece hasta pasar el límite, cada tantas horas. La gráfica de memoria del pod lo distingue: una fuga es una rampa que sube sin parar; un pico de fotos gigantes, un salto.

09

El apagado ordenado, segundo a segundo

Extra

Graceful shutdown, second by second

Cada deploy, y cada vez que el autoscaler (la pieza que decide cuántos pods correr según la carga) quita pods, se apagan procesos. Si se apagan mal, cada uno corta las requests que tenía a medias. Con 3 a 12 pods del gateway, cada deploy apaga varios procesos, y eso suma.

El apagado ordenado de un pod, en una escala de 0 a 60 segundos. En el segundo 0 el pod sale del balanceo. Triton en C01, con un plazo de 60 segundos: preStop de 0 a 10, SIGTERM en el segundo 10, drena lo que tiene en curso hasta 30 segundos, hasta el 40, y queda un margen hasta el 60, donde llegaría el SIGKILL si sigue vivo. El gateway de C01, con un plazo de 45 segundos: preStop de 0 a 15, SIGTERM en el 15, drena hasta 20 segundos, hasta el 35, margen hasta el 45 y SIGKILL ahí si sigue vivo. Si nadie lo cambia, el plazo es de 30 segundos: sin preStop, SIGTERM en el segundo 0 y SIGKILL en el 30. EL APAGADO ORDENADO DE UN POD Segundo 0: el pod sale del balanceo. Triton en C01 · plazo de 60 s 10 s hasta 30 s margen SIGTERM SIGKILL si sigue vivo Gateway en C01 · plazo de 45 s 15 s hasta 20 s margen SIGTERM SIGKILL si sigue vivo Si nadie lo cambia · plazo de 30 s 30 s para todo SIGTERM SIGKILL si sigue vivo 0 10 20 30 40 50 60 s preStop drena lo que tiene margen
El apagado de un pod de Triton y de uno del gateway en C01, y el que trae Kubernetes si nadie lo cambia. Fíjate en que el plazo se cuenta desde el segundo 0, con el preStop adentro. El SIGKILL llega solo si el proceso sigue vivo al final del plazo.

¿Por qué esperar antes del SIGTERM?

Sacar un pod del balanceo no es instantáneo. Kubernetes avisa que el pod está terminando, y cada pieza que reparte tráfico tiene que enterarse y actualizar su lista: kube-proxy, el componente que reparte las requests entre pods dentro del clúster (el grupo de máquinas que maneja Kubernetes), y el ALB, el balanceador de AWS que recibe las requests de afuera. Mientras tanto siguen llegando requests, y si el proceso dejara de aceptar de inmediato, se perderían. El sleep del preStop lo mantiene atendiendo mientras la baja se propaga. En el gateway es más largo porque el ALB tarda más que kube-proxy (M06).

Los plazos de gracia de C01 y de C10La cuenta
TritonpreStop + drenado + margen
10 + 30 + 20 s
60 s
Gateway
15 + 20 + 10 s
45 s
vLLM, el servidor del LLM de C10la respuesta más larga: 1,024 tokens × 50 ms ≈ 51 s
15 + 60 + 15 s
90 s
Por omisiónnadie lo cambió
lo que trae Kubernetes
30 s
Resultado
La regla: el plazo de gracia cubre el preStop, el drenado más largo y un margen. El tiempo del preStop cuenta dentro del plazo: si no caben, el SIGKILL llega a media respuesta.

Forma exec contra forma shell

Así se ve cada forma en un Dockerfile, que lleva un solo CMD (si hay dos, cuenta el último):

dockerfile
# Forma exec: uvicorn es el PID 1 y recibe el SIGTERM
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080", "--timeout-graceful-shutdown", "20"]

# Forma shell: el PID 1 es /bin/sh, que no le pasa el SIGTERM a uvicorn
CMD uvicorn app:app --host 0.0.0.0 --port 8080 --timeout-graceful-shutdown 20

Con la forma shell, el SIGTERM se pierde. Uvicorn sigue atendiendo como si nada hasta que, a los 45 s, el SIGKILL corta lo que tenga a medias y el contenedor termina con 137. Cada deploy tarda 45 s por pod y corta requests, sin que nadie vea un error en el código.

Si de verdad necesitas un script (un archivo de comandos para la shell) como PID 1, por ejemplo para preparar algo antes de arrancar, hay dos salidas. La sencilla: que su última línea sea exec uvicorn …, que cambia el script por uvicorn sin cambiar el PID. La otra, un init mínimo como tini: un programa chiquito que se queda como PID 1, le pasa las señales a tu proceso y recoge a los procesos hijos que terminan. Docker lo trae con la opción --init.

Cómo probar que tu apagado funciona

En tu máquina, docker stop manda SIGTERM y espera 10 s antes del SIGKILL. Con la forma exec, uvicorn cierra en menos de un segundo; la estación 7 del Dojo lo revisa así. Si docker stop tarda los 10 s completos, la señal no llegó.

En Kubernetes, borra un pod con kubectl delete pod y mira dos cosas: cuánto tarda en desaparecer y con qué código terminó. Un 0 es un apagado limpio (con tini delante, el de uvicorn sale con 143); un 137, que el plazo se acabó. Y mientras tanto, manda tráfico: si el apagado es bueno, ningún cliente ve un error. Si el SIGKILL corta una request a medias, el balanceador ve la conexión cortada y le contesta al cliente con un error: un 502, en HTTP.

Un worker a mitad de un trabajo largo

Un worker de una cola, un proceso que toma trabajos de una fila y los hace uno por uno, tiene otro problema: sus trabajos pueden durar minutos. En M06, cuando baja la cola, el autoscaler quita pods de workers sin mirar qué están haciendo. Un worker que procesa un video de 10 minutos recibe SIGTERM a los 15 s de empezar. Si muere, el video vuelve a la cola cuando vence el tiempo que la cola le había dado, y otro worker empieza de cero.

Las tres defensas de la Ronda: un plazo de gracia mayor que el trabajo más largo; un worker que, al recibir SIGTERM, termina lo que tiene y ya no toma otro, y trabajos que se puedan repetir sin hacer daño, porque repetirlos va a pasar de todos modos.

10

Errores comunes

Esencial

Common mistakes

1. El SIGTERM que nunca llega

CMD en forma shell, o un script propio como PID 1 sin manejador. El pod espera todo el plazo de gracia y muere con SIGKILL, con requests a medias.

Cómo evitarlo: forma exec y un servidor que maneje SIGTERM (uvicorn, Triton); si hace falta un script, que acabe con exec, o tini. Pruébalo con docker stop: el contenedor debe cerrar en segundos, no esperar los 10 s de Docker.

2. Un apagado más largo que el plazo

El preStop más el drenado no caben en terminationGracePeriodSeconds, y el SIGKILL llega a media respuesta en cada deploy.

Cómo evitarlo: plazo = preStop + drenado + margen, como C01 (60 = 10 + 30 + 20). En los workers de una cola, un plazo mayor que el trabajo más largo.

3. Multiplicar procesos sin contar la memoria

uvicorn --workers 4 arranca 4 procesos, y si la app carga un modelo en la GPU, cada uno carga su copia de los pesos y su contexto de CUDA (lo que cada proceso prepara para usar la GPU). Con un modelo grande, ni dos caben.

Cómo evitarlo: un proceso por pod, y más pods. La concurrencia va dentro del proceso: un threadpool, o el batching de Triton.

4. Más hilos para más cálculo

Agregar hilos a un cálculo en Python puro no lo acelera (el GIL), y pasar de los núcleos que hay tampoco: solo agrega turnos.

Cómo evitarlo: pregunta primero si el trabajo espera o calcula. Si espera, concurrencia. Si calcula, núcleos de verdad: procesos aparte, librerías en C que suelten el GIL o más pods.

5. Buscar el traceback de un 137

Un OOMKilled no deja excepción ni último log, así que revisar el código buscando el error es perder el tiempo. Confundirlo con un CUDA out of memory (VRAM, una excepción) también.

Cómo evitarlo: primero kubectl describe pod: Last State, Reason y Exit Code. Si dice OOMKilled, mide la memoria en el pico, no en promedio, y limita lo que crece con la carga: decodes simultáneos, tamaño de los archivos, requests en vuelo.

6. Datos compartidos sin candado

Un contador o una lista que tocan varios hilos pierde actualizaciones de vez en cuando, sin ningún error. Las pruebas pasan; en producción, con carga, falla.

Cómo evitarlo: un threading.Lock alrededor, o que cada hilo trabaje con lo suyo y se junte al final.

7. Una liveness que pregunta por otros

Si la liveness del gateway revisa que Triton responda, cuando Triton cae el kubelet reinicia en cascada gateways sanos, que no arreglan nada (C01).

Cómo evitarlo: la liveness solo pregunta si el proceso responde. Las dependencias van en la readiness probe, la pregunta de «¿le mando tráfico?», que solo saca al pod del balanceo.

11

En la vida real

Extra

In real life

C01: un límite de CPU que congela

Cada request de C01 pasa por dos procesos en máquinas distintas: python con uvicorn en el gateway (≈ 6 ms de CPU por foto, en un hilo del threadpool) y tritonserver en el nodo de la T4. En el pico de diseño llegan 500 requests por segundo a todo el servicio y cada una tarda en promedio ≈ 50 ms, así que hay ≈ 25 en vuelo a la vez (500 × 0.05 s, según M03): con 5 pods del gateway en el pico, unas 5 por pod.

La Ronda (M06) cuenta por qué C01 no le pone límite de CPU al gateway. Con un límite de 1 núcleo, el kernel le da 100 ms de CPU por cada ventana de 100 ms. El ejemplo de M06: llegan 4 fotos web grandes a la vez, y 4 hilos las decodifican en paralelo (C01 usa 2, pero la idea es la misma), cada una con 40 ms de CPU:

Cuatro hilos contra un límite de 1 núcleoLa cuenta
Cuota por ventanalímite de 1 núcleo
1 × 100 ms
100 ms de CPU
Lo que gastan 4 hilos en paralelo
4 × 1 ms por cada ms
4 ms de CPU por ms
Se acaba la cuota
100 ÷ 4
a los 25 ms
Le falta a cada foto
40 − 25
15 ms
Congelado
del ms 25 al ms 100
75 ms sin hacer nada
Terminan
100 + 15
a los 115 ms
Resultado
Cada foto tardó 115 ms en vez de 40, y el uso promedio de CPU se ve bajo. Por eso C01 reserva CPU para el gateway, pero no le pone tope.

Otra historia de C01: si el engine de TensorRT no coincide con la GPU o con la versión, Triton sale al arrancar (--exit-on-error=true) y el pod queda en CrashLoopBackOff sin llegar nunca a estar listo. Es una falla ruidosa a propósito, mejor que un servidor «vivo» que no sabe clasificar. Y como Triton tarda en cargar el engine, una startupProbe, la pregunta que el kubelet hace solo mientras el proceso arranca, le da hasta 5 minutos (60 intentos, cada 5 s) antes de que la liveness empiece a contar.

C10: un LLM que tarda minutos en volver

Con el LLM de C10, reiniciar un pod cuesta minutos: cargar 70 GB de pesos (≈ 70 mil millones de parámetros en FP8, 1 byte cada uno) y preparar la GPU, con una startupProbe de hasta 15 minutos. Por eso su liveness es conservadora, tarda en darlo por muerto: un reinicio por error sale carísimo. Su apagado le da a vLLM, el programa que sirve el modelo, hasta 60 s para terminar las respuestas en curso.

C13: esperar el final de la frase

En la transcripción en vivo de C13, un worker no se apaga a media frase. Al recibir la orden, deja de aceptar sesiones y espera el final de la frase en curso de cada una antes de pasarlas a otro worker. Una frase dura como mucho ≈ 20.6 s (20 s de voz más 0.6 s para notar que terminó), y en el peor caso son dos: ≈ 41 s. Por eso su plazo de gracia es de 90 s, el triple del que trae Kubernetes.

Un mensaje venenoso

M02 cuenta el caso de un mensaje de una cola que provoca un OOM cada vez: un PDF corrupto o una imagen que dispara un bug. El worker que lo toma muere sin excepción, el mensaje vuelve a la cola, otro worker lo toma y muere también. Como el proceso nunca alcanza a anotar el fallo, el único que cuenta los intentos es la cola: tras 5, lo manda a una cola aparte, la DLQ (dead-letter queue). Sin ese tope, el mismo mensaje tumbaría workers para siempre, y cada intento volvería a cargar el modelo en la GPU.

La herramienta de pruebas también es un proceso

Para medir cuánto aguanta C01, la Ronda (M03) usa Locust, un programa de Python que simula usuarios. Un solo proceso de Python puede saturarse antes que el servicio que mide, porque, por el GIL, no pasa de un núcleo: por eso se corre con --processes 4.

Fuera de ML

Chrome corre los sitios en procesos aparte: si una pestaña se cae, ves el error solo en esa y las demás siguen. Android tiene su propio asesino de memoria: cuando falta, cierra sin aviso las apps que llevan rato en segundo plano, y por eso a veces una app «empieza de cero» al volver a ella. Y cada vez que aprietas Ctrl+C en una terminal, le mandas un SIGINT al programa que corre: si sale, deja un 130.

12

Ejercicios

Esencial

Exercises

Resuélvelos con lápiz antes de abrir la solución. Los de requests por segundo y de memoria se pueden comprobar con las calculadoras.

  1. 1Fácil

    ¿Programa o proceso? (a) El archivo tritonserver dentro de la imagen. (b) Las 3 copias del gateway que atienden requests. (c) app.py. (d) El PID 1 dentro del contenedor de Triton.

    Ver la solución paso a paso
    • (a) Programa: un binario ejecutable que espera en el disco.
    • (b) Tres procesos del mismo programa, cada uno con su memoria y su PID.
    • (c) Programa: código fuente, texto que el intérprete lee al arrancar.
    • (d) Proceso: tritonserver corriendo, con su PID, sus hilos y su memoria.
  2. 2Fácil

    Explica cada código de salida: 0, 1, 130, 137, 139 y 143.

    Ver la solución paso a paso
    • 0: todo bien. 1: el programa falló solo; por ejemplo, una excepción de Python que nadie atrapó.
    • 130 = 128 + 2: SIGINT, alguien apretó Ctrl+C.
    • 137 = 128 + 9: SIGKILL. En Kubernetes, un OOMKilled o un plazo de gracia vencido.
    • 139 = 128 + 11: SIGSEGV, el proceso tocó memoria que no era suya.
    • 143 = 128 + 15: lo terminó un SIGTERM. O no tenía manejador, o lo atendió y al final salió como si la señal lo hubiera terminado, como uvicorn detrás de tini.
  3. 3Fácil

    ¿Concurrencia, paralelismo o los dos? (a) Un cajero que atiende tres filas, un cliente de cada una por turnos. (b) Cuatro hilos que decodifican cuatro fotos con Pillow en una máquina de 4 núcleos. (c) Un hilo del gateway que, mientras espera a Triton, atiende otra request.

    Ver la solución paso a paso
    • (a) Concurrencia: tres filas en curso y un solo cajero, que es como un núcleo.
    • (b) Paralelismo: cuatro núcleos trabajando en el mismo instante. Como las cuatro tareas están en curso a la vez, también es concurrencia: todo paralelismo lo es.
    • (c) Concurrencia: aprovecha la espera, no otro núcleo.
  4. 4Fácil

    Un proceso recibe SIGTERM y lo ignora. ¿Qué pasa en Kubernetes si nadie cambió el plazo de gracia? ¿Y si recibe SIGKILL?

    Ver la solución paso a paso
    • Con SIGTERM ignorado, el kubelet espera los 30 s del plazo por omisión y después manda SIGKILL. Las requests a medias se cortan, y el contenedor termina con 137.
    • SIGKILL no se puede atender ni ignorar: el kernel lo termina en el acto, también con 137.
  5. 5Medio

    Cuatro tareas de 30 ms de CPU cada una en 2 núcleos. (a) Si cada núcleo hace dos, una tras otra, ¿cuándo termina cada una y cuál es el promedio? (b) ¿Y si las cuatro se turnan en los 2 núcleos cada 10 ms?

    Ver la solución paso a paso
    • (a) Núcleo 1: A de 0 a 30 y C de 30 a 60. Núcleo 2: B de 0 a 30 y D de 30 a 60. Terminan a los 30, 30, 60 y 60 ms: promedio (30 + 30 + 60 + 60) ÷ 4 = 45 ms.
    • (b) En cada turno de 10 ms corren dos: A y B de 0 a 10, C y D de 10 a 20, y así. A y B completan sus 30 ms a los 50 ms; C y D, a los 60 ms. Promedio: (50 + 50 + 60 + 60) ÷ 4 = 55 ms.
    • La última termina igual a los 60 ms: con trabajo de pura CPU, turnarse no acelera; solo reparte la espera.
  6. 6Medio

    El gateway gasta 6 ms de CPU y espera 12 ms a Triton por request, en un solo núcleo. ¿Cuántas requests por segundo da con 1 en vuelo? ¿Con 2? ¿Cuántas en vuelo hacen falta para llenar el núcleo?

    Ver la solución paso a paso
    • Cada request tarda 6 + 12 = 18 ms. Con 1 en vuelo: 1,000 ÷ 18 ≈ 55.6 requests por segundo, con la CPU al 55.6 × 6 ÷ 1,000 ≈ 33 %.
    • Con 2 en vuelo: 2 × 1,000 ÷ 18 ≈ 111.1.
    • El tope de un núcleo es 1,000 ÷ 6 ≈ 166.7. Para llegar hacen falta 18 ÷ 6 = 3 en vuelo: 3 × 55.6 ≈ 166.7.
  7. 7Medio

    Un contenedor con 2 GiB de límite corre un proceso que usa 300 MB de base, sin otros archivos en memoria, y cada PNG de 40 MP ocupa 320 MB al decodificarse. ¿Cuántos decodes caben a la vez? ¿Qué pasa con uno más?

    Ver la solución paso a paso
    • 2 GiB = 2,147,483,648 B ≈ 2,147 MB. Quedan 2,147 − 300 = 1,847 MB, y 1,847 ÷ 320 ≈ 5.8: caben 5.
    • Con 5: 300 + 5 × 320 = 1,900 MB, el 88 % del límite.
    • Con 6: 300 + 6 × 320 = 2,220 MB, más que 2,147. El kernel mata al proceso: OOMKilled, código 137, y el kubelet lo reinicia.
  8. 8Medio

    Un pod tiene un preStop de 5 s, su servidor tarda hasta 25 s en drenar y nadie cambió el plazo de gracia. ¿Alcanza? ¿Qué cambias?

    Ver la solución paso a paso
    • El plazo por omisión es 30 s, y 5 + 25 = 30: el margen es cero. Si un día el drenado tarda 26 s, el SIGKILL llega a media respuesta.
    • Sube terminationGracePeriodSeconds a preStop + drenado + margen: 5 + 25 + 10 = 40 s o más, como los 45 s del gateway de C01.
  9. 9Reto

    Un contenedor tiene un límite de CPU de 1.5 núcleos: 150 ms de CPU por cada ventana de 100 ms, y si se los acaba antes, se congela hasta la siguiente ventana. Llegan 4 fotos a la vez y 4 hilos las decodifican en paralelo, con 40 ms de CPU cada una. ¿Cuándo termina cada foto? ¿Y con un límite de 2 núcleos?

    Ver la solución paso a paso
    • Con 1.5: los 4 hilos gastan 4 ms de CPU por cada ms, así que la cuota se acaba a los 150 ÷ 4 = 37.5 ms. Cada foto lleva 37.5 ms; le faltan 2.5 ms.
    • El contenedor queda congelado hasta el ms 100. Ahí terminan, a los 100 + 2.5 = 102.5 ms: les faltaba el 6 % del trabajo y tardaron 2.6 veces lo necesario.
    • Con 2 núcleos, la cuota es de 200 ms y las cuatro fotos necesitan 4 × 40 = 160 ms: cabe. Terminan a los 40 ms, sin congelarse.
    • La lección: el límite de CPU es un precipicio. O no pasa nada, o pagas el resto de la ventana.
  10. 10Reto

    Cada deploy del gateway tarda 45 s por pod, y durante los deploys los clientes ven errores. Los pods viejos terminan con Exit Code 137, sin OOMKilled, y en sus logs no aparece el mensaje de apagado de uvicorn. ¿Qué pasa? ¿Cómo lo confirmas y cómo lo arreglas?

    Ver la solución paso a paso
    • 137 sin OOMKilled es un SIGKILL del kubelet: el proceso seguía vivo al vencer el plazo de 45 s. Eso explica los 45 s por pod.
    • Uvicorn sabe apagarse en orden con SIGTERM y no lo hizo: no le llegó. La sospecha principal es un CMD en forma shell, con /bin/sh como PID 1, que no reenvía la señal.
    • Para confirmarlo: docker top muestra qué es el PID 1 del contenedor (o, desde adentro, cat /proc/1/cmdline, si la imagen no trae ps), y docker stop en tu máquina tarda los 10 s completos en vez de cerrar al instante.
    • El arreglo: CMD en forma exec, para que uvicorn sea el PID 1. Si hace falta un script, que acabe con exec, o tini. Después, el deploy tarda lo que el preStop y el drenado, y no se corta nada.
13

Lo que sigue

Esencial

What comes next

En la Ronda:

  • M02 · Patrones de inferencia: los workers que toman mensajes de una cola, y el mensaje venenoso que provoca un OOM y mata al proceso sin excepción.
  • M03 · Latencia y capacidad: las requests en vuelo, Locust con varios procesos y dos causas de un p99 alto: el límite de CPU que congela, el de «En la vida real», y las pausas del propio proceso.
  • M05 · Contenedores: un proceso por contenedor, la forma exec que hace a uvicorn el PID 1, tini y PYTHONUNBUFFERED=1.
  • M06 · Orquestación y escalado: OOMKilled y su 137, las probes, el apagado ordenado con preStop y plazo de gracia, y los workers que reciben SIGTERM a mitad de un trabajo.
  • M07 · Servidores de modelos: uvicorn --workers 4 y sus 4 copias del modelo, el threadpool del gateway y las instancias de Triton, con un proceso por instancia en su backend de Python.
  • M08 · Optimización de modelos: hilos contra procesos, medidos con una ResNet-50 en CPU.

En el Dojo: la estación 7, «Empaquetado (Docker)», revisa que el PID 1 de cada imagen sea uvicorn y que el proceso cierre en menos de un segundo al recibir SIGTERM. Cuando abran, la 6, «Código de serving», arma el threadpool del gateway, y la 9, «Kubernetes», las probes y el plazo de gracia.

En Sensei: «Memoria, caché y disco» abre la RAM donde viven los procesos y la memoria virtual. «CPU y GPU» cuenta los núcleos, las vCPU y CUDA. «El sistema operativo» explica el kernel, los cgroups que ponen los límites y cómo un proceso le pide cosas al kernel. «Hacer varias cosas a la vez» sigue con el event loop, el GIL y los threadpools. Dos avisos para la Ronda: en las GPUs, kernel es otra cosa, una función que corre en la GPU (kernel (de gpu)); y scheduler también nombra a la pieza de Kubernetes que decide en qué máquina corre cada pod y a la de Triton que junta requests en batches.

14

Palabras que vas a oír

Esencial

Words you will hear

Estos son los conceptos que presentó esta lección. Cada uno tiene su tarjeta con la definición completa.

ProgramaprogramUn archivo, o un grupo de archivos, con instrucciones para una computadora. En el disco no hace nada; cuando lo arrancas, a ese programa corriendo se le llama proceso.Más

Puede ser código fuente, texto que escribe una persona y que un intérprete lee al correr (como los archivos .py del gateway de C01), o un binario ejecutable ya compilado a instrucciones de máquina (como tritonserver). Del mismo programa pueden correr muchos procesos a la vez, cada uno con su propia memoria. La imagen de un contenedor es al contenedor lo que el programa al proceso: la plantilla quieta.

Analogía. La receta escrita: dice qué hacer, pero no cocina nada. Se rompe en que a una receta la sigue alguien que improvisa; a un programa, la CPU lo sigue al pie de la letra.

Ejemplo. El gateway de C01 es un programa: una carpeta con app.py y sus librerías. Con 3 pods, ese único programa corre como 3 procesos.

Ver en el glosario →

ProcesoprocessUn programa corriendo. El sistema operativo le da memoria propia, un número que lo identifica (el PID) y al menos un hilo, una línea de ejecución, y lo turna en la CPU con los demás procesos.Más

Es la unidad con la que el kernel reparte la máquina. Cada proceso tiene su memoria, que los otros no pueden tocar; su PID y el de su padre; uno o más hilos; los archivos y conexiones que abrió; sus variables de entorno y el usuario con el que corre. Termina con un código de salida, porque acabó o porque lo mató una señal, y al terminar el kernel recupera toda su memoria. Un contenedor es, por dentro, uno o más procesos con una vista propia de la máquina.

Analogía. Cocinar la receta ahora, en una cocina con su mesa (la memoria), sus ingredientes (los datos) y su número de orden (el PID). Se rompe en que, al terminar, la cocina se borra completa en un instante: nada de lo que había en la mesa sobrevive.

Ejemplo. uvicorn --workers 4 arranca 4 procesos que atienden requests, más uno que los vigila. Si cargan un modelo en la GPU, son 4 copias de los pesos y 4 contextos de CUDA.

Ver en el glosario →

HilothreadUna línea de ejecución dentro de un proceso: el punto del código por donde va el programa. Cada hilo lleva su propia cuenta de en qué va y comparte la memoria con los otros hilos del mismo proceso.Más

Todo proceso nace con un hilo y puede crear más, para hacer varias cosas a la vez sin copiar memoria. El planificador del kernel turna los hilos en los núcleos, y cada cambio es un cambio de contexto. Un hilo puede estar corriendo, listo (esperando turno) o bloqueado (esperando la red, el disco o un candado, sin gastar CPU). Compartir memoria tiene un riesgo: dos hilos que cambian el mismo dato pueden perder actualizaciones (una condición de carrera), y por eso existen los candados (locks). En CPython, el GIL deja ejecutar Python a un solo hilo a la vez.

Analogía. Varios cocineros en la misma cocina: comparten la mesa y los ingredientes, así que se coordinan rápido, pero pueden agarrar el mismo tazón. Se rompe en que un cocinero ve lo que hace el otro; dos hilos no se enteran de que chocaron.

Ejemplo. El gateway de C01 tiene un hilo que atiende las requests y un threadpool que decodifica fotos, dos a la vez (PREPROCESS_CONCURRENCY=2), todo dentro del mismo proceso.

Ver en el glosario →

PIDprocess IDEl número con el que el sistema operativo identifica a un proceso (un programa corriendo) mientras vive. Con él se le habla; por ejemplo, para pedirle que termine.Más

Lo asigna el kernel al crear el proceso, en orden; al llegar al tope vuelve a empezar y reusa los de procesos que ya terminaron. Por eso cambia en cada corrida. Cada proceso conoce también el PID de su padre (el PPID), y así los procesos forman un árbol. El PID 1 es el primero que arranca: en Linux suele ser systemd; en una Mac, launchd. Un contenedor tiene su propia numeración: adentro, el PID 1 es el programa principal (por fuera, la máquina le da otro número), y tiene una regla especial: el kernel no le aplica la acción por omisión de una señal (salvo la de un SIGKILL que llegue de afuera), así que si no maneja SIGTERM, la señal se ignora.

Ejemplo. kill 4821 le manda SIGTERM al proceso con PID 4821. En la imagen del gateway de C01, el CMD en forma exec hace que uvicorn sea el PID 1 y reciba el SIGTERM de cada deploy.

Ver en el glosario →

Binario ejecutableexecutable binaryUn archivo con instrucciones de máquina, las órdenes simples que entiende la CPU, ya traducidas y listas para arrancar directo. Sirve para un solo tipo de CPU (x86-64 o arm64) y un solo sistema operativo.Más

Sale de compilar código fuente (C, C++, Go, Rust). Se le dice binario porque no es texto para personas, sino los bits que lee la CPU. Un binario de Linux para x86-64 no corre, sin un emulador, en arm64 (una Mac con chip M, un Graviton de AWS) ni en otro sistema operativo; en un contenedor, ese error se ve como exec format error. El propio intérprete de Python es un binario, python, compilado desde C.

Ejemplo. tritonserver es el binario de Triton, compilado desde C++. Cuando corre Triton, el proceso que aparece en ps es ese binario.

Ver en el glosario →

CompilarcompileTraducir un programa completo, antes de correrlo y una sola vez, a instrucciones de máquina: las órdenes simples que entiende la CPU. Lo hace otro programa, el compilador, y el resultado es un binario ejecutable, un archivo que se arranca directo.Más

Los lenguajes compilados (C, C++, Go, Rust) pagan la traducción una vez y después corren a toda velocidad, porque la CPU ejecuta el resultado directo. El precio: un binario por cada arquitectura y sistema operativo, y volver a compilar con cada cambio. Python hace otra cosa: lo corre un intérprete. La palabra también se usa con modelos: TensorRT compila un modelo en un engine hecho para un modelo de GPU y una versión de TensorRT, y torch.compile compila partes de un modelo de PyTorch mientras el programa corre.

Ejemplo. Triton se compila desde C++ al binario tritonserver. El modelo de C01 se compila con TensorRT a un engine FP16 para la T4: si la versión no coincide, Triton no arranca y el pod queda en CrashLoopBackOff.

Ver en el glosario →

IntérpreteinterpreterUn programa que lee tu código y lo va ejecutando mientras corre, sin traducirlo antes a instrucciones para la CPU. Python funciona así.Más

CPython, el intérprete de Python más usado, traduce cada archivo .py a bytecode (instrucciones sencillas para el propio intérprete; el de los módulos que importa lo guarda en __pycache__, si puede escribir ahí) y las ejecuta una por una con un ciclo escrito en C. Cada instrucción de bytecode cuesta muchas instrucciones de máquina: por eso un ciclo de Python puro es decenas de veces más lento que el mismo ciclo en C, y el trabajo pesado se deja a librerías compiladas, como NumPy y PyTorch. El intérprete es, a su vez, un binario: cuando «corre el gateway», el proceso es python.

Analogía. Un intérprete de conferencias, que traduce frase por frase mientras el orador habla, contra un traductor que entrega el libro ya traducido (el compilador). Se rompe en que CPython sí traduce antes, a bytecode; lo que hace al momento es ejecutarlo.

Ejemplo. dis.dis muestra el bytecode de una función: en Python 3.14, return precio * 1.16 son cuatro instrucciones, entre ellas BINARY_OP para la multiplicación.

Ver en el glosario →

ConcurrenciaconcurrencyTener varias tareas en curso al mismo tiempo, aunque en cada instante avance solo una. Ayuda cuando las tareas esperan: mientras una espera, otra usa la CPU.Más

Es una forma de organizar el trabajo, no de hacerlo más rápido. Con trabajo de pura CPU en un núcleo, turnarse no baja el tiempo total y empeora el promedio. Con esperas (la red, el disco, otro servicio), sí ayuda: el gateway de C01 atiende otras requests mientras espera a Triton. Se logra con hilos, con procesos o con un event loop. No es lo mismo que el paralelismo, que necesita varios núcleos: todo paralelismo es concurrencia, pero no al revés.

Analogía. Un mesero que atiende cinco mesas: toma una orden, la lleva a la cocina y, mientras se prepara, atiende otra mesa. Se rompe en que el mesero decide cuándo cambiar de mesa; con hilos, lo decide el planificador del sistema operativo.

Ejemplo. Cuatro requests del gateway con 6 ms de CPU y 10 ms de espera cada una: una tras otra, la cuarta sale a los 64 ms; concurrentes, en el mismo núcleo, a los 34 ms.

Ver en el glosario →

ParalelismoparallelismQue varias tareas avancen en el mismo instante, cada una en su propio núcleo de la CPU (o en una GPU). Necesita hardware: con un solo núcleo, dos tareas nunca avanzan en el mismo instante.Más

Es lo único que acelera el trabajo de pura CPU: cuatro fotos de 40 ms de CPU tardan 160 ms en un núcleo y 40 ms en cuatro. Puede ir dentro de una tarea (4 hilos sobre la misma foto: menos latencia) o entre tareas (4 procesos, cada uno con su foto: más throughput). En CPython, los hilos no corren Python en paralelo por el GIL; sí lo hacen los procesos aparte y las librerías en C que lo sueltan. Una GPU es paralelismo en grande: miles de cuentas a la vez.

Ejemplo. La Ronda (M08) midió una ResNet-50 en 4 núcleos: 1 proceso con 4 hilos da ≈ 61 fotos por segundo, a ≈ 16.5 ms cada una; 4 procesos con 1 hilo, ≈ 86 fotos por segundo, a ≈ 46 ms.

Ver en el glosario →

Señalsignal · SIGTERM, SIGKILLUn aviso corto, con un número, que el sistema operativo le entrega a un proceso (un programa corriendo). SIGTERM le pide que termine; SIGKILL lo termina sin preguntarle.Más

Al recibir una señal, el proceso corre su manejador (una función suya), la ignora o deja que pase la acción por omisión, que casi siempre es terminar. Las de todos los días: SIGINT (2, Ctrl+C; en Python, KeyboardInterrupt), SIGTERM (15, el aviso amable de kill, docker stop y Kubernetes) y SIGKILL (9, que no se puede atender ni ignorar: el kernel quita al proceso de en medio). Un servidor bien hecho, al recibir SIGTERM, deja de aceptar requests, termina las que tiene en curso y sale; si tarda más que el plazo de gracia (30 s si nadie lo cambia en Kubernetes), le llega SIGKILL. Dentro de un contenedor, el PID 1 ignora las señales que no maneja, salvo un SIGKILL que llegue de afuera.

Analogía. Una nota que te pasan por debajo de la puerta: puedes leerla y actuar (el manejador) o seguir en lo tuyo. SIGKILL no es una nota: es que te saquen del cuarto. Se rompe en que una nota la lees cuando quieres; una señal interrumpe al proceso en cuanto llega.

Ejemplo. En un deploy de C01, Triton recibe SIGTERM, termina lo que tiene en curso en hasta 30 s y sale. Si a los 60 s sigue vivo, el kubelet le manda SIGKILL.

Ver en el glosario →

Código de salidaexit code · exit statusEl número de 0 a 255 que deja un programa al terminar. 0 quiere decir que todo salió bien, y cualquier otro, que algo falló. Si lo mató una señal del sistema operativo, vale 128 más el número de la señal: 137 = 128 + 9, la de SIGKILL.Más

Lo lee el padre: la terminal (echo $?), Docker o el kubelet. Del 1 al 125 los define cada programa (1 es el error de siempre; Python sale con 1 si una excepción no se atrapa). El 126 y el 127 los pone la shell de la terminal (no se pudo ejecutar, no existe). De 128 en adelante, 128 + n quiere decir «lo mató la señal n»: 130 es Ctrl+C, 137 es SIGKILL, 139 es SIGSEGV y 143 es SIGTERM. Cabe en un byte, así que da la vuelta: sys.exit(256) sale con 0. El subprocess de Python lo da en negativo (−9 en vez de 137). Kubernetes lo muestra en kubectl describe pod como Exit Code, junto con la razón, por ejemplo OOMKilled.

Ejemplo. Exit Code 137 con Reason OOMKilled: el contenedor pasó su límite de memoria y el kernel lo mató. 137 es 128 + 9, la señal SIGKILL.

Ver en el glosario →

OOMout of memory · OOMKilledQuedarse sin memoria (out of memory). Cuando en Linux ya no alcanza la RAM, el sistema operativo elige un programa corriendo, casi siempre el que más memoria usa, y lo mata sin avisarle; en un contenedor pasa lo mismo cuando llega a su propio límite. Kubernetes lo reporta como OOMKilled, con código 137.Más

El kernel lo hace como último recurso, cuando la RAM (o el tope de memoria de un cgroup) ya no alcanza. El proceso no se entera: no hay excepción ni traceback, y se pierden los últimos logs que no había escrito. En un contenedor de Kubernetes, el límite es limits.memory: el kernel mata al proceso aunque a la máquina le sobre memoria, y el kubelet lo reinicia. No es lo mismo que el MemoryError de Python (el sistema negó la memoria al pedirla, y el programa sí se entera) ni que CUDA out of memory (se acabó la VRAM de la GPU: es una excepción dentro del proceso). Se previene midiendo el pico, no el promedio, y limitando lo que crece con la carga.

Ejemplo. El gateway de C01 tiene 1 GiB. Un PNG de 40 MP ocupa ≈ 320 MB al decodificarse: con ≈ 80 MB de base y 160 MB de archivos en vuelo, caben 2 a la vez (≈ 880 MB); con 3 (≈ 1,200 MB), OOMKilled. Por eso PREPROCESS_CONCURRENCY=2.

Ver en el glosario →

Cambio de contextocontext switchLo que hace el sistema operativo para pasar un núcleo de la CPU de un hilo (una línea de ejecución) a otro: guarda por dónde iba el que sale y carga el estado del que entra. Toma del orden de microsegundos.Más

El planificador del kernel lo hace cuando se acaba el turno de un hilo, cuando el hilo se bloquea (espera la red, el disco o un candado) o cuando despierta uno más urgente. Guarda los registros de la CPU, entre ellos en qué instrucción iba y dónde está su pila, y carga los del siguiente; entre hilos de procesos distintos, cambia también el mapa de memoria. Cada cambio es barato, pero miles por segundo se notan, y el hilo que entra encuentra fría la caché. Gracias a él, una máquina de 10 núcleos corre miles de hilos «a la vez».

Ejemplo. Si un cambio cuesta ≈ 3 µs, 1,000 cambios por segundo se llevan 3 ms de cada segundo de un núcleo (0.3 %); 20,000, 60 ms (6 %).

Ver en el glosario →

15

Chequeo

Esencial

Check

Intenta responder en tu cabeza antes de abrir cada respuesta.

  1. 1¿Cuál es la diferencia entre un programa y un proceso?Ver respuesta

    El programa es un archivo con instrucciones, quieto en el disco. El proceso es ese programa corriendo, con memoria propia, un PID, al menos un hilo y sus archivos abiertos. Del mismo programa corren muchos procesos, como los pods del gateway de C01.

  2. 2Cuando «corre el gateway» de C01, ¿qué programa está corriendo de verdad?Ver respuesta

    El binario python, el intérprete, que ejecuta el bytecode de uvicorn y de la app. Triton, en cambio, es un binario ya compilado desde C++: tritonserver.

  3. 3¿Qué comparten dos hilos del mismo proceso, y qué tiene cada uno?Ver respuesta

    Comparten la memoria, los archivos abiertos y el PID. Cada uno tiene su lugar en el código y su pila. Por compartir memoria, dos hilos pueden pisarse (una condición de carrera), y por eso existen los candados.

  4. 4Si tu trabajo es pura CPU y tienes un solo núcleo, ¿turnarlo en varios hilos lo acelera?Ver respuesta

    No. La última tarea termina igual y el promedio empeora: cuatro fotos de 40 ms salen en promedio a los 100 ms una tras otra y a los 145 ms por turnos. La concurrencia ayuda cuando hay esperas; con pura CPU, solo ayudan más núcleos.

  5. 5¿Por qué un servidor maneja SIGTERM, y qué pasa si no lo hace?Ver respuesta

    SIGTERM es el aviso amable para que termine lo que tiene en curso y salga. Sin un manejador, o muere al instante (143) o, si es el PID 1 de un contenedor, la señal se ignora y al vencer el plazo de gracia llega SIGKILL (137). En los dos casos, las requests a medias se cortan.

  6. 6¿Qué te dice un 137 con OOMKilled, y en qué se distingue de un CUDA out of memory?Ver respuesta

    Que el contenedor pasó su límite de RAM y el kernel lo mató con SIGKILL (128 + 9): sin excepción, sin traceback y sin los últimos logs. CUDA out of memory es otra cosa: la VRAM de la GPU no alcanzó, y es una excepción dentro del proceso, que el programa ve y puede atrapar.

Sigue · 17Memoria, caché y discoLa computadora por dentro Léelo en la RondaM03 · Latencia y capacidadLo que acabas de leer, con el ojo del entrevistador. ← 15 · Matrices, FLOPs y qué tan rápido calcula una GPU