C++服務編譯耗時優化原理及實踐(美團)


一、背景

大型C++工程項目,都會面臨編譯耗時較長的問題。不管是開發調試迭代、准入測試,亦或是持續集成階段,編譯行為無處不在,降低編譯時間對提高研發效率來說具有非常重要意義。

美團搜索與NLP部為公司提供基礎的搜索平台服務,出於性能的考慮,底層的基礎服務通過C++語言實現,其中我們負責的深度查詢理解服務(DeepQueryUnderstanding,下文簡稱DQU)也面臨着編譯耗時較長這個問題,整個服務代碼在優化前編譯時間需要二十分鍾左右(32核機器並行編譯),已經影響到了團隊開發迭代的效率。基於這樣的背景,我們針對DQU服務的編譯問題進行了專項優化。在這個過程中,我們也積累了一些優化的知識和經驗,在這里分享給大家。

二、編譯原理及分析

2.1 編譯原理介紹

為了更好地理解編譯優化方案,在介紹優化方案之前,我們先簡單介紹一下編譯原理,通常我們在進行C++開發時,編譯的過程主要包含下面四個步驟:

預處理器:宏定義替換,頭文件展開,條件編譯展開,刪除注釋。

  • gcc -E選項可以得到預處理后的結果,擴展名為.i 或 .ii。
  • C/C++預處理不做任何語法檢查,不僅是因為它不具備語法檢查功能,也因為預處理命令不屬於C/C++語句(這也是定義宏時不要加分號的原因),語法檢查是編譯器要做的事情。
  • 預處理之后,得到的僅僅是真正的源代碼。

編譯器:生成匯編代碼,得到匯編語言程序(把高級語言翻譯為機器語言),該種語言程序中的每條語句都以一種標准的文本格式確切的描述了一條低級機器語言指令。

  • gcc -S選項可以得到編譯后的匯編代碼文件,擴展名為.s。
  • 匯編語言為不同高級語言的不同編譯器提供了通用的輸出語言。

匯編器:生成目標文件。

  • gcc -c選項可以得到匯編后的結果文件,擴展名為.o。
  • .o文件,是按照的二進制編碼方式生成的文件。

鏈接器:生成可執行文件或庫文件。

  • 靜態庫:指編譯鏈接時,把庫文件的代碼全部加入到可執行文件中,因此生成的文件比較大,但在運行時也就不再需要庫文件了,其后綴名一般為“.a”。
  • 動態庫:在編譯鏈接時並沒有把庫文件的代碼加入到可執行文件中,而是在程序執行時由運行時鏈接文件加載庫,這樣可執行文件比較小,動態庫一般后綴名為“.so”。
  • 可執行文件:將所有的二進制文件鏈接起來融合成一個可執行程序,不管這些文件是目標二進制文件還是庫二進制文件。

2.2 C++編譯特點

(1)每個源文件獨立編譯

C/C++的編譯系統和其他高級語言存在很大的差異,其他高級語言中,編譯單元是整個Module,即Module下所有源碼,會在同一個編譯任務中執行。而在C/C++中,編譯單元是以文件為單位。每個.c/.cc/.cxx/.cpp源文件是一個獨立的編譯單元,導致編譯優化時只能基於本文件內容進行優化,很難跨編譯單元提供代碼優化。

(2)每個編譯單元,都需要獨立解析所有包含的頭文件

如果N個源文件引用到了同一個頭文件,則這個頭文件需要解析N次(對於Thrift文件或者Boost頭文件這類動輒幾千上萬行的頭文件來說,簡直就是“鬼故事”)。

如果頭文件中有模板(STL/Boost),則該模板在每個cpp文件中使用時都會做一次實例化,N個源文件中的std::vector<int>會實例化N次。

(3)模板函數實例化

在C++ 98語言標准中,對於源代碼中出現的每一處模板實例化,編譯器都需要去做實例化的工作;而在鏈接時,鏈接器還需要移除重復的實例化代碼。顯然編譯器遇到一個模板定義時,每次都去進行重復的實例化工作,進行重復的編譯工作。此時,如果能夠讓編譯器避免此類重復的實例化工作,那么可以大大提高編譯器的工作效率。在C++ 0x標准中一個新的語言特性 -- 外部模板的引入解決了這個問題。

