Skip to main content

Advanced forms, imports and pragmas

24. Advanced​

These low-level descriptor forms are intended for specialized memory and fabric access patterns. Prefer statically named arrays, sockets, and memory iterators when they express the same operation.

Streams​

Streams are low-level descriptors used for advanced memory access patterns. A stream is bound from a descriptor stored in memory and is then read. On the AST route (ast_to_mlir), a stream can also be bound directly to a receive (dir=in) socket and read, or to a send (dir=out) socket and written:

socket rx [[dir=in]];
stream s;
u64 dsd[2];
xp a[n=4];

dsd[0] = rx;
s = dsd[0]; // bind from memory
s = rx; // or bind directly to the receive socket (AST route)
i in [0, 4) a[i] <- s[]; // read through the stream

socket tx [[dir=out]];
stream w;
w = tx; // bind directly to a send socket (AST route)
i in [0, 4) w[] <- a[i]; // a stream bound to a send socket can only be written

A stream holds a descriptor, such as a receive socket's, that the program selects at run time. dsd[0] = rx stores the descriptor of rx in a u64, s = dsd[0] loads a copy of it into s, and s[] then reads as rx[] does. Carbide does not check that the loaded descriptor is valid. Prefer a statically named socket or memory iterator in new code.

See STREAMS_DESIGN.md for the rules.

Memref​

memref(type, socket[]) treats each data word received from a socket as a local-memory address and reads the value at that address. The word is an absolute 16-bit word address in the receiving PE's memory, such as one fixed with address(), not an index into an array, so the sender must know the receiver's memory layout. The equivalent memref(socket[]) spelling omits type; Tungsten then infers the loaded type from the expression's use context:

// Each 16-bit word received on s is the address of an xp value.
a[i] = memref(xp, s[]);

// The assignment destination makes the inferred loaded type xp.
a[i] = memref(s[]);

This is Tungsten's explicit spelling of the hardware fabric data-as-address (FA) source mode. It is not an ordinary receive followed by a software-indexed load: the fabric word supplies the memory address to the same instruction that consumes the loaded value. The socket's input queue must therefore be dense; otherwise an address can be lost while the memory operation is executing.

On SDR, an FA operation consumes one 16-bit address word regardless of whether the loaded value is 16 or 32 bits. FA and the related fabric-index (FI) mode are available for 16- and 32-bit operations, but not 64-bit operations, and cannot access configuration memory.

The compiler does not check that an operand addresses main memory or data cache rather than configuration memory; SDR data cache is data memory and is legal. Direct operands, addresses derived through a DSR/XR/SR, the address consumed by FA, FI-selected indices, register-indirect addresses, runtime SETBASE/SETLEN values, and persistent cursor state must all avoid configuration space as a program obligation.

On SDR, FA/FI cannot be combined with vector predication, source control-transform, or another source that uses the fabric/operand-address/IP/SP/LFSR input path; this includes vector and tensor operand-as-address descriptors. A fabric output destination used by the same operation must be dense on architectures with configurable queue density, cannot enable local-switch flip, cannot enable fabric flush, and has restricted control options. FI also cannot be combined with a 4-D vector (tensor) operand whose wave option is set. An instruction may use only one of single-step, vector-step, or microthread mode across its operands. Carbide does not reject FA combined with another fabric source (for example memref(xp, s[]) + t[]); avoiding it is a program obligation.

Send/receive and FIFO single-step or vector-step combinations, and ring/FIFO operand-as-address combinations, are not supported by this interface. Avoid combining low-level address modes unless the operation explicitly supports them.

The compiler does not check queue density. Keeping the queues an FA/FI operation uses dense is a program obligation, including after any direct change to queue configuration memory.

memref only requests the address mode in the generated descriptor. It does not create a Paint route or a loopback color. A program that feeds locally generated addresses back through the fabric must declare and route that loopback explicitly in Paint.


25. Imports​

Tungsten supports importing declarations from other .w files into the program's global scope. The declarations made available depend on the import form:

  • A whole-package import (import m;, or from m import {...};) splices every top-level declaration of the package: its functions, global arrays and other declarations. They obey the importer's scope rules, so a package global array and an importer global array with the same name are a duplicate declaration.
  • A name-list import (from m import f; or from m import {f, g};) splices only the named functions. Each name must be a function defined in the package; naming anything else, such as a struct or an array, is an error (symbol [x] is not defined in package [...]). Nothing else from the package becomes visible: a named function cannot use a package global or call a package function that was not also named. Functions that are not named are not compiled, so they are not checked either.

Each declaration of a package is spliced at most once per program, at the first import that selects it, in source order with each import's own imports expanded in place. A later import that selects it again adds nothing, so these all work:

  • a diamond, where two imported packages both import a third, which is spliced once with its global arrays;
  • importing the same package, or naming the same function, more than once;
  • a name-list import followed by a whole-package import of the same package, which splices only the declarations not yet spliced (the named functions stay at the name-list import, so a named function cannot read a package global array that the later whole-package import splices; put the whole-package import first);
  • a package naming a function of the main file, which is already present.

Top-level code in a package (statements, including annotated statements and execution blocks) is not a declaration, and neither is an import statement: each is spliced at every import that selects the package, as a textual splice would, so a package imported twice runs its top-level code twice while its declarations are spliced once.

A package is identified by its file, whatever path spelled the import. Two different packages that define the same function are still a duplicate definition, and a package global array still clashes with an importer global array of the same name.

A whole-package import also splices the package's own imports, so whole-package imports must not form a cycle. A package that imports itself, directly or through other packages, is an error (import cycle [...]) that lists every file on the cycle and the line of each import. A name-list import never follows the package's own imports, so it cannot take part in a cycle.

Import All Symbols​

import module_name;
import path.to.module;

A dotted name maps to a file name in two ways. import a.b; looks for a_b.w in the source's directory, then for a/b.w under each libpath= directory and in the installed packages.

Import Specific Symbols​

from module import {symbol1, symbol2};
from module import symbol;

Import Everything Explicitly​

from module import {...};

26. Pragmas​

A pragma is a compiler directive for the statement it annotates. It selects how the compiler lowers that statement: which instruction variant computes it, how its DSDs are configured, or which task runs it. Pragmas have two equivalent syntaxes.

Hash Syntax​

#pragma tool option value
#pragma tool value

The tool and option are separate words: #pragma tool option value is the hash spelling of [[tool.option(value)]], and #pragma tool value of [[tool(value)]]. The dotted tool.option name belongs to the bracket syntax only.

As in C, pragma words are case-sensitive. Tool, option and level names are lowercase, so high is a level and HIGH is not. Only the on/off words also have uppercase spellings, ON and OFF (see Pragma Values).

Carbide does not yet check pragma names or values. A misspelled tool, option or value compiles without a warning, even with wall, and usually has no effect. #pragma fifo priority HIGH instead turns microthreading off for that FIFO access, which can hang a parallel block. Check the generated Yeti when a pragma matters.

Bracket Syntax​

[[tool.option(value)]]

Multiple pragmas:

[[timer(on), timer.limit(100), timer.repeat(on)]]

Pragma on Statements​

Pragmas annotate the statement that follows. Both multiplies below use fmule instead of fmuls:

sp a[n=4] = {1.0, 2.0, 3.0, 4.0};
sp b[n=4] = {1.0, 2.0, 3.0, 4.0};
sp c[n=4];
sp d[n=4];

#pragma math fast on
i in [0, 4) c[i] <- a[i] * b[i];

// or
[[math.fast(on)]]
i in [0, 4) d[i] <- a[i] * b[i];

An annotation does not introduce a new lexical scope. A declaration in the annotated statement has its ordinary enclosing-scope lifetime, while names introduced by the annotation itself (such as vectorize.iter) are visible only within that statement.

A pragma written directly on a declaration with an initializer, such as [[math.fast(on)]] sp x = a[1] * a[2];, currently has no effect. Declare the variable first and annotate the assignment, or annotate a block that holds the declaration.

A statement takes one annotation: one #pragma line, or one bracket list, which may name several pragmas:

[[math.fast(on), math.ehalf(on)]]
i in [0, 4) c[i] <- (a[i] + b[i]) * b[i];

Both pragmas apply: the add is fadde (math ehalf) and the multiply is fmule (math fast). With no pragma they are fadds and fmuls.

A Pragma Needs a Statement​

