关于 incremental-build
incremental-build 是一项用于诊断 MSBuild 增量构建行为的技能,涵盖目标意外重跑和 Visual Studio 最新状态检查。它要求通过二进制日志、时间戳、输出跟踪和无改动的重复构建收集证据;除非用户要求修复,否则不修改项目。
主要功能
- 01诊断 MSBuild 的 Inputs 和 Outputs
- 02调查二进制日志和诊断日志
- 03检查时间戳及输出文件是否存在
- 04核对批处理输入与输出的映射
- 05指导检查 Visual Studio 最新状态
- 06验证无改动构建的跳过行为
适用场景
- 01查明 MSBuild 目标重跑的原因
- 02检查生成输出是否已跟踪并可清理
- 03调查 Visual Studio 中的重新构建差异
MCP 相关
该技能将 binlog MCP 服务 Microsoft.AITools.BinlogMcp 指定为检查第二次构建二进制日志的首选方式,并指导使用 load_binlog、search_binlog 等工具。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ skills --skill incremental-build安装后在 Claude Code 中输入/incremental-build确认已加载。
详细介绍
必须涵盖的诊断内容
对于每个诊断或解释请求,最终回答必须明确涵盖以下所有要点,即使直接原因只是缺少
Inputs/Outputs:
- 目标及确切的根因证据;
- 实际输入/输出时间戳比较;
- 缺失、已删除、已清理或被重写的输出;
- 完整的输入,包括动态或易变的输入;
- 已评估的输入/输出路径,以及批处理列表项时的一对一映射;
- 两次完全相同且没有更改的构建,以及预期的第二次构建跳过/过期证据。
除非用户要求修复,否则不要进行编辑。如果无法运行某项检查,请将其标记为未经验证的检查,不要省略,也不要声称检查成功。当缺少 Inputs 或 Outputs 时,应将时间戳比较或一对一映射报告为不适用——未启用增量机制,而不是报告为未经验证的比较。
MSBuild 增量构建的工作原理
MSBuild 的增量构建机制可在输出已是最新状态时跳过目标,从而大幅缩短后续构建的时间。
- 具有
Inputs和Outputs属性的目标:MSBuild 会比较Inputs中所有文件与Outputs中所有文件的时间戳。如果每个输出文件都比每个输入文件新,则会完全跳过该目标。 - 不带
Inputs/Outputs:每次调用构建时都会运行该目标。这是默认行为,也是增量构建缓慢最常见的原因。 - 基于时间戳的比较:MSBuild 使用文件系统时间戳(最后写入时间)来判断文件是否过期。它不使用内容哈希。这意味着触碰文件(仅更新时间戳而不更改内容)会触发重新构建。
<Target Name="Transform"
Inputs="@(TransformFiles)"
Outputs="@(TransformFiles->'$(OutputPath)%(Filename).out')">
</Target>
<Target Name="PrintMessage">
<Message Text="This runs every build" />
</Target>
增量构建失效的原因(主要原因)
-
自定义目标缺少 Inputs/Outputs — 缺少这两个属性时,目标总会运行。这是导致不必要的重新构建的最常见原因。
-
Outputs 路径中包含易变属性 — 如果输出路径中包含会在构建之间发生变化的内容(例如时间戳、构建编号或随机 GUID),MSBuild 将永远找不到上一次的输出,并且总会重新构建。
-
在受跟踪的 Outputs 之外写入文件 — 如果目标写入的文件未列在其
Outputs中,MSBuild 就不会识别这些文件。目标可能会被跳过(因为其声明的输出已是最新状态),但下游目标仍可能被触发。 -
缺少 FileWrites 注册 — 构建期间创建但未注册到
FileWrites项组中的文件不会被dotnet clean清理。久而久之,过期文件可能会干扰增量检查。 -
Glob 发生变化 — 添加或删除源文件时,项集(例如
@(Compile))会发生变化。由于这些项会作为Inputs,输入集也会随之变化并触发重新构建。这是预期行为,但可能令人意外。 -
属性发生变化 — 用于
Inputs或Outputs路径的属性(例如$(Configuration)、$(TargetFramework))在更改时会导致重新构建。Debug 和 Release 之间的切换按设计会触发完整重新构建。 -
NuGet 包更新 — 更改包版本会更新
project.assets.json,并可能更改许多已解析程序集路径。这会改变ResolveAssemblyReferences和CoreCompile的输入,从而触发重新构建。 -
构建服务器 VBCSCompiler 缓存失效 — Roslyn 编译器服务器(
VBCSCompiler)会缓存编译状态。如果服务器被回收(超时、崩溃或手动终止),即使 MSBuild 的增量检查已通过,下次构建也可能会更慢,因为编译器必须重新填充其内存缓存。
诊断“为什么又重新生成了?”
使用二进制日志(binlog)准确了解目标为何执行,而不是被跳过。
在建议修复方案之前,请为每个重新运行的目标报告以下简明清单:
- 实际时间戳比较 — 指出最新的已解析输入和最早的已解析输出及其最后写入时间,并说明哪个输入更新。
- 输出是否存在 — 确认每个声明的输出在第二次构建之前都存在,且未被删除、清理或其他目标改写。
- 输入是否完整 — 验证
Inputs是否包含所有可能导致输出失效的文件,包括易变依赖项或生成的依赖项。如果某个生产者每次构建都会触碰输入,请检查其内容是否发生变化。修复生产者中不必要的重写(例如,仅在内容发生变化时写入);如果相关内容确实发生了变化,则依赖目标必须重新运行。只有在有证据表明某个文件不会影响输出时,才将其排除,绝不能仅仅因为它经常变化就排除。 - 已解析路径是否稳定 — 展开两次构建中的属性和相对路径;输出必须解析到相同位置,且不能包含时间戳、构建编号或 GUID。
- 批处理时是否一一对应 — 对于项转换,将每个输入标识映射到其预期输出,并报告缺失、重复或未匹配的对应项。
- 无更改验证 — 不做任何编辑,连续运行两次相同的构建,并引用第二次构建的跳过或过期原因。
如果用户要求诊断或解释,除非用户要求修复,否则不要编辑项目。如果无法执行构建,请区分已检查的事实和预期行为,也不要声称已应用或验证修复。
WriteLinesToFile 上的 Overwrite="true" 本身并不是增量目标重新运行的原因。只有在 MSBuild 已安排目标执行后,任务才会重写文件;正确设置 Inputs/Outputs 后,可以在任务执行前跳过目标。
使用 binlog 的分步指南
- 不做任何编辑,连续构建两次,并收集第二次构建的结构化证据和文本证据:
第一次构建用于建立基线。第二次构建才是需要实现增量构建的那次。分析dotnet build /bl:first.binlog dotnet build /bl:second.binlog -fl "-flp:v=diag;logfile=second-full.log;performancesummary"second.binlog。
首选:binlog MCP
使用 binlog MCP 服务(Microsoft.AITools.BinlogMcp,通过 binlog MCP 命名空间提供)分析第二份 binlog:
- 使用
load_binlog加载second.binlog。 - 使用
search_binlog搜索 "Building target completely"、"Building target incrementally"、"Skipping target" 和 "is newer than output"。 - 使用
get_target_info_by_name查询重新运行的目标。 - 使用
get_project_target_list确认目标及其项目上下文。 - 只有在了解跳过原因后,才使用
get_expensive_targets对未跳过的目标排序。
后备方案:诊断文本日志(MCP 不可用时)
-
读取第二次构建期间捕获的诊断日志。默认后备方案不要再次运行构建,也不要依赖重放支持:
grep 'Building target\|Target.*was not skipped' second-full.log在完全实现增量构建的情况下,大多数目标都应被跳过。
-
检查未跳过的目标,查找诊断日志中的执行消息。检查表明目标为何运行的“过期”消息。
-
查找关键消息,查看 binlog 中的以下内容:
"Building target 'X' completely"— 表示 MSBuild 未找到输出,或所有输出都缺失;目标将完整执行。"Building target 'X' incrementally"— 表示部分(而非全部)输出已过期。"Skipping target 'X' because all output files are up-to-date"— 表示目标已正确跳过。
-
搜索 "is newer than output" 消息,找出触发重新构建的具体输入文件:
grep "is newer than output" second-full.log这会准确显示哪个输入文件的时间戳导致 MSBuild 认为目标已过期。
其他诊断技巧
- 在 MSBuild Structured Log Viewer 中并排比较
first.binlog和second.binlog,查看有哪些变化。 - 使用
grep 'Target Performance Summary' -A 30 second-full.log,查看第二次构建中耗时最长的目标——这些就是你的优化目标。 - 检查运行时间为零但仍然执行的目标——它们可能存在不必要的依赖关系,导致自身被执行。
FileWrites 与干净构建
FileWrites 项组是 MSBuild 用于跟踪构建过程中生成文件的机制。它为 dotnet clean 提供支持,并有助于保持增量行为正确。
FileWrites项:将自定义目标创建的任何文件注册到此项中,以便dotnet clean知道要删除它们。如果不这样做,生成的文件会在多次构建后不断累积,并可能干扰增量检查。FileWritesShareable项:对于多个项目共享的文件(例如,共享的生成代码),请使用此项。这些文件会被跟踪,但如果仍有其他项目引用它们,则不会被删除。- 如果未注册:文件会在输出目录和中间目录中不断累积。
dotnet clean不会删除这些文件,它们可能导致过期数据问题或干扰最新状态检查。
注册生成文件的模式
在创建生成文件的目标中,将其添加到 FileWrites:
<Target Name="MyGenerator" Inputs="..." Outputs="$(IntermediateOutputPath)generated.cs">
<WriteLinesToFile File="$(IntermediateOutputPath)generated.cs" Lines="@(GeneratedLines)" />
<ItemGroup>
<FileWrites Include="$(IntermediateOutputPath)generated.cs" />
</ItemGroup>
</Target>
Visual Studio 快速最新状态检查
Visual Studio 有自己的最新状态检查(Fast Up-to-Date Check,简称 FUTDC),它与 MSBuild 的 Inputs/Outputs 机制相互独立。了解两者的区别,对于诊断“在 VS 中会重新构建,但在命令行中不会”的问题至关重要。
- VS FUTDC 速度更快,因为它在进程内运行,无需调用 MSBuild,就能检查一组已知项。它会将已知项类型(Compile、Content、EmbeddedResource 等)的时间戳与项目的主要输出进行比较。
- 它可能出错,如果项目使用了自定义生成操作、用于生成文件的自定义目标,或 FUTDC 不认识的非标准项类型。
- 禁用 FUTDC,强制 Visual Studio 使用 MSBuild 的完整增量检查:
<PropertyGroup> <DisableFastUpToDateCheck>true</DisableFastUpToDateCheck> </PropertyGroup> - 诊断 FUTDC 的判断:查看 VS 中的“输出”窗口:转到 Tools → Options → Projects and Solutions → SDK-Style Projects,并将 Up-to-date Checks 日志级别设置为 Verbose 或更高。FUTDC 会准确记录它认为哪些文件已过期。
- 常见的 VS FUTDC 问题:
- 自定义生成操作未向 FUTDC 系统注册
CopyToOutputDirectory项比上次构建的时间更新- 由 FUTDC 未评估的目标动态添加的项
- 已修改且
CopyToOutputDirectory="PreserveNewest"的Content或None项
让自定义目标支持增量构建
以下是结构良好的增量自定义目标的完整示例:
<Target Name="GenerateConfig"
Inputs="$(MSBuildProjectFile);@(ConfigInput)"
Outputs="$(IntermediateOutputPath)config.generated.cs"
BeforeTargets="CoreCompile">
<WriteLinesToFile File="$(IntermediateOutputPath)config.generated.cs" Lines="..." />
<ItemGroup>
<FileWrites Include="$(IntermediateOutputPath)config.generated.cs" />
<Compile Include="$(IntermediateOutputPath)config.generated.cs" />
</ItemGroup>
</Target>
此示例的要点:
Inputs包含$(MSBuildProjectFile):确保项目文件本身发生变化时(例如,影响生成的属性被修改时),目标会重新运行。Inputs包含@(ConfigInput):驱动生成的实际源文件。Outputs使用$(IntermediateOutputPath):生成的文件存放在obj/目录中,该目录由 MSBuild 管理并会自动清理。BeforeTargets="CoreCompile":确保生成的文件在编译器运行前可用。- 注册到
FileWrites:确保dotnet clean删除生成的文件。 - 包含在
Compile中:将生成的文件添加到编译中,无需在评估时就要求该文件存在。
应避免的常见错误
<Target Name="BadTarget" BeforeTargets="CoreCompile">
<Exec Command="generate-code.exe" />
</Target>
<Target Name="BadTarget2"
Inputs="@(Compile)"
Outputs="$(OutputPath)gen_$([System.DateTime]::Now.Ticks).cs">
<Exec Command="generate-code.exe" />
</Target>
<Target Name="GoodTarget"
Inputs="@(Compile)"
Outputs="$(IntermediateOutputPath)generated.cs"
BeforeTargets="CoreCompile">
<Exec Command="generate-code.exe -o $(IntermediateOutputPath)generated.cs" />
<ItemGroup>
<FileWrites Include="$(IntermediateOutputPath)generated.cs" />
<Compile Include="$(IntermediateOutputPath)generated.cs" />
</ItemGroup>
</Target>
性能摘要和预处理
MSBuild 提供了内置工具,可帮助了解当前运行的内容以及运行原因。
-
/clp:PerformanceSummary— 在生成结束时附加摘要,显示每个目标和任务耗费的时间。可用它快速找出开销最大的操作:dotnet build /clp:PerformanceSummary这会显示按累计时间排序的目标表,方便找出不应该在增量生成中运行的目标。
-
/pp:preprocess.xml— 生成一个内联所有导入内容的 XML 文件,展示经过完整求值的项目。要了解定义了哪些目标、属性和项,以及它们的来源,这个工具非常有用:dotnet msbuild /pp:preprocess.xml搜索预处理后的输出,查找任意目标中
Inputs和Outputs的定义,或了解完整的导入链。 -
将两者结合使用,了解正在运行的内容(
PerformanceSummary)和已导入的内容(/pp),再与 binlog 分析交叉对照,便可全面了解情况。
常见修复方法
- 始终为自定义目标添加
Inputs和Outputs— 这是提升增量生成性能最有效的单项改动。缺少这两个属性时,目标每次都会运行。 - 使用
$(IntermediateOutputPath)存放生成的文件 —obj/中的文件会由 MSBuild 的清理基础架构跟踪,不会在不同配置之间残留。 - 在
FileWrites中注册生成的文件 — 确保dotnet clean会将其删除,避免累积过时文件。 - 避免生成器中不必要的易变内容 — 除非有需要,否则不要在文件路径或生成内容中嵌入时间戳、随机值或生成计数器。当内容未变化时,保留生成文件的时间戳(例如,使用
WriteLinesToFile配合Overwrite="true" WriteOnlyWhenDifferent="true")。在Inputs中保留实际依赖项;如果必需的易变内容发生变化,则接受依赖目标重新生成。只有在证明某个文件不会影响输出后,才将其排除。 - 需要传递项但不创建增量生成依赖时,使用
Returns而不是Outputs—Outputs有双重作用:定义增量检查,并返回目标产生的项。如果只需将项传递给调用目标而不影响增量性,请改用Returns:<Target Name="GetFiles" Outputs="@(DiscoveredFiles)">...</Target> <Target Name="GetFiles" Returns="@(DiscoveredFiles)">...</Target>