在C++ 98中,已經有一個叫做顯式實例化(Explicit Instantiation)的語言特性,它的目的是指示編譯器立即進行模板實例化操作(即強制實例化)。而外部模板語法就是在顯式實例化指令的語法基礎上進行修改得到的,通過在顯式實例化指令前添加前綴extern,從而得到外部模板的語法。

① 顯式實例化語法:template class vector<MyClass>。 ② 外部模板語法:extern template class vector<MyClass>。

一旦在一個編譯單元中使用了外部模板聲明,那么編譯器在編譯該編譯單元時,會跳過與該外部模板聲明匹配的模板實例化。

(4)虛函數

編譯器處理虛函數的方法是:給每個對象添加一個指針,存放了指向虛函數表的地址,虛函數表存儲了該類(包括繼承自基類)的虛函數地址。如果派生類重寫了虛函數的新定義,該虛函數表將保存新函數的地址,如果派生類沒有重新定義虛函數,該虛函數表將保存函數原始版本的地址。如果派生類定義了新的虛函數,則該函數的地址將被添加到虛函數表中。

調用虛函數時,程序將查看存儲在對象中的虛函數表地址,轉向相應的虛函數表,使用類聲明中定義的第幾個虛函數,程序就使用數組的第幾個函數地址,並執行該函數。

使用虛函數后的變化:

① 對象將增加一個存儲地址的空間(32位系統為4字節,64位為8字節)。 ② 每個類編譯器都創建一個虛函數地址表。 ③ 對每個函數調用都需要增加在表中查找地址的操作。

(5)編譯優化

GCC提供了為了滿足用戶不同程度的的優化需要,提供了近百種優化選項,用來對編譯時間,目標文件長度,執行效率這個三維模型進行不同的取舍和平衡。優化的方法不一而足,總體上將有以下幾類:

① 精簡操作指令。 ② 盡量滿足CPU的流水操作。 ③ 通過對程序行為地猜測,重新調整代碼的執行順序。 ④ 充分使用寄存器。 ⑤ 對簡單的調用進行展開等等。

如果全部了解這些編譯選項,對代碼針對性的優化還是一項復雜的工作,幸運的是GCC提供了從O0-O3以及Os這幾種不同的優化級別供大家選擇,在這些選項中,包含了大部分有效的編譯優化選項,並且可以在這個基礎上,對某些選項進行屏蔽或添加,從而大大降低了使用的難度。

  • O0:不做任何優化,這是默認的編譯選項。
  • O和O1:對程序做部分編譯優化,編譯器會嘗試減小生成代碼的尺寸,以及縮短執行時間,但並不執行需要占用大量編譯時間的優化。
  • O2:是比O1更高級的選項,進行更多的優化。GCC將執行幾乎所有的不包含時間和空間折中的優化。當設置O2選項時,編譯器並不進行循環展開以及函數內聯優化。與O1比較而言,O2優化增加了編譯時間的基礎上,提高了生成代碼的執行效率。
  • O3:在O2的基礎上進行更多的優化,例如使用偽寄存器網絡,普通函數的內聯,以及針對循環的更多優化。
  • Os:主要是對代碼大小的優化, 通常各種優化都會打亂程序的結構,讓調試工作變得無從着手。並且會打亂執行順序,依賴內存操作順序的程序需要做相關處理才能確保程序的正確性。

編譯優化有可能帶來的問題:

① 調試問題:正如上面所提到的,任何級別的優化都將帶來代碼結構的改變。例如:對分支的合並和消除,對公用子表達式的消除,對循環內load/store操作的替換和更改等,都將會使目標代碼的執行順序變得面目全非,導致調試信息嚴重不足。

② 內存操作順序改變問題:在O2優化后,編譯器會對影響內存操作的執行順序。例如:-fschedule-insns允許數據處理時先完成其他的指令;-fforce-mem有可能導致內存與寄存器之間的數據產生類似臟數據的不一致等。對於某些依賴內存操作順序而進行的邏輯,需要做嚴格的處理后才能進行優化。例如,采用Volatile關鍵字限制變量的操作方式,或者利用Barrier迫使CPU嚴格按照指令序執行。

(6)C/C++ 跨編譯單元的優化只能交給鏈接器

當鏈接器進行鏈接的時候,首先決定各個目標文件在最終可執行文件里的位置。然后訪問所有目標文件的地址重定義表,對其中記錄的地址進行重定向(加上一個偏移量,即該編譯單元在可執行文件上的起始地址)。然后遍歷所有目標文件的未解決符號表,並且在所有的導出符號表里查找匹配的符號,並在未解決符號表中所記錄的位置上填寫實現地址,最后把所有的目標文件的內容寫在各自的位置上,就生成一個可執行文件。鏈接的細節比較復雜,鏈接階段是單進程,無法並行加速,導致大項目鏈接極慢。

三、服務問題分析

DQU是美團搜索使用的查詢理解平台,內部包含了大量的模型、詞表、在代碼結構上,包含20多個Thrift文件 ,使用大量Boost處理函數 ,同時引入了SF框架,公司第三方組件SDK以及分詞三個Submodule,各個模塊采用動態庫編譯加載的方式,模塊之間通過消息總線做數據的傳輸,消息總線是一個大的Event類,這樣這個類就包含了各個模塊需要的數據類型的定義,所以各個模塊都會引入Event頭文件,不合理的依賴關系造成這個文件被改動,幾乎所有的模塊都會重新編譯。

每個服務所面臨的編譯問題都有各自的特點,但是遇到問題的本質原因是類似的,結合編譯的過程和原理,我們從預編譯展開、頭文件依賴以及編譯過程耗時3個方面對DQU服務編譯問題進行了分析。

3.1 編譯展開分析

編譯展開分析就是通過C++的預編譯階段保留的.ii文件,查看通過展開后的編譯文件大小,具體可以通過在cmake中指定編譯選型 “-save-temps” 保留編譯中間文件。

set(CMAKE_CXX_FLAGS "-std=c++11 ${CMAKE_CXX_FLAGS} -ggdb -Og -fPIC -w -Wl,--export-dynamic -Wno-deprecated -fpermissive -save-temps") 

編譯耗時的最直接原因就是編譯文件展開之后比較大,通過編譯展開后的文件大小和內容,通過預編譯展開分析能看到文件展開后的文件有40多萬行,發現有大量的Boost庫引用及頭文件引用造成的展開文件比較大,影響到編譯的耗時。通過這個方式能夠找到各個文件編譯耗時的共性,下圖是編譯展開后文件大小截圖。

3.2 頭文件依賴分析

頭文件依賴分析是從引用頭文件數量的角度來看代碼是否合理的一種分析方式,我們實現了一個腳本,用來統計頭文件的依賴關系,並且分析輸出頭文件依賴引用計數,用來輔助判斷頭文件依賴關系是否合理。

(1) 頭文件引用總數結果統計

通過工具統計出編譯源文件直接和間接依賴的頭文件的總個數,用來從頭文件引入數量上分析問題。

(2) 單個頭文件依賴關系統計

通過工具分析頭文件依賴關系,生成依賴關系拓撲圖,能夠直觀的看到依賴不合理的地方。

圖中包含引用層次關系,以及引用頭文件個數。

3.3 編譯耗時結果分段統計

編譯耗時分段統計是從結果上看各個文件的編譯耗時以及各個編譯階段的耗時情況,這個是直觀的一個結果,正常情況下,是和文件展開大小以及頭文件引用個數是正相關的,cmake通過指定環境變量能打印出編譯和鏈接階段的耗時情況,通過這個數據能直觀的分析出耗時情況。

set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CMAKE_COMMAND} -E time") set_property(GLOBAL PROPERTY RULE_LAUNCH_LINK "${CMAKE_COMMAND} -E time") 

編譯耗時結果輸出:

3.4 分析工具建設

通過上面的工具分析能拿到幾個編譯數據:

① 頭文件依賴關系及個數。 ② 預編譯展開大小及內容。 ③ 各個文件編譯耗時。 ④ 整體鏈接耗時。 ⑤ 可以計算出編譯並行度。

通過這幾個數據的輸入我們考慮可以做個自動化分析工具,找出優化點以及界面化展示。基於這個目的,我們建設了全流程自動化分析工具,能夠自動分析耗時共性問題以及TopN耗時文件。分析工具處理流程如下圖所示:

(1) 整體統計分析效果

具體字段說明:

① cost_time 編譯耗時,單位是秒。 ② file_compile_size,編譯中間文件大小,單位是M。 ③ file_name,文件名稱。 ④ include_h_nums,引入頭文件個數,單位是個。 ⑤ top_h_files_info, 引入最多的TopN頭文件。

(2)Top10 編譯耗時文件統計

用來展示統計編譯耗時最久的TopN文件,N可以自定義指定。

(3)Top10編譯中間文件大小統計

通過統計和展示編譯文件大小,用來判斷這塊是否符合預期,這個是和編譯耗時一一對應的。

(4)Top10引入最多頭文件的頭文件統計

(5)Top10頭文件重復次數統計

目前,這個工具支持一鍵化生成編譯耗時分析結果,其中幾個小工具,比如依賴文件個數工具已經集成到公司的上線集成測試流程中,通過自動化工具檢查代碼改動對編譯耗時的影響,工具的建設還在不斷迭代優化中,后續會集成到公司的MCD平台中,可以自動分析來定位編譯耗時長的問題,解決其它部門編譯耗時問題。

四、優化方案與實踐

通過運用上述相關工具,我們能夠發現Top10編譯耗時文件的共性,比如都依賴消息總線文件platform_query_analysis_enent.h,這個文件又直接間接引入2000多個頭文件,我們重點優化了這類文件,通過工具的編譯展開,找出了Boost使用、模板類展開、Thrift頭文件展開等共性問題,並針對這些問題做專門的優化。此外,我們也使用了一些業內通用的編譯優化方案,並取得了不錯的效果。下面詳細介紹我們采用的各種優化方案。

4.1 通用編譯加速方案

業內有不少通用編譯加速工具(方案),無需侵入代碼就能提高編譯速度,非常值得嘗試。

(1)並行編譯

在Linux平台上一般使用GNU的Make工具進行編譯,在執行make命令時可以加上-j參數增加編譯並行度,如make -j 4將開啟4個任務。在實踐中我們並不將該參數寫死,而是通過$(nproc)方法動態獲取編譯機的CPU核數作為編譯並發度,從而最大限度利用多核的性能優勢。

(2)分布式編譯

使用分布式編譯技術,比如利用Distcc和Dmucs構建大規模、分布式C++編譯環境,Linux平台利用網絡集群進行分布式編譯,需要考慮網絡時延與網絡穩定性。分布式編譯適合規模較大的項目,比如單機編譯需要數小時甚至數天。DQU服務從代碼規模以及單機編譯時長來說,暫時還不需要使用分布式的方式來加速,具體細節可以參考Distcc官方文檔說明。

(3)預編譯頭文件

PCH(Precompiled Header),該方法預先將常用頭文件的編譯結果保存起來,這樣編譯器在處理對應的頭文件引入時可以直接使用預先編譯好的結果,從而加快整個編譯流程。PCH是業內十分常用的加速編譯的方法,且大家反饋效果非常不錯。在我們的項目中,由於涉及到很多Shared Library的編譯生成,而Shared Library相互之間無法共享PCH,因此沒有取得預想效果。

(4)CCache

CCache(Compiler Cache是一個編譯緩存工具,其原理是將cpp的編譯結果保存在文件緩存中,以后編譯時若對應文件無變動可直接從緩存中獲取編譯結果。需要注意的是,Make本身也有一定緩存功能,當目標文件已編譯(且依賴無變化)時,若源文件時間戳無變化也不會再次編譯;但CCache是按文件內容做的緩存,且同一機器的多個項目可以共享緩存,因此適用面更大。

(5)Module編譯

如果你的項目是用C++ 20進行開發的,那么恭喜你,Module編譯也是一個優化編譯速度的方案,C++20之前的版本會把每一個cpp當做一個編譯單元處理,會存在引入的頭文件被多次解析編譯的問題。而Module的出現就是解決這一問題,Module不再需要頭文件(只需要一個模塊文件,不需要聲明和實現兩個文件),它會將你的(.ixx 或者 .cppm)模塊實體直接編譯,並自動生成一個二進制接口文件。import和include預處理不同,編譯好的模塊下次import的時候不會重復編譯,可以大幅度提高編譯器的效率。

(6)自動依賴分析

Google也推出了開源的Include-What-You-Use工具(簡稱IWYU),基於Clang的C/C++工程冗余頭文件檢查工具。IWYU依賴Clang編譯套件,使用該工具可以掃描出文件依賴問題,同時該工具還提供腳本解決頭文件依賴問題,我們嘗試搭建了這套分析工具,這個工具也提供自動化頭文件解決方案,但是由於我們的代碼依賴比較復雜,有動態庫、靜態庫、子倉庫等,這個工具提供的優化功能不能直接使用,其它團隊如果代碼結構比較簡單的話,可以考慮使用這個工具分析優化,會生成如下結果文件,指導哪些頭文件需要刪除。

>>> Fixing #includes in '/opt/meituan/zhoulei/query_analysis/src/common/qa/record/brand_record.h'
@@ -1,9 +1,10 @@ ​ #ifndef _MTINTENTION_DATA_BRAND_RECORD_H_ #define _MTINTENTION_DATA_BRAND_RECORD_H_ -#include "qa/data/record.h" -#include "qa/data/template_map.hpp" -#include "qa/data/template_vector.hpp" -#include <boost/serialization/version.hpp> +#include <boost/serialization/version.hpp> // for BOOST_CLASS_VERSION +#include <string> // for string +#include <vector> // for vector + +#include "qa/data/file_buffer.h" // for REG_TEMPLATE_FILE_HANDLER ​ 

4.2 代碼優化方案與實踐

(1)前置類型聲明

通過分析頭文件引用統計,我們發現項目中被引用最多的是總線類型Event,而該類型中又放置了各種業務需要的成員,示例如下:

#include “a.h” #include "b.h" class Event { // 業務A, B, C ... A1 a1; A2 a2; // ... B1 b1; B2 b2; // ... }; 

這導致Event中包含了數量龐大的頭文件,在頭文件展開后,文件大小達到15M;而各種業務都會需要使用Event,自然會嚴重拖累編譯性能。

我們通過前置類型聲明來解決這個問題,即不引入對應類型的頭文件,只做前置聲明,在Event中只使用對應類型的指針,如下所示:

class A2; // ... class Event { // 業務A, B, C ... shared_ptr<A1> a1; shared_ptr<A2> a2; // ... shared_ptr<B1> b1; shared_ptr<B2> b2; // ... }; 

只有在真正使用對應成員變量時,才需要引入對應頭文件;這樣真正做到了按需引入頭文件。

(2)外部模板

由於模板被使用時才會實例化這一特性,相同的實例可以出現在多個文件對象中。編譯器要對每一處模板進行實例化,鏈接器還要移除重復的實例化代碼。當在廣泛使用模板的項目中,編譯器會產生大量的冗余代碼,這會極大地增加編譯時間和鏈接時間。C++ 11新標准中可以通過外部模板來避免。

// util.h template <typename T> void max(T) { ... } 
// A.cpp extern template void max<int>(int); #include "util.h" template void max<int>(int); // 顯式地實例化 void test1() { max(1); } 

​在編譯A.cpp的時候,實例化出一個 max<int>(int)版本的函數。

// B.cpp #include "util.h" extern template void max<int>(int); // 外部模板的聲明 void test2() { max(2); } 

在編譯B.cpp的時候,就不再生成 max<int>(int)實例化代碼,這樣就節省了前面提到的實例化,編譯以及鏈接的耗時了。

(3)多態替換模板使用

我們的項目重度使用詞典相關操作,如加載詞典、解析詞典、匹配詞典(各種花式匹配),這些操作都是通過Template模板擴展支持各種不同類型的詞典。據統計,詞典的類型超過150個,這也造成模板展開的代碼量膨脹。

template <class R> class Dict { public: // 匹配key和condition,賦值給record bool match(const string &key, const string &condition, R &record); // 對每種類型的Record都會展開一次 private: map<string, R> dict; }; 

​幸運的是,我們詞典的絕大部分操作都可以抽象出幾類接口,因此可以只實現針對基類的操作:

class Record { // 基類 public: virtual bool match(const string &condition); // 派生類需實現 }; ​ class Dict { public: shared_ptr<Record> match(const string &key, const string &condition); // 使用方傳入派生類的指針即可 private: map<string, shared_ptr<Record>> dict; }; 

​通過繼承和多態,我們有效避免了大量的模板展開。需要注意的是,使用指針作為Map的Value會增加內存分配的壓力,推薦使用Tcmalloc或Jemalloc替換默認的Ptmalloc優化內存分配。

(4)替換Boost庫

Boost是一個廣泛使用的基礎庫,涵蓋了大量常用函數,十分方便、好用,然而也存在一些不足之處。一個顯著缺點是其實現采用了hpp的形式,即聲明和實現均放在頭文件中,這會造成預編譯展開后十分巨大。

// 字符串操作是常用功能,僅僅引入該頭文件展開大小就超過4M #include <boost/algorithm/string.hpp> // 與此相對的,引入多個STL的頭文件,展開后僅僅只有1M #include <vector> #include <map> // ... 

在我們項目中主要使用的Boost函數不超過二十個,部分可以在STL中找到替代,部分我們手動做了實現,使得項目從重度依賴Boost轉變成絕大部分達到Boost-Free,大大降低了編譯的負擔。

(5)預編譯

代碼中有一些平常改動比較少,但是對編譯耗時產生一定的影響,比如Thrift生成的文件,模型庫文件以及Common目錄下的通用文件,我們采取提起預編譯成動態庫,減少后續文件的編譯耗時,也解決了部分編譯依賴。

(6)解決編譯依賴,提高編譯並行度

在我們項目中有大量模塊級別的動態庫文件需要編譯,cmake文件指定的編譯依賴關系在一定程度上限制了編譯並行度的執行。

比如下面這個場景,通過合理設置庫文件依賴關系,可以提高編譯並行度。

4.3 優化效果

我們通過32C、64G內存機器做了編譯耗時優化前后的效果對比,統計結果如下:

4.4 守住優化成果

編譯優化是一件“逆水行舟”的事情,開發人員總是傾向於不斷增加新的功能、新的庫乃至新的框架,而要刪除舊代碼、舊庫、下線舊框架總是困難重重(相信一線開發人員一定深有體會)。因此,如何守住之前取得的優化成果也是至關重要的。我們在實踐中有以下幾點體會:

  • 代碼審核是困難的(引起編譯耗時增加的改動,往往無法通過審核代碼直觀地發現)。
  • 工具、流程才值得依賴。
  • 關鍵在於控制增量。

我們發現,cpp文件的編譯耗時,和其預編譯展開文件(.ii)大小呈正相關(絕大部分情況下);對每一個上線版本,將其所有cpp文件的預編譯展開大小記錄下來,就形成了其編譯指紋(CF,Compile Fingerprint)。通過比較相鄰兩個版本的CF,就能較准確的知道新版帶來的編譯耗時主要由哪些改動引入,並可以進一步分析耗時上漲是否合理,是否有優化空間。

我們將該種方式制作成腳本工具並引入上線流程,從而能夠很清楚的了解每次代碼發版帶來的編譯性能影響,並有效地幫助我們守住前期的優化成果。

五、總結

DQU項目是美團搜索業務環節中重要的一環,該系統需要對接20+RPC、數十個模型、加載超過300個詞典,使用內存數十G,日均響應請求超過20億的大型C++服務。在業務高速迭代的情況,冗長的編譯時間為開發同學帶來較大的困擾,一定程度上制約了開發效率。最終我們通過編譯優化分析工具建設,結合采用了通用編譯優化加速方案和代碼層面的優化,將DQU的編譯時間縮短了70%,並通過引CCache等手段,使得本地開發的編譯,能夠在100s內完成,給開發團隊節省了大量的時間。

在取得階段性成果之后,我們總結整個問題解決的過程,並沉淀出一些分析方法、工具以及流程規范。這些工具在后續的開發迭代過程中,能夠快速有效地檢測新的代碼變更帶來的編譯時間變化,並成為了我們的上線流程檢查中的一環檢測標准。這一點與我們以往一次性的或者針對性的編譯優化,產生了很大的區別。畢竟代碼的維護是一個持久的過程,系統化的解決這一問題,不只是需要有效的方法和便捷的工具,更需要一個標准化的,規范化的上線流程來保持成果。希望本文對大家能有所幫助。

參考文獻

作者簡介

本文作者周磊、識瀚、朱超、王鑫、劉亮、昌術、李超、雲森、永超等,均來自美團AI平台搜索與NLP部。

| 想閱讀更多技術文章,請關注美團技術團隊(meituantech)官方微信公眾號。

| 在公眾號菜單欄回復【2019年貨】、【2018年貨】、【2017年貨】、【算法】等關鍵詞,可查看美團技術團隊歷年技術文章合集。

https://my.oschina.net/meituantech/blog/4792764


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM