Skip to content

perf: type-bound member accessors and no bool boxing (~10-33% faster object rendering) - #652

Merged
rexm merged 5 commits into
masterfrom
perf/typed-member-accessor
Aug 5, 2026
Merged

perf: type-bound member accessors and no bool boxing (~10-33% faster object rendering)#652
rexm merged 5 commits into
masterfrom
perf/typed-member-accessor

Conversation

@rexm

@rexmrexm commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Two targeted optimizations to the reflection-based member access path (the path used when rendering plain .NET objects, including anonymous types):

  1. Type-bound member accessors.ObjectDescriptors are already cached per type, but the shared ReflectionMemberAccessor re-resolved its per-type accessor table on every member access via a type-keyed LookupSlim lookup. The descriptor's member accessor is now pre-bound to the described type at descriptor creation, so the common case (instance type matches the descriptor) goes straight to the member-name lookup. If the instance type doesn't match the bound type, it falls back to the original shared path — behavior is unchanged.

  2. No boxing for bool property reads. The cached Func<object, object> getters box value-type property values on every read. bool properties — ubiquitous in {{#if}}/{{#unless}}-heavy templates — now return the cached BoxedValues.True/False instances instead of allocating a fresh box per read.

Dictionary-based data paths are untouched. All 1837 tests pass.

Benchmarks

MediumRun (LaunchCount=1, 15 iterations), Apple M4, .NET 10. Baseline is master @ 37ca7b9.

Object-path suites (the target of this change):

BenchmarkCasemasterThis PRΔ timemaster allocPR alloc
RenderSimpleobject856.85 ns575.79 ns−32.8%48 B0 B
RenderNestedobject, rows=56,764.33 ns5,650.00 ns−16.5%1192 B592 B
RenderNestedobject, rows=2024.52 us18.94 us−22.7%4624 B2224 B
RenderListN=10, object2,916.26 ns2,585.00 ns−11.4%480 B0 B
RenderListN=100, object27.48 us22.77 us−17.1%6720 B1920 B
RenderListN=1000, object259.14 us236.41 us−8.8%71520 B23520 B
RenderToStringclean13.95 us12.93 us−7.3%32.09 KB30.91 KB
RenderToStringhtml14.75 us13.83 us−6.2%35.31 KB34.14 KB

Full suite (including neutral paths):

All 30 benchmark cases
BenchmarkCasemasterThis PRΔ timemaster allocPR alloc
Execution.CallHelperWithoutParameters44.35 ns44.08 ns-0.6%NANA
EndToEndN=5, dictionary28.78 us29.52 us+2.6%NANA
EndToEndN=5, object29.49 us28.38 us-3.8%NANA
RenderNesteddictionary, rows=55,413.42 ns5,438.00 ns+0.5%544 B544 B
RenderNestedobject, rows=56,764.33 ns5,650.00 ns-16.5%1192 B592 B
RenderNesteddictionary, rows=2018.54 us18.14 us-2.2%2176 B2176 B
RenderNestedobject, rows=2024.52 us18.94 us-22.7%4624 B2224 B
RenderListN=10, dictionary2,420.21 ns2,435.00 ns+0.6%0 B0 B
RenderListN=10, object2,916.26 ns2,585.00 ns-11.4%480 B0 B
RenderListN=100, dictionary23.42 us23.20 us-0.9%1920 B1920 B
RenderListN=100, object27.48 us22.77 us-17.1%6720 B1920 B
RenderListN=1000, dictionary231.70 us235.50 us+1.6%23520 B23520 B
RenderListN=1000, object259.14 us236.41 us-8.8%71520 B23520 B
LargeArrayN=20000389.13 us386.69 us-0.6%NANA
LargeArrayN=40000801.86 us777.20 us-3.1%NANA
LargeArrayN=800001.571 ms1.561 ms-0.7%NANA
RenderSimpledictionary509.96 ns501.56 ns-1.6%0 B0 B
RenderSimpleexpando414.36 ns410.34 ns-1.0%0 B0 B
RenderSimpleobject856.85 ns575.79 ns-32.8%48 B0 B
Execution.CallHelperWithOneParameter46.16 ns45.68 ns-1.0%NANA
Execution.CallHelperWithTwoParameter46.45 ns47.37 ns+2.0%NANA
Execution.LateCallHelperWithoutParameters44.11 ns43.66 ns-1.0%NANA
Execution.LateCallHelperWithOneParameter46.17 ns45.70 ns-1.0%NANA
Execution.LateCallHelperWithTwoParameter46.32 ns47.71 ns+3.0%NANA
Execution.CallBlockHelperWithoutParameters47.04 ns47.44 ns+0.9%NANA
Execution.CallBlockHelperWithOneParameter47.65 ns47.47 ns-0.4%NANA
Execution.CallBlockHelperWithTwoParameter48.15 ns49.04 ns+1.8%NANA
RenderToStringclean13.95 us12.93 us-7.3%32.09 KB31656 B
RenderToStringhtml14.75 us13.83 us-6.2%35.31 KB34960 B

Dictionary/expando cases and helper-dispatch (Execution.*) benchmarks are neutral (within ±3% run-to-run noise), as expected — this change only affects the reflection member-access path.

Remaining RenderList allocations at larger N are iterator index boxing beyond the BoxedValues int cache (0–19) — identical for object and dictionary data, and a possible follow-up.

Note: includes the RenderToString benchmark suite commit (also submitted separately as #650) so the numbers above are reproducible from this branch.

🤖 Generated with Claude Code

rexmand others added 4 commits August 4, 2026 22:02
Plain-object descriptors are cached per type, but ReflectionMemberAccessor
re-resolved its per-type accessor table on every member access via a
type-keyed lookup. The descriptor's member accessor is now pre-bound to the
described type, so the common case (instance type matches the descriptor)
goes straight to the member-name lookup. Mismatched instance types fall
back to the original shared path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Value-type properties returned through the cached Func<object, object>
getters box on every access. For bool properties — common in {{#if}}/
{{#unless}}-heavy templates — return the cached BoxedValues.True/False
instances instead of allocating a fresh box per read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rexm
rexm enabled auto-merge August 5, 2026 03:45
@sonarqubecloud

Copy link
Copy Markdown

@rexm
rexm merged commit 9ee2d48 into masterAug 5, 2026
7 checks passed
@rexm
rexm deleted the perf/typed-member-accessor branch August 5, 2026 04:10
Sign up for freeto 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

@rexm