字节跳动百万级 Metrics Agent 性能优化的探索与实践
可观测性应用使企业机构能够利用他们的数据特征来获得竞争优势。它能够在正确的时间提高正确数据的战略重要性,以便根据明确的数据分析结果采取快速行动。—— Gartner
来源 | 云原生可观测团队
在字节跳动内部,metricserver2(以下简称 Agent)是与时序数据库 ByteTSD 配套使用的用户指标打点 Agent,用于在物理机粒度收集用户的指标打点数据。
随着字节跳动业务的迅速发展,技术团队采集的可观测数据量日益庞大,Agent 也逐渐面临严峻的技术挑战:
几乎所有服务节点上均部署集成了 Agent,装机量达到百万以上
Agent 需要负责打点数据的解析、聚合、压缩、协议转换和发送,属于 CPU 和 Mem 密集的服务
以上两者结合,使得 Agent 在字节跳动内部监控全链路服务成本中的占比达到 70% 以上,因此对 Agent 进行性能优化,实现降本增效,已经成为一个刻不容缓的任务。
Receiver:监听 socket、UDP 端口,接收 SDK 发出的 metrics 数据
Msg-Parser:对数据包进行反序列化,丢掉不符合规范的打点,然后将数据点暂存在 Storage 中
Storage:支持 7 种类型的 metircs 指标存储
Flusher:在每个发送周期的整时刻,触发任务获取 Storage 的快照,并对其存储的 metrics 数据进行聚合,将聚合后的数据按照发送要求进行编码
Compress:负责对编码的数据包进行压缩
Sender:支持 HTTP 和 TCP 方式,将数据发给后端服务
接下来,我们将按照数据接收、数据处理、数据发送三个部分来分析 Agent 优化的性能热点。
{ // Process Functionmsgpack::unpacked msg;msgpack::unpack(&msg, buffer.data(), buffer.size());msgpack::object obj = msg.get();std::vector<std::vector<std::string>> vecs;if (obj.via.array.ptr[0].type == 5) {std::vector<std::string> vec;obj.convert(&vec);vecs.push_back(vec);} else if (obj.via.array.ptr[0].type == 6) {obj.convert(&vecs);} else {++fail_count;return result;}// Some more process steps}
因此在第二步,对 msgpack::object 进行转换的时候,我们不再转换为 string,而是使用 string_view,可以优化掉 string 的复制和内存分配等:
// Define string_view convert struct.template <>struct msgpack::adaptor::convert<std::string_view> {msgpack::object const& operator()(msgpack::object const& o, std::string_view& v) const {switch (o.type) {case msgpack::type::BIN:v = std::string_view(o.via.bin.ptr, o.via.bin.size);break;case msgpack::type::STR:v = std::string_view(o.via.str.ptr, o.via.str.size);break;default:throw msgpack::type_error();break;}return o;}};static bool string_reference(msgpack::type::object_type type, std::size_t, void*) {return type == msgpack::type::STR;}{msgpack::unpacked msg;msgpack::unpack(msg, buffer.data(), buffer.size(), string_reference);msgpack::object obj = msg.get();std::vector<std::vector<std::string_view>> vecs;if (obj.via.array.ptr[0].type == msgpack::type::STR) {std::vector<std::string_view> vec;obj.convert(&vec);vecs.push_back(vec);} else if (obj.via.array.ptr[0].type == msgpack::type::ARRAY) {obj.convert(&vecs);} else {++fail_count;return result;}}
经过验证可以看到:零拷贝的时候,转换完的所有数据的内存地址都在原来的的 buffer 的内存地址范围内。而使用 string 进行复制的时候,内存地址和 buffer 的内存地址明显不同。
Agent 在接收端通过系统调用完成数据接收后,会立刻将数据投递到异步的线程池内,进行数据的解析工作,以达到不阻塞接收端的效果。
但我们在对线上数据进行分析时发现,用户产生的数据包大小是不固定的,并且存在大量的小包(比如一条打点数据)。这会导致异步线程池内的任务数量较多,平均每个任务的体积较小,线程池需要频繁的从队列获取新的任务,带来了处理性能的下降。
因此我们充分理解了 msgpack 的协议格式后(https://github.com/msgpack/msgpack/blob/master/spec.md),在接收端将多个数据小包(一条打点数据)聚合成一个数据大包(多条打点数据),进行一次任务提交,提高了接收端的处理性能,降低了线程切换的开销。
static inline bool tryMerge(std::string& merge_buf, std::string& recv_buf, int msg_size, int merge_buf_cap) {uint16_t big_endian_len, host_endian_len, cur_msg_len;memcpy(&big_endian_len, (void*)&merge_buf[1], sizeof(big_endian_len));host_endian_len = ntohs(big_endian_len);cur_msg_len = recv_buf[0] & 0x0f;if((recv_buf[0] & 0xf0) != 0x90 || merge_buf.size() + msg_size > merge_buf_cap || host_endian_len + cur_msg_len > 0xffff) {// upper 4 digits are not 1001// or merge_buf cannot hold anymore data// or array 16 in the merge_buf cannot hold more objs (although not possible right now, but have to check)return false;}// start merginghost_endian_len += cur_msg_len;merge_buf.append(++recv_buf.begin(), recv_buf.begin() + msg_size);// update elem cnt in array 16big_endian_len = htons(host_endian_len);memcpy((void*)&merge_buf[1], &big_endian_len, sizeof(big_endian_len));return true;}{ // receiver function// array 16 with 0 memberstd::string merge_buf({(char)0xdc, (char)0x00, (char)0x00});for(int i = 0 ; i < 1024; ++i) {int r = recv(fd, const_cast<char *>(tmp_buffer_.data()), tmp_buffer_size_, 0);if (r > 0) {if(!tryMerge(merge_buf, tmp_buffer_, r, tmp_buffer_size_)) {// Submit Task}// Some other logics}}
从关键的系统指标的角度看,在 merge 逻辑有收益时(接收 QPS = 48k/75k/120k/150k),小包合并逻辑大大减少了上下文切换,执行指令数,icache/dcache miss,并且增加了 IPC(instructions per cycle)见下表:
同时通过对前后火焰图的对比分析看,在合并数据包之后,原本用于调度线程池的 CPU 资源更多的消耗在了收包上,也解释了小包合并之后 context switch 减少的情况。
用户在打点指标中的 Tags,是拼接成字符串进行纯文本传递的,这样设计的主要目的是简化 SDK 和 Agent 之间的数据格式。但这种方式就要求 Agent 必须对字符串进行解析,将文本化的 Tags 反序列化出来,又由于在接收端收到的用户打点 QPS 很高,这也成为了 Agent 的性能热点。
早期 Agent 在实现这个解析操作时,采用了遍历字符串的方式,将字符串按 | 和 = 分割成 key-value 对。在其成为性能瓶颈后,我们发现它很适合使用 SIMD 进行加速处理。
原版
inline bool is_tag_split(const char &c) {return c == '|' || c == ' ';}inline bool is_kv_split(const char &c) {return c == '=';}bool find_str_with_delimiters(const char *str, const std::size_t &cur_idx, const std::size_t &end_idx,const Process_State &state, std::size_t *str_end) {if (cur_idx >= end_idx) {return false;}std::size_t index = cur_idx;while (index < end_idx) {if (state == TAG_KEY) {if (is_kv_split(str[index])) {*str_end = index;return true;} else if (is_tag_split(str[index])) {return false;}} else {if (is_tag_split(str[index])) {*str_end = index;return true;}}index++;}if (state == TAG_VALUE) {*str_end = index;return true;}return false;}
SIMD 版
#if defined(__SSE__)static std::size_t find_key_simd(const char *str, std::size_t end, std::size_t idx) {if (idx >= end) { return 0; }for (; idx + 16 <= end; idx += 16) {__m128i v = _mm_loadu_si128((const __m128i*)(str + idx));__m128i is_tag = _mm_or_si128(_mm_cmpeq_epi8(v, _mm_set1_epi8('|')),_mm_cmpeq_epi8(v, _mm_set1_epi8(' ')));__m128i is_kv = _mm_cmpeq_epi8(v, _mm_set1_epi8('='));int tag_bits = _mm_movemask_epi8(is_tag);int kv_bits = _mm_movemask_epi8(is_kv);// has '|' or ' ' firstbool has_tag_first = ((kv_bits - 1) & tag_bits) != 0;if (has_tag_first) { return 0; }if (kv_bits) { // found '='return idx + __builtin_ctz(kv_bits);}}for (; idx < end; ++idx) {if (is_kv_split(str[idx])) { return idx; }else if (is_tag_split(str[idx])) { return 0; }}return 0;}static std::size_t find_value_simd(const char *str, std::size_t end, std::size_t idx) {if (idx >= end) { return 0; }for (; idx + 16 <= end; idx += 16) {__m128i v = _mm_loadu_si128((const __m128i*)(str + idx));__m128i is_tag = _mm_or_si128(_mm_cmpeq_epi8(v, _mm_set1_epi8('|')),_mm_cmpeq_epi8(v, _mm_set1_epi8(' ')));int tag_bits = _mm_movemask_epi8(is_tag);if (tag_bits) {return idx + __builtin_ctz(tag_bits);}}for (; idx < end; ++idx) {if (is_tag_split(str[idx])) { return idx; }}return idx;}
构建的测试用例格式为 [text]=[text]| * 10。text 则是测试例子里的 str_size,用来测试不同 str_size 下使用 simd 的收益。可以看到,在 str_size 较大时,simd 性能明显高于标量的实现。
Agent 在数据聚合过程中,需要一个 map 来存储一个指标的所有序列,用于对一段时间内的打点值进行聚合计算,得到一个固定间隔的观测值。这个 map 的 key 是指标的 tags,map 的 value 是指标的值。我们通过采集火焰图发现,这个 map 的查找操作存在一定程度的热点。
下面是 _M_find_before_node 的实现:
这个函数作用是:算完 hash 后,在 hash 桶里找到匹配 key 的元素。这也意味着,即使命中了,hash 查找的时候也要进行一次 key 的比较操作。而在 Agent 里,这个 key 的比较操作定义为:
bool operator==(const TagSet &other) const {if (tags.size() != other.tags.size()) {return false;}for (size_t i = 0; i < tags.size(); ++i) {auto &left = tags[i];auto &right = other.tags[i];if (left.key_ != right.key_ || left.value_ != right.value_) {return false;}}return true;}
这里需要遍历整个 Tagset 的元素并比较他们是否相等。在查找较多的情况下,每次 hash 命中后都要进行这样一次操作是非常耗时的。可能导致时间开销增大的原因有:
每个 tag 的 key_ 和 value_ 是单独的内存(如果数据较短,stl 不会额外分配内存,这样的情况下就没有单独分配的内存了),存在着 cache miss 的开销,硬件预取效果也会变差
需要频繁地调用 memcmp 函数
按个比较每个 tag,分支较多
因此,我们将 TagSet 的数据使用 string_view 表示,并将所有的 data 全部存放在同一块内存中。在 dictionary encode 的时候,再把 TagSet 转换成 string 的格式返回出去。
// TagView#include <functional>#include <string>#include <vector>struct TagView {TagView() = default;TagView(std::string_view k, std::string_view v) : key_(k), value_(v) {}std::string_view key_;std::string_view value_;};struct TagViewSet {TagViewSet() = default;TagViewSet(const std::vector<TagView> &tgs, std::string&& buffer) : tags(tgs),tags_buffer(std::move(buffer)) {}TagViewSet(std::vector<TagView> &&tgs, std::string&& buffer) { tags = std::move(tgs); }TagViewSet(const std::vector<TagView> &tgs, size_t buffer_assume_size) {tags.reserve(tgs.size());tags_buffer.reserve(buffer_assume_size);for (auto& tg : tgs) {tags_buffer += tg.key_;tags_buffer += tg.value_;}const char* start = tags_buffer.c_str();for (auto& tg : tgs) {std::string_view key(start, tg.key_.size());start += key.size();std::string_view value(start, tg.value_.size());start += value.size();tags.emplace_back(key, value);}}bool operator==(const TagViewSet &other) const {if (tags.size() != other.tags.size()) {return false;}// not compare every tagreturn tags_buffer == other.tags_buffer;}std::vector<TagView> tags;std::string tags_buffer;};struct TagViewSetPtrHash {inline std::size_t operator()(const TagViewSet *tgs) const {return std::hash<std::string>{}(tgs->tags_buffer);}};
验证结果表明,当 Tagset 中 kv 的个数大于 2 的时候,新方法性能较好。
早期 Agent 使用 zlib 进行数据发送前的压缩,随着用户打点规模的增长,压缩逐步成为了 Agent 的性能热点。
因此我们通过构造满足线上用户数据特征的数据集,对常用的压缩库进行了测试:
zlib 使用 cloudflare:
zlib 使用 1.2.11:
通过测试结果我们可以看到,除 bzip2 外,其他压缩算法均在不同程度上优于 zlib:
zlib 的高性能分支,基于 cloudflare 优化 比 1.2.11 的官方分支性能好,压缩 CPU 开销约为后者的 37.5%
采用 SIMD 指令加速计算
zstd 能够在压缩率低于 zlib 的情况下,获得更低的 CPU 开销,因此如果希望获得比当前更好的压缩率,可以考虑 zstd 算法
若不考虑压缩率的影响,追求极致低的 CPU 开销,那么 snappy 是更好的选择
上述优化取得了非常好的效果,经过上线验证得出:
CPU 峰值使用量降低了 10.26%,平均使用量降低了 6.27%
Mem 峰值使用量降低了 19.67%,平均使用量降低了 19.81%
综合分析以上性能热点和优化方案,可以看到我们对 Agent 优化的主要考量点是:
减少不必要的内存拷贝
减少程序上下文的切换开销,提高缓存命中率
使用SIMD指令来加速处理关键性的热点逻辑
除此之外,我们还在开展 PGO 和 clang thinLTO 的验证工作,借助编译器的能力来进一步优化 Agent 性能。
字节跳动云原生可观测(Cloud Native-Observability)团队提供日均数十 PB 级可观测性数据采集、存储和查询分析的引擎底座,致力于为业务、业务中台、基础架构建设完整统一的可观测性技术支撑能力。同时,团队也正通过火山引擎持续对外输出云上可观测技术能力。