Skip to content

Improve shared parser inlining and generated code layout - #743

Open
Altren wants to merge 3 commits into
getml:mainfrom
Altren:perf/inline-parser-helpers-and-cold-errors
Open

Altren wants to merge 3 commits into
getml:mainfrom
Altren:perf/inline-parser-helpers-and-cold-errors

Conversation

@Altren

@Altren Altren commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Important edit: while testing #744 with Linux+GCC I found that this PR decrease LTO GCC performance by ~40% and increase no-LTO by ~25%, so I guess it might be better cancel this PR even with clear win-win on clang on MacOS

Reading vectors of structs still leaves small parser helpers out of line and error-handling code mixed into hot functions in optimized builds. Explicitly inline scalar parsing and vector-element parsing, and mark eight existing error branches as unlikely. This improves generated code layout and slightly reduces code size, with read improvements both without LTO and with ThinLTO.

The shared-parser changes are:

  • Add RFL_ALWAYS_INLINE to ParserBasicType::read.
  • Move VectorReader's local parsing lambda into an explicitly inlined private parse method. The insertion logic is unchanged.
  • Extend the existing use of [[unlikely]] to failed field reads, failed object/array reads, and collected-error checks in ViewReader and NamedTupleParser.

Benchmarks

Compare with upstream main at 10fb6bb. The checked-in parser_inlining read benchmarks cover 100,000 records with either two or sixteen float fields; this PR adds matching YAS cases. A local extended harness uses those same schemas to measure reads and writes, and adds native serializer references for all four formats.

A separate local Dataset-only harness measures all four formats using a nested Dataset: 500 groups, each containing 3,000 Point2 { double x; double y; } values and 3,000 uint64_t IDs. Dataset rows below come from this new run; record rows retain their original measurements. Both gains and losses are subject to the same 3% cutoff.

All reflect-cpp cases use ordinary rfl::<format>::write/read, without custom parsers or field annotations. Native references use Cereal portable binary archives, YAS binary archives, yyjson directly, and MessagePack C directly. The native JSON and MessagePack converters are handwritten for these schemas, retaining field names and checking required fields and numeric types; they are reference implementations, not replacements for reflect-cpp's general parsing and error-aggregation semantics.

Input generation and equality checks are outside the timers. Reads include byte decoding, C++ object construction, and destruction. Writes include encoding into an owned byte container and its destruction. Cereal uses the same memory-stream construction and copies on both sides. YAS uses memory buffers; JSON and MessagePack include their DOM/tree conversion. Read fixtures are serialized before timing. No filesystem I/O is timed.

Apple Clang 21, macOS arm64, C++20, Release -O3 -DNDEBUG. Median CPU times from six runs per configuration, with at least 0.1 s warmup, 40 measured iterations per record case, and 10 measured iterations per Dataset case. Baseline, patched, and native configurations are rotated and reversed between runs. ThinLTO is enabled on benchmark code and the reflect-cpp core, including yyjson and the final link; MessagePack C and Google Benchmark are the same separately built libraries in all configurations.

All times are milliseconds. Gain = old / new; above 1.00× is faster. Native serializer comparisons are provided separately below the PR description. Changes below 3% in measured CPU time are treated as noise. A row is shown only if the absolute change reaches 3% in at least one build configuration; an em dash in a Gain cell means that configuration is below the cutoff. The cutoff uses unrounded measurements.

Reads

Format Data Bytes Old, no LTO New, no LTO Gain Old, ThinLTO New, ThinLTO Gain
Cereal Record2 800,009 1.627 1.222 1.33× 0.982 0.793 1.24×
Cereal Record16 6,400,009 11.531 9.584 1.20× 8.738 7.319 1.19×
JSON Record2 3,683,353 2.678 2.479 1.08× 2.335 2.343 —
MessagePack Record2 2,500,014 2.880 2.551 1.13× 2.356 2.225 1.06×
MessagePack Record16 20,100,014 27.310 26.350 1.04× 27.050 26.575 —
YAS Record2 800,015 1.265 1.048 1.21× 0.423 0.397 1.07×
YAS Record16 6,400,015 7.082 6.842 1.04× 4.241 3.228 1.31×
Cereal Dataset 36,008,009 24.224 17.401 1.39× 13.383 12.230 1.09×
JSON Dataset 70,124,332 50.572 47.977 1.05× 42.971 43.573 —
MessagePack Dataset 48,009,011 61.761 57.234 1.08× 47.198 46.890 —
YAS Dataset 36,008,015 20.063 17.124 1.17× 7.426 2.320 3.20×

The original record fixtures and all four new Dataset fixtures are byte-identical across all six baseline/patched/native and LTO/no-LTO configurations. Every reader successfully decodes the baseline output and reproduces every field. These are local measurements; gains depend on compiler, link configuration, schema, and workload. The patch changes read-side code only; writes are measured as a control.

Generated code layout and size

The compiler has more freedom to optimize the successful path after helper inlining and error-branch hints. The original four-format, read-only record benchmark executable (without the extended native/write/nested cases above) has a smaller machine-code section in both configurations:

Build Before __text After __text Change
Without LTO 897,896 bytes 855,532 bytes -4.7%
ThinLTO 507,600 bytes 474,984 bytes -6.4%

These are executable __text section sizes measured with llvm-objdump, not total file sizes or debug information. The executable includes the shared benchmark infrastructure and verification driver in both variants. Code-size results may differ for other programs.

Validation

Run the checked-in reflect-cpp record read cases with (the local native, write, and nested comparisons above use an extended harness):

./build/benchmarks/all/reflect-cpp-all-format-benchmarks \
  --benchmark_filter=parser_inlining \
  --benchmark_min_warmup_time=0.1 \
  --benchmark_min_time=40x \
  --benchmark_repetitions=6

For a local LTO comparison, build the same sources separately with CMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF and ON in Release mode.

Informational comparison: reflect-cpp vs native serializers

This is context for the absolute performance of reflect-cpp, not evidence for this PR’s improvement. New is the build with this PR; Native uses ordinary Cereal/YAS archives or the C library plus a handwritten schema converter for JSON/MessagePack. Ratio = New / Native: above 1× means reflect-cpp takes more time.

Median CPU times from six rotated/reversed runs per configuration, with 0.1 s warmup and at least 0.25 s measured CPU time per case. Compiler, optimization flags, libraries, timed ownership/copying, and input generation follow the methodology above. All 12 fixtures are byte-identical across old-native, revised-native, and patched-reflect-cpp builds, both with and without ThinLTO; every reader reproduces the baseline data. Separate semantic checks cover reordered/duplicate/unknown fields and invalid inputs.

Reads

Format Data New, no LTO Native, no LTO New / Native New, ThinLTO Native, ThinLTO New / Native
Cereal Record2 1.267 0.749 1.69× 0.797 0.741 1.08×
Cereal Record16 9.604 6.207 1.55× 7.347 5.883 1.25×
JSON Record2 2.466 2.248 1.10× 2.324 2.102 1.11×
MessagePack Record2 2.577 2.085 1.24× 2.335 2.108 1.11×
MessagePack Record16 26.375 26.594 0.99× 26.758 26.771 1.00×
YAS Record2 1.038 0.165 6.31× 0.402 0.225 1.79×
YAS Record16 6.975 1.311 5.32× 3.190 2.836 1.13×
Cereal Dataset 18.797 11.698 1.61× 12.345 12.388 1.00×
JSON Dataset 47.470 42.802 1.11× 43.214 41.555 1.04×
MessagePack Dataset 58.247 44.598 1.31× 47.862 46.189 1.04×
YAS Dataset 17.526 4.076 4.30× 7.067 5.134 1.38×

Writes

Format Data New, no LTO Native, no LTO New / Native New, ThinLTO Native, ThinLTO New / Native
Cereal Record2 1.001 0.796 1.26× 0.832 0.854 0.97×
Cereal Record16 7.074 6.836 1.03× 7.369 6.834 1.08×
MessagePack Record16 18.332 22.014 0.83× 22.036 15.458 1.43×
Cereal Dataset 15.658 12.664 1.24× 13.535 12.928 1.05×

@Altren

Altren commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Also I was thinking if a raw serialization vs reflect-cpp serialisation based on the same library benchmark would be useful in the repository. I understand that reflect-cpp does a bit more in terms of error handling vs raw serializer. But in theory perfect world implementation for the rfl::ceral::write should be as fast as generic handwritten cereal serializer.
My current implementation is far from perfect. Some serializers are easy to do (f.e. cereal), while others are not so simple (f.e. my msgpack implementation is 1.2x slower than rfl::msgpack::write)

@Altren

Altren commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

I'll also try to investigate why YAS is still so much slower, even after all my improvements that bring other formats close to baseline.

@Altren

Altren commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

While testing #744 with Linux+GCC I found that this PR decrease LTO GCC performance by ~40% and increase no-LTO by ~25%, so I guess it might be better cancel this PR even with clear win-win on clang on MacOS

This branch has not been deployed

No deployments
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