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;
}