Qual è la divisione intera più veloce che supporta la divisione per zero, indipendentemente dal risultato?


109

Sommario:

Sto cercando il modo più veloce per calcolare

(int) x / (int) y

senza ottenere un'eccezione per y==0. Invece voglio solo un risultato arbitrario.


Sfondo:

Quando si codificano algoritmi di elaborazione delle immagini, spesso è necessario dividere per un valore alfa (accumulato). La variante più semplice è il semplice codice C con aritmetica dei numeri interi. Il mio problema è che in genere ottengo una divisione per errore zero per i pixel dei risultati con alpha==0. Tuttavia questi sono esattamente i pixel in cui il risultato non ha alcuna importanza: non mi interessano i valori di colore dei pixel con alpha==0.


Dettagli:

Sto cercando qualcosa come:

result = (y==0)? 0 : x/y;

o

result = x / MAX( y, 1 );

xey sono numeri interi positivi. Il codice viene eseguito un numero enorme di volte in un ciclo annidato, quindi sto cercando un modo per sbarazzarmi della ramificazione condizionale.

Quando y non supera l'intervallo di byte, sono soddisfatto della soluzione

unsigned char kill_zero_table[256] = { 1, 1, 2, 3, 4, 5, 6, 7, [...] 255 };
[...]
result = x / kill_zero_table[y];

Ma questo ovviamente non funziona bene per gamme più grandi.

Immagino che la domanda finale sia: qual è il trucco più veloce che cambia 0 in qualsiasi altro valore intero, lasciando tutti gli altri valori invariati?


chiarimenti

Non sono sicuro al 100% che la ramificazione sia troppo costosa. Tuttavia, vengono utilizzati compilatori diversi, quindi preferisco il benchmarking con poche ottimizzazioni (il che è davvero discutibile).

Di sicuro, i compilatori sono fantastici quando si tratta di manipolare un po ', ma non posso esprimere il risultato "non mi interessa" in C, quindi il compilatore non sarà mai in grado di utilizzare l'intera gamma di ottimizzazioni.

Il codice dovrebbe essere completamente compatibile con C, le piattaforme principali sono Linux 64 Bit con gcc & clang e MacOS.


22
Come hai stabilito che il ramo if è troppo costoso?
Djechlin

7
Come si è stabilito che non v'è una filiale?
leemes

13
+1 per la profilazione, con la previsione dei rami moderni potresti non averne bisogno. Inoltre, perché codifichi i tuoi algoritmi di elaborazione delle immagini?
TC1

8
"Qual è il trucco più veloce che gioca un po '..." Forse y += !y? Nessun ramo necessario per calcolarlo. Potresti confrontare x / (y + !y)con x / max(y, 1)e forse anche y ? (x/y) : 0. Immagino che non ci saranno rami in nessuno di essi, almeno con le ottimizzazioni attivate.
leemes

6
Chiunque pensi che la previsione dei rami dei giorni nostri significhi che non devi farlo non ha profilato abbastanza codice di eliminazione dei rami che viene eseguito a livello di pixel. La previsione dei rami moderni è accettabile se le 0sezioni alfa sono enormi e contigue. C'è un posto per giocherellare con le micro ottimizzazioni e le operazioni per pixel sono esattamente quelle.
Yakk - Adam Nevraumont

Risposte:


107

Ispirato da alcuni commenti, mi sono sbarazzato del ramo sul mio Pentium e sul mio gcccompilatore usando

int f (int x, int y)
{
        y += y == 0;
        return x/y;
}

Il compilatore fondamentalmente riconosce che può utilizzare un flag di condizione del test nell'addizione.

Come da richiesta il montaggio:

.globl f
    .type   f, @function
f:
    pushl   %ebp
    xorl    %eax, %eax
    movl    %esp, %ebp
    movl    12(%ebp), %edx
    testl   %edx, %edx
    sete    %al
    addl    %edx, %eax
    movl    8(%ebp), %edx
    movl    %eax, %ecx
    popl    %ebp
    movl    %edx, %eax
    sarl    $31, %edx
    idivl   %ecx
    ret

Poiché questa si è rivelata una domanda e una risposta così popolari, elaborerò un po 'di più. L'esempio precedente si basa sull'idioma di programmazione che un compilatore riconosce. Nel caso precedente, viene utilizzata un'espressione booleana nell'aritmetica integrale e l'uso di flag di condizione viene inventato nell'hardware per questo scopo. In condizioni generali i flag sono accessibili solo in C tramite l'uso di idioma. Questo è il motivo per cui è così difficile creare una libreria di interi a precisione multipla portabile in C senza ricorrere all'assembly (inline). La mia ipotesi è che i compilatori più decenti capiranno l'idioma di cui sopra.

