LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnSystem Design CapstonesMulti-Tenant LLM Platform
🏗️HardSystem Design

Multi-Tenant LLM Platform

Design a shared LLM platform with tenant-scoped state, quota enforcement, adapter routing, KV accounting, and measured GPU utilization.

39 min read
Learning path
Step 169 of 196 in the full curriculum
Code Completion SystemLLM-Powered Search Engine

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

At 09:00, one hundred workspaces send requests to an internal-doc assistant. By 09:04, Tenant A's long design review has filled the KV cache, Tenant B's incident summary is waiting, and the GPU dashboard says 60% utilization. A trace then shows a worse symptom: Tenant B's retrieved context includes a private postmortem from Tenant A.

Pause on the diagnosis. Should you add GPUs because users are waiting? Not yet. Low utilization beside a growing queue points first to admission, scheduling, or memory shape. The cross-tenant context is a separate authorization failure. More capacity won't repair either boundary.

Code completion gave you one product surface: one developer, one editor context, one low-latency serving path. A multi-tenant large language model (LLM) platform puts that path on shared GPU capacity while keeping workspace identity attached to prompts, adapters, caches, retrieval, and bills.

We'll follow Tenant A's request through that platform. Three earlier ideas supply the machinery: an LLM generates one token at a time, the KV cache stores intermediate attention state, and batching lets one resident model serve several requests in shared steps.

Side-by-side split of a multi-tenant serving fleet. Shared once: one frozen 72B FP16 base (144 GB), CUDA kernels, and the H100 worker pool. Tenant-scoped: Tenant A KV pages P0 P2 P4, adapter tenant-a/docs-v2, retrieval namespace A, and GPU-second metering. Sharing weights doesn't share prompts, adapters, cache, or bills.
The cheap move is sharing one 144 GB base copy. The hard move is keeping Tenant A's KV pages, adapter, retrieval namespace, and bill from ever becoming Tenant B's.

What is the central tension in a multi-tenant LLM platform?

Answer

The platform shares selected capacity to reduce duplicated cost, while tenant-scoped state, scheduler policy, measured latency objectives, and metering keep shared execution accountable.

Before choosing an allocator or queue, define what “healthy” means for each tenant. For request rrr, measure first-token delay from arrival and each gap after the first token:

TTFTr=tfirst token,r−tarrival,r,ITLr,i=tr,i−tr,i−1\mathrm{TTFT}_r = t_{\mathrm{first\ token},r} - t_{\mathrm{arrival},r}, \qquad \mathrm{ITL}_{r,i} = t_{r,i} - t_{r,i-1}TTFTr​=tfirst token,r​−tarrival,r​,ITLr,i​=tr,i​−tr,i−1​

An illustrative service contract might require p95(TTFT) <= 800 ms and p95(ITL) <= 120 ms for an interactive tier. Those are scenario inputs, not universal targets. A request counts as goodput only when it completes successfully and meets the tier's latency gates. Queue wait, prefill, decode, adapter loading, and network transfer all belong in the trace, or a passing GPU metric can hide a failing user experience.

Now we have a decision rule for the incident: fix the term that moved the percentile, and don't call a hardware specification a measured result. The sections below attach that rule to each shared state boundary.

Why shared capacity needs hard boundaries

Start with the first admission question: can the base model fit? In this scenario, a dense 72-billion-parameter model stored in FP16 needs about 144 GB in decimal units for weights alone. NVIDIA lists 80 GB of HBM3 for one H100 SXM configuration.[1]Reference 1H100 GPUhttps://www.nvidia.com/en-us/data-center/h100/ One copy therefore needs more than one such GPU before KV cache, activations, or runtime work are counted.

If one hundred tenants each received a full copy, weights alone would consume 14.4 TB. Predict what sharing changes: it removes duplicate weight storage, but it says nothing about who may read a prompt, adapter, KV block, retrieval result, or usage record.

Run the arithmetic before we choose a scheduler:

shared-weight-capacity.py
1def weight_storage_gb(parameters_billions: int, bytes_per_parameter: int) -> float: 2 return parameters_billions * bytes_per_parameter 3 4base_weight_gb = weight_storage_gb(parameters_billions=72, bytes_per_parameter=2) 5per_tenant_weight_tb = base_weight_gb * 100 / 1000 6 7assert base_weight_gb == 144 8assert per_tenant_weight_tb == 14.4 9print("one_fp16_weight_copy_gb:", base_weight_gb) 10print("one_hundred_copies_tb:", per_tenant_weight_tb)
Output
1one_fp16_weight_copy_gb: 144 2one_hundred_copies_tb: 14.4

Sharing introduces three concrete engineering tensions:

  1. Compute contention. All tenants want the GPU's CUDA cores during deploy windows, incidents, and review bursts.
  2. Memory contention. Every active conversation consumes KV cache memory. A tenant with a long design-review transcript can evict another tenant's chat if limits aren't enforced.
  3. Weight customization. Tenants want different behaviors. One needs terse API-reference answers; another needs incident-triage summaries. Loading a full model copy per customization wastes capacity, so we need scoped lightweight adapters or separate pools where required.

Why is "one model copy per tenant" usually impractical for a large serving fleet?

Answer

Large dense models can require more memory than one GPU just for weights. Sharing base weights reduces duplication, but each shared-state mechanism still needs an explicit authorization and accounting boundary.

Weight sharing solves one cost problem. It doesn't make an inference engine a tenant boundary. Serving engines such as vLLM and SGLang load weights, page KV, and run kernels. The surrounding control plane authenticates the tenant, enforces quotas, chooses an adapter, and meters GPU-time around that engine.

Predict what happens if tenant_id disappears after the gateway. A Tenant A docs request still reaches a valid worker, but its cache, adapter, or bill can now be attributed to the wrong workspace. Keep identity on every hop:

Diagram showing tenant-a request, Gateway auth, RPM, TPM, Scheduler queues + KV caps, and GPU worker shared base.
tenant-a request, Gateway auth, RPM, TPM, Scheduler queues + KV caps, and GPU worker shared base.

If any hop drops tenant_id, you don't have a platform. You have a shared GPU with a leak surface.

How we pack requests together: continuous batching

When a GPU processes a batch, one serving step applies resident weights across several requests. Suppose Tenant A's answer needs 10 output tokens and Tenant B's needs 500. A static batch holds both until B finishes, so A's slot has nothing useful to do for most of the run.

Predict the next step: once A emits its end-of-sequence token, should the scheduler wait for B or admit a queued request? Continuous batching, also called in-flight batching and described in the Orca paper,[2]Reference 2Orca: A Distributed Serving System for Transformer-Based Generative Models.https://www.usenix.org/conference/osdi22/presentation/yu replaces completed work at iteration boundaries. The empty slot can take Tenant C while B keeps decoding.

That improves utilization only when admission and scheduling keep the mix healthy. Prompt length, output length, queue policy, and scheduler overhead still determine throughput. A finished request need not occupy a decode slot, but a tenant can still monopolize slots with long prefills or large KV allocations.

The scheduler also has to meet tiered latency objectives. If an enterprise tenant has a tighter p95 TTFT or ITL target, packing more tokens is useful only while that percentile stays inside its contract. Rate limits and preemption provide the next controls.

The toy schedule below makes the slot transition visible. Tenant A has one token left, Tenant B has three, and Tenant C is waiting. What should remain active after one iteration?

