Dukungan untuk instruksi khusus peranti keras di .NET Core (sekarang bukan hanya SIMD)

Pendahuluan


Beberapa tahun yang lalu, kami memutuskan bahwa sudah waktunya untuk mendukung kode SIMD di .NET . Kami memperkenalkan System.Numerics namespace dengan tipe Vector2 , Vector3 , Vector4 dan Vector<T> . Jenis-jenis ini mewakili API tujuan umum untuk membuat, mengakses, dan memanipulasi instruksi vektor bila memungkinkan. Mereka juga menyediakan kompatibilitas perangkat lunak untuk kasus-kasus di mana perangkat keras tidak mendukung instruksi yang sesuai. Ini memungkinkan, dengan refactoring minimal, untuk membuat vektor sejumlah algoritma. Namun, secara umum pendekatan ini mempersulit penerapan untuk mendapatkan keuntungan penuh dari semua yang tersedia, pada perangkat keras modern, instruksi vektor. Selain itu, perangkat keras modern menyediakan sejumlah instruksi khusus, non-vektor, yang dapat secara signifikan meningkatkan kinerja. Pada artikel ini, saya akan berbicara tentang bagaimana kami menghindari batasan-batasan ini dalam .NET Core 3.0.



Catatan: Belum ada istilah untuk terjemahan Intrisics . Di akhir artikel ada suara untuk opsi terjemahan. Jika kami memilih opsi yang baik, kami akan mengubah artikel


Apa fungsi bawaan


Di .NET Core 3.0, kami menambahkan fungsionalitas baru yang disebut fungsi bawaan perangkat keras khusus (WF jauh). Fungsionalitas ini menyediakan akses ke banyak instruksi perangkat keras tertentu yang tidak dapat hanya diwakili oleh mekanisme tujuan umum. Mereka berbeda dari instruksi SIMD yang ada dalam hal mereka tidak memiliki tujuan umum ( WF baru bukan lintas platform dan arsitektur mereka tidak memberikan kompatibilitas perangkat lunak). Sebagai gantinya, mereka secara langsung menyediakan fungsionalitas platform dan perangkat keras khusus untuk pengembang .NET. Fungsi SIMD yang ada, misalnya, lintas platform, memberikan kompatibilitas perangkat lunak, dan sedikit disarikan dari perangkat keras yang mendasarinya. Abstraksi ini bisa mahal, selain itu, dapat mencegah pengungkapan beberapa fungsionalitas (ketika, misalnya, fungsionalitas tidak ada, atau sulit untuk ditiru pada semua platform target).


Fungsi bawaan baru , dan tipe yang didukung, terletak di bawah System.Runtime.Intrinsics . Untuk .NET Core 3.0, saat ini, ada satu System.Runtime.Intrinsics.X86 . Kami sedang berupaya mendukung fungsi bawaan untuk platform lain seperti System.Runtime.Intrinsics.Arm .


Di bawah ruang nama khusus platform, WF dikelompokkan ke dalam kelas yang mewakili kelompok instruksi perangkat keras yang terintegrasi secara logis (sering disebut arsitektur set instruksi (ISA)). Setiap kelas menyediakan properti IsSupported menunjukkan apakah perangkat keras yang menjalankan kode mendukung set instruksi ini. Selanjutnya, setiap kelas tersebut berisi serangkaian metode yang dipetakan ke serangkaian instruksi yang sesuai. Kadang-kadang ada subkelas tambahan yang sesuai dengan bagian dari set instruksi yang sama, yang mungkin dibatasi (didukung) oleh perangkat keras tertentu. Sebagai contoh, kelas Lzcnt menyediakan akses ke instruksi untuk menghitung nol terkemuka . Dia memiliki subclass yang disebut X64 , yang berisi bentuk instruksi ini hanya digunakan pada mesin dengan arsitektur 64-bit.


Beberapa kelas ini secara alami bersifat hierarkis. Misalnya, jika Lzcnt.X64.IsSupported mengembalikan true, maka Lzcnt.IsSupported juga harus mengembalikan true, karena ini adalah subkelas eksplisit. Atau, misalnya, jika Sse2.IsSupported mengembalikan true, maka Sse.IsSupported harus mengembalikan true, karena Sse2 secara eksplisit mewarisi dari Sse . Namun, perlu dicatat bahwa kesamaan nama kelas bukan merupakan indikator milik mereka dalam hierarki warisan yang sama. Misalnya, Bmi2 tidak diwarisi dari Bmi1 , sehingga nilai yang dikembalikan oleh IsSupported untuk dua set instruksi ini akan berbeda. Prinsip dasar dalam pengembangan kelas-kelas ini adalah presentasi eksplisit spesifikasi ISA. SSE2 membutuhkan dukungan untuk SSE1, sehingga kelas yang mewakilinya terkait dengan warisan. Pada saat yang sama, BMI2 tidak memerlukan dukungan untuk BMI1, jadi kami tidak menggunakan warisan. Berikut ini adalah contoh dari API di atas.


 namespace System.Runtime.Intrinsics.X86 { public abstract class Sse { public static bool IsSupported { get; } public static Vector128<float> Add(Vector128<float> left, Vector128<float> right); // Additional APIs public abstract class X64 { public static bool IsSupported { get; } public static long ConvertToInt64(Vector128<float> value); // Additional APIs } } public abstract class Sse2 : Sse { public static new bool IsSupported { get; } public static Vector128<byte> Add(Vector128<byte> left, Vector128<byte> right); // Additional APIs public new abstract class X64 : Sse.X64 { public static bool IsSupported { get; } public static long ConvertToInt64(Vector128<double> value); // Additional APIs } } } 

Anda dapat melihat lebih banyak di kode sumber di tautan berikut source.dot.net atau dotnet / coreclr di GitHub


Pemeriksaan IsSupported diproses oleh kompiler JIT sebagai konstanta runtime (ketika optimasi diaktifkan), sehingga Anda tidak perlu kompilasi silang untuk mendukung beberapa ISA, platform, atau arsitektur. Sebagai gantinya, Anda hanya perlu menulis kode menggunakan ekspresi if , akibatnya cabang kode yang tidak digunakan (mis. Cabang-cabang yang tidak dapat dijangkau karena nilai variabel dalam pernyataan kondisional) akan dibuang ketika kode asli dihasilkan.


Penting bahwa verifikasi IsSupported sesuai mendahului penggunaan perintah perangkat keras IsSupported . Jika tidak ada pemeriksaan seperti itu, maka kode menggunakan perintah khusus platform yang berjalan pada platform / arsitektur di mana perintah ini tidak didukung akan melempar pengecualian runtime PlatformNotSupportedException .


Apa manfaat yang mereka berikan?


Tentu saja, fungsi bawaan perangkat keras khusus tidak untuk semua orang, tetapi mereka dapat digunakan untuk meningkatkan kinerja dalam operasi yang sarat dengan perhitungan. ML.NET dan ML.NET menggunakan metode ini untuk mempercepat operasi seperti menyalin di memori, mencari indeks elemen dalam array atau string, mengubah ukuran gambar, atau bekerja dengan vektor / matriks / tensor. Vektorisasi manual dari beberapa kode yang ternyata menjadi hambatan juga bisa lebih sederhana daripada yang terdengar. Vektorisasi kode, pada kenyataannya, adalah untuk melakukan beberapa operasi pada suatu waktu, secara umum, menggunakan instruksi SIMD (satu aliran instruksi, banyak aliran data).


Sebelum Anda memutuskan untuk membuat vektor beberapa kode, Anda perlu melakukan profil untuk memastikan bahwa kode ini benar-benar bagian dari "hot spot" (dan, oleh karena itu, optimasi Anda akan memberikan peningkatan kinerja yang signifikan). Penting juga untuk melakukan profil pada setiap tahap vektorisasi, karena vektorisasi tidak semua kode mengarah pada peningkatan produktivitas.


Vektorisasi dari algoritma sederhana


Untuk menggambarkan penggunaan fungsi bawaan, kami menggunakan algoritme untuk menjumlahkan semua elemen array atau rentang. Jenis kode ini adalah kandidat yang ideal untuk vektorisasi, karena pada setiap iterasi, operasi sepele yang sama dilakukan.


Contoh implementasi dari algoritma semacam itu mungkin terlihat sebagai berikut:


 public int Sum(ReadOnlySpan<int> source) { int result = 0; for (int i = 0; i < source.Length; i++) { result += source[i]; } return result; } 

Kode ini cukup sederhana dan mudah, tetapi pada saat yang sama cukup lambat untuk data input besar, seperti tidak hanya satu operasi sepele per iterasi.


 BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362 AMD Ryzen 7 1800X, 1 CPU, 16 logical and 8 physical cores .NET Core SDK=3.0.100-preview9-013775 [Host] : .NET Core 3.0.0-preview9-19410-10 (CoreCLR 4.700.19.40902, CoreFX 4.700.19.40917), 64bit RyuJIT [AttachedDebugger] DefaultJob : .NET Core 3.0.0-preview9-19410-10 (CoreCLR 4.700.19.40902, CoreFX 4.700.19.40917), 64bit RyuJIT 

MetodeHitungBerartiKesalahanStddev
Jumlah12,477 ns0,0192 ns0,0179 ns
Jumlah22.164 ns0,0265 ns0,0235 ns
Jumlah43,224 ns0,0302 ns0,0267 ns
Jumlah84.347 ns0,0665 ns0,0622 ns
Jumlah168,444 ns0,2042 ns0,3734 ns
Jumlah3213.963 ns0,2182 ns0,2041 ns
Jumlah6450,344 ns0,2955 ns0,2620 ns
Jumlah12860.139 ns0,3890 ns0,3639 ns
Jumlah256106,416 ns0,6404 ns0,5990 ns
Jumlah512291.450 ns3.5148 ns3,2878 ns
Jumlah1024574.243 ns9.5851 ns8,4970 ns
Jumlah20481 137.819 ns5,9363 ns5,5529 ns
Jumlah40962 228.341 ns22.8882 ns21.4097 ns
Jumlah81922 973.040 ns14.2863 ns12.6644 ns
Jumlah163845 883.504 ns15.9619 ns14.9308 ns
Jumlah3276811 699.237 ns104.0970 ns97.3724 ns

Tingkatkan Produktivitas Melalui Siklus Penempatan


Prosesor modern memiliki berbagai opsi untuk meningkatkan kinerja kode. Untuk aplikasi single-threaded, salah satu opsi tersebut adalah melakukan beberapa operasi primitif dalam satu siklus prosesor.


Sebagian besar prosesor modern dapat melakukan empat operasi tambahan dalam satu siklus clock (dalam kondisi optimal), sebagai akibatnya, dengan "tata letak" kode yang benar, Anda kadang-kadang dapat meningkatkan kinerja, bahkan dalam implementasi single-threaded.


Meskipun JIT dapat melakukan loop unrolling sendiri, JIT konservatif dalam membuat keputusan semacam ini, karena ukuran kode yang dihasilkan. Oleh karena itu, mungkin menguntungkan untuk menggunakan loop, dalam kode, secara manual.


Anda dapat memperluas loop dalam kode di atas sebagai berikut:


 public unsafe int SumUnrolled(ReadOnlySpan<int> source) { int result = 0; int i = 0; int lastBlockIndex = source.Length - (source.Length % 4); // Pin source so we can elide the bounds checks fixed (int* pSource = source) { while (i < lastBlockIndex) { result += pSource[i + 0]; result += pSource[i + 1]; result += pSource[i + 2]; result += pSource[i + 3]; i += 4; } while (i < source.Length) { result += pSource[i]; i += 1; } } return result; } 

Kode ini sedikit lebih rumit, tetapi lebih baik menggunakan fitur perangkat keras.


Untuk loop yang sangat kecil, kode ini berjalan sedikit lebih lambat. Tetapi tren ini sudah berubah untuk input data dari delapan elemen, setelah itu kecepatan eksekusi mulai meningkat (waktu eksekusi kode yang dioptimalkan, untuk 32 ribu elemen, lebih sedikit 26% dari waktu versi aslinya). Perlu dicatat bahwa optimasi seperti itu tidak selalu meningkatkan produktivitas. Misalnya, ketika bekerja dengan koleksi dengan elemen tipe float "yang digunakan" dari algoritma memiliki kecepatan yang hampir sama dengan yang asli. Karena itu, sangat penting untuk melakukan profiling.


MetodeHitungBerartiKesalahanStddev
Sumunrolled12,922 ns0,0651 ns0,0609 ns
Sumunrolled23,576 ns0,0116 ns0,0109 ns
Sumunrolled43,708 ns0,0157 ns0,0139 ns
Sumunrolled84,832 ns0,0486 ns0,0454 ns
Sumunrolled167,490 ns0,1131 ns0,1058 ns
Sumunrolled3211.277 ns0,0910 ns0,0851 ns
Sumunrolled6419.761 ns0,2016 ns0,1885 ns
Sumunrolled12836.639 ns0,3043 ns0,2847 ns
Sumunrolled25677.969 ns0,8409 ns0,7866 ns
Sumunrolled512146.357 ns1.3209 ns1.2356 ns
Sumunrolled1024287.354 ns0,9223 ns0,8627 ns
Sumunrolled2048566.405 ns4.0155 ns3,5596 ns
Sumunrolled40961 131.016 ns7,3601 ns6.5246 ns
Sumunrolled81922 259.836 ns8,6539 ns8.0949 ns
Sumunrolled163844 501.295 ns6.4186 ns6,0040 ns
Sumunrolled327688 979.690 ns19,5265 ns18.2651 ns


Tingkatkan produktivitas melalui vektorisasi loop


Meski begitu, tapi kami masih bisa sedikit mengoptimalkan kode ini. Instruksi SIMD adalah opsi lain yang disediakan oleh prosesor modern untuk meningkatkan kinerja. Menggunakan instruksi tunggal, mereka memungkinkan Anda untuk melakukan beberapa operasi dalam satu siklus clock tunggal. Ini mungkin lebih baik daripada loop terbuka berlangsung, karena, pada kenyataannya, hal yang sama dilakukan, tetapi dengan jumlah yang lebih kecil dari kode yang dihasilkan.


Untuk memperjelas, setiap operasi penambahan, dalam siklus yang digunakan, membutuhkan 4 byte. Jadi, kita membutuhkan 16 byte untuk 4 operasi penambahan dalam bentuk diperluas. Pada saat yang sama, instruksi penambahan SIMD juga melakukan 4 operasi tambahan, tetapi hanya membutuhkan 4 byte. Ini berarti kami memiliki lebih sedikit instruksi untuk CPU. Selain itu, dalam kasus instruksi SIMD, CPU dapat membuat asumsi dan melakukan optimasi, tetapi ini di luar ruang lingkup artikel ini. Apa yang lebih baik adalah bahwa prosesor modern dapat menjalankan lebih dari satu instruksi SIMD pada suatu waktu, yaitu, dalam beberapa kasus, Anda dapat menerapkan strategi campuran, pada saat yang sama melakukan pemindaian siklus parsial dan vektorisasi.


Secara umum, Anda harus mulai dengan melihat kelas tujuan umum Vector<T> untuk tugas Anda. Dia, seperti WF baru, akan menanamkan instruksi SIMD, tetapi pada saat yang sama, mengingat keserbagunaan kelas ini, ia dapat mengurangi jumlah pengkodean "manual".


Kode mungkin terlihat seperti ini:


 public int SumVectorT(ReadOnlySpan<int> source) { int result = 0; Vector<int> vresult = Vector<int>.Zero; int i = 0; int lastBlockIndex = source.Length - (source.Length % Vector<int>.Count); while (i < lastBlockIndex) { vresult += new Vector<int>(source.Slice(i)); i += Vector<int>.Count; } for (int n = 0; n < Vector<int>.Count; n++) { result += vresult[n]; } while (i < source.Length) { result += source[i]; i += 1; } return result; } 

Kode ini bekerja lebih cepat, tetapi kami dipaksa untuk merujuk ke setiap elemen secara terpisah saat menghitung jumlah akhir. Juga, Vector<T> tidak memiliki ukuran yang ditentukan secara tepat, dan dapat bervariasi, tergantung pada peralatan di mana kode berjalan. fungsi bawaan khusus perangkat keras menyediakan fungsionalitas tambahan yang sedikit dapat meningkatkan kode ini dan membuatnya sedikit lebih cepat (dengan biaya kompleksitas kode tambahan dan persyaratan pemeliharaan).


MetodeHitungBerartiKesalahanStddev
SumVectorT14,517 ns0,0752 ns0,0703 ns
SumVectorT24,853 ns0,0609 ns0,0570 ns
SumVectorT45,047 ns0,0909 ns0,0850 ns
SumVectorT85,671 ns0,0251 ns0,0223 ns
SumVectorT166.579 ns0,0330 ns0,0276 ns
SumVectorT3210.460 ns0,0241 ns0,0226 ns
SumVectorT6417.148 ns0,0407 ns0,0381 ns
SumVectorT12823.239 ns0,0853 ns0,0756 ns
SumVectorT25662.146 ns0,8319 ns0,7782 ns
SumVectorT512114.863 ns0,4175 ns0,3906 ns
SumVectorT1024172.129 ns1,8673 ns1.7467 ns
SumVectorT2048429.722 ns1,0461 ns0,9786 ns
SumVectorT4096654.209 ns3,6215 ns3,0241 ns
SumVectorT81921 675.046 ns14,5231 ns13,5849 ns
SumVectorT163842 514.778 ns5.3369 ns4,9921 ns
SumVectorT327686,689.829 ns13,9947 ns13.0906 ns


CATATAN Untuk artikel ini, saya secara paksa membuat ukuran Vector<T> sama dengan 16 byte menggunakan parameter konfigurasi internal ( COMPlus_SIMD16ByteOnly=1 ). Tweak ini menormalkan hasil ketika membandingkan SumVectorT dengan SumVectorizedSse , dan memungkinkan kami untuk menjaga kode tetap sederhana. Secara khusus, ia menghindari penulisan lompatan bersyarat if (Avx2.IsSupported) { } . Kode ini hampir identik dengan kode untuk Sse2 , tetapi berkaitan dengan Vector256<T> (32-byte) dan memproses lebih banyak elemen dalam satu iterasi dari loop.


Dengan demikian, menggunakan fungsi bawaan yang baru, kode dapat ditulis ulang sebagai berikut:


 public int SumVectorized(ReadOnlySpan<int> source) { if (Sse2.IsSupported) { return SumVectorizedSse2(source); } else { return SumVectorT(source); } } public unsafe int SumVectorizedSse2(ReadOnlySpan<int> source) { int result; fixed (int* pSource = source) { Vector128<int> vresult = Vector128<int>.Zero; int i = 0; int lastBlockIndex = source.Length - (source.Length % 4); while (i < lastBlockIndex) { vresult = Sse2.Add(vresult, Sse2.LoadVector128(pSource + i)); i += 4; } if (Ssse3.IsSupported) { vresult = Ssse3.HorizontalAdd(vresult, vresult); vresult = Ssse3.HorizontalAdd(vresult, vresult); } else { vresult = Sse2.Add(vresult, Sse2.Shuffle(vresult, 0x4E)); vresult = Sse2.Add(vresult, Sse2.Shuffle(vresult, 0xB1)); } result = vresult.ToScalar(); while (i < source.Length) { result += pSource[i]; i += 1; } } return result; } 

Kode ini, sekali lagi, sedikit lebih rumit, tetapi secara signifikan lebih cepat untuk semua orang kecuali set input terkecil. Untuk 32 ribu elemen, kode ini mengeksekusi 75% lebih cepat dari siklus yang diperluas, dan 81% lebih cepat dari kode sumber contoh.


Anda perhatikan bahwa kami menulis beberapa cek yang IsSupported . Yang pertama memeriksa apakah perangkat keras saat ini mendukung sekumpulan fungsi bawaan yang diperlukan, jika tidak, maka optimasi dilakukan melalui kombinasi sapuan dan Vector<T> . Opsi terakhir akan dipilih untuk platform seperti ARM / ARM64 yang tidak mendukung set instruksi yang diperlukan, atau jika set telah dinonaktifkan untuk platform. Tes IsSupported kedua, dalam metode SumVectorizedSse2 , digunakan untuk optimasi tambahan jika perangkat keras mendukung Ssse3 instruksi Ssse3 .


Jika tidak, sebagian besar logikanya pada dasarnya sama dengan loop yang diperluas. Vector128<T> adalah tipe 128-bit yang mengandung Vector128<T>.Count . Dalam hal ini, uint , yang itu sendiri adalah 32-bit, dapat memiliki 4 (128/32) elemen, ini adalah bagaimana kami meluncurkan loop.


MetodeHitungBerartiKesalahanStddev
Dirangkum14,555 ns0,0192 ns0,0179 ns
Dirangkum24,848 ns0,0147 ns0,0137 ns
Dirangkum45,381 ns0,0210 ns0,0186 ns
Dirangkum84,838 ns0,0209 ns0,0186 ns
Dirangkum165.107 ns0,0175 ns0,0146 ns
Dirangkum325,646 ns0,0230 ns0,0204 ns
Dirangkum646,763 ns0,0338 ns0,0316 ns
Dirangkum1289.308 ns0,1041 ns0,0870 ns
Dirangkum25615.634 ns0,0927 ns0,0821 ns
Dirangkum51234,706 ns0,2851 ns0,2381 ns
Dirangkum102468.110 ns0,4016 ns0,3756 ns
Dirangkum2048136.533 ns1.3104 ns1.2257 ns
Dirangkum4096277.930 ns0,5913 ns0,5531 ns
Dirangkum8192554.720 ns3,5133 ns3,2864 ns
Dirangkum163841 110.730 ns3,3043 ns3.0909 ns
Dirangkum327682 200,996 ns21.0538 ns19.6938 ns


Kesimpulan


Fungsi bawaan yang baru memberi Anda kesempatan untuk memanfaatkan fungsionalitas khusus perangkat keras dari mesin tempat Anda menjalankan kode. Ada sekitar 1.500 API untuk X86 dan X64 yang didistribusikan lebih dari 15 set, ada terlalu banyak untuk dijelaskan dalam satu artikel. Dengan membuat profil kode untuk mengidentifikasi kemacetan, Anda dapat menentukan bagian dari kode yang mendapat manfaat dari vektorisasi dan mengamati peningkatan kinerja yang cukup baik. Ada banyak skenario di mana vektorisasi dapat diterapkan dan loop unfolding hanyalah awal.


Siapa pun yang ingin melihat lebih banyak contoh dapat mencari penggunaan fungsi bawaan dalam kerangka kerja (lihat dotnet dan aspnet ), atau di artikel komunitas lainnya. Dan meskipun WF saat ini sangat luas, masih ada banyak fungsi yang perlu diperkenalkan. Jika Anda memiliki fungsi yang ingin Anda perkenalkan, jangan ragu untuk mendaftarkan permintaan API Anda melalui dotnet / corefx di GitHub . Proses peninjauan API dijelaskan di sini dan ada contoh yang bagus dari templat permintaan API yang ditentukan pada langkah 1.


Terima kasih khusus


Saya ingin menyampaikan terima kasih khusus kepada anggota komunitas kami Fei Peng (@fiigii) dan Jacek Blaszczynski (@ 4creators) atas bantuan mereka dalam mengimplementasikan WF , serta kepada semua anggota komunitas atas umpan balik yang berharga mengenai pengembangan, implementasi dan kemudahan penggunaan fungsi ini.




Kata penutup untuk terjemahan


Saya suka mengamati perkembangan platform .NET, dan, khususnya, bahasa C #. Berasal dari dunia C ++, dan memiliki sedikit pengalaman berkembang di Delphi dan Java, saya sangat nyaman memulai program menulis dalam C #. Pada tahun 2006, bahasa pemrograman ini (yaitu bahasa itu) bagi saya lebih ringkas dan praktis daripada Jawa dalam dunia pengumpulan sampah yang dikelola dan lintas-platform. Karena itu, pilihan saya jatuh pada C #, dan saya tidak menyesalinya. Tahap pertama dalam evolusi suatu bahasa hanyalah penampilannya. Pada tahun 2006, C # menyerap semua yang terbaik pada waktu itu dalam bahasa dan platform terbaik: C ++ / Java / Delphi. Pada 2010, F # go public. Itu adalah platform eksperimental untuk mempelajari paradigma fungsional dengan tujuan memperkenalkannya ke dunia .NET. Hasil percobaan adalah tahap berikutnya dalam evolusi C # - perluasan kemampuannya terhadap FP, melalui pengenalan fungsi anonim, ekspresi lambda, dan, pada akhirnya, LINQ. Perpanjangan bahasa ini menjadikan C # yang paling canggih, dari sudut pandang saya, bahasa tujuan umum. Langkah evolusi selanjutnya terkait dengan mendukung konkurensi dan asinkron. Tugas / Tugas <T>, seluruh konsep TPL, pengembangan LINQ - PLINQ, dan, akhirnya, async / menunggu. , - , .NET C# — . Span<T> Memory<T>, ValueTask/ValueTask<T>, IAsyncDispose, ref readonly struct in, foreach, IO.Streams. GC . , — . , .NET C#, , . ( ) .

Source: https://habr.com/ru/post/id467663/


All Articles