Initial Commit

This commit is contained in:
Jeremy Ruston
2026-03-13 15:36:43 +00:00
parent 4cd84a6ba1
commit f272f718fa
7 changed files with 642 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"description": "Performance measurement edition",
"plugins": [
"tiddlywiki/performance"
],
"themes": [
"tiddlywiki/vanilla",
"tiddlywiki/snowwhite"
]
}
+2 -1
View File
@@ -13,7 +13,8 @@
"tiddlywiki/menubar",
"tiddlywiki/jszip",
"tiddlywiki/confetti",
"tiddlywiki/tour"
"tiddlywiki/tour",
"tiddlywiki/performance"
],
"themes": [
"tiddlywiki/vanilla",
@@ -0,0 +1,230 @@
/*\
title: $:/plugins/tiddlywiki/performance/perf-replay-command.js
type: application/javascript
module-type: command
Command to replay a recorded performance timeline against the current wiki
Usage: --perf-replay <timeline.json> [--no-coalesce]
Loads the wiki (use --load before this command), builds a widget tree
using fakeDocument, then replays the recorded store modifications
batch by batch, measuring refresh performance for each batch.
\*/
"use strict";
exports.info = {
name: "perf-replay",
synchronous: false
};
var Command = function(params,commander,callback) {
this.params = params;
this.commander = commander;
this.callback = callback;
};
Command.prototype.execute = function() {
var self = this,
fs = require("fs"),
path = require("path"),
wiki = this.commander.wiki,
widget = require("$:/core/modules/widgets/widget.js");
// Parse parameters
if(this.params.length < 1) {
return "Missing timeline filename. Usage: --perf-replay <timeline.json>";
}
var timelinePath = this.params[0];
// Load timeline
var timelineData;
try {
timelineData = JSON.parse(fs.readFileSync(timelinePath,"utf8"));
} catch(e) {
return "Error reading timeline file: " + e.message;
}
if(!Array.isArray(timelineData) || timelineData.length === 0) {
return "Timeline file is empty or invalid";
}
// Count tiddlers in wiki
var tiddlerCount = 0;
wiki.each(function() { tiddlerCount++; });
// Build a widget tree against fakeDocument (mirroring what render.js does in the browser)
var PAGE_TEMPLATE_TITLE = "$:/core/ui/RootTemplate";
// Create root widget
var rootWidget = new widget.widget({
type: "widget",
children: []
},{
wiki: wiki,
document: $tw.fakeDocument
});
// Enable performance instrumentation
var perf = new $tw.Performance(true);
// Wrap filter execution with perf measurement
var origCompileFilter = wiki.compileFilter;
var filterInvocations = 0;
wiki.compileFilter = function(filterString) {
var compiledFilter = origCompileFilter.call(wiki,filterString);
return perf.measure("filter: " + filterString.substring(0,80),function(source,widget) {
filterInvocations++;
return compiledFilter.call(this,source,widget);
});
};
// Re-initialise parsers so filters get wrapped
wiki.clearCache(null);
// Build and render the page widget tree
var pageWidgetNode = wiki.makeTranscludeWidget(PAGE_TEMPLATE_TITLE,{
document: $tw.fakeDocument,
parentWidget: rootWidget,
recursionMarker: "no"
});
var pageContainer = $tw.fakeDocument.createElement("div");
var renderStart = $tw.utils.timer();
pageWidgetNode.render(pageContainer,null);
var renderTime = $tw.utils.timer(renderStart);
// Link root widget
rootWidget.domNodes = [pageContainer];
rootWidget.children = [pageWidgetNode];
// Group timeline events by batch
var batches = [];
var currentBatch = null;
$tw.utils.each(timelineData,function(event) {
if(!currentBatch || event.batch !== currentBatch.batchId) {
currentBatch = {batchId: event.batch, events: []};
batches.push(currentBatch);
}
currentBatch.events.push(event);
});
// Replay each batch and measure refresh
var results = [],
totalRefreshTime = 0,
totalFilterInvocations = 0;
self.commander.streams.output.write("\nPerformance Timeline Replay\n");
self.commander.streams.output.write("==========================\n");
self.commander.streams.output.write("Wiki: " + tiddlerCount + " tiddlers\n");
self.commander.streams.output.write("Timeline: " + timelineData.length + " operations in " + batches.length + " batches\n");
self.commander.streams.output.write("Initial render: " + renderTime.toFixed(2) + "ms\n\n");
self.commander.streams.output.write(padRight("Batch",8) + padRight("Ops",6) + padRight("Changed",10) +
padRight("Refresh(ms)",14) + padRight("Filters",10) + "Tiddlers Changed\n");
self.commander.streams.output.write(padRight("-----",8) + padRight("---",6) + padRight("-------",10) +
padRight("-----------",14) + padRight("-------",10) + "----------------\n");
$tw.utils.each(batches,function(batch,index) {
// Apply all operations in this batch directly (bypassing the intercepted addTiddler
// to avoid re-recording)
var changedTiddlers = Object.create(null);
$tw.utils.each(batch.events,function(event) {
if(event.op === "add") {
wiki.addTiddler(new $tw.Tiddler(event.fields));
changedTiddlers[event.title] = {modified: true};
} else if(event.op === "delete") {
wiki.deleteTiddler(event.title);
changedTiddlers[event.title] = {deleted: true};
}
});
// Measure refresh
filterInvocations = 0;
var refreshStart = $tw.utils.timer();
pageWidgetNode.refresh(changedTiddlers);
var refreshTime = $tw.utils.timer(refreshStart);
totalRefreshTime += refreshTime;
totalFilterInvocations += filterInvocations;
var changedTitles = Object.keys(changedTiddlers);
var titlesDisplay = changedTitles.slice(0,3).join(", ");
if(changedTitles.length > 3) {
titlesDisplay += " (+" + (changedTitles.length - 3) + " more)";
}
results.push({
batch: index + 1,
ops: batch.events.length,
changed: changedTitles.length,
refreshMs: refreshTime,
filters: filterInvocations,
tiddlers: changedTitles
});
self.commander.streams.output.write(
padRight(String(index + 1),8) +
padRight(String(batch.events.length),6) +
padRight(String(changedTitles.length),10) +
padRight(refreshTime.toFixed(2),14) +
padRight(String(filterInvocations),10) +
titlesDisplay + "\n"
);
});
// Summary statistics
var refreshTimes = results.map(function(r) { return r.refreshMs; }).sort(function(a,b) { return a - b; });
var p50 = percentile(refreshTimes,50);
var p95 = percentile(refreshTimes,95);
var p99 = percentile(refreshTimes,99);
var maxRefresh = refreshTimes[refreshTimes.length - 1] || 0;
var meanRefresh = batches.length > 0 ? totalRefreshTime / batches.length : 0;
self.commander.streams.output.write("\nSummary\n");
self.commander.streams.output.write("-------\n");
self.commander.streams.output.write("Initial render: " + renderTime.toFixed(2) + "ms\n");
self.commander.streams.output.write("Total refresh time: " + totalRefreshTime.toFixed(2) + "ms\n");
self.commander.streams.output.write("Mean refresh: " + meanRefresh.toFixed(2) + "ms\n");
self.commander.streams.output.write("P50 refresh: " + p50.toFixed(2) + "ms\n");
self.commander.streams.output.write("P95 refresh: " + p95.toFixed(2) + "ms\n");
self.commander.streams.output.write("P99 refresh: " + p99.toFixed(2) + "ms\n");
self.commander.streams.output.write("Max refresh: " + maxRefresh.toFixed(2) + "ms\n");
self.commander.streams.output.write("Total filters run: " + totalFilterInvocations + "\n");
// Output filter breakdown
self.commander.streams.output.write("\nTop Filter Execution Times\n");
self.commander.streams.output.write("--------------------------\n");
var measures = perf.measures;
var orderedMeasures = Object.keys(measures).sort(function(a,b) {
return measures[b].time - measures[a].time;
}).slice(0,20);
$tw.utils.each(orderedMeasures,function(name) {
var m = measures[name];
self.commander.streams.output.write(
padRight(m.time.toFixed(2) + "ms",14) +
padRight(String(m.invocations) + "x",10) +
padRight((m.time / m.invocations).toFixed(3) + "ms avg",16) +
name + "\n"
);
});
// Write JSON results
var jsonResultPath = timelinePath.replace(/\.json$/,"") + "-results.json";
try {
fs.writeFileSync(jsonResultPath,JSON.stringify({
wiki: {tiddlerCount: tiddlerCount},
timeline: {operations: timelineData.length, batches: batches.length},
initialRender: renderTime,
summary: {
totalRefreshTime: totalRefreshTime,
meanRefresh: meanRefresh,
p50: p50,
p95: p95,
p99: p99,
maxRefresh: maxRefresh,
totalFilterInvocations: totalFilterInvocations
},
batches: results,
topFilters: orderedMeasures.map(function(name) {
return {name: name, time: measures[name].time, invocations: measures[name].invocations};
})
},null,"\t"),"utf8");
self.commander.streams.output.write("\nDetailed results written to: " + jsonResultPath + "\n");
} catch(e) {
self.commander.streams.output.write("\nWarning: Could not write results file: " + e.message + "\n");
}
self.callback(null);
return null;
};
function percentile(sortedArray,p) {
if(sortedArray.length === 0) return 0;
var index = Math.ceil(sortedArray.length * p / 100) - 1;
return sortedArray[Math.max(0,index)];
}
function padRight(str,width) {
while(str.length < width) {
str += " ";
}
return str;
}
exports.Command = Command;
@@ -0,0 +1,7 @@
{
"title": "$:/plugins/tiddlywiki/performance",
"name": "Performance",
"description": "Record and replay wiki store modifications for performance testing",
"list": "readme ui",
"stability": "STABILITY_0_EXPLORATORY"
}
+202
View File
@@ -0,0 +1,202 @@
title: $:/plugins/tiddlywiki/performance/readme
! Performance Testing Plugin
This plugin provides a framework for measuring the performance of TiddlyWiki's refresh cycle — the process that updates the display when tiddlers are modified.
The idea is to capture a realistic workload by recording store modifications while a user interacts with a wiki in the browser, and then replaying those modifications under Node.js where the refresh cycle can be precisely measured in isolation.
!! Motivation
An important motivation for this framework is to enable LLMs to iteratively optimise TiddlyWiki's performance. The workflow is:
# An LLM makes a change to the TiddlyWiki codebase (e.g. optimising a filter operator, caching a computation, or restructuring a widget's refresh logic)
# The LLM runs `--perf-replay` against a recorded timeline to measure the impact
# The LLM reads the JSON results file to determine whether the change improved, regressed, or had no effect on performance
# The LLM iterates: tries another approach, measures again, and converges on the best solution
This tight edit-measure-iterate loop works because `--perf-replay` runs entirely under Node.js with no browser required, produces machine-readable JSON output, and completes in seconds.
!! How It Works
The framework has two parts:
!!! 1. Recording (Browser)
The plugin intercepts `wiki.addTiddler()` and `wiki.deleteTiddler()` to capture every store modification as it happens. Each operation is recorded with:
* A sequence number and high-resolution timestamp
* The full tiddler fields (so the exact state can be recreated)
* A batch identifier that tracks TiddlyWiki's change batching via `$tw.utils.nextTick()`
The batch tracking is important because TiddlyWiki groups multiple store changes that occur in the same tick into a single refresh cycle. The recorder preserves these batch boundaries so that playback triggers the same pattern of refreshes.
!!! 2. Playback (Node.js)
The `--perf-replay` command loads a wiki and builds the full widget tree using TiddlyWiki's `$tw.fakeDocument` — the lightweight DOM implementation used for server-side rendering. It then replays the recorded timeline batch by batch, calling `widgetNode.refresh(changedTiddlers)` after each batch and measuring how long it takes.
This means we are measuring TiddlyWiki's own refresh logic (widget tree traversal, filter evaluation, DOM diffing) in isolation from browser layout and paint. This is intentional — it lets us identify performance bottlenecks within TiddlyWiki itself, independent of which browser is being used.
!! Why Store-Level Recording?
An alternative would be to record DOM events (clicks, keystrokes) and replay them in a headless browser. Store-level recording was chosen instead because:
* The refresh cycle responds to ''store changes'', not DOM events — store modifications are the natural input
* Store changes are fully deterministic and reproducible
* No DOM dependency means playback works in pure Node.js with no headless browser to install
* A headless browser would add its own overhead, making measurements less precise
!! Recording
# Include this plugin in your wiki
# Open the Control Panel and find the "Performance Testing Recorder" tab
# Click "Start Recording"
# Interact with the wiki — open tiddlers, edit, type, navigate, switch tabs
# Click "Stop Recording"
# Download the `timeline.json` file
!!! Draft Coalescing
When editing a tiddler, TiddlyWiki writes to draft tiddlers on every keystroke. By default, the recorder coalesces rapid draft updates within the same batch, keeping only the last update. This produces a more compact timeline that focuses on the refresh-relevant changes.
Uncheck "Coalesce rapid draft updates" to record every individual keystroke. This is useful when you specifically want to measure the performance impact of rapid typing.
!! Playback
```
tiddlywiki editions/performance --load mywiki.html --perf-replay timeline.json
```
Or from any edition that includes this plugin:
```
tiddlywiki myedition --perf-replay timeline.json
```
Playback runs at full speed with no delays between batches. The recorded timestamps are preserved in the timeline for reference but are not used for pacing.
!! What Gets Measured
* ''Initial render time'' — the time to build and render the full widget tree from scratch
* ''Refresh time per batch'' — the time `widgetNode.refresh(changedTiddlers)` takes for each batch of store modifications
* ''Filter execution'' — individual filter timings and invocation counts, showing which filters are the most expensive
* ''Statistical summary'' — mean, P50, P95, P99, and maximum refresh times across all batches
!! Output
The command produces two forms of output:
!!! Text Report (stdout)
A human-readable table printed to the console showing per-batch timings, a summary with percentile statistics, and a breakdown of the most expensive filter executions.
!!! JSON Results File
A `<timeline-name>-results.json` file is written alongside the input timeline. This is the primary output for automated consumption. The file contains:
```json
{
"wiki": {
"tiddlerCount": 2076
},
"timeline": {
"operations": 156,
"batches": 42
},
"initialRender": 55.46,
"summary": {
"totalRefreshTime": 234.5,
"meanRefresh": 5.58,
"p50": 4.12,
"p95": 18.7,
"p99": 31.2,
"maxRefresh": 31.2,
"totalFilterInvocations": 4821
},
"batches": [
{
"batch": 1,
"ops": 1,
"changed": 1,
"refreshMs": 12.3,
"filters": 293,
"tiddlers": ["$:/StoryList"]
}
],
"topFilters": [
{
"name": "filter: [subfilter{$:/core/config/GlobalImportFilter}]",
"time": 5.65,
"invocations": 5
}
]
}
```
All times are in milliseconds. The key fields for automated analysis:
* `summary.totalRefreshTime` — the single most important number: total time spent in refresh across all batches
* `summary.meanRefresh` — average refresh time per batch
* `summary.p95` / `summary.p99` — tail latency indicators
* `initialRender` — time to build the widget tree from scratch (measures startup cost)
* `batches[].refreshMs` — per-batch breakdown, useful for identifying which user actions are expensive
* `topFilters[]` — the most expensive filters by total execution time, useful for identifying optimisation targets
!! Example: LLM Optimisation Workflow
An LLM optimising TiddlyWiki performance would follow this pattern:
!!! Step 1: Establish baseline
```
node ./tiddlywiki.js editions/performance --load mywiki.html --perf-replay timeline.json
```
Read `timeline-results.json` and note the baseline `summary.totalRefreshTime`.
!!! Step 2: Make a change
Edit a source file (e.g. optimise a filter operator in `core/modules/filters/`).
!!! Step 3: Measure impact
Run the same `--perf-replay` command again and read the new `timeline-results.json`.
!!! Step 4: Compare
Compare `summary.totalRefreshTime` and `summary.p95` between baseline and new results. If improved, keep the change. If regressed, revert and try a different approach.
!!! Step 5: Iterate
Repeat steps 2-4 until the target metric is optimised.
The JSON results file makes step 4 straightforward — an LLM can read two JSON files and compare numeric fields directly without parsing tabular text output.
!! Timeline Format
The timeline is a JSON array of operations:
```json
[
{
"seq": 0,
"t": 123.45,
"batch": 0,
"op": "add",
"title": "$:/StoryList",
"isDraft": false,
"fields": {
"title": "$:/StoryList",
"list": "GettingStarted",
"text": ""
}
}
]
```
* `seq` — sequential operation number
* `t` — milliseconds since recording started
* `batch` — batch identifier (operations in the same batch trigger a single refresh)
* `op` — `"add"` or `"delete"`
* `isDraft` — whether this is a draft tiddler (used for coalescing)
* `fields` — complete tiddler fields (null for delete operations)
+143
View File
@@ -0,0 +1,143 @@
/*\
title: $:/plugins/tiddlywiki/performance/recorder.js
type: application/javascript
module-type: startup
Store modification recorder for performance testing.
Intercepts wiki.addTiddler() and wiki.deleteTiddler() to capture
a timeline of all store modifications with batch boundary tracking.
\*/
"use strict";
exports.name = "perf-recorder";
exports.platforms = ["browser"];
exports.after = ["load-modules"];
exports.synchronous = true;
exports.startup = function() {
var STATE_TIDDLER = "$:/state/performance/recording",
TIMELINE_TIDDLER = "$:/temp/performance/timeline",
COALESCE_CONFIG = "$:/config/performance/coalesce-drafts",
timeline = [],
seq = 0,
startTime = null,
recording = false,
batchId = 0,
currentBatch = 0,
origNextTick = $tw.utils.nextTick,
origAddTiddler = $tw.wiki.addTiddler.bind($tw.wiki),
origDeleteTiddler = $tw.wiki.deleteTiddler.bind($tw.wiki);
// Patch nextTick to track batch boundaries
$tw.utils.nextTick = function(fn) {
origNextTick(function() {
if(recording) {
currentBatch = ++batchId;
}
fn();
});
};
// Patch addTiddler
$tw.wiki.addTiddler = function(tiddler) {
if(recording) {
if(!(tiddler instanceof $tw.Tiddler)) {
tiddler = new $tw.Tiddler(tiddler);
}
var title = tiddler.fields.title;
// Skip our own state/timeline tiddlers
if(title !== STATE_TIDDLER && title !== TIMELINE_TIDDLER) {
timeline.push({
seq: seq++,
t: $tw.utils.timer(startTime),
batch: currentBatch,
op: "add",
title: title,
isDraft: tiddler.hasField("draft.of"),
fields: tiddler.getFieldStrings()
});
}
}
return origAddTiddler.apply(null,arguments);
};
// Patch deleteTiddler
$tw.wiki.deleteTiddler = function(title) {
if(recording) {
if(title !== STATE_TIDDLER && title !== TIMELINE_TIDDLER) {
timeline.push({
seq: seq++,
t: $tw.utils.timer(startTime),
batch: currentBatch,
op: "delete",
title: title,
isDraft: false,
fields: null
});
}
}
return origDeleteTiddler.apply(null,arguments);
};
// Listen for recording state changes
$tw.wiki.addEventListener("change",function(changes) {
if(STATE_TIDDLER in changes) {
var state = $tw.wiki.getTiddlerText(STATE_TIDDLER,"").trim();
if(state === "yes" && !recording) {
// Start recording
timeline = [];
seq = 0;
batchId = 0;
currentBatch = 0;
startTime = $tw.utils.timer();
recording = true;
console.log("performance: Recording started");
} else if(state !== "yes" && recording) {
// Stop recording and save timeline
recording = false;
var coalesce = $tw.wiki.getTiddlerText(COALESCE_CONFIG,"yes").trim() === "yes";
var output = coalesce ? coalesceDrafts(timeline) : timeline;
origAddTiddler(new $tw.Tiddler({
title: TIMELINE_TIDDLER,
type: "application/json",
text: JSON.stringify(output,null,"\t")
}));
console.log("performance: Recording stopped. " + timeline.length + " operations captured" +
(coalesce ? " (" + output.length + " after coalescing drafts)" : ""));
}
}
});
/*
Coalesce rapid draft tiddler updates within the same batch.
Keeps only the last update for each draft tiddler per batch.
*/
function coalesceDrafts(events) {
var result = [],
i = 0;
while(i < events.length) {
var event = events[i];
if(event.isDraft && event.op === "add") {
// Look ahead for later updates to this same draft in the same batch
var lastIndex = i;
for(var j = i + 1; j < events.length; j++) {
if(events[j].batch !== event.batch) {
break;
}
if(events[j].title === event.title && events[j].op === "add") {
lastIndex = j;
}
}
// Keep only the last one, but fix its seq to maintain ordering
result.push(events[lastIndex]);
i = lastIndex + 1;
} else {
result.push(event);
i++;
}
}
return result;
}
};
+48
View File
@@ -0,0 +1,48 @@
title: $:/plugins/tiddlywiki/performance/ui
caption: Recorder
! Performance Testing Recorder
<$let
state="$:/state/performance/recording"
timeline="$:/temp/performance/timeline"
coalesceConfig="$:/config/performance/coalesce-drafts"
>
!! Recording
<$reveal state=<<state>> type="nomatch" text="yes">
<$button set=<<state>> setTo="yes" class="tc-btn-big-green">Start Recording</$button>
</$reveal>
<$reveal state=<<state>> type="match" text="yes">
<$button set=<<state>> setTo="no" class="tc-btn-big-green" style="background: #d33;">Stop Recording</$button>
&nbsp; //Recording in progress...//
</$reveal>
!! Options
<$checkbox tiddler=<<coalesceConfig>> field="text" checked="yes" unchecked="no" default="yes"> Coalesce rapid draft updates</$checkbox>
!! Timeline
<$reveal type="nomatch" state=<<timeline>> text="">
<$let timelineText={{$(timeline)$}}>
<$vars count={{{ [<timeline>get[text]jsonextract[]] +[count[]] }}}>
Timeline contains <<count>> operations.
</$vars>
<$button>
<$action-sendmessage $message="tm-download-file" $param=<<timeline>> filename="timeline.json"/>
Download timeline.json
</$button>
</$let>
</$reveal>
<$reveal type="match" state=<<timeline>> text="">
No timeline recorded yet. Click "Start Recording", interact with the wiki, then click "Stop Recording".
</$reveal>
</$let>