Skip to content

Commit 3a31761

Browse files
authored
Add EPP request/response processing latency metrics (#1658)
* add EPP request/response processing latency metrics Signed-off-by: satyamg1620 <Satyam.Gupta.3@ibm.com> * address review feedback on processing latency metrics Signed-off-by: satyamg1620 <Satyam.Gupta.3@ibm.com> * fix subsystem prefix in processing latency metric tests Signed-off-by: satyamg1620 <Satyam.Gupta.3@ibm.com> * Measure EPP processing latency at the handler boundary Signed-off-by: satyamg1620 <Satyam.Gupta.3@ibm.com> * Assert EPP processing latency metrics in e2e preset Signed-off-by: satyamg1620 <Satyam.Gupta.3@ibm.com> --------- Signed-off-by: satyamg1620 <Satyam.Gupta.3@ibm.com>
1 parent a550284 commit 3a31761

8 files changed

Lines changed: 189 additions & 0 deletions

File tree

docs/metrics.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ Label `{name}` (the pool name).
110110
| `scheduler_e2e_duration_seconds` | Histogram | End-to-end scheduling latency. |
111111
| `scheduler_attempts_total` | Counter | Scheduling attempts; labels `{status, target_model_name, endpoint_name, namespace, port}`. |
112112

113+
### EPP processing overhead
114+
115+
Unlabelled.
116+
117+
| Name | Type | Notes |
118+
|---|---|---|
119+
| `request_processing_duration_seconds` | Histogram | Time from request receipt until the request body has been handled. Includes admission control, so under the flow control feature gate this covers queue wait; `flow_control_request_queue_duration_seconds` separates it out. |
120+
| `response_processing_duration_seconds` | Histogram | Sum of the per-chunk handler slices for a streamed response, so model-server generation time between chunks is excluded. For a non-streaming response, the interval from response headers to completion. |
121+
113122
### Plugin, info, and model rewrite
114123

115124
| Name | Type | Notes |

pkg/epp/handlers/server.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ type RequestContext struct {
136136
RequestDroppedReason errcommon.RequestDroppedReason
137137
modelServerStreaming bool
138138

139+
// responseProcessingDuration is the EPP cost of handling the response. For a
140+
// streamed response it is the sum of the per-chunk handler slices, since the
141+
// gaps between chunks are model server generation time. For a non-streaming
142+
// response it is the single interval from responseHeadersReceivedAt onward,
143+
// during which the response is entirely in EPP's hands.
144+
responseProcessingDuration time.Duration
145+
responseHeadersReceivedAt time.Time
146+
139147
Response *Response
140148

141149
reqHeaderResp *extProcPb.ProcessingResponse
@@ -265,6 +273,25 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
265273
},
266274
}
267275

