Skip to content

perf: keep many-deal paths at milliseconds - #1141

Merged
kirillDevPro merged 9 commits into
mainfrom
perf/many-deals-latency
Oct 29, 2026
Merged

kirillDevPro merged 9 commits into
mainfrom
perf/many-deals-latency

Conversation

@kirillDevPro

Copy link
Copy Markdown
Collaborator

What & why

With many deals (ten cores, 20k-100k report rows, 5000 retained closed and 200 open orders per core) several paths spent tens of milliseconds on the UI thread or issued one SQL statement per row. Each candidate was measured first on synthetic data at that scale; only paths over a 1-2 ms UI budget (or doing O(N) statements where a bounded batch does) were changed. Output stays identical: parity tests compare against frozen copies of the old code.

Path Before After
Ten-core order drain per feed wake (UI) 3.4 ms 1.5 ms (p95 < 2)
Trade bubble refold, 100k prints (UI) ~77 ms ~0.6 ms on UI, fold in background
Ten-pane order geometry 13.9 ms 7.9 ms
Trade window neighbour pick, 50k trades (UI) up to 39 ms < 0.001 ms
Tuner KPI and delta summary per render full table scans < 0.001 ms
Screener, 8000 rows (UI) 9.2 ms 0.3-1.4 ms
Report read by close date desc, 100k rows 82 ms 40 ms (20k: 31 -> 7.7)
Archived trace read, 1000 traces 1802 statements 4

Notable:

  • The dev profile (the debug build) compiled moon-chart at opt-level 0; it now gets opt-level 2 like moon-core.
  • Fix: a core whose valuation partition was wiped by a clock-offset adoption while the startup reconciliation walk was running kept its history unvalued until restart; a running walk now restarts on RescanCore/PurgeLegacy.
  • Fix: normalized_quote_breakdown_matches_unfenced_sql raced the cache-health tests in parallel runs; it now holds the existing health guard.
  • Measured within budget and left unchanged: Telegram notification read and reports, Report totals, strategy metadata, chart trade history seeks, Assets, Profit Monitor, valuation reconciliation.

How to verify

  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, make test (nextest workspace + doc tests) - green locally.
  • Scale probes are #[ignore]: e.g. cargo test -p moon-core --lib scale_probe -- --ignored --nocapture.
  • Data in all new tests is synthetic.

A heavy install (ten cores, thousands of deals in the 72-hour window)
raised the question whether the Telegram notification read, the
per-picture day sums and the report totals stay cheap. Add ignored
probes that build that load on synthetic data and time each path, so
the numbers can be re-measured after any change to them.

Measured within budget: the 72-hour read over 5000 closes and ten
cores takes about 29 ms every 2 s, twenty pictures over 2000 closes
about 0.5 ms, and the Telegram reports over 20000 deals 21-63 ms.
No production code changes.
A Report read sorted by close date (newest first) over a large replica
scanned and sorted every matching row with all its wide columns before
LIMIT cut it to the displayed page: 31 ms at 20000 deals and 82 ms at
100000.

Select the ordered, per-source-limited row keys first, then fetch the
wide rows by rowid and sort only that bounded set. The inner key pass
keeps the old WHERE, ORDER BY with its primary-key tie-break and LIMIT,
so the same row set and order come back; ascending, computed and
coin-seek reads keep their old SQL. Now 7.7 ms at 20000 and 40 ms at
100000.

Add an ignored scale probe of the Report reads, a tied-row parity test
of the old and new statements, and run the coin-index seek test that
the current IN predicate already satisfies.
Every feed wake drains all cores' order batches on the UI thread in one
pass. With ten cores holding 5000 retained closed orders and 200 open
orders each, that pass took 3.4 ms for order lines and 3.1 ms for table
messages: a fifth of a 60 Hz frame spent rebuilding state that mostly
did not change.

- Cache each market's open-order draw membership and rebuild it only
  when an order is inserted, revived or closed, or the retained set
  changes size, instead of rescanning every retained order per batch.
- Skip the auto-fit range rebuild for batches that move no line price.
- Drop consumed order batches on one named background thread
  ("order-retirement", bounded queue) instead of on the UI thread.

The ten-core drain is now 1.5 ms for lines and 1.4 ms for tables, p95
under 2 ms. Rows, order, revisions and auto-fit ranges are unchanged;
heavy-core scale probes and parity tests cover the paths.
Valuation ran one statement per report change: a burst of N changed
deals produced N outbox inserts, N single-row report reads and repeated
per-row rate and value statements, and reading archived order traces
issued one query per deal plus one per answer (1802 statements for
1000 traces). Unresolved deals waiting for a rate sat in an unbounded
in-memory map that was rescanned against every blocked rate minute.

- Stage report page changes into the outbox with bounded multi-row
  inserts inside the same writer transaction.
- Load rows, cached rates and values in bounded batches; every batch
  stays well under SQLite's variable limit.
- Keep deferred deals in a TEMP table on the worker connection, indexed
  by their retry time, and take bounded due batches from it.
- Read archived trace answers and lines in bounded batches (1802
  statements down to 4).

Stored values, rates, provenance, modes and the reconciliation order
are unchanged; scalar-versus-batched parity tests cover them.
normalized_quote_breakdown_matches_unfenced_sql failed in full parallel
runs, also on the base commit, and passed alone. It pins current rates
but did not hold the valuation cache health guard its neighbours hold,
so a concurrent corruption test could flip cache health between its
two reads and one side came back without valuation. Take the existing
test_health_guard() before pinning rates.
…hart

A chart with a long retained tape spent tens of milliseconds on the UI
thread whenever trade bubbles refolded: 77 ms per refold at 100000
prints. The debug build that is run day to day compiled moon-chart at
opt-level 0, because the dev profile optimizes only non-workspace
dependencies and moon-core.

- Compile moon-chart at opt-level 2 in the dev profile, as moon-core
  already is. The bubble pipeline drops from 55 to 11 ms and ten-pane
  order geometry from 14 to 8 ms.
- Share one price lookup between both sides, reuse expired slots
  without a second hash, cache packed price/time sort keys and pick
  percentile ranks by selection instead of a full sort.
- Prepare bubble folds, native markers and the release of superseded
  buffers on a two-worker background executor. The previous fold stays
  on screen; a result whose settings, layout, epoch, history generation
  or visible range no longer match is dropped. The UI thread now spends
  about 0.6 ms per refold.
- Use the same lateness-bounded tick lookup on wgpu as on DX11/Metal.
- Box Orders rows so sorting traced orders moves less memory.

Bubbles, markers, Orders rows and their order match the synchronous
path; parity tests compare against frozen copies of the old code.
Opening a Trade window, or clicking another trade in an open one,
stable-sorted the whole period's history on the UI thread to pick the
twenty nearest neighbours: up to 39 ms on open and 26 ms on a re-click
at 50000 trades.

Select the neighbours on a background executor and hand the UI the
prepared rows; the whole period stays retained, as before. A refresh
keeps the window's current focus. The view holds one cancellable
selection task and a generation counter, so an older selection that
finishes after a newer click is dropped instead of replacing it.

Neighbour order, ties and rows without a uid are unchanged; tests
compare against a frozen copy of the old selector and cover
out-of-order completions.
A core's history could stay without dollar values until the app was
restarted. When a core's clock offset is adopted, its valuation
partition is wiped (RescanCore) and only a reconciliation walk can
revalue the rows, because no per-row events follow. The walk was
re-armed only when it was idle; at startup it is usually running over
the whole history while cores connect, so it carried on from its
cursor and never came back to the wiped rows it had already passed.

Restart a running walk from the first source on RescanCore and
PurgeLegacy, as an idle one already was; Row and Delete events still
leave the walk alone. A regression test wipes a core behind the cursor
and checks its rows end up valued.

Also attempt RELEASE after a failed ROLLBACK TO of the ready-values
savepoint, returning the rollback error, so a rollback failure no
longer leaves the savepoint open as the connection's transaction.
The Analytics window is one GPUI entity with no shell throttle, so every
repaint re-ran its render-time passes. The Entry/Exit tab scanned the
whole deal table three times for its KPI line plus once for the delta
summary on each render, a changed reload hashed and cloned all judged
rows on the UI thread, and the Screener rebuilt and re-sorted every
market row (9.2 ms at 8000 rows) on the UI thread.

- Cache the tuner's render population and delta summaries keyed on the
  row revision, locale and the time window in which a verdict can
  change by itself; KPI and delta reads now cost under 1 us.
- Prepare the reload scope and judged-row snapshots on a background
  executor; stale results are dropped by sequence and skip key.
- Sort Screener rows by index and reuse unfiltered market counts; build
  unfiltered market snapshots in the background from shared rows and
  publish them atomically. Source, scope, text, volume and sort apply
  at once to the cached rows and again at publication. The UI side now
  takes 0.3-1.4 ms.

Figures, labels, rows and their order are unchanged; parity tests and
a cold-locale regression cover the caches.
@kirillDevPro
kirillDevPro merged commit de9f735 into main Oct 29, 2026
10 checks passed
@kirillDevPro
kirillDevPro deleted the perf/many-deals-latency branch October 29, 2026 09:23
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