Skip to content

Commit 09ff550

Browse files
Frozen Functions Implementation
Added comment explaining traverse with `rw` arguments Added comment about c++ traversal Added recursion guards Simplified comment on TraverseCallback operator() Removed redundant profiling loop Fixed recursion_guard by wrapping it in namespace Fixed typing import Improved handling of nullptr variant/domains Removed warn log Added JitFlag FreezingTraverseScope Using try_cast in get_traversable_base Renamed to EnableObjectTraversal flag Added comment for traverse_py_cb_ro_impl Added rw value for traversal of trampolines Removed outdated function Excluding texture from traversal outside of frozen function Testing fix for windows compilation Fixing windows compilation bugs Added base value test for custom_type_ext traversal Added nested traversal test Exposing frozen function related flags to python Improved warning about non-drjit types Added option to specify backend if no input variable is specified Allow for changes in the last dimension of tensor shapes Marked payload and fn as used Reverted comment Formatting Removed printing of keys Fixed backend test Added method to detect recording frozen functions with the wrong backend Added comments for arguments of dr.freeze decorator Added flag test to DR_TRAVERSE_CB_RO/RW macros Removed test for `EnableObjectTraversal` flag in `DR_TRAVERSE_CB` macro Fixed tensor freezing indexing issue Removed deprecated logging code Added comment about tensor shape inference Using Wenzel's documentation, and renamed arguments Added comment regarding `frozen_function_tp_traverse` and `frozen_function_clear` Fixed typo Added warning text to documentation Fixed freezing drjit optimizers Added optimizer freezing tests Small refactor Suggestion from Wenzel
1 parent d735d3e commit 09ff550

27 files changed

Lines changed: 6154 additions & 68 deletions

drjit/__init__.py

Lines changed: 252 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@
1919
import sys as _sys
2020
if _sys.version_info < (3, 11):
2121
try:
22-
from typing_extensions import overload, Optional, Type, Tuple, List, Sequence, Union, Literal, Callable
22+
from typing_extensions import overload, Optional, Type, Tuple, List, Sequence, Union, Literal, Callable, TypeVar
2323
except ImportError:
2424
raise RuntimeError(
2525
"Dr.Jit requires the 'typing_extensions' package on Python <3.11")
2626
else:
27-
from typing import overload, Optional, Type, Tuple, List, Sequence, Union, Literal, Callable
27+
from typing import overload, Optional, Type, Tuple, List, Sequence, Union, Literal, Callable, TypeVar
2828

2929
from .ast import syntax, hint
3030
from .interop import wrap
@@ -2494,6 +2494,256 @@ def binary_search(start, end, pred):
24942494

24952495
return start
24962496

2497+
# Represents the frozen function passed to the decorator without arguments
2498+
F = TypeVar("F")
2499+
# Represents the frozen function passed to the decorator with arguments
2500+
F2 = TypeVar("F2")
2501+
2502+
@overload
2503+
def freeze(
2504+
f: None = None,
2505+
*,
2506+
state_fn: Optional[Callable],
2507+
limit: Optional[int] = None,
2508+
warn_after: int = 10,
2509+
backend: Optional[JitBackend] = None,
2510+
) -> Callable[[F], F]:
2511+
"""
2512+
Decorator to "freeze" functions, which improves efficiency by removing
2513+
repeated JIT tracing overheads.
2514+
2515+
In general, Dr.Jit traces computation and then compiles and launches kernels
2516+
containing this trace (see the section on :ref:`evaluation <eval>` for
2517+
details). While the compilation step can often be skipped via caching, the
2518+
tracing cost can still be significant especially when repeatedly evaluating
2519+
complex models, e.g., as part of an optimization loop.
2520+
2521+
The :py:func:`@dr.freeze <drjit.freeze>` decorator adresses this problem by
2522+
altogether removing the need to trace repeatedly. For example, consider the
2523+
following decorated function:
2524+
2525+
.. code-block:: python
2526+
2527+
@dr.freeze
2528+
def f(x, y, z):
2529+
return ... # Complicated code involving the arguments
2530+
2531+
Dr.Jit will trace the first call to the decorated function ``f()``, while
2532+
collecting additional information regarding the nature of the function's inputs
2533+
and regarding the CPU/GPU kernel launches representing the body of ``f()``.
2534+
2535+
If the function is subsequently called with *compatible* arguments (more on
2536+
this below), it will immediately launch the previously made CPU/GPU kernels
2537+
without re-tracing, which can substantially improve performance.
2538+
2539+
When :py:func:`@dr.freeze <drjit.freeze>` detects *incompatibilities* (e.g., ``x``
2540+
having a different type compared to the previous call), it will conservatively
2541+
re-trace the body and keep track of another potential input configuration.
2542+
2543+
Frozen functions support arbitrary :ref:`PyTrees <pytrees>` as function
2544+
arguments and return values.
2545+
2546+
The following may trigger re-tracing:
2547+
2548+
- Changes in the **type** of an argument or :ref:`PyTree <pytrees>` element.
2549+
- Changes in the **length** of a container (``list``, ``tuple``, ``dict``).
2550+
- Changes of **dictionary keys** or **field names** of dataclasses.
2551+
- Changes in the AD status (:py:`dr.grad_enabled() <drjit.grad_enabled>`) of a variable.
2552+
- Changes of (non-PyTree) **Python objects**, as detected by mismatching ``hash()``.
2553+
2554+
The following more technical conditions also trigger re-tracing:
2555+
- A Dr.Jit variable changes from/to a **scalar** configuration (size ``1``).
2556+
- The sets of variables of the same size change. In the example above, this
2557+
would be the case if ``len(x) == len(y)`` in one call, and ``len(x) != len(y)``
2558+
subsequently.
2559+
- When Dr.Jit variables reference external memory (e.g. mapped NumPy arrays), the
2560+
memory can be aligned or unaligned. A re-tracing step is needed when this
2561+
status changes.
2562+
2563+
These all correspond to situations where the generated kernel code may need to
2564+
change, and the system conservatively re-traces to ensure correctness.
2565+
2566+
Frozen functions support arguments with a different variable *width* (see
2567+
:py:func:`dr.with() <drjit.width>`) without re-tracing, as long as the sets of
2568+
variables of the same width stay consistent.
2569+
2570+
Some constructions are problematic and should be avoided in frozen functions.
2571+
2572+
- The function :py:func:`dr.width() <drjit.width>` returns an integer literal
2573+
that may be merged into the generated code. If the frozen function is later
2574+
rerun with differently-sized arguments, the executed kernels will still
2575+
reference the old size. One exception to this rule are constructions like
2576+
`dr.arange(UInt32, dr.width(a))`, where the result only implicitly depends on
2577+
the width value.
2578+
2579+
**Advanced features**. The :py:func:`@dr.freeze <drjit.freeze>` decorator takes
2580+
several optional parameters that are helpful in certain situations.
2581+
2582+
- **Warning when re-tracing happens too often**: Incompatible arguments trigger
2583+
re-tracing, which can mask issues where *accidentally* incompatible arguments
2584+
keep :py:func:`@dr.freeze <drjit.freeze>` from producing the expected
2585+
performance benefits.
2586+
2587+
In such situations, it can be helpful to warn and identify changing
2588+
parameters by name. This feature is enabled and set to ``10`` by default.
2589+
2590+
.. code-block:: pycon
2591+
2592+
>>> @dr.freeze(warn_after=1)
2593+
>>> def f(x):
2594+
... return x
2595+
...
2596+
>>> f(Int(1))
2597+
>>> f(Float(1))
2598+
The frozen function has been recorded 2 times, this indicates a problem
2599+
with how the frozen function is being called. For example, calling it
2600+
with changing python values such as an index. For more information about
2601+
which variables changed set the log level to ``LogLevel::Debug``.
2602+
2603+
- **Limiting memory usage**. Storing kernels for many possible input
2604+
configuration requires device memory, which can become problematic. Set the
2605+
``limit=`` parameter to enable a LRU cache. This is useful when calls to a
2606+
function are mostly compatible but require occasional re-tracing.
2607+
2608+
Args:
2609+
limit (Optional[int]): An optional integer specifying the maximum number of
2610+
stored configurations. Once this limit is reached, incompatible calls
2611+
requiring re-tracing will cause the last used configuration to be dropped.
2612+
2613+
warn_after (int): When the number of re-tracing steps exceeds this value,
2614+
Dr.Jit will generate a warning that explains which variables changed
2615+
between calls to the function.
2616+
2617+
state_fn (Optional[Callable]): This optional callable can specify additional
2618+
state to identifies the configuration. ``state_fn`` will be called with
2619+
the same arguments as that of the decorated function. It should return a
2620+
traversable object (e.g., a list or tuple) that is conceptually treated
2621+
as if it was another input of the function.
2622+
2623+
backend (Optional[JitBackend]): If no inputs are given when calling the
2624+
frozen function, the backend used has to be specified using this argument.
2625+
It must match the backend used for computation within the function.
2626+
"""
2627+
2628+
2629+
@overload
2630+
def freeze(
2631+
f: F,
2632+
*,
2633+
state_fn: Optional[Callable] = None,
2634+
limit: Optional[int] = None,
2635+
warn_after: int = 10,
2636+
backend: Optional[JitBackend] = None,
2637+
) -> F: ...
2638+
2639+
2640+
def freeze(
2641+
f: Optional[F] = None,
2642+
*,
2643+
state_fn: Optional[Callable] = None,
2644+
limit: Optional[int] = None,
2645+
warn_after: int = 10,
2646+
backend: Optional[JitBackend] = None,
2647+
) -> Union[F, Callable[[F2], F2]]:
2648+
limit = limit if limit is not None else -1
2649+
backend = backend if backend is not None else JitBackend.Invalid
2650+
2651+
def decorator(f):
2652+
"""
2653+
Internal decorator, returned in ``dr.freeze`` was used with arguments.
2654+
"""
2655+
import functools
2656+
import inspect
2657+
2658+
def inner(closure, *args, **kwargs):
2659+
"""
2660+
This inner function is the one that gets actually frozen. It receives
2661+
any additional state such as closures or state specified with the
2662+
``state`` lambda, and allows for traversal of it.
2663+
"""
2664+
return f(*args, **kwargs)
2665+
2666+
class FrozenFunction:
2667+
def __init__(self, f) -> None:
2668+
closure = inspect.getclosurevars(f)
2669+
self.closure = (closure.nonlocals, closure.globals)
2670+
self.frozen = detail.FrozenFunction(
2671+
inner,
2672+
limit,
2673+
warn_after,
2674+
backend,
2675+
)
2676+
2677+
def __call__(self, *args, **kwargs):
2678+
_state = state_fn(*args, **kwargs) if state_fn is not None else None
2679+
return self.frozen([self.closure, _state], *args, **kwargs)
2680+
2681+
@property
2682+
def n_recordings(self):
2683+
"""
2684+
Represents the number of times the function was recorded. This
2685+
includes occasions where it was recorded due to a dry-run failing.
2686+
It does not necessarily correspond to the number of recordings
2687+
currently cached see ``n_cached_recordings`` for that.
2688+
"""
2689+
return self.frozen.n_recordings
2690+
2691+
@property
2692+
def n_cached_recordings(self):
2693+
"""
2694+
Represents the number of recordings currently cached of the frozen
2695+
function. If a recording fails in dry-run mode, it will not create
2696+
a new recording, but replace the recording that was attemted to be
2697+
replayed. The number of recordings can also be limited with
2698+
the ``max_cache_size`` argument.
2699+
"""
2700+
return self.frozen.n_cached_recordings
2701+
2702+
def clear(self):
2703+
"""
2704+
Clears the recordings of the frozen function, and resets the
2705+
``n_recordings`` counter. The reference to the function is still
2706+
kept, and the frozen function can be called again to re-trace
2707+
new recordings.
2708+
"""
2709+
return self.frozen.clear()
2710+
2711+
def __get__(self, obj, type=None):
2712+
if obj is None:
2713+
return self
2714+
else:
2715+
return FrozenMethod(self.frozen, self.closure, obj)
2716+
2717+
class FrozenMethod(FrozenFunction):
2718+
"""
2719+
A FrozenMethod currying the object into the __call__ method.
2720+
2721+
If the ``freeze`` decorator is applied to a method of some class, it has
2722+
to call the internal frozen function with the ``self`` argument. To this
2723+
end we implement the ``__get__`` method of the frozen function, to
2724+
return a ``FrozenMethod``, which holds a reference to the object.
2725+
The ``__call__`` method of the ``FrozenMethod`` then supplies the object
2726+
in addition to the arguments to the internal function.
2727+
"""
2728+
def __init__(self, frozen, closure, obj) -> None:
2729+
self.obj = obj
2730+
self.frozen = frozen
2731+
self.closure = closure
2732+
2733+
def __call__(self, *args, **kwargs):
2734+
_state = state_fn(self.obj, *args, **kwargs) if state_fn is not None else None
2735+
return self.frozen([self.closure, _state], self.obj, *args, **kwargs)
2736+
2737+
return functools.wraps(f)(FrozenFunction(f))
2738+
2739+
if f is not None:
2740+
return decorator(f)
2741+
else:
2742+
return decorator
2743+
2744+
2745+
del F
2746+
del F2
24972747

24982748
def assert_true(
24992749
cond,

drjit/opt.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ class Optimizer(Generic[Extra], MutableMapping[str, dr.ArrayBase]):
126126
# - an arbitrary sequence of additional optimizer-dependent state values
127127
state: Dict[str, Tuple[dr.ArrayBase, Optional[LearningRate], Extra]]
128128

129+
DRJIT_STRUCT = {
130+
"lr": LearningRate,
131+
"state": dict,
132+
}
133+
129134
def __init__(
130135
self,
131136
lr: LearningRate,
@@ -960,10 +965,15 @@ def _step(
960965
# Compute the step size scale, which is a product of
961966
# - EMA debiasing factor
962967
# - Adaptive/parameter-specific scaling
968+
Float32 = dr.float32_array_t(dr.leaf_t(grad))
969+
Float64 = dr.float64_array_t(dr.leaf_t(grad))
970+
ema_factor = Float32(
971+
-dr.sqrt(1 - Float64(self.beta_2) ** t) / (1 - Float64(self.beta_1) ** t)
972+
)
963973
scale = cache.product(
964974
dr.leaf_t(grad), # Desired type
965975
lr,
966-
-dr.sqrt(1 - self.beta_2**t) / (1 - self.beta_1**t),
976+
ema_factor,
967977
)
968978

969979
# Optional: use maximum of second order term
@@ -981,9 +991,11 @@ def _step(
981991
def _reset(self, key: str, value: dr.ArrayBase, /) -> None:
982992
valarr = value.array
983993
tp = type(valarr)
994+
UInt = dr.uint32_array_t(dr.leaf_t(tp))
995+
t = UInt(0)
984996
m_t = dr.opaque(tp, 0, valarr.shape)
985997
v_t = dr.opaque(tp, 0, valarr.shape)
986-
self.state[key] = value, None, (0, m_t, v_t)
998+
self.state[key] = value, None, (t, m_t, v_t)
987999

9881000
# Blend between the old and new versions of the optimizer extra state
9891001
def _select(

0 commit comments

Comments
 (0)