Skip to content

Commit 893a9b5

Browse files
committed
feature: add slo metrics to flowcontrol
Signed-off-by: Loic Marchal <lmarchal@redhat.com>
1 parent e7b7d75 commit 893a9b5

8 files changed

Lines changed: 317 additions & 47 deletions

File tree

pkg/epp/flowcontrol/controller/controller.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/contracts"
3939
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/controller/internal"
4040
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/types"
41+
fwkrequest "github.com/llm-d/llm-d-router/pkg/epp/framework/common/request"
4142
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
4243
"github.com/llm-d/llm-d-router/pkg/epp/metrics"
4344
)
@@ -222,21 +223,27 @@ func (fc *FlowController) EnqueueAndWait(
222223
flowKey := req.FlowKey()
223224
priority := strconv.Itoa(flowKey.Priority)
224225
reqBytes := req.ByteSize()
226+
sloClass := metrics.ClassifySLO(extractHeader(req, fwkrequest.TTFTSLOMsHeaderKey))
227+
metrics.RecordFlowControlSLOIncomingRequest(sloClass, req.InferencePoolName())
225228
metrics.IncFlowControlQueueSize(
226229
flowKey.ID, priority,
227230
req.InferencePoolName(),
231+
sloClass,
228232
req.ModelName(), req.TargetModelName())
229233
defer metrics.DecFlowControlQueueSize(
230234
flowKey.ID, priority,
231235
req.InferencePoolName(),
236+
sloClass,
232237
req.ModelName(), req.TargetModelName())
233238
metrics.AddFlowControlQueueBytes(
234239
flowKey.ID, priority,
235240
req.InferencePoolName(),
241+
sloClass,
236242
req.ModelName(), req.TargetModelName(), reqBytes)
237243
defer metrics.SubFlowControlQueueBytes(
238244
flowKey.ID, priority,
239245
req.InferencePoolName(),
246+
sloClass,
240247
req.ModelName(), req.TargetModelName(), reqBytes)
241248

242249
// 1. Create the derived context that governs this request's lifecycle (Parent Cancellation + TTL).
@@ -286,6 +293,14 @@ func (fc *FlowController) EnqueueAndWait(
286293
return finalOutcome, err
287294
}
288295

296+
func extractHeader(req flowcontrol.FlowControlRequest, name string) string {
297+
infReq := req.InferenceRequest()
298+
if infReq == nil || infReq.Headers == nil {
299+
return ""
300+
}
301+
return fwkrequest.GetHeader(infReq.Headers, name)
302+
}
303+
289304
var errNoShards = errors.New("no viable active shards available")
290305

291306
// tryDistribution handles a single attempt to select a shard and submit a request.

pkg/epp/flowcontrol/controller/internal/item.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"time"
2727

2828
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/types"
29+
"github.com/llm-d/llm-d-router/pkg/epp/framework/common/request"
2930
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
3031
"github.com/llm-d/llm-d-router/pkg/epp/metrics"
3132
)
@@ -159,16 +160,30 @@ func (fi *FlowItem) finalizeInternal(outcome types.QueueOutcome, err error) {
159160

160161
duration := time.Since(fi.enqueueTime)
161162
flowKey := fi.originalRequest.FlowKey()
163+
outcomeStr := outcome.String()
162164
metrics.RecordFlowControlRequestQueueDuration(
163-
flowKey.ID, strconv.Itoa(flowKey.Priority), outcome.String(),
165+
flowKey.ID, strconv.Itoa(flowKey.Priority), outcomeStr,
164166
fi.originalRequest.InferencePoolName(),
165167
fi.OriginalRequest().ModelName(), fi.OriginalRequest().TargetModelName(),
166168
duration)
167169

170+
sloClass := metrics.ClassifySLO(extractHeader(fi.originalRequest, request.TTFTSLOMsHeaderKey))
171+
metrics.RecordFlowControlSLORequestQueueDuration(
172+
sloClass, outcomeStr, fi.originalRequest.InferencePoolName(),
173+
duration)
174+
168175
fi.done <- finalState
169176
close(fi.done)
170177
}
171178

179+
func extractHeader(req flowcontrol.FlowControlRequest, name string) string {
180+
infReq := req.InferenceRequest()
181+
if infReq == nil || infReq.Headers == nil {
182+
return ""
183+
}
184+
return request.GetHeader(infReq.Headers, name)
185+
}
186+
172187
// inferOutcome determines the correct QueueOutcome and Error based on the cause of finalization and whether the item
173188
// was already admitted to a queue.
174189
func inferOutcome(cause error, isQueued bool) (types.QueueOutcome, error) {

pkg/epp/framework/common/request/headers.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ package request
1818

1919
import "strings"
2020

21+
const (
22+
// TTFTSLOMsHeaderKey is the request header name for SLO time-to-first-token in milliseconds.
23+
TTFTSLOMsHeaderKey = "x-slo-ttft-ms"
24+
// TPOTSLOMsHeaderKey is the request header name for SLO time-per-output-token in milliseconds.
25+
TPOTSLOMsHeaderKey = "x-slo-tpot-ms"
26+
)
27+
2128
// GetHeader returns the value for key from headers, with case-insensitive lookup.
2229
func GetHeader(headers map[string]string, key string) string {
2330
if v, ok := headers[key]; ok {

pkg/epp/framework/common/request/headers_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
func TestGetHeader(t *testing.T) {
2626
headers := map[string]string{"X-SLO-TTFT-MS": "42", "Other": "x"}
2727
assert.Equal(t, "42", GetHeader(headers, "X-SLO-TTFT-MS"))
28-
assert.Equal(t, "42", GetHeader(headers, "x-slo-ttft-ms"))
28+
assert.Equal(t, "42", GetHeader(headers, TTFTSLOMsHeaderKey))
2929
assert.Equal(t, "", GetHeader(headers, "missing"))
3030
assert.Equal(t, "", GetHeader(nil, "k"))
3131
}

pkg/epp/framework/plugins/flowcontrol/ordering/slodeadline/slo_deadline.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,6 @@ const (
3737
// It selects the request with the earliest SLO-based deadline.
3838
// For detailed documentation, see README.md.
3939
SLODeadlineOrderingPolicyType = "slo-deadline-ordering-policy"
40-
41-
// sloTtftHeader is the request header name for SLO time-to-first-token in milliseconds.
42-
sloTtftHeader = "x-slo-ttft-ms"
4340
)
4441

4542
func SLODeadlineOrderingPolicyFactory(name string, _ json.RawMessage, _ plugin.Handle) (plugin.Plugin, error) {
@@ -95,11 +92,11 @@ func calculateSLODeadline(item flowcontrol.QueueItemAccessor) time.Time {
9592
if infReq == nil || infReq.Headers == nil {
9693
return sloMaxDeadlineTime
9794
}
98-
sloTtft := request.GetHeader(infReq.Headers, sloTtftHeader)
99-
if sloTtft == "" {
95+
sloTTFT := request.GetHeader(infReq.Headers, request.TTFTSLOMsHeaderKey)
96+
if sloTTFT == "" {
10097
return sloMaxDeadlineTime
10198
}
102-
ms, err := strconv.ParseInt(strings.TrimSpace(sloTtft), 10, 64)
99+
ms, err := strconv.ParseInt(strings.TrimSpace(sloTTFT), 10, 64)
103100
if err != nil || ms < 0 {
104101
return sloMaxDeadlineTime
105102
}

pkg/epp/framework/plugins/flowcontrol/ordering/slodeadline/slo_deadline_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/stretchr/testify/assert"
2424
"github.com/stretchr/testify/require"
2525

26+
fwkrequest "github.com/llm-d/llm-d-router/pkg/epp/framework/common/request"
2627
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
2728
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol/mocks"
2829
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
@@ -54,7 +55,7 @@ func TestSLODeadlinePolicy_RequiredQueueCapabilities(t *testing.T) {
5455
func makeSLOItem(id string, received time.Time, sloTTFTMs string) flowcontrol.QueueItemAccessor {
5556
req := mocks.NewMockFlowControlRequest(10, id, testFlowKey)
5657
req.ReceivedTimestampV = received
57-
req.InferenceRequestV = &scheduling.InferenceRequest{Headers: map[string]string{sloTtftHeader: sloTTFTMs}}
58+
req.InferenceRequestV = &scheduling.InferenceRequest{Headers: map[string]string{fwkrequest.TTFTSLOMsHeaderKey: sloTTFTMs}}
5859
return &mocks.MockQueueItemAccessor{
5960
EffectiveTTLV: 0,
6061
OriginalRequestV: req,
@@ -115,7 +116,7 @@ func TestCalculateSLODeadline(t *testing.T) {
115116
// Valid header
116117
reqValid := mocks.NewMockFlowControlRequest(1, "valid", testFlowKey)
117118
reqValid.ReceivedTimestampV = now
118-
reqValid.InferenceRequestV = &scheduling.InferenceRequest{Headers: map[string]string{sloTtftHeader: "200"}}
119+
reqValid.InferenceRequestV = &scheduling.InferenceRequest{Headers: map[string]string{fwkrequest.TTFTSLOMsHeaderKey: "200"}}
119120
accValid := &mocks.MockQueueItemAccessor{OriginalRequestV: reqValid}
120121
deadline := calculateSLODeadline(accValid)
121122
assert.Equal(t, now.Add(200*time.Millisecond), deadline)
@@ -132,7 +133,7 @@ func TestCalculateSLODeadline(t *testing.T) {
132133

133134
// Invalid value
134135
reqInvalid := mocks.NewMockFlowControlRequest(3, "inv", testFlowKey)
135-
reqInvalid.InferenceRequestV = &scheduling.InferenceRequest{Headers: map[string]string{sloTtftHeader: "x"}}
136+
reqInvalid.InferenceRequestV = &scheduling.InferenceRequest{Headers: map[string]string{fwkrequest.TTFTSLOMsHeaderKey: "x"}}
136137
accInvalid := &mocks.MockQueueItemAccessor{OriginalRequestV: reqInvalid}
137138
assert.Equal(t, sloMaxDeadlineTime, calculateSLODeadline(accInvalid))
138139

pkg/epp/metrics/metrics.go

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,27 @@ var (
401401
append([]string{"fairness_id", "priority", "outcome", "inference_pool"}, modelLabels...),
402402
)
403403

404+
flowControlSLORequestQueueDuration = prometheus.NewHistogramVec(
405+
prometheus.HistogramOpts{
406+
Subsystem: inferenceExtension,
407+
Name: "flow_control_slo_request_queue_duration_seconds",
408+
Help: metricsutil.HelpMsgWithStability("Distribution of the total time requests spend in the EPP flow control layer, partitioned by SLO class.", compbasemetrics.ALPHA),
409+
Buckets: []float64{
410+
0.0001, 0.0005, 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0,
411+
},
412+
},
413+
[]string{"slo_class", "outcome", "inference_pool"},
414+
)
415+
416+
flowControlSLOIncomingRequestsTotal = prometheus.NewCounterVec(
417+
prometheus.CounterOpts{
418+
Subsystem: inferenceExtension,
419+
Name: "flow_control_slo_incoming_requests_total",
420+
Help: metricsutil.HelpMsgWithStability("Total number of requests that entered the EPP flow control layer via EnqueueAndWait.", compbasemetrics.ALPHA),
421+
},
422+
[]string{"slo_class", "inference_pool"},
423+
)
424+
404425
flowControlDispatchCycleDuration = prometheus.NewHistogramVec(
405426
prometheus.HistogramOpts{
406427
Subsystem: inferenceExtension,
@@ -431,7 +452,7 @@ var (
431452
Name: "flow_control_queue_size",
432453
Help: metricsutil.HelpMsgWithStability("Current number of requests actively held in the Flow Control queue.", compbasemetrics.ALPHA),
433454
},
434-
append([]string{"fairness_id", "priority", "inference_pool"}, modelLabels...),
455+
append([]string{"fairness_id", "priority", "inference_pool", "slo_class"}, modelLabels...),
435456
)
436457

437458
flowControlQueueBytes = prometheus.NewGaugeVec(
@@ -440,7 +461,7 @@ var (
440461
Name: "flow_control_queue_bytes",
441462
Help: metricsutil.HelpMsgWithStability("Current total size in bytes of requests actively held in the Flow Control queue.", compbasemetrics.ALPHA),
442463
},
443-
append([]string{"fairness_id", "priority", "inference_pool"}, modelLabels...),
464+
append([]string{"fairness_id", "priority", "inference_pool", "slo_class"}, modelLabels...),
444465
)
445466

446467
flowControlPoolSaturation = prometheus.NewGaugeVec(
@@ -505,6 +526,8 @@ func Register(customCollectors ...prometheus.Collector) {
505526
metrics.Registry.MustRegister(prefixCacheHitRatio)
506527
metrics.Registry.MustRegister(prefixCacheHitLength)
507528
metrics.Registry.MustRegister(flowControlRequestQueueDuration)
529+
metrics.Registry.MustRegister(flowControlSLORequestQueueDuration)
530+
metrics.Registry.MustRegister(flowControlSLOIncomingRequestsTotal)
508531
metrics.Registry.MustRegister(flowControlDispatchCycleDuration)
509532
metrics.Registry.MustRegister(flowControlQueueSize)
510533
metrics.Registry.MustRegister(flowControlQueueBytes)
@@ -556,6 +579,8 @@ func Reset() {
556579
prefixCacheHitRatio.Reset()
557580
prefixCacheHitLength.Reset()
558581
flowControlRequestQueueDuration.Reset()
582+
flowControlSLORequestQueueDuration.Reset()
583+
flowControlSLOIncomingRequestsTotal.Reset()
559584
flowControlQueueSize.Reset()
560585
flowControlQueueBytes.Reset()
561586
flowControlPoolSaturation.Reset()
@@ -858,6 +883,59 @@ func RecordFlowControlRequestQueueDuration(
858883
).Observe(duration.Seconds())
859884
}
860885

886+
// SLO class constants label for flow control SLO metrics (bounded buckets for the TTFT SLO header in ms).
887+
const (
888+
SLOClassNone = "none"
889+
SLOClassBelowMS200 = "below_ms_200"
890+
SLOClassMS200to399 = "ms_200_399"
891+
SLOClassMS400to599 = "ms_400_599"
892+
SLOClassMS600to799 = "ms_600_799"
893+
SLOClassMS800to1000 = "ms_800_1000"
894+
SLOClassAboveMS1000 = "above_ms_1000"
895+
)
896+
897+
// ClassifySLO maps a raw SLO header value (in milliseconds) to a bounded SLO class label.
898+
// Returns SLOClassNone when the header is absent or unparseable.
899+
func ClassifySLO(rawHeaderValue string) string {
900+
if rawHeaderValue == "" {
901+
return SLOClassNone
902+
}
903+
ms, err := strconv.ParseInt(rawHeaderValue, 10, 64)
904+
if err != nil || ms < 0 {
905+
return SLOClassNone
906+
}
907+
switch {
908+
case ms < 200:
909+
return SLOClassBelowMS200
910+
case ms < 400:
911+
return SLOClassMS200to399
912+
case ms < 600:
913+
return SLOClassMS400to599
914+
case ms < 800:
915+
return SLOClassMS600to799
916+
case ms <= 1000:
917+
return SLOClassMS800to1000
918+
default:
919+
return SLOClassAboveMS1000
920+
}
921+
}
922+
923+
// RecordFlowControlSLORequestQueueDuration records the queue duration for a request partitioned by its
924+
// SLO class (derived from the TTFT SLO header "x-slo-ttft-ms").
925+
func RecordFlowControlSLORequestQueueDuration(
926+
sloClass, outcome, inferencePool string,
927+
duration time.Duration,
928+
) {
929+
flowControlSLORequestQueueDuration.WithLabelValues(
930+
sloClass, outcome, inferencePool,
931+
).Observe(duration.Seconds())
932+
}
933+
934+
// RecordFlowControlSLOIncomingRequest increments the count of requests that entered flow control at EnqueueAndWait.
935+
func RecordFlowControlSLOIncomingRequest(sloClass, inferencePool string) {
936+
flowControlSLOIncomingRequestsTotal.WithLabelValues(sloClass, inferencePool).Inc()
937+
}
938+
861939
// RecordFlowControlDispatchCycleDuration records the duration of a dispatch cycle in the Flow Control layer.
862940
func RecordFlowControlDispatchCycleDuration(duration time.Duration) {
863941
flowControlDispatchCycleDuration.WithLabelValues().Observe(duration.Seconds())
@@ -874,23 +952,23 @@ func RecordFlowControlRequestEnqueueDuration(
874952
}
875953

876954
// IncFlowControlQueueSize increments the Flow Control queue size gauge.
877-
func IncFlowControlQueueSize(fairnessID, priority, inferencePool, modelName, targetModelName string) {
878-
flowControlQueueSize.WithLabelValues(fairnessID, priority, inferencePool, modelName, targetModelName).Inc()
955+
func IncFlowControlQueueSize(fairnessID, priority, inferencePool, sloClass, modelName, targetModelName string) {
956+
flowControlQueueSize.WithLabelValues(fairnessID, priority, inferencePool, sloClass, modelName, targetModelName).Inc()
879957
}
880958

881959
// DecFlowControlQueueSize decrements the Flow Control queue size gauge.
882-
func DecFlowControlQueueSize(fairnessID, priority, inferencePool, modelName, targetModelName string) {
883-
flowControlQueueSize.WithLabelValues(fairnessID, priority, inferencePool, modelName, targetModelName).Dec()
960+
func DecFlowControlQueueSize(fairnessID, priority, inferencePool, sloClass, modelName, targetModelName string) {
961+
flowControlQueueSize.WithLabelValues(fairnessID, priority, inferencePool, sloClass, modelName, targetModelName).Dec()
884962
}
885963

886964
// AddFlowControlQueueBytes increments the Flow Control queue bytes gauge.
887-
func AddFlowControlQueueBytes(fairnessID, priority, inferencePool, modelName, targetModelName string, bytes uint64) {
888-
flowControlQueueBytes.WithLabelValues(fairnessID, priority, inferencePool, modelName, targetModelName).Add(float64(bytes))
965+
func AddFlowControlQueueBytes(fairnessID, priority, inferencePool, sloClass, modelName, targetModelName string, bytes uint64) {
966+
flowControlQueueBytes.WithLabelValues(fairnessID, priority, inferencePool, sloClass, modelName, targetModelName).Add(float64(bytes))
889967
}
890968

891969
// SubFlowControlQueueBytes decrements the Flow Control queue bytes gauge.
892-
func SubFlowControlQueueBytes(fairnessID, priority, inferencePool, modelName, targetModelName string, bytes uint64) {
893-
flowControlQueueBytes.WithLabelValues(fairnessID, priority, inferencePool, modelName, targetModelName).Sub(float64(bytes))
970+
func SubFlowControlQueueBytes(fairnessID, priority, inferencePool, sloClass, modelName, targetModelName string, bytes uint64) {
971+
flowControlQueueBytes.WithLabelValues(fairnessID, priority, inferencePool, sloClass, modelName, targetModelName).Sub(float64(bytes))
894972
}
895973

896974
// RecordFlowControlPoolSaturation records the current saturation level for an inference pool.

0 commit comments

Comments
 (0)