Skip to content

[SM120] Add optional FlashInfer PCIe-IPC all-reduce for switch-free hosts - #34528

Open
AliceChenyy wants to merge 19 commits into
sgl-project:mainfrom
AliceChenyy:feat/flashinfer-pcie-ipc-ar
Open

AliceChenyy wants to merge 19 commits into
sgl-project:mainfrom
AliceChenyy:feat/flashinfer-pcie-ipc-ar

Conversation

@AliceChenyy

@AliceChenyy AliceChenyy commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Motivation

On switch-free intra-node hosts — no NVLink, no multicast, every peer transfer crossing the CPU root complex — none of SGLang's custom all-reduce backends apply. CustomAllreduce and QuickAllReduce assume NVLink/multicast, pymscclpp assumes its own fabric, so on these machines every per-layer reduction falls back to NCCL. NCCL's ring is bandwidth-optimal but latency-poor at decode message sizes, and it shows up directly in TPOT.

FlashInfer's pcie_ipc kernels (flashinfer-ai/flashinfer#4393) target exactly this fabric: naive all-to-all peer writes collapse here, so the kernels stage their pushes to keep one outbound and one inbound stream per rank, and the 8-rank path uses a 4+4 island decomposition so the scarce cross-socket link carries the minimum.

This affects the whole RTX PRO 6000 / RTX 6000D class, where SGLang is otherwise well supported.

Scope. This is a decode-latency path, not a general NCCL replacement. It is sized so
prefill chunks stay on NCCL (see Workspace sizing), because these kernels win by latency
at small messages and lose to a ring at 200 MB. On a host that also wants prefill improved,
this composes with a bandwidth-optimal backend rather than replacing it.

Modifications

Opt-in behind SGLANG_ENABLE_PCIE_IPC_ALLREDUCE (default off). +527/−0 across five files:

  • distributed/device_communicators/pcie_ipc_ar.py (new) — adapter between GroupCoordinator and PcieIpcAllReduceWorkspace.
  • distributed/parallel_state.py (+29) — construction, one "pcie_ipc" branch in _resolve_outplace_all_reduce_method, one dispatch arm. Built only on the TP group; other groups would just pin IPC buffers without issuing the reductions these kernels target.
  • environ.py (+11) — the gate plus SGLANG_PCIE_IPC_MAX_NUMEL.
  • model_executor/runner/base_runner.py (+9) — one prepare() call from warmup, before the FlashInfer autotune context opens. See Autotuning for why the timing is load-bearing.

Two design points worth review:

No size heuristic lives in SGLang. should_pcie_ipc_ar() delegates to workspace.supports(), which consults FlashInfer's own tuning table. A shape the kernels do not beat is reported unsupported and the caller keeps its NCCL path. This avoids a threshold knob that would need per-machine tuning and would drift from the kernel side. SGLang's job is to make sure that table is populated for this host and these shapes rather than left on the seed policy -- see Autotuning.

The workspace is built on the first eligible tensor, not at group construction. It cannot grow afterwards and costs ~2 * world_size * max_numel * itemsize per rank, so it must be sized for the largest reduction the model issues — one prefill chunk, chunked_prefill_size * hidden. chunked_prefill_size is in the server args but hidden is not known when the group is built, so construction is deferred to the first reduction, whose trailing dimension is exactly that. Ranks run the same reduction sequence, so they reach that call with the same shape and build the same workspace without extra exchange.

Sizing it for a prefill chunk instead is the wrong trade, which is worth
spelling out because an earlier revision of this PR did exactly that. These
kernels win by latency at small messages; a prefill chunk is three orders of
magnitude larger than a decode reduction, which is NCCL ring territory. Measured
at 8 ranks, TP8, 8k context:

workspace TTFT TPOT output tok/s
NCCL only 1910 ms 21.14 ms 35.02
prefill-sized (chunked_prefill_size * hidden) 3176 ms 13.64 ms 38.43
decode-sized (this PR) 1849 ms 13.62 ms 48.05

Routing prefill through the kernels costs 66% on TTFT and buys nothing on TPOT.
The decode-sized workspace is also ~250x smaller, which matters at long context:
at 128k with 4 concurrent requests the prefill-sized workspace regressed TPOT by
45% (122.33 ms against NCCL's 84.50 ms), and that regression disappears once it
is sized for decode (84.60 ms).

Autotuning

supports() answers from FlashInfer's tuning table, so what is in that table decides both which
shapes reach the kernels and which launch config they get. Left alone the table holds FlashInfer's
seed policy — a default, not a measurement of this host — so this wires the autotuner in: the
workspace is constructed with tune_batches bounded by what it will actually accept and with an
explicit tune_cache path, the coordinator's cpu_group is handed down as the autotuner's
rendezvous group, and tune() runs once when the workspace is built.

The timing is the load-bearing part. The workspace is built on the first eligible reduction,
and that reduction arrives inside SGLang's own FlashInfer autotune pass. FlashInfer declines to
profile a collective from an autotune context it did not open, so tune() returned having measured
nothing while still returning cleanly — zero Tuning flashinfer::pcie_ipc_all_reduce lines and no
cache written. prepare(hidden) is therefore called from the runner's warmup, just before that
context opens, which is where vLLM's port of these kernels does the same thing. _tune() refuses
and says so if it still finds itself nested, and it logs how many shapes tune() actually covered
rather than only that it returned: an autotune that measures nothing otherwise reads as a success,
which is exactly how two revisions of this change were misread.

Measured on 4x RTX PRO 6000 Blackwell SE (SM120, PCIe, no NVLink), TP4, DeepSeek-V4-Flash,
decode-only TPOT via a warm-prefix method, 2 repeats per cell. Baseline is this PR's own tree with
the backend off, so both columns share one NCCL path:

ISL conc FI AR off as first submitted with this fix
8k 1 9.53 ms 8.62 8.18
8k 8 14.14 14.64 12.75
8k 32 23.92 26.01 21.28
32k 1 9.10 8.36 8.25
32k 8 14.68 14.99 13.32
32k 32 27.75 29.89 24.93
128k 1 9.33 8.60 8.47
128k 8 13.70 13.99 12.34

As first submitted this backend regressed decode at concurrency >= 8 (+2% to +4%); with the
table populated it is -9% to -14% across every cell. GSM8K 100q: 0.970.

Where the win actually comes from, since it is not where I first assumed. Running the same
build with the tuning genuinely executed and with it silently skipped lands within 1% on all eight
cells. The measured tactics are worth almost nothing here; the gain is the bucket alignment that
tune_batches brings, which changes which entry each shape resolves to. FlashInfer's seed tactics
are close to optimal on this fabric — what was wrong was asking the table about buckets the
workspace would never serve. The tuning call is still worth keeping: it is what makes the table
this host's own rather than a default, and on a fabric whose seed policy fits less well it is the
part that adapts.

Two consequences worth knowing before deploying:

  • The cache key carries the PCIe topology (pcieswitch-pairs on this host, rootcplx-noswitch on
    another 8x SM120 box). Tuning tables do not transfer between machine types; a new host pays
    the measurement once, about 2 s for six shapes, and reuses it after that.
  • Autotuning replays kernels and cannot run under graph capture. If the first reduction still
    arrives there, the seed policy stays in force and a warning says so.

Accuracy

GSM8K, 200 questions, same server and args as the perf runs:

arm accuracy invalid
NCCL 0.960 0.000
PCIe-IPC, prefill-sized workspace 0.955 0.000
PCIe-IPC, decode-sized (this PR) 0.945 0.000

The 0.015 spread is 3 questions out of 200, within binomial noise at this sample
size (SE ~= 0.015). At the kernel level, 10 shapes from 1 to 16384 tokens at
world 8 / hidden 6144 / bf16 were compared elementwise against NCCL with 0
mismatches; the maximum absolute difference is a constant 2.5e-1, one bf16
quantization step at that magnitude, i.e. accumulation order rather than
corruption.

Benchmarking Results

One 8x RTX PRO 6000 Blackwell Server Edition host, GPUs 0-3 and 4-7 on separate
NUMA nodes, no NVLink. Same tree, same client, same server args; the arms differ
by one environment variable.

image       lmsysorg/sglang:nightly-dev-cu13-20260812-c7c03ec5
sglang      c54dc4582 + this branch
flashinfer  0.6.15.post1 + flashinfer#4393 @ 090e6466 (the PR was force-pushed on 08-12; this is the rebased equivalent of the 6573c65 the first numbers were taken on)
torch       2.13.0+cu130
sgl-kernel  0.4.6.post1
driver      595.58.03
server      --tp 8 --quantization modelopt_fp4 --kv-cache-dtype fp8_e4m3
            --chunked-prefill-size 16384 --mem-fraction-static 0.82
            --disable-radix-cache --disable-custom-all-reduce --cuda-graph-max-bs 64
client      bench_serving random, --random-range-ratio 1.0, --num-prompts 3x bs, OSL 256

Mean values. KV pool was 342912 tokens in every arm; #cached-token was 0 on
every prefill, so no TTFT is a prefix-cache artifact.

TTFT (ms)

ISL / batch NCCL this PR
8k, bs1 1909.68 1848.65
8k, bs4 6324.39 6209.99
128k, bs1 33384.58 33410.24
128k, bs4 111272.74 111421.98

TPOT (ms)

ISL / batch NCCL this PR
8k, bs1 21.14 13.62 (-36%)
8k, bs4 30.30 25.23 (-17%)
128k, bs1 21.51 14.11 (-34%)
128k, bs4 84.50 84.60

Output throughput (tok/s)

ISL / batch NCCL this PR
8k, bs1 35.02 48.05 (+37%)
8k, bs4 72.82 80.93 (+11%)
128k, bs1 6.58 6.92 (+5%)
128k, bs4 7.07 7.06

TPOT is where these kernels pay: -34% to -36% at bs1, and the ratio does not
depend on context length, since a decode reduction's size is set by the batch.
TTFT and the 128k bs4 cells are level with NCCL by construction -- those
reductions are above the workspace bound and stay on NCCL.

Before any of this was measured, all 56 ordered GPU pairs were verified to carry
data intact. A sister host with the same driver returned zeroed buffers on every
cross-device copy while nvidia-smi topo -p2p r reported all-OK; a bandwidth
test does not catch that.

Known limitations

  1. Requires a recent FlashInfer. [feat]custom all reduce kernel flashinfer-ai/flashinfer#4393 merged on 2026-08-20, so PcieIpcAllReduceWorkspace is available upstream and its signature is settled. The ImportError path degrades to NCCL with a warning, so builds without the module are unaffected.

  2. TP4 coverage arrived mid-review. Earlier revisions of this description said the
    kernels had no effect at world_size == 4: FlashInfer's policy table gated that branch on
    hidden == 4096 exactly, so GLM's 6144 was rejected on every shape. Upstream has since
    removed that constraint (remove hidden size constraint in flashinfer#4393, closing
    flashinfer#4463), and TP4×PP2 now
    works. Measured on the same host, against NCCL:

    ISL / batch TPOT output tok/s
    8k, bs1 20.55 → 18.49 (-10%) 37.19 → 40.28 (+8%)
    8k, bs4 31.74 → 30.24 (-5%) 87.34 → 90.26 (+3%)
    128k, bs1 20.80 → 18.79 (-10%) 11.08 → 11.33 (+2%)
    128k, bs4 120.92 → 121.26 14.10 → 14.08

    The gain is smaller than at 8 ranks (-10% against -36%), which is what the fabric predicts:
    at 4 ranks NCCL has half the peers to reach and a less degraded baseline to beat. Kernel
    numerics at world 4 / hidden 6144: 6 shapes, 0 skipped, 0 mismatches.

  3. Not the best option at every point. On the same host a bandwidth-optimal FP8-compressing ring beats these kernels on prefill TTFT (1381 ms vs 2239 at 8k bs1) while losing on decode. There is no single best all-reduce on this fabric, which is part of why this is opt-in rather than autodetected.

  4. Single host, single model, bf16 reductions only. No world-size-2 coverage, no unit test yet.

Checklist

  • Format with pre-commit run --all-files
  • Accuracy validation (GSM8K vs NCCL)
  • Upstream flashinfer#4393 merged (2026-08-20)
  • Unit test (test/registered/unit/distributed/test_pcie_ipc_ar.py, CPU-only)

CI States

Latest PR Test (Base): ❌ Run #34474387380
Latest PR Test (Extra): ❌ Run #34474387092
Latest PR Test (AMD ROCm 10): ❌ Run #34474387492

AliceChenyy and others added 7 commits August 11, 2026 22:58
On switch-free intra-node hosts -- no NVLink, no multicast, every peer
transfer crossing the CPU root complex -- the existing custom all-reduce
backends do not apply, so every per-layer reduction falls back to NCCL.
FlashInfer's pcie_ipc kernels target exactly this fabric.

Add an opt-in communicator behind SGLANG_ENABLE_PCIE_IPC_ALLREDUCE. It is
a thin adapter: shape coverage is delegated to FlashInfer's own tuning
table via workspace.supports(), so an unsupported shape is reported as
such and the caller keeps its NCCL path. No size heuristic lives here.

The workspace cannot grow after construction, so it is sized for the
largest reduction the model issues -- one prefill chunk. The hidden size
is unknown when the group is built, so construction is deferred to the
first eligible tensor; ranks issue the same reduction sequence and so
agree on the shape without extra exchange.

Measured on one 8x SM120 host (GPUs 0-3 and 4-7 on separate NUMA nodes),
GLM-5.2-NVFP4, TP8, 8k context, against NCCL: TPOT 40.16 -> 13.70 ms,
output throughput 27.31 -> 77.27 tok/s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PCIe-IPC path was resolved ahead of ca, so on a host that does have
NVLink -- where CustomAllreduce applies and is the faster of the two --
enabling the flag would have handed those reductions to the slower
backend. FlashInfer's topology probe only looks for switch-local peers;
it does not detect NVLink and so cannot decline on its own.

Order is the fix: ca answers first on the fabrics it supports, and the
PCIe-IPC kernels pick up only what it leaves, which is exactly the
switch-free case they were written for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workspace was sized for one prefill chunk so that prefill reductions
would also run on these kernels. Measured on 8x SM120, TP8, 8k context,
that is the wrong trade: against NCCL as the baseline, TTFT 1910 ms and
TPOT 21.14 ms, a prefill-sized workspace gives 3176 ms / 13.64 ms while a
decode-sized one gives 1849 ms / 13.62 ms. Handing prefill to the kernels
costs 66% on TTFT and buys nothing on TPOT -- these kernels win by latency
at small messages, and a prefill chunk is three orders of magnitude larger
than a decode reduction, which is NCCL ring territory.

The decode-sized workspace is also ~250x smaller, which matters at long
context: at 128k with 4 concurrent requests the prefill-sized workspace
regressed TPOT by 45% (122.33 ms against NCCL's 84.50 ms), and that
regression disappears once it is sized for decode (84.60 ms).

Derive the bound from the decode phase's captured batch. Verified end to
end: the default builds max_numel = 393216 on this config and reproduces
an explicit cap within 0.3% on every cell. GSM8K over the four runs spans
0.945-0.960, within binomial noise at 200 questions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every way this adapter declines a reduction is silent by design: an
unsupported world size, a missing flashinfer build, a workspace that
cannot be allocated, and a shape outside the tuning table all end in the
caller keeping its NCCL path. That is the right behaviour and the reason
it needs tests -- a regression there does not raise, it just quietly
stops using the kernels, which reads as "the kernels are slow".

CPU-only: the constructor is bypassed and the workspace stubbed, since
none of the logic under test needs a process group or a device.

Also pins the decode-sized default, so re-sizing the workspace for a
prefill chunk cannot come back unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They had landed after the Mori RDMA transfer timeout, which is an
unrelated block; the repo's env-var conventions ask for entries to sit
under the section they belong to. These two belong with
CustomAllReduceV2 and the deterministic all-reduce knobs.

Also drops "so there is no size knob here" from the enable flag's
comment, which read oddly directly above a size knob. The point it was
making -- that shape coverage is FlashInfer's decision and not something
this env var controls -- is now said plainly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops an unused import and reflows one assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ormandj

ormandj commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

TP2 integration result for DeepSeek-V4-Flash-0731 on 2× RTX PRO 6000 Blackwell Max-Q (SM120), connected over PCIe Gen4 x16.

The measured consumer head was 3894b9014b30549fe7749c8dfeb0eaedecfa3e8f. Inspection of current head f28d875d121a1ec0ab879ef54873220c2ed23c6a found the same workspace-width derivation.

With DSPARK block size 5, target verification uses six token rows per running request. The derived workspace was:

32 max decode batch × 4096 hidden = 131,072 elements

The C32 target-verification bound is:

32 × 6 × 4096 = 786,432 elements

The 131,072-element workspace admitted at most floor(32 / 6) = 5 concurrent requests. In the C8 trace, all 11,938 all-reduces used NCCL and zero used PCIe-IPC.

Setting SGLANG_PCIE_IPC_MAX_NUMEL=786432 routed all 11,938 C8 reductions through PCIe-IPC. In matched 127-step profiler captures:

  • C1 mean full-model step duration changed from 15.681 ms with NCCL to 14.965 ms with PCIe-IPC (-4.57%).
  • C8 mean full-model step duration changed from 35.399 ms with NCCL to 33.421 ms with PCIe-IPC (-5.59%).
  • Per-rank workspace allocation increased from approximately 2 MiB to 12 MiB.
  • Logged max_total_num_tokens remained 771,072.
  • Prefill-sized reductions remained on NCCL.

The FlashInfer backend was #4393 at dca29052ac92789df4df95455170209a93b1ee73.

A later integration using current SGLang head f28d875d121a1ec0ab879ef54873220c2ed23c6a, the same FlashInfer head, and the explicit 786,432-element workspace completed five repetitions at every decode concurrency from C1 through C32, five cache-cold prefill repetitions at 8K, 32K, 64K, and 128K, GSM8K with 1,261/1,319 correct and zero request errors, and 8/8 long-output requests. These later results are integration coverage, not isolated performance attribution.

…eed policy

The workspace was built without ever calling tune(), so supports() answered
from FlashInfer's seed policy and the kernels ran untuned tactics. FlashInfer
needs a host-side group to rendezvous the autotuner on every rank, so pass the
coordinator's cpu_group down and tune once at workspace build. Results persist
to FlashInfer's cache, so only the first server on a host pays for it.

Autotuning replays kernels and cannot run under graph capture; if the first
eligible reduction arrives there, keep the seed policy and say so.
The workspace was built without a tune_cache, so the measured tactics lived
only in that process: every server start re-measured them, contradicting the
claim that only the first one pays. Name FlashInfer's default path explicitly
so the file is written and the next start reuses it.
Building the workspace on the first eligible reduction puts the tuning call
inside SGLang's own FlashInfer autotune pass, and FlashInfer declines to profile
a collective from an autotune context it did not open -- so tune() returned
having measured nothing, and the kernels kept running the seed policy. Verified:
zero 'Tuning flashinfer::pcie_ipc_all_reduce' lines and no cache written.

Add prepare(hidden) and call it from the runner's warmup just before the
autotune context opens, which is where vLLM's port does the same thing. _tune()
now refuses and says so when it finds itself nested, rather than reporting a
tuning that did not happen.
tune() declines shapes silently, so a log line that only proves no exception
was raised is how an autotune that measured nothing reads as a success -- which
is exactly how the previous two revisions here were misread.
@AliceChenyy
AliceChenyy marked this pull request as ready for review August 28, 2026 09:45
AliceChenyy and others added 3 commits August 31, 2026 09:13
One conflict, in environ.py's all-reduce block: upstream deleted
SGLANG_ENABLE_CUSTOM_ALL_REDUCE_V2_MULTINODE (no references remain anywhere
in the tree) and moved the flashinfer/triton split-tile vars elsewhere in the
file. Kept only this PR's two vars; everything else follows upstream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Jiminator Jiminator left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments

from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase

register_cpu_ci(est_time=5, suite="base-c-test-cpu")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

base-c-test-cpu is no longer a registered suite. Could we move this unit test to base-a-test-cpu?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to base-a-test-cpu. Confirmed with collect_tests that the file is now picked up there, and that nothing in the tree still references base-c-test-cpu.

if (
envs.SGLANG_ENABLE_PCIE_IPC_ALLREDUCE.get()
and self.world_size > 1
and "tp" in self.unique_name

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also matches attention_tp and moe_tp. Some of those groups have no pynccl or custom all-reduce communicator, so they hit the assertion at line 1032 before reaching the PCIe IPC code

This causes using the SGLANG_ENABLE_PCIE_IPC_ALLREDUCE with a model like Qwen3-8B TP4/DP2 with DP attention and Qwen3-30B-A3B TP4/EP2 to fail. I am not too certain on the scope of this PR, but I think this is definitely worht bringing up/addressing. If they’re outside its scope, please keep this backend disabled for them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and thank you -- reproduced both failures on 8x SM120. eligible_group() now matches the group name exactly, so attention_tp / moe_tp / pdmux_prefill_tp keep NCCL; Qwen3-32B TP4/DP2 and Qwen3-30B-A3B TP4/EP2 both start with the flag set, where before they hit the assertion during graph capture.

try:
from sglang.srt.server_args import get_global_server_args

config = get_global_server_args().cuda_graph_config

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we read the resolved configuration here, or pass the workspace size from the runner into prepare()?

After upstream’s configuration changes, this field stays None when I pass --cuda-graph-max-bs 128. The resolved value in get_exec().graph.cuda_graph_config.decode.max_bs is 128, but this function returns None, so the workspace falls back to 64 rows.

A test using prepare_server_args() would catch this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was worse than a None: ServerArgs has no cuda_graph_config at all, so the read raised AttributeError on every call and the bare except made the 64-row fallback permanent. It now reads get_exec().graph.cuda_graph_config and warns when it falls back; measured 16 and 32 rows for --cuda-graph-max-bs 16 and 32, against a constant 64 before.


Decode runs inside a captured graph, so the largest captured batch bounds the
reduction. Speculative decoding verifies several tokens per sequence in one
forward, and the decode phase's ``max_bs`` is the batch the runner captures

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

max_bs counts requests, so it doesn’t include speculative token width. With four draft tokens, 64 requests need 256 rows for verification. Please account for that when sizing the workspace and update this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and the docstring was wrong to claim otherwise -- both are fixed. The width is now max_bs * draft_tokens, verified on hardware: 32 requests with 4 draft tokens size the workspace for 128 rows.

comm._device = torch.device("cpu")
comm._world_size = world_size
comm._workspace_cls = workspace_cls or MagicMock()
comm._build_failed = False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we update this helper and mock _tune() for these sizing and shape tests? The later tuning change added _cpu_group, but this helper never sets it. _ensure_workspace() now calls _tune(), which reads that field, so six of the nine tests fail.

Adding _cpu_group = None alone still fails when FlashInfer is unavailable, because _tune() imports it before checking the field. We could mock tuning here and test its behavior separately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done -- the helper sets _cpu_group and stubs tuning, and tuning is covered separately in TestTuning. As you noted, _cpu_group = None alone was not enough, since _tune imports FlashInfer before it reads the field.

def should_pcie_ipc_ar(self, inp: torch.Tensor) -> bool:
"""Whether FlashInfer has a tuned configuration for this exact shape.

``supports`` consults the tuning table, so a shape the kernels lose on is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we update this description of supports()? The earlier version used a performance-based table to reject some shapes, but that changed upstream it seems. supports() now checks whether the kernel can run the tensor, so it can accept shapes even when NCCL would be faster.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten, here and in the module docstring -- supports() reports whether the kernels can run a shape, not whether they beat NCCL, so the size bound is what keeps prefill-sized reductions on NCCL.

# After ``ca``: the PCIe-IPC kernels are for hosts where no fabric-specific
# backend applies. They do not probe for NVLink, so on a host that has it
# this ordering is what keeps the faster backend in front of them.
if (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In eager execution, the symmetric-memory branch at lines 757–767 returns through NCCL before reaching this code. Compiled execution can reach this code and select IPC instead. Could we clarify which backend should take priority when both this feature and --enable-symm-mem are enabled? If IPC should take priority in both cases, the earlier branch needs updating.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made explicit: the two flags are independent opt-ins, and with both set the backend now keeps symmetric memory and logs once. Deciding it in eligible_group() rather than in the dispatch means eager and compiled behave the same, so the earlier branch needs no change.

"""
from flashinfer.autotuner import AutoTuner

if AutoTuner.get().is_tuning_mode:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says FlashInfer won’t allow that call, is this correct?

Is there another reason we need this check? If so, could we explain that reason here? Otherwise, we could remove the check and update the comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right to question it -- the comment was wrong. autotune() is reference-counted and workspace.tune() installs its own process group, so FlashInfer does permit the call; the real reason to decline is that tune() mutates shared autotuner state for the length of the call and would time the enclosing pass with our settings. Comment now says that.

self._bound_stream = stream
return self._workspace.all_reduce(inp)

def destroy(self) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workspace needs to be released before the process groups are destroyed, because FlashInfer uses those groups during cleanup.

Could we call this method from GroupCoordinator.destroy()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now called from GroupCoordinator.destroy(), ahead of destroy_process_group(). One caveat worth flagging: the server shutdown path does not currently call destroy_model_parallel() at all, so today this is reached from benchmark/one_batch.py and tests -- verified clean there.

return
try:
tuned = self._workspace.tune(
[hidden], dtype=torch.bfloat16, tune_group=self._cpu_group

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

supports() accepts fp16 tensors, but this call only tunes bf16. FlashInfer stores separate tuning results for each dtype, so an fp16 model won’t use the results from this call.

We should either tune fp16 when the model uses it, or explicitly limit this adapter to bf16.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Limited to bf16, with a one-off warning for anything else. Tuning the model dtype would need measurements we do not have, so declining seemed the honest option until then.

base-c-test-cpu is no longer a registered suite, so this file was collected by
nothing. The mock helper also never set _cpu_group, which _ensure_workspace now
reads through _tune, so six of the nine cases errored once that call was added.

Register on base-a-test-cpu, give the helper the field, and stub tuning there so
the sizing and shape cases exercise what they claim to. Tuning gets its own
class: every way it declines returns normally, which is indistinguishable from a
successful tune unless asserted.
…eardown

"tp" in unique_name also matches attention_tp, moe_tp and pdmux_prefill_tp.
Those groups carry no pynccl or custom all-reduce communicator, so a reduction
dispatched to them trips the assertion in _all_reduce_out_place instead of
falling back: with the flag set, Qwen3-32B TP4/DP2 and Qwen3-30B-A3B TP4/EP2
fail during CUDA graph capture. Reproduced on 8x SM120, fixed by matching the
group name exactly; the rule now lives next to the backend with tests.

Separately, GroupCoordinator.destroy() never released the workspace. Release it
there, ahead of destroy_process_group(), because FlashInfer collectives on that
group while tearing the workspace down.
ServerArgs has no cuda_graph_config: the resolved config moved onto the exec
bag, so the old read raised AttributeError on every call and a bare except
turned that into a permanent, silent fallback to a fixed 64 rows. Whatever
--cuda-graph-max-bs an operator passed, the workspace ignored it.

Read the exec bag, and multiply by the speculative draft width -- max_bs counts
requests, not rows, so a verify pass needs max_bs * draft_tokens of them. The
fallback now says so instead of staying quiet. Measured on 8x SM120: max-bs 16
and 32 give 16 and 32 rows where both previously gave 64, and 32 requests with
4 draft tokens give 128.

Also decline anything but bf16. FlashInfer keys tuning results by dtype and
only bf16 is tuned here, so an fp16 model would have run the seed policy while
looking tuned.
…ld to symm-mem

prepare() sat inside _flashinfer_autotune, which only runs for models that
autotune; every other model built the workspace lazily on its first reduction,
inside another autotune context, where tuning declines. Move it to the general
warmup path next to the other pre-initialised workspaces.

That lazy path also ignored --disable-flashinfer-autotune: on 8x SM120 the
adapter tuned seven shapes and wrote a cache with the flag set. Honour it.

Finally, --enable-symm-mem and SGLANG_ENABLE_PCIE_IPC_ALLREDUCE are independent
opt-ins, and with both set the result depended on the execution mode -- eager
returned through symmetric memory's NCCL branch, compiled selected IPC. Keep
symmetric memory, the pre-existing feature, and say so once.
@AliceChenyy

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review -- all eleven points are addressed in four commits on top of 7ec557d3, each of which passes the unit suite on its own (15 -> 21 -> 28 -> 34 cases).

Two of your comments turned out to hide larger problems, so flagging them explicitly:

  • The workspace sizing never worked. ServerArgs has no cuda_graph_config, so _decode_width() raised AttributeError on every call and a bare except turned that into a permanent fallback to a fixed 64 rows -- --cuda-graph-max-bs had no effect at all. Fixed, and the fallback is no longer silent.
  • The is_tuning_mode comment was simply wrong. autotune() is reference-counted and workspace.tune() installs its own process group, so FlashInfer does not refuse the call. The guard is still worth keeping, but for the opposite reason: tune() mutates shared autotuner state and would time the enclosing pass with our settings.

I also re-measured on 8x SM120 (GPUs 0-3 and 4-7 on separate NUMA nodes), three reps per cell with the first discarded, rep spread reported:

ISL conc OFF TPOT ON TPOT delta
8192 1 12.10 8.20 -32.2%
8192 8 14.31 11.13 -22.2%
8192 32 22.45 19.54 -13.0%
32768 1 12.21 8.35 -31.6%
32768 8 16.05 12.79 -20.3%
32768 32 23.62 20.76 -12.1%

GSM8K (200 examples, run at both ends of each arm): OFF 0.990 / 0.985, ON 0.990 / 0.990. TTFT is unchanged within noise, which is expected -- the workspace is decode-sized by design, so prefill stays on NCCL.

One caveat that seems worth documenting rather than hiding: on TP4 within a single NUMA node the same measurement shows -8.5% at concurrency 1 and nothing measurable at 8 or 32. The all-reduce never crosses the socket link there, so there is little for these kernels to remove. Anyone evaluating this on a 4-GPU host should expect roughly nothing, and that is the topology, not the backend.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants