El maître que solo te sienta si cabéis en una mesa: CPU, Memory y Topology Manager en RKE2

Cierre de la serie “por debajo del motor”. Vimos el cable entre GPUs y el host: NUMA, hugepages y aislamiento de CPU hecho a mano. Aquí está la pieza que lo hace declarativo y a escala: cómo el kubelet de RKE2 pinnea cada pod de vLLM al NUMA node correcto sin un solo script.

TL;DR

Pinnear NUMA con numactl/isolcpus/taskset —lo del post anterior— no escala a un cluster donde los pods nacen y mueren y hay decenas de nodos. El kubelet lo automatiza con tres componentes que funcionan como Hint Providers de un coordinador central, el Topology Manager: el CPU Manager (asigna CPUs exclusivas a contenedores de pods Guaranteed con CPU entera), el Memory Manager (memoria y hugepages NUMA-local) y el Device Manager/plugin de GPU (sabe qué GPU está en qué NUMA node). Con la política single-numa-node, el Topology Manager solo admite el pod si sus CPUs, su memoria y su GPU caben en el mismo dominio NUMA; si no caben, rechaza el pod —admisión estricta, como el maître que no sienta a un grupo de ocho si no hay mesa de ocho. En RKE2 todo esto se configura con kubelet-arg en /etc/rancher/rke2/config.yaml. Este post explica el mecanismo, da los 10 knobs y desmonta los gotchas que rompen el pinning en silencio: el fichero cpu_manager_state que hay que borrar al cambiar de política, la QoS que tiene que ser exactamente Guaranteed, y el reserved-cpus que debe casar con el isolcpus del host. Sobre un cluster genérico RKE2 con nodos 4×H100 SXM.

Dónde estás: la orquestación que materializa el host

El stack vertical · estás en la orquestaciónMotor · pod vLLM (TP, batching)ESTÁS AQUÍ · kubelet: CPU/Memory/Topology Mgrpinning declarativo + admisión NUMAHost · NUMA, hugepages, isolcpus (post 2)CUDA + NCCL + NVLink (post 1)Hardware · 2 sockets, 4×H100 SXMel kubelet traduce intención declarativa en el pinning crudo de abajo

La analogía: el maître de un restaurante con mesas que no se juntan

Un restaurante tiene mesas de distintos tamaños, y —regla de la casa— las mesas no se juntan. Llega un grupo de ocho. El maître mira si hay una sola mesa donde quepan los ocho. Si la hay, los sienta; si solo quedan mesas de cuatro, no los acepta —prefiere rechazar la reserva a sentar al grupo partido en dos mesas separadas, porque sabe que la cena partida va mal.

Ese maître es el Topology Manager en política single-numa-node. El “grupo” es un pod de inferencia que pide CPUs, memoria y una GPU. La “mesa” es un NUMA node. El maître pregunta a tres ayudantes —¿hay CPUs libres en algún node? (CPU Manager), ¿hay memoria libre? (Memory Manager), ¿hay GPU libre? (Device Manager)— y solo admite el pod si los tres recursos caben en el mismo node. Si no, lo rechaza (el pod queda en Failed con TopologyAffinityError), y el scheduler probará otro nodo.

La diferencia con el post anterior: allí eras el maître, sentando a mano a cada proceso con numactl. Aquí el maître es el kubelet, y lo hace para cada pod, en cada nodo, automáticamente, y rechazando lo que no cabe. Eso es lo que convierte el pinning artesanal en una propiedad declarativa del cluster.

El mecanismo: Hint Providers y el coordinador

El Topology Manager no asigna recursos; coordina a los que sí lo hacen. El flujo, cuando un pod Guaranteed llega a un nodo:

Admisión de un pod Guaranteed · single-numa-nodeTopology Managercoordinador · admite/rechazaCPU Managerstatic: CPUs exclusivashint: ¿cores en node X?Memory ManagerStatic: cpuset.memshint: ¿RAM/hugepages?Device Managerplugin GPU NVIDIAhint: ¿GPU en node X?

hint NUMAhint NUMAhint NUMA

¿Los tres hints coinciden en 1 node?SÍ → admiteNO → rechaza

Los tres managers son Hint Providers: cada uno le dice al Topology Manager en qué NUMA node(s) podría satisfacer su parte. El Topology Manager calcula la intersección y, según la política, decide:

  • none (default): no coordina; cada manager hace lo suyo sin alinear. Sin garantía NUMA.
  • best-effort: intenta alinear en un node; si no puede, admite igual (en el node que sea). Mejor que nada, sin garantía.
  • restricted: si no logra alinear, rechaza el pod. Estricto, pero permite afinidad multi-node si la intersección lo da.
  • single-numa-node: exige que todo quepa en un único NUMA node, o rechaza. El más estricto y el que de verdad garantiza la localidad del post anterior.

Y dos precondiciones sin las cuales nada de esto se activa:

  1. El pod tiene que ser QoS Guaranteed: requests == limits en CPU y memoria, y CPU entera (no 500m). Solo así el CPU Manager asigna CPUs exclusivas.
  2. El CPU Manager tiene que estar en política static (no none).

Sin esas dos, el Topology Manager no tiene nada que alinear y el pinning no ocurre —aunque la política esté puesta. Es el gotcha nº1.

Cómo se configura en RKE2

RKE2 pasa argumentos al kubelet con la clave kubelet-arg en /etc/rancher/rke2/config.yaml. La configuración de referencia para nodos GPU de inferencia:

# /etc/rancher/rke2/config.yaml  (en cada nodo agent con GPUs)
kubelet-arg:
  - "cpu-manager-policy=static"
  - "topology-manager-policy=single-numa-node"
  - "topology-manager-scope=pod"
  - "memory-manager-policy=Static"
  - "reserved-cpus=0-1,64-65"            # housekeeping; debe casar con el host
  - "system-reserved=memory=8Gi"
  - "kube-reserved=memory=4Gi"
  - "reserved-memory=0:memory=4Gi;1:memory=4Gi"   # requerido por Memory Manager Static
node-label:
  - "fibercli.local/numa-pinned=true"

Tras desplegarlo: systemctl restart rke2-agent. Gotcha crítico: si el nodo ya corrió con cpu-manager-policy=none, hay un fichero de estado /var/lib/kubelet/cpu_manager_state que fija la política antigua; cambiar el arg sin borrar ese fichero hace que el kubelet falle al arrancar o ignore la nueva política. Hay que: parar el agent, rm /var/lib/kubelet/cpu_manager_state, arrancar. (Lo mismo aplica a memory_manager_state).

Y el pod de vLLM, para ser elegible, Guaranteed con CPU entera:

resources:
  requests:
    cpu: "16"            # entero, no "16000m" fraccionado raro
    memory: "200Gi"
    nvidia.com/gpu: "2"  # TP=2 → 2 GPUs del mismo NUMA node
    hugepages-1Gi: "16Gi"
  limits:
    cpu: "16"            # == requests  → QoS Guaranteed
    memory: "200Gi"
    nvidia.com/gpu: "2"
    hugepages-1Gi: "16Gi"

Con esto, en un nodo con la config de arriba, el kubelet asigna 16 CPUs exclusivas del NUMA node donde están las 2 GPUs pedidas, su memoria local y las hugepages —o rechaza el pod si no caben juntas. El pinning artesanal del post anterior, ahora declarativo.

Los 10 knobs donde tocar

Ordenados por dependencia (los primeros son precondición de los siguientes). La referencia canónica es la doc del Topology Manager de Kubernetes y la config de RKE2.

Knob 1 — cpu-manager-policy=static: el cimiento

Sin esto, no hay CPUs exclusivas y nada de lo demás se activa.

kubelet-arg: [ "cpu-manager-policy=static" ]

Gotcha: cambiarlo requiere borrar /var/lib/kubelet/cpu_manager_state y reiniciar el kubelet, o el arranque falla. Es la causa nº1 de “puse la política y no pinnea”.

Knob 2 — QoS Guaranteed + CPU entera: la precondición del pod

No es config de nodo, es del pod, pero sin ella el knob 1 no hace nada para ese pod. requests == limits en CPU y memoria, y CPU entera. Un cpu: 500m o un requests != limits degrada el pod a Burstable y pierde el pinning. Mucha gente pone la política de nodo y olvida la QoS del pod.

Knob 3 — topology-manager-policy=single-numa-node: admisión estricta

kubelet-arg: [ "topology-manager-policy=single-numa-node" ]

El maître estricto. Para inferencia con GPU, es la política correcta: garantiza que CPU+memoria+GPU comparten node. best-effort no garantiza (admite desalineado); restricted permite afinidad multi-node. Empieza por single-numa-node y baja a restricted solo si tienes problemas de admisión.

Knob 4 — topology-manager-scope=pod: agrupar el pod entero

kubelet-arg: [ "topology-manager-scope=pod" ]

Con scope container (default), cada contenedor se alinea por separado; con scope pod, todo el pod va al mismo node. Para un pod de vLLM con sidecars (métricas, proxy), scope pod evita que el sidecar arrastre el contenedor principal a otro node. Recomendado para inferencia.

Knob 5 — memory-manager-policy=Static + reserved-memory: memoria NUMA-local

kubelet-arg:
  - "memory-manager-policy=Static"
  - "reserved-memory=0:memory=4Gi;1:memory=4Gi"

El Memory Manager Static fuerza cpuset.mems para que la memoria del pod salga del node correcto (y las hugepages). Requiere declarar reserved-memory por node, o el kubelet no arranca. Es el equivalente declarativo del --membind del post anterior.

Knob 6 — reserved-cpus: los cores housekeeping (debe casar con isolcpus)

kubelet-arg: [ "reserved-cpus=0-1,64-65" ]

Reserva cores para el sistema y los daemons; el resto quedan para pods exclusivos. Clave de la serie: estos reserved-cpus deben ser los mismos cores que dejaste fuera de isolcpus en el host (post anterior). Si el host aísla 2-31 pero RKE2 reserva 0-3, hay un desajuste: cores aislados que el kubelet asigna a pods sin que estén realmente quietos. Coordina las dos capas.

Knob 7 — Plugin de GPU con topología NUMA (NVIDIA GPU Operator)

El Device Manager solo puede dar un hint NUMA correcto si el plugin de GPU expone en qué node está cada GPU. El NVIDIA device plugin / GPU Operator lo hace, pero hay que verificar que la información de topología llega (en algunas versiones requiere flags). Sin hint de GPU, el Topology Manager alinea CPU y memoria pero no la GPU —y la localidad GPU es justo la que más importa.

Knob 8 — hugepages como recurso del pod

resources:
  limits:
    hugepages-1Gi: "16Gi"   # el nodo debe tenerlas pre-reservadas (post 2, knob 4)

Las hugepages que reservaste en el arranque del host (post anterior) se piden como recurso. El Memory Manager las asigna NUMA-local. Si las pides sin haberlas reservado en el nodo, el pod no se programa.

Knob 9 — system-reserved / kube-reserved: no sobre-suscribir

kubelet-arg:
  - "system-reserved=cpu=500m,memory=8Gi"
  - "kube-reserved=cpu=500m,memory=4Gi"

Reserva recursos para el sistema y los componentes de K8s para que el nodo no se quede sin aire bajo carga. Mal calibrado, o el nodo se ahoga (poco reservado) o desperdicias capacidad (demasiado). Debe ser coherente con reserved-cpus.

Knob 10 — Labels + taints: que vLLM caiga aquí y lo demás no

# nodo GPU: taint para repeler lo que no necesita GPU
node-taint: [ "nvidia.com/gpu=present:NoSchedule" ]
node-label: [ "fibercli.local/numa-pinned=true" ]

Mantén los nodos NUMA-pinned para inferencia y echa de ahí lo que no la necesita (bases de datos, el backend de Langfuse, runners). Un ClickHouse robando ancho de banda de memoria a un pod de vLLM cuidadosamente pinneado tira por tierra todo el trabajo de los nueve knobs anteriores. El aislamiento de workloads es el cierre.

Tabla resumen

#KnobDóndeFunción
1cpu-manager-policy=statickubelet-argCPUs exclusivas (cimiento)
2QoS Guaranteed + CPU enterapod specprecondición del pinning
3topology-manager-policy=single-numa-nodekubelet-argadmisión estricta NUMA
4topology-manager-scope=podkubelet-argagrupar pod entero
5memory-manager-policy=Statickubelet-argmemoria/hugepages NUMA-local
6reserved-cpuskubelet-arghousekeeping (casar con isolcpus)
7plugin GPU con topologíaGPU Operatorhint NUMA de la GPU
8hugepages-1Gipod spechugepages como recurso
9system/kube-reservedkubelet-argno sobre-suscribir
10taints + labelsconfig nodoaislar workloads GPU

Verificar que el pinning de verdad ocurrió

No te fíes de que la config “esté puesta”. Comprueba:

# ¿La política activa es la que pusiste?
cat /var/lib/kubelet/cpu_manager_state | jq .policyName    # "static"

# ¿Qué CPUs exclusivas tiene el contenedor?
kubectl exec <pod> -- cat /sys/fs/cgroup/cpuset.cpus.effective

# Dentro del pod: ¿la GPU asignada es local a esos cores?
kubectl exec <pod> -- nvidia-smi topo -m

# ¿Hubo rechazos por topología?
kubectl describe pod <pod> | grep -i TopologyAffinityError

Un pod en Failed con TopologyAffinityError no es un bug: es el maître haciendo su trabajo —ese nodo no tenía una mesa donde cupieran CPU+memoria+GPU juntas. La respuesta es revisar el sizing del pod o del nodo, no relajar la política a la ligera.

