微信工程師:常見性能優化方案與實用工具

架構互聯高可用 2024-03-08 09:11:58

性能優化是降本增效路上必不可少的手段之一,在合適的時機采用合理的手段進行性能優化,一方面可以實現系統性能提升的目標,另一方面也可以借機對腐化的代碼進行清理。在程序員的面試環節中,性能優化的問題也幾乎是必考題。

然而性能優化並非一錘子買賣,需要一直監控,一直優化。過早的優化、過度的優化,以及優化 ROI 都是程序員們在工作中需要評估的關鍵點。本文作者總結了日常工作中常見的性能優化問題,圍繞數據結構展開推薦了常見的幾種性能優化方案——既有提升 3 倍性能的優化技巧,也有扛住26 億/s API 調用量的健壯方案。文末還推薦了三款好用的性能測試工具,值得點贊收藏!

01使用 Class 代替 ProtoBuf 協議在大量調用的 API 代碼中盡量不要用 ProtoBuf 協議,最好使用 C++的 Class 來代替。因爲 ProtoBuf 采用的是 Arena 內存分配器策略,有些場景會比 C++的 Class 內存管理複雜,當有大量內存分配和釋放的時候會比 Class 的性能差很多。而且 Protobuf 會不斷分配和回收小內存對象,持續地分配和刪除小內存對象導致産生內存碎片,降低程序的內存使用率,尤其是當協議中包含 string 類型的時候,性能差距可能有幾倍。對于包含了很多小對象的 Protobuf message,析構過程會導致堆上分配了許多對象,從而會變得複雜,導致析構函數執行速度變慢。

下面給出一個實際開發中使用 Class 代替 Protobuf 的例子:

ProtoBuf 協議:

message Param {optional string name = 1;optional string value = 2;}message ParamHit {enum Type {Unknown = 0;WhiteList = 1;LaunchLayer = 2;BaseAB = 3;DefaultParam = 4; }optional Param param = 1;optional uint64 group_id = 2;optional uint64 expt_id = 3;optional uint64 launch_layer_id = 4;optional string hash_key_used = 5;optional string hash_key_val_used = 6;optional Type type = 7;optional bool is_hit_mbox = 8;}

改寫的 Class :

