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 |
|---|---|---|---|
Yes |
No |
Always compiled; plain aligned host allocation. Obtain the process-wide default via
|
|
Yes |
Reports yes |
Always compiled; host memory that stands in for device memory, so code that mirrors a pool
and takes a |
|
No |
Yes |
CUDA/HIP builds only. A host pointer dereference of a returned block is undefined behavior. |
|
Yes |
Yes |
CUDA/HIP builds only. One allocation reachable from both host and device, at the cost of driver-managed page migration. |
|
Yes |
No |
CUDA/HIP builds only. Page-locked host memory; faster/asynchronous transfers to a device than ordinary pageable memory. |
|
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 |
|
The default. Resolves |
Bind once |
|
A raw |
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
PODVectordescriptors and plain values, that is offsets and sizes only, as the nested typeLayout;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.