Every pragma annotates a statement. A pragma with no statement after it (at the end of the file or of a block, or before a function, an import, or a param or lock declaration) is an error, as is a file of only pragmas. Tungsten has no file-level pragmas. Carbide reports each of these, and two annotations on one statement, as input tungsten parse failed after a parser dump; the number before :list(pragma in the dump is the pragma's line.

Common Pragmas​

Each comment names the Yeti that the pragma changes. A pragma that does not apply to its statement changes nothing: math fast on an add, for example.

Floating-Point Precision​

math fast on multiplies in ep (10 mantissa bits, about three decimal digits). A multiply becomes fmule instead of fmuls. It drops the low 13 mantissa bits of both factors, which rounds them toward zero, and rounds the product to ep: 3.14159265 * 2.0 gives 6.28125, and 1.00146484375 * 1.0 gives 1.0009765625. A multiply-add becomes fmaces instead of fmacs. It drops the low 13 bits of the two factors, adds the exact product, and rounds the sum to sp. fmule and fmaces can do four multiplies per cycle where fmuls and fmacs do one; when both factors are sp arrays, memory reads limit fmule to about two elements per cycle. On xp and u16 operands math fast on also selects imul11 instead of imul16, which multiplies only the low 11 bits of each operand: 2048 * 2 gives 0.

math ehalf on rounds a sum to ep: fadde instead of fadds and fsube instead of fsubs. On a multiply-add it gives fmace, with or without math fast, which drops the low 13 bits of the two factors and rounds the result to ep.

#pragma math ehalf on
i in [0, 4) c[i] <- a[i] + b[i]; // fadde

Microthreads​

uthread enable on runs the statement's socket transfers as microthreads: their DSDs get .upr=HIGH instead of .upr=NONE.

socket rx;
socket tx;

#pragma uthread enable on
i in [0, 8) tx[] <- rx[]; // send and recv DSDs: .upr=HIGH

FIFO Low Priority​

A FIFO side's LP bit (low-priority arbitration at incomplete SIMD width) is set unless the last fifo lp on an access to that side is off:

xp src[n=8] = {1, 2, 3, 4, 5, 6, 7, 8};
xp dst[n=8];
xp m[n=8];
xp fifo f = {.mem=m};

#pragma fifo lp off
i in [0, 8) f[] <- src[i]; // write side: .wlp=0
i in [0, 8) dst[i] <- f[]; // the read side keeps LP set

FIFO Priority​

fifo priority sets the microthread priority of the FIFO side each microthreaded access uses. The default is low. A FIFO access is microthreaded in an arm of a parallel block, except a FIFO write fed from a socket, and in a statement under uthread enable on that also moves data through a socket. Elsewhere this pragma changes nothing:

#pragma fifo priority high
parallel {
i in [0, 8) dst[i] <- f[]; // .rupr=HIGH
i in [0, 8) f[] <- src[i]; // .wupr=HIGH
}

Dot Product Accumulator​

dot.id(n) selects accumulator DPRn, where n is 0 to 3. The default is DPR0.

sp a[n=8] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0};
sp b[n=8] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0};
sp c[n=1];

[[dot.id(1)]]
k in [0, 8) c[0] <- ∑ a[k] * b[k]; // fdps DPR1.clear, fstdpas ... = DPR1

Timer​

timer(on) on a forever loop runs the loop body as the timer task, task 30. The timer ticks down once per cycle from timer.limit(n), which is 1 to 65535 and required, and the body runs each time it reaches 0. timer.repeat(on) re-arms the timer at the start of each run of the body rather than at the end. Carbide currently re-arms it at the end as well, so the period is timer.limit plus the body's run time either way. Without timer(on), timer.limit and timer.repeat do nothing. The timer is sdr only.

A program has one timer statement. Its forever loop must be outside any function, no break or continue may leave it, and its body must not carry a scalar variable from one run to the next: keep such state in an array element, as ticks[0] does below. A body with a loop that uses its iterator, such as j in [0, 7) b[j+1] <- b[j] + j;, currently fails to compile ([builtin-setjt] precedes the branch into the loop its iterator ends).

xp ticks[n=1];

[[timer(on), timer.limit(100), timer.repeat(on)]]
forever {
ticks[0] += 1;
}

Pragma Values​

ValueDescription
on/ON/true/enableEnable feature
off/OFF/false/disableDisable feature
lowLow priority
medMedium priority
highHigh priority
medlow, medhighRead as low and high
integerInteger value

An option may accept a narrower range: dot.id takes 0 to 3, and timer.limit takes 1 to 65535.