Skip to content

perf: typed field accessors, cached TypeCode, and range coverage - #9

Merged
holonsoftFounder merged 3 commits into
mainfrom
perf/v4-typed-accessors
Sep 13, 2026
Merged

holonsoftFounder merged 3 commits into
mainfrom
perf/v4-typed-accessors

Conversation

@holonsoftFounder

Copy link
Copy Markdown
Member

Phase 2 complete. Three commits, each measured on its own.

1. Range violation coverage first (prerequisite)

Each of the five ConverterRangeViolationBehaviour values had exactly one test on one int field. Since the range check was about to move onto per-type accessors, a mistake affecting ushort but not int would have gone unnoticed — and these are read-direction paths, so the golden vectors are structurally unable to cover them.

AllNumericRangesPoco now carries all ten range-capable types (sbyte → decimal) in little- and big-endian variants, every behaviour asserted per field by name. Also pins that None behaves like IgnoreAndContinue rather than zeroing a field, and that an in-range value never invokes the handler. 72 → 83 tests.

2. Cached TypeCode

Type.GetTypeCode(...) ran per field per message in both directions. ConverterFieldInfo already had a private _fieldTypeCode but only populated it for range-checked fields — half-used, and the hot path recomputed it anyway.

Read −5.7% to −7.0%, Write LE −12.5%, Write BE −3.6%, strings −4% to −5%. Every row improves, which is expected here: both SetFieldValue and WriteFieldValueToArray called it, so no row should have stayed flat.

3. Typed accessors — no boxing in either direction

Action<T, object> on read and Func<T, object> on write boxed every value type once per field per message, and IsInRange(object) boxed again.

FastInvoke gained BuildTypedFieldSetter/BuildTypedFieldGetter. The pre-existing typed helpers only worked on PropertyInfo via Delegate.CreateDelegate, which cannot work for fields — a field has no get/set method. The new ones compile an expression that touches the field directly.

Method boxing typed time alloc
Read LE 118.86 ns 94.78 ns −20.3% 344 → 104 B
Read BE 148.85 ns 115.31 ns −22.5% 568 → 328 B
Read reused 114.23 ns 90.71 ns −20.6% 288 → 48 B
Write LE 209.75 ns 204.25 ns −2.6% 888 → 648 B
Write BE 333.25 ns 336.31 ns +0.9% 1528 → 1288 B
Read w/ string 71.39 ns 73.42 ns +2.8% 288 → 264 B
Write w/ string 182.10 ns 155.78 ns −14.5% 472 → 448 B

Reading into a reused instance allocates 48 bytes instead of 288.

The write path barely moves in time because it is no longer dominated by the box. What remains is BitConverter.GetBytes allocating a byte[] per field, LINQ Reverse for big-endian, and the growing List<byte> — exactly what Phase 3 removes.

Enum.ToObject is gone too: enums are accessed as int and the compiled expression converts.

Cumulative against shipped 3.6.1 (bcea658)

before now time alloc
Read LE 118.09 ns 94.78 ns −19.7% 344 → 104 B
Read reused 115.58 ns 90.71 ns −21.5% 288 → 48 B
Write LE 245.33 ns 204.25 ns −16.7% 888 → 648 B

Golden vectors unmoved throughout. 83/83.

🤖 Generated with Claude Code

christian.vogt@hiquaro.com and others added 3 commits September 13, 2026 22:23
…e orders

Each of the five ConverterRangeViolationBehaviour values had exactly one test on
one int field. A per type mistake, for example SetToMinValue being right for int
and wrong for ushort, would not have been noticed.

AllNumericRangesPoco carries sbyte, short, ushort, int, uint, long, ulong, float,
double and decimal, each with its own limits and default, in a little endian and a
big endian variant. Every behaviour is asserted per field by name, and big endian
must produce the same replacement values as little endian.

Also pins that None behaves like IgnoreAndContinue rather than zeroing the field,
and that a value inside its range never invokes the handler at all.

These are read direction paths, so the byte exact golden vectors cannot cover
them. This is the safety net for the typed accessor rewrite, where the range
check moves onto the accessor and becomes per type code.

72 to 83 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Type.GetTypeCode(kvp.Value.FieldInfo.FieldType) ran for every field of every
message in both directions. ConverterFieldInfo already had a private
_fieldTypeCode, but it was only populated for range checked fields, so the field
existed, was half used, and the hot path recomputed the value anyway.

It is public and always resolved now, and both call sites use it. The only
remaining Type.GetTypeCode calls happen while preparing the converter.

A/B of the same tree with and without the change, back to back:

  Read  LE      125.08 -> 116.38 ns    -7.0%
  Read  BE      152.64 -> 143.47 ns    -6.0%
  Read  reused  120.23 -> 113.35 ns    -5.7%
  Write LE      240.06 -> 210.17 ns   -12.5%
  Write BE      354.62 -> 341.77 ns    -3.6%
  Read  string   75.27 ->  71.53 ns    -5.0%
  Write string  183.39 -> 175.87 ns    -4.1%

Every row improves, which is what one expects here: both SetFieldValue and
WriteFieldValueToArray called GetTypeCode, so there is no row that should have
stayed flat. Allocation is unchanged, this removes work and not garbage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The read path assigned through Action<T, object> and the write path read through
Func<T, object>, so every value type was boxed once per field per message. The
range check went through IsInRange(object) and boxed again.

FastInvoke gained BuildTypedFieldSetter and BuildTypedFieldGetter. The existing
typed helpers only worked on PropertyInfo via Delegate.CreateDelegate, which is
useless for fields because a field has no get or set method. The new ones compile
an expression that touches the field directly and fold an optional conversion in.

ConverterFieldInfo builds a typed pair per field and exposes Set<TField> and
Get<TField>. Casting the stored Delegate to Action<T, int> is a type check, not
an allocation. An enum is accessed as int and the compiled expression converts,
so Enum.ToObject and the box it returned are gone as well.

Converted: 54 setter call sites and 10 range checks on the read side, the whole
switch on the write side. IsInRange(object) is no longer called anywhere. The two
remaining Setter calls assign strings, which are reference types and never boxed.

  Read  LE      118.86 ->  94.78 ns  -20.3%   344 -> 104 B
  Read  BE      148.85 -> 115.31 ns  -22.5%   568 -> 328 B
  Read  reused  114.23 ->  90.71 ns  -20.6%   288 ->  48 B
  Write LE      209.75 -> 204.25 ns   -2.6%   888 -> 648 B
  Write BE      333.25 -> 336.31 ns   +0.9%  1528 -> 1288 B
  Read  string   71.39 ->  73.42 ns   +2.8%   288 -> 264 B
  Write string  182.10 -> 155.78 ns  -14.5%   472 -> 448 B

The write path hardly moves in time because it is no longer dominated by the box.
What remains is BitConverter.GetBytes allocating a byte[] per field, LINQ Reverse
for big endian and the growing List<byte>, which is what Phase 3 removes.

Enum accessors are typed on int on purpose. Typing them on the primitive that
DestinationType names would not match the read path, which assembles the raw
value as an int for every DestinationType it supports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@holonsoftFounder
holonsoftFounder merged commit 89b2782 into main Sep 13, 2026
2 checks passed
@holonsoftFounder
holonsoftFounder deleted the perf/v4-typed-accessors branch September 13, 2026 20:50
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