Source document

Enrutamiento consciente del coste

Spanish text, transcribed from the repository. The unformatted source is routing.md.

1. Objetivo

Elegir, para cada petición, la secuencia de nodos que cubre las capas solicitadas con el menor coste estimado. El coste está en milisegundos equivalentes, así que se puede comparar directamente con la latencia que observa el usuario.

2. Modelo de coste

2.1 Coste de una etapa

\operatorname{cost}(n) = w_\ell \cdot \text{latencia}_n
+ w_c \cdot \text{load}_n \cdot 120
+ w_k \cdot \frac{100}{\max(\operatorname{score}_n,\ 0.01)}
SímboloPeso por defectoSignificado
w_\ell1.0La latencia medida pesa 1 a 1.
w_c1.0Un nodo al 100 % de carga añade 120 ms equivalentes.
w_k0.35Un nodo con score 1 añade 35 ms; con score 28, 1.25 ms.

2.2 Coste de una ruta

\operatorname{coste}(R) = \sum_{n \in R} \operatorname{cost}(n)
+ w_h \cdot 15 \cdot (|R| - 1)
+ w_p \cdot 10 \cdot \frac{\text{tokens\_prompt}}{1024}
  • Término de saltos: cada frontera entre nodos cuesta 15 ms equivalentes.
  • Término de prefill: un prompt largo penaliza por igual todas las rutas, pero hace que las diferencias de latencia pesen proporcionalmente menos.
  • Ruta vacía → \infty.

3. Camino mínimo por fronteras de fragmento

Una restricción manda sobre todo lo demás: un fragmento se ejecuta completo. El nodo que recibe next_layer = L solo puede continuar si tiene un fragmento que empieza en L; entrar en mitad de un fragmento recalcularía capas que otro nodo ya ha hecho.

Por eso score_route() resuelve un camino mínimo en un grafo acíclico:

  • Vértices: las fronteras de capa first, first+1, ..., last+1.
  • Aristas: cada fragmento (L, E) de un nodo vivo, que va de L a E+1 con coste cost(nodo) + coste_salto (el salto solo se cobra si cambia de nodo).
  • Solución: programación dinámica en orden ascendente de capa, con desempate por número de etapas y después por node_id (determinismo total).

Un nodo con dos fragmentos contiguos cuenta como una sola etapa; un nodo que reaparece más adelante (por ejemplo, sirviendo la cabeza replicada y después la cola) cuenta dos veces, porque ejecuta dos tramos.

Si no existe camino completo se lanza NoRouteAvailable con la ventana en el mensaje.

4. Ejemplo numérico

Tres nodos, gpu saturado pero potente, mini sano y laptop con réplica implícita:

NodoLatenciaCargaScore\operatorname{cost}(n)
gpu40 ms0.81040 + 96 + 3.5 = 139.5
mini6 ms0.266 + 24 + 5.8 = 35.8
laptop9 ms0.039 + 0 + 11.7 = 20.7
  • Camino gpu (0-3) → mini (4-7) → laptop (8-11): 139.5 + 35.8 + 20.7 + 2·15 = 226.0
  • Camino mini (0-7) → laptop (8-11): 35.8 + 20.7 + 15 = 71.5 ← óptimo

Resultado: ("mini", "laptop"). Es el caso que verifica test_score_route_prefers_the_cheapest_covering_pipeline.

El DP explora mini para la cabeza (0-3) y, como también es dueño de 4-7, lo mantiene en la misma etapa: no se cobra un salto ficticio ni se lista dos veces.

5. Ventana de capas

RoutingRequest.layer_range permite pedir un subconjunto:

  • None: la ventana es [primera capa servida, última capa servida] de los nodos vivos.
  • (8, 11): solo la cola; el router puede devolver un único nodo.

prompt_tokens no cambia la cobertura, solo el coste relativo del prefill.

6. Failover

failover(route, redundancy_map, failed_node, ...):

  1. Si failed_node no está en la ruta → se devuelve la ruta intacta con la razón "no participa en la ruta". No es un error: el orquestador puede sondear nodos ociosos.
  2. Se determinan las capas del nodo caído (de su NodeState.served_layers si hay telemetría; si no, de redundancy_map), recortadas a la ventana de la ruta.
  3. Para cada capa se busca el holder vivo más barato distinto del caído. Si todas las capas tienen sustituto, el sustituto ocupa la posición del caído. Solo se colapsan duplicados consecutivos: si el sustituto ya estaba más adelante en la ruta y además es dueño de otro tramo, reaparece al final para no perder cobertura.
  4. Si falta alguna capa: - fallback_model no nulo → ruta degradada con los supervivientes (degraded=True). - fallback_model=None → RoutingFailure (excepción controlada).

7. Telemetría

NodeState se puede construir desde diccionarios (NodeState.from_dict) para que el router funcione con respuestas de health llegadas por red:

{
  "node_id": "mini",
  "latency_ms": 6.4,
  "current_load": 0.2,
  "capacity_score": 13.0,
  "served_layers": [[7, 9]],
  "alive": true
}

WorkerState::health() devuelve además served_requests, failed_requests, load y latency_ms, que son útiles para diagnóstico pero no entran en el coste. El load del plano de datos llega por la RPC Health y se agrega en el nodo.

8. Ajuste de pesos

use mesh_router::{RoutingRequest, RouteWeights, score_route};

// Prioriza latencia sobre capacidad (LAN rápida, nodos pequeños)
let rapido = RouteWeights {
    latency: 2.0,
    load: 1.5,
    capacity: 0.1,
    hop: 0.05,
    prompt: 0.2,
};
let ruta = score_route(RoutingRequest::default(), &estados, rapido, "ncm")?;
println!("{} saltos, coste {:.1}", ruta.hops(), ruta.cost);

Recomendaciones:

  • Ethernet 1 GbE: subir hop a 0.3-0.5 si el pipeline tiene 3+ nodos.
  • Nodos muy heterogéneos: subir capacity para no cargar el mini-PC.
  • Carga variable: subir load para evitar el nodo caliente antes de que se sature.
  • Plano de datos remoto: el salto worker → DataPlane por gRPC no se modela como hop; entra en latency porque el nodo lo mide con su propio EWMA.

9. Lo que el router no hace

  • No reserva capacidad ni impone cuotas: dos peticiones pueden elegir el mismo nodo.
  • No mide la red en tiempo real: la latencia llega del EWMA del worker.
  • No reintenta: si el nodo elegido falla durante la petición, el orquestador recompone el pipeline para las siguientes.