Выражение шаблонов против рукописного кода

В настоящее время я пишу библиотеку выражений шаблона С++ и сравниваю некоторые экземпляры с рукописным кодом на уровне сборки. Рукописная функция следующая:

spinor multiply(vector const& a, vector const& b)
{
        spinor result = {
                a.at<1>() * b.at<1>() - a.at<2>() * b.at<2>()
                          - a.at<4>() * b.at<4>() - a.at<8>() * b.at<8>(),
                a.at<1>() * b.at<2>() - a.at<2>() * b.at<1>(),
                a.at<1>() * b.at<4>() - a.at<4>() * b.at<1>(),
                a.at<1>() * b.at<8>() - a.at<8>() * b.at<1>(),
                a.at<2>() * b.at<4>() - a.at<4>() * b.at<2>(),
                a.at<2>() * b.at<8>() - a.at<8>() * b.at<2>(),
                a.at<4>() * b.at<8>() - a.at<8>() * b.at<4>()
        };

        return result;
}

Класс vector - это всего лишь обертка над четырьмя удвоениями, которую можно прочитать с помощью функции-члена at<index>(). Из-за конструктивных решений индексы для четырех компонентов 1, 2, 4, 8, к которым обращаются с помощью at<index>() вместо обычного 0, 1, 2, 3.

Цель этой функции - вернуть результат умножения двух векторов (в пространстве Минковского). Если вы знакомы с геометрической алгеброй, вы увидите точечный продукт (первый компонент result, симметричный при обмене a и b) и клин-продукт (остальные компоненты, антисимметричные при обмене a и b). Если вы не знакомы с геометрической алгеброй, просто возьмите эту функцию в качестве рецепта для умножения векторов.

Если я скомпилирую функцию выше с GCC 4.7 и посмотрю на разборку, заданную objdump -SC a.out, это даст мне следующий результат:

400bc0: movsd  0x8(%rsi),%xmm6
400bc5: mov    %rdi,%rax
400bc8: movsd  (%rsi),%xmm8
400bcd: movsd  0x8(%rdx),%xmm5
400bd2: movapd %xmm6,%xmm9
400bd7: movsd  (%rdx),%xmm7
400bdb: movapd %xmm8,%xmm0
400be0: mulsd  %xmm5,%xmm9
400be5: movsd  0x10(%rsi),%xmm4
400bea: mulsd  %xmm7,%xmm0
400bee: movsd  0x10(%rdx),%xmm1
400bf3: movsd  0x18(%rdx),%xmm3
400bf8: movsd  0x18(%rsi),%xmm2
400bfd: subsd  %xmm9,%xmm0
400c02: movapd %xmm4,%xmm9
400c07: mulsd  %xmm1,%xmm9
400c0c: subsd  %xmm9,%xmm0
400c11: movapd %xmm3,%xmm9
400c16: mulsd  %xmm2,%xmm9
400c1b: subsd  %xmm9,%xmm0
400c20: movapd %xmm6,%xmm9
400c25: mulsd  %xmm7,%xmm9
400c2a: movsd  %xmm0,(%rdi)
400c2e: movapd %xmm5,%xmm0
400c32: mulsd  %xmm8,%xmm0
400c37: subsd  %xmm9,%xmm0
400c3c: movapd %xmm4,%xmm9
400c41: mulsd  %xmm7,%xmm9
400c46: mulsd  %xmm2,%xmm7
400c4a: movsd  %xmm0,0x8(%rdi)
400c4f: movapd %xmm1,%xmm0
400c53: mulsd  %xmm8,%xmm0
400c58: mulsd  %xmm3,%xmm8
400c5d: subsd  %xmm9,%xmm0
400c62: subsd  %xmm7,%xmm8
400c67: movapd %xmm4,%xmm7
400c6b: mulsd  %xmm5,%xmm7
400c6f: movsd  %xmm0,0x10(%rdi)
400c74: mulsd  %xmm2,%xmm5
400c78: movapd %xmm1,%xmm0
400c7c: mulsd  %xmm6,%xmm0
400c80: movsd  %xmm8,0x18(%rdi)
400c86: mulsd  %xmm3,%xmm6
400c8a: mulsd  %xmm2,%xmm1
400c8e: mulsd  %xmm4,%xmm3
400c92: subsd  %xmm7,%xmm0
400c96: subsd  %xmm5,%xmm6
400c9a: subsd  %xmm1,%xmm3
400c9e: movsd  %xmm0,0x20(%rdi)
400ca3: movsd  %xmm6,0x28(%rdi)
400ca8: movsd  %xmm3,0x30(%rdi)
400cad: retq   
400cae: nop
400caf: nop

Это выглядит довольно хорошо для меня - компоненты первого (%rsi) и второго (%rdx) векторов доступны только один раз, а фактические вычисления выполняются только в регистре. В конце результат записывается по адресу в регистре %rdi. Поскольку это первый регистр аргументов, я думаю, что здесь используется оптимизация возвращаемого значения.

Сравните это со следующим списком для версии шаблона выражения выше:

