پورتال کاج

واژه پرتال به معنی دروازه یا محل ورود به یک شهرمی باشد و تا بحال بیشترین کاربرد را در حوزه IT داشته است. طبق تعاریفی که تا بحال ارائه شده است، پرتال را می توان یک مرکز ارائه خدمات و اطلاعات اینترنتی دانست

بهبودهای عملکرد در .NET 10


Stephen Toub - MSFT
مهندس نرم‌افزار شریک

فرزندانم عاشق "فروزن" هستند. آن‌ها هر کلمه‌ای را می‌خوانند، هر صحنه‌ای را بازسازی می‌کنند، و حتی جزئیات دقیق درخشش لباس یخی السا را توصیف می‌کنند. من این فیلم را بیشتر از آنچه بتوانم بشمارم دیده‌ام، به حدی که اگر مرا در حال کدزنی زنده دیده باشید، احتمالاً ناخودآگاه ارجاعی به آرندل در آن دیده‌اید. پس از دیدن‌های زیاد، شروع کردم به توجه بیشتر به جزئیات، مانند اینکه در ابتدای فیلم، برداشت‌کنندگان یخ آهنگی می‌خوانند که به طور ظریفی تضادهای مرکزی داستان، سفر شخصیت‌ها، و حتی کلید حل اوج داستان را پیش‌بینی می‌کند. کمی شرمنده‌ام که اعتراف کنم این ارتباط را تا دیدن دهم یا چیزی شبیه به آن درک نکردم، و در همان زمان متوجه شدم که هیچ ایده‌ای ندارم آیا این برداشت یخ واقعاً "چیزی" بوده یا فقط وسیله‌ای هوشمندانه برای دیزنی برای بافتن داستان. معلوم شد، همانطور که بعداً تحقیق کردم، کاملاً واقعی است.

در قرن 19، قبل از یخچال، یخ کالایی فوق‌العاده ارزشمند بود. زمستان‌ها در شمال ایالات متحده، برکه‌ها و دریاچه‌ها را به معادن طلای فصلی تبدیل می‌کرد. عملیات موفق‌ترین با دقت اجرا می‌شد: کارگران برف را از سطح پاک می‌کردند تا یخ ضخیم‌تر و قوی‌تر شود، و سطح را با گاوآهن‌های اسبی به مستطیل‌های کامل امتیاز می‌دادند، و دریاچه را به صفحه شطرنج یخ‌زده تبدیل می‌کردند. وقتی شبکه بریده شد، تیم‌هایی با اره‌های بلند کار می‌کردند تا بلوک‌های یکنواخت با وزن چند صد پوند را آزاد کنند. این بلوک‌ها در کانال‌های آب باز شناور می‌شدند به سمت ساحل، جایی که مردان با میله‌ها بلوک‌ها را روی رمپ‌ها اهرم می‌کردند و به انبار می‌بردند. اساساً، آنچه فیلم نشان می‌دهد.

انبار خود هنری بود. خانه‌های یخ چوبی عظیم، گاهی اوقات ده‌ها هزار تن را نگه می‌داشتند، با عایق‌بندی، معمولاً کاه. اگر خوب انجام شود، این عایق می‌توانست یخ را برای ماه‌ها، حتی در گرمای تابستان، جامد نگه دارد. اگر بد انجام شود، درها را باز می‌کردید و به آبکی می‌رسیدید. و برای کسانی که یخ را در مسافت‌های طولانی، معمولاً با کشتی، جابجا می‌کردند، هر درجه، هر ترک در عایق، هر روز اضافی در مسیر به معنای ذوب بیشتر و ضرر بیشتر بود.

فردریک تودور، "شاه یخ" بوستون را وارد کنید. او وسواس کارایی سیستمی داشت. جایی که رقبا ضرر اجتناب‌ناپذیر می‌دیدند، تودور مشکل قابل حل می‌دید. پس از آزمایش عایق‌های مختلف، به خاک‌اره ارزان، محصول جانبی کارخانه چوب‌بری، تکیه کرد که بهتر از کاه عمل می‌کرد، و آن را به طور متراکم اطراف یخ بسته‌بندی می‌کرد تا ضرر ذوب را به طور قابل توجهی کاهش دهد. برای کارایی برداشت، عملیات او سیستم امتیازدهی شبکه ناتانیل جارویس وایت را اتخاذ کرد، که بلوک‌های یکنواخت تولید می‌کرد که می‌توانستند محکم بسته‌بندی شوند، و شکاف‌های هوا را که در غیر این صورت در انبار کشتی افزایش می‌دادند، به حداقل می‌رساند. و برای کوتاه کردن زمان حیاتی بین ساحل و کشتی، تودور زیرساخت بندر و انبارها را نزدیک اسکله‌ها ساخت، که اجازه می‌داد کشتی‌ها خیلی سریع‌تر بارگیری و تخلیه شوند. هر تغییر، از ابزارها تا طراحی خانه یخ تا لجستیک، آخرین را تقویت می‌کرد، و برداشت محلی پرریسک را به تجارت جهانی قابل اعتماد تبدیل می‌کرد. با بهبودهای تودور، یخ جامد به مکان‌هایی مانند هاوانا، ریو دو ژانیرو، و حتی کلکته (سفری چهار ماهه در دهه 1830) می‌رسید. بهبودهای عملکرد او اجازه داد محصول سفرهایی را تحمل کند که قبلاً غیرقابل تصور بود.

آنچه یخ تودور را نیمی از جهان دوام آورد، یک ایده بزرگ نبود. بلکه انبوهی از بهبودهای کوچک بود، هر کدام اثر آخرین را چند برابر می‌کرد. در توسعه نرم‌افزار، اصل مشابهی برقرار است: جهش‌های بزرگ در عملکرد به ندرت از یک تغییر گسترده واحد می‌آید، بلکه از صدها یا هزاران بهینه‌سازی هدفمند که به چیزی تحول‌آفرین ترکیب می‌شوند. داستان عملکرد .NET 10 در مورد یک ایده جادویی دیزنی‌وار نیست؛ بلکه در مورد تراشیدن دقیق نانوثانیه‌ها اینجا و ده‌ها بایت آنجا، ساده‌سازی عملیات‌هایی که تریلیون‌ها بار اجرا می‌شوند.

در بقیه این پست، همانطور که در بهبودهای عملکرد در .NET 9، .NET 8، .NET 7، .NET 6، .NET 5، .NET Core 3.0، .NET Core 2.1، و .NET Core 2.0 کردیم، به صدها بهبود عملکرد کوچک اما معنادار و تجمعی از .NET 9 که داستان .NET 10 را تشکیل می‌دهند، می‌پردازیم (اگر به جای ارتقا از .NET 9، از LTS بمانید و از .NET 8 ارتقا دهید، بهبودهای بیشتری خواهید دید بر اساس تجمع تمام بهبودها در .NET 9 نیز). بنابراین، بدون مقدمه بیشتر، یک فنجان نوشیدنی گرم مورد علاقه‌تان را بگیرید (یا، با توجه به مقدمه من، شاید چیزی کمی خنک‌تر)، بنشینید، استراحت کنید، و "Let It Go"!

یا، هوم، شاید، عملکرد .NET 10 را "Into the Unknown" فشار دهیم؟

عملکرد .NET 10 را "Show Yourself" کنیم؟

"آیا می‌خواهید یک سرویس سریع Snowman بسازید؟"

خودم را بیرون می‌کنم.

تنظیم بنچمارکینگ

مانند پست‌های قبلی، این تور پر از میکروبنچمارک‌هایی است که برای نمایش بهبودهای عملکرد مختلف طراحی شده‌اند. بیشتر این بنچمارک‌ها با استفاده از BenchmarkDotNet 0.15.2 پیاده‌سازی شده‌اند، با تنظیم ساده برای هر کدام.

برای همراهی، مطمئن شوید .NET 9 و .NET 10 نصب شده‌اند، زیرا بیشتر بنچمارک‌ها همان تست را روی هر کدام مقایسه می‌کنند. سپس، یک پروژه C# جدید در دایرکتوری جدید benchmarks ایجاد کنید:

dotnet new console -o benchmarks
cd benchmarks

