Skip to content

arena: pack objects, refine string size classes and align external allocations - #98

Merged
edsiper merged 4 commits into
masterfrom
arena-compact-nodes
Oct 22, 2026
Merged

edsiper merged 4 commits into
masterfrom
arena-compact-nodes

Conversation

@edsiper

@edsiper edsiper commented Oct 17, 2026

Copy link
Copy Markdown
Member

This PR makes arena-backed CFL object graphs smaller and faster to build, and
fixes a misaligned allocation in the arena. The public API, struct layouts and
heap-backed behaviour are unchanged.

Motivation

Decoded telemetry records are mostly structure: variants, kvpairs, key/value
lists and short owned map keys. In an arena, a typical log record with six
short keys and a nested map took about 1,320 bytes on x86_64, roughly 14 times
its MessagePack size. Most of that was padding:

  • every arena allocation was aligned for long double (16 bytes on x86_64
    and arm64), so 40-byte variants and kvpairs took 48 bytes;
  • owned SDS strings used size classes starting at 32 bytes of payload, so a
    three-byte key such as msg took 80 bytes.

Changes

Object packing

Variants, kvpairs, key/value lists, arrays (struct and entry storage) and SDS
headers only hold pointers, sizes, 64-bit integers and doubles. They are now
allocated with the strictest of those alignments instead of the raw allocation
alignment. Compile-time checks verify that every CFL object type fits that
alignment on the target platform.

Finer SDS size classes

  • Payloads below 128 bytes use classes that grow in alignment-sized steps of
    the complete allocation, so a short string uses at most one alignment unit
    more than it needs. The number of small classes adapts to the platform
    alignment, keeping the same coverage on 32-bit and 64-bit targets.
  • Payloads from 128 bytes to 1 KiB use classes that grow by about 25 percent.
  • Released strings are still recycled through per-class free lists.
  • Pooled SDS blocks are no longer zeroed before use, because the SDS layer
    initializes the headers and the payload it writes. Variants and kvpairs are
    still zeroed.

Allocation fast path

Bump allocations from the current chunk take an inlined fast path. Walking the
remaining chunks and reserving a new one moved to a separate slow path with
the same behaviour as before.

Fix: external allocation alignment

External allocations, used for large owned SDS strings, returned their
payload right after a one-byte class field. Large arena strings therefore
stored their headers at a misaligned address: undefined behaviour, reported by
UBSan, and a potential fault on strict-alignment targets. The external header
is now padded to the arena alignment, like normal chunks.

New benchmark

cfl-benchmark-arena-records builds batches of decoded log records (short
keys, a nested map, an array, strings, integers and doubles). It reports
nanoseconds per record and used and reserved bytes per record, with options
for referenced or owned values, record edits (remove, insert, rename) and the
chunk size. See benchmarks/README.md.

API

No public header changes, and no symbols removed.

Internal allocation helpers, declared in src/cfl_arena_internal.h and not
part of the public API:

Function Purpose
void *cfl_arena_alloc_object(struct cfl_arena *arena, size_t size) Uninitialized CFL object storage with object alignment
void *cfl_arena_calloc_object(struct cfl_arena *arena, size_t count, size_t size) Zeroed, overflow-checked version of the above

CFL_ARENA_SDS_MAX_PAYLOAD (1024) names the largest payload recycled through
the SDS size classes.

The public raw allocators (cfl_arena_malloc(), cfl_arena_calloc(),
cfl_arena_memdup(), cfl_arena_strndup()) keep the documented alignment,
including long double. Tests check this after interleaved object
allocations.

Compatibility

No public struct layout, signature or return value changes, and heap-backed
values behave as before. Observable differences, none of which are documented
guarantees:

  • cfl_sds_alloc() on an arena-backed string reports the new, tighter class
    capacity. It is always at least the string length.
  • Arena statistics (cfl_arena_bytes_used(), cfl_arena_bytes_reserved())
    report smaller values for the same graph. External allocations carry a
    slightly larger header because of the alignment fix.
  • Bytes past the terminator of a recycled arena string are no longer zeroed,
    as with heap strings.

Performance

Release build, x86_64, best of 5 runs.

cfl-benchmark-arena-records arena 512 3000:

Workload CPU Reserved bytes per record
Referenced values 321.0 → 245.8 ns/record (−23%) 2,118 → 1,541 (−27%)
Owned values 396.3 → 294.1 ns/record (−26%) 2,760 → 1,894 (−31%)
Owned values with edits 484.9 → 361.1 ns/record (−26%) 2,664 → 1,829 (−31%)

Existing benchmarks:

Benchmark Time Arena reserved
cfl-benchmark-variant-arena arena 1000 1000 8192 98.2 → 41.1 ms (2.4× faster) 181,280 → 123,600 bytes (−32%)
cfl-benchmark-variant-mutable arena 100 100 10 8192 −9% −27%
cfl-benchmark-variant-mutable, random and bimodal payloads −10% −17 to −22%

benchmarks/run_variant_matrix.sh (270 arena configurations): reserved bytes
median −13%, time geometric mean −10%. The only configurations with more
reserved bytes are large strings with external storage disabled and 4 KiB
chunks, at up to +3.8%. Those configurations already leave about 40 percent
of reserved memory unused because of how oversized chunks are reused after
reset.

Downstream, Fluent Bit's per-chunk arena for decoded log records uses 25%
fewer bytes per flat record (1,320 → 990) and 18% fewer per Kubernetes record
(4,428 → 3,628).

Testing

New and updated cases in tests/arena.c:

  • every SDS payload size from 0 to 1,056 bytes: capacity, content,
    terminator, monotonic class capacities, bounded waste, slot reuse and
    bytes_used accounting;
  • every string filled to its reported capacity, with neighbouring
    allocations verified intact;
  • byte-by-byte growth from small classes through medium classes, chunk
    storage and external storage, plus self-append and cfl_sds_printf();
  • natural alignment of variants, kvpairs, kvlists, arrays, array entries and
    SDS headers in arena storage, including external strings;
  • raw allocations keeping the public alignment between packed objects;
  • an upper bound on reserved bytes for records with short keys;
  • reset clearing every free list without handing out aliased storage;
  • heap strings and kvlists keeping their exact capacities;
  • randomized insert, remove, rename and replace on an arena kvlist, checked
    against a shadow model across resets;
  • the replaced reuse_sds_size_classes test now checks class invariants
    instead of fixed capacities.

The new size-class and alignment tests fail on the previous implementation.

Commands:

cmake -S . -B build -DCFL_TESTS=On
cmake --build build -j8
ctest --test-dir build --output-on-failure    # 34/34 passed
scripts/agent-verify.sh                       # passed
  • Valgrind: all 34 tests clean, with --leak-check=full.
  • Clang ASan and MSan: all tests pass.
  • Clang UBSan: no reports in CFL code. The only reports come from acutest's
    test function pointer casts, which also appear on master.
  • GCC and Clang, GNU99 and GNU17, -Wall -Wextra -Wpedantic: no new warnings
    compared to master.
  • Downstream, replacing the bundled lib/cfl with this branch as in
    build-downstream, at each project's origin/master:
    • cmetrics: 23/23 passed;
    • ctraces: 7/7 passed;
    • cprofiles: 6/6 passed;
    • all clean under Valgrind.
  • Fluent Bit with this patch bundled: internal tests pass, and the Python
    integration scenarios that use CFL graphs pass normally and under Valgrind.

Not run locally: 32-bit targets (no 32-bit libc available). The size-class
math was checked for 4, 8 and 16-byte object alignment, and CI covers ARM64
and Windows.

External allocations returned their payload right after a one byte class
field, so large arena strings stored their headers at a misaligned
address. Pad the header to the arena alignment like normal chunks.

Signed-off-by: Eduardo Silva <eduardo@chronosphere.io>
Variants, kvpairs, key/value lists, arrays and SDS headers only hold
pointers, sizes, integers and doubles. Allocate them with that alignment
instead of the raw allocation alignment, which includes long double and
pads every 40 byte object to 48 bytes on x86_64 and arm64.

Owned SDS strings used classes starting at 32 bytes of payload, so a
three byte map key took 80 bytes. Small classes now grow in alignment
steps of the whole allocation up to 128 bytes of payload, and larger
classes grow by about 25 percent. Pooled blocks are no longer zeroed,
since the SDS layer initializes what it uses.

Bump allocations from the current chunk take an inlined fast path.
Decoded log record batches use 27 to 31 percent less arena memory and
20 to 25 percent less CPU. Raw allocations keep the public alignment.

Signed-off-by: Eduardo Silva <eduardo@chronosphere.io>
Signed-off-by: Eduardo Silva <eduardo@chronosphere.io>
Signed-off-by: Eduardo Silva <eduardo@chronosphere.io>
@edsiper
edsiper merged commit 2c5e0d7 into master Oct 22, 2026
28 of 29 checks passed
@edsiper
edsiper deleted the arena-compact-nodes branch October 22, 2026 01:28
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