ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Visual Studio 编译速度优化:加载、编译、调试三段时间拆解

Visual Studio 编译速度优化:加载、编译、调试三段时间拆解 改一个头文件全量编译跑八分钟按一下 F5光等调试器起来就喝完了半杯咖啡。这种事在 Visual Studio 里太常见了常见到很多人已经默认VS 就是慢然后把时间浪费在等进度条上。我不这么看。大部分所谓的Visual Studio 编译速度慢其实是三个完全不同的问题被混成了一句抱怨解决方案加载慢、编译链接慢、调试启动慢。这三件事的成因、观测手段和优化手段几乎没有交集混在一起调只会越调越乱。下面这套东西是我在几个十万行级别的 C 工程和几十个项目的 .NET 解决方案上反复折腾之后沉淀下来的口径和操作从怎么测、怎么定位到具体开关怎么改、改完怎么验证尽量都落到能直接抄的程度。1. 先量化再动手把编译慢拆成可测量的三段时间优化最怕凭感觉。我见过团队花了两个月重构项目结构结果一测收益只有 7%因为真正的瓶颈其实是杀毒软件在扫描.obj目录。所以第一步永远是拿到数字而且必须是可复现、可对比的数字。1.1 加载、编译、调试启动三件必须分开记账的事先用一张表把这三段时间的典型成因对齐一下后面所有操作都围绕这张表展开阶段典型耗时来源第一手观测入口解决方案加载源代码管理插件轮询、设计器加载、大型 IntelliSense 数据库、.vs目录在慢盘上状态栏提示、任务管理器磁盘 IO、devenv.exe内存占用编译与链接cl.exe / link.exe 本身、头文件扩散、代码分析、分析器与源生成器、生成事件里的外部工具生成输出的详细级别日志、binlog、Build Insights调试启动符号加载策略、诊断工具窗口、编辑并继续、热重载初始化输出窗口的调试频道、调试器附加耗时我通常建议先做一次基线测量在完全干净的机器状态刚重启、没开浏览器、网盘同步已暂停下记录四个指标——解决方案加载到可编辑的秒数、改一个无依赖的.cpp后增量编译的秒数、全量 Rebuild 的秒数、按 F5 到第一行代码命中的秒数。这四个数就是后续所有优化的裁判。注意基线一定要在同一个配置、同一台机器、同一份代码版本上取。跨机器比数字没有意义笔记本插不插电源都能差出 40%。1.2 用 MSBuild 自带的口径拿到第一手数据Visual Studio 的界面只告诉你正在生成不告诉你时间花在哪。MSBuild 命令行提供了更细的口径直接在开发者命令提示符里跑msbuild MySolution.sln /m /p:ConfigurationDebug /clp:PerformanceSummary;ShowTimestampPerformanceSummary会在构建结束后打印每个 Target 和 Task 的耗时排序ShowTimestamp给每行输出加上时间戳。这两个参数组合起来一眼就能看出是某一个自定义 Task 拖了 60 秒还是cl.exe 均匀地慢。如果要用图形化界面测把详细级别调高工具 → 选项 → 项目和解决方案 → 生成并运行 → MSBuild 项目生成输出详细级别改成详细。然后在输出窗口的生成频道里找Time Elapsed和各个任务的开始结束时间。这一步的信息量比很多人想象的大——比如你会突然发现每次构建都在跑一个RestorePackages目标而它其实什么都不需要做。1.3 Binlog 和 Build Insights定位到具体文件、具体 Target详细日志是文本流看久了会晕。更舒服的做法是生成 binlogmsbuild MySolution.sln /m /bl:build.binlog然后用 MSBuild Structured Log Viewer 打开这是社区里公认最好用的工具免费。它能看甘特图直接暴露项目之间的依赖是不是把并行度卡死了——很多看起来该并行的解决方案实际上因为一条隐式的项目引用编译阶段只有两个项目在跑。C 工程还有更专业的工具C Build Insights在 Visual Studio 安装程序里通过单个组件勾选安装。装完之后在 VS 的分析菜单里能看到Build Insights其中Build Time视图按文件排序列出编译耗时Files视图能看出哪个头文件被解析了多少次。这个数据往往令人震惊一个只有 200 行的.cpp因为#include了一个公共大头部解析了 30 万行代码。还嫌不够细的话随 MSVC 工具集一起安装的命令行采集工具也可以做无界面采集适合放进 CI 里长期监控。2. C 工程里的三个时间黑洞多数人从没打开看过C 项目的编译时间对配置极度敏感同样一份代码开关改几个字编译时间能差三到五倍。下面这三类最值得优先排查。2.1 头文件扩散与预编译头的位置选择头文件扩散是 C 编译慢的头号原因也是最难靠某个开关解决的问题。诊断方法是打开/showIncludes让编译器把每个源文件展开的头文件列表全部打出来配合结构化日志统计每个头文件被包含的次数和总展开量。你会发现真正的元凶通常是几个人人都要用、但内容很重的公共头日志宏、类型定义、第三方库的完整头。处理思路有三条按投入产出比排序把明显只用到指针或引用的类型改成前置声明把头文件包含挪到.cpp里检查公共头有没有把实现也塞进去了模板以外的内联函数、静态对象定义这类东西会让每个包含它的编译单元都重复生成代码预编译头要薄而稳。预编译头/Yc生成、/Yu使用配合/Fp指定 pch 文件路径的价值在于把一坨不常变的头部只解析一次所以内容选择比体积重要放了每天都会改的自家头等于每次改动都让整个 pch 失效、全量重编。我一般的做法是把标准库、平台 SDK、稳定的第三方库放进 pch自家业务头一律不进。如果你用的是 CMake从 3.16 起有target_precompile_headers可以按 target 声明预编译头跨平台还能自动切换成 GCC/Clang 的 pch 机制比手写/Yc/Yu干净得多。另外还有个大杀器是合并编译单元Unity Build把多个.cpp拼成一个再编译CMake 里开启UNITY_BUILD并配合UNITY_BUILD_BATCH_SIZE控制每组文件数。它能把编译时间砍掉一半甚至更多代价是容易出现静态变量和匿名命名空间重名冲突而且改一个文件会牵动整组重编只适合稳定代码或 CI 的全量构建不适合日常开发迭代。2.2 /analyze、/FR、/ZI 这些看起来无害的开关Visual Studio 的项目属性模板会给新项目勾上一堆好东西它们在功能上是加分项在时间上是纯负债。本地开发配置里我通常会关掉这些开关 / 选项位置影响/analyzeC Core Guidelines 检查、代码分析C/C → 常规 → 启用代码分析编译时间可能变成 3 到 10 倍这是单点收益最大的一项/FR浏览器信息C/C → 常规 → 浏览信息编译期额外写库文件调试体验换来的时间很贵/ZI编辑并继续C/C → 常规 → 调试信息格式生成更重的 PDB链接和调试都跟着变慢/GL全程序优化C/C → 优化 → 全程序优化链接阶段做 LTCGRelease 链接时间成倍增长自定义生成步骤 / 生成事件生成事件目录每次构建无条件执行最常见的隐形杀手/analyze是典型的CI 里开着、本地也该开着但实在等不起的选项。我的处理方式是本地 Debug 配置关闭Release 配置关闭单独建一个Analysis配置或者放到 CI 流水线里跑。这样代码质量没有失控日常开发也不会被拖死。/ZI换成/Zi之后要记得关掉编辑并继续否则会得到一个警告并且实际还是在走/ZI的路径。2.3 链接阶段的增量构建与 FASTLINK链接慢的人往往搞错了方向以为是 CPU 不行其实大部分时间是磁盘 IO 和 PDB 写入。几个实际有效的做法Debug 配置保留/INCREMENTAL增量链接不要为了让二进制干净而关掉它用/DEBUG:FASTLINK生成精简 PDB。VS 2017 之后的 x64 Debug 配置默认就走这条路但如果项目是从老版本升级上来的很可能还写着/DEBUG改成 FASTLINK 后链接时间经常能砍掉一半。缺点是部分老旧的调试工具和崩溃分析工具读不了这种 PDB发布版本不要用打开/Gy函数级链接并配合 Release 的/OPT:REF、/OPT:ICF能减小最终体积同时减轻链接阶段的符号处理压力确定不用全程序优化时把/GL关掉。LTCG 的链接时间和代码规模基本是超线性关系工程越大越难受。还有一个容易被忽略的点/MP多处理器编译和预编译头、某些老式优化开关存在冲突。如果开了/MP反而更慢或者报奇怪的错先检查是不是同时开了不兼容的选项。另外/MP会让每个编译进程各占几百 MB 到一 GB 以上的内存物理内存不够时系统开始换页速度会断崖式下跌——这是加了并行反而更慢最常见的原因。3. .NET 解决方案从快速最新检查到分析器开销.NET 项目的慢法和 C 完全不是一回事。C 慢在编译单元.NET 慢在明明没改什么它却非要重新编译一遍以及各种在构建管道里搭便车的分析器。3.1 快速最新检查为什么失效以及怎么读出原因SDK 风格项目Microsoft.NET.Sdk有一套快速最新检查机制简称 FUTDC。它的工作方式是在构建前快速对比输入输出如果判断没有变化就直接跳过 MSBuild 的完整评估流程打印一串项目是最新的。这是 .NET 增量构建快的关键。但它非常容易被打破而且打破之后你只会看到构建变慢看不到任何报错。常见原因有这么几类输出目录里的文件被外部工具改写或删除比如某个脚本在构建后覆盖了bin下的配置None或Content里声明了CopyToOutputDirectory而这些文件的时间戳在每次同步或生成时都会变代码生成器、模板引擎、资源压缩工具是重灾区项目里用了通配符包含文件新文件一加就触发全量评估生成事件的最后一步修改了输出目录的时间戳某个源生成器每次生成的中间文件内容或时间戳不稳定。诊断方法很直接把构建输出的详细级别调到详细搜索项目名称和不是最新的VS 会明确告诉你哪一项对不上比如输入文件 X 比输出文件 Y 新。找到那一项之后要么把它从检查范围里排除要么修掉产生它的工具。这个功能本身在工具 → 选项 → 项目和解决方案 → SDK 风格项目下可以开关。除非你有非常明确的理由否则不要关掉它——关掉之后每次构建都走完整 MSBuild 评估通常会慢好几倍。3.2 Roslyn 分析器和源生成器到底吃掉了多少时间Roslyn 分析器在 IDE 里的实时分析和构建时的批量分析是两条路径。如果你在项目里装了一堆代码规范分析器构建时的开销会非常可观几十个项目的解决方案上跑满一分钟都不稀奇。可以这样在项目文件里做区分让分析器只服务于编辑器体验不拖慢构建PropertyGroup RunAnalyzersDuringBuildfalse/RunAnalyzersDuringBuild RunAnalyzersDuringLiveAnalysistrue/RunAnalyzersDuringLiveAnalysis EnforceCodeStyleInBuildfalse/EnforceCodeStyleInBuild /PropertyGroup这里RunAnalyzersDuringBuild控制构建时是否运行分析器EnforceCodeStyleInBuild控制.editorconfig里的代码风格规则是否在构建时参与报错/警告。把这两项关掉编辑器里的波浪线还在构建速度能立刻回来。规范检查应该交给 CI本地开发不该为它买单。源生成器Source Generator是另一块开销。它每次编译都要跑生成的内容如果参与编译还会影响增量判断。如果发现某个生成器特别慢可以在生成器项目里开启增量生成器IIncrementalGenerator来缓解或者把它的输出落到中间目录并纳入 FUTDC 的检查范围。实在不行的考虑把它改成预生成在解决方案里放一个专门的生成工具项目构建时手动或按需触发。3.3 解决方案过滤器和运行时只生成启动项目大解决方案的加载时间和构建评估时间和项目数量基本是正相关的。如果你日常只改其中五个项目就没有理由让另外一百二十个项目参与加载和评估。解决方案筛选器.slnf是这里最省事的解法在解决方案资源管理器右键 → 创建解决方案筛选器勾选你关心的项目生成一个.slnf文件。以后打开.slnf而不是.slnVS 只会加载筛选出来的项目加载时间和 IntelliSense 内存占用都能明显下降。这个文件可以签进仓库团队成员各自按模块打开自己的筛选器。还有一个开关几乎所有人都该打开工具 → 选项 → 项目和解决方案 → 生成并运行 → 运行时仅生成启动项目和依赖项。默认情况下按 F5 会构建整个解决方案而实际上你只需要启动项目加它的依赖链。打开这个选项按 F5 的等待时间经常能从一分钟降到几秒。另外老式的packages.config项目每次构建都会跑一遍RestorePackages目标白白浪费几秒到几十秒。这类项目建议迁移到PackageReference短期无法迁移的先去详细日志里确认它是否每次都执行再评估通过配置跳过的可行性。4. 让自定义构建步骤真正增量而不是每次都无条件执行标题里那些编译关键词很多团队的实际痛点其实不在编译器而在编译器之外——前端构建、资源压缩、代码生成、协议生成。这些东西一旦以生成事件的形式挂在构建流程里就变成了每次构建都要交的税。4.1 生成前事件为什么每次都会跑PreBuildEvent和PostBuildEvent这两类事件MSBuild 在执行时是无条件的它不做任何输入输出比对。所以只要你在里面写了npm run build、sass、protoc那么无论你有没有改前端代码每次按 F5 都要等一遍。更糟的是这类工具通常会更新输出文件的时间戳进而让 FUTDC 或者 C 的增量判断失效导致连编译本身都变成全量。这就是所谓一个生成事件毁掉整条增量链。我见过的典型症状是前端同学改一行 CSS后端同学拉代码后发现自己这边的构建突然从 5 秒变成 90 秒查半天查不出原因。根因就在这儿。4.2 用 Inputs/Outputs 写一个可跳过的 Target正确做法是用 MSBuild 的Target配合Inputs和Outputs属性让 MSBuild 自己做时间戳比对。写法大致是这样ItemGroup FrontendSource Includeweb\src\**\*.* / FrontendSource Includeweb\package.json / FrontendSource Includeweb\webpack.config.js / /ItemGroup Target NameBuildFrontend BeforeTargetsBuild Inputs(FrontendSource) Outputs$(MSBuildProjectDirectory)\wwwroot\app.bundle.js Exec Commandnpm run build:dev WorkingDirectory$(MSBuildProjectDirectory)\web / /Target这段配置的含义是只要Inputs里所有文件的时间戳都不比Outputs里那个产物新MSBuild 就会打印跳过目标 BuildFrontend并直接返回一毫秒都不花。只有真正改了前端源码或构建配置时才会执行。几个实操要点注意Inputs/Outputs是整体比对只要有一个输入更新整个 Target 就会完整重跑一遍它不做文件级的部分增量。所以输入集合要尽量收窄别把整个node_modules或者日志目录写进去。注意Outputs必须填写真实存在且时间戳可靠的产物路径。如果构建脚本内部会先删除再重建产物时间戳逻辑依然成立但如果脚本因为失败而没有生成产物MSBuild 下次会重新执行这正好是想要的行为。另外Exec任务失败时的错误信息往往只有一行命令已退出代码为 X非常不利于排查。建议在Exec里显式加ContinueOnError或者给脚本加详细输出让失败原因直接出现在构建日志里。4.3 前端构建链的正确挂载姿势对于 Sass、TypeScript、Webpack、Vite 这类工具有个更根本的原则日常开发不要把它们的完整构建塞进 VS 的构建流程。正确姿势是另开一个终端跑 watch 模式让它持续监听文件变化并增量输出VS 这边只负责把产物目录当成已存在的资源。这样改一行样式前端几百毫秒就编译完了根本不需要等 VS 的构建。只有在 CI 的全量构建里才需要把完整的前端构建挂成 MSBuild Target而且同样要用Inputs/Outputs做条件判断。这样本地开发不必等流水线上又不会漏构建。我在几个项目里做过这个改造本地按 F5 的等待时间从平均 40 秒降到了 4 秒左右改动量其实只有几十行 XML。5. 环境层面那些不进代码库、却决定一半速度的东西代码和配置优化完了还有一层常被完全忽视的变量机器环境和构建并行度。这部分不是玄学是可量化的。5.1 实时防护、云同步目录和 .vs 缓存实时防护是编译性能的头号环境杀手。C 编译会产生海量小文件.obj、.pdb、.ilk、各种中间文件每一次打开、写入、关闭都要过一次扫描钩子。给下面这些路径和进程加排除项通常是所有优化里回报最高的单项操作类型建议加入排除的内容目录项目根目录、bin、obj、%USERPROFILE%\.nuget\packages、%LOCALAPPDATA%\Microsoft\VisualStudio、%TEMP%进程devenv.exe、MSBuild.exe、cl.exe、link.exe、VBCSCompiler.exe、ServiceHub.*第二类杀手是把代码仓库放在云同步目录里。同步客户端会持续扫描、加锁、读取文件和编译器的文件访问直接打架表现是同一份代码在同事机器上很快在我这儿很慢。把仓库挪出同步目录或者至少给仓库目录设置成不同步能立刻消除这个问题。第三类是.vs目录。它存着 IntelliSense 数据库、解决方案相关的缓存路径记录里通常保存在解决方案同级。如果解决方案放在网络盘、机械盘或者同步盘上这个数据库的读写会成为持续性的拖累。我一般这样做确认.vs已经在本地 SSD 上定期在关闭 VS 后整个删掉重建它会在下次打开时自动生成如果出现 IntelliSense 卡顿、CPU 长期高占用而构建又没问题删掉它往往比任何设置都管用。同理项目属性里可以配置 IntelliSense 的回退位置把数据库落到本地快速盘。5.2 并行度的实际上限在哪并行构建有几个独立的控制点很多人只知道其中一两个MSBuild 命令行用/m使用全部处理器或/m:N指定最大并发数Visual Studio 里对应的是工具 → 选项 → 项目和解决方案 → 生成并运行 → 最大并行项目生成数C 编译单元级别的并行是/MP和上面两个是叠加关系。理论上是核越多越快实际上有个明显的拐点。原因有三个一是每个cl.exe实例的内存需求8 个并发在 16 GB 的机器上很容易触发换页性能反而更差二是解决方案的项目依赖图通常会让并行度上不去这时候增加并发数毫无意义三是磁盘 IO 会先饱和尤其是机械盘或者虚拟机的共享存储。判断方法很简单用 binlog 看甘特图。如果图上是明显的阶梯状说明并行度没吃满瓶颈在依赖链应该去拆依赖或者用解决方案筛选器减少参与项目如果图上是一片密集但整体很慢瓶颈在 IO 或者内存应该降并发数、换盘。实测经验是先按物理核数设置观察内存占用如果提交内存接近物理内存上限就把并发数减半这通常比硬堆并发更快。5.3 编译缓存和更紧凑的构建调度如果同一份代码需要反复全量编译比如频繁切换分支、跑回归编译缓存的价值非常大。原理和 ccache 一样把源文件加编译参数的哈希作为键缓存生成的.obj命中时直接复用。在 CMake 工程里挂载非常简单cmake -B build -G Ninja ^ -DCMAKE_C_COMPILER_LAUNCHERsccache ^ -DCMAKE_CXX_COMPILER_LAUNCHERsccache ninja -C build要注意几个前提缓存工具对/Zi的支持一般不好调试信息在链接期写 PDB和缓存模型冲突通常需要改成/Z7把调试信息放进.obj预编译头和/MP与缓存的兼容性也需要实测确认。另外缓存目录要放在本地盘放网络盘等于白干。生成器选择上CMake 工程用 Ninja 生成器通常比 MSBuild 生成器快一截因为 Ninja 的调度开销更小、依赖图更紧凑。Visual Studio 对 CMake 项目支持直接选择 Ninja 生成器调试体验不受影响值得一试。至于分布式编译方案团队规模到一定程度可以考虑但对个人开发者来说把前面几节做好收益已经足够。6. 改完怎么验证以及别把构建搞坏优化做到这一步最危险的动作是一次改十项然后一起测。一旦出问题你根本不知道是哪一项引起的。我的做法永远是单变量改一项跑一次增量构建和一次全量 Rebuild记录数字确认没引入新错误再做下一项。6.1 用固定口径验证收益别凭感觉建议在仓库根目录放一个简单的记录表每次调整都填一行。表的结构可以是这样变更项解决方案加载增量构建全量 RebuildF5 到断点备注基线42s95s7m20s38s关闭云同步前加实时防护排除40s61s5m10s36s收益最大关闭本地 /analyze40s61s3m05s36s只影响全量前端构建改为增量 Target40s9s3m05s8s增量收益巨大启用解决方案筛选器17s8s2m50s7s加载收益巨大数字不用多精确同一台机器上前后对比就够。这张表还有一个额外作用当有人质疑改这些配置有必要吗你直接把表拍过去。我在团队里推这套东西时最有说服力的从来不是讲道理而是这张表。需要强调的是改一个文件后增量构建这个指标比全量 Rebuild重要得多因为日常开发 95% 的时间都在做前者。全量构建只在切换分支、拉新代码、CI 上才发生。优化优先级应该完全照着这个比例来排。6.2 MSB6006 这类报错的排查顺序优化过程中改动了构建配置很可能撞上MSB6006——cmd.exe 已退出代码为 X这类错误。这个错误本身信息量极低它只说明 MSBuild 启动的某个工具返回了非零退出码不告诉你是谁、为什么。排查顺序我一般是这样走的把构建详细级别调到详细找到报错前最后执行的那条命令行。这是唯一可靠的定位手段没有捷径。把那条命令原样复制到命令行窗口里手动执行。能不能复现决定了问题在脚本本身还是在 VS 的调用环境。检查工作目录。Exec任务默认的工作目录是项目目录但脚本里的相对路径往往是按脚本所在目录写的。这两者不一致时脚本会找不到文件然后返回一个毫无意义的退出码。检查路径中的空格、中文和特殊字符。老式工具链对带空格路径的处理经常出问题尤其是自定义生成步骤里手写的命令行。路径用引号包起来、或者干脆把工程放到纯 ASCII 无空格路径下测试能快速排除这一类。检查是不是被安全软件拦了。脚本被拦截时通常表现为手动能跑、构建里跑不了。检查命令行长度。Windows 的命令行长度上限是 8191 个字符源文件一多、宏定义一多就容易超。解法是用响应文件把参数写进.rsp文件再用参数文件引用。检查是不是编译器自身崩溃。如果是cl.exe而不是脚本报错优先怀疑内存不足并发过高和预编译头不匹配这会给出更具体的 C1853 之类错误。注意cmd.exe返回码的具体含义取决于脚本自己怎么写。如果脚本里没有正确处理和传播错误码退出码可能和真实原因毫无关系。写到Exec里的脚本最后一行务必要有明确的错误码返回逻辑否则下次排查还是抓瞎。6.3 把有效配置固化进仓库别只修自己那台机器一个人调快了没有意义如果没有固化下来同事拉一份新代码、换一台机器一切又回到原点。所以最后一步是把验证有效的配置写进版本控制C 的编译选项和预编译头配置写进项目文件或者.props本地个人偏好不要覆盖团队共识SDK 风格项目的分析器开关写进Directory.Build.props一次生效全解决方案前端构建的增量 Target 放进项目文件或公共.targets解决方案筛选器.slnf文件提交到仓库实时防护排除、Ninja 生成器这类环境相关的操作写成一份简短的README或者初始化脚本放进仓库的docs或tools目录。我个人在实践中体会到的一点是编译速度的优化八成收益来自两类事情——把不必要的工作彻底跳过增量判断、筛选器、关闭分析以及把文件系统的摩擦降到最低实时防护排除、本地 SSD、清理 .vs。真正需要动编译器参数的机会比想象中少。剩下的两成才是/MP、/analyze、/GL这些开关的取舍。还有个小技巧值得分享如果团队里有人抱怨我的机器就是慢先别让他改配置让他把任务管理器打开观察构建时的磁盘队列长度和内存提交量。这两个数字往往能直接把问题定性——队列长度长期接近或超过 2就是 IO 被打爆了换盘或者减并发内存提交量贴着物理上限就是并发过头了。比任何猜测都快。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进