Швидше випробування на роздільність, ніж оператор%?


23

Я помітив цікаву річ на своєму комп’ютері. * Рукописний тест на роздільність значно швидше, ніж %оператор. Розглянемо мінімальний приклад:

* AMD Ryzen Threadripper 2990WX, GCC 9.2.0

static int divisible_ui_p(unsigned int m, unsigned int a)
{
    if (m <= a) {
        if (m == a) {
            return 1;
        }

        return 0;
    }

    m += a;

    m >>= __builtin_ctz(m);

    return divisible_ui_p(m, a);
}

Приклад обмежений непарними aта m > 0. Однак це можна легко узагальнити для всіх aі m. Код просто перетворює поділ на ряд доповнень.

Тепер розглянемо тестову програму, складену з -std=c99 -march=native -O3:

    for (unsigned int a = 1; a < 100000; a += 2) {
        for (unsigned int m = 1; m < 100000; m += 1) {
#if 1
            volatile int r = divisible_ui_p(m, a);
#else
            volatile int r = (m % a == 0);
#endif
        }
    }

... і результати на моєму комп’ютері:

| implementation     | time [secs] |
|--------------------|-------------|
| divisible_ui_p     |    8.52user |
| builtin % operator |   17.61user |

Тому більш ніж у 2 рази швидше.

Питання: Чи можете ви сказати мені, як поводиться код на вашій машині? Чи пропущена можливість оптимізації в GCC? Чи можете ви зробити цей тест ще швидше?


ОНОВЛЕННЯ: За запитом, ось мінімальний відтворюваний приклад:

#include <assert.h>

static int divisible_ui_p(unsigned int m, unsigned int a)
{
    if (m <= a) {
        if (m == a) {
            return 1;
        }

        return 0;
    }

    m += a;

    m >>= __builtin_ctz(m);

    return divisible_ui_p(m, a);
}

int main()
{
    for (unsigned int a = 1; a < 100000; a += 2) {
        for (unsigned int m = 1; m < 100000; m += 1) {
            assert(divisible_ui_p(m, a) == (m % a == 0));
#if 1
            volatile int r = divisible_ui_p(m, a);
#else
            volatile int r = (m % a == 0);
#endif
        }
    }

    return 0;
}

складений gcc -std=c99 -march=native -O3 -DNDEBUGна AMD Ryzen Threadripper 2990WX с

gcc --version
gcc (Gentoo 9.2.0-r2 p3) 9.2.0

UPDATE2: За запитом, версія, яка може обробляти будь-яку aі m(якщо ви також хочете уникнути цілого числа переповнення, тест повинен бути реалізований з цілим типом в два рази довше, ніж цілі числа введення):

int divisible_ui_p(unsigned int m, unsigned int a)
{
#if 1
    /* handles even a */
    int alpha = __builtin_ctz(a);

    if (alpha) {
        if (__builtin_ctz(m) < alpha) {
            return 0;
        }

        a >>= alpha;
    }
#endif

    while (m > a) {
        m += a;
        m >>= __builtin_ctz(m);
    }

    if (m == a) {
        return 1;
    }

#if 1
    /* ensures that 0 is divisible by anything */
    if (m == 0) {
        return 1;
    }
#endif

    return 0;
}

Коментарі не для розширеного обговорення; ця розмова була переміщена до чату .
Самуель Liew

Я хотів би також побачити тест, де ви насправді стверджуєте, що ці два rs, які ви обчислюєте, дійсно рівні між собою.
Майк Накіс

@MikeNakis Я щойно додав це.
DaBler

2
Більшість застосувань у реальному житті a % bмають bзначно менше, ніж a. Завдяки більшості ітерацій у вашому тестовому випадку вони мають однаковий розмір або bбільше, і ваша версія може бути швидшою для багатьох процесорів у таких ситуаціях.
Метт Тіммерманс

Відповіді:


11

Те, що ви робите, називається зниженням міцності: заміною дорогої операції на низку дешевих.

Інструкція з моди на багатьох процесорах повільна, оскільки вона не перевірялася в декількох загальних орієнтирах, тому дизайнери оптимізували інші інструкції. Цей алгоритм буде працювати гірше, якщо йому доведеться робити багато ітерацій, і він %буде краще працювати на процесорі, де йому потрібно лише два тактових цикли.

Нарешті, пам’ятайте, що існує багато ярликів, щоб залишити поділ на конкретні константи. (Хоча компілятори, як правило, про це подбають.)


історично не перевірялося в кількох загальних орієнтирах - Також тому, що поділ за своєю суттю є ітераційним і важко зробити його швидким! x86, як мінімум , залишається частиною div/ idivякі полюбили любов в Intel Penryn, Broadwell та IceLake (верхні апаратні дільники)
Пітер Кордес,

1
Моє розуміння "зниження сили" полягає в тому, що ви замінюєте важку операцію в циклі однією легшою операцією, наприклад, замість x = i * constкожної ітерації ви робите x += constкожну ітерацію. Я не думаю, що заміна одиничного множення на цикл shift / add буде називатися зменшенням сили. en.wikipedia.org/wiki/… говорить, що цей термін може бути використаний таким чином, але із зауваженням "Цей матеріал оспорюється. Він краще описується як оптимізація виводків та призначення інструкцій".
Пітер Кордес

9

Я сам відповім на своє запитання. Здається, що я став жертвою галузевого передбачення. Взаємний розмір операндів, здається, не має значення, лише їх порядок.

Розглянемо наступну реалізацію

int divisible_ui_p(unsigned int m, unsigned int a)
{
    while (m > a) {
        m += a;
        m >>= __builtin_ctz(m);
    }

    if (m == a) {
        return 1;
    }

    return 0;
}

і масиви

unsigned int A[100000/2];
unsigned int M[100000-1];

for (unsigned int a = 1; a < 100000; a += 2) {
    A[a/2] = a;
}
for (unsigned int m = 1; m < 100000; m += 1) {
    M[m-1] = m;
}

які не перетасовуються за допомогою функції перетасування .

Без перетасування результати все-таки є

| implementation     | time [secs] |
|--------------------|-------------|
| divisible_ui_p     |    8.56user |
| builtin % operator |   17.59user |

Однак, коли я перетасовую ці масиви, результати відрізняються

| implementation     | time [secs] |
|--------------------|-------------|
| divisible_ui_p     |   31.34user |
| builtin % operator |   17.53user |
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.