class ParamHitInfo {public:class Param {public: Param() = default; ~Param() = default;const std::string & name() const {return name_; }void set_name(const std::string &name) { name_ = name; }void clear_name() { name_.clear(); }const std::string & value() const {return value_; }void set_value(const std::string &value) { value_ = value; }void clear_value() { value_.clear(); }void Clear() { clear_name(); clear_value(); }private:std::string name_, value_;}; ParamHitInfo() { expt_id_ = group_id_ = launch_layer_id_ = 0u; is_hit_mbox_ = false; type_ = ParamHit::Unknown; } ~ParamHitInfo() = default;void Clear() { clear_group_id(); clear_expt_id(); clear_launch_layer_id(); clear_is_hit_mbox(); clear_hash_key_used(); clear_hash_key_val_used(); clear_type(); param_.Clear(); }const ParamHit ToProtobuf() const { ParamHit ans; ans.set_expt_id(expt_id_); ans.set_group_id(group_id_); ans.set_launch_layer_id(launch_layer_id_); ans.set_is_hit_mbox(is_hit_mbox_); ans.set_hash_key_used(hash_key_used_); ans.set_hash_key_val_used(hash_key_val_used_); ans.set_type(type_); ans.mutable_param()->set_name(param_.name()); ans.mutable_param()->set_value(param_.value());return ans; }uint64_t group_id() const {return group_id_; }void set_group_id(const uint64_t group_id) { group_id_ = group_id; }void clear_group_id() { group_id_ = 0u; }uint64_t expt_id() const {return expt_id_; }void set_expt_id(const uint64_t expt_id) { expt_id_ = expt_id; }void clear_expt_id() { expt_id_ = 0u; }uint64_t launch_layer_id() const {return launch_layer_id_; }void set_launch_layer_id(const uint64_t launch_layer_id) { launch_layer_id_ = launch_layer_id; }void clear_launch_layer_id() { launch_layer_id_ = 0u; }bool is_hit_mbox() const {return is_hit_mbox_; }void set_is_hit_mbox(const bool is_hit_mbox) { is_hit_mbox_ = is_hit_mbox; }void clear_is_hit_mbox() { is_hit_mbox_ = false; }const std::string & hash_key_used() const {return hash_key_used_; }void set_hash_key_used(const std::string &hash_key_used) { hash_key_used_ = hash_key_used; }void clear_hash_key_used() { hash_key_used_.clear(); }const std::string & hash_key_val_used() const {return hash_key_val_used_; }void set_hash_key_val_used(const std::string &hash_key_val_used) { hash_key_val_used_ = hash_key_val_used; }void clear_hash_key_val_used() { hash_key_val_used_.clear(); }ParamHit_Type type() const {return type_; }void set_type(const ParamHit_Type type) { type_ = type; }void clear_type() { type_ = ParamHit::Unknown; }const Param & param() const {return param_; }Param * mutable_param() {return &param_; }std::string ShortDebugString() const {std::string ans = "type: " + std::to_string(type_); ans.append(", group_id: ").append(std::to_string(group_id_)); ans.append(", expt_id: ").append(std::to_string(expt_id_)); ans.append(", launch_layer_id: ").append(std::to_string(launch_layer_id_)); ans.append(", hash_key_used: ").append(hash_key_used_); ans.append(", hash_key_val_used: ").append(hash_key_val_used_); ans.append(", param_name: ").append(param_.name()); ans.append(", param_val: ").append(param_.value()); ans.append(", is_hit_mbox: ").append(std::to_string(is_hit_mbox_));return ans; }int ByteSize() {int ans = 0; ans += sizeof(uint64_t) * 3 + sizeof(bool) + sizeof(ParamHit_Type); ans += hash_key_used_.size() + hash_key_val_used_.size() + param_.name().size() + param_.value().size();return ans; }private: ParamHit_Type type_;uint64_t group_id_, expt_id_, launch_layer_id_;std::string hash_key_used_, hash_key_val_used_;bool is_hit_mbox_; Param param_;};

性能測試代碼:

TEST(ParamHitDestructorPerf, test) {vector<ParamHit> hits;vector<ParamHitInfo> hit_infos;const int hit_cnts = 1000;vector<pair<string, string>> params;for (int i=0; i<hit_cnts; ++i) {string name = "name: " + to_string(i);string val;int n = 200; val.resize(n);for (int i=0; i<n; ++i) val[i] = (i%10 + 'a'); params.push_back(make_pair(name, val)); }int uin_start = 12345645;for (int i=0; i<hit_cnts; ++i) { ParamHit hit; hit.set_expt_id(i + uin_start); hit.set_group_id(i + 1 + uin_start); hit.set_type(ParamHit::BaseAB); hit.set_is_hit_mbox(false); hit.set_hash_key_used("uin_bytes"); hit.set_hash_key_val_used(BusinessUtil::UInt64ToLittleEndianBytes(i));auto p = hit.mutable_param(); p->set_name(params[i].first); p->set_value(params[i].second); hits.emplace_back(std::move(hit)); }for (int i=0; i<hit_cnts; ++i) { ParamHitInfo hit; hit.set_expt_id(i + uin_start); hit.set_group_id(i + 1 + uin_start); hit.set_type(ParamHit::BaseAB); hit.set_is_hit_mbox(false); hit.set_hash_key_used("uin_bytes"); hit.set_hash_key_val_used(BusinessUtil::UInt64ToLittleEndianBytes(i));auto p = hit.mutable_param(); p->set_name(params[i].first); p->set_value(params[i].second); hit_infos.emplace_back(std::move(hit)); }int kRuns = 1000; chrono::high_resolution_clock::time_point t1 = chrono::high_resolution_clock::now(); {for (int i=0; i<kRuns; ++i) {for (auto &&hit: hits) {auto tmp = hit; } } } chrono::high_resolution_clock::time_point t2 = chrono::high_resolution_clock::now();auto time_span = chrono::duration_cast<chrono::milliseconds>(t2 - t1);std::cerr << "ParamHit_PB Destructor kRuns: " << kRuns << " hit_cnts: " << hit_cnts << " cost: " << time_span.count() << "ms\n"; t1 = chrono::high_resolution_clock::now(); {for (int i=0; i<kRuns; ++i) {for (auto &&hit: hit_infos) {auto tmp = hit; } } } t2 = chrono::high_resolution_clock::now(); time_span = chrono::duration_cast<chrono::milliseconds>(t2 - t1);std::cerr << "ParamHitInfo_Class Destructor kRuns: " << kRuns << " hit_cnts: " << hit_cnts << " cost: " << time_span.count() << "ms\n";}

性能對比結果:

可以看到使用 C++的 Class 相比于 ProtoBuf 可以提升3倍的性能。02使用 Cache Friendly 的數據結構

這裏想先抛出一個問題:使用哈希表的查找一定比使用數組的查找快嗎?

Q:理論上來說哈希表的查找複雜度是 O(1),數組的查找複雜度是 O(n),那麽是不是可以得到一個結論就是說哈希表的查找速度一定比數組快呢?

A:其實是不一定的,由于數組具有較高的緩存局部性,可提高 CPU 緩存的命中率,所以在有些場景下數組的查找效率要遠遠高于哈希表。

這裏給出一個常見操作耗時的數據(2020年):

下面也給出一個項目中的使用 Cache Friendly 優化的例子:

優化前的數據結構:

class HitContext {public:inline void update_hash_key(const std::string &key, const std::string &val) { hash_keys_[key] = val; }inline const std::string * search_hash_key(const std::string &key) const {auto it = hash_keys_.find(key);return it != hash_keys_.end() ? &(it->second) : ptr; }private: Context context_;std::unordered_map<std::string, std::string> hash_keys_;};

優化後的數據結構:

class HitContext {public:inline void update_hash_key(const std::string &key, const std::string &val) {if (Misc::IsSnsHashKey(key)) {auto sns_id = Misc::FastAtoi(key.c_str()+Misc::SnsHashKeyPrefix().size()); sns_hash_keys_.emplace_back(sns_id, Misc::LittleEndianBytesToUInt32(val));return; } hash_keys_[key] = val; }inline void update_hash_key(const std::string &key, const uint32_t val) {if (Misc::IsSnsHashKey(key)) {auto sns_id = Misc::FastAtoi(key.c_str()+Misc::SnsHashKeyPrefix().size()); sns_hash_keys_.emplace_back(sns_id, val);return; } hash_keys_[key] = Misc::UInt32ToLittleEndianBytes(val); }inline const std::string search_hash_key(const std::string &key, bool &find) const {if (Misc::IsSnsHashKey(key)) {auto sns_id = Misc::FastAtoi(key.c_str()+Misc::SnsHashKeyPrefix().size());auto it = std::find_if(sns_hash_keys_.rbegin(), sns_hash_keys_.rend(), [sns_id](const std::pair<uint32_t, uint32_t> &v) { return v.first == sns_id; }); find = it != sns_hash_keys_.rend();return find ? Misc::UInt32ToLittleEndianBytes(it->second) : ""; }auto it = hash_keys_.find(key); find = it != hash_keys_.end();return find ? it->second : ""; }private: Context context_;std::unordered_map<std::string, std::string> hash_keys_;std::vector<std::pair<uint32_t, uint32_t>> sns_hash_keys_;};

性能測試代碼:

TEST(HitContext, test) {const int keycnt = 264;std::vector<std::string> keys, vals;for (int j = 0; j < keycnt; ++j) {auto key = j+21324;auto val = j+94512454; keys.push_back("sns"+std::to_string(key)); vals.push_back(std::to_string(val)); }const int kRuns = 1000;std::unordered_map<uint32_t, uint64_t> hash_keys; chrono::high_resolution_clock::time_point t1 = chrono::high_resolution_clock::now();for (int i = 0; i < kRuns; ++i) { HitContext1 ctx;for (int j = 0; j < keycnt; ++j) { ctx.update_hash_key(keys[j], vals[j]); }for (int j=0; j<keycnt; ++j) {auto val = ctx.search_hash_key(keys[j]);if (!val) assert(0); } } chrono::high_resolution_clock::time_point t2 = chrono::high_resolution_clock::now();auto time_span = chrono::duration_cast<chrono::microseconds>(t2 - t1);std::cerr << "HashTable Hitcontext cost: " << time_span.count() << "us" << std::endl; hash_keys.clear(); t1 = chrono::high_resolution_clock::now();for (int i = 0; i < kRuns; ++i) { HitContext2 ctx;for (int j = 0; j < keycnt; ++j) { ctx.update_hash_key(keys[j], vals[j]); }for (int j=0; j<keycnt; ++j) {bool find = false;auto val = ctx.search_hash_key(keys[j], find);if (!find) assert(0); } } t2 = chrono::high_resolution_clock::now(); time_span = chrono::duration_cast<chrono::microseconds>(t2 - t1);std::cerr << "Vector HitContext cost: " << time_span.count() << "us" << std::endl;}

性能對比結果:

03使用 jemalloc/tcmalloc 代替普通的 malloc 方式

由于代碼中大量使用了 C++的 STL,所以會出現以下幾種缺點:

內存碎片:頻繁分配和釋放不同大小的對象,可能導致內存碎片,降低內存的使用效率。Cache 不友好:而且 STL 的普通內存分配器分散了對象的內存地址,降低了數據的緩存命中率。並發差:STL 的默認內存分配器可能使用全局鎖,相當于給加了一把大鎖,在多線程環境下性能表現很差。

目前在我們的代碼中加 jemalloc 還是很方便的,就是在所編譯的 target 中加下依賴就好了,比如:

cc_library(name = "mmexpt_dye_api",srcs = ["mmexpt_dye_api.cc",],hdrs = ["mmexpt_dye_api.h",],includes = ['.'],deps = ["//mm3rd/jemalloc:jemalloc",],copts = ["-O3","-std=c++11",],linkopts = [],visibility = ["//visibility:public"],)

使用 jemalloc 與不使用 jemalloc 前後性能對比(這裏的測試場景是在 loadbusiness 的時候,具體涉及到了一些業務代碼)

可以發現使用 jemalloc 可以提升20%多的性能,還是優化了很大的,很小的開發成本(只需要加一個編譯依賴)帶來不錯的收益。

04使用無鎖數據結構

在過去項目開發的時候使用過一種雙 buffer 的無鎖數據結構,之所以使用雙 buffer 是因爲 API 有大約 26億/s 的調用量,這麽高的調用量對性能的要求是很高的。數據結構的定義:

