
Predgovor
Postoji jednostavna i vrlo korisna alatka — , 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 , 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 …
Š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 — č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 — . 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):

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
