tinker-nomics

Tinker Multiplexing

How multi-tenant LoRA multiplexes hundreds of models on shared GPUs — clock cycles, SGMV kernels, and why per-token pricing works.

Why multiplexing matters

The economics of managed fine-tuning rest on one question: can you share GPUs across users without sacrificing quality? Full fine-tuning says no — each user needs a dedicated GPU allocation because the entire model is modified. LoRA says yes — the base model stays frozen and shared, and each user's modifications live in a tiny adapter (a few hundred MB vs tens of GB).

This isn't just a nice optimization. It's the fundamental architectural requirement for per-token pricing. If Tinker had to dedicate GPUs per user, it would charge per GPU-hour (like self-hosted). Because LoRA lets hundreds of users share the same GPU pool, Tinker can charge per token — you pay for compute consumed, not hardware reserved.

Full fine-tune vs multi-tenant LoRA

Qwen3-8B (8.19B params, hidden=4096, 36 layers) · rank 32

Full fine-tune

GPU 0 — User A

~68 GB dedicated

Weights 16 GB + optimizer 32 GB + gradients 16 GB + activations ~4 GB

GPU 1 — User B

~68 GB dedicated

Weights 16 GB + optimizer 32 GB + gradients 16 GB + activations ~4 GB

GPU 2 — User C

~68 GB dedicated

Weights 16 GB + optimizer 32 GB + gradients 16 GB + activations ~4 GB

Multi-tenant LoRA

Base model (frozen)

Qwen3-8B — 16 GB, loaded once

User A

198 MB

User B

198 MB

User C

198 MB

LoRA optimizer states

~1.2 GB total

Only for A, B matrices — not the frozen base

KV cache + activations

~4 GB (shared)

3 users full fine-tune

204 GB total

3 × H100 80GB

vs

3 users multi-tenant LoRA

~21.8 GB total

1 × H100 80GB · 9× smaller

LoRA rank-32 adapter size for Qwen3-8B

Per layer: 7 linear projections (q, k, v, o, gate, up, down) each get rank-32 A + B matrices

36 layers × ~2.74M params/layer = ~99M LoRA params per adapter = 198 MB in bf16

3 users × 198 MB = 0.6 GB of adapters — vs 16 GB base model (83× ratio)

3

The key enabler is LoRA's radical parameter efficiency: a rank-32 adapter for an 8B model is ~198 MB versus ~68 GB for full fine-tuning (weights + optimizer + gradients). Adapter swaps are a sub-second memcpy rather than a minutes-long model reload. Full fine-tuning cannot participate in this scheme because swapping billions of parameters between users would obliterate the throughput gains.

Why LoRA's tiny footprint makes multi-tenancy physically possible

MetricFull fine-tune (Qwen3-8B)LoRA rank-32 (Qwen3-8B)
Adapter / checkpoint size~16 GB (full weights, bf16)~198 MB (A + B matrices)
Training memory per user~68 GB (weights + optim + grads)~16 GB base + 198 MB adapter + 400 MB optim
Swap time between users~25 sec (reload 16 GB)~200 ms (swap 198 MB)
Users per H100 80GB1 (68 GB per user)100s (16 GB shared + ~600 MB/user)
Size ratio (base : adapter)1:1 (every user = full copy)82:1 (16 GB base vs 198 MB adapter)

These are the same Qwen3-8B numbers from the interactive diagram above. The 82:1 ratio between base model and adapter size is what makes multi-tenancy physically possible — swapping 198 MB is a sub-second memcpy, not a minutes-long model reload.

The clock cycle: one heartbeat of multi-tenant training

Tinker assigns training jobs to a shared worker pool — GPUs executing forward-backward operations in lockstep. Each discrete step is a clock cycle. Each cycle may involve multiple LoRA models from different users being trained simultaneously.

Multi-tenant LoRA clock cycle

Step through two full cycles to see how adapters are batched on shared GPUs

Cycle 1 · 1/10
Scatter weightsForwardBackwardGather gradientsOptim step
User A (chatbot LoRA)
User B (code LoRA)
User C (summarizer LoRA)

GPU 0

Base modelUser AUser B

GPU 1

Base modelUser AUser B

GPU 2

Base modelUser AUser B

GPU 3

Base modelUser AUser B
Scatter weights: Users A & B submit jobs. Their rank-32 LoRA adapters (~198 MB each) are scattered to all 4 GPUs. The frozen Qwen3-8B base model (16 GB) is already loaded.

Step time: clock cycles and batch splitting

In each clock cycle, Tinker runs forward_backward() and optim_step() together on the shared GPU pool, potentially for multiple LoRA adapters simultaneously.

We profiled Tinker's step time using the pipelined async pattern across two models and varying batch sizes. The key findings: each clock cycle has a fixed capacity per model pool, large batches get split across multiple cycles, and different models run on separate pools with different cycle times.

ModelB × GTok/stepCycles/stepCycle timeStep time
Qwen3-4B4 × 28K1.04.0s ± 1.6s4.0s ± 1.6s
Qwen3-4B8 × 865K~1.22.9s ± 0.2s3.2s ± 0.6s
Qwen3-4B64 × 161M~6.4~5.2s est.33s ± 4.5s
Qwen3-30B-A3B (MoE, 3B active)4 × 28K1.04.7s ± 0.6s4.7s ± 0.6s
Qwen3-30B-A3B (MoE, 3B active)8 × 865K~1.85.8s ± 2.7s10.7s ± 4.8s

LoRA rank 32, 1024 tokens/completion, pipelined async, 10 steps per config (step 0 excluded as cold start). Cycle time = total wall-clock / total cycles. Source: exp_profile_step (W&B: tinker-nomics).

The cycle time is roughly constant per model but not perfectly fixed. For 4B at moderate batch (8×8), it's a tight 2.9s ± 0.2s. But at larger batch (64×16), the per-cycle time rises to ~5.2s — suggesting cycles do more work when more data is queued. At tiny batch (4×2), only 3 data points and noisier (4.0s ± 1.6s). The cycle isn't a perfectly fixed metronome; it's more like a scheduling quantum that stretches under load.

The 30B-A3B pool is noisier across the board. At small batch (4×2, warmed up), cycle time is 4.7s ± 0.6s. At larger batch (8×8), it jumps to 5.8s ± 2.7s. The first 2–3 steps are especially noisy (up to 15s) — likely cold start as the pool allocates workers. The 30B pool is probably smaller (fewer MoE users), so less statistical smoothing from other workloads. The cycle IDs are in completely different ranges (5.1M vs 3.8M), confirming separate worker pools per model.

The MoE comparison is revealing: Qwen3-30B-A3B has only 3B active params (fewer than 4B dense) but is 3.3x slower at the same batch size. Total params matter — all 30B of expert weights must be loaded even though only 3B fire per token, plus the LoRA adapter spans more linear layers.

The per-cycle variance comes from scheduling overhead, not GPU compute noise: network round-trips (your machine → Tinker API → worker pool → back), batch assembly and partitioning when splitting across cycles, adapter routing in the multi-tenant pool, and LoRA weight sync after each optimizer step. At larger batches the response payload is bigger (1M tokens of results vs 65K), which explains why per-cycle time stretches. This overhead is small relative to compute at realistic batch sizes — the important thing is that step time is stable and predictable (no drift across steps), which is what matters for cost estimation.

For cost prediction: step time ≈ ceil(B × G / capacity) × cycle_time. Profile 3–5 steps at your target batch size to get a reliable estimate, then multiply by total steps.

How the SGMV kernel works

One fused GPU kernel serving three different LoRA adapters simultaneously.

In any given clock cycle, the pool might serve a chatbot fine-tune (47 tokens), a code generation RL rollout (4,096 tokens), and a summarization job (512 tokens) — all at once. The naive approach of launching separate GPU kernels per user wastes parallelism. SGMV (Segmented Gather Matrix-Vector, from the Punica paper) fuses all users' LoRA computations into a single kernel call.

SGMV is not the attention kernel. It operates on the linear projections — the weight matrix multiplies before and after attention (q/k/v/o_proj) plus in the MLP (gate/up/down_proj). That's 7 projections per layer × 36 layers = 252 SGMV calls per forward pass. For each projection, the base model computes Y = x · W as a single shared GEMM across all users' tokens. SGMV then adds the per-user LoRA delta x · A · B.

The computation flows in three stages. First, all users' tokens are flattened into one matrix — batch and sequence dimensions collapsed, with segment indices tracking which adapter each token belongs to. Then SGMV runs in two phases: shrink (project from hidden dim 4,096 down to rank 32 via each user's A matrix) and expand (project back up via B). Each phase is one fused kernel call.

Forward pass: what actually happens

For every linear layer (q/k/v/o projections, gate/up/down MLP — 7 per layer, 252 total), LoRA adds a small correction on top of the frozen base weight. Here's the idea: each user has their own tiny adapter matrices A and B. The base model weight W is shared. The trick is doing all users at once.

Naive: one kernel per user (slow)

for each user i:
  y_i = x_i @ W         # base matmul
  y_i += x_i @ A_i @ B_i  # LoRA fix-up

3 users = 3 separate kernel launches. GPU sits idle between them.

SGMV: all users in one shot (fast)

# stack all tokens, track who owns each row
x   = [user_a_tokens; user_b_tokens; user_c_tokens]
seg = [  A, A, ...,     B, B, ...,     C, C, ...   ]

y = x @ W                # one big shared matmul
y += sgmv(x, seg, As, Bs)  # one kernel, all adapters

1 shared GEMM + 2 fused kernels. Full GPU utilization.

What happens inside sgmv()? Two phases, each a single kernel launch:

def sgmv(x, seg, As, Bs):
    # Phase 1: "shrink" — project down to LoRA rank
    # each user's rows hit THEIR A matrix
    # 4096-dim  →  rank 32  (128x smaller)
    v = zeros(N, rank)
    v[seg == A] = x[seg == A] @ A_a   # user A's 47 tokens × A_a
    v[seg == B] = x[seg == B] @ A_b   # user B's 4096 tokens × A_b
    v[seg == C] = x[seg == C] @ A_c   # user C's 512 tokens × A_c
    # ↑ all happens in ONE kernel call, not three

    # Phase 2: "expand" — project back up to full dim
    # each user's rows hit THEIR B matrix
    # rank 32  →  4096-dim
    delta = zeros(N, hidden)
    delta[seg == A] = v[seg == A] @ B_a
    delta[seg == B] = v[seg == B] @ B_b
    delta[seg == C] = v[seg == C] @ B_c
    # ↑ also ONE kernel call

    return delta   # add this to the base matmul output

That's it. The base matmul x @ W dominates the compute. The LoRA part (shrink + expand) adds only 2 × rank / hidden_dim extra — for rank 32 on a 4096-dim model, that's 1.6% overhead.

This pattern has a name: grouped (or ragged) GEMM

“A batch of matrix multiplies where each group has a different number of rows and a different weight matrix.” The same GPU primitive powers two major use cases:

LoRAeach user's tokens → that user's adapter weights. The segment index is user ID.MoEeach token → its assigned expert's weights. The segment index comes from the router's top-k gate.

Both face the same GPU challenge: groups have ragged shapes (different token counts per adapter or expert), so you can't use one rectangular matmul. Padding wastes compute; serial kernels waste parallelism. The grouped kernel tiles across all groups simultaneously, assigning thread blocks proportional to each group's size. NVIDIA CUTLASS, Punica, and Megablocks all implement this pattern.

User requestsFlatten + batchSGMV kernelUser A4×12 = 47 tokChatbot SFTUser B1×4096 tokCode gen RLUser C2×256 = 512 tokSummarizerx: [4655, 4096]A: rows [0..46] — 47 tokB: rows [47..4142]4,096 tokensC: rows [4143..4654] — 512 toksegments: [0]×47 [1]×4096 [2]×512Each row = 1 token, routed by IDShrink: 4096 → rank 32x_A · A_a → [47, 32]x_B · A_b → [4096, 32]x_C · A_c → [512, 32]Expand: rank 32 → 4096v_A · B_a → [47, 4096]v_B · B_b → [4096, 4096]v_C · B_c → [512, 4096]Y = x·W + x·A·BEach phase = 1 fused kernel call · LoRA overhead ≈ 1.6% of base FLOPs · Runs 252× per fwd pass
Left: three users with different tasks and token counts. Middle: all tokens flattened into one matrix with segment indices. Right: SGMV shrinks to rank 32 then expands back, one fused call per phase. The base model matmul x·W is a separate shared GEMM — SGMV only adds the per-user LoRA delta.

Why RL needs batch-invariant kernels

On-policy RL computes logprobs for the same tokens twice — once during rollout (sampling) and once during the training forward pass. These logprobs must match exactly. If they don't, the policy gradient is computed against stale probabilities, and what should be on-policy RL silently becomes off-policy. As Horace He (Thinking Machines Lab) puts it: “the different numerics between training and inference implicitly turns our on-policy RL into off-policy RL.”

In a multi-tenant pool this is almost guaranteed to happen. The effective batch size is the total flattened token count across all users — and it changes constantly as users join, leave, or have different sequence lengths. The base model ops are where the problem lives: x @ W matmuls switch tile configurations at different batch sizes, RMSNorm flips between data-parallel and split-reduction, and attention changes its KV split count. The result: the same tokens produce bitwise-different logits depending on who else is sharing the GPU.

Notably, the SGMV adapter kernel is less affected — each user's tokens are already isolated by segment index, so the per-user reduction doesn't cross into other users' tokens. The nondeterminism danger is in the shared base model computation that happens alongside SGMV, not in the per-user adapter delta itself.

