Multi-GPU & Multi-Node

One model, many GPUs β€” and, when one machine is not enough, many machines. TensorSharp implements tensor parallelism in the Megatron-LM column/row-parallel pattern, and extends it across hosts with a peer-to-peer TCP mesh. On the architectures that shard no weights, the same --tp N flag runs a layer split instead β€” a capacity feature, not a speed one.

🧭

In one line: add --tp N to run on N local GPUs β€” sharding every layer where the architecture supports it, splitting the model by whole layers where it does not; add --tp-node-id and --tp-peers on top of that to span machines. Both TensorSharp.Cli and TensorSharp.Server take the flags, on the direct cuda backend and on the GGML CUDA / Vulkan backends.

When do you need it?

On the GGML backends, fused per-rank block graphs make --tp 2 decode faster than a single GPU on models that fit on one card too β€” 51.7 vs 37.3 tok/s on Gemma 4 E4B Q8_0 (2Γ— RTX 2000 Ada). If you need more concurrent throughput rather than lower single-stream latency, reach for continuous batching first β€” it is on by default and costs nothing to try.

TP vs. the layer split β€” what a multi-GPU box actually does

There are two different ways a model can occupy more than one GPU, and only one of them is --tp.

The layer split applies to four architectures, all of which run through their own whole-model executors. DeepSeek V4 Flash (deepseek4), DeepSeek V4.1 Flash (deepseek41) and GLM 5.x (glm-dsa, glm5next) do it by default, with no flag at all: they spread across every visible GPU because neither fits on one card. TS_DSV4_NGPU and TS_GLM_NGPU cap how many devices they use. Qwen 3.8 Flash Next (qwen4exp) is the one that asks: it stays on a single GPU until you pass --tp N, and what that runs is a layer split, not tensor parallelism β€” qwen4exp shards no weights, and this is the same (and only) multi-GPU mode llama.cpp offers the architecture, whose -sm row refuses to load it. For GLM-5.2, GLM-5.3 and GLM-5.3-Flash alike, omitting --tp keeps that default split, while --tp N on a GGML GPU backend selects native local/single-process tensor parallelism β€” GLM-5.3 (not Flash) is the same 79-block glm-dsa shape as 5.2, so it loads on the GLM-5.2 path with no new code and no new flag.

On every other architecture, running without --tp uses a single GPU. There is no automatic layer split on the generic per-op or fused-graph paths β€” a model that does not fit one card fails at load rather than being spread silently (the refusal that names the exact --n-cpu-moe N comes from the DeepSeek V4 and GLM 5.x whole-model loaders). An architecture that supports neither tensor parallelism nor a layer split now says so on stderr and runs on one GPU, instead of accepting --tp and quietly leaving the other cards idle.

So on a 3-GPU box, --backend ggml_cuda alone gives you all three GPUs on GLM 5.x and DeepSeek V4 (layer split) and one GPU on Gemma 4 and Qwen 3.8 Flash Next; adding --tp 3 switches GLM-5.2, GLM-5.3 and GLM-5.3-Flash to their native local/single-process tensor-parallel path, and caps DeepSeek V4's layer split at three devices (there --tp N is only a device count β€” the same thing TS_DSV4_NGPU sets), gives Gemma 4 all three GPUs by sharding inside its layers, and gives Qwen 3.8 Flash Next all three by splitting whole layers across them. On GLM-5.2 that switch is a downgrade in speed and buys only capacity; on plain GLM-5.3 it is an accepted mode that has never been run at any degree above one, and one whose replicated MLA and indexer caches multiply the KV footprint by N and shorten the context that fits; on Qwen 3.8 Flash Next the layer split is neither faster nor slower, and buys only capacity too β€” see Measured results.

How tensor parallelism works

Each transformer block is rewritten into a pair of complementary shardings, so exactly one collective is needed per block half:

  1. Column-parallel projections

    QKV and gate/up split their output dimension β€” attention heads or the FFN intermediate size β€” across GPUs. No communication is needed: each rank simply produces its own slice of the activations.

  2. Independent per-rank compute

    Attention (or the activation function) runs on each GPU over only the heads that GPU owns, against that GPU's own KV cache.

  3. Row-parallel projections + AllReduce

    The output and down projections split their input dimension, so each rank computes a partial sum. One AllReduce adds the partials together and every rank ends the block holding the identical hidden state.

