Чи є безперервними масиви?


12

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

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

На відміну від List<byte>цих масивів не є суміжними, тому немає копіювання даних навколо, лише додатковий розподіл 4 М. Коли елемент шукається вгору, індекс ділиться на 4М, щоб отримати індекс масиву, що містить елемент, а потім модуль 4М, щоб отримати індекс всередині масиву.

Чи можете ви вказати на проблеми з таким підходом? Ось мій список:

  • У безперервних масивах немає кеш-локації, що призводить до поганої продуктивності. Однак при розмірі блоку 4M, схоже, буде достатньо місця для хорошого кешування.
  • Доступ до елемента не такий простий, є додатковий рівень непрямості. Це оптимізуватиметься? Чи це призведе до проблем із кешем?
  • Оскільки після досягнення межі 4M спостерігається лінійний ріст, у вас може бути набагато більше виділень, ніж зазвичай (скажімо, максимум 250 виділень на 1 ГБ пам'яті). Після 4М жодна зайва пам'ять не копіюється, проте я не впевнений, що додаткові асигнування дорожчі, ніж копіювання великих шматів пам'яті.

8
Ви вичерпали теорію (врахував кеш, обговорили асимптотичну складність), все, що залишилося, - це підключити параметри (тут, 4М елементів на підпис) і, можливо, мікрооптимізувати. Зараз настав час для орієнтування, тому що без виправлення апаратного забезпечення та реалізації, є занадто мало даних для подальшого обговорення продуктивності.

3
Якщо ви працюєте з більш ніж 4 мільйонами елементів в одній колекції, я сподіваюся, що мікрооптимізація контейнерів - це найменша ступінь вашої продуктивності.
Теластин

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

@Doval Це насправді не розкручений зв'язаний список, оскільки 4М фрагменти зберігаються в масиві, тому доступ до будь-якого елемента є O (1), а не O (n / B), де B - розмір блоку.

2
@ user2313838 Якщо в пам'яті було 1000 МБ пам'яті та масиві 350 МБ, потрібна пам'ять для нарощування масиву була б на 1050 Мб, що більше, ніж наявна, це головна проблема, ваш ефективний ліміт - 1/3 вашої загальної площі. TrimExcessдопоможе лише тоді, коли список вже створений, і навіть тоді він ще потребує достатньо місця для копії.
noisecapella

Відповіді:


5

У згаданих вами масштабах стурбованість абсолютно відрізняється від згаданих вами.

Місце кеша

  • Є два пов'язані з цим поняття:
    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 ++ не роблять цього; ви повинні вдатися до бібліотек або мовних додатків.

Щоб проілюструвати, чому:

  • Робіть обчислення адреси
  • (У випадку ОП, завантажте базову адресу фрагмента (яка вже є в кеші), а потім зробіть більше обчислення адреси)
  • Читати з / записувати на адресу елемента

Останній крок все-таки займає левову частку часу.

Особиста пропозиція

  • Ви можете надати 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
Здається, ви пропустили головну перевагу структури даних, яка полягає в тому, що вона дозволяє створювати дуже великі списки, не втрачаючи пам'яті. Під час розширення списку <T> йому потрібен новий масив удвічі більший, ніж старий, і обидва повинні бути присутніми в пам'яті одночасно.
Френк Хілеман

6

Варто відзначити , що C ++ вже має еквівалентну структуру в відповідності зі стандартом, std::deque. Наразі він рекомендований як вибір за замовчуванням для необхідності послідовності з випадковим доступом до матеріалів.

Дійсність полягає в тому, що суміжна пам'ять майже зовсім непотрібна, коли дані проходять певний розмір - кеш-лінія становить всього 64 байти, а розмір сторінки - всього 4-8 КБ (типові значення на даний момент). Після того, як ви починаєте говорити про кілька МБ, це дійсно виходить з вікна, як стурбованість. Те саме стосується витрат на розподіл. Ціна на обробку всіх цих даних - навіть просто їх читання - все одно зменшує ціну розподілу.

Єдиний інший привід турбуватися про це - це взаємодія з API API. Але ви не можете отримати вказівник на буфер списку, так що тут немає ніякого занепокоєння.


Це цікаво, я не знав, що він dequeмав подібну реалізацію
noisecapella

Хто зараз рекомендує std :: deque? Чи можете ви надати джерело? Я завжди думав, що std :: вектор був рекомендованим вибором за замовчуванням.
Теймпз

std::dequeнасправді сильно не відштовхується, частково через те, що впровадження стандартної бібліотеки MS настільки погано.
Себастьян Редл

3

Коли шматки пам'яті розподіляються в різні моменти часу, як у підмасивах у вашій структурі даних, вони можуть розташовуватися далеко один від одного в пам'яті. Це проблема чи ні, залежить від процесора, і це дуже важко передбачити більше. Ви повинні це протестувати.

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

Основна перевага полягає в тому, що ви можете підходити ближче до верхньої межі пам’яті у вашій системі, якщо ви використовуєте ці типи структур послідовно. Поки ви збільшуєте структуру даних і не виробляєте сміття, ви уникаєте додаткових колекцій сміття, які б мали місце для звичайного Списку. Для гігантського списку це може внести величезні значення: різниця між продовженням запуску та втратою пам'яті.

Додаткові асигнування є проблемою лише в тому випадку, якщо ваші шматки підрядного масиву невеликі, оскільки в кожному виділенні масиву є накладні витрати.

Я створив подібні структури для словників (хеш-таблиць). Словник, наданий рамкою .net, має ту ж проблему, що і List. Словники складніше в тому, що вам потрібно також уникати повторних повторних розробок.


Ущільнювальний колектор міг ущільнювати шматки поруч.
DeadMG

@DeadMG Я мав на увазі ситуацію, коли цього не може статися: між ними є інші шматки, які не є сміттям. За допомогою списку <T> ви гарантуєте суміжну пам'ять для свого масиву. З відрізним списком пам’ять є суміжною лише в межах шматка, якщо тільки у вас не виникла ситуація, коли ви згадуєте. Але для ущільнення також може знадобитися переміщення безлічі даних навколо, а великі масиви потрапляють у Велику купу об’єктів. Це складно.
Френк Хілеман

2

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

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


Ущільнення GC не мають фрагментації.
DeadMG

Це правда, але ущільнення LOH доступне лише з .NET 4.5, якщо я правильно пам'ятаю.
user2313838

Ущільнення купи також може спричинити більше накладних витрат, ніж поведінка при копіюванні на перерозподіл стандарту List.
user2313838

Досить великий і відповідний розмір об'єкта в будь-якому випадку фактично не має фрагментації.
DeadMG

2
@DeadMG: Справжня стурбованість ущільненням GC (за допомогою цієї схеми 4MB) полягає в тому, що це може витрачати марний час на лопати навколо цих 4 МБ яловичих тортів. В результаті це може призвести до великих пауз ГК. З цієї причини, використовуючи цю схему 4 МБ, важливо відстежувати життєво важливу статистику ГК, щоб побачити, що вона робить, і вжити коригуючих дій.
rwong

1

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

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

Він використовує подвійний безкоштовний список для досягнення вставки та видалення постійного часу за допомогою одного вільного списку для вільних блоків, готових до вставки (блоки, які не є повними), і підроздільного списку всередині блоку для індексів у цьому блоці готовий бути поверненим після вставки.

Я висвітлю плюси і мінуси цієї структури. Почнемо з деяких мінусів, оскільки їх є декілька:

Мінуси

  1. Щоб вставити в цю структуру на пару сотень мільйонів елементів, потрібно std::vector(в чотири рази більше), ніж (чисто суміжна структура). І я досить пристойний у мікрооптимізаціях, але для цього є просто концептуально більше роботи, оскільки звичайний випадок повинен спочатку перевірити вільний блок у верхній частині вільного списку блоків, а потім отримати доступ до блоку та вивести безкоштовний індекс із блоку вільний список, напишіть елемент у вільній позиції, а потім перевірте, чи блок заповнений, і виведіть блок зі списку вільних блоків, якщо так. Це все ще операція постійного часу, але з набагато більшою константою, ніж відштовхування до std::vector.
  2. Це забирає приблизно вдвічі довший доступ до елементів за допомогою шаблону з випадковим доступом з урахуванням додаткової арифметики для індексації та додаткового шару непрямості.
  3. Послідовний доступ не відображає ефективно дизайн ітератора, оскільки ітератор повинен виконувати додаткові розгалуження кожного разу, коли він збільшується.
  4. У нього трохи пам'яті, зазвичай близько 1 біта на елемент. 1 біт на елемент може здатися не дуже схожим, але якщо ви використовуєте це для зберігання мільйона 16-бітних цілих чисел, то це на 6,25% більше використання пам'яті, ніж ідеально компактний масив. Однак на практиці це, як правило, використовує менше пам'яті, ніж std::vectorякщо ви не ущільнюєте, vectorщоб усунути зайву ємність, яку він резервує. Також я зазвичай не використовую його для зберігання таких підліткових елементів.

Плюси

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

Тепер одним з найбільших плюсів для мене було те, що це стало банальним зробити незмінну версію цієї структури даних, як це:

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

З тих пір це відкривало всі види дверей для написання додаткових функцій, позбавлених побічних ефектів, що полегшувало досягнення винятків - безпеку, безпеку ниток тощо. Незмінність - це щось, що я виявив, що я міг легко досягти ця структура даних заднім числом і випадково, але, мабуть, одна з найприємніших переваг, яку вона отримала, оскільки значно полегшила підтримку бази даних коду.

У безперервних масивах немає кеш-локації, що призводить до поганої продуктивності. Однак при розмірі блоку 4M, схоже, буде достатньо місця для хорошого кешування.

Місцевість довідників - це не те, чим слід займатися у блоках такого розміру, не кажучи вже про 4-кілобайтних блоках. Рядок кеша зазвичай становить всього 64 байти. Якщо ви хочете зменшити пропуски кешу, то просто зосередитесь на правильному вирівнюванні цих блоків та надайте перевагу більше послідовних моделей доступу, коли це можливо.

Дуже швидкий спосіб перетворити схему пам'яті з випадковим доступом у послідовну - використовувати біт. Скажімо, у вас є індекс індексу, і вони у випадковому порядку. Ви можете просто орати їх і позначати біти в бітах. Потім ви можете перебирати свій біт і перевіряти, які байти не нульові, перевіряючи, скажімо, 64-бітові одночасно. Як тільки ви зіткнетеся з набором 64 біт, з яких встановлено щонайменше один біт, ви можете скористатися інструкціями FFS для швидкого визначення того, які біти встановлені. Біти повідомляють вам, до яких індексів слід отримати доступ, за винятком випадків, коли ви отримуєте індекси, відсортовані в послідовному порядку.

Це має певні накладні витрати, але може бути корисним обміном у деяких випадках, особливо якщо ви збираєтеся перебирати ці показники багато разів.

Доступ до елемента не такий простий, є додатковий рівень непрямості. Це оптимізуватиметься? Чи це призведе до проблем із кешем?

Ні, його неможливо оптимізувати. Принаймні, випадковий доступ, принаймні, завжди буде коштувати дорожче з цією структурою. Це часто не збільшить ваш кеш-промах настільки сильно, хоча оскільки ви прагнете отримувати високу тимчасову локальність з масивом покажчиків на блоки, особливо якщо ваші загальні шляхи виконання випадків використовують послідовні схеми доступу.

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

На практиці копіювання часто швидше, тому що це рідкісний випадок, лише трапляється щось на кшталт log(N)/log(2)загального часу, одночасно спрощуючи звичайний випадок із забрудненням, коли ви можете просто записати елемент у масив багато разів, перш ніж він стане повним і його потрібно перерозподілити знову. Тому, як правило, ви не отримаєте швидших вставок із таким типом структури, тому що звичайна робота у випадку є дорожчою, навіть якщо їй не доведеться мати справу з тим дорогим рідкісним випадком перерозподілу величезних масивів.

Основна привабливість цієї структури для мене, незважаючи на всі мінуси, - це зменшення використання пам’яті, не турбуючись про OOM, можливість зберігати індекси та покажчики, які не стають недійсними, паралельністю та незмінністю. Приємно мати структуру даних, куди ви можете вставляти та видаляти речі за постійний час, поки вона сама очищає вас і не приводить до недійсності покажчиків та індексів у структурі.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.