Nondeterministic inference: batch size changes cause different reduction orders, producing different logits for identical inputs
Standard kernels change their reduction strategy based on batch size — the same tokens produce different logits depending on how many other tokens are in the batch. For RL, this means rollout logprobs and training logprobs silently disagree. From Defeating Nondeterminism in LLM Inference (Horace He, Thinking Machines Lab).

The fix: make the base model ops batch-invariant — a token's output must be identical whether the flattened batch has 47 tokens or 4,655. Thinking Machines Lab's batch_invariant_ops library provides drop-in replacements for torch.mm, torch.addmm, torch.log_softmax, and torch.mean with fixed reduction orders. SGLang v0.4.6 integrated these alongside batch-invariant attention kernels (fixed split-KV size) for deterministic inference.

With batch-invariant kernels, the KL divergence between rollout and training logprobs is a flat line at exactly 0 — true on-policy RL. Without it, KL hovers around ~0.001 with intermittent spikes that correlate with reward collapse. In Thinking Machines' RLVR experiments on BigMath, the non-invariant version hit reward collapse around step 318; the batch-invariant version trained smoothly.

The tradeoff is severe — batch-invariant kernels are significantly slower (~60% overhead), which directly reduces effective FLOPs/second. In practice, it's an open question whether these are used for all workloads or only selectively for on-policy RL where logprob matching is critical. For off-policy methods, SFT, or workloads where exact reproducibility isn't required, standard (faster) kernels would be the pragmatic choice.

Both pieces are in production separately: SGMV-style multi-LoRA kernels ship in vLLM (Punica-derived, Jan 2024) and SGLang (S-LoRA, 2,000 concurrent adapters on one H100). Batch-invariant base ops ship in TML's library and SGLang's deterministic inference mode. Whether Tinker combines both in its multi-tenant training pool is not publicly confirmed — this section describes the likely architecture based on the published literature and blog posts.

The economics: why multi-tenant RL can be cheaper

Co-serving training and inference on the same GPUs is well-established in the literature. The key insight is that the two workloads have complementary resource profiles: inference is memory-bandwidth bound, training is compute bound. FlexLLM (NSDI 2026) showed 1.9–4.8x training throughput improvement and 80% memory reduction by interleaving PEFT fine-tuning with inference on shared weights. LLMStation (ATC 2025) went further with operator-level suspend/resume — pausing backward-pass nodes during latency-sensitive prefill, co-executing during decode, all with 18μs scheduler overhead.

What's new is combining these co-serving techniques with the LoRA multiplexing and batch-invariant kernels described above to build a multi-tenant RL training service that's cheaper than dedicated GPUs. The cost reduction stacks:

LoRA multiplex — amortize the base model

16 GB of base weights loaded once, shared across hundreds of users. Each adapter is ~200 MB. Self-hosted means one full model load per user; a shared pool reduces per-user memory cost by 80–100x.

Co-serve training + inference — fill the idle gaps

RL has natural dead time: waiting for rollouts, weight sync, optimizer steps. Co-serving fills those gaps with inference or another user's training. Dedicated RL utilization: ~30–40%. Shared pool: above 70%.

Statistical multiplexing — users don't all peak at once

Different users are in different RL phases — some generating rollouts (bandwidth-bound), some running backward passes (compute-bound), some idle between steps. The pool smooths these peaks naturally.

Batch-invariant kernels — the price of correctness

Multi-tenant batching only works for RL if logprobs are reproducible regardless of who else is sharing the GPU. The ~60% overhead of batch-invariant kernels is already factored into the utilization numbers above — and the alternative (silent off-policy corruption) is far more expensive.

# Why per-token pricing can be cheaper than renting GPUs

──────────────────────────────────────────────────────

Self-hosted (dedicated H100): 8 GPUs × $3.50/hr × 10 hr = $280

GPU utilization: ~30% (idle during rollout gen, sync, eval)

Effective cost per useful FLOP: $280 / 0.30 = $933 equivalent

Tinker (shared pool): same FLOPs, billed per token

Pool utilization: ~70% (co-serving fills the gaps)

Effective cost per useful FLOP: 2.3x cheaper at same hardware price

──────────────────────────────────────────────────────

The gap widens with more users — statistical multiplexing improves with scale

Sources: Tinker — Under the Hood | Punica (MLSys 2024) | S-LoRA (MLSys 2024) | Defeating Nondeterminism (Thinking Machines) | FlexLLM (NSDI 2026) | LLMStation (USENIX ATC 2025)