Skip to content

fix[next]: collect closure vars via the compiler's scope analysis - #2866

Merged
havogt merged 2 commits into
GridTools:mainfrom
havogt:closure-var-fixes-2864-on-main
Sep 9, 2026
Merged

fix[next]: collect closure vars via the compiler's scope analysis#2866
havogt merged 2 commits into
GridTools:mainfrom
havogt:closure-var-fixes-2864-on-main

Conversation

@havogt

@havogt havogt commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Description

get_closure_vars_from_function used inspect.getclosurevars, which reads only the enclosing function's own code object. A generator expression, lambda or nested function compiles to its own code object, so a global referenced only inside one was never collected. Free variables are unaffected, since closing over one creates a cell on the enclosing code object.

Take the global names from the compiler's scope analysis instead: every scope is a child symtable.Function whose get_globals() is exactly the set of names that compile to LOAD_GLOBAL there, so locals shadowing a global and comprehension targets are excluded by construction. The source is analyzed under from __future__ import annotations so annotations contribute no names on any supported Python version.

The name analysis is cached per code object, because the toolchain collects the closure variables of a function many times over (every stage fingerprint does). Values are still looked up on every call.

No construct the frontend accepts today creates a nested scope, so existing programs are unaffected. Tuple comprehensions (#2833) reach it; the tests that need them stay with that PR.

Requirements

  • All fixes and/or new features come with corresponding tests.
  • Important design decisions have been documented in the appropriate ADR inside the docs/development/ADRs/ folder.

https://claude.ai/code/session_01Sr9xdMdZgG4wjLLAKbweYb

`get_closure_vars_from_function` used `inspect.getclosurevars`, which disassembles
the enclosing function's own code object and collects the names loaded by its
`LOAD_GLOBAL` instructions. A generator expression, lambda or nested function
compiles to a separate code object, so a global referenced only inside one is
recorded there and nowhere else, and was never collected. Free variables are
unaffected, because closing over one forces a cell whose name is recorded on the
enclosing code object as `co_freevars`.

Take the global names from the compiler's own scope analysis instead, via the
`symtable` module this file already imports. Every scope the compiler creates is a
child `symtable.Function` whose `get_globals()` is exactly the set of names that
compile to `LOAD_GLOBAL` there, so locals shadowing a global and comprehension
targets are excluded by construction. The source is analyzed under
`from __future__ import annotations` so annotations contribute no names on any
supported version. Free variables keep coming from `inspect.getclosurevars`.

No construct the frontend accepts today creates a nested scope, so this changes
nothing for existing programs; it closes the mechanism ahead of tuple
comprehensions (GridTools#2833), which reach it. Same source change as GridTools#2864, rebased onto
main; the tests that need tuple comprehensions stay with GridTools#2833.

Claude-Session: https://claude.ai/code/session_01Sr9xdMdZgG4wjLLAKbweYb
@havogt

havogt commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

fyi, here is a memoization addition, but not sure if it's worth taking it havogt#79

@egparedes egparedes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good to me. I would merge here first the memoization optimization and then merge this.

`get_closure_vars_from_function` re-read and re-analyzed the source on every
call, and the toolchain calls it many times per definition: every in-memory
stage cache fingerprints its input with `semantic_fingerprinter`, which
deconstructs each DSL definition function it meets, including those reached
through closure variables. For a chain of 16 operators that is 186 collections
at decoration and 65 more on the first call.

The global names are a property of the code object, so cache them per code
object in a `WeakKeyDictionary`; values are still looked up on every call, so a
rebound global is seen. Free variables are read from the closure cells directly
instead of through `inspect.getclosurevars`, which disassembled the function
only to reach them.

Per call this is 3 us instead of 284 us for a 5-line function and 94 us instead
of 12.6 ms for a 200-line one, against 92 us and 6.6 ms for the `getclosurevars`
scan on main.
@havogt
havogt merged commit 7bf4784 into GridTools:main Sep 9, 2026
24 checks passed
@havogt
havogt deleted the closure-var-fixes-2864-on-main branch September 9, 2026 13:14
havogt added a commit to havogt/gt4py that referenced this pull request Sep 9, 2026
…sions

GridTools#2866 fixed closure variable collection across nested scopes and landed the
collection-level tests in 'test_source_utils.py'. These are the parser- and
execution-level counterparts, which exercise the same fix through
'FieldOperatorParser' and through a compiled program.

'test_free_variables_still_resolve_through_the_parser' is the parser-level
counterpart of the identically-shaped collection-level test in
'test_source_utils.py'; the name differs so the tree holds no two same-named
tests.

Claude-Session: https://claude.ai/code/session_01VR1cyTQ4wysovMBAwPBAWh
havogt added a commit that referenced this pull request Sep 9, 2026
`get_closure_vars_from_function` calls `inspect.getclosurevars` only for
its `nonlocals`, but `getclosurevars` disassembles the whole function to
also find the global names, which since #2866 come from the cached
symbol-table analysis anyway. Read the free variables from `co_freevars`
and the closure cells directly, in `_free_variables_from_closure_cells`.
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.

2 participants