لمحة صغيرة عن SIMD في .NET / C #

يتم توجيه انتباهك إلى نظرة عامة صغيرة على إمكانات توجيه الخوارزميات في .NET Framework و .NETCORE. الغرض من هذه المقالة هو تقديم هذه التقنيات لأولئك الذين لم يعرفوها على الإطلاق وإظهار أن .NET لا يتخلف كثيراً عن اللغات "الحقيقية المترجمة" للمواطن الأصلي.
التنمية.


لقد بدأت للتو في تعلم أساليب التحويل ، لذلك إذا وجهني شخص من المجتمع إلى حالة غير صريحة أو قدم نسخًا محسنة من الخوارزميات الموضحة أدناه ، فسوف أكون سعيدًا جدًا.


قليلا من التاريخ


في .NET ، ظهر SIMD لأول مرة في عام 2015 مع إصدار .NET Framework 4.6. ثم تمت إضافة أنواع Matrix3x2 و Matrix4x4 و Plane و Quaternion و Vector2 و Vector3 و Vector4 ، مما سمح بإنشاء حسابات متجهة. في وقت لاحق ، تمت إضافة نوع Vector <T> ، مما أتاح مزيدًا من الفرص لتوجيه الخوارزميات. لكن العديد من المبرمجين كانوا لا يزالون غير راضين عن ذلك حدت الأنواع المذكورة أعلاه من تدفق أفكار المبرمج ولم تسمح باستخدام القوة الكاملة لتعليمات SIMD للمعالجات الحديثة. بالفعل في الوقت الحاضر ، في .NET Core 3.0 Preview ، ظهرت مساحة اسم System.Runtime.Intrinsics ، والتي توفر حرية أكبر بكثير في اختيار التعليمات. للحصول على أفضل النتائج في السرعة ، تحتاج إلى استخدام RyuJit وبناء إما على x64 أو تعطيل Prefer 32 بت والبناء على AnyCPU. جميع المعايير التي قمت بتشغيلها على جهاز كمبيوتر مع معالج Intel Core i7-6700 3.40 جيجاهرتز (Skylake).


تلخيص عناصر الصفيف


قررت أن أبدأ بالمشكلة الكلاسيكية ، والتي تتم كتابتها غالبًا عندما يتعلق الأمر بالتوجه. هذه هي مهمة العثور على مجموع عناصر الصفيف. سنكتب أربعة تطبيقات لهذه المهمة ، وسوف نلخص عناصر مجموعة Array:


الأكثر وضوحا


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

باستخدام LINQ


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

باستخدام المتجهات من 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; } 

باستخدام التعليمات البرمجية من مساحة 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; } 

أطلقت معيارًا عن هذه الطرق الأربع على جهاز الكمبيوتر الخاص بي وحصلت على هذه النتيجة:


الطريقةItemsCountمتوسط
ساذج1075.12 ن
LINQ101 186.85 ن
المتجهات1060.09 ن
الجوهرية10255.40 ن
ساذج100360.56 ن
LINQ1002 719.24 ن
المتجهات10060.09 ن
الجوهرية100345.54 ن
ساذج10001 847.88 ن
LINQ100012 033.78 ن
المتجهات1000240.38 ن
الجوهرية1000630.98 ن
ساذج1000018 403.72 ن
LINQ10000102 489.96 ن
المتجهات100007 316.42 ن
الجوهرية100003 365.25 ن
ساذج100000176 630.67 ns
LINQ100000975 998.24 ns
المتجهات10000078 828.03 ن
الجوهرية10000041 269.41 ن

يمكن أن نرى أن الحلول مع Vectors و Intrinsics هي أسرع بكثير من الحل الواضح ومع LINQ. الآن نحن بحاجة إلى معرفة ما يحدث في هاتين الطريقتين.


النظر في طريقة المتجهات بمزيد من التفاصيل:


المتجهات
 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 = Vector <int> .Count؛ - هذا هو عدد الأرقام 4 بايت يمكننا وضعه في ناقل. إذا تم استخدام تسريع الأجهزة ، فإن هذه القيمة توضح عدد الأرقام التي يمكن وضعها في سجل SIMD بأربع أرقام. في الحقيقة ، يُظهر عدد عناصر هذا النوع التي يمكن تشغيلها بالتوازي ؛
  • accVector - متجه تتراكم فيه نتيجة الوظيفة ؛
    var v = ناقل جديد <int> (array، i)؛ - يتم تحميل البيانات في متجه v جديد ، من الصفيف ، بدءًا من الفهرس i. سيتم تحميل بيانات vectorSize بالضبط.
  • accVector = Vector.Add (accVector، v)؛ - يتم إضافة متجهين.
    على سبيل المثال ، يتم تخزين أرقام الصفيف 8: {0 ، 1 ، 2 ، 3 ، 4 ، 5 ، 6 ، 7} و vectorSize == 4 ، ثم:
    في التكرار الأول من accVector حلقة = {0 ، 0 ، 0 ، 0} ، v = {0 ، 1 ، 2 ، 3} ، بعد الإضافة في accVector ، ستكون: {0 ، 0 ، 0 ، 0} + {0 ، 1 ، 2 ، 3} = {0 ، 1 ، 2 ، 3}.
    في التكرار الثاني ، v = {4 ، 5 ، 6 ، 7} وبعد الإضافة accVector = {0 ، 1 ، 2 ، 3} + {4 ، 5 ، 6 ، 7} = {4 ، 6 ، 8 ، 10}.
  • يبقى فقط للحصول على مجموع جميع عناصر المتجه بطريقة أو بأخرى ، لذلك يمكننا تطبيق الضرب العددي بواسطة متجه مليء بوحدات: int result = Vector.Dot (accVector، Vector <int> .One)؛
    ثم اتضح: {4 ، 6 ، 8 ، 10} {1 ، 1 ، 1 ، 1} = 4 1 + 6 1 + 8 1 + 10 * 1 = 28.
  • في النهاية ، إذا لزم الأمر ، تتم إضافة الأرقام التي لا تتناسب مع المتجه الأخير.

إذا نظرت إلى رمز أسلوب 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; } 

يمكنك أن ترى أنه يشبه إلى حد كبير المتجهات مع بعض الاستثناءات:


  • يتم إعطاء vectorSize بواسطة ثابت. هذا لأنه يتم استخدام إرشادات Avx2 التي تعمل على سجلات 256 بت بشكل صريح في هذه الطريقة. في التطبيق الحقيقي ، يجب أن يكون هناك فحص لمعرفة ما إذا كان معالج Avx2 الحالي يدعم التعليمات ، وإذا لم يكن كذلك ، فاتصل برمز آخر. يبدو شيء مثل هذا:
     if (Avx2.IsSupported) { DoThingsForAvx2(); } else if (Avx.IsSupported) { DoThingsForAvx(); } ... else if (Sse2.IsSupported) { DoThingsForSse2(); } ... 
  • var accVector = Vector256 <int> .Zero؛ تم إعلان accVector على أنه ناقل 256 بت مليء بالأصفار.
  • ثابت (int * ptr = Array) - يتم إدخال مؤشر إلى صفيف في ptr.
  • ثم نفس العمليات كما في Vectors: تحميل البيانات في متجه وإضافة متجهين.
  • لتلخيص عناصر المتجه تم تطبيق الطريقة التالية:
    • يتم إنشاء صفيف على المكدس: var temp = stackalloc int [vectorSize]؛
    • يتم تحميل المتجه إلى هذا الصفيف: Avx2.Store (temp، accVector)؛
    • في حلقة يتم تلخيص عناصر الصفيف.
  • ثم يتم إضافة عناصر الصفيف التي لم يتم وضعها في المتجه الأخير

قارن بين صفيفين


من الضروري مقارنة صفيفين من البايتات. في الواقع هذه هي المشكلة التي بسببها بدأت في تعلم SIMD في .NET. مرة أخرى ، سوف نكتب عدة طرق للمعيار ، وسنقوم بمقارنة مجموعتين: ArrayA و ArrayB:


