Skip to content

Ruby impl perf codegen - #226

Closed
yosiat wants to merge 84 commits into
masterfrom
ruby-impl-perf-codegen
Closed

Ruby impl perf codegen#226
yosiat wants to merge 84 commits into
masterfrom
ruby-impl-perf-codegen

Conversation

@yosiat

@yosiat yosiat commented Apr 12, 2026

Copy link
Copy Markdown
Owner

Based on #218 but rewriting the internals to do code generation for faster performance.

yosiat added 30 commits April 11, 2026 16:11
…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}
yosiat added 28 commits April 11, 2026 16:11
…imeWriter buffer corruption

The global @@writer singleton shared a DateTimeWriter with a mutable @buf
across all threads. Under Puma, concurrent requests could corrupt datetime
output by writing to the same buffer simultaneously. Each thread now gets
its own Writer instance via Thread.current.
…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).
@yosiat

yosiat commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Closed in favor of better solution.

@yosiat yosiat closed this Jul 6, 2026
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