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;, orfrom 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;orfrom 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
| Value | Description |
|---|---|
on/ON/true/enable | Enable feature |
off/OFF/false/disable | Disable feature |
low | Low priority |
med | Medium priority |
high | High priority |
medlow, medhigh | Read as low and high |
| integer | Integer value |
An option may accept a narrower range: dot.id takes 0 to 3, and timer.limit takes 1 to
65535.