Un altro modo per evitare i rami, come osservato anche in alcuni dei commenti precedenti, è l'esecuzione predicata. Ho quindi preso il primo codice di philipp e il mio codice e l'ho eseguito attraverso il compilatore da ARM e il compilatore GCC per l'architettura ARM, che presenta un'esecuzione predicata. Entrambi i compilatori evitano il ramo in entrambi gli esempi di codice:

La versione di Philipp con il compilatore ARM:

f PROC
        CMP      r1,#0
        BNE      __aeabi_idivmod
        MOVEQ    r0,#0
        BX       lr

La versione di Philipp con GCC:

f:
        subs    r3, r1, #0
        str     lr, [sp, #-4]!
        moveq   r0, r3
        ldreq   pc, [sp], #4
        bl      __divsi3
        ldr     pc, [sp], #4

Il mio codice con il compilatore ARM:

f PROC
        RSBS     r2,r1,#1
        MOVCC    r2,#0
        ADD      r1,r1,r2
        B        __aeabi_idivmod

Il mio codice con GCC:

f:
        str     lr, [sp, #-4]!
        cmp     r1, #0
        addeq   r1, r1, #1
        bl      __divsi3
        ldr     pc, [sp], #4

Tutte le versioni necessitano ancora di un ramo alla routine di divisione, perché questa versione di ARM non dispone di hardware per una divisione, ma il test per y == 0è completamente implementato tramite l'esecuzione predicata.


Potresti mostrarci il codice assembler risultante? O come hai stabilito che non esiste una filiale?
Haatschii

1
Eccezionale. Può essere fatto constexpred evitare lanci di tipo inutile come questo: template<typename T, typename U> constexpr auto fdiv( T t, U u ) -> decltype(t/(u+!u)) { return t/(u+!u); } E se vuoi 255,(lhs)/(rhs+!rhs) & -!rhs
Yakk - Adam Nevraumont

1
@leemes ma era mia intenzione |non &. Ops, ( (lhs)/(rhs+!rhs) ) | -!rhsdovresti impostare il tuo valore su 0xFFFFFFFif rhsis 0e lhs/rhsif rhs!=0.
Yakk - Adam Nevraumont

1
Questo è stato molto intelligente.
Theodoros Chatzigiannakis

1
Bella risposta! Di solito ricorro all'assemblaggio per questo genere di cose, ma è sempre orribile da mantenere (per non parlare della meno portabilità;)).
Leone

20

Ecco alcuni numeri concreti, su Windows con GCC 4.7.2:

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

int main()
{
  unsigned int result = 0;
  for (int n = -500000000; n != 500000000; n++)
  {
    int d = -1;
    for (int i = 0; i != ITERATIONS; i++)
      d &= rand();

#if CHECK == 0
    if (d == 0) result++;
#elif CHECK == 1
    result += n / d;
#elif CHECK == 2
    result += n / (d + !d);
#elif CHECK == 3
    result += d == 0 ? 0 : n / d;
#elif CHECK == 4
    result += d == 0 ? 1 : n / d;
#elif CHECK == 5
    if (d != 0) result += n / d;
#endif
  }
  printf("%u\n", result);
}

Nota che intenzionalmente non sto chiamando srand(), quindi rand()restituisce sempre esattamente gli stessi risultati. Nota anche che -DCHECK=0conta semplicemente gli zeri, in modo che sia ovvio quanto spesso è apparso.

Ora, compilandolo e cronometrandolo in vari modi:

$ for it in 0 1 2 3 4 5; do for ch in 0 1 2 3 4 5; do gcc test.cc -o test -O -DITERATIONS=$it -DCHECK=$ch && { time=`time ./test`; echo "Iterations $it, check $ch: exit status $?, output $time"; }; done; done

mostra l'output che può essere riassunto in una tabella:

Iterations  | 0        | 1        | 2        | 3         | 4         | 5
-------------+-------------------------------------------------------------------
Zeroes       | 0        | 1        | 133173   | 1593376   | 135245875 | 373728555
Check 1      | 0m0.612s | -        | -        | -         | -         | -
Check 2      | 0m0.612s | 0m6.527s | 0m9.718s | 0m13.464s | 0m18.422s | 0m22.871s
Check 3      | 0m0.616s | 0m5.601s | 0m8.954s | 0m13.211s | 0m19.579s | 0m25.389s
Check 4      | 0m0.611s | 0m5.570s | 0m9.030s | 0m13.544s | 0m19.393s | 0m25.081s
Check 5      | 0m0.612s | 0m5.627s | 0m9.322s | 0m14.218s | 0m19.576s | 0m25.443s

Se gli zeri sono rari, la -DCHECK=2versione funziona male. Man mano che gli zeri iniziano ad apparire di più, il -DCHECK=2case inizia a funzionare significativamente meglio. Tra le altre opzioni, non c'è davvero molta differenza.

Per -O3, però, è una storia diversa:

Iterations  | 0        | 1        | 2        | 3         | 4         | 5
-------------+-------------------------------------------------------------------
Zeroes       | 0        | 1        | 133173   | 1593376   | 135245875 | 373728555
Check 1      | 0m0.646s | -        | -        | -         | -         | -
Check 2      | 0m0.654s | 0m5.670s | 0m9.905s | 0m14.238s | 0m17.520s | 0m22.101s
Check 3      | 0m0.647s | 0m5.611s | 0m9.085s | 0m13.626s | 0m18.679s | 0m25.513s
Check 4      | 0m0.649s | 0m5.381s | 0m9.117s | 0m13.692s | 0m18.878s | 0m25.354s
Check 5      | 0m0.649s | 0m6.178s | 0m9.032s | 0m13.783s | 0m18.593s | 0m25.377s

Lì, il controllo 2 non ha svantaggi rispetto agli altri controlli e mantiene i vantaggi man mano che gli zeri diventano più comuni.

Tuttavia, dovresti davvero misurare per vedere cosa succede con il tuo compilatore e i tuoi dati campione rappresentativi.


4
Fai in modo che il 50% delle voci sia d=0casuale, invece di renderlo quasi sempre d!=0, e vedrai più errori di previsione dei rami. La previsione dei rami è ottima se un ramo è quasi sempre seguito, o se il seguito di un ramo o dell'altro è davvero
irregolare

@Yakk L' diterazione è il ciclo interno, quindi i d == 0casi sono distribuiti in modo uniforme. E rende d == 0realistico il 50% dei casi ?

2
è realistico rendere 0.002%i casi d==0? Sono distribuiti in tutto, ogni 65000 iterazioni che colpisci il tuo d==0caso. Anche se 50%potrebbe non accadere spesso, 10%o 1%potrebbe accadere facilmente, o anche 90%o 99%. Il test visualizzato verifica solo "se praticamente mai, mai scendi in un ramo, la predizione del ramo rende inutile la rimozione del ramo?", A cui la risposta è "sì, ma non è interessante".
Yakk - Adam Nevraumont

1
No, perché le differenze saranno effettivamente invisibili a causa del rumore.
Joe

3
La distribuzione degli zeri non si riferisce alla distribuzione trovata nella situazione del richiedente della domanda. Le immagini contenenti un mix di 0 alfa e altri hanno buchi o forma irregolare, ma (di solito) questo non è rumore. Presumere che tu non sappia nulla dei dati (e considerarli rumore) è un errore. Questa è un'applicazione del mondo reale con immagini reali che possono avere 0 alpha. E poiché è probabile che una riga di pixel abbia tutti a = 0 o tutti a> 0, approfittare della predizione dei rami potrebbe essere il più veloce, specialmente quando a = 0 si verifica molto e divisioni (lente) (15+ cicli !) vengono evitati.
DDS

13

Senza conoscere la piattaforma non c'è modo di conoscere il metodo esatto più efficiente, tuttavia, su un sistema generico questo potrebbe avvicinarsi all'ottimale (utilizzando la sintassi dell'assembler Intel):

(supponiamo che il divisore sia dentro ecxe il dividendo sia dentro eax)

mov ebx, ecx
neg ebx
sbb ebx, ebx
add ecx, ebx
div eax, ecx

Quattro istruzioni non ramificate a ciclo singolo più la divisione. Il quoziente sarà dentro eaxe il resto sarà dentro edxalla fine. (Questo tipo di mostra perché non vuoi inviare un compilatore per fare il lavoro di un uomo).


dov'è la divisione?
Yakk - Adam Nevraumont

1
questo non fa la divisione, inquina solo il divisore in modo che la divisione per zero sia impossibile
Tyler Durden

@ Jens Timmerman Scusa, l'ho scritto prima di aggiungere l'istruzione div. Ho aggiornato il testo.
Tyler Durden,

1

Secondo questo link , puoi semplicemente bloccare il segnale SIGFPE con sigaction()(non l'ho provato da solo, ma credo che dovrebbe funzionare).

Questo è l'approccio più veloce possibile se gli errori di divisione per zero sono estremamente rari: paghi solo per le divisioni per zero, non per le divisioni valide, il normale percorso di esecuzione non viene modificato affatto.

Tuttavia, il sistema operativo sarà coinvolto in ogni eccezione ignorata, il che è costoso. Penso che dovresti avere almeno mille buone divisioni per divisione per zero che ignori. Se le eccezioni sono più frequenti, probabilmente pagherai di più ignorando le eccezioni che controllando ogni valore prima della divisione.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.