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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ __Instructions on adding a new model to existing or new SKUs can be found [here]
| `meta-llama/Llama-2-70b-hf` | ✅ | ✅ | ✅ | ✅ |
| `internlm/internlm-20b` | ✅ | ✅ | ✅ | ✅ |
| `Qwen/Qwen-72B` | ✅ | ✅ | ✅ | ✅ |
| `deepseek-ai/DeepSeek-V3` *(MoE)* | ✅ | ✅ | ✅ | ✅ |
| `mistralai/Mixtral-8x7B-v0.1` *(MoE)* | ✅ | ✅ | ✅ | ✅ |

* All models support a maximum context length of 4k except `Llama3-8B` and `Llama3-70B` which support 16k context length by passing additional CLI params:

Expand Down
111 changes: 111 additions & 0 deletions docs/disaggregated_scheduling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Disaggregated Prefill/Decode Scheduling

VIDUR's `DisaggregatedScheduler` simulates separate prefill and decode worker
fleets (Dynamo-style P/D disaggregation), enabling sweep-based optimization of
the prefill-to-decode worker ratio for a given model, traffic pattern, and
interconnect.

## Motivation

In collocated serving, every replica handles both prefill and decode.
Disaggregated serving dedicates some replicas exclusively to prefill and others
to decode:

```
request → prefill_replica → [KV transfer] → decode_replica → response
```

The optimal split depends on the prompt/output length distribution, GPU
compute characteristics, and interconnect bandwidth. VIDUR can sweep this
ratio without running real hardware.

## How it works

The scheduler is a discrete-event simulation with four queues:

```
arrival_queue → prefill_queue → kv_transfer_queue → decode_queue → done
```

**KV transfer latency** — the key new latency term vs collocated serving:

```
t_kv = kv_bytes / interconnect_bandwidth

kv_bytes = 2 × num_layers × num_kv_heads × head_dim × seq_len × 2 bytes (fp16)
```

For DeepSeek-V3 (61 layers, 128 KV heads, head_dim=56, seq_len=1024):
- NVLink (600 GB/s): ~0.13 ms
- InfiniBand HDR (400 GB/s): ~0.20 ms
- PCIe 4.0 ×16 (64 GB/s): ~1.25 ms

## Usage

```python
from vidur.config import ReplicaConfig
from vidur.scheduler.disaggregated_scheduler import (
DisaggregatedScheduler,
DisaggregatedReplicaSchedulerConfig,
)

sched_config = DisaggregatedReplicaSchedulerConfig(
prefill_fleet_size=2,
decode_fleet_size=4,
interconnect_bandwidth_gbps=400.0, # InfiniBand
)
replica_config = ReplicaConfig(
model_name="deepseek-ai/DeepSeek-V3",
device="a100",
)
scheduler = DisaggregatedScheduler(replica_config, sched_config)
result = scheduler.simulate(requests)

print(f"P99 E2E latency: {result.e2e_latency_p99_ms:.1f} ms")
print(f"TTFT P50: {result.ttft_p50_ms:.1f} ms")
print(f"KV transfer avg: {result.kv_transfer_mean_ms:.1f} ms")
```

## P/D ratio sweep

```python
for p_frac in [0.2, 0.3, 0.4, 0.5]:
fleet = 8
p = max(1, int(fleet * p_frac))
d = fleet - p
cfg = DisaggregatedReplicaSchedulerConfig(
prefill_fleet_size=p, decode_fleet_size=d,
interconnect_bandwidth_gbps=600.0,
)
result = DisaggregatedScheduler(replica_config, cfg).simulate(requests)
print(f"p={p} d={d}: p99={result.e2e_latency_p99_ms:.0f}ms ttft_p50={result.ttft_p50_ms:.0f}ms")
```

## Configuration reference

| Parameter | Default | Description |
|-----------|---------|-------------|
| `prefill_fleet_size` | 1 | Number of prefill-only replicas |
| `decode_fleet_size` | 1 | Number of decode-only replicas |
| `interconnect_bandwidth_gbps` | 400.0 | NVLink=600, InfiniBand=400, PCIe=64 |

## MoE models

DeepSeek-V3 and Mixtral use `BaseMoEModelConfig`, which adds:
- `num_experts` / `num_active_experts` (top-k routing)
- `expert_intermediate_dim`
- `kv_lora_rank` / `q_lora_rank` (MLA attention for DeepSeek-V3)

The `MoELayerExecutionTimePredictor` extends the standard RF predictor with
load-imbalance correction (λ^0.72) using a closed-form multinomial approximation
for expected maximum expert load.

To use DeepSeek-V3:

```bash
python -m vidur.main \
--replica_config_model_name deepseek-ai/DeepSeek-V3 \
--replica_config_device a100 \
--cluster_config_num_replicas 1 \
--replica_config_tensor_parallel_size 8
```
197 changes: 197 additions & 0 deletions tests/test_moe_model_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Tests for MoE model configuration additions to VIDUR."""

import os
import sys

import pytest

# Import model_config directly to avoid pulling in the full vidur stack
# (which requires sklearn, ray, etc.).
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from vidur.config.model_config import (
BaseModelConfig,
BaseMoEModelConfig,
DeepSeekV3ModelConfig,
MixtralModelConfig,
)


class TestDeepSeekV3ModelConfig:
def test_name(self):
assert DeepSeekV3ModelConfig.get_name() == "deepseek-ai/DeepSeek-V3"

def test_moe_fields(self):
cfg = DeepSeekV3ModelConfig(
num_q_heads=128,
num_kv_heads=128,
embedding_dim=7168,
mlp_hidden_dim=18432,
)
assert cfg.num_experts == 256
assert cfg.num_active_experts == 8
assert cfg.expert_intermediate_dim == 2048
assert cfg.num_shared_experts == 1

def test_mla_fields(self):
cfg = DeepSeekV3ModelConfig(
num_q_heads=128,
num_kv_heads=128,
embedding_dim=7168,
mlp_hidden_dim=18432,
)
assert cfg.kv_lora_rank == 512
assert cfg.q_lora_rank == 1536

def test_is_moe_subclass(self):
assert issubclass(DeepSeekV3ModelConfig, BaseMoEModelConfig)
assert issubclass(DeepSeekV3ModelConfig, BaseModelConfig)

def test_auto_discovery(self):
"""BaseFixedConfig.create_from_name should find DeepSeekV3 automatically."""
found = BaseModelConfig.create_from_name("deepseek-ai/DeepSeek-V3")
assert isinstance(found, DeepSeekV3ModelConfig)


class TestMixtralModelConfig:
def test_name(self):
assert MixtralModelConfig.get_name() == "mistralai/Mixtral-8x7B-v0.1"

def test_moe_fields(self):
cfg = MixtralModelConfig(
num_q_heads=32,
num_kv_heads=8,
embedding_dim=4096,
mlp_hidden_dim=14336,
)
assert cfg.num_experts == 8
assert cfg.num_active_experts == 2
assert cfg.num_shared_experts == 0

def test_no_mla(self):
cfg = MixtralModelConfig(
num_q_heads=32,
num_kv_heads=8,
embedding_dim=4096,
mlp_hidden_dim=14336,
)
assert cfg.kv_lora_rank == 0 # standard MHA

def test_auto_discovery(self):
found = BaseModelConfig.create_from_name("mistralai/Mixtral-8x7B-v0.1")
assert isinstance(found, MixtralModelConfig)


class TestDisaggregatedScheduler:
def test_kv_bytes_per_token(self):
"""Sanity check KV cache size formula."""
from unittest.mock import MagicMock

mod = self._load_scheduler_module()
kv_cache_bytes_per_token = mod.kv_cache_bytes_per_token
DisaggregatedScheduler = mod.DisaggregatedScheduler

model_cfg = MagicMock()
model_cfg.embedding_dim = 4096
model_cfg.num_q_heads = 32
model_cfg.num_kv_heads = 8
model_cfg.num_layers = 32

replica_cfg = MagicMock()
replica_cfg.model_config = model_cfg

head_dim = 4096 // 32 # 128
expected = 2 * 8 * 128 * 32 * 2 # 2 * kv_heads * head_dim * layers * fp16
assert kv_cache_bytes_per_token(replica_cfg) == expected

def _load_scheduler_module(self):
import importlib.util

mod_name = "vidur.scheduler.disaggregated_scheduler"
if mod_name in sys.modules:
return sys.modules[mod_name]
spec = importlib.util.spec_from_file_location(
mod_name,
os.path.join(
os.path.dirname(__file__),
"..",
"vidur",
"scheduler",
"disaggregated_scheduler.py",
),
)
mod = importlib.util.module_from_spec(spec)
# Register before exec so dataclasses can resolve the module's annotations
sys.modules[mod_name] = mod
spec.loader.exec_module(mod)
return mod

def test_simulation_runs(self):
"""Run a small simulation end-to-end."""
from unittest.mock import MagicMock

mod = self._load_scheduler_module()
DisaggregatedScheduler = mod.DisaggregatedScheduler

model_cfg = MagicMock()
model_cfg.embedding_dim = 4096
model_cfg.num_q_heads = 32
model_cfg.num_kv_heads = 8
model_cfg.num_layers = 32

replica_cfg = MagicMock()
replica_cfg.model_config = model_cfg

scheduler = DisaggregatedScheduler(
replica_config=replica_cfg,
prefill_fleet_size=2,
decode_fleet_size=4,
interconnect_bandwidth_gbps=400.0,
prefill_time_fn=lambda seq_len: seq_len * 0.001, # 1ms per token
decode_time_fn=lambda seq_len, out_len: out_len * 0.02, # 20ms per token
)

requests = [(i * 0.1, 512, 128) for i in range(20)] # 20 requests
result = scheduler.simulate(requests)

assert result.num_requests == 20
assert result.prefill_fleet_size == 2
assert result.decode_fleet_size == 4
assert result.p50_e2e_latency_s > 0
assert result.throughput_rps > 0
assert 0.0 <= result.prefill_utilisation <= 1.0
assert 0.0 <= result.decode_utilisation <= 1.0

def test_p_d_ratio_affects_ttft(self):
"""More prefill workers → lower TTFT."""
from unittest.mock import MagicMock

mod = self._load_scheduler_module()
DisaggregatedScheduler = mod.DisaggregatedScheduler

model_cfg = MagicMock()
model_cfg.embedding_dim = 4096
model_cfg.num_q_heads = 32
model_cfg.num_kv_heads = 8
model_cfg.num_layers = 32

replica_cfg = MagicMock()
replica_cfg.model_config = model_cfg

requests = [(i * 0.05, 256, 64) for i in range(30)]

def make_scheduler(n_prefill, n_decode):
return DisaggregatedScheduler(
replica_config=replica_cfg,
prefill_fleet_size=n_prefill,
decode_fleet_size=n_decode,
interconnect_bandwidth_gbps=400.0,
prefill_time_fn=lambda seq_len: seq_len * 0.002,
decode_time_fn=lambda seq_len, out_len: out_len * 0.01,
)

result_1pf = make_scheduler(1, 5).simulate(requests)
result_3pf = make_scheduler(3, 3).simulate(requests)

# More prefill workers should reduce mean TTFT (or at worst not increase it)
assert result_3pf.mean_ttft_s <= result_1pf.mean_ttft_s + 0.01
Loading