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.
Secciones15
- 01Programa y proceso
- 02Compilar o interpretar
- 03Hilos y turnos
- 04Concurrencia y paralelismo
- 05Procesos o hilos para servir un modelo extra
- 06Señales: avisarle algo a un proceso
- 07Códigos de salida: cómo termina un proceso
- 08OOM: cuando se acaba la memoria
- 09El apagado ordenado, segundo a segundo extra
- 10Errores comunes
- 11En la vida real extra
- 12Ejercicios
- 13Lo que sigue
- 14Palabras que vas a oír
- 15Chequeo
¿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?
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:
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 2dNadie 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.
Programa y proceso
EsencialProgram 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.
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.
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)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? Truesubprocess.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.
Compilar o interpretar
EsencialCompiling 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:
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) 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? TrueRESUME 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».
- python y app.py · intérprete y código
Dos archivos.pythones 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 conexec format error. - Proceso python · corre el bytecode
Un proceso del binariopython. Ejecuta el bytecode deapp.pyy 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 binariotritonservercorriendo, 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.
Hilos y turnos
EsencialThreads 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:
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)después del hilo: ['tenis.jpg']
después del proceso hijo: ['tenis.jpg']
código de salida del hijo: 0El 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+
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:
| Paso | Hilo A | Hilo B | El contador queda en |
|---|---|---|---|
| 1 | lee 41 | 41 | |
| 2 | lee 41 | 41 | |
| 3 | escribe 41 + 1 | 42 | |
| 4 | escribe 41 + 1 | 42 |
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.
Concurrencia y paralelismo
EsencialConcurrency 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.
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.
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.
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}")esperar rapidez con 4 hilos: ×4
calcular rapidez con 4 hilos: ×1Esperar 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.
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.
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.
Procesos o hilos para servir un modelo
ExtraProcesses 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).
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ón | Fotos por segundo | Cada 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).
Señales: avisarle algo a un proceso
EsencialSignals: 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ñal | Número | Quién la manda | Qué quiere decir | ¿Se puede atender? |
|---|---|---|---|---|
| SIGINT | 2 | La terminal, cuando aprietas Ctrl+C | «Interrumpe lo que haces» | Sí: Python la convierte en KeyboardInterrupt |
| SIGTERM | 15 | kill, docker stop, Kubernetes | «Por favor, termina»: el aviso amable | Sí |
| SIGKILL | 9 | kill -9; Docker y Kubernetes al vencer el plazo; el kernel sin memoria | No es un aviso: el kernel quita al proceso de en medio | No |
| SIGSEGV | 11 | El kernel, cuando el proceso toca memoria que no es suya | Un 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).
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")SIGINT es 2 · SIGKILL es 9 · SIGTERM es 15
llegó la señal 15: dejo de aceptar trabajo, termino lo que tengo y salgoUn 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
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
Una espera: el preStop
Un
sleepcorto 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
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
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.
Códigos de salida: cómo termina un proceso
EsencialExit 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.
kill -l 9El 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.
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}")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: 137Python 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:
Last State: Terminated
Reason: OOMKilled
Exit Code: 137Las 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.
OOM: cuando se acaba la memoria
EsencialOOM: 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:
- 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ó.
- 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). - 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.
PREPROCESS_CONCURRENCY=2) y rechaza las fotos de más de 40 MP antes de abrirlas.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.
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:
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")MemoryError: el sistema dijo que no, y Python se enteró a tiempo
el proceso sigue vivoEl 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.
| Causa | Quién lo termina | Cómo | Código | Lo que ves |
|---|---|---|---|---|
| Se pasó de memoria | El kernel | SIGKILL | 137 | OOMKilled |
| Falló al arrancar, o una excepción | El propio programa | Sale solo | 1, u otro | Error; si se repite, CrashLoopBackOff |
| Se colgó | El kubelet, porque falló la liveness | SIGTERM y, al vencer el plazo, SIGKILL | 137, o 0 si alcanzó a cerrar | Liveness probe failed |
| Una versión nueva | Kubernetes lo reemplaza: no es un reinicio | SIGTERM y apagado ordenado | 0 | Un pod nuevo, con RESTARTS en 0 |
- 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, opythoncon 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.
El apagado ordenado, segundo a segundo
ExtraGraceful 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.
¿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).
Forma exec contra forma shell
Así se ve cada forma en un Dockerfile, que lleva un solo CMD (si hay dos, cuenta el último):
# 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 20Con 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.
Errores comunes
EsencialCommon 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.
En la vida real
ExtraIn 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:
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.
Ejercicios
EsencialExercises
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.
- 1Fácil
¿Programa o proceso? (a) El archivo
tritonserverdentro 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:
tritonservercorriendo, con su PID, sus hilos y su memoria.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
terminationGracePeriodSecondsa preStop + drenado + margen: 5 + 25 + 10 = 40 s o más, como los 45 s del gateway de C01.
- 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.
- 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
CMDen forma shell, con/bin/shcomo PID 1, que no reenvía la señal. - Para confirmarlo:
docker topmuestra qué es el PID 1 del contenedor (o, desde adentro,cat /proc/1/cmdline, si la imagen no traeps), ydocker stopen tu máquina tarda los 10 s completos en vez de cerrar al instante. - El arreglo:
CMDen forma exec, para que uvicorn sea el PID 1. Si hace falta un script, que acabe conexec, o tini. Después, el deploy tarda lo que el preStop y el drenado, y no se corta nada.
Lo que sigue
EsencialWhat 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 4y 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.
Palabras que vas a oír
EsencialWords 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 %).
Chequeo
EsencialCheck
Intenta responder en tu cabeza antes de abrir cada respuesta.
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.
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¿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.
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¿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¿Qué te dice un 137 con OOMKilled, y en qué se distingue de un
CUDA out of memory?Ver respuestaQue 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 memoryes otra cosa: la VRAM de la GPU no alcanzó, y es una excepción dentro del proceso, que el programa ve y puede atrapar.