Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
title: "Full-parameter fine-tuning goes multi-tenant: slashing accelerator footprint by 67% with llm-d time-slicing"
description: "Demonstrates llm-d time-slicing in a real-world multi-tenant use case: running SFT and full-parameter RL fine-tuning across different base models using OpenRL and the time-slicing stack."
slug: multi-tenant-full-parameter-fine-tuning-time-slicing
date: 2026-09-01T10:00
authors:
- aishu
- sunilarora
tags: [blog, updates, llm-d, rl]
---

# Full-parameter fine-tuning goes multi-tenant: slashing accelerator footprint by 67% with llm-d time-slicing

When we [introduced co-operative time-slicing in llm-d](https://llm-d.ai/blog/rl-post-training-co-operative-time-slicing), we made a claim: if RL phases become schedulable units, independent jobs can share accelerators with near-zero waste. Today we're backing that claim with a measured, end-to-end proof.

[OpenRL](https://opensource.googleblog.com/2026/06/introducing-openrl-a-self-hosted-post-training-api-for-fine-tuning-llms.html), an open source, Kubernetes-native, Tinker-compatible, self-hosted fine-tuning service built on the llm-d time-slicing stack, runs supervised fine-tuning (SFT) and full-parameter reinforcement learning for multiple tenants concurrently on the same GPUs.

<!-- truncate -->

## Key Results at a Glance

By time-slicing three distinct workloads (two RL, one SFT) on different base models, we achieved:

- **67% reduction in hardware footprint:** Cut the required physical GPU count from 6 dedicated GPUs down to just 2 shared GPUs.
- **38% savings in GPU-hours:** Reduced total compute consumption from 2.10 GPU-hours to 1.30 GPU-hours.
- **40% more experiments in the same timeframe:** On the same two GPUs, the three workloads finished in 39 minutes time-sliced versus 54 minutes run one after another, at just three tenants.
- **Over 2x increase in GPU duty cycle:** Boosted average trainer GPU utilization from 15.6% to 34.2%, with ample headroom for more tenants before saturation.
- **Zero quality degradation:** All three workloads achieved identical convergence curves compared to their dedicated-GPU baselines.

This post walks through what the time-slicing platform provides, what a managed RL service (RLaaS) adds, and what the combination measures.

## Enterprise RL & Multi-Tenancy

A post-training API is multi-tenant by definition: one team fine-tunes a customer support assistant on internal tickets, another trains a text-to-SQL agent on proprietary database schemas.

For parameter efficient methods like LoRA, engines like vLLM and PyTorch already support many tenants over one frozen base model. However, full-parameter fine-tuning (FFT) breaks this paradigm. Because every training step can mutate all weights, each tenant requires the whole model, whole optimizer state, and whole GPU.

This has forced enterprises into making expensive choices:

1. Siloed, dedicated GPUs: allocating dedicated GPU capacity to every single tenant. Due to the nature of RL runs these dedicated GPUs may sit idle (30-74%) as RL alternates between generation and training.
2. Queuing : forcing teams to wait in line, slowing the development velocity and time to market.

Llm-d time-slicing eliminates this trade-off by enabling full-parameter customization at a fraction of the hardware cost.

## Case study: OpenRL

The [llm-d time-slicing stack](https://github.com/llm-d-incubation/llm-d-rl-time-slicing) supplies the machinery — the Snapshot Agent that snapshots and restores a worker's state between VRAM and host memory, the TimeSlice Orchestrator that grants tenants exclusive access in turn, and a client library that wraps two RPCs, `acquire()` and `yield()`. What a managed service must add is exactly one thing: knowing where its tenants' phase boundaries are. That turns out to be the easy part.

The API *is* the phase structure. OpenRL exposes [Tinker-style primitives](https://github.com/gke-labs/open-rl) — `generate_samples` for rollouts, `forward_backward` for training, and `optim_step` for the weight update. Every tenant's job arrives pre-decomposed into the schedulable units time-slicing was built around. The service wraps `acquire()` and `yield()` around each work unit, users write an ordinary training loop and get time-sliced multi-tenancy without knowing it exists.

The integration comes down to two moves:

1. **Oversubscribe the hardware.** The OpenRL orchestrator provisions dedicated trainer and sampler workers per tenant and binds multiple tenants' workers to the same physical GPUs using [Kubernetes Dynamic Resource Allocation](https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/). With per-tenant worker processes, tenants can bring different stacks — PyTorch FSDP or Megatron, vLLM or SGLang — to the same shared hardware.
2. **Wrap GPU work units in acquire/yield.** Each worker loop pops a work unit from its tenant queue, calls `acquire()`, runs the work, and calls `yield()`. The orchestrator and Snapshot Agent handle the rest — granting exclusive access in turn and context-switching tenant states. Work that doesn't need the GPU, like persisting checkpoints, runs from the host copy and never takes the lock.

## What we measured

To validate the architecture, we ran a highly heterogeneous, concurrent workload representing typical enterprise workloads:

- Tenant A (Text-to-SQL RL): Fine-tuning Qwen3-1.7B on the Spider dataset.
- Tenant B (Math RL): Fine-tuning Qwen2.5-7B on GSM8K.
- Tenant C (Dialogue SFT): Supervised fine-tuning of Gemma 4 E2B on MultiWOZ.

All three jobs were multiplexed onto a shared pool of two NVIDIA H100 GPUs (one for training, one for sampling).

<div style="text-align:center; margin:20px 0">
<iframe src="/img/blogs/openrl-time-slicing/blog-interleaving.html" title="GPU occupancy animation" scrolling="no" style="width:100%; height:260px; border:0"></iframe>
</div>

**All three tenants converged — matching baseline learning curves.** The text-to-SQL tenant climbed from 25% to 43% in response accuracy, the math RL tenant roughly doubled its eval accuracy, and the dialogue SFT tenant's eval loss fell from 4.77 to 0.56 over 80 steps — a curve point-for-point identical to its baseline run. Overall, time-slicing changed where the jobs ran — not what they learned.

<div style="text-align:center; margin:20px 0">
<img src="/img/blogs/openrl-time-slicing/convergence.webp" alt="Convergence: each tenant's time-sliced curve over its dedicated-GPU baseline" style="width:100%; height:auto" />
</div>

**Higher resource efficiency.** With one dedicated GPU per worker, these three tenants would hold six GPUs — a trainer and sampler each. Given the inherent dependency between trainer and sampler in synchronous RL, those GPUs sit largely idle — gaps the job itself cannot fill. Time-slicing fills them with other tenants' work: the same jobs run on two GPUs, lifting duty cycles roughly 2x compared with their dedicated-GPU runs.

<div style="text-align:center; margin:20px 0">
<img src="/img/blogs/openrl-time-slicing/duty-cycle.webp" alt="Duty cycle: six dedicated baseline GPUs vs two time-sliced GPUs" style="width:100%; height:auto" />
</div>

**Minimal system overhead.** Context switches cost 0.5–1.6 seconds (median). Queue waits per phase run 1–15 seconds (median) — highest for the SFT tenant, whose short steps queue behind the RL tenants' longer phases.

<div style="text-align:center; margin:20px 0">
<img src="/img/blogs/openrl-time-slicing/switch-wait.webp" alt="Context-switch time and queue wait per phase" style="width:100%; height:auto" />
</div>

The full comparison:

| | GPUs provisioned | Workload run time | GPU-hours |
|---|---|---|---|
| Dedicated, all concurrent | 6 | 21 min | 2.10 |
| Shared, jobs run serially | 2 | 54 min | 1.80 |
| **Time-sliced** | **2** | **39 min** | **1.30** |

*Small demonstration runs like these are time-slicing's hardest case: at production scale, phases run for minutes, switch costs amortize toward zero, and the reclaimable idle only grows.*

- **Versus six dedicated GPUs** — time-slicing provisions two-thirds fewer GPUs and pays for ~38% less GPU time.
- **Versus two GPUs running jobs serially** — identical hardware, but time-slicing finishes the workload ~28% sooner, as it reclaims idle windows.

The trade is per-tenant wall-clock time in favor of accelerator efficiency: an individual job runs 1.9–2.5x longer than it would alone, and the three workloads collectively ran only 1.9x slower even though the GPU count fell 3x; the difference is the reclaimed idle time.

## What's next

We're rolling out a selective state offload backend on Snapshot Agent that allows snapshotting specific GPU memory regions — LoRA adapter weights, accumulated gradients, optimizer state — while the shared base model stays resident. One interface now does it all: whole-process parking for full-parameter tenants, region-level parking for LoRA tenants. In early testing, offloading LoRA adapters through this backend delivered a 2.8x tenant-density gain on a single NVIDIA L4 trainer GPU.

**Get Started Today**

If you are building or scaling a multi-tenant post-training platform, you can start getting efficiency gains today:

- Deploy the [time-slicing stack](https://github.com/llm-d-incubation/llm-d-rl-time-slicing) today
- Run [OpenRL](https://github.com/gke-labs/open-rl) and get this integration out of the box.

**Acknowledgements**

Thanks to [Edwin Hernandez](https://github.com/Edwinhr716), [Jessica Chen](https://github.com/jessicaochen), [Ling Lin](https://github.com/lynnl0927) and [Shuby Mishra](https://github.com/ShubyM) for bringing this to life.
13 changes: 11 additions & 2 deletions blog/authors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,11 @@ bogdan:

aishu:
name: Aishu Kamal
title: Software Engineer, Google
title: Staff Software Engineer, Google
image_url: https://github.com/aishukamal.png
socials:
linkedin: https://www.linkedin.com/in/aishwaryakamal
github: https://github.com/aishukamal

dolev:
name: Dolev Ish Am
Expand Down Expand Up @@ -590,4 +591,12 @@ revitale:
name: Revital Eres
title: Software Engineer at, IBM
url: https://github.com/revit13
image_url: https://avatars.githubusercontent.com/u/16211463?v=4
image_url: https://avatars.githubusercontent.com/u/16211463?v=4

sunilarora:
name: Sunil Arora
title: Software Engineer, Google
image_url: https://github.com/droot.png
socials:
linkedin: https://www.linkedin.com/in/sunilarora101/
github: https://github.com/droot
190 changes: 190 additions & 0 deletions static/img/blogs/openrl-time-slicing/blog-interleaving.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Interleaving — blog embed</title>
<style>
:root {
color-scheme: light;
--surface-1: #fcfcfb;
--text-secondary: #52514e;
--grid: #e1e0d9;
--tenant-a: #2a78d6;
--tenant-b: #eb6834;
--tenant-c: #1baf7a;
--idle-track: #f0efec;
}
html, body { margin: 0; padding: 0; background: var(--surface-1); }
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; overflow: hidden; }
.wrap { margin: 0 auto; padding: 10px 12px 6px; }
.hdr { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 4px 10px; margin: 0 0 10px; }
.hdr h1 { font-size: 12px; font-weight: 600; color: #0b0b0b; margin: 0; }
.legend { display: flex; gap: 4px 14px; align-items: center; flex-wrap: wrap; font-size: 11px; color: var(--text-secondary); }
.legend .key { display: inline-flex; align-items: center; gap: 6px; }
.legend .sw { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }
canvas { display: block; }
/* compact header on narrow screens so the fixed-height embed never clips */
@media (max-width: 600px) {
.hdr h1 { display: none; }
.legend { font-size: 10px; gap: 3px 10px; }
.hdr { margin: 0 0 6px; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="hdr">
<h1>GPU occupancy</h1>
<div class="legend">
<span class="key"><span class="sw" style="background:var(--tenant-a)"></span>Tenant A — text-to-SQL RL</span>
<span class="key"><span class="sw" style="background:var(--tenant-b)"></span>Tenant B — math RL</span>
<span class="key"><span class="sw" style="background:var(--tenant-c)"></span>Tenant C — dialogue SFT</span>
</div>
</div>
<canvas id="cv"></canvas>
</div>

<script src="data.js"></script>
<script>
// Minimal blog embed: two GPU lanes, tenant holds sweeping with the playhead.
// One full window -> one 40 s loop (38.5 s sweep + 1.5 s hold on the completed
// frame, then instant restart). Canvas sizes to the container width so the
// embed works at any iframe width.
// Deterministic: content is a pure function of the loop phase and RUN_DATA.
const TCOL = { A: '#2a78d6', B: '#eb6834', C: '#1baf7a' };
const INK = '#0b0b0b', SEC = '#52514e', MUT = '#898781', GRID = '#e1e0d9',
IDLE = '#f0efec', SURF = '#fcfcfb';
const H100_MIB = 81559;
const MAXW = RUN_DATA.windowSec;

const LOOP = 40, HOLD = 1.5, SWEEP = LOOP - HOLD; // exact 40 s loop
const PADL = 8, PADR = 8;
const LANE_LABEL_H = 17, BLOCK_H = 26, VRAM_H = 16, LANE_GAP = 14, AXIS_H = 24;
const H = RUN_DATA.lanes.length * (LANE_LABEL_H + BLOCK_H + VRAM_H + LANE_GAP) + AXIS_H;

const LANE_NAMES = ['Trainer GPU', 'Sampler GPU'];
const laneData = RUN_DATA.lanes.map((L, i) => ({
label: LANE_NAMES[i] || L.label,
holds: RUN_DATA.holds.filter(h => h.gpu === L.gpu).sort((a, b) => a.t0 - b.t0),
fb: RUN_DATA.dcgm[L.gpu] || null,
}));

const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const wrap = document.querySelector('.wrap');

let W = 760, plotW = W - PADL - PADR;
function sizeCanvas() {
W = Math.max(300, wrap.clientWidth - 24); // minus .wrap padding
plotW = W - PADL - PADR;
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
cv.style.width = W + 'px'; cv.style.height = H + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
sizeCanvas();
window.addEventListener('resize', () => { sizeCanvas(); if (fixedT !== null) draw(fixedT); });

const t2x = t => PADL + t / MAXW * plotW;

function rr(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}

function draw(T) {
ctx.fillStyle = SURF; ctx.fillRect(0, 0, W, H);
let y = 0;
for (const L of laneData) {
ctx.fillStyle = SEC;
ctx.font = '600 11px system-ui, sans-serif';
ctx.fillText(L.label, PADL, y + 11);
y += LANE_LABEL_H;

// idle track up to the playhead
const ix1 = Math.min(t2x(Math.min(MAXW, T)), PADL + plotW);
if (ix1 > PADL) { ctx.fillStyle = IDLE; rr(PADL, y + 7, ix1 - PADL, BLOCK_H - 14, 3); ctx.fill(); }

for (const h of L.holds) {
const t1 = Math.min(h.t1, T);
if (t1 <= h.t0) continue;
const x0 = Math.max(t2x(h.t0), PADL);
const x1 = Math.min(t2x(t1), PADL + plotW);
const bw = Math.max(x1 - x0, 0.75);
ctx.fillStyle = TCOL[h.tenant];
rr(x0, y, bw, BLOCK_H, Math.min(3, bw / 2)); ctx.fill();
if (bw > 26) {
ctx.fillStyle = '#ffffff';
ctx.font = '600 10px system-ui, sans-serif';
ctx.fillText(h.tenant, x0 + bw / 2 - 3, y + BLOCK_H / 2 + 3.5);
}
}
y += BLOCK_H;

// VRAM underlay (DCGM FB_USED, 0-80 GiB), revealed with the playhead
if (L.fb) {
const baseY = y + VRAM_H;
ctx.beginPath();
let started = false, lastX = null;
for (let i = 0; i < L.fb.t.length; i++) {
const t = L.fb.t[i];
if (t > T) break;
const x = Math.min(Math.max(t2x(t), PADL), PADL + plotW);
const vy = baseY - (L.fb.fb[i] / H100_MIB) * VRAM_H;
if (!started) { ctx.moveTo(x, baseY); ctx.lineTo(x, vy); started = true; }
else ctx.lineTo(x, vy);
lastX = x;
}
if (started) {
ctx.lineTo(lastX, baseY); ctx.closePath();
ctx.fillStyle = 'rgba(82,81,78,0.18)'; ctx.fill();
}
ctx.strokeStyle = GRID; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PADL, baseY + 0.5); ctx.lineTo(PADL + plotW, baseY + 0.5); ctx.stroke();
}
y += VRAM_H + LANE_GAP;
}

// minutes axis
ctx.fillStyle = MUT; ctx.font = '10.5px system-ui, sans-serif';
ctx.strokeStyle = GRID;
for (let m = 0; m <= Math.floor(MAXW / 60); m += 5) {
const x = t2x(m * 60);
ctx.beginPath(); ctx.moveTo(x + 0.5, y - 2); ctx.lineTo(x + 0.5, y + 2); ctx.stroke();
const lab = m + ' min';
const tw = ctx.measureText(lab).width;
if (x + tw / 2 < PADL + plotW && x - tw / 2 > 0) ctx.fillText(lab, x - tw / 2, y + 14);
else if (m === 0) ctx.fillText(lab, PADL, y + 14);
}

// playhead
const px = t2x(Math.min(T, MAXW));
if (T < MAXW) {
ctx.strokeStyle = INK; ctx.globalAlpha = 0.45; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px + 0.5, 0); ctx.lineTo(px + 0.5, y - 4); ctx.stroke();
ctx.globalAlpha = 1;
}
}

// fixed playhead override for deterministic frame grabs (?t=seconds)
const q = new URLSearchParams(location.search);
const fixedT = q.has('t') ? Math.max(0, +q.get('t') || 0) : null;

let t0 = null;
function frame(ts) {
if (fixedT !== null) { draw(fixedT); return; }
if (t0 === null) t0 = ts;
const phase = ((ts - t0) / 1000) % LOOP;
draw(Math.min(phase / SWEEP, 1) * MAXW);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions static/img/blogs/openrl-time-slicing/data.js

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading