Wątek przeniesiony 2021-07-17 06:25 z C/C++ przez kq.

Klasa wczytująca plik w kawałkach

1

Piszę coś w rodzaju klona programu tar i aby nie wykorzystywać zbyt dużo pamięci ram wymyśliłem, że plik będę wczytywał w kawałkach. Proszę o code review i wskazówki, bo to mój pierwszy kontakt z plikami binarnym i cywilizowanym wczytywaniem plików.

FileReader.hpp

#include <fstream>
#include <filesystem>
#include <optional>
#include <vector>

class FileReader
{
public:
    explicit FileReader(const std::string&);
    [[nodiscard]] std::optional<std::vector<std::byte>> readFileChunk();

private:
    size_t iterPos{ 0 };
    size_t fileSize{ 0 };
    std::filesystem::path path;
    const size_t megabyte = 1048576;
    const size_t maxBufferSize = megabyte * 10;
};

FileReader.cpp

#include "FileReader.hpp"
#include <iostream>

FileReader::FileReader(const std::string& path) : path(path)
{
    if (std::filesystem::exists(path))
    {
        fileSize = std::filesystem::file_size(path);
    }
    else
    {
        std::cerr << path << " does not exist!\n";
    }
}

[[nodiscard]] std::optional<std::vector<std::byte>> FileReader::readFileChunk()
{
    if (iterPos == fileSize)
    {
        return std::nullopt;
    }

    const size_t memoryToRead = fileSize - iterPos;
    const size_t bufferSize = (memoryToRead < maxBufferSize) ? memoryToRead : maxBufferSize;
    std::vector<std::byte> bytes(bufferSize);

    std::ifstream ifs(path, std::ios::binary | std::ios::ate);
    ifs.seekg(iterPos);
    ifs.read(reinterpret_cast<char*>(bytes.data()), bufferSize);// reinterpret_cast czy jest bardziej cywilizowany sposób?

    iterPos += bufferSize;

    return std::move(bytes);// Nie ma NRVO więc std::move?
}
4

Tak na szybko:

    const size_t megabyte = 1048576;
    const size_t maxBufferSize = megabyte * 10;

Te stałe nie powinny okupować miejsca w instancji klasy, powinieneś użyć constexpr static.

Rozważyłbym użycie parametru wyjściowego (na wzór std::getline), aby uninknąć wymuszenia alokacji przy każdym wywołaniu funkcji. optional wydaje się nadmiarowy, kiedy wystarczy zwrócenie pustego wektora.

Zbenchmarkowałbym czy wielokrotne otwieranie i seekowanie pliku nie ma negatywnego wpływu na wydajność, ale wygląda mi trochę brzydko.

const size_t bufferSize = (memoryToRead < maxBufferSize) ? memoryToRead : maxBufferSize;

std::min

FileReader::FileReader(const std::string& path) : path(path)
{
    if (std::filesystem::exists(path))
    {
        fileSize = std::filesystem::file_size(path);
    }
    else
    {
        std::cerr << path << " does not exist!\n";
    }
}

Rzuciłbym wyjątek i dodał factory method zwracającą optional<FileReader> albo unique_ptr<FileReader>.

3

Poczytaj to https://web.archive.org/web/20180314195042/http://cpp.indi.frih.net/blog/2014/09/how-to-read-an-entire-file-into-memory-in-cpp/
zwłaszcza drugą połowę.

Poza tym: ifs.read(reinterpret_cast<char*>(bytes.data()), bufferSize);// reinterpret_cast czy jest bardziej cywilizowany sposób?
Da się, ale to jest trudne a przynajmniej niewygodne, musisz napisać specjalizację streamu dla std::byte/uint8_t. Boilerplate'u się naklepiesz a zysk z tego średni.

Nie sprawdzasz czy czytanie się udało. To api jest w opór głupie, no ale co zrobisz...:

ifs.read(reinterpret_cast<char*>(bytes.data()), bufferSize);
iterPos += ifs.gcount();
if(!ifs || ifs.eof()) { //możesz to rozbić na kilka bloków.
  //obsłuż błąd i EOF
  //cośtam

  ifs.clear(); //poczytaj dokumentację, bo możesz czyścić flagi błędów selektywnie.
  //albo możesz rzucać w przypadku błędu, sprawa otwarta.
}
0

@kq: Zrobiłem benchmark przy użyciu <chrono> polegający na tym, że wczytywałem plik o rozmiarze 273mb i zmieniałem rozmiar maksymalnego bufora, wyniki są dla mnie zaskakujące:

File size is 273 mb
Elapsed time: 0.209932 s
File opened 1 times
File size is 273 mb
Elapsed time: 0.0942114 s
File opened 28 times
File size is 273 mb
Elapsed time: 0.0797358 s
File opened 92 times

Z czego to może wynikać?

kod testu (FileReader po drobnych zmianach):

    int counter = 0;
    auto start = std::chrono::high_resolution_clock::now();
    FileReader fr("test");
    while (true)
    {
        std::vector<std::byte> bytes = fr.readFileChunk();
        if (!bytes.empty())
        {
            counter++;
        }
        else
        {
            break;
        }
    }
    auto finish = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> elapsed = finish - start;
    std::cout << "Elapsed time: " << elapsed.count() << " s\n";
    std::cout << "File opened " << counter << " times\n";
0

