Files
1jehuang--jcode/scripts/profile_real_spawn.py
jeremy a129aa4792 perf(tui): cut the decorative animation's cost on a real session
The previous fix stopped the animation from running when nothing was on screen.
It did not make the animation itself cheap, and the user correctly reported that
spawning a new session still lagged. That report was right, and my first repro
could not see it: an isolated `JCODE_HOME` lands on the onboarding screen with no
transcript, while a real spawn resumes a real session.

Measured against the real environment instead (`scripts/repro_real_spawn_lag.py`,
`scripts/measure_animation_cpu_cost.py`), the animation cost 0.257 CPU cores over
an idle client and nearly doubled keystroke latency. `draw-stats` could not show
it, because the animation-only partial repaint deliberately skips
`record_draw_call_attribution`: it reported `draws_per_s: 0.0` while the client
burned 0.3 cores. `perf` on that client named the work.

Two fixes, both measured:

1. Stop cloning the whole screen twice per animation frame. The "cheap" partial
   repaint did `clone_from` to seed a working buffer and `clone` to keep a copy,
   even though only the animated rectangle changes: ~920k cell copies a second at
   60fps on a 160x48 terminal to update ~2200 cells. Now it seeds once and
   thereafter copies only the animated rows, and keeps the remembered frame
   current by re-rendering those same rows (deterministic for a given elapsed
   time) instead of copying 7680 cells. Every path that rewrites the buffer
   clears `seeded_animation_area`, so the "outside the rectangle is already
   correct" assumption can never go stale.

2. Cap the decorative animation at 30fps. Its cost is linear in frame rate while
   its perceived smoothness is not, and the sweep
   (`scripts/sweep_animation_fps.py`) shows where the curve flattens:

   | fps | CPU over idle baseline |
   |-----|------------------------|
   | 60  | 0.224 cores            |
   | 30  | 0.104 cores            |
   | 20  | 0.080 cores            |

   Functional motion (status spinners, scroll catch-up, streaming) keeps the full
   configured `animation_fps`, because there smoothness is the feature. A user who
   configures a *lower* rate keeps it: the cap is a ceiling, not an override.

Net effect on the reported symptom, same real session:

| | before | after |
|---|---|---|
| keystroke latency p50 | 6.6ms | 1.08ms |
| keystroke latency p95 | 13.8ms | 1.21ms |
| animation cost | 0.257 cores | 0.121 cores |

Keystroke latency is the number the user feels, and it improved 6x.

The optimization is a correctness risk, not just a speed change, so
`copying_only_the_animated_rows_matches_cloning_the_whole_frame` proves the
row-copy sequence is byte-identical to a full clone every tick across sizes and a
run of ticks, plus a degenerate empty rectangle. Two existing cadence tests
asserted the decoration runs at exactly the configured 60fps; they now assert it
animates (faster than idle, still smooth) and the cap has its own test, so the
intent is pinned without freezing the number this commit deliberately changes.

The measurement scripts are committed because the bug was invisible to every
existing instrument: harnesses that fake the environment measure the wrong thing,
and CPU is the only honest signal when a render path is exempt from draw counting.
2026-07-29 17:17:32 -07:00

86 lines
3.6 KiB
Python

#!/usr/bin/env python3
"""
Profile a client spawned into the user's *real* environment.
`repro_real_spawn_lag.py` showed the shape of the problem: 0.3 CPU cores burned
while `draws_per_s` reads 0.0. Those are not full frames, so `draw-stats` cannot
see them: the animation-only partial repaint path deliberately skips
`record_draw_call_attribution`. `perf` sees the work regardless of which path
does it, so this attributes the burn on a real session.
"""
from __future__ import annotations
import argparse, json, os, signal, subprocess, sys, tempfile, time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import repro_slash_flicker as flick # noqa: E402
from repro_real_spawn_lag import recent_session # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--binary", default=str(REPO_ROOT / "target" / "selfdev" / "jcode"))
ap.add_argument("--session", default=None)
ap.add_argument("--seconds", type=float, default=8.0)
ap.add_argument("--freq", type=int, default=997)
args = ap.parse_args()
flick.ROWS, flick.COLS = 48, 160
binary = str(Path(args.binary).resolve())
runtime = Path(os.environ.get("JCODE_RUNTIME_DIR") or f"/run/user/{os.getuid()}")
env = os.environ.copy()
env["JCODE_SOCKET"] = env.get("JCODE_SOCKET") or str(runtime / "jcode.sock")
env["JCODE_DEBUG_CONTROL"] = "1"
env["JCODE_THEME"] = "dark"
debug_sock = runtime / "jcode-debug.sock"
scratch = Path(os.environ.get("JCODE_SCRATCH_DIR") or tempfile.gettempdir())
root = Path(tempfile.mkdtemp(prefix="jcode-profile-real-", dir=str(scratch)))
cmd_path, resp_path = root / "client_cmd", root / "client_resp"
session = args.session or recent_session(debug_sock, str(REPO_ROOT))
if not session:
session = flick.dbg(debug_sock, f"create_session:{REPO_ROOT}").strip().split()[-1]
print(f"== profiling real spawn ==\n binary : {binary}\n session: {session}")
client = None
try:
client = flick.launch(binary, env, session, cmd_path, resp_path)
if not flick.settle(cmd_path, resp_path, timeout_s=90.0):
print("client never came up")
return 3
time.sleep(3.0)
sched = (json.loads(flick.client_cmd(cmd_path, resp_path, "draw-stats 1"))
.get("redraw_schedule") or {})
print(f" donut={sched.get('idle_animation_active')} "
f"area={sched.get('idle_animation_area')} "
f"interval={sched.get('interval_ms')}ms")
data = root / "perf.data"
print(f" sampling {args.seconds}s at {args.freq}Hz ...")
rec = subprocess.run(["perf", "record", "-F", str(args.freq), "-g",
"--pid", str(client.proc.pid), "-o", str(data),
"--", "sleep", str(args.seconds)],
capture_output=True, text=True)
if rec.returncode != 0 or not data.exists():
print(" perf record failed:"); print((rec.stderr or rec.stdout)[-1500:]); return 3
rep = subprocess.run(["perf", "report", "-i", str(data), "--stdio",
"--no-children", "--percent-limit", "1.0", "-g", "none"],
capture_output=True, text=True)
print("\n=== self time ===")
for line in rep.stdout.splitlines():
if line.strip() and not line.startswith("#"):
print(line[:150])
return 0
finally:
if client:
client.shutdown()
import shutil
shutil.rmtree(root, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())