Sökresultat för

Optimering av Word-Count programmet

49 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
Optimering av Word-Count programmet

I min förra artikel använde jag ranges & views från C++20/23 för att implementera ett program som läste in alla ord från en textfil och matade ut en HTML-fil med orden, där storleken var proportionell mot dess frekvens, samt att varje ord fick en slumpmässigt vald färg. Tonvikten i den artikeln låg på att illustrera modern C++. I denna uppföljande artikel tänkte jag stegvis optimera programmet, för att se hur snabbt programmet kan köra.

Strategin i denna artikel är att steg-för-steg optimera programmet, enligt följande steg.

Version Förväntad effekt
Baseline Utgångsläge
Kompileringsflaggor "Gratis" speedup
Förallokering Viss uppsnabbning
Optimerade teckenfunktioner Viss uppsnabbning
Minnesmappad fil Stor uppsnabbning

Version 1 - Baseline

Jag visar först startversionen (baseline) för vårt program och sen i de följande stegen försöker jag göra programmet snabbare. Ibland lyckas det, ibland kanske resultatet uteblir eller blir mediokert.

Jämfört med koden i förra artikeln, så har jag extraherat ut den del som läser och genererar html texten. Detta för att senare kunna köra benchmarks på de olika versionerna. Den kompletta koden finns i en GitHub repo; se länksamlingen sist i denna artikel. Tänk också på att jag visar bara relevanta delar av koden och hänvisar till GitHub repon för övrig kod.

Så här ser run() funktionen ut.

auto run(Params const& params) -> string {
    // --- loading words ---
    auto infile = std::ifstream{params.filename};
    if (not infile) throw std::invalid_argument{"cannot open "s + params.filename.string()};

    auto freqs = std::unordered_map<string, unsigned>{};
    
    auto keep_nonsmall_words = [&params](string const& word) { return word.size() >= params.min_length; };
    auto to_lowercase = [](string const& word) {
        auto result = string{};
        for (char ch: word) {
            auto c = static_cast<unsigned char>(ch);
            result.push_back(static_cast<char>(std::tolower(c)));
        }
        return result;
    };
    auto keep_nonmodern_words = [](string const& word) {
        static auto const modern_words = std::unordered_set{
            "electronic"s, "distributed"s, "copies"s, "copyright"s, "gutenberg"s
        };
        return not modern_words.contains(word);
    };
    
    auto load_pipeline =
            r::subrange{WordIterator{infile}, WordIterator{}}
            | v::filter(keep_nonsmall_words)
            | v::transform(to_lowercase)
            | v::filter(keep_nonmodern_words);
    auto count_words = [&freqs](string const& word) { ++freqs[word]; };

    r::for_each(load_pipeline, count_words);

    // --- sorting <word,count> pairs ---
    using WordFreq = std::pair<string, unsigned>;
    auto sortable = std::vector<WordFreq>{freqs.begin(), freqs.end()};

    auto by_freq_desc = [](WordFreq const& a, WordFreq const& b) { return a.second > b.second; };
    r::sort(sortable, by_freq_desc);

    // --- making html span tags ---
    auto items    = sortable | v::take(params.max_words) | r::to<std::vector<WordFreq>>();
    auto max_freq = items.front().second;
    auto min_freq = items.back().second;
    auto scale    = static_cast<double>(params.max_font - params.min_font) / (max_freq - min_freq);
    auto R        = std::default_random_engine{std::random_device{}()};

    struct SpanTagGenerator {
        Params const& params;
        double        scale;
        unsigned      min_freq;
        std::default_random_engine& R;

        auto color() -> string {
            auto Byte = std::uniform_int_distribution<unsigned short>{0, 255};
            return std::format("#{:02X}{:02X}{:02X}", Byte(R), Byte(R), Byte(R));
        }

        SpanTagGenerator(Params const& params_, double scale_, unsigned min_freq_, std::default_random_engine& R_)
            : params(params_), scale(scale_), min_freq(min_freq_), R(R_) {}

        auto operator()(WordFreq& wf) -> string {
            auto word = wf.first;
            auto freq = wf.second;
            auto size = static_cast<unsigned>((freq - min_freq) * scale + params.min_font);
            auto colr = color();
            constexpr auto fmt =
                    R"(<span style="font-size: {} px; color: {};" title="The word '{}' occurs {} times">{}</span>)";
            return std::format(fmt, size, colr, word, freq, word);
        }
    };

    r::shuffle(items, R);
    auto tags        = std::vector<string>{};
    auto to_span_tag = SpanTagGenerator{params, scale, min_freq, R};
    for (WordFreq& item: items) tags.push_back(to_span_tag(item));

    // --- generating html text ---
    auto html = std::ostringstream{};
    html << R"(<!DOCTYPE html>
        <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=yes">
                <title>Word Frequencies</title>
            </head>
        <body>)";
    html << std::format("<h1>The {} most frequent words in {}</h1>", params.max_words, params.filename.string());
    for (string const& tag: tags) html << tag << "\n";
    html << "</body></html>\n";

    return html.str();
}

Struct:en Params innehåller alla program-parametrar samt en funktion för att läsa in dessa från kommandoraden. Klassen WordIterator är densamma som i förra artikeln. Kör vi programmet har vi en förfluten tid att utgå ifrån.

Yes> g++ -std=c++23 -Wall -o baseline \
     ../wordcount/utils.cxx ../wordcount/baseline.cxx ../wordcount/baseline-main.cxx
Yes> ./baseline --file ../data/shakespeare.txt
--- WordCount - Baseline ---
loading 5.3 MB from ../data/shakespeare.txt
written: ./shakespeare.html
elapsed: 1470 ms

Version 2 - Kompilera med optimering

Ett första grundläggande steg är att kompilera programmet med optimering påslagen. Det finns några olika kompileringsflaggor att välja på.

Option Brief Effect Use case
-O0 None No optimization. Same as no optimization flag. When you want to make it explicit that no optimization should be performed.
-O1 Optimize Basic optimizations that do not take a long time to compile. Same as option -O. Reduces code size and execution time without significantly increasing compilation time.
-O2 Optimize more The industry standard for release builds. Enables nearly all supported optimizations that do not involve a space-speed tradeoff. Provides a significant performance boost and is generally recommended for deployment.
-O3 Optimize most Turns on everything in -O2, plus aggressive optimizations like loop unrolling and function inlining. Can make code faster (especially for math-heavy loops) but might increase binary size significantly and compile slower.
-Os Reduce code size Enables all -O2 optimizations that do not typically increase code size. Systems with limited memory (embedded systems).
-Ofast Optimize for speed Disregards strict standard compliance (specifically for floating-point math) to achieve maximum speed. High-performance computing where minor precision loss is acceptable.
-Og Optimize some, for debugging Enables optimizations that do not interfere with debugging. A better alternative to -O0 when you want some performance but still need to use a debugger effectively.

En annan kompileringsflagga är -march=native, som instruerar kompilatorn att generera maskin-kod optimerad för den CPU som kompileringen körs på. Detta fungerar bra när man vet att programmet inte ska köras på datorer med annan CPU typ.

Det finns ytterligare en kompileringsflagga som skulle kunna vara av intresse, som aktiverar link-time optimization (LTO). Flaggan är -flto. Emellertid, har den obefintlig inverkan på ett program bestående av en eller få *.cxx filer, vilket kallas för TU (Translation Unit).

Så, vi kompilerar med flaggorna: -O3 -march=native

Yes> g++ -std=c++23 -Wall -O3 -march=native  -o baseline-opti \
     ../wordcount/utils.cxx ../wordcount/baseline.cxx ../wordcount/baseline-main.cxx
wordcount/baseline-main.cxx
Yes> ./baseline-opti --file ../data/shakespeare.txt
--- WordCount - Baseline ---
loading 5.3 MB from ../data/shakespeare.txt
written: ./shakespeare.html
elapsed: 131 ms
Yes>

Oj, vilken uppsnabbning det blev bara genom att ändra lite på kommandoraden 😃

Version 3 - Förallokering

De containers vi använder sköter sin egen minneshantering, vilket innebär att de börjar med en liten intern datastruktur och allt eftersom man stoppar in fler element så omallokeras den interna datastrukturen. Det innebär att ett eller flera minnesblock allokeras och de befintliga destrueras. Genom att förallokera med metoden reserve(), kan alla (eller de flesta) omallokeringar undvikas. Det här är en enkel kod-optimering, vilken jag förvisso rekommenderar att alltid göra.

Först förallokerar vi hash-map freqs. Eftersom vi inte vet det exakta antalet unika ord, så gör vi en estimering. Vi föreställer oss att orden i genomsnitt är 8 bokstäver, vilket låter oss dela filstorleken med 8. Sedan, tänker vi oss att en fjärdedel av dessa utgörs av unika ord. Detta värde används för att förallokera map:en.

auto freqs    = std::unordered_map<string, unsigned>{};
auto filesize = fs::file_size(params.filename);
auto approx_total_words  = filesize / 8;
auto approx_unique_words = approx_total_words / 4;
freqs.reserve(approx_unique_words);

I lambdat to_lowercase vet vi att retur-värdet kommer att ha samma längd som in-argumentet. Således blir det enkelt att förallokera string-objektet result.

auto to_lowercase = [](string const& word) {
    auto result = string{};
    result.reserve(word.size());

Vi vet att vektorn sortable ska innehålla samtliga std::pair element i map:en freqs. Eftersom vi först behöver förallokera och sedan sätta in data, så kan vi inte använda den behändiga konstruktorn vi använde i baseline. Innehållet i freqs flyttas nu över till vektorn sortable, med hjälp av metoden insert och ett intervall av move-iterators.

//auto sortable = std::vector<WordFreq>{freqs.begin(), freqs.end()};
auto sortable = std::vector<WordFreq>{};
sortable.reserve(freqs.size());
sortable.insert(sortable.end(), std::make_move_iterator(freqs.begin()), std::make_move_iterator(freqs.end()));

När det gäller sortering, så vet vi att bara de N mest frekventa orden är av intresse, vilket medför att vi kan nöja oss med att sortera dessa, vilket är precis vad partial_sort gör. Här sätter vi N till den minsta av max_words och antal element i sortable, vilket skyddar mot att vi läst in en väldigt liten fil.

//r::sort(sortable, by_freq_desc);
auto const N = std::min<unsigned>(params.max_words, sortable.size());
r::partial_sort(sortable, sortable.begin() + N, by_freq_desc);

I stället för att köra en liten cool pipeline, så är det mer effektivt att sonika kapa bort de osorterade element vi inte behöver. Metoden resize trunkerar innehållet om argumentet är mindre än den aktuella storleken. Sedan, för att inte behöva ändra variabel-namn, behåller vi namnet items och flyttar innehållet från sortable till items.

//auto items = sortable | v::take(params.max_words) | r::to<std::vector<WordFreq>>();
sortable.resize(N);
auto items = std::move(sortable);

Vektorn tags ska ha samma storlek som items.

auto tags = std::vector<string>{};
tags.reserve(items.size());

Till slut, har vi kommit fram till HTML genereringen. Buffert-typen std::ostringstream, som vi använder i baseline, saknar dessvärre en reserve metod, vilket innebär att vi använder ett string-objekt direkt, vilket går att förallokera.

auto html = string{};
html.reserve(500 + (items.size() * 150));
html += R"(<!DOCTYPE html>
    <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=yes">
            <title>Word Frequencies</title>
        </head>
    <body>)";
