diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 27f561b69f..0b54cbdd52 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -46,6 +46,7 @@ import { useEvent } from "./context/event" import { ClientProvider, useClient } from "./context/client" import { StartupLoading } from "./component/startup-loading" import { DevToolsSidebar } from "./component/devtools-sidebar" +import { PerformanceDevTools } from "./component/performance-devtools" import { DevTools } from "./devtools" import { Reconnecting } from "./component/reconnecting" import { DataProvider, useData } from "./context/data" @@ -1135,7 +1136,10 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { - + <> + + + diff --git a/packages/tui/src/component/performance-devtools.tsx b/packages/tui/src/component/performance-devtools.tsx new file mode 100644 index 0000000000..c326d4574f --- /dev/null +++ b/packages/tui/src/component/performance-devtools.tsx @@ -0,0 +1,93 @@ +import { useRenderer } from "@opentui/solid" +import { onCleanup } from "solid-js" +import { DevTools } from "../devtools" + +const sampleInterval = 1_000 +const eventLoopInterval = 100 + +export function PerformanceDevTools() { + const renderer = useRenderer() + const runtime = DevTools.register({ id: "runtime-performance", title: "Runtime performance" }) + const rendering = DevTools.register({ id: "renderer-performance", title: "Renderer performance" }) + let previousTime = performance.now() + let previousCpu = process.cpuUsage() + let eventLoopTime = previousTime + let eventLoopLag = 0 + + renderer.resetStats() + renderer.setGatherStats(true) + + const eventLoopTimer = setInterval(() => { + const now = performance.now() + eventLoopLag = Math.max(eventLoopLag, now - eventLoopTime - eventLoopInterval) + eventLoopTime = now + }, eventLoopInterval) + + const sample = setInterval(() => { + const now = performance.now() + const cpu = process.cpuUsage() + const elapsed = now - previousTime + const memory = process.memoryUsage() + const stats = renderer.getStats() + const scheduler = renderer.getSchedulerState() + const frameTimes = stats.frameTimes.toSorted((left, right) => left - right) + const frameP95 = frameTimes[Math.ceil(frameTimes.length * 0.95) - 1] + + runtime.setAll([ + { + key: "TUI CPU", + value: `${(((cpu.user - previousCpu.user + cpu.system - previousCpu.system) / (elapsed * 1_000)) * 100).toFixed(1)}%`, + }, + { key: "Event loop max", value: `${Math.max(0, eventLoopLag).toFixed(1)} ms` }, + { key: "RSS", value: megabytes(memory.rss) }, + { key: "Heap", value: `${megabytes(memory.heapUsed)} / ${megabytes(memory.heapTotal)}` }, + { key: "Array buffers", value: megabytes(memory.arrayBuffers) }, + ]) + rendering.setAll([ + { key: "FPS", value: stats.fps }, + { key: "Frame p95", value: milliseconds(frameP95) }, + { key: "Frame max", value: milliseconds(stats.maxFrameTime || undefined) }, + { key: "Native render", value: microseconds(stats.nativeRenderTime) }, + { key: "Terminal write", value: microseconds(stats.nativeStdoutWriteTime) }, + { key: "Frame callbacks", value: milliseconds(stats.frameCallbackTime) }, + { key: "Cells updated", value: stats.cellsUpdated }, + { key: "Cells average", value: stats.averageCellsUpdated }, + { key: "Target FPS", value: renderer.targetFps }, + { + key: "Scheduler", + value: scheduler.isRendering + ? "rendering" + : scheduler.hasScheduledRender + ? "scheduled" + : scheduler.isRunning + ? "live" + : "idle", + }, + { key: "Terminal", value: `${renderer.width} x ${renderer.height}` }, + ]) + + previousTime = now + previousCpu = cpu + eventLoopLag = 0 + }, sampleInterval) + + onCleanup(() => { + clearInterval(eventLoopTimer) + clearInterval(sample) + renderer.setGatherStats(false) + }) + + return null +} + +function megabytes(bytes: number) { + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +function milliseconds(value: number | undefined) { + return value === undefined ? "n/a" : `${value.toFixed(2)} ms` +} + +function microseconds(value: number | undefined) { + return value === undefined ? "n/a" : milliseconds(value / 1_000) +} diff --git a/packages/tui/src/devtools/index.ts b/packages/tui/src/devtools/index.ts index ddb84b6272..1050c10f61 100644 --- a/packages/tui/src/devtools/index.ts +++ b/packages/tui/src/devtools/index.ts @@ -4,10 +4,12 @@ import { createSignal } from "solid-js" export type Value = string | number | boolean | null +export type Entry = Readonly<{ key: string; value: Value }> + export type Group = Readonly<{ id: string title: string - entries: readonly Readonly<{ key: string; value: Value }>[] + entries: readonly Entry[] }> const [groups, setGroups] = createSignal([]) @@ -35,6 +37,16 @@ export function register(input: { id: string; title: string }) { }), ) }, + setAll(entries: readonly Entry[]) { + setGroups((groups) => + groups.map((group) => { + if (group.id !== input.id) return group + const next = new Map(group.entries.map((entry) => [entry.key, entry])) + entries.forEach((entry) => next.set(entry.key, entry)) + return { ...group, entries: [...next.values()] } + }), + ) + }, } } diff --git a/packages/tui/test/devtools.test.ts b/packages/tui/test/devtools.test.ts index 5997c8aa4e..4cb19a3a75 100644 --- a/packages/tui/test/devtools.test.ts +++ b/packages/tui/test/devtools.test.ts @@ -17,3 +17,26 @@ test("registers and updates grouped DevTools data", () => { ], }) }) + +test("updates a DevTools group in one batch", () => { + const group = DevTools.register({ id: "batch", title: "Batch data" }) + + group.setAll([ + { key: "CPU", value: "20%" }, + { key: "Memory", value: "100 MB" }, + ]) + group.setAll([ + { key: "CPU", value: "30%" }, + { key: "FPS", value: 60 }, + ]) + + expect(DevTools.data().find((item) => item.id === "batch")).toEqual({ + id: "batch", + title: "Batch data", + entries: [ + { key: "CPU", value: "30%" }, + { key: "Memory", value: "100 MB" }, + { key: "FPS", value: 60 }, + ], + }) +})