Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions benchmark/latency/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Glass-to-pixel latency harness

The suite in `benchmark/` measures how fast the emulator **consumes** bytes: `cat` a file,
time how long it takes to return. Every number in `benchmark_results/` is of that shape.

None of it can see the interval that decides whether a terminal feels snappy - the time
between a byte landing in the PTY and the pixel that byte produces. That interval holds the
redraw debounce, the data-stream poll timeout and the whole paint pass, and it is invisible
to a throughput benchmark: an emulator can parse 1.6 GB/s and still wait 50 ms before
drawing any of it.

This harness measures that interval.

## What is measured

Build with the probe compiled in (it always is; it is inert unless the env flag is set) and
launch with `BOSSTERM_FRAME_PROBE=1`. A daemon thread then writes a JSON snapshot once a
second to `~/.bossterm/frame-probe.json`:

| Field | Meaning |
|---|---|
| `byteToPaintMs` | PTY chunk arrival to the end of the paint pass that first draws it. The number a user feels. |
| `paintCostMs` | Wall time inside `renderTerminal`. Decides whether a throttle is needed at all. |
| `lockedCaptureMs` | UI-thread time holding the terminal buffer lock to capture a frame. Expected to grow with scrollback depth. |
| `drawCallsPerFrame` | `drawText` invocations per paint. The multiplier on text layout cost. |
| `idleFrames` | Paints that drew no newly-arrived data (blink, resize, selection, scroll). |

Each is `{n, p50, p95, p99, max, mean}`. Latencies are milliseconds. **Report percentiles,
never means** - the tail is what gets noticed.

### The honest caveat

`byteToPaintMs` is measured to *draw-issued*, not to photons. It excludes GPU present and
vsync, so it is a **lower bound** on real latency. Two builds measured the same way compare
soundly; an absolute claim about "how many milliseconds a user waits" does not follow from
this number alone and needs the external anchor below.

## Running it

```bash
# 1. Launch with the probe on. The user runs the app; nothing here launches it.
BOSSTERM_FRAME_PROBE=1 ./gradlew :bossterm-app:run --no-daemon

# 2. Zero the histograms immediately before a workload.
./benchmark/latency/probe.sh reset

# 3. Run one workload in the BossTerm window under test.
./benchmark/latency/workloads.sh keystrokes # (a) 200 single keypresses at a prompt
./benchmark/latency/workloads.sh bulk # (b) cat a 5 MB log
./benchmark/latency/workloads.sh tui # (c) full-screen redraw loop
./benchmark/latency/workloads.sh scroll # (d) full-screen scroll
./benchmark/latency/workloads.sh aged # (e) (b) again, after 10k lines of scrollback

# 4. Read the result.
./benchmark/latency/probe.sh show
```

Workload (e) is the one that exposes scrollback-dependent cost: run it in the *same* tab as
a preceding `bulk`, never a fresh one.

## What shipped, and what it bought

Measured on this harness, then made default (no flags remain):

| change | effect |
|---|---|
| redraw debounce removed (was 8 ms, 50 ms under load) | interactive echo 16.4 ms -> ~2-4 ms |
| `performanceMode` default `balanced` -> `latency` | ~4 ms of that, on its own |
| blanks extend a batched run; ASCII cells skip the grapheme probes | `drawText` 208 -> 30 per frame on `tui`, 176 -> 60 on `scroll` |

Not fixed, and not a render problem: bulk output still shows a ~1.2 s p95 on a 5 MB `cat`.
`triggerToPaint` there is single-digit milliseconds, so the time is queue wait and parse,
upstream of anything the renderer or the debounce controls.

## Verifying a renderer change

A renderer change fails by producing a wrong *picture*, which no unit test sees. Render
`unicode-torture.sh` twice, once before and once after, capture the window both times, and
diff the two images. Check column alignment specifically: a merged glyph run that advances by
font metrics rather than by cell width drifts progressively along a line, which a
whole-image pixel count will not make obvious.

## External anchor - do this once

