Skip to content

Add support for custom allocators - #2697

Open
vogelsgesang wants to merge 1 commit into
simdjson:masterfrom
vogelsgesang:avogelsgesang-allocator-suppor
Open

Add support for custom allocators#2697
vogelsgesang wants to merge 1 commit into
simdjson:masterfrom
vogelsgesang:avogelsgesang-allocator-suppor

Conversation

@vogelsgesang

@vogelsgesang vogelsgesang commented Apr 25, 2026

Copy link
Copy Markdown

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

  • Is the general approach (custom abstract base class + RAII buffer
    wrapper) acceptable, or would you prefer a different shape entirely
    (templated, std::pmr-only, hooks, ...)?
  • Are we OK with threading the allocator through so many API layers
    (parser → document → dom_parser_implementation → per-kernel
    subclasses)? It is mechanical but still a bit of churn.
  • I had to drop noexcept from a number of public methods because a
    user allocator may throw. Three options:
    1. Drop noexcept unconditionally (current PR).
    2. Drop noexcept only when SIMDJSON_EXCEPTIONS is set.
    3. Gate behind a new SIMDJSON_SUPPORT_THROWING_ALLOCATOR macro,
      defaulting to off.
  • Do we actually care about strong exception safety? If not, several
    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 virtual
allocate and deallocate functions.

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_resource or a
std::allocator<T>-conforming type can plug it in via
simdjson::allocator_adapter<Alloc>.

Why not std::pmr or std::allocator directly?

PR #1467 attempted to use std::allocator and was closed because of
three issues:

  1. std::allocator::allocate throws std::bad_alloc -- incompatible
    with simdjson's error-code model.
  2. Mixing operator new[] / delete[] with allocator-allocated memory
    produced alloc-dealloc-mismatch under sanitizers.
  3. 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

struct allocation_result {
  void* ptr;
  size_t size;  // actual bytes allocated, >= requested
};

class allocator {
public:
  virtual ~allocator() = default;

  /**
   * Allocate AT LEAST `size` bytes. Return `{nullptr, 0}` on failure, OR throw.
   * The returned `size` may exceed the request; pass it back to `deallocate`
   * unchanged. Modelled on std::allocator::allocate_at_least (C++23).
   */
  virtual allocation_result allocate(size_t size) = 0;

  /**
   * Deallocate memory previously returned by `allocate()`.
   * `size` must equal `allocation_result::size` from the corresponding
   * `allocate()` call (sized-deallocation style, like C++14
   * `operator delete(void*, size_t)`).
   */
  virtual void deallocate(void* ptr, size_t size) noexcept = 0;
};

Notes:

  • allocate may throw, but the default never does, so existing
    behaviour is preserved.
  • The default allocator is a process-wide singleton, wrapping
    new[] (std::nothrow). Parsers store a single allocator*; users
    who don't opt in pay no extra indirection on the hot path beyond
    one virtual call per (rare) allocation.
  • allocation_result::size returns the actual bytes allocated. This
    is 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::parser and the
    builder.

RAII wrapper

internal::allocated_buffer<T> replaces the std::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_buffer first and
only move-assign into the member on success. A failed allocation
(returned nullptr or thrown) leaves the parser in its previous usable
state -- strong exception safety. simdjson itself never catches
allocator exceptions; they propagate to the caller unchanged.

Adapter for standard Allocator types

For users that already have a type modelling the C++ Allocator named
requirement (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_least
is available it forwards to std::allocator_traits::allocate_at_least
so over-allocation surfaces through to simdjson's capacity tracking.

simdjson::allocator_adapter<std::pmr::polymorphic_allocator<uint8_t>> a{
    std::pmr::polymorphic_allocator<uint8_t>{&my_resource}};
simdjson::ondemand::parser parser{a};

Scope

Applies to dom::parser, dom::document, ondemand::parser,
dom_parser_implementation (+ kernel subclasses), and
builder::string_builder.

Deliberately not applied to:

  • padded_string / padded_string_builder -- input role symmetrical
    with padded_string_view (caller brings the buffer).
  • ondemand::parser::get_parser() -- swappable allocator on a static
    thread-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.

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.
@lemire

lemire commented May 1, 2026

Copy link
Copy Markdown
Member

Sorry for the delay.

  1. I don't think we want to break the noexcept. It would be ok to do noexcept(myallocator.except) with a default allocator that is noexcept. The general rule should be to avoid breaking our public interface.
  2. I am not too worried about performance and we will verify this anyhow.

@vogelsgesang

Copy link
Copy Markdown
Author

Sorry for the delay.

No worries, thanks for the feedback!

I don't think we want to break the noexcept. [...] The general rule should be to avoid breaking our public interface.

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 noexcept? (At least for most "just call the functions" usages)

It would be ok to do noexcept(myallocator.except) with a default allocator that is noexcept

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

  1. Drop noexcept unconditionally (current PR).
  2. Drop noexcept only when SIMDJSON_EXCEPTIONS is set.
  3. Gate behind a new SIMDJSON_SUPPORT_THROWING_ALLOCATOR macro,
    defaulting to off.

For my use case, both would be fine. I would go with (3) since it means less code churn.

@lemire

lemire commented May 1, 2026

Copy link
Copy Markdown
Member

@vogelsgesang Can't we require the allocator to be noexcept ?

T* allocate(std::size_t n) noexcept {
        try {
 // blabla
        } catch (...) {
            return nullptr;
        }
 }

@vogelsgesang

vogelsgesang commented May 2, 2026

Copy link
Copy Markdown
Author

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)
We have two memory limits: one process-wide limit and a per-request limit.
We over-commit memory, i.e. we allow more requests to be in the system at the same time than we could serve if every request actually uses its full memory budget. We bet on the fact that most requests are simple and don't actually use the memory.
Our allocator instances track how much memory was already allocated per query and then throw the corresponding "per-request limit exceeded" or "global limit exceeded". Depending on which limit we reach, this counts differently against our SLA: If we hit the per-request limit, it's the users fault since he gave us a too complex query. This doesn't count against our SLA. On the other hand, if the query failed due to us over-comitting memory, it does count against our SLA, since this is an optimization done by us under the covers, outside the control of the user.

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 allocate as noexcept as you propose, I would simply do the following in my downstream code:

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>...
}

@lemire

lemire commented May 2, 2026

Copy link
Copy Markdown
Member

@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.

@lemire

lemire commented May 4, 2026

Copy link
Copy Markdown
Member

@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 parser is precisely to control and limit allocations.

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!!!

@vogelsgesang

Copy link
Copy Markdown
Author

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.

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 auto someCaller() was oversimplified and only focused on the the aspect of "how could I propagate the exception payload via a threadlocal" while ignoring the reuse of allocations. In practice, I will be reusing the parser.

(Also, I will be using an ondemand::parser instead of a dom::parser. In fact, I would even prefer a parser which pre-parses the document into a "tape" similar to the DOM parser, but into a single instead of two tapes as the DOM parser currently does - but I am digressing...)

And the long version: I am planning to integrate simdjson into a database system. This system will receive queries like

SELECT
  json_value(my_table.details, '$.stacktrace[0].symbol_name') AS crash_symbol,
  COUNT(*)
FROM log_events
WHERE json_value(my_table.metadata, '$.event.type') = 'crash'
GROUP BY crash_symbol

Note how:

  1. I have an input table log_events. Let's assume it contains 1 million records
  2. I have 2 json_value calls accessing JSON documents from 2 different columns (metadata and details)

-> I will be processing 2 million JSON documents for this query.

When starting to process this query, I would set up two simdjson::ondemand::parser instances in the thread-local storage of each thread, one for each of the json_value call sites.
For each new tuple from the log_events, I would reuse those two parsers.
After the query is done, I would tear down the parser instances.
I think the setup / teardown cost of two parsers per thread is acceptable for processing 2 million tuples.

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.

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 GROUP BY crash_symbol is already taking up 99MB, then the simdjson parser would have only 1MB of the overall query budget of 100MB left. However, if the hash table happens to only take 5 MB, the simdjson parser would be allowed to use up to 95MB.

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

@lemire

lemire commented May 4, 2026

Copy link
Copy Markdown
Member

@vogelsgesang

We could also keep the style of the current PR and remove the noexcept if we can establish that it does not affect the performance. That would require a major release, of course.

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?".

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).

@vogelsgesang

Copy link
Copy Markdown
Author

We could also keep the style of the current PR and remove the noexcept if we can establish that it does not affect the performance. That would require a major release, of course.

The longer I think about it, the more I realize that removing the noexcept is actually the least important part of this change. I think I would just keep the noexcept.

From the earlier options

  1. Drop noexcept unconditionally (current PR).
  2. Drop noexcept only when SIMDJSON_EXCEPTIONS is set.
  3. Gate behind a new SIMDJSON_SUPPORT_THROWING_ALLOCATOR macro, defaulting to off.

(2) and (3) also aren't nice, because throwing allocators would be a rarely used feature, and new code would probably add new unconditional noexcepts, which has a risk of inadvertently breaking the SIMDJSON_SUPPORT_THROWING_ALLOCATOR config. The threadlocal approach is good enough for me.

I will update the PR soon(-ish) to keep the noexcept in place


simdjson_warn_unused
inline error_code document::allocate(size_t capacity) noexcept {
inline error_code document::allocate(size_t capacity) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread doc/performance.md
* [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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much, @vogelsgesang, for leading this effort! I would also benefit greatly from this functionality in one of my projects.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants