El modelo detrás de un socket
Vamos a sacar el modelo de tu proceso. La buena noticia es que ya está fuera: el services.py que dejaste corriendo en la terminal 1 desde el paso 2 incluye un servicio de detección escuchando en el puerto 8002. Lo único que falta es hablarle.
Inferencia como I/O de red
Crea detector.py. El corazón son cuatro líneas:
class RemoteDetector:
async def detect(self, jpeg: bytes) -> dict:
async with self.session.post(self.url, data=jpeg) as response:
response.raise_for_status()
return await response.json()
Eso es todo lo que queda de la inferencia en tu programa: mandar bytes por un socket y esperar un JSON. El modelo corre en otro proceso, y el día de mañana podría correr en otra máquina sin que tu código cambie una línea.
Fíjate en las dos palabras nuevas de aiohttp:
async withes unwithnormal que puede esperar al abrir y al cerrar. Aquí abre la petición HTTP cediendo el control mientras el servidor responde.await response.json()espera a que llegue el cuerpo de la respuesta y lo convierte. También cede, porque el cuerpo llega por la red y puede tardar.
Ninguna de las dos calcula nada. Las dos esperan. Tu proceso volvió a ser lo que asyncio hace bien.
Cuarenta a la vez
Ahora la demostración. remote_inference.py lanza cuarenta detecciones simultáneas con el mismo gather que ya conoces, y mantiene el latido corriendo para vigilar el event loop:
async def main():
jpeg = one_jpeg()
beat = asyncio.create_task(heartbeat())
await asyncio.sleep(0.35)
async with RemoteDetector() as detector:
t0 = time.perf_counter()
results = await asyncio.gather(*(detector.detect(jpeg) for _ in range(40)))
elapsed = time.perf_counter() - t0
beat.cancel()
print(f"\n40 inferences, all in flight at once: {elapsed:.2f}s")
python remote_inference.py
tick gap 101 ms
tick gap 101 ms
tick gap 101 ms
40 inferences, all in flight at once: 0.37s
Checkpoint · Cuarenta peticiones, cero hilos
Dos cosas a la vez en esa salida, y las dos importan:
- El latido no se movió de 101 milisegundos. El event loop estuvo libre todo el tiempo.
- Cuarenta inferencias tardaron 0.37 segundos. Una sola, secuencial, habría tardado eso mismo multiplicado por cuarenta.
Y en tu proceso no hay ni un hilo. Compara con el paso anterior: allí necesitabas un ThreadPoolExecutor con su pool, su coordinación y su ceremonia. Aquí solo hay corrutinas esperando sockets.
Lo que acabas de comprar
Este cambio parece pequeño y es el más importante del taller. Al mover el cálculo detrás de un socket ganaste tres cosas de golpe:
- El event loop dejó de estar en peligro. Ninguna función de tu proceso ocupa la CPU largo rato, así que la trampa del paso 6 ya no puede ocurrir.
- La concurrencia dejó de costar. Cuarenta peticiones en vuelo son cuarenta objetos esperando en memoria, no cuarenta hilos con su pila cada uno.
- El cuello de botella se puede escalar sin tocar tu código. Si el modelo va lento, le das más recursos al servicio. Lo verás con tus ojos en el paso 13.
Ojo
Ojo con la lectura fácil de esto: el cálculo no desapareció, se mudó. Alguien sigue pagando la CPU, y en este taller ese alguien es svc_model.py corriendo en tu misma máquina. Lo que ganaste es que ese coste ya no vive dentro de tu event loop, y que puedes escalarlo, moverlo o cambiarlo por separado.
Checkpoint
Ya tienes la mitad del pipeline en su forma final: la inferencia es una llamada de red. Falta la otra mitad, porque la ingesta todavía usa urllib bloqueante del paso 3. Vamos a por ella.