cpp26-reflection · part 25

reflect_soa: AoS → SoA → AoSoA, one struct, three layouts

· upd 2026-10-07· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak

Correction, 6 October 2026. This post presents reflect_soa as a published library, and it is not one. The repository linked below holds a README and nothing else: it has no code, release or licence. What the post shows is a design sketch. The Compiler Explorer demo is a reduced hand-written stand-in: a six-field ParticleSoA written by hand, a reflection walk that prints the field list, and one loop. It contains no SoaVector, define_aggregate, AoSoA, benchmark harness or timing. The timings the post printed (14.2, 4.3, 4.1, 3.8 and 4.0 ms), the “3.3x” and “3.8x faster” ratios and the claim of 2-3x better bandwidth use came from no harness, machine or method. They were not measured, and I removed them. I also fixed four slips. Of the 32 bytes of Particle the CPU reads 24 (six floats), not 12. One AoSoA field array of 8 floats is 32 bytes, but the six arrays the loop touches are 192 bytes, so a group is not one cache line. Five layouts are listed in the harness, not four. And the seven posts of the arc ran from 20 June to 2 October, not 15 months. Four code claims do not work as written, and I marked or fixed them. First, define_aggregate adds data members only, so a synthesised SoaVector cannot have size(), a size constructor or col<>(). Second, the workload lambda indexes container.x[i], which does not compile for the AoS layout. Third, the “compile-time asserts” that every layout gives the same output would be run-time checks of results. Fourth, on GCC 16.2.0 build_soa_type is refused as written: define_aggregate must be evaluated from a consteval block ('define_aggregate' not evaluated from 'consteval' block), and soa_impl<T> is never declared. The alias using SoaVector = [: ... :] itself compiled in my test once the type was defined that way. The comparison table now keeps only the rows I could check against the Zig and soa_derive documentation; the Unity DOTS and ISPC rows and the benchmark and boilerplate columns are removed. A measured comparison of struct layouts (32-byte and 24-byte array of structs, a hot/cold split and a struct of arrays, on one noisy machine) is in the struct layout toolkit post. The text below is otherwise left as published, so you can read it against this note.

No published library. github.com/wrocpp/reflect_soa is a placeholder repository with a README only. This post describes a design sketch.

Prior art: Barry Revzin’s 2025 blog post “Implementing a Struct of Arrays” showed how reflection turns the SoA pattern from a macro-library chore into a small template. A library named reflect_soa could build on that and add two siblings: AoSoA (cache-line-aligned blocks) and an A/B benchmark harness that times the layouts for you.

The problem

You have a particle simulation:

struct Particle {
    float x, y, z;        // position
    float vx, vy, vz;     // velocity
    float mass;
    std::uint32_t color;
};

std::vector<Particle> particles(100'000);

// Hot loop: update positions
for (auto& p : particles) {
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.z += p.vz * dt;
}

On a modern x86 this loop is bandwidth-bound. Every particle is 32 bytes; your CPU only cares about 24 of them (position + velocity, six floats); the rest evicts cache lines for nothing. The compiler can’t auto-vectorise because adjacent position values are 32 bytes apart in memory.

Data-oriented design’s answer: SoA.

struct ParticleSoA {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<float> mass;
    std::vector<std::uint32_t> color;
};

Now the hot loop touches only x/y/z/vx/vy/vz and nothing else. SIMD auto-vectorises.

The catch: you now maintain two types. The Particle struct you think about + the ParticleSoA bag of parallel arrays. Every change to Particle propagates to both.

The reflection answer

#include <reflect_soa/soa.hpp>  // sketch: there is no such header

struct Particle {
    float x, y, z;
    float vx, vy, vz;
    float mass;
    std::uint32_t color;
};

// Sketch, does not work as written: define_aggregate adds data members only,
// so size(), this constructor and col<>() cannot be generated into the struct.
soa::SoaVector<Particle> particles(100'000);

// Hot loop
for (std::size_t i = 0; i < particles.size(); ++i) {
    particles.x[i]  += particles.vx[i] * dt;
    particles.y[i]  += particles.vy[i] * dt;
    particles.z[i]  += particles.vz[i] * dt;
}

// Or, for auto-vectorisation-friendly range access:
std::span x_span = particles.col<&Particle::x>();
std::span vx_span = particles.col<&Particle::vx>();
for (std::size_t i = 0; i < x_span.size(); ++i)
    x_span[i] += vx_span[i] * dt;

SoaVector<T> is a define_aggregate-synthesised struct whose fields are std::vector<float> x, std::vector<float> y, and so on, one per reflected field of Particle. The storage is the parallel-arrays layout; the access pattern is the positional one.

AoSoA: cache-line-aligned blocks

For workloads that do some whole-particle work (render, cull) and some per-component work (integrate), pure SoA hurts the whole-particle operations. AoSoA (Array of Struct of Arrays) strikes a balance: chunks of N particles laid out as SoA within the chunk, but chunks themselves are an AoS.

soa::AoSoAVector<Particle, 8> particles_aosoa(100'000);
// Particles stored in groups of 8.
// Each field array in a group is 8×4 = 32 bytes. The six arrays the loop
// touches are 192 bytes, and all eight fields are 256 bytes, so a group spans
// several cache lines (64 bytes on x86, 128 on Apple M-series).

particles_aosoa.each_group([dt](auto& group) {
    // group.x is an std::array<float, 8>
    for (std::size_t i = 0; i < 8; ++i) {
        group.x[i] += group.vx[i] * dt;
    }
});

Auto-vectorises better than SoA when you’re doing small windows at a time, and keeps whole-particle access localised.

The synthesis

SoaVector<T>’s job: walk T’s reflection, emit a struct-of-arrays layout. define_aggregate is the primitive:

// Sketch, refused by GCC 16.2.0 as written: define_aggregate must be evaluated
// from a consteval block, and soa_impl<T> is never declared.
template <typename T>
consteval auto build_soa_type() {
    std::vector<std::meta::info> specs;
    constexpr auto ctx = std::meta::access_context::unchecked();
    for (auto m : std::meta::nonstatic_data_members_of(^^T, ctx)) {
        auto field_type = std::meta::type_of(m);
        auto vector_of_field = std::meta::substitute(^^std::vector, {field_type});
        specs.push_back(std::meta::data_member_spec(
            vector_of_field,
            {.name = std::define_static_string(std::meta::identifier_of(m))}));
    }
    return std::meta::define_aggregate(^^soa_impl<T>, specs);
}

template <typename T>
using SoaVector = [: build_soa_type<T>() :];

For each T::field, emit std::vector<decltype(T::field)> field in the synthesised type. That’s the whole SoA transform.

The A/B benchmark harness

You’re rarely sure which layout wins for your workload without measuring. A harness could look like this:

// Sketch: nothing here is built, and nothing was measured.
#include <reflect_soa/bench.hpp>

auto report = soa::bench::compare<Particle>({
    .layouts = {soa::AoS, soa::SoA, soa::AoSoA<4>, soa::AoSoA<8>, soa::AoSoA<16>},
    .n = 1'000'000,
    // Does not compile for the AoS layout: a vector of Particle has no member x.
    // A real harness needs an accessor per layout.
    .workload = [](auto& container, float dt) {
        for (std::size_t i = 0; i < container.size(); ++i) {
            container.x[i] += container.vx[i] * dt;
            container.y[i] += container.vy[i] * dt;
            container.z[i] += container.vz[i] * dt;
        }
    },
});

std::println("{}", report);

Such a harness would generate all five layouts from the same Particle reflection, run the workload against each and report timings and ratios, so the layout choice rests on a measurement of your own workload. This post prints no such measurement.

Compared to…

Language / library AoS→SoA conversion
Zig std.MultiArrayList In the standard library
Rust soa_derive crate #[derive(StructOfArray)] derive macro
C++26 reflection (the sketch in this post) SoaVector<T>, not built

When not to use this

  • Hot loops are small / rare. If you only touch particles occasionally, AoS is simpler and the perf diff is noise.
  • You need per-particle invariants. AoS keeps particle.x and particle.y adjacent; debugging a scrambled AoSoA is harder when you don’t know the layout.
  • define_aggregate is too experimental for your project’s compiler pin. The capability was behind -freflection-latest in early clang-p2996; production-grade may need to wait for GCC / MSVC implementations to catch up. Keep an eye on the project’s “known-good compiler versions” table.

What a library could ship (not built)

None of this exists. It is the shape the sketch above points to:

  • soa::SoaVector<T>: synthesised struct-of-arrays.
  • soa::AoSoAVector<T, N>: array-of-struct-of-N-arrays.
  • soa::bench::compare<T>({.layouts = ..., .workload = ...}): harness.
  • soa::span_of<&T::field>(container): get an std::span<field_type> view.
  • Run-time checks that every layout produces identical numerical output for the same workload (no silent drift).

A first version would handle flat aggregates only. Nested SoA (SoaVector where fields are themselves SoA-transformable), GPU / SYCL tuned layouts (padding for SPIR-V) and packed-bitfield variants are open questions.

The arc closes

Seven library designs, in posts from 20 June to 2 October:

  1. reflect_llmschema: AI era
  2. reflect_arbitrary: property-based testing
  3. reflect_optics: lenses
  4. reflect_dx: debugger printers + docs
  5. reflect_telemetry: metrics
  6. reflect_tracing: spans
  7. reflect_soa: data layout

Arc 6 ends here. Arc 7 will be decided by what the community asks for most. Drop ideas in Discussions or on Slack.

The Monday companion: shipping seven reflection libraries

Companion Monday short: “Shipping seven C++26 reflection libraries: what we learned”, a retrospective on Arc 6.