|
Deki 0.19.0
Modular C++ 2D engine for embedded displays and desktop
|
Every allocation in code that runs on a device goes through the engine. No new[], no malloc, no heap_caps_* in the engine's runtime code or in a package's. Deki::Memory is the orchestrator; Deki::Buffer<T> is what you almost always want.
This is not a style preference, and it is enforced by tools/ci/check_allocations.py. Three reasons it has to hold:
<deki/providers/Buffer.h>. An owning array that allocates through Deki::Memory and frees itself.
The one thing not automated is whether the memory was there: check if (!buffer) once, where the buffer is created, instead of unwinding on every branch after it.
A frame arena would remove the failure handling too, but it has to be sized up front. One large enough for a single full-screen gradient bake reserves 150 KB whether or not a project has a gradient, which on a part with a few hundred KB of heap is half the memory held against a feature that may not exist. Arenas pay off where there is headroom. Most allocations here are long-lived and owned by something anyway - glyph tables, pixel data, particle columns, framebuffers which is what RAII serves and an arena does not.
<deki/providers/Memory.h>. There is no default and nothing is inferred. Every allocating call names a region, and omitting it does not compile.
| Region | Where it goes |
|---|---|
| Deki::Memory::Internal | Internal RAM. Fast, and the scarce one |
| Deki::Memory::External | PSRAM where the board has it. Megabytes, and markedly slower - it is reached over SPI |
Deki::Memory::AllocateDma(bytes, region) adds the constraint that a DMA engine must be able to read the block. That is orthogonal to placement: a framebuffer must be reachable whether the board keeps it internally or in PSRAM, which is why it is a separate entry point rather than a third region.
The engine ships two. The set is open:
A package keeps its regions in its own namespace, not in Deki::Memory which it could not do anyway, a class being closed, and that is the point. Two board packages that both want an Rtc constant cannot collide, because the language keeps them apart. Where the constant lives is never load-bearing: a region's identity is its hashed name, so moving one between namespaces changes nothing at all.
Region() hashes the name at compile time with the same FNV-1a the rest of the engine uses, so a region is a constant expression a provider can switch on.
Two packages naming the same region mean the same region - that is how two board packages agree on "rtc" without coordinating, exactly like two packages both requiring the spi capability. What that does not survive is two packages meaning different things by one word, so qualify names you did not invent: particles.scratch, esp32.rtc. A debug build reports two different names that hash alike, because the allocation header stores only the id.
A package can name a region; only the board's IMemoryProvider can serve it. A provider that has not heard of esp32.rtc returns nullptr and the failure names the region:
It is not substituted for another region. For Rtc in particular that matters more than usual: the point of it is surviving deep sleep, so quietly handing back ordinary RAM would lose the data at the one moment it was supposed to persist.
A target advertises the regions it has as capabilities, prefixed memory::
| Capability | Comes from |
|---|---|
| memory:internal | every platform - derived |
| memory:external | the platform's psramSize - derived |
| memory:esp32.rtc | the board's provides, because only it knows |
So a package that needs one declares it and the build refuses a target that lacks it, by name, before anything runs:
The two halves cannot drift, because Region("esp32.rtc") and "memory:esp32.rtc" are the same token with a prefix rather than two lists somebody keeps in step. Capability names are lowercase with ., -, : and _; a platform declaring "WiFi" gets a warning at load, since an exact-string match would silently never fire.
If the chosen region is full, the allocation fails and returns null. It is not retried in the other region. This is deliberate:
A failure logs what it was, how big, which region, and that no fallback was attempted. Every caller already has to handle null.
A board with no external RAM resolves everything to internal. That is not a fallback; it is the only region there is.
Deki::Memory (<deki/providers/Memory.h>), for the cases Buffer<T> does not cover:
Every allocating call takes one, and almost nobody writes one:
In a debug build the default argument captures the call site, so a failure reads:
There is no name to pass and no way to pass one. A hand-written tag is a second identity for the same allocation that someone has to keep in step with the first, and the compiler already knows the answer. Two allocations in one function are told apart by their line numbers:
It names the caller, not the engine. Buffer<T>::Allocate and Memory::Allocate both take the context and forward it rather than re-defaulting it, so the location is where you wrote the call, not Buffer.h. A test asserts exactly that, because a version that got this wrong would report a plausible-looking file on every allocation in the engine and be confidently useless.
Nothing is copied. Every member points at a string literal with static storage duration, which is what lets the debug leak table hold the pointer.
Capture is on in every build. It costs 4,272 bytes on the ESP32-S3 firmware - 0.28% of a 1.5 MB image, about 40 bytes per call site, which is cheap enough not to think about.
Define DEKI_STRIP_ALLOC_CONTEXT for a build that needs the flash back. AllocContext becomes an empty struct, no paths or function names reach the binary, and a failure reports what it was trying to do without claiming an origin:
Note the layout differs between a stripped and an unstripped build, so an engine and the packages calling it have to agree - the same constraint MemoryStats already imposes.
<deki/providers/Vector.h>. Buffer<T> covers a count you know when you allocate. When it changes and has no fixed bound, std::vector is the wrong answer on a device: push_back calls operator new, which cannot report exhaustion with exceptions off, so it aborts and the board reboots.
Deki::Vector is that shape with the one thing changed that matters here everything that can allocate returns bool:
Reach for Buffer first. Most "dynamic" data here turns out to have a bound the project already knows, and a fixed allocation cannot fragment a small heap the way repeated growth can.
These call operator new, which Deki::Buffer<T> cannot replace. On a device build the engine replaces the operators themselves (src/providers/OperatorNew.cpp), which changes two things:
These do go through the engine: operator new calls IMemoryProvider::AllocateRaw, so a board sees every allocation on the device and can substitute its heap for all of them.
What it is not is Memory::Allocate. That would put a 16-byte header before every std::string that outgrows its small-string buffer, memset memory a vector is about to overwrite, and pay the ESP32 provider's 16-byte SIMD alignment on small blocks - and ESP-IDF implements aligned allocation by over-allocating, so that is more waste than the header. AllocateRaw has none of it: natural alignment, no header, the pointer the heap returned.
Measured, 1.6M paired 48-byte allocations, engine at -O2:
| ns/op | |
|---|---|
| malloc + free | 47-52 |
| routed through the provider | 46-51 - indistinguishable |
| routed, plus DEKI_MEMORY_TRACK_RAW | 63-71 (+35%) |
So the routing is free and always on; counting the bytes is opt-in, because with no header the block size has to come from the heap and on Windows that is _msize calling HeapSize under a lock. Build with DEKI_MEMORY_TRACK_RAW while hunting where memory went, and Memory::LogStats will report what containers hold; without it that line says so rather than printing a misleading zero.
What is given up along with the header is its magic value, so a double free of container memory is as undiagnosable as it was when this path was plain malloc. Anything the engine can describe goes through Allocate and keeps the guard.
Placement is deliberately not decided here. Routing by size the way the removed Auto rule did would silently move a large std::vector into PSRAM and make a hot loop slow for reasons invisible in the code. A caller who wants external memory names Deki::Memory::External and gets Deki::Buffer; operator new cannot know, so it does not guess.
It is compiled only where __cpp_exceptions is undefined - i.e. exactly the builds where the abort problem exists. Host and editor builds keep the standard operators. The over-aligned forms (align_val_t) are left to libstdc++ so they stay a matched pair.
So: for a count that changes at runtime, use Deki::Vector<T> and handle the false. For anything whose size comes from a file, a scene, or the screen, use Deki::Buffer<T>. std::vector stays right for editor and tooling code, and on a device its exhaustion is now a logged abort rather than a silent one.
<deki/providers/IMemoryProvider.h>. A platform supplies one and hands it over before Memory::Initialize():
The interface reports external RAM (HasExternalRAM, totals, free) and allocates from each region: AllocateExternal / FreeExternal, AllocateInternal / FreeInternal, and AllocateDMA.
Every placement virtual is pure - implement all of them. A provider states what each region means on its platform, even when the answer is "the same as the others here". A desktop provider writing return malloc(size); in AllocateDMA has made a claim you can read and check.
AllocateDMA is the one that matters. A provider that could inherit it from AllocateInternal would hand a display peripheral memory it cannot reach on any part where those differ - silent corruption, with nothing in the build to say so.
GetAvailableInternalRAM is required for the same reason: a provider that answers 0 without meaning it makes the startup line report an exhausted board.
Pure virtuals occupy the same vtable slots as implemented ones, so this costs no ABI version. It stops a provider compiling until it decides.
AllocateRaw / FreeRaw / GetRawBlockSize are the fourth pair plus a query: the plain natural-alignment allocator the global operator new routes through, so a board's heap serves std::vector and scalar new as well as everything the engine places. They default to malloc/free, and GetRawBlockSize returns 0 where the platform cannot answer - which only costs accuracy in LogStats. Override both of the pair or neither.
AllocateDMA is separate from AllocateInternal because on the ESP32-S3 most internal RAM is DMA-reachable but not all of it, and a display peripheral reading a framebuffer that is not gets silent corruption rather than an error. The ESP32 provider asks for MALLOC_CAP_DMA explicitly. Whoever overrides one of AllocateInternal/FreeInternal must override both: the pair has to match (the ESP32 provider uses heap_caps_aligned_alloc with 16-byte alignment - it also lets the blit dispatcher engage the S3 SIMD kernels - and heap_caps_free).
IMemoryProvider is part of the package ABI, so its virtuals are locked by tools/ci/check_abi.py. Adding, removing or reordering one moves vtable slots and requires bumping kDekiReflectionAbiVersion in the same commit.
Fails on new[], delete[], malloc/calloc/realloc and heap_caps_* in runtime code - the engine's own as well as every package's. An allocation in the engine reaches every project on every board, where a package's reaches only the projects that installed it, so if anything the engine is the more important half; leaving it out of the first version of this lint immediately proved the point, since ProjectSettings was keeping its on-device settings chunk with a bare realloc.
Skipped: editor/, tests/, generated/, third_party/ and the engine's platforms/editor and platforms/desktop layers, all of which run on the host where exceptions are on and the heap is a desktop's. Exempt by path, because they are the bottom of the stack rather than code with permission to break the rule: providers/Memory.cpp, providers/IMemoryProvider.h, any *MemoryProvider.*, and providers/OperatorNew.cpp - which has to define operator new[] so that nothing else needs to.
Comments and string literals are blanked before matching, so a comment explaining this rule does not trip it.
A genuinely unavoidable case takes a waiver with a reason - one without a reason does not count, so a diff always shows which exceptions were argued for:
It has already earned its keep: it found a delete[] on a Deki::Memory buffer in Standard2DRenderer that a manual sweep had missed.