Еще две части из этой серии:

Интересно, что черновик этого поста был написан пару месяцев назад, и он был относительно коротким. Суть его заключалась в следующем: Сборщик мусора Go явно уступает тому, что есть в .NET. Подробности см. В следующих сообщениях:« 1 , 2 , 3 , 4 (обратите внимание, что некоторые из них довольно свежие). . »

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

А теперь в лес :)

Что такое сборка мусора?

Сообщение довольно длинное, поэтому пропустите этот раздел, если вы знаете, что такое сборщик мусора.

Сборка мусора (GC, сборщик мусора) - это часть среды выполнения, отвечающая за освобождение памяти, используемой «мертвыми» объектами. Вот как это работает:

  • «Живой» объект - это любой объект в куче, который либо используется прямо сейчас (~ указатель на него хранится в одном из регистров ЦП), либо может потенциально использоваться в будущем (~ может быть программа в конечном итоге получает указатель на такой объект). Если вы посмотрите на свою кучу как на график объектов, ссылающихся друг на друга, легко заметить, что если какой-то объект O жив, то каждый объект, на который он ссылается напрямую (O1, O2,… O_m), тоже жив: имея указатель на O, вы можете получить указатель на O1, O2,… O_m с помощью одной инструкции ЦП. То же самое можно сказать и об объектах, на которые ссылаются O1, O2,… O_m - каждый из этих объектов тоже жив. Другими словами, если объект Q достижим из некоторого живого объекта A, Q также жив.
  • «Мертвые» объекты - это все остальные объекты в куче. Они «мертвы», потому что код не может каким-либо образом получить указатель на любой из них в будущем. Их невозможно найти, а значит, и использовать.
  • Хорошая аналогия из реального мира: вы хотите узнать, какие города (объекты) доступны по дорожной сети, если вы начинаете поездку из любого города с аэропортом (корень GC).

Это определение также объясняет фундаментальную часть любого алгоритма сборки мусора: он должен время от времени проверять, что достижимо (живое), и удалять все остальное. Вот как это обычно бывает:

  • Заморозить каждую нить
  • Отметить все корни GC (объекты, на которые ссылаются регистры ЦП, локальные переменные / кадры стека вызовов или статические поля - то есть все, что либо используется прямо сейчас, либо доступно сразу) как живые
  • Отметьте все объекты, которые достижимы из корней сборщика мусора, как живые; считайте все остальное мертвым.
  • Сделайте память, выделенную мертвыми объектами, снова доступной - например, вы можете просто пометить его как «доступный для будущих распределений» или сжать кучу, переместив все живые объекты, чтобы не было пробелов
  • Наконец, разморозьте все нити.

То, что здесь описывается, обычно называется GC Mark and Sweep, и это наиболее простая из возможных реализаций, но не самая эффективная. В частности, это подразумевает, что мы должны приостановить все для выполнения GC, поэтому коллекторы, имеющие такие паузы, также называются коллекторами Stop-the-World или STW - в отличие от непостоянные коллекционеры .

Pauseless не сильно отличаются от сборщиков STW с точки зрения того, как они решают проблему - они просто делают почти все одновременно с вашим кодом. И, очевидно, это сложно - если мы вернемся к нашей реальной аналогии с городами и дорогами, это будет похоже на попытку нанести на карту города, до которых можно добраться из аэропортов, при условии, что:

  • На самом деле у вас нет карты, но есть парк автомобилей, которыми вы управляете.
  • Пока эти автомобили едут, строятся новые города и дороги, а некоторые дороги разрушаются.

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

В переводе на наш не вымышленный случай это означает, что во время работы сборщика мусора любая операция записи указателя требует специальной проверки (барьер записи) - и она все равно замедлит ваш код.

Нет никакой черно-белой разницы между беспаузными и STW GC - это просто продолжительность пауз STW:

  • Как продолжительность паузы STW зависит от разных факторов? Например. ~ фиксировано или пропорционально размеру набора живых объектов (O (alive_set_size))?
  • Если эти паузы фиксированные, какова фактическая продолжительность? Если он достаточно крошечный для вашего конкретного случая, это то же самое, что сборщик мусора без пауз.
  • Если эти паузы не зафиксированы, можем ли мы убедиться, что они никогда не превышают максимально возможного?

Наконец, обратите внимание, что разные реализации GC могут оптимизироваться для разных факторов:

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

Хуже всего то, что вы явно не можете получить все это сразу, то есть разные реализации GC имеют свои преимущества и недостатки. Вот почему так сложно написать хороший тест для сборки мусора :)

Что такое GCBurn?

GCBurn - это тест, который мы создали для измерения наиболее важных показателей производительности сборщика мусора напрямую, то есть путем их фактического измерения, а не запроса счетчиков производительности или API, предоставляемых средой выполнения.

Прямые измерения дают несколько преимуществ:

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

Во-вторых, GCBurn предназначен для сравнения яблок с яблоками, т. Е. Все его тесты выполняют почти одинаковую последовательность действий / распределения - полагаясь на идентичные распределения, одинаковые генераторы случайных чисел. и т. д. и т. д. Вот почему ожидается, что его результаты для разных языков и фреймворков будут напрямую сопоставимы.

GCBurn выполняет два теста:

Пиковая пропускная способность распределения («Тест скорости»)

Цель здесь состоит в том, чтобы измерить пиковую скорость выделения пакетов - скорость выделения памяти, предполагая, что ничто другое (в частности, сборщик мусора) не замедляет ее.

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

GCBurn Test

Это более сложное тестовое измерение:

  • Пиковая устойчивая пропускная способность выделения (объектов в секунду, байтов в секунду) - т. е. пропускная способность, измеренная за относительно длительный период времени, при условии, что мы выделять, удерживать и в конечном итоге освобождать каждый объект, и что размеры и продолжительность удержания для этих объектов соответствуют распределениям, близким к реальным.
  • Распределение частоты и продолжительности пауз потоков, вызванных GC - 50% процентиль (p50), p95, p99, p99.9, p99.99 + минимальное, максимальное и среднее значения.
  • Распределение частоты и продолжительности STW (глобальных) пауз, вызванных GC

Вот как работает тест:

  • Выделите «статический набор» желаемого размера (я объясню это дальше)
  • Запустить T потоков / горутин, где каждый поток:
  • Выделяет объекты (фактически, массивы / фрагменты int64-s) в соответствии с заранее созданным шаблоном распределения размера и времени жизни. По сути, шаблон представляет собой список кортежей из трех значений: (size, ~ floor (log10 (duration)), str (duration) [0] - ‘0’). Последние два значения кодируют «продолжительность удержания» - ее показатель степени и первую цифру в ее десятичном представлении в микросекундах. Это оптимизация, позволяющая «освободить» операцию достаточно эффективно и иметь временную сложность O (1) для каждого выделенного объекта - здесь мы торгуем небольшой точностью в обмен на скорость.
  • Каждые 16 выделений попробуйте освободить выделенные объекты, срок удержания которых уже истек.
  • Для каждой итерации цикла измерьте время, затраченное на текущую итерацию. Если это заняло более 10 микросекунд (обычно итерация должна занимать менее 0,1 микросекунды), предположим, что произошла пауза сборщика мусора, поэтому занесите время ее начала и окончания в список для этого потока.
  • Следите за количеством распределений и общим размером выделенных объектов. Остановитесь через D секунд.
  • Подождите, пока все потоки завершатся.

Когда вышеуказанная часть будет завершена, для каждого потока будет известно следующее:

  • количество выделений, которое он смог выполнить, а также их общий размер в байтах.
  • Список возникших пауз (интервалов пауз).

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

Зная все это, легко получить упомянутую выше статистику.

Теперь несколько важных деталей:

  • Я уже упоминал, что последовательность (шаблон) распределения создается заранее. Это происходит главным образом потому, что мы не хотим тратить циклы ЦП на генерацию набора случайных чисел для каждого распределения. Сгенерированная последовательность состоит из ~ 1M элементов ~ (размер, журнал (продолжительность)). См. BurnTester.TryInitialize (C # / Go), чтобы увидеть фактическую реализацию.
  • Каждый поток GCBurn использует одну и ту же последовательность, но начинается там со случайной точки. Когда он доходит до конца, он продолжается с начала последовательности.
  • Чтобы убедиться, что шаблон абсолютно идентичен для всех языков, мы используем собственный генератор случайных чисел (см. StdRandom.cs / std_random.go) - на самом деле это реализация minstd_rand из C ++ 11 портирован на C # и Go.
  • И в целом мы следим за тем, чтобы все используемые нами случайные значения были идентичны на разных платформах - точки начала потока в последовательности распределения, размеры и продолжительность в этой последовательности и т. Д.

Используемые нами распределения размера объекта и продолжительности удержания (см. Семплеры - C #, Go) должны быть максимально приближены к реальным:

Размер:

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

Продолжительность удержания:

  • Точно так же он состоит из 95% «уровня метода» + 4,9% «уровня запроса» + 0,1% длительности «длительного» удержания, где:
  • Продолжительность удержания на уровне метода соответствует абсолютному значению нормально распределенной переменной с средним значением = 0 микросекунд, stdDev = 0,1 микросекунды.
  • Продолжительность удержания на уровне запроса соответствует аналогичному распределению, но с stdDev = 100 мс (миллисекунды)
  • Продолжительность «долгоживущего» удержания соответствует нормальному распределению с средним значением = stdDev = 10 секунд.
  • Продолжительность удержания усечена, чтобы соответствовать диапазону [0… 1000 секунд].

И, наконец, статический набор - это набор объектов, следующих точно такому же распределению размеров, которое никогда не использовалось во время теста - другими словами, это наш живой набор. Если ваше приложение кэширует или хранит много данных в ОЗУ (или имеет некоторую утечку памяти), он будет большим. Точно так же он должен быть небольшим для простых приложений без сохранения состояния (например, простых веб-серверов / серверов API).

Если вы дочитаете до этого места, вы, вероятно, захотите увидеть результаты - и вот они:

Полученные результаты

Мы запускали test-all (или Test-All.bat в Windows) на множестве самых разных машин и выгружали результат в папку результатов.

Test-all запускает следующие тесты:

  • Тест пропускной способности распределения пиков («Тест скорости») - с использованием 1, 25%, 50%, 75% и 100% от макс. # потоков, система может работать параллельно. Так, например, для Core i7–8700K «100% потоков» = 12 потоков (6 ядер * 2 потока на ядро ​​с гиперпоточностью).
  • Тест GCBurn - для статического размера набора = 0 МБ, 1 МБ, 10%, 25%, 50% и 75% от общего объема ОЗУ на тестируемой машине и при использовании 100% макс. # потоков. Каждый тест длится 2 минуты.
  • GCBurn test - все те же настройки, что и в предыдущем случае, но с использованием 75% макс. # потоков, система может работать параллельно.
  • Наконец, он запускает все эти тесты в трех режимах для .NET - Server GC + SustainedLowLatency (режим, который вы, вероятно, будете использовать на своих производственных серверах), Server GC + Batch и Workstation. GC. Мы делаем то же самое для Go - единственная подходящая опция там GOGC, но мы не заметили никакой разницы после установки его на 50%: похоже, что Go в любом случае постоянно запускает GC ~ в этом тесте.

Итак, начнем. Вы также можете открыть Таблицу Google со всеми данными, которые я использовал для диаграмм здесь, а также папку Результаты на GitHub с необработанным тестовым выводом »(там гораздо больше цифр).

Напоминаем, что 1 операция в этом тесте = выделение 16-байтового объекта.

.NET явно превосходит Go on Burst:

  • Он не только в 3 раза (Ubuntu)… в 5 раз (Windows) быстрее в однопоточном тесте, но также лучше масштабируется с количеством потоков - увеличивая разрыв до 12 раз на 96-ядерном монстре m5. 24xlarge.
  • Выделение кучи в .NET очень дешево. Если вы посмотрите на числа, то на самом деле они всего в ~ 3-4 раза дороже, чем выделение стека: вы можете делать ~ 1 млрд простых вызовов в секунду на поток - против почти 0,3 млрд выделений.
  • Похоже, .NET Core немного быстрее в Windows, а Go, напротив, почти в 2 раза медленнее по сравнению с Linux.

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

.NET все еще быстрее в этом тесте, хотя разрыв здесь не так велик - он составляет 20… 50% в зависимости от ОС (меньше в Linux, больше в Windows) и статического размера набора.

Вы также можете заметить, что Go не смог пройти тесты «Размер статического набора = 50% RAM / 75% RAM» - он не прошел с OOM (Out of Memory). Запуск теста на 75% доступных ядер ЦП помог Go пройти тест «Статический набор = 50% ОЗУ», но он все равно не смог пройти его на 75%:

Увеличение продолжительности теста (результаты, представленные здесь для продолжительности 2 минуты) приводило к более надежному отказу Go в тесте «Статический набор = 50% RAM» - так что похоже, GC там просто не может с темпом распределения, если активный набор достаточно велик.

Кроме того, нет никаких существенных изменений между 100% и 75% прохождением теста ядра процессора.

Также очевидно, что оба распределителя плохо масштабируются с количеством ядер. Этот график подтверждает эту точку зрения:

Как видите, в ~ 5 раз больше ядер означает только 2,5-кратное ускорение.

Не похоже, что пропускная способность памяти является узким местом: ~ 70 M ops / sec. перевести в ~ 6,5 ГБ / сек., что составляет почти 10% доступной пропускной способности памяти на компьютере Core i7.

Также интересно, что Go начинает обгонять .NET в случае «Статический набор = 50% RAM». Вам интересно, почему?

Да, это совершенно постыдная часть для .NET:

  • Паузы Го здесь почти не видны - потому что они крошечные. Макс. пауза, которую я смог измерить, составила ~ 1,3 секунды - для сравнения .NET получил 125 секунд. STW пауза в том же тестовом примере.
  • Почти все паузы STW в Go на самом деле составляют менее миллисекунды. Если вы посмотрите более реалистичный тестовый пример (см., Например, этот файл), вы заметите, что статический набор 16 ГБ на ~ обычном 16-ядерном сервере подразумевает вашу самую длинную паузу = 50 мс (против 5 с для .NET) и 99,99 % пауз короче 7 мс (92 мс для .NET)!
  • Кажется, что время паузы STW линейно масштабируется с размером статического набора как для .NET, так и для Go. Но если мы сравним большие паузы, они будут примерно в 100 раз короче для Go.
  • Вывод: инкрементный сборщик мусора Go действительно работает; У параллельного сборщика мусора .NET - нет.

Хорошо, наверное, я тогда очень сильно напугал всех .NET-разработчиков - особенно если предположить, что я упомянул, что GCBurn разработан, чтобы быть ближе к реальной жизни. Ожидаете ли вы, что в .NET будут такие же паузы? Да и нет:

  • ~ 185 ГБ (это ~ 2 миллиарда объектов, и время паузы сборки мусора на самом деле зависит от этого числа, а не от размера рабочего набора в ГБ), статический набор намного превосходит то, что вы можете ожидать в реальной жизни. Скорее всего, даже статический набор на 16 ГБ намного превосходит то, что вы можете увидеть в любом хорошо спроектированном приложении.
  • Хорошо спроектированный на самом деле означает: исходя из представленных здесь выводов, ни один разработчик в здравом уме не будет создавать приложение .NET, полагающееся на статический набор с несколькими гигабайтами. Есть много способов преодолеть это ограничение, но в конечном итоге все они вынуждают вас хранить данные либо в огромных управляемых массивах, либо в неуправляемых буферах. .NET Core 2.1 - в частности, его ref Struct, Span ‹T› и Memory ‹T› значительно упрощают эти усилия. .
  • Кроме того, «хорошо спроектированный» также означает «без утечек памяти». Как вы могли заметить, при утечке ссылок в .NET ожидается, что у вас будут все более продолжительные паузы STW. Очевидно, что ваше приложение в конечном итоге выйдет из строя - но обратите внимание, что до того, как это произойдет, оно также может временно перестать отвечать - все из-за растущих пауз STW. И чем больше у вас будет дополнительной оперативной памяти, тем хуже будет.
  • Отслеживание макс. Время паузы сборщика мусора и размер рабочего набора сборщика мусора после второго поколения должны иметь решающее значение, чтобы убедиться, что вы не страдаете от увеличения p95-p99 из-за утечки памяти.

Будучи в душе разработчиком .NET, я действительно хочу, чтобы команда .NET Core быстрее решила проблему max_STW_pause_time = O (static_set_size). Если этого не сделать, разработчикам .NET придется прибегать к обходным путям, что на самом деле никуда не годится. И, наконец, даже тот факт, что он существует, будет служить препятствием для многих потенциальных приложений .NET - подумайте об IoT, робототехнике и других управляющих приложениях; высокочастотная торговля, игры или игровые серверы и т. д.

Что касается Go, то удивительно, насколько хорошо там решена эта проблема. И стоит отметить, что команда Go постоянно боролась с паузами STW, начиная с 2014 года, и в конечном итоге им удалось убрать все паузы O (alive_set_size) (как утверждает команда - по-видимому, тест не доказывает это, но, возможно, это просто потому, что GCBurn заходит слишком далеко, чтобы разоблачить это :)). В любом случае, если вас интересуют подробности того, как это там произошло, можно начать с этого поста: https://blog.golang.org/ismmkeynote

Я все еще спрашиваю себя, какой из этих двух вариантов я бы предпочел - то есть более быстрый распределитель .NET или крошечные паузы сборщика мусора Go. И, честно говоря, здесь я склоняюсь к Go - в основном потому, что производительность его распределителя по-прежнему неплохая, но паузы в 100 раз короче на больших кучах довольно привлекательны. Что касается OOM в больших кучах (или необходимости иметь в 2 раза больше памяти, чтобы избежать OOM) - ну, память дешевая. Хотя это может быть более важным, если вы запускаете несколько приложений Go на одном компьютере (например, настольные приложения и микросервисы).

Короче говоря, эта ситуация с паузами STW заставила меня позавидовать тому, что есть у разработчиков Go - наверное, впервые .

Хорошо, есть еще одна тема, которую нужно осветить, а именно, другие режимы сборки мусора в .NET (спойлер: они не спасают положение, но об этом все же стоит поговорить):

Серверный сборщик мусора в параллельном режиме (SustainedLowLatency или Interactive) обеспечивает наивысшую пиковую пропускную способность, хотя разница с пакетным режимом минимальна.

Стабильная пропускная способность также самая высокая в режиме Server GC + SLL. Server GC + Batch тоже очень близок, но GC Workstation просто не масштабируется с ростом размера статического набора.

И, наконец, паузы:

Мне пришлось добавить эту таблицу (из упомянутой Google Spreadsheet - см. Последний лист там), чтобы показать, что:

  • Сборщик мусора рабочей станции имеет меньшие паузы STW только при статическом размере набора ; выход за пределы 16 ГБ делает его все менее и менее привлекательным с этой точки зрения - и почти в 3 раза менее привлекательным для корпуса 48 ГБ по сравнению с режимом Server GC + Batch.
  • Интересно, что Server GC + Batch начинает опережать Server GC + SLL при размере статического набора ≥ 16 ГБ, то есть GC в пакетном режиме фактически имеет меньшие паузы, чем параллельный GC на больших кучах.
  • И, наконец, Server GC + SLL и Server GC + Batch на самом деле вполне сопоставимы по времени паузы. Т.е. concurrent GC в .NET явно не выполняет много функций одновременно, хотя на самом деле он может быть достаточно эффективным в нашем конкретном случае. Мы создаем статический набор перед основным тестом, поэтому, похоже, нет необходимости много перемещать - почти все, что должен делать GC, - это отмечать живые объекты, и это именно то, что параллельный GC должен делать одновременно. Так что, почему он производит почти такую ​​же постыдно долгую паузу, что и пакетный сборщик мусора, остается полной загадкой.
  • Вы можете спросить, почему мы не протестировали интерактивный режим Server GC + - на самом деле, мы это сделали, но не заметили существенной разницы с Server GC + SLL.

Выводы

.NET Core:

  • Имеет время паузы O (alive_object_count) STW для коллекций Gen2 - независимо от того, какой режим GC вы выберете. Очевидно, что эти паузы могут быть произвольными по длине - все зависит от размера вашего живого набора. Мы измерили 125 сек. пауза на куче ~ 200 ГБ.
  • Намного быстрее (в 3… 12 раз) при пакетном распределении - такое распределение действительно похоже на выделение стека в .NET.
  • Обычно на 20… 50% быстрее при длительных испытаниях пропускной способности. «Размер статического набора = 200 ГБ» был единственным случаем, когда продолжалось.
  • Вы никогда не должны использовать GC Workstation на серверах .NET Core - или, по крайней мере, вы должны точно знать, что ваш рабочий набор достаточно мал, чтобы это было полезно.
  • Серверный сборщик мусора в параллельном режиме (SustainedLowLatency или Interactive) кажется хорошим вариантом по умолчанию, хотя он не сильно отличается от пакетного режима, что на самом деле довольно удивительно.

Go:

  • У STW нет O (alive_object_count) пауз - точнее, кажется, что у него действительно есть O (alive_object_count) пауз, но они все равно в 100 раз короче, чем для .NET.
  • Практически любая пауза короче 1 мс; самый длинный, который мы видели, был 1,3 секунды. - на огромном живом комплекте ~ 200 Гб.
  • Он медленнее, чем .NET на тестах GCBurn. Windows + i7–8700K - это то место, где мы измерили наибольшую разницу - то есть, похоже, у Go есть некоторые проблемы с распределителем памяти в Windows.
  • Go не справлялся со случаем «статический набор = 75% ОЗУ» - никогда. Этот тест на Go всегда запускал OOM. Точно так же он надежно терпел неудачу в случае «статический набор = 50% ОЗУ», если вы запускали этот тест достаточно долго (2 мин. = ~ 50% вероятность отказа, 10 мин. - Я помню только один случай, когда он не работал). крушение). Похоже, сборщик мусора просто не успевает за темпами выделения ресурсов, и такие вещи, как «использовать только 75% ядер ЦП для выделения», не помогают. Не уверен, что это может быть важно в реальной жизни: GCBurn делает все, что делает GCBurn, и большинство приложений этого не делают. С другой стороны, устойчивая пропускная способность одновременного распределения обычно ниже, чем непараллельная пиковая пропускная способность, поэтому реальное приложение, производящее аналогичную нагрузку на распределение на многоядерной машине, не Выдумано.
  • Но даже принимая все это во внимание, на самом деле довольно спорно, что лучше делать, если ваш GC не успевает за темпами распределения: приостановить приложение на несколько минут или выйти из строя с OOM. Готов поспорить, большинство разработчиков предпочли бы второй вариант.

Оба:

  • Пиковая скорость распределения масштабируется ~ линейно с количеством ядер в тестах пакетного распределения.
  • С другой стороны, устойчивая параллельная пропускная способность обычно ниже, чем непараллельная пиковая пропускная способность , т. Е. Устойчивая одновременная пропускная способность плохо масштабируется ни для Go, ни для .NET. Похоже, дело не в пропускной способности памяти: совокупная скорость выделения может быть в 10 раз ниже доступной пропускной способности.

Заявление об ограничении ответственности и эпилог

  1. GCBurn предназначен для измерения нескольких очень конкретных показателей. Мы пытались приблизить его к реальной жизни в некоторых аспектах, но, очевидно, это не означает, что выводимые числа - это то, что вы должны ожидать в своем реальном приложении. Как и любой тест производительности, он предназначен для измерения экстремума того, что он должен измерять, и игнорировать почти все остальное. Так что, пожалуйста, не ждите от него большего :)
  2. Я понимаю, что методология спорна - честно говоря, здесь трудно найти что-либо, что не было бы спорным. Так что оставим мелкие проблемы в покое, если у вас есть идеи о том, почему было бы ужасно неправильно оценивать GC, как это сделали мы, оставьте свои комментарии. Я определенно буду счастлив обсудить это.
  3. Я уверен, что есть способы улучшить тест без значительного увеличения объема работы или кода. Если вы знаете, как это сделать, оставьте, пожалуйста, и комментарии, или просто внесите свой вклад.
  4. Точно так же сделайте то же самое, если вы обнаружите там ошибки.
  5. Я намеренно не акцентировал внимание на деталях реализации сборщика мусора (поколения, уплотнения и т. Д.). Эти детали, очевидно, важны, но об этом есть много сообщений, а также о современной сборке мусора в целом. И, к сожалению, почти нет сообщений о фактической производительности сборки мусора и распределения. Я хотел восполнить этот пробел.
  6. Если вы хотите перевести тест на какой-нибудь другой язык (например, Java) и написать похожий пост, это было бы просто потрясающе.

Что касается моей серии статей «Go vs C #», то следующий пост будет о среде выполнения и системе типов. И поскольку я не вижу необходимости писать несколько тысяч тестов LOC для этого, это не займет много времени - так что следите за обновлениями!

Переходите к части 3: компилятор, среда выполнения, система типов, модули и все остальное

P.S. Посмотрите мой новый проект: Stl.Fusion, библиотека с открытым исходным кодом для .NET Core и Blazor, стремящаяся стать вашим выбором №1 для приложений реального времени. Его единый государственный конвейер обновления действительно уникален и умопомрачен. И если вам понравился пост, не забудьте проголосовать за него :)