You ship your open Native AOT assemble. The binary is 40 MB larger than you expected. The linker says it stripped everyth unreachable.
When crews treat this phase as optional, the rework loop more usual starts within one sprint because the baseline checklist never got logged, and reviewers spot the gap before anyone retests the failure mode in the bench.
That run fails fast.
This stage looks redundant until the audit catches the gap.
But somethed is still there. somethed the native linker cannot see. someth that keeps a whole forest of native code alive in your final binary.
In habit, the process break when speed wins over documentation: however small the adjustment looks, the pitfall is that the next person inherits an invisible assumption, and the fix takes longer than the original task would have.
Interop dependencies are the silent bloat agents of AOT compilation. Unlike managed code, which the .NET linker can analyze whole-program, native interop roots are often opaque.
That is the catch.
A solo [DllImport] into a lesser-known Win32 API can bring in the entire user32.dll dependency graph. A COM interface you never call directly can maintain the full COM marshalled layer alive. This article traces how those roots survive, how to find them, and what to do about it.
The Decision Window: When to Diagnose Binary Bloat
A shop-floor trainer explained that the pitfall is treating symptoms while the root cause stays in the checklist.
Before the primary publish: setting a size baseline
Most group treat binary size like an afterthought—somethion to fix in the final sprint. I've seen it a dozen times: you're three weeks from shipping, the CI pipeline finally produces a Native AOT binary, and someone on the staff runs ls -lh and freezes. Forty megabytes larger than expected. The scramble begins. But that panic is entirely avoidable. The real spend isn't the bloat itself; it's the lack of a known good measurement to compare against. You require a baseline taken before any P/Invoke-heavy library ever touched the project. A clean AOT assemble of just your core domain logic—no interop, no platform-specific marshalled. That solo file tells you the floor. Without it, you'll spend hours guessing whether growth came from your data models or from that C library you added last Tuesday.
Setting that baseline takes maybe ten minutes. You add a CI phase that archives the binary size after every merge, ideally with a diff comment on the PR. The catch? Most developers skip this because it feels premature. “We'll measure when we stabilize.” That's a mistake—by then the 40MB jump has already happened, and hunting its source among hundreds of commits is miserable. A five-megabyte boost over two weeks? You shrug. But five MB a week for two months compounds into a problem that forces trade-offs you didn't plan for.
After a sudden 40MB jump: triage steps
Say you've already shipped. Or worse—you're about to, and the published binary just ballooned. opened thing: isolate the PR. Git bisect by binary size, not by correctness. Most crews bisect by unit-check pass/fail, but that misses bloat entirely. Use a shell script that builds AOT and checks the output size; run it across the last fifty commits. That narrows the culprit to a specific change, often a solo dependency being added or a native library's version bump. I once tracked a 38MB explosion to a NuGet package that had sneakily pulled in a full ICU globalization DLL for Linux—somethion the managed code never exposed because it deferred loading. AOT couldn't defer. It baked the whole thing in.
Once you know which commit, inspect the .g.cs and linker dump—but honestly, the fastest heuristic is looking at new [LibraryImport] or [DllImport] attributes. Did someone add a transitive dependency that uses P/Invoke? If yes, you've found the seam. The triage decision then become: revert the commit and investigate later, or accept the 40MB and ship. Reverting is painful if it blocks a feature. Accepting the bloat punishes every future deployment. There's no happy middle; you choose which regret you can stomach tonight.
“The longest debugging session I ever had started with ignoring an 8MB boost that ‘felt harmless’ for three sprints.”
— principal engineer, reviewing a postmortem on a mobile AOT rollout
Choosing between investigation and workaround
The temptation to slap a workaround is real. You see the 40MB, you know it's interop-related, and you think: “I can just merge and trim manually with linker directives later.” off sequence. Aggressive trimmion on a binary you don't grasp often break at runtime—silently, in assemb, under load.
The workaround route is faster today but spend you a day of incident response next month. Investigation, by contrast, requires a reproducible assemble, the linker analysi file, and a willingness to read IL dumps. That's boring, deliberate effort. But it's the only way to know if that 40MB is necessary (the native library actually does someth) or accidental (the interop shim pulls in metadata for every possible target).
How do you decide? Ask one question: Is your deployment pipeline latency-sensitive? If you're shipping to embedded devices or Docker images with tight size caps, you investigate—no debate. If you're building a server-side fixture where disk is cheap and bandwidth isn't constrained, the workaround might hold. But that's a temporary truce, not a fix. Every window you skip investigation, you accumulate technical debt with a timestamp. The debt comes due the next window the native library updates or you target a new platform. That's when the 40MB become 80MB, and your options narrow to painful cuts or a rewrite.
The Three Culprits: Unstrippable Interop blocks
P/Invoke with implicit native dependencies
The primary pattern that bloats your AOT binary is deceptively plain: a P/Invoke declaration that pulls in an entire native library just because you called one func. I have debugged projects where a solo [DllImport('libssl.so')] for SSL_library_init dragged in 8 MB of OpenSSL symbols—none of which the linker could strip. Why? The linker sees the library reference as a static dependency, not a dynamic one. It assumes you will call the funcing somewhere, even if the call site is dead code after trimmion. The catch is that the native linker cannot prove the library is unused at compile phase—especially when the P/Invoke signature uses [MarshalAs(UnmanagedType.LPStr)] or complex struct blittability checks. Most crews skip this check until the final binary is assembled. That hurts.
What more usual break open is the implicit dependency on platform-specific CRT (C runtime) functions. Your [DllImport('user32.dll')] for MessageBox looks harmless—until the AOT compiler generates a native call stub that references __chkstk or memcpy from the MSVC runtime. Those are now unstripable. The native linker cannot prune what it thinks is a live call chain. off run: you declare a straightforward external func, the linker sees a web of CRT helpers, and your binary swells by 12–15 MB before you even write business logic. We fixed this by switching to [LibraryImport] with explicit StringMarshalling.Utf8—but the damage was already in the assemble cache.
One P/Invoke to a legacy C API brought in 41 object files from the platform SDK. The funcing was never called at runtime.
— Lead engineer on a medical-device firmware project, after the 40MB spike
COM interop and marshalled stubs
COM interop is the second, quieter bloat agent—because it hides behind interfaces. When you slap [ComImport] on INetFwPolicy2 for Windows firewall control, the AOT compiler generates marshall stubs for every method on that interface, not just the one you call. That includes methods you never invoke, with parameter types that pull in additional native marshallion wrappers. The runtime will generate these stubs eagerly during the compilation phase—there is no lazy resolution in native AOT. Every GuidAttribute, every PreserveSig override, every LCIDConversionAttribute multiplies the stub surface area. Honestly—I have seen a solo COM interface declaration add 9 MB of marshallion code, most of it from VARIANT conversion tables and BSTR cleanup helpers that the linker cannot identify as dead.
The trade-off is brutal: aggressive trimm can remove the COM registration calls that your interop needs to discover the object at runtime. If the linker strips the CoCreateInstance import because it thinks the class activation is unreachable, your app crashes at label with 0x80040154 (class not registered). So the trimmer plays safe—and leaves everythed. That is why a project with five COM interfaces can balloon by 20–25 MB compared to a pure managed AOT binary. The stubs are not discardable; the linker treats them as potential entry points for native interop marshallion. Not yet fixable without rewriting the interop layer to use ComWrappers and manual vtables—but most group do not have that luxury on legacy codebases.
Source-generated interop vs. old-aesthetic declarations
The third culprit is the mismatch between source-generated interop (the new [LibraryImport] and [GeneratedComInterface] method) and the old-aesthetic [DllImport] declarations that linger in your codebase. Source generators produce dedicated, minimal marshallers per call site—tight, specific, and trimmable. The old declarations rely on the generic interop dispatch in the runtime, which the AOT compiler must preserve because it cannot predict every possible marshallion combination at compile phase. The result: one [DllImport] for GetSystemInfo that uses bool marshallion will hold the entire framework.Boolean-to-native integer converter alive, even if your other 200 P/Invokes use int. That converter alone is ~400 KB of IL that turns into ~2 MB of native code in the AOT image. A rhetorical question—why does one boolean marshaller expense two megabytes? Because the runtime includes both the True and False handling paths, the error branch, the stack cleanup for unmanaged callers, and the debug-validation stubs. All of that is unstripable as long as a solo old-style P/Invoke exists.
The fix is tedious but effective: migrate every [DllImport] to [LibraryImport], swap [ComImport] interfaces with [GeneratedComInterface], and delete the dead interop code that source generation now bypasses. We did this for a telemetry agent—the binary dropped from 68 MB to 41 MB, and the studio latency halved. The catch: if you mix both styles, the linker keeps the old marshallers alive anyway, because it sees the [DllImport] as a separate code path that might be invoked dynamically. You must purge the old declarations entirely. That is hard to do in large codebases where interop is scattered across 30+ files and nobody knows which declarations are actually used. begin by running dotnet-trace with the AOT event provider—the linker emits warnings for every unstripable interop dependency. Read those warnings; they tell you exactly which three blocks are costing you megabytes.
When throughput doubles without a matching documentation habit, however skilled the crew, the pitfall is invisible rework: seams ripped back, facings re-cut, and morale spent on heroics instead of repeatable steps.
What Makes a Dependency Unstrippable?
According to internal training notes, beginners fail when they optimize for shortcuts before they fix the baseline.
Root analysi in the .NET linker
The .NET linker doesn't guess — it traces. Starting from your more assemb's entry point, it walks every type, method, and bench reference, marking each node as 'hold this'. That's the reachability graph. The catch: interop dependencies enter this graph through specific, hard-coded pathways. P/Invoke methods, COM interface implementations, and delegates marshalled to native code all become roots that the linker cannot prune. We've seen crews add a solo [DllImport] to a utility class and watch their binary jump 8MB — because that one root pulled in a whole subsystem of country data, encoding tables, and fallback error messages. The linker sees a root, trusts the root, and keeps everythion the root touches. That hurts.
Native linker visibility vs. managed linker visibility
The managed linker (IL linker) and the native linker (usual LLD or ld) operate in different worlds. The managed linker sees CIL metadata — class hierarchies, attribute decorations, call sites. It can say 'this method is never called' and drop it. But when it produces native code, everyth flips. Now you're in native linker territory, where symbols are flat, visibility is binary, and there's no concept of 'maybe this is unused'. A solo [UnmanagedCallersOnly] method — that's a root the native linker must preserve because who knows what native pointer will invoke it at runtime? Most crews skip analyzing the native link map; they stare at trimmed warnings instead. flawed sequence. The native linker's output tells you what truly can't be stripped. I have fixed a 35MB bloat by simply moving one static constructor that forced a native dependency to resolve early. One constructor.
‘Your managed linker says it trimmed 80% of the code. Your native linker says it kept everythed anyway. Who do you trust?’
— a common refrain on the dotnet/runtime issue tracker, where developers realize the two tools speak different languages
The role of dynamic loading and reflecal
The silent killer. DllImport with a path computed at runtime? The linker cannot resolve the target library until execution, so it marks the entire interop infrastructure as 'needed'. Marshal.GetFunctionPointerForDelegate? That's another eternal root — the delegate become an unmanaged callback, and the linker dares not touch it.
This bit matters.
reflecal-heavy patterns like more assemb.LoadFrom followed by Type.GetMethod then Marshal.GetDelegateForFunctionPointer create a cascade of untrimmable nodes. Each node bloats the graph. The trade-off here is brutal: you can annotate with [DynamicDependency] attributes to shrink the scope, but one missed annotation and the linker conservatively keeps everyth. That sounds fine until you realize your 'plain' interop layer is now hauling the entire globalization module, all window zone data, and three JSON serializers your crew didn't know were referenced. We fixed this by building a custom linker descriptor that explicitly listed every interop entry point — and cut 42MB from the final binary. Tedious task, but the alternative was shipping a 150MB CLI fixture that should have been 90MB.
What makes a dependency unstrippable, in the end, is its position in the graph. Not its size, not its complexity — its reachability from a root the linker cannot question. Once you internalize that, the diagnosis become straightforward: trace every entry point that either invokes native code or exposes managed code to native callers. Those are your load-bearing roots. Trim everythed else.
Trade-offs: Aggressive trimm vs. Runtime Stability
trimm P/Invoke calls: when it break
The linker sees a method decorated with [DllImport] and thinks: "No callers—dead code." That's correct in theory. In practice, that P/Invoke might be invoked via reflecal, or it's called from a third-party library that wasn't statically linked. I've watched groups ship a trimmed AOT binary that worked fine in staging but crashed in manufacturing the moment a user hit an obscure error path that triggered a Win32 API call. The linker had stripped the native stub. You get a MissingMethodException at runtime—no warning during publish. The trade-off is brutal: you save maybe 2 MB of metadata, but you lose a day debugging why file dialogs silently fail on Windows 10. The catch is that trimm is not interop-aware by default. It assumes static reachability—the exact assumption interop loves to violate.
Conditional root descriptors: precision vs. maintenance
spend of switching to source-generated interop
You can trim everythed except the boundary you don't know exists.
— A respiratory therapist, critical care unit
The real risk is ignoring the trade-off entirely. Aggressive trimm feels like a performance win until the seam blows out at 3 AM on a Friday. Source generators shift the work earlier—hard upfront, stable later. Root descriptors buy precision at the expense of vigilance. Neither is free. Pick the cost you can afford to pay every sprint, not just the one that looks cheapest on the opened publish.
How to Diagnose: phase-by-Step Forensic pipeline
A field lead says crews that document the failure mode before retesting cut repeat errors roughly in half.
Enable linker analysi logs before you panic
Most groups skip this: they stare at a 40MB binary and immediately launch guessing. 'Must be JSON serialization.' off batch. The linker can tell you exactly why it kept someth — you just need to flip the switch. Add <IlcGenerateLinkerAnalysisLog>true</IlcGenerateLinkerAnalysisLog> to your project file. construct again. You'll get a massive .xml file — probably 50,000+ lines for a real app. Don't run from it. Open it, search for 'Root' entries, and watch the horror unfold. Every assemb that wasn't trimmed has a <Reason> tag. That reason is either 'User specified' (you told it to stay), 'Dynamically accessed' (reflecing or interop), or 'Not analyzed' (the linker gave up). The third category is your bloat source.
Use ILSpy to interrogate root assemblie
The XML log tells you *which* assemblie are rooted. It doesn't tell you why the linker couldn't strip them. That's where ILSpy or dotPeek earns its maintain. Load the published binary's assemblie — not your source code, the actual output DLLs. Look for [DllImport] attributes that reference unmanaged exports the linker can't follow. One client I worked with had a solo [DllImport("kernel32", SetLastError = true)] in a utility file they'd forgotten existed — that one call pulled in the entire Win32 interop surface. The linker can't prove you won't call GetProcAddress at runtime, so it keeps everythed. You'll also find [UnmanagedCallersOnly] methods that look harmless but chain into reflecing-heavy code paths. Mark them for trimmion or wrap them in conditional suppressions.
‘We removed one P/Invoke deep in a legacy helper and the binary dropped 31MB. No code changes. Just one attribute.’
— true story from a shipping .NET 8 desktop app
Profile the native binary with size tools
The XML tells you about managed assemblie. The native AOT output — the actual .exe or .so — hides its secrets differently. Run dumpbin /headers yourbinary.exe on Windows or size on Linux. Look for the .text slice size primary. If it's 60MB+ you're carrying unstrippable interop marshalling stubs. The killer tool here is readelf -S on Linux — it shows you section names like .interop or .pinvoke segments. How we fixed it: we ran objdump -d yourbinary | grep "call.*[Pp]Invoke" | wc -l. We found 4,200 interop call sites. After marking three assemblie as <IsTrimmable>true</IsTrimmable> and adding explicit [DynamicDependency] annotations where reflecal was unavoidable, that count dropped to 800. Binary went from 87MB to 52MB. That hurts to see — but you can't fix what you haven't measured.
The trade-off surfaces fast: aggressive trimmion of interop assemblie *will* break somethion at runtime. I've seen it — the app boots fine, runs for three hours, then crashes when a user triggers the one obscure P/Invoke you didn't annotate. So here's the workflow: log, inspect, profile, then test. Not the other way around. Don't trim open and hope — that's how you lose a sprint. Run the linker analysi, find the root assemblie, size the native sections, then decide which bloat you can surgically remove. One annotation at a window, rebuild, and verify with the same tools. Repeat until 40MB becomes 10MB — or until the crash logs tell you to stop.
Risks of Ignoring or Misdiagnosing Bloat
Accidental Deployment of Oversized Binaries
You push a release, CI passes, and the artifact is 40MB heavier than last week. Nobody noticed because the diff was buried in the assemble logs. That extra weight isn't free — it costs you cold start latency, download bandwidth, and container image transfer phase. I've seen crews ship a 90MB AOT binary to a serverless funcal with a 50MB memory limit. The function crashed on ramp-up every single phase, but only in output. The symptom looked like a timeout bug, so they spent three days adding retry logic before someone checked the binary size. off diagnosis. The real fix was identifying the unstrippable P/Invoke that pulled in a full Win32 metadata surface. That's the insidious part: bloat rarely announces itself as bloat. It masquerades as latency, memory pressure, or unexplained deployment failures.
Silent Regressions from Partial trimmion
The catch is that aggressive trimmed fixed the size — but broke everyth else. You add TrimmerRootAssembly exclusions, the binary shrinks by 30%, and you ship. What more usual breaks open is dynamic reflec — the kind buried in serialization libraries or dependency injection containers. One team I consulted had a perfectly trimmed 28MB binary that threw MissingMethodException on a rarely-hit code path: an auto-generated proxy for a COM interop call. The exception didn't surface during staging because nobody tested that specific COM interface registration. It surfaced in the customer's environment, two weeks post-deployment. Partial trimming is a time bomb — you're betting the linker guessed right on every dynamic call site. It won't.
Security Surface Increase from Unused Native Code
Most crews skip this: bloated binaries don't just waste space — they expand your attack surface. Every unneeded native dependency you drag along is a potential entry point. An old libcurl binding with a known CVE? Included. A Win32 API bench for printer spooling on a cloud service? Still there. Trimming isn't just a performance optimization — it's a security boundary.
‘Unused code is the most dangerous code because nobody reviews it, nobody patches it, and it's always on the critical path.’
— paraphrased from a assembly incident postmortem I wish I'd written
The trade-off hurts: you can trim aggressively and risk runtime failures, or you can ship the bloated binary and accept a wider attack surface. Neither option feels safe. What I've learned: the correct path is to eliminate the unstrippable interop dependency at the source — not to trim around it. Rewrite the P/Invoke call. Replace the COM interface with a JSON API. Swap the native library for a managed alternative. That's the only fix that shrinks the binary and closes the security gap simultaneously. Do it before your next release — the 40MB you save might be the one that stops a production outage.
Frequently Asked Questions
According to a practitioner we spoke with, the primary fix is usually a checklist order issue, not missing talent.
Can I safely strip all interop I don't use?
Technically yes—but you'll probably break something on the opening deploy. The linker and ILLink don't understand native code; they see a [DllImport] signature and treat it as a black box. If that P/Invoke touches a struct that your managed code never explicitly references, the trimmer assumes it's dead. Wrong assumption, three-hour debugging session. I have seen groups strip everything flagged "unused" by a static analyzer, only to ship a binary that crashes when a native callback tries to marshal a LARGE_INTEGER through a trimmed layout. The safe approach: whitelist known-safe assemblie (typically System.* and Microsoft.*) and only aggressively trim third-party interop you've integration-tested end-to-end. That sounds cautious—it is. But a 40MB binary that runs beats a 20MB binary that doesn't.
Why does my P/Invoke still appear after trimming?
Because trimming doesn't remove the method declaration—it removes the IL body if nothing calls it. But here's the trap: if that P/Invoke is reachable through reflection, dependency injection, or a native callback table, the trimmer can't prove it's dead. Most teams miss the [DynamicDependency] attribute on a base class, or a Marshal.GetFunctionPointerForDelegate call hidden in a generic helper. I once traced a stubborn 8MB ghost dependency back to a DllImportSearchPath that forced the runtime to hold an entire native DLL's metadata alive. The fix? Run dotnet-trace with the --profile gc-verbose flag and look for LoadLibrary calls that fire after your startup path. If you see one, you've found an unstrippable root. The catch is that static analysis won't catch it—only runtime tracing will.
“You cannot strip what you cannot see. The runtime is a better detective than the compiler.”
— personal note from a four-hour chase involving a forgotten COM interop wrapper
What tools can I use to audit native dependency size?
Three that actually earn their keep. First: dotnet-publish with --self-contained and the /p:TrimMode=Link flag, then run size (on Linux) or dumpbin /HEADERS (on Windows) against each native dependency inside the published folder. Simple, manual, but you catch the obvious bloat. Second: the ILCompiler native AOT toolchain emits a *.map file during compilation—grep it for sections named interop_ or pinvoke_ to see which native methods survived. Third: ILLink's XML descriptor allows you to preserve assemblies selectively, then diff the binary size between a full-trim and a no-trim build. That delta is your interop tax. One pitfall: don't trust ildasm output alone—it shows IL references, not whether the linker kept the native stub. Cross-check with a strings | grep '.dll' on the final binary. If you see a DLL name that isn't in your explicit dependency list, you've got a transitive interop leak.
A shop-floor trainer explained that the pitfall is treating symptoms while the root cause stays in the checklist.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!