Historical. This note records how tensor-split was designed and argued for in April 2026, against viiwork 1.x. The feature shipped and is now ordinary configuration, but the configuration vocabulary below is v1 and no longer valid. In viiwork 2 a model entry says:
gpus: [4, 5, 6, 7, 8, 9] gpus_per_backend: 2 # was gpus.tensor_split.group_size llamacpp: split_mode: layer # was gpus.tensor_split.mode split_weights: [1.16, 1.0]Consecutive entries of
gpusare paired, so the example is three backends of two cards.base_portis gone — backends take loopback ports. The reasoning about layer-split, interconnect and VRAM is unchanged and is why this note is kept. SeeREADME.mdfor current configuration.
Status: draft proposal
Author: tensor-split investigation (gfx906-fork-cutover sibling worktree)
Date: 2026-04-09
Applies to: branch tensor-split-investigation
Background
viiwork's current architecture spawns N independent llama-server processes, one per GPU, each pinned via ROCR_VISIBLE_DEVICES=<gpuid>, all serving the same model independently. Requests are load-balanced across these per-GPU backends, giving N-way concurrency at single-GPU memory cost.
This works perfectly for any model that fits one GPU (≤ 16 GB on Radeon VII), which has been every model viiwork has run to date. It cannot run a model that exceeds single-GPU VRAM — and that's the entire reason this branch exists.
The tensor-split investigation (see memory/smoke_test_results_2026_04_09.md and memory/gemma4_31b_results_2026_04_09.md) confirmed that llama.cpp's --tensor-split + --split-mode layer works on the gfx906 stripped fork over the rig's PCIe gen1 x1 mining-rack risers, with surprisingly small throughput penalties (-13% TG at N=2, -25% at N=6 for the 26B-A4B MoE; -7% to -10% for Gemma 4 31B Dense). Layer-split is the right mode (row-split crashes, and is the wrong mode for this interconnect anyway). The only thing missing is the viiwork-side plumbing to spawn this configuration.
What needs to change
A tensor-split deployment is structurally different from the existing replica deployment:
| Aspect | Current (replicas) | Tensor-split |
|---|---|---|
| llama-server processes | N (one per GPU) | 1 (spans N GPUs) |
ROCR_VISIBLE_DEVICES | single int per process | comma-separated list |
| Per-process port | base_port + i | single port |
| Concurrent slots | N × parallel | single slot (one big request at a time) |
| Memory per GPU | full model | model / N |
| Total addressable model size | ≤ single GPU VRAM | ≤ N × single GPU VRAM |
| Health check loop | per-process | one process |
| Balancer behavior | pick lowest-latency idle | trivial (one backend) |
/v1/models | one entry, N-way capacity | one entry, 1-way capacity |
| Failure recovery | restart one of N | restart the only one (longer outage) |
The two modes are not naturally interleaved on the same host. A given viiwork node should be either replica-mode or tensor-split-mode, not both. Mixed-mode would mean splitting the GPUs into two groups serving different models, which is already covered by running two viiwork instances on different ports (the existing multi-model-per-host pattern in setup-node.sh).
Proposed config shape
The minimal-blast-radius option: a boolean toggle on the gpus block that switches the spawn semantics. No new top-level mode field, no schema break for existing configs.
# viiwork.tensor-split.yaml
server:
host: 0.0.0.0
port: 8080
model:
path: /models/gemma-4-31B-it-Q4_K_M.gguf
context_size: 4096
n_gpu_layers: 999
parallel: 1 # single slot — tensor-split serializes anyway
gpus:
devices: [4, 5, 6, 7] # explicit list; the model spans all of them
base_port: 8081 # single port assigned (base_port itself), no offsets
tensor_split:
enabled: true # the new toggle
mode: layer # layer | row (default: layer)
weights: [1, 1, 1, 1] # optional per-GPU split fractions; default = even
main_gpu: 0 # only used with split-mode=row; default 0
backend:
binary: /usr/local/bin/llama-server
extra_args: []
Why this shape:
gpus.devicesalready exists and already takes a list. We reuse it instead of addingtensor_split.devices.gpus.tensor_split.enabled: trueis the discriminator. Manager branches on this.mode,weights,main_gpumap 1:1 to llama.cpp's--split-mode,--tensor-split,--main-gpu. Don't invent new vocabulary.parallel: 1is enforced/defaulted in tensor-split mode regardless of what the user sets — single slot is a hardware property here, not a policy choice.- Existing replica-mode configs are untouched; the absence of
gpus.tensor_splitmeans the defaultenabled=falseand current behavior is preserved exactly.
Alternative considered: top-level mode: field
mode: tensor-split # or "replicas" (default)
Rejected. It's a more invasive schema change, requires bumping config version, and forces every existing config to add an explicit mode: replicas line on next deploy. The boolean toggle hidden under gpus is strictly less disruptive and conveys the same intent.
Alternative considered: backends: list
backends:
- kind: tensor-split
devices: [4, 5, 6, 7]
...
- kind: replica
devices: [0]
...
Rejected for now. Most general, biggest refactor, and gives mixed-mode capability we don't need. Worth revisiting only if someone genuinely wants to run tensor-split and replicas in the same viiwork process — and even then the existing two-instances-on-different-ports pattern handles it.
Code changes
internal/config/config.go
Add a TensorSplitConfig type and embed in GPUConfig:
type TensorSplitConfig struct {
Enabled bool `yaml:"enabled"`
Mode string `yaml:"mode"` // "layer" | "row"; default "layer"
Weights []float64 `yaml:"weights"` // optional per-GPU fractions
MainGPU int `yaml:"main_gpu"` // for mode=row only
}
type GPUConfig struct {
Count int `yaml:"count"`
Devices []int `yaml:"devices"`
BasePort int `yaml:"base_port"`
Offset int `yaml:"offset"`
PowerLimitWatts int `yaml:"power_limit_watts"`
TensorSplit TensorSplitConfig `yaml:"tensor_split"`
}
Validate() additions:
- if
tensor_split.enabledthen requirelen(devices) >= 2 - if
tensor_split.mode == "", default to"layer" - if
tensor_split.mode == "row", log a loud warning (we know it crashes on the gfx906 fork; let users opt in to the experiment) - if
tensor_split.weights != nilthen requirelen(weights) == len(devices) - if
tensor_split.enabledthen forcemodel.parallel = 1(or warn and override)
internal/process/backend.go
The Backend struct grows multi-GPU fields, but only one is populated at a time:
type Backend struct {
GPUID int // -1 if multi-GPU (tensor-split)
GPUIDs []int // populated if tensor-split, empty otherwise
TensorSplit bool
SplitMode string
SplitWeights []float64
MainGPU int
// ... (existing fields unchanged)
}
buildArgs() adds the tensor-split flags:
if b.TensorSplit {
args = append(args, "--split-mode", b.SplitMode)
if len(b.SplitWeights) > 0 {
args = append(args, "--tensor-split", joinFloats(b.SplitWeights, ","))
} else {
// even split: 1,1,1,...
args = append(args, "--tensor-split", evenSplit(len(b.GPUIDs)))
}
if b.SplitMode == "row" {
args = append(args, "--main-gpu", strconv.Itoa(b.MainGPU))
}
}
buildEnv() produces the comma-separated ROCR_VISIBLE_DEVICES:
if b.TensorSplit {
env = append(env, fmt.Sprintf("ROCR_VISIBLE_DEVICES=%s", joinInts(b.GPUIDs, ",")))
} else {
env = append(env, fmt.Sprintf("ROCR_VISIBLE_DEVICES=%d", b.GPUID))
}
Start() power-limit application loops over GPUIDs instead of single GPUID when multi-GPU. (Each GPU gets its own rocm-smi --setpoweroverdrive ... -d <id> call.)
internal/process/manager.go
NewManager's loop becomes a branch:
if cfg.GPUs.TensorSplit.Enabled {
// single backend spans all devices
devices := cfg.GPUs.ResolvedDevices()
port := cfg.GPUs.BasePort
addr := fmt.Sprintf("localhost:%d", port)
m.Backends = []*Backend{{
GPUID: -1,
GPUIDs: devices,
TensorSplit: true,
SplitMode: cfg.GPUs.TensorSplit.Mode,
SplitWeights: cfg.GPUs.TensorSplit.Weights,
MainGPU: cfg.GPUs.TensorSplit.MainGPU,
ModelPath: cfg.Model.Path,
Port: port,
ContextSize: cfg.Model.ContextSize,
NGPULayers: cfg.Model.NGPULayers,
Parallel: 1, // forced single-slot
Binary: cfg.Backend.Binary,
ExtraArgs: cfg.Backend.ExtraArgs,
HealthTimeout: cfg.Health.Timeout.Duration,
PowerLimitWatts: cfg.GPUs.PowerLimitWatts,
State: balancer.NewBackendState(-1, addr), // gpu_id=-1 means "tensor-split aggregate"
LogWriter: logWriter,
}}
} else {
// existing per-GPU loop, unchanged
for i, gpuID := range devices { ... }
}
internal/balancer/
balancer.BackendState already takes a single int for GPUID. Two options:
- Sentinel value
-1for tensor-split aggregate, treated as "the special backend". Pick logic: if any backend hasGPUID == -1, that's the only choice. Trivial. - Add a
GPUIDs []intfield toBackendStatefor richer reporting. More invasive but exposes the multi-GPU shape to dashboard and/v1/status.
Recommend #1 for the initial cut, #2 as a follow-up when the dashboard work is done.
Implemented. Both, in the end:
GPUIDis the-1sentinel andGPUIDs []intcarries the device list. The sentinel alone turned out to be insufficient once anything had to display a backend — see theX-GPU-Backendnote below.
internal/proxy/handler.go
/v1/modelsshould report the model as available withn_slots: 1(vsn_slots: N*parallelin replica mode). The dashboard currently shows per-GPU rows; in tensor-split mode it should show a single "tensor-split (4 GPUs)" row.- The 429 backpressure path triggers when in-flight ≥ max-in-flight; with single-slot tensor-split, max-in-flight should default to 1 (or a small queue depth).
X-GPU-Backendheader should report something liketensor-split:4-7instead of a single GPU id.
Implemented, as
ts-4,5(matching theprocess.Backend.label()convention already used in logs) viabalancer.BackendState.Label(). This one bit before it was fixed: formattingGPUIDdirectly renderedgpu--1, which is merely unhelpful in a log line but actively broken in the header — a 5×TS=2 fleet returnedgpu--1for all five backends, so no client could attribute a request to a backend. Anything that displays a backend must go throughLabel(), neverGPUID.
internal/peer/
No changes needed for the cross-node mesh — peers see a viiwork node as a single endpoint. The tensor-split node advertises one model with one slot, and peer routing is based on model name, not slot count. But: the mesh latency-aware picker should weigh tensor-split nodes appropriately given they serialize. Easiest fix: bump the latency window or add a "single-slot penalty" multiplier when picking. Defer to a follow-up.
internal/gpu/ and internal/power/
No structural changes. The collectors sample all visible GPUs regardless of how viiwork has them grouped. Dashboard will show all N GPUs as busy when one tensor-split request is in flight, which is correct.
Migration / backward compat
- Configs without
gpus.tensor_splitcontinue to work exactly as before (defaultenabled=false). - Existing
viiwork.yaml,configs/viiwork.gfx906.yaml,configs/viiwork.soak.yamlunchanged. - New
configs/viiwork.tensor-split.yaml.examplechecked in alongside the existing examples. setup-node.shgets a new option: "tensor-split mode for one big model" vs the existing "N replicas of one small model". Both produce a docker-compose plus config.- The MCP server (
cmd/viiwork-mcp) needs no changes — it talks to viiwork's HTTP API, which is unchanged.
Open questions
- Concurrency model. Single-slot tensor-split serializes requests. Should viiwork explicitly queue requests at the proxy layer (return 429 immediately) or let llama-server's internal slot machinery do it (incoming requests block until the slot frees)? Recommend: queue at the proxy with a configurable depth, return
Retry-Afterpast depth — matches existing replica-mode backpressure semantics. - Failure recovery. If the single tensor-split backend dies, there's no failover within the node. Restart attempts already exist (max 3 in
manager.respawnCounts); reuse that path. Mesh peers can pick up the model if they have it. Document this as a known limitation: "tensor-split nodes have no per-node redundancy." - Power limit application. Currently
gpus.power_limit_wattsis one number applied to each GPU. In tensor-split mode it still applies to each of the N GPUs in the group. Confirmed correct, but mention in docs. - Pipelines.
pipeline.PipelineConfigis per-model. In tensor-split mode there's only one model per viiwork process, so pipeline support carries over unchanged. No work needed. - Bench harness. The
bench.shandbench-sustained.shscripts already hit the OpenAI-compatible HTTP API and don't care about backend topology. They will produce meaningful results against tensor-split mode immediately, with the caveat that effective concurrency is 1 instead of N×parallel.
Testing plan
- Unit tests for
config.Validatecovering:tensor_split.enabledrequiresdevicesof length ≥ 2tensor_split.weightslength must matchdeviceslength- default
modeis"layer" parallelis forced to 1 with a logged override
- Unit test for
Backend.buildArgs()andbuildEnv()in tensor-split mode — verify correct flags andROCR_VISIBLE_DEVICES. - Integration test (build tag
integration) using mock httptest backends to confirm Manager only starts one backend in tensor-split mode and the proxy routes there. - Bench validation against the gfx906 fork on the actual rig: load Gemma 4 31B Q4_K_M with
gpus: { devices: [4,5], tensor_split: { enabled: true } }, runbench.sh, confirm tok/s within ±5% of the manualdocker run llama-server --tensor-splitnumbers frommemory/gemma4_31b_results_2026_04_09.md(16.2 tok/s @ N=2 long-prompt).
Estimated implementation cost
- config.go + tests: ~100 LOC
- backend.go + manager.go branching: ~80 LOC
- proxy headers / status / models updates: ~50 LOC
- example config + docs: ~30 LOC
- integration test: ~80 LOC
- Total: ~340 LOC for the minimal viable cut. Mesh-balancer single-slot weighting and dashboard tensor-split-aware row are deferred to follow-ups.
Appendix — what we deliberately are NOT changing
- The
gpus.countsemantics. Replica mode keeps usingcountas before. - The balancer's adaptive mode. With one backend it trivially always picks that one.
- The peer registry's model-based routing. It already aggregates by model name, not by per-GPU slots.
- The dashboard's GPU history rings. They're per-physical-GPU and stay that way.
- The cost / power / IPMI samplers. They're already host-wide, not per-backend.
- The MCP server. It's a thin HTTP client; nothing to change.