struct expt_api_new_shm {void *p_shm_data;// headervolatile int *p_mem_switch; // 0:uninit. 1:mem 1 on server. 2:mem 2 on serveruint32_t *p_crc_sum;// data expt_new_context* p_new_context; parameter2business* p_param2business;char* p_business_cache; HashTableWithCache hash_table; //多級哈希表};

常用的幾個函數:

int InitExptNewShmData(expt_api_new_shm *pstShmData, void *pData) {int ptr_offset = EXPT_NEW_SHM_HEADER_SIZE; pstShmData->p_shm_data = pData; pstShmData->p_mem_switch = MAKE_PTR(volatile int *, pData, 0); pstShmData->p_crc_sum = MAKE_PTR(uint32_t *, pData, 4); pstShmData->p_new_context = (expt_new_context *)((uint8_t *)pstShmData->p_shm_data + ptr_offset); pstShmData->p_param2business = (parameter2business *)((uint8_t *)pstShmData->p_shm_data + ptr_offset + EXPT_NEW_SHM_OFFSET_0); pstShmData->p_business_cache = (char *)((uint8_t *)pstShmData->p_shm_data + ptr_offset + EXPT_NEW_SHM_OFFSET_1);size_t node_size = sizeof(APICacheItem), row_cnt = sizeof(auModsInCache)/sizeof(size_t);size_t hash_tbl_size = CalHashTableWithCacheSize(node_size, row_cnt, auModsInCache); pstShmData->hash_table.pTable = (void *)((uint8_t *)pstShmData->p_shm_data + EXPT_NEW_SHM_SIZE - hash_tbl_size);int ret = HashTableWithCacheInit(&pstShmData->hash_table, hash_tbl_size, node_size, row_cnt, auModsInCache);return ret;}int ResetExptNewShmData(expt_api_new_shm *pstShmData) {int iOffset = 0;if (*pstShmData->p_mem_switch <= 1) { iOffset = 0; } else if (*pstShmData->p_mem_switch > 1) { iOffset = EXPT_NEW_SHM_DATA_SIZE; }void *ptrData = MAKE_PTR(void *, pstShmData->p_shm_data, EXPT_NEW_SHM_HEADER_SIZE + iOffset);memset(ptrData, 0, EXPT_NEW_SHM_DATA_SIZE);return 0;}int ResetExptNewShmHeader(expt_api_new_shm *pstShmData) {memset(pstShmData->p_shm_data, 0, EXPT_NEW_SHM_HEADER_SIZE);return 0;}void SwitchNewShmMemToWrite(expt_api_new_shm *pstShmData) {int iSwitchOffset = EXPT_NEW_SHM_DATA_SIZE * ((*pstShmData->p_mem_switch <= 1 ? 0 : 1));int ptr_offset = EXPT_NEW_SHM_HEADER_SIZE + iSwitchOffset; pstShmData->p_new_context = (expt_new_context *)((uint8_t *)pstShmData->p_shm_data + ptr_offset); pstShmData->p_param2business = (parameter2business *)((uint8_t *)pstShmData->p_shm_data + ptr_offset + EXPT_NEW_SHM_OFFSET_0); pstShmData->p_business_cache = (char *)((uint8_t *)pstShmData->p_shm_data + ptr_offset + EXPT_NEW_SHM_OFFSET_1);}void SwitchNewShmMemToWriteDone(expt_api_new_shm *pstShmData) {if (*pstShmData->p_mem_switch <= 1) *pstShmData->p_mem_switch = 2;else *pstShmData->p_mem_switch = 1;}void SwitchNewShmMemToRead(expt_api_new_shm *pstShmData) {int iSwitchOffset = EXPT_NEW_SHM_DATA_SIZE * ((*pstShmData->p_mem_switch <= 1 ? 1 : 0));int ptr_offset = EXPT_NEW_SHM_HEADER_SIZE + iSwitchOffset; pstShmData->p_new_context = (expt_new_context *)((uint8_t *)pstShmData->p_shm_data + ptr_offset); pstShmData->p_param2business = (parameter2business *)((uint8_t *)pstShmData->p_shm_data + ptr_offset + EXPT_NEW_SHM_OFFSET_0); pstShmData->p_business_cache = (char *)((uint8_t *)pstShmData->p_shm_data + ptr_offset + EXPT_NEW_SHM_OFFSET_1);}

雙 buffer 的工作原理就是:設置兩個 buffer,一個用于讀,另一個用于寫。

初始化這兩個 buffer 爲空,調用 InitExptNewShmData 函數。

對于寫操作,先准備數據,即調用 SwitchNewShmMemToWrite 函數,等數據准備完(即寫完相應的數據),然後調用 SwitchNewShmMemToWriteDone 函數,完成指針的切換。

對于讀操作,線程從讀 buffer 讀取數據,調用 SwitchNewShmMemToRead 函數。

我們平台的場景主要是讀,而且由于拉取實驗配置采用的都是增量的拉取方式,所以配置的改變也不是很頻繁,也就很少有寫操作的出現。采用雙 buffer 無鎖數據結構的優勢在于可以提高並發性能,由于讀寫操作在不同的 buffer 上同時進行,所以不需要額外加鎖,減少了數據競爭和鎖沖突的可能性。當然這種數據結構也有相應的缺點,就是會多用了一倍的內存,用空間換時間。

05對于特定的場景采用特定的處理方式

這其實也很容易理解,有很多場景是需要定制化優化的,所以不能從主體代碼的層面去優化了,那換個思路,是不是可以從返回的數據格式進行優化呢?舉個我們過去遇到的一個例子:我們平台有一個染色場景,就是需要對當天登錄的所有微信用戶計算命中情況,舊的數據格式其實返回了一堆本身染色場景不需要的字段,所以這裏其實是可以優化的。

優化前的數據格式:

struct expt_param_item {int experiment_id;int expt_group_id;int layer_id;int domain_id;uint32_t seq;uint32_t start_time;uint32_t end_time;uint8_t expt_type;uint16_t expt_client_expand;int parameter_id;uint8_t value[MAX_PARAMETER_VLEN];char param_name[MAX_PARAMETER_NLEN];int value_len;uint8_t is_pkg = 0;uint8_t is_white_list = 0;uint8_t is_launch = 0;uint64_t bucket_src = 0;uint8_t is_control = 0;};

其實染色場景下不需要參數的信息,只保留實驗 ID、組 ID 以及分桶的信息就好了。優化後的數據格式:

struct DyeHitInfo {int expt_id, group_id;uint64_t bucket_src; DyeHitInfo(){} DyeHitInfo(int expt_id_, int group_id_, uint64_t bucket_src_) :expt_id(expt_id_), group_id(group_id_), bucket_src(bucket_src_){}bool operator <(const DyeHitInfo &hit) const {if (expt_id == hit.expt_id) {if (group_id == hit.group_id) return bucket_src < hit.bucket_src;return group_id < hit.group_id; }return expt_id < hit.expt_id; }bool operator==(const DyeHitInfo &hit) {return hit.expt_id == expt_id && hit.group_id == group_id && hit.bucket_src == bucket_src; }std::string ToString() const {char buf[1024];sprintf(buf, "expt_id: %u, group_id: %u, bucket_src: %lu", expt_id, group_id, bucket_src);return std::string(buf); }};

優化前後性能對比:

所以其實針對某些特殊場景做一些定制化的開發成本也沒有很高,但是帶來的收益卻是巨大的。06善用性能測試工具

這裏列舉一些常見的性能測試工具:linux 提供的 perf、GNU 編譯器提供的 gprof、Valgrind、strace 等等。

這裏推薦幾個覺得好用的工具:

perf(linux 自帶性能測試工具)

https://godbolt.org/(可以查看代碼對應的彙編代碼)

微信運維提供的性能測試工具:

https://github.com/brendangregg/FlameGraph (生成火焰圖的工具)

07總結其實還有一些性能優化的地方,比如使用合適的數據結構和算法,減少大對象的拷貝,減少無效的計算,IO 與計算分離,分支預測等等,後續如果有時間的話可以再更新一篇新的文章。性能優化不是一錘子買賣,所以需要一直監控,一直優化。需要注意的一點是不要過度優化,在提升程序性能的時候不要丟掉代碼的可維護性,而且還要評估下性能提升帶來的收益是否與花費的時間成正比。總之,性能優化,長路漫漫。如果覺得本篇文章的內容對你有幫助,歡迎轉發分享。

-End-原創作者|張江濤

本文由高可用架構轉載。技術原創及架構實踐文章,歡迎通過公衆號菜單「聯系我們」進行投稿

0 阅读:0

架構互聯高可用

簡介:感謝大家的關注