
Prefazione
Esiste un'utilità così semplice e molto utile — , 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 , 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 …
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 — 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 — . 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):

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
