Teste de divisibilidade mais rápido que o% de operador?

23

Notei uma coisa curiosa no meu computador. * O teste de divisibilidade manuscrita é significativamente mais rápido que o %operador. Considere o exemplo mínimo:

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

O exemplo é limitado por ímpar ae m > 0. No entanto, pode ser facilmente generalizado para todos ae m. O código apenas converte a divisão em uma série de adições.

Agora considere o programa de teste compilado com -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
        }
    }

... e os resultados no meu computador:

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

Portanto, mais de 2 vezes mais rápido.

A pergunta: você pode me dizer como o código se comporta na sua máquina? Perdeu a oportunidade de otimização no GCC? Você pode fazer esse teste ainda mais rápido?


UPDATE: Conforme solicitado, aqui está um exemplo reproduzível mínimo:

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

compilado com o gcc -std=c99 -march=native -O3 -DNDEBUGAMD Ryzen Threadripper 2990WX com

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

UPDATE2: Conforme solicitado, a versão que pode lidar com qualquer um ( ae mse você também deseja evitar o excesso de números inteiros, o teste deve ser implementado com o tipo inteiro duas vezes mais do que os números inteiros de entrada):

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
fonte
Comentários não são para discussão prolongada; esta conversa foi movida para o bate-papo .
Samuel Liew
Eu também gostaria de ver um teste em que você realmente afirma que esses dois rs que você calcula são realmente iguais um ao outro.
Mike Nakis 19/03
@ MikeNakis Acabei de adicionar isso.
DaBler 19/03
2
A maioria dos usos da vida real a % btem bmuito menor que a. Na maioria das iterações do seu caso de teste, elas têm tamanho semelhante ou bé maior e sua versão pode ser mais rápida em muitas CPUs nessas situações.
Matt Timmermans

Respostas:

11

O que você está fazendo é chamado de redução de força: substituindo uma operação cara por uma série de operações baratas.

A instrução mod em muitas CPUs é lenta, porque historicamente não foi testada em vários benchmarks comuns e, portanto, os designers otimizaram outras instruções. Esse algoritmo terá um desempenho pior se for necessário fazer muitas iterações e% terá um desempenho melhor em uma CPU em que precisará apenas de dois ciclos de clock.

Por fim, esteja ciente de que existem muitos atalhos para obter o restante da divisão por constantes específicas. (Embora os compiladores geralmente cuidem disso para você.)

Davislor
fonte
historicamente não foi testado em vários benchmarks comuns - também porque a divisão é inerentemente iterativa e difícil de acelerar! x86 pelo menos faz o resto como parte de div/ idivque adquiriu algum amor na Intel Penryn, Broadwell e IceLake (divisores de hardware com maior radix)
Peter Cordes
11
Meu entendimento de "redução de força" é que você substitui uma operação pesada em loop por uma única operação mais leve, por exemplo, em vez de x = i * constcada iteração, você faz x += consttodas as iterações. Eu não acho que substituir uma única multiplicação por um loop de deslocamento / adição seria chamado de redução de força. en.wikipedia.org/wiki/… diz que o termo talvez possa ser usado dessa maneira, mas com uma nota "Este material é contestado. É melhor descrito como otimização do olho mágico e atribuição de instruções".
Peter Cordes
9

Eu mesmo vou responder minha pergunta. Parece que me tornei vítima da previsão de ramificações. O tamanho mútuo dos operandos não parece importar, apenas a ordem deles.

Considere a seguinte implementação

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

e as matrizes

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

que são / não são embaralhadas usando o modo aleatório função .

Sem embaralhar, os resultados ainda são

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

No entanto, depois de embaralhar essas matrizes, os resultados são diferentes

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