Tes dapat dibagi lebih cepat dari% operator?

23

Saya memperhatikan hal yang aneh di komputer saya. * Tes keterbagian tulisan tangan secara signifikan lebih cepat daripada %operator. Pertimbangkan contoh minimal:

* 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);
}

Contoh dibatasi oleh ganjil adan m > 0. Namun, dapat dengan mudah digeneralisasikan untuk semua adan m. Kode hanya mengubah divisi menjadi serangkaian tambahan.

Sekarang pertimbangkan program pengujian yang disusun dengan -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
        }
    }

... dan hasilnya di komputer saya:

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

Karena itu lebih dari 2 kali lebih cepat.

Pertanyaannya: Bisakah Anda memberi tahu saya bagaimana kode berperilaku pada mesin Anda? Apakah itu kehilangan peluang pengoptimalan di GCC? Bisakah Anda melakukan tes ini lebih cepat?


PEMBARUAN: Seperti yang diminta, berikut adalah contoh minimal yang dapat direproduksi:

#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;
}

dikompilasi dengan gcc -std=c99 -march=native -O3 -DNDEBUGAMD Ryzen Threadripper 2990WX dengan

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

UPDATE2: Seperti yang diminta, versi yang dapat menangani apa saja adan m(jika Anda juga ingin menghindari overflow integer, tes harus dilaksanakan dengan tipe integer dua kali selama input integer):

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;
}
DaBler
sumber
Komentar bukan untuk diskusi panjang; percakapan ini telah dipindahkan ke obrolan .
Samuel Liew
Saya juga ingin melihat tes di mana Anda benar-benar menyatakan bahwa kedua rs yang Anda hitung memang sama satu sama lain.
Mike Nakis
@ MikeNakis saya baru saja menambahkan itu.
DaBler
2
Sebagian besar penggunaan kehidupan nyata a % bmemiliki bjauh lebih kecil daripada a. Melalui sebagian besar iterasi dalam test case Anda, mereka memiliki ukuran yang sama, atau blebih besar, dan versi Anda bisa lebih cepat pada banyak CPU dalam situasi tersebut.
Matt Timmermans

Jawaban:

11

Apa yang Anda lakukan disebut pengurangan kekuatan: mengganti operasi yang mahal dengan serangkaian operasi yang murah.

Instruksi mod pada banyak CPU lambat, karena secara historis tidak diuji dalam beberapa tolok ukur umum dan oleh karena itu perancang mengoptimalkan instruksi lain sebagai gantinya. Algoritma ini akan berkinerja lebih buruk jika harus melakukan banyak iterasi, dan %akan berkinerja lebih baik pada CPU di mana ia hanya membutuhkan dua siklus clock.

Akhirnya, ketahuilah bahwa ada banyak jalan pintas untuk mengambil sisa pembagian oleh konstanta tertentu. (Meskipun kompiler umumnya akan mengurus ini untuk Anda.)

Davislor
sumber
secara historis tidak diuji dalam beberapa tolok ukur umum - Juga karena pembagian secara inheren berulang dan sulit untuk dibuat cepat! x86 setidaknya tidak tersisa sebagai bagian dari div/ idivyang mendapatkan kecintaan pada Intel Penryn, Broadwell dan IceLake (pembagi perangkat keras radix yang lebih tinggi)
Peter Cordes
1
Pemahaman saya tentang "pengurangan kekuatan" adalah bahwa Anda mengganti operasi yang berat dalam satu lingkaran dengan operasi yang lebih ringan, misalnya alih-alih x = i * constsetiap iterasi yang Anda lakukan x += constsetiap iterasi. Saya tidak berpikir mengganti perkalian tunggal dengan shift / add loop akan disebut pengurangan kekuatan. en.wikipedia.org/wiki/… mengatakan istilah ini mungkin dapat digunakan dengan cara ini, tetapi dengan catatan "Materi ini diperdebatkan. Lebih baik digambarkan sebagai optimasi lubang dan penugasan instruksi."
Peter Cordes
9

Saya akan menjawab pertanyaan saya sendiri. Tampaknya saya menjadi korban prediksi cabang. Ukuran timbal-balik dari operan tampaknya tidak penting, hanya pesanan mereka.

Pertimbangkan implementasi berikut

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;
}

dan array

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;
}

yang sedang / tidak dikocok menggunakan fungsi acak .

Tanpa pengocokan, hasilnya tetap

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

Namun, setelah saya mengocok array ini, hasilnya berbeda

| implementation     | time [secs] |
|--------------------|-------------|
| divisible_ui_p     |   31.34user |
| builtin % operator |   17.53user |
DaBler
sumber