Сохраняет ли С# массивы размером более 512 длин (4096 байт) по-разному?

Я сделал некоторые тесты с типами коллекций, реализованными в .NET Framework.

Из справочного источника я знаю, что List<T> использует массив для хранения содержимого. Чтобы избежать изменения размера массива при каждой вставке, длина массива удваивается каждый раз, когда свободное пространство заканчивается.

Размер - диаграмма времени для <code> List <T>.Add </code>

Теперь мой ориентир вставляет случайные значения long в List (см. рисунок выше для графика размера - времени). В размерах списков, таких как 128 или 256, есть очевидные "запаздывающие спайки", где необходимо перераспределить внутренний массив. Но при размере 512 (и 128, хотя?), Кажется, существует очень большое отставание, и время, затрачиваемое на вставку одного элемента, увеличивается.

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

Тесты были выполнены как 64-битное приложение на машине Windows 10/i7-3630QM (исходный код, как показано ниже). Поскольку одна операция добавления не измерима, я создаю 1000 списков и добавляю по одному элементу для каждого размера списка.

for (int i = 1; i <= MaxCollectionSize; i++)
{
    // Reset time measurement
    TestContainer.ResetSnapshot();

    // Enable time measurement
    TestContainer.BeginSnapshot();
    // Execute one add operation on 1000 lists each
    ProfileAction.Invoke(TestContainer);
    TestContainer.EndSnapShot();

    double elapsedMilliseconds = (TestContainer.GetElapsedMilliSeconds() / (double)Stopwatch.Frequency) * 1000;
    // ...
}

EDIT: я дважды проверял результаты и да, они воспроизводимы. Я увеличил количество коллекций, проверенных с 1000 до 10000, и теперь результат стал намного более плавным (см. Изображение ниже). Шипы от изменения размера внутреннего массива теперь хорошо видны. И все же шаги на графике остаются - это расхождение с ожидаемой сложностью O (1), которая должна быть вставкой массива, если вы игнорируете изменение размера.

Я также пытался запускать коллекцию GC перед каждой операцией Add, и график оставался точно таким же.

Что касается проблем создания объектов делегата: все мои делегаты (например, ProfileAction) - это свойства экземпляра, которые остаются назначенными в течение одного полного цикла тестирования, в этом случае 10000 списков с 1000 операций добавления.

введите описание изображения здесь

Ответы

Ответ 1

Хорошо, сначала посмотрим на простые части картины. Шипы вызваны перераспределением, копированием и сборкой мусора - нет большого сюрприза. Аномально низкие времена для нескольких первых дополнений к списку легко объясняются местностью кэш-памяти - в то время как куча все еще вписывается в память целиком, доступ к памяти может быть случайным, но при этом очень малой задержкой. Как только куча становится достаточно большой, а значение длины массива (а также значение счетчика списка) становится достаточно далеко от вставленного значения, локальность кэша становится заметным эффектом - при тестировании на моем компьютере в 32-разрядном коде x86, оптимизация для местоположения кеша улучшает производительность всего теста в четыре раза.

Однако, хотя эти эффекты хорошо объясняют как сами шипы, так и тот факт, что операции после каждого всплеска занимают больше времени, чем до всплеска, они действительно не объясняют следующую тенденцию - нет очевидной причины, почему вставка 600-го элемента должен занимать больше времени, чем вставить 550-й (при условии, что последний размер был равен 512 или около того). Профилирование прекрасно показывает, что постоянные издержки достаточно высоки, но со временем не заметно заметно увеличиваются.

Мой тестовый код обрезается до самых оснований:

var collections = new List<int>[100000];

for (var i = 0; i < collections.Length; i++)
{
  collections[i] = new List<int>();       
}

for (var i = 0; i < 1024; i++)
{
  for (var j = 0; j < collections.Length; j++)
  {
    collections[j].Add(i);
  }
}

Несмотря на то, что единственная абстракция, которая остается, - это сама Add, тренд все еще виден в тестовых данных, хотя я должен отметить, что моя кривая нигде не является такой же гладкой, как ваша, а отклонения огромны. Типичный цикл может занять около 20 мс, а шипы достигают 5 с.

Хорошо, пора посмотреть на разборку. Мой тестовый код очень прост (только тело внутреннего цикла):

002D0532  mov         eax,dword ptr [ebp-18h]  
002D0535  mov         ecx,dword ptr [eax+esi*4+8]  
002D0539  mov         edx,ebx  
002D053B  cmp         dword ptr [ecx],ecx  
002D053D  call        7311D5F0  

collections ссылка хранится в стеке. Оба i и j находятся в регистрах, как и ожидалось, и на самом деле j находится в esi, что очень удобно. Итак, сначала возьмем ссылку на collections, добавим j * 4 + 8, чтобы получить фактическую ссылку на список, и сохраните ее в ecx (this в методе, который мы собираемся вызывать). i хранится в ebx, но его нужно переместить в edx, чтобы вызвать Add - нет большой передачи значения между двумя регистрами общего назначения, хотя:) Тогда есть простая оптимистическая нулевая проверка и, наконец, называть себя.

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

Время для просмотра самого метода Add:) Помните, ecx содержит экземпляр списка, а edx - элемент, который мы добавляем.

Во-первых, там обычный метод пролога, ничего особенного. Затем мы проверим размер массива:

8bf1    mov esi, ecx
8bfa    mov edi, edx
8b460c  mov eax, DWORD PTR [esi+0xc]    ; Get the list size
8b5604  mov edx, DWORD PTR [esi+0x4]    ; Get the array reference
3bf204  cmp eax, DWORD PTR [edx+0x4]    ; size == array.Length?
741c    je HandleResize ; Not important for us

У нас есть еще три доступа к памяти. Первые два по существу идентичны, так как загружаемые значения располагаются достаточно близко. Массив будет располагаться только до первого изменения размера массива, что еще больше улучшит производительность кеша в первых нескольких вставках. Обратите внимание, что здесь не так много, что CPU может делать параллельно, но три обращения к памяти должны по-прежнему оплачивать только затраты времени ожидания. Ветвь почти всегда будет предсказана правильно - она ​​берется только после достижения размера массива, после чего мы делаем одну и ту же ветку один раз для каждого списка.

Остаются две части: добавление самого элемента и обновление внутренней версии списка (для отказа от текущих перечислений в списке):

_items[_size++] = item;
_version++;

Это немного словнее в сборке:)

8b5604  mov edx, DWORD PTR [esi+0x4]    ; get the array reference again
8b4e0c  mov ecx, DWORD PTR [esi+0xc]    ; ... and the list size
8d4101  lea eax, [ecx+0x1]  ; Funny, but the best way to get size + 1 :)
89460c  mov DWORD PTR [esi+0xc], eax    ; ... and store the new size back in the list object
3b4a04  cmp ecx, DWORD PTR [edx+0x4]    ; Array length check
7318    jae ThrowOutOfRangeException    ; If array is shorter than size, throw
897c8a08    mov DWORD PTR [edx+ecx*4+0x8], edi  ; Store item in the array
ff4610  inc DWORD PTR [esi+0x10]    ; Increase the version
; ... and the epilogue, not important

Что это. У нас есть ветвь, которая никогда не будет принята (при условии однопоточной, мы уже проверяем размер массива ранее). У нас довольно много доступа: четыре, которые относятся к самому списку (включая два обновления) и еще два в массиве (включая одно обновление). Теперь, пока нет причин для промаха в кеше в списке (он почти всегда уже загружен), из-за обновлений возникают недостоверности. Напротив, доступ к массиву всегда будет приводить к провалу кеша в нашем сценарии, за исключением того, что до первого изменения размера массива. Фактически, вы можете видеть, что вначале нет промаха в кеше (массив и объект, размещенный, маленький), затем одна промашка (все еще размещена, но элемент за пределами строки кэша), затем два (как длина, так и элемент доступа за пределами строки кэша).

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

Со всем этим в руке, я бы пришел к выводу, что List.Add действительно O (1), когда размер массива не требуется. Для очень маленьких массивов (и массивов, помеченных их refrence), есть несколько дополнительных оптимизаций, которые делают вещи быстрее, но это не важно здесь.

Таким образом, тренд, который вы видите в ваших профилирующих данных, должен быть либо экологическим, либо напрямую связан с самим профилированием, либо просто плохо выбранным методом усреднения. Например, если я запустил это на 100 000 списков:

  • Добавьте первые 550 элементов
  • Добавить еще 100 элементов
  • И еще 100 элементов

Существует разница между временем 2 и 3, но нет тенденции - это так же вероятно, как 2, чтобы быть быстрее, так как это для 3, чтобы быть быстрее (порядка разницы в 2 мс на временных интервалах ~ 400 мс, поэтому около 0,5% отклонения). И все же, если я сделаю "разминку" с 2100 предметами, последующие шаги займут почти половину времени, как раньше. Изменение количества списков не имеет заметного эффекта для каждой коллекции (если все вписывается в вашу физическую память, конечно:)).

Хорошо, это очень заметно даже при простом Stopwatch, запущенном за пределами отладчика в режиме деблокирования, и с простой выборкой данных результата. Поэтому мы можем исключить как профилирующие эффекты, так и статистические ошибки.

Но какова может быть экологическая причина?

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

Итак, глядя на все это... Я понятия не имею, почему существует тренд. Однако обратите внимание, что тренд определенно не является линейным: увеличение быстро уменьшается при увеличении размера списка. От примерно 15 тыс. Элементов на тренде исчезает полностью, поэтому Add - это действительно O (1), исключая размеры массива - у него просто какое-то странное поведение при некоторых размерах:)

