incremental-build

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

How MSBuild Incremental Build Works

MSBuild增量构建的工作原理

MSBuild's incremental build mechanism allows targets to be skipped when their outputs are already up to date, dramatically reducing build times on subsequent runs.
  • Targets with
    Inputs
    and
    Outputs
    attributes
    : MSBuild compares the timestamps of all files listed in
    Inputs
    against all files listed in
    Outputs
    . If every output file is newer than every input file, the target is skipped entirely.
  • Without
    Inputs
    /
    Outputs
    : The target runs every time the build is invoked. This is the default behavior and the most common cause of slow incremental builds.
  • Incremental
    attribute on targets
    : Targets can explicitly opt in or out of incremental behavior. Setting
    Incremental="false"
    forces the target to always run, even if
    Inputs
    and
    Outputs
    are specified.
  • Timestamp-based comparison: MSBuild uses file system timestamps (last write time) to determine staleness. It does not use content hashes. This means touching a file (updating its timestamp without changing content) will trigger a rebuild.
xml
<!-- This target is incremental: skipped if Output is newer than all Inputs -->
<Target Name="Transform"
        Inputs="@(TransformFiles)"
        Outputs="@(TransformFiles->'$(OutputPath)%(Filename).out')">
  <!-- work here -->
</Target>

<!-- This target always runs because it has no Inputs/Outputs -->
<Target Name="PrintMessage">
  <Message Text="This runs every build" />
</Target>
MSBuild的增量构建机制允许在输出文件已处于最新状态时跳过目标执行,大幅缩短后续构建的耗时。
  • 带有
    Inputs
    Outputs
    属性的目标
    :MSBuild会将
    Inputs
    中列出的所有文件的时间戳与
    Outputs
    中的所有文件进行比较。如果每个输出文件的时间戳都晚于所有输入文件,该目标将被完全跳过。
  • Inputs
    /
    Outputs
    的目标
    :每次触发构建时该目标都会运行。这是默认行为,也是增量构建速度慢的最常见原因。
  • 目标的
    Incremental
    属性
    :目标可以显式启用或禁用增量行为。设置
    Incremental="false"
    会强制目标始终运行,即使已指定
    Inputs
    Outputs
  • 基于时间戳的比较:MSBuild使用文件系统时间戳(最后写入时间)来判断文件是否过期,不使用内容哈希。这意味着仅修改文件时间戳(不更改内容)也会触发重建。
xml
<!-- This target is incremental: skipped if Output is newer than all Inputs -->
<Target Name="Transform"
        Inputs="@(TransformFiles)"
        Outputs="@(TransformFiles->'$(OutputPath)%(Filename).out')">
  <!-- work here -->
</Target>

<!-- This target always runs because it has no Inputs/Outputs -->
<Target Name="PrintMessage">
  <Message Text="This runs every build" />
</Target>

Why Incremental Builds Break (Top Causes)

增量构建失效的常见原因

  1. Missing Inputs/Outputs on custom targets — Without both attributes, the target always runs. This is the single most common cause of unnecessary rebuilds.
  2. Volatile properties in Outputs path — If the output path includes something that changes between builds (e.g., a timestamp, build number, or random GUID), MSBuild will never find the previous output and will always rebuild.
  3. File writes outside of tracked Outputs — If a target writes files that aren't listed in its
    Outputs
    , MSBuild doesn't know about them. The target may be skipped (because its declared outputs are up to date), but downstream targets may still be triggered.
  4. Missing FileWrites registration — Files created during the build but not registered in the
    FileWrites
    item group won't be cleaned by
    dotnet clean
    . Over time, stale files can confuse incremental checks.
  5. Glob changes — When you add or remove source files, the item set (e.g.,
    @(Compile)
    ) changes. Since these items feed into
    Inputs
    , the set of inputs changes and triggers a rebuild. This is expected behavior but can be surprising.
  6. Property changes — Properties that feed into
    Inputs
    or
    Outputs
    paths (e.g.,
    $(Configuration)
    ,
    $(TargetFramework)
    ) will cause rebuilds when changed. Switching between Debug and Release is a full rebuild by design.
  7. NuGet package updates — Changing a package version updates
    project.assets.json
    and potentially many resolved assembly paths. This changes the inputs to
    ResolveAssemblyReferences
    and
    CoreCompile
    , triggering a rebuild.
  8. Build server VBCSCompiler cache invalidation — The Roslyn compiler server (
    VBCSCompiler
    ) caches compilation state. If the server is recycled (timeout, crash, or manual kill), the next build may be slower even though MSBuild's incremental checks pass, because the compiler must repopulate its in-memory caches.
  1. 自定义目标缺少Inputs/Outputs — 若未同时配置这两个属性,目标会每次都运行。这是导致不必要重建的最常见原因。
  2. 输出路径包含易变属性 — 如果输出路径包含构建间会变化的内容(如时间戳、构建编号或随机GUID),MSBuild将永远无法找到之前的输出文件,从而每次都会触发重建。
  3. 在追踪的Outputs之外写入文件 — 如果目标写入的文件未列入其
    Outputs
    ,MSBuild将无法识别这些文件。即使该目标因声明的输出已更新而被跳过,下游目标仍可能被触发。
  4. 未注册FileWrites — 构建过程中创建但未注册到
    FileWrites
    项组的文件,无法通过
    dotnet clean
    清理。随着时间推移,过期文件会干扰增量检查。
  5. 通配符匹配变化 — 当添加或移除源文件时,项集(如
    @(Compile)
    )会发生变化。由于这些项会作为
    Inputs
    的输入,输入集的变化会触发重建。这是预期行为,但可能会让用户感到意外。
  6. 属性变化 — 影响
    Inputs
    Outputs
    路径的属性(如
    $(Configuration)
    $(TargetFramework)
    )发生变化时,会触发重建。在Debug和Release模式间切换会触发完整重建,这是设计使然。
  7. NuGet包更新 — 更改包版本会更新
    project.assets.json
    ,并可能改变许多已解析程序集的路径。这会导致
    ResolveAssemblyReferences
    CoreCompile
    的输入发生变化,从而触发重建。
  8. 构建服务器VBCSCompiler缓存失效 — Roslyn编译器服务器(
    VBCSCompiler
    )会缓存编译状态。如果服务器被回收(超时、崩溃或手动终止),即使MSBuild的增量检查通过,下一次构建也可能变慢,因为编译器需要重新填充内存中的缓存。

Diagnosing "Why Did This Rebuild?"

诊断「为何触发了重建?」

Use binary logs (binlogs) to understand exactly why targets ran instead of being skipped.
使用二进制日志(binlog)可以准确了解目标未被跳过而运行的原因。

Step-by-step using binlog

使用binlog的分步诊断流程

  1. Build twice with binlogs to capture the incremental build behavior:
    shell
    dotnet build /bl:first.binlog
    dotnet build /bl:second.binlog
    The first build establishes the baseline. The second build is the one you want to be incremental. Analyze
    second.binlog
    .
  2. Replay the second binlog to a diagnostic text log:
    shell
    dotnet msbuild second.binlog -noconlog -fl -flp:v=diag;logfile=second-full.log;performancesummary
    Then search for targets that actually executed:
    bash
    grep 'Building target\|Target.*was not skipped' second-full.log
    In a perfectly incremental build, most targets should be skipped.
  3. Inspect non-skipped targets by looking for their execution messages in the diagnostic log. Check for "out of date" messages that indicate why a target ran.
  4. Look for key messages in the binlog:
    • "Building target 'X' completely"
      — means MSBuild found no outputs or all outputs are missing; this is a full target execution.
    • "Building target 'X' incrementally"
      — means some (but not all) outputs are out of date.
    • "Skipping target 'X' because all output files are up-to-date"
      — target was correctly skipped.
  5. Search for "is newer than output" messages to find the specific input file that triggered the rebuild:
    bash
    grep "is newer than output" second-full.log
    This reveals exactly which input file's timestamp caused MSBuild to consider the target out of date.
  1. 两次构建并生成binlog,以捕获增量构建行为:
    shell
    dotnet build /bl:first.binlog
    dotnet build /bl:second.binlog
    第一次构建建立基准,第二次构建是需要分析的增量构建。重点分析
    second.binlog
  2. 将第二个binlog重放为诊断文本日志
    shell
    dotnet msbuild second.binlog -noconlog -fl -flp:v=diag;logfile=second-full.log;performancesummary
    然后搜索实际执行的目标:
    bash
    grep 'Building target\|Target.*was not skipped' second-full.log
    在理想的增量构建中,大多数目标都应被跳过。
  3. 检查未被跳过的目标,查看诊断日志中的执行信息,寻找表明目标运行原因的「过期」相关消息。
  4. 在binlog中查找关键消息
    • "Building target 'X' completely"
      — 表示MSBuild未找到输出文件或所有输出文件缺失,该目标会完整执行。
    • "Building target 'X' incrementally"
      — 表示部分(非全部)输出文件已过期。
    • "Skipping target 'X' because all output files are up-to-date"
      — 目标已被正确跳过。
  5. 搜索「is newer than output」消息,找到触发重建的具体输入文件:
    bash
    grep "is newer than output" second-full.log
    这会明确显示是哪个输入文件的时间戳导致MSBuild判定目标过期。

Additional diagnostic techniques

其他诊断技巧

  • Compare
    first.binlog
    and
    second.binlog
    side by side in the MSBuild Structured Log Viewer to see what changed.
  • Use
    grep 'Target Performance Summary' -A 30 second-full.log
    to see which targets consumed the most time in the second build — these are your optimization targets.
  • Check for targets with zero-duration that still ran — they may have unnecessary dependencies causing them to execute.
  • 在MSBuild结构化日志查看器中并排比较
    first.binlog
    second.binlog
    ,查看两次构建的差异。
  • 使用
    grep 'Target Performance Summary' -A 30 second-full.log
    查看第二次构建中耗时最长的目标——这些是需要优化的重点。
  • 检查耗时为零但仍运行的目标——它们可能存在不必要的依赖关系导致执行。

FileWrites and Clean Build

FileWrites与清理构建

The
FileWrites
item group is MSBuild's mechanism for tracking files generated during the build. It powers
dotnet clean
and helps maintain correct incremental behavior.
  • FileWrites
    item
    : Register any file your custom targets create so that
    dotnet clean
    knows to remove them. Without this, generated files accumulate across builds and may confuse incremental checks.
  • FileWritesShareable
    item
    : Use this for files that are shared across multiple projects (e.g., shared generated code). These files are tracked but not deleted if other projects still reference them.
  • If not registered: Files accumulate in the output and intermediate directories.
    dotnet clean
    won't remove them, and they may cause stale data issues or confuse up-to-date checks.
FileWrites
项组是MSBuild追踪构建过程中生成文件的机制,为
dotnet clean
提供支持,同时有助于维持正确的增量构建行为。
  • FileWrites
    :注册自定义目标创建的所有文件,确保
    dotnet clean
    知道要删除这些文件。如果不注册,生成的文件会在多次构建中累积,可能干扰增量检查。
  • FileWritesShareable
    :用于跨多个项目共享的文件(如共享生成代码)。这些文件会被追踪,但如果其他项目仍引用它们,则不会被删除。
  • 未注册的影响:文件会在输出目录和中间目录中累积。
    dotnet clean
    无法删除它们,可能导致过期数据问题或干扰最新状态检查。

Pattern for registering generated files

注册生成文件的模式

Add generated files to
FileWrites
inside the target that creates them:
xml
<Target Name="MyGenerator" Inputs="..." Outputs="$(IntermediateOutputPath)generated.cs">
  <!-- Generate the file -->
  <WriteLinesToFile File="$(IntermediateOutputPath)generated.cs" Lines="@(GeneratedLines)" />

  <!-- Register for clean -->
  <ItemGroup>
    <FileWrites Include="$(IntermediateOutputPath)generated.cs" />
  </ItemGroup>
</Target>
在创建文件的目标内部,将生成的文件添加到
FileWrites
xml
<Target Name="MyGenerator" Inputs="..." Outputs="$(IntermediateOutputPath)generated.cs">
  <!-- Generate the file -->
  <WriteLinesToFile File="$(IntermediateOutputPath)generated.cs" Lines="@(GeneratedLines)" />

  <!-- Register for clean -->
  <ItemGroup>
    <FileWrites Include="$(IntermediateOutputPath)generated.cs" />
  </ItemGroup>
</Target>

Visual Studio Fast Up-to-Date Check

Visual Studio快速最新状态检查

