diff --git a/build.sbt b/build.sbt index be3ae143..31b046da 100644 --- a/build.sbt +++ b/build.sbt @@ -31,7 +31,6 @@ lazy val lwjglNatives = { val lwjglVersion = "3.3.3" val jomlVersion = "1.10.0" - lazy val root = (project in file(".")) .settings( name := "Cyfra", @@ -51,10 +50,9 @@ lazy val root = (project in file(".")) "org.scalameta" % "munit_3" % "1.0.0" % Test, "org.junit.jupiter" % "junit-jupiter" % "5.6.2" % Test, "org.junit.jupiter" % "junit-jupiter-engine" % "5.7.2" % Test, + "org.scalatest" %% "scalatest" % "3.2.16" % Test, // Added ScalaTest dependency "com.lihaoyi" %% "sourcecode" % "0.4.3-M5" ) ) lazy val vulkanSdk = System.getenv("VULKAN_SDK") -javaOptions += s"-Dorg.lwjgl.vulkan.libname=$vulkanSdk/lib/libvulkan.1.dylib" - diff --git a/src/main/scala/io/computenode/cyfra/api/Buffer.scala b/src/main/scala/io/computenode/cyfra/api/Buffer.scala new file mode 100644 index 00000000..37e7cedd --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/api/Buffer.scala @@ -0,0 +1,206 @@ +package io.computenode.cyfra.api + +import io.computenode.cyfra.vulkan.VulkanContext +import io.computenode.cyfra.vulkan.memory.{Buffer => VkBuffer} +import org.lwjgl.util.vma.Vma.* +import org.lwjgl.vulkan.VK10.* +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.lwjgl.BufferUtils +import java.util.concurrent.atomic.AtomicBoolean +import scala.util.{Try, Success, Failure} +import org.lwjgl.system.MemoryUtil + +/** + * Represents a buffer in GPU memory + */ +class Buffer( + private[api] val vulkanContext: VulkanContext, + val size: Int, + val isHostVisible: Boolean = false +) extends AutoCloseable { + + private val closed = new AtomicBoolean(false) + + // Create the underlying Vulkan buffer + private[api] val vkBuffer: VkBuffer = { + if (isHostVisible) { + new VkBuffer( + size, + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + VMA_MEMORY_USAGE_CPU_TO_GPU, + vulkanContext.allocator + ) + } else { + new VkBuffer( + size, + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + 0, + VMA_MEMORY_USAGE_CPU_TO_GPU, + vulkanContext.allocator + ) + } + } + + /** + * Copies data from a ByteBuffer to this buffer + */ + def copyFrom(src: ByteBuffer): Try[Unit] = Try { + // Save original position + val origPos = src.position() + + // Make sure we're reading from the start of the buffer + src.position(0) + + // Get a mappable buffer to write to + val dst = vkBuffer.mapToByteBuffer() + + // Reset position of destination + dst.position(0) + + // Copy byte-by-byte (more reliable) + for (i <- 0 until Math.min(src.remaining(), size)) { + dst.put(i, src.get(i)) + } + + // Restore source position + src.position(origPos) + + // Unmap the buffer + vkBuffer.unmap() + } + + /** + * Copy data from this buffer to host memory + * @return ByteBuffer containing the buffer data + * @throws IllegalStateException if buffer has been closed + */ + def copyToHost(): Try[ByteBuffer] = Try { + // Map the memory + val srcBuffer = vkBuffer.mapToByteBuffer() + + // Explicitly invalidate the memory to ensure host sees device changes + vmaInvalidateAllocation(vulkanContext.allocator.get, vkBuffer.allocation, 0, size) + + // Create a new ByteBuffer to hold the data + val dstBuffer = BufferUtils.createByteBuffer(size) + + // Copy data + srcBuffer.limit(size) + srcBuffer.position(0) + dstBuffer.put(srcBuffer) + dstBuffer.flip() + + // Unmap memory when done + vkBuffer.unmap() + + dstBuffer + } + + /** + * Creates a new buffer with the same data as this one + * @return A new Buffer instance + * @throws IllegalStateException if buffer has been closed + */ + def duplicate(): Try[Buffer] = Try { + if (closed.get()) { + throw new IllegalStateException("Buffer has been closed") + } + + val newBuffer = new Buffer(vulkanContext, size, isHostVisible) + try { + VkBuffer.copyBuffer(vkBuffer, newBuffer.vkBuffer, size, vulkanContext.commandPool) + .block().destroy() + newBuffer + } catch { + case e: Exception => + newBuffer.close() + throw e + } + } + + /** + * Map the buffer to host memory (only for host-visible buffers) + * @return A ByteBuffer mapped to the device memory + * @throws UnsupportedOperationException if the buffer is not host-visible + * @throws IllegalStateException if buffer has been closed + */ + def map(): Try[ByteBuffer] = Try { + if (closed.get()) { + throw new IllegalStateException("Buffer has been closed") + } + if (!isHostVisible) { + throw new UnsupportedOperationException("Cannot map a non-host-visible buffer") + } + + var mappedBuffer: ByteBuffer = null + try { + mappedBuffer = vkBuffer.mapToByteBuffer() + + if (mappedBuffer == null || !mappedBuffer.isDirect() || mappedBuffer.capacity() != size) { + println("[ERROR] Invalid memory mapping - null, non-direct buffer, or wrong size") + return Failure(new IllegalStateException("Memory mapping failed")) + } + + // Create a duplicate to avoid direct manipulation of the mapped memory + val result = BufferUtils.createByteBuffer(size) + + // Save original position and limit + val originalPosition = mappedBuffer.position() + val originalLimit = mappedBuffer.limit() + + // Ensure proper buffer state for copying + mappedBuffer.position(0).limit(size) + + // Copy data + result.put(mappedBuffer) + + // Restore original position and limit + mappedBuffer.position(originalPosition).limit(originalLimit) + + // Prepare result for reading + result.flip() + + result + } catch { + case e: Exception => + println(s"[ERROR] Buffer mapping failed: ${e.getMessage}") + throw new RuntimeException("Failed to map buffer", e) + } finally { + // Make sure to unmap if needed + if (mappedBuffer != null) { + try { + vkBuffer.unmap() + } catch { + case e: Exception => + println(s"[ERROR] Failed to unmap buffer: ${e.getMessage}") + } + } + } + } + + /** + * Get the buffer size in bytes + */ + def getSize: Int = size + + /** + * Check if buffer is host visible + */ + def isHostAccessible: Boolean = isHostVisible + + override def close(): Unit = { + if (closed.compareAndSet(false, true)) { + try { + // Wait for device operations to complete before destroying + vulkanContext.deviceWaitIdle() // Use the context method instead of device.waitIdle() + vkBuffer.destroy() + } catch { + case e: Exception => + println(s"[ERROR] Failed to properly close buffer: ${e.getMessage}") + throw new RuntimeException("Buffer close operation failed", e) + } + } + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/api/ComputeContext.scala b/src/main/scala/io/computenode/cyfra/api/ComputeContext.scala new file mode 100644 index 00000000..7740543e --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/api/ComputeContext.scala @@ -0,0 +1,277 @@ +package io.computenode.cyfra.api + +import io.computenode.cyfra.vulkan.VulkanContext +import io.computenode.cyfra.vulkan.memory.Buffer as VkBuffer +import org.joml.Vector3i +import java.nio.ByteBuffer +import io.computenode.cyfra.vulkan.executor.SequenceExecutor +import io.computenode.cyfra.vulkan.executor.SequenceExecutor.{Compute, LayoutLocation, Dependency} +import java.util.concurrent.locks.ReentrantLock +import java.util.concurrent.atomic.AtomicBoolean +import scala.util.{Try, Success, Failure} +import scala.util.control.NonFatal +import io.computenode.cyfra.vulkan.executor.BufferAction +import org.lwjgl.vulkan.VK10.* +import org.lwjgl.util.vma.Vma.* + +/** + * High-level abstraction for GPU computation operations. + * Manages Vulkan resources and provides a simplified API for compute operations. + */ +class ComputeContext(enableValidation: Boolean = false) extends AutoCloseable { + private val vulkanContext = new VulkanContext(enableValidation) + private val executionLock = new ReentrantLock() + private val closed = new AtomicBoolean(false) + + /** + * Creates a buffer in GPU memory + * @param size Size of the buffer in bytes + * @param isHostVisible Whether the buffer should be host-visible for direct mapping + * @return A new Buffer instance + * @throws IllegalStateException if context has been closed + */ + def createBuffer(size: Int, isHostVisible: Boolean = false): Try[Buffer] = Try { + val usageFlags = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | + VK_BUFFER_USAGE_TRANSFER_SRC_BIT | + VK_BUFFER_USAGE_TRANSFER_DST_BIT + + val memoryUsage = if (isHostVisible) { + VMA_MEMORY_USAGE_CPU_TO_GPU + } else { + VMA_MEMORY_USAGE_GPU_ONLY + } + + val vkBuffer = new io.computenode.cyfra.vulkan.memory.Buffer( + size, + usageFlags, + if (isHostVisible) VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT else 0, + memoryUsage, + vulkanContext.allocator + ) + + new Buffer(vulkanContext, size, isHostVisible) + } + + /** + * Creates a shader from SPIR-V binary + * @param spirvCode SPIR-V binary code + * @param workgroupSize Size of the workgroup for this shader + * @param layout Layout information for descriptor sets + * @param entryPoint Entry point function name + * @return A new Shader instance + * @throws IllegalStateException if context has been closed + */ + def createShader( + spirvCode: ByteBuffer, + workgroupSize: Vector3i, + layout: LayoutInfo, + entryPoint: String = "main" + ): Try[Shader] = Try { + if (closed.get()) { + throw new IllegalStateException("ComputeContext has been closed") + } + + new Shader(vulkanContext, spirvCode, workgroupSize, layout, entryPoint) + } + + /** + * Creates a pipeline from a shader + * @param shader The shader to use in this pipeline + * @return A new Pipeline instance + * @throws IllegalStateException if context has been closed + */ + def createPipeline(shader: Shader): Try[Pipeline] = Try { + if (closed.get()) { + throw new IllegalStateException("ComputeContext has been closed") + } + + new Pipeline(vulkanContext, shader) + } + + /** + * Executes a pipeline with the given input/output buffers + * @param pipeline The pipeline to execute + * @param inputs Input buffers + * @param outputs Output buffers + * @param elemCount Number of elements to process + * @throws IllegalStateException if context has been closed + */ + def execute( + pipeline: Pipeline, + inputs: Seq[Buffer], + outputs: Seq[Buffer], + elemCount: Int + ): Try[Unit] = Try { + if (closed.get()) { + throw new IllegalStateException("ComputeContext has been closed") + } + + executionLock.lock() + try { + val bufferActions = Map( + LayoutLocation(0, 0) -> BufferAction.LoadTo, + LayoutLocation(1, 0) -> BufferAction.LoadFrom + ) + + val computeOp = Compute(pipeline.vkPipeline, bufferActions) + val sequence = SequenceExecutor.ComputationSequence(Seq(computeOp), Seq()) + + val executor = try { + new SequenceExecutor(sequence, vulkanContext) + } catch { + case e: Exception => + e.printStackTrace() + throw e + } + + try { + // Map buffers with proper position reset + val inBuffers = inputs.map { buffer => + val byteBuffer = buffer.vkBuffer.mapToByteBuffer() + // Important: Reset position to ensure the entire buffer is available + byteBuffer.position(0) + byteBuffer + } + + // Before execution, validate buffers + inBuffers.zipWithIndex.foreach { case (buf, idx) => + if (buf == null || !buf.isDirect || buf.capacity() < 4 * elemCount) { + throw new IllegalArgumentException(s"Invalid input buffer $idx: ${if (buf == null) "null" else s"direct=${buf.isDirect}, capacity=${buf.capacity()}"}") + } + } + + // Execute + val results = executor.execute(inBuffers, elemCount) + + // Process results + outputs.zipWithIndex.foreach { case (buffer, idx) => + if (idx < results.length) { + buffer.copyFrom(results(idx)) + } else { + throw new RuntimeException(s"Missing result for output buffer $idx") + } + } + } catch { + case e: Exception => + e.printStackTrace() + throw new RuntimeException("Failed to execute pipeline", e) + } finally { + executor.destroy() + } + } finally { + executionLock.unlock() + } + } + + /** + * Create and execute a pipeline sequence with dependencies + * @param operations List of (pipeline, inputs, outputs) operations + * @param dependencies List of (fromPipeline, fromSet, toPipeline, toSet) dependencies + * @param elemCount Number of elements to process + * @throws IllegalStateException if context has been closed + */ + def executeSequence( + operations: Seq[(Pipeline, Seq[Buffer], Seq[Buffer])], + dependencies: Seq[(Pipeline, Int, Pipeline, Int)], + elemCount: Int + ): Try[Seq[Buffer]] = Try { + if (closed.get()) { + throw new IllegalStateException("ComputeContext has been closed") + } + + executionLock.lock() + try { + // Map operations to Compute instances with buffer actions + val computeOps = operations.map { case (pipeline, inputs, outputs) => + val actions = (inputs.zipWithIndex.map { case (_, idx) => + LayoutLocation(0, idx) -> BufferAction.LoadTo + } ++ + outputs.zipWithIndex.map { case (_, idx) => + LayoutLocation(1, idx) -> BufferAction.LoadFrom + }).toMap + + Compute(pipeline.vkPipeline, actions) + } + + // Map dependencies + val vkDependencies = dependencies.map { case (fromPipeline, fromSet, toPipeline, toSet) => + Dependency(fromPipeline.vkPipeline, fromSet, toPipeline.vkPipeline, toSet) + } + + val sequence = SequenceExecutor.ComputationSequence(computeOps, vkDependencies) + val executor = new SequenceExecutor(sequence, vulkanContext) + + try { + // Use mapToByteBuffer instead of getData + val inBuffers = operations.flatMap(_._2).map { buf => + val mappedBuf = buf.vkBuffer.mapToByteBuffer() + mappedBuf + }.distinct + + val results = executor.execute(inBuffers, elemCount) + + // Create new output buffers with results + results.map { byteBuffer => + createBuffer(byteBuffer.remaining()) match { + case Success(buffer) => + buffer.copyFrom(byteBuffer) match { + case Success(_) => buffer + case Failure(e) => throw new RuntimeException("Failed to copy result to output buffer", e) + } + case Failure(e) => throw new RuntimeException("Failed to create output buffer", e) + } + } + } finally { + executor.destroy() + } + } finally { + executionLock.unlock() + } + } + + /** + * Create buffer with the specified data + * @param data The data to copy to the buffer + * @param isHostVisible Whether the buffer should be host-visible + * @return A new Buffer instance initialized with the provided data + */ + def createBufferWithData(data: ByteBuffer, isHostVisible: Boolean = false): Try[Buffer] = + for { + buffer <- createBuffer(data.remaining(), isHostVisible) + _ <- buffer.copyFrom(data) + } yield buffer + + /** + * Create an int buffer with the given array of integers + * @param data The integer data + * @param isHostVisible Whether the buffer should be host-visible + * @return A buffer containing the integer data + */ + def createIntBuffer(data: Array[Int], isHostVisible: Boolean = false): Try[Buffer] = Try { + val byteBuffer = ByteBuffer.allocateDirect(data.length * 4) + data.foreach(byteBuffer.putInt) + byteBuffer.flip() + + createBufferWithData(byteBuffer, isHostVisible).get + } + + /** + * Create a float buffer with the given array of floats + * @param data The float data + * @param isHostVisible Whether the buffer should be host-visible + * @return A buffer containing the float data + */ + def createFloatBuffer(data: Array[Float], isHostVisible: Boolean = false): Try[Buffer] = Try { + val byteBuffer = ByteBuffer.allocateDirect(data.length * 4) + data.foreach(byteBuffer.putFloat) + byteBuffer.flip() + + createBufferWithData(byteBuffer, isHostVisible).get + } + + override def close(): Unit = { + if (closed.compareAndSet(false, true)) { + vulkanContext.destroy() + } + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/api/LayoutModel.scala b/src/main/scala/io/computenode/cyfra/api/LayoutModel.scala new file mode 100644 index 00000000..1b5f214a --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/api/LayoutModel.scala @@ -0,0 +1,60 @@ +package io.computenode.cyfra.api + +/** + * Represents layout information for descriptor sets and bindings + */ +case class LayoutInfo(sets: Seq[LayoutSet]) { + /** + * Creates a new layout with an additional set + * @param set The set to add + * @return A new LayoutInfo instance + */ + def withSet(set: LayoutSet): LayoutInfo = + LayoutInfo(sets :+ set) + + /** + * Creates a new standard input-output layout + * @param inputBindingCount Number of input bindings + * @param outputBindingCount Number of output bindings + * @param elementSize Size of each element in bytes + * @return A new LayoutInfo instance + */ + def withIOLayout(inputBindingCount: Int, outputBindingCount: Int, elementSize: Int): LayoutInfo = { + val inputSet = LayoutSet(0, (0 until inputBindingCount).map(id => Binding(id, elementSize))) + val outputSet = LayoutSet(1, (0 until outputBindingCount).map(id => Binding(id, elementSize))) + LayoutInfo(Seq(inputSet, outputSet)) + } +} + +object LayoutInfo { + /** + * Creates a standard input-output layout + * @param inputBindingCount Number of input bindings + * @param outputBindingCount Number of output bindings + * @param elementSize Size of each element in bytes + * @return A new LayoutInfo instance + */ + def standardIOLayout(inputBindingCount: Int, outputBindingCount: Int, elementSize: Int): LayoutInfo = { + val inputSet = LayoutSet(0, (0 until inputBindingCount).map(id => Binding(id, elementSize))) + val outputSet = LayoutSet(1, (0 until outputBindingCount).map(id => Binding(id, elementSize))) + LayoutInfo(Seq(inputSet, outputSet)) + } +} + +/** + * Represents a descriptor set with bindings + */ +case class LayoutSet(id: Int, bindings: Seq[Binding]) { + /** + * Creates a new set with an additional binding + * @param binding The binding to add + * @return A new LayoutSet instance + */ + def withBinding(binding: Binding): LayoutSet = + LayoutSet(id, bindings :+ binding) +} + +/** + * Represents a binding with an ID and size + */ +case class Binding(id: Int, size: Int) \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/api/Pipeline.scala b/src/main/scala/io/computenode/cyfra/api/Pipeline.scala new file mode 100644 index 00000000..8bb316d3 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/api/Pipeline.scala @@ -0,0 +1,65 @@ +package io.computenode.cyfra.api + +import io.computenode.cyfra.vulkan.VulkanContext +import io.computenode.cyfra.vulkan.compute.{ComputePipeline => VkComputePipeline} +import java.util.concurrent.atomic.AtomicBoolean +import scala.util.{Try, Success, Failure} +import org.joml.Vector3i + +/** + * Represents a compute pipeline + */ +class Pipeline( + private[api] val vulkanContext: VulkanContext, + shader: Shader +) extends AutoCloseable { + + private val closed = new AtomicBoolean(false) + + // Create underlying Vulkan pipeline + private[api] val vkPipeline = new VkComputePipeline(shader.vkShader, vulkanContext) + + /** + * Get the shader used by this pipeline + * @return The shader instance + */ + def getShader: Shader = shader + + /** + * Calculate the optimal workgroup count based on element count + * @param elementCount Total number of elements to process + * @return Optimal workgroup count + */ + def calculateWorkgroupCount(elementCount: Int): Vector3i = { + val workgroupSize = shader.getWorkgroupDimensions + val x = Math.ceil(elementCount.toDouble / workgroupSize.x).toInt + new Vector3i(x, 1, 1) + } + + /** + * Execute the pipeline with specified buffers + * @param context The compute context + * @param inputs Input buffers + * @param outputs Output buffers + * @param elementCount Number of elements to process + * @throws IllegalStateException if pipeline has been closed + */ + def execute( + context: ComputeContext, + inputs: Seq[Buffer], + outputs: Seq[Buffer], + elementCount: Int + ): Try[Unit] = Try { + if (closed.get()) { + throw new IllegalStateException("Pipeline has been closed") + } + + context.execute(this, inputs, outputs, elementCount) + } + + override def close(): Unit = { + if (closed.compareAndSet(false, true)) { + vkPipeline.close() + } + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/api/Shader.scala b/src/main/scala/io/computenode/cyfra/api/Shader.scala new file mode 100644 index 00000000..fa57faa8 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/api/Shader.scala @@ -0,0 +1,119 @@ +package io.computenode.cyfra.api + +import io.computenode.cyfra.vulkan.VulkanContext +import io.computenode.cyfra.vulkan.compute.{Shader => VkShader, LayoutInfo => VkLayoutInfo} +import org.joml.Vector3i +import java.nio.ByteBuffer +import java.nio.file.{Files, Paths} +import io.computenode.cyfra.vulkan.compute.{LayoutSet => VkLayoutSet, Binding => VkBinding, InputBufferSize} +import java.util.concurrent.atomic.AtomicBoolean +import scala.util.{Try, Success, Failure} +import java.io.{FileInputStream, IOException} +import java.nio.channels.FileChannel +import scala.util.Using + +/** + * Represents a compute shader + */ +class Shader( + private[api] val vulkanContext: VulkanContext, + spirvCode: ByteBuffer, + workgroupSize: Vector3i, + layoutInfo: LayoutInfo, + entryPoint: String = "main" +) extends AutoCloseable { + + private val closed = new AtomicBoolean(false) + + // Convert to Vulkan layout info + private val vkLayoutInfo = new VkLayoutInfo( + layoutInfo.sets.map(set => + VkLayoutSet(set.id, set.bindings.map(binding => + VkBinding(binding.id, InputBufferSize(binding.size)) + )) + ) + ) + + private[api] val vkShader = new VkShader( + spirvCode, + workgroupSize, + vkLayoutInfo, + entryPoint, + vulkanContext.device + ) + + /** + * Get the working group dimensions + * @return Vector3i containing the dimensions + */ + def getWorkgroupDimensions: Vector3i = workgroupSize + + /** + * Get the layout information + * @return Layout information for this shader + */ + def getLayoutInfo: LayoutInfo = layoutInfo + + /** + * Get the entry point for this shader + * @return The shader entry point function name + */ + def getEntryPoint: String = entryPoint + + override def close(): Unit = { + if (closed.compareAndSet(false, true)) { + vkShader.close() + } + } +} + +object Shader { + /** + * Load a shader from a file + * @param context Vulkan context + * @param path Path to the SPIR-V file + * @param workgroupSize Size of the workgroup + * @param layoutInfo Layout information + * @param entryPoint Entry point function name + * @return A new Shader instance + */ + def loadFromFile( + context: VulkanContext, + path: String, + workgroupSize: Vector3i, + layoutInfo: LayoutInfo, + entryPoint: String = "main" + ): Try[Shader] = Try { + val bytes = Files.readAllBytes(Paths.get(path)) + val buffer = ByteBuffer.allocateDirect(bytes.length) + buffer.put(bytes) + buffer.flip() + + new Shader(context, buffer, workgroupSize, layoutInfo, entryPoint) + } + + /** + * Load a shader from a resource + * @param context Vulkan context + * @param resourcePath Resource path to the SPIR-V file + * @param workgroupSize Size of the workgroup + * @param layoutInfo Layout information + * @param entryPoint Entry point function name + * @return A new Shader instance + */ + def loadFromResource( + context: VulkanContext, + resourcePath: String, + workgroupSize: Vector3i, + layoutInfo: LayoutInfo, + entryPoint: String = "main" + ): Try[Shader] = Try { + Using.resource(getClass.getClassLoader.getResourceAsStream(resourcePath)) { inputStream => + val channel = java.nio.channels.Channels.newChannel(inputStream) + val buffer = ByteBuffer.allocateDirect(inputStream.available()) + channel.read(buffer) + buffer.flip() + new Shader(context, buffer, workgroupSize, layoutInfo, entryPoint) + } + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/samples/AnimatedMandelbrot.scala b/src/main/scala/io/computenode/cyfra/samples/AnimatedMandelbrot.scala new file mode 100644 index 00000000..5699af75 --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/samples/AnimatedMandelbrot.scala @@ -0,0 +1,176 @@ +package io.computenode.cyfra.samples.foton + +import java.io.File +import io.computenode.cyfra.* +import io.computenode.cyfra.dsl.Algebra.{*, given} +import io.computenode.cyfra.dsl.Functions.* +import io.computenode.cyfra.dsl.GSeq +import io.computenode.cyfra.dsl.Value.* +import io.computenode.cyfra.foton.animation.AnimatedFunctionRenderer.Parameters +import io.computenode.cyfra.foton.animation.{AnimatedFunction, AnimatedFunctionRenderer} +import io.computenode.cyfra.foton.animation.AnimationFunctions.{AnimationInstant, smooth} +import io.computenode.cyfra.utility.Color.* +import io.computenode.cyfra.utility.Math3D.* +import io.computenode.cyfra.utility.Units.Milliseconds + +import scala.concurrent.duration.DurationInt +import java.nio.file.{Files, Path, Paths, StandardCopyOption} + +object AnimatedMandelbrot: + // Animation parameters + private val AnimationDuration = 10.seconds + private val FramesPerSecond = 60 + // Small batch size prevents memory overflow + // and distributes CPU load across time instead of all at once + private val BatchSize = 8 + private val ImageWidth = 1024 + private val ImageHeight = 1024 + + // Mandelbrot parameters + private val BaseZoom = 1.0f + private val TargetZoom = 50.0f + private val MandelbrotCenterX = -0.743643887037151f + private val MandelbrotCenterY = 0.231825904205330f + private val InitialFocusX = 0.0f + private val InitialFocusY = 1.25f + private val IterationLimit = 80000 + + /** + * Finds the last rendered frame in a directory to enable resuming animation renders + */ + def findLastRenderedFrame(directory: Path): Int = + val dir = directory.toFile + if !dir.exists() then + dir.mkdirs() + return 0 + + val framePattern = "frame(\\d+)\\.png".r + + Option(dir.listFiles()) + .getOrElse(Array.empty[File]) + .filter(_.isFile) + .filter(_.getName.endsWith(".png")) + .flatMap { file => + val name = file.getName + framePattern.findFirstMatchIn(name).map(_.group(1).toInt) + } match + case frames if frames.isEmpty => 0 + case frames => frames.max + 1 + + /** + * Calculates the Mandelbrot set iteration count at a given point with animation parameters + */ + def calculateMandelbrot(c: Vec2[Float32], globalTimePos: Float32): Int32 = + // Calculate zoom factor based on animation time + val zoom = BaseZoom + (TargetZoom - BaseZoom) * globalTimePos + + // Calculate focus point based on animation time + val focusX = mix(InitialFocusX, MandelbrotCenterX, globalTimePos) + val focusY = mix(InitialFocusY, MandelbrotCenterY, globalTimePos) + + // Scale coordinates based on zoom and focus + val scaledC = vec2( + (c.x - 0.3f) * 2.5f / zoom + focusX, + (c.y - 0.5f) * 2.5f / zoom + focusY + ) + + // Calculate iteration count using Z = Z² + C + // Using GSeq with lazy evaluation to avoid memory explosion + // Each iteration is computed on-demand instead of materializing the entire sequence + GSeq + .gen(vec2(0f, 0f), next = z => ((z.x * z.x) - (z.y * z.y), 2.0f * z.x * z.y) + scaledC) + .limit(IterationLimit) + .map(length) + .takeWhile(_ < 2.0f) + .count + + /** + * Creates a function that produces the animated Mandelbrot visualization + */ + def createMandelbrotFunction(batchStartTime: Float32, + batchDuration: Milliseconds, + animationDurationMs: Float32): AnimatedFunction = + def mandelbrotColor(uv: Vec2[Float32])(using instant: AnimationInstant): Vec4[Float32] = + // Calculate global animation position + val globalTimePos = (instant.time + batchStartTime) / animationDurationMs + + // Get iteration count for this coordinate + val recursionCount = calculateMandelbrot(uv, globalTimePos) + + // Normalize iteration count based on zoom level + val normalizer = 300f * (1f + 0.1f * globalTimePos) + val colorPos = min(1f, recursionCount.asFloat / normalizer) + + // Apply color mapping + val color = interpolate(InterpolationThemes.Blue, colorPos) + (color.r, color.g, color.b, 1.0f) + + AnimatedFunction.fromCoord(mandelbrotColor, batchDuration) + + /** + * Processes a batch of rendered frames, moving them to final location with correct names + */ + def processBatchFrames(batchDir: Path, outputDir: Path, batchStart: Int, totalFrames: Int): Unit = + val frameFiles = Option(batchDir.toFile.listFiles()) + .getOrElse(Array.empty[File]) + .filter(_.getName.endsWith(".png")) + .sorted + + val frameDigits = totalFrames.toString.length + + frameFiles.zipWithIndex.foreach { case (file, i) => + val frameNum = batchStart + i + val frameFormatted = frameNum.toString.padLeft(frameDigits, '0') + val destFile = outputDir.resolve(s"frame$frameFormatted.png") + Files.move(file.toPath, destFile, StandardCopyOption.REPLACE_EXISTING) + } + + batchDir.toFile.delete() + + // String extension method for padding + extension (s: String) + def padLeft(len: Int, padChar: Char): String = + s.reverse.padTo(len, padChar).reverse.mkString + + @main + def mandelbrot(): Unit = + val animationDurationMs = AnimationDuration.toMillis.toFloat + val totalFrames = (animationDurationMs / 1000f * FramesPerSecond).toInt + + val outputDir = Paths.get("mandelbrot") + // Resume capability prevents redundant work if previous run crashed + val startFrame = findLastRenderedFrame(outputDir) + + println(s"Starting from frame $startFrame of $totalFrames") + + // Process in batches to manage memory and prevent CPU overload + for + batchStart <- startFrame until totalFrames by BatchSize + batchEnd = Math.min(batchStart + BatchSize, totalFrames) + do + println(s"Rendering batch from $batchStart to ${batchEnd-1}") + + // Calculate timing for this batch + val batchDuration = AnimationDuration * ((batchEnd - batchStart).toFloat / totalFrames) + val startTime = animationDurationMs * (batchStart.toFloat / totalFrames) + + // Create temporary output directory for batch + val batchDir = outputDir.resolve(s"batch_${batchStart}_to_${batchEnd-1}") + batchDir.toFile.mkdirs() + + // Create and render the animation function + val mandelbrotFunction = createMandelbrotFunction( + startTime, + batchDuration, + animationDurationMs + ) + + val batchParameters = Parameters(ImageWidth, ImageHeight, FramesPerSecond) + val batchRenderer = AnimatedFunctionRenderer(batchParameters) + batchRenderer.renderFramesToDir(mandelbrotFunction, batchDir) + + // Process generated frames + processBatchFrames(batchDir, outputDir, batchStart, totalFrames) + + // Force garbage collection between batches to prevent memory leaks + System.gc() \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/samples/ApiExample.scala b/src/main/scala/io/computenode/cyfra/samples/ApiExample.scala new file mode 100644 index 00000000..859bc37c --- /dev/null +++ b/src/main/scala/io/computenode/cyfra/samples/ApiExample.scala @@ -0,0 +1,60 @@ +package io.computenode.cyfra.examples + +import io.computenode.cyfra.api.* +import org.joml.Vector3i +import org.lwjgl.BufferUtils +import io.computenode.cyfra.vulkan.compute.Shader.loadShader +import scala.util.{Success, Failure, Try} + +object SimpleExample { + def main(args: Array[String]): Unit = { + // Create a compute context + val context = new ComputeContext(enableValidation = true) + + try { + // Create an array of integers 0-1023 + val inputData = Array.tabulate(1024)(i => i) + + // Create input buffer with the data + context.createIntBuffer(inputData, isHostVisible = true) match { + case Success(inputBuffer) => + // Create output buffer + context.createBuffer(4 * 1024) match { + case Success(outputBuffer) => + // Create shader and pipeline + val spirvCode = loadShader("copy_test.spv") + val layoutInfo = LayoutInfo.standardIOLayout(1, 1, 4) + + for { + shader <- context.createShader(spirvCode, new Vector3i(128, 1, 1), layoutInfo) + pipeline <- context.createPipeline(shader) + _ <- context.execute(pipeline, Seq(inputBuffer), Seq(outputBuffer), 1024) + resultBuffer <- outputBuffer.copyToHost() + } yield { + // Read back and print results + val results = Array.fill(1024)(0) + for (i <- 0 until 1024) { + results(i) = resultBuffer.getInt() + } + + println("Results: " + results.take(10).mkString(", ")) + + // Cleanup + pipeline.close() + shader.close() + inputBuffer.close() + outputBuffer.close() + } + + case Failure(e) => + println(s"Failed to create output buffer: ${e.getMessage}") + } + + case Failure(e) => + println(s"Failed to create input buffer: ${e.getMessage}") + } + } finally { + context.close() + } + } +} \ No newline at end of file diff --git a/src/main/scala/io/computenode/cyfra/vulkan/VulkanContext.scala b/src/main/scala/io/computenode/cyfra/vulkan/VulkanContext.scala index 18c918db..684aecc3 100644 --- a/src/main/scala/io/computenode/cyfra/vulkan/VulkanContext.scala +++ b/src/main/scala/io/computenode/cyfra/vulkan/VulkanContext.scala @@ -3,6 +3,7 @@ package io.computenode.cyfra.vulkan import io.computenode.cyfra.vulkan.command.{CommandPool, Queue, StandardCommandPool} import io.computenode.cyfra.vulkan.core.{DebugCallback, Device, Instance} import io.computenode.cyfra.vulkan.memory.{Allocator, DescriptorPool} +import org.lwjgl.vulkan.VK10 /** @author * MarconZet Created 13.04.2020 @@ -24,6 +25,12 @@ private[cyfra] class VulkanContext(val enableValidationLayers: Boolean = false) val descriptorPool: DescriptorPool = new DescriptorPool(device) val commandPool: CommandPool = new StandardCommandPool(device, computeQueue) + def deviceWaitIdle(): Unit = { + if (device != null) { + VK10.vkDeviceWaitIdle(device.get) + } + } + def destroy(): Unit = { commandPool.destroy() descriptorPool.destroy() diff --git a/src/main/scala/io/computenode/cyfra/vulkan/compute/ComputePipeline.scala b/src/main/scala/io/computenode/cyfra/vulkan/compute/ComputePipeline.scala index 79250f01..144393f0 100644 --- a/src/main/scala/io/computenode/cyfra/vulkan/compute/ComputePipeline.scala +++ b/src/main/scala/io/computenode/cyfra/vulkan/compute/ComputePipeline.scala @@ -57,7 +57,7 @@ private[cyfra] class ComputePipeline(val computeShader: Shader, context: VulkanC pPipeline.get(0) } - protected def close(): Unit = { + def close(): Unit = { vkDestroyPipeline(device.get, handle, null) vkDestroyPipelineLayout(device.get, pipelineLayout, null) descriptorSetLayouts.map(_._1).foreach(vkDestroyDescriptorSetLayout(device.get, _, null)) diff --git a/src/main/scala/io/computenode/cyfra/vulkan/compute/Shader.scala b/src/main/scala/io/computenode/cyfra/vulkan/compute/Shader.scala index 2b42b205..7e67c9bd 100644 --- a/src/main/scala/io/computenode/cyfra/vulkan/compute/Shader.scala +++ b/src/main/scala/io/computenode/cyfra/vulkan/compute/Shader.scala @@ -35,7 +35,7 @@ private[cyfra] class Shader(shaderCode: ByteBuffer, val workgroupDimensions: Vec pShaderModule.get() } - protected def close(): Unit = + def close(): Unit = vkDestroyShaderModule(device.get, handle, null) } @@ -46,12 +46,25 @@ object Shader { private def loadShader(path: String, classLoader: ClassLoader): ByteBuffer = try { - val file = new File(Objects.requireNonNull(classLoader.getResource(path)).getFile) - val fis = new FileInputStream(file) - val fc = fis.getChannel - fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size()) - } catch + val resourceUrl = Objects.requireNonNull(classLoader.getResource(path)) + if (resourceUrl.getProtocol == "jar") { + // Handle resources inside a JAR + val inputStream = classLoader.getResourceAsStream(path) + val bytes = inputStream.readAllBytes() + val buffer = ByteBuffer.allocateDirect(bytes.length) + buffer.put(bytes) + buffer.flip() + buffer + } else { + // Handle resources in the filesystem + val file = new File(resourceUrl.getFile) + val fis = new FileInputStream(file) + val fc = fis.getChannel + fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size()) + } + } catch { case e: IOException => throw new RuntimeException(e) + } } diff --git a/src/main/scala/io/computenode/cyfra/vulkan/executor/SequenceExecutor.scala b/src/main/scala/io/computenode/cyfra/vulkan/executor/SequenceExecutor.scala index 8945893b..65911292 100644 --- a/src/main/scala/io/computenode/cyfra/vulkan/executor/SequenceExecutor.scala +++ b/src/main/scala/io/computenode/cyfra/vulkan/executor/SequenceExecutor.scala @@ -18,6 +18,7 @@ import org.lwjgl.vulkan.* import org.lwjgl.vulkan.KHRSynchronization2.vkCmdPipelineBarrier2KHR import org.lwjgl.vulkan.VK10.* import org.lwjgl.vulkan.VK13.* +import org.lwjgl.system.MemoryUtil import java.nio.ByteBuffer @@ -106,11 +107,17 @@ private[cyfra] class SequenceExecutor(computeSequence: ComputationSequence, cont vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline.get) - val pDescriptorSets = stack.longs(pipelineToDescriptorSets(pipeline).map(_.get): _*) + val descriptorSets = pipelineToDescriptorSets(pipeline) + val pDescriptorSets = stack.longs(descriptorSets.map(_.get): _*) vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline.pipelineLayout, 0, pDescriptorSets, null) val workgroup = pipeline.computeShader.workgroupDimensions - vkCmdDispatch(commandBuffer, dataLength / workgroup.x, 1 / workgroup.y, 1 / workgroup.z) // TODO this can be changed to indirect dispatch, this would unlock options like filters + vkCmdDispatch( + commandBuffer, + Math.max(1, (dataLength + workgroup.x() - 1) / workgroup.x()), // Ceiling division + 1, // Always use at least 1 + 1 // Always use at least 1 + ) } check(vkEndCommandBuffer(commandBuffer), "Failed to finish recording command buffer") @@ -138,9 +145,30 @@ private[cyfra] class SequenceExecutor(computeSequence: ComputationSequence, cont val buffers = set.bindings.zip(actions).map { case (binding, action) => binding.size match case InputBufferSize(elemSize) => - new Buffer(elemSize * dataLength, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | action.action, 0, VMA_MEMORY_USAGE_GPU_ONLY, allocator) + val memoryUsage = + if ((action.action & BufferAction.LoadFrom.action) != 0) { + VMA_MEMORY_USAGE_CPU_TO_GPU + } else { + VMA_MEMORY_USAGE_GPU_ONLY + } + + val memoryFlags = + if ((action.action & BufferAction.LoadFrom.action) != 0) + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT + else + 0 + + new Buffer(elemSize * dataLength, + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | action.action, + memoryFlags, + memoryUsage, + allocator) case UniformSize(size) => - new Buffer(size, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | action.action, 0, VMA_MEMORY_USAGE_GPU_ONLY, allocator) + new Buffer(size, + VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | action.action, + 0, + VMA_MEMORY_USAGE_GPU_ONLY, + allocator) } set.update(buffers) (set, buffers) @@ -150,54 +178,66 @@ private[cyfra] class SequenceExecutor(computeSequence: ComputationSequence, cont } def execute(inputs: Seq[ByteBuffer], dataLength: Int): Seq[ByteBuffer] = pushStack { stack => - timed("Vulkan full execute"): - val setToBuffers = createBuffers(dataLength) + try { + timed("Vulkan full execute"): + val setToBuffers = createBuffers(dataLength) + + def buffersWithAction(bufferAction: BufferAction): Seq[Buffer] = + computeSequence.sequence.collect { case x: Compute => + pipelineToDescriptorSets(x.pipeline).map(setToBuffers).zip(x.pumpLayoutLocations).flatMap(x => x._1.zip(x._2)).collect { + case (buffer, action) if (action.action & bufferAction.action) != 0 => buffer + } + }.flatten + + val stagingBuffer = new Buffer( + inputs.map(_.remaining()).max, + VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, + VMA_MEMORY_USAGE_UNKNOWN, + allocator + ) + + buffersWithAction(BufferAction.LoadTo).zipWithIndex.foreach { case (buffer, i) => + Buffer.copyBuffer(inputs(i), stagingBuffer, buffer.size) + Buffer.copyBuffer(stagingBuffer, buffer, buffer.size, commandPool).block().destroy() + } - def buffersWithAction(bufferAction: BufferAction): Seq[Buffer] = - computeSequence.sequence.collect { case x: Compute => - pipelineToDescriptorSets(x.pipeline).map(setToBuffers).zip(x.pumpLayoutLocations).flatMap(x => x._1.zip(x._2)).collect { - case (buffer, action) if (action.action & bufferAction.action) != 0 => buffer + val fence = new Fence(device) + val commandBuffer = recordCommandBuffer(dataLength) + val pCommandBuffer = stack.callocPointer(1).put(0, commandBuffer) + val submitInfo = VkSubmitInfo + .calloc(stack) + .sType$Default() + .pCommandBuffers(pCommandBuffer) + + timed("Vulkan render command"): + val result = vkQueueSubmit(queue.get, submitInfo, fence.get) + if (result != VK_SUCCESS) { + throw new RuntimeException(s"Failed to submit command buffer: ${result}") } - }.flatten - - val stagingBuffer = new Buffer( - inputs.map(_.remaining()).max, - VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, - VMA_MEMORY_USAGE_UNKNOWN, - allocator - ) - - buffersWithAction(BufferAction.LoadTo).zipWithIndex.foreach { case (buffer, i) => - Buffer.copyBuffer(inputs(i), stagingBuffer, buffer.size) - Buffer.copyBuffer(stagingBuffer, buffer, buffer.size, commandPool).block().destroy() - } + fence.block() // Wait for completion + vkQueueWaitIdle(queue.get) // Ensure all queue operations are done - val fence = new Fence(device) - val commandBuffer = recordCommandBuffer(dataLength) - val pCommandBuffer = stack.callocPointer(1).put(0, commandBuffer) - val submitInfo = VkSubmitInfo - .calloc(stack) - .sType$Default() - .pCommandBuffers(pCommandBuffer) - - timed("Vulkan render command"): - check(vkQueueSubmit(queue.get, submitInfo, fence.get), "Failed to submit command buffer to queue") - fence.block().destroy() - - val output = buffersWithAction(BufferAction.LoadFrom).map { buffer => - Buffer.copyBuffer(buffer, stagingBuffer, buffer.size, commandPool).block().destroy() - val out = BufferUtils.createByteBuffer(buffer.size) - Buffer.copyBuffer(stagingBuffer, out, buffer.size) - out - } + buffersWithAction(BufferAction.LoadFrom).foreach { buffer => + vmaInvalidateAllocation(allocator.get, buffer.allocation, 0, VK_WHOLE_SIZE) + } - stagingBuffer.destroy() - commandPool.freeCommandBuffer(commandBuffer) - setToBuffers.keys.foreach(_.update(Seq.empty)) - setToBuffers.flatMap(_._2).foreach(_.destroy()) + val results = buffersWithAction(BufferAction.LoadFrom).map { buffer => + val out = BufferUtils.createByteBuffer(buffer.size) + Buffer.copyBuffer(buffer, out, buffer.size) + out + } + + stagingBuffer.destroy() + commandPool.freeCommandBuffer(commandBuffer) + setToBuffers.keys.foreach(_.update(Seq.empty)) + setToBuffers.flatMap(_._2).foreach(_.destroy()) - output + results + } catch { + case e: Exception => + throw e + } } def destroy(): Unit = diff --git a/src/main/scala/io/computenode/cyfra/vulkan/memory/Buffer.scala b/src/main/scala/io/computenode/cyfra/vulkan/memory/Buffer.scala index 91c27ec1..eff1278d 100644 --- a/src/main/scala/io/computenode/cyfra/vulkan/memory/Buffer.scala +++ b/src/main/scala/io/computenode/cyfra/vulkan/memory/Buffer.scala @@ -10,7 +10,9 @@ import org.lwjgl.system.MemoryUtil.* import org.lwjgl.util.vma.Vma.* import org.lwjgl.util.vma.VmaAllocationCreateInfo import org.lwjgl.vulkan.VK10.* -import org.lwjgl.vulkan.{VkBufferCopy, VkBufferCreateInfo, VkCommandBuffer} +import org.lwjgl.vulkan.{VkBufferCopy, VkBufferCreateInfo, VkCommandBuffer, VkBufferMemoryBarrier} +import org.lwjgl.system.MemoryUtil +import java.nio.ByteOrder import java.nio.{ByteBuffer, LongBuffer} import scala.util.Using @@ -20,6 +22,9 @@ import scala.util.Using */ private[cyfra] class Buffer(val size: Int, val usage: Int, flags: Int, memUsage: Int, val allocator: Allocator) extends VulkanObjectHandle { + // Add this field to the Buffer class to track mapping state + private var isMapped = false + val (handle, allocation) = pushStack { stack => val bufferInfo = VkBufferCreateInfo .calloc(stack) @@ -49,42 +54,271 @@ private[cyfra] class Buffer(val size: Int, val usage: Int, flags: Int, memUsage: memFree(byteBuffer) } - protected def close(): Unit = - vmaDestroyBuffer(allocator.get, handle, allocation) + /** mapToByteBuffer: Maps GPU memory directly to a ByteBuffer for CPU access */ + def mapToByteBuffer(): ByteBuffer = { + // Use stack allocation for temporary resources + pushStack { stack => + val pData = stack.callocPointer(1) + + // Map the memory + check(vmaMapMemory(allocator.get, allocation, pData), "Failed to map buffer memory") + isMapped = true // Mark as mapped + + try { + val mappedAddress = pData.get(0) + + if (mappedAddress == 0L) { + throw new RuntimeException("Failed to map memory: null pointer returned") + } + + // Create a direct ByteBuffer that points to the GPU memory + val buffer = MemoryUtil.memByteBuffer(mappedAddress, size) + + // IMPORTANT: The buffer is now directly mapping GPU memory + // DO NOT call unmap() until you're done with this buffer! + + // Return a duplicate to avoid position/limit changes affecting the original + val result = buffer.duplicate().order(ByteOrder.nativeOrder()) + result.position(0) + result.limit(size) + result + } catch { + case e: Exception => + // Clean up on error + vmaUnmapMemory(allocator.get, allocation) + isMapped = false // Mark as unmapped + throw e + } + // NOTE: We're not unmapping here because the caller needs access to mapped memory + } + } + + /** Unmaps memory previously mapped with mapToByteBuffer */ + def unmap(): Unit = { + try { + // Check if already unmapped to prevent double unmapping + if (!isMapped) { + println("Buffer memory already unmapped or never mapped") + return + } + + // Perform the unmapping + vmaUnmapMemory(allocator.get, allocation) + + // Mark as unmapped + isMapped = false + } catch { + case e: Exception => + println(s"Failed to unmap buffer memory: ${e.getMessage}") + e.printStackTrace() + throw e // Rethrow to notify caller + } + } + + def getSize: Int = size + + protected def close(): Unit = { + try { + if (isMapped) { + vmaUnmapMemory(allocator.get, allocation) + isMapped = false + } + vmaDestroyBuffer(allocator.get, handle, allocation) + } catch { + case e: Exception => + println(s"Exception in buffer close: ${e.getMessage}") + e.printStackTrace() + } + } } object Buffer { - def copyBuffer(src: ByteBuffer, dst: Buffer, bytes: Long): Unit = + // Add this helper method + def validateSize(requested: Long, available: Int): Int = { + if (requested <= 0) { + println("Invalid buffer size requested: " + requested) + return 0 + } + if (requested > Int.MaxValue) { + println(s"Buffer size too large (${requested}), capping at Int.MaxValue") + return Int.MaxValue + } + Math.min(requested.toInt, available) + } + + def copyBuffer(src: ByteBuffer, dst: Buffer, bytes: Int): Unit = { + // Validate parameters + if (src == null || dst == null) { + println("Source or destination buffer is null") + return + } + + val safeBytes = Math.min(Math.min(src.remaining(), dst.getSize), bytes) + if (safeBytes <= 0) { + println(s"Invalid copy size: srcRemaining=${src.remaining()}, " + + s"dstSize=${dst.getSize}, requested=${bytes}") + return + } + + // Create a temporary duplicate of the source buffer to avoid position changes + val srcCopy = src.duplicate() + srcCopy.limit(Math.min(src.position() + safeBytes, src.limit())) + + // Map memory with error checking + val pData = memAllocPointer(1) + try { + val result = vmaMapMemory(dst.allocator.get, dst.allocation, pData) + + if (result != 0) { + println(s"Failed to map memory, error code: $result") + return + } + + val data = pData.get(0) + if (data == 0L) { + println("Mapped memory address is null!") + return + } + + // Copy with bounds checking + try { + memCopy(memAddress(srcCopy), data, safeBytes) + } catch { + case e: Exception => + println(s"Exception during memory copy: ${e.getMessage}") + e.printStackTrace() + } + } catch { + case e: Exception => + println(s"Exception during buffer copy: ${e.getMessage}") + e.printStackTrace() + } finally { + try { + vmaUnmapMemory(dst.allocator.get, dst.allocation) + } catch { + case e: Exception => + println(s"Failed to unmap memory: ${e.getMessage}") + } + memFree(pData) + } + } + + def copyBuffer(src: ByteBuffer, dst: Buffer, bytes: Long): Unit = { + val safeBytes = Math.min(Math.min(src.remaining(), dst.getSize), bytes) + if (safeBytes <= 0) { + // Fix: Don't return Failure in a method returning Unit + println("Invalid copy size") + return // Just return without copying anything + } + + val srcCopy = src.duplicate() + srcCopy.order(ByteOrder.nativeOrder()) // Use native order + pushStack { stack => val pData = stack.callocPointer(1) check(vmaMapMemory(dst.allocator.get, dst.allocation, pData), "Failed to map destination buffer memory") val data = pData.get() - memCopy(memAddress(src), data, bytes) - vmaFlushAllocation(dst.allocator.get, dst.allocation, 0, bytes) + memCopy(memAddress(srcCopy), data, safeBytes) + vmaFlushAllocation(dst.allocator.get, dst.allocation, 0, safeBytes) vmaUnmapMemory(dst.allocator.get, dst.allocation) } + } def copyBuffer(src: Buffer, dst: ByteBuffer, bytes: Long): Unit = pushStack { stack => + val safeBytes = validateSize(bytes, Math.min(src.getSize, dst.remaining())) + if (safeBytes <= 0) { + println("Cannot copy zero or negative bytes") + return + } + val pData = stack.callocPointer(1) check(vmaMapMemory(src.allocator.get, src.allocation, pData), "Failed to map destination buffer memory") val data = pData.get() - memCopy(data, memAddress(dst), bytes) + + // Copy the data + memCopy(data, memAddress(dst), safeBytes) + + // Update the position of the destination buffer + dst.position(dst.position() + safeBytes) + vmaUnmapMemory(src.allocator.get, src.allocation) } - def copyBuffer(src: Buffer, dst: Buffer, bytes: Long, commandPool: CommandPool): Fence = + def copyBuffer(src: Buffer, dst: Buffer, bytes: Long, commandPool: CommandPool): Fence = { + // Validate parameters + if (src == null || dst == null) { + throw new IllegalArgumentException("Source or destination buffer is null") + } + + val safeBytes = validateSize(bytes, Math.min(src.getSize, dst.getSize)) + if (safeBytes <= 0) { + throw new IllegalArgumentException("Invalid copy size: " + bytes) + } + pushStack { stack => val commandBuffer = commandPool.beginSingleTimeCommands() + + // Source buffer memory barrier - ensure writes are visible + val srcBarrier = VkBufferMemoryBarrier.calloc(1, stack) + .sType(VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER) + .srcAccessMask(VK_ACCESS_MEMORY_WRITE_BIT) + .dstAccessMask(VK_ACCESS_TRANSFER_READ_BIT) + .srcQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED) + .dstQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED) + .buffer(src.get) + .offset(0) + .size(safeBytes) - val copyRegion = VkBufferCopy - .calloc(1, stack) + vkCmdPipelineBarrier( + commandBuffer, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, + null, + srcBarrier, + null + ) + + // Execute the copy + val copyRegion = VkBufferCopy.calloc(1, stack) .srcOffset(0) .dstOffset(0) - .size(bytes) + .size(safeBytes) vkCmdCopyBuffer(commandBuffer, src.get, dst.get, copyRegion) + + // Destination buffer barrier - ensure reads happen after copy + val dstBarrier = VkBufferMemoryBarrier.calloc(1, stack) + .sType(VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER) + .srcAccessMask(VK_ACCESS_TRANSFER_WRITE_BIT) + .dstAccessMask(VK_ACCESS_MEMORY_READ_BIT) + .srcQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED) + .dstQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED) + .buffer(dst.get) + .offset(0) + .size(safeBytes) + vkCmdPipelineBarrier( + commandBuffer, + VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + 0, + null, + dstBarrier, + null + ) + commandPool.endSingleTimeCommands(commandBuffer) } - + } + + /** wrapByteBuffer: Takes an existing ByteBuffer and wraps it with a new GPU buffer object, so you can manage that data in Vulkan. */ + def wrapByteBuffer(data: ByteBuffer, allocator: Allocator): Buffer = { + val buf = new Buffer(data.remaining(), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, 0, VMA_MEMORY_USAGE_CPU_TO_GPU, allocator) + val bytes = new Array[Byte](data.remaining()) + data.get(bytes) + data.rewind() + Buffer.copyBuffer(ByteBuffer.wrap(bytes), buf, bytes.length) + buf + } } diff --git a/src/test/scala/io/computenode/cyfra/api/BufferTest.scala b/src/test/scala/io/computenode/cyfra/api/BufferTest.scala new file mode 100644 index 00000000..21861529 --- /dev/null +++ b/src/test/scala/io/computenode/cyfra/api/BufferTest.scala @@ -0,0 +1,101 @@ +package io.computenode.cyfra.api + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.BeforeAndAfterEach +import org.scalatest.matchers.should.Matchers +import java.nio.ByteBuffer +import org.lwjgl.BufferUtils +import scala.util.{Success, Failure} + +class BufferTest extends AnyFunSuite with BeforeAndAfterEach with Matchers { + private var context: ComputeContext = _ + + override def beforeEach(): Unit = { + context = new ComputeContext(enableValidation = true) + } + + override def afterEach(): Unit = { + if (context != null) context.close() + } + + test("buffer creation") { + val bufferTry = context.createBuffer(1024, isHostVisible = true) + bufferTry.isSuccess shouldBe true + + val buffer = bufferTry.get + buffer.getSize should equal(1024) + buffer.isHostAccessible shouldBe true + + buffer.close() + } + + test("buffer copy from and to host") { + val bufferTry = context.createBuffer(16, isHostVisible = true) + bufferTry.isSuccess shouldBe true + + val buffer = bufferTry.get + + // Create test data + val testData = BufferUtils.createByteBuffer(16) + for (i <- 0 until 4) { + testData.putInt(i) + } + testData.flip() + + // Copy to buffer + val copyResult = buffer.copyFrom(testData) + copyResult.isSuccess shouldBe true + + // Copy back from buffer + val resultTry = buffer.copyToHost() + resultTry.isSuccess shouldBe true + + val result = resultTry.get + result.remaining() should equal(16) + + // Verify data + for (i <- 0 until 4) { + result.getInt() should equal(i) + } + + buffer.close() + } + + test("buffer duplicate") { + val bufferTry = context.createBuffer(16, isHostVisible = true) + bufferTry.isSuccess shouldBe true + + val buffer = bufferTry.get + + // Create test data + val testData = BufferUtils.createByteBuffer(16) + for (i <- 0 until 4) { + testData.putInt(i) + } + testData.flip() + + // Copy to buffer + buffer.copyFrom(testData) + + // Duplicate buffer + val duplicateTry = buffer.duplicate() + duplicateTry.isSuccess shouldBe true + + val duplicate = duplicateTry.get + + // Copy back from duplicate + val resultTry = duplicate.copyToHost() + resultTry.isSuccess shouldBe true + + val result = resultTry.get + result.remaining() should equal(16) + + // Verify data + for (i <- 0 until 4) { + result.getInt() should equal(i) + } + + buffer.close() + duplicate.close() + } +} \ No newline at end of file diff --git a/src/test/scala/io/computenode/cyfra/api/ComputeContextApiTest.scala b/src/test/scala/io/computenode/cyfra/api/ComputeContextApiTest.scala new file mode 100644 index 00000000..d1dc0239 --- /dev/null +++ b/src/test/scala/io/computenode/cyfra/api/ComputeContextApiTest.scala @@ -0,0 +1,132 @@ +package io.computenode.cyfra.api + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.BeforeAndAfterEach +import org.scalatest.matchers.should.Matchers +import org.joml.Vector3i +import java.nio.{ByteBuffer, ByteOrder} +import org.lwjgl.BufferUtils +import io.computenode.cyfra.vulkan.compute.Shader.loadShader +import scala.util.{Success, Failure, Try} + +class ComputeContextApiTest extends AnyFunSuite with BeforeAndAfterEach with Matchers { + private var context: ComputeContext = _ + + override def beforeEach(): Unit = { + println("Initializing ComputeContext...") + context = new ComputeContext(enableValidation = true) + } + + override def afterEach(): Unit = { + if (context != null) context.close() + } + + test("execute simple shader pipeline") { + try { + val shaderBytes = loadShader("copy_test.spv").array() + + var i = 5 // Skip header + while (i < shaderBytes.length / 4) { + val word = ((shaderBytes(i * 4) & 0xFF)) | + ((shaderBytes(i * 4 + 1) & 0xFF) << 8) | + ((shaderBytes(i * 4 + 2) & 0xFF) << 16) | + ((shaderBytes(i * 4 + 3) & 0xFF) << 24) + + val opCode = word & 0xFFFF + val wordCount = (word >> 16) & 0xFFFF + + if (opCode == 0x3E) { + println(s"Found OpStore at word $i") + } + + i += wordCount + } + } catch { + case e: Exception => println(s"Error parsing shader: ${e.getMessage}") + } + + println("Disassembling SPIR-V shader with spirv-dis:") + try { + val processBuilder = new ProcessBuilder("spirv-dis", "src/test/resources/copy_test.spv") + val process = processBuilder.start() + val output = scala.io.Source.fromInputStream(process.getInputStream).mkString + println(output.split("\n").filter(_.contains("Store")).mkString("\n")) + process.waitFor() + } catch { + case e: Exception => println("Could not disassemble shader: " + e.getMessage) + } + + println("Loading SPIR-V shader 'copy_test.spv'...") + val spirvCode = loadShader("copy_test.spv") + + println("Creating shader layout information...") + val layoutInfo = LayoutInfo(Seq( + LayoutSet(0, Seq(Binding(0, 4))), + LayoutSet(1, Seq(Binding(0, 4))) + )) + + println("Creating shader with ComputeContext...") + val shaderTry = context.createShader( + spirvCode, + new Vector3i(128, 1, 1), + layoutInfo + ) + shaderTry.isSuccess shouldBe true + + val shader = shaderTry.get + + println("Creating compute pipeline...") + val pipelineTry = context.createPipeline(shader) + pipelineTry.isSuccess shouldBe true + + val pipeline = pipelineTry.get + + val dataSize = 8 + println(s"Creating input buffer with size ${4 * dataSize} bytes...") + val inputBufferTry = context.createBuffer(4 * dataSize, isHostVisible = true) + inputBufferTry.isSuccess shouldBe true + + val inputBuffer = inputBufferTry.get + + val inputData = BufferUtils.createByteBuffer(4 * dataSize) + for (i <- 0 until dataSize) { + inputData.putInt(i) + } + inputData.flip() + + println("Copying data to input buffer...") + val copyResult = inputBuffer.copyFrom(inputData) + copyResult.isSuccess shouldBe true + + println(s"Creating output buffer with size ${4 * dataSize} bytes...") + val outputBufferTry = context.createBuffer(4 * dataSize, isHostVisible = true) + outputBufferTry.isSuccess shouldBe true + + val outputBuffer = outputBufferTry.get + + println("Executing compute pipeline...") + val executeTry = context.execute(pipeline, Seq(inputBuffer), Seq(outputBuffer), dataSize) + executeTry.isSuccess shouldBe true + + println("Reading results from output buffer...") + val resultTry = outputBuffer.copyToHost() + resultTry.isSuccess shouldBe true + + val result = resultTry.get + result.order(ByteOrder.nativeOrder()) + result.rewind() + + println("Verifying computation results...") + for (i <- 0 until dataSize) { + val expectedVal = i + 10000 + val actualVal = result.getInt() + actualVal should be(expectedVal) + } + + println("Cleaning up resources...") + outputBuffer.close() + inputBuffer.close() + pipeline.close() + shader.close() + } +} \ No newline at end of file diff --git a/src/test/scala/io/computenode/cyfra/api/PipelineTest.scala b/src/test/scala/io/computenode/cyfra/api/PipelineTest.scala new file mode 100644 index 00000000..b5c13d4a --- /dev/null +++ b/src/test/scala/io/computenode/cyfra/api/PipelineTest.scala @@ -0,0 +1,52 @@ +package io.computenode.cyfra.api + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.BeforeAndAfterEach +import org.scalatest.matchers.should.Matchers +import org.joml.Vector3i +import io.computenode.cyfra.vulkan.compute.Shader.loadShader +import scala.util.{Success, Failure} + +class PipelineTest extends AnyFunSuite with BeforeAndAfterEach with Matchers { + private var context: ComputeContext = _ + + override def beforeEach(): Unit = { + context = new ComputeContext(enableValidation = true) + } + + override def afterEach(): Unit = { + if (context != null) context.close() + } + + test("pipeline creation") { + // This test assumes "copy_test.spv" exists in the resources + val spirvCode = loadShader("copy_test.spv") + val layoutInfo = LayoutInfo(Seq( + LayoutSet(0, Seq(Binding(0, 4))), + LayoutSet(1, Seq(Binding(0, 4))) + )) + + val shaderTry = context.createShader( + spirvCode, + new Vector3i(128, 1, 1), + layoutInfo + ) + shaderTry.isSuccess shouldBe true + + val shader = shaderTry.get + val pipelineTry = context.createPipeline(shader) + pipelineTry.isSuccess shouldBe true + + val pipeline = pipelineTry.get + pipeline.getShader should equal(shader) + + // Test workgroup calculation + val workgroupCount = pipeline.calculateWorkgroupCount(1024) + workgroupCount.x should equal(8) // 1024 / 128 = 8 + workgroupCount.y should equal(1) + workgroupCount.z should equal(1) + + pipeline.close() + shader.close() + } +} \ No newline at end of file diff --git a/src/test/scala/io/computenode/cyfra/api/ShaderTest.scala b/src/test/scala/io/computenode/cyfra/api/ShaderTest.scala new file mode 100644 index 00000000..0fac52d4 --- /dev/null +++ b/src/test/scala/io/computenode/cyfra/api/ShaderTest.scala @@ -0,0 +1,68 @@ +package io.computenode.cyfra.api + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.BeforeAndAfterEach +import org.scalatest.matchers.should.Matchers +import org.joml.Vector3i +import java.nio.ByteBuffer +import io.computenode.cyfra.vulkan.compute.Shader.loadShader +import scala.util.{Success, Failure} + +class ShaderTest extends AnyFunSuite with BeforeAndAfterEach with Matchers { + private var context: ComputeContext = _ + + override def beforeEach(): Unit = { + context = new ComputeContext(enableValidation = true) + } + + override def afterEach(): Unit = { + if (context != null) context.close() + } + + test("shader creation") { + // This test assumes "copy_test.spv" exists in the resources + val spirvCode = loadShader("copy_test.spv") + val layoutInfo = LayoutInfo(Seq( + LayoutSet(0, Seq(Binding(0, 4))), + LayoutSet(1, Seq(Binding(0, 4))) + )) + + val shaderTry = context.createShader( + spirvCode, + new Vector3i(128, 1, 1), + layoutInfo + ) + + shaderTry.isSuccess shouldBe true + + val shader = shaderTry.get + shader.getWorkgroupDimensions should equal(new Vector3i(128, 1, 1)) + shader.getEntryPoint should equal("main") + shader.getLayoutInfo.sets.length should equal(2) + + shader.close() + } + + test("shader layout info") { + val spirvCode = loadShader("copy_test.spv") + val layoutInfo = LayoutInfo.standardIOLayout(1, 1, 4) + + val shaderTry = context.createShader( + spirvCode, + new Vector3i(128, 1, 1), + layoutInfo + ) + + shaderTry.isSuccess shouldBe true + + val shader = shaderTry.get + shader.getLayoutInfo.sets.size should equal(2) + shader.getLayoutInfo.sets(0).id should equal(0) + shader.getLayoutInfo.sets(1).id should equal(1) + shader.getLayoutInfo.sets(0).bindings.size should equal(1) + shader.getLayoutInfo.sets(1).bindings.size should equal(1) + shader.getLayoutInfo.sets(0).bindings(0).size should equal(4) + + shader.close() + } +} \ No newline at end of file