Skip to content

GpuShaderTraining.kt

src/jvmMain/kotlin/afield/graphics/shader/learning/GpuShaderTraining.kt · 268 lines

package afield.graphics.shader.learning

import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.buffers.*
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.device.Fence
import afield.graphics.render.lighting.LightingGpuTimer
import afield.document.context.*
import afield.graphics.render.*
import afield.graphics.scene.*
import afield.graphics.shader.*
import afield.graphics.render.Module
import afield.graphics.vulkan.targets.RenderToTexture
import afield.graphics.texture.*
import afield.graphics.vulkan.util.vkCheck
import afield.graphics.shader.fromViews
import afield.math.linalg.Rectanglef
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.*
import org.lwjgl.vulkan.NVCooperativeVector.*
import org.lwjgl.vulkan.KHRCooperativeMatrix.VK_COMPONENT_TYPE_FLOAT16_KHR
import org.lwjgl.vulkan.VK13.*
import java.io.File
import java.security.MessageDigest
import org.json.JSONObject
import kotlin.math.sqrt
import kotlin.random.Random

On-GPU training of the learned RGB function, with no CPU image or weight readback.

GpuShaderTraining is the in-renderer alternative to the offline ShaderTraining path. It needs a device with cooperative-vector training enabled and the prebuilt training shaders under build/lighting/learned-shader/; otherwise its constructor refuses and the offline path remains.

class GpuShaderTraining(val renderer: Renderer,val width: Int,val height: Int,val batchSize: Int=256,val seed: Int=42):AutoCloseable

A small reusable learned RGB function, trained directly from Field images on the graphics queue. Architecture matches LearnedShader: 16 positional features -> 32 ReLU -> 32 ReLU -> RGB. Slang differentiates the network; cooperative-vector outer products accumulate its gradients. FP32 Adam master weights/moments, loss-scaled FP16 gradients and inference matrices. The source remains caller-owned. Train/evaluate/close only between or within render-thread work. No CPU image/weight readback is used in train/evaluate. This is not a general graph ML runtime.

init

    init {
        require(renderer.device.supportsCooperativeVectorTraining) { "Cooperative-vector training is not enabled; offline ShaderTraining remains available" }
        require(width>0 && height>0 && width.toLong()*height<=16_777_216 && batchSize in 32..4096)
        for(entry in listOf("train","optimize","evaluate"))require(File("build/lighting/learned-shader/training-$entry.spv").isFile) {
            "Build optional shaders with ./gradlew buildLearnedShaders"
        }
        MemoryStack.stackPush().use { s ->
            val p=VkPhysicalDeviceCooperativeVectorPropertiesNV.calloc(s).`sType$Default`()
            VK11.vkGetPhysicalDeviceProperties2(renderer.device.physicalDevice.vkPhysicalDevice,VkPhysicalDeviceProperties2.calloc(s).`sType$Default`().pNext(p.address()))
            require(p.cooperativeVectorTrainingFloat16Accumulation()) { "FP16 cooperative-vector gradient accumulation unavailable" }
        }
    }

data class Layer(val inputs: Int,val outputs: Int,val matrixOffset: Int,val biasOffset: Int,val matrixBytes: Int)

    data class Layer(val inputs: Int,val outputs: Int,val matrixOffset: Int,val biasOffset: Int,val matrixBytes: Int)

val layers: List<Layer>

val trainingBytes: Int

    private val owned=mutableListOf<VulkanBuffer>()
    private val modules=mutableListOf<ComputeModule>()
    private val tasks=mutableMapOf<String,ComputeMesh>()
    private val parameters=mutableMapOf<String,BufferParameters>()
    private val initial: VulkanBuffer

internal val weights: VulkanBuffer

internal val gradients: VulkanBuffer

internal val optimizer: VulkanBuffer

    private val samples: VulkanBuffer
    private val pixels: VulkanBuffer

val output

    val output=RenderToTexture(renderer){this.width=this@GpuShaderTraining.width;this.height=this@GpuShaderTraining.height;useDepth=false;format=VK_FORMAT_R32G32B32A32_SFLOAT}
    private val sampler=TextureSampler(renderer,1,false,filter=VK_FILTER_NEAREST)
    private var source: HasImageView=output
    private val sourceProvider=object:HasImageViewArray,PreparesSampledImages {
        override val imageViewArray get()=listOf(source)
        override fun prepareSampledImagesFor(command: CommandBuffer) { (source as? PreparesSampledImages)?.prepareSampledImagesFor(command) }
    }
    private val gather: ComputeModule
    private val gatherTask: ComputeMesh
    private val resolve: Module
    private val timer=LightingGpuTimer(renderer)

val gpuMilliseconds get()=timer.samples.toList()

val allocatedBytes get()

    val allocatedBytes get()=owned.sumOf { it.requestedSize }+output.colorImageRefs.sumOf { it?.memorySize ?: 0 }

var steps=0;private set

var learningRate

    var learningRate=.001f
        set(value){require(value.isFinite() && value>0 && value<=.1f);field=value}
    private var initialized=false
    private var closed=false

init

body — 54 lines, from line 86
    init {
        var cursor=0
        layers=listOf(16 to 32,32 to 32,32 to 3).map { (inputs,outputs) ->
            val bytes=MemoryStack.stackPush().use { s ->
                val size=s.callocPointer(1);val info=conversion(s,inputs,outputs).pDstSize(size)
                vkCheck(vkConvertCooperativeVectorMatrixNV(renderer.device.vkDevice,info),"training matrix layout query")
                size[0].toInt().also { require(it>0) }
            }
            val m=align(cursor);val b=align(m+bytes);cursor=b+outputs*2;Layer(inputs,outputs,m,b,bytes)
        }
        trainingBytes=align(cursor)
        initial=allocate(3456,host=true)
        val random=Random(seed);initial.mapAndWrite_bytes {
            for(i in 0 until 3456)put(i,0)
            val offsets=listOf(0,1088,3200)
            layers.forEachIndexed { layer,l ->val range=sqrt(6f/(l.inputs+l.outputs))
                repeat(l.inputs*l.outputs){n->putShort(offsets[layer]+n*2,java.lang.Float.floatToFloat16((random.nextFloat()*2-1)*range))} }
        }
        weights=allocate(trainingBytes.toLong());gradients=allocate(trainingBytes.toLong())
        optimizer=allocate(trainingBytes.toLong()/2*16);samples=allocate(batchSize.toLong()*32);pixels=allocate(width.toLong()*height*16)
        val bindings=mapOf(0 to weights,1 to gradients,2 to optimizer,3 to samples,4 to pixels,5 to weights)
        for(entry in listOf("train","optimize","evaluate")) {
            val file=File("build/lighting/learned-shader/training-$entry.spv")
            check(file.isFile) { "Build optional shaders with ./gradlew buildLearnedShaders" }
            val reflection=Introspection.fromSpirv("GPU learning $entry",ShaderKind.COMPUTE_SHADER,file.readBytes())
            check(reflection.valid && reflection.pushConstants.single().members.map { it.offset }==(0..52 step 4).toList()) { "GPU training push ABI mismatch" }
            reflection.ssbo.replaceAll { b ->check(b.set==0 && b.binding in bindings);Introspected.UBO("buffer${b.binding}",b.size,b.set,b.binding,b.members) }
            val module=ComputeModule(Parameters(renderer,name="learning-$entry",computeIntrospection=reflection,ssboResolver={ requested ->
                val buffer=bindings.getValue(requested.source!!.binding)
                requested.size=buffer.requestedSize;requested.usage=buffer.usage;requested.memory=buffer.reqMask
                buffer.source=requested.store();requested.result=FrameBufferGroup(renderer,n=1,buffers=mutableListOf(buffer));parameters["$entry-${requested.name}"]=requested
                requested
            }))
            check(module.valid);modules+=module
            val p=module.pointer();val task=ComputeMesh(renderer,p);p+=entry to task;task.push.setMembers(reflection.pushConstants.single().members)
            task.push["step"]=0;task.push["rate"]=learningRate;task.push["time"]=0f;task.push["view"]=0
            task.push["count"]=batchSize;task.push["width"]=width;task.push["height"]=height;task.push["words"]=trainingBytes/4
            layers.forEachIndexed { i,l ->task.push["w$i"]=l.matrixOffset;task.push["b$i"]=l.biasOffset };tasks[entry]=task
        }
        tasks.getValue("train").x=(batchSize+63)/64;tasks.getValue("optimize").x=(trainingBytes/4+63)/64
        tasks.getValue("evaluate").apply {x=(width+7)/8;y=(height+7)/8}
        gather=ComputeModule(parameters(renderer){name="learning-sample-source";compute=GATHER
            textureResolver={fromViews(sourceProvider,it.name,sampler)} })
        check(gather.valid);val g=gather.pointer();gatherTask=ComputeMesh(renderer,g);g+="samples" to gatherTask
        gatherTask.x=(batchSize+63)/64;gatherTask.push.setMembers(gather.ci.pushConstants.single().members)
        gatherTask.push["samples"]=samples;gatherTask.push["count"]=batchSize
        resolve=Module(parameters(renderer){name="learning-resolve";+output;vertex=VERTEX;fragment=FRAGMENT;depthTest=false;depthWrite=false;blend=false})
        check(resolve.valid);val p=resolve.pointer();val quad=Mesh(renderer,p);quad.resize(3,0,p);quad.push.setMembers(resolve.fi.pushConstants.single().members)
        quad.push["pixels"]=pixels;quad.push["width"]=width;resolve.meshes["quad"]=quad;output.modules["resolve"]=p
    }
    private fun allocate(size: Long,host: Boolean=false)=VulkanBuffer(renderer,size,VK_BUFFER_USAGE_STORAGE_BUFFER_BIT or VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT or
        VK_BUFFER_USAGE_TRANSFER_SRC_BIT or VK_BUFFER_USAGE_TRANSFER_DST_BIT,
        if(host)VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT else VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
        needsDeviceAddress=true).also { owned+=it }

