Отидете срещу C #, част 2: Събиране на боклука

По-рано в същата серия:

  • Част 1: Goroutines vs Async-Await

Интересното е, че проектът за този пост беше написан преди няколко месеца и беше сравнително кратък. Същността му беше: „GC на Go определено е по-нисък от това, което има .NET, вижте следните публикации: 1, 2, 3, 4 (имайте предвид, че някои от тях са доста скорошни) за подробности.“

Но… не можах да се спра да по някакъв начин да тествам това, затова помолих един мой приятел - експерт Go - да ми помогне с еталонния показател. И написахме GCBurn - сравнително прост бенчмарк за събиране на боклук и разпределение на паметта, който в момента поддържа Go и C #, въпреки че сте свободни да го пренасяте на всеки друг език с GC.

А сега да влезем в гората :)

Какво представлява събирането на боклука?

Публикацията е доста дълга, така че, моля, пропуснете този раздел, ако знаете какво е GC.

Събиране на боклук (GC, Garbage Collector) е част от време на изпълнение, отговорно за възстановяване на паметта, използвана от „мъртви“ обекти. Ето как работи:

  • Обектът „Alive“ е всеки обект в купчината, който или се използва в момента (~ показалец към него се съхранява в един от регистрите на процесора), или би могъл да бъде използван в бъдеще (~ може да има програма, която в крайна сметка да придобие указател на такъв обект). Ако погледнете на своята купчина като графика на обектите, които се отнасят един към друг, лесно е да забележите, че ако някой обект O е жив, то всеки обект, към който се позовава директно (O1, O2, ... O_m), също е жив: има показалец към О, можете да придобиете указател към O1, O2, ... O_m с помощта на една инструкция за процесор. Същото може да се каже и за обектите, за които се отнасят O1, O2, ... O_m - всеки от тези обекти също е жив. С други думи, ако обект Q е достъпен от някой жив обект A, Q също е жив.
  • „Мъртви“ обекти са всички останали обекти в грамадата. Те са "мъртви", защото няма начин кодът по някакъв начин да придобие указател към някой от тях в бъдеще. Няма начин да ги намерите и по този начин няма начин да ги използвате.
  • Добра аналогия в реалния свят е: искате да разберете кои градове (обекти) са достъпни от пътната мрежа, ако приемете, че започвате пътуването си от всеки град с летището (GC root).
Визуализация на достъпна зона около един произход. Източникът на изображения: https://www.graphhopper.com/blog/2018/07/04/high-precision-reachability/

Това определение обяснява и основната част на всеки алгоритъм за събиране на боклука: той трябва да проверява какво е достъпно (живо) от време на време и да изтрива всичко останало. Ето как обикновено се случва:

  • Замразете всяка нишка
  • Маркирайте всички корени на GC (обекти, посочени от регистрите на процесора, локали / рамки за стекове на обаждания или статични полета - т.е. всичко, което се използва в момента или е незабавно на разположение) като живо
  • Маркирайте всеки обект, който е достъпен от корените на GC, също като жив; считайте всичко останало за мъртво.
  • Направете отново достъпна паметта от мъртви обекти - напр. можете просто да го маркирате като „наличен за бъдещи разпределения“ или да уплътните купчината, като премествате всички живи обекти, така че да няма пропуски
  • Накрая размразете всички нишки.

Описаното тук обикновено се нарича „Маркирайте и преместете“ GC и това е най-лесното възможно изпълнение, но не и най-ефикасното. По-конкретно, това означава, че трябва да спрем всичко, за да изпълним GC, затова колекционерите, които имат такива паузи, се наричат ​​също Stop-the-World или STW колектори - противно на колекторите без пауза.

Pauseless не се различават много от колекторите STW по отношение на това как решават проблема - просто правят почти всичко едновременно с вашия код. И очевидно това е сложно - ако се върнем към нашата реална аналогия с градовете и пътищата, това е като да се опитаме да картографираме градовете, достъпни от летищата, като приемем, че:

  • Всъщност нямате карта - но има парк от превозни средства, които управлявате
  • Докато тези превозни средства шофират, се изграждат нови градове и пътища, а някои пътища са унищожени.

Всичко това прави проблема по-сложен - по-специално не можете да изградите пътя, както сте използвали в случая: трябва да проверите дали флотът работи в момента (т.е. GC търси живи обекти) и дали премина през град, от който току-що построен път започва (т.е. GC вече е отбелязал този град като жив). Ако това е вярно, трябва да уведомите флота (GC), за да се върнете там и да намерите всички градове, достъпни по новия път.

Преведено в нашия нефикционален случай, това означава, че докато GC работи, всяка операция по записване на указател изисква специална проверка (бариера за запис) - и все още ще забави вашия код.

Няма разлика между черно и бяло между пауза и STW GC - става въпрос само за продължителността на паузите на STW:

  • Как продължителността на STW паузата зависи от различни фактори? Например фиксиран ли е или пропорционален на размера на набора от живи обекти (O (live_set_size))?
  • Ако тези паузи са фиксирани, каква е действителната продължителност? Ако е достатъчно мъничко за вашия конкретен случай, това е същото като напълно пауза GC.
  • Ако тези паузи не са фиксирани, можем ли да се уверим, че те никога не са по-дълги от максималното, което можем да си позволим?

И накрая, имайте предвид, че различните реализации на GC могат да оптимизират за различни фактори:

  • Общо забавяне, причинено от GC (или цялостна пропускателна способност на програмата) - т.е. приблизително,% от времето, прекарано в GC + всички свързани разходи (напр. Проверка на бариерата в горния пример).
  • Разпределението на продължителността на паузата на STW - очевидно, колкото по-къса, толкова по-добра (съжалявам, тук няма какавиди) + в идеалния случай не искате да имате O (liveSetSize) паузи.
  • Като цяло% от паметта, изразходвана единствено за GC, или поради нейното специфично внедряване. Допълнителната памет може да се използва директно или от разпределител, или от GC, да не е налична поради фрагментация на купчината и т.н.
  • Пропускателна способност за разпределение на паметта - GC обикновено е плътно съчетано с разпределител на памет. По-специално, разпределителят може да предизвика пауза в текущата нишка, ако GC изостава, може да свърши част от работата на GC или може да използва по-скъпи структури от данни, за да даде възможност за определен вид GC.
  • И т.н. - много повече фактори са изброени тук.

Най-лошото е: очевидно не можете да получите всичко това наведнъж, т.е. различните GC внедрявания имат своите предимства и компромиси. Ето защо е трудно да напишете добър показател за GC :)

Какво е GCBurn?

GCBurn е еталон, който създадохме, за да измерваме директно най-важните показатели за ефективност на GC - т.е. чрез действително измерване на тях, а не чрез проверка на броячи за изпълнение или API, предоставени от изпълнението.

Директните измервания осигуряват няколко предимства:

  • Преносимост: сравнително лесно е да приставим нашия показател за почти всяко време на изпълнение - изобщо няма значение дали има API-тата, позволяващи да питаме какво измерваме по някакъв начин или не. И определено сте добре дошли да направите това - напр. Много ми е любопитно да видя как Java се сравнява с Go и .NET Core.
  • По-малко съмнителна валидност: Освен това е по-лесно да потвърдите, че получените данни са действително валидни: получаването на едни и същи номера от време на изпълнение винаги би повдигнало въпроси като „как можете да сте сигурни, че получавате правилни номера?“ И добри отговори на тях въпросите предполагат, че вероятно ще отделите много повече време за изучаване на конкретна GC имплементация и начин на събиране на показателите там, отколкото за писане на подобен тест.

