Derivatives¶
This example takes derivatives with ppy.grad and ppy.value_and_grad,
which follow one rule table on every path, so the nine digits this program
prints are the same nine everywhere. ppy.grad(f) is the gradient of f
with respect to its first parameter. ppy.value_and_grad(f, argnums=1) is
the value and the gradient with respect to another.
Run it¶
What it prints¶
python gradients.ppy, ppy run gradients.ppy
Reverse mode on both paths¶
def f(x: float, y: float) -> float:
return math.sin(x) * y + x * x
df = ppy.grad(f)
both = ppy.value_and_grad(f, argnums=1)
Under CPython the derivative is made from f's source the first time it is
called: the body restated one operation at a time, then every operation's
adjoint in reverse.
Natively the autodiff transform does the same to the IR, reverse mode over
the core and tensor dialects, and slope(x, y) compiles to a call to the
derived function. There is no finite differencing and no second
implementation to drift.
A derivative is an ordinary function¶
dg is used inside a loop like any callable. newton is native, and the
root it finds is a root: the last column prints g at that root, zero to
floating-point precision.
Compared with JAX and PyTorch¶
Newton's method on g from 100,000 starting points near 0.4, six steps
each, with the derivative from each framework's own autodiff. The programs
are in compare/: gradients_bench.ppy,
gradients_jax.py, gradients_torch.py.
Milliseconds for the whole batch, best of five, over five processes.
PPy: ppy.grad(g) is a function. newton calls it in a loop, and a
loop over the starting points calls newton. Everything is scalar and
native; nothing is batched:
dg = ppy.grad(g)
def newton(x: float) -> float:
for _ in range(6):
x = x - g(x) / dg(x)
return x
def newton_all(count: int) -> float:
total = 0.0
for i in range(count):
total += newton(0.4 + i * 1e-6)
return total / count
JAX: jax.grad over jnp functions. To run 100,000 solves it wants
them batched: vmap under jit, the loop as lax.fori_loop so it traces,
and jax_enable_x64 so the digits match:
def newton(x):
def step(_, x):
return x - g(x) / dg(x)
return jax.lax.fori_loop(0, 6, step, x)
newton_all = jax.jit(jax.vmap(newton))
PyTorch: torch.func.grad and vmap. The six steps stay a Python loop
over a 100,000-element tensor in float64. The thread count is pinned to the
performance cores', since its default of one per logical core spins on this
hybrid CPU and the batch takes hundreds of milliseconds some runs:
dg = grad(g)
def newton(x):
for _ in range(6):
x = x - g(x) / dg(x)
return x
newton_all = vmap(newton)
| PPy | JAX vmap + jit |
PyTorch torch.func |
|
|---|---|---|---|
| newton, 100k starts | 11.89 ± 0.07 | 1.23 ± 0.10 | 6.52 ± 1.04 |
The derivative is the same nine digits in all three: reverse mode over the same rule table. What differs is the shape of the program.
JAX and PyTorch are fast here because the problem batches, and XLA and ATen
vectorize across the batch. A scalar newton in a Python loop would cost
them tens of microseconds per call. PPy's scalar loop pays nothing per call
and is not vectorized across the batch.
Which one is faster depends on whether your problem comes as 100,000 independent solves or as one.
Intel Core Ultra 9 386H; JAX 0.11.1 and PyTorch 2.14.0 (CPU) on CPython 3.13.13, PPy on CPython 3.13.13, from a checkout on a native filesystem.
Limitations¶
What cannot be differentiated is refused:
- a branch or a loop inside
fisE1660 - a non-
floatsignature isE1661 - an effect no derivative follows (I/O, a write, a thread) is
E1662
Read on: Derivatives · The IR
gradients.ppy is hand-written; there is no .py source and no conversion
step.
36_autodiff/gradients.ppy¶
import math
import ppy
def f(x: float, y: float) -> float:
return math.sin(x) * y + x * x
def g(x: float) -> float:
return math.exp(-x * x) * math.cos(3.0 * x)
df = ppy.grad(f)
both = ppy.value_and_grad(f, argnums=1)
dg = ppy.grad(g)
def slope(x: float, y: float) -> float:
return df(x, y)
def pair(x: float, y: float) -> float:
v, grad_y = both(x, y)
return v * 2.0 + grad_y
def newton(x: float) -> float:
for _ in range(6):
x = x - g(x) / dg(x)
return x
def main() -> None:
print(f"{slope(0.7, 2.0):.9f} {pair(0.7, 2.0):.9f}")
print(f"{dg(0.3):.9f} {newton(0.4):.9f} {g(newton(0.4)):.1e}")
main()
Counterpart programs¶
The programs the comparison above measured, each written the way its tool expects. The PPy one is first.
gradients_bench.ppy (PPy)
"""The derivatives of `gradients.ppy`, and Newton's method from 100,000 starts: the PPY side."""
import math
import time
import ppy
def f(x: float, y: float) -> float:
return math.sin(x) * y + x * x
def g(x: float) -> float:
return math.exp(-x * x) * math.cos(3.0 * x)
df = ppy.grad(f)
both = ppy.value_and_grad(f, argnums=1)
dg = ppy.grad(g)
def slope(x: float, y: float) -> float:
return df(x, y)
def pair(x: float, y: float) -> float:
v, grad_y = both(x, y)
return v * 2.0 + grad_y
def newton(x: float) -> float:
for _ in range(6):
x = x - g(x) / dg(x)
return x
def newton_all(count: int) -> float:
"""The mean root over `count` starting points near 0.4, one Newton solve each."""
total = 0.0
for i in range(count):
total += newton(0.4 + i * 1e-6)
return total / count
def worst_residual(count: int) -> float:
worst = 0.0
for i in range(count):
worst = max(worst, abs(g(newton(0.4 + i * 1e-6))))
return worst
def main() -> None:
print(f"{slope(0.7, 2.0):.9f} {pair(0.7, 2.0):.9f}")
newton_all(1000)
best = 1e9
mean = 0.0
for _ in range(5):
started = time.perf_counter()
mean = newton_all(100_000)
best = min(best, (time.perf_counter() - started) * 1000.0)
print(f"# newton, 100k starts: {best:.2f} ms")
print(f"{mean:.9f} {worst_residual(100_000):.0e}")
main()
gradients_jax.py (Python)
"""The same derivatives under JAX: `jax.grad`, and `vmap` over the starting points, jitted."""
import time
import jax
import jax.numpy as jnp
jax.config.update("jax_enable_x64", True)
def f(x, y):
return jnp.sin(x) * y + x * x
def g(x):
return jnp.exp(-x * x) * jnp.cos(3.0 * x)
df = jax.grad(f)
both = jax.value_and_grad(f, argnums=1)
dg = jax.grad(g)
def newton(x):
def step(_, x):
return x - g(x) / dg(x)
return jax.lax.fori_loop(0, 6, step, x)
newton_all = jax.jit(jax.vmap(newton))
def timed(label, run):
best = 1e9
answer = None
for _ in range(5):
started = time.perf_counter()
answer = run()
best = min(best, time.perf_counter() - started)
print(f"# {label}: {best * 1000:.2f} ms")
return answer
def main():
v, grad_y = both(0.7, 2.0)
print(f"{float(df(0.7, 2.0)):.9f} {float(v * 2.0 + grad_y):.9f}")
starts = 0.4 + jnp.arange(100_000) * 1e-6
newton_all(starts).block_until_ready()
roots = timed("newton, 100k starts", lambda: newton_all(starts).block_until_ready())
print(f"{float(jnp.mean(roots)):.9f} {float(jnp.max(jnp.abs(jax.vmap(g)(roots)))):.0e}")
main()
gradients_torch.py (Python)
"""The same derivatives under PyTorch: `torch.func.grad` and `vmap` over the starting points."""
import time
import torch
from torch.func import grad, vmap
torch.set_default_dtype(torch.float64)
# PyTorch's default is one thread per logical core; on a hybrid CPU that
# count spins on the efficiency cores and the batch takes hundreds of
# milliseconds some runs and seven others. The performance cores' count is
# the number to give it, as a program would.
torch.set_num_threads(min(8, torch.get_num_threads()))
def f(x, y):
return torch.sin(x) * y + x * x
def g(x):
return torch.exp(-x * x) * torch.cos(3.0 * x)
df = grad(f)
dg = grad(g)
def newton(x):
for _ in range(6):
x = x - g(x) / dg(x)
return x
newton_all = vmap(newton)
def timed(label, run):
best = 1e9
answer = None
for _ in range(5):
started = time.perf_counter()
answer = run()
best = min(best, time.perf_counter() - started)
print(f"# {label}: {best * 1000:.2f} ms")
return answer
def main():
x = torch.tensor(0.7)
y = torch.tensor(2.0, requires_grad=True)
v = f(x, y)
grad_y = torch.autograd.grad(v, y)[0]
print(f"{float(df(x, torch.tensor(2.0))):.9f} {float(v.detach() * 2.0 + grad_y):.9f}")
starts = 0.4 + torch.arange(100_000, dtype=torch.float64) * 1e-6
newton_all(starts)
roots = timed("newton, 100k starts", lambda: newton_all(starts))
print(f"{float(roots.mean()):.9f} {float(vmap(g)(roots).abs().max()):.0e}")
main()
Source: examples/36_autodiff.