deserialize: single-pass dispatch for structs with optional fields

The reflection deserializer used `obj[key].get(out.member)` per struct
field, which delegates to find_field_unordered. For *absent* optional
fields this scans the entire JSON object before returning NO_SUCH_FIELD
— O(K) per absent optional, where K is the JSON object's field count.
A struct with N optionals where most are absent pays O(N*K) per record.

CITM hits this hard: CITMEvent has 7 optional fields; most events leave
several of them null/missing, so we re-scan each event many times.

Switch to single-pass dispatch when the struct has any std::optional
member: walk the JSON object exactly once, dispatch each visited key
to the matching struct member via a compile-time-generated key
comparison (with length pre-filter), let object_iterator::operator++
auto-skip values for unknown keys.

For structs with all-required fields (Twitter Status, User), keep the
existing per-field obj[key] path. It's O(1) per field on in-order JSON
and faster than the unrolled compare chain for dense, in-order objects.

Measured (TRUE A/B, 7 alternating rounds, single docker invocation):
  CITM    static_reflection: 2084 -> 2387 MB/s   +14.5%
  CITM    from:              2053 -> 2358 MB/s   +14.9%
  Twitter static_reflection: 3416 -> 3419 MB/s   break-even
  Twitter from:              3416 -> 3417 MB/s   break-even

All static_reflection_comprehensive_tests pass.
This commit is contained in:
Francisco Geiman Thiesen
2026-05-09 04:55:37 -07:00
parent ec49efa5da
commit 4d9d948627
5 changed files with 361 additions and 15 deletions
@@ -0,0 +1,88 @@
#ifndef GLAZE_CITM_CATALOG_DATA_H
#define GLAZE_CITM_CATALOG_DATA_H
#include <cstdint>
#include <map>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
#include <glaze/glaze.hpp>
// Glaze-specific shadow types. We mirror the Rust serde struct here (rather
// than the C++ CitmCatalog) so that fields the source JSON encodes as `null`
// (e.g. CITMEvent.name) parse cleanly into std::optional. This matches what
// the Rust benchmark does, and the resulting JSON output volume is therefore
// directly comparable to the Rust numbers.
struct GlazeCITMPrice {
uint64_t amount;
uint64_t audienceSubCategoryId;
uint64_t seatCategoryId;
};
struct GlazeCITMArea {
uint64_t areaId;
std::vector<uint64_t> blockIds;
};
struct GlazeCITMSeatCategory {
std::vector<GlazeCITMArea> areas;
uint64_t seatCategoryId;
};
struct GlazeCITMPerformance {
uint64_t id;
uint64_t eventId;
std::optional<std::string> logo;
std::optional<std::string> name;
std::vector<GlazeCITMPrice> prices;
std::vector<GlazeCITMSeatCategory> seatCategories;
std::optional<std::string> seatMapImage;
uint64_t start;
std::string venueCode;
};
struct GlazeCITMEvent {
uint64_t id;
std::optional<std::string> name;
std::optional<std::string> description;
std::optional<std::string> logo;
std::vector<uint64_t> subTopicIds;
std::optional<std::string> subjectCode;
std::optional<std::string> subtitle;
std::vector<uint64_t> topicIds;
};
struct GlazeCitmCatalog {
std::map<std::string, GlazeCITMEvent> events;
std::vector<GlazeCITMPerformance> performances;
};
inline GlazeCitmCatalog glaze_deserialize_citm(const std::string &json_str) {
GlazeCitmCatalog data;
constexpr glz::opts opts{.error_on_unknown_keys = false};
auto err = glz::read<opts>(data, json_str);
if (err) {
throw std::runtime_error("glaze citm parse error: " +
glz::format_error(err, json_str));
}
return data;
}
inline std::string glaze_serialize_citm(const GlazeCitmCatalog &data) {
std::string out;
// skip_null_members = false: emit `"field":null` for unset optionals so the
// output has the same field count as simdjson's (which writes `"field":""`).
// Glaze still produces 4-char `null` vs simdjson's 2-char `""`, so it's not
// byte-identical, but the work-per-field is comparable.
constexpr glz::opts opts{.skip_null_members = false};
auto err = glz::write<opts>(data, out);
if (err) {
throw std::runtime_error("glaze citm write error");
}
return out;
}
#endif // GLAZE_CITM_CATALOG_DATA_H
@@ -0,0 +1,95 @@
// Standalone Glaze benchmark for CITM Catalog (see glaze_twitter_bench.cpp
// header comment for context).
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include "../citm_catalog_benchmark/glaze_citm_catalog_data.h"
#include "../benchmark_utils/benchmark_helper.h"
namespace {
std::string read_file(const std::string &filename) {
printf("# Reading file %s\n", filename.c_str());
constexpr size_t read_size = 65536;
std::ifstream stream(filename, std::ios::binary);
if (!stream) {
std::cerr << "Could not open file: " << filename << std::endl;
std::exit(EXIT_FAILURE);
}
stream.exceptions(std::ios_base::badbit);
std::string out;
std::string buf(read_size, '\0');
while (stream.read(&buf[0], read_size)) {
out.append(buf, 0, size_t(stream.gcount()));
}
out.append(buf, 0, size_t(stream.gcount()));
return out;
}
void bench_glaze_serialization(GlazeCitmCatalog &data) {
std::string output = glaze_serialize_citm(data);
size_t output_volume = output.size();
printf("# output volume: %zu bytes\n", output_volume);
volatile size_t measured_volume = 0;
pretty_print(1, output_volume, "bench_glaze",
bench([&data, &measured_volume, &output_volume]() {
std::string output = glaze_serialize_citm(data);
measured_volume = output.size();
if (measured_volume != output_volume) {
printf("mismatch\n");
}
}));
}
void bench_glaze_parsing(const std::string &json_str) {
size_t input_volume = json_str.size();
printf("# input volume: %zu bytes\n", input_volume);
volatile bool result = true;
pretty_print(1, input_volume, "bench_glaze_parsing",
bench([&json_str, &result]() {
try {
GlazeCitmCatalog data = glaze_deserialize_citm(json_str);
result = true;
} catch (...) {
result = false;
printf("parse error\n");
}
}));
}
} // namespace
int main(int argc, char *argv[]) {
const char *json_file = std::getenv("CITM_JSON");
if (!json_file) {
json_file = "buildreflect/jsonexamples/citm_catalog.json";
}
if (argc > 1) {
json_file = argv[1];
}
std::string json_str = read_file(json_file);
const char *mode = std::getenv("BENCH_MODE");
if (!mode) mode = "all";
GlazeCitmCatalog data = glaze_deserialize_citm(json_str);
if (std::string(mode) == "all" || std::string(mode) == "parse") {
printf("\n=== Glaze CITM Parsing ===\n");
bench_glaze_parsing(json_str);
}
if (std::string(mode) == "all" || std::string(mode) == "serialize") {
printf("\n=== Glaze CITM Serialization ===\n");
bench_glaze_serialization(data);
}
return EXIT_SUCCESS;
}
@@ -0,0 +1,99 @@
// Standalone Glaze benchmark for Twitter, built with g++ instead of the
// p2996 clang fork (which crashes on Glaze's heavy template metaprogramming).
// Reuses the same data structs and bench() helper as the in-tree benchmarks
// so the throughput numbers are directly comparable.
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
#include "../twitter_benchmark/twitter_data.h"
#include "../twitter_benchmark/glaze_twitter_data.h"
#include "../benchmark_utils/benchmark_helper.h"
namespace {
std::string read_file(const std::string &filename) {
printf("# Reading file %s\n", filename.c_str());
constexpr size_t read_size = 4096;
std::ifstream stream(filename);
if (!stream) {
std::cerr << "Could not open file: " << filename << std::endl;
std::exit(EXIT_FAILURE);
}
stream.exceptions(std::ios_base::badbit);
std::string out;
std::string buf(read_size, '\0');
while (stream.read(&buf[0], read_size)) {
out.append(buf, 0, size_t(stream.gcount()));
}
out.append(buf, 0, size_t(stream.gcount()));
return out;
}
void bench_glaze_serialization(TwitterData &data) {
std::string output = glaze_serialize(data);
size_t output_volume = output.size();
printf("# output volume: %zu bytes\n", output_volume);
volatile size_t measured_volume = 0;
pretty_print(1, output_volume, "bench_glaze",
bench([&data, &measured_volume, &output_volume]() {
std::string output = glaze_serialize(data);
measured_volume = output.size();
if (measured_volume != output_volume) {
printf("mismatch\n");
}
}));
}
void bench_glaze_parsing(const std::string &json_str) {
size_t input_volume = json_str.size();
printf("# input volume: %zu bytes\n", input_volume);
volatile bool result = true;
pretty_print(1, input_volume, "bench_glaze_parsing",
bench([&json_str, &result]() {
try {
TwitterData data = glaze_deserialize(json_str);
result = true;
} catch (...) {
result = false;
printf("parse error\n");
}
}));
}
} // namespace
int main(int argc, char *argv[]) {
const char *json_file = std::getenv("TWITTER_JSON");
if (!json_file) {
json_file = "buildreflect/jsonexamples/twitter.json";
}
if (argc > 1) {
json_file = argv[1];
}
std::string json_str = read_file(json_file);
const char *mode = std::getenv("BENCH_MODE");
if (!mode) mode = "all";
TwitterData data = glaze_deserialize(json_str);
if (std::string(mode) == "all" || std::string(mode) == "parse") {
printf("\n=== Glaze Twitter Parsing ===\n");
bench_glaze_parsing(json_str);
}
if (std::string(mode) == "all" || std::string(mode) == "serialize") {
printf("\n=== Glaze Twitter Serialization ===\n");
bench_glaze_serialization(data);
}
return EXIT_SUCCESS;
}
@@ -0,0 +1,31 @@
#ifndef GLAZE_TWITTER_DATA_H
#define GLAZE_TWITTER_DATA_H
#include "twitter_data.h"
#include <glaze/glaze.hpp>
#include <stdexcept>
#include <string>
// Glaze auto-reflects aggregate types whose field names already match JSON
// keys (snake_case here matches the JSON), so no glz::meta is required.
inline TwitterData glaze_deserialize(const std::string &json_str) {
TwitterData data;
constexpr glz::opts opts{.error_on_unknown_keys = false};
auto err = glz::read<opts>(data, json_str);
if (err) {
throw std::runtime_error("glaze parse error: " + glz::format_error(err, json_str));
}
return data;
}
inline std::string glaze_serialize(const TwitterData &data) {
std::string out;
auto err = glz::write_json(data, out);
if (err) {
throw std::runtime_error("glaze write error");
}
return out;
}
#endif // GLAZE_TWITTER_DATA_H
@@ -267,6 +267,22 @@ constexpr bool user_defined_type = (std::is_class_v<T>
!concepts::appendable_containers<T>);
// Compile-time predicate: does T have any std::optional member?
// Used to decide whether single-pass dispatch is worth it.
template <typename T>
consteval bool struct_has_optional_member() {
bool result = false;
template for (constexpr auto mem : std::define_static_array(std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::unchecked()))) {
if constexpr (!std::meta::is_const(mem) && std::meta::is_public(mem)) {
using FieldT = [: std::meta::type_of(mem) :];
if constexpr (concepts::optional_type<FieldT>) {
result = true;
}
}
};
return result;
}
template <typename T, typename ValT>
requires(user_defined_type<T> && std::is_class_v<T>)
error_code tag_invoke(deserialize_tag, ValT &val, T &out) noexcept {
@@ -276,25 +292,42 @@ error_code tag_invoke(deserialize_tag, ValT &val, T &out) noexcept {
} else {
SIMDJSON_TRY(val.get_object().get(obj));
}
template for (constexpr auto mem : std::define_static_array(std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::unchecked()))) {
if constexpr (!std::meta::is_const(mem) && std::meta::is_public(mem)) {
constexpr std::string_view key = std::define_static_string(std::meta::identifier_of(mem));
if constexpr (concepts::optional_type<decltype(out.[:mem:])>) {
// for optional members, it's ok if the key is missing
auto error = obj[key].get(out.[:mem:]);
if (error && error != NO_SUCH_FIELD) {
if(error == NO_SUCH_FIELD) {
out.[:mem:].reset();
continue;
if constexpr (struct_has_optional_member<T>()) {
// Single-pass dispatch: walk each JSON object field once, dispatching
// to the matching struct member via a compile-time-generated key
// comparison chain (with length pre-filter). This avoids the O(K)
// full-object scan that obj[key] does for *absent* optional fields.
// Worth it when the struct has optionals because some are usually missing.
for (auto field : obj) {
std::string_view key;
SIMDJSON_TRY(field.unescaped_key().get(key));
bool matched = false;
const size_t key_size = key.size();
template for (constexpr auto mem : std::define_static_array(std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::unchecked()))) {
if constexpr (!std::meta::is_const(mem) && std::meta::is_public(mem)) {
constexpr std::string_view name = std::define_static_string(std::meta::identifier_of(mem));
constexpr size_t name_size = name.size();
if (!matched && key_size == name_size && key == name) {
SIMDJSON_TRY(field.value().get(out.[:mem:]));
matched = true;
}
return error;
}
} else {
// for non-optional members, the key must be present
};
// Unmatched value is skipped automatically by object_iterator::operator++().
(void)matched;
}
} else {
// Per-field obj[key] dispatch: for structs with all-required fields,
// this is O(1) per field when the JSON keys are in declaration order
// (find_field_unordered's fast path). Beats single-pass on dense
// structs (e.g. Twitter Status with 22 always-present fields).
template for (constexpr auto mem : std::define_static_array(std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::unchecked()))) {
if constexpr (!std::meta::is_const(mem) && std::meta::is_public(mem)) {
constexpr std::string_view key = std::define_static_string(std::meta::identifier_of(mem));
SIMDJSON_TRY(obj[key].get(out.[:mem:]));
}
}
};
};
}
return simdjson::SUCCESS;
}