Cheap perf wins in jsonpull C++ port

Profiling tl_2022_us_county.json (sample(1) on Apple Silicon) showed
~38% of parse time in allocator work and ~14% in std::string::push_back
during string-token construction. These changes target the low-hanging
fruit from that profile:

- Pre-reserve 2 slots in json_array and 4 slots in json_hash so
  coordinate `[x, y]` pairs and typical GeoJSON property maps avoid
  the 0 -> 1 -> 2 -> 4 vector-growth chain (and the shared_ptr copies
  it incurs).
- Reuse a parser-wide std::string buffer for JSON_STRING tokens
  instead of constructing a fresh local std::string per token. The
  buffer is cleared (capacity preserved) at the start of each token
  and copied into the final json_string, so once it has grown to the
  longest string seen it stops reallocating entirely.
- std::move the freshly-created container shared_ptr into the parser
  container stack in the `[` and `{` handlers, and move it out of the
  frame on the matching `]` / `}`. Each move skips one atomic
  inc/dec round-trip per container open and close.

On a tl_2022_us_county.json benchmark (4-iter user-time mean, Apple
Silicon, /usr/bin/time):
- main baseline:                              ~8.17s
- jsonpull-cpp before these changes:          ~10.90s  (+33%)
- jsonpull-cpp with these changes:            ~9.33s   (+14%)

So this commit recovers roughly half of the post-port regression.
The remaining gap is dominated by shared_ptr atomic refcount traffic
on the parse tree and per-node heap allocations, which would require
the larger unique_ptr/arena reworks to address.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Erica Fischer
2026-05-30 18:28:11 -07:00
co-authored by Cursor
parent 29b5dff333
commit 3f526ccad7
2 changed files with 44 additions and 10 deletions
+21 -4
View File
@@ -128,15 +128,25 @@ struct json_string : json_object {
struct json_array : json_object {
std::vector<json_object_ptr> array_value;
json_array() : json_object(JSON_ARRAY) {}
json_array(json_object *p, json_pull *pl) : json_object(JSON_ARRAY, p, pl) {}
// Coordinate-heavy GeoJSON dominates the parse workload, and every
// `[x, y]` (or `[x, y, z]`) pair would otherwise force the inner
// vector through 0 -> 1 -> 2 -> 4 growths plus the matching
// shared_ptr copies. Reserving 2 slots up front eliminates those
// reallocations for the common case and adds only a single small
// allocation for larger rings (which still grow geometrically).
json_array() : json_object(JSON_ARRAY) { array_value.reserve(2); }
json_array(json_object *p, json_pull *pl) : json_object(JSON_ARRAY, p, pl) { array_value.reserve(2); }
};
struct json_hash : json_object {
std::vector<json_entry> entries_value;
json_hash() : json_object(JSON_HASH) {}
json_hash(json_object *p, json_pull *pl) : json_object(JSON_HASH, p, pl) {}
// Most GeoJSON property hashes have a handful of keys (type, id,
// properties, geometry, plus a few attribute fields). Reserving 4
// slots avoids the 0 -> 1 -> 2 -> 4 growth chain for the typical
// case while only modestly over-allocating for one-key hashes.
json_hash() : json_object(JSON_HASH) { entries_value.reserve(4); }
json_hash(json_object *p, json_pull *pl) : json_object(JSON_HASH, p, pl) { entries_value.reserve(4); }
};
inline std::string &json_object::string() {
@@ -232,7 +242,14 @@ struct json_pull {
std::vector<parse_frame> container_stack;
json_object_ptr root;
// Scratch buffers reused across tokens so we don't reallocate per
// number/string. number_buffer accumulates raw digits before atof();
// string_buffer accumulates decoded bytes before being copied into
// the final json_string. Both are cleared (capacity preserved) at
// the start of each token, so once they grow to the largest seen
// size they stop reallocating entirely.
std::string number_buffer;
std::string string_buffer;
};
json_pull_ptr json_begin_file(FILE *f);