WebRTC中使用CPU使用情况作为码率控制的依据之一。当CPU过度使用(overusing)时,进行视频编码的降级(adapt down);当CPU未充分使用(uderusing)时,进行视频编码的升级(adapt up)。目的是在当前设备性能条件下,尽可能地提供高质量的视频。这里的质量,包括清晰度、流畅度等综合指标。

WebRTC中关于CPU使用度检测的代码主要在 overuse_frame_detector.cc 的OveruseFrameDetector类中实现,其对象overuse_detector_在VideoStreamEncoder构造时作为参数传入。具体来说,OveruseFrameDetector负责实现检测数据的处理,VideoStreamEncoder负责初始化、提供数据、反馈回调等流程。

1 什么是“CPU使用度”

首先,我们需要明确什么是CPU使用度。顾名思义,往往我们会认为与CPU占用率有关。 但是阅读OveruseFrameDetector,除了注释中提到CPU,代码中并没有相关实现。实际上,所谓的“CPU使用度”是指编码耗时 / 采集耗时,是一个比值。当该比值越大时,表示编码跟不上采集,达到了编码器的性能瓶颈,需要对编码进行降级;反之,比值越小,表示编码能力有富余,还可以进行编码升级,提供更好的视频质量。换句话说,就是一个生产者与消费者的关系。

由此可见,CPU使用度并不与CPU产生直接关联,而是通过编码相对速率来衡量当前的运行性能。这样做的好处有几个:

  • 不与CPU硬件绑定,与硬件平台解耦
  • 兼顾当前软件运行环境的影响,比如其他应用对CPU的消耗、操作系统对各进程资源的调度策略

具体如何推导出该比值,将会在下文中介绍。

2 初始化与配置

和其他observer类型一样,OveruseFrameDetector在VideoStreamEncoder构造时注册。OveruseFrameDetector本身的构造中对配置项进行初始化,其中最重要的两个:

  • low_encode_usage_threshold_percent: Underusing的阈值,默认为42。低于该阈值,则认为Underusing。
  • high_encode_usage_threshold_percent:Overusing的阈值,默认为85。高于该阈值,则认为Overusing。

配置项的更改,只发生在编码器重新创建的时候(流初始化,或者编码格式变化):

void VideoStreamEncoder::ReconfigureEncoder() {
  // ...
  if (pending_encoder_creation_) {
    overuse_detector_->StopCheckForOveruse();
    overuse_detector_->StartCheckForOveruse(
        &encoder_queue_,
        GetCpuOveruseOptions(
            settings_, encoder_->GetEncoderInfo().is_hardware_accelerated),
        this);
    pending_encoder_creation_ = false;
  }
  // ...
}

CpuOveruseOptions GetCpuOveruseOptions(
    const VideoStreamEncoderSettings& settings,
    bool full_overuse_time) {
  CpuOveruseOptions options;

  if (full_overuse_time) {
    options.low_encode_usage_threshold_percent = 150;
    options.high_encode_usage_threshold_percent = 200;
  }
  if (settings.experiment_cpu_load_estimator) {
    options.filter_time_ms = 5 * rtc::kNumMillisecsPerSec;
  }
  return options;
}

由上可知,当编码器 is_hardware_accelerate,也就是使用硬件编码时,该阈值会进行调整,分别设置成150与200,相较软件编码的42、85提升了不少。这样做的用意是,硬件编码占用的是专用视频处理单元(这里统称为VPU),不占用CPU,因此可以适当“压榨”,保持VPU较高负载,不影响系统整体性能。除此之外,还因为硬件编码是异步运行(输入与输出不在同一线程)的,所以耗时评估上存在误差,使用较宽松的阈值能减少误差,该问题后续会提到。

3 检测开启与终止

检测的开启与终止在VideoStreamEncoder中控制,当编码器需要重置时调用:

void VideoStreamEncoder::ReconfigureEncoder() {
  // ...
  if (pending_encoder_creation_) {
    overuse_detector_->StopCheckForOveruse(); // 终止检测
    overuse_detector_->StartCheckForOveruse(  // 开启检测
        &encoder_queue_,
        GetCpuOveruseOptions(
            settings_, encoder_->GetEncoderInfo().is_hardware_accelerated),
        this);
    pending_encoder_creation_ = false;
  }
  // ...
}

检测机制是在线程中每隔5秒进行检查:

void OveruseFrameDetector::StartCheckForOveruse(...) {
  // ...
  check_overuse_task_ = RepeatingTaskHandle::DelayedStart(
      task_queue->Get(), TimeDelta::ms(kTimeToFirstCheckForOveruseMs),
      [this, overuse_observer] {
        CheckForOveruse(overuse_observer); // 在这里完成判断与反馈
        return TimeDelta::ms(kCheckForOveruseIntervalMs); // kCheckForOveruseIntervalMs = 5000 (ms)
      });
  // ...
}

4 样本数据采集

为了得到“编码耗时 / 采集耗时”这个比值,需要分别获取编码耗时、采集耗时的样本。具体来说,

  • 编码耗时 = 本帧编码结束时间 - 本帧编码开始时间
  • 采集耗时 = 本帧编码开始时间 - 上一帧编码开始时间

由此可知,真正要采集的样本是每帧的编码开始时间、编码结束时间。

4.1 编码开始时间

为了保证时间的准确性,在视频源刚到达VideoStreamEncoder时就进行了记录:

//
void VideoStreamEncoder::OnFrame(const VideoFrame& video_frame) {
  // ...
  int64_t post_time_us = rtc::TimeMicros();
  // ... 
  // MaybeEncodeVideoFrame(incoming_frame, post_time_us);
}

在编码时传递到了OveruseFrameDetector中:

void VideoStreamEncoder::EncodeVideoFrame(const VideoFrame& video_frame,
                                          int64_t time_when_posted_us) {
  // ...
  overuse_detector_->FrameCaptured(out_frame, time_when_posted_us);
  // ...
}

4.2 编码结束时间

在编码返回的回调函数中,对编码结束的时间点进行了记录:

EncodedImageCallback::Result VideoStreamEncoder::OnEncodedImage(...) {
   // ...
   RunPostEncode(image_copy, rtc::TimeMicros(), temporal_index);
   // ...
}

在RunPostEncode时传递到了OveruseFrameDetector中:

void VideoStreamEncoder::RunPostEncode(EncodedImage encoded_image,
                                       int64_t time_sent_us,
                                       int temporal_index) {
  // ...
  overuse_detector_->FrameSent(
      encoded_image.Timestamp(), time_sent_us,
      encoded_image.capture_time_ms_ * rtc::kNumMicrosecsPerMillisec,
      encode_duration_us);}
  // ...

5 计算过程

数据已经采集到,接下来是如何计算。

5.1 采集耗时计算

采集耗时的计算相对简单,当前帧与上一帧的时间差即可:

// overuser_frame_dectector.cc
void FrameCaptured(const VideoFrame& frame,
                   int64_t time_when_first_seen_us,
                   int64_t last_capture_time_us) override {
  // 计算时间差
  if (last_capture_time_us != -1)
    AddCaptureSample(1e-3 * (time_when_first_seen_us - last_capture_time_us));
  // 保存,用于后续计算编码耗时
  frame_timing_.push_back(FrameTiming(frame.timestamp_us(), frame.timestamp(),
                                      time_when_first_seen_us));
}

5.2 编码耗时计算

编码耗时的计算过程相对复杂,主要原因是编码结束的回调VideoStreamEncoder::OnEncodedImage与编码开始VideoStreamEncoder::OnFrame不属于同一线程,也就是异步运行,那么就导致了一个问题:如何判断结束与开始是对应同一帧?此外,还有以下情况让匹配难度雪上加霜:

  • 空域多层编码(如Simulcast),每层编码结束的回调是交叉乱序的
  • 编码失败或丢帧处理,导致编码结束时间缺失,没有正确匹配的可能
  • 硬件编码(如Android端的MediaCodec)时,接口内部的输入、输出可能分属不同线程,加剧了编码开始与结束的异步程度

更糟糕的是,一旦开始匹配错误,会出现“一步错,步步错”的累积错误反应,最终导致数据统计完全失效。

为了解决该问题,WebRTC的方法是假设所有帧的编码耗时应在1秒以内,并且从通过如下方法来匹配:

