Skip to content

Support aligned and nested structs - #13

Merged
jallum merged 1 commit into
mainfrom
perf/alignment
Aug 31, 2026
Merged

Support aligned and nested structs#13
jallum merged 1 commit into
mainfrom
perf/alignment

Conversation

@jallum

@jallum jallum commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • precompute FlatBuffers struct member offsets, alignment, and padded size when loading schemas
  • encode and decode internal/trailing padding, nested structs, aligned enum members, and struct vectors
  • reject recursive structs and reference types inside structs
  • extend bidirectional flatc interoperability coverage with adversarial 8-byte layouts

TDD and verification

  • added failing padded/nested struct tests before implementation
  • mix test --warnings-as-errors (120 tests, 0 failures)
  • mix coveralls --warnings-as-errors (99.3%)
  • mix test test/integration/flatc_interop_test.exs --only flatc --warnings-as-errors (2 tests, 0 failures)
  • mix compile --warnings-as-errors
  • git diff --check
  • Benchee runtime smoke test: no material regression after removing a proposed generic field-reordering optimization

Adversarial review

A schema-precomputed alignment ordering was prototyped, but it forced the generic writer to reorder vtable locations at runtime and regressed representative encoding by about 12% without reducing that fixture. It was removed from this PR; generated writer code can revisit it without runtime sorting.

After main advanced, CI also exposed that treating unions as generally 4-byte-aligned bypassed the established error for unsupported empty union vectors. The alignment helper now keeps union vectors on the prior error path while table union payloads continue to resolve to supported table offsets.

@jallum
jallum merged commit e190c15 into main Aug 31, 2026
4 checks passed
jallum added a commit that referenced this pull request Aug 31, 2026
Stacked on #13.

## Summary

- generate schema-specific binary decode clauses during `use Flatbuffer`
expansion
- compile table field IDs/defaults, enum and union dispatch, struct
offsets, and vector element sizes into the caller
- retain the interpreted reader for arbitrary iodata
- track the top-level schema with `@external_resource`
- cover scalar widths, strings, defaults, schema evolution, safe names,
enums, unions, recursive tables, padded structs, vectors, identifiers,
malformed enum values, and fallback behavior

## TDD and verification

The first test traced `Flatbuffer.read/2` and failed until binary reads
stopped delegating to the interpreted implementation. The trace uses its
own OTP trace session so it cannot interfere with ExCoveralls.

- Elixir 1.18 / OTP 27: 131 tests, 0 failures, 2 excluded
- Elixir 1.19 / OTP 28: 129 tests, 0 failures, 2 excluded
- Elixir 1.20 / OTP 29 coverage: 129 tests, 0 failures, 99.4%
- flatc interoperability: 2 tests, 0 failures
- `mix compile --warnings-as-errors`
- `mix format --check-formatted`
- `git diff --check`

## Benchee

M4 Pro, Elixir 1.19.4, OTP 28.3, 100 integers and 20 child tables:

| reader | average | memory | reductions |
|---|---:|---:|---:|
| interpreted | 7.27 µs | 36.65 KB | 2.11 K |
| generated | 2.78 µs | 19.77 KB | 1.33 K |

That is 2.62x faster with 46% less allocation.

## Adversarial review

The fast path is deliberately restricted to binaries; flattening
arbitrary iodata would trade hidden copying for benchmark speed. Iodata
keeps the existing implementation. Generated clauses preserve
compact-vtable schema evolution, unknown enum/union behavior, safe
binary keys, identifier errors, and recursive table schemas.

OTP 29 exposed exponential type analysis when each generated field
directly refined the row map shape. Generated code now routes puts
through a small helper, keeping runtime map construction while
presenting a stable type boundary to the compiler. Bitstring sizes are
explicitly pinned for OTP 29 correctness warnings.
jallum added a commit that referenced this pull request Aug 31, 2026
Stacked on #14 (and #13).

## Summary

- generate schema-specialized reverse-builder dispatch during `use
Flatbuffer` expansion
- keep schema-independent mechanics shared in the internal
`Flatbuffer.Writer`: final framing, string pooling, vector
materialization, scalar validation, aligned pushes, and vtable
construction
- compile table fields and IDs, scalar defaults, enum/union dispatch,
struct padding, vector element sizes, and alignment into the caller
- precompute alignment-friendly table emission order while independently
assembling vtable locations in field-ID order
- preserve `to_iolist/1` and `to_binary/1`, compact/shared vtables,
shared strings, atom/binary input keys, safe schemas, recursive tables,
and existing error tuples
- extend flatc interoperability so the C++ verifier reads buffers from
both interpreted and generated writers

The shared-runtime refactor removed 291 lines (about 30%) from the
writer generator; generated code now contains only the
schema-specialized control flow and measurements.

## TDD and verification

The first test traced `Flatbuffer.to_iolist/2` and
`Flatbuffer.to_binary/2`; it failed on the original macro delegation and
passes only when both calls use generated code.

- Elixir 1.18 / OTP 27: 138 tests, 0 failures, 2 excluded
- Elixir 1.19 / OTP 28: 136 tests, 0 failures, 2 excluded
- Elixir 1.20 / OTP 29 coverage: 136 tests, 0 failures, 99.5%
- flatc interoperability: 2 tests, 0 failures; C++ verifies interpreted
and generated writer buffers
- `mix deps.unlock --check-unused`
- `mix compile --warnings-as-errors`
- `mix format --check-formatted`
- `git diff --check`

## Benchee

M4 Pro, Elixir 1.19.4, OTP 28.3, 100 integers and 20 child tables, after
shared-runtime extraction:

| writer | average | median | memory | reductions |
|---|---:|---:|---:|---:|
| interpreted | 11.45 µs | 11.21 µs | 55.45 KB | 4.62 K |
| generated | 10.30 µs | 10.08 µs | 45.80 KB | 3.56 K |

The generated writer remains about 10% faster, allocates 17.4% less
memory, and uses 22.9% fewer reductions. Both produce a 1,384-byte
representative buffer.

## flatc review and alignment ordering

flatc generates fixed `add_*` calls and groups scalar emission by wire
size/alignment. The Elixir generator follows the same useful property,
but computes true schema alignment for structs as well. Reference
objects are prepared first; inline fields are emitted by descending
alignment with a deterministic field-ID tiebreaker. Each emitted
location has its own generated local, so the vtable is assembled in ID
order without a runtime sort. This is why alignment-friendly ordering is
practical in either implementation but only wins here after the ordering
work itself is moved to compile time.

## Adversarial review

- alternate valid field order changes non-canonical bytes, so tests
compare decoded semantics and flatc verification rather than assuming
one byte layout
- sparse/absent fields still produce compact vtables because generated
location collection is ID-ordered and stops at the highest present field
- union vectors retain the existing explicit error behavior
- safe-schema code generation never interns schema field names; hygienic
locals avoid field-number-derived atoms
- shared strings/vtables, default omission, nested structs, recursive
tables, malformed scalar/enum/table/union values, binary keys, and
iolist output have dedicated parity coverage
- the module boundary was benchmarked after extraction; only
schema-dependent dispatch remains quoted
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