Ikhtisar kecil SIMD di .NET / C #

Perhatian Anda diundang ke ikhtisar kecil tentang kapabilitas vektorisasi algoritma di .NET Framework dan .NETCORE. Tujuan artikel ini adalah untuk memperkenalkan teknik-teknik ini kepada mereka yang tidak mengetahuinya sama sekali dan untuk menunjukkan bahwa .NET tidak ketinggalan jauh di belakang bahasa "nyata, dikompilasi" untuk penduduk asli
pengembangan.


Saya baru mulai mempelajari teknik-teknik vektorisasi, jadi jika seseorang dari komunitas mengarahkan saya ke cant eksplisit, atau menawarkan versi peningkatan dari algoritma yang dijelaskan di bawah ini, saya akan sangat senang.


Sedikit sejarah


Di .NET, SIMD pertama kali muncul pada 2015 dengan dirilisnya .NET Framework 4.6. Kemudian jenis Matrix3x2, Matrix4x4, Plane, Quaternion, Vector2, Vector3, dan Vector4 ditambahkan, yang memungkinkan pembangunan perhitungan vektor. Kemudian, tipe Vector <T> ditambahkan, yang memberikan lebih banyak peluang untuk algoritma vektorisasi. Tetapi banyak programmer masih tidak senang karena tipe-tipe di atas membatasi aliran pikiran programmer dan tidak memungkinkan kekuatan penuh dari instruksi SIMD dari prosesor modern untuk digunakan. Sudah saat ini, dalam .NET Core 3.0 Preview, System.Runtime.Names intrinsik telah muncul, yang memberikan kebebasan yang jauh lebih besar dalam memilih instruksi. Untuk mendapatkan hasil terbaik dalam kecepatan, Anda harus menggunakan RyuJit dan Anda harus membangun di bawah x64 atau menonaktifkan Prefer 32-bit dan membangun di bawah AnyCPU. Semua tolok ukur yang saya jalankan di komputer dengan prosesor Intel Core i7-6700 3.40GHz (Skylake).


Ringkas elemen-elemen dari array


Saya memutuskan untuk memulai dengan masalah klasik, yang sering ditulis pertama kali tentang vektorisasi. Ini adalah tugas untuk menemukan jumlah elemen array. Kami akan menulis empat implementasi dari tugas ini, kami akan merangkum elemen-elemen dari array Array:


Paling jelas


public int Naive() { int result = 0; foreach (int i in Array) { result += i; } return result; } 

Menggunakan LINQ


 public long LINQ() => Array.Aggregate<int, long>(0, (current, i) => current + i); 

Menggunakan vektor dari System.Numerics:


 public int Vectors() { int vectorSize = Vector<int>.Count; var accVector = Vector<int>.Zero; int i; var array = Array; for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); accVector = Vector.Add(accVector, v); } int result = Vector.Dot(accVector, Vector<int>.One); for (; i < array.Length; i++) { result += array[i]; } return result; } 

Menggunakan kode dari ruang System.Runtime.Intrinsics:


 public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); accVector = Avx2.Add(accVector, v); } } int result = 0; var temp = stackalloc int[vectorSize]; Avx2.Store(temp, accVector); for (int j = 0; j < vectorSize; j++) { result += temp[j]; } for (; i < array.Length; i++) { result += array[i]; } return result; } 

Saya meluncurkan patokan pada 4 metode ini di komputer saya dan mendapatkan hasil ini:


MetodeItemCountMedian
Naif1075,12 ns
LINQ101 186,85 ns
Vektor1060,09 ns
Intrinsik10255.40 ns
Naif100360,56 ns
LINQ1002 719.24 ns
Vektor10060,09 ns
Intrinsik100345,54 ns
Naif10001 847,88 ns
LINQ100012 033.78 ns
Vektor1000240,38 ns
Intrinsik1000630,98 ns
Naif10.00018 403,72 ns
LINQ10.000102 489,96 ns
Vektor10.0007 316.42 ns
Intrinsik10.0003 365.25 ns
Naif100.000176 630,67 ns
LINQ100.000975 998,24 ns
Vektor100.00078 828.03 ns
Intrinsik100.00041 269,41 ns

Dapat dilihat bahwa solusi dengan Vektor dan Intrinsik jauh lebih cepat daripada solusi yang jelas dan dengan LINQ. Sekarang kita perlu mencari tahu apa yang terjadi dalam kedua metode ini.


Pertimbangkan metode Vektor secara lebih rinci:


Vektor
 public int Vectors() { int vectorSize = Vector<int>.Count; var accVector = Vector<int>.Zero; int i; var array = Array; for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); accVector = Vector.Add(accVector, v); } int result = Vector.Dot(accVector, Vector<int>.One); for (; i < array.Length; i++) { result += array[i]; } return result; } 

  • int vectorSize = Vektor <int> .Count; - ini adalah berapa banyak angka 4 byte yang dapat kita masukkan ke dalam vektor. Jika akselerasi perangkat keras digunakan, nilai ini menunjukkan berapa banyak angka 4-byte dapat ditempatkan dalam satu register SIMD. Bahkan, ini menunjukkan berapa banyak elemen jenis ini dapat dioperasikan secara paralel;
  • accVector - vektor di mana hasil fungsi akan terakumulasi;
    var v = Vector baru <int> (array, i); - data dimuat ke dalam vektor v baru, dari array, mulai dari indeks i. Data vectorSize akan memuat.
  • accVector = Vector.Add (accVector, v); - dua vektor ditambahkan.
    Misalnya, 8 angka disimpan dalam Array: {0, 1, 2, 3, 4, 5, 6, 7} dan vectorSize == 4, lalu:
    Dalam iterasi pertama dari loop accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3}, setelah penambahan dalam accVector akan menjadi: {0, 0, 0, 0} + {0, 1, 2 , 3} = {0, 1, 2, 3}.
    Dalam iterasi kedua, v = {4, 5, 6, 7} dan setelah penambahan accVector = {0, 1, 2, 3} + {4, 5, 6, 7} = {4, 6, 8, 10}.
  • Tetap hanya untuk entah bagaimana mendapatkan jumlah semua elemen vektor, untuk ini kita dapat menerapkan perkalian skalar dengan vektor yang diisi dengan unit: int result = Vector.Dot (accVector, Vector <int> .One);
    Kemudian hasilnya: {4, 6, 8, 10} {1, 1, 1, 1} = 4 1 + 6 1 + 8 1 + 10 * 1 = 28.
  • Pada akhirnya, jika diperlukan, maka angka ditambahkan yang tidak sesuai dengan vektor terakhir.

Jika Anda melihat kode metode Intrinsics:


Intrinsik
 public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); accVector = Avx2.Add(accVector, v); } } int result = 0; var temp = stackalloc int[vectorSize]; Avx2.Store(temp, accVector); for (int j = 0; j < vectorSize; j++) { result += temp[j]; } for (; i < array.Length; i++) { result += array[i]; } return result; } 

Anda dapat melihat bahwa ini sangat mirip dengan Vektor dengan beberapa pengecualian:


  • vectorSize diberikan oleh konstanta. Ini karena instruksi Avx2 yang beroperasi pada register 256 bit secara eksplisit digunakan dalam metode ini. Dalam aplikasi nyata, harus ada pemeriksaan untuk melihat apakah prosesor Avx2 saat ini mendukung instruksi dan, jika tidak, panggil kode lain. Itu terlihat seperti ini:
     if (Avx2.IsSupported) { DoThingsForAvx2(); } else if (Avx.IsSupported) { DoThingsForAvx(); } ... else if (Sse2.IsSupported) { DoThingsForSse2(); } ... 
  • var accVector = Vector256 <int> .Zero; accVector dinyatakan sebagai vektor 256 bit yang diisi dengan nol.
  • fixed (int * ptr = Array) - pointer ke array dimasukkan dalam ptr.
  • Kemudian operasi yang sama seperti di Vektor: memuat data ke dalam vektor dan menambahkan dua vektor.
  • Untuk meringkas elemen-elemen vektor, metode berikut ini diterapkan:
    • sebuah array dibuat di stack: var temp = stackalloc int [vectorSize];
    • vektor dimuat ke dalam array ini: Avx2.Store (temp, accVector);
    • dalam satu lingkaran elemen-elemen array dijumlahkan.
  • lalu elemen-elemen array yang tidak ditempatkan di vektor terakhir ditambahkan

Bandingkan dua array


Perlu untuk membandingkan dua array byte. Sebenarnya ini adalah masalah karena itu saya mulai belajar SIMD di .NET. Sekali lagi, kami akan menulis beberapa metode untuk benchmark, kami akan membandingkan dua array: ArrayA dan ArrayB:


Solusi paling jelas:


 public bool Naive() { for (int i = 0; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } 

Solusi melalui LINQ:


 public bool LINQ() => ArrayA.SequenceEqual(ArrayB); 

Solusi melalui fungsi MemCmp:


 [DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)] static extern int memcmp(byte[] b1, byte[] b2, long count); public bool MemCmp() => memcmp(ArrayA, ArrayB, ArrayA.Length) == 0; 

