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?
- The model does not fit on one GPU. Each GPU holds only
1/Nof the sharded weights, so a model that overflows a single card's VRAM fits comfortably across two or four. - The model does not fit on one machine. Distributed TP pools the GPUs of several hosts into one logical group β 2 nodes Γ 4 GPUs is a global degree of 8.
- You want more memory bandwidth per token. Every GPU works on its own slice of every projection, so the per-layer matmuls are split rather than queued.
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:
-
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.
-
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.
-
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.
| Architecture | TP | Strategy / 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
- GPU backends only. TP runs on
cuda,ggml_cuda, andggml_vulkan. MLX and the CPU backends are single-device. - Divisibility.
numHeads,numKVHeads, andintermediateSizemust all divide evenly by the TP degree. Quantized row-parallel splits additionally needne0divisible bytp Γ blockSize. - Batched forward under TP is implemented for Qwen 3 and Mistral 3. MoE models (Gemma 4, Qwen 3.5/3.6, GPT OSS, Nemotron-H) fall back to per-sequence forward when TP is active, so continuous batching gives less of a lift there.
- Local degree β€ visible GPUs. Requesting more ranks than the host has devices fails fast at startup.
- Multi-node keeps the per-op forward. The fused whole-model TP graphs are per-rank and single-process; a distributed run falls back to op-at-a-time execution.
Measured results
2Γ RTX 2000 Ada (16 GB each, PCIe, no NVLink), --backend ggml_cuda --tp 2, prefill 512 / decode 64, tokens per second:
| Model | 1 GPU | --tp 2 |
|---|---|---|
| Gemma 4 E4B Q8_0 | 2760 / 37.3 | 2488 / 51.7 |
| Gemma 4 26B-A4B IQ4_XS | 1845 / 48.5 | 2537 / 51.2 |
| Qwen 3.5-9B Q8_0 | 1461 / 23.1 | 399 / 24.4 |
| Qwen 3.5-35B-A3B IQ4_XS | does not fit | 184 / 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.
| Variable | Default | What it does |
|---|---|---|
TENSORSHARP_TP_DEGREE | 1 | Local GPU count to split across (= --tp on both the CLI and the server). |
TENSORSHARP_TP_DEVICES | 0..tp-1 | GPU ordinals the ranks map to, e.g. 0,2. GGML backends. |
TENSORSHARP_TP_NODE_ID | unset | This node's 0-based ID (= --tp-node-id). Set together with the peer list. |
TENSORSHARP_TP_PEERS | unset | Comma-separated host:port list of every node (= --tp-peers). |
TENSORSHARP_TP_CONNECT_TIMEOUT_SECONDS | 120 | How long a node retries outbound connections to its peers. Raise it when a slow orchestrator staggers node startup. |
TENSORSHARP_TP_RECV_TIMEOUT_SECONDS | 300 | Per-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_P2P | off | 1 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_ALLREDUCE | off | 1 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_THRESHOLD | 262144 | Element 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_PARALLEL | on | 0 drives the ranks sequentially instead of concurrently β a diagnostic, and a large slowdown. |
TS_GEMMA4_TP_FUSED_MOE | on | 0 falls back from Gemma 4's fused MoE trunk to the whole-expert per-op path. |
GGML_CUDA_ALLREDUCE | auto | nccl / internal / none, passed through to ggml's collective selection. |
GGML_CUDA_AR_BF16_THRESHOLD | 1 MB | Payload 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:
- KV cache tier β paged KV blocks are persisted so a prefix computed by one process can be reused by another (
--redis-url/--paged-kv-redis-url, envTS_KV_CACHE_REDIS_URL; entries expire afterTS_KV_CACHE_REDIS_TTL_MINUTES, default 24 h). - Responses API store β the OpenAI Responses store becomes durable and shared instead of in-memory (
TS_RESPONSES_STORE_REDIS_URL;--redis-urlsets both at once).
# 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
| Symptom | Likely cause & fix |
|---|---|
| Startup fails: requested TP degree exceeds device count | The process sees fewer CUDA devices than --tp asks for. Check CUDA_VISIBLE_DEVICES and the driver. |
Model loads on one GPU despite --tp 2 | The 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 degree | Pick a degree that divides numHeads, numKVHeads, and intermediateSize β usually a power of two. |
| A node hangs waiting for peers | The 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 GPUs | Suspect 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 GPU | Prefill 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.