Являются ли несмежные массивы быстродействующими?


12

В C #, когда пользователь создает List<byte>и добавляет к нему байты, есть вероятность, что ему не хватает места и ему нужно выделить больше места. Он выделяет двойной (или некоторый другой множитель) размер предыдущего массива, копирует байты и сбрасывает ссылку на старый массив. Я знаю, что список растет в геометрической прогрессии, потому что каждое распределение является дорогостоящим, и это ограничивает его O(log n)распределениями, где простое добавление 10дополнительных элементов каждый раз приведет к O(n)распределению.

Однако для больших размеров массивов может быть много потерянного пространства, может быть, почти половина массива. Чтобы уменьшить объем памяти, я написал аналогичный класс, NonContiguousArrayListкоторый использует List<byte>в качестве резервного хранилища, если в списке меньше 4 МБ, тогда он будет выделять дополнительные байтовые массивы 4 МБ по мере увеличения их NonContiguousArrayListразмера.

В отличие от List<byte>этих массивов, они не являются смежными, поэтому нет необходимости копировать данные, только дополнительное выделение 4M. При поиске элемента индекс делится на 4M для получения индекса массива, содержащего элемент, а затем по модулю 4M для получения индекса в массиве.

Можете ли вы указать на проблемы с этим подходом? Вот мой список:

  • Несмежные массивы не имеют локальности кэша, что приводит к снижению производительности. Однако при размере блока 4M кажется, что места будет достаточно для хорошего кэширования.
  • Доступ к элементу не так прост, есть дополнительный уровень косвенности. Будет ли это оптимизировано? Это может вызвать проблемы с кешем?
  • Поскольку после достижения предела 4 МБ наблюдается линейный рост, вы можете иметь гораздо больше выделений, чем обычно (скажем, максимум 250 выделений на 1 ГБ памяти). Никакая дополнительная память не копируется после 4M, однако я не уверен, что дополнительные выделения дороже, чем копирование больших кусков памяти.

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

3
Если вы работаете с более чем 4 миллионами элементов в одной коллекции, я ожидаю, что микрооптимизация контейнера - это наименьшая из ваших проблем с производительностью.
Теластин

2
То, что вы описываете, похоже на развернутый связанный список (с очень большими узлами). Ваше утверждение, что они не имеют локальности кэша, немного неверно. Только большая часть массива помещается в одну строку кэша; скажем, 64 байта. Таким образом, каждые 64 байта вы будете иметь промах кэша. Теперь рассмотрим развернутый связанный список, узлы которого кратны 64 байтам (включая заголовок объекта для сборки мусора). Вы по-прежнему получаете только одну потерю кэша каждые 64 байта, и даже не имеет значения, что узлы не соседствуют в памяти.
Довал

@Doval Это на самом деле не развернутый связанный список, поскольку блоки 4M хранятся в массиве сами, поэтому доступ к любому элементу - это O (1), а не O (n / B), где B - размер блока.

2
@ user2313838 Если бы было 1000 МБ памяти и массив 350 МБ, объем памяти, необходимый для увеличения массива, составил бы 1050 МБ, что больше, чем доступно, и это главная проблема, ваш эффективный лимит составляет 1/3 вашего общего пространства. TrimExcessпоможет только тогда, когда список уже создан, и даже тогда ему все еще требуется достаточно места для копии.
noisecapella

Ответы:


5

В упомянутых вами масштабах проблемы совершенно отличаются от тех, о которых вы упоминали.

Cache locality

  • Есть две взаимосвязанные концепции:
    1. Локальность, повторное использование данных в той же строке кэша (пространственная локальность), которую недавно посетили (временная локальность)
    2. Автоматическая предварительная выборка кэша (потоковая передача).
  • В упомянутых вами масштабах (от сотен мегабайт до гигабайта, в 4-мегабайтных блоках) эти два фактора больше связаны с вашим шаблоном доступа к элементу данных, чем с макетом памяти.
  • Мой (невежественный) прогноз состоит в том, что статистически не может быть большой разницы в производительности, чем гигантское непрерывное распределение памяти. Нет выгоды, нет потерь.

Схема доступа к элементу данных

  • Эта статья наглядно демонстрирует, как шаблоны доступа к памяти влияют на производительность.
  • Короче говоря, просто имейте в виду, что если ваш алгоритм уже ограничен пропускной способностью памяти, единственный способ повысить производительность - это сделать более полезную работу с данными, уже загруженными в кэш.
  • Другими словами, даже если YourList[k]и YourList[k+1]с высокой вероятностью быть последовательным (один из четырех миллионов шансов не быть последовательным), этот факт не поможет производительности, если вы получите доступ к своему списку совершенно случайно или большими непредсказуемыми шагами, напримерwhile { index += random.Next(1024); DoStuff(YourList[index]); }

Взаимодействие с системой GC

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

Затраты на вычисления смещения адреса

  • Типичный код C # уже выполняет много вычислений смещения адресов, поэтому дополнительные издержки вашей схемы не будут хуже, чем типичный код C #, работающий с одним массивом.
    • Помните, что код C # также выполняет проверку диапазона массива; и этот факт не мешает C # достичь сопоставимой производительности обработки массива с помощью кода C ++.
    • Причина в том, что производительность в основном ограничена пропускной способностью памяти.
    • Хитрость в максимизации полезности от пропускной способности памяти заключается в использовании SIMD-инструкций для операций чтения / записи в памяти. Ни типичный C #, ни типичный C ++ не делают этого; Вы должны прибегнуть к библиотекам или языковым дополнениям.

Для иллюстрации почему:

  • Делать адресный расчет
  • (В случае с OP, загрузите базовый адрес блока (который уже находится в кеше), а затем выполните дополнительные вычисления адреса)
  • Чтение из / запись по адресу элемента

Последний шаг все еще занимает львиную долю времени.

Личное предложение

  • Вы можете предоставить CopyRangeфункцию, которая будет вести себя как Array.Copyфункция, но будет работать между двумя вашими экземплярами NonContiguousByteArrayили между одним экземпляром и другим нормальным byte[]. эти функции могут использовать код SIMD (C ++ или C #) для максимального использования пропускной способности памяти, и тогда ваш код C # может работать в скопированном диапазоне без дополнительных затрат на множественную разыменование или вычисление адреса.

Проблемы юзабилити и совместимости

  • Очевидно, вы не можете использовать это NonContiguousByteArrayс любыми библиотеками C #, C ++ или иностранного языка, которые ожидают непрерывные байтовые массивы или байтовые массивы, которые могут быть закреплены.
  • Однако, если вы напишите свою собственную библиотеку ускорения C ++ (с помощью P / Invoke или C ++ / CLI), вы можете передать список базовых адресов нескольких блоков по 4 МБ в базовый код.
    • Например, если вам нужно предоставить доступ к элементам, начинающимся с (3 * 1024 * 1024)и заканчивающимся на (5 * 1024 * 1024 - 1), это означает, что доступ будет охватывать chunk[0]и chunk[1]. Затем вы можете создать массив (размер 2) байтовых массивов (размер 4М), закрепить эти адреса чанков и передать их в базовый код.
  • Другой проблемой является удобство и простота использования , что вы не будете в состоянии реализовать IList<byte>эффективно интерфейс: Insertи Removeпросто слишком долго , чтобы процесс , потому что они требуют O(N)времени.
    • На самом деле, похоже, что вы не можете реализовать ничего, кроме того IEnumerable<byte>, что его можно сканировать последовательно и все.

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

6

Стоит отметить, что C ++ уже имеет эквивалентную структуру по стандарту std::deque. В настоящее время это рекомендуется в качестве выбора по умолчанию для необходимости последовательности вещей с произвольным доступом.

Реальность такова, что непрерывная память почти полностью не нужна, когда данные проходят определенный размер: строка кэша занимает всего 64 байта, а размер страницы составляет всего 4-8 КБ (типичные значения в настоящее время). Как только вы начинаете говорить о нескольких МБ, это становится проблемой. То же самое относится и к стоимости размещения. Цена на обработку всех этих данных, даже если их просто прочитать, в любом случае превосходит цену распределения.

Единственная причина беспокоиться об этом - это взаимодействие с C API. Но вы все равно не можете получить указатель на буфер списка, так что здесь нет проблем.


Это интересно, я не знал, что dequeбыла похожая реализация
noisecapella

Кто сейчас рекомендует std :: deque? Можете ли вы предоставить источник? Я всегда думал, что std :: vector был рекомендуемым выбором по умолчанию.
Teimpz

std::dequeна самом деле очень обескуражен, отчасти потому, что реализация стандартной библиотеки MS очень плохая.
Себастьян Редл

3

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

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

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

Дополнительные выделения являются проблемой только в том случае, если ваши куски под-массива малы, поскольку при каждом выделении массива возникают накладные расходы памяти.

Я создал аналогичные структуры для словарей (хэш-таблиц). Словарь, предоставляемый платформой .net, имеет ту же проблему, что и List. Словари сложнее в том смысле, что вам также нужно избегать перефразирования.


Уплотняющий коллектор мог компактировать куски рядом друг с другом.
DeadMG

@DeadMG Я имел в виду ситуацию, когда это не может произойти: между ними есть другие куски, которые не являются мусором. С List <T>, вам гарантируется непрерывная память для вашего массива. При использовании чанкованного списка память является смежной только в чанке, если только у вас нет удачной ситуации сжатия, о которой вы упомянули. Но сжатие также может потребовать перемещения большого количества данных, и большие массивы попадают в кучу больших объектов. Это сложно.
Фрэнк Хилман

2

При размере блока 4M даже один блок не может быть непрерывным в физической памяти; это больше, чем типичный размер страницы виртуальной машины. Местность не имеет значения в таком масштабе.

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


Компактные ГХ не содержат фрагментов.
DeadMG

Это правда, но сжатие LOH доступно только с .NET 4.5, если я правильно помню.
user2313838

Сжатие кучи также может повлечь за собой больше издержек, чем стандартное копирование при перераспределении List.
user2313838

В любом случае, достаточно большой и подходящего размера объект практически не фрагментирован.
DeadMG

2
@DeadMG: Истинная проблема с уплотнением GC (с этой схемой 4MB) заключается в том, что он может тратить бесполезное время, копаясь вокруг этих 4MB beefcakes. В результате это может привести к большим паузам GC. По этой причине при использовании этой схемы 4 МБ важно отслеживать жизненно важную статистику GC, чтобы видеть, что она делает, и предпринимать корректирующие действия.
rwong

1

Я вращаю некоторые из самых центральных частей моей кодовой базы (механизм ECS) вокруг типа структуры данных, которую вы описали, хотя он использует меньшие непрерывные блоки (больше как 4 килобайта вместо 4 мегабайт).

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

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

Я расскажу о плюсах и минусах этой структуры. Давайте начнем с некоторых минусов, потому что есть ряд из них:

Cons

  1. Вставка пары сотен миллионов элементов в эту структуру занимает примерно в 4 раза больше, чем std::vector(чисто смежная структура). И я неплохо разбираюсь в микрооптимизациях, но концептуально еще предстоит проделать большую работу, поскольку в общем случае сначала нужно проверить свободный блок в верхней части списка свободных блоков, затем получить доступ к блоку и получить свободный индекс из блока. свободный список, напишите элемент в свободной позиции, а затем проверьте, заполнен ли блок, и вытолкните блок из списка свободных блоков, если это так. Это все еще операция с постоянным временем, но с гораздо большей постоянной, чем возврат к std::vector.
  2. Это требует примерно вдвое больше времени при доступе к элементам с использованием шаблона произвольного доступа, учитывая дополнительную арифметику для индексации и дополнительный уровень косвенности.
  3. Последовательный доступ не отображается эффективно на дизайн итератора, поскольку итератор должен выполнять дополнительное ветвление при каждом увеличении.
  4. Он имеет немного памяти, обычно около 1 бита на элемент. 1 бит на элемент может показаться не таким уж большим, но если вы используете это для хранения миллиона 16-битных целых чисел, это потребляет на 6,25% больше памяти, чем идеально компактный массив. Однако на практике это имеет тенденцию использовать меньше памяти, чем, std::vectorесли вы не сжимаете, vectorчтобы устранить избыточную емкость, которую он резервирует. Также я обычно не использую его для хранения таких маленьких элементов.

Pros

  1. Последовательный доступ с использованием for_eachфункции, которая принимает диапазоны обработки обратных вызовов элементов в блоке, почти конкурирует со скоростью последовательного доступа std::vector(только как разность 10%), поэтому он не намного менее эффективен в наиболее критичных для меня случаях использования для меня ( большая часть времени, проведенного в двигателе ECS, находится в последовательном доступе).
  2. Это позволяет выполнять постоянное удаление из середины со структурой, освобождающей блоки, когда они становятся полностью пустыми. В результате обычно достаточно убедиться, что структура данных никогда не использует значительно больше памяти, чем необходимо.
  3. Он не делает недействительными индексы для элементов, которые непосредственно не удаляются из контейнера, поскольку он просто оставляет дыры позади, используя подход со свободным списком, чтобы исправить эти дыры при последующей вставке.
  4. Вам не нужно сильно беспокоиться об исчерпании памяти, даже если эта структура содержит эпическое количество элементов, поскольку она запрашивает только небольшие непрерывные блоки, которые не создают проблем для ОС, чтобы найти огромное количество смежных неиспользованных страницы.
  5. Он хорошо подходит для параллелизма и безопасности потоков, не блокируя всю структуру, поскольку операции обычно локализуются в отдельных блоках.

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

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

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

Несмежные массивы не имеют локальности кэша, что приводит к снижению производительности. Однако при размере блока 4M кажется, что места будет достаточно для хорошего кэширования.

Не стоит беспокоиться о локальности ссылок для блоков такого размера, не говоря уже о блоках по 4 килобайта. Строка кэша обычно всего 64 байта. Если вы хотите уменьшить количество кешей, просто сконцентрируйтесь на правильном выравнивании этих блоков и, по возможности, предпочтите более последовательные шаблоны доступа.

Очень быстрый способ превратить шаблон памяти с произвольным доступом в последовательный - использовать набор битов. Допустим, у вас есть множество индексов, и они в случайном порядке. Вы можете просто просматривать их и отмечать биты в наборе битов. Затем вы можете перебирать свой набор битов и проверять, какие байты отличны от нуля, проверяя, скажем, 64-битные за раз. Как только вы встретите набор из 64 битов, из которых установлен хотя бы один бит, вы можете использовать инструкции FFS, чтобы быстро определить, какие биты установлены. Эти биты сообщают вам, к каким индексам вы должны обращаться, за исключением того, что теперь вы получаете индексы, отсортированные в последовательном порядке.

Это имеет некоторые накладные расходы, но в некоторых случаях может быть полезным обменом, особенно если вы собираетесь многократно повторять эти индексы.

Доступ к элементу не так прост, есть дополнительный уровень косвенности. Будет ли это оптимизировано? Это может вызвать проблемы с кешем?

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

Поскольку после достижения предела 4 МБ наблюдается линейный рост, вы можете иметь гораздо больше выделений, чем обычно (скажем, максимум 250 выделений на 1 ГБ памяти). Никакая дополнительная память не копируется после 4M, однако я не уверен, что дополнительные выделения дороже, чем копирование больших кусков памяти.

На практике копирование часто происходит быстрее, потому что это редкий случай, происходящий только как log(N)/log(2)общее количество раз, одновременно упрощая простой дешевый общий случай, когда вы можете просто записать элемент в массив много раз, прежде чем он заполнится и его нужно будет перераспределить снова. Поэтому, как правило, вы не будете получать более быстрые вставки с такой структурой, потому что работа с общим случаем обходится дороже, даже если она не должна иметь дело с этим дорогим редким случаем перераспределения огромных массивов.

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

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.