Каковы опасности при создании потока с размером стека 50x по умолчанию?

228

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

Я читал, что размер стека по умолчанию для потока равен 1 МБ, поэтому для перемещения всех моих float[]файлов мне потребуется расширить стек примерно в 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.

Хотя кажется, что стек на самом деле становится медленнее .

Vercas
источник
Я должен был бы не согласиться, согласно результатам моего теста (см. Комментарий внизу страницы для результатов), показывают, что стек немного быстрее, чем глобальный, и намного быстрее, чем куча; и чтобы быть уверенным, что мои результаты точны, я провел тест 20 раз, и каждый метод вызывался 100 раз за каждую итерацию теста. Вы определенно правильно проводите тест?
Сэм
Я получаю очень противоречивые результаты. С полным доверием, x64, выпуском конфигурации, без отладчика, все они одинаково быстры (разница менее 1%; колеблются), а у вас действительно намного быстрее со стеком. Мне нужно проверить дальше! Редактировать : Ваше ДОЛЖНО вызвать исключение переполнения стека. Вы просто выделяете достаточно для массива. O_o
Vercas
Да, я знаю, это близко. Вам нужно повторить тесты несколько раз, как я, может быть, попробовать в среднем около 5 или около того пробежек.
Сэм
1
@Voo Первый прогон занял столько же времени, сколько и сотый прогон любого теста для меня. По моему опыту, эта вещь Java JIT не относится к .NET вообще. Единственный «прогрев», который делает .NET - это загрузка классов и сборок при первом использовании.
Vercas
2
@ Voo Проверьте мой тест и тот, который он добавил в комментарии к этому ответу. Соберите коды вместе и запустите несколько сотен тестов. Затем вернитесь и сообщите о своем заключении. Я сделал свои тесты очень тщательно, и я очень хорошо знаю, о чем говорю, когда говорю, что .NET не интерпретирует какой-либо байт-код, как это делает Java, он мгновенно его JIT-кодирует.
Vercas
28

Я обнаружил увеличение скорости обработки на 530%!

Это, безусловно, самая большая опасность, я бы сказал. Что-то серьезно не так с вашим тестом, в коде, который ведет себя так непредсказуемо, обычно где-то скрыта неприятная ошибка.

Очень и очень трудно потреблять много стекового пространства в программе .NET, за исключением чрезмерной рекурсии. Размер стека каркаса управляемых методов задан в камне. Просто сумма аргументов метода и локальных переменных в методе. За исключением тех, которые могут быть сохранены в регистре процессора, вы можете игнорировать это, так как их так мало.

Увеличение размера стека ничего не даст, вы просто зарезервируете кучу адресного пространства, которое никогда не будет использовано. Нет механизма, который мог бы объяснить увеличение производительности от использования памяти, конечно.

В отличие от нативной программы, особенно написанной на C, она также может зарезервировать место для массивов в кадре стека. Основной вектор атаки вредоносного ПО за переполнением стекового буфера. Возможно, и в C #, вам придется использовать stackallocключевое слово. Если вы делаете это, то очевидной опасностью является необходимость написания небезопасного кода, подверженного таким атакам, а также случайного повреждения фрейма стека. Очень сложно диагностировать ошибки. В последующих джиттерах есть контрмеры против этого, я думаю, начиная с .NET 4.0, где джиттер генерирует код для помещения «cookie» в фрейм стека и проверяет, остается ли он неповрежденным, когда метод возвращается. Мгновенный сбой на рабочем столе без какого-либо способа перехватить или сообщить о неудаче, если это произойдет. Это ... опасно для психического состояния пользователя.

Основной поток вашей программы, запущенный операционной системой, будет иметь размер стека 1 МБ по умолчанию, 4 МБ при компиляции вашей программы для x64. Увеличение, которое требует запуска Editbin.exe с параметром / STACK в событии после сборки. Обычно вы можете запросить до 500 МБ, прежде чем ваша программа будет иметь проблемы с запуском при работе в 32-битном режиме. Потоки тоже могут, конечно, намного проще, опасная зона обычно составляет около 90 МБ для 32-битной программы. Срабатывает, когда ваша программа долгое время работала, а адресное пространство было фрагментировано из предыдущих выделений. Общее использование адресного пространства уже должно быть высоким, более одного гигабайта, чтобы получить этот режим отказа.

Тройной проверь свой код, там что-то не так. Вы не можете получить ускорение в 5 раз с большим стеком, если не напишите явно свой код, чтобы воспользоваться им. Который всегда требует небезопасного кода. Использование указателей в C # всегда имеет ловкость для создания более быстрого кода, он не подвергается проверкам границ массива.

Ганс Пассант
источник
21
5-кратное ускорение, о котором сообщают, было от перемещения float[]к float*. Большой стек был просто, как это было достигнуто. Ускорение в 5 раз в некоторых случаях вполне оправдано для этого изменения.
Марк Гравелл
3
Хорошо, у меня еще не было фрагмента кода, когда я начал отвечать на вопрос. Все еще достаточно близко.
Ганс Пассант
22

У меня была бы оговорка, что я просто не знаю, как это предсказать - разрешения, GC (который должен сканировать стек) и т. Д. - все это может быть затронуто. Я бы очень хотел использовать неуправляемую память вместо этого:

