Skip to content

Commit 8bef5f0

Browse files
authored
Add picker tracing decorator to scheduling pipeline (llm-d#1708)
* Trace the picker stage with an inline pick_endpoints span Wrap the picker call in runPickerPlugin in a single inline "pick_endpoints" span via tracing.Tracer(schedplugins.TracerScope) (SpanKindInternal), recording the candidate and selected endpoint counts (llm_d.epp.picker.candidate_endpoints, llm_d.epp.picker.selected_endpoints = len(result.TargetEndpoints), nil-guarded) plus the conditional gen_ai.request.{model,id} keys. request is threaded into runPickerPlugin (sole caller Run) so the span carries the request keys, matching the filter and scorer spans. Follows the single-step span convention from llm-d#1565 and llm-d#1693; picking behavior and the per-plugin latency metric are unchanged. Refs: llm-d#1694, llm-d#1483 Signed-off-by: ChethanUK <chethanuk@outlook.com> * feat(scheduling): record top endpoint scores on picker span The picker almost always returns a single target (with an occasional fallback), so the count of selected endpoints carries little diagnostic signal. What explains a routing decision is the score distribution across the strongest candidates: how close the runner-up was, and how the top scores were spread. Replace the selected_endpoints count attribute with the names and weighted scores of the highest-scoring candidates, ordered by descending score and capped at five so the payload stays bounded on production fleet sizes (~100 pods). The top set is captured before Pick runs because pickers reorder the candidate slice in place. Signed-off-by: ChethanUK <chethanuk@outlook.com> --------- Signed-off-by: ChethanUK <chethanuk@outlook.com> Signed-off-by: chethanuk <chethanuk@outlook.com>
1 parent e3995e3 commit 8bef5f0

3 files changed

Lines changed: 310 additions & 2 deletions

File tree

pkg/epp/scheduling/constants.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,8 @@ package scheduling
1919
// TracerScope is the OTel instrumentation scope for spans emitted by the
2020
// scheduling engine.
2121
const TracerScope = "llm-d-router/pkg/epp/scheduling"
22+
23+
// maxTracedEndpointScores bounds how many of the highest-scoring endpoints are
24+
// recorded on the picker span. Candidate sets can reach production fleet sizes
25+
// (~100 pods); the strongest few carry the signal for why an endpoint was picked.
26+
const maxTracedEndpointScores = 5

pkg/epp/scheduling/scheduler_profile.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package scheduling
1919
import (
2020
"context"
2121
"fmt"
22+
"sort"
2223
"strings"
2324
"time"
2425

@@ -125,7 +126,7 @@ func (p *SchedulerProfile) Run(ctx context.Context, request *fwksched.InferenceR
125126
// if we got here, there is at least one endpoint to score
126127
weightedScorePerEndpoint := p.runScorerPlugins(ctx, request, endpoints)
127128

128-
result := p.runPickerPlugin(ctx, weightedScorePerEndpoint)
129+
result := p.runPickerPlugin(ctx, request, weightedScorePerEndpoint)
129130

130131
return result, nil
131132
}
@@ -202,7 +203,7 @@ func (p *SchedulerProfile) runScorerPlugins(ctx context.Context, request *fwksch
202203
return weightedScorePerEndpoint
203204
}
204205

205-
func (p *SchedulerProfile) runPickerPlugin(ctx context.Context, weightedScorePerEndpoint map[fwksched.Endpoint]float64) *fwksched.ProfileRunResult {
206+
func (p *SchedulerProfile) runPickerPlugin(ctx context.Context, request *fwksched.InferenceRequest, weightedScorePerEndpoint map[fwksched.Endpoint]float64) *fwksched.ProfileRunResult {
206207
logger := log.FromContext(ctx)
207208

208209
// Allocate the ScoredEndpoint values as a single contiguous backing array
@@ -222,6 +223,33 @@ func (p *SchedulerProfile) runPickerPlugin(ctx context.Context, weightedScorePer
222223
}
223224
logger.V(logutil.VERBOSE).Info("Running picker plugin", "plugin", p.picker.TypedName())
224225
logger.V(logutil.DEBUG).Info("Candidate pods for picking", "endpoints-weighted-score", scoredEndpoints)
226+
227+
ctx, span := tracing.Tracer(TracerScope).Start(ctx, "pick_endpoints",
228+
trace.WithSpanKind(trace.SpanKindInternal),
229+
)
230+
defer span.End()
231+
232+
span.SetAttributes(attribute.Int("llm_d.epp.picker.candidate_endpoints", len(scoredEndpoints)))
233+
// The picker almost always returns a single target, so its count carries
234+
// little signal. The score distribution across the strongest candidates is
235+
// what explains why an endpoint was chosen, so record the highest-scoring
236+
// few (names with their weighted scores). Captured before Pick because
237+
// pickers reorder scoredEndpoints in place.
238+
if names, scores := topScoredEndpoints(scoredEndpoints, maxTracedEndpointScores); len(names) > 0 {
239+
span.SetAttributes(
240+
attribute.StringSlice("llm_d.epp.picker.top_endpoints", names),
241+
attribute.Float64Slice("llm_d.epp.picker.top_scores", scores),
242+
)
243+
}
244+
if request != nil {
245+
if request.TargetModel != "" {
246+
span.SetAttributes(attribute.String("gen_ai.request.model", request.TargetModel))
247+
}
248+
if request.RequestID != "" {
249+
span.SetAttributes(attribute.String("gen_ai.request.id", request.RequestID))
250+
}
251+
}
252+
225253
before := time.Now()
226254
result := p.picker.Pick(ctx, scoredEndpoints)
227255
metrics.RecordPluginProcessingLatency(pickerExtensionPoint, p.picker.TypedName().Type, p.picker.TypedName().Name, time.Since(before))
@@ -230,6 +258,31 @@ func (p *SchedulerProfile) runPickerPlugin(ctx context.Context, weightedScorePer
230258
return result
231259
}
232260

261+
// topScoredEndpoints returns the names and weighted scores of the highest
262+
// scoring candidates, ordered by descending score with the endpoint name as a
263+
// stable tiebreaker and capped at limit. The returned slices are index-aligned.
264+
func topScoredEndpoints(scored []*fwksched.ScoredEndpoint, limit int) ([]string, []float64) {
265+
ranked := make([]*fwksched.ScoredEndpoint, len(scored))
266+
copy(ranked, scored)
267+
sort.Slice(ranked, func(i, j int) bool {
268+
if ranked[i].Score != ranked[j].Score {
269+
return ranked[i].Score > ranked[j].Score
270+
}
271+
return ranked[i].GetMetadata().NamespacedName.String() <
272+
ranked[j].GetMetadata().NamespacedName.String()
273+
})
274+
if limit < len(ranked) {
275+
ranked = ranked[:limit]
276+
}
277+
names := make([]string, len(ranked))
278+
scores := make([]float64, len(ranked))
279+
for i, se := range ranked {
280+
names[i] = se.GetMetadata().NamespacedName.String()
281+
scores[i] = se.Score
282+
}
283+
return names, scores
284+
}
285+
233286
func enforceScoreRange(score float64) float64 {
234287
if score < 0 {
235288
return 0
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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 scheduling
18+
19+
import (
20+
"context"
21+
"reflect"
22+
"testing"
23+
24+
"go.opentelemetry.io/otel"
25+
"go.opentelemetry.io/otel/attribute"
26+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
27+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
28+
"go.opentelemetry.io/otel/trace"
29+
k8stypes "k8s.io/apimachinery/pkg/types"
30+
31+
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
32+
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
33+
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
34+
)
35+
36+
// setupPickerSpanRecorder installs an in-memory span recorder as the global
37+
// tracer provider and returns it, restoring the previous provider on cleanup.
38+
func setupPickerSpanRecorder(t *testing.T) *tracetest.SpanRecorder {
39+
t.Helper()
40+
recorder := tracetest.NewSpanRecorder()
41+
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
42+
origTP := otel.GetTracerProvider()
43+
otel.SetTracerProvider(tp)
44+
t.Cleanup(func() { otel.SetTracerProvider(origTP) })
45+
return recorder
46+
}
47+
48+
func findPickerSpans(spans []sdktrace.ReadOnlySpan, name string) []sdktrace.ReadOnlySpan {
49+
var out []sdktrace.ReadOnlySpan
50+
for _, s := range spans {
51+
if s.Name() == name {
52+
out = append(out, s)
53+
}
54+
}
55+
return out
56+
}
57+
58+
func pickerSpanAttributes(span sdktrace.ReadOnlySpan) map[attribute.Key]attribute.Value {
59+
attrs := make(map[attribute.Key]attribute.Value)
60+
for _, kv := range span.Attributes() {
61+
attrs[kv.Key] = kv.Value
62+
}
63+
return attrs
64+
}
65+
66+
func newWeightedScores(names ...string) map[fwksched.Endpoint]float64 {
67+
scores := make(map[fwksched.Endpoint]float64, len(names))
68+
for i, name := range names {
69+
endpoint := fwksched.NewEndpoint(
70+
&fwkdl.EndpointMetadata{NamespacedName: k8stypes.NamespacedName{Name: name}}, nil, nil)
71+
scores[endpoint] = float64(i)
72+
}
73+
return scores
74+
}
75+
76+
// fakePicker returns the first selected candidates as targets, or a nil result
77+
// when nilResult is set, and optionally emits a child span from its context.
78+
type fakePicker struct {
79+
typedName fwkplugin.TypedName
80+
selected int
81+
nilResult bool
82+
emitChild bool
83+
}
84+
85+
var _ fwksched.Picker = &fakePicker{}
86+
87+
func (f *fakePicker) TypedName() fwkplugin.TypedName { return f.typedName }
88+
89+
func (f *fakePicker) Pick(ctx context.Context, scoredPods []*fwksched.ScoredEndpoint) *fwksched.ProfileRunResult {
90+
if f.emitChild {
91+
_, span := otel.Tracer("test").Start(ctx, "inner_picker_span")
92+
span.End()
93+
}
94+
if f.nilResult {
95+
return nil
96+
}
97+
targets := make([]fwksched.Endpoint, 0, f.selected)
98+
for i := 0; i < f.selected && i < len(scoredPods); i++ {
99+
targets = append(targets, scoredPods[i].Endpoint)
100+
}
101+
return &fwksched.ProfileRunResult{TargetEndpoints: targets}
102+
}
103+
104+
func TestRunPickerPluginSingleSpan(t *testing.T) {
105+
recorder := setupPickerSpanRecorder(t)
106+
107+
picker := &fakePicker{typedName: fwkplugin.TypedName{Type: "max-score", Name: "instance-a"}, selected: 1}
108+
profile := NewSchedulerProfile().WithPicker(picker)
109+
scores := newWeightedScores("pod1", "pod2", "pod3")
110+
111+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
112+
result := profile.runPickerPlugin(ctx, &fwksched.InferenceRequest{TargetModel: "m1", RequestID: "r1"}, scores)
113+
root.End()
114+
115+
if result == nil || len(result.TargetEndpoints) != 1 {
116+
t.Fatalf("runPickerPlugin returned %v, want 1 target", result)
117+
}
118+
119+
spans := findPickerSpans(recorder.Ended(), "pick_endpoints")
120+
if len(spans) != 1 {
121+
t.Fatalf("got %d pick_endpoints spans, want 1", len(spans))
122+
}
123+
span := spans[0]
124+
if span.SpanKind() != trace.SpanKindInternal {
125+
t.Errorf("span kind = %v, want Internal", span.SpanKind())
126+
}
127+
if span.Parent().SpanID() != root.SpanContext().SpanID() {
128+
t.Errorf("parent span ID = %v, want root %v", span.Parent().SpanID(), root.SpanContext().SpanID())
129+
}
130+
131+
attrs := pickerSpanAttributes(span)
132+
for _, tc := range []struct {
133+
key attribute.Key
134+
want string
135+
}{
136+
{"gen_ai.request.model", "m1"},
137+
{"gen_ai.request.id", "r1"},
138+
} {
139+
if got := attrs[tc.key].AsString(); got != tc.want {
140+
t.Errorf("%s = %q, want %q", tc.key, got, tc.want)
141+
}
142+
}
143+
if got := attrs["llm_d.epp.picker.candidate_endpoints"].AsInt64(); got != 3 {
144+
t.Errorf("candidate_endpoints = %d, want 3", got)
145+
}
146+
// newWeightedScores assigns score i to the i-th name, so the span records
147+
// candidates highest score first.
148+
if got, want := attrs["llm_d.epp.picker.top_endpoints"].AsStringSlice(), []string{"/pod3", "/pod2", "/pod1"}; !reflect.DeepEqual(got, want) {
149+
t.Errorf("top_endpoints = %v, want %v", got, want)
150+
}
151+
if got, want := attrs["llm_d.epp.picker.top_scores"].AsFloat64Slice(), []float64{2, 1, 0}; !reflect.DeepEqual(got, want) {
152+
t.Errorf("top_scores = %v, want %v", got, want)
153+
}
154+
}
155+
156+
func TestRunPickerPluginTopScoresCapped(t *testing.T) {
157+
recorder := setupPickerSpanRecorder(t)
158+
159+
picker := &fakePicker{typedName: fwkplugin.TypedName{Type: "max-score", Name: "multi"}, selected: 2}
160+
profile := NewSchedulerProfile().WithPicker(picker)
161+
// Seven candidates (scores 0..6) exceed the cap of five.
162+
scores := newWeightedScores("pod1", "pod2", "pod3", "pod4", "pod5", "pod6", "pod7")
163+
164+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
165+
profile.runPickerPlugin(ctx, &fwksched.InferenceRequest{}, scores)
166+
root.End()
167+
168+
spans := findPickerSpans(recorder.Ended(), "pick_endpoints")
169+
if len(spans) != 1 {
170+
t.Fatalf("got %d pick_endpoints spans, want 1", len(spans))
171+
}
172+
attrs := pickerSpanAttributes(spans[0])
173+
if got, want := attrs["llm_d.epp.picker.top_endpoints"].AsStringSlice(), []string{"/pod7", "/pod6", "/pod5", "/pod4", "/pod3"}; !reflect.DeepEqual(got, want) {
174+
t.Errorf("top_endpoints = %v, want the 5 highest-scoring %v", got, want)
175+
}
176+
if got, want := attrs["llm_d.epp.picker.top_scores"].AsFloat64Slice(), []float64{6, 5, 4, 3, 2}; !reflect.DeepEqual(got, want) {
177+
t.Errorf("top_scores = %v, want %v", got, want)
178+
}
179+
}
180+
181+
func TestRunPickerPluginNilResult(t *testing.T) {
182+
recorder := setupPickerSpanRecorder(t)
183+
184+
picker := &fakePicker{typedName: fwkplugin.TypedName{Type: "noop-picker", Name: "noop"}, nilResult: true}
185+
profile := NewSchedulerProfile().WithPicker(picker)
186+
scores := newWeightedScores("pod1", "pod2")
187+
188+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
189+
result := profile.runPickerPlugin(ctx, &fwksched.InferenceRequest{}, scores)
190+
root.End()
191+
192+
if result != nil {
193+
t.Fatalf("runPickerPlugin returned %v, want nil", result)
194+
}
195+
spans := findPickerSpans(recorder.Ended(), "pick_endpoints")
196+
if len(spans) != 1 {
197+
t.Fatalf("got %d pick_endpoints spans, want 1 (span must end on nil result)", len(spans))
198+
}
199+
// Scores are captured from the candidates before Pick, so they are recorded
200+
// even when the picker selects nothing.
201+
if got, want := pickerSpanAttributes(spans[0])["llm_d.epp.picker.top_scores"].AsFloat64Slice(), []float64{1, 0}; !reflect.DeepEqual(got, want) {
202+
t.Errorf("top_scores = %v, want %v", got, want)
203+
}
204+
}
205+
206+
func TestRunPickerPluginNestsDelegateSpan(t *testing.T) {
207+
recorder := setupPickerSpanRecorder(t)
208+
209+
picker := &fakePicker{typedName: fwkplugin.TypedName{Type: "child", Name: "c"}, selected: 1, emitChild: true}
210+
profile := NewSchedulerProfile().WithPicker(picker)
211+
scores := newWeightedScores("pod1")
212+
213+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
214+
profile.runPickerPlugin(ctx, &fwksched.InferenceRequest{}, scores)
215+
root.End()
216+
217+
outer := findPickerSpans(recorder.Ended(), "pick_endpoints")
218+
inner := findPickerSpans(recorder.Ended(), "inner_picker_span")
219+
if len(outer) != 1 || len(inner) != 1 {
220+
t.Fatalf("got %d pick_endpoints and %d inner spans, want 1 each", len(outer), len(inner))
221+
}
222+
if inner[0].Parent().SpanID() != outer[0].SpanContext().SpanID() {
223+
t.Errorf("inner span parent = %v, want pick_endpoints span %v",
224+
inner[0].Parent().SpanID(), outer[0].SpanContext().SpanID())
225+
}
226+
}
227+
228+
func TestRunPickerPluginOmitsEmptyGenAI(t *testing.T) {
229+
recorder := setupPickerSpanRecorder(t)
230+
231+
picker := &fakePicker{typedName: fwkplugin.TypedName{Type: "p", Name: "n"}, selected: 1}
232+
profile := NewSchedulerProfile().WithPicker(picker)
233+
scores := newWeightedScores("pod1")
234+
235+
ctx, root := otel.Tracer("test").Start(context.Background(), "root")
236+
profile.runPickerPlugin(ctx, &fwksched.InferenceRequest{}, scores)
237+
root.End()
238+
239+
spans := findPickerSpans(recorder.Ended(), "pick_endpoints")
240+
if len(spans) != 1 {
241+
t.Fatalf("got %d pick_endpoints spans, want 1", len(spans))
242+
}
243+
attrs := pickerSpanAttributes(spans[0])
244+
if _, ok := attrs["gen_ai.request.model"]; ok {
245+
t.Error("gen_ai.request.model set for empty TargetModel")
246+
}
247+
if _, ok := attrs["gen_ai.request.id"]; ok {
248+
t.Error("gen_ai.request.id set for empty RequestID")
249+
}
250+
}

0 commit comments

Comments
 (0)