Skip to main content

Program structure and data

Tungsten is a domain-specific programming language designed for programming processing elements (PEs) on wafer-scale hardware. It provides high-level abstractions for data-parallel computation, inter-PE communication via sockets, and fine-grained control over memory and execution. Tungsten programs are compiled to microcode that runs directly on individual PEs.

File extension: .w


1. Program Structure​

A Tungsten program is a sequence of top-level declarations and statements. There is no required main function — the top-level statements themselves define the program's behavior. A typical program declares data, communication endpoints (sockets), and computation logic.

// Declare parameters (configurable at compile time)
xp param N;

// Declare data
xp a[n=N]; // no initialiser — data loaded via miset
xp b = 1;

// Compute: add b to every element of a
i in [0, N) a[i] <- a[i] + b;

Statements are terminated by semicolons (;); a newline does not end a statement. A statement block's closing } needs no semicolon. A struct, union or typedef declaration does: struct P { xp x; xp y; };. Multiple declarations and computation blocks can appear at the top level.

Named Execution Blocks​

You can optionally name top-level blocks of code with an identifier:

main {
i in [0, N) a[i] = a[i] + 1;
}

The name is only a label. Named and unnamed top-level blocks run in source order, a name may repeat, and main is not an entry point.

Lexical Name Visibility​

Value and type names are visible from their declaration to the end of the enclosing { } block and in nested blocks entered after that declaration. Scalars and arrays follow the same rule. A reference cannot use a declaration later in the same scope or one in a child or sibling scope. An inner declaration may shadow an outer declaration while the inner scope is active.

Array names are unique within one scope: declaring a second array with the same name in the same scope is an error (duplicate array declaration), whether the extents differ or not and whether either declaration carries a [[...]] annotation. An annotation does not open a scope. An array declared in a nested block may still shadow an outer array. Arrays spliced in by a whole-package import share the importer's global scope, so they collide with same-named global arrays of the importer (see Imports).

A scalar name may also be declared only once in a scope, but Carbide does not always say so: inside a block, a second xp q is accepted and acts as an assignment, and at top level it fails with an internal error, IRCore verification failed: duplicate symbol '@q'.

Function arguments are visible in their function body. A loop's bounds see the names of the enclosing scope and the loop's earlier iterators, so i in [0, 4) j in [i, 4) walks a triangle. An iterator is also visible in the loop body. The iterators that a ∑ names are visible only in its operands.

A function call may name a function declared later in the file. Its arguments follow the rules above.

These rules describe name lookup only; they do not specify storage allocation or storage lifetime.


2. Comments​

Tungsten supports C-style comments:

// Single-line comment

/* Multi-line
comment */

3. Data Types​

Numeric Types​

These are the types a kernel normally uses:

TypeDescription
xpNative 16-bit PE integer — indices, loop counters, general integer work
u1616-bit unsigned integer
s1616-bit signed integer — arithmetic on it gives the same results as on xp, which is the type kernels normally use
u3232-bit unsigned integer
s3232-bit signed integer
u6464-bit unsigned integer
s6464-bit signed integer
u1Boolean / predicate — see below
hpHalf-precision float (16-bit) — the same format as f16
spSingle-precision float (32-bit, stored as 2 × 16-bit words) — the same format as f32
epExtended half-precision float (19-bit): the precision of hp with the range of sp — see below

On SDR, u64 and s64 are storage types with no numeric operations. A 64-bit array can be initialised: the image holds each element as eight little-endian bytes. Each literal is converted to the element type as in C, so it keeps all 64 bits without a suffix (see Arrays and Typed Literals):

u64 x[m=2] = { 0x0123456789abcdef, 0xfedcba9876543210 };
u16 xw[2][4] address(&x); // the same storage as four 16-bit words per element

Apart from initial data, a 64-bit value comes either from the sparse __builtin_merge form (MERGES32, see Hardware Intrinsics) or from a descriptor stored for runtime stream selection (see Streams). A MERGES32 value can be sent, or stored into a 64-bit array by a loop such as i in [0, 2) o[i] = __builtin_merge(ia[i], va[i]);. Every other use of a 64-bit value is rejected at compile time. This covers reads and writes of scalar u64/s64 variables, their initialisers, copies between 64-bit arrays, sends of 64-bit elements, arithmetic, comparisons, conditions, selects, bitwise operators, conversions and bitcasts. To read or write the words of a 64-bit array, declare a u16 or u32 alias over it with address(&array).

hp and sp are the half- and single-precision IEEE formats — the same layouts usually written f16 and f32, though those spellings are not accepted in Tungsten source.

The widths u2–u15 and s2–s15 size the fields of a packed wavelet: declare a value you pack with wavelet(...), or a handler parameter that unpacks one, with its field width (see Bit-Packed Wavelet Arguments). Use u16/s16 or wider for other variables. Elsewhere Carbide accepts these widths but stores and computes them in 16 bits: they do not wrap at their width, so a u4 set to 3 + 14 holds 17.

Booleans and Predicates (u1)​

u1 is the boolean type. A u1 value does not live in a general-purpose register: a scalar occupies the predicate file, and a u1 array is word storage holding 0 or 1. A u1 is produced by a comparison and is typically consumed by a branch, a ternary, or a cast.

Tungsten has no bool keyword. The conventional spelling is a typedef:

typedef u1 bool;

bool flag = 1; // constant initializer
bool p = a[0] < b[0]; // from a comparison
c[0] = p ? 1 : 0; // consumed by a ternary

An array of u1 holds one truth value per element, stored as a 16-bit word of 0 or 1:

u1 vflag[N];
i in [0, N) vflag[i] = (a[i] < b[i]);

A u1 used where a number is expected widens implicitly to 0 or 1 (p + 1 is 1 or 2); an explicit cast or a ternary states the intent. This holds for a comparison stored in a u1 first. A comparison used directly as a value is the hardware compare mask instead: xp t = a < b; is -1 when true, and so is (a < b) * 1. Only as an operand of + or - does it read as 1 (see Comparisons as values).

Extended Precision (ep)​

ep is the hardware's extended half-precision format: one sign bit, an 8-bit exponent and a 10-bit mantissa. That is the exponent range of sp with the significand of hp — magnitudes up to roughly 3.4 × 10³⁸, carrying about three decimal digits.

It exists for multiplication. Where a computation needs more range than hp can represent but not the precision of sp, ep lets the multiplier run at a higher rate: the datapath retires four extended-precision products in the time it takes one single-precision product on SDR.

Storage. An ep value occupies the same 32-bit word as sp and uses the same IEEE-754 layout; the low 13 mantissa bits are unused. ep therefore saves no memory — the gain is throughput, not footprint.

What to expect. Results carry the precision of hp over the range of sp. A value needing more than 10 mantissa bits will not survive a round trip; the discarded bits are gone. A subnormal whose mantissa is zero above bit 13 is treated as zero. A NaN stays a NaN even when only the discarded bits distinguish it from infinity.

Rounding. The two directions differ. Arithmetic that produces an ep result rounds to the format, whereas a value consumed as a multiply operand has its low 13 mantissa bits discarded outright. A converted value and a computed one may therefore differ in the last bit, so do not write code that depends on the two agreeing.

Casting. Widening is exact and free:

ep e;
sp s = e; // every ep value is already a valid sp

The extended format is a subset of single precision and shares its memory layout, so nothing is converted and nothing is lost.

Narrowing gives up precision. Assignment narrows implicitly; a cast makes the point explicit:

sp x, y;
ep acc;

acc = (ep)(x * y); // explicit narrowing

The compiler tracks ep as distinct from sp throughout compilation, and assignment converts between them in either direction without a cast. Writing the cast keeps every point at which precision is given up visible in the source.

Reinterpreting a 32-bit Value​

To read a 32-bit value as two 16-bit halves (or vice versa), use a union (see Section 6 — Unions):

union wavelet {
u32 u; // 32-bit unsigned integer view
xp w[2]; // two 16-bit integer halves: w[0]=lower, w[1]=upper
};

Composite Types​

Structs, unions and typedefs are written with the keyword itself; there is no separate type name for them in a declaration:

struct point { xp x; xp y; };
union bits { sp f; u32 u; };

typedef sp real; // an existing base type
typedef struct point point_t; // a struct or union

See Section 6 — Structs and Unions and Section 7 — Typedefs.

Type Sizes​

sizeof reports storage in units of 16-bit words:

TypeSize (words)
xp1
u11
u161
hp1
u322
s322
sp2
ep2
u644
s644

sizeof is resolved at compile time. A 32-bit struct member starts at an even word, so a struct can take more words than its members add up to. sizeof of an array is the size of the whole array:

struct mix { xp a; sp b; };
xp v[4];

sizeof(xp) // 1
sizeof(sp) // 2
sizeof(struct mix) // 4: b starts at word 2
sizeof(v[0]) // 1, the size of an expression's type
sizeof(v) // 4

4. Variables​

Variable Declarations​

Variables are declared with a type followed by a name, and optionally an initial value:

xp x;
xp y = 5;
sp pi = 3.14159;
hp temperature = 0.0;

Multiple variables can be declared on a single line:

xp a = 1, b = 2, c = 3;

