Skip to content

Cut per-solve managed allocations by caching per-type reflection - #105

Draft
HowardvanRooijen wants to merge 1 commit into
feature/benchmarksfrom
feature/solve-perf
Draft

Cut per-solve managed allocations by caching per-type reflection#105
HowardvanRooijen wants to merge 1 commit into
feature/benchmarksfrom
feature/solve-perf

Conversation

@HowardvanRooijen

@HowardvanRooijen HowardvanRooijen commented Sep 2, 2026

Copy link
Copy Markdown
Member

Uses the Z3.Linq.Benchmarks suite from the PR below it to find, fix and verify a memory hot
path: every Solve/Optimize re-does reflection whose answer is fixed for the life of the
environment type, and the collection marshaller allocates twice over. Caching the reflection and
filling the collection array in place cuts managed allocation per solve by ~18-26% across every
shape
, with no behaviour change.

The hot path the benchmarks pointed at

Running the suite (BenchmarkDotNet, MemoryDiagnoser) and reading the Allocated column - which
is deterministic, unlike the native-bound Mean - the per-solve managed allocation is dominated by
the library rebuilding, from reflection, what it already knew:

  • The TheoremVariableTypeMappingAttribute on a member's type is looked up three times per member
    per solve
    - once in the environment builder, once in the bounds asserter, once in the marshaller
    • and each lookup allocates an attribute array, even though almost no type carries the attribute.
  • GetProperties/GetFields hand back a fresh array on every call, so the environment builder
    reallocates the member list of every type on every solve.
  • The global- and predicate-rewriter attribute lookups allocate an array to ask a yes/no question;
    the predicate one runs once per call node in every constraint.
  • The anonymous-type check allocates an attribute array via GetCustomAttributes(...).Any().
  • The collection marshaller fills an ArrayList (a boxed object[] backing store) and then copies
    it into a typed array with ToArray - two arrays where one will do.

The fix

All small, local, and behaviour-preserving:

Change Effect
Cache TheoremVariableTypeMappingAttribute per type (absence cached too) 3 reflection calls + array allocs per member per solve -> one dictionary lookup
Cache public instance property/field arrays per type no GetProperties/GetFields array per type per solve
IsDefined guard on the global- and predicate-rewriter lookups no attribute array on the common no-rewriter path
IsDefined for the anonymous-type CompilerGenerated check no attribute array
Fill a typed array in place in the collection marshaller no ArrayList, no boxed object[] backing store, no copy-out array

The cached arrays are private and only ever read, so sharing one instance is safe. Nothing changes
about what a solve computes - only how much it allocates getting there.

Verified

Measured with Z3.Linq.Benchmarks (short job, MemoryDiagnoser), same machine, before vs after:

Shape Allocated before Allocated after Reduction
ScalarSymbols 4.48 KB 3.32 KB -25.9%
ValueTuple 4.51 KB 3.39 KB -24.8%
NestedObject 6.44 KB 4.93 KB -23.4%
Collection 5.59 KB 4.60 KB -17.7%
WideSymbols 8.05 KB 6.23 KB -22.6%
Optimize 5.44 KB 4.30 KB -21.0%
  • No time claim. Each solve creates and disposes a native Z3 context, which dominates the
    wall-clock and swamps the managed work; the short-job Mean values move in both directions well
    inside their error bars. Allocated is the metric that is stable enough to stand behind, and it
    is what a caching change is expected to move.
  • dotnet build solutions/Z3.Linq.slnx -c Release - clean, TreatWarningsAsErrors and
    documentation generation on.
  • 322/322 tests green in Release, unchanged - the marshalling, bounds, rewriter and
    anonymous/named/nested/collection paths this touches are all covered, so the identical test pass
    is the behaviour-preservation evidence.
  • ./build.ps1 -Configuration Release - 46 tasks, 0 errors, 0 warnings.

Release note

No public-API or behaviour change - an internal allocation reduction on the solve path. Releases
remain on hold under #60 regardless.

🤖 Generated with Claude Code

The benchmark suite showed every Solve/Optimize re-doing reflection whose
answer is fixed for the life of the environment type, and the collection
marshaller double-allocating. This caches the per-type reflection and fills
the collection array in place, cutting managed allocation per solve by
~18-26% across every environment shape, with no behaviour change (322 tests
still green).

- Cache the TheoremVariableTypeMappingAttribute lookup per type: the
  environment builder, the bounds asserter and the marshaller each asked for
  it - three times per member per solve - allocating an attribute array each
  time. Absence is cached as cheaply as presence.
- Cache the public instance property and field arrays per type, which
  GetProperties/GetFields otherwise reallocate on every solve.
- Guard the global- and predicate-rewriter attribute lookups with IsDefined,
  so the common no-rewriter path allocates no attribute array. The predicate
  lookup is hit once per call node in every constraint.
- Replace the CompilerGenerated .GetCustomAttributes(...).Any() anonymous-type
  check with IsDefined, which allocates nothing.
- Fill the collection element array in place instead of an ArrayList that
  keeps a boxed object[] backing store and is then copied into a typed array
  by ToArray.

Measured with the Z3.Linq.Benchmarks suite (BenchmarkDotNet, short job,
MemoryDiagnoser). Allocation is deterministic; the Mean times are dominated
by native Z3 context creation per solve and sit within short-job noise, so no
time claim is made.

| Shape         | Before  | After   |
|---------------|---------|---------|
| ScalarSymbols | 4.48 KB | 3.32 KB |
| ValueTuple    | 4.51 KB | 3.39 KB |
| NestedObject  | 6.44 KB | 4.93 KB |
| Collection    | 5.59 KB | 4.60 KB |
| WideSymbols   | 8.05 KB | 6.23 KB |
| Optimize      | 5.44 KB | 4.30 KB |

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PipoHZJsgydrV3JC3fQN6Q
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

Test Results

  1 files  ±0    1 suites  ±0   11s ⏱️ ±0s
309 tests ±0  309 ✅ ±0  0 💤 ±0  0 ❌ ±0 
322 runs  ±0  322 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 323baca. ± Comparison against base commit a4b7859.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant