Multi-node inference optimization#
2026-08-21
9 min read time
This page is the reference for running Hyperloom across a multi-node GPU cluster
(--nodes >= 2). It covers both supported backends, the cluster hand-off
contract, pod image requirements, the shared filesystem requirement, the workload
skill variables, and common failure patterns.
For the corresponding single-node flow, see Run a Hyperloom optimization.
Backends#
Hyperloom supports two multi-node control planes. Both serve the same model and differ only in how Hyperloom restarts the inference server each optimization round.
Backend |
Control plane |
Notes |
|---|---|---|
|
SSH |
Hyperloom SSHes into each pod to launch or restart SGLang. Supports prefill/decode (PD) disaggregation. |
|
Ray Dashboard REST API |
Pods form a KubeRay cluster. Restarts go through the Ray Dashboard; no SSH required. |
Cluster hand-off#
Hyperloom never provisions or releases a cluster. The provisioning platform
(typically Primus-Claw) creates the GPU
pods and hands the running cluster over through HYPERLOOM_MN_EXT_* environment
variables. Hyperloom adopts it, benchmarks it, and restarts the server on it each
round.
Without a hand-off, a --nodes >= 2 run exits immediately with code 2.
The following table lists all hand-off variables.
Variable |
Backend |
Required |
Meaning |
|---|---|---|---|
|
both |
yes |
Benchmark frontend URL ( |
|
infera |
yes |
Path to a private key authorized on the pods. |
|
infera |
PD only |
Prefill pod IPs, comma-separated, in rank order. |
|
infera |
PD only |
Decode pod IPs, comma-separated, in rank order. |
|
infera |
aggregated |
Worker pod IPs, comma-separated, leader first. At least one of |
|
infera |
no |
SSH base port (default |
|
infera |
no |
|
|
rayjob |
no |
Ray head IP — Dashboard on |
|
rayjob |
no |
Ray Dashboard auth token, if the dashboard requires authentication. |
The container image and per-pod CPU or memory are the provisioning platform’s
inputs, not optimize flags.
Pod image requirements#
Neither backend runs the inference engine at pod start. Pods come up idle and Hyperloom launches or relaunches the server each round.
infera#
The image must provide an SSH control plane so Hyperloom can reach the pods:
openssh-serverinstalled, with/run/sshdpresent and host keys baked in at build time (ssh-keygen -A) so sshd starts without write access at runtime.An entrypoint that starts sshd and then blocks. The platform injects the authorized public key as
MN_SSH_AUTHORIZED_KEYand the port asMN_SSH_PORT.
mkdir -p /root/.ssh /run/sshd && chmod 700 /root/.ssh
printf '%s\n' "$MN_SSH_AUTHORIZED_KEY" > /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
ssh-keygen -A # no-op when host keys are baked in
printf 'Port %s\nPermitRootLogin prohibit-password\nPasswordAuthentication no\n' \
"${MN_SSH_PORT:-2233}" > /etc/ssh/sshd_config.d/mn.conf
/usr/sbin/sshd -e # detaches, so pid 1 must block below
exec tail -f /dev/null
A dedicated port rather than :22 is required: pods run with hostNetwork for
RDMA, where :22 collides with the node’s own sshd.
rayjob#
The image must have Ray installed and on PATH at the version the KubeRay
cluster expects. KubeRay runs ray start in the head and worker containers. Pin
Ray’s CLI dependency explicitly:
python -m pip install "ray[default]==2.44.1" "click==8.1.8"
Keep click below 8.2 regardless of what Ray’s declared dependency allows — Ray
2.44.1 accepts click>=7.0, but click 8.2 changed Sentinel and breaks Ray’s
CLI on import, preventing ray start from running.
The cluster idles through the RayJob submitter: the platform sets its entrypoint
to tail -f /dev/null so the cluster stays up between rounds.
Session output#
Each run produces a session directory under
$USER_DATA_PATH/<model_basename>/<UTC_timestamp>/. The key files are:
File |
Contents |
|---|---|
|
Live status: |
|
Session manifest; required for |
|
Final downstream contract; written at CLOSE |
$USER_DATA_PATH is platform-injected. See
session_breakdown.json integration in Hyperloom for the
full schema.
Workload skill reference#
The pinned workload skill at
docs/how-to/multi-node/hyperloom-remote-mn-qwen3-30b/SKILL.md
contains two ready-to-run blocks — Workload A (infera + PD) and Workload B
(rayjob) — each a FLAGS list and an Environment block. The agent runs
inference_optimizer optimize with those blocks and monitors state.json until a
terminal stop_reason.
optimize flags#
The following flags are used in multi-node workload skill blocks.
Flag |
Meaning |
|---|---|
|
Model path under |
|
Node count; must be |
|
|
|
GPUs per pod (default 8); feeds the TP feasibility check |
|
Tensor parallelism degree |
|
Expert parallelism degree (MoE; often |
|
Benchmark input length, output length, and concurrency. These must be flags — the corresponding env vars are ignored and overwritten |
|
Target GPU (for example |
|
|
|
Optimization goal and time budget |
|
Enable PD disaggregation (infera only) |
|
Prefill topology (infera PD) |
|
Decode topology (infera PD) |
|
KV transfer plane for PD. Use |
|
Extra sglang args applied on every server restart |
|
Skip the framework-tuning agent phase |
Environment variables#
The following variables are exported on the sandbox side before calling optimize.
Variable |
Meaning |
|---|---|
|
Sequence-length jitter for the benchmark |
|
InferenceX checkout path under |
|
Magpie checkout path under |
|
TraceLens checkout path under |
|
PD bootstrap and wait timeouts (Workload A only) |
|
Kernel-Forge checkout for the Forge kernel backend (Workload B only) |
Do not set HYPERLOOM_MN_EXT_* variables or USER_DATA_PATH — the platform
injects those.
Example launch command#
The following example shows an infera + PD disaggregated launch after the platform has handed a cluster over:
inference_optimizer optimize \
--model ${NFS_SHARED_ROOT}/models/Qwen3-30B-A3B \
--nodes 2 --mn-backend infera \
--pd-mode disaggregated \
--pd-prefill-nodes 1 --pd-decode-nodes 1 \
--tp 8 --ep 8 \
--pd-transfer-backend mooncake ...
For rayjob, replace --mn-backend infera with --mn-backend rayjob and omit
the --pd-* flags. The platform supplies HYPERLOOM_MN_EXT_HEAD_IP instead of
the SSH and IP variables.
Troubleshooting#
Run exits immediately with code 2#
Cause: No cluster hand-off. HYPERLOOM_MN_EXT_SERVICE_URL is unset or the
infera backend is missing its required SSH key and IP list.
Fix: Confirm the platform has provisioned a cluster and injected the
HYPERLOOM_MN_EXT_* variables before calling optimize. Check that
HYPERLOOM_MN_EXT_SSH_KEY is set and that at least one of
HYPERLOOM_MN_EXT_PREFILL_IPS, HYPERLOOM_MN_EXT_DECODE_IPS, or
HYPERLOOM_MN_EXT_WORKER_IPS is populated for infera.
PD run produces 0 output tokens#
Cause: --pd-transfer-backend nixl was used. nixl can yield zero output
tokens on some configurations.
Fix: Switch to --pd-transfer-backend mooncake.
Ray --num-gpus rejected (rayjob)#
Symptom: ray start --head --num-gpus=N fails with Error: no such option: --num-gpus or Click-related import errors.
Cause: Click ≥ 8.2 is incompatible with Ray 2.44. The rayjob image was built with a newer Click version.
Fix:
pip install --quiet 'click<8.3.0' 'ray[default]==2.44.1'
ray --version
Ray tasks stuck pending, GPU usage 0% (rayjob)#
Cause: Ray was started with --num-gpus=0 or the flag was omitted. Tasks
submitted with num_gpus>=1 queue indefinitely.
Fix:
RAY_NUM_GPUS="$(python3 -c 'import torch; print(torch.cuda.device_count() or 1)')"
ray stop --force || true
ulimit -Sn 65536 2>/dev/null || true
ray start --head --disable-usage-stats --num-gpus="$RAY_NUM_GPUS" --include-dashboard=false
ray status
If a stale Ray head is already running, stop it first so Hyperloom can create a fresh head with the correct GPU and resource configuration.
Raylet zombie or SIGABRT (rayjob)#
Symptom: The raylet aborts on startup, or ray stop leaves zombie raylet
processes.
Cause: The container’s ulimit -n (open files) is too low. On a large node
with many workers, the raylet needs nofile >= 65536.
Fix: Launch the container with a high open-files hard limit. This cannot be raised from inside the container:
docker run --ulimit nofile=1048576 ...
The runtime raises the soft limit automatically before ray start; the hard cap
must be set at container launch time.
Resume fails after a crash#
Symptom: --resume-from exits with manifest.json not found or
state.json missing.
Cause: USER_DATA_PATH differs from the original session, or the session
failed before writing manifest.json.
Fix:
# Locate the session directory
find "$USER_DATA_PATH" -name manifest.json
# Resume with the exact session directory
python3 -m hyperloom.inference_optimizer.cli optimize \
--resume-from /path/to/session
If manifest.json never existed, resume is not possible — restart with a fresh
launch.
For issues not covered here, see Troubleshooting Hyperloom.