man advect/tutorials/staging
STAGING(1)User CommandsSTAGING(1)
────────────────────────────────────────────────────────────────────────────────────────

Staging and Serialization

Dynamic transforms follow every call through Python. When the same computation will run repeatedly with matching inputs, stage compiles that input signature, optimizes the graph once, and returns an immutable StagedProgram for repeated execution.

Stage once, call many times

import numpy as np

import advect as ad


def loss(x):
    return np.sum(np.sin(x) ** 2)


sample = np.linspace(-0.5, 0.5, 8)
program = ad.stage(loss, sample)

print(f"sample loss: {program(sample):.6f}")
print(f"shifted loss: {program(sample + 0.1):.6f}")

The example fixes the positional pytree, shape, dtype, device, and Python scalar category. Calls that change that contract fail instead of compiling a hidden second program. The original Python function is not rerun during warm calls. The optimizer merges repeated computations, so two outputs of one call may share storage even where eager NumPy would return separate arrays; copy an output before writing to it in place if another output must stay unchanged.

Declare a signature without example data

Use ArraySpec when no representative value is available. The kw_specs argument declares keyword inputs, and StaticSpec snapshots a compile-time Python value. Static values can control Python branches because they are known while staging:

@ad.stage(
    specs=(ad.ArraySpec((4,), "float64"),),
    kw_specs={
        "scale": ad.ArraySpec((), "float64"),
        "center": ad.StaticSpec(True),
    },
)
def transform(x, *, scale, center):
    if center:
        x = x - np.mean(x)
    return scale * x


values = np.array([1.0, 2.0, 4.0, 5.0])
result = transform(
    values,
    scale=np.asarray(2.0),
    center=True,
)
print("staged transform:", result)

The static value is part of the signature: calling this program with center=False is a contract mismatch. Data-dependent Python branches remain dynamic-only because an abstract staged value has no data to test.

Dtypes are known while staging, so they can select a branch too. Inside a staged function, x.dtype is the dtype object that your array provider's arrays report, and a test such as x.dtype == np.float32 takes the branch that eager and dynamic code take. The same holds for the dtypes of derived values and, with xp = x.__array_namespace__(), for xp.float32, xp.result_type, and xp.finfo(x).dtype. Staging from examples presents their provider's dtype objects, while specs= alone stages against NumPy, so pass examples to see another provider's dtypes. A program derived from a staged or restored program, such as grad(program) or vjp_program(program), records no provider, so a custom primitive rule that its derivation runs sees NumPy dtypes. Staged values have bool or one of the numeric dtypes int8 to int64, uint8 to uint64, float16 to float64, complex64, and complex128; staging rejects any other dtype, such as object, bytes, or float128, with a TypeError that names it.

Differentiate the program once

grad and value_and_grad accept a staged program and return another staged program. vjp_program adds an explicit cotangent input for a reusable pullback:

value_and_gradient = ad.value_and_grad(program)
value, gradient = value_and_gradient(sample)

field_program = ad.stage(np.sin, sample)
pullback_program = ad.vjp_program(field_program)
cotangent = np.linspace(1.0, 2.0, sample.size)
input_cotangent = pullback_program(sample, cotangent=cotangent)

np.testing.assert_allclose(input_cotangent, cotangent * np.cos(sample))
print(f"staged loss: {value:.6f}")
print("staged gradient:", gradient)
print("reusable pullback:", input_cotangent)

Warm derivative calls execute their prebuilt graphs. They do not create a dynamic tape or run a reverse sweep. This is the reusable counterpart to the one-shot pullback returned by dynamic vjp.

Save and restore the program

import json

payload = json.dumps(value_and_gradient.to_dict(), sort_keys=True)
restored = ad.StagedProgram.from_dict(json.loads(payload))
restored_value, restored_gradient = restored(sample)

np.testing.assert_allclose(restored_gradient, gradient)
print(f"restored loss: {restored_value:.6f}")
print("serialized bytes:", len(payload.encode()))

The artifact contains the graph and its exact call contract, not Python code. Captured arrays and static values are snapshotted at compile time. A captured array that the program returns is read-only on NumPy and a fresh copy on providers without read-only arrays, so writing to it cannot change later calls. A custom primitive referenced by the graph must be imported or registered under the same stable name before loading, with an implementation that matches the saved program.

Provider-neutral functions written through x.__array_namespace__() can be staged against an explicit Array API revision and replayed by a compatible provider. NumPy-authored functions retain the separate NumPy frontend contract. Staged from another provider's examples, they lower to the same graph as from NumPy examples unless they branch on x.dtype, which is that provider's dtype object; a NumPy call that receives such a dtype object raises NumPy's TypeError while staging. Save and load a program with the same Advect version.

Advect 0.3.0 uses saved-program schema version 3. Programs saved with schema version 2 by earlier releases must be staged again from their original callable and signature, then saved with the new release. Changing the version number in the JSON is insufficient: version 3 changes the meaning of weak scalar metadata to preserve NumPy's scalar promotion rules. Schema versions describe the saved format and are independent of package release numbers.

$ █

[1:docs] [2:playground] [git:source] $ man advect/tutorials/staging