Skip to content

Write json_view's dump() without a call or conversion per token - #5633

Open
nlohmann wants to merge 11 commits into
json-view/21-imagesfrom
json-view/22-view-dump-fast
Open

nlohmann wants to merge 11 commits into
json-view/21-imagesfrom
json-view/22-view-dump-fast

Conversation

@nlohmann

@nlohmann nlohmann commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Part of the stack for the zero-copy view (#5295). This PR gives the default json_view::dump() a dedicated writer and makes it write floats from their digits. It builds on #5632 and on the Żmij float writer below it.

Summary

A dedicated writer for the default dump()

The default dump() (no indentation, no ensure_ascii) gets its own writer, dump_compact. It makes the same walk and produces the same output, with these changes:

  • The write position stays in a local variable. Stores through char pointers may alias anything, so with the position as a member of the output buffer, the compiler reloads it after every store. output_buffer gets cursor(), limit() and set_cursor() for this.
  • Strings and number tokens of the source are copied with fixed-size moves of 32 bytes wherever the source has that many bytes left, instead of one memcpy call per token. Up to 256 bytes, several 32-byte moves are used. Longer copies, and copies near the end of the source, go through an out-of-line memcpy. The buffer keeps 64 bytes of slack for the overshoot, and the size estimate of dump() includes it (+160 instead of +64), so the buffer does not grow just before the end.
  • The innermost open container is kept in local variables; the stack holds only the ones around it. The stack is a small_stack: it starts in a local array of 32 entries and moves to the heap only for deeper nesting, and its address does not escape, so its pointers stay in registers. Shallow documents need no allocation besides the output. The general writer (indented output, ensure_ascii) uses the same stack.
  • Documents that are not edited are walked through the node array in order, so a container only needs its end. Integer tokens are read from the source directly (-0 is written as 0, as parse() reads it; with number_format::source the token is copied as it is).
  • Strings that are not plain source strings (decoded, or written by edits) are written by write_decoded: runs without a quote, a backslash or a control character are copied, and the rest is escaped. plain_output_run finds these runs eight bytes at a time. It does not validate non-ASCII bytes: strings of a document are valid UTF-8, and a damaged image loaded with image_check::bounds writes them unchanged here.

Indented output and ensure_ascii still use the general writer. Editable documents take the new path too (through the navigation policy); strings and tokens written by edits are copied with a plain memcpy.

Floats written from their digits

For double (where it is IEEE 754 binary64; otherwise the general writer is used), json_view::dump() writes a float token of at most 15 significant digits from its digits, without converting it to a double and back. The output is unchanged: it is what json::dump() writes for the same value.

  • Why this is exact. Two decimals of at most 15 significant digits are farther apart than the rounding interval of a normal double; this is the argument behind DBL_DIG. So the token's digits, without trailing zeros, are the shortest digits of its double. The library writes exactly those (Żmij, via zmij::to_shortest() and dtoa_impl::write_shortest()); with Grisu2 this would not hold.
  • When it applies. The token must not be edited, its digits must fit the recorded layout, and the decimal exponent must lie in [−290, 290], which keeps the value away from subnormals and overflow.
  • Other tokens (16 or more digits, or edited values) are converted from the digits already read with decimal_to_float(), so the token is not read twice, and then written from the bits of the double, without the checks of the general to_chars() path. Non-finite values are written as null.
  • write_short_decimal() (new in to_chars.hpp, with powers_of_ten_16()) extends the digits to 16 and hands them to write_shortest(). The overload with a digit count is used when the count from the token's layout matches the value; otherwise the digits are counted. A token from an image loaded with image_check::bounds can have bytes that are not digits, so the layout may not describe the value; the check keeps such a token from reaching the counted overload, and dump() can no longer read out of bounds for it.
  • Doubles are written into the output directly, not through a local buffer. Other float types are written as before.

Output growth

dump()'s output now grows in steps of 64 KiB within its reserved size estimate (the source extent), instead of being resized to the estimate at once. resize() zero-fills, and for a pretty-printed source the estimate is far larger than the compact output. A step does not go beyond the reserved capacity, so a good estimate is never copied to a larger allocation.

Performance

Measured on Apple M1 Max (Apple clang -O2) and an x86-64 KVM VPS (Haswell, GCC 13 / Clang 18 -O2, pinned to one core, about ±5–10% noise), µs, best of 3 interleaved rounds of 15 runs; files from nativejson-benchmark.

dump() of the whole document, with the general writer (before) and with this PR (after):

Apple M1 x86-64 GCC x86-64 Clang
canada 5287 → 3680 (−30%) 8179 → 6949 (−15%) 7986 → 7178 (−10%)
citm_catalog 378 → 132 (−65%) 710 → 266 (−63%) 631 → 279 (−56%)
twitter 238 → 74 (−69%) 379 → 166 (−56%) 400 → 187 (−53%)

Documents of mostly strings and integers gain the most (the copies and the registers); canada is dominated by writing 17-digit floats and gains the least.

Core library (json::parse / dump)

json::dump() is not affected. This PR adds powers_of_ten_16() and write_short_decimal() to to_chars.hpp; only the view uses them. The other changes are in include/nlohmann/detail/view/serializer.hpp and json_view.hpp.

Tests

Some edit tests move from unit-json_view_edit.cpp to a new file, tests/src/unit-json_view_edit_values.cpp, so that no test object exceeds the 32,767 sections that the MinGW linker of the Windows clang job accepts.

  • unit-json_view_dump.cpp: 20,000 random float tokens with 1 to 17 significant digits, in every spelling (with and without a point, e/E exponents, signs, leading and trailing zeros, exponents from about 1e-320 to 1e300), checked against json::dump() of the same text. The number_format::source output is also checked indented and with ensure_ascii. The existing tests compare dump() with ordered_json::dump() on generated documents, edited documents (the differential test), and loaded images.
  • unit-json_view_image.cpp: float tokens of an image loaded with image_check::bounds whose bytes are not digits, or whose digit count differs from the recorded layout. dump() must still write a number.
  • unit-json_view_edit.cpp: float types other than double (here float) are written as basic_json writes them, with non-finite values as null.
  • unit-to_chars.cpp: write_short_decimal() against to_chars() of the same double, for examples and for 100,000 random digit strings, exponents and trailing zeros, with and without the digit count. powers_of_ten_16() is checked as well.

Public API

No breaking changes. The output of json_view::dump() is unchanged. The new dtoa_impl::write_short_decimal() and powers_of_ten_16() are in detail; the public API does not change.


Written by Claude Code.

🤖 Generated with Claude Code

@nlohmann
nlohmann added this pull request to stack #5636 September 29, 2026 14:20
@nlohmann
nlohmann removed this pull request from stack #5636 September 30, 2026 13:19
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from 2202ac0 to b6f0daf Compare September 30, 2026 13:20
@nlohmann
nlohmann added this pull request to stack #5739 September 30, 2026 13:21
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch 3 times, most recently from d50ae18 to 61afa69 Compare September 30, 2026 18:06
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Sep 30, 2026
@nlohmann
nlohmann marked this pull request as ready for review September 30, 2026 18:16
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from 61afa69 to 1df24a0 Compare September 30, 2026 19:19
Comment thread include/nlohmann/detail/view/serializer.hpp Fixed
Comment thread include/nlohmann/detail/view/serializer.hpp Fixed
Comment thread include/nlohmann/detail/view/serializer.hpp Dismissed
Comment thread include/nlohmann/detail/view/serializer.hpp Dismissed
Comment thread single_include/nlohmann/json_view.hpp Fixed
Comment thread single_include/nlohmann/json_view.hpp Fixed
Comment thread single_include/nlohmann/json_view.hpp Dismissed
Comment thread single_include/nlohmann/json_view.hpp Dismissed
@nlohmann
nlohmann removed this pull request from stack #5739 October 6, 2026 09:26
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from 2db0e30 to 64c3dbd Compare October 6, 2026 09:28
@nlohmann nlohmann changed the title Write compact dumps of json_view without a library call per token Write json_view's dump() without a call or conversion per token Oct 6, 2026
@nlohmann
nlohmann added this pull request to stack #5768 October 6, 2026 09:29
Comment thread include/nlohmann/detail/view/serializer.hpp Dismissed
Comment thread single_include/nlohmann/json_view.hpp Dismissed
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from 64c3dbd to accba4d Compare October 7, 2026 14:44
Comment thread include/nlohmann/detail/view/serializer.hpp Dismissed
Comment thread single_include/nlohmann/json_view.hpp Dismissed
Comment thread tests/src/unit-json_view_image.cpp Dismissed
@nlohmann nlohmann closed this Oct 10, 2026
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch 3 times, most recently from 15031b9 to 2b9644d Compare October 14, 2026 15:38
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from 2b9644d to fa01cf2 Compare October 14, 2026 15:55
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from fa01cf2 to f81cfaf Compare October 16, 2026 06:46
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from f81cfaf to 1ef5204 Compare October 16, 2026 17:42
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from 1ef5204 to 7456054 Compare October 17, 2026 06:02
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from 7456054 to 0f75bf1 Compare October 17, 2026 06:32
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from 0f75bf1 to 601afa7 Compare October 17, 2026 12:28
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from 601afa7 to ea0afb4 Compare October 17, 2026 21:25
The default dump() (no indentation, no ensure_ascii) gets its own
writer that makes the same walk and produces the same output:

- the write position stays in a local variable instead of a
  member, so the compiler keeps it in a register across stores
  through aliasing char pointers;
- strings and number tokens are copied with fixed-size 32-byte
  moves wherever enough source bytes remain, instead of one
  memcpy call per token;
- the innermost open container lives in local variables; a stack
  that starts as a local array of 32 entries holds the rest;
- unedited documents are walked through the node array in order,
  and integer tokens are read from the source directly.

On top of that, float tokens of at most 15 significant digits are
written straight from their digits via zmij::to_shortest() and
write_shortest(), without converting to a double and back: such
decimals are farther apart than a double's rounding interval, so
the token's digits are the double's shortest digits. Tokens of
16+ digits, or edited values, still go through decimal_to_float().
The view's own NEON write_decimal() is removed in favor of the
shared writer, and the dump output now grows in 64 KiB steps
instead of being resized to its estimate at once.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Test the template options through enabled() so that conditions combined
with them are not constant, and mark the nav::value() null dereference
as a false positive like materialize.hpp does.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
A float node of an image loaded with image_check::bounds can hold a
token whose bytes are not digits, so its value need not have the digit
count recorded in the node. write_double_at() passed that count to
write_short_decimal(), which indexes its table of powers of ten by it:
an assertion failure in debug builds, an out-of-bounds read in release
builds. Use the counted overload only if the value has exactly that many
digits, otherwise count them.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
…nstant

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
… compile

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The MinGW linker of the Windows clang jobs cannot link object files with more
than 32767 sections ("relocation truncated to fit: IMAGE_REL_AMD64_REL32
against `.rdata'"). unit-json_view_edit.cpp has 35409 sections since the fast
dump instantiates more code for editable documents, so its test cases "views
and values", "deeply nested values", "pointers below a null value", and
"strings of other documents are checked" move into
unit-json_view_edit_values.cpp. The helper exception_of_call() that both
files use moves into json_view_test_helpers.hpp.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The dump and comparison tests moved to their own file in the dump pull
request; the tests of the fast dump() join them there.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the json-view/22-view-dump-fast branch from ea0afb4 to 2ca2cf0 Compare October 18, 2026 02:27

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L review needed It would be great if someone could review the proposed changes. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants