Cải thiện hiệu suất trong .NET 11

Tác giả: Stephen Toub – Kỹ sư Distinguished tại Microsoft

Mục lục

Thiết lập benchmark

Bài viết chứa đầy các micro-benchmark minh họa từng cải tiến riêng lẻ. Hầu hết chúng sử dụng BenchmarkDotNet và mỗi benchmark được viết để tự chứa, để bạn có thể tự thử nghiệm.

Bắt đầu bằng cách đảm bảo bạn đã cài đặt cả .NET 10 và .NET 11 (hầu hết các benchmark so sánh cùng mã chạy trên cả hai phiên bản) và tạo một dự án console mới trong thư mục benchmarks mới:

dotnet new console -o benchmarks
cd benchmarks

Thay thế nội dung của tệp benchmarks.csproj được tạo bằng nội dung sau, trong đó multi-target cả hai phiên bản để BenchmarkDotNet có thể build cho từng phiên bản:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net11.0;net10.0</TargetFrameworks>
    <LangVersion>preview</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <ServerGarbageCollection>true</ServerGarbageCollection>
    <SystemPackageVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.12</SystemPackageVersion>
    <SystemPackageVersion Condition="'$(TargetFramework)' == 'net11.0'">11.0.0-rc.1.26425.128</SystemPackageVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.16.0-preview.1" />
    <PackageReference Include="System.IO.Hashing" Version="$(SystemPackageVersion)" />
    <PackageReference Include="System.Runtime.Caching" Version="$(SystemPackageVersion)" />
    <PackageReference Include="System.Numerics.Tensors" Version="$(SystemPackageVersion)" />
  </ItemGroup>
</Project>

JIT

Mọi dòng mã được quản lý cuối cùng đều đến trình biên dịch JIT, vì vậy hãy bắt đầu từ đó.

Deabstraction

Chúng ta với tư cách là các nhà phát triển yêu thích các trừu tượng. Chúng cho phép chúng ta viết mã sạch, có thể tái sử dụng, theo hướng đối tượng, nhưng chúng ta không muốn trả giá cho mỗi trừu tượng tại thời điểm chạy. Runtime có thể thường.undo một trừu tượng khi chứng minh rằng các hiệu ứng không thể quan sát được.

Mỗi khi bạn viết interface trong C#, bạn đang tạo một hợp đồng, lời hứa rằng bất kỳ kiểu nào triển khai giao diện đó có thể được thay thế cho bất kỳ kiểu nào khác. Sự linh hoạt đó cực kỳ quý giá vì, chẳng hạn, đó là lý do tại sao chúng ta có thể viết IEnumerable<T> và nó hoạt động tốt như nhau trên mảng, danh sách, các tập hợp khác, LINQ, iterator tùy chỉnh, v.v.

Với .NET 11, JIT đã cải thiện đáng kể “deabstraction” – quá trình loại bỏ các trừu tượng khi có thể chứng minh rằng chúng không cần thiết. Điều này bao gồm:

  • Cải thiện phân tích escape để phát hiện các đối tượng không rời khỏi khung stack hiện tại
  • Tối ưu hóa boxing nullable
  • Cải thiện Conditional Escape Analysis (CEA) cho enumerator
  • Devirtualize generic virtual methods (GVMs)
  • Đơn giản hóa đại diện delegate

Runtime Async

.NET 11 giới thiệu cách mới để chia trách nhiệm cho async/await, được gọi là “runtime async”. Thay vì trình biên dịch C# chịu trách nhiệm chuyển đổi, JIT thực hiện điều đó. Trình biên dịch C# phát ra IL contract nhỏ hơn nhiều cho mỗi phương thức async đủ điều kiện và đánh dấu phương thức đó là async trong metadata. Runtime và JIT sau đó thực hiện công việc phụ thuộc vào kiến thức runtime.

