snapshot of https://github.com/livekit/livekit.git @ 08c5433449c106943231037d0b892895f6e92d3b (2026-09-15)
This commit is contained in:
274
pkg/sfu/ccutils/trenddetector.go
Normal file
274
pkg/sfu/ccutils/trenddetector.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ccutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/logger"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type TrendDirection int
|
||||
|
||||
const (
|
||||
TrendDirectionInconclusive TrendDirection = iota
|
||||
TrendDirectionUpward
|
||||
TrendDirectionDownward
|
||||
)
|
||||
|
||||
func (t TrendDirection) String() string {
|
||||
switch t {
|
||||
case TrendDirectionInconclusive:
|
||||
return "INCONCLUSIVE"
|
||||
case TrendDirectionUpward:
|
||||
return "UPWARD"
|
||||
case TrendDirectionDownward:
|
||||
return "DOWNWARD"
|
||||
default:
|
||||
return fmt.Sprintf("%d", int(t))
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type trendDetectorNumber interface {
|
||||
int64 | float64
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type trendDetectorSample[T trendDetectorNumber] struct {
|
||||
value T
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type trendDetectorSampleElapsed[T trendDetectorNumber] struct {
|
||||
value T
|
||||
sinceFirst time.Duration
|
||||
}
|
||||
|
||||
func (t trendDetectorSampleElapsed[T]) MarshalLogObject(e zapcore.ObjectEncoder) error {
|
||||
e.AddFloat64("value", float64(t.value))
|
||||
e.AddDuration("sinceFirst", t.sinceFirst)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type TrendDetectorConfig struct {
|
||||
RequiredSamples int `yaml:"required_samples,omitempty"`
|
||||
RequiredSamplesMin int `yaml:"required_samples_min,omitempty"`
|
||||
DownwardTrendThreshold float64 `yaml:"downward_trend_threshold,omitempty"`
|
||||
DownwardTrendMaxWait time.Duration `yaml:"downward_trend_max_wait,omitempty"`
|
||||
CollapseThreshold time.Duration `yaml:"collapse_threshold,omitempty"`
|
||||
ValidityWindow time.Duration `yaml:"validity_window,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type TrendDetectorParams struct {
|
||||
Name string
|
||||
Logger logger.Logger
|
||||
Config TrendDetectorConfig
|
||||
}
|
||||
|
||||
type TrendDetector[T trendDetectorNumber] struct {
|
||||
params TrendDetectorParams
|
||||
|
||||
startTime time.Time
|
||||
numSamples int
|
||||
samples []trendDetectorSample[T]
|
||||
lowestValue T
|
||||
highestValue T
|
||||
|
||||
direction TrendDirection
|
||||
}
|
||||
|
||||
func NewTrendDetector[T trendDetectorNumber](params TrendDetectorParams) *TrendDetector[T] {
|
||||
return &TrendDetector[T]{
|
||||
params: params,
|
||||
startTime: time.Now(),
|
||||
direction: TrendDirectionInconclusive,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) Seed(value T) {
|
||||
if len(t.samples) != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
t.samples = append(t.samples, trendDetectorSample[T]{value: value, at: time.Now()})
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) AddValue(value T) {
|
||||
t.numSamples++
|
||||
if t.lowestValue == 0 || value < t.lowestValue {
|
||||
t.lowestValue = value
|
||||
}
|
||||
if value > t.highestValue {
|
||||
t.highestValue = value
|
||||
}
|
||||
|
||||
// Ignore duplicate values in collapse window.
|
||||
//
|
||||
// Bandwidth estimate is received periodically. If the estimate does not change, it will be repeated.
|
||||
// When there is congestion, there are several estimates received with decreasing values.
|
||||
//
|
||||
// Using a sliding window, collapsing repeated values and waiting for falling trend to ensure that
|
||||
// the reaction is not too fast, i. e. reacting to falling values too quick could mean a lot of re-allocation
|
||||
// resulting in layer switches, key frames and more congestion.
|
||||
//
|
||||
// But, on the flip side, estimate could fall once or twice within a sliding window and stay there.
|
||||
// In those cases, using a collapse window to record a value even if it is duplicate. By doing that,
|
||||
// a trend could be detected eventually. It will be delayed, but that is fine with slow changing estimates.
|
||||
var lastSample *trendDetectorSample[T]
|
||||
if len(t.samples) != 0 {
|
||||
lastSample = &t.samples[len(t.samples)-1]
|
||||
}
|
||||
if lastSample != nil && lastSample.value == value && t.params.Config.CollapseThreshold > 0 && time.Since(lastSample.at) < t.params.Config.CollapseThreshold {
|
||||
return
|
||||
}
|
||||
|
||||
t.samples = append(t.samples, trendDetectorSample[T]{value: value, at: time.Now()})
|
||||
t.prune()
|
||||
t.updateDirection()
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) GetLowest() T {
|
||||
return t.lowestValue
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) GetHighest() T {
|
||||
return t.highestValue
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) GetDirection() TrendDirection {
|
||||
return t.direction
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) HasEnoughSamples() bool {
|
||||
return t.numSamples >= t.params.Config.RequiredSamples
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) MarshalLogObject(e zapcore.ObjectEncoder) error {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var samples []trendDetectorSampleElapsed[T]
|
||||
if len(t.samples) > 0 {
|
||||
firstTime := t.samples[0].at
|
||||
for _, sample := range t.samples {
|
||||
samples = append(samples, trendDetectorSampleElapsed[T]{sample.value, sample.at.Sub(firstTime)})
|
||||
}
|
||||
}
|
||||
|
||||
e.AddString("name", t.params.Name)
|
||||
e.AddTime("startTime", t.startTime)
|
||||
e.AddDuration("elapsed", time.Since(t.startTime))
|
||||
e.AddInt("numSamples", t.numSamples)
|
||||
e.AddArray("samples", logger.ObjectSlice(samples))
|
||||
e.AddFloat64("lowestValue", float64(t.lowestValue))
|
||||
e.AddFloat64("highestValue", float64(t.highestValue))
|
||||
e.AddFloat64("kendallsTau", t.kendallsTau())
|
||||
e.AddString("direction", t.direction.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) prune() {
|
||||
// prune based on a few rules
|
||||
|
||||
// 1. If there are more than required samples
|
||||
if len(t.samples) > t.params.Config.RequiredSamples {
|
||||
t.samples = t.samples[len(t.samples)-t.params.Config.RequiredSamples:]
|
||||
}
|
||||
|
||||
// 2. drop samples that are too old
|
||||
if len(t.samples) != 0 && t.params.Config.ValidityWindow > 0 {
|
||||
cutoffTime := time.Now().Add(-t.params.Config.ValidityWindow)
|
||||
cutoffIndex := -1
|
||||
for i := 0; i < len(t.samples); i++ {
|
||||
if t.samples[i].at.After(cutoffTime) {
|
||||
cutoffIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cutoffIndex >= 0 {
|
||||
t.samples = t.samples[cutoffIndex:]
|
||||
}
|
||||
}
|
||||
|
||||
// 3. collapse same values at the front to just the last of those samples
|
||||
if len(t.samples) != 0 {
|
||||
cutoffIndex := -1
|
||||
firstValue := t.samples[0].value
|
||||
for i := 1; i < len(t.samples); i++ {
|
||||
if t.samples[i].value != firstValue {
|
||||
cutoffIndex = i - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cutoffIndex >= 0 {
|
||||
t.samples = t.samples[cutoffIndex:]
|
||||
} else {
|
||||
// all values are the same, just keep the last one
|
||||
t.samples = t.samples[len(t.samples)-1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) updateDirection() {
|
||||
if len(t.samples) < t.params.Config.RequiredSamplesMin {
|
||||
t.direction = TrendDirectionInconclusive
|
||||
return
|
||||
}
|
||||
|
||||
// using Kendall's Tau to find trend
|
||||
kt := t.kendallsTau()
|
||||
|
||||
t.direction = TrendDirectionInconclusive
|
||||
switch {
|
||||
case kt > 0 && len(t.samples) >= t.params.Config.RequiredSamples:
|
||||
t.direction = TrendDirectionUpward
|
||||
case kt < t.params.Config.DownwardTrendThreshold && (len(t.samples) >= t.params.Config.RequiredSamples || t.samples[len(t.samples)-1].at.Sub(t.samples[0].at) > t.params.Config.DownwardTrendMaxWait):
|
||||
t.direction = TrendDirectionDownward
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TrendDetector[T]) kendallsTau() float64 {
|
||||
concordantPairs := 0
|
||||
discordantPairs := 0
|
||||
|
||||
for i := 0; i < len(t.samples)-1; i++ {
|
||||
for j := i + 1; j < len(t.samples); j++ {
|
||||
if t.samples[i].value < t.samples[j].value {
|
||||
concordantPairs++
|
||||
} else if t.samples[i].value > t.samples[j].value {
|
||||
discordantPairs++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (concordantPairs + discordantPairs) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return (float64(concordantPairs) - float64(discordantPairs)) / (float64(concordantPairs) + float64(discordantPairs))
|
||||
}
|
||||
Reference in New Issue
Block a user