Accélérer les E/S de fichiers C/C++ sans trop d'effort

Accélérer les E/S de fichiers C/C++ sans trop d'effort

Avant-propos

Il existe un utilitaire si simple et très utile dans le monde - BDelta, et il se trouve qu'il était ancré dans notre processus de production depuis très longtemps (même s'il n'était pas possible d'installer sa version, mais ce n'était certainement pas la dernière disponible). Nous l'utilisons pour l'usage auquel il est destiné : créer des correctifs binaires. Si vous regardez ce qu'il y a dans le référentiel, cela devient un peu triste : en fait, il a été abandonné il y a longtemps et une grande partie est très obsolète (mon ancien collègue y a déjà fait plusieurs modifications, mais c'était il y a longtemps) . En général, j'ai décidé de ressusciter cette affaire : j'ai bifurqué, jeté ce que je n'avais pas prévu d'utiliser, déplacé le projet vers cmake, intégré des microfonctions « chaudes », supprimé les grands tableaux de la pile (et les tableaux de longueur variable, ce qui me fait franchement une « bombe »), exécuté à nouveau le profileur - et découvert qu'environ 40 % du temps était consacré à fécrire...

Alors, qu'est-ce qui se passe avec fwrite ?

Dans ce code, fwrite (dans mon cas de test spécifique : créer un correctif entre des fichiers proches de 300 Mo, les données d'entrée sont entièrement en mémoire) est appelé des millions de fois avec une petite taille de tampon. Évidemment, cette chose va ralentir et j'aimerais donc influencer d'une manière ou d'une autre cette honte. Il n'y a pas encore de volonté de mettre en œuvre différents types de sources de données, d'entrées-sorties asynchrones, je voulais trouver une solution plus simple. La première chose qui m'est venue à l'esprit était d'augmenter la taille du tampon.

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

mais je n'ai pas obtenu d'amélioration significative du résultat (maintenant fwrite représentait environ 37% du temps) - ce qui signifie qu'il ne s'agit toujours pas d'écrire fréquemment des données sur le disque. En regardant « sous le capot » de fwrite, vous pouvez voir qu'une structure de verrouillage/déverrouillage de FILE se produit à l'intérieur de quelque chose comme ceci (pseudo-code, toutes les analyses ont été effectuées sous 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;
}

Selon le profileur, _fwrite_nolock ne représente que 6 % du temps, le reste étant une surcharge. Dans mon cas particulier, la sécurité des threads est clairement excessive, je vais donc la sacrifier en remplaçant l'appel fwrite par _fwrite_nolock - vous n'avez même pas besoin d'être intelligent avec les arguments. Au total : cette simple manipulation a considérablement réduit le coût d'enregistrement du résultat, qui dans la version originale représentait près de la moitié du temps passé. À propos, dans le monde POSIX, il existe une fonction similaire : fwrite_unlocked. D'une manière générale, il en va de même pour le fread. Ainsi, en utilisant une paire de #defines, vous pouvez obtenir une solution complètement multiplateforme sans verrous inutiles s'ils ne sont pas nécessaires (et cela arrive assez souvent).

fwrite, _fwrite_nolock, setvbuf

Faisons abstraction du projet original et commençons à tester un cas spécifique : écrire un gros fichier (512 Mo) en portions extrêmement petites - 1 octet. Système de test : AMD Ryzen 7 1700, 16 Go de RAM, disque dur 7200 tr/min 64 Mo de cache, Windows 10 1809, le binaire a été construit en 32 bits, les optimisations sont activées, la bibliothèque est liée statiquement.

Échantillon pour l'expérience :


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

Les variables seront TEST_BUFFER_SIZE, et dans quelques cas, nous remplacerons fwrite_unlocked par fwrite. Commençons par le cas fwrite sans définir explicitement la taille du tampon (commentez setvbuf et le code associé) : temps 27048906 µs, vitesse d'écriture - 18.93 Mo/s. Fixons maintenant la taille du tampon à 64 Ko : temps - 25037111 μs, vitesse - 20.44 Mb/s. Testons maintenant le fonctionnement de _fwrite_nolock sans appeler setvbuf : 7262221 µs, vitesse - 70.5 Mb/s !

Ensuite, expérimentons avec la taille du tampon (setvbuf) :

Accélérer les E/S de fichiers C/C++ sans trop d'effort

Les données ont été obtenues en faisant la moyenne de 5 expériences ; j'étais trop paresseux pour calculer les erreurs. Quant à moi, 93 Mo/s lors de l'écriture de 1 octet sur un disque dur ordinaire est un très bon résultat, il vous suffit de sélectionner la taille de tampon optimale (dans mon cas, 256 Ko est parfait) et de remplacer fwrite par _fwrite_nolock/fwrite_unlocked ( au cas où la sécurité des threads n'est pas nécessaire, bien sûr).
De même avec du fread dans des conditions similaires. Comme je n'ai pas de machine matérielle avec Linux sous la main (les ordinateurs monocarte ne comptent pas), j'ai décidé de mener une expérience limitée sur une machine virtuelle (Hyper-V, OpenSUSE 15, GCC 8.3.1) - la Le modèle est, en principe, le même : fwrite « nu » 20 Mb/s, fwrite + 256 Ko de tampon produit 23 Mb/s, fwrite_unlocked avec le même tampon - 35 Mb/s (binaire 64 bits, assemblé g++ -o2 - s -static-libgcc -static-libstdc++ fwrite_test.cpp -o fwrite_test).

Postface

Le but de la rédaction de cet article était de décrire une technique simple et efficace dans de nombreux cas (je n'ai jamais rencontré les fonctions _fwrite_nolock/fwrite_unlocked auparavant, elles ne sont pas très populaires - mais en vain). Je ne prétends pas que le matériel est nouveau, mais j'espère que l'article sera utile à la communauté.

Source: habr.com