Cómo se conecta con el resto del stack

Con el host (post anterior). Este post es la automatización declarativa de aquel. cpu-manager-policy=static materializa el taskset; memory-manager-policy=Static materializa el --membind; reserved-cpus debe casar con el isolcpus. Las dos capas son una sola decisión vista desde dos sitios: el host la ejecuta, el kubelet la declara. Descoordinarlas (isolcpus 2-31 vs reserved-cpus 0-3) rompe ambas.

Con el interconnect (post 1). El Topology Manager pinnea la GPU correcta al pod, pero si pides 2 GPUs para TP=2, querrás que esas dos compartan NVLink. La política NUMA garantiza que están en el mismo socket; que estén NVLink-conectadas lo garantiza el hardware del baseboard (post 1, knob 1). Las dos cosas juntas son lo que hace que TP=2 rinda.

Con el autoscaling. Cuando KEDA escala pods de vLLM, cada réplica nueva pasa por la admisión del Topology Manager. Si el nodo no tiene una “mesa” libre, el pod queda pendiente —el autoscaling de pods y el de nodos (cluster-autoscaler) tienen que contar con la granularidad NUMA, no solo con CPU/memoria agregada.

Con capacity planning. El dimensionado cambia: no es “128 vCPU por nodo”, es “128 menos los reserved-cpus, en bloques que quepan por NUMA node”. Un nodo de 2 sockets × 64 cores no sirve un pod que pida 80 cores en single-numa-node: no caben en una mesa. El planning tiene que razonar por node, no por nodo.

Con la convivencia de servicios. El taint del knob 10 es lo que mantiene a Langfuse, bases de datos y runners fuera de los nodos de inferencia. Sin esa frontera, todo el pinning fino se lo come un vecino ruidoso. La observabilidad va en sus nodos; la inferencia, pinneada, en los suyos.

Trampas y cosas que no son lo que parecen

Política puesta, QoS olvidada. El error más común: cpu-manager-policy=static en el nodo pero el pod es Burstable (requests != limits o CPU fraccionada). El pinning no ocurre y nadie avisa. La QoS Guaranteed con CPU entera es condición necesaria.

cpu_manager_state fosilizado. Cambiar de política sin borrar /var/lib/kubelet/cpu_manager_state (y memory_manager_state) hace que el kubelet falle o ignore el cambio. Parar agent → borrar fichero → arrancar.

reserved-cpusisolcpus. Si el host aísla unos cores y RKE2 reserva otros, los managers asignan a pods cores que no están realmente quietos, o dejan idle cores aislados. Las dos listas tienen que ser coherentes. Es el fallo de coordinación entre el post anterior y este.

Plugin de GPU sin topología NUMA. Si el device plugin no expone el NUMA node de cada GPU, el Topology Manager alinea CPU y memoria pero deja la GPU al azar —y la localidad de la GPU es la que más pesa. Verifica que el GPU Operator publica la topología.

single-numa-node que rechaza demasiado. Si los pods piden más recursos de los que caben en un node (p. ej. más cores que los de un socket), el rechazo es constante. La respuesta no es bajar a best-effort (que silencia el problema sirviendo desalineado), sino dimensionar el pod para que quepa en una mesa, o aceptar restricted con conocimiento de causa.

Creer que best-effort “es casi igual”. best-effort admite el pod aunque no logre alinear: te da la falsa sensación de NUMA-awareness mientras sirves desde el socket equivocado. Para inferencia con SLO de cola, single-numa-node o restricted; best-effort solo si la alternativa es no programar nada.

Conclusión

El post anterior pinneaba a mano; este lo hace a escala y con una garantía que el numactl artesanal no daba: admisión estricta. El kubelet, vía CPU Manager, Memory Manager y Topology Manager, actúa como un maître que solo sienta al pod si sus CPUs, su memoria y su GPU caben en la misma mesa NUMA, y que rechaza lo que no cabe en vez de servir una cena partida. De los diez knobs, los dos primeros —cpu-manager-policy=static y QoS Guaranteed con CPU entera— son la precondición sin la cual los otros ocho no hacen nada, y son justo los que más se olvidan; el resto afina la política, la memoria, las hugepages y la convivencia. El hilo que cierra la serie: el rendimiento de inferencia que parecía un problema del motor (vLLM lento) o del modelo (cuantización) es, demasiadas veces, un problema del cable (NVLink no usado), del host (NUMA remoto, jitter) o de la orquestación (pinning que no ocurrió porque la QoS estaba mal). Bajar de nivel no es esnobismo de infraestructura: es donde están las causas raíz que ningún dashboard de la capa de aplicación te va a señalar.

Ver también

Referencias