Trong bài viết này, chúng ta sẽ tìm hiểu cách tùy chỉnh linker cho một ứng dụng NativeAOT để loại bỏ cả một chuỗi dependency chắc chắn không bao giờ cần dùng, nhưng linker không thể tự xác định điều đó. Bằng cách sử dụng method substitution, bạn có thể giảm đáng kể kích thước binary NativeAOT cuối cùng nhờ kích hoạt thêm khả năng loại bỏ mã chết.
Mục lục
Bối cảnh: công cụ dòng lệnh dd-dotnet
Datadog .NET SDK bao gồm một ứng dụng dòng lệnh .NET nhỏ có tên dd-dotnet. Công cụ này được cài đặt cùng các thư viện auto-instrumentation và phục vụ nhiều mục đích khác nhau. Ứng dụng được thiết kế gọn nhẹ và được biên dịch bằng NativeAOT để tạo ra một binary nhỏ, có thể chạy mà không yêu cầu cài đặt một phiên bản .NET cụ thể trên máy đích.
Ứng dụng phụ thuộc vào thư viện Microsoft.Diagnostics.Runtime, còn được gọi là ClrMD. Đây là công cụ dùng để kiểm tra tiến trình và crash dump, được sử dụng trong dd-dotnet để phân tích lỗi. Khi bổ sung hỗ trợ cho .NET 11, chúng tôi cần cập nhật phiên bản ClrMD đang sử dụng. Từ đây, vấn đề bắt đầu trở nên đáng chú ý.
Cập nhật gói NuGet ClrMD
Việc cập nhật gói NuGet khá đơn giản. Chúng tôi chỉ cần nâng phiên bản trong tệp csproj:
- <PackageReference Include="Microsoft.Diagnostics.Runtime" Version="3.2.527301" /><br>+ <PackageReference Include="Microsoft.Diagnostics.Runtime" Version="4.1.745802" />
Có một tài liệu hướng dẫn chuyển đổi từ phiên bản 3.x sang 4.x, nhưng ứng dụng không bị ảnh hưởng bởi các thay đổi được đề cập trong đó. Việc còn lại là cập nhật target framework từ .NET 8 lên .NET 10. Không có lý do đặc biệt nào khiến chúng tôi trì hoãn việc này; chỉ đơn giản là nguyên tắc “nếu chưa hỏng thì đừng sửa”.
Mọi thứ đều build thành công và toàn bộ bài kiểm thử đều đạt. Vậy chỉ cần phát hành là xong, đúng không? Đáng tiếc là bản cập nhật có một hệ quả không mong muốn: binary lớn hơn đáng kể.
Cập nhật gói làm kích thước binary tăng đáng kể
Sau khi cập nhật ClrMD, chúng tôi kiểm tra kích thước binary đã biên dịch:
- win-x64: từ 3.519 MB lên 3.956 MB, tăng 437 kB, tương đương 12.42%.
- linux-x64: từ 4.028 MB lên 4.459 MB, tăng 431 kB, tương đương 10.71%.
- linux-musl-x64: từ 4.025 MB lên 4.458 MB, tăng 433 kB, tương đương 10.77%.
- linux-arm64: từ 3.502 MB lên 3.813 MB, tăng 311 kB, tương đương 8.88%.
- linux-musl-arm64: từ 3.499 MB lên 3.810 MB, tăng 310 kB, tương đương 8.88%.
Binary này cần được giữ ở kích thước nhỏ nhất có thể. Vì vậy, mức tăng khoảng 0.5 MB, tương đương khoảng 10%, rõ ràng không thể dễ dàng bỏ qua. May mắn là có những công cụ hỗ trợ điều tra loại vấn đề này.
Phân tích mức tăng kích thước bằng Sizoscope
Công cụ tôi thường dùng để điều tra kích thước ứng dụng NativeAOT là Sizoscope của Michal Strehovsky. Công cụ có cả giao diện đồ họa và phiên bản dotnet CLI. Cả hai đều hiển thị những thành phần tạo nên binary, chẳng hạn namespace nào chiếm nhiều dung lượng nhất, kiểu nào được đưa vào binary và nhiều thông tin khác.
Sizoscope không cho biết chính xác tại sao các namespace khác nhau lại được đưa vào binary, nhưng nó cung cấp điểm khởi đầu rất tốt. Quan trọng hơn, dữ liệu do công cụ tạo ra khá dễ để một LLM phân tích.
Thay vì cài giao diện đồ họa, tôi cài phiên bản CLI:
dotnet tool install sizoscope-cli --global
Sau đó, tôi yêu cầu Claude Code sử dụng công cụ này để tìm nguyên nhân khiến phiên bản mới chiếm thêm dung lượng so với phiên bản trước. Để thực hiện phân tích, cần bật thêm các chẩn đoán trong quá trình biên dịch bằng cách thêm nội dung sau vào tệp dự án:
<PropertyGroup><br> <IlcGenerateMstatFile>true</IlcGenerateMstatFile><br> <IlcGenerateDgmlFile>true</IlcGenerateDgmlFile><br></PropertyGroup>
Claude tự xử lý toàn bộ phần này. Sau một thời gian ngắn, nó đưa ra phân tích về nguyên nhân khiến binary tăng kích thước.
Mã không thể chạy vẫn kéo theo dependency
Vấn đề nằm ở constructor của Microsoft.Diagnostics.Runtime.Implementation.SymbolServer, có cấu trúc tương tự như sau:
internal SymbolServer(FileSymbolCache cache, Uri server, bool trace, TokenCredential? credential)<br>{<br> // ...<br> if (IsSymweb)<br> _tokenCredential ??= new InteractiveBrowserCredential();<br>}
Property IsSymweb và kiểu InteractiveBrowserCredential là nguyên nhân cốt lõi. InteractiveBrowserCredential nằm trong assembly Azure.Identity, kéo theo các dependency:
Azure.IdentityMicrosoft.Identity.ClientMicrosoft.Identity.Client.Extensions.MsalSystem.Text.Json
Tất cả các dependency này chỉ cần thiết khi IsSymweb có giá trị true. Vậy IsSymweb là gì?
Đây là một property trên SymbolServer, thực hiện so sánh chuỗi giữa property Host của tham số Uri server truyền vào constructor với giá trị URI SymwebHost đã biết:
public static readonly Uri SymwebHost = new("https://symweb.azurefd.net/");<br><br>public Uri Server { get; private set; }<br><br>private bool IsSymweb => Server.Host.Equals(SymwebHost.Host, StringComparison.OrdinalIgnoreCase);
Vấn đề là chúng ta biết phép so sánh này sẽ không bao giờ trả về true, bởi giá trị SymwebHost không bao giờ được truyền vào SymbolServer trong các luồng xử lý của ứng dụng. Ngoài ra, chúng ta cũng không cấu hình SymbolTokenCredential.
Tuy nhiên, compiler và linker không biết điều đó. Phép so sánh được thực hiện tại runtime, nên linker phải giả định rằng lời gọi new InteractiveBrowserCredential() có thể xảy ra. Vì vậy, linker phải giữ lại kiểu này cùng mọi dependency mà kiểu đó sử dụng.
Do thiết kế hiện tại, dường như không có cách thông thường nào để giải quyết vấn đề. Dù làm gì, linker vẫn thấy kiểu này có thể được truy cập và giữ lại toàn bộ chuỗi dependency, khiến binary lớn hơn.
Tuy nhiên, có một cách khắc phục: thay thế implementation của một method tại thời điểm biên dịch.
Dùng linker substitution để cải thiện trimming và loại bỏ mã chết
ILLink.Substitutions.xml là tệp cấu hình XML được .NET IL trimmer, tức illink, và compiler NativeAOT, tức ilc, sử dụng để thay thế phần thân của các method hoặc field cụ thể bằng giá trị trả về hằng số hoặc câu lệnh throw.
Cơ chế này phù hợp với chính loại tối ưu cần thực hiện: giúp compiler loại bỏ mã chết trong những trường hợp linker không thể tự phân tích tại thời điểm build.
Theo tài liệu, về lý thuyết bạn chỉ cần nhúng tệp XML vào dự án:
<ItemGroup><br> <EmbeddedResource Include="ILLink.Substitutions.xml"><br> <LogicalName>ILLink.Substitutions.xml</LogicalName><br> </EmbeddedResource><br></ItemGroup>
Tuy nhiên, với NativeAOT, cách này không hoạt động như mong đợi. Thay vào đó, cần dùng <IlcArg> để truyền trực tiếp tệp thông qua tham số --substitution:
<ItemGroup><br> <IlcArg Include="--substitution:$(MSBuildThisFileDirectory)ILLink.Substitutions.xml" /><br></ItemGroup>
Nguyên nhân là các target của SDK Microsoft.DotNet.ILCompiler dường như không kết nối ILLinkSubstitutionsXmls với PublishAot. Vì vậy, cần truyền trực tiếp tệp bằng tham số --substitution.
Tệp ILLink.Substitutions.xml có thể chứa một hoặc nhiều tham chiếu đến các method cần loại bỏ hoặc thay thế phần thân bằng một hằng số. Bạn cũng có thể loại bỏ embedded resource và bật thay thế theo điều kiện.
Trong trường hợp này, chúng ta chỉ cần thay phần thân của property IsSymweb bằng giá trị hằng số false. Khi đó, linker có thể xác định rằng constructor new InteractiveBrowserCredential() sẽ không bao giờ được gọi. Do đó, các dependency dành cho xác thực Azure và MSAL cũng không còn cần thiết.
Nội dung tệp ILLink.Substitutions.xml:
<linker><br> <assembly fullname="Microsoft.Diagnostics.Runtime"><br> <type fullname="Microsoft.Diagnostics.Runtime.Implementation.SymbolServer"><br> <method signature="System.Boolean get_IsSymweb()" body="stub" value="false" /><br> </type><br> </assembly><br></linker>
Kiểm thử substitution
Chỉ cần publish lại ứng dụng bằng NativeAOT, chúng ta có thể thấy tác động rất lớn:
- win-x64: từ 3.956 MB xuống 2.949 MB, giảm 25.43%.
- linux-x64: từ 4.459 MB xuống 3.466 MB, giảm 22.27%.
- linux-musl-x64: từ 4.458 MB xuống 3.465 MB, giảm 22.28%.
- linux-arm64: từ 3.813 MB xuống 2.965 MB, giảm 22.24%.
- linux-musl-arm64: từ 3.810 MB xuống 2.963 MB, giảm 22.23%.
Thêm substitution giúp binary giảm 22-25%, thậm chí còn nhỏ hơn phiên bản ban đầu, trước khi cập nhật ClrMD từ 3.x lên 4.x.
- win-x64: từ 3.518 MB ở .NET 8 và ClrMD 3.x xuống 2.949 MB ở .NET 10 và ClrMD 4.x, giảm 16.2%.
- linux-x64: từ 4.027 MB xuống 3.466 MB, giảm 13.9%.
- linux-musl-x64: từ 4.028 MB xuống 3.465 MB, giảm 14.0%.
- linux-arm64: từ 3.499 MB xuống 2.965 MB, giảm 15.3%.
- linux-musl-arm64: từ 3.501 MB xuống 2.963 MB, giảm 15.4%.
Thoạt nhìn, kết quả này có vẻ bất ngờ. Tuy nhiên, phần lớn mức giảm kích thước binary đến từ việc nâng cấp .NET 8 lên .NET 10. Nếu chỉ cập nhật lên .NET 10 mà không cập nhật ClrMD, binary còn nhỏ hơn nữa.
Dù vậy, substitution bổ sung cho ilc đã khiến việc nâng ClrMD từ 3.x lên 4.x gần như không làm tăng kích thước binary cuối cùng. Đồng thời, chúng ta vẫn có thể hỗ trợ .NET 11 khi phiên bản này được phát hành. Đây là kết quả đôi bên cùng có lợi.
Có hai điểm đáng chú ý. Điểm rõ ràng nhất là ILLink.Substitutions.xml giúp biến các property vốn chỉ được xác định tại runtime thành hằng số tại thời điểm biên dịch, từ đó đơn giản hóa đáng kể quá trình trimming.
Điểm thứ hai là khả năng sử dụng Sizoscope CLI để nhanh chóng điều tra chính xác nguyên nhân binary tăng kích thước và tìm ra hướng khắc phục. Tất nhiên, bạn vẫn cần kiểm tra lại các kết luận do LLM đưa ra. Tuy nhiên, nếu đang xây dựng ứng dụng NativeAOT, việc sử dụng LLM để tìm cơ hội trimming trong binary có thể là một cách đơn giản để giảm kích thước ứng dụng.
Tổng kết
Trong bài viết này, chúng ta đã giảm kích thước binary NativeAOT bằng cách sử dụng ILLink.Substitutions.xml để thay thế phần thân của một property bằng hằng số tại thời điểm linking.
Khi trả về một hằng số thay vì thực hiện phép so sánh tại runtime, ngay cả trong trường hợp phép so sánh đó luôn cho cùng một kết quả, linker có thể loại bỏ nhiều mã chết hơn. Trong một số trường hợp, điều này cũng cho phép loại bỏ cả một chuỗi dependency.
Với trường hợp cụ thể này, việc thay thế một property trong ClrMD bằng giá trị false đã giúp giảm kích thước binary từ 22-25%, thay vì phụ thuộc vào phép so sánh được thực hiện tại runtime.



