C++ Allocators and std::pmr: Build a Batch Arena for Short-Lived Data

Build a bounded C++20 batch arena for 1,500 short-lived strings, measure what the target library requests, and choose a PMR resource that fits the lifetime.

KnowledgeGate Team

Exam prep & CS education

Updated 26 Oct 20266 min read

You profile a parser and find allocation calls everywhere, but replacing them is unsafe until you can name a real lifetime boundary. Allocator-aware containers and std::pmr select storage by workload lifetime, and one bounded batch proves the policy. If allocator-aware types are new to you, start with the C++ Tutorial: Complete Learning Path in 12 Weeks.

C++ allocators solve a lifetime-policy problem

Keep three ideas separate. An object's C++ lifetime says when construction and destruction occur. Storage is the bytes that hold that object. Allocation policy says where dynamic storage comes from and how it is reclaimed. A container still owns and destroys its elements, while its allocator or memory resource supplies storage.

Ask two questions before choosing a policy: do most objects die together, and can one thread own the storage? A batch parser that discards every field after processing has a clear bulk-release point. A cache whose entries disappear independently does not. Profile first, change only the resource, then compare the same workload. An allocator is a lifetime tool, not a promise of universal speed.

std::pmr separates container types from memory resources

std::pmr::polymorphic_allocator<T> stores a runtime pointer to a std::pmr::memory_resource. The container remains an ordinary C++ type, while that resource supplies allocation and deallocation behaviour. std::pmr::vector<T> is the convenient alias for a vector using this polymorphic allocator.

Runtime resource choice lets the same PMR vector type use different storage policies without defining another container template.

The chain is container to selected resource to an optional upstream resource. std::pmr::new_delete_resource() is the general heap-backed fallback. std::pmr::null_memory_resource() instead throws std::bad_alloc when the selected resource cannot satisfy a request, which makes an arena's capacity limit visible.

Nested allocation matters. Construct std::pmr::vector<std::pmr::string> with the chosen resource so both the vector block and string payloads use it. std::pmr::vector<std::string> redirects only the vector's own storage. Each ordinary std::string continues to follow its own allocator.

std::pmr batch arena: work 250 records end to end

The C++20 program gives one batch exactly 262,144 bytes. A counting resource records requests before forwarding them to the arena. Its null upstream makes exhaustion throw instead of silently reaching the heap.

Because the wrapper sits between the container and arena, it observes every request, including nested PMR-string allocations through allocator propagation. processed_characters confirms that all 1,500 values are consumed before release.

cpp
#include <array>
#include <cstddef>
#include <iostream>
#include <memory_resource>
#include <string>
#include <vector>

class counting_resource final : public std::pmr::memory_resource {
public:
    explicit counting_resource(std::pmr::memory_resource* upstream)
        : upstream_(upstream) {}

    std::size_t requests = 0;
    std::size_t requested_bytes = 0;

private:
    void* do_allocate(std::size_t bytes, std::size_t alignment) override {
        ++requests;
        requested_bytes += bytes;
        return upstream_->allocate(bytes, alignment);
    }

    void do_deallocate(void* p, std::size_t bytes,
                       std::size_t alignment) override {
        upstream_->deallocate(p, bytes, alignment);
    }

    bool do_is_equal(const std::pmr::memory_resource& other) const
        noexcept override {
        return this == &other;
    }

    std::pmr::memory_resource* upstream_;
};

int main() {
    alignas(std::max_align_t)
    std::array<std::byte, 256 * 1024> storage{};

    std::pmr::monotonic_buffer_resource arena(
        storage.data(), storage.size(), std::pmr::null_memory_resource());
    counting_resource measured_arena{&arena};

    {
        std::pmr::vector<std::pmr::string> fields{&measured_arena};
        fields.reserve(1500);

        for (int record = 0; record < 250; ++record) {
            for (int field = 0; field < 6; ++field) {
                fields.emplace_back(48, char('a' + field));
            }
        }

        std::size_t processed_characters = 0;
        for (const auto& field : fields) {
            processed_characters += field.size();
        }

        std::cout << "sizeof string: " << sizeof(std::pmr::string)
                  << "\nvector capacity: " << fields.capacity()
                  << "\nstring capacity: " << fields.front().capacity()
                  << "\nrequests: " << measured_arena.requests
                  << "\nrequested bytes: " << measured_arena.requested_bytes
                  << "\nprocessed characters: " << processed_characters << '\n';
    } // Every client of arena is destroyed here.

    arena.release();
}

Portable arithmetic:

  • 250 records × 6 fields = 1,500 strings.

  • 1,500 × 48 characters = 72,000 logical characters.

  • With one null terminator per string, at least 1,500 × 49 = 73,500 character slots must exist inside the objects or their allocated storage.

That last figure is not an arena-size formula. Vector slot size, string capacity, alignment, and growth rules depend on the implementation.

The buffer is deliberately larger than the reported requests because requested bytes and actual arena position are not identical measures. Alignment padding can consume additional space. A bounded test must succeed or throw based on the real resource, not on payload arithmetic alone.

An illustrative Apple clang 21 with libc++ trace reported sizeof(std::pmr::string) = 32, vector capacity 1,500, string capacity 55, 1,501 requests, and 132,000 requested bytes. The check is 1,500 × 32 = 48,000 bytes for one vector block. The 1,500 string blocks request 56 bytes each, including terminators, so 1,500 × 56 = 84,000 bytes. Then 48,000 + 84,000 = 132,000 bytes. Every physical-layout value in this trace is implementation-specific. Successful completion proves that this run needed zero upstream heap allocations; a smaller arena may throw.

Memory map from a 262,144-byte arena to 1,500 PMR strings, showing 132,000 requested bytes and vector destruction before release.

std::pmr resource choice should follow the workload

Choose from the lifetime and sharing pattern, not from the class name alone.

Workload

Resource

Why

Boundary

Batch or request scratch data

monotonic_buffer_resource

Cheap whole-arena release

All clients die together

Repeated small blocks with mixed individual frees on one thread

unsynchronized_pool_resource

Freed blocks can be reused

One thread owns access

Shared pool across threads

synchronized_pool_resource

Internal synchronisation

Resource is shared

General long-lived ownership

Default or explicit upstream resource

Supports ordinary independent lifetimes

Owners release independently

Hard-cap testing

Fixed initial buffer plus null_memory_resource()

Exhaustion becomes std::bad_alloc

Fixed capacity is enforced

monotonic_buffer_resource::deallocate does not reclaim an individual block. Consumption grows until release() or resource destruction, so the approach is strong only when the common end boundary is genuine.

C++ allocator lifetimes decide whether references remain valid

Destruction order is part of correctness. In the example, every string and the vector leave their nested scope before arena.release() runs. Never release or destroy a resource while any allocator-aware client might read, destroy, or deallocate through it.

A client's destructor may call deallocate through its stored resource pointer even when a monotonic resource ignores individual reclamation. That pointer must still name a live resource object.

An escaping std::string_view, pointer, iterator, or reference into one of the 1,500 strings becomes dangling after release. If a parsed value must survive the batch, copy it into an owning std::string or a container backed by a longer-lived resource.

The broader C++ Object Lifetime and Storage Duration derives scope, storage duration, construction order and dangling handles through a seven-object trace, while Pointers in C for GATE refreshes address and value reasoning. PMR adds a narrower obligation: every allocator-aware client must be destroyed before its resource is released.

Two batch lifecycles reuse one arena; an escaping string_view dangles after release, while a copied std::string remains valid.

std::pmr traps that invalidate benchmark conclusions

Small-string optimisation can hide allocations. Short values may stay inside each string object, invisible to the resource. Keep the worked 48-character input, print capacity on the target library, and do not generalise the libc++ trace.

Missing reserve(1500) changes the experiment. Vector growth then adds allocation requests and element moves. Reserve in both baseline and PMR versions.

A monotonic arena can hide per-batch growth. Keep null_memory_resource() in bounded tests, catch std::bad_alloc at the batch boundary, and size from measured high-water use rather than logical payload alone.

An unsynchronised pool is not safe for concurrent access. Give each worker its own resource where ownership permits, or use a synchronised resource when sharing is required.

For the same input, record allocation-call count, requested bytes, peak arena use, batch throughput, and p95 latency. One timing run cannot establish a win.

C++ allocator questions test resource choice and propagation

Useful questions ask you to reason in detail, not recite: choose monotonic or pool resources for two stated lifetimes; explain why std::pmr::vector<std::string> does not redirect string payloads; find the view made dangling by arena.release().

Trace both directions carefully. Allocation flows from container to resource to upstream. Safe teardown flows from elements to container to resource release. A complete answer justifies lifetime, threading, and exhaustion behaviour, not merely a PMR class name.

C++ allocators and std::pmr: the short decision rule

Use a monotonic resource when allocations share a proven end boundary. Use a pool when small blocks are freed and reused during a longer session. Keep ordinary ownership when data may escape unpredictably. Always measure the target implementation, include its standard library in the result, and make every resource outlive its clients.

For focused concepts and practice, continue with the C++ Programming Course. For a broader route across coding and systems topics, browse Coding & Skill Development Courses.