Skip to content

Commit 04cc371

Browse files
authored
test(flowcontrol): harden and reorganize the flow-control benchmark suite (llm-d#1638)
* test(flowcontrol): harden and reorganize the flow-control benchmark suite Test-only changes to pkg/epp/flowcontrol/benchmark; no production code is touched. - Re-scope the full-path benchmark (FullPathStress -> FullPath) to what it uniquely measures: dispatch throughput and allocation overhead through the real producer -> concurrency detector -> controller path. Drop the off-layer producer leak-counter assertion (covered by the inflightload producer's own unit tests), report d/s like the other benchmarks, and fix a b.Fatalf called from inside RunParallel (invalid off the benchmark goroutine). - Fix the mock benchDetector so the PerformanceMatrix L sweep is meaningful: it accumulates in-flight up to the limit and reports saturated once exceeded (real W>L backpressure), while self-healing the empty-queue idle grants the processor's 1ms dispatch ticker would otherwise leak (which previously latched and hung at L=1). Distinguish leaked idle grants from genuine load saturation via a release counter. Add detector unit tests covering saturation-at-limit and self-heal. - Replace fixed bootstrap time.Sleep calls with a warmup request that blocks until dispatch -- deterministic readiness instead of arbitrary timing. - Reorganize into doc.go (two-family overview) + helpers_test.go + benchmark_test.go, and dedup the shared registry/policy setup. - Add a bench-smoke make target and CI step (go test -bench=. -benchtime=1x) so benchmark bodies execute on every PR; go test only compiles them otherwise, letting runtime breakage (panics, deadlocks) go undetected. Signed-off-by: Luke Van Drie <lukevandrie@google.com> * test(flowcontrol): correct FullPath backpressure comment The inline comment claimed the FullPath benchmark sustains W>L queuing, but worker count rounds down to <= L and the real detector's in-flight load is released near-instantly, so dispatch is effectively free-flowing. Reword to match the function godoc: FullPath prices dispatch + the detector's per-cycle DynamicAttribute reads, not sustained backpressure. Signed-off-by: Luke Van Drie <lukevandrie@google.com> * test(flowcontrol): address review feedback on benchmark suite - Use the llm-d Authors copyright header on the new files (doc.go, detector_test.go) per review. - Fail fast if the PerformanceMatrix warmup request is not dispatched, matching the full-path harness, instead of silently timing a dead dispatch loop. - Fail fast if a matrix coordinate dispatches zero requests, so a wedged dispatch path produces a red build instead of plausible zero rows. - Drop the matrix harness TTL from 5m to 30s: nothing in a throughput benchmark legitimately waits minutes, and a tight TTL bounds a wedged coordinate to seconds of CI time instead of minutes per cell. - Document the liveness bias of the benchDetector's stuck-read reclaim heuristic: true queueing still reports saturated while releases flow; a stalled release transiently over-admits rather than latching. Signed-off-by: Luke Van Drie <lukevandrie@google.com> --------- Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent 62ae6ca commit 04cc371

6 files changed

Lines changed: 436 additions & 204 deletions

File tree

.github/workflows/ci-pr-checks.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ jobs:
113113
GO_BUILD_CACHE_VOL: ${{ steps.go-cache.outputs.build }}
114114
run: make test-unit
115115

116+
# Anti-rot: execute the benchmark bodies once so runtime breakage (panics,
117+
# deadlocks) is caught. `go test` only compiles benchmarks; it never runs
118+
# them without -bench.
119+
- name: Run benchmark smoke
120+
shell: bash
121+
env:
122+
GO_MOD_CACHE_VOL: ${{ steps.go-cache.outputs.mod }}
123+
GO_BUILD_CACHE_VOL: ${{ steps.go-cache.outputs.build }}
124+
run: make bench-smoke
125+
116126
- name: Run hermetic integration tests
117127
shell: bash
118128
env:

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,11 @@ bench-tokenizer: image-build-builder ## Run external tokenizer + scorer benchmar
315315
@printf "Run 'EXTERNAL_TOKENIZER_ENABLED=true KV_CACHE_ENABLED=true make env-dev-kind' first.\n\n"
316316
$(BUILDER_RUN_CLUSTER) 'go test -bench=. -benchmem -count=5 -timeout=5m ./test/profiling/tokenizerbench/'
317317

318+
.PHONY: bench-smoke
319+
bench-smoke: image-build-builder ## Smoke-run the flowcontrol benchmarks once (-benchtime=1x) to catch runtime rot
320+
@printf "\033[33;1m==== Running Flow Control Benchmark Smoke ====\033[0m\n"
321+
$(BUILDER_RUN) 'go test -run=^$$ -bench=. -benchtime=1x -timeout=5m ./pkg/epp/flowcontrol/benchmark/...'
322+
318323
.PHONY: post-deploy-test
319324
post-deploy-test: ## Run post deployment tests
320325
@echo "Success!"

pkg/epp/flowcontrol/benchmark/benchmark_test.go

Lines changed: 50 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -27,29 +27,17 @@ import (
2727
"testing"
2828
"time"
2929

30-
k8stypes "k8s.io/apimachinery/pkg/types"
31-
32-
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/contracts/mocks"
3330
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/controller"
34-
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/registry"
3531
"github.com/llm-d/llm-d-router/pkg/epp/flowcontrol/types"
36-
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
3732
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
38-
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
3933
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/requestcontrol"
4034
requesthandling "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/requesthandling"
4135
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
42-
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/fairness/globalstrict"
43-
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/ordering/fcfs"
44-
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/saturationdetector/concurrency"
45-
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/usagelimits"
46-
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/dataproducer/inflightload"
47-
igwtestutils "github.com/llm-d/llm-d-router/test/utils"
4836
)
4937

5038
// BenchmarkFlowController_PerformanceMatrix evaluates throughput across a matrix of variables.
51-
// It systematically evaluates the impact of strict egress limits, data parallelism, priority
52-
// levels, flow density, and concurrent connections.
39+
// It systematically evaluates the impact of strict egress limits, priority levels, flow density,
40+
// and concurrent connections.
5341
func BenchmarkFlowController_PerformanceMatrix(b *testing.B) {
5442
if testing.Short() {
5543
b.Skip("skipping PerformanceMatrix in short mode")
@@ -84,11 +72,17 @@ func BenchmarkFlowController_PerformanceMatrix(b *testing.B) {
8472
// runMatrixCoordinate executes a single coordinate of the performance hypercube.
8573
func runMatrixCoordinate(b *testing.B, m benchMatrix) {
8674
ctx, cancel := context.WithCancel(context.Background())
75+
defer cancel() // Ensure SUT goroutines are torn down even when the coordinate fails fatally.
8776

8877
fc, detector := setupBenchmarkHarness(ctx, b, m.priorities, m.limit, nil, nil)
8978

90-
// Yield briefly to allow the background supervisor to bootstrap the data plane.
91-
time.Sleep(10 * time.Millisecond)
79+
// Warm up: block on one request until it's dispatched, proving the dispatch loop is live before
80+
// timing. Release the slot per the loop's immediate-drain protocol (a no-op under free-flow).
81+
warmup := &benchRequest{key: flowcontrol.FlowKey{ID: "warmup", Priority: 0}, byteSize: 1024}
82+
if outcome, err := fc.EnqueueAndWait(ctx, warmup); outcome != types.QueueOutcomeDispatched {
83+
b.Fatalf("warmup request was not dispatched: outcome=%v, err=%v", outcome, err)
84+
}
85+
detector.Release()
9286

9387
reqs := make([]*benchRequest, m.flows)
9488
for i := 0; i < int(m.flows); i++ {
@@ -174,6 +168,13 @@ func runMatrixCoordinate(b *testing.B, m benchMatrix) {
174168
elapsed := b.Elapsed().Seconds()
175169
telemetry.report(b, elapsed)
176170

171+
// Every coordinate expects flow (W>L or free-flow), so zero dispatches means the dispatch path
172+
// wedged mid-run: fail loudly instead of publishing plausible-looking zero rows. Assert on the
173+
// benchmark goroutine (never inside RunParallel, where FailNow is invalid).
174+
if telemetry.dispatchCount.Load() == 0 {
175+
b.Fatalf("coordinate %s dispatched zero requests; the dispatch path is wedged", m.name())
176+
}
177+
177178
cancel() // Graceful teardown to prevent async skewing of subsequent coordinates.
178179
time.Sleep(50 * time.Millisecond) // Wait for SUT background goroutines to terminate.
179180
}
@@ -282,140 +283,48 @@ func BenchmarkFlowController_MassCancellation(b *testing.B) {
282283
}
283284
}
284285

285-
// fullPathBenchHarness holds shared setup state for full-path benchmarks that
286-
// wire a real InFlightLoadProducer, real concurrency detector, and real
287-
// persistent endpoints into the FlowController.
288-
type fullPathBenchHarness struct {
289-
fc *controller.FlowController
290-
producer *inflightload.InFlightLoadProducer
291-
epMeta *fwkdl.EndpointMetadata
292-
schedEp scheduling.Endpoint
293-
}
294-
295-
// setupFullPathBenchmark creates the shared harness for benchmarks that exercise
296-
// the complete flow control data path: producer -> detector -> controller -> cleanup.
297-
func setupFullPathBenchmark(ctx context.Context, b *testing.B, name string, numPriorities int) *fullPathBenchHarness {
298-
b.Helper()
299-
if numPriorities < 1 {
300-
numPriorities = 1
301-
}
302-
handle := igwtestutils.NewTestHandle(ctx)
303-
304-
producerName := name + "-producer"
305-
producerPlugin, err := inflightload.InFlightLoadProducerFactory(
306-
producerName, fwkplugin.StrictDecoder([]byte(`{}`)), handle,
307-
)
308-
if err != nil {
309-
b.Fatal(err)
310-
}
311-
producer := producerPlugin.(*inflightload.InFlightLoadProducer)
312-
313-
detectorPlugin, err := concurrency.ConcurrencyDetectorFactory(
314-
name+"-detector",
315-
fwkplugin.StrictDecoder([]byte(fmt.Sprintf(
316-
`{"maxConcurrency": 100, "inFlightLoadProducerName": %q}`, producerName,
317-
))),
318-
handle,
319-
)
320-
if err != nil {
321-
b.Fatal(err)
322-
}
323-
realDetector := detectorPlugin.(flowcontrol.SaturationDetector)
324-
325-
epMeta := &fwkdl.EndpointMetadata{
326-
NamespacedName: k8stypes.NamespacedName{Name: "pod-1", Namespace: "default"},
327-
}
328-
ep := fwkdl.NewEndpoint(epMeta, fwkdl.NewMetrics())
329-
if err := producer.Extract(ctx, fwkdl.EndpointEvent{
330-
Type: fwkdl.EventAddOrUpdate, Endpoint: ep,
331-
}); err != nil {
332-
b.Fatal(err)
333-
}
334-
335-
fPolicy, err := globalstrict.GlobalStrictFairnessPolicyFactory(registry.DefaultFairnessPolicyRef, nil, handle)
336-
if err != nil {
337-
b.Fatal(err)
338-
}
339-
handle.AddPlugin(registry.DefaultFairnessPolicyRef, fPolicy)
340-
341-
oPolicy, err := fcfs.FCFSOrderingPolicyFactory(registry.DefaultOrderingPolicyRef, nil, handle)
342-
if err != nil {
343-
b.Fatal(err)
344-
}
345-
handle.AddPlugin(registry.DefaultOrderingPolicyRef, oPolicy)
346-
347-
defaults := registry.PriorityBandPolicyDefaults{
348-
OrderingPolicy: oPolicy.(flowcontrol.OrderingPolicy),
349-
FairnessPolicy: fPolicy.(flowcontrol.FairnessPolicy),
350-
}
351-
reg := setupRegistry(b, defaults, priorityLevels(numPriorities))
352-
353-
fc := controller.NewFlowController(ctx, name+"-bench", &controller.Config{
354-
DefaultRequestTTL: 5 * time.Minute,
355-
ExpiryCleanupInterval: 1 * time.Hour,
356-
EnqueueChannelBufferSize: 2000,
357-
}, controller.Deps{
358-
Registry: reg,
359-
SaturationDetector: realDetector,
360-
EndpointCandidates: &mocks.MockEndpointCandidates{Candidates: []fwkdl.Endpoint{ep}},
361-
UsageLimitPolicy: usagelimits.DefaultPolicy(),
362-
})
363-
364-
time.Sleep(10 * time.Millisecond)
365-
366-
schedEp := scheduling.NewEndpoint(epMeta, fwkdl.NewMetrics(), nil)
367-
368-
return &fullPathBenchHarness{
369-
fc: fc,
370-
producer: producer,
371-
epMeta: epMeta,
372-
schedEp: schedEp,
373-
}
374-
}
375-
376-
// BenchmarkFlowController_FullPathStress exercises the complete flow control
377-
// data path under realistic production conditions: real InFlightLoadProducer,
378-
// real concurrency detector reading DynamicAttributes from persistent endpoints,
379-
// real FlowController with backpressure from a concurrency-gated detector,
380-
// multiple priority bands, concurrent workers creating unique flows (agentic
381-
// churn), and the full PreRequest -> dispatch -> StartOfStream -> EndOfStream
382-
// lifecycle per request.
286+
// BenchmarkFlowController_FullPath measures dispatch throughput and allocation overhead of the
287+
// complete flow-control data path using REAL components: a real InFlightLoadProducer feeding a real
288+
// concurrency SaturationDetector, which gates a real FlowController. Unlike the throughput
289+
// microbenchmarks (which use the mock benchDetector), this captures the cost of the detector's
290+
// per-dispatch DynamicAttribute reads against live producer state in the hot path.
383291
//
384-
// What it catches:
385-
// - PluginState entry leaks (addedTokensEntry not cleaned up by OnEvicted)
386-
// - DynamicAttribute closure leaks (producer tracker never decremented)
387-
// - Cross-band counter drift (stats routed to wrong priority band)
388-
// - Registry flow infrastructure leaks under concurrent churn
389-
// - Contention-induced allocation growth (lock convoy, sync.Map thrashing)
292+
// Each iteration runs the full request lifecycle: EnqueueAndWait (admission) -> PreRequest
293+
// (producer records in-flight load) -> ResponseBody StartOfStream / EndOfStream (producer releases
294+
// it). Workers spread across priority bands and mint a unique flow per request for registry churn.
390295
//
391296
// Run with:
392297
//
393-
// go test -bench=FullPathStress -benchtime=5000x -count=3 -run=^$ ./pkg/epp/flowcontrol/benchmark/
298+
// go test -bench=FullPath -run=^$ ./pkg/epp/flowcontrol/benchmark/
394299
//
395-
// Stable B/op across runs = no leak. Counter assertion at the end catches
396-
// tracker drift that B/op alone would miss.
397-
func BenchmarkFlowController_FullPathStress(b *testing.B) {
300+
// Reports d/s (dispatch throughput). Producer-level leak correctness is covered by the inflightload
301+
// producer's own unit tests, not here.
302+
func BenchmarkFlowController_FullPath(b *testing.B) {
398303
const numPriorities = 4
399304

400305
ctx := b.Context()
401-
h := setupFullPathBenchmark(ctx, b, "stress", numPriorities)
306+
h := setupFullPathBenchmark(ctx, b, "fullpath", numPriorities)
402307

403308
sosResp := &requestcontrol.Response{StartOfStream: true}
404309
eosResp := &requestcontrol.Response{EndOfStream: true}
405310
profileResults := map[string]*scheduling.ProfileRunResult{
406311
"decode": {TargetEndpoints: []scheduling.Endpoint{h.schedEp}},
407312
}
408313

409-
// Concurrency-gated: each dispatched request consumes a slot. Workers
410-
// release immediately after ResponseBody, creating sustained backpressure
411-
// (W workers > L limit). This forces real queuing in the dispatch cycle.
314+
telemetry := newBenchmarkTelemetry()
315+
316+
// Concurrency-gated by the real detector: each dispatched request records in-flight load (via
317+
// PreRequest) and releases it after ResponseBody. Load is held only briefly, so this measures
318+
// free-flowing dispatch plus the detector's per-cycle DynamicAttribute read cost rather than
319+
// sustained W>L queuing.
412320
var globalReqID atomic.Uint64
413321

414322
b.ResetTimer()
415323
b.ReportAllocs()
416324
b.SetParallelism(max(100/runtime.GOMAXPROCS(0), 1))
417325

418326
b.RunParallel(func(pb *testing.PB) {
327+
var local threadTelemetry
419328
for pb.Next() {
420329
id := globalReqID.Add(1)
421330
reqID := fmt.Sprintf("req-%d", id)
@@ -427,34 +336,37 @@ func BenchmarkFlowController_FullPathStress(b *testing.B) {
427336
byteSize: 512,
428337
}
429338
outcome, _ := h.fc.EnqueueAndWait(ctx, fcReq)
430-
431339
if outcome != types.QueueOutcomeDispatched {
432-
b.Fatalf("request %s was not dispatched: %v", reqID, outcome)
340+
telemetry.recordReject(&local)
341+
continue
433342
}
343+
telemetry.recordDispatch(&local)
434344

435-
// 2. Post-scheduling: producer tracks the request on the endpoint.
345+
// 2. Post-scheduling: producer records in-flight load on the endpoint, which the
346+
// detector reads to compute saturation.
436347
infReq := &scheduling.InferenceRequest{
437348
RequestID: reqID,
438349
Body: &requesthandling.InferenceRequestBody{TokenizedPrompt: &requesthandling.TokenizedPrompt{PerPromptTokens: [][]uint32{benchTokenIDs}}},
439350
}
440351
schedResult := &scheduling.SchedulingResult{ProfileResults: profileResults}
441352
h.producer.PreRequest(ctx, infReq, schedResult)
442353

443-
// 3. Response lifecycle: release counters.
354+
// 3. Response lifecycle: release the in-flight load.
444355
infReq.SchedulingResult = schedResult
445356
h.producer.ResponseBody(ctx, infReq, sosResp, h.epMeta)
446357
h.producer.ResponseBody(ctx, infReq, eosResp, h.epMeta)
447358
}
359+
telemetry.commit(&local)
448360
})
449361

450362
b.StopTimer()
451-
b.ReportMetric(float64(globalReqID.Load()), "total-requests")
363+
telemetry.report(b, b.Elapsed().Seconds())
452364

453-
finalRequests := h.producer.GetRequests(h.epMeta.NamespacedName.String())
454-
finalTokens := h.producer.GetTokens(h.epMeta.NamespacedName.String())
455-
if finalRequests != 0 || finalTokens != 0 {
456-
b.Errorf("counter leak detected after %d requests across %d priorities: requests=%d, tokens=%d",
457-
globalReqID.Load(), numPriorities, finalRequests, finalTokens)
365+
// A fully saturated full-path run must dispatch every request; a rejection
366+
// means the throughput numbers are skewed. Assert on the benchmark goroutine
367+
// (never inside RunParallel, where FailNow is invalid).
368+
if rejects := telemetry.rejectCount.Load(); rejects > 0 {
369+
b.Fatalf("full-path benchmark saw %d rejected requests; throughput numbers are unreliable", rejects)
458370
}
459371
}
460372

0 commit comments

Comments
 (0)