从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

一、引言

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

其中,性能尤为关键------它既能提升系统稳定性,又能降低成本、优化效果。因此,工程团队将微秒级的性能优化作为核心攻坚方向。

本文将结合具体案例,分享算法SRE在日常性能优化中的宝贵经验,助力更多同学在实践中优化系统性能、实现业务价值最大化。

二、给浮点转换降温

算法工程的核心是排序,而排序离不开特征。特征大多是浮点数,必然伴随频繁的数值转换。零星转换对CPU无足轻重,可一旦规模如洪水倾泻,便会出现CPU瞬间飙红、性能断崖式下跌的情况,导致被迫堆硬件,白白抬高成本开销。

例如:《交易商详页相关推荐 - neuron-csprd-r-tr-rel-cvr-v20-s6》 特征处理占用CPU算力时间的61%。其中大量工作都在做Double浮点转换,如图所示:

优化前CPU时间占比 18%

Double.parseDouble、Double.toString是JDK原生原子API了,还能优化?直接给答案:能!

浮点转字符串:Ryu算法

github.com/ulfjack/ryu

Ryu算法,用"查表+定长整数运算"彻底摒弃"动态多精度运算+内存管理"的重开销,既正确又高效。

算法的完整正确性证明:

dl.acm.org/citation.cf...? doid=3296979.3192369

伪代码说明

perl 复制代码
// ------"普通"浮点到字符串(高成本)------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算法

github.com/wrandelshof...

相比Java自带的Double.parseDouble使用复杂状态机(如BigDecimal或 BigInteger)来处理各种情况,FastDoubleParser使用以下优化策略。

FastDoubleParser 优化策略

※ 分离阶段

  • 将输入拆分为三个部分:significand、exponent、special cases(如 NaN, Infinity)。
  • 解析时直接处理整数位和小数位的组合。

※ 整型加速 + 倍数转换

  • 在范围允许的情况下使用"64位整数直接表示"有效位。
  • 再通过预计算的"幂次表(10ⁿ 或 2ⁿ)"进行快速缩放,避免慢速浮点乘法。

※ 避免慢路径

  • 避免使用BigDecimal**或字符串转高精度,再转回double的慢路径。
  • 对于大多数输入,整个解析过程不涉及任何内存分配。

※ SIMD加速(原版 C++)

在C++中使用SIMD指令批量处理字符,Java版受限于JVM,但仍通过循环展开等技术尽量进行优化。

转换思路

vbnet 复制代码
Input: "123.45e2"1. 拆分成:   significand = 12345 (去掉小数点)   exponent = 2 - 2 = 0  // 小数点后两位,但有 e22. 快速转换:   result = 12345 * 10^0 = 12345.03. 最终使用 Double.longBitsToDouble 构造结果

压测报告

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

代码优化样例

通过多层判断,尽可能不让Object o做toString()操作。

减少toString触发的可能

工具类 替换浮点转换算法

工具类 替换浮点转换算法

性能实测效果

启用Ryu、Fast_Float算法替换JDK原生浮点转换,效果如下:

优化后CPU时间占比 0.19%【性能提升(18-0.19)/18=98%】

CPU实际获得50%收益

RT实际获得25%左右性能收益

小结

告别原生JDK浮点转换的高昂代价,拥抱Ryu与FastDoubleParser,让CPU从繁忙到清闲,性能"回血",节约的成本大家可以吃火锅。

三、拔掉诡异的GC毛刺

小堆GC问题

特征维度多时内存压力大,GC问题可以预期。但很多同学可能没有见过,小堆场景,GC也可能频繁触发,甚至引发异常。

如图所示:18GB堆 扩容 -> 30GB堆,均出现RT99周期脉冲,致使5~6%的失败率。

社区瀑布流广告投放-Neuron精排 因GC导致错误

GC问题分析

首先这是GC问题,其次增加了近1倍的内存,没有丝毫缓解 ,判断这应该是个伪GC问题

Neuron**主要功能就是拿着特征转向量做排序。一般特征量都是亿起步,多的达十亿,因此特征缓存必不可少。但是这个场景,仅仅是将1700个左右的广告特征信息进行了缓存,为什么对象内存会出现周期性的脉冲?

