tang-expr
Symbolic expression graphs. Trace Rust math into a DAG, differentiate it, simplify it, compile it to native closures or WGSL compute shaders.
The pipeline
Rust code trace symbolic
with ExprId ─────────────► expression graph
│
┌────────────┼────────────┐
│ │ │
diff simplify deps
│ │ │
derivatives fewer nodes sparsity
│ │ bitmask
└────────────┼────────────┘
│
┌────────────┼────────────┐
│ │ │
compile eval to_wgsl
│ │ │
Box<dyn Fn> f64 value WGSL kernel
(CPU) (GPU)
Quickstart
use ;
// trace a function into a graph
let = trace;
// evaluate
let result = graph.eval; // 25.0
// differentiate
let mut graph = graph;
let dy_dx = graph.diff;
let gradient = graph.eval; // 6.0
// simplify
let simplified = graph.simplify;
// compile to native Rust
let f = graph.compile;
assert_eq!;
// compile to WGSL for GPU
let kernel = graph.to_wgsl;
9 RISC primitives
All of mathematics reduces to these operations:
Add Mul Neg Recip Sqrt Sin Atan2 Exp2 Log2
Plus Var (input), Lit (constant), and Select (branchless ternary).
ExprId implements the full Scalar trait, so sin, cos, tan, exp, ln, min, max, pow, atan2 etc. all work through decomposition into the 9 primitives. You trace normal tang math and get a graph for free.
Sparsity analysis
Know which outputs depend on which inputs without evaluating:
let sparsity = graph.jacobian_sparsity;
// sparsity[i] is a u64 bitmask: bit j set means output i depends on var j
Supports up to 64 input variables.
Integer dialect (tang_expr::int)
A separate graph (IntGraph) for exact 32-bit integer kernels, such as
entropy decoders that must match a CPU reference bit for bit. The f64 RISC
graph above is untouched.
- Types:
i32,u32andbool.i8/u8exist only as buffer storage (Elem), widened on load and packed 4 peru32word. - Ops: wrapping
+ − ×,MulHi(somul_widegives the exact 64-bit product as(lo, hi)),& | ^ ! <<, and>>(arithmetic oni32). Shift amounts are taken mod 32. Alsomin/max/clamp,div/rem(WGSL semantics),clz,== != < <=,select,bitcast, and read-onlyload. - Scratch buffers:
buffer_rw(name, elem, len, base)declares a small fixed-length read-write array (in WGSL,name[base + i]withbasea host expression, e.g. a workgroup array slice).load_rwandstorethread an effect token (token(), and eachstorereturns the next one), so data dependencies order the effects. Scratch loads and stores are never inlined or moved: every backend runs them in the one shared schedule order, so their results agree across backends for any graph. Out-of-range loads read 0 and out-of-range stores are dropped. cram-codec uses this for the per-lane adaptive probability maps of its learned rANS coder. - Loops:
while_loop(init, |g, s| (cond, next))is structured SSA, like MLIRscf.while, withfor_rangeas sugar. A loop's states areState(l, k)nodes and its results areLoopOut(l, k)nodes. Loops nest, and each node belongs to the innermost loop whose state it reads, so loop invariants are hoisted by construction. Mixing the states of two sibling loops is rejected. - Contract: nothing is reassociated or contracted. The only rewrite is folding all-constant nodes, which uses the interpreter's own op functions.
- Lowerings, all from one schedule:
Program: register bytecode, the CPU reference.to_wgsl_body/to_wgsl_fn: WGSL.MulHiuses 16-bit-limb helpers inWGSL_PRELUDE, because WGSL has no 64-bit integers.to_rust: Rust source.
- Emission: a single-use node is inlined into its user when both are in
the same scope. Every other node becomes a
let, and loop states becomevars that are snapshotted at the top of each iteration. - Tests: every op on both types against Rust's own semantics, and loops
(nested, data-dependent trip count, parallel state update, loads) against
plain Rust. The emitted WGSL is validated with naga 25, and the emitted
Rust is compiled with
rustcand its output compared with the interpreter.
First user. cram-codec builds its learned TCG3 MLP as an IntGraph
(lmodel3::Net32::graph), with the stream's weights as constants. The GPU
kernel's mlp_gen is to_wgsl_body of that graph, and TC_CPU_IR=1 makes
the CPU decoder evaluate the same graph through Program.
Not yet covered, but needed to generate the whole rANS lane-decode kernel:
- Stores and scatter into storage buffers (only lane-local scratch buffers exist).
- Barriers and
workgroupUniformLoad. - Side effects ordered with loops, for example a data-dependent word fetch
that advances a stream pointer. This can be expressed today as loop state
plus
load, but there is no write-back. - Kernel entry points and bindings.
- A C lowering, which would need unsigned casts to avoid signed-overflow UB.
Integer dialect (0.2)
tang_expr::int::IntGraph is a separate, bit-exact 32-bit integer graph:
i32/u32/bool, i8/u8 storage, wrapping arithmetic, exact
mul_wide, and structured while loops with invariant hoisting. The CPU
interpreter, the generated WGSL and the generated Rust compute the same bits.
use ;
// sum of i*i for i in 0..n
let mut g = new;
let n = g.param;
let zero = g.u32;
let s = g.for_range;
assert_eq!;
let wgsl = g.to_wgsl_fn; // or g.to_rust("sum_sq", &s)
See examples/int_dialect.rs.
Design
- Structural interning — identical subexpressions always share the same
ExprId(automatic CSE) - Thread-local graph —
trace()isolates graphs cleanly;with_graph()for manual control - Memoized differentiation —
diff()caches results to avoid redundant work - Fixpoint simplification — rewrites until convergence: constant folding, identity/annihilation rules, cancellation (
x + (-x) → 0,x * (1/x) → 1) - Dead code elimination —
compile()only emits reachable operations
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option. Contributions are accepted under the same terms with a DCO sign-off (see CONTRIBUTING.md).