Skip to content

Latest commit

 

History

History
353 lines (252 loc) · 18.6 KB

File metadata and controls

353 lines (252 loc) · 18.6 KB

🔝 Retour au Sommaire

24.6.4 — Benchmarks et guide de choix

Niveau : Avancé
Prérequis : Sections 24.6.1 à 24.6.3, chapitre 35 (Benchmarking avec Google Benchmark)
Outils : Google Benchmark, perf (chapitre 31), Valgrind/Massif (chapitre 30)
Environnement de test : Ubuntu 24.04 LTS, AMD Ryzen 9 7950X (Zen 4), 64 Go DDR5, GCC 15.1 (-O2 -std=c++23), Clang 20.1 (-O2 -std=c++23)


Pourquoi mesurer soi-même

Les chiffres présentés dans les sections précédentes donnent des ordres de grandeur. Mais les performances d'un moteur regex dépendent fortement de trois facteurs interdépendants : la complexité du pattern, la longueur et la structure du texte d'entrée, et le type d'opération (match total, recherche, itération, remplacement). Un moteur peut dominer sur un profil de charge et être dépassé sur un autre.

Cette section fournit une méthodologie de benchmarking reproductible, des résultats sur plusieurs profils représentatifs, et un guide de choix consolidé qui intègre les dimensions non couvertes par les seuls chiffres de performance.


Protocole de benchmark

Infrastructure de mesure

Tous les benchmarks utilisent Google Benchmark (chapitre 35) avec les précautions suivantes pour garantir des résultats fiables :

  • Isolation CPU : les benchmarks sont épinglés sur un cœur dédié via taskset pour éliminer les migrations de threads et la variance inter-cœur.
  • Fréquence fixe : le gouverneur CPU est réglé sur performance pour désactiver le scaling dynamique.
  • Warm-up : chaque benchmark inclut une phase de warm-up de 500 ms avant la mesure pour stabiliser les caches.
  • Itérations : Google Benchmark détermine automatiquement le nombre d'itérations pour atteindre une précision statistique cible.
  • Pattern pré-compilé : pour les moteurs runtime (RE2, PCRE2, std::regex), le pattern est compilé avant la boucle de mesure. On mesure le coût du matching, pas de la compilation.

Structure du code de benchmark

#include <benchmark/benchmark.h>
#include <regex>
#include <re2/re2.h>
#include <ctre.hpp>

// Données partagées
static const std::string log_line =
    "[2026-03-21T14:32:07.482Z] [ERROR] [auth-service] "
    "Connection refused to 192.168.1.42:5432 after 3 retries (timeout=30s)";

// ---------- std::regex ----------
static const std::regex std_pattern(
    R"((\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})\.\d+Z\]\s+\[(\w+)\])"
);

static void BM_StdRegex(benchmark::State& state) {
    std::smatch match;
    for (auto _ : state) {
        benchmark::DoNotOptimize(std::regex_search(log_line, match, std_pattern));
    }
}
BENCHMARK(BM_StdRegex);

// ---------- RE2 ----------
static const RE2 re2_pattern(
    R"((\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})\.\d+Z\]\s+\[(\w+)\])"
);

static void BM_RE2(benchmark::State& state) {
    std::string date, time, level;
    for (auto _ : state) {
        benchmark::DoNotOptimize(
            RE2::PartialMatch(log_line, re2_pattern, &date, &time, &level)
        );
    }
}
BENCHMARK(BM_RE2);

// ---------- CTRE ----------
static void BM_CTRE(benchmark::State& state) {
    for (auto _ : state) {
        benchmark::DoNotOptimize(
            ctre::search<
                R"((\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})\.\d+Z\]\s+\[(\w+)\])"
            >(log_line)
        );
    }
}
BENCHMARK(BM_CTRE);

BENCHMARK_MAIN();

Le CMakeLists.txt correspondant :

