diff --git a/boot/boot.js b/boot/boot.js index df89561d46a..058e0f685fc 100644 --- a/boot/boot.js +++ b/boot/boot.js @@ -14,6 +14,8 @@ var _boot = (function($tw) { "use strict"; +if(typeof performance !== "undefined") { performance.mark("tw-boot-start"); } + // Include bootprefix if we're not given module data if(!$tw) { $tw = require("./bootprefix.js").bootprefix(); @@ -1158,6 +1160,30 @@ $tw.Wiki = function(options) { pluginTiddlers = [], // Array of tiddlers containing registered plugins, ordered by priority pluginInfo = Object.create(null), // Hashmap of parsed plugin content shadowTiddlers = Object.create(null), // Hashmap by title of {source:, tiddler:} + systemTiddlerTitles = null, // Array of system tiddler titles (starting with "$:/") + nonSystemTiddlerTitles = null, // Array of non-system tiddler titles + partitionTiddlerTitles = function() { + if(systemTiddlerTitles === null) { + systemTiddlerTitles = []; + nonSystemTiddlerTitles = []; + var titles = getTiddlerTitles(); + for(var i = 0, length = titles.length; i < length; i++) { + if(titles[i].indexOf("$:/") === 0) { + systemTiddlerTitles.push(titles[i]); + } else { + nonSystemTiddlerTitles.push(titles[i]); + } + } + } + }, + getSystemTiddlerTitles = function() { + partitionTiddlerTitles(); + return systemTiddlerTitles; + }, + getNonSystemTiddlerTitles = function() { + partitionTiddlerTitles(); + return nonSystemTiddlerTitles; + }, shadowTiddlerTitles = null, getShadowTiddlerTitles = function() { if(!shadowTiddlerTitles) { @@ -1206,6 +1232,14 @@ $tw.Wiki = function(options) { tiddlers[title] = tiddler; // Check we've got the title tiddlerTitles = $tw.utils.insertSortedArray(tiddlerTitles || [],title); + // Maintain system/non-system partitions + if(systemTiddlerTitles !== null) { + if(title.indexOf("$:/") === 0) { + $tw.utils.insertSortedArray(systemTiddlerTitles,title); + } else { + $tw.utils.insertSortedArray(nonSystemTiddlerTitles,title); + } + } // Record the new tiddler state updateDescriptor["new"] = { tiddler: tiddler, @@ -1246,6 +1280,14 @@ $tw.Wiki = function(options) { tiddlerTitles.splice(index,1); } } + // Delete from system/non-system partitions + if(systemTiddlerTitles !== null) { + var partitionArray = title.indexOf("$:/") === 0 ? systemTiddlerTitles : nonSystemTiddlerTitles; + var partitionIndex = partitionArray.indexOf(title); + if(partitionIndex !== -1) { + partitionArray.splice(partitionIndex,1); + } + } // Record the new tiddler state updateDescriptor["new"] = { tiddler: this.getTiddler(title), @@ -1284,6 +1326,16 @@ $tw.Wiki = function(options) { return getTiddlerTitles().slice(0); }; + // Get an array of all system tiddler titles (returns cached array; do not mutate) + this.allSystemTitles = function() { + return getSystemTiddlerTitles(); + }; + + // Get an array of all non-system tiddler titles (returns cached array; do not mutate) + this.allNonSystemTitles = function() { + return getNonSystemTiddlerTitles(); + }; + // Iterate through all tiddler titles this.each = function(callback) { var titles = getTiddlerTitles(), @@ -2539,7 +2591,9 @@ $tw.boot.loadStartup = function(options){ // Load tiddlers if($tw.boot.tasks.readBrowserTiddlers) { + if(typeof performance !== "undefined") { performance.mark("tw-boot-store-read-start"); } $tw.loadTiddlersBrowser(); + if(typeof performance !== "undefined") { performance.mark("tw-boot-store-read-end"); } } else { $tw.loadTiddlersNode(); } @@ -2551,6 +2605,7 @@ $tw.boot.loadStartup = function(options){ $tw.hooks.invokeHook("th-boot-tiddlers-loaded"); }; $tw.boot.execStartup = function(options){ + if(typeof performance !== "undefined") { performance.mark("tw-boot-exec-start"); } // Unpack plugin tiddlers $tw.wiki.readPluginInfo(); $tw.wiki.registerPluginTiddlers("plugin",$tw.safeMode ? ["$:/core"] : undefined); @@ -2578,6 +2633,7 @@ $tw.boot.execStartup = function(options){ $tw.boot.executedStartupModules = Object.create(null); $tw.boot.disabledStartupModules = $tw.boot.disabledStartupModules || []; // Repeatedly execute the next eligible task + if(typeof performance !== "undefined") { performance.mark("tw-boot-startup-modules-start"); } $tw.boot.executeNextStartupTask(options.callback); }; /* @@ -2645,6 +2701,7 @@ $tw.boot.executeNextStartupTask = function(callback) { } taskIndex++; } + if(typeof performance !== "undefined") { performance.mark("tw-boot-complete"); } if(typeof callback === "function") { callback(); } diff --git a/boot/bootprefix.js b/boot/bootprefix.js index c4ebc617275..42e751901e8 100644 --- a/boot/bootprefix.js +++ b/boot/bootprefix.js @@ -14,6 +14,8 @@ See Boot.js for further details of the boot process. /* eslint-disable @stylistic/indent */ +if(typeof performance !== "undefined") { performance.mark("tw-bootprefix-start"); } + var _bootprefix = (function($tw) { "use strict"; @@ -23,7 +25,7 @@ $tw.boot = $tw.boot || Object.create(null); // Config $tw.config = $tw.config || Object.create(null); -$tw.config.maxEditFileSize = 100 * 1024 * 1024; // 100MB +$tw.config.maxEditFileSize = 200 * 1024 * 1024; // 200MB // Detect platforms if(!("browser" in $tw)) { @@ -121,6 +123,7 @@ return $tw; if(typeof(exports) === "undefined") { // Set up $tw global for the browser window.$tw = _bootprefix(window.$tw); + if(typeof performance !== "undefined") { performance.mark("tw-bootprefix-end"); } } else { // Export functionality as a module exports.bootprefix = _bootprefix; diff --git a/core/modules/filters.js b/core/modules/filters.js index 2cfa60914ec..17ea80ecdee 100644 --- a/core/modules/filters.js +++ b/core/modules/filters.js @@ -261,6 +261,7 @@ exports.compileFilter = function(filterString) { var filterOperators = this.getFilterOperators(); // Assemble array of functions, one for each operation var operationFunctions = []; + var operationSubFunctions = []; // Unwrapped sub-functions for fast path // Step through the operations var self = this; $tw.utils.each(filterParseTree,function(operation) { @@ -289,20 +290,24 @@ exports.compileFilter = function(filterString) { operand.value = self.getTextReference(operand.text,"",currTiddlerTitle); operand.multiValue = [operand.value]; } else if(operand.variable) { - var varTree = $tw.utils.parseFilterVariable(operand.text); - operand.value = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source})[0] || ""; + if(!operand._varTree) { + operand._varTree = $tw.utils.parseFilterVariable(operand.text); + } + operand.value = widgetClass.evaluateVariable(widget,operand._varTree.name,{params: operand._varTree.params, source: source})[0] || ""; operand.multiValue = [operand.value]; } else if(operand.multiValuedVariable) { - var varTree = $tw.utils.parseFilterVariable(operand.text); - var resultList = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source}); + if(!operand._varTree) { + operand._varTree = $tw.utils.parseFilterVariable(operand.text); + } + var resultList = widgetClass.evaluateVariable(widget,operand._varTree.name,{params: operand._varTree.params, source: source}); if((resultList.length > 0 && resultList[0] !== undefined) || resultList.length === 0) { - operand.multiValue = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source}) || []; + operand.multiValue = widgetClass.evaluateVariable(widget,operand._varTree.name,{params: operand._varTree.params, source: source}) || []; operand.value = operand.multiValue[0] || ""; } else { operand.value = ""; operand.multiValue = []; } - operand.isMultiValueOperand = true; + operand.isMultiValueOperand = true; } else { operand.value = operand.text; operand.multiValue = [operand.value]; @@ -343,6 +348,7 @@ exports.compileFilter = function(filterString) { return resultArray; } }; + operationSubFunctions.push(operationSubFunction); var filterRunPrefixes = self.getFilterRunPrefixes(); // Wrap the operator functions in a wrapper function that depends on the prefix operationFunctions.push((function() { @@ -372,6 +378,10 @@ exports.compileFilter = function(filterString) { } })()); }); + // Detect single "or" run for fast path (bypass LinkedList) + var isSingleOrRun = filterParseTree.length === 1 && + (!filterParseTree[0].prefix || filterParseTree[0].prefix === ""); + var singleOrSubFunction = isSingleOrRun ? operationSubFunctions[0] : null; // Return a function that applies the operations to a source iterator of tiddler titles var fnMeasured = $tw.perf.measure("filter: " + filterString,function filterFunction(source,widget) { if(!source) { @@ -382,23 +392,30 @@ exports.compileFilter = function(filterString) { if(!widget) { widget = $tw.rootWidget; } - var results = new $tw.utils.LinkedList(); self.filterRecursionCount = (self.filterRecursionCount || 0) + 1; + var resultArray; if(self.filterRecursionCount < MAX_FILTER_DEPTH) { - $tw.utils.each(operationFunctions,function(operationFunction) { - var operationResult = operationFunction(results,source,widget); - if(operationResult) { - if(operationResult.variables) { - // If the filter run prefix has returned variables, create a new fake widget with those variables - widget = widget.makeFakeWidgetWithVariables(operationResult.variables); + if(singleOrSubFunction) { + // Fast path: single "or" run, return array directly without LinkedList + resultArray = singleOrSubFunction(source,widget); + } else { + var results = new $tw.utils.LinkedList(); + $tw.utils.each(operationFunctions,function(operationFunction) { + var operationResult = operationFunction(results,source,widget); + if(operationResult) { + if(operationResult.variables) { + // If the filter run prefix has returned variables, create a new fake widget with those variables + widget = widget.makeFakeWidgetWithVariables(operationResult.variables); + } } - } - }); + }); + resultArray = results.toArray(); + } } else { - results.push("/**-- Excessive filter recursion --**/"); + resultArray = ["/**-- Excessive filter recursion --**/"]; } self.filterRecursionCount = self.filterRecursionCount - 1; - return results.toArray(); + return resultArray; }); if(this.filterCacheCount >= 2000) { // To prevent memory leak, we maintain an upper limit for cache size. diff --git a/core/modules/filters/is/shadow.js b/core/modules/filters/is/shadow.js index 4f1335c692b..b379ab44e1e 100644 --- a/core/modules/filters/is/shadow.js +++ b/core/modules/filters/is/shadow.js @@ -13,6 +13,18 @@ Filter function for [is[shadow]] Export our filter function */ exports.shadow = function(source,prefix,options) { + // Fast path: when source is wiki.each (all real tiddlers), use shadow title list + if(source === options.wiki.each && prefix !== "!") { + // Return real tiddlers that are also shadow tiddlers (overridden shadows) + var results = [], + shadowTitles = options.wiki.allShadowTitles(); + for(var i = 0, len = shadowTitles.length; i < len; i++) { + if(options.wiki.tiddlerExists(shadowTitles[i])) { + results.push(shadowTitles[i]); + } + } + return results; + } var results = []; if(prefix === "!") { source(function(tiddler,title) { diff --git a/core/modules/filters/is/system.js b/core/modules/filters/is/system.js index b27feb3923e..ad6f52b7770 100644 --- a/core/modules/filters/is/system.js +++ b/core/modules/filters/is/system.js @@ -13,6 +13,14 @@ Filter function for [is[system]] Export our filter function */ exports.system = function(source,prefix,options) { + // Fast path: when iterating all tiddlers, use pre-partitioned arrays + if(source === options.wiki.each) { + if(prefix === "!") { + return options.wiki.allNonSystemTitles(); + } else { + return options.wiki.allSystemTitles(); + } + } var results = []; if(prefix === "!") { source(function(tiddler,title) { diff --git a/core/modules/filters/is/tiddler.js b/core/modules/filters/is/tiddler.js index 24ca5cee29e..36cdc587018 100644 --- a/core/modules/filters/is/tiddler.js +++ b/core/modules/filters/is/tiddler.js @@ -13,6 +13,13 @@ Filter function for [is[tiddler]] Export our filter function */ exports.tiddler = function(source,prefix,options) { + // Fast path: wiki.each only iterates real tiddlers, all of which exist + if(source === options.wiki.each) { + if(prefix === "!") { + return []; // No real tiddler fails tiddlerExists + } + return source; // Return iterator directly; all real tiddlers pass + } var results = []; if(prefix === "!") { source(function(tiddler,title) { diff --git a/core/modules/filters/tags.js b/core/modules/filters/tags.js index c4ce719c8c6..006670ee5f3 100644 --- a/core/modules/filters/tags.js +++ b/core/modules/filters/tags.js @@ -13,6 +13,21 @@ Filter operator returning all the tags of the selected tiddlers Export our filter function */ exports.tags = function(source,operator,options) { + // Fast path: cache result when iterating all tiddlers + if(source === options.wiki.each) { + return options.wiki.getGlobalCache("filter-tags-all-tiddlers",function() { + var tags = {}; + source(function(tiddler,title) { + var t, length; + if(tiddler && tiddler.fields.tags) { + for(t=0, length=tiddler.fields.tags.length; t0 && fieldIndex [--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 "; + } + 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; diff --git a/plugins/tiddlywiki/performance/plugin.info b/plugins/tiddlywiki/performance/plugin.info new file mode 100644 index 00000000000..720f16bcbda --- /dev/null +++ b/plugins/tiddlywiki/performance/plugin.info @@ -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_1_EXPERIMENTAL" +} diff --git a/plugins/tiddlywiki/performance/readme.tid b/plugins/tiddlywiki/performance/readme.tid new file mode 100644 index 00000000000..bf949dd4601 --- /dev/null +++ b/plugins/tiddlywiki/performance/readme.tid @@ -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 `-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) diff --git a/plugins/tiddlywiki/performance/recorder.js b/plugins/tiddlywiki/performance/recorder.js new file mode 100644 index 00000000000..f38a849f0fa --- /dev/null +++ b/plugins/tiddlywiki/performance/recorder.js @@ -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; + } +}; diff --git a/plugins/tiddlywiki/performance/ui.tid b/plugins/tiddlywiki/performance/ui.tid new file mode 100644 index 00000000000..cfba60c7bd0 --- /dev/null +++ b/plugins/tiddlywiki/performance/ui.tid @@ -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=<> type="nomatch" text="yes"> +<$button set=<> setTo="yes" class="tc-btn-big-green">Start Recording + + +<$reveal state=<> type="match" text="yes"> +<$button set=<> setTo="no" class="tc-btn-big-green" style="background: #d33;">Stop Recording +  //Recording in progress...// + + +!! Options + +<$checkbox tiddler=<> field="text" checked="yes" unchecked="no" default="yes"> Coalesce rapid draft updates + +!! Timeline + +<$reveal type="nomatch" state=<> text=""> + +<$let timelineText={{$(timeline)$}}> +<$vars count={{{ [get[text]jsonextract[]] +[count[]] }}}> +Timeline contains <> operations. + + +<$button> +<$action-sendmessage $message="tm-download-file" $param=<> filename="timeline.json"/> +Download timeline.json + + + + + +<$reveal type="match" state=<> text=""> +No timeline recorded yet. Click "Start Recording", interact with the wiki, then click "Stop Recording". + + +