mcpField
D
dotnet-build-optimization

dotnet-build-optimization

github.com/wshaddix开发8673 次安装35 周前更新
代码仓库

关于 dotnet-build-optimization

用于诊断和改善 .NET SDK 构建性能的技能,面向 SDK 风格项目,涵盖增量构建故障、二进制日志分析、并行构建、构建缓存及还原优化。

主要功能

  1. 01诊断增量构建故障
  2. 02分析 MSBuild 二进制日志
  3. 03配置并行构建和图构建
  4. 04优化 NuGet 还原与缓存
  5. 05解释重复构建的常见原因

适用场景

  1. 01查找源码未变但目标重复运行的原因
  2. 02检查构建事件和目标耗时
  3. 03提升大型解决方案的构建性能
  4. 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(&quot;$(Version)&quot;)]"
                    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(&quot;$(Version)&quot;)]"
                    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 任务将项目列表分配给可用的工作节点。解决方案生成使用此机制并行编译项目。

诊断并行生成问题

并行生成可能会暴露串行生成所掩盖的潜在问题:

  1. 共享文件上的竞态条件: 两个项目同时写入同一输出目录。解决方法:为每个项目使用单独的输出目录(SDK 默认值为 bin/Debug/$(TargetFramework)/)。
  2. 未声明的依赖项: 项目 A 依赖项目 B 的输出,但未声明 <ProjectReference>。串行生成时,项目 B 恰好先生成;并行生成时,项目 A 可能先生成。解决方法:添加显式的 <ProjectReference>。
  3. 目录创建竞态: 多个项目创建同一个中间目录。解决方法:使用 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 注意事项

  1. 诊断构建问题时运行 dotnet build 却不加 /bl。 默认详细程度下的控制台输出会省略有关目标为何运行的关键信息。诊断时务必捕获二进制日志(/bl)——无论控制台详细程度如何,它都会记录所有信息。

  2. 认为不设置 Inputs/Outputs 也能进行增量构建。 没有 Inputs/Outputs 的目标每次构建都会无条件运行。MSBuild 不会隐式进行增量构建——必须声明目标读取和写入哪些文件。完整模式请参阅 [skill:dotnet-msbuild-authoring]。

  3. 忘记在 Copy 任务中设置 SkipUnchangedFiles="true"。 如果不设置此标志,Copy 总会更新目标文件的时间戳,即使文件内容相同,也会触发下游目标重新运行。

  4. 调查构建问题时使用 /v:diagnostic 而不是 /bl。 诊断详细程度会向控制台输出数千行内容,难以搜索。二进制日志包含相同信息,且格式结构化、可搜索。请使用 /bl 和 Structured Log Viewer。

  5. 未检查就分享 .binlog 文件。 二进制日志包含完整文件路径、环境变量值,以及通过 MSBuild 属性传递的潜在机密。对外分享前请检查或清理日志。

  6. 认为 /m(并行构建)总是更快。 对于小型解决方案(项目少于 5 个),启动工作节点的开销可能超过并行带来的收益。请分别使用和不使用 /m 进行性能分析并确认。对于大型解决方案,/graph 模式的调度效果优于默认的 /m。

  7. 在 CI 中未使用 --locked-mode 就提交 packages.lock.json。 只有 CI 以锁定模式还原时,锁文件才有用。如果不使用 --locked-mode,NuGet 会忽略锁文件并按常规方式解析依赖项,从而无法实现确定性还原。

  8. 未先检查二进制日志就修改 .csproj 属性来改善构建性能。 许多“构建缓慢”问题由单个非增量目标引起,而不是全局构建配置问题。进行大范围配置更改前,请先使用 /bl 进行诊断。


参考资料

评论

支持 Markdown