Norms, token embeddings, and the LM head are replicated on every rank rather than sharded β€” they are small, and replicating them removes a collective from the critical path.

Local TP β€” one process, several GPUs

Local TP lives in a single process. On the direct cuda backend one thread issues commands to all GPUs in sequence and CUDA streams provide the real parallelism; AllReduce runs as peer-to-peer device copies plus an element-wise add kernel. On the GGML backends a rank worker pool drives the GPUs concurrently β€” a GGML op submits and synchronizes in one call, so a sequential rank loop would run the cards strictly one after another.

# CLI β€” split the model across 2 GPUs
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model model.gguf --backend cuda --tp 2

# Same on the GGML CUDA backend
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model model.gguf --backend ggml_cuda --tp 2

# Pick which physical GPUs the ranks map to
TENSORSHARP_TP_DEVICES=0,2 dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll \
    --model model.gguf --backend ggml_cuda --tp 2

# Server β€” same flag (TENSORSHARP_TP_DEGREE=2 also works)
dotnet TensorSharp.Server.Host/bin/TensorSharp.Server.Host.dll \
    --model model.gguf --backend ggml_cuda --tp 2

Or put it in a config file:

{ "model": "model.gguf", "backend": "cuda", "tp": 2 }

Distributed TP β€” several machines

Each node runs its own process over its own local GPUs and connects to every other node over TCP with a length-prefixed framing protocol. AllReduce is hierarchical: reduce locally over P2P inside each node, exchange over TCP between node representatives, then broadcast back down β€” so only 1/tp_local of the data ever crosses the network.

# 2 nodes x 2 GPUs each = global TP degree 4.
# Node 0:
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model model.gguf --backend cuda --tp 2 \
    --tp-node-id 0 --tp-peers "192.168.1.10:9500,192.168.1.11:9500"

# Node 1 β€” same peer list, different node ID:
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model model.gguf --backend cuda --tp 2 \
    --tp-node-id 1 --tp-peers "192.168.1.10:9500,192.168.1.11:9500"

The server can front such a cluster, but only as node 0 β€” the driver that owns sampling and serves HTTP. Every other node runs a TensorSharp.Cli worker with the same model, backend, and peer list. The TENSORSHARP_TP_* environment variables work in place of the flags.

# Node 0 β€” server / driver:
dotnet TensorSharp.Server.Host/bin/TensorSharp.Server.Host.dll --model model.gguf --backend cuda \
    --tp 2 --tp-node-id 0 --tp-peers "192.168.1.10:9500,192.168.1.11:9500"

# Node 1 β€” CLI worker:
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model model.gguf --backend cuda --tp 2 \
    --tp-node-id 1 --tp-peers "192.168.1.10:9500,192.168.1.11:9500"
⚠️

Every node must pass the same --tp-peers list, in the same order, and a unique --tp-node-id (0-based, indexing into that list). The port β€” 9500 in the examples β€” is not a default: choose one and make sure it is reachable between all nodes. Peer traffic is unauthenticated and unencrypted, so keep the cluster on a trusted private network.

Nodes are usually started by hand a few seconds or minutes apart, so a node that comes up first keeps retrying its outbound connections for up to 120 seconds instead of failing on the first refused connection. Once the mesh is complete each node prints [TcpCommunicator] Rank r/N connected to all peers.

Supported architectures

Nearly every autoregressive architecture in TensorSharp runs under TP; heterogeneous layers get their own sharding strategy. The ones whose --tp means something else are marked as such. This table describes local support; no GLM 5.x release claims distributed/cross-node TP β€” GLM-5.2, GLM-5.3 and GLM-5.3-Flash alike hard-refuse --tp-node-id/--tp-peers before the model is constructed.

ArchitectureTPStrategy / notes
Mistral 3βœ…Fused and separate QKV layouts, YaRN RoPE.
Gemma 4βœ…Dense and MoE, per-layer head dims; multimodal embeddings are injected into the TP path so vision/audio prompts survive. On GGML the fused whole-model MoE trunk splits inside each expert (gate/up column-parallel, down row-parallel) so global expert ids keep working β€” TS_GEMMA4_TP_FUSED_MOE=0 falls back to the whole-expert per-op path. Direct CUDA uses per-expert slicing.
Qwen 3.5 / 3.6 familyβœ…Block-cyclic V-head ownership for the GatedDeltaNet recurrent layers β€” each rank keeps its own delta/conv state, device-resident, and needs no cross-rank traffic for the recurrent path. On GGML the whole GDN block runs as one packed per-rank kernel, MoE uses expert parallelism (whole experts per rank, Megatron-split shared expert), and the LM head is column-parallel with no collective at all. Direct CUDA uses expert slicing.
Qwen 3.8 Flash Nextβœ… (layer split)Different mechanism: qwen4exp shards no weights. --tp N gives each GPU a contiguous run of whole layers β€” the whole-token graph is cut at the device boundaries and the hidden state handed across β€” which is the only multi-GPU mode llama.cpp offers this architecture too (-sm row refuses to load it). Greedy output stays byte-identical to the single-GPU run and throughput is unchanged, so it is a capacity feature; see The layer split, measured. TS_Q4E_LAYER_SPLIT=20,28 overrides the automatic balance with explicit per-GPU layer counts, and throws rather than ignoring a value it cannot honour.
GPT OSSβœ…MoE expert slicing, attention sinks, YaRN. Runs on the GGML backends too, though the GGML MoE path still walks experts per token per rank rather than using expert parallelism.
Nemotron-Hβœ…MoE expert slicing; Mamba2 SSM layers are computed on rank 0 and broadcast. Same GGML MoE caveat as GPT OSS.
Muse-Glimmerβœ…Dense, but with three shapes that need care: the fused [gate|up] is split per segment (a contiguous split silently hands one rank all of gate), the per-head QK RMSNorms are 1-D [head_dim] vectors and stay replicated (the Q norm also carries the folded qk_scale_factor), and the attention output gate is column-parallel by head and applied inside the per-rank region. Both AllReduces land on the raw matmul output, before the 1e-8 post-norms β€” reducing after a non-linear norm produces fluent but wrong output. 2 KV heads cap the degree at --tp 2.
DeepSeek V4.1 Flashβœ… (layer split, + experimental routed-MoE TP)Layer placement is the default and the measured path, sized against each device's free VRAM; --tp N / TS_DSV4_NGPU caps the count. TS_DSV41_TP=N (2–8, equal to that count) additionally shards routed-expert gate/up along the FFN intermediate and down along its input, reducing partials through host-staged F32 buffers, while attention, shared experts and caches keep their layer placement. The partitions are block-aligned and unequal β€” the 2304-wide intermediate is nine 256-element K-quant blocks, so two ranks take 1280+1024 and four take 768+512+512+512. The first full Q2_K run of it was slower than the layer split. Attention TP and distributed groups are not implemented. On --backend cpu β€” the pure-C# DeepSeek4CpuExecutor, a correctness and portability path rather than a serving one β€” distributed TP groups and any TS_DSV41_TP other than 0 are refused before a single weight is read.
DeepSeek V4 Flashβœ… (layer split)Different mechanism: DSV4's whole-model executors split the model by layer across GPUs rather than sharding every weight, so there is no per-layer AllReduce. --tp N simply caps how many GPUs the split uses (same as TS_DSV4_NGPU); with no flag it uses every visible device. The split balances the largest per-device load, counting the fixed residents β€” embedding table on the first device, output head and the whole DSpark drafter on the last β€” where they actually land.
GLM 5.xβœ… (local only)GGML GPU backends only; native TP is local/single-process for the whole family β€” GLM-5.2, GLM-5.3 and GLM-5.3-Flash. GLM-5.2 shards MLA heads and hidden rows inside every routed expert. GLM-5.3 (not Flash) is the same 79-block glm-dsa shape as 5.2 β€” 256 routed experts at top-8 plus one shared expert, MLA with the lightning indexer, rope base 8e6 β€” so --tp N is accepted for it untouched on the GLM-5.2 path, with no new code and no new flag; its MLA and indexer caches are replicated per rank, so the KV footprint multiplies by N and the context that fits shrinks accordingly. Nothing above one rank has ever been run for it β€” the only recorded arithmetic is an 8Γ— A40 46 GB box where --tp 8 needs 41.7 GiB per rank and does not fit β€” so treat it as an accepted mode, not a validated configuration. GLM-5.3-Flash also head-shards KDA with per-rank recurrent state; its MLA heads and routed-expert hidden rows are sharded likewise. Attention partials reduce before each nonlinear Sinkhorn hyper-connection. On GLM-5.3-Flash's eligible segmented fast path, routed-MoE partials reduce first, then every rank computes and adds the replicated shared expert locally; hyper-connections, pooled indexer, router, norms, dense layers and embedding remain unsharded and execute per rank, while output norm / LM head stay on rank 0. The segmented fast path and TS_GLM_TP_FUSED are gated on the Flash boolean, so plain GLM-5.3 always runs the combined scheduler. For Flash, CPU MoE, tracing, partial TS_GLM_TP_SHARD, oversubscription, or missing native hyper-connection kernels select the combined scheduler fallback, where the shared expert runs once on rank 0; TS_GLM_TP_FUSED=0 forces that diagnostic fallback. Omitting --tp keeps the default layer split.
DiffusionGemmaβ€”Not applicable (text-diffusion sampler, not autoregressive decode).
Qwen-Image-Editβ€”Not applicable (MMDiT image generation).

Two MoE shardings are in play. Expert slicing (direct CUDA, and GPT OSS / Nemotron-H everywhere) gives each GPU 1/tp of every expert's weights with a replicated router, so load stays balanced whichever experts a token selects. Expert parallelism (Qwen 3.5/3.6 on GGML) partitions whole experts instead, which keeps each rank down to a single batched ggml_mul_mat_id dispatch per projection rather than a per-(token, expert) loop. Gemma 4 and GLM 5.x on GGML use a third variant: the Megatron split goes inside every expert (gate/up column-parallel, down row-parallel), so every rank still sees the full expert table and a token's selected ids stay globally valid β€” which is what ggml_mul_mat_id requires, since it will not accept the same expert id twice in one token's selection.

Requirements & constraints

Measured results

2Γ— RTX 2000 Ada (16 GB each, PCIe, no NVLink), --backend ggml_cuda --tp 2, prefill 512 / decode 64, tokens per second:

Model1 GPU--tp 2
Gemma 4 E4B Q8_02760 / 37.32488 / 51.7
Gemma 4 26B-A4B IQ4_XS1845 / 48.52537 / 51.2
Qwen 3.5-9B Q8_01461 / 23.1399 / 24.4
Qwen 3.5-35B-A3B IQ4_XSdoes not fit184 / 18.1
Muse-Glimmer 30B UD-IQ2_XXS †1171 / 40.21569 / 63.2
Muse-Glimmer 30B Q8_0, 28.2 GB †does not fit1691 / 34.3

† Measured on 2Γ— RTX PRO 4000 Blackwell (24 GB each, PCIe), not the RTX 2000 Ada pair the other rows use β€” the two hardware sets are not directly comparable to each other, only each row against its own 1-GPU column. Muse-Glimmer is the one model here that beats a single GPU on both phases (1.34Γ— prefill, 1.57Γ— decode); its Q8_0 has no 1-GPU number because 28.2 GB of weights does not fit on a 24 GB card at all.

That is not universal. On 3Γ— RTX PRO 6000 (PCIe, no NVLink), GLM-5.2 UD-IQ2_XXS at prefill 2048 / decode 64 measures 915.9 / 43.9 tok/s on the plain layer split against 505.6 / 17.6 under --tp 3: each of the 78 layers needs two all-reduces of a [6144, n_tokens] hidden state, and on a PCIe host that costs more than the split saves. TP also holds a full-length cache on every rank, dropping the fitted context from 342,272 to 91,136 tokens, and it changes the reduction order β€” against the recorded llama.cpp goldens a 2-bit MoE reproduces 3 of 6 prompts under --tp 3 where the layer split reproduces 5 of 6. There, TP is a capacity feature rather than a latency one. How much TP buys depends on the interconnect and on how much of a layer can be split at all.

Decode β€” the memory-bound half TP is meant to help β€” reaches 1.39Γ— a single GPU on Gemma 4 E4B and 1.06Γ— on Qwen 3.5-9B, with both Gemma 4 models producing output byte-identical to their single-GPU runs. Prefill is compute-bound and pays the collectives, so it lands at or below the single-GPU figure for models that fit on one card. Qwen 3.5-35B-A3B does not fit a 16 GB card at all β€” TP is the only way to run it, and memory splits 9.4 + 8.0 GB across the pair.

The layer split, measured

A layer split shards no weights, so it should cost nothing and gain nothing β€” and that is what it measures. 2Γ— A100-80GB, Qwen3.8-Flash-Next-UD-Q2_K_XL (73.4 GiB), --tp 2 against one GPU:

Measure1 GPU--tp 2 (layer split)
Greedy outputByte-identical β€” same SHA-256
VRAMwhole model on one card24.2 + 26.2 GB
Prefill~1520–1550 t/s~1520–1550 t/s
Decode~56 t/s~56 t/s

llama.cpp on the same box behaves the same way: -sm layer takes pp1536 / tg128 from 1094 / 61.2 on one GPU to 1200 / 61.5 on two β€” about 10% prefill, nothing on decode β€” and -sm row refuses to load this architecture at all. So the reason to pass --tp N here is that the weights, the caches and the context do not fit on one card, not throughput. Startup prints which mode ran and the per-GPU layer/byte split. TS_Q4E_LAYER_SPLIT=20,28 gives explicit layer counts per GPU (llama.cpp's --tensor-split in spirit) and throws rather than silently ignoring a value it cannot honour β€” useful, because the automatic balance prices weights and cannot see the vision tower, which loads later and lands on GPU 0.

CUDA graph capture under TP

A tensor-parallel token is dozens of small per-rank submissions, and replaying them is worth about 45% of decode throughput β€” so graph capture stays on under TP (disable with TS_GGML_TP_CUDA_GRAPHS=0). On 4Γ—A40:

ModelCapture offCapture on
Qwen 3.5-9B, --tp 488 tok/s128.5 tok/s
Qwen 3.5-35B-A3B, --tp 271.3 tok/s104.1 tok/s β€” the difference between TP losing and winning against a single GPU

The collective transport is likewise chosen by measurement, not capability flags: at startup the group verifies that peer copies between the advertised device pairs actually deliver their bytes and that a real NCCL AllReduce completes, then picks the fastest transport that passes. Hosts that advertise peer access which never arrives (common on virtualized cloud instances) keep the NCCL collective with peer transport disabled rather than losing it β€” which matters past two GPUs, where the pinned-host pipeline does not apply and the alternative is reducing through host RAM at every layer boundary (4Γ—A40, Qwen 3.5-9B Q8_0 decode: 53.5 β†’ 75.1 tok/s).

Tuning & diagnostics

Local AllReduce prefers CUDA peer-to-peer DMA. At startup the group enables peer access for every pair that reports it, then runs a round-trip self-test β€” some topologies (L4 cards behind certain PCIe switches, IOMMU-enabled hosts, small BAR1 windows) claim peer access but silently transfer corrupt data, and any pair that fails is demoted to host staging permanently. Hardware with no peer access at all (A16 vGPU profiles, most consumer cards) stages through host memory from the start. The fallbacks are automatic; the switches below exist to force them for diagnosis.

VariableDefaultWhat it does
TENSORSHARP_TP_DEGREE1Local GPU count to split across (= --tp on both the CLI and the server).
TENSORSHARP_TP_DEVICES0..tp-1GPU ordinals the ranks map to, e.g. 0,2. GGML backends.
TENSORSHARP_TP_NODE_IDunsetThis node's 0-based ID (= --tp-node-id). Set together with the peer list.
TENSORSHARP_TP_PEERSunsetComma-separated host:port list of every node (= --tp-peers).
TENSORSHARP_TP_CONNECT_TIMEOUT_SECONDS120How long a node retries outbound connections to its peers. Raise it when a slow orchestrator staggers node startup.
TENSORSHARP_TP_RECV_TIMEOUT_SECONDS300Per-receive timeout on a peer socket, so a stalled peer fails the collective instead of hanging on the OS TCP keepalive (often 2+ hours).
TENSORSHARP_TP_DISABLE_P2Poff1 forces every cross-GPU transfer through host memory β€” exactly the path no-peer hardware takes. Use it to test whether a multi-GPU defect lives in the P2P DMA path.
TENSORSHARP_TP_HOST_ALLREDUCEoff1 runs the local AllReduce as device→host, sum on the CPU, host→device. Slower, but mirrors the known-good multi-node reduce exactly.
TS_GGML_TP_DEVICE_AR_THRESHOLD262144Element count above which AllReduce uses ggml's device collective instead of a host reduction. GGML activations already live in host RAM, so small payloads are cheaper to sum there.
TS_GGML_TP_PARALLELon0 drives the ranks sequentially instead of concurrently β€” a diagnostic, and a large slowdown.
TS_GGML_TP_CUDA_GRAPHSon0 turns CUDA graph capture off for multi-GPU runs. Capture is on by default because a tensor-parallel token is dozens of small per-rank submissions that replay far more cheaply than they re-issue (see above). The opt-out is translated into a native GGML_CUDA_DISABLE_GRAPHS before the first backend call, because ggml latches that value on first use.
TS_GEMMA4_TP_FUSED_MOEon0 falls back from Gemma 4's fused MoE trunk to the whole-expert per-op path.
GGML_CUDA_ALLREDUCEautonccl / internal / none, passed through to ggml's collective selection. Setting it explicitly also skips the NCCL pre-flight probe.
TS_GGML_TP_AR_PROBEonBehavioural pre-flight of the NCCL collective before model load: some cloud hosts advertise GPU P2P that never delivers, and NCCL's first AllReduce then spins both GPUs forever β€” the classic symptom is a TP model load that hangs at kernel warmup. On a failed probe TensorSharp reroutes to ggml's pinned-host-memory internal pipeline. 0 skips the probe; force ignores the cached per-host verdict (~/.cache/tensorsharp/tp-collective-probe).
TS_GGML_TP_AR_PROBE_MS10000Deadline for the probe's AllReduce before the collective is declared broken; 0 disables the probe.
GGML_CUDA_AR_BF16_THRESHOLD1 MBPayload size above which ggml converts F32 collectives to BF16. TensorSharp raises ggml's own default (always convert) so decode-sized reductions stay exact; 0 disables the conversion.

Startup logs make the topology explicit: Tensor parallelism: N GPUs (<device names>) for the local group, a TP: P2P disabled… line or self-test warning when a pair is demoted, and one [TcpCommunicator] Rank r/N connected to all peers. per node.

Shared state across a cluster

When several server processes front the same workload, the server can optionally keep two pieces of state in Redis instead of in-process memory:

# Both tiers on one Redis
dotnet TensorSharp.Server.Host/bin/TensorSharp.Server.Host.dll --model model.gguf --backend cuda \
    --redis-url localhost:6379

# KV cache only, 12-hour TTL
dotnet TensorSharp.Server.Host/bin/TensorSharp.Server.Host.dll --model model.gguf --backend cuda \
    --paged-kv-redis-url localhost:6379 --paged-kv-redis-ttl 720

Troubleshooting

SymptomLikely cause & fix
Startup fails: requested TP degree exceeds device countThe process sees fewer CUDA devices than --tp asks for. Check CUDA_VISIBLE_DEVICES and the driver.
Model loads on one GPU despite --tp 2The backend is mlx, cpu, or ggml_cpu/ggml_metal. TP applies to cuda, ggml_cuda, and ggml_vulkan. If the backend is right, check stderr: an architecture that supports neither tensor parallelism nor a layer split prints a notice and runs on one GPU rather than failing.
A dimension is not divisible by the TP degreePick a degree that divides numHeads, numKVHeads, and intermediateSize β€” usually a power of two.
A node hangs waiting for peersThe peer list, order, or port does not match on all nodes, or a firewall blocks the port. Raise TENSORSHARP_TP_CONNECT_TIMEOUT_SECONDS if the nodes simply start far apart.
Garbled output only with multiple GPUsSuspect the P2P DMA path. Re-run with TENSORSHARP_TP_HOST_ALLREDUCE=1, then TENSORSHARP_TP_DISABLE_P2P=1; if the output becomes correct, the topology's peer DMA is at fault.
Multi-GPU is slower than a single GPUPrefill is compute-bound and pays the collectives, so it can trail a single card on a model that already fits. Decode should be faster on the GGML backends β€” if it is not, check that the fused paths are on (TS_GEMMA4_TP_FUSED_MOE unset, TS_GGML_TP_FUSED_MATMUL unset) and that the run is single-process, since multi-node falls back to the per-op forward. On a PCIe host with no NVLink, a model whose layers split poorly can be slower under TP no matter what β€” GLM 5.x needs two all-reduces per layer, and GLM-5.2 is measurably slower under --tp 3 than on the plain layer split; plain GLM-5.3 has never been run above one rank, so the same reasoning applies to it without a measurement behind it. Use TP there for capacity, not speed.
πŸ“–

Repository reference: USAGE.md β†’ Tensor Parallelism & Distributed Inference, FEATURES.md, and the TensorSharp.Distributed project.