Write CBOR integers without branching on their sign or size

The sizes of a run of integers are hard to predict, and so is the type of
each element in an array that mixes negative (number_integer) and
non-negative (number_unsigned) values. Writing an integer branched on the
type, the sign, and the 1/2/4/8-byte size class, and the mispredictions
were the larger part of the time left on integer arrays.

- number_integer and number_unsigned share one case of write_cbor; the
  major type and the argument are selected with a sign mask.
- An integer's head is assembled without branches: the size class comes
  from a table indexed by count_leading_zeros(), and the first byte and
  the argument in network order are stored as one byte and one 8-byte
  word, of which only the head's length is kept. Heads of strings and
  containers keep the branching write_cbor_head(), as their lengths are
  mostly predictable.
- Arrays are written by write_cbor_array(), kept out of line so that the
  registers its loop needs are not saved on every call of write_cbor.
  Runs of integers are encoded straight into space reserved from the sink
  (the new reserve()/commit() of both sinks), so the write pointer stays
  in a register instead of being reloaded for every value.

The output is unchanged; the new test compares integer arrays at every
head-size boundary, in runs shorter and longer than one reservation and
broken up by other values, with an independent encoder through each sink.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
This commit is contained in:
Niels Lohmann committed 2026-10-14 13:48:16 +02:00
1 parent e26e1ad312
commit bbb33374d4
4 files changed
+499 -50

No files matched your search

+156
View File
@@ -13,6 +13,7 @@ using nlohmann::json;
#include <cstdint>
#include <limits>
#include <sstream>
#include <string>
#include <vector>
@@ -182,6 +183,161 @@ TEST_CASE("binary writer output sinks")
}
}
namespace
{
// the CBOR head of an integer, encoded independently of binary_writer
void reference_cbor_head(std::vector<std::uint8_t>& out, const std::uint8_t major_type, const std::uint64_t argument)
{
if (argument <= 23)
{
out.push_back(static_cast<std::uint8_t>(major_type + argument));
return;
}
int length = 8;
std::uint8_t additional = 27;
if (argument <= 0xFF)
{
length = 1;
additional = 24;
}
else if (argument <= 0xFFFF)
{
length = 2;
additional = 25;
}
else if (argument <= 0xFFFFFFFF)
{
length = 4;
additional = 26;
}
out.push_back(static_cast<std::uint8_t>(major_type + additional));
for (int i = length - 1; i >= 0; --i)
{
out.push_back(static_cast<std::uint8_t>(argument >> (8 * i)));
}
}
// the CBOR encoding of an integer value
void reference_cbor_integer(std::vector<std::uint8_t>& out, const json& j)
{
if (j.is_number_unsigned())
{
reference_cbor_head(out, 0x00, j.get<std::uint64_t>());
return;
}
const auto n = j.get<std::int64_t>();
if (n >= 0)
{
reference_cbor_head(out, 0x00, static_cast<std::uint64_t>(n));
}
else
{
reference_cbor_head(out, 0x20, static_cast<std::uint64_t>(-1 - n));
}
}
// integers at every boundary of the CBOR head sizes, signed and unsigned
std::vector<json> boundary_integers()
{
std::vector<json> result;
const std::uint64_t boundaries[] =
{
0, 1, 22, 23, 24, 25, 254, 255, 256, 257, 65534, 65535, 65536, 65537,
4294967294ULL, 4294967295ULL, 4294967296ULL, 4294967297ULL,
9223372036854775806ULL, 9223372036854775807ULL, 9223372036854775808ULL,
18446744073709551614ULL, 18446744073709551615ULL
};
for (const std::uint64_t u : boundaries)
{
result.emplace_back(u);
if (u <= static_cast<std::uint64_t>((std::numeric_limits<std::int64_t>::max)()))
{
const auto n = static_cast<std::int64_t>(u);
result.emplace_back(static_cast<json::number_integer_t>(n));
result.emplace_back(static_cast<json::number_integer_t>(-n));
result.emplace_back(static_cast<json::number_integer_t>(-n - 1));
}
}
return result;
}
} // namespace
// Arrays of integers are written in runs straight into space reserved from the
// sink, with a branch-free head; check them against an independent encoder for
// runs shorter and longer than one reservation, broken up by other values, and
// through each sink.
TEST_CASE("CBOR integer arrays")
{
const std::vector<json> integers = boundary_integers();
for (const std::size_t length :
{
std::size_t{1}, std::size_t{27}, std::size_t{28}, std::size_t{29}, std::size_t{57}, std::size_t{1000}
})
{
for (const std::size_t break_every :
{
std::size_t{0}, std::size_t{1}, std::size_t{7}, std::size_t{28}
})
{
CAPTURE(length)
CAPTURE(break_every)
json array = json::array();
std::vector<std::uint8_t> expected;
for (std::size_t i = 0; i < length; ++i)
{
if (break_every != 0 && i % (break_every + 1) == break_every)
{
array.push_back("x");
expected.push_back(0x61);
expected.push_back('x');
}
else
{
const json& value = integers[(i * 7) % integers.size()];
array.push_back(value);
reference_cbor_integer(expected, value);
}
}
std::vector<std::uint8_t> head;
reference_cbor_head(head, 0x80, length);
expected.insert(expected.begin(), head.begin(), head.end());
CHECK(json::to_cbor(array) == expected);
std::vector<std::uint8_t> through_adapter = {0xAA};
json::to_cbor(array, through_adapter);
CHECK(std::vector<std::uint8_t>(through_adapter.begin() + 1, through_adapter.end()) == expected);
std::ostringstream stream;
json::to_cbor(array, stream);
const std::string streamed = stream.str();
CHECK(std::vector<std::uint8_t>(streamed.begin(), streamed.end()) == expected);
CHECK(json::from_cbor(expected) == array);
// the same array as an object member, and as single values
const json object = {{"a", array}, {"b", array}};
CHECK(json::from_cbor(json::to_cbor(object)) == object);
}
}
for (const auto& value : integers)
{
CAPTURE(value)
std::vector<std::uint8_t> expected;
reference_cbor_integer(expected, value);
CHECK(json::to_cbor(value) == expected);
}
}
// binary_reserve_hint() is documented as a *lower* bound on the serialized size,
// so that reserving it up front can never leave the returned vector holding
// capacity beyond what the value actually needs.