Memory model

Underneath the DCEL mesh representation (DCEL), and every other structure that can be mirrored to a device, sits a small, self-contained memory foundation: a placement policy (MemoryResource), a bump-allocating arena over it (Pool), and a placement-independent array descriptor stored inside it (PODVector/ PODSpan). None of this is a general-purpose allocator: it exists to solve one specific problem, described below, and its API is shaped entirely around that problem. Most users reading a mesh from a file (Reading data) only ever touch a Pool at the call site (construct one, pass it in, keep it alive) without needing the rest of this page – it is aimed at readers who want to understand why the API looks the way it does, who are storing their own data alongside a mesh, or who are debugging a use-after-move/ use-after-grow style failure.

Why a custom memory model?

EBGeometry is header-only with no external dependencies, and one of its design goals (see Running queries on a GPU) is that the exact same data – a DCEL mesh’s vertices, half-edges, and faces – can be built once on the host and then queried either on the host or on a CUDA/HIP device, without maintaining two separate representations. A std::vector, or any container that stores an absolute pointer to its own data, cannot do this: a pointer that is valid in host address space is meaningless (or outright invalid) once the same bytes are copied into device memory. Every structure built on this memory model instead stores a byte offset relative to a base address that is supplied separately, so the identical bit pattern resolves correctly against a host base and against a device base after a byte-for-byte mirror – with no pointer patching pass required.

Important

The recurring idea across every class on this page is: store an offset, not a pointer; the base is supplied by the caller. Everything else – Pool’s control block, PODVector’s two access styles, mirror() – follows from that one decision.

MemoryResource: where bytes live

MemoryResource is an abstract, runtime-virtual placement policy: it decides where a block of bytes physically lives, without its caller needing to know which. The concrete resources are below; the four backed by the GPU runtime exist only in a CUDA or HIP build:

Resource

Host-accessible

Device-accessible

Notes

HostMemoryResource

Yes

No

Always compiled; plain aligned host allocation. Obtain the process-wide default via EBGeometry::hostMemoryResource().

EmulatedDeviceMemoryResource

Yes

Reports yes

Always compiled; host memory that stands in for device memory, so code that mirrors a pool and takes a rebasedView() runs unchanged in a plain C++ build. Reach it through GPU::memoryResource() (Running queries on a GPU), never hand it to a real kernel.

DeviceMemoryResource

No

Yes

CUDA/HIP builds only. A host pointer dereference of a returned block is undefined behavior.

ManagedMemoryResource

Yes

Yes

CUDA/HIP builds only. One allocation reachable from both host and device, at the cost of driver-managed page migration.

PinnedMemoryResource

Yes

No

CUDA/HIP builds only. Page-locked host memory; faster/asynchronous transfers to a device than ordinary pageable memory.

MappedMemoryResource

Yes

Yes

CUDA/HIP builds only. Page-locked and mapped into the device address space – no migration, the device reads host RAM in place over the interconnect.

Allocation and deallocation are always host-only operations (memory is never obtained from device code, even for a device-resident resource), and every pool block is aligned to EBGeometry::PoolBaseAlign (256 bytes), which is at least as strict as everything the library ever stores in a pool, including SIMD-width SoA blocks.

The process-wide resources (hostMemoryResource(), deviceMemoryResource() and the others) are created on first use and never destroyed, so a Pool with static storage can still free its block when the program exits. On a GPU build that free may come after the GPU runtime has shut down; the error the runtime then returns is ignored, since the memory went with it.

Note

An object built directly in a Managed or Mapped pool resolves through the pool’s host control block (see below), which a kernel cannot follow. To hand it to a kernel without a mirror, freeze the pool and call rebasedView() onto the pool itself: the view holds the pool’s base address, which both sides can use. A view rebased onto a Managed or Mapped mirror can likewise be used on the host; a view of a Device pool cannot. CustomShape builds a tape this way, and PointClouds mirrors a point cloud to device memory instead.

Note

A MemoryResource is non-copyable: its identity is meaningful (two Pools built over the same resource share a placement), and copying an allocator has no meaning. Pass it by reference, and it must outlive every Pool built over it.

Pool: the arena

A Pool is a single contiguous byte block obtained from a MemoryResource. reserve(count, elemSize, alignment) bump-allocates a sub-region and returns its byte offset from base(). Nothing is ever individually freed. If the block is too small, reserve transparently grows it: it allocates a new, larger block (geometric doubling), memcpys every byte currently in use into it, and frees the old block – so base() can change on any reserve() call. A block holds at most the largest multiple of the 256-byte base alignment that a size_t holds. An initial size passed to the constructor, or a request that with the bytes already reserved would need more, aborts in every build (see Always-on checks (EBGEOMETRY_REQUIRE)) rather than wrapping round to a smaller size.

Building and querying are not separate phases. A pool-resident object is usable from the moment its storage is reserved, including while the same pool keeps being built into, because it does not remember an address: it holds a pointer to the pool’s control block (PoolControl), a small heap-resident record holding the current base, and re-reads the base through it on every access. grow() publishes the new base there, so a block move is invisible to every object resolving through it.

EBGeometry::Pool pool(EBGeometry::hostMemoryResource());

auto first  = EBGeometry::Parser::readIntoFlatMeshSDF<T>("a.stl", pool);
const T d   = first.signedDistance(x);       // queryable immediately

auto second = EBGeometry::Parser::readIntoFlatMeshSDF<T>("b.stl", pool);   // may grow and move the block

const T same = first.signedDistance(x);      // ... which changes nothing here

The control block is also why a Pool can be moved freely – into a container, into a class member, out of a factory – without disturbing anything built into it. A move transfers ownership of the control block, whose address does not change. What a pool-resident object cannot survive is the pool being destroyed: the block dies with it, so the pool must outlive everything reserved from it.

freeze(): the mirror precondition

freeze() seals a pool: reserve() is forbidden afterwards (it aborts in every build, see Always-on checks (EBGEOMETRY_REQUIRE)) and base() can no longer move. It is the precondition for mirror(), since you cannot take a byte-for-byte copy of a block that might still be reallocated, and for handing a pool built in managed or mapped memory to a device (a rebasedView() onto it, or GPU::transform over it), since a kernel holds the base by value. Freezing is not required to query anything on the host.

mirror(src, dstResource) copies a frozen source pool’s entire block, byte-for-byte, into a fresh block from dstResource (a plain memcpy for a host-to-host copy, or a GPU::memcpy for a host/device transfer), and returns the result already frozen. Because every offset stored inside the block is relative rather than absolute, the copy is immediately usable against its own (possibly device-resident) base() – this is the host-to-device upload, and, used host-to-host, an exact independent copy. A mirror records the identity of the pool it ultimately came from (mirrorOf()), which is what lets an object built in the original pool verify that it is being rebased onto a faithful copy of its own storage; mirroring through intermediate pools (host to pinned staging to device) preserves that identity across every hop.

Warning

freeze() has no inverse. Once a pool is frozen it can never be reserved into again. Since freezing is only needed to mirror a pool or hand it to a device, the practical rule is simply to do it last.

Resolved addresses do not survive a reserve

The control block protects anything that re-resolves. It cannot protect an address that has already been resolved, and a reserve that grows the pool deallocates the block such an address points into:

auto& e = mesh.getEdge(3);       // a raw address, resolved right now
mesh.reserveFaces(pool, n);      // may grow -- the old block is freed
e.setFace(7);                    // undefined behavior: write into freed memory

The hazard is intermittent (a grow only happens when a request exceeds the current capacity) and its quiet form is worse than a crash: if the freed block is still mapped, the write lands in the abandoned copy and is silently lost, while the object itself now reads from the new block.

Important

Resolve, use, discard. A pointer, reference or PODSpan obtained from pool storage must not outlive the next reserve() on that pool. To carry data across a reserve, carry it by value and write it back through a fresh resolution:

Edge e = mesh.getEdge(3);       // snapshot, independent of any base
mesh.reserveFaces(pool, n);     // may grow
e.setFace(7);
mesh.getEdge(3) = e;            // fresh resolution against the current base

Everything that returns by value – signedDistance(), getAllVertexCoordinates(), and so on – is unaffected, which is the overwhelming majority of the query surface.

Note

For the same reason, querying an object while another thread reserves from the same pool is a data race: the reader may observe a base being republished, or have the block reallocated underneath it. Pools are not internally synchronized. Finish building before sharing a pool across threads, or serialize the reserves.

Note

Pool is move-only, RAII, and deliberately not trivially copyable – it owns a block, a control block and a MemoryResource* with real lifetime semantics. This is by design: it is one of only two non-trivial types anywhere in this memory model (MemoryResource is the other). Everything stored inside a Pool – every PODVector, and every structure built entirely from PODVectors and plain values – is trivially copyable POD; the Pool itself never is, and is never mirrored to a device or passed to device code directly.

PODVector and PODSpan: placement-independent storage

A PODVector<T> is a trivially-copyable, 16-byte descriptor (uint64_t offset + uint32_t size + uint32_t capacity – no pointer at all) of a contiguous array of T living inside a Pool. T itself must be trivially copyable, since it is device-visible storage.

EBGeometry::PODVector<MyPodType> vec;

vec.reserveFrom(pool, 128);              // must not be frozen
vec.push_back(pool.base(), someValue);   // never reallocates -- capacity is fixed at reserveFrom()

Two access styles are provided, and choosing between them is the main day-to-day decision this memory model asks of a caller:

Style

Methods

When to use it

Resolve every call

at(base, i), data(base)

The default. Resolves base + offset fresh on every call, so it is correct while the pool is still being built into, after freeze(), and against any base you have in hand – host or (mirrored) device. Costs one extra addition per access.

Bind once

bind(base) -> PODSpan<T>

A raw pointer + size pair for a genuinely hot inner loop (SIMD traversal, a tight BVH query), where re-deriving the pointer every iteration is measurable overhead. Safe only for as long as no further reserve() happens on the pool.

Important

PODSpan<T> (returned by bind()) captures a raw pointer, so it is exactly the resolved-address hazard of Resolved addresses do not survive a reserve in container form: it must not outlive the next reserve(). Taking one against a frozen (or already-mirrored) pool removes the question entirely, since such a pool can never be reserved into again. Prefer at()/data() unless you have measured that the resolve-every-call cost actually matters.

pool.freeze();

const auto span = vec.bind(pool.base());   // safe: pool is frozen, base is stable

for (const auto& v : span) { ... }          // hot loop, no per-element re-resolution

Tip

If a type you are adding needs to be part of this memory model (stored inside a Pool, mirrored to a device), give it only PODVector members and other plain, trivially-copyable values – never a raw pointer or a container that owns its own allocation. A static_assert(std::is_trivially_copyable_v<...>) at the bottom of the class, matching the one on every class described here, is cheap insurance that a later change does not silently break the contract.

These classes adopt this model: DCEL::MeshT, BVH::PackedBVH, the mesh distance fields FlatMeshSDF, MeshSDF and TriMeshSDF, PointCloudBVH, PointCloudHashGrid, the BVH unions BVHUnion and BVHSmoothUnion, Tape, and a fused composition (Fused::) with an operand that lives in a pool. Each is split into two parts:

  • a layout: its PODVector descriptors and plain values, that is offsets and sizes only, as the nested type Layout;

  • a handle: that layout plus one location, which says where the pool is. Every one of these classes is a handle: it derives from PoolHandle, which owns the single PoolLocation (a PoolControl* for host resolution, a raw base for a device view, and whether that base is host-accessible) and the operations on it. Nothing else in the library holds a location.

All of them offer rebasedView() as their single crossing point, deepCopy() for genuinely independent storage, layout(), and a constructor taking a layout and a location. A composite handle holds the layouts of its parts and one location for all of them: MeshSDF holds a mesh layout and a BVH layout, a BVH union holds the layouts of its two BVHs, and each rebuilds a temporary inner handle when it needs one.

The split exists for one case rebasedView() cannot handle by itself: a pool-resident object stored inside another object’s pool, such as a TriMeshSDF held in the primitive array of a BVH union (BVH-accelerated unions). Mirroring the pool copies whatever is stored there byte for byte, and a stored location would be wrong in the mirror – on a device mirror it could not even be rewritten. So nested storage holds the layout, never the handle: a BVH union of TriMeshSDF stores TriMeshSDF::Layout, and as it evaluates each primitive it rebuilds a TriMeshSDF from the stored layout and the union’s own location. That is sound because both were reserved from the same pool, which the union checks when it is built, and it holds in every mirror, since a layout contains no address. A container can tell the two apart at compile time with isPoolHandle<P>, so a handle stored where a layout belongs is a compile error rather than a wrong answer on a device.

One consequence worth stating plainly, because it changes what familiar code means: copying a pool-resident object copies descriptors, not data. The copy resolves against the same pool memory as the original, so writing through one is visible through the other. That is exactly what makes the type trivially copyable, and therefore what lets a rebased view be byte-copied into a device address space – but it means a copy constructor is not a deep copy. Use deepCopy(Pool&) when independent storage is what you want.

See DCEL for how DCEL::MeshT is built on top of Pool/PODVector, including the pitfalls specific to it, and BVH for BVH::PackedBVH.