The in-process number is a proxy. Before any conclusion rests on it, confirm it tracks
reality: film ~10 keypresses at a shell prompt with a phone at 240 fps, count frames from
key-down to the glyph appearing, and compare the median against `byteToPaintMs.p50` plus one
frame of present. If they disagree by more than a frame, the probe is wrong and gets fixed
before any tuning decision is made on it.
69 changes: 69 additions & 0 deletions benchmark/latency/probe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Read and reset the BossTerm frame-latency probe.
#
# The probe writes a JSON snapshot once a second to $BOSSTERM_FRAME_PROBE_OUT, or
# ~/.bossterm/frame-probe.json by default. It only runs when the app was launched with
# BOSSTERM_FRAME_PROBE=1; without that, the file never appears.
set -euo pipefail

OUT="${BOSSTERM_FRAME_PROBE_OUT:-$HOME/.bossterm/frame-probe.json}"
RESET="$(dirname "$OUT")/frame-probe.reset"

usage() {
cat <<USAGE
usage: probe.sh {reset|show|watch|path}

reset zero the histograms, then wait for the sampler to confirm
show print the current snapshot
watch print a new snapshot every second until interrupted
path print where the snapshot file lives

Snapshot file: $OUT
USAGE
}

require_snapshot() {
if [[ ! -f "$OUT" ]]; then
echo "no snapshot at $OUT" >&2
echo "launch the app with BOSSTERM_FRAME_PROBE=1 and give it a second to sample" >&2
exit 1
fi
}

case "${1:-}" in
reset)
require_snapshot
before=$(stat -f %m "$OUT" 2>/dev/null || stat -c %Y "$OUT")
touch "$RESET"
# The sampler deletes the marker as it zeroes. Waiting for that, rather than
# returning immediately, is what stops a workload from starting against counters
# that still hold the previous run.
for _ in $(seq 1 50); do
if [[ ! -f "$RESET" ]]; then
now=$(stat -f %m "$OUT" 2>/dev/null || stat -c %Y "$OUT")
if [[ "$now" != "$before" ]]; then
echo "reset confirmed"
exit 0
fi
fi
sleep 0.2
done
echo "reset marker was not consumed within 10s - is the app running with BOSSTERM_FRAME_PROBE=1?" >&2
exit 1
;;
show)
require_snapshot
cat "$OUT"
;;
watch)
require_snapshot
while true; do
date +%H:%M:%S
cat "$OUT"
echo
sleep 1
done
;;
path) echo "$OUT" ;;
*) usage; exit 1 ;;
esac
32 changes: 32 additions & 0 deletions benchmark/latency/unicode-torture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Every construct the text pass special-cases, on one screen.
#
# The fast text path (blank batching + the ASCII probe skip) is the one change here that can
# fail silently: it produces a WRONG PICTURE rather than a wrong value, so no unit test sees
# it. This fixture exists to be rendered twice, with the fast path off and on, and the two
# frames compared pixel for pixel.
#
# Deliberately heavy on runs of spaces, because that is exactly what blank batching changes:
# aligned columns, indentation, and trailing gaps under an underline.
printf '\033[2J\033[H'
echo "ascii the quick brown fox jumps over the lazy dog 0123456789"
echo "aligned name size modified mode"
echo "aligned src/ 4096 Aug 26 10:11 drwxr-xr-x"
echo "aligned README.md 1024 Aug 26 10:12 -rw-r--r--"
printf 'zwj \xF0\x9F\x91\xA8\xE2\x80\x8D\xF0\x9F\x92\xBB family \xF0\x9F\x91\xA9\xE2\x80\x8D\xF0\x9F\x91\xA9\xE2\x80\x8D\xF0\x9F\x91\xA7 end\n'
printf 'flags \xF0\x9F\x87\xBA\xF0\x9F\x87\xB8 \xF0\x9F\x87\xAF\xF0\x9F\x87\xB5 \xF0\x9F\x87\xAE\xF0\x9F\x87\xB3 end\n'
printf 'skintone \xF0\x9F\x91\x8B\xF0\x9F\x8F\xBD \xF0\x9F\x91\x8D\xF0\x9F\x8F\xBF end\n'
printf 'varsel \xE2\x9C\x94\xEF\xB8\x8F \xE2\x9A\xA0\xEF\xB8\x8F \xE2\x9D\xA4\xEF\xB8\x8F end\n'
printf 'cjk \xE4\xBD\xA0\xE5\xA5\xBD\xE4\xB8\x96\xE7\x95\x8C \xE3\x81\x93\xE3\x82\x93\xE3\x81\xAB\xE3\x81\xA1\xE3\x81\xAF end\n'
printf 'powerline \xEE\x82\xB0 \xEE\x82\xB1 \xEE\x82\xB2 \xEE\x82\xB3 end\n'
printf 'underline \033[4munderlined with gaps\033[0m and \033[4mtrailing \033[0m|\n'
printf 'bold/italic \033[1mbold text\033[0m \033[3mitalic text\033[0m \033[1;3mboth\033[0m\n'
printf 'truecolor '
for i in 0 1 2 3 4 5 6 7; do printf '\033[38;2;%d;%d;%dm block \033[0m' $((i*31)) $((255-i*31)) $((i*17)); done
printf '\n'
printf 'inverse \033[7m inverse with spaces \033[0m end\n'
printf 'bg runs \033[41m red \033[42m green \033[44m blue \033[0m end\n'
printf 'trailing text with trailing spaces \n'
printf 'leading indented by many spaces\n'
printf 'dim \033[2mdim text here\033[0m end\n'
echo
80 changes: 80 additions & 0 deletions benchmark/latency/workloads.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Fixed workloads for the frame-latency probe.
#
# Run these INSIDE the BossTerm tab being measured, with the probe reset immediately
# beforehand (`probe.sh reset`). Each one is deterministic so two builds see identical work.
#
# Scope note: the probe stamps a chunk when it ARRIVES from the PTY, so everything here
# measures the output half of the loop (arrival -> pixel). The input half (keypress ->
# PTY write) is not covered by any of these and needs the external camera anchor described
# in README.md. `interactive` is the closest proxy: isolated single-character writes, which
# is the shape a shell echo has, and the case the redraw debounce penalises hardest.
set -euo pipefail

DURATION_TUI="${DURATION_TUI:-10}"
FIXTURE_DIR="${TMPDIR:-/tmp}/bossterm-latency"
mkdir -p "$FIXTURE_DIR"

make_log() {
local path="$FIXTURE_DIR/bulk-5mb.log"
if [[ ! -f "$path" ]]; then
# Deterministic content, mixed line lengths and some ANSI colour so the run
# exercises style runs rather than one uniform batch.
awk 'BEGIN {
for (i = 0; i < 60000; i++) {
printf "\033[32m%06d\033[0m INFO module-%02d request completed in %3d ms path=/api/v1/resource/%d\n", i, i % 32, i % 250, i
}
}' > "$path"
fi
echo "$path"
}

case "${1:-}" in
interactive)
# 200 isolated single-character writes, 50 ms apart. Each one is its own PTY chunk,
# so each pays the full arrival-to-paint cost with no batching to hide behind.
for _ in $(seq 1 200); do
printf 'x'
sleep 0.05
done
printf '\n'
;;
bulk)
cat "$(make_log)"
;;
tui)
# Full-screen repaint loop on the alternate screen: the regime that trips
# HIGH_VOLUME mode (>100 redraws/sec) and drops the terminal to 20 fps.
printf '\033[?1049h'
end=$(( SECONDS + DURATION_TUI ))
frame=0
while (( SECONDS < end )); do
printf '\033[H'
for row in $(seq 1 40); do
printf '\033[%dm row %02d frame %06d %s\033[0m\n' \
$(( 31 + (row + frame) % 7 )) "$row" "$frame" \
'the quick brown fox jumps over the lazy dog 0123456789'
done
frame=$(( frame + 1 ))
sleep 0.02
done
printf '\033[?1049l'
echo "tui: $frame frames in ${DURATION_TUI}s"
;;
scroll)
# Continuous single-line scrolling: every chunk shifts the whole viewport.
for i in $(seq 1 5000); do
printf '\033[36m%05d\033[0m scrolling line with enough width to fill a normal terminal column count\n' "$i"
done
;;
aged)
# Finding E: the per-frame snapshot walks screen AND full history, so cost is
# expected to rise with scrollback depth. Run in the SAME tab, never a fresh one.
seq 1 10000 | sed 's/^/scrollback filler line /'
cat "$(make_log)"
;;
*)
echo "usage: workloads.sh {interactive|bulk|tui|scroll|aged}" >&2
exit 1
;;
esac
Loading
Loading