Яка ідеальна швидкість зростання для динамічно розподіленого масиву?


83

C ++ має std :: vector, а Java - ArrayList, а багато інших мов мають власну форму динамічно виділеного масиву. Коли в динамічному масиві не вистачає місця, він перерозподіляється на більшу область, а старі значення копіюються в новий масив. Основним питанням роботи такого масиву є те, як швидко масив збільшується в розмірі. Якщо ви завжди зростете лише настільки великим, щоб відповідати поточному поштовху, ви в кінцевому підсумку будете перерозподілятись кожного разу. Тому має сенс подвоїти розмір масиву або помножити його, скажімо, в 1,5 рази.

Чи є ідеальний фактор росту? 2x? 1,5 рази? Під ідеалом я маю на увазі математично виправдану, найкращу збалансовану продуктивність та марну пам’ять. Я усвідомлюю це теоретично, враховуючи, що ваша програма може мати будь-який потенційний розподіл поштовхів, що це певною мірою залежить від програми. Але мені цікаво дізнатись, чи є значення, яке "зазвичай" найкраще, або воно вважається найкращим за певних жорстких обмежень.

Я чув, що десь є папір про це, але мені не вдалося його знайти.

Відповіді:


43

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

Не існує такого поняття, як «ідеальний фактор росту». Це не просто теоретично залежить від програми, це, безумовно, залежить від програми.

2 є досить поширеним фактором зростання - я впевнений, що саме це ArrayListі List<T>використовується в .NET. ArrayList<T>в Java використовує 1.5.

EDIT: Як зазначає Еріх, Dictionary<,>у .NET використовується "подвійний розмір, а потім збільшити до наступного простого числа", щоб хеш-значення могли розумно розподілятися між сегментами. (Я впевнений, що нещодавно бачив документацію, яка стверджує, що прості числа насправді не такі чудові для розподілу хеш-сегментів, але це аргумент для іншої відповіді.)


102

Пам’ятаю, я багато років тому читав, чому 1,5 віддають перевагу над двома, принаймні, як застосовується до C ++ (це, мабуть, не стосується керованих мов, де система виконання може переміщати об’єкти за бажанням).

Міркування такі:

  1. Скажімо, ви починаєте з 16-байтового розподілу.
  2. Коли вам потрібно більше, ви виділяєте 32 байти, а потім звільняєте 16 байт. Це залишає 16-байтову діру в пам'яті.
  3. Коли вам потрібно більше, ви виділяєте 64 байти, звільняючи 32 байти. Це залишає 48-байтовий отвір (якщо 16 та 32 були суміжними).
  4. Коли вам потрібно більше, ви виділяєте 128 байт, звільняючи 64 байти. Це залишає 112-байтову діру (за умови, що всі попередні розподіли сусідні).
  5. І так, і так далі.

Ідея полягає в тому, що при розширенні в 2 рази немає сенсу в часі, коли отриманий отвір коли-небудь стане достатньо великим для повторного використання для наступного розподілу. Використовуючи розподіл 1,5x, ми маємо таке:

  1. Почніть з 16 байт.
  2. Коли вам потрібно більше, виділіть 24 байти, а потім звільніть 16, залишивши 16-байтову дірку.
  3. Коли вам потрібно більше, виділіть 36 байт, а потім звільніть 24, залишивши 40-байтовий отвір.
  4. Коли вам потрібно більше, виділіть 54 байти, а потім звільніть 36, залишивши 76-байтовий отвір.
  5. Коли вам потрібно більше, виділіть 81 байт, а потім звільніть 54, залишивши 130-байтовий отвір.
  6. Коли вам потрібно більше, використовуйте 122 байти (округлення вгору) із 130-байтового отвору.

5
Випадкове повідомлення на форумі, яке я знайшов ( objectmix.com/c/… ), спричиняє аналогічні причини. Плакат стверджує, що (1 + sqrt (5)) / 2 - це верхня межа повторного використання.
Naaff

19
Якщо це твердження вірне, тоді phi (== (1 + sqrt (5)) / 2) є справді оптимальним числом для використання.
Кріс Джестер-Янг

1
Мені подобається ця відповідь, тому що вона розкриває обгрунтування 1,5x проти 2x, але Джон є технічно найбільш правильним для мого висловлювання. Я повинен був просто запитати, чому раніше рекомендували 1,5: p
Джозеф Гарвін,

6
Facebook використовує 1,5 у своїй реалізації FBVector, стаття тут пояснює, чому 1,5 є оптимальним для FBVector.
csharpfolk

2
@jackmott Правильно, саме так, як зазначив мій відповідь: "це, ймовірно, не стосується керованих мов, де система виконання може переміщати об'єкти за бажанням".
Кріс Джестер-Янг,

47

В ідеалі (в межах, коли n → ∞), це золотий перетин : ϕ = 1,618 ...

На практиці потрібно щось близьке, наприклад 1.5.

Причиною є те, що ви хочете мати можливість повторно використовувати старі блоки пам'яті, скористатися кешуванням та уникати постійного змушення ОС давати більше сторінок пам’яті. Рівняння, яке ви вирішили б, щоб забезпечити це, зменшується до x n - 1 - 1 = x n + 1 - x n , рішення якого наближається до x = ϕ для великих n .


15

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

Отже, просто швидко перевіряючи, Ruby (1.9.1-p129), здається, використовує 1.5x при додаванні до масиву, а Python (2.6.2) використовує 1.125x плюс константу (in Objects/listobject.c):

/* This over-allocates proportional to the list size, making room
 * for additional growth.  The over-allocation is mild, but is
 * enough to give linear-time amortized behavior over a long
 * sequence of appends() in the presence of a poorly-performing
 * system realloc().
 * The growth pattern is:  0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...
 */
new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);

/* check for integer overflow */
if (new_allocated > PY_SIZE_MAX - newsize) {
    PyErr_NoMemory();
    return -1;
} else {
    new_allocated += newsize;
}

newsizeвище - кількість елементів у масиві. Добре зауважте, що newsizeдодається до new_allocated, тому вираз із бітовими зсувами та трикомпонентним оператором насправді просто обчислює перерозподіл.


Тож масив зростає з n до n + (n / 8 + (n <9? 3: 6)), що означає, що коефіцієнт зростання, за термінологією запитання, становить 1,25x (плюс константа).
ShreevatsaR

Чи не буде це 1,125x плюс константа?
Джейсон Крейтон,

10

Скажімо, ви збільшуєте розмір масиву на x. Тож припустімо, що ви починаєте з розміру T. Наступного разу, коли ви виростите масив, його розмір буде T*x. Тоді це буде T*x^2і так далі.

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

T*x^n <= T + T*x + T*x^2 + ... + T*x^(n-2)

Ми можемо видалити Т з обох сторін. Отже, ми отримуємо це:

x^n <= 1 + x + x^2 + ... + x^(n-2)

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

Наприклад, якщо ми хочемо мати змогу зробити це на 3-му кроці (тобто, n=3), то маємо

x^3 <= 1 + x 

Це рівняння справедливе для всіх x таких, що 0 < x <= 1.3(приблизно)

Подивіться, який x ми отримуємо для різних n, нижче:

n  maximum-x (roughly)

3  1.3

4  1.4

5  1.53

6  1.57

7  1.59

22 1.61

Зауважте, що коефіцієнт зростання повинен бути меншим, ніж 2з тих пір x^n > x^(n-2) + ... + x^2 + x + 1 for all x>=2.


Ви, схоже, стверджуєте, що вже можете повторно використовувати раніше вивільнену пам'ять під час другого розподілу з коефіцієнтом 1,5. Це неправда (див. Вище). Повідомте мене, якщо я вас неправильно зрозумів.
awx

На 2-му розподілі ви виділяєте 1,5 * 1,5 * T = 2,25 * T, тоді як загальний вивільнення, яке ви будете робити до цього часу, становить T + 1,5 * T = 2,5 * T. Отже, 2,5 перевищує 2,25.
CEGRD

Ах, мені слід прочитати уважніше; все, що ви говорите, це те, що загальна кількість виділеної пам'яті буде більше, ніж виділена пам'ять на n-му розподілі, не те, що ви можете використовувати її повторно на n-му виділенні.
awx

4

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

Я бачив 1,5x 2,0x phi x та потужність 2, що використовувались раніше.


Фі! Це приємне число для використання. Я повинен почати використовувати його з цього моменту. Дякую! +1
Кріс Джестер-Янг

Я не розумію ... чому фі? Якими властивостями він володіє, що робить його придатним для цього?
Джейсон Крейтон,

4
@Jason: phi робить послідовність Фібоначчі, тому наступний розмір розподілу - це сума поточного розміру та попереднього розміру. Це дозволяє помірний темп зростання, швидший ніж 1,5, але не 2 (див. Мій пост, чому> = 2 - це не гарна ідея, принаймні для некерованих мов).
Кріс Джестер-Янг

1
@ Джейсон: Крім того, на думку коментатора мого допису, будь-яке число> фі - насправді погана ідея. Я сам не зробив математики, щоб підтвердити це, тож сприйміть це з достатньою кількістю солі.
Кріс Джестер-Янг

2

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

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

У Scala ви можете замінити loadFactor для стандартних хеш-таблиць бібліотеки за допомогою функції, яка враховує поточний розмір. Як не дивно, але масштабовані масиви просто подвоюються, що більшість людей роблять на практиці.

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

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


2

Ще два центи

  • Більшість комп’ютерів мають віртуальну пам’ять! У фізичній пам'яті ви можете мати скрізь випадкові сторінки, які відображаються як єдиний суміжний простір у віртуальній пам'яті вашої програми. Вирішення опосередкованості здійснюється апаратним забезпеченням. Вичерпання віртуальної пам'яті було проблемою в 32-розрядних системах, але насправді це вже не проблема. Отже заповнення отвору вже не є проблемою (крім особливих умов). Оскільки Windows 7 навіть Microsoft підтримує 64-бітну без зайвих зусиль. @ 2011
  • O (1) досягається з будь-яким коефіцієнтом r > 1. Той самий математичний доказ працює не тільки для 2 як параметр.
  • r = 1,5 можна обчислити, old*3/2так що немає необхідності в операціях з плаваючою комою. (Я кажу, /2тому що компілятори замінять його зміщенням бітів у згенерованому коді збірки, якщо вважатимуть за потрібне.)
  • MSVC вибрав для r = 1,5, тому існує принаймні один основний компілятор, який не використовує 2 як співвідношення.

Як хтось згадав, 2 відчуває себе краще, ніж 8. А також 2 відчуває себе краще, ніж 1.1.

Я відчуваю, що 1,5 - це хороший дефолт. Крім цього, це залежить від конкретного випадку.


2
Краще використовувати n + n/2для затримки переповнення. Використання n*3/2зменшує вашу можливу потужність наполовину.
owacoder

@owacoder Правда. Але коли n * 3 не підходить, але n * 1,5 підходить, ми говоримо про багато пам'яті. Якщо n є 32-бітовим несинхронним, то n * 3 переливається, коли n дорівнює 4G / 3, тобто приблизно 1,333G. Це величезна кількість. Це багато пам’яті, яку потрібно мати за один розподіл. Еверн більше, якщо елементи не мають 1 байт, а, наприклад, 4 байти кожен. Цікаво про варіант використання ...
Notinlist

3
Це правда, що це може бути випадком краю, але випадки краю - це те, що зазвичай кусає. Звичка шукати можливе переповнення або іншу поведінку, яка може натякати на кращий дизайн, ніколи не є поганою ідеєю, навіть якщо це може здатися надуманим у теперішній час. Візьмемо для прикладу 32-розрядні адреси. Зараз нам потрібно 64 ...
owacoder

1

Я погоджуюсь з Джоном Скітом, навіть мій друг теоретик-крафтер наполягає, що це може бути доведено як O (1) при встановленні коефіцієнта в 2 рази.

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


2
Для деталізації подвоєння розміру масиву означає, що ви отримуєте амотизовані вставки O (1). Ідея полягає в тому, що кожного разу, коли ви вставляєте елемент, ви також копіюєте елемент зі старого масиву. Скажімо, у вас є масив розміром m , в якому є m елементів. При додаванні елемента m + 1 місця не залишається, тому ви виділяєте новий масив розміром . Замість того, щоб копіювати всі перші m елементів, ви копіюєте кожен раз, коли вставляєте новий елемент. Це мінімізує дисперсію (за винятком виділення пам’яті), і після того, як ви вставите 2 м елементи, ви скопіюєте всі елементи зі старого масиву.
hvidgaard

-1

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

По-перше, це множення на 2: size << 1. Це множення на що- небудь між 1 і 2: int (float (size) * x), де x - це число, * - це математика з плаваючою комою, а процесор має запустити додаткові інструкції щодо кастингу між float та int. Іншими словами, на рівні машини для подвоєння потрібна одна, дуже швидка інструкція, щоб знайти новий розмір. Множення на щось між 1 і 2 вимагає принаймніодна інструкція для розміщення розміру на плаваючу, одна інструкція для множення (що є множенням з плаваючою величиною, тому, ймовірно, потрібно щонайменше вдвічі більше циклів, якщо не в 4 або навіть у 8 разів більше), і одна інструкція для повернення до int, і це передбачає, що ваша платформа може виконувати плаваючу математику на регістрах загального призначення, а не вимагати використання спеціальних регістрів. Коротше кажучи, слід очікувати, що математика для кожного розподілу займе принаймні в 10 разів довше, ніж проста зміна вліво. Якщо ви копіюєте багато даних під час перерозподілу, це може не мати великої різниці.

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

Моя відповідь на питання? Ні, немає ідеального числа. Це настільки специфічно для програми, що насправді ніхто навіть не намагається. Якщо ваша мета - ідеальне використання пам’яті, вам майже не пощастило. Для продуктивності кращі менш часті розподіли, але якби ми пішли саме з цим, ми могли б помножити на 4 або навіть 8! Звичайно, коли Firefox переходить від використання 1 ГБ до 8 ГБ одним пострілом, люди збираються скаржитися, тож це навіть не має сенсу. Ось декілька основних правил, якими я хотів би керуватися:

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

Не думайте над цим. Якщо ви щойно витратили 4 години, намагаючись зрозуміти, як зробити те, що вже було зроблено, ви просто витратили свій час. Цілком чесно, якби був кращий варіант, ніж * 2, це було б зроблено у векторному класі С ++ (і багатьох інших місцях) десятиліття тому.

Нарешті, якщо ви дійсно хочете оптимізувати, не потійте дрібниці. Зараз вже ніхто не дбає про те, щоб витратити 4 КБ пам’яті, якщо він не працює над вбудованими системами. Коли ви потрапляєте до 1 ГБ об’єктів, розмір яких від 1 МБ до 10 МБ, подвоєння, мабуть, занадто велике (я маю на увазі, це від 100 до 1000 об’єктів). Якщо ви можете оцінити очікуваний темп розширення, ви можете вирівняти його до лінійного темпу зростання в певний момент. Якщо ви очікуєте приблизно 10 об’єктів на хвилину, то вирощування з 5 до 10 розмірів об’єкта на крок (раз на 30 секунд до хвилини), мабуть, добре.

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


11
Звичайно, n + n >> 1це те саме, що 1.5 * n. Досить легко придумати подібні хитрощі для кожного практичного фактору росту, який ви можете подумати.
Бьорн Ліндквіст,

Це хороший момент. Однак зауважте, що поза ARM це принаймні подвоює кількість інструкцій. (Багато інструкцій ARM, включаючи інструкцію додавання, можуть необов’язково змінити один з аргументів, дозволяючи вашому прикладу працювати в одній інструкції. Однак більшість архітектур не можуть цього зробити.) Ні, в більшості випадків подвоєння числа інструкцій від одного до двох не є суттєвою проблемою, але для більш складних факторів росту, де математика є більш складною, це може мати різницю в продуктивності для чутливої ​​програми.
Rybec Arethdar

@Rybec - Хоча можуть існувати деякі програми, чутливі до варіацій синхронізації за однією чи двома інструкціями, дуже малоймовірно, що будь-яка програма, яка використовує динамічні перерозподіли, коли-небудь буде стурбована цим. Якщо йому потрібно точно контролювати хронометраж, він, ймовірно, замість цього використовуватиме статично виділене сховище.
owacoder

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