Skip to content

Commit c28564a

Browse files
thisiscamclaude
andcommitted
jet_array: register lax.scan_p rule + bounded-rank logistic broadcast
_scan_jet_rule: thread Taylor-series tangents through scan iterations by rebinding the scan body under JetTrace. Required by ACNet's ``jax.lax.scan`` over uniform-width middle layers, which previously hit ``KeyError: scan`` at jet trace time on MIMIC d=53. Subtlety in xs/ys series layout: outside scan, an xs of primal shape (T, *Sx) carries series of shape (order, T, *Sx); inside the body the per-iteration view is (*Sx) primal + (order, *Sx) series. The rule transposes xs from (order, T, *Sx) → (T, order, *Sx) before binding scan_p, and ys back from (T, order, *Sy) → (order, T, *Sy) afterwards. log_space mode is rejected with NotImplementedError until LogSeries-through-scan is wired. _logistic_taylor: reshape j_idx to broadcast explicitly over the leading series axis so the multiply works at x.ndim >= 1 under jit (bare ``j_idx * u`` failed rank-promotion). tests/test_scan_jet.py: cover the new rule on a constant-coefficient linear recurrence and on the ACNet softmax-over-scan body. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ae482ca commit c28564a

4 files changed

Lines changed: 926 additions & 5 deletions

File tree

‎jet_array/__init__.py‎

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,10 @@ def _logistic_taylor(primals_in, series_in, _jet_effective_order=None, **_):
937937

938938
u = _prepend_primal(x, series) # shape (K+1, ...)
939939
j_idx = jnp.arange(order + 1, dtype=dtype) # 0…K
940-
u_scaled = j_idx * u # j·u_j
940+
# Reshape for explicit broadcast over leading series axis when x has
941+
# rank >= 1; bare `j_idx * u` fails rank-promotion under jit.
942+
j_bcast = j_idx.reshape((order + 1,) + (1,) * x.ndim)
943+
u_scaled = j_bcast * u # j·u_j
941944

942945
pad = (order - 1, 0) # left padding
943946
u_pad = jnp.pad(u_scaled, (pad,) + ((0, 0),) * (x.ndim), mode="constant")[::-1]
@@ -1475,3 +1478,165 @@ def _register_pjit_log_rule():
14751478
from .log_space import log_space_rules
14761479
log_space_rules[jex.core.primitives.jit_p] = _pjit_jet_rule
14771480
_register_pjit_log_rule()
1481+
1482+
1483+
def _scan_jet_rule(primals_in, series_in, *, jaxpr, num_consts, num_carry, length,
1484+
reverse, unroll, linear=None, _split_transpose=None, **params):
1485+
"""Jet rule for ``lax.scan_p``.
1486+
1487+
Threads Taylor-series tangents through scan iterations. The original
1488+
scan body has signature
1489+
1490+
body(*consts, *carry, *x_per_iter) -> (*new_carry, *y_per_iter)
1491+
1492+
Under jet, every input/output gains a Taylor-series companion of shape
1493+
``(order, *primal_shape)``. We build a *new* body whose flat input
1494+
layout is
1495+
1496+
[consts_p..., consts_s..., carry_p..., carry_s..., x_p..., x_s...]
1497+
1498+
so that ``lax.scan_p`` can treat consts_p+consts_s as consts, carry_p+
1499+
carry_s as the threaded carry, and x_p+x_s as scan inputs. The body
1500+
is then traced under JetTrace to give it Taylor-aware semantics.
1501+
1502+
Subtlety on xs series shape: outside the scan, an x with primal shape
1503+
``(T, *Sx)`` carries series of shape ``(order, T, *Sx)`` because the
1504+
JetTrace prepends ``order`` as the leading axis. Inside the scan body
1505+
(per iteration) the body sees ``x_p`` of shape ``(*Sx)`` and expects
1506+
``x_s`` of shape ``(order, *Sx)`` — so we must transpose xs series to
1507+
``(T, order, *Sx)`` before binding ``scan_p``, then transpose ys series
1508+
back from ``(T, order, *Sy)`` to ``(order, T, *Sy)`` after.
1509+
"""
1510+
if not series_in:
1511+
primals_out = lax.scan_p.bind(
1512+
*primals_in, jaxpr=jaxpr, num_consts=num_consts,
1513+
num_carry=num_carry, length=length, reverse=reverse,
1514+
unroll=unroll, linear=linear,
1515+
_split_transpose=_split_transpose,
1516+
)
1517+
return primals_out, [zero_series] * len(primals_out)
1518+
1519+
s0 = series_in[0]
1520+
if hasattr(s0, "sign") and hasattr(s0, "log_mag"):
1521+
raise NotImplementedError(
1522+
"scan jet rule does not yet support log_space mode "
1523+
"(LogSeries through scan body); use raw mode for now"
1524+
)
1525+
order = s0.shape[0]
1526+
log_space = False
1527+
1528+
consts_p = list(primals_in[:num_consts])
1529+
init_p = list(primals_in[num_consts:num_consts + num_carry])
1530+
xs_p = list(primals_in[num_consts + num_carry:])
1531+
consts_s = list(series_in[:num_consts])
1532+
init_s = list(series_in[num_consts:num_consts + num_carry])
1533+
xs_s = list(series_in[num_consts + num_carry:])
1534+
1535+
nc, ni, nx = num_consts, num_carry, len(xs_p)
1536+
ny = len(jaxpr.out_avals) - num_carry
1537+
1538+
body_in_avals = list(jaxpr.in_avals)
1539+
consts_avals_p = body_in_avals[:nc]
1540+
carry_avals_p = body_in_avals[nc:nc + ni]
1541+
x_avals_p = body_in_avals[nc + ni:]
1542+
consts_avals_s = [
1543+
core.ShapedArray((order,) + a.shape, a.dtype) for a in consts_avals_p
1544+
]
1545+
carry_avals_s = [
1546+
core.ShapedArray((order,) + a.shape, a.dtype) for a in carry_avals_p
1547+
]
1548+
x_avals_s = [
1549+
core.ShapedArray((order,) + a.shape, a.dtype) for a in x_avals_p
1550+
]
1551+
new_in_avals = (
1552+
consts_avals_p + consts_avals_s
1553+
+ carry_avals_p + carry_avals_s
1554+
+ x_avals_p + x_avals_s
1555+
)
1556+
1557+
body_fn = core.jaxpr_as_fun(jaxpr)
1558+
1559+
def new_body_fn(*flat_inputs):
1560+
c_p = flat_inputs[:nc]
1561+
c_s = flat_inputs[nc:2 * nc]
1562+
cy_p = flat_inputs[2 * nc:2 * nc + ni]
1563+
cy_s = flat_inputs[2 * nc + ni:2 * nc + 2 * ni]
1564+
x_p = flat_inputs[2 * nc + 2 * ni:2 * nc + 2 * ni + nx]
1565+
x_s = flat_inputs[2 * nc + 2 * ni + nx:]
1566+
1567+
primals_body = list(c_p) + list(cy_p) + list(x_p)
1568+
series_body = list(c_s) + list(cy_s) + list(x_s)
1569+
1570+
body_wrapped = lu.wrap_init(
1571+
body_fn,
1572+
debug_info=lu.DebugInfo(
1573+
traced_for="scan_jet_inner",
1574+
func_src_info="scan body",
1575+
arg_names=None, result_paths=None,
1576+
),
1577+
)
1578+
f_jet = jet_fun(jet_subtrace(body_wrapped), order, None, log_space)
1579+
out_p, out_s = f_jet.call_wrapped(primals_body, series_body)
1580+
1581+
new_carry_p = list(out_p[:ni])
1582+
new_carry_s = list(out_s[:ni])
1583+
y_p = list(out_p[ni:])
1584+
y_s = list(out_s[ni:])
1585+
return tuple(new_carry_p) + tuple(new_carry_s) + tuple(y_p) + tuple(y_s)
1586+
1587+
new_body_wrapped = lu.wrap_init(
1588+
new_body_fn,
1589+
debug_info=lu.DebugInfo(
1590+
traced_for="scan_jet_outer",
1591+
func_src_info="jet scan wrapper",
1592+
arg_names=None, result_paths=None,
1593+
),
1594+
)
1595+
new_jaxpr_inner, _, new_consts = pe.trace_to_jaxpr_dynamic(
1596+
new_body_wrapped, tuple(new_in_avals)
1597+
)
1598+
new_jaxpr = core.ClosedJaxpr(new_jaxpr_inner, new_consts)
1599+
1600+
xs_s_for_scan = [jnp.swapaxes(s, 0, 1) for s in xs_s]
1601+
new_inputs = (
1602+
consts_p + consts_s
1603+
+ init_p + init_s
1604+
+ xs_p + xs_s_for_scan
1605+
)
1606+
1607+
bind_kwargs = dict(
1608+
jaxpr=new_jaxpr,
1609+
num_consts=2 * nc,
1610+
num_carry=2 * ni,
1611+
length=length,
1612+
reverse=reverse,
1613+
unroll=unroll,
1614+
)
1615+
# `linear` and `_split_transpose` exist in older JAX (≤0.9) scan_p but
1616+
# were removed in newer versions. Pass through only when present.
1617+
if linear is not None:
1618+
consts_linear = list(linear[:nc])
1619+
init_linear = list(linear[nc:nc + ni])
1620+
xs_linear = list(linear[nc + ni:])
1621+
bind_kwargs["linear"] = tuple(
1622+
consts_linear + [False] * nc
1623+
+ init_linear + [False] * ni
1624+
+ xs_linear + [False] * nx
1625+
)
1626+
if _split_transpose is not None:
1627+
bind_kwargs["_split_transpose"] = _split_transpose
1628+
1629+
out_flat = lax.scan_p.bind(*new_inputs, **bind_kwargs)
1630+
1631+
new_carry_p = list(out_flat[:ni])
1632+
new_carry_s = list(out_flat[ni:2 * ni])
1633+
ys_p = list(out_flat[2 * ni:2 * ni + ny])
1634+
ys_s = list(out_flat[2 * ni + ny:])
1635+
ys_s = [jnp.swapaxes(s, 0, 1) for s in ys_s]
1636+
1637+
primals_out = new_carry_p + ys_p
1638+
series_out = new_carry_s + ys_s
1639+
return primals_out, series_out
1640+
1641+
1642+
jet_rules[lax.scan_p] = _scan_jet_rule

‎tests/test_log_space.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,16 @@ def test_round_trip_zero_preserved():
5050

5151

5252
def test_round_trip_denormal():
53-
"""Denormals down to 1e-310 round-trip without loss when
54-
flush_denormals=False (the option needed for end-to-end log_space
55-
pipelines that keep their consumer in log-domain too)."""
56-
vals = jnp.array([1e-300, -1e-200, 5e-310, -1e-50])
53+
"""Tiny but normal float64 values (above finfo.tiny ≈ 2.2e-308)
54+
round-trip without loss when flush_denormals=False.
55+
56+
True denormals (below finfo.tiny) cannot round-trip on XLA CPU because
57+
the backend has IEEE flush-to-zero enabled — jnp.log of a denormal
58+
underflows to -inf inside raw_to_log. Test the boundary just above
59+
that range, where flush_denormals=False is meaningfully different
60+
from the default (the default would still flush log_mag values below
61+
log(tiny) ≈ -708.4)."""
62+
vals = jnp.array([1e-300, -1e-200, 1e-307, -1e-50])
5763
back = log_to_raw(raw_to_log(vals), flush_denormals=False)
5864
rel_err = jnp.max(jnp.abs(back - vals) / jnp.abs(vals))
5965
assert float(rel_err) < 1e-13

0 commit comments

Comments
 (0)