این دو فایل در دایرکتوری benchmarks تولید می‌کند: benchmarks.csproj، که فایل پروژه با اطلاعات در مورد نحوه کامپایل اپلیکیشن است، و Program.cs، که کد اپلیکیشن را شامل می‌شود. در نهایت، همه چیز در benchmarks.csproj را با این جایگزین کنید:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net10.0;net9.0</TargetFrameworks>
    <LangVersion>Preview</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <ServerGarbageCollection>true</ServerGarbageCollection>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.15.2" />
  </ItemGroup>

</Project>

با این، آماده‌ایم. مگر اینکه خلاف آن ذکر شود، سعی کردم هر بنچمارک را مستقل کنم؛ فقط محتوای کامل آن را در فایل Program.cs کپی/پیست کنید، همه چیز را جایگزین کنید، و سپس بنچمارک‌ها را اجرا کنید. هر تست در بالای خود کامنتی برای فرمان dotnet برای اجرای بنچمارک دارد. معمولاً چیزی شبیه این است:

dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0

که بنچمارک را در حالت release روی هر دو .NET 9 و .NET 10 اجرا می‌کند و نتایج مقایسه‌شده را نشان می‌دهد. تغییر رایج دیگر، که وقتی بنچمارک فقط روی .NET 10 اجرا شود (معمولاً چون دو رویکرد را مقایسه می‌کند نه یک چیز روی دو نسخه)، به شرح زیر است:

dotnet run -c Release -f net10.0 --filter "*"

در سراسر پست، بسیاری از بنچمارک‌ها و نتایج دریافتی از اجرای آن‌ها را نشان دادم. مگر اینکه خلاف آن بیان شود (مثلاً چون بهبود خاص سیستم‌عامل را نشان می‌دهم)، نتایج نشان‌داده‌شده از اجرای آن‌ها روی لینوکس (Ubuntu 24.04.1) روی پردازنده x64 است.

BenchmarkDotNet v0.15.2, Linux Ubuntu 24.04.1 LTS (Noble Numbat)
11th Gen Intel Core i9-11950H 2.60GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.100-rc.1.25451.107
  [Host]     : .NET 9.0.9 (9.0.925.41916), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

همانطور که همیشه، یک disclaimer سریع: این‌ها میکروبنچمارک‌ها هستند، عملیات‌هایی را زمان‌سنجی می‌کنند که آنقدر کوتاه هستند که با پلک زدن از دست می‌دهید (اما وقتی چنین عملیات‌هایی میلیون‌ها بار اجرا می‌شوند، صرفه‌جویی واقعاً جمع می‌شود). اعداد دقیق شما بستگی به سخت‌افزار، سیستم‌عامل، آنچه ماشین شما در حال حاضر مشغول آن است، چقدر قهوه از صبح خورده‌اید، و شاید اینکه آیا عطارد در حال عقب‌نشینی است یا نه. به عبارت دیگر، انتظار نداشته باشید نتایج شما دقیقاً با من مطابقت کند، اما تست‌هایی انتخاب کردم که همچنان در جهان واقعی نسبتاً قابل تکرار باشند.

حالا، از پایین پشته شروع کنیم. تولید کد.

JIT

در میان تمام حوزه‌های .NET، کامپایلر Just-In-Time (JIT) یکی از تأثیرگذارترین‌ها است. هر اپلیکیشن .NET، چه ابزار کنسول کوچک یا سرویس سازمانی بزرگ، در نهایت به JIT تکیه می‌کند تا کد زبان میانی (IL) را به کد ماشین بهینه‌شده تبدیل کند. هر بهبود در کیفیت کد تولیدشده توسط JIT، اثر موجی دارد و عملکرد را در سراسر اکوسیستم بهبود می‌بخشد بدون نیاز به تغییر کد توسط توسعه‌دهندگان یا حتی کامپایل مجدد C# آن‌ها. و با .NET 10، کمبود این بهبودها وجود ندارد.

Deabstraction

مانند بسیاری از زبان‌ها، .NET历史上 دارای "جریمه انتزاع" بوده، آن تخصیص‌های اضافی و غیرمستقیم‌هایی که هنگام استفاده از ویژگی‌های سطح بالا مانند رابط‌ها، تکرارکننده‌ها، و delegateها رخ می‌دهد. هر سال، JIT بهتر و بهتر در بهینه‌سازی لایه‌های انتزاع می‌شود، تا توسعه‌دهندگان کد ساده بنویسند و همچنان عملکرد عالی بگیرند. .NET 10 این سنت را ادامه می‌دهد. نتیجه این است که C# idiomatic (استفاده از رابط‌ها، لوپ‌های foreach، لامبداها، و غیره) حتی نزدیک‌تر به سرعت خام کد دست‌کاری‌شده و تنظیم‌شده اجرا می‌شود.

تخصیص پشته اشیاء

یکی از هیجان‌انگیزترین حوزه‌های پیشرفت deabstraction در .NET 10، استفاده گسترش‌یافته از تحلیل فرار (escape analysis) برای فعال کردن تخصیص پشته اشیاء است. تحلیل فرار تکنیک کامپایلر برای تعیین اینکه آیا یک شیء تخصیص‌یافته در یک متد از آن متد فرار می‌کند، یعنی تعیین اینکه آیا آن شیء پس از بازگشت متد قابل دسترسی است (برای مثال، ذخیره‌شده در یک فیلد یا بازگشت به کالر) یا به نوعی استفاده می‌شود که ران‌تایم نمی‌تواند درون متد ردیابی کند (مانند پاس به یک کالر ناشناخته). اگر کامپایلر بتواند ثابت کند شیء فرار نمی‌کند، سپس عمر شیء توسط متد محدود می‌شود، و می‌تواند روی پشته تخصیص یابد به جای هیپ. تخصیص پشته خیلی ارزان‌تر است (فقط ضربه زدن به اشاره‌گر برای تخصیص و آزادسازی خودکار هنگام خروج متد) و فشار GC را کاهش می‌دهد چون، خوب، شیء نیاز به ردیابی توسط GC ندارد. .NET 9 قبلاً برخی پشتیبانی محدود تحلیل فرار و تخصیص پشته را معرفی کرده بود؛ .NET 10 این را به طور قابل توجهی جلوتر می‌برد.

dotnet/runtime#115172 به JIT آموزش می‌دهد چگونه تحلیل فرار مرتبط با delegateها را انجام دهد، و به طور خاص اینکه متد Invoke delegate (که توسط ران‌تایم پیاده‌سازی شده) اشاره this را ذخیره نمی‌کند. سپس اگر تحلیل فرار بتواند ثابت کند که اشاره شیء delegate چیزی است که در غیر این صورت فرار نکرده، delegate می‌تواند به طور مؤثر تبخیر شود. این بنچمارک را در نظر بگیرید:


// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[DisassemblyDiagnoser]
[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "y")]
public partial class Tests
{
    [Benchmark]
    [Arguments(42)]
    public int Sum(int y)
    {
        Func<int, int> addY = x => x + y;
        return DoubleResult(addY, y);
    }

    private int DoubleResult(Func<int, int> func, int arg)
    {
        int result = func(arg);
        return result + result;
    }
}

اگر فقط این بنچمارک را اجرا کنیم و .NET 9 و .NET 10 را مقایسه کنیم، بلافاصله می‌توانیم بگوییم چیزی جالب در حال رخ دادن است.

MethodRuntimeMeanRatioCode SizeAllocatedAlloc Ratio
Sum.NET 9.019.530 ns1.00118 B88 B1.00
Sum.NET 10.06.685 ns0.3432 B24 B0.27

کد C# برای Sum پیچیدگی تولید کد توسط کامپایلر C# را پنهان می‌کند. نیاز به ایجاد یک Func<int, int> دارد که "بستن" روی محلی y است. این به معنای آن است که کامپایلر نیاز به "بالا بردن" y دارد تا دیگر محلی واقعی نباشد، و در عوض به عنوان فیلدی روی یک شیء زندگی کند؛ delegate سپس می‌تواند به متدی روی آن نمونه کلاس نمایش اشاره کند، و به y دسترسی دهد. این تقریباً آنچه IL تولیدشده توسط کامپایلر C# به نظر می‌رسد وقتی به C# دی‌کامپایل می‌شود:


public int Sum(int y)
{
    <>c__DisplayClass0_0 c = new();
    c.y = y;

    Func<int, int> func = new(c.<Sum>b__0);

    return DoubleResult(func, c.y);
}

private sealed class <>c__DisplayClass0_0
{
    public int y;

    internal int <Sum>b__0(int x) => x + y;
}

از آن، می‌توانیم ببینیم که closure منجر به دو تخصیص می‌شود: یک تخصیص برای "کلاس نمایش" (آنچه کامپایلر C# این نوع closureها را می‌نامد) و یک تخصیص برای delegate که به متد <Sum>b__0 روی آن نمونه کلاس نمایش اشاره می‌کند. این همان چیزی است که 88 بایت تخصیص در نتایج .NET 9 را توجیه می‌کند: کلاس نمایش 24 بایت است، و delegate 64 بایت است. در نسخه .NET 10، اما، فقط یک تخصیص 24 بایتی می‌بینیم؛ زیرا JIT با موفقیت تخصیص delegate را حذف کرده است. اینجا کد اسمبلی نتیجه است:


; .NET 9
; Tests.Sum(Int32)
       push      rbp
       push      r15
       push      rbx
       lea       rbp,[rsp+10]
       mov       ebx,esi
       mov       rdi,offset MT_Tests+<>c__DisplayClass0_0
       call      CORINFO_HELP_NEWSFAST
       mov       r15,rax
       mov       [r15+8],ebx
       mov       rdi,offset MT_System.Func<System.Int32, System.Int32>
       call      CORINFO_HELP_NEWSFAST
       mov       rbx,rax
       lea       rdi,[rbx+8]
       mov       rsi,r15
       call      CORINFO_HELP_ASSIGN_REF
       mov       rax,offset Tests+<>c__DisplayClass0_0.<Sum>b__0(Int32)
       mov       [rbx+18],rax
       mov       esi,[r15+8]
       cmp       [rbx+18],rax
       jne       short M00_L01
       mov       rax,[rbx+8]
       add       esi,[rax+8]
       mov       eax,esi
M00_L00:
       add       eax,eax
       pop       rbx
       pop       r15
       pop       rbp
       ret
M00_L01:
       mov       rdi,[rbx+8]
       call      qword ptr [rbx+18]
       jmp       short M00_L00
; Total bytes of code 112

; .NET 10
; Tests.Sum(Int32)
       push      rbx
       mov       ebx,esi
       mov       rdi,offset MT_Tests+<>c__DisplayClass0_0
       call      CORINFO_HELP_NEWSFAST
       mov       [rax+8],ebx
       mov       eax,[rax+8]
       mov       ecx,eax
       add       eax,ecx
       add       eax,eax
       pop       rbx
       ret
; Total bytes of code 32

در هر دو .NET 9 و .NET 10، JIT با موفقیت DoubleResult را inline کرد، به طوری که delegate فرار نمی‌کند، اما سپس در .NET 10، قادر است آن را روی پشته تخصیص دهد. ووهو! البته هنوز فرصت آینده‌ای وجود دارد، زیرا JIT تخصیص شیء closure را حذف نمی‌کند، اما این باید با تلاش بیشتر قابل حل باشد، امیدوارم در آینده نزدیک.

dotnet/runtime#104906 از @hez2010 و dotnet/runtime#112250 این نوع تحلیل و تخصیص پشته را به آرایه‌ها گسترش می‌دهند. چند بار کد مانند این نوشته‌اید؟


// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
    [Benchmark]
    public void Test()
    {
        Process(new string[] { "a", "b", "c" });

        static void Process(string[] inputs)
        {
            foreach (string input in inputs)
            {
                Use(input);
            }

            [MethodImpl(MethodImplOptions.NoInlining)]
            static void Use(string input) { }
        }
    }
}

بعضی متد که می‌خواهم فراخوانی کنم آرایه‌ای از ورودی‌ها را می‌پذیرد و چیزی برای هر ورودی انجام می‌دهد. نیاز به تخصیص یک آرایه برای پاس ورودی‌هایم دارم، یا صریحاً، یا شاید غیرمستقیم به دلیل استفاده از params یا expression مجموعه. ایده‌آل، در آینده overloadی از چنین متد Process وجود خواهد داشت که ReadOnlySpan<string> را به جای string[] می‌پذیرد، و سپس می‌توانم تخصیص را با ساختن اجتناب کنم. اما برای تمام این موارد که مجبور به ایجاد آرایه هستم، .NET 10 به نجات می‌آید.

MethodRuntimeMeanRatioAllocatedAlloc Ratio
Test.NET 9.011.580 ns1.0048 B1.00
Test.NET 10.03.960 ns0.340.00

JIT قادر بود Process را inline کند، ببیند که آرایه هرگز فریم را ترک نمی‌کند، و آن را روی پشته تخصیص دهد.

البته، حالا که قادر به تخصیص پشته آرایه‌ها هستیم، همچنین می‌خواهیم بتوانیم با روش رایج استفاده از آن آرایه‌ها کنار بیاییم: از طریق اسپن‌ها. dotnet/runtime#113977 و dotnet/runtime#116124 به تحلیل فرار آموزش می‌دهند که بتواند در مورد فیلدهای structها استدلال کند، که شامل Span<T> می‌شود، زیرا آن "فقط" یک struct است که یک فیلد ref T و یک فیلد int length ذخیره می‌کند.


// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[MemoryDiagnoser(displayGenColumns: false)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
    private byte[] _buffer = new byte[3];

    [Benchmark]
    public void Test() => Copy3Bytes(0x12345678, _buffer);

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void Copy3Bytes(int value, Span<byte> dest) =>
        BitConverter.GetBytes(value).AsSpan(0, 3).CopyTo(dest);
}

اینجا، از BitConverter.GetBytes استفاده می‌کنیم، که یک byte[] تخصیص می‌دهد که شامل بایت‌های از ورودی است (در این مورد، یک آرایه چهار بایتی برای int خواهد بود)، سپس سه تا از چهار بایت را برش می‌دهیم، و آن‌ها را به اسپن مقصد کپی می‌کنیم.

MethodRuntimeMeanRatioAllocatedAlloc Ratio
Test.NET 9.09.7717 ns1.0432 B1.00
Test.NET 10.00.8718 ns0.090.00

در .NET 9، 32 بایت تخصیص را که انتظار داریم برای byte[] در GetBytes می‌گیریم (هر شیء روی 64 بیت حداقل 24 بایت است، که شامل چهار بایت برای طول آرایه می‌شود، و سپس چهار بایت برای داده در اسلات‌های 24-27 خواهد بود، و اندازه تا مرز کلمه بعدی پد می‌شود، برای 32). در .NET 10، با inline شدن GetBytes و AsSpan، JIT می‌تواند ببیند که آرایه فرار نمی‌کند، و نسخه تخصیص‌شده روی پشته آن را می‌تواند برای بذر اسپن استفاده کند، درست مانند اینکه از هر تخصیص پشته دیگری (مانند stackalloc) ایجاد شده باشد. (این مورد همچنین نیاز به کمک کمی از dotnet/runtime#113093 داشت، که به JIT آموزش داد که عملیات اسپن خاصی، مانند Memmove استفاده‌شده داخلی توسط CopyTo، غیرفرار هستند.)

Devirtualization

رابط‌ها و متدهای مجازی جنبه‌ای حیاتی از .NET و انتزاع‌هایی که فعال می‌کند هستند. سپس، باز کردن این انتزاع‌ها و "devirtualize" کردن وظیفه مهمی برای JIT است، که در .NET 10 جهش‌های قابل توجهی در قابلیت‌ها گرفته است.

در حالی که آرایه‌ها یکی از ویژگی‌های مرکزی ارائه‌شده توسط C# و .NET هستند، و در حالی که JIT انرژی زیادی صرف می‌کند و کار عالی در بهینه‌سازی بسیاری از جنبه‌های آرایه‌ها انجام می‌دهد، یک حوزه خاص باعث درد آن شده: پیاده‌سازی رابط آرایه. ران‌تایم تعداد زیادی پیاده‌سازی رابط برای T[] تولید می‌کند، و چون آن‌ها متفاوت از هر پیاده‌سازی رابط دیگر در .NET پیاده‌سازی شده‌اند، JIT قادر نبوده قابلیت‌های devirtualization مشابهی را که در جای دیگر اعمال می‌کند، اعمال کند. و، برای هر کسی که به میکروبنچمارک‌ها عمیق غواصی کرده، این می‌تواند به برخی مشاهدات عجیب منجر شود. اینجا مقایسه عملکرد بین تکرار روی ReadOnlyCollection<T> با استفاده از لوپ foreach (رفتن از طریق enumerator آن) و استفاده از لوپ for (ایندکسینگ روی هر عنصر) است.


// dotnet run -c Release -f net9.0 --filter "*"
// dotnet run -c Release -f net9.0 --filter "*" --runtimes net9.0 net10.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.ObjectModel;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
    private ReadOnlyCollection<int> _list = new(Enumerable.Range(1, 1000).ToArray());

    [Benchmark]
    public int SumEnumerable()
    {
        int sum = 0;
        foreach (var item in _list)
        {
            sum += item;
        }
        return sum;
    }

    [Benchmark]
    public int SumForLoop()
    {
        ReadOnlyCollection<int> list = _list;
        int sum = 0;
        int count = list.Count;
        for (int i = 0; i < count; i++)
        {
            sum += _list[i];
        }
        return sum;
    }
}

