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

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

Introduzione

Esiste nel mondo un'utile e semplice utilità — BDelta, e così è accaduto che si è radicata nel nostro processo produttivo da molto tempo (anche se non sono riuscito a installare la sua versione, era sicuramente precedente all'ultima disponibile). La utilizziamo per il suo scopo principale — la creazione di patch binari. Se si guarda cosa c'è nel repository, diventa un po' triste: in sostanza è stato abbandonato da tempo e molte cose sono diventate obsolete (una volta il mio ex-collega ha fatto alcune modifiche, ma è passato molto tempo). In generale, ho deciso di riportarla in vita: ho fatto un fork, ho eliminato ciò che non intendo usare, ho trasferito il progetto su cmake, ho inlined le microfunzioni “calde”, ho rimosso grandi array dallo stack (e array di lunghezza variabile, che onestamente mi danno fastidio), ho fatto girare nuovamente il profiler — e ho scoperto che circa il 40% del tempo viene speso su fwrite

Cosa c'è con fwrite?

Nel codice attuale fwrite (nel mio specifico caso: creazione di un patch tra file da quasi 300 MB, i dati di input sono completamente in memoria) viene chiamato milioni di volte con un buffer di piccole dimensioni. È evidente che questa cosa sarà lenta, e quindi vorrei trovare un modo per influenzare questo problema senza complicazioni. Non ho voglia di implementare fonti di dati di vario genere o input/output asincroni, volevo trovare una soluzione più semplice. La prima cosa che mi è venuta in mente è stata aumentare la dimensione del buffer

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

ma non ho ottenuto un miglioramento significativo (ora fwrite richiedeva circa il 37% del tempo) — significa che il problema non è nella scrittura frequente dei dati su disco. Guardando “sotto il cofano” di fwrite si può vedere che all’interno avviene un lock/unlock della struttura FILE più o meno così (pseudocodice, tutta l'analisi è stata effettuata con Visual Studio 2017):


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

Se si crede al profiler, solo il 6% del tempo viene speso su _fwrite_nolock, il resto è overhead. Nel mio caso specifico la sicurezza del thread è un chiaro superfluo, e farò a meno di essa, sostituendo la chiamata a fwrite con _fwrite_nolock — non c'è bisogno di complicare le cose nemmeno con argomenti. In sintesi: questa semplice manipolazione ha ridotto drasticamente i costi di registrazione del risultato, che nella versione originale costituivano quasi la metà del tempo speso. A proposito, nel mondo POSIX esiste una funzione analoga — fwrite_unlocked. In effetti, lo stesso vale per fread. Così, con un paio di #define, è possibile ottenere una soluzione abbastanza cross-platform senza blocchi superflui nel caso in cui non siano necessari (ed è una situazione abbastanza comune).

fwrite, _fwrite_nolock, setvbuf

Asturiamo dall'originale e occupiamoci del test di un caso specifico: registrazione di un grande file (512 MB) in porzioni estremamente piccole — 1 byte. Sistema di prova: AMD Ryzen 7 1700, 16 GB di RAM, HDD 7200 rpm con 64 MB di cache, Windows 10 1809, binario costruito a 32 bit, ottimizzazioni abilitate, libreria collegata staticamente.

Esempio per eseguire 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;
}

Come variabili utilizzeremo TEST_BUFFER_SIZE e per un paio di casi sostituiremo fwrite_unlocked con fwrite. Iniziamo con il caso di fwrite senza un'impostazione esplicita della dimensione del buffer (commentiamo setvbuf e il codice correlato): 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 testeremo il funzionamento di _fwrite_nolock senza chiamare setvbuf: 7262221 µs, velocità — 70.5 MB/s!

Procediamo quindi a sperimentare con la dimensione del buffer (setvbuf):

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

I dati sono stati ottenuti come media di 5 esperimenti, non mi sono preso la briga di calcolare gli errori. A mio avviso, 93 MB/s durante la scrittura di 1 byte su un normale HDD è un risultato davvero buono, basta semplicemente scegliere la dimensione del buffer ottimale (nel mio caso 256 KB è perfetto) e sostituire fwrite con _fwrite_nolock/fwrite_unlocked (nel caso in cui la sicurezza dei thread non sia necessaria, ovviamente).
Analogamente per fread in condizioni simili. Poiché non ho una macchina 'reale' con linux a disposizione (i single board non contano), ho deciso di condurre un esperimento limitato su una macchina virtuale (Hyper-V, OpenSUSE 15, GCC 8.3.1) — la regola è sostanzialmente la stessa: fwrite 'puro' 20 MB/s, fwrite + buffer di 256 KB ha reso 23 MB/s, fwrite_unlocked con lo stesso buffer — 35 MB/s (binario a 64 bit, compilato con g++ -o2 -s -static-libgcc -static-libstdc++ fwrite_test.cpp -o fwrite_test).

Postfazione

L'obiettivo di questo articolo era descrivere un metodo semplice ed efficace che funziona in molti casi (non avevo mai incontrato le funzioni _fwrite_nolock/fwrite_unlocked, non sono molto popolari — e sarebbe un peccato). Non pretendo di portare novità, ma spero che l'articolo si riveli utile alla comunità.

Fonte: habr.com

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