// overuse_frame_detector.cc
absl::optional<int> FrameSent(
      uint32_t timestamp,
      int64_t time_sent_in_us,
      int64_t /* capture_time_us */,
      absl::optional<int> /* encode_duration_us */) {
    static const int64_t kEncodingTimeMeasureWindowMs = 1000; // 设置1秒的编码耗时间隔值
    // 多层编码时,用后来的帧数的结束时间更新对应值,通过timestamp变量来识别是否对应帧
    for (auto& it : frame_timing_) {
      if (it.timestamp == timestamp) {
        it.last_send_us = time_sent_in_us;
        break;
      }
    }
    // 从编码开始时间的数据库中搜索匹配
    while (!frame_timing_.empty()) {
      FrameTiming timing = frame_timing_.front(); // 从最早记录的时间开始,越往后则与time_sent_in_us间隔越远
      // 在1秒时间间隔内,还不是收网的时候,先退出来保持数据库,等下一帧结束时再观察
      if (time_sent_in_us - timing.capture_us <
          kEncodingTimeMeasureWindowMs * rtc::kNumMicrosecsPerMillisec) {
        break;
      }
      // 超过1秒时间间隔,该帧的耗时可以统计了
      // 注意,这里计算差值用的是timing.last_send_us,而不是time_sent_in_us
      if (timing.last_send_us != -1) {
        encode_duration_us.emplace(
            static_cast<int>(timing.last_send_us - timing.capture_us));

        if (last_processed_capture_time_us_ != -1) {
          int64_t diff_us = timing.capture_us - last_processed_capture_time_us_;
          AddSample(1e-3 * (*encode_duration_us), 1e-3 * diff_us);
        }
        last_processed_capture_time_us_ = timing.capture_us;
      }
      // 在数据库中移除该帧
      frame_timing_.pop_front();
    }
    
}

由以上可以看出,每次调用FrameSent,成功匹配的不一定是当前帧,有可能是历史的某几帧。

5.3 数据平滑

为了防止出现检测结果抖动,影响用户体验,需要对数据进行平滑处理。这里使用了指数滤波器rtc::ExpFilter。

// overuse_frame_detector.h
std::unique_ptr<rtc::ExpFilter> filtered_processing_ms_;
std::unique_ptr<rtc::ExpFilter> filtered_frame_diff_ms_;

5.4 比值计算

每一帧数据记录后,马上进行检测结果的更新:

void OveruseFrameDetector::EncodedFrameTimeMeasured(int encode_duration_ms) {
  // ...
  encode_usage_percent_ = usage_->Value();
  // ...
}

int Value() override {
  // ...
  float frame_diff_ms = std::max(filtered_frame_diff_ms_->filtered(), 1.0f);
  frame_diff_ms = std::min(frame_diff_ms, max_sample_diff_ms_); // 这里要注意max_sample_diff_ms_的限制
  float encode_usage_percent =
      100.0f * filtered_processing_ms_->filtered() / frame_diff_ms;
  return static_cast<int>(encode_usage_percent + 0.5); // 四舍五入处理
}

6 反馈调节

在每隔5秒调用的OveruseFrameDetector::CheckForOveruse中完成了对检测结果的判断和反馈调节:

void OveruseFrameDetector::CheckForOveruse(
    AdaptationObserverInterface* observer) { // observer指向VideoStreamEncoder对象
  if (IsOverusing(*encode_usage_percent_)) {
    // ...
    observer->AdaptDown(kScaleReasonCpu); // 编码降级
  } else if (IsUnderusing(*encode_usage_percent_, now_ms)) {
    // ...
    observer->AdaptUp(kScaleReasonCpu); // 编码升级
  }
}

其中,IsOverusing为真,需要满足连续2次超过阈值:

bool OveruseFrameDetector::IsOverusing(int usage_percent) {
  RTC_DCHECK_RUN_ON(&task_checker_);

  if (usage_percent >= options_.high_encode_usage_threshold_percent) {
    ++checks_above_threshold_;
  } else {
    checks_above_threshold_ = 0;
  }
  return checks_above_threshold_ >= options_.high_threshold_consecutive_count; // high_threshold_consecutive_count为2
}

而IsUnderusing则只要1次低于阈值即可:

bool OveruseFrameDetector::IsUnderusing(int usage_percent, int64_t time_now) {
  // ...
  return usage_percent < options_.low_encode_usage_threshold_percent;
}