Skip to content

Generate specialized encoder functions at compile time (3-6x faster encoding) - #449

Open
csWen wants to merge 1 commit into
elixir-protobuf:mainfrom
csWen:compile-time-specialized-encoders
Open

Generate specialized encoder functions at compile time (3-6x faster encoding)#449
csWen wants to merge 1 commit into
elixir-protobuf:mainfrom
csWen:compile-time-specialized-encoders

Conversation

@csWen

@csWen csWen commented Aug 31, 2026

Copy link
Copy Markdown

Implements the proposal from #448.

What changes

Protobuf.encode/1 currently interprets MessageProps on every call: per-field dispatch on metadata, presence checks, and IO.iodata_length/1 walks to write length prefixes. This PR moves that work to compile time: the DSL compiles each message into a specialized __encode_sized__/1 that returns {iodata, byte_size}, so dispatch, presence checks, tags, and wire types are resolved once at compile time and parents write length prefixes without re-walking the iodata. Map entries are encoded straight from key/value pairs, without building entry structs.

The interpreter stays for what isn't worth specializing: messages with a transform_module, values the generated clauses reject, and error reporting — a failing message is replayed through the interpreter so Protobuf.EncodeError names the failing field exactly as before.

Varint.encode/1 now builds each varint as a single binary in one bit-syntax instruction, and rejects integers that don't fit in 64 bits instead of silently truncating them.

Results

Google benchmark datasets (Apple M1 Max, Elixir 1.18.2 / OTP 26):

dataset main this PR speedup memory
google_message1_proto2 6.40 µs 1.13 µs 5.7x 2.80 KB → 1.50 KB
google_message1_proto3 6.76 µs 1.37 µs 4.9x 2.59 KB → 1.37 KB
google_message2 2.80 µs 0.46 µs 6.1x 1.67 KB → 616 B

The production ~8 MB message from #448 goes from 716 ms to 223 ms (3.2x) — large messages sit at the lower end of the range since raw byte copying takes a bigger share of the time.

Costs

Every message module now carries its generated encoder: compiling this repo's test suite goes from ~3.8 s to ~8.9 s, and ebin grows from ~2.8 MB to ~3.8 MB. Decoding is untouched.

Compatibility

  • Wire output is byte-identical to main; the conformance suite passes unchanged (2674 tests, 0 failures). All 581 unit tests, Dialyzer, and Credo pass.
  • No public API changes: __encode_sized__/1 is a generated internal, everything still goes through Protobuf.encode/1.

Protobuf.encode/1 interpreted MessageProps on every call: per-field
dispatch on metadata, presence checks, and IO.iodata_length/1 walks to
write length prefixes. For large messages this interpretation overhead
dominates encoding time.

The DSL now compiles each message's field metadata into a specialized
__encode_sized__/1, resolving dispatch, presence checks, field tags,
and wire types at compile time. Encoders return {iodata, byte_size} so
parents write length prefixes without re-walking the iodata, and map
entries are encoded straight from key/value pairs via
__encode_entry_sized__/2 without building entry structs.

The interpreter remains for messages with a transform module and for
values the generated clauses reject, and failed messages are replayed
through it so Protobuf.EncodeError still names the failing field.

Varint.encode/1 now builds each varint as a single binary in one
bit-syntax instruction and rejects integers that don't fit in 64 bits
instead of silently truncating them.

Encoding is 3-6x faster depending on message shape, with about half the
allocations. Wire output is byte-identical; the conformance suite
passes unchanged.
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