Подтвердить что ты не робот

С# и .NET: stackalloc

У меня есть несколько вопросов о функциональности оператора stackalloc.

  • Как это распределяется? Я думал, что это что-то вроде:

    void* stackalloc(int sizeInBytes)
    {
        void* p = StackPointer (esp);
        StackPointer += sizeInBytes;
        if(StackPointer exceeds stack size)
            throw new StackOverflowException(...);
        return p;
    }
    

    Но я сделал несколько тестов, и я не уверен, как это работает. Мы не можем точно знать, что он делает и как он это делает, но я хочу знать основы.

  • Я думал, что распределение стека (ну, я действительно уверен в этом) быстрее, чем распределение кучи. Итак, почему этот пример:

     class Program
     {
         static void Main(string[] args)
         {
             Stopwatch sw1 = new Stopwatch();
             sw1.Start();
             StackAllocation();
             Console.WriteLine(sw1.ElapsedTicks);
    
             Stopwatch sw2 = new Stopwatch();
             sw2.Start();
             HeapAllocation();
             Console.WriteLine(sw2.ElapsedTicks);
         }
         static unsafe void StackAllocation()
         {
             for (int i = 0; i < 100; i++)
             {
                 int* p = stackalloc int[100];
             }
         }
         static void HeapAllocation()
         {
             for (int i = 0; i < 100; i++)
             {
                 int[] a = new int[100];
             }
         }
     }
    

дает средние результаты 280 ~ тиков для распределения стека и обычно 1-0 тиков для распределения кучи? (На моем персональном компьютере, Intel Core i7).

На компьютере, который я использую сейчас (Intel Core 2 Duo), результаты имеют больше смысла, чем предыдущие (вероятно, потому, что оптимизированный код не был проверен в VS):    460 ~ тиков для размещения стека и около 380 тиков для размещения кучи.

Но это все еще не имеет смысла. Почему это так? Я полагаю, что CLR замечает, что мы не используем массив, так что, возможно, он даже не выделяет его?

4b9b3361

Ответ 1

Случай, когда stackalloc быстрее:

 private static volatile int _dummy; // just to avoid any optimisations
                                         // that have us measuring the wrong
                                         // thing. Especially since the difference
                                         // is more noticable in a release build
                                         // (also more noticable on a multi-core
                                         // machine than single- or dual-core).
 static void Main(string[] args)
 {
     System.Diagnostics.Stopwatch sw1 = new System.Diagnostics.Stopwatch();
     Thread[] threads = new Thread[20];
     sw1.Start();
     for(int t = 0; t != 20; ++t)
     {
        threads[t] = new Thread(DoSA);
        threads[t].Start();
     }
     for(int t = 0; t != 20; ++t)
        threads[t].Join();
     Console.WriteLine(sw1.ElapsedTicks);

     System.Diagnostics.Stopwatch sw2 = new System.Diagnostics.Stopwatch();
     threads = new Thread[20];
     sw2.Start();
     for(int t = 0; t != 20; ++t)
     {
        threads[t] = new Thread(DoHA);
        threads[t].Start();
     }
     for(int t = 0; t != 20; ++t)
        threads[t].Join();
     Console.WriteLine(sw2.ElapsedTicks);
     Console.Read();
 }
 private static void DoSA()
 {
    Random rnd = new Random(1);
    for(int i = 0; i != 100000; ++i)
        StackAllocation(rnd);
 }
 static unsafe void StackAllocation(Random rnd)
 {
    int size = rnd.Next(1024, 131072);
    int* p = stackalloc int[size];
    _dummy = *(p + rnd.Next(0, size));
 }
 private static void DoHA()
 {
    Random rnd = new Random(1);
    for(int i = 0; i != 100000; ++i)
        HeapAllocation(rnd);
 }
 static void HeapAllocation(Random rnd)
 {
    int size = rnd.Next(1024, 131072);
    int[] a = new int[size];
    _dummy = a[rnd.Next(0, size)];
 }

