FrameArena.kt
src/jvmMain/kotlin/afield/math/linalg/arena/FrameArena.kt · 292 lines
package afield.math.linalg.arena
Per-thread scratch memory for temporary vectors, reclaimed all at once when a scope ends.
frameScratch runs a block against the current thread's FrameArena; vectors allocated inside it
become invalid when the block returns. Scopes nest, each restoring its own ArenaMark. Use an
explicit FrameArena (and close it) for short-lived threads, since FrameArena.current keeps its
native storage until the thread calls FrameArena.closeCurrent.
With debug checks on (the default), using a vector after its scope has ended, or from another thread, throws instead of reading reused memory.
import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.max
class FrameArena
Thread-confined, resettable scratch storage for generated linear-algebra values.
Handles never expose a MemorySegment or native address. This lets growth move
the backing allocation while preserving logical offsets. Debug validation is on
by default because Field code is unusually good at retaining values in callbacks.
class FrameArena(
initialCapacityBytes: Int = DEFAULT_CAPACITY_BYTES,
val debugChecks: Boolean = ARENA_CHECKS_COMPILED,
) : AutoCloseable {
internal enum class Kind(val componentCount: Int)
internal enum class Kind(val componentCount: Int) {
VEC2(2),
VEC3(3),
VEC4(4),
}
private var storageOwner: Arena = Arena.ofConfined()
private var storage: MemorySegment
One entry per 16-byte slot: (stamp.toLong() shl 32) or (kind.ordinal + 1). 0 means free. Packing stamp and kind together keeps the per-access validation to a single array load.
private var slotMetadata: LongArray
private var cursorBytes = 0
private var resetFloorStamp = 0
private var closed = false
private val ownerThreadId = if (ARENA_CHECKS_COMPILED) Thread.currentThread().threadId() else -1L
private val ownerThreadName = if (ARENA_CHECKS_COMPILED) Thread.currentThread().name else ""
private val scope = FrameScope(this)
private var markStack = LongArray(8)
private var markDepth = 0
var highWaterBytes: Int
var highWaterBytes: Int = 0
private set
var allocationCount: Long
var allocationCount: Long = 0
private set
var growthCount: Long
var growthCount: Long = 0
private set
var bytesCopiedDuringGrowth: Long
var bytesCopiedDuringGrowth: Long = 0
private set
var resetCount: Long
var resetCount: Long = 0
private set
init
init {
require(initialCapacityBytes > 0) { "initial capacity must be positive" }
val capacity = align16(initialCapacityBytes)
storage = storageOwner.allocate(capacity.toLong(), ALIGNMENT_BYTES.toLong())
slotMetadata = if (ARENA_CHECKS_COMPILED && debugChecks) LongArray(capacity / SLOT_BYTES) else LongArray(0)
}
Cached so the hot allocation path does not call MemorySegment.byteSize() on every allocation.
private var capacityBytesCached: Int = storage.byteSize().toInt()
val capacityBytes: Int
val capacityBytes: Int
get() = capacityBytesCached
val usedBytes: Int
val usedBytes: Int
get() = cursorBytes
val isClosed: Boolean
val isClosed: Boolean
get() = closed
val checksEnabled: Boolean
val checksEnabled: Boolean
get() = ARENA_CHECKS_COMPILED && debugChecks
inline fun <R> scoped(block: FrameScope.() -> R): R {
Runs a non-suspending scratch region and invalidates everything allocated in the block when it returns.
inline fun <R> scoped(block: FrameScope.() -> R): R {
val mark = mark()
return try {
block(scopeForInline())
} finally {
restore(mark)
}
}
internal fun scopeForInline(): FrameScope
@PublishedApi
internal fun scopeForInline(): FrameScope = scope
fun reset()
fun reset() {
checkAccess()
cursorBytes = 0
markDepth = 0
resetFloorStamp = if (ARENA_CHECKS_COMPILED && debugChecks) nextStamp() else 0
resetCount++
}
fun mark(): ArenaMark
fun mark(): ArenaMark {
checkAccess()
val stamp = if (ARENA_CHECKS_COMPILED && debugChecks) nextStamp() else 0
val token = pack(stamp, cursorBytes)
if (markDepth == markStack.size) markStack = markStack.copyOf(markStack.size * 2)
markStack[markDepth++] = token
return ArenaMark(token)
}
fun restore(mark: ArenaMark)
fun restore(mark: ArenaMark) {
checkAccess()
check(markDepth > 0 && markStack[markDepth - 1] == mark.token) {
"FrameArena marks must be restored once, in LIFO order"
}
val restoredCursor = unpackOffset(mark.token)
if (ARENA_CHECKS_COMPILED && debugChecks) {
val firstDeadSlot = restoredCursor / SLOT_BYTES
val lastUsedSlot = (cursorBytes + SLOT_BYTES - 1) / SLOT_BYTES
slotMetadata.fill(0L, firstDeadSlot, lastUsedSlot)
}
cursorBytes = restoredCursor
markDepth--
}
internal fun allocateVector(kind: Kind): Long
internal fun allocateVector(kind: Kind): Long {
checkAccess()
val offset = align16(cursorBytes)
val required = offset + SLOT_BYTES
ensureCapacity(required)
val stamp = if (ARENA_CHECKS_COMPILED && debugChecks) nextStamp() else 0
if (ARENA_CHECKS_COMPILED && debugChecks) {
slotMetadata[offset / SLOT_BYTES] = packSlotMetadata(stamp, kind)
}
cursorBytes = required
highWaterBytes = max(highWaterBytes, cursorBytes)
allocationCount++
if (ARENA_CHECKS_COMPILED && debugChecks) {
for (component in 0 until 4) {
storage.set(ValueLayout.JAVA_FLOAT, offset.toLong() + component * Float.SIZE_BYTES, Float.NaN)
}
}
return pack(stamp, offset)
}
internal fun read(token: Long, component: Int, expectedKind: Kind): Float
internal fun read(token: Long, component: Int, expectedKind: Kind): Float {
val offset = validate(token, component, expectedKind)
return storage.get(ValueLayout.JAVA_FLOAT, offset.toLong() + component * Float.SIZE_BYTES)
}
internal fun write(token: Long, component: Int, value: Float, expectedKind: Kind)
internal fun write(token: Long, component: Int, value: Float, expectedKind: Kind) {
val offset = validate(token, component, expectedKind)
storage.set(ValueLayout.JAVA_FLOAT, offset.toLong() + component * Float.SIZE_BYTES, value)
}
internal fun resolve(token: Long, expectedKind: Kind): Int
Validates a handle once and returns its byte offset. Multi-component operations resolve each input and the destination a single time, then use readAt/writeAt, instead of re-validating on every component. The component index is a generated constant in that case, so the per-component range check is unnecessary.
internal fun resolve(token: Long, expectedKind: Kind): Int {
val offset = unpackOffset(token)
if (!ARENA_CHECKS_COMPILED || !debugChecks) return offset
// Thread-confinement and closed-state are enforced by the confined FFM arena on the get/set
// that follows (WrongThreadException / IllegalStateException); this path only checks the
// arena-specific logic the segment cannot know: liveness after reset, slot ownership, kind.
val stamp = unpackStamp(token)
check(stamp > resetFloorStamp) {
"${expectedKind.name} handle was used after FrameArena.reset()"
}
check(offset >= 0 && offset + SLOT_BYTES <= cursorBytes && offset % SLOT_BYTES == 0) {
"${expectedKind.name} handle points outside live FrameArena storage: offset=$offset used=$cursorBytes"
}
val metadata = slotMetadata[offset / SLOT_BYTES]
val currentStamp = (metadata ushr 32).toInt()
check(currentStamp == stamp) {
"${expectedKind.name} handle is stale or belongs to another FrameArena " +
"(handleStamp=$stamp, slotStamp=$currentStamp, offset=$offset)"
}
val currentKind = (metadata.toInt() and 0xff) - 1
check(currentKind == expectedKind.ordinal) {
"Arena handle type mismatch: expected ${expectedKind.name}, slot contains " +
Kind.entries.getOrNull(currentKind)?.name
}
return offset
}
internal fun readAt(offset: Int, component: Int): Float
internal fun readAt(offset: Int, component: Int): Float =
storage.get(ValueLayout.JAVA_FLOAT, offset.toLong() + component * Float.SIZE_BYTES)
internal fun writeAt(offset: Int, component: Int, value: Float)
internal fun writeAt(offset: Int, component: Int, value: Float) =
storage.set(ValueLayout.JAVA_FLOAT, offset.toLong() + component * Float.SIZE_BYTES, value)
private fun validate(token: Long, component: Int, expectedKind: Kind): Int {
if (ARENA_CHECKS_COMPILED && debugChecks) {
require(component in 0 until expectedKind.componentCount) {
"component $component outside ${expectedKind.name}"
}
}
return resolve(token, expectedKind)
}
private fun ensureCapacity(requiredBytes: Int) {
if (requiredBytes <= capacityBytes) return
val newCapacity = align16(max(requiredBytes, Math.multiplyExact(capacityBytes, 2)))
val newOwner = Arena.ofConfined()
val newStorage = newOwner.allocate(newCapacity.toLong(), ALIGNMENT_BYTES.toLong())
MemorySegment.copy(storage, 0, newStorage, 0, cursorBytes.toLong())
bytesCopiedDuringGrowth += cursorBytes
val oldOwner = storageOwner
storageOwner = newOwner
storage = newStorage
capacityBytesCached = newCapacity
if (ARENA_CHECKS_COMPILED && debugChecks) {
slotMetadata = slotMetadata.copyOf(newCapacity / SLOT_BYTES)
}
oldOwner.close()
growthCount++
}
private fun checkAccess() {
if (!ARENA_CHECKS_COMPILED || !debugChecks) return
check(!closed) { "FrameArena is closed" }
val current = Thread.currentThread()
check(current.threadId() == ownerThreadId) {
"FrameArena owned by '$ownerThreadName' was accessed from '${current.name}'"
}
}
override fun close()
override fun close() {
if (closed) return
checkAccess()
closed = true
storageOwner.close()
}
companion object
const val DEFAULT_CAPACITY_BYTES: Int = 64 * 1024
val checksCompiledIn: Boolean
val checksCompiledIn: Boolean
get() = ARENA_CHECKS_COMPILED
private const val SLOT_BYTES = 16
private const val ALIGNMENT_BYTES = 16
private val globalStamp: AtomicInteger? = if (ARENA_CHECKS_COMPILED) AtomicInteger(0) else null
private val currentArena = ThreadLocal<FrameArena?>()
fun current(): FrameArena
Scratch arena owned by the current platform thread. This is intended for stable render, update, and worker threads; prefer an explicit FrameArena for short-lived virtual threads so each task does not retain native memory.
fun current(): FrameArena = currentArena.get()
?.takeUnless { it.isClosed }
?: FrameArena().also(currentArena::set)
fun closeCurrent()
Releases native storage retained by current on this thread.
fun closeCurrent() {
currentArena.get()?.close()
currentArena.remove()
}
private fun nextStamp(): Int = checkNotNull(globalStamp).incrementAndGet().also {
check(it > 0) { "FrameArena allocation stamp space exhausted" }
}
private fun pack(stamp: Int, offset: Int): Long =
(stamp.toLong() shl 32) or (offset.toLong() and 0xffff_ffffL)
private fun unpackStamp(token: Long): Int = (token ushr 32).toInt()
private fun unpackOffset(token: Long): Int = token.toInt()
private fun packSlotMetadata(stamp: Int, kind: Kind): Long =
(stamp.toLong() shl 32) or (kind.ordinal + 1).toLong()
private fun align16(value: Int): Int = (value + 15) and -16
}
}
value class ArenaMark internal constructor(internal val token: Long)
@JvmInline
value class ArenaMark internal constructor(internal val token: Long)
inline fun <R> frameScratch(block: FrameScope.() -> R): R = FrameArena.current().scoped(block)
Runs an allocation-free, non-suspending scratch region on this platform thread. Nested calls are supported and restore their arena marks in LIFO order.