Как оптимизировать возвращаемые значения функции в C и С++ на x86-64?

x86-64 ABI определяет два возвращаемых регистра: rax и rdx, размер 64-битных (8 байтов).

Предполагая, что x86-64 является единственной целевой платформой, какая из этих двух функций:

uint64_t f(uint64_t * const secondReturnValue) {
    /* Calculate a and b. */
    *secondReturnValue = b;
    return a;
}

std::pair<uint64_t, uint64_t> g() {
    /* Calculate a and b, same as in f() above. */
    return { a, b };
}

даст лучшую производительность, учитывая текущее состояние компиляторов C/С++, ориентированных на x86-64? Есть ли какие-либо подводные камни по одной или другой версии? Компиляторы (GCC, Clang) всегда могут оптимизировать std::pair для возврата в rax и rdx?

ОБНОВЛЕНИЕ: Как правило, возвращение пары происходит быстрее, если компилятор оптимизирует методы std::pair (примеры двоичного вывода с GCC 5.3.0 и Clang 3.8.0). Если f() не встроен, компилятор должен сгенерировать код для записи значения в память, например:

movq b, (%rdi)
movq a, %rax
retq

Но в случае g() для компилятора достаточно:

movq a, %rax
movq b, %rdx
retq

Поскольку инструкции для записи значений в память обычно медленнее, чем инструкции для записи значений в регистры, вторая версия должна быть быстрее.

Ответ 1

Так как ABI указывает, что в некоторых случаях для результата 2-го слова должны использоваться два регистра, любой соответствующий компилятор должен подчиняться этому правилу.

Однако для таких крошечных функций я предполагаю, что большая часть производительности будет получена от inlining.

Вы можете скомпилировать и связать с g++ -flto -O2 с помощью оптимизации времени соединения.

Я предполагаю, что вторая функция (возвращающая пару через 2 регистра) может быть немного быстрее и что, возможно, в некоторых ситуациях компилятор GCC может встроить и оптимизировать первый во второй.

Но вам действительно нужно ориентироваться, если вам это очень понравится.

Ответ 2

Обратите внимание, что ABI определяет упаковку любой небольшой структуры в регистры для передачи/возврата (если она содержит только целые типы). Это означает, что возврат a std::pair<uint32_t, uint32_t> означает, что значения должны быть сдвинуты + ORed в rax.

Это, вероятно, еще лучше, чем путешествие в оба конца через память, поскольку настройка пространства для указателя и передача этого указателя в качестве дополнительного аргумента имеет некоторые накладные расходы. (Помимо этого, однако, кругооборот через кеш L1 довольно дешев, например, задержка ~ 5 с. Магазин/загрузка почти наверняка попадет в кеш-память L1, поскольку память стека используется все время. Даже если он промахивается, сохранение хранилища еще может произойти, поэтому выполнение не останавливается до тех пор, пока ROB не заполнится, потому что магазин не сможет уйти в отставку. См. Руководство по микроаршруту Agner Fog и другие материалы в теги wiki. )

В любом случае вот код, который вы получаете от gcc 5.3 -O2, используя функции, которые принимают args вместо возврата константы времени компиляции значения (что приведет к movabs rax, 0x...):

#include <cstdint>
#include <utility>
#define type_t uint32_t

type_t f(type_t * const secondReturnValue, type_t x) {
    *secondReturnValue = x+4;
    return x+2;
}
    lea     eax, [rsi+4]           # LEA is an add-and-shift instruction that uses memory-operand syntax and encoding
    mov     DWORD PTR [rdi], eax
    lea     eax, [rsi+2]
    ret

std::pair<type_t, type_t> g(type_t x) { return {x+2, x+4}; }
    lea     eax, [rdi+4]
    lea     edx, [rdi+2]
    sal     rax, 32
    or      rax, rdx
    ret

type_t use_pair(std::pair<type_t, type_t> pair) {
    return pair.second + pair.first;
}
    mov     rax, rdi
    shr     rax, 32
    add     eax, edi
    ret

Так что это действительно неплохо. Два или три insns в вызывающем и вызываемом для упаковки и распаковки пары значений uint32_t. Однако нигде не так хорошо, как возврат пары значений uint64_t.

Если вы специально оптимизируете для x86-64 и заботитесь о том, что происходит для не-встроенных функций с несколькими возвращаемыми значениями, то предпочитает возвращать std::pair<uint64_t, uint64_t> (или int64_t, очевидно), даже если вы назначаете эти пары более узким целым в вызывающем. Обратите внимание, что в x32 ABI (-mx32) указатели имеют всего 32 бита. Не предполагайте, что указатели имеют 64 бит при оптимизации для x86-64, если вы заботитесь об этом ABI.

Если какой-либо из членов пары имеет 64 бит, они используют отдельные регистры. Он не делает ничего глупого, как разделение одного значения между верхней половиной одной рег и нижней половиной другой.