Perché gli strlen di glibc devono essere così complicati per funzionare rapidamente?


286

Stavo esaminando il strlencodice qui e mi chiedevo se le ottimizzazioni utilizzate nel codice fossero davvero necessarie? Ad esempio, perché qualcosa come il seguente non dovrebbe funzionare altrettanto bene o meglio?

unsigned long strlen(char s[]) {
    unsigned long i;
    for (i = 0; s[i] != '\0'; i++)
        continue;
    return i;
}

Il codice più semplice non è migliore e / o più facile da ottimizzare per il compilatore?

Il codice strlennella pagina dietro il collegamento è simile al seguente:

/* Copyright (C) 1991, 1993, 1997, 2000, 2003 Free Software Foundation, Inc.
   This file is part of the GNU C Library.
   Written by Torbjorn Granlund (tege@sics.se),
   with help from Dan Sahlin (dan@sics.se);
   commentary by Jim Blandy (jimb@ai.mit.edu).

   The GNU C Library is free software; you can redistribute it and/or
   modify it under the terms of the GNU Lesser General Public
   License as published by the Free Software Foundation; either
   version 2.1 of the License, or (at your option) any later version.

   The GNU C Library is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
   Lesser General Public License for more details.

   You should have received a copy of the GNU Lesser General Public
   License along with the GNU C Library; if not, write to the Free
   Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
   02111-1307 USA.  */

#include <string.h>
#include <stdlib.h>

#undef strlen

/* Return the length of the null-terminated string STR.  Scan for
   the null terminator quickly by testing four bytes at a time.  */
size_t
strlen (str)
     const char *str;
{
  const char *char_ptr;
  const unsigned long int *longword_ptr;
  unsigned long int longword, magic_bits, himagic, lomagic;

  /* Handle the first few characters by reading one character at a time.
     Do this until CHAR_PTR is aligned on a longword boundary.  */
  for (char_ptr = str; ((unsigned long int) char_ptr
            & (sizeof (longword) - 1)) != 0;
       ++char_ptr)
    if (*char_ptr == '\0')
      return char_ptr - str;

  /* All these elucidatory comments refer to 4-byte longwords,
     but the theory applies equally well to 8-byte longwords.  */

  longword_ptr = (unsigned long int *) char_ptr;

  /* Bits 31, 24, 16, and 8 of this number are zero.  Call these bits
     the "holes."  Note that there is a hole just to the left of
     each byte, with an extra at the end:

     bits:  01111110 11111110 11111110 11111111
     bytes: AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD

     The 1-bits make sure that carries propagate to the next 0-bit.
     The 0-bits provide holes for carries to fall into.  */
  magic_bits = 0x7efefeffL;
  himagic = 0x80808080L;
  lomagic = 0x01010101L;
  if (sizeof (longword) > 4)
    {
      /* 64-bit version of the magic.  */
      /* Do the shift in two steps to avoid a warning if long has 32 bits.  */
      magic_bits = ((0x7efefefeL << 16) << 16) | 0xfefefeffL;
      himagic = ((himagic << 16) << 16) | himagic;
      lomagic = ((lomagic << 16) << 16) | lomagic;
    }
  if (sizeof (longword) > 8)
    abort ();

  /* Instead of the traditional loop which tests each character,
     we will test a longword at a time.  The tricky part is testing
     if *any of the four* bytes in the longword in question are zero.  */
  for (;;)
    {
      /* We tentatively exit the loop if adding MAGIC_BITS to
     LONGWORD fails to change any of the hole bits of LONGWORD.

     1) Is this safe?  Will it catch all the zero bytes?
     Suppose there is a byte with all zeros.  Any carry bits
     propagating from its left will fall into the hole at its
     least significant bit and stop.  Since there will be no
     carry from its most significant bit, the LSB of the
     byte to the left will be unchanged, and the zero will be
     detected.

     2) Is this worthwhile?  Will it ignore everything except
     zero bytes?  Suppose every byte of LONGWORD has a bit set
     somewhere.  There will be a carry into bit 8.  If bit 8
     is set, this will carry into bit 16.  If bit 8 is clear,
     one of bits 9-15 must be set, so there will be a carry
     into bit 16.  Similarly, there will be a carry into bit
     24.  If one of bits 24-30 is set, there will be a carry
     into bit 31, so all of the hole bits will be changed.

     The one misfire occurs when bits 24-30 are clear and bit
     31 is set; in this case, the hole at bit 31 is not
     changed.  If we had access to the processor carry flag,
     we could close this loophole by putting the fourth hole
     at bit 32!

     So it ignores everything except 128's, when they're aligned
     properly.  */

      longword = *longword_ptr++;

      if (
#if 0
      /* Add MAGIC_BITS to LONGWORD.  */
      (((longword + magic_bits)

        /* Set those bits that were unchanged by the addition.  */
        ^ ~longword)

       /* Look at only the hole bits.  If any of the hole bits
          are unchanged, most likely one of the bytes was a
          zero.  */
       & ~magic_bits)
#else
      ((longword - lomagic) & himagic)
#endif
      != 0)
    {
      /* Which of the bytes was the zero?  If none of them were, it was
         a misfire; continue the search.  */

      const char *cp = (const char *) (longword_ptr - 1);

      if (cp[0] == 0)
        return cp - str;
      if (cp[1] == 0)
        return cp - str + 1;
      if (cp[2] == 0)
        return cp - str + 2;
      if (cp[3] == 0)
        return cp - str + 3;
      if (sizeof (longword) > 4)
        {
          if (cp[4] == 0)
        return cp - str + 4;
          if (cp[5] == 0)
        return cp - str + 5;
          if (cp[6] == 0)
        return cp - str + 6;
          if (cp[7] == 0)
        return cp - str + 7;
        }
    }
    }
}
libc_hidden_builtin_def (strlen)

Perché questa versione viene eseguita rapidamente?

Non sta facendo molto lavoro inutile?


2
I commenti non sono per una discussione estesa; questa conversazione è stata spostata in chat .
Samuel Liew

18
Per riferimento futuro, il repository dei sorgenti ufficiale per GNU libc è su < sourceware.org/git/?p=glibc.git >. < sourceware.org/git/?p=glibc.git;a=blob;f=string/… > mostra effettivamente un codice simile al precedente; tuttavia, sysdepsverrà invece utilizzata un'implementazione del linguaggio assembly scritta a mano dalla directory, sulla maggior parte delle architetture supportate da glibc (l'architettura più comunemente usata che non ha una sostituzione è MIPS).
zwol,

9
Votare per chiudere questo come principalmente basato sull'opinione pubblica; "Sono davvero necessari xxx in xxx?" è soggettivo alle opinioni delle persone.
SS Anne,

2
@ JL2210: buon punto, risolto il titolo per catturare lo spirito della domanda in un titolo che non suona come se si chiedesse se sono necessarie prestazioni, proprio perché abbiamo bisogno di queste ottimizzazioni per ottenere prestazioni.
Peter Cordes,

9
@ JL2210 FWIW, il titolo originale era "Perché è così complesso in C [sic!]", E fu chiuso come "troppo ampio", quindi riaperto, quindi chiuso come "principalmente basato sull'opinione". Ho cercato di risolvere questo problema (entrando nel fuoco incrociato di "hai rotto la mia domanda!" E "voi ragazzi state abusando dei vostri poteri di modifica!" Nel frattempo), ma IMVHO il problema ha mentito (e si trova ancora) nella premessa di base della domanda, il che era problematico ("questo codice è troppo complesso per me capire" non è adatto per domande e risposte - IMO è una richiesta per tutoraggio, non per una risposta). Non lo tocco di nuovo con un palo da 60 piedi :)

Risposte:


233

Non è necessario e non dovresti mai scrivere codice del genere, soprattutto se non sei un compilatore C / fornitore di librerie standard. È il codice utilizzato per implementare strlencon alcuni hack e ipotesi sulla velocità molto discutibili (che non sono testati con asserzioni o menzionati nei commenti):

  • unsigned long è di 4 o 8 byte
  • i byte sono 8 bit
  • un puntatore può essere lanciato unsigned long longe nonuintptr_t
  • si può allineare il puntatore semplicemente controllando che i 2 o 3 bit di ordine più basso siano zero
  • si può accedere a una stringa come unsigned longs
  • si può leggere oltre la fine dell'array senza effetti negativi.

Inoltre, un buon compilatore potrebbe persino sostituire il codice scritto come

size_t stupid_strlen(const char s[]) {
    size_t i;
    for (i=0; s[i] != '\0'; i++)
        ;
    return i;
}

(notare che deve essere un tipo compatibile con size_t) con una versione incorporata del compilatore incorporato strlen, o vettorializzare il codice; ma è improbabile che un compilatore sia in grado di ottimizzare la versione complessa.


La strlenfunzione è descritta da C11 7.24.6.3 come:

Descrizione

  1. La strlenfunzione calcola la lunghezza della stringa indicata da s.

ritorna

  1. La strlenfunzione restituisce il numero di caratteri che precedono il carattere null terminante.

Ora, se la stringa puntata da sera in una matrice di caratteri abbastanza lunga da contenere la stringa e il NUL di terminazione, il comportamento sarà indefinito se accediamo alla stringa oltre il terminatore null, ad esempio in

char *str = "hello world";  // or
char array[] = "hello world";

Quindi davvero l' unico modo in C completamente portatile / conforme agli standard per implementarlo correttamente è il modo in cui è scritto nella tua domanda , ad eccezione di trasformazioni banali - puoi far finta di essere più veloce svolgendo il ciclo ecc., Ma deve ancora essere fatto un byte alla volta.

