
Wstęp
Na świecie istnieje takie proste i bardzo przydatne narzędzie — , i tak wyszło, że od bardzo dawna zakorzeniło się w naszym procesie produkcyjnym (prawda, nie udało się zainstalować jego wersji, ale na pewno nie była to najnowsza dostępna). Używamy jej zgodnie z jej przeznaczeniem — do tworzenia binarnych patchy. Jeśli spojrzeć na repozytorium, staje się nieco smutno: w zasadzie jest ono od dawna porzucone, a wiele w nim jest mocno przestarzałe (kiedyś wprowadził kilka poprawek mój były kolega, ale zdarzyło się to dawno). W każdym razie, postanowiłem ożywić ten projekt: sforkowałem, usunąłem to, czego nie planuję używać, przeniosłem projekt na , zainlinowałem „gorące” mikro-funkcje, usunąłem z stosu duże tablice (i tablice o zmiennym rozmiarze, z których się szczerze „bumuję”), ponownie uruchomiłem profiler — i dowiedziałem się, że około 40% czasu poświęca się na …
Co tam z fwrite?
W tym kodzie fwrite (w moim konkretnym przypadku: tworzenie patcha między podobnymi plikami 300 MB, dane wejściowe w całości w pamięci) wywoływane jest miliony razy z małym rozmiarem bufora. Oczywiście, że ten mechanizm będzie spowalniał, dlatego chciałbym jakoś wpłynąć na ten problem bez wprowadzania różnych źródeł danych, asynchronicznego wejścia/wyjścia na razie nie mam ochoty, chciałem znaleźć prostsze rozwiązanie. Pierwsza myśl, która przyszła mi do głowy — zwiększyć rozmiar bufora
setvbuf(file, nullptr, _IOFBF, 64 * 1024)ale znaczącej poprawy wyniku nie uzyskałem (teraz fwrite zajmowało około 37% czasu) — więc problem nie leży w częstym zapisywaniu danych na dysku. Zaglądając „pod maskę” fwrite, można zobaczyć, że wewnątrz odbywa się lock/unlock struktury FILE mniej więcej tak (pseudokod, cały analiz przeprowadzono pod Visual Studio 2017):
size_t fwrite (const void *buffer, size_t size, size_t count, FILE *stream)
{
size_t retval = 0;
_lock_str(stream); /* zablokuj strumień */
__try
{
retval = _fwrite_nolock(buffer, size, count, stream);
}
__finally
{
_unlock_str(stream); /* odblokuj strumień */
}
return retval;
}
Jeśli wierzyć profilerowi, na _fwrite_nolock przypada tylko 6% czasu, reszta to overhead. W moim przypadku bezpieczeństwo wątkowe to wyraźne nadmiar, więc zdecyduję się je poświęcić, zastępując wywołanie fwrite na — nawet z argumentami nie trzeba kombinować. W rezultacie: ta prosta manipulacja znacznie zmniejszyła koszty zapisu wyniku, które w pierwotnej wersji stanowiły prawie połowę czasu potrzebnego. Przy okazji, w świecie POSIX istnieje podobna funkcja — . Ogólnie rzecz biorąc, to samo dotyczy fread. W ten sposób za pomocą pary #define można uzyskać całkiem uniwersalne rozwiązanie bez zbędnych blokad, jeśli nie są one potrzebne (co zdarza się dość często).
fwrite, _fwrite_nolock, setvbuf
Porzućmy pierwotny projekt i zajmijmy się testowaniem konkretnego przypadku: zapisu dużego pliku (512 MB) w ekstremalnie małych porcjach — po 1 bajcie. System testowy: AMD Ryzen 7 1700, 16 GB RAM, HDD 7200 rpm 64 MB cache, Windows 10 1809, binarka budowana w 32-bitowym, optymalizacje włączone, biblioteka statycznie powiązana.
Przykład do przeprowadzenia eksperymentu:
#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;
}
Jako zmienne będą występować TEST_BUFFER_SIZE, a dla pary przypadków zamienimy fwrite_unlocked na fwrite. Zaczniemy od przypadku fwrite bez wyraźnego ustawienia rozmiaru bufora (zakomentujemy setvbuf i powiązany kod): czas 27048906 µs, prędkość zapisu — 18.93 MB/s. Teraz ustawimy rozmiar bufora na 64 KB: czas — 25037111 µs, prędkość — 20.44 MB/s. Teraz przetestujemy działanie _fwrite_nolock bez wywołania setvbuf: 7262221 µs, prędkość — 70.5 MB/s!
Następnie przeprowadzimy eksperymenty z rozmiarem bufora (setvbuf):

Dane uzyskano na podstawie średniej z 5 eksperymentów, na obliczanie błędów nie miałem cierpliwości. Z mojego punktu widzenia, 93 MB/s przy zapisie po 1 bajcie na zwykłym HDD — to naprawdę niezły wynik, wystarczy dobrać optymalny rozmiar bufora (w moim przypadku 256 KB — idealnie) i zamienić fwrite na _fwrite_nolock/fwrite_unlocked (jeśli oczywiście nie potrzebna jest bezpieczeństwo wątków).
Podobnie z fread w takich warunkach. Ponieważ nie mam 'metalowej' maszyny z linuxem pod ręką (jednopłytkowce się nie liczą), postanowiłem przeprowadzić ograniczony eksperyment na wirtualnej maszynie (Hyper-V, OpenSUSE 15, GCC 8.3.1) — zasada jest w zasadzie ta sama: 'goły' fwrite 20 MB/s, fwrite + bufor 256 KB dał 23 MB/s, fwrite_unlocked z takim samym buforem — 35 MB/s (binarka 64-bitowa, budowana g++ -o2 -s -static-libgcc -static-libstdc++ fwrite_test.cpp -o fwrite_test).
Epilog
Celem napisania tego artykułu było opisanie prostego i skutecznego w wielu przypadkach podejścia (z funkcjami _fwrite_nolock/fwrite_unlocked wcześniej się nie spotkałem, nie są zbyt popularne - a szkoda). Nie roszczę sobie prawa do nowości materiału, ale mam nadzieję, że artykuł okaże się przydatny dla społeczności.
Źródło: habr.com
