Эффективная реализация напольного/евклидова целочисленного деления

Половое деление - это когда результат всегда уменьшается (в сторону -∞), а не в сторону 0:

типы делений

Можно ли эффективно реализовать напольное или евклидово целочисленное деление в C/C++?

(очевидное решение — проверить знак делимого)


person Vladimir Panteleev    schedule 04.11.2010    source источник


Ответы (6)


Я возвращаюсь к этому вопросу пять лет спустя, так как это актуально и для меня. Я провел некоторые измерения производительности на двух версиях на чистом C и двух версиях на встроенной сборке для x86-64, и результаты могут быть интересными.

Испытываемые варианты напольного деления:

  1. Реализация, которую я использую уже некоторое время;
  2. Небольшой вариант представленного выше, в котором используется только одно деление;
  3. Предыдущий, но реализованный вручную на inline-ассемблере; а также
  4. Версия CMOV реализована в сборке.

Ниже приведена моя тестовая программа:

#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>

#ifndef VARIANT
#define VARIANT 3
#endif

#if VARIANT == 0
#define floordiv(a, b) (((a) < 0)?((((a) + 1) / (b)) - 1):((a) / (b)))
#elif VARIANT == 1
#define floordiv(a, b) ((((a) < 0)?((a) - ((b) - 1)):(a)) / (b))
#elif VARIANT == 2
#define floordiv(a, b) ({                                   \
    int result;                                             \
    asm("test %%eax, %%eax; jns 1f; sub %1, %%eax;"         \
        "add $1, %%eax; 1: cltd; idivl %1;"                 \
        : "=a" (result)                                     \
        : "r" (b),                                          \
          "0" (a)                                           \
        : "rdx");                                           \
    result;})
#elif VARIANT == 3
#define floordiv(a, b) ({                                           \
    int result;                                                     \
    asm("mov %%eax, %%edx; sub %1, %%edx; add $1, %%edx;"           \
        "test %%eax, %%eax; cmovs %%edx, %%eax; cltd;"              \
        "idivl %1;"                                                 \
        : "=a" (result)                                             \
        : "r" (b),                                                  \
          "0" (a)                                                   \
        : "rdx");                                                   \
    result;})
#endif

double ntime(void)
{
    struct timeval tv;

    gettimeofday(&tv, NULL);
    return(tv.tv_sec + (((double)tv.tv_usec) / 1000000.0));
}

void timediv(int n, int *p, int *q, int *r)
{
    int i;

    for(i = 0; i < n; i++)
        r[i] = floordiv(p[i], q[i]);
}

int main(int argc, char **argv)
{
    int n, i, *q, *p, *r;
    double st;

    n = 10000000;
    p = malloc(sizeof(*p) * n);
    q = malloc(sizeof(*q) * n);
    r = malloc(sizeof(*r) * n);
    for(i = 0; i < n; i++) {
        p[i] = (rand() % 1000000) - 500000;
        q[i] = (rand() % 1000000) + 1;
    }

    st = ntime();
    for(i = 0; i < 100; i++)
        timediv(n, p, q, r);
    printf("%g\n", ntime() - st);
    return(0);
}

Я скомпилировал это с помощью gcc -march=native -Ofast, используя GCC 4.9.2, и результаты на моем Core i5-2400 были следующими. Результаты довольно воспроизводимы от запуска к запуску — они всегда приземляются в одном и том же порядке, по крайней мере.

  • Вариант 0: 7,21 секунды
  • Вариант 1: 7,26 секунды
  • Вариант 2: 6,73 секунды
  • Вариант 3: 4,32 секунды

Так что реализация CMOV, по крайней мере, превосходит все остальные. Что меня удивляет, так это то, что вариант 2 превосходит свою версию на чистом C (вариант 1) с довольно большим отрывом. Я бы подумал, что компилятор должен иметь возможность генерировать код, по крайней мере, столь же эффективный, как мой.

Вот некоторые другие платформы для сравнения:

AMD Athlon 64 X2 4200+, GCC 4.7.2:

  • Вариант 0: 26,33 секунды
  • Вариант 1: 25,38 секунды
  • Вариант 2: 25,19 секунды
  • Вариант 3: 22,39 секунды

Xeon E3-1271 v3, GCC 4.9.2:

  • Вариант 0: 5,95 секунды
  • Вариант 1: 5,62 секунды
  • Вариант 2: 5,40 секунды
  • Вариант 3: 3,44 секунды

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

person Dolda2000    schedule 22.04.2016
comment
Обратите внимание, что варианты 0 и 1 (остальные не проверялись) дают численно правильные результаты для деления пола только тогда, когда делитель положительный. (И в этом случае результаты также совпадают с евклидовым делением.) Если, например, a, b = 5, -3, эти формулы говорят, что частное равно -1, но результат деления пола должен быть -2. (Евклидово деление для 5, -3 равно -1, но эти формулы дают другой ответ, чем и пол, и евклидово деление, когда оба операнда отрицательны.) - person dubiousjim; 27.07.2017
comment
Проверяли ли вы версии, в которых делитель является положительной константой (но делимое вычисляется во время выполнения)? Для степени двойки оператор сдвига вправо почти наверняка будет таким же быстрым, как и любая альтернатива, и, вероятно, более читабельным, чем что-либо столь же эффективное. Что касается других преимуществ, то оптимальный код для напольного/евклидова деления должен быть быстрее, чем код для усеченного, но я не знаю надежного способа убедить компиляторы генерировать хороший код. - person supercat; 02.11.2017
comment
@supercat: для постоянных делителей времени компиляции компилятор сможет генерировать лучший код, независимо от того, является ли он степенью двойки или нет, используя этот трюк. Так что да, деление с делителями, постоянными во время компиляции, безусловно, будет быстрее, чем любой алгоритм, который обрабатывает динамические делители. - person Dolda2000; 02.11.2017
comment
@ Dolda2000: Мой вопрос заключался в том, что нужно сделать, чтобы сгенерировать эффективный код для полового / евклидова деления в случае положительного постоянного делителя? Использование знакового оператора / обычно приводит к тому, что компилятор генерирует дополнительный код для создания неравномерности, близкой к нулю, что затем заставляет программиста писать дополнительный дополнительный код. Я думаю, что лучший подход — преобразовать в беззнаковый, добавить одно смещение, выполнить деление, а затем вычесть другое смещение, но это кажется немного неприглядным. - person supercat; 02.11.2017
comment
@supercat: Обширный документ на эту тему общедоступен, если вы хотите изучить техника. Он включает случаи как для усеченного, так и для напольного деления. - person Dolda2000; 02.11.2017

Я написал тестовую программу для проверки идей, представленных здесь:

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <windows.h>

#define N 10000000
#define M 100

int dividends[N], divisors[N], results[N];

__forceinline int floordiv_signcheck(int a, int b)
{
    return (a<0 ? a-(b-1) : a) / b;
}

__forceinline int floordiv_signcheck2(int a, int b)
{
    return (a - (a<0 ? b-1 : 0)) / b;
}

__forceinline int floordiv_signmultiply(int a, int b)
{
    return (a + (a>>(sizeof(a)*8-1))*(b-1)) / b;
}

__forceinline int floordiv_floatingpoint(int a, int b)
{
    // I imagine that the call to floor can be replaced to a cast
    // if you can get FPU rounding control to work (I couldn't).
    return floor((double)a / b);
}

void main()
{
    for (int i=0; i<N; i++)
    {
        dividends[i] = rand();
        do
            divisors[i] = rand();
        while (divisors[i]==0);
    }

    LARGE_INTEGER t0, t1;

    QueryPerformanceCounter(&t0);
    for (int j=0; j<M; j++)
        for (int i=0; i<N; i++)
            results[i] = floordiv_signcheck(dividends[i], divisors[i]);
    QueryPerformanceCounter(&t1);
    printf("signcheck    : %9llu\n", t1.QuadPart-t0.QuadPart);

    QueryPerformanceCounter(&t0);
    for (int j=0; j<M; j++)
        for (int i=0; i<N; i++)
            results[i] = floordiv_signcheck2(dividends[i], divisors[i]);
    QueryPerformanceCounter(&t1);
    printf("signcheck2   : %9llu\n", t1.QuadPart-t0.QuadPart);

    QueryPerformanceCounter(&t0);
    for (int j=0; j<M; j++)
        for (int i=0; i<N; i++)
            results[i] = floordiv_signmultiply(dividends[i], divisors[i]);
    QueryPerformanceCounter(&t1);
    printf("signmultiply : %9llu\n", t1.QuadPart-t0.QuadPart);

    QueryPerformanceCounter(&t0);
    for (int j=0; j<M; j++)
        for (int i=0; i<N; i++)
            results[i] = floordiv_floatingpoint(dividends[i], divisors[i]);
    QueryPerformanceCounter(&t1);
    printf("floatingpoint: %9llu\n", t1.QuadPart-t0.QuadPart);
}

Результаты:

