Могу ли я изменить этот макрос на встроенную функцию без снижения производительности?

(EDIT: давайте озаглавим это «Уроки того, как измерения могут пойти не так». Я до сих пор не понял, что именно вызывает несоответствие.)

Я нашел очень быструю функцию целочисленного квадратного корня здесь Марка Крауна. По крайней мере, с GCC на моей машине это явно самая быстрая функция извлечения квадратного корня из целых чисел, которую я тестировал (включая функции в Hacker's Delight, этой страницы и floor(sqrt()) из стандартной библиотеки).

После небольшой очистки форматирования, переименования переменной и использования типов с фиксированной шириной это выглядит так:

static uint32_t mcrowne_isqrt(uint32_t val)
{
    uint32_t temp, root = 0;

    if (val >= 0x40000000)
    {
        root = 0x8000;
        val -= 0x40000000;
    }

    #define INNER_ISQRT(s)                              \
    do                                                  \
    {                                                   \
        temp = (root << (s)) + (1 << ((s) * 2 - 2));    \
        if (val >= temp)                                \
        {                                               \
            root += 1 << ((s)-1);                       \
            val -= temp;                                \
        }                                               \
    } while(0)

    INNER_ISQRT(15);
    INNER_ISQRT(14);
    INNER_ISQRT(13);
    INNER_ISQRT(12);
    INNER_ISQRT(11);
    INNER_ISQRT(10);
    INNER_ISQRT( 9);
    INNER_ISQRT( 8);
    INNER_ISQRT( 7);
    INNER_ISQRT( 6);
    INNER_ISQRT( 5);
    INNER_ISQRT( 4);
    INNER_ISQRT( 3);
    INNER_ISQRT( 2);

    #undef INNER_ISQRT

    temp = root + root + 1;
    if (val >= temp)
        root++;
    return root;
}

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

Моя текущая итерация выглядит так (обратите внимание на атрибут always_inline, который я добавил на всякий случай):

static inline void inner_isqrt(const uint32_t s, uint32_t& val, uint32_t& root) __attribute__((always_inline));
static inline void inner_isqrt(const uint32_t s, uint32_t& val, uint32_t& root)
{
    const uint32_t temp = (root << s) + (1 << ((s << 1) - 2));
    if(val >= temp)
    {
        root += 1 << (s - 1);
        val -= temp;
    }
}

//  Note that I just now changed the name to mcrowne_inline_isqrt, so people can compile my full test.
static uint32_t mcrowne_inline_isqrt(uint32_t val)
{
    uint32_t root = 0;

    if(val >= 0x40000000)
    {
        root = 0x8000; 
        val -= 0x40000000;
    }

    inner_isqrt(15, val, root);
    inner_isqrt(14, val, root);
    inner_isqrt(13, val, root);
    inner_isqrt(12, val, root);
    inner_isqrt(11, val, root);
    inner_isqrt(10, val, root);
    inner_isqrt(9, val, root);
    inner_isqrt(8, val, root);
    inner_isqrt(7, val, root);
    inner_isqrt(6, val, root);
    inner_isqrt(5, val, root);
    inner_isqrt(4, val, root);
    inner_isqrt(3, val, root);
    inner_isqrt(2, val, root);

    const uint32_t temp = root + root + 1;
    if (val >= temp)
        root++;
    return root;
}

Что бы я ни делал, встроенная функция всегда медленнее, чем макрос. Макро-версия обычно занимает около 2,92 с для (2 ^ 28 - 1) итераций со сборкой -O2, тогда как встроенная версия обычно занимает около 3,25 с. РЕДАКТИРОВАТЬ: я сказал 2 ^ 32 - 1 итерация раньше, но я забыл, что изменил его. Для полной гаммы требуется немного больше времени.

Возможно, компилятор просто глуп и отказывается встраивать его (еще раз обратите внимание на атрибут always_inline!), но если это так, то в любом случае версия с макросом предпочтительнее. (Я попытался проверить сборку, чтобы увидеть, но это было слишком сложно как часть программы. Оптимизатор пропустил все, когда я попытался скомпилировать только функции, конечно, и у меня возникли проблемы с компиляцией его как библиотеки из-за нубизма с GCC .)

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

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


ОБНОВЛЕНИЕ: пользователь 1034749 ниже получает один и тот же вывод сборки из обеих функций, когда он помещает их в отдельные файлы и компилирует их. Я попробовал его точную командную строку, и я получаю тот же результат, что и он. Для всех намерений и целей, этот вопрос решен.

Тем не менее, я все еще хотел бы знать, почему мои измерения выходят по-разному. Очевидно, мой код измерения или исходный процесс сборки приводил к тому, что все было по-другому. Я опубликую код ниже. Кто-нибудь знает, в чем заключалась сделка? Может быть, мой компилятор на самом деле встраивает всю функцию mcrowne_isqrt() в цикл моей функции main(), но не встраивает всю другую версию?

ОБНОВЛЕНИЕ 2 (втиснутое перед тестированием кода): обратите внимание, что если я поменяю порядок тестов и сделаю встроенную версию первой, встроенная версия выйдет быстрее, чем версия макроса, на ту же сумму. Это проблема кэширования, или компилятор встраивает один вызов, а другой нет, или что?

#include <iostream>
#include <time.h>      //  Linux high-resolution timer
#include <stdint.h>

/*  Functions go here */

timespec timespecdiff(const timespec& start, const timespec& end)
{
    timespec elapsed;
    timespec endmod = end;
    if(endmod.tv_nsec < start.tv_nsec)
    {
        endmod.tv_sec -= 1;
        endmod.tv_nsec += 1000000000;
    }

    elapsed.tv_sec = endmod.tv_sec - start.tv_sec;
    elapsed.tv_nsec = endmod.tv_nsec - start.tv_nsec;
    return elapsed;
}


int main()
{
    uint64_t inputlimit = 4294967295;
    //  Test a wide range of values
    uint64_t widestep = 16;

    timespec start, end;

    //  Time macro version:
    uint32_t sum = 0;
    clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &start);
    for(uint64_t num = (widestep - 1); num <= inputlimit; num += widestep)
    {
        sum += mcrowne_isqrt(uint32_t(num));
    }
    clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &end);
    timespec markcrowntime = timespecdiff(start, end);
    std::cout << "Done timing Mark Crowne's sqrt variant.  Sum of results = " << sum << " (to avoid over-optimization)." << std::endl;


    //  Time inline version:
    sum = 0;
    clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &start);
    for(uint64_t num = (widestep - 1); num <= inputlimit; num += widestep)
    {
        sum += mcrowne_inline_isqrt(uint32_t(num));
    }
    clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &end);
    timespec markcrowninlinetime = timespecdiff(start, end);
    std::cout << "Done timing Mark Crowne's inline sqrt variant.  Sum of results = " << sum << " (to avoid over-optimization)." << std::endl;

    //  Results:
    std::cout << "Mark Crowne sqrt variant time:\t" << markcrowntime.tv_sec << "s, " << markcrowntime.tv_nsec << "ns" << std::endl;
    std::cout << "Mark Crowne inline sqrt variant time:\t" << markcrowninlinetime.tv_sec << "s, " << markcrowninlinetime.tv_nsec << "ns" << std::endl;
    std::cout << std::endl;
}

