From 1f7394d75ab7a8cd665c351c371512669a77ba7d Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 21:04:00 +0200 Subject: [PATCH] add opt-in render profiler instrumentation Gated behind -Djprototerm.profile=true (or JPROTOTERM_PROFILE=1), accumulates per-frame nanos into snapshot/fingerprint/draw/frame-total buckets and dumps to stderr every N renders. Splits the three suspected render costs: native snapshot marshaling, fingerprint hashing, and canvas draw recording. Co-Authored-By: Claude Opus 4.8 --- .../com/gregor/jprototerm/RenderProfiler.java | 76 +++++++++++++++++++ .../gregor/jprototerm/TerminalPaneNode.java | 26 +++++++ 2 files changed, 102 insertions(+) create mode 100644 src/main/java/com/gregor/jprototerm/RenderProfiler.java diff --git a/src/main/java/com/gregor/jprototerm/RenderProfiler.java b/src/main/java/com/gregor/jprototerm/RenderProfiler.java new file mode 100644 index 0000000..28e5f64 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/RenderProfiler.java @@ -0,0 +1,76 @@ +package com.gregor.jprototerm; + +/** + * Lightweight render profiler, disabled unless {@code -Djprototerm.profile=true} (or the + * {@code JPROTOTERM_PROFILE=1} environment variable) is set. It accumulates wall-clock nanos + * into a handful of buckets and prints aggregate per-frame stats to stderr every + * {@code jprototerm.profile.frames} render invocations (default 120). + * + *

All render work runs on the JavaFX application thread, so the accumulators are plain + * fields with no synchronization. + * + *

Caveat: JavaFX canvas drawing is deferred to the QuantumRenderer thread, so the + * {@link #DRAW} bucket measures only the cost of recording draw commands, not the + * GPU paint. Pair this with {@code -Djavafx.pulseLogger=true} to see the render-thread side. + */ +final class RenderProfiler { + static final int SNAPSHOT = 0; + static final int FINGERPRINT = 1; + static final int DRAW = 2; + static final int FRAME = 3; + private static final int BUCKETS = 4; + private static final String[] NAMES = {"snapshot", "fingerprint", "draw", "frame-total"}; + + private static final boolean ENABLED = + Boolean.getBoolean("jprototerm.profile") || "1".equals(System.getenv("JPROTOTERM_PROFILE")); + private static final int DUMP_FRAMES = Integer.getInteger("jprototerm.profile.frames", 120); + + private static final long[] totalNanos = new long[BUCKETS]; + private static final long[] counts = new long[BUCKETS]; + private static int frames; + + private RenderProfiler() { + } + + static boolean enabled() { + return ENABLED; + } + + /** Returns a start timestamp, or 0 when profiling is disabled. */ + static long start() { + return ENABLED ? System.nanoTime() : 0L; + } + + /** Records the time elapsed since {@code startNanos} into {@code bucket}. */ + static void stop(int bucket, long startNanos) { + if (!ENABLED) { + return; + } + totalNanos[bucket] += System.nanoTime() - startNanos; + counts[bucket]++; + } + + /** Marks the end of one render invocation; dumps and resets every {@code DUMP_FRAMES}. */ + static void frame() { + if (!ENABLED) { + return; + } + if (++frames < DUMP_FRAMES) { + return; + } + dump(); + } + + private static void dump() { + StringBuilder sb = new StringBuilder(192); + sb.append("[render-profile] ").append(frames).append(" renders"); + for (int i = 0; i < BUCKETS; i++) { + double totalMs = totalNanos[i] / 1_000_000.0; + sb.append(String.format(" | %s %.3fms/f (n=%d)", NAMES[i], totalMs / frames, counts[i])); + totalNanos[i] = 0; + counts[i] = 0; + } + System.err.println(sb); + frames = 0; + } +} diff --git a/src/main/java/com/gregor/jprototerm/TerminalPaneNode.java b/src/main/java/com/gregor/jprototerm/TerminalPaneNode.java index cfbc1ed..cb39616 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPaneNode.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPaneNode.java @@ -91,7 +91,9 @@ final class TerminalPaneNode extends Region { void renderFull(boolean active) { prepareGeometry(); + long snapshotStart = RenderProfiler.start(); RenderStateSnapshot snapshot = pane.snapshotFull(); + RenderProfiler.stop(RenderProfiler.SNAPSHOT, snapshotStart); long renderedVersion = pane.snapshotVersion(); boolean withKitty = pane.kittyEnabled() && hasKittyGraphics(); updateRowsFull(snapshot); @@ -102,6 +104,13 @@ final class TerminalPaneNode extends Region { } void renderIncremental(boolean active) { + long frameStart = RenderProfiler.start(); + renderIncrementalBody(active); + RenderProfiler.stop(RenderProfiler.FRAME, frameStart); + RenderProfiler.frame(); + } + + private void renderIncrementalBody(boolean active) { boolean geometryChanged = prepareGeometry(); boolean withKitty = pane.kittyEnabled() && hasKittyGraphics(); if (drawnContentVersion == Long.MIN_VALUE || geometryChanged || withKitty) { @@ -113,7 +122,9 @@ final class TerminalPaneNode extends Region { return; } + long snapshotStart = RenderProfiler.start(); RenderStateSnapshot snapshot = pane.snapshot(); + RenderProfiler.stop(RenderProfiler.SNAPSHOT, snapshotStart); long renderedVersion = pane.snapshotVersion(); int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty(); if (dirty == DIRTY_FULL) { @@ -167,7 +178,9 @@ final class TerminalPaneNode extends Region { Set liveRows = new HashSet<>(); for (RenderRow row : snapshot.renderRows()) { TerminalRowNode node = rowNode(row.row()); + long fpStart = RenderProfiler.start(); long fingerprint = rowFingerprint(row); + RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart); node.render(row); rowFingerprints.put(row.row(), fingerprint); liveRows.add(row.row()); @@ -197,7 +210,9 @@ final class TerminalPaneNode extends Region { continue; } TerminalRowNode node = rowNode(row.row()); + long fpStart = RenderProfiler.start(); long fingerprint = rowFingerprint(row); + RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart); node.renderChanged(row); rowFingerprints.put(row.row(), fingerprint); } @@ -212,7 +227,9 @@ final class TerminalPaneNode extends Region { return Set.of(); } + long shiftStart = RenderProfiler.start(); ShiftPlan plan = detectShift(snapshot, changedRows); + RenderProfiler.stop(RenderProfiler.FINGERPRINT, shiftStart); if (plan == null) { return Set.of(); } @@ -692,6 +709,7 @@ final class TerminalPaneNode extends Region { private void render(RenderRow row) { prepareCanvas(row); + long drawStart = RenderProfiler.start(); GraphicsContext gc = canvas.getGraphicsContext2D(); gc.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight()); gc.setFontSmoothingType(FontSmoothingType.LCD); @@ -699,14 +717,20 @@ final class TerminalPaneNode extends Region { paintSidePadding(gc, row, canvas.getWidth(), canvas.getHeight()); drawRow(gc, row, rowTop(row), metrics.cellWidth(), metrics.lineHeight()); + RenderProfiler.stop(RenderProfiler.DRAW, drawStart); + + long fpStart = RenderProfiler.start(); cellFingerprints = cellFingerprints(row); + RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart); } private void renderChanged(RenderRow row) { double oldWidth = canvas.getWidth(); double oldHeight = canvas.getHeight(); prepareCanvas(row); + long fpStart = RenderProfiler.start(); long[] nextFingerprints = cellFingerprints(row); + RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart); if (cellFingerprints.length != nextFingerprints.length || oldWidth != canvas.getWidth() || oldHeight != canvas.getHeight()) { @@ -714,6 +738,7 @@ final class TerminalPaneNode extends Region { return; } + long drawStart = RenderProfiler.start(); GraphicsContext gc = canvas.getGraphicsContext2D(); gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFont(metrics.font()); @@ -741,6 +766,7 @@ final class TerminalPaneNode extends Region { if (runStart >= 0) { repaintColumns(gc, row, runStart, runEnd); } + RenderProfiler.stop(RenderProfiler.DRAW, drawStart); cellFingerprints = nextFingerprints; }