Menggunakan vektor dari System.Numerics:


 public bool Vectors() { int vectorSize = Vector<byte>.Count; int i = 0; for (; i < ArrayA.Length - vectorSize; i += vectorSize) { var va = new Vector<byte>(ArrayA, i); var vb = new Vector<byte>(ArrayB, i); if (!Vector.EqualsAll(va, vb)) { return false; } } for (; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } 

Menggunakan Intrinsik:


 public unsafe bool Intrinsics() { int vectorSize = 256 / 8; int i = 0; const int equalsMask = unchecked((int) (0b1111_1111_1111_1111_1111_1111_1111_1111)); fixed (byte* ptrA = ArrayA) fixed (byte* ptrB = ArrayB) { for (; i < ArrayA.Length - vectorSize; i += vectorSize) { var va = Avx2.LoadVector256(ptrA + i); var vb = Avx2.LoadVector256(ptrB + i); var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; } } for (; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } } 

Hasil patokan di komputer saya:


MetodeItemCountMedian
Naif10.00066 719.1 ns
LINQ10.00071 211.1 ns
Vektor10.0003 695.8 ns
Memcmp10.000600,9 ns
Intrinsik10.0001 607,5 ns
Naif100.000588 633,7 ns
LINQ100.000651 191,3 ns
Vektor100.00034 659.1 ns
Memcmp100.0005 513,6 ns
Intrinsik100.00012,078.9 ns
Naif1.000.0005 637 293.1 ns
LINQ1.000.0006 622 666.0 ns
Vektor1.000.000777 974.2 ns
Memcmp1.000.000361 704,5 ns
Intrinsik1.000.000434 252,7 ns

Semua kode untuk metode ini, saya pikir, jelas, dengan pengecualian dua baris dalam Intrinsics:


 var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; } 

Dalam yang pertama, dua vektor dibandingkan untuk kesetaraan dan hasilnya disimpan dalam vektor areEqual, di mana semua bit diatur ke 1 dalam elemen pada posisi tertentu jika elemen yang sesuai dalam va dan vb sama. Ternyata jika vektor dari byte va dan vb benar-benar sama, maka dalam areEquals semua elemen harus sama dengan 255 (11111111b). Karena Avx2.CompareEqual adalah pembungkus di atas _mm256_cmpeq_epi8, maka di situs web Intel Anda dapat melihat kode semu dari operasi ini:
Metode MoveMask dari vektor menghasilkan angka 32-bit. Nilai bit adalah bit tinggi dari masing-masing 32 elemen byte tunggal dari vektor. Kodesemu dapat ditemukan di sini .


Jadi, jika beberapa byte dalam va dan vb tidak cocok, maka dalam areEqual byte yang sesuai akan menjadi 0, oleh karena itu bit yang paling signifikan dari byte ini juga akan 0, yang berarti bahwa bit yang sesuai dalam respons Avx2.MoveMask juga akan 0 dan perbandingan dengan equalsMask tidak akan berfungsi.


Mari kita menganalisis contoh kecil, dengan asumsi bahwa panjang vektor adalah 8 byte (untuk menulis itu kurang):


  • Misalkan va = {100, 10, 20, 30, 100, 40, 50, 100}, dan vb = {100, 20, 10, 30, 100, 40, 80, 90};
  • Maka areEqual akan sama dengan {255, 0, 0, 255, 255, 255, 0, 0};
  • Metode MoveMask akan mengembalikan 10011100b, yang perlu dibandingkan dengan mask 11111111b, karena Karena topeng ini tidak sama, ternyata vektor va dan vb tidak sama.

Hitung berapa kali suatu elemen muncul dalam koleksi


Terkadang perlu untuk menghitung berapa kali elemen tertentu ditemukan dalam koleksi, misalnya int, algoritma ini juga dapat dipercepat. Mari kita menulis beberapa metode untuk perbandingan, kita akan mencari elemen Item dalam array Array.


Yang paling jelas:


 public int Naive() { int result = 0; foreach (int i in Array) { if (i == Item) { result++; } } return result; } 

menggunakan LINQ:


 public int LINQ() => Array.Count(i => i == Item); 

