Skip to content

[Incrementalism] SaveChangedAssemblyStep unconditionally bumps assembly timestamps, causing cascading rebuilds #11265

@sbomer

Description

@sbomer

Summary

SaveChangedAssemblyStep in AssemblyModifierPipeline unconditionally calls File.SetLastWriteTimeUtc(destination, DateTime.UtcNow) on every assembly it processes, even when the content is unchanged. This causes unnecessary downstream target re-runs on incremental builds.

Root Cause

In AssemblyModifierPipeline.cs, SaveChangedAssemblyStep.CopyIfChanged():

// NOTE: We still need to update the timestamp on this file, or this target would run again
File.SetLastWriteTimeUtc (destination.ItemSpec, DateTime.UtcNow);

This timestamp bump was added to prevent the calling target from re-running. However, _AfterILLinkAdditionalSteps (which calls AssemblyModifierPipeline with SourceFiles == DestinationFiles, i.e. in-place) uses flag files (_AndroidLinkFlag / _AdditionalPostLinkerStepsFlag) for its incrementalism — the per-assembly timestamp bumps are unnecessary for this target.

Impact

NativeAOT (confirmed)

For NativeAOT builds, @(ResolvedAssemblies) points directly to obj/Release/_Microsoft.Android.Resource.Designer.dll (no R2R intermediate copy). The in-place timestamp bump makes the designer DLL newer than CoreCompile's outputs, causing:

  1. CoreCompile re-runs → regenerates UnnamedProject.dll
  2. _GenerateTrimmableTypeMap re-runs → regenerates TypeMap DLLs
  3. _GenerateJavaStubs re-runs → not skipped on incremental build

CoreCLR (suspected)

For CoreCLR, @(ResolvedAssemblies) points to R2R copies (obj/Release/.../R2R/*.dll), so CoreCompile is not affected. However, the bumped R2R timestamps may cause downstream targets (e.g., _RemoveRegisterAttribute via CopyIfChanged) to do unnecessary work on incremental builds.

Why CoreCLR appears unaffected

CoreCLR's R2R step copies assemblies to a separate directory before _AfterILLinkAdditionalSteps runs. The timestamp bump hits the R2R copies, not the original assemblies that CoreCompile references. NativeAOT has no R2R step, so the pipeline operates directly on the originals.

Suggested Fix

Review whether the timestamp bump is needed for each caller:

  • _LinkAssembliesNoShrink: source ≠ destination, uses assembly-level Inputs/Outputs → bump IS needed
  • _AfterILLinkAdditionalSteps: source == destination (in-place), uses flag files → bump is NOT needed

Options:

  1. Skip the timestamp bump when source == destination (in-place mode)
  2. Eliminate the in-place mode entirely by having _AfterILLinkAdditionalSteps write to a separate directory
  3. Audit all callers and remove the bump where flag-based incrementalism makes it unnecessary

Repro

# Build a NativeAOT + trimmable typemap project twice
dotnet build -c Release -p:_AndroidTypeMapImplementation=trimmable
dotnet build -c Release -p:_AndroidTypeMapImplementation=trimmable -v:diagnostic 2>&1 | grep "Building target.*CoreCompile.*completely"
# CoreCompile will re-run due to designer DLL timestamp

This content was created with assistance from AI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageIssues that need to be assigned.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions