[BAC-1484] Speed up Entity.represent for large nested payloads (~3-4x) - #26
Conversation
Precompute [json_name, name, type, custom_serializer] tuples once per class instead of camelizing names and looking up serializers on every represent call. Read hash fields via string-then-symbol lookup instead of copying each hash with_indifferent_access (kept for classes with custom serializers, whose blocks receive the hash), and skip the options merge when no options are passed.
| config.example_status_persistence_file_path = ".rspec_status" | ||
|
|
||
| # Performance specs are opt-in: PERFORMANCE=1 bundle exec rspec spec/performance | ||
| config.filter_run_excluding :performance unless ENV["PERFORMANCE"] |
There was a problem hiding this comment.
is it take much to run? maybe make them on by default?
There was a problem hiding this comment.
They add ~4.5s locally vs 0.08s for the unit suite (~50x slower), and more on CI — plus wall-clock assertions on shared CI runners are the classic flakiness source. That's why they're opt-in (PERFORMANCE=1 bundle exec rspec spec/performance). Happy to flip the default if you feel strongly — it's a one-liner.
yard
left a comment
There was a problem hiding this comment.
Does the dynamically emitted serialization/deserialization code still apply? Any optimizations to be made there?
| if object_or_hash.is_a?(Hash) | ||
| object_or_hash[name] | ||
| value = object_or_hash[name] | ||
| value.nil? ? object_or_hash[name.to_sym] : value |
There was a problem hiding this comment.
Since we are talking optimizations here, shall this “try this, then try that” be gone too?
There was a problem hiding this comment.
Measured it: on symbol-keyed sources (worst case — the string lookup always misses first) the fallback costs ~16ms per 5000 products (180ms vs 164ms for string-keyed, ~9%). There's no generic single lookup without knowing the hash's key style; we could sniff the style once per hash and pick one accessor, but that felt like extra branching for a second-order win. FWIW it's the same string-then-symbol convention the compiled from_json assigner already uses. Can do the sniff as a follow-up if you think it's worth it.
yard
left a comment
There was a problem hiding this comment.
Also pt. 2 (killing with_indifferent_access everywhere but for custom serilizer block) does look a tad weird, we probably don’t really gain much from it but now implement it ourselves
|
@yard on your two review comments: Does the dynamically emitted code still apply? Yes — the compiled On dropping |
What
Entity.representwas slow on large payloads. This PR makes it ~4x faster for hash sources and ~3x faster for entity object graphs, and adds an opt-in performance spec suite that documents the baseline.Benchmark — one catalog entity holding 5000 products, each with regular fields plus a small nested entity (min / avg over 5 runs, same machine):
Why it works (in simple terms)
Representing a list of 5000 products means calling
represent10,001 times (the root + each product + each product's nested entity). The old code redid the same preparation inside every one of those calls — preparation whose result never changes:It kept re-translating the same column names. Every product has the same attributes, and
created_atcamelizes tocreatedAtevery single time — yet the inflector ran again for every attribute of every object (~50,000 times per call). Now each class computes its "represent plan" once — the output key, the type and the custom serializer for each attribute — and every later call just walks that precomputed list. Like a spreadsheet: you translate the header row once, not once per row. (Same trick as the compiled JSON assigner from BAC-1267 — and like it, the plan is built per exact class and invalidated ifattribute/serializesis called later.)It kept photocopying hashes just to read them. Every nested hash was wrapped in
with_indifferent_access— a full copy — only so fields could be found whether the keys are strings or symbols. Now we simply try the string key first and the symbol key second; no copy at all. The one place that genuinely relies on the copy —serializesblocks, which receive the source hash and may look fields up either way — still gets it, so their behavior is unchanged.It allocated option hashes nobody asked for.
default_represent_options.merge(options)ran for every nested entity even whenoptionswas empty. The merge is now skipped when there is nothing to merge.None of this changes what
representreturns — it just stops paying per object for work that is per class. One observable nuance: untyped hash attributes now pass the original hash through instead of an indifferent-access copy (identical once rendered to JSON).Performance specs
New opt-in suite, excluded from the default run (CI is unaffected):
It represents the 5000-product catalog from both source shapes, asserts the output is correct, prints the timings, and fails only if a run exceeds a deliberately generous 2s budget — a catastrophic-regression guard (e.g. accidentally quadratic serialization), not a microbenchmark.
Jira
BAC-1484
🤖 Generated with Claude Code