Загрузка и хранение длинных двойников в x86-64

Сегодня заметил странную вещь. При копировании long double1 все gcc, clang и icc генерируют инструкции fld и fstp с TBYTE операндами памяти.

То есть следующая функция:

void copy_prim(long double *dst, long double *src) {
    *src = *dst;
}

Создает следующую сборку:

copy_prim(long double*, long double*):
  fld TBYTE PTR [rdi]
  fstp TBYTE PTR [rsi]
  ret

Теперь, согласно таблицам Agner, это плохой выбор для производительности, так как fld занимает четыре операции ( ни один не объединен) и fstp занимает колоссальные семь мопов (ни один не объединен) по сравнению, скажем, с одним объединенным моопом каждый для movaps в/из регистра xmm.

Интересно, что clang начинает использовать movaps, как только вы вставляете long double в struct. Следующий код:

struct long_double {
    long double x;
};

void copy_ld(long_double *dst, long_double *src) {
    *src = *dst;
}

Компилируется в ту же сборку с fld/fstp, как показано ранее для gcc и icc, но clang теперь использует:

copy_ld(long_double*, long_double*):
  movaps xmm0, xmmword ptr [rdi]
  movaps xmmword ptr [rsi], xmm0
  ret

Как ни странно, если вы вставите дополнительный элемент int в struct (который удваивает его размер до 32 байтов из-за выравнивания), все компиляторы генерируют код копирования только для SSE:

copy_ldi(long_double_int*, long_double_int*):
  movdqa xmm0, XMMWORD PTR [rdi]
  movaps XMMWORD PTR [rsi], xmm0
  movdqa xmm0, XMMWORD PTR [rdi+16]
  movaps XMMWORD PTR [rsi+16], xmm0
  ret

Есть ли какая-либо функциональная причина для копирования значений с плавающей запятой с помощью fld и fstp или это просто пропущенная оптимизация?


1 Хотя long double (т. е. число с плавающей запятой расширенной точности x86) номинально составляет 10 байт на x86, у него есть sizeof == 16 и alignof == 16, поскольку выравнивание должно быть степенью двойки и размером обычно должен быть не меньше размера выравнивания.


person BeeOnRope    schedule 28.11.2017    source источник
comment
10-байтовое хранилище (я полагаю, 8 + 2) и 16-байтовая перезагрузка попадают в стойло переадресации хранилища. Кроме этого, кажется, что чистая пропущенная оптимизация использует генератор кода по умолчанию для случаев, когда вы не собираетесь с ним работать.   -  person Peter Cordes    schedule 29.11.2017
comment
Это напоминает мне о пропущенных оптимизациях для atomic<double> загрузки/сохранения: частый отказ от целочисленных регистров, даже когда CAS не нужен, только mov. stackoverflow.com/questions/45055402/   -  person Peter Cordes    schedule 29.11.2017
comment
Странно, как туннелирование через struct иногда избегает этого. Похоже, что происходит то, что скаляризация срабатывает для gcc, так что простая структура с одним long double в конечном итоге выглядит как long double, а затем возвращается к плохому codegen (но не на clang). Когда вы добавляете достаточно других вещей, это останавливается и переходит к обычной логике копирования структуры, которая намного лучше. Как ни странно, icc по-прежнему странно обрабатывает некоторые более сложные, например этот. Попробуйте удалить или добавить участника int, и код полностью изменится.   -  person BeeOnRope    schedule 29.11.2017
comment
Я думаю, вы просто видите, что компиляторы умеют копировать целые структуры, независимо от содержимого. Вы просто получаете code-gen по умолчанию для загрузки структуры или long double, когда компилятор просматривает структуру и оптимизирует ее до того, что он будет делать для одного примитивного типа. Вот только с long double это особенно плохо. (Хотя на самом деле копирование вокруг double с SSE2 вместо x87 также лучше, даже с -mfpmath=387. На самом деле ALU uop отсутствует, но задержка перезагрузки хранилища выше на 1c для fld/fstp, чем movq/movq (SKL от Agner Fog)   -  person Peter Cordes    schedule 29.11.2017
comment
@peter, ты заметил странное поведение ICC для long double плюс 4 ints.   -  person BeeOnRope    schedule 29.11.2017
comment
Нет, этого я не смотрел. Похоже, когда нет набивки, ICC видит сквозь структуру и стреляет себе в ногу. ICC очень хорош в автовекторизации (включая циклы поиска с подсчетом поездок в зависимости от данных), но хуже, чем gcc/clang во многих других вещах. (Кстати, ICC18 правильно поддерживает -march=skylake и так далее. ICC17, кажется, распознает только -march=native на Godbolt или, может быть, какие-то странные вещи, такие как corei7-avx, но не skylake-avx512. Но это влияет на генерацию кода, только если есть какое-либо дополнение: godbolt.org/g/SttDQT)   -  person Peter Cordes    schedule 29.11.2017


Ответы (1)


Это похоже на большую пропущенную оптимизацию для кода, который должен копировать long double без его обработки. fstp m80/fld m80 задержка приема-передачи 8 циклов на Skylake по сравнению с 5 для movdqa переадресации хранилища из хранилища в перезагрузку. Что еще более важно, Agner перечисляет fstp m80 как пропускную способность один на 5 тактов, так что происходит что-то неконвейерное!

Единственная возможная выгода, о которой я могу думать, - это переадресация из магазина long double, который все еще находится в полете. Рассмотрим цепочку зависимостей данных, которая включает в себя некоторую математику x87, хранилище long double, затем вашу функцию, затем загрузку long double и еще математику x87. Согласно таблицам Агнера, fld/fstp добавит 8 циклов, но movdqa увидит остановку переадресации с сохранением и добавит 5 + 11 циклов или около того для медленного перенаправления с сохранением.

Вероятно, стратегия с наименьшей задержкой для копирования m80 будет состоять из 64-битных + 16-битных целочисленных mov/movzx инструкций загрузки/сохранения. Мы знаем, что fstp m80 и fld m80 используют 2 отдельных операций хранения данных (порт 4) или загрузки (p23), и я думаю, мы можем предположить, что они разбиты на 64-битную мантисса и 16-битный знак: экспонента.

Конечно, для пропускной способности и задержки в случаях, отличных от переадресации хранилища, movdqa кажется лучшим выбором, потому что, как вы указываете, ABI гарантирует выравнивание по 16 байтам. 16-байтовое хранилище может пересылаться на fld m80.


Тот же аргумент применим для копирования double или float с целым числом вместо x87 (например, 32-битного кода): fld m32/fstp m32 имеет задержку приема-передачи на 1 цикл выше, чем SSE movd, и задержку на 2 цикла выше, чем целое число mov на процессорах семейства Sandybridge. (В отличие от PowerPC/Cell load-hit-store, нет штрафа за переадресацию хранилища из хранилищ FP в целочисленные загрузки. Строгая модель упорядочения памяти x86 не позволяет использовать отдельные буферы хранилища для FP и целых чисел, если это то, что делает PPC.)

Как только компилятор понимает, что он не собирается использовать инструкции FP для float / double / long double, он обычно должен заменить загрузку/сохранение на не-x87. Но копирование double или float с помощью x87 допустимо, если возникает проблема с давлением в целочисленных/SSE-регистрах.

Заполнение целочисленного регистра в 32-битном коде почти всегда велико, а -mfpmath=sse используется по умолчанию для 64-битного кода. Вы можете представить себе редкие случаи, когда использование x87 для копирования double в 64-битном коде стоило бы того, но компиляторы скорее ухудшили бы ситуацию, чем улучшили бы, если бы они искали места для использования x87. В gcc есть -mfpmath=sse+387, но обычно это не очень хорошо. (И это даже не учитывая нагрузку на файл физического регистра при использовании x87 + SSE. Будем надеяться, что «пустое» состояние x87 не использует никаких физических регистров. xsave знает о том, что части архитектурного состояния пусты, поэтому он может избежать их сохранения... )

person Peter Cordes    schedule 29.11.2017