移动平均(EWMA)在 HLS.JS 的实践

才开始大家一直在想,什么是移动均线? 哪里会用到??

对于常规的页面,实际上很难说需要到具体到有需要用到 网络宽带的预估,但是对于流媒体或者上传服务时候,这个就很重要了,我们需要根据不同网速情况,做策略上的更改,从而改善体验。

做视频播放的很多童鞋,经常在 Youtube 上看到这么一个仪表盘,界面。

大家会看到网速的实际情况。

如果叫大家来实现,实际上,或许很轻而易举会想到用一个网络资源的请求,和时间来完成做一次除法就OK了。

const start = Date.now()

fetch(url, {}).then((result) => {
	const end = Date.now();
	const bw = length / (end - start) * 8000;	
})

似乎,这样就可以表示一个当前分片的网速了,但是我们实际上并不会用当前的瞬时值作为一个衡量当前宽带是否满足我们播放某种清晰度的标准。

而 hls.js 巧妙的借鉴了 移动平均 来反映当前网络的一个趋势。

移动平均

移动平均(moving average,MA),又称“移动平均线”简称均线,是技术分析中一种分析时间序列数据的工具。最常见的是利用股价、回报或交易量等变数计算出移动平均。移动平均可抚平短期波动,反映出长期趋势或周期。数学上,移动平均可视为一种卷积。

如果有平时会参加股票交易或者 ICO 交易的童鞋,对 5日均线,20 均线会非常熟悉了。

移动平均,平时大概有三种算法;

  • 简单移动平均(simple moving average,SMA)
  • 加权移动平均(weighted moving average,WMA)
  • 指数加权移动平均(Exponentially Weighted Moving-Average, EWMA)

而 hls.js 则用到的 上诉的 指数加权移动平均, EWMA 来进行网速的预测。

指数移动加权平均法,是指各数值的加权系数随时间呈指数式递减,越靠近当前时刻的数值加权系数就越大。

vt = αvt−1 + (1 − α)θt // 公式 2-1

其中 θt 为 某一天的 股票价格,系数 α 表示加权下降的速率,其值越小下降的越快;vt为 t 时刻 EWMA 的值。

在 t=0 时刻,一般初始化 v0=0 ,对 EWMA 的表达式进行归纳可以将 t 时刻的表达式写成:

vt = (1 − α )(θt +α θt−1 + ... + α t−1θ1) // 公式 2-2

从上面式子中可以看出,数值的加权系数随着时间呈指数下降。在数学中一般会以 1/e 来作为一个临界值,小于该值的加权系数的值不作考虑,接着来分析上面 α=0.9 和 α=0.98 的情况。

当 α=0.9 时,0.910 约等于 1/e ,因此认为此时是近10个数值的加权平均。

当 α=0.98 时,0.950 约等于 1/e,因此认为此时是近 50 个数值的加权平均。这种情况也正是移动加权平均的来源。

hls.js 中的实践

首先我们看下 ewma-bandwidth-estimator.js 也是 hls.js 直接可以访问到的 controller.

class EwmaBandWidthEstimator {
  constructor (hls, slow, fast, defaultEstimate) {
    this.hls = hls;
    this.defaultEstimate_ = defaultEstimate;
    this.minWeight_ = 0.001;
    this.minDelayMs_ = 50;
    this.slow_ = new EWMA(slow);
    this.fast_ = new EWMA(fast);
  }
...
}	

里面定义了我们需要使用到 hls 的实例,以及一些常量 详见配置,比如最小的权重以及最小的请求用时。

同时定义了,需要引入 EWMA 的 最慢和最快 周期值。


  sample (durationMs, numBytes) {
    durationMs = Math.max(durationMs, this.minDelayMs_);
    let numBits = 8 * numBytes,
      // weight is duration in seconds
      durationS = durationMs / 1000,
      // value is bandwidth in bits/s
      bandwidthInBps = numBits / durationS;
    this.fast_.sample(durationS, bandwidthInBps);
    this.slow_.sample(durationS, bandwidthInBps);
  }

主要是用于计算宽带值,然后进行数据采样。

 canEstimate () {
    let fast = this.fast_;
    return (fast && fast.getTotalWeight() >= this.minWeight_);
  }

  getEstimate () {
    if (this.canEstimate()) {
      // Take the minimum of these two estimates.  This should have the effect of
      // adapting down quickly, but up more slowly.
      return Math.min(this.fast_.getEstimate(), this.slow_.getEstimate());
    } else {
      return this.defaultEstimate_;
    }
  }

获取估算后的值。

接下来我们再看

https://github.com/video-dev/hls.js/blob/master/src/utils/ewma.js

constructor (halfLife) {
    // Larger values of alpha expire historical data more slowly.
    this.alpha_ = halfLife ? Math.exp(Math.log(0.5) / halfLife) : 0;
    this.estimate_ = 0;
    this.totalWeight_ = 0;
  }

设置我们的 α 值。设置一些初始值。

sample (weight, value) {
    let adjAlpha = Math.pow(this.alpha_, weight);
    // 参考公式 2-1
    this.estimate_ = value * (1 - adjAlpha) + adjAlpha * this.estimate_;
    this.totalWeight_ += weight;
  }

进行计算。

  getTotalWeight () {
    return this.totalWeight_;
  }

  getEstimate () {
    if (this.alpha_) {
      let zeroFactor = 1 - Math.pow(this.alpha_, this.totalWeight_);
      return this.estimate_ / zeroFactor;
    } else {
      return this.estimate_;
    }
  }

我们可以简单写个图表实时算下瞬时 request 和 EWMA 趋势的差别。


var myChart;
var x_durations = [];
var currentbw = [];
var ewwa = [];
var lowEwma = [];
var fastEwma = [];

var option = {
  tooltip : {
      trigger: 'axis'
  },
  legend: {
      data:['current Request', 'ewma'],
  },
  toolbox: {
      show : true,
      feature : {
          mark : {show: true},
          dataView : {show: true, readOnly: false},
          magicType : {show: true, type: ['line', 'bar', 'stack', 'tiled']},
          restore : {show: true},
          saveAsImage : {show: true}
      }
  },
  calculable : true,
  xAxis : [
      {
          type : 'category',
          boundaryGap : false,
          data : x_durations
      }
  ],
  yAxis : [
      {
          type : 'value'
      }
  ],
  series : [
      {
          name:'current Request',
          type:'line',
          stack: 'Network bandwidth Speed',
          data:currentbw
      },
      {
          name:'ewma',
          type:'line',
          stack: 'Network bandwidth Speed',
          data:ewwa
      },
  ]
};


function getMatchRangeTime(time, ranges) {
  if (ranges.length === 0) {
    return 0;
  }
  var len = ranges.length;
  for (var i = 0; i < ranges.length; i ++) {
    var start = ranges.start(i);
    var end = ranges.end(i);
    if (time >= start && time <= end) {
      return ranges.end(i);
    }
  }
  return time;
}

var video = document.querySelector('.js-video');
if(Hls.isSupported()) {
  var hls = new Hls();
  hls.loadSource('https://video-dev.github.io/streams/x36xhzz/x36xhzz.m3u8');
  hls.attachMedia(video);
  hls.on(Hls.Events.MANIFEST_PARSED,function() {
    video.play();
    x_durations.push(hls.getBufferTime())
  });
  window.hlsplayer = hls;
  hls.on('hlsFragLoaded', (event, result) => {
    if (result.frag.type === 'audio') {
      return;
    }
    if (result.stats) {
      // logger.log(result);
      var {loaded, tfirst, tload} = result.stats;
      var bandwidth = (loaded / (tload - tfirst) * 1000 * 8);
      currentbw.push(bandwidth);
    }
    if (x_durations.length === 4 ) {
      myChart = echarts.init(document.querySelector('.js-canvas-box'));
    }
    if (x_durations.length >= 4) {
      myChart.setOption(option);
    }
    var bufferTime = getMatchRangeTime(video.currentTime, video.buffered);
    x_durations.push(bufferTime);
    ewwa.push(hls.abrController._bwEstimator.getEstimate())

    // console.log(hls)
  });
}


而我们宽带峰值设置为

如果我们用瞬时值,其实很大程度还是会受到一些因素比如 DNS 建连,程序执行消耗,以及后端响应的因素影响,而借助 EWMA 能够帮助我们客观的评估当前趋势。

完整代码: https://github.com/JackPu/freleap.github.io/tree/master/ewma

系列解读:

扩展阅读