Skip to content

Commit 6126dff

Browse files
thisiscamclaude
andcommitted
def_deriv rules now honor effective_order via scan path
_deriv_prop_propagate (used by every primitive registered through def_deriv, currently just lax.erf_p) previously used lax.conv_general_dilated, which is a single fused XLA op with no runtime control flow — so a tracer-valued effective_order could not short-circuit any work. Switch path on the parameter: - effective_order is None: keep conv_general_dilated. Optimal for full-K computation, no overhead vs the prior implementation. - effective_order is not None: rewrite the same Faà di Bruno recurrence as a _jet_scan body, so iterations k > effective_order become no-ops via the existing lax.cond guard. def_deriv now also calls _register_eff_order_rule(prim) so the trace's process_primitive plumbs effective_order into the rule. No-op behaviorally for any caller that never passes effective_order. Tests: tests/test_effective_order.py covers every primitive in _rules_with_effective_order. For each, it asserts that jet(f, ..., effective_order=k)[:k] equals jet(f, ...)[:k] across multiple values of k, both eager and under jit. Plus a composition test (exp(sin(x))) and a full-K-equals-no-hint test for erf. 86 new tests, all passing. README: clarify the three-bucket taxonomy now that the def_deriv bucket is fully covered. Move erf into the aware list; list the remaining handful of unsupported primitives explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bc36df3 commit 6126dff

3 files changed

Lines changed: 219 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -238,20 +238,25 @@ combine with `jax.vmap`.
238238
applies elementwise along the order axis — `(a + b)[k] = a[k] + b[k]`
239239
— so it is already a single `O(K)` XLA op over the series array.
240240
There is no convolution loop to short-circuit, and `effective_order`
241-
has no effect. (Output entries above `effective_order` still hold
241+
has no effect. Output entries above `effective_order` still hold
242242
correct values for these primitives, because the linear op computes
243-
every entry uniformly.)
244-
- **Nonlinear without an `effective_order`-aware rule** (`sin`, `cos`,
245-
`sinh`, `cosh`, `square`, `sqrt`, `erf`, …): the rule computes the
243+
every entry uniformly.
244+
- **Nonlinear with an `effective_order`-aware rule.** Currently
245+
`exp`, `expm1`, `log`, `log1p`, `pow`, `logistic`, `tanh`, `erf_inv`,
246+
`div`, `sin`, `cos`, `sinh`, `cosh`, `mul`, `dot_general`,
247+
`conv_general_dilated`, `erf`, and any future `def_deriv`-registered
248+
primitive. The convolution scan short-circuits iterations beyond
249+
`effective_order`. Output entries above `effective_order` are
250+
unspecified — slice to `series_out[:effective_order]` before using
251+
them.
252+
- **Nonlinear without an `effective_order`-aware rule.** A few rules
253+
(`abs`, `max`, `min`, `atan2`, `integer_pow`, `cumprod`, `cummax`,
254+
`cummin`, the `reduce_*` family, `scatter-add`) currently compute the
246255
full `O(K²)` convolution regardless. `effective_order` is silently
247-
ignored on this path.
248-
- **Nonlinear with an `effective_order`-aware rule** (`exp`, `expm1`,
249-
`log`, `log1p`, `pow`, `logistic`, `tanh`, `erf_inv`, `div`): the
250-
convolution scan short-circuits iterations beyond `effective_order`.
251-
Output entries above `effective_order` are unspecified — slice to
252-
`series_out[:effective_order]` before using them.
256+
ignored on this path. These can be made aware with the same
257+
`_jet_scan` plumbing as the others — see the source for the pattern.
253258
- Because output entries above `effective_order` are unspecified for the
254-
third category, treat the whole `series_out[effective_order:]` slice as
259+
second category, treat the whole `series_out[effective_order:]` slice as
255260
garbage even if upstream linear ops would otherwise leave it intact.
256261
- The win is largest when the dominant cost is in the convolution-style
257262
loops that the hint short-circuits. For shallow programs the overhead of

