Writing Paint Programs
This guide walks through how to write Paint programs, building from minimal single-tile layouts to multi-tile pipelines with routing, replication, and auto-routing. Each section introduces new concepts with working examples.
For the full primitive reference, see Paint Language Reference.
1. Hello World: One Tile
The simplest possible paint program — a single PE running one kernel:
let t = tile()
t : code('add [N:N])
What happens:
tile()creates a 1×1 region at position(0, 0).code('add ...)attaches the Tungsten kerneladd.wto that tile.N:Nbinds the kernel parameterNto the paint variableN, which atest.rcrow sets (N=4) and the Makefile'sPAINTVARSpasses to paint.
There is no routing because the kernel operates only on local memory.
Makefile integration
The Makefile of the tutorial's lesson 1, Hello, wafer!, abridged:
include $(FLUTEMK)
include $(TESTMK)
PAINTVARS=N
PAINTFLAGS=lib=.. $(PAINTOPTS) verbose arch=${ARCH}
and the first row of its test.rc, which names the test and sets its variables:
tutorial N=4 ARCH=sdr
PAINTVARS lists the row variables that paint imports. PAINTFLAGS adds the
library search path where paint finds the .w files, any PAINTOPTS given
on the command line, and the target architecture.
2. Two Tiles: Send and Receive
To move data between tiles you need a color and routing:
let txcolor = color()
let send = tile()
send : code('send [tx:txcolor N:N])
send : paint(txcolor [C>R])
send : name('send)
let recv = tile()
recv : code('recv [rx:txcolor N:N])
recv : paint(txcolor [L>C])
recv : name('recv)
hstack(send recv)
Concepts introduced
| Concept | Syntax | Purpose |
|---|---|---|
| Color | color() | Allocates a routing channel |
| Socket binding | tx:txcolor | Binds a Tungsten socket to a color; paint picks its physical number |
| Route rule | [C>R] | PE sends from Center to Right |
| Route rule | [L>C] | PE receives from Left into Center |
| Naming | name('send) | Labels the region for debug and miget |
| Layout | hstack(send recv) | Places tiles side by side |
How data flows
send recv
┌──────┐ ┌──────┐
│ C>R │ ───▶ │ L>C │
│ │ │ │
└──────┘ └──────┘
x=0 x=1
The sender's C>R route sends what its CE puts on txcolor to its right
(east) neighbor. The receiver's L>C route takes what arrives from its left
(west) neighbor on the same color and delivers it to its CE.
3. Three-Tile Pipeline
A compute tile in the middle receives on one color and sends on another:
let rxcolor = color()
let txcolor = color()
let send = tile()
send : code('send [tx:rxcolor N:N])
send : paint(rxcolor [C>R])
send : name('send)
let compute = tile()
compute : code('compute [rx:rxcolor tx:txcolor N:N])
compute : paint(rxcolor [L>C])
compute : paint(txcolor [C>R])
compute : name('compute)
let recv = tile()
recv : code('recv [rx:txcolor N:N])
recv : paint(txcolor [L>C])
recv : name('recv)
hstack(send compute recv)
Key pattern: two colors through one tile
When data passes through a tile's CE (received, then sent on), it needs two distinct colors:
rxcolorcarries data into the tile (L>C)txcolorcarries data out of the tile (C>R)
A switch position takes one source, so one color cannot carry both L>C
and C>R in the same position. On one color this would take two
switch positions
(;) and an advance between them. Data that the router carries past the
CE, as in [L>R], needs only one color.
4. Rectangles and Boundary Routing
For 2D grids, use rect() and boundary operators:
let [a b c d a' b' c' d'] = color()
let grid = rect(0 0 W H 'x 'y)
(* Outbound routing with boundary suppression *)
grid : paint(a' [CXR|R]) (* send right, suppress at right edge *)
grid : paint(b' [CXL|L]) (* send left, suppress at left edge *)
grid : paint(c' [CXU|U]) (* send up, suppress at top edge *)
grid : paint(d' [CXD|D]) (* send down, suppress at bottom edge *)
(* Inbound routing with boundary suppression *)
grid : paint(a [L|LXC]) (* receive from left, suppress at left edge *)
grid : paint(b [R|RXC]) (* receive from right, suppress at right edge *)
grid : paint(c [D|DXC]) (* receive from below, suppress at bottom edge *)
grid : paint(d [U|UXC]) (* receive from above, suppress at top edge *)
(* Declare swap pairs *)
grid : colorpair(a a')
grid : colorpair(b b')
grid : colorpair(c c')
grid : colorpair(d d')
Boundary operator |
| before > means "don't accept from this direction at the boundary":
grid : paint(c [L|L>R]) (* leftmost column doesn't accept from LEFT *)
| after > means "don't send in this direction at the boundary":
grid : paint(c [L>R|R]) (* rightmost column doesn't send RIGHT *)
The boundary is the edge of the rect() the rule is painted on. Apply
boundary rules to one rect() that covers the grid, as here. On a region
joined with union, hstack or vstack, Paint cuts the route inside the
region (see Boundary operators).
Color swap (X, H, V)
X replaces >. In [CXR|R] on a', it makes a' take what the core
sends on a. In [L|LXC] on a, it makes a take a' arriving from the
left neighbor. So the kernel sends right and receives from the left with the
one socket a, and every tile runs the same rules. A color takes input from
one port at a time, so without swap a uniform row needs colors that
alternate from tile to tile.
5. Using Functions
Paint supports user-defined functions to encapsulate common patterns:
define make_pipeline_tile(kernel rx_color tx_color params) {
let t = tile()
t : code(kernel params)
t : paint(rx_color [L>C])
t : paint(tx_color [C>R])
t
}
define make_sender(kernel color params) {
let t = tile()
t : code(kernel params)
t : paint(color [C>R])
t
}
define make_receiver(kernel color params) {
let t = tile()
t : code(kernel params)
t : paint(color [L>C])
t
}
Usage:
let c1 = color()
let c2 = color()
let s = make_sender('send c1 [N:N tx:c1])
s : name('send)
let m = make_pipeline_tile('compute c1 c2 [N:N rx:c1 tx:c2])
m : name('compute)
let r = make_receiver('recv c2 [N:N rx:c2])
r : name('recv)
hstack(s m r)
6. Replication Patterns
rep — simple replication
let row = tile()
row : code('pe [N:N])
let grid_row = rep(row 'x 8 1 0) (* 8 copies, each +1 in x *)
hstackrep / vstackrep — stacking with bounding-box awareness
let cell = tile()
cell : code('pe [N:N])
let row = hstackrep(cell 8) (* 8 copies horizontally *)
let col = vstackrep(cell 4) (* 4 copies vertically *)
let row_named = hstackrep(cell 8 0 'col) (* named dimension *)
let spaced = hstackrep(cell 4 2) (* a 2-tile gap between copies *)
arrayplace — non-uniform placement
let t = tile()
t : code('pe [idx:@rank])
let offsets = [0 3 5 10]
let ranks = [0 1 2 3]
let layout = arrayplace(t offsets [rank:ranks])
Each copy is placed at the corresponding Y offset and named x0, x1, etc.
Optional geo-variable with arrayplace
let ids = [100 200 300 400]
let layout_with_ids = arrayplace(t offsets [rank:ranks id:ids])
This continues the preceding example: both @rank and @id are defined
for every copy; placement still uses offsets, not the ID values.
7. Geo-Variables in Detail
Geo-variables pass tile-dependent values to kernels. Paint compiles each distinct resolved parameter combination once and reuses matching variants:
(* Define regions with different geo-values *)
let left = tile(0 0)
left : geovar('rank 0)
let right = tile(1 0)
right : geovar('rank 1)
(* Both tiles use the same code, but @rank differs *)
let both = rect(0 0 2 1)
both : code('kernel [id:@rank])
both : union(left right)
The @ prefix in code() references the geo-variable. The geo-variable
must be defined on every PE where the corresponding code attribute is applied.
Here the geo-variables sit on left and right, beside the code region
both. Setting them on parts of a region and then applying code() to that
whole region does not work: paint ignores them without a message (see
Geo-Variables).
8. Dispatch Pattern: RPC over a Color
This is the pattern used for wavelet-driven dispatch in Tungsten:
let cmdcolor = color()
let rxtx = color()
let send = tile()
send : code('send [tx:cmdcolor])
send : paint(cmdcolor [C>R])
send : name('send)
let compute = tile()
compute : code('spmv [dotrx:cmdcolor y:rxtx])
compute : paint(cmdcolor [L>C])
compute : paint(rxtx [C>R])
compute : name('spmv)
let recv = tile()
recv : code('recv [rx:rxtx N:N])
recv : paint(rxtx [L>C])
recv : name('recv)
hstack(send compute recv)
The compute tile uses cmdcolor for incoming command wavelets (dispatch)
and rxtx for outgoing result data. This maps directly to Tungsten's
dispatch() block and socket model.
9. Auto-Routing with Terminals and Transports
For complex layouts where tiles are not adjacent, use terminals and transports instead of manual routing:
let c = color()
(* Sender tile with a terminal *)
let a = tile(0 0)
a : paint(c [C>R])
a : terminal('tx 'data RIGHT c [1 1] [1 1] 1)
a : name('a)
(* Receiver tile with a terminal *)
let b = tile(5 0)
b : paint(c [L>C])
b : terminal('rx 'data CENTER c [1 1] [1 1] 1)
b : name('b)
(* Connect them *)
let arc = transport('a.tx 'b.rx 'arc)
(* Merge everything *)
let all = union(a b arc)
Terminal rules
- Terminals must share the same tag to be connected.
- Both terminals use one color: paint gives the two terminals of a transport the same physical color.
- Each terminal needs a
paint()rule on its PE that routes its color through its side, such as[C>R]forRIGHT; without one, paint stops withhas no pins defined. - The region must be named before the terminal can be referenced.
- Apply
name()afterterminal(). - A transport names each terminal by its path of region names, such as
'a.txor'outer.inner.rx.
If paint cannot connect a transport, it prints
port(s) of arc a.tx -> b.rx not found and still writes the image, which
then has no route: the receiver waits forever. A transport left out of the
union gives no message at all.
Using aliases for hierarchy
alias() gives a nested terminal a name on the outer region, so a
transport can name 'outer.rx without knowing what is inside outer:
let inner = tile(5 0)
inner : paint(c [L>C])
inner : terminal('rx 'data CENTER c [1 1] [1 1] 1)
inner : name('inner)
let outer = rect(5 0 2 1)
outer : union(inner)
outer : alias('rx 'inner.rx)
outer : name('outer)
let arc = transport('a.tx 'outer.rx 'arc)
10. Dictionaries for Configuration
Dictionaries provide keyword-argument-style parameter passing:
define make_module(options) {
let [width height depth] = query(options [width:4 height:4 depth:1])
let r = rect(0 0 width height)
r : code('kernel [W:width H:height D:depth])
r
}
(* Use with custom values *)
let m1 = make_module([width:8 height:8]) (* depth defaults to 1 *)
(* Use with all defaults *)
let m2 = make_module([])
query(dict defaults) looks up each key from defaults in dict,
falling back to the default value if not found. Keys of dict that are not
in defaults are ignored without a warning, so a misspelt key such as
[widht:3] leaves the default.
11. Conditional Logic
Paint has no if/else statement, but setif() provides conditional
expressions:
let use_buffer = lt(N 16)
let r = setif(use_buffer make_small(N) make_large(N))
Warning: both branches are always evaluated. Both must be valid expressions even if only one is used.
(* Conditional region inclusion *)
let optional = setif(ENABLE make_extra_tile() rect(0 0 0 0))
let all = union(main optional)
An empty rectangle rect(0 0 0 0) serves as a no-op region.
hstackrep(r 0) is another way to conditionally exclude a region.
12. Switch Routing (Multi-Phase)
For kernels that change a tile's routing while they run, for example to take turns on a shared color:
t : paint(c [L>R; L>U; D>U; ring pop=all])
This configures three switch positions:
- Accept from left, send right
- Accept from left, send up
- Accept from down, send up
The ring flag makes the switch cycle back to position 0 after the last
position. ring and pop=all are modifiers, not positions.
Pop modes
| Mode | Description |
|---|---|
pop=all | Unconditional pop |
pop=adv | Pop on advance |
pop=advnop | Pop on advance or nop |
A pop removes command 0 of a control wavelet as it leaves the router, so the next router acts on the next command; see Switch Positions.
13. Includes and Library Structure
Break large paint files into reusable modules:
(* common.paint *)
define make_sender(kernel color N) {
let t = tile()
t : code(kernel [tx:color N:N])
t : paint(color [C>R])
t
}
(* main.paint *)
include "common.paint"
let c = color()
let s = make_sender('send c N)
s : name('send)
Library paths are specified in the Makefile:
PAINTFLAGS = lib=../common lib=../kernels
14. Passthrough Routing
passthrough(region color_in color_out) links colors for bus and
entry-point assignment. It does not program any router routes.
To forward data without touching the PE, use an explicit route:
(* Pass data through without touching the PE *)
let c = color()
let t = tile()
t : paint(c [L>R]) (* horizontal pass-through *)
let vertical = tile(0 1)
vertical : paint(c [D>U]) (* vertical pass-through on another tile *)
union(t vertical)
15. Complete Real-World Example: 9-Point Stencil
A 2D stencil computation where each PE communicates with its four
neighbors. This section shows the layout; the bounds and ninept kernels it
attaches are not shown, and W and H are set on the paint command line:
(* Allocate 8 colors: 4 directions × send/receive *)
let [a b c d a' b' c' d'] = color()
(* Boundary tiles run a special kernel *)
let shore = union(
rect(0 0 W 1) (* bottom row *)
rect(0 H-1 W 1) (* top row *)
rect(0 1 1 H-2) (* left column *)
rect(W-1 1 1 H-2) (* right column *)
)
shore : code('bounds [a:a b:b c:c d:d])
(* Interior tiles run the stencil kernel *)
let lake = rect(1 1 W-2 H-2)
lake : code('ninept [a:a b:b c:c d:d])
(* All tiles share the same routing *)
let beach = rect(0 0 W H 'x 'y)
beach : paint(a' [CXR|R])
beach : paint(b' [CXL|L])
beach : paint(c' [CXU|U])
beach : paint(d' [CXD|D])
beach : paint(a [L|LXC])
beach : paint(b [R|RXC])
beach : paint(c [D|DXC])
beach : paint(d [U|UXC])
beach : colorpair(a a')
beach : colorpair(b b')
beach : colorpair(c c')
beach : colorpair(d d')
let prog = union(beach lake shore)
prog : name('ninept)
Anatomy
- 8 colors: Each direction needs a send color and a receive color
(paired via
colorpair). Xswap: on each tile,a'carries what the core sends ona, andadelivers to the core what arrives ona'. The kernels bind onlya,b,candd.- Boundary
|: Prevents routing off the edge of the grid. The rules are painted onbeach, onerect()over the whole grid, because a|rule acts at the edges of each rectangle it is painted on; onunion(lake shore)it would cut routes inside the grid. - Two code regions:
boundson the perimeter,nineptin the interior. They share the same routing since the route rules are applied to the fullbeachrectangle.
Common Patterns Summary
| Pattern | Paint idiom |
|---|---|
| Single tile | tile() + code() |
| Send right | paint(c [C>R]) |
| Receive from left | paint(c [L>C]) |
| Pipeline tile | Two colors: paint(c1 [L>C]) + paint(c2 [C>R]) |
| Horizontal chain | hstack(a b c) |
| 2D grid | rect(0 0 W H) with boundary | routing |
| Neighbor swap | colorpair() + X swap modifier |
| Conditional region | setif(flag region rect(0 0 0 0)) |
| Reusable module | define f(...) { ... } + include |
| Position-dependent params | geovar() + @var in code |
| Complex layout | terminal() + transport() + union() |