menggunakan vektor dari System.Numerics.Vectors:


 public int Vectors() { var mask = new Vector<int>(Item); int vectorSize = Vector<int>.Count; var accResult = new Vector<int>(); int i; var array = Array; for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); var areEqual = Vector.Equals(v, mask); accResult = Vector.Subtract(accResult, areEqual); } int result = 0; for (; i < array.Length; i++) { if (array[i] == Item) { result++; } } result += Vector.Dot(accResult, Vector<int>.One); return result; } 

Menggunakan Intrinsik:


 public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; //var mask = Avx2.SetAllVector256(Item); //var mask = Avx2.SetVector256(Item, Item, Item, Item, Item, Item, Item, Item); var temp = stackalloc int[vectorSize]; for (int j = 0; j < vectorSize; j++) { temp[j] = Item; } var mask = Avx2.LoadVector256(temp); var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); var areEqual = Avx2.CompareEqual(v, mask); accVector = Avx2.Subtract(accVector, areEqual); } } int result = 0; Avx2.Store(temp, accVector); for(int j = 0; j < vectorSize; j++) { result += temp[j]; } for(; i < array.Length; i++) { if (array[i] == Item) { result++; } } return result; } 

Hasil patokan di komputer saya:


MetodeItemCountMedian
Naif10002 824.41 ns
LINQ100012 138,95 ns
Vektor1000961,50 ns
Intrinsik1000691.08 ns
Naif10.00027 072.25 ns
LINQ10.000113 967.87 ns
Vektor10.0007 571,82 ns
Intrinsik10.0004,296.71 ns
Naif100.000361 028.46 ns
LINQ100.0001.091.994,28 ns
Vektor100.00082 839.29 ns
Intrinsik100.00040 307,91 ns
Naif1.000.0001 634 175,46 ns
LINQ1.000.0006 194 257,38 ns
Vektor1.000.000583 901,29 ns
Intrinsik1.000.000413 520,38 ns

Metode Vektor dan Intrinsik sepenuhnya identik dalam logika, perbedaannya hanya dalam pelaksanaan operasi tertentu. Idenya secara keseluruhan adalah:


  • vektor topeng dibuat di mana nomor yang diperlukan disimpan di setiap elemen;
  • Bagian array dimasukkan ke dalam vektor v dan dibandingkan dengan mask, maka semua bit akan diatur dalam elemen yang sama di areEqual, karena areEqual adalah vektor dari ints, maka jika Anda mengatur semua bit dari satu elemen, kita mendapatkan -1 pada elemen ini ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
  • vektor areEqual dikurangi dari accVector dan kemudian accVector akan menjadi jumlah berapa kali elemen item terjadi di semua vektor v untuk setiap posisi (minus min memberi nilai tambah).

Semua kode dari artikel dapat ditemukan di GitHub


Kesimpulan


Saya memeriksa hanya sebagian kecil dari kemungkinan yang disediakan oleh .NET untuk perhitungan vektorisasi. Untuk daftar intrinsik yang lengkap dan terkini di .NETCORE di bawah x86, Anda dapat merujuk ke kode sumber . Sangat nyaman bahwa dalam file C # dalam ringkasan masing-masing intrinsik ada namanya sendiri dari dunia C, yang menyederhanakan pemahaman tentang tujuan intrinsik ini dan terjemahan dari algoritma C ++ / C yang ada ke .NET. Dokumentasi System.Numerics.Vector tersedia di msdn .


Menurut pendapat saya, .NET memiliki keunggulan besar dibandingkan C ++, karena .NET Kompilasi JIT sudah terjadi pada mesin klien, kompiler dapat mengoptimalkan kode untuk prosesor klien tertentu, memberikan kinerja maksimum. Pada saat yang sama, seorang programmer untuk menulis kode cepat dapat tetap berada dalam kerangka satu bahasa dan teknologi.


UPD (15/9/2019):


Ada patokan di tolok ukur

Dalam benchmark, saya menggunakan IterationSetup, yang, ternyata, dapat sangat mempengaruhi kinerja benchmark yang bekerja dalam waktu kurang dari 100 ms. Jika Anda mengulanginya di GlobalSetup, maka hasilnya akan seperti ini.


Jumlah elemen array:


MetodeItemCountBerartiKesalahanStddevRasio
Naif103,531 ns0,0336 ns0,0314 ns1,00
LINQ1076.925 ns0,4166 ns0,3897 ns21.79
Vektor102,750 ns0,0210 ns0,0196 ns0,78
Intrinsik106.513 ns0,0623 ns0,0582 ns1,84
Naif10047.982 ns0,3975 ns0,3524 ns1,00
LINQ100590.414 ns3,8808 ns3.4402 ns12.31
Vektor10010.122 ns0,0747 ns0,0699 ns0,21
Intrinsik10014.277 ns0,0566 ns0,0529 ns0,30
Naif1000569.910 ns2,8297 ns2.6469 ns1,00
LINQ10005,658.570 ns31,7465 ns29.6957 ns9,93
Vektor100079.598 ns0,3498 ns0,3272 ns0,14
Intrinsik100066.970 ns0,3937 ns0,3682 ns0,12
Naif10.0005,637.571 ns37.5050 ns29.2814 ns1,00
LINQ10.00056.498.987 ns294.8776 ns275.8287 ns10.02
Vektor10.000772.900 ns2,6802 ns2,5070 ns0,14
Intrinsik10.000579.152 ns2,8371 ns2,6538 ns0,10
Naif100.00056.352.865 ns230.7916 ns215.8826 ns1,00
LINQ100.000562.610.571 ns3,775.7631 ns3,152.9332 ns9,98
Vektor100.0008,389.647 ns165,9590 ns227.1666 ns0,15
Intrinsik100.0007,261.334 ns89.6468 ns69,9903 ns0,13

Membandingkan dua array:


MetodeItemCountBerartiKesalahanStddevRasio
Naif10.0007.033.8 ns50,636 ns47.365 ns1,00
LINQ10.00064.841,4 ns289.157 ns270.478 ns9.22
Vektor10.000504.0 ns2,406 ns2,251 ns0,07
Memcmp10.000368.1 ns2,637 ns2,466 ns0,05
Intrinsik10.000283,6 ns1,135 ns1,061 ns0,04
Naif100.00085.214,4 ns903.868 ns845.478 ns1,00
LINQ100.000702.279,4 ns2,846.609 ns2,662.720 ns8.24
Vektor100.0005,179.2 ns45.337 ns42.409 ns0,06
Memcmp100.0004,510,5 ns24.292 ns22.723 ns0,05
Intrinsik100.0002,957,0 ns11.452 ns10.712 ns0,03
Naif1.000.000844,006,1 ns3,552.478 ns3,322.990 ns1,00
LINQ1.000.0006,483.079,3 ns42.641.040 ns39.886.455 ns7.68
Vektor1.000.00054.180.1 ns357.258 ns334.180 ns0,06
Memcmp1.000.00049,480.1 ns515.675 ns457.133 ns0,06
Intrinsik1.000.00036.633.9 ns680.525 ns636.564 ns0,04

Jumlah kemunculan elemen dalam array:


MetodeItemCountBerartiKesalahanStddevRasio
Naif108,844 ns0,0772 ns0,0603 ns1,00
LINQ1087.456 ns0,9496 ns0,8883 ns9,89
Vektor103,140 ns0,0406 ns0,0380 ns0,36
Intrinsik1013.813 ns0,0825 ns0,0772 ns1.56
Naif100107.310 ns0,6975 ns0,6183 ns1,00
LINQ100626.285 ns5.7677 ns5.3951 ns5.83
Vektor10011.844 ns0,2113 ns0,1873 ns0,11
Intrinsik10019.616 ns0,1018 ns0,0903 ns0,18
Naif10001.032.466 ns6.3799 ns5.6556 ns1,00
LINQ10006,266.605 ns42,6585 ns39,9028 ns6.07
Vektor100083,417 ns0,5393 ns0,4780 ns0,08
Intrinsik100088,358 ns0,4921 ns0,4603 ns0,09
Naif10.0009,942.503 ns47.9732 ns40.0598 ns1,00
LINQ10.00062.305.598 ns643.8775 ns502.6972 ns6.27
Vektor10.000914.967 ns7.2959 ns6.8246 ns0,09
Intrinsik10.000931.698 ns6.3444 ns5.9346 ns0,09
Naif100.00094.834.804 ns793.8585 ns703.7349 ns1,00
LINQ100.000626.620.968 ns4,696.9221 ns4,393.5038 ns6.61
Vektor100.0009,000.827 ns179.5351 ns192.1005 ns0,09
Intrinsik100.0008,690.771 ns101.7078 ns95.1376 ns0,09
Naif1.000.000959.302.249 ns4,268.2488 ns3,783.6914 ns1,00
LINQ1.000.0006.218.681.888 ns31.321.9277 ns29.298.5506 ns6.48
Vektor1.000.00099.778.488 ns1,975.6001 ns4,252.6877 ns0,10
Intrinsik1.000.00096.449.350 ns1,171.8067 ns978.5116 ns0,10

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


All Articles