CollectiveX is an experimental MoE expert-parallel communication benchmark. It measures dispatch,
combine and paired roundtrip latency across EP libraries and accelerator systems, and writes one
result file per case into results/ on the cluster that ran it. A second suite, the
vLLM swap_blocks benchmark, measures pinned CPU↔GPU and same-GPU block
copies through the same matrix and pool launchers.
CollectiveX runs through the CollectiveX Sweep workflow or by hand on each pool (see Running By Hand). Uploading the results to the CollectiveX database is the publish step: the dashboard shows the latest stored run. Results are not committed to this repository. CollectiveX does not rank, recommend, or select; any downstream display or comparison is the consumer's responsibility. docs/methodology.md states the measurement rules in a summary, including each backend's engine contract, and then defines each published field.
| Systems | EP8 | EP16 |
|---|---|---|
| H100/H200/B200/B300 | 1x8 NVLink, scale-up | 2x8 NVLink + RDMA, scale-out |
| MI300X/MI325X/MI355X | 1x8 XGMI, scale-up | 2x8 XGMI + RDMA, scale-out |
| GB200/GB300 | 2x4 MNNVL, scale-up | 4x4 MNNVL, scale-up |
Physical host count does not determine scope: both GB topologies stay inside one 72-GPU MNNVL
scale-up domain. configs/platform_config.json registers which backend runs where. normal mode
runs the decode and prefill ladders; low-latency runs decode, plus prefill for NCCL EP.
| Backend | Engine selector (maturity) |
normal |
low-latency |
|---|---|---|---|
deepep-v2 |
vLLM --all2all-backend deepep_v2, SGLang --moe-a2a-backend deepep (production) |
EP8, EP16: H100, H200, B200, B300, GB200, GB300 | EP8: H100. EP8, EP16: H200, B200, GB200, GB300 |
mori |
vLLM --all2all-backend mori_*, SGLang --moe-a2a-backend mori (production) |
EP8, EP16: MI300X, MI325X, MI355X | EP16: MI300X, MI325X, MI355X |
uccl-ep |
none; engines load it as a drop-in deep_ep under vLLM's DeepEP backends (candidate) |
EP8: H100, H200, B200, MI300X, MI325X, MI355X. EP16: H100, H200, B200, MI300X, MI325X | EP8: B200. EP8, EP16: H100, H200. EP16: MI300X, MI325X |
nccl-ep |
TensorRT-LLM NcclEP (production for low-latency rank-major BF16 where it matches, candidate otherwise) |
EP8, EP16: H100, H200, B200, B300, GB200, GB300 | EP8: B300. EP8, EP16: H100, H200, B200, GB200, GB300 |
flashinfer-ep |
vLLM --all2all-backend flashinfer_nvlink_one_sided (production) |
EP8: H100, H200, B200, B300. EP8, EP16: GB200, GB300 | none |
Every backend runs a BF16 dispatch control and an FP8 dispatch; nccl-ep's FP8 is its internal DS_FP8E3M4 recipe, low-latency prefill and decode. Combine is always BF16. Coverage is uniform routing only.
Eleven of the registered EP cases are unmeasured in the published run: MI355X MoRI EP16, H100 EP16 prefill for DeepEP V2 and NCCL EP low-latency, and MI325X UCCL-EP low-latency EP16 FP8. docs/methodology.md gives each reason.
results/cases.json is the committed copy of the full matrix: 282 requested cases, of which 254 are
runnable EP cases, 18 are swap-blocks cases (two per pool) and 10 are unsupported EP cells (UCCL-EP
EP16 on MI355X and FlashInfer EP16 on H100, H200, B200 and B300, once per phase; FlashInfer's MNNVL
workspace needs every rank inside one NVLink domain).
A cell is listed as unsupported only when its backend runs on that SKU at another EP size, and
low-latency adds only runnable cells, so a missing low-latency cell (DeepEP V2 on B300, for example)
appears nowhere; docs/methodology.md gives each reason. The NCCL EP
contract, row identity and wheel pin are in docs/nccl-ep.md.
Dispatch the CollectiveX Sweep workflow, choosing a pool (or all) and the same filters run.sh
takes. Each shard becomes one job in the InferenceX CI priority queue, scored through
inferencex-e2e/configs/ci-priority.yaml, where collectivex ranks above every engine
benchmark except a model that file boosts further (currently kimik3.1). The job lands on the pool's runner (runner_label and runner_partition in
configs/platform_config.json), runs run.sh --shard and uploads results/<sku>/ as an artifact
to publish as below. Hand runs bypass the queue and wait for whatever CI holds the nodes.
Run from the pool's Slurm login node, in a checkout of the commit you want to measure:
collectivex/run.sh h200-dgxc --list-shards # what will run, one allocation per shard
setsid nohup collectivex/run.sh h200-dgxc > run-h200.log 2>&1 < /dev/null &
collectivex/run.sh h200-dgxc --shard h200-dgxc-nccl-ep-bf16-n2 # rerun one failed shardrun.sh resolves the pool's cases with sweep_matrix.py (--suite, --backend, --ep,
--modes and --precisions narrow it, and --swap-profile picks the swap-blocks grid), gives
each shard (one backend, mode, precision and node count) its own allocation through the pool's
launcher, and copies every result to results/<sku>/<case_id>.json, replacing the previous result
for that case. A failed shard does not stop the others; the closing summary lists each shard's exit
code and runs summarize.py over the pool's results. Interrupting run.sh releases its
allocations. Runtime logs stay under /tmp/inferencex-collectivex-<uid>/logs/<execution>/ on the
login node.
h200-dgxc, b200-nscale and gb200 run from the registry alone. h100-dgxc, b300, gb300 and the AMD pools also need an operator document that names a stage directory (see Runner Configuration).
These variables change what a row measures. The result records each one (in kernel_generation,
implementation.fp8_consume, implementation.rank_alignment or implementation.library_config),
but the case_id does not, so a run with one replaces the case's stored default-configuration
result:
| Variable | Default | Effect |
|---|---|---|
COLLX_CUDA_GRAPH=0 |
1 |
times graph-capable decode cells eager (-eager-spin-ar) |
CX_FP8_CONSUME=dequant |
native |
stages every FP8 pair inside the chain, a verification hatch |
COLLX_NCCL_LL_LAYOUT=expert-major |
rank-major |
runs nccl-ep low-latency on its expert-major layout |
COLLX_NCCL_LL_ZERO_COPY=0 |
1 |
stages nccl-ep low-latency dispatch, TensorRT-LLM's graphed path before #20631 (drops -zc; a library row) |
COLLX_ALIGN_SPIN_US |
2000 |
the per-sample rank-alignment spin |
COLLX_TIME (allocation minutes: 300 per EP shard and 45 per swap-blocks shard unless set),
COLLX_RUN_TIMEOUT (the per-case hang guard, 5400 s), COLLX_IMAGE_REFRESH=1 (re-import the
image) and COLLX_ALLOCATION=sbatch change only how a shard runs. COLLX_ALLOCATION=sbatch holds
the nodes with a batch job that only sleeps instead of salloc. It is for a pool whose controller
kills interactive allocations about ten seconds after granting them while batch jobs run; every
later step already goes through srun --jobid.
Results record the commit they were measured at, so run.sh refuses to start while tracked files
under collectivex/ (outside results/) have uncommitted edits. --allow-dirty runs anyway and
marks the results source_dirty, so they cannot be published. results/<sku>/ is gitignored. To
publish, copy the pool's results/<sku>/ and results/cases.json off the cluster and, from an
InferenceX-app checkout with DATABASE_COLLECTIVEX_WRITE_URL set, run
bun run admin:db:ingest:collectivex --dir <copy>/results. It lays the uploaded files over the
latest stored run, replacing those cases, and stores the result as a new run. It rejects a file that
is not a runnable case in cases.json, was measured from uncommitted code, or contains an IP
address or host-identifying field, and every uploaded file must share one source SHA.
Every PR that changes what CollectiveX measures appends one entry (or one per scope) to the end of
perf-changelog.yaml: the date, the PR link, the pools it affects and,
optionally, the suites, backends, modes, phases, EP sizes and precisions that narrow it, with a
description. python3 collectivex/changelog.py validate checks the file, and
CI also checks that existing entries are unchanged and that new entries link the PR adding them,
name registry pools and select runnable cases with every selector value.
An upload names the merged PRs it publishes: admin:db:ingest:collectivex --dir <copy>/results --pr <n>. The database stores those entries against the new run, so each run records the changes it
measures and each change the runs that measured it, with the before and after results of every case
it selects.
results/cases.json stays committed; a unit test fails when it no longer matches the configs, so
regenerate it whenever they change:
python3 collectivex/sweep_matrix.py --suites ep,swap-blocks --cases-json collectivex/results/cases.jsonEach SKU's Slurm, storage, image and network values come from its entry in
configs/platform_config.json, which also records architecture and product, fixed placement,
launcher, runnable backend/EP pairs and the scale-out fabric identity (NIC and switch, so
same-GPU clusters on different fabrics are distinct entries). An optional operator document at
COLLECTIVEX_OPERATOR_CONFIG, or else ${XDG_CONFIG_HOME:-$HOME/.config}/inferencex/collectivex.json,
overrides the entry's operator block field by field:
{"runners": {"b300": {"storage_roots": ["/data/home/<you>"]}}}It accepts partition, account, qos, squash_dir, stage_dir, storage_roots,
enroot_cache_path, exclude_nodes, nodelist, lock_dir and the network fields below. An
unknown field, a SKU the registry lacks and non-JSON input fail closed, and no value is ever
evaluated as shell. Duplicate JSON keys are not rejected (json.load keeps the last), and entries
for other SKUs are not validated, so a misspelled SKU is silently ignored. The registry's network
block is applied after the document and wins for every key it sets.
stage_dir must be visible at the same path on the login node and every allocated node. Each shard
copies collectivex/ into a mode-0700 job_<execution> child there and removes it once its
allocation is confirmed released. Without one, the stage goes beside the image under squash_dir,
which h200-dgxc and b200-nscale do; h100-dgxc, b300, both GB pools and the AMD pools require one,
and gb200's registry storage_roots supplies it.
storage_roots derives both stage_dir and squash_dir (<root>/collectivex/stage and
<root>/collectivex/containers, from the first root where both can be created) after the document
is merged in, so on gb200, whose registry entry carries storage_roots, a lone stage_dir in the
document is overridden: give the document its own storage_roots there. A run under an account
other than the one that owns the registry's squash_dir (a CI account's home on h100-dgxc and b300)
needs its own squash_dir or storage_roots as well: each shard creates its backend cache under
squash_dir, and an image squash another account owns is reused only while it is current.
Every non-MNNVL EP16 placement requires rdma_devices, the operator-approved scale-out devices.
The other network fields are socket_ifname, ib_gid_index, rdma_service_level,
rdma_traffic_class, rail_isolated, rdma_fabric, rdma_relaxed_ordering, rdma_gbs,
uccl_rdma_recv_tokens, host_verbs_provider, deepep_efa_fork and single_node_rdma_devices. Without socket_ifname each node's default-route interface carries the
cross-node sockets (h100-dgxc, h200-dgxc, b300). HCAs are never guessed, and every scale-out node must
show its socket interface up and an active port on each listed device before backend setup; the
methodology's Execution Isolation says what each field
drives.
Enroot imports the image once per pool (once per node where the squash dir is node-local, as on
mi355x) into a squash keyed by image tag and platform; the tag's
manifest digest in its container registry, resolved at launch, decides when it is stale, and
COLLX_IMAGE_REFRESH=1 forces a re-import. An image owned by another account is reused when
readable and current, but never replaced or deleted; the launcher stops with an error instead.
Backends the image does not ship (DeepEP V2 and UCCL-EP, built at their source pins in
runtime/common.sh, the nccl-extensions wheels, FlashInfer at vLLM's pin, and aws-ofi-nccl
where an EFA host needs it)
are cached in a cluster-local backend cache that reaches the container only as /cx-cache.
python3 -m unittest discover collectivex/tests -p 'test_*.py'
python3 collectivex/sweep_matrix.py --backend all --out /tmp/cx-matrix.json >/dev/null
for f in collectivex/run.sh collectivex/runtime/*.sh collectivex/launchers/*.sh; do bash -n "$f"; done
collectivex/run.sh h200-dgxc --dry-run # each shard's environment, without allocatingCore paths are run.sh, configs/, sweep_matrix.py, bench/, runtime/, launchers/,
results/cases.json and tests/. summarize.py and bandwidth.py read results and gate nothing.