LearnedShader.kt
src/jvmMain/kotlin/afield/graphics/shader/learning/LearnedShader.kt · 298 lines
package afield.graphics.shader.learning
import afield.document.context.*
import afield.graphics.render.*
import afield.graphics.render.Module
import afield.graphics.render.lighting.LightingGpuTimer
import afield.graphics.scene.*
import afield.graphics.shader.*
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.buffers.BufferParameters
import afield.graphics.vulkan.buffers.VulkanBuffer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.targets.RenderToTexture
import afield.math.linalg.Rectanglef
import java.io.File
import java.security.MessageDigest
import org.json.JSONObject
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.*
import org.lwjgl.vulkan.VK13.*
Runs a trained shader function as a compute pass that writes its own render target.
LearnedShader loads a model directory written by training, evaluates it each frame for the current parameter, and can LearnedShader.attach itself to the renderer's pre-target list. It uses cooperative vectors when the device supports them and a scalar variant otherwise.
class LearnedShader
A frozen, user-trained shader function. Weights are owned by this view; replace the view at a frame boundary to change weights. The ordinary arithmetic variant needs no cooperative vectors. Architecture/layout are explicit and checked, not a general ML graph runtime.
class LearnedShader(
val renderer: Renderer,
val width: Int = 1920,
val height: Int = 1080,
val cooperative: Boolean = renderer.device.supportsCooperativeVector,
weightsFile: File = File("public/docs/assets/learned_shader/weights.f16"),
manifestFile: File = File("public/docs/assets/learned_shader/weights.json"),
val name: String = "learned-shader",
attach: Boolean = true,
) : Target, AutoCloseable {
private val manifest = JSONObject(manifestFile.readText())
private val weights = weightsFile.readBytes()
private val offsets = List(6) { manifest.getJSONArray("offsets").getInt(it) }
private val bindings = mutableMapOf<String, BufferParameters>()
private val owned = mutableListOf<VulkanBuffer>()
private val timer = LightingGpuTimer(renderer)
val gpuMilliseconds
val gpuMilliseconds
get() = timer.samples.toList()
private val hasAnalyticReference = manifest.optString("sourceKind", "builtin") == "builtin"
val parameterRange: ClosedFloatingPointRange<Float>
val parameterRange: ClosedFloatingPointRange<Float> = manifest.optJSONArray("parameterRange")?.let {
require(it.length() == 2)
it.getDouble(0).toFloat()..it.getDouble(1).toFloat()
} ?: (-1f..1f)
var time = 0f
fun setParameter(value: Float)
Map the original capture parameter (for example seconds) into the network's normalized time.
fun setParameter(value: Float) {
require(value.isFinite() && value in parameterRange) {
"Parameter $value outside captured domain $parameterRange"
}
time = (value - parameterRange.start) / (parameterRange.endInclusive - parameterRange.start) * 2 - 1
}
var view
0 original function, 1 learned function, 2 original / learned / absolute error x8.
var view = if (hasAnalyticReference) 2 else 1
set(v) {
require(v in 0..2 && (hasAnalyticReference || v == 1)) {
"Captured models have no embedded analytic reference; use ShaderLearningView to compare with the real source"
}
field = v
}
val output
val output = RenderToTexture(renderer) {
this.width = this@LearnedShader.width
this.height = this@LearnedShader.height
useDepth = false
format = VK_FORMAT_R32G32B32A32_SFLOAT
}
private val compute: ComputeModule
private val task: ComputeMesh
private val resolve: Module
private var closed = false
val allocatedBytes
val allocatedBytes
get() = owned.sumOf { it.requestedSize } + output.colorImageRefs.sumOf { it?.memorySize ?: 0 }
init
body — 125 lines, from line 92
init {
require(
parameterRange.start.isFinite() &&
parameterRange.endInclusive.isFinite() &&
parameterRange.start < parameterRange.endInclusive,
)
require(width > 0 && height > 0 && width.toLong() * height <= 16_777_216)
require(manifest.getInt("schema") == 1 && manifest.getJSONArray("shape").toList() == listOf(16, 32, 32, 3))
require(
offsets.all { it % 64 == 0 } && offsets == listOf(0, 1024, 1088, 3136, 3200, 3392) && weights.size == 3456,
) {
"Frozen MLP layout mismatch"
}
require(
MessageDigest.getInstance("SHA-256").digest(weights).joinToString("") { "%02x".format(it) } ==
manifest.getString("sha256"),
) {
"Weight checksum mismatch"
}
if (cooperative) {
check(renderer.device.supportsCooperativeVector) { "Cooperative vectors were not enabled" }
MemoryStack.stackPush().use { s ->
val count = s.mallocInt(1)
NVCooperativeVector.vkGetPhysicalDeviceCooperativeVectorPropertiesNV(
renderer.device.physicalDevice.vkPhysicalDevice,
count,
null,
)
val props = VkCooperativeVectorPropertiesNV.calloc(count[0], s)
for (i in 0 until count[0]) props[i].`sType$Default`()
NVCooperativeVector.vkGetPhysicalDeviceCooperativeVectorPropertiesNV(
renderer.device.physicalDevice.vkPhysicalDevice,
count,
props,
)
check(
(0 until count[0]).any {
val p = props[it]
p.inputType() == 0 &&
p.inputInterpretation() == 0 &&
p.matrixInterpretation() == 0 &&
p.biasInterpretation() == 0 &&
p.resultType() == 0
},
) {
"FP16 cooperative-vector combination unavailable"
}
}
}
val binary = File("build/lighting/learned-shader/learned-${if(cooperative)"coop" else "scalar"}.spv")
check(binary.isFile) { "Build optional learned shaders with: ./gradlew buildLearnedShaders" }
val reflection =
Introspection.fromSpirv("Field learned function", ShaderKind.COMPUTE_SHADER, binary.readBytes())
check(reflection.valid && reflection.fixedWorkgroupSize == listOf(8, 8, 1))
// Field rounds the 40-byte std430 payload to a 16-byte block boundary.
check(
reflection.pushConstants.single().size == 48 &&
reflection.pushConstants.single().members.map { it.offset } == (0..36 step 4).toList(),
) {
"Learned shader push ABI mismatch"
}
check(reflection.ssbo.map { it.binding }.sorted() == listOf(0, 1) && reflection.ssbo.all { it.set == 0 })
reflection.ssbo.replaceAll { b ->
check(b.members.single().arrayStride == if (b.binding == 0) 4 else 16)
Introspected.UBO(if (b.binding == 0) "weights" else "pixels", b.size, b.set, b.binding, b.members)
}
compute = ComputeModule(
Parameters(
renderer,
name = name,
computeIntrospection = reflection,
ssboResolver = { requested ->
bindings.getOrPut(requested.name) {
requested.customize.add {
size = when (name) {
"weights" -> weights.size.toLong()
"pixels" -> width.toLong() * height * 16
else -> error("Unexpected learned shader binding $name")
}
if (name == "pixels") {
notHosted()
memory = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
usage = usage or VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT
needsDeviceAddress = true
}
}
requested
}
},
),
)
check(compute.valid)
bindings.values.flatMap { it.result!!.buffers }.forEach { owned += it }
bindings.getValue("weights").result!!.get().mapAndWrite_bytes { put(weights) }
val p = compute.pointer()
task = ComputeMesh(renderer, p)
task.x = (width + 7) / 8
task.y = (height + 7) / 8
task.push.setMembers(reflection.pushConstants.single().members)
p += "mlp" to task
task.push["width"] = width
task.push["height"] = height
listOf("w0", "b0", "w1", "b1", "w2", "b2").forEachIndexed { i, key -> task.push[key] = offsets[i] }
resolve = Module(
parameters(renderer) {
this.name = "$name-resolve"
+output
vertex = VERTEX
fragment = FRAGMENT
depthTest = false
depthWrite = false
blend = false
},
)
check(resolve.valid)
val rp = resolve.pointer()
val mesh = Mesh(renderer, rp)
mesh.resize(3, 0, rp)
mesh.push.setMembers(resolve.fi.pushConstants.single().members)
mesh.push["pixels"] = bindings.getValue("pixels").result!!.get()
mesh.push["width"] = width
resolve.meshes["quad"] = mesh
output.modules["resolve"] = rp
if (attach) attach()
}
fun attach(): LearnedShader
fun attach(): LearnedShader {
check(!closed)
output.blitToScreen(name = name)
renderer.renderList.preTargets[name] = this
return this
}
override fun render(command: CommandBuffer, index: Int)
body — 41 lines, from line 225
override fun render(command: CommandBuffer, index: Int) {
if (closed) return
require(time.isFinite() && time in -1f..1f) { "Training domain is [-1,1] in x, y and time" }
task.push["time"] = time
task.push["view"] = view
barrier(
command,
VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
VK_ACCESS_2_MEMORY_READ_BIT or VK_ACCESS_2_MEMORY_WRITE_BIT,
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
VK_ACCESS_2_SHADER_READ_BIT or VK_ACCESS_2_SHADER_WRITE_BIT,
)
timer.begin(command, index)
compute.render(command, Rectanglef())
timer.end(command, index)
barrier(
command,
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
VK_ACCESS_2_SHADER_WRITE_BIT,
VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT,
VK_ACCESS_2_SHADER_READ_BIT,
)
output.render(command, index)
}
private fun barrier(c: CommandBuffer, src: Long, access: Long, dst: Long, dstAccess: Long) =
MemoryStack.stackPush().use { s ->
vkCmdPipelineBarrier2(
c.vkCommandBuffer,
VkDependencyInfo.calloc(s)
.`sType$Default`()
.pMemoryBarriers(
VkMemoryBarrier2.calloc(1, s)
.`sType$Default`()
.srcStageMask(src)
.srcAccessMask(access)
.dstStageMask(dst)
.dstAccessMask(dstAccess),
),
)
}
override fun close()
override fun close() {
if (closed) return
closed = true
if (renderer.renderList.preTargets.remove(name, this)) renderer.renderList.modules.remove(name)
renderer.cleanup += Runnable {
compute.cleanup()
resolve.cleanup()
owned.forEach { it.cleanup() }
timer.close()
output.imageViewArray.forEach { it.imageView.cleanup() }
output.colorImageRefs.forEach { it?.cleanup() }
output.defaultSampler?.cleanup()
}
}
companion object
companion object {
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
#extension GL_EXT_scalar_block_layout : require
layout(buffer_reference,std430) readonly buffer Pixels {vec4 data[];};
layout(push_constant,scalar) 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];}
"""
}
}