(Come hanno sottolineato i commentatori, quando la portabilità rigorosa è troppo onerosa, trarre vantaggio da ipotesi ragionevoli o note per la sicurezza non è sempre una cosa negativa. Soprattutto nel codice che fa parte di un'implementazione specifica C. Ma devi capire regole prima di sapere come / quando puoi piegarle.)


L' strlenimplementazione collegata controlla innanzitutto i byte singolarmente fino a quando il puntatore non punta al limite di allineamento naturale di 4 o 8 byte di unsigned long. Lo standard C afferma che l'accesso a un puntatore che non è correttamente allineato ha un comportamento indefinito , quindi questo deve assolutamente essere fatto affinché il prossimo trucco sporco sia ancora più sporco. (In pratica su alcune architetture della CPU diverse da x86, una parola disallineata o un carico di doppia parola si guasteranno. C non è un linguaggio assembly portatile, ma questo codice lo utilizza in questo modo). È anche ciò che rende possibile leggere oltre la fine di un oggetto senza il rischio di errori nelle implementazioni in cui la protezione della memoria funziona in blocchi allineati (ad esempio pagine di memoria virtuale da 4 kB).

Ora viene la parte sporca: il codice si rompe la promessa e legge 4 o 8 8-bit byte alla volta (una long int), e usa un po 'trucco con oltre unsigned per capire rapidamente se ci fossero eventuali zero byte all'interno di quei 4 o 8 byte: utilizza un numero appositamente predisposto per far sì che il bit di trasporto cambi bit catturati da una maschera di bit. In sostanza, ciò determinerebbe quindi se uno qualsiasi dei 4 o 8 byte nella maschera siano zero presumibilmente più veloci di quanto non farebbe un ciclo attraverso ciascuno di questi byte. Alla fine c'è un loop alla fine per capire quale byte era il primo zero, se presente, e per restituire il risultato.

Il problema più grande è che in sizeof (unsigned long) - 1alcuni sizeof (unsigned long)casi fuori dai casi leggerà oltre la fine della stringa - solo se il byte null è nell'ultimo byte a cui si accede (ovvero in little-endian il più significativo, e in big-endian il meno significativo) , non accede alla matrice senza limiti!


Il codice, anche se utilizzato per implementare strlenin una libreria standard C è un codice errato . Ha diversi aspetti definiti dall'implementazione e non definiti e non dovrebbe essere usato da nessuna parte invece che dal sistema fornito strlen- Ho rinominato la funzione the_strlenqui e ho aggiunto quanto segue main:

int main(void) {
    char buf[12];
    printf("%zu\n", the_strlen(fgets(buf, 12, stdin)));
}

Il buffer è dimensionato con cura in modo da contenere esattamente la hello worldstringa e il terminatore. Tuttavia sul mio processore a 64 bit unsigned longsono 8 byte, quindi l'accesso a quest'ultima parte supererebbe questo buffer.

Se ora compilo con -fsanitize=undefinede -fsanitize=addressed eseguire il programma risultante, ottengo:

% ./a.out
hello world
=================================================================
==8355==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffffe63a3f8 at pc 0x55fbec46ab6c bp 0x7ffffe63a350 sp 0x7ffffe63a340
READ of size 8 at 0x7ffffe63a3f8 thread T0
    #0 0x55fbec46ab6b in the_strlen (.../a.out+0x1b6b)
    #1 0x55fbec46b139 in main (.../a.out+0x2139)
    #2 0x7f4f0848fb96 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
    #3 0x55fbec46a949 in _start (.../a.out+0x1949)

Address 0x7ffffe63a3f8 is located in stack of thread T0 at offset 40 in frame
    #0 0x55fbec46b07c in main (.../a.out+0x207c)

  This frame has 1 object(s):
    [32, 44) 'buf' <== Memory access at offset 40 partially overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism or swapcontext
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (.../a.out+0x1b6b) in the_strlen
Shadow bytes around the buggy address:
  0x10007fcbf420: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf440: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf450: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10007fcbf470: 00 00 00 00 00 00 00 00 00 00 f1 f1 f1 f1 00[04]
  0x10007fcbf480: f2 f2 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf490: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==8355==ABORTING

cioè sono successe cose brutte.


120
Ri: "hack e ipotesi sulla velocità molto discutibili", cioè molto discutibili nel codice portatile . La libreria standard è scritta per una particolare combinazione compilatore / hardware, con la conoscenza del comportamento reale delle cose che la definizione del linguaggio lascia come indefinita. Sì, la maggior parte delle persone non dovrebbe scrivere codice in questo modo, ma nel contesto dell'implementazione della libreria standard non portatile non è intrinsecamente male.
Pete Becker,

4
D'accordo, non scrivere mai cose del genere da soli. O quasi mai. L'ottimizzazione prematura è la fonte di tutti i mali. (In questo caso potrebbe in realtà essere motivato). Se finisci per fare molte chiamate strlen () sulla stessa stringa molto lunga, la tua applicazione potrebbe forse essere scritta in modo diverso. Come esempio, devi salvare la lunghezza della stringa in una variabile già quando viene creata la stringa e non è necessario chiamare strlen ().
ghellquist,

65
@ghellquist: L'ottimizzazione di una chiamata in libreria di uso frequente non è certo "ottimizzazione prematura".
jamesqf,

7
@Antti Haapala: Esattamente perché pensi che strlen dovrebbe essere O (1)? E ciò che abbiamo qui sono diverse implementazioni, tutte O (n), ma con moltiplicatori costanti diversi. Potresti non pensare che sia importante, ma per alcuni di noi un'implementazione di un algoritmo O (n) che fa il suo lavoro in microsecondi è molto meglio di uno che richiede secondi, o addirittura millisecondi, perché potrebbe essere chiamato diversi miliardi di volte nel corso di lavoro.
jamesqf,

8
@PeteBecker: non solo, nel contesto delle librerie standard (non tanto in questo caso) la scrittura di codice non portabile può essere la norma poiché lo scopo di una libreria standard è fornire un'interfaccia standard a elementi specifici dell'implementazione.
PlasmaHH,

148

Ci sono state molte ipotesi (leggermente o del tutto) sbagliate nei commenti su alcuni dettagli / background per questo.

Stai guardando l'implementazione ottimizzata del fallback C di glibc. (Per gli ISA che non hanno un'implementazione asm scritta a mano) . O una vecchia versione di quel codice, che è ancora nell'albero dei sorgenti di glibc. https://code.woboq.org/userspace/glibc/string/strlen.c.html è un browser di codice basato sull'attuale albero glibc git. Apparentemente è ancora usato da alcuni target glibc tradizionali, incluso MIPS. (Grazie @zwol).

Su ISA popolari come x86 e ARM, glibc usa l'asm scritto a mano

Quindi l'incentivo a cambiare qualcosa su questo codice è inferiore a quanto si pensi.

Questo codice bithack ( https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord ) non è ciò che funziona effettivamente sul tuo server / desktop / laptop / smartphone. È meglio di un loop ingenuo byte alla volta, ma anche questo bithack è piuttosto male rispetto all'asm efficiente per le CPU moderne (in particolare x86 in cui AVX2 SIMD consente di controllare 32 byte con un paio di istruzioni, consentendo da 32 a 64 byte per orologio ciclo nel ciclo principale se i dati sono caldi nella cache L1d su CPU moderne con carico vettoriale 2 / clock e throughput ALU, ovvero per stringhe di medie dimensioni in cui l'overhead di avvio non domina.)

