Foundations of Pyrefly's Tensor Shape Types #4807
stroxler
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Introduction
We've rewired Pyrefly's tensor shape type system around composability. The new
system is not coupled to
torch, can attach shape information to classes thatproduce or operate on arrays without being arrays themselves, and could extend
to integer-valued properties beyond shapes. For example, the CuTe DSL treats
both shapes and strides as fundamental to a tensor layout. The system is also
built from relatively simple concepts that we think could eventually be
specified in a PEP.
This post covers:
arithmetic, shape representation, and our transformation DSL
Avik shared
at the PyCon Typing Summit in May 2026
Elements of the shape type system
Symbolic arithmetic
The basic building block of the shape type system is symbolic integer arithmetic.
An
IntVaris a type variable whose values are integers rather than types, andInt[N]is the type of an integer whose type-level value isN.For example, we can write a function that adds two dimensions:
If
ahas typeArray[[2, 3]], Pyrefly bindsNto2andMto3, so the return type isArray[[5]]. The same function also works whenone or both dimensions are still symbolic:
We can also capture integer values and use them in shapes:
The literal argument
3is captured asInt[3], soN * Copiesevaluates to30.Pyrefly normalizes symbolic expressions and knows that, for example:
N + M == M + NN + 0 == NN + N == 2 * NPyrefly can substitute known values and compare normalized expressions, but it
is not a general integer constraint solver and cannot solve arbitrary
comparisons like an SMT solver.
In these examples,
IntVaris a new kind of type variable, analogous toParamSpecandTypeVarTuple. It is allowed to appear in different places(inside
Int, which represents a symbolic integer, orIntTuple, whichrepresents a tuple of symbolic integers). It supports arithmetic which
TypeVardoes not; if and when we make a PEP, we'll need a new runtime classto enable this (similar to how
TypeVarTupleallows the*Tssyntax whereasTypeVardoes not).IntTuple and shapes
Symbolic arithmetic describes individual dimensions; to describe shapes, we
need to collect those dimensions.
IntTupleis a tuple of symbolic integers,commonly used to represent a shape. A fixed-rank form such as
IntTuple[M, int, 6]is equivalent totuple[Int[M], Int, Literal[6]]: asymbolic dimension becomes
Int[M], anunknown dimension becomes
Int, and a concrete dimension becomes a literal.Bare
IntTuplehas gradual rank. Unliketuple[Int, ...], it is graduallycompatible with fixed-rank
IntTupletypes.IntTupleis not tied to tensors, arrays, or any particular class. For example,we can write:
For readability, shape arguments have a compact list spelling, which is enabled
whenever a type parameter is bound by
IntTuple. These two types mean the samething:
The contents of an
IntTuple[...]are dimensions rather than ordinary Pythontypes, so symbolic expressions can be written directly as its elements:
The
*Elements[...]form for unpackingIntTupleIntTuplealso supports an unpacked shape variable.Elements[Shape]means"the dimensions contained in
Shape", much likeUnpackdoes for an ordinarytuple type. For example, a linear layer preserves every leading dimension and
replaces only the last one:
In this call, Pyrefly infers
BatchasIntTuple[2, 7],Inas3, andOutas5.Bare
IntTupleleaves both rank and dimensions unknown.Array[[int, 3]]retains rank two and the second dimension while leaving the first dimension
gradual.
Shape transformation DSL functions
Arithmetic and unpacking can express direct relationships between input and
output shapes, but some transforms require control flow. Dropping an axis,
validating a rank, or computing every output shape of a split are easier to
express as functions.
@type_shape_dsl_functionmarks a restricted Pythonfunction that Pyrefly can call from an annotation.
For example:
flatten_last_two_shape(Shape)runs after Pyrefly infersShape. Although itis not an
IntTuple,dsl.Invalid(...)is an allowed DSL sentinel in anyresult domain; returning it reports a type error at the call site.
The DSL supports local bindings, conditionals, integer arithmetic, indexing and
slicing, bounded generators, and helpers such as
len,range,zip,any,dsl.concat,dsl.sum, anddsl.prod. Its result domains areInt,IntTuple, andIntTuples(a tuple ofIntTuplevalues). Imprecise inputsproduce gradual results when evaluation cannot finish precisely.
The Flag bound
Output shapes often depend on ordinary control arguments: for example, a
keepdimargument determines whether a reduction removes an axis. AFlag[T]bound preserves such arguments as literals and lets the captured value flow
into a DSL call.
Tmay be composed fromint,bool,str,None, andtheir literal forms. This avoids a combinatorial set of overloads, reducing stub
maintenance and overload-resolution cost:
The default call binds
KeepdimtoLiteral[False]; the second binds it toLiteral[True]. AFlagtype variable directly annotates one functionparameter.
The argument does not have to be statically known. If the caller has only a
broad
bool, the call is still valid:Because the DSL branches on
keepdim, Pyrefly cannot choose a result shape andfalls back to a gradual one.
Flagcan also capture class parameters, which isuseful when an
nn.Moduleconstructor argument affects the shape returned byforward.The
IntTuplestype andMapIntTuplesconstructThe examples so far compute one output shape, but operations such as
splitand
chunkreturn multiple arrays with potentially different shapes.The
IntTuplestype represents a tuple of shapes. To connect those shapes toordinary generic types such as
TensororArray, we introduce a type-levelmap operator:
This maps each shape to
Array[Shape]. In a parameter position, it can reversethat mapping to infer
Shapes. This operator is restricted to cases where thesecond argument is assignable to
IntTuples(typically the second argument iseither a type variable or a DSL function call, as we'll describe below).
MapIntTuplesin a parameter positiontorch.catuses the parameter form:A call through the public
torchAPI has a symbolic result:From
(x, y), Pyrefly infersShapes = tuple[IntTuple[N, 3], IntTuple[M, 3]]. It then evaluatescat_shape(Shapes, 0), which checks the ranks and non-concatenated dimensionsand sums the concatenated dimensions.
MapIntTuplesin a return positionTensor.chunkuses the return form to mapTensorover the shapes computed byits DSL function:
For a concrete call, the mapped result is a fixed-length tuple with a distinct
shape type for each result:
Potential applications to the general type system
Two of these operators may also be useful outside shape typing:
Elementsfor splatting tuplesThe
Elementsoperator is a missing combinator in our current tuple-typealgebra. We can capture a
TypeVarTuple, for example:But
TypeVarTupleties the syntax for declaring variadic parameters to theability to unpack tuple contents. That prevents signatures which combine two
independently typed tuples.
With
Elements, we could write:I'm not sure how often this comes up, but
Elementssupplies the missingoperation without requiring both tuples to originate as
TypeVarTuples.Map, which could generalizeMapIntTuplesto arbitrarytuplesMapIntTuplesis currently restricted toIntTuples, a tuple ofIntTuplevalues. A general
Mapover tuples could replace fixed-length overload stackssuch as those used by
asyncio.gather, which stripsAwaitablefrom everytuple element. This would simplify stubs, remove their maximum-arity cliff, and
avoid expensive overload resolution. I've seen
asyncio.gathercalls inproduction with over 40 arguments; we are obviously not going to cover those
with overloads.
Some history for the curious: evolution of Pyrefly's shape type system
Since the May 2026 PyCon Typing Summit, Pyrefly's shape system has gone through
three versions as we tested it against array libraries and ML models at Meta.
V0 type system: the initial
torchexperimentAt the time of Avik's PyCon Typing Summit
talk,
Pyrefly tensor shape types were tied to
torch.Tensor.The shape was expressed as a
TypeVarTupleand we used a registration-based DSLwhere some functions would be analyzed twice: once using normal types, and then
again to evaluate the shape. We used normal
TypeVartype variables to representsymbolic integers, and we had a
Dim[...]type used to capture a symbolicinteger from a normal integer parameter.
For example, we might have had a function like
where
NandMhere are normal type variables.The DSL shape transforms were written in a Python-like language, but both the
transforms and their registration with
torchfunctions were baked into thePyrefly binary so at that point only a Pyrefly developer could reasonably have
worked on developing shape type library stubs.
V1 type system: entirely stub-driven
In an initial sprint to make Pyrefly tensor shapes more useful after PyCon,
we made a series of improvements to the original prototype:
torch.Tensorby introducing aShapedArraytype to handle most special behaviors, including shapetransformations.
@shape_dsl_functiondecorator to mark DSL transformationsso that they could live in stubs rather than the Pyrefly binary, and we
"registered" them using a
@uses_shape_dsldecorator.TypeVarTuples ofTypeVars withIntTuple, and introduced theIntVartype-variable kind for symbolicarithmetic. A distinct runtime class would be needed in any future PEP
because ordinary
TypeVars do not support expressions such asM + N.DimtoInt, which was mostly a matter of taste giventhat the variable
dimis actually usually not a size integer butrather than axis number in libraries like numpy, pytorch, and jax.
Pyrefly 1.2 shipped this design, including enough support to prototype NumPy
stubs. Its
cosine_similaritystub looked like this:V2 type system: where we are today
V1 exposed two problems:
hard to understand and unsound for some optional arguments, making the
implementation difficult to maintain or specify.
ShapedArray. That excludednon-array abstractions and layouts with multiple integer tuples, such as
CuTe shapes and strides.
PEP 827 introduces expression-like
type-level syntax for transformations from input types to output types. That
suggested the replacement: make each shape transform an explicit type-level
function call. The V2 DSL accepts a restricted
subset of Python and returns types that can be used directly in annotations.
The cosine similarity example described above now looks like this:
S1,S2, andDimare inferred at the call site; the return annotationpasses
broadcast(S1, S2)andDimto the DSL function.The result is a system whose core concepts are independent of
torchand canrepresent integer-valued properties on abstractions beyond arrays. There is
still work to do before these ideas are ready for standardization, but explicit
type-level functions give us a simpler foundation for experimenting with shape
typing and, potentially, other forms of integer-aware typing in Python.
Looking forward
We're still making some changes, for example adding an
Indextype so that we can eliminate somebaked-in logic for array indexing.
But we're fairly confident about the core components:
IntVarandIntfor symbolic arithmeticIntTupleandIntTuplesfor composing ints into shapesWe'd love to hear thoughts about this or ideas about what could be improved
All reactions