الحل الأكثر وضوحا:


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

الحل عبر LINQ:


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

الحل عبر وظيفة 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; 

باستخدام المتجهات من 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; } 

باستخدام الجوهرية:


 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; } } 

نتيجة الاختبار على جهاز الكمبيوتر الخاص بي:


الطريقةItemsCountمتوسط
ساذج1000066 719.1 ن
LINQ1000071 211.1 ن
المتجهات100003 695.8 ن
Memcmp10000600.9 ن
الجوهرية100001 607.5 ن
ساذج100000588 633.7 ن
LINQ100000651 191.3 ن
المتجهات10000034 659.1 ن
Memcmp1000005 513.6 ن
الجوهرية10000012078.9 ن
ساذج1،000،0005 637 293.1 ن
LINQ1،000،0006 622 666.0 ن
المتجهات1،000،000777 974.2 ن
Memcmp1،000،000361 704.5 ن
الجوهرية1،000،000434 252.7 ن

أعتقد أن كل الشفرة الخاصة بهذه الطرق مفهومة ، باستثناء سطرين في Intrinsics:


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

في الأول ، يتم مقارنة متجهين من أجل المساواة ويتم تخزين النتيجة في متجه متساوي ، حيث يتم تعيين كل البتات على 1 في عنصر في موضع معين إذا كانت العناصر المقابلة في va و vb متساوية. اتضح أنه إذا كانت المتجهات من البايتة va و vb متساوية تمامًا ، فعندئذٍ تكون جميع العناصر مساوية لـ 255 (11111111b). بسبب Avx2.CompareEqual عبارة عن غلاف يحتوي على _mm256_cmpeq_epi8 ، ثم على موقع Intel على الويب يمكنك رؤية الكود الزائف لهذه العملية:
أسلوب MoveMask من ناقل يجعل رقم 32 بت. قيم البتات هي البتات العالية لكل عنصر من عناصر البايت البالغ عددها 32 عنصرًا في المتجه. يمكن العثور على الكود الكاذب هنا .


وبالتالي ، إذا لم تتطابق بعض البايتات في va و vb ، فعندها تكون البايتات المقابلة هي 0 ، وبالتالي فإن البتات الأكثر أهمية في هذه البايتات ستكون 0 أيضًا ، مما يعني أن البتات المقابلة في استجابة Avx2.MoveMask ستكون أيضًا 0 وستكون المقارنة أيضًا 0 مع equalsMask لن تعمل.


دعنا نحلل مثالًا صغيرًا ، على افتراض أن طول الموجه 8 بايت (لكتابته كان أقل):


  • اسمحوا va = {100 ، 10 ، 20 ، 30 ، 100 ، 40 ، 50 ، 100} ، و vb = {100 ، 20 ، 10 ، 30 ، 100 ، 40 ، 80 ، 90} ؛
  • ثم تكون Equal مساوية لـ {255، 0، 0، 255، 255، 255، 0، 0}؛
  • ستُرجع طريقة MoveMask 10011100b ، والتي ستحتاج إلى مقارنة مع القناع 11111111b ، لأن نظرًا لأن هذه الأقنعة غير متساوية ، فقد تبين أن المتجهات va و vb غير متساوية.

حساب عدد مرات حدوث عنصر في المجموعة


في بعض الأحيان يكون من الضروري حساب عدد المرات التي يتم فيها العثور على عنصر معين في مجموعة ما ، على سبيل المثال ، ints ، يمكن أيضًا تسريع هذه الخوارزمية. دعنا نكتب بعض الطرق للمقارنة ، سنبحث عن عنصر العنصر في صفيف الصفيف.


الأكثر وضوحا:


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

باستخدام LINQ:


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

باستخدام المتجهات من 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; } 

باستخدام الجوهرية:


 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; } 

نتيجة الاختبار على جهاز الكمبيوتر الخاص بي:


الطريقةItemsCountمتوسط
ساذج10002 824.41 ن
LINQ100012 138.95 ن
المتجهات1000961.50 ن
الجوهرية1000691.08 ن
ساذج1000027 072.25 ن
LINQ10000113 967.87 ns
المتجهات100007 571.82 ن
الجوهرية100004،296.71 ن
ساذج100000361 028.46 ن
LINQ1000001،091،994.28 ن
المتجهات10000082 839.29 ns
الجوهرية10000040 307.91 ن
ساذج1،000،0001 634 175.46 ns
LINQ1،000،0006 194 257.38 ns
المتجهات1،000،000583 901.29 ns
الجوهرية1،000،000413 520.38 ن

تتشابه طرق المتجهات والأصالة في المنطق تمامًا ، والاختلافات هي فقط في تنفيذ عمليات محددة. الفكرة ككل هي:


  • يتم إنشاء ناقل متجه يتم فيه تخزين العدد المطلوب في كل عنصر ؛
  • يتم تحميل جزء من المصفوفة في المتجه v ومقارنته مع القناع ، ثم يتم تعيين جميع البتات في عناصر متساوية في Equal ، لأن areEqual عبارة عن متجه من ints ، ثم إذا قمت بتعيين جميع وحدات بت عنصر واحد ، فسنحصل على -1 في هذا العنصر ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1) ؛
  • the vector areEqual يتم طرحها من accVector وبعد ذلك سيكون accVector هو مجموع عدد المرات التي حدث فيها عنصر العنصر في جميع المتجهات v لكل موقف (ناقص min يعطي زائد).

يمكن العثور على جميع التعليمات البرمجية من المقال على جيثب


الخاتمة


لقد درست جزءًا صغيرًا جدًا من الاحتمالات التي يوفرها .NET لحسابات البيانات. للحصول على قائمة كاملة وحديثة من العناصر الداخلية المتاحة في .NETCORE تحت x86 ، يمكنك الرجوع إلى التعليمات البرمجية المصدر . من المريح أنه في ملفات C # في ملخص كل مضمن ، يوجد اسم خاص به من عالم C ، مما يبسط فهم الغرض من هذا المضمون وترجمة خوارزميات C ++ / C الحالية إلى .NET. وثائق System.Numerics.Vector متاحة في msdn .


في رأيي ، .NET لديه ميزة كبيرة على C ++ ، لأنه يتم تجميع JIT بالفعل على جهاز العميل ، ثم يمكن للمترجم تحسين الكود لمعالج عميل معين ، مما يوفر أقصى أداء. في الوقت نفسه ، يمكن أن يظل مبرمج لكتابة التعليمات البرمجية السريعة في إطار لغة واحدة والتكنولوجيا.


محدث (09/15/2019):


كان هناك عضادة في المعايير

في المقاييس ، استخدمت IterationSetup ، والتي ، كما اتضح فيما بعد ، يمكن أن تؤثر بشكل كبير على أداء المعايير التي تنجح في أقل من 100 مللي ثانية. إذا أعدتها على GlobalSetup ، فستكون النتائج هكذا.


مجموع عناصر الصفيف:


الطريقةItemsCountيعنيخطأStddevالنسبة
ساذج103.531 ن0.0336 ن0.0314 ن1.00
LINQ1076.925 ن0.4166 ن0.3897 ن21.79
المتجهات102.750 ن0.0210 ن0.0196 ن0.78
الجوهرية106.513 ن0.0623 ن0.0582 ن1.84
ساذج10047.982 ن0.3975 ن0.3524 ن1.00
LINQ100590.414 ن3.8808 ن3.4402 ن12.31
المتجهات10010.122 ن0.0747 ن0.0699 ن0.21
الجوهرية10014.277 ن0.0566 ن0.0529 ن0.30
ساذج1000569.910 ن2.8297 ن2.6469 ن1.00
LINQ10005658.570 ن31.7465 ن29.6957 ن9.93
المتجهات100079.598 ن0.3498 ن0.3272 ن0.14
الجوهرية100066.970 ن0.3937 ن0.3682 ن0.12
ساذج100005637.571 ن37.5050 ن29.2814 ن1.00
LINQ100005649887 ن294.8776 ن275.8287 ن10.02
المتجهات10000772.900 ن2.6802 ن2.5070 ن0.14
الجوهرية10000579.152 ن2.8371 ن2.6538 ن0.10
ساذج10000056352.865 ن230.7916 ن215.8826 ن1.00
LINQ10000056210.571 ن3775.7631 ن3،152.9332 ن9.98
المتجهات1000008،389.647 ن165.9590 ن227.1666 ن0.15
الجوهرية1000007،261.334 ن89.6468 ن69.9903 ن0.13

مقارنة مجموعتين:


الطريقةItemsCountيعنيخطأStddevالنسبة
ساذج100007033.8 ن50.636 ن47.365 ن1.00
LINQ1000064841.4 ن289.157 ن270.478 ن9.22
المتجهات10000504.0 ن2.406 ن2.251 ن0.07
Memcmp10000368.1 ن2.637 ن2.466 ن0.05
الجوهرية10000283.6 ن1.135 ن1.061 ن0.04
ساذج10000085214.4 ن903.868 ن845.478 ن1.00
LINQ100000702،279.4 ن2846.609 ن2662.720 ن8.24
المتجهات1000005،179.2 ن45.337 ن42.409 ن0.06
Memcmp1000004510.5 ن24.292 ن22.723 ن0.05
الجوهرية100000297.0 نانوثانية11.452 ن10.712 ن0.03
ساذج1،000،000844،006.1 ن352.478 ن3232.990 ن1.00
LINQ1،000،0006،483،079.3 ن4264.040 ن39886.455 ن7.68
المتجهات1،000،00054180.1 ن357.258 ن334.180 ن0.06
Memcmp1،000،00049.480.1 ن515.675 ن457.133 ن0.06
الجوهرية1،000،00036،633.9 ن680.525 ن636.564 ن0.04

عدد تكرارات عنصر في صفيف


الطريقةItemsCountيعنيخطأStddevالنسبة
ساذج108.844 ن0.0772 ن0.0603 ن1.00
LINQ1087.456 ن0.9496 ن0.8883 ن9.89
المتجهات103.140 ن0.0406 ن0.0380 ن0.36
الجوهرية1013.813 ن0.0825 ن0.0772 ن1.56
ساذج100107.310 ن0.6975 ن0.6183 ن1.00
LINQ100626.285 ن5.7677 ن5.3951 ن5.83
المتجهات10011.844 ن0.2113 ن0.1873 ن0.11
الجوهرية10019.616 ن0.1018 ن0.0903 ن0.18
ساذج10001،032.466 ن6.3799 ن5.6556 ن1.00
LINQ10006266.605 ن42.68585 ن39.9028 ن6.07
المتجهات100083.417 ن0.5393 ن0.4780 ن0.08
الجوهرية100088.358 ن0.4921 ن0.4603 ن0.09
ساذج100009،942.503 ن47.9732 ن40.0598 ن1.00
LINQ1000062305.598 ن643.8775 ن502.6972 ن6.27
المتجهات10000914.967 ن7.2959 ن6.8246 ن0.09
الجوهرية10000931.698 ن6.3444 ن5.9346 ن0.09
ساذج10000094،834.804 ن793.8585 ن703.7349 ن1.00
LINQ100000626،620.968 ن4669.9221 ن4،393.5038 ن6.61
المتجهات1000009000.827 ن179.5351 ن192.1005 ن0.09
الجوهرية1000008690.771 ن101.7078 ن95.1376 ن0.09
ساذج1،000،000959302.249 ن4،268.2488 ن3،783.6914 ن1.00
LINQ1،000،0006،218،681.888 ن31321.9277 ن29298.5506 ن6.48
المتجهات1،000،00099778.488 ن1975.6001 ن4،252.6877 ن0.10
الجوهرية1،000،00096،449.350 ن1117.8067 ن978.5116 ن0.10

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


All Articles