Является ли доступ к статически или динамически распределенной памяти быстрее?
Есть два способа выделения глобального массива в C:
Вопрос в том, какой метод имеет лучшую производительность? И на сколько?
Как понятно, первый метод должен быть быстрее.
Поскольку со вторым методом для доступа к массиву вы должны обращаться к адресу разыменования элемента каждый раз, когда он обращается, например:
- прочитайте переменную
data
, которая содержит указатель на начало массива
- вычислить смещение для конкретного элемента
- доступ к элементу
При первом методе компилятор жестко кодирует адрес переменной data
в код, первый шаг пропускается, поэтому мы имеем:
- вычислить смещение для определенного элемента по фиксированному адресу, определенному во время компиляции
- получить доступ к элементу массива
Каждый доступ к памяти эквивалентен примерно 40 тактам процессора, поэтому, используя динамическое распределение, специально для нечетких чтений может иметь значительное снижение производительности и статическое распределение, поскольку переменная data
может быть удалена из кэша более часто доступная переменная. Напротив, стоимость разыменования статически назначенной глобальной переменной равна 0, так как ее адрес уже закодирован в коде.
Правильно ли это?
Ответы
Ответ 1
Всегда нужно ориентироваться, чтобы быть уверенным. Но, игнорируя влияние кеша на данный момент, эффективность может зависеть от того, насколько спорадически вы обращаетесь к двум. Здесь рассмотрим char data_s[65536]
и char *data_p = malloc(65536)
Если доступ является спорадическим, статический/глобальный будет немного быстрее:
// slower because we must fetch data_p and then store
void
datasetp(int idx,char val)
{
data_p[idx] = val;
}
// faster because we can store directly
void
datasets(int idx,char val)
{
data_s[idx] = val;
}
Теперь, если мы рассмотрим кеширование, datasetp
и datasets
будут примерно одинаковыми [после первого доступа], так как выборка data_p
будет выполняться из кеша [нет гарантии, но вероятно], поэтому разница во времени намного меньше.
Однако при доступе к данным в узком цикле они будут примерно одинаковыми, потому что компилятор [оптимизатор] предварительно запрограммирует data_p
в начале цикла и помещает его в регистр:
void
datasetalls(char val)
{
int idx;
for (idx = 0; idx < 65536; ++idx)
data_s[idx] = val;
}
void
datasetallp(char val)
{
int idx;
for (idx = 0; idx < 65536; ++idx)
data_p[idx] = val;
}
void
datasetallp_optimized(char val)
{
int idx;
register char *reg;
// the optimizer will generate the equivalent code to this
reg = data_p;
for (idx = 0; idx < 65536; ++idx)
reg[idx] = val;
}
Если доступ настолько спорадичен, что data_p
выдается из кеша, разница в производительности не имеет большого значения, поскольку доступ к [либо] массиву нечатен. Таким образом, это не цель для настройки кода.
Если такое выселение происходит, фактический массив данных, скорее всего, также будет выселен.
Значительно больший массив может иметь больше эффекта (например, если вместо 65536
мы имели 100000000
, то простой обход высекал бы data_p
, и к тому времени, когда мы достигли конца массива, самый левый записи уже будут выселены.
Но в этом случае дополнительный выбор из data_p
будет накладным 0.000001%.
Таким образом, это помогает либо тестировать [или модель] конкретный шаблон использования/доступа.
UPDATE:
Основываясь на некоторых дополнительных экспериментах [вызванных комментарием Питера], функция datasetallp
не оптимизируется к эквиваленту datasetallp_optimized
для определенных условий из-за строгих соображений сглаживания.
Поскольку массивы char
[или unsigned char
], компилятор генерирует выборка data_p
для каждой итерации цикла. Обратите внимание, что если массивы не char
(например, int
), оптимизация происходит, и data_p
выбирается только один раз, потому что char
может псевдонимы чем-либо, но int
более ограничен.
Если мы изменим char *data_p
на char *restrict data_p
, получим оптимизированное поведение. Добавление restrict
сообщает компилятору, что data_p
не будет ничего псевдоним [даже сам], поэтому безопасно оптимизировать выборку.
Личное примечание. Хотя я это понимаю, мне кажется, что без restrict
компилятор должен предположить, что data_p
может использовать псевдоним для себя. Хотя я уверен, что есть и другие [одинаково изобретенные] примеры, единственные, о которых я могу думать, - это data_p
, указывающие на себя или что data_p
является частью структуры:
// simplest
char *data_p = malloc(65536);
data_p = (void *) &data_p;
// closer to real world
struct mystruct {
...
char *data_p;
...
};
struct mystruct mystruct;
mystruct.data_p = (void *) &mystruct;
Это были бы случаи, когда оптимизация выборки была бы неправильной. Но, ИМО, они отличаются от простого случая, с которым мы имеем дело. По крайней мере, версия структуры. И, если программист должен сделать первый, IMO, они получают то, что они заслуживают [и компилятор должен разрешить оптимизацию выборки].
Для себя я всегда передаю код, эквивалентный datasetallp_optimized
[sans register
], поэтому я обычно не вижу многопроцессорную проблему [если вы] слишком много. Я всегда верил в "предоставление компилятору полезного намека" относительно моих намерений, поэтому я просто делаю это аксиоматически. Он сообщает компилятору и другому программисту, что цель - "выборка data_p
только один раз".
Кроме того, проблема с несколькими записями не возникает при использовании data_p
для ввода [поскольку мы ничего не изменяем, алиасинг не рассматривается]:
// does fetch of data_p once at loop start
int
datasumallp(void)
{
int idx;
int sum;
sum = 0;
for (idx = 0; idx < 65536; ++idx)
sum += data_p[idx];
return sum;
}
Но, хотя это может быть довольно распространено, "жесткая" функция манипуляции с примитивным массивом с явным массивом [либо data_s
или data_p
] часто менее полезна, чем передача адреса массива в качестве аргумента.
Примечание: clang
будет оптимизировать некоторые функции, используя data_s
в memset
вызовы, поэтому во время экспериментов я немного изменил код примера, чтобы предотвратить это.
void
dataincallx(array_t *data,int val)
{
int idx;
for (idx = 0; idx < 65536; ++idx)
data[idx] = val + idx;
}
Это не страдает от проблемы многозадачности. То есть dataincallx(data_s,17)
и dataincallx(data_p,37)
работают примерно одинаково [с исходной дополнительной data_p
выборкой]. Скорее всего, это то, что можно использовать вообще [лучшее повторное использование кода и т.д.].
Итак, различие между data_s
и data_p
становится немного более спорным. В сочетании с разумным использованием restrict
[или использованием типов, отличных от char
], служебные данные извлечения data_p
могут быть сведены к минимуму до такой степени, что это не так уж важно.
Теперь это больше подходит для архитектурных/дизайнерских вариантов выбора массива фиксированного размера или динамического выделения. Другие отметили компромиссы.
Это зависит от использования.
Если у нас было ограниченное число функций массива, но большое количество различных массивов, передача адреса массива в функции является явным победителем.
Однако, если у нас было большое количество функций манипуляции с массивами и [скажем] один массив (например, [2D] массив - игровая плата или сетка), может быть лучше, что каждая функция ссылается на глобальную [либо data_s
или data_p
].
Ответ 2
Вычисление смещений не является большой проблемой производительности. Вы должны подумать о том, как вы будете использовать массив в своем коде. Скорее всего, вы напишете что-то вроде data[i] = x;
, а затем независимо от того, где хранится data
, программа должна загрузить базовый адрес и рассчитать смещение.
Сценарий, в котором компилятор может жестко закодировать адрес в случае статически выделенного массива, возникает только тогда, когда вы пишете что-то вроде data[55] = x;
, что, вероятно, является гораздо менее вероятным вариантом использования.
Во всяком случае, мы говорим о нескольких тиках ЦП здесь и там. Это не то, что вы должны преследовать, пытаясь выполнить ручную оптимизацию.
Каждый доступ к памяти эквивалентен примерно 40 тактам CPU
Что!? Какой процессор это? Какой-то древний компьютер с 1960 года?
Что касается кэш-памяти, эти проблемы могут быть более обоснованными. Возможно, что статически распределенная память использует кеш данных лучше, но это просто предположение, и вам нужно иметь очень специфический процессор, чтобы иметь это обсуждение.
Тем не менее существует значительная разница в производительности между статическим и динамическим распределением, и это само распределение. Для каждого вызова malloc
есть вызов API OS, который, в свою очередь, запускает функцию поиска, проходящую через кучу, и ищет свободный сегмент. Библиотеке также необходимо отслеживать адрес этого сегмента внутри, так что, когда вы вызываете free()
, он знает, сколько памяти будет выпущено. Кроме того, чем больше вы называете malloc/free, тем больше сегментируется куча.
Ответ 3
Я думаю, что локальность данных гораздо важнее, чем вычисление базового адреса массива. (Я мог бы представить случаи, когда доступ к содержимому указателя чрезвычайно быстрый, потому что он находится в регистре, тогда как смещение указателя стека или текстового сегмента является постоянной времени компиляции, доступ к регистру может быть быстрее.)
Но реальная проблема - это локальность данных, что часто является причиной осторожности с динамической памятью в критически важных циклах производительности. Если у вас есть более динамически распределенные данные, которые находятся близко к вашему массиву, скорее всего, память останется в кеше. Если у вас есть данные, разбросанные по всей ОЗУ, распределенные в разное время, у вас может быть много промахов кэш, к которым они обращаются. В этом случае было бы лучше разместить их статически (или в стеке) рядом друг с другом, если это возможно.
Ответ 4
Здесь есть небольшой эффект. Это вряд ли будет значительным, но это реально. Часто требуется одна дополнительная инструкция для разрешения дополнительного уровня косвенности для глобального указателя на буфер вместо глобального массива. Для большинства применений другие соображения будут более важными (например, повторное использование одного и того же пространства царапин, что позволяет каждой функции использовать собственный буфер нуля). Также: избегайте пределов размера компиляции!
Этот эффект присутствует только при непосредственном обращении к глобальному, а не по обходу адреса в качестве параметра функции. Оптимизация ссылок/цельной программы может полностью вернуться к тому, где глобально используется как функция arg изначально, и иметь возможность использовать ее в любом другом коде.
Позвольте сравнить простые тестовые функции, скомпилированные clang 3.7 для x86-64 (SystemV ABI, поэтому первый arg находится в rdi
). Результаты по другим архитектурам будут по существу одинаковыми:
int data_s[65536];
int *data_p;
void store_s(int val) { data_s[val] = val; }
movsxd rax, edi ; sign-extend
mov dword ptr [4*rax + data_s], eax
ret
void store_p(int val) { data_p[val] = val; }
movsxd rax, edi
mov rcx, qword ptr [rip + data_p] ; the extra level of indirection
mov dword ptr [rcx + 4*rax], eax
ret
Хорошо, так что накладные расходы на одну дополнительную нагрузку. (mov r64, [rel data_p]
). В зависимости от того, где хранятся другие статические/глобальные объекты data_p
, он может оставаться горячим в кеше, даже если мы его часто не используем. Если это в строке кэша без каких-либо других часто посещаемых данных, тем не менее он тратит большую часть этой строки кэша.
Накладные расходы выплачиваются только один раз за вызов функции, хотя даже если есть цикл. (Если массив не является массивом указателей, так как правила сглаживания C требуют, чтобы компилятор предполагал, что любой указатель может указывать на data_p
, если только он не может доказать обратное. Это основная угроза производительности при использовании глобальных указателей-указателей.)
Если вы не используете restrict
, компилятор все равно должен предположить, что буферы, на которые указывают int *data_p1
и int *data_p2
, могут перекрываться, хотя это и мешает autovectorization, циклическому разворачиванию и многим другим оптимизациям. Статические буферы не могут пересекаться с другими статическими буферами, но restrict
по-прежнему необходим при использовании статического буфера и указателя в том же цикле.
В любом случае, давайте взглянем на код для очень простых циклов типа memset:
void loop_s(int val) { for (int i=0; i<65536; ++i) data_s[i] = val; }
mov rax, -262144 ; loop counter, counting up towards zero
.LBB3_1: # =>This Inner Loop Header: Depth=1
mov dword ptr [rax + data_s+262144], edi
add rax, 4
jne .LBB3_1
ret
Обратите внимание, что clang использует здесь эффективный адрес, не относящийся к RIP, для data_s
, потому что он может.
void loop_p(int val) { for (int i=0; i<65536; ++i) data_p[i] = val; }
mov rax, qword ptr [rip + data_p]
xor ecx, ecx
.LBB4_1: # =>This Inner Loop Header: Depth=1
mov dword ptr [rax + 4*rcx], edi
add rcx, 1
cmp rcx, 65536
jne .LBB4_1
ret
Обратите внимание на mov rax, qword [rip + data_p]
в loop_p
и менее эффективную структуру цикла, поскольку он использует счетчик циклов в качестве индекса массива.
gcc 5.3 имеет гораздо меньшую разницу между двумя циклами: он получает начальный адрес в регистр и увеличивает его, сравнивая с конечный адрес. Таким образом, он использует режим однорежимной адресации для магазина, который может лучше работать на процессорах Intel. Единственная разница в структуре цикла/служебных данных для gcc заключается в том, что статическая версия буфера получает начальный указатель в регистр с mov r32, imm32
, а не нагрузкой из памяти. (Таким образом, адрес является непосредственной константой, встроенной в поток команд.)
В код общей библиотеки, а в OS X, где все исполняемые файлы должны быть независимы от позиции, единственным вариантом является gcc-путь. Вместо mov r32, imm32
, чтобы получить адрес в регистр, он будет использовать lea r64, [RIP + displacement]
. Возможность сохранить инструкцию путем встраивания абсолютного адреса в другие инструкции исчезает, когда вам нужно компенсировать адрес переменной величиной (например, индексом массива). Если индекс массива является константой времени компиляции, его можно сместить в смещение для RIP-относительной нагрузки или хранения вместо LEA. Для цикла над массивом это может произойти только при полной разворачивании и, следовательно, маловероятно.
Тем не менее, дополнительный уровень косвенности по-прежнему существует с указателем на динамически выделенную память. По-прежнему существует вероятность пропустить кеш или TLB при выполнении нагрузки вместо LEA
.
Обратите внимание, что глобальные данные (в отличие от static
) имеют дополнительный уровень косвенности через глобальную таблицу смещения, но в верхней части косвенности или ее отсутствия при динамическом распределении. компиляция с gcc 5.3 -fPIC
.
int global_data_s[65536];
int access_global_s(int i){return global_data_s[i];}
mov rax, QWORD PTR [email protected][rip] ; load from a RIP-relative address, instead of an LEA
movsx rdi, edi
mov eax, DWORD PTR [rax+rdi*4] ; load, indexing the array
ret
int *global_data_p;
int access_global_p(int i){return global_data_p[i];}
mov rax, QWORD PTR [email protected][rip] ; extra layer of indirection through the GOT
movsx rdi, edi
mov rax, QWORD PTR [rax] ; load the pointer (the usual layer of indirection)
mov eax, DWORD PTR [rax+rdi*4] ; load, indexing the array
ret
Если я правильно понимаю это, компилятор не предполагает, что определение символа для глобальных символов в текущем блоке компиляции - это определения, которые будут фактически использоваться во время связи. Таким образом, относительное смещение RIP не является константой времени компиляции. Благодаря динамической компоновке времени выполнения она также не является константой времени ссылки, поэтому используется дополнительный уровень косвенности через GOT.Это печально, и я надеюсь, что компиляторы на OS X не имеют накладных расходов для глобальных переменных. С помощью -O0 -fwhole-program
в godbolt я вижу, что даже глобальные глобальные объекты доступны только с RIP-относительной адресацией, а не через GOT, поэтому, возможно, эта опция еще более ценна, чем обычно, при создании независимых от положения исполняемых файлов. Надеюсь, оптимизация времени соединения также работает, потому что это можно использовать при создании разделяемых библиотек.
Как указывалось многими другими ответами, существуют и другие важные факторы, такие как локальность памяти и накладные расходы на фактическое выполнение allocate/free. Они не имеют большого значения для большого буфера (нескольких страниц), который был выделен один раз при запуске программы. См. Комментарии к ответу Питера А. Шнайдера.
Динамическое распределение может принести значительные выгоды, хотя, если вы в конечном итоге используете ту же память, что и место для царапин для нескольких разных вещей, поэтому он остается горячим в кеше. Предоставление каждой функции собственного статического буфера для пространства с царапинами часто является плохим ходом, если они не нужны одновременно: грязная память должна быть записана обратно в основную память, когда она больше не нужна, и навсегда останется частью программного пространства.
Хорошим способом получения буферов с маленькими царапинами без накладных расходов malloc
(или new
) является их создание в стеке (например, как локальные переменные массива). C99 допускает локальные массивы переменного размера, такие как foo(int n) { int buf[n]; ...; }
Будьте осторожны, чтобы не переусердствовать и не закончилось пространство стека, но текущая страница стека будет горячей в TLB. Функции _local
в моих ссылках godbolt выделяют в стек массив переменных размеров, который имеет некоторые накладные расходы для повторного выравнивания стека с границей 16B после добавления размера переменной. Похоже, что clang заботится о том, чтобы замаскировать бит знака, но gcc-выход выглядит так, как будто он просто разрывается веселыми и захватывающими способами, если n
отрицательный. (В godbolt используйте "двоичную" кнопку, чтобы получить выход дизассемблера вместо вывода asm компилятора, поскольку разборка использует hex для непосредственных констант, например clang movabs rcx, 34359738352
is 0x7fffffff0
). Хотя это требует нескольких инструкций, это намного дешевле, чем malloc
. Среднее или большое распределение с помощью malloc
, например 64kiB, обычно выполняет системный вызов mmap
. Но это стоимость распределения, а не стоимость доступа к выделенному.
Наличие буфера в стеке означает, что указатель стека является базовым адресом для индексации в нем. Это означает, что для хранения этого указателя не требуется дополнительный регистр, и его не нужно загружать из любой точки.
Если какие-либо элементы статически инициализируются на ненулевое значение в статическом (или глобальном), весь массив или структура будет сидеть там в исполняемом файле, что является большой потерей пространства, если большинство записей должно быть равно нулю при запуске программы, (Или если данные могут быть вычислены на лету быстро.)
В некоторых системах наличие гигантского массива с нулевым инициализированным статическим массивом не стоит вам ничего, если вы даже не читаете части, которые вам не нужны. Ленивое отображение памяти означает, что ОС сопоставляет все страницы вашего гигантского массива с одной и той же страницей обнуленной памяти и выполняет копирование на запись. Воспользоваться этим было бы уродливым взломом производительности, которое можно было использовать только в том случае, если бы вы были уверены, что действительно этого хотите, и были уверены, что ваш код никогда не будет запущен в системе, где ваш char data[1<<30]
фактически использовал бы эту большую память сразу.
Каждый доступ к памяти эквивалентен примерно 40 тактам процессора.
Это вздор. Задержка может быть где угодно от 3 или 4 циклов (кэш L1) до нескольких сотен циклов (основная память) или даже с ошибкой страницы, требующей доступа к диску. Помимо сбоя страницы, большая часть этой задержки может перекрываться с другой работой, поэтому влияние на пропускную способность может быть значительно ниже. Нагрузка с постоянного адреса может начинаться, как только инструкция переходит в ядро из-за порядка, так как это начало новой цепочки зависимостей. Размер окна вне порядка ограничен (в ядре Intel Skylake имеется буфера повторного заказа в 224 раза и может одновременно иметь 72 загрузки в полете). Полный провал кэша (или, что еще хуже, промах TLB, за которым следует промаха в кеше), часто останавливает исполнение вне очереди. См. http://agner.org/optimize/ и другие ссылки в x86 wiki. Также см. это сообщение в блоге о влиянии размера ROB на то, сколько промахов кеша может быть в полете сразу.
Ячейки вне очереди для других архитектур (например, ARM и PPC) аналогичны, но ядра в порядке больше страдают от промахов в кешках, потому что они не могут ничего делать, пока ждут. (Большие ядра x86, такие как микроархитектура Intel и AMD (не микроархитектуры Silvermont или Jaguar), имеют больше ресурсов вне очереди, чем другие проекты. AFAIK, большинство ядер ARM имеют значительно меньшие буферы для запуска ранних ранних нагрузок и/или скрытие латентности кэширования.)
Ответ 5
Я бы сказал, что вы действительно должны его профилировать. Теоретически вы правы, но есть некоторые основные вещи, которые вы должны запомнить.
Язык C - это язык высокого уровня, такой как многие, существуют сегодня, и вы говорите машине, что делать. Подойдя ближе к машинным кодам, мы будем рассматривать ASM или аналогичные. Если вы создаете код, компилируете и связываете или что-то еще, компилятор будет стараться правильно выполнить то, что вы требуете и оптимизируете (если вы этого не хотите). Помните, существуют также такие понятия, как компиляция Just-In-Time (JIT).
Поэтому я не могу ответить на ваш вопрос. С одной стороны, вы можете быть уверены. Статический массив, скорее всего, будет быстрее, особенно с размером 65536, потому что есть больше шансов на оптимизацию для компилятора. Это может зависеть от того, какой размер вы определили. Для GCC 65536 байтов, похоже, является общим для стеков и кешей, не уверен. Некоторые компиляторы могут даже сказать, что массив слишком велик, потому что он пытается сохранить его в других иерархиях памяти, таких как кеши, которые также быстрее, чем в памяти произвольного доступа.
Наконец, но не в последнюю очередь помните, что современные операционные системы также имеют управление памятью с использованием виртуальной памяти.
Статическая память может храниться в сегментах данных и, скорее всего, будет загружаться при выполнении программы, но помните, что это тоже время, которое вам нужно рассмотреть. Выделить память ОС при запуске программы или выполнить ее во время выполнения? Это действительно зависит от вашего приложения.
Итак, я думаю, вам действительно нужно сравнить ваши результаты и посмотреть, насколько они быстрее. Но в качестве тенденции я бы сказал, что ваш статический массив скомпилирует код, который будет работать быстрее.