年轻代+老年代 周期共振脉冲

如图所示,关键的问题在于 "共振" 。因此要用放大镜看问题,再如图所示:

线索 矛盾点 疑惑点
老年代回收 3GB 老年代3GB回收,对于C4垃圾回收器,应该毫无压力
年轻代徒增 9GB 老年代GC,为什么年轻代会同步往上飚?
年轻代瞬间回收 9GB 年轻代内存飚升后,为什么瞬间 又把内存释放
共振点CPU无压力 两代整体回收12GB,对于C4垃圾回收器,应该毫无压力 GC窗口期间,CPU算力充足,为什么会导致 RT99 成倍往上飚?

到这里,其实问题已经很明显了:

  • C4作为世界顶级垃圾回收器,GC的能力不用怀疑,STW(Stop-The-World)的时间理论是亚毫秒级。
  • 如果GC能力没问题,算力又充足,那么造成RT99翻倍的原因:要么是线程在等数据,要么是线程忙不过来。
  • Neuron堆内存大头是缓存,那么老年代回收的数据一定是缓存数据,年轻代一定是在回补缓存缺口。

为什么会有这个逻辑?因为缓存命中率一直是 99.9%【1700个广告条目】 ,如图所示:

在极高缓存命中率的场景下,仅清理少量缓存条目,也可能造成"缓存缺口"。缓存缺口本质上也是一次"中断",线程被迫等待或执行数据回补,导致性能抖动。

为方便理解,类比"缺页中断"(Page Fault):当程序访问未加载的内存页时,操作系统必须中断执行、加载数据,再继续运行。

解决方案

首先是缓存命中率一定是越高越好,99.9%的命中率没毛病。问题出在1700条广告缓存条目,究竟为何必须如此频繁地设置过期?【TTL: 60~90s】

原因是:业务期望广告特征,能够尽可能实时更新。

缓存失效策略

失效时间 60~90s

关键在于,缓存条目必须及时失效,却又不能因GC过度而引发性能问题。从观察结果来看,年轻代的GC没有对RT99的性能产生明显影响,这说明年轻代GC的力度恰到好处,不会造成频繁的"缓存缺口"。 既然如此,我们考虑:如果能彻底规避老年代GC,性能瓶颈的问题是否就能迎刃而解?

因此,我们尝试大幅提高对象晋升到老年代的门槛,直接提升了几个数量级。

ruby 复制代码
增加JVM参数:
-XX:GPGCTimeStampPromotionThresholdMS # 对象晋升老年代前的时间阈值默认值:2000  调整为:6000000 (1.6小时)
-XX:GPGCOldGCIntervalSecs # 老年代固定GC时间推荐。注意:并不是关闭 OldGC默认值:600 调整为:600000

在这个场景中,实际有效的对象并不多,最多不过5GB。 其余大部分都是生命周期不超过2分钟的短期广告特征条目(约1700条)。这种短生命周期、低占用的场景完全靠年轻代GC就能轻松支撑,根本不需要启用分代GC。

实际测试一天后,完全印证了这一判断:GC抖动、RT99抖动以及错误率抖动全都彻底消失,同时内存也没有出现任何泄漏

小结

C4的分代GC对大堆确实有奇效,但放在小堆场景里,非要套个复杂架构,就成了典型的"形式主义"

大堆适用,小堆不行。

四、是谁偷走了RT时间

业务瓶颈的卡点

最近算法特征多了,推理成本就高了;RT一长,用户体验就垮了;产品一急,秒开优化就立项了。

全业务链路都已锁定 RT 优化目标,社区个性化精排也在其中,可这一链路优化阻力最大------RT99长期卡在120ms 以上,始终难以突破。

活用三昧真火

性能分析必看CPU火焰图。一看图就是GC问题。

GC日志分析,年轻代+老年代,堆积起来约150GB,而堆内存才给108GB,怎么做到的?->>> 频繁GC!

看看哪里分配内存比较疯狂,如图内存分配火焰图所示:

内存分配压力指向两大热点

※ Dump

业务刚需,大量序列化点对象带来的瞬时垃圾情有可原。

※ 特征

真正的"吞金兽"------独占超过50%的堆。业务方解释:当前500万特征才勉强把命中率抬到80%,想继续往上,只能指数级内存扩容,总特征数10亿+。堆已拉到128GB,找不到更大规格的机器

也就是说内存主要被特征吞掉了,优化空间基本没有。

如果优化止步于此,显然无法满足业务方的期望,于是我们进一步深入到Wall火焰图进行更精细的分析。

Wall火焰图同时捕获了CPU执行与IO等待,因此不能简单地以栈顶宽度判断性能瓶颈。否则只会发现线程池空闲的等待任务,看似正常,但真正的性能瓶颈却隐藏在细节中。

因此,我们需要放大视角,聚焦到具体的业务逻辑堆栈位置。在这个案例中,一旦放大便能发现显著问题:特征读取阶段的IO等待时间,竟然超过了远程DML推理与Kafka Dump的总耗时。这直接说明,所谓的80%特征缓存命中率存在明显的缓存击穿现象,大量请求可能被迫穿透至远端Redis或C引擎进行加载,其耗时成本远高于本地缓存命中的场景。

逐帧跟踪确认

通过进一步的Trace跟踪分析,我们的猜测得到了验证。

通过和C引擎团队联合排查发现,现有架构采用了早期的部署模式,其中为索引分片路由而设立的中间Proxy层成为性能瓶颈,其RT999甚至超过100ms。这种架构带来的问题在于,上游业务对特征数量需求极大,即使缓存已扩大到500万条目,也仅能达到80%的命中率。算法工程团队通过对特征请求进行多层拆分及异步并发查询优化,但仍有少量长尾特征无法命中缓存,只能依靠C引擎响应。一旦任何一批次特征查询触发了C引擎的慢查询,这一请求的整体RT势必大幅提升,甚至可能超时

好在C引擎同时提供了一种更先进的垂直多副本部署模式,能够去除Proxy这一中心化的瓶颈组件。未来的新架构仍会保留索引分片设计,但会利用旁路方式实现完全的去中心化。

小结

通过Wall火焰图深入分析RT性能瓶颈,并结合Trace工具验证猜想,是优化系统性能不可或缺的关键步骤。

五、结语:性能优化无止尽

性能优化没有终点,只有下一个起点。每次性能的提升,不仅是对技术边界的突破,更是为业务创造了更多可能性。本文分享的场景和实操经验,旨在抛砖引玉,帮助各位同学掌握深度性能分析的方法论,避免走弯路,更高效地解决工程难题。希望每位研发和SRE同学,都能从微妙的细节中捕捉优化机会,让应用在极致性能的路上稳步前进。

往期回顾

1.得物自研DScript2.0脚本能力从0到1演进

2.社区造数服务接入MCP|得物技术

3.CSS闯关指南:从手写地狱到"类"积木之旅|得物技术

4.从零实现模块级代码影响面分析方案|得物技术

5.以细节诠释专业,用成长定义价值------对话@孟同学 |得物技术

文 / 月醴

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

相关推荐
漫步企鹅1 天前
【CPU】英特尔酷睿Ultra 5 225H与Ultra7 258V(Lunar Lake架构)PK
pc·cpu·intel·联想笔记本·pk
2301_8018217116 天前
实验-设计一个应用系统(计算机组成原理)
cpu·risc-v·计算机组成原理·logisim
农夫山泉2号1 个月前
【“星瑞” O6 评测】 — CPU llama.cpp不同优化速度对比
cpu·llama·llama.cpp·星瑞o6
曲幽2 个月前
Python本地部署Stable Diffusion实现在纯CPU环境下的实现
python·ai·stable diffusion·cpu·openvino·lcm
比特冬哥2 个月前
在MCU工程中优化CPU工作效率的几种方法
mcu·cpu
草捏子2 个月前
从CPU原理看:为什么你的代码会让CPU"原地爆炸"?
后端·cpu
草捏子2 个月前
为什么CPU缓存偏偏是三级?少一级不够,多一级浪费?
cpu
用户33766763012402 个月前
cpu权限管理
cpu
用户33766763012402 个月前
关于寄存器的秘密?流水线寄存器冲突的解决方案?原来真实寄存器的数量有可能上千个?
cpu