Skip to content

proto[next]: ambient binding of the offset provider - #71

Open
havogt wants to merge 14 commits into
mainfrom
ambient-offset-provider
Open

proto[next]: ambient binding of the offset provider#71
havogt wants to merge 14 commits into
mainfrom
ambient-offset-provider

Conversation

@havogt

@havogt havogt commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Prototype, not for upstream. Backs the "ambient values" stretch goal in
gt4py_knowledge#29 — the
document should link this branch rather than vendoring a mock, since the point
is that offset_provider= disappears from the real call path.

What it does

mesh = gtx.Namespace("mesh")

with gtx.bind(mesh, my_mesh):
    prog(a, out)          # no offset_provider=

A program called without offset_provider collects one from whatever is bound
to an ambient namespace: every Connectivity / Dimension reachable as a public
attribute of the bound object, keyed by attribute name. Colliding names across
namespaces raise rather than silently picking one mesh's table.

Binding is a contextvars.ContextVar, so it nests, unwinds, and is per-context —
which is what lets the same programs run against a second mesh in one process.
gt4py already carries _offset_provider this way in embedded/context.py; this
generalizes it to the compiled path and makes it user-facing.

Content hashing of frozen elements

Ambient values are static for the jitted programs that see them, so they can
identify by content rather than by id. gtx.freeze(conn) computes a content
hash once and caches it on the element; common.hash_offset_provider_items_by_id
prefers it over id(v) and falls back for anything unfrozen, so existing
behaviour is unchanged.

This fixes a real recompile: that function's own docstring warns it "could
generate different hashes for two offset providers that are semantically equal".
Measured with tmp/ambient_demo.py over 4 calls / 3 mesh objects, two of which
hold byte-identical tables:

compiled variants
id-keyed (before) 3
content-keyed frozen (after) 2

The O(size) cost is paid once at freeze time, never per call.

Findings worth keeping

  • readonly=True freezing breaks the gtfn bindings. Marking the buffer
    non-writeable is what would make the cached hash trustworthy, but the generated
    pybind11 signatures take mutable ndarray parameters and reject a read-only
    array outright (TypeError: run(): incompatible function arguments). So it is
    off by default and the hash is currently only as stable as the caller's
    discipline. Fixing the bindings to accept read-only arrays is the real
    prerequisite for immutable ambient values.
  • The unbound case fails badly: KeyError: "Offset 'V2E' not found in offset provider." from deep in lowering, naming neither the namespace nor the
    binding. Should become a proper DSLError.
  • The arguments.py:148 TODO (temporary pass still needs runtime offset-provider
    information) is why the key change is opt-in via freezing rather than a
    wholesale switch to type-based keying.

Scope

Binding half only. Referring to ambient fields by name inside an operator —
mesh.edge_length — is not implemented; that needs the hidden-parameter
mechanism (GTCallable.__gt_implicit_args__) from the read-only-field stretch
goal. Worth noting for that slice: type_translation.py already has
NamespaceProxy over FrozenNamespace | EnumMeta | ModuleType, so
container-attribute, bare-name and module-prefix access are the same existing
mechanism — what's missing there is fields as values and late binding, not the
access syntax.

Tests

tests/next_tests/unit_tests/test_ambient.py — 8 backend-free tests (binding,
nesting, collisions, freeze, cache-key collapse and separation, readonly).
Regression: test_arg_call_interface.py 297 passed; offset_provider=None still
resolves to {} when nothing is bound, so the default path is unchanged.

havogt added 3 commits August 5, 2026 18:12
Prototype for the 'ambient values' idea in the knowledge base: a program with
no 'offset_provider=' takes its connectivities from whatever is bound to an
ambient namespace at call time. Binding half only; ambient *fields* referenced
by name inside an operator are not implemented.
A frozen element carries a content hash computed once at freeze time, and
'hash_offset_provider_items_by_id' prefers it over 'id(v)'. Two semantically
equal meshes then share a compiled program instead of triggering a recompile.
Unfrozen elements keep the previous id-based behaviour.
…tors

Adds a second spelling next to the 'gtx.bind' context manager: a 'bind={ns: value}'
kwarg where 'offset_provider=' goes today, scoped to that one call. Both program
and field operator entry points now resolve the offset provider from the ambient
context when the caller passes none, so direct field-operator calls work too.

'__call__' becomes a thin wrapper around '_invoke' so the existing bodies are
untouched; 'ProgramWithBoundArgs' overrides '_invoke' accordingly.
@havogt

havogt commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Added 2473af83e: call-time bind= alongside the context manager, and ambient resolution for field operators, not just programs.

