См. Ответ @ canton7 о некоторых результатах синхронизации и выводе x86 asm, на которых я основывал свои выводы.(У меня нет компилятора Windows или C #).
Аномалии: ассемблер "release" для циклов в SharpLab не соответствует номерам производительности @ canton7 BenchmarkDotNet для любых процессоров Intel или AMD.Asm показывает, что TestDouble
действительно делает a+=b
внутри цикла, но синхронизация показывает, что он работает так же быстро, как и целочисленный цикл 1 /. (задержка добавления FP составляет 3-5 циклов на всех AMD K8 / K10 /Семейство Bulldozer / Ryzen и Intel P6 через Skylake.)
Может быть, это всего лишь оптимизация первого прохода, и после более длительного запуска JIT полностью оптимизирует добавление FP (поскольку значение не возвращается). Так что я думаю, что, к сожалению, у нас все еще нет действующего asm, который на самом деле работает, но мы можем видеть тот беспорядок, который создает оптимизатор JIT.
НадеюсьЯ не понимаю, как TestDoubleStructWithIn
может быть медленнее, чем целочисленный цикл, но только в два раза медленнее (не в 3 раза), если только цикл long
не выполняется с 1 итерацией за такт.При таких высоких показателях накладные расходы при запуске должны быть незначительными.Счетчик циклов, хранящийся в памяти, мог бы объяснить это (наложение узкого места в ~ 6 циклов на каждую итерацию для всего, скрытие задержки всего, кроме очень медленных версий FP.) Но @ canton7 говорит, что они тестировали с помощью сборки Release.Но их i7-8650U могут не поддерживать max-turbo = 4,20 ГГц для всех контуров из-за ограничений мощности / температуры.(минимальная поддерживаемая частота для всех ядер = 1,90 ГГц), поэтому, глядя на время в секундах, а не на циклы, можно было бы отбросить нас к петлям без узкого места?Это по-прежнему не объясняет примитивного двойника с той же скоростью, что и long;они, должно быть, были оптимизированы.
Разумно ожидать, что этот класс будет встроен и оптимизирован так, как вы его используете .Хороший компилятор сделает это.Но JIT должен быстро компилироваться, так что это не всегда хорошо, и ясно, что в этом случае это не для double
.
Для целочисленных циклов 64-разрядное целочисленное сложение на x86-64 имеет 1задержка цикла, и современные суперскалярные процессоры имеют достаточную пропускную способность для запуска цикла, содержащего сложение, с той же скоростью, что и в противном случае пустой цикл, который просто отсчитывает счетчик.Таким образом, мы не можем сказать по времени, что компилятор сделал a + b * 1000000000
вне цикла (но все еще выполнил пустой цикл), или что.
@ canton7 использовал SharpLab, чтобы посмотреть на JIT x86-64asm для автономной версии AddDoubleStructs
и для цикла, который ее вызывает. standalone and loop, x86-64, режим выпуска .
Мы видим, что для примитива long c = a + b
он полностью оптимизировал удаление (но сохранил пустой цикл обратного отсчета)!Если мы используем a = a+b;
, мы получаем фактическую add
инструкцию, даже если a
не возвращается из функции.
loops.AddLongs(Int64, Int64)
L0000: mov eax, 0x3b9aca00 # i = init
# do {
# long c = a+b optimized out
L0005: dec eax # --i;
L0007: test eax, eax
L0009: jg L0005 # }while(i>0);
L000b: ret
Но в версии struct есть действительная инструкция add
отa = LongStruct.Add(a, b);
.(Мы получаем то же самое с a = a+b;
с примитивом long
.)
loops.AddLongStructs(LongStruct a, LongStruct b)
L0000: mov eax, 0x3b9aca00
L0005: add rdx, r8 # a += b; other insns are identical
L0008: dec eax
L000a: test eax, eax
L000c: jg L0005
L000e: ret
Но если мы изменим его на LongStruct.Add(a, b);
(нигде не присваивая результат), мы получим L0006: add rdx, r8
внецикл (поднимая a + b), а затем L0009: mov rcx, rdx
/ L000c: mov [rsp], rcx
внутри цикла.(зарегистрируйте копию и затем сохраните ее в абсолютно пустом месте, совершенно безумно.) В C # (в отличие от C / C ++) запись a+b;
сама по себе в качестве оператора является ошибкой, поэтому мы не можем увидеть, будет ли примитивный эквивалентпо-прежнему приводить к глупым потерянным инструкциям.Only assignment, call, increment, decrement, await, and new object expressions can be used as a statement
.
Я не думаю, что мы можем обвинить любую из этих пропущенных оптимизаций в структуре как таковой . Но даже если вы сравните это с / без add
в цикле, это не приведет к реальному замедлению в этом цикле на современном x86. Пустой цикл достигает узкого места пропускной способности цикла 1 / тактового цикла с использованием всего 2 мопов в цикле (dec
и слияния макросов test/jg
), оставляя место для еще 2 мопов без замедления, если они не создают узкого места. хуже чем 1 / сутки. (https://agner.org/optimize/) например, imul edx, r8d
с задержкой в 3 цикла замедлит цикл в 3 раза. Пропускная способность фронт-энда "4 мопа" предполагает недавнюю Intel. Семейство Bulldozer уже, Ryzen - 5 -широкий.
Это нестатические функции-члены класса (без причины, но я сразу не заметил, поэтому не изменяю их сейчас). В соглашении о вызовах asm первый аргумент (RCX) является указателем this
, а аргументы 2 и 3 являются явными аргументами функции-члена (RDX и R8).
JIT code-gen добавляет test eax,eax
после dec eax
, который уже устанавливает FLAGS (кроме CF, который мы не тестируем) в соответствии с i - 1
. Отправной точкой является положительная постоянная времени компиляции; любой компилятор C оптимизировал бы это до dec eax
/ jnz
. Я думаю, что dec eax
/ jg
также будет работать, проваливаясь, когда dec
дает ноль, потому что 1 > 1
ложно.
DoubleStruct против соглашения о вызовах
Соглашение о вызовах, используемое C # на x86-64, передает 8-байтовые структуры в целочисленные регистры , что отстой для структуры, содержащей double
(потому что она должна быть возвращена в регистры XMM для vaddsd
или другие операции FP). Так что у вашей структуры есть неизбежный недостаток для вызовов не встроенных функций.
### stand-alone versions of functions: not inlined into a loop
# with primitive double, args are passed in XMM regs
standalone.AddDoubles(Double, Double)
L0000: vzeroupper
L0003: vmovaps xmm0, xmm1 # stupid missed optimization defeating the purpose of AVX 3-operand instructions
L0008: vaddsd xmm0, xmm0, xmm2 # vaddsd xmm0, xmm1, xmm2 would do retval = a + b
L000d: ret
# without `in`. Significantly less bad with `in`, see the link.
standalone.AddDoubleStructs(DoubleStruct a, DoubleStruct b)
L0000: sub rsp, 0x18 # reserve 24 bytes of stack space
L0004: vzeroupper # Weird to use this in a function that doesn't have any YMM vectors...
L0007: mov [rsp+0x28], rdx # spill args 2 (rdx=double a) and 3 (r8=double b) to the stack.
L000c: mov [rsp+0x30], r8 # (first arg = rcx = unused this pointer)
L0011: mov rax, [rsp+0x28]
L0016: mov [rsp+0x10], rax # copy a to another place on the stack!
L001b: mov rax, [rsp+0x30]
L0020: mov [rsp+0x8], rax # copy b to another place on the stack!
L0025: vmovsd xmm0, qword [rsp+0x10]
L002c: vaddsd xmm0, xmm0, [rsp+0x8] # add a and b in the SSE/AVX FPU
L0033: vmovsd [rsp], xmm0 # store the result to yet another stack location
L0039: mov rax, [rsp] # reload it into RAX, the return value
L003d: add rsp, 0x18
L0041: ret
Это просто безумие. Этот является code-gen режима выпуска, но компилятор сохраняет структуры в памяти, затем перезагружает + сохраняет их снова перед их фактической загрузкой в FPU. (Я предполагаю, что копия int-> int может быть конструктором, но я понятия не имею. Я обычно смотрю на вывод компилятора C / C ++, который обычно не такой тупой в оптимизированных сборках).
Использование in
в функции arg позволяет избежать дополнительной копии каждого ввода во 2-е место в стеке , но все равно переносит их из целого числа в XMM с сохранением / перезагрузкой.
Это то, что делает gcc для int-> xmm с настройкой по умолчанию, но это пропущенная оптимизация. Агнер Фог говорит (в своем руководстве по микроархам), что руководство по оптимизации AMD предлагает сохранять / перезагружать при настройке на Bulldozer, но он обнаружил, что это не быстрее даже на AMD. (Там, где ALU int-> xmm имеет задержку ~ 10 циклов, в отличие от 2–3 циклов в Intel или Ryzen, с пропускной способностью 1 / такт такой же, как в хранилищах.)
Хорошей реализацией этой функции (если мы застряли в соглашении о вызовах) было бы vmovq xmm0, rdx
/ vmovq xmm1, r8
, затем vaddsd, затем vmovq rax, xmm0
/ ret
.
После встраивания в цикл
Примитив double
оптимизируется аналогично long
:
- Примитив:
double c = a + b;
полностью оптимизирует
a = a + b
(как и используемый @ canton7) все еще делает не , хотя результат все еще не используется. Это будет узким местом с задержкой vaddsd
(от 3 до 5 циклов в зависимости от Bulldozer против Ryzen против Intel до Skylake против Skylake.) Но это остается в регистрах.
loops.AddDoubles(Double, Double)
L0000: vzeroupper
L0003: mov eax, 0x3b9aca00
# do {
L0008: vaddsd xmm1, xmm1, xmm2 # a += b
L000d: dec eax # --i
L000f: test eax, eax
L0011: jg L0008 # }while(i>0);
L0013: ret
Вставка версии структуры
Все накладные расходы на сохранение / перезагрузку должны исчезнуть после встраивания функции в цикл; это большая часть смысла Что ж, сюрприз, он не оптимизирует . 2x store / reload находится на критическом пути цепочки зависимостей данных (перенос FP) !!! Это огромная пропущенная оптимизация.
Задержка сохранения / перезагрузки на современном Intel составляет около 5 или 6 циклов, медленнее, чем добавление FP. a
загружается / сохраняется по пути в XMM0, а затем снова по пути обратно.
loops.AddDoubleStructs(DoubleStruct, DoubleStruct)
L0000: sub rsp, 0x18
L0004: vzeroupper
L0007: mov [rsp+0x28], rdx # spill function args: a
L000c: mov [rsp+0x30], r8 # and b
L0011: mov eax, 0x3b9aca00 # i= init
# do {
L0016: mov rdx, [rsp+0x28]
L001b: mov [rsp+0x10], rdx # tmp_a = copy a to another local
L0020: mov rdx, [rsp+0x30]
L0025: mov [rsp+0x8], rdx # tmp_b = copy b
L002a: vmovsd xmm0, qword [rsp+0x10] # tmp_a
L0031: vaddsd xmm0, xmm0, [rsp+0x8] # + tmp_b
L0038: vmovsd [rsp], xmm0 # tmp_a = sum
L003e: mov rdx, [rsp]
L0042: mov [rsp+0x28], rdx # a = copy tmp_a
L0047: dec eax # --i;
L0049: test eax, eax
L004b: jg L0016 # }while(i>0)
L004d: add rsp, 0x18
L0051: ret
Примитивный цикл double
оптимизирует процесс до простого цикла, сохраняя все в регистрах, без умной оптимизации, которая нарушала бы строгую FP. т.е. не превращая его в множитель, или используя несколько аккумуляторов, чтобы скрыть FP, добавляют задержку. (Но из версии long
мы знаем, что компилятор не будет делать ничего лучше независимо.) Он делает все добавления в виде одной длинной цепочки зависимостей, поэтому один addsd
на 3 (Broadwell или ранее, Ryzen) или 4 цикла (Skylake).