Skip to content

[BAC-1490] Cut represent cost further: type dispatch and per-call preamble - #27

Open
HolyWalley wants to merge 4 commits into
feature/BAC-1484from
feature/BAC-1490
Open

[BAC-1490] Cut represent cost further: type dispatch and per-call preamble#27
HolyWalley wants to merge 4 commits into
feature/BAC-1484from
feature/BAC-1490

Conversation

@HolyWalley

@HolyWalley HolyWalley commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #26. That PR compiled a per-class represent plan; profiling afterwards showed the remaining cost was per-value type dispatch and per-call preamble work, not the plan. Four changes here, each measured in isolation before landing.

Targets feature/BAC-1484 so the diff is only the new work. Tracked in BAC-1490.

Results

Same fixture and spec as #26 (spec/performance, 5000 nested products). Ruby 4.0.1, activemodel 8.1.2, 5 spec runs per revision, each doing 5 timed iterations after 2 warmups. min is the best observed run, avg the mean of the reported averages.

master #26 (base) this PR
Hash payload 83.9 / 93.1 ms 22.1 / 25.1 ms 17.7 / 19.7 ms
Entity object graph 71.6 / 77.4 ms 30.2 / 33.7 ms 19.2 / 20.0 ms
Allocations, hash 470,027 35,005 15,003
Allocations, entity 425,025 35,006 15,004
speedup vs #26 vs master
Hash payload 1.25x min / 1.27x avg 4.74x min / 4.72x avg
Entity object graph 1.57x min / 1.68x avg 3.73x min / 3.87x avg

Allocations are down 57% against #26 and 31x against master.

The entity-object-graph path was the slower of the two on #26 (30.2 ms vs 22.1 ms) and is now level with the hash path.

What changed

1. Serialize pre-cast values with serialize_cast_value (8b539b4)

represent called type.serialize_with_options on every leaf, which re-runs the full cast — Boolean#serialize does a FALSE_VALUES Set lookup per value, Float#serialize re-runs Helpers::Numeric#cast. When the source is one of our own instances that work is redundant; the values were already cast by these exact types on assignment. ActiveModel models this as serialize_cast_value, which this gem already uses in attribute.rb.

The guard is instance_of?(self), not !is_a?(Hash) — an ActiveRecord model is not a Hash either, but its values were cast by AR's types, not ours (AR decimal → BigDecimal where the entity declares :float). Compatibility is resolved once at plan-compile time, since SerializeCastValue.serialize re-derives it behind a rescue per value.

2. Freeze the default options hash (d56d9cd)

default_represent_options returned a fresh { camelize: true } per call, and every nested entity calls it once — 10,001 hash allocations on this fixture, purely to read one key. Ruby 3.4's opt_hash_freeze makes a frozen static literal allocation-free. Preferred over memoising, which would have broken the documented ability to override the method; the method is still called every time.

3. Inline the field read, drop each_with_object (36156a7)

each_with_object allocates one object per call more than each with an explicit memo, and is ~29% slower on this loop shape. fetch_field_value re-tested is_a?(Hash) for every field though the answer is fixed per call, and re-derived name.to_sym on each symbol-key lookup. Hoisting the test and interning the symbol at plan-compile time is ~37% faster for symbol-keyed hashes and ~45% for object sources. String keys still take precedence. fetch_field_value is public API and stays, just off the hot path.

4. Represent arrays of entities in one pass (c77f57b)

Type::Array called Type::Entity#serialize_with_options per element, which called represent per element, re-running the whole preamble — options resolution, custom_serializers lookup, plan lookup — 5000 times for the same answer. represent_all resolves it once and loops.

Measured per element, the removed method frames are worth nothing (1151 ns vs 1163 ns); the hoisted preamble is the entire win, 1209 ns → 973 ns. build_representation takes the plan as an argument so represent and represent_all share one loop body rather than duplicating it. The dispatch checks the exact class, not is_a?, because a Type::Entity subclass may override serialize_with_options and routing around it would silently change behaviour.

This fires automatically for any attribute :xs, :array, of: "SomeEntity" — no caller changes. represent_all is also public, so collections built by hand outside an entity attribute can use it directly.

Correctness

Serialized output is byte-identical on master, #26 and this branch — the full 942 KB JSON hashes to f8d9c3ca… on all three.

Beyond the suite, the following were diffed before/after and are unchanged: nil array elements, empty and nil arrays, string- and symbol-keyed hashes, uncast/raw input ({ a: "7", b: 9 } still coerces to { "a" => 7, "b" => "9" }), subclass instances, mixed entity/hash arrays, custom serializes blocks, and camelize: false.

Suite: 53 examples, 1 failure — inspect_spec.rb:45, which fails identically on master and on #26 (a Rails 8 inspect-formatting change, unrelated). 3 specs added for represent_all. Rubocop reports the same 9 pre-existing offences as #26; no new ones.

Notes

Deliberately not done

Measured and rejected, recorded in BAC-1490 so nobody repeats them:

  • Building the JSON string directly instead of a Hash. Prototyped with pre-baked frozen "key": fragments, byte-identical output: 39.1 ms vs 27.3 ms and 2.4x the allocations. to_json over the finished hash is only ~13% of total; a Ruby traversal cannot beat the C generator. The intermediate hash is not the problem.
  • Struct/Data as the represent output. Same allocation cost as the hash, and JSON.generate cannot serialize them natively.

Still open: a fast path for arrays of scalars (tags-style attributes still serialize element-by-element) needs pre_cast threaded through the serialize_with_options signature, and specs for uncast input first. A naive version silently breaks coercion and the current suite does not catch it.


🤖 Generated with Claude Code

https://claude.ai/code/session_01PWj42911KiUWpDA4BkpLPh

HolyWalley and others added 4 commits August 8, 2026 16:53
`represent` called `type.serialize_with_options` on every leaf, which re-runs
the full cast: `Boolean#serialize` does a `FALSE_VALUES` Set lookup per value,
`Float#serialize` re-runs `Helpers::Numeric#cast`, and so on. When the source is
one of our own instances that work is redundant — the values were already cast
by these exact types on assignment.

ActiveModel models this as `serialize_cast_value` (already used in
`attribute.rb`). Compatibility is resolved once at plan-compile time rather than
per value, since `SerializeCastValue.serialize` re-derives it behind a `rescue`.

The guard is `instance_of?(self)`, not `!is_a?(Hash)`: an ActiveRecord model is
not a Hash either, but its values were cast by AR's types, not ours. Custom
serializers are excluded in the plan because their output never went through the
type. Nested :entity/:array types are not compatible and keep recursing as before.

Entity object graph in spec/performance: 33.8ms -> 27.4ms min over 5 runs. Hash
payloads are unchanged, as they must be — raw input still needs coercion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWj42911KiUWpDA4BkpLPh
`default_represent_options` returned a fresh `{ camelize: true }` on every call,
and every nested entity calls it once — 10,001 hash allocations to represent the
5000-product performance fixture, ~28% of all allocations for that call, purely
to read one key.

Ruby 3.4's opt_hash_freeze makes a frozen static hash literal allocation-free:
the same object comes back every time. Ruby 3.2/3.3 (still supported by the
gemspec) get one wasted `freeze` call and no benefit, but no regression either.

Preferred over memoizing the result, which would have broken the documented
ability to override this method dynamically. The method is still called on every
invocation, so overrides behave exactly as before.

The hash reaches `serializes` blocks as `entity_options`. Nothing in the gem
mutates it, but a block that did would now raise FrozenError rather than
silently mutating a throwaway hash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWj42911KiUWpDA4BkpLPh
Two per-iteration costs in the represent loop, both multiplied by every attribute
of every nested entity (~55k iterations for the 5000-product perf fixture):

`each_with_object` allocates one object per call more than a plain `each` with
an explicit memo hash, and is ~29% slower on this loop shape. That is once per
nested entity, so it was 10,001 allocations on the fixture.

`fetch_field_value` re-tested `is_a?(Hash)` for every field even though the
answer is fixed for the whole call, and re-derived `name.to_sym` on each symbol
key lookup. Hoisting the test out of the loop and interning the symbol once at
plan-compile time is ~37% faster for symbol-keyed hashes and ~45% for object
sources. String-keyed hashes hit on the first lookup and never consult the
symbol, so they are unaffected. String keys still take precedence, matching
`fetch_field_value`.

`fetch_field_value` itself is public API and stays; it is just no longer on the
hot path.

spec/performance, min of 5 runs:

  hash payload         23.6ms -> 20.2ms
  entity object graph  26.3ms -> 21.6ms
  allocations          25004 -> 15003 per represent

Serialized output is byte-identical (verified by hashing the full 942KB JSON).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWj42911KiUWpDA4BkpLPh
`Type::Array` serialized an array of entities by calling
`Type::Entity#serialize_with_options` per element, which called `represent` per
element, which re-ran the whole preamble — options resolution, the
`custom_serializers` lookup and the compiled plan lookup — for all 5000 elements
even though every one of them produces the same answer.

`represent_all` resolves that once and loops. Only `instance_of?` and the Hash
test stay per element, since those genuinely vary: heterogeneous arrays, nil
holes and raw hashes all keep behaving exactly as before.

Measured per element on a small entity, the removed method frames are worth
nothing (1151ns vs 1163ns — Ruby calls are cheap); the hoisted preamble is the
entire win, 1209ns -> 973ns.

`build_representation` now takes the plan as an argument so `represent` and
`represent_all` share one loop body rather than duplicating it.

The dispatch in `Type::Array` checks the exact class, not `is_a?`: a
`Type::Entity` subclass may override `serialize_with_options`, and routing
around it would silently change behaviour.

spec/performance, min of 5 runs:

  hash payload         20.7ms -> 18.9ms
  entity object graph  22.5ms -> 21.0ms

Flat ~8% from arrays of 5 upward; a 1-element array is neutral. Allocations are
unchanged (15003) — this removes work, not objects. Serialized output is
byte-identical, verified by hashing the full 942KB JSON and by diffing 12 edge
cases (nil holes, empty and nil arrays, string- and symbol-keyed hashes,
subclass instances, mixed entity/hash arrays, custom serializers, camelize:
false).

`represent_all` is public, so collections built by hand outside an entity
attribute can use it directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWj42911KiUWpDA4BkpLPh
@HolyWalley
HolyWalley marked this pull request as ready for review August 8, 2026 20:58
@HolyWalley
HolyWalley requested review from taleh007 and yard August 8, 2026 20:58
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