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.

🧭

In one line: add --tp N to run on N local GPUs; 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.

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/bin/TensorSharp.Server.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/bin/TensorSharp.Server.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

Every autoregressive architecture in TensorSharp runs under TP; heterogeneous layers get their own sharding strategy.

ArchitectureTPStrategy / notes
Qwen 3βœ…Reference implementation β€” standard column/row-parallel QKV + FFN.
Mistral 3βœ…Fused and separate QKV layouts, YaRN RoPE.
Gemma 3βœ…Separate Q/K/V, GELU, sliding-window attention.
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.
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.
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.

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

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.

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_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.
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/bin/TensorSharp.Server.dll --model model.gguf --backend cuda \
    --redis-url localhost:6379

# KV cache only, 12-hour TTL
dotnet TensorSharp.Server/bin/TensorSharp.Server.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.
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.
πŸ“–

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