关于 dotnet-build-optimization
用于诊断和改善 .NET SDK 构建性能的技能,面向 SDK 风格项目,涵盖增量构建故障、二进制日志分析、并行构建、构建缓存及还原优化。
主要功能
- 01诊断增量构建故障
- 02分析 MSBuild 二进制日志
- 03配置并行构建和图构建
- 04优化 NuGet 还原与缓存
- 05解释重复构建的常见原因
适用场景
- 01查找源码未变但目标重复运行的原因
- 02检查构建事件和目标耗时
- 03提升大型解决方案的构建性能
- 04缩短 CI 中的依赖还原时间
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-build-optimization安装后在 Claude Code 中输入/dotnet-build-optimization确认已加载。
详细介绍
dotnet-build-optimization
诊断和修复构建性能问题的指南:增量构建失败诊断工作流、使用 MSBuild Structured Log Viewer 分析二进制日志、并行构建配置、构建缓存和还原优化。涵盖从症状(每次构建都进行完整重建)到根本原因(缺少 Inputs/Outputs、时间戳损坏、生成器副作用)再到修复的诊断工作流。
版本假设:.NET 8.0+ SDK(MSBuild 17.8+)。所有示例均使用 SDK 样式的项目。
**范围边界:**本技能负责构建优化和诊断——增量构建失败、二进制日志、并行构建、构建缓存和还原优化。MSBuild 错误解读和 CI 漂移诊断由 [skill:dotnet-build-analysis] 负责。MSBuild 编写(targets、props、items、conditions)由 [skill:dotnet-msbuild-authoring] 负责。自定义任务开发由 [skill:dotnet-msbuild-tasks] 负责。NuGet 锁定文件和 Central Package Management 配置由 [skill:dotnet-project-structure] 负责。
交叉引用:[skill:dotnet-msbuild-authoring] 提供自定义 targets、导入顺序和增量构建编写模式相关内容。[skill:dotnet-msbuild-tasks] 提供自定义任务开发相关内容。[skill:dotnet-build-analysis] 提供 MSBuild 错误、NuGet 还原失败和 CI 漂移诊断相关内容。[skill:dotnet-project-structure] 提供锁定文件、CPM 和 nuget.config 配置相关内容。
增量构建失败诊断
如果某个目标在源代码没有变更时仍会在每次构建时运行,则说明构建不是增量构建。这会浪费时间,也会掩盖真正的变更。诊断工作流遵循一套可重复的模式:检测症状、捕获二进制日志、定位问题目标、确定增量构建失败的原因,然后应用修复。
诊断工作流
1. Symptom: Build takes longer than expected, or output says
"Building target 'X' completely" on every build
2. Capture binary log: dotnet build /bl
3. Open the .binlog in MSBuild Structured Log Viewer
4. Search for targets that ran (not skipped)
5. Check: Does the target have Inputs/Outputs?
- No -> Add Inputs/Outputs (see fix patterns below)
- Yes -> Compare timestamps: are outputs older than inputs?
-> Check for volatile writers or missing output files
6. Apply fix, rebuild, verify target is skipped
步骤 1:捕获二进制日志
# Produce msbuild.binlog in the project directory
dotnet build /bl
# Named log file
dotnet build /bl:build-debug.binlog
# Binary log for restore + build (captures full pipeline)
dotnet build /bl -restore
/bl 开关会将每个 MSBuild 事件——属性求值、项列表、目标进入/退出、任务执行和时间戳——记录为紧凑的二进制格式。二进制日志包含完整源路径和环境变量;请勿将其提交到版本控制系统或公开分享。
步骤 2:在 MSBuild Structured Log Viewer 中打开
从 msbuildlog.com 下载。打开 .binlog 文件。关键视图:
| 视图 | 用途 |
|---|---|
| Timeline | 查看哪些目标并行运行,以及每个目标耗时多久 |
| Target Results | 按“Built”(已运行)与“Skipped”(命中增量构建)筛选 |
| Search | 查找特定目标名称、属性值或文件路径 |
| Properties | 检查构建中任意时刻的已求值属性值 |
| Items | 检查项集合(Compile、Content 等)及其元数据 |
步骤 3:查找非增量目标
在 Structured Log Viewer 中搜索目标名称并检查其结果。应该支持增量构建但却完整运行的目标,会显示“Building target 'X' completely”,并附带原因:
- “Output file does not exist”——预期的输出文件缺失或已删除
- “Input file is newer than output file”——源文件已更改,或前置步骤重写了输出文件
- 未声明 Inputs/Outputs——由于 MSBuild 无法检查文件是否为最新,该目标总是运行
常见增量构建失败模式
自定义目标缺少 Inputs/Outputs
**症状:**自定义目标在每次构建时都会运行。
**根本原因:**目标没有 Inputs/Outputs 属性。没有这些属性,MSBuild 就会无条件运行目标。
**修复:**添加能反映实际读取和写入文件的 Inputs 和 Outputs:
<Target Name="GenerateVersionFile" BeforeTargets="CoreCompile">
<WriteLinesToFile File="$(IntermediateOutputPath)Version.g.cs"
Lines="[assembly: System.Reflection.AssemblyInformationalVersion("$(Version)")]"
Overwrite="true" />
</Target>
<Target Name="GenerateVersionFile"
BeforeTargets="CoreCompile"
Inputs="$(MSBuildProjectFullPath)"
Outputs="$(IntermediateOutputPath)Version.g.cs">
<WriteLinesToFile File="$(IntermediateOutputPath)Version.g.cs"
Lines="[assembly: System.Reflection.AssemblyInformationalVersion("$(Version)")]"
Overwrite="true" />
</Target>
完整的 Inputs/Outputs 模式和批处理说明,请参阅 [skill:dotnet-msbuild-authoring]。
文件复制导致时间戳异常
症状: 由于输出文件的时间戳始终比输入文件新,目标会重复运行。
根本原因: 不带 SkipUnchangedFiles="true" 的 Copy 任务每次复制时都会更新目标文件的时间戳,即使内容相同也是如此。
修复:
<Copy SourceFiles="@(ConfigTemplate)"
DestinationFolder="$(OutputPath)" />
<Copy SourceFiles="@(ConfigTemplate)"
DestinationFolder="$(OutputPath)"
SkipUnchangedFiles="true" />
生成器无条件写入
症状: 即使输入没有变化,代码生成器目标也会在每次构建时运行。
根本原因: 生成器会无条件写入输出文件,即使内容相同也会更新文件的时间戳。下一次构建时,系统会发现“输入比输出新”(因为生成器本身是下游目标的输入)。
修复: 先写入临时文件,仅当内容不同时再进行复制:
<Target Name="GenerateCode"
BeforeTargets="CoreCompile"
Inputs="@(SchemaFile)"
Outputs="@(SchemaFile->'$(IntermediateOutputPath)%(Filename).g.cs')">
<Exec Command="codegen %(SchemaFile.Identity) -o $(IntermediateOutputPath)%(SchemaFile.Filename).g.cs.tmp" />
<Copy SourceFiles="$(IntermediateOutputPath)%(SchemaFile.Filename).g.cs.tmp"
DestinationFiles="$(IntermediateOutputPath)%(SchemaFile.Filename).g.cs"
SkipUnchangedFiles="true" />
</Target>
易变的中间文件
症状: 依赖中间输出的目标会重复运行,因为更早的目标总是重新生成这些文件。
根本原因: 上游目标生成中间文件(例如生成的代码、资源包)时没有正确设置 Inputs/Outputs,导致这些文件在每次构建时都会被重写。下游目标会将它们视为“已更改”,因此重新运行。
修复: 为上游目标添加 Inputs/Outputs。如果上游目标来自 SDK 或 NuGet 包且无法修改,可使用 Touch 任务;当内容未变化时,将其输出的时间戳重置为稳定值。
二进制日志分析
捕获二进制日志
# Basic binary log (outputs msbuild.binlog)
dotnet build /bl
# Named output file
dotnet build /bl:diagnostic.binlog
# Include restore phase
dotnet build /bl -restore
# Detailed verbosity in console + binary log
dotnet build /bl /v:minimal
二进制日志会捕获所有内容,不受 /v: 详细程度级别的影响。/v: 开关仅控制控制台输出。诊断时始终使用 /bl;控制台详细程度适合快速浏览。
预处理项目视图
-pp(预处理)开关会转储经过完整求值的项目文件,其中包含所有导入、条件和属性替换:
# Dump the preprocessed project to stdout
dotnet msbuild MyApp.csproj -pp
# Redirect to a file for easier reading
dotnet msbuild MyApp.csproj -pp > preprocessed.xml
预处理输出会显示:
- 所有导入的
.props和.targets文件及其源路径 - 最终求值后的属性值
- 完成所有 Include/Exclude/Update/Remove 操作后的完整项列表
- 所有条件均已解析的目标定义
无需打开二进制日志,使用 -pp 即可回答“此属性来自哪里?”或“哪个 .targets 文件定义了此目标?”。
二进制日志中的关键诊断搜索
| 搜索查询 | 揭示的信息 |
|---|---|
目标名称(例如 CoreCompile) | 目标是否运行或被跳过,以及原因 |
$property(例如 $TargetFramework) | 构建过程中各个阶段的求值结果 |
文件路径(例如 Order.cs) | 哪些目标处理了该文件,以及处理时间 |
"Building target" | 所有已运行(未跳过)的目标 |
"Skipping target" | 所有被跳过的目标(增量构建命中) |
| 警告/错误文本 | 诊断信息的源位置和构建上下文 |
并行构建
解决方案级并行
MSBuild 可以使用多个工作节点并行构建解决方案中互相独立的项目:
# Use all available CPU cores (default behavior for dotnet build)
dotnet build
# Explicit: 4 worker nodes
dotnet build /m:4
# Single-threaded (useful for debugging build order issues)
dotnet build /m:1
dotnet build 默认启用 /m(多进程)。每个工作节点都是独立的 MSBuild 进程,每次构建一个项目。没有依赖关系的项目会并行构建。
图构建模式
图构建(/graph)会在构建前分析项目依赖关系图,并安排项目以实现最大并行度:
# Graph-aware parallel build
dotnet build /graph
# Graph build with explicit parallelism
dotnet build /graph /m:8
与默认并行构建相比,图模式的优势:
- 静态调度: 提前确定完整的依赖关系图,而不是在构建过程中发现依赖关系
- 避免重复求值: 每个项目只求值一次,而不是每个引用它的项目都求值一次
- 更充分地利用节点: 依赖项满足后,工作节点即可接收项目
对于依赖关系图具有较高并行度的大型解决方案(50 个以上项目),图模式尤其有效。
BuildInParallel 任务属性
单个 MSBuild 任务(如 MSBuild 任务)可以声明是否支持并行调用:
<MSBuild Projects="@(ProjectReference)"
BuildInParallel="true"
Targets="Build" />
BuildInParallel="true" 允许 MSBuild 任务将项目列表分配给可用的工作节点。解决方案生成使用此机制并行编译项目。
诊断并行生成问题
并行生成可能会暴露串行生成所掩盖的潜在问题:
- 共享文件上的竞态条件: 两个项目同时写入同一输出目录。解决方法:为每个项目使用单独的输出目录(SDK 默认值为
bin/Debug/$(TargetFramework)/)。 - 未声明的依赖项: 项目 A 依赖项目 B 的输出,但未声明
<ProjectReference>。串行生成时,项目 B 恰好先生成;并行生成时,项目 A 可能先生成。解决方法:添加显式的<ProjectReference>。 - 目录创建竞态: 多个项目创建同一个中间目录。解决方法:使用
MakeDir任务并设置ContinueOnError="true",或确保每个项目使用自己的$(IntermediateOutputPath)。
使用 /m:1 确认生成可以串行完成,然后使用 /m 检查并行问题。带时间线视图的二进制日志可以显示项目调度情况并揭示竞态条件。
生成缓存和还原优化
NuGet 还原优化
NuGet 还原通常是最慢的生成步骤,在 CI 中尤为明显。以下模式可以缩短还原时间:
# Locked restore: skip resolution if lock file is current
dotnet restore --locked-mode
# Use lock files for deterministic restores
dotnet restore --use-lock-file
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
锁文件还原(--locked-mode)会跳过整个依赖项解析算法,直接读取 packages.lock.json 中的确切版本。这样速度更快,还能确保 CI 使用与本地测试相同的版本。有关锁文件和 CPM 配置的详细信息,请参阅 [skill:dotnet-project-structure]。
SDK 生成缓存
.NET SDK 会缓存多个生成产物,以避免重复工作:
| 缓存 | 位置 | 用途 |
|---|---|---|
| NuGet 全局包 | ~/.nuget/packages/ | 已下载的包内容 |
| NuGet HTTP 缓存 | ~/.local/share/NuGet/http-cache/ | 源查询的 HTTP 响应缓存 |
| MSBuild 项目结果缓存 | 内存中(每个生成会话) | 跳过对已生成项目的重复评估 |
obj/ 中间输出 | 每个项目的 obj/ 目录 | 编译器状态、生成的文件、时间戳 |
CI 生成优化
# GitHub Actions: cache NuGet packages between runs
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
nuget-${{ runner.os }}-
# Use locked restore for speed and determinism
- name: Restore
run: dotnet restore --locked-mode
NoWarn 和 TreatWarningsAsErrors 策略
涉及分析器时,生成级别的警告配置会影响生成时间:
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CA2007</NoWarn>
</PropertyGroup>
警告配置规则:
- 在
Directory.Build.props中启用TreatWarningsAsErrors,确保本地和 CI 生成的行为一致 - 谨慎使用
NoWarn,并始终附上行内说明注释 - 对于单条规则的控制,优先使用
.editorconfig严重性规则,而不是NoWarn - 有关检测警告抑制的误用,请参阅 [skill:dotnet-build-analysis]
Agent 注意事项
-
诊断构建问题时运行
dotnet build却不加/bl。 默认详细程度下的控制台输出会省略有关目标为何运行的关键信息。诊断时务必捕获二进制日志(/bl)——无论控制台详细程度如何,它都会记录所有信息。 -
认为不设置 Inputs/Outputs 也能进行增量构建。 没有
Inputs/Outputs的目标每次构建都会无条件运行。MSBuild 不会隐式进行增量构建——必须声明目标读取和写入哪些文件。完整模式请参阅 [skill:dotnet-msbuild-authoring]。 -
忘记在 Copy 任务中设置
SkipUnchangedFiles="true"。 如果不设置此标志,Copy总会更新目标文件的时间戳,即使文件内容相同,也会触发下游目标重新运行。 -
调查构建问题时使用
/v:diagnostic而不是/bl。 诊断详细程度会向控制台输出数千行内容,难以搜索。二进制日志包含相同信息,且格式结构化、可搜索。请使用/bl和 Structured Log Viewer。 -
未检查就分享
.binlog文件。 二进制日志包含完整文件路径、环境变量值,以及通过 MSBuild 属性传递的潜在机密。对外分享前请检查或清理日志。 -
认为
/m(并行构建)总是更快。 对于小型解决方案(项目少于 5 个),启动工作节点的开销可能超过并行带来的收益。请分别使用和不使用/m进行性能分析并确认。对于大型解决方案,/graph模式的调度效果优于默认的/m。 -
在 CI 中未使用
--locked-mode就提交packages.lock.json。 只有 CI 以锁定模式还原时,锁文件才有用。如果不使用--locked-mode,NuGet 会忽略锁文件并按常规方式解析依赖项,从而无法实现确定性还原。 -
未先检查二进制日志就修改
.csproj属性来改善构建性能。 许多“构建缓慢”问题由单个非增量目标引起,而不是全局构建配置问题。进行大范围配置更改前,请先使用/bl进行诊断。