signcheck    :  61458768
signcheck2   :  61284370
signmultiply :  61625076
floatingpoint: 287315364

Итак, по моим результатам проверка знака самая быстрая:

(a - (a<0 ? b-1 : 0)) / b
person Vladimir Panteleev    schedule 05.11.2010
comment
Ваше время для всех, кроме первого, включает printf для предыдущего ответа. printf довольно медленный, не знаю, достаточно ли он медленный, чтобы повлиять на ваши результаты или нет. - person Mark Ransom; 06.11.2010
comment
На вас также могут повлиять встраивающие решения, принятые компилятором, стоит посмотреть на сгенерированную сборку. Попробуйте double вместо float, так как там тоже могут происходить конверсии. - person Mark Ransom; 06.11.2010
comment
Спасибо за советы, обновил программу. Использование double заставило работать с плавающей запятой немного быстрее, но ненамного. В остальном результат не сильно изменился. - person Vladimir Panteleev; 06.11.2010
comment
Разве эта программа не только тестирует положительные дивиденды? rand() указано, чтобы возвращать только положительные целые числа. - person Dolda2000; 21.04.2016
comment
Эй, это правда. Возможно, это нужно пересмотреть. - person Vladimir Panteleev; 21.04.2016
comment
Умножение в варианте signmultiply фактически может быть заменено логическим И, которое имеет меньшую задержку: return (a - (a>>(sizeof(a)*8-1))&(b-1)) / b;. Однако обратите внимание, что ВСЕ варианты signcheck/signmultiply страдают недостатком памяти, если a близко к INT_MIN. Если вы точно не знаете, что a - b + 1 никогда не опускается ниже INT_MIN, я настоятельно не рекомендую их использовать. - person Kai Petzke; 08.10.2020

Было бы более эффективно придумать что-то бесплатное для ветвления, чтобы исправить результат на основе знака, поскольку ветки дороги.

См. стр. 20 и далее главы 2 в Hacker's Delight о том, как получить доступ к знаку.

person starblue    schedule 05.11.2010
comment
Единственное, что я могу придумать, это умножение бита знака на делитель. - person Vladimir Panteleev; 06.11.2010
comment
Вы можете представить определенные условия 0 или 1 и добавить их к результату. - person starblue; 06.11.2010

Можно ли эффективно реализовать напольное или евклидово целочисленное деление в C/C++?

да.

(очевидное решение — проверить знак делимого)

Я полностью согласен, и мне было бы трудно поверить, что существует альтернатива, которая значительно быстрее.

person Jason S    schedule 05.11.2010

Просто примечание: инструкция x86 sar выполняет деление по полу, когда речь идет о степенях двойки.

person Vladimir Panteleev    schedule 14.01.2012

Поскольку IEEE-754 указывает округление в сторону -inf как один из обязательных режимов округления, я полагаю, что ответ на ваш вопрос - да. Но, возможно, вы можете объяснить, хотите ли вы знать, как можно было бы реализовать процедуру, если бы вы писали компилятор, или узнать, как использовать конкретный компилятор для выполнения операции?

person High Performance Mark    schedule 04.11.2010
comment
Упс, я имел в виду целочисленное деление! Я поправлю вопрос. Я не уверен, какое отношение к этому вопросу имеют написание компиляторов, поскольку я проверял, и C/C++ выполняет усеченное деление. - person Vladimir Panteleev; 05.11.2010
comment
@CyberShadow: поэтому приведите делитель и делимое к каким-либо числам с плавающей запятой, используйте пол и приведите результат обратно к целому числу. Или вы ищете какой-то другой ответ? - person High Performance Mark; 05.11.2010
comment
Я не думаю, что использование с плавающей запятой будет быстрее, чем проверка знака... - person Vladimir Panteleev; 05.11.2010
comment
hardwaresecrets.com/fullimage.php?image=6761 показывает, что двойное -точное деление может конкурировать с целочисленным делением (остаются затраты на преобразования и, возможно, на переключение режима округления каждый раз). Однако этот подход по-прежнему не работает для всего диапазона 64-битных целых чисел и не работает (точно), если целевая платформа не имеет (точно) вычислений с плавающей запятой IEEE-754 (включая функции для изменения округления). Режим). - person Pascal Cuoq; 05.11.2010
comment
Я протестировал его, и производительность была ужасной, но я, вероятно, делаю это неправильно. Взгляните на программу, которую я разместил в своем собственном ответе, - может ли возня с режимом округления значительно ускорить работу? - person Vladimir Panteleev; 06.11.2010