Może więcej alokacji i kopiowania? Zrób benchmark bez zmiany wielkości bufora, tylko z ponownym użyciem już otwartego strumienia.

0

Nie wiem czy stosowanie vectora w tym miejscu jest uzasadnione. Nie planujesz zmieniać jego rozmiaru. Chyba użyłbym std::string, wygląda naturalniej i prawdopodobnie treść będzie traktowana jak string. Poza tym brakuje mi sposobu na określenie ile chcesz przeczytać danych. Dla danych tesktowych, pewnie przydałaby się linia (lub ilość linii), a dla binarnych ilość bajtów.

0

@alagner
Czyli to jest RAII? Teraz plik jest otwierany w konstruktorze i zamykany w destruktorze? Wynik testu jest identyczny.

#include "FileReader.hpp"
#include <iostream>

FileReader::FileReader(const std::string& path) : path(path), ifs(path, std::ios::binary | std::ios::ate)
{
    if (std::filesystem::exists(path))
    {
        fileSize = std::filesystem::file_size(path);
        std::cout << "File size is " << fileSize / megabyte << " mb\n";
    }
    else
    {
        std::cerr << path << " does not exist!\n";
    }
}

[[nodiscard]] std::vector<std::byte> FileReader::readFileChunk()
{
    if (iterPos == fileSize)
    {
        return {};
    }

    const size_t memoryToRead = fileSize - iterPos;
    const size_t bufferSize = std::min(memoryToRead, maxBufferSize);
    std::vector<std::byte> bytes(bufferSize);

    ifs.seekg(iterPos);
    ifs.read(reinterpret_cast<char*>(bytes.data()), bufferSize);

    iterPos += ifs.gcount();

    return std::move(bytes);
}
1

No RAII, RAII.
https://en.cppreference.com/w/cpp/io/basic_ifstream
I wyjaśnij mi proszę, po co otwierasz z ate?
Czytasz ile możesz, eofbit się zapali jak wyjedziesz poza zakres.

EDIT: i ten move na końcu imho jest nadmiarowy.

1

A po co za każdym razem robić seek?

0

@kq: @alagner

Aktualna definicja klasy:

#include "FileReader.hpp"
#include <iostream>

FileReader::FileReader(const std::string& path) : path(path), ifs(path, std::ios::binary)
{
    if (std::filesystem::exists(path))
    {
        fileSize = std::filesystem::file_size(path);
        std::cout << "File size is " << fileSize / megabyte << " mb\n";
    }
    else
    {
        std::cerr << path << " does not exist!\n";
    }
}

[[nodiscard]] std::vector<std::byte> FileReader::readFileChunk()
{
    if (iterPos == fileSize)
    {
        return {};
    }

    const size_t memoryToRead = fileSize - iterPos;
    const size_t bufferSize = std::min(memoryToRead, maxBufferSize);
    std::vector<std::byte> bytes(bufferSize);
    
    ifs.read(reinterpret_cast<char*>(bytes.data()), bufferSize);

    iterPos += ifs.gcount();

    return std::move(bytes);
}

Test:

File size is 273 mb
Elapsed time: 0.180484 s
Function called 1 times
File size is 273 mb
Elapsed time: 0.092175 s
Function called 28 times
File size is 273 mb
Elapsed time: 0.0479522 s
Function called 274 times
File size is 273 mb
Elapsed time: 0.0454998 s
Function called 2731 times

Wygląda na to, że optymalne wyniki są wtedy, gdy wczytuje po 0.1mb - 1mb. Poniżej tego czas zaczyna rosnąć.
Jakieś pomysły czemu tak jest? System operacyjny, cache?

3

Ja gdybym projektował interfejs do takich odczytów, to bym zrobił klasę, która posiada jakiś bufor na dane + iterator na dane + opcję "skonsumowania" jakiegoś prefiksu - w takich zastosowaniach jak kompresja, nieczęsto się i tak spotka dane stałej szerokości, więc trzeba zmienić to na "strumień". Żeby unikać zbytnich alokacji zbyt długich kawałków pamięci naraz (i realokacji), użyłbym czegoś w stylu tego:
https://github.com/scylladb/scylla/blob/master/utils/chunked_vector.hh

Innymi słowy, ten zwracany vector z metody czytającej jest designem który uniemożliwia efektywną implementację (jeśli już, to sugestia @kq jest spoko, żeby użyć parametru wyjściowego).

Co do tego czemu czytanie w dużych chunkach jest wolne, nie wiem jakiego systemu operacyjnego używasz, ale z pewnością to jest wpływ jakichś integracji OSa ze sprzętem.
Microsoft pisze (https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/cc938632(v=technet.10)?redirectedfrom=MSDN ) że optymalnie to 64 KiB, i wyżej zaczyna być znacznie wolniej:

When transferring data blocks greater than 64 KB, the I/O subsystem breaks the transfers into 64-KB blocks.

Na Linuksie, z moich testów kiedyś, też wynikało że więcej niż 64 KiB powoduje zwolnienie czytania, ale nie badałem z czego to się bierze. Może u Ciebie ta granica idzie wyżej, bo używasz fstreamów, które nakładają dodatkową warstwę buforującą dane (jedna to bufor fstreama, druga to bufor systemu operacyjnego).

1 użytkowników online, w tym zalogowanych: 0, gości: 1