Ubrzanje unosa/izlaza datoteka C/C++, bez previše napora

Ubrzanje unosa/izlaza datoteka C/C++, bez previše napora

Predgovor

Postoji jednostavna i vrlo korisna alatka — BDelta, i tako je ispalo da je ona već dugo ukorijenjena u našem proizvodnom procesu (iako nije bilo moguće instalirati njenu verziju, bila je sigurno daleko od posljednje dostupne). Koristimo je za njenu namjenu — izradu binarnih zakrpa. Kada pogledam što se nalazi u repozitoriju, postajem pomalo tužan: zapravo je odavno zapušten i mnoge informacije su zastarjele (neki od mojih bivših kolega su nekada unosili promjene, ali to je bilo davno). Sve u svemu, odlučio sam oživjeti ovo: forkovao sam, izbacio što ne planiram koristiti, prebacio projekt na cmake, uvrstio „vruće” mikrofunkcije, uklonio velike nizove s steka (i nizove promjenljive dužine, od kojih me otvoreno „pali”), ponovo pokrenuo profajler — i saznao da se oko 40% vremena troši na fwrite…

Što je s fwrite?

U ovom kodu fwrite (u svom konkretnom testnom slučaju: izrada zakrpe između sličnih 300 MB datoteka, ulazni podaci u potpunosti u memoriji) poziva se milijune puta s malim bufferom. Očigledno je da će to usporiti proces, pa bih nekako želio utjecati na to bez obzira na sve. Ne želim uvoditi različite izvore podataka ili asinkrono ulaz/izlaz, želio bih pronaći jednostavnije rešenje. Prvo što mi je palo na pamet — povećati veličinu buffer-a

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

ali nisam dobio značajno poboljšanje rezultata (sada se na fwrite troši oko 37% vremena) — znači da nije svejedno u čestom pisanju podataka na disk. Kada pogledam „ispod haube” fwrite, mogu vidjeti da se unutar odvija zaključavanje/otključavanje FILE strukture otprilike ovako (pseudokod, cijela analiza provedena je u 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;
}

Ako vjerujem profajleru, na _fwrite_nolock otpada samo 6% vremena, ostatak — na overhead. U svom konkretnom slučaju, sigurnost u više niti je očigledna suvišnost, nju ću žrtvovati, zamjenjujući poziv fwrite s _fwrite_nolock — čak i s argumentima ne treba previše filozofirati. Ukratko: ova jednostavna manipulacija značajno je smanjila troškove za zapis rezultata, koji su u izvornom varijanti iznosili gotovo polovicu vremenskih troškova. Usput, u svijetu POSIX postoji slična funkcija — fwrite_unlocked. Uistinu, to se odnosi i na fread. Tako se uz pomoć nekoliko #define može dobiti sasvim funkcionalno rješenje bez nepotrebnih zaključavanja u slučaju da nisu potrebna (a to se često događa).

fwrite, _fwrite_nolock, setvbuf

Pogledajmo malo dalje od originalnog projekta i bavit ćemo se testiranjem konkretnog slučaja: zapis velikog datoteke (512 MB) u izuzetno malim porcijama — u 1 bajt. Testni sustav: AMD Ryzen 7 1700, 16 GB RAM-a, HDD 7200 rpm 64 MB cache, Windows 10 1809, binarni je građen za 32-bitni sustav, optimizacije uključene, biblioteka je statički povezivana.

Uzorak za provođenje eksperimenta:


#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;
}

Kao varijable koristit ćemo TEST_BUFFER_SIZE, a također za nekoliko slučajeva zamijenit ćemo fwrite_unlocked s fwrite. Počnimo s slučajem fwrite bez izričitog postavljanja veličine baferira (zakomentirat ćemo setvbuf i povezani kod): vrijeme 27048906 μs, brzina pisanja — 18,93 MB/s. Sada postavljamo veličinu bafera na 64 KB: vrijeme — 25037111 μs, brzina — 20,44 MB/s. Sada ćemo testirati rad _fwrite_nolock bez poziva setvbuf: 7262221 μs, brzina — 70,5 MB/s!

Dalje ćemo eksperimentirati s veličinom bafera (setvbuf):

Ubrzanje unosa/izlaza datoteka C/C++, bez previše napora

Podaci su dobiveni prosjekom od 5 eksperimenta, a nisam se pot trudio da izračunam pogreške. Po mom mišljenju, 93 MB/s pri pisanju po 1 bajtu na običnom HDD-u — to je vrlo dobar rezultat, samo treba odabrati optimalnu veličinu bafera (u mom slučaju 256 KB — to je točno) i zamijeniti fwrite s _fwrite_nolock/fwrite_unlocked (u slučaju da nije potrebna sigurnost u više niti, naravno).
Slično je s fread u sličnim uvjetima. Budući da nemam 'hardverski' stroj s linuxom pri ruci (jednoplatni računari se ne računaju), odlučio sam provesti ograničeni eksperiment na virtualnoj mašini (Hyper-V, OpenSUSE 15, GCC 8.3.1) — uzorak je zapravo isti: 'goloin' fwrite 20 MB/s, fwrite + bafer od 256 KB dao je 23 MB/s, fwrite_unlocked s istim baferom — 35 MB/s (binarno 64-bitno, sakupljeno g++ -o2 -s -static-libgcc -static-libstdc++ fwrite_test.cpp -o fwrite_test).

Epilog

Cilj ovog članka bio je opisati jednostavnu i učinkovitu tehniku koja se može koristiti u mnogim slučajevima (s funkcijama _fwrite_nolock/fwrite_unlocked ranije nisam imao iskustva, nisu jako popularne — a šteta). Ne tvrdim da je materijal nov, ali se nadam da će članak biti koristan zajednici.

Izvor: habr.com

Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere 🔥 Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere | ProHoster