Skip to content

Commit 45abee6

Browse files
committed
feat: add P/D disaggregation output to llm-d generator
Add prefill/decode disaggregation as an output option to the llm-d deployment generator. When pd_enabled=True, the generator produces separate Kustomize patches for prefill and decode deployments instead of the single patch-vllm.yaml. All three patch variants (vllm, prefill, decode) use a single unified template (patch-modelserver.yaml.j2) rendered with different context (deployment_name, replica_count, extra_args) to avoid maintaining near-identical templates that would drift. If the roles diverge significantly, splitting is a one-step refactor. Also adds Field(ge=1) validation on prefill_replicas/decode_replicas in the API schema. Signed-off-by: Amit Oren <amoren@redhat.com>
1 parent 4894ca0 commit 45abee6

7 files changed

Lines changed: 364 additions & 34 deletions

File tree

src/planner/api/routes/configuration.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import yaml
88
from fastapi import APIRouter, Depends, HTTPException, Request, status
9-
from pydantic import BaseModel
9+
from pydantic import BaseModel, Field
1010
from starlette.concurrency import run_in_threadpool
1111

1212
from planner.api.dependencies import (
@@ -32,6 +32,9 @@ class GenerateDeploymentRequest(BaseModel):
3232
configuration: DeploymentConfiguration
3333
namespace: str = "default"
3434
stack: StackType = "vllm"
35+
pd_enabled: bool = False
36+
prefill_replicas: int = Field(1, ge=1, le=32)
37+
decode_replicas: int = Field(1, ge=1, le=32)
3538

3639

3740
class DeploymentModeRequest(BaseModel):
@@ -53,27 +56,21 @@ def _generate_yaml_from_config(
5356
deployment_generator: DeploymentGenerator,
5457
llmd_generator: LlmdDeploymentGenerator,
5558
yaml_validator: YAMLValidator,
59+
pd_enabled: bool = False,
60+
prefill_replicas: int = 1,
61+
decode_replicas: int = 1,
5662
) -> dict[str, Any]:
57-
"""Generate YAML files from a deployment configuration.
58-
59-
Args:
60-
config: Deployment configuration
61-
namespace: Kubernetes namespace
62-
stack: Deployment stack (vllm or llm-d)
63-
deployment_generator: vLLM deployment generator
64-
llmd_generator: llm-d deployment generator
65-
yaml_validator: YAML validator
66-
67-
Returns:
68-
Dict with deployment_id, namespace, files (file paths), and contents (YAML strings)
69-
70-
Raises:
71-
HTTPException: If stack is unknown or YAML validation fails
72-
"""
63+
"""Generate YAML files from a deployment configuration."""
7364
logger.info(f"Generating deployment for model: {config.model_name} (stack={stack})")
7465

7566
if stack == "llm-d":
76-
result = llmd_generator.generate_all(config=config, namespace=namespace)
67+
result = llmd_generator.generate_all(
68+
config=config,
69+
namespace=namespace,
70+
pd_enabled=pd_enabled,
71+
prefill_replicas=prefill_replicas,
72+
decode_replicas=decode_replicas,
73+
)
7774
elif stack == "vllm":
7875
result = deployment_generator.generate_all(config=config, namespace=namespace)
7976
else:
@@ -132,6 +129,9 @@ async def generate_deployment(
132129
deployment_generator,
133130
llmd_generator,
134131
yaml_validator,
132+
pd_enabled=request.pd_enabled,
133+
prefill_replicas=request.prefill_replicas,
134+
decode_replicas=request.decode_replicas,
135135
)
136136

137137
return DeploymentBundle(

src/planner/configuration/llmd_generator.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,30 +70,93 @@ def generate_all(
7070
self,
7171
config: DeploymentConfiguration,
7272
namespace: str = "default",
73+
pd_enabled: bool = False,
74+
prefill_replicas: int = 1,
75+
decode_replicas: int = 1,
7376
) -> dict[str, Any]:
7477
"""Generate all llm-d deployment files.
7578
7679
Returns a dict with: deployment_id, namespace, files, contents.
80+
81+
When *pd_enabled* is True, separate prefill and decode patches are
82+
generated instead of the single ``patch-vllm.yaml``.
7783
"""
84+
if not 1 <= prefill_replicas <= 32:
85+
msg = f"prefill_replicas must be between 1 and 32, got {prefill_replicas}"
86+
raise ValueError(msg)
87+
if not 1 <= decode_replicas <= 32:
88+
msg = f"decode_replicas must be between 1 and 32, got {decode_replicas}"
89+
raise ValueError(msg)
90+
7891
deployment_id = generate_deployment_id(config)
7992
context = self._prepare_context(config, deployment_id, namespace)
8093

81-
configs: list[tuple[str, str, str]] = [
82-
("kustomization.yaml.j2", "modelserver/kustomization.yaml", "kustomization"),
83-
("patch-vllm.yaml.j2", "modelserver/patch-vllm.yaml", "patch_vllm"),
84-
("values.yaml.j2", "scheduler/values.yaml", "helm_values"),
94+
context["pd_enabled"] = pd_enabled
95+
96+
# Build the list of (template, output path, key, extra_context) tuples.
97+
# All model-server patches use the same unified template with different
98+
# deployment_name, replica_count, and extra_args.
99+
patch_template = "patch-modelserver.yaml.j2"
100+
101+
configs: list[tuple[str, str, str, dict[str, Any]]] = [
102+
("kustomization.yaml.j2", "modelserver/kustomization.yaml", "kustomization", {}),
85103
]
86104

105+
if pd_enabled:
106+
configs.append(
107+
(
108+
patch_template,
109+
"modelserver/patch-prefill.yaml",
110+
"patch_prefill",
111+
{
112+
"deployment_name": "prefill",
113+
"replica_count": prefill_replicas,
114+
"extra_args": [
115+
"--kv-connector=nixlv2",
116+
"--kv-role=kv_producer",
117+
"--enable-chunked-prefill",
118+
],
119+
},
120+
)
121+
)
122+
configs.append(
123+
(
124+
patch_template,
125+
"modelserver/patch-decode.yaml",
126+
"patch_decode",
127+
{
128+
"deployment_name": "decode",
129+
"replica_count": decode_replicas,
130+
"extra_args": ["--kv-connector=nixlv2", "--kv-role=kv_consumer"],
131+
},
132+
)
133+
)
134+
else:
135+
configs.append(
136+
(
137+
patch_template,
138+
"modelserver/patch-vllm.yaml",
139+
"patch_vllm",
140+
{
141+
"deployment_name": "decode",
142+
"replica_count": context["replicas"],
143+
"extra_args": [],
144+
},
145+
)
146+
)
147+
148+
configs.append(("values.yaml.j2", "scheduler/values.yaml", "helm_values", {}))
149+
87150
deployment_dir = self.output_dir / deployment_id
88151
(deployment_dir / "modelserver").mkdir(parents=True, exist_ok=True)
89152
(deployment_dir / "scheduler").mkdir(parents=True, exist_ok=True)
90153

91154
generated_files: dict[str, str] = {}
92155
generated_contents: dict[str, str] = {}
93156

94-
for template_name, output_rel_path, config_type in configs:
157+
for template_name, output_rel_path, config_type, extra_ctx in configs:
95158
template = self.env.get_template(template_name)
96-
rendered = template.render(**context)
159+
rendered = template.render(**context, **extra_ctx)
97160

98161
output_path = deployment_dir / output_rel_path
99162
output_path.parent.mkdir(parents=True, exist_ok=True)

src/planner/configuration/templates/llmd/kustomization.yaml.j2

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,9 @@ labels:
3939
create: true
4040

4141
patches:
42+
{% if pd_enabled %}
43+
- path: patch-prefill.yaml
44+
- path: patch-decode.yaml
45+
{% else %}
4246
- path: patch-vllm.yaml
47+
{% endif %}

src/planner/configuration/templates/llmd/patch-vllm.yaml.j2 renamed to src/planner/configuration/templates/llmd/patch-modelserver.yaml.j2

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
apiVersion: apps/v1
22
kind: Deployment
33
metadata:
4-
name: decode
4+
name: {{ deployment_name }}
55
spec:
6-
replicas: {{ replicas }}
6+
replicas: {{ replica_count }}
77
template:
88
spec:
99
containers:
@@ -13,6 +13,9 @@ spec:
1313
- "{{ model_id }}"
1414
- "--tensor-parallel-size={{ tensor_parallel }}"
1515
- "--port=8000"
16+
{% for arg in extra_args %}
17+
- "{{ arg }}"
18+
{% endfor %}
1619
resources:
1720
requests:
1821
nvidia.com/gpu: "{{ gpus_per_replica }}"

tests/unit/test_llmd_generator.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,195 @@ def test_generate_deployment_with_stack_vllm_is_default(
348348
assert response.status_code == 200
349349
data = response.json()
350350
assert "inferenceservice" in data["files"]
351+
352+
353+
@pytest.mark.unit
354+
class TestPDDisaggregation:
355+
"""Tests for P/D (prefill/decode) disaggregation output."""
356+
357+
def test_pd_disabled_produces_single_patch(
358+
self,
359+
llmd_generator: LlmdDeploymentGenerator,
360+
sample_config: DeploymentConfiguration,
361+
) -> None:
362+
"""Default (pd_enabled=False) has patch_vllm, no patch_prefill/patch_decode."""
363+
result = llmd_generator.generate_all(sample_config)
364+
365+
assert "patch_vllm" in result["contents"]
366+
assert "patch_prefill" not in result["contents"]
367+
assert "patch_decode" not in result["contents"]
368+
369+
def test_pd_disabled_patch_targets_decode_deployment(
370+
self,
371+
llmd_generator: LlmdDeploymentGenerator,
372+
sample_config: DeploymentConfiguration,
373+
) -> None:
374+
"""Non-PD patch must use name 'decode' to match the llm-d base Deployment."""
375+
result = llmd_generator.generate_all(sample_config)
376+
parsed = yaml.safe_load(result["contents"]["patch_vllm"])
377+
378+
assert parsed["metadata"]["name"] == "decode"
379+
380+
def test_pd_enabled_produces_prefill_and_decode_patches(
381+
self,
382+
llmd_generator: LlmdDeploymentGenerator,
383+
sample_config: DeploymentConfiguration,
384+
) -> None:
385+
"""pd_enabled=True produces patch_prefill and patch_decode, no patch_vllm."""
386+
result = llmd_generator.generate_all(sample_config, pd_enabled=True)
387+
388+
assert "patch_prefill" in result["contents"]
389+
assert "patch_decode" in result["contents"]
390+
assert "patch_vllm" not in result["contents"]
391+
392+
def test_pd_prefill_patch_has_correct_replicas(
393+
self,
394+
llmd_generator: LlmdDeploymentGenerator,
395+
sample_config: DeploymentConfiguration,
396+
) -> None:
397+
"""Prefill patch uses prefill_replicas value."""
398+
result = llmd_generator.generate_all(
399+
sample_config, pd_enabled=True, prefill_replicas=2
400+
)
401+
parsed = yaml.safe_load(result["contents"]["patch_prefill"])
402+
403+
assert parsed["spec"]["replicas"] == 2
404+
assert parsed["metadata"]["name"] == "prefill"
405+
406+
def test_pd_decode_patch_has_correct_replicas(
407+
self,
408+
llmd_generator: LlmdDeploymentGenerator,
409+
sample_config: DeploymentConfiguration,
410+
) -> None:
411+
"""Decode patch uses decode_replicas value."""
412+
result = llmd_generator.generate_all(
413+
sample_config, pd_enabled=True, decode_replicas=3
414+
)
415+
parsed = yaml.safe_load(result["contents"]["patch_decode"])
416+
417+
assert parsed["spec"]["replicas"] == 3
418+
assert parsed["metadata"]["name"] == "decode"
419+
420+
def test_pd_kustomization_references_both_patches(
421+
self,
422+
llmd_generator: LlmdDeploymentGenerator,
423+
sample_config: DeploymentConfiguration,
424+
) -> None:
425+
"""Kustomization patches list has patch-prefill.yaml and patch-decode.yaml."""
426+
result = llmd_generator.generate_all(sample_config, pd_enabled=True)
427+
parsed = yaml.safe_load(result["contents"]["kustomization"])
428+
429+
patch_paths = [p["path"] for p in parsed["patches"]]
430+
assert "patch-prefill.yaml" in patch_paths
431+
assert "patch-decode.yaml" in patch_paths
432+
assert "patch-vllm.yaml" not in patch_paths
433+
434+
def test_pd_all_outputs_valid_yaml(
435+
self,
436+
llmd_generator: LlmdDeploymentGenerator,
437+
sample_config: DeploymentConfiguration,
438+
) -> None:
439+
"""All contents parse as valid YAML when pd_enabled=True."""
440+
result = llmd_generator.generate_all(sample_config, pd_enabled=True)
441+
442+
for key, content in result["contents"].items():
443+
parsed = yaml.safe_load(content)
444+
assert parsed is not None, f"{key} rendered as empty YAML"
445+
446+
def test_rejects_zero_prefill_replicas(
447+
self,
448+
llmd_generator: LlmdDeploymentGenerator,
449+
sample_config: DeploymentConfiguration,
450+
) -> None:
451+
"""generate_all() raises ValueError when prefill_replicas < 1."""
452+
with pytest.raises(ValueError, match="must be between 1 and 32"):
453+
llmd_generator.generate_all(sample_config, pd_enabled=True, prefill_replicas=0)
454+
455+
def test_rejects_zero_decode_replicas(
456+
self,
457+
llmd_generator: LlmdDeploymentGenerator,
458+
sample_config: DeploymentConfiguration,
459+
) -> None:
460+
"""generate_all() raises ValueError when decode_replicas < 1."""
461+
with pytest.raises(ValueError, match="must be between 1 and 32"):
462+
llmd_generator.generate_all(sample_config, pd_enabled=True, decode_replicas=0)
463+
464+
def test_rejects_prefill_replicas_above_max(
465+
self,
466+
llmd_generator: LlmdDeploymentGenerator,
467+
sample_config: DeploymentConfiguration,
468+
) -> None:
469+
"""generate_all() raises ValueError when prefill_replicas > 32."""
470+
with pytest.raises(ValueError, match="must be between 1 and 32"):
471+
llmd_generator.generate_all(sample_config, pd_enabled=True, prefill_replicas=33)
472+
473+
def test_rejects_decode_replicas_above_max(
474+
self,
475+
llmd_generator: LlmdDeploymentGenerator,
476+
sample_config: DeploymentConfiguration,
477+
) -> None:
478+
"""generate_all() raises ValueError when decode_replicas > 32."""
479+
with pytest.raises(ValueError, match="must be between 1 and 32"):
480+
llmd_generator.generate_all(sample_config, pd_enabled=True, decode_replicas=33)
481+
482+
483+
@pytest.mark.unit
484+
class TestDeployAPINewParams:
485+
"""Tests for new parameters exposed in the deploy API endpoint."""
486+
487+
def test_generate_deployment_llmd_with_pd_enabled(
488+
self, client: TestClient, sample_config: DeploymentConfiguration
489+
) -> None:
490+
"""POST with pd_enabled=True should return patch_prefill and patch_decode."""
491+
response = client.post(
492+
"/api/v1/generate-deployment",
493+
json={
494+
"configuration": sample_config.model_dump(),
495+
"namespace": "test-ns",
496+
"stack": "llm-d",
497+
"pd_enabled": True,
498+
},
499+
)
500+
assert response.status_code == 200
501+
data = response.json()
502+
assert "patch_prefill" in data["files"]
503+
assert "patch_decode" in data["files"]
504+
505+
@pytest.mark.parametrize(
506+
"field",
507+
["prefill_replicas", "decode_replicas"],
508+
)
509+
def test_generate_deployment_rejects_zero_replicas(
510+
self,
511+
client: TestClient,
512+
sample_config: DeploymentConfiguration,
513+
field: str,
514+
) -> None:
515+
"""API returns 422 when replica count is < 1."""
516+
payload = {
517+
"configuration": sample_config.model_dump(),
518+
"namespace": "test-ns",
519+
"stack": "llm-d",
520+
"pd_enabled": True,
521+
field: 0,
522+
}
523+
response = client.post("/api/v1/generate-deployment", json=payload)
524+
assert response.status_code == 422
525+
526+
def test_generate_deployment_rejects_replicas_above_max(
527+
self,
528+
client: TestClient,
529+
sample_config: DeploymentConfiguration,
530+
) -> None:
531+
"""API returns 422 when replica count exceeds 32."""
532+
response = client.post(
533+
"/api/v1/generate-deployment",
534+
json={
535+
"configuration": sample_config.model_dump(),
536+
"namespace": "test-ns",
537+
"stack": "llm-d",
538+
"pd_enabled": True,
539+
"prefill_replicas": 33,
540+
},
541+
)
542+
assert response.status_code == 422

0 commit comments

Comments
 (0)