|
| 1 | +package sk.ainet.lang.tensor.scratch |
| 2 | + |
| 3 | +/** |
| 4 | + * Pool of reusable [FloatArray] scratch buffers, scoped to a single forward |
| 5 | + * (or backward) pass. |
| 6 | + * |
| 7 | + * Workspace allocation is generic across nn workloads — attention, RoPE, |
| 8 | + * convolutions, embedding gathers, training-time gradient buffers all need |
| 9 | + * short-lived intermediates. Routing those through a pool eliminates per-step |
| 10 | + * allocation pressure on the GC heap. |
| 11 | + * |
| 12 | + * Typical lifecycle: |
| 13 | + * |
| 14 | + * 1. The runtime owns a [ScratchPool] (one per forward-running thread). |
| 15 | + * 2. The runtime opens a [scope] block around each forward pass. |
| 16 | + * 3. Layers acquire buffers via [acquireFloat] / [acquireFloatZeroed] from |
| 17 | + * the [sk.ainet.context.ExecutionContext.scratch] field. |
| 18 | + * 4. On scope exit every buffer acquired in the scope is recycled to the |
| 19 | + * per-size-class free list for the next pass. |
| 20 | + * |
| 21 | + * Buffers may be larger than the requested size — callers must always index |
| 22 | + * within the size they asked for, not `buf.size`. This is the standard |
| 23 | + * scratch-buffer contract: external `outSize`/`n`/`stride` variables drive |
| 24 | + * iteration; the array's own `.size` is an implementation detail. |
| 25 | + * |
| 26 | + * Acquires made *outside* an active scope are not tracked and become regular |
| 27 | + * allocations (the buffer is not returned to the pool when the caller drops |
| 28 | + * it). This is intentional — it lets the no-op pool implementation avoid |
| 29 | + * any scope bookkeeping. |
| 30 | + * |
| 31 | + * Single-threaded by intent: one forward pass at a time per pool. |
| 32 | + * Concurrent forwards must use separate pools. |
| 33 | + */ |
| 34 | +public interface ScratchPool { |
| 35 | + /** |
| 36 | + * Acquire a [FloatArray] with at least [minSize] elements. Contents are |
| 37 | + * unspecified — callers must overwrite the range they read. |
| 38 | + */ |
| 39 | + public fun acquireFloat(minSize: Int): FloatArray |
| 40 | + |
| 41 | + /** |
| 42 | + * Acquire a [FloatArray] with at least [minSize] elements, with the |
| 43 | + * range `[0, minSize)` zero-filled. Use when the caller relies on |
| 44 | + * sparse-write zeros (e.g. padding a smaller block into a larger |
| 45 | + * destination and leaving the gap implicit). |
| 46 | + */ |
| 47 | + public fun acquireFloatZeroed(minSize: Int): FloatArray |
| 48 | + |
| 49 | + /** |
| 50 | + * Open a forward-pass scope. All buffers acquired inside [block] are |
| 51 | + * recycled at exit. Scopes may be nested; each open/close balances. |
| 52 | + */ |
| 53 | + public fun <R> scope(block: () -> R): R |
| 54 | + |
| 55 | + /** Stats for benchmarking and leak detection. */ |
| 56 | + public fun stats(): ScratchStats |
| 57 | +} |
| 58 | + |
| 59 | +public data class ScratchStats( |
| 60 | + val acquireCount: Long, |
| 61 | + val cacheHits: Long, |
| 62 | + val highWaterBytes: Long, |
| 63 | + val activeBuffers: Int |
| 64 | +) |
| 65 | + |
| 66 | +/** |
| 67 | + * No-op pool: every [acquireFloat] / [acquireFloatZeroed] allocates a fresh |
| 68 | + * array; [scope] just runs the block. Default carrier returned by |
| 69 | + * [sk.ainet.context.ExecutionContext.scratch] when no pooling is configured — |
| 70 | + * preserves pre-pool behavior bit-for-bit. |
| 71 | + */ |
| 72 | +public object NoopScratchPool : ScratchPool { |
| 73 | + override fun acquireFloat(minSize: Int): FloatArray = FloatArray(minSize) |
| 74 | + override fun acquireFloatZeroed(minSize: Int): FloatArray = FloatArray(minSize) |
| 75 | + override fun <R> scope(block: () -> R): R = block() |
| 76 | + override fun stats(): ScratchStats = ScratchStats(0L, 0L, 0L, 0) |
| 77 | +} |
| 78 | + |
| 79 | +/** |
| 80 | + * Size-classed slab pool with power-of-two buckets starting at 64 floats. |
| 81 | + * |
| 82 | + * Sizes round up to the next power of two: `1..64 → 64`, `65..128 → 128`, |
| 83 | + * ... up to [MAX_CLASSES] classes (`64 * 2^19 ≈ 33M floats` = 128 MB at the |
| 84 | + * top of the range). Up to [maxBuffersPerClass] buffers are retained per |
| 85 | + * class; surplus buffers are dropped to GC at scope exit. |
| 86 | + * |
| 87 | + * Choice rationale: hotspot sizes in nn workloads are model-derived and |
| 88 | + * predictable (head_dim, seq_len, n_heads). Power-of-two slabs cap |
| 89 | + * fragmentation at 2× per buffer with no hash-map churn — strictly better |
| 90 | + * than a free-list-by-exact-size for these access patterns. |
| 91 | + */ |
| 92 | +public class SizeClassedScratchPool( |
| 93 | + private val maxBuffersPerClass: Int = 8 |
| 94 | +) : ScratchPool { |
| 95 | + |
| 96 | + private val classes: Array<ArrayDeque<FloatArray>> = |
| 97 | + Array(MAX_CLASSES) { ArrayDeque() } |
| 98 | + |
| 99 | + /** Stack of scope frames; each frame is the list of buffers acquired in |
| 100 | + * that scope. `addLast` on enter, drain on exit. */ |
| 101 | + private val scopeStack: ArrayDeque<ArrayDeque<FloatArray>> = ArrayDeque() |
| 102 | + |
| 103 | + private var acquireCount: Long = 0L |
| 104 | + private var cacheHits: Long = 0L |
| 105 | + private var highWaterBytes: Long = 0L |
| 106 | + private var currentBytes: Long = 0L |
| 107 | + |
| 108 | + override fun acquireFloat(minSize: Int): FloatArray { |
| 109 | + val cls = sizeClass(minSize) |
| 110 | + val cache = classes[cls] |
| 111 | + acquireCount++ |
| 112 | + val buf = if (cache.isNotEmpty()) { |
| 113 | + cacheHits++ |
| 114 | + cache.removeLast() |
| 115 | + } else { |
| 116 | + FloatArray(sizeForClass(cls)) |
| 117 | + } |
| 118 | + scopeStack.lastOrNull()?.addLast(buf) |
| 119 | + currentBytes += buf.size.toLong() * 4L |
| 120 | + if (currentBytes > highWaterBytes) highWaterBytes = currentBytes |
| 121 | + return buf |
| 122 | + } |
| 123 | + |
| 124 | + override fun acquireFloatZeroed(minSize: Int): FloatArray { |
| 125 | + val buf = acquireFloat(minSize) |
| 126 | + buf.fill(0f, 0, minSize) |
| 127 | + return buf |
| 128 | + } |
| 129 | + |
| 130 | + override fun <R> scope(block: () -> R): R { |
| 131 | + val frame: ArrayDeque<FloatArray> = ArrayDeque() |
| 132 | + scopeStack.addLast(frame) |
| 133 | + try { |
| 134 | + return block() |
| 135 | + } finally { |
| 136 | + scopeStack.removeLast() |
| 137 | + for (buf in frame) returnToCache(buf) |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + override fun stats(): ScratchStats { |
| 142 | + var active = 0 |
| 143 | + for (frame in scopeStack) active += frame.size |
| 144 | + return ScratchStats(acquireCount, cacheHits, highWaterBytes, active) |
| 145 | + } |
| 146 | + |
| 147 | + private fun returnToCache(buf: FloatArray) { |
| 148 | + currentBytes -= buf.size.toLong() * 4L |
| 149 | + val cls = sizeClass(buf.size) |
| 150 | + val cache = classes[cls] |
| 151 | + if (cache.size < maxBuffersPerClass) cache.addLast(buf) |
| 152 | + } |
| 153 | + |
| 154 | + public companion object { |
| 155 | + public const val MIN_SIZE: Int = 64 |
| 156 | + public const val LOG_MIN_SIZE: Int = 6 |
| 157 | + public const val MAX_CLASSES: Int = 20 |
| 158 | + |
| 159 | + /** |
| 160 | + * Bucket index for a request of [minSize] floats. Returns 0 for any |
| 161 | + * `minSize <= MIN_SIZE`; otherwise the smallest class whose size is |
| 162 | + * `>= minSize`. Capped at [MAX_CLASSES] - 1 — requests beyond that |
| 163 | + * still allocate an array of the requested rounded size, but reuse |
| 164 | + * is bounded by the top class. |
| 165 | + */ |
| 166 | + public fun sizeClass(minSize: Int): Int { |
| 167 | + if (minSize <= MIN_SIZE) return 0 |
| 168 | + val cls = (32 - (minSize - 1).countLeadingZeroBits()) - LOG_MIN_SIZE |
| 169 | + return cls.coerceIn(0, MAX_CLASSES - 1) |
| 170 | + } |
| 171 | + |
| 172 | + /** Floats in bucket [cls]. */ |
| 173 | + public fun sizeForClass(cls: Int): Int = MIN_SIZE shl cls |
| 174 | + } |
| 175 | +} |
0 commit comments