fun reset() { check(!closed);initialized=false;steps=0 }

Restart the deterministic initial model, including optimizer state, on the next recorded call.

    private fun initialize(command: CommandBuffer) {
        if(initialized)return
        barrier(command)
        vkCmdFillBuffer(command.vkCommandBuffer,weights.buffer,0,weights.requestedSize,0)
        vkCmdFillBuffer(command.vkCommandBuffer,optimizer.buffer,0,optimizer.requestedSize,0)
        barrier(command)
        MemoryStack.stackPush().use { s ->
            val matrixOffsets=listOf(0,1088,3200);val biasOffsets=listOf(1024,3136,3392)
            layers.forEachIndexed { i,l ->
                val info=VkConvertCooperativeVectorMatrixInfoNV.calloc(1,s);info[0].set(conversion(s,l.inputs,l.outputs))
                info.srcData().deviceAddress(initial.getGPUAddress(command)+matrixOffsets[i]);info.dstData().deviceAddress(weights.getGPUAddress(command)+l.matrixOffset)
                info.pDstSize(s.pointers(l.matrixBytes.toLong()));vkCmdConvertCooperativeVectorMatrixNV(command.vkCommandBuffer,info)
                vkCmdCopyBuffer(command.vkCommandBuffer,initial.buffer,weights.buffer,VkBufferCopy.calloc(1,s).srcOffset(biasOffsets[i].toLong())
                    .dstOffset(l.biasOffset.toLong()).size(l.outputs*2L))
            }
        }
        barrier(command);initialized=true
    }

fun train(command: CommandBuffer,image: HasImageView,parameter: Float,iterations: Int=1)

Sample the already-rendered source at this normalized parameter, then perform iterations Adam updates. Each step uses a new reproducible minibatch. Every eighth hashed pixel is held out; evaluate that subset separately when judging generalization.

    fun train(command: CommandBuffer,image: HasImageView,parameter: Float,iterations: Int=1) {
        check(!closed && !command.inRenderPassInstance);require(parameter.isFinite() && parameter in -1f..1f && iterations in 1..64)
        val data=requireNotNull(image.imageView.underlyingImage).imageData
        require(data.width.toLong()*data.height>=32 && data.usage and VK_IMAGE_USAGE_SAMPLED_BIT!=0)
        require(image.imageView.underlyingImage!!.format in setOf(VK_FORMAT_R16G16B16A16_SFLOAT,VK_FORMAT_R32G32B32A32_SFLOAT)) { "Train on linear RGBA16F/RGBA32F; keep coverage separate" }
        source=image;initialize(command);timer.begin(command,renderer.currentImage)
        repeat(iterations) {
            barrier(command);vkCmdFillBuffer(command.vkCommandBuffer,gradients.buffer,0,gradients.requestedSize,0)
            gatherTask.push["parameter"]=parameter;gatherTask.push["seed"]=seed+steps;gather.render(command,Rectanglef());barrier(command)
            tasks.getValue("train").push["step"]=steps+1;modules[0].render(command,Rectanglef());barrier(command)
            val task=tasks.getValue("optimize");task.push["step"]=steps+1;task.push["rate"]=learningRate
            modules[1].render(command,Rectanglef());barrier(command);steps++
        }
        timer.end(command,renderer.currentImage)
    }

