Files
tippecanoe/jsonpull/jsonpull.h
T
Erica FischerandCursor 3f526ccad7 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>
2026-05-30 18:28:11 -07:00

287 lines
10 KiB
C++

#ifndef JSONPULL_H
#define JSONPULL_H
#include <stdio.h>
#include <sys/types.h>
#include <cassert>
#include <memory>
#include <string>
#include <vector>
typedef enum json_type {
// These types can be returned by json_read()
JSON_HASH,
JSON_ARRAY,
JSON_NUMBER,
JSON_STRING,
JSON_TRUE,
JSON_FALSE,
JSON_NULL,
// These and JSON_HASH and JSON_ARRAY can be called back by json_read_with_separators()
JSON_COMMA,
JSON_COLON,
// These are only used internally as expectations of what comes next
JSON_ITEM,
JSON_KEY,
JSON_VALUE,
} json_type;
struct json_object;
struct json_pull;
typedef std::shared_ptr<json_object> json_object_ptr;
typedef std::shared_ptr<json_pull> json_pull_ptr;
// A single key/value pair inside a JSON_HASH. The pairs are stored in
// insertion order in a single std::vector<json_entry> on json_hash, so
// callers can range-for over `o->entries()` with structured bindings
// (`for (auto &[k, v] : o->entries()) ...`) while still preserving the
// order keys appeared in the source document.
struct json_entry {
json_object_ptr key;
json_object_ptr value;
};
// json_object is a small base type that just records the JSON type and
// the back-pointers to its parent and parser. The actual value payload
// lives in a type-specific subclass (json_number, json_string, json_array,
// json_hash), so that JSON_TRUE / JSON_FALSE / JSON_NULL nodes pay only
// the base-class cost and a JSON_HASH does not also drag along a string
// or a number field. Type-tagged accessor methods on the base class
// downcast and return references to the underlying subclass storage.
//
// Children are owned by their parent (via std::vector<json_object_ptr>
// inside json_array / json_hash); the raw `parent` and `parser`
// back-pointers stay valid as long as the node is attached to the tree.
// json_disconnect() splices a node out of its parent and walks the
// detached subtree clearing those back-pointers so the subtree can
// outlive the original parser.
//
// json_object intentionally has no virtual functions and no virtual
// destructor: subclasses are constructed via std::make_shared<json_xxx>(),
// and std::shared_ptr remembers the deleter from the original type, so
// destroying a shared_ptr<json_object> that actually points at a
// json_string still runs ~json_string(). Dispatch on `type` is what the
// rest of the code already does. The accessor methods assert at debug
// time that the type matches before downcasting.
struct json_object {
json_object *parent = nullptr;
json_pull *parser = nullptr;
json_type type;
json_object(json_type t) : type(t) {}
json_object(json_type t, json_object *p, json_pull *pl) : parent(p), parser(pl), type(t) {}
// Type-tagged accessors. Each one asserts that the receiver is of
// the right kind, then downcasts to the storage in the appropriate
// subclass. Inline so the assert and cast disappear at -O.
inline std::string &string();
inline const std::string &string() const;
// Numbers are stored in a discriminated union (double / unsigned /
// signed) so a json_number is only 40 bytes instead of 48. The
// large_*() accessors return 0 when the number is not currently
// stored in that representation, matching the prior convention
// where "0" meant "not set, fall through to the next slot".
inline double number() const;
inline unsigned long long large_unsigned() const;
inline long long large_signed() const;
inline void set_number(double d);
inline void set_large_unsigned(unsigned long long u);
inline void set_large_signed(long long s);
inline std::vector<json_object_ptr> &array();
inline const std::vector<json_object_ptr> &array() const;
inline std::vector<json_entry> &entries();
inline const std::vector<json_entry> &entries() const;
};
struct json_number : json_object {
enum repr_t { REPR_DOUBLE,
REPR_LARGE_UNSIGNED,
REPR_LARGE_SIGNED };
repr_t repr = REPR_DOUBLE;
union value_t {
double d;
unsigned long long u;
long long s;
value_t() : d(0) {}
} value;
json_number() : json_object(JSON_NUMBER) {}
json_number(json_object *p, json_pull *pl) : json_object(JSON_NUMBER, p, pl) {}
};
struct json_string : json_object {
std::string string_value;
json_string() : json_object(JSON_STRING) {}
json_string(json_object *p, json_pull *pl) : json_object(JSON_STRING, p, pl) {}
};
struct json_array : json_object {
std::vector<json_object_ptr> array_value;
// 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;
// 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() {
assert(type == JSON_STRING);
return static_cast<json_string *>(this)->string_value;
}
inline const std::string &json_object::string() const {
assert(type == JSON_STRING);
return static_cast<const json_string *>(this)->string_value;
}
inline double json_object::number() const {
assert(type == JSON_NUMBER);
auto *n = static_cast<const json_number *>(this);
switch (n->repr) {
case json_number::REPR_LARGE_UNSIGNED:
return static_cast<double>(n->value.u);
case json_number::REPR_LARGE_SIGNED:
return static_cast<double>(n->value.s);
case json_number::REPR_DOUBLE:
default:
return n->value.d;
}
}
inline unsigned long long json_object::large_unsigned() const {
assert(type == JSON_NUMBER);
auto *n = static_cast<const json_number *>(this);
return n->repr == json_number::REPR_LARGE_UNSIGNED ? n->value.u : 0;
}
inline long long json_object::large_signed() const {
assert(type == JSON_NUMBER);
auto *n = static_cast<const json_number *>(this);
return n->repr == json_number::REPR_LARGE_SIGNED ? n->value.s : 0;
}
inline void json_object::set_number(double d) {
assert(type == JSON_NUMBER);
auto *n = static_cast<json_number *>(this);
n->repr = json_number::REPR_DOUBLE;
n->value.d = d;
}
inline void json_object::set_large_unsigned(unsigned long long u) {
assert(type == JSON_NUMBER);
auto *n = static_cast<json_number *>(this);
n->repr = json_number::REPR_LARGE_UNSIGNED;
n->value.u = u;
}
inline void json_object::set_large_signed(long long s) {
assert(type == JSON_NUMBER);
auto *n = static_cast<json_number *>(this);
n->repr = json_number::REPR_LARGE_SIGNED;
n->value.s = s;
}
inline std::vector<json_object_ptr> &json_object::array() {
assert(type == JSON_ARRAY);
return static_cast<json_array *>(this)->array_value;
}
inline const std::vector<json_object_ptr> &json_object::array() const {
assert(type == JSON_ARRAY);
return static_cast<const json_array *>(this)->array_value;
}
inline std::vector<json_entry> &json_object::entries() {
assert(type == JSON_HASH);
return static_cast<json_hash *>(this)->entries_value;
}
inline const std::vector<json_entry> &json_object::entries() const {
assert(type == JSON_HASH);
return static_cast<const json_hash *>(this)->entries_value;
}
struct json_pull {
const char *error = nullptr; // points at a string literal; no allocation
int line = 1;
ssize_t (*read)(struct json_pull *, char *buf, size_t n) = nullptr;
void *source = nullptr;
std::vector<char> buffer;
ssize_t buffer_tail = 0;
ssize_t buffer_head = 0;
// Stack of currently-open containers; the top is the innermost container
// being parsed. Each frame also remembers what token is expected next
// (an item, a comma, a key, a colon, or a value). This stack is the
// only place the parser-only `expect` state lives, so it does not
// pollute json_object once parsing finishes. Replaces the previous
// single `container` pointer / parent walk, which previously required
// enable_shared_from_this<json_object> on every json_object instance.
struct parse_frame {
json_object_ptr container;
json_type expect;
};
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);
json_pull_ptr json_begin_string(const char *s);
json_pull_ptr json_begin(ssize_t (*read)(struct json_pull *, char *buffer, size_t n), void *source);
// json_end is now a thin convenience that resets the caller's json_pull_ptr.
// The parser (and any tree it still owns) is freed when the last shared_ptr
// to it is dropped, so calling json_end is optional if the json_pull_ptr will
// go out of scope on its own.
void json_end(json_pull_ptr &p);
typedef void (*json_separator_callback)(json_type type, json_pull *j, void *state);
json_object_ptr json_read_tree(json_pull_ptr j);
json_object_ptr json_read(json_pull_ptr j);
json_object_ptr json_read_separators(json_pull_ptr j, json_separator_callback cb, void *state);
// json_free now just resets the caller's json_object_ptr. The subtree is
// destroyed when the last shared_ptr to it is dropped (typically by also
// being removed from its parent or parser).
void json_free(json_object_ptr &j);
// Splice o out of its parent's array/object and clear parent/parser back-pointers
// throughout the detached subtree so it can outlive the original parser.
void json_disconnect(json_object_ptr j);
json_object_ptr json_hash_get(json_object_ptr o, const char *s);
json_object_ptr json_hash_get(json_object *o, const char *s);
std::string json_stringify(json_object_ptr o);
#endif