276+
// Request-phase failures (parser resolution, body parsing, admission
277+
// rejection) leave the switch before the success path, so both call this.
278+
// Flow-control rejections carry the queue wait and are the slowest samples;
279+
// dropping them would bias the histogram low.
280+
recordRequestProcessing := sync.OnceFunc(func() {
281+
metrics.RecordRequestProcessingLatency(time.Since(reqCtx.RequestReceivedTimestamp))
282+
})
283+
284+
// Record EPP response processing latency once when the stream ends. Using a
285+
// defer (rather than emitting on end-of-stream) ensures aborted streams
286+
// (client cancel or upstream error before EOS) are also recorded, so the
287+
// metric is not biased toward fully streamed responses. The guard skips
288+
// requests that never reached the response phase.
289+
defer func() {
290+
if reqCtx.responseProcessingDuration > 0 {
291+
metrics.RecordResponseProcessingLatency(reqCtx.responseProcessingDuration)
292+
}
293+
}()
294+
268295
buf := s.bufferPool.Get().(*bytes.Buffer)
269296
buf.Reset()
270297
defer func() {
@@ -461,10 +488,13 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
461488
if parseResult.SkipResponseProcessing {
462489
reqCtx.RequestState = RequestResponseProcessingSkipped
463490
}
491+
492+
recordRequestProcessing()
464493
}
465494
case *extProcPb.ProcessingRequest_RequestTrailers:
466495
// This is currently unused.
467496
case *extProcPb.ProcessingRequest_ResponseHeaders:
497+
respHeadersReceivedAt := time.Now()
468498
for _, header := range v.ResponseHeaders.Headers.GetHeaders() {
469499
value := string(header.RawValue)
470500
loggerTrace.Info("header", "key", header.Key, "value", value)
@@ -478,12 +508,15 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
478508
reqCtx.RequestState = ResponseReceived
479509
reqCtx = s.HandleResponseHeaders(ctx, reqCtx, v)
480510
reqCtx.respHeaderResp = s.generateResponseHeaderResponse(reqCtx)
511+
reqCtx.responseHeadersReceivedAt = respHeadersReceivedAt
512+
reqCtx.responseProcessingDuration += time.Since(respHeadersReceivedAt)
481513

482514
case *extProcPb.ProcessingRequest_ResponseBody:
483515
endOfStream := v.ResponseBody.EndOfStream
484516
chunk := v.ResponseBody.Body
485517

486518
if reqCtx.modelServerStreaming {
519+
respBodyStart := time.Now()
487520
if endOfStream {
488521
reqCtx.ResponseComplete = true
489522
reqCtx.ResponseCompleteTimestamp = time.Now()
@@ -493,6 +526,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
493526
chunk = rewriteModelName(chunk, reqCtx.TargetModelName, reqCtx.IncomingModelName)
494527
// For streaming response, we send response chunk back to envoy every time we received it.
495528
reqCtx.respBodyResp = generateResponseBodyResponses(chunk, endOfStream, reqCtx.Response.DynamicMetadata)
529+
reqCtx.responseProcessingDuration += time.Since(respBodyStart)
496530
} else {
497531
respBody = append(respBody, chunk...)
498532
if endOfStream {
@@ -513,6 +547,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
513547

514548
// Handle the err and fire an immediate response.
515549
if err != nil {
550+
recordRequestProcessing()
516551
if logger.V(logutil.DEBUG).Enabled() {
517552
logger.V(logutil.DEBUG).Error(err, "Failed to process request", "request", req)
518553
} else {
@@ -553,6 +588,7 @@ func (s *StreamingServer) finishResponse(ctx context.Context, reqCtx *RequestCon
553588
return
554589
}
555590

591+
start := time.Now()
556592
reqCtx.ResponseComplete = true
557593
reqCtx.ResponseCompleteTimestamp = time.Now()
558594
reqCtx = s.HandleResponseBody(ctx, reqCtx, body, true)
@@ -562,6 +598,13 @@ func (s *StreamingServer) finishResponse(ctx context.Context, reqCtx *RequestCon
562598
// For non-streaming response, we send response back to envoy after receiving all the response body.
563599
reqCtx.respBodyResp = generateResponseBodyResponses(body, setEos, reqCtx.Response.DynamicMetadata)
564600
}
601+
if modelStreaming || reqCtx.responseHeadersReceivedAt.IsZero() {
602+
reqCtx.responseProcessingDuration += time.Since(start)
603+
} else {
604+
// Supersedes the header slice already accumulated: the interval since the
605+
// response headers arrived covers it and the body wait in between.
606+
reqCtx.responseProcessingDuration = time.Since(reqCtx.responseHeadersReceivedAt)
607+
}
565608
}
566609

567610
// rewriteModelName replaces occurrences of the target (internal) model name with the

pkg/epp/metrics/llm_d_router_metrics.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,30 @@ var (
279279
},
280280
[]string{"extension_point", "plugin_type", "plugin_name"},
281281
)
282+
283+
llmdRequestProcessingLatency = prometheus.NewHistogramVec(
284+
prometheus.HistogramOpts{
285+
Subsystem: LLMDRouterEndpointPickerSubsystem,
286+
Name: "request_processing_duration_seconds",
287+
Help: metricsutil.HelpMsgWithStability("EPP request processing latency distribution in seconds, from request receipt until the request body has been handled, including admission control.", compbasemetrics.ALPHA),
288+
Buckets: []float64{
289+
0.0005, 0.001, 0.002, 0.005, 0.01, 0.015, 0.025, 0.04, 0.06, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
290+
},
291+
},
292+
[]string{},
293+
)
294+
295+
llmdResponseProcessingLatency = prometheus.NewHistogramVec(
296+
prometheus.HistogramOpts{
297+
Subsystem: LLMDRouterEndpointPickerSubsystem,
298+
Name: "response_processing_duration_seconds",
299+
Help: metricsutil.HelpMsgWithStability("EPP response processing latency distribution in seconds: the sum of per-chunk handler time for a streamed response, or the interval from response headers to completion for a non-streaming response.", compbasemetrics.ALPHA),
300+
Buckets: []float64{
301+
0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5,
302+
},
303+
},
304+
[]string{},
305+
)
282306
)
283307

284308
// --- llm-d Info Metrics ---

pkg/epp/metrics/metrics.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,8 @@ func Register(customCollectors ...prometheus.Collector) {
458458
metrics.Registry.MustRegister(llmdSchedulerAttemptsTotal)
459459
metrics.Registry.MustRegister(pluginProcessingLatencies)
460460
metrics.Registry.MustRegister(llmdPluginProcessingLatencies)
461+
metrics.Registry.MustRegister(llmdRequestProcessingLatency)
462+
metrics.Registry.MustRegister(llmdResponseProcessingLatency)
461463
metrics.Registry.MustRegister(inferenceExtensionInfo)
462464
metrics.Registry.MustRegister(llmdInferenceExtensionInfo)
463465
metrics.Registry.MustRegister(flowControlRequestQueueDuration)
@@ -528,6 +530,8 @@ func Reset() {
528530
llmdSchedulerAttemptsTotal.Reset()
529531
pluginProcessingLatencies.Reset()
530532
llmdPluginProcessingLatencies.Reset()
533+
llmdRequestProcessingLatency.Reset()
534+
llmdResponseProcessingLatency.Reset()
531535
inferenceExtensionInfo.Reset()
532536
llmdInferenceExtensionInfo.Reset()
533537
flowControlRequestQueueDuration.Reset()
@@ -765,6 +769,18 @@ func RecordSchedulerE2ELatency(duration time.Duration) {
765769
llmdSchedulerE2ELatency.WithLabelValues().Observe(duration.Seconds())
766770
}
767771

772+
// RecordRequestProcessingLatency records the EPP request processing latency,
773+
// measured from request receipt until the request body has been handled.
774+
func RecordRequestProcessingLatency(duration time.Duration) {
775+
llmdRequestProcessingLatency.WithLabelValues().Observe(duration.Seconds())
776+
}
777+
778+
// RecordResponseProcessingLatency records the EPP response processing latency
779+
// for a single request.
780+
func RecordResponseProcessingLatency(duration time.Duration) {
781+
llmdResponseProcessingLatency.WithLabelValues().Observe(duration.Seconds())
782+
}
783+
768784
// RecordSchedulerAttempt records a scheduling attempt with status and endpoint information.
769785
func RecordSchedulerAttempt(err error, targetModelName string, result *fwksched.SchedulingResult) {
770786
if err != nil {

pkg/epp/metrics/metrics_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,60 @@ func TestSchedulerE2ELatency(t *testing.T) {
881881
}
882882
}
883883

884+
func TestRequestProcessingLatency(t *testing.T) {
885+
Reset()
886+
durations := []time.Duration{
887+
200 * time.Microsecond,
888+
800 * time.Microsecond,
889+
1500 * time.Microsecond,
890+
3 * time.Millisecond,
891+
8 * time.Millisecond,
892+
15 * time.Millisecond,
893+
30 * time.Millisecond,
894+
75 * time.Millisecond,
895+
150 * time.Millisecond,
896+
}
897+
for _, duration := range durations {
898+
RecordRequestProcessingLatency(duration)
899+
}
900+
901+
want, err := os.Open("testdata/llm_d_request_processing_duration_seconds_metric")
902+
if err != nil {
903+
t.Fatal(err)
904+
}
905+
defer want.Close()
906+
if err := promtestutil.GatherAndCompare(metrics.Registry, want, "llm_d_epp_request_processing_duration_seconds"); err != nil {
907+
t.Error(err)
908+
}
909+
}
910+
911+
func TestResponseProcessingLatency(t *testing.T) {
912+
Reset()
913+
durations := []time.Duration{
914+
200 * time.Microsecond,
915+
800 * time.Microsecond,
916+
1500 * time.Microsecond,
917+
3 * time.Millisecond,
918+
8 * time.Millisecond,
919+
15 * time.Millisecond,
920+
30 * time.Millisecond,
921+
75 * time.Millisecond,
922+
150 * time.Millisecond,
923+
}
924+
for _, duration := range durations {
925+
RecordResponseProcessingLatency(duration)
926+
}
927+
928+
want, err := os.Open("testdata/llm_d_response_processing_duration_seconds_metric")
929+
if err != nil {
930+
t.Fatal(err)
931+
}
932+
defer want.Close()
933+
if err := promtestutil.GatherAndCompare(metrics.Registry, want, "llm_d_epp_response_processing_duration_seconds"); err != nil {
934+
t.Error(err)
935+
}
936+
}
937+
884938
func TestFlowControlDispatchCycleLengthMetric(t *testing.T) {
885939
Reset()
886940
scenarios := []struct {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# HELP llm_d_epp_request_processing_duration_seconds [ALPHA] EPP request processing latency distribution in seconds, from request receipt until the request body has been handled, including admission control.
2+
# TYPE llm_d_epp_request_processing_duration_seconds histogram
3+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.0005"} 1
4+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.001"} 2
5+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.002"} 3
6+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.005"} 4
7+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.01"} 5
8+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.015"} 6
9+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.025"} 6
10+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.04"} 7
11+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.06"} 7
12+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.1"} 8
13+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.25"} 9
14+
llm_d_epp_request_processing_duration_seconds_bucket{le="0.5"} 9
15+
llm_d_epp_request_processing_duration_seconds_bucket{le="1"} 9
16+
llm_d_epp_request_processing_duration_seconds_bucket{le="2.5"} 9
17+
llm_d_epp_request_processing_duration_seconds_bucket{le="5"} 9
18+
llm_d_epp_request_processing_duration_seconds_bucket{le="10"} 9
19+
llm_d_epp_request_processing_duration_seconds_bucket{le="+Inf"} 9
20+
llm_d_epp_request_processing_duration_seconds_sum 0.2835
21+
llm_d_epp_request_processing_duration_seconds_count 9
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# HELP llm_d_epp_response_processing_duration_seconds [ALPHA] EPP response processing latency distribution in seconds: the sum of per-chunk handler time for a streamed response, or the interval from response headers to completion for a non-streaming response.
2+
# TYPE llm_d_epp_response_processing_duration_seconds histogram
3+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.0001"} 0
4+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.00025"} 1
5+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.0005"} 1
6+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.001"} 2
7+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.0025"} 3
8+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.005"} 4
9+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.01"} 5
10+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.025"} 6
11+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.05"} 7
12+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.1"} 8
13+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.25"} 9
14+
llm_d_epp_response_processing_duration_seconds_bucket{le="0.5"} 9
15+
llm_d_epp_response_processing_duration_seconds_bucket{le="1"} 9
16+
llm_d_epp_response_processing_duration_seconds_bucket{le="2.5"} 9
17+
llm_d_epp_response_processing_duration_seconds_bucket{le="5"} 9
18+
llm_d_epp_response_processing_duration_seconds_bucket{le="+Inf"} 9
19+
llm_d_epp_response_processing_duration_seconds_sum 0.2835
20+
llm_d_epp_response_processing_duration_seconds_count 9

test/e2e/requests_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,8 @@ func verifyMetrics(infPoolName string, numTargetPorts int) {
344344
"llm_d_epp_request_running",
345345
"llm_d_epp_ready_endpoints",
346346
"llm_d_epp_info",
347+
"llm_d_epp_request_processing_duration_seconds",
348+
"llm_d_epp_response_processing_duration_seconds",
347349
}
348350
expectedMetrics := make([]string, 0, len(preset)+len(decodePods)*numTargetPorts*2)
349351
expectedMetrics = append(expectedMetrics, preset...)

0 commit comments

Comments
 (0)