Mô hình lập trình không thay đổi. Đây vẫn là C# async/await; await vẫn tuân theo mẫu awaiter, ngoại lệ và hủy bỏ vẫn xuất hiện qua đối tượng task-like được trả về.

Trong .NET 11, mã ứng dụng bật tính năng này bằng compiler feature switch:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <Features>$(Features);runtime-async=on</Features>
  </PropertyGroup>
</Project>

Kiểm tra biên

C# là ngôn ngữ an toàn bộ nhớ. Việc truy cập mảng, chuỗi và span được đảm bảo nằm trong giới hạn bởi runtime. JIT làm việc để loại bỏ các kiểm tra biên và overhead đó ở bất cứ nơi nào nó có thể chứng minh chỉ số hợp lệ.

Phân tích phạm vi trong .NET 11 đã được cải thiện đáng kể, bao gồm:
– Cải thiện logic khi gặp biểu thức != hằng số
– Kết hợp tốt hơn các phép toán bitwise OR
– Xử lý tốt hơn kiểm tra biên cho truy cập từ cuối mảng
– Loại bỏ kiểm tra biên thừa trong các phép truy cập tuần tự

Lan truyền khẳng định

JIT liên tục học các sự thật trong khi biên dịch phương thức: một giá trị bằng hằng số, một tham chiếu không phải null, một số nguyên rơi vào một phạm vi nhất định, v.v. “Lan truyền khẳng định” mang những sự thật đó về phía trước để chúng có thể đơn giản hóa mã sau này.

Đơn giản hóa

Một khi JIT biết đủ về đầu vào của phép toán, nó có thể thường thay thế phép toán bằng thứ gì đó đơn giản và rẻ hơn. Các cải tiến bao gồm:

  • Constant folding cho phép JIT thực hiện công việc một lần để nó không cần lặp lại tại thời điểm chạy
  • Cải thiện”peephole optimizations” để thay thế các mẫu ngắn, có thể nhận dạng bằng mẫu tương đương rẻ hơn
  • Cải thiện”if-conversion” để thay thế các nhánh nhỏ bằng lệnh conditional-move hoặc hình thức không nhánh khác

Vector hóa

.NET 11 cải thiện AVX-512 broadcasting và masking, giúp giảm kích thước dữ liệu và footprint bộ nhớ đệm. Các cải tiến bao gồm embedded broadcasting, embedded masking, và việc sử dụng nhỏ nhất hợp lệ các hằng số lặp lại.

Intrinsics

JIT nhận ra và xử lý đặc biệt các API managed cụ thể được gọi là intrinsics. Trong .NET 11, điều này bao gồm cải thiện recognition cho IBinaryNumber<T>.Log2, loại bỏ overhead boxing cho Enum.Equals, và cải thiện Vector256.Sum và Vector512.Sum.

Phân bổ thanh ghi

Mã được tạo liên tục di chuyển giá trị giữa tập thanh ghi nhanh hạn chế của CPU và các vị trí stack tạm thời. Phân bổ thanh ghi trong trình biên dịch quyết định giá trị nào ở lại trong thanh ghi và giá trị nào bị “đổ” xuống stack.

Write Barriers và thu gom rác

.NET garbage collector là generational: đối tượng mới bắt đầu ở gen0, trong khi các đối tượng sống sót sau các lần thu gom được thăng cấp lên gen1 và gen2. JIT phát ra một đoạn mã nhỏ để cập nhật sổ sách của GC mỗi khi một ghi tham chiếu có thể tạo ra tham chiếu đến đối tượng younger.

Các cải tiến trong .NET 11 bao gồm:
– Mở rộng calls vào array-store helper để JIT có thể loại bỏ kiểm tra covariance và tối ưu hóa write barrier
– Cải thiện phân tích heap-destination cho việc sao chép struct whole-struct
– Sử dụng SIMD để sao chép phần còn lại của struct hỗn hợp

Kiến thức runtime và dữ liệu đóng băng

JIT chỉ có thể tối ưu hóa những sự thật mà nó biết. Một số sự thật đến từ phân tích của chính nó; những sự thật khác là hợp đồng được cung cấp bởi runtime.

Throughput JIT và dọn dẹp

Chất lượng của mã được tạo không phải là mối quan tâm duy nhất; thời gian sản xuất ra nó cũng quan trọng. Mỗi phân tích mà JIT thực hiện đều có chi phí.

Khởi động và triển khai

Trước khi managed Main có thể chạy, host native cần tìm các phụ thuộc của ứng dụng, CoreCLR cần tải đủ kiểu và mã để bắt đầu thực thi, và các phần infrastructure framework khác nhau cần tự khởi tạo.

Các cải tiến bao gồm:
– Tránh kiểm tra từng tài sản chống lại thư mục servicing trừ khi resolver thực sự probe thư mục đó
– Tránh sao chép lặp lại tên thư mục servicing
– Chuẩn化 tách separators một lần khi parse .deps.json
– Cải thiện ReadyToRun cho Comparer và EqualityComparer defaults
– Thêm source generator cho EventSource để tránh reflection khi khởi tạo

Đa luồng

Đa luồng là mối quan tâm cắt ngang ảnh hưởng đến hầu hết mọi ứng dụng và dịch vụ. Monitor là primitive đồng bộ lịch sử được sử dụng để triển khai lock.

Các cải tiến trong .NET 11:
– Lưu trữ condition variable trực tiếp trên lock, loại bỏ lookup ConditionalWeakTable
– Loại bỏ các ghi chú volatile không cần thiết khỏi nhiều trường library
– Thay thế full memory barriers trong HashMap bằng các thao tác acquire và release hẹp hơn
– Giảm chi phí lập lịch xung quanh các work item thread-pool nhỏ

Số học

BigInteger là một trong những kiểu mà nhiều ứng dụng có thể không bao giờ cần, nhưng đối với những ứng dụng cần, thường không có thay thế thực tế nào.

Trong .NET 11, BigInteger nhận được một cuộc đại tu đáng kể:
– Viết lại các phần đáng kể của triển khai, thay đổi limbs từ uint thành nuint
– Cải thiện các thuật toán xung quanh các limbs rộng hơn, bao gồm Montgomery multiplication và sliding-window exponentiation
– Thêm API UTF-8 mới để tránh transcode
– Cải thiện casting sang double và float

Toàn cầu hóa

Nhiều API liên quan đến toàn cầu hóa dựa trên dữ liệu có thể tốn kém để định vị và giải thích. DateTime.Now, ví dụ, phụ thuộc vào dữ liệu chuyển đổi múi giờ.

Các cải tiến:
– Cải thiện đáng kể TimeZoneInfo bằng cách lưu trữ dữ liệu chuyển đổi theo năm
– ToUpperInvariant và ToLowerInvariant giờ đây thử đường dẫn ASCII managed trước
– Cải thiện parse định dạng “O” cho DateTime
– Cải thiện DateTime.ToString("G")

Chuỗi và Span

UTF-8 ở khắp mọi nơi, từ giao thức web và JSON payloads đến tệp trên đĩa. Vì chuỗi .NET sử dụng UTF-16, ứng dụng thường cần chuyển đổi giữa hai định dạng này.

Các cải tiến:
– Cải thiện UTF-8 encoding validation cho surrogate pairs trên Arm64
– Cải thiện UTF-8 decoding trên Arm
– Cải thiện Base64 encoding/decoding
– Cải thiện MemoryExtensions.CommonPrefixLength
– Cải thiện string.Concat khi nhận string[] hoặc List<string?>

Tìm kiếm và so sánh

Tìm kiếm theo một cách, hình thức này hay hình thức khác là một trong những việc phổ biến nhất mà chương trình làm. Và khi nói đến tìm kiếm văn bản, regex là cách cực kỳ phổ biến và hữu ích.

Các cải tiến regex trong .NET 11:
– Thêm cleanup pass cuối cùng sau khi tối ưu hóa toàn bộ mẫu
– Cải thiện prefix extraction cho alternations với ordinal ignore-case
– Cải thiện tìm kiếm literal prefixes hoàn chỉnh
– Cải thiện atomic loops theo sau bởi literals

Tập hợp và LINQ

Nhiều công việc collection và LINQ trong .NET 11 đến từ việc tận dụng tốt hơn thông tin đã có sẵn. Collection thường biết nhiều hơn những gì IEnumerable<T> có thể biểu đạt: số lượng, lưu trữ liên tục, comparer, hoặc bố cục hash table.

Các cải tiến:
– ImmutableArray.Create sử dụng Array.Copy thay vì vòng lặp element thủ công
– Cải thiện Dictionary<TKey, TValue>.Remove cho value-type keys
– Cải thiện HashSet<T> internal chain walks
– Thêm Enumerable.FullJoin
– Cải thiện AsyncEnumerable.Append chain performance

I/O

Hiệu suất I/O thường được đánh đồng với tốc độ của thiết bị cơ bản, nhưng việc truyền tải chỉ là một phần của thao tác.

Các cải tiến:
– Sử dụng overlapped I/O cho redirected child-process output trên Windows
– Cải thiện RandomAccess.Read trên Windows bằng cách sử dụng convention OVERLAPPED.hEvent
– Giảm phân bổ trong TarWriter và ZipArchive
– Truy cập trực tiếp DeflateEncoder/ZLibEncoder/GZipEncoder types

Mạng

Mạng là nguồn sống của nhiều ứng dụng và nằm trực tiếp trên hot path của các dịch vụ có thể mở rộng.

Các cải tiến:
– Thêm chiến lược Happy-Eyeballs-like cho Socket.ConnectAsync
– Cải thiện Socket.Blocking trên Unix
– Giảm phân bổ trong SslStream trên Windows, macOS và Linux
– Cải thiện SocketsHttpHandler cho AutomaticDecompression và MultipartContent
– Cải thiện Uri parsing và normalization

JSON

JSON readers và writers dành phần lớn thời gian để quét văn bản. .NET 11 làm cho nhiều quét đó hiệu quả hơn.

Các cải tiến:
– Utf8JsonWriter sử dụng SearchValues được tiền tính toán cho quy tắc escaping mặc định
– Utf8JsonReader.SkipWhiteSpace sử dụng IndexOfAnyExcept

Chẩn đoán

Tạo Activity, poll metrics và logging đều thêm overhead ngoài những gì ứng dụng đang cố gắng thực hiện.

Các cải tiến:
– Cải thiện validation trace ID và baggage cho W3C Propagator
– Cải thiện marshalling arguments và environment khi khởi động process
– Sử dụng posix_spawn trên macOS thay vì fork-then-exec
– Cải thiện query ProcessName trên Linux và macOS
– Giảm binary size cho Native AOT khi chỉ sử dụng local process APIs

Mã hóa

ASN.1 là định dạng mô tả dữ liệu nhị phân được sử dụng bởi chứng chỉ, khóa công khai và riêng tư, và nhiều cấu trúc mã hóa khác.

Các cải tiến:
– Thêm ValueAsnReader, span-based ref struct counterpart cho AsnReader
– Vector hóa validation và transcoding cho IA5String và VisibleString
– Thêm one-shot path cho SHA-1 hash cho mục đích không bí mật
– Cải thiện AES key wrap trên Windows và Apple platforms
– Thêm cache cho Certificate Revocation Lists và Authority Information Access downloads

Tiếp theo là gì?

Sau hàng trăm cải tiến hiệu suất, .NET 11 thực sự “lớn hơn một level”. Nếu bất kỳ ví dụ nào trong bài viết này trông giống mã trong ứng dụng của bạn, vui lòng thử bản phát hành candidate mới nhất của .NET 11 và đo lường các workloads của riêng bạn.

Chúc bạn lập trình vui vẻ!