‎jet_array/__init__.py‎

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -502,36 +502,66 @@ def def_deriv(prim, deriv):
502502
Define the jet rule for a primitive in terms of its first derivative.
503503
"""
504504
jet_rules[prim] = partial(deriv_prop, prim, deriv)
505+
_register_eff_order_rule(prim)
505506

506507

507-
def deriv_prop(prim, deriv, primals_in, series_in):
508+
def deriv_prop(prim, deriv, primals_in, series_in, _jet_effective_order=None):
508509
(x,) = primals_in
509510
(series,) = series_in
510511
primal_out = prim.bind(x)
511-
c0, cs = jet(deriv, primals_in, series_in)
512-
tail = _deriv_prop_propagate(c0, cs, x, series)
512+
c0, cs = jet(deriv, primals_in, series_in,
513+
effective_order=_jet_effective_order)
514+
tail = _deriv_prop_propagate(c0, cs, x, series,
515+
effective_order=_jet_effective_order)
513516
return primal_out, tail
514517

515518

516-
def _deriv_prop_propagate(c0, cs, x, series):
519+
def _deriv_prop_propagate(c0, cs, x, series, effective_order=None):
517520
c = _prepend_primal(c0, cs)
518521
u = _prepend_primal(x, series)
519-
u_scaled = jnp.arange(u.shape[0]) * u
520-
u_pad = jnp.pad(
521-
u_scaled,
522-
((len(c) - 1, 0),) + ((0, 0),) * (u.ndim - 1),
523-
mode="constant",
524-
constant_values=0,
525-
)
526-
full = lax.conv_general_dilated(
527-
u_pad[None, None, :],
528-
c[::-1][None, None, :],
529-
window_strides=(1,),
530-
padding="VALID",
531-
dimension_numbers=("NCW", "IOW", "NCW"),
532-
)[0, 0]
533-
v_tail = full[1 : len(u)] / jnp.arange(1, len(u))
534-
return v_tail
522+
523+
if effective_order is None:
524+
# Fast path: single fused conv, optimal for full-K computation.
525+
u_scaled = jnp.arange(u.shape[0]) * u
526+
u_pad = jnp.pad(
527+
u_scaled,
528+
((len(c) - 1, 0),) + ((0, 0),) * (u.ndim - 1),
529+
mode="constant",
530+
constant_values=0,
531+
)
532+
full = lax.conv_general_dilated(
533+
u_pad[None, None, :],
534+
c[::-1][None, None, :],
535+
window_strides=(1,),
536+
padding="VALID",
537+
dimension_numbers=("NCW", "IOW", "NCW"),
538+
)[0, 0]
539+
return full[1 : len(u)] / jnp.arange(1, len(u))
540+
541+
# Scan path: same Faà di Bruno recurrence
542+
# v[k] = (1/k) * sum_{j=1..k} j * u[j] * c[k-j]
543+
# rewritten as a scan body so _jet_scan can short-circuit iterations
544+
# k > effective_order.
545+
n = u.shape[0]
546+
if n == 1:
547+
return jnp.zeros_like(series)
548+
549+
j_idx = jnp.arange(n, dtype=u.dtype).reshape((n,) + (1,) * (u.ndim - 1))
550+
u_scaled = j_idx * u
551+
pad_width = [(n - 2, 0)] + [(0, 0)] * (u.ndim - 1)
552+
u_pad = jnp.pad(u_scaled, pad_width, mode="constant")[::-1]
553+
554+
v = jnp.zeros_like(u)
555+
556+
def body(v_acc, k):
557+
u_slice = lax.dynamic_slice_in_dim(u_pad, n - 1 - k, n - 1, axis=0)
558+
v_k = jnp.einsum("i...,i...->...", c[:-1], u_slice) / k
559+
v_acc = v_acc.at[k].set(v_k)
560+
return v_acc, None
561+
562+
v_final, _ = _jet_scan(body, v, jnp.arange(1, n, dtype=jnp.int32),
563+
effective_order)
564+
return v_final[1:]
535565

536566

537567
def_deriv(

‎tests/test_effective_order.py‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Contract tests for ``effective_order``.
2+
3+
For every primitive registered with ``_register_eff_order_rule``, verify
4+
that calling ``jet(f, primals, series, effective_order=k)`` produces
5+
output series whose first ``k`` entries match the full computation
6+
``jet(f, primals, series)``.
7+
8+
Entries above index ``k`` are unspecified by contract (rules in the
9+
"nonlinear with aware rule" bucket leave them garbage), so this test
10+
checks only the ``[:k]`` slice.
11+
"""
12+
13+
import pytest
14+
import jax
15+
import jax.numpy as jnp
16+
import numpy as np
17+
from jax import lax
18+
19+
from jet_array import jet
20+
import jet_array
21+
22+
23+
RTOL = 1e-10
24+
ATOL = 1e-12
25+
26+
K = 12
27+
28+
29+
def _safe_x0(name):
30+
"""Pick a primal at which the function is well-defined and well-conditioned."""
31+
return {
32+
"log": 1.3,
33+
"log1p": 0.5,
34+
"div": 1.7, # exercised via the f below
35+
"pow": 1.4,
36+
"erf_inv": 0.3, # erf_inv defined on (-1, 1)
37+
"tanh": 0.4,
38+
"logistic": 0.4,
39+
"exp": 0.3,
40+
"expm1": 0.3,
41+
"erf": 0.5,
42+
"sin": 0.5,
43+
"cos": 0.5,
44+
"sinh": 0.4,
45+
"cosh": 0.4,
46+
}.get(name, 0.5)
47+
48+
49+
def _univariate_fn(name):
50+
"""Map primitive name to a univariate test function exercising that primitive.
51+
52+
For non-univariate primitives (mul, dot_general, conv_general_dilated)
53+
we return None; those are exercised separately.
54+
"""
55+
if name == "log":
56+
return lambda x: lax.log(x)
57+
if name == "log1p":
58+
return lambda x: lax.log1p(x)
59+
if name == "exp":
60+
return lambda x: lax.exp(x)
61+
if name == "expm1":
62+
return lambda x: lax.expm1(x)
63+
if name == "logistic":
64+
return lambda x: lax.logistic(x)
65+
if name == "tanh":
66+
return lambda x: lax.tanh(x)
67+
if name == "erf":
68+
return lambda x: lax.erf(x)
69+
if name == "erf_inv":
70+
return lambda x: lax.erf_inv(x)
71+
if name == "sin":
72+
return lambda x: lax.sin(x)
73+
if name == "cos":
74+
return lambda x: lax.cos(x)
75+
if name == "sinh":
76+
return lambda x: lax.sinh(x)
77+
if name == "cosh":
78+
return lambda x: lax.cosh(x)
79+
if name == "pow":
80+
return lambda x: lax.pow(x, jnp.float64(1.7))
81+
if name == "div":
82+
return lambda x: 2.0 / (1.0 + x)
83+
return None
84+
85+
86+
def _eff_order_aware_univariate_names():
87+
return sorted(
88+
p.name for p in jet_array._rules_with_effective_order
89+
if _univariate_fn(p.name) is not None
90+
)
91+
92+
93+
@pytest.mark.parametrize("name", _eff_order_aware_univariate_names())
94+
@pytest.mark.parametrize("eff", [1, 2, 4, 8, K])
95+
def test_effective_order_prefix_matches_full(name, eff):
96+
"""series[:eff] with effective_order=eff equals series[:eff] without it."""
97+
fn = _univariate_fn(name)
98+
x0 = jnp.asarray(_safe_x0(name), dtype=jnp.float64)
99+
series_in = jnp.zeros(K, dtype=jnp.float64).at[0].set(1.0)
100+
101+
_, s_full = jet(fn, (x0,), (series_in,))
102+
_, s_eff = jet(fn, (x0,), (series_in,), effective_order=jnp.array(eff))
103+
104+
np.testing.assert_allclose(
105+
s_eff[:eff], s_full[:eff], rtol=RTOL, atol=ATOL,
106+
err_msg=f"{name} eff={eff}: prefix mismatch",
107+
)
108+
109+
110+
@pytest.mark.parametrize("name", _eff_order_aware_univariate_names())
111+
def test_effective_order_under_jit(name):
112+
"""effective_order works under jax.jit with a tracer-valued eff."""
113+
fn = _univariate_fn(name)
114+
x0 = jnp.asarray(_safe_x0(name), dtype=jnp.float64)
115+
series_in = jnp.zeros(K, dtype=jnp.float64).at[0].set(1.0)
116+
117+
@jax.jit
118+
def f(x, k):
119+
return jet(fn, (x,), (series_in,), effective_order=k)
120+
121+
_, s_ref = jet(fn, (x0,), (series_in,))
122+
for eff in [1, 4, K]:
123+
_, s = f(x0, jnp.array(eff))
124+
np.testing.assert_allclose(
125+
s[:eff], s_ref[:eff], rtol=RTOL, atol=ATOL,
126+
err_msg=f"{name} (jit) eff={eff}: prefix mismatch",
127+
)
128+
129+
130+
def test_effective_order_in_composition():
131+
"""A composed function (sin then exp) propagates effective_order correctly."""
132+
fn = lambda x: lax.exp(lax.sin(x))
133+
x0 = jnp.asarray(0.5, dtype=jnp.float64)
134+
series_in = jnp.zeros(K, dtype=jnp.float64).at[0].set(1.0)
135+
136+
_, s_ref = jet(fn, (x0,), (series_in,))
137+
for eff in [1, 3, 6, K]:
138+
_, s = jet(fn, (x0,), (series_in,), effective_order=jnp.array(eff))
139+
np.testing.assert_allclose(
140+
s[:eff], s_ref[:eff], rtol=RTOL, atol=ATOL,
141+
err_msg=f"compose eff={eff}: prefix mismatch",
142+
)
143+
144+
145+
def test_effective_order_full_K_equals_no_hint():
146+
"""effective_order=K (the full size) must produce results bit-equal to no hint."""
147+
fn = lambda x: lax.erf(x)
148+
x0 = jnp.asarray(0.4, dtype=jnp.float64)
149+
series_in = jnp.zeros(K, dtype=jnp.float64).at[0].set(1.0)
150+
151+
_, s_no = jet(fn, (x0,), (series_in,))
152+
_, s_K = jet(fn, (x0,), (series_in,), effective_order=jnp.array(K))
153+
np.testing.assert_allclose(s_no, s_K, rtol=1e-12, atol=1e-14)

0 commit comments

Comments
 (0)