[NativeAOT] Prototype guarded incremental compilation - #132962

Closed
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype
Closed

[NativeAOT] Prototype guarded incremental compilation#132962
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype

Conversation

@awakecoding

@awakecodingawakecoding commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a disabled-by-default, internal NativeAOT incremental-compilation prototype for Windows x64 COFF. A single ILC process retains its completed dependency graph, recompiles a prevalidated finite set of changed method bodies, and creates each updated object by patching a copy of the immutable baseline object.

The prototype adds no public API. Incremental rejection is explicit: ILC prints ILC_INCREMENTAL_REJECTED, exits with code 85, removes outputs it created, and requires the caller to start a fresh clean compilation. Ordinary compiler failures are not classified as clean-fallback requests.

Tracking issue: #132977

Implementation

  • Captures the effective IL provider chain, including generated P/Invoke stubs.
  • Validates all update assemblies before baseline object emission.
  • Resets the mutable MethodCodeNode code, GC, EH, debug, local, and dependency state required for recompilation.
  • Recompiles the union of methods changed by the current and previous updates, so sequential edits and reverts always derive from the original baseline.
  • Verifies ordered static and conditional dependencies, reasons, marked state, GC info, frame/unwind data, EH state, debug state, symbols, relocations/addends, alignment, COMDAT state, and object locations after code generation.
  • Records selected COFF fragment locations per compilation and binds the baseline assembly, configuration, and object with SHA-256.
  • Copies from the same verified baseline handle, validates every non-relocation byte, stages unique same-directory files, flushes them to disk, and publishes without overwrite.
  • Poisons retained state after any post-mutation failure so a failed attempt cannot be reused.

Supported envelope

Incremental compilation is accepted only for:

  • A Windows host targeting Windows x64 NativeAOT COFF, single-file compilation, and one primary input.
  • OptimizationMode.None with exactly one compiler thread.
  • Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.
  • Baseline and updated PE files with equal length, MVID, metadata method count, and complete non-body content after masking only timestamp, checksum, debug-directory, strong-name payload, and encoded method-body ranges.
  • Method bodies with unchanged encoded size, max stack, init-locals, local signature, and EH shape.
  • Non-constructor, non-generic leaf methods with exactly one marked, non-canonical, non-unboxing, non-foldable MethodCodeNode, no conditional dependencies or EH, and overlayable ECMA IL.
  • Identical allowed opcode streams where only explicit integer or floating-point constant operands change.
  • Selected non-COMDAT COFF fragments whose size, alignment, symbols, relocations, addends, GC/frame/EH/debug state, location, bounds, and overlap checks remain valid.

All other cases request a clean compilation.

RDM build-performance impact

Measured on one eligible edit to reachable Program.BuildModuleViewIndex in the RDM RdmNativeAotFast=true workload:

RDM stepClean pathRetained incremental pathImpact
ILC object generation/update640.395 s0.291837 s2,194.36x faster; 99.9544% less wall time
Native link36.55 s36.55 sunchanged
ILC + native link developer loop676.945 s (11m 16.9s)36.842 s18.37x faster; 94.56% less wall time
Time saved per eligible edit/link iteration640.103 s (10m 40.1s)derived from the rows above

The 3,744,339,247-byte incremental object exactly matched the clean object with SHA-256 B3140045782498DC4A06F712C2DAA329B732D6340DFCD3D80AD4181E17844206. The update reused 13,455,307 of 13,455,308 object nodes, patched one byte, and allocated 1,568,736 managed bytes.

These are measured component timings. A separate clean RDM publish measured 951.9 s (15m 51.9s), but a comparable complete incremental dotnet publish was not timed. The defensible practical result is the ~36.8-second edit-and-link loop instead of ~10–11 minutes, not a 36.8-second full build.

The first request still pays for the clean compilation and retains roughly 34–36 GiB. The result applies only to edits that pass the narrow safety gate.

Validation

  • build.cmd clr+libs+host baseline: succeeded with 0 warnings/errors.
  • build.cmd clr.aot+libs -rc Release -lc Release: succeeded with 0 warnings/errors.
  • ILCompiler.Compiler.Tests Release: 80 passed, 0 failed, 0 skipped.
  • Focused IncrementalCompilationTests: 58 passed, 0 failed, 0 skipped.
  • Dedicated priority-0 Windows-x64 NativeAOT incremental smoke test: runs automatically during BuildNativeAot and succeeded with 0 warnings/errors.
    • Incremental edited object and independent clean edited object: 0BE481A1B058F826D4FCD0E013DEFC544BF4382E16CE8C5549EF94AE1F73666F.
    • Reverted object and baseline object: 0CB70EB6CAA77ABC4C6F120AE64C1AF3F6370A37AEC4FD54FBF8A96179E44497.
    • Explicit exit-85 rejection logging was validated under Windows PowerShell; CFG legs skip the unsupported differential path.
    • Build-only comparison objects are written under the intermediate tree and excluded from Helix payloads.

Limitations

Normal compiler-driven source edits commonly change the MVID and therefore request a clean compilation. Productization would require a supported command/API contract, complete cross-process content keys for references/resources/toolchain/JIT/environment, request isolation, eviction and crash recovery, broader invalidation for optimized/inlined/preinitialized/reflection/generic/global facts, discovery of newly dirty nodes, validation or regeneration of linker-affecting side outputs, and compile-time-checked internal seams across the compiler assemblies.

Note

This pull request description was generated with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@jkotas

Copy link
Copy Markdown
Member

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

@jkoritzinsky

Copy link
Copy Markdown
Member

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

I am trying to improve incremental build performance for NativeAOT on very large projects like Remote Desktop Manager. It currently takes a good 15 minutes to make a change and rebuild, and memory peaks over 32GB in ILC. I've been told you're supposed to just make managed builds with the warnings for NativeAOT to figure out what to fix for NativeAOT and then just wait on the NativeAOT build, trusting that it works, but this prevents actually trying out the NativeAOT build and repeatedly iterate on it. I know my project (RDM) is huge, but that doesn't mean we can't optimize the build tooling to introduce incremental builds

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

Where is this documented?

@jkoritzinsky

Copy link
Copy Markdown
Member

You can set the IlcMultiModule property to true to try it out. Once again, no guarantees, this is unsupported, etc.

I also don't know if we actually have the MSBuild targets set up correctly to make this run in an incremental manner so this may need more work for that front.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

@jkotas@jkoritzinsky I have created an issue here for NativeAOT incremental compilation support: #132977

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thanks — I tried IlcMultiModule on our RDM NativeAOT build. After working around a Windows target-plumbing issue (TargetOS was not forwarded to BuildFrameworkNativeObjects.proj), framework caching did work: 175 framework objects plus Framework.lib were byte/mtime-identical, and an identical publish dropped from 420.7s to 63.1s.

The app targets only compiled the top-level module, not its 509 non-framework references, so the stock link failed with 168 unresolved externals. I also tried custom per-module orchestration: 505/509 modules compiled and all 505 were reused on a warm run, but four modules hit rooting/generic issues and the aggregate link still failed with 1,248 unresolved externals. So this validates framework-level incrementality, but app-module orchestration and multifile generic/reflection support need more work before it is usable for this workload.

Note

This comment was drafted with GitHub Copilot.

@MichalStrehovsky

Copy link
Copy Markdown
Member

Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.

This is just the start. An incremental compilation needs to disable all whole program optimizations, including trimming. These optimizations all have butterfly effects that are difficult to capture and reconstruct., A small change in one method might invalidate an optimization done elsewhere.

Once all the applicable optimizations are disabled, an incremental compilation mode gives:

  1. Output that behaves differently from a real optimized native AOT compilation (this applies in the presence of trim/AOT unsafe code - the trimming+optimizations are invisible to trim/AOT safe code)
  2. Subpar quality outputs because all the ways that native AOT could do better through trimming and whole program optimizations are gone.

If the problem is that the RDM codebase has many trimming warnings (I saw Newtonsoft) and instead of fixing the warnings you're rooting assemblies and retesting, you're not going to have a good time with native AOT or trimming and incremental compilation will not help you, unless you intend to ship the unoptimized incremental build.

First step to native AOT conversion is eliminating all the trimming warnings, we document that in the first section. Maybe the doc doesn't explain the gravity of the situation well enough but the "ensure there are no behavior changes by thoroughly testing your app after building as Native AOT" part really means that if there is any trimming warning, you need to retest the entire app after any change due to the butterfly effects. It is not possible to reliably ship a large app that has trimming warnings.

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

I understand where you are going with this, but I respectfully disagree: this means you have to fully port your app for NativeAOT before you can start trying it out despite the warnings. It's important to be able to iterate on the partial port early on to help with prioritization of the work to be done, but also early validation of code paths which differ significantly in a NativeAOT build as opposed to the managed build.

AI made it possible to consider porting RDM to NativeAOT, but it is still a moonshot project. The first blocker we recently resolved was porting all of RDM Windows from DevExpress+WinForms to Avalonia UI, which involved about 1500+ UI components. WinForms is fine for NativeAOT, but DevExpress isn't. That alone took almost a year with AI, and once finally fixed, I could have my first successful launches of the RDM Windows application with NativeAOT despite a ton of build warnings. Not all features work, but it's enough to start iterating and validating by prioritizing what areas of the application should be fixed next for NativeAOT safety.

The second major blocker we have resolved recently is the PowerShell SDK, which is in-process, and will always rely on JIT for obvious reasons. I didn't want to move things out of process because we expose .NET live objects in scripts in-process for some advanced features. I developed my own PowerShell SDK that remains in-process, and offers live object proxies from a NativeAOT-safe .NET application. Here's the custom PowerShell SDK I developed specifically for NativeAOT consumption: https://github.com/Devolutions/multi-pwsh

My point is not that I would like to ship RDM in production with tons of trim-safety warnings remaining. It is that I would like to iterate much faster on experimental (or production) NativeAOT builds. Managed builds differ too much from NativeAOT builds for them to be useful beyond producing warnings. For instance, my managed build of RDM uses the regular PowerShell SDK while the NativeAOT build uses my NativeAOT-safe PowerShell SDK replacement. Several features are also gated for NativeAOT-safety, as I work my way through the huge backlog of things to fix.

But let's say we fast forward to the future at a point where I have fixed all the NativeAOT build warnings. Even then, my NativeAOT builds will still be extremely slow (RDM won't magically become thin and lightweight), so it means I still have to use managed builds for the inner loop. Those managed builds will always significantly differ from a NativeAOT build. There is a point to be made about introducing incremental builds in NativeAOT in places where it can be done.

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds. I want to explore ways we can do it in places where there's a potential gain to be made, and then work on making the non-reusable parts of the build process faster, in hope that the NativeAOT build time becomes manageable. Right now, it's slow even for a CI build, and we need to bring it down.

@agocke

Copy link
Copy Markdown
Member

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds.

I don't see how this follows. As Michal mentioned, whole program optimization means that a change in any method can produce changes in completely unrelated methods. For an incremental compilation to be correct this would somehow need to be fully accounted for, and it might end up leading to zero reuse in common cases.

@agocke

Copy link
Copy Markdown
Member

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

thanks Andy, that explains it much better. I suggest closing the current PR, and keep the discussion going in the high-level NativeAOT incremental build issue: #132977

I would then research how we could take inspiration from rustc internals and come up with a new prototype for incremental builds in .NET NativeAOT does that doesn't disable certain passes. It would obviously be a lot more work, but I'm up for giving it a try.

@MichalPetryka

Copy link
Copy Markdown
Contributor

The only prior art I'm aware of here is rustc.

MSVC does have incremental builds and LTCG, afair they track everything to handle wpo changes.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@awakecoding@jkotas@jkoritzinsky@MichalStrehovsky@agocke@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[NativeAOT] Prototype guarded incremental compilation - #132962

Closed
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype
Closed

[NativeAOT] Prototype guarded incremental compilation#132962
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype

Conversation

@awakecoding

@awakecodingawakecoding commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a disabled-by-default, internal NativeAOT incremental-compilation prototype for Windows x64 COFF. A single ILC process retains its completed dependency graph, recompiles a prevalidated finite set of changed method bodies, and creates each updated object by patching a copy of the immutable baseline object.

The prototype adds no public API. Incremental rejection is explicit: ILC prints ILC_INCREMENTAL_REJECTED, exits with code 85, removes outputs it created, and requires the caller to start a fresh clean compilation. Ordinary compiler failures are not classified as clean-fallback requests.

Tracking issue: #132977

Implementation

  • Captures the effective IL provider chain, including generated P/Invoke stubs.
  • Validates all update assemblies before baseline object emission.
  • Resets the mutable MethodCodeNode code, GC, EH, debug, local, and dependency state required for recompilation.
  • Recompiles the union of methods changed by the current and previous updates, so sequential edits and reverts always derive from the original baseline.
  • Verifies ordered static and conditional dependencies, reasons, marked state, GC info, frame/unwind data, EH state, debug state, symbols, relocations/addends, alignment, COMDAT state, and object locations after code generation.
  • Records selected COFF fragment locations per compilation and binds the baseline assembly, configuration, and object with SHA-256.
  • Copies from the same verified baseline handle, validates every non-relocation byte, stages unique same-directory files, flushes them to disk, and publishes without overwrite.
  • Poisons retained state after any post-mutation failure so a failed attempt cannot be reused.

Supported envelope

Incremental compilation is accepted only for:

  • A Windows host targeting Windows x64 NativeAOT COFF, single-file compilation, and one primary input.
  • OptimizationMode.None with exactly one compiler thread.
  • Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.
  • Baseline and updated PE files with equal length, MVID, metadata method count, and complete non-body content after masking only timestamp, checksum, debug-directory, strong-name payload, and encoded method-body ranges.
  • Method bodies with unchanged encoded size, max stack, init-locals, local signature, and EH shape.
  • Non-constructor, non-generic leaf methods with exactly one marked, non-canonical, non-unboxing, non-foldable MethodCodeNode, no conditional dependencies or EH, and overlayable ECMA IL.
  • Identical allowed opcode streams where only explicit integer or floating-point constant operands change.
  • Selected non-COMDAT COFF fragments whose size, alignment, symbols, relocations, addends, GC/frame/EH/debug state, location, bounds, and overlap checks remain valid.

All other cases request a clean compilation.

RDM build-performance impact

Measured on one eligible edit to reachable Program.BuildModuleViewIndex in the RDM RdmNativeAotFast=true workload:

RDM stepClean pathRetained incremental pathImpact
ILC object generation/update640.395 s0.291837 s2,194.36x faster; 99.9544% less wall time
Native link36.55 s36.55 sunchanged
ILC + native link developer loop676.945 s (11m 16.9s)36.842 s18.37x faster; 94.56% less wall time
Time saved per eligible edit/link iteration640.103 s (10m 40.1s)derived from the rows above

The 3,744,339,247-byte incremental object exactly matched the clean object with SHA-256 B3140045782498DC4A06F712C2DAA329B732D6340DFCD3D80AD4181E17844206. The update reused 13,455,307 of 13,455,308 object nodes, patched one byte, and allocated 1,568,736 managed bytes.

These are measured component timings. A separate clean RDM publish measured 951.9 s (15m 51.9s), but a comparable complete incremental dotnet publish was not timed. The defensible practical result is the ~36.8-second edit-and-link loop instead of ~10–11 minutes, not a 36.8-second full build.

The first request still pays for the clean compilation and retains roughly 34–36 GiB. The result applies only to edits that pass the narrow safety gate.

Validation

  • build.cmd clr+libs+host baseline: succeeded with 0 warnings/errors.
  • build.cmd clr.aot+libs -rc Release -lc Release: succeeded with 0 warnings/errors.
  • ILCompiler.Compiler.Tests Release: 80 passed, 0 failed, 0 skipped.
  • Focused IncrementalCompilationTests: 58 passed, 0 failed, 0 skipped.
  • Dedicated priority-0 Windows-x64 NativeAOT incremental smoke test: runs automatically during BuildNativeAot and succeeded with 0 warnings/errors.
    • Incremental edited object and independent clean edited object: 0BE481A1B058F826D4FCD0E013DEFC544BF4382E16CE8C5549EF94AE1F73666F.
    • Reverted object and baseline object: 0CB70EB6CAA77ABC4C6F120AE64C1AF3F6370A37AEC4FD54FBF8A96179E44497.
    • Explicit exit-85 rejection logging was validated under Windows PowerShell; CFG legs skip the unsupported differential path.
    • Build-only comparison objects are written under the intermediate tree and excluded from Helix payloads.

Limitations

Normal compiler-driven source edits commonly change the MVID and therefore request a clean compilation. Productization would require a supported command/API contract, complete cross-process content keys for references/resources/toolchain/JIT/environment, request isolation, eviction and crash recovery, broader invalidation for optimized/inlined/preinitialized/reflection/generic/global facts, discovery of newly dirty nodes, validation or regeneration of linker-affecting side outputs, and compile-time-checked internal seams across the compiler assemblies.

Note

This pull request description was generated with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@jkotas

Copy link
Copy Markdown
Member

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

@jkoritzinsky

Copy link
Copy Markdown
Member

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

I am trying to improve incremental build performance for NativeAOT on very large projects like Remote Desktop Manager. It currently takes a good 15 minutes to make a change and rebuild, and memory peaks over 32GB in ILC. I've been told you're supposed to just make managed builds with the warnings for NativeAOT to figure out what to fix for NativeAOT and then just wait on the NativeAOT build, trusting that it works, but this prevents actually trying out the NativeAOT build and repeatedly iterate on it. I know my project (RDM) is huge, but that doesn't mean we can't optimize the build tooling to introduce incremental builds

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

Where is this documented?

@jkoritzinsky

Copy link
Copy Markdown
Member

You can set the IlcMultiModule property to true to try it out. Once again, no guarantees, this is unsupported, etc.

I also don't know if we actually have the MSBuild targets set up correctly to make this run in an incremental manner so this may need more work for that front.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

@jkotas@jkoritzinsky I have created an issue here for NativeAOT incremental compilation support: #132977

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thanks — I tried IlcMultiModule on our RDM NativeAOT build. After working around a Windows target-plumbing issue (TargetOS was not forwarded to BuildFrameworkNativeObjects.proj), framework caching did work: 175 framework objects plus Framework.lib were byte/mtime-identical, and an identical publish dropped from 420.7s to 63.1s.

The app targets only compiled the top-level module, not its 509 non-framework references, so the stock link failed with 168 unresolved externals. I also tried custom per-module orchestration: 505/509 modules compiled and all 505 were reused on a warm run, but four modules hit rooting/generic issues and the aggregate link still failed with 1,248 unresolved externals. So this validates framework-level incrementality, but app-module orchestration and multifile generic/reflection support need more work before it is usable for this workload.

Note

This comment was drafted with GitHub Copilot.

@MichalStrehovsky

Copy link
Copy Markdown
Member

Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.

This is just the start. An incremental compilation needs to disable all whole program optimizations, including trimming. These optimizations all have butterfly effects that are difficult to capture and reconstruct., A small change in one method might invalidate an optimization done elsewhere.

Once all the applicable optimizations are disabled, an incremental compilation mode gives:

  1. Output that behaves differently from a real optimized native AOT compilation (this applies in the presence of trim/AOT unsafe code - the trimming+optimizations are invisible to trim/AOT safe code)
  2. Subpar quality outputs because all the ways that native AOT could do better through trimming and whole program optimizations are gone.

If the problem is that the RDM codebase has many trimming warnings (I saw Newtonsoft) and instead of fixing the warnings you're rooting assemblies and retesting, you're not going to have a good time with native AOT or trimming and incremental compilation will not help you, unless you intend to ship the unoptimized incremental build.

First step to native AOT conversion is eliminating all the trimming warnings, we document that in the first section. Maybe the doc doesn't explain the gravity of the situation well enough but the "ensure there are no behavior changes by thoroughly testing your app after building as Native AOT" part really means that if there is any trimming warning, you need to retest the entire app after any change due to the butterfly effects. It is not possible to reliably ship a large app that has trimming warnings.

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

I understand where you are going with this, but I respectfully disagree: this means you have to fully port your app for NativeAOT before you can start trying it out despite the warnings. It's important to be able to iterate on the partial port early on to help with prioritization of the work to be done, but also early validation of code paths which differ significantly in a NativeAOT build as opposed to the managed build.

AI made it possible to consider porting RDM to NativeAOT, but it is still a moonshot project. The first blocker we recently resolved was porting all of RDM Windows from DevExpress+WinForms to Avalonia UI, which involved about 1500+ UI components. WinForms is fine for NativeAOT, but DevExpress isn't. That alone took almost a year with AI, and once finally fixed, I could have my first successful launches of the RDM Windows application with NativeAOT despite a ton of build warnings. Not all features work, but it's enough to start iterating and validating by prioritizing what areas of the application should be fixed next for NativeAOT safety.

The second major blocker we have resolved recently is the PowerShell SDK, which is in-process, and will always rely on JIT for obvious reasons. I didn't want to move things out of process because we expose .NET live objects in scripts in-process for some advanced features. I developed my own PowerShell SDK that remains in-process, and offers live object proxies from a NativeAOT-safe .NET application. Here's the custom PowerShell SDK I developed specifically for NativeAOT consumption: https://github.com/Devolutions/multi-pwsh

My point is not that I would like to ship RDM in production with tons of trim-safety warnings remaining. It is that I would like to iterate much faster on experimental (or production) NativeAOT builds. Managed builds differ too much from NativeAOT builds for them to be useful beyond producing warnings. For instance, my managed build of RDM uses the regular PowerShell SDK while the NativeAOT build uses my NativeAOT-safe PowerShell SDK replacement. Several features are also gated for NativeAOT-safety, as I work my way through the huge backlog of things to fix.

But let's say we fast forward to the future at a point where I have fixed all the NativeAOT build warnings. Even then, my NativeAOT builds will still be extremely slow (RDM won't magically become thin and lightweight), so it means I still have to use managed builds for the inner loop. Those managed builds will always significantly differ from a NativeAOT build. There is a point to be made about introducing incremental builds in NativeAOT in places where it can be done.

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds. I want to explore ways we can do it in places where there's a potential gain to be made, and then work on making the non-reusable parts of the build process faster, in hope that the NativeAOT build time becomes manageable. Right now, it's slow even for a CI build, and we need to bring it down.

@agocke

Copy link
Copy Markdown
Member

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds.

I don't see how this follows. As Michal mentioned, whole program optimization means that a change in any method can produce changes in completely unrelated methods. For an incremental compilation to be correct this would somehow need to be fully accounted for, and it might end up leading to zero reuse in common cases.

@agocke

Copy link
Copy Markdown
Member

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

thanks Andy, that explains it much better. I suggest closing the current PR, and keep the discussion going in the high-level NativeAOT incremental build issue: #132977

I would then research how we could take inspiration from rustc internals and come up with a new prototype for incremental builds in .NET NativeAOT does that doesn't disable certain passes. It would obviously be a lot more work, but I'm up for giving it a try.

@MichalPetryka

Copy link
Copy Markdown
Contributor

The only prior art I'm aware of here is rustc.

MSVC does have incremental builds and LTCG, afair they track everything to handle wpo changes.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@awakecoding@jkotas@jkoritzinsky@MichalStrehovsky@agocke@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[NativeAOT] Prototype guarded incremental compilation - #132962

Closed
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype
Closed

[NativeAOT] Prototype guarded incremental compilation#132962
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype

Conversation

@awakecoding

