Які небезпеки при створенні потоку розміром стека 50x за замовчуванням?


228

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

Я читав, що розмір стека за замовчуванням для потоку становить 1 Мб, тому для того, щоб перемістити всі мої float[]s, мені доведеться розширити стек приблизно в 50 разів (до 50 МБ ~).

Я розумію, що це, як правило, вважається "небезпечним" і не рекомендується, але після порівняння мого поточного коду проти цього методу я виявив 530% збільшення швидкості обробки! Тому я не можу просто пройти повз цей варіант без подальшого дослідження, що призводить мене до мого питання; які небезпеки пов'язані із збільшенням штабелю до такого великого розміру (що може піти не так) і які заходи безпеки слід вжити, щоб мінімізувати такі небезпеки?

Мій тестовий код,

public static unsafe void TestMethod1()
{
    float* samples = stackalloc float[12500000];

    for (var ii = 0; ii < 12500000; ii++)
    {
        samples[ii] = 32768;
    }
}

public static void TestMethod2()
{
    var samples = new float[12500000];

    for (var i = 0; i < 12500000; i++)
    {
        samples[i] = 32768;
    }
}

98
+1. Серйозно. Ви запитуєте, що ЛУКСИ Як ідіотське запитання поза нормою, і тоді ви ДУЖЕ хороший випадок, що у вашому конкретному сценарії це розумно враховувати, тому що ви зробили домашнє завдання і оцінили результат. Це ДУЖЕ добре - я пропускаю це з багатьма питаннями. Дуже приємно - добре, що ви вважаєте щось подібне, на жаль, багато програмістів на C # не знають про ці можливості оптимізації. Так, часто це не потрібно - але іноді це критично і викликає значні зміни.
TomTom

5
Мені цікаво побачити два коди, які мають 530% різниці в швидкості обробки, виключно за рахунок переміщення масиву в стек. Це просто не правильно.
Діалектик

13
Перш ніж спуститися з цієї дороги: чи намагалися ви Marshal.AllocHGlobal(не забудьте FreeHGlobalтакож) виділити дані поза керованою пам'яттю? Потім киньте покажчик на a float*, і вам слід відсортувати.
Марк Гравелл

2
Це правильно, якщо ти робиш багато виділень. Stackalloc обходить всі проблеми GC, які також можуть створювати / створювати дуже сильну локальність на рівні процесора. Це одне з речей, що капелюх виглядає як мікрооптимізація - якщо ви не пишете високоефективну математичну програму і маєте саме таку поведінку, і це має значення;)
TomTom

6
Моя підозра: один із цих методів запускає перевірку меж при кожній ітерації циклу, а інший - або оптимізовано.
pjc50

Відповіді:


45

Порівнюючи тестовий код із Семом, я визначив, що ми обидва праві!
Однак про різні речі:

  • Доступ до пам’яті (читання і письмо) так само швидко, де б вона не була - стек, глобальний або купа.
  • Разом з тим, це розподіл є найшвидшим на стеці та найповільнішим на купі.

Виходить так: stack<global < heap. (час розподілу)
Технічно розподіл стеків насправді не є розподілом, час виконання просто гарантує, що частина стека (фрейм?) зарезервована для масиву.

Я настійно раджу бути обережними з цим.
Я рекомендую наступне:

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

( Примітка : 1. застосовується лише до типів значень; еталонні типи будуть розподілятися на купі, а користь буде зменшена до 0)

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

Розділ нижче - моя перша відповідь. Це неправильно, і тести є невірними. Він зберігається лише для довідки.


Мій тест вказує, що виділена стеком пам'ять, а глобальна пам'ять принаймні на 15% повільніше, ніж (займає 120% часу) купи пам'яті, виділеної купу, для використання в масивах!

Це мій тестовий код , і це зразок виводу:

Stack-allocated array time: 00:00:00.2224429
Globally-allocated array time: 00:00:00.2206767
Heap-allocated array time: 00:00:00.1842670
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 100.80 %| 120.72 %|
--+---------+---------+---------+
G |  99.21 %|    -    | 119.76 %|
--+---------+---------+---------+
H |  82.84 %|  83.50 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

Я протестував на Windows 8.1 Pro (з оновленням 1), використовуючи i7 4700 MQ, під .NET 4.5.1
Я тестував і x86, і x64, і результати однакові.