The first prototype only patched Program.__call__, so a direct field_operator call still required offset_provider=. Both entry points now resolve from the ambient context when the caller passes none. (For the record: the demo has always run on gtx.gtfn_cpu — compiled, not embedded; the variant counts come from the compiled-programs pool.)

Two spellings, same mechanism

with gtx.bind(mesh, m):     # scoped to a region
    prog(a, out)

prog(a, out, bind={mesh: m})    # scoped to one call, where offset_provider= goes today

bind= is sugar: it enters the same ContextVar bindings for the duration of the call and unwinds after, which is what makes the two composable rather than alternative.

All six combinations plus embedded:

program on run_gtfn_cpu:
  offset_provider= (today)     [1 3]
  gtx.bind context manager     [1 3]
  bind= call kwarg             [1 3]
field_operator on run_gtfn_cpu:
  offset_provider= (today)     [1 3]
  gtx.bind context manager     [1 3]
  bind= call kwarg             [1 3]
field_operator embedded:
  bind= call kwarg             [1 3]
cache keying (compiled program variants):
  2 variants for 5 calls / 3 meshes

Implementation note

__call__ is now a thin wrapper around _invoke, so the existing method bodies are untouched (no re-indentation, no behavioural diff). ProgramWithBoundArgs overrides _invoke instead of __call__ and delegates via super()._invoke(...).

Tests

test_ambient.py is up to 15 tests, including a parametrized embedded end-to-end matrix over {program, field_operator} × {offset_provider=, context manager, bind=}, and a check that bind= does not leak past the call.

Incidentally, writing those tests reproduced the very bug closure-variable-resolution exists to fix: gtx.neighbor_sum(...) inside a field operator fails to resolve as a module-prefixed builtin, and has to be imported bare.

havogt added 3 commits August 5, 2026 20:36
Two defects the backend matrix exposed, neither visible in a single-connectivity
demo or in embedded-only tests:

- 'offset_provider_of' built the mapping from 'dir()' (alphabetical) rather than
  the bound object's own '__dict__' (insertion order). gt4py's offset provider is
  order-sensitive, so a multi-connectivity mesh got wrong results on gtfn and a
  segfault elsewhere.
- 'freeze' hashed via 'np.asarray', which refuses device arrays; every GPU
  backend failed. Uses 'asnumpy()' now.

Adds a backend-matrix test covering {program, field_operator} x
{offset_provider=, context manager, bind=}.
A declaration carries the type, so an operator referring to an ambient value
types at decoration without the value being bound: 'from_value' dispatches on
'__gt_type__', so no type-system change is needed.

Execution is not wired up yet. The reference survives FOAST but dies at
'itir.Program' construction ('Symbols {SymbolRef(dx)} not found'), because eve
validates symbol refs before any remap can run. Both forms therefore need the
reference to become a real parameter first.
An operator can refer to 'dx = Static[float]' without it being a parameter, so
it need not be threaded through nested operators. Three pieces:

- embedded: a bound declaration behaves as the scalar it stands for.
- compiled: '_SubstituteAmbientValues' replaces the reference by its value
  before lowering, since 'ClosureVarFolding' runs at decoration when nothing is
  bound yet.
- caching: bound values enter the compiled-program key, and are mirrored onto
  the declaration so the lowering cache (which fingerprints closure variables)
  cannot serve one value's code for another.

'Extern[T]' currently folds like 'Static[T]'; making it a runtime argument needs
a synthesised program parameter, as does the ambient-field case.
@havogt

havogt commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Added 5b39d47ad + fbf5638c5: ambient values reachable by bare name, not just connectivities.

dx = gtx.Static[gtx.float64]        # declared once, never a parameter

@gtx.field_operator
def delta_x(f: IJFloatField) -> IJFloatField:
    """Forward difference in x."""
    return (1.0 / dx) * (f(IDim + 1) - f)

run_delta_x(f, out, bind={dx: 0.5})     # or: with gtx.bind(dx, 0.5):

dx never appears in a signature, so an operator nested two levels down sees it without anything being threaded through — there is a test for exactly that.

How it works

  • Declaration carries the type. Static[float] / Extern[float] implement __gt_type__, and type_translation.from_value already dispatches on that — so the operator type-checks at decoration with the value still unbound, and no type-system change was needed.
  • Embedded: a bound declaration behaves as the scalar it stands for (the operator body is plain Python there).
  • Compiled: _SubstituteAmbientValues replaces the reference with its value before lowering. ClosureVarFolding cannot do this — it runs at decoration, when nothing is bound.
  • Caching: bound values enter the compiled-program key and are mirrored onto the declaration object, because the lowering cache fingerprints closure variables. Without the mirror, dx=0.25 silently reused the code generated for dx=0.5 — the tests pin this.

Correction to the earlier plan

I said Extern would need hidden parameters while Static could ride the existing static-arg fold. That was wrong: the existing fold only works because static args are already parameters, so they are bound when itir.Program is constructed and only then replaced by literals. A free symbol does not survive construction at all — eve validates symbol refs in itir.Program.__init__.

Honest gap

Extern[T] currently behaves like Static[T] — it folds and therefore recompiles per value, rather than being passed as a runtime argument. Making it distinct needs a synthesised program parameter (FOAST params, PAST params, threading the value in as an argument), which is the same machinery ambient fields (mesh.edge_length) need. Documented in the module docstring rather than papered over.

Tests

tests/next_tests/integration_tests/feature_tests/ffront_tests/test_ambient_values.py — binding at call, two distinct values not sharing a compiled program, reaching a nested operator, the context-manager spelling, decoration-time typing, and the unbound error.

Verified on CPU backends only this round (47 passed ambient, 162 passed test_arg_call_interface.py); the full GPU/dace matrix has not been re-run since these commits.

havogt added 2 commits August 5, 2026 21:57
The lowering cache did not distinguish two bindings of the same declaration
because its fingerprinter walks closure variables and sees only the declaration
object. That was worked around by mirroring the bound value onto the
declaration's '__dict__', which made the binding process-wide.

Register a deconstructor for 'AmbientValue' with the frontend fingerprinter
instead: it reads the current binding from the ContextVar, so the cache
distinguishes values and bindings stay context-local.
Replaces the bespoke machinery with a parameter, added once when the program is
defined ('func_to_past'). From there ambient values travel the ordinary path:
type checking, lowering, 'static_params' and the compiled-program key all treat
them as arguments, and only the value is supplied per call.

The two forms now differ in one place only — whether the synthesised parameter
is listed as static:

- 'Extern[T]': ordinary runtime argument, one compiled program for all values.
- 'Static[T]': static argument, so the existing fold bakes it in and the
  existing key specialises on it — one program per distinct value.

Measured on gtfn and dace, 2 values: Static 2 variants, Extern 1, both correct.

Deletes '_SubstituteAmbientValues', 'current_static_key' and the ambient
fingerprint deconstructor: the operator's IR no longer holds the value, so the
lowering cache needs no help. A free symbol in a lowered operator resolves
against the program's parameters, so no threading into operator signatures is
needed.
@havogt

havogt commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

1a8052a5c: replaces the bespoke machinery with a synthesised program parameter, which turned out to delete more than it added.

What changed

A declaration becomes a program parameter once, when the program is defined (func_to_past). From there it travels the ordinary path — type checking, lowering, static_params, the compiled-program key all treat it as an argument, and only the value is supplied per call. The caller never names it.

The two forms then differ in exactly one place: whether that parameter is listed as a static one.

2 values → variants
Extern[T] ordinary runtime argument 1
Static[T] static argument: existing fold bakes it in, existing key specialises 2
  gtfn  Static  correct=True  variants for 2 values = 2
  gtfn  Extern  correct=True  variants for 2 values = 1
  dace  Static  correct=True  variants for 2 values = 2
  dace  Extern  correct=True  variants for 2 values = 1

Two findings that made it small

  • A free symbol in a lowered operator resolves against the program's parameters, and both gtfn and dace codegen fine that way. So no threading into operator signatures or call sites — the thing I expected to be the bulk of the work does not exist.
  • The set of ambient declarations is fixed at definition time, so the parameter is synthesised once rather than per call. transform_utils._get_closure_vars_recursively already collects them transitively through nested operators, so there is no per-operator inspection pass either.

Deleted

  • _SubstituteAmbientValues (the pre-lowering constant substitution)
  • ambient.current_static_key() and both compiled-program-key edits
  • the AmbientValue fingerprint deconstructor in stages.py

That last one is the satisfying part: the cache-staleness problem is gone rather than worked around, because the operator's IR no longer contains the value at all.

Known gap: canonical identifiers

Synthesised parameters currently take the closure variable's local name. Two operators referring to the same declaration under different local names, or two modules that both call theirs dx, will collide or mis-bind. The fix is to name the declaration explicitly — dx = Static[float]("dx") — and rename local references to the canonical name in a FOAST pass. Worth doing before this is used for anything real; a generated counter would not do, since the name lands in the compiled signature and would shift with import order.

Parameters are already sorted by name so the signature is deterministic.

Tests

test_ambient_values.py grows Extern cases and a test pinning the variant counts above (2 vs 1) — that is the only observable difference between the two forms, so it is worth asserting directly.

Verified on CPU backends: 80 ambient tests, plus 328 in test_arg_call_interface.py / test_ambient_binding.py since func_to_past and decorator are shared paths. GPU not re-run since these commits.

Connectivities and values were bound by two different rules: a 'Namespace'
harvested connectivities from a bound object by attribute *name*, while values
were keyed by declaration identity. Now there is one rule — the declaration is
the key:

    prog(a, out, bind={V2E: connectivity, dx: 0.5})

A 'FieldOffset' already names the offset and fixes its source and target, so it
is the declaration; the offset provider is assembled from the bound ones. This
retires 'Namespace', 'offset_provider_of' and the name-collision check it
needed, and lets a container supply the very offset an operator refers to rather
than one that merely shares its name.

The offset provider itself is unchanged: it is still assembled and passed as
today, so only the binding surface moves.
@gtx.field_operator
def delta_x(f: IJFloatField) -> IJFloatField:
"""Forward difference in x."""
return (1.0 / dx) * (f(IDim + 1) - f)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return (1.0 / dx) * (f(IDim + 1) - f)
return (1.0 / grid.dx) * (f(IDim + 1) - f)

where the __getattr__ does a contextvar.get()

havogt added 5 commits August 6, 2026 12:36
…ithmetic protocol

Declarations now live in a container and are read as 'grid.dx'. The declaration
is a descriptor: class access ('Grid.dx') yields the declaration, which is what
'bind=' takes as a key; instance access yields the bound value. Embedded
execution therefore sees a plain scalar, and the 12 arithmetic dunders that made
'AmbientValue' impersonate one are gone — they were also incomplete (no
comparisons, no '%', no numpy interop).

A container-qualified name ('Grid_dx') gives the synthesised parameter an
identifier that cannot collide across modules, which was the known gap.

Only declarations an operator actually *reads* become parameters: a container is
a place to declare things, and an operator that never reads 'grid.dx' must not
acquire it — for a 'Static[T]' that would specialise the compiled program on an
unused value.

The frontend needed two hooks: a container types itself as a namespace over its
own class (so 'grid.dx' resolves to the declared type with nothing bound), and
the lowering rewrites the attribute to a reference to the synthesised parameter.
Declarations are annotations in a container ('dx: Static[float]') and bind to a
'contextvars.ContextVar'. 'Static[T]'/'Extern[T]' are PEP 695 aliases over
'Annotated', so a type checker sees plain 'T' while the binding machinery reads
the marker — the bespoke 'AmbientValue' object is gone, and with it the
descriptor it needed.

'Grid.dx' (class access) is the variable, which 'bind=' takes as a key;
'grid.dx' (instance access) is its value. Binding is stdlib set/reset, so the
dict-in-a-ContextVar store disappears; a 'FieldOffset' carries its own variable
instead of a central registry.

The offset provider is now assembled from the offsets *this* program references
rather than from everything currently bound. That removes global state and fixes
a real defect: an unrelated bound mesh leaked into every program's offset
provider, where it also perturbed the compiled-program key and forced spurious
recompiles.
'Grid(dx=0.5, nu=1e-3)' carries values and binds every declaration it holds, so
the caller provides the grid (or the mesh) as one thing and each program picks
the parts it needs — rather than the caller tracking which program reads what.

The two uses of a container instance do not collide: one constructed with values
keeps them in its instance dict, so attribute access finds them directly; one
constructed empty has nothing there, so access falls through to '__getattr__' and
reads the bound variable. That is the instance an operator reads through.

'gtx.bind' now also takes containers, and an undeclared keyword is rejected at
construction rather than silently ignored.
Two containers with the same class name in different modules produced the same
synthesised parameter, and the result was silently wrong rather than an error:
both declarations collapsed onto one parameter and one binding won. A container
class name is not unique, so the parameter now carries a stable digest of the
fully qualified name, and the declaration keeps the qualified name for
diagnostics.

A second collision sat behind it: '_get_closure_vars_recursively' merges by
name, so two modules that both call their container 'grid' shadowed one another.
Every caller now uses one per-operator walk that looks at each operator's own
closure variables instead of a merged mapping.
Container identity ends up in the synthesised parameter name, which changes the
stage fingerprint and therefore the build-cache key. 'id()' would be unique but
would differ on every interpreter restart, so the cache would never hit; module
and qualified name are stable but not always unique.

Two containers built by the same factory share both and nothing stable
distinguishes them, so they are now rejected at definition rather than silently
sharing a parameter. 'class Grid(Container, name=...)' separates them when that
is intended.

The registry backing the check is weak, write-once at class definition, and never
consulted on the execution path — unlike the offset registry removed earlier,
which sat on the lookup path and leaked.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant