# Optimering av Word-Count programmet

10 december 2025 | https://www.ribomation.se/blog/2025/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.

```cpp
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.

```shell
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`

```shell
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.

```cpp
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`.

```cpp
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_.

```cpp
//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.

```cpp
//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`.

```cpp
//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`.

```cpp
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.

```cpp
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.

```shell
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.

```cpp
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

```shell
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.

```cpp
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");
```

```shell
--------------------------------------------------------------
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.

```cpp
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`.

```cpp
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

```cpp
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.

```cpp
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.

```cpp
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.

```cpp
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.

```cpp
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

```shell
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.

```cpp
#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.

```shell
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++.
