vk

index

Overview

A Vulkan compute device: one or two queues, the lanes, pipelines from SPIR-V, host-visible buffers, dispatches polled by fence, and readbacks into gfx surfaces. Compute only: no window, no swapchain.

Functions

NameSignature
openopen(opts) -> dev, err
packpack(fmt, ...) -> bytes

open(opts) -> dev, err

Open the one compute device a program uses

opts fielddescription
driveran ICD manifest to force; it becomes VK_DRIVER_FILES for the process
layerstrue keeps the host's implicit layers for this open; default off
appthe application name the driver sees, default "lilush"
prioritythe first queue's global priority, "low", "medium", "high" or "realtime"; nil asks for none
lanea second queue, { family = i or "compute", priority = word }; see "Lanes"

Call open before the gfx thread pool starts: threads made before the driver loads see none of its thread-local state.

A forced driver stays in the environment, so a later open without one reads it too. An empty string clears it, and the loader discovers again. Nil leaves the environment as it is. Tests force lavapipe this way, so their bytes are the software device's on every machine.

layers is each open's own, since the loader reads its say at the open: an open without it hides the implicit layers again after one that kept them. A VK_LOADER_LAYERS_DISABLE the process had before the first open is left as it is.

The device is the first with a compute queue, a GPU before a software one. A machine with both an Intel ICD and lavapipe computes on the GPU unless driver says otherwise.

Returns nil and a message when no driver loads, no device has a compute queue, the ABI bridge lacks a symbol the driver needs, or a priority, or a lane asked for by index, is not the device's to give.

Lanes

lane = { family = i, priority = word } opens a second queue beside the first; dispatch names it as lane 2, the first as lane 1. family is a 1-based index into info().families and must offer compute; nil takes the first queue's family, which must then count two queues or more. Vulkan puts one global priority on a family's queues, so two lanes of one family share one: a lane with no priority takes the first queue's, and info().lane.priority says so; a word on the lane alone, or one that differs from the first queue's, is refused.

family = "compute" asks by word for the first family other than the first queue's that offers compute and no graphics: an engine of its own on the devices that have one, where a short dispatch gets past a long one in flight on the first queue. By word the lane is taken where the device offers it. With no such family, or with the driver saying the family lacks the lane's priority, or unable to say, the open goes on with one queue: info().queues answers 1 and info().lane is nil, and the program decides. An index asks exactly, and the open refuses what the device cannot give.