Initializers in a list run left to right. Each declarator is visible from its own name onward, so a later initializer reads earlier declarators, while an earlier initializer that names a later declarator reads the enclosing declaration of that name. A declarator's own initializer cannot use its name, even when an enclosing declaration has the same one:

xp x = 10, w = 1;
{
xp y = x + 1, x = y + 1; // y = 11 (outer x), inner x = 12
xp w = w + 1; // error: 'w' is used before declaration
}

Volatile Variables​

Use the volatile keyword to prevent the compiler from optimizing away reads and writes:

volatile xp run = 0;

5. Arrays​

Declaration​

Arrays are declared with dimensions in brackets. Dimension names are optional:

xp a[n=4]; // 1D array with named dimension 'n'
xp b[4]; // 1D array (unnamed dimension)
sp matrix[m=3][n=4]; // 2D array
hp tensor[a=2][b=3][c=4]; // 3D array

Dimension sizes can use parameters and expressions:

xp param N;
xp data[n=N];
xp grid[n=2*N][m=N+1];

What Dimension Names Are For​

A dimension name is not a comment. It is recorded with the array in the symbol map, and the image tools address the dimension by that name when they load data into the compiled image or read results back — selecting a slice, pinning a single index, or changing the order of iteration:

miget image=bench.img map=bench.map symbol=data [n=5:10] # slice
miget image=bench.img map=bench.map symbol=grid [m n] # iterate m outermost

Leaving a dimension unnamed does not remove it from the map. The compiler supplies a name instead, numbering dimensions n0, n1, n2 and so on in declaration order, so an unnamed dimension is still addressable — just under a generated name rather than a meaningful one. Naming dimensions yourself is what keeps those commands readable, and what keeps them stable when an array gains a dimension. An array of a 32-bit type (sp, u32) has one more dimension in the map, _word<2, for the two 16-bit words of each element.

See miset and miget for the full syntax.

Initialization​

Arrays can be initialized with literal values:

xp a[n=4] = {1, 2, 3, 4};

Integer initializers convert each literal to the element type as in C (see Typed Literals); initialized data keeps all 64 bits:

u64 x[n=2] = {0x0123456789abcdef, -1}; // 0x0123456789abcdef, 0xffffffffffffffff
s64 y[n=2] = {0x7fffffffffffffff}; // a single element fills the array
u64 w[n=1] = {0x80000000}; // 0x0000000080000000
u32 z[n=1] = {0x100000005}; // 0x5: not diagnosed, keeps the low 32 bits
sp f[n=1] = {0x100000001}; // 4294967296.0, the nearest sp value

Multi-dimensional initialization:

xp a[n=2][m=4] = {
{1, 2, 3, 4},
{5, 6, 7, 8}
};

A list of one value fills every element, unlike C: xp a[n=4] = {1}; holds 1, 1, 1, 1.

hp b[n=N] = {0.0};

A shorter list of two or more values zero-fills the elements it does not reach, as in C: xp c[n=4] = {5, 6}; holds 5, 6, 0, 0. A list must not be longer than its array. In a multi-dimensional initializer, give each inner list in full or as {}. Carbide checks neither rule. It cuts off a list that is too long without a message, and it lays the values of short inner lists end to end instead of padding each row: xp q[n=2][m=3] = {{1, 2}, {3, 4, 5}}; holds 1, 2, 3, 4, 5, 0.

There is no generator form that computes each element from its index; list the values, or leave the array uninitialised and load it at image-preparation time.

Empty initialization zero-fills every element, as in C: {} is the one-value list {0}, and a block-scope array is zeroed again each time its declaration runs:

xp a[n=4] = {};
typedef xp row[n=4];
row r = {};

An empty inner list zero-fills its own row, so {{}, {1, 2, 3}} is {{0, 0, 0}, {1, 2, 3}}. A scalar or struct rejects {} as it rejects {0}.

Uninitialised arrays (filled at image-preparation time):

hp row_a[k=K]; /* no initialiser — data loaded via miset */

Design Principle: One Kernel Per Role

Never duplicate a Tungsten kernel just to change initialisation data. If multiple tiles run the same computation but operate on different data (e.g., different rows of a matrix), write one .w file with uninitialised arrays and use miset to load per-tile data into the compiled image. This keeps the codebase maintainable and avoids divergence between copies.

See the miset/miget documentation for details on per-tile data loading.

Array Access​

a[0] = 42;
x = a[i];
matrix[r][c] = value;

Write one subscript per dimension. Carbide rejects extra subscripts but not missing ones: on xp m[n=2][k=3], m[1] = 9; writes word 1 of m, which is m[0][1], not row 1.

A ∑ whose affine subscripts, over constant iterator ranges, would read outside an operand array is rejected (∑ reads outside 'a'). No other access is checked when it compiles: on a 4-element a, i in [0, 6) a[i] = 0; and a[7] = 9; compile without a warning and overwrite the memory after a. Compiling with fsanitize=bounds inserts a runtime assertion for every access outside parallel arms that the compiler cannot prove in bounds; a failing assertion stops at a breakpoint. See Array Bounds Verification.

Array Attributes​

Arrays support special placement attributes:

xp b[N] address(0x14) = {0xdead, 0xbeef, 0xdead, 0xbeef}; // at word address 0x14
xp c[N] bank(2); // at an address whose low 4 bits are 2
xp d[N] align(4); // at a multiple of 4 words

Addresses count 16-bit words; main memory is 0x0000 to 0x5fff. bank(n) takes n from 0 to 15. Two arrays given the same address() share storage, and Carbide does not warn about it.

Base Address Access​

You can get and set the base memory address of an array:

xp abase = bufA.base; // get base address
bufA.base <- abase; // set base address

.base is the array's address in 16-bit words, so one sp element is 2 words. Setting it makes later accesses to the array use the new address. No data moves, and the array keeps its declared extents. Do not read .base again after setting it: Carbide can return the address it read before the assignment. Keep the address in a variable, as abase above.

Array Length​

Carbide does not support reading an array's length: a.length, a.len and len(a) are rejected.


6. Structs and Unions​

Struct Declarations​

struct Point {
xp x;
xp y;
};

Structs can contain arrays and nested structs:

struct Inner {
xp c;
sp d[2];
};

struct Outer {
xp a;
struct Inner in;
sp tail;
};

Using Structs​

struct Point p;
p.x = 10;
p.y = 20;

struct Outer o;
o.a = 1;
o.in.c = 2;
o.in.d[0] = 3.0;

Arrays of structs:

struct Point points[10];
points[0].x = 1;
points[3].y = 42;

A part of a member access that is followed by . names one struct, so it subscripts every dimension of an array of structs (points[3].y, never points.y); only dimensions of extent 1 may be left out. The compiler rejects a missing subscript, and more subscripts than an array has dimensions, in every member access, including one in a function that is never called.

Inline Struct Variable Declarations​

Structs can be declared and instantiated together:

struct Point {
xp x;
xp y;
} mypoint;

Union Declarations​

Unions allow multiple types to share the same memory:

union wavelet {
sp s;
xp x[2];
};

Using unions:

union wavelet w;
w.s = 1.0;
xp high_word = w.x[1];

Unions are commonly used for bit-level reinterpretation:

union wvinter {
xp x;
hp h;
} val[N];

// Reinterpret hp as xp for bit manipulation. x>> is the arithmetic right
// shift, so val[i].x becomes -1 where the sign bit of a[i] is clear, 0 where it is set
i in [0, N) val[i].h <- a[i] * -0.0;
i in [0, N) val[i].x <- val[i].x x>> 15;

7. Typedefs​

Typedefs create aliases for existing types:

// Simple type alias
typedef sp float_32;
float_32 var1;

// Array typedef
typedef sp float_arr[N];
float_arr my_array; // equivalent to: sp my_array[N]

// Multi-dimensional via typedef
typedef sp float_arr[N];
float_arr tensor[M]; // equivalent to: sp tensor[M][N]

// Typedef of typedef
typedef float_32 float;

// Typedef of struct
struct tree_node {
xp root;
xp next;
};
typedef struct tree_node tnode;
tnode node;

// Typedef of array of structs
typedef struct tree_node node_array[M];
node_array nodes;

// Typedef of union
union wavelet {
sp s;
xp x[2];
};
typedef union wavelet wvlet;

Typedefs can also be declared locally inside blocks:

main {
typedef sp local_float;
local_float var2 = 0.0;
}

8. Parameters​

A parameter is resolved at compile time and can be used wherever a compile-time value is required — array sizes, loop bounds, and expressions. Its value is set when invoking the compiler, so the same program compiles with different configurations.

The param qualifier goes after the base type: xp param N; is correct and param xp N; is an error. A parameter is always a scalar.

xp param N; // required parameter (must be set at compile time)
xp param M = 10; // parameter with default value
xp param P, Q, R; // several at once

Parameters are set via the compiler command line:

carbide source=mycode.w set=N=16 set=M=32

A parameter declared without a default carries no value in the source, so it has to be supplied with set= on the command line; without it the compile fails with parameter [N] is not initialized. In a Paint layout the kernel's code() binding supplies it: Paint compiles code('mycode [N:16]) with set=N=16 (see Code Attachment in the Paint Language Reference).

xp param N;
xp a[n=N];
i in [0, N) a[i] = i;