Skip to content

Commit 58e48ce

Browse files
thomasahleclaude
andcommitted
compile_to_callable resolves Derivative nodes automatically
Users no longer call simplify/simplify_for_compile before compiling: step = compile_to_callable(loss, *[loss.grad(p) for p in params]) CompiledProgram detects Derivative nodes (simplify="auto") and applies the compile-simplify preset internally with ONE shared memo across all outputs — strictly better than per-output simplify_for_compile calls, since the loss and its gradients keep their common forward subtrees shared at the symbolic level. Pre-simplified inputs are untouched (behavior identical); simplify= True/False overrides. compile_simplify_args() exposes the shared preset. Suite: 782 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a9e9fe9 commit 58e48ce

3 files changed

Lines changed: 110 additions & 15 deletions

File tree

tensorgrad/compiler/runtime.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,52 @@
1717
import sympy
1818
import torch
1919

20-
from tensorgrad.tensor import Tensor, Variable
20+
from tensorgrad.tensor import Derivative, Tensor, Variable, compile_simplify_args
2121
from tensorgrad.compiler.lower import lower_program
2222
from tensorgrad.compiler.codegen_torch import TorchCodegen
2323

2424
# Max number of cached shape/dtype specializations per program (LRU-evicted).
2525
SPECIALIZATION_CACHE_SIZE = 32
2626

2727

28+
def _contains_derivative(tensors) -> bool:
29+
"""Iterative DAG walk (shared visited-set across all outputs)."""
30+
seen: set[int] = set()
31+
stack = list(tensors)
32+
while stack:
33+
t = stack.pop()
34+
if id(t) in seen:
35+
continue
36+
seen.add(id(t))
37+
if isinstance(t, Derivative):
38+
return True
39+
stack.extend(getattr(t, "factors", ()) or ())
40+
stack.extend(getattr(t, "terms", ()) or ())
41+
stack.extend(getattr(t, "inputs", ()) or ())
42+
if (inner := getattr(t, "tensor", None)) is not None:
43+
stack.append(inner)
44+
return False
45+
46+
2847
class CompiledProgram:
29-
def __init__(self, tensors: tuple[Tensor, ...], verbose: bool = False, torch_compile: bool = False):
48+
def __init__(
49+
self,
50+
tensors: tuple[Tensor, ...],
51+
verbose: bool = False,
52+
torch_compile: bool = False,
53+
simplify: bool | str = "auto",
54+
):
55+
# Derivative nodes can't be lowered; resolve them here so users can
56+
# write compile_to_callable(loss, *[loss.grad(p) for p in params])
57+
# directly. One shared args dict = one shared memo across ALL outputs,
58+
# which preserves cross-output subtree sharing (better than per-output
59+
# simplify_for_compile calls). "auto" simplifies only when a Derivative
60+
# is present, keeping behavior identical for pre-simplified inputs.
61+
if simplify == "auto":
62+
simplify = _contains_derivative(tensors)
63+
if simplify:
64+
shared_args = compile_simplify_args()
65+
tensors = tuple(t.simplify_for_compile(shared_args) for t in tensors)
3066
self.tensors = tensors
3167
self.verbose = verbose
3268
self.torch_compile = torch_compile
@@ -132,10 +168,22 @@ def __call__(self, values: dict, shapes: dict = None):
132168
return tuple(wrapped)
133169

134170

135-
def compile_to_callable(*tensors: Tensor, verbose: bool = False, torch_compile: bool = False):
171+
def compile_to_callable(
172+
*tensors: Tensor,
173+
verbose: bool = False,
174+
torch_compile: bool = False,
175+
simplify: bool | str = "auto",
176+
):
136177
"""Compile one or more tensorgrad tensors into a fast callable.
137178
179+
Derivative nodes are resolved automatically (see simplify_for_compile), so
180+
gradients can be passed raw:
181+
182+
step = compile_to_callable(loss, *[loss.grad(p) for p in params])
183+
138184
Returns f(values: dict[Variable, torch.Tensor], shapes: dict[Symbol, int])
139185
-> named torch.Tensor or tuple of them (one per input tensor).
140186
"""
141-
return CompiledProgram(tuple(tensors), verbose=verbose, torch_compile=torch_compile)
187+
return CompiledProgram(
188+
tuple(tensors), verbose=verbose, torch_compile=torch_compile, simplify=simplify
189+
)

tensorgrad/tensor.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@
1414
from tensorgrad.utils import _MatchEdgesKey
1515

1616

17+
def compile_simplify_args() -> dict[str, Any]:
18+
"""The simplify() argument preset used on the way into the compiler:
19+
resolve Derivative nodes, leave the algebra to the compiler's IR passes,
20+
memoize across shared subtrees. One dict instance = one shared memo, so
21+
passing the same dict to several simplify() calls preserves cross-output
22+
subtree sharing (used by compile_to_callable for loss + gradients)."""
23+
return {
24+
"grad_steps": float("inf"),
25+
"expand_functions": False,
26+
"combine_products": False,
27+
"sum_combine_terms": False,
28+
"factor_components": False,
29+
"memoize": True,
30+
}
31+
32+
1733
_lazy_rename = False
1834

1935
# Lazily imported tensorgrad.compiler.canon module (compositional structural
@@ -252,7 +268,7 @@ def full_simplify(self, expand=True) -> "Tensor":
252268
expr = new
253269
return expr
254270

255-
def simplify_for_compile(self) -> "Tensor":
271+
def simplify_for_compile(self, args: Optional[dict[str, Any]] = None) -> "Tensor":
256272
"""Eliminate Derivative nodes with minimal other rewriting, so the
257273
compiler's IR passes (factoring, stabilization) do the algebra instead.
258274
@@ -264,17 +280,15 @@ def simplify_for_compile(self) -> "Tensor":
264280
recover identical compact code at compile time — validated to match the
265281
full path's output while turning a >300s deep-model simplify into
266282
sub-second. Memoized by structural key across shared subtrees.
283+
284+
Note: `compile_to_callable` applies this automatically to any input
285+
containing Derivative nodes, sharing one memo across all outputs — so
286+
`compile_to_callable(loss, *[loss.grad(p) for p in params])` just
287+
works. Call this directly only when you want the simplified symbolic
288+
form itself. Pass `args` (from `compile_simplify_args()`) to share a
289+
memo across several calls.
267290
"""
268-
return self.simplify(
269-
{
270-
"grad_steps": float("inf"),
271-
"expand_functions": False,
272-
"combine_products": False,
273-
"sum_combine_terms": False,
274-
"factor_components": False,
275-
"memoize": True,
276-
}
277-
)
291+
return self.simplify(args if args is not None else compile_simplify_args())
278292

279293
@final
280294
def substitute(self, x: "Variable", y: "Tensor") -> "Tensor":

tests/test_simplify_memo.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,36 @@ def grad_nodes(depth):
144144
n2, n4, n8 = grad_nodes(2), grad_nodes(4), grad_nodes(8)
145145
# Grows linearly, not exponentially: doubling depth must not >4x the nodes.
146146
assert n4 < 4 * n2 and n8 < 4 * n4, f"superlinear: {n2}, {n4}, {n8}"
147+
148+
149+
def test_compile_to_callable_accepts_raw_derivatives():
150+
"""compile_to_callable resolves Derivative nodes internally (shared memo
151+
across outputs), so raw loss.grad(p) can be passed directly."""
152+
b, i, j, k = symbols("b i j k")
153+
x = Variable("x", b, i)
154+
W1 = Variable("W1", i, j)
155+
W2 = Variable("W2", j, k)
156+
y = Variable("y", b, k)
157+
loss = F.sum((F.relu(x @ W1) @ W2 - y) ** 2)
158+
params = [W1, W2]
159+
160+
# The killer-demo one-liner: raw Derivative nodes straight in.
161+
f_raw = compile_to_callable(loss, *[loss.grad(p) for p in params])
162+
# Explicit route for comparison.
163+
f_exp = compile_to_callable(loss.simplify_for_compile(),
164+
*[loss.grad(p).simplify_for_compile() for p in params])
165+
166+
dims = {b: 4, i: 5, j: 6, k: 3}
167+
torch.manual_seed(0)
168+
vals = {
169+
x: torch.randn(4, 5).refine_names("b", "i"),
170+
W1: torch.randn(5, 6).refine_names("i", "j"),
171+
W2: torch.randn(6, 3).refine_names("j", "k"),
172+
y: torch.randn(4, 3).refine_names("b", "k"),
173+
}
174+
outs_raw = f_raw(dict(vals), dims)
175+
outs_exp = f_exp(dict(vals), dims)
176+
for a, e in zip(outs_raw, outs_exp):
177+
torch.testing.assert_close(
178+
a.rename(None), e.align_to(*a.names).rename(None), rtol=1e-4, atol=1e-6
179+
)

0 commit comments

Comments
 (0)