Per qualche strlen in più – pt.2

P

Per qualche strlen in più come ottimizzare la strlen in C – pt.2

col. Mortimer: Che succede ragazzo?
Il Monco: Niente vecchio... non mi tornavano i conti. Me ne mancava uno.

Ok, siamo pronti per la seconda parte di …niente vecchio… la strlen è un po’ lenta oggi…

Allora, nel benchmark confronteremo i risultati (di velocità, ovviamente) delle quattro versioni di strlen() viste nello scorso post, e cioè: K&R, FreeBSD, glibc, e musl libc e il nostro punto di riferimento sarà, evidentemente, la strlen() di default del sistema, anche perché uno dei nostri obbiettivi è scoprire cosa usa di default Linux, che è, al solito, il nostro sistema di riferimento. Vediamo il codice del benchmark:

#include <stdint.h>
#include <limits.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <string.h>

// prototipi locali
size_t strlenKaR(const char *s);
size_t strlenFreeBSD(const char *str);
size_t strlenGlibc(const char *str);
size_t strlenMusl(const char *s);

int main(int argc, char* argv[])
{
    static char s[1000000000];
    clock_t t_start;
    size_t  result;
    clock_t t_end;
    double  t_passed;
    int i;

    // riempio la stringa di test
    for (i = 0; i < sizeof(s); i++) {
        if (i
            s[i] = 'a';
        else
            s[i] = 'u';
    }
    s[i - 1] = 0; // terminatore

    // esegue con strlen() di default e calcola tempo
    t_start  = clock();
    result   = strlen(s);
    t_end    = clock();
    t_passed = ((double)(t_end - t_start)) / CLOCKS_PER_SEC;
    printf("strlen(s)        =

    // esegue con strlenKaR() e calcola tempo
    t_start  = clock();
    result   = strlenKaR(s);
    t_end    = clock();
    t_passed = ((double)(t_end - t_start)) / CLOCKS_PER_SEC;
    printf("strlenKaR(s)     =

    // esegue con strlenFreeBSD() e calcola tempo
    t_start  = clock();
    result   = strlenFreeBSD(s);
    t_end    = clock();
    t_passed = ((double)(t_end - t_start)) / CLOCKS_PER_SEC;
    printf("strlenFreeBSD(s) =

    // esegue con strlenGlibc() e calcola tempo
    t_start  = clock();
    result   = strlenGlibc(s);
    t_end    = clock();
    t_passed = ((double)(t_end - t_start)) / CLOCKS_PER_SEC;
    printf("strlenGlibc(s)   =

    // esegue con strlenMusl() e calcola tempo
    t_start  = clock();
    result   = strlenMusl(s);
    t_end    = clock();
    t_passed = ((double)(t_end - t_start)) / CLOCKS_PER_SEC;
    printf("strlenMusl(s)    =

    return 0;
}

Come avrete notato, abbiamo creato una stringa enorme (e se no che test era?) e l’abbiamo riempita di una sequenza di “au” (…beh, si poteva riempire di qualsiasi cosa, ma quel giorno mi ero svegliato con un “au” in testa che non vi dico…). Abbiamo aggiunto il dovuto terminatore di stringa e chiamato in sequenza le varie strlen() usando un metodo affidabile (con clock()) per verificare il tempo reale di attività (un metodo che abbiamo già usato in un vecchio post). Le strlen() sono, ovviamente, quelle dello scorso post (i codici li trovate li), ribattezzate come strlenKaR(), strlenFreeBSD(), strlenGlibc() e strlenMusl(), per distinguerle dall’unica funzione non locale, che è la strlen() di libreria. Come si nota per la versione K&R ho modificato il prototipo (e la definizione) per uniformarla alla strlen() standard.

Vediamo ora i risultati del benchmark con compilazione normale (-O0, che è il default e si può anche omettere) e con una notevole ottimizzazione (-O2, che è quella che si usa normalmente nei casi reali):

usando gcc -O0

aldo@mylinux:~/blogtest$ ./strlens 
strlen(s)        = 999999999 - Tempo trascorso: 0.054814 secondi
strlenKaR(s)     = 999999999 - Tempo trascorso: 2.173237 secondi
strlenFreeBSD(s) = 999999999 - Tempo trascorso: 0.308145 secondi
strlenGlibc(s)   = 999999999 - Tempo trascorso: 0.252466 secondi
strlenMusl(s)    = 999999999 - Tempo trascorso: 0.304617 secondi

usando gcc -O2

aldo@mylinux:~/blogtest$ ./strlens 
strlen(s)        = 999999999 - Tempo trascorso: 0.164509 secondi
strlenKaR(s)     = 999999999 - Tempo trascorso: 0.395466 secondi
strlenFreeBSD(s) = 999999999 - Tempo trascorso: 0.091599 secondi
strlenGlibc(s)   = 999999999 - Tempo trascorso: 0.102967 secondi
strlenMusl(s)    = 999999999 - Tempo trascorso: 0.091931 secondi

I risultati sono veramente interessanti. La strlenKaR() è, come ci aspettavamo, molto più lenta delle versioni che usano l’algoritmo descritto in ZeroInWord e, anche compilando con -O2, è più lenta delle altre compilate con -O0, anche se, comunque, il divario tra le versioni ottimizzate è minore di quello tra le non ottimizzate. Ripeto, siamo in un caso limite: la stringa misurata è veramente enorme, e con stringhe “normali” la differenza di prestazioni sarebbe molto più piccola, comunque è evidente che le versioni con algoritmo speciale vanno meglio e sono, giustamente, da preferire, specialmente ricordando che “le super-ottimizzazioni sono sicuramente raccomandabili per le piccole funzioni di uso frequente” (…e questa è una mia auto-citazione dal mio ultimo post…). Le tre funzioni strlenFreeBSD(), strlenGlibc e strlenMusl() hanno prestazioni ottime ed analoghe, visto che usano lo stesso algoritmo, e la versione glibc prevale leggermente compilando con -O0, mentre che compilando con -O2 la migliore è la musl, con la FreeBSD a ruota.

E veniamo alla parte sorprendente: pare che la strlen() di default è nettamente più veloce di tutte le altre compilando con -O0 mentre perde il vantaggio compilando con -O2. Quindi, almeno senza ottimizzazioni, sul mio sistema Linux la strlen() di default non è quella della glibc (come uno si aspetterebbe) ma è qualcos’altro. E cosa è, allora? Proviamo a commentare la linea “#include <string.h>” e a ricompilare, e vediamo cosa ci dice il compilatore…

aldo@mylinux:~/blogtest$ gcc -c -O0 strlens.c -o strlens.o
strlens.c: In function 'main':
strlens.c:35:16: warning: implicit declaration of function 'strlen'
     result   = strlen(s);
                ^
strlens.c:35:16: warning: incompatible implicit declaration of built-in function 'strlen'
strlens.c:35:16: note: include '<string.h>' or provide a declaration of 'strlen'

Ecco, pare che la strlen() di default sia una funzione implicita del compilatore gcc, quindi non si usa la versione della libreria e, del resto “…in alcuni casi queste funzioni possono essere addirittura inlineate e/o scritte in assembly…” (…e questa è un’altra mia auto-citazione dal mio ultimo post…). Per confermare tutto questo ho cercato una versione in assembly della strlen() e l’ho trovata qui su stackoverflow. Ho modificato leggermente Il codice (la originale non restituiva correttamente il risultato) e ho modificato il benchmark per usarla. Il codice è il seguente:

#include <immintrin.h>

// una possibile implementazione in assembler della strlen() implicita in gcc
size_t strlenAsm(const char* src)
{
    size_t result = 0;
    unsigned int tmp1;
    __m128i zero = _mm_setzero_si128(), vectmp;

    // A pointer-increment may perform better than an indexed addressing mode
    asm(
        "\n.Lloop:\n\t"
            "movdqu   
            "pcmpeqb 
            "pmovmskb
            "add      $0x10,
            "test    
            "jz  .Lloop\n\t"

        "bsf
        "add
        : [res] "+r"(result), [vectmp] "=&x" (vectmp), [itmp] "=&r" (tmp1)
        : [zerovec] "x" (zero)  // There might already be a zeroed vector reg when inlining
            , [src] "r" (src)
            , [dummy] "m" (*(const char (*)[])src) // this reads the whole object, however
                                                   // long gcc thinks it is not needed
                                                   // because of the dummy input
    );

    return result - tmp1 - 1;
}

e i risultati diventano così (con -O0, e con -O2 il risultato della strlenAsm() non cambia visto che, essendo in assembly, viene compilata a parte e senza ottimizzazioni, come si può notare nelle linee successive):

usando gcc -O0

aldo@mylinux:~/blogtest$ gcc -c strlenasm.c -o strlenasm.o
aldo@mylinux:~/blogtest$ gcc -c -O0 strlens.c -o strlens.o
aldo@mylinux:~/blogtest$ gcc strlens.o strlenasm.o -o strlens
aldo@mylinux:~/blogtest$ ./strlens 
strlen(s)        = 999999999 - Tempo trascorso: 0.054087 secondi
strlenKaR(s)     = 999999999 - Tempo trascorso: 2.163937 secondi
strlenFreeBSD(s) = 999999999 - Tempo trascorso: 0.306909 secondi
strlenGlibc(s)   = 999999999 - Tempo trascorso: 0.251383 secondi
strlenMusl(s)    = 999999999 - Tempo trascorso: 0.305544 secondi
strlenAsm(s)     = 999999999 - Tempo trascorso: 0.061378 secondi

Ecco, il risultato canta: la versione implicita di gcc è scritta in assembly (e ripeto: non è affatto una scelta stranissima per piccole funzioni di libreria di uso frequente). Resta il mistero del perché compilando con -O2 (riguardare i risultati più sopra) la strlen() di default sembra non essere più la versione implicita, visto che le sue prestazioni peggiorano. Questo è un mistero che mi riprometto di risolvere prima o poi.

(…ma, visto che siamo ad Agosto, sicuramente durante le vacanze me ne dimenticherò, di solito mentre sono in spiaggia penso a ben altro…)

Ciao e al prossimo post!

A proposito di me

Aldo Abate

È un Cinefilo prestato alla Programmazione in C. Per mancanza di tempo ha rinunciato ad aprire un CineBlog (che richiede una attenzione quasi quotidiana), quindi ha deciso di dedicarsi a quello che gli riesce meglio con il poco tempo a disposizione: scrivere articoli sul suo amato C infarcendoli di citazioni Cinematografiche. Il risultato finale è ambiguo e spiazzante, ma lui conta sul fatto che il (si spera) buon contenuto tecnico induca gli appassionati di C a leggere ugualmente gli articoli ignorando la parte Cinefila.
Ma in realtà il suo obiettivo principale è che almeno un lettore su cento scopra il Cinema grazie al C...

Di Aldo Abate

Gli articoli più letti

Articoli recenti

Commenti recenti