Skip to content

perf(reader): a pass over a file's records makes the plan of each class once, not once for each record (#1335) - #1503

Draft
DrunkOnJava wants to merge 13 commits into
mainfrom
perf/1335-plan-cache
Draft

DrunkOnJava wants to merge 13 commits into
mainfrom
perf/1335-plan-cache

Conversation

@DrunkOnJava

@DrunkOnJava DrunkOnJava commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

Stacked on #1483 (its commits come first on this branch; this PR is the last two). Decoding a record works out, for each class it meets, the chain of classes and the key of each field (the class plan). Each decode made them afresh, so a pass over a file's 17,000 records made them 17,000 times over. The passes of extract_records now keep the plans between records (native_parameters::PlanCache), in a cache whose size is bounded. A plan depends on the schema, the class and the fields a projected decode keeps, and nothing else, so a decode that finds one made earlier reads exactly as it would have with its own.

Measured at 0767639, against #1483's measured head 4140b96 (run 42130101254): the exports of the 33 samples of Revit 2015 to 2023 take 0.974 of #1483's time (per file 0.96 to 1.00; every head run below every base run on 23 of 39 files, none the other way), ifc.sha256 and diagnostics.json are equal on all 33 (66 of 66), and peak memory moves by +0.11 MiB on average (largest rise 1.4 MiB, inside run noise). The pushed head adds the bound on the cache, the probe's added checks and docs; time and bytes are not re-measured at it (the bound runs only when a plan is made, not when one is found, and no real file reaches it: the largest cache any of 88 files makes is 17.4% of the limit, see Testing).

Motivation

#1335's plan-timing profile (run 42127560399, scratch head 76e8036, an EPYC 7763, shares only): 1,024,681 class plans were built over the 33 samples of 2015 to 2023, 2,114 ms of 68,189 (3.1%) of the exports' time; on 2015 rac_advanced 47 of the definitions pass's 166 ms (28%), 20 of the wall records' 61 ms (32%) and 8.5 of the phase read's 111 ms. The measured gain is 2.6%.

Changes

  • src/research/native_parameters.rs: ClassPlan no longer borrows the registry (its chain is the classes' tags, which the loop reads from the registry), so a plan outlives the decode that made it. PlanCache holds them by class tag and the address and length of the kept fields; the plan records which fields it was made for, and a hit is taken only where they are the same fields, so another list at the same address is not taken for it. A cache is for one schema: decode_graph_cached empties it when the schema's checksum is another's. A plan's depth check on a hit is the one the building made. decode_graph_with_catalog_usage and decode_graph_projected take a fresh cache, as each decode made its own plans before.
  • The cache is bounded. A plan costs PLAN_ENTRY_COST (8) and one unit for each field of every class of its chain and each field kept; the cache holds at most PLAN_CACHE_COST (1,000,000, an estimate of about 60 bytes a unit, so about 60 MB, not measured) of cost, PlanCache::with_limit sets another. A plan that would pass the limit empties the cache first; a plan dearer than the whole limit is not kept at all. Without it a crafted schema (one class of 50,000 fields and 30,000 empty child classes, each child's plan holding its 50,000 ancestor field names, about 2 MB, and 2,000 of them enough for wasm32's 4 GiB) or a caller of the public decode_graph_cached that passes a new field list each call would grow the cache without bound, where before each plan was freed with its record. The bound cannot change a graph: a decode that finds no plan makes its own, which is what every decode did before the cache, so a cleared cache or a plan that is not kept only costs the rebuilding. The probe decodes every record through a cache whose limit is 48 (so it empties itself or keeps nothing) and compares each with the decode that makes its own plans.
  • src/research/native_document.rs: extract_records makes one cache for its pass and decodes through decode_graph_cached.
  • probes/examples/probe_1335_projected_graphs.rs: decodes every record through one cache shared by the whole file and every list, and through the 48-cost cache, in full and projected, and compares each with the same decode that makes its own plans: same refusal or the same graph (every object's class, token, start, end and fields, the edges, the consumed bytes, the usage). It prints the most the shared cache holds after any decode (plan-cache size), states its FACT in its module doc, and now exits 1 where anything it compares differs, so a job can turn red on it. What it proves is that a shared cache equals a fresh one; that a decode without a cache equals the one before the cache existed rests on the exports' byte identity, which Measure shows, not on this probe.
  • docs/maintaining.md: the section on projected graphs covers the cache and says to run the probe after a change to the cache.
  • tools/ci/scrub-holdout-results.sh: the probe's count lines (records decoded, differences, verdict words, schema class names, the readers' field names, never the file's name or an ElementId) are kept for held-out models, with its exit N; checked on a copy of a real probe output.
  • changelog.d/changed/1335-plan-cache.md.

Testing

Measure run 42130101254 (-f ref=0767639e -f base=4140b96c -f samples=true -f sample_years="2015 2023" -f models="-" -f scorers=none -f timing=true -f probe=probe_1335_projected_graphs; this head's first commit against #1483's measured head; shard 1 an EPYC 9V74 and shard 2 a 9V45; median of three exports each) and run 42130115171 (the probe alone, Revit 2024 to 2027 samples and the Revit 2025 models).

class files #1483 (s) this PR (s) sum mean median per file peak memory
samples 2015 6 7.8 7.6 0.973 0.971 0.973 0.96 to 0.98 1.00 to 1.02
samples 2016 to 2018 9 8.0 7.8 0.977 0.976 0.977 0.97 to 0.98 1.00
samples 2019 to 2022 15 16.7 16.3 0.975 0.976 0.976 0.96 to 1.00 1.00
samples 2023 3 3.9 3.8 0.965 0.965 0.960 0.96 to 0.98 1.00
33 samples 33 36.3 35.4 0.974 0.974 0.976 0.96 to 1.00 1.00 to 1.02
reference models 6 5.8 5.7 0.990 0.984 0.984 0.96 to 1.01 1.00

Peak memory: the largest rise is 1.4 MiB, on 2015 rst_basic (the medians of three runs are 81,784 and 83,216 KiB, 79.9 and 81.3 MiB; the three runs of the base alone span 81,740 to 83,120 KiB and those of the head 80,920 to 83,352, so the rise is inside the run's own noise), the mean change over the 39 files is +0.11 MiB, and no file rises by 2 MiB. The profile estimated 3.1% of the export time for building plans; 2.6% is measured.

Audit-honesty checklist

Breaking changes

None to the stable API, the CLIs or any export. rvt::research (unstable): native_parameters gains PlanCache (with with_limit, entries, cost and clears), PLAN_CACHE_COST, PLAN_ENTRY_COST and decode_graph_cached.

Related issues

Refs #1335.

… a difference and checks the bound, and held-out probe counts are kept (#1335)
# Conflicts:
#	docs/maintaining.md
#	probes/examples/probe_1335_projected_graphs.rs
#	src/research/native_document.rs
#	src/research/native_parameters.rs
#	src/research/native_walls.rs
#	tools/ci/scrub-holdout-results.sh

This branch has not been deployed

No deployments
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