Я вращаю некоторые из самых центральных частей моей кодовой базы (механизм ECS) вокруг типа структуры данных, которую вы описали, хотя он использует меньшие непрерывные блоки (больше как 4 килобайта вместо 4 мегабайт).
Он использует двойной свободный список для выполнения операций вставки и удаления в постоянное время с одним свободным списком для свободных блоков, которые готовы к вставке (блоки, которые не являются полными), и списком без свободного доступа внутри блока для индексов в этом блоке. готов к исправлению после вставки.
Я расскажу о плюсах и минусах этой структуры. Давайте начнем с некоторых минусов, потому что есть ряд из них:
Cons
- Вставка пары сотен миллионов элементов в эту структуру занимает примерно в 4 раза больше, чем
std::vector
(чисто смежная структура). И я неплохо разбираюсь в микрооптимизациях, но концептуально еще предстоит проделать большую работу, поскольку в общем случае сначала нужно проверить свободный блок в верхней части списка свободных блоков, затем получить доступ к блоку и получить свободный индекс из блока. свободный список, напишите элемент в свободной позиции, а затем проверьте, заполнен ли блок, и вытолкните блок из списка свободных блоков, если это так. Это все еще операция с постоянным временем, но с гораздо большей постоянной, чем возврат к std::vector
.
- Это требует примерно вдвое больше времени при доступе к элементам с использованием шаблона произвольного доступа, учитывая дополнительную арифметику для индексации и дополнительный уровень косвенности.
- Последовательный доступ не отображается эффективно на дизайн итератора, поскольку итератор должен выполнять дополнительное ветвление при каждом увеличении.
- Он имеет немного памяти, обычно около 1 бита на элемент. 1 бит на элемент может показаться не таким уж большим, но если вы используете это для хранения миллиона 16-битных целых чисел, это потребляет на 6,25% больше памяти, чем идеально компактный массив. Однако на практике это имеет тенденцию использовать меньше памяти, чем,
std::vector
если вы не сжимаете, vector
чтобы устранить избыточную емкость, которую он резервирует. Также я обычно не использую его для хранения таких маленьких элементов.
Pros
- Последовательный доступ с использованием
for_each
функции, которая принимает диапазоны обработки обратных вызовов элементов в блоке, почти конкурирует со скоростью последовательного доступа std::vector
(только как разность 10%), поэтому он не намного менее эффективен в наиболее критичных для меня случаях использования для меня ( большая часть времени, проведенного в двигателе ECS, находится в последовательном доступе).
- Это позволяет выполнять постоянное удаление из середины со структурой, освобождающей блоки, когда они становятся полностью пустыми. В результате обычно достаточно убедиться, что структура данных никогда не использует значительно больше памяти, чем необходимо.
- Он не делает недействительными индексы для элементов, которые непосредственно не удаляются из контейнера, поскольку он просто оставляет дыры позади, используя подход со свободным списком, чтобы исправить эти дыры при последующей вставке.
- Вам не нужно сильно беспокоиться об исчерпании памяти, даже если эта структура содержит эпическое количество элементов, поскольку она запрашивает только небольшие непрерывные блоки, которые не создают проблем для ОС, чтобы найти огромное количество смежных неиспользованных страницы.
- Он хорошо подходит для параллелизма и безопасности потоков, не блокируя всю структуру, поскольку операции обычно локализуются в отдельных блоках.
Теперь одним из самых больших плюсов для меня было то, что стало тривиально сделать неизменную версию этой структуры данных, например:
С тех пор это открыло все виды возможностей для написания большего количества функций, лишенных побочных эффектов, которые значительно облегчили достижение исключительной безопасности, поточной безопасности и т. Д. Я обнаружил, что неизменность была своего рода вещью, с которой я мог легко достичь эта структура данных задним числом и случайно, но, возможно, одно из самых приятных преимуществ, которые она в итоге получила, поскольку она значительно упростила поддержку кодовой базы.
Несмежные массивы не имеют локальности кэша, что приводит к снижению производительности. Однако при размере блока 4M кажется, что места будет достаточно для хорошего кэширования.
Не стоит беспокоиться о локальности ссылок для блоков такого размера, не говоря уже о блоках по 4 килобайта. Строка кэша обычно всего 64 байта. Если вы хотите уменьшить количество кешей, просто сконцентрируйтесь на правильном выравнивании этих блоков и, по возможности, предпочтите более последовательные шаблоны доступа.
Очень быстрый способ превратить шаблон памяти с произвольным доступом в последовательный - использовать набор битов. Допустим, у вас есть множество индексов, и они в случайном порядке. Вы можете просто просматривать их и отмечать биты в наборе битов. Затем вы можете перебирать свой набор битов и проверять, какие байты отличны от нуля, проверяя, скажем, 64-битные за раз. Как только вы встретите набор из 64 битов, из которых установлен хотя бы один бит, вы можете использовать инструкции FFS, чтобы быстро определить, какие биты установлены. Эти биты сообщают вам, к каким индексам вы должны обращаться, за исключением того, что теперь вы получаете индексы, отсортированные в последовательном порядке.
Это имеет некоторые накладные расходы, но в некоторых случаях может быть полезным обменом, особенно если вы собираетесь многократно повторять эти индексы.
Доступ к элементу не так прост, есть дополнительный уровень косвенности. Будет ли это оптимизировано? Это может вызвать проблемы с кешем?
Нет, это нельзя оптимизировать. По крайней мере, с этой структурой произвольный доступ всегда будет стоить дороже. Это часто не увеличит количество пропущенных кешем, хотя, поскольку вы будете стремиться получить высокую временную локальность с массивом указателей на блоки, особенно если в ваших общих путях исполнения используются шаблоны последовательного доступа.
Поскольку после достижения предела 4 МБ наблюдается линейный рост, вы можете иметь гораздо больше выделений, чем обычно (скажем, максимум 250 выделений на 1 ГБ памяти). Никакая дополнительная память не копируется после 4M, однако я не уверен, что дополнительные выделения дороже, чем копирование больших кусков памяти.
На практике копирование часто происходит быстрее, потому что это редкий случай, происходящий только как log(N)/log(2)
общее количество раз, одновременно упрощая простой дешевый общий случай, когда вы можете просто записать элемент в массив много раз, прежде чем он заполнится и его нужно будет перераспределить снова. Поэтому, как правило, вы не будете получать более быстрые вставки с такой структурой, потому что работа с общим случаем обходится дороже, даже если она не должна иметь дело с этим дорогим редким случаем перераспределения огромных массивов.
Основная привлекательность этой структуры для меня, несмотря на все недостатки, заключается в уменьшении использования памяти, не нужно беспокоиться об OOM, возможности хранить индексы и указатели, которые не становятся недействительными, параллелизма и неизменности. Приятно иметь структуру данных, в которую вы можете вставлять и удалять объекты в постоянное время, пока она очищается для вас и не делает недействительными указатели и индексы в структуре.