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?
- 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.
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.
- Tensor parallelism (
--tp N) puts every layer on every rank and splits the weights within each layer, so a decode step reads1/Nof the bytes per device and the ranks all-reduce at each layer boundary. It is opt-in β nothing splits a tensor unless you ask β and most, though no longer all, of the architectures in the table below implement it. - The layer split puts whole layers on different devices and runs them in sequence: device 0 evaluates layers 0..k, hands the hidden state to device 1, and so on. The cut points are not a naive
n_layer/Nβ the loader measures each device's free VRAM and bin-packs the layers to balance the largest fraction-of-budget used, so an uneven set of cards still fills evenly. There are no collectives and no per-layer split, so it costs nothing on a slow interconnect, but only one GPU is busy at a time.
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:
-
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.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.
| Architecture | TP | Strategy / 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
- 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 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 |
| Muse-Glimmer 30B UD-IQ2_XXS β | 1171 / 40.2 | 1569 / 63.2 |
| Muse-Glimmer 30B Q8_0, 28.2 GB β | does not fit | 1691 / 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:
| Measure | 1 GPU | --tp 2 (layer split) |
|---|---|---|
| Greedy output | Byte-identical β same SHA-256 | |
| VRAM | whole model on one card | 24.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:
| Model | Capture off | Capture on |
|---|---|---|
Qwen 3.5-9B, --tp 4 | 88 tok/s | 128.5 tok/s |
Qwen 3.5-35B-A3B, --tp 2 | 71.3 tok/s | 104.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.
| 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_GGML_TP_CUDA_GRAPHS | on | 0 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_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. Setting it explicitly also skips the NCCL pre-flight probe. |
TS_GGML_TP_AR_PROBE | on | Behavioural 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_MS | 10000 | Deadline for the probe's AllReduce before the collective is declared broken; 0 disables the probe. |
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.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
| 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. 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 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. 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.