Pomiar ilości tekstu i kodu w moich wpisach

Eksperymentalne badanie ilości kodu i tekstu w 68 wpisach na blogu w 4 językach (Perl 5, Raku, Python, Rust) wraz z analizą wydajności I/O i HTTP Keep-Alive.

Daniel Gustaw

Daniel Gustaw

17 min read

Pomiar ilości tekstu i kodu w moich wpisach

W 2017 roku, z czystej ciekawości, postanowiłem sprawdzić, jaką część moich artykułów na blogu stanowi tekst pisany, a jaką kod źródłowy. Napisałem wtedy zwięzły, 21-liniowy program w Perlu, który dał mi szybką odpowiedź.

W 2021 roku – przy okazji eksperymentów z językiem Perl 6 (obecnie Raku) – przepisałem ten skrypt i ku mojemu zaskoczeniu otrzymałem zupełnie inne liczby. Z braku czasu zostawiłem ten temat, lecz niedawno powróciłem do niego, aby przeprowadzić kompleksowe śledztwo.

Okazało się, że tak z pozoru trywialny problem – „zlicz znaki tekstu i kodu na stronie HTML” – kryje pod spodem fascynujący świat inżynieryjnych niuansów. Od błędów w darmowych bibliotekach CPAN, przez subtelności zagnieżdżania w drzewie DOM, aż po specyfikację Unicode (grafemy vs punkty kodowe) i optymalizację warstwy sieciowej HTTP.

W tym artykule zabiorę Cię w podróż przez kolejne próby uzgodnienia wyników w 4 językach programowania (Perl 5, Raku, Python oraz Rust), przedstawiając kompletne statystyki dla 68 artykułów, wykresy ewolucji proporcji kodu oraz niespodziewane wyniki wydajnościowe.


Akt I: Rok 2017 i 21-liniowy skrypt w Perlu 5

Moje pierwsze podejście z 2017 roku opierało się na prostym założeniu: pobieramy stronę główną bloga, wyciągamy z niej odnośniki do artykułów z nagłówków <h2>, pobieramy każdy post z osobna, a następnie zliczamy znaki w elementach tekstowych (<h1>-<h4>, <p>, <li>) oraz w blokach kodu (<pre>).

Oto oryginalny skrypt wykorzystujący bibliotekę HTML::TagParser:

#!/usr/bin/env perl
use warnings;
use strict;
use HTML::TagParser;

my $url = 'https://gustawdaniel.com';
my @tags = ("h1 h2 h3 h4 li p", "pre");

print "|     text |     code | title \n";

my @list = HTML::TagParser->new( $url )->getElementsByTagName( "h2" );
foreach my $elem ( @list ) {
    my $post = HTML::TagParser->new( $url . $elem->firstChild()->getAttribute( "href" ) );
    my @str = ("", "");
    foreach my $i ( (0, 1) ) {
        my @elements = map { $post->getElementsByTagName($_) } split / /, $tags[$i];
        $str[$i] = join("", map { $_->innerText } @elements);
    }
    printf("| %8d | %8d | %-60s \n", (map { $str[$_] =~ y===c } (0, 1)), $elem->innerText);
}

Działało doskonale… aż do momentu, gdy po latach uruchomiłem go ponownie i natrafiłem na dwa istotne problemy.

Problem 1: Brak obsługi HTTPS

Pierwszy błąd przy ponownym uruchomieniu w nowym środowisku brzmiał:

URI::Fetch failed: Protocol scheme 'https' is not supported (LWP::Protocol::https not installed)

Okazało się, że moduł HTML::TagParser pod spodem korzysta z LWP::UserAgent. Domyślnie w Perlu LWP wspiera jedynie protokół http://. Dopiero instalacja systemowego pakietu liblwp-protocol-https-perl (lub przez CPAN: cpanm LWP::Protocol::https) pozwoliła nawiązywać połączenia szyfrowane.

Problem 2: Anatomia Buga w CPAN (HTML::TagParser)

Po naprawieniu HTTPS skrypt zadziałał, lecz dla niektórych wpisów (np. o prawie Zipfa oraz prawie Benforda) pokazywał okrągłe 0 znaków kodu, mimo że artykuły te zawierały mnóstwo bloków <pre>!

Debugowanie wykazało błąd w samej bibliotece HTML::TagParser (HTML/TagParser.pm, linia 257). Moduł ten przy parsowaniu wartości atrybutów w ujęciu podwójnego cudzysłowu " stosował wyrażenie regularne, które ucinało ciąg tekstowy w miejscu wystąpienia… pojedynczego apostrofu '!

Adresy artykułów na blogu wyglądały następująco:

Serwer po zapytaniu o nieistniejący zniekształcony URL zwracał stronę 404 lub przekierowanie na stronę główną, gdzie brakowało tagów <pre>.

Hakerską poprawką w Perlu było sięgnięcie bezpośrednio do wewnętrznej struktury reprezentacji węzła w Perlu ($node->[0]->[$node->[1]]->[2]), w której przechowywany jest nieprzetworzony, surowy tekst atrybutów HTML:

sub get_href {
    my ($node) = @_;
    my $raw = $node->[0]->[$node->[1]]->[2] // "";
    if ($raw =~ /href=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i) {
        return $1 // $2 // $3;
    }
    return $node->getAttribute("href");
}

Błąd w bibliotece wyeliminowany, ale to był dopiero początek odkryć.


Akt II: Przepisanie na Raku (Perl 6) i wyzwanie zagnieżdżania DOM

W 2021 roku spróbowałem przepisać ten algorytm do Raku. W Raku zamiast operować na przestarzałym HTML::TagParser, użyłem nowocześniejszego modułu DOM::Tiny z selektorami CSS:

#!/usr/bin/env raku
use WWW;
use DOM::Tiny;

my $base_url = 'https://gustawdaniel.com';
my $dom = DOM::Tiny.parse(get($base_url));

for $dom.find('h3 a') -> $a {
    my $url  = $a.attr('href');
    my $post = DOM::Tiny.parse(get($url));

    my $text = $post.find('h1, h2, h3, h4, p, li').map(*.all-text).join;
    my $code = $post.find('pre').map(*.all-text).join;

    printf("| %8d | %8d | %-60s \n", $text.chars, $code.chars, $a.all-text.trim);
}

Kod skrócił się do zaledwie 18 linii i stał się wyjątkowo czytelny. Jednak podczas audytu wyników dla wszystkich 68 artykułów na blogu zauważyłem rozbieżności.

W jednym ze starych artykułów z 2017 roku („Application with FOSUserBundle and Google Maps API”) Raku zwrócił 42 469 znaków kodu, podczas gdy inne parsery wskazywały 42 529 znaków.

Dlaczego brakowało 60 znaków?

We wpisie tym znajdowało się aż 70 bloków kodu <pre> zawierających szablony HTML i Twig. Wewnątrz niektórych bloków <pre> znajdowały się zagnieżdżone elementy HTML, np.:

<pre class="astro-code"><code><div class="container eternity-form">...</div></code></pre>

Liniowy przeszukiwacz drzewa w Raku bez przekazywania kontekstu rodzica, natrafiając na wewnętrzny znacznik <div>, „gubił” informację, że znajduje się wewnątrz bloku <pre> i zaliczał zawarty w nim kod HTML do tekstu artykułu!

Rozwiązaniem okazało się rekurencyjne przekazywanie flagi $is_pre w głąb drzewa DOM:

sub collect-text($tree, $is_pre = False) {
    my $t = "";
    my $c = "";
    for $tree.children -> $node {
        if $node ~~ DOM::Tiny::HTML::Text {
            if $is_pre { $c ~= $node.text; } else { $t ~= $node.text; }
        } elsif $node ~~ DOM::Tiny::HTML::Tag {
            my $in_code = $is_pre || $node.tag eq "pre";
            my ($sub_t, $sub_c) = collect-text($node, $in_code);
            $t ~= $sub_t;
            $c ~= $sub_c;
        }
    }
    return ($t, $c);
}

Akt III: Niuans parsujący – spacje i encje HTML

Porównując różne biblioteki parsowania w Pythonie (selectolax), Ruste (scraper), Perlu 5 (Mojo::DOM) oraz Raku (DOM::Tiny), odkryłem kolejne źródła rozbieżności w wynikach:

  1. Encje HTML (&amp;, &lt;, &gt;, &quot;): Niektóre biblioteki zwracają surowy tekst z drzewa DOM wraz z encjami HTML (gdzie &amp; liczy się jako 5 znaków), podczas gdy inne automatycznie dekodują je do postaci znaków &, <, >, " (gdzie & to 1 znak). Aby uzyskać powtarzalność, wszystkie parsery muszą pracować na zdekodowanym tekście.

  2. Elementy <span> generatorów składni (Highlighterów): Współczesne silniki blogowe (np. Astro ze Shiki lub Prism) dzielą kod w blokach <pre> na dziesiątki zagnieżdżonych elementów <span class="line"><span class="token keyword">const</span>...</span>. Niektóre parsery HTML przy operacji all_text doklejają spójniki lub spacje pomiędzy sąsiadującymi tagami <span>. Jeśli parser doda spację po każdym słowie kluczowym ujętym w <span>, wynik zliczenia znaków w kodzie drastycznie wzrośnie!


Akt IV: Tajemnica Unicode – Grafemy vs Punkty Kodowe vs Bajty

Najbardziej fascynujące odkrycie czekało na mnie podczas analizy wpisu #13 (tRPC – super fast development cycle…).

Wyniki wyliczeń tekstu dla tego wpisu wyglądały tak:

Dlaczego Raku uporczywie pokazywało o 1 znak mniej? Przeskanowałem tekst wpisu znak po znaku w poszukiwaniu symboli spoza zakładowej tablicy ASCII. Odpowiedź kryła się we fragmencie zdania:

„I learned tRPC today and fall in love ❤️ instantly…”

Spójrzmy na emoji czerwonego serca ❤️. W standardzie Unicode ten symbol składa się z dwóch punktów kodowych (Code Points):

  1. U+2764 (Czarny kształt serca )
  2. U+FE0F (Niewidzialny modyfikator VARIATION SELECTOR-16, nakazujący wyrenderować kolorowe emoji).

Jak liczą to języki programowania?

Aby Raku zliczał znaki w dokładnie taki sam sposób jak Python, Rust czy Perl 5 (czyli punkty kodowe), wystarczyło zastąpić metodę .chars metodą .codes:

# .codes zlicza punkty kodowe Unicode (Code Points) zamiast grafemów NFG
printf("| %8d | %8d | %-60s \n", $text.codes, $code.codes, $title);

Po tej zmianie Raku osiągnął 100% zgodności co do jednego bajtu z pozostałymi językami.


Akt V: Tabela wyników dla wszystkich 68 artykułów

Po ujednoliceniu algorytmu zliczania i obsłudze zagnieżdżania w DOM, wygenerowałem pełne zestawienie długości tekstu oraz kodu dla wszystkich 68 wpisów opublikowanych na blogu.

Podsumowanie statystyczne:

Tabela pomiarowa:

#Znaki tekstuZnaki koduUdział koduTytuł artykułu
17 77010 78258.1%Leveraging SIMD in Rust for High-Performance Computing
210 7948 75444.8%From MLP to CNN. Neural Networks for MNIST Digit Recognition
37 90614 17264.2%Rust Wasm performance on snake game example
49 37010 51752.9%Activation Functions in Machine Learning
516 4216 08827.0%Machine Learning XOR from Scratch
65 3243 50439.7%LangChain Exemplary Use Cases
71 8358 46182.2%Fastify Prisma REST backend
81 1712 45467.7%Web Push Notifications
92 85410 89879.3%Svelte snake deployed on deno
104 47810 27769.6%Rust implementation of RFC 7396 - JSON Merge Patch
116 2191 59020.4%Tutorial for ESM + CommonJS package creators
122 86063818.2%How to Install Yay on a Pure Arch Linux Docker Image
134 3814569.4%Simplifying Linux Command Line with GPT-CLI (rust, open source)
1410 4248 60245.2%tRPC - super fast development cycle for fullstack typescript apps
151 73956724.6%How to install MongoDB 6 on Fedora 37
164 6852 32533.2%QuickSort implementation in Rust, Typescript and Go
172 6401 82340.8%ZeroMQ pull-push pattern for Node JS
184 0543 10343.4%New Google Identity in Nuxt 3
1917 9754 70320.7%Selected syntax in JavaScript ES2020, ES2021 and ES2022
206 2913 38935.0%CodinGame: Best fit to data - Rust - Regression Analysis
219 73413 41957.9%CodinGame: Derivative Time - Part 1, Recursion (Typescript)
227 97117 23868.4%CodinGame: Quaternion Multiplication - Rust, NodeJS - Parsing, Algebra
235 4537 56358.1%CodinGame: ASCI Art - Rust, NodeJs - Strings, Arrays, Loops
241 53880534.4%Overload Signatures in Typescript
2512 71215 48154.9%Login by Metamask - Rest Backend in Fastify (Node, Typescript, Prisma)
263 0512 71147.1%Login Component in Nuxt (Rest Strapi)
273 6425 46160.0%Maximum Inequality [Linear Search] rust and typescript
286 4275 63546.7%Pulumi - Infrastructure as a Code [ Digital Ocean ]
291 1532 01163.6%Last Occurrence [Linear Search] easy
305 5732 27028.9%Analysis of Zipf’s Law in Node.js
315 6282 18628.0%Retry Policy - How to Handle Random, Unpredictable Errors
322 0121 43841.7%Publishing an update of the package in the AUR repository
333 7261 73531.8%Least Common Multiple - Number Theory
348 0256 50144.8%How to configure SSL in local development
3513 7533 21718.9%Another installation guide for Arch Linux (i3)
3617 6455 76924.6%Benford’s Law for the Fibonacci Sequence in Java, Rust, and Node JS
375 2264888.5%Bolt (always) Lite - MITM, Proxy, Insomnia and Vue
3814 8015 19025.9%Process Control in Node JS
394 16878815.9%Xss attack using script style and image
408 1116 70445.2%Broadcast Channel API
415 95311 48865.9%Analysis of the frequency of altcoin names in the English language corpus
424 9775 39152.0%Scraping the most popular Twitter accounts
431 66700.0%How to create a free email account with custom domain?
442 54973522.4%Telegram Bot in Typescript
452 8292237.3%Installation of a renewable TLS certificate (certbot + apache on Ubuntu)
4611 0633 99726.5%Data scraping in Perl
4713 49410 56143.9%Scraping Facebook in 2021
486 68900.0%How the war for compatibility shaped the frontend?
495 7892 36429.0%We squeeze data from PDF like juice from a lemon
509 9007 39242.7%Fetch, Promise and Template String on example of To Do List in JavaScript
519 6474 42331.4%Communication between Vue components in Meteor
521 8451999.7%Git styled calendar with custom dates
536 2229 66360.8%How many families can fit on the plane - an algorithmics problem
542 71100.0%Scraping WordPress - 4300 court rulings in exchange rate lawsuits without a line of code
559 7725 33535.3%Ruby on Rails - quick introduction
562 63189725.4%Infrastructure as Code (Terraform + Digital Ocean)
572 4911 71840.8%Calculating the Difference Between JSON Files
584 1374 42151.7%Scraping of the Pharmacy Register
5911 2668 42942.8%How to download contact data for 20k lawyers in an hour
605 3994 31844.4%Scraping from money.pl in 30 lines of code.
6117 60216 39748.2%Data Structuring on the Example of CHF NBP Course
6223 41942 52964.5%Application with FOSUserBundle and Google Maps API
637 8762 91627.0%Compilation of PHP 7 interpreter in BunsenLabs
6417 06611 55040.4%Analysis of Apache logs with GoAccess
6511 2659 31345.3%The impact of indexing on search performance in MySQL database
6616 23522 12157.7%Tesseract-OCR and testing selects.
6712 06612 33950.6%Visualization of a dynamic correlation network.
688 13311 65258.9%Data logging in MySql, Ajax, and Behat

Wykresy ewolucji tekstu i kodu

Poniższe wykresy obrazują zmianę objętości artykułów oraz procentowy udział kodu źródłowego na przestrzeni lat:

Ewolucja ilości tekstu i kodu w kolejnych wpisach

Procentowy udział kodu we wpisach


Akt VI: Konfrontacja 4 języków programowania

Stworzyłem spójne implementacje algorytmu zliczania tekstu i kodu w 4 językach:

1. Python (httpx + selectolax)

#!/usr/bin/env python3
import httpx
from selectolax.parser import HTMLParser

BASE_URL = "https://gustawdaniel.com"
res = httpx.get(BASE_URL)
parser = HTMLParser(res.text)

for a in parser.css("h3 > a"):
    path = a.attributes.get("href", "")
    url = path if path.startswith("http") else f"{BASE_URL}{path}"
    post_parser = HTMLParser(httpx.get(url).text)

    code_str = "".join(pre.text(deep=True) for pre in post_parser.css("section pre"))
    for pre in post_parser.css("section pre"):
        pre.decompose()
    text_str = "".join(sec.text(deep=True) for sec in post_parser.css("section"))

    print(f"| {len(text_str):8d} | {len(code_str):8d} | {a.text().strip():<60} |")

2. Rust (ureq + scraper)

use ureq;
use scraper::{Html, Selector};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let body = ureq::get("https://gustawdaniel.com").call()?.into_string()?;
    let fragment = Html::parse_fragment(&body);

    for element in fragment.select(&Selector::parse("h3 > a")?) {
        let path = element.value().attr("href").unwrap_or("");
        let post_body = ureq::get(path).call()?.into_string()?;
        let post_fragment = Html::parse_fragment(&post_body);

        let mut code = String::new();
        let mut text = String::new();

        for section in post_fragment.select(&Selector::parse("section")?) {
            for node in section.descendants() {
                if let Some(t) = node.value().as_text() {
                    if node.parents().any(|p| p.value().as_element().map_or(false, |e| e.name() == "pre")) {
                        code.push_str(t);
                    } else {
                        text.push_str(t);
                    }
                }
            }
        }
        println!("| {:8} | {:8} | {:<60} |", text.chars().count(), code.chars().count(), element.text().collect::<String>());
    }
    Ok(())
}

3. Perl 5 (Mojo::UserAgent + Mojo::DOM)

#!/usr/bin/env perl
use warnings;
use strict;
use Mojo::DOM;
use Mojo::UserAgent;

my $ua   = Mojo::UserAgent->new;
my $main = $ua->get('https://gustawdaniel.com')->res->dom;

sub collect_text {
    my ($node) = @_;
    my ($t, $c) = ("", "");
    for my $child ($node->child_nodes->each) {
        if ($child->type eq "text") {
            $t .= $child->content;
        } elsif ($child->type eq "tag") {
            if ($child->tag eq "pre") {
                $c .= $child->all_text;
            } else {
                my ($st, $sc) = collect_text($child);
                $t .= $st; $c .= $sc;
            }
        }
    }
    return ($t, $c);
}

for my $elem ($main->find('h3 a')->each) {
    my $post = $ua->get($elem->attr('href'))->res->dom;
    my ($text, $code) = ("", "");
    for my $sec ($post->find('section')->each) {
        my ($t, $c) = collect_text($sec);
        $text .= $t; $code .= $c;
    }
    printf("| %8d | %8d | %-60s \n", length($text), length($code), $elem->all_text);
}

Podsumowanie audytu dla wszystkich 68 wpisów:

Porównanie językówLiczba wpisów z 100% identycznymi wynikamiZgodność
Rust vs Python68 / 68100.00% (Wzorzec)
Rust vs Perl 5 (Mojo::DOM)68 / 68100.00% (Wzorzec)
Rust vs Raku (.codes)68 / 68100.00% (Wzorzec)

Wszystkie skrypty dały co do jednego znaku tożsame wyniki.


Akt VII: Wyścig wydajności i Pula Połączeń HTTP

Mając 4 działające i w 100% zgodne skrypty, zmierzyłem wydajność wykonania wszystkich programów (czas rzeczywisty real, czas CPU oraz szczytowe zużycie pamięci operacyjnej Max RSS).

Niespodziewany wynik wstępny

W pierwszej wersji benchmarku skrypt w Perlu 5 z Mojo::UserAgent wykonywał się w czasie 11.39 s, podczas gdy podstawowa wersja w Ruste potrzebowała 5.21 s, a Python 5.74 s.

Zastanawiające było jednak to, dlaczego Python i podstawowy Rust były tylko 2-krotnie szybsze od Perla, mimo że procesor w Rust wykonywał całą pracę w zaledwie 0.37 s!

Powód leżał w warstwie sieciowej HTTP i utrzymywaniu sesji (HTTP Keep-Alive):

  1. Mojo::UserAgent w Perlu oraz zoptymalizowany klient sieciowy reużywają połączenia TCP/TLS.
  2. Podstawowa wersja skryptu w Ruste (rust/src/main.rs) wywoływała statyczną funkcję ureq::get(&url) w pętli. Bez jawnie utworzonego agenta z pulą połączeń, dla każdego z 68 zapytań nawiązywane było nowe połączenie TCP i nowy handshake TLS.
  3. W Pythonie (count_text_and_code.py) wywoływano httpx.get(url) w pętli. Podobnie jak w Ruste, wywołanie httpx.get() tworzy tymczasowego klienta i zamyka gniazdo po każdym zapytaniu. Gdybyśmy użyli with httpx.Client() as client:, Python również utrzymywałby pulę połączeń Keep-Alive.

Reorganizacja w Ruste: rust/src/bin/connection_pool.rs

Tworzymy jawną instancję ureq::Agent::new_with_defaults(), która przechowuje pulę otwartych połączeń TCP i reużywa je dla kolejnych zapytań HTTP:

// rust/src/bin/connection_pool.rs - jawny agent z pulą połączeń
let agent = ureq::Agent::new_with_defaults();

let body: String = agent.get(BASE_URL).call()?.into_string()?;

for element in fragment.select(&selector) {
    // Reużywanie istniejącego połączenia TLS dzięki tej samej instancji agenta:
    let post_body: String = agent.get(&url).call()?.into_string()?;
}

Ostateczne zestawienie benchmarków

Poniższa tabela przedstawia precyzyjne pomiary czasu oraz pamięci RAM zarejestrowane dla poszczególnych implementacji:

Język / WariantBiblioteka HTTP / ParserConnection PoolCzas rzeczywisty (real)Czas CPU (user+sys)Zużycie RAM (Max RSS)
🥇 Rust (connection_pool)ureq::Agent + scraper (Release)Tak3.34 s0.28 s15.05 MB
🥈 Rust (rust Standard)ureq::get + scraper (Release)Nie5.21 s0.37 s14.50 MB
🥉 Python (httpx)httpx.get + selectolaxNie5.74 s0.82 s85.16 MB
Perl 5Mojo::UserAgent + Mojo::DOMTak11.39 s3.14 s42.29 MB
RakuWWW (get) + DOM::TinyNie37.63 s31.93 s388.90 MB

Wykresy porównawcze wydajności

Czas rzeczywisty wykonania (Wall-Clock Real Time)

Czas rzeczywisty wykonania

Zużycie czasu procesora (CPU Time)

Czas procesora CPU

Szczytowe zużycie pamięci RAM (Max RSS)

Szczytowe zużycie pamięci RAM


Podsumowanie

To, co miało być zwykłym wyliczeniem statystyk bloga, zmieniło się w niesamowity test wiedzy inżynieryjnej:

  1. Jakość bibliotek: Nawet popularne pakiety w CPAN mogą zawierać nieoczekiwane błędy w regexach obcinające wartości atrybutów na apostrofach.
  2. Drzewo DOM: Należy precyzyjnie kontrolować kontekst zagnieżdżenia elementów (takich jak <pre>) w kodzie HTML.
  3. Specyfikacja Unicode: Zanim porównasz długość stringów w różnych językach, upewnij się, czy mierzysz grafemy (NFG), punkty kodowe (Code Points), czy bajty UTF-8.
  4. Wydajność i RAM: Rust z pulą połączeń HTTP osiąga imponujący wynik 3.34 s oraz zużywa zaledwie 15 MB RAM (5.7x mniej niż Python i 26x mniej niż Raku).
  5. Wydajność sieciowa: Utrzymywanie trwałej sesji HTTP (Keep-Alive) ma drastycznie większy wpływ na czas wykonania skryptów sieciowych niż sam wybór języka programowania czy szybkość parsera HTML.

Other articles

You can find interesting also.