NanoComputeShader.kt
src/jvmMain/kotlin/afield/lang/nano/NanoComputeShader.kt · 200 lines
package afield.lang.nano
import java.nio.ByteBuffer
import java.nio.ByteOrder
The host side of nano_compute.glsl, the GLSL compute port of the Nano VM: the buffer layout a caller has to honour,
the mapping struct, and the packing helpers for getting bytes in and out.
The shader runs one independent Nano VM instance per GPU invocation, decoding the same opcode map NanoVM.kt
documents. Three of its four storage buffers hold bytes and are cut into non-overlapping per-instance slices; the
fourth, at binding 0, holds one NanoMapping per dispatch id and says where that instance's three slices begin.
Nothing in the tree dispatches it. SOURCE reads the GLSL off the classpath and the helpers below build the
buffers, so what is missing is the Vulkan plumbing: pipeline, descriptor sets, submit and readback.
Buffer bindings
| binding | layout | access | description |
|---|---|---|---|
| 0 | NanoMapping[] |
r-only | dispatch-id → slice offsets |
| 1 | uint[] (bytes) |
r/w | RAM: persistent working memory |
| 2 | uint[] (bytes) |
r-only | code: assembled Nano programs |
| 3 | uint[] (bytes) |
r/w | OUT stream: results of OUT rX |
Byte-packing
All three byte buffers pack 4 bytes per uint: the byte at logical offset B lives in word B >> 2 at bit-shift (B
& 3) << 3. A host has to honour that when uploading code and RAM and when reading OUT results back, which is what
packBytes, packPrograms and unpackBytes are for.
Push constants (20 bytes, std430)
| field | type | description |
|---|---|---|
| numInstances | uint32 | total instances (over-dispatch guard) |
| defaultMaxSteps | uint32 | step limit when mapping.maxSteps == 0 |
| ramSize | uint32 | RAM bytes per instance; must be power-of-2 |
| codeSize | uint32 | code bytes per instance (≤ 256) |
Mapping buffer layout (NanoMapping struct, 20 bytes each)
| field | type | description |
|---|---|---|
| ramBase | uint32 | byte offset into RAM buffer for this instance |
| codeBase | uint32 | byte offset into code buffer for this instance |
| outputBase | uint32 | byte offset into OUT buffer for this instance |
| maxSteps | uint32 | per-instance step cap (0 → use push-constant) |
| outputCapacity | uint32 | maximum OUT bytes; overflow halts this VM |
Minimal dispatch example (pseudocode)
val N = 1024 // number of Nano instances
val ramSize = 32 // bytes per VM
val codeSize = 64 // bytes per program
val outSize = 64 // max OUT bytes per VM
val mappings = NanoComputeShader.buildMappings(N, ramSize, codeSize, outSize)
// upload mappings, ramBytes (N*ramSize), codeBytes (N*codeSize), outBytes (N*outSize)
// bind as buffers 0..3
// dispatch ceil(N / 64) groups of 64
object NanoComputeShader
The GLSL source, the mapping-buffer builder and the byte-packing helpers, and nothing that talks to a device.
object NanoComputeShader {
val SOURCE: String by lazy
Source of the nano_compute.glsl compute shader.
val SOURCE: String by lazy {
NanoComputeShader::class.java.getResourceAsStream("/nano_compute.glsl")?.bufferedReader()?.readText()
?: error("nano_compute.glsl not found on classpath")
}
Mapping buffer
data class NanoMapping
Kotlin mirror of the NanoMapping GPU struct (20 bytes, std430).
@param ramBase byte offset into the RAM buffer for this instance @param codeBase byte offset into the code buffer for this instance @param outputBase byte offset into the OUT buffer for this instance @param maxSteps per-instance step cap; 0 means use the push-constant default @param outputCapacity maximum OUT bytes; zero disables output, overflow halts the VM
data class NanoMapping(
val ramBase: Int,
val codeBase: Int,
val outputBase: Int,
val maxSteps: Int = 0,
val outputCapacity: Int = 0,
)
fun buildMappings
Build a flat ByteBuffer containing one NanoMapping per instance, with each instance allocated contiguous, non-overlapping slices.
Slice layout (zero-based):
- RAM : i * ramSize … i * ramSize + ramSize - 1
- code : i * codeSize … i * codeSize + codeSize - 1
- OUT : i * outSize … i * outSize + outSize - 1
@param numInstances number of VM instances @param ramSize RAM bytes per instance (must be power of 2, typically 32) @param codeSize code bytes per instance (typically 64) @param outSize OUT-stream bytes allocated per instance @param maxSteps per-instance step cap (0 → use push-constant default)
fun buildMappings(
numInstances: Int,
ramSize: Int = NanoVM.RAM_SIZE,
codeSize: Int = 64,
outSize: Int = 64,
maxSteps: Int = 0,
): ByteBuffer {
require(numInstances > 0) { "numInstances must be > 0" }
require(ramSize > 0 && (ramSize and (ramSize - 1)) == 0) {
"ramSize must be a power of 2 (got $ramSize)"
}
require(codeSize > 0) { "codeSize must be > 0" }
require(outSize >= 0) { "outSize must be >= 0" }
require(maxSteps >= 0) { "maxSteps must be >= 0" }
// Check offsets before allocation; signed Int offsets cannot represent larger slices.
Math.multiplyExact(numInstances, ramSize)
Math.multiplyExact(numInstances, codeSize)
Math.multiplyExact(numInstances, outSize)
// Each NanoMapping = 5 × uint32 = 20 bytes
val buf = ByteBuffer.allocate(Math.multiplyExact(numInstances, 20)).order(ByteOrder.LITTLE_ENDIAN)
for (i in 0 until numInstances) {
buf.putInt(i * ramSize) // ramBase
buf.putInt(i * codeSize) // codeBase
buf.putInt(i * outSize) // outputBase
buf.putInt(maxSteps) // maxSteps
buf.putInt(outSize) // outputCapacity
}
buf.flip()
return buf
}
Byte-packing helpers
fun packBytes(bytes: ByteArray): ByteBuffer
Pack bytes into a uint[]-compatible ByteBuffer using the shader's 4-bytes-per-uint little-endian layout.
The returned buffer is ready for upload to a Vulkan SSBO. Its capacity is ceil(bytes.size / 4) * 4 bytes.
fun packBytes(bytes: ByteArray): ByteBuffer {
val wordCount = (bytes.size + 3) / 4
val buf = ByteBuffer.allocate(wordCount * 4).order(ByteOrder.LITTLE_ENDIAN)
buf.put(bytes)
// zero-pad to word boundary (ByteBuffer.allocate already zero-fills)
buf.flip()
return buf
}
fun unpackBytes(buf: ByteBuffer, baseOffset: Int, length: Int): ByteArray
Unpack length bytes from a uint[]-packed ByteBuffer starting at byte offset baseOffset.
Use this to read back RAM or OUT results after a dispatch.
fun unpackBytes(buf: ByteBuffer, baseOffset: Int, length: Int): ByteArray {
require(baseOffset >= 0 && baseOffset + length <= buf.limit()) {
"range [$baseOffset, ${baseOffset + length}) out of buffer bounds (limit=${buf.limit()})"
}
val out = ByteArray(length)
val view = buf.duplicate().order(ByteOrder.LITTLE_ENDIAN)
view.position(baseOffset)
view.get(out)
return out
}
fun packPrograms
Pack programs (each up to codeSize bytes) into a single flat ByteBuffer ready for binding as the code SSBO.
Programs shorter than codeSize are zero-padded (byte 0x00 = NOP).
fun packPrograms(
programs: List<ByteArray>,
codeSize: Int = 64,
): ByteBuffer {
val wordCount = (programs.size * codeSize + 3) / 4
val buf = ByteBuffer.allocate(wordCount * 4).order(ByteOrder.LITTLE_ENDIAN)
for (prog in programs) {
val slice = ByteArray(codeSize)
prog.copyInto(slice, endIndex = minOf(prog.size, codeSize))
buf.put(slice)
}
buf.flip()
return buf
}
Assemble convenience
fun assemble(source: String): ByteArray = nanoAssemble(source)
Assemble source with NanoAssembler and return the raw bytes, or throw NanoAssemblyException on error.
Useful for building code buffers inline.
Delegates to the top-level nanoAssemble() function in NanoInterface.kt (same package).