Repository navigation
Place the closed-trade read cache by row count - #797
Conversation
Scripts that read their closed trades again and again (strategy.closedtrades accessors in a loop) were about 1.9 to 2.0 times slower than on 1.10.0 on histories of 15,500 and 39,456 closed trades. Two things made them slow: every cache hit rewrote the rank of the other entries of its set, and four-way sets spread consecutive trades over four times the pages their rows need. The cache now follows the number of closed trades. While the ledger holds at most as many rows as the cache has entries (215,070 by default), row n owns slot n: one way, one probe per lookup, and only the pages the rows reach are committed (25,000 closed trades hold 98 pages, 7.8 MB, where four-way sets held 391 pages, 31.2 MB). With more rows the four-way sets apply as before. An append that takes the ledger past the cache's rows, or a rewind back to them, drops every cached row and keeps the allocated pages. In a set, a hit on the most recent entry changes nothing and a hit on any other entry ages only the entries more recent than it, so a full set replaces the entry touched longest ago, as the documentation says. A miss is probed and counted once. Every value a script reads is unchanged: the reports of a 15,500-trade script are identical field by field. Measured on an aarch64 build host, one pinned core, engine time against 1.10.0 built with the same compiler: a script that scans 15,500 closed trades again and again takes 1.03 of the 1.10.0 time (five rounds; main before this change 1.92); a script that only opens and closes trades is unchanged by this change (1.10 on 150,000 bars, as on main). With 250,000 closed trades, above the cache's rows, a script that scans all of them every 6,000 bars takes 40 s, against 27 s on main before this change and 138 s on 1.10.0 (two rounds). Timings on x86-64 were not measured. Tests: the placement oracle in tests/ledger_default_cache_rows.hpp now takes the number of rows the ledger holds, and nine tests that compared resident bytes or entry counts with it state both placements; three new tests pin the least-recently-used rule, the single probe of a miss and the placement change. Documentation: docs/pages/memory-management.md and the header comment describe the placement by row count. Two sentences of the memory page leave in the ledger read cache row: the sentence that began "Storage is private: a global ordinal chooses one four-way set" (introduced by ba14721) is rewritten to state the placement for both populations, and "Page growth preserves entries through the 64/128/256 boundaries." (introduced by 69cddc9) now says that growth preserves entries through every page boundary, every 256 rows within the cache's rows. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
There was a problem hiding this comment.
Graphify reviewed this change.
No issues found by static checks — no coupling regressions or blocking issues in the code graph. That is not the same as safe to merge: see what was not checked.
Not checked: tests were not run; formal verification proved 0 of 1 changed function(s) (1 not verified).
Formal verification. PR-changed functions: 0/1 verified (0 proven, 0 may-equivalent, 0 distinguished) · 1 not verified (1 unsupported).
Not verified on this run: resize (unsupported).
Graphify review — findings
Adds count-based placement to the trade ledger's closed-trade read cache: while a run holds no more closed trades than the cache has entries (215,070 by default), each trade owns the slot of its ordinal. A read makes one probe and only the pages the trades reach are committed, so 25,000 closed trades hold 98 pages (7.8 MB) instead of 391 (31.2 MB). Past that capacity the cache falls back to four-way sets, where a hit on the newest entry is a no-op, a hit on an older entry ages only the more recent ones, and a miss is probed once. Crossing the capacity in either direction, by appending past it or rewinding back under it, drops every cached row but keeps the allocated pages. The changelog and the memory-management table now describe this placement and the rewind transition alongside the existing retirement and rewind wording.
Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 873 functions depend on the 856 functions this change touches.
Health — this change adds coupling hotspots:
- new:
TradeLedger::TradeLedger()— 0 callers, 8 callees
Verification — 873 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 872 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
1403 of 1403 test file(s) selected (100%) via static blast radius.
Escalated to a full run for safety (
noncode-change,uncovered-code) — the selection is not trustworthy on its own (see below). CI should run the whole suite.
benchmarks/tests/test_aggregate.py— full-run-safetybenchmarks/tests/test_check_provenance.py— full-run-safetybenchmarks/tests/test_compare.py— full-run-safetybenchmarks/tests/test_public_names.py— full-run-safetybenchmarks/tests/test_run_all.py— full-run-safetybenchmarks/tests/test_run_strategy_feeds.py— full-run-safetydocker/pineforge_sink_finalize_test.py— full-run-safetydocker/pineforge_sink_replay_rq_test.py— full-run-safetydocker/pineforge_sink_replay_test.py— full-run-safetydocker/pineforge_sink_to_json_test.py— full-run-safetydocker/quota_columns_test.py— full-run-safetydocker/quota_convert_test.py— full-run-safetydocker/quota_finalize_test.py— full-run-safetydocker/replay_rq_engine_test.py— full-run-safetydocker/run_json_artifact_window_test.py— full-run-safetydocker/run_json_bin_test.py— full-run-safetydocker/run_json_bounded_transport_test.py— full-run-safetydocker/run_json_bulk_equity_fuzz_test.py— full-run-safetydocker/run_json_bulk_equity_test.py— full-run-safetydocker/run_json_codes_test.py— full-run-safetydocker/run_json_converter_metadata_test.py— full-run-safetydocker/run_json_converter_session_test.py— full-run-safetydocker/run_json_diagnostics_test.py— full-run-safetydocker/run_json_equity_scan_test.py— full-run-safetydocker/run_json_fused_test.py— full-run-safetydocker/run_json_harnesses_test.py— full-run-safetydocker/run_json_metrics_only_test.py— full-run-safetydocker/run_json_misc2_test.py— full-run-safetydocker/run_json_misc_test.py— full-run-safetydocker/run_json_pfbar_test.py— full-run-safetydocker/run_json_provenance_test.py— full-run-safetydocker/run_json_quota_supervisor_test.py— full-run-safetydocker/run_json_replay_test.py— full-run-safetydocker/run_json_selected_sink_test.py— full-run-safetydocker/run_json_sink_lexical_test.py— full-run-safetydocker/run_json_sink_summary_test.py— full-run-safetydocker/run_json_sink_test.py— full-run-safetydocker/run_json_sink_vectors_test.py— full-run-safetydocker/run_json_spool_work_test.py— full-run-safetydocker/run_json_stringio_test.py— full-run-safetydocker/run_json_symbol_feeds_test.py— full-run-safetydocker/run_json_syminfo_test.py— full-run-safetydocker/run_json_trade_scan_test.py— full-run-safetydocker/run_json_window_budget_test.py— full-run-safetydocker/run_json_wrapper_work_test.py— full-run-safetyinclude/pineforge/native_run_spec.hpp— full-run-safetyscripts/derive_corpus_feeds_self_test.py— full-run-safetyscripts/fingerprint_self_test.py— full-run-safetyscripts/run_corpus_self_test.py— full-run-safetyscripts/test_abi_receipt_skips.py— full-run-safety- … and 1353 more
non-code file(s) changed (
CHANGELOG.md,docs/pages/memory-management.md,tests/cr150.d/ledger-cache-bound.cmake) → running the full suite for safety (a code graph can't see config/fixture/data deps)
changed code file(s) with no mapped test (
CHANGELOG.md,docs/pages/memory-management.md,src/engine_orders.cpp) — a coverage gap or a missing link — running the full suite rather than only the selected tests
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
Could not verify: Could not verify resize.
The verifier did not have enough to check resize, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: unsupported: member function — receiver/self (`this`) not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver
· 1 more finding(s) on lines outside this diff (see the check run).
The capture counter starts after the ledger cache has been populated, so resident cache-page growth must not be required in its working-heap peak. Restore the symmetric fixed-window comparison while retaining the exact row-count-dependent cache residency assertions and all output oracles.
Retain the row-count-derived page-growth upper envelope, since Release can allocate cache pages during capture. Do not require that growth in the measured peak when pages are already resident. Keep the exact cache residency, scratch-window, output and profile checks unchanged.
Refs #797 Preserve the published member and merge it onto the release train base. No product or test content is changed by this integration commit.
…#825) Include #797, #804, #817, #801 and #816: closed-trade cache placement, fill-time drawdown halt and flatten, reversal exit sizing, selected-capacity admission, and the percent default quantity cap. Document the intentional replacement of these earlier descriptions: - docs/design/native-feature-parity.md, introduced by 91d65ad: the old `update_risk_state(bar.close)` once per script bar at the close mark description is obsolete after #804. The cell now describes the fill-time peak, closing-fill threshold, halt latch and immediate flatten. Preserve MG10-a and MG10-b as HISTORICAL, measured on engine f5e929d, before the #804 change, with their values, claim status and grade unchanged. - docs/pages/memory-management.md, introduced by ba14721: "Storage is private: a global ordinal chooses one four-way set" no longer describes placement below the cap after #797 (653be41). Row n owns slot n while retained rows fit the cap; above it the existing four-way sets apply. Crossing the cap invalidates hot rows while retaining allocated pages. - docs/pages/memory-management.md, introduced by 69cddc9: "Page growth preserves entries through the 64/128/256 boundaries." is replaced by the current 256-slot page-boundary rule, which preserves entries through every page boundary within the cap. Re-anchor the remaining documentation citations and move the private-fixture registration evidence anchor from line 3470 to the actual token at line 3473. Preserve its row, predicate and reason; no new parity result or closed surface is claimed. Closes #804, closes #805. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Summary
A script that reads its closed trades again and again (
strategy.closedtradesaccessors in a loop) is about 1.9 to 2.0 times slower on main than on 1.10.0 on histories of 15,500 and 39,456 closed trades (the 1.10.0 cache holds 65,536 rows; main is faster than 1.10.0 on such scans at 75,000 closed trades and above). This change restores the 1.10.0 speed of that script (1.03 of the 1.10.0 engine time on a 15,500-trade history) and lets the closed-trade read cache commit up to four times fewer pages while the history is small. Every value a script reads is unchanged.PR type: code (engine source, tests and documentation).
What changes
nowns slotn: one way, one probe per lookup, and only the pages that the rows reach are committed. With more rows the four-way sets apply as before. An append that takes the ledger past the cache's rows, and a rewind that brings it back to them, drop every cached row and keep the allocated pages.docs/pages/memory-management.mdsays. Before, every hit rewrote the rank of every other entry of the set.src/engine_orders.cppis the only source file that changes; the header changes comment lines only. No layout, ABI or public interface changes.Memory
Below the cache's rows the cache commits only the pages the rows reach: 25,000 closed trades hold 98 pages (7.8 MB) where four-way sets held 391 pages (31.2 MB), up to four times less. At and above the cache's rows the pages are the same as before. The declared cap and the cache's row count do not change; the ledger's fixed base grows by 8 bytes (one flag in its private state), so
resident_cache_bytes()and the accounted charge are 8 bytes larger in every run (22,624 to 22,632 bytes for an empty ledger on the aarch64 build host).Measurements
Every timing figure in this description comes from one build per arm. The rounds repeat those binaries; they are not independent build replicates. Each cell is the engine time divided by the engine time of 1.10.0 built with the same compiler (GCC 13.3) in the same round, so 1.0 is equal and 2.0 is twice as slow. aarch64 build host, one pinned core, arms rotated within each round, cold start; median and range over the rounds. Timings on x86-64 were not measured.
Against the released 1.10.0 binary (built with GCC 11.4) the same first script takes 1.066 [1.018 - 1.082] with this change and 2.017 [1.910 - 2.065] on main; the local 1.10.0 build is 1.038 [1.001 - 1.051] of that binary, which is the compiler difference. The reports of the first script are identical field by field (15,500 trades, 31,000 equity points) on main and on this change; the 1.10.0 report was not compared field by field here.
The last row is the one place where this change is slower than main: with 250,000 closed trades, above the cache's rows, scanning all of them takes 40.1 s against 27.3 s on main (1.48 times) and 137.7 s on 1.10.0. In that row both 1.10.0 rounds and one round of this change ran with other tasks using more than 10 percent of the pinned core (12.8 and 18.3 percent for 1.10.0, 12.2 percent for this change), and only two rounds were run.
The difference comes from the replacement rule of the four-way regime. Main's hit rule aged every other entry of a set on every hit, which is not the documented rule, and a script that calls several accessors on the same row hits the entry it has just loaded several times in a row; a cyclic scan over a history larger than the cache's rows then keeps part of its rows by accident. With the documented least-recently-used rule it keeps none. The cache counters of a unit test show it: a cache of 1,024 entries and a cyclic scan over 4,096 rows with six accessor calls on each row, 512 of the 4,096 first accesses hit on main and none on this change; with one accessor call per row the counts are identical.
Tests
tests/ledger_default_cache_rows.hpptakes the number of rows the ledger holds and models both placements from the documented geometry (no product code is called). Nine tests that compared resident bytes or entry counts with it state both placements and keep exact equality:test_trade_ledger_cache_policy,test_p2cx_tledger_budget,test_p2cx_tledger_io,test_closed_trade_text_reads,test_ledger_retired_cache_bound,test_ledger_cache_bound,test_trade_ledger_cache_sweep,test_report_sink_trade_residency,test_selected_metrics_l17b.test_report_sink_trade_residencynow samples 100,000, 250,000 and 1,000,000 trades: the heap plateau claim holds only past the cache's rows (215,070), and every sample's cache pages are checked exactly. Intest_selected_metrics_l17b, only the upper side of the peak allowance between the 70,000-row and the 100,000-row sink runs adds 9,345,024 bytes for cache-page growth; one 64 KiB scratch window remains on both sides. In the focused Release build the larger run's peak exceeds the smaller run's by exactly 9,345,024 bytes in all three modes. In Debug and sanitizer builds the allocation counter does not see the cache pages and the two peaks are equal, so the upper allowance is loose by that growth in those profiles. Release retains one scratch window of slack around its observed page growth.test_ledger_lru_victim(the replacement order),test_ledger_probe_once(a miss is probed and counted once) andtest_ledger_adaptive_cache(one probe per lookup below the cache's rows, the change of placement drops rows and keeps pages, a rewind returns to the first placement, a copy keeps values).🤖 Generated with Claude Code