Inside SPy: OpSpec, MetaArg, and the blue/red dispatch model
SPy is a Python variant designed to be both easily interpreted and statically compiled to native code or WebAssembly. Its central trick is a two-color evaluation model: expressions whose value is known at compile time are blue, and expressions that must be evaluated at runtime are red. The process of eliminating all blue computation from the AST is called redshifting — the resulting tree is “less blue”, so the average color shifts toward the red end of the spectrum.
This post digs into three closely related concepts — OpSpec, MetaArg, and
@blue.metafunc — that together implement SPy’s operator dispatch system.
The Blue/Red Model in a Nutshell¶
When you write x + y in SPy, the compiler doesn’t just emit an add instruction. It
first runs a blue lookup to decide which add function to call based on the static
types of x and y. The lookup is discarded; only the resulting direct call survives
into the redshifted AST. This is how SPy gets zero-cost operator dispatch without
sacrificing Python-style expressiveness.
OpSpec — The Result of a Blue Operator Lookup¶
OpSpec is what a blue dispatch function returns to say: “here is the concrete runtime
function you should call for this operation.”
Internally, a W_OpSpec holds:
_w_func: aW_Func(the concrete implementation to call)_args_wam: an optional list ofW_MetaArg(the argument rewrite list)_w_const: an optional constant value (theconstvariant)
There are four variants:
| Variant | Condition | Meaning |
|---|---|---|
| null | _w_func is None, _w_const is None | No implementation found — will raise a type error |
| simple | _w_func set, _args_wam is None | Call _w_func with the caller’s arguments as-is |
| complex | Both _w_func and _args_wam set | Call _w_func with the explicit argument list |
| const | _w_const set | The result is already a known constant |
Simple vs Complex OpSpec: What the Arg List Does¶
The argument list is a compile-time argument rewrite rule. The key is in
typecheck_opspec:
if w_opspec.is_simple():
out_args_wam = in_args_wam # pass the caller's args through
else:
out_args_wam = w_opspec._args_wam # use the OpSpec's own arg listEach entry in the list is a W_MetaArg that is either:
red — “take this argument from the caller at call time” → encoded as
ArgSpec.Arg(i)blue — “this is a compile-time constant; bake it in” → encoded as
ArgSpec.Const(value, loc)
This makes OpSpec(func, [args...]) a form of partial application at blue time:
constants decided during dispatch are burned directly into the W_OpImpl, with no
runtime overhead.
MetaArg — A Statically-Typed Argument Wrapper¶
W_MetaArg is how values are passed around inside the blue evaluation machinery. Instead
of a bare W_Object, a MetaArg bundles:
color—"blue"(value known at compile time) or"red"(value only known at runtime)w_static_T— the static type, which is what the dispatch system operates on_w_val— the actual object (present for blue, absent for abstract red MetaArgs)loc— source location for error messagessym— the symbol associated with the value (for diagnostics)
In SPy user code, you construct a MetaArg explicitly when building a complex OpSpec:
@blue
def foo() -> OpSpec:
arg = MetaArg('blue', i32, 42) # type i32, value 42, known at compile time
return OpSpec(bar, [arg])Here 42 will be baked into the W_OpImpl as a ArgSpec.Const — it never flows through
the runtime call.
@blue.metafunc — The Idiomatic Dispatch Decorator¶
@blue.metafunc is syntactic sugar for writing type-based dispatch functions. The
decorated function receives its arguments already wrapped as MetaArg objects, and
returns an OpSpec selecting the implementation:
@blue.metafunc
def myprint(m_x):
if m_x.static_type == int:
def myprint_int(x: int) -> None:
print(x)
return OpSpec(myprint_int)
if m_x.static_type == str:
def myprint_str(x: str) -> None:
print(x)
return OpSpec(myprint_str)
raise TypeError("don't know how to print this")The function runs entirely at blue time. The raise TypeError becomes a
compile-time error — calling myprint with an unsupported type is a compilation
failure, not a runtime crash. After redshifting, only the chosen concrete function
remains in the AST.
This is the SPy equivalent of C++ template specialization, but written in ordinary Python
with if statements, and fully debuggable with breakpoint().
Putting It Together: OpSpec, W_ASTFunc, and ast.FuncDef¶
A natural question is: can you construct an OpSpec from a raw ast.FuncDef? The short
answer is no, not directly — but you’re only one step away.
OpSpec stores a W_Func internally. W_ASTFunc (which wraps an ast.FuncDef) is a
subclass of W_Func, so the chain is:
ast.FuncDef → W_ASTFunc → W_OpSpecA bare ast.FuncDef is just an AST node — it has no resolved types, no closure, no FQN.
The W_ASTFunc wrapper is what gives it all of that. In practice, by the time any
@blue function body executes, every def statement in scope has already been turned
into a W_ASTFunc by exec_stmt_FuncDef. So when you write:
@blue.metafunc
def myop(m_x):
def impl(x: i32) -> i32:
return x * 2
return OpSpec(impl) # impl is already W_ASTFunc hereThe inner def impl is evaluated during redshifting, producing a fully formed
W_ASTFunc that goes directly into the OpSpec. That’s the intended idiom.
Implicit Type Conversions: __convert_to__ and __convert_from__¶
The conversion system is a clean illustration of how the dispatch machinery extends to user-defined types. Consider:
@struct
class CursedStr:
value: str
@blue.metafunc
def __convert_to__(m_expT, m_gotT, m_x):
if m_expT.blueval == int:
def conv(x: CursedStr) -> int:
return int(x.value)
return OpSpec(conv, [m_x])
return OpSpec.NULL
@blue.metafunc
def __convert_from__(m_expT, m_gotT, m_x):
if m_gotT.blueval == int:
def conv(x: int) -> CursedStr:
return CursedStr(str(x))
return OpSpec(conv, [m_x])
return OpSpec.NULLWhy three arguments — m_expT, m_gotT, m_x?¶
Both hooks plug into the operator.CONVERT(expT, gotT, x) operator, which has a fixed
3-argument blue signature:
expT— the expected (destination) typegotT— the actual (source) typex— the value to convert
Both methods receive all three unchanged, because the lookup in convop.py forwards them
directly:
elif w_conv_to := w_gotT.lookup_func("__convert_to__"):
w_opspec = vm.fast_metacall(w_conv_to, [wam_expT, wam_gotT, wam_x])
elif w_conv_from := w_expT.lookup_func("__convert_from__"):
w_opspec = vm.fast_metacall(w_conv_from, [wam_expT, wam_gotT, wam_x])Why no m_self?¶
These are not instance methods. They are class-level metafuncs looked up on the type
object. Conversion is a purely static/type-level decision. The actual value to convert
is m_x, not self.
Which method is called?¶
The lookup order in get_opspec is:
Check the built-in multimethod table (numeric conversions)
Try
__convert_to__on the source type (gotT)Try
__convert_from__on the destination type (expT)
| Situation | Method called | Reasoning |
|---|---|---|
inc(s) — source is CursedStr, dest is int | __convert_to__ on CursedStr | CursedStr knows how to become int |
s2: CursedStr = 123 — source is int, dest is CursedStr | __convert_from__ on CursedStr | CursedStr knows how to be built from int |
Why pass [m_x] explicitly?¶
conv takes one argument, but CONVERT was called with three (expT, gotT, x).
Without the explicit arg list, a simple OpSpec(conv) would try to pass all three
arguments to conv and fail. [m_x] says: take only the value argument from the call
site. The m_expT and m_gotT are blue constants used only for the dispatch decision —
they are fully eliminated by redshifting.
When Does All This Happen? The Two-Pass Model¶
Operator dispatch and conversion resolution happen once — in the
ASTFrame/DopplerFrame AST walker — but this walker is entered twice in the
pipeline:
source → pyparse → parse → symtable ─┬─ [interp: ASTFrame]
└─ [redshift: DopplerFrame] → C → compilePass 1: Interpreter (ASTFrame, redshifting=False)¶
For every expression node, the interpreter:
Recursively evaluates operands into
W_MetaArgobjectsCalls the appropriate
OPERATOR(blue call → producesW_OpImpl)Executes the
W_OpImplimmediately to get the runtime valueOn return from
eval_expr, checks the declared type annotation (varname) → callsCONVERT_maybe→ executes any needed conversion immediately
Everything runs eagerly. The dispatch decisions are made on static types, even in interpreter mode.
Pass 2: Redshifter (DopplerFrame, redshifting=True)¶
DopplerFrame inherits from ASTFrame and reuses all the same eval_expr_*
methods. The dispatch logic is identical. The difference is what happens with the
results:
In
ASTFrame,eval_opimplexecutes theW_OpImpland returns the value.In
DopplerFrame,eval_opimplrecordsself.opimpl[op] = w_opimpland then callsshift_opimpl(...), which emits a newast.Callnode pointing directly to the resolved concrete function.
Similarly for conversions: instead of executing them, DopplerFrame.eval_expr injects
them as explicit ast.Call nodes in the redshifted AST.
So after redshifting, inc(s) (where s: CursedStr is implicitly converted to int)
becomes something like:
# redshifted:
print(inc(CursedStr_to_int(s))) # conversion is now an explicit direct callAll the blue dispatch logic is gone.
| Interpreter | Redshifter | |
|---|---|---|
| Dispatch resolution | call_OP + CONVERT_maybe | Same, inherited |
| After resolution | Execute W_OpImpl immediately | Serialize into ast.Call node |
| Conversions | Execute immediately | Inject as explicit ast.Call in new AST |
| Result | Runtime value | Redshifted AST with only direct calls |
Summary¶
SPy’s operator dispatch system is built around three interlocking concepts:
MetaArg— carries a value plus its static type and color through the blue evaluation machineryOpSpec— the return value of a blue dispatch function; specifies which concrete function to call and optionally rewrites the argument list to bake in compile-time constants@blue.metafunc— the idiomatic way to write type-based dispatch: receives arguments asMetaArgobjects, returns anOpSpec, runs entirely at compile time
Together they implement a dispatch model where all type-checking, overload resolution, and implicit conversion logic runs at compile time (blue time) and is completely absent from the compiled output — leaving only direct function calls in the redshifted AST.