Proposal: PD-aware autoscaling for StormService roles
Summary
This issue proposes extending PodAutoscaler to support PD-aware autoscaling for
disaggregated inference workloads running on StormService.
Today, PodAutoscaler can scale either:
- a whole workload through
scaleTargetRef, or
- one StormService role through
subTargetSelector.roleName.
This works for single-target autoscaling, but it is not enough for
prefill/decode disaggregated serving. In PD disaggregation, prefill and decode
are separate serving pools with different pressure signals and scaling needs.
A single autoscaling decision often needs to adjust both roles together
while keeping a workload-specific prefill/decode ratio.
The goal is to support this without introducing a new CRD.
Problem
PD-disaggregated inference has at least two independently scalable roles:
prefill: usually sensitive to queue depth, prompt tokens, and TTFT.
decode: usually sensitive to KV usage, decode backlog, and token latency.
With the current API, users need one PodAutoscaler per role:
spec:
scaleTargetRef:
apiVersion: orchestration.aibrix.ai/v1alpha1
kind: StormService
name: llama
subTargetSelector:
roleName: prefill
minReplicas: 1
maxReplicas: 12
metricsSources:
- metricSourceType: pod
targetMetric: prefill_queue_tokens
targetValue: "10000"
This creates several problems:
- Each role is scaled independently, so the autoscalers do not know the desired
relationship between prefill and decode capacity.
- It is difficult to express workload-specific ratio choices such as:
- when prefill is under pressure,
prefill:decode = 2:1 is preferred;
- when decode is under pressure,
prefill:decode = 1:2 is preferred.
- The current single-target status does not show why a PD profile was selected
or how each role contributed to the final decision.
- StormService has both
spec.replicas and role-level replicas, so the scaling
target must be explicit to avoid confusing RoleSet-level scaling with
role-pool scaling.
Business Context
This proposal is important for current PD-disaggregated serving scenarios because prefill and decode are not two fully independent capacity pools. They are separate roles with different pressure signals, but the end-to-end service quality depends on keeping them balanced for the active traffic pattern.
If users configure one PodAutoscaler for prefill and another one for decode, each autoscaler can only react to its own metrics. The two autoscalers do not know the desired workload-specific relationship between the pools, such as prefill:decode = 2:1 for long-prompt traffic or prefill:decode = 1:2 for long-output or high-concurrency generation traffic. This can scale one side while simply moving the bottleneck to the other side.
For example, when prefill pressure is high, increasing only prefill replicas may improve prompt ingestion but still leave decode capacity or KV pressure as the next limiter. Conversely, when decode is under pressure, scaling only decode without preserving enough prefill capacity can hurt TTFT or leave the service shape mismatched with the workload.
A single multi-target PodAutoscaler lets the service owner treat a StormService PD deployment as one logical serving system while still scaling individual role pools. It can select a benchmark-derived profile based on the role that is currently under pressure, then apply coordinated desired replicas to both prefill and decode.
This is mainly useful for StormService-based PD deployments where operators already know several tested P/D shapes for their workload. It is less useful for ordinary single-pool model serving, where a single-target PodAutoscaler remains simpler and sufficient.
Motivation
The desired prefill/decode shape is workload dependent. Long-prompt workloads
usually put more pressure on prefill, while long-output or high-concurrency
generation workloads usually put more pressure on decode. The right ratio also
depends on model size, tensor parallelism, KV cache capacity, latency SLOs, and
the observed input/output length distribution.
In practice, teams often benchmark several candidate shapes and keep the ones
that work well for their service. For example:
When prefill pressure is high:
prefill:decode = 2:1
When decode pressure is high:
prefill:decode = 1:2
The autoscaling API should make this operational knowledge easy to express. It
should not require users to encode a dynamic ratio formula inside the CRD.
This proposal uses profiles[] for that reason: each profile is a tested
scaling shape, and triggerTarget describes which target's pressure should
activate that shape.
Goals
- Keep using the existing
PodAutoscaler CRD.
- Support one
PodAutoscaler managing multiple StormService role targets.
- Allow each role target to keep its own metric source, min replicas, and max
replicas.
- Allow users to define simple, tested scaling profiles for different role
pressure scenarios.
- Keep the API generic enough to avoid PD-specific field names.
- Keep the status compatible with existing
PodAutoscalerStatus fields.
Non-goals
- Do not introduce a new autoscaling CRD.
- Do not add a generic resource budget field in the first version.
- Do not expose a user-written dynamic ratio formula.
- Do not make
StormService.spec.replicas > 1 part of role-pool autoscaling
semantics.
- Do not expose low-level scheduler or hardware-specific details in the API.
Proposed API
Add multi-target support to PodAutoscalerSpec:
apiVersion: autoscaling.aibrix.ai/v1alpha1
kind: PodAutoscaler
metadata:
name: llama-pd
spec:
scaleTargetRef:
apiVersion: orchestration.aibrix.ai/v1alpha1
kind: StormService
name: llama
scalingStrategy: APA
targets:
- name: prefill
subTargetSelector:
roleName: prefill
minReplicas: 4
maxReplicas: 24
metricsSources:
- metricSourceType: pod
protocolType: http
port: "8000"
path: /metrics
targetMetric: prefill_queue_tokens
targetValue: "10000"
- name: decode
subTargetSelector:
roleName: decode
minReplicas: 4
maxReplicas: 48
metricsSources:
- metricSourceType: pod
protocolType: http
port: "8000"
path: /metrics
targetMetric: decode_kv_usage
targetValue: "80"
profiles:
- name: prefill-heavy
triggerTarget: prefill
weights:
prefill: 2
decode: 1
- name: decode-heavy
triggerTarget: decode
weights:
prefill: 1
decode: 2
Field semantics
targets[] generalizes the existing single-target fields:
targets[].name is the logical target name used by profiles and status.
targets[].subTargetSelector.roleName selects the StormService role.
targets[].minReplicas and targets[].maxReplicas bound that role.
targets[].metricsSources define how pressure is observed for that role.
profiles[] defines simple scaling shapes:
profiles[].triggerTarget references targets[].name.
profiles[].weights maps target names to relative scaling weights.
- Weights are ratios, not absolute replica counts.
The concrete total desired replica count is still computed by the autoscaling
strategy. A profile only describes how that total should be shaped across
targets after a trigger target is selected.
For example:
profiles:
- name: prefill-heavy
triggerTarget: prefill
weights:
prefill: 2
decode: 1
This means: when the prefill target is the pressure trigger, shape the target
replicas toward prefill:decode = 2:1, while still respecting each target's
minReplicas and maxReplicas.
Example decision flow
Start with a StormService that runs prefill and decode as role pools:
apiVersion: orchestration.aibrix.ai/v1alpha1
kind: StormService
metadata:
name: llama
spec:
replicas: 1
template:
spec:
roles:
- name: prefill
replicas: 4
- name: decode
replicas: 4
Because spec.replicas is 1, each role's replicas represents the global
pool size:
prefill pool = 4
decode pool = 4
total = 8
Then configure one PodAutoscaler for both roles:
apiVersion: autoscaling.aibrix.ai/v1alpha1
kind: PodAutoscaler
metadata:
name: llama-pd
spec:
scaleTargetRef:
apiVersion: orchestration.aibrix.ai/v1alpha1
kind: StormService
name: llama
scalingStrategy: APA
targets:
- name: prefill
subTargetSelector:
roleName: prefill
minReplicas: 4
maxReplicas: 24
metricsSources:
- metricSourceType: pod
targetMetric: prefill_queue_tokens
targetValue: "10000"
- name: decode
subTargetSelector:
roleName: decode
minReplicas: 4
maxReplicas: 48
metricsSources:
- metricSourceType: pod
targetMetric: decode_kv_usage
targetValue: "80"
profiles:
- name: prefill-heavy
triggerTarget: prefill
weights:
prefill: 2
decode: 1
- name: decode-heavy
triggerTarget: decode
weights:
prefill: 1
decode: 2
Case 1: prefill target triggers scale up
If the prefill metric crosses its target first, the controller selects:
activeProfile: prefill-heavy
triggerTarget: prefill
The selected shape is:
If the scale-up algorithm decides the desired total size should be 12, the
weighted split becomes:
This shows that a profile is not an absolute replica assignment. It is a target
shape. The concrete desiredReplicas are still computed on each reconciliation
and must respect each target's bounds.
The status could look like:
status:
desiredScale: 12
actualScale: 8
activeProfile: prefill-heavy
triggerTarget: prefill
targets:
- name: prefill
currentReplicas: 4
desiredReplicas: 8
reason: MetricAboveTarget
- name: decode
currentReplicas: 4
desiredReplicas: 4
reason: ProfileRatio
conditions:
- type: ProfileSelected
status: "True"
reason: TriggerTargetMetricAboveTarget
message: selected profile prefill-heavy because target prefill triggered scale up
In this example, currentReplicas still reflects the observed pool size before
the new target has fully taken effect. desiredReplicas is the computed target.
After the scale operation converges, prefill's currentReplicas should move
from 4 to 8.
Case 2: decode target triggers scale up
If the decode metric crosses its target first, the controller selects:
activeProfile: decode-heavy
triggerTarget: decode
The selected shape is:
If the scale-up algorithm decides the desired total size should be 12, the
weighted split becomes:
The status could look like:
status:
desiredScale: 12
actualScale: 8
activeProfile: decode-heavy
triggerTarget: decode
targets:
- name: prefill
currentReplicas: 4
desiredReplicas: 4
reason: ProfileRatio
- name: decode
currentReplicas: 4
desiredReplicas: 8
reason: MetricAboveTarget
conditions:
- type: ProfileSelected
status: "True"
reason: TriggerTargetMetricAboveTarget
message: selected profile decode-heavy because target decode triggered scale up
StormService scaling semantics
For role-pool autoscaling, the target StormService should use one RoleSet:
apiVersion: orchestration.aibrix.ai/v1alpha1
kind: StormService
metadata:
name: llama
spec:
replicas: 1
template:
spec:
roles:
- name: prefill
replicas: 4
- name: decode
replicas: 4
In this mode, role replicas represent global pool size:
prefill pool = spec.template.spec.roles[prefill].replicas
decode pool = spec.template.spec.roles[decode].replicas
This proposal should not interpret StormService.spec.replicas > 1 as
role-pool autoscaling, because then a desired role replica count becomes
ambiguous: it could mean either per-RoleSet replicas or global replicas.
Status
Existing PodAutoscalerStatus fields should remain compatible:
status:
desiredScale: 12
actualScale: 8
lastScaleTime: ...
conditions: ...
scalingHistory: ...
scheduledBounds: ...
In multi-target mode:
status.desiredScale is the sum of status.targets[].desiredReplicas.
status.actualScale is the sum of status.targets[].currentReplicas.
- existing conditions and scaling history remain available.
Add multi-target details:
status:
desiredScale: 12
actualScale: 8
activeProfile: prefill-heavy
triggerTarget: prefill
targets:
- name: prefill
roleName: prefill
currentReplicas: 4
desiredReplicas: 8
reason: MetricAboveTarget
- name: decode
roleName: decode
currentReplicas: 4
desiredReplicas: 4
reason: ProfileRatio
conditions:
- type: ProfileSelected
status: "True"
reason: TriggerTargetMetricAboveTarget
message: selected profile prefill-heavy because target prefill triggered scale up
- type: ScaleApplied
status: "True"
reason: StormServiceUpdated
message: updated prefill and decode role replicas
This should allow users to answer:
- which profile is active;
- which target triggered that profile;
- what the desired and current replicas are for each role;
- whether the scale operation was applied successfully.
Validation
When spec.targets is set:
scaleTargetRef.kind must be StormService.
- the target StormService must have
spec.replicas == 1.
targets[].name must be unique.
- each target must set
subTargetSelector.roleName.
- each selected role must exist in the StormService template.
- top-level
subTargetSelector, minReplicas, maxReplicas, and
metricsSources should not be used together with targets[].
profiles[].name must be unique.
profiles[].triggerTarget must reference one of targets[].name.
profiles[].weights keys must reference targets[].name.
profiles[].weights values must be positive integers.
Note: the current PodAutoscalerSpec.maxReplicas field is required in the Go
API. Supporting this multi-target shape would require making the top-level
maxReplicas optional when targets[] is set, or adding equivalent conditional
validation in the CRD schema.
Backward compatibility
Existing single-target PodAutoscaler resources should continue to work without
changes:
spec:
scaleTargetRef: ...
subTargetSelector:
roleName: decode
minReplicas: 1
maxReplicas: 96
metricsSources: ...
The new behavior is only enabled when spec.targets[] is configured.
Open questions
- How should the controller select
triggerTarget when a target has multiple
metrics?
- If multiple targets trigger at the same time, should the controller select the
target with the strongest scale-up signal?
- If no target clearly triggers, should the controller keep the current profile
and current replicas?
- Should
profiles[] be required when targets[] is configured, or should
targets be allowed to scale independently without profiles?
- Should each target be allowed to override
scalingStrategy, or should the
first version keep one top-level spec.scalingStrategy?
- How should existing
schedules[] interact with targets[]? The first version
may keep schedules only for single-target autoscaling.
- Should profiles be applied only to scale-up decisions, or also to scale-down
decisions?
Related reading
The following links are non-normative background references. They specifically
motivate why the desired prefill/decode ratio is workload dependent and why a
benchmark-derived profile is useful.
- pd分离最佳配比实验方法:
describes a benchmark-driven method for measuring prefill and decode capacity
separately, then deriving suitable P/D ratios from observed RPS, TTFT, TPOT,
and KV cache limits.
- AWS: Disaggregated prefill and decode on SageMaker HyperPod:
gives practical ratio guidance: start around 1:1 for balanced workloads and
move toward 2:1 or 3:1 when prefill pressure dominates.
Proposal: PD-aware autoscaling for StormService roles
Summary
This issue proposes extending
PodAutoscalerto support PD-aware autoscaling fordisaggregated inference workloads running on
StormService.Today,
PodAutoscalercan scale either:scaleTargetRef, orsubTargetSelector.roleName.This works for single-target autoscaling, but it is not enough for
prefill/decode disaggregated serving. In PD disaggregation, prefill and decode
are separate serving pools with different pressure signals and scaling needs.
A single autoscaling decision often needs to adjust both roles together
while keeping a workload-specific prefill/decode ratio.
The goal is to support this without introducing a new CRD.
Problem
PD-disaggregated inference has at least two independently scalable roles:
prefill: usually sensitive to queue depth, prompt tokens, and TTFT.decode: usually sensitive to KV usage, decode backlog, and token latency.With the current API, users need one
PodAutoscalerper role:This creates several problems:
relationship between prefill and decode capacity.
prefill:decode = 2:1is preferred;prefill:decode = 1:2is preferred.or how each role contributed to the final decision.
spec.replicasand role-level replicas, so the scalingtarget must be explicit to avoid confusing RoleSet-level scaling with
role-pool scaling.
Business Context
This proposal is important for current PD-disaggregated serving scenarios because
prefillanddecodeare not two fully independent capacity pools. They are separate roles with different pressure signals, but the end-to-end service quality depends on keeping them balanced for the active traffic pattern.If users configure one
PodAutoscalerforprefilland another one fordecode, each autoscaler can only react to its own metrics. The two autoscalers do not know the desired workload-specific relationship between the pools, such asprefill:decode = 2:1for long-prompt traffic orprefill:decode = 1:2for long-output or high-concurrency generation traffic. This can scale one side while simply moving the bottleneck to the other side.For example, when
prefillpressure is high, increasing only prefill replicas may improve prompt ingestion but still leave decode capacity or KV pressure as the next limiter. Conversely, when decode is under pressure, scaling only decode without preserving enough prefill capacity can hurt TTFT or leave the service shape mismatched with the workload.A single multi-target
PodAutoscalerlets the service owner treat a StormService PD deployment as one logical serving system while still scaling individual role pools. It can select a benchmark-derived profile based on the role that is currently under pressure, then apply coordinated desired replicas to bothprefillanddecode.This is mainly useful for StormService-based PD deployments where operators already know several tested P/D shapes for their workload. It is less useful for ordinary single-pool model serving, where a single-target
PodAutoscalerremains simpler and sufficient.Motivation
The desired prefill/decode shape is workload dependent. Long-prompt workloads
usually put more pressure on prefill, while long-output or high-concurrency
generation workloads usually put more pressure on decode. The right ratio also
depends on model size, tensor parallelism, KV cache capacity, latency SLOs, and
the observed input/output length distribution.
In practice, teams often benchmark several candidate shapes and keep the ones
that work well for their service. For example:
The autoscaling API should make this operational knowledge easy to express. It
should not require users to encode a dynamic ratio formula inside the CRD.
This proposal uses
profiles[]for that reason: each profile is a testedscaling shape, and
triggerTargetdescribes which target's pressure shouldactivate that shape.
Goals
PodAutoscalerCRD.PodAutoscalermanaging multiple StormService role targets.replicas.
pressure scenarios.
PodAutoscalerStatusfields.Non-goals
StormService.spec.replicas > 1part of role-pool autoscalingsemantics.
Proposed API
Add multi-target support to
PodAutoscalerSpec:Field semantics
targets[]generalizes the existing single-target fields:targets[].nameis the logical target name used by profiles and status.targets[].subTargetSelector.roleNameselects the StormService role.targets[].minReplicasandtargets[].maxReplicasbound that role.targets[].metricsSourcesdefine how pressure is observed for that role.profiles[]defines simple scaling shapes:profiles[].triggerTargetreferencestargets[].name.profiles[].weightsmaps target names to relative scaling weights.The concrete total desired replica count is still computed by the autoscaling
strategy. A profile only describes how that total should be shaped across
targets after a trigger target is selected.
For example:
This means: when the
prefilltarget is the pressure trigger, shape the targetreplicas toward
prefill:decode = 2:1, while still respecting each target'sminReplicasandmaxReplicas.Example decision flow
Start with a
StormServicethat runs prefill and decode as role pools:Because
spec.replicasis1, each role'sreplicasrepresents the globalpool size:
Then configure one
PodAutoscalerfor both roles:Case 1: prefill target triggers scale up
If the prefill metric crosses its target first, the controller selects:
The selected shape is:
If the scale-up algorithm decides the desired total size should be
12, theweighted split becomes:
This shows that a profile is not an absolute replica assignment. It is a target
shape. The concrete
desiredReplicasare still computed on each reconciliationand must respect each target's bounds.
The status could look like:
In this example,
currentReplicasstill reflects the observed pool size beforethe new target has fully taken effect.
desiredReplicasis the computed target.After the scale operation converges, prefill's
currentReplicasshould movefrom
4to8.Case 2: decode target triggers scale up
If the decode metric crosses its target first, the controller selects:
The selected shape is:
If the scale-up algorithm decides the desired total size should be
12, theweighted split becomes:
The status could look like:
StormService scaling semantics
For role-pool autoscaling, the target StormService should use one RoleSet:
In this mode, role replicas represent global pool size:
This proposal should not interpret
StormService.spec.replicas > 1asrole-pool autoscaling, because then a desired role replica count becomes
ambiguous: it could mean either per-RoleSet replicas or global replicas.
Status
Existing
PodAutoscalerStatusfields should remain compatible:In multi-target mode:
status.desiredScaleis the sum ofstatus.targets[].desiredReplicas.status.actualScaleis the sum ofstatus.targets[].currentReplicas.Add multi-target details:
This should allow users to answer:
Validation
When
spec.targetsis set:scaleTargetRef.kindmust beStormService.spec.replicas == 1.targets[].namemust be unique.subTargetSelector.roleName.subTargetSelector,minReplicas,maxReplicas, andmetricsSourcesshould not be used together withtargets[].profiles[].namemust be unique.profiles[].triggerTargetmust reference one oftargets[].name.profiles[].weightskeys must referencetargets[].name.profiles[].weightsvalues must be positive integers.Note: the current
PodAutoscalerSpec.maxReplicasfield is required in the GoAPI. Supporting this multi-target shape would require making the top-level
maxReplicasoptional whentargets[]is set, or adding equivalent conditionalvalidation in the CRD schema.
Backward compatibility
Existing single-target
PodAutoscalerresources should continue to work withoutchanges:
The new behavior is only enabled when
spec.targets[]is configured.Open questions
triggerTargetwhen a target has multiplemetrics?
target with the strongest scale-up signal?
and current replicas?
profiles[]be required whentargets[]is configured, or shouldtargets be allowed to scale independently without profiles?
scalingStrategy, or should thefirst version keep one top-level
spec.scalingStrategy?schedules[]interact withtargets[]? The first versionmay keep schedules only for single-target autoscaling.
decisions?
Related reading
The following links are non-normative background references. They specifically
motivate why the desired prefill/decode ratio is workload dependent and why a
benchmark-derived profile is useful.
describes a benchmark-driven method for measuring prefill and decode capacity
separately, then deriving suitable P/D ratios from observed RPS, TTFT, TPOT,
and KV cache limits.
gives practical ratio guidance: start around 1:1 for balanced workloads and
move toward 2:1 or 3:1 when prefill pressure dominates.