В GCC есть опции настройки x86 для управления стратегией string-ops и временем, когда нужно встроить или вызвать библиотеку. (См. https://gcc.gnu.org/onlinedocs/gcc/x86-Options.html). -mmemcpy-strategy=strategy
принимает alg:max_size:dest_align
триплетов, но путь грубой силы -mstringop-strategy=rep_byte
Мне пришлось использовать __restrict
, чтобы gcc распознал шаблон memcpy, вместо того, чтобы просто выполнять обычную автоматическую векторизацию после проверки перекрытия / возврата к тупому циклу байтов. (Интересный факт: gcc -O3 автоматически векторизуется даже с -mno-sse
, используя полную ширину целочисленного регистра. Таким образом, вы получите тупой байтовый цикл, только если вы скомпилируете с -Os
(оптимизация по размеру) или -O2
( меньше полной оптимизации)).
Обратите внимание, что если src и dst перекрываются с dst > src
, результат будет , а не memmove
. Вместо этого вы получите повторяющийся шаблон с длиной = dst-src
. rep movsb
должен правильно реализовывать точную семантику байт-копии даже в случае наложения, поэтому он все равно будет действителен (но медленен на современных процессорах: я думаю, что микрокод просто вернется к циклу байтов).
gcc попадает в rep movsb
только через распознавание шаблона memcpy
и затем выбор встроенного memcpy как rep movsb
. Он не переходит непосредственно из цикла байтового копирования в rep movsb
, и поэтому возможный псевдоним побеждает оптимизацию. (Для -Os
может быть интересно рассмотреть возможность использования rep movs
напрямую, хотя, когда анализ псевдонимов не может доказать, что это memcpy или memmove, на процессорах с быстрым rep movsb
.)
void fn(char *__restrict dst, const char *__restrict src, int l) {
for (int i=0; i<l; i++) {
dst[i] = src[i];
}
}
Это, вероятно, не должно "считаться", потому что я, вероятно, не рекомендую эти параметры настройки для любого варианта использования, кроме "заставить компилятор использовать rep movs
", так что это не так отличается от встроенного. Я не проверял все параметры настройки -mtune=silvermont
/ -mtune=skylake
/ -mtune=bdver2
(версия Bulldozer 2 = Piledriver) / и т. д., но я сомневаюсь, что любой из них позволяет это сделать. Так что это нереальный тест, потому что никто, использующий -march=native
, не получит этот код поколения.
Но вышеприведенный C компилирует с gcc8.1 -xc -O3 -Wall -mstringop-strategy=rep_byte -minline-all-stringops
в проводнике компилятора Godbolt по этому ассемблеру для x86-64 System V:
fn:
test edx, edx
jle .L1 # rep movs treats the counter as unsigned, but the source uses signed
sub edx, 1 # what the heck, gcc? mov ecx,edx would be too easy?
lea ecx, [rdx+1]
rep movsb # dst=rdi and src=rsi
.L1: # matching the calling convention
ret
Интересный факт: соглашение о вызовах SysV x86-64, оптимизированное для встраивания rep movs
, не является совпадением ( Почему Windows64 использует соглашение о вызовах, отличное от всех других ОС на x86-64? ). Я думаю, что gcc одобрил это, когда разрабатывалось соглашение о вызовах, поэтому он сохранил инструкции.
rep_8byte
выполняет связку настроек для обработки отсчетов, не кратных 8, и, возможно, выравнивание, я не выглядел тщательно.
Я также не проверял другие компиляторы.
Встраивание rep movsb
было бы плохим выбором без гарантии выравнивания, поэтому хорошо, что компиляторы не делают этого по умолчанию. (Пока они делают что-то лучше.) В руководстве по оптимизации Intel есть раздел по memcpy и memset с векторами SIMD против rep movs
. См. Также http://agner.org/optimize/, и другие ссылки на производительность в вики-теге x86 .
(я сомневаюсь, что gcc сделал бы что-то иначе, если бы вы сделали dst=__builtin_assume_aligned(dst, 64);
или любой другой способ передачи выравнивания компилятору, хотя. Например, alignas(64)
на некоторых массивах.)
Микроархитектура Intel IceLake будет иметь функцию «коротких повторений», которая предположительно уменьшает накладные расходы при запуске для rep movs
/ rep stos
, делая их гораздо более полезными для небольших подсчетов. (В настоящее время rep
строковый микрокод имеет значительные накладные расходы при запуске: Какую настройку выполняет REP? )
стратегии memmove / memcpy:
Кстати, memcpy в glibc использует довольно приятную стратегию для небольших входов, которые нечувствительны к перекрытию: две нагрузки -> два хранилища, которые могут частично перекрываться, для копий шириной до 2 регистров. Это означает, что любые входные данные из 4,7 байтов разветвляются, например.
Источник asm от Glibc содержит хороший комментарий, описывающий стратегию: https://code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S.html#19.
Для больших входов он использует регистры SSE XMM, регистры AVX YMM или rep movsb
(после проверки внутренней переменной конфигурации, которая устанавливается на основе обнаружения ЦП, когда glibc инициализирует себя). Я не уверен, какие процессоры он будет использовать rep movsb
, если есть, но есть поддержка для его использования для больших копий.
rep movsb
вполне может быть довольно разумным выбором для небольшого размера кода и не страшного масштабирования с подсчетом для байтового цикла, подобного этому , с безопасной обработкой для маловероятного случая перекрытия.
Затраты на запуск микрокода - большая проблема с его использованием для копий, которые обычно невелики на текущих процессорах.
Вероятно, это лучше, чем байтовый цикл, если средний размер копии может составлять от 8 до 16 байт на текущих процессорах, и / или различное число вызывает много ошибочных прогнозов ветвлений. Это не хорошо , но это менее плохо.
Некоторая оптимизация глазка в последний раз для превращения байтового цикла в rep movsb
может быть хорошей идеей, если компиляция выполняется без векторизации. (Или для компиляторов, таких как MSVC, которые делают цикл байтов даже при полной оптимизации.)
Было бы замечательно, если бы компиляторы знали об этом более напрямую и рассматривали возможность его использования для -Os
(оптимизировать под размер кода больше скорости) при настройке процессоров с функцией Enhanced Rep Movs / Stos Byte (ERMSB). (См. Также Enhanced REP MOVSB для memcpy , где вы найдете множество полезных сведений о пропускной способности памяти x86 в однопоточном режиме по сравнению со всеми ядрами, хранилищах NT, которые не используют RFO, и rep movs
с использованием протокола кеша, избегающего RFO ... ).
На старых процессорах rep movsb
было не так хорошо для больших копий, поэтому рекомендуемая стратегия была rep movsd
или movsq
со специальной обработкой для последних нескольких подсчетов. (Предполагая, что вы вообще собираетесь использовать rep movs
, например, в коде ядра, где вы не можете касаться векторных регистров SIMD.)
Автоматическая векторизация -mno-sse
с использованием целочисленных регистров намного хуже, чем rep movs
для копий среднего размера, которые нагреваются в кэше L1d или L2, поэтому gcc обязательно должен использовать rep movsb
или rep movsq
после проверки на перекрытие, не цикл копирования qword, если только он не ожидает, что небольшие входные данные (например, 64 байта) будут общими.
Единственное преимущество байтового цикла - небольшой размер кода; это в значительной степени дно ствола; умная стратегия, подобная glibc, была бы намного лучше для небольших, но неизвестных размеров копий. Но это слишком много кода для встраивания, и вызов функции имеет определенную стоимость (разлив регистров, перекрывающих вызовы, и зацикливание красной зоны, плюс фактическая стоимость инструкций call
/ ret
и косвенное перенаправление динамического связывания).
Особенно в «холодной» функции, которая не часто запускается (поэтому вы не хотите тратить на нее большой объем кода, увеличивая объем используемой в кэше программы, локальность TLB, страницы, загружаемые с диска , так далее). Если вы пишете asm вручную, вы, как правило, знаете больше об ожидаемом распределении по размеру и сможете встроить быстрый путь с отступлением к чему-то другому.
Помните, что компиляторы принимают решения относительно потенциально большого числа циклов в одной программе, и большая часть кода в большинстве программ находится вне горячих циклов. Они не должны раздуть их всех. Вот почему gcc по умолчанию равен -fno-unroll-loops
, если не включена оптимизация по профилю. (Тем не менее, автоматическая векторизация включена в -O3
и может создавать огромное количество кода для некоторых маленьких циклов, подобных этому. Довольно глупо, что gcc тратит огромные объемы кода на прологи / эпилоги циклов, но незначительные объемы по фактическому циклу; все знают, что цикл будет запускать миллионы итераций каждый раз, когда выполняется внешний код.)
К сожалению, этот векторизованный код gcc не очень эффективен или компактен. Он тратит большой объем кода на код очистки цикла для 16-байтового случая SSE (полностью развертывая 15 байт-копий). С 32-байтовыми векторами AVX мы получаем свернутый байт loop для обработки оставшихся элементов. (Для 17-байтовой копии это довольно ужасно по сравнению с 1 вектором XMM + 1-байтовым или glibc-стилем, перекрывающимся 16-байтовыми копиями). В gcc7 и более ранних версиях он выполняет ту же полную развертку до границы выравнивания, что и пролог цикла, поэтому он вдвое больше раздутый.
IDK, если оптимизация на основе профиля оптимизирует стратегию gcc, например, предпочтение меньшему / более простому коду, когда счетчик мал при каждом вызове, поэтому код с векторизацией не будет достигнут. Или измените стратегию, если код «холодный» и выполняется только один раз или не запускается вообще за один прогон всей программы. Или, если счет обычно составляет 16 или 24 или что-то еще, то скаляр для последних n % 32
байтов ужасен, поэтому в идеале PGO получит его в особых случаях меньшего количества. (Но я не слишком оптимистичен.)
Я мог бы сообщить об ошибке GCC по поводу пропущенной оптимизации об обнаружении memcpy после проверки наложения, вместо того, чтобы оставлять ее только на векторизатор. И / или об использовании rep movs
для -Os
, возможно, с -mtune=icelake
, если появится больше информации об этом уарче.
Большая часть программного обеспечения компилируется только с -O2
, так что глазок для rep movs
, кроме авто-векторизатора, может иметь значение. (Но вопрос в том, положительная или отрицательная разница)!