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.
Description
write_cbor()serializes object keys by calling itself on the key:json/include/nlohmann/detail/output/binary_writer.hpp
Lines 403 to 407 in 8ec98e2
el.firstis astring_t, but the parameter isconst BasicJsonType&, so each key is implicitly converted to a temporarybasic_json— which heap-allocates astring_t(plus its buffer for keys beyond SSO) — purely to reach thevalue_t::stringcase 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 newcalls while serializing a 1000-entry object (-O2, g++ 13.3.0):to_cbor, array of 1000 ints (baseline)to_cbor, object, 1000 short keys (SSO)to_cbor, object, 1000 64-char keysto_msgpack, object, 1000 short keysto_ubjson, object, 1000 short keysto_bson, object, 1000 short keysSo CBOR and MessagePack pay one allocation per key even when the key fits in the small-string buffer (the
string_tobject itself is heap-allocated bybasic_json), and two per key otherwise. UBJSON and BSON pay none.Minimal code example
Suggested fix
Factor the string-writing body of the
value_t::stringcase out ofwrite_cborinto a helper takingconst string_t&(saywrite_cbor_string), call it from both thevalue_t::stringcase and the key loop, and do the same forwrite_msgpack. This is the same shape as the BSON writer cleanup in #5313, which changedwrite_bson_unsignedto take the underlying value rather than thebasic_json.Compiler and operating system
g++ 13.3.0, Ubuntu 24.04,
-std=c++14,-O2Library version
develop@ 8ec98e2Would the fix be breaking?
No. This is a purely internal refactor of two
privatemember functions ofdetail::binary_writer:value_t::stringcase runs today, soto_cbor/to_msgpackproduce exactly the same bytes. Existing round-trip tests should pass unchanged.binary_writeris indetailand the affected functions are not part of the documented API. No signature inbasic_jsonchanges.object_t::key_type, which is not necessarilystring_tfor a customObjectType. The helper should be written so key types that merely convert tostring_tstill compile — either by templating it or by keepingwrite_cbor(el.first)as a fallback for non-string_tkey types. Getting this wrong would break exoticbasic_jsonspecializations 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.