Skip to content

Core

Expr represents a calculation whose inputs are not yet known. Function gives that calculation named inputs and outputs so that you can evaluate it, differentiate it, or generate C code. The functions guide shows how to use both.

The first sections cover modelling. Verification, rewriting, and text rendering are lower-level interfaces for inspecting or extending the compiler.

Expressions

Expr dataclass

One node of the expression graph: an op, its argument nodes and the resulting TensorType.

Nodes are immutable and interned, so building the same expression twice returns the same object. Create leaves with sym and const, then combine them with the operators, methods and builders below.

support property

support: Support

Flat row-major indices of the entries that may be nonzero, or None when any may be.

Every other entry is zero for all finite input values. A product with a constant zero counts as zero. The result is a set or a sorted array of indices, computed on first access and cached.

scalar

scalar() -> Expr

Return this expression with a hint to expand its procedure into straight-line scalar code.

The hint overrides the automatic size limits and applies to the entry point too. Procedures expand only when scalar substitution preserves each store's rounding and conversion. A block hint in the same function wins. See the lowering page of How it works for the policy.

block

block() -> Expr

Return this expression with a hint to keep its procedure as loops over buffers.

The procedure is not expanded into scalar code and survives as its own C procedure. Every other optimization still runs on it.

values

values() -> Expr

Return the stored values in row-major coordinate order, as sc.values does.

to_dense

to_dense() -> Expr

Place stored values at their coordinates, filling other entries with zero.

sum

sum(axis: int | tuple[int, ...] | None = None, *, keepdims: bool = False) -> Expr

Sum over the chosen axes, or every axis when axis is None.

Negative axes count from the end. An empty tuple leaves every entry unchanged. keepdims retains the reduced axes with extent one. Empty domains sum to zero. The result keeps the operand's dtype.

mean

mean(axis: int | tuple[int, ...] | None = None, *, keepdims: bool = False) -> Expr

Average over the chosen axes, counting structural zeros in sparse inputs.

Axes and keepdims follow sum. An empty reduction domain returns NaN.

abs

abs() -> Expr

Elementwise absolute value, retaining the operand's dtype.

sym(name, shape=None, *, dtype=dtypes.float64, diff=True) staticmethod

sym

Create a named symbolic input, the leaf every graph is built from.

Parameters:

Name Type Description Default
name str

the input's name, which becomes its name on any Function declaring it.

required
shape int | tuple[int, ...] | None

an int for a rank-1 shape, a tuple as given, or None for a scalar.

None
dtype DType | str

the element type, float64 unless you say otherwise.

float64
diff bool

whether derivatives with respect to this input are meaningful. Setting it to False tells AD the input is a constant parameter, so terms through it vanish.

True

const(value, *, dtype=None, pattern=None) staticmethod

const

Wrap an array or scalar as a constant node.

A NumPy array or scalar keeps its dtype, and anything else becomes float64, unless dtype says otherwise. Constants are never differentiable: a derivative with respect to one is structurally zero.

A SciPy sparse array becomes a sparse constant with its pattern. Duplicate entries are summed and explicitly stored zeros stay stored. pattern instead makes value the stored values of a sparse constant, in the pattern's coordinate order.

ExprOp

Bases: StrEnum

The expression dialect's operation set.

A StrEnum, so an op is a proper enum value and still prints and serializes as its name. OP_INFO carries the arity and the differentiability of each.

OpInfo dataclass

What the compiler knows about one ExprOp: its arity (None for variadic) and whether it has derivative rules. OP_INFO holds one per op.

pattern_rule gives the stored pattern of an elementwise op or MATMUL with a sparse operand. An elementwise op without one refuses a sparse operand. Movement ops and reductions compute the pattern from the stored coordinates instead. support_rule names how Expr.support follows the op, None when any entry may be nonzero.

Functions

Function dataclass

A Python body and declarations, traced once for each binding of its shape holes.

Calling with symbolic leaves embeds the selected instance in a graph. Numerical leaves evaluate its compiled code. Each call takes one positional argument per declared parameter. instantiate returns the concrete graph for inspection or ahead-of-time compilation.

instantiate

instantiate(shapes: tuple[int | tuple[int, ...] | TensorType, ...] | None = None) -> ConcreteFunction[SymbolicInputs, NumericalInputs, SymbolicOutputs, NumericalOutputs]

Bind input shape holes in flat leaf order and return the cached graph instance.

A fully declared function needs no arguments. Bare functions must be bound by calling them. TensorType bindings must agree with the declaration's dtype and differentiability.

symbolic_call

symbolic_call(*args: *Ss) -> SymbolicOutputs

Embed a symbolic call, including the zero-input case.

numerical_call

numerical_call(*args: *Ns) -> NumericalOutputs

Bind shapes, compile the instance, and evaluate its numerical outputs.

factory

factory(name: str, inputs: Sequence[str], outputs: Sequence[str | DerivSpec], aux: Any = None) -> Function

Derive named outputs once per binding, with one parameter per selected input leaf.

Shape templates must include every open source input in inputs. Fixed source inputs may be omitted when the requested outputs do not depend on them. Seeds use fwd:<input> and weights use lam:<output>, as in ConcreteFunction.factory.

sparsity

sparsity(*, of: str | None = None) -> CanonicalPattern | None
sparsity(*args: *Ns, of: str | None = None) -> CanonicalPattern | None
sparsity(*args: *Ss, of: str | None = None) -> CanonicalPattern | None
sparsity(*args: Any, of: str | None = None) -> CanonicalPattern | None

Return a sparse output's stored pattern without numerical evaluation or compilation.

Pass the same arguments as a call to select its binding. Fully specified functions also accept no arguments. of selects an output by name and is required for multiple outputs. Dense outputs return None. A batched sparse output returns the pattern its matrices share.

compile

compile() -> None

Compile a fully specified function ahead of its first numerical call.

recompile

recompile() -> None

Drop a fully specified function's compiled handle so its next numerical call uses another cache path.

solver_stats

solver_stats(name: str | None = None) -> SolverStats

Return the latest statistics for a solver in the fully specified graph.

Function.instantiate returns a concrete instance, which holds the resolved shapes and expression graph for inspection and export.

ConcreteFunction dataclass

A named expression graph: named inputs, named outputs, and the computation between them.

ConcreteFunction is the unit of composition, differentiation and compilation. Calling fn(inputs) with Expr leaves puts one call node in a larger graph. Lowering may still inline a small callee when it expands a procedure into scalar code. fn.factory(...) derives a new ConcreteFunction carrying the requested derivatives. Calling fn(inputs) with array leaves lowers it, renders C, compiles and caches a shared library, and calls it through the pointer entry that every generated function shares.

Each call takes one argument per parameter in the input tree. A group remains a tuple-valued parameter. Empty calls evaluate numerically; symbolic_call() embeds a zero-input graph. A single-output result is a bare leaf.

Input and output names matter beyond display. Derivatives are requested by name, and the generated C symbols are built from them.

A ConcreteFunction is immutable. Use @scaly.function(...) to build one from a Python body, or ConcreteFunction.build from expressions already in hand.

input_names property

input_names: tuple[str, ...]

The input leaf names in C-signature order.

output_names property

output_names: tuple[str, ...]

The output leaf names in C-signature order.

