# Caminos calientes: inventario, cerrojos y `spawn` Un *hot path* aquí es el código que se ejecuta por petición o por token, cuyo coste se multiplica por el tráfico. La regla es simple: en el camino caliente no hay logging verboso, ni asignaciones evitables, ni cerrojos exclusivos que se puedan evitar. ## 1. Inventario de caminos calientes | Camino | Frecuencia | Dónde | Presupuesto | | --- | --- | --- | --- | | Recepción/reenvío de activaciones | cada petición y cada salto | `mesh-worker-net::worker::handle_forward` | p50 < 1 ms por salto | | Enrutamiento | cada petición del plano de control | `mesh-router::route_request` | puro, sin E/S | | `should_throttle` / `enforce` | cada ciclo de telemetría (~1 s) | `mesh-orchestrator::host_guard` | frío de facto | | `assign_layers` | arranque y cambio de topología | `mesh-scheduler` | frío de facto | | Framing (`Frame::read_from` / `write_to`) | cada mensaje | `mesh-worker-net::frame` | parte del anterior | Los dos últimos son «calientes» sólo por nombre: `assign_layers` se ejecuta al arrancar y `host_guard` una vez por segundo. Se documentan para dejar claro que **no** se optimizan, y por qué: gastar complejidad ahí no compra nada. ## 2. Cerrojos: inventario y decisión Todos los cerrojos del workspace, con su justificación. La regla del proyecto: `Arc` para contadores, `RwLock` cuando la lectura domina, `Mutex` cuando hay mutación o el crítico es trivial. | Cerrojo | Tipo | ¿En el hot path? | Decisión | | --- | --- | --- | --- | | `WorkerState::d_model` | `tokio::sync::RwLock` | **sí** | **Optimizado**: era `write()` por petición | | `DataPlane::channel` | `tokio::sync::RwLock` | **sí** | **Optimizado**: era `Mutex` | | `Telomerase::channel` | `tokio::sync::RwLock` | sí (ronda de expansión) | **Optimizado**: era `Mutex` | | `WorkerState::routing` | `tokio::sync::RwLock` | **sí** | **Se mantiene** (ver 2.1) | | `PeerPool::inner` | `tokio::sync::Mutex` | **sí** | **Se mantiene** (ver 2.2) | | `HostResourceGuard::state` | `std::sync::Mutex` | no | **Se mantiene** (ver 2.3) | | `NodeRegistry::nodes` | `std::sync::Mutex` | no | **Se mantiene** (ver 2.4) | | `WorkerClient::stream` | `tokio::sync::Mutex` | sí (cliente) | **Se mantiene** (ver 2.5) | Contadores (`served`, `failed`, `in_flight`, `ewma_us`) son `AtomicU64`/`AtomicU32` con `Ordering::Relaxed`: son telemetría, no sincronización, y no ordenan nada. ### 2.1 `WorkerState::routing` — se mantiene `RwLock` Se lee en cada salto (`routing.get(&(layer_end + 1))`) y se escribe sólo al reconfigurar la topología. Es el caso de manual de `RwLock`: la lectura domina por órdenes de magnitud. No se convierte en un mapa inmutable porque **cambia en caliente**: el crecimiento modular del clúster añade destinos sin reiniciar el nodo. Sustituirlo por un `arc_swap` eliminaría el `await`, pero añade una dependencia y un tipo nuevo para ahorrar un `await` sobre un `RwLock` no contendido; no compensa hoy. ### 2.2 `PeerPool::inner` — se mantiene `Mutex` `checkout` hace `guard.get_mut(&peer)?.pop()`, y `checkin` hace `entry(peer).or_default() .push(stream)`: las dos operaciones necesitan `&mut HashMap`, así que un `RwLock` no serviría (el camino rápido también pediría escritura). El crítico es un `pop`/`push` sobre un `Vec`: nanosegundos, sin `await` dentro. `Mutex` es la elección correcta. Lo que **sí** importa aquí es no retenerlo: el intercambio de red ocurre fuera del cerrojo. Si se moviera dentro, toda la latencia del vecino se serializaría por nodo. ### 2.3 `HostResourceGuard::state` — se mantiene `std::sync::Mutex` Es `std::sync::Mutex` y no `tokio::sync::Mutex` a propósito: el crítico es leer y escribir un `struct { throttled: bool, paused: bool }`, sin `await`. Con el `Mutex` asíncrono se pagaría un registro en el reactor de tokio para una operación de dos bytes. El cerrojo se envenena si el que lo tiene entra en pánico, y por eso `should_throttle` y `reset` usan `PoisonError::into_inner()`: un pánico ajeno no puede dejar el guardián inutilizable, que es justo el fallo que la auditoría de la fase 5 encontró en otra forma. ### 2.4 `NodeRegistry::nodes` — se mantiene `Mutex` Igual que el anterior: `std::sync::Mutex` sobre un `BTreeMap`, sin `await` dentro, y con `PoisonError` tratado. No está en ningún camino caliente (se toca al descubrir o podar nodos). ### 2.5 `WorkerClient::stream` — se mantiene `Mutex` El crítico abarca el intercambio completo con el worker porque la conexión no admite peticiones cruzadas: dos respuestas en el mismo socket se mezclarían. Es un cerrojo **funcional**, no un accidente. Un `RwLock` aquí permitiría dos lectores simultáneos sobre el mismo `TcpStream`, que es exactamente el error que el pool evita al prestar una conexión a un solo consumidor. ## 3. Optimizaciones aplicadas en la fase 6 ### 3.1 `d_model`: fuera el cerrojo exclusivo del camino caliente **Antes**, en `handle_forward`: ```rust let d_model = { let mut guard = state.d_model.write().await; // write() en CADA petición if *guard == 0 { *guard = header.cols.max(1); } *guard }; ``` El campo se inicializa una vez y a partir de ahí es de sólo lectura. Pero `RwLock` serializa a los escritores: **todas** las peticiones concurrentes del nodo hacían cola en un cerrojo exclusivo para leer un `u32` que ya no cambiaba. **Después**: ```rust let mut d_model = *state.d_model.read().await; // lectura compartida if d_model == 0 { let mut guard = state.d_model.write().await; // sólo la primera vez if *guard == 0 { *guard = header.cols.max(1); } d_model = *guard; } ``` ### 3.2 Traza: mover en vez de clonar ```rust // antes: let mut trace = header.trace.clone(); // después: let mut trace = header.trace; ``` `header` se consume en la función (`Frame { header, payload } = frame`), así que la copia era un `Vec` completo —una `String` por nodo visitado— asignado en **cada salto**. El préstamo lo permite porque `HopHeader` no implementa `Drop`: se mueve un campo y los demás se siguen leyendo. ### 3.3 Canales gRPC: `Mutex` → `RwLock` `DataPlane::channel` y `Telomerase::channel` guardan `Option`. El patrón es asimétrico: casi todas las llamadas sólo leen el cliente ya creado y lo clonan (barato, multiplexa sobre HTTP/2); sólo la primera —o la primera tras un fallo— lo escribe. Detalle que importa: el guard de lectura se suelta en su propio bloque **antes** de pedir el de escritura, porque el `RwLock` de tokio no es reentrante y pedir `write()` teniendo `read()` en la misma tarea se queda bloqueado para siempre. Y tras tomar el de escritura se vuelve a comprobar el `Option`, porque otra tarea pudo conectar mientras se cambiaba de cerrojo. ### 3.4 `const` en lo que se resuelve en compilación Los umbrales que no dependen del entorno ya eran `const` (`EWMA_ALPHA`, `IDLE_PER_PEER`, `DEFAULT_CPU_THRESHOLD`, `DEFAULT_MEMORY_THRESHOLD`, `DEFAULT_PAUSE_THRESHOLD`, `DEFAULT_HYSTERESIS`, `MAX_ACCURACY_LOSS`, `MAX_HOST_LOAD_FOR_ELIGIBILITY`, `INT4_MIN/MAX`, `DEFAULT_GROUP_SIZE`, `TERNARY_VALUES`). Se revisaron y no quedaba ningún literal mágico repetido en el camino caliente que mereciera promoción. Los que **no** se promueven, a propósito: - `Duration::from_secs(5)` del timeout: es configuración, y `WorkerConfig` ya la expone como campo mutable. Un `const` aquí sólo escondería que es un valor por defecto. - `max_concurrent: 4`, `d_model: Some(24)` del orquestador: son parámetros de arranque que dependen de la topología, no constantes del algoritmo. - El nombre del modelo por defecto: se interpola en telemetría y se puede cambiar por CLI. ### 3.5 Los `tokio::spawn` y su camino de cancelación Regla: un `spawn` sin cancelación es una fuga. Inventario tras la fase 5: | Spawn | Cancelación | | --- | --- | | `Worker::serve_with_shutdown` (conexiones) | `JoinSet` + `shutdown().await` (corregido en 0.5.1) | | `Cluster` (una tarea por nodo) | `NodeEntry::task: Option` + `abort()` en `stop()` | | `transport.rs` (eco de test) | `JoinHandle` abortado al final del test | | `hecate-node bench` (servidor de eco) | abortado explícitamente | Ninguno queda detachado. ## 4. Medición antes/después ### 4.1 Primer intento: plano de datos simulado a 0 ms (fase 6) `hecate-node bench --mock --nodes 3 --requests 600 --concurrency 1,8,32`, tres repeticiones por configuración, mismo equipo, de forma consecutiva. | Concurrencia | Antes (rps) | Después (rps) | | --- | --- | --- | | 1 | 2 536 · 2 483 · 1 940 | 2 228 · 2 171 · 2 313 | | 8 | 7 028 · 7 177 · 7 928 | 7 820 · 7 853 · 8 025 | | 32 | 10 557 · 11 042 · 10 816 | 11 272 · 11 037 · 11 183 | Las diferencias caían dentro del ruido, **y el método estaba mal**: comparar dos binarios en dos sesiones distintas mide la deriva térmica del equipo, no el cambio. Una medición posterior dio «681 → 627 rps» (una regresión del 8 %) con el mismo código, sólo porque el equipo estaba más caliente. ### 4.2 Segundo intento: 5 ms de cómputo y mediciones **intercaladas** (fase 7) Se añadió `--compute-latency-ms N` al plano de datos simulado: una latencia de cómputo por fragmento, que es lo que hace un modelo real. Con 5 ms y un pipeline de 3 nodos el p50 sube a ~50 ms, que es el régimen en el que el cerrojo podría llegar a solaparse. El A/B se hizo **intercalado**: en la misma sesión, alternando el binario sin optimizar (A) y el optimizado (C), cinco pares, concurrencia 32. | Repetición | A — sin optimizar (rps) | C — optimizado (rps) | | --- | --- | --- | | 1 | 615,3 | 614,7 | | 2 | 599,4 | 613,7 | | 3 | 615,3 | 614,9 | | 4 | 600,2 | 614,2 | | 5 | 613,8 | 612,8 | | **Mediana** | **613,8** | **614,2** | **Diferencia: +0,07 %.** Indistinguible del ruido. ### Conclusión: el cerrojo no estaba contendido Con cómputo realista (5 ms por fragmento) y medición intercalada, las optimizaciones **no producen ninguna mejora medible**. La razón es concreta: el `RwLock` de `d_model` se toma una vez por petición y se suelta **antes** del cómputo, así que dos peticiones concurrentes casi nunca se encuentran dentro del crítico. El tiempo se va en el `sleep` del plano de datos. **Las optimizaciones se mantienen por corrección estructural, no por rendimiento.** Quitar una exclusión mutua innecesaria del camino caliente es correcto aunque no se mida: es un punto de serialización que crecería con la carga y que no cuesta nada eliminar. Lo que **no** se hace es presentar esta tabla como una mejora: no lo es. ### Qué haría falta para que se midiera Que el tiempo **fuera** del cómputo fuese comparable al tiempo **dentro** del cerrojo, es decir, un plano de datos con latencia de decenas de microsegundos y una concurrencia mucho mayor. En ese régimen el cerrojo se solapa de verdad. No es el caso de HECATE: el cómputo de un fragmento siempre domina sobre el coste de leer un `u32`. ## 5. Guardia: los benchmarks exigen el binario de release En `debug` el benchmark de cómputo da **0,49 GFLOPS** frente a **34-37 GFLOPS** en release: 70× menos. Ese número no es un detalle de rendimiento, alimenta el `tier` del nodo y la variante de modelo que el selector le asigna. Un nodo capaz clasificado como `low` recibe una variante diminuta y el clúster rinde mal por una decisión tomada con un número falso. Por eso `bench` y `profile` **se niegan** a ejecutarse desde un binario de debug: ```text $ cargo run -p hecate_node -- bench --mock error: `bench` mide rendimiento y este binario está compilado en debug (los GFLOPS salen ~70x por debajo y el tier del nodo queda mal clasificado). ``` `--allow-debug` lo permite si el número no se va a usar para decidir. `scripts/verify.sh` comprueba que la negativa ocurre de verdad (no basta con que el código exista: se ejecuta y se verifica el fallo). ## 6. Qué no se ha optimizado, y por qué - **`route_request`**: es aritmética pura sobre un `BTreeMap` de nodos (unidades). Validar los nodos en cada llamada (`node.validate()`) cuesta, pero el grafo cambia en caliente y validar en el borde es lo que impide enrutar a un nodo con latencia `NaN`. Se prefiere correcto a rápido. - **Serialización**: se serializa el encabezado a JSON en cada trama. Un formato binario ahorraría ancho de banda, pero rompe la legibilidad del protocolo, que es lo que permite depurar un pipeline a mano con `nc`. No se cambia sin una alternativa igual de depurable. - **`payload.to_vec()`** en `handle_forward`: `prost` exige `Vec` para el mensaje gRPC, así que la copia desde `Bytes` es inevitable sin cambiar el `.proto` a `bytes`. Se documenta como coste aceptado, no como descuido.