400cb0: mov    (%rsi),%rdx
400cb3: mov    0x8(%rsi),%rax
400cb7: movsd  0x1f1(%rip),%xmm4        # 400eb0 <_IO_stdin_used+0x10>
400cbe: 
400cbf: movsd  0x10(%rdx),%xmm3
400cc4: movsd  0x18(%rdx),%xmm0
400cc9: mulsd  0x10(%rax),%xmm3
400cce: xorpd  %xmm4,%xmm0
400cd2: mulsd  0x18(%rax),%xmm0
400cd7: movsd  0x8(%rdx),%xmm2
400cdc: movsd  (%rdx),%xmm1
400ce0: mulsd  0x8(%rax),%xmm2
400ce5: mulsd  (%rax),%xmm1
400ce9: subsd  %xmm3,%xmm0
400ced: subsd  %xmm2,%xmm0
400cf1: addsd  %xmm0,%xmm1
400cf5: movsd  %xmm1,(%rdi)
400cf9: movsd  (%rdx),%xmm0
400cfd: movsd  0x8(%rdx),%xmm1
400d02: mulsd  0x8(%rax),%xmm0
400d07: mulsd  (%rax),%xmm1
400d0b: subsd  %xmm1,%xmm0
400d0f: movsd  %xmm0,0x8(%rdi)
400d14: movsd  (%rdx),%xmm0
400d18: movsd  0x10(%rdx),%xmm1
400d1d: mulsd  0x10(%rax),%xmm0
400d22: mulsd  (%rax),%xmm1
400d26: subsd  %xmm1,%xmm0
400d2a: movsd  %xmm0,0x10(%rdi)
400d2f: movsd  0x8(%rdx),%xmm0
400d34: movsd  0x10(%rdx),%xmm1
400d39: mulsd  0x10(%rax),%xmm0
400d3e: mulsd  0x8(%rax),%xmm1
400d43: subsd  %xmm1,%xmm0
400d47: movsd  %xmm0,0x18(%rdi)
400d4c: movsd  (%rdx),%xmm0
400d50: movsd  0x18(%rdx),%xmm1
400d55: mulsd  0x18(%rax),%xmm0
400d5a: mulsd  (%rax),%xmm1
400d5e: subsd  %xmm1,%xmm0
400d62: movsd  %xmm0,0x20(%rdi)
400d67: movsd  0x8(%rdx),%xmm0
400d6c: movsd  0x18(%rdx),%xmm1
400d71: mulsd  0x18(%rax),%xmm0
400d76: mulsd  0x8(%rax),%xmm1
400d7b: subsd  %xmm1,%xmm0
400d7f: movsd  %xmm0,0x28(%rdi)
400d84: movsd  0x10(%rdx),%xmm0
400d89: movsd  0x18(%rdx),%xmm1
400d8e: mulsd  0x18(%rax),%xmm0
400d93: mulsd  0x10(%rax),%xmm1
400d98: subsd  %xmm1,%xmm0
400d9c: movsd  %xmm0,0x30(%rdi)
400da1: retq   

Подпись этой функции

spinor<product<vector, vector>>(product<vector, vector> const&)

Надеюсь, вы мне доверяете, что обе версии дают тот же результат. Первые две строки извлекают первый и второй вектор, которые сохраняются в качестве ссылок в product. Я задавался вопросом о следующих вещах:

  • Что делает movsd 0x1f1(%rip),%xmm4 в сочетании с xorpd %xmm4,%xmm0? Я уже выяснил, что это называется "относительная адресация RIP", см. http://www.x86-64.org/documentation/assembly.html
  • Почему GCC не использует больше регистров, например. кэшировать 0x10(%rax), который читается четыре раза?

Я также сравнивал обе функции, генерируя 100000000 случайных векторов и принимая время на выполнение обеих функций:

ET: 7.5 sec
HW: 6.8 sec

Ручная работа на 10% быстрее. Кто-нибудь имеет опыт работы с шаблонами выражений и знает, как заставить их работать ближе к их рукописному коллеге?

Ответ 1

Было бы ясно, если бы мы точно знали содержимое адреса 0x400eb0, но я подозреваю, что оно 0x8000 0000 0000 0000 8000 0000 0000 0000 или похожее (возможно, с ведущим 0, потому что код не векторизован), написанный как 128 бит int.

В этом случае a xorpd меняет знак второго операнда.

Почему чтение регистра не кэшируется - лучше спросите об этом в списке рассылки gcc-help. Возможно, компилятор не может доказать, что два вектора или промежуточный результат не являются псевдонимами.

Но против общего мнения компиляторы не оптимизируют всегда отлично, но только лучше, чем 90% (или 99%?) всех программистов (если они пытаются писать сборку), а иногда (редко) они производят очень медленный код.

Но ваш подход очень хорош - бенчмаркинг и поиск генерируемого объектного кода - это правильная вещь, если вы хотите оптимизировать.

PS: их код можно было бы ускорить, используя векторные инструкции (mulpd вместо mulsd), что умножает два или четыре удвоения за один раз), например SSE или AVX. Но некоторые инструкции необходимы для перетасовки значений в нужные места в регистрах, поэтому коэффициент усиления всегда медленнее, чем два или четыре раза.