Skip to content

optimize cbor buffering #20

Description

@NagyZoltanPeter

Context

CBOR encoding is on the hot path for every FFI request and event emission. Current implementations in all wrapper languages do unnecessary allocations and/or copies.


C++ — no-alloc preliminary size counting

cborEncodedSize / cborEncodeInto in the generated .hpp wrappers currently construct a basic_cbor_encoder<CountingSink> without a custom allocator. jsoncons's encoder holds an internal nesting stack (std::vector<stack_item>) that heap-allocates on first use.

Fix: supply a std::pmr::polymorphic_allocator backed by a std::pmr::monotonic_buffer_resource over a stack-local arena. With null_memory_resource() as the upstream (fail-loud on overflow rather than silently falling back to heap), the counting pass becomes truly allocation-free for typical nesting depths.

alignas(std::max_align_t) std::byte arena[512]; // ~21 nesting levels
std::pmr::monotonic_buffer_resource mbr{arena, sizeof(arena),
                                        std::pmr::null_memory_resource()};
std::pmr::polymorphic_allocator<char> alloc{&mbr};
jsoncons::cbor::basic_cbor_encoder<CountingSink,
                                   std::pmr::polymorphic_allocator<char>>
    enc{CountingSink{n}, jsoncons::cbor_encode_options{}, alloc};

The arena size covers the encoder's nesting stack only — field count and string data are not relevant, only the maximum structural nesting depth of the encoded type.

Apply to both cborEncodedSize and cborEncodeInto in api_codegen_cbor_hpp.nim.


Nim — reduce allocations and copies in cborEncodeShared

Current cborEncodeShared chain:

memoryOutput()          # alloc 1: OutputStream ref + PageBuffers (paged heap)
writeValue(w, v)        # streaming writes into pages
s.getOutput(seq[byte])  # alloc 2: consolidates all pages into a new seq[byte]
allocShared0(buf.len)   # alloc 3: shared heap destination
copyMem(p, buf[0], n)   # copy  2  (pages→seq was copy 1)

3 allocations, 2 copies. The intermediate seq[byte] is a pure intermediary that exists only to reveal the total length before the allocShared0.

Option A — skip the consolidation seq: walk the PageBuffers directly after encoding, sum the page lengths for the total, allocShared0 once, then copy each page span into the shared buffer in a single pass. Eliminates alloc 2 and copy 1.

Option B — unsafeMemoryOutput with a conservative upper bound: compute a compile-time or type-level upper bound on the encoded size, allocShared0(bound) upfront, encode directly via unsafeMemoryOutput(p, bound), trim to actual length. Eliminates alloc 2 and both copies, at the cost of potentially over-allocating.

faststreams already exposes unsafeMemoryOutput(pointer, len) which encodes directly into a caller-supplied buffer — the missing piece for Option B is a safe size bound per type.

Relevant code: brokers/internal/api_cbor_codec.nimcborEncodeShared.


Other wrapper languages

Check whether the same double-copy / over-allocation pattern exists in:

  • Python (api_codegen_cbor_py.nim): cbor2.dumps() always returns a new bytes object; the copy into the FFI buffer is unavoidable, but verify no extra intermediate is created.
  • Rust (api_codegen_cbor_rust.nim): ciborium serializes into a Vec<u8>; check whether encode_into or a writer-to-slice API exists.
  • Go (api_codegen_cbor_go.nim): fxamacker/cbor encodes into a []byte; check whether NewEncoder(w) with a pre-allocated bytes.Buffer or a direct-write path avoids the intermediate copy.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions