Skip to content

Accept function parameters and closure cells as arguments with compile=True#6388

Open
rostan-t wants to merge 2 commits into
NVIDIA:mainfrom
rostan-t:ndd-capture-params-and-closures
Open

Accept function parameters and closure cells as arguments with compile=True#6388
rostan-t wants to merge 2 commits into
NVIDIA:mainfrom
rostan-t:ndd-capture-params-and-closures

Conversation

@rostan-t

@rostan-t rostan-t commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Category:

New feature (non-breaking change which adds functionality)

Description:

#6382 detects constant variables to accept them as argument for transparent pipelining, as long as those variables are defined locally in a function. This PR adds support for parameters and closure cells.

The following can now be accepted:

def transform(jpegs, device, crop, mean, std):
    images = ndd.decoders.image(images, device=device)
    images = ndd.crop_mirror_normalize(images, mean=mean, std=std)
    return images


reader = ndd.readers.File(file_root=DATA_DIR)
for jpegs, _ in reader.next_epoch(batch=BATCH_SIZE):
    images = transform(
        jpegs,
        device="gpu",
        crop=(224, 224),
        mean=(0.485 * 255, 0.456 * 255, 0.406 * 255),
        std=(0.229 * 255, 0.224 * 255, 0.225 * 255),
    )

Things that still cannot be reliably captured include variables of mutable types, e.g. lists.

Additional information:

Affected modules and functionalities:

Transparent pipelining.

Key points relevant for the review:

  • Is there any low hanging fruit that we could capture additionally?
  • Do the choices made for closure cells make sense?
  • Is the test coverage correct?

Tests:

  • Existing tests apply:
    • test_compile.py
  • New tests added
    • Python tests
    • GTests
    • Benchmark
    • Other
  • N/A

Checklist

Documentation

  • Existing documentation applies
  • Documentation updated
    • Docstring
    • Doxygen
    • RST
    • Jupyter
    • Other
  • N/A

DALI team only

Requirements

  • Implements new requirements
  • Affects existing requirements
  • N/A

REQ IDs: N/A

JIRA TASK: DALI-4740

Comment thread dali/python/nvidia/dali/experimental/dynamic/_source_analysis.py Dismissed
@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR extends transparent pipelining to accept function parameters and closure cells as invariant arguments under compile=True, building on the constant-variable detection from #6382. It introduces a Binding datatype, replaces ModuleInfo.local_rhs with the more general binding(), and adds a tracing chain (_is_binding_invariant → _is_param_invariant → _is_arg_invariant) that walks the live call stack and CST to verify that what was passed to a parameter was itself an invariant expression.

  • _source_analysis.py: ModuleInfo.binding() now returns Binding(in_scope, rhs) for both local variables and parameters; the new _is_param_invariant/_is_arg_invariant methods resolve the caller's call expression and use inspect.Signature.bind (with CST nodes as argument values) to map the parameter back to its call-site expression, recursing through chained and closure cases. _matches_callee handles plain functions, bound methods, and functools.partial.
  • _compile.py: _matches now correctly handles equality comparisons that return numpy arrays or np.bool_ (rather than Python bool) by falling back to np.all(result).item().
  • test_compile_invariants.py: 165 new test cases covering parameters, closures, cross-file, methods, functools.partial, default values, and all expected-rejection paths.

Confidence Score: 5/5

Safe to merge; the changes are additive and conservative — misclassification falls back to eager mode rather than producing incorrect compiled output.

The tracing logic is carefully gated: _is_binding_invariant and _is_immutable_value form independent guards so no mutable value can be captured even if one gate is over-generous. The _compile.py fix correctly handles numpy bool equality. Test coverage is thorough across the common patterns. The one design note (O(depth) tracing for recursive functions) affects only an unusual usage pattern and only on the first-trace pass.

No files require special attention; _source_analysis.py is the complex core and warrants a careful read of _is_binding_invariant's three branches.

Important Files Changed

Filename Overview
dali/python/nvidia/dali/experimental/dynamic/_source_analysis.py Core change: ModuleInfo.binding() replaces local_rhs(), exposing parameter and closure-cell bindings; new _is_binding_invariant, _is_param_invariant, _is_arg_invariant, _live_owner_frame, _split_call_args, _matches_callee methods implement the full tracing chain.
dali/python/nvidia/dali/experimental/dynamic/_compile.py Bug fix in _matches: actual == expected can return a numpy array or numpy bool_ (not a Python bool) when compared against a numpy scalar; the np.all(result).item() fallback handles this correctly.
dali/test/python/experimental_mode/test_compile_invariants.py 165 new test cases covering parameters, closure cells, cross-file, method types, partial application, defaults, and all expected-rejection paths.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["classify(frame, inputs, kwargs)"] --> B["_Classifier.classify()"]
    B --> C{"call_at(frame)?"}
    C -- None --> D["return None (eager)"]
    C -- Call node --> E["_split_call_args(call)"]
    E -- has *args/**kwargs --> D
    E -- ok --> F["_capture_arg(node, value)"]
    F --> G{"is CompiledBatch?"}
    G -- yes --> H["return CompileRef"]
    G -- no --> I["is_invariant(node)"]
    I --> J{"node type?"}
    J -- literal/True/False/None --> K["True"]
    J -- Name --> L["_is_name_invariant"]
    J -- Attribute --> M["_is_dali_chain"]
    L --> O["ModuleInfo.binding"]
    O -- None --> P["False"]
    O -- Binding --> Q["_is_binding_invariant"]
    Q -- in_scope+rhs --> R["is_invariant(rhs)"]
    Q -- in_scope+param --> S["_is_param_invariant"]
    Q -- closure live owner --> T["_live_owner_frame"]
    T --> U["_Classifier(owner_frame)"]
    U --> S
    Q -- closure dead owner --> V["True (frozen cell)"]
    S --> W["resolve_callsite_frame"]
    W --> X["_is_arg_invariant"]
    X --> Y{"sig.bind"}
    Y -- omitted --> Z["default != empty"]
    Y -- provided --> AA["is_invariant(bound_arg)"]
    AA --> I
    L --> AB["_is_immutable_value(resolved)"]
Loading

Reviews (5): Last reviewed commit: "Add compile invariant tests for params a..." | Re-trigger Greptile

Comment thread dali/python/nvidia/dali/experimental/dynamic/_source_analysis.py Outdated
Comment thread dali/python/nvidia/dali/experimental/dynamic/_source_analysis.py
@rostan-t rostan-t force-pushed the ndd-capture-params-and-closures branch from 47d1d9f to c30da81 Compare June 8, 2026 14:01
@rostan-t

rostan-t commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

!build

Comment thread dali/python/nvidia/dali/experimental/dynamic/_source_analysis.py Outdated
if binding.in_scope:
classifier, frame = self, self.frame
elif frame := self._live_owner_frame(name_node.value):
classifier = _Classifier(self.module_info, frame)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Minor / Design note] self.module_info is reused here as the CST analysis for the owner's frame. This is correct because Python closures are always lexically nested in the same source file, so the owner frame's variable bindings are covered by the same ModuleInfo. Worth a brief comment clarifying this assumption for readers who might wonder why we don't do _get_module_info(frame.f_code.co_filename) here like _is_param_invariant does.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

What would you suggest?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe just:

          elif frame := self._live_owner_frame(name_node.value):
              # A live closure owner is a lexical parent, so it shares this module's CST metadata.
              classifier = _Classifier(self.module_info, frame)

The contrast with _is_param_invariant is that call arguments can come from a different caller module, while a live
closure owner is lexically enclosing this function, so self.module_info is the right one.

elif frame := self._live_owner_frame(name_node.value):
classifier = _Classifier(self.module_info, frame)
else:
return True # owner returned: frozen cell

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Minor] The comment # owner returned: frozen cell is accurate but doesn't tell the full story: returning True here only says the binding is stable (the cell can't be rebound once the owner has returned); the immutability of the value is still checked later in _is_name_invariant via _is_immutable_value(_static_eval(...)). This two-phase separation is the right design, but a one-liner like # cell is frozen; value immutability is checked by the caller would make the invariant clearer.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I don't think it's necessary to re-specify this for this specific case. Immutability is checked by the caller for all names, not just cells of closures with dead parents.

return ndd.resize(imgs, size=[width, height])

resize_partial = functools.partial(resize, width=64)
return resize_partial(images, height=128)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Test coverage] A few functools.partial and special parameter kinds are untested:

  1. Positional partial: functools.partial(fn, first_arg_value) — the first positional argument is pre-filled, not a keyword. _matches_callee and inspect.signature should handle this, but it is worth confirming with a test.

  2. Keyword-only parameters: def rotate(imgs, *, angle=60):angle is KEYWORD_ONLY. The path through _is_arg_invariant should work (it doesn't special-case KEYWORD_ONLY), but there is no test.

  3. Positional-only parameters: def rotate(imgs, angle, /):sig.bind(*pos_nodes, **kw_nodes) handles these positionally, but again no test.

Even a quick expect_captured=True and one expect_captured=False for each would guard against regressions.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  1. Partial functions with bound positional arguments are actually not supported because inspect.BoundArguments.apply_defaults doesn't restore positional arguments. However, I don't think that we should add a test for this case because it's a result of how BoundArguments handles arguments that we can accept because it's uncommon enough and there's an easy workaround.
  2. and 3. fixed by 10c82ab.

def test_import_name(images):
from math import pi

return ndd.rotate(images, angle=pi)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Low-hanging fruit / design question] test_import_name is currently rejected because a from math import pi statement produces an ImportAssignment (not a plain Assignment) in LibCST's scope analysis, and ModuleInfo.binding filters these out with type(assignment) is not Assignment.

Local imports of immutable scalars seem like a natural case users would write:

def transform(images):
    from math import pi
    return ndd.rotate(images, angle=pi)

pi is a float (immutable), there is only one assignment, and it is function-scoped. Accepting ImportAssignment alongside Assignment (still guarded by _is_immutable_value + the single-assignment check) would cover this case with minimal risk. Is this intentionally deferred, or just not considered?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is true but I would leave it as a follow-up because it's not in the scope for this PR.
I wouldn't call it a low-hanging fruit because it requires a whitelist of modules that we can accept. Accepting all imported variables of immutable types would not work as they can always be reassigned somewhere else.

@dali-automaton

Copy link
Copy Markdown
Collaborator

CI MESSAGE: [54075060]: BUILD STARTED

@rostan-t rostan-t force-pushed the ndd-capture-params-and-closures branch from c30da81 to 10c82ab Compare June 8, 2026 16:04
@dali-automaton

Copy link
Copy Markdown
Collaborator

CI MESSAGE: [54075060]: BUILD PASSED

@rostan-t

rostan-t commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

!build

@dali-automaton

Copy link
Copy Markdown
Collaborator

CI MESSAGE: [54208281]: BUILD STARTED

@rostan-t rostan-t force-pushed the ndd-capture-params-and-closures branch from 78f1eec to 68fb1ac Compare June 9, 2026 16:18
@rostan-t

rostan-t commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

!build

@dali-automaton

Copy link
Copy Markdown
Collaborator

CI MESSAGE: [54208755]: BUILD STARTED

@dali-automaton

Copy link
Copy Markdown
Collaborator

CI MESSAGE: [54208755]: BUILD PASSED

rostan-t added 2 commits June 12, 2026 10:53
…ible

Signed-off-by: Rostan Tabet <rtabet@nvidia.com>
Signed-off-by: Rostan Tabet <rtabet@nvidia.com>
@rostan-t rostan-t force-pushed the ndd-capture-params-and-closures branch from 68fb1ac to 1c116c7 Compare June 12, 2026 10:54
@rostan-t

Copy link
Copy Markdown
Collaborator Author

!build

@dali-automaton

Copy link
Copy Markdown
Collaborator

CI MESSAGE: [54548519]: BUILD STARTED

@dali-automaton

Copy link
Copy Markdown
Collaborator

CI MESSAGE: [54548519]: BUILD PASSED

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants