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.
| Name | Signature |
|---|---|
| open | open(opts) -> dev, err |
| pack | pack(fmt, ...) -> bytes |
open(
opts) ->dev,err
Open the one compute device a program uses
| opts field | description |
|---|---|
driver | an ICD manifest to force; it becomes VK_DRIVER_FILES for the process |
layers | true keeps the host's implicit layers for this open; default off |
app | the application name the driver sees, default "lilush" |
priority | the first queue's global priority, "low", "medium", "high" or "realtime"; nil asks for none |
lane | a 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.
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.
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.
| method | description |
|---|---|
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:
| field | description |
|---|---|
name | the device's name |
api | the Vulkan version it supports, e.g. "1.4.305" |
vendor | vendor id |
device | device id |
type | "discrete", "integrated", "virtual", "cpu" or "other" |
fp64 | true when shaders can use doubles; enabled when present |
push | push-constant bytes a pipeline can take, 128 at least |
invocations | the most invocations in one workgroup |
group | the largest workgroup size, {x, y, z} |
groups | the most workgroups one dispatch can count, {x, y, z} |
align | the storage buffer offset alignment, in bytes |
range | the largest storage buffer, in bytes |
memory | one string per memory type, its flags as words: local, visible, coherent, cached, lazy |
families | one 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 |
family | the first queue's family, a 1-based index into families |
queues | the lanes opened, 1 or 2 |
priority | the first queue's global priority, when one was asked for |
lane | the second lane's family and priority, or nil |
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.
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.
| method | description |
|---|---|
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_f64 | the same for the other word types |
buf:get_u32(offset) | read one word |
buf:get_i32, get_f32, get_f64 | the 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.
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.
| method | description |
|---|---|
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.