infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
/***************************************************************************
* Copyright ( C ) 2026 by Vadim Peretokin - vadim . peretokin @ mudlet . org *
* *
* This program is free software ; you can redistribute it and / or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation ; either version 2 of the License , or *
* ( at your option ) any later version . *
* *
* This program is distributed in the hope that it will be useful , *
* but WITHOUT ANY WARRANTY ; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the *
* GNU General Public License for more details . *
* *
* You should have received a copy of the GNU General Public License *
* along with this program ; if not , write to the *
* Free Software Foundation , Inc . , *
* 59 Temple Place - Suite 330 , Boston , MA 02111 - 1307 , USA . *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/*
* Report - only performance baseline for the text and trigger pipelines , for the
* libmudlet refactor ' s " no more than 10% throughput loss " gate ( issue # 9011 ) .
*
* Absolute numbers are meaningless across machines , so nothing is asserted on
* timing and no baseline is committed : the gate is enforced by comparing an
* older and a newer build of this binary on the SAME machine with
* test / compare - perf - baseline . py . The benchmark feeds a fixed , deterministic
* corpus through the production cTelnet : : loopbackTest ( ) path and prints one
* ` METRIC < name > < value > ` line per measurement .
*
fix: new profiles process game text about twice as fast (#9705)
#### Brief overview of PR changes/additions
- The starter UI armed **77 always-active PCRE triggers** (12 chat + 65
vitals) at package load, so every line a game sent was matched against
all of them - and every line one matched was then re-walked in Lua with
all 77 patterns **recompiled from source**, because `rex.match` given a
pattern string compiles it afresh on every call. They are now fronted by
4 triggers (3 chat-routing groups + 1 vitals prefilter) and compiled
once. The 65 vitals shapes and 12 chat shapes are byte-identical and
still do all the reading.
- The plain-text vitals layer now retires itself once GMCP or MSDP holds
the source lock, since `applyVitals` discards its readings from that
point anyway, and re-arms on disconnect.
- `PipelineBenchmark` created its profile through the production
new-profile path, so the starter UI was **inside** the
`text_lines_per_sec` baseline backing the "no more than 10% throughput
loss" gate for #9011 - the guard built to catch this class of regression
could not see it. Pipeline metrics now come from a profile with default
packages suppressed; the shipped configuration is reported separately as
`defaults_*` and gated in its own right.
#### Motivation for adding to Mudlet
Every new 5.0 profile was paying roughly half its text throughput to a
default package, and the perf guard had the cost baked into its own
baseline so nothing flagged it.
#### Other info (issues closed, discussion etc)
Findings C17 and C18 of the 5.0 QA sweep. Bisected there to `69cd06b1c`
- "add: starter interface with health bars, map and chat for new
players" (#9454); the benchmark half is the interaction of that with
`7d67d4bfb` - "infrastructure: perf baseline" (#9509).
Measured on a quiet 16-core box, Release, no ASan, alternating paired
runs so drift is shared between arms:
| workload | before | after | |
| --- | --- | --- | --- |
| `TelnetBenchmark` `benchLargeData`, 1000 lines that match nothing |
22.25 ms `[22.1-22.6]` | 12.0 ms `[11.9-12.2]` | **1.85x** |
| `PipelineBenchmark`, 25k lines of realistic game output, new-user
profile | 9,998 lines/s `[9,856-10,072]` | 16,503 lines/s
`[16,257-16,632]` | **1.65x** |
Complete separation in both (21 and 9 pairs; within-arm spread ±1.7% and
±1.5%, so ~3% is the smallest effect distinguishable from noise - the
effect is 85% and 65%). The bare pipeline measures 116,000 lines/s, so
the starter UI's remaining cost on that corpus is 7.0x, down from 11.6x;
the residual is the capture layer doing its designed work on a corpus
where 1 line in 11 is a tell and another 1 in 11 a vitals prompt.
Two notes for reviewers:
- `config.lua` is bumped to 1.1.0, so mpkg offers the update - but
default packages are installed at profile creation, so **profiles
already created on a 5.0 PTB keep the old copy** until they update it.
- Touches `src/mudlet.cpp` / `src/mudlet.h` /
`test/functional_tests/CMakeLists.txt`, which #9695 also touches; the
CMakeLists hunk will likely conflict trivially (both append a test
file).
**Test case:** create a fresh profile against any game without GMCP,
confirm the health/mana gauges and chat tabs still appear from prompt
and chat lines, then `ctest -R StarterUiTriggerCostTest`.
Assisted-by: Claude:claude-opus-5
2026-08-08 11:04:14 +02:00
* ` text_ * ` , ` trigger_ * ` and ` peak_rss_kb ` come from a profile with the default
* packages suppressed ; ` defaults_ * ` from one carrying them .
*
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
* Built with the functional tests but deliberately NOT registered with ctest by
* default ( report - only and slow ) ; run it directly , or configure with
* - DREGISTER_PERF_BENCHMARK = ON to also get it under ctest :
* QT_QPA_PLATFORM = offscreen . / PipelineBenchmark
*
* Companion for the live - GUI display / echo path is the Stressinator display
* package ; see docs / libmudlet - perf - baseline . md .
*/
# include <QtTest/QtTest>
# include <algorithm>
# include <clocale>
# include <cstdio>
# include <limits>
# include <random>
// Whether this binary is AddressSanitizer-instrumented. Emitted as an invariant
// so the compare script refuses an ASan-vs-release comparison (their absolute
// numbers are incomparable). Clang reports it through __has_feature; GCC through
// __SANITIZE_ADDRESS__ (and any Qt __has_feature shim harmlessly returns 0, so
// the GCC path still catches it).
# if defined(__has_feature)
# if __has_feature(address_sanitizer)
# define BENCH_BUILD_ASAN 1
# endif
# endif
# if !defined(BENCH_BUILD_ASAN) && defined(__SANITIZE_ADDRESS__)
# define BENCH_BUILD_ASAN 1
# endif
# ifndef BENCH_BUILD_ASAN
# define BENCH_BUILD_ASAN 0
# endif
# include "Host.h"
# include "MudletInstanceCoordinator.h"
# include "TLuaInterpreter.h"
# include "TMainConsole.h"
# include "TTrigger.h"
# include "TelnetServerStub.h"
# include "ctelnet.h"
# include "dlgConnectionProfiles.h"
# include "mudlet.h"
extern void qInitResources_mudlet ( ) ;
extern void qInitResources_qm ( ) ;
extern void qInitResources_additional_splash_screens ( ) ;
extern void qInitResources_mudlet_fonts_common ( ) ;
extern void qInitResources_mudlet_fonts_posix ( ) ;
static void initializeQRCResources ( ) ;
class PipelineBenchmark : public QObject
{
Q_OBJECT
private :
TelnetServerStub * mpServer = nullptr ;
const QString mHostname = qsl ( " Perf-Baseline-Host " ) ;
const QString mLocalhost = qsl ( " localhost " ) ;
quint16 mPort = 0 ;
// Both phases feed these identical bytes, so text and trigger numbers are
// directly comparable.
QByteArray mCorpus ;
int mCorpusLines = 0 ;
qint64 mCorpusBytes = 0 ;
double mTextBestPassSeconds = 0.0 ;
// Report the FASTEST pass, not the average: the least-disturbed pass isolates
// intrinsic speed from transient CPU contention (this often runs on a shared/CI
// box), which is what a before/after gate wants. More passes raise the chance
// one lands in a clean window; TConsole's 10 000-line scrollback cap bounds
// memory regardless of corpus size.
static constexpr int kCorpusLines = 25000 ;
static constexpr int kFeedPasses = 6 ;
// Seeded with a constant so the corpus bytes are identical on every run and
// every machine; one line per '\n' keeps the processed-line count exact.
static QByteArray generateCorpus ( int lines , int & outLineCount )
{
std : : mt19937 rng ( 0xC0FFEEu ) ;
auto pick = [ & rng ] ( int n ) {
return static_cast < int > ( rng ( ) % static_cast < unsigned > ( n ) ) ;
} ;
// Varied building blocks so substring/regex triggers have realistic text
// to match (and mostly miss) against.
static const char * const rooms [ ] = { " Village Square " , " Dark Forest " , " Ancient Tower " , " Misty Harbour " , " Goblin Warren " } ;
static const char * const actors [ ] = { " Gandalf " , " Aragorn " , " Legolas " , " Gimli " , " Frodo " } ;
static const char * const foes [ ] = { " orc " , " goblin " , " troll " , " wraith " , " spider " } ;
static const char * const items [ ] = { " a rusty sword " , " a wooden shield " , " a healing potion " , " a silver ring " , " a torn map " } ;
QByteArray out ;
out . reserve ( static_cast < qsizetype > ( lines ) * 96 ) ;
int count = 0 ;
for ( int i = 0 ; i < lines ; + + i ) {
switch ( pick ( 11 ) ) {
case 0 :
out + = " You are standing in a dark forest. The trees tower above you. " ;
break ;
case 1 :
out + = " \x1b [1;31mThe " ;
out + = foes [ pick ( 5 ) ] ;
out + = " hits you for " ;
out + = QByteArray : : number ( pick ( 40 ) + 1 ) ;
out + = " damage! \x1b [0m " ;
break ;
case 2 :
out + = " \x1b [32mThe " ;
out + = rooms [ pick ( 5 ) ] ;
out + = " \x1b [0m " ;
break ;
case 3 :
out + = " \x1b [36m " ;
out + = actors [ pick ( 5 ) ] ;
out + = " tells you 'meet me at the tower' \x1b [0m " ;
break ;
case 4 :
out + = " You gain " ;
out + = QByteArray : : number ( pick ( 500 ) + 1 ) ;
out + = " experience points. " ;
break ;
case 5 :
out + = " The caf \xc3 \xa9 serves cr \xc3 \xa8 me br \xc3 \xbb l \xc3 \xa9 "
" e. \xe6 \x97 \xa5 \xe6 \x9c \xac \xe8 \xaa \x9e \xe2 \x98 \xba " ;
break ;
case 6 :
out + = " \x1b [33mHP: " ;
out + = QByteArray : : number ( pick ( 100 ) + 1 ) ;
out + = " /100 MP: " ;
out + = QByteArray : : number ( pick ( 50 ) + 1 ) ;
out + = " /50 \x1b [0m " ;
break ;
case 7 :
out + = " You are carrying: " ;
out + = items [ pick ( 5 ) ] ;
out + = " , " ;
out + = items [ pick ( 5 ) ] ;
out + = " , and " ;
out + = QByteArray : : number ( pick ( 100 ) ) ;
out + = " gold coins. " ;
break ;
case 8 :
out + = " \x1b [38;5;208mA glowing ember drifts past the " ;
out + = rooms [ pick ( 5 ) ] ;
out + = " . \x1b [0m " ;
break ;
case 9 :
// One long single-line paragraph, to force word-wrap passes the
// short templates never exercise.
out + = " The ancient library stretches away in every direction, its towering shelves crammed with "
" mouldering tomes, cracked scrolls and curiosities gathered across a hundred forgotten ages; "
" dust drifts through the amber shafts of light that spill from the high stained-glass windows, "
" and somewhere far above, unseen, the slow tick of a great clock marks out the patient centuries "
" as you catch your breath and let your gaze wander over the winding aisles ahead. " ;
break ;
default :
out + = " A gentle breeze carries the scent of pine and distant woodsmoke across the clearing "
" as you catch your breath and survey the winding path ahead. " ;
break ;
}
out + = " \r \n " ;
+ + count ;
}
outLineCount = count ;
return out ;
}
fix: exact-match triggers no longer copy every line they check (#9853)
#### Brief overview of PR changes/additions
- `match_exact_match()` did `QString text = haystack;` then chopped a
trailing newline. The assignment is copy-on-write and cheap, but
`chop()` mutates, forcing the detach: a heap allocation plus a full
character copy of the line.
- The newline is always present - `TMainConsole::runTriggers()` appends
one to every line before dispatch (`TMainConsole.cpp:1570`) - so the
chop always fires and the copy always happens, once per exact-match
pattern, per reachable trigger, per line of game text.
- Use a `QStringView`. Chopping a view moves only its own end pointer,
so nothing is allocated, and the comparison against the needle is
unchanged. Both chop one UTF-16 code unit, so behaviour is identical for
every input.
#### Motivation for adding to Mudlet
Removes a per-line heap allocation and line copy from the trigger
matching path, which runs for every line of game text.
#### Other info (issues closed, discussion etc)
Measured with `PipelineBenchmark` on macOS/arm64, Release,
`-DUSE_SANITIZER=""`, two binaries from one toolchain run in 24
interleaved ABBA pairs so run-order effects cancel: trigger throughput
**+3.28%** (t=6.41), trigger overhead **-6.37%** (t=-4.94). The
text-only control moved +0.23% (t=-0.56, 12/24 paired wins), i.e. no
effect, which is the check that the trigger deltas are real.
Worth stating plainly: the stock benchmark corpus contains **no**
exact-match patterns, so `match_exact_match()` is never entered by it.
Twelve were added locally purely to measure this. The gain therefore
scales with how many exact-match patterns a profile actually has, and is
zero for a profile with none.
**Test case:** behaviour-neutral, so the checks are for regressions
around where the chop lands. Exact-match triggers fire correctly on a
plain ASCII line, on a line with an accented character, on one with an
em dash, and on one containing an emoji (a surrogate pair, i.e. two
UTF-16 code units - the case most likely to expose a code-unit-based
chop). A line with trailing whitespace before the newline correctly does
*not* match. Verified against before/after builds on macOS, plus 5
trigger functional test suites.
---------
Signed-off-by: Jay Howard <jay.patrick.howard@gmail.com>
2026-08-13 08:47:25 -05:00
// A realistic ~four-dozen always-active trigger mix. Some patterns never
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
// match, so the miss path is costed too. Lua-code matchers are excluded and
// every trigger carries an empty script, so a match runs the full regex +
// capture path (the cost we want) but TTrigger::execute() returns before any
// Lua runs - keeping Lua execution and buffer pollution out of the timed path.
// Prompt triggers are omitted: they need a GA signal a loopback feed cannot send.
int installTriggerSet ( Host * host , bool & allOk )
{
int n = 0 ;
auto addKind = [ & ] ( const QStringList & patterns , int kind , bool multiline ) {
QList < int > kinds ;
kinds . reserve ( patterns . size ( ) ) ;
for ( int i = 0 ; i < patterns . size ( ) ; + + i ) {
kinds < < kind ;
}
auto * pT = new TTrigger ( qsl ( " bench_%1 " ) . arg ( n ) , patterns , kinds , multiline , host ) ;
pT - > setIsFolder ( false ) ;
pT - > setTemporary ( false ) ;
pT - > setConditionLineDelta ( 5 ) ;
pT - > setIsActive ( true ) ;
allOk = pT - > registerTrigger ( ) & & allOk ;
allOk = pT - > setScript ( QString ( ) ) & & allOk ;
allOk = pT - > state ( ) & & allOk ;
+ + n ;
} ;
auto addColor = [ & ] ( int ansiFg , int ansiBg ) {
auto * pT = new TTrigger ( nullptr , host ) ;
pT - > setIsFolder ( false ) ;
pT - > setTemporary ( false ) ;
allOk = pT - > setupTmpColorTrigger ( ansiFg , ansiBg ) & & allOk ;
pT - > setIsActive ( true ) ;
allOk = pT - > registerTrigger ( ) & & allOk ;
allOk = pT - > setScript ( QString ( ) ) & & allOk ;
allOk = pT - > state ( ) & & allOk ;
pT - > setName ( qsl ( " bench_%1 " ) . arg ( n ) ) ;
+ + n ;
} ;
for ( const QString & s :
{ qsl ( " forest " ) , qsl ( " orc " ) , qsl ( " gold " ) , qsl ( " experience " ) , qsl ( " sword " ) , qsl ( " tower " ) , qsl ( " damage " ) , qsl ( " coins " ) , qsl ( " café " ) , qsl ( " Square " ) , qsl ( " dragon " ) , qsl ( " teleport " ) } ) {
addKind ( { s } , REGEX_SUBSTRING , false ) ;
}
for ( const QString & r : { qsl ( " ^( \\ w+) tells you '(.+)'$ " ) ,
qsl ( " You gain ( \\ d+) experience " ) ,
qsl ( " hits you for ( \\ d+) damage " ) ,
qsl ( " HP: ( \\ d+)/( \\ d+) MP: ( \\ d+)/( \\ d+) " ) ,
qsl ( " carrying: (.+)$ " ) ,
qsl ( " ( \\ d+) gold coins " ) ,
qsl ( " The ( \\ w+ \\ w+) " ) ,
qsl ( " ^A glowing ( \\ w+) " ) ,
qsl ( " whisper from ( \\ w+): " ) ,
qsl ( " ^ \\ [( \\ d{2}):( \\ d{2}) \\ ] " ) ,
qsl ( " reaches level ( \\ d+) " ) ,
qsl ( " ( \\ w+) arrives from the ( \\ w+) " ) } ) {
addKind ( { r } , REGEX_PERL , false ) ;
}
for ( const QString & s : { qsl ( " You are " ) , qsl ( " The " ) , qsl ( " HP: " ) , qsl ( " You gain " ) } ) {
addKind ( { s } , REGEX_BEGIN_OF_LINE_SUBSTRING , false ) ;
}
fix: exact-match triggers no longer copy every line they check (#9853)
#### Brief overview of PR changes/additions
- `match_exact_match()` did `QString text = haystack;` then chopped a
trailing newline. The assignment is copy-on-write and cheap, but
`chop()` mutates, forcing the detach: a heap allocation plus a full
character copy of the line.
- The newline is always present - `TMainConsole::runTriggers()` appends
one to every line before dispatch (`TMainConsole.cpp:1570`) - so the
chop always fires and the copy always happens, once per exact-match
pattern, per reachable trigger, per line of game text.
- Use a `QStringView`. Chopping a view moves only its own end pointer,
so nothing is allocated, and the comparison against the needle is
unchanged. Both chop one UTF-16 code unit, so behaviour is identical for
every input.
#### Motivation for adding to Mudlet
Removes a per-line heap allocation and line copy from the trigger
matching path, which runs for every line of game text.
#### Other info (issues closed, discussion etc)
Measured with `PipelineBenchmark` on macOS/arm64, Release,
`-DUSE_SANITIZER=""`, two binaries from one toolchain run in 24
interleaved ABBA pairs so run-order effects cancel: trigger throughput
**+3.28%** (t=6.41), trigger overhead **-6.37%** (t=-4.94). The
text-only control moved +0.23% (t=-0.56, 12/24 paired wins), i.e. no
effect, which is the check that the trigger deltas are real.
Worth stating plainly: the stock benchmark corpus contains **no**
exact-match patterns, so `match_exact_match()` is never entered by it.
Twelve were added locally purely to measure this. The gain therefore
scales with how many exact-match patterns a profile actually has, and is
zero for a profile with none.
**Test case:** behaviour-neutral, so the checks are for regressions
around where the chop lands. Exact-match triggers fire correctly on a
plain ASCII line, on a line with an accented character, on one with an
em dash, and on one containing an emoji (a surrogate pair, i.e. two
UTF-16 code units - the case most likely to expose a code-unit-based
chop). A line with trailing whitespace before the newline correctly does
*not* match. Verified against before/after builds on macOS, plus 5
trigger functional test suites.
---------
Signed-off-by: Jay Howard <jay.patrick.howard@gmail.com>
2026-08-13 08:47:25 -05:00
// Exact-match patterns cost the whole line on every call, so they are
// costed at the same count as the substring group.
for ( const QString & s : { qsl ( " You are hungry. " ) ,
qsl ( " You are thirsty. " ) ,
qsl ( " It is pitch black. " ) ,
qsl ( " The door is closed. " ) ,
qsl ( " You have no keys. " ) ,
qsl ( " Nothing happens. " ) ,
qsl ( " You feel better. " ) ,
qsl ( " Your wounds close. " ) ,
qsl ( " The orc dies. " ) ,
qsl ( " You are hidden. " ) ,
qsl ( " A cool breeze blows. " ) ,
qsl ( " You cannot go that way. " ) } ) {
addKind ( { s } , REGEX_EXACT_MATCH , false ) ;
}
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
addColor ( 1 , TTrigger : : scmIgnored ) ;
addColor ( 2 , TTrigger : : scmIgnored ) ;
addColor ( 3 , TTrigger : : scmIgnored ) ;
addColor ( 6 , TTrigger : : scmIgnored ) ;
addKind ( { qsl ( " The ( \\ w+) hits you " ) , qsl ( " damage " ) } , REGEX_PERL , true ) ;
addKind ( { qsl ( " ( \\ w+) tells you " ) , qsl ( " tower " ) } , REGEX_PERL , true ) ;
return n ;
}
double feedCorpusBestPass ( Host * host , int passes )
{
double best = std : : numeric_limits < double > : : max ( ) ;
for ( int i = 0 ; i < passes ; + + i ) {
QElapsedTimer timer ;
timer . start ( ) ;
host - > mTelnet . loopbackTest ( mCorpus ) ;
best = std : : min ( best , timer . nsecsElapsed ( ) / 1.0e9 ) ;
}
return best ;
}
static void emitMetric ( const char * name , double value )
{
std : : printf ( " METRIC %s %.2f \n " , name , value ) ;
std : : fflush ( stdout ) ;
}
static void emitMetric ( const char * name , qint64 value )
{
std : : printf ( " METRIC %s %lld \n " , name , value ) ;
std : : fflush ( stdout ) ;
}
// Process-wide peak RSS in kB (VmHWM never decreases). /proc pseudo-files
// report a size of 0, so QFile::atEnd() is immediately true and readLine()
// loops never start - read it all in one go.
static qint64 readPeakRssKb ( )
{
# if defined(Q_OS_LINUX)
QFile status ( qsl ( " /proc/self/status " ) ) ;
if ( ! status . open ( QIODevice : : ReadOnly | QIODevice : : Text ) ) {
return - 1 ;
}
const QList < QByteArray > lines = status . readAll ( ) . split ( ' \n ' ) ;
for ( const QByteArray & line : lines ) {
if ( line . startsWith ( " VmHWM: " ) ) {
const QList < QByteArray > parts = line . simplified ( ) . split ( ' ' ) ;
if ( parts . size ( ) > = 2 ) {
return parts . at ( 1 ) . toLongLong ( ) ;
}
}
}
return - 1 ;
# else
return - 1 ;
# endif
}
private slots :
void initTestCase ( )
{
// QApplication's construction adopts the environment locale, which on some
// machines makes printf("%f") emit comma decimals the compare script cannot
// parse. Force C numeric formatting for every METRIC line, independent of
// whatever the environment or Lua startup leaves LC_NUMERIC at.
std : : setlocale ( LC_NUMERIC , " C " ) ;
initializeQRCResources ( ) ;
mCorpus = generateCorpus ( kCorpusLines , mCorpusLines ) ;
mCorpusBytes = mCorpus . size ( ) ;
// An invariant, emitted here so it is present regardless of which bench
// slots run: the compare script rejects an ASan-vs-release comparison.
emitMetric ( " build_asan " , static_cast < qint64 > ( BENCH_BUILD_ASAN ) ) ;
qInfo ( ) . nospace ( ) < < " Corpus: " < < mCorpusLines < < " lines, " < < mCorpusBytes < < " bytes " ;
}
void init ( )
{
mpServer = new TelnetServerStub ( qApp ) ;
// Ephemeral port (0) so parallel worktree runs never collide; read the
// actual port back afterwards.
mpServer - > start ( mLocalhost , 0 ) ;
mPort = mpServer - > serverPort ( ) ;
mudlet : : start ( ) ;
mudlet : : self ( ) - > setupConfig ( ) ;
mudlet : : self ( ) - > takeOwnershipOfInstanceCoordinator ( std : : make_unique < MudletInstanceCoordinator > ( " MudletInstanceCoordinator " ) ) ;
mudlet : : self ( ) - > init ( ) ;
mudlet : : self ( ) - > setStorePasswordsSecurely ( false ) ;
deleteProfileDirectory ( mHostname ) ;
}
void cleanup ( )
{
delete mpServer ;
mpServer = nullptr ;
deleteProfileDirectory ( mHostname ) ;
delete mudlet : : self ( ) ;
}
void benchTextPipeline ( )
{
Host * host = startProfile ( ) ;
QVERIFY ( host ) ;
fix: new profiles process game text about twice as fast (#9705)
#### Brief overview of PR changes/additions
- The starter UI armed **77 always-active PCRE triggers** (12 chat + 65
vitals) at package load, so every line a game sent was matched against
all of them - and every line one matched was then re-walked in Lua with
all 77 patterns **recompiled from source**, because `rex.match` given a
pattern string compiles it afresh on every call. They are now fronted by
4 triggers (3 chat-routing groups + 1 vitals prefilter) and compiled
once. The 65 vitals shapes and 12 chat shapes are byte-identical and
still do all the reading.
- The plain-text vitals layer now retires itself once GMCP or MSDP holds
the source lock, since `applyVitals` discards its readings from that
point anyway, and re-arms on disconnect.
- `PipelineBenchmark` created its profile through the production
new-profile path, so the starter UI was **inside** the
`text_lines_per_sec` baseline backing the "no more than 10% throughput
loss" gate for #9011 - the guard built to catch this class of regression
could not see it. Pipeline metrics now come from a profile with default
packages suppressed; the shipped configuration is reported separately as
`defaults_*` and gated in its own right.
#### Motivation for adding to Mudlet
Every new 5.0 profile was paying roughly half its text throughput to a
default package, and the perf guard had the cost baked into its own
baseline so nothing flagged it.
#### Other info (issues closed, discussion etc)
Findings C17 and C18 of the 5.0 QA sweep. Bisected there to `69cd06b1c`
- "add: starter interface with health bars, map and chat for new
players" (#9454); the benchmark half is the interaction of that with
`7d67d4bfb` - "infrastructure: perf baseline" (#9509).
Measured on a quiet 16-core box, Release, no ASan, alternating paired
runs so drift is shared between arms:
| workload | before | after | |
| --- | --- | --- | --- |
| `TelnetBenchmark` `benchLargeData`, 1000 lines that match nothing |
22.25 ms `[22.1-22.6]` | 12.0 ms `[11.9-12.2]` | **1.85x** |
| `PipelineBenchmark`, 25k lines of realistic game output, new-user
profile | 9,998 lines/s `[9,856-10,072]` | 16,503 lines/s
`[16,257-16,632]` | **1.65x** |
Complete separation in both (21 and 9 pairs; within-arm spread ±1.7% and
±1.5%, so ~3% is the smallest effect distinguishable from noise - the
effect is 85% and 65%). The bare pipeline measures 116,000 lines/s, so
the starter UI's remaining cost on that corpus is 7.0x, down from 11.6x;
the residual is the capture layer doing its designed work on a corpus
where 1 line in 11 is a tell and another 1 in 11 a vitals prompt.
Two notes for reviewers:
- `config.lua` is bumped to 1.1.0, so mpkg offers the update - but
default packages are installed at profile creation, so **profiles
already created on a 5.0 PTB keep the old copy** until they update it.
- Touches `src/mudlet.cpp` / `src/mudlet.h` /
`test/functional_tests/CMakeLists.txt`, which #9695 also touches; the
CMakeLists hunk will likely conflict trivially (both append a test
file).
**Test case:** create a fresh profile against any game without GMCP,
confirm the health/mana gauges and chat tabs still appear from prompt
and chat lines, then `ctest -R StarterUiTriggerCostTest`.
Assisted-by: Claude:claude-opus-5
2026-08-08 11:04:14 +02:00
QVERIFY ( noTriggersAreRunningYet ( host ) ) ;
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
const double seconds = feedCorpusBestPass ( host , kFeedPasses ) ;
mTextBestPassSeconds = seconds ;
// A silently-disconnected pipeline would report absurdly good numbers, so
// prove data flowed: the console must sit near its 10 000-line scrollback cap.
const int bufferedLines = host - > mpConsole - > buffer . getLastLineNumber ( ) ;
QVERIFY2 ( bufferedLines > 1000 , qPrintable ( qsl ( " console buffer only holds %1 lines - the pipeline did not process the corpus " ) . arg ( bufferedLines ) ) ) ;
emitMetric ( " text_corpus_lines " , static_cast < qint64 > ( mCorpusLines ) ) ;
emitMetric ( " text_corpus_bytes " , mCorpusBytes ) ;
emitMetric ( " text_lines_per_sec " , mCorpusLines / seconds ) ;
emitMetric ( " text_mb_per_sec " , ( mCorpusBytes / 1.0e6 ) / seconds ) ;
emitMetric ( " text_best_pass_ms " , seconds * 1000.0 ) ;
}
improve: the ISO 8859-1 decoder no longer allocates a string for every character (#9856)
#### Brief overview of PR changes/additions
- The `ISO 8859-1` branch of `translateToPlainTextInner()` wrapped each
decoded character in a temporary `QString` before appending it, costing
one heap allocation per character on the per-character decode path.
- Append the bare `QChar` instead, which is what the adjacent branch
five lines above (bytes below 128 for table-based encodings) already
does.
- Second commit adds a `latin1_*` phase to `PipelineBenchmark`, which
previously set no server encoding and so never entered this branch at
all.
#### Motivation for adding to Mudlet
Removes a heap allocation per character of received text for every
profile using ISO 8859-1 — worth about **23% more throughput** on that
decode path.
#### Other info (issues closed, discussion etc)
**Measured** with the new `latin1_*` phase on macOS/arm64, Release,
`-DUSE_SANITIZER=""`, two binaries from one toolchain run in 12
interleaved ABBA pairs so run-order effects cancel:
| metric | before | after | delta | pairs favouring | t |
|---|---|---|---|---|---|
| `latin1_lines_per_sec` | 79,629 | 98,322 | **+23.47%** | 12/12 |
+28.61 |
| `latin1_best_pass_ms` | 314.0 | 254.3 | **-19.01%** | 12/12 | -25.58 |
| `text_best_pass_ms` (control) | 294.9 | 296.2 | +0.42% | 4/12 | +0.13
|
ISO 8859-1 has no lookup table, so **every** byte takes the changed line
— roughly 2.4 million heap allocations removed per pass over the 2.4 MB
corpus. The control metric not moving is the check that the delta is
real.
Note the first commit's message says "No benchmark figure is quoted";
that statement is superseded by the second commit and the table above.
Caution when reading the phases: do not compare `latin1_*` against
`text_*` directly. Latin-1 decoding is intrinsically cheaper than UTF-8,
so that phase reads faster regardless of this change. Only the
before/after of the same metric is meaningful.
This is an inconsistency rather than a deliberate choice: both branches
were written in the same commit (#969), five lines apart, and the ISO
8859-1 one was never revisited. `git log -L` over these lines shows only
incidental touches since — CP437 support in #3579, the GBK/GB18030
decoder, a comment typo fix in #1495, a signed/unsigned cast pass, and
the brace-formatting pass in #1115.
Behaviour is unchanged by construction: both forms evaluate the same
`QChar::fromLatin1(ch)` and hand the identical `QChar` to
`QString::append()`; only the temporary `QString` disappears.
**Test case:** `lua setServerEncoding("ISO 8859-1")` then `lua
feedTriggers("caf\233 na\239ve \253\254 \160\176\191\208\247\n", false)`
renders `café naïve ýþ °¿Ð÷`, exercising bytes 0xE9, 0xEF, 0xFD, 0xFE,
0xA0, 0xB0, 0xBF, 0xD0 and 0xF7 through the changed line. Output is
byte-identical before and after, verified against before/after builds on
macOS.
Note for anyone testing: do not use byte 0xFF. `CHAR_END_OF_FILE '\xff'`
(`TStringUtils.h:32`) is Mudlet's internal line-commit and prompt marker
— `CHAR_IS_COMMIT_CHAR` lists it beside `\n` and `\r`, and
`TBuffer.cpp:1668`/`:1684` use it to set `promptBuffer` — so it can
never arrive as displayable text regardless of this change.
`GlyphOverflowTest`, `CopyAsImageTest`, `WindowBackgroundTest`,
`ProfileRoundTripTest`, `TriggerSameLineMatchTest`, `cTelnetBufferTest`,
`TelnetSgrDefaultColorTest` and `TelnetStringSequenceRecoveryTest` all
pass.
---------
Signed-off-by: Jay Howard <jay.patrick.howard@gmail.com>
2026-08-13 08:48:41 -05:00
// ISO 8859-1 has no lookup table, so every received byte takes the single-byte
// branch of the decoder - unlike the default encoding, which never enters it.
// Decoding the UTF-8 corpus as Latin-1 yields mojibake, which is irrelevant:
// the byte count through that branch is what is being timed.
void benchLatin1Decode ( )
{
Host * host = startProfile ( ) ;
QVERIFY ( host ) ;
const auto result = host - > mTelnet . setEncoding ( " ISO 8859-1 " , false ) ;
QVERIFY2 ( result . first , qPrintable ( result . second ) ) ;
const double seconds = feedCorpusBestPass ( host , kFeedPasses ) ;
const int bufferedLines = host - > mpConsole - > buffer . getLastLineNumber ( ) ;
QVERIFY2 ( bufferedLines > 1000 , qPrintable ( qsl ( " console buffer only holds %1 lines - the pipeline did not process the corpus " ) . arg ( bufferedLines ) ) ) ;
emitMetric ( " latin1_lines_per_sec " , mCorpusLines / seconds ) ;
emitMetric ( " latin1_mb_per_sec " , ( mCorpusBytes / 1.0e6 ) / seconds ) ;
emitMetric ( " latin1_best_pass_ms " , seconds * 1000.0 ) ;
}
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
void benchTriggerEngine ( )
{
Host * host = startProfile ( ) ;
QVERIFY ( host ) ;
fix: new profiles process game text about twice as fast (#9705)
#### Brief overview of PR changes/additions
- The starter UI armed **77 always-active PCRE triggers** (12 chat + 65
vitals) at package load, so every line a game sent was matched against
all of them - and every line one matched was then re-walked in Lua with
all 77 patterns **recompiled from source**, because `rex.match` given a
pattern string compiles it afresh on every call. They are now fronted by
4 triggers (3 chat-routing groups + 1 vitals prefilter) and compiled
once. The 65 vitals shapes and 12 chat shapes are byte-identical and
still do all the reading.
- The plain-text vitals layer now retires itself once GMCP or MSDP holds
the source lock, since `applyVitals` discards its readings from that
point anyway, and re-arms on disconnect.
- `PipelineBenchmark` created its profile through the production
new-profile path, so the starter UI was **inside** the
`text_lines_per_sec` baseline backing the "no more than 10% throughput
loss" gate for #9011 - the guard built to catch this class of regression
could not see it. Pipeline metrics now come from a profile with default
packages suppressed; the shipped configuration is reported separately as
`defaults_*` and gated in its own right.
#### Motivation for adding to Mudlet
Every new 5.0 profile was paying roughly half its text throughput to a
default package, and the perf guard had the cost baked into its own
baseline so nothing flagged it.
#### Other info (issues closed, discussion etc)
Findings C17 and C18 of the 5.0 QA sweep. Bisected there to `69cd06b1c`
- "add: starter interface with health bars, map and chat for new
players" (#9454); the benchmark half is the interaction of that with
`7d67d4bfb` - "infrastructure: perf baseline" (#9509).
Measured on a quiet 16-core box, Release, no ASan, alternating paired
runs so drift is shared between arms:
| workload | before | after | |
| --- | --- | --- | --- |
| `TelnetBenchmark` `benchLargeData`, 1000 lines that match nothing |
22.25 ms `[22.1-22.6]` | 12.0 ms `[11.9-12.2]` | **1.85x** |
| `PipelineBenchmark`, 25k lines of realistic game output, new-user
profile | 9,998 lines/s `[9,856-10,072]` | 16,503 lines/s
`[16,257-16,632]` | **1.65x** |
Complete separation in both (21 and 9 pairs; within-arm spread ±1.7% and
±1.5%, so ~3% is the smallest effect distinguishable from noise - the
effect is 85% and 65%). The bare pipeline measures 116,000 lines/s, so
the starter UI's remaining cost on that corpus is 7.0x, down from 11.6x;
the residual is the capture layer doing its designed work on a corpus
where 1 line in 11 is a tell and another 1 in 11 a vitals prompt.
Two notes for reviewers:
- `config.lua` is bumped to 1.1.0, so mpkg offers the update - but
default packages are installed at profile creation, so **profiles
already created on a 5.0 PTB keep the old copy** until they update it.
- Touches `src/mudlet.cpp` / `src/mudlet.h` /
`test/functional_tests/CMakeLists.txt`, which #9695 also touches; the
CMakeLists hunk will likely conflict trivially (both append a test
file).
**Test case:** create a fresh profile against any game without GMCP,
confirm the health/mana gauges and chat tabs still appear from prompt
and chat lines, then `ctest -R StarterUiTriggerCostTest`.
Assisted-by: Claude:claude-opus-5
2026-08-08 11:04:14 +02:00
QVERIFY ( noTriggersAreRunningYet ( host ) ) ;
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
bool triggersOk = true ;
const int triggerCount = installTriggerSet ( host , triggersOk ) ;
QVERIFY2 ( triggerCount > 0 , " no triggers were installed " ) ;
QVERIFY2 ( triggersOk , " a trigger failed to compile, register or take its script " ) ;
fix: new profiles process game text about twice as fast (#9705)
#### Brief overview of PR changes/additions
- The starter UI armed **77 always-active PCRE triggers** (12 chat + 65
vitals) at package load, so every line a game sent was matched against
all of them - and every line one matched was then re-walked in Lua with
all 77 patterns **recompiled from source**, because `rex.match` given a
pattern string compiles it afresh on every call. They are now fronted by
4 triggers (3 chat-routing groups + 1 vitals prefilter) and compiled
once. The 65 vitals shapes and 12 chat shapes are byte-identical and
still do all the reading.
- The plain-text vitals layer now retires itself once GMCP or MSDP holds
the source lock, since `applyVitals` discards its readings from that
point anyway, and re-arms on disconnect.
- `PipelineBenchmark` created its profile through the production
new-profile path, so the starter UI was **inside** the
`text_lines_per_sec` baseline backing the "no more than 10% throughput
loss" gate for #9011 - the guard built to catch this class of regression
could not see it. Pipeline metrics now come from a profile with default
packages suppressed; the shipped configuration is reported separately as
`defaults_*` and gated in its own right.
#### Motivation for adding to Mudlet
Every new 5.0 profile was paying roughly half its text throughput to a
default package, and the perf guard had the cost baked into its own
baseline so nothing flagged it.
#### Other info (issues closed, discussion etc)
Findings C17 and C18 of the 5.0 QA sweep. Bisected there to `69cd06b1c`
- "add: starter interface with health bars, map and chat for new
players" (#9454); the benchmark half is the interaction of that with
`7d67d4bfb` - "infrastructure: perf baseline" (#9509).
Measured on a quiet 16-core box, Release, no ASan, alternating paired
runs so drift is shared between arms:
| workload | before | after | |
| --- | --- | --- | --- |
| `TelnetBenchmark` `benchLargeData`, 1000 lines that match nothing |
22.25 ms `[22.1-22.6]` | 12.0 ms `[11.9-12.2]` | **1.85x** |
| `PipelineBenchmark`, 25k lines of realistic game output, new-user
profile | 9,998 lines/s `[9,856-10,072]` | 16,503 lines/s
`[16,257-16,632]` | **1.65x** |
Complete separation in both (21 and 9 pairs; within-arm spread ±1.7% and
±1.5%, so ~3% is the smallest effect distinguishable from noise - the
effect is 85% and 65%). The bare pipeline measures 116,000 lines/s, so
the starter UI's remaining cost on that corpus is 7.0x, down from 11.6x;
the residual is the capture layer doing its designed work on a corpus
where 1 line in 11 is a tell and another 1 in 11 a vitals prompt.
Two notes for reviewers:
- `config.lua` is bumped to 1.1.0, so mpkg offers the update - but
default packages are installed at profile creation, so **profiles
already created on a 5.0 PTB keep the old copy** until they update it.
- Touches `src/mudlet.cpp` / `src/mudlet.h` /
`test/functional_tests/CMakeLists.txt`, which #9695 also touches; the
CMakeLists hunk will likely conflict trivially (both append a test
file).
**Test case:** create a fresh profile against any game without GMCP,
confirm the health/mana gauges and chat tabs still appear from prompt
and chat lines, then `ctest -R StarterUiTriggerCostTest`.
Assisted-by: Claude:claude-opus-5
2026-08-08 11:04:14 +02:00
// trigger_overhead_ms subtracts the text pass, so the count reported has
// to be the count actually running.
const int rootTriggers = static_cast < int > ( host - > getTriggerUnit ( ) - > getTriggerRootNodeList ( ) . size ( ) ) ;
QVERIFY2 ( rootTriggers = = triggerCount ,
qPrintable ( qsl ( " installed %1 root triggers but %2 are running - something else registered triggers on this profile " ) . arg ( triggerCount ) . arg ( rootTriggers ) ) ) ;
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
const double seconds = feedCorpusBestPass ( host , kFeedPasses ) ;
const int bufferedLines = host - > mpConsole - > buffer . getLastLineNumber ( ) ;
QVERIFY2 ( bufferedLines > 1000 , qPrintable ( qsl ( " console buffer only holds %1 lines - the pipeline did not process the corpus " ) . arg ( bufferedLines ) ) ) ;
// Untimed sentinel proving TriggerUnit consumes what the loopback path
// feeds - a disconnected trigger engine would just flatter the timed numbers.
auto * sentinel = new TTrigger ( qsl ( " bench_sentinel " ) , { qsl ( " __bench_sentinel__ " ) } , { REGEX_SUBSTRING } , false , host ) ;
sentinel - > setIsFolder ( false ) ;
sentinel - > setTemporary ( false ) ;
sentinel - > setIsActive ( true ) ;
QVERIFY ( sentinel - > registerTrigger ( ) ) ;
QVERIFY ( sentinel - > setScript ( qsl ( " benchSentinelFired = true " ) ) ) ;
QVERIFY ( sentinel - > state ( ) ) ;
QByteArray probe { " __bench_sentinel__ \r \n " } ;
host - > mTelnet . loopbackTest ( probe ) ;
QVERIFY2 ( host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " assert(benchSentinelFired) " ) ) , " sentinel trigger did not fire - the trigger engine is not seeing pipeline data " ) ;
emitMetric ( " trigger_count " , static_cast < qint64 > ( triggerCount ) ) ;
emitMetric ( " trigger_lines_per_sec " , mCorpusLines / seconds ) ;
emitMetric ( " trigger_mb_per_sec " , ( mCorpusBytes / 1.0e6 ) / seconds ) ;
emitMetric ( " trigger_best_pass_ms " , seconds * 1000.0 ) ;
if ( mTextBestPassSeconds > 0.0 ) {
// Trigger throughput includes the text-pipeline cost, which dilutes a
// matcher-only regression ~4x; subtracting isolates it (valid because
// both phases feed identical bytes).
emitMetric ( " trigger_overhead_ms " , ( seconds - mTextBestPassSeconds ) * 1000.0 ) ;
}
}
// VmHWM is process-wide and monotonic, so reading it after the feed phases
// captures the true peak for the whole run.
void benchPeakMemory ( )
{
Host * host = startProfile ( ) ;
QVERIFY ( host ) ;
fix: new profiles process game text about twice as fast (#9705)
#### Brief overview of PR changes/additions
- The starter UI armed **77 always-active PCRE triggers** (12 chat + 65
vitals) at package load, so every line a game sent was matched against
all of them - and every line one matched was then re-walked in Lua with
all 77 patterns **recompiled from source**, because `rex.match` given a
pattern string compiles it afresh on every call. They are now fronted by
4 triggers (3 chat-routing groups + 1 vitals prefilter) and compiled
once. The 65 vitals shapes and 12 chat shapes are byte-identical and
still do all the reading.
- The plain-text vitals layer now retires itself once GMCP or MSDP holds
the source lock, since `applyVitals` discards its readings from that
point anyway, and re-arms on disconnect.
- `PipelineBenchmark` created its profile through the production
new-profile path, so the starter UI was **inside** the
`text_lines_per_sec` baseline backing the "no more than 10% throughput
loss" gate for #9011 - the guard built to catch this class of regression
could not see it. Pipeline metrics now come from a profile with default
packages suppressed; the shipped configuration is reported separately as
`defaults_*` and gated in its own right.
#### Motivation for adding to Mudlet
Every new 5.0 profile was paying roughly half its text throughput to a
default package, and the perf guard had the cost baked into its own
baseline so nothing flagged it.
#### Other info (issues closed, discussion etc)
Findings C17 and C18 of the 5.0 QA sweep. Bisected there to `69cd06b1c`
- "add: starter interface with health bars, map and chat for new
players" (#9454); the benchmark half is the interaction of that with
`7d67d4bfb` - "infrastructure: perf baseline" (#9509).
Measured on a quiet 16-core box, Release, no ASan, alternating paired
runs so drift is shared between arms:
| workload | before | after | |
| --- | --- | --- | --- |
| `TelnetBenchmark` `benchLargeData`, 1000 lines that match nothing |
22.25 ms `[22.1-22.6]` | 12.0 ms `[11.9-12.2]` | **1.85x** |
| `PipelineBenchmark`, 25k lines of realistic game output, new-user
profile | 9,998 lines/s `[9,856-10,072]` | 16,503 lines/s
`[16,257-16,632]` | **1.65x** |
Complete separation in both (21 and 9 pairs; within-arm spread ±1.7% and
±1.5%, so ~3% is the smallest effect distinguishable from noise - the
effect is 85% and 65%). The bare pipeline measures 116,000 lines/s, so
the starter UI's remaining cost on that corpus is 7.0x, down from 11.6x;
the residual is the capture layer doing its designed work on a corpus
where 1 line in 11 is a tell and another 1 in 11 a vitals prompt.
Two notes for reviewers:
- `config.lua` is bumped to 1.1.0, so mpkg offers the update - but
default packages are installed at profile creation, so **profiles
already created on a 5.0 PTB keep the old copy** until they update it.
- Touches `src/mudlet.cpp` / `src/mudlet.h` /
`test/functional_tests/CMakeLists.txt`, which #9695 also touches; the
CMakeLists hunk will likely conflict trivially (both append a test
file).
**Test case:** create a fresh profile against any game without GMCP,
confirm the health/mana gauges and chat tabs still appear from prompt
and chat lines, then `ctest -R StarterUiTriggerCostTest`.
Assisted-by: Claude:claude-opus-5
2026-08-08 11:04:14 +02:00
QVERIFY ( noTriggersAreRunningYet ( host ) ) ;
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
// Feed one pass so the peak still reflects pipeline work when this slot
// runs on its own.
feedCorpusBestPass ( host , 1 ) ;
// Skip the metric entirely when the read fails (non-Linux, or /proc
// unavailable) rather than emitting a bogus -1 the compare script would
// read as a real value.
const qint64 peakRssKb = readPeakRssKb ( ) ;
if ( peakRssKb > = 0 ) {
emitMetric ( " peak_rss_kb " , peakRssKb ) ;
}
}
fix: new profiles process game text about twice as fast (#9705)
#### Brief overview of PR changes/additions
- The starter UI armed **77 always-active PCRE triggers** (12 chat + 65
vitals) at package load, so every line a game sent was matched against
all of them - and every line one matched was then re-walked in Lua with
all 77 patterns **recompiled from source**, because `rex.match` given a
pattern string compiles it afresh on every call. They are now fronted by
4 triggers (3 chat-routing groups + 1 vitals prefilter) and compiled
once. The 65 vitals shapes and 12 chat shapes are byte-identical and
still do all the reading.
- The plain-text vitals layer now retires itself once GMCP or MSDP holds
the source lock, since `applyVitals` discards its readings from that
point anyway, and re-arms on disconnect.
- `PipelineBenchmark` created its profile through the production
new-profile path, so the starter UI was **inside** the
`text_lines_per_sec` baseline backing the "no more than 10% throughput
loss" gate for #9011 - the guard built to catch this class of regression
could not see it. Pipeline metrics now come from a profile with default
packages suppressed; the shipped configuration is reported separately as
`defaults_*` and gated in its own right.
#### Motivation for adding to Mudlet
Every new 5.0 profile was paying roughly half its text throughput to a
default package, and the perf guard had the cost baked into its own
baseline so nothing flagged it.
#### Other info (issues closed, discussion etc)
Findings C17 and C18 of the 5.0 QA sweep. Bisected there to `69cd06b1c`
- "add: starter interface with health bars, map and chat for new
players" (#9454); the benchmark half is the interaction of that with
`7d67d4bfb` - "infrastructure: perf baseline" (#9509).
Measured on a quiet 16-core box, Release, no ASan, alternating paired
runs so drift is shared between arms:
| workload | before | after | |
| --- | --- | --- | --- |
| `TelnetBenchmark` `benchLargeData`, 1000 lines that match nothing |
22.25 ms `[22.1-22.6]` | 12.0 ms `[11.9-12.2]` | **1.85x** |
| `PipelineBenchmark`, 25k lines of realistic game output, new-user
profile | 9,998 lines/s `[9,856-10,072]` | 16,503 lines/s
`[16,257-16,632]` | **1.65x** |
Complete separation in both (21 and 9 pairs; within-arm spread ±1.7% and
±1.5%, so ~3% is the smallest effect distinguishable from noise - the
effect is 85% and 65%). The bare pipeline measures 116,000 lines/s, so
the starter UI's remaining cost on that corpus is 7.0x, down from 11.6x;
the residual is the capture layer doing its designed work on a corpus
where 1 line in 11 is a tell and another 1 in 11 a vitals prompt.
Two notes for reviewers:
- `config.lua` is bumped to 1.1.0, so mpkg offers the update - but
default packages are installed at profile creation, so **profiles
already created on a 5.0 PTB keep the old copy** until they update it.
- Touches `src/mudlet.cpp` / `src/mudlet.h` /
`test/functional_tests/CMakeLists.txt`, which #9695 also touches; the
CMakeLists hunk will likely conflict trivially (both append a test
file).
**Test case:** create a fresh profile against any game without GMCP,
confirm the health/mana gauges and chat tabs still appear from prompt
and chat lines, then `ctest -R StarterUiTriggerCostTest`.
Assisted-by: Claude:claude-opus-5
2026-08-08 11:04:14 +02:00
// Must run after benchPeakMemory: VmHWM is process-wide and monotonic, so
// the bare peak_rss_kb has to be read before any packaged profile exists.
// defaults_peak_rss_kb is then the high-water mark including this pass, and
// its excess over peak_rss_kb is what the packages cost.
void benchDefaultPackages ( )
{
Host * host = startProfile ( DefaultPackages : : Install ) ;
QVERIFY ( host ) ;
const int rootTriggers = static_cast < int > ( host - > getTriggerUnit ( ) - > getTriggerRootNodeList ( ) . size ( ) ) ;
// Needs a fresh HOME/XDG_CONFIG_HOME: the starter UI is gated on
// mudlet::experiencedMudletPlayer(), which answers from the machine's
// own Mudlet history, and without it this slot silently measures the
// same thing as benchTextPipeline. A trigger count would not catch that
// - the other default packages register root folders of their own.
QVERIFY2 ( host - > mInstalledPackages . contains ( qsl ( " mudlet-base-ui " ) ) ,
" the starter UI is not installed, so this profile is not the one a new user gets and defaults_* "
" would describe something else entirely. Re-run under a fresh HOME and XDG_CONFIG_HOME. " ) ;
const double seconds = feedCorpusBestPass ( host , kFeedPasses ) ;
const int bufferedLines = host - > mpConsole - > buffer . getLastLineNumber ( ) ;
QVERIFY2 ( bufferedLines > 1000 , qPrintable ( qsl ( " console buffer only holds %1 lines - the pipeline did not process the corpus " ) . arg ( bufferedLines ) ) ) ;
emitMetric ( " defaults_root_triggers " , static_cast < qint64 > ( rootTriggers ) ) ;
emitMetric ( " defaults_text_lines_per_sec " , mCorpusLines / seconds ) ;
emitMetric ( " defaults_text_best_pass_ms " , seconds * 1000.0 ) ;
const qint64 peakRssKb = readPeakRssKb ( ) ;
if ( peakRssKb > = 0 ) {
emitMetric ( " defaults_peak_rss_kb " , peakRssKb ) ;
}
}
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
private :
fix: new profiles process game text about twice as fast (#9705)
#### Brief overview of PR changes/additions
- The starter UI armed **77 always-active PCRE triggers** (12 chat + 65
vitals) at package load, so every line a game sent was matched against
all of them - and every line one matched was then re-walked in Lua with
all 77 patterns **recompiled from source**, because `rex.match` given a
pattern string compiles it afresh on every call. They are now fronted by
4 triggers (3 chat-routing groups + 1 vitals prefilter) and compiled
once. The 65 vitals shapes and 12 chat shapes are byte-identical and
still do all the reading.
- The plain-text vitals layer now retires itself once GMCP or MSDP holds
the source lock, since `applyVitals` discards its readings from that
point anyway, and re-arms on disconnect.
- `PipelineBenchmark` created its profile through the production
new-profile path, so the starter UI was **inside** the
`text_lines_per_sec` baseline backing the "no more than 10% throughput
loss" gate for #9011 - the guard built to catch this class of regression
could not see it. Pipeline metrics now come from a profile with default
packages suppressed; the shipped configuration is reported separately as
`defaults_*` and gated in its own right.
#### Motivation for adding to Mudlet
Every new 5.0 profile was paying roughly half its text throughput to a
default package, and the perf guard had the cost baked into its own
baseline so nothing flagged it.
#### Other info (issues closed, discussion etc)
Findings C17 and C18 of the 5.0 QA sweep. Bisected there to `69cd06b1c`
- "add: starter interface with health bars, map and chat for new
players" (#9454); the benchmark half is the interaction of that with
`7d67d4bfb` - "infrastructure: perf baseline" (#9509).
Measured on a quiet 16-core box, Release, no ASan, alternating paired
runs so drift is shared between arms:
| workload | before | after | |
| --- | --- | --- | --- |
| `TelnetBenchmark` `benchLargeData`, 1000 lines that match nothing |
22.25 ms `[22.1-22.6]` | 12.0 ms `[11.9-12.2]` | **1.85x** |
| `PipelineBenchmark`, 25k lines of realistic game output, new-user
profile | 9,998 lines/s `[9,856-10,072]` | 16,503 lines/s
`[16,257-16,632]` | **1.65x** |
Complete separation in both (21 and 9 pairs; within-arm spread ±1.7% and
±1.5%, so ~3% is the smallest effect distinguishable from noise - the
effect is 85% and 65%). The bare pipeline measures 116,000 lines/s, so
the starter UI's remaining cost on that corpus is 7.0x, down from 11.6x;
the residual is the capture layer doing its designed work on a corpus
where 1 line in 11 is a tell and another 1 in 11 a vitals prompt.
Two notes for reviewers:
- `config.lua` is bumped to 1.1.0, so mpkg offers the update - but
default packages are installed at profile creation, so **profiles
already created on a 5.0 PTB keep the old copy** until they update it.
- Touches `src/mudlet.cpp` / `src/mudlet.h` /
`test/functional_tests/CMakeLists.txt`, which #9695 also touches; the
CMakeLists hunk will likely conflict trivially (both append a test
file).
**Test case:** create a fresh profile against any game without GMCP,
confirm the health/mana gauges and chat tabs still appear from prompt
and chat lines, then `ctest -R StarterUiTriggerCostTest`.
Assisted-by: Claude:claude-opus-5
2026-08-08 11:04:14 +02:00
enum class DefaultPackages { Skip , Install } ;
// Called before the benchmark installs any of its own, so anything running
// came from elsewhere and would be timed as pipeline cost.
bool noTriggersAreRunningYet ( Host * host )
{
const size_t rootTriggers = host - > getTriggerUnit ( ) - > getTriggerRootNodeList ( ) . size ( ) ;
if ( rootTriggers = = 0 ) {
return true ;
}
qWarning ( " %s " ,
qPrintable ( qsl ( " %1 root triggers are running on a profile that should have none - a package or a "
" leftover profile is being measured as pipeline cost " )
. arg ( rootTriggers ) ) ) ;
return false ;
}
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
// Mirrors the profile-creation helper the other functional tests use.
fix: new profiles process game text about twice as fast (#9705)
#### Brief overview of PR changes/additions
- The starter UI armed **77 always-active PCRE triggers** (12 chat + 65
vitals) at package load, so every line a game sent was matched against
all of them - and every line one matched was then re-walked in Lua with
all 77 patterns **recompiled from source**, because `rex.match` given a
pattern string compiles it afresh on every call. They are now fronted by
4 triggers (3 chat-routing groups + 1 vitals prefilter) and compiled
once. The 65 vitals shapes and 12 chat shapes are byte-identical and
still do all the reading.
- The plain-text vitals layer now retires itself once GMCP or MSDP holds
the source lock, since `applyVitals` discards its readings from that
point anyway, and re-arms on disconnect.
- `PipelineBenchmark` created its profile through the production
new-profile path, so the starter UI was **inside** the
`text_lines_per_sec` baseline backing the "no more than 10% throughput
loss" gate for #9011 - the guard built to catch this class of regression
could not see it. Pipeline metrics now come from a profile with default
packages suppressed; the shipped configuration is reported separately as
`defaults_*` and gated in its own right.
#### Motivation for adding to Mudlet
Every new 5.0 profile was paying roughly half its text throughput to a
default package, and the perf guard had the cost baked into its own
baseline so nothing flagged it.
#### Other info (issues closed, discussion etc)
Findings C17 and C18 of the 5.0 QA sweep. Bisected there to `69cd06b1c`
- "add: starter interface with health bars, map and chat for new
players" (#9454); the benchmark half is the interaction of that with
`7d67d4bfb` - "infrastructure: perf baseline" (#9509).
Measured on a quiet 16-core box, Release, no ASan, alternating paired
runs so drift is shared between arms:
| workload | before | after | |
| --- | --- | --- | --- |
| `TelnetBenchmark` `benchLargeData`, 1000 lines that match nothing |
22.25 ms `[22.1-22.6]` | 12.0 ms `[11.9-12.2]` | **1.85x** |
| `PipelineBenchmark`, 25k lines of realistic game output, new-user
profile | 9,998 lines/s `[9,856-10,072]` | 16,503 lines/s
`[16,257-16,632]` | **1.65x** |
Complete separation in both (21 and 9 pairs; within-arm spread ±1.7% and
±1.5%, so ~3% is the smallest effect distinguishable from noise - the
effect is 85% and 65%). The bare pipeline measures 116,000 lines/s, so
the starter UI's remaining cost on that corpus is 7.0x, down from 11.6x;
the residual is the capture layer doing its designed work on a corpus
where 1 line in 11 is a tell and another 1 in 11 a vitals prompt.
Two notes for reviewers:
- `config.lua` is bumped to 1.1.0, so mpkg offers the update - but
default packages are installed at profile creation, so **profiles
already created on a 5.0 PTB keep the old copy** until they update it.
- Touches `src/mudlet.cpp` / `src/mudlet.h` /
`test/functional_tests/CMakeLists.txt`, which #9695 also touches; the
CMakeLists hunk will likely conflict trivially (both append a test
file).
**Test case:** create a fresh profile against any game without GMCP,
confirm the health/mana gauges and chat tabs still appear from prompt
and chat lines, then `ctest -R StarterUiTriggerCostTest`.
Assisted-by: Claude:claude-opus-5
2026-08-08 11:04:14 +02:00
Host * startProfile ( DefaultPackages defaultPackages = DefaultPackages : : Skip )
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
{
fix: new profiles process game text about twice as fast (#9705)
#### Brief overview of PR changes/additions
- The starter UI armed **77 always-active PCRE triggers** (12 chat + 65
vitals) at package load, so every line a game sent was matched against
all of them - and every line one matched was then re-walked in Lua with
all 77 patterns **recompiled from source**, because `rex.match` given a
pattern string compiles it afresh on every call. They are now fronted by
4 triggers (3 chat-routing groups + 1 vitals prefilter) and compiled
once. The 65 vitals shapes and 12 chat shapes are byte-identical and
still do all the reading.
- The plain-text vitals layer now retires itself once GMCP or MSDP holds
the source lock, since `applyVitals` discards its readings from that
point anyway, and re-arms on disconnect.
- `PipelineBenchmark` created its profile through the production
new-profile path, so the starter UI was **inside** the
`text_lines_per_sec` baseline backing the "no more than 10% throughput
loss" gate for #9011 - the guard built to catch this class of regression
could not see it. Pipeline metrics now come from a profile with default
packages suppressed; the shipped configuration is reported separately as
`defaults_*` and gated in its own right.
#### Motivation for adding to Mudlet
Every new 5.0 profile was paying roughly half its text throughput to a
default package, and the perf guard had the cost baked into its own
baseline so nothing flagged it.
#### Other info (issues closed, discussion etc)
Findings C17 and C18 of the 5.0 QA sweep. Bisected there to `69cd06b1c`
- "add: starter interface with health bars, map and chat for new
players" (#9454); the benchmark half is the interaction of that with
`7d67d4bfb` - "infrastructure: perf baseline" (#9509).
Measured on a quiet 16-core box, Release, no ASan, alternating paired
runs so drift is shared between arms:
| workload | before | after | |
| --- | --- | --- | --- |
| `TelnetBenchmark` `benchLargeData`, 1000 lines that match nothing |
22.25 ms `[22.1-22.6]` | 12.0 ms `[11.9-12.2]` | **1.85x** |
| `PipelineBenchmark`, 25k lines of realistic game output, new-user
profile | 9,998 lines/s `[9,856-10,072]` | 16,503 lines/s
`[16,257-16,632]` | **1.65x** |
Complete separation in both (21 and 9 pairs; within-arm spread ±1.7% and
±1.5%, so ~3% is the smallest effect distinguishable from noise - the
effect is 85% and 65%). The bare pipeline measures 116,000 lines/s, so
the starter UI's remaining cost on that corpus is 7.0x, down from 11.6x;
the residual is the capture layer doing its designed work on a corpus
where 1 line in 11 is a tell and another 1 in 11 a vitals prompt.
Two notes for reviewers:
- `config.lua` is bumped to 1.1.0, so mpkg offers the update - but
default packages are installed at profile creation, so **profiles
already created on a 5.0 PTB keep the old copy** until they update it.
- Touches `src/mudlet.cpp` / `src/mudlet.h` /
`test/functional_tests/CMakeLists.txt`, which #9695 also touches; the
CMakeLists hunk will likely conflict trivially (both append a test
file).
**Test case:** create a fresh profile against any game without GMCP,
confirm the health/mana gauges and chat tabs still appear from prompt
and chat lines, then `ctest -R StarterUiTriggerCostTest`.
Assisted-by: Claude:claude-opus-5
2026-08-08 11:04:14 +02:00
mudlet : : self ( ) - > mSkipDefaultPackageInstall = ( defaultPackages = = DefaultPackages : : Skip ) ;
infrastructure: add performance baseline benchmark for the text and trigger pipeline (#9509)
#### Brief overview of PR changes/additions
- Adds `test/functional_tests/PipelineBenchmark.cpp` - a headless,
deterministic, report-only benchmark. It feeds a fixed 25,000-line
corpus (plain text, ANSI SGR colour, UTF-8, and long wrapping-heavy
prose adopted from the Stressinator display package) through the
production `cTelnet -> TBuffer -> TConsole -> TriggerUnit` path via
`loopbackTest()`, and prints `METRIC` lines: text-pipeline throughput,
trigger-engine throughput with a realistic ~34-trigger set, derived
trigger overhead, and peak RSS.
- Adds `test/compare-perf-baseline.py` - the primary workflow. Given an
older and a newer build run on the **same machine**, it parses their
`METRIC` output, prints per-metric deltas, and exits non-zero on a
PASS/FAIL against the 10% gate.
- `test/functional_tests/CMakeLists.txt` builds the benchmark **always**
but keeps it **out of the default ctest suite**; it is report-only and
slow, so it should not run on every CI pipeline. Opt in with
`-DREGISTER_PERF_BENCHMARK=ON` to also register it with ctest.
- Adds `docs/libmudlet-perf-baseline.md` documenting the before/after
workflow. **No canonical, machine-specific numbers are committed** - the
figures in the doc are explicitly illustrative.
#### Motivation for adding to Mudlet
The libmudlet refactor's "no more than 10% throughput loss" gate is
unenforceable without a reproducible way to measure it. Absolute numbers
are meaningless across machines, so this provides a deterministic
harness plus a same-machine before/after comparison tool that turns the
gate into a mechanical PASS/FAIL.
#### Other info (issues closed, discussion etc)
Part of the libmudlet refactor (#8681, #9011) - referenced, not closed.
- **Report-only**: it makes no timing assertions (absolute speed varies
wildly between machines and CI runners), but it does assert the pipeline
genuinely processed data - console buffer fill, every trigger
compiled/registered, and an untimed sentinel trigger firing - so a
silently-disconnected pipeline fails instead of reporting inflated
numbers.
- Each phase feeds the corpus 6 times and reports the **fastest pass**:
the least-disturbed pass isolates intrinsic speed from transient CPU
contention, keeping run-to-run spread ~2% even on a loaded machine.
- **Companion, not a replacement, for the live-GUI path.**
`PipelineBenchmark` runs offscreen and covers the telnet -> buffer ->
trigger core (the piece the refactor moves). The **Stressinator display
benchmark** covers the on-screen render/echo path on a live build; its
wrapping-heavy prose has been adopted into this corpus. Between them
they cover bytes-off-the-socket to pixels-on-screen.
Assisted-by: Claude:claude-fable-5
**Test case:**
Primary workflow - build an older and a newer tree on the same machine,
then compare:
```
flock /tmp/mudlet-functional-tests.lock \
test/compare-perf-baseline.py --run \
../mudlet-before/build/test/functional_tests/PipelineBenchmark \
build/test/functional_tests/PipelineBenchmark
```
Or run the benchmark once directly (it is built even without the ctest
opt-in):
```
QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 \
./build/test/functional_tests/PipelineBenchmark
```
Illustrative output (absolute values vary per machine; nothing is
asserted on timing):
```
METRIC text_corpus_lines 25000
METRIC text_corpus_bytes 1436934
METRIC text_lines_per_sec 4281.46
METRIC text_mb_per_sec 0.41
METRIC text_best_pass_ms 5839.12
METRIC trigger_count 34
METRIC trigger_lines_per_sec 3323.25
METRIC trigger_mb_per_sec 0.32
METRIC trigger_best_pass_ms 7523.40
METRIC trigger_overhead_ms 1683.64
METRIC peak_rss_kb 1402384
```
To drive it through ctest instead, configure with
`-DREGISTER_PERF_BENCHMARK=ON`, then `ctest -R PipelineBenchmark -V`.
2026-07-27 22:01:25 +02:00
const QString port = QString : : number ( mPort ) ;
QTimer : : singleShot ( 0 , qApp , [ this , port ] ( ) {
mudlet : : self ( ) - > startAutoLogin ( { } ) ;
QTest : : qWait ( 100 ) ;
QTest : : mouseClick ( mudlet : : self ( ) - > mpConnectionDialog - > new_profile_button , Qt : : LeftButton ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClicks ( QApplication : : focusWidget ( ) , mHostname ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClick ( QApplication : : focusWidget ( ) , Qt : : Key_Tab ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClicks ( QApplication : : focusWidget ( ) , mLocalhost ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClick ( QApplication : : focusWidget ( ) , Qt : : Key_Tab ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClicks ( QApplication : : focusWidget ( ) , port ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClick ( QApplication : : focusWidget ( ) , Qt : : Key_Return ) ;
} ) ;
QSignalSpy loaded ( mudlet : : self ( ) , & mudlet : : signal_profileLoaded ) ;
if ( ! loaded . wait ( 5000 ) ) {
qWarning ( " Profile took too long to load " ) ;
return nullptr ;
}
Host * host = mudlet : : self ( ) - > getActiveHost ( ) ;
if ( ! host ) {
qWarning ( " No active host " ) ;
return nullptr ;
}
QSignalSpy connected ( & ( host - > mTelnet ) , & cTelnet : : signal_connected ) ;
if ( ! connected . wait ( 3000 ) ) {
qWarning ( " Could not connect to the stub " ) ;
return nullptr ;
}
return host ;
}
void deleteProfileDirectory ( const QString & profileName )
{
const QString path = mudlet : : getMudletPath ( enums : : profileHomePath , profileName ) ;
QDir dir ( path ) ;
if ( dir . exists ( ) ) {
dir . removeRecursively ( ) ;
}
}
} ;
static void initializeQRCResources ( )
{
# ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
qInitResources_additional_splash_screens ( ) ;
# endif
# ifdef INCLUDE_FONTS
qInitResources_mudlet_fonts_common ( ) ;
# if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
qInitResources_mudlet_fonts_posix ( ) ;
# endif
# endif
qInitResources_mudlet ( ) ;
qInitResources_qm ( ) ;
}
# include "PipelineBenchmark.moc"
QTEST_MAIN ( PipelineBenchmark )