Visual Studio has its own up-to-date check (Fast Up-to-Date Check, or FUTDC) that is separate from MSBuild's
Inputs
/
Outputs
mechanism. Understanding the difference is critical for diagnosing "it rebuilds in VS but not on the command line" issues.
  • VS FUTDC is faster because it runs in-process and checks a known set of items without invoking MSBuild at all. It compares timestamps of well-known item types (Compile, Content, EmbeddedResource, etc.) against the project's primary output.
  • It can be wrong if your project uses custom build actions, custom targets that generate files, or non-standard item types that FUTDC doesn't know about.
  • Disable FUTDC to force Visual Studio to use MSBuild's full incremental check:
    xml
    <PropertyGroup>
      <DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
    </PropertyGroup>
  • Diagnose FUTDC decisions by viewing the Output window in VS: go to Tools → Options → Projects and Solutions → SDK-Style Projects and set Up-to-date Checks logging level to Verbose or above. FUTDC will log exactly which file it considers out of date.
  • Common VS FUTDC issues:
    • Custom build actions not registered with the FUTDC system
    • CopyToOutputDirectory
      items that are newer than the last build
    • Items added dynamically by targets that FUTDC doesn't evaluate
    • Content
      or
      None
      items with
      CopyToOutputDirectory="PreserveNewest"
      that have been modified
Visual Studio拥有独立于MSBuild
Inputs
/
Outputs
机制的最新状态检查(Fast Up-to-Date Check,简称FUTDC)。理解两者的差异对于诊断「VS中触发重建但命令行中不触发」的问题至关重要。
  • VS FUTDC速度更快:它在进程内运行,无需调用MSBuild即可检查一组已知项,将知名项类型(Compile、Content、EmbeddedResource等)的时间戳与项目主输出进行比较。
  • 可能出现误判:如果项目使用自定义构建操作、生成文件的自定义目标,或FUTDC不识别的非标准项类型,就可能出现误判。
  • 禁用FUTDC:强制Visual Studio使用MSBuild的完整增量检查:
    xml
    <PropertyGroup>
      <DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
    </PropertyGroup>
  • 诊断FUTDC决策:在VS的输出窗口中查看相关日志:依次进入工具 → 选项 → 项目和解决方案 → SDK风格项目,将最新状态检查的日志级别设置为详细或更高。FUTDC会记录它判定为过期的具体文件。
  • VS FUTDC常见问题
    • 自定义构建操作未注册到FUTDC系统
    • CopyToOutputDirectory
      项的时间戳晚于上次构建
    • 目标动态添加的项未被FUTDC评估
    • 已修改的、带有
      CopyToOutputDirectory="PreserveNewest"
      属性的
      Content
      None

Making Custom Targets Incremental

实现自定义增量目标

The following is a complete example of a well-structured incremental custom target:
xml
<Target Name="GenerateConfig"
        Inputs="$(MSBuildProjectFile);@(ConfigInput)"
        Outputs="$(IntermediateOutputPath)config.generated.cs"
        BeforeTargets="CoreCompile">
  <!-- Generate file only if inputs changed -->
  <WriteLinesToFile File="$(IntermediateOutputPath)config.generated.cs" Lines="..." />
  <ItemGroup>
    <FileWrites Include="$(IntermediateOutputPath)config.generated.cs" />
    <Compile Include="$(IntermediateOutputPath)config.generated.cs" />
  </ItemGroup>
</Target>
Key points in this example:
  • Inputs
    includes
    $(MSBuildProjectFile)
    : This ensures the target reruns if the project file itself changes (e.g., a property that affects generation is modified).
  • Inputs
    includes
    @(ConfigInput)
    : The actual source files that drive generation.
  • Outputs
    uses
    $(IntermediateOutputPath)
    : Generated files go in the
    obj/
    directory, which is managed by MSBuild and cleaned automatically.
  • BeforeTargets="CoreCompile"
    : The generated file is available before the compiler runs.
  • FileWrites
    registration
    : Ensures
    dotnet clean
    removes the generated file.
  • Compile
    inclusion
    : Adds the generated file to the compilation without requiring it to exist at evaluation time.
以下是一个结构规范的自定义增量目标完整示例:
xml
<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
    :无需在评估阶段确保文件存在,即可将生成的文件加入编译流程。

Common mistakes to avoid

需避免的常见错误

xml
<!-- BAD: No Inputs/Outputs — runs every build -->
<Target Name="BadTarget" BeforeTargets="CoreCompile">
  <Exec Command="generate-code.exe" />
</Target>

<!-- BAD: Volatile output path — never finds previous output -->
<Target Name="BadTarget2"
        Inputs="@(Compile)"
        Outputs="$(OutputPath)gen_$([System.DateTime]::Now.Ticks).cs">
  <Exec Command="generate-code.exe" />
</Target>

<!-- GOOD: Stable paths, registered outputs -->
<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>
xml
<!-- 错误:无Inputs/Outputs — 每次构建都运行 -->
<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>

Performance Summary and Preprocess

性能汇总与预编译

MSBuild provides built-in tools to understand what's running and why.
  • /clp:PerformanceSummary
    — Appends a summary at the end of the build showing time spent in each target and task. Use this to quickly identify the most expensive operations:
    shell
    dotnet build /clp:PerformanceSummary
    This shows a table of targets sorted by cumulative time, making it easy to spot targets that shouldn't be running in an incremental build.
  • /pp:preprocess.xml
    — Generates a single XML file with all imports inlined, showing the fully evaluated project. This is invaluable for understanding what targets, properties, and items are defined and where they come from:
    shell
    dotnet msbuild /pp:preprocess.xml
    Search the preprocessed output to find where
    Inputs
    and
    Outputs
    are defined for any target, or to understand the full chain of imports.
  • Use both together to understand what's running (
    PerformanceSummary
    ) and what's imported (
    /pp
    ), then cross-reference with binlog analysis for a complete picture.
MSBuild提供内置工具帮助用户了解构建的执行内容和原因。
  • /clp:PerformanceSummary
    — 在构建结束时追加一个汇总信息,显示每个目标和任务的耗时。使用该参数可快速识别最耗时的操作:
    shell
    dotnet build /clp:PerformanceSummary
    该参数会生成按累计耗时排序的目标表格,便于发现增量构建中不应运行的目标。
  • /pp:preprocess.xml
    — 生成一个内联所有导入内容的单XML文件,展示完全评估后的项目。该文件对于理解目标、属性和项的定义及来源非常重要:
    shell
    dotnet msbuild /pp:preprocess.xml
    在预编译输出中搜索,可找到任意目标的
    Inputs
    Outputs
    定义位置,或了解完整的导入链。
  • 结合使用这两个工具:通过
    PerformanceSummary
    了解执行内容,通过
    /pp
    了解导入内容,再结合binlog分析,可获得完整的构建全景。

Common Fixes

常见修复方案

  • Always add
    Inputs
    and
    Outputs
    to custom targets
    — This is the single most impactful change for incremental build performance. Without both attributes, the target runs every time.
  • Use
    $(IntermediateOutputPath)
    for generated files
    — Files in
    obj/
    are tracked by MSBuild's clean infrastructure and won't leak between configurations.
  • Register generated files in
    FileWrites
    — Ensures
    dotnet clean
    removes them and prevents stale file accumulation.
  • Avoid volatile data in build — Don't embed timestamps, random values, or build counters in file paths or generated content unless you have a deliberate strategy for managing staleness. If you must use volatile data, isolate it to a single file with minimal downstream impact.
  • Use
    Returns
    instead of
    Outputs
    when you need to pass items without creating incremental build dependency
    Outputs
    serves double duty: it defines the incremental check AND the items returned from the target. If you only need to pass items to calling targets without affecting incrementality, use
    Returns
    instead:
    xml
    <!-- Outputs: affects incremental check AND return value -->
    <Target Name="GetFiles" Outputs="@(DiscoveredFiles)">...</Target>
    
    <!-- Returns: only affects return value, no incremental check -->
    <Target Name="GetFiles" Returns="@(DiscoveredFiles)">...</Target>
  • 始终为自定义目标添加
    Inputs
    Outputs
    — 这是提升增量构建性能最有效的措施。若未同时配置这两个属性,目标会每次都运行。
  • 使用
    $(IntermediateOutputPath)
    存放生成文件
    obj/
    目录下的文件由MSBuild的清理基础设施追踪,不会在不同配置间泄漏。
  • 将生成文件注册到
    FileWrites
    — 确保
    dotnet clean
    会删除这些文件,防止过期文件累积。
  • 避免在构建中使用易变数据 — 除非有明确的过期管理策略,否则不要在文件路径或生成内容中嵌入时间戳、随机值或构建计数器。如果必须使用易变数据,应将其隔离到单个文件,减少对下游的影响。
  • 当仅需传递项而不创建增量构建依赖时,使用
    Returns
    替代
    Outputs
    Outputs
    有双重作用:定义增量检查规则,同时定义目标返回的项。如果仅需将项传递给调用目标而不影响增量性,应使用
    Returns
    xml
    <!-- Outputs:影响增量检查和返回值 -->
    <Target Name="GetFiles" Outputs="@(DiscoveredFiles)">...</Target>
    
    <!-- Returns:仅影响返回值,不触发增量检查 -->
    <Target Name="GetFiles" Returns="@(DiscoveredFiles)">...</Target>