[BAC-1490] Cut represent cost further: type dispatch and per-call preamble - #27
Open
HolyWalley wants to merge 4 commits into
Open
[BAC-1490] Cut represent cost further: type dispatch and per-call preamble#27HolyWalley wants to merge 4 commits into
HolyWalley wants to merge 4 commits into
Conversation
`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
marked this pull request as ready for review
August 8, 2026 20:58
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.
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-1484so 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.minis the best observed run,avgthe mean of the reported averages.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)representcalledtype.serialize_with_optionson every leaf, which re-runs the full cast —Boolean#serializedoes aFALSE_VALUESSet lookup per value,Float#serializere-runsHelpers::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 asserialize_cast_value, which this gem already uses inattribute.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 (ARdecimal→ BigDecimal where the entity declares:float). Compatibility is resolved once at plan-compile time, sinceSerializeCastValue.serializere-derives it behind arescueper value.2. Freeze the default options hash (d56d9cd)
default_represent_optionsreturned 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'sopt_hash_freezemakes 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_objectallocates one object per call more thaneachwith an explicit memo, and is ~29% slower on this loop shape.fetch_field_valuere-testedis_a?(Hash)for every field though the answer is fixed per call, and re-derivedname.to_symon 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_valueis public API and stays, just off the hot path.4. Represent arrays of entities in one pass (c77f57b)
Type::ArraycalledType::Entity#serialize_with_optionsper element, which calledrepresentper element, re-running the whole preamble — options resolution,custom_serializerslookup, plan lookup — 5000 times for the same answer.represent_allresolves 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_representationtakes the plan as an argument sorepresentandrepresent_allshare one loop body rather than duplicating it. The dispatch checks the exact class, notis_a?, because aType::Entitysubclass may overrideserialize_with_optionsand routing around it would silently change behaviour.This fires automatically for any
attribute :xs, :array, of: "SomeEntity"— no caller changes.represent_allis 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, customserializesblocks, andcamelize: 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 forrepresent_all. Rubocop reports the same 9 pre-existing offences as #26; no new ones.Notes
41e5989, before[BAC-1482] Add read_only attribute supportand the 0.15.1 version bump landed on master. Neither touchesschemas/json.rb, so a rebase should be clean, but [BAC-1484] Speed up Entity.represent for large nested payloads (~3-4x) #26 wants rebasing before merge and this one after.Deliberately not done
Measured and rejected, recorded in BAC-1490 so nobody repeats them:
"key":fragments, byte-identical output: 39.1 ms vs 27.3 ms and 2.4x the allocations.to_jsonover the finished hash is only ~13% of total; a Ruby traversal cannot beat the C generator. The intermediate hash is not the problem.JSON.generatecannot serialize them natively.Still open: a fast path for arrays of scalars (
tags-style attributes still serialize element-by-element) needspre_castthreaded through theserialize_with_optionssignature, 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