علمت أنه على أجهزة الكمبيوتر الجديدة ، أصبحت بعض اختبارات الانحدار أبطأ. شيء شائع ، يحدث. تكوين غير صحيح في مكان ما في Windows أو ليس أفضل القيم في BIOS. ولكن هذه المرة لم نتمكن من العثور على نفس الإعداد "ترسيتها". نظرًا لأن التغيير مهم: 9 مقابل 19 ثانية (على الرسم البياني ، الأزرق هو الحديد القديم والبرتقالي هو الجديد) ، كان علي أن أتعمق أكثر.

نفس نظام التشغيل ، نفس الجهاز ، معالج مختلف: أبطأ مرتين
يمكن بالتأكيد أن يسمى انخفاض الأداء من 9.1 إلى 19.6 ثانية كبيرًا. أجرينا فحوصات إضافية مع تغيير في إصدارات البرامج المختبرة وإعدادات Windows و BIOS. ولكن لا ، لم تتغير النتيجة. ظهر الاختلاف الوحيد فقط على المعالجات المختلفة. أدناه هي النتيجة على أحدث وحدة المعالجة المركزية.

وهنا هو الذي يستخدم للمقارنة.

يعمل Xeon Gold على بنية مختلفة تسمى Skylake ، شائعة لمعالجات Intel الجديدة منذ منتصف عام 2017. إذا قمت بشراء أحدث الأجهزة ، فستحصل على معالج بهندسة Skylake. هذه سيارات جيدة ، ولكن ، كما أظهرت الاختبارات ، فإن الجدة والسرعة ليسا الشيء نفسه.
إذا لم يكن هناك شيء آخر يساعدك ، فأنت بحاجة إلى استخدام ملف التعريف لإجراء بحث متعمق. دعونا نختبر على المعدات القديمة والجديدة ونحصل على شيء مثل هذا:

تعرض علامة التبويب في Windows Performance Analyzer (WPA) في الجدول الفرق بين التتبع 2 (11 ثانية) والتتبع 1 (19 ثانية). يقابل الفرق السلبي في الجدول زيادة في استهلاك وحدة المعالجة المركزية في اختبار أبطأ. إذا نظرت إلى الاختلافات الأكثر أهمية في استهلاك وحدة المعالجة المركزية ، فسترى
AwareLock :: Contention و
JIT_MonEnterWorker_InlineGetThread_GetThread_PatchLabel و
ThreadNative.SpinWait . كل شيء يشير إلى "دوران" في وحدة المعالجة المركزية [الغزل - محاولة دورية للحصول على قفل تقريبًا. في.] ، عندما تكافح الخيوط للحظر. ولكن هذه علامة خاطئة ، لأن الغزل ليس السبب الرئيسي لانخفاض الإنتاجية. تعني المنافسة المتزايدة على الأقفال أن شيئًا ما في برنامجنا قد تباطأ وأبقى القفل ، مما أدى إلى زيادة الغزل في وحدة المعالجة المركزية. لقد تحققت من وقت القفل والمؤشرات الرئيسية الأخرى ، مثل أداء القرص ، ولكن لم أجد أي شيء ذي معنى يمكن أن يفسر تدهور الأداء. على الرغم من أن هذا ليس منطقيًا ، لكنني عدت إلى زيادة الحمل على وحدة المعالجة المركزية بطرق مختلفة.
سيكون من المثير للاهتمام أن نجد بالضبط مكان تعطل المعالج. يحتوي WPA على أعمدة الملف # والسطر # ، ولكنها تعمل فقط مع الأحرف الخاصة ، التي لا نمتلكها ، لأن هذا هو رمز .NET Framework. ثاني أفضل شيء يمكننا القيام به هو الحصول على عنوان dll حيث توجد التعليمات المسماة Image RVA. إذا قمت بتحميل هذا دلل في المصحح والقيام به
u xxx.dll+ImageRVA
ثم يجب أن نرى التعليمات التي تحرق معظم دورات وحدة المعالجة المركزية ، لأنها ستكون العنوان "الساخن" الوحيد.

سندرس هذا العنوان باستخدام طرق Windbg المختلفة:
0:000> u clr.dll+0x19566B-10
clr!AwareLock::Contention+0x135:
00007ff8`0535565b f00f4cc6 lock cmovl eax,esi
00007ff8`0535565f 2bf0 sub esi,eax
00007ff8`05355661 eb01 jmp clr!AwareLock::Contention+0x13f (00007ff8`05355664)
00007ff8`05355663 cc int 3
00007ff8`05355664 83e801 sub eax,1
00007ff8`05355667 7405 je clr!AwareLock::Contention+0x144 (00007ff8`0535566e)
00007ff8`05355669 f390 pause
00007ff8`0535566b ebf7 jmp clr!AwareLock::Contention+0x13f (00007ff8`05355664)
ومع طرق JIT المختلفة:
0:000> u clr.dll+0x2801-10
clr!JIT_MonEnterWorker_InlineGetThread_GetThread_PatchLabel+0x124:
00007ff8`051c27f1 5e pop rsi
00007ff8`051c27f2 c3 ret
00007ff8`051c27f3 833d0679930001 cmp dword ptr [clr!g_SystemInfo+0x20 (00007ff8`05afa100)],1
00007ff8`051c27fa 7e1b jle clr!JIT_MonEnterWorker_InlineGetThread_GetThread_PatchLabel+0x14a (00007ff8`051c2817)
00007ff8`051c27fc 418bc2 mov eax,r10d
00007ff8`051c27ff f390 pause
00007ff8`051c2801 83e801 sub eax,1
00007ff8`051c2804 75f9 jne clr!JIT_MonEnterWorker_InlineGetThread_GetThread_PatchLabel+0x132 (00007ff8`051c27ff)
الآن لدينا قالب. في إحدى الحالات ، يكون العنوان الساخن عبارة عن قفزة ، وفي الحالة الأخرى ، يكون طرحًا. لكن كلا التعليمات الساخنة يسبقها نفس بيان الإيقاف المؤقت العام. تنفذ طرق مختلفة نفس تعليمات المعالج ، والتي تستغرق لوقت طويل جدًا لسبب ما. دعنا نقيس سرعة تنفيذ عبارة الإيقاف المؤقت ونرى ما إذا كنا نفكر بشكل صحيح.
إذا تم توثيق المشكلة ، فإنها تصبح ميزة.
وحدة المعالجة المركزية | توقف في نانو ثانية |
Xeon E5 1620v3 3.5 جيجا هرتز | 4 |
Xeon® Gold 6126 بسرعة 2.60 جيجاهرتز | 43 |
الإيقاف المؤقت في معالجات Skylake الجديدة يستغرق وقتًا أطول. بالطبع ، يمكن أن يصبح أي شيء أسرع ، وأحيانًا أبطأ قليلاً. ولكن أبطأ
عشر مرات ؟ إنه أشبه بخلل. يؤدي البحث البسيط على الإنترنت حول تعليمات الإيقاف المؤقت إلى
دليل Intel ، الذي يذكر بشكل صريح الهيكل المصغر Skylake وتعليمات الإيقاف المؤقت:

لا ، هذا ليس خطأ ، هذه وظيفة موثقة. حتى أن هناك
صفحة تشير إلى وقت تنفيذ جميع تعليمات المعالج تقريبًا.
- جسر ساندي 11
- اللبلاب بريدج 10
- هاسويل 9
- برودويل 9
- SkylakeX 141
يشار إلى عدد دورات المعالج هنا. لحساب الوقت الفعلي ، تحتاج إلى قسمة عدد الدورات على تردد المعالج (عادة في GHz) والحصول على الوقت بالثواني النانوي.
هذا يعني أنه إذا قمت بتشغيل تطبيقات متعددة الخيوط على .NET على آخر جهاز ، فيمكنها العمل بشكل أبطأ. شخص ما لاحظ هذا بالفعل وفي أغسطس 2017
سجل خطأ . تم
إصلاح المشكلة في .NET Core 2.1 و .NET Framework 4.8 Preview.
تم تحسين فترة الانتظار في العديد من بدائل المزامنة للحصول على أداء أفضل على Intel Skylake والمعماريات الدقيقة اللاحقة. [495945 ، mscorlib.dll ، خطأ]
ولكن نظرًا لأنه لا يزال هناك عام قبل إصدار .NET 4.8 ، فقد طلبت دعم الإصلاحات بشكل مسبق بحيث يعود .NET 4.7.2 إلى السرعة العادية على المعالجات الجديدة. نظرًا لوجود أقفال حصرية متبادلة (spinlocks) في العديد من أجزاء .NET ، يجب عليك تتبع الحمل المتزايد لوحدة المعالجة المركزية عندما تعمل Thread.SpinWait وأساليب الغزل الأخرى.

على سبيل المثال ، يستخدم Task.Result الغزل داخليًا ، لذلك أتوقع زيادة كبيرة في حمل وحدة المعالجة المركزية وانخفاض الأداء في الاختبارات الأخرى.
ما مدى سوء ذلك؟
نظرت إلى رمز .NET Core لمعرفة المدة التي سيستمر فيها المعالج في الدوران إذا لم يتم تحرير القفل قبل استدعاء WaitForSingleObject لدفع ثمن تبديل السياق "باهظ الثمن". يأخذ تبديل السياق في مكان ما ميكرو ثانية أو أكثر إذا توقع العديد من مؤشرات الترابط نفس كائن kernel.
تضاعف أقفال .NET الحد الأقصى لمدة الغزل بعدد النوى ، إذا أخذنا الحالة المطلقة حيث يتوقع الخيط على كل قلب نفس القفل ويستمر الغزل طويلًا بما يكفي ليعمل الجميع قليلاً قبل الدفع مقابل مكالمة kernel. يستخدم Spinning in .NET خوارزمية تقادم أسية عندما تبدأ بدورة من 50 مكالمات إيقاف مؤقت ، حيث يتضاعف عدد الدورات لكل ثلاث مرات حتى يتجاوز عداد الدوران التالي المدة القصوى. حسبت إجمالي مدة الغزل لكل معالج لمختلف المعالجات وعدد مختلف من النوى:

فيما يلي رمز الغزل المبسط في .NET Locks:
/// <summary> /// This is how .NET is spinning during lock contention minus the Lock taking/SwitchToThread/Sleep calls /// </summary> /// <param name="nCores"></param> void Spin(int nCores) { const int dwRepetitions = 10; const int dwInitialDuration = 0x32; const int dwBackOffFactor = 3; int dwMaximumDuration = 20 * 1000 * nCores; for (int i = 0; i < dwRepetitions; i++) { int duration = dwInitialDuration; do { for (int k = 0; k < duration; k++) { Call_PAUSE(); } duration *= dwBackOffFactor; } while (duration < dwMaximumDuration); } }
سابقًا ، كان وقت الغزل في الفاصل بالمللي ثانية (19 مللي ثانية لـ 24 نوى) ، وهو بالفعل كثيرًا مقارنة بوقت تبديل السياق المذكور سابقًا ، وهو ترتيب من حيث الحجم بشكل أسرع. ولكن في معالجات Skylake ، ينفجر إجمالي وقت الدوران للمعالج ببساطة حتى 246 مللي ثانية على جهاز 24 بت أو 48 نواة ، وذلك ببساطة لأن تعليمات الإيقاف المؤقت تباطأت بمقدار 14 مرة. هل هذا صحيح؟ لقد كتبت اختبارًا صغيرًا للتحقق من الدوران الكلي على وحدة المعالجة المركزية - والأرقام المحسوبة تتماشى جيدًا مع التوقعات. فيما يلي 48 خيطًا على وحدة معالجة مركزية من 24 نواة في انتظار قفل واحد ، والذي أطلقت عليه اسم Monitor.PulseAll:

سيفوز خيط واحد فقط في السباق ، لكن 47 ستستمر في الدوران حتى تفقد معدل ضربات القلب. هذا دليل تجريبي على أن لدينا مشكلة في تحميل وحدة المعالجة المركزية وأن الدوران الطويل جدًا حقيقي. إنه يقوض قابلية التوسع ، لأن هذه الدورات تذهب بدلاً من العمل المفيد للخيوط الأخرى ، على الرغم من أن تعليمات الإيقاف المؤقت تحرر بعض الموارد المشتركة لوحدة المعالجة المركزية ، مما يوفر النوم لفترة أطول. سبب الغزل هو محاولة الحصول على قفل بشكل أسرع دون الوصول إلى النواة. إذا كان الأمر كذلك ، فإن زيادة الحمل على وحدة المعالجة المركزية ستكون اسمية فقط ، ولكنها لا تؤثر على الأداء على الإطلاق ، لأن النواة تعمل في مهام أخرى. لكن الاختبارات أظهرت انخفاضًا في الأداء في العمليات ذات الخيوط الفردية تقريبًا ، حيث يضيف مؤشر ترابط شيئًا واحدًا إلى قائمة انتظار العمل ، بينما يتوقع مؤشر ترابط العمل نتيجة ، ثم ينفذ مهمة معينة مع عنصر العمل.
السبب هو الأسهل للعرض في الرسم التخطيطي. يحدث دوران الخصومة ثلاث مرات في كل خطوة. بعد كل جولة ، يتم فحص القفل مرة أخرى لمعرفة ما إذا كان الخيط الحالي يمكنه استلامه. على الرغم من أن الغزل يحاول أن يكون صادقًا ويتحول من وقت لآخر إلى خيوط أخرى لمساعدته على إكمال عمله. هذا يزيد من فرص تحرير القفل على الاختيار التالي. تكمن المشكلة في أن الفحص لأخذ أمر ممكن فقط في نهاية جولة الدوران الكاملة:

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

العديد من مللي ثانية من الانتظار حتى ينتهي الغزل. هل هذه مشكلة حقيقية؟
لقد قمت بإنشاء تطبيق اختبار بسيط يقوم بتنفيذ قائمة انتظار للشركات المصنعة للمستهلكين ، حيث ينفذ سير العمل كل عنصر عمل لمدة 10 مللي ثانية ، ويتأخر المستهلك من 1-9 مللي ثانية قبل عنصر العمل التالي. هذا يكفي لرؤية التأثير:

نرى في التأخيرات من 1-2 مللي ثانية ، المدة الإجمالية هي 2.2-2.3 ثانية ، بينما في حالات أخرى يكون العمل أسرع حتى 1.2 ثانية. هذا يدل على أن الغزل المفرط على وحدة المعالجة المركزية ليس مجرد مشكلة تجميلية في التطبيقات المترابطة. إنه يؤذي حقا الترابط البسيط للمنتج - المستهلك ، والذي يتضمن خيوطين فقط. بالنسبة إلى التشغيل أعلاه ، تتحدث بيانات ETW عن نفسها: إن الزيادة في الغزل هي التي تسبب التأخير الملحوظ:

إذا نظرت بعناية إلى القسم الذي يحتوي على "الفرامل" ، فسوف نرى 11 مللي ثانية من الغزل في المنطقة الحمراء ، على الرغم من أن العامل (الأزرق الفاتح) قد أكمل عمله وأعطى القفل منذ وقت طويل.

تبدو الحالة السريعة غير التنكسية أفضل بكثير ، حيث يتم إنفاق 1 مللي ثانية فقط على الدوران للحظر.

لقد استخدمت تطبيق الاختبار
SkylakeXPause . يحتوي
الأرشيف المضغوط على شفرة مصدر وثنائيات لـ .NET Core و .NET 4.5. للمقارنة ، قمت بتثبيت .NET 4.8 Preview مع الإصلاحات و .NET Core 2.0 ، الذي لا يزال يطبق السلوك القديم. تم تصميم التطبيق لـ .NET Standard 2.0 و .NET 4.5 ، مما ينتج عن كل من exe و dll. الآن يمكنك التحقق من سلوك الغزل القديم والجديد جنبًا إلى جنب دون الحاجة إلى إصلاح أي شيء ، إنه مريح للغاية.
readonly object _LockObject = new object(); int WorkItems; int CompletedWorkItems; Barrier SyncPoint; void RunSlowTest() { const int processingTimeinMs = 10; const int WorkItemsToSend = 100; Console.WriteLine($"Worker thread works {processingTimeinMs} ms for {WorkItemsToSend} times"); // Test one sender one receiver thread with different timings when the sender wakes up again // to send the next work item // synchronize worker and sender. Ensure that worker starts first double[] sendDelayTimes = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; foreach (var sendDelay in sendDelayTimes) { SyncPoint = new Barrier(2); // one sender one receiver var sw = Stopwatch.StartNew(); Parallel.Invoke(() => Sender(workItems: WorkItemsToSend, delayInMs: sendDelay), () => Worker(maxWorkItemsToWork: WorkItemsToSend, workItemProcessTimeInMs: processingTimeinMs)); sw.Stop(); Console.WriteLine($"Send Delay: {sendDelay:F1} ms Work completed in {sw.Elapsed.TotalSeconds:F3} s"); Thread.Sleep(100); // show some gap in ETW data so we can differentiate the test runs } } /// <summary> /// Simulate a worker thread which consumes CPU which is triggered by the Sender thread /// </summary> void Worker(int maxWorkItemsToWork, double workItemProcessTimeInMs) { SyncPoint.SignalAndWait(); while (CompletedWorkItems != maxWorkItemsToWork) { lock (_LockObject) { if (WorkItems == 0) { Monitor.Wait(_LockObject); // wait for work } for (int i = 0; i < WorkItems; i++) { CompletedWorkItems++; SimulateWork(workItemProcessTimeInMs); // consume CPU under this lock } WorkItems = 0; } } } /// <summary> /// Insert work for the Worker thread under a lock and wake up the worker thread n times /// </summary> void Sender(int workItems, double delayInMs) { CompletedWorkItems = 0; // delete previous work SyncPoint.SignalAndWait(); for (int i = 0; i < workItems; i++) { lock (_LockObject) { WorkItems++; Monitor.PulseAll(_LockObject); } SimulateWork(delayInMs); } }
الاستنتاجات
هذه ليست مشكلة .NET. تتأثر جميع عمليات تنفيذ spinlock باستخدام عبارة الإيقاف المؤقت. راجعت بسرعة جوهر Windows Server 2016 ، ولكن لا توجد مثل هذه المشكلة على السطح. يبدو أن Intel كانت لطيفة بما فيه الكفاية - وألمحت إلى أن هناك حاجة إلى بعض التغييرات في نهج الغزل.
تم الإبلاغ عن خطأ لـ .NET Core في أغسطس 2017 ، وفي سبتمبر 2017 تم
إصدار تصحيح وإصدار من .NET Core 2.0.3. لا يظهر الارتباط فقط رد الفعل الممتاز لمجموعة .NET Core ، ولكن أيضًا حقيقة أنه تم إصلاح المشكلة قبل بضعة أيام في الفرع الرئيسي ، بالإضافة إلى مناقشة حول تحسينات الدوران الإضافية. لسوء الحظ ، لا يتحرك Desktop .NET Framework بسرعة كبيرة ، ولكن في مواجهة .NET Framework 4.8 Preview ، لدينا على الأقل دليل مفاهيمي على أن الإصلاحات هناك قابلة للتنفيذ أيضًا. أنتظر الآن الواجهة الخلفية لـ .NET 4.7.2 لاستخدام .NET بأقصى سرعة وعلى آخر جهاز. هذا هو الخطأ الأول الذي وجدته والذي يرتبط مباشرة بتغييرات الأداء بسبب تعليمات CPU واحدة. يبقى ETW المحلل الرئيسي في Windows. إذا استطعت ، سأطلب من Microsoft نقل البنية التحتية ETW إلى Linux ، لأن المحللون الحاليون في Linux لا يزالون هراء. لقد أضافوا مؤخرًا ميزات kernel المثيرة للاهتمام ، ولكن لا توجد حتى الآن أدوات تحليل مثل WPA.
إذا كنت تعمل مع .NET Core 2.0 أو .NET Framework لسطح المكتب على أحدث المعالجات التي تم إصدارها منذ منتصف عام 2017 ، فعندئذ في حالة حدوث مشاكل في تدهور الأداء ، يجب عليك التحقق من تطبيقاتك باستخدام منشئ ملفات تعريف - والترقية إلى .NET Core ، ونأمل أن يتم ذلك قريبًا .NET سطح المكتب سيخبرك طلب الاختبار الخاص بي عن وجود مشكلة أو عدم وجودها.
D:\SkylakeXPause\bin\Release\netcoreapp2.0>dotnet SkylakeXPause.dll -check
Did call pause 1,000,000 in 3.5990 ms, Processors: 8
No SkylakeX problem detected
أو
D:\SkylakeXPause\SkylakeXPause\bin\Release\net45>SkylakeXPause.exe -check
Did call pause 1,000,000 in 3.6195 ms, Processors: 8
No SkylakeX problem detected
ستبلغ الأداة عن مشكلة إذا كنت تعمل على .NET Framework بدون التحديث المناسب وعلى معالج Skylake.
آمل أن تجد التحقيق في هذه المشكلة مثيرًا كما فعلت. لفهم المشكلة حقًا ، تحتاج إلى إنشاء وسيلة لاستنساخها ، مما يسمح لك بالتجربة والبحث عن العوامل المؤثرة. والباقي مجرد عمل ممل ، لكنني الآن أفضل بكثير في فهم أسباب وعواقب محاولة دورية للحصول على قفل على وحدة المعالجة المركزية.