html += std::format("<h1>The {} most frequent words in {}</h1>", params.max_words, params.filename.string());
for (string const& tag: tags) html += tag + "\n";
html += "</body></html>\n";

return html;

Så med dessa kod-modifieringar, så kompilerar och kör vi programmet på nytt.

Yes> g++ -std=c++23 -Wall -O3 -march=native  -o using-reserve \
    ../wordcount/utils.cxx ../wordcount/using-reserve.cxx ../wordcount/using-reserve-main.cxx
Yes> ./using-reserve --file ../data/shakespeare.txt
--- WordCount - Using reserve and partial_sort ---
loading 5.3 MB from ../data/shakespeare.txt
written: ./shakespeare.html
elapsed: 129 ms

Ja, det blev en förbättring, men tämligen blygsam 🫤

Version 4 - Optimering av tecken-funktioner

Funktionen bool is_letter(char c) anropas för varje inläst bokstav, vilket är tusentals. Går det att snabba upp denna, samt den tillhörande to_lowercase().

Om vi vet att indata utgörs av engelsk ASCII-text, så går det att förenkla de två funktioner som hanterar tecken och bokstäver. Med hjälp benchmarking kan vi också estimera hur mycket bättre kan det bli.

Jämförande benchmark av is_letter

Jag använder mig av ramverket Google Benchmark. I GitHub repon kan du se hur jag använder CMake för att ladda ned och inkorporera ramverket med övrig kod.

static bool is_letter_orig(char c) {
    const auto ch = static_cast<unsigned char>(c);
    return std::isalpha(ch) || ch == '\'';
}

static bool is_letter_opti(char c) {
    const auto ch = static_cast<unsigned char>(c);
    return ('A' <= ch && ch <= 'Z')
           || ('a' <= ch && ch <= 'z')
           || ch == '\'';
}

static void is_letter_orig_bm(benchmark::State& state) {
    for (auto _ : state) {
        auto result = is_letter_orig('A');
        benchmark::DoNotOptimize(result);
    }
}
BENCHMARK(is_letter_orig_bm)
    ->Unit(benchmark::kNanosecond)
    ->Name("is_letter original");

static void is_letter_opti_bm(benchmark::State& state) {
    for (auto _ : state) {
        auto result = is_letter_opti('A');
        benchmark::DoNotOptimize(result);
    }
}
BENCHMARK(is_letter_opti_bm)
    ->Unit(benchmark::kNanosecond)
    ->Name("is_letter optimized");

När jag kör programmet, så får jag följande utdata

Run on (8 X 1992 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x4)
  L1 Instruction 32 KiB (x4)
  L2 Unified 256 KiB (x4)
  L3 Unified 8192 KiB (x1)
Load Average: 1.71, 0.69, 0.52
--------------------------------------------------------------
Benchmark                    Time             CPU   Iterations
--------------------------------------------------------------
is_letter original        11.3 ns         11.5 ns    104799236
is_letter optimized      0.913 ns        0.926 ns    679818028

Följande tabell sammanfattar ovanstående utdata

Metric Original Optimized Improvement
Mean time 11.3 ns 0.9 ns ~12x faster

Det var en signifikant förbättring och helt klart värt att tillämpa i programmet.

Jämförande benchmark av to_lower

Hur blir det då för to_lower? Här följer en benchmark för denna, plus körexempel.

static char to_lower_orig(char c) {
    return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}

static char to_lower_opti(char c) {
    if ('A' <= c && c <= 'Z') {
        return static_cast<char>((c - 'A') + 'a');
    }
    return c;
}

static void to_lower_orig_bm(benchmark::State& state) {
    for (auto _ : state) {
        auto result = to_lower_orig('A');
        benchmark::DoNotOptimize(result);
    }
}
BENCHMARK(to_lower_orig_bm)
    ->Unit(benchmark::kNanosecond)
    ->Name("to_lower original");

static void to_lower_opti_bm(benchmark::State& state) {
    for (auto _ : state) {
        auto result = to_lower_opti('A');
        benchmark::DoNotOptimize(result);
    }
}
BENCHMARK(to_lower_opti_bm)
    ->Unit(benchmark::kNanosecond)
    ->Name("to_lower optimized");
--------------------------------------------------------------
Benchmark                    Time             CPU   Iterations
--------------------------------------------------------------
to_lower original         8.41 ns         8.53 ns     77731484
to_lower optimized        1.11 ns         1.13 ns    599801894

Sammanfattning av resultatet

Metric Original Optimized Improvement
Mean time 8.4 ns 1.1 ns ~7.6x faster

Även med denna funktion får vi en förbättring. I och med att den anropas för alla ord så är det värt att byta ut denna också. Dessutom, är det värt att konvertera till gemener redan i samband med inläsningen.

Optimerade tecken-funktioner

Så här ser relevanta delar av fjärde versionen av vårt program ut. Först, modifieringen av read_next i WordIterator.

void read_next() {
    if (input == nullptr) {
        at_eof = true;
        return;
    }

    current_word.clear();
    for (char ch; input->get(ch) && not is_letter(ch);) {}
    if (input->gcount()) input->unget();

    if (current_word.empty() && input->eof()) {
        at_eof = true;
        input = nullptr;
        return;
    }

    for (char ch; input->get(ch) && is_letter(ch);) {
        current_word.push_back(to_lower(ch));
    }
    at_eof = false;
}

static bool is_letter(char c) {
    const auto ch = static_cast<unsigned char>(c);
    return ('A' <= ch && ch <= 'Z')
           || ('a' <= ch && ch <= 'z')
           || ch == '\'';
}

static char to_lower(char c) {
    if ('A' <= c && c <= 'Z') {
        return static_cast<char>((c - 'A') + 'a');
    }
    return c;
}

Sedan, borttagning av to_lowercase i run.

auto load_pipeline =
    r::subrange{WordIterator{infile}, WordIterator{}}
    | v::filter(keep_nonsmall_words)
  //  | v::transform(to_lowercase)
    | v::filter(keep_nonmodern_words);

Kompilerar och kör vi programmet, får vi

Yes> g++ -std=c++23 -Wall -O3 -march=native  -o char-fn \
    ../wordcount/utils.cxx ../wordcount/char-fn.cxx ../wordcount/char-fn-main.cxx
Yes> ./char-fn --file ../data/shakespeare.txt
--- WordCount - Optimized char functions ---
loading 5.3 MB from ../data/shakespeare.txt
written: ./shakespeare.html
elapsed: 108 ms

Hmm, en klar förbättring. Inte så mycket som jag hoppats på. Jag misstänker att kompilator-optimeringen har gjort ganska mycket helt på egen hand. Hursomhelst, en intressant övning, att detaljstudera en del av programmet.

Version 5 - Minnesmappad indata-fil

Låt oss göra en sista optimering, för att se om det går att kapa ned exekveringstiden ytterligare. Denna gång tänkte jag byta ut filhanteringen mot att mappa in text-filen direkt i minnet och därmed ha tillgång till en stor tecken-array.

Dessutom, låta alla text-strängar i freqs representeras av string_view objekt, vilka pekar tillbaka in i den minnesmappade filen. Tanken är att dels snabba upp inläsningen och dels drastiskt minska ned minnesallokeringen av text-strängar.

När väl det är sagt, så vill jag till protokollet nämna att std::string sparar korta strängar direkt i objekten (Short String Optimization), vilket innebär att strängar på 15 eller färre tecken sparas i objektet, utan någon heap-allokering (gäller för GCC). Så man bör förvänta sig blott en marginell förbättring med string_view i detta fall.

Class MemoryMappedFile

Fördelen med att mappa in en fil i minnet är att den då blir en del av det virtuella minnessystemet. Detta system delar in hela adressrummet i lika stora delar, så kallade VM pages. Även det fysiska minnet (RAM) delas in på samma sätt. VM systemet håller reda på vilka pages som finns inlästa i RAM.

Första åtkomst-operationen till en adress inom en VM-page, leder till en så kallad page-fault. När detta inträffar kommer en ledig plats i RAM tas i anspråk. Finns det ingen, så kommer en väljas och innehållet, om det har blivit modifierat, sparas till en dedikerad partition på hårddisken, kallad swap-area. Om en adresserad VM-page finns i swap kommer denna att läsas in. Om den inte var sparad så skapas den ur "tomma intet".

Det är just detta vi är intresserad av för en minnesmappad fil. Genom att mappa om en del av VM till en fil på hårddisken, så kommer filen läsas in som en del av upprepade page-faults. Det här tricket gör man med systemanropet mmap.

Nedan visar jag min implementation. Konstruktorn utför mappningen genom att först öppna filen med systemanropet open och sen anropa mmap. Destruktorn avslutar mappningen. Själva minnessegmentet exponeras med metoden data(), som returnerar ett std::span<char> objekt. Denna datatyp kom i C++20 och är perfekt för att representera ett sammanhängande minnesblock.

Copy, move och default constructors är markerade som delete, vilket innebär att kompilatorn inte genereras dessa medlemmar. Detta för att förhindra att någon kopierar eller flyttar ut ett dylikt objekt från det block det är deklarerat i. Samt, förhindra att man skapar ett objekt utan tillhörande fil.

class MemoryMappedFile {
    void* storage = nullptr;
    size_t size = 0;
public:
    explicit MemoryMappedFile(const fs::path& filename) {
        const auto fd = open(filename.string().c_str(), O_RDWR);
        if (fd == -1) throw std::invalid_argument{"cannot open "s + filename.string()};

        size = fs::file_size(filename);
        storage = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
        if (storage == MAP_FAILED) throw std::runtime_error{"mmap failed: "s + strerror(errno)};
        close(fd);
    }
    ~MemoryMappedFile() {
        munmap(storage, size);
    }
    [[nodiscard]] auto data() const -> std::span<char> {
        return std::span{static_cast<char *>(storage), size};
    }
    MemoryMappedFile() = delete;
    MemoryMappedFile(MemoryMappedFile const&) = delete;
    MemoryMappedFile& operator=(MemoryMappedFile const&) = delete;
    MemoryMappedFile(MemoryMappedFile&&) noexcept = delete;
    MemoryMappedFile& operator=(MemoryMappedFile&&) noexcept = delete;
};

Minnessegmentet skapas med flaggorna PROT_READ | PROT_WRITE, vilket gör segmentet både läs- och skrivbart. Det sistnämnda behövs eftersom vi avser att skriva tillbaka gemener för de bokstäver som är versaler. Så länge man inte anropar systemanropet msync, så kommer inte innehållet i minnesblocket skrivas tillbaka till filen, vilket vi ju inte vill.

Class WordIterator

Denna klass är delvis densamma som tidigare, men med några viktiga förändringar. För att kunna fungera ihop med ett span segment, så returneras string_view objekt, i stället för string dito.

Här är det på sin plats att understryka vikten av att livslängden för samtliga string_view objekt inte får överstiga livslängden för den minnesmappade filen, eftersom all textdata ju pekar rakt in i minnesblocket.

class WordIterator {
    span<char>              payload{};
    span<char>::iterator    current_pos{};
    unsigned                min_length{};
    string_view             current_word{};
    bool                    at_end = true;
public:
    using iterator_concept  = std::input_iterator_tag;
    using iterator_category = std::input_iterator_tag;
    using value_type        = string_view;
    using reference         = value_type;
    using pointer           = void;
    using difference_type   = std::ptrdiff_t;

    WordIterator() = default;
    explicit WordIterator(span<char> payload_, unsigned min_length_)
        : payload(payload_), current_pos(payload.begin()), min_length(min_length_) {
        read_next();
    }

