博客 / 詳情

返回

從CPU冒煙到絲滑體驗:算法SRE性能優化實戰全揭秘|得物技術

一、引言

在算法工程中,大家一般關注四大核心維度:穩定、成本、效果、性能

其中,性能尤為關鍵——它既能提升系統穩定性,又能降低成本、優化效果。因此,工程團隊將微秒級的性能優化作為核心攻堅方向。

本文將結合具體案例,分享算法SRE在日常性能優化中的寶貴經驗,助力更多同學在實踐中優化系統性能、實現業務價值最大化。

二、給浮點轉換降温

算法工程的核心是排序,而排序離不開特徵。特徵大多是浮點數,必然伴隨頻繁的數值轉換。零星轉換對CPU無足輕重,可一旦規模如洪水傾瀉,便會出現CPU瞬間飆紅、性能斷崖式下跌的情況,導致被迫堆硬件,白白抬高成本開銷。

例如:《交易商詳頁相關推薦 - neuron-csprd-r-tr-rel-cvr-v20-s6》 特徵處理佔用CPU算力時間的61%。其中大量工作都在做Double浮點轉換,如圖所示:

image.png

優化前CPU時間佔比 18%

Double.parseDouble、Double.toString是JDK原生原子API了,還能優化?直接給答案:能!

浮點轉字符串:Ryu算法

https://github.com/ulfjack/ryu

Ryu算法,用“查表+定長整數運算”徹底摒棄“動態多精度運算+內存管理”的重開銷,既正確又高效。

算法的完整正確性證明:

https://dl.acm.org/citation.cfm? doid=3296979.3192369

偽代碼説明

// ——“普通”浮點到字符串(高成本)——void convertStandard(double d, char *out) {    // 1. 拆分浮點:符號、指數、尾數    bool sign = (d < 0);    int  exp  = extractExponent(d);    // 提取二進制指數    uint64_t mant = extractMantissa(d);        // 2. 構造大整數:mant × 2^exp —— 可能要擴容內存    BigInt num = BigInt_from_uint64(mant);    num = BigInt_mul_pow2(num, exp);    // 多精度移位,高開銷       // 3. 逐位除以 10 生成十進制,每次都是多精度除法    //    ——每次 divMod 都要循環內部分配和多精度運算    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. 去除多餘零、插入小數點和符號    formatOutput(sign, buf, len, out);}

// ——Ryu 方法(低成本)——void convertRyu(double d, char *out) {    // 1. 拆分浮點:符號、真實指數、尾數(隱含1)    bool sign = (d < 0);    int  e2   = extractBiasedExponent(d) - BIAS;    uint64_t m2 = extractMantissa(d) | IMPLIED_ONE;        // 2. 一次查表:獲得 5^k 和對應位移量    //    ——預先計算好,運行時無動態開銷    int      k     = computeDecimalExponent(e2);    uint64_t pow5  = POW5_TABLE[k];        // 只讀數組(cache 友好)    int      shift = SHIFT_TABLE[k];        // 3. 單次 64×64 位乘法 + 右移 —— 固定時間    __uint128_t prod = ( __uint128_t )m2 * pow5;    uint64_t    v    = (uint64_t)(prod >> shift);        // 4. 固定最多 ~20 次小循環,v%10 生成每位數字    //    ——循環次數上限,與具體數值無關    char buf[24];    int  len = 0;    do {        buf[len++] = '0' + (v % 10);        v /= 10;    } while (v);       // 5. 去零、插小數點、加符號:輕量字符串操作    formatShort(sign, buf, len, k, out);}

傳統方法 vs. Ryu算法對比:

算法比較 “普通”算法 Ryu算法
內存分配 BigInt動態擴容 + 釋放 →heap分配/回收成本高 全/靜態表 + 棧數組,無malloc→ 零動態分配
算術成本 頻繁多精度除法(數百納秒) 單次64位乘法+位移(約30-40納秒)
循環次數 取決於浮點數數值難以預測 固定次數易於優化和預測
緩存友好 內存分散不利CPU緩存 棧上集中CPU緩存友好

字符串轉浮點:Fast_Float算法

https://github.com/wrandelshofer/FastDoubleParser

相比Java自帶的Double.parseDouble使用複雜狀態機(如BigDecimal或 BigInteger)來處理各種情況,FastDoubleParser使用以下優化策略。

FastDoubleParser 優化策略

※  分離階段

  • 將輸入拆分為三個部分:significand、exponent、special cases(如 NaN, Infinity)。
  • 解析時直接處理整數位和小數位的組合。

※  整型加速 + 倍數轉換

  • 在範圍允許的情況下使用“64位整數直接表示”有效位。
  • 再通過預計算的“冪次表(10ⁿ 或 2ⁿ)”進行快速縮放,避免慢速浮點乘法。

※  避免慢路徑

  • 避免使用BigDecimal**或字符串轉高精度,再轉回double的慢路徑。
  • 對於大多數輸入,整個解析過程不涉及任何內存分配。

※  SIMD加速(原版 C++)

在C++中使用SIMD指令批量處理字符,Java版受限於JVM,但仍通過循環展開等技術儘量進行優化。

轉換思路

Input: "123.45e2"1. 拆分成:   significand = 12345 (去掉小數點)   exponent = 2 - 2 = 0  // 小數點後兩位,但有 e22. 快速轉換:   result = 12345 * 10^0 = 12345.03. 最終使用 Double.longBitsToDouble 構造結果

壓測報告

742f0d155c9d3dcb03b856d87390db22.png

Double 字符解析相對JDK原生API 4.43倍 加速

代碼優化樣例

通過多層判斷,儘可能不讓Object o做toString()操作。

b543a4bbafe56dacda88ea62f85428e7.png

減少toString觸發的可能

image.png

工具類 替換浮點轉換算法

image.png

工具類 替換浮點轉換算法

性能實測效果

啓用Ryu、Fast_Float算法替換JDK原生浮點轉換,效果如下:

image.png

優化後CPU時間佔比 0.19%【性能提升(18-0.19)/18=98%】

image.png

CPU實際獲得50%收益

image.png

RT實際獲得25%左右性能收益

小結

告別原生JDK浮點轉換的高昂代價,擁抱Ryu與FastDoubleParser,讓CPU從繁忙到清閒,性能“回血”,節約的成本大家可以吃火鍋。

三、拔掉詭異的GC毛刺

小堆GC問題

特徵維度多時內存壓力大,GC問題可以預期。但很多同學可能沒有見過,小堆場景,GC也可能頻繁觸發,甚至引發異常。

如圖所示:18GB堆 擴容 -> 30GB堆,均出現RT99週期脈衝,致使5~6%的失敗率。

image.png

社區瀑布流廣告投放-Neuron精排   因GC導致錯誤

GC問題分析

首先這是GC問題,其次增加了近1倍的內存,沒有絲毫緩解,判斷這應該是個偽GC問題

Neuron主要功能就是拿着特徵轉向量做排序。一般特徵量都是億起步,多的達十億,因此特徵緩存必不可少。但是這個場景,僅僅是將1700個左右**的廣告特徵信息進行了緩存,為什麼對象內存會出現週期性的脈衝?

image.png

年輕代+老年代 週期共振脈衝

如圖所示,關鍵的問題在於 “共振” 。因此要用放大鏡看問題,再如圖所示:
8118603d00894fce15629313ce6b33fd.png
3a4b16c76141db6c0263c47963704f08.png
7789f5a3b73f886bfe0cc2c2f9093b4e.png

線索 矛盾點 疑惑點
老年代回收 3GB 老年代3GB回收,對於C4垃圾回收器,應該毫無壓力
年輕代徒增 9GB 老年代GC,為什麼年輕代會同步往上飈?
年輕代瞬間回收 9GB 年輕代內存飈升後,為什麼瞬間又把內存釋放
共振點CPU無壓力 兩代整體回收12GB,對於C4垃圾回收器,應該毫無壓力 GC窗口期間,CPU算力充足,為什麼會導致 RT99 成倍往上飈?

到這裏,其實問題已經很明顯了:

  • C4作為世界頂級垃圾回收器,GC的能力不用懷疑,STW(Stop-The-World)的時間理論是亞毫秒級。
  • 如果GC能力沒問題,算力又充足,那麼造成RT99翻倍的原因:要麼是線程在等數據,要麼是線程忙不過來。
  • Neuron堆內存大頭是緩存,那麼老年代回收的數據一定是緩存數據,年輕代一定是在回補緩存缺口。

為什麼會有這個邏輯?因為緩存命中率一直是 99.9%【1700個廣告條目】 ,如圖所示:

image.png

在極高緩存命中率的場景下,僅清理少量緩存條目,也可能造成“緩存缺口”。緩存缺口本質上也是一次“中斷”,線程被迫等待或執行數據回補,導致性能抖動。

為方便理解,類比“缺頁中斷”(Page Fault):當程序訪問未加載的內存頁時,操作系統必須中斷執行、加載數據,再繼續運行。

解決方案

首先是緩存命中率一定是越高越好,99.9%的命中率沒毛病。問題出在1700條廣告緩存條目,究竟為何必須如此頻繁地設置過期?【TTL: 60~90s】

原因是:業務期望廣告特徵,能夠儘可能實時更新。
image.png
image.png

緩存失效策略

失效時間 60~90s

關鍵在於,緩存條目必須及時失效,卻又不能因GC過度而引發性能問題。從觀察結果來看,年輕代的GC沒有對RT99的性能產生明顯影響,這説明年輕代GC的力度恰到好處,不會造成頻繁的“緩存缺口”。 既然如此,我們考慮:如果能徹底規避老年代GC,性能瓶頸的問題是否就能迎刃而解?

因此,我們嘗試大幅提高對象晉升到老年代的門檻,直接提升了幾個數量級。

增加JVM參數:
-XX:GPGCTimeStampPromotionThresholdMS # 對象晉升老年代前的時間閾值默認值:2000  調整為:6000000 (1.6小時)
-XX:GPGCOldGCIntervalSecs # 老年代固定GC時間推薦。注意:並不是關閉 OldGC默認值:600 調整為:600000

在這個場景中,實際有效的對象並不多,最多不過5GB。 其餘大部分都是生命週期不超過2分鐘的短期廣告特徵條目(約1700條)。這種短生命週期、低佔用的場景完全靠年輕代GC就能輕鬆支撐,根本不需要啓用分代GC。

實際測試一天後,完全印證了這一判斷:GC抖動、RT99抖動以及錯誤率抖動全都徹底消失,同時內存也沒有出現任何泄漏
image.png
image.png

小結

C4的分代GC對大堆確實有奇效,但放在小堆場景裏,非要套個複雜架構,就成了典型的“形式主義”

大堆適用,小堆不行。

四、是誰偷走了RT時間

業務瓶頸的卡點

最近算法特徵多了,推理成本就高了;RT一長,用户體驗就垮了;產品一急,秒開優化就立項了。

全業務鏈路都已鎖定 RT 優化目標,社區個性化精排也在其中,可這一鏈路優化阻力最大——RT99長期卡在120ms 以上,始終難以突破。

fd1507cea99e79f1dcaa39b24b06aa8b.png

活用三昧真火

性能分析必看CPU火焰圖。一看圖就是GC問題。

GC日誌分析,年輕代+老年代,堆積起來約150GB,而堆內存才給108GB,怎麼做到的?->>> 頻繁GC!
c18f40944fe8b7b90e9816afa5077ade.png
image.png

image.png

看看哪裏分配內存比較瘋狂,如圖內存分配火焰圖所示:

c18f40944fe8b7b90e9816afa5077ade.png

內存分配壓力指向兩大熱點

※  Dump

業務剛需,大量序列化點對象帶來的瞬時垃圾情有可原。

※  特徵

真正的“吞金獸”——獨佔超過50%的堆。業務方解釋:當前500萬特徵才勉強把命中率抬到80%,想繼續往上,只能指數級內存擴容,總特徵數10億+。堆已拉到128GB,找不到更大規格的機器

也就是説內存主要被特徵吞掉了,優化空間基本沒有。

如果優化止步於此,顯然無法滿足業務方的期望,於是我們進一步深入到Wall火焰圖進行更精細的分析。

image.png

Wall火焰圖同時捕獲了CPU執行與IO等待,因此不能簡單地以棧頂寬度判斷性能瓶頸。否則只會發現線程池空閒的等待任務,看似正常,但真正的性能瓶頸卻隱藏在細節中。

因此,我們需要放大視角,聚焦到具體的業務邏輯堆棧位置。在這個案例中,一旦放大便能發現顯著問題:特徵讀取階段的IO等待時間,竟然超過了遠程DML推理與Kafka Dump的總耗時。這直接説明,所謂的80%特徵緩存命中率存在明顯的緩存擊穿現象,大量請求可能被迫穿透至遠端Redis或C引擎進行加載,其耗時成本遠高於本地緩存命中的場景。
f01c91658693c89c88079aedf800b3ff.png

逐幀跟蹤確認

通過進一步的Trace跟蹤分析,我們的猜測得到了驗證。

fd483f94f059b93c534b29e9f8f1a0c0.png

通過和C引擎團隊聯合排查發現,現有架構採用了早期的部署模式,其中為索引分片路由而設立的中間Proxy層成為性能瓶頸,其RT999甚至超過100ms。這種架構帶來的問題在於,上游業務對特徵數量需求極大,即使緩存已擴大到500萬條目,也僅能達到80%的命中率。算法工程團隊通過對特徵請求進行多層拆分及異步併發查詢優化,但仍有少量長尾特徵無法命中緩存,只能依靠C引擎響應。一旦任何一批次特徵查詢觸發了C引擎的慢查詢,這一請求的整體RT勢必大幅提升,甚至可能超時

好在C引擎同時提供了一種更先進的垂直多副本部署模式,能夠去除Proxy這一中心化的瓶頸組件。未來的新架構仍會保留索引分片設計,但會利用旁路方式實現完全的去中心化。

image.png

小結

通過Wall火焰圖深入分析RT性能瓶頸,並結合Trace工具驗證猜想,是優化系統性能不可或缺的關鍵步驟。

五、結語:性能優化無止盡

性能優化沒有終點,只有下一個起點。每次性能的提升,不僅是對技術邊界的突破,更是為業務創造了更多可能性。本文分享的場景和實操經驗,旨在拋磚引玉,幫助各位同學掌握深度性能分析的方法論,避免走彎路,更高效地解決工程難題。希望每位研發和SRE同學,都能從微妙的細節中捕捉優化機會,讓應用在極致性能的路上穩步前進。

往期回顧

1.得物自研DScript2.0腳本能力從0到1演進

2.社區造數服務接入MCP|得物技術

3.CSS闖關指南:從手寫地獄到“類”積木之旅|得物技術

4.從零實現模塊級代碼影響面分析方案|得物技術

5.以細節詮釋專業,用成長定義價值——對話@孟同學 |得物技術

文 / 月醴

關注得物技術,每週更新技術乾貨

要是覺得文章對你有幫助的話,歡迎評論轉發點贊~

未經得物技術許可嚴禁轉載,否則依法追究法律責任。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.