На этот вопрос нельзя ответить точно без некоторых дополнительных деталей, таких как:
- Какова целевая платформа (архитектура ЦП, большая часть, но конфигурация памяти тоже играет роль)?
- Каково распределение и предсказуемость 1 длин копий (и, в меньшей степени, распределение и предсказуемость согласований)?
- Будет ли когда-либо статически известен размер копии во время компиляции?
Тем не менее, я могу указать на пару вещей, которые, вероятно, будут неоптимальными по крайней мере для некоторой комбинации вышеуказанных параметров.
Заявление о переключении из 32 регистров
Оператор switch с 32 регистрами - это симпатичный способ обработки конечных байтов от 0 до 31 и, вероятно, очень хорошо эталонного теста, но может плохо работать в реальном мире по крайней мере из-за двух факторов.
Размер кода
Один только этот оператор switch занимает несколько сотен байтов кода для тела в дополнение к таблице поиска из 32 записей, необходимой для перехода в правильное место для каждой длины. Стоимость этого не будет отображаться в целевом тесте memcpy
на полноразмерном процессоре, потому что все по-прежнему соответствует самому быстрому уровню кеширования: но в реальном мире вы также выполняете другой код, и есть конкуренция за uop кеш и кеши данных и инструкций L1.
Такое количество инструкций может занять полностью 20% эффективного размера вашего кэша uop 3, а промахи кэша uop (и соответствующие циклы перехода от кеша к устаревшему кодировщику) могут легко свести на нет небольшое преимущество, которое дает этот сложный переключатель.
Вдобавок к этому коммутатору требуется таблица поиска с 32 записями и 256 байтами для целей перехода 4. Если вы когда-либо пропустите DRAM при этом поиске, вы говорите о штрафе в 150+ циклов: сколько не промахов вам нужно, чтобы получить switch
того, что стоит, учитывая, что это, вероятно, сэкономит несколько или два максимум ? Опять же, это не будет отображаться в микробенчмарке.
Как бы то ни было, в этом memcpy
нет ничего необычного: такое «исчерпывающее перечисление случаев» распространено даже в оптимизированных библиотеках. Я могу сделать вывод, что либо их разработка была вызвана в основном микробенчмарками, либо это все еще стоит того для большого фрагмента кода общего назначения, несмотря на недостатки. Тем не менее, безусловно, существуют сценарии (давление кэша инструкций и / или данных), где это неоптимально.
Прогнозирование ветвей
Оператор switch полагается на одну косвенную ветвь для выбора среди альтернатив. Это будет эффективно в той степени, в которой предсказатель ветвления может предсказать это косвенное ветвление, что в основном означает, что последовательность наблюдаемых длин должна быть предсказуемой.
Поскольку это непрямая ветвь, существует больше ограничений на предсказуемость ветвления, чем у условного ветвления, поскольку количество записей BTB ограничено. Недавние процессоры добились здесь больших успехов, но можно с уверенностью сказать, что если ряды длин, подаваемых на memcpy
, не следуют простой повторяющейся схеме за короткий период (всего 1 или 2 на старых процессорах), будет ветвление-неверный прогноз по каждому вызову.
Эта проблема особенно коварна, потому что она, вероятно, больше всего навредит вам в реальном мире именно в тех ситуациях, когда микробенчмарк показывает switch
как лучший: короткие длины. Для очень больших длин поведение конечного 31 байта не очень важно, поскольку в нем преобладает массовое копирование. Для коротких отрезков switch
имеет первостепенное значение (действительно, для копий размером 31 байт или меньше выполняется все)!
Для этих коротких длин предсказуемая серия длин очень хорошо работает для switch
, поскольку косвенный прыжок в основном бесплатный. В частности, типичный memcpy
эталонный тест «просматривает» серию длин, многократно используя одну и ту же длину для каждого субтеста, чтобы сообщить результаты для удобного построения графиков «время - длина». switch
отлично справляется с этими тестами, часто выдает результаты, такие как 2 или 3 цикла для небольших отрезков в несколько байтов.
В реальном мире ваши длины могут быть небольшими, но непредсказуемыми. В этом случае косвенная ветвь будет часто неверно предсказывать 5, что приводит к штрафу в ~ 20 циклов на современных процессорах. По сравнению с лучшим случаем пары циклов это на порядок хуже. Таким образом, стеклянная челюсть здесь может быть очень серьезной (то есть поведение switch
в этом типичном случае может быть на порядок хуже, чем у лучших, тогда как при большой длине вы обычно видите разницу максимум в 50% между разные стратегии).
Решения
Итак, как вы можете добиться большего, чем указано выше, по крайней мере, в условиях, когда switch
разваливается?
Использовать устройство Даффа
Одним из решений проблемы размера кода является объединение корпусов переключателей вместе, устройство Даффа - стиль.
Например, собранный код для случаев длины 1, 3 и 7 выглядит так:
Длина 1
movzx edx, BYTE PTR [rsi]
mov BYTE PTR [rcx], dl
ret
Длина 3
movzx edx, BYTE PTR [rsi]
mov BYTE PTR [rcx], dl
movzx edx, WORD PTR [rsi+1]
mov WORD PTR [rcx+1], dx
Длина 7
movzx edx, BYTE PTR [rsi]
mov BYTE PTR [rcx], dl
movzx edx, WORD PTR [rsi+1]
mov WORD PTR [rcx+1], dx
mov edx, DWORD PTR [rsi+3]
mov DWORD PTR [rcx+3], edx
ret
Это можно объединить в один случай с различными переходами:
len7:
mov edx, DWORD PTR [rsi-6]
mov DWORD PTR [rcx-6], edx
len3:
movzx edx, WORD PTR [rsi-2]
mov WORD PTR [rcx-2], dx
len1:
movzx edx, BYTE PTR [rsi]
mov BYTE PTR [rcx], dl
ret
Этикетки ничего не стоят, они объединяют футляры вместе и удаляют две ret
инструкции из 3. Обратите внимание, что основа для rsi
и rcx
здесь изменилась: они указывают на последний байт, из которого / в копируется, а не на первый. Это изменение бесплатное или очень дешевое, в зависимости от кода до перехода.
Вы можете удлинить это для большей длины (например, вы можете прикрепить длины 15 и 31 к цепочке выше) и использовать другие цепи для недостающих длин. Полное упражнение предоставляется читателю. Вероятно, вы сможете уменьшить размер на 50% только с помощью этого подхода, и гораздо лучше, если вы объедините его с чем-то еще, чтобы уменьшить размеры от 16 до 31.
Этот подход помогает только с размером кода (и, возможно, с размером таблицы переходов, если вы уменьшите размер, как описано в 4, и получите меньше 256 байт, что позволяет использовать таблицу поиска байтового размера. Он ничего не делает для предсказуемости.
Перекрывающиеся магазины
Один из приемов, который помогает как с размером кода, так и с предсказуемостью, - это использовать перекрывающиеся хранилища. То есть memcpy
размером от 8 до 15 байтов может быть выполнено без ветвлений с двумя 8-байтовыми хранилищами, причем второе хранилище частично перекрывает первое. Например, чтобы скопировать 11 байтов, вы должны сделать 8-байтовую копию в относительной позиции 0
и 11 - 8 == 3
. Некоторые байты в середине будут «скопированы дважды», но на практике это нормально, поскольку 8-байтовая копия имеет ту же скорость, что и 1-, 2- или 4-байтовая копия.
Код на C выглядит так:
if (Size >= 8) {
*((uint64_t*)Dst) = *((const uint64_t*)Src);
size_t offset = Size & 0x7;
*(uint64_t *)(Dst + offset) = *(const uint64_t *)(Src + offset);
}
... и соответствующая сборка не проблематична:
cmp rdx, 7
jbe .L8
mov rcx, QWORD PTR [rsi]
and edx, 7
mov QWORD PTR [rdi], rcx
mov rcx, QWORD PTR [rsi+rdx]
mov QWORD PTR [rdi+rdx], rcx
В частности, обратите внимание, что вы получаете ровно две загрузки, два хранилища и одно and
(в дополнение к cmp
и jmp
, существование которых зависит от того, как вы организуете окружающий код). Это уже связано или лучше, чем большинство сгенерированных компилятором подходов для 8-15 байтов, которые могут использовать до 4 пар загрузки / сохранения.
Старые процессоры понесли некоторые штрафы за такое «перекрытие хранилищ», но новые архитектуры (по крайней мере, за последнее десятилетие или около того), похоже, справляются с ними без штрафных санкций 6. У этого есть два основных преимущества:
Поведение без ветвей для диапазона размеров. По сути, это квантует ветвление, так что многие значения идут по одному и тому же пути. Все размеры от 8 до 15 (или от 8 до 16, если хотите) идут по одному и тому же пути и не подвержены влиянию ошибочных прогнозов.
По крайней мере, 8 или 9 различных случаев из switch
включены в один случай с долей общего размера кода.
Этот подход может быть объединен с подходом switch
, но с использованием лишь нескольких случаев, или его можно расширить до большего размера с помощью условных перемещений, которые могут, например, выполнять все перемещения от 8 до 31 байта без переходов.
Что сработает лучше всего, опять же, зависит от распределения ветвей, но в целом этот метод «перекрытия» работает очень хорошо.
Выравнивание
Существующий код не касается выравнивания.
Фактически, это вообще недопустимо для C или C ++, поскольку указатели char *
просто приводятся к более крупным типам и разыменовываются, что незаконно, хотя на практике он генерирует коды, которые работают на современных компиляторах x86 (но в факт не прошел бы для платформы с более строгими требованиями к выравниванию).
Кроме того, часто бывает лучше заниматься выравниванием отдельно. Есть три основных случая:
- Источник и место назначения уже согласованы. Даже оригинальный алгоритм здесь будет работать нормально.
- Источник и место назначения относительно выровнены, но абсолютно не выровнены. То есть есть значение
A
, которое может быть добавлено как к источнику, так и к месту назначения, чтобы оба они были выровнены.
- Источник и место назначения полностью не совмещены (т. Е. Фактически не выровнены, и случай (2) не применяется).
Существующий алгоритм будет работать нормально в случае (1). В случае (2) потенциально отсутствует большая оптимизация, поскольку небольшой вводный цикл может превратить невыровненную копию в выровненную.
Это также, вероятно, плохо работает в случае (3), поскольку в общем случае в случае полного смещения вы можете выбрать либо выровнять место назначения, либо источник, а затем продолжить «полувыравнивание».
Штрафы за выравнивание со временем становятся все меньше, и на самых последних чипах они скромны для кода общего назначения, но все же могут быть серьезными для кода с большим количеством загрузок и сохранений. Для больших копий это, вероятно, не имеет большого значения, поскольку в конечном итоге вы ограничите пропускную способность DRAM, но для меньших копий несовпадение может снизить пропускную способность на 50% или более.
Если вы используете NT-хранилища, выравнивание также может быть важным, потому что многие из NT-хранилищ плохо работают с несовпадающими аргументами.
Нет разворачивания
По умолчанию код не разворачивается, а компиляторы разворачивают разное количество раз. Ясно, что это неоптимально, поскольку среди двух компиляторов с разными стратегиями развертывания лучше всего будет не более одного.
Лучший подход (по крайней мере, для известных целей платформы) - определить, какой коэффициент развертывания лучше всего, а затем применить его в коде.
Более того, развертывание часто можно разумно комбинировать с "вводным" нашим "завершающим" кодом, выполняя свою работу лучше, чем мог бы компилятор.
Известные размеры
Основная причина, по которой трудно превзойти «встроенную» memcpy
процедуру с современными компиляторами, заключается в том, что компиляторы не просто вызывают библиотеку memcpy
всякий раз, когда memcpy
появляется в исходном коде. Они знают контракт memcpy
и могут реализовать его с помощью одной встроенной инструкции или даже меньше 7 в правильном сценарии.
Это особенно очевидно при известной длине memcpy
. В этом случае, если длина мала, компиляторы просто вставят несколько инструкций, чтобы выполнить копирование эффективно и на месте. Это не только позволяет избежать накладных расходов на вызов функции, но и позволяет избежать всех проверок размера и т. Д., А также генерирует во время компиляции эффективный код для копии, как и большой switch
в приведенной выше реализации - но без затрат на switch
.
Точно так же компилятор много знает о выравнивании структур в вызывающем коде и может создать код, который эффективно справляется с выравниванием.
Если вы просто реализуете memcpy2
как библиотечную функцию, это будет сложно воспроизвести. Вы можете частично разделить метод на маленькую и большую части: маленькая часть появляется в файле заголовка, а выполняет некоторые проверки размера и потенциально просто вызывает существующий memcpy
, если размер мал, или делегирует библиотечную подпрограмму, если он большой. С помощью магии встраивания вы можете попасть в то же место, что и встроенный memcpy
.
Наконец, вы также можете попробовать уловки с __builtin_constant_p
или его эквивалентами, чтобы эффективно справиться с небольшим известным случаем.
1 Обратите внимание, что здесь я провожу различие между «распределением» размеров - например, можно сказать _ равномерно распределенным между 8 и 24 байтами - и «предсказуемостью» фактической последовательности размеров ( например, есть ли у размеров предсказуемая закономерность)? Вопрос предсказуемости несколько тонкий, потому что он зависит от реализации, поскольку, как описано выше, определенные реализации по своей сути более предсказуемы.
2 В частности, ~ 750 байтов инструкций в clang
и ~ 600 байтов в gcc
только для тела, поверх 256-байтовой таблицы поиска переходов для тела переключателя, в котором было 180-250 инструкций ( gcc
и clang
соответственно). Godbolt link.
3 В основном 200 объединенных мопов из эффективного размера кэша мопов в 1000 инструкций. В то время как недавние x86 имели размер кэша uop около ~ 1500 uop, вы не можете использовать все это за пределами чрезвычайно выделенного заполнения вашей кодовой базы из-за ограничительных правил назначения кода для кеширования.
4 Варианты переключения имеют разную скомпилированную длину, поэтому скачок нельзя вычислить напрямую. Как бы то ни было, это можно было сделать по-другому: они могли бы использовать 16-битное значение в таблице поиска за счет отказа от использования memory-source для jmp
, сократив его размер на 75%.
5 В отличие от условного предсказания ветвления, которое имеет типичную скорость предсказания в наихудшем случае ~ 50% (для полностью случайных ветвей), трудно предсказуемая косвенная ветвь может легко приблизиться к 100%, так как вы не Подбрасывая монетку, вы выбираете почти бесконечный набор целей ветвления. Это происходит в реальном мире: если memcpy
используется для копирования небольших строк с длиной, равномерно распределенной от 0 до 30, код switch
будет неверно предсказывать ~ 97% времени.
6 Конечно, за смещение магазинов могут быть штрафы, но они также, как правило, небольшие и становятся все меньше.
7 Например, memcpy
в стек, за которым следует некоторая манипуляция и копирование в другое место, можно полностью исключить, напрямую перемещая исходные данные в их окончательное место. Даже такие вещи, как malloc
, за которым следует memcpy
, можно полностью исключить.
person
BeeOnRope
schedule
09.05.2017
switch
ветки. Смотрится неплохо. 10/10 совершит :) - person dom0   schedule 08.10.2014void *memcpy(void * restrict s1, const void * restrict s2, size_t n);
- person chux - Reinstate Monica   schedule 08.10.2014switch (Size)
с его 32 случаями совпаденияSize
диапазон0<=Size<32
. Можетswitch (Size&31)
? Избегайте внутреннего генерированияif size > 31
. - person chux - Reinstate Monica   schedule 08.10.2014memcpy
- это максимально эффективное устранение несоосности. Эта реализация неоптимальна в том смысле, что при невыровненных буферах она будет выдавать хранилища, пересекающие строки кэша и страницы, и выполняет много лишней работы по очистке. - person Stephen Canon   schedule 08.10.2014memcpy
реализаций, так что я признаю, что у меня несколько больше опыта, чем у большинства людей. знак равно - person Stephen Canon   schedule 08.10.2014