continuous-batch-slots.py
1from collections import deque 2 3active = {"tenant-a": 1, "tenant-b": 3} 4waiting = deque([("tenant-c", 2)]) 5 6for tenant in list(active): 7 active[tenant] -= 1 8 if active[tenant] == 0: 9 del active[tenant] 10 admitted, remaining_tokens = waiting.popleft() 11 active[admitted] = remaining_tokens 12 13assert active == {"tenant-b": 2, "tenant-c": 2} 14print("active_after_iteration:", active)
Output
1active_after_iteration: {'tenant-b': 2, 'tenant-c': 2}

Why does continuous batching need tenant awareness?

Answer

Pure throughput scheduling can let one tenant dominate decode slots or KV memory. Tenant-aware batching keeps the GPU full while honoring priority tiers, quotas, and latency SLOs.

If you add speculative decoding to a shared pool, the draft path has to be authorized for that tenant and compatible with the target model, tokenizer, and adapter. A faster wrong token is still a wrong token. A draft that reused another tenant's prefix is an isolation bug, not a cache win.

The slot is now doing useful work for C. The next question is what C should customize without forcing a second 144 GB base copy.

How we customize behavior without duplicating weights: LoRA adapters

Tenant A wants terse API answers and Tenant B wants incident summaries. Predict the cheapest change: duplicate the 144 GB base or attach a smaller request-specific update? LoRA (Low-Rank Adaptation[3]Reference 3LoRA: Low-Rank Adaptation of Large Language Models.https://arxiv.org/abs/2106.09685) learns low-rank matrices beside selected original weight layers. The base stays fixed while the selected adapter changes the projection for that request.

An adapter isn't one universal size. Rank, target modules, model dimensions, and dtype determine its footprint. It's usually far smaller than a base copy, but the worker still has to budget adapter residency beside KV blocks. Measure that footprint for the model and ranks you actually serve.

The S-LoRA (Serving Thousands of Concurrent LoRA Adapters) system[4]Reference 4S-LoRA: Serving Thousands of Concurrent LoRA Adapters.https://arxiv.org/abs/2311.03285 studies how to keep many adapters available while sharing base weights. Caching alone doesn't solve the problem. A multi-LoRA runtime also needs to map each row in a batch to the right adapter while preserving the common base path.[5]Reference 5LoRA Adaptershttps://docs.vllm.ai/en/stable/features/lora/

Punica's Segmented Gather Matrix-Vector multiplication (SGMV) targets batched LoRA requests with mixed adapters.[6]Reference 6Punica: Multi-Tenant LoRA Servinghttps://arxiv.org/abs/2310.18547 vLLM exposes related controls: max_loras limits resident adapters and --max-lora-rank sizes the largest supported rank.[5]Reference 5LoRA Adaptershttps://docs.vllm.ai/en/stable/features/lora/ Setting a very high rank cap for small adapters can reserve workspace you never use. Benchmark the actual adapter mix and hardware before turning a paper's concurrency result into a fleet plan. Runtime adapter loading (VLLM_ALLOW_RUNTIME_LORA_UPDATING) is documented as a fully trusted, isolated-environment feature, not a public tenant API.

Same 144 GB base, different adapters Tenant A loads docs-v2 for terse API answers. Tenant B loads incident-v7 for triage summaries. The frozen 72B weights stay shared. The adapter is small, but residency on the GPU still isn't a permission grant.

The platform can keep versioned adapters in an object-storage registry such as S3 (Simple Storage Service), then load an authorized one into GPU memory on demand. Frequently used adapters stay in an LRU cache; cold entries can move to host RAM or disk. Residency is a performance decision, never an authorization decision.

What does LoRA share and what does it customize?

Answer

The frozen base model weights are shared across tenants. Small adapter matrices customize tone, domain behavior, or tenant policy without loading a separate full model per tenant.

Two-slot LoRA cache after Tenant C arrives: B/incident-v7 and C/code-review-v1 stay hot, A/docs-v2 was evicted. Tenant A then requests code-review-v1 and is rejected. Cache residency is not authorization.
After C loads, the two-slot LRU holds B and C. Tenant A asking for code-review-v1 still fails the auth check. A hot adapter on the GPU is not a permission grant.

The small lifecycle below checks authorization before it looks in the cache. Predict the last three lines: Tenant C should evict the least-recently-used entry, and Tenant A's request for C's adapter should fail even when that adapter is hot. Multi-LoRA runtimes can share base-model steps across adapters, but lookup, residency, and mixed-adapter execution still have measurable cost, so benchmark the actual mix:

lora-adapter-routing.py
1from collections import OrderedDict 2from dataclasses import dataclass 3 4@dataclass 5class Request: 6 tenant_id: str 7 requested_adapter: str 8 prompt: str 9 10@dataclass 11class Response: 12 text: str 13 14@dataclass 15class AdapterWeights: 16 adapter_id: str 17 18class AdapterStore: 19 def download(self, adapter_id: str) -> AdapterWeights: 20 print(f"Loading adapter {adapter_id} into GPU cache") 21 return AdapterWeights(adapter_id) 22 23class BaseModel: 24 def generate(self, request: Request, adapter: AdapterWeights) -> Response: 25 token_count = len(request.prompt.split()) 26 return Response( 27 f"tenant={request.tenant_id} adapter={adapter.adapter_id} " 28 f"prompt_tokens={token_count}" 29 ) 30 31class LoRAAdapterManager: 32 """Routes only authorized adapters on a shared base model.""" 33 def __init__(self, max_hot_adapters: int = 2): 34 self.base_model = BaseModel() 35 self.adapter_cache: OrderedDict[str, AdapterWeights] = OrderedDict() 36 self.adapter_store = AdapterStore() 37 self.max_hot_adapters = max_hot_adapters 38 self.authorized_adapter = { 39 "tenant-a": "docs-v2", 40 "tenant-b": "incident-v7", 41 "tenant-c": "code-review-v1", 42 } 43 44 def serve(self, request: Request) -> Response: 45 if self.authorized_adapter.get(request.tenant_id) != request.requested_adapter: 46 raise PermissionError("adapter is not authorized for tenant") 47 adapter_id = f"{request.tenant_id}/{request.requested_adapter}" 48 49 if adapter_id not in self.adapter_cache: 50 if len(self.adapter_cache) >= self.max_hot_adapters: 51 evicted_id, _ = self.adapter_cache.popitem(last=False) 52 print(f"Evicting adapter {evicted_id}") 53 adapter_weights = self.adapter_store.download(adapter_id) 54 self.adapter_cache[adapter_id] = adapter_weights 55 56 self.adapter_cache.move_to_end(adapter_id) 57 return self.base_model.generate(request, self.adapter_cache[adapter_id]) 58 59manager = LoRAAdapterManager(max_hot_adapters=2) 60for request in [ 61 Request("tenant-a", "docs-v2", "draft a migration note"), 62 Request("tenant-b", "incident-v7", "summarize the outage timeline"), 63 Request("tenant-c", "code-review-v1", "review this auth diff quickly"), 64]: 65 response = manager.serve(request) 66 67print(response.text) 68print("hot adapters:", list(manager.adapter_cache)) 69try: 70 manager.serve(Request("tenant-a", "code-review-v1", "use another policy")) 71except PermissionError as error: 72 print("blocked:", error)
Output
1Loading adapter tenant-a/docs-v2 into GPU cache 2Loading adapter tenant-b/incident-v7 into GPU cache 3Evicting adapter tenant-a/docs-v2 4Loading adapter tenant-c/code-review-v1 into GPU cache 5tenant=tenant-c adapter=tenant-c/code-review-v1 prompt_tokens=5 6hot adapters: ['tenant-b/incident-v7', 'tenant-c/code-review-v1'] 7blocked: adapter is not authorized for tenant

The output shows both decisions: LRU controls residency, while the authorization map controls use. If Tenant A and Tenant B alternate on every request, object-storage loads and evictions can thrash the adapter cache and raise TTFT. Profile reuse before assuming LRU is enough; pin high-tier adapters only when the measured latency benefit pays for their reserved memory.

Adapters customize behavior. They don't separate conversation state. That boundary is the KV budget.

Why can an adapter cache become a latency problem?

Answer

If hot tenants alternate and the GPU adapter cache is too small, every request triggers an adapter load or eviction. Pin high-service-level-agreement (SLA) adapters or schedule with adapter locality to avoid cache thrash.

How we keep conversations separate: KV cache isolation

Every generated token reads key and value vectors from earlier tokens. The KV cache keeps that work so autoregressive decoding doesn't recompute the whole history. That cache is tenant-sensitive state: a block table or cache entry owned by Tenant A must never become visible to Tenant B.

The memory cost in concrete numbers

KV-cache memory grows with context length, so use it as an admission input rather than an after-the-fact alert. For a decoder with fixed-width KV heads, a useful first-order estimate is:

KV memory per request=2×nlayers×nkv_heads×dhead×seq_len×dtype_bytes\begin{aligned} \text{KV memory per request} = &2 \times n_{\text{layers}} \times n_{\text{kv\_heads}} \times d_{\text{head}} \\ &\times \text{seq\_len} \times \text{dtype\_bytes} \end{aligned}KV memory per request=​2×nlayers​×nkv_heads​×dhead​×seq_len×dtype_bytes​

The leading 2 counts keys and values. The remaining terms are model depth, KV heads, head dimension, context length, and bytes per element. Grouped-query attention (GQA) stores fewer KV heads than attention heads, which is why n_kv_heads sets the memory slope.

Now make the estimate concrete. Suppose a model has 80 layers, 8 KV heads, head dimension 128, a 4,000-token context, and FP16 (2 bytes per element). Before calculating, predict what happens if the model uses 16 KV heads: every other term stays fixed, so the KV footprint doubles.

2×80×8×128×4,000×2=1,310,720,000 bytes≈1.22 GiB per request2 \times 80 \times 8 \times 128 \times 4{,}000 \times 2 = 1{,}310{,}720{,}000 \text{ bytes} \approx 1.22 \text{ GiB per request}2×80×8×128×4,000×2=1,310,720,000 bytes≈1.22 GiB per request

Sixteen KV heads doubles the result to about 2.44 GiB. At high concurrency, that per-request state can become the admission constraint before compute throughput does.

kv-cache-budget.py
1def kv_gib(layers: int, kv_heads: int, head_dim: int, tokens: int, dtype_bytes: int = 2) -> float: 2 bytes_used = 2 * layers * kv_heads * head_dim * tokens * dtype_bytes 3 return bytes_used / (1024 ** 3) 4 5request_gib = kv_gib(layers=80, kv_heads=8, head_dim=128, tokens=4_000) 6double_heads_gib = kv_gib(layers=80, kv_heads=16, head_dim=128, tokens=4_000) 7 8assert round(request_gib, 2) == 1.22 9assert round(double_heads_gib, 2) == 2.44 10print("eight_kv_heads_gib:", round(request_gib, 2)) 11print("sixteen_kv_heads_gib:", round(double_heads_gib, 2))
Output
1eight_kv_heads_gib: 1.22 2sixteen_kv_heads_gib: 2.44

Why is KV cache both a capacity problem and a privacy boundary?

Answer

It grows with active context and can dominate VRAM. It also stores conversation-derived state, so allocation or cache-namespace bugs can violate isolation.

PagedAttention: paging for GPUs

Suppose Tenant A's 47-token conversation and Tenant B's 31-token conversation arrive together. If each request reserves one contiguous maximum-length region, holes appear as soon as their lengths diverge. PagedAttention, introduced in the vLLM paper,[7]Reference 7Efficient Memory Management for Large Language Model Serving with PagedAttentionhttps://arxiv.org/abs/2309.06180 instead stores KV in blocks and maps each logical sequence to physical blocks. The figure uses 16-token blocks to make that mapping visible; production block size remains a runtime and performance choice.

Tenant A occupies three physical blocks and Tenant B occupies two. Paging makes those blocks packable, but it doesn't grant access. The serving layer still owns request-to-table authorization, released-reference invalidation, and any memory-clearing policy required by its threat model.

PagedAttention mapping for 16-token blocks. Tenant A’s 47-token sequence occupies logical blocks 0-15, 16-31, and 32-46, stored in physical pages P0, P2, and P4. Tenant B’s 31-token sequence occupies 0-15 and 16-30 in P1 and P3. Packing reuses non-contiguous pages; tenant-scoped table access and page-release policy stay outside the allocator.
A's 47 tokens land in P0, P2, and P4. B's 31 tokens land in P1 and P3. Paging packs the pool. The block table, not the allocator, is what keeps A from reading B.

HBM is the hottest KV tier, not the only possible tier

Keep active decode blocks in local GPU high-bandwidth memory (HBM) when latency matters. That pool is scarce, though. Paused or reusable blocks can move to CPU DRAM or a remote cache, and Mooncake studies a disaggregated design using CPU, DRAM, SSD, and remote direct memory access (RDMA) resources independently of one inference worker.[8]Reference 8Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving.https://arxiv.org/abs/2407.00079

Predict the tradeoff before reading the tiers: a remote hit saves local recomputation only if transfer and lookup fit the deadline. Disaggregation moves the bottleneck rather than deleting it:

PlacementUseful forCost paid
GPU HBMActive decode and latency-critical prefixesScarce accelerator memory
Local CPU DRAMPaused sequences or overflow near one workerHost-to-device transfer latency and bandwidth
Remote KV over an RDMA-capable fabricReuse across workers, prefill/decode separation, larger aggregate cacheNetwork transfer, cache lookup, replication, and failure handling

The scheduler should compare measured transfer time with local recomputation. A remote hit can lose for a short prompt or a congested link. Token count alone isn't enough; admission needs byte-level KV budgets, measured bandwidth, and a deadline check.

Tenant isolation follows the blocks off GPU. Remote keys must include the trust scope plus model, adapter, tokenizer, and KV layout version. The cache service must authorize reads before returning block metadata, encrypt traffic as required by the threat model, and invalidate ownership when a request or tenant is deleted. RDMA reduces CPU involvement in data movement; it doesn't provide application-level authorization by itself.

When should the scheduler restore remote KV instead of recomputing a prefix?

Answer

The scheduler should restore it only when the predicted lookup and transfer time fits the request deadline and is lower than the recomputation cost. Include network congestion, prefix size, compatible model state, and tenant authorization in the decision.

Prefix caching and the cross-tenant leak risk

Multi-tenant traffic often repeats a system prompt, tool schema, or retrieved prefix. Prefix caching can reuse those KV blocks and skip repeated prefill work. SGLang's RadixAttention uses a prefix tree, while vLLM's automatic prefix caching hashes blocks rather than using a radix tree.[9]Reference 9SGLang: Efficient Execution of Structured Language Model Programshttps://arxiv.org/abs/2312.07104[10]Reference 10Automatic Prefix Cachinghttps://docs.vllm.ai/en/stable/design/prefix_caching/

Ask what a hit can improve. It mainly lowers TTFT (Time to First Token) by removing repeated prefill. It doesn't make decode itself cheaper.

Now ask whether identical prompt text proves identical state. It doesn't. vLLM's block hash includes extra values such as LoRA IDs, and its optional cache_salt on the first block limits reuse to requests in the same trust group, reducing timing-based probing.[10]Reference 10Automatic Prefix Cachinghttps://docs.vllm.ai/en/stable/design/prefix_caching/ Routing still has to enforce compatible model, tokenizer, adapter, and policy context.

Private-prefix reuse without a tenant or approved trust-group namespace is an isolation bug, even when the intended change was a TTFT optimization. Scope cache reuse first, then measure its latency benefit.

What must be included in a safe prefix-cache namespace?

Answer

Tenant or explicit shared trust-group identity plus compatible base model, adapter, and tokenizer configuration. Public shared prompts need a deliberate shared namespace, not accidental reuse.

tenant-scoped-prefix-cache.py
1def cache_key(trust_group: str, model: str, adapter: str, tokenizer: str, prefix: str) -> tuple[str, ...]: 2 return trust_group, model, adapter, tokenizer, prefix 3 4prompt = "You are the internal docs assistant." 5cache = { 6 cache_key("tenant:tenant-a", "base-v3", "docs-v2", "tok-v3", prompt): "kv-7" 7} 8 9same_tenant = cache_key("tenant:tenant-a", "base-v3", "docs-v2", "tok-v3", prompt) 10other_tenant = cache_key("tenant:tenant-b", "base-v3", "docs-v2", "tok-v3", prompt) 11 12assert cache.get(same_tenant) == "kv-7" 13assert cache.get(other_tenant) is None 14print("authorized_hit:", same_tenant in cache) 15print("cross_tenant_hit:", other_tenant in cache)
Output
1authorized_hit: True 2cross_tenant_hit: False

Chunked prefill for multi-tenant fairness

The prefill phase processes an input prompt and writes initial KV state. The decode phase emits output tokens one at a time. Prefill usually asks for compute; decode repeatedly reads memory. A long prefill can therefore delay another tenant's live stream even when the first request hasn't generated anything.

Predict the fairer schedule: let the long prefill occupy the GPU until it finishes, or split it so decodes get turns? Chunked prefill, studied in Sarathi-Serve,[11]Reference 11Sarathi-Serve: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills.https://arxiv.org/abs/2308.16369 breaks the prompt into bounded chunks and interleaves those chunks with other work.

The chunk budget is a tuning knob, not a magic constant. In vLLM, max_num_batched_tokens controls it: smaller values tend to protect inter-token latency, while larger values tend to improve TTFT and prefill throughput. Current V1 docs say chunked prefill is enabled whenever possible, with example low-latency values around 2,048 and throughput-oriented values above 8,192. Treat those as starting points, then measure against your SLO.[11]Reference 11Sarathi-Serve: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills.https://arxiv.org/abs/2308.16369[12]Reference 12Optimization and Tuning.https://docs.vllm.ai/en/latest/configuration/optimization.html

Chunking bounds one noisy neighbor's turn. It doesn't enforce tenant quotas or guarantee fairness without admission and scheduler policy.

Why does chunked prefill protect other tenants from a long prompt?

Answer

It breaks a long compute-heavy prefill into smaller slices that can interleave with decode work from other tenants, preventing one 32K prompt from blocking the whole batch.

Tenant-aware preemption

When admitted work approaches its KV budget, the scheduler has three honest choices: reject it, leave it queued, or preempt lower-priority work. An enterprise tier may displace best-effort work, but that decision belongs in the service policy and its metrics.

Current vLLM V1 docs default to RECOMPUTE rather than SWAP because recomputation has lower overhead in that architecture. Your platform still needs a measured choice: recomputation spends extra compute and latency, while swapping spends transfer bandwidth and host memory.[12]Reference 12Optimization and Tuning.https://docs.vllm.ai/en/latest/configuration/optimization.html

In the conceptual scheduler below, predict the victim before reading the loop. The incoming enterprise request has higher priority than both running requests and needs 80 MB. The starter request has the lowest priority and the largest KV footprint, so evicting it frees enough space without displacing business traffic:

tenant-aware-preemption.py
1from dataclasses import dataclass 2from typing import Protocol 3 4class GPUAllocator(Protocol): 5 def get_num_free_blocks(self) -> int: ... 6 7@dataclass 8class Tenant: 9 name: str 10 priority: int # Larger number = higher priority 11 12@dataclass 13class Request: 14 tenant: Tenant 15 estimated_kv_memory: int 16 tokens_generated: int 17 can_recompute: bool = True 18 19class TenantAwareScheduler: 20 def __init__(self, gpu_allocator: GPUAllocator, block_size_mb: int): 21 self.running_requests: list[Request] = [] 22 self.gpu_allocator = gpu_allocator 23 self.block_size_mb = block_size_mb 24 25 def available_kv_memory(self) -> int: 26 return self.gpu_allocator.get_num_free_blocks() * self.block_size_mb 27 28 def evict_for_recompute(self, request: Request) -> None: 29 print( 30 f"Evicting KV cache for tenant={request.tenant.name} " 31 f"priority={request.tenant.priority}; " 32 "the request will be recomputed if resumed." 33 ) 34 35 def swap_to_cpu(self, request: Request) -> None: 36 print( 37 f"Swapping KV cache for tenant={request.tenant.name} " 38 f"priority={request.tenant.priority} " 39 "to host memory." 40 ) 41 42 def schedule(self, request: Request) -> None: 43 self.running_requests.append(request) 44 print( 45 f"Scheduling request tenant={request.tenant.name} " 46 f"priority={request.tenant.priority}." 47 ) 48 49 def preempt(self, request: Request) -> None: 50 if request.can_recompute: 51 self.evict_for_recompute(request) 52 else: 53 self.swap_to_cpu(request) 54 55 def preempt_if_needed(self, new_request: Request) -> None: 56 free_memory_mb = self.available_kv_memory() 57 if free_memory_mb >= new_request.estimated_kv_memory: 58 self.schedule(new_request) 59 return 60 61 candidates = sorted( 62 self.running_requests, 63 key=lambda r: ( 64 r.tenant.priority, # Lowest priority first 65 -r.estimated_kv_memory, # Free the biggest KV footprint first 66 r.tokens_generated, # Prefer to kill work that has done less decode 67 ), 68 ) 69 70 victims = [] 71 for candidate in candidates: 72 if new_request.tenant.priority <= candidate.tenant.priority: 73 continue 74 victims.append(candidate) 75 free_memory_mb += candidate.estimated_kv_memory 76 if free_memory_mb >= new_request.estimated_kv_memory: 77 break 78 79 if free_memory_mb < new_request.estimated_kv_memory: 80 print("Cannot free enough KV memory without preempting higher or equal priority requests.") 81 return 82 83 for victim in victims: 84 self.preempt(victim) 85 self.running_requests.remove(victim) 86 self.schedule(new_request) 87 88class FakeAllocator: 89 def __init__(self, free_blocks: int): 90 self.free_blocks = free_blocks 91 92 def get_num_free_blocks(self) -> int: 93 return self.free_blocks 94 95scheduler = TenantAwareScheduler(FakeAllocator(free_blocks=4), block_size_mb=16) 96scheduler.running_requests = [ 97 Request(Tenant("starter", priority=1), estimated_kv_memory=96, tokens_generated=8), 98 Request(Tenant("business", priority=2), estimated_kv_memory=80, tokens_generated=120), 99] 100 101incoming = Request(Tenant("enterprise", priority=4), estimated_kv_memory=80, tokens_generated=0) 102scheduler.preempt_if_needed(incoming) 103print("running tenants:", [request.tenant.name for request in scheduler.running_requests])
Output
1Evicting KV cache for tenant=starter priority=1; the request will be recomputed if resumed. 2Scheduling request tenant=enterprise priority=4. 3running tenants: ['business', 'enterprise']

The miniature loop accounts for released KV memory before admitting the newcomer. In production, release and allocation must be atomic. Otherwise two concurrent decisions can both observe the same free blocks and over-admit work.

What should a tenant-aware preemption policy optimize for?

Answer

Free enough KV memory while minimizing service-objective impact. Prefer lower-priority tenants, large KV footprints, and requests with less generated work, then recompute or swap depending on runtime support.

Hard per-tenant limits

Scheduling alone can't protect a pool from an oversized request that arrives before a fair-share decision. The platform also enforces hard context, concurrency, and KV quotas by tenant tier. The illustration below compares shared namespaces with stronger runtime boundaries:

Three isolation tiers for the same 100-team fleet. Shared pool: lowest cost, shared kernels and KV, highest noisy-neighbor risk. Namespace isolation: tenant queues, KV budgets, and cache salt, still on shared kernels. Dedicated boundary: MIG, VM, or pool with no shared workers, highest cost and lowest blast radius.
Move right only when the threat model forbids a shared kernel. Namespace controls (queues, KV caps, cache salt) still run on shared workers. Dedicated MIG, VM, or pool is the expensive way to remove those paths.

Example admission policy. These numbers are scenario inputs, not universal tiers:

Tenant TierMax Concurrent RequestsMax Context LengthKV Cache Budget
Enterprise508K256 GB
Business204K80 GB
Starter52K20 GB

Derive a budget from max_concurrent × max_context × bytes_per_token, then add headroom for fragmentation, replica placement, and bursts. Using the earlier ~1.22 GiB at 4K tokens, or ~0.000305 GiB per token for that model shape:

  • Enterprise peak: 50×8000×0.000305≈12250 \times 8000 \times 0.000305 \approx 12250×8000×0.000305≈122 GiB raw KV; the 256 GB row is roughly 2× that for HBM packing waste, multi-worker share, and burst.
  • Business peak: 20×4000×0.000305≈2420 \times 4000 \times 0.000305 \approx 2420×4000×0.000305≈24 GiB raw; 80 GB is a multi-replica pool budget, not a single-request cap.
  • Starter peak: 5×2000×0.000305≈35 \times 2000 \times 0.000305 \approx 35×2000×0.000305≈3 GiB raw; 20 GB keeps small tenants from thrashing under noisy neighbors while still hard-capping spend.

Publish the formula and measured bytes/token for your model. The table is a policy example, not an industry standard. Apply these checks before scheduling GPU work:

admit-under-tenant-kv-budget.py
1TIERS = { 2 "enterprise": {"max_concurrent": 50, "max_context": 8_000, "kv_gib": 256.0}, 3 "starter": {"max_concurrent": 5, "max_context": 2_000, "kv_gib": 20.0}, 4} 5 6def admit(tier: str, active_requests: int, context_tokens: int, projected_kv_gib: float) -> str: 7 policy = TIERS[tier] 8 if active_requests >= policy["max_concurrent"]: 9 return "REJECT_CONCURRENCY_LIMIT" 10 if context_tokens > policy["max_context"]: 11 return "REJECT_CONTEXT_LIMIT" 12 if projected_kv_gib > policy["kv_gib"]: 13 return "REJECT_KV_BUDGET" 14 return "ADMIT" 15 16assert admit("starter", 5, 1_000, 3.0) == "REJECT_CONCURRENCY_LIMIT" 17assert admit("starter", 2, 2_400, 3.0) == "REJECT_CONTEXT_LIMIT" 18assert admit("starter", 2, 1_900, 22.0) == "REJECT_KV_BUDGET" 19assert admit("enterprise", 12, 7_500, 180.0) == "ADMIT" 20print("starter_at_capacity:", admit("starter", 5, 1_000, 3.0)) 21print("starter_long_prompt:", admit("starter", 2, 2_400, 3.0)) 22print("enterprise_request:", admit("enterprise", 12, 7_500, 180.0))
Output
1starter_at_capacity: REJECT_CONCURRENCY_LIMIT 2starter_long_prompt: REJECT_CONTEXT_LIMIT 3enterprise_request: ADMIT

Why are hard KV limits necessary even with fair scheduling?

Answer

Schedulers arbitrate admitted work, but a single oversized prompt can allocate too much memory before fairness helps. Hard limits reject or downsize requests before they consume shared KV blocks.

Hard limits stop oversized prompts. They don't stop a tenant from sending a legal-sized flood, so the next boundary sits at the gateway.

How we prevent one tenant from overwhelming the rest: rate limiting and fair queues

Rate limiting sits at the gateway, before a request reaches the GPU. Predict which tenant is more expensive: one request with a 64K prompt or one hundred requests with 100-token prompts. Request count alone can't answer, so enforce two distinct budgets:

  • Requests per minute (RPM): Controls burst traffic to protect the API gateway from connection exhaustion.
  • Tokens per minute (TPM): Controls sustained throughput to protect GPU compute capacity.

The 64K request can consume more prefill work and KV memory than the one hundred short turns, even though it uses fewer requests. RPM alone would admit it and could let one tenant monopolize the cache.

Distributed sliding-window enforcement

For RPM, a distributed sliding-window limiter using Redis with a Lua script gives one decision across gateway nodes. A local in-memory counter sees only one instance, so load balancing lets a tenant exceed its apparent limit.

The Lua script below removes entries older than the window, counts the remaining requests, and either allows the new request or rejects it. The key detail is using a unique sorted-set member (a request ID with a timestamp) instead of the raw timestamp alone. If two requests land in the same clock tick and you use the timestamp as both score and member, Redis collapses them into one entry and undercounts traffic:

distributed-sliding-window-enforcement.lua
1-- Redis Lua Script for RPM Sliding-Window Limiting 2local key = KEYS[1] 3local limit = tonumber(ARGV[1]) 4local window_ms = tonumber(ARGV[2]) -- e.g., 60_000 5local now_ms = tonumber(ARGV[3]) 6local member = ARGV[4] -- unique request id, e.g. "1713468123456:req-9f3c" 7 8-- Remove timestamped entries older than the window 9redis.call('ZREMRANGEBYSCORE', key, 0, now_ms - window_ms) 10 11-- Count current requests 12local count = redis.call('ZCARD', key) 13 14if count < limit then 15 redis.call('ZADD', key, now_ms, member) 16 redis.call('PEXPIRE', key, window_ms) 17 return 1 -- Allowed 18else 19 return 0 -- Rejected 20end

TPM is trickier because final output length is unknown at admission. Reserve prompt tokens plus max_output_tokens, then reconcile the reservation with actual usage when the stream finishes.

Strictly synchronized Redis decisions can become a bottleneck at high throughput. Bounded bursts or approximate local counters are possible alternatives, but they weaken strict-limit semantics. Document and measure that tradeoff.

Token admission needs reservation and reconciliation. Reserve prompt plus maximum allowed output before execution, then release unused output capacity after the stream completes:

reserve-and-reconcile-token-budget.py
1class TokenBudget: 2 def __init__(self, remaining: int): 3 self.remaining = remaining 4 5 def reserve(self, prompt_tokens: int, max_output_tokens: int) -> int: 6 reservation = prompt_tokens + max_output_tokens 7 if reservation > self.remaining: 8 raise ValueError("TPM budget exceeded") 9 self.remaining -= reservation 10 return reservation 11 12 def reconcile(self, reservation: int, prompt_tokens: int, output_tokens: int) -> None: 13 self.remaining += reservation - (prompt_tokens + output_tokens) 14 15budget = TokenBudget(remaining=1_000) 16held = budget.reserve(prompt_tokens=300, max_output_tokens=400) 17budget.reconcile(held, prompt_tokens=300, output_tokens=120) 18 19assert budget.remaining == 580 20print("tokens_remaining_after_actual_usage:", budget.remaining)
Output
1tokens_remaining_after_actual_usage: 580

Fairness inside the scheduler

RPM and TPM protect the edge, but they don't decide which admitted request gets the next GPU step. A tenant with one 64K prompt can still consume more service time than dozens of short turns.

Inside the runtime, keep per-tenant queues and charge a virtual token budget for every prefill chunk and decode step. Schedule by priority plus virtual finish time, not raw request count. Each tenant then makes forward progress, while a higher-SLO tier can receive a defined larger share.

Keep rate limiting and quota management separate. Rate limits control bursts and protect capacity; quotas cap total usage and protect the bill. A tenant can stay under its RPM limit and still exhaust a monthly token quota in one afternoon.

Why is RPM not enough for LLM rate limiting?

Answer

One request can contain a huge prompt and request many output tokens. TPM and KV budgets protect GPU work and memory, while RPM mostly protects the gateway from bursty connection pressure.

Budgets protect capacity. They don't keep Tenant Y's postmortem out of Tenant X's context. That requires an authorization boundary at every state-bearing hop.

How we keep data private: the isolation stack

Every state-bearing layer needs an authorization boundary and a testable release policy. A request can be correctly authenticated at the gateway and still leak through retrieval, adapter lookup, prefix reuse, or KV allocation. Treat any cross-tenant read as an incident, even when the other layers behaved correctly.

The RAG relevance vs. authorization gap

Many platforms augment LLMs with retrieval-augmented generation (RAG). A vector database ranks documents by relevance. Relevance isn't permission.

Tenant X searches for “incident-retention exception policy.” Tenant Y's private postmortem may rank higher because both teams use the same reliability vocabulary. If the filter is missing, the model can summarize Tenant Y's incident for Tenant X.

Predict where to apply the check: after top-K results return, or inside the vector query? Authorization belongs in the query, before foreign candidates enter application memory:

Diagram showing tenant-a query, Filter in the vector query?, A docs only, and B postmortem in app memory.
tenant-a query, Filter in the vector query?, A docs only, and B postmortem in app memory.

Use authorization filtering in the retrieval operation itself. Pass authorized scope into the database query, then test that a foreign result can't cross the boundary:

tenant-filtered-retrieval.py
1documents = [ 2 {"tenant": "tenant-a", "text": "Retention exception policy A", "score": 0.88}, 3 {"tenant": "tenant-b", "text": "Private postmortem details B", "score": 0.99}, 4] 5 6def authorized_search(tenant: str, top_k: int) -> list[str]: 7 allowed = [doc for doc in documents if doc["tenant"] == tenant] 8 ranked = sorted(allowed, key=lambda doc: doc["score"], reverse=True) 9 return [doc["text"] for doc in ranked[:top_k]] 10 11results = authorized_search("tenant-a", top_k=1) 12assert results == ["Retention exception policy A"] 13assert all("postmortem details B" not in text for text in results) 14print("authorized_results:", results)
Output
1authorized_results: ['Retention exception policy A']

The predicate now runs before candidates leave the data layer. A post-filter after broad top-K can expose foreign IDs, scores, or embeddings to application memory, and it can leave no authorized candidate after the top-K slice has already been chosen.

Why must tenant filtering happen inside the vector database query?

Answer

Post-filtering can retrieve foreign document IDs, scores, or embeddings before dropping them. Authorization must be part of the retrieval predicate so disallowed evidence is never returned to the application.

Model and prompt isolation

The same check applies outside retrieval:

  • LoRA adapter isolation. Authorize the adapter ID against the tenant before loading an encrypted artifact. If adapter weights contain private tuning, record residency and release rules in the data contract.
  • Prompt isolation. Keep raw prompts out of plaintext logs and authenticate or encrypt gateway-to-worker transport. The mTLS (mutual Transport Layer Security) and memory-boundary policy depends on the threat model.
  • Harder runtime boundaries. If that threat model rules out shared workers, evaluate dedicated node pools, MIG (Multi-Instance GPU) partitions, or VM boundaries.[13]Reference 13Supported GPUshttps://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-gpus.html Kubernetes placement selects hardware; it doesn't create isolation by itself.

State sanitization

When a shared worker serves more than one tenant, state lifecycle becomes part of the security contract:

  • KV reference lifecycle. Remove request access to released KV blocks when a sequence completes. If the threat model requires cleared memory before reuse, implement and verify that policy rather than assuming the allocator provides it.
  • Batch construction policy. Shared-base batching is fine only when every row carries its own tenant ID, adapter handle, KV table, and metering context. For regulated workloads, dedicated pools or MIG / VM boundaries may be safer than hardening every shared-kernel path.

PII masking as a tiered control

For regulated workloads that can tolerate redaction, a request can pass through a lightweight PII masking service, such as Presidio,[14]Reference 14Presidio: Data Protection and De-identification SDK.https://github.com/microsoft/presidio before it reaches the model router. Masking reduces exposure of raw credit card or Social Security numbers. It doesn't decide which tenant's documents retrieval may return, and any placeholder-to-original mapping needs its own access control.

Two traces of the same Tenant A prompt. Kept identity: Jane becomes [NAME], retrieval stays on A docs, adapter docs-v2, KV owner A. Dropped identity: the prompt is still masked, but a global vector search returns Tenant B’s private postmortem. Masking lowers exposure; dropped tenant identity is still a leak.
Both traces mask Jane to [NAME]. Only the left one keeps tenant_id=A through retrieval and KV ownership. Masking without an authorization predicate still fetches B's postmortem.

When is PII masking helpful, and when is it insufficient?

Answer

It reduces exposure for data that can be safely replaced with placeholders. It's insufficient for regulated tenants that need hard hardware or VM isolation, strict audit controls, or full-fidelity private context.

How we attribute cost: per-tenant metering and chargeback

A shared fleet needs an answer for each tenant: what did this workspace consume, and what should it pay? Token count is only a proxy. Two requests with equal token counts can use different GPU time when their prompt/output split, batch occupancy, preemptions, or cache-hit rate differs.

Attach a defensible metering record to every request. Include tenant_id, model and adapter version, prompt and output tokens, cache-hit tokens, queue wait, prefill and decode time, KV blocks held, preemption count, and GPU worker type. Cache-hit tokens matter because reused prefixes skip prefill. A public billing policy can price cached input separately, as current provider documents show.[15]Reference 15Prompt cachinghttps://developers.openai.com/api/docs/guides/prompt-caching[16]Reference 16Prompt caching.https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching

For internal allocation, GPU time is the honest unit. A reasonable per-request estimate is:

costreq≈gpu_seconds×node_hourly_rate/3600+adapter_residency+storage\text{cost}_{\text{req}} \approx \text{gpu\_seconds} \times \text{node\_hourly\_rate} / 3600 + \text{adapter\_residency} + \text{storage}costreq​≈gpu_seconds×node_hourly_rate/3600+adapter_residency+storage

Here gpu_seconds is the request's share of busy GPU time: prefill plus decode steps, divided by batch occupancy so shared steps are apportioned across co-batched tenants. You can bill on simpler token-and-tier dimensions, then reconcile against measured GPU time to find traffic shapes that cost more than their token bill suggests.

meter-shared-gpu-cost.py
1records = [ 2 {"tenant": "tenant-a", "gpu_seconds": 0.40, "cache_hit_tokens": 800}, 3 {"tenant": "tenant-b", "gpu_seconds": 1.25, "cache_hit_tokens": 0}, 4] 5NODE_HOURLY_RATE = 8.00 6 7def compute_dollars(record: dict[str, float]) -> float: 8 return record["gpu_seconds"] * NODE_HOURLY_RATE / 3600 9 10costs = {record["tenant"]: compute_dollars(record) for record in records} 11assert costs["tenant-b"] > costs["tenant-a"] 12print("metered_gpu_cost_usd:", {tenant: round(value, 6) for tenant, value in costs.items()})
Output
1metered_gpu_cost_usd: {'tenant-a': 0.000889, 'tenant-b': 0.002778}

Why is per-tenant token count not enough for cost attribution?

Answer

Equal token counts can map to very different GPU time depending on prompt/output split, batch occupancy, preemptions, and cache hits. Attribute internal cost in GPU-seconds and discount cache-hit tokens, then map that to a token-and-tier price.

How the system grows: scaling, canary, and fault tolerance

The opening incident is now easier to place. A queue can grow while GPU utilization stays moderate, and a canary can be healthy at the model endpoint while its adapter or KV contract is wrong. Scaling, release, and recovery need separate signals.

Auto-scaling on queue depth

CPU utilization alone doesn't describe LLM serving pressure. Queue depth, admitted-token backlog, request latency, and KV occupancy connect capacity to the SLOs we defined earlier. Current vLLM metrics expose vllm:num_requests_running plus its waiting and swapped variants, and vllm:kv_cache_usage_perc for the fraction of used KV blocks.[17]Reference 17Metricshttps://docs.vllm.ai/en/stable/design/metrics/ Confirm exact labels in the deployed version before writing an alert. Sustained waiting with high KV usage suggests capacity pressure; average CPU by itself doesn't identify the bottleneck.

Cold-starting a GPU worker means making weights, kernels, and runtime state ready before it can serve. A platform can keep warm replicas, scale from a forecast, or shed eligible best-effort work while new capacity starts. Each choice trades idle cost against the SLO budget.

Worked scenario, not a benchmark: assume target traffic has a measured capacity of 4,000 output tokens/s per ready replica, needs 12 replicas, and can ramp 30% in under 10 minutes. If pulling weights and warming CUDA graphs takes 8 minutes, a warm buffer of about 0.3×12≈40.3 \times 12 \approx 40.3×12≈4 replicas covers the ramp. Use a p95 queue-age threshold such as 200 ms for two windows as a scenario policy, not a universal autoscaling constant. If the buffer is exhausted, shed starter-tier work before enterprise latency gates fail.

Before scaling, read the trace rather than guessing from one dashboard:

ObservationFirst hypothesisCheck before changing capacity
Queue age rises while KV usage stays lowAdmission or scheduler policy is holding workWaiting-state labels, priority queues, chunk budget, and gateway reservations
KV usage and preemptions rise while utilization is moderateKV capacity is the limiting resourceBytes per token, context mix, block fragmentation, and preemption count
TTFT rises but ITL stays inside targetQueue or prefill path is slowQueue wait, prefill time, adapter load, and prefix-cache hit rate
ITL breaches after a tenant's long prompt arrivesDecode is sharing a compute-heavy prefillChunked-prefill budget, per-tenant token spend, and decode scheduling
Requests fail only after a rolloutVersion, adapter, or state compatibility changedRoute pointer, model/adapter contract, KV namespace, and correctness smoke tests

The table gives hypotheses, not proofs. Scale when the measured bottleneck is capacity; fix policy or state when it isn't.

Any GPU performance number needs a measurement receipt. Name the accelerator and count, topology, engine and version, model and dtype, prompt/output distribution, concurrency, warmup and steady-state window, exact baseline and tuning, and correctness check. Hardware specifications are ceilings, not serving benchmarks. Report goodput under the tenant's TTFT and ITL gates when comparing designs.

Model versioning and canary rollouts

Models and LoRA adapters need versioned rollout because a new artifact can change quality, latency, memory use, or compatibility.

Predict what a 1% adapter canary must prove. Routing a controlled slice of eligible traffic from v1 to v2 tests more than output text: it tests the adapter registry, residency, KV namespace, and tenant authorization. Monitor route-specific quality, p95 TTFT and ITL, error rate, safety signals, cache misses, and preemptions over a declared observation window.

If the gates pass, increase exposure gradually. If one fails, route new eligible requests to v1 and freeze the canary. Active streams may still be on v2, and old adapter residency or KV entries may still be referenced, so changing a route pointer isn't a blanket zero-downtime promise.

Base-model updates need a separate worker pool because weights, kernels, and KV state change together. Send shadow traffic to the candidate pool to check correctness and measure the target workload before exposing real tenant traffic. After the evidence passes, shift the gateway and drain the old pool without deleting state still referenced by active streams.

Rollback is a state transition, not only a router edit: restore the previous route and adapter registry, stop new traffic to the candidate, keep compatible old state available until streams drain, invalidate incompatible prefix or KV entries, and rerun health plus tenant-isolation checks. Record which gate caused the rollback so the next canary tests that failure directly.

Why are adapter canaries easier than base-model canaries?

Answer

Adapters are small and can usually be routed per tenant on an existing base model. Base-model changes require new worker pools, separate KV caches, shadow traffic, throughput validation, and drain logic.

Fault tolerance

System reliability relies on handling GPU failures and model crashes gracefully. The response should preserve tenant scope while it removes bad work:

StrategyTrigger ConditionAction TakenArchitectural Impact
Dead Letter Queues (DLQ)Repeated CUDA out-of-memory (OOM) or crashesMove a repeatedly failing request to DLQ after a bounded retry countStops that request from causing an unbounded retry loop
Circuit BreakingModel/adapter error rate crosses a configured thresholdFast-fail new requests for that specific adapterLimits repeated work while operators investigate
Active Health ChecksMissed node heartbeats (e.g., stuck kernel)Mark unhealthy, stop new routing, drain or fail in-flight work according to policyRemoves a suspected worker from new admission
Zone RedundancyEntire Availability Zone failureShift eligible traffic to healthy zones, subject to spare capacityReduces zone-failure impact when capacity is available

These mechanisms need a control plane or equivalent coordination layer to track worker readiness, orchestrate model deployments, and update routing. When an incident starts, freeze the rollout, stop new routing to the suspect worker or adapter, inspect the request trace and tenant scope, then fail over or roll back. Readiness checks reduce routing to unavailable workers; they don't prove model quality or prevent every runtime failure.

Why does a poison request need a dead-letter queue?

Answer

Repeated CUDA OOMs or malformed inputs can trigger retry storms and consume shared capacity. A DLQ removes the repeatedly failing request after bounded retries so it no longer consumes retries.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A platform considers 100 isolated copies of a 72B FP16 base model on 80 GB GPUs. What design change addresses the weight footprint before discussing request scheduling?

Correct answer: Share the frozen base weights across tenants and isolate tenant adapters, KV state, queues, and authorization; 100 full copies would be about 14.4 TB.

A 72B FP16 model needs roughly 144 GB of weights, so 100 independent copies require about 14.4 TB before runtime state. Sharing a frozen base can improve economics, but tenant-specific adapters, caches, quotas, and access controls still need explicit isolation.

2.For an 80-layer model with 8 KV heads, head dimension 128, 4,000 tokens, and BF16 values, use 2 × layers × KV heads × head_dim × tokens × bytes. What KV size follows?

Correct answer: About 1.22 GiB, because the formula gives 1,310,720,000 bytes for keys and values at two bytes per element.

The calculation is 2 × 80 × 8 × 128 × 4,000 × 2 = 1,310,720,000 bytes, or about 1.22 GiB. The leading factor already accounts for both keys and values; grouped-query attention is represented by using 8 KV heads rather than the full attention-head count.

3.A low-priority tenant's long prompt monopolizes a continuous batch while interactive requests miss their latency target. Which scheduler change preserves throughput and fairness?

Correct answer: Use tenant-aware queues with virtual token budgets, chunked prefill, priorities, and preemption that releases and admits state atomically.

Request counts alone do not express work when prompt and generation lengths differ. Tenant-aware token budgets and chunked prefill bound monopolization, while priorities and atomic KV release/admission protect interactive latency without throwing away continuous batching.

4.An adapter cache holds Tenant B's incident-v7 and Tenant C's code-review-v1 after evicting Tenant A's docs-v2. Tenant A now requests the resident code-review-v1, but its registry authorizes only docs-v2. What should happen?

Correct answer: Reject the request at authorization, and treat cache residency only as a performance concern rather than permission.

An LRU cache describes what is resident, not what a tenant may use. Authorization must be checked independently before loading or applying an adapter. An evicted adapter can be reloaded only for an authorized tenant and compatible model contract.

5.A long-context request misses local KV. Local recomputation has a measured 180 ms p95. Remote restore has a 210 ms p95, and policy does not authorize that tenant's KV across the remote trust boundary. What should the scheduler do?

Correct answer: Recompute locally because remote restore is slower at p95 and fails the tenant authorization boundary for this request.

Remote KV reuse must beat recomputation within the deadline and pass the trust-scope check. Here it loses both gates: 210 ms exceeds 180 ms at p95, and policy forbids the remote read. Another workload may choose restore, but this request must recompute locally.

6.Two tenants use the same base model and tokenizer but different LoRA adapters. A prefix cache is keyed only by prompt text, and one tenant receives behavior from the other adapter. What cache contract fixes the leak?

Correct answer: Namespace entries by tenant or approved trust group, base model, adapter version, tokenizer, policy context, and a cache salt before reuse.

Prefix reuse is safe only when the model behavior, tokenizer, trust scope, and authorization context match. Adapter identity must be part of the contract, and tenant or trust-group namespaces prevent a cache hit from crossing a data boundary.

7.A business tier permits 20 requests per minute and 4,000 tokens per minute, but a burst of long prompts exceeds the token allowance while staying below RPM. Which quota controller should act?

Correct answer: Reserve and reconcile token budgets at admission and completion, enforcing TPM separately from RPM with tier-specific limits.

RPM cannot bound work when prompt and output lengths vary. The gateway should reserve expected tokens, reconcile actual usage, and enforce both TPM and RPM so a long burst cannot consume another tier's capacity or bypass its contract.

8.GPU utilization is moderate, but queue time and KV eviction rise during long prompts. A base-model canary also shows repeated health failures. Which operational response is strongest?

Correct answer: Scale or shed work from queue and KV signals, preserve circuit breakers and DLQs, and canary adapters or models with rollback-ready health checks.

CPU or aggregate GPU utilization can miss memory and queue bottlenecks. Queue depth, KV pressure, and eviction are direct signals for admission and scaling. A failing canary needs isolation, health checks, circuit breaking, and rollback or DLQ handling rather than wider exposure.

8 questions remaining.

Next Step
Continue to LLM-Powered Search Engine

You can now keep tenant identity on routing, KV, adapters, and bills. Search asks the next question: retrieved evidence, rankings, and citations have to respect those same scopes before the model writes an answer.

PreviousCode Completion System
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

H100 GPU

NVIDIA · 2026

https://www.nvidia.com/en-us/data-center/h100/

Orca: A Distributed Serving System for Transformer-Based Generative Models.

Yu, G.-I., et al. · 2022 · OSDI 2022

https://www.usenix.org/conference/osdi22/presentation/yu

LoRA: Low-Rank Adaptation of Large Language Models.

Hu, E. J., et al. · 2021 · ICLR

https://arxiv.org/abs/2106.09685

S-LoRA: Serving Thousands of Concurrent LoRA Adapters.

Sheng, Y., et al. · 2023 · arXiv preprint

https://arxiv.org/abs/2311.03285

LoRA Adapters

vLLM · 2026

https://docs.vllm.ai/en/stable/features/lora/

Punica: Multi-Tenant LoRA Serving

Chen, L., Ye, Z., Wu, Y., Zhuo, D., Ceze, L., & Krishnamurthy, A. · 2023

https://arxiv.org/abs/2310.18547

Efficient Memory Management for Large Language Model Serving with PagedAttention

Kwon, W., et al. · 2023 · SOSP 2023

https://arxiv.org/abs/2309.06180

Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving.

Qin, Y., et al. · 2024

https://arxiv.org/abs/2407.00079

SGLang: Efficient Execution of Structured Language Model Programs

Zheng, L., Yin, L., Xie, Z., et al. · 2023 · arXiv:2312.07104

https://arxiv.org/abs/2312.07104

Automatic Prefix Caching

vLLM · 2026

https://docs.vllm.ai/en/stable/design/prefix_caching/

Sarathi-Serve: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills.

Agrawal, A., et al. · 2023 · arXiv preprint

https://arxiv.org/abs/2308.16369

Optimization and Tuning.

vLLM · 2026

https://docs.vllm.ai/en/latest/configuration/optimization.html

Supported GPUs

NVIDIA · 2026

https://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-gpus.html

Presidio: Data Protection and De-identification SDK.

Microsoft Presidio. · 2023 · GitHub

https://github.com/microsoft/presidio

Prompt caching

OpenAI · 2026

https://developers.openai.com/api/docs/guides/prompt-caching

Prompt caching.

Anthropic. · 2026 · Official documentation

https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching

Metrics

vLLM · 2026

https://docs.vllm.ai/en/stable/design/metrics/

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.