Skip to content

Proposal: PD-aware autoscaling for StormService roles #2613

Description

@googs1025

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:

prefill:decode = 2:1

If the scale-up algorithm decides the desired total size should be 12, the
weighted split becomes:

prefill = 8
decode  = 4

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:

prefill:decode = 1:2

If the scale-up algorithm decides the desired total size should be 12, the
weighted split becomes:

prefill = 4
decode  = 8

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions