Skip to content

perf: smoother solve cold-start — warming-up UX + live-frame display fix - #23

Merged
jack-champagne merged 10 commits into
mainfrom
rchari/perf-smooth-solve
Jun 27, 2026
Merged

perf: smoother solve cold-start — warming-up UX + live-frame display fix#23
jack-champagne merged 10 commits into
mainfrom
rchari/perf-smooth-solve

Conversation

@Rchari1

@Rchari1 Rchari1 commented Jun 22, 2026

Copy link
Copy Markdown
Member

Makes the live-solve experience smoother. Stacked on #22; base rchari/qa-chat-fixes. Do not merge.

Diagnosis (measured)

The run-orchestrator lag is entirely cold-start, not the solver or the watcher:

  • ~79 s launch → first iteration (Julia + Piccolo load + first-iter JIT)
  • ~35 s the first plot_pulse call (CairoMakie compiles its render stack — one-time)
  • ~114 s of apparent "nothing happening" before the first frame
  • Steady state is fine: ~1.5–3 s/iter, plots 0.2–0.9 s.

amico-run spawns a fresh Julia per solve, so every cold solve pays this — not just the first.

Cold-start UX (the perf story here)

  • Inspector "warming up" state — shows "Julia warming up…" instead of an idle panel, so the cold start reads as intentional, not frozen. Buffered like the image/completion so it shows even if the panel opens late; replaced by the first frame.
  • Skip the iter-0 plot — it's just the random init, and it defers Makie's first-plot compile off the very first iteration.
  • Launch stays idle for a prior finished run — no stale last-plot on open; the panel goes idle → warming → frames for the next solve.

Live-frame display fix (the "plots never show" bug)

LiveRunSink shared one iter counter between frame display and log-line parsing. The run.log AMICODE_ITER lines stream once per iteration and race the counter to max before the lagging PNG frames land (the solver logs iter=k, then writes iter_k.png), so every image() hit k <= latest and was deduped away → blank inspector for the whole solve.

Folded-in review follow-ups

Kept here rather than restacking the 7-deep chain for one-liners (flagged on each PR thread):

No local sysimage build

An earlier rev added an opt-in PackageCompiler sysimage build to collapse the cold start. Removed — the native compile (CairoMakie-dominated, ~25–50 min, per-machine, non-shippable) wasn't worth it even opt-in. The generic amico-run --sysimage <path> flag + manifest plumbing (already on main from β.1) stays as the hook for the intended fast-path: a prebuilt sysimage distributed like Piccolissimo's — CI build on self-hosted binary-builder runners → tarball+sha256 → Cloudflare R2 → manifest.json → download. That's a separate effort (a Piccolissimo image can't be reused directly — amicode's needs Piccolo + CairoMakie baked in).

Tests

47 extension + 45 amico-run green (incl. SinkDedup live-path tests, ~-expansion, demo run.log packaging assertion).

Note for later

To actually eliminate the ~80 s cold start, the two levers are (a) the prebuilt-sysimage download above, or (b) a warm Julia daemon (keep one Piccolo-loaded process alive, send it solves) — bigger amico-run change, no build cost.

🤖 Generated with Claude Code

Rchari1 and others added 7 commits June 22, 2026 11:03
…onds)

Diagnosed the run-orchestrator lag: it's all cold-start — ~80s Julia/Piccolo
load + first-iter JIT, plus a ~35s first plot_pulse (Makie compiles its render
stack once). Steady state is fine (~1.5-3s/iter, 0.2-0.9s plots).

Fix: a PackageCompiler sysimage baking Piccolo + CairoMakie + the solve/plot
code paths (build_sysimage.jl + sysimage_exercise.jl). install.sh builds it
best-effort at <project>/amico-sysimage.{dylib,so}; amico-run auto-detects a
sysimage beside --project and passes --sysimage (no agent/flag change). Falls
back cleanly to no-sysimage (slow first run) if the build is skipped/fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tart)

- Inspector shows 'Julia warming up — first solve compiles (~1-2 min)' when a
  run has started but emitted no frame yet, instead of an idle panel, so the
  cold start doesn't read as frozen. Buffered like the image/completion so it
  shows even if the panel opens late; replaced by the first frame.
- Template skips the iter-0 plot (just the random init) — defers Makie's
  first-plot compile off the very first iteration when there's no sysimage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ll step

The PackageCompiler build (CairoMakie native-compile) is a long one-time cost
(~25-50 min, machine-dependent) — too slow to sit on the install critical path
(β.4's <=60min target). Flip it to opt-in: install.sh builds it only with
AMICO_BUILD_SYSIMAGE=1 (else prints the tip), plus a 'pnpm --filter amicode-v2
sysimage' script. amico-run auto-detects it once present; until then solves pay
the cold start and the inspector shows 'warming up'. One-time + per-machine
(native artifact — not shippable prebuilt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st the first)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…watch

Live frames/FINISHED were detected only via fs.watch (FSEvents), which coalesces
and silently drops events under CPU load → the inspector could sit on 'warming
up' while iter_*.png frames pile up on disk unrendered (reported: 'ran but not
plotting', frames present + valid on disk).

Add a 700ms polling backstop to RunsRootWatcher: re-resolve latest, rescan the
active run for the newest frame + FINISHED, and poke the log tailer. fs.watch
stays for low latency; the poll guarantees delivery. All sinks are idempotent
(frame dedup by iter, finishedSeen guard, log byte-offset) so the two paths
never double-deliver. LogTailer.poke() drains only after attach() sets the
start offset (never re-reads replayed lines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ERY 6

- A new run's 'warming up' state now clears the previous run's image + stats, so
  the old iter-N plot no longer lingers on screen while the new solve compiles
  (reported: 'displaying iter 60 from the old one as the new iters run').
- Template plots every 6 iters (was 10) for more frequent live frames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck review nits

The real "plots never display" bug: LiveRunSink shared one counter between
image() and iter(). run.log AMICODE_ITER lines stream once per iteration and
race the counter to max BEFORE the lagging PNG frames land (solver logs iter=k,
then writes iter_k.png), so every image() hit `k <= latest` and was dropped —
blank inspector for the whole solve. Split into SinkDedup: frames dedup only
against frames; log lines advance a separate high-water mark for the status bar.

Extracted SinkDedup to the pure, vscode-free run_dir_reader layer so the live
path is finally unit-tested (Jack #9: "no test covering the live status-bar /
incremental inspector path" — the exact gap this regression slipped through).

Also folds in the remaining non-blocking review nits (kept here rather than
restacking the 7-deep chain to place one-liners on their home branches):
- #13: #runlabel was styled but never populated → setRunLabel + webview handler.
- #12: resolveJuliaProject now expands a leading ~ (parity with resolveRunsRoot).
- #9:  server_manager ServerOptions.env comment no longer cites the removed
       AMICODE_EXTENSION_URL/MCP-callback env.
- #9/#11: AGENTS.md step-2 author path now matches step-3 run path
       (/tmp/amicode-work/solve.jl) and the invocation passes --lab default
       (run provenance — amico-run supports --lab).

Plus the cold-start display polish already in flight on this branch: launch
stays idle for a prior finished run (no stale plot), and the placeholder toggles
via style.display (the [hidden] attr was overridden by .placeholder{display:flex}).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ipped)

Jack #11 must-change ("bundle a real run.log") never actually landed: the
demo run.log is matched by .gitignore's `*.log`, so it was never committed.
.vscodeignore has `!demo/**` so it'd ship IF present on disk — meaning it
only "worked" on the capture machine; a clean checkout / CI VSIX shipped the
demo with NO run.log → blank stats row, exactly the failure he flagged.

Un-ignore the asset (.gitignore negation), track it, and assert it in the
packaging manifest test so a future drop is caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jack-champagne

Copy link
Copy Markdown
Member

Recording the #9#23 link for the record.

On #9 I flagged the absence of a test for the live status-bar / incremental inspector path. That gap was masking a real bug: at #9's head LiveRunSink shares one latestIter between image() (PNG frames) and iter() (run.log lines) — file_watcher.ts:37,47,52. The AMICODE_ITER log lines run ahead of the lagging PNG writes, so every image(k) hits k <= latestIter and drops the frame. #9 compiles and its tests pass, but the live inspector is blank for the whole solve.

#23 is what makes chat→solve→inspector actually work end-to-end:

  • e21539c splits frame dedup from the log high-water mark (SinkDedup) and folds in the missing live-path test (the regression guard: log lines must not suppress lagging frames).
  • 2287cad adds the poll backstop for frames fs.watch drops.

Net: #9 and #23 are a unit — #9 on its own ships a blank inspector. So the live-path test gap on #9 reads as "covered downstack in #23," not unaddressed.

The on-machine PackageCompiler build (CairoMakie native-compile dominated,
~25-50 min, per-machine, non-shippable) wasn't worth it even opt-in. Removed:
- julia/build_sysimage.jl + julia/sysimage_exercise.jl (the build scripts)
- the `sysimage` npm script
- the install.sh build block (AMICO_BUILD_SYSIMAGE gating)
- amico-run's auto-detect of <project>/amico-sysimage.{dylib,so} + its test

Kept the generic `--sysimage <path>` flag + manifest plumbing (already on main
from β.1): it's the passive hook for the intended fast-path — a prebuilt
sysimage distributed like Piccolissimo's (CI build on self-hosted binary-builder
runners → tarball+sha256 → Cloudflare R2 → manifest.json → download), pointed at
via the flag. That's a separate effort; a Piccolissimo image can't be reused
directly since amicode's needs Piccolo + CairoMakie baked in.

Until that exists, every solve pays the cold start and the inspector shows
"warming up" — which is now the whole of this PR's perf story.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Rchari1 Rchari1 changed the title perf: smoother solve cold-start — warming-up UX + optional sysimage perf: smoother solve cold-start — warming-up UX + live-frame display fix Jun 25, 2026

@jack-champagne jack-champagne left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the keystone and the code is correct — almost ready to go. The frame/log dedup (SinkDedup), the poll backstop, the AGENTS step-2/3 path reconciliation, the bundled run.log, and the #runlabel wiring close the open findings from #9/#11/#13, and the placeholder.hiddendisplay:none change squashes a real latent bug (the placeholder was never actually hiding — .placeholder{display:flex} out-specifies [hidden]). Two changes before merge:

  1. Retitle. The final commit removed the sysimage, so "perf: smoother solve cold-start" no longer fits — the cold start itself is unchanged; what's left is a warming-up indicator plus the folded-in review fixes. Something like "inspector: live-frame fix + warming-up state; fold review fixes; drop local sysimage."
  2. One watcher-integration test (or a tracked follow-up if you'd rather not block the unit). SinkDedup is now unit-tested — good — but RunsRootWatcher's poll / idle-on-finished baseline / warming-up transition has zero coverage and is the most intricate code in the stack. Inline notes below.

Everything else is minor (inline). This is the PR that makes the whole stack function end-to-end — almost there.


/** fs.watch backstop: re-resolve `latest`, then rescan the active run for new
* frames / FINISHED and drain the log — catching anything FSEvents dropped. */
private tick(): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[important] This poll backstop — plus the idle-on-finished baseline in start() and the warming-up transition — is the most intricate new code in the release, and none of it has a test. The new SinkDedup unit test covers the dedup primitive, not this state machine. One test that appends iter_*.png + AMICODE_ITER lines to a temp run dir over time and asserts the sink/inspector calls (incl. a finished run at launch staying idle, and warming → first frame) would close it. Fine as a tracked follow-up if you don't want to block the unit — but it's the riskiest untested code in the stack.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e83d76a — added test/watcher_statemachine.test.ts covering exactly this. It drives RunsRootWatcher over a temp run dir and asserts the inspector calls: (a) a run already FINISHED at launch stays idle (no stale plot rendered, even after a poll tick); (b) a fresh run shows warming-up, then the poll delivers the newest frame, then completion fires once. Infra to make the vscode-coupled watcher testable: a minimal vscode stub aliased in vitest.config.ts, the inspector mocked to capture calls, and tick() driven directly so the poll path is deterministic (no 700ms-timer race). 49 tests green.

const m = ITER_PNG_RE.exec(f);
if (m) { const k = parseInt(m[1], 10); if (k > newest) { newest = k; newestPath = path.join(runDir, f); } }
}
if (newestPath) this.sink.image(newestPath, newest); // deduped by lastFrameIter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] The poll delivers only the newest frame per tick, so frames produced between two ticks are skipped on this path. Correct for "show the latest pulse" (not an animation) — worth a one-line comment saying so, since otherwise it reads like a dropped-frame bug.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — added a comment in e83d76a: the poll intentionally delivers only the newest frame per tick (latest pulse, not an animation; the fs.watch path still catches most frames at low latency), so a coalesced frame is no loss. The new state-machine test also asserts the newest-wins behavior explicitly (two frames between ticks → only iter_18 delivered).

@printf("AMICODE_ITER iter=%d f=%.6e inf_pr=%.3e inf_du=%.3e\n", k, st.obj_value, st.inf_pr, st.inf_du)
flush(stdout)
(k % PLOT_EVERY == 0) && save_control_plot(k)
(k > 0 && k % PLOT_EVERY == 0) && save_control_plot(k) # skip iter-0 (just the random init; defers Makie's first-plot compile off the first iter)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] skip-iter-0 + PLOT_EVERY=6 means a solve converging in <6 iters emits no per-iter frame — the inspector shows "warming up" until the end-of-solve guarantee frame. Fine, just noting the edge; the warming-up state covers it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted in e83d76a — added a comment at PLOT_EVERY flagging the <6-iter edge (no per-iter frame; the end-of-solve guarantee frame + the warming-up state cover it). No behavior change.

[important] Add a test for the live RunsRootWatcher state machine — the poll
backstop + idle-on-finished-at-launch baseline + warming→first-frame transition,
which the SinkDedup unit test (the dedup primitive) did not cover. The test drives
the watcher over a temp run dir and asserts the inspector calls:
  - a run already FINISHED at launch stays idle (no stale plot re-rendered),
  - a fresh run shows warming-up, then the poll delivers the newest frame
    (newest-wins across a tick), then completion fires once.
Infra: a minimal `vscode` stub aliased via vitest.config.ts so node-side modules
that import vscode are unit-testable; the inspector is mocked to capture calls;
tick() is driven directly so the poll path is deterministic (no 700ms race).

[minor] file_watcher.ts: comment that the poll delivers only the newest frame per
tick by design (latest pulse, not an animation) — not a dropped-frame bug.

[minor] solve_template.jl: note the <PLOT_EVERY-iter edge (a solve converging in
<6 iters emits no per-iter frame; the warming-up state covers it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Rchari1

Rchari1 commented Jun 27, 2026

Copy link
Copy Markdown
Member Author

Thanks @jack-champagne — all three addressed in e83d76a:

  • [important] live state-machine testtest/watcher_statemachine.test.ts drives RunsRootWatcher over a temp run dir and asserts the inspector calls: finished-at-launch stays idle, fresh run → warming → poll-delivered newest frame → completion. Made it testable with a minimal aliased vscode stub (vitest.config.ts) + a mocked inspector + direct tick() (no timer race). This is exactly the path the original frame-blank bug lived in, now guarded.
  • [minor] poll-newest-per-tick documented as intentional (not a dropped-frame bug).
  • [minor] <6-iter no-frame edge noted at PLOT_EVERY.

49 tests green. Re-requesting review.

@Rchari1
Rchari1 requested a review from jack-champagne June 27, 2026 00:05
@jack-champagne
jack-champagne changed the base branch from rchari/qa-chat-fixes to main June 27, 2026 00:33

@jack-champagne jack-champagne left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three addressed in e83d76a — verified against the diff, not just the replies:

  • [important] live state-machine test: test/watcher_statemachine.test.ts drives the real RunsRootWatcher over a temp run dir, calling tick() directly for determinism. Asserts the three states I flagged — finished-at-launch stays idle (no stale frame), fresh run → warming-up + run label, poll backstop delivers newest frame (newest-wins on coalesced ticks), then terminal completion. No external-binary dependency, so it actually runs in CI (unlike the #22 merge test).
  • [minor] newest-frame-per-tick comment added.
  • [minor] <6-iter edge comment added.

Keystone verified-correct and validated live. Resolves #27. Good to merge.

@jack-champagne
jack-champagne merged commit 9a80c67 into main Jun 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants