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.

  • edge decides what is read outside the border:

Edge

Reading outside the canvas gives

clamp (default)

the nearest pixel on the border

wrap

the pixel on the opposite side (a torus)

zero

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
}
  • .npy files of a 3-D canvas are [depth, height, width], for load and save.

  • .vti (VTK ImageData) opens in ParaView, VisIt and PyVista: save rock.temperature "t.vti" writes one channel and save 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 with flux() and vision.differentiate work in 3-D as in 2-D, with the same bits on every device (conformance case vis.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:

  • x and y are the coordinates of the pixel; width and height are 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, and canvas.channel[i, j] reads a pixel of another canvas;

  • assigning to a channel (temperature = ..., +=, -=, *=, /=) writes this pixel.

The pixel rules#

  1. Each run of the kernel owns one pixel.

  2. A channel by name is the state of that pixel.

  3. channel[i, j] reads the state at the start of the step.

  4. Nobody writes to another pixel; the compiler rejects temperature[x + 1, y] = ....

  5. 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
    }
}
  • let creates an immutable value; var a mutable one. Every variable starts with a value.

  • The bounds of a for must be integers that are the same for every pixel (numbers, parameters, width, height). To depend on the pixel, put an if inside 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

7 / 2

3.5: / is always real division

7 % 3

1: integer remainder; with floats, the remainder of the truncated division

x ^ 2, (-2) ^ 2

x * x, 4: integer powers up to 16 are exact products and accept a negative base

x ^ 0.5

pow(x, 0.5)

-x ^ 2

-(x ^ 2): ^ binds tighter than unary minus and is right-associative

int(2.9), int(-2.9)

2, -2: truncates and saturates at the limits of int

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

1 / 0

3.4028235e38

log(0)

−3.4028235e38

sqrt(-1)

0

asin(2)

π/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

kernel_name

runs the kernel once on every pixel

measure_name

takes one measurement and appends it to the measure’s series

repeat n { ... }

repeats the block n times

load canvas.channel "file" [nodata v]

fills a channel from a file (see Real data)

print measure

the value, or for a series: first, last, minimum and maximum

print canvas.channel

minimum, mean and maximum of the channel

print canvas.channel[i, j]

the value of one pixel

save canvas.channel "file.png"

saves the channel as an image (.npy saves the raw floats)

save measure "file.csv"

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

pixel rule: a kernel only writes to its own pixel

write to v, not to v[x + 1, y]; let the other pixel read you

the bounds of a for must be integers that are the same for every pixel

use numbers, params, width or height; use an if inside the loop to depend on the pixel

unknown name 'temperatre'; did you mean 'temperature'?

a typo

channels hold numbers; convert with float(...)

v = float(x > 3)

'a' was created with let (immutable); use var to be able to change it

declare it with var

the channel 'height' uses a reserved name

width and height are the canvas size; pick another name

the number (1e+39) does not fit in float32

the largest float32 is 3.4028235e38

to read another canvas, give the pixel: a.p[x, y]

reading another canvas always takes an index

'dem.asc' is 3x2 (width x height) and the canvas 'map' is 2x3

declare the canvas with the resolution of the file

2 cells without data (NaN, or the NODATA_value of an .asc)

say which number replaces them: load t.elevation "dem.asc" nodata 0