C/С++ производительность статических массивов и динамических массивов

Когда производительность важна для приложения, следует ли учитывать, следует ли объявлять массив в стеке против кучи? Позвольте мне изложить, почему этот вопрос пришел на ум.

Поскольку массивы в C/С++ не являются объектами и распадаются на указатели, компилятор использует предоставленный индекс для выполнения арифметики указателя для доступа к элементам. Я понимаю, что эта процедура отличается от от статически объявленного массива до динамически объявленного массива при прохождении первого измерения.

Если я должен был объявить массив в стеке следующим образом:

  int array[2][3] = { 0, 1, 2, 3, 4, 5 }
  //In memory        { row1 } { row2 }

Этот массив будет храниться в формате Row Major в памяти, поскольку он хранится в непрерывном блоке памяти. Это означает, что когда я пытаюсь получить доступ к элементу в массиве, компилятор должен выполнить некоторое сложение и умножение, чтобы определить правильное местоположение.

Итак, если бы я сделал следующее

  int x = array[1][2]; // x = 5

Затем компилятор будет использовать эту формулу где:

i = индекс строки j = индекс столбца n = размер одной строки (здесь n = 2)
array = указатель на первый элемент

  *(array + (i*n) + j)
  *(array + (1*2) + 2)  

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

Теперь, в массиве, объявленном в куче, парадигма отличается и требует многоступенчатого решения. Примечание. Я мог бы также использовать новый оператор С++ здесь, но я считаю, что нет никакой разницы в том, как представлены данные.

  int ** array;
  int rowSize = 2;
  // Create a 2 by 3 2d array on the heap
  array = malloc(2 * sizeof(int*));
  for (int i = 0; i < 2; i++) {
      array[i] = malloc(3 * sizeof(int));
  }

  // Populating the array
  int number = 0;
  for (int i = 0; i < 2; i++) {
      for (int j = 0l j < 3; j++) {
          array[i][j] = number++;
      }
  }

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

              int *        int int int
int ** array-> [0]          0   1   2
               [1]          3   4   5

Это означает, что умножение больше не задействовано? Если бы я сделал следующее

int x = array[1][1];

Затем будет выполняться арифметика косвенности/указателя на массиве [1], чтобы получить доступ к указателю на вторую строку, а затем выполнить это еще раз, чтобы получить доступ ко второму элементу. Правильно ли я это говорю?

Теперь, когда есть какой-то контекст, вернемся к вопросу. Если я пишу код для приложения, для которого требуется четкая производительность, например игра, которая имеет около 0,016 секунды для рендеринга фрейма, я должен дважды подумать об использовании массива в стеке против кучи? Теперь я понимаю, что для использования malloc или нового оператора существует однократная стоимость, но в определенный момент (как и анализ Big O), когда набор данных становится большим, лучше ли было бы переходить через динамический массив, чтобы избежать значительного числа строк индексация?

Ответ 1

Они будут применяться к "простой" C (не С++).

Сначала очистите некоторую терминологию

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

Есть 3 места (относительно C), где может стоять переменная (включая массивы):

  • Stack: это локальные переменные функции без static.
  • Раздел данных: пространство выделяется для них при запуске программы. Это любые глобальные переменные (будь то static или нет, там ключевое слово относится к видимости) и любые объявленные локальные переменные функции static.
  • Куча: динамически распределенная память (malloc() и free()), указанная указателем. Вы получаете доступ к этим данным только с помощью указателей.

Теперь посмотрим, как доступны одномерные массивы

Если вы обращаетесь к массиву с индексом константы (может быть #define d, но не const в простой C), этот индекс может быть рассчитан компилятором. Если у вас есть истинный массив в разделе "Данные", он будет доступен без какой-либо косвенности. Если у вас есть указатель (куча) или массив на стеке, всегда необходимо косвенное направление. Таким образом, массивы в разделе "Данные" с этим типом доступа могут быть очень немного быстрее. Но это не очень полезная вещь, которая бы превратила мир.

Если вы обращаетесь к массиву с индексной переменной, он по существу всегда распадается на указатель, поскольку индекс может измениться (например, приращение в цикле for). Сгенерированный код, вероятно, будет очень похожим или даже идентичным для всех типов здесь.

Принесите дополнительные размеры

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

Если вы получаете доступ по индексам, обратите внимание, что память является линейной. Если более поздние размеры истинного массива не кратные 2, компилятор должен будет генерировать умножения. Например, в массиве int arr[4][12]; второе измерение равно 12. Если вы теперь обращаетесь к нему как arr[i][j], где i и j являются индексными переменными, линейная память должна быть проиндексирована как 12 * i + j. Поэтому компилятор должен генерировать код для умножения с константой здесь. Сложность зависит от того, насколько "далека" константа от мощности 2. Здесь полученный код, скорее всего, будет выглядеть как вычисление (i<<3) + (i<<2) + j для доступа к элементу в массиве.

Если вы создаете двумерный "массив" из указателей, размер размеров не имеет значения, поскольку в вашей структуре есть указатели. Здесь, если вы можете написать arr[i][j], это означает, что вы объявили его как, например, int* arr[4], а затем malloc() выделили четыре куска памяти 12 int. Обратите внимание, что ваши четыре указателя (которые компилятор теперь может использовать в качестве базы) также потребляют память, которая не была сделана, если это был истинный массив. Также обратите внимание, что здесь сгенерированный код будет содержать двойную косвенность: сначала код загружает указатель на i из arr, затем он будет загружать int из этого указателя на j.

Если длины "далеки" от степеней 2 (для доступа к элементам необходимо создать сложную "умножить на константные" коды), то с помощью указателей могут генерироваться более быстрые коды доступа.

Как Джеймс Канзе, упомянутый в его ответе, в некоторых случаях компилятор может оптимизировать доступ к истинным многомерным массивам. Такая оптимизация невозможна для массивов, составленных из указателей, поскольку "массив" на самом деле не является линейным блоком памяти в этом случае.

Значение локальности

Если вы разрабатываете обычные настольные/мобильные архитектуры (Intel/ARM 32/64-разрядные процессоры), это также имеет значение. Это то, что, вероятно, сидит в кеше. Если по какой-либо причине ваши переменные уже попали в кеш, они будут доступны быстрее.

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

Использование истинных многомерных массивов вместо составления одного из указателей также может помочь на этом основании, так как истинный массив всегда является линейным блоком памяти, поэтому, как правило, для загрузки может потребоваться меньшее количество блоков кэша. Состав рассеянного указателя (то есть, если использовать отдельно malloc() ed chunks), наоборот, может потребоваться больше блоков кэша и может привести к конфликтам в строке кэша в зависимости от того, как куски физически попали в кучу.

Ответ 2

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

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

Если у вас есть роскошь быть в состоянии выбирать между двумя вариантами, это должно означать, что размеры уже забиты. Затем вам не нужна схема множественного распределения, которую вы проиллюстрировали. Вы можете выполнить одно динамическое распределение вашего 2D-массива. В C:

int (*array)[COLUMNS];
array = malloc(ROWS * sizeof(*array));

В С++:

std::vector<std::array<int, COLUMNS>> array(ROWS);

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

Ответ 3

Обычный способ реализации 2-мерного массива в С++ было бы обернуть его в класс, используя std::vector<int>, и имеют классы, которые вычисляют индекс. Однако:

На все вопросы, касающиеся оптимизации, можно ответить только измерения, и даже тогда они действительны только для компилятора вы используете на машине, на которой выполняете измерения.

Если вы пишете:

int array[2][3] = { ... };

а затем что-то вроде:

for ( int i = 0; i != 2; ++ i ) {
    for ( int j = 0; j != 3; ++ j ) {
        //  do something with array[i][j]...
    }
}

Трудно представить компилятор, который фактически не генерирует что-то вроде:

for ( int* p = array, p != array + whatever; ++ p ) {
    //  do something with *p
}

Это одна из самых фундаментальных оптимизаций вокруг, и было не менее 30 лет.

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

Если вы находитесь на С++, вы обычно пишете класс Matrix используя std::vector<int> для памяти и вычисляя индексы явно используют умножение. (Улучшенная местность вероятно, приведет к повышению производительности, несмотря на умножение.) Это может усложнить задачу компилятор для выполнения вышеуказанной оптимизации, но если это окажется проблема, вы всегда можете предоставить специализированные итераторы для обрабатывая этот конкретный случай. У вас больше читаемый и более гибкий код (например, размеры не имеют быть постоянным), при небольшой или без потери производительности.

Ответ 4

Часто происходит обмен между потреблением памяти и скоростью. Эмпирически я был свидетелем того, что создание массива в стеке происходит быстрее, чем распределение в куче. По мере увеличения размера массива это становится более очевидным.

Вы всегда можете уменьшить потребление памяти. Например, вы можете использовать short или char вместо int и т.д.

По мере увеличения размера массива, особенно с использованием realloc, может быть намного больше замены страницы (вверх и вниз), чтобы поддерживать смежное расположение элементов.

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

Ответ 5

Распределение памяти Stalk обеспечивает более быстрый доступ к данным, чем куча. ЦП будет искать адрес в кеше, если он его не имеет, если он не находит адрес в кеше, он будет искать в основной памяти. Stalk является предпочтительным местом после кэша.