fun evaluate(command: CommandBuffer,index: Int,parameter: Float)

Evaluate the learned function at any point in the trained parameter interval; source is not rendered.

    fun evaluate(command: CommandBuffer,index: Int,parameter: Float) {
        check(!closed);require(parameter.isFinite() && parameter in -1f..1f);initialize(command);barrier(command)
        tasks.getValue("evaluate").push["time"]=parameter;modules[2].render(command,Rectanglef());barrier(command);output.render(command,index)
    }

fun save(directory: File,sourceShaderDigest: String,fixedState: String): File

Blocking, explicit checkpoint BETWEEN frames. Converts opaque training matrices to the existing portable LearnedShader row-major format. No training-optimal layout escapes into the file. The caller records the source digest and fixed-state description it actually used. A new directory prevents a partial checkpoint from replacing a working model.

body — 41 lines, from line 187
    fun save(directory: File,sourceShaderDigest: String,fixedState: String): File {
        check(!closed && initialized && steps>0 && renderer.currentCommandBuffer==null)
        require(sourceShaderDigest.matches(Regex("[0-9a-f]{64}")) && fixedState.isNotBlank())
        require(!directory.exists() || directory.listFiles().isNullOrEmpty()) { "Choose a new model directory: $directory" }
        directory.mkdirs()
        val packed=VulkanBuffer(renderer,3456,VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT or VK_BUFFER_USAGE_TRANSFER_DST_BIT,
            VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,needsDeviceAddress=true)
        val command=CommandBuffer(renderer.commandPool,true,true);val fence=Fence(renderer.device,false)
        val bytes=ByteArray(3456)
        try {
            command.beginRecording();barrier(command);vkCmdFillBuffer(command.vkCommandBuffer,packed.buffer,0,3456,0);barrier(command)
            MemoryStack.stackPush().use { s ->
                val matrices=listOf(0,1088,3200);val biases=listOf(1024,3136,3392)
                layers.forEachIndexed { i,l ->
                    val info=VkConvertCooperativeVectorMatrixInfoNV.calloc(1,s)
                    info[0].set(conversion(s,l.inputs,l.outputs).srcSize(l.matrixBytes.toLong())
                        .srcLayout(VK_COOPERATIVE_VECTOR_MATRIX_LAYOUT_TRAINING_OPTIMAL_NV).srcStride(0)
                        .dstLayout(VK_COOPERATIVE_VECTOR_MATRIX_LAYOUT_ROW_MAJOR_NV).dstStride(l.inputs*2L)
                        .pDstSize(s.pointers(l.inputs*l.outputs*2L)))
                    info.srcData().deviceAddress(weights.getGPUAddress(command)+l.matrixOffset)
                    info.dstData().deviceAddress(packed.getGPUAddress(command)+matrices[i])
                    vkCmdConvertCooperativeVectorMatrixNV(command.vkCommandBuffer,info)
                    vkCmdCopyBuffer(command.vkCommandBuffer,weights.buffer,packed.buffer,VkBufferCopy.calloc(1,s)
                        .srcOffset(l.biasOffset.toLong()).dstOffset(biases[i].toLong()).size(l.outputs*2L))
                }
            }
            barrier(command);command.endRecording()
            MemoryStack.stackPush().use { renderer.graphQueue.submit(it.pointers(command.vkCommandBuffer),null,null,null,fence) }
            fence.fenceWait();packed.mapAndRead_bytes { get(bytes) }
        } finally { fence.cleanup();command.cleanup();packed.cleanup() }
        val values=java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN).asShortBuffer()
        require((0 until values.remaining()).all { java.lang.Float.float16ToFloat(values[it]).isFinite() }) { "Model contains nonfinite weights" }
        File(directory,"weights.f16").writeBytes(bytes)
        File(directory,"weights.json").writeText(JSONObject().put("schema",1).put("shape",listOf(16,32,32,3))
            .put("offsets",listOf(0,1024,1088,3136,3200,3392)).put("sha256",MessageDigest.getInstance("SHA-256").digest(bytes).joinToString(""){"%02x".format(it)})
            .put("sourceKind","captured-module").put("sourceShaderDigest",sourceShaderDigest).put("fixedState",fixedState)
            .put("parameterRange",listOf(-1,1)).put("steps",steps).put("seed",seed).put("batchSize",batchSize)
            .put("training","Vulkan cooperative vectors; Slang autodiff; FP32 Adam; FP16 gradients and published weights")
            .put("validation","Not measured by save; inspect held-out frames separately").toString(2)+"\n")
        return directory
    }

override fun close()

    override fun close() { if(closed)return;closed=true;renderer.cleanup+=Runnable {
        modules.forEach { it.cleanup() };gather.cleanup();resolve.cleanup();sampler.cleanup();timer.close();owned.asReversed().forEach { it.cleanup() }
        output.imageViewArray.forEach { it.imageView.cleanup() };output.colorImageRefs.forEach { it?.cleanup() };output.defaultSampler?.cleanup()
    } }

companion object

    companion object {
        private fun align(x: Int)=(x+63)/64*64
        private fun conversion(s: MemoryStack,inputs: Int,outputs: Int)=VkConvertCooperativeVectorMatrixInfoNV.calloc(s).`sType$Default`()
            .srcSize(inputs*outputs*2L).srcComponentType(VK_COMPONENT_TYPE_FLOAT16_KHR).dstComponentType(VK_COMPONENT_TYPE_FLOAT16_KHR)
            .numRows(outputs).numColumns(inputs).srcLayout(VK_COOPERATIVE_VECTOR_MATRIX_LAYOUT_ROW_MAJOR_NV).srcStride(inputs*2L)
            .dstLayout(VK_COOPERATIVE_VECTOR_MATRIX_LAYOUT_TRAINING_OPTIMAL_NV).dstStride(0)
        private fun barrier(c: CommandBuffer)=MemoryStack.stackPush().use { s ->
            vkCmdPipelineBarrier2(c.vkCommandBuffer,VkDependencyInfo.calloc(s).`sType$Default`().pMemoryBarriers(VkMemoryBarrier2.calloc(1,s).`sType$Default`()
                .srcStageMask(VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT).srcAccessMask(VK_ACCESS_2_MEMORY_READ_BIT or VK_ACCESS_2_MEMORY_WRITE_BIT)
                .dstStageMask(VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT).dstAccessMask(VK_ACCESS_2_MEMORY_READ_BIT or VK_ACCESS_2_MEMORY_WRITE_BIT)))
        }
        private const val VERTEX="""#version 460
        void main(){vec2 p=vec2((gl_VertexIndex<<1)&2,gl_VertexIndex&2);gl_Position=vec4(p*2-1,0,1);}
        """
        private const val FRAGMENT="""#version 460
        #extension GL_EXT_buffer_reference : require
        layout(buffer_reference,std430,buffer_reference_align=16) readonly buffer Pixels {vec4 data[];};
        layout(push_constant) uniform P {Pixels pixels;uint width;} p;
        layout(location=0) out vec4 color;void main(){uvec2 xy=uvec2(gl_FragCoord.xy);color=p.pixels.data[xy.y*p.width+xy.x];}
        """
        private const val GATHER="""#version 460
        #extension GL_EXT_buffer_reference : require
        layout(local_size_x=64) in;
        layout(buffer_reference,std430,buffer_reference_align=16) buffer Samples {vec4 data[];};
        layout(set=0,binding=0) uniform sampler2D source;
        layout(push_constant) uniform P {Samples samples;uint count;float parameter;uint seed;} p;
        uint hash(uint x){x^=x>>16;x*=0x7feb352du;x^=x>>15;x*=0x846ca68bu;return x^(x>>16);}
        void main(){uint id=gl_GlobalInvocationID.x;if(id>=p.count)return;ivec2 size=textureSize(source,0);uint total=uint(size.x*size.y);
            uint pixel=hash(id^hash(p.seed))%total;for(int n=0;n<32 && (hash(pixel)&7)==0;n++)pixel=(pixel+1)%total;
            ivec2 xy=ivec2(pixel%uint(size.x),pixel/uint(size.x));vec4 target=texelFetch(source,xy,0);
            bool valid=(hash(pixel)&7)!=0 && abs(target.a-1)<.001 && !any(isnan(target)) && !any(isinf(target));
            p.samples.data[id*2]=vec4((vec2(xy)+.5)/vec2(size)*2-1,p.parameter,1);
            p.samples.data[id*2+1]=vec4(valid?target.rgb:vec3(0),valid?1:0);}
        """
    }
}