從CPU冒煙到絲滑體驗(yàn):算法SRE性能優(yōu)化實(shí)戰(zhàn)全揭秘
一、引言
在算法工程中,大家一般關(guān)注四大核心維度:穩(wěn)定、成本、效果、性能。
其中,性能尤為關(guān)鍵——它既能提升系統(tǒng)穩(wěn)定性,又能降低成本、優(yōu)化效果。因此,工程團(tuán)隊(duì)將微秒級(jí)的性能優(yōu)化作為核心攻堅(jiān)方向。
本文將結(jié)合具體案例,分享算法SRE在日常性能優(yōu)化中的寶貴經(jīng)驗(yàn),助力更多同學(xué)在實(shí)踐中優(yōu)化系統(tǒng)性能、實(shí)現(xiàn)業(yè)務(wù)價(jià)值最大化。
二、給浮點(diǎn)轉(zhuǎn)換降溫
算法工程的核心是排序,而排序離不開(kāi)特征。特征大多是浮點(diǎn)數(shù),必然伴隨頻繁的數(shù)值轉(zhuǎn)換。零星轉(zhuǎn)換對(duì)CPU無(wú)足輕重,可一旦規(guī)模如洪水傾瀉,便會(huì)出現(xiàn)CPU瞬間飆紅、性能斷崖式下跌的情況,導(dǎo)致被迫堆硬件,白白抬高成本開(kāi)銷(xiāo)。
例如:《交易商詳頁(yè)相關(guān)推薦 - neuron-csprd-r-tr-rel-cvr-v20-s6》 特征處理占用CPU算力時(shí)間的61%。其中大量工作都在做Double浮點(diǎn)轉(zhuǎn)換,如圖所示:
圖片
優(yōu)化前CPU時(shí)間占比 18%
Double.parseDouble、Double.toString是JDK原生原子API了,還能優(yōu)化?直接給答案:能!
浮點(diǎn)轉(zhuǎn)字符串:Ryu算法
https://github.com/ulfjack/ryu
Ryu算法,用“查表+定長(zhǎng)整數(shù)運(yùn)算”徹底摒棄“動(dòng)態(tài)多精度運(yùn)算+內(nèi)存管理”的重開(kāi)銷(xiāo),既正確又高效。
算法的完整正確性證明:https://dl.acm.org/citation.cfm? doid=3296979.3192369
偽代碼說(shuō)明
// ——“普通”浮點(diǎn)到字符串(高成本)——
void convertStandard(double d, char *out) {
// 1. 拆分浮點(diǎn):符號(hào)、指數(shù)、尾數(shù)
bool sign = (d < 0);
int exp = extractExponent(d); // 提取二進(jìn)制指數(shù)
uint64_t mant = extractMantissa(d);
// 2. 構(gòu)造大整數(shù):mant × 2^exp —— 可能要擴(kuò)容內(nèi)存
BigInt num = BigInt_from_uint64(mant);
num = BigInt_mul_pow2(num, exp); // 多精度移位,高開(kāi)銷(xiāo)
// 3. 逐位除以 10 生成十進(jìn)制,每次都是多精度除法
// ——每次 divMod 都要循環(huán)內(nèi)部分配和多精度運(yùn)算
char buf[32];
int len = 0;
while (!BigInt_is_zero(num)) {
BigInt digit, rem;
BigInt_divmod(num, 10, &digit, &rem); // 慢:多精度除法
buf[len++] = '0' + BigInt_to_uint32(digit);
BigInt_free(num);
num = rem;
}
// 4. 去除多余零、插入小數(shù)點(diǎn)和符號(hào)
formatOutput(sign, buf, len, out);
}
// ——Ryu 方法(低成本)——
void convertRyu(double d, char *out) {
// 1. 拆分浮點(diǎn):符號(hào)、真實(shí)指數(shù)、尾數(shù)(隱含1)
bool sign = (d < 0);
int e2 = extractBiasedExponent(d) - BIAS;
uint64_t m2 = extractMantissa(d) | IMPLIED_ONE;
// 2. 一次查表:獲得 5^k 和對(duì)應(yīng)位移量
// ——預(yù)先計(jì)算好,運(yùn)行時(shí)無(wú)動(dòng)態(tài)開(kāi)銷(xiāo)
int k = computeDecimalExponent(e2);
uint64_t pow5 = POW5_TABLE[k]; // 只讀數(shù)組(cache 友好)
int shift = SHIFT_TABLE[k];
// 3. 單次 64×64 位乘法 + 右移 —— 固定時(shí)間
__uint128_t prod = ( __uint128_t )m2 * pow5;
uint64_t v = (uint64_t)(prod >> shift);
// 4. 固定最多 ~20 次小循環(huán),v%10 生成每位數(shù)字
// ——循環(huán)次數(shù)上限,與具體數(shù)值無(wú)關(guān)
char buf[24];
int len = 0;
do {
buf[len++] = '0' + (v % 10);
v /= 10;
} while (v);
// 5. 去零、插小數(shù)點(diǎn)、加符號(hào):輕量字符串操作
formatShort(sign, buf, len, k, out);
}
傳統(tǒng)方法 vs. Ryu算法對(duì)比:
算法比較 | “普通”算法 | Ryu算法 |
內(nèi)存分配 | BigInt動(dòng)態(tài)擴(kuò)容 + 釋放 →heap分配/回收成本高 | 全/靜態(tài)表 + 棧數(shù)組,無(wú)malloc→ 零動(dòng)態(tài)分配 |
算術(shù)成本 | 頻繁多精度除法 (數(shù)百納秒) | 單次64位乘法+位移 (約30-40納秒) |
循環(huán)次數(shù) | 取決于浮點(diǎn)數(shù)數(shù)值 難以預(yù)測(cè) | 固定次數(shù) 易于優(yōu)化和預(yù)測(cè) |
緩存友好 | 內(nèi)存分散 不利CPU緩存 | 棧上集中 CPU緩存友好 |
字符串轉(zhuǎn)浮點(diǎn):Fast_Float算法
https://github.com/wrandelshofer/FastDoubleParser
相比Java自帶的Double.parseDouble使用復(fù)雜狀態(tài)機(jī)(如BigDecimal或 BigInteger)來(lái)處理各種情況,F(xiàn)astDoubleParser使用以下優(yōu)化策略。
FastDoubleParser 優(yōu)化策略
※ 分離階段
- 將輸入拆分為三個(gè)部分:significand、exponent、special cases(如 NaN, Infinity)。
- 解析時(shí)直接處理整數(shù)位和小數(shù)位的組合。
※ 整型加速 + 倍數(shù)轉(zhuǎn)換
- 在范圍允許的情況下使用“64位整數(shù)直接表示”有效位。
- 再通過(guò)預(yù)計(jì)算的“冪次表(10? 或 2?)”進(jìn)行快速縮放,避免慢速浮點(diǎn)乘法。
※ 避免慢路徑
- 避免使用BigDecimal或字符串轉(zhuǎn)高精度,再轉(zhuǎn)回double的慢路徑。
- 對(duì)于大多數(shù)輸入,整個(gè)解析過(guò)程不涉及任何內(nèi)存分配。
※ SIMD加速(原版 C++)
在C++中使用SIMD指令批量處理字符,Java版受限于JVM,但仍通過(guò)循環(huán)展開(kāi)等技術(shù)盡量進(jìn)行優(yōu)化。
轉(zhuǎn)換思路
Input: "123.45e2"
1. 拆分成:
significand = 12345 (去掉小數(shù)點(diǎn))
exponent = 2 - 2 = 0 // 小數(shù)點(diǎn)后兩位,但有 e2
2. 快速轉(zhuǎn)換:
result = 12345 * 10^0 = 12345.0
3. 最終使用 Double.longBitsToDouble 構(gòu)造結(jié)果
壓測(cè)報(bào)告
Double 字符解析相對(duì)JDK原生API 4.43倍 加速
代碼優(yōu)化樣例
通過(guò)多層判斷,盡可能不讓Object o做toString()操作。
減少toString觸發(fā)的可能
工具類(lèi) 替換浮點(diǎn)轉(zhuǎn)換算法
性能實(shí)測(cè)效果
啟用Ryu、Fast_Float算法替換JDK原生浮點(diǎn)轉(zhuǎn)換,效果如下:
優(yōu)化后CPU時(shí)間占比 0.19%【性能提升(18-0.19)/18=98%】
CPU實(shí)際獲得50%收益
RT實(shí)際獲得25%左右性能收益
小結(jié)
告別原生JDK浮點(diǎn)轉(zhuǎn)換的高昂代價(jià),擁抱Ryu與FastDoubleParser,讓CPU從繁忙到清閑,性能“回血”,節(jié)約的成本大家可以吃火鍋。
三、拔掉詭異的GC毛刺
小堆GC問(wèn)題
特征維度多時(shí)內(nèi)存壓力大,GC問(wèn)題可以預(yù)期。但很多同學(xué)可能沒(méi)有見(jiàn)過(guò),小堆場(chǎng)景,GC也可能頻繁觸發(fā),甚至引發(fā)異常。
如圖所示:18GB堆 擴(kuò)容 -> 30GB堆,均出現(xiàn)RT99周期脈沖,致使5~6%的失敗率。
社區(qū)瀑布流廣告投放-Neuron精排 因GC導(dǎo)致錯(cuò)誤
GC問(wèn)題分析
首先這是GC問(wèn)題,其次增加了近1倍的內(nèi)存,沒(méi)有絲毫緩解,判斷這應(yīng)該是個(gè)偽GC問(wèn)題。
Neuron主要功能就是拿著特征轉(zhuǎn)向量做排序。一般特征量都是億起步,多的達(dá)十億,因此特征緩存必不可少。但是這個(gè)場(chǎng)景,僅僅是將1700個(gè)左右的廣告特征信息進(jìn)行了緩存,為什么對(duì)象內(nèi)存會(huì)出現(xiàn)周期性的脈沖?
年輕代+老年代 周期共振脈沖
如圖所示,關(guān)鍵的問(wèn)題在于“共振”。因此要用放大鏡看問(wèn)題,再如圖所示:
共振點(diǎn) 放大
共振點(diǎn)CPU峰值水位:28%
GC 暫停時(shí)間
線索 | 矛盾點(diǎn) | 疑惑點(diǎn) |
老年代回收 3GB | 老年代3GB回收,對(duì)于C4垃圾回收器,應(yīng)該毫無(wú)壓力 | |
年輕代徒增 9GB | 老年代GC,為什么年輕代會(huì)同步往上飚? | |
年輕代瞬間回收 9GB | 年輕代內(nèi)存飚升后,為什么瞬間又把內(nèi)存釋放? | |
共振點(diǎn)CPU無(wú)壓力 | 兩代整體回收12GB,對(duì)于C4垃圾回收器,應(yīng)該毫無(wú)壓力 | GC窗口期間,CPU算力充足,為什么會(huì)導(dǎo)致 RT99 成倍往上飚? |
到這里,其實(shí)問(wèn)題已經(jīng)很明顯了:
- C4作為世界頂級(jí)垃圾回收器,GC的能力不用懷疑,STW(Stop-The-World)的時(shí)間理論是亞毫秒級(jí)。
- 如果GC能力沒(méi)問(wèn)題,算力又充足,那么造成RT99翻倍的原因:要么是線程在等數(shù)據(jù),要么是線程忙不過(guò)來(lái)。
- Neuron堆內(nèi)存大頭是緩存,那么老年代回收的數(shù)據(jù)一定是緩存數(shù)據(jù),年輕代一定是在回補(bǔ)緩存缺口。
為什么會(huì)有這個(gè)邏輯?因?yàn)榫彺婷新室恢笔?nbsp;99.9%【1700個(gè)廣告條目】,如圖所示:
圖片
在極高緩存命中率的場(chǎng)景下,僅清理少量緩存條目,也可能造成“緩存缺口”。緩存缺口本質(zhì)上也是一次“中斷”,線程被迫等待或執(zhí)行數(shù)據(jù)回補(bǔ),導(dǎo)致性能抖動(dòng)。
為方便理解,類(lèi)比“缺頁(yè)中斷”(Page Fault):當(dāng)程序訪問(wèn)未加載的內(nèi)存頁(yè)時(shí),操作系統(tǒng)必須中斷執(zhí)行、加載數(shù)據(jù),再繼續(xù)運(yùn)行。
解決方案
首先是緩存命中率一定是越高越好,99.9%的命中率沒(méi)毛病。問(wèn)題出在1700條廣告緩存條目,究竟為何必須如此頻繁地設(shè)置過(guò)期?【TTL: 60~90s】
原因是:業(yè)務(wù)期望廣告特征,能夠盡可能實(shí)時(shí)更新。
緩存失效策略
失效時(shí)間 60~90s
關(guān)鍵在于,緩存條目必須及時(shí)失效,卻又不能因GC過(guò)度而引發(fā)性能問(wèn)題。從觀察結(jié)果來(lái)看,年輕代的GC沒(méi)有對(duì)RT99的性能產(chǎn)生明顯影響,這說(shuō)明年輕代GC的力度恰到好處,不會(huì)造成頻繁的“緩存缺口”。既然如此,我們考慮:如果能徹底規(guī)避老年代GC,性能瓶頸的問(wèn)題是否就能迎刃而解?
因此,我們嘗試大幅提高對(duì)象晉升到老年代的門(mén)檻,直接提升了幾個(gè)數(shù)量級(jí)。
增加JVM參數(shù):
-XX:GPGCTimeStampPromotionThresholdMS # 對(duì)象晉升老年代前的時(shí)間閾值
默認(rèn)值:2000 調(diào)整為:6000000 (1.6小時(shí))
-XX:GPGCOldGCIntervalSecs # 老年代固定GC時(shí)間推薦。注意:并不是關(guān)閉 OldGC
默認(rèn)值:600 調(diào)整為:600000
在這個(gè)場(chǎng)景中,實(shí)際有效的對(duì)象并不多,最多不過(guò)5GB。 其余大部分都是生命周期不超過(guò)2分鐘的短期廣告特征條目(約1700條)。這種短生命周期、低占用的場(chǎng)景完全靠年輕代GC就能輕松支撐,根本不需要啟用分代GC。
實(shí)際測(cè)試一天后,完全印證了這一判斷:GC抖動(dòng)、RT99抖動(dòng)以及錯(cuò)誤率抖動(dòng)全都徹底消失,同時(shí)內(nèi)存也沒(méi)有出現(xiàn)任何泄漏。
GC 毛刺消失
RT99失敗率 毛刺峰值降至 1/10 +
小結(jié)
C4的分代GC對(duì)大堆確實(shí)有奇效,但放在小堆場(chǎng)景里,非要套個(gè)復(fù)雜架構(gòu),就成了典型的“形式主義”
大堆適用,小堆不行。
四、是誰(shuí)偷走了RT時(shí)間
業(yè)務(wù)瓶頸的卡點(diǎn)
最近算法特征多了,推理成本就高了;RT一長(zhǎng),用戶體驗(yàn)就垮了;產(chǎn)品一急,秒開(kāi)優(yōu)化就立項(xiàng)了。
全業(yè)務(wù)鏈路都已鎖定 RT 優(yōu)化目標(biāo),社區(qū)個(gè)性化精排也在其中,可這一鏈路優(yōu)化阻力最大——RT99長(zhǎng)期卡在120ms 以上,始終難以突破。
圖片
活用三昧真火
性能分析必看CPU火焰圖。一看圖就是GC問(wèn)題。
GC日志分析,年輕代+老年代,堆積起來(lái)約150GB,而堆內(nèi)存才給108GB,怎么做到的?->>> 頻繁GC!
GC算力消耗占比 超50%
至少要 150GB 勉強(qiáng)夠用
看看哪里分配內(nèi)存比較瘋狂,如圖內(nèi)存分配火焰圖所示:
圖片
內(nèi)存分配壓力指向兩大熱點(diǎn)
※ Dump
業(yè)務(wù)剛需,大量序列化點(diǎn)對(duì)象帶來(lái)的瞬時(shí)垃圾情有可原。
※ 特征
真正的“吞金獸”——獨(dú)占超過(guò)50%的堆。業(yè)務(wù)方解釋:當(dāng)前500萬(wàn)特征才勉強(qiáng)把命中率抬到80%,想繼續(xù)往上,只能指數(shù)級(jí)內(nèi)存擴(kuò)容,總特征數(shù)10億+。堆已拉到128GB,找不到更大規(guī)格的機(jī)器。
也就是說(shuō)內(nèi)存主要被特征吞掉了,優(yōu)化空間基本沒(méi)有。
如果優(yōu)化止步于此,顯然無(wú)法滿足業(yè)務(wù)方的期望,于是我們進(jìn)一步深入到Wall火焰圖進(jìn)行更精細(xì)的分析。
圖片
Wall火焰圖同時(shí)捕獲了CPU執(zhí)行與IO等待,因此不能簡(jiǎn)單地以棧頂寬度判斷性能瓶頸。否則只會(huì)發(fā)現(xiàn)線程池空閑的等待任務(wù),看似正常,但真正的性能瓶頸卻隱藏在細(xì)節(jié)中。
因此,我們需要放大視角,聚焦到具體的業(yè)務(wù)邏輯堆棧位置。在這個(gè)案例中,一旦放大便能發(fā)現(xiàn)顯著問(wèn)題:特征讀取階段的IO等待時(shí)間,竟然超過(guò)了遠(yuǎn)程DML推理與Kafka Dump的總耗時(shí)。這直接說(shuō)明,所謂的80%特征緩存命中率存在明顯的緩存擊穿現(xiàn)象,大量請(qǐng)求可能被迫穿透至遠(yuǎn)端Redis或C引擎進(jìn)行加載,其耗時(shí)成本遠(yuǎn)高于本地緩存命中的場(chǎng)景。
逐幀跟蹤確認(rèn)
通過(guò)進(jìn)一步的Trace跟蹤分析,我們的猜測(cè)得到了驗(yàn)證。
圖片
通過(guò)和C引擎團(tuán)隊(duì)聯(lián)合排查發(fā)現(xiàn),現(xiàn)有架構(gòu)采用了早期的部署模式,其中為索引分片路由而設(shè)立的中間Proxy層成為性能瓶頸,其RT999甚至超過(guò)100ms。這種架構(gòu)帶來(lái)的問(wèn)題在于,上游業(yè)務(wù)對(duì)特征數(shù)量需求極大,即使緩存已擴(kuò)大到500萬(wàn)條目,也僅能達(dá)到80%的命中率。算法工程團(tuán)隊(duì)通過(guò)對(duì)特征請(qǐng)求進(jìn)行多層拆分及異步并發(fā)查詢優(yōu)化,但仍有少量長(zhǎng)尾特征無(wú)法命中緩存,只能依靠C引擎響應(yīng)。一旦任何一批次特征查詢觸發(fā)了C引擎的慢查詢,這一請(qǐng)求的整體RT勢(shì)必大幅提升,甚至可能超時(shí)。
好在C引擎同時(shí)提供了一種更先進(jìn)的垂直多副本部署模式,能夠去除Proxy這一中心化的瓶頸組件。未來(lái)的新架構(gòu)仍會(huì)保留索引分片設(shè)計(jì),但會(huì)利用旁路方式實(shí)現(xiàn)完全的去中心化。
圖片
小結(jié)
通過(guò)Wall火焰圖深入分析RT性能瓶頸,并結(jié)合Trace工具驗(yàn)證猜想,是優(yōu)化系統(tǒng)性能不可或缺的關(guān)鍵步驟。
五、結(jié)語(yǔ):性能優(yōu)化無(wú)止盡
性能優(yōu)化沒(méi)有終點(diǎn),只有下一個(gè)起點(diǎn)。每次性能的提升,不僅是對(duì)技術(shù)邊界的突破,更是為業(yè)務(wù)創(chuàng)造了更多可能性。本文分享的場(chǎng)景和實(shí)操經(jīng)驗(yàn),旨在拋磚引玉,幫助各位同學(xué)掌握深度性能分析的方法論,避免走彎路,更高效地解決工程難題。希望每位研發(fā)和SRE同學(xué),都能從微妙的細(xì)節(jié)中捕捉優(yōu)化機(jī)會(huì),讓?xiě)?yīng)用在極致性能的路上穩(wěn)步前進(jìn)。