The two IRs¶
Hexir defines two dialects of its own. Everything below them is standard MLIR.
Dialect |
Question it answers |
Works on |
|---|---|---|
|
what to compute, and where |
tensors |
|
how one device computes it |
buffers |
hexir — the graph level¶
Whole-tensor operations with value semantics. No loops, no memory, no devices yet.
func.func @main() {
%a = hexir.constant dense<[[3.0, 1.0], [2.0, 2.0]]> : tensor<2x2xf64>
%b = hexir.constant dense<[[1.0, 5.0], [5.0, 2.0]]> : tensor<2x2xf64>
%m = hexir.linear %a, %b : tensor<2x2xf64>
%r = hexir.relu %m : tensor<2x2xf64>
hexir.print %r : tensor<2x2xf64>
return
}
After the placement pass, each operation carries where it runs:
%m = hexir.linear %a, %b {device = "cuda"} : tensor<2x2xf64>
%r = hexir.relu %m {device = "cpu"} : tensor<2x2xf64>
Five operations lower end to end: constant, linear, add, relu,
print. Several more (sigmoid, softmax, gelu, tanh, and others) are
declared in TableGen but have no lowering — asking for one gives you a clear
error rather than wrong code.
hextir — the kernel level¶
One device kernel, with the loops written out. Values are buffers (memref),
not tensors.
hextir.prim_func @linear_0(%A: memref<2x2xf64>,
%B: memref<2x2xf64>,
%C: memref<2x2xf64>) attributes {device = "cpu"} {
hextir.block "matmul" {
hextir.for "parallel" %c0 to %c2 step %c1 {
^bb0(%i: index):
hextir.for "parallel" %c0 to %c2 step %c1 {
^bb0(%j: index):
hextir.buffer_store %zero, %C[%i, %j] : f64, memref<2x2xf64>
hextir.for "serial" %c0 to %c2 step %c1 {
^bb0(%k: index):
%x = hextir.buffer_load %A[%i, %k] : memref<2x2xf64> -> f64
%y = hextir.buffer_load %B[%k, %j] : memref<2x2xf64> -> f64
%acc = hextir.buffer_load %C[%i, %j] : memref<2x2xf64> -> f64
%p = arith.mulf %x, %y : f64
%s = arith.addf %acc, %p : f64
hextir.buffer_store %s, %C[%i, %j] : f64, memref<2x2xf64>
}
}
}
}
hextir.return
}
Three ideas are doing the work here.
Destination passing. A prim_func returns nothing. The last argument is
the buffer it writes into. This is how kernels work on real hardware, and it is
why the caller has to say what the result type is.
The loop kind is the schedule. hextir.for carries a kind: serial,
parallel, vectorized, unrolled or thread_binding. That one attribute
is the scheduling decision, so a scheduling pass rewrites an attribute rather
than restructuring the IR. Placement picks it:
// device = "cpu"
hextir.for "parallel" %c0 to %c2 step %c1 { ... }
// device = "cuda"
hextir.for "thread_binding" %c0 to %c2 step %c1 bind "blockIdx.x" { ... }
Reduction axes (the k loop of a matrix multiply) stay serial on both,
because they cannot be run in parallel without more work.
Blocks have names. hextir.block "matmul" wraps the loop nest of one
computation. A scheduler can then say “tile block matmul by 32” instead of
pattern-matching on loop structure.
The bridge between them¶
hexir.call_tir is the only way down from the graph level to the kernel level.
flowchart LR
A["hexir<br/>tensors<br/>returns a value"] -->|hexir.call_tir| B["hextir<br/>buffers<br/>writes into an output"]
%r = hexir.call_tir @linear_0(%a, %b)
: (tensor<2x2xf64>, tensor<2x2xf64>) -> tensor<2x2xf64>
The two levels disagree on purpose. hexir works on tensors and returns a
result; a prim_func works on buffers and writes its result into a
destination. call_tir is where that gap is crossed: it names the result type
on the tensor side, and its verifier enforces the contract — the callee must
take one buffer per argument plus one more for the destination.
Get it wrong and you get told:
error: 'hexir.call_tir' op expected @matmul to take 3 buffers
(2 inputs + 1 destination), but it takes 2
A third pair, now gone¶
There used to be ls_cpu and ls_gpu dialects: mirror-image add, mul,
matmul and relu ops that existed only so -emit=mlir-hetero showed
placement in the op name. They were removed.
The pipeline materialized linalg ops into them and converted them straight
back, which sounds like a no-op but was lossy: rebuilding each op discarded its
destination operand and every attribute except device. One conversion pattern
was missing entirely, so any program using hexir.add failed to compile.
-emit=mlir-hetero now prints the real linalg op with its device attribute,
which says strictly more than a renamed one did.