A priority wants the driver's global priority extension, and the driver refuses one above the process's reach (high and realtime want CAP_SYS_NICE on Intel's). Asked for by index, or on the first queue, the open answers nil and a message naming the want; info().families says what each family offers where the driver answers the query.

With two lanes of different families every buffer is shared between them: a lane reads what the other wrote, with no ownership transfer. Whether a dispatch on lane 2 overtakes a long one in flight on lane 1 is the device's to say, not this module's.

Errors

Programmer errors raise: bad arguments, a span outside a buffer, a box outside a surface, a pipeline or buffer from another device. Runtime failures return nil and a message that names the call and the Vulkan result: no driver, a lost device, out of memory. A device lost under a job raises from done and wait, as a closed one does: the job will never answer.

The device

methoddescription
dev:info()the device's identity and limits, below
dev:pipeline(spirv, nbuffers, push_bytes)a compute pipeline, below
dev:buffer(bytes)a storage buffer, below
dev:dispatch(pipe, buffers, push, gx, gy, gz, lane)submit a job, below
dev:idle()wait for every submitted job; true, or nil and a message
dev:close()destroy its jobs, buffers and pipelines, then the device

close takes the device's jobs, buffers and pipelines with it, a running job waited for, so a program collects nothing first. After close, the methods of the device and of its buffers and jobs raise.

dev:info() returns a table:

fielddescription
namethe device's name
apithe Vulkan version it supports, e.g. "1.4.305"
vendorvendor id
devicedevice id
type"discrete", "integrated", "virtual", "cpu" or "other"
fp64true when shaders can use doubles; enabled when present
pushpush-constant bytes a pipeline can take, 128 at least
invocationsthe most invocations in one workgroup
groupthe largest workgroup size, {x, y, z}
groupsthe most workgroups one dispatch can count, {x, y, z}
alignthe storage buffer offset alignment, in bytes
rangethe largest storage buffer, in bytes
memoryone string per memory type, its flags as words: local, visible, coherent, cached, lazy
familiesone table per queue family: flags as words (graphics, compute, transfer, sparse, protected, video_decode, video_encode), count, the queues it offers, and priorities, the global priorities it offers as words, absent where the driver does not say
familythe first queue's family, a 1-based index into families
queuesthe lanes opened, 1 or 2
prioritythe first queue's global priority, when one was asked for
lanethe second lane's family and priority, or nil

Pipelines

dev:pipeline(spirv, nbuffers, push_bytes) builds a compute pipeline from SPIR-V bytes, the contents of a .spv file. The entry point is main. The shader binds nbuffers storage buffers, 0 to 16, at bindings 0 .. nbuffers - 1 of set 0. It takes one push-constant block of push_bytes, whole words; 0 for none. Returns the pipeline, or nil and a message. A count outside 0 to 16 answers nil too, not a raise: the cap guards the body gfx builds its pipeline through, so both read one refusal.

A pipeline can have 256 jobs in flight. Past that, dispatch returns nil until some are released or collected.

Buffers

dev:buffer(bytes) returns host-visible storage memory, zeroed and mapped for its life, or nil and a message. Writes flush and reads invalidate on memory that is not coherent, so the caller never does. Offsets count from 0, in bytes. A span outside the buffer raises.

methoddescription
buf:size()the size in bytes
buf:write(offset, s)write the bytes of s
buf:bytes(offset, len)read bytes; no arguments reads the whole buffer
buf:put_u32(offset, ...)write little-endian words, from the numbers given or from one table of them
buf:put_i32, put_f32, put_f64the same for the other word types
buf:get_u32(offset)read one word
buf:get_i32, get_f32, get_f64the same for the other word types
buf:range_f64(offset, count)the least and greatest of count doubles, skipping NaN; nil when all are NaN
buf:read_into(surface, offset, x, y, w, h)copy w*h RGBA8 texels onto a surface or canvas
buf:read_u16_into(surface, offset, x, y, w, h)copy w*h 16-bit words onto a surface as height texels
buf:land(surface)copy the surface's RGBA8 texels whole from offset 0, and hand the surface the buffer as its mirror

Both readbacks read row-major from offset and write the box at x, y, 1-indexed as the canvas counts. The box must lie inside the surface. read_u16_into writes each little-endian word as red (high byte), green (low byte), blue 0, alpha 255: the height texel sphere_tex reads. Both mark the surface over the box, as any canvas write does: its height pyramid stale, and its mirror, where gfx holds one, dirty.

land is the whole-map readback for a map the spheres read on the device. It copies the texels for the cores as read_into would. Where gfx draws on this device, the surface keeps the buffer as its mirror, so the texels are not sent to the device again. The buffer must hold at least the surface's w*h*4 bytes. A buffer a surface took is held: write, the put_* methods and binding it to a dispatch raise "the buffer is a surface's mirror since its landing". The hold ends when the surface lets go: at its collection, its resize, another land onto it, gfx.use_device(nil) or the device's close. Reading it stays allowed, land on another surface included. Where gfx is not bound to this device, land ends as a whole-map read_into does, and nothing is held. The hold is taken whether or not a call on the device ever reads the surface. A map that only calls under gfx's floor read holds its buffer with no reader, and gfx.device_stats().mirrors counts it as unread; a map no sphere on the device will read is read back with read_into.

Dispatches and jobs

dev:dispatch(pipe, buffers, push, gx, gy, gz, lane) records one command buffer and submits it with a fence. It binds the pipeline and buffers in binding order. push is a string of exactly push_bytes bytes, or a table of u32 words; nil for a pipeline without one. It dispatches gx x gy x gz workgroups; gy and gz default to 1. lane is 1, the default, or 2 where the open took a lane; any other raises. A barrier makes the shader's writes visible to the host. Returns a job, or nil and a message.

methoddescription
job:done()poll the fence; never waits. True once the job finished, false while it runs
job:wait(seconds)wait; no argument waits forever. True, or nil and "the job has not finished" on a timeout
job:release()free the job's descriptor set and command buffer now, waiting for it if it runs. The collector does the same later

A buffer holds the shader's results once done() returns true. A read before that reads whatever is there.

pack(fmt, ...) -> bytes

Pack numbers into little-endian bytes

One letter of fmt per value: u a u32, i an i32, f an f32, d an f64. Use it for a push block or a buf:write. Raises when the letter count differs from the value count, on an unknown letter, or on an integer out of its range.