Platform
One header holds everything basalt_core asks the operating system for outside file and console I/O and the synchronisation primitives: virtual memory, a high-resolution clock, the CPU’s instruction-set extensions, process termination, and a handful of raw memory operations. The two halves — Windows and Linux — sit behind identical declarations.
Nothing in this layer has a policy. Reserve does not round your size, an Instant’s epoch is unspecified, and GetCpuFeatures chooses nothing for you.
Most application code never calls into it directly. An arena is the reserve/commit model with the policy attached, and VirtualArray<T> is the growable buffer built on it. Reach for the raw calls when you are building something those two do not cover.
Virtual memory: four verbs
Section titled “Virtual memory: four verbs”Address space and physical memory are separate resources, and this layer keeps them separate. Reserve claims a range of addresses and costs no memory. Commit backs part of that range with real pages. Decommit gives the pages back but keeps the addresses. Release gives the whole reservation back.
The VirtualArray<T> page works that model through with the consequences it has for a growing buffer; this page covers only what the raw calls add.
const Result<void*, MemoryError> reserved = Reserve(region_size);if (!reserved.ok){ return Error(reserved.error);}
const Result<void, MemoryError> committed = Commit(reserved.value, page_size);// ... use the committed prefix ...
Release(reserved.value, region_size);Four contracts to hold on to:
- You align, not the layer.
ReserveandCommittake the size you give them. QueryGetVirtualMemoryInfofor the page size and the reservation granularity, and round before you call. The primitives do not round for you and do not tell you when you should have. - Committed memory arrives zeroed. The operating system guarantees it, so there is no need to clear it yourself.
Releaseneeds the exact base pointer returned byReserve— not an address inside the region. Itssizeargument is used on Linux and ignored on Windows; pass the real one anyway.- The two undo operations report nothing.
DecommitandReleasereturnvoid, because a teardown failure is not something a caller can act on.ReserveandCommitreturn aResultwithMemoryError.
Reserved but uncommitted memory is not readable. Touching it faults.
Time is monotonic and relative
Section titled “Time is monotonic and relative”const Instant start = GetInstant();BuildScene(arena);const Duration elapsed = GetInstant() - start;
ConsoleWrite(ConsoleStream::Stdout, Str8Format(arena, "%llu ms\n", elapsed.Milliseconds()));An Instant is a raw counter reading with an unspecified epoch. On its own it means nothing; subtract two and you get a Duration, which is a real span in nanoseconds. Instants taken on different threads are comparable, because the clock is process-wide.
Duration’s getters divide by compile-time constants, so converting to milliseconds or seconds costs a multiply and a shift rather than a division. The single real division happens once, inside operator-, done as a whole-seconds-plus-remainder split that cannot overflow at any clock frequency a machine actually has.
GetTickFrequency exposes the raw ticks per second. It is cached after the first call and you rarely want it: Instant and Duration already do the conversion, and threading a frequency through your own code is the mistake those two types exist to prevent.
CPU features are a question you ask, not an answer you get
Section titled “CPU features are a question you ask, not an answer you get”GetCpuFeatures probes the CPU once with CPUID, caches the result, and returns it by reference. Every later call is a plain read, and concurrent readers need no synchronisation.
const CpuFeatures& features = GetCpuFeatures();if (features.avx2){ TransformWide(input, output); // lives in its own translation unit}else{ TransformScalar(input, output);}Two entries are not what they look like:
- SSE2 is absent from the struct. The x86-64 ABI guarantees it, so there is nothing to ask.
sse4_1is a prerequisite, not an option. SSE4.1 is required bybasalt_coreitself, so the field is exposed for diagnostics rather than for dispatch — there is no fallback to branch to. AVX2 is the opposite: an accelerator, never required.
The VEX- and EVEX-encoded extensions — AVX, AVX2, FMA3, F16C and AVX-512F — read true only when the operating system also saves the wide registers they use. A CPU that supports AVX2 under an OS that has not enabled that state reports avx2 false, because the first instruction would fault.
avx512_foundation covers AVX-512F only. The other AVX-512 subsets are not probed, so a false does not mean “no AVX-512 at all” for a subset that is not there.
Aborting
Section titled “Aborting”Abort terminates the process immediately, after triggering a debug break so an attached debugger stops at the call rather than at the corpse. Abort allocates nothing on the way out, including the debugger check on Linux, so it is safe to call after an out-of-memory failure.
Immediately means what it says: no destructors, no atexit handlers, no C runtime flush. Anything you needed on disk had to be there before the call. This is the path BS_ASSERT takes on a failed assertion, and the reason a release build’s exit code tells you which happened.
| Constant | Value | Meaning |
|---|---|---|
EXIT_CODE_SUCCESS |
0 | Normal termination. |
EXIT_CODE_ABORT |
1 | Abort with no explicit code. |
EXIT_CODE_ASSERT_FAILED |
2 | An assertion failed. |
TriggerDebugBreak on its own breaks into an attached debugger. With no debugger attached its behaviour is the platform’s, and may terminate the process — so it is a debugging aid, not a control-flow tool.
Raw memory and the global allocator
Section titled “Raw memory and the global allocator”MemoryZero and MemoryCopy are the byte-level operations, and MemoryCopy requires that source and destination do not overlap. There is no overlapping-move variant. A size of 0 is a no-op whatever the pointers, so copying an empty String8 or Span<T> whose data pointer is null needs no check at the call site.
AllocateBuffer, AllocateBufferZeroed and FreeBuffer are the escape hatch for a large buffer an arena is a poor fit for. The two allocating calls return memory aligned to 64 bytes, which is why they exist for SIMD-sized work rather than as a general allocator. Almost all allocation should go through an arena instead; when it should not, going through these three keeps the strategy in one place.
The allocating calls are [[nodiscard]] and can fail. A failure, or a size of 0, asserts; in a release build it returns nullptr, so check the pointer before you use it.
MemoryPoison and MemoryUnpoison mark ranges for AddressSanitizer and compile to nothing without it. The arena and VirtualArray<T> already use them on your behalf — see Arena — and you need them only if you are writing an allocator of your own.
Machine information
Section titled “Machine information”GetLogicalCoreCount returns the number of logical cores, hyper-threads included — the number you would size a thread pool from. On Windows it counts every processor group, so a machine with more than 64 logical processors reports all of them. It never returns 0. GetVirtualMemoryInfo returns the page size, the reservation granularity (64 KB on Windows in the usual case) and the large-page size, which is 0 when it is unsupported or unqueried. CACHE_LINE_SIZE is a compile-time 64, for padding a structure to avoid false sharing.
When this is the wrong tool
Section titled “When this is the wrong tool”| Situation | What to reach for |
|---|---|
| Ordinary allocation | An arena |
| A growable buffer whose addresses must not move | VirtualArray<T> |
| The date, the time of day, a timestamp to store | Not this layer — the clock is uptime-relative |
| Sleeping, or scheduling work later | Not this layer, and not in v0.1 |
| Creating threads | Not this layer — v0.1 ships the synchronisation primitives, not a thread or job API |
| Environment variables, arguments, the process list | Not this layer |
| Memory that overlaps its own destination | Not MemoryCopy — write the move yourself |
API summary
Section titled “API summary”Parameters, failure behaviour and the sanitizer’s shadow-granularity rules are documented in basalt/core/platform/Platform.h. The normative contracts are in docs/specs/platform-virtual-memory.md and docs/specs/platform-primitives.md.
Virtual memory
Section titled “Virtual memory”| Signature | Purpose |
|---|---|
Reserve(size) |
Claims address space. Returns the base, or MemoryError::OutOfMemory. |
Commit(ptr, size) |
Backs a range with zeroed physical memory. Ok, or MemoryError::OutOfMemory. |
Decommit(ptr, size) |
Returns the pages, keeps the reservation. Contents are lost. |
Release(ptr, size) |
Frees the whole reservation. ptr must be the base from Reserve. |
Timing
Section titled “Timing”| Signature | Purpose |
|---|---|
GetInstant() |
Reads the monotonic clock. The epoch is unspecified. |
end - start |
The Duration between two Instants. |
Duration::Nanoseconds() / Microseconds() / Milliseconds() / Seconds() |
Integer conversions. |
Duration::SecondsFloat() |
The same span as an f64. |
GetTickFrequency() |
Ticks per second. Cached, never zero, rarely needed. |
Machine
Section titled “Machine”| Signature | Purpose |
|---|---|
GetCpuFeatures() |
The cached feature set, by reference. Probed once. |
GetLogicalCoreCount() |
Logical cores, hyper-threads included, across every processor group. At least 1. |
GetVirtualMemoryInfo() |
page_size, reserve_granularity, large_page_size. |
CACHE_LINE_SIZE |
64. For padding against false sharing. |
Process
Section titled “Process”| Signature | Purpose |
|---|---|
Abort(exit_code) |
Terminates now. No destructors, no atexit, no flush. |
TriggerDebugBreak() |
Breaks into an attached debugger. Platform-dependent without one. |
Memory operations
Section titled “Memory operations”| Signature | Purpose |
|---|---|
MemoryZero(destination, size) |
Zero-fills a byte range. A size of 0 is a no-op. |
MemoryCopy(destination, source, size) |
Copies bytes. The ranges must not overlap. A size of 0 is a no-op. |
AllocateBuffer(size) |
A 64-byte-aligned buffer outside any arena. nullptr on failure in release. |
AllocateBufferZeroed(size) |
The same, zeroed. |
FreeBuffer(buffer) |
Frees one of the two above. |
MemoryPoison(address, size) / MemoryUnpoison(address, size) |
AddressSanitizer range marking. Empty without the sanitizer. |
Errors
Section titled “Errors”| Variant | Meaning |
|---|---|
MemoryError::OutOfMemory |
The reservation or the commit was refused. |
ErrorToString(error) |
A static description. Takes no arena, so it works under memory pressure. |