Ruby impl perf codegen - #226
Closed
yosiat wants to merge 84 commits into
Closed
Conversation
…fter first call. +3.6% IPS
Result: {"status":"keep","total_ips":13443.41,"total_allocs":4818}
…aseline! Simple 2300: 209 ips (was 175)
Result: {"status":"keep","total_ips":15600.3,"total_allocs":4818}
…er baseline! Simple 2300: 265 ips
Result: {"status":"keep","total_ips":18463.56,"total_allocs":4818}
…11% over baseline. 14422 vs 12978
Result: {"status":"keep","total_ips":14421.88,"total_allocs":4818}
…allocs unchanged at 4818
Result: {"status":"keep","total_ips":17167.58,"total_allocs":4818}
…6% over baseline
Result: {"status":"keep","total_ips":17339.66,"total_allocs":4818}
…one call. +40.5% over baseline
Result: {"status":"keep","total_ips":18243.27,"total_allocs":4818}
… over baseline. IPS surpasses best C result
Result: {"status":"keep","total_ips":18904.34,"total_allocs":4860}
…% over baseline! Simple: 284 ips (was 175)
Result: {"status":"keep","total_ips":19826.9,"total_allocs":4860}
…aseline. Simple: 297 ips!
Result: {"status":"keep","total_ips":20225.64,"total_allocs":4860}
… baseline
Result: {"status":"keep","total_ips":20332.18,"total_allocs":4860}
…ple: 307 ips, HasOne: 163 ips
Result: {"status":"keep","total_ips":20987.65,"total_allocs":4860}
…line
Result: {"status":"keep","total_ips":21186.95,"total_allocs":4860}
…baseline! Simple: 322 ips
Result: {"status":"keep","total_ips":22023.47,"total_allocs":4860}
…3% over baseline! Simple: 339 ips
Result: {"status":"keep","total_ips":23014.96,"total_allocs":4860}
…. Marginal improvement
Result: {"status":"keep","total_ips":23137.24,"total_allocs":4860}
…ver baseline! Simple: 383 ips (was 175)
Result: {"status":"keep","total_ips":25560.74,"total_allocs":4860}
… (was 175). More than 2x original!
Result: {"status":"keep","total_ips":26872.55,"total_allocs":4866}
… ips. Inner loop is now pure array access
Result: {"status":"keep","total_ips":28254.49,"total_allocs":4878}
…eduction). IPS ~26k (within noise of best).
Result: {"status":"keep","total_ips":26078.45,"total_allocs":184}
…d call. +131.9% over baseline! Simple 2300: 477 (was 175), HasOne 2300: 231
Result: {"status":"keep","total_ips":30095.94,"total_allocs":190}
…% over baseline, +2.5% over prev
Result: {"status":"keep","total_ips":30839.7,"total_allocs":190}
… paths for attrs-only and attrs+has_one. +138.8%
Result: {"status":"keep","total_ips":31000.08,"total_allocs":190}
…baseline. HasOne 2300: 241 (was 235)
Result: {"status":"keep","total_ips":31455.28,"total_allocs":190}
…_DEFINED checks. +151.3% over baseline! Simple 2300: 520
Result: {"status":"keep","total_ips":32615.5,"total_allocs":190}
…ublic_send. +172.1% over baseline! HasOne 2300: 313 (was 244)
Result: {"status":"keep","total_ips":35318.35,"total_allocs":190}
…e class). +178.6% over baseline! HasOne: 323, Simple: 547
Result: {"status":"keep","total_ips":36152.28,"total_allocs":190}
…nce.
Result: {"status":"keep","total_ips":36156.07,"total_allocs":190}
…ociations When a serializer declares has_one pointing to a plain method on the model (not a real AR association), object.association() raises AssociationNotFoundError. Rescue and fall back to public_send, matching has_many behavior which already uses public_send directly.
…ocal caching When Serializer.new is called without :only/:except/:context/:scope options, SerializationDescriptor.build now returns a thread-local cached copy instead of duplicating the descriptor on every call. This avoids allocating ~8 objects per call (descriptor, 2 array dups, serializer instance, 3 association duplicates with recursive sub-descriptors). Each thread gets its own duplicate (created once, reused forever), keeping the mutable per-call state (serializer @object, association Writer/RecordState) thread-safe under Puma. Also fixes a latent bug in RecordState#setup where the IndexedRow fast path could crash when a reused RecordState transitions from an IndexedRow-backed object to a non-indexed object. Benchmark (panko_oj, single Game object, Rails 8.0): Unpersisted: 119k → 190k i/s (+59%) Persisted: 96k → 171k i/s (+78%) Allocations per call: 79 → 23 objects (-71%)
Instead of creating a new Engine::Serializer on every serialize_to_json / to_json call, cache it on the descriptor via engine_serializer. Since descriptors are thread-local (from the previous commit), the cached engine is also per-thread and safe to reuse. Both write_fields and _serialize_many validate that the cached attributes_writer matches the current object type via AttributesWriter.writer_for, handling the edge case where a reused engine encounters a different object type (e.g. AR model then Hash). Also refactors AttributesWriter.create into writer_for (returns class) and create (instantiates), enabling cheap is_a? checks without allocation. Benchmark (panko_oj, single Game object, Rails 8.0): Unpersisted: 190k → 225k i/s (+18%) Persisted: 171k → 213k i/s (+25%) Cumulative from baseline: Unpersisted: 120k → 225k i/s (+88%) Persisted: 96k → 213k i/s (+122%) panko_json (array serialization): no change — one engine per batch.
…ordAttributesWriter Foundation for the code-generation backend that will replace the generic loop in Engine::Serializer with unrolled per-attribute code. - CodeGen module with enable!/disable! and PANKO_CODE_GEN=0 env var - FilterMask: frozen boolean arrays for filtered serialization - GeneratedBase: thin public API (serialize_one/many) + shared helpers (_resolve_type, _write_value) for generated cold-path code - ActiveRecordAttributesWriter: parallel cache storage, build_caches!, thread-local RecordState, runtime dispatch to generated methods (no attribute loops — all iteration is generated by the Compiler) No integration — existing tests unaffected.
Emitter: source string builder with per-attribute code patterns for each write strategy (cached hot path, filtered, first-pass type resolution, dirty-attribute fallback, non-indexed Rails 7.x fallback). Compiler: takes a SerializationDescriptor and produces a GeneratedBase subclass with all methods defined via module_eval. Generated methods: - _write_indexed_cached / _write_indexed_cached_filtered (unrolled hot path) - _write_indexed_first_pass / _write_indexed_first_pass_filtered (unrolled cold) - _write_ar_fallback / _write_ar_fallback_filtered (unrolled dirty/non-indexed) - _write_one (AR dispatch, Hash/PORO stubs for later commits) Emitter specs eval generated code with doubles to verify behavior, not string patterns. Compiler specs use real AR records and verify output matches the existing Engine::Serializer identically. No integration — existing tests unaffected.
SerializationDescriptor#engine_serializer now returns a compiled generated class for attrs-only serializers when CodeGen is enabled. Serializers with method fields or associations fall back to Engine::Serializer (support added in upcoming commits). - Add Hash/PORO unrolled write methods to Compiler + Emitter - Generate _serialize_many with once-per-batch type dispatch - Add AR alias resolution in ActiveRecordAttributesWriter - Association#serializer_writer delegates to descriptor.engine_serializer - Expose attr_writer on GeneratedBase for Compiler to set delegates All 309 tests pass across Rails 7.2, 8.0, 8.1.
Compiler generates unrolled _write_method_fields with literal method names and serialization keys baked into the source — no public_send, no array lookups at runtime. Pooled serializer stored as a class ivar (no Thread.current needed since compiled classes are per-descriptor). - Emitter: emit_method_field(name, key) with literal values - Compiler: gen_write_method_fields, wired into _write_one and _serialize_many - Propagates serialization_context for scope/context access - engine_serializer gate relaxed: now covers attrs + method fields All 308 tests pass across Rails 7.2, 8.0, 8.1.
…ll recompilation ArraySerializer always passes only:/except: keys, causing descriptor duplication on every call. Previously engine_serializer compiled a new class each time via module_eval — ~600x slower than the old Engine::Serializer constructor. Now the compiled class is stored on the class-level descriptor and reused across all duplicated descriptors.
…ng to code-gen Removes the association gate in engine_serializer — all serializers now use code-gen regardless of associations. Key changes: - Compiler generates _write_has_one/_write_has_many with inline AR target resolution (literal method calls, no public_send) and static sub-masks for associations with built-in filters (e.g. has_many :foos, only: [:name]) - FilterMask computation on SerializationDescriptor compares filtered descriptors against canonical to produce boolean masks for attrs, method_fields, has_one, and has_many (with recursive nested masks) - Serialization context flows explicitly through the call chain via a context parameter, propagated to sub-serializers in association calls - Canonical descriptor now creates serializer instance when method_fields are added (fixes nil serializer on canonical for sub-serializers) - Generated _write_hash_filtered/_write_plain_filtered for non-AR filtering - Generated _write_method_fields_filtered for method field filtering
…apper Sub-serializer calls now go directly to _write_one (positional args) instead of through _serialize_one (keyword args + push_object/pop wrapper). Saves one method call and keyword arg overhead per association per object.
Split both classes into concern-based modules: Emitter: ActiveRecordAttributes, ObjectAttributes, MethodFields, Associations Compiler: ActiveRecordMethods, ObjectMethods, MethodFields, Associations, Dispatch Each module is a separate file (~50-80 lines) focused on one concern. Prepares the structure for adding hash-path variants alongside their JSON counterparts without bloating a single file.
Serialize/to_a now build Ruby Hashes directly via generated _write_one_hash methods instead of routing through ObjectWriter. Each Compiler/Emitter module gets _hash variants (cached, first-pass, fallback, filtered) mirroring the existing JSON path. ValueCapture stands in as a minimal writer so the existing ValuesWriter pipeline handles all type coercion — no explicit type.deserialize needed.
… now the only path Delete Engine::Serializer (generic loop), AR::Writer (attribute dispatch), HashWriter, PlainWriter, ObjectWriter, and creator.rb. All serialization now goes through compiled GeneratedBase subclasses. Move SKIP constant to Panko::Engine module. Rewrite CLAUDE.md for code-gen architecture; add lib/panko/code_gen/CLAUDE.md for Compiler/Emitter details.
…ution Replace the respond_to?(:association) + rescue AssociationNotFoundError pattern with reflect_on_association guard. This avoids expensive exception creation for method-based has_ones (e.g. game.scores returning self). Single game benchmark: 253k -> 318k i/s (+32%). has_one dispatch: 1.34us -> 0.57us (-57%).
…uplication Replace separate _filtered method variants with a single method that accepts an attr_mask parameter. FilterMask::EMPTY (with INCLUDE_ALL that returns true for any index) is passed for unfiltered calls, so the same generated code handles both cases. This halves the number of generated methods per serializer class (e.g. GamePanko: 35 -> 19 methods) and removes ~476 lines of duplicated emitter, compiler, and dispatch code.
…p_generated_source API Static dispatch (_write_one, _write_one_hash, _serialize_many) and no-op stubs for optional concerns now live as pre-written methods on GeneratedBase. Cold-path AR methods (_write_indexed_first_pass, _write_ar_fallback) and Hash-object methods (_write_hash) are pre-written loops — only the hot-path _write_indexed_cached and methods requiring literal names (_write_plain, _write_method_fields, associations) remain generated. Adds dump_generated_source on Serializer for inspecting generated code, with labels that include serializer name and field info.
Replace parallel array indirection (aw.col[i], aw.key[i], aw.dir[i], aw.wtr[i]) with per-attribute class ivars (@_col_0, @_dir_0, @_wtr_0) and literal serialization keys baked in at compile time. Eliminates the @_ar_writer lookup and all array indexing from the hot path — each attribute is now just ivar reads and a literal string.
Generated code previously special-cased unfiltered slots at runtime with `mf_mask.nil? || mf_mask[i]` and `ho_masks&.dig(i) || @_ho_static_masks[i] || EMPTY`. The same defaults now live in the FilterMask itself: - `FilterMask::EMPTY` stores `INCLUDE_ALL` for method_fields / has_one / has_many, and a new `NIL_SLOTS` sentinel for has_one_masks / has_many_masks. Both sentinels respond to `[]` without allocating. - `SerializationDescriptor.compute_filter_mask` always populates every slot — never raw `nil`. - `Compiler#compute_static_masks` falls back to `FilterMask::EMPTY` per slot, so `@_ho_static_masks[i]` / `@_hm_static_masks[i]` are always `FilterMask`s. Generated code collapses to `if ho_mask[i]` and `nested = ho_masks[i] || @_ho_static_masks[i]` — no `.nil?`, no `&.`.
_write_one and _write_one_hash are now generated per serializer by the new Compiler::Dispatch module. They: - compute object.is_a?(ActiveRecord::Base) once into is_ar and reuse it for each has_one reflection check (previously re-checked per association) - inline method fields / has_one / has_many blocks instead of calling separate stub methods, and emit nothing for absent concerns (e.g. a leaf serializer with no method fields or associations now skips three method calls per record) - own push_object(key) / pop on the JSON path, folding the old _serialize_one wrapper into the write itself; _serialize_many and parent has_* emitters pass a key (or nil inside arrays) instead of wrapping each call Alongside, _write_hash / _write_hash_hash are generated and unrolled with literal object["name"] lookups, removing the per-call each_with_index over @_attrs. GeneratedBase loses _write_one, _write_one_hash, _serialize_one, all six concern stubs, and _write_hash / _write_hash_hash — everything above either lives on the generated subclass now or is unused. Compiler modules for method_fields and associations are gone; their emitters remain and are invoked from Dispatch.
The dumper is the tool used to regenerate the benchmarks/playground fixtures — previously it had no entry point on the serializer class. dump_generated_source now takes a required +file:+ and invokes StandaloneDumper, producing a self-contained <Name>Generated class suitable for experimentation. Inspection of the compiled runtime class is still available via _descriptor.engine_serializer.dump_source for debugging.
StandaloneDumper is a separate code generator from the runtime Compiler — it emits self-contained <Name>Generated classes for playground use. When the Compiler was updated to produce inlined _write_one / _write_one_hash with a single is_ar check and unrolled _write_hash, the dumper kept its old three-method dispatch (separate _write_has_one, _write_has_many, _write_method_fields, empty stubs, looped _write_hash). The playground serializers therefore looked stale. The dumper now mirrors Compiler::Dispatch: - _write_one owns push_object(key) / pop - is_ar computed once, threaded into has_one target resolution - method-fields / has_one / has_many inlined; absent concerns emit nothing - _write_hash unrolled with literal object["name"] lookups - no _serialize_one, no stub methods - _serialize_many loops via _write_one(obj, writer, nil, ...) - has_one sub-dispatch calls Sub._write_one(target, writer, "key", ...) directly — no external push_object/pop wrapping benchmarks/playground/serializers/*.rb regenerated via DUMP_CODE=true bundle exec appraisal 8.0.0 ruby benchmarks/playground/main.rb
Drops the shared @_serializer instance (populated via instance_variable_set + serialization_context= on every call) in favor of @_serializer_class — an anonymous subclass of the user's serializer whose initialize assigns @serialization_context and @object directly. Generated _write_one allocates one instance per record via @_serializer_class.new(context, object), eliminating the per-call instance_variable_set reflection and the cross-call state contamination risk that the shared instance carried. StandaloneDumper, the Compiler, the Dispatch emitter, and GeneratedBase all move in lockstep to the new shape.
When a record is loaded via .select(:a_subset) and the serializer declares attributes not in the selection, ActiveRecordAttributesWriter stamps @_col_i = nil for the unselected columns. The generated _write_indexed_cached then did row[nil] and raised "TypeError: no implicit conversion from nil into Integer" on every post-warmup call. Emit a nil-guard in emit_cached_attr / emit_cached_attr_hash so a missing column produces a nil JSON/Hash value instead. Adds regression coverage exercising both the first-pass and warmed cached paths.
The non-indexed branch of `_write_ar_fallback` was a pre-written `each_with_index` loop on `GeneratedBase`. Stackprof on unpersisted records showed this loop as the #1 self-time frame (~10%). This change moves `_write_ar_fallback` (and its `_hash` twin) onto each generated class. The indexed branch remains a loop — it's a cold path (dirty records only) and unrolling it would double the generated source for no measurable win. The non-indexed `else` branch is unrolled into one `if attr_mask[i]` block per attribute. Dispatch semantics are unchanged: each attribute still goes through `Panko::Engine::AttributesWriter::ActiveRecord::ValuesWriter.write` (direct `cached_writer` dispatch is E6, deferred to its own commit). Each generated class now stamps `@_attr_#{i}` onto itself at class build time (via a new `stamp_attr_ivars!` call from `ActiveRecordAttributesWriter#initialize`). This lets the unrolled code read the `Panko::Attribute` by ivar instead of through `aw.attrs[i]`. The generated code reads `@_attr_#{i}.name` at call time (through `rs.read_attribute`), so `handle_class_change`'s rewrite of `attr.name` for AR column aliases is still respected. Open question (not measured): for serializers with 32+ attributes the unrolled method may exceed YJIT's inlining budget. TODO inline in `gen_write_ar_fallback` — gate on `@n` and fall back to a loop if a regression shows up on a wide serializer. Playground benchmark (Ruby 3.4.5 YJIT, Rails 8.0): | Before | After | Delta Unpersisted | 236k i/s | 252-256k | +7-8% Persisted | 256k i/s | 249-255k | within noise Unpersisted improvement matches the expected +11% target band; persisted is unchanged because the hot path (`_write_indexed_cached`) wasn't touched. New spec coverage (`spec/features/ar_fallback_unrolled_spec.rb`): - Unpersisted records — all attrs, nil values, in-memory mutations - Persisted record after mutation (indexed branch with dirty hash) - `attribute :foo, alias: :bar` — name_for_serialization routing - AR `alias_attribute` — class-change rewrite propagates at call time - Empty serializer — no emitted attribute blocks, still valid method - Partial select on non-indexed path — `rs.read_attribute` returns nil
The non-indexed `else` branch of `_write_ar_fallback` (unrolled in E5) was
still calling `Panko::Engine::AttributesWriter::ActiveRecord::ValuesWriter
.write` per attribute. That wrapper does a `Thread.current[:panko_values_writer]`
lookup, a `Writer#write` dispatch, and a `name_for_serialization` resolution
on every call, even after the type-specific writer has been materialized.
E6 short-circuits that: after warmup `attribute.cached_writer` holds a
concrete writer (`StringWriter`, `IntegerWriter`, ...) that was resolved and
stashed inside `ValuesWriter::Writer#write`. We call it directly with a
cached `@_attr_#{i}_key` ivar for the JSON key. The cold path (first call,
`cached_writer` still nil) still delegates to `ValuesWriter.write` so the
writer and type get resolved and cached for subsequent calls. The
`cw.write(...) || push_value(type.deserialize(v), key)` shape mirrors the
existing fallback inside `Writer#write` for writers whose `#write` returns
false (e.g. `DateTimeWriter` on a non-String value).
## `name_for_serialization` stability
The key concern for this change is that `@_attr_#{i}_key` is stamped at class
build time and must not drift at runtime. Walking the attribute lifecycle:
- `Attribute#name_for_serialization` returns `@alias_name || @name`.
- `SerializationDescriptor.duplicate` does a shallow `attributes.dup`, so
runtime Attribute instances are shared with the canonical descriptor.
- `handle_class_change` mutates `attr.name` and `attr.alias_name` when an
AR `alias_attribute` is declared, but in lockstep: `attr.alias_name = old
name` and `attr.name = underlying column`, so `name_for_serialization`
continues to return the original DSL name.
- Every other emitter already bakes `name_for_serialization` in as a string
literal at build time (`emit_cached_attr`, `emit_plain_attr`,
`emit_hash_attr`, `emit_method_field`, StandaloneDumper). Caching it on
the fallback path is the same invariant applied one branch wider — so no
new incorrectness is introduced.
- There is a pre-existing edge case (Panko DSL `aliases foo: :bar` colliding
with AR `alias_attribute :foo`) where `handle_class_change` would
overwrite the DSL alias; that was already broken on the indexed path and
is out of scope for E6.
Added specs cover: cold-to-hot transition, `cached_writer` population, STI
class pivot in both directions, AR `alias_attribute`, partial select on the
non-indexed path, Panko DSL alias, the `cw.write` -> `type.deserialize`
fallback, and ivar stamping at class build time. All specs pass against all
Rails versions (7.2, 8.0, 8.1) — 310 examples per version, 0 failures.
## Files touched
- `lib/panko/code_gen/active_record_attributes_writer.rb` — `stamp_attr_ivars!`
now also sets `@_attr_#{i}_key = attr.name_for_serialization`.
- `lib/panko/code_gen/emitter/active_record_attributes.rb` — `emit_fallback_attr`
and `emit_fallback_attr_hash` emit the E6 direct-dispatch shape with an
inline comment explaining the flow.
- `lib/panko/code_gen/standalone_dumper.rb` — `_write_ar_fallback` is now
built via the emitter so the dumped source matches what the runtime
compiles. Bootstrap stamps `@_attr_#{i}` and `@_attr_#{i}_key` explicitly
for self-contained reading.
- `spec/features/ar_fallback_cached_writer_spec.rb` — new coverage.
## Benchmark numbers (Ruby 3.4.5 YJIT, `benchmarks/playground/main.rb`)
| Bench | E5 baseline (c9c3516) | E6 (this commit) | Delta |
|-------------|----------------------|------------------|-------|
| Unpersisted | 250.6k / 246.4k i/s | 259.8k / 254.8k | +3.5% |
| Persisted | 248.2k / 243.8k i/s | 245.5k / 240.4k | flat |
Persisted is flat as expected — persisted records hit `_write_indexed_cached`
which E6 does not touch. The gain is on the non-indexed fallback branch
exercised by unpersisted (and by dirty records on Rails < 8).
Owner
Author
|
Closed in favor of better solution. |
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.
Based on #218 but rewriting the internals to do code generation for faster performance.