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:

  1. Cuota real de cada nodo: q_i = L \cdot \dfrac{s_i}{\sum_j s_j}.
  2. Asignación entera: a_i = \lfloor q_i \rfloor.
  3. 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).
  4. 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_owner cubre 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:

  1. 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.
  2. Candidatos: nodos ordenados por puntuación ascendente, excluyendo a los propietarios primarios de la cabeza.
  3. Número de réplicas: replica_count o \max(1, \lfloor N/3 \rfloor).
  4. 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] > 1 solo 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 en L, consulta qué nodo atiende L+1.
  • Los holders marcados alive=False se 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:

  1. Se conservan los nodos vivos de la ruta (route.nodes).
  2. 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.
  3. Se regeneran los fragmentos de cada superviviente (WorkerState::set_routing() con el plan nuevo), se reescribe la cadena y se sirve fallback_model (ncm-tiny por defecto). El plano de datos de cada nodo se reconfigura fuera de la ruta (Describe al arrancar), así que el cambio no cuesta una recarga en caliente.
  4. status()["state"] == "degraded" y status()["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 es capa → [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() falla failure_threshold veces.