Accelerazione dell'input/output di file C/C++, senza troppa fatica

Accelerazione dell'input/output di file C/C++, senza troppa fatica

Prefazione

Esiste un'utilità così semplice e molto utile — BDelta, e così è successo che è profondamente radicata nel nostro processo produttivo (anche se non sono riuscito a installare la sua versione, ma sicuramente non era l'ultima disponibile). La utilizziamo per il suo scopo principale — costruzione di patch binari. Se guardiamo cosa c'è nel repository, diventa un po' triste: fondamentalmente è stato trascurato da tempo e molte cose sono fortemente obsolete (una volta ci ha apportato alcune modifiche un mio ex-collega, ma è passato molto tempo). In generale, ho deciso di resuscitare questo progetto: ho fatto un fork, ho eliminato ciò che non intendo utilizzare, ho migrato il progetto su cmake, ho inlineato le microfunzioni 'calde', ho rimosso dal stack grandi array (e array di lunghezza variabile, che mi infastidiscono apertamente), ho eseguito nuovamente il profiler — e ho scoperto che circa il 40% del tempo viene speso su fwrite

Cosa c'è quindi con fwrite?

In questo codice fwrite (nel mio caso specifico: costruzione di un patch tra file da circa 300 MB, i dati di input sono completamente in memoria) viene chiamato milioni di volte con un buffer di piccole dimensioni. È evidente che questo sarà un collo di bottiglia, e quindi vorrei in qualche modo influenzare questo malcostume. Attualmente non ho voglia di implementare fonti di dati di vario tipo, o la I/O asincrona, quindi volevo trovare una soluzione più semplice. La prima cosa che mi è venuta in mente è stata quella di aumentare la dimensione del buffer

setvbuf(file, nullptr, _IOFBF, 64* 1024)

ma non ho ottenuto miglioramenti significativi (ora fwrite occupava circa il 37% del tempo) — quindi la questione non era tutta nella scrittura frequente dei dati su disco. Guardando 'sotto il cofano' di fwrite, si può notare che all'interno avviene un lock/unlock della struttura FILE in questo modo (codice pseudonimo, tutta l'analisi è stata eseguita sotto Visual Studio 2017):


size_t fwrite (const void *buffer, size_t size, size_t count, FILE *stream)
{
   size_t retval = 0;
   _lock_str(stream);   /* lock stream */
   __try
   {
      retval = _fwrite_nolock(buffer, size, count, stream);
   }
   __finally 
   {
       _unlock_str(stream);   /* unlock stream */
   }
   return retval;
}

Se si crede al profiler, solo il 6% del tempo va a _fwrite_nolock, il resto è overhead. Nel mio caso specifico, la sicurezza del thread è un chiaro superfluo, e quindi sacrifiquerò questo, sostituendo la chiamata a fwrite con _fwrite_nolock — non è nemmeno necessario complicare gli argomenti. Risultato: questa semplice manovra ha ridotto drasticamente i costi di scrittura del risultato, che nella versione originale rappresentavano quasi metà dei tempi impiegati. A proposito, nel mondo POSIX esiste una funzione analoga — fwrite_unlocked. In generale, lo stesso vale anche per fread. In questo modo, con un paio di #define, si può ottenere una soluzione cross-platform senza inutili blocchi nel caso in cui non siano necessari (e ciò accade piuttosto frequentemente).

fwrite, _fwrite_nolock, setvbuf

Facciamo un passo indietro dal progetto originale e occupiamoci della prova di un caso specifico: scrittura di un grande file (512 MB) in porzioni estremamente piccole — di 1 byte. Sistema di test: AMD Ryzen 7 1700, 16 GB di RAM, HDD 7200 rpm 64 MB di cache, Windows 10 1809, l'eseguibile è stato costruito in 32 bit, ottimizzazioni attivate, la libreria è statica.

Campione per condurre l'esperimento:


#include <chrono>
#include <cstdio>
#include <inttypes.h>
#include <memory>

#ifdef _MSC_VER
#define fwrite_unlocked _fwrite_nolock
#endif

using namespace std::chrono;

int main()
{
    std::unique_ptr<FILE, int(*)(FILE*)> file(fopen("test.bin", "wb"), fclose);
    if (!file)
        return 1;

    constexpr size_t TEST_BUFFER_SIZE = 256 * 1024;
    if (setvbuf(file.get(), nullptr, _IOFBF, TEST_BUFFER_SIZE) != 0)
        return 2;

    auto start = steady_clock::now();
    const uint8_t b = 77;
    constexpr size_t TEST_FILE_SIZE = 512 * 1024 * 1024;
    for (size_t i = 0; i < TEST_FILE_SIZE; ++i)
        fwrite_unlocked(&b, 1, sizeof(b), file.get());

    auto end = steady_clock::now();
    auto interval = duration_cast<microseconds>(end - start);
    printf("Time: %lldn", interval.count());

    return 0;
}

Le variabili saranno TEST_BUFFER_SIZE, e per un paio di casi sostituiremo fwrite_unlocked con fwrite. Iniziamo con il caso fwrite senza impostare esplicitamente la dimensione del buffer (commentiamo setvbuf e il codice associato): tempo 27048906 µs, velocità di scrittura — 18.93 MB/s. Ora impostiamo la dimensione del buffer a 64 KB: tempo — 25037111 µs, velocità — 20.44 MB/s. Ora testiamo il funzionamento di _fwrite_nolock senza chiamare setvbuf: 7262221 µs, velocità — 70.5 MB/s!

Continuando a sperimentare con la dimensione del buffer (setvbuf):

Accelerazione dell'input/output di file C/C++, senza troppa fatica

I dati sono stati ottenuti dalla media di 5 esperimenti, non mi sono preoccupato di calcolare gli errori. A mio avviso, 93 MB/s scrivendo 1 byte su un normale HDD — è un ottimo risultato, basta scegliere la dimensione ottimale del buffer (nel mio caso 256 KB — perfetto) e sostituire fwrite con _fwrite_nolock/fwrite_unlocked (nel caso non sia necessaria la sicurezza del thread, ovviamente).
Lo stesso vale anche per fread in condizioni simili. Poiché non ho una macchina 'metallica' con Linux a disposizione (le schede a scheda singola non contano), ho deciso di condurre un esperimento limitato su una macchina virtuale (Hyper-V, OpenSUSE 15, GCC 8.3.1) — la tendenza è fondamentalmente la stessa: fwrite 'nudo' 20 MB/s, fwrite + buffer di 256 KB ha dato 23 MB/s, fwrite_unlocked con lo stesso buffer — 35 MB/s (l'eseguibile è stato costruito in 64 bit con g++ -o2 -s -static-libgcc -static-libstdc++ fwrite_test.cpp -o fwrite_test).

Epifania

Lo scopo della scrittura di questo articolo era descrivere un espediente semplice ed efficace in molti casi (non avevo mai affrontato funzioni _fwrite_nolock/fwrite_unlocked, non sono molto popolari — e sarebbe un peccato). Non pretendo di avere qualcosa di nuovo, ma spero che l'articolo risulti utile alla comunità.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster