ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从Demo到生产:毕昇编译器在鲲鹏ARM服务器上的优化实践

从Demo到生产:毕昇编译器在鲲鹏ARM服务器上的优化实践 做技术的人大概都经历过这种状态Demo 跑得欢天喜地一上生产就原形毕露。我这次的项目就是这么个典型——一个数据处理服务本地 x86 机器上演示效果很好等到要部署到生产环境问题一个接一个冒出来。最后逼得我在编译器这个层面重新做了一次选型从 GCC、Clang 一路试到毕昇编译器BISHENG才把性能、兼容性和交付成本这三笔账彻底算清楚。这个话题适合谁如果你手头有个项目正要从小规模原型走向正式生产尤其目标环境是鲲鹏这类 ARM 服务器或者你正在为 Java/C 服务的 CPU 占用率和接口延迟发愁这篇文章应该能帮你少走几周弯路。我会把选型逻辑、实操命令、踩坑记录全部摊开来讲尽量做到你照着抄就能用。1. 项目概述Demo 的起点与生产环境的落差1.1 我们做的这个项目是什么具体业务不展开太多简单说是一个面向内部业务的日志与指标处理服务核心链路是读取上游消息做清洗、聚合再写入下游存储同时提供一串实时查询接口。技术栈是我在公司里最常用的组合——底层核心计算用 C 写业务接口用 JavaSpring Boot包一层本地构建和演示都在 x86 的开发机上跑数据规模是压测脚本模拟出来的一切看起来都很正常。Demo 阶段最大的特点就是“能跑就行”。我当时的代码可以说相当任性依赖库直接从包管理器安装、编译参数用默认的优化级别、JVM 参数全靠默认、甚至连镜像都没打直接靠开发机上的环境跑起来。演示效果也确实不错接口响应基本在几十毫秒CPU 占用也不高评审会上大家都很满意。但那时候我心里是虚的——Demo 的数据量大概只有生产环境的五十分之一而且开发机的 CPU 是家用型号指令集、缓存和服务器完全是两回事。这里我想插一句很多人觉得 Demo 阶段的代码只是“临时的”后面反正是要重写的。但现实是Demo 验证过的逻辑、接口、依赖关系大概率会被保留下来甚至直接成为生产代码的骨架。所以从一开始就抱着“这玩意儿可能上生产”的心态去写后面会省掉大量返工。可惜这个道理我也是踩了坑之后才真正想明白。1.2 生产环境给出的第一记闷棍真正到了部署环节第一步就卡住了生产用的服务器是鲲鹏 920 的 ARM 架构。这倒不是领导拍脑袋决定的而是整体基础设施在做功耗和成本的优化新服务原则上都要优先部署到 ARM 平台上。我们的代码好歹是跨平台的Java 和 C 理论上都能跑但“理论上能跑”和“真正能跑”之间隔着一条河。第一个问题是原生依赖。C 侧用了好几个第三方库有的直接装的是 x86 的安装包有的甚至是自己拿 Makefile 编出来的里面还夹带着对 x86 指令的隐式依赖。Java 侧倒是省心JDK 本身就是跨平台的但问题出在 JNI 调用——我们有几个性能关键路径是通过 JNI 调 C 的这部分一跨架构就全得重编。第二个问题是性能。同样的代码搬到 ARM 上之后第一次压测结果简直让人怀疑人生接口 P99 延迟比 x86 翻了接近一倍CPU 占用率常年顶着 80% 以上。当时团队里有人开玩笑说要不就跳过 ARM 直接申请几台 x86 算了但我知道这个口子不能开——这次能申请下次呢如果架构分裂以后所有服务都要维护两套环境成本不是一般的高。所以摆在面前的路只有一条把代码和工具链真正调明白让它能在 ARM 上跑得又快又稳。接下来的问题就是用什么工具链来调2. 选型过程为什么 BISHENG 会进入我的视野2.1 毕昇是什么不是什么先把这个名字说清楚因为很多人第一次听到“毕昇”都会愣一下。毕昇编译器BiSheng Compiler是华为推出的、基于 LLVM 的编译工具链目标是面向鲲鹏处理器做深度优化另一个配套组件是毕昇 JDKBiSheng JDK基于 OpenJDK 做了针对性增强。也就是说毕昇不是一个从零搞出来的“新语言”也不是跟 GCC 分庭抗礼的“另一套编译器”它更像是 LLVM 上游的一个内部增强分支加上针对鲲鹏架构的高性能后端。顺带提一句名字的来历——毕昇是古代活字印刷术的发明者用他来命名编译器寓意是把代码像活字一样“排版”成最优的机器指令。这个意象还挺准确的同一套源代码不同编译器产出的机器码质量差别很大好的编译器就像排版师傅一样能把字排得整齐省纸。用人话总结如果你的目标 CPU 是鲲鹏用毕昇就相当于给编译器开了个“定向加速”的外挂。GCC 和标准 Clang 当然也能编译出可运行的代码但它们只会按通用 ARMv8 指令集做保守优化不会专门针对鲲鹏的微架构、SVE 向量指令和缓存层次去做调度毕昇在这些方面有针对性加强。我第一次认真了解毕昇是因为一个压测报告——同一条代码在鲲鹏服务器上用 GCC 编出来跑的成绩是 A用毕昇编出来能到 A 的 1.2 倍以上。这种差距在 Demo 阶段根本没人关心但在生产环境里CPU 占用率差 20%就意味着同样的硬件能多扛 20% 的流量换算成全年的服务器成本是一笔不小的数字。就是从那时候开始我把毕昇列进了正式选型名单。2.2 竞品对比GCC、Clang、OpenJDK 与毕昇选型这种事光看一篇博客是不够的我习惯自己搭一个最小化基准环境做对比。以下是我在鲲鹏 920 服务器上用同一个 Demo 服务的两个核心模块做的实测对比工具链组合Java 侧C 侧备注官方 OpenJDK 17能跑不适用启动慢GC 参数需要手动调GCC 11 官方 JDK能跑能跑稳定但性能平庸默认优化保守Clang 15 官方 JDK能跑能跑编译速度快但汇编优化一般毕昇 JDK 毕昇编译器能跑能跑性能最好但需要接受它的发布节奏这份对比表里我最看重的是两行能不能稳定跑通以及性能上限在哪里。实测下来毕昇编译器配合高性能优化选项和 LTO 之后C 模块相比 GCC 11 的默认优化有大约 18%~25% 的提升Java 侧在换了毕昇 JDK 并调完 GC 参数后P99 延迟降了差不多三成。这个成绩单已经不是“一点点优化”了它直接改变了我们原有的部署方案。2.3 决策的关键因素不是只为了快不过老实说纯看性能数字还不足以让我下决心毕昇最终胜出还因为三件“软性”的事。第一生态兼容性。毕昇基于 LLVM它编出来的代码在标准 ABI 上与 GCC 和 Clang 是兼容的这意味着团队里已有的 CMake 工程、第三方静态库、JNI 封装不需要为了换编译器而大改。我当时给自己定的底线就是“不能为了性能把工程搞得四不像”毕昇没有破坏这个底线。第二配套组件完整。毕昇工具链并不只是编译器本身它还有配套的 JDK、容器镜像和性能分析工具Java 侧和 C 侧可以统一到一个体系里不用混搭几种来源不明的优化补丁。这一点在之后的运维阶段帮了很大的忙——出了问题只需要在一个知识体系里排查不用同时维护几套不同的修修补补。第三是发布节奏和问题响应。毕昇有稳定的社区版本和商业支持渠道不是某个团队内部用完了就丢的项目。我们在试用阶段提过几个兼容性疑问响应速度比预期快不少。对于一个要长期演进的生产系统来说这点比短期性能更重要。3. 核心细节解析与实操要点3.1 Java 侧从 OpenJDK 换成毕昇 JDK 的具体做法Java 侧是我最先动手的地方因为改造成本最低。毕昇 JDK 的安装其实和普通 OpenJDK 没什么两样拿到对应架构的包解压、配环境变量就行。我这里的操作记录如下基于毕昇 JDK 17实际版本号以官方发布为准# 解压到指定目录 sudo tar -xzf bisheng-jdk-17.0.x-linux-aarch64.tar.gz -C /opt/java/ # 配置环境变量 export JAVA_HOME/opt/java/bisheng-jdk-17.0.x export PATH$JAVA_HOME/bin:$PATH # 验证版本 java -version这一步做完Java 侧其实就已经换成了毕昇 JDK但换 JDK 只是开始真正拉大差距的是后续的 JVM 参数调整。我后来在项目里固定的启动参数是这一套java -Xms8g -Xmx8g \ -XX:UseG1GC -XX:MaxGCPauseMillis200 \ -XX:AlwaysPreTouch -XX:UseStringDeduplication \ -XX:EnableCDS -XX:UseAppCDS \ -jar your-service.jar这里面有两个参数值得多说一句。-XX:AlwaysPreTouch的作用是在启动阶段就把堆内存全部物理映射避免运行过程中内存页缺失造成的突然卡顿这在生产环境里对 P99 延迟的帮助非常直观-XX:UseAppCDS则是把类加载的结果缓存下来让第二次及之后的启动速度明显变快一并降低了初始化期的 CPU 峰值。毕昇 JDK 对这些参数的支持和 OpenJDK 完全一致所以迁移成本几乎为零。3.2 C/C 侧编译选项的完整记录C 侧才是重头戏因为整个服务的计算热点都在这边。我给当时的编译脚本留了一份底稿这里简化后贴出来作为在鲲鹏上使用毕昇编译器的参考模板# 使用毕昇编译器的 C 前端 /opt/bisheng/bin/bisheng \ -O3 \ -flto \ -marcharmv8.2-asve \ -fno-strict-aliasing \ -fomit-frame-pointer \ -pipe \ -stdc17 \ -o demo_service main.cpp core.cpp逐个看这些参数的含义-O3是比默认-O2更激进的优化级别会做更多循环展开和函数内联性能敏感代码基本是标配。-flto开启链接时优化LTO让编译器能看到整个程序的调用关系跨编译单元做内联和常量传播。LTO 对性能的提升有时候比-O3还明显缺点是内存消耗比较大后面我会提到一个因此踩过的坑。-marcharmv8.2-asve是告诉编译器可以放心使用 ARMv8.2 架构的扩展指令尤其是 SVE可扩展向量扩展。SVE 是鲲鹏处理器比较吃性能的特性同样的循环用上向量化之后能快好几倍代价是编译产物不能在旧 ARM 芯片上运行。-fno-strict-aliasing是为了照顾项目里历史遗留的一些指针别名问题虽然它会让编译器少做一点优化但能避免一些只在优化开启后才爆出来的奇怪错误。我们这种从 Demo 一路改过来的代码保守一点不丢人。-fomit-frame-pointer减少栈帧开销纯性能向。注意-marcharmv8.2-asve这类指令集参数必须和目标机器的 CPU 匹配。如果编译产物的运行环境不止一种 CPU 型号建议提前确认最老的那台是否支持 SVE否则线上很容易出现 Illegal instruction 类的崩溃。如果追求更极致的性能毕昇编译器还支持 PGO基于性能分析的优化。思路是先编译一个带插桩的版本跑一遍代表性流量生成 profile 数据再用这份数据指导最终编译。命令大致是这样的# 第一步生成插桩版本 /opt/bisheng/bin/bisheng -O3 -fprofile-generate -o demo_prof main.cpp core.cpp # 第二步用代表性数据跑一遍生成 profile ./demo_prof --input ./sample_data.bin # 第三步使用 profile 做最终编译 /opt/bisheng/bin/bisheng -O3 -fprofile-use -o demo_service main.cpp core.cppPGO 的效果非常明显因为编译器可以根据真实运行数据判断哪些分支是热的、哪些循环值得向量化。但需要注意profile 数据必须来自有代表性的流量如果拿一个和线上行为完全不同的测试数据去生成 profile效果甚至会适得其反。3.3 容器与镜像改造编译问题解决之后还有一个绕不过去的环节交付。生产环境用的是容器化部署而我们的旧镜像都是基于 x86 基础镜像构建的。当时最省事的方法是改造成 multi-arch 镜像用 Buildx 在构建机上同时产出 amd64 和 arm64 两个版本docker buildx build --platform linux/amd64,linux/arm64 \ -t registry.internal/demo-service:1.4.0 \ --push .基础镜像方面我最终选择了 openEuler 的官方镜像因为毕昇工具链和 openEuler 处于同一体系很多底层优化能直接对上。实践下来这套组合的兼容性确实最稳。这里有个经验不要盲目迷信“通用”的 Ubuntu ARM 镜像能用同生态体系的基础镜像排查问题会容易得多。还有就是镜像里的 glibc 版本必须和编译环境保持一致否则就会出现运行时缺符号或者版本不匹配的怪问题。4. 实操过程与性能验证4.1 性能测试方案从 Demo 压测到生产压力性能验证我分了三步走避免一上来就被单一数据迷惑。第一步是单模块微基准。针对 C 核心计算模块我写了一个独立的压力测试程序固定输入数据循环跑一百万次对比 GCC 版本和毕昇版本的吞吐量。这个测试的好处是纯粹能直接反映编译器优化的差异。第二步是 Java 接口压测。用 wrk 对 Spring Boot 接口做持续压测分别记录 QPS、P50、P99 和 CPU 占用。这一层面夹杂了 GC、网络、框架开销更接近线上真实情况。第三步才是联合环境测试。把服务完整部署到生产集群里灌入演练流量观察完整链路的指标。这一步最关键因为前面的微基准和接口压测再好都可能在链路层面暴露出来数据库连接池、下游超时、线程池配置之类的新问题。4.2 测试数据与结论这里放一组我整理的对比数据数字经过脱敏量级和趋势是真实的场景GCC 11 OpenJDK毕昇编译器 毕昇 JDK提升幅度C 模块吞吐ops/s12.4 万15.2 万22.6%Java 接口 QPS3200410028.1%P99 延迟ms230158-31.3%平均 CPU 占用86%71%-17.4%我最看重的是 CPU 占用那一行。生产系统不是比谁跑分高而是比同样的硬件能承载多少业务。CPU 从 86% 降到 71%意味着可以把更多流量切到这组机器上或者在高峰期少申请节点。对于以成本为主要驱动力选择 ARM 平台的项目来说这个收益是实实在在的。4.3 上线后的稳定性观察性能数字好看只是第一关真正要命的是稳定性。这个服务上线头两周我一直盯着几个关键指标CPU 有没有异常毛刺、GC 暂停有没有超标、P99 延迟有没有随时间劣化。结果比我预想的要稳。最明显的一个变化是之前用默认参数跑的时候每到高峰期 GC 暂停时间会冲到几百毫秒接口偶发超时换成毕昇 JDK 并调好 G1 参数之后GC 暂停基本稳定在几十毫秒级别P99 曲线也平了。我后来复盘发现这其实不完全是毕昇 JDK 的魔法而是因为换工具链的过程中我们被迫把之前没认真做的 JVM 参数调优补上了。很多时候性能差的根源不是工具不行而是你压根没认真看待它。5. 常见问题与排查技巧实录5.1 问题速查表实操过程中我们遇到了不少问题大部分都能在社区文档里找到答案但有一些是只有亲自踩过才知道的。整理成速查表现象可能原因解决办法编译产物在旧 CPU 上报 Illegal instruction-march指定了过新的指令集确认目标机器 CPU 型号降级为armv8.1-a或去掉sve运行时提示 glibc 版本不存在编译环境 glibc 版本高于运行环境统一基础镜像版本或静态链接相关库JNI 调用崩溃原生库未按目标架构重新编译用毕昇编译器重新编译全部原生依赖LTO 编译时内存爆掉-flto对大工程内存消耗极大限制并行度-flto-jobs2或拆分链接单元AppCDS 归档文件失效JVM 参数或类路径变化重新生成归档并在 CI 中把生成纳入构建流程5.2 印象最深的三个坑第一个坑和 PGO 有关。我们用测试环境的小流量数据生成了 profile结果正式编译出来的版本在线上反而变慢了。排查半天才发现测试流量的热点分布和生产完全不一样——测试环境大量请求走缓存而线上大多是缓存未命中的路径两条路径的代码热权重完全颠倒。从那以后我生成 PGO profile 只用两类数据要么是线上真实流量的采样要么是明确模拟过线上分布的压力数据。第二个坑是 AppCDS 的“失效”陷阱。JDK 换到毕昇之后我们顺手把堆大小和 GC 参数调了一遍然后发现服务启动时日志里多了很多归档加载失败的警告。原因是 CDS 归档文件对 JVM 参数很敏感堆大小、GC 策略变了之后旧的归档就会失效需要重新生成。这个问题在标准 OpenJDK 上同样存在但确实容易在迁移后被忽视。第三个坑是混合编译环境。项目里有一部分第三方库是用 GCC 编的另一部分用毕昇编的链接之后出现了运行时行为不一致的现象。后来我们统一了编译工具链把所有原生依赖都改成毕昇编译器编译问题才彻底消失。这里要提醒一句混用不同编译器的产物在绝大多数情况下没问题但一旦涉及 C ABI 和异常处理的细节风险就会浮出来。生产环境最好统一别给自己留隐患。6. 一些最真实的体会项目跑到今天毕昇已经成了我们这个团队在鲲鹏平台上默认的工具链。回头再看这次从 Demo 到生产的经历我最想说的不是毕昇有多强而是一个项目在 Demo 阶段欠下的债迟早要在生产阶段加倍偿还。那些“先跑起来再说”的默认参数、未跨架构验证的原生依赖、不严谨的性能测试每一个都会在正式环境里变成具体的故障和成本。如果你也在做类似的迁移我的建议是三步走先把 Java 和 C 侧的工具链统一换成目标平台的增强版本再把 JVM 参数和编译参数按照前面的思路调一遍最后一定用贴合线上分布的流量做压测和 PGO。至于毕昇具体能帮你省多少我不会给你画饼——同一个项目可能得到完全不同的数字但方向一定是正的。最后分享一个我个人的操作习惯无论用什么编译器我都会在 CI 流水线里保留一个“标准 GCC 标准 OpenJDK”的对照构建。它的作用不是生产而是作为性能调优的基线参照。每次毕昇版本有更新我都用同一套基准数据重新跑一遍对比确认收益仍然存在也顺便评估新版本带来的风险。工具选择这件事最怕的不是选错而是不知道当初为什么选它。
返回列表