@awakecodingawakecoding commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a disabled-by-default, internal NativeAOT incremental-compilation prototype for Windows x64 COFF. A single ILC process retains its completed dependency graph, recompiles a prevalidated finite set of changed method bodies, and creates each updated object by patching a copy of the immutable baseline object.

The prototype adds no public API. Incremental rejection is explicit: ILC prints ILC_INCREMENTAL_REJECTED, exits with code 85, removes outputs it created, and requires the caller to start a fresh clean compilation. Ordinary compiler failures are not classified as clean-fallback requests.

Tracking issue: #132977

Implementation

  • Captures the effective IL provider chain, including generated P/Invoke stubs.
  • Validates all update assemblies before baseline object emission.
  • Resets the mutable MethodCodeNode code, GC, EH, debug, local, and dependency state required for recompilation.
  • Recompiles the union of methods changed by the current and previous updates, so sequential edits and reverts always derive from the original baseline.
  • Verifies ordered static and conditional dependencies, reasons, marked state, GC info, frame/unwind data, EH state, debug state, symbols, relocations/addends, alignment, COMDAT state, and object locations after code generation.
  • Records selected COFF fragment locations per compilation and binds the baseline assembly, configuration, and object with SHA-256.
  • Copies from the same verified baseline handle, validates every non-relocation byte, stages unique same-directory files, flushes them to disk, and publishes without overwrite.
  • Poisons retained state after any post-mutation failure so a failed attempt cannot be reused.

Supported envelope

Incremental compilation is accepted only for:

  • A Windows host targeting Windows x64 NativeAOT COFF, single-file compilation, and one primary input.
  • OptimizationMode.None with exactly one compiler thread.
  • Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.
  • Baseline and updated PE files with equal length, MVID, metadata method count, and complete non-body content after masking only timestamp, checksum, debug-directory, strong-name payload, and encoded method-body ranges.
  • Method bodies with unchanged encoded size, max stack, init-locals, local signature, and EH shape.
  • Non-constructor, non-generic leaf methods with exactly one marked, non-canonical, non-unboxing, non-foldable MethodCodeNode, no conditional dependencies or EH, and overlayable ECMA IL.
  • Identical allowed opcode streams where only explicit integer or floating-point constant operands change.
  • Selected non-COMDAT COFF fragments whose size, alignment, symbols, relocations, addends, GC/frame/EH/debug state, location, bounds, and overlap checks remain valid.

All other cases request a clean compilation.

RDM build-performance impact

Measured on one eligible edit to reachable Program.BuildModuleViewIndex in the RDM RdmNativeAotFast=true workload:

RDM stepClean pathRetained incremental pathImpact
ILC object generation/update640.395 s0.291837 s2,194.36x faster; 99.9544% less wall time
Native link36.55 s36.55 sunchanged
ILC + native link developer loop676.945 s (11m 16.9s)36.842 s18.37x faster; 94.56% less wall time
Time saved per eligible edit/link iteration640.103 s (10m 40.1s)derived from the rows above

The 3,744,339,247-byte incremental object exactly matched the clean object with SHA-256 B3140045782498DC4A06F712C2DAA329B732D6340DFCD3D80AD4181E17844206. The update reused 13,455,307 of 13,455,308 object nodes, patched one byte, and allocated 1,568,736 managed bytes.

These are measured component timings. A separate clean RDM publish measured 951.9 s (15m 51.9s), but a comparable complete incremental dotnet publish was not timed. The defensible practical result is the ~36.8-second edit-and-link loop instead of ~10–11 minutes, not a 36.8-second full build.

The first request still pays for the clean compilation and retains roughly 34–36 GiB. The result applies only to edits that pass the narrow safety gate.

Validation

  • build.cmd clr+libs+host baseline: succeeded with 0 warnings/errors.
  • build.cmd clr.aot+libs -rc Release -lc Release: succeeded with 0 warnings/errors.
  • ILCompiler.Compiler.Tests Release: 80 passed, 0 failed, 0 skipped.
  • Focused IncrementalCompilationTests: 58 passed, 0 failed, 0 skipped.
  • Dedicated priority-0 Windows-x64 NativeAOT incremental smoke test: runs automatically during BuildNativeAot and succeeded with 0 warnings/errors.
    • Incremental edited object and independent clean edited object: 0BE481A1B058F826D4FCD0E013DEFC544BF4382E16CE8C5549EF94AE1F73666F.
    • Reverted object and baseline object: 0CB70EB6CAA77ABC4C6F120AE64C1AF3F6370A37AEC4FD54FBF8A96179E44497.
    • Explicit exit-85 rejection logging was validated under Windows PowerShell; CFG legs skip the unsupported differential path.
    • Build-only comparison objects are written under the intermediate tree and excluded from Helix payloads.

Limitations

Normal compiler-driven source edits commonly change the MVID and therefore request a clean compilation. Productization would require a supported command/API contract, complete cross-process content keys for references/resources/toolchain/JIT/environment, request isolation, eviction and crash recovery, broader invalidation for optimized/inlined/preinitialized/reflection/generic/global facts, discovery of newly dirty nodes, validation or regeneration of linker-affecting side outputs, and compile-time-checked internal seams across the compiler assemblies.

Note

This pull request description was generated with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@jkotas

Copy link
Copy Markdown
Member

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

@jkoritzinsky

Copy link
Copy Markdown
Member

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

I am trying to improve incremental build performance for NativeAOT on very large projects like Remote Desktop Manager. It currently takes a good 15 minutes to make a change and rebuild, and memory peaks over 32GB in ILC. I've been told you're supposed to just make managed builds with the warnings for NativeAOT to figure out what to fix for NativeAOT and then just wait on the NativeAOT build, trusting that it works, but this prevents actually trying out the NativeAOT build and repeatedly iterate on it. I know my project (RDM) is huge, but that doesn't mean we can't optimize the build tooling to introduce incremental builds

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

Where is this documented?

@jkoritzinsky

Copy link
Copy Markdown
Member

You can set the IlcMultiModule property to true to try it out. Once again, no guarantees, this is unsupported, etc.

I also don't know if we actually have the MSBuild targets set up correctly to make this run in an incremental manner so this may need more work for that front.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

@jkotas@jkoritzinsky I have created an issue here for NativeAOT incremental compilation support: #132977

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thanks — I tried IlcMultiModule on our RDM NativeAOT build. After working around a Windows target-plumbing issue (TargetOS was not forwarded to BuildFrameworkNativeObjects.proj), framework caching did work: 175 framework objects plus Framework.lib were byte/mtime-identical, and an identical publish dropped from 420.7s to 63.1s.

The app targets only compiled the top-level module, not its 509 non-framework references, so the stock link failed with 168 unresolved externals. I also tried custom per-module orchestration: 505/509 modules compiled and all 505 were reused on a warm run, but four modules hit rooting/generic issues and the aggregate link still failed with 1,248 unresolved externals. So this validates framework-level incrementality, but app-module orchestration and multifile generic/reflection support need more work before it is usable for this workload.

Note

This comment was drafted with GitHub Copilot.

@MichalStrehovsky

Copy link
Copy Markdown
Member

Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.

This is just the start. An incremental compilation needs to disable all whole program optimizations, including trimming. These optimizations all have butterfly effects that are difficult to capture and reconstruct., A small change in one method might invalidate an optimization done elsewhere.

Once all the applicable optimizations are disabled, an incremental compilation mode gives:

  1. Output that behaves differently from a real optimized native AOT compilation (this applies in the presence of trim/AOT unsafe code - the trimming+optimizations are invisible to trim/AOT safe code)
  2. Subpar quality outputs because all the ways that native AOT could do better through trimming and whole program optimizations are gone.

If the problem is that the RDM codebase has many trimming warnings (I saw Newtonsoft) and instead of fixing the warnings you're rooting assemblies and retesting, you're not going to have a good time with native AOT or trimming and incremental compilation will not help you, unless you intend to ship the unoptimized incremental build.

First step to native AOT conversion is eliminating all the trimming warnings, we document that in the first section. Maybe the doc doesn't explain the gravity of the situation well enough but the "ensure there are no behavior changes by thoroughly testing your app after building as Native AOT" part really means that if there is any trimming warning, you need to retest the entire app after any change due to the butterfly effects. It is not possible to reliably ship a large app that has trimming warnings.

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

I understand where you are going with this, but I respectfully disagree: this means you have to fully port your app for NativeAOT before you can start trying it out despite the warnings. It's important to be able to iterate on the partial port early on to help with prioritization of the work to be done, but also early validation of code paths which differ significantly in a NativeAOT build as opposed to the managed build.

AI made it possible to consider porting RDM to NativeAOT, but it is still a moonshot project. The first blocker we recently resolved was porting all of RDM Windows from DevExpress+WinForms to Avalonia UI, which involved about 1500+ UI components. WinForms is fine for NativeAOT, but DevExpress isn't. That alone took almost a year with AI, and once finally fixed, I could have my first successful launches of the RDM Windows application with NativeAOT despite a ton of build warnings. Not all features work, but it's enough to start iterating and validating by prioritizing what areas of the application should be fixed next for NativeAOT safety.

The second major blocker we have resolved recently is the PowerShell SDK, which is in-process, and will always rely on JIT for obvious reasons. I didn't want to move things out of process because we expose .NET live objects in scripts in-process for some advanced features. I developed my own PowerShell SDK that remains in-process, and offers live object proxies from a NativeAOT-safe .NET application. Here's the custom PowerShell SDK I developed specifically for NativeAOT consumption: https://github.com/Devolutions/multi-pwsh

My point is not that I would like to ship RDM in production with tons of trim-safety warnings remaining. It is that I would like to iterate much faster on experimental (or production) NativeAOT builds. Managed builds differ too much from NativeAOT builds for them to be useful beyond producing warnings. For instance, my managed build of RDM uses the regular PowerShell SDK while the NativeAOT build uses my NativeAOT-safe PowerShell SDK replacement. Several features are also gated for NativeAOT-safety, as I work my way through the huge backlog of things to fix.

But let's say we fast forward to the future at a point where I have fixed all the NativeAOT build warnings. Even then, my NativeAOT builds will still be extremely slow (RDM won't magically become thin and lightweight), so it means I still have to use managed builds for the inner loop. Those managed builds will always significantly differ from a NativeAOT build. There is a point to be made about introducing incremental builds in NativeAOT in places where it can be done.

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds. I want to explore ways we can do it in places where there's a potential gain to be made, and then work on making the non-reusable parts of the build process faster, in hope that the NativeAOT build time becomes manageable. Right now, it's slow even for a CI build, and we need to bring it down.

@agocke

Copy link
Copy Markdown
Member

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds.

I don't see how this follows. As Michal mentioned, whole program optimization means that a change in any method can produce changes in completely unrelated methods. For an incremental compilation to be correct this would somehow need to be fully accounted for, and it might end up leading to zero reuse in common cases.

@agocke

Copy link
Copy Markdown
Member

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

thanks Andy, that explains it much better. I suggest closing the current PR, and keep the discussion going in the high-level NativeAOT incremental build issue: #132977

I would then research how we could take inspiration from rustc internals and come up with a new prototype for incremental builds in .NET NativeAOT does that doesn't disable certain passes. It would obviously be a lot more work, but I'm up for giving it a try.

@MichalPetryka

Copy link
Copy Markdown
Contributor

The only prior art I'm aware of here is rustc.

MSVC does have incremental builds and LTCG, afair they track everything to handle wpo changes.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@awakecoding@jkotas@jkoritzinsky@MichalStrehovsky@agocke@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[NativeAOT] Prototype guarded incremental compilation - #132962

Closed
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype
Closed

[NativeAOT] Prototype guarded incremental compilation#132962
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype

Conversation

@awakecoding

@awakecodingawakecoding commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a disabled-by-default, internal NativeAOT incremental-compilation prototype for Windows x64 COFF. A single ILC process retains its completed dependency graph, recompiles a prevalidated finite set of changed method bodies, and creates each updated object by patching a copy of the immutable baseline object.

The prototype adds no public API. Incremental rejection is explicit: ILC prints ILC_INCREMENTAL_REJECTED, exits with code 85, removes outputs it created, and requires the caller to start a fresh clean compilation. Ordinary compiler failures are not classified as clean-fallback requests.

Tracking issue: #132977

Implementation

  • Captures the effective IL provider chain, including generated P/Invoke stubs.
  • Validates all update assemblies before baseline object emission.
  • Resets the mutable MethodCodeNode code, GC, EH, debug, local, and dependency state required for recompilation.
  • Recompiles the union of methods changed by the current and previous updates, so sequential edits and reverts always derive from the original baseline.
  • Verifies ordered static and conditional dependencies, reasons, marked state, GC info, frame/unwind data, EH state, debug state, symbols, relocations/addends, alignment, COMDAT state, and object locations after code generation.
  • Records selected COFF fragment locations per compilation and binds the baseline assembly, configuration, and object with SHA-256.
  • Copies from the same verified baseline handle, validates every non-relocation byte, stages unique same-directory files, flushes them to disk, and publishes without overwrite.
  • Poisons retained state after any post-mutation failure so a failed attempt cannot be reused.

Supported envelope

Incremental compilation is accepted only for:

  • A Windows host targeting Windows x64 NativeAOT COFF, single-file compilation, and one primary input.
  • OptimizationMode.None with exactly one compiler thread.
  • Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.
  • Baseline and updated PE files with equal length, MVID, metadata method count, and complete non-body content after masking only timestamp, checksum, debug-directory, strong-name payload, and encoded method-body ranges.
  • Method bodies with unchanged encoded size, max stack, init-locals, local signature, and EH shape.
  • Non-constructor, non-generic leaf methods with exactly one marked, non-canonical, non-unboxing, non-foldable MethodCodeNode, no conditional dependencies or EH, and overlayable ECMA IL.
  • Identical allowed opcode streams where only explicit integer or floating-point constant operands change.
  • Selected non-COMDAT COFF fragments whose size, alignment, symbols, relocations, addends, GC/frame/EH/debug state, location, bounds, and overlap checks remain valid.

All other cases request a clean compilation.

RDM build-performance impact

Measured on one eligible edit to reachable Program.BuildModuleViewIndex in the RDM RdmNativeAotFast=true workload:

RDM stepClean pathRetained incremental pathImpact
ILC object generation/update640.395 s0.291837 s2,194.36x faster; 99.9544% less wall time
Native link36.55 s36.55 sunchanged
ILC + native link developer loop676.945 s (11m 16.9s)36.842 s18.37x faster; 94.56% less wall time
Time saved per eligible edit/link iteration640.103 s (10m 40.1s)derived from the rows above

The 3,744,339,247-byte incremental object exactly matched the clean object with SHA-256 B3140045782498DC4A06F712C2DAA329B732D6340DFCD3D80AD4181E17844206. The update reused 13,455,307 of 13,455,308 object nodes, patched one byte, and allocated 1,568,736 managed bytes.

These are measured component timings. A separate clean RDM publish measured 951.9 s (15m 51.9s), but a comparable complete incremental dotnet publish was not timed. The defensible practical result is the ~36.8-second edit-and-link loop instead of ~10–11 minutes, not a 36.8-second full build.

The first request still pays for the clean compilation and retains roughly 34–36 GiB. The result applies only to edits that pass the narrow safety gate.

Validation

  • build.cmd clr+libs+host baseline: succeeded with 0 warnings/errors.
  • build.cmd clr.aot+libs -rc Release -lc Release: succeeded with 0 warnings/errors.
  • ILCompiler.Compiler.Tests Release: 80 passed, 0 failed, 0 skipped.
  • Focused IncrementalCompilationTests: 58 passed, 0 failed, 0 skipped.
  • Dedicated priority-0 Windows-x64 NativeAOT incremental smoke test: runs automatically during BuildNativeAot and succeeded with 0 warnings/errors.
    • Incremental edited object and independent clean edited object: 0BE481A1B058F826D4FCD0E013DEFC544BF4382E16CE8C5549EF94AE1F73666F.
    • Reverted object and baseline object: 0CB70EB6CAA77ABC4C6F120AE64C1AF3F6370A37AEC4FD54FBF8A96179E44497.
    • Explicit exit-85 rejection logging was validated under Windows PowerShell; CFG legs skip the unsupported differential path.
    • Build-only comparison objects are written under the intermediate tree and excluded from Helix payloads.

Limitations

Normal compiler-driven source edits commonly change the MVID and therefore request a clean compilation. Productization would require a supported command/API contract, complete cross-process content keys for references/resources/toolchain/JIT/environment, request isolation, eviction and crash recovery, broader invalidation for optimized/inlined/preinitialized/reflection/generic/global facts, discovery of newly dirty nodes, validation or regeneration of linker-affecting side outputs, and compile-time-checked internal seams across the compiler assemblies.

Note

This pull request description was generated with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@jkotas

Copy link
Copy Markdown
Member

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

@jkoritzinsky

Copy link
Copy Markdown
Member

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

I am trying to improve incremental build performance for NativeAOT on very large projects like Remote Desktop Manager. It currently takes a good 15 minutes to make a change and rebuild, and memory peaks over 32GB in ILC. I've been told you're supposed to just make managed builds with the warnings for NativeAOT to figure out what to fix for NativeAOT and then just wait on the NativeAOT build, trusting that it works, but this prevents actually trying out the NativeAOT build and repeatedly iterate on it. I know my project (RDM) is huge, but that doesn't mean we can't optimize the build tooling to introduce incremental builds

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

Where is this documented?

@jkoritzinsky

Copy link
Copy Markdown
Member

You can set the IlcMultiModule property to true to try it out. Once again, no guarantees, this is unsupported, etc.

I also don't know if we actually have the MSBuild targets set up correctly to make this run in an incremental manner so this may need more work for that front.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

@jkotas@jkoritzinsky I have created an issue here for NativeAOT incremental compilation support: #132977

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thanks — I tried IlcMultiModule on our RDM NativeAOT build. After working around a Windows target-plumbing issue (TargetOS was not forwarded to BuildFrameworkNativeObjects.proj), framework caching did work: 175 framework objects plus Framework.lib were byte/mtime-identical, and an identical publish dropped from 420.7s to 63.1s.

The app targets only compiled the top-level module, not its 509 non-framework references, so the stock link failed with 168 unresolved externals. I also tried custom per-module orchestration: 505/509 modules compiled and all 505 were reused on a warm run, but four modules hit rooting/generic issues and the aggregate link still failed with 1,248 unresolved externals. So this validates framework-level incrementality, but app-module orchestration and multifile generic/reflection support need more work before it is usable for this workload.

Note

This comment was drafted with GitHub Copilot.

@MichalStrehovsky

Copy link
Copy Markdown
Member

Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.

This is just the start. An incremental compilation needs to disable all whole program optimizations, including trimming. These optimizations all have butterfly effects that are difficult to capture and reconstruct., A small change in one method might invalidate an optimization done elsewhere.

Once all the applicable optimizations are disabled, an incremental compilation mode gives:

  1. Output that behaves differently from a real optimized native AOT compilation (this applies in the presence of trim/AOT unsafe code - the trimming+optimizations are invisible to trim/AOT safe code)
  2. Subpar quality outputs because all the ways that native AOT could do better through trimming and whole program optimizations are gone.

If the problem is that the RDM codebase has many trimming warnings (I saw Newtonsoft) and instead of fixing the warnings you're rooting assemblies and retesting, you're not going to have a good time with native AOT or trimming and incremental compilation will not help you, unless you intend to ship the unoptimized incremental build.

First step to native AOT conversion is eliminating all the trimming warnings, we document that in the first section. Maybe the doc doesn't explain the gravity of the situation well enough but the "ensure there are no behavior changes by thoroughly testing your app after building as Native AOT" part really means that if there is any trimming warning, you need to retest the entire app after any change due to the butterfly effects. It is not possible to reliably ship a large app that has trimming warnings.

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

I understand where you are going with this, but I respectfully disagree: this means you have to fully port your app for NativeAOT before you can start trying it out despite the warnings. It's important to be able to iterate on the partial port early on to help with prioritization of the work to be done, but also early validation of code paths which differ significantly in a NativeAOT build as opposed to the managed build.

AI made it possible to consider porting RDM to NativeAOT, but it is still a moonshot project. The first blocker we recently resolved was porting all of RDM Windows from DevExpress+WinForms to Avalonia UI, which involved about 1500+ UI components. WinForms is fine for NativeAOT, but DevExpress isn't. That alone took almost a year with AI, and once finally fixed, I could have my first successful launches of the RDM Windows application with NativeAOT despite a ton of build warnings. Not all features work, but it's enough to start iterating and validating by prioritizing what areas of the application should be fixed next for NativeAOT safety.

The second major blocker we have resolved recently is the PowerShell SDK, which is in-process, and will always rely on JIT for obvious reasons. I didn't want to move things out of process because we expose .NET live objects in scripts in-process for some advanced features. I developed my own PowerShell SDK that remains in-process, and offers live object proxies from a NativeAOT-safe .NET application. Here's the custom PowerShell SDK I developed specifically for NativeAOT consumption: https://github.com/Devolutions/multi-pwsh

My point is not that I would like to ship RDM in production with tons of trim-safety warnings remaining. It is that I would like to iterate much faster on experimental (or production) NativeAOT builds. Managed builds differ too much from NativeAOT builds for them to be useful beyond producing warnings. For instance, my managed build of RDM uses the regular PowerShell SDK while the NativeAOT build uses my NativeAOT-safe PowerShell SDK replacement. Several features are also gated for NativeAOT-safety, as I work my way through the huge backlog of things to fix.

But let's say we fast forward to the future at a point where I have fixed all the NativeAOT build warnings. Even then, my NativeAOT builds will still be extremely slow (RDM won't magically become thin and lightweight), so it means I still have to use managed builds for the inner loop. Those managed builds will always significantly differ from a NativeAOT build. There is a point to be made about introducing incremental builds in NativeAOT in places where it can be done.

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds. I want to explore ways we can do it in places where there's a potential gain to be made, and then work on making the non-reusable parts of the build process faster, in hope that the NativeAOT build time becomes manageable. Right now, it's slow even for a CI build, and we need to bring it down.

@agocke

Copy link
Copy Markdown
Member

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds.

I don't see how this follows. As Michal mentioned, whole program optimization means that a change in any method can produce changes in completely unrelated methods. For an incremental compilation to be correct this would somehow need to be fully accounted for, and it might end up leading to zero reuse in common cases.

@agocke

Copy link
Copy Markdown
Member

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

thanks Andy, that explains it much better. I suggest closing the current PR, and keep the discussion going in the high-level NativeAOT incremental build issue: #132977

I would then research how we could take inspiration from rustc internals and come up with a new prototype for incremental builds in .NET NativeAOT does that doesn't disable certain passes. It would obviously be a lot more work, but I'm up for giving it a try.

@MichalPetryka

Copy link
Copy Markdown
Contributor

The only prior art I'm aware of here is rustc.

MSVC does have incremental builds and LTCG, afair they track everything to handle wpo changes.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@awakecoding@jkotas@jkoritzinsky@MichalStrehovsky@agocke@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[NativeAOT] Prototype guarded incremental compilation - #132962

Closed
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype
Closed

[NativeAOT] Prototype guarded incremental compilation#132962
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype

Conversation

@awakecoding

@awakecodingawakecoding commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a disabled-by-default, internal NativeAOT incremental-compilation prototype for Windows x64 COFF. A single ILC process retains its completed dependency graph, recompiles a prevalidated finite set of changed method bodies, and creates each updated object by patching a copy of the immutable baseline object.