glibc usa trucchi di collegamento dinamico per risolvere strlenuna versione ottimale per la tua CPU, quindi anche in x86 c'è una versione SSE2 (vettori a 16 byte, linea di base per x86-64) e una versione AVX2 (vettori a 32 byte).

x86 ha un efficiente trasferimento di dati tra registri vettoriali e per scopi generici, il che lo rende in modo univoco (?) utile per l'utilizzo di SIMD per velocizzare le funzioni su stringhe a lunghezza implicita in cui il controllo del loop dipende dai dati. pcmpeqb/ pmovmskbrende possibile testare 16 byte separati alla volta.

glibc ha una versione di AArch64 come quella che utilizza AdvSIMD e una versione per CPU AArch64 in cui i registri vector-> GP bloccano la pipeline, quindi utilizza effettivamente questo bithack . Ma utilizza zero-conteggio iniziale per trovare il byte all'interno del registro una volta ottenuto un hit e sfrutta gli efficienti accessi non allineati di AArch64 dopo aver verificato l'attraversamento della pagina.

Anche correlato: perché questo codice è 6,5 volte più lento con le ottimizzazioni abilitate? ha qualche dettaglio in più su cosa è veloce o lento in x86 asm strlencon un buffer di grandi dimensioni e una semplice implementazione asm che potrebbe essere utile per gcc per sapere come inline. (Alcune versioni di gcc sono inconsapevolmente in linea, il rep scasbche è molto lento, o un bithack a 4 byte alla volta come questo. Quindi la ricetta inline strlen di GCC deve essere aggiornata o disabilitata.)

Asm non ha "comportamenti indefiniti" in stile C ; è sicuro accedere ai byte nella memoria come preferisci e un carico allineato che include tutti i byte validi non può essere difettoso. La protezione della memoria si verifica con granularità della pagina allineata; gli accessi allineati sono più stretti di così non possono oltrepassare un limite di pagina. È sicuro leggere oltre la fine di un buffer all'interno della stessa pagina su x86 e x64? Lo stesso ragionamento si applica al codice macchina che questo hack C consente ai compilatori di creare per un'implementazione indipendente non in linea di questa funzione.

Quando un compilatore emette un codice per chiamare una funzione non inline sconosciuta, deve presumere che la funzione modifichi qualsiasi / tutte le variabili globali e qualsiasi memoria a cui potrebbe avere un puntatore. cioè tutto tranne i locali che non hanno avuto il loro indirizzo di escape devono essere sincronizzati in memoria durante la chiamata. Questo vale per le funzioni scritte in asm, ovviamente, ma anche per le funzioni di libreria. Se non si abilita l'ottimizzazione del tempo di collegamento, si applica anche a unità di traduzione separate (file di origine).


Perché questo è sicuro come parte di glibc ma non altrimenti.

Il fattore più importante è che questo strlennon può essere integrato in nient'altro. Non è sicuro per quello; contiene UB strettamente aliasing (lettura dei chardati tramite un unsigned long*). char*è consentito alias qualsiasi altra cosa, ma non è vero il contrario .

Questa è una funzione di libreria per una libreria compilata in anticipo (glibc). Non verrà integrato con l'ottimizzazione del tempo di collegamento nei chiamanti. Ciò significa che deve solo compilare un codice macchina sicuro per una versione autonoma di strlen. Non deve essere portatile / sicuro C.

La libreria GNU C deve solo compilare con GCC. Apparentemente non è supportato per compilarlo con clang o ICC, anche se supportano le estensioni GNU. GCC è un compilatore in anticipo che trasforma un file sorgente C in un file oggetto di codice macchina. Non un interprete, quindi a meno che non sia in linea in fase di compilazione, i byte in memoria sono solo byte in memoria. cioè UB con alias rigoroso non è pericoloso quando gli accessi con tipi diversi avvengono in funzioni diverse che non si allineano l'una con l'altra.

Ricorda che strlenil comportamento è definito dallo standard ISO C. Il nome di tale funzione è specificamente parte dell'implementazione. Compilatori come GCC trattano persino il nome come una funzione integrata a meno che non lo si usi -fno-builtin-strlen, quindi strlen("foo")può essere una costante di tempo di compilazione 3. La definizione nella libreria viene usata solo quando gcc decide di emettere effettivamente una chiamata invece di incorporare la propria ricetta o qualcosa del genere.

Quando UB non è visibile al compilatore al momento della compilazione, si ottiene un codice macchina sano di mente. Il codice macchina deve funzionare per il caso no-UB e, anche se lo volessi , non c'è modo per l'asm di rilevare quali tipi ha usato il chiamante per mettere i dati nella memoria puntata.

Glibc è compilato in una libreria statica o dinamica indipendente che non può essere integrata con l'ottimizzazione del tempo di collegamento. Gli script di compilazione di glibc non creano librerie statiche "fat" contenenti codice macchina + gcc GIMPLE rappresentazione interna per l'ottimizzazione del tempo di collegamento durante l'inserimento in un programma. (ovvero libc.anon parteciperà -fltoall'ottimizzazione del tempo di collegamento nel programma principale). Costruire glibc in questo modo sarebbe potenzialmente pericoloso per gli obiettivi che lo utilizzano effettivamente.c .

