Dynamic Shapes
Execute computation graphs with input-conditioned variable dimensions without recompilation.
Overview
Historically, ML compilers like XLA were designed around static shapes: every tensor dimension must be known at compile time, and passing an input with a different shape triggers JIT recompilation.
GoMLX supports input-conditioned dynamic shapes. This allows dimensions (such as batch size $N$, sequence length $L$, or spatial dimensions $H \times W$) to be symbolic (shapes.DynamicDim / -1). Graphs compiled with dynamic dimensions can process inputs of varying sizes at runtime without re-executing Go graph construction.
graph TD
subgraph Static ["Static JIT (XLA)"]
S1["Input [16, 128]"] --> C1["Compile Executable 1"]
S2["Input [32, 256]"] --> C2["Compile Executable 2 (High Latency)"]
end
subgraph Dynamic ["Dynamic Shapes (ONNX / Go)"]
D1["Input [?, ?]"] --> CD["Compile Dynamic Graph ONCE"]
CD --> E1["Execute [16, 128] (Zero Recompilation)"]
CD --> E2["Execute [32, 256] (Zero Recompilation)"]
endBackend Capabilities
Backends declare their dynamic shape capabilities via backend.Capabilities().DynamicShapes (of type compute.DynamicShapesSupport):
| Mode | Enum Value | Description | Typical Backends |
|---|---|---|---|
| Native | compute.DynamicShapesNative | True dynamic shapes. Compiles once; executes arbitrary dynamic dimensions with zero recompilation latency. | onnx (ONNX Runtime), go (Portable Go) |
| Recompiling | compute.DynamicShapesRecompiling | Backend-managed JIT specialization. Accepts symbolic graphs and shares constant model weights, but specializes/recompiles kernels internally per shape. | onnx:rocm (ONNX Runtime for ROCm), TensorRT, TorchInductor |
| None | compute.DynamicShapesNone | Static only. Backend requires concrete static shapes. GoMLX rebuilds and compiles a new *Executable per shape. | xla (PJRT CPU/CUDA/TPU) |
You can query if dynamic shapes are supported programmatically:
if backend.Capabilities().HasDynamicShapes() {
// Dynamic shapes available
}
Configuring Exec for Dynamic Shapes
When creating a graph.Exec or model.Exec, you specify which input axes are dynamic using WithDynamicAxes (or WithDynamicAxesSpecs):
// Graph building function taking two inputs: tokens [batch, seq] and seqLen [batch]
graphFn := func(tokens, seqLen *Node) *Node {
// ...
}
exec := graph.NewExec(backend, graphFn)
// Declare dynamic axes: "batch" and "seq" for tokens, "batch" for seqLen.
// Named axes with matching names are validated to have identical runtime dimension values.
exec.WithDynamicAxes(
[]string{"batch", "seq"}, // Input 0 (tokens)
[]string{"batch"}, // Input 1 (seqLen)
)
If an axis has an empty string "", it is an anonymous dynamic axis. If a slice entry is shorter than the input rank, trailing axes are assumed static.
Operations Supporting Dynamic Shapes
GoMLX provides a rich set of operations designed to handle dynamic tensors seamlessly:
1. Dimension Abstraction & Size Extraction
DimensionSpecFor(x, axis): Returns aDimensionSpecfor the given axis. If static, it returnsStaticDim(dim); if dynamic, it returnsNamedDynamicDim(name, DimensionSize(x, axis)).DimensionSpecsFor(x): Returns a slice ofDimensionSpecs for all axes ofx.DimensionSize(x, axis): Returns anInt32scalar*Noderepresenting the dimension size. Returns a constantScalaron static shapes, or queries the backend on dynamic shapes.
2. Reshaping
DynamicReshape(x, specs...): Reshapesxto targetDimensionSpecs (StaticDim,DynamicDim,NamedDynamicDim,InferredDim,NamedInferredDim). Automatically falls back to staticReshapeif all dimensions are static.DynamicReshapeLike(x, ref)/ReshapeLike(x, ref): Reshapesxto match the exact shape ofref(static or dynamic).Reshape(x, dims...)/ReshapeWithShape(x, shape): Standard reshape operations automatically detect dynamic operands and delegate to dynamic reshape internally.
3. Broadcasting
DynamicBroadcastInDim(x, broadcastAxes, specs...): Low-level broadcast specifying which source axes map to which targetDimensionSpecs.DynamicBroadcastLike(x, ref)/BroadcastLike(x, ref): Broadcastsxto match the shape of reference noderef(works for both static and dynamic).DynamicBroadcastToShape(x, targetShape)/BroadcastToShape(x, targetShape): Broadcastsxto a target static or dynamicShape.DynamicBroadcastToDims(x, specs...)/BroadcastPrefix(x, targetRank): Prefix-aligned broadcasting.
4. Sequence Generation & Padding
DynamicIota(g, dtype, iotaAxis, specs...): Generates sequence values[0, 1, 2, ...]alongiotaAxiswith dynamic dimensions.IotaLike(ref, iotaAxis): Generates sequence values matchingref’s static or dynamic shape.DynamicPad(x, fillVal, padSpecs...): Pads tensors with dynamic or static padding amounts.
5. Polymorphic Operations
Standard tensor operations work transparently with dynamic shapes without code changes:
ExpandAxes(x, axes...),InsertAxes(x, axes...),ExpandLeftToRank(x, rank)Squeeze(x, axes...)Slice(x, ranges...),Gather(x, indices)Concatenate(nodes, axis),Stack(axis, nodes...)Dot(a, b),Einsum(equation, operands...)Where(cond, a, b)TopK(x, k),TopKMask(x, k)
Writing Polymorphic Layers
Most layer libraries in GoMLX are written polymorphically: they work with both static and dynamic graphs without conditional branches:
func LayerNorm(x *Node, epsilon float64) *Node {
// DimensionSpecFor and DimensionSize work transparently on both static and dynamic inputs.
mean := ReduceMean(x, -1)
meanKeep := DynamicReshape(mean, append(DimensionSpecsFor(x)[:x.Rank()-1], StaticDim(1))...)
variance := ReduceMean(Square(Sub(x, meanKeep)), -1)
varianceKeep := DynamicReshape(variance, append(DimensionSpecsFor(x)[:x.Rank()-1], StaticDim(1))...)
return Div(Sub(x, meanKeep), Sqrt(AddScalar(varianceKeep, epsilon)))
}
Checking for Dynamic Shapes
If your algorithm requires specialized handling between static and dynamic graphs, you can inspect the shape at graph-building time:
if x.Shape().IsDynamic() {
// Dynamic shape execution path
} else {
// Static shape specialized path
}
When Dynamic Shapes Are Not Available: Bucketing & Padding
On static backends like XLA (compute.DynamicShapesNone), feeding tensors with arbitrary unconstrained shapes creates a new compiled binary for every unique shape:
- If sequence lengths vary from 1 to 512, there will be up to 512 separate JIT compilations, causing massive latency spikes and memory bloat.
Bucketing Strategy
To prevent compilation explosion on static backends, bucket inputs into a small set of discrete sizes and pad the remainder with zeros or padding tokens. Common bucketing strategies:
- Power-of-2 Bucketing: Bucket sequence lengths into $32, 64, 128, 256, 512, \dots$.
- Two-Bit Bucketing: Bucket sequence lengths into numbers that use only the 2-bits: $16, 24, 32, 48, \dots$, see
github.com/gomlx/compute/support.TwoBitBucketLen(). - Linear Bucketing: Round up to multiples of 32 or 64.
Sentence Tokenizer Bucketing
For text and NLP models using go-huggingface, use the tokenizers/bucket package:
import "github.com/gomlx/go-huggingface/tokenizers/bucket"
// Pack tokens into predefined bucket sizes with zero-padding
pack := bucket.Pack(tokenizedBatch, bucket.Config{
BucketSizes: []int{32, 64, 128, 256, 512},
PaddingID: tokenizer.PadTokenID(),
})
This restricts the total number of compiled executables to the number of configured buckets, providing near-optimal GPU utilization without compilation explosion.