Skip to content

Commit 4b5a425

Browse files
perf: Reduce allocations on the response-body streaming path
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
1 parent 9a8c999 commit 4b5a425

6 files changed

Lines changed: 216 additions & 38 deletions

File tree

pkg/common/envoy/metadata.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import (
2121
)
2222

2323
func ExtractMetadataValues(req *extProcPb.ProcessingRequest) map[string]any {
24-
metadata := make(map[string]any)
25-
if req != nil && req.MetadataContext != nil && req.MetadataContext.FilterMetadata != nil {
26-
for key, val := range req.MetadataContext.FilterMetadata {
27-
metadata[key] = val.AsMap()
28-
}
24+
if req == nil || req.MetadataContext == nil || len(req.MetadataContext.FilterMetadata) == 0 {
25+
return nil
26+
}
27+
metadata := make(map[string]any, len(req.MetadataContext.FilterMetadata))
28+
for key, val := range req.MetadataContext.FilterMetadata {
29+
metadata[key] = val.AsMap()
2930
}
3031
return metadata
3132
}

pkg/common/envoy/metadata_test.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,35 @@ func TestExtractMetadataValues(t *testing.T) {
3939

4040
tests := []struct {
4141
name string
42-
metadata map[string]*structpb.Struct
42+
req *extProcPb.ProcessingRequest
4343
expected map[string]any
4444
}{
4545
{
46-
name: "Exact match",
47-
metadata: makeFilterMetadata(),
46+
name: "Nil request",
47+
req: nil,
48+
expected: nil,
49+
},
50+
{
51+
name: "Nil MetadataContext",
52+
req: &extProcPb.ProcessingRequest{},
53+
expected: nil,
54+
},
55+
{
56+
name: "Empty FilterMetadata",
57+
req: &extProcPb.ProcessingRequest{
58+
MetadataContext: &corev3.Metadata{
59+
FilterMetadata: map[string]*structpb.Struct{},
60+
},
61+
},
62+
expected: nil,
63+
},
64+
{
65+
name: "Populated metadata",
66+
req: &extProcPb.ProcessingRequest{
67+
MetadataContext: &corev3.Metadata{
68+
FilterMetadata: makeFilterMetadata(),
69+
},
70+
},
4871
expected: map[string]any{
4972
"key-1": map[string]any{
5073
"hello": "world",
@@ -56,13 +79,7 @@ func TestExtractMetadataValues(t *testing.T) {
5679

5780
for _, tt := range tests {
5881
t.Run(tt.name, func(t *testing.T) {
59-
req := &extProcPb.ProcessingRequest{
60-
MetadataContext: &corev3.Metadata{
61-
FilterMetadata: tt.metadata,
62-
},
63-
}
64-
65-
result := ExtractMetadataValues(req)
82+
result := ExtractMetadataValues(tt.req)
6683
if diff := cmp.Diff(result, tt.expected); diff != "" {
6784
t.Errorf("ExtractMetadataValues() unexpected response (-want +got): %v", diff)
6885
}

pkg/epp/handlers/server.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,10 +406,9 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
406406
return status.Errorf(codes.Unknown, "cannot receive stream request: %v", recvErr)
407407
}
408408

409-
reqCtx.Request.Metadata = envoy.ExtractMetadataValues(req)
410-
411409
switch v := req.Request.(type) {
412410
case *extProcPb.ProcessingRequest_RequestHeaders:
411+
reqCtx.Request.Metadata = envoy.ExtractMetadataValues(req)
413412
requestID := envoy.ExtractHeaderValue(v, reqcommon.RequestIDHeaderKey)
414413
// request ID is a must for maintaining a state per request in plugins that hold internal state and use PluginState.
415414
// if request id was not supplied as a header, we generate it ourselves.
@@ -439,6 +438,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
439438
// Message is buffered, we can read and decode.
440439
if v.RequestBody.EndOfStream {
441440
loggerTrace.Info("decoding")
441+
reqCtx.Request.Metadata = envoy.ExtractMetadataValues(req)
442442
reqCtx.Request.RawBody = make([]byte, buf.Len())
443443
copy(reqCtx.Request.RawBody, buf.Bytes())
444444

@@ -494,6 +494,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
494494
case *extProcPb.ProcessingRequest_RequestTrailers:
495495
// This is currently unused.
496496
case *extProcPb.ProcessingRequest_ResponseHeaders:
497+
reqCtx.Request.Metadata = envoy.ExtractMetadataValues(req)
497498
respHeadersReceivedAt := time.Now()
498499
for _, header := range v.ResponseHeaders.Headers.GetHeaders() {
499500
value := string(header.RawValue)

pkg/epp/requestcontrol/director.go

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -521,9 +521,14 @@ func (d *Director) HandleResponseHeader(ctx context.Context, reqCtx *handlers.Re
521521
// to the ext_proc response sent back to Envoy.
522522
func (d *Director) HandleResponseBody(ctx context.Context, reqCtx *handlers.RequestContext, endOfStream bool) *handlers.RequestContext {
523523
logger := log.FromContext(ctx).WithValues("stage", "bodyChunk")
524-
logger.V(logutil.TRACE).Info("Entering HandleResponseBodyChunk")
524+
loggerTrace := logger.V(logutil.TRACE)
525+
if loggerTrace.Enabled() {
526+
loggerTrace.Info("Entering HandleResponseBodyChunk")
527+
}
525528
if len(d.requestControlPlugins.responseStreamingPlugins) == 0 {
526-
logger.V(logutil.TRACE).Info("Exiting HandleResponseBodyChunk")
529+
if loggerTrace.Enabled() {
530+
loggerTrace.Info("Exiting HandleResponseBodyChunk")
531+
}
527532
return reqCtx
528533
}
529534

@@ -536,7 +541,6 @@ func (d *Director) HandleResponseBody(ctx context.Context, reqCtx *handlers.Requ
536541
EndOfStream: endOfStream,
537542
Usage: reqCtx.Usage,
538543
}
539-
requestID := reqCtx.Request.Headers[reqcommon.RequestIDHeaderKey]
540544

541545
if endOfStream {
542546
// Drain the async queue: close the channel and wait for the goroutine to finish
@@ -558,10 +562,13 @@ func (d *Director) HandleResponseBody(ctx context.Context, reqCtx *handlers.Requ
558562
}
559563
q := d.loadOrCreateResponseBodyQueue(reqCtx)
560564
if !q.enqueue(work) {
561-
logger.V(logutil.DEBUG).Info("Skipping response body chunk because the async queue is closed", "requestID", requestID)
565+
logger.V(logutil.DEBUG).Info("Skipping response body chunk because the async queue is closed",
566+
"requestID", reqCtx.Request.Headers[reqcommon.RequestIDHeaderKey])
562567
}
563568
}
564-
logger.V(logutil.TRACE).Info("Exiting HandleResponseBodyChunk")
569+
if loggerTrace.Enabled() {
570+
loggerTrace.Info("Exiting HandleResponseBodyChunk")
571+
}
565572
return reqCtx
566573
}
567574

@@ -592,11 +599,12 @@ func (d *Director) runPreRequestPlugins(ctx context.Context, request *fwksched.I
592599
schedulingResult *fwksched.SchedulingResult) {
593600
loggerDebug := log.FromContext(ctx).V(logutil.DEBUG)
594601
for _, plugin := range d.requestControlPlugins.preRequestPlugins {
595-
loggerDebug.Info("Running PreRequest plugin", "plugin", plugin.TypedName())
602+
tn := plugin.TypedName()
603+
loggerDebug.Info("Running PreRequest plugin", "plugin", tn)
596604
before := time.Now()
597605
plugin.PreRequest(ctx, request, schedulingResult)
598-
metrics.RecordPluginProcessingLatency(fwkrc.PreRequestExtensionPoint, plugin.TypedName().Type, plugin.TypedName().Name, time.Since(before))
599-
loggerDebug.Info("Completed running PreRequest plugin successfully", "plugin", plugin.TypedName())
606+
metrics.RecordPluginProcessingLatency(fwkrc.PreRequestExtensionPoint, tn.Type, tn.Name, time.Since(before))
607+
loggerDebug.Info("Completed running PreRequest plugin successfully", "plugin", tn)
600608
}
601609
}
602610

@@ -606,13 +614,14 @@ func (d *Director) runRequestHeaderProcessors(ctx context.Context, request *fwks
606614
}
607615
loggerDebug := log.FromContext(ctx).V(logutil.DEBUG)
608616
for _, plugin := range d.requestControlPlugins.requestHeaderPlugins {
609-
loggerDebug.Info("Running RequestHeaderProcessor plugin", "plugin", plugin.TypedName())
617+
tn := plugin.TypedName()
618+
loggerDebug.Info("Running RequestHeaderProcessor plugin", "plugin", tn)
610619
before := time.Now()
611620
if err := plugin.RequestHeader(ctx, request); err != nil {
612621
return err
613622
}
614-
metrics.RecordPluginProcessingLatency(fwkrc.RequestHeaderExtensionPoint, plugin.TypedName().Type, plugin.TypedName().Name, time.Since(before))
615-
loggerDebug.Info("Completed running RequestHeaderProcessor plugin successfully", "plugin", plugin.TypedName())
623+
metrics.RecordPluginProcessingLatency(fwkrc.RequestHeaderExtensionPoint, tn.Type, tn.Name, time.Since(before))
624+
loggerDebug.Info("Completed running RequestHeaderProcessor plugin successfully", "plugin", tn)
616625
}
617626
return nil
618627
}
@@ -637,38 +646,45 @@ func (d *Director) runAdmissionPlugins(ctx context.Context,
637646
request *fwksched.InferenceRequest, endpoints []fwksched.Endpoint) error {
638647
loggerDebug := log.FromContext(ctx).V(logutil.DEBUG)
639648
for _, plugin := range d.requestControlPlugins.admissionPlugins {
640-
loggerDebug.Info("Running Admit plugin", "plugin", plugin.TypedName())
649+
tn := plugin.TypedName()
650+
loggerDebug.Info("Running Admit plugin", "plugin", tn)
641651
before := time.Now()
642652
denyReason := plugin.Admit(ctx, request, endpoints)
643-
metrics.RecordPluginProcessingLatency(fwkrc.AdmissionExtensionPoint, plugin.TypedName().Type, plugin.TypedName().Name, time.Since(before))
653+
metrics.RecordPluginProcessingLatency(fwkrc.AdmissionExtensionPoint, tn.Type, tn.Name, time.Since(before))
644654
if denyReason != nil {
645-
loggerDebug.Info("Admit plugin denied the request", "plugin", plugin.TypedName(), "reason", denyReason.Error())
655+
loggerDebug.Info("Admit plugin denied the request", "plugin", tn, "reason", denyReason.Error())
646656
return denyReason
647657
}
648-
loggerDebug.Info("Completed running Admit plugin successfully", "plugin", plugin.TypedName())
658+
loggerDebug.Info("Completed running Admit plugin successfully", "plugin", tn)
649659
}
650660
return nil
651661
}
652662

653663
func (d *Director) runResponseHeaderPlugins(ctx context.Context, request *fwksched.InferenceRequest, response *fwkrc.Response, targetEndpoint *fwkdl.EndpointMetadata) {
654664
loggerDebug := log.FromContext(ctx).V(logutil.DEBUG)
655665
for _, plugin := range d.requestControlPlugins.responseReceivedPlugins {
656-
loggerDebug.Info("Running ResponseReceived plugin", "plugin", plugin.TypedName())
666+
tn := plugin.TypedName()
667+
loggerDebug.Info("Running ResponseReceived plugin", "plugin", tn)
657668
before := time.Now()
658669
plugin.ResponseHeader(ctx, request, response, targetEndpoint)
659-
metrics.RecordPluginProcessingLatency(fwkrc.ResponseReceivedExtensionPoint, plugin.TypedName().Type, plugin.TypedName().Name, time.Since(before))
660-
loggerDebug.Info("Completed running ResponseReceived plugin successfully", "plugin", plugin.TypedName())
670+
metrics.RecordPluginProcessingLatency(fwkrc.ResponseReceivedExtensionPoint, tn.Type, tn.Name, time.Since(before))
671+
loggerDebug.Info("Completed running ResponseReceived plugin successfully", "plugin", tn)
661672
}
662673
}
663674

664675
func (d *Director) runResponseBodyPlugins(ctx context.Context, request *fwksched.InferenceRequest, response *fwkrc.Response, targetEndpoint *fwkdl.EndpointMetadata) {
665676
loggerTrace := log.FromContext(ctx).V(logutil.TRACE)
666677
for _, plugin := range d.requestControlPlugins.responseStreamingPlugins {
667-
loggerTrace.Info("Running ResponseStreaming plugin", "plugin", plugin.TypedName())
678+
tn := plugin.TypedName()
679+
if loggerTrace.Enabled() {
680+
loggerTrace.Info("Running ResponseStreaming plugin", "plugin", tn)
681+
}
668682
before := time.Now()
669683
plugin.ResponseBody(ctx, request, response, targetEndpoint)
670-
metrics.RecordPluginProcessingLatency(fwkrc.ResponseStreamingExtensionPoint, plugin.TypedName().Type, plugin.TypedName().Name, time.Since(before))
671-
loggerTrace.Info("Completed running ResponseStreaming plugin successfully", "plugin", plugin.TypedName())
684+
metrics.RecordPluginProcessingLatency(fwkrc.ResponseStreamingExtensionPoint, tn.Type, tn.Name, time.Since(before))
685+
if loggerTrace.Enabled() {
686+
loggerTrace.Info("Completed running ResponseStreaming plugin successfully", "plugin", tn)
687+
}
672688
}
673689
}
674690

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
Copyright 2026 The llm-d 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 requestcontrol
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
"github.com/go-logr/logr"
24+
"k8s.io/apimachinery/pkg/types"
25+
"sigs.k8s.io/controller-runtime/pkg/log"
26+
27+
reqcommon "github.com/llm-d/llm-d-router/pkg/common/request"
28+
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
29+
fwkrh "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/requesthandling"
30+
"github.com/llm-d/llm-d-router/pkg/epp/handlers"
31+
)
32+
33+
const (
34+
benchChunksPerOp = 10
35+
// maxAllocsPerResponse is the allocation ceiling for one complete
36+
// response (benchChunksPerOp intermediate chunks + 1 end-of-stream).
37+
// Bump this intentionally when a change adds justified allocations.
38+
maxAllocsPerResponse = 60
39+
)
40+
41+
func TestHandleResponseBodyAllocs(t *testing.T) {
42+
plugin := newTestResponseStreaming("alloc-plugin")
43+
director := NewDirectorWithConfig(nil, &mockScheduler{}, nil, nil,
44+
NewConfig().WithResponseStreamingPlugins(plugin))
45+
46+
ctx := log.IntoContext(context.Background(), logr.Discard())
47+
48+
avg := testing.AllocsPerRun(100, func() {
49+
reqCtx := &handlers.RequestContext{
50+
Request: &handlers.Request{
51+
Headers: map[string]string{
52+
reqcommon.RequestIDHeaderKey: "alloc-request",
53+
},
54+
},
55+
Response: &handlers.Response{
56+
Headers: map[string]string{},
57+
},
58+
TargetPod: &fwkdl.EndpointMetadata{
59+
NamespacedName: types.NamespacedName{Namespace: "ns", Name: "pod"},
60+
},
61+
Usage: fwkrh.Usage{},
62+
}
63+
for chunk := 0; chunk < benchChunksPerOp; chunk++ {
64+
director.HandleResponseBody(ctx, reqCtx, false)
65+
}
66+
director.HandleResponseBody(ctx, reqCtx, true)
67+
68+
plugin.mu.Lock()
69+
plugin.respsOnStreaming = plugin.respsOnStreaming[:0]
70+
plugin.targetPodsOnStreaming = plugin.targetPodsOnStreaming[:0]
71+
plugin.mu.Unlock()
72+
})
73+
if avg > maxAllocsPerResponse {
74+
t.Errorf("HandleResponseBody allocations regressed: got %.0f, ceiling %d", avg, maxAllocsPerResponse)
75+
}
76+
}
77+
78+
// BenchmarkHandleResponseBody measures the per-response cost of
79+
// Director.HandleResponseBody with one streaming plugin registered.
80+
// Each operation simulates 10 intermediate chunks followed by one
81+
// end-of-stream chunk.
82+
//
83+
// Run:
84+
//
85+
// go test -run='^$' -bench=BenchmarkHandleResponseBody -benchmem -count=10 \
86+
// ./pkg/epp/requestcontrol/ | tee bench.out
87+
// benchstat bench.out
88+
func BenchmarkHandleResponseBody(b *testing.B) {
89+
plugin := newTestResponseStreaming("bench-plugin")
90+
director := NewDirectorWithConfig(nil, &mockScheduler{}, nil, nil,
91+
NewConfig().WithResponseStreamingPlugins(plugin))
92+
93+
ctx := log.IntoContext(context.Background(), logr.Discard())
94+
95+
b.ReportAllocs()
96+
b.ResetTimer()
97+
for i := 0; i < b.N; i++ {
98+
reqCtx := &handlers.RequestContext{
99+
Request: &handlers.Request{
100+
Headers: map[string]string{
101+
reqcommon.RequestIDHeaderKey: "bench-request",
102+
},
103+
},
104+
Response: &handlers.Response{
105+
Headers: map[string]string{},
106+
},
107+
TargetPod: &fwkdl.EndpointMetadata{
108+
NamespacedName: types.NamespacedName{Namespace: "ns", Name: "pod"},
109+
},
110+
Usage: fwkrh.Usage{},
111+
}
112+
113+
for chunk := 0; chunk < benchChunksPerOp; chunk++ {
114+
director.HandleResponseBody(ctx, reqCtx, false)
115+
}
116+
// Wait for async queue to drain before the final synchronous chunk.
117+
director.HandleResponseBody(ctx, reqCtx, true)
118+
119+
// Reset plugin state for the next iteration.
120+
plugin.mu.Lock()
121+
plugin.respsOnStreaming = plugin.respsOnStreaming[:0]
122+
plugin.targetPodsOnStreaming = plugin.targetPodsOnStreaming[:0]
123+
plugin.mu.Unlock()
124+
}
125+
}

0 commit comments

Comments
 (0)