Add support for custom allocators - #2697
Conversation
Related to simdjson#1017; learning from the closed simdjson#1467. Embedded and arena-oriented users have long asked for a way to route simdjson's scratch allocations through their own pool or tracking allocator without globally replacing `operator new`. Until now the only option was a fork. This change threads an injectable allocator through the parser/document/builder hierarchy so each instance can own its allocation policy. The design keeps the hot path identical: allocators are passed by reference and stored as a single pointer; the default allocator is a process-wide singleton using `new[] (std::nothrow)` so code that does not opt in sees no behavioural change and no extra indirection beyond one virtual call per (rare) allocation. The interface intentionally diverges from `std::allocator` and `std::pmr::memory_resource` to address the three concrete problems that got simdjson#1467 closed: `std::allocator::allocate` throws on OOM (incompatible with simdjson's error-code model), mixing `operator new[]` / `delete[]` with allocator-owned memory produces alloc-dealloc-mismatch under sanitizers, and `std::unique_ptr<T[]>` does not natively support stateful allocators. Users that already have a `std::allocator<T>`- or `std::pmr::memory_resource`-conforming type can still plug in via `simdjson::allocator_adapter<Alloc>`, which forwards to `std::allocator_traits::allocate_at_least` when available. `allocator::allocate` returns `{ptr, actual_size}` modelled on C++23 `std::allocator::allocate_at_least`. The actual size is propagated into the parser's `_capacity` / `loaded_bytes.capacity()` etc., so when the allocator over-provisions (jemalloc size classes, huge-page rounding, slab allocators) the next slightly-larger document can often be parsed without reallocating. The RAII wrapper `internal::allocated_buffer<T>` replaces the various `std::unique_ptr<T[]>` buffers. It remembers both the byte size and the allocator pointer that produced the memory so sized-deallocation works correctly even after move-assigning between parsers that use different allocators -- which was the main reason for not just reusing `std::pmr`: we need per-instance immutability of the allocator while still allowing buffers to migrate on move. Because a user allocator may throw (e.g. `std::bad_alloc` from an arena that has exhausted its budget), public methods that can allocate drop `noexcept`. Exceptions propagate unchanged; simdjson never swallows them. Internally the reallocation sites first allocate into a local `allocated_buffer` and only move-assign into the member on success, preserving strong exception safety -- a partial OOM leaves the parser in its previous usable state rather than a half-initialised one. The per-kernel `create_dom_parser_implementation` virtual takes the allocator by reference so the architecture-specific `dom_parser_implementation` subclasses can forward it to their own scratch buffers; touching every kernel is unavoidable but mechanical. The change applies to `dom::parser`, `dom::document`, `ondemand::parser`, `dom_parser_implementation` (and all kernel subclasses), and `builder::string_builder`. `padded_string` / `padded_string_builder` deliberately do not take an allocator -- they are symmetrical with `padded_string_view`, which already lets the caller bring their own buffer. The ondemand thread-local parser returned by `get_parser()` deliberately keeps the default allocator: a static thread-local whose allocator could be swapped would make lifetime reasoning brittle. Users that need a custom allocator own their own parser instance, which the docs now call out explicitly.
|
Sorry for the delay.
|
No worries, thanks for the feedback!
Sounds good. Just so I understand the guidance: Is simdjson aiming for source-code compatibility or ABI compatibility? Afaict, source-code compatibility would be retained even if we remove the
That would only work if we would pass in the allocator type as a template parameter - that's not what this commit is doing, though. This commit doesn't pass the allocator as a template parameter but as a type-erased runtime parameter. With (1) being a no-go, I see two remaining options
For my use case, both would be fine. I would go with (3) since it means less code churn. |
|
@vogelsgesang Can't we require the allocator to be noexcept ? T* allocate(std::size_t n) noexcept {
try {
// blabla
} catch (...) {
return nullptr;
}
} |
|
it's not perfect for my particular downstream use case, but works for me, too. In my use case, the exceptions thrown by the allocator have additional payload information. More details (feel free to skip) I see how this might be an edge case, and for the majority of users of simdjson, keeping the allocators noexcept might indeed be the better choice. If we keep threadlocal std::exception_ptr threadlocal_exception_ptr;
// My custom allocator, implemented in my downstream code
struct tracking_allocator : allocator {
// ... <other functions> ...
simdjson::allocation_result allocate(size_t size) override {
try {
return backing_allocator->allocate(size_t size);
} catch(...) {
threadlocal_exception_ptr = std::current_exception();
return nullptr;
}
}
// ... <other functions> ...
};
auto someCaller() {
tracking_allocator alloc(1);
simdjson::dom::parser parser(alloc);
simdjson::dom::element elem;
auto result = parser.parse(jsonStr, jsonStr.size()).get();
if (result == simdjson::MEMALLOC) {
std::rethrow_exception(threadlocal_exception_ptr);
}
// ...<more code>...
} |
|
@vogelsgesang We can also do the macro thing. (We have lots of little macros for specific use cases.) But let me go to something I feel is important. The way the simdjson lib is built, you can effectively avoid allocations. That's the point of the parser, we have it have a capacity and then from there, it can basically be reused. That's really how we hope people use it on long running services. So I am a little worried that you might be doing lots of allocation and de-allocation. Of course, you do what you want... but I just want to make sure that there is no confusion as to what we expect. |
|
@vogelsgesang So, here for example... auto someCaller() {
tracking_allocator alloc(1);
simdjson::dom::parser parser(alloc);
simdjson::dom::element elem;
auto result = parser.parse(jsonStr, jsonStr.size()).get();
if (result == simdjson::MEMALLOC) {
std::rethrow_exception(threadlocal_exception_ptr);
}
...We prefer that users do not allocate a parser each time they parse a document. The reason we have distinct type So let us say that each thread only ever parses one JSON document at any one time, then what we would suggest is a thread local parser. And you can even put a limit on it (so that it throws or return an error if the input is too large). So you can create a parser, say that the parser can't exceed that much capacity and then reuse the parser. Your memory usage will then be flat as far as the parser is concerned. If your purpose is to have bounded capacity, there is no need for a custom allocator at all. The functionality is built in!!! |
Understood - Thank you for pointing out the potential performance pitfalls and that I should reuse the allocations! Long story short: Don't worry, (I think?) I know what I am doing My example regarding (Also, I will be using an And the long version: I am planning to integrate simdjson into a database system. This system will receive queries like Note how:
-> I will be processing 2 million JSON documents for this query. When starting to process this query, I would set up two
Afaict, I can only set a static limit via this mechanism and there is no callback mechanism "I am about to allocate X MB - is that ok right now?". In my case, whether an allocation should be successful also depends on what the other parts of the query are doing. I want to limit the overall usage for the complete query, not only of each individual component. JSON-parsing is just one potential user of memory. E.g., if the hash table for the The easiest way I could find to make such memory budgeting work across a wide set of data structures (tuple buffers, hash tables, regular expression engines, ...) was to pass each of those component the same (query-local) allocator. As soon as the memory allocated via that allocator goes over the budget, I throw an exception and thereby fail the query |
|
We could also keep the style of the current PR and remove the
If the parser has max capacity X and you try to parse something larger, it will fail right away, with some kind of memory error. It is designed so that you can forbid the parser from allocating (at all). |
The longer I think about it, the more I realize that removing the From the earlier options
(2) and (3) also aren't nice, because throwing allocators would be a rarely used feature, and new code would probably add new unconditional I will update the PR soon(-ish) to keep the |
|
|
||
| simdjson_warn_unused | ||
| inline error_code document::allocate(size_t capacity) noexcept { | ||
| inline error_code document::allocate(size_t capacity) { |
There was a problem hiding this comment.
We currently return the error code and throw an exception as well. I'm not entirely clear on the reasoning here—would you mind clarifying the intended behavior?
| * [NDEBUG macro](#ndebug-macro) | ||
| * [Reusing the parser for maximum efficiency](#reusing-the-parser-for-maximum-efficiency) | ||
| * [Reusing string buffers](#reusing-string-buffers) | ||
| * [Custom allocators](#custom-allocators) |
There was a problem hiding this comment.
Thank you very much, @vogelsgesang, for leading this effort! I would also benefit greatly from this functionality in one of my projects.
Description: Support for custom allocators (addresses #1017).
I also added support for allocate_at_least-style overallocations.
(The use case I would like to integrate simdjson into could benefit from it)
Type of change: New Feature
How to verify / test: New test cases added as part of the PR
AI Disclaimer:
Please don't review the code line-by-line yet -- I would first like
high-level alignment on the API shape and the rationale. I am happy
to redo the implementation once we agree on the direction.
This PR is intended as the discussion vehicle for adding custom-allocator
support; per CONTRIBUTING.md I would normally open an issue first, but
the design is concrete enough that a PR seemed clearer and drafting the
code change was not too much effort thanks to Claude & Cursor.
Open design questions
wrapper) acceptable, or would you prefer a different shape entirely
(templated,
std::pmr-only, hooks, ...)?(parser → document →
dom_parser_implementation→ per-kernelsubclasses)? It is mechanical but still a bit of churn.
noexceptfrom a number of public methods because auser allocator may throw. Three options:
noexceptunconditionally (current PR).noexceptonly whenSIMDJSON_EXCEPTIONSis set.SIMDJSON_SUPPORT_THROWING_ALLOCATORmacro,defaulting to off.
reallocation sites can be simplified by skipping the
allocate-into-local-then-move-assign dance.
High-level Design
We use a virtual base class
simdjson::allocator. This class provides virtualallocateanddeallocatefunctions.A
simdjson::allocator&is threaded through the various classes / interfaces,so we have it available at all allocation sites.
Users who already have a
std::pmr::memory_resourceor astd::allocator<T>-conforming type can plug it in viasimdjson::allocator_adapter<Alloc>.Why not
std::pmrorstd::allocatordirectly?PR #1467 attempted to use
std::allocatorand was closed because ofthree issues:
std::allocator::allocatethrowsstd::bad_alloc-- incompatiblewith simdjson's error-code model.
operator new[]/delete[]with allocator-allocated memoryproduced alloc-dealloc-mismatch under sanitizers.
std::unique_ptr<T[]>does not natively support stateful allocators;per-allocation custom deleters get unwieldy.
I tried to address all 3 issues in this design.
The allocator interface
Notes:
allocatemay throw, but the default never does, so existingbehaviour is preserved.
new[] (std::nothrow). Parsers store a singleallocator*; userswho don't opt in pay no extra indirection on the hot path beyond
one virtual call per (rare) allocation.
allocation_result::sizereturns the actual bytes allocated. Thisis propagated into the parser's
_capacity/_max_depth/loaded_bytes.capacity()etc., so when the allocator over-provisions(jemalloc size classes, huge-page rounding, slab allocators) the next
slightly-larger document can often be parsed without reallocating at
all. There are tests covering this for both
dom::parserand thebuilder.
RAII wrapper
internal::allocated_buffer<T>replaces thestd::unique_ptr<T[]>buffers.It stores the byte size and the allocator pointer that produced the memory,
so sized-deallocation works correctly even after move-assigning between parsers
that use different allocators.
Exception safety
Reallocation sites allocate into a local
allocated_bufferfirst andonly move-assign into the member on success. A failed allocation
(returned
nullptror thrown) leaves the parser in its previous usablestate -- strong exception safety. simdjson itself never catches
allocator exceptions; they propagate to the caller unchanged.
Adapter for standard
AllocatortypesFor users that already have a type modelling the C++
Allocatornamedrequirement (
std::allocator<T>,std::pmr::polymorphic_allocator<T>,or a third-party pool's allocator),
simdjson::allocator_adapter<Alloc>wraps it as a
simdjson::allocator. When__cpp_lib_allocate_at_leastis available it forwards to
std::allocator_traits::allocate_at_leastso over-allocation surfaces through to simdjson's capacity tracking.
Scope
Applies to
dom::parser,dom::document,ondemand::parser,dom_parser_implementation(+ kernel subclasses), andbuilder::string_builder.Deliberately not applied to:
padded_string/padded_string_builder-- input role symmetricalwith
padded_string_view(caller brings the buffer).ondemand::parser::get_parser()-- swappable allocator on a staticthread-local would make lifetime reasoning brittle.
Performance
The default-allocator path is unchanged: same
new[] (std::nothrow),no extra heap traffic, one extra
allocator*member on each parser.No measurable regression is expected; happy to run a before/after
parsing benchmark if you want hard numbers before merging.