Редагувати : я збільшив розмір стека всіх потоків 201 МБ, розмір вибірки до 50 мільйонів і зменшив ітерацій до 5.
Результати такі ж, як і вище :

Stack-allocated array time: 00:00:00.4504903
Globally-allocated array time: 00:00:00.4020328
Heap-allocated array time: 00:00:00.3439016
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 112.05 %| 130.99 %|
--+---------+---------+---------+
G |  89.24 %|    -    | 116.90 %|
--+---------+---------+---------+
H |  76.34 %|  85.54 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

Хоча, здається, стек насправді стає повільніше .


Мені доведеться не погодитися, згідно з результатами мого еталону (результати див. У коментарі внизу сторінки) показано, що стек незначно швидший, ніж глобальний, і набагато швидше, ніж купа; і, щоб бути впевненим, що мої результати точні, я провів тест 20 разів, і кожен метод називався 100 разів за ітерацію тесту. Ви напевно правильно виконуєте свій орієнтир?
Сем

Я отримую дуже непослідовні результати. З повною довірою, x64, конфігурація випуску, без налагодження, всі вони однаково швидкі (менше 1% різниці; коливання), тоді як ваш справді набагато швидший зі стеком. Мені потрібно ще тестувати! Редагувати : ВАМ ПОВИНЕН викинути виняток переповнення стека. Ви просто виділите достатньо для масиву. O_o
Веркас

Так, я знаю, це вже близько. Вам потрібно повторити орієнтири кілька разів, як я, можливо, спробуйте взяти в середньому більше 5 пробіжок.
Сем

1
@Voo Перший запуск зайняв стільки ж часу, як і 100-й запуск будь-якого тесту для мене. З мого досвіду, ця річ Java JIT взагалі не стосується .NET Єдине "розігрівання", яке робить .NET, це завантаження класів та збірок при першому використанні.
Веркас

2
@Voo Перевірте мій орієнтир і той, що від суті, який він додав у коментарі до цієї відповіді. Зберіть коди разом і проведіть кілька сотень тестів. Потім поверніться і повідомте про свій висновок. Я зробив свої тести дуже ретельно, і я дуже добре знаю, про що я говорю, коли говорять, що .NET не інтерпретує жоден байт-код, як це робить Java, він це JIT миттєво.
Веркас

28

Я виявив 530% збільшення швидкості обробки!

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

Дуже, дуже важко споживати багато місця у стеці в .NET-програмі, за винятком надмірної рекурсії. Розміри кадру стека керованих методів встановлюються в камінь. Просто сума аргументів методу та локальних змінних у методі. Мінус тих, які можна зберегти в реєстрі процесора, ви можете проігнорувати це, оскільки їх так мало.

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

Це на відміну від рідної програми, зокрема, написаної на C, вона також може зарезервувати місце для масивів на кадрі стека. Основний вектор атаки зловмисних програм за буфером стека переповнюється. Можливо і в C #, вам доведеться використовувати stackallocключове слово. Якщо ви це робите, то очевидною небезпекою є необхідність написати небезпечний код, який піддається таким атакам, а також випадкова пошкодження кадру стека. Дуже важко діагностувати помилки. Протилежний захід проти цього є в пізніших тремтіннях, я думаю, починаючи з .NET 4.0, де тремтіння генерує код, щоб поставити "cookie" на фрейм стека і перевіряє, чи він ще недоторканий, коли метод повертається. Миттєвий збій на робочий стіл без жодного способу перехопити або повідомити про випадковість, якщо це станеться. Це ... небезпечно для психічного стану користувача.

Основний потік вашої програми, запущений операційною системою, матиме стек 1 Мб за замовчуванням, 4 Мб, коли ви компілюєте програму, націлену на x64. Збільшення, що вимагає запуску Editbin.exe з параметром / STACK у події збірки після публікації. Зазвичай ви можете вимагати до 500 Мб, перш ніж у вашої програми виникнуть проблеми при запуску в 32-бітному режимі. Нитки також можуть, набагато простіше, звичайно, небезпечна зона, як правило, коливається близько 90 Мб для 32-бітної програми. Запускається, коли ваша програма працює протягом тривалого часу, а адресний простір роздроблений від попередніх розподілів. Загальний обсяг використання адресного простору повинен мати високий рівень, щоб отримати цей режим відмови.

Потрійно перевірити свій код, там щось дуже не так. Ви не можете отримати прискорення x5 з більшим стеком, якщо явно не напишете свій код, щоб скористатися ним. Що завжди вимагає небезпечного коду. Використовуючи покажчики в C #, завжди є сприятливим для створення більш швидкого коду, він не піддається перевірки меж масиву.


21
Звіт про 5-кратну швидкість повідомлявся про перехід від float[]до float*. Великий стек був просто тим, як це було досягнуто. Прискорення x5 у деяких сценаріях цілком розумно для цієї зміни.
Marc Gravell

3
Гаразд, у мене ще не було фрагмента коду, коли я почав відповідати на питання. Ще досить близько.
Ганс Пасант

22

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

var ptr = Marshal.AllocHGlobal(sizeBytes);
try
{
    float* x = (float*)ptr;
    DoWork(x);
}
finally
{
    Marshal.FreeHGlobal(ptr);
}

1
Побічне питання: Чому GC потрібно сканувати стек? Пам'ять, виділена особою stackalloc, не підлягає збору сміття.
дкастро

6
@dcastro йому потрібно сканувати стек, щоб перевірити наявність посилань, які існують лише в стеку. Я просто не знаю, що він буде робити, коли дістанеться до такого величезного stackalloc- йому якось потрібно це перестрибнути, і ви сподіваєтесь, що це буде робити без особливих зусиль - але справа, яку я намагаюся зробити, - це те, що це вводить зайві ускладнення / проблеми. IMO - stackallocце чудово, як буфер скретчів, але для виділеного робочого простору очікується просто виділити куди-небудь пам’ять десь, а не зловживати / плутати стек,
Марк Гравелл

8

Одне, що може піти не так, це те, що ви можете не отримати дозвіл на це. Якщо не працює в режимі повного довіри, Framework просто ігнорує запит на більший розмір стека (див. MSDN на Thread Constructor (ParameterizedThreadStart, Int32))

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


1
Хороша ідея, я повторю його натомість. Окрім цього, мій код працює в режимі повного довіри, тож чи є ще якісь речі, на які слід звернути увагу?
Сем

6

Масиви з високою ефективністю можуть бути доступними так само, як і звичайний C # one, але це може стати початком неприємностей: Розгляньте наступний код:

float[] someArray = new float[100]
someArray[200] = 10.0;

Ви очікуєте, що виключення не пов'язане, і це абсолютно має сенс, тому що ви намагаєтеся отримати доступ до елемента 200, але максимальне дозволене значення - 99. Якщо ви переходите до маршруту stackalloc, то навколо вашого масиву не буде об'єкта, який би пересвідчив і нижче не відображатиметься жодне виняток:

Float* pFloat =  stackalloc float[100];
fFloat[200]= 10.0;

Вище виділяєте достатню кількість пам'яті, щоб утримувати 100 поплавків, і ви встановлюєте розміщення пам'яті sizeof (float), яке починається з місця, розпочатого з цієї пам’яті, + 200 * sizeof (float) для утримування значення float 10. Не дивно, що ця пам'ять знаходиться поза виділено пам'ять для поплавків, і ніхто не знав би, що може бути збережено за цією адресою. Якщо вам пощастило, ви, можливо, використали деяку не використану пам'ять, але в той же час, можливо, ви можете перезаписати якесь місце, яке було використано для зберігання інших змінних. Підводячи підсумок: непередбачувана поведінка часу виконання.


Фактично неправильно. Тести виконання та компілятори все ще є.
TomTom

9
@TomTom erm, ні; відповідь має заслугу; у цьому питанні йдеться про те stackalloc, в якому випадку ми говоримо про float*інше - яке не має однакових перевірок. Це називається unsafeз дуже вагомих причин. Особисто я з задоволенням користуюся, unsafeколи є вагомі причини, але Сократ робить деякі розумні моменти.
Marc Gravell

@Marc Для показаного коду (після запуску JIT) більше немає перевірок меж, тому що компілятор є тривіальним для того, щоб всі доходи були в межах. Взагалі, хоча це, безумовно, може змінити значення.
Ву

6

Мови мікробіргового маркірування з JIT та GC, такими як Java або C #, можуть бути дещо складними, тому загалом хорошою ідеєю є використання існуючих рамок - Java пропонує mhf або Caliper, які є чудовими, на жаль, наскільки мені відомо, C # не пропонує все, що наближається до них. Джон Скіт написав це тут, за яким я сліпо припускаю, що піклується про найважливіші речі (Джон знає, що він робить у цій галузі; також так, ніяких турбот, я насправді перевіряв). Я трохи змінив час, тому що 30 секунд за тест після розминки було занадто багато для мого терпіння (5 секунд потрібно зробити).

Тож спочатку результати .NET 4.5.1 під Windows 7 x64 - цифри позначають ітерації, які він міг би запустити за 5 секунд, тим вище, тим краще.

x64 JIT:

Standard       10,589.00  (1.00)
UnsafeStandard 10,612.00  (1.00)
Stackalloc     12,088.00  (1.14)
FixedStandard  10,715.00  (1.01)
GlobalAlloc    12,547.00  (1.18)

x86 JIT (так, це все одно сумно):

Standard       14,787.00   (1.02)
UnsafeStandard 14,549.00   (1.00)
Stackalloc     15,830.00   (1.09)
FixedStandard  14,824.00   (1.02)
GlobalAlloc    18,744.00   (1.29)

Це дає набагато більш розумну швидкість, що становить максимум 14% (а більша частина накладних витрат пов'язана з тим, що GC повинен працювати, вважайте це реально гіршим сценарієм). Результати x86 цікаві, хоча - не зовсім зрозуміло, що там відбувається.

і ось код:

public static float Standard(int size) {
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float UnsafeStandard(int size) {
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float Stackalloc(int size) {
    float* samples = stackalloc float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float FixedStandard(int size) {
    float[] prev = new float[size];
    fixed (float* samples = &prev[0]) {
        for (var ii = 0; ii < size; ii++) {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }
        return samples[size - 1];
    }
}

public static unsafe float GlobalAlloc(int size) {
    var ptr = Marshal.AllocHGlobal(size * sizeof(float));
    try {
        float* samples = (float*)ptr;
        for (var ii = 0; ii < size; ii++) {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }
        return samples[size - 1];
    } finally {
        Marshal.FreeHGlobal(ptr);
    }
}

static void Main(string[] args) {
    int inputSize = 100000;
    var results = TestSuite.Create("Tests", inputSize, Standard(inputSize)).
        Add(Standard).
        Add(UnsafeStandard).
        Add(Stackalloc).
        Add(FixedStandard).
        Add(GlobalAlloc).
        RunTests();
    results.Display(ResultColumns.NameAndIterations);
}

Цікаве спостереження, мені доведеться ще раз перевірити свої орієнтири. Хоча це все ще не відповідає на моє запитання, " ... які небезпеки пов'язані зі збільшенням стека до такого великого розміру ... ". Навіть якщо мої результати невірні, питання все одно є актуальним; Я все-таки ціную зусилля.
Сем

1
@Sam При використанні 12500000в якості розміру я фактично отримую виняток stackoverflow . Але в основному мова йшла про відхилення основної передумови, що використання коду, виділеного стеком, швидше на кілька порядків. Тут ми робимо майже найменший обсяг роботи, інакше різниця становить лише приблизно 10-15% - на практиці вона буде ще нижчою. Це, на мою думку, безумовно, змінює всю дискусію.
Voo

5

Оскільки різниця в продуктивності занадто велика, проблема ледь пов'язана з розподілом. Ймовірно, це викликано доступом до масиву.

Я розібрав тіло циклу функцій:

TestMethod1:

IL_0011:  ldloc.0 
IL_0012:  ldloc.1 
IL_0013:  ldc.i4.4 
IL_0014:  mul 
IL_0015:  add 
IL_0016:  ldc.r4 32768.
IL_001b:  stind.r4 // <----------- This one
IL_001c:  ldloc.1 
IL_001d:  ldc.i4.1 
IL_001e:  add 
IL_001f:  stloc.1 
IL_0020:  ldloc.1 
IL_0021:  ldc.i4 12500000
IL_0026:  blt IL_0011

TestMethod2:

IL_0012:  ldloc.0 
IL_0013:  ldloc.1 
IL_0014:  ldc.r4 32768.
IL_0019:  stelem.r4 // <----------- This one
IL_001a:  ldloc.1 
IL_001b:  ldc.i4.1 
IL_001c:  add 
IL_001d:  stloc.1 
IL_001e:  ldloc.1 
IL_001f:  ldc.i4 12500000
IL_0024:  blt IL_0012

Ми можемо перевірити використання інструкції і, що ще важливіше, виняток, який вони містять у специфікації ECMA :

stind.r4: Store value of type float32 into memory at address

Винятки, які він кидає:

System.NullReferenceException

І

stelem.r4: Replace array element at index with the float32 value on the stack.

Виняток, який він кидає:

System.NullReferenceException
System.IndexOutOfRangeException
System.ArrayTypeMismatchException

Як бачимо, stelemбільше працює в області перевірки діапазону масивів та перевірки типу. Оскільки тіло циклу мало що робить (лише присвоює значення), накладні витрати перевірки домінують у процесі обчислення. Тож тому продуктивність відрізняється на 530%.

І це також відповідає на ваші запитання: небезпека полягає у відсутності діапазону масивів та перевірки типу. Це небезпечно (як зазначено у декларації про функцію; D).


4

EDIT: (невелика зміна коду та вимірювання призводить до великих змін у результаті)

По-перше, я запустив оптимізований код у відладчику (F5), але це було неправильно. Його слід запускати без налагоджувача (Ctrl + F5). По-друге, код може бути ретельно оптимізований, тому ми повинні ускладнювати його, щоб оптимізатор не псувався з нашим вимірюванням. Я змусив всі методи повертати останній елемент у масиві, і масив заповнюється по-різному. Також в ОП є додатковий нуль, TestMethod2який завжди робить його в десять разів повільніше.

Я спробував деякі інші методи, крім двох, які ви надали. Спосіб 3 має той же код, що і ваш метод 2, але функція оголошена unsafe. Спосіб 4 використовує доступ до вказівника до регулярно створеного масиву. Метод 5 використовує вказівний доступ до некерованої пам’яті, як описав Марк Гравелл. Всі п’ять методів працюють у дуже подібні часи. М5 - найшвидший (а М1 - близький другий). Різниця між найшвидшим і найповільнішим - це певні 5%, що не те, про що я б хвилювався.

    public static unsafe float TestMethod3()
    {
        float[] samples = new float[5000000];

        for (var ii = 0; ii < 5000000; ii++)
        {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }

        return samples[5000000 - 1];
    }

    public static unsafe float TestMethod4()
    {
        float[] prev = new float[5000000];
        fixed (float* samples = &prev[0])
        {
            for (var ii = 0; ii < 5000000; ii++)
            {
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            }

            return samples[5000000 - 1];
        }
    }

    public static unsafe float TestMethod5()
    {
        var ptr = Marshal.AllocHGlobal(5000000 * sizeof(float));
        try
        {
            float* samples = (float*)ptr;

            for (var ii = 0; ii < 5000000; ii++)
            {
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            }

            return samples[5000000 - 1];
        }
        finally
        {
            Marshal.FreeHGlobal(ptr);
        }
    }

Тож M3 такий же, як M2, позначений лише "небезпечним"? Досить підозріло, що це буде швидше ... ти впевнений?
Роман Старков

@romkyns Я щойно запустив тест (M2 проти M3), і дивно, що M3 насправді на 2,14% швидше, ніж M2.
Сем

" Висновок полягає в тому, що використання стека не потрібне ". При розподілі великих блоків, таких, як я дав у своєму дописі, я погоджуюся, але, щойно завершивши ще кілька орієнтирів M1 проти M2 (використовуючи ідею PFM для обох методів), я, безумовно, доведеться не погодитися, оскільки М1 зараз на 135% швидший, ніж М2.
Сем

1
@Sam Але ви все ще порівнюєте доступ до вказівника з доступом до масиву! ТАК це головне те, що робить це швидше. TestMethod4vs TestMethod1- набагато краще порівняння для stackalloc.
Роман Старков

@romkyns Ага, добре, я забув про це; Я повторно оцінив показники , зараз різниця лише у 8% (М1 - швидше з двох).
Сем
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.