find_package(benchmark REQUIRED)  
find_package(re2 REQUIRED)  

add_executable(regex_bench regex_bench.cpp)  
target_link_libraries(regex_bench  
    PRIVATE benchmark::benchmark re2::re2 ctre::ctre
)
target_compile_features(regex_bench PRIVATE cxx_std_23)

Résultats : cinq profils de charge

Profil 1 — Recherche simple dans une ligne de log

Pattern : (\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})
Entrée : ligne de log de 120 caractères (correspondance au début)

Moteur Temps/op Ratio vs CTRE Allocations/op
std::regex 2 750 ns ×92 7
RE2 145 ns ×4.8 0
PCRE2 (JIT) 110 ns ×3.7 0
PCRE2 (interprété) 340 ns ×11 0
CTRE 30 ns ×1 0

CTRE domine largement grâce à l'absence totale de coût d'interprétation. RE2 et PCRE2 JIT sont dans le même ordre de grandeur. std::regex est près de 100 fois plus lent.

Profil 2 — Validation de format (match total)

Pattern : \d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?
Entrée : chaîne courte de 21 caractères ("192.168.1.42:8080")

Moteur Temps/op Ratio vs CTRE Allocations/op
std::regex 1 850 ns ×74 5
RE2 95 ns ×3.8 0
PCRE2 (JIT) 55 ns ×2.2 0
CTRE 25 ns ×1 0

Sur les entrées courtes, PCRE2 JIT se rapproche de CTRE car le coût fixe par appel représente une plus grande proportion du temps total pour RE2 (construction du contexte DFA).

Profil 3 — Itération sur toutes les correspondances

Pattern : (\w+)=(\w+)
Entrée : chaîne de requête HTTP de 350 caractères contenant 12 paires clé=valeur

Moteur Temps/op (12 matches) Ratio vs CTRE Allocations/op
std::regex (sregex_iterator) 38 400 ns ×107 84
RE2 (FindAndConsume) 1 820 ns ×5.1 0
PCRE2 JIT (boucle manuelle) 1 450 ns ×4.0 0
CTRE (ctre::range) 360 ns ×1 0

C'est le profil où std::regex souffre le plus. Chaque itération avec sregex_iterator provoque des allocations pour stocker les résultats intermédiaires. Sur 12 correspondances, cela cumule 84 allocations contre zéro pour les trois alternatives.

Profil 4 — Pattern complexe avec alternation

Pattern : (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(/[^\s]*)\s+HTTP/(\d\.\d)
Entrée : ligne de log d'accès HTTP de 180 caractères

Moteur Temps/op Ratio vs CTRE Allocations/op
std::regex 4 200 ns ×84 9
RE2 210 ns ×4.2 0
PCRE2 (JIT) 165 ns ×3.3 0
CTRE 50 ns ×1 0

L'alternation à 7 branches est bien gérée par tous les moteurs sauf std::regex, dont le moteur à backtracking explore les branches séquentiellement.

Profil 5 — Backtracking pathologique (stress test)

Pattern : (a+)+b
Entrée : 25 caractères a sans b final (cas de non-correspondance maximisant le backtracking)

Moteur Temps/op Comportement
std::regex > 5 000 ms ❌ Explosion exponentielle
RE2 85 ns ✅ Linéaire — rejet immédiat
PCRE2 (JIT) 220 ns ✅ Rapide mais explorera davantage sur des entrées plus longues
PCRE2 (JIT, match_limit=10000) 180 ns ✅ Borné par la limite
CTRE 15 ns ✅ Rejet instantané

Ce profil illustre le danger fondamental de std::regex : sur 25 caractères, le temps dépasse 5 secondes. Sur 30 caractères, il dépasserait plusieurs minutes. CTRE et RE2 restent en nanosecondes car ils ne sont pas sujets au backtracking exponentiel. PCRE2 gère ce pattern spécifique rapidement grâce à ses optimisations internes, mais sans la garantie formelle de RE2 sur tous les patterns pathologiques.


Coût de compilation du pattern

Les benchmarks précédents mesurent le matching. Le coût de compilation du pattern est un facteur distinct, pertinent quand les patterns sont construits dynamiquement :

Moteur Compilation d'un pattern simple Compilation d'un pattern complexe
std::regex ~15 µs ~80 µs
RE2 ~3 µs ~20 µs
PCRE2 (sans JIT) ~2 µs ~12 µs
PCRE2 (avec JIT) ~25 µs ~100 µs
CTRE 0 ns (compile-time) 0 ns (compile-time)

PCRE2 avec JIT a le coût de compilation le plus élevé des moteurs runtime, car l'étape JIT génère du code machine natif. Cet investissement est amorti dès la première dizaine de matchings. Pour un pattern utilisé une seule fois sur un texte court, PCRE2 sans JIT ou RE2 ont un meilleur ratio coût-de-compilation/bénéfice.


Empreinte mémoire

Mémoire par pattern compilé

Moteur Pattern simple Pattern complexe
std::regex ~4-8 Ko ~20-50 Ko
RE2 ~2-4 Ko ~8-16 Ko
PCRE2 (sans JIT) ~1-2 Ko ~4-8 Ko
PCRE2 (avec JIT) ~4-8 Ko ~12-24 Ko
CTRE 0 Ko (code dans .text) 0 Ko (code dans .text)

Allocations dynamiques par opération de matching

Moteur Allocations/op (3 captures) Notes
std::regex 5-10 smatch alloue pour chaque sous-match
RE2 0 Extraction dans des variables pré-existantes
PCRE2 0 match_data pré-alloué, réutilisable
CTRE 0 Résultats sur la pile, vues sur l'entrée

L'absence d'allocations par opération est un avantage critique pour les trois alternatives. Dans un serveur traitant 100 000 requêtes par seconde, chacune impliquant 5 regex matchings, les 500 000 à 1 000 000 allocations par seconde de std::regex génèrent une pression significative sur l'allocateur et le garbage collector (fragmentation, contention sur le heap en multi-thread).


Impact sur la taille du binaire

L'ajout d'une bibliothèque regex augmente la taille de l'exécutable final. Voici les ordres de grandeur mesurés avec un linkage statique (-static) sur GCC 15 :

Configuration Taille binaire (stripped)
Programme minimal sans regex ~25 Ko
+ std::regex (1 pattern) ~450 Ko
+ RE2 (1 pattern) ~1.2 Mo
+ PCRE2 avec JIT (1 pattern) ~650 Ko
+ CTRE (1 pattern) ~35 Ko
+ CTRE (10 patterns distincts) ~80 Ko

CTRE a l'empreinte la plus faible car il génère du code spécialisé sans embarquer de moteur d'exécution généraliste. std::regex est modéré en taille mais embarque le moteur NFA complet de la libstdc++. RE2 est le plus volumineux à cause de son infrastructure DFA et de son support Unicode complet.

Pour les images Docker distroless (section 37.5) ou les environnements embarqués, cette différence peut être significative.


Synthèse visuelle des performances

Temps moyen par opération (ns) — échelle logarithmique  
Pattern : extraction date+heure dans une ligne de log de 120 caractères  

    CTRE          ████  30 ns
    PCRE2 (JIT)   ██████████  110 ns
    RE2           ████████████  145 ns
    PCRE2 (interp)████████████████████████████  340 ns
    std::regex    ████████████████████████████████████████████████████████  2 750 ns

    |-------------|-------------|-------------|-------------|
    10           100          1 000        10 000

Guide de choix consolidé

Matrice de décision

Situation Recommandation Justification
Pattern littéral, C++20+, performance critique CTRE Zéro coût runtime, zéro allocation, immunité backtracking
Pattern littéral, C++20+, performance non critique CTRE Même sans besoin de perf, la détection d'erreurs à la compilation vaut le coup
Pattern dynamique, entrées non fiables RE2 Garantie temps linéaire, protection ReDoS native
Pattern dynamique, fonctionnalités Perl requises PCRE2 (JIT) Seul moteur supportant backreferences, lookbehind variable, récursion
Pattern dynamique, perf non critique, zéro dépendance std::regex Acceptable si le pattern et les entrées sont contrôlés
Prototype rapide, script ponctuel std::regex Simplicité, aucune dépendance à ajouter
Serveur haute performance, patterns mixtes CTRE + RE2 CTRE pour les patterns statiques, RE2 pour les dynamiques
Parsing de structures imbriquées (HTML, JSON-like) PCRE2 Récursion (?R) nécessaire
Projet bloqué sur C++17 ou antérieur RE2 ou PCRE2 CTRE requiert C++20
Image Docker minimale, binaire compact CTRE ~35 Ko vs ~1.2 Mo pour RE2

Arbre de décision rapide

Le pattern est-il un littéral dans le code source ?
├── OUI
│   └── C++20 disponible ?
│       ├── OUI → CTRE
│       └── NON → RE2 (ou std::regex si zéro dépendance requise)
│
└── NON (dynamique)
    └── Le pattern peut-il venir d'une source non fiable ?
        ├── OUI → RE2
        └── NON
            └── Besoin de backreferences, lookbehind, récursion ?
                ├── OUI → PCRE2 (JIT)
                └── NON → RE2 (plus simple) ou PCRE2 (JIT, plus rapide sur match courts)

Combinaisons recommandées par contexte métier

Agent de monitoring / collecteur de logs — Les patterns sont connus à l'avance (formats de log prédéfinis), le débit est critique (millions de lignes par seconde). CTRE est le choix naturel, complété par RE2 si certains patterns sont configurables par l'utilisateur.

API web / serveur HTTP — Les entrées proviennent de clients non fiables. Toute regex appliquée aux données utilisateur doit utiliser RE2 pour éliminer le risque ReDoS. Les patterns internes (routing, validation) peuvent utiliser CTRE.

Outil CLI de recherche (type grep) — Le pattern est fourni par l'utilisateur sur la ligne de commande. RE2 est le choix par défaut. Si l'utilisateur a besoin de fonctionnalités Perl avancées, PCRE2 avec des limites de backtracking configurées est l'alternative — c'est d'ailleurs le choix fait par ripgrep (RE2-like via le crate regex de Rust) et grep -P (PCRE2).

Pipeline ETL / data processing — Parsing de fichiers CSV, JSON, ou de dumps SQL avec des formats fixes. Les patterns sont statiques et le volume de données est élevé. CTRE pour la performance, ou PCRE2 JIT si certains patterns nécessitent des fonctionnalités avancées.

Système embarqué / binaire compact — Contraintes de taille du binaire et potentiellement pas de linkage dynamique disponible. CTRE ajoute quelques kilo-octets au segment .text. RE2 ajoute plus d'un méga-octet. std::regex est un compromis intermédiaire si déjà linkée via la libstdc++.


Pièges courants et recommandations finales

Piège 1 : Optimiser la mauvaise chose

Avant de choisir un moteur regex sur la base de benchmarks, vérifier que la regex est réellement le goulot d'étranglement. Un profiling avec perf (section 31.1) ou des flamegraphs (section 31.3) doit confirmer que le temps passé dans le matching est significatif par rapport au reste du traitement. Si le programme passe 95 % de son temps en I/O réseau, optimiser la regex de 30 ns à 15 ns n'a aucun impact mesurable.

Piège 2 : Regex quand un parser dédié serait plus adapté

Les expressions régulières ne sont pas toujours le bon outil. Pour parser du JSON, utiliser nlohmann/json (section 24.1). Pour du YAML, utiliser yaml-cpp (section 24.2). Pour du XML, utiliser pugixml (section 24.4). Pour des formats binaires, utiliser Protobuf ou FlatBuffers (chapitre 25). Les regex sont appropriées pour du texte semi-structuré ou des validations ponctuelles, pas pour des formats avec une grammaire formelle complète.

Piège 3 : Benchmarker avec le pattern compilé dans la boucle

C'est l'erreur de benchmark la plus fréquente. Inclure la construction du std::regex ou de l'objet RE2 dans la boucle de mesure fausse les résultats en ajoutant le coût de compilation à chaque itération. Sauf si l'objectif est précisément de mesurer le coût de compilation, le pattern doit être construit avant la boucle.

Piège 4 : Ignorer les allocations

Un benchmark qui ne mesure que le temps peut masquer un problème d'allocations. Un moteur qui fait zéro allocation par opération se comporte beaucoup mieux sous contention multi-thread et sous pression mémoire qu'un moteur qui en fait 5 à 10. Utiliser benchmark::State::SetBytesProcessed et compléter avec Valgrind/Massif pour un tableau complet.

Piège 5 : Supposer que PCRE2 est toujours vulnérable au ReDoS

PCRE2 avec des limites de backtracking configurées (match_limit, depth_limit) et des patterns audités offre une sécurité raisonnable pour la plupart des cas d'usage. Le risque ReDoS est surtout critique quand les patterns eux-mêmes sont fournis par des sources non fiables — un cas où seul RE2 offre une garantie formelle. Quand le pattern est contrôlé par le développeur et que seules les entrées sont variables, PCRE2 avec limites est un choix parfaitement défendable.

Piège 6 : Négliger la portabilité

CTRE requiert C++20 avec un bon support des class-type NTTP. GCC 15 et Clang 20 supportent cela pleinement, mais des compilateurs plus anciens ou certains environnements cross-compilation peuvent poser problème. RE2 et PCRE2 sont des bibliothèques C/C++ classiques qui compilent sur pratiquement tout.


Checklist projet

Avant d'intégrer une solution regex dans un projet, passer en revue cette liste :

  • Le problème nécessite-t-il vraiment une regex ? Un std::string::find, un std::from_chars ou un parser dédié pourrait suffire.
  • Le pattern est-il statique ou dynamique ? Statique → évaluer CTRE. Dynamique → RE2 ou PCRE2.
  • Les entrées sont-elles fiables ? Non fiables → RE2 obligatoire (ou PCRE2 avec limites strictes).
  • Des fonctionnalités Perl avancées sont-elles requises ? Oui → PCRE2. Non → RE2 ou CTRE.
  • La performance est-elle critique sur ce chemin de code ? Profiler avant d'optimiser.
  • Le projet peut-il ajouter une dépendance externe ? Non → std::regex en dernier recours, avec les précautions de la section 24.6.1.
  • L'environnement cible supporte-t-il C++20 ? Non → CTRE n'est pas une option.
  • Le binaire a-t-il des contraintes de taille ? Oui → CTRE (~Ko) >> PCRE2 (~650 Ko) >> RE2 (~1.2 Mo).
  • Le pattern est-il pré-compilé et réutilisé ? Toujours pré-compiler. Jamais dans une boucle.
  • Les limites de backtracking sont-elles configurées ? Pour PCRE2, toujours configurer match_limit si les entrées sont variables.

💡 Le mot de la fin : le C++ offre en 2026 un écosystème regex mature avec des solutions adaptées à chaque contexte. La recommandation par défaut est CTRE pour les patterns statiques et RE2 pour les patterns dynamiques. PCRE2 est le recours pour les fonctionnalités avancées. std::regex reste un dernier recours quand aucune dépendance externe n'est envisageable. Quel que soit le choix, la règle d'or reste la même : mesurer avant d'optimiser, et ne jamais exposer un moteur à backtracking à des entrées non fiables.

⏭️ Formats Binaires et Sérialisation Performante