Source document
Redundancia y reparto de capas
Spanish text, transcribed from the repository. The unformatted
source is
redundancy.md.
1. Puntuación de capacidad
Cada nodo declara cuatro magnitudes medibles: VRAM, RAM, NPU (TOPS) y latencia base. La puntuación combina las tres primeras con pesos normalizados y penaliza la latencia:
\operatorname{score}(n) = w_v \cdot \text{VRAM} + w_r \cdot \text{RAM} + w_n \cdot \text{NPU}
- w_\ell \cdot \frac{\text{latencia}}{100}
con w = (2.0,\ 0.5,\ 1.5,\ 0.25) normalizados para sumar 1. Si el nodo está cargado se aplica además un factor (1 - 0.5 \cdot \text{load}), de modo que un nodo al 100 % de carga conserva la mitad de atractivo.
Suelo: \operatorname{score} \ge 10^{-3} para evitar divisiones por cero en node_cost().
2. Reparto proporcional por resto mayor
Dadas N puntuaciones y L capas:
- Cuota real de cada nodo: q_i = L \cdot \dfrac{s_i}{\sum_j s_j}.
- Asignación entera: a_i = \lfloor q_i \rfloor.
- Sobran R = L - \sum_i a_i capas (0 \le R < N). Se reparten de una en una entre los nodos con mayor parte fraccionaria q_i - a_i; a igualdad, mayor puntuación; a igualdad, menor
node_id(determinismo total). min_layers_per_node: si algún nodo queda con menos capas de las permitidas, se las quita al nodo más cargado que aún pueda cederlas. Si no hay donante,ShardingError.
Propiedades garantizadas (verificadas en tests/test_scheduler.py):
- \sum_i a_i = L exactamente.
- Cada capa tiene un propietario primario:
plan.primary_ownercubre 0 \dots L-1. - El reparto es determinista: mismos nodos y pesos → mismo plan.
- Nodos con más puntuación reciben más capas.
3. Bloques contiguos y orden del pipeline
Las capas se entregan en bloques contiguos, ordenados por puntuación descendente:
score alto score bajo
[nodo A: 0-6][nodo B: 7-9][nodo C: 10-11]
Contigüidad = un solo salto de red por frontera; orden por capacidad = el nodo más potente recibe la cabeza (las capas más caras) y el más ligero cierra el pipeline.
ShardingPlan.nodes() devuelve el orden real del pipeline (por primera capa primaria) y next_hop(node) el siguiente nodo. El orquestador usa ambos para encadenar los workers.
4. Réplica de la cabeza
Motivo: las primeras capas reciben la entrada y son el punto de fallo más doloroso. Se replican en los nodos más ligeros que no las posean, porque son los que tienen margen de VRAM/RAM:
- Tamaño de cabeza: H = \max(1, \operatorname{round}(L \cdot \rho)), con \rho =
replicate_head_ratio(0.25 por defecto) y H \le L-1. - Candidatos: nodos ordenados por puntuación ascendente, excluyendo a los propietarios primarios de la cabeza.
- Número de réplicas:
replica_counto \max(1, \lfloor N/3 \rfloor). - Los rangos replicados se fusionan con los primarios del nodo mediante
merge_ranges()cuando son contiguos (una réplica de 0-2 en un nodo que ya tiene 3-5 se convierte en un único fragmento 0-5).
Ejemplo con 3 nodos, 24 capas y \rho = 0.25:
A (score 28): 0-11 ← propietario primario
B (score 13): 12-18
C (score 9): 19-23 + réplica 0-5
redundancy_map[0] = [A, C]
redundancy_map[6] = [A]
coverage() = {0..5: 2, resto: 1}
5. Invariantes del mapa de redundancia
plan.redundancy_map[layer] es una lista de nodos capaces de servir esa capa, con el propietario primario siempre en primera posición:
holders(layer)[0] == owner_of(layer).uncovered_layers() == []en cualquier plan válido.coverage()[layer] > 1solo en las capas replicadas (la cabeza, por defecto).layer_count(node)cuenta primarias y réplicas, porque es lo que el worker puede ejecutar de verdad.
6. Failover paso a paso
flowchart TD
F[Nodo X cae] --> L{¿Qué capas servía X en esta ruta?}
L --> R{¿Todas tienen holder vivo distinto de X?}
R -->|Sí| S[Elegir el holder más barato por capa]
S --> P[Empalmar en la posición de X y deduplicar]
P --> OK[Ruta nueva · degraded=False]
R -->|No| D{¿fallback_model permitido?}
D -->|Sí| G[Redistribuir capas entre supervivientes]
G --> M[Modelo ncm-tiny · degraded=True]
D -->|No| E[RoutingFailure controlada]
Detalles de implementación (failover()):
- Empalme posicional: el sustituto ocupa el hueco de X para no alterar el orden del pipeline.
- Deduplicación solo de duplicados consecutivos: si el sustituto ya estaba en la ruta y además es dueño de un tramo posterior, debe reaparecer al final para no perder cobertura. Ese caso se resuelve con una tabla de reenvío por capa en el worker (
set_routing_table): tras terminar el fragmento que acaba enL, consulta qué nodo atiendeL+1. - Los holders marcados
alive=Falsese descartan. - El coste de la ruta nueva se recalcula con
route_cost(); los nodos sin telemetría usan valores por defecto (DEFAULT_LATENCY_MS,DEFAULT_CAPACITY_SCORE).
7. Modo degradado
Cuando no hay réplica para todas las capas perdidas:
- Se conservan los nodos vivos de la ruta (
route.nodes). - El orquestador recalcula el plan sobre los supervivientes con
rebalance(), que mantiene el número total de capas y la proporción de cabeza del plan anterior. - Se regeneran los fragmentos de cada superviviente (
WorkerState::set_routing()con el plan nuevo), se reescribe la cadena y se sirvefallback_model(ncm-tinypor defecto). El plano de datos de cada nodo se reconfigura fuera de la ruta (Describeal arrancar), así que el cambio no cuesta una recarga en caliente. status()["state"] == "degraded"ystatus()["model"] == "ncm-tiny".
Si no queda ningún superviviente, status()["state"] == "failed" y forward() lanza ClusterError.
8. Rebalanceo al incorporar un nodo
Cluster::start() despliega el plan inicial; el crecimiento del clúster se hace con ClusterConfig::new(nodes, total_layers): se recalcula el plan sobre todos los nodos vivos, se redespliega (los workers existentes adoptan el plan nuevo con WorkerState::set_routing(), el nuevo arranca) y rewire() vuelve a elegir ruta. Si el plan no cabe (más nodos que capas con el mínimo configurado), se lanza ClusterError::Scheduler.
9. Límites del diseño
- La réplica cubre solo la cabeza por defecto. Para replicar tramos intermedios basta con extender
assign_layers(): el mapa ya escapa → [nodos], no está limitado a la cabeza.- No hay consenso: el orquestador es la única autoridad. Dos orquestadores sobre el mismo clúster producirían planes divergentes. - Cambiar un plan cambia pesos en esta implementación (se regeneran por semilla); en un despliegue con checkpoints reales equivaldría a recargar el fragmento, con el coste de E/S correspondiente.
- Un nodo lento no es un nodo caído: el router lo evita por coste, pero no se declara fallo hasta que
remote_health()fallafailure_thresholdveces.