diff --git a/gcc/overuse_detector.go b/gcc/overuse_detector.go new file mode 100644 index 0000000..53b60a9 --- /dev/null +++ b/gcc/overuse_detector.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2025 The Pion community +// SPDX-License-Identifier: MIT + +package gcc + +import ( + "math" + "time" +) + +const ( + kUp = 0.0087 + kDown = 0.039 + + minNumDeltas = 60 +) + +const ( + defaultThresholdGain = 4.0 + defaultOveruseTimeThreshold = 5 * time.Millisecond +) + +type overuseDetector struct { + adaptiveThreshold bool + thresholdGain float64 + overUseTimeThreshold time.Duration + delayThreshold float64 + lastUpdate time.Time + firstOverUse time.Time + overUseCounter int + previousTrend float64 +} + +func newOveruseDetector(adaptive bool) *overuseDetector { + return &overuseDetector{ + adaptiveThreshold: adaptive, + thresholdGain: defaultThresholdGain, + overUseTimeThreshold: defaultOveruseTimeThreshold, + delayThreshold: 6, + lastUpdate: time.Time{}, + firstOverUse: time.Time{}, + overUseCounter: 0, + previousTrend: 0, + } +} + +func (d *overuseDetector) update(ts time.Time, trend float64, numDeltas int) usage { + if d.lastUpdate.IsZero() { + d.lastUpdate = ts + } + if numDeltas < 2 { + return usageNormal + } + modifiedTrend := float64(min(numDeltas, minNumDeltas)) * trend * d.thresholdGain + + var currentUsage usage + switch { + case modifiedTrend > d.delayThreshold: + if d.firstOverUse.IsZero() { + delta := ts.Sub(d.lastUpdate) + d.firstOverUse = ts.Add(-delta / 2) + } + d.overUseCounter++ + if ts.Sub(d.firstOverUse) > d.overUseTimeThreshold && d.overUseCounter > 1 && trend >= d.previousTrend { + d.firstOverUse = time.Time{} + d.overUseCounter = 0 + currentUsage = usageOver + } + case modifiedTrend < -d.delayThreshold: + d.firstOverUse = time.Time{} + d.overUseCounter = 0 + currentUsage = usageUnder + default: + d.firstOverUse = time.Time{} + d.overUseCounter = 0 + currentUsage = usageNormal + } + d.adaptThreshold(ts, modifiedTrend) + d.previousTrend = trend + d.lastUpdate = ts + + return currentUsage +} + +func (d *overuseDetector) adaptThreshold(ts time.Time, modifiedTrend float64) { + if !d.adaptiveThreshold { + return + } + if math.Abs(modifiedTrend) > d.delayThreshold+15 { + return + } + k := kUp + if math.Abs(modifiedTrend) < d.delayThreshold { + k = kDown + } + delta := min(ts.Sub(d.lastUpdate), 100*time.Millisecond) + d.delayThreshold += k * (math.Abs(modifiedTrend) - d.delayThreshold) * float64(delta.Milliseconds()) + d.delayThreshold = min(d.delayThreshold, 600.0) + d.delayThreshold = max(d.delayThreshold, 6.0) +} diff --git a/gcc/overuse_detector_test.go b/gcc/overuse_detector_test.go new file mode 100644 index 0000000..58d4555 --- /dev/null +++ b/gcc/overuse_detector_test.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: 2025 The Pion community +// SPDX-License-Identifier: MIT + +package gcc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestOveruseDetectorUpdate(t *testing.T) { + type estimate struct { + ts time.Time + estimate float64 + numDeltas int + } + cases := []struct { + name string + adaptive bool + values []estimate + expected []usage + }{ + { + name: "noEstimateNoUsageStatic", + adaptive: false, + values: []estimate{}, + expected: []usage{}, + }, + { + name: "overuseStatic", + adaptive: false, + values: []estimate{ + {time.Time{}, 1.0, 1}, + {time.Time{}.Add(5 * time.Millisecond), 20, 2}, + {time.Time{}.Add(20 * time.Millisecond), 30, 3}, + }, + expected: []usage{usageNormal, usageNormal, usageOver}, + }, + { + name: "normaluseStatic", + adaptive: false, + values: []estimate{{estimate: 0}}, + expected: []usage{usageNormal}, + }, + { + name: "underuseStatic", + adaptive: false, + values: []estimate{{time.Time{}, -20, 2}}, + expected: []usage{usageUnder}, + }, + { + name: "noOverUseBeforeDelayStatic", + adaptive: false, + values: []estimate{ + {time.Time{}.Add(time.Millisecond), 20, 1}, + {time.Time{}.Add(2 * time.Millisecond), 30, 2}, + {time.Time{}.Add(30 * time.Millisecond), 50, 3}, + }, + expected: []usage{usageNormal, usageNormal, usageOver}, + }, + { + name: "noOverUseIfEstimateDecreasedStatic", + adaptive: false, + values: []estimate{ + {time.Time{}.Add(time.Millisecond), 20, 1}, + {time.Time{}.Add(10 * time.Millisecond), 40, 2}, + {time.Time{}.Add(30 * time.Millisecond), 50, 3}, + {time.Time{}.Add(35 * time.Millisecond), 3, 4}, + }, + expected: []usage{usageNormal, usageNormal, usageOver, usageNormal}, + }, + { + name: "noEstimateNoUsageAdaptive", + adaptive: true, + values: []estimate{}, + expected: []usage{}, + }, + { + name: "overuseAdaptive", + adaptive: true, + values: []estimate{ + {time.Time{}, 1, 1}, + {time.Time{}.Add(5 * time.Millisecond), 20, 2}, + {time.Time{}.Add(20 * time.Millisecond), 30, 3}, + }, + expected: []usage{usageNormal, usageNormal, usageOver}, + }, + { + name: "normaluseAdaptive", + adaptive: true, + values: []estimate{{estimate: 0}}, + expected: []usage{usageNormal}, + }, + { + name: "underuseAdaptive", + adaptive: true, + values: []estimate{{time.Time{}, -20, 2}}, + expected: []usage{usageUnder}, + }, + { + name: "noOverUseBeforeDelayAdaptive", + adaptive: true, + values: []estimate{ + {time.Time{}.Add(time.Millisecond), 20, 1}, + {time.Time{}.Add(2 * time.Millisecond), 30, 2}, + {time.Time{}.Add(30 * time.Millisecond), 50, 3}, + }, + expected: []usage{usageNormal, usageNormal, usageOver}, + }, + { + name: "noOverUseIfEstimateDecreasedAdaptive", + adaptive: true, + values: []estimate{ + {time.Time{}.Add(time.Millisecond), 20, 1}, + {time.Time{}.Add(10 * time.Millisecond), 40, 2}, + {time.Time{}.Add(30 * time.Millisecond), 50, 3}, + {time.Time{}.Add(35 * time.Millisecond), 3, 4}, + }, + expected: []usage{usageNormal, usageNormal, usageOver, usageNormal}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + od := newOveruseDetector(tc.adaptive) + received := []usage{} + for _, e := range tc.values { + u := od.update(e.ts, e.estimate, e.numDeltas) + received = append(received, u) + } + assert.Equal(t, tc.expected, received) + }) + } +} + +func TestOveruseDetectorAdaptThreshold(t *testing.T) { + cases := []struct { + name string + od *overuseDetector + ts time.Time + estimate float64 + expectedThreshold float64 + }{ + { + name: "minThreshold", + od: &overuseDetector{ + adaptiveThreshold: true, + }, + ts: time.Time{}, + estimate: 0, + expectedThreshold: 6, + }, + { + name: "increase", + od: &overuseDetector{ + adaptiveThreshold: true, + delayThreshold: 12.5, + lastUpdate: time.Time{}.Add(time.Second), + }, + ts: time.Time{}.Add(2 * time.Second), + estimate: 25, + expectedThreshold: 23.375, + }, + { + name: "maxThreshold", + od: &overuseDetector{ + adaptiveThreshold: true, + delayThreshold: 600, + lastUpdate: time.Time{}, + }, + ts: time.Time{}.Add(time.Second), + estimate: 610, + expectedThreshold: 600, + }, + { + name: "decrease", + od: &overuseDetector{ + adaptiveThreshold: true, + delayThreshold: 12.5, + lastUpdate: time.Time{}, + }, + ts: time.Time{}.Add(10 * time.Millisecond), + estimate: 1, + expectedThreshold: 8.015, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.od.adaptThreshold(tc.ts, tc.estimate) + assert.Equal(t, tc.expectedThreshold, tc.od.delayThreshold) + }) + } +}