results property

results: tuple[Expr, ...]

Everything one call computes: the outputs, then the residuals of a custom reverse rule.

result_names property

result_names: tuple[str, ...]

The output names, then the first of res:0, res:1, ... that no output takes, one per residual.

nodes cached property

nodes: tuple[Expr, ...]

Every expression node the results reach, each after its operands.

input_shapes property

input_shapes: tuple[tuple[int, ...], ...]

The input leaf shapes in C-signature order.

output_shapes property

output_shapes: tuple[tuple[int, ...], ...]

The output leaf shapes in C-signature order.

build classmethod

build(name: str, input_tree: Tree[Any, Any], inputs: Sequence[Expr], output_tree: Tree[Any, Any], outputs: Sequence[Expr], *, output_coloring_widths: Sequence[int | None] | None = None, device: DeviceSpec | str | None = None, descriptor: Any = None, role: Role | None = None) -> ConcreteFunction[Any, Any, Any, Any]

Build a function from its trees and the expressions at their leaves.

inputs are the INPUT expressions of input_tree's leaves and outputs the expressions of output_tree's leaves, both in flat leaf order. Every input the outputs depend on must be among inputs.

input_map

input_map() -> dict[str, Expr]

The symbolic input leaves keyed by their declared names, in declaration order.

output_map

output_map() -> dict[str, Expr]

The output expressions keyed by their declared names, in declaration order.

symbolic_call

symbolic_call(*args: *Ss) -> SymbolicOutputs

Embed a call using one symbolic argument per declared parameter.

numerical_call

numerical_call(*args: *Ns) -> NumericalOutputs

Compile and evaluate one numerical argument per declared parameter.

compile

compile() -> None

Compile ahead of the first numerical call, or reuse the cached library.

recompile

recompile() -> None

Drop the compiled handle so the next numerical call uses another cache path.

solver_stats

solver_stats(name: str | None = None) -> SolverStats

Return the latest stats for a solver reached by this compiled function.

factory

factory(name: str, inputs: Sequence[str], outputs: Sequence[str | DerivSpec], aux: Mapping[str, Sequence[str]] | None = None) -> ConcreteFunction

Build a new ConcreteFunction whose outputs mix this function's outputs and derivatives of them.

Parameters:

Name Type Description Default
name str

the new function's name, which also names its generated C symbols.

required
inputs Sequence[str]

the input names, in order. Besides the declared inputs, "fwd:<input>" names the seed of a forward derivative and "lam:<output>" the weight of an adjoint or of an aux combination.

required
outputs Sequence[str | DerivSpec]

output names and DerivSpec requests such as Grad("cost", "x"). A request's output is named {kind}_{of}_{wrt}.

required
aux Mapping[str, Sequence[str]] | None

extra outputs usable by name in outputs and in requests. Each maps a new name to a list of output names and stands for their sum weighted by the matching "lam:<output>" inputs, as in a Lagrangian.

None

Returns:

Type Description
ConcreteFunction

A ConcreteFunction with the selected inputs and the requested outputs.

Raises:

Type Description
ValueError

if a name in inputs, outputs or aux is unknown, or an aux name shadows an output.

Types

TensorType dataclass

The shape and dtype of an expression, and whether it is differentiable (diff).

A pattern makes the tensor sparse: it lists the coordinates stored in each matrix over the last two axes, and every other entry is zero. Leading axes are batch axes sharing the pattern.

storage_shape property

storage_shape: tuple[int, ...]

The shape of the stored values: shape when dense, the batch axes and nnz when sparse.

storage_size property

storage_size: int

The number of stored values, including explicit zeros.

DType dataclass

Interned dtype descriptor.

See dtypes for the canonical instances. A DType compares equal to its string name, so dtypes.float64 == "float64" holds.

Source code in src/scaly/ir/types.py
@dataclass(frozen=True, slots=True)
class DType:
  """Interned dtype descriptor.

  See ``dtypes`` for the canonical instances. A ``DType`` compares equal to its string ``name``,
  so ``dtypes.float64 == "float64"`` holds.
  """

  name: str
  bits: int
  c_type: str
  is_floating: bool = False
  is_integer: bool = False
  is_bool: bool = False
  is_unsigned: bool = False

  @property
  def itemsize(self) -> int:
    return self.bits // 8

  def numpy(self) -> np.dtype:
    return np.dtype(self.name)

  def __str__(self) -> str:
    return self.name

  def __eq__(self, other: object) -> bool:  # backward compat with string dtype
    if isinstance(other, DType):
      return (self.name, self.bits, self.c_type, self.is_floating, self.is_integer, self.is_bool, self.is_unsigned) == (
        other.name,
        other.bits,
        other.c_type,
        other.is_floating,
        other.is_integer,
        other.is_bool,
        other.is_unsigned,
      )
    if isinstance(other, str):
      return self.name == other
    return NotImplemented

  def __hash__(self) -> int:
    return hash(self.name)

dtypes

Canonical interned dtype instances. Mirrors the small tinygrad-style registry.

SparsityPattern dataclass

The structurally nonzero entries of a matrix, as coordinate lists rows and cols.

The entry order is the order of the values stored alongside the pattern. It is not necessarily row- or column-major. to_csr and to_csc return the permutation into either order.

canonicalize

canonicalize() -> tuple[CanonicalPattern, tuple[int, ...]]

Return the shared row-major pattern and the permutation that reorders its values.

values_canonical[k] = values[permutation[k]]. Stored zeros keep their coordinates. The pattern is what TensorType(shape, pattern=...) takes.

to_mask

to_mask() -> np.ndarray

Return a dense Boolean mask of the stored coordinates.

to_csr

to_csr() -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]

(row_ptr, col_ind, val_perm): val_perm[k] is the COO position of CSR slot k, so values_csr[k] = values[val_perm[k]] pairs a compact COO-ordered value buffer with the CSR indices. The COO order is arbitrary, for example piece by piece for a mapped sparse Jacobian.

to_csc

to_csc() -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]

(col_ptr, row_ind, val_perm), the compressed-column counterpart of to_csr.

CanonicalPattern dataclass

The stored coordinates of a sparse matrix, in row-major order.

Equal coordinates give the same object, so is compares two patterns. Build one with SparsityPattern.canonicalize. The digest identifies the shape and coordinates.

union

union(other: CanonicalPattern) -> CanonicalPattern

Return the coordinates stored in either pattern.

intersection

intersection(other: CanonicalPattern) -> CanonicalPattern

Return the coordinates stored in both patterns.

matmul

matmul(other: CanonicalPattern) -> CanonicalPattern

Return the Boolean matrix product of two patterns.

as_dtype

as_dtype(value: DType | str | None) -> DType

Coerce a dtype name, a DType or None into a canonical DType.

None means float64, scaly's default.

Source code in src/scaly/ir/types.py
def as_dtype(value: DType | str | None) -> DType:
  """Coerce a dtype name, a ``DType`` or ``None`` into a canonical ``DType``.

  ``None`` means ``float64``, scaly's default.
  """
  if value is None:
    return dtypes.float64
  if isinstance(value, DType):
    return value
  if isinstance(value, str):
    return dtypes.from_name(value)
  raise TypeError(f"cannot interpret {value!r} as a DType")

Builders

einsum

einsum(spec: str, *operands: Any) -> Expr

Contract labelled axes, with NumPy's string subscript and broadcasting rules.

A label repeated within one operand selects a diagonal, and ... stands for broadcast leading axes. Operands contract pairwise from left to right as matrix products, so their order decides the size of each intermediate. Sparse operands follow the restrictions of these operations, including the refusal of batched sparse matrix products.

diag

diag(x: Any, k: int = 0) -> Expr

Build a diagonal matrix from a vector, or extract a matrix's diagonal.

k selects the diagonal above the main one when positive, below when negative. Only rank-one and rank-two inputs are accepted, following numpy.diag.

matmul

matmul(x: Expr, y: Expr, *, sampled: CanonicalPattern | None = None) -> Expr

Matrix product with NumPy's matmul rules.

The last two axes of each operand multiply as matrices and leading axes broadcast. A vector on the left acts as one row and a vector on the right as one column, and that axis is removed from the result.

dot

dot(x: Any, y: Any) -> Expr

Scalar product of two expressions with the same number of entries, whatever their shapes.

sumsqr

sumsqr(x: Any) -> Expr

Sum of squares of every entry: dot(x, x), a scalar.

Source code in src/scaly/ir/expr.py
def sumsqr(x: Any) -> Expr:
  """Sum of squares of every entry: ``dot(x, x)``, a scalar."""
  x = as_expr(x)
  return dot(x, x)

reduce_max

reduce_max(x: Any, axis: int | tuple[int, ...] | None = None, *, keepdims: bool = False) -> Expr

Maximum over axes, propagating NaN and splitting derivatives equally at ties.

Axes and keepdims follow Expr.sum. Structural zeros participate in sparse reductions. An empty reduction domain raises ValueError during construction.

Source code in src/scaly/ir/expr.py
def reduce_max(x: Any, axis: int | tuple[int, ...] | None = None, *, keepdims: bool = False) -> Expr:
  """Maximum over axes, propagating NaN and splitting derivatives equally at ties.

  Axes and ``keepdims`` follow ``Expr.sum``. Structural zeros participate in sparse
  reductions. An empty reduction domain raises ``ValueError`` during construction.
  """
  return _extreme(x, "max", axis, keepdims)

reduce_min

reduce_min(x: Any, axis: int | tuple[int, ...] | None = None, *, keepdims: bool = False) -> Expr

Minimum over axes, with the same shape, NaN and derivative rules as reduce_max.

Source code in src/scaly/ir/expr.py
def reduce_min(x: Any, axis: int | tuple[int, ...] | None = None, *, keepdims: bool = False) -> Expr:
  """Minimum over axes, with the same shape, NaN and derivative rules as ``reduce_max``."""
  return _extreme(x, "min", axis, keepdims)

argmax

argmax(x: Any, axis: int | None = None, *, keepdims: bool = False) -> Expr

Index of the first maximum or NaN, as nondifferentiable int64 values.

Floating operands, a single integer axis or None, and keepdims follow NumPy. With axis=None indices address the flattened logical array. Structural zeros participate. An empty reduction domain raises ValueError. Function output leaves currently require float64. Cast indices to float64 to return them numerically, or use the integer expression inside the function.

Source code in src/scaly/ir/expr.py
def argmax(x: Any, axis: int | None = None, *, keepdims: bool = False) -> Expr:
  """Index of the first maximum or NaN, as nondifferentiable int64 values.

  Floating operands, a single integer axis or ``None``, and ``keepdims`` follow
  NumPy. With ``axis=None`` indices address the flattened logical array. Structural
  zeros participate. An empty reduction domain raises ``ValueError``.
  Function output leaves currently require float64. Cast indices to float64 to
  return them numerically, or use the integer expression inside the function.
  """
  return _arg_extreme(x, "max", axis, keepdims)

argmin

argmin(x: Any, axis: int | None = None, *, keepdims: bool = False) -> Expr

Index of the first minimum or NaN, with the same rules as argmax.

Source code in src/scaly/ir/expr.py
def argmin(x: Any, axis: int | None = None, *, keepdims: bool = False) -> Expr:
  """Index of the first minimum or NaN, with the same rules as ``argmax``."""
  return _arg_extreme(x, "min", axis, keepdims)

norm_inf

norm_inf(x: Any) -> Expr

Maximum absolute entry, propagating NaN. Empty inputs raise ValueError.

Source code in src/scaly/ir/expr.py
def norm_inf(x: Any) -> Expr:
  """Maximum absolute entry, propagating NaN. Empty inputs raise ``ValueError``."""
  return reduce_max(as_expr(x).abs())

norm_1

norm_1(x: Any) -> Expr

Sum of absolute entries, with slope zero at zero and value zero for empty inputs.

Source code in src/scaly/ir/expr.py
def norm_1(x: Any) -> Expr:
  """Sum of absolute entries, with slope zero at zero and value zero for empty inputs."""
  return as_expr(x).abs().sum()

norm_2

norm_2(x: Any) -> Expr

Euclidean norm of every entry, as a scalar.

Source code in src/scaly/ir/expr.py
def norm_2(x: Any) -> Expr:
  """Euclidean norm of every entry, as a scalar."""
  return sumsqr(x).sqrt()

stack

stack(xs: Iterable[Any], *, axis: int = 0) -> Expr

Join equally shaped expressions along a new axis, adding one to the rank.

concat

concat(xs: Iterable[Any], *, axis: int = 0) -> Expr

Join expressions along an existing axis. All other dimensions must agree.

split

split(x: Any, sections: int | Iterable[int], *, axis: int = 0) -> tuple[Expr, ...]

Split x along axis, into sections equal parts or at the given boundaries.

