A kernel body is an ordinary Dawn function whose only effect is Dev.
Recording it once yields a TileProg, which render prints as cuda_tile
text and encode writes as bytecode. Buffers and launches are std/gpu's,
and a format is a value type with a witness of the same name: Float and
F64, Int and I64, Bool and I1 (std/dtype), BF16, F16 and F32
(std/narrow), I32 (std/int/i32).
use std/dtype.{F64}
use tileir/dev.{Dev, Param, load_cell, store_cell, add, DYN_DIM}
use tileir/prog.{trace3, cells, In, Out}
use tileir/render.{render}
use tileir/bytecode.{encode}
fn vadd(a: Param[Float], b: Param[Float], out: Param[Float]) -> Unit !Dev =
store_cell(out, add(load_cell(a), load_cell(b)))
let g = cells([DYN_DIM], [128]) # 128-wide cells, as many as the grid has
let (prog, entry) = trace3("vadd", In(F64, g), In(F64, g), Out(F64, g), vadd)
let text = render(prog) # cuda_tile text
let bytes = encode(prog) # bytecode for tileiras
| Module | Contents |
|---|---|
dev | the Dev effect, the opaque handle types (Tile[D], Param[D], Idx, views, ...) and the typed functions a kernel calls |
prog | TileProg; trace1 to trace5 and trace_kernel, the recording handlers; the parameter markers |
lower | lower(prog), the linear instruction table |
render | render(prog), the cuda_tile text; line_map |
bytecode | encode(prog); bytecode_version() is "13.4" |
asm | tileiras_args(version, gpu_name), assembler_defect(version, gpu_name): how to call tileiras (see below) |
The full public surface is what ./bin/dawn doc packages/tileir prints.
Parameters and cells#
trace1 to trace5 take one marker per parameter and answer an entry
std/gpu can launch with typed arguments (launch_entryN):
In(d, cells)is read only; a write into it is refused while recording.In(d, Whole)is an input read anywhere (a gather's table, a pointer read).Out(d, cells)is written one cell per block, and theOutcells are the launch grid: dimensionjof anOutfollows grid axisalong[j], each axis at most once (the identity unlessalongsays otherwise), and an axis no dimension follows has one block. A batched product keeps its batch on axis 2 withcells([B, M, N], [1, T, T], along: [2, 0, 1]).Shared(d)is the escape hatch: atomics, scatters, a block that writes two regions, a write through pointers.grep Shared(finds every one.Scalar(d)is a value the launch passes by value (i32,i64,f32orf64), not a buffer: no cells, read in the body withscalar(p), and passed withstd/gpu'sscalar_arg(v).cells(extent, tile, pad: PadZero, along: ..)cuts the tensor. An extent ofDYN_DIMis the grid's to decide;padis what a read past the extent answers;along[j]is the grid axis dimensionjfollows, orFREE_AXISwhen the kernel picks that cell itself.
trace_kernel(name, params, body) records a body over param(d, pos)
handles, every one Shared; it is there for kernels with more than five
parameters. params says how each is passed: ByPtr("f32") is a buffer,
ByValue("f32") a scalar the launch hands over (i32, i64, f32 or
f64), which the body reads with scalar(p) and never through memory. launch_entryN refuses an Out or Shared argument that
aliases another argument, a grid that disagrees with the cells and a tensor
shorter than its cells, before any handler runs.
Writing a kernel#
A kernel names no shape it can read off its operands. The cells say where a block reads and writes, element-wise operations take their shape and format from their operands, and a matrix product's from its two factors:
use tileir/dev.{Dev, Param, load_at, store_cell, zeros, mma, d_range}
use tileir/prog.{trace3, cells, In, Out, FREE_AXIS}
fn matmul(a: Param[Float], b: Param[Float], c: Param[Float]) -> Unit !Dev = {
var acc = zeros(c)
for k in d_range(0, 256 / 32) { acc = mma(load_at(a, [k]), load_at(b, [k]), acc) }
store_cell(c, acc)
}
let (prog, entry) = trace3("matmul",
In(F64, cells([256, 256], [64, 32], along: [0, FREE_AXIS])),
In(F64, cells([256, 256], [32, 64], along: [FREE_AXIS, 1])),
Out(F64, cells([256, 256], [64, 64])), matmul)
load_cell(p)reads this block's cell;load_at(p, [k])names the cell along eachFREE_AXISdimension. Lanes past the extent read the marker'spad, so a tail needs no mask.store_cell(o, t)writes this block's cell of anOut;store_sub(o, [i, j], t)writes one piece of it, shaped liket. These are the only writes anOuttakes: astore,scatter, atomic orstore_viewinto one is refused while recording.zeros(p)andfill(p, v)are a tile shaped like a cell ofp, the accumulator a block starts from.full(shape, v)is a float tile of any shape whose format is the one it is checked against:let m: Tile[F32] = full([BQ, 1], 0.0). The value is aD, so a literal is already rounded to the format and aFloatcomputed on the host is writtenf32(x).- A constant is rank 0:
f_const(F64, 0.5),i_const(3);f_const(BF16, 0.1)records the bfloat16 nearest to 0.1.lit(0.5)is a float constant with no format, which takes the format of the element-wise operation it meets (mul(s, lit(0.5)); write it after a typed operand, since the checker finds the format left to right); a place that needs a format of its own refuses it and asks forf_const. - An operand of an element-wise operation widens on its own in two cases: a
rank-0 tile widens to any shape, and a tile of the operation's rank widens
along its dimensions of length 1, so
sub(s, reduce_max(s, keepdims: true))needs nothing more. A tile of another rank is refused (a reduction withoutkeepdimsdropped the dimension it would line up by).broadcast(t, shape)widens explicitly, for places that are not element-wise (a loop's starting value); places that need a rank-0 tile (a condition, a loop bound, a cell index) refuse a wider one. Idxarithmetic isidx_add,idx_sub,idx_mul,idx_divandidx_rem(signed, toward zero): a grid axis that holds a head and a group folded together is read back asidx_div(b, g)andidx_rem(b, g).mma(a, b, acc)is the matrix producta * b + acc.aandbshare a format andaccmay have another, from the dialect's table: f8E4M3FN, f8E5M2 and f16 operands into f16 or f32, bf16, tf32 and f32 into f32, f64 into f64. Alit(0.0)accumulator takes the accumulator's format, so it needs one from the context:let s: Tile[F32] = mma(a, b, lit(0.0)).mma_scaledis the block-scaled product andmmaithe i8 one.t.to(BF16)converts a float tile, rounding to nearest even (a tile already in that format is answered unchanged);float_to_floattakes the other roundings.t.transpose()swaps a rank-2 tile's dimensions;permute_tile(t, perm)reorders any rank.reshape(t, shape)regroupst's lanes intoshape, row-major, as many lanes as before: a[T, T]product goes into a[1, T, T]cell asstore_cell(o, reshape(acc, [1, T, T])). No write reshapes on its own.reduce_sum,reduce_max,reduce_minandscan_sumtakedim:(the last by default) andkeepdims:;d_reduceandd_scantake a body for any other fold. A rank-1 tile reduces to rank 0.retile(p, extent, tile)is a second view of a parameter a kernel reads two ways, as a grid viewload_viewreads at any index.- The pointer path is still there for what no cell describes:
load(p, base, shape, strides:, mask:, pad:, hints:)andstore(p, base, t, strides:, mask:, hints:),gather,scatter, the atomics,tensor_viewand the views cut from it. A memory operation takes an element offset:tile_at(idx, n)isidx * n. - An operation's attributes are named parameters with the dialect's default:
add(a, b, rounding: Down),d_global("t", F64, xs, visibility: Private). Float arithmetic isadd,sub,mul,div,max,min,neg,abs, and+ - * /and unary-on tiles (and onIdx) are the same calls: over an integer tile they record the integer operations. Importingmaxandminshadows the prelude's in that module. - Memory operations are ordered by the token chain the recorder threads
through them, not by program order.
d_fork2runs two chains, and writes it cannot show to be disjoint are refused.
Control flow#
A value that changes as a loop goes is a var, which a for over a
d_range carries: var acc = zeros(p), then acc = t in the body replaces it
with a tile of the same format and shape, and after the loop it holds what the
last iteration assigned. A var starts from a tile with a shape (zeros(p),
full, a broadcast), not from a rank-0 constant or a lit. This is
FlashAttention with bf16 inputs and f32 accumulation:
use std/float.{INFINITY}
use std/narrow.{f32}
var m: Tile[F32] = full([BQ, 1], f32(-INFINITY))
var l: Tile[F32] = full([BQ, 1], 0.0)
var acc: Tile[F32] = full([BQ, D], 0.0)
for j in d_range(0, N / BK) {
let s: Tile[F32] = mma(tq, load_at(k, [j]).transpose(), lit(0.0)) * lit(scale)
let m_new = max(m, reduce_max(s, keepdims: true))
let p = exp(s - m_new)
let alpha = exp(m - m_new)
l = l * alpha + reduce_sum(p, keepdims: true)
acc = mma(p.to(BF16), load_at(v, [j]), acc * alpha)
m = m_new
}
store_cell(o, (acc / l).to(F64)) # o: Param[Float]
carry, get and set remain for the loops a for cannot write: d_loop's
body is a closure with a data-dependent exit.
for j in d_range(lower, upper, step: By(n), unsigned_cmp: false) { .. }is a loop. The bounds are host numbers (Int, fixed when the kernel is recorded) orIdxvalues the device computes; the step isOneby default, orBy(n)of the same kind as the bounds, so a grid-stride loop writesstep: By(nprog). The induction variable is always anIdx. (extent_of(p, dim)andblocks_of(p, dim)are a parameter's extent and its number of cells along a dimension.)d_loop { .. }runs until its body answers a true rank-0 mask. On the iteration that answers true the loop stops with the carries as they entered it, so compute the mask before thesets.d_if(cond, () => .., () => ..)runs one of two regions, neither of which may load or store; a carry either sets is theif's answer, and a branch that does not set it keeps what it held.
A region carries a carry only if its body sets it, and carries those in the
order they were made; one the body only reads is the tile it held outside,
and a loop that sets nothing carries nothing but the memory token. The recorder finds
them by recording the body once as a trial and discarding it, so a body runs
twice on the host (2^n times n regions deep). A body is otherwise ordinary
Dawn: host if and for in it unroll while recording.
Nesting is capped at MAX_LOOP_DEPTH (16) and one recording at
MAX_HANDLES (65536) handles; past either the recording panics.
Occupancy#
A 128 by 128 f16 matrix product on sm_86 holds one block per SM by default
(65568 bytes of shared memory) and is slower than the pointer path it
replaces; with hint_occupancy(2) it fits two (49184 bytes) and is faster.
Give such a kernel hints: [for_arch("sm_86", [hint_occupancy(2)])]. The
measurement is in §6.26 of the design document.
What the recorder refuses#
Each value handle has a format and a shape, and every element-wise operand is
held to the shape the operation takes from its operands (after the rank-0
and same-rank rules), as is the k of mma, mma_scaled and mmai and the format pair of
mma. A mismatch panics
while the kernel records, naming the operation by its depth-first number in
TileProg.ops (MakeToken(0) is #0), for example:
tileir: kernel `vadd_half`: op #6 `addf`: rhs is tile<128xf64>, declared tile<64xf64>.
Assembling with tileiras (#558)#
Neither this package nor std/gpu runs tileiras: you assemble the bytes
and hand with_gpu_real the cubins. With tileiras 13.4.92, pass
--opt-level 0 when assembling for sm_90, sm_100, sm_103, sm_107 or
sm_110. At the default level that assembler stores a wrong loop exit value
for a loop that leaves through break with two carried values
(#558,
NVIDIA/cuda-tile#25). Other
targets, and other tileiras versions, keep the default level. Ask asm
rather than hard-coding this, so the workaround lifts when you move to a fixed
tileiras:
use tileir/asm.{tileiras_args, assembler_defect}
let args = tileiras_args("13.4.92", "sm_90") # ["--gpu-name", "sm_90", "--opt-level", "0"]
let why = assembler_defect("13.4.92", "sm_90") # Some("tileiras 13.4.92 miscompiles ...")
# then: tileiras <args> -o kernel.cubin kernel.tilebc
-O0 costs device time: measured on sm_86, up to 4.5x for kernels that
compute and 8x for a small loop (§6.28).
Writing your own Dev handler#
A handler has to answer every operation of Dev, so a new operation is a
version bump (see the changelog). What a handler owes the ones that issue
nothing:
t_call_enter(name)/t_call_exit()andt_body_enter()/t_body_exit(): answer()and record nothing. The call pair wraps the body of each public function; the body pair wraps each closure a public function takes.prog.trace_callsuses them to attribute operations to the calls the kernel wrote;render.line_maprefuses a program whose operations fall outside any pair.t_shape_of(h): answer the format and shape of handleh(rank 1 or more). The named reductions andbroadcastread it.t_cell_view,t_sub_view,t_cell_fill,t_retileandt_extent_ofread a parameter's marker; a handler with no markers should refuse them.t_carry_new,t_carry_get,t_carry_setkeep the carries, andt_trial_begin/t_trial_endbracket a trial:t_trial_endanswers the carries made before the trial that were set during it, in the order they were made, and puts every piece of the handler's state back as it was att_trial_begin.
Changes between versions: CHANGELOG.md. Design, measurements and the bytecode format: `docs/tile-backend-design.md` (in Chinese).
API#
tileir/asm
How to call tileiras on the bytes bytecode.encode writes, for one
assembler version and one --gpu-name: tileiras_args answers the
arguments, and assembler_defect says why they are not the plain ones.
This package never runs tileiras and neither does std/gpu: a program
hands with_gpu_real cubins somebody else assembled. So this module is
where the knowledge of a broken assembler lives, for that somebody (a build
script, scripts/tile-gpu-diff) to ask.
asm.tileiras_args
fn tileiras_args(tileiras: String, gpu_name: String) -> List[String]
The arguments to put before -o <cubin> <bytecode> when tileiras
version tileiras (as tileiras --version prints it after V, e.g.
"13.4.92") assembles for gpu_name (e.g. "sm_90"): --gpu-name and,
for a combination assembler_defect knows to be broken, the arguments
that avoid the defect. For tileiras 13.4.92 on sm_90, sm_100, sm_103,
sm_107 and sm_110 that is --opt-level 0.
asm.assembler_defect
fn assembler_defect(tileiras: String, gpu_name: String) -> Option[String]
Why tileiras_args adds arguments for this combination, or None when
it does not: one English sentence naming the assembler, the target, the
defect and the issues that track it.
tileir/bytecode
TileProg as CUDA Tile IR bytecode: the file tileiras assembles into a
cubin. Layer 1 of tile-backend-design 6.2: scripts/tile-golden pins the
bytes and then hands them to tileiras --gpu-name sm_86, whose exit code
is the one machine verdict on whether what we emit is a Tile IR program.
bytecode.BYTECODE_MAJOR
const BYTECODE_MAJOR: Int
The bytecode version written into the header (tile-backend-design 6.3).
The three numbers that must move together are this one, the tileiras pin
in scripts/tile-golden/toolchain.txt, and the driver in the ledger.
Knife T8 moved the minor from 2 to 3 and knife U2 from 3 to 4. 13.4 is
what tileiras 13.4.92 calls its current version; it still accepts 13.1,
13.2 and 13.3, and 13.2 is what this tree pinned from knife 3 to knife T15.
Neither move changed a device answer: the cubin tileiras --gpu-name sm_86
produces from the 13.3 bytes is byte for byte the cubin it produced from
the 13.2 bytes (tile-backend-design 6.11), and the cubin 13.4.92 produces
from the 13.4 bytes is byte for byte the one it produces from the 13.3
bytes (6.18), each measured over every golden rather than assumed.
bytecode.BYTECODE_MINOR
const BYTECODE_MINOR: Int
The minor half of the bytecode version whose major is BYTECODE_MAJOR;
the note there on what must move together covers this number too.
bytecode.bytecode_version
fn bytecode_version() -> String
The version as tileiras --list-versions spells it.
bytecode.ieee_bits
fn ieee_bits(x: Float, p: Int, emin: Int, emax: Int) -> Int
The IEEE pattern of x in the binary format with p significand bits
(the hidden bit included) and minimum normal exponent emin, as an Int of
p + 1 + the exponent width bits: the sign, the biased exponent, the
significand. x must already be a value of the format (the callers round
it first); a value above the format's range answers the infinity of its
sign and NaN answers the canonical quiet NaN.
bytecode.put_varint_u64
fn put_varint_u64(b: Buf, x: Int) -> Buf
x, a 64-bit PATTERN whose top bit may be set, as an LEB128 varint.
put_varint refuses a negative Int because a length, an index and a
value number never are; a zigzagged float pattern can be.
bytecode.zigzag
fn zigzag(x: Int) -> Int
The reader's writeSignedVarInt encoding: zigzag, so a pattern with its
sign bit set (every negative float, -inf among them) is a short varint
and not a ten-byte one. The shift is arithmetic, which is what makes the
mask all ones for a negative value.
bytecode.put_varint
fn put_varint(b: Buf, x: Int) -> Buf
x as an unsigned LEB128 varint appended to b.
bytecode.global_payload
fn global_payload(dtype: String, values: List[Float]) -> Bytes
The initializer blob of a global: every element's bytes in order, which
is the layout get_global's pointer then walks (the dialect: "the
initial values are stored in memory in linear order").
bytecode.encode_kernel
fn encode_kernel(k: Kernel) -> Bytes
k as a bytecode file holding one kernel entry.
tileir/dev
The device side of a kernel: a Dev effect whose operations are the tile
instructions a kernel body issues, and the typed surface over them. A kernel
body is an ordinary Dawn function of type fn(...) -> Unit !Dev; nothing
here runs it. prog.trace_kernel runs it once under a recording handler and
keeps what it issued as a TileProg; render.render turns that into Tile IR
text. The host side (buffers, launch) is std/gpu, which never sees a
TileProg: docs/tile-backend-design.md 5.
impls registered by this module (visible program-wide, no use needed): Show[RedId], Show[AssumePred], Show[Padding], Show[Overflow], Show[Visibility], FloatDtype[Float], FloatDtype[F32], FloatDtype[TF32], FloatDtype[F16], FloatDtype[BF16], FloatDtype[F8E4M3FN], FloatDtype[F8E5M2], RangeBound[Int], RangeBound[Idx], StagedIter[DRange[B]], StagedVar[Tile[D]], Add[Tile[D]], Sub[Tile[D]], Mul[Tile[D]], Div[Tile[D]], Neg[Tile[D]], FromFloat[Tile[D]], Add[Idx], Sub[Idx], Mul[Idx], Div[Idx], Rem[Idx], FromInt[Idx]
Tile
type Tile[D]
A tile value inside a kernel: the SSA handle the handler issued for it,
of any rank, rank 0 (tile<f64>) included. D is a phantom (spec 2.7):
Tile[Float] and Tile[F32] are two types over one representation, so
add of an f64 tile and an f32 one is a type error.
The SHAPE is not in the type. It belongs to the value: the recording
handler holds every handle's format and shape, a public function reads
them off its operands (t_shape_of) rather than taking them as
arguments, and a place that needs a particular rank (an if's
condition, a loop bound, a reduction's yield) is held to it when the
kernel records. Until the batch's PR-3 a rank-0 tile had a type of its
own, Scalar[D]; it was folded in here because every reason it existed
(an if wants a rank-0 i1, a store wants a tile as wide as its
pointers) is a fact the recording sees, and the rank is one dimension of
the shape like any other (docs/tile-backend-design.md 6.26).
Idx
type Idx
A scalar index tile inside a kernel: a block id, or later what is computed
from one. Distinct from Tile[D] (it is i32 and has no format marker)
and from a host Int (a host number is not an SSA name; load wants the
latter and the checker enforces it). idx_of and idx_as_tile cross
between this and a rank-0 Tile[I32] explicitly.
Param
type Param[D]
A kernel parameter: its position in the entry signature and its dtype
name. The phantom D is fixed at param() and read back as the string,
so load can tell the handler which format it is reading without a
dictionary the effect could not carry.
RedId
type RedId = IdF(dtype: String, value: Float) | IdI(dtype: String, value: Int)
The identity of one operand of a reduction: what a fold that has seen
nothing starts from, in that operand's tile format. reduce carries one
per operand.
Its FORMAT is checked outside this repository (tileiras refuses an f32
identity for an f64 operand, measured) and its VALUE is not: a sum whose
identity is 1.0 assembles into a cubin. So the value is a layer-2 claim
and nothing else, which is what scripts/tile-gpu-diff's
reduce-identity-wrong mutant is for.
AssumePred
type AssumePred = DivBy(divisor: Int, every: Option[Int], along: Option[Int]) | SameElements(groups: List[Int]) | Bounded(lb: Option[Int], ub: Option[Int])
What an assume attaches to a value: one of the dialect's three
predicate attributes (knife T6). Each is a separate bytecode attribute
tag, and the payloads are three different shapes, which is why this is a
sum type rather than three fields.
DivBy every element of the tile is a multiple of divisor,
which must be a positive power of two. With every and
along given, only the FIRST element of each group of
every along dimension along is claimed to be a
multiple, and the rest of the group is claimed to
increase by one from it (by the pointee's width, for a
tile of pointers). The two are used together or not at
all, and neither may be given for a rank-0 tile.
SameElements one value per dimension: dividing dimension k into
groups of groups[k], every group holds one repeated
value. 1 everywhere is true of any tile.
Bounded every element, read as a SIGNED integer, is at least
lb and at most ub; either bound may be absent, and
both are inclusive.
NOTHING CHECKS THESE. The dialect says so in as many words (Ops.td's
AssumeOp: "assume does not check the correctness of the predicate.
Incorrect predicates may inject incorrect static information and cause
miscompilation"), so a false predicate is undefined behaviour and not a
wrong answer. That is why the coverage ledger carries a named layer-3
exemption for assume and this comment is where the reason lives:
a mutant that made a predicate false would be measuring nothing.
Hint
type Hint
One optimization hint: the dialect's own spelling of the key, and the
integer under it. OptimizationHintsAttr (AttrDefs.td) names five keys
and every one this backend can carry takes an integer, so a hint is a
pair and not a sum type: allow_tma is the one boolean, and it belongs
to the two view operations that ruling 2 defers.
Which key is legal is decided by the OPERATION, not by this type
(OptimizationHintsAttr::verifyWithOp): an entry takes
num_cta_in_cga, num_worker_warps_per_cta and occupancy, a load or
a store takes latency. Knife T15's four constructors below are the
legal ones, and nothing here refuses the others -- see Hints.
Hints
type Hints
The optimization_hints of one entry or one memory operation: the
dialect's NESTED dictionary, one entry per architecture. The outer key
is an sm_xx name (or default, the fallback the dialect reads when
the compilation target has no entry of its own) and the value is that
architecture's hints.
NOTHING CHECKS THESE, and that is the point of them. The dialect says a
hint is a suggestion: tileiras computes the same answer with them and
without them, and its verifier for the keys and the ranges is behind
-Wunsupported-hints, off by default, so an unknown architecture, an
unknown key and an out-of-range value are all accepted in silence
(measured, knife T15; docs/tile-backend-design.md 6.10). What is NOT
silent is the SHAPE: every value of the outer dictionary must be a
dictionary, keys may not repeat, and the counts on the wire must match
what follows them. That is the whole of what layer 1 can say here, and
the ledger's exemption for this family says so.
Ptrs
type Ptrs[D]
A tile of POINTERS inside a kernel. D is a phantom as Tile[D]'s is:
the element format the pointers point AT, so a Ptrs[Float] where a
Ptrs[I32] is wanted is a type error.
Every load and store of the strided surface builds one of these
inside the lowering and never shows it. This type is what the three
pointer conversions of knife T2 convert, and it exists because they have
nothing else to convert: ptr_to_int, int_to_ptr and ptr_to_ptr say
nothing about tiles of numbers.
TensorView
type TensorView[D]
A tensor_view value inside a kernel: a reference to a region of global
memory with a static extent and a static stride along each dimension.
D is a phantom, as Tile[D]'s is.
GridView
type GridView[D]
A GRID view value inside a kernel: a tensor_view cut into a grid of
equally sized tiles, which is a partition_view when the grid steps by
the tile and a strided_view when it steps by something else.
load_view and store_view index the GRID, one index a tile dimension,
and the tile they move is tile_shape wide. Knife T11 called this type
PartitionView[D]; knife T13 renamed it when strided_view became the
second constructor answering it, because the two are ONE surface: they
differ in what the type says and in nothing a kernel does with the
handle.
GatherScatterView
type GatherScatterView[D]
A gather_scatter_view value inside a kernel (knife T13). It is a grid
view in every dimension but sparse_dim, where the index is a 1D tile of
ROW NUMBERS rather than one tile index: a load collects those rows and
stacks them into the tile. It is a type of its own rather than a
GridView[D] because its indices are a different shape, which is what
GatherIdx spells, and because atomic_red_view_tko refuses it.
GatherIdx
type GatherIdx = At(i: Idx) | Rows(t: Tile[I32])
One index of a gather/scatter load or store: At is the tile index every
other view takes at every dimension, and Rows is the 1D tile the sparse
dimension takes. Its length must be the tile's own extent there, which
the lowering holds it to.
Dim
type Dim = Fixed(n: Int) | Dyn(v: Idx)
One dimension of a tensor_view_dyn: a constant extent or stride, or a
value the kernel computed. Fixed is what tensor_view takes for every
dimension, so the two spellings say the same thing when nothing is
dynamic; Dyn is the ? one.
Padding
type Padding = NoPadding | PadZero | PadNegZero | PadNan | PadPosInf | PadNegInf
Which value an out-of-bounds lane of a load_view reads. NoPadding is
the dialect's absent padding_value: out-of-bounds lanes then hold
unspecified values, which is why no kernel here uses it for a lane it
then stores.
The four special values are only legal over a floating point element
format; PadZero is legal over any (Types.cpp's
verifyPartitionViewLike).
Overflow
type Overflow = NoAssumption | NoSignedWrap | NoUnsignedWrap | NoWrap
What an integer operation lets the compiler assume about overflow: the
dialect's overflow attribute. NoAssumption is none, the default; the
other three tell it to assume the operation does not wrap, as a signed
operation (no_signed_wrap), as an unsigned one (no_unsigned_wrap) or
as either (no_wrap). They are assumptions and not arithmetic: a program
that wraps under one of them is undefined, so a caller uses them only
where it can prove the range, and no corpus can distinguish them from
NoAssumption on a program that keeps its promise.
Written today: add_i with NoSignedWrap, sub_i with
NoUnsignedWrap, mul_i with NoWrap (knife T4's three, one per
value). Any other pairing is refused when the kernel is recorded; the
dialect allows all of them, and teaching the writer and the renderer the
rest is a change with a layer-1 answer to show.
Carry
type Carry[D]
A device variable of format D: a tile a kernel body replaces as it goes,
which a region carries when its body replaces it. Made by carry, read
by get, replaced by set; its format and shape are its first tile's
for good.
Step
type Step[B] = One | By(by: B)
How a loop steps: by one, or by a bound's own kind of value. One is a
constructor and not a literal because a parameter default must be pure
(spec, default values): the constant 1 of an Idx loop is an operation
the kernel records, so it is made when the loop starts, not when the
range is written. A literal step is By(2); a device step is
By(nprog) (a grid-stride loop, ruling-drange-step-20261006).
DRange
type DRange[B] = { lower: B, upper: B, step: Step[B], unsigned: Bool }
What d_range answers: the range, not yet recorded.
Visibility
type Visibility = Public | Private
A global's symbol visibility: the dialect's symbol_visibility, a field
the Global section's record grew at 13.3. Public is the default.
FloatDtype
trait FloatDtype[D] {
fn float_dtype[D: FloatDtype]() -> Dtype[D]
fn host_float[D: FloatDtype](v: D) -> Float
}
The float formats a constant can be made in when nothing but the TYPE
names the format (tileir 0.10.0): full and mma's accumulator read
theirs off this rather than off a witness argument, so
let m: Tile[F32] = full([32, 1], 0.0) is an f32 tile and
full([32, 1], 0.0) with no expected type does not check.
float_dtype answers the witness of D from the type alone. A
function generic in D and given no D value has no other way to learn
which format it is making (a type parameter that appears in no argument
position cannot be inferred, and a Tile[D] is erased at run time), so
D is in the return position and the dictionary this bound passes is
that way. std/dtype's HasDtype is the same shape for every format;
this one is kept apart because the seven formats here are the dialect's
arithmetic floats, the operands mma takes, and HasDtype would let
the exponent-only f8E8M0FNU, the sub-byte f4E2M1FN and the integers
into full's bound, turning a type error into a recording refusal.
fn host_float[D: FloatDtype](v: D) -> Float-
The host
Floata value of the format is, exactly: every value of a narrow format is a binary64, and this is how a constant'sDvalue reaches the recording, which keeps aFloat(tileir 0.13.0).
RangeBound
trait RangeBound[B] {
fn range_idx[B: RangeBound](b: B) -> Idx !Dev
}
What a loop's bounds may be: a host number, fixed when the kernel is
recorded (Int), or a value the device computes (Idx). Both come to
an Idx when the loop starts; an Int records an idx_const for it.
dev.DYN_DIM
const DYN_DIM: Int
The extent (or the stride) of a tensor view dimension that is not known
until the kernel runs: the dialect prints it ? and carries its value in
an OPERAND of make_tensor_view (knife T12). It is a MARKER and not a
number: what goes on the wire is ShapedType::kDynamic, and that lives
in bytecode.dawn with every other value this package writes. Any
non-positive marker would do, because a static extent and a static stride
are both strictly positive; this one is minus one so that a shape printed
in a diagnostic reads as one.
Dev
effect Dev {
fn t_block_id(axis: Int) -> Int !Dev
fn t_load(param: Int, dtype: String, base: Int, shape: List[Int], strides: List[Int], mask: Option[Int], pad: Option[Int], hints: List[(String, List[(String, Int)])]) -> Int !Dev
fn t_store(param: Int, dtype: String, base: Int, shape: List[Int], strides: List[Int], v: Int, mask: Option[Int], hints: List[(String, List[(String, Int)])]) -> Unit !Dev
fn t_gather(param: Int, dtype: String, index: Int, shape: List[Int], mask: Option[Int], pad: Option[Int]) -> Int !Dev
fn t_scatter(param: Int, dtype: String, index: Int, shape: List[Int], v: Int, mask: Option[Int]) -> Unit !Dev
fn t_atomic_rmw(param: Int, dtype: String, mode: String, index: Int, shape: List[Int], v: Int, mask: Option[Int]) -> Int !Dev
fn t_atomic_cas(param: Int, dtype: String, index: Int, shape: List[Int], cmp: Int, v: Int, mask: Option[Int]) -> Int !Dev
fn t_scalar(param: Int, dtype: String) -> Int !Dev
fn t_constf(dtype: String, shape: List[Int], value: Float) -> Int !Dev
fn t_consti(dtype: String, shape: List[Int], value: Int) -> Int !Dev
fn t_iota(n: Int) -> Int !Dev
fn t_lanes(base: Int, shape: List[Int], strides: List[Int]) -> Int !Dev
fn t_unaryf(op: String, dtype: String, shape: List[Int], a: Int) -> Int !Dev
fn t_binaryf(op: String, dtype: String, shape: List[Int], a: Int, b: Int) -> Int !Dev
fn t_fma(dtype: String, shape: List[Int], a: Int, b: Int, c: Int) -> Int !Dev
fn t_mmaf(dtype: String, acc: String, m: Int, k: Int, n: Int, a: Int, b: Int, c: Int) -> Int !Dev
fn t_mmaf_scaled(dtype: String, scale: String, dims: List[Int], a: Int, b: Int, c: Int, sa: Int, sb: Int) -> Int !Dev
fn t_mmai(m: Int, k: Int, n: Int, a: Int, b: Int, c: Int) -> Int !Dev
fn t_cmpf(pred: String, dtype: String, shape: List[Int], a: Int, b: Int) -> Int !Dev
fn t_cmpi(pred: String, shape: List[Int], a: Int, b: Int) -> Int !Dev
fn t_unaryi(op: String, dtype: String, shape: List[Int], a: Int) -> Int !Dev
fn t_binaryi(op: String, dtype: String, shape: List[Int], a: Int, b: Int) -> Int !Dev
fn t_convert(op: String, from: String, to: String, shape: List[Int], a: Int) -> Int !Dev
fn t_repack(op: String, from: String, to: String, shape: List[Int], a: Int) -> Int !Dev
fn t_select(dtype: String, shape: List[Int], cond: Int, a: Int, b: Int) -> Int !Dev
fn t_idx_const(value: Int) -> Int !Dev
fn t_idx_add(a: Int, b: Int) -> Int !Dev
fn t_idx_mul(a: Int, b: Int) -> Int !Dev
fn t_loop_begin(lower: Int, upper: Int, step: Int, unsigned: Bool, inits: List[Int]) -> (Int, List[Int]) !Dev
fn t_loop_end(outs: List[Int]) -> List[Int] !Dev
fn t_while_begin(inits: List[Int]) -> List[Int] !Dev
fn t_while_end(cond: Int, outs: List[Int]) -> List[Int] !Dev
fn t_return_if(cond: Int) -> Unit !Dev
fn t_spread(dtype: String, shape: List[Int], src: Int) -> Int !Dev
fn t_reduce_begin(dim: Int, shape: List[Int], identities: List[RedId], operands: List[Int]) -> List[Int] !Dev
fn t_reduce_end(yields: List[Int]) -> List[Int] !Dev
fn t_scan_begin(dim: Int, reverse: Bool, shape: List[Int], identities: List[RedId], operands: List[Int]) -> List[Int] !Dev
fn t_scan_end(yields: List[Int]) -> List[Int] !Dev
fn t_if_begin(cond: Int) -> Unit !Dev
fn t_if_else(yields: List[Int]) -> Unit !Dev
fn t_if_end(yields: List[Int]) -> List[Int] !Dev
fn t_extract(dtype: String, from: List[Int], to: List[Int], src: Int, indices: List[Int]) -> Int !Dev
fn t_cat(dtype: String, dim: Int, lhs_shape: List[Int], rhs_shape: List[Int], lhs: Int, rhs: Int) -> Int !Dev
fn t_permute(dtype: String, shape: List[Int], perm: List[Int], src: Int) -> Int !Dev
fn t_insert(dtype: String, sub: List[Int], whole: List[Int], src: Int, dst: Int, indices: List[Int]) -> Int !Dev
fn t_powi(dtype: String, exp_dtype: String, shape: List[Int], a: Int, n: Int) -> Int !Dev
fn t_num_blocks(axis: Int) -> Int !Dev
fn t_extent_of(param: Int, dim: Int, blocks: Bool) -> Int !Dev
fn t_carry_new(init: Int) -> Int !Dev
fn t_carry_get(c: Int) -> Int !Dev
fn t_carry_set(c: Int, h: Int) -> Unit !Dev
fn t_trial_begin() -> Unit !Dev
fn t_trial_end() -> List[Int] !Dev
fn t_tok_get() -> Int !Dev
fn t_tok_set(tok: Int) -> Unit !Dev
fn t_tok_join(toks: List[Int]) -> Int !Dev
fn t_shape_of(h: Int) -> (String, List[Int]) !Dev
fn t_ptrs(param: Int, dtype: String, shape: List[Int]) -> Int !Dev
fn t_ptr_offset(dtype: String, shape: List[Int], ptrs: Int, offs: Int) -> Int !Dev
fn t_ptr_to_int(dtype: String, shape: List[Int], ptrs: Int) -> Int !Dev
fn t_int_to_ptr(dtype: String, shape: List[Int], ints: Int) -> Int !Dev
fn t_ptr_to_ptr(from: String, to: String, shape: List[Int], ptrs: Int) -> Int !Dev
fn t_load_ptrs(dtype: String, shape: List[Int], ptrs: Int, mask: Option[Int], pad: Option[Int]) -> Int !Dev
fn t_assert(shape: List[Int], cond: Int, message: String) -> Unit !Dev
fn t_assume(pred: AssumePred, dtype: String, shape: List[Int], src: Int) -> Int !Dev
fn t_print(fmt: String, dtype: String, shape: List[Int], args: List[Int]) -> Unit !Dev
fn t_token_only(op: String) -> Unit !Dev
fn t_store_ptrs(dtype: String, shape: List[Int], ptrs: Int, value: Int, mask: Option[Int]) -> Unit !Dev
fn t_global(sym: String, dtype: String, values: List[Float], align: Int, is_private: Bool, constant: Bool) -> Unit !Dev
fn t_get_global(sym: String, dtype: String, shape: List[Int]) -> Int !Dev
fn t_alloca(dtype: String, num_elem: Int, align: Int, shared: Bool, shape: List[Int]) -> Int !Dev
fn t_tensor_view(param: Int, dtype: String, base: Int, shape: List[Int], strides: List[Int], dyn_shape: List[Int], dyn_strides: List[Int]) -> Int !Dev
fn t_partition_view(dtype: String, view: Int, tile_shape: List[Int], dim_map: List[Int], padding: Padding) -> Int !Dev
fn t_strided_view(dtype: String, view: Int, tile_shape: List[Int], traversal: List[Int], dim_map: List[Int], padding: Padding) -> Int !Dev
fn t_gather_view(dtype: String, view: Int, tile_shape: List[Int], sparse_dim: Int, padding: Padding) -> Int !Dev
fn t_atomic_red_view(dtype: String, view: Int, indices: List[Int], mode: String, scope: String, value: Int) -> Unit !Dev
fn t_load_view(dtype: String, view: Int, indices: List[Int], inbounds: List[Bool]) -> Int !Dev
fn t_store_view(dtype: String, view: Int, indices: List[Int], value: Int, inbounds: List[Bool]) -> Unit !Dev
fn t_tensor_shape(view: Int, dim: Int) -> Int !Dev
fn t_index_space_shape(view: Int, dim: Int) -> Int !Dev
fn t_cell_view(param: Int, dtype: String, free: List[Int]) -> (Int, List[Int]) !Dev
fn t_cell_fill(param: Int, value: Float) -> Int !Dev
fn t_sub_view(param: Int, dtype: String, sub: List[Int], at: List[Int]) -> (Int, List[Int]) !Dev
fn t_retile(param: Int, dtype: String, extent: List[Int], tile: List[Int], pad: Padding) -> Int !Dev
fn t_reshape(dtype: String, from: List[Int], to: List[Int], src: Int) -> Int !Dev
fn t_broadcast(dtype: String, from: List[Int], to: List[Int], src: Int) -> Int !Dev
fn t_call_enter(name: String) -> Unit !Dev
fn t_call_exit() -> Unit !Dev
fn t_body_enter() -> Unit !Dev
fn t_body_exit() -> Unit !Dev
}
The device, as operations a recording handler answers. Names carry a t_
prefix for the reason std/gpu's carry gpu_: use tileir/dev.{Dev} brings
every operation into the importing module's namespace (spec 6.5).
t_block_id answers the handle of the block index along axis (0, 1, 2).
t_load answers the handle of a tile of dtype and shape whose lane
[i, j, ..] is element base + i * strides[0] + j * strides[1] + .. of
parameter param; t_store writes one there. base is an index handle
and strides are host constants in elements, so a tile of a larger array
is a stride rather than an operation of its own. Both take an optional
mask (an i1 tile of shape: a lane whose mask bit is 0 is not read
and not written), and t_load an optional pad (a tile of dtype and
shape: what a masked-off lane reads instead). None for both is the
unmasked memory operation of the earlier releases.
t_gather / t_scatter are those same two operations with the element
offsets given as an i32 TILE rather than built from base and
strides: lane [i, j, ..] reads or writes element index[i, j, ..]
of param. There is NO new dialect operation behind them (knife 12).
offset, which every load already goes through, takes any tile<Nxi32>
as its offset operand, so an index tile that came out of memory is the
same instruction as one the stride ladder computed, and all the two
spellings differ in is where the numbers come from. mask and pad
mean what they mean on t_load: a lane the mask blocks is neither read
nor written, so an index outside the array is legal there.
A t_scatter whose index names one element twice has NO defined
answer: the dialect does not order the lanes of a single store against
each other. Every kernel and every corpus here writes a permutation, and
scripts/tile-gpu-diff/gath_diff.dawn says so beside its data.
t_atomic_rmw / t_atomic_cas are the two atomic operations (knife
14), addressed the way t_gather and t_scatter are: an i32 index tile
rather than a base and a stride ladder, because the reason to want an
atomic is that the destination is data dependent. Both answer the tile
of values memory held BEFORE the update, and both take the same optional
mask (a lane it blocks is not touched at all).
t_atomic_rmw(param, dtype, mode, index, shape, v, mask) applies mode
to the element and the matching lane of v in one transaction: mode
is one of and / or / xor / add / addf / max / min /
umax / umin / xchg. The integer modes want an integer buffer and
addf a float one; add is the integer sum and addf the float one,
two modes rather than one because the dialect spells them apart.
t_atomic_cas(param, dtype, index, shape, cmp, v, mask) writes the lane
of v where the element equals the matching lane of cmp and leaves it
alone otherwise, answering what was there either way. Its comparison is
on the BITS, so a float cmp that is a NaN never matches and the two
zeroes are two values.
Unlike a scatter, an atomic whose lanes name ONE element twice is
defined: that is what an atomic is for. What is not defined is the
order they arrive in, so the fold has to be one whose answer does not
depend on it -- integer addition is, and histogram is the kernel that
rests on it.
t_constf / t_consti answer a tile of shape every lane of which
holds the given host constant; t_iota the n-wide i32 tile
[0, 1, .., n-1] (Tile IR's iota is one-dimensional and this is it);
t_lanes the i32 tile of the ELEMENT OFFSETS a load at base with
shape and strides reads, which is the same tile the pointer
arithmetic of that load is built from.
t_unaryf(op, ..) / t_binaryf(op, ..) / t_fma are the element-wise
floating-point arithmetic: op is one of negf / absf for the unary
form and addf / subf / mulf / divf / maxf / minf for the binary
one. t_cmpf(pred, ..) / t_cmpi(pred, ..) answer an i1 tile of the
operands' shape, pred being one of eq / ne / lt / le / gt /
ge; a float comparison is ORDERED, so every predicate answers 0 on a
NaN operand. t_select takes an i1 tile and two dtype tiles and
answers the second where the condition is 1 and the third where it is 0.
t_mmaf is the matrix multiply-accumulate: a is [m, k], b is
[k, n], c and the answer are [m, n]. dtype is the operands'
format and acc the accumulator's and the answer's, which may differ
(tileir 0.10.0): the dialect's table pairs each operand format with the
accumulators it allows, and the recording holds a kernel to it. t_mmai is the INTEGER one
(knife 11): its a and b are i8 tiles, its c and its answer are
i32 ones, and both operands are read SIGNED. It takes no dtype
argument for that reason -- the three formats are fixed by the dialect
(Ops.td's MmaIOp: lhs and rhs are i8, acc and result are
i32), and there is nothing for a caller to choose.
t_unaryi(op, ..) / t_binaryi(op, ..) are the element-wise INTEGER
arithmetic (knife 10): op is negi / absi for the unary form and one
of addi / subi / muli / divi / remi / maxi / mini /
mulhii / andi / ori / xori / shli / shri / shru for the
binary one. Every one of them is EXACT on the device: an i32 result has
one value and no rounding mode, which is why the layer-2 corpus for
these has no tolerance branch.
shri and shru are one dialect opcode under two signedness
attributes: shri is arithmetic (the sign bit is copied down) and
shru is logical (zeros are shifted in), and the two differ on every
negative operand. divi / remi / maxi / mini are signed, as
t_cmpi is; mulhii takes no signedness at all (the dialect defines it
for unsigned operands, and bytecode.dawn says so where it is encoded).
Overflow is none everywhere: the device wraps, and so must a host
reference (std/gpu's wrap_i32).
t_convert(op, from, to, shape, a) is the width and format change:
ftof (a float tile to another float format, nearest_even; knife T17
adds ftof_zero, ftof_neg_inf, ftof_pos_inf and ftof_away, the
same conversion under the other four IEEE modes, which bytecode 13.4
allows on some pairs of formats and not on others),
itof (integer tile to float, signed, nearest_even), ftoi (float
tile to integer, signed, nearest_int_to_zero, the one mode the dialect
accepts here; ftoi_sat is the same with 13.4's saturating modifier),
exti (a narrower integer tile to a wider one, UNSIGNED,
so an i1 mask widens to 0 or 1 rather than to 0 or -1), trunci (a
wider integer tile to a narrower one, which for i32 to i1 is the low
bit) and bitcast (the same bits read as another format of the same
width, i32 and f32 here). extis is exti under the OTHER
signedness, spelled as a second name for the reason shri and shru
are: one dialect opcode, two names, and the attribute is the whole
difference.
t_repack(op, from, to, shape, a) is the other two conversions, pack
and unpack, and what separates them from the six above is that the two
tiles have different SHAPES. Both reinterpret a rank-1 tile as the byte
array it occupies, so shape is the SOURCE's and the answer's lane count
is scaled by the ratio of the two element widths; pack answers an i8
tile and unpack takes one. They are the only way to reach a sub-byte
format (i4, f4E2M1FN), which no pointer type can hold.
t_idx_const / t_idx_add / t_idx_mul answer scalar i32 handles: a
host constant, and the sum and product of two index handles.
t_loop_begin / t_loop_end bracket a region (tile-backend-design 5.2).
t_loop_begin(lower, upper, step, unsigned, inits) opens a for over the index
handles [lower, upper) by step, with inits as the loop-carried
values entering it, and answers the induction variable's handle and one
handle per carried value as the body sees them; the operations issued
until the matching t_loop_end fall inside the region. t_loop_end(outs)
closes it with outs as the values the body hands to the next iteration
(one per carried value, in order) and answers one handle per carried
value as they stand after the loop. unsigned is the loop's
unsignedCmp attribute: false compares the induction variable against
the bound as a SIGNED i32 and true as an unsigned one, which is a
different loop whenever the range straddles 2^31. The handler carries
the memory ordering token through the loop on its own; a body never
sees it. Only
the d_range family calls the pair; a body calls those.
t_while_begin / t_while_end bracket the OTHER loop region, the one
whose trip count is not known when it opens (knife T5).
t_while_begin(inits) opens a loop with inits as the loop-carried
values entering it and answers one handle per carried value as the body
sees them; t_while_end(cond, outs) closes it with the rank-0 i1
handle cond -- true when this iteration is the last -- and outs, the
values the NEXT iteration receives, and answers one handle per carried
value as they stand after the loop. What the loop answers when it stops
is the values that ENTERED the iteration that stopped it, not outs:
the handler writes if cond { break <entering> } else { yield } before
the continue, so a body reads "run me while cond is false, and give
me back the value I was called with". The memory ordering token rides
through as the last carried value, exactly as for's does, and it is
carried by BOTH terminators (a break yields the loop's final values,
and the token is one of them). Only the d_loop family calls the pair.
t_return_if(cond) ends the WHOLE KERNEL for this tile block when the
rank-0 i1 handle cond is true and does nothing when it is false: the
handler records if cond { return } else { yield } where the call is.
Bytecode 13.4 is what lets that if sit inside a loop (knife T17:
ReturnOp's parents became entry, if and loop, and its verifier walks
through if and loop to the entry); inside a for, a reduction or a
scan it is refused at recording time, because the dialect refuses it.
Nothing is carried and nothing is answered: what the block stored before
the return stays stored, and nothing after it runs.
A shape is a list of tile dimensions, each a power of two, EXCEPT that
[] is the rank-0 tile (tile<f64>): the shape a reduction answers and
an if branches on. The typed surface reads every shape off its
operands, so a kernel body never writes one for a value it already has.
t_constf / t_consti of shape [] record NOTHING when they are
issued: the recording handler keeps the value aside and answers a handle
for it, and the constant is materialised where it is used, at the shape
the use wants (one constant of tile<1024xf64> where a 1024-wide
operation takes it, a rank-0 one where a region's body does). That is
what "a constant takes no shape" (the batch's C3') costs the recording,
and it is why a handle a body holds for a constant may never appear in
the recorded program at all.
t_spread(dtype, shape, src) widens a rank-0 tile to shape, every lane
the same value: a reshape to one lane and a broadcast to shape.
The typed surface never calls it directly: an element-wise operation
whose operand is rank 0 has the recording spread it (the rank-0 rule),
and broadcast is the explicit spelling.
t_reduce_begin / t_reduce_end and t_if_begin / t_if_else /
t_if_end bracket a region the way the loop pair does.
t_reduce_begin(dim, shape, identities, operands) opens a reduction of
the operands, tiles of shape, along dim, one identity per operand
in the same order, and answers TWO handles per operand as the body sees
them: for operand i, the ACCUMULATOR at 2 * i and the ELEMENT at
2 * i + 1. The
result of a reduction of a rank-r tile is rank r - 1: the dimension
dim is dropped.
That order is MEASURED, not read: Ops.td's prose says the region
arguments are [op_0_current_iter, op_0_prev_iter, ...] (element first)
and its own mlirExample names them (%acc, %elem) (accumulator first),
and the two cannot both be true. A kernel whose body answers its first
region argument and nothing else answers the FIRST element of the
dimension on the device, and one that answers its second answers the
LAST, for reduce and for scan, forward and reversed (09-04, sm_86,
tileiras 13.3.36). So argument 2 * i is the fold of everything before
this point and 2 * i + 1 is the value arriving now. It could never
show under reduce alone -- every reduction body in this tree is
commutative -- and it decides the answer under scan, whose bodies are
not.
The same probe says the IDENTITY is not folded in at the first position:
the fold of one element is that element, not f(identity, element).
Every identity here is a real identity of its body, so the two readings
agree, and this is written down rather than relied on.
t_reduce_end(yields) closes it with the new accumulator of each operand
and answers one result per operand.
t_scan_begin / t_scan_end are the same bracket for the prefix scan:
the same dim, shape, identities and operands, the same two
handles per operand, and one attribute more, reverse. What differs is
the SHAPE OF THE RESULT: a scan keeps every dimension (it answers the
running accumulator at each position along dim) where a reduction
drops the one it folded.
t_if_begin(cond) opens the then-region of an if over the rank-0 i1
handle cond; t_if_else(yields) closes it with what it yields and
opens the else-region; t_if_end(yields) closes that one and answers one
handle per yielded value. The plan's sketch put no arguments on
t_if_else; the then-region's yields have to reach the record somewhere,
and this is the only operation of the bracket that can carry them.
t_extract / t_cat / t_permute are the shape operations (knife T2).
t_extract(dtype, from, to, src, indices) answers the subtile of src
whose SLICE number along dimension k is indices[k]: to must divide
from evenly, there is one index per dimension, and the indices are
slice numbers rather than element offsets (the dialect's own wording).
t_cat(dtype, dim, lhs_shape, rhs_shape, lhs, rhs) joins two tiles that
agree in every dimension but dim. t_permute(dtype, shape, perm, src)
reorders the dimensions: dimension k of the answer is dimension
perm[k] of the source, and the source's rank is at least two.
t_insert(dtype, sub, whole, src, dst, indices) is t_extract's other
direction (knife T16, bytecode 13.4): it answers dst (of shape whole)
with the subtile whose slice number along dimension k is indices[k]
replaced by src (of shape sub). The same three rules hold with the
two shapes' roles exchanged: sub divides whole, one index per
dimension, and the indices are slice numbers.
t_powi(dtype, exp_dtype, shape, a, n) is the one float operation here
whose two operands have DIFFERENT formats (knife T16, bytecode 13.4):
a is a float tile of dtype and n an integer tile of exp_dtype
(i1, i8, i16 or i32, which the dialect reads as SIGNED) of the same
shape, so it cannot travel through t_binaryf, whose one dtype is
both operands'.
t_num_blocks(axis) answers the extent of the grid along axis, which
is t_block_id's companion: t_block_id is where this block is and this
is how many there are. A grid-stride loop needs both.
t_tok_get / t_tok_set / t_tok_join are the handler's memory
ordering cell, exposed. t_tok_get answers the token every memory
operation issued here would take; t_tok_set puts one back, so that two
chains can start from the same place; t_tok_join merges two or more of
them into one and makes it the current token. They are the only
operations here through which a body sees the token at all, and the
typed surface has one client for them, d_fork2. Everything else keeps
the chain a line.
t_shape_of(h) is the other state query (knife C1'): the element format
and the shape the handler holds for handle h, issuing nothing. For a
tile it is the tile's type, [] for rank 0, a tile read through a view
included. For a tensor_view it is the tensor's extent along each
dimension (DYN_DIM where an operand carries it); for a partition,
strided or gather/scatter view, the tile one load_view or
store_view moves. A token has no shape, and the recording handler
refuses the question under the kernel's name and the number of the
operation about to be recorded. It is how a public function reads the
shape off an operand rather than taking it as an argument, which since
the batch's PR-3 is every public function that has an operand.
t_ptrs / t_ptr_offset / t_ptr_to_int / t_int_to_ptr /
t_ptr_to_ptr / t_load_ptrs are the pointer surface. t_ptrs(param,
dtype, shape) answers a tile of shape every lane of which is the
parameter's own address (the reshape and broadcast every ladder opens
with); t_ptr_offset advances such a tile by an i32 tile of ELEMENT
offsets, the elements being the pointee's; the three conversions are the
dialect's own, address-preserving and pure type changes; and
t_load_ptrs reads through a pointer tile the body built, sitting in the
token chain exactly as t_load does. Between them they are the second
way to address memory here, the first being the base and strides of
t_load -- and they are the only way to say "the same bytes read as
another format", which is what t_ptr_to_ptr is for.
t_assert / t_assume / t_print are knife T6's three, and no two of
them are the same kind of thing. t_assert issues an operation with no
result that can STOP the launch: the host is told when a lane's
condition is false. t_assume issues one whose result is its operand,
carrying a claim the compiler may use and nobody checks. t_print is in
the token chain (print_tko takes the current token and answers the
next), and what it does is not visible in any buffer: it writes to the
process's standard output when the context is synchronised.
t_store_ptrs is t_load_ptrs's other direction, and t_global /
t_get_global are the static globals (knife T7).
t_global(sym, dtype, values, align, is_private, constant) DECLARES one:
it adds a record to the module beside the entry rather than an instruction
to the body, so it answers nothing and may not be issued inside a region.
is_private and constant are the two fields the Global section's record
grew at bytecode 13.3 (knife T8); they are booleans here and the writer
turns the first into a SymbolVisibility enum value, the way every other
enum in this package lives in bytecode.dawn and not on this surface.
t_get_global(sym, dtype, shape) answers a tile of shape every lane
of which holds the global's own address, the same reshape and broadcast
t_ptrs performs on a parameter; from there the pointer surface above
reads and writes it. A t_get_global naming a symbol no t_global
declared, or declaring the same symbol twice, is refused when the kernel
is recorded.
t_mmaf_scaled is the block-scaled matrix multiply (knife T10). It is
t_mmaf with two more operands and three formats instead of one: the
two matrices are dtype, the two scale tiles are scale, and the
accumulator and the result are f32, which is the only output type the
dialect allows. dims is [m, k, n, block], where block is the scale
vector size V: a scale tile is [m, k / block] on the left and
[k / block, n] on the right, and one of its elements scales a run of
block elements along K.
The four numbers travel as ONE LIST rather than as four parameters
because a Dawn function value takes at most eight parameters (the
compiler emits dawn/rt/Fn0 to Fn9 and the evidence pack is one of
the nine), and an effect operation is a function value. Eleven
parameters compile and then fail at run time with
NoClassDefFoundError: dawn/rt/Fn12; this shape has eight.
t_alloca(dtype, num_elem, align, shared, shape) is the automatic
allocation (knife T10). It answers a tile of shape every lane of which
holds the SAME address, the way t_get_global does, because alloca
answers a rank-0 tile<ptr<D>> too. num_elem elements of dtype are
reserved, aligned to align bytes, and the allocation lives as long as
the block the operation is in: it is scratch within one launch and
nothing reads it afterwards. shared is the dialect's global unit
attribute, which says the address may be handed to other tile threads;
without it the address is private to this one.
t_tensor_view's shape and strides may each hold DYN_DIM where the
extent is not known until the kernel runs (knife T12). Those positions
print as ? in the type and take an OPERAND instead: dyn_shape and
dyn_strides are the handles for them, in the order the markers appear,
and the recording handler refuses a count that does not match the
markers. Every one of them is a rank-0 i32 tile, which is what an Idx
already is.
t_tensor_shape(view, dim) and t_index_space_shape(view, dim) are the
two SHAPE QUERIES (knife T12). Each answers the extent along one
dimension, and each is t_block_id's shape: the dialect's operation has
one result per dimension and the lowering issues the whole of it, binding
the handle to the dim-th. t_tensor_shape asks the TENSOR (so its
dimensions are the tensor view's own and a ? one is the point of it);
t_index_space_shape asks the GRID of tiles a partition view cuts the
tensor into, so its rank is the tile's rank and its extent along k is
ceil(shape[dim_map[k]] / tile_shape[k]).
t_strided_view and t_gather_view are the other two views (knife
T13). A strided view is a partition view whose TRAVERSAL is parametric:
index k advances traversal[k] elements rather than tile_shape[k],
so consecutive tiles may overlap or skip. A gather/scatter view has no
dim_map: its index at sparse_dim is a 1D tile of ROW NUMBERS rather
than a tile index, and t_load_view takes that handle in that position.
t_atomic_red_view reduces a whole tile into one tile of a partition or
a strided view's grid and answers nothing, so it is a Unit operation
that still moves the token chain on.
A load or a store inside a reduction or an if is REFUSED: the dialect
requires a reduction's body to be pure, and neither region carries the
memory ordering token, so a memory operation inside one would have no
order at all. t_print is refused there for the same reason, and
t_assert and t_assume are not: neither is ordered against memory.
t_cell_view(param, dtype, free) and t_cell_fill(param, value) read
the CELLS a parameter's marker gives it (prog.trace1 to trace5; the
batch's PR-1, docs/tile-backend-design.md 6.25). t_cell_view answers a
partition view over the parameter's whole tensor, cut into its cells and
padded as the marker says, and the index of THIS block's cell in it: a
dimension that follows grid axis k takes the block id along k, and
the dimensions that follow no axis take the handles free names, in
order. The view is the handler's to build and to reuse: one per
parameter while it is in scope. For an In parameter it is the one view
here whose base carries an alignment claim (assume div_by<16> on the
parameter itself), because a buffer std/gpu allocates starts on a
256-byte boundary and an In's cells start at its first element; for an
Out it carries none. t_cell_fill answers a tile of the parameter's
cell shape and format with every lane value, which for an integer
format must be a whole number. A parameter with no cells (a Whole or
Shared marker, or any parameter under trace_kernel) is refused by
both, under the kernel's name and the call that asked.
t_sub_view(param, dtype, sub, at) is t_cell_view's finer cut, for
an Out written in pieces (store_sub): a partition view over the same
tensor cut into tiles of sub, which must divide the cell's tile in
every dimension, and the index of the piece at of THIS block's cell in
it (block * (tile / sub) + at along each dimension). at is host
numbers, each below tile / sub, so a piece can never leave the block's
own cell; the recording refuses one that would.
t_retile(param, dtype, extent, tile, pad) is the escape hatch for a
parameter read two ways (the batch's C4): a partition view over the
parameter's whole row-major tensor of extent, cut into tile, padded
with pad, which load_view then reads at any index. It answers the
view alone; an In's carries the alignment claim its cells do.
t_reshape(dtype, from, to, src) regroups the lanes of src into to,
which holds as many: what a reduction that keeps its dimension issues
after the fold. t_broadcast(dtype, from, to, src) widens the
dimensions of length 1 in from to their length in to, every other
dimension unchanged and the rank the same; a rank-0 tile is t_spread's.
t_call_enter(name) and t_call_exit() record nothing a kernel computes.
Every public function below wraps its body in called(name, ..), which
brackets it with the two, so a handler can tell which public call issued
which operations: prog.trace_calls keeps the kernel's own calls as a
side table beside the program, and every other handler answers (). They
are the only way that is known exactly, because the mapping is not one to
one (tile_at issues t_idx_const and t_idx_mul, add issues
t_binaryf, d_range a whole region).
t_body_enter() and t_body_exit() record nothing either. Every public
function that takes a closure (d_range, d_range, d_loop, the
reductions, the scans, d_if, d_fork2) runs that closure between the
two, and runs nothing else between them; a region's trial (Carry) runs
it between them too, and the trial's calls are thrown away with the rest. A call marked inside a body
is one the KERNEL wrote, in the region the closure fills; a call marked
anywhere else inside another call is one the library made on its own
behalf (idx_mul inside tile_at). That difference is what lets
prog.trace_calls keep a tree of the kernel's calls rather than only the
outermost ones.
dev.for_arch
fn for_arch(arch: String, hints: List[(String, Int)]) -> (String, List[(String, Int)])
The hints of one architecture, for the list Hints is. arch is an
sm_xx name or default.
dev.hint_num_cta_in_cga
fn hint_num_cta_in_cga(n: Int) -> (String, Int)
num_cta_in_cga: how many CTAs to put in a CGA. An ENTRY hint. The
dialect wants a power of two no greater than 16, and 1 on sm_80 to
sm_89, which is every architecture this machine has.
dev.hint_num_worker_warps_per_cta
fn hint_num_worker_warps_per_cta(n: Int) -> (String, Int)
num_worker_warps_per_cta: how many worker warps to give a CTA. An
ENTRY hint. The dialect wants a power of two no greater than 32 and
clamps anything but 4 or 8 into that range.
dev.hint_occupancy
fn hint_occupancy(n: Int) -> (String, Int)
occupancy: how many blocks to aim to keep resident. An ENTRY hint,
and the one key AttrDefs.td's table leaves out while
verifyWithOp accepts it. The dialect wants 1 to 32.
dev.hint_latency
fn hint_latency(n: Int) -> (String, Int)
latency: the latency to assume for a memory operation. A LOAD or
STORE hint, and the only key either of them takes (allow_tma is the
view operations'). The dialect wants 1 to 10.
dev.is_float_dtype
fn is_float_dtype(name: String) -> Bool
Whether name is one of the dialect's floating point formats. What the
special padding values are confined to.
dev.dim_extent
fn dim_extent(d: Dim) -> Int
The extent of a Dim as the type states it: the constant, or DYN_DIM
for the ones an operand carries.
dev.dim_operands
fn dim_operands(ds: List[Dim]) -> List[Int]
The handles of the dynamic entries of ds, in order. What
make_tensor_view takes as its dynamicShape or dynamicStrides group,
and its length is how many DYN_DIM markers dim_extent produced.
dev.inbounds_note
fn inbounds_note(inbounds: List[Bool]) -> String
The suffix a recorded load_view or store_view carries in a handler's
log when it makes an inbounds promise, and nothing when it makes none, so
the logs of every kernel that makes none stay as they were.
dev.padding_name
fn padding_name(p: Padding) -> String
The keyword the dialect spells a padding value with, for a diagnostic and for the renderer. The NUMBER each one carries on the wire is not here: it lives in bytecode.dawn with every other enum this package writes, and this surface only ever names the value.
dev.padding_is_special
fn padding_is_special(p: Padding) -> Bool
Whether p is one of the four SPECIAL padding values, which
verifyPartitionViewLike allows only over a floating point element
type. zero is legal over any format and the absent parameter is legal
everywhere.
dev.padding_present
fn padding_present(p: Padding) -> Bool
Whether the view carries a padding_value at all. An
atomic_red_view_tko refuses one (AtomicRedViewTkoOp::verify) and a
load past the end of the tensor reads it.
dev.param
fn param[D](d: Dtype[D], pos: Int) -> Param[D]
Parameter pos of the entry, of format d. Positions count from zero and
must agree with the params list given to trace_kernel; the handler
refuses a load or store that disagrees.
dev.param_dtype
fn param_dtype[D](p: Param[D]) -> String
The dtype name p was declared with ("f64", "f32").
dev.tile_at
fn tile_at(idx: Idx, n: Int) -> Idx !Dev
The element offset of tile block idx in a run of n-element tiles:
idx * n. What load and store want as their base, and the one
place a block index becomes an element offset; a kernel over a
multi-dimensional grid builds its base from several of these
(idx_add / idx_mul), which is why the memory operations take the
offset rather than the block index.
dev.row_major
fn row_major(shape: List[Int]) -> List[Int]
The row-major strides of shape: the suffix products, [1] for a
one-dimensional tile. What load and store use when the tile is a
contiguous run of memory (strides: None); strides: Some(..) is the
one to use when it is a block of a larger array.
dev.permute
fn permute(strides: List[Int], perm: List[Int]) -> List[Int]
The strides of a PERMUTED view of an array laid out by strides:
dimension k of the view is dimension perm[k] of the array, so
permute(row_major([r, c]), [1, 0]) is the layout that reads a row-major
r by c array transposed. A permutation is a rearrangement of the
strides and nothing else: no operation is issued, no element moves, and
the tile a load answers is already in the order the permuted view has.
This is not the dialect's permute (opcode 0x53), which knife T2 added
as permute_tile below: a transpose of an array being READ is a layout
and issues nothing, where a transpose of a tile already in registers is
an instruction. Both are here because both are real; which one a kernel
wants is decided by where the elements are.
Why the layout is two lists rather than one Layout record: knife 8
fixed the pointer path's load as a shape and a strides list, and since
the batch's PR-3 the shape is the one geometry load still takes (the
arithmetic reads every shape off its operands). A record would be a
second spelling of the same pair, not a shorter one.
dev.axis_strides
fn axis_strides(rank: Int, axis: Int, step: Int) -> List[Int]
The strides that walk ONE axis of a tile of rank dimensions and hold
every other still: step at axis and zero elsewhere. A load with these
reads the same element down a whole axis, and lanes_strided with them
answers that axis's coordinate.
dev.coord
fn coord(base: Idx, shape: List[Int], axis: Int, step: Int) -> Tile[I32] !Dev
The i32 tile of shape whose lane [i, j, ..] holds the coordinate of
that lane along axis, counted from base in steps of step: lane
[i, j] of coord(base, shape, 0, 1) is base + i. The one thing a
multi-dimensional boundary needs and a flat lanes cannot say, because a
tile at the edge of an array is outside it along one axis and inside it
along the other.
dev.and_mask
fn and_mask(a: Tile[Bool], b: Tile[Bool]) -> Tile[Bool] !Dev
The lanes both masks select: a select, a where it is 1 choosing b
and 0 elsewhere. Knife 10 brought andi, which computes the same tile
over i1 operands; this is still a select because switching it would
re-record six kernels that were measured against a device under the
spelling they have, and buy no claim the andi clients below do not
already make. The shape is the masks' own.
dev.or_mask
fn or_mask(a: Tile[Bool], b: Tile[Bool]) -> Tile[Bool] !Dev
The lanes either mask selects: a select, a where it is 1 choosing
the all-true tile and b elsewhere. The or to and_mask's and,
written the same way and for the same reason.
dev.in_range
fn in_range(c: Tile[I32], lo: Int, hi: Int) -> Tile[Bool] !Dev
The lanes whose coordinate c satisfies lo <= c < hi, as a mask of
c's shape. Both bounds are host constants; lo is not always zero,
because a stencil reads a coordinate one before the tile's own.
dev.axis_mask
fn axis_mask(base: Idx, shape: List[Int], axis: Int, limit: Int) -> Tile[Bool] !Dev
The lanes whose axis coordinate, counted from base, is below
limit: tail_mask for one axis of a multi-dimensional tile. A tile
over an array whose extent is not a multiple of the tile's covers the
whole of the last block along each axis, and this is the mask that keeps
it from reading or writing what is past the end along that one. Like
tail_mask it tests the upper bound alone, because the coordinate it is
given starts at a block's own base and counts up; a stencil, whose taps
reach BELOW that base, wants in_range. shape is the tile's: a mask
has no operand to read it from.
dev.load
fn load[D](p: Param[D], base: Idx, shape: List[Int], strides: Option[List[Int]] = None, mask: Option[Tile[Bool]] = None, pad: Option[Tile[D]] = None, hints: List[(String, List[(String, Int)])] = []) -> Tile[D] !Dev
The tile of p's format and shape whose element [i, j, ..] is at
element base + i * strides[0] + j * strides[1] + .. of p: THE load of
the pointer path, the escape hatch beside load_cell for every read a
parameter's cells do not describe (a stride of zero, a stencil's
neighbour, a tile that starts between two cells). The five names 0.6.0
spelled it with were folded into it in 0.7.0 and removed in 0.8.0.
shape is the one shape this takes, because it is the one shape no
operand carries: it is the block's geometry, what the load reads.
strides:Noneisrow_major(shape), a contiguous tile.Some(s)is every other layout:sis in ELEMENTS and is a host constant, so a tile of a larger array is a stride of that array's row length rather than a new operation (the ladder is oneiotaper dimension, reshaped and broadcast toshape, scaled by that dimension's stride and summed).mask:Nonereads every lane, so every element named must be insidep.Some(m), ani1tile ofshape, reads only the lanes whose bit is 1; a lane whose bit is 0 is not read at all.pad: what a lane the mask blocks answers, a tile ofp's format: a rank-0 one (f_const(F64, 0.0)) is widened toshape. With a mask and no pad such a lane answers an UNSPECIFIED value (the dialect's load with nopaddingValue), which is right for a lane that is only ever stored back under the same mask. A pad with no mask is refused when the kernel is recorded: every lane is read, so it would name a value nothing can answer.hints:optimization_hintsonload_ptr_tko(knife T15); the one key a load takes islatency. A hint does not change the answer, only the bytes.
Why strides is an Option rather than defaulting to row_major(shape):
the shape-once ruling makes strides the exception a kernel states, not a
value every call recomputes and shows.
dev.store
fn store[D](p: Param[D], base: Idx, v: Tile[D], strides: Option[List[Int]] = None, mask: Option[Tile[Bool]] = None, hints: List[(String, List[(String, Int)])] = []) -> Unit !Dev
Write the tile v at base: THE store of the pointer path, load's
other direction, with the options load has and no pad. The shape is
v's own, so it cannot disagree with the value; a rank-0 v is refused
(a store writes a tile with a dimension: broadcast(v, [1]) is one).
strides lays the tile out as load's does; mask writes only the
lanes whose bit is 1 and leaves memory as it was under the rest; hints
is optimization_hints on store_ptr_tko, whose one key is latency.
Without a mask every lane is written, so every element named must be
inside p.
An Out parameter is written through its cells (store_cell,
store_sub) and nothing else: a store into one is refused when the
kernel records, and so is one into an In. A Shared parameter, or any
parameter of trace_kernel, takes this.
dev.gather
fn gather[D](p: Param[D], index: Tile[I32], mask: Option[Tile[Bool]] = None, pad: Option[Tile[D]] = None) -> Tile[D] !Dev
The tile of p's format whose lane [i, j, ..] is element
index[i, j, ..] of p, shaped like index: a load whose element
offsets come from a tile instead of from base and strides. Without a
mask every lane is read, so every index must be inside p; with one,
a lane whose bit is 0 is not read at all and answers the matching lane of
pad (a rank-0 pad is widened). That is how an index OUTSIDE the array
is handled, and the reason the corpora carry such indices: the mask, not
the arithmetic, is what keeps the read inside the buffer.
Nothing new reaches the device here (knife 12). A strided load already
builds an i32 tile of element offsets and hands it to offset; this
hands offset a tile somebody else computed. Which is why an index
tile LOADED from a buffer is the whole of data-dependent addressing:
load it, then gather with it. 0.8.0 folded gather_masked into the
two options, as 0.7.0 did the pointer path's loads.
dev.scatter
fn scatter[D](p: Param[D], index: Tile[I32], v: Tile[D], mask: Option[Tile[Bool]] = None) -> Unit !Dev
Write lane [i, j, ..] of v to element index[i, j, ..] of p: a
store whose element offsets come from a tile. Without a mask every
lane is written, so every index must be inside p; with one, a lane
whose bit is 0 leaves memory as it was, which is what an index outside
the array gets. NO TWO LANES WRITTEN MAY NAME THE SAME ELEMENT: the
dialect gives the lanes of one store no order against each other, so a
duplicated index has no answer at all rather than one of two. A
permutation is the shape every caller here writes. A scatter is not a
cell write, so its parameter is a Shared one.
dev.atomic_rmw
fn atomic_rmw[D](p: Param[D], mode: String, index: Tile[I32], v: Tile[D], mask: Option[Tile[Bool]] = None) -> Tile[D] !Dev
Apply mode to element index[i, j, ..] of p and lane [i, j, ..]
of v, one indivisible transaction per lane, and answer what each
element held BEFORE. Only the lanes mask selects take part (every lane
without one); the others are not read and not written, which is how an
index outside the array is handled, exactly as on scatter.
Two lanes MAY name one element here -- that is the whole point of an
atomic, and the difference between this and scatter. The order they
arrive in is still unspecified, so a caller may only use a mode whose
answer does not depend on it. Integer add is such a mode and is the
one histogram uses; xchg is not, and a kernel that used it on
colliding lanes would have no answer to compare against.
dev.atomic_add
fn atomic_add(p: Param[I32], index: Tile[I32], v: Tile[I32], mask: Option[Tile[Bool]] = None) -> Unit !Dev
Add lane [i, j, ..] of v to element index[i, j, ..] of the integer
buffer p where mask selects it, and throw the old value away: the
scatter-with-collisions a histogram is. Named because the returned tile
is what a caller forgets to use, and a function that never answers it
cannot be misread as a plain store.
dev.atomic_cas
fn atomic_cas[D](p: Param[D], index: Tile[I32], cmp: Tile[D], v: Tile[D], mask: Option[Tile[Bool]] = None) -> Tile[D] !Dev
Compare element index[i, j, ..] of p against lane [i, j, ..] of
cmp, write the matching lane of v where the two are equal, leave
the element alone where they are not, and answer what was there either
way -- one indivisible transaction per lane, on the lanes mask
selects (every lane without one). The comparison is on the BITS and not
on the format's own equality, so two NaNs with different payloads are
two values.
dev.add
fn add[D](a: Tile[D], b: Tile[D], rounding: Rounding = NearestEven, ftz: Bool = false) -> Tile[D] !Dev
The element-wise sum of two tiles of one format.
rounding is the dialect's rounding attribute and ftz its
flush_to_zero unit attribute, both at the dialect's own defaults
(nearest_even, not set). NearestEven is the one mode
tile-backend-design 3.2's theorem covers and what every kernel here
writes unless it says otherwise. The other values this package writes
are listed at "the attributes of float arithmetic" below.
dev.sub
fn sub[D](a: Tile[D], b: Tile[D], rounding: Rounding = NearestEven, ftz: Bool = false) -> Tile[D] !Dev
The element-wise difference a - b. rounding and ftz as for add.
dev.mul
fn mul[D](a: Tile[D], b: Tile[D], rounding: Rounding = NearestEven, ftz: Bool = false) -> Tile[D] !Dev
The element-wise product a * b. rounding and ftz as for add.
dev.div
fn div[D](a: Tile[D], b: Tile[D], rounding: Rounding = NearestEven, ftz: Bool = false) -> Tile[D] !Dev
The element-wise quotient a / b. rounding and ftz as for add.
dev.max
fn max[D](a: Tile[D], b: Tile[D], propagate_nan: Bool = false) -> Tile[D] !Dev
The element-wise larger of a and b. Without propagate_nan (the
dialect's default), a NaN operand answers the other operand; with it, a
NaN operand answers a canonical NaN.
dev.min
fn min[D](a: Tile[D], b: Tile[D], propagate_nan: Bool = false) -> Tile[D] !Dev
The element-wise smaller of a and b. propagate_nan as for max.
dev.rounding_name
fn rounding_name(r: Rounding) -> String
The dialect's spelling of a rounding direction: the keyword
rounding<..> is written with, and the one std/narrow's file header
pairs with each variant. The renderer spells the modes from the
operation names, and this module's tests hold the two to each other.
dev.sqrt_approx
fn sqrt_approx[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise square root under the APPROXIMATE rounding mode: the architecture's fast reciprocal-square-root path rather than the correctly rounded root. f32 only. A tolerance-tier operation by construction, and the tolerance is the hardware's, not a series'.
dev.neg
fn neg[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise negation -a: a sign flip, exact for every value.
dev.abs
fn abs[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise absolute value |a|: a sign clear, exact for every value.
dev.exp
fn exp[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise exponential e^a. The device computes it with the CUDA
Math API (the dialect's full rounding mode, its default and the only
one an f64 tile accepts here), which is an APPROXIMATION: a kernel built
on it is a tolerance-tier kernel and no corpus makes it exact
(docs/tile-backend-design.md 6.5).
At bytecode 13.2 exp carries no attribute at all. Its rounding_mode
field was added in 13.3, and a 13.2 reader that met one would take the
next byte of the stream for something else (measured: failed to get
result type 0 for CmpIOp).
dev.exp2
fn exp2[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise 2^a, without flush_to_zero. Tolerance tier.
dev.sqrt
fn sqrt[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise square root, rounding<nearest_even> and no
flush_to_zero. Unlike its neighbours here this one is a CORRECTLY
ROUNDED operation in IEEE 754, so a kernel built on it can be held to the
bit-exact tier; the tier is a measurement, not an assumption, and
scripts/tile-gpu-diff records which one each kernel passed under.
dev.rsqrt
fn rsqrt[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise reciprocal square root, without flush_to_zero: a
hardware approximation, tolerance tier.
dev.tanh
fn tanh[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise hyperbolic tangent, rounding<full> (the only mode an
f64 tile accepts: approx is f32-only and nearest_even is refused,
both measured). Tolerance tier.
dev.floor
fn floor[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise largest integer not above a. Exact for every value it
is defined on, so a kernel built on it stays in the bit-exact tier.
dev.ceil
fn ceil[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise smallest integer not below a. Exact.
dev.pow
fn pow[D](a: Tile[D], b: Tile[D]) -> Tile[D] !Dev
The element-wise a raised to the power b. Tolerance tier.
dev.powi
fn powi[D, E](a: Tile[D], n: Tile[E]) -> Tile[D] !Dev
The element-wise a raised to the INTEGER power n: Tile IR's fpowi
(knife T16, bytecode 13.4), pow's sibling with an exponent tile of an
integer format (i1, i8, i16 or i32) instead of a float one. The dialect
reads n as SIGNED whatever its width, so an i8 lane holding 0xFF is the
power -1 and not 255.
EXACT tier, but not against the power a mathematician means. Over f64
the device answers right-to-left binary exponentiation of a to |n|
(one correctly rounded product a step) and, for a negative n, the
reciprocal of that: bit for bit on every lane measured on an RTX 3080
(tileiras 13.4.92, sm_86), and up to 34 units in the last place away
from the correctly rounded power on the same corpus
(docs/tile-backend-design.md 6.19). a^0 is 1.0 for every a, a NaN
included. tileref's ref_fpowi is that sequence; pow above, with a
float exponent, is a different operation and stays tolerance tier.
dev.sin
fn sin[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise sine. The device runs the CUDA Math API's, an
APPROXIMATION, and the host second opinion is tileref/ref.ref_sin, an
independent range reduction and Taylor series. Tolerance tier.
dev.tan
fn tan[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise tangent. Tolerance tier, and the one of the five with poles: near an odd multiple of pi / 2 the answer is enormous and the relative part of the tolerance is what carries the comparison, so a corpus must not sit on a pole.
dev.sinh
fn sinh[D](a: Tile[D]) -> Tile[D] !Dev
The element-wise hyperbolic sine. Tolerance tier. It grows like e^|x|,
so a corpus reaching |x| = 700 in f64 is at the edge of the format.
dev.atan2
fn atan2[D](a: Tile[D], b: Tile[D]) -> Tile[D] !Dev
The element-wise atan2 of a over b, with the quadrant taken from
the signs of both.
THE ARGUMENT ORDER IS THE NUMERATOR FIRST, which is C's atan2(y, x)
with the names swapped: the dialect calls its first operand x and its
second y and defines the operation as "the arc tangent of the ratio of
first and second input arguments x / y" (Ops.td, Atan2Op). So the
dialect's x is C's y. Reading the operand names instead of the
sentence gets the two backwards on every lane off the positive axis,
which is what the layer-2 mutant atan2-operands-swapped is. The quadrant
behaviour is measured rather than assumed, in scripts/tile-gpu-diff.
Tolerance tier.
dev.remf
fn remf[D](a: Tile[D], b: Tile[D]) -> Tile[D] !Dev
The element-wise floating-point remainder of a by b.
TRUNCATED division, not IEEE remainder: the dialect states it as
a - trunc(a / b) * b, so the answer takes the sign of the DIVIDEND and
its magnitude is below |b| (Ops.td, RemFOp). That is C's fmod and
not C's remainder, and the two differ on every lane where the true
quotient is more than half a step from its truncation, so a corpus of
negative dividends and of |a| < |b| is what tells them apart. The
device is what settles it and scripts/tile-gpu-diff is where it was
settled.
The value is EXACT wherever it is defined: the remainder of two representable numbers is itself representable. So a kernel whose only operation is this one could be held to the bit-exact tier; the sweep that covers it here is a tolerance-tier kernel because its neighbours are.
dev.fma
fn fma[D](a: Tile[D], b: Tile[D], c: Tile[D]) -> Tile[D] !Dev
The element-wise fused multiply-add a * b + c, rounded ONCE (the product
is not rounded before the sum). A host reference that multiplies and then
adds agrees with this only where the product is exact.
dev.erf
fn erf[D](a: Tile[D]) -> Tile[D] !Dev
The error function of a, element-wise, BY COMPOSITION.
Abramowitz and Stegun, Handbook of Mathematical Functions, formula
7.1.26: for x >= 0, with t = 1 / (1 + 0.3275911 x),
erf(x) = 1 - (a1 t + a2 t^2 + a3 t^3 + a4 t^4 + a5 t^5) exp(-x^2) + e
with |e| <= 1.5e-7 for every x >= 0 (the handbook's own bound; the
largest this tree has measured is 1.385e-7, in a std/gpu test block).
The negative half comes from the odd symmetry erf(-x) = -erf(x), which
is what the select below is: 7.1.26 holds for x >= 0 only, and
dropping the sign flip answers erf(|x|).
1.5e-7 is inside the atol = 1e-5 leetgpu compares under
(docs/tile-backend-design.md 6.5), so every kernel built on this is a
TOLERANCE tier kernel and cannot be anything else. The host reference it
is compared against is tileref/ref.ref_erf, a series accurate to 4e-15, so
the difference a device run reports IS this error and not a disagreement
between two approximations.
Operations used, all of them already here: absf, mulf, addf,
divf, negf, exp, subf, a float compare and a select. The
constants take no shape: each is materialised where it is used, at the
operand's.
dev.mma
fn mma[A, C: FloatDtype](a: Tile[A], b: Tile[A], acc: Tile[C]) -> Tile[C] !Dev
The matrix multiply-accumulate a * b + acc: a is [m, k], b is
[k, n] and acc and the answer are [m, n]. The two operands share
one format and the accumulator may have another (tileir 0.10.0; 0.9's
mmaf took three of one format): bf16 operands into an f32
accumulator is mma(a, b, acc) with a: Tile[BF16] and
acc: Tile[F32]. The pairs the dialect allows (Ops.td's MmaFOp table)
are the only ones the recording takes:
| operands | accumulator |
|---|---|
f8E4M3FN, f8E5M2, f16 | f16 or f32 |
bf16, tf32, f32 | f32 |
f64 | f64 |
and it refuses any other pair with this table in the message. The
accumulator's format is C, read from the type (FloatDtype), so a
format-less lit(0.0) accumulator is made in it: C comes from the
accumulator's own type or from what the answer is checked against, as
in let s: Tile[F32] = mma(a, b, lit(0.0)).
The three numbers are the operands' own, and the recording refuses a b
whose K is not a's; a rank-0 accumulator is widened to [m, n]. This
is the ONE operation here whose summation over k has no specified
order and no promised instruction: tileiras --remarks=tensorcore says
whether the device used a tensor core or plain FMAs, and either way a
kernel built on it is a tolerance-tier kernel
(docs/tile-backend-design.md 6.5).
fast_acc is a 13.3 flag and is never written here. The Tile IR
operation is still mmaf; the integer one is mmai below.
dev.mma_scaled
fn mma_scaled[D, S](a: Tile[D], b: Tile[D], c: Tile[F32], sa: Tile[S], sb: Tile[S]) -> Tile[F32] !Dev
The BLOCK-SCALED floating-point matrix multiply-accumulate (knife T10;
mmaf_scaled before tileir 0.10.0): mma for the low-precision
formats that carry a separate exponent per run of K. a is [m, k], b is [k, n], c and the answer are f32
tiles of [m, n], and sa and sb are scale tiles of [m, k / block]
and [k / block, n]: each scale element multiplies a run of block
elements along K in the matrix beside it, and block is read off sa.
The dialect allows two combinations and nothing else (Ops.td's table):
f8E4M3FN, f8E5M2 or f4E2M1FN operands with f8E8M0FNU scales, and
f4E2M1FN operands with f8E4M3FN scales. The result is f32 in both.
There is no combination with a wide operand format, which is why this
operation cannot be assembled for this machine's sm_86 at all: every
operand type it takes is one tileiras refuses below sm_100.
dev.mmai
fn mmai(a: Tile[I8], b: Tile[I8], c: Tile[I32]) -> Tile[I32] !Dev
The INTEGER matrix multiply-accumulate a * b + c (knife 11): a is an
i8 tile of [m, k], b an i8 tile of [k, n], and c and the
answer are i32 tiles of [m, n]. Both operands are read SIGNED (the
dialect carries signedness_lhs and signedness_rhs separately; this
surface fixes both, because a kernel that wanted the unsigned reading
would want a u8 tile type the dialect does not have).
Unlike mma, this is an EXACT operation: every partial product of two
i8 values fits in 15 bits and the accumulator is i32, so the sum over k
is the same integer whatever order the device folds it in. A kernel built
on it is exact tier, and that is a theorem rather than a corpus.
It keeps its i where the float mma dropped its f (tileir 0.10.0),
as the integer arithmetic keeps addi beside the float add: its
formats are the dialect's and not the caller's, its tier is exact where
mma's is tolerance, and one name over both would check an I8 operand
against the float table and refuse it only when the kernel records.
dev.f_const
fn f_const[D: FloatDtype](d: Dtype[D], value: D) -> Tile[D] !Dev
The rank-0 tile of format d holding value, fixed when the kernel is
recorded, which is what a host value in a kernel body means. value is a
D (tileir 0.13.0), so the rounding to the format happens where the host
value is made, in plain sight: f_const(BF16, 0.1) types the literal as a
BF16 and records the bfloat16 nearest to 0.1, the 0.10009765625 the device
holds, and a Float computed on the host is f_const(BF16, bf16(x)).
dev.lit
fn lit[D](value: Float) -> Tile[D] !Dev
A float constant with no format of its own: mul(s, lit(0.5)) is
mul(s, f_const(F64, 0.5)) when s is a Tile[Float] and
mul(s, f_const(F32, 0.5)) when it is a Tile[F32] (tileir 0.8.2). As
a constant's shape is the use's, so is its format: the recording makes
it where it meets an element-wise operation, of that operation's format
(JAX calls this a weakly typed constant). D is found from what the
call is checked against, so lit is written after a typed operand
(mul(s, lit(0.5)), not mul(lit(0.5), s)) or under an annotation.
A place that needs a format of its own -- a broadcast, a reduction, a
loop's starting value, a store, an integer operation -- refuses a lit
by name and says to write f_const there.
dev.full
fn full[D: FloatDtype](shape: List[Int], value: D) -> Tile[D] !Dev
The tile of shape every lane of which holds value, in the format the
call is checked against (tileir 0.10.0): let m: Tile[F32] =
full([32, 1], 0.0), or a Carry[F32] annotation on the carry it
starts. Like lit, its format comes from where it is used; unlike
lit, it has one of its own once made, so it can be what a broadcast
or a loop's starting value cannot take a lit for. It records exactly
what broadcast(f_const(F32, 0.0), [32, 1]) records (one constant of
that shape, made where it is used), so swapping the two changes no byte.
zeros(p) and fill(p, v) are the same tile shaped like a cell of p
in p's format; they stay, because there the parameter already says
both, and they also make integer tiles, which this does not.
dev.i8_const
fn i8_const(value: Int) -> Tile[I8] !Dev
The rank-0 i8 tile holding the low octet of value, read as a
two's-complement signed byte. The padding value of a masked load over an
8-bit buffer, and the only way to name an i8 constant here: there is no
i8 ARITHMETIC in this surface (exti widens to i32 and trunci comes
back), so this and the memory operations are the whole of what an i8
tile does.
dev.lanes_strided
fn lanes_strided(base: Idx, shape: List[Int], strides: List[Int]) -> Tile[I32] !Dev
The i32 tile of shape holding the ELEMENT OFFSET each lane of a tile
at base laid out by strides reads: base + i * strides[0] + ... The
same ladder a load at that base builds, memoised, so a masked load costs
no instruction more than an unmasked one. shape and strides are the
block's geometry, which no operand carries.
dev.lanes
fn lanes(base: Idx, shape: List[Int]) -> Tile[I32] !Dev
lanes_strided over a contiguous tile, which is the run of element
indices [base, base + 1, .., base + prod(shape) - 1] in row-major
order.
dev.eq
fn eq[D](a: Tile[D], b: Tile[D]) -> Tile[Bool] !Dev
a == b over two tiles of one float format, as a mask of their shape.
Ordered: a NaN operand answers 0 for every one of the six comparisons
this heads, ne included.
dev.ne
fn ne[D](a: Tile[D], b: Tile[D]) -> Tile[Bool] !Dev
a != b: one of the six ordered predicates eq heads, so a NaN operand
answers 0 here too.
dev.lt
fn lt[D](a: Tile[D], b: Tile[D]) -> Tile[Bool] !Dev
a < b: one of the six ordered predicates eq heads.
dev.le
fn le[D](a: Tile[D], b: Tile[D]) -> Tile[Bool] !Dev
a <= b: one of the six ordered predicates eq heads.
dev.gt
fn gt[D](a: Tile[D], b: Tile[D]) -> Tile[Bool] !Dev
a > b: one of the six ordered predicates eq heads.
dev.ge
fn ge[D](a: Tile[D], b: Tile[D]) -> Tile[Bool] !Dev
a >= b: one of the six ordered predicates eq heads.
dev.eq_i
fn eq_i(a: Tile[I32], b: Tile[I32]) -> Tile[Bool] !Dev
The same six over two i32 tiles, signed: a == b as a mask of their
shape.
dev.ne_i
fn ne_i(a: Tile[I32], b: Tile[I32]) -> Tile[Bool] !Dev
a != b over two i32 tiles, signed: one of the six eq_i heads.
dev.lt_i
fn lt_i(a: Tile[I32], b: Tile[I32]) -> Tile[Bool] !Dev
a < b over two i32 tiles, signed: one of the six eq_i heads.
dev.le_i
fn le_i(a: Tile[I32], b: Tile[I32]) -> Tile[Bool] !Dev
a <= b over two i32 tiles, signed: one of the six eq_i heads.
dev.gt_i
fn gt_i(a: Tile[I32], b: Tile[I32]) -> Tile[Bool] !Dev
a > b over two i32 tiles, signed: one of the six eq_i heads.
dev.ge_i
fn ge_i(a: Tile[I32], b: Tile[I32]) -> Tile[Bool] !Dev
a >= b over two i32 tiles, signed: one of the six eq_i heads.
dev.add_i
fn add_i(a: Tile[I32], b: Tile[I32], overflow: Overflow = NoAssumption) -> Tile[I32] !Dev
The element-wise sum a + b, wrapping modulo 2^32 under the default
overflow; see Overflow for the other three.
dev.sub_i
fn sub_i(a: Tile[I32], b: Tile[I32], overflow: Overflow = NoAssumption) -> Tile[I32] !Dev
The element-wise difference a - b, wrapping. overflow as for add_i.
dev.mul_i
fn mul_i(a: Tile[I32], b: Tile[I32], overflow: Overflow = NoAssumption) -> Tile[I32] !Dev
The element-wise product a * b, wrapping: the LOW 32 bits of the
64-bit product. mulhi_i is the other half. overflow as for add_i.
dev.div_i
fn div_i(a: Tile[I32], b: Tile[I32]) -> Tile[I32] !Dev
The element-wise signed quotient a / b, rounded toward zero (the
dialect's default rounding for divi, and to_int's). Division by zero
is undefined on the device, as it is in CUDA.
dev.rem_i
fn rem_i(a: Tile[I32], b: Tile[I32]) -> Tile[I32] !Dev
The element-wise signed remainder a % b, whose sign follows the
dividend (the remainder of a quotient rounded toward zero).
dev.max_i
fn max_i(a: Tile[I32], b: Tile[I32]) -> Tile[I32] !Dev
The element-wise signed larger of a and b.
dev.min_i
fn min_i(a: Tile[I32], b: Tile[I32]) -> Tile[I32] !Dev
The element-wise signed smaller of a and b.
dev.mulhi_i
fn mulhi_i(a: Tile[I32], b: Tile[I32]) -> Tile[I32] !Dev
The HIGH 32 bits of the 64-bit product of a and b, read as UNSIGNED
operands: the dialect defines mulhii for unsigned integers and gives it
no signedness attribute (Ops.td, MulhiIOp). A hash that needs the whole
product takes this beside mul_i.
dev.shl_i
fn shl_i(a: Tile[I32], b: Tile[I32]) -> Tile[I32] !Dev
The element-wise left shift a << b, wrapping: the bits shifted past the
top are dropped. A shift count outside [0, 32) is undefined on the
device.
dev.shr_i
fn shr_i(a: Tile[I32], b: Tile[I32]) -> Tile[I32] !Dev
The element-wise ARITHMETIC right shift a >> b: the sign bit is copied
into the vacated positions, so a negative operand stays negative. This
is shri with signedness<signed>.
dev.shr_u
fn shr_u(a: Tile[I32], b: Tile[I32]) -> Tile[I32] !Dev
The element-wise LOGICAL right shift a >>> b: zeros are shifted in, so
a negative operand becomes a large positive one. This is the SAME opcode
as shr_i with signedness<unsigned>, and the two differ on every
negative operand and nowhere else -- which is what
scripts/tile-gpu-diff's shri-always-logical mutant measures.
dev.neg_i
fn neg_i(a: Tile[I32]) -> Tile[I32] !Dev
The element-wise negation -a, wrapping (so -(-2^31) is -2^31).
dev.abs_i
fn abs_i(a: Tile[I32]) -> Tile[I32] !Dev
The element-wise absolute value |a|, wrapping (so |-2^31| is
-2^31).
dev.overflow_name
fn overflow_name(o: Overflow) -> String
The dialect's spelling of an overflow assumption, as overflow<..>
writes it.
dev.addi
fn addi[D](a: Tile[D], b: Tile[D]) -> Tile[D] !Dev
The element-wise sum a + b over two integer tiles of one format,
wrapping.
dev.subi
fn subi[D](a: Tile[D], b: Tile[D]) -> Tile[D] !Dev
The element-wise difference a - b, wrapping.
dev.muli
fn muli[D](a: Tile[D], b: Tile[D]) -> Tile[D] !Dev
The element-wise product a * b, wrapping: the low bits of the exact
product, as many as the format holds.
dev.shli
fn shli[D](a: Tile[D], b: Tile[D]) -> Tile[D] !Dev
The element-wise left shift a << b, wrapping. A shift count outside
[0, width) is undefined on the device.
dev.shri
fn shri[D](a: Tile[D], b: Tile[D]) -> Tile[D] !Dev
The element-wise ARITHMETIC right shift a >> b: the sign bit is copied
into the vacated positions.
dev.consti
fn consti[D](d: Dtype[D], value: Int) -> Tile[D] !Dev
The rank-0 tile of the integer format d holding value, truncated to
the format's width. i_const is the i32 spelling.
dev.int_to_float
fn int_to_float[A, B](a: Tile[A], to_d: Dtype[B]) -> Tile[B] !Dev
The float tile of format to_d holding each lane of the signed integer
tile a, rounded to nearest even. Exact for every i32 into an f64 tile
(a binary64 significand covers the whole range) and not always exact
into an f32 one, nor from an i64 tile.
dev.float_to_int
fn float_to_int[A, B](a: Tile[A], to_d: Dtype[B], saturating: Bool = false) -> Tile[B] !Dev
The integer tile of format to_d holding each lane of the float tile
a, TRUNCATED TOWARD ZERO.
Without saturating (the default) a lane that is infinite, NaN or
outside the target's range is undefined on the device (the dialect says
so), so a kernel that uses this owes a bound on its input. With it,
bytecode 13.4's saturating modifier (knife T17), every lane has an
answer: a lane above the target's largest value is that value, one below
its smallest is that one, an infinity is the end of its own sign and a
NaN is 0 (Ops.td's FToIOp says all four in as many words); the rest
truncate toward zero either way. saturating is legal on every ftoi
the dialect allows (FToIOp has no per-format rule for it), so the
recording handler checks it by the same rule as the plain conversion:
the operation name ftoi_sat is on its list.
dev.mask_to_int
fn mask_to_int(a: Tile[Bool]) -> Tile[I32] !Dev
The i32 tile holding 1 where the mask is set and 0 where it is not: an
exti from i1, ZERO-extending. Signedness is the whole of the claim
here -- a signed widening of a one-bit 1 is -1, not 1 -- and it is why a
count of the lanes a comparison selects is this and not a select.
dev.int_to_mask
fn int_to_mask(a: Tile[I32]) -> Tile[Bool] !Dev
The mask holding the LOW BIT of each lane of a: a trunci from i32
to i1. The parity test, and the one direction back from an integer to
a mask that costs no comparison.
dev.bits_to_float
fn bits_to_float(a: Tile[I32]) -> Tile[F32] !Dev
The f32 tile whose lanes hold the BITS of a unchanged: a bitcast,
which moves no bits and reads them as another format of the same width.
Not int_to_float, which preserves the value rather than the pattern.
dev.bits_to_int
fn bits_to_int(a: Tile[F32]) -> Tile[I32] !Dev
The i32 tile whose lanes hold the bits of the f32 tile a unchanged: the
inverse of bits_to_float, and its round trip is the identity on every
bit pattern.
dev.float_to_float
fn float_to_float[A, B](a: Tile[A], to_d: Dtype[B], rounding: Rounding = NearestEven) -> Tile[B] !Dev
The float tile of format to_d holding each lane of the float tile a,
rounded by rounding: the dialect's ftof. Widening (f16 to f64) is
exact and narrowing rounds.
Which modes a pair of formats may take is the dialect's table (Ops.td's
FToFOp at bytecode 13.4, knife T17; prog.ftof_modes is it), and the
recording handler refuses a kernel that asks for another, with the pair
and the modes it allows. On an exact widening every mode is allowed;
otherwise NearestAway only from f32 to tf32, Down only from f64 to
f32, Up from f64 to f32 and to f8E8M0FNU, and TowardZero to every
format but the 8-bit and 4-bit floats other than f8E8M0FNU. The table
is the dialect's to the letter, so it is checked here rather than left
to tileiras.
The default is the one exception to "the mode it names": into
f8E8M0FNU, which has no nearest_even, the default conversion writes
zero, the dialect's own default there, as it did before rounding was
a parameter. TowardZero writes the same mode under its own name.
The two formats must DIFFER (Ops.td's FToFOp says so and its verifier
enforces it); ftof from a format to itself is refused by tileiras,
not here.
dev.to
fn to[A, B](a: Tile[A], fmt: Dtype[B]) -> Tile[B] !Dev
a in the float format fmt, rounded to nearest even: p.to(BF16)
(tileir 0.10.0). float_to_float with its default rounding, except
that a tile already in fmt is answered as it is and records nothing,
where float_to_float would ask tileiras for an ftof between equal
formats, which it refuses. A kernel generic in its format can then write
p.to(D) and get no instruction where D is the format p has.
Another rounding is float_to_float's third argument.
dev.ext_u8
fn ext_u8(a: Tile[I8]) -> Tile[I32] !Dev
The i32 tile holding each lane of the i8 tile a widened UNSIGNED, so
an octet reads as a value in [0, 255]. This is how a kernel over a
DeviceBuffer[U8] gets at its pixels: the dialect has no u8 tile type, and
the signedness of the widening is where the format's reading lives
(std/gpu's U8 says the same thing from the host's side).
dev.trunc_u8
fn trunc_u8(a: Tile[I32]) -> Tile[I8] !Dev
The i8 tile holding the LOW OCTET of each lane of the i32 tile a: a
trunci, which keeps the bits and drops the rest. What a kernel stores
back into an 8-bit buffer, and the reason a value outside [0, 255] is the
caller's problem rather than an error: the octet is what the buffer
holds, and std/gpu's wrap_u8 is the host reading of the same byte.
dev.ext_i4
fn ext_i4(a: Tile[I4]) -> Tile[I32] !Dev
The i32 tile holding each lane of the i4 tile a widened with its
SIGN, so a nibble reads as a value in [-8, 7]. The dialect has no
arithmetic at four bits at all (Types.td leaves i4 out of
CudaTile_IntTileType), so this is how a kernel computes over one:
widen, compute at 32 bits, trunc_i4 back.
ext_u4 is the same widening the other way and answers [0, 15]. Which
of the two a kernel takes is a device answer on every nibble whose top
bit is set, and scripts/tile-gpu-diff's exti-i4-zero-extends is that
sentence as a mutant.
dev.ext_u4
fn ext_u4(a: Tile[I4]) -> Tile[I32] !Dev
The i32 tile holding each lane of the i4 tile a widened with zeros,
so a nibble reads as a value in [0, 15].
dev.trunc_i4
fn trunc_i4(a: Tile[I32]) -> Tile[I4] !Dev
The i4 tile holding the LOW NIBBLE of each lane of the i32 tile a: a
trunci, which keeps the bits and drops the rest. The wrap at four bits,
and std/gpu's wrap_i4 is the host reading of the same nibble.
dev.pack_bytes
fn pack_bytes[A](a: Tile[A]) -> Tile[I8] !Dev
The rank-1 i8 tile holding the BYTES of the rank-1 tile a: the
dialect's pack. No bit moves; the tile is reinterpreted as the byte
array it occupies, so the answer has lanes * bits / 8 lanes.
Both tiles must be RANK 1 (PackOp's verifier says so, to leave no
ambiguity about which lane is which byte) and a's format may not itself
be an 8-bit format (bitcast is that one). A sub-byte format packs two
lanes to a byte, the EVEN lane in the low nibble; Types.td writes that
order out and std/gpu.i4_lo is the host reading of it.
dev.unpack_bytes
fn unpack_bytes[B](a: Tile[I8], to_d: Dtype[B]) -> Tile[B] !Dev
The rank-1 tile of format to_d the rank-1 i8 tile a holds: the
dialect's unpack, and pack_bytes's inverse. The answer has
lanes * 8 / bits(to_d) lanes, so unpacking a byte tile into a sub-byte
format DOUBLES the lane count.
dev.tail_mask
fn tail_mask(base: Idx, shape: List[Int], limit: Int) -> Tile[Bool] !Dev
The mask of the lanes at base that fall inside a buffer of limit
elements: lanes(base, shape) < limit. The boundary of a pointer-path
kernel over a vector whose length is not a multiple of the tile width; a
parameter's cells (load_cell, store_cell) carry their own and need
none. shape is the block's geometry.
dev.select
fn select[D](cond: Tile[Bool], a: Tile[D], b: Tile[D]) -> Tile[D] !Dev
The element-wise choice: lane i of a where lane i of cond is 1,
lane i of b where it is 0. A rank-0 condition or value is widened to
the shape of the others.
dev.idx_const
fn idx_const(value: Int) -> Idx !Dev
The scalar index tile holding the host constant value: a loop bound, a
step, a stride. The constant is fixed when the kernel is recorded, which
is what a host Int in a kernel body means (tile-backend-design 5.2).
dev.idx_div
fn idx_div(a: Idx, b: Idx) -> Idx !Dev
The quotient a / b of two index tiles, rounded toward zero.
dev.carry
fn carry[D](init: Tile[D]) -> Carry[D] !Dev
A device variable starting at init. init must have a format of its
own (f_const, not lit) and a shape: a loop's accumulator starts from
zeros(p) or a broadcast, as before.
dev.get
fn get[D](c: Carry[D]) -> Tile[D] !Dev
The tile c holds where the body stands: inside a region that carries
it, the region's block argument; after the region, its result.
dev.set
fn set[D](c: Carry[D], t: Tile[D]) -> Unit !Dev
Replace the tile c holds with t, of the same format and shape (the
recording refuses any other). Not inside a reduction's or a scan's body,
whose regions carry nothing.
dev.d_range
fn d_range[B: RangeBound](lower: B, upper: B, step: Step[B] = One, unsigned_cmp: Bool = false) -> DRange[B]
The index range [lower, upper) by step, to loop over with for:
for j in d_range(0, K / TK) { acc = mma(load_at(a, [j]), load_at(b, [j]), acc) }
The body runs once, on the host, with the induction variable (an Idx
whichever kind the bounds are), and what it issues falls inside the
loop's region. A var the body assigns is carried through the loop and
holds what the last iteration assigned after it. A body may nest a
for; the handler bounds the depth. The memory ordering token is carried
by the handler, so a store after the loop is ordered after every store
inside it. break, continue and return are not allowed in the body
(staged-for-design); a data-dependent exit is d_loop.
With Int bounds the range records idx_const for lower, upper and step,
in that order; with Idx bounds it records only what the step needs.
unsigned_cmp sets the loop's unsignedCmp unit attribute (knife T4):
the induction variable is compared against the upper bound as an
UNSIGNED 32-bit integer, so a range whose bounds straddle 2^31 runs
forward instead of not running at all. Its own kernel is
scripts/tile-golden's attr_ucmp.
dev.d_loop
fn d_loop(body: fn() -> Tile[Bool] !Dev) -> Unit !Dev
The loop whose trip count the DATA decides: body runs on the host and
answers a rank-0 mask, true when the loop is to stop here; the carries it
sets are carried (Carry). When the mask is true the loop stops with the
carries as they ENTERED that iteration, so the loop reads "while the
mask is false, keep what the body set"; what body set on the iteration
that stopped it is discarded. Compute the mask from what the iteration
started with, before the sets, unless that is not what you mean.
This is d_range's sibling and the only other region that iterates. The
difference is where the trip count comes from: d_range's bounds are
index handles the host or the grid supplies, and this one's exit is a
value the kernel computes from the tiles it is carrying. A body that
never answers a true mask never terminates on the device, exactly as a
while (true) would; nothing here bounds it.
Do not store inside one unless the store is meant to happen on the
discarded iteration too: body runs to the end every time, including
the last.
dev.d_return_if
fn d_return_if(cond: Tile[Bool]) -> Unit !Dev
End the kernel for this tile block where cond is true, from wherever the
call is: the top of the kernel, an if, or -- what bytecode 13.4 added
(knife T17) -- the body of a d_loop, where it leaves the loop AND the
kernel at once. Stores issued before it are kept; nothing recorded after
it runs for a block whose cond was true, including the code after the
loop. Where cond is false it does nothing and the body carries on.
Not inside d_range, a reduction or a scan: the dialect forbids a return
under a for (use the loop for an early exit, Ops.td says) and the
other two are pure regions. The recording refuses all three.
dev.idx_eq
fn idx_eq(a: Idx, b: Idx) -> Tile[Bool] !Dev
a == b over two index tiles, as the rank-0 mask d_if branches on.
Signed, as every integer comparison here is.
dev.idx_ne
fn idx_ne(a: Idx, b: Idx) -> Tile[Bool] !Dev
a != b over two index tiles, signed, as a rank-0 mask: one of the six
index predicates idx_eq heads.
dev.idx_lt
fn idx_lt(a: Idx, b: Idx) -> Tile[Bool] !Dev
a < b over two index tiles, signed, as a rank-0 mask: one of the six
index predicates idx_eq heads.
dev.idx_le
fn idx_le(a: Idx, b: Idx) -> Tile[Bool] !Dev
a <= b over two index tiles, signed, as a rank-0 mask: one of the six
index predicates idx_eq heads.
dev.idx_gt
fn idx_gt(a: Idx, b: Idx) -> Tile[Bool] !Dev
a > b over two index tiles, signed, as a rank-0 mask: one of the six
index predicates idx_eq heads.
dev.idx_ge
fn idx_ge(a: Idx, b: Idx) -> Tile[Bool] !Dev
a >= b over two index tiles, signed, as a rank-0 mask: one of the six
index predicates idx_eq heads.
dev.d_reduce
fn d_reduce[D](t: Tile[D], identity: Float, body: fn(Tile[D], Tile[D]) -> Tile[D] !Dev, dim: Int = -1) -> Tile[D] !Dev
The reduction of the tile t along dimension dim (the last by
default; -k is the k-th from the end), which answers t with that
dimension DROPPED: a [64, 32] tile along dimension 1 answers a [64]
one, and a [1024] tile answers a rank-0 one. body runs ONCE, on the
host, with the accumulator and an element of the tile as the region sees
them, IN THAT ORDER, both rank 0, and answers the new accumulator; what
it issues in between is the region. identity is what a fold that has
seen nothing starts from (0.0 for a sum, -inf for a max), written in
t's format (a whole number for an integer one), and must be the
identity of body or the answer is wrong on the device and nowhere else.
THE ORDER IS NOT SPECIFIED. Tile IR requires the function to be associative and commutative and says nothing about the tree, so a host reference that folds left is not an oracle for this in general (docs/tile-backend-design.md 6.5). A corpus on which every order is exact, or a tolerance, is. Integer addition modulo 2^32 is exactly associative and commutative, so an integer sum's answer does not depend on the order at all.
dev.d_reduce2
fn d_reduce2[A](values: Tile[A], idxs: Tile[I32], value_identity: Float, index_identity: Int, body: fn(Tile[A], Tile[I32], Tile[A], Tile[I32]) -> (Tile[A], Tile[I32]) !Dev, dim: Int = -1) -> (Tile[A], Tile[I32]) !Dev
d_reduce over TWO tiles at once, a tile of values and the i32 index
tile beside it: the shape argmax and argmin have, where the winning value
and the lane it came from must travel together through one fold. body
takes the accumulator of each and then the element of each, and answers
the new accumulator of each.
dev.d_scan
fn d_scan[D](t: Tile[D], identity: Float, body: fn(Tile[D], Tile[D]) -> Tile[D] !Dev, dim: Int = -1, reverse: Bool = false) -> Tile[D] !Dev
The inclusive prefix scan of the tile t along dimension dim (the last
by default): an answer of the SAME shape whose position j is the fold
of everything at or before j along that dimension. body runs ONCE, on
the host, with the ACCUMULATOR (everything folded so far) and the ELEMENT
arriving now, in that order, and answers the new accumulator; what it
issues in between is the region. With reverse the prefix runs the
other way: position j folds everything at or after it, and the
accumulator is still the first argument.
identity is what a fold that has seen nothing starts from, as a
reduction's is. The device does not fold it in at the first position
(measured, see t_reduce_begin above), so it must be a real identity of
body or nothing at all depends on it.
THE EVALUATION ORDER IS NOT SPECIFIED, the same way d_reduce's is
not: the dialect asks the function to be associative and says the order
within each prefix is implementation-defined. Measured on sm_86, the
device's float prefix sum is NOT the sequential left fold -- it differs
in the last bits from lane 2 onwards -- so a host reference that folds
left is not a bit oracle for a float scan, and a float scan kernel is
tolerance tier (docs/tile-backend-design.md 6.6). What a scan does NOT
need is COMMUTATIVITY: every element keeps its side of the accumulator,
which is why a non-commutative body (an affine composition, "the last
value that carried a flag") is legal here and is not under reduce.
dev.d_scan2
fn d_scan2[D](a: Tile[D], b: Tile[D], a_identity: Float, b_identity: Float, body: fn(Tile[D], Tile[D], Tile[D], Tile[D]) -> (Tile[D], Tile[D]) !Dev, dim: Int = -1, reverse: Bool = false) -> (Tile[D], Tile[D]) !Dev
d_scan over TWO tiles at once, both of one format: the shape a
first-order linear recurrence has, where a step's coefficient and its
addend must travel through one fold together. body takes the
accumulator of each and then the element of each, and answers the new
accumulator of each.
The dialect's own description says scan "is restricted to only
support single tile input". Its verifier does not say so, tileiras
assembles a two-operand scan, and the DEVICE answers one correctly:
measured on sm_86 with the affine composition
(c, v) . (c', v') = (c * c', c' * v + v'), which is
h[t] = a[t] * h[t-1] + x[t] (docs/tile-backend-design.md 6.6). The
sentence in the dialect's prose is the only thing that says otherwise
and nothing enforces it, so this is recorded as a measurement and the
kernels that need it name it.
dev.d_if
fn d_if(cond: Tile[Bool], on_true: fn() -> Unit !Dev, on_false: fn() -> Unit !Dev) -> Unit !Dev
The if over the rank-0 mask cond: on_true and on_false each run
on the host, and what each issues is one of the operation's two regions.
A carry either branch sets is a result of the if (Carry): each branch
starts from the tile the carry held before the if, a branch that does
not set it answers that tile, and after the if the carry holds the
branch's answer the mask chose. Both must keep the carry's format and
shape. A wider cond is refused when the kernel records.
Both regions are always written, as the dialect's reader requires (an
if with one region is refused, measured). Neither may load or store.
dev.extract
fn extract[D](t: Tile[D], to: List[Int], indices: List[Idx]) -> Tile[D] !Dev
The subtile of t of shape to whose slice number along dimension k
is indices[k]. to must divide t's shape evenly in every dimension,
and there are shape[k] / to[k] slices along dimension k; the indices
are SLICE numbers and not element offsets, which is the dialect's own
wording and the one thing about extract that is easy to read
backwards. An index outside its range is undefined behaviour, not an
error. to is the one shape this takes: no operand carries it.
dev.insert
fn insert[D](s: Tile[D], t: Tile[D], indices: List[Idx]) -> Tile[D] !Dev
t with its subtile whose slice number along dimension k is
indices[k] replaced by s: Tile IR's insert (knife T16, bytecode
13.4), extract's other direction. s's shape must divide t's
evenly in every dimension and the indices are SLICE numbers, exactly as
extract's are, so extract(insert(s, t, i), shape(s), i) is s and
insert(extract(t, shape(s), i), t, i) is t. Every lane outside the
named subtile is t's own. An index outside its range is undefined
behaviour, not an error.
dev.cat
fn cat[D](a: Tile[D], b: Tile[D], dim: Int) -> Tile[D] !Dev
a and b joined along dimension dim: the two agree in every other
dimension, and the answer's dim is the sum of theirs.
dev.permute_tile
fn permute_tile[D](t: Tile[D], perm: List[Int]) -> Tile[D] !Dev
t with its dimensions reordered: dimension k of the answer is
dimension perm[k] of t, so permute_tile(t, [1, 0, 2]) exchanges
the first two axes of a rank-3 tile. perm is a permutation of
0 .. rank - 1 and t has rank two or more.
NOT permute above, which takes strides and answers strides: that one is
a LAYOUT for a load to read an array through, and no instruction comes of
it. This one is the dialect's own permute (opcode 0x53) and moves the
elements of a tile that is already in registers. Before knife T2 the
layout was the only thing here that could be called a transpose, and the
comment on permute said the opcode had no client; it has one now, and
the two remain different operations at different times.
dev.transpose
fn transpose[D](t: Tile[D]) -> Tile[D] !Dev
The rank-2 t with its two dimensions exchanged: permute_tile(t,
[1, 0]), the same instruction (tileir 0.10.0). A tile of another rank
is refused when the kernel records, by the check every permutation
passes ("a permutation of a 3-dimensional tile has that many entries");
permute_tile with the whole permutation is what a higher rank writes.
dev.num_blocks
fn num_blocks(axis: Int) -> Idx !Dev
The number of tile blocks along axis (0, 1 or 2): the extent of the
grid this kernel was launched on, which is block_id's companion. A grid
smaller than the work is covered by stepping through it, and this is the
step.
dev.scalar
fn scalar[D: ScalarDtype](p: Param[D]) -> Tile[D] !Dev
Parameter p of the entry, read as a rank-0 tile: the value the launch
passed by value (a ByValue parameter of trace_kernel).
Nothing is loaded: the dialect's entry parameter IS the tile, so this
issues no instruction and a kernel that reads it twice reads one SSA value.
Arithmetic with it follows the rank-0 rule, so mul(t, scalar(scale))
scales every lane of t, and idx_of(scalar(n)) is a runtime extent or
loop bound. A parameter that is a buffer, or one declared in another
format, is refused when the kernel records (tileir 0.12.0).
dev.extent_of
fn extent_of[D](p: Param[D], dim: Int) -> Idx !Dev
How many elements p's tensor has along dimension dim, as an index:
the number its cells declare, or for a DYN_DIM dimension the grid's
blocks along the axis it follows times its tile, which is what such an
extent is (prog.cells). p must have cells (an In or an Out of
trace1 to trace5; a dynamic dimension that follows no grid axis is
refused by the markers already) (tileir 0.9.0).
dev.blocks_of
fn blocks_of[D](p: Param[D], dim: Int) -> Idx !Dev
How many cells p is cut into along dimension dim: the extent over
the tile, rounded up, or for a DYN_DIM dimension the grid's blocks
along the axis it follows. The upper bound of a loop over a dimension of
an In that follows no grid axis: d_range(idx_const(0), blocks_of(k, 0),
idx_const(1)) { j => .. load_at(k, [j]) .. } (tileir 0.9.0).
dev.d_fork2
fn d_fork2(a: fn() -> Unit !Dev, b: fn() -> Unit !Dev) -> Unit !Dev
a and b issued as two INDEPENDENT chains of memory operations, joined
afterwards: both start from the ordering token as it stands here, and
everything after this call is ordered after both. Neither is ordered
against the other, so the two must not touch the same element, and the
recording refuses two chains it cannot show to be disjoint.
This is the only client of t_tok_get / t_tok_set / t_tok_join, and
the only thing in this package that makes the token graph a graph. A
body that just issues its stores in order gets the line it already had.
dev.ptrs
fn ptrs[D](p: Param[D], shape: List[Int]) -> Ptrs[D] !Dev
The tile of shape every lane of which holds p's own address: the
reshape and broadcast every address ladder opens with, as a value a body
can name. ptr_offset is what moves it. shape is the geometry of the
pointer tile, which nothing else carries.
dev.ptr_offset
fn ptr_offset[D](ps: Ptrs[D], offs: Tile[I32]) -> Ptrs[D] !Dev
ps advanced by offs, in ELEMENTS of the pointee format: lane i of
the answer is lane i of ps plus offs[i] elements. The same
offset instruction a strided load builds, with the body's own tile
where its ladder would be.
dev.ptr_to_int
fn ptr_to_int[D](ps: Ptrs[D]) -> Tile[Int] !Dev
ps as a tile of i64 addresses. The values are to be read UNSIGNED; the
inverse is int_to_ptr.
dev.int_to_ptr
fn int_to_ptr[D](v: Tile[Int], d: Dtype[D]) -> Ptrs[D] !Dev
A tile of i64 addresses as pointers to d. The inverse of ptr_to_int,
and the point of the pair is that an address can be COMPUTED: a kernel
that wants a byte offset no offset can express takes the address apart
here and puts it back. A write through what this answers cannot be
followed back to a parameter, so it is refused in a kernel with an In or
an Out parameter (Shared and trace_kernel ones take it).
dev.ptr_to_ptr
fn ptr_to_ptr[A, B](ps: Ptrs[A], to_d: Dtype[B]) -> Ptrs[B] !Dev
ps reinterpreted as pointers to to_d: the same addresses, another
element format. Nothing is converted and nothing is read; what changes is
what a later ptr_offset counts in and what a later load_ptrs reads.
The dialect keeps this apart from int_to_ptr and ptr_to_int so that a
compiler can still follow pointer provenance across it.
dev.d_global
fn d_global[D](sym: String, d: Dtype[D], values: List[Float], align: Int = 0, visibility: Visibility = Public, constant: Bool = false) -> Unit !Dev
Declare a statically allocated global named sym holding values, one
element of d each. The allocation lives as long as the loaded module
does and is initialized at module load time, so what one launch writes
the next launch of the same module reads;
scripts/tile-gpu-diff/global_diff.dawn is the measurement of that
sentence.
This is a MODULE-LEVEL declaration and not an instruction: it answers
nothing, and issuing it inside a d_range, a d_loop, a d_if or a
reduction is refused. Declare each global once, before the
get_global that names it.
The three remaining parameters are the three fields of the Global section's record past the values, at the dialect's own defaults (public, writable, alignment 0), and any combination of them may be given:
align: the alignment in bytes. The record has carried the number since 13.1, so this is the one field a corpus can give two different values in one module.visibility:Privatesays the symbol is not part of the module's interface, so a host that has the module cannot look it up. That sentence is the dialect's and not this machine's, and knife TG measured the difference. What the value DOES reach is the cubin:tileirasgives a private global a LOCAL ELF binding and a public one a GLOBAL binding, which scripts/tile-golden holds onglobal_syms. What it does not reach is any answer: the CUDA driver'scuModuleGetGlobal(std/gpu'smodule_global) answers a LOCAL global as readily as a GLOBAL one, the host reads and writes both, and the next launch reads the write (scripts/tile-gpu-diff/sym_diff.dawn). So this buys the byte, layer 1 and a binding, and scripts/tileir-features/attrs.txt carries the exemptionvisibility-not-in-the-lookupon the row.constant: the global is read-only, which is a promise to the compiler and not a guard. The dialect does not say a store through a pointer got from a constant global is diagnosed, so this tree does not store through one. The flag reaches one byte of the bytecode and nothing further: with the writer's flag forced to 0 the sm_86 cubintileiraswrites is byte for byte the same one (measured, knife TG; scripts/tile-golden'sconstant-flag-as-mutableis the gate). The exemption on the row isconstant-not-in-the-cubin.
dev.visibility_name
fn visibility_name(v: Visibility) -> String
The dialect's spelling of a symbol visibility.
dev.global_ptrs
fn global_ptrs[D](sym: String, d: Dtype[D], shape: List[Int]) -> Ptrs[D] !Dev
The tile of shape every lane of which holds the address of the global
named sym: get_global answers a rank-0 tile<ptr<D>>, and this is
the same reshape and broadcast ptrs performs on a parameter. The
element at position x of the global is ptr_offset's x from here,
which is what the dialect means by "the initial values are stored in
memory in linear order".
dev.alloca_ptrs
fn alloca_ptrs[D](d: Dtype[D], num_elem: Int, align: Int, shape: List[Int], shared: Bool = false) -> Ptrs[D] !Dev
The tile of shape every lane of which holds the address of a fresh
automatic allocation of num_elem elements of d, aligned to align
bytes (knife T10). alloca answers a rank-0 tile<ptr<D>>, so this is
the same reshape and broadcast global_ptrs performs, and ptr_offset
walks the elements from here.
The allocation is SCRATCH: the dialect limits its lifetime to the block
the operation is in, so what one launch writes is not there for the
next, and each alloca in a block answers a region of its own. By
default the address is private to this tile thread; shared sets the
dialect's global unit attribute, and then the address may be read and
written by other tile threads as well as this one. The lifetime is the
same and so is everything else; what changes is one bit of the
operation's flags word.
align must be a non-zero power of two and at least the natural size of
d, which the dialect's verifier requires and the recording handler
refuses ahead of it.
dev.tensor_view
fn tensor_view[D](p: Param[D], base: Idx, shape: List[Int], strides: List[Int]) -> TensorView[D] !Dev
The tensor_view over p's buffer starting base ELEMENTS in, with
shape elements along each dimension and strides elements between two
neighbours along it (knife T11), in p's format. This is the whole of
the addressing: a view says where a tensor is and how it is laid out, and
nothing about the tiles it will be read in. A kernel whose parameter's
cells describe the tile it reads wants load_cell, which builds the view
itself; this is the escape hatch for every other view.
base is the same rank-0 index a strided load takes, and it becomes
an offset on the parameter's own pointer, so a view may start anywhere
in the buffer. Every stride is STRICTLY POSITIVE, which is where the two
addressing schemes part: a strided load reads a broadcast with a
stride of zero and make_tensor_view refuses one
(docs/tile-backend-design.md 6.14).
dev.tensor_view_dyn
fn tensor_view_dyn[D](p: Param[D], base: Idx, shape: List[Dim], strides: List[Dim]) -> TensorView[D] !Dev
tensor_view with dimensions the kernel computes (knife T12). A Fixed
entry is an extent (or a stride) the TYPE states; a Dyn entry prints
? there and hands make_tensor_view the value as an operand, so one
cubin serves every tensor of that rank rather than one shape.
Everything else is tensor_view's: the base is an element offset on the
parameter's own pointer, and a dynamic extent or stride is held to the
same rule a static one is (strictly positive), except that the device
rather than the recorder is what holds it.
dev.idx_of
fn idx_of(t: Tile[I32]) -> Idx
A rank-0 Tile[I32] as an index. Idx and a rank-0 Tile[I32] are ONE
lowered type (tile<i32>) with two surface names, and this and
idx_as_tile are the only places a kernel crosses between them.
It is here for knife T12: a dynamic extent has to come from somewhere,
and the general somewhere is a buffer. The rank is not checkable here (a
handle carries no shape at this layer), so the recording holds it
instead: an index of rank one or more is refused where it is used (a
loop bound, a cell index), and every dynamic operand of a
make_tensor_view must be a rank-0 i32 tile and is refused by name in
the lowering if it is not.
dev.idx_as_tile
fn idx_as_tile(i: Idx) -> Tile[I32]
idx_of the other way: an index as the rank-0 i32 tile every
element-wise operation takes, so a shape the device answered can be
written out, or compared against a tile (the rank-0 rule widens it).
Until the batch's PR-3 this was idx_as_scalar.
dev.partition_view
fn partition_view[D](tv: TensorView[D], tile_shape: List[Int], dim_map: List[Int], padding: Padding) -> GridView[D] !Dev
tv cut into a grid of tile_shape tiles, in tv's format.
dim_map[k] says which dimension of the tensor view tile dimension k
runs along, so the identity map is the plain partition and a
permutation of it transposes the tile; padding says what an
out-of-bounds lane of a load_view reads.
The INDEX SPACE of the answer has one dimension per tile dimension, and
its extent along k is the tensor view's extent along dim_map[k]
divided by tile_shape[k], rounded up: a tile may run past the end of
the tensor and a tile index may not. view_grid computes it.
dev.strided_view
fn strided_view[D](tv: TensorView[D], tile_shape: List[Int], traversal: List[Int], dim_map: List[Int], padding: Padding) -> GridView[D] !Dev
tv cut into a grid of tile_shape tiles that ADVANCES by traversal
(knife T13): index k of the grid covers traversal[k] elements along
the tensor dimension dim_map[k] and reads tile_shape[k] of them, so
the tiles overlap when the traversal is the shorter and skip when it is
the longer. traversal == tile_shape is partition_view written the
long way, and the dialect's own documentation says so.
Every traversal stride is STRICTLY POSITIVE and the tile dimensions are
powers of two, which is StridedViewType::verify and is what the
recording handler holds. The index space along k is
ceil(shape[dim_map[k]] / traversal[k]): strided_grid computes it, and
partial tiles at the far end are IN it (the dialect includes them).
dev.gather_scatter_view
fn gather_scatter_view[D](tv: TensorView[D], tile_shape: List[Int], sparse_dim: Int, padding: Padding) -> GatherScatterView[D] !Dev
tv read a row at a time along sparse_dim (knife T13). The index at
that dimension is a 1D tile of row numbers rather than a tile index, so
one load collects tile_shape[sparse_dim] rows chosen while the kernel
runs and stacks them into the tile; every other dimension is a grid view's
tile index.
There is no dim_map: what the dialect holds against the tensor view
instead is that sparse_dim is one of its dimensions.
dev.load_view
fn load_view[D](v: GridView[D], indices: List[Idx], inbounds: List[Bool] = []) -> Tile[D] !Dev
The tile of v's grid at indices, one index a tile dimension. In the
token chain where every other load is; lanes that fall outside the
underlying tensor read the view's padding value.
inbounds (knife T18) is the dialect's inbounds array: true at a
dimension PROMISES that no tile this load reads is cut by the tensor's
edge along it, so the assembler may drop the bounds handling there.
[], the default, promises nothing at any dimension; otherwise it holds
one entry per index. The promise is the caller's: the dialect makes a
broken one undefined behaviour, and tileiras answers a tile that does
reach the edge with an unnamed failed to compile Tile IR program.
dev.store_view
fn store_view[D](v: GridView[D], indices: List[Idx], t: Tile[D], inbounds: List[Bool] = []) -> Unit !Dev
t written into the tile of v's grid at indices. In the token chain
where every other store is; lanes that fall outside the underlying
tensor are MASKED, which is the store's half of the same rule the
padding value is the load's half of. A view of an Out parameter is not
one of its cells, so the recording refuses this into one (store_cell
and store_sub are an Out's writes); a Shared parameter takes it.
inbounds is load_view's promise, for the lanes this store would mask.
dev.load_gather
fn load_gather[D](v: GatherScatterView[D], indices: List[GatherIdx]) -> Tile[D] !Dev
The tile v gathers at indices: the rows named by the Rows entry at
sparse_dim, each cut to the tile at the At indices of the other
dimensions. In the token chain where every other load is.
dev.store_gather
fn store_gather[D](v: GatherScatterView[D], indices: List[GatherIdx], t: Tile[D]) -> Unit !Dev
t scattered back the way load_gather collects: row k of the tile
goes to the row the Rows entry names at k. In the token chain where
every other store is.
dev.atomic_red_view
fn atomic_red_view[D](v: GridView[D], indices: List[Idx], mode: String, scope: String, t: Tile[D]) -> Unit !Dev
t reduced into the tile of v's grid at indices, one element at a
time and ATOMICALLY: atomic_red_view_tko (knife T13). Unlike
atomic_rmw, it answers nothing at all, so two tile blocks may reduce
into one tile of the grid and the result does not depend on the order
they arrive in.
mode is one of the nine the operation takes (and, or, xor, add,
addf, max, min, umax, umin; xchg is refused because a
reduction that returns nothing cannot exchange). scope is tl_blk or
device; the ordering is always relaxed, which is the only one the
dialect allows here. A view carrying a padding_value is refused.
dev.tensor_dim
fn tensor_dim[D](tv: TensorView[D], dim: Int) -> Idx !Dev
The extent of tv's tensor along dimension dim, asked of the DEVICE:
get_tensor_shape (knife T12). The dialect's operation answers every
dimension at once, the way get_tile_block_id answers every axis, and
this names one of them; the lowering issues the whole operation and binds
this handle to the dim-th result.
For a static dimension the answer is the number already in the type, and
for a ? one it is the operand make_tensor_view was handed. That is
what makes it worth asking: a kernel over a dynamic tensor has no other
way to know where the tensor ends.
dev.index_space_dim
fn index_space_dim[D](v: GridView[D], dim: Int) -> Idx !Dev
The extent of v's INDEX SPACE along tile dimension dim, asked of the
device: get_index_space_shape (knife T12). The index space is the grid
of tiles the partition view cuts its tensor into, so this is
ceil(shape[dim_map[dim]] / tile_shape[dim]) computed where the tensor's
extent is known, which for a dynamic tensor is only on the device.
view_grid is the same number computed on the host from a static shape.
dev.view_grid
fn view_grid(shape: List[Int], tile_shape: List[Int], dim_map: List[Int]) -> List[Int]
The extent of a partition view's index space, as the grid a kernel over
it is launched on: ceil(shape[dim_map[k]] / tile_shape[k]) along tile
dimension k. The host needs the same number, so this is the one place
it is computed.
dev.strided_grid
fn strided_grid(shape: List[Int], traversal: List[Int], dim_map: List[Int]) -> List[Int]
The same for a STRIDED view, where the grid steps by the traversal and
not by the tile: ceil(shape[dim_map[k]] / traversal[k]). Partial tiles
at the far end of the tensor are in the index space, which is why this
rounds up exactly as view_grid does.
dev.load_ptrs
fn load_ptrs[D](ps: Ptrs[D]) -> Tile[D] !Dev
The tile the pointers in ps point at, unmasked, shaped like ps. In
the token chain where load is, and ordered against every other memory
operation the same way.
dev.store_ptrs
fn store_ptrs[D](ps: Ptrs[D], v: Tile[D]) -> Unit !Dev
Write v through the pointers in ps, unmasked: load_ptrs's other
direction, and the same store_ptr_tko a store through a parameter
emits. In the token chain where store is. A write through pointers
into an Out is refused (an Out's writes are its cells'), as is one the
recording cannot follow to a parameter while any parameter is an In or
an Out.
dev.i64_const
fn i64_const(value: Int) -> Tile[Int] !Dev
The rank-0 i64 tile holding value. What an address is offset by, in
bytes; the i64 spelling of consti.
dev.add_i64
fn add_i64(a: Tile[Int], b: Tile[Int]) -> Tile[Int] !Dev
The element-wise i64 sum a + b, the spelling address arithmetic uses.
addi over i64 tiles is the same instruction and the same bytes.
dev.d_assert
fn d_assert(cond: Tile[Bool], message: String) -> Unit !Dev
Stop the kernel with message when any lane of cond is false (knife
T6). Every zero lane prints the message and its index, and the host is
told that an assertion fired; the dialect leaves the tile block free to
keep running, so what a failing kernel wrote before the assert is not
defined away.
The failure reaches the HOST, not this program: a launch whose assertion fired makes the CUDA context report an error, and that error is sticky -- every later launch in the same process fails too. That is why scripts/tile-gpu-diff runs the failing corpus in a process of its own, and it is the reason "this launch should fail" is a judgement shape and not just another comparison.
dev.d_assume
fn d_assume[D](t: Tile[D], pred: AssumePred) -> Tile[D] !Dev
t with pred attached: the same value, and a claim about it the
compiler may use. The claim is NOT checked, here or on the device, so a
false one is undefined behaviour (AssumePred says where the dialect
says so).
dev.assume_div_by
fn assume_div_by[D](t: Tile[D], divisor: Int) -> Tile[D] !Dev
t, assumed to hold multiples of divisor in every lane.
dev.assume_same_elements
fn assume_same_elements[D](t: Tile[D], groups: List[Int]) -> Tile[D] !Dev
t, assumed to repeat one value in every group of groups[k] along
dimension k.
dev.assume_in_range
fn assume_in_range[D](t: Tile[D], lb: Option[Int], ub: Option[Int]) -> Tile[D] !Dev
t, assumed to hold values between lb and ub inclusive, read
signed. Either bound may be None.
dev.d_print
fn d_print[D](fmt: String, args: List[Tile[D]]) -> Unit !Dev
Print fmt with args interleaved, C printf style: one format
expression (a % and its conversion) per argument, and the dialect
refuses a count that disagrees (PrintTkoOp's verifier counts them the
way format_args in prog.dawn does). Every argument is of one format,
the first's.
RANK-0 ARGUMENTS ONLY, and that is a decision about what can be JUDGED rather than a limit of the operation. The dialect takes a tile of any shape and says in as many words that prints "are not guaranteed to be atomic" and that simultaneous ones "may be interleaved", so the bytes a wide tile puts on standard output are not a value a test can compare. One rank-0 tile printed by one tile block is one line, and scripts/tile-gpu-diff compares it byte for byte. The effect operation underneath carries a shape, so nothing here has to change if a later knife finds a judgement for the wide case.
TOKEN ORDERED, which is what the _tko in the opcode's name means: this
takes the ordering token as it stands and answers the next one, so a
print sits in the same chain as the loads and stores around it. Without
that the compiler is free to move it (Ops.td says so).
dev.memory_fence_alias
fn memory_fence_alias() -> Unit !Dev
The alias proxy fence (memory_fence_alias_tko, bytecode 13.4). Along the
token chain it makes every earlier access that reaches the same physical
memory through a different virtual alias complete before anything after
it. It answers nothing: like d_print it takes the ordering token as it
stands and puts the next in its place, so it sits in the chain with the
loads and stores around it. Refused in a reduce, scan or if region for
the reason d_print is.
dev.gdc_launch_dependents
fn gdc_launch_dependents() -> Unit !Dev
Tell the grid's dependent kernel it may be scheduled (gdc_launch_dependents_tko,
Programmatic Dependent Launch, bytecode 13.4). Grid-wide: every block must
execute it, or finish, before the dependent is scheduled. It controls
scheduling only and orders no memory. On a target below sm_90 it is a
no-op, and so it is on any target when the kernel was not launched with
the programmatic stream serialization attribute, which std/gpu's launch
does not set.
dev.gdc_wait
fn gdc_wait() -> Unit !Dev
Wait for the predecessor kernel to complete (gdc_wait_tko): every store
it made is visible after this. The pair of gdc_launch_dependents, with
the same two no-op conditions.
dev.load_cell
fn load_cell[D](p: Param[D]) -> Tile[D] !Dev
This block's cell of p, read through a view of p's cells: the tile
of p's marker, at the block ids of the grid axes its dimensions
follow. Lanes past the extent read the marker's padding value, so
cells([1000], [1024], pad: PadNegInf) reads a whole row with no mask.
Every dimension must follow a grid axis; load_at names the cells of
the ones that do not. p must be an In or an Out cut into cells.
dev.load_at
fn load_at[D](p: Param[D], free: List[Idx]) -> Tile[D] !Dev
load_cell for a parameter some of whose dimensions follow no grid
axis (FREE_AXIS in its along): free names the cell along each of
those, in dimension order, and the others are the block's own, as for
load_cell. The left operand of a matrix product cut
cells([M, K], [TM, TK], along: [0, FREE_AXIS]) is read
load_at(a, [k]) inside the loop over k.
dev.store_cell
fn store_cell[D](p: Param[D], t: Tile[D]) -> Unit !Dev
t written into this block's cell of p, an Out cut into cells:
the same view load_cell reads, at the block's own index. Lanes past
the extent are not written. No format marker and no shape: t must be
the tile of p's marker, which the recording holds it to, and a rank-0
t is widened to it (every lane of the cell the same value).
This and store_sub are the only writes an Out takes (the batch's D-7):
a store, a scatter, an atomic or a store_view into an Out is
refused while the kernel records, so the claim "each block writes its own
cell and nothing else" is not a convention but the only thing a kernel
can say.
dev.store_sub
fn store_sub[D](p: Param[D], at: List[Int], t: Tile[D]) -> Unit !Dev
t written into piece at of this block's cell of p, an Out: the
cell is cut into pieces shaped like t (which must divide the cell's
tile in every dimension), and at names one by its number along each
dimension, host numbers each below tile / shape(t). What a block that
writes its cell in several parts writes with: a slab per segment, the
two halves of a rotated row. The piece can never leave the block's cell,
which is the recording's claim and the reason at is a host number.
dev.zeros
fn zeros[D](p: Param[D]) -> Tile[D] !Dev
A tile shaped like one cell of p, in p's format, every lane zero:
the accumulator a block starts from before it stores its cell.
dev.fill
fn fill[D](p: Param[D], v: Float) -> Tile[D] !Dev
A tile shaped like one cell of p, in p's format, every lane v.
For an integer format v must be a whole number.
dev.retile
fn retile[D](p: Param[D], extent: List[Int], tile: List[Int], pad: Padding = PadZero) -> GridView[D] !Dev
p's tensor, of extent elements row-major, cut into tiles of tile
and padded with pad, as a grid view load_view reads at any index:
the escape hatch for a parameter a kernel reads two ways (the batch's
C4), when one marker can say only one of them. Read only: an Out's
writes are its cells. An In's view carries the alignment claim its cells
carry.
dev.reduce_sum
fn reduce_sum[D](t: Tile[D], dim: Int = -1, keepdims: Bool = false) -> Tile[D] !Dev
The sum of t along dimension dim (the last by default, -k the
k-th from the end): the tile with that dimension dropped, or kept with
length 1 when keepdims is true, so a [BQ, BK] tile's row sums are
[BQ] or [BQ, 1]. The identity is the format's own (0), so a fold
seeded wrongly cannot be written. The order of the fold is not
specified, as d_reduce's is not.
A rank-1 tile reduces to a rank-0 one, which every element-wise
operation of a wider shape widens on its own (the recording's rank-0
rule, 6.25): sub(t, reduce_max(t)) needs no broadcast.
dev.reduce_max
fn reduce_max[D](t: Tile[D], dim: Int = -1, keepdims: Bool = false) -> Tile[D] !Dev
reduce_sum for the largest element; the identity is -inf for a float
format and the least value for an integer one.
dev.reduce_min
fn reduce_min[D](t: Tile[D], dim: Int = -1, keepdims: Bool = false) -> Tile[D] !Dev
reduce_sum for the smallest element; the identity is +inf for a
float format and the greatest value for an integer one.
dev.scan_sum
fn scan_sum[D](t: Tile[D], dim: Int = -1, reverse: Bool = false) -> Tile[D] !Dev
The running sum of t along dim (the last by default): the tile of
t's shape whose element i along dim is the sum of elements 0 to
i, or i to the end when reverse is true.
dev.broadcast
fn broadcast[D](t: Tile[D], shape: List[Int]) -> Tile[D] !Dev
t widened to shape: every dimension of length 1 in t takes its
length in shape, and every other dimension must already have it. The
rank does not change; a rank-0 t widens to any shape (a constant
becomes one constant of that shape). This is the explicit half of the
rank-0 rule: a [BQ, 1] row statistic is broadcast(m, [BQ, BK])
before it meets a [BQ, BK] tile, and a loop's starting value is
broadcast(f_const(F64, 0.0), [64, 64]), which full writes for you.
dev.reshape
fn reshape[D](t: Tile[D], shape: List[Int]) -> Tile[D] !Dev
t with its lanes regrouped into shape, in row-major order: the lanes
are read off t row by row and laid into shape row by row, which is
Tile IR's reshape. shape holds as many lanes as t and every
dimension is a power of two; the recording refuses anything else. This
is how a tile meets a cell of another rank: a [T, T] product is stored
into a [1, T, T] cell as store_cell(o, reshape(acc, [1, T, T])).
Adding or dropping a dimension of length 1 moves no lane; regrouping the
others may cost data movement the compiler chooses.
tileir/lower
TileProg as a linear Tile IR instruction table: the one sequence both
consumers emit. The text renderer (render.dawn) spells each instruction as
cuda_tile assembly and the bytecode writer (bytecode.dawn) encodes it;
neither invents an operation, an operand order or a value number of its
own, so the text scripts/tile-golden pins and the bytes tileiras accepts
are two spellings of this table and cannot drift apart.
impls registered by this module (visible program-wide, no use needed): Show[Elem], Show[Ty], Show[Ref], Show[Instr], Show[Kernel], Show[Owner]
Elem
type Elem = Num(name: String) | PtrTo(name: String)
The element type of a tile: a number format by its Tile IR name (i32,
f32, f64), or a pointer to one.
Ty
type Ty = Token | Tile(shape: List[Int], elem: Elem) | TensorView(elem: Elem, shape: List[Int], strides: List[Int]) | PartitionView(tile_shape: List[Int], elem: Elem, shape: List[Int], strides: List[Int], dim_map: List[Int], padding: Padding) | StridedView(tile_shape: List[Int], traversal: List[Int], elem: Elem, shape: List[Int], strides: List[Int], dim_map: List[Int], padding: Padding) | GatherScatterView(tile_shape: List[Int], elem: Elem, shape: List[Int], strides: List[Int], sparse_dim: Int, padding: Padding)
A Tile IR value type: the ordering token, a tile of elem with the
given shape ([] is the scalar tile<elem>, [n] is tile<n x elem>),
or one of the two view types knife T11 added.
TensorView is !cuda_tile.tensor_view<shape x elem, strides=[..]>: a
reference to a region of global memory, its extent along each dimension
and the number of ELEMENTS one step along that dimension costs. An entry
of either list may be DYN_DIM, which is the dialect's ?: the extent
(or the stride) is not in the type at all and MakeTensorView carries it
in an operand instead (knife T12). The MARKER is what travels through
this module; the value the writer puts on the wire for it
(ShapedType::kDynamic) lives in bytecode.dawn.
PartitionView is
!cuda_tile.partition_view<tile=(..), tensor_view<..>, dim_map=[..]>,
and it is FLAT rather than holding a Ty: its elem, shape and
strides are the tensor view's own, and tensor_view_of builds that
type back when the writer needs to intern it. A nested Ty would say
the same thing and make every match arm carry a case that cannot occur
(the dialect's parameter is a TensorViewType and nothing else).
StridedView and GatherScatterView are knife T13's two, and both are
flat for the same reason. A strided view is a partition view whose
TRAVERSAL is parametric: index k covers traversal[k] elements rather
than tile_shape[k], so consecutive tiles may overlap or skip. A
gather/scatter view has no dim_map and a sparse_dim instead: the index
at that dimension is a 1D tile of ROW NUMBERS rather than a tile index, so
one load collects rows chosen at run time.
padding is the one OPTIONAL parameter any of the three has: NoPadding
is the dialect's absent padding_value, and out-of-bounds loads then read
unspecified values.
Ref
type Ref = Arg(pos: Int) | Val(id: Int)
An operand: an entry parameter by position, or the value an earlier instruction defined, by its number.
Instr
type Instr = MakeTok(dst: Int) | BlockIds(x: Int, y: Int, z: Int) | ConstInt(dst: Int, ty: Ty, value: Int) | ConstFloat(dst: Int, ty: Ty, value: Float) | MulInt(dst: Int, ty: Ty, lhs: Ref, rhs: Ref) | AddInt(dst: Int, ty: Ty, lhs: Ref, rhs: Ref) | Reshape(dst: Int, src: Ref, from: Ty, to: Ty) | Broadcast(dst: Int, src: Ref, from: Ty, to: Ty) | IotaTile(dst: Int, ty: Ty) | Offset(dst: Int, ptrs: Ref, offs: Ref, ptr_ty: Ty, off_ty: Ty) | LoadPtr(dst: Int, tok_out: Int, ptrs: Ref, mask: Option[Ref], pad: Option[Ref], tok_in: Ref, ptr_ty: Ty, mask_ty: Ty, val_ty: Ty, hints: List[(String, List[(String, Int)])]) | StorePtr(tok_out: Int, ptrs: Ref, value: Ref, mask: Option[Ref], tok_in: Ref, ptr_ty: Ty, mask_ty: Ty, val_ty: Ty, hints: List[(String, List[(String, Int)])]) | AtomicRMWPtr(dst: Int, tok_out: Int, ptrs: Ref, mode: String, arg: Ref, mask: Option[Ref], tok_in: Ref, ptr_ty: Ty, mask_ty: Ty, val_ty: Ty) | AtomicCASPtr(dst: Int, tok_out: Int, ptrs: Ref, cmp: Ref, val: Ref, mask: Option[Ref], tok_in: Ref, ptr_ty: Ty, mask_ty: Ty, val_ty: Ty) | FloatUn(dst: Int, op: String, ty: Ty, src: Ref) | FloatBin(dst: Int, op: String, ty: Ty, lhs: Ref, rhs: Ref) | FloatFma(dst: Int, ty: Ty, lhs: Ref, rhs: Ref, acc: Ref) | FloatMma(dst: Int, ty: Ty, lhs_ty: Ty, rhs_ty: Ty, lhs: Ref, rhs: Ref, acc: Ref) | FloatMmaScaled(dst: Int, ty: Ty, lhs_ty: Ty, rhs_ty: Ty, scale_ty: Ty, rhs_scale_ty: Ty, lhs: Ref, rhs: Ref, acc: Ref, lhs_scale: Ref, rhs_scale: Ref) | IntMma(dst: Int, ty: Ty, lhs_ty: Ty, rhs_ty: Ty, lhs: Ref, rhs: Ref, acc: Ref) | CmpFloat(dst: Int, pred: String, ty: Ty, res_ty: Ty, lhs: Ref, rhs: Ref) | CmpInt(dst: Int, pred: String, ty: Ty, res_ty: Ty, lhs: Ref, rhs: Ref) | IntUn(dst: Int, op: String, ty: Ty, src: Ref) | IntBin(dst: Int, op: String, ty: Ty, lhs: Ref, rhs: Ref) | Cast(dst: Int, op: String, from: Ty, to: Ty, src: Ref) | SelectTile(dst: Int, cond_ty: Ty, ty: Ty, cond: Ref, lhs: Ref, rhs: Ref) | ForLoop(results: List[Int], tys: List[Ty], iv: Int, lower: Ref, upper: Ref, step: Ref, unsigned: Bool, inits: List[Ref], carried: List[Int], body: List[Instr]) | WhileLoop(results: List[Int], tys: List[Ty], inits: List[Ref], carried: List[Int], body: List[Instr]) | BreakVals(values: List[Ref], tys: List[Ty]) | Cont(values: List[Ref], tys: List[Ty]) | ReduceTile(results: List[Int], tys: List[Ty], dim: Int, identities: List[RedId], operands: List[Ref], operand_tys: List[Ty], args: List[Int], body: List[Instr]) | ScanTile(results: List[Int], tys: List[Ty], dim: Int, reverse: Bool, identities: List[RedId], operands: List[Ref], operand_tys: List[Ty], args: List[Int], body: List[Instr]) | IfElse(results: List[Int], tys: List[Ty], cond: Ref, cond_ty: Ty, on_true: List[Instr], on_false: List[Instr]) | YieldVals(values: List[Ref], tys: List[Ty]) | ExtractTile(dst: Int, src: Ref, from: Ty, to: Ty, indices: List[Ref]) | InsertTile(dst: Int, src: Ref, dest: Ref, sub: Ty, to: Ty, indices: List[Ref]) | FloatPowI(dst: Int, ty: Ty, exp_ty: Ty, base: Ref, exp: Ref) | CatTile(dst: Int, dim: Int, lhs: Ref, rhs: Ref, lhs_ty: Ty, rhs_ty: Ty, to: Ty) | PermuteTile(dst: Int, src: Ref, perm: List[Int], from: Ty, to: Ty) | BlockCounts(x: Int, y: Int, z: Int) | JoinTokens(dst: Int, toks: List[Ref]) | PtrToIntTile(dst: Int, src: Ref, from: Ty, to: Ty) | IntToPtrTile(dst: Int, src: Ref, from: Ty, to: Ty) | PtrToPtrTile(dst: Int, src: Ref, from: Ty, to: Ty) | AssertTile(cond: Ref, cond_ty: Ty, message: String) | AssumeTile(dst: Int, src: Ref, pred: AssumePred, ty: Ty) | PrintTile(tok_out: Int, fmt: String, args: List[Ref], arg_ty: Ty, tok_in: Ref) | TokenOnlyTile(tok_out: Int, op: String, tok_in: Ref) | GetGlobalPtr(dst: Int, sym: String, ty: Ty) | AllocaPtr(dst: Int, num_elem: Int, align: Int, shared: Bool, ty: Ty) | MakeTensorView(dst: Int, base: Ref, base_ty: Ty, ty: Ty, dyn_shape: List[Ref], dyn_strides: List[Ref], dyn_ty: Ty) | MakePartitionView(dst: Int, src: Ref, src_ty: Ty, ty: Ty) | MakeStridedView(dst: Int, src: Ref, src_ty: Ty, ty: Ty) | MakeGatherScatterView(dst: Int, src: Ref, src_ty: Ty, ty: Ty) | GetTensorShape(dsts: List[Int], src: Ref, src_ty: Ty, res_ty: Ty) | GetIndexSpaceShape(dsts: List[Int], src: Ref, src_ty: Ty, res_ty: Ty) | LoadViewTile(dst: Int, tok_out: Int, view: Ref, view_ty: Ty, indices: List[Ref], idx_tys: List[Ty], ty: Ty, tok_in: Ref, inbounds: List[Bool]) | StoreViewTile(tok_out: Int, view: Ref, view_ty: Ty, value: Ref, val_ty: Ty, indices: List[Ref], idx_tys: List[Ty], tok_in: Ref, inbounds: List[Bool]) | AtomicRedViewTile(tok_out: Int, view: Ref, view_ty: Ty, value: Ref, val_ty: Ty, indices: List[Ref], idx_tys: List[Ty], mode: String, scope: String, tok_in: Ref) | Ret
One Tile IR instruction. Every dst / tok_out / x / y / z is a
value number this instruction defines; every Ref refers to a parameter
or an earlier definition. The types are the ones the operation's assembly
and bytecode both state: a result type on every value-producing
instruction, plus the operand types the assembly prints (from / to on
the shape operations, ptr_ty / off_ty / val_ty on the memory ones).
A memory instruction carries the mask and the padding value it was given,
None standing for the operand the assembly and the bytecode both leave
out; mask_ty is the i1 tile type both spell when there is one. A
comparison carries the type of its operands and the i1 type of its
result, as its assembly prints both.
ScanTile is the prefix scan, ReduceTile with one attribute more
(reverse) and results that keep every dimension: its tys are its
operand_tys, where a reduction's drop dim.
ReduceTile is the reduction: results are its rank-0 results, typed
tys; operands the tiles it folds, typed operand_tys; identities
one per operand; args the 2 * k rank-0 block arguments of its one
region (accumulator then element, per operand) and body that region,
YieldVals last. IfElse is the two-region conditional: cond is a
rank-0 i1, on_true and on_false are its regions and each ends in
its own YieldVals, and results are the values it defines after both.
ExtractTile, CatTile and PermuteTile are the shape operations
(knife T2). ExtractTile's indices are the SLICE numbers along each
dimension of from, one per dimension and each a rank-0 i32 operand, not
element offsets; CatTile's dim is the dimension the two operands are
joined along; PermuteTile's perm says that dimension k of the
answer is dimension perm[k] of the source. InsertTile is
ExtractTile's other direction (knife T16): src, typed sub, replaces
the subtile of dest its indices name, and the answer has dest's
type to; the indices are slice numbers along each dimension of to.
FloatPowI is fpowi (knife T16): a float base typed ty (which the
answer shares) and an integer exponent typed exp_ty, the one float
instruction whose two operands differ in format. BlockCounts is the grid's
extent, get_tile_block_id's companion and its exact shape.
JoinTokens merges two or more ordering tokens into one.
GetGlobalPtr is get_global (knife T7): the address of the module's
global named sym, as the rank-0 tile<ptr<D>> the dialect's verifier
demands. The spread to a full tile is Reshape and Broadcast after
it, the same two the parameter ladder emits, so this instruction has one
result and no operands.
FloatMmaScaled is mmaf_scaled (knife T10): FloatMma with two more
operands and three formats. The two matrices share lhs_ty /rhs_ty
element format, the two scale tiles share scale_ty / rhs_scale_ty's,
and ty is the f32 accumulator and result, which is the only output
format the dialect allows. It carries no attribute and no optional
field, so unlike mmaf it writes no flags word.
AllocaPtr is alloca (knife T10) and has the same shape: one result,
no operands, a rank-0 tile<ptr<D>>, spread by the same two
instructions. What it carries instead of a symbol is the request --
num_elem elements and an alignment, both inline I64Attrs -- and
shared, the global unit attribute, which is a bit of the flags
word and not an attribute in the stream.
PtrToIntTile, IntToPtrTile and PtrToPtrTile are the three pointer
conversions: a pointer tile as i64, an i64 tile as pointers, and a
pointer tile reinterpreted at another element format. All three are pure
type changes -- the address is the same number in every one of them --
which is why each carries both its operand type and its result type and
nothing else.
TokenOnlyTile is knife T19's: the alias fence and the two gdc_*
signals, which take a token, define the next and have no operand or
attribute of their own.
AssertTile, AssumeTile and PrintTile are knife T6's three.
AssertTile defines nothing and takes the rank-1 or rank-0 i1 tile
its cond_ty names. AssumeTile defines a value of the SAME type as
its operand, which is what AllTypesMatch<["value", "result"]> in the
dialect means, so one ty covers both. PrintTile defines a token and
takes one: it is in the memory ordering chain, and its args share one
type because the typed surface only builds them that way.
ForLoop is the loop: results are the values it defines after itself,
typed tys (one per carried value); iv (a scalar i32) and carried
(typed tys again) are the block arguments its body sees; lower,
upper, step and inits are operands from outside; unsigned is the
loop's unsignedCmp attribute, which decides whether the induction
variable is compared against the bound as a signed or an unsigned
32-bit integer. body ends in Cont(values, tys), the values handed to
the next iteration.
WhileLoop is the other loop (knife T5): ForLoop without an induction
variable or bounds, because it runs until a break. Its body ends in
an IfElse with no results whose then-branch is a single BreakVals
and whose else-branch is an empty YieldVals, followed by the Cont.
BreakVals carries the loop's FINAL values and Cont the next
iteration's; both lists are as long as tys.
Kernel
type Kernel = { name: String, params: List[KParam], globals: List[Global], body: List[Instr], hints: List[(String, List[(String, Int)])], producer: Option[String], debug: Option[DebugInfo] }
A lowered kernel: its entry name, the dtype name of each parameter in
order (a ByPtr parameter is a scalar tile<ptr<dtype>>, a ByValue one a rank-0 tile<dtype>), the module's
globals in declaration order, and its body, Ret last.
The globals ride through the lowering untouched: they are records of the
module and not instructions of the entry, so nothing here numbers them
or names them by value. render_kernel prints one line each above the
entry and encode_kernel writes them as the Global section.
Owner
type Owner = { op: Int, inner: List[List[Owner]] }
Which operation of the program an instruction of Kernel.body came from:
op is the operation's number with TileProg.ops read depth first, a
region before its body (prog's op_number, MakeToken(0) being 0), and
inner is the same for each body of a region instruction, in the order
the text prints them (an if has two). A pointer ladder is the operation
that FIRST needed it, because that is where it is emitted.
lower.tensor_view_of
fn tensor_view_of(t: Ty) -> Ty
The tensor_view one of the three views looks into, as its own type.
The writer interns it as a separate entry of the Type section, because
the view's own record names it by index.
lower.view_tile_of
fn view_tile_of(t: Ty) -> Ty
The tile one load_view_tko from a view answers, and the one a
store_view_tko into it takes: tile_shape of the tensor view's element
format. getViewTileType is this function, and all three views spell it
the same way.
lower.view_tile_shape
fn view_tile_shape(t: Ty) -> List[Int]
The tile shape of one of the three views, which is also the rank of its
index space (getViewIndexRank reads getTileShape().size() in all
three).
lower.view_index_tys
fn view_index_tys(t: Ty) -> List[Ty]
The type each index operand of a view has, in order. Every view but a
gather/scatter one takes a rank-0 i32 tile everywhere; that one takes a
1D i32 tile of the tile's own length at sparse_dim, because the index
there is a list of ROW NUMBERS rather than one tile index
(GatherScatterViewType::verifyIndices).
lower.param_ty
fn param_ty(dtype: String) -> Ty
The scalar pointer tile an entry parameter of format dtype is.
lower.kparam_ty
fn kparam_ty(k: KParam) -> Ty
The type of entry parameter k: the scalar pointer tile of a buffer, the
rank-0 tile<dtype> of a scalar passed by value.
lower.i1_tile
fn i1_tile(shape: List[Int]) -> Ty
The i1 tile of shape, the type of a mask and of a comparison; the
empty shape is the rank-0 tile<i1> an if branches on.
lower.num_tile
fn num_tile(shape: List[Int], dtype: String) -> Ty
The tile of shape and the named element format.
lower.lanes_in
fn lanes_in(shape: List[Int]) -> Int
The product of shape's dimensions: how many lanes a tile of it has, and
1 for the rank-0 tile.
lower.rank0_of
fn rank0_of(t: Ty) -> Ty
The rank-0 tile of the same element format as t: what a reduction's
region takes, whatever the operand's rank is.
lower.lower
fn lower(prog: TileProg) -> Kernel
prog as a Kernel: its operations in order, each memory operation
preceded by the pointer ladder it needs, Ret last. A handle no earlier
operation defined, or one a finished loop body defined, is refused rather
than numbered.
lower.lower_spans
fn lower_spans(prog: TileProg) -> (Kernel, List[Owner])
lower(prog), and beside it which operation each instruction came from:
one Owner per instruction of Kernel.body but the closing Ret, which
no operation issued, nested the way the instructions are. This is the same
loop lower runs, not a second one, so the table and the instructions
cannot disagree.
lower.instr_count
fn instr_count(body: List[Instr]) -> Int
How many instructions body is, a region's own body counted: the number
of operations the bytecode lists for it, one each (knife T14b). A region
counts as one and then what is inside it; its terminator is an
instruction of its body, so it is counted there.
lower.defines
fn defines(i: Instr) -> List[Int]
The values an instruction defines, in definition order.
lower.defines_inside
fn defines_inside(body: List[Instr]) -> List[Int]
Every value body defines, at any depth, in the order the text reads
them: for a For, its results, its induction variable, its carried
values, then its body's.
tileir/prog
A tile program as data: TileProg, the SSA record a recording handler
keeps of one run of a kernel body, and trace_kernel, that handler. The
text renderer (render.dawn) and the bytecode writer (knife 3) are its two
consumers; the golden pins the text, tileiras pins the bytes.
impls registered by this module (visible program-wide, no use needed): Show[TileOp], Show[Global], Show[KParam], Show[TileProg], Show[DebugInfo], Show[Cells], Show[Call]
TileOp
type TileOp = MakeToken(dst: Int) | BlockId(dst: Int, axis: Int) | Load(dst: Int, tok_out: Int, param: Int, dtype: String, base: Int, shape: List[Int], strides: List[Int], mask: Option[Int], pad: Option[Int], tok_in: Int, hints: List[(String, List[(String, Int)])]) | Store(tok_out: Int, param: Int, dtype: String, base: Int, shape: List[Int], strides: List[Int], value: Int, mask: Option[Int], tok_in: Int, hints: List[(String, List[(String, Int)])]) | Gather(dst: Int, tok_out: Int, param: Int, dtype: String, index: Int, shape: List[Int], mask: Option[Int], pad: Option[Int], tok_in: Int) | Scatter(tok_out: Int, param: Int, dtype: String, index: Int, shape: List[Int], value: Int, mask: Option[Int], tok_in: Int) | AtomicRMW(dst: Int, tok_out: Int, param: Int, dtype: String, mode: String, index: Int, shape: List[Int], value: Int, mask: Option[Int], tok_in: Int) | AtomicCAS(dst: Int, tok_out: Int, param: Int, dtype: String, index: Int, shape: List[Int], cmp: Int, value: Int, mask: Option[Int], tok_in: Int) | ConstF(dst: Int, dtype: String, shape: List[Int], value: Float) | ConstI(dst: Int, dtype: String, shape: List[Int], value: Int) | Iota(dst: Int, n: Int) | Lanes(dst: Int, base: Int, shape: List[Int], strides: List[Int]) | UnaryF(dst: Int, op: String, dtype: String, shape: List[Int], src: Int) | BinaryF(dst: Int, op: String, dtype: String, shape: List[Int], lhs: Int, rhs: Int) | Fma(dst: Int, dtype: String, shape: List[Int], lhs: Int, rhs: Int, acc: Int) | MmaF(dst: Int, dtype: String, out: String, m: Int, k: Int, n: Int, lhs: Int, rhs: Int, acc: Int) | MmaFScaled(dst: Int, dtype: String, scale: String, m: Int, k: Int, n: Int, block: Int, lhs: Int, rhs: Int, acc: Int, lhs_scale: Int, rhs_scale: Int) | MmaI(dst: Int, m: Int, k: Int, n: Int, lhs: Int, rhs: Int, acc: Int) | CmpF(dst: Int, pred: String, dtype: String, shape: List[Int], lhs: Int, rhs: Int) | CmpI(dst: Int, pred: String, shape: List[Int], lhs: Int, rhs: Int) | UnaryI(dst: Int, op: String, dtype: String, shape: List[Int], src: Int) | BinaryI(dst: Int, op: String, dtype: String, shape: List[Int], lhs: Int, rhs: Int) | Convert(dst: Int, op: String, from: String, to: String, shape: List[Int], src: Int) | Repack(dst: Int, op: String, from: String, to: String, from_shape: List[Int], to_shape: List[Int], src: Int) | Select(dst: Int, dtype: String, shape: List[Int], cond: Int, lhs: Int, rhs: Int) | IdxConst(dst: Int, value: Int) | IdxAdd(dst: Int, lhs: Int, rhs: Int) | IdxMul(dst: Int, lhs: Int, rhs: Int) | For(iv: Int, lower: Int, upper: Int, step: Int, unsigned: Bool, inits: List[Int], carried: List[Int], results: List[Int], body: List[TileOp]) | Loop(inits: List[Int], carried: List[Int], results: List[Int], body: List[TileOp]) | Break(values: List[Int]) | Return | Continue(values: List[Int]) | Spread(dst: Int, dtype: String, shape: List[Int], src: Int) | Reduce(results: List[Int], dim: Int, shape: List[Int], identities: List[RedId], operands: List[Int], args: List[Int], body: List[TileOp]) | Scan(results: List[Int], dim: Int, reverse: Bool, shape: List[Int], identities: List[RedId], operands: List[Int], args: List[Int], body: List[TileOp]) | If(results: List[Int], cond: Int, on_true: List[TileOp], on_false: List[TileOp]) | Yield(values: List[Int]) | Extract(dst: Int, dtype: String, from: List[Int], to: List[Int], src: Int, indices: List[Int]) | Insert(dst: Int, dtype: String, sub: List[Int], whole: List[Int], src: Int, dest: Int, indices: List[Int]) | PowI(dst: Int, dtype: String, exp_dtype: String, shape: List[Int], base: Int, exp: Int) | Cat(dst: Int, dtype: String, dim: Int, lhs_shape: List[Int], rhs_shape: List[Int], lhs: Int, rhs: Int) | Permute(dst: Int, dtype: String, shape: List[Int], perm: List[Int], src: Int) | NumBlocks(dst: Int, axis: Int) | ScalarArg(dst: Int, param: Int, dtype: String) | JoinToks(dst: Int, toks: List[Int]) | MakePtrs(dst: Int, param: Int, dtype: String, shape: List[Int]) | PtrAdd(dst: Int, dtype: String, shape: List[Int], ptrs: Int, offs: Int) | PtrInt(dst: Int, dtype: String, shape: List[Int], ptrs: Int) | IntPtr(dst: Int, dtype: String, shape: List[Int], ints: Int) | PtrCast(dst: Int, from: String, to: String, shape: List[Int], ptrs: Int) | LoadAt(dst: Int, tok_out: Int, dtype: String, shape: List[Int], ptrs: Int, mask: Option[Int], pad: Option[Int], tok_in: Int) | Assert(shape: List[Int], cond: Int, message: String) | Assume(dst: Int, pred: AssumePred, dtype: String, shape: List[Int], src: Int) | Print(tok_out: Int, fmt: String, dtype: String, shape: List[Int], args: List[Int], tok_in: Int) | TokenOnly(tok_out: Int, op: String, tok_in: Int) | StoreAt(tok_out: Int, dtype: String, shape: List[Int], ptrs: Int, value: Int, mask: Option[Int], tok_in: Int) | GetGlobal(dst: Int, sym: String, dtype: String, shape: List[Int]) | Alloca(dst: Int, dtype: String, num_elem: Int, align: Int, shared: Bool, shape: List[Int]) | TensorViewOf(dst: Int, param: Int, dtype: String, base: Int, shape: List[Int], strides: List[Int], dyn_shape: List[Int], dyn_strides: List[Int]) | StridedViewOf(dst: Int, dtype: String, view: Int, tile_shape: List[Int], traversal: List[Int], dim_map: List[Int], padding: Padding) | GatherViewOf(dst: Int, dtype: String, view: Int, tile_shape: List[Int], sparse_dim: Int, padding: Padding) | AtomicRedViewOf(tok_out: Int, dtype: String, view: Int, indices: List[Int], mode: String, scope: String, value: Int, tok_in: Int) | PartitionViewOf(dst: Int, dtype: String, view: Int, tile_shape: List[Int], dim_map: List[Int], padding: Padding) | LoadView(dst: Int, tok_out: Int, dtype: String, view: Int, indices: List[Int], tok_in: Int, inbounds: List[Bool]) | StoreView(tok_out: Int, dtype: String, view: Int, indices: List[Int], value: Int, tok_in: Int, inbounds: List[Bool]) | TensorShapeOf(dst: Int, view: Int, dim: Int) | IndexSpaceShapeOf(dst: Int, view: Int, dim: Int) | CellViewOf(dst: Int, param: Int, dtype: String, align: Int, shape: List[Int], strides: List[Int], dyn_shape: List[Int], dyn_strides: List[Int]) | ReshapeOf(dst: Int, dtype: String, from: List[Int], to: List[Int], src: Int) | BroadcastOf(dst: Int, dtype: String, from: List[Int], to: List[Int], src: Int)
One SSA operation of a tile program. Every dst / tok_out is a handle
this operation defines; every other handle field refers to an earlier
definition. param is a position in TileProg.params; n is a tile
width; dtype is a std/gpu dtype name. IdxConst / IdxAdd / IdxMul
are scalar i32 values: a host constant, a sum, a product.
For is a region (tile-backend-design 5.2): a loop over the index handles
[lower, upper) by step, with the induction variable iv and the
carried values entering as inits (handles defined before the loop),
named carried inside body and results after it, the three lists
the same length. body is the region's own operations in issue order,
Continue(values) last: the values the next iteration receives, one per
carried value. The recording handler carries the memory token through
every loop as the LAST carried value, so inits, carried and results
each end in a token handle and Continue ends in the body's last token.
iv, carried and everything body defines are visible inside the
region only; results are the loop's values after it.
Loop is the other iterating region (knife T5): the same carried
values as a For and no induction variable or bounds, because its trip
count is not known when it opens. Its body ends in
If([], cond, [Break(values)], [Yield([])]) followed by
Continue(values): break hands the loop its final values and
continue hands the next iteration its values, and the if between
them is the only place a Break appears. What Break carries is what
ENTERED the iteration (the carried handles and the token as it stands
when the body ends), and what Continue carries is what the body
computed. Break's values, Continue's values, inits, carried and
results are all the same length, and all five end in a token.
Return ends the kernel for the tile block that reaches it, and the one
place it is recorded is If([], cond, [Return], [Yield([])]) (knife
T17): an early exit taken where cond holds. It carries nothing, the
token included, because nothing after it runs. Bytecode 13.4 lets that
if sit in a Loop's body as well as at the top of the entry or inside
another if; never under a For, a Reduce or a Scan.
Insert is Extract's other direction (knife T16): src is the
subtile of shape sub, dest the tile of shape whole it goes into,
and dst the tile that answers, dest with one subtile replaced.
PowI is the float power with an INTEGER exponent tile (knife T16):
base is a dtype tile and exp an exp_dtype one of the same shape.
Extract, Cat and Permute are knife T2's shape operations, and
NumBlocks the grid's extent (BlockId's companion). JoinToks merges
two or more tokens into one, and it is the one operation here that makes
the token chain a GRAPH: every other memory operation takes the token
before it and answers the next, so the record's order is a line.
MakePtrs, PtrAdd, PtrInt, IntPtr, PtrCast, LoadAt and
StoreAt are the pointer surface. A Load builds its own address
ladder in the lowering and never names it; these seven name it:
MakePtrs is the parameter spread over a tile, PtrAdd an offset by
an i32 tile the body computed, and the three conversions are the
dialect's ptr_to_int, int_to_ptr and ptr_to_ptr. LoadAt and
StoreAt are Load and Store through such a tile, and both sit in
the token chain exactly as Load and Store do.
GetGlobal is the other producer of a pointer tile (knife T7): the
address of a module-level Global, spread over shape the way
MakePtrs spreads a parameter's. What it names is a Global of the
same TileProg and not an operation, which is why it carries a symbol
and not a handle.
Alloca is the third producer of a pointer tile (knife T10): a fresh
automatic allocation of num_elem elements of dtype, aligned to
align bytes and spread over shape the same way. Unlike GetGlobal
it names nothing: each occurrence is its own allocation, and the
dialect limits the lifetime to the block the operation sits in.
shared is the global unit attribute.
Assert, Assume and Print are knife T6's three. Assert defines
nothing and is not in the token chain: it is the one operation here that
can make a launch fail. Assume defines a value equal to its operand and
carries a predicate attribute nothing checks. Print IS in the token
chain (print_tko), so it carries tok_in and defines tok_out the way
a store does, and its args are all of one format and shape, which is
what the typed surface allows.
Spread widens a rank-0 tile to n lanes. Reduce and If are the two
other regions. A Scan is a Reduce that keeps every dimension and
carries one attribute more (reverse): its results have the shape of
its operands. A Reduce over k operands folds them along dim with
one identity each; args are the 2 * k rank-0 handles the body sees
(accumulator then element, per operand, in operand order) and results
the k rank-0 handles after it; body ends in Yield(values), the new
accumulators. An If holds TWO regions, on_true and on_false, each
ending in its own Yield; results are the handles after it. n is 0
for a rank-0 tile everywhere it appears.
CellViewOf, ReshapeOf and BroadcastOf are the batch's PR-1
(docs/tile-backend-design.md 6.25). CellViewOf is the tensor view a
parameter's CELLS are read and written through: TensorViewOf with no
base (a parameter's cells start at its first element) and one field
more, align. A non-zero align is a claim that the parameter's
address is a multiple of that many bytes, which the lowering issues as
assume div_by<align> on the parameter itself, ahead of the view; the
recording sets it for every parameter it cuts into cells, In and Out
(since PR-2; PR-1 set it for an In only). ReshapeOf
regroups a tile's lanes into another shape of as many, and BroadcastOf
widens the dimensions of length 1; Spread stays the rank-0 case.
Global
type Global = { sym: String, dtype: String, values: List[Float], align: Int, is_private: Bool, constant: Bool }
One statically allocated global of the module (knife T7): its symbol
name, the element format, the values it is initialized with (one per
element, so the tile type is tile<len(values) x dtype> and its rank is
1, which GlobalOp's verifier requires) and the alignment in bytes, 0
for the dialect's default.
A global is NOT part of the entry: it sits beside it in the module, and
its lifetime is the module's. What the bytecode carries is a record in
the Global section (id 6) rather than an instruction in the function
body, which is why global 0x31 is structural in
scripts/tileir-features/features.txt the way entry and module are.
is_private and constant are the two fields the Global section's record
grew at bytecode 13.3 (Ops.td versions GlobalOp's symbol_visibility and
constant arguments "13.3", and BytecodeWriter.cpp's writeGlobalSection
writes four varints per global below that version and six at or above it).
Knife T8 moved the pin to 13.3 and so this record carries them:
is_private becomes the SymbolVisibility enum value the writer holds
(bytecode.dawn's VIS_PUBLIC and VIS_PRIVATE) and constant says the
global is read-only.
Neither changes what a kernel computes here, and neither can be checked
from the host without cuModuleGetGlobal, which the Gpu effect does not
have; scripts/tileir-features/attrs.txt records that as the exemption
no-module-symbol-ffi.
KParam
type KParam = ByPtr(dtype: String) | ByValue(dtype: String)
How one entry parameter reaches the kernel. ByPtr is a buffer: the
parameter is a scalar tile<ptr<dtype>> and the kernel loads and stores
through it. ByValue is a scalar the launch passes in the argument area
itself (knife K4.1): the parameter is a rank-0 tile<dtype> and the kernel
reads it with scalar (in dev), never through memory. Only i32, i64,
f32 and f64 are accepted by value; the others have an argument-area
layout nobody has measured (docs/tileir-k4-design.md 3.2).
TileProg
type TileProg = { name: String, params: List[KParam], globals: List[Global], ops: List[TileOp], hints: List[(String, List[(String, Int)])], producer: Option[String], debug: Option[DebugInfo] }
A kernel as recorded: its entry name, how each parameter is passed in
order (ByPtr for a buffer, ByValue for a scalar), the globals its
module declares in declaration order, its operations in issue order
(MakeToken(0) first), and the optimization_hints of the entry itself.
hints is empty for every kernel but the ones knife T15 records with
trace_kernel(.., hints: hs). It sits on the program rather than on an
operation because that is where the dialect puts it: optimization_hints
is an argument of entry, written in the function TABLE and not in the
body (BytecodeWriter.cpp's writeFunctionTableSection).
producer is the string of the bytecode's Producer section (knife T14a,
docs/tile-backend-design.md 6.35). None for every recorded kernel: the
recording handler never sets it, a caller who wants the section builds
TileProg { ..p, producer: Some(s) }. The text renderer does not print
it, because the MLIR text has no place for it; only the bytes and
tileirdisasm show it, and the device never does.
debug is the bytecode's Debug section (knife T14b, docs 6.36): None
for every recorded kernel, written only for a caller who builds
TileProg { ..p, debug: Some(d) }. The text renderer does not print it
either.
DebugInfo
type DebugInfo = { dir: String, file: String, line: Int, col: Int, locs: List[(Int, Int)] }
Where a kernel's instructions came from, for the Debug section (knife
T14b). dir and file are the DIFile (the file's directory and its base
name); line and col are where the kernel itself starts, the
DISubprogram's line and the function's own location; locs is one
(line, col) per instruction of the LOWERED kernel, in the order the
bytecode lists them (a region's instruction before the ones in its body,
a body's terminator last, the closing return the last of all), each a
1-based position in file. The count is checked when the program is
encoded: a list that is not one per instruction is refused, because a
location table that lines up with nothing is worse than none.
The recording handler cannot know a Dawn source position (nothing in the
language reads one at run time), so the positions come from the caller:
render.text_debug gives the position of each instruction's line in the
program's own .mlir text, and a caller with better knowledge (a Dawn
source line from a call map) builds the list itself.
Cells
type Cells = Whole | Grid(extent: List[Int], tile: List[Int], pad: Padding, along: List[Int])
How a kernel parameter's tensor is cut. Whole is not cut at all.
Grid cuts a row-major tensor of extent into cells of tile:
- an extent of
DYN_DIMis the launch's to decide, and is the grid's blocks along the axis that dimension follows times the tile, so the cells then cover the tensor with no tail; padis what a read past the extent answers (the partition view'spadding_value, a float format's only for the four special ones);along[j]is the grid axis dimensionjfollows, orFREE_AXIS. A dimension that follows axiskhas as many cells as the grid has blocks alongk.
cells builds one with the defaults; Whole and Grid are its
values, for a match.
Arg
type Arg[D] = In(d: Dtype[D], g: Cells) | Out(d: Dtype[D], g: Cells) | Shared(d: Dtype[D]) | Scalar(d: Dtype[D])
One parameter of a kernel, for trace1 to trace5: its format marker
(std/gpu's F64 and so on), what the kernel does to it, and for In and
Out how its tensor is cut.
Inis read and never written: the recording refuses any write into it.In(F64, Whole)is an input read anywhere (a gather's table).Outis written, one cell per tile block, and its cells are the launch grid; it may be read (an in-place kernel reads its cell first).Sharedis read and written in ways no cell describes: atomics, scatters, a stencil's neighbours.Scalaris no buffer: the launch passes a value (std/gpu'sscalar), the entry parameter is a rank-0tile<dtype>and the body reads it withscalar(p). It isi32,i64,f32orf64, and it has no cells.
Marker
type Marker
A parameter's marker with its format erased, so markers of several
formats fit in one list: what trace_calls takes.
Call
type Call = { name: String, parent: Int, ops_from: Int, ops_to: Int, globals_from: Int, globals_to: Int }
One call the KERNEL made, as the recording saw it, from the side table
trace_calls keeps: the name of the public function (dev.dawn's called
marks), the call it was made inside, and the half-open ranges of
operations and of TileProg.globals it issued, its children's included.
A row is a call the kernel body wrote: one at the top of the body, or one
inside a closure the kernel handed to a region function (dev.dawn's body
marks say which those are). What a public function calls on its own
behalf is not a row: tile_at's idx_const and idx_mul are
tile_at's operations. parent is the row of the region call whose
closure this call is in (d_range, d_scan, ...), or -1 at the top of the
body, and it is always an EARLIER row: the rows are in the order the
calls started.
ops_from and ops_to number operations the way TileProg.ops reads
depth first, a region before its body (the numbering a refusal's op #k
uses), with the entry's MakeToken(0) as 0, so the first call of a body
starts at 1. A call's range holds its children's ranges, and what is left
of it once they are taken out is what the call issued itself: for a region
call, the region and its terminator.
prog.MAX_LOOP_DEPTH
const MAX_LOOP_DEPTH: Int
How deep loops may nest. A kernel body that recurses through d_range
unrolls at recording time, one region per call, and this is where the
handler stops it (tile-backend-design 5.2).
prog.MAX_HANDLES
const MAX_HANDLES: Int
How many handles one recording may mint. A helper that recurses without a loop unrolls into a flat record that never ends; this is where the handler stops that.
prog.FREE_AXIS
const FREE_AXIS: Int
The axis entry of a cut dimension that follows no grid axis: the kernel
picks that cell some other way (the k of a matrix product's loop). The
same number as std/gpu's.
prog.kparam_dtype
fn kparam_dtype(k: KParam) -> String
The dtype name k carries, whichever way it is passed.
prog.ftof_mode
fn ftof_mode(op: String, to: String) -> String
The rounding mode an ftof named op writes into a tile of to, in the
dialect's spelling. The plain ftof takes nearest_even, except into
f8E8M0FNU, which does not allow it and takes zero; the four suffixed
names (knife T17) take the mode they name. The renderer spells the mode
from here, and bytecode.dawn's tests hold the writer's
ftof_rounding_of to it, so the bytes and the text cannot disagree.
prog.ftof_modes
fn ftof_modes(from: String, to: String) -> List[String]
The rounding modes ftof allows from from to to: Ops.td's FToFOp
table at bytecode 13.4, read top to bottom with the first matching row
deciding (FToFOp::verify enforces it in that order). 13.3 had two rows,
"f8E8M0FNU takes zero or positive_inf" and "everything else takes
nearest_even"; 13.4 split the second into five.
to f8E8M0FNU zero, positive_inf to any other float under 16 bits nearest_even an exact widening all five IEEE modes f64 to f32 all but nearest_away f32 to tf32 nearest_even, zero, nearest_away any other narrowing nearest_even, zero
Knife T17 asked tileiras 13.4.92 about every pair of the nine float
formats in all five modes, 360 cells, and its verifier agreed with this
table on every one (docs/tile-backend-design.md 6.20). Nineteen cells the
table allows then fail in code generation with an unnamed failed to
compile Tile IR program -- the directed modes into tf32 and bf16, and
ties-away into bf16, from the narrower formats -- and are NOT refused
here: that is this assembler's gap, not the dialect's rule.
prog.op_count
fn op_count(ops: List[TileOp]) -> Int
How many operations ops holds counted the way TileProg.ops reads
depth first: one for each, plus everything inside a region, its closing
Continue / Yield included. Of a whole program's ops, one more than
the largest number a Call range or a refusal's op #k can name.
prog.mma_accumulators
fn mma_accumulators(dtype: String) -> List[String]
The accumulator formats the dialect's mmaf allows over operands of
dtype (Ops.td's MmaFOp table, tileir 0.10.0), or none for a format it
does not take as an operand at all. What dev.mma is held to.
prog.trace_kernel
fn trace_kernel(name: String, params: List[KParam], body: fn() -> Unit !Dev, hints: List[(String, List[(String, Int)])] = []) -> TileProg
Run body once under a handler that records what it issues, and answer
the record. params says how each entry parameter is passed, in order
(ByPtr(dtype) for a buffer, ByValue(dtype) for a scalar); the body's
param(d, pos) values must agree with it (position in range, same
format, a buffer for a memory operation and a scalar for scalar) or the
run panics. Two runs of the same body answer equal
programs.
Regions: t_loop_begin pushes the operations recorded so far and opens an
empty list for the loop body; t_loop_end pops, wraps the body in a For
and appends it to what was pushed. The token cell is carried through the
loop as the last loop-carried value: the body starts from the loop's
carried token and the code after the loop continues from the loop's token
result, so a store after a loop is ordered after every store inside it.
A t_loop_begin deeper than MAX_LOOP_DEPTH, a t_loop_end with no open
loop or with the wrong number of values, or a body that ends with a loop
still open, is refused.
hints is the entry's optimization_hints (knife T15), empty for every
kernel but knife T15's. It is the last parameter, after body, so that
the hundreds of trace_kernel(name, params, () => ..) calls written
before it had a default still read the same (knife K2 folded
trace_kernel_hinted into it; docs/std-defaults-design.md 3 says why the
defaults go after the body).
prog.cells
fn cells(extent: List[Int], tile: List[Int], pad: Padding = PadZero, along: List[Int] = list.map(range(0, len(tile)), k => if…)) -> Cells
extent cut into cells of tile, a read past the extent answering
pad, dimension k following grid axis k (the first three; a fourth
and later follow none). Pass along for a cut whose dimensions follow
other axes, or none: the left operand of a matrix product follows axis 0
along its rows and FREE_AXIS along K. An Out's dimensions each follow
an axis, no two the same one: a batched product's output cut
along: [2, 0, 1] keeps its batch on axis 2.
prog.trace1
fn trace1[A](name: String, a: Arg[A], body: fn(Param[A]) -> Unit !Dev, hints: List[(String, List[(String, Int)])] = []) -> (TileProg, Entry1[A])
trace_kernel for a kernel of one parameter, described by its marker
rather than by a format name; beside the program, the entry std/gpu's
launch_entry1 runs. The body receives the Param the marker makes, so
its format is the marker's. A marker whose cells are malformed, or that
disagree with another's or with the grid, is refused before the body
runs; a write into an In parameter is refused while it records. The
program is the one trace_kernel records for the same body.
prog.trace2
fn trace2[A, B](name: String, a: Arg[A], b: Arg[B], body: fn(Param[A], Param[B]) -> Unit !Dev, hints: List[(String, List[(String, Int)])] = []) -> (TileProg, Entry2[A, B])
trace1 for two parameters.
prog.trace3
fn trace3[A, B, C](name: String, a: Arg[A], b: Arg[B], c: Arg[C], body: fn(Param[A], Param[B], Param[C]) -> Unit !Dev, hints: List[(String, List[(String, Int)])] = []) -> (TileProg, Entry3[A, B, C])
trace1 for three parameters.
prog.trace4
fn trace4[A, B, C, E](name: String, a: Arg[A], b: Arg[B], c: Arg[C], d: Arg[E], body: fn(Param[A], Param[B], Param[C], Param[E]) -> Unit !Dev, hints: List[(String, List[(String, Int)])] = []) -> (TileProg, Entry4[A, B, C, E])
trace1 for four parameters.
prog.trace5
fn trace5[A, B, C, E, F](name: String, a: Arg[A], b: Arg[B], c: Arg[C], d: Arg[E], e: Arg[F], body: fn(Param[A], Param[B], Param[C], Param[E], Param[F]) -> Unit !Dev, hints: List[(String, List[(String, Int)])] = []) -> (TileProg, Entry5[A, B, C, E, F])
trace1 for five parameters.
prog.trace_calls
fn trace_calls(name: String, params: List[KParam], body: fn() -> Unit !Dev, hints: List[(String, List[(String, Int)])] = [], markers: List[Marker] = []) -> (TileProg, List[Call])
trace_kernel, and beside the program the side table of the calls the
kernel made, in the order they started. The program is the one
trace_kernel answers: it is the same handler, and the marks put nothing
into ops.
With markers (one erased marker a parameter, and params empty) it
is trace1 to trace5's recording instead, roles and cells and all, and
the program is the one they answer: the body then reads its parameters
with param(d, k) in the markers' formats. What the site's call map of a
kernel recorded with cells runs.
prog.defined
fn defined(op: TileOp) -> List[Int]
The handles an operation defines in the region it sits in, in definition
order: what a consumer that renumbers (the lowering) or checks
well-formedness walks. A For defines its results there; its iv,
carried and body belong to the region inside it (defined_inside).
prog.defined_inside
fn defined_inside(ops: List[TileOp]) -> List[Int]
Every handle ops define, at any depth, in issue order: for a For, its
results, then its induction variable, its carried handles and what its
body defines; for a Loop, the same without the induction variable;
for a Reduce, its results, its region arguments and its
body; for an If, its results and then each branch in turn.
tileir/render
TileProg as Tile IR text: the cuda_tile dialect's assembly, one module
with one entry, spelled the way cuda-tile-translate prints it. The text is
layer 0 of tile-backend-design 6.2: scripts/tile-golden pins it byte for
byte, and it is what a person reads when the bytecode (bytecode.dawn)
disagrees with the device.
impls registered by this module (visible program-wide, no use needed): Show[CallLines], Show[LineMap]
CallLines
type CallLines = { name: String, parent: Int, lines: List[(Int, Int)] }
The lines of render(prog) one call the kernel made is answerable for,
its children's left out: its globals' lines and the lines of the
operations it issued itself, each a 1-based half-open range, in text
order, empty ranges left out. parent is the call's row's parent, so
the rows are the side table's tree. A region call (d_range, d_scan)
has the region's header line, its terminator and its closing brace; the
lines between are its children's.
LineMap
type LineMap = { text: String, head: List[(Int, Int)], calls: List[CallLines], tail: (Int, Int) }
render(prog) cut into who wrote each line. head is the module line,
the entry line and make_token, which no call issued; calls is one row
per row of trace_calls' side table, in its order; tail is return
and the two closing braces. Every line of text is in exactly one of
them.
render.ty
fn ty(t: Ty) -> String
t as the assembly spells it: token, tile<f64>, tile<128xf64>,
tile<64x32xf64>. Every dimension in order, x-separated, the element
format last; the rank-0 tile is the element format alone.
render.render_kernel
fn render_kernel(k: Kernel) -> String
k as one cuda_tile.module @m holding its globals and one
entry @<name>. Every parameter is a scalar pointer tile of its dtype;
the body is k.body in order, one line each. The globals come first,
because global operations are directly nested in the module and the
get_global in the entry names them. The text ends with a newline.
render.text_debug
fn text_debug(prog: TileProg, dir: String, file: String) -> DebugInfo
The Debug section for prog whose locations are its own .mlir text
(render(prog) saved as dir/file): each instruction at the line and the
column its text starts at, the kernel at its entry line. This is what
MLIR itself records for an operation parsed from that file, and it needs
no source the recording handler cannot see. A caller that knows a better
source (a Dawn line from a call map) builds DebugInfo itself.
render.line_map
fn line_map(prog: TileProg, calls: List[Call]) -> Result[LineMap, String]
The line map of prog from the side table calls that trace_calls
answered with it: operations through lower_spans to instructions,
instructions through the layout render_kernel uses to lines, and every
operation to the innermost call whose range holds it. An Err names the
first thing that does not fit: a row whose range is not inside its
parent's, two rows of one parent that overlap, or an operation or a
global no call issued, which is what a body that calls t_* directly, or
a side table that is not this program's, looks like.