var ptr = Marshal.AllocHGlobal(sizeBytes);
try
{
    float* x = (float*)ptr;
    DoWork(x);
}
finally
{
    Marshal.FreeHGlobal(ptr);
}
Марк Гравелл
источник
1
Дополнительный вопрос: зачем GC сканировать стек? Выделенная память stackallocне подлежит сборке мусора.
dcastro
6
@dcastro он должен сканировать стек, чтобы проверить ссылки, которые существуют только в стеке. Я просто не знаю, что он собирается делать, когда он добирается до такого огромного stackalloc- ему нужно прыгнуть, и вы надеетесь, что он сделает это без особых усилий - но я пытаюсь подчеркнуть, что он вводит ненужные осложнения / проблемы. ИМО, stackallocотлично подходит в качестве рабочего буфера, но для выделенного рабочего пространства более вероятно, что он просто выделит где-то чанк-память, а не будет злоупотреблять / сбивать с толку стек,
Марк Гравелл
8

Одна вещь, которая может пойти не так, это то, что вы можете не получить разрешение на это. Если не работать в режиме полного доверия, Framework просто проигнорирует запрос на больший размер стека (см. MSDN на Thread Constructor (ParameterizedThreadStart, Int32))

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

PMF
источник
1
Хорошая идея, я перейду вместо этого. Кроме того, мой код работает в режиме полного доверия, поэтому есть ли другие вещи, на которые я должен обратить внимание?
Сэм
6

Массивы с высокой производительностью могут быть доступны так же, как и обычный C #, но это может стать началом проблемы: Рассмотрим следующий код:

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

Вы ожидаете исключение вне границы, и это полностью имеет смысл, потому что вы пытаетесь получить доступ к элементу 200, но максимально допустимое значение равно 99. Если вы идете по маршруту stackalloc, тогда не будет никакого объекта, обернутого вокруг вашего массива для проверки привязки, и следующее не будет показывать никаких исключений:

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

Выше вы выделяете достаточно памяти для хранения 100 float, и вы устанавливаете размер памяти (float), который начинается с места, начатого в этой памяти, + 200 * sizeof (float) для хранения вашего значения float 10. Неудивительно, что эта память находится за пределами выделенная память для поплавков, и никто не будет знать, что может быть сохранено в этом адресе. Если вам повезет, вы могли использовать некоторую неиспользуемую в данный момент память, но в то же время, скорее всего, вы могли бы перезаписать какое-то место, которое использовалось для хранения других переменных. Подводя итог: непредсказуемое поведение во время выполнения.

MHOOS
источник
На самом деле неправильно. Тесты времени выполнения и компилятора все еще там.
TomTom
9
@ TomTom ... нет; ответ имеет свои достоинства; речь идет о вопросе stackalloc, в этом случае мы говорим и float*т. д. - которые не имеют одинаковые проверки. Это вызвано unsafeочень веской причиной. Лично я очень рад использовать, unsafeкогда есть веская причина, но Сократ делает некоторые разумные замечания.
Марк Гравелл
@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);
}
Voo
источник
Интересное наблюдение, мне придется снова проверить свои тесты. Хотя это все еще не отвечает на мой вопрос: « ... какие опасности связаны с увеличением стека до такого большого размера ... ». Даже если мои результаты неверны, вопрос остается в силе; Я ценю усилия, тем не менее.
Сэм
1
@Sam При использовании в 12500000качестве размера я на самом деле получаю исключение stackoverflow. Но в основном речь шла о том, чтобы отвергнуть основную предпосылку, что использование кода, выделенного из стека, на несколько порядков быстрее. В противном случае мы делаем здесь наименьшее количество работы, и разница уже составляет всего около 10-15% - на практике она будет еще ниже ... это, на мой взгляд, определенно меняет всю дискуссию.
Во
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).

HKTonyLee
источник
4

РЕДАКТИРОВАТЬ: (небольшое изменение в коде и в измерении приводит к значительным изменениям в результате)

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

Я попробовал некоторые другие методы, в дополнение к двум, которые вы предоставили. Метод 3 имеет тот же код, что и ваш метод 2, но функция объявлена unsafe. Метод 4 использует указатель доступа к регулярно создаваемому массиву. Метод 5 использует указатель доступа к неуправляемой памяти, как описано Марком Гравеллом. Все пять методов работают в очень похожее время. M5 - самый быстрый (а M1 - второй). Разница между самым быстрым и самым медленным составляет около 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);
        }
    }
Dialecticus
источник
То есть М3 такой же, как М2, помеченный только как «небезопасный»? Скорее подозрительно, что это будет быстрее ... ты уверен?
Роман Старков
@romkyns Я только что выполнил тест (M2 против M3), и на удивление M3 на самом деле на 2,14% быстрее, чем M2.
Сэм
« Вывод состоит в том, что использование стека не требуется». При выделении больших блоков, как я дал в своем посте, я согласен, но после того, как я только что завершил еще несколько тестов M1 против M2 (используя идею PFM для обоих методов), я бы определенно Я должен не согласиться, так как M1 теперь на 135% быстрее, чем M2.
Сэм
1
@Sam Но вы все еще сравниваете доступ к указателю с доступом к массиву! ЧТО это то , что делает прежде всего органов его быстрее. TestMethod4против TestMethod1гораздо лучшего сравнения stackalloc.
Роман Старков
@romkyns Ах, да, хорошо, я забыл об этом; Я перезапустил тесты , теперь разница только в 8% (M1 - самый быстрый из двух).
Сэм