Skip to content

Commit bd218db

Browse files
authored
feat: advanced pytree support (#40)
1 parent 3298c33 commit bd218db

16 files changed

Lines changed: 627 additions & 117 deletions

File tree

d9d/core/pytree/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Recursive traversal of nested tensor structures ("pytrees")."""
2+
3+
from .ops import (
4+
PyTreeSpec,
5+
tree_flatten,
6+
tree_leaves,
7+
tree_leaves_with_path,
8+
tree_map,
9+
tree_map_only,
10+
tree_unflatten,
11+
)
12+
13+
__all__ = [
14+
"PyTreeSpec",
15+
"tree_flatten",
16+
"tree_leaves",
17+
"tree_leaves_with_path",
18+
"tree_map",
19+
"tree_map_only",
20+
"tree_unflatten",
21+
]

d9d/core/pytree/flatten.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import dataclasses
2+
from collections.abc import Callable
3+
from typing import Any
4+
5+
import optree
6+
import optree.dataclasses
7+
8+
_NAMESPACE = "d9d"
9+
10+
IsLeaf = Callable[[Any], bool]
11+
12+
13+
class PyTreeFlattener:
14+
"""Flattens pytrees into their leaves, descending into dataclasses as internal nodes."""
15+
16+
def __init__(self):
17+
"""Constructs a PyTreeFlattener."""
18+
self._registered: set[type] = set()
19+
self._is_dataclass_memo: dict[type, bool] = {}
20+
21+
def flatten(self, tree: Any, is_leaf: IsLeaf | None = None) -> tuple[list[Any], optree.PyTreeSpec]:
22+
"""Flattens ``tree``, lazily registering any dataclass types discovered as leaves.
23+
24+
Args:
25+
tree: The nested structure to flatten.
26+
is_leaf: Optional predicate; when it returns ``True`` for a node, that node is treated as
27+
a leaf and not traversed further (even if it is a dataclass or container).
28+
29+
Returns:
30+
A tuple of the leaf list and the ``optree`` structure specification.
31+
"""
32+
while True:
33+
leaves, treespec = optree.tree_flatten(tree, is_leaf=is_leaf, namespace=_NAMESPACE)
34+
if self._register_unknown_nodes(leaves, is_leaf):
35+
return leaves, treespec
36+
37+
def flatten_with_path(
38+
self, tree: Any, is_leaf: IsLeaf | None = None
39+
) -> tuple[list[tuple[Any, ...]], list[Any], optree.PyTreeSpec]:
40+
"""Flattens ``tree`` alongside the path to each leaf, lazily registering dataclass types.
41+
42+
Args:
43+
tree: The nested structure to flatten.
44+
is_leaf: Optional predicate; when it returns ``True`` for a node, that node is treated as
45+
a leaf and not traversed further (even if it is a dataclass or container).
46+
47+
Returns:
48+
A tuple of the per-leaf paths, the leaf list, and the ``optree`` structure specification.
49+
"""
50+
while True:
51+
paths, leaves, treespec = optree.tree_flatten_with_path(tree, is_leaf=is_leaf, namespace=_NAMESPACE)
52+
if self._register_unknown_nodes(leaves, is_leaf):
53+
return paths, leaves, treespec
54+
55+
def _register_unknown_nodes(self, leaves: list[Any], is_leaf: IsLeaf | None) -> bool:
56+
"""Registers any not-yet-registered dataclass types among ``leaves``.
57+
58+
Args:
59+
leaves: The leaves produced by a flatten pass.
60+
is_leaf: The predicate passed to the flatten call, if any.
61+
62+
Returns:
63+
``True`` if all leaves were already fully expanded (nothing to register), else ``False``.
64+
"""
65+
unregistered = {
66+
type(leaf)
67+
for leaf in leaves
68+
if type(leaf) not in self._registered
69+
and self._is_dataclass_type(type(leaf))
70+
and not (is_leaf is not None and is_leaf(leaf))
71+
}
72+
if not unregistered:
73+
return True
74+
75+
for cls in unregistered:
76+
self._register(cls)
77+
return False
78+
79+
def _is_dataclass_type(self, tp: type) -> bool:
80+
cached = self._is_dataclass_memo.get(tp)
81+
if cached is None:
82+
cached = dataclasses.is_dataclass(tp)
83+
self._is_dataclass_memo[tp] = cached
84+
return cached
85+
86+
def _register(self, cls: type) -> None:
87+
if cls in self._registered:
88+
return
89+
optree.dataclasses.register_node(cls, namespace=_NAMESPACE)
90+
self._registered.add(cls)

d9d/core/pytree/ops.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from collections.abc import Callable
2+
from typing import Any, TypeVar, cast
3+
4+
import optree
5+
6+
from d9d.core.types import PyTree
7+
8+
from .flatten import IsLeaf, PyTreeFlattener
9+
10+
TLeaf = TypeVar("TLeaf")
11+
TMapped = TypeVar("TMapped")
12+
TTree = TypeVar("TTree", bound=PyTree)
13+
14+
PyTreeSpec = optree.PyTreeSpec
15+
16+
17+
_flattener = PyTreeFlattener()
18+
19+
20+
def tree_flatten(tree: PyTree[TLeaf], is_leaf: IsLeaf | None = None) -> tuple[list[TLeaf], PyTreeSpec]:
21+
"""Flattens a pytree into its leaves and a structure specification.
22+
23+
Args:
24+
tree: The nested structure to flatten.
25+
is_leaf: Optional predicate; when it returns ``True`` for a node, that node is kept as a leaf
26+
and not traversed further.
27+
28+
Returns:
29+
A tuple of the leaf list and a ``PyTreeSpec`` that can rebuild the structure via
30+
`tree_unflatten`.
31+
"""
32+
return _flattener.flatten(tree, is_leaf)
33+
34+
35+
def tree_unflatten(treespec: PyTreeSpec, leaves: list[TLeaf]) -> PyTree[TLeaf]:
36+
"""Reconstructs a pytree from leaves and a structure specification.
37+
38+
Args:
39+
treespec: A specification produced by `tree_flatten`.
40+
leaves: The leaves to place into the structure, in flatten order.
41+
42+
Returns:
43+
The reconstructed nested structure.
44+
"""
45+
return optree.tree_unflatten(treespec, leaves)
46+
47+
48+
def tree_leaves(tree: PyTree[TLeaf], is_leaf: IsLeaf | None = None) -> list[TLeaf]:
49+
"""Returns the leaves of a pytree in deterministic (sorted-key) order.
50+
51+
Args:
52+
tree: The nested structure to flatten.
53+
is_leaf: Optional predicate; when it returns ``True`` for a node, that node is kept as a leaf
54+
and not traversed further.
55+
56+
Returns:
57+
The list of leaves.
58+
"""
59+
return _flattener.flatten(tree, is_leaf)[0]
60+
61+
62+
def tree_map(func: Callable[[TLeaf], TMapped], tree: PyTree[TLeaf]) -> PyTree[TMapped]:
63+
"""Applies ``func`` to every leaf of a pytree, returning a structurally-identical tree.
64+
65+
Args:
66+
func: The function to apply to each leaf.
67+
tree: The nested structure to map over.
68+
69+
Returns:
70+
A new tree with ``func`` applied to each leaf.
71+
"""
72+
leaves, treespec = _flattener.flatten(tree)
73+
return optree.tree_unflatten(treespec, [func(leaf) for leaf in leaves])
74+
75+
76+
def tree_map_only(
77+
filters: type | tuple[type, ...],
78+
func: Callable[[Any], Any],
79+
tree: TTree,
80+
) -> TTree:
81+
"""Applies ``func`` only to leaves that are instances of ``type_or_types``.
82+
83+
Leaves of any other type are returned unchanged. This is the common case for tensor
84+
operations over trees that also carry non-tensor bookkeeping (e.g. moving only tensors to a
85+
device while leaving strings and ints alone). The returned tree preserves the structure and
86+
leaf types of the input.
87+
88+
Args:
89+
filters: The leaf type(s) that ``func`` should be applied to.
90+
func: The function to apply to matching leaves.
91+
tree: The nested structure to map over.
92+
93+
Returns:
94+
A new tree with ``func`` applied to matching leaves only.
95+
"""
96+
leaves, treespec = _flattener.flatten(tree)
97+
mapped = [func(leaf) if isinstance(leaf, filters) else leaf for leaf in leaves]
98+
return cast(TTree, optree.tree_unflatten(treespec, mapped))
99+
100+
101+
def tree_leaves_with_path(tree: PyTree[TLeaf], is_leaf: IsLeaf | None = None) -> list[tuple[tuple[Any, ...], TLeaf]]:
102+
"""Returns ``(path, leaf)`` pairs for every leaf of a pytree.
103+
104+
Each path is a tuple of keys and indices reaching the leaf from the root: ``str`` for dict keys
105+
and dataclass field names, ``int`` for sequence indices.
106+
107+
Args:
108+
tree: The nested structure to flatten.
109+
is_leaf: Optional predicate; when it returns ``True`` for a node, that node is kept as a leaf
110+
and not traversed further.
111+
112+
Returns:
113+
A list of ``(path, leaf)`` tuples in deterministic (sorted-key) order.
114+
"""
115+
paths, leaves, _ = _flattener.flatten_with_path(cast(Any, tree), is_leaf)
116+
return list(zip(paths, leaves, strict=True))

d9d/internals/metric_collector/collector.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import torch
2-
import torch.utils._pytree as pytree # noqa: PLC2701
32
from torch.profiler import record_function
43

4+
from d9d.core import pytree
55
from d9d.core.dist_context import DistributedContext
66
from d9d.core.types import PyTree
77
from d9d.metric import Metric

d9d/loop/component/job_logger.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
from typing import Any
44

55
import torch
6-
import torch.utils._pytree as pytree # noqa: PLC2701
76
from torch.distributed.checkpoint.stateful import Stateful
87

8+
from d9d.core import pytree
99
from d9d.core.dist_context import DistributedContext
1010
from d9d.core.types import PyTree, ScalarTree
1111
from d9d.internals.metric_collector import AsyncMetricCollector
@@ -22,20 +22,7 @@ def _flatten_pytree_for_metrics(tree: PyTree[float]) -> dict[str, float]:
2222
flat_dict = {}
2323

2424
for path_tuple, value in pytree.tree_leaves_with_path(tree):
25-
path_segments = []
26-
27-
for key in path_tuple:
28-
match key:
29-
case pytree.MappingKey(k):
30-
path_segments.append(str(k))
31-
case pytree.SequenceKey(idx):
32-
path_segments.append(str(idx))
33-
case pytree.GetAttrKey(name):
34-
path_segments.append(name)
35-
case _:
36-
path_segments.append(str(key))
37-
38-
flat_key = "/".join(path_segments)
25+
flat_key = "/".join(str(segment) for segment in path_tuple)
3926
flat_dict[flat_key] = value
4027

4128
return flat_dict

d9d/loop/component/pipeline_state.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,14 @@
11
from collections.abc import Iterator
22
from contextlib import contextmanager
3-
from typing import Generic, TypeVar, cast
3+
from typing import Generic, TypeVar
44

55
import torch
6-
import torch.utils._pytree as pytree # noqa: PLC2701
76

7+
from d9d.core import pytree
88
from d9d.core.types import PyTree
99

1010
TState = TypeVar("TState", bound=PyTree)
1111

12-
TMap = TypeVar("TMap")
13-
14-
15-
def _detach_leaf(x: TMap) -> TMap:
16-
"""Detaches a tensor from the computation graph if the input is a tensor.
17-
18-
Args:
19-
x: The input object.
20-
21-
Returns:
22-
The detached tensor or original object.
23-
"""
24-
if isinstance(x, torch.Tensor):
25-
return cast(TMap, x.detach())
26-
return x
27-
2812

2913
class PipelineStateHandler(Generic[TState]):
3014
"""Holds the transient per-microbatch side-data of one step.
@@ -46,7 +30,7 @@ def store(self, microbatch_idx: int, state: TState):
4630
microbatch_idx: The index of the microbatch within the current pack.
4731
state: The side-data PyTree to store; every tensor leaf is detached.
4832
"""
49-
self._state[microbatch_idx] = pytree.tree_map(_detach_leaf, state)
33+
self._state[microbatch_idx] = pytree.tree_map_only(torch.Tensor, lambda x: x.detach(), state)
5034

5135
@contextmanager
5236
def scope(self, microbatch_idx: int) -> Iterator[TState]:
@@ -66,7 +50,7 @@ def scope(self, microbatch_idx: int) -> Iterator[TState]:
6650
try:
6751
yield state
6852
finally:
69-
self._state[microbatch_idx] = pytree.tree_map(_detach_leaf, state)
53+
self._state[microbatch_idx] = pytree.tree_map_only(torch.Tensor, lambda x: x.detach(), state)
7054

7155
def reset(self):
7256
"""Resets the underlying storage, clearing all state."""

d9d/loop/run/_device.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import torch
2-
import torch.utils._pytree as pytree # noqa: PLC2701
32

3+
from d9d.core import pytree
44
from d9d.core.types import MicrobatchPack
55

66

@@ -14,6 +14,4 @@ def move_pack_to_device(pack: MicrobatchPack, device: torch.types.Device) -> Mic
1414
Returns:
1515
A new pack with all tensors moved to the device.
1616
"""
17-
return [
18-
pytree.tree_map(lambda x: x.to(device) if isinstance(x, torch.Tensor) else x, microbatch) for microbatch in pack
19-
]
17+
return [pytree.tree_map_only(torch.Tensor, lambda x: x.to(device), microbatch) for microbatch in pack]

0 commit comments

Comments
 (0)