
Eessõna
On maailmas üks lihtne ja väga kasulik utiliit — , ja nii juhtuski, et see on juba ammu juurdunud meie tootmisprotsessi (tõsi, selle versiooni installimine ei õnnestunud, kuid see ei olnud kindlasti viimase saadaval versioon). Kasutame seda oma algses otstarbes - binaarsete patchide loomisel. Kui vaadata, mis seal repos on, siis muutub pisut kurvaks: põhimõtteliselt on see juba ammu maha jäetud ja palju seal on tugevalt vananenud (kunagi tegi sinna mitmeid parandusi mu endine kolleeg, kuid see juhtus kaua aega tagasi). Ühesõnaga, otsustasin selle asja elustada: forkisin, viskasin välja selle, mida ei kavatse kasutada, ning tõin projekti üle , inlinesin "kuumad" mikrofunktsioonid, eemaldasin virnast suured massiivid (ja muutuva pikkusega massiivid, millest mul tõeliselt "pöördub"), läbisin uuesti profiilerit - ja sain teada, et umbes 40% ajast kulub …
Mis seal fwrite’iga siis toimub?
Antud koodis kutsutakse fwrite (minu konkreetses testjuhtumis: patchi loomine sarnaste 300 MB failide vahel, sisendandmed on täielikult mälus) miljoneid kordi välja väikese suurusega puhvriga. On ilmselge, et see asi hakkab kõvasti segama, ja seetõttu tahaks kuidagi selle asja üle mõjutada. Erinevate andmeallikate, asünkroonse I/O rakendamine ei ole hetkel soovitav, tahaks leida lihtsamat lahendust. Esimene asi, mis pähe tuli — suurendada puhversuurust
setvbuf(file, nullptr, _IOFBF, 64* 1024)aga tulemus ei paranenud oluliselt (nüüd kulus fwrite’ile umbes 37% ajast) - tähendas, et asi ei ole siiski sagedases andmete salvestamises kettale. Vaatamata fwrite “kapoti alla” võib näha, et seal toimub FILE struktuuri lukustamine/avalukustamine umbes nii (pseudokood, kogu analüüs viidi läbi Visual Studio 2017 all):
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;
}
Kui uskuda profiilerit, siis _fwrite_nolock’ile kulub vaid 6% ajast, ülejäänud on overhead. Minu konkreetses jutuhoos on samal ajal vooluohutus ilmselge üleliigsus, selle ohverdan, asendades fwrite’i nimetuse — isegi argumentide kasutamine ei ole vajalik. Kokkuvõtteks: see lihtne manipuleerimine vähendas oluliselt tulemuse salvestamise kulusid, mis originaalses variandis moodustasid peaaegu poole ajakuludest. Muide, POSIX maailmas on olemas sarnane funktsioon — . Üldiselt kehtib sama ka freadi kohta. Seega on paar #define'i abil võimalik saada täiesti platvormide vahel töötav lahendus ilma liigsete lukustusteta, kui need ei ole vajalikud (mis toimub üsna sageli).
fwrite, _fwrite_nolock, setvbuf
Lähme originaalsest projektist eemale ja keskendume konkreetse juhtumi testimisele: suure faili (512 MB) salvestamine äärmiselt väikeste portsjonitena — 1 bait. Testisüsteem: AMD Ryzen 7 1700, 16 GB RAM, HDD 7200 rpm 64 MB vahemälu, Windows 10 1809, binaar ehitatud 32-bitine, optimeerimised on sisse lülitatud, teek on staatiliselt lingitud.
Katseproov eksperimentide läbiviimiseks:
#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;
}
Muutujateks on TEST_BUFFER_SIZE ning paaril juhul asendame fwrite_unlocked fwrite'iga. Alustame fwrite juhtumist, ilma et oleksime selgelt bufferi suurust seadnud (kommenteerime välja setvbuf ja sellega seotud koodi): aeg 27048906 µs, kirjutamise kiirus — 18.93 MB/s. Nüüd seadistame bufferi suuruseks 64 KB: aeg — 25037111 µs, kiirus — 20.44 MB/s. Testime nüüd _fwrite_nolock'i setvbuf'i kutsumata: 7262221 µs, kiirus — 70.5 MB/s!
Edasi katsetame bufferi suurusega (setvbuf):

Andmed on saadud 5 katse keskmistamisel, vigu ma ei arvestanud. Minu arvates on 93 MB/s kirjutamine 1 baitiga tavalisele HDD-le väga hea tulemus, tuleb vaid valida optimaalne bufferi suurus (minu jaoks 256 KB — ideaalne) ja asendada fwrite _fwrite_nolock'i või fwrite_unlocked'iga (kui voogude ohutus muidugi ei ole vajalik).
Sarnane kehtib fread'i kohta sarnastes tingimustes. Kuna mul ei ole käsitsi linuxi 'raudmasinat' (üheplaadilised ei kvalifitseeru), otsustasin läbi viia piiratud eksperimendi virtuaalmasinas (Hyper-V, OpenSUSE 15, GCC 8.3.1) — seaduspärasus on põhimõtteliselt sama: 'paljas' fwrite 20 MB/s, fwrite + 256 KB buffer andis 23 MB/s, fwrite_unlocked sama bufferiga — 35 MB/s (binaar 64-bitine, kompileeritud g++ -o2 -s -static-libgcc -static-libstdc++ fwrite_test.cpp -o fwrite_test).
Eessõna
Selle artikli eesmärk oli kirjeldada lihtsat ja tõhusat meetodit, mis töötab paljudes olukordades (funktsioonidega _fwrite_nolock/fwrite_unlocked, millega ma enne ei olnud kokku puutunud, ei ole nad just väga populaarsed — kuid asjata). Uue materjali osas ma pretentsiooni ei oma, kuid loodan, et artikkel osutub kogukonnale kasulikuks.
Allikas: habr.com