In effetti, come commenta @zwol, LTO non può essere usato quando si costruisce glibc stesso , a causa di un codice "fragile" come questo che potrebbe rompersi se fosse possibile allineare tra i file sorgente glibc. (Ci sono alcuni usi interni di strlen, ad esempio forse come parte printfdell'implementazione)


Questo strlenfa alcune ipotesi:

  • CHAR_BITè un multiplo di 8 . Vero su tutti i sistemi GNU. POSIX 2001 garantisce persino CHAR_BIT == 8. (Questo sembra sicuro per i sistemi con CHAR_BIT= 16o 32, come alcuni DSP; il ciclo prologo non allineato eseguirà sempre 0 iterazioni se sizeof(long) = sizeof(char) = 1ogni puntatore è sempre allineato ed p & sizeof(long)-1è sempre zero.) Ma se hai un set di caratteri non ASCII dove i caratteri sono 9 o largo 12 bit, 0x8080...è il modello sbagliato.
  • (forse) unsigned longè 4 o 8 byte. O forse funzionerebbe effettivamente per qualsiasi dimensione unsigned longfino a 8, e usa un assert()per verificarlo.

Quei due non sono possibili UB, sono solo non portabilità ad alcune implementazioni C. Questo codice fa parte (o faceva parte ) dell'implementazione C su piattaforme in cui funziona, quindi va bene.

Il prossimo presupposto è il potenziale UB:

  • Un carico allineato che contiene byte validi non può essere difettoso ed è sicuro finché si ignorano i byte all'esterno dell'oggetto che si desidera effettivamente. (Vero in asm su tutti i sistemi GNU e su tutte le CPU normali perché la protezione della memoria avviene con granularità di pagine allineate. È sicuro leggere oltre la fine di un buffer all'interno della stessa pagina su x86 e x64? Sicuro in C quando l'UB non è visibile al momento della compilazione. Senza inline, questo è il caso qui. Il compilatore non può provare che leggere oltre il primo 0è UB; potrebbe essere un char[]array C contenente {1,2,0,3}ad esempio)

Quest'ultimo punto è ciò che rende sicuro leggere qui oltre la fine di un oggetto C. Questo è praticamente sicuro anche quando ci si allinea con i compilatori attuali perché penso che al momento non trattino che implicare un percorso di esecuzione sia irraggiungibile. Ma comunque, il rigoroso aliasing è già uno showtopper se mai lo lasci in linea.

Quindi avresti problemi come la vecchia memcpy macro CPP non sicura del kernel Linux che utilizzava il puntatore-casting unsigned long( gcc, aliasing rigoroso e storie horror ).

Questo strlenrisale all'epoca in cui si poteva cavarsela con cose del genere in generale ; era abbastanza sicuro senza il "solo quando non inline" prima di GCC3.


L'UB che è visibile solo guardando oltre i confini di chiamata / retata non può farci del male. (es. chiamando questo su a char buf[]invece che su un array di unsigned long[]cast a a const char*). Una volta che il codice macchina è impostato su pietra, si tratta solo di byte in memoria. Una chiamata di funzione non in linea deve presumere che il chiamato legge qualsiasi / tutta la memoria.


Scrivere questo in modo sicuro, senza UB alias rigoroso

L' attributo GCC typemay_alias fornisce a un tipo lo stesso alias-qualunque trattamento di char*. (Suggerito da @KonradBorowsk). Le intestazioni GCC attualmente lo usano per tipi vettoriali SIMD x86 come in questo __m128imodo puoi sempre farlo in sicurezza _mm_loadu_si128( (__m128i*)foo ). (Vedi `reinterpret_cast`ing tra il puntatore vettoriale hardware e il tipo corrispondente è un comportamento indefinito? Per maggiori dettagli su cosa questo significhi e non significhi.)

strlen(const char *char_ptr)
{
  typedef unsigned long __attribute__((may_alias)) aliasing_ulong;

  aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;
  for (;;) {
     unsigned long ulong = *longword_ptr++;  // can safely alias anything
     ...
  }
}

Puoi anche usare aligned(1)per esprimere un tipo con alignof(T) = 1.
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;

È un modo portatile per esprimere un carico di aliasing in ISOmemcpy , che i compilatori moderni sanno come incorporare come una singola istruzione di carico. per esempio

   unsigned long longword;
   memcpy(&longword, char_ptr, sizeof(longword));
   char_ptr += sizeof(longword);

Questo funziona anche per carichi non allineati perché memcpyfunziona come se fosse charun accesso immediato. Ma in pratica i compilatori moderni comprendono memcpymolto bene.

Il pericolo qui è che se GCC non sa per certo che char_ptrè allineato a parole, non lo incorporerà su alcune piattaforme che potrebbero non supportare carichi non allineati in asm. ad es. MIPS prima di MIPS64r6 o ARM precedente. Se hai una vera chiamata di funzione memcpysolo per caricare una parola (e lasciarla in un'altra memoria), sarebbe un disastro. A volte GCC può vedere quando il codice allinea un puntatore. O dopo il ciclo char-at-a-time che raggiunge un limite ulong che potresti usare
p = __builtin_assume_aligned(p, sizeof(unsigned long));

Questo non evita il possibile UB read-past-the-object, ma con l'attuale GCC non è pericoloso nella pratica.


Perché è necessaria una sorgente C ottimizzata a mano: i compilatori attuali non sono abbastanza buoni

L'asm ottimizzato a mano può essere ancora migliore quando si desidera l'ultimo calo delle prestazioni per una funzione di libreria standard ampiamente utilizzata. Soprattutto per qualcosa di simile memcpy, ma anche strlen. In questo caso non sarebbe molto più semplice usare C con intrinseci x86 per sfruttare SSE2.

Ma qui stiamo solo parlando di una versione ingenua vs. bithack C senza funzionalità specifiche ISA.

(Penso che possiamo prenderlo come un dato che strlenè ampiamente usato che è importante farlo funzionare il più velocemente possibile. Quindi la domanda diventa se possiamo ottenere un codice macchina efficiente da una fonte più semplice. No, non possiamo.)

Il GCC e il clang corrente non sono in grado di auto-vettorizzare i loop in cui il conteggio delle iterazioni non è noto prima della prima iterazione . (ad esempio, deve essere possibile verificare se il ciclo eseguirà almeno 16 iterazioni prima di eseguire la prima iterazione.) Ad esempio, è possibile memcpy autovectorizing (buffer di lunghezza esplicita) ma non strcpy o strlen (stringa di lunghezza implicita), data corrente compilatori.

Ciò include i loop di ricerca o qualsiasi altro loop con if()breakun contatore dipendente dai dati .

ICC (il compilatore di Intel per x86) può auto-vettorializzare alcuni cicli di ricerca, ma fa comunque solo ingenui byte per volta per una C semplice / ingenua strlencome usa la libc di OpenBSD. ( Godbolt ). (Dalla risposta di @ Peske ).

Una libc ottimizzata a mano strlenè necessaria per le prestazioni con i compilatori correnti . Andare 1 byte alla volta (con lo srotolamento di forse 2 byte per ciclo su CPU superscalari estese) è patetico quando la memoria principale può tenere il passo con circa 8 byte per ciclo e la cache L1d può fornire da 16 a 64 per ciclo. (2x carichi a 32 byte per ciclo su moderne CPU x86 tradizionali da Haswell e Ryzen. Senza contare AVX512 che può ridurre la velocità di clock solo per l'utilizzo di vettori a 512 bit; motivo per cui glibc probabilmente non ha fretta di aggiungere una versione AVX512 . Sebbene con vettori a 256 bit, AVX512VL + BW Comparazione mascheramento in una maschera e ktesto kortestpotrebbe rendere strlenpiù amichevole HyperThreading riducendone i UOP / iterazione.)

Sto includendo non-x86 qui, questo è il "16 byte". ad esempio, la maggior parte delle CPU AArch64 può fare almeno questo, credo, e sicuramente qualcosa di più. E alcuni hanno una velocità di esecuzione sufficiente per strlentenere il passo con quella larghezza di banda di carico.

Naturalmente i programmi che funzionano con stringhe di grandi dimensioni dovrebbero in genere tenere traccia delle lunghezze per evitare di dover ripetere la ricerca della lunghezza delle stringhe C di lunghezza implicita molto spesso. Ma le prestazioni di breve o media durata beneficiano ancora di implementazioni scritte a mano, e sono sicuro che alcuni programmi finiscono per usare stringhe su stringhe di media lunghezza.


12
Alcune note: (1) Al momento non è possibile compilare glibc stesso con alcun compilatore diverso da GCC. (2) Al momento non è possibile compilare glibc stesso con le ottimizzazioni del tempo di collegamento abilitate, a causa proprio di questo tipo di casi, in cui il compilatore vedrà UB se è consentito che si verifichi l'inline. (3) CHAR_BIT == 8è un requisito POSIX (a partire dal -2001 rev; vedere qui ). (4) L'implementazione di fallback in C strlenviene utilizzata per alcune CPU supportate, credo che la più comune sia MIPS.
zwol,

1
È interessante notare che l'UB aliasing rigoroso potrebbe essere risolto utilizzando l' __attribute__((__may_alias__))attributo (questo non è portatile, ma dovrebbe andare bene per glibc).
Konrad Borowski,

1
@SebastianRedl: puoi leggere / scrivere qualsiasi oggetto tramite a char*, ma è comunque UB leggere / scrivere un char oggetto (ad esempio parte di a char[]) attraverso a long*. Regola aliasing rigorosa e puntatori 'char *'
Peter Cordes,

1
Gli standard C e C ++ affermano che CHAR_BITdevono essere almeno 8 ( qv allegato E di C11), quindi almeno 7 bit charnon è qualcosa di cui un avvocato linguista deve preoccuparsi. Ciò è stato motivato dal requisito "Per i valori letterali di stringa UTF − 8, gli elementi dell'array hanno tipo chare vengono inizializzati con i caratteri della sequenza di caratteri multibyte, codificati in UTF − 8".
Davislor,

2
Sembra che questa analisi sia una buona base per proporre una patch che renda il codice più solido di fronte alle ottimizzazioni attualmente disabilitate, oltre a dare una risposta fantastica.
Deduplicatore,

61

È spiegato nei commenti nel file che hai collegato:

 27 /* Return the length of the null-terminated string STR.  Scan for
 28    the null terminator quickly by testing four bytes at a time.  */

e:

 73   /* Instead of the traditional loop which tests each character,
 74      we will test a longword at a time.  The tricky part is testing
 75      if *any of the four* bytes in the longword in question are zero.  */

In C, è possibile ragionare in dettaglio sull'efficienza.

È meno efficiente scorrere i singoli caratteri alla ricerca di un valore nullo piuttosto che testare più di un byte alla volta, come fa questo codice.

La complessità aggiuntiva deriva dalla necessità di garantire che la stringa sotto test sia allineata nel posto giusto per iniziare a testare più di un byte alla volta (lungo un limite di parole lunghe, come descritto nei commenti), e dalla necessità di garantire che i presupposti circa le dimensioni dei tipi di dati non vengono violati quando si utilizza il codice.

Nella maggior parte (ma non in tutti) i moderni software di sviluppo, questa attenzione ai dettagli dell'efficienza non è necessaria o non vale il costo della complessità del codice aggiuntivo.

Un posto in cui ha senso prestare attenzione a questo tipo di efficienza è nelle librerie standard, come nell'esempio che hai collegato.


Se vuoi leggere di più sui confini delle parole, vedi questa domanda e questa eccellente pagina di Wikipedia


39

Oltre alle ottime risposte qui, voglio sottolineare che il codice collegato alla domanda è per l'implementazione di GNU strlen.

L' implementazione di OpenBSD distrlen è molto simile al codice proposto nella domanda. La complessità di un'implementazione è determinata dall'autore.

...
#include <string.h>

size_t
strlen(const char *str)
{
    const char *s;

    for (s = str; *s; ++s)
        ;
    return (s - str);
}

DEF_STRONG(strlen);

EDIT : Il codice OpenBSD che ho linkato sopra sembra essere un'implementazione di fallback per gli ISA che non hanno una propria implementazione asm. Esistono implementazioni diverse a strlenseconda dell'architettura. Il codice per amd64strlen , ad esempio, è asm. Simile ai commenti / risposte di PeterCordes che sottolineano che anche le implementazioni GNU non di fallback sono asm.


5
Questo rende una bella illustrazione dei diversi valori ottimizzati negli strumenti OpenBSD vs GNU.
Jason,

11
È l' implementazione di fallback portatile di glibc . Tutti i principali ISA hanno implementazioni asm scritte a mano in glibc, usando SIMD quando aiuta (ad esempio su x86). Vedi code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/… e code.woboq.org/userspace/glibc/sysdeps/aarch64/multiarch/…
Peter Cordes,

4
Anche la versione OpenBSD ha un difetto che l'originale evita! Il comportamento di s - strnon è definito se il risultato non è rappresentabile in ptrdiff_t.
Antti Haapala,

1
@AnttiHaapala: in GNU C, la dimensione massima dell'oggetto è PTRDIFF_MAX. Ma è ancora possibile avere mmappiù memoria di quella almeno su Linux (ad es. In un processo a 32 bit con un kernel x86-64 potrei eseguire il mmap di circa 2,7 GB contigui prima di iniziare a ricevere errori). IDK su OpenBSD; il kernel potrebbe rendere impossibile raggiungerlo returnsenza segfault o fermarsi all'interno della dimensione. Ma sì, penseresti che la codifica difensiva che evita il C UB teorico sarebbe qualcosa che OpenBSD vorrebbe fare. Anche se strlennon possono essere in linea e veri compilatori lo compileranno solo in un sottrazione.
Peter Cordes,

2
@PeterCordes esattamente. Stessa cosa in OpenBSD, ad es. Assembly i386: cvsweb.openbsd.org/cgi-bin/cvsweb/src/lib/libc/arch/i386/string/…
dchest

34

In breve, questa è un'ottimizzazione delle prestazioni che la libreria standard può fare sapendo con quale compilatore è compilata - non dovresti scrivere codice come questo, a meno che tu non stia scrivendo una libreria standard e possa dipendere da un compilatore specifico. In particolare, sta elaborando il numero di byte di allineamento contemporaneamente: 4 su piattaforme a 32 bit, 8 su piattaforme a 64 bit. Ciò significa che può essere 4 o 8 volte più veloce dell'iterazione di byte naïf.

Per spiegare come funziona, considera la seguente immagine. Supponiamo qui la piattaforma a 32 bit (allineamento di 4 byte).

Diciamo che la lettera "H" di "Ciao, mondo!" stringa è stata fornita come argomento per strlen. Poiché alla CPU piace avere le cose allineate in memoria (idealmente, address % sizeof(size_t) == 0), i byte prima dell'allineamento vengono elaborati byte per byte, usando il metodo lento.

Quindi, per ogni blocco di dimensioni di allineamento, calcolandolo (longbits - 0x01010101) & 0x80808080 != 0controlla se uno dei byte all'interno di un numero intero è zero. Questo calcolo ha un falso positivo quando almeno uno dei byte è maggiore di 0x80, ma il più delle volte dovrebbe funzionare. In caso contrario (poiché si trova nell'area gialla), la lunghezza viene aumentata della dimensione dell'allineamento.

Se uno qualsiasi dei byte all'interno di un numero intero risulta essere zero (o 0x81), la stringa viene controllata byte per byte per determinare la posizione di zero.

Questo può rendere un accesso fuori limite, tuttavia poiché è all'interno di un allineamento, è più probabile che non vada bene, le unità di mappatura della memoria di solito non hanno una precisione a livello di byte.


Questa implementazione fa parte di glibc. Il sistema GNU protegge la memoria con granularità della pagina. Quindi sì, un carico allineato che include tutti i byte validi è sicuro.
Peter Cordes,

size_tnon è garantito che sia allineato.
SS Anne,

32

Volete che il codice sia corretto, gestibile e veloce. Questi fattori hanno un'importanza diversa:

"corretto" è assolutamente essenziale.

"maintainable" dipende da quanto si intende mantenere il codice: strlen è una funzione di libreria C standard da oltre 40 anni. Non cambierà. La manutenibilità è quindi poco importante - per questa funzione.

"Veloce": in molte applicazioni, strcpy, strlen ecc. Utilizzano una notevole quantità di tempo di esecuzione. Per ottenere lo stesso guadagno di velocità complessivo di questa implementazione complicata, ma non molto complicata, del miglioramento del compilatore richiederebbe sforzi eroici.

Essere veloci ha un altro vantaggio: quando i programmatori scoprono che chiamare "strlen" è il metodo più veloce che possono misurare il numero di byte in una stringa, non sono più tentati di scrivere il proprio codice per rendere le cose più veloci.

Quindi per strlen, la velocità è molto più importante e la manutenibilità molto meno importante rispetto alla maggior parte del codice che scriverete mai.

Perché deve essere così complicato? Supponi di avere una stringa di 1.000 byte. L'implementazione semplice esaminerà 1.000 byte. Un'implementazione corrente probabilmente esaminerebbe le parole a 64 bit alla volta, il che significa 125 parole a 64 bit o otto byte. Potrebbe anche usare le istruzioni vettoriali che esaminano diciamo 32 byte alla volta, il che sarebbe ancora più complicato e persino più veloce. L'uso delle istruzioni vettoriali porta a un codice un po 'più complicato ma piuttosto semplice, controllare se uno degli otto byte in una parola a 64 bit è zero richiede alcuni trucchi intelligenti. Quindi, per stringhe medio-lunghe, ci si può aspettare che questo codice sia circa quattro volte più veloce. Per una funzione importante come strlen, vale la pena scrivere una funzione più complessa.

PS. Il codice non è molto portatile. Ma fa parte della libreria Standard C, che fa parte dell'implementazione: non è necessario che sia portatile.

PPS. Qualcuno ha pubblicato un esempio in cui uno strumento di debug si è lamentato dell'accesso ai byte oltre la fine di una stringa. È possibile progettare un'implementazione che garantisca quanto segue: Se p è un puntatore valido a un byte, qualsiasi accesso a un byte nello stesso blocco allineato che sarebbe un comportamento indefinito secondo lo standard C, restituirà un valore non specificato.

PPPS. Intel ha aggiunto istruzioni ai processori successivi che formano un blocco predefinito per la funzione strstr () (trovare una sottostringa in una stringa). La loro descrizione è sbalorditiva, ma possono rendere quella particolare funzione probabilmente 100 volte più veloce. (Fondamentalmente, dato un array a contenente "Hello, world!" E un array b che inizia con 16 byte "HelloHelloHelloH" e contiene più byte, si scopre che la stringa a non si verifica in b prima di iniziare con l'indice 15) .


Oppure ... Se sto scoprendo che sto facendo molta elaborazione basata su stringhe e c'è un collo di bottiglia, probabilmente implementerò la mia versione di Pascal Strings invece di migliorare strlen ...
Baldrickk,

1
Nessuno ti chiede di migliorare strlen. Ma renderlo abbastanza buono evita sciocchezze come le persone che implementano le proprie stringhe.
gnasher729,


24

In breve: il controllo di una stringa byte per byte sarà potenzialmente lento su architetture in grado di recuperare grandi quantità di dati alla volta.

Se il controllo per la terminazione nulla può essere eseguito su una base a 32 o 64 bit, riduce la quantità di controlli che il compilatore deve eseguire. Questo è ciò che il codice collegato tenta di fare, tenendo presente un sistema specifico. Fanno ipotesi su indirizzamento, allineamento, uso della cache, impostazioni del compilatore non standard ecc. Ecc.

Leggere byte per byte come nell'esempio sarebbe un approccio sensato su una CPU a 8 bit o quando si scrive una lib portatile scritta nello standard C.

Guardare le librerie C standard per consigliare come scrivere codice veloce / buono non è una buona idea, perché sarà non portatile e si baserà su ipotesi non standard o comportamenti scarsamente definiti. Se sei un principiante, la lettura di tale codice sarà probabilmente più dannosa dell'istruzione.


1
Naturalmente è molto probabile che l'ottimizzatore srotoli o vettorizzi automaticamente questo loop e il pre-fetcher può rilevare banalmente questo schema di accesso. Se questi trucchi contano davvero sui processori moderni dovrebbe essere testato. Se c'è una vittoria da ottenere, probabilmente sta usando le istruzioni vettoriali.
vescovo russo,

6
@russbishop: Lo speri, ma no. GCC e clang sono completamente incapaci di loop auto-vettorializzanti in cui il conteggio delle iterazioni non è noto prima della prima iterazione. Ciò include i cicli di ricerca o qualsiasi altro ciclo con un dipendente dai dati if()break. L'ICC può auto-vettorializzare tali loop, ma IDK riesce bene con un ingenuo sforzo. E sì, SSE2 pcmpeqb/ pmovmskbè molto buono per strlen, testando 16 byte alla volta. code.woboq.org/userspace/glibc/sysdeps/x86_64/strlen.S.html è la versione SSE2 di glibc. Vedi anche queste domande e risposte .
Peter Cordes,

Questo è sfortunato. Di solito sono molto anti-UB ma, come fai notare, le stringhe C richiedono la lettura end-of-buffer tecnicamente UB per consentire anche la vettorializzazione. Penso che lo stesso valga per ARM64 poiché richiede l'allineamento.
vescovo russo,

-6

Una cosa importante non menzionata dalle altre risposte è che la FSF è molto cauta nel garantire che il codice proprietario non lo trasformi in progetti GNU. Negli standard di codifica GNU in Riferirsi a programmi proprietari , c'è un avvertimento sull'organizzazione dell'implementazione in modo che non possa essere confuso con il codice proprietario esistente:

Non fare in alcun caso riferimento al codice sorgente Unix per o durante il tuo lavoro su GNU! (O a qualsiasi altro programma proprietario.)

Se hai un vago ricordo degli interni di un programma Unix, questo non significa assolutamente che non puoi scriverne un'imitazione, ma cerca di organizzare l'imitazione internamente secondo linee diverse, perché è probabile che questo renda i dettagli di la versione Unix irrilevante e diversa dai tuoi risultati.

Ad esempio, le utility Unix sono state generalmente ottimizzate per ridurre al minimo l'uso della memoria; se invece vai per la velocità , il tuo programma sarà molto diverso.

(Enfasi mia.)


5
Come risponde alla domanda?
SS Anne,

1
La domanda in OP era "questo codice più semplice non funzionerebbe meglio?", E questa è una domanda che non è sempre decisa in merito tecnico. Per un progetto come GNU, evitare le insidie ​​legali è una parte importante del codice "funziona meglio", e le implementazioni "ovvie" strlen()probabilmente usciranno simili o identiche al codice esistente. Qualcosa di "folle" come l'implementazione di glibc non può essere fatto risalire a questo. Considerando quanta controversia legale c'erano oltre rangeCheck- 11 righe di codice! - nella lotta Google / Oracle, direi che la preoccupazione della FSF era ben posizionata.
Jack Kelly,
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.