Source code in src/scaly/ir/expr.py
def split(x: Any, sections: int | Iterable[int], *, axis: int = 0) -> tuple[Expr, ...]:
  """Split ``x`` along ``axis``, into ``sections`` equal parts or at the given boundaries."""
  x = as_expr(x)
  axis = axis if axis >= 0 else axis + len(x.shape)
  if axis < 0 or axis >= len(x.shape):
    raise ValueError(f"split axis {axis} out of bounds for shape {x.shape}")
  if isinstance(sections, int):
    if sections <= 0 or x.shape[axis] % sections != 0:
      raise ValueError(f"cannot split axis of length {x.shape[axis]} into {sections} equal sections")
    sizes = (x.shape[axis] // sections,) * sections
  else:
    sizes = tuple(int(s) for s in sections)
    if any(s < 0 for s in sizes):
      raise ValueError(f"split sizes {sizes} cannot contain negative entries")
    if sum(sizes) != x.shape[axis]:
      raise ValueError(f"split sizes {sizes} do not sum to axis length {x.shape[axis]}")
  ret: list[Expr] = []
  start = 0
  for size in sizes:
    index = [slice(None)] * len(x.shape)
    index[axis] = slice(start, start + size)
    ret.append(x[tuple(index)])
    start += size
  return tuple(ret)

vec

vec(x: Any) -> Expr

Flatten x to rank 1 in row-major order.

Source code in src/scaly/ir/expr.py
def vec(x: Any) -> Expr:
  """Flatten ``x`` to rank 1 in row-major order."""
  return as_expr(x).vec()

gather

gather(x: Any, indices: Any, *, axis: int | None = None, mode: str = 'promise_in_bounds', fill_value: float = 0.0) -> Expr

Read x at int64 indices, preserving the index shape.

With axis=None, indices address the flattened source. Otherwise, the chosen axis is replaced by the index shape, as with NumPy's take. fill returns fill_value outside the source. promise_in_bounds requires every index to lie within the source.

scatter

scatter(base: Any, indices: Any, values: Any, *, axis: int | None = None, combine: str = 'add', mode: str = 'promise_in_bounds') -> Expr

Update a tensor at flat int64 indices, resolving duplicates in index order.

combine is set, add, max or min. fill discards out-of-range updates. axis=None addresses the flattened base. With an axis, values have the shape a gather at the same indices would return.

segment_sum

segment_sum(values: Any, ids: Any, n: int) -> Expr

Sum flat values by segment id into a vector of length n.

Segment ids are int64 values in [0, n). Empty segments are zero, and repeated ids accumulate in input order.

segment_max

segment_max(values: Any, ids: Any, n: int, *, fill_value: float = -np.inf) -> Expr

Take the maximum in each segment, using fill_value for empty segments.

Out-of-range segment ids are discarded. NaN propagates within a segment. Derivatives split equally between tied values, including the initial fill.

take

take(x: Any, indices: Any, *, axis: int = -1, mode: str = 'fill', fill_value: float = 0.0) -> Expr

Read entries along axis, inserting the shape of the int64 indices.

Negative indices are out of range. Under fill they return fill_value.

dynamic_slice

dynamic_slice(x: Any, start: Any, size: int, *, axis: int = -1, mode: str = 'fill', fill_value: float = 0.0) -> Expr

Read a fixed-size interval with an int64 start that may vary at runtime.

Positions past either end of the selected axis return fill_value under fill, with the start left unchanged.

index_add

index_add(base: Any, indices: Any, values: Any, *, axis: int = -1, mode: str = 'fill') -> Expr

Add updates along axis, summing duplicate indices in input order.

index_set

index_set(base: Any, indices: Any, values: Any, *, axis: int = -1, mode: str = 'fill') -> Expr

Replace entries along axis. The last update to each entry wins.

atan2

atan2(y: Any, x: Any) -> Expr

Two-argument arctangent, elementwise: the angle of the point (x, y).

Source code in src/scaly/ir/expr.py
def atan2(y: Any, x: Any) -> Expr:
  """Two-argument arctangent, elementwise: the angle of the point ``(x, y)``."""
  return binary(ExprOp.ATAN2, *_operands(y, x))

minimum

minimum(x: Any, y: Any) -> Expr

Elementwise minimum, propagating NaN. Derivatives split equally at ties.

Source code in src/scaly/ir/expr.py
def minimum(x: Any, y: Any) -> Expr:
  """Elementwise minimum, propagating NaN. Derivatives split equally at ties."""
  return binary(ExprOp.MINIMUM, *_operands(x, y))

maximum

maximum(x: Any, y: Any) -> Expr

Elementwise maximum, propagating NaN. Derivatives split equally at ties.

Source code in src/scaly/ir/expr.py
def maximum(x: Any, y: Any) -> Expr:
  """Elementwise maximum, propagating NaN. Derivatives split equally at ties."""
  return binary(ExprOp.MAXIMUM, *_operands(x, y))

where

where(condition: Any, x: Any, y: Any) -> Expr

Choose elementwise: x where the bool condition holds, y elsewhere.

The three broadcast together and x and y share a dtype. The generated C computes both branches, so a branch may be NaN or infinite where it is not chosen, and the derivative ignores the branch not chosen too: where(x > 0, sqrt(x), 0) has derivative zero at x = 0, including when a Function or vmap computes the square root. The condition has no derivative. Custom derivative rules receive numerical seeds without condition flags and must return finite results on inactive entries. In reverse mode, a select inside a mapped Function also guards derivatives of the map's arguments. A selected zero tangent guards later partials, including across ordinary and mapped calls: sqrt(where(x > 0, x, 0)) has derivative zero at x = -1 in both modes. Dense matrix products drop the condition in reverse mode: for a 2-by-2 matrix of ones M, where(x > 0, sqrt(x) @ M, 0).sum() at x = [0, 0] gives a zero forward product with seed [1, 1], but gradient [NaN, NaN].

Source code in src/scaly/ir/expr.py
def where(condition: Any, x: Any, y: Any) -> Expr:
  """Choose elementwise: ``x`` where the bool ``condition`` holds, ``y`` elsewhere.

  The three broadcast together and ``x`` and ``y`` share a dtype. The generated C computes both
  branches, so a branch may be NaN or infinite where it is not chosen, and the derivative ignores
  the branch not chosen too: ``where(x > 0, sqrt(x), 0)`` has derivative zero at ``x = 0``,
  including when a ``Function`` or ``vmap`` computes the square root. The condition has no derivative.
  Custom derivative rules receive numerical seeds without condition flags and must return finite
  results on inactive entries. In reverse mode, a select inside a mapped ``Function`` also guards
  derivatives of the map's arguments. A selected zero tangent guards later partials, including across
  ordinary and mapped calls:
  ``sqrt(where(x > 0, x, 0))`` has derivative zero at ``x = -1`` in both modes.
  Dense matrix products drop the condition in reverse mode: for a 2-by-2 matrix of ones ``M``,
  ``where(x > 0, sqrt(x) @ M, 0).sum()`` at ``x = [0, 0]`` gives a zero forward product
  with seed ``[1, 1]``, but gradient ``[NaN, NaN]``.
  """
  condition = _condition(condition)
  x, y = _operands(x, y)
  dtype = elementwise_dtype(ExprOp.SELECT, condition.type.dtype, x.type.dtype, y.type.dtype)
  if condition.type.pattern is not None and x.type.pattern is None and y.type.pattern is None:
    condition = to_dense(condition)
  shape = broadcast_shape(condition.shape, broadcast_shape(x.shape, y.shape))
  type_ = TensorType(
    shape, dtype=dtype, diff=op_diff(ExprOp.SELECT, x, y), pattern=stored_pattern(ExprOp.SELECT, (condition.type, x.type, y.type), shape)
  )
  if type_.pattern is not None:
    chosen = _constant_condition(condition, stored_indices(type_), shape)
    if chosen is not None:
      condition = pack(Expr.const(chosen.reshape(type_.storage_shape), dtype=dtypes.bool_, lowering=condition.lowering), type_.pattern, shape=shape)
  return Expr(
    ExprOp.SELECT,
    (condition, x, y),
    type_,
    lowering=common_lowering(condition, x, y),
  )

cast

cast(x: Any, dtype: DType | str) -> Expr

Convert x elementwise to dtype, as NumPy's astype does.

A float becomes an integer by dropping its fraction, and a float outside the integer's range, NaN included, has no defined result. A cast to bool is x != 0, so NaN is true. A cast keeps its derivative only from one float dtype to another.

uint8 is not supported for expressions and raises ValueError.

Source code in src/scaly/ir/expr.py
def cast(x: Any, dtype: DType | str) -> Expr:
  """Convert ``x`` elementwise to ``dtype``, as NumPy's ``astype`` does.

  A float becomes an integer by dropping its fraction, and a float outside the integer's range,
  NaN included, has no defined result. A cast to ``bool`` is ``x != 0``, so NaN is true. A cast
  keeps its derivative only from one float dtype to another.

  ``uint8`` is not supported for expressions and raises ``ValueError``.
  """
  x, dtype = _condition(x) if isinstance(x, bool) else as_expr(x), as_dtype(dtype)
  if dtype == x.type.dtype:
    return x
  if dtype.is_bool:
    return not_equal(x, 0)
  diff = x.type.diff and x.type.dtype.is_floating and dtype.is_floating
  return Expr(ExprOp.CAST, (x,), TensorType(x.shape, dtype=dtype, diff=diff), lowering=x.lowering)

equal

equal(x: Any, y: Any) -> Expr

Elementwise x == y as a bool expression. The == operator compares expressions by identity.

Source code in src/scaly/ir/expr.py
def equal(x: Any, y: Any) -> Expr:
  """Elementwise ``x == y`` as a bool expression. The ``==`` operator compares expressions by identity."""
  return binary(ExprOp.EQ, *_operands(x, y))

not_equal

not_equal(x: Any, y: Any) -> Expr

Elementwise x != y as a bool expression, true wherever either side is NaN.

Source code in src/scaly/ir/expr.py
def not_equal(x: Any, y: Any) -> Expr:
  """Elementwise ``x != y`` as a bool expression, true wherever either side is NaN."""
  return binary(ExprOp.NE, *_operands(x, y))

isfinite

isfinite(x: Any) -> Expr

Elementwise test that a floating value is neither infinite nor NaN.

The generated C refuses to compile under -ffast-math, which assumes no value is either.

Source code in src/scaly/ir/expr.py
def isfinite(x: Any) -> Expr:
  """Elementwise test that a floating value is neither infinite nor NaN.

  The generated C refuses to compile under ``-ffast-math``, which assumes no value is either.
  """
  return unary(ExprOp.ISFINITE, as_expr(x))

print(fmt, *values)

Return the first of values, printing them all each time generated code computes it.

fmt has one {} for each value, and {{ and }} for literal braces. Each print ends its line. A float64 prints with 17 significant digits and a float32 with 9, enough to read each back exactly, an integer in full and a bool as True or False, and a tensor prints flat as [a, b, ...]. The print is part of the returned expression, so a function's outputs have to use it. A print they do not use raises ValueError when Scaly runs the function body to build its expression graph.

Parameters:

Name Type Description Default
fmt str

the text to print, with a {} placeholder for each value.

required
values Any

the expressions to print. The first is returned.

()

vmap

vmap(callee: Function[SI, NI, SO, NO] | ConcreteFunction[SI, NI, SO, NO], length: int) -> Function[SI, NI, SO, NO]

Map independent calls over a leading axis, preserving parameter and output trees.

Each output leaf has shape (length, *callee_shape). Use broadcast for a whole shared tensor and window for rank-1 overlapping slices of a declared width. Affine views are read from their base in place. Other views preserve their element order.

Sparse matrices

These builders convert between sparse and dense tensors. The sparse matrices guide shows them in use.

pack

pack(value: Any, pattern: CanonicalPattern | SparsityPattern, *, shape: tuple[int, ...] | None = None) -> Expr

Build a sparse tensor from its stored values, including explicit zeros.

A SparsityPattern takes the values in its own coordinate order, a canonical pattern in row-major order. Leading axes of value are batch axes. shape defaults to those axes followed by the pattern's shape.

Source code in src/scaly/ir/expr.py
def pack(value: Any, pattern: CanonicalPattern | SparsityPattern, *, shape: tuple[int, ...] | None = None) -> Expr:
  """Build a sparse tensor from its stored values, including explicit zeros.

  A ``SparsityPattern`` takes the values in its own coordinate order, a canonical pattern in
  row-major order. Leading axes of ``value`` are batch axes. ``shape`` defaults to those axes
  followed by the pattern's shape.
  """
  x = as_expr(value)
  if isinstance(pattern, SparsityPattern):
    pattern, permutation = pattern.canonicalize()
    if not x.shape or x.shape[-1] != len(permutation):
      raise ValueError("PACK values' last axis must match the pattern's stored entries")
    indices = np.arange(x.size, dtype=np.int64).reshape(x.shape)[..., permutation]
    x = gather(x, indices)
  if not isinstance(pattern, CanonicalPattern):
    raise TypeError("PACK requires a canonical pattern or SparsityPattern")
  shape = (*x.shape[:-1], *pattern.shape) if shape is None else shape
  type_ = TensorType(shape, x.type.dtype, x.type.diff, pattern)
  return Expr(ExprOp.PACK, (x,), type_, attrs={"pattern": pattern}, lowering=x.lowering)

values

values(value: Any) -> Expr

Return a sparse tensor's stored values, in row-major coordinate order, as a dense tensor.

The result has the storage shape. A dense tensor is returned unchanged.

Source code in src/scaly/ir/expr.py
def values(value: Any) -> Expr:
  """Return a sparse tensor's stored values, in row-major coordinate order, as a dense tensor.

  The result has the storage shape. A dense tensor is returned unchanged.
  """
  x = as_expr(value)
  if x.type.pattern is None:
    return x
  if x.op == ExprOp.PACK:
    return x.args[0]
  return Expr(ExprOp.VALUES, (x,), TensorType(x.type.storage_shape, x.type.dtype, x.type.diff), lowering=x.lowering)

to_dense

to_dense(value: Any) -> Expr

Return a dense tensor with zeros outside the pattern. A dense tensor is returned unchanged.

Source code in src/scaly/ir/expr.py
def to_dense(value: Any) -> Expr:
  """Return a dense tensor with zeros outside the pattern. A dense tensor is returned unchanged."""
  x = as_expr(value)
  if x.type.pattern is None:
    return x
  return _scatter_zero(values(x).vec(), stored_indices(x.type), x.size).reshape(x.shape)

from_dense

from_dense(value: Any, pattern: CanonicalPattern | SparsityPattern) -> Expr

Keep the entries of a dense tensor inside pattern as a sparse tensor and drop the others.

Source code in src/scaly/ir/expr.py
def from_dense(value: Any, pattern: CanonicalPattern | SparsityPattern) -> Expr:
  """Keep the entries of a dense tensor inside ``pattern`` as a sparse tensor and drop the others."""
  x = as_expr(value)
  if x.type.pattern is not None:
    raise ValueError("from_dense requires a dense tensor")
  canonical = pattern.canonicalize()[0] if isinstance(pattern, SparsityPattern) else pattern
  type_ = TensorType(x.shape, x.type.dtype, x.type.diff, canonical)
  return pack(gather(x, stored_indices(type_).reshape(type_.storage_shape)), canonical, shape=x.shape)

sparsify

sparsify(value: Any) -> Expr

Store only the entries that Expr.support says may be nonzero.

Batch axes share one pattern, covering the entries of every batch entry. A sparse tensor is returned unchanged.

Source code in src/scaly/ir/expr.py
def sparsify(value: Any) -> Expr:
  """Store only the entries that ``Expr.support`` says may be nonzero.

  Batch axes share one pattern, covering the entries of every batch entry. A sparse tensor is
  returned unchanged.
  """
  x = as_expr(value)
  if x.type.pattern is not None:
    return x
  if len(x.shape) < 2:
    raise ValueError("sparsify requires at least two axes")
  matrix_size = x.shape[-2] * x.shape[-1]
  entries = range(matrix_size) if x.support is None else sorted({i % matrix_size for i in x.support})
  return from_dense(
    x, CanonicalPattern((x.shape[-2], x.shape[-1]), tuple(i // x.shape[-1] for i in entries), tuple(i % x.shape[-1] for i in entries))
  )

PyTorch weights

load_torch_state_dict

load_torch_state_dict(path: str | Path) -> dict[str, np.ndarray]

Load a simple PyTorch state_dict zip checkpoint without importing torch.

Supports the modern torch.save(state_dict, ...) zip layout: data.pkl stores tensor metadata and data/<n> stores raw CPU storage bytes. This is intentionally narrow. Unsupported pickle globals fail loudly instead of being materialized.

Source code in src/scaly/utils/torch_state_dict.py
def load_torch_state_dict(path: str | Path) -> dict[str, np.ndarray]:
  """Load a simple PyTorch ``state_dict`` zip checkpoint without importing torch.

  Supports the modern ``torch.save(state_dict, ...)`` zip layout: ``data.pkl``
  stores tensor metadata and ``data/<n>`` stores raw CPU storage bytes. This is
  intentionally narrow. Unsupported pickle globals fail loudly instead of being
  materialized.
  """
  p = Path(path)
  storage_blobs: dict[str, bytes] = {}

  def rebuild_tensor(storage: tuple[Any, ...], storage_offset: int, size: tuple[int, ...], stride: tuple[int, ...], *args: Any) -> np.ndarray:
    del args
    if len(storage) < 5 or storage[0] != "storage":
      raise TypeError(f"unsupported torch storage persistent id: {storage!r}")
    dtype = _torch_dtype(storage[1])
    key, numel = str(storage[2]), int(storage[4])
    raw = storage_blobs[key]
    base = np.frombuffer(raw, dtype=dtype, count=numel)
    shape = tuple(int(x) for x in size)
    strides = tuple(int(x) * dtype.itemsize for x in stride)
    if not shape:
      return np.asarray(base[int(storage_offset)], dtype=dtype)
    return np.lib.stride_tricks.as_strided(base[int(storage_offset) :], shape=shape, strides=strides).copy()

  def rebuild_parameter(data: np.ndarray, *args: Any) -> np.ndarray:
    del args
    return data

  class Parameter:
    tensor: np.ndarray

    def __setstate__(self, state: tuple[np.ndarray, ...]) -> None:
      self.tensor = state[0]

  class TorchPickle(pickle.Unpickler):
    def find_class(self, module: str, name: str) -> Any:
      if module == "torch" and name in _TORCH_STORAGE_DTYPES:
        return _TORCH_STORAGE_DTYPES[name]
      if module == "torch" and name in _TORCH_DTYPES:
        return _TORCH_DTYPES[name]
      if module in {"torch._utils", "torch._tensor"} and name in {"_rebuild_tensor", "_rebuild_tensor_v2"}:
        return rebuild_tensor
      if module in {"torch._utils", "torch.nn.parameter"} and name in {"_rebuild_parameter", "_rebuild_parameter_with_state"}:
        return rebuild_parameter
      if module == "torch.nn.parameter" and name == "Parameter":
        return Parameter
      if module in {"collections", "numpy", "numpy.core.multiarray", "_codecs", "builtins"}:
        return super().find_class(module, name)
      raise pickle.UnpicklingError(f"unsupported global in torch checkpoint: {module}.{name}")

    def persistent_load(self, pid: Any) -> Any:
      return pid

  if not zipfile.is_zipfile(p):
    raise ValueError(f"unsupported legacy non-zip PyTorch checkpoint: {p}")
  with zipfile.ZipFile(p) as zf:
    names = zf.namelist()
    base = names[0].split("/", 1)[0]
    storage_blobs = {name.rsplit("/", 1)[-1]: zf.read(name) for name in names if name.startswith(f"{base}/data/")}
    with zf.open(f"{base}/data.pkl") as data:
      state = TorchPickle(data).load()
  if not isinstance(state, dict):
    raise TypeError(f"expected a state_dict in {p}, got {type(state).__name__}")
  return {k: v.tensor if isinstance(v, Parameter) else v for k, v in state.items()}

Verification and rewriting

verify_expr

verify_expr(root: Expr | Iterable[Expr], spec: 'Spec | None' = None) -> None

Topologically walk the DAG below root and raise on the first violation.

spec defaults to spec_expr, the full expression-dialect contract.

Source code in src/scaly/ir/expr_spec.py
def verify_expr(root: Expr | Iterable[Expr], spec: "Spec | None" = None) -> None:
  """Topologically walk the DAG below ``root`` and raise on the first violation.

  ``spec`` defaults to ``spec_expr``, the full expression-dialect contract.
  """
  if spec is None:
    spec = spec_expr
  outputs = (root,) if isinstance(root, Expr) else tuple(root)
  for node in topo(outputs):
    result = spec.check(node)
    if result is not None:
      rule, diag = result
      label = node.name or f"%{node.id}"
      raise VerifyError(f"verify_expr: node {label} op={ExprOp(node.op).value} failed rule {rule.description!r}: {diag}")

Spec

Bases: Generic[Node, Op]

A verifier: a set of Rules, indexed by op so checking a node touches only its own.

Rules with op=None apply to every node. Both dialects build their specs from this class.

Source code in src/scaly/ir/spec.py
class Spec(Generic[Node, Op]):
  """A verifier: a set of ``Rule``s, indexed by op so checking a node touches only its own.

  Rules with ``op=None`` apply to every node. Both dialects build their specs from this class.
  """

  def __init__(self, rules: Iterable[Rule[Node, Op]]) -> None:
    self.any: list[Rule[Node, Op]] = []
    self.by_op: dict[Op, list[Rule[Node, Op]]] = defaultdict(list)
    for rule in rules:
      if rule.op is None:
        self.any.append(rule)
      else:
        self.by_op[rule.op].append(rule)

  def candidates(self, op: Op) -> Iterable[Rule[Node, Op]]:
    yield from self.any
    yield from self.by_op.get(op, ())

  def check(self, node: Node) -> tuple[Rule[Node, Op], str] | None:
    for rule in self.candidates(getattr(node, "op")):
      diag = rule.check(node)
      if diag is not None:
        return rule, diag
    return None

  def merge(self, *others: Spec[Node, Op]) -> Spec[Node, Op]:
    rules = list(self.any)
    for op_rules in self.by_op.values():
      rules.extend(op_rules)
    for other in others:
      rules.extend(other.any)
      for op_rules in other.by_op.values():
        rules.extend(op_rules)
    return Spec(rules)

Rule dataclass

Bases: Generic[Node, Op]

One verifier check: check returns an error message for a bad node, or None.

The rule applies to nodes whose op is op, or to every node when op is None.

Source code in src/scaly/ir/spec.py
@dataclass(frozen=True, slots=True)
class Rule(Generic[Node, Op]):
  """One verifier check: ``check`` returns an error message for a bad node, or ``None``.

  The rule applies to nodes whose op is ``op``, or to every node when ``op`` is ``None``.
  """

  op: Op | None
  description: str
  check: Callable[[Node], str | None]

  def applies(self, node: Node) -> bool:
    return self.op is None or getattr(node, "op") == self.op

VerifyError

Bases: Exception

Raised by verify_expr and verify_program at the first node failing a rule.

The message names the node, its op and the rule it broke. Both dialects raise this one type.

Source code in src/scaly/ir/spec.py
class VerifyError(Exception):
  """Raised by ``verify_expr`` and ``verify_program`` at the first node failing a rule.

  The message names the node, its op and the rule it broke. Both dialects raise this one type.
  """

Pattern dataclass

One rewrite: nodes with op op (any op when None) that satisfy predicate become replacement(node).

Source code in src/scaly/ir/match.py
@dataclass(frozen=True, slots=True)
class Pattern[Node: _HasOpArgs]:
  """One rewrite: nodes with op ``op`` (any op when ``None``) that satisfy ``predicate`` become ``replacement(node)``."""

  op: Hashable | None
  predicate: Callable[[Node], bool]
  replacement: Callable[[Node], Node]

  def matches(self, node: Node) -> bool:
    return (self.op is None or node.op == self.op) and self.predicate(node)

PatternMatcher

An op-indexed set of rewrite Patterns.

Indexing by op means a node is only tested against patterns that could match it, which is what keeps rewriting cheap as the pattern set grows.

Source code in src/scaly/ir/match.py
class PatternMatcher[Node: _HasOpArgs]:
  """An op-indexed set of rewrite ``Pattern``s.

  Indexing by op means a node is only tested against patterns that could match it, which is what
  keeps rewriting cheap as the pattern set grows.
  """

  def __init__(self, patterns: Iterable[Pattern[Node]]):
    self.any: list[Pattern[Node]] = []
    self.by_op: dict[Hashable, list[Pattern[Node]]] = defaultdict(list)
    for pattern in patterns:
      if pattern.op is None:
        self.any.append(pattern)
      else:
        self.by_op[pattern.op].append(pattern)

  def candidates(self, op: Hashable) -> Iterable[Pattern[Node]]:
    yield from self.any
    yield from self.by_op.get(op, ())

  def rewrite(self, node: Node) -> Node | None:
    for pattern in self.candidates(node.op):
      if pattern.matches(node):
        ret = pattern.replacement(node)
        if ret is not node:
          return ret
    return None

rewrite

rewrite(root: Node, patterns: Iterable[Pattern[Node]] | PatternMatcher[Node], *, rebuild: Callable[[Node, tuple[Node, ...]], Node] | None = None, fixpoint: bool = True, revisit: bool = False, max_steps: int = 1000000, memo: dict[Node, Node] | None = None) -> Node

Apply patterns across a graph, bottom up, and return the rewritten root.

Iterative over an explicit stack, so depth in the graph never becomes depth on the Python stack. Each node is visited once with its already-rewritten children memoized by identity, so shared subgraphs stay shared. fixpoint retries the matcher on a node until nothing fires. revisit instead walks into a replacement's subgraph so nested rewrites collapse in one pass. max_steps bounds the total number of replacements. rebuild defaults to the expression adapter rebuild_expr. Program adapters may reuse memo across roots with identical patterns, rebuild, and options. Expression nodes do not support caller memoization because their lowering hints depend on traversal provenance.

Source code in src/scaly/ir/match.py
def rewrite[Node: _HasOpArgs](
  root: Node,
  patterns: Iterable[Pattern[Node]] | PatternMatcher[Node],
  *,
  rebuild: Callable[[Node, tuple[Node, ...]], Node] | None = None,
  fixpoint: bool = True,
  revisit: bool = False,
  max_steps: int = 1_000_000,
  memo: dict[Node, Node] | None = None,
) -> Node:
  """Apply ``patterns`` across a graph, bottom up, and return the rewritten root.

  Iterative over an explicit stack, so depth in the graph never becomes depth on the Python
  stack. Each node is visited once with its already-rewritten children memoized by identity, so
  shared subgraphs stay shared. ``fixpoint`` retries the matcher on a node until nothing fires.
  ``revisit`` instead walks into a replacement's subgraph so nested rewrites collapse in one
  pass. ``max_steps`` bounds the total number of replacements. ``rebuild`` defaults to the
  expression adapter ``rebuild_expr``. Program adapters may reuse ``memo`` across roots with
  identical patterns, rebuild, and options. Expression nodes do not support caller memoization
  because their lowering hints depend on traversal provenance.
  """
  if memo is not None and isinstance(root, Expr):
    raise ValueError("caller memoization is only supported for Program nodes")
  matcher = patterns if isinstance(patterns, PatternMatcher) else PatternMatcher(patterns)
  rebuild = rebuild or cast(Callable[[Node, tuple[Node, ...]], Node], rebuild_expr)
  done: dict[int, Node] = {}
  lowerings: dict[int, Lowering] = {}
  forward: dict[int, Node] = {}
  alive: list[Node] = []
  steps = 0
  stack: list[tuple[Node, bool]] = [(root, False)]
  while stack:
    node, ready = stack.pop()
    if id(node) in done:
      continue
    if memo is not None and node in memo:
      done[id(node)] = memo[node]
      continue
    if not ready:
      stack.append((node, True))
      stack.extend((a, False) for a in reversed(node.args) if id(a) not in done)
      continue
    if (target := forward.pop(id(node), None)) is not None:
      if id(target) not in done:
        raise RuntimeError(f"rewrite replacement at {node.op} contains the node it replaces")
      done[id(node)] = done[id(target)]
      if isinstance(node, Expr):
        lowerings[id(node)] = lowerings[id(target)]
      if memo is not None:
        memo[node] = done[id(node)]
      continue
    args = tuple(done[id(a)] for a in node.args)
    cur = node if all(a is b for a, b in zip(args, node.args, strict=True)) else rebuild(node, args)
    lowering = _combined_lowering(node.lowering, (lowerings[id(arg)] for arg in node.args)) if isinstance(node, Expr) else "auto"
    new = matcher.rewrite(cur)
    while new is not None:
      steps += 1
      if steps > max_steps:
        names = sorted({getattr(p.replacement, "__qualname__", repr(p.replacement)) for p in matcher.candidates(cur.op)})
        raise RuntimeError(f"rewrite exceeded {max_steps} steps at {cur.op} with patterns {names}")
      if isinstance(new, Expr):
        new = cast(Node, _apply_lowering(new, lowering, preserve_identity=id(new) in lowerings))
      alive.append(new)
      if revisit:
        forward[id(node)] = new
        stack.extend(((node, True), (new, False)))
        break
      cur = new
      new = matcher.rewrite(cur) if fixpoint else None
    else:
      done[id(node)] = cur
      if isinstance(node, Expr):
        lowerings[id(node)] = lowering
        lowerings[id(cur)] = lowering
      if memo is not None:
        memo[node] = cur
  return done[id(root)]

simplify

simplify(expr: Expr) -> Expr

Apply algebraic identities and constant folding until the graph stops changing.

Covers the shared arithmetic identities of passes/arith.py (neutral elements, zero annihilation, x - x, negation normalization, small constant powers), folding of all-constant subgraphs, identity reshape, transpose and gather, slice of slice, slice of stack, A.T @ v as v @ A (and v @ A.T as A @ v), and a matmul with an all-ones vector as sums. Also folds elementwise and matrix products with no possible nonzero terms, and sums, gathers, scatters, stacks and concatenations of known zeros.

Source code in src/scaly/passes/expr.py
def simplify(expr: Expr) -> Expr:
  """Apply algebraic identities and constant folding until the graph stops changing.

  Covers the shared arithmetic identities of ``passes/arith.py`` (neutral elements, zero
  annihilation, ``x - x``, negation normalization, small constant powers), folding of all-constant
  subgraphs, identity reshape, transpose and gather, slice of slice, slice of stack, ``A.T @ v`` as
  ``v @ A`` (and ``v @ A.T`` as ``A @ v``), and a matmul with an all-ones vector as sums.
  Also folds elementwise and matrix products with no possible nonzero terms, and sums,
  gathers, scatters, stacks and concatenations of known zeros.
  """
  return rewrite(expr, SIMPLIFY_PATTERNS, fixpoint=False, revisit=True, max_steps=100_000)

cse

cse(expr: Expr) -> Expr

Merge structurally equal subgraphs so each distinct computation appears once.

Source code in src/scaly/passes/expr.py
def cse(expr: Expr) -> Expr:
  """Merge structurally equal subgraphs so each distinct computation appears once."""
  return cse_many([expr])[0]

cse_many

cse_many(outputs: Iterable[Expr]) -> tuple[Expr, ...]

Common-subexpression elimination across several outputs at once.

Sharing is found between the outputs as well as inside each, which is the point of asking for several derivatives from one Function.factory call.

Source code in src/scaly/passes/expr.py
def cse_many(outputs: Iterable[Expr]) -> tuple[Expr, ...]:
  """Common-subexpression elimination across several outputs at once.

  Sharing is found *between* the outputs as well as inside each, which is the point of asking
  for several derivatives from one ``Function.factory`` call.
  """
  outputs = tuple(outputs)
  memo: dict[tuple[Any, ...], Expr] = {}
  replacements: dict[int, Expr] = {}
  for node in topo(outputs):
    cur = _replace_args(node, replacements)
    key = _structural_key(cur)
    cur = memo.setdefault(key, cur)
    replacements[node.id] = cur
  return tuple(replacements[out.id] for out in outputs)

Text

format_expr

format_expr(outputs: Expr | Iterable[Expr]) -> str

Render a graph as a topological dump with stable %0, %1, ... names.

For the stable assembly form meant for diffs and tests, use render_expr_assembly.

Source code in src/scaly/ir/expr.py
def format_expr(outputs: Expr | Iterable[Expr]) -> str:
  """Render a graph as a topological dump with stable ``%0``, ``%1``, ... names.

  For the stable assembly form meant for diffs and tests, use ``render_expr_assembly``.
  """
  outs = (outputs,) if isinstance(outputs, Expr) else tuple(outputs)
  nodes = topo(outs)
  loc = {e.id: i for i, e in enumerate(nodes)}
  lines: list[str] = []
  for i, e in enumerate(nodes):
    lhs = f"%{i}"
    if e.op == ExprOp.INPUT:
      rhs = f"input {e.name}"
    elif e.op == ExprOp.CONST:
      assert e.value is not None
      rhs = f"const {np.array2string(e.value, threshold=6)}"
    elif e.op == ExprOp.CALL:
      callee = e.attrs["callee"]
      rhs = f"call {callee.name}[{e.attrs['output']}]({', '.join(f'%{loc[a.id]}' for a in e.args)})"
    elif e.op == ExprOp.COND:
      branches = ", ".join(fn.name for fn in e.attrs["branches"])
      rhs = f"cond [{branches}][{e.attrs['output']}]({', '.join(f'%{loc[a.id]}' for a in e.args)})"
    elif e.op == ExprOp.LOOP:
      cond = e.attrs["cond"]
      n_carry = e.attrs["n_carry"]
      outers = e.args[n_carry : n_carry + len(e.attrs["starts"])]
      bindings = ", ".join(
        [f"%{loc[a.id]}" for a in e.args[:n_carry]]
        + [f"%{loc[a.id]}[{s}::{st}]" for a, s, st in zip(outers, e.attrs["starts"], e.attrs["strides"], strict=True)]
      )
      trip = f" trip=%{loc[e.args[-1].id]}" if len(e.args) > n_carry + len(outers) else ""
      head = f"loop[{e.attrs['length']}{', reverse' if e.attrs['reverse'] else ''}{', index' if e.attrs['index'] else ''}]"
      rhs = f"{head} {e.attrs['callee'].name}[{e.attrs['output']}]({bindings}){trip}" + (f" while {cond.name}" if cond is not None else "")
    else:
      rhs = f"{ExprOp(e.op).value}({', '.join(f'%{loc[a.id]}' for a in e.args)})"
    lines.append(f"{lhs} = {rhs} : {_type_text(e.type)}")
  lines.append("outputs " + ", ".join(f"%{loc[e.id]}" for e in outs))
  return "\n".join(lines)

render_expr_assembly

render_expr_assembly(obj: Function | ConcreteFunction | Expr | Iterable[Expr], *, name: str | None = None) -> str

Render the expression Expr dialect as a compact SSA assembly listing.

When given a ConcreteFunction, the listing is a expr.module containing every transitive expression callee body before the requested function. This mirrors what C rendering eventually needs.

Source code in src/scaly/ir/text.py
def render_expr_assembly(obj: Function | ConcreteFunction | Expr | Iterable[Expr], *, name: str | None = None) -> str:
  """Render the expression ``Expr`` dialect as a compact SSA assembly listing.

  When given a ``ConcreteFunction``, the listing is a ``expr.module`` containing every transitive expression
  callee body before the requested function. This mirrors what C rendering eventually needs.
  """
  if callable(instantiate := getattr(obj, "instantiate", None)):
    obj = instantiate()
  # The ConcreteFunction case is duck-typed on the surface ``_render_function_module`` actually uses:
  # ``ir`` is below ``function`` in the import-layer order, so it cannot import ``ConcreteFunction`` at runtime.
  # It is tested first so an object that is both a ConcreteFunction and iterable still renders as an
  # ``expr.module`` rather than a bare region.
  if all(hasattr(obj, attr) for attr in ("outputs", "name", "input_names", "output_names")):
    return _render_function_module(cast("ConcreteFunction", obj))
  if isinstance(obj, Expr):
    return _render_expr_region((obj,), name=name)
  if isinstance(obj, Iterable):
    return _render_expr_region(tuple(cast("Iterable[Expr]", obj)), name=name)
  raise TypeError(f"render_expr_assembly expects a ConcreteFunction, an Expr, or an iterable of Expr, got {type(obj).__name__}")

render_program_assembly

render_program_assembly(root: ProgramNode) -> str

Render Program IR as structured assembly with explicit control-flow constructs.

Source code in src/scaly/ir/text.py
def render_program_assembly(root: ProgramNode) -> str:
  """Render Program IR as structured assembly with explicit control-flow constructs."""
  lines: list[str] = []
  _render_program_node(root, 0, lines)
  return "\n".join(lines)