mudlet/test/functional_tests/PipelineBenchmark.cpp

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

605 lines
26 KiB
C++
Raw Permalink Normal View History

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\xa8me br\xc3\xbbl\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)