Skip to content

Commit 24dcacc

Browse files
committed
feat(flowcontrol): add VTC fairness policy with token-based cost and starvation prevention
Signed-off-by: Guangya Liu <gyliu513@gmail.com>
1 parent 75ff054 commit 24dcacc

4 files changed

Lines changed: 753 additions & 0 deletions

File tree

cmd/epp/runner/runner.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import (
7474
sourcenotifications "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/source/notifications"
7575
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/fairness/globalstrict"
7676
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/fairness/roundrobin"
77+
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/fairness/vtc"
7778
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/ordering/edf"
7879
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/ordering/fcfs"
7980
slodeadline "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/ordering/slodeadline"
@@ -543,6 +544,7 @@ func (r *Runner) registerInTreePlugins() {
543544
// Flow Control plugins
544545
fwkplugin.Register(globalstrict.GlobalStrictFairnessPolicyType, globalstrict.GlobalStrictFairnessPolicyFactory)
545546
fwkplugin.Register(roundrobin.RoundRobinFairnessPolicyType, roundrobin.RoundRobinFairnessPolicyFactory)
547+
fwkplugin.Register(vtc.VTCFairnessPolicyType, vtc.VTCFairnessPolicyFactory)
546548
fwkplugin.Register(fcfs.FCFSOrderingPolicyType, fcfs.FCFSOrderingPolicyFactory)
547549
fwkplugin.Register(edf.EDFOrderingPolicyType, edf.EDFOrderingPolicyFactory)
548550
fwkplugin.Register(slodeadline.SLODeadlineOrderingPolicyType, slodeadline.SLODeadlineOrderingPolicyFactory)

pkg/epp/framework/plugins/flowcontrol/fairness/functional_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
2929
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/fairness/globalstrict"
3030
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/fairness/roundrobin"
31+
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/fairness/vtc"
3132
)
3233

3334
// TestFairnessPolicyConformance is the main conformance test suite for FairnessPolicy implementations.
@@ -39,6 +40,7 @@ func TestFairnessPolicyConformance(t *testing.T) {
3940
policies := map[string]fwkplugin.FactoryFunc{
4041
globalstrict.GlobalStrictFairnessPolicyType: globalstrict.GlobalStrictFairnessPolicyFactory,
4142
roundrobin.RoundRobinFairnessPolicyType: roundrobin.RoundRobinFairnessPolicyFactory,
43+
vtc.VTCFairnessPolicyType: vtc.VTCFairnessPolicyFactory,
4244
}
4345

4446
for name, f := range policies {
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package vtc implements a Virtual Token Count (VTC) fairness policy for flow control.
18+
//
19+
// Unlike round-robin (which gives each flow one turn regardless of request size), VTC tracks a
20+
// cumulative virtual token cost per flow. The flow with the lowest virtual counter gets the next
21+
// dispatch opportunity, ensuring tenants sending large prompts do not starve tenants sending small
22+
// ones.
23+
//
24+
// Cost function:
25+
//
26+
// cost = inputTokenWeight × inputTokens + outputTokenWeight × outputTokens
27+
//
28+
// Input tokens are estimated via a cascade: client-provided hint → pre-tokenized prompt length →
29+
// prompt character count / 4 → request byte size / 4. Output tokens default to 128 unless
30+
// configured otherwise.
31+
//
32+
// Starvation prevention: when a flow's counter does not yet exist (new or rejoining after idle),
33+
// its counter is initialized to the current minimum among active flows rather than zero, preventing
34+
// it from monopolizing dispatch turns when it returns.
35+
package vtc
36+
37+
import (
38+
"context"
39+
"encoding/json"
40+
"fmt"
41+
"math"
42+
"slices"
43+
"sync"
44+
45+
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
46+
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
47+
)
48+
49+
const (
50+
// VTCFairnessPolicyType is the registration key for the VTC fairness policy.
51+
VTCFairnessPolicyType = "vtc-fairness-policy"
52+
53+
// normalizationThreshold prevents unbounded float64 growth. When any counter exceeds this,
54+
// the global minimum is subtracted from all counters to preserve relative differences.
55+
normalizationThreshold = 1e12
56+
57+
// defaultOutputTokenEstimate is used when the request does not carry an explicit max_tokens hint.
58+
defaultOutputTokenEstimate = 128.0
59+
)
60+
61+
// VTCFairnessPolicyFactory creates a VTC fairness policy from optional JSON parameters.
62+
//
63+
// Supported parameters:
64+
//
65+
// {
66+
// "inputTokenWeight": 1.0,
67+
// "outputTokenWeight": 3.0
68+
// }
69+
//
70+
// outputTokenWeight defaults to 3.0 to reflect that autoregressive decode tokens consume
71+
// sequentially more cache and execution time than prefill tokens.
72+
func VTCFairnessPolicyFactory(name string, params json.RawMessage, _ fwkplugin.Handle) (fwkplugin.Plugin, error) {
73+
return newVTC(name, params)
74+
}
75+
76+
// vtcConfig holds the JSON-parsed configuration for the VTC policy.
77+
type vtcConfig struct {
78+
InputTokenWeight float64 `json:"inputTokenWeight"`
79+
OutputTokenWeight float64 `json:"outputTokenWeight"`
80+
}
81+
82+
// vtc implements FairnessPolicy using Virtual Token Counting.
83+
// The struct is immutable after construction and shared across all priority bands (Singleton).
84+
type vtc struct {
85+
name string
86+
inputTokenWeight float64
87+
outputTokenWeight float64
88+
}
89+
90+
func newVTC(name string, params json.RawMessage) (*vtc, error) {
91+
if name == "" {
92+
name = VTCFairnessPolicyType
93+
}
94+
95+
cfg := vtcConfig{
96+
InputTokenWeight: 1.0,
97+
OutputTokenWeight: 3.0,
98+
}
99+
100+
if len(params) > 0 {
101+
if err := json.Unmarshal(params, &cfg); err != nil {
102+
return nil, fmt.Errorf("failed to parse VTC parameters: %w", err)
103+
}
104+
}
105+
106+
if cfg.InputTokenWeight <= 0 {
107+
cfg.InputTokenWeight = 1.0
108+
}
109+
if cfg.OutputTokenWeight <= 0 {
110+
cfg.OutputTokenWeight = 3.0
111+
}
112+
113+
return &vtc{
114+
name: name,
115+
inputTokenWeight: cfg.InputTokenWeight,
116+
outputTokenWeight: cfg.OutputTokenWeight,
117+
}, nil
118+
}
119+
120+
// TypedName returns the type and name tuple of this plugin instance.
121+
func (p *vtc) TypedName() fwkplugin.TypedName {
122+
return fwkplugin.TypedName{
123+
Type: VTCFairnessPolicyType,
124+
Name: p.name,
125+
}
126+
}
127+
128+
// vtcBandState holds the mutable per-band state for the VTC policy (Flyweight pattern).
129+
type vtcBandState struct {
130+
mu sync.Mutex
131+
counters map[string]float64 // flow ID → cumulative virtual token cost
132+
}
133+
134+
// NewState initializes the policy state for a specific priority band.
135+
func (p *vtc) NewState(_ context.Context) any {
136+
return &vtcBandState{
137+
counters: make(map[string]float64),
138+
}
139+
}
140+
141+
// Pick selects the flow with the lowest virtual token counter from the given priority band.
142+
//
143+
// Algorithm:
144+
// 1. Sort active flow keys for deterministic tie-breaking.
145+
// 2. For each non-empty queue, initialize its counter (with counter-lift if rejoining).
146+
// 3. Select the flow with the smallest counter.
147+
// 4. Advance the winner's counter by its estimated token cost.
148+
// 5. Prune counters for flows no longer in the active set.
149+
// 6. Normalize counters if any value exceeds the threshold.
150+
func (p *vtc) Pick(
151+
_ context.Context,
152+
flowGroup flowcontrol.PriorityBandAccessor,
153+
) (flowcontrol.FlowQueueAccessor, error) {
154+
if flowGroup == nil {
155+
return nil, nil //nolint:nilnil
156+
}
157+
158+
v := flowGroup.PolicyState()
159+
s, ok := v.(*vtcBandState)
160+
if !ok {
161+
return nil, fmt.Errorf("invalid state type for VTC policy: expected *vtcBandState, got %T", v)
162+
}
163+
164+
s.mu.Lock()
165+
defer s.mu.Unlock()
166+
167+
keys := flowGroup.FlowKeys()
168+
if len(keys) == 0 {
169+
return nil, nil //nolint:nilnil
170+
}
171+
172+
// Sort for deterministic tie-breaking.
173+
slices.SortFunc(keys, func(a, b flowcontrol.FlowKey) int { return a.Compare(b) })
174+
175+
// Compute the minimum counter among active queues that already have a counter.
176+
// Used to lift rejoining flows so they don't monopolize dispatch turns.
177+
activeMin := math.MaxFloat64
178+
for _, key := range keys {
179+
queue := flowGroup.Queue(key.ID)
180+
if queue == nil || queue.Len() == 0 {
181+
continue
182+
}
183+
if c, exists := s.counters[key.ID]; exists && c < activeMin {
184+
activeMin = c
185+
}
186+
}
187+
if activeMin == math.MaxFloat64 {
188+
activeMin = 0
189+
}
190+
191+
// Find the non-empty flow with the lowest virtual counter.
192+
var bestQueue flowcontrol.FlowQueueAccessor
193+
bestCounter := math.MaxFloat64
194+
activeIDs := make(map[string]struct{}, len(keys))
195+
196+
for _, key := range keys {
197+
activeIDs[key.ID] = struct{}{}
198+
199+
queue := flowGroup.Queue(key.ID)
200+
if queue == nil || queue.Len() == 0 {
201+
continue
202+
}
203+
204+
// Counter-lift: a new or rejoining flow starts at activeMin to avoid unfairly consuming
205+
// all dispatch capacity upon return.
206+
if _, exists := s.counters[key.ID]; !exists {
207+
s.counters[key.ID] = activeMin
208+
}
209+
210+
if s.counters[key.ID] < bestCounter {
211+
bestCounter = s.counters[key.ID]
212+
bestQueue = queue
213+
}
214+
}
215+
216+
if bestQueue == nil {
217+
return nil, nil //nolint:nilnil
218+
}
219+
220+
// Advance the winner's counter by its estimated token cost.
221+
winnerID := bestQueue.FlowKey().ID
222+
s.counters[winnerID] += p.estimateCost(bestQueue)
223+
224+
// Prune counters for flows no longer in the active set.
225+
for id := range s.counters {
226+
if _, active := activeIDs[id]; !active {
227+
delete(s.counters, id)
228+
}
229+
}
230+
231+
normalizeCounters(s)
232+
233+
return bestQueue, nil
234+
}
235+
236+
// estimateCost computes the weighted token cost for the head item of a queue.
237+
func (p *vtc) estimateCost(queue flowcontrol.FlowQueueAccessor) float64 {
238+
head := queue.PeekHead()
239+
if head == nil {
240+
return p.inputTokenWeight + p.outputTokenWeight*defaultOutputTokenEstimate
241+
}
242+
243+
req := head.OriginalRequest()
244+
if req == nil {
245+
return p.inputTokenWeight + p.outputTokenWeight*defaultOutputTokenEstimate
246+
}
247+
248+
inputTokens := estimateInputTokens(req)
249+
cost := p.inputTokenWeight*inputTokens + p.outputTokenWeight*defaultOutputTokenEstimate
250+
if cost <= 0 {
251+
// Floor to prevent counter stagnation on zero-cost requests.
252+
return 1
253+
}
254+
return cost
255+
}
256+
257+
// estimateInputTokens estimates the input token count from the request via a cascade of heuristics.
258+
//
259+
// Priority order:
260+
// 1. Client-provided hint via InputTokenCountHint() (e.g., pre-tokenized completions or /inference/v1/generate token IDs).
261+
// 2. Length of a pre-tokenized prompt in InferenceRequestBody.TokenizedPrompt.
262+
// 3. Character-based estimate: PromptText length / 4.
263+
// 4. Raw request size estimate: InferenceRequest.RequestSizeBytes / 4.
264+
// 5. Final fallback: FlowControlRequest.ByteSize() / 4.
265+
func estimateInputTokens(req flowcontrol.FlowControlRequest) float64 {
266+
ir := req.InferenceRequest()
267+
if ir != nil && ir.Body != nil {
268+
if hint := ir.Body.InputTokenCountHint(); hint > 0 {
269+
return float64(hint)
270+
}
271+
272+
if ir.Body.TokenizedPrompt != nil && len(ir.Body.TokenizedPrompt.TokenIDs) > 0 {
273+
return float64(len(ir.Body.TokenizedPrompt.TokenIDs))
274+
}
275+
276+
if text := ir.Body.PromptText(); len(text) > 0 {
277+
return float64(len(text)) / 4.0
278+
}
279+
280+
if ir.RequestSizeBytes > 0 {
281+
return float64(ir.RequestSizeBytes) / 4.0
282+
}
283+
}
284+
285+
if bs := req.ByteSize(); bs > 0 {
286+
return float64(bs) / 4.0
287+
}
288+
289+
return 1 // minimum to avoid zero-cost dispatch
290+
}
291+
292+
// normalizeCounters subtracts the global minimum from all counters when any counter exceeds the
293+
// threshold, preserving relative differences while preventing unbounded growth.
294+
func normalizeCounters(s *vtcBandState) {
295+
var maxC float64
296+
for _, c := range s.counters {
297+
if c > maxC {
298+
maxC = c
299+
}
300+
}
301+
if maxC <= normalizationThreshold {
302+
return
303+
}
304+
305+
minC := math.MaxFloat64
306+
for _, c := range s.counters {
307+
if c < minC {
308+
minC = c
309+
}
310+
}
311+
for id := range s.counters {
312+
s.counters[id] -= minC
313+
}
314+
}

0 commit comments

Comments
 (0)