Source document

Caminos calientes: inventario, cerrojos y spawn

Spanish text, transcribed from the repository. The unformatted source is performance/hot-paths.md.

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

CaminoFrecuenciaDóndePresupuesto
Recepción/reenvío de activacionescada petición y cada saltomesh-worker-net::worker::handle_forwardp50 < 1 ms por salto
Enrutamientocada petición del plano de controlmesh-router::route_requestpuro, sin E/S
should_throttle / enforcecada ciclo de telemetría (~1 s)mesh-orchestrator::host_guardfrío de facto
assign_layersarranque y cambio de topologíamesh-schedulerfrío de facto
Framing (Frame::read_from / write_to)cada mensajemesh-worker-net::frameparte 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<Atomic…> para contadores, RwLock cuando la lectura domina, Mutex cuando hay mutación o el crítico es trivial.

CerrojoTipo¿En el hot path?Decisión
WorkerState::d_modeltokio::sync::RwLocksíOptimizado: era write() por petición
DataPlane::channeltokio::sync::RwLocksíOptimizado: era Mutex
Telomerase::channeltokio::sync::RwLocksí (ronda de expansión)Optimizado: era Mutex
WorkerState::routingtokio::sync::RwLocksíSe mantiene (ver 2.1)
PeerPool::innertokio::sync::MutexsíSe mantiene (ver 2.2)
HostResourceGuard::statestd::sync::MutexnoSe mantiene (ver 2.3)
NodeRegistry::nodesstd::sync::MutexnoSe mantiene (ver 2.4)
WorkerClient::streamtokio::sync::Mutexsí (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:

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:

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

// 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<String> 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<Cliente>. 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:

SpawnCancelación
Worker::serve_with_shutdown (conexiones)JoinSet + shutdown().await (corregido en 0.5.1)
Cluster (una tarea por nodo)NodeEntry::task: Option<JoinHandle> + 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.

ConcurrenciaAntes (rps)Después (rps)
12 536 · 2 483 · 1 9402 228 · 2 171 · 2 313
87 028 · 7 177 · 7 9287 820 · 7 853 · 8 025
3210 557 · 11 042 · 10 81611 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ónA — sin optimizar (rps)C — optimizado (rps)
1615,3614,7
2599,4613,7
3615,3614,9
4600,2614,2
5613,8612,8
Mediana613,8614,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:

$ 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<u8> 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.