وقتی پرسیده می‌شود "کدام یک سریع‌تر خواهد بود"، پاسخ واضح "SumForLoop" است. بالاخره، SumEnumerable یک enumerator تخصیص می‌دهد و دو برابر تعداد فراخوانی رابط (MoveNext+Current در هر تکرار در مقابل this[int] در هر تکرار) دارد. معلوم می‌شود، پاسخ واضح نیز اشتباه است. اینجا زمان‌بندی‌ها روی ماشین من برای .NET 9:

MethodMean
SumEnumerable949.5 ns
SumForLoop1,932.7 ns

چی چی؟ اگر ToArray را به جای آن به ToList تغییر دهم، اما، اعداد خیلی بیشتر با انتظارات ما همخوانی دارند.

MethodMean
SumEnumerable1,542.0 ns
SumForLoop894.1 ns

پس چه خبر است؟ خیلی ظریف است. اول، لازم است بدانید که ReadOnlyCollection<T> فقط یک IList<T> دلخواه را می‌پیچد، GetEnumerator() ReadOnlyCollection<T> _list.GetEnumerator() را برمی‌گرداند (برای این بحث، مورد خاص که لیست خالی است را نادیده می‌گیرم)، و ایندکسر ReadOnlyCollection<T> فقط به ایندکسر IList<T> ایندکس می‌کند. تا اینجا احتمالاً همه این‌ها به آنچه انتظار دارید صدا می‌دهد. اما جایی که چیزها جالب می‌شود اطراف آنچه JIT قادر به devirtualize کردن است. در .NET 9، برای devirtualize فراخوانی‌های به پیاده‌سازی رابط به طور خاص روی T[] تلاش می‌کند، پس نه _list.GetEnumerator() فراخوانی را devirtualize می‌کند و نه _list[index] فراخوانی را. اما، enumerator که برگردانده می‌شود فقط یک نوع معمولی است که IEnumerator<T> را پیاده می‌کند، و JIT مشکلی با devirtualize کردن اعضای MoveNext و Current آن ندارد. که به معنای آن است که در واقع هزینه بیشتری برای رفتن از طریق ایندکسر پرداخت می‌کنیم، زیرا برای N عنصر، مجبور به N فراخوانی رابط هستیم، در حالی که با enumerator، فقط یکی با فراخوانی رابط GetEnumerator و سپس هیچ دیگری بعد از آن.

خوشبختانه، این حالا در .NET 10 حل شده است. dotnet/runtime#108153, dotnet/runtime#109209, dotnet/runtime#109237, و dotnet/runtime#116771 همه امکان devirtualize پیاده‌سازی متد رابط آرایه را برای JIT ممکن می‌کنند. حالا وقتی همان بنچمارک را اجرا می‌کنیم (بازگشت به استفاده از ToArray)، نتایج خیلی بیشتر با انتظارات ما همخوانی دارد، با هر دو بنچمارک از .NET 9 به .NET 10 بهبود یافته، و با SumForLoop روی .NET 10 سریع‌ترین بودن.

MethodRuntimeMeanRatio
SumEnumerable.NET 9.0968.5 ns1.00
SumEnumerable.NET 10.0775.5 ns0.80
SumForLoop.NET 9.01,960.5 ns1.00
SumForLoop.NET 10.0624.6 ns0.32

یکی از چیزهای واقعاً جالب در مورد این، تعداد کتابخانه‌هایی است که بر اساس فرضیه اینکه سریع‌تر است از ایندکسر IList<T> برای تکرار استفاده شود تا IEnumerable<T> برای...