Cut per-solve managed allocations by caching per-type reflection - #105
Draft
HowardvanRooijen wants to merge 1 commit into
Draft
Cut per-solve managed allocations by caching per-type reflection#105HowardvanRooijen wants to merge 1 commit into
HowardvanRooijen wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uses the
Z3.Linq.Benchmarkssuite from the PR below it to find, fix and verify a memory hotpath: every
Solve/Optimizere-does reflection whose answer is fixed for the life of theenvironment 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 theAllocatedcolumn - whichis deterministic, unlike the native-bound
Mean- the per-solve managed allocation is dominated bythe library rebuilding, from reflection, what it already knew:
TheoremVariableTypeMappingAttributeon a member's type is looked up three times per memberper solve - once in the environment builder, once in the bounds asserter, once in the marshaller
GetProperties/GetFieldshand back a fresh array on every call, so the environment builderreallocates the member list of every type on every solve.
the predicate one runs once per call node in every constraint.
GetCustomAttributes(...).Any().ArrayList(a boxedobject[]backing store) and then copiesit into a typed array with
ToArray- two arrays where one will do.The fix
All small, local, and behaviour-preserving:
TheoremVariableTypeMappingAttributeper type (absence cached too)GetProperties/GetFieldsarray per type per solveIsDefinedguard on the global- and predicate-rewriter lookupsIsDefinedfor the anonymous-typeCompilerGeneratedcheckArrayList, no boxedobject[]backing store, no copy-out arrayThe 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:ScalarSymbolsValueTupleNestedObjectCollectionWideSymbolsOptimizewall-clock and swamps the managed work; the short-job
Meanvalues move in both directions wellinside their error bars.
Allocatedis the metric that is stable enough to stand behind, and itis what a caching change is expected to move.
dotnet build solutions/Z3.Linq.slnx -c Release- clean,TreatWarningsAsErrorsanddocumentation generation on.
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