Важные отличия между этим кодом и тем в вопросе:

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

  • Более крупные размеры выделены.

  • Различные размеры, назначенные каждый раз (хотя я засеял случайный генератор, чтобы сделать тесты более детерминированными). Это делает фрагментацию кучи более вероятной, делая распределение кучи менее эффективным, чем с одинаковыми распределениями каждый раз.

Кроме того, также стоит отметить, что stackalloc часто используется в качестве альтернативы использованию fixed для привязки массива к куче. Привязка массивов является плохим для производительности кучи (не только для этого кода, но и для других потоков, использующих одну и ту же кучу), поэтому влияние производительности будет еще больше, если заявленная память будет использоваться в течение разумного промежутка времени.

В то время как мой код демонстрирует случай, когда stackalloc дает преимущество в производительности, в вопросе, вероятно, ближе к большинству случаев, когда кто-то может "оптимизировать" его, используя его. Надеемся, что два фрагмента кода показывают, что целая stackalloc может дать толчок, это также может сильно повредить производительность.

Как правило, вы даже не должны рассматривать stackalloc, если вам не понадобится использовать фиксированную память для взаимодействия с неуправляемым кодом в любом случае, и его следует рассматривать как альтернативу fixed, а не альтернативой распределению общей кучи, Использование в этом случае по-прежнему требует осторожности, предусмотрительности перед началом работы и профилирования после того, как вы закончите.

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

Edit:

Чтобы ответить на часть 1 вопроса. Stackalloc концептуально много, как вы описываете. Он получает кусок памяти стека, а затем возвращает указатель на этот кусок. Он не проверяет, что память поместится как таковая, но если она попытается получить память в конце стека, которая защищена .NET при создании потока, то это приведет к тому, что ОС вернет исключение в runtime, который затем превращается в исключение, управляемое .NET. То же самое происходит, если вы просто выделите один байт в методе с бесконечной рекурсией - если только вызов не был оптимизирован, чтобы избежать распределения стека (иногда это возможно), тогда один байт в конечном итоге будет содержать достаточно, чтобы вызвать исключение.

Ответ 2

  • Я не могу дать точный ответ, но stackalloc реализуется с использованием кода операции IL localloc. Я просмотрел машинный код, сгенерированный сборкой release для stackalloc, и был более запутанным, чем я ожидал. Я не знаю, будет ли localloc проверять размер стека по вашему тегу if, или если переполнение стека будет обнаружено ЦП при фактическом переполнении аппаратного стека.

    Комментарии к этому ответу указывают, что ссылка, предоставленная localloc, выделяет пространство из "локальной кучи". Проблема в том, что нет хорошей онлайн-ссылки для MSIL, кроме фактического стандарта, доступного в формате PDF. Ссылка выше относится к классу System.Reflection.Emit.OpCodes, который не относится к MSIL, а скорее к библиотеке для генерации MSIL.

    Однако в стандартном документе ECMA 335 - Common Language Infrastructure есть более точное описание:

    Часть каждого состояния метода представляет собой локальный пул памяти. Память может быть явно выделена из пула локальной памяти с помощью команды localloc. Вся память в пуле локальной памяти восстанавливается при выходе метода, и это единственный способ восстановления памяти пула памяти (нет инструкции, предоставляемой свободной локальной памяти, которая была выделена во время вызова этого метода). Пул локальной памяти используется для выделения объектов, тип или размер которых неизвестен во время компиляции и которые программист не хочет выделять в управляемой куче.

    Таким образом, в основном "пул локальной памяти" - это то, что иначе известно как "стек", а язык С# использует оператор stackalloc для выделения из этого пула.

  • В сборке релиза оптимизатор достаточно умен, чтобы полностью удалить вызов HeapAllocation, что привело к значительному сокращению времени выполнения. Похоже, что при использовании stackalloc недостаточно разумно выполнять ту же оптимизацию. Если вы либо отключите оптимизацию, либо каким-то образом используете выделенный буфер, вы увидите, что stackalloc немного быстрее.