Второ, GCBurn е предназначен да сравнява ябълките с ябълките - т.е. всичките му тестове извършват почти точно една и съща последователност от действия / разпределения - разчитайки на идентични разпределения, на идентични генератори на случайни числа и т.н., и т.н. Ето защо резултатите от него са за различни езици и се очаква рамките да са пряко сравними.

Има два теста, които GCBurn изпълнява:

Максимална пропускателна способност („Тест на скоростта“)

Намерението тук е да се измери максималната степен на разпределение на пика - скоростта на разпределение на паметта, при условие че нищо друго (по-специално GC) не го забавя.

  • Започнете T нишки / подпрограми, където всяка нишка:
  • Разпределя 16-байтови обекти (обекти с две полета int64) възможно най-бързо
  • Направете това в цикъл за 1ms, следете общия брой на разпределенията
  • Изчакайте всички нишки да завършат и скоростта на разпределение (обекти в секунда).
  • Повторете това ~ 30 пъти и отпечатайте макс. измерена степен на разпределение.

Тест на GCBurn

Това е по-сложно изпитване за измерване:

  • Максимална устойчивост на разпределение (обекти / секунда, байтове / секунда) - т.е. пропускателна способност, измерена за сравнително дълъг период от време, като се предполага, че разпределяме, задържаме и евентуално освобождаваме всеки обект и че размерите и продължителността на задържане за тези обекти са след разпределението които са близки до тези в реалния живот.
  • Разпределение на честотата и продължителността на паузните нишки, причинени от GC - 50% перцентил (p50), p95, p99, p99.9, p99.99 + min, max и средни стойности.
  • Разпределение на честотата и продължителността на STW (глобални) паузи, причинени от GC

Ето как работи тестът:

  • Отделете „статичен набор“ с желания размер (ще обясня по-нататък)
  • Започнете T нишки / подпрограми, където всяка нишка:
  • Разпределя обекти (всъщност масиви / резени на int64-s), следвайки предварително генерирания модел на разпределение на размера и целия живот. Моделът всъщност е списък на кортежи с 3 стойности: (размер, ~ етаж (log10 (продължителност)), str (продължителност) [0] - '0'). Последните две стойности кодират „продължителност на задържане“ - нейната експонента и първата цифра в десетичното му представяне в микросекунди. Това е оптимизация, позволяваща операцията „освобождаване“ да бъде доста ефективна и да има O (1) сложност на времето за всеки разпределен обект - ние търгуваме малко прецизност в замяна на скоростта тук.
  • Веднъж на всеки 16 разпределения опитайте да освободите разпределените обекти, чиято продължителност на задържане вече е изтекла.
  • За всяка итерация на цикъла измерете времето, което отнема текущата итерация. Ако отне повече от 10 микросекунди (обикновено итерацията трябва да отнеме по-малко от 0,1 микросекунди), приемете, че е имало GC пауза, така че регистрирайте началния и крайния й час в списък за тази тема.
  • Следете броя на разпределенията и общия размер на разпределените обекти. Спрете след D секунди.
  • Изчакайте всички нишки да завършат.

Когато горната част е завършена, за всяка нишка се знае следното:

  • Броят на разпределенията, които беше в състояние да извърши, както и общият им размер в байтове
  • Списъкът на паузите (интервалите за пауза), които е изпитал.

Имайки тези списъци за всяка нишка, е възможно да се изчисли списъкът от интервали от STW паузи (т.е. периоди, когато всяка нишка е била поставена на пауза) - чрез просто пресичане на всички тези списъци.

Като знаем всичко това, лесно е да се изготвят горепосочените статистически данни.

Сега някои важни подробности:

  • Споменах, че последователността (моделът) на разпределение е предварително генерирана. Това се прави най-вече, защото не искаме да харчим цикли на процесора за генериране на набор от произволни числа за всяко разпределение. Генерираната последователност се състои от ~ 1M елемента от ~ (размер, лог (продължителност)). Вижте BurnTester.TryInitialize (C # / Go), за да видите действителното изпълнение.
  • Всеки GCBurn нишка използва една и съща последователност, но започва от произволна точка там. Когато стигне до края, той продължава от началото на последователността.
  • За да сме сигурни, че моделът е абсолютно идентичен за всеки език, използваме персонализиран генератор на произволни числа (вижте StdRandom.cs / std_random.go) - в действителност това е C ++ 11 реализация на minstd_rand на C ++, пренесено на C # и Go.
  • И като цяло ние се уверяваме, че всички случайни стойности, които използваме, са идентични за всички платформи - начални точки на нишката в последователността на разпределение, размери и продължителност в тази последователност и т.н.

Разпределенията за размер на обекта и продължителност на задържане, които използваме (вижте примерни устройства - C #, Go), са предназначени да бъдат близки до реалния живот:

Размер:

  • 99% от "типичните" обекти + 0,99% от "големите" + 0,01% от "изключително големите", където:
  • "Типичният" размер е след нормалното разпределение със средно = 32 байта и stdDev = 64 байта
  • "Големият" размер е след нормалното разпределение в дневника, като средното за нормалното разпределение е средното = log (2 Kb) = 11 и stdDev = 1.
  • "Изключително голям" размер следва нормалното разпределение на дневника с подреденото средно значение на нормалното разпределение = log (64 Kb) = 16 и stdDev = 1.
  • Размерът е съкратен, за да се побере в диапазона [32B .. 128KB], а по-късно се превежда в размер на масив / парче, като се вземат предвид референтният размер (8B) и размерът на заглавката на масива (24B) за C # и размера на парче (24B) за Go ,

Продължителност на задържане:

  • По същия начин, той се състои от 95% „ниво на метода“ + 4,9% от „ниво на заявка“ + 0,1% от „продължителност на живот“, където:
  • Продължителността на задържане на ниво метод е след абсолютна стойност на нормално разпределена променлива със средно = 0 микросекунди, stdDev = 0,1 микросекунда
  • Продължителността на задържане на ниво заявка следва подобно разпределение, но със stdDev = 100ms (милисекунди)
  • Продължителността на задържане с продължителна продължителност следва нормалното разпределение със средно = stdDev = 10 секунди.
  • Продължителността на задържане е съкратена, за да се впише в обхват [0… 1000 секунди].

И накрая, статичният набор е набор от обекти, следващи абсолютно същото разпределение на размера, което никога не се пуска по време на теста - с други думи, това е нашият жив набор. Ако приложението ви кешира или съхранява много данни в RAM памет (или има известно изтичане на памет), това ще бъде голямо. По същия начин, той трябва да е малък за обикновени приложения без състояние (например прости сървъри за уеб / API).

Ако четете до този момент, вероятно нямате търпение да видите резултатите - и ето ги:

Резултати

Пуснахме тест всички (или Test-All.bat на Windows) на набор от много различни машини и изхвърлихме папката изход към резултати.

Test-all изпълнява следните тестове:

  • Изпитване на максимална пропускателна способност ("Тест за скорост") - използвайки 1, 25%, 50%, 75% и 100% от макс. # от нишките, които системата всъщност може да работи паралелно. Така напр. за Core i7–8700K „100% нишки“ = 12 нишки (6 ядра * 2 нишки на ядро ​​w / хиперточка).
  • Тест на GCBurn - за статичен размер на зададения = 0MB, 1MB, 10%, 25%, 50% и 75% от общото количество RAM на тествана машина и използване на 100% от макс. # от нишките. Всеки тест работи 2 минути.
  • Тест на GCBurn - всички същите настройки като в предишния случай, но с използване на 75% от макс. # от нишките, които системата всъщност може да работи паралелно.
  • И накрая, той изпълнява всички тези тестове в 3 режима за .NET - Server GC + SusishedLowLatency (режимът, който вероятно ще използвате на вашите производствени сървъри), Server GC + Batch и Workstation GC. Правим същото и за Go - единствената подходяща опция там е GOGC, но не сме забелязали разлика, след като я настроихме на 50%: изглежда, че Go така или иначе изпълнява GC ~ непрекъснато на този тест.

Така че нека започнем Можете също така да отворите Google Spreadsheet с всички данни, които използвах за диаграми тук, както и папка с резултати в GitHub със суров изходен тест (много повече числа има там).

12-ядрен невиртуализиран процесор Intel Core i7–8700K при 3.70GHz96-ядрен екземпляр AWS m5.24xlage (хардуерен процесор: Intel Xeon Platinum 8175M CPU @ 2.50GHz)

Само напомняне, 1 операция на този тест = разпределение на 16-байтов обект.

.NET ясно бие Отидете на разпръсквания:

  • Не само че е 3x (Ubuntu)… 5x (Windows) по-бърз при един тест с резба, но и намалява по-добре с броя на нишките - разширявайки разликата до 12x при 96-ядрено чудовище, m5.24xlarge.
  • Разпределенията за купчина са изключително евтини в .NET. Ако погледнете числата, те всъщност са само ~ 3–4x по-скъпи от разпределението на стекове: можете да правите ~ 1B прости разговори в секунда на нишка - срещу почти 0,3B разпределения.
  • Изглежда .NET Core е малко по-бърз в Windows и напротив, Go е почти 2 пъти по-бавен в сравнение с Linux.
12-ядрен невиртуализиран процесор Intel Core i7–8700K при 3.70GHz16-ядрен екземпляр AWS m5.4xlarge (хардуерен процесор: Intel Xeon Platinum 8175M CPU @ 2.50GHz)

Операция на този тест е единично разпределение след псевдо-реално разпределение, описано по-рано; освен това всеки обект, разпределен на този тест, има живот след друго разпределение на псевдо-реалния живот.

.NET все още е по-бърз при този тест, въпреки че разликата тук не е толкова голяма - той е 20… 50% в зависимост от OS (по-малък в Linux, по-голям в Windows) и статичен размер на зададените.

Може също да забележите, че Go не може да премине тестовете за „Статичен набор = 50% RAM / 75% RAM“ - не успя с OOM (Out of Memory). Извършването на теста на 75% от наличните ядра на процесора помогна да премине теста „Static set = 50% RAM“, но все още не може да го направи на 75%:

12-ядрен невиртуализиран процесор Intel Core i7–8700K при 3.70GHz16-ядрен екземпляр AWS m5.4xlarge (хардуерен процесор: Intel Xeon Platinum 8175M CPU @ 2.50GHz)

Увеличаването на продължителността на теста (изводите, представени тук са за 2 мин. Продължителност), е довело до отказ на Go по-надеждно при теста „Статичен набор = 50% RAM“ - така че привидно GC просто не може да бъде в крак с темп на разпределение, ако живият набор е достатъчно голям.

Освен това, няма значителни промени между 100% и 75% тестове за ядро ​​на процесора.

Също така е очевидно, че и двата разпределителя не скалират добре броя на ядрата. Тази диаграма доказва точката:

96-ядрен екземпляр AWS m5.24xlage (хардуерен процесор: Intel Xeon Platinum 8175M CPU @ 2.50GHz)

Както виждате, ~ 5x повече ядра се превеждат до 2.5x ускорение тук.

Не изглежда, че честотната лента на паметта е препятствието: ~ 70 M ops / sec. преведете до ~ 6.5 GB / sec., което е близо 10% от наличната честотна лента на паметта на Core i7 машина.

Интересното е също, че Go започва да бие .NET в случай „Static set = 50% RAM“. Любопитни ли сте защо?

12-ядрен невиртуализиран процесор Intel Core i7–8700K при 3.70GHz96-ядрен екземпляр AWS m5.24xlage (хардуерен процесор: Intel Xeon Platinum 8175M CPU @ 2.50GHz)

Да, това е абсолютно срамна част за .NET:

  • Паузите на Go едва се виждат тук - защото са мънички. Макс. пауза, която успях да измеря, беше ~ 1,3 секунди - за сравнение .NET получи 125 сек. STW пауза в същия тестов случай.
  • Почти всички паузи на STW в Go са наистина подмилисекундни. Ако погледнете по-добър тестов случай в реалния живот (вижте например този файл), ще забележите, че статичният 16 GB на статичен 16-ядрен сървър предполага най-дългата ви пауза = 50 ms (срещу 5s за .NET) и 99,99% от паузите са по-къси от 7ms (92ms за .NET)!
  • Времето за пауза на STW изглежда се мащабира линейно със статичния зададен размер както за .NET, така и за Go. Но ако сравним по-големите паузи, те са ~ 100 пъти по-къси за Go.
  • Заключение: Посредственият GC работи; .NET едновременно GC не.

Добре, вероятно съм се уплашил от всички разработчици на .NET в този момент - особено при положение, че споменах, че GCBurn е проектиран така, че да е близък до реалния живот. Така че очаквате ли да получите подобни паузи в .NET? Да и не:

  • ~ 185GB (това е ~ 2 милиарда обекти, а времето за пауза в GC всъщност зависи от този брой, а не от размера на работния набор в GB), статичният набор е много по-голям от това, което може да очаквате в реалния живот. Вероятно, дори статичният набор от 16 GB е далеч над това, което може да видите във всяко добре проектирано приложение.
  • „Добре проектиран“ всъщност означава: „въз основа на изводите, представени тук, никой разработчик в правилния си ум няма да изработи .NET приложение, разчитащо на многогигабайт статичен набор“. Има много начини за преодоляване на това ограничение, но в крайна сметка всички те ви принуждават да съхранявате данните си или в огромни управлявани масиви, или в неуправляеми буфери. .NET Core 2.1 - по-конкретно, неговите структури, Span и Memory опростяват тези усилия много.
  • Освен това, „добре проектиран“ означава и „без течове на паметта“. Както може да забележите, ако пропускате справки в .NET, се очаква да имате по-дълги и по-дълги паузи на STW. Очевидно приложението ви в крайна сметка ще се срине - но имайте предвид, че преди да се случи, то може също да стане временно бездействащо - всичко това поради нарастващите STW паузи. И колкото повече RAM разполагате, толкова по-лошо ще става.
  • Проследяване на макс. Времето за пауза на GC и размерът на работния набор след Gen2 на GC трябва да бъдат от решаващо значение, за да не се търсите от увеличаване на p95-p99 поради течове на паметта.

Като сърце .NET разработчик, аз наистина искам .NET Core екип адресира по-рано проблема max_STW_pause_time = O (static_set_size). Освен ако не е адресирано, разработчиците на .NET ще трябва да разчитат на заобикалящи проблеми, което всъщност не е добре. И накрая, дори фактът, че съществува, ще действа като showstopper за много потенциални .NET приложения - помислете за IoT, роботика и други контролни приложения; високочестотна търговия, игри или сървъри за игри и т.н.

Що се отнася до Go, невероятно е колко добре е решен този въпрос там. И си струва да се отбележи, че екипът на Go се бореше със STW паузите много последователно, като се започне от 2014 г. - и в крайна сметка те успяха да убият всички O (live_set_size) паузи (както екипът твърди - привидно тестът не го доказва, но може би това е просто защото GCBurn отива твърде далеч, за да изложи това :)). Както и да е, ако се интересувате от подробности как се е случило там, тази публикация е добре да започнете от: https://blog.golang.org/ismmkeynote

Все още си задавам въпроса какво от тези две опции бих предпочел - т.е. по-бърз разпределител на .NET или малки паузи на GC. И честно казано, аз съм склонен към Отиди - най-вече, защото работата на неговия разпределител все още не изглежда зле, но 100 пъти по-късите паузи на големи купища са доста привлекателни. Що се отнася до OOMs на по-големи купища (или трябва да имате 2 пъти повече памет, за да избегнете OOM) - добре, паметта е евтина. Въпреки че това може да е по-важно, ако стартирате множество Go приложения на една и съща машина (помислете за настолни приложения и микросервизи).

Накратко, тази ситуация със STW паузи ме накара да завиждам на това, което имат разработчиците на Go - вероятно за първи път.

Добре, има една последна тема, която трябва да се покрие, а именно други режими на GC в .NET (спойлер: те не могат да спестят деня, но все пак си струва да поговорим):

Сървърният GC в паралелен режим (SusishedLowLatency или Interactive) осигурява най-високата максимална пропускателна способност, въпреки че разликата с Batch е минимална.

Поддържаната пропускателна способност е най-висока и в режим GC + SLL на сървъра. Сървърният GC + Batch също е много близък, но Workstation GC просто не се увеличава с нарастващия размер на статичния набор.

И накрая, паузи:

Трябваше да добавя тази таблица (от споменатия електронна таблица на Google - вижте последния лист там), за да покажа, че:

  • Работна станция GC всъщност има по-малки STW паузи, само когато статичен размер на зададения <16 GB; надхвърлянето на 16 GB го прави по-малко и по-малко привлекателен от тази гледна точка - и почти 3 пъти по-малко привлекателен за 48 GB случай в сравнение със сървърния GC + Batch режим.
  • Интересното е, че сървърът GC + Batch започва да бие Server GC + SLL при статичен размер на зададения ≥ 16 GB - т.е. пакетният режим GC всъщност има по-малки паузи в сравнение с едновременните GC на големи купища.
  • И накрая, Server GC + SLL и Server GC + Batch всъщност са доста сравними по отношение на времето за пауза. Т.е. едновременно GC в .NET очевидно не прави много едновременно - въпреки че всъщност може да бъде доста ефикасен в нашия конкретен случай. Ние създаваме статичния набор преди основния тест, така че на пръв поглед не е необходимо да преместваме много - почти цялата работа, която GC трябва да свърши там, е да маркира живи обекти и точно това трябва да прави едновременно GC. Така че защо произвежда почти същата срамно дълга пауза като пакетната GC е пълна загадка.
  • Може да попитате защо не тествахме сървърния GC + интерактивен режим - всъщност го направихме, но не забелязахме съществена разлика със сървъра GC + SLL.

Заключения

.NET Core:

  • Има O (live_object_count) STW време за пауза в колекции Gen2 - без значение какъв режим на GC изберете. Очевидно тези паузи могат да бъдат произволно дълги - всичко зависи от вашия жив зададен размер. Измерихме 125 сек. пауза на купчина от 200 GB.
  • Много по-бързо (3… 12x) при разделянето на разпределения - такива разпределения наистина са подобни на разпределения на стекове в .NET.
  • Обикновено с 20… 50% по-бързи при тестове за устойчива производителност. „Статичен размер на зададения = 200 GB“ беше единственият случай, когато Go отиде напред.
  • Никога не трябва да използвате Workstation GC на .NET Core сървъри - или поне трябва точно да знаете, че вашият работен комплект е достатъчно мъничък, за да бъде полезен.
  • Сървърният GC в паралелен режим (SusishedLowLatency или Interactive) изглежда е добър по подразбиране - въпреки че не се различава много с Batch режима, което всъщност е доста изненадващо.

Отивам:

  • Няма O (live_object_count) STW паузи - по-точно, изглежда, че всъщност има O (live_object_count) паузи, но те все още са 100 пъти по-къси, отколкото за .NET.
  • Почти всяка пауза там е по-къса от 1 ms; най-дългата, която видяхме беше 1,3 сек. - на огромен ~ 200 GB жив комплект.
  • По-бавно е от .NET при тестовете на GCBurn. Windows + i7–8700K е мястото, където сме измерили най-голямата разлика - т.е. привидно Go има някои проблеми с разпределителя на паметта си в Windows.
  • Go не може да се справи с „статичен набор = 75% RAM“ - никога. Този тест в Go винаги задействаше OOM. По същия начин, той надеждно се провали в случай "статичен набор = 50% RAM", ако провеждате този тест достатъчно дълго (2 мин. = ~ 50% шанс за неуспех, 10 мин. - Спомням си само един случай, когато не катастрофа). Изглежда, GC просто не може да бъде в крак с темпото на разпределение там и неща като „използват само 75% от процесорни ядра за разпределения“ не помагат. Не сме сигурни дали това може да е важно в реалния живот: разпределението е всичко, което прави GCBurn, и повечето приложения не правят само това. От друга страна, устойчивата паралелна пропускателна способност на разпределение обикновено е по-ниска от несъпътстваща пикова пропускливост, така че приложението в реалния живот, което произвежда подобно натоварване при разпределение на многоядрена машина, не изглежда измислено.
  • Но дори и като се вземе предвид всичко това, всъщност е доста спорно какво е по-добре да направите, в случай че вашият GC не може да бъде в крак с темпото на разпределение: да спрете приложението за няколко минути или да се провалите с OOM. Обзалагам се, че повечето разработчици всъщност биха предпочели втория вариант.

И двете:

  • Мащаб на скорост на разпределение на пиковата честота ~ линейно с ядрото се изчислява при тестовете за разпределение на разрушаване.
  • От друга страна, устойчивата паралелна пропускателна способност за разпределение обикновено е по-ниска от несъпътстващата пикова пропускливост - т.е. поддържаната паралелна пропускателна способност не се мащабира добре, както за Go, така и за .NET. Изглежда, това не е поради честотната лента на паметта: скоростта на разпределение на агрегата може да бъде 10 пъти по-ниска от наличната честотна лента.

Отказ от отговорност и епилог

  1. GCBurn е предназначен да измерва няколко много специфични показатели. Опитахме се да го доближим до реалния живот в някои аспекти, но очевидно това не означава, че цифрите, които извежда, са това, което трябва да очаквате в истинското си приложение. Както всеки тест за ефективност, той е предназначен да измерва крайността на това, което трябва да измерва - и да игнорира почти всичко останало. Така че, моля, не очаквайте повече от това :)
  2. Разбирам, че методологията е спорна - честно казано, тук е трудно да се намери нещо, което не е спорно. Така че оставяйки незначителни проблеми сами, ако имате някакви идеи защо може да е ужасно погрешно да оцените GC както ние, моля, оставете коментарите си. Определено ще се радвам да обсъдим това.
  3. Сигурен съм, че има начини за подобряване на теста без значително увеличаване на количеството работа или код. Ако знаете как да направите това, моля, оставете коментарите също или просто дайте своя принос.
  4. По същия начин, моля, направете същото, ако намерите някои грешки там.
  5. Умишлено не се съсредоточих върху подробности за внедряването на GC (поколения, уплътнения и т.н.). Тези подробности очевидно са важни, но има много публикации за това, както и за съвременното събиране на боклук като цяло. И за съжаление, почти няма публикации за действителната ефективност на GC и разпределението. Това е разликата, която исках да преодолея.
  6. Ако желаете да преведете теста на някой друг език (например Java) и да напишете подобна публикация, би било просто невероятно.

Що се отнася до моята поредица „Go срещу C #“, следващата публикация ще бъде относно системата за изпълнение и тип. И тъй като не виждам нужда да напиша няколко хиляди теста за LOC за това, не би трябвало да отнеме толкова време - затова продължете да се настройвате!

Послепис Ако ви харесва публикацията, моля, не забравяйте да „пляскате“ за нея :)