Trova rapidamente se un valore è presente in un array C?


124

Ho un'applicazione incorporata con un ISR critico nel tempo che deve scorrere attraverso un array di dimensioni 256 (preferibilmente 1024, ma 256 è il minimo) e verificare se un valore corrisponde al contenuto degli array. A boolsarà impostato su true se questo è il caso.

Il microcontrollore è un LX4357 NXP, core ARM Cortex M4 e il compilatore è GCC. Ho già combinato il livello di ottimizzazione 2 (3 è più lento) e inserendo la funzione nella RAM anziché nella memoria flash. Uso anche l'aritmetica del puntatore e un forciclo, che esegue il conto alla rovescia anziché verso l'alto (controllando se i!=0è più veloce di verificare se i<256). Tutto sommato, ho una durata di 12,5 µs che deve essere ridotta drasticamente per essere fattibile. Questo è il codice (pseudo) che uso ora:

uint32_t i;
uint32_t *array_ptr = &theArray[0];
uint32_t compareVal = 0x1234ABCD;
bool validFlag = false;

for (i=256; i!=0; i--)
{
    if (compareVal == *array_ptr++)
    {
         validFlag = true;
         break;
     }
}

Quale sarebbe il modo più veloce in assoluto per farlo? È consentito l'utilizzo dell'assemblaggio in linea. Sono ammessi anche altri trucchi "meno eleganti".


28
C'è un modo per memorizzare il valore nell'array in modo diverso? Se puoi ordinarli, una ricerca binaria sarà sicuramente più veloce. Se i dati da archiviare e ricercare rientrano in un determinato intervallo, potrebbero essere rappresentabili con una bit map, ecc.
Remo.D

20
@BitBank: saresti sorpreso di quanto i compilatori siano migliorati negli ultimi tre decenni. ARM è particolarmente adatto ai compilatori. E so per
certo

8
domanda fantastica, la gente dimentica che ci sono casi del mondo reale in cui le prestazioni sono importanti. troppe volte a domande come questa si risponde "usa solo stl"
Kik,

14
Il titolo "... iterare attraverso un array" è fuorviante poiché in effetti stai semplicemente cercando un dato valore. L'iterazione su un array implica che si debba fare qualcosa su ciascuna voce. L'ordinamento, se il costo può essere ammortizzato su molte ricerche, è in effetti un approccio efficiente indipendente dai problemi di implementazione del linguaggio.
Hardmath,

8
Sei sicuro di non poter semplicemente utilizzare una ricerca binaria o una tabella hash? Una ricerca binaria per 256 elementi == 8 confronti. Una tabella hash == 1 salto in media (o 1 salto massimo se hai un hash perfetto). Dovresti ricorrere all'ottimizzazione dell'assemblaggio solo dopo che 1) hai un algoritmo di ricerca decente ( O(1)o O(logN), rispetto a O(N)) e 2) che hai profilato per essere il collo di bottiglia.
Groo

Risposte:


105

In situazioni in cui le prestazioni sono della massima importanza, il compilatore C molto probabilmente non produrrà il codice più veloce rispetto a quello che si può fare con un linguaggio assembly assemblato a mano. Tendo a prendere la strada della minor resistenza - per piccole routine come questa, scrivo solo codice asm e ho una buona idea di quanti cicli ci vorranno per eseguire. Potresti essere in grado di giocherellare con il codice C e far sì che il compilatore generi un buon output, ma potresti finire per perdere un sacco di tempo a sintonizzare l'output in quel modo. I compilatori (soprattutto di Microsoft) hanno fatto molta strada negli ultimi anni, ma non sono ancora così intelligenti come il compilatore tra le orecchie perché stai lavorando sulla tua situazione specifica e non solo su un caso generale. Il compilatore potrebbe non utilizzare determinate istruzioni (ad es. LDM) che possono accelerare questo, ed è " È improbabile che sia abbastanza intelligente da srotolare il circuito. Ecco un modo per farlo che incorpora le 3 idee che ho citato nel mio commento: srotolamento del ciclo, prefetch della cache e utilizzo dell'istruzione di caricamento multiplo (ldm). Il conteggio del ciclo di istruzioni arriva a circa 3 clock per elemento dell'array, ma questo non tiene conto dei ritardi di memoria.

Teoria di funzionamento: il design della CPU ARM esegue la maggior parte delle istruzioni in un ciclo di clock, ma le istruzioni vengono eseguite in una pipeline. I compilatori C cercheranno di eliminare i ritardi della pipeline intercalando altre istruzioni in mezzo. Quando viene presentato con un ciclo stretto come il codice C originale, il compilatore avrà difficoltà a nascondere i ritardi perché il valore letto dalla memoria deve essere immediatamente confrontato. Il mio codice seguente alterna tra 2 set di 4 registri per ridurre significativamente i ritardi della memoria stessa e la pipeline che recupera i dati. In generale, quando si lavora con set di dati di grandi dimensioni e il codice non utilizza la maggior parte o tutti i registri disponibili, non si ottengono le massime prestazioni.

; r0 = count, r1 = source ptr, r2 = comparison value

   stmfd sp!,{r4-r11}   ; save non-volatile registers
   mov r3,r0,LSR #3     ; loop count = total count / 8
   pld [r1,#128]
   ldmia r1!,{r4-r7}    ; pre load first set
loop_top:
   pld [r1,#128]
   ldmia r1!,{r8-r11}   ; pre load second set
   cmp r4,r2            ; search for match
   cmpne r5,r2          ; use conditional execution to avoid extra branch instructions
   cmpne r6,r2
   cmpne r7,r2
   beq found_it
   ldmia r1!,{r4-r7}    ; use 2 sets of registers to hide load delays
   cmp r8,r2
   cmpne r9,r2
   cmpne r10,r2
   cmpne r11,r2
   beq found_it
   subs r3,r3,#1        ; decrement loop count
   bne loop_top
   mov r0,#0            ; return value = false (not found)
   ldmia sp!,{r4-r11}   ; restore non-volatile registers
   bx lr                ; return
found_it:
   mov r0,#1            ; return true
   ldmia sp!,{r4-r11}
   bx lr

Aggiornare: ci sono molti scettici nei commenti che pensano che la mia esperienza sia aneddotica / senza valore e richieda prove. Ho usato GCC 4.8 (da Android NDK 9C) per generare il seguente output con l'ottimizzazione -O2 (tutte le ottimizzazioni attivate incluso lo svolgimento di loop ). Ho compilato il codice C originale presentato nella domanda sopra. Ecco cosa ha prodotto GCC:

.L9: cmp r3, r0
     beq .L8
.L3: ldr r2, [r3, #4]!
     cmp r2, r1
     bne .L9
     mov r0, #1
.L2: add sp, sp, #1024
     bx  lr
.L8: mov r0, #0
     b .L2

L'output di GCC non solo non srotola il loop, ma spreca anche un orologio su uno stallo dopo il LDR. Richiede almeno 8 clock per elemento dell'array. Fa un buon lavoro usando l'indirizzo per sapere quando uscire dal loop, ma tutte le cose magiche che i compilatori sono in grado di fare non si trovano da nessuna parte in questo codice. Non ho eseguito il codice sulla piattaforma di destinazione (non ne possiedo uno), ma chiunque abbia esperienza nelle prestazioni del codice ARM può vedere che il mio codice è più veloce.

Aggiornamento 2: ho dato a Visual Studio 2013 SP2 di Microsoft la possibilità di fare meglio con il codice. È stato in grado di utilizzare le istruzioni NEON per vettorializzare l'inizializzazione del mio array, ma la ricerca del valore lineare come scritta dall'OP è risultata simile a quella generata da GCC (ho rinominato le etichette per renderlo più leggibile):

loop_top:
   ldr  r3,[r1],#4  
   cmp  r3,r2  
   beq  true_exit
   subs r0,r0,#1 
   bne  loop_top
false_exit: xxx
   bx   lr
true_exit: xxx
   bx   lr

Come ho detto, non possiedo l'hardware esatto dell'OP, ma testerò le prestazioni su un nVidia Tegra 3 e Tegra 4 delle 3 diverse versioni e pubblicherò presto i risultati qui.

Aggiornamento 3: ho eseguito il mio codice e il codice ARM compilato di Microsoft su un Tegra 3 e Tegra 4 (Surface RT, Surface RT 2). Ho eseguito 1000000 iterazioni di un ciclo che non riesce a trovare una corrispondenza in modo che tutto sia nella cache ed è facile da misurare.

             My Code       MS Code
Surface RT    297ns         562ns
Surface RT 2  172ns         296ns  

In entrambi i casi il mio codice viene eseguito quasi il doppio della velocità. La maggior parte delle moderne CPU ARM darà probabilmente risultati simili.


13
@ LưuVĩnhPhúc - questo è generalmente vero, ma gli ISR ​​stretti sono una delle maggiori eccezioni, in quanto spesso sai molto di più rispetto al compilatore.
sapi,

47
Il difensore del diavolo: ci sono prove quantitative che questo codice sia più veloce?
Oliver Charlesworth,

11
@BitBank: non è abbastanza buono. Devi sostenere i tuoi reclami con prove .
Razze di leggerezza in orbita,

13
Ho imparato la mia lezione anni fa. Ho realizzato un incredibile loop interno ottimizzato per una routine grafica su un Pentium, utilizzando in modo ottimale i tubi a U e V. Sono arrivato a 6 cicli di clock per loop (calcolati e misurati), ed ero molto orgoglioso di me stesso. Quando l'ho provato con la stessa cosa scritta in C, la C era più veloce. Non ho mai più scritto un'altra riga dell'assemblatore Intel.
Rocketmagnet,

14
"scettici nei commenti che ritengono che la mia esperienza sia aneddotica / senza valore e richieda prove". Non prendere i loro commenti troppo negativamente. Mostrare la prova rende la tua risposta molto migliore.
Cody Grey

87

C'è un trucco per ottimizzarlo (una volta mi è stato chiesto durante un colloquio di lavoro):

  • Se l'ultima voce dell'array contiene il valore che stai cercando, restituisce true
  • Scrivi il valore che stai cercando nell'ultima voce dell'array
  • Scorrere l'array fino a quando non si incontra il valore che si sta cercando
  • Se l'hai riscontrato prima dell'ultima voce dell'array, restituisci true
  • Restituisci falso

bool check(uint32_t theArray[], uint32_t compareVal)
{
    uint32_t i;
    uint32_t x = theArray[SIZE-1];
    if (x == compareVal)
        return true;
    theArray[SIZE-1] = compareVal;
    for (i = 0; theArray[i] != compareVal; i++);
    theArray[SIZE-1] = x;
    return i != SIZE-1;
}

Questo produce un ramo per iterazione invece di due rami per iterazione.


AGGIORNARE:

Se ti è consentito allocare l'array SIZE+1, puoi eliminare la parte "Scambio dell'ultima voce":

bool check(uint32_t theArray[], uint32_t compareVal)
{
    uint32_t i;
    theArray[SIZE] = compareVal;
    for (i = 0; theArray[i] != compareVal; i++);
    return i != SIZE;
}

Puoi anche eliminare l'aritmetica aggiuntiva incorporata theArray[i], utilizzando invece quanto segue:

bool check(uint32_t theArray[], uint32_t compareVal)
{
    uint32_t *arrayPtr;
    theArray[SIZE] = compareVal;
    for (arrayPtr = theArray; *arrayPtr != compareVal; arrayPtr++);
    return arrayPtr != theArray+SIZE;
}

Se il compilatore non lo applica già, questa funzione lo farà sicuramente. D'altra parte, potrebbe essere più difficile per l'ottimizzatore srotolare il ciclo, quindi dovrai verificare che nel codice assembly generato ...


2
@ratchetfreak: OP non fornisce alcun dettaglio su come, dove e quando questo array viene allocato e inizializzato, quindi ho dato una risposta che non dipende da quello.
Barak Manos,

3
L'array è nella RAM, tuttavia le scritture non sono consentite.
Wlamers,

1
bello, ma l'array non è più const, il che non lo rende thread-safe. Sembra un prezzo elevato da pagare.
EOF

2
@EOF: dove è constmai stato menzionato nella domanda?
Barak Manos,

4
@barakmanos: se ti passo un array e un valore e ti chiedo se il valore è nell'array, di solito non presumo che modificherai l'array. La domanda originale non menziona constné i thread, ma penso che sia giusto menzionare questo avvertimento.
EOF,

62

Stai chiedendo aiuto per ottimizzare il tuo algoritmo, che potrebbe spingerti verso l'assemblatore. Ma il tuo algoritmo (una ricerca lineare) non è così intelligente, quindi dovresti considerare di cambiarlo. Per esempio:

Funzione hash perfetta

Se i tuoi 256 valori "validi" sono statici e noti al momento della compilazione, puoi utilizzare una funzione hash perfetta . È necessario trovare una funzione hash che associ il valore di input a un valore compreso nell'intervallo 0 .. n , in cui non vi sono collisioni per tutti i valori validi a cui tieni. Cioè, nessun due valori "validi" hanno lo stesso valore di output. Quando cerchi una buona funzione hash, miri a:

  • Mantieni la funzione hash ragionevolmente veloce.
  • Riduci a icona n . Il più piccolo che puoi ottenere è 256 (funzione hash perfetta minima), ma probabilmente è difficile da ottenere, a seconda dei dati.

Nota per funzioni hash efficienti, n è spesso una potenza di 2, che equivale a una maschera bit a bit di bit bassi (operazione AND). Funzioni hash di esempio:

  • CRC di byte di input, modulo n .
  • ((x << i) ^ (x >> j) ^ (x << k) ^ ...) % n(raccogliendo come molti i, j, k, ..., se necessario, con turni di sinistra o destra)

Quindi si crea una tabella fissa di n voci, in cui l'hash associa i valori di input a un indice i nella tabella. Per valori validi, la tabella i contiene il valore valido. Per tutte le altre voci della tabella, assicurarsi che ogni voce dell'indice i contenga altri valori non validi che non sono associati a i .

Quindi nella tua routine di interrupt, con input x :

  1. Hash x per indicizzare i (che è nell'intervallo 0..n)
  2. Cerca la voce i nella tabella e vedi se contiene il valore x .

Questo sarà molto più veloce di una ricerca lineare di 256 o 1024 valori.

Ho scritto del codice Python per trovare funzioni hash ragionevoli.

Ricerca binaria

Se ordini il tuo array di 256 valori "validi", puoi fare una ricerca binaria , piuttosto che una ricerca lineare. Ciò significa che dovresti essere in grado di cercare una tabella a 256 voci in soli 8 passaggi ( log2(256)) o una tabella a 1024 voci in 10 passaggi. Ancora una volta, questo sarà molto più veloce di una ricerca lineare di 256 o 1024 valori.


Grazie per quello L'opzione di ricerca binaria è quella che ho scelto. Vedi anche un commento precedente nel primo post. Questo fa molto bene il trucco senza usare il montaggio.
Wlamers,

11
Anzi, prima di provare a ottimizzare il tuo codice (come usare assembly o altri trucchi) dovresti probabilmente vedere se riesci a ridurre la complessità algoritmica. Solitamente ridurre la complessità algoritmica sarà più efficiente rispetto al tentativo di scap alcuni cicli ma mantenendo la stessa complessità algoritmica.
ysdx,

3
+1 per la ricerca binaria. La riprogettazione algoritmica è il modo migliore per ottimizzare.
Rocketmagnet,

Un'idea popolare è che ci vuole troppo sforzo per trovare una routine hash efficiente, quindi la "best practice" è una ricerca binaria. A volte, tuttavia, le "migliori pratiche" non sono abbastanza buone. Supponiamo di instradare il traffico di rete al volo nel momento in cui è arrivata l'intestazione di un pacchetto (ma non il suo payload): l'uso di una ricerca binaria renderebbe il tuo prodotto irrimediabilmente lento. I prodotti incorporati di solito hanno tali vincoli e requisiti che ciò che è "best practice" in, ad esempio, un ambiente di esecuzione x86 sta "prendendo la via più semplice" in embedded.
Olof Forshell,

60

Mantenere la tabella in ordine ordinato e utilizzare la ricerca binaria non srotolata di Bentley:

i = 0;
if (key >= a[i+512]) i += 512;
if (key >= a[i+256]) i += 256;
if (key >= a[i+128]) i += 128;
if (key >= a[i+ 64]) i +=  64;
if (key >= a[i+ 32]) i +=  32;
if (key >= a[i+ 16]) i +=  16;
if (key >= a[i+  8]) i +=   8;
if (key >= a[i+  4]) i +=   4;
if (key >= a[i+  2]) i +=   2;
if (key >= a[i+  1]) i +=   1;
return (key == a[i]);

Il punto è,

  • se sai quanto è grande il tavolo, allora sai quante iterazioni ci saranno, quindi puoi srotolarlo completamente.
  • Quindi, non ha senso testare il ==caso su ogni iterazione perché, ad eccezione dell'ultima iterazione, la probabilità di quel caso è troppo bassa per giustificare il tempo dedicato al test per esso. **
  • Infine, espandendo la tabella a una potenza di 2, aggiungi al massimo un confronto e al massimo un fattore di archiviazione due.

** Se non sei abituato a pensare in termini di probabilità, ogni punto di decisione ha un'entropia , che è l'informazione media che apprendi eseguendola. Per i >=test, la probabilità di ogni ramo è di circa 0,5 e -log2 (0,5) è 1, quindi ciò significa che se prendi un ramo impari 1 bit, e se prendi l'altro ramo impari un bit e la media è solo la somma di ciò che impari su ogni ramo moltiplicato per la probabilità di quel ramo. Quindi 1*0.5 + 1*0.5 = 1, quindi l'entropia del >=test è 1. Dato che hai 10 bit da imparare, ci vogliono 10 rami. Ecco perché è veloce!

D'altra parte, cosa succede se il tuo primo test è if (key == a[i+512)? La probabilità di essere vero è 1/1024, mentre la probabilità di falso è 1023/1024. Quindi, se è vero, impari tutti i 10 bit! Ma se è falso impari -log2 (1023/1024) = .00141 bit, praticamente nulla! Quindi la quantità media che impari da quel test è 10/1024 + .00141*1023/1024 = .0098 + .00141 = .0112bit. Circa un centesimo di bit. Quel test non sta portando il suo peso!


4
Mi piace molto questa soluzione. Può essere modificato per l'esecuzione in un numero fisso di cicli per evitare analisi forensi basate sulla temporizzazione se la posizione del valore è un'informazione sensibile.
OregonTrail

1
@OregonTrail: Forensics basata sui tempi? Problema divertente, ma commento triste.
Mike Dunlavey,

16
Vedi loop srotolati come questo nelle librerie di criptovalute per prevenire gli attacchi a tempo en.wikipedia.org/wiki/Timing_attack . Ecco un buon esempio github.com/jedisct1/libsodium/blob/… In questo caso stiamo impedendo a un utente malintenzionato di indovinare la lunghezza di una stringa. Di solito l'attaccante prenderà diversi milioni di campioni di una chiamata di funzione per eseguire un attacco di temporizzazione.
OregonTrail

3
+1 fantastico! Bella piccola ricerca srotolata. Non l'avevo mai visto prima. Potrei usarlo.
Rocketmagnet,

1
@OregonTrail: secondo il tuo commento basato sul tempo. Ho dovuto più di una volta scrivere codice crittografico che viene eseguito in un numero fisso di cicli, per evitare la perdita di informazioni dagli attacchi basati sul tempo.
TonyK,

16

Se l'insieme di costanti nella tabella è noto in anticipo, è possibile utilizzare l' hash perfetto per garantire che venga effettuato un solo accesso alla tabella. L'hash perfetto determina una funzione di hash che associa ogni chiave interessante a uno slot unico (quella tabella non è sempre densa, ma puoi decidere quanto un tavolo non denso ti puoi permettere, con le tabelle meno dense che in genere portano a funzioni di hashing più semplici).

Di solito, la funzione hash perfetta per l'insieme specifico di chiavi è relativamente facile da calcolare; non vuoi che sia lungo e complicato perché è in competizione per il tempo forse meglio speso facendo più sonde.

L'hash perfetto è uno schema "1 sonda max". Si può generalizzare l'idea, con il pensiero che si dovrebbe scambiare la semplicità di calcolo del codice hash con il tempo necessario per fare k sonde. Dopotutto, l'obiettivo è "il minor tempo totale di ricerca", non meno sonde o la funzione di hash più semplice. Tuttavia, non ho mai visto nessuno costruire un algoritmo di hashing k-probes-max. Ho il sospetto che uno possa farlo, ma questa è probabilmente una ricerca.

Un altro pensiero: se il tuo processore è estremamente veloce, l'unica sonda in memoria da un hash perfetto probabilmente domina il tempo di esecuzione. Se il processore non è molto veloce, allora le sonde k> 1 potrebbero essere pratiche.


1
Un Cortex-M non è assolutamente veloce .
Salterio,

2
In questo caso, infatti, non ha bisogno di alcuna tabella hash. Vuole solo sapere se una determinata chiave è nel set, non vuole mapparla su un valore. Quindi è sufficiente se la funzione hash perfetta associa ogni valore a 32 bit a 0 o 1 dove "1" potrebbe essere definito come "è nell'insieme".
David Ongaro,

1
Buon punto, se riesce a ottenere un generatore di hash perfetto per produrre una tale mappatura. Ma sarebbe "un set estremamente denso"; Credo che riesca a trovare un generatore di hash perfetto che lo faccia. Potrebbe stare meglio cercando di ottenere un hash perfetto che produca una costante K se nel set e qualsiasi valore tranne K se non nel set. Ho il sospetto che sia difficile ottenere un hash perfetto anche per quest'ultimo.
Ira Baxter,

@DavidOngaro table[PerfectHash(value)] == valueproduce 1 se il valore è nel set e 0 se non lo è, e ci sono modi ben noti per produrre la funzione PerfectHash (vedi, ad esempio, burtleburtle.net/bob/hash/perfect.html ). Cercare di trovare una funzione hash che mappi direttamente tutti i valori nell'insieme su 1 e tutti i valori non nell'insieme su 0 è un'attività folle.
Jim Balter,

@DavidOngaro: una funzione hash perfetta ha molti "falsi positivi", vale a dire che i valori non nell'insieme avrebbero lo stesso hash dei valori nell'insieme. Quindi devi avere una tabella, indicizzata dal valore di hash, contenente il valore di input "nel set". Quindi per convalidare qualsiasi dato valore di input tu (a) lo hash; (b) usa il valore di hash per cercare la tabella; (c) verificare se la voce nella tabella corrisponde al valore di input.
Craig McQueen,

14

Usa un set di hash. Dà O (1) tempo di ricerca.

Il codice seguente presuppone che è possibile riservare il valore 0come valore "vuoto", ovvero non presente nei dati effettivi. La soluzione può essere espansa per una situazione in cui non è così.

#define HASH(x) (((x >> 16) ^ x) & 1023)
#define HASH_LEN 1024
uint32_t my_hash[HASH_LEN];

int lookup(uint32_t value)
{
    int i = HASH(value);
    while (my_hash[i] != 0 && my_hash[i] != value) i = (i + 1) % HASH_LEN;
    return i;
}

void store(uint32_t value)
{
    int i = lookup(value);
    if (my_hash[i] == 0)
       my_hash[i] = value;
}

bool contains(uint32_t value)
{
    return (my_hash[lookup(value)] == value);
}

In questo esempio di implementazione, il tempo di ricerca sarà in genere molto basso, ma nel caso peggiore può arrivare fino al numero di voci memorizzate. Per un'applicazione in tempo reale, puoi anche considerare un'implementazione usando alberi binari, che avranno un tempo di ricerca più prevedibile.


3
Dipende da quante volte questa ricerca deve essere eseguita affinché questa sia efficace.
maxywb,

1
Ehm, la ricerca può essere eseguita alla fine dell'array. E questo tipo di hashing lineare ha alti tassi di collisione - in nessun modo otterrai O (1). I buoni set di hash non sono implementati in questo modo.
Jim Balter,

@JimBalter Codice vero, non perfetto. Più come l'idea generale; avrebbe potuto semplicemente indicare il codice di hash set esistente. Ma considerando che si tratta di una routine di servizio di interruzione, può essere utile dimostrare che la ricerca non è un codice molto complesso.
jpa,

Dovresti solo ripararlo in modo che mi avvolga.
Jim Balter,

Il punto di una funzione hash perfetta è che esegue una sonda. Periodo.
Ira Baxter,

10

In questo caso, potrebbe essere utile studiare i filtri Bloom . Sono in grado di stabilire rapidamente che un valore non è presente, il che è positivo poiché la maggior parte dei 2 ^ 32 valori possibili non si trovano in quell'array di elementi 1024. Tuttavia, ci sono alcuni falsi positivi che avranno bisogno di un controllo extra.

Poiché il tuo tavolo è apparentemente statico, puoi determinare quali falsi positivi esistono per il tuo filtro Bloom e metterli in un hash perfetto.


1
Interessante, non avevo mai visto i filtri Bloom prima.
Rocketmagnet,

8

Supponendo che il tuo processore funzioni a 204 MHz, che sembra essere il massimo per LPC4357, e anche supponendo che il tuo risultato di temporizzazione rifletta il caso medio (metà dell'array attraversato), otteniamo:

  • Frequenza della CPU: 204 MHz
  • Periodo di ciclo: 4,9 ns
  • Durata in cicli: 12,5 µs / 4,9 ns = 2551 cicli
  • Cicli per iterazione: 2551/128 = 19,9

Pertanto, il ciclo di ricerca impiega circa 20 cicli per iterazione. Non sembra terribile, ma immagino che per renderlo più veloce devi guardare l'assemblea.

Consiglierei di abbandonare l'indice e utilizzare invece un confronto di puntatori e creare tutti i puntatori const.

bool arrayContains(const uint32_t *array, size_t length)
{
  const uint32_t * const end = array + length;
  while(array != end)
  {
    if(*array++ == 0x1234ABCD)
      return true;
  }
  return false;
}

Vale almeno la pena testarlo.


1
-1, ARM ha una modalità di indirizzo indicizzato, quindi è inutile. Per quanto riguarda la creazione del puntatore const, GCC rileva già che non cambia. Il constnulla doesnt't add neanche.
Salterio,

11
@MSalters OK, non ho verificato con il codice generato, il punto era esprimere qualcosa che lo rendesse più semplice a livello C, e penso che gestire i puntatori anziché un puntatore e un indice sia più semplice. Semplicemente non sono d'accordo sul fatto che " constnon aggiunge nulla": dice molto chiaramente al lettore che il valore non cambierà. Questa è un'informazione fantastica.
Rilassati il

9
Questo è un codice profondamente incorporato; le ottimizzazioni finora hanno incluso lo spostamento del codice da flash a RAM. Eppure deve ancora essere più veloce. A questo punto, la leggibilità non è l'obiettivo.
Salterio,

1
@MSalters "ARM ha una modalità di indirizzo indicizzato, quindi questo è inutile" - beh, se perdi completamente il punto ... l'OP ha scritto "Uso anche l'aritmetica del puntatore e un ciclo for". unwind non ha sostituito l'indicizzazione con i puntatori, ha semplicemente eliminato la variabile index e quindi una sottrazione aggiuntiva su ogni iterazione di loop. Ma l'OP era saggio (a differenza di molte persone che rispondevano e commentavano) e finì per fare una ricerca binaria.
Jim Balter,

6

Altre persone hanno suggerito di riorganizzare la tabella, aggiungere un valore sentinella alla fine o ordinarlo per fornire una ricerca binaria.

Affermate "Uso anche l'aritmetica del puntatore e un ciclo for, che esegue il conto alla rovescia anziché verso l'alto (controllando se i != 0è più veloce che controllando se i < 256)."

Il mio primo consiglio è: sbarazzarsi dell'aritmetica del puntatore e del conto alla rovescia. Cose come

for (i=0; i<256; i++)
{
    if (compareVal == the_array[i])
    {
       [...]
    }
}

tende ad essere idiomatico per il compilatore. Il ciclo è idiomatico e l'indicizzazione di un array su una variabile di ciclo è idiomatica. La giocoleria con l'aritmetica dei puntatori e i puntatori tenderà a offuscare gli idiomi per il compilatore e fargli generare codice relativo a ciò che hai scritto piuttosto che a quello che lo scrittore del compilatore ha deciso di essere il miglior corso per l' attività generale .

Ad esempio, il codice sopra potrebbe essere compilato in un ciclo che va da -256o -255a zero, indicizzando off &the_array[256]. Forse roba che non è nemmeno esprimibile in C valida ma corrisponde all'architettura della macchina per la quale stai generando.

Quindi non microottimizzare. Stai solo gettando le chiavi nelle opere del tuo ottimizzatore. Se vuoi essere intelligente, lavora sulle strutture dei dati e sugli algoritmi, ma non microottimizzare la loro espressione. Tornerà solo a morderti, se non sull'attuale compilatore / architettura, poi su quello successivo.

In particolare, l'utilizzo dell'aritmetica del puntatore anziché di array e indici è un veleno per il compilatore che è pienamente consapevole di allineamenti, posizioni di archiviazione, considerazioni di aliasing e altre cose e per fare ottimizzazioni come la riduzione della resistenza nel modo più adatto all'architettura della macchina.


I loop sui puntatori sono idiomatici in C e i compilatori ottimizzati possono gestirli così come l'indicizzazione. Ma tutto questo è discutibile perché l'OP ha finito per fare una ricerca binaria.
Jim Balter,

3

La vettorializzazione può essere utilizzata qui, come spesso accade nelle implementazioni di memchr. Si utilizza il seguente algoritmo:

  1. Crea una maschera della tua query ripetuta, uguale in lunghezza al conteggio dei bit del tuo sistema operativo (64-bit, 32-bit, ecc.). Su un sistema a 64 bit, ripetere la query a 32 bit due volte.

  2. Elabora l'elenco come un elenco di più dati contemporaneamente, semplicemente eseguendo il cast dell'elenco in un elenco di un tipo di dati più grande ed estraendo i valori. Per ogni pezzo, XOR con la maschera, quindi XOR con 0b0111 ... 1, quindi aggiungi 1, quindi e con una maschera di 0b1000 ... 0 ripetuta. Se il risultato è 0, non c'è sicuramente una corrispondenza. Altrimenti, potrebbe esserci (di solito con probabilità molto alta) una corrispondenza, quindi cerca normalmente il blocco.

Implementazione di esempio: https://sourceware.org/cgi-bin/cvsweb.cgi/src/newlib/libc/string/memchr.c?rev=1.3&content-type=text/x-cvsweb-markup&cvsroot=src


3

Se riesci a soddisfare il dominio dei tuoi valori con la quantità di memoria disponibile per la tua applicazione, la soluzione più veloce sarebbe quella di rappresentare il tuo array come un array di bit:

bool theArray[MAX_VALUE]; // of which 1024 values are true, the rest false
uint32_t compareVal = 0x1234ABCD;
bool validFlag = theArray[compareVal];

MODIFICARE

Sono stupito dal numero di critici. Il titolo di questo thread è "Come posso trovare rapidamente se un valore è presente in un array C?" per il quale rimarrò fedele alla mia risposta perché risponde esattamente a ciò. Potrei sostenere che questa ha la funzione hash più efficiente in termini di velocità (poiché indirizzo === valore). Ho letto i commenti e sono consapevole delle ovvie avvertenze. Indubbiamente questi avvertimenti limitano la gamma di problemi che questo può essere usato per risolvere, ma, per quei problemi che risolve, risolve in modo molto efficiente.

Invece di rifiutare completamente questa risposta, considerala come il punto di partenza ottimale per cui puoi evolverti usando le funzioni hash per raggiungere un migliore equilibrio tra velocità e prestazioni.


8
Come si ottengono 4 voti positivi? La domanda afferma che è un Cortex M4. La cosa ha 136 KB di RAM, non 262.144 KB.
Salterio,

1
È sorprendente quanti voti sono stati dati a risposte manifestamente sbagliate perché il rispondente ha perso la foresta per gli alberi. Per il caso più grande del PO O (log n) << O (n).
msw,

3
Divento molto scontroso con i programmatori che bruciano ridicole quantità di memoria, quando ci sono soluzioni di gran lunga migliori disponibili. Ogni 5 anni sembra che il mio PC stia esaurendo la memoria, dove 5 anni fa quella quantità era abbondante.
Craig McQueen

1
@CraigMcQueen Kids in questi giorni. Spreco di memoria. Oltraggioso! Ai miei tempi, avevamo 1 MiB di memoria e una dimensione della parola di 16 bit. / s
Cole Johnson

2
Cosa c'è con i critici aspri? L'OP afferma chiaramente che la velocità è assolutamente critica per questa porzione di codice e StephenQuan ha già menzionato una "ridicola quantità di memoria".
Bogdan Alexandru,

1

Assicurati che le istruzioni ("lo pseudo codice") e i dati ("l'array") siano in memorie separate (RAM) in modo che l'architettura CM4 Harvard sia sfruttata al massimo. Dal manuale dell'utente:

inserisci qui la descrizione dell'immagine

Per ottimizzare le prestazioni della CPU, ARM Cortex-M4 ha tre bus per l'accesso alle istruzioni (codice) (I), l'accesso ai dati (D) e l'accesso al sistema (S). Quando le istruzioni e i dati sono conservati in memorie separate, gli accessi al codice e ai dati possono essere eseguiti in parallelo in un ciclo. Quando il codice e i dati sono conservati nella stessa memoria, le istruzioni per caricare o archiviare i dati possono richiedere due cicli.


Interessante, Cortex-M7 ha istruzioni opzionali / cache di dati, ma prima sicuramente no. en.wikipedia.org/wiki/ARM_Cortex-M#Silicon_customization .
Peter Cordes,

0

Mi dispiace se la mia risposta ha già ricevuto risposta - sono solo un lettore pigro. Sentiti libero di votare poi))

1) è possibile rimuovere il contatore "i", basta confrontare i puntatori, ad es

for (ptr = &the_array[0]; ptr < the_array+1024; ptr++)
{
    if (compareVal == *ptr)
    {
       break;
    }
}
... compare ptr and the_array+1024 here - you do not need validFlag at all.

tutto ciò non darà alcun miglioramento significativo, tuttavia, tale ottimizzazione probabilmente potrebbe essere raggiunta dal compilatore stesso.

2) Come già menzionato da altre risposte, quasi tutte le CPU moderne sono basate su RISC, ad esempio ARM. Anche le moderne CPU Intel X86 usano i core RISC all'interno, per quanto ne so (compilando da X86 al volo). La principale ottimizzazione per RISC è l'ottimizzazione della pipeline (e anche per Intel e altre CPU), riducendo al minimo i salti di codice. Un tipo di tale ottimizzazione (probabilmente una delle maggiori) è quella del "rollback del ciclo". È incredibilmente stupido ed efficiente, anche il compilatore Intel può farlo AFAIK. Sembra:

if (compareVal == the_array[0]) { validFlag = true; goto end_of_compare; }
if (compareVal == the_array[1]) { validFlag = true; goto end_of_compare; }
...and so on...
end_of_compare:

In questo modo l'ottimizzazione è che la pipeline non viene interrotta nel caso peggiore (se compare array assente nell'array), quindi è il più veloce possibile (ovviamente senza contare le ottimizzazioni dell'algoritmo come tabelle hash, array ordinati e così via, menzionato in altre risposte, che possono dare risultati migliori a seconda della dimensione dell'array. L'approccio di rollback dei cicli può essere applicato anche lì. Sto scrivendo qui che penso di non aver visto in altri)

La seconda parte di questa ottimizzazione è che l'elemento dell'array viene preso per indirizzo diretto (calcolato in fase di compilazione, assicurarsi di utilizzare un array statico) e non è necessaria un'ulteriore operazione ADD per calcolare il puntatore dall'indirizzo di base dell'array. Questa ottimizzazione potrebbe non avere effetti significativi, poiché l'architettura ARM di AFAIK ha funzioni speciali per accelerare l'indirizzamento di array. Ma comunque è sempre meglio sapere che hai fatto tutto il meglio direttamente nel codice C, giusto?

Il rollback del ciclo può sembrare scomodo a causa dello spreco di ROM (sì, hai fatto bene posizionandolo nella parte veloce della RAM, se la tua scheda supporta questa funzione), ma in realtà è un giusto pagamento per la velocità, basato sul concetto RISC. Questo è solo un punto generale di ottimizzazione del calcolo: sacrifichi lo spazio per motivi di velocità e viceversa, a seconda delle tue esigenze.

Se ritieni che il rollback per un array di 1024 elementi sia un sacrificio troppo grande per il tuo caso, puoi considerare il "rollback parziale", ad esempio dividendo l'array in 2 parti di 512 elementi ciascuna, oppure 4x256 e così via.

3) le moderne CPU spesso supportano operazioni SIMD, ad esempio set di istruzioni ARM NEON - consente di eseguire le stesse operazioni in parallelo. Francamente, non ricordo se è adatto per operazioni di confronto, ma penso che potrebbe essere, dovresti verificarlo. Googling mostra che potrebbero esserci anche alcuni trucchi, per ottenere la massima velocità, vedi https://stackoverflow.com/a/5734019/1028256

Spero che possa darti alcune nuove idee.


L'OP ha ignorato tutte le risposte insensate incentrate sull'ottimizzazione dei loop lineari, ma ha invece preordinato l'array e fatto una ricerca binaria.
Jim Balter,

@Jim, è ovvio che quel tipo di ottimizzazione dovrebbe essere fatta per prima. Le risposte "insensate" potrebbero non sembrare così sciocche in alcuni casi d'uso quando, ad esempio, non si ha il tempo di ordinare l'array. O se la velocità che ottieni, non è comunque sufficiente
Mixaz

"è ovvio che quel tipo di ottimizzazione dovrebbe essere fatta per prima" - ovviamente non per le persone che hanno fatto un grande sforzo per sviluppare soluzioni lineari. "Non hai tempo per ordinare l'array" - Non ho idea di cosa significhi. "O se la velocità che ottieni, non è comunque sufficiente" - Uh, se la velocità di una ricerca binaria non è "sufficiente", fare una ricerca lineare ottimizzata non la migliorerà. Ora ho finito con questo argomento.
Jim Balter,

@JimBalter, se avessi problemi come OP, certamente prenderei in considerazione l'uso di alg come la ricerca binaria o qualcosa del genere. Non riuscivo a pensare che OP non lo avesse già preso in considerazione. "Non hai tempo per ordinare l'array" significa che l'array di ordinamento richiede tempo. Se è necessario farlo per ciascun set di dati di input, potrebbe essere necessario più tempo di un loop lineare. "O se la velocità che ottieni, non è comunque sufficiente" significa: i suggerimenti di ottimizzazione sopra potrebbero essere utilizzati per accelerare il codice di ricerca binaria o altro
Mixaz

0

Sono un grande fan di hashing. Il problema ovviamente è trovare un algoritmo efficiente che sia veloce e che utilizzi una quantità minima di memoria (specialmente su un processore incorporato).

Se conosci in anticipo i valori che possono verificarsi, puoi creare un programma che esegua una moltitudine di algoritmi per trovare il migliore - o, piuttosto, i migliori parametri per i tuoi dati.

Ho creato un programma del genere che puoi leggere in questo post e ho ottenuto risultati molto veloci. 16000 voci si traducono approssimativamente in 2 ^ 14 o in media 14 confronti per trovare il valore usando una ricerca binaria. Ho mirato esplicitamente a ricerche molto veloci - in media trovando il valore in <= 1.5 ricerche - che ha comportato maggiori requisiti di RAM. Credo che con un valore medio più conservativo (diciamo <= 3) si potrebbe risparmiare molta memoria. In confronto, il caso medio di una ricerca binaria su 256 o 1024 voci comporterebbe un numero medio di confronti di 8 e 10, rispettivamente.

La mia ricerca media richiedeva circa 60 cicli (su un laptop con un Intel i5) con un algoritmo generico (utilizzando una divisione per una variabile) e 40-45 cicli con uno specializzato (probabilmente utilizzando una moltiplicazione). Ciò dovrebbe tradursi in tempi di ricerca al di sotto dei microsecondi sull'MCU, a seconda ovviamente della frequenza di clock in cui viene eseguita.

Può essere modificato ulteriormente nella vita reale se l'array entry tiene traccia di quante volte è stato effettuato l'accesso. Se l'array di voci viene ordinato dalla più alla meno accessibile prima che gli indici vengano calcolati, troverà i valori più comuni con un solo confronto.


0

È più un addendum che una risposta.

Ho avuto un caso simile in passato, ma il mio array è stato costante su un numero considerevole di ricerche.

In metà di essi, il valore cercato NON era presente nella matrice. Poi ho capito che avrei potuto applicare un "filtro" prima di fare qualsiasi ricerca.

Questo "filtro" è solo un semplice numero intero, calcolato UNA VOLTA e utilizzato in ogni ricerca.

È in Java, ma è piuttosto semplice:

binaryfilter = 0;
for (int i = 0; i < array.length; i++)
{
    // just apply "Binary OR Operator" over values.
    binaryfilter = binaryfilter | array[i];
}

Quindi, prima di fare una ricerca binaria, controllo binaryfilter:

// Check binaryfilter vs value with a "Binary AND Operator"
if ((binaryfilter & valuetosearch) != valuetosearch)
{
    // valuetosearch is not in the array!
    return false;
}
else
{
    // valuetosearch MAYBE in the array, so let's check it out
    // ... do binary search stuff ...

}

Puoi usare un algoritmo hash "migliore", ma questo può essere molto veloce, specialmente per grandi numeri. Potrebbe essere questo potrebbe farti risparmiare ancora più cicli.

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.