Хранит ли c# массивы размером более 512 лонгов (4096 байт) по-разному?

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

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

Size - Time diagram for <code>List<T>.Add</code>

теперь мой бенчмарк вставит random 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;
    // ...
}

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

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

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

enter image description here

2 ответов


Хорошо, давайте сначала посмотрим на простые части картины. Всплески вызваны перераспределением, копированием и сбором мусора-здесь нет ничего удивительного. Аномально низкие времена для нескольких первых добавлений к списку легко объясняются локальностью кэша - в то время как куча все еще вписывается в память целиком, доступ к памяти может быть случайным, при этом все еще очень низкая латентность. Как только куча становится достаточно большой, а значение длины массива (а также Значение подсчета списка) получает достаточно далеко от при вставке значения локальность кэша становится заметным эффектом-при тестировании на моей машине в 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 ссылка хранится в стеке. Оба!--9--> и 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

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

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

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

это немного wordier в сборке :)

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

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

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

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

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

  1. добавить первые 550 пунктов
  2. добавить еще 100 предметы
  3. и еще 100 пунктов

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

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

но что может быть причиной окружающей среды?

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

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

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

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

списки, предварительно выделенные для 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 MiB общего использования кэша-скорее всего, результат того, что я пренебрегаю какой-то важной вещью, которая также нуждается в кэшировании, или некоторой оптимизации на части ОС или процессора, чтобы предотвратить меня от захвата всего кэша или что-то :)

небольшая нелинейность в очень маленьком списке подсчеты, скорее всего, связаны с медленным переполнением кэша нижнего уровня-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). Поскольку нет изменения размера массива, ни один из этих объектов не должен перемещаться в памяти вообще, и есть хорошая коллокация.


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

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

однако:

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

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

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

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