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 <noreply@anthropic.com>
This commit is contained in:
Gregor Lohaus
2026-05-31 21:04:00 +02:00
parent 50641d0a6a
commit 1f7394d75a
2 changed files with 102 additions and 0 deletions

View File

@@ -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).
*
* <p>All render work runs on the JavaFX application thread, so the accumulators are plain
* fields with no synchronization.
*
* <p>Caveat: JavaFX canvas drawing is deferred to the QuantumRenderer thread, so the
* {@link #DRAW} bucket measures only the cost of <em>recording</em> 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;
}
}

View File

@@ -91,7 +91,9 @@ final class TerminalPaneNode extends Region {
void renderFull(boolean active) { void renderFull(boolean active) {
prepareGeometry(); prepareGeometry();
long snapshotStart = RenderProfiler.start();
RenderStateSnapshot snapshot = pane.snapshotFull(); RenderStateSnapshot snapshot = pane.snapshotFull();
RenderProfiler.stop(RenderProfiler.SNAPSHOT, snapshotStart);
long renderedVersion = pane.snapshotVersion(); long renderedVersion = pane.snapshotVersion();
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics(); boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
updateRowsFull(snapshot); updateRowsFull(snapshot);
@@ -102,6 +104,13 @@ final class TerminalPaneNode extends Region {
} }
void renderIncremental(boolean active) { 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 geometryChanged = prepareGeometry();
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics(); boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
if (drawnContentVersion == Long.MIN_VALUE || geometryChanged || withKitty) { if (drawnContentVersion == Long.MIN_VALUE || geometryChanged || withKitty) {
@@ -113,7 +122,9 @@ final class TerminalPaneNode extends Region {
return; return;
} }
long snapshotStart = RenderProfiler.start();
RenderStateSnapshot snapshot = pane.snapshot(); RenderStateSnapshot snapshot = pane.snapshot();
RenderProfiler.stop(RenderProfiler.SNAPSHOT, snapshotStart);
long renderedVersion = pane.snapshotVersion(); long renderedVersion = pane.snapshotVersion();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty(); int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) { if (dirty == DIRTY_FULL) {
@@ -167,7 +178,9 @@ final class TerminalPaneNode extends Region {
Set<Integer> liveRows = new HashSet<>(); Set<Integer> liveRows = new HashSet<>();
for (RenderRow row : snapshot.renderRows()) { for (RenderRow row : snapshot.renderRows()) {
TerminalRowNode node = rowNode(row.row()); TerminalRowNode node = rowNode(row.row());
long fpStart = RenderProfiler.start();
long fingerprint = rowFingerprint(row); long fingerprint = rowFingerprint(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
node.render(row); node.render(row);
rowFingerprints.put(row.row(), fingerprint); rowFingerprints.put(row.row(), fingerprint);
liveRows.add(row.row()); liveRows.add(row.row());
@@ -197,7 +210,9 @@ final class TerminalPaneNode extends Region {
continue; continue;
} }
TerminalRowNode node = rowNode(row.row()); TerminalRowNode node = rowNode(row.row());
long fpStart = RenderProfiler.start();
long fingerprint = rowFingerprint(row); long fingerprint = rowFingerprint(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
node.renderChanged(row); node.renderChanged(row);
rowFingerprints.put(row.row(), fingerprint); rowFingerprints.put(row.row(), fingerprint);
} }
@@ -212,7 +227,9 @@ final class TerminalPaneNode extends Region {
return Set.of(); return Set.of();
} }
long shiftStart = RenderProfiler.start();
ShiftPlan plan = detectShift(snapshot, changedRows); ShiftPlan plan = detectShift(snapshot, changedRows);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, shiftStart);
if (plan == null) { if (plan == null) {
return Set.of(); return Set.of();
} }
@@ -692,6 +709,7 @@ final class TerminalPaneNode extends Region {
private void render(RenderRow row) { private void render(RenderRow row) {
prepareCanvas(row); prepareCanvas(row);
long drawStart = RenderProfiler.start();
GraphicsContext gc = canvas.getGraphicsContext2D(); GraphicsContext gc = canvas.getGraphicsContext2D();
gc.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight()); gc.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight());
gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFontSmoothingType(FontSmoothingType.LCD);
@@ -699,14 +717,20 @@ final class TerminalPaneNode extends Region {
paintSidePadding(gc, row, canvas.getWidth(), canvas.getHeight()); paintSidePadding(gc, row, canvas.getWidth(), canvas.getHeight());
drawRow(gc, row, rowTop(row), metrics.cellWidth(), metrics.lineHeight()); drawRow(gc, row, rowTop(row), metrics.cellWidth(), metrics.lineHeight());
RenderProfiler.stop(RenderProfiler.DRAW, drawStart);
long fpStart = RenderProfiler.start();
cellFingerprints = cellFingerprints(row); cellFingerprints = cellFingerprints(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
} }
private void renderChanged(RenderRow row) { private void renderChanged(RenderRow row) {
double oldWidth = canvas.getWidth(); double oldWidth = canvas.getWidth();
double oldHeight = canvas.getHeight(); double oldHeight = canvas.getHeight();
prepareCanvas(row); prepareCanvas(row);
long fpStart = RenderProfiler.start();
long[] nextFingerprints = cellFingerprints(row); long[] nextFingerprints = cellFingerprints(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
if (cellFingerprints.length != nextFingerprints.length if (cellFingerprints.length != nextFingerprints.length
|| oldWidth != canvas.getWidth() || oldWidth != canvas.getWidth()
|| oldHeight != canvas.getHeight()) { || oldHeight != canvas.getHeight()) {
@@ -714,6 +738,7 @@ final class TerminalPaneNode extends Region {
return; return;
} }
long drawStart = RenderProfiler.start();
GraphicsContext gc = canvas.getGraphicsContext2D(); GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(metrics.font()); gc.setFont(metrics.font());
@@ -741,6 +766,7 @@ final class TerminalPaneNode extends Region {
if (runStart >= 0) { if (runStart >= 0) {
repaintColumns(gc, row, runStart, runEnd); repaintColumns(gc, row, runStart, runEnd);
} }
RenderProfiler.stop(RenderProfiler.DRAW, drawStart);
cellFingerprints = nextFingerprints; cellFingerprints = nextFingerprints;
} }