一、引言
作为国内领先的在线视频平台,哔哩哔哩(以下简称“B站”)正经历着业务体量和用户规模的快速增长。随着访问量的持续增长和业务复杂程度的增加,在相对有限的服务器资源下如何优化在线服务性能和提高资源利用率,成为了工程研发团队面临的重要挑战之一。
本文将以笔者所在的商业技术中心为例,重点讨论效果广告引擎的在线推理部分。文章将分享笔者在实际工作中遇到的挑战及相应的优化方案。首先,将介绍项目背景和当前系统的运行状况;接着,将详细探讨性能指标量化、服务调用、CPU计算、内存治理及网络IO等方面的优化策略;最后,将总结对性能优化的一些思考,并展望未来性能优化的方向。本文的目的是回顾并总结当前在线服务性能优化的工作,同时也希望这些经验能为其他研发人员在处理类似问题时提供参考和启发。
二、项目背景
笔者所在的团队主要负责在线效果广告引擎的研发工作,该服务作为商业化系统的重要组成之一,为公司带来了实质性的商业贡献。通过精准高效的广告投放,能够为公司带来稳定且可观的广告收入,成为支撑平台发展的关键营收来源之一,进一步支持了平台的内容创新和技术研发,构成良性循环。对于广告主而言,效果广告引擎提供了精准定向用户的能力,显著提升了广告传播的效果,为其带来更高的广告转化和投资回报。对于用户而言,通过更贴近用户行为习惯的广告投放,确保了广告内容与用户兴趣和需求的高度匹配,最大限度地保障了用户体验。
随着效果广告业务的快速发展,处理的业务复杂度不断提升,对在线服务的处理效率和吞吐量提出了更高要求。同时,B站的用户规模和使用时长的持续增长也加大了这一挑战。以在线推理服务为例,它需要对广告创意候选集进行一系列预估打分,主要包括特征计算和模型计算两个环节。特征处理阶段涉及用户和广告数据的提取、过滤、拼接等操作,随着特征数据的深入挖掘和应用,所需要处理的数据量也在不断增加。在模型计算阶段,支持的模型类型从LR、FM模型逐渐升级到DNN模型,增强了模型的表达能力,但同时也加大了算力资源的消耗。类似的资源开销增长问题也存在于效果广告引擎的其他服务中。因此,工程研发团队面临的挑战在于如何有效地对效果广告引擎进行性能优化,确保在硬件资源相对有限的情况下,依然能够支持并促进业务的持续增长。
三、系统现状
首先需要介绍一下效果广告引擎的系统构成,主要包含了以下几个服务:
检索引擎:作为广告业务的入口,接受来自各个调用方的请求,并且会对流量进行预处理,其中包括对流量进行实验分组和标记。
效果广告检索服务:作为效果广告的业务核心,负责对候选集中的广告创意进行优选,并且将胜选的结果回传给检索引擎。
召回/粗排服务:根据流量的上下文信息,从所有在投的广告创意中挑选出一批符合条件的广告创意,并且进行粗排打分,将最终的Top N作为候选集返回给效果广告检索服务。
推理服务:负责对候选集中的广告创意进行一系列精排打分,将最终结果返回给效果广告检索服务。
此处需要说明的是,由于本文的重点是在线推理服务,因此对于广告引擎中的其他部分进行了大幅简化,实际的效果广告引擎要更为复杂。为了进一步便于理解,使用下图来说明简化后的效果广告引擎内部各服务之间的调用关系及主要功能模块:
图1 效果广告引擎调用关系及主要功能模块
目前效果广告引擎的在线集群规模已经达到了数千台服务器,其中在线推理服务的CPU资源占比约为整体的45%,召回/粗排服务占比约为21%,效果广告检索服务占比约为10%。通过CPU资源的分配比例,可以直观反映出各服务之间计算复杂度的差异,同时也揭示了系统中存在的潜在性能瓶颈。推理服务作为系统资源开销最大的在线服务,对其进行性能优化的收益也是最为显著的。
在对效果广告引擎的背景及现状有了初步的了解之后,下面本文将针对推理服务的各项优化手段进行更为详细的介绍。
四、优化手段
在实际工作中,对于在线服务的性能优化首先要建立在性能度量的基础上,因此在开始优化之前,需要对在线服务的各项数据指标进行可量化的测量和分析。
性能指标量化
从宏观角度来看,可以通过埋点的方式对在线服务的各个模块耗时进行监控和分析,定位到耗时较高的模块。之后,可以通过更细粒度的埋点或者日志,来找到开销较高的操作,并进行性能优化。同时作为在线服务,效果广告引擎的各服务之间的调用耗时也是需要监控的。受益于服务使用的BRPC框架,效果广告引擎的子服务都实现了较为完善的监控指标,包括各模块之间的平均耗时、中位数耗时、97线耗时等,并且对于各类远程调用也都有对应的耗时监控指标。依靠这些能够被量化的数据,我们能够快速定位到哪些模块和调用的耗时较高,并且能够在开发人力有限的情况下,给出性能优化的先后顺序,尽可能提高单次性能优化的收益。需要特别注意的是,在确保性能指标不失真的前提下,可以对性能指标的收集和上报进行一定程度的采样操作,主要是为了防止性能度量本身给服务带来过大的额外算力开销。
服务调用
在得到较为完善的性能指标之后,就可以结合对于推理服务的业务理解,从业务流程和服务调用的角度对在线服务进行全局分析。这部分的优化思路主要是在处理一次用户请求的过程中,减少数据的重复计算,并且降低数据传输的成本。
在较早的设计中,效果广告检索服务会将候选集中的广告创意拆分成多个推理请求,并行发送给多个推理服务节点,从而确保单个请求的处理耗时不会较高。如上文所述,推理服务需要获取用户侧的数据来进行特征处理,这些数据存放在Redis集群中。因此在处理每一个推理请求时,推理服务都需要单独访问一次Redis来获取用户数据,造成了Redis服务端访问较多,并且数据重复传输的问题。通过将访问Redis的操作上移至广告检索服务,然后再发送给推理服务的方式,有效减少了对Redis服务的访问量,降低了Redis服务端的算力开销和网络IO开销。
此外,在对早期方案重构的过程中,我们对服务调用之间所使用的数据格式也进行了升级,将原本类似JSON的数据处理方式,升级成了基于Protobuf3的数据处理方式。相比于文本格式的JSON,PB编码的数据通常更小,并且拥有更快的序列化和反序列化速度,这在处理大量数据时尤其重要。同时,将推理请求中的字段类型与特征计算中所需要的数据类型进行对齐,减少了大量的字符串转化及数据校验操作,降低了CPU算力开销。
这一类问题看似比较基础,但是在早期引擎架构快速迭代的过程中,由于不同阶段的各种原因,导致各个服务之间的设计无法完全一致,一些细节问题是比较容易被忽略的。随着业务的迭代和增长,这类小问题的影响就会被逐渐放大,导致服务性能下降和算力资源浪费。因此,定期对在线服务的业务和架构进行梳理回顾,是保障服务健康稳定的重要手段之一。
CPU算力
将视角聚焦到推理服务中,对于单次推理请求,我们同样也可以使用减少数据重复计算的方式来降低CPU算力开销,并且可以使用Perf性能分析工具,来进一步优化热点函数的算力开销。
首先在进行特征计算的过程中,包含了对于用户侧特征和广告侧特征的处理,其中用户侧特征的计算结果是能够被重复使用的。在推理服务的处理过程中,单次请求中的多个广告创意,会使用多线程并行的方式进行处理,此时会先将用户侧数据与单个广告创意进行计算,将结果存储在特征计算的运行时对象中,并且通过标记来区分用户侧特征和广告侧特征。然后,将其中的用户侧特征计算结果复制到其他线程的运行时对象中,再启动线程进行并行计算。这样既可以使用多线程来提高批量广告的特征计算处理速度,又不会因为重复计算用户侧数据而造成额外的算力开销。
进一步的,通过使用Perf性能分析工具,可以观察到具体某段代码的执行效率,并且分析出主要的性能开销点。在实际工作中,由于推理服务本身的迭代较为频繁,我们会定期对服务性能进行评估和回顾。当发现存在性能热点时,会优先进行性能优化,常见的代码优化手段有:
- 减少分支:分支预测失败会导致CPU流水线刷新,浪费大量的CPU周期。尽可能地减少分支,或者尽量使分支预测更加准确,可以帮助提高代码的性能。
- 循环展开:循环展开可以减少分支和循环开销,同时也可以提高指令级并行性。但是也要注意,过度展开可能会增大代码体积,对指令缓存造成压力。
- 数据局部性优化:尽可能地保持数据的局部性,使得数据能够高效地利用CPU缓存。这包括空间局部性(访问相邻的数据项)和时间局部性(短时间内重复访问同一数据项)。
- 向量化:利用CPU的SIMD指令集,可以同时对多个数据进行操作。在编写代码时,尽可能使数据结构和算法可以利用SIMD指令进行向量化操作。
针对这些优化手段,下面笔者会提供一些实际工作中遇到的具体事例以作参考。
1. 使用__builtin_expect内建函数来提供分支预测的提示,该函数会给GCC编译器提示,告知其某个条件判断的结果更可能是true还是false,通常用于优化代码中高度可能或者不可能执行的分支。在实际编写代码的过程中,该函数通常与宏一起使用,包括Linux在内的各种代码中都封装了自己likely和unlikely宏来提高性能。
2. 使用循环展开来提高代码性能,下面这段代码是通过循环展开来优化数据构建的例子,需要注意的是,当批量处理完展开部分的循环体之后,还需要处理剩余的迭代。
// 循环展开
for (uint32_t idx = start_idx; idx + 3 < end_idx; idx += 4) {
result[value[idx]].emplace_back(feaid, ins);
result[value[idx + 1]].emplace_back(feaid, ins);
result[value[idx + 2]].emplace_back(feaid, ins);
result[value[idx + 3]].emplace_back(feaid, ins);
}
// 处理剩余的迭代
for (uint32_t idx = end_idx - (end_idx - start_idx) % 4; idx < end_idx; ++idx) {
result[value[idx]].emplace_back(feaid, ins);
}
3. 使用函数指针的方式来减少条件判断,并且提高时间局部性,下面是一个简化后的例子。这段代码的目的是根据“field_type”获取“AdInfo”类中对应的成员函数指针,并且在一个循环中对“ad_info_list”集合中的每一个“AdInfo”对象调用这个成员函数。
typedef int64_t (AdInfo::*field_func)(void) const;
static field_func get_field_func(int field_type) {
switch(field_type) {
case 1:
return &AdInfo::id1;
case 2:
return &AdInfo::id2;
case 3:
return &AdInfo::id3;
default:
return nullptr;
}
}
auto selected_func = get_field_func(field_type);
if (selected_func != nullptr) {
for (const auto& ad_info : ad_info_list) {
auto val = (ad_info.*selected_func)();
// ...
}
}
4. 利用AVX指令集进行并行计算,下面是一个使用AVX256指令集计算两个“std::vector”向量的点积的代码示例,首先使用“_mm256_mul_ps“函数和“_mm256_add_ps”函数完成了浮点数的相乘和累加,然后通过“_mm256_hadd_ps”函数得到计算结果,最后处理不能被8整除的部分。
float dot_product_avx256(const std::vector<float>& vec1, const std::vector<float>& vec2) {
if (vec1.size() != vec2.size()) {
return 0;
}
size_t vec_size = vec1.size();
size_t block_width = 8;
size_t loop_cnt = vec_size / block_width;
size_t remainder = vec_size % block_width;
__m256 sum = _mm256_setzero_ps();
for (size_t i = 0; i < loop_cnt * block_width; i += block_width) {
__m256 a = _mm256_loadu_ps(&vec1[i]);
__m256 b = _mm256_loadu_ps(&vec2[i]);
__m256 c = _mm256_mul_ps(a, b);
sum = _mm256_add_ps(sum, c);
}
__m256 hsum = _mm256_hadd_ps(sum, sum);
__m256 hsum2 = _mm256_hadd_ps(hsum, hsum);
float result[8];
_mm256_storeu_ps(result, hsum2);
float dot = result[0] + result[4];
for (size_t i = loop_cnt * block_width; i < vec_size; ++i) {
dot += vec1[i] * vec2[i];
}
return dot;
}
对于在线服务的性能优化是一件细致且琐碎的工作,上述的优化手段及实践仅是一小部分,更多繁复的细节不再赘述。针对不同的业务场景,性能优化是否最终有效还需要更全面的测试才能得到验证。通过“观测、定位、优化、测试”这样的正向循环,在经过持续一年的性能优化后,推理服务的CPU开销相对降低了21%,同时峰值的吞吐量提高了13%。
内存治理
在内存治理方面,常见的优化手段主要围绕着数据格式的设计与升级,而此处笔者想分享的,是关于服务运行时的内存治理。具体到实际工作中,效果广告引擎中的多数在线服务,都是基于BRPC框架开发的C++服务,通过SessionData对象来管理一次请求中的数据。
在笔者目前使用的版本中,BRPC框架可以通过在服务启动时预生成若干个SessionData对象来响应请求,当某个SessionData对象完成一次响应后,会清理其中保存的数据以等待下一个请求调用。当SessionData对象中需要存储的成员变量过多时,就会产生频繁的内存申请和释放,同时容易导致内存碎片化。为了解决这个问题,我们对SessionData中的数据进行了预分配和池化处理,当SessionData创建时就对其进行了初始化,一次性分配了所需的内存,并且在清理数据时仅重置数据而不进行销毁操作。
此外,SessionData对象本身也是通过一个对象池进行管理的,当请求到达时会从对象池中获取一个SessionData对象来处理请求,当请求处理完成后归还至对象池中。若在线服务的访问量突然增加,或者服务处理时间突然变长时,将会导致对象池中没有可用的SessionData对象。此时,对象池会创建并初始化新的SessionData对象以响应后续请求。然而,当服务恢复平稳之后,这些新创建出的SessionData对象就会处于闲置状态,不会被主动回收释放,这就导致了运行时内存的增加。为了解决这个问题,通过BRPC框架中的hook函数,实现了SessionData对象的回收机制。需要注意的是,回收SessionData对象时会释放一部分内存,如果同时进行大量的回收操作,会导致服务性能的抖动,因此设计了一个较为平滑的回收方案。当服务检测到目前对象池的空闲对象数量大于设定时,会按一定概率对多余的SessionData对象进行回收,在保证服务的稳定性和弹性的同时,也有效降低了服务运行时的内存开销。根据SessionData对象在服务中的定义及内存占比不同,在线服务的运行时内存开销下降了约15%~22%左右。
网络IO
在网络IO方面,已经通过采用Protobuf作为服务间的传输格式来降低数据的传输量,然而对于一些特殊场景而言,直接使用原生的数据格式可能并不是最优解。
例如,推理服务以容器化的方式部署在物理机上,服务需要加载多个模型数据以及用于进行特征计算的正排词表数据,当内存占用较大时会使用多个节点的NUMA节点内存,当出现访问远端节点的内存时会导致一定的内存延迟增加。由于推理服务是一个计算密集型的服务,在负载较高时带宽竞争的情况会更为明显,内存延迟可能会导致服务的响应时间显著升高,严重情况下可能会导致服务不可用。
为了解决这类问题,同时进一步对服务架构进行解耦,计划将推理服务中的模型计算模块单独进行服务化改造,同时该服务仅需要加载少量模型数据,将内存用量限制在单个NUMA节点所管理的内存空间内,通过将进程进行NUMA节点绑定的部署方式避免了跨节点访问内存。
在这个设计方案中,推理服务需要在特征计算后,将每个广告创意得到的特征签名数组(通常为uint64数组)发送给模型计算服务。最直观的数据传输方案是为每一个待处理的广告创意定义一个uint64数组来传输特征签名数组,这里有个问题就是每个uint64数组都会添加一些额外的数据信息,导致消息体变大。并且由于每个特征签名都是经过哈希计算后的数值较大的uint64,因此Protobuf内置的变长算法并不能起到压缩数据的作用。
// 原版(未使用)
message Input {
repeated uint64 value = 1 [packed = true];
}
message Request {
repeated Input inputs = 1;
}
改进后的设计,是将所有广告创意放在同一个uint64数组中,并且新增一个uint32数组来记录每个广告创意对应的特征签名下标。使用两个大数组的方式降低了数据传输开销。
// 改进I
message Request {
repeated uint32 index = 1 [packed = true];
repeated uint64 value = 2 [packed = true];
}
由于在同一批请求中,包含了许多公共的特征签名,主要是来自用户侧的特征数据,因此可以提取出公共的部分,以减少value中的重复数据。相较于“改进I”,实测网络传输的带宽下降了50%。
// 改进II
message Request {
repeated uint32 index = 1 [packed = true];
repeated uint64 value = 2 [packed = true];
repeated uint64 shared = 3 [packed = true];
}
在“改进II”方案上取得的收益,主要来自于用户侧特征签名占整体比例的多少,随着将来模型的迭代升级,这个比例的变化情况会难以预估,并且筛选公共特征签名的过程也带来了不少的性能开销。最终的数据传输方案采用倒排索引的方式,对同一批次的特征签名和广告创意创建一个倒排索引。
// 改进III
message Inverted {
repeated uint64 key = 1 [packed = true];
repeated uint32 index = 2 [packed = true];
repeated uint32 value = 3 [packed = true];
repeated uint32 length = 4 [packed = true];
}
message Request {
Inverted inverted = 1;
}
在“改进III”方案中,key字段用来存储所有的特征签名,index字段记录了每个特征签名对应在value数组中的起始和终止下标,value字段则记录了该特征签名所对应的广告创意下标。这个方案的优点是所有特征签名数据仅记录一次,最大限度减少了不可压缩的uint64的数据量,index和value字段的数值大小十分有限,在实际场景中都可以通过变长压缩的方式进行大幅压缩,同时使用了packed标志,进一步减少了元数据带来的开销。为了后续重建每个广告创意的特征签名列表时的性能考虑,额外定义了length字段来存储每个广告创意的特征签名列表长度。即使在额外增加length信息之后,相较于“改进II”方案,该方案进一步将网络传输的带宽下降了10%,同时由于构建请求的计算复杂度下降,构建请求的模块耗时也降低了14%,在降低网络IO的同时,也降低了CPU算力开销。
五、思考与展望
在保障业务持续迭代的同时,通过对在线服务进行多轮不断的升级和优化,服务性能有了显著提升,其中CPU、内存、网络IO等资源的使用效率得到了明显改善,在线集群整体节省了上万核的CPU算力。在此过程中,研发团队也积累了丰富的经验和知识,将为未来的项目研发提供了重要支持。
性能优化是一个持续的过程,它要求我们的研发人员在业务发展和技术更新的过程中不断发现问题、探索解决方案,并持续进行精细化调整。这不仅仅是一个单次任务,而是一个长期的、不断打磨和优化的过程,旨在寻找既实用又高效的优化策略。更为关键的是,性能优化往往不是孤立的任务,而是需要跨服务、跨团队的协作,这要求项目主导方需要全面考虑系统的负载和收益,协同各方共同推进优化方案的实施,实现真正的效益最大化。
在编写本文的过程中,由于文章篇幅限制以及个人工作经验的局限,笔者仅能分享在线推理服务性能优化方面的部分心得和成果。在此,要感谢所有在工作项目中给予帮助和支持的公司及团队成员。本文旨在为从事相关领域的读者提供一些启发,尤其希望能对面临类似性能挑战的研发人员带来灵感和实际收获。
本期作者
李渊驰哔哩哔哩资深开发工程师