... если вы не предварительно выделите списки. В этом случае результаты на 100% согласуются с моими прогнозами, основанными только на локальности кэш-памяти. Что, по-видимому, говорит о том, что модели изменения размера и GCing оказывают огромное влияние на эффективность обычных алгоритмов кеширования (по крайней мере, на моем процессоре - это будет сильно отличаться, я соглашусь). Помните, когда мы говорили о промахах кеша, которые были получены во время всей операции Add? Там есть трюк - если мы сможем поддерживать достаточное количество линий кэша между двумя циклами, промаха в кеше будет предотвращена очень часто; если мы предположим, что в 64-байтной строке кэша и алгоритмах оптимальной кэш-аннулирования вы не получите пропусков в доступе к членам списка и доступе к длине массива, а всего один промах на массив один раз в 16 добавляет. Нам совсем не нужна остальная часть массива! Вам понадобится еще несколько строк кеша (например, экземпляры списка), но массивы - самая большая сделка.

Теперь сделаем математику. Сто тысяч коллекций, 2 * 64B кеша, каждый в худшем случае, добавляет до 12 MiB, и у меня есть 10 MiB кеша на мне - я могу почти подобрать все соответствующие данные массива в кеше! Теперь, конечно, я не единственное приложение (и поток), использующее этот кеш, поэтому мы можем ожидать, что точка перевертывания будет несколько ниже идеального - посмотрим, как изменение количества коллекций меняет наши результаты.

Списки, предварительно выделенные для 8000 элементов (32 кБ), добавление 2000 элементов, 100, 100

Lists   A       B   C
400     18      1   1
800     52      2   2
1600    120     6   6
3200    250     12  12
6400    506     25  25
12800   1046    52  53
25600   5821    270 270

Ха! Довольно приятно. Тайминги красиво увеличиваются линейно с подсчетом списка, до последнего элемента - это, когда закончился наш кеш. Это где-то около 3-8 мегабайт общего использования кеша - скорее всего, это результат того, что я пренебрегаю какой-то важной вещью, которая также нуждается в кешировании, или некоторой оптимизации на части ОС или CPU, чтобы я не мог запугать весь кеш или что-то:)

Небольшая нелинейность в очень малых подсчетах списка, скорее всего, связана с медленным переполнением кэша более низкого уровня - 400 удобно помещается в моем кэше L2, 800 уже переполняется немного, 1600 - немного больше и к тому времени, когда мы достигнем 3200, кэш L2 можно пренебречь почти полностью.

И для нашей окончательной проверки, тот же сценарий, но добавив 4000 позиций вместо 2000:

Lists   A       B   C
400     42      1   1
800     110     3   2
1600    253     6   6
3200    502     12  12
6400    1011    25  25
12800   2091    52  53
25600   10395   250 250

Как вы можете видеть, количество элементов не влияет на время вставки (за элемент), вся тренда просто исчезла.

Итак, вот оно. Тенденция вызвана GC косвенно (через субоптимальное распределение в вашем коде и шаблоны уплотнения в GC, которые нарушают локальность кэш-памяти) и переполнение кеша напрямую. При меньших количествах элементов, скорее всего, любой данный кусок требуемой памяти будет находиться в кеше прямо сейчас. Когда массивы должны быть изменены, большая часть кэшированной памяти практически бесполезна и будет медленно отменена и заменена более полезной памятью, но весь шаблон использования памяти - это нечто очень далекое от того, для чего оптимизирован процессор. В отличие от этого, сохраняя предварительно распределенные массивы, мы гарантируем, что как только у нас будет список в памяти, мы также увидим длину массива (бонус 1), а строки кэша, уже указывающие на конец массива, будут полезны для нескольких циклов (бонус 2). Поскольку нет размера массива, ни один из этих объектов не должен вообще перемещаться в памяти, и там есть хороший colocation.

Ответ 2

Сохраняет ли С# массивы размером более 512 длин (4096 байт)?

Нет. Это происходит, когда общий размер (IIRC) 84kB или более: используется Большая куча объектов (которая не является уплотнением или поколением).

Однако:

создать 1000 списков и добавить по одному элементу для каждого размера списка.

Время отклика около ~ 5 мс для каждого теста. Дельта диспетчера Windows больше, чем это (фактические значения использовались от 40 мс до 100 мс в зависимости от версии и версии). Не могли бы вы видеть, как планировщик выполняет переключатель потока?

Предложите, чтобы вы попытались с каждым размером, работающим не менее 250 мс, чтобы выровнять эти эффекты.

РЕДАКТИРОВАТЬ. Также, как Лассе комментирует примечания к вопросу: это может быть GC. Чтобы устранить это из ваших таймингов, в начале цикла размера, но перед запуском часов заставьте GC. Также контролируйте счетчики производительности GC.