    reference operator*() const { return current_word; }
    WordIterator& operator++() { read_next(); return *this; }
    WordIterator operator++(int) {
        auto tmp = *this; ++(*this); return tmp;
    }

    friend bool operator==(WordIterator const& a, WordIterator const& b) {
        if (a.at_end && b.at_end) return true;
        return a.at_end == b.at_end &&
               a.payload.data() == b.payload.data() &&
               a.current_pos == b.current_pos;
    }
    friend bool operator!=(WordIterator const& a, WordIterator const& b) {
        return !(a == b);
    }
private:
    //...
};

En annan modifiering är att efterbehandlingen av inlästa ord är flyttade direkt till read_next() metoden. Det innebär att denna skippar såväl korta som moderna ord, och omvandlar bokstäver till gemener.

void read_next() {
    while (true) {
        while (current_pos != payload.end() && !is_letter(*current_pos)) {
            ++current_pos;
        }

        if (current_pos == payload.end()) {
            at_end = true;
            current_word = {};
            return;
        }

        auto start = current_pos;
        while (current_pos != payload.end() && is_letter(*current_pos)) {
            *current_pos = to_lower(*current_pos);
            ++current_pos;
        }

        auto sp = span<char>(start, current_pos);;
        auto sv = string_view{sp.data(), sp.size()};
        if (sv.size() < min_length || modern_words.contains(sv)) {
            continue;
        }
        
        current_word = sv;
        at_end = false;
        break;
    }
}

inline static std::unordered_set<string_view> const modern_words = {
    "electronic"sv, "distributed"sv, "copies"sv, "copyright"sv, "gutenberg"sv
};
static bool is_letter(char c) {
    const auto ch = static_cast<unsigned char>(c);
    return ('A' <= ch && ch <= 'Z')
           || ('a' <= ch && ch <= 'z')
           || ch == '\'';
}
static char to_lower(char c) {
    if ('A' <= c && c <= 'Z') {
        return static_cast<char>((c - 'A') + 'a');
    }
    return c;
}

Function WordCount::run()

I huvudfunktionen run() så är givetvis den stora förändringen hur inläsningen sker. Här visar jag detta kod-fragment.

auto freqs = std::unordered_map<string_view, unsigned>{};
//...
freqs.reserve(approx_unique_words);

auto file  = MemoryMappedFile{params.filename};
auto first = WordIterator{file.data(), params.min_length};
auto last  = WordIterator{};
r::for_each(r::subrange{first, last}, [&freqs](string_view word) {
    ++freqs[word];
});

Kompilerar och kör vi programmet, så kan det se ut som följande

Yes> g++ -std=c++23 -Wall -O3 -march=native -o memmap \
    ../wordcount/utils.cxx ../wordcount/mem-map-file.cxx ../wordcount/mem-map-file-main.cxx
Yes> ./memmap  --file ../data/shakespeare.txt
--- WordCount - Memory-mapped file ---
loading 5.3 MB from ../data/shakespeare.txt
written: ./shakespeare.html
elapsed: 35 ms

Oj, oj, oj! Från 1470 ms ned till 35 ms, genom att optimera programmet. 😃

Jämförande benchmark

Det sista jag tänkte göra i denna (alldeles för långa) artikel, är att jämföra de olika versionerna med ett samlande benchmark. Så här ser koden ut. Jag använder mig av Google Benchmark ramverket återigen.

#include <benchmark/benchmark.h>
#include "params.hxx"

namespace ribomation::wordcount::baseline {
    extern auto run(Params const& P) -> std::string;
}
namespace ribomation::wordcount::using_reserve {
    extern auto run(Params const& P) -> std::string;
}
namespace ribomation::wordcount::char_fn {
    extern auto run(Params const& P) -> std::string;
}
namespace ribomation::wordcount::mem_map {
    extern auto run(Params const& P) -> std::string;
}
using ribomation::wordcount::Params;


static void baseline_bm(benchmark::State& state) {
    auto params = Params{};
    for (auto _ : state) {
        auto html = ribomation::wordcount::baseline::run(params);
        benchmark::DoNotOptimize(html);
    }
}
BENCHMARK(baseline_bm)->Unit(benchmark::kMillisecond)->Name("Baseline");

static void reserve_bm(benchmark::State& state) {
    auto params = Params{};
    for (auto _ : state) {
        auto html = ribomation::wordcount::using_reserve::run(params);
        benchmark::DoNotOptimize(html);
    }
}
BENCHMARK(reserve_bm)->Unit(benchmark::kMillisecond)->Name("Using reserve()");

static void characters_bm(benchmark::State& state) {
    auto params = Params{};
    for (auto _ : state) {
        auto html = ribomation::wordcount::char_fn::run(params);
        benchmark::DoNotOptimize(html);
    }
}
BENCHMARK(characters_bm)->Unit(benchmark::kMillisecond)->Name("Opt char fns");

static void memmap_bm(benchmark::State& state) {
    auto params = Params{};
    for (auto _ : state) {
        auto html = ribomation::wordcount::mem_map::run(params);
        benchmark::DoNotOptimize(html);
    }
}
BENCHMARK(memmap_bm)->Unit(benchmark::kMillisecond)->Name("Memory-mapped file");

BENCHMARK_MAIN();

Kör man benchmark programmet, kan det se ut så här.

Yes> ./wordcount-gbench
Run on (8 X 1992 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x4)
  L1 Instruction 32 KiB (x4)
  L2 Unified 256 KiB (x4)
  L3 Unified 8192 KiB (x1)
Load Average: 1.35, 0.89, 0.56
-------------------------------------------------------------
Benchmark                   Time             CPU   Iterations
-------------------------------------------------------------
Baseline                  118 ms          121 ms            5
Using reserve()           116 ms          119 ms            6
Opt char fns             96.7 ms         99.3 ms            7
Memory-mapped file       32.0 ms         32.9 ms           21

Sammanfattning av resultatet

Version Tid Förbättring jmf baseline
Baseline, utan opt flaggor 1470 ms ~0x
Kompileringsflaggor 118 ms ~12.4x
Förallokering 116 ms ~12.7x
Optimerade teckenfunktioner 97 ms ~15.2x
Minnesmappad fil 32 ms ~45.9x

Sammanfattning

I denna artikel har jag stegvis optimerat ett word-count-program som jag tidigare implementerat med C++20/23 ranges och views. Utgångspunkten var en tydlig, men relativt långsam, baseline-version som analyserade Shakespeares samlade verk på cirka 1,5 sekunder.

Genom att först slå på kompilatoroptimeringar (-O3 -march=native) fick vi en stor “gratis” hastighetsökning. Därefter följde mer riktade förbättringar: förallokering av containers och strängar, användning av partial_sort för att bara sortera de mest frekventa orden samt optimerade teckenfunktioner som utnyttjar antagandet om engelsk ASCII-text. Dessa förändringar gav successivt kortare körtider, men utan att påverka programmets funktionalitet.

Den största vinsten kom när filinläsningen byggdes om till att använda en minnesmappad fil (mmap) i kombination med std::span<char> och std::string_view. Då kunde programmet både minska mängden minnesallokeringar och utnyttja operativsystemets virtuella minnessystem mer effektivt, vilket sänkte exekveringstiden från ungefär 1470 ms till runt 35 ms.

Slutligen verifierades förbättringarna med Google Benchmark, där de olika versionerna kördes sida vid sida med identiska parametrar. Förhoppningen är att artikeln inte bara visar hur mycket prestanda man kan plocka ut ur ett till synes enkelt program, utan också ger en konkret metodik för hur man systematiskt kan mäta, resonera om och genomföra optimeringar i modern C++.