ОБНОВЛЕНИЕ 3: я до сих пор понятия не имею, как надежно сравнивать время работы разных функций без учета времени, зависящего от порядка тестов. Буду очень признателен за любые советы!

Однако, если кто-то еще, читающий это, заинтересован в быстрой реализации sqrt, я должен упомянуть: код Марка Крауна тестируется быстрее, чем любая другая чистая версия C/C++, которую я пробовал с приличным отрывом (несмотря на проблемы с надежностью при тестировании), но следующее Код SSE кажется, что он может быть немного быстрее для скалярного 32-битного целого числа sqrt. Его нельзя обобщить для полномасштабных 64-битных целочисленных входов без знака без потери точности (и первое преобразование со знаком также должно быть заменено загрузкой, встроенной для обработки значений> = 2 ^ 63):

uint32_t sse_sqrt(uint64_t num)
{
    //  Uses 64-bit input, because SSE conversion functions treat all
    //  integers as signed (so conversion from a 32-bit value >= 2^31
    //  will be interpreted as negative).  As it stands, this function
    //  will similarly fail for values >= 2^63.
    //  It can also probably be made faster, since it generates a strange/
    //  useless movsd %xmm0,%xmm0 instruction before the sqrtsd.  It clears
    //  xmm0 first too with xorpd (seems unnecessary, but I could be wrong).
    __m128d result;
    __m128d num_as_sse_double = _mm_cvtsi64_sd(result, num);
    result = _mm_sqrt_sd(num_as_sse_double, num_as_sse_double);
    return _mm_cvttsd_si32(result);
}

person Community    schedule 22.11.2011    source источник
comment
Небольшой нюанс, но temp следует объявлять const в обеих функциях.   -  person Kerrek SB    schedule 22.11.2011
comment
Попробуйте передать его, используя аргументы шаблона.   -  person Pubby    schedule 22.11.2011
comment
Хорошая мысль, Керрек С.Б. Я даже не думал об этом. Изменение этого не имело никакого эффекта, но я все равно обновляю сообщение.   -  person Mike S    schedule 22.11.2011
comment
Pubby спасибо за идею, но я уже пробовал передавать константы через параметр шаблона, и это не дало результата. Именно это я имел в виду в своей последней строке о попытке запечь постоянное значение с помощью шаблонов.   -  person Mike S    schedule 22.11.2011
comment
inline не гарантирует, что компилятор сделает его встроенным. Компилятор все еще может отказаться от этого. Вы можете заставить некоторые компиляторы поместить его в строку с чем-то вроде __forceinline в VC++ или __attribute__((always_inline)) в gcc.   -  person JohnPS    schedule 22.11.2011
comment
Как видите, на всякий случай я действительно использовал __attribute__((always_inline)) в объявлении. ;) Я все еще не уверен, что компилятор не пытается бороться со мной, поэтому я сделал слабую попытку проверить сборку (безрезультатно).   -  person Mike S    schedule 22.11.2011
comment
Макрос INNER_ISQRT не так уж и плох, поскольку он локальный и сразу же неопределенный после того, как он больше не нужен. #define не ограничен...   -  person Throwback1986    schedule 22.11.2011
comment
#define может не иметь области действия, но #undef, похоже, в этом случае эффективно имитирует локальную область видимости. Из-за его присутствия я не могу получить доступ к INNER_ISQRT из-за пределов функции, в которой он определен. Может быть какое-то тонкое исключение, которое я упускаю, но оно все равно идет по касательной. В конце концов, смысл моего вопроса по-прежнему заключается в том, чтобы найти способ избежать макроса в первую очередь (без какого-либо снижения производительности).   -  person Mike S    schedule 22.11.2011
comment
(Помимо #undef, мой комментарий о том, что макрос является локальным, был в основном о близости его использования к его реализации. Просто макросы кажутся немного менее опасными, когда их определение находится под рукой, а не скрыто в каком-то загадочном заголовке, который сильно злоупотребляет препроцессором.)   -  person Mike S    schedule 22.11.2011
comment
Я очень удивлен, что это будет быстрее, чем вызов floor(sqrt(x)). Я предполагаю, что виноват int -> float -> int туда и обратно.   -  person Matthieu M.    schedule 22.11.2011
comment
@ Mike S - ой, я не прокрутил вправо, чтобы увидеть attribute__((always_inline)). Я видел, что макрос работает быстрее, но это было с длинными функциями со многими переменными. Анализируя сборку, я понял, что компилятор (VC++ 2010) по-разному манипулирует регистрами, когда я использовал inline, а не определение, хотя я был немного удивлен, что тоже была разница!   -  person JohnPS    schedule 22.11.2011
comment
Мэтью, я уверен, что вы правы в том, что вероятным виновником являются конверсии. Загрузки-хит-хранилища неприятны, и именно из-за них я начал искать функции квадратного корня для целых чисел. Странное расхождение во времени между встроенной/макро-версией isqrt Марка Крауна было небольшим в моем тестировании, но Floor(sqrt()) занимает почти в три раза больше времени. (Обратите внимание, что я еще не засек встроенный SSE sqrtss.)   -  person Mike S    schedule 22.11.2011
comment
АРРРРРРР. Любопытно, я изменил порядок теста floor(sqrt()), чтобы поставить его первым... и теперь он занимает всего на 50% больше времени, чем mcrowne_isqrt, а не в 2-3 раза дольше. Мне ДЕЙСТВИТЕЛЬНО нужно найти более надежный способ тестирования этих функций!   -  person Mike S    schedule 22.11.2011


Ответы (1)


Я попробовал ваш код с gcc 4.5.3. Я изменил вашу вторую версию кода, чтобы она соответствовала первой, например:

(1 << ((s) * 2 - 2)

vs

(1 << ((s << 1) - 1)

да, s * 2 == s ‹‹ 1, но "-2" и "-1"?

Также я изменил ваши типы, заменив uint32_t на «unsigned long», потому что на моей 64-битной машине «long» не является 32-битным числом.

И тогда я бегу:

g++ -ggdb -O2 -march=native -c -pipe inline.cpp
g++ -ggdb -O2 -march=native -c -pipe macros.cpp
objdump -d inline.o > inline.s
objdump -d macros.o > macros.s

Я мог бы использовать "-S" вместо "-c" для ассемблера, но я хотел бы видеть ассемблер без дополнительной информации.

и знаете что?
Ассемблер полностью одинаковый, что в первой, что во второй версии. Поэтому я думаю, что ваши измерения времени просто неверны.

person fghj    schedule 22.11.2011
comment
К сожалению, -1 была полной опечаткой. Я, должно быть, сделал это в спешке, но вы правы в том, что код совершенно неверен. Фиксированный! Я сопоставлю типы двух функций, чтобы быть уверенным, но пока я получаю те же измерения, что и до использования таймера высокого разрешения Linux (более 2^32 - 1 итерация). - person Mike S; 22.11.2011
comment
Хорошо, я изменил типы исходной функции на uint32_t (вместо того, чтобы встроенная функция соответствовала оригиналу), потому что этот код в любом случае работает только для 32-битных входных данных. Для 64-битного sqrt потребуется несколько изменений, поэтому с технической точки зрения неоднозначный тип unsigned long не подходит для 64-битной машины. Во всяком случае, я измеряю их точно так же, как и раньше. Я опубликую свой код измерения выше и изменю тип исходной функции для загрузки. - person Mike S; 22.11.2011
comment
К сожалению, небольшое исправление: я также протестировал всю гамму, но время, которое я дал, было для (2 ^ 28 - 1) итераций, а не (2 ^ 32 - 1). Во всяком случае, я исправил функции и разместил свой код синхронизации выше, но я все еще получаю ту же разницу во времени. Я попробую с вашими вариантами сборки, так как вы получаете тот же код сборки. - person Mike S; 22.11.2011
comment
Я просто скомпилировал каждую функцию в отдельный файл, используя ваши параметры (кстати, спасибо за это; я новичок в GCC и обычно использую цепочку сборки из моей IDE), и... вы правы. Две функции компилируются в один и тот же код. Это сильно отличается от компиляции их вместе как части программы. Насколько я понимаю, моя тестовая программа встраивает вызов mcrown_isqrt() в цикл моей основной функции, но не встраивает вызов mcrown_inline_isqrt(). Есть ли другие возможности? - person Mike S; 22.11.2011
comment
какую версию gcc вы используете? - person fghj; 22.11.2011
comment
Сонофа... Думаю, проблема в моем коде измерения. Я просто поменял местами порядок тестов, и теперь инлайн-версия быстрее, чем макро-версия, на ту же величину. Может ли это быть проблемой кэширования? Это кажется маловероятным, учитывая огромное количество итераций. Я предполагаю, что единственная другая возможность заключается в том, что компилятор встраивает один из вызовов sqrt, но не другой, в зависимости от порядка их вызова... - person Mike S; 22.11.2011
comment
Просто предположение: планировщик ядра может подсчитывать использование вашего процессора, и из-за того, что вы потребляете процессор как свинью, он снижает ваш динамический приоритет, и вы получаете меньше ресурсов процессора в конце вашего теста, поэтому последний алгоритм не зависит от того, какой из них , показать худший результат. - person fghj; 22.11.2011
comment
Я установил часы на CLOCK_PROCESS_CPUTIME_ID, чтобы предотвратить подобные проблемы, но могут ли они все еще появляться? Если это так, похоже, это нанесло бы ущерб всем видам тестирования. Я ищу команду для установки определенного приоритета от запуска процесса до его завершения, но пока не нашел (все, что я нашел, касается изменения приоритета уже запущенных процессов). Вы бы знали навскидку? - person Mike S; 22.11.2011
comment
Я попытался запустить с максимально возможным приоритетом, используя nice -n -20 ./infuriatingtest. Однако это ни на что не повлияло, и расхождения все еще существуют в зависимости от заказа тестов. Это все еще может быть проблемой планирования, если некоторые тесты прерываются чаще, чем другие, или дольше, но CLOCK_PROCESS_CPUTIME_ID все равно должен заботиться об этом, насколько я знаю... hrm. Любые другие идеи? - person Mike S; 22.11.2011