Skip to content

Optimize per-keystroke hot path: precompiled regexes, memoization, render caching#115

Open
simeji wants to merge 10 commits into
masterfrom
claude/perf-refactor
Open

Optimize per-keystroke hot path: precompiled regexes, memoization, render caching#115
simeji wants to merge 10 commits into
masterfrom
claude/perf-refactor

Conversation

@simeji

@simeji simeji commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Behavior-preserving performance refactoring of the interactive hot path. Every keystroke used to recompile ~10+ regexes, re-evaluate the JMESPath query, re-marshal the entire filtered document, and re-run the jsoncolor formatter over the whole output — even when nothing changed (scrolling, candidate cycling, cursor moves). This PR removes that redundant work without changing any observable behavior.

Changes

Each commit is independently green (go build, go vet, go test):

  1. bench: micro-benchmarks for all hot-path functions with deterministic in-code fixtures (no testdata files), plus a go vet fix (unreachable break).
  2. Static regex hoisting: validate(), GetKeywords(), isJMESPathQuery(), getFilteredDataLegacy(), getItem(), Suggestion.Get() compiled their patterns on every call; they are now package-level vars (following the existing reWildcardIndexed pattern). Two literal-only alternations became plain strings.Contains.
  3. Dynamic regex caching: suggestion prefix patterns are built from user input, so they are memoized via a small compileCached helper instead. The keyword is intentionally not QuoteMeta'd where it wasn't before — keyword-as-regex matching and invalid-pattern fallbacks are preserved exactly. drawCandidates now uses strings.Index (its rows are built with plain spaces, so the [[:space:]] regex was equivalent to a literal search).
  4. Memoization: origin/originData are write-once, so GetFilteredData/GetPretty results are pure functions of (query, confirm) — a single-entry memo makes repeat frames free. evalJMESPath gets a small bounded cache (one keystroke evaluates the same expression several times via evalBaseExpr and the suggestion paths).
  5. Keymode: skip pretty-printing the filtered JSON when only the key candidates are displayed.
  6. Allocation reduction: single preallocated slice in getCurrentKeys (sort order on raw keys unchanged), pass the pretty string through to rowsToCells instead of re-joining rows, preallocate monochrome cell slices, utf8.RuneCountInString instead of []rune conversions in highlightCandidateKey.
  7. Render caching: cache formatted termbox cells keyed by content; scrolling and candidate cycling reuse them. Only the outer slice is copied per frame — highlightCandidateKey is copy-on-write (pinned by TestHighlightCandidateKeyOriginalUnmodified), so row slices are never mutated.

Benchmarks (Apple M1, benchstat, count=10)

Benchmark Before After Δ
GetPretty n=5000 q=. (repeat frame) 8.9 ms 34 ns -100.0%
GetFilteredDataJMESPath n=5000 .users[*].name 885 µs 77 ns -99.99%
RowsToCellsColor (35k rows, repeat frame) 120 ms 0.94 ms -99.2%
Validate (per keystroke) 9.2 µs / 147 allocs 1.2 µs / 1 alloc -86.6%
GetKeywords 4.2 µs 0.95 µs -77.5%
SuggestionGet (cold path) 11.2 µs 7.1 µs -36.4%
GetCurrentKeys (cold path) 3.9 µs 2.9 µs -25.7%

The headline numbers are cache hits, which is precisely the scroll/candidate-cycle frame profile; cold-path numbers (Validate, GetKeywords, Suggestion*) improve independently of caching.

Behavior verification

  • All 112 existing tests pass unchanged at every commit; no test file was modified.
  • An equivalence dump (not committed) ran GetPretty/Get for 30+ queries (legacy paths, wildcards, pipes, partial function typing, &field, dotted keys, error paths, repeated/interleaved calls to exercise the caches) against master and this branch: 288/288 output lines byte-identical, except . | keys(@) where master itself is non-deterministic across runs (Go map iteration order); this branch pins one order per session, which is within the unspecified-order semantics.

Notes for reviewers

  • The caches are unsynchronized by design: the app is a single-goroutine termbox event loop, and compileCached uses sync.Map only so tests stay race-free.
  • Deliberately not changed: []rune query storage, validate() rule order, suggestion keyword-as-regex behavior, candidate ordering, output formatting, and the highlightCandidateKey copy-on-write contract.

@codex review

simeji added 8 commits July 4, 2026 12:32
Add benchmarks covering the per-keystroke hot path: query validation,
keyword parsing, JSON filtering (legacy and JMESPath), pretty-printing,
suggestion candidates, terminal cell conversion, and key-line search.
Fixtures are generated deterministically in code (no testdata files).
validate(), GetKeywords(), isJMESPathQuery(), getFilteredDataLegacy(),
getItem(), and Suggestion.Get() compiled their regexes on every call —
up to ~10 compilations per keystroke. Compile them once at package init,
following the existing reWildcardFieldTyping/reWildcardIndexed pattern.
The two literal-only alternations in isJMESPathQuery become plain
strings.Contains checks (exactly equivalent).
Suggestion prefix patterns are built from user input, so they cannot be
precompiled. Memoize successful compiles in a sync.Map keyed by pattern
(bounded by distinct keystrokes per session); failed compiles keep the
exact regexp.Compile error behavior. The keyword is intentionally NOT
QuoteMeta'd where it wasn't before, preserving keyword-as-regex matching.

drawCandidates matched the selected candidate with a per-frame regex of
literal parts only ([[:space:]] can only match the plain spaces the rows
are built with), so a strings.Index search is exactly equivalent.
The engine calls GetPretty once per frame even when the query is
unchanged (scrolling, candidate cycling, cursor moves), re-running the
filter and re-marshaling the entire result each time. origin/originData
are write-once, so results are pure functions of (query, confirm): a
single-entry memo makes repeat frames free.

evalJMESPath gets a small bounded cache as well — one keystroke can
evaluate the same expression several times via evalBaseExpr and the
suggestion paths, each round-tripping through json.Marshal +
simplejson.NewJson.

Cached results are safe to share: all callers only read the returned
simplejson values and reassign (never mutate) the slices.
getContents pretty-printed the filtered JSON and immediately discarded
the string when keymode is active. Call GetFilteredData directly in that
branch; the marshal error was already ignored at this call site.
getCurrentKeys built two slices and used a strings.Builder per dotted
key; build one preallocated slice, sort raw keys, escape in place
(ordering stays defined on the unescaped names).

rowsToCells rejoined all rows into one string every frame to feed the
jsoncolor formatter even though the engine already had the exact pretty
string — pass it through as TerminalDrawAttributes.ContentsRaw. The
monochrome fallback now preallocates its cell slices.

highlightCandidateKey allocated rune slices just to count runes; use
utf8.RuneCountInString and pre-size the row builder.
Scrolling and candidate cycling redraw identical content, but every
frame re-ran the jsoncolor formatter over the whole document. Cache the
converted cells keyed by the raw content string and reuse them when the
content is unchanged.

Only the outer slice is copied per frame: Draw replaces rows with
highlighted copies (highlightCandidateKey is copy-on-write, pinned by
TestHighlightCandidateKeyOriginalUnmodified), and the row slices
themselves are never mutated.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7f5783352a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread json_manager.go
simeji added 2 commits July 4, 2026 22:05
The eval cache could retain up to 64 full result trees across query
changes, multiplying memory usage on large inputs. Clear it whenever
the (query, confirm) key misses: all evalJMESPath calls happen inside
that computation, so the cache still deduplicates repeated evaluations
within one filtering pass while releasing results once the query moves
on. Addresses Codex review feedback.
moveCursorWordBackwark/moveCursorWordForward were empty stubs with no
callers; drawFuncHelp was superseded by the inline rendering in Draw.
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.

1 participant