The prototype adds no public API. Incremental rejection is explicit: ILC prints ILC_INCREMENTAL_REJECTED, exits with code 85, removes outputs it created, and requires the caller to start a fresh clean compilation. Ordinary compiler failures are not classified as clean-fallback requests.

Tracking issue: #132977

Implementation

  • Captures the effective IL provider chain, including generated P/Invoke stubs.
  • Validates all update assemblies before baseline object emission.
  • Resets the mutable MethodCodeNode code, GC, EH, debug, local, and dependency state required for recompilation.
  • Recompiles the union of methods changed by the current and previous updates, so sequential edits and reverts always derive from the original baseline.
  • Verifies ordered static and conditional dependencies, reasons, marked state, GC info, frame/unwind data, EH state, debug state, symbols, relocations/addends, alignment, COMDAT state, and object locations after code generation.
  • Records selected COFF fragment locations per compilation and binds the baseline assembly, configuration, and object with SHA-256.
  • Copies from the same verified baseline handle, validates every non-relocation byte, stages unique same-directory files, flushes them to disk, and publishes without overwrite.
  • Poisons retained state after any post-mutation failure so a failed attempt cannot be reused.

Supported envelope

Incremental compilation is accepted only for:

  • A Windows host targeting Windows x64 NativeAOT COFF, single-file compilation, and one primary input.
  • OptimizationMode.None with exactly one compiler thread.
  • Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.
  • Baseline and updated PE files with equal length, MVID, metadata method count, and complete non-body content after masking only timestamp, checksum, debug-directory, strong-name payload, and encoded method-body ranges.
  • Method bodies with unchanged encoded size, max stack, init-locals, local signature, and EH shape.
  • Non-constructor, non-generic leaf methods with exactly one marked, non-canonical, non-unboxing, non-foldable MethodCodeNode, no conditional dependencies or EH, and overlayable ECMA IL.
  • Identical allowed opcode streams where only explicit integer or floating-point constant operands change.
  • Selected non-COMDAT COFF fragments whose size, alignment, symbols, relocations, addends, GC/frame/EH/debug state, location, bounds, and overlap checks remain valid.

All other cases request a clean compilation.

RDM build-performance impact

Measured on one eligible edit to reachable Program.BuildModuleViewIndex in the RDM RdmNativeAotFast=true workload:

RDM stepClean pathRetained incremental pathImpact
ILC object generation/update640.395 s0.291837 s2,194.36x faster; 99.9544% less wall time
Native link36.55 s36.55 sunchanged
ILC + native link developer loop676.945 s (11m 16.9s)36.842 s18.37x faster; 94.56% less wall time
Time saved per eligible edit/link iteration640.103 s (10m 40.1s)derived from the rows above

The 3,744,339,247-byte incremental object exactly matched the clean object with SHA-256 B3140045782498DC4A06F712C2DAA329B732D6340DFCD3D80AD4181E17844206. The update reused 13,455,307 of 13,455,308 object nodes, patched one byte, and allocated 1,568,736 managed bytes.

These are measured component timings. A separate clean RDM publish measured 951.9 s (15m 51.9s), but a comparable complete incremental dotnet publish was not timed. The defensible practical result is the ~36.8-second edit-and-link loop instead of ~10–11 minutes, not a 36.8-second full build.

The first request still pays for the clean compilation and retains roughly 34–36 GiB. The result applies only to edits that pass the narrow safety gate.

Validation

  • build.cmd clr+libs+host baseline: succeeded with 0 warnings/errors.
  • build.cmd clr.aot+libs -rc Release -lc Release: succeeded with 0 warnings/errors.
  • ILCompiler.Compiler.Tests Release: 80 passed, 0 failed, 0 skipped.
  • Focused IncrementalCompilationTests: 58 passed, 0 failed, 0 skipped.
  • Dedicated priority-0 Windows-x64 NativeAOT incremental smoke test: runs automatically during BuildNativeAot and succeeded with 0 warnings/errors.
    • Incremental edited object and independent clean edited object: 0BE481A1B058F826D4FCD0E013DEFC544BF4382E16CE8C5549EF94AE1F73666F.
    • Reverted object and baseline object: 0CB70EB6CAA77ABC4C6F120AE64C1AF3F6370A37AEC4FD54FBF8A96179E44497.
    • Explicit exit-85 rejection logging was validated under Windows PowerShell; CFG legs skip the unsupported differential path.
    • Build-only comparison objects are written under the intermediate tree and excluded from Helix payloads.

Limitations

Normal compiler-driven source edits commonly change the MVID and therefore request a clean compilation. Productization would require a supported command/API contract, complete cross-process content keys for references/resources/toolchain/JIT/environment, request isolation, eviction and crash recovery, broader invalidation for optimized/inlined/preinitialized/reflection/generic/global facts, discovery of newly dirty nodes, validation or regeneration of linker-affecting side outputs, and compile-time-checked internal seams across the compiler assemblies.

Note

This pull request description was generated with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@jkotas

Copy link
Copy Markdown
Member

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

@jkoritzinsky

Copy link
Copy Markdown
Member

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

I am trying to improve incremental build performance for NativeAOT on very large projects like Remote Desktop Manager. It currently takes a good 15 minutes to make a change and rebuild, and memory peaks over 32GB in ILC. I've been told you're supposed to just make managed builds with the warnings for NativeAOT to figure out what to fix for NativeAOT and then just wait on the NativeAOT build, trusting that it works, but this prevents actually trying out the NativeAOT build and repeatedly iterate on it. I know my project (RDM) is huge, but that doesn't mean we can't optimize the build tooling to introduce incremental builds

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

Where is this documented?

@jkoritzinsky

Copy link
Copy Markdown
Member

You can set the IlcMultiModule property to true to try it out. Once again, no guarantees, this is unsupported, etc.

I also don't know if we actually have the MSBuild targets set up correctly to make this run in an incremental manner so this may need more work for that front.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

@jkotas@jkoritzinsky I have created an issue here for NativeAOT incremental compilation support: #132977

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thanks — I tried IlcMultiModule on our RDM NativeAOT build. After working around a Windows target-plumbing issue (TargetOS was not forwarded to BuildFrameworkNativeObjects.proj), framework caching did work: 175 framework objects plus Framework.lib were byte/mtime-identical, and an identical publish dropped from 420.7s to 63.1s.

The app targets only compiled the top-level module, not its 509 non-framework references, so the stock link failed with 168 unresolved externals. I also tried custom per-module orchestration: 505/509 modules compiled and all 505 were reused on a warm run, but four modules hit rooting/generic issues and the aggregate link still failed with 1,248 unresolved externals. So this validates framework-level incrementality, but app-module orchestration and multifile generic/reflection support need more work before it is usable for this workload.

Note

This comment was drafted with GitHub Copilot.

@MichalStrehovsky

Copy link
Copy Markdown
Member

Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.

This is just the start. An incremental compilation needs to disable all whole program optimizations, including trimming. These optimizations all have butterfly effects that are difficult to capture and reconstruct., A small change in one method might invalidate an optimization done elsewhere.

Once all the applicable optimizations are disabled, an incremental compilation mode gives:

  1. Output that behaves differently from a real optimized native AOT compilation (this applies in the presence of trim/AOT unsafe code - the trimming+optimizations are invisible to trim/AOT safe code)
  2. Subpar quality outputs because all the ways that native AOT could do better through trimming and whole program optimizations are gone.

If the problem is that the RDM codebase has many trimming warnings (I saw Newtonsoft) and instead of fixing the warnings you're rooting assemblies and retesting, you're not going to have a good time with native AOT or trimming and incremental compilation will not help you, unless you intend to ship the unoptimized incremental build.

First step to native AOT conversion is eliminating all the trimming warnings, we document that in the first section. Maybe the doc doesn't explain the gravity of the situation well enough but the "ensure there are no behavior changes by thoroughly testing your app after building as Native AOT" part really means that if there is any trimming warning, you need to retest the entire app after any change due to the butterfly effects. It is not possible to reliably ship a large app that has trimming warnings.

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

I understand where you are going with this, but I respectfully disagree: this means you have to fully port your app for NativeAOT before you can start trying it out despite the warnings. It's important to be able to iterate on the partial port early on to help with prioritization of the work to be done, but also early validation of code paths which differ significantly in a NativeAOT build as opposed to the managed build.

AI made it possible to consider porting RDM to NativeAOT, but it is still a moonshot project. The first blocker we recently resolved was porting all of RDM Windows from DevExpress+WinForms to Avalonia UI, which involved about 1500+ UI components. WinForms is fine for NativeAOT, but DevExpress isn't. That alone took almost a year with AI, and once finally fixed, I could have my first successful launches of the RDM Windows application with NativeAOT despite a ton of build warnings. Not all features work, but it's enough to start iterating and validating by prioritizing what areas of the application should be fixed next for NativeAOT safety.

The second major blocker we have resolved recently is the PowerShell SDK, which is in-process, and will always rely on JIT for obvious reasons. I didn't want to move things out of process because we expose .NET live objects in scripts in-process for some advanced features. I developed my own PowerShell SDK that remains in-process, and offers live object proxies from a NativeAOT-safe .NET application. Here's the custom PowerShell SDK I developed specifically for NativeAOT consumption: https://github.com/Devolutions/multi-pwsh

My point is not that I would like to ship RDM in production with tons of trim-safety warnings remaining. It is that I would like to iterate much faster on experimental (or production) NativeAOT builds. Managed builds differ too much from NativeAOT builds for them to be useful beyond producing warnings. For instance, my managed build of RDM uses the regular PowerShell SDK while the NativeAOT build uses my NativeAOT-safe PowerShell SDK replacement. Several features are also gated for NativeAOT-safety, as I work my way through the huge backlog of things to fix.

But let's say we fast forward to the future at a point where I have fixed all the NativeAOT build warnings. Even then, my NativeAOT builds will still be extremely slow (RDM won't magically become thin and lightweight), so it means I still have to use managed builds for the inner loop. Those managed builds will always significantly differ from a NativeAOT build. There is a point to be made about introducing incremental builds in NativeAOT in places where it can be done.

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds. I want to explore ways we can do it in places where there's a potential gain to be made, and then work on making the non-reusable parts of the build process faster, in hope that the NativeAOT build time becomes manageable. Right now, it's slow even for a CI build, and we need to bring it down.

@agocke

Copy link
Copy Markdown
Member

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds.

I don't see how this follows. As Michal mentioned, whole program optimization means that a change in any method can produce changes in completely unrelated methods. For an incremental compilation to be correct this would somehow need to be fully accounted for, and it might end up leading to zero reuse in common cases.

@agocke

Copy link
Copy Markdown
Member

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

thanks Andy, that explains it much better. I suggest closing the current PR, and keep the discussion going in the high-level NativeAOT incremental build issue: #132977

I would then research how we could take inspiration from rustc internals and come up with a new prototype for incremental builds in .NET NativeAOT does that doesn't disable certain passes. It would obviously be a lot more work, but I'm up for giving it a try.

@MichalPetryka

Copy link
Copy Markdown
Contributor

The only prior art I'm aware of here is rustc.

MSVC does have incremental builds and LTCG, afair they track everything to handle wpo changes.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@awakecoding@jkotas@jkoritzinsky@MichalStrehovsky@agocke@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[NativeAOT] Prototype guarded incremental compilation - #132962

Closed
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype
Closed

[NativeAOT] Prototype guarded incremental compilation#132962
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype

Conversation

@awakecoding

@awakecodingawakecoding commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a disabled-by-default, internal NativeAOT incremental-compilation prototype for Windows x64 COFF. A single ILC process retains its completed dependency graph, recompiles a prevalidated finite set of changed method bodies, and creates each updated object by patching a copy of the immutable baseline object.

The prototype adds no public API. Incremental rejection is explicit: ILC prints ILC_INCREMENTAL_REJECTED, exits with code 85, removes outputs it created, and requires the caller to start a fresh clean compilation. Ordinary compiler failures are not classified as clean-fallback requests.

Tracking issue: #132977

Implementation

  • Captures the effective IL provider chain, including generated P/Invoke stubs.
  • Validates all update assemblies before baseline object emission.
  • Resets the mutable MethodCodeNode code, GC, EH, debug, local, and dependency state required for recompilation.
  • Recompiles the union of methods changed by the current and previous updates, so sequential edits and reverts always derive from the original baseline.
  • Verifies ordered static and conditional dependencies, reasons, marked state, GC info, frame/unwind data, EH state, debug state, symbols, relocations/addends, alignment, COMDAT state, and object locations after code generation.
  • Records selected COFF fragment locations per compilation and binds the baseline assembly, configuration, and object with SHA-256.
  • Copies from the same verified baseline handle, validates every non-relocation byte, stages unique same-directory files, flushes them to disk, and publishes without overwrite.
  • Poisons retained state after any post-mutation failure so a failed attempt cannot be reused.

Supported envelope

Incremental compilation is accepted only for:

  • A Windows host targeting Windows x64 NativeAOT COFF, single-file compilation, and one primary input.
  • OptimizationMode.None with exactly one compiler thread.
  • Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.
  • Baseline and updated PE files with equal length, MVID, metadata method count, and complete non-body content after masking only timestamp, checksum, debug-directory, strong-name payload, and encoded method-body ranges.
  • Method bodies with unchanged encoded size, max stack, init-locals, local signature, and EH shape.
  • Non-constructor, non-generic leaf methods with exactly one marked, non-canonical, non-unboxing, non-foldable MethodCodeNode, no conditional dependencies or EH, and overlayable ECMA IL.
  • Identical allowed opcode streams where only explicit integer or floating-point constant operands change.
  • Selected non-COMDAT COFF fragments whose size, alignment, symbols, relocations, addends, GC/frame/EH/debug state, location, bounds, and overlap checks remain valid.

All other cases request a clean compilation.

RDM build-performance impact

Measured on one eligible edit to reachable Program.BuildModuleViewIndex in the RDM RdmNativeAotFast=true workload:

RDM stepClean pathRetained incremental pathImpact
ILC object generation/update640.395 s0.291837 s2,194.36x faster; 99.9544% less wall time
Native link36.55 s36.55 sunchanged
ILC + native link developer loop676.945 s (11m 16.9s)36.842 s18.37x faster; 94.56% less wall time
Time saved per eligible edit/link iteration640.103 s (10m 40.1s)derived from the rows above

The 3,744,339,247-byte incremental object exactly matched the clean object with SHA-256 B3140045782498DC4A06F712C2DAA329B732D6340DFCD3D80AD4181E17844206. The update reused 13,455,307 of 13,455,308 object nodes, patched one byte, and allocated 1,568,736 managed bytes.

These are measured component timings. A separate clean RDM publish measured 951.9 s (15m 51.9s), but a comparable complete incremental dotnet publish was not timed. The defensible practical result is the ~36.8-second edit-and-link loop instead of ~10–11 minutes, not a 36.8-second full build.

The first request still pays for the clean compilation and retains roughly 34–36 GiB. The result applies only to edits that pass the narrow safety gate.

Validation

  • build.cmd clr+libs+host baseline: succeeded with 0 warnings/errors.
  • build.cmd clr.aot+libs -rc Release -lc Release: succeeded with 0 warnings/errors.
  • ILCompiler.Compiler.Tests Release: 80 passed, 0 failed, 0 skipped.
  • Focused IncrementalCompilationTests: 58 passed, 0 failed, 0 skipped.
  • Dedicated priority-0 Windows-x64 NativeAOT incremental smoke test: runs automatically during BuildNativeAot and succeeded with 0 warnings/errors.
    • Incremental edited object and independent clean edited object: 0BE481A1B058F826D4FCD0E013DEFC544BF4382E16CE8C5549EF94AE1F73666F.
    • Reverted object and baseline object: 0CB70EB6CAA77ABC4C6F120AE64C1AF3F6370A37AEC4FD54FBF8A96179E44497.
    • Explicit exit-85 rejection logging was validated under Windows PowerShell; CFG legs skip the unsupported differential path.
    • Build-only comparison objects are written under the intermediate tree and excluded from Helix payloads.

Limitations

Normal compiler-driven source edits commonly change the MVID and therefore request a clean compilation. Productization would require a supported command/API contract, complete cross-process content keys for references/resources/toolchain/JIT/environment, request isolation, eviction and crash recovery, broader invalidation for optimized/inlined/preinitialized/reflection/generic/global facts, discovery of newly dirty nodes, validation or regeneration of linker-affecting side outputs, and compile-time-checked internal seams across the compiler assemblies.

Note

This pull request description was generated with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@jkotas

Copy link
Copy Markdown
Member

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

@jkoritzinsky

Copy link
Copy Markdown
Member

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

I am trying to improve incremental build performance for NativeAOT on very large projects like Remote Desktop Manager. It currently takes a good 15 minutes to make a change and rebuild, and memory peaks over 32GB in ILC. I've been told you're supposed to just make managed builds with the warnings for NativeAOT to figure out what to fix for NativeAOT and then just wait on the NativeAOT build, trusting that it works, but this prevents actually trying out the NativeAOT build and repeatedly iterate on it. I know my project (RDM) is huge, but that doesn't mean we can't optimize the build tooling to introduce incremental builds

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

Where is this documented?

@jkoritzinsky

Copy link
Copy Markdown
Member

You can set the IlcMultiModule property to true to try it out. Once again, no guarantees, this is unsupported, etc.

I also don't know if we actually have the MSBuild targets set up correctly to make this run in an incremental manner so this may need more work for that front.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

@jkotas@jkoritzinsky I have created an issue here for NativeAOT incremental compilation support: #132977

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thanks — I tried IlcMultiModule on our RDM NativeAOT build. After working around a Windows target-plumbing issue (TargetOS was not forwarded to BuildFrameworkNativeObjects.proj), framework caching did work: 175 framework objects plus Framework.lib were byte/mtime-identical, and an identical publish dropped from 420.7s to 63.1s.

The app targets only compiled the top-level module, not its 509 non-framework references, so the stock link failed with 168 unresolved externals. I also tried custom per-module orchestration: 505/509 modules compiled and all 505 were reused on a warm run, but four modules hit rooting/generic issues and the aggregate link still failed with 1,248 unresolved externals. So this validates framework-level incrementality, but app-module orchestration and multifile generic/reflection support need more work before it is usable for this workload.

Note

This comment was drafted with GitHub Copilot.

@MichalStrehovsky

Copy link
Copy Markdown
Member

Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.

This is just the start. An incremental compilation needs to disable all whole program optimizations, including trimming. These optimizations all have butterfly effects that are difficult to capture and reconstruct., A small change in one method might invalidate an optimization done elsewhere.

Once all the applicable optimizations are disabled, an incremental compilation mode gives:

  1. Output that behaves differently from a real optimized native AOT compilation (this applies in the presence of trim/AOT unsafe code - the trimming+optimizations are invisible to trim/AOT safe code)
  2. Subpar quality outputs because all the ways that native AOT could do better through trimming and whole program optimizations are gone.

If the problem is that the RDM codebase has many trimming warnings (I saw Newtonsoft) and instead of fixing the warnings you're rooting assemblies and retesting, you're not going to have a good time with native AOT or trimming and incremental compilation will not help you, unless you intend to ship the unoptimized incremental build.

First step to native AOT conversion is eliminating all the trimming warnings, we document that in the first section. Maybe the doc doesn't explain the gravity of the situation well enough but the "ensure there are no behavior changes by thoroughly testing your app after building as Native AOT" part really means that if there is any trimming warning, you need to retest the entire app after any change due to the butterfly effects. It is not possible to reliably ship a large app that has trimming warnings.

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

I understand where you are going with this, but I respectfully disagree: this means you have to fully port your app for NativeAOT before you can start trying it out despite the warnings. It's important to be able to iterate on the partial port early on to help with prioritization of the work to be done, but also early validation of code paths which differ significantly in a NativeAOT build as opposed to the managed build.

AI made it possible to consider porting RDM to NativeAOT, but it is still a moonshot project. The first blocker we recently resolved was porting all of RDM Windows from DevExpress+WinForms to Avalonia UI, which involved about 1500+ UI components. WinForms is fine for NativeAOT, but DevExpress isn't. That alone took almost a year with AI, and once finally fixed, I could have my first successful launches of the RDM Windows application with NativeAOT despite a ton of build warnings. Not all features work, but it's enough to start iterating and validating by prioritizing what areas of the application should be fixed next for NativeAOT safety.

The second major blocker we have resolved recently is the PowerShell SDK, which is in-process, and will always rely on JIT for obvious reasons. I didn't want to move things out of process because we expose .NET live objects in scripts in-process for some advanced features. I developed my own PowerShell SDK that remains in-process, and offers live object proxies from a NativeAOT-safe .NET application. Here's the custom PowerShell SDK I developed specifically for NativeAOT consumption: https://github.com/Devolutions/multi-pwsh

My point is not that I would like to ship RDM in production with tons of trim-safety warnings remaining. It is that I would like to iterate much faster on experimental (or production) NativeAOT builds. Managed builds differ too much from NativeAOT builds for them to be useful beyond producing warnings. For instance, my managed build of RDM uses the regular PowerShell SDK while the NativeAOT build uses my NativeAOT-safe PowerShell SDK replacement. Several features are also gated for NativeAOT-safety, as I work my way through the huge backlog of things to fix.

But let's say we fast forward to the future at a point where I have fixed all the NativeAOT build warnings. Even then, my NativeAOT builds will still be extremely slow (RDM won't magically become thin and lightweight), so it means I still have to use managed builds for the inner loop. Those managed builds will always significantly differ from a NativeAOT build. There is a point to be made about introducing incremental builds in NativeAOT in places where it can be done.

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds. I want to explore ways we can do it in places where there's a potential gain to be made, and then work on making the non-reusable parts of the build process faster, in hope that the NativeAOT build time becomes manageable. Right now, it's slow even for a CI build, and we need to bring it down.

@agocke

Copy link
Copy Markdown
Member

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds.

I don't see how this follows. As Michal mentioned, whole program optimization means that a change in any method can produce changes in completely unrelated methods. For an incremental compilation to be correct this would somehow need to be fully accounted for, and it might end up leading to zero reuse in common cases.

@agocke

Copy link
Copy Markdown
Member

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

thanks Andy, that explains it much better. I suggest closing the current PR, and keep the discussion going in the high-level NativeAOT incremental build issue: #132977

I would then research how we could take inspiration from rustc internals and come up with a new prototype for incremental builds in .NET NativeAOT does that doesn't disable certain passes. It would obviously be a lot more work, but I'm up for giving it a try.

@MichalPetryka

Copy link
Copy Markdown
Contributor

The only prior art I'm aware of here is rustc.

MSVC does have incremental builds and LTCG, afair they track everything to handle wpo changes.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@awakecoding@jkotas@jkoritzinsky@MichalStrehovsky@agocke@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[NativeAOT] Prototype guarded incremental compilation - #132962

Closed
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype
Closed

[NativeAOT] Prototype guarded incremental compilation#132962
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype

Conversation

@awakecoding

@awakecodingawakecoding commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a disabled-by-default, internal NativeAOT incremental-compilation prototype for Windows x64 COFF. A single ILC process retains its completed dependency graph, recompiles a prevalidated finite set of changed method bodies, and creates each updated object by patching a copy of the immutable baseline object.

The prototype adds no public API. Incremental rejection is explicit: ILC prints ILC_INCREMENTAL_REJECTED, exits with code 85, removes outputs it created, and requires the caller to start a fresh clean compilation. Ordinary compiler failures are not classified as clean-fallback requests.

Tracking issue: #132977

Implementation

  • Captures the effective IL provider chain, including generated P/Invoke stubs.
  • Validates all update assemblies before baseline object emission.
  • Resets the mutable MethodCodeNode code, GC, EH, debug, local, and dependency state required for recompilation.
  • Recompiles the union of methods changed by the current and previous updates, so sequential edits and reverts always derive from the original baseline.
  • Verifies ordered static and conditional dependencies, reasons, marked state, GC info, frame/unwind data, EH state, debug state, symbols, relocations/addends, alignment, COMDAT state, and object locations after code generation.
  • Records selected COFF fragment locations per compilation and binds the baseline assembly, configuration, and object with SHA-256.
  • Copies from the same verified baseline handle, validates every non-relocation byte, stages unique same-directory files, flushes them to disk, and publishes without overwrite.
  • Poisons retained state after any post-mutation failure so a failed attempt cannot be reused.

Supported envelope

Incremental compilation is accepted only for:

  • A Windows host targeting Windows x64 NativeAOT COFF, single-file compilation, and one primary input.
  • OptimizationMode.None with exactly one compiler thread.
  • Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.
  • Baseline and updated PE files with equal length, MVID, metadata method count, and complete non-body content after masking only timestamp, checksum, debug-directory, strong-name payload, and encoded method-body ranges.
  • Method bodies with unchanged encoded size, max stack, init-locals, local signature, and EH shape.
  • Non-constructor, non-generic leaf methods with exactly one marked, non-canonical, non-unboxing, non-foldable MethodCodeNode, no conditional dependencies or EH, and overlayable ECMA IL.
  • Identical allowed opcode streams where only explicit integer or floating-point constant operands change.
  • Selected non-COMDAT COFF fragments whose size, alignment, symbols, relocations, addends, GC/frame/EH/debug state, location, bounds, and overlap checks remain valid.

All other cases request a clean compilation.

RDM build-performance impact

Measured on one eligible edit to reachable Program.BuildModuleViewIndex in the RDM RdmNativeAotFast=true workload:

RDM stepClean pathRetained incremental pathImpact
ILC object generation/update640.395 s0.291837 s2,194.36x faster; 99.9544% less wall time
Native link36.55 s36.55 sunchanged
ILC + native link developer loop676.945 s (11m 16.9s)36.842 s18.37x faster; 94.56% less wall time
Time saved per eligible edit/link iteration640.103 s (10m 40.1s)derived from the rows above

The 3,744,339,247-byte incremental object exactly matched the clean object with SHA-256 B3140045782498DC4A06F712C2DAA329B732D6340DFCD3D80AD4181E17844206. The update reused 13,455,307 of 13,455,308 object nodes, patched one byte, and allocated 1,568,736 managed bytes.

These are measured component timings. A separate clean RDM publish measured 951.9 s (15m 51.9s), but a comparable complete incremental dotnet publish was not timed. The defensible practical result is the ~36.8-second edit-and-link loop instead of ~10–11 minutes, not a 36.8-second full build.

The first request still pays for the clean compilation and retains roughly 34–36 GiB. The result applies only to edits that pass the narrow safety gate.

Validation

  • build.cmd clr+libs+host baseline: succeeded with 0 warnings/errors.
  • build.cmd clr.aot+libs -rc Release -lc Release: succeeded with 0 warnings/errors.
  • ILCompiler.Compiler.Tests Release: 80 passed, 0 failed, 0 skipped.
  • Focused IncrementalCompilationTests: 58 passed, 0 failed, 0 skipped.
  • Dedicated priority-0 Windows-x64 NativeAOT incremental smoke test: runs automatically during BuildNativeAot and succeeded with 0 warnings/errors.
    • Incremental edited object and independent clean edited object: 0BE481A1B058F826D4FCD0E013DEFC544BF4382E16CE8C5549EF94AE1F73666F.
    • Reverted object and baseline object: 0CB70EB6CAA77ABC4C6F120AE64C1AF3F6370A37AEC4FD54FBF8A96179E44497.
    • Explicit exit-85 rejection logging was validated under Windows PowerShell; CFG legs skip the unsupported differential path.
    • Build-only comparison objects are written under the intermediate tree and excluded from Helix payloads.

Limitations

Normal compiler-driven source edits commonly change the MVID and therefore request a clean compilation. Productization would require a supported command/API contract, complete cross-process content keys for references/resources/toolchain/JIT/environment, request isolation, eviction and crash recovery, broader invalidation for optimized/inlined/preinitialized/reflection/generic/global facts, discovery of newly dirty nodes, validation or regeneration of linker-affecting side outputs, and compile-time-checked internal seams across the compiler assemblies.

Note

This pull request description was generated with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@jkotas

Copy link
Copy Markdown
Member

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

@jkoritzinsky

Copy link
Copy Markdown
Member

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

I am trying to improve incremental build performance for NativeAOT on very large projects like Remote Desktop Manager. It currently takes a good 15 minutes to make a change and rebuild, and memory peaks over 32GB in ILC. I've been told you're supposed to just make managed builds with the warnings for NativeAOT to figure out what to fix for NativeAOT and then just wait on the NativeAOT build, trusting that it works, but this prevents actually trying out the NativeAOT build and repeatedly iterate on it. I know my project (RDM) is huge, but that doesn't mean we can't optimize the build tooling to introduce incremental builds

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

Where is this documented?

@jkoritzinsky

Copy link
Copy Markdown
Member

You can set the IlcMultiModule property to true to try it out. Once again, no guarantees, this is unsupported, etc.

I also don't know if we actually have the MSBuild targets set up correctly to make this run in an incremental manner so this may need more work for that front.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

@jkotas@jkoritzinsky I have created an issue here for NativeAOT incremental compilation support: #132977

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thanks — I tried IlcMultiModule on our RDM NativeAOT build. After working around a Windows target-plumbing issue (TargetOS was not forwarded to BuildFrameworkNativeObjects.proj), framework caching did work: 175 framework objects plus Framework.lib were byte/mtime-identical, and an identical publish dropped from 420.7s to 63.1s.

The app targets only compiled the top-level module, not its 509 non-framework references, so the stock link failed with 168 unresolved externals. I also tried custom per-module orchestration: 505/509 modules compiled and all 505 were reused on a warm run, but four modules hit rooting/generic issues and the aggregate link still failed with 1,248 unresolved externals. So this validates framework-level incrementality, but app-module orchestration and multifile generic/reflection support need more work before it is usable for this workload.

Note

This comment was drafted with GitHub Copilot.

@MichalStrehovsky

Copy link
Copy Markdown
Member

Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.

This is just the start. An incremental compilation needs to disable all whole program optimizations, including trimming. These optimizations all have butterfly effects that are difficult to capture and reconstruct., A small change in one method might invalidate an optimization done elsewhere.

Once all the applicable optimizations are disabled, an incremental compilation mode gives:

  1. Output that behaves differently from a real optimized native AOT compilation (this applies in the presence of trim/AOT unsafe code - the trimming+optimizations are invisible to trim/AOT safe code)
  2. Subpar quality outputs because all the ways that native AOT could do better through trimming and whole program optimizations are gone.

If the problem is that the RDM codebase has many trimming warnings (I saw Newtonsoft) and instead of fixing the warnings you're rooting assemblies and retesting, you're not going to have a good time with native AOT or trimming and incremental compilation will not help you, unless you intend to ship the unoptimized incremental build.

First step to native AOT conversion is eliminating all the trimming warnings, we document that in the first section. Maybe the doc doesn't explain the gravity of the situation well enough but the "ensure there are no behavior changes by thoroughly testing your app after building as Native AOT" part really means that if there is any trimming warning, you need to retest the entire app after any change due to the butterfly effects. It is not possible to reliably ship a large app that has trimming warnings.

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

I understand where you are going with this, but I respectfully disagree: this means you have to fully port your app for NativeAOT before you can start trying it out despite the warnings. It's important to be able to iterate on the partial port early on to help with prioritization of the work to be done, but also early validation of code paths which differ significantly in a NativeAOT build as opposed to the managed build.

AI made it possible to consider porting RDM to NativeAOT, but it is still a moonshot project. The first blocker we recently resolved was porting all of RDM Windows from DevExpress+WinForms to Avalonia UI, which involved about 1500+ UI components. WinForms is fine for NativeAOT, but DevExpress isn't. That alone took almost a year with AI, and once finally fixed, I could have my first successful launches of the RDM Windows application with NativeAOT despite a ton of build warnings. Not all features work, but it's enough to start iterating and validating by prioritizing what areas of the application should be fixed next for NativeAOT safety.

The second major blocker we have resolved recently is the PowerShell SDK, which is in-process, and will always rely on JIT for obvious reasons. I didn't want to move things out of process because we expose .NET live objects in scripts in-process for some advanced features. I developed my own PowerShell SDK that remains in-process, and offers live object proxies from a NativeAOT-safe .NET application. Here's the custom PowerShell SDK I developed specifically for NativeAOT consumption: https://github.com/Devolutions/multi-pwsh

My point is not that I would like to ship RDM in production with tons of trim-safety warnings remaining. It is that I would like to iterate much faster on experimental (or production) NativeAOT builds. Managed builds differ too much from NativeAOT builds for them to be useful beyond producing warnings. For instance, my managed build of RDM uses the regular PowerShell SDK while the NativeAOT build uses my NativeAOT-safe PowerShell SDK replacement. Several features are also gated for NativeAOT-safety, as I work my way through the huge backlog of things to fix.

But let's say we fast forward to the future at a point where I have fixed all the NativeAOT build warnings. Even then, my NativeAOT builds will still be extremely slow (RDM won't magically become thin and lightweight), so it means I still have to use managed builds for the inner loop. Those managed builds will always significantly differ from a NativeAOT build. There is a point to be made about introducing incremental builds in NativeAOT in places where it can be done.

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds. I want to explore ways we can do it in places where there's a potential gain to be made, and then work on making the non-reusable parts of the build process faster, in hope that the NativeAOT build time becomes manageable. Right now, it's slow even for a CI build, and we need to bring it down.

@agocke

Copy link
Copy Markdown
Member

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds.

I don't see how this follows. As Michal mentioned, whole program optimization means that a change in any method can produce changes in completely unrelated methods. For an incremental compilation to be correct this would somehow need to be fully accounted for, and it might end up leading to zero reuse in common cases.

@agocke

Copy link
Copy Markdown
Member

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

thanks Andy, that explains it much better. I suggest closing the current PR, and keep the discussion going in the high-level NativeAOT incremental build issue: #132977

I would then research how we could take inspiration from rustc internals and come up with a new prototype for incremental builds in .NET NativeAOT does that doesn't disable certain passes. It would obviously be a lot more work, but I'm up for giving it a try.

@MichalPetryka

Copy link
Copy Markdown
Contributor

The only prior art I'm aware of here is rustc.

MSVC does have incremental builds and LTCG, afair they track everything to handle wpo changes.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@awakecoding@jkotas@jkoritzinsky@MichalStrehovsky@agocke@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[NativeAOT] Prototype guarded incremental compilation - #132962

Closed
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype
Closed

[NativeAOT] Prototype guarded incremental compilation#132962
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-incremental-prototype

Conversation

@awakecoding

@awakecodingawakecoding commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a disabled-by-default, internal NativeAOT incremental-compilation prototype for Windows x64 COFF. A single ILC process retains its completed dependency graph, recompiles a prevalidated finite set of changed method bodies, and creates each updated object by patching a copy of the immutable baseline object.

The prototype adds no public API. Incremental rejection is explicit: ILC prints ILC_INCREMENTAL_REJECTED, exits with code 85, removes outputs it created, and requires the caller to start a fresh clean compilation. Ordinary compiler failures are not classified as clean-fallback requests.

Tracking issue: #132977

Implementation

  • Captures the effective IL provider chain, including generated P/Invoke stubs.
  • Validates all update assemblies before baseline object emission.
  • Resets the mutable MethodCodeNode code, GC, EH, debug, local, and dependency state required for recompilation.
  • Recompiles the union of methods changed by the current and previous updates, so sequential edits and reverts always derive from the original baseline.
  • Verifies ordered static and conditional dependencies, reasons, marked state, GC info, frame/unwind data, EH state, debug state, symbols, relocations/addends, alignment, COMDAT state, and object locations after code generation.
  • Records selected COFF fragment locations per compilation and binds the baseline assembly, configuration, and object with SHA-256.
  • Copies from the same verified baseline handle, validates every non-relocation byte, stages unique same-directory files, flushes them to disk, and publishes without overwrite.
  • Poisons retained state after any post-mutation failure so a failed attempt cannot be reused.

Supported envelope

Incremental compilation is accepted only for:

  • A Windows host targeting Windows x64 NativeAOT COFF, single-file compilation, and one primary input.
  • OptimizationMode.None with exactly one compiler thread.
  • Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.
  • Baseline and updated PE files with equal length, MVID, metadata method count, and complete non-body content after masking only timestamp, checksum, debug-directory, strong-name payload, and encoded method-body ranges.
  • Method bodies with unchanged encoded size, max stack, init-locals, local signature, and EH shape.
  • Non-constructor, non-generic leaf methods with exactly one marked, non-canonical, non-unboxing, non-foldable MethodCodeNode, no conditional dependencies or EH, and overlayable ECMA IL.
  • Identical allowed opcode streams where only explicit integer or floating-point constant operands change.
  • Selected non-COMDAT COFF fragments whose size, alignment, symbols, relocations, addends, GC/frame/EH/debug state, location, bounds, and overlap checks remain valid.

All other cases request a clean compilation.

RDM build-performance impact

Measured on one eligible edit to reachable Program.BuildModuleViewIndex in the RDM RdmNativeAotFast=true workload:

RDM stepClean pathRetained incremental pathImpact
ILC object generation/update640.395 s0.291837 s2,194.36x faster; 99.9544% less wall time
Native link36.55 s36.55 sunchanged
ILC + native link developer loop676.945 s (11m 16.9s)36.842 s18.37x faster; 94.56% less wall time
Time saved per eligible edit/link iteration640.103 s (10m 40.1s)derived from the rows above

The 3,744,339,247-byte incremental object exactly matched the clean object with SHA-256 B3140045782498DC4A06F712C2DAA329B732D6340DFCD3D80AD4181E17844206. The update reused 13,455,307 of 13,455,308 object nodes, patched one byte, and allocated 1,568,736 managed bytes.

These are measured component timings. A separate clean RDM publish measured 951.9 s (15m 51.9s), but a comparable complete incremental dotnet publish was not timed. The defensible practical result is the ~36.8-second edit-and-link loop instead of ~10–11 minutes, not a 36.8-second full build.

The first request still pays for the clean compilation and retains roughly 34–36 GiB. The result applies only to edits that pass the narrow safety gate.

Validation

  • build.cmd clr+libs+host baseline: succeeded with 0 warnings/errors.
  • build.cmd clr.aot+libs -rc Release -lc Release: succeeded with 0 warnings/errors.
  • ILCompiler.Compiler.Tests Release: 80 passed, 0 failed, 0 skipped.
  • Focused IncrementalCompilationTests: 58 passed, 0 failed, 0 skipped.
  • Dedicated priority-0 Windows-x64 NativeAOT incremental smoke test: runs automatically during BuildNativeAot and succeeded with 0 warnings/errors.
    • Incremental edited object and independent clean edited object: 0BE481A1B058F826D4FCD0E013DEFC544BF4382E16CE8C5549EF94AE1F73666F.
    • Reverted object and baseline object: 0CB70EB6CAA77ABC4C6F120AE64C1AF3F6370A37AEC4FD54FBF8A96179E44497.
    • Explicit exit-85 rejection logging was validated under Windows PowerShell; CFG legs skip the unsupported differential path.
    • Build-only comparison objects are written under the intermediate tree and excluded from Helix payloads.

Limitations

Normal compiler-driven source edits commonly change the MVID and therefore request a clean compilation. Productization would require a supported command/API contract, complete cross-process content keys for references/resources/toolchain/JIT/environment, request isolation, eviction and crash recovery, broader invalidation for optimized/inlined/preinitialized/reflection/generic/global facts, discovery of newly dirty nodes, validation or regeneration of linker-affecting side outputs, and compile-time-checked internal seams across the compiler assemblies.

Note

This pull request description was generated with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@jkotas

Copy link
Copy Markdown
Member

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

@jkoritzinsky

Copy link
Copy Markdown
Member

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

What are you trying to achieve with this?

It would be better to start with an issue discussing the experience you would like to see.

I am trying to improve incremental build performance for NativeAOT on very large projects like Remote Desktop Manager. It currently takes a good 15 minutes to make a change and rebuild, and memory peaks over 32GB in ILC. I've been told you're supposed to just make managed builds with the warnings for NativeAOT to figure out what to fix for NativeAOT and then just wait on the NativeAOT build, trusting that it works, but this prevents actually trying out the NativeAOT build and repeatedly iterate on it. I know my project (RDM) is huge, but that doesn't mean we can't optimize the build tooling to introduce incremental builds

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

I feel like the existing "multifile" mode that we haven't productized is a better approach here (one object file per assembly, would allow incremental ILC execution at the assembly boundary).

Where is this documented?

@jkoritzinsky

Copy link
Copy Markdown
Member

You can set the IlcMultiModule property to true to try it out. Once again, no guarantees, this is unsupported, etc.

I also don't know if we actually have the MSBuild targets set up correctly to make this run in an incremental manner so this may need more work for that front.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

@jkotas@jkoritzinsky I have created an issue here for NativeAOT incremental compilation support: #132977

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thanks — I tried IlcMultiModule on our RDM NativeAOT build. After working around a Windows target-plumbing issue (TargetOS was not forwarded to BuildFrameworkNativeObjects.proj), framework caching did work: 175 framework objects plus Framework.lib were byte/mtime-identical, and an identical publish dropped from 420.7s to 63.1s.

The app targets only compiled the top-level module, not its 509 non-framework references, so the stock link failed with 168 unresolved externals. I also tried custom per-module orchestration: 505/509 modules compiled and all 505 were reused on a warm run, but four modules hit rooting/generic issues and the aggregate link still failed with 1,248 unresolved externals. So this validates framework-level incrementality, but app-module orchestration and multifile generic/reflection support need more work before it is usable for this workload.

Note

This comment was drafted with GitHub Copilot.

@MichalStrehovsky

Copy link
Copy Markdown
Member

Scanner, preinitialization, custom inlining, method folding, native debug info, profile/order layout, custom JIT configuration, CFG, resilience, dehydration, dependency logs, exports, maps, metadata/SourceLink output, reachability modes, and other side outputs disabled.

This is just the start. An incremental compilation needs to disable all whole program optimizations, including trimming. These optimizations all have butterfly effects that are difficult to capture and reconstruct., A small change in one method might invalidate an optimization done elsewhere.

Once all the applicable optimizations are disabled, an incremental compilation mode gives:

  1. Output that behaves differently from a real optimized native AOT compilation (this applies in the presence of trim/AOT unsafe code - the trimming+optimizations are invisible to trim/AOT safe code)
  2. Subpar quality outputs because all the ways that native AOT could do better through trimming and whole program optimizations are gone.

If the problem is that the RDM codebase has many trimming warnings (I saw Newtonsoft) and instead of fixing the warnings you're rooting assemblies and retesting, you're not going to have a good time with native AOT or trimming and incremental compilation will not help you, unless you intend to ship the unoptimized incremental build.

First step to native AOT conversion is eliminating all the trimming warnings, we document that in the first section. Maybe the doc doesn't explain the gravity of the situation well enough but the "ensure there are no behavior changes by thoroughly testing your app after building as Native AOT" part really means that if there is any trimming warning, you need to retest the entire app after any change due to the butterfly effects. It is not possible to reliably ship a large app that has trimming warnings.

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

It would be better use of your tokens to have AI replace all the trim unsafe code in RDM. I bet the compile times will get more reasonable too once assemblies are no longer reflection-rooted.

I understand where you are going with this, but I respectfully disagree: this means you have to fully port your app for NativeAOT before you can start trying it out despite the warnings. It's important to be able to iterate on the partial port early on to help with prioritization of the work to be done, but also early validation of code paths which differ significantly in a NativeAOT build as opposed to the managed build.

AI made it possible to consider porting RDM to NativeAOT, but it is still a moonshot project. The first blocker we recently resolved was porting all of RDM Windows from DevExpress+WinForms to Avalonia UI, which involved about 1500+ UI components. WinForms is fine for NativeAOT, but DevExpress isn't. That alone took almost a year with AI, and once finally fixed, I could have my first successful launches of the RDM Windows application with NativeAOT despite a ton of build warnings. Not all features work, but it's enough to start iterating and validating by prioritizing what areas of the application should be fixed next for NativeAOT safety.

The second major blocker we have resolved recently is the PowerShell SDK, which is in-process, and will always rely on JIT for obvious reasons. I didn't want to move things out of process because we expose .NET live objects in scripts in-process for some advanced features. I developed my own PowerShell SDK that remains in-process, and offers live object proxies from a NativeAOT-safe .NET application. Here's the custom PowerShell SDK I developed specifically for NativeAOT consumption: https://github.com/Devolutions/multi-pwsh

My point is not that I would like to ship RDM in production with tons of trim-safety warnings remaining. It is that I would like to iterate much faster on experimental (or production) NativeAOT builds. Managed builds differ too much from NativeAOT builds for them to be useful beyond producing warnings. For instance, my managed build of RDM uses the regular PowerShell SDK while the NativeAOT build uses my NativeAOT-safe PowerShell SDK replacement. Several features are also gated for NativeAOT-safety, as I work my way through the huge backlog of things to fix.

But let's say we fast forward to the future at a point where I have fixed all the NativeAOT build warnings. Even then, my NativeAOT builds will still be extremely slow (RDM won't magically become thin and lightweight), so it means I still have to use managed builds for the inner loop. Those managed builds will always significantly differ from a NativeAOT build. There is a point to be made about introducing incremental builds in NativeAOT in places where it can be done.

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds. I want to explore ways we can do it in places where there's a potential gain to be made, and then work on making the non-reusable parts of the build process faster, in hope that the NativeAOT build time becomes manageable. Right now, it's slow even for a CI build, and we need to bring it down.

@agocke

Copy link
Copy Markdown
Member

I understand whole program optimization comes with its challenges - but that doesn't mean there is zero opportunity for introducing at least partial artifact reuse in between builds.

I don't see how this follows. As Michal mentioned, whole program optimization means that a change in any method can produce changes in completely unrelated methods. For an incremental compilation to be correct this would somehow need to be fully accounted for, and it might end up leading to zero reuse in common cases.

@agocke

Copy link
Copy Markdown
Member

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Thinking about this more, I think the fundamental problem with this PR is it tries to tackle the problem by disabling certain passes, which puts everything in an unsupported state. The proper path to incremental support probably goes through incremental dependency analysis. Even then, I'm not sure how much time you would actually save in practice. It's also a big project. The only prior art I'm aware of here is rustc.

thanks Andy, that explains it much better. I suggest closing the current PR, and keep the discussion going in the high-level NativeAOT incremental build issue: #132977

I would then research how we could take inspiration from rustc internals and come up with a new prototype for incremental builds in .NET NativeAOT does that doesn't disable certain passes. It would obviously be a lot more work, but I'm up for giving it a try.

@MichalPetryka

Copy link
Copy Markdown
Contributor

The only prior art I'm aware of here is rustc.

MSVC does have incremental builds and LTCG, afair they track everything to handle wpo changes.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@awakecoding@jkotas@jkoritzinsky@MichalStrehovsky@agocke@MichalPetryka