Skip to content

to_cbor()/to_msgpack() allocate a temporary basic_json for every object key #5318

Description

@nlohmann

Description

write_cbor() serializes object keys by calling itself on the key:

for (const auto& el : *j.m_data.m_value.object)
{
write_cbor(el.first);
write_cbor(el.second);
}

el.first is a string_t, but the parameter is const BasicJsonType&, so each key is implicitly converted to a temporary basic_json — which heap-allocates a string_t (plus its buffer for keys beyond SSO) — purely to reach the value_t::string case a few lines above. write_msgpack() has the identical pattern.

The UBJSON and BSON writers in the same file already do it the direct way, writing the key's characters straight to the output adapter without constructing a basic_json.

Measurement

Counting global operator new calls while serializing a 1000-entry object (-O2, g++ 13.3.0):

case allocations
to_cbor, array of 1000 ints (baseline) 13
to_cbor, object, 1000 short keys (SSO) 1016
to_cbor, object, 1000 64-char keys 2016
to_msgpack, object, 1000 short keys 1016
to_ubjson, object, 1000 short keys 17
to_bson, object, 1000 short keys 14

So CBOR and MessagePack pay one allocation per key even when the key fits in the small-string buffer (the string_t object itself is heap-allocated by basic_json), and two per key otherwise. UBJSON and BSON pay none.

Minimal code example

static long allocs = 0;
void* operator new(std::size_t n) { ++allocs; void* p = std::malloc(n); if (!p) throw std::bad_alloc(); return p; }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

#include <nlohmann/json.hpp>
using json = nlohmann::json;

int main()
{
    json j = json::object();
    for (int i = 0; i < 1000; ++i) { char k[8]; std::snprintf(k, sizeof k, "k%04d", i); j[k] = i; }

    allocs = 0; (void)json::to_cbor(j);   std::printf("cbor   %ld\n", allocs); // 1016
    allocs = 0; (void)json::to_ubjson(j); std::printf("ubjson %ld\n", allocs); // 17
}

Suggested fix

Factor the string-writing body of the value_t::string case out of write_cbor into a helper taking const string_t& (say write_cbor_string), call it from both the value_t::string case and the key loop, and do the same for write_msgpack. This is the same shape as the BSON writer cleanup in #5313, which changed write_bson_unsigned to take the underlying value rather than the basic_json.

Compiler and operating system

g++ 13.3.0, Ubuntu 24.04, -std=c++14, -O2

Library version

develop @ 8ec98e2

Would the fix be breaking?

No. This is a purely internal refactor of two private member functions of detail::binary_writer:

  • Byte-for-byte identical output. The extracted helper would run the same length-prefix and character-writing code the value_t::string case runs today, so to_cbor/to_msgpack produce exactly the same bytes. Existing round-trip tests should pass unchanged.
  • No API/ABI surface. binary_writer is in detail and the affected functions are not part of the documented API. No signature in basic_json changes.
  • One thing to watch: object keys are object_t::key_type, which is not necessarily string_t for a custom ObjectType. The helper should be written so key types that merely convert to string_t still compile — either by templating it or by keeping write_cbor(el.first) as a fallback for non-string_t key types. Getting this wrong would break exotic basic_json specializations at compile time, so it deserves a test with a custom object type.

I'm happy to open a PR for this if it's wanted.

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions