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:
| Type | Description |
|---|---|
xp | Native 16-bit PE integer — indices, loop counters, general integer work |
u16 | 16-bit unsigned integer |
s16 | 16-bit signed integer — arithmetic on it gives the same results as on xp, which is the type kernels normally use |
u32 | 32-bit unsigned integer |
s32 | 32-bit signed integer |
u64 | 64-bit unsigned integer |
s64 | 64-bit signed integer |
u1 | Boolean / predicate — see below |
hp | Half-precision float (16-bit) — the same format as f16 |
sp | Single-precision float (32-bit, stored as 2 × 16-bit words) — the same format as f32 |
ep | Extended 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:
| Type | Size (words) |
|---|---|
xp | 1 |
u1 | 1 |
u16 | 1 |
hp | 1 |
u32 | 2 |
s32 | 2 |
sp | 2 |
ep | 2 |
u64 | 4 |
s64 | 4 |
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
.wfile with uninitialised arrays and usemisetto 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;