The .vis language#
Write the physics. Vision makes it run, everywhere, the same way.
A .vis program declares canvases (grids of pixels with channels), kernels (the rule each
pixel runs), measures (one number over a whole canvas) and one run block (the order of
things). The compiler checks the whole program before anything runs on the GPU.
The full grammar is in Language reference.
Parameters#
param steps = 500
param alpha = 0.2
param N = 512
A param is a named numeric constant. An integer literal makes an integer parameter, a literal
with a decimal point makes a float. Override them from the command line without editing the file:
vision run model.vis --param steps=2000 --param alpha=0.1.
Canvases and channels#
canvas plate[N, N] edge clamp {
temperature
source
}
The size is
[width, height]and can use parameters.A channel is a 32-bit float per pixel (
f32, the default) or a 32-bit integer (name: i32), and starts at 0.edgedecides what is read outside the border:
Edge |
Reading outside the canvas gives |
|---|---|
|
the nearest pixel on the border |
|
the pixel on the opposite side (a torus) |
|
0 |
3-D canvases#
A third size makes a volume: the kernels get the coordinate z (the layer, from 0) and the size depth, and
every read of a 3-D canvas gives three coordinates. 2-D and 3-D canvases mix freely: a kernel on a surface reads
a column of the volume, a kernel on the volume reads the surface above it.
canvas rock[128, 128, 64] edge clamp { temperature }
canvas surface[128, 128] { sun }
kernel diffuse on rock {
temperature += 0.15 * (temperature[x - 1, y, z] + temperature[x + 1, y, z] + temperature[x, y - 1, z]
+ temperature[x, y + 1, z] + temperature[x, y, z - 1] + temperature[x, y, z + 1]
- 6 * temperature)
if z == 0 {
temperature = surface.sun[x, y]
}
}
run {
repeat 500 { diffuse }
save rock "output/rock.vti" // every channel of the canvas, for ParaView
}
.npyfiles of a 3-D canvas are[depth, height, width], forloadandsave..vti(VTK ImageData) opens in ParaView, VisIt and PyVista:save rock.temperature "t.vti"writes one channel andsave rock "rock.vti"every channel of the canvas. The values are the bits of the GPU (float32 or int32), and rows are written so that the picture is the same as the.png(y of VTK grows upwards); read back by VTK 9.7 they are the same bits.The HTML report shows the middle layer of a 3-D channel; its numbers are of the whole volume.
Measures,
scenarios, integer channels withflux()andvision.differentiatework in 3-D as in 2-D, with the same bits on every device (conformance casevis.volume).
A 7-point diffusion on 128 x 128 x 128 runs 200 steps in 25 ms on the RTX 4050 (about 17 billion cell updates per second) and in 74 ms on the Intel GPU, in the auditable arithmetic.
Integer channels: totals conserved exactly#
With an i32 channel the state is a count of units (here 1/1000 degree), and flux(d, s), d / 2^s rounded
half away from zero with flux(-d) = -flux(d), moves exactly as much out of a cell as into its neighbour:
canvas plate[512, 384] { heat: i32 }
kernel diffuse on plate {
heat += flux(heat[x - 1, y] - heat, 3) + flux(heat[x + 1, y] - heat, 3)
+ flux(heat[x, y - 1] - heat, 3) + flux(heat[x, y + 1] - heat, 3)
}
measure total = sum(heat) on plate
After 300 steps the total is 280 900 000 units, the same as at the start, with the same bits on numpy, NVIDIA, Intel and llvmpipe: no float simulation can promise that mass or energy does not drift.
Ensembles: uncertainty as a distribution#
scenarios n { ... } runs a block n times from the same state; in the kernels scenario is the index of the
scenario, so uncertain inputs can be drawn per scenario with rand(scenario, ...):
kernel diffuse on plate {
let alpha = 0.08 + 0.14 * rand(scenario, 0, 11) // the conductivity of this scenario
temperature += alpha * (temperature[x - 1, y] + temperature[x + 1, y]
+ temperature[x, y - 1] + temperature[x, y + 1] - 4 * temperature)
}
measure hot = count(temperature > 40) on plate
run {
setup
scenarios 64 {
repeat 120 { diffuse }
hot
}
print hot
}
hot: 64 measurements | first 473 | last 533 | min 269 | max 553 | mean 418.75 | p5 293 | median 417 | p95 543.8
The 64 values, and so the percentiles, are the same bits on numpy, NVIDIA, Intel and llvmpipe: the risk estimate of a report does not depend on the machine it ran on (64 scenarios of 120 steps on 160 x 120: 59 ms on the RTX 4050).
Calibration: fitting params to data#
vision.differentiate turns a program into one that also computes the derivatives of its channels and measures
with respect to chosen params: for every channel c that depends on a param p the new program has a channel
c__d_p, and every measure M gets M__d_p. The result is an ordinary .vis program, so the derivatives are the same bits on every device.
vision.calibrate uses them to fit params to data, within bounds:
from vision.calibrate import calibrate
fit = calibrate(open("plate.vis").read(), # loads plate.observed and has: measure misfit = sum((temperature - observed)^2)
{"alpha": (0.10, 0.01, 0.24), "power": (50.0, 1.0, 500.0), "loss": (0.0, 0.0, 0.05)},
"misfit", device="nvidia")
fit.params # {'alpha': 0.16999998392513851, 'power': 73.50000247230948, 'loss': 0.003999998602826023}
With data made by the same model at alpha 0.17, power 73.5 and loss 0.004 (120 x 90, 80 steps), the fit takes
5 steps and 13 runs, and the fitted params are the same bits on numpy, NVIDIA, Intel and llvmpipe (0.2 s on
Intel, 0.8 s on the RTX 4050, 1.2 s on numpy). The steps of the fit do not depend on the machine either. The derivatives agree with finite differences.
Integers, comparisons, rand and floor have no derivative: they are steps.
Kernels: the pixel rule#
kernel diffuse on plate {
let neighbours = temperature[x - 1, y] + temperature[x + 1, y]
+ temperature[x, y - 1] + temperature[x, y + 1]
temperature += alpha * (neighbours - 4 * temperature)
}
A kernel is the code of one pixel. The GPU runs it on every pixel of the canvas at once.
Inside a kernel:
xandyare the coordinates of the pixel;widthandheightare the size of the canvas;a channel name alone (
temperature) is the value of this pixel;channel[i, j]reads any pixel of the same canvas, andcanvas.channel[i, j]reads a pixel of another canvas;assigning to a channel (
temperature = ...,+=,-=,*=,/=) writes this pixel.
The pixel rules#
Each run of the kernel owns one pixel.
A channel by name is the state of that pixel.
channel[i, j]reads the state at the start of the step.Nobody writes to another pixel; the compiler rejects
temperature[x + 1, y] = ....Writes take effect in the next step.
Because of these rules there are no data races, no atomics and no dependence on the order in which the GPU schedules pixels.
Variables, conditions and loops#
kernel spread on plant {
let burning = state == 1 // let: cannot be changed
var heat = 0.0 // var: can be changed
for dx in -1 to 1 { // both ends included: -1, 0, 1
for dy in -1 to 1 {
heat += radiation[x + dx, y + dy]
}
}
if burning and heat > 10 {
state = 2
} else if heat > 5 {
state = 1
} else {
state = 0
}
}
letcreates an immutable value;vara mutable one. Every variable starts with a value.The bounds of a
formust be integers that are the same for every pixel (numbers, parameters,width,height). To depend on the pixel, put anifinside the loop.Comparisons cannot be chained: write
0 < v and v < 1.Line breaks are not significant and no
;is needed.
Types and arithmetic#
There are three types: int, float (32-bit) and bool. Channels always hold floats; a
boolean must be converted to be stored: v = float(x > 3) or v = select(x > 3, 1.0, 0.0).
Expression |
Result |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Operator precedence, from lowest to highest: or, and, not, comparisons
(== != < <= > >=), + -, * / %, unary -, ^.
Every value is finite#
Vision has no infinity and no NaN. A computation that overflows gives the largest float with the same sign, and every function has a finite value for every input:
Expression |
Result |
|---|---|
|
3.4028235e38 |
|
−3.4028235e38 |
|
0 |
|
π/2 |
This is the price of identical results on every GPU (see How determinism works). If a measure is ±3.4028235e38, some computation overflowed. A literal that does not fit in a float32 is a compile error.
The run block#
run {
load terrain.elevation "data/dem.asc" nodata 0
heat
repeat steps { diffuse average }
print average
print plate.temperature
print plate.temperature[128, 128]
save plate.temperature "out/plate.png"
save average "out/average.csv"
}
Command |
What it does |
|---|---|
|
runs the kernel once on every pixel |
|
takes one measurement and appends it to the measure’s series |
|
repeats the block |
|
fills a channel from a file (see Real data) |
|
the value, or for a series: first, last, minimum and maximum |
|
minimum, mean and maximum of the channel |
|
the value of one pixel |
|
saves the channel as an image ( |
|
saves the whole series of a measure |
Names#
Names of parameters, canvases, channels, kernels and measures are yours, in any language
(Unicode letters are accepted). They cannot be a keyword, a built-in function, or one of x,
y, width and height.
Common errors#
Message |
What to do |
|---|---|
|
write to |
|
use numbers, params, |
|
a typo |
|
|
|
declare it with |
|
|
|
the largest float32 is 3.4028235e38 |
|
reading another canvas always takes an index |
|
declare the canvas with the resolution of the file |
|
say which number replaces them: |