Chapter 1: Introduction to JAX
Course: "Getting Started with JAX" (ApX Machine Learning), Chapter 1 · Original course link: https://apxml.com/courses/getting-started-with-jax/chapter-1-introduction-to-jax Notebook environment: Python 3.14 · JAX 0.12.0.1 · CPU only. The text and code follow how JAX 0.12 actually behaves. Outdated patterns go in ❌ cells, each followed right away by a ✅ recommended version, and both sides are checked with
assert. Things to watch out for are marked "⚠️ Note", and the "Version notes" section at the end of the chapter collects them. "🔍 Extra" sections are optional deep dives.
Learning goals
- Explain what JAX is: a numerical computing platform with a NumPy-like interface + composable function transformations (
jit/grad/vmap/shard_map), compiled by XLA under the hood. - Know how
jax.numpyis like NumPy and how it differs, especially immutability, thefloat32default, explicit random keys, and asynchronous dispatch. - Understand the "pure functions + function transformations" design and the constraints that tracing brings.
- Install and verify JAX, and check and control which device (CPU / GPU / TPU) an array lives on.
- Create, inspect, compute with, index, functionally update, reduce, and reshape arrays with confidence.
In this chapter
| Section | Contents |
|---|---|
| 1.1 What is JAX? | What it is, its two pillars, the XLA compilation pipeline |
| 1.2 JAX vs. NumPy | Similarities, five key differences, when to use which |
| 1.3 Core design: function transformations | Pure functions, grad / jit / vmap / parallelism and how they compose |
| 1.4 Installation and setup | Installing and verifying on CPU / GPU / TPU |
| 1.5 Working with JAX arrays | Creation, random numbers, attributes, immutability, operations, indexing |
| 1.6 Device management: CPU, GPU, TPU | Listing devices, device_put, host vs. device memory |
| 1.7 Hands-on: basic array operations | Basic exercises + 4 extra exercises |
| Chapter 1 quiz | Quiz questions, answers, and explanations |
| Chapter summary & cheat sheet | Key points, API quick reference |
| Version notes | Patterns and conclusions in this chapter to watch for under JAX 0.12 |
The explanations are reorganized around the course's key points. The example code keeps the course's variable names and values where possible, and all of it has been run for real.
# Imports and environment info used throughout the chapter
import sys
import time
import warnings
import subprocess
import numpy as np
import jax
import jax.numpy as jnp
print(f"Python : {sys.version.split()[0]}")
print(f"JAX : {jax.__version__}")
print(f"NumPy : {np.__version__}")
print(f"Default backend: {jax.default_backend()}")
print(f"Devices: {jax.devices()}")
1.1 What is JAX?¶
JAX is a Python library from Google built for high-performance numerical computing, and it is especially popular in machine learning research. Its strength is making full use of accelerators like GPUs and TPUs. Rather than thinking of JAX as "yet another array library", think of it as a platform: you write functions with familiar array syntax, then use JAX's "transformations" to reshape those functions.
JAX stands on two pillars:
1. A familiar NumPy interface
The jax.numpy module (conventionally imported as jnp) mirrors the NumPy API almost one to one. If you can write NumPy, you can pick up JAX quickly. A lot of existing NumPy code runs after you swap np for jnp.
2. Composable function transformations
This is where JAX really shines. A transformation is a higher-order function: it takes a function and returns a new function.
| Transformation | What it does |
|---|---|
jax.jit | Just-In-Time compilation: uses XLA to compile the function into machine code optimized for the target hardware, cutting Python overhead and fusing operations |
jax.grad | Automatic differentiation: returns a new function that computes the gradient of the original |
jax.vmap | Automatic vectorization: turns a function that "handles one example" into one that "handles a batch", with no hand-written loop |
jax.shard_map | Multi-device parallelism: splits the computation across devices by data and runs it there (SPMD) |
⚠️ Note: Vectorization is not the same thing as quantization. Quantization is a separate technique for lowering numerical precision and has nothing to do with
vmap.⚠️ Note: Older material often lists
jax.pmapas the fourth main transformation. In JAX 0.12, thejax.pmapdocstring opens with "Old way of doing parallel map. Usejax.shard_mapinstead.". It still runs, but it is a legacy API. In new code, usejax.shard_map, orjax.shardingtogether withjitfor automatic sharding (verified in the cell below).
These transformations can be composed freely, for example jax.jit(jax.grad(f)) or jax.vmap(jax.grad(f)).
The overall pipeline: JAX takes a Python function written with jax.numpy, traces it into an intermediate representation (jaxpr), hands that to the XLA (Accelerated Linear Algebra) compiler, and finally generates and runs optimized code for CPU / GPU / TPU.
Python function (jnp) ──trace──▶ jaxpr ──lower──▶ StableHLO ──XLA compile──▶ CPU / GPU / TPU machine code
Functional style: JAX transformations work best on pure functions. A pure function's output depends only on its inputs, and it has no side effects (no changing globals, no I/O). JAX doesn't force you to write pure functions, but if a function isn't pure, the transformed version may behave in surprising ways. How to handle stateful programs (model parameters, optimizer state, and so on) is covered in Chapter 6.
References: JAX documentation, OpenXLA
⚠️ Note: The JAX docs have moved to
docs.jax.dev(the old addressjax.readthedocs.ioredirects there), and the code lives atgithub.com/jax-ml/jax.
# Check: jax.shard_map is the currently recommended parallel API, and the jax.pmap docstring marks it as legacy
print(jax.pmap.__doc__.strip().splitlines()[0])
assert hasattr(jax, "shard_map")
assert "shard_map" in jax.pmap.__doc__
🔍 Extra (1.1): See a jaxpr for yourself¶
⚪ Optional: Read this if you want to see what transformations generate internally; Chapters 2 and 3 use
make_jaxpragain and again.
We mentioned an "intermediate representation". What does it actually look like? jax.make_jaxpr shows you exactly what JAX traced your function into. Reading jaxprs is the best way into understanding transformations like jit and grad. grad itself is just another function transformation, and what it produces is still a jaxpr.
# Look at the jaxpr that JAX gets from tracing
def f(x):
return jnp.sin(x) ** 2 + 1.0
print("jaxpr of f:")
print(jax.make_jaxpr(f)(1.0))
print("jaxpr of grad(f) (the extra cos and multiplications are the chain rule):")
print(jax.make_jaxpr(jax.grad(f))(1.0))
# Check: d/dx [sin²x + 1] = 2·sin x·cos x
assert jnp.allclose(jax.grad(f)(1.0), 2 * jnp.sin(1.0) * jnp.cos(1.0))
# Check available devices (the CPU is always there)
print(f"JAX devices: {jax.devices()}")
# Creating a NumPy array
np_array = np.array([1.0, 2.0, 3.0])
print(f"NumPy array: {np_array}, type: {type(np_array)}")
# Creating a JAX array: check its type with jax.Array (the concrete class is ArrayImpl)
jnp_array = jnp.array([1.0, 2.0, 3.0])
print(f"JAX array: {jnp_array}, type: {type(jnp_array)}")
assert isinstance(jnp_array, jax.Array)
assert not isinstance(jnp_array, np.ndarray) # a JAX array is not a NumPy array
# Basic operations are written exactly the same way
a_np = np.arange(6).reshape((2, 3))
b_np = np.array([[1, 1, 1], [2, 2, 2]])
c_np = a_np + b_np * 2
a_jnp = jnp.arange(6).reshape((2, 3))
b_jnp = jnp.array([[1, 1, 1], [2, 2, 2]])
c_jnp = a_jnp + b_jnp * 2 # same syntax as NumPy
print(f"NumPy result:\n{c_np}")
print(f"JAX result:\n{c_jnp}")
# Check that the results are close (useful when comparing floating-point results)
print(f"Results close: {np.allclose(c_np, c_jnp)}")
assert np.allclose(c_np, c_jnp)
Key differences¶
Difference 1: immutability (the most important one)
NumPy arrays are mutable: you can change elements in place. JAX arrays are immutable: once created, they can't be changed. To "modify" one, you use the indexed update syntax x.at[idx].set(value), which produces a new array. Why this design? Because transformations like jit and grad need functions to be pure. If arrays could be overwritten in place, the results of tracing and differentiation couldn't be trusted.
np_array = np.array([1, 2, 3])
np_array[0] = 100 # fine in NumPy
print(f"Modified NumPy array: {np_array}")
jnp_array = jnp.array([1, 2, 3])
try:
# raises TypeError in JAX
jnp_array[0] = 100
except TypeError as e:
print(f"\nError when modifying a JAX array in place: {e}")
# The JAX way: .at[index].set(value) creates a new array with the new value
updated_jnp_array = jnp_array.at[0].set(100)
print(f"\nOriginal JAX array (unchanged): {jnp_array}")
print(f"Updated JAX array (new object): {updated_jnp_array}")
assert jnp.array_equal(jnp_array, jnp.array([1, 2, 3]))
assert jnp.array_equal(updated_jnp_array, jnp.array([100, 2, 3]))
Difference 2: hardware acceleration NumPy only runs on the CPU. JAX runs on CPU, GPU, and TPU, and usually puts data on the best available device automatically.
Difference 3: execution model (asynchronous dispatch + optional whole-function compilation) Every NumPy operation is synchronous: it returns only when the work is done. Without jit, JAX also runs one operation at a time right away, but dispatch is asynchronous: Python gets a handle to the result array and returns immediately while the device computes in the background. Only when you actually need the values (print, converting to NumPy, calling .block_until_ready()) does it stop and wait for the computation to finish. With jax.jit, the whole function is first compiled into a single XLA program and then run.
This directly affects benchmarking: without .block_until_ready(), you only measure the dispatch time.
⚠️ Note: People often describe JAX as "lazily evaluated". That isn't accurate. JAX doesn't queue up computations and wait until you ask for a result. Every operation is dispatched right away; it just returns without waiting for the result. On this machine's CPU (JAX 0.12), dispatch takes a fraction of a millisecond while waiting for the result takes over ten milliseconds, about two orders of magnitude apart (the exact numbers vary with the machine and load). See the cell below.
# Asynchronous dispatch: not waiting vs. waiting for the computation to finish
x = jax.random.normal(jax.random.key(0), (2000, 2000))
_ = (x @ x).block_until_ready() # warm up to exclude first-call overhead
t0 = time.perf_counter()
y = x @ x # only dispatches, returns immediately
t_dispatch = time.perf_counter() - t0
y.block_until_ready() # wait for it to finish so it doesn't affect the next timing
t0 = time.perf_counter()
y = (x @ x).block_until_ready() # wait until it is really done
t_real = time.perf_counter() - t0
print(f"No wait (dispatch only): {t_dispatch * 1e3:8.3f} ms")
print(f"block_until_ready: {t_real * 1e3:8.3f} ms")
print("Takeaway: benchmarks must use .block_until_ready(), or they badly underestimate the time.")
assert t_dispatch < t_real
Difference 4: function transformations jit, grad, vmap, and the parallel transformation (shard_map) are unique to JAX; NumPy has nothing like them.
Difference 5: types and precision JAX uses 32-bit types by default (float32 / int32), on every device. NumPy defaults to 64-bit (float64 / int64). The reason is that 32-bit math is much faster on accelerators and uses less memory.
By default, JAX disables 64-bit types. Even if you explicitly write dtype=jnp.float64, you only get float32, plus a warning. To use 64-bit types, run jax.config.update("jax_enable_x64", True) when your program starts, before creating any arrays (or set the environment variable JAX_ENABLE_X64=1). Turning it on midway doesn't raise an error, but arrays created earlier stay float32, and mixing old and new arrays gives you inconsistent precision (verified in the cell below). If you only want 64-bit for a short stretch of code, write with jax.enable_x64(True):.
Type promotion rules for mixed-type operations also differ a bit from NumPy; see the Extra section below.
⚠️ Note: With the default configuration,
jnp.array([1.0, 2.0], dtype=jnp.float64)gives youfloat32, not float64, along withUserWarning: Explicitly requested dtype float64 ... will be truncated to dtype float32(JAX 0.12).
# Check the default floating-point type
np_float = np.array([1.0]).dtype
jnp_float = jnp.array([1.0]).dtype
print(f"Default NumPy float type: {np_float}")
print(f"Default JAX float type: {jnp_float}")
assert np_float == np.float64
assert jnp_float == jnp.float32
# ❌ Old style (outdated): with the default config, dtype=jnp.float64 has no effect and is truncated to float32
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
try:
jnp_float64 = jnp.array([1.0, 2.0], dtype=jnp.float64)
print(f"Requested float64, actual dtype: {jnp_float64.dtype}")
except Exception as e:
print(f"Error: {type(e).__name__}: {e}")
for w in caught:
print(f"Observed warning: {w.message}")
assert jnp_float64.dtype == jnp.float32
assert any("truncated to dtype float32" in str(w.message) for w in caught)
# ✅ Recommended: turn on jax_enable_x64 when the program starts
# This must be set before creating any arrays; to avoid affecting the rest of this notebook, we demo it in a subprocess
x64_demo = '''
import jax
jax.config.update("jax_enable_x64", True) # must be set before creating arrays
import jax.numpy as jnp
a = jnp.array([1.0, 2.0], dtype=jnp.float64)
assert a.dtype == jnp.float64
assert jnp.array([1.0]).dtype == jnp.float64 and jnp.arange(3).dtype == jnp.int64
print("With x64 on, explicit float64:", a.dtype)
print("With x64 on, default float:", jnp.array([1.0]).dtype, ", default int:", jnp.arange(3).dtype)
'''
r = subprocess.run([sys.executable, "-c", x64_demo], capture_output=True, text=True)
print(r.stdout or r.stderr)
assert r.returncode == 0 and "float64" in r.stdout
# For comparison: turning on x64 midway -- no error, but arrays created before it are still float32
late_demo = '''
import jax, jax.numpy as jnp
early = jnp.ones(2) # created before x64 is on
jax.config.update("jax_enable_x64", True) # deliberately turned on one step late
late = jnp.ones(2, dtype=jnp.float64)
assert early.dtype == jnp.float32 and late.dtype == jnp.float64
print("x64 turned on midway: array created before", early.dtype, ", array created after", late.dtype)
'''
r = subprocess.run([sys.executable, "-c", late_demo], capture_output=True, text=True)
print(r.stdout or r.stderr)
assert r.returncode == 0 and "float32" in r.stdout
Comparison table¶
| Feature | NumPy | JAX |
|---|---|---|
| API | — | Closely mirrors NumPy |
| Mutability | Mutable | Immutable |
| Hardware | CPU only | CPU / GPU / TPU |
| Execution model | Synchronous, one op at a time | One op at a time with asynchronous dispatch; whole function compiled into an XLA program under jit |
| Function transformations | None | jit / grad / vmap / shard_map |
| Default precision | 64-bit | 32-bit (on every device; 64-bit needs jax_enable_x64) |
| Random numbers | Implicit global state | PRNG keys passed explicitly |
| Main goal | General numerical computing | High-performance, differentiable numerical computing |
| State handling | Side effects allowed | Prefers pure functions with state passed explicitly |
⚠️ Note: The execution model is "asynchronous dispatch", not "lazy evaluation"; the parallel transformation is
shard_map, no longerpmap. Comparison tables in older material often fill these two cells with "lazy, often via JIT" andjit, grad, vmap, pmap; both were covered above.
When to use which? The two can live side by side in the same project. For data loading, preprocessing, postprocessing, and talking to other libraries, plain NumPy is fine. Hand the core computations that need acceleration, gradients, vectorization, or parallelism to jax.numpy.
🔍 Extra (1.2): Three more behavior differences that trip people up¶
🟢 Must-read
(1) Two type promotion pitfalls.
- Weak types: Python scalars (
1.5,2) are "weakly typed" in operations and don't promote an array to 64 bits. Anint32array +1.5givesfloat32, while NumPy would givefloat64. - Integer overflow doesn't raise: In NumPy 2, an
int8array +300raisesOverflowError. JAX truncates (wraps) 300 toint8and silently gives you a wrong result.
# Weak types: Python scalars don't raise precision to 64 bits
print("NumPy: int64 array + 1.5 ->", (np.array([1, 2, 3]) + 1.5).dtype)
print("JAX : int32 array + 1.5 ->", (jnp.array([1, 2, 3]) + 1.5).dtype)
assert (jnp.array([1, 2, 3]) + 1.5).dtype == jnp.float32
# Integer overflow: NumPy raises, JAX silently wraps around
try:
print(np.array([1, 2, 3], dtype=np.int8) + 300)
except OverflowError as e:
print(f"\nNumPy int8 + 300 -> OverflowError: {e}")
wrapped = jnp.array([1, 2, 3], dtype=jnp.int8) + 300
print("JAX int8 + 300 ->", wrapped, "(300 is truncated to 44, so the result is wrong)")
assert jnp.array_equal(wrapped, jnp.array([45, 46, 47], dtype=jnp.int8))
(2) Out-of-bounds indexing doesn't raise. NumPy raises IndexError when you go out of bounds. So that it can compile for accelerators, JAX does no runtime bounds checking: reads clamp the index into the valid range, and an out-of-bounds .at[].set simply drops the update. This is one of the differences most likely to hide bugs. When you need well-defined out-of-bounds behavior, use .at[i].get(mode="fill", fill_value=...).
a = jnp.arange(5)
try:
np.arange(5)[10]
except IndexError as e:
print(f"NumPy out of bounds: IndexError: {e}")
print(f"JAX a[10] -> {a[10]} (clamped to the last element)")
print(f"JAX a.at[10].set(99) -> {a.at[10].set(99)} (update dropped)")
print(f"JAX a.at[10].get(mode='fill', fill_value=-1) -> {a.at[10].get(mode='fill', fill_value=-1)}")
assert a[10] == 4
assert jnp.array_equal(a.at[10].set(99), a)
assert a.at[10].get(mode="fill", fill_value=-1) == -1
(3) Copy semantics when converting between NumPy and JAX. Both jnp.asarray(np_arr) and jnp.array(np_arr) copy the data to the device, so changing the NumPy array afterward doesn't affect the JAX array. In the other direction, np.asarray(jax_arr) gives a read-only NumPy array; use np.array(jax_arr) when you need a writable copy.
src = np.array([1.0, 2.0])
j = jnp.asarray(src)
src[0] = 99.0
print(f"After modifying the NumPy source array, the JAX array is still: {j}")
assert j[0] == 1.0
back = np.asarray(jnp.ones(2))
print(f"Is np.asarray(jax array) writable: {back.flags.writeable}")
try:
back[0] = 5
except ValueError as e:
print(f"Write error: {e}")
assert not back.flags.writeable
assert np.array(jnp.ones(2)).flags.writeable # np.array gives a writable copy
1.3 Core design: function transformations¶
At the heart of JAX are function transformations. Instead of inventing a whole new data structure or programming model, JAX acts like a "meta-library" that works directly on the Python / NumPy-style code you already have.
Functional programming and pure functions¶
JAX's design comes from functional programming. A pure function satisfies two conditions:
- Output depends only on explicit inputs: the same inputs always give the same output;
- No side effects: it doesn't change globals, doesn't modify its inputs, and does no I/O (printing, writing files, and so on).
Pure functions are easy to analyze, optimize, compile, differentiate, and parallelize. When JAX transforms a function, it runs the function once with special tracers as inputs, records the sequence of operations inside (the computation graph), and then transforms that graph.
The main transformations¶
jax.jit: just-in-time compilation with XLA, with especially large speedups on GPU / TPU;jax.grad: returns a new function that computes the gradient;jax.vmap: automatic vectorization, turning a single-example function into a batched one, usually with no need for a loop;jax.shard_map: runs in parallel across multiple devices (SPMD, single program, multiple data).
⚠️ Note: As in 1.1, vectorization is not quantization; for parallelism use
jax.shard_map, sincepmapis a legacy API.
What makes transformations powerful is that they compose: you can JIT a gradient function, or vectorize a function that computes gradients.
Example: a linear model¶
Model and loss (mean squared error):
$$ \hat{y} = x\,w + b,\qquad L(w, b) = \frac{1}{N}\sum_{i=1}^{N}\left(\hat{y}_i - y_i\right)^2 $$
def predict(params, inputs):
# A simple linear model
return jnp.dot(inputs, params['w']) + params['b']
def loss_fn(params, inputs, targets):
predictions = predict(params, inputs)
error = predictions - targets
return jnp.mean(error ** 2) # mean squared error
# grad: differentiate loss_fn with respect to its first argument (params)
grad_fn = jax.grad(loss_fn)
# jit: compile the gradient function
jit_grad_fn = jax.jit(grad_fn)
# vmap: suppose predict handles a single input; vmap lets it handle a batch
# Map over axis 0 of the inputs argument; params is shared across all examples (None)
batched_predict = jax.vmap(predict, in_axes=(None, 0))
So far we have only defined the transformed functions; we haven't called them yet. (For example, print(batched_predict) just prints a function object.) Next we make a bit of data, actually run all three functions, and check the grad result against the analytic gradient.
Differentiating the MSE gives the analytic gradient:
$$ \frac{\partial L}{\partial w} = \frac{2}{N} X^\top(\hat{y} - y),\qquad \frac{\partial L}{\partial b} = \frac{2}{N}\sum_i(\hat{y}_i - y_i) $$
Note: the parameters (params) and the data are passed in as arguments rather than captured by a closure. This is a basic rule for writing JAX functions.
# Build a small dataset: y = 3*x0 - 2*x1 + 0.5 + noise
key = jax.random.key(0)
k_x, k_noise = jax.random.split(key)
inputs = jax.random.normal(k_x, (8, 2)) # 8 examples, 2 features each
true_w, true_b = jnp.array([3.0, -2.0]), 0.5
targets = inputs @ true_w + true_b + 0.1 * jax.random.normal(k_noise, (8,))
params = {'w': jnp.zeros(2), 'b': jnp.array(0.0)}
print("Loss:", loss_fn(params, inputs, targets))
grads = grad_fn(params, inputs, targets)
print("Gradients (a dict with the same structure as params):", grads)
jit_grads = jit_grad_fn(params, inputs, targets)
print("JIT gradients:", jit_grads)
# Check 1: results match with and without jit
assert jax.tree.all(jax.tree.map(jnp.allclose, grads, jit_grads))
# Check 2: matches the analytic gradient
resid = predict(params, inputs) - targets
assert jnp.allclose(grads['w'], 2 / len(targets) * inputs.T @ resid, atol=1e-5)
assert jnp.allclose(grads['b'], 2 * resid.mean(), atol=1e-5)
# vmap version: the single-example predict applied to a batch of inputs
true_params = {'w': true_w, 'b': jnp.array(true_b)}
single = predict(true_params, inputs[0]) # one example -> scalar
batch = batched_predict(true_params, inputs) # a batch of examples -> (8,)
print("Single-example output shape:", single.shape, "batch output shape:", batch.shape)
# Check 3: the vmap result matches a per-example loop
assert batch.shape == (8,)
assert jnp.allclose(batch, jnp.stack([predict(true_params, xi) for xi in inputs]))
Put these transformations together and you get a complete gradient descent training loop. This is exactly what "composable" means:
@jax.jit
def update(params, inputs, targets, lr=0.1):
grads = jax.grad(loss_fn)(params, inputs, targets)
# jax.tree.map applies the same update to every entry in the params dict
return jax.tree.map(lambda p, g: p - lr * g, params, grads)
p = {'w': jnp.zeros(2), 'b': jnp.array(0.0)}
for step in range(201):
p = update(p, inputs, targets)
if step % 50 == 0:
print(f"step {step:3d} loss = {loss_fn(p, inputs, targets):.5f}")
print("Learned parameters:", p, "\nTrue parameters: w = [3, -2], b = 0.5")
# Check: converges to the noise level and the parameters are close to the true values
assert loss_fn(p, inputs, targets) < 0.01
assert jnp.allclose(p['w'], true_w, atol=0.15)
What tracing means: when JAX transforms a function, it runs the function once with tracers standing in for the real inputs.
Take jit as an example: its tracers carry only abstract values, that is, shape and dtype, with no concrete numbers. The exception is arguments declared as static arguments (via jax.jit(f, static_argnames=...)). grad on its own is different: its tracers still carry concrete values, so a Python if keeps working under grad (covered in detail in Chapter 3, section 3.7).
jit traces only the first time it sees a given input shape/dtype. So side effects inside the function (printing, changing globals) may happen only once, and the transformed function won't behave the way you expect. Immutable arrays are another expression of the pure-function requirement.
⚠️ Note: "Tracing with abstract values only" describes
jit, not every transformation. Withgradalone, a value-dependent Pythonifdifferentiates just fine; only once you wrap it injitdo you getTracerBoolConversionError(verified in the cell for Extra (1) below).
⚠️ Note: Some translations of the course describe this as "marking a token as static". What it actually means is declaring an argument as a static argument.
🔍 Extra (1.3)¶
🟢 Must-read
(1) See for yourself that "side effects happen only during tracing". The print in the function below runs only the first time you call it with a given shape/type (that is, while tracing). After that, calls hit the compilation cache and nothing is printed. Change the input shape and it traces again. To print runtime values inside a compiled function, use jax.debug.print.
counter = {"n": 0}
@jax.jit
def impure(x):
print(" [Python print] tracing, x =", x) # runs during tracing; x is a Tracer
jax.debug.print(" [debug.print] runtime x = {}", x) # runs on every call
counter["n"] += 1 # side effect: happens only during tracing
return x * 2
print("Call 1 (shape (2,))"); impure(jnp.ones(2)).block_until_ready()
print("Call 2 (shape (2,))"); impure(jnp.ones(2)).block_until_ready()
print("Call 3 (shape (3,))"); impure(jnp.ones(3)).block_until_ready()
print(f"counter = {counter['n']} (called 3 times, but traced only 2 times)")
assert counter["n"] == 2
# For comparison: "abstract values with only shape and dtype" is jit's behavior; with grad alone, tracers carry concrete values
def py_if(x):
return x * x if x > 0 else -x
assert jax.grad(py_if)(-2.0) == -1.0 # grad alone: Python if works
try:
jax.jit(py_if)(-2.0)
jit_if_ok = True
except jax.errors.TracerBoolConversionError:
jit_if_ok = False
assert not jit_if_ok # jit: an abstract value can't be converted to bool
print("Python if works under grad alone; under jit it raises TracerBoolConversionError")
(2) The classic impure-function mistake: reading a global variable. During tracing, the global's value is baked into the compiled result as a constant. If you change the global later, the jit function doesn't see it. So every value that can change must be passed in explicitly as an argument.
scale = 2.0
@jax.jit
def scaled(x):
return x * scale # reads a global variable -- impure
print("With scale=2:", scaled(jnp.array(1.0)))
scale = 100.0
print("After changing scale to 100:", scaled(jnp.array(1.0)), " <- still 2; it uses the old value from tracing time")
assert scaled(jnp.array(1.0)) == 2.0
# The right way: pass values that can change in as arguments
scaled_ok = jax.jit(lambda x, s: x * s)
assert scaled_ok(jnp.array(1.0), scale) == 100.0
(3) A few practical details about grad (you'll need them soon):
jax.gradrequires the function to return a scalar. By default it differentiates with respect to argument 0; useargnums=(0, 1)to differentiate with respect to several arguments at once.- Arguments can be nested structures like dicts and lists (JAX calls these pytrees), and the gradient comes back with the same structure;
gradsabove is an example. - In training you usually want both the loss value and the gradient.
jax.value_and_gradcomputes both in one go, so you don't repeat the forward pass.
loss_val, grads2 = jax.value_and_grad(loss_fn)(params, inputs, targets)
print("Loss:", loss_val)
print("Gradients:", grads2)
assert jnp.allclose(loss_val, loss_fn(params, inputs, targets))
# Differentiating with respect to several arguments: argnums=(0, 1) covers both params and inputs
g_params, g_inputs = jax.grad(loss_fn, argnums=(0, 1))(params, inputs, targets)
print("Shape of the gradient w.r.t. inputs:", g_inputs.shape)
assert g_inputs.shape == inputs.shape
# A non-scalar output raises an error
try:
jax.grad(predict)(params, inputs)
except TypeError as e:
print("Error when differentiating a non-scalar output:", str(e).splitlines()[0])
(4) A composition example: per-example gradients. jax.vmap(jax.grad(single_loss), in_axes=(None, 0, 0)) computes each example's own gradient in one line (common in differential privacy, example-importance analysis, and similar settings). This is the signature use of combining vmap and grad. For the full example and checks see 04 §4.6; for more advanced material see 07 Topic 4.
1.4 Installation and setup¶
Requirements: JAX 0.12 needs Python ≥ 3.12. Use a virtual environment so it doesn't clash with other packages on your system:
# Option 1: venv
python3.12 -m venv jax-env
source jax-env/bin/activate # Windows: jax-env\Scripts\activate
# Option 2: conda
conda create -n jax-env python=3.12
conda activate jax-env
Install commands (the official JAX installation guide is the source of truth):
| Platform | Command | Notes |
|---|---|---|
| CPU (Linux / macOS / Windows) | pip install -U jax | jaxlib is a dependency of jax and gets installed automatically |
| NVIDIA GPU, CUDA 12 | pip install -U "jax[cuda12]" | Ships the CUDA / cuDNN runtime through pip; you only need the NVIDIA driver installed |
| NVIDIA GPU, CUDA 13 | pip install -U "jax[cuda13]" | Same as above; needs a newer driver |
| CUDA installed locally | pip install -U "jax[cuda12-local]" / "jax[cuda13-local]" | Uses your local CUDA; making sure the versions match is up to you |
| Google Cloud TPU VM | pip install -U "jax[tpu]" | Usually preinstalled on Colab's TPU runtime |
| Apple Silicon GPU (Metal) | pip install jax-metal | Experimental plugin that supports only some operations and is very strict about the JAX version; while learning, just use the CPU |
| Windows + NVIDIA GPU | Install inside WSL2 the Linux way | Native Windows supports CPU only |
⚠️ Note: Older tutorials often say "requires Python 3.7+", use
pip install --upgrade jax jaxlibfor CPU, andpip install --upgrade "jax[cuda11_cudnn82]" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.htmlfor GPU. All of that is outdated now. JAX 0.12's package metadata (see the cell below) shows: the requirement isRequires-Python >=3.12; the available extras no longer includecuda11_cudnn82(CUDA 11 is no longer supported, and you no longer need-fto point at a download page); and you don't need to listjaxlibseparately.
# Check: read the Python version requirement and the available install extras from the installed jax package metadata
import importlib.metadata as md_
meta = md_.metadata("jax")
extras = meta.get_all("Provides-Extra")
print("jax version:", meta["Version"])
print("Requires-Python:", meta["Requires-Python"])
print("Available install extras (pip install 'jax[xxx]'):", extras)
assert meta["Requires-Python"] == ">=3.12"
assert {"cuda12", "cuda13", "tpu"} <= set(extras)
assert "cuda11_cudnn82" not in extras
assert "jaxlib" in " ".join(meta.get_all("Requires-Dist")) # jaxlib is installed automatically as a dependency
Verify the installation: run a small script to confirm that JAX can find a device and complete a computation. Below we first run the old style from the course example (using PRNGKey), then give the recommended version that uses jax.random.key. The number the script prints at the end is the sum of squares of some random numbers. Its exact value depends on the JAX version and RNG configuration, so it's normal for yours to differ from someone else's.
⚠️ Note: The course example gives
10.811179161071777as the result. On JAX 0.12 (CPU), the same seed gives9.738718.... Even switchingjax_threefry_partitionableback toFalseonly gives2.658...; neither matches (verified in the cells below). JAX only guarantees reproducibility within the same version and configuration, so don't hard-code specific random values in tests.
# ❌ Old style (outdated): the old PRNGKey; also, the example output 10.811179161071777 can't be reproduced on JAX 0.12
COURSE_VALUE = 10.811179161071777
try:
key_old = jax.random.PRNGKey(0)
x_old = jax.random.normal(key_old, (10,))
y_course = float(jnp.dot(x_old, x_old))
print(f"Value given in the original example: {COURSE_VALUE}")
print(f"Actual output : {y_course}")
except Exception as e:
print(f"Error: {type(e).__name__}: {e}")
assert abs(y_course - COURSE_VALUE) > 1e-3 # the value in the example can't be reproduced
# ✅ Recommended: an installation check script (using jax.random.key)
try:
devices = jax.devices()
print("JAX found these devices:")
for i, device in enumerate(devices):
print(f"{i}: {device.platform.upper()} ({device.device_kind})")
# Try a simple computation
key = jax.random.key(0)
x = jax.random.normal(key, (10,))
y = jnp.dot(x, x)
print(f"\nRan a simple JAX operation successfully. Result: {y}")
# Check the default backend
print(f"\nDefault backend: {jax.default_backend()}")
except Exception as e:
print("An error occurred while verifying JAX:")
print(e)
print("\nPlease check the installation steps, especially the GPU driver / CUDA version (if applicable).")
# Check: the result matches an independent NumPy computation, and the old and new key styles give the same random numbers
assert np.isclose(float(y), np.dot(np.asarray(x), np.asarray(x)), rtol=1e-5)
assert np.isclose(float(y), y_course)
There are three possible kinds of output: CPU only (0: CPU (cpu), default backend cpu); a single GPU (such as 0: GPU (NVIDIA ...), default backend gpu); or multiple GPUs or TPUs (several devices are listed).
# Extra demo: the exact random values depend on the RNG configuration (toggle jax_threefry_partitionable in a subprocess)
rng_demo = '''
import jax, jax.numpy as jnp
jax.config.update("jax_threefry_partitionable", {flag})
x = jax.random.normal(jax.random.key(0), (10,))
print(float(jnp.dot(x, x)))
'''
values = {}
for flag in (True, False):
r = subprocess.run([sys.executable, "-c", rng_demo.format(flag=flag)], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
values[flag] = float(r.stdout)
print(f"jax_threefry_partitionable={flag!s:5}: dot(x, x) = {values[flag]}")
print(f"Value given in the original example : {COURSE_VALUE}")
assert values[True] != values[False] # different config, different random numbers
assert all(abs(v - COURSE_VALUE) > 1e-3 for v in values.values()) # neither matches the value in the example
🔍 Extra (1.4): Troubleshooting your environment¶
⚪ Optional: Read this when something is wrong with your environment and you need to file an issue or ask for help.
jax.print_environment_info() prints the jax / jaxlib / numpy / Python versions and device info. When filing an issue or asking for help, pasting its output is the easiest way to go.
jax.print_environment_info()
# Creating arrays
print(f"JAX version: {jax.__version__}")
# Create an array from a list
python_list = [1.0, 2.0, 3.0]
jax_array_from_list = jnp.array(python_list)
print(f"Array created from a list: {jax_array_from_list}")
print(f"Type: {type(jax_array_from_list)}")
assert isinstance(jax_array_from_list, jax.Array)
# Create a 2D array from a nested list
python_nested_list = [[1, 2], [3, 4]]
jax_2d_array = jnp.array(python_nested_list)
print(f"2D array:\n{jax_2d_array}")
# Array of zeros
zeros_array = jnp.zeros((2, 3)) # shape (2 rows, 3 columns)
print(f"Zeros array:\n{zeros_array}")
# Array of ones
ones_array = jnp.ones((3,), dtype=jnp.int32) # shape (3,), integer type
print(f"Ones array: {ones_array}")
print(f"Ones array dtype: {ones_array.dtype}")
# Evenly spaced integer sequence
range_array = jnp.arange(5) # like Python's range(5) -> [0, 1, 2, 3, 4]
print(f"arange array: {range_array}")
# Evenly spaced points in an interval
linspace_array = jnp.linspace(0, 1, 5) # 5 points from 0 to 1 (endpoints included)
print(f"linspace array: {linspace_array}")
assert zeros_array.dtype == jnp.float32 and ones_array.dtype == jnp.int32
Random numbers¶
Random numbers in JAX work very differently from NumPy. NumPy relies on a hidden global state that every call to np.random.rand() quietly updates. That's a side effect, and it clashes with the pure-function approach. JAX uses explicit pseudo-random number generator (PRNG) keys instead:
- Create a key with
jax.random.key(seed); - A random function takes a key, its output depends only on that key, it doesn't modify the key, and there is no global state;
- Same key → same random numbers (reproducible);
- When you need new random numbers, use
jax.random.splitto split the key into new subkeys.
⚠️ Note: A lot of older code creates keys with
random.PRNGKey(0). That old API still works, but the official recommendation for new code isjax.random.key(0). Both produce exactly the same random numbers; the differences are compared below. Also, saying "this operation consumes the key" is misleading: random functions don't change or "use up" a key. The real rule is: don't reuse the same key to generate another independent set of random numbers.
from jax import random
# Create the initial key (recommended style); the seed is usually an integer
key = random.key(0)
print(f"Initial key: {key.dtype}, underlying data {random.key_data(key)}")
# Generate normally distributed random numbers. The random function "uses" the key but doesn't modify it
normal_random_numbers = random.normal(key, shape=(2, 2))
print(f"Normal random numbers:\n{normal_random_numbers}")
# To generate more random numbers, split the key first
key, subkey = random.split(key) # gives a new key and a subkey
uniform_random_numbers = random.uniform(subkey, shape=(3,))
print(f"\nKey after splitting: {random.key_data(key)}")
print(f"Subkey: {random.key_data(subkey)}")
print(f"Uniform random numbers: {uniform_random_numbers}")
# Calling again with the "same" original key gives exactly the same result -- purity and reproducibility
same_normal_numbers = random.normal(random.key(0), shape=(2, 2))
print(f"\nSame normal random numbers:\n{same_normal_numbers}")
assert jnp.allclose(normal_random_numbers, same_normal_numbers)
How jax.random.key differs from jax.random.PRNGKey
jax.random.PRNGKey(0) (old) | jax.random.key(0) (new, recommended) | |
|---|---|---|
| Return value | A plain uint32 array of shape (2,) | A typed key array with dtype key<fry> and shape () |
| Can it be mistaken for data in arithmetic? | Yes (key + 1 doesn't raise, which invites subtle bugs) | No, it raises an error right away |
| Shape of a batch of keys | split(k, 3) → (3, 2), with an extra "implementation detail" dimension | split(k, 3) → (3,), an intuitive shape |
| Switching the RNG algorithm | PRNGKey(0, impl="rbg") also works, but you only get raw uint32[4] data; the algorithm isn't stored with the array, so every later use depends on the global config or on you remembering it (otherwise it raises an error) | key(0, impl="rbg"); the algorithm is recorded in the dtype (key<rbg>) and used automatically from then on |
| Getting the underlying data | It already is the data | jax.random.key_data(k) |
When you need to interoperate with old code, use jax.random.wrap_key_data(raw) to wrap an old-style uint32 array as a new-style key. For more on managing random numbers (the different PRNG algorithms, serialization, key management inside scan/vmap), see Advanced 10: Random numbers and sharding.
# key vs. PRNGKey demo (apart from the ❌ comparison cell in 1.4, this is the only place PRNGKey appears)
old_key = jax.random.PRNGKey(0)
new_key = jax.random.key(0)
print(f"PRNGKey(0): {old_key}, dtype={old_key.dtype}, shape={old_key.shape}")
print(f"key(0) : dtype={new_key.dtype}, shape={new_key.shape}")
print(f"key_data(key(0)) = {jax.random.key_data(new_key)}")
# They generate the same random numbers
same = jnp.array_equal(random.normal(old_key, (3,)), random.normal(new_key, (3,)))
print("Same random numbers:", same)
# Shapes after batch splitting
print("split(PRNGKey, 3).shape =", random.split(old_key, 3).shape)
print("split(key, 3).shape =", random.split(new_key, 3).shape)
# An old-style key can be mistaken for data in arithmetic; a new-style key raises an error
print("Old-style key + 1 =", old_key + 1, "(silently succeeds, which is dangerous)")
try:
new_key + 1
except TypeError as e:
print("New-style key + 1 raises:", str(e).splitlines()[0])
assert same
assert old_key.dtype == jnp.uint32 and old_key.shape == (2,)
assert new_key.shape == () and jax.dtypes.issubdtype(new_key.dtype, jax.dtypes.prng_key)
assert random.split(new_key, 3).shape == (3,)
assert bool(jax.random.wrap_key_data(old_key) == new_key) # converting between old and new
# Both styles can choose the RNG algorithm, but only the typed key records it in the dtype
raw_rbg = jax.random.PRNGKey(0, impl="rbg")
typed_rbg = jax.random.key(0, impl="rbg")
print("PRNGKey(0, impl='rbg'):", raw_rbg.dtype, raw_rbg.shape, "| key(0, impl='rbg'):", typed_rbg.dtype)
assert raw_rbg.dtype == jnp.uint32 and raw_rbg.shape == (4,)
assert "rbg" in str(typed_rbg.dtype)
try:
random.normal(raw_rbg, (2,)) # raw data carries no algorithm info: it's read as the default threefry, and the shape doesn't match
raw_ok = True
except TypeError:
raw_ok = False
assert not raw_ok
random.normal(typed_rbg, (2,)) # a typed key carries its algorithm, so it just works
🔍 Extra (1.5-a): The number one random-number mistake: reusing a key¶
🟢 Must-read
Never use the same key twice. If you use the same key, the same distribution, and the same shape to generate two "independent" sets of random numbers, they come out identical. Even with different distributions, both sets come from the same stream of random bits and are highly correlated (for example, normal and uniform results from the same key are monotonically related), so they still aren't independent.
The standard practice: before each use of randomness, split first, generate the random numbers with the subkey, pass the new main key along, and never use the old key again. When you need several keys at once, use split(key, n). Inside a loop you can also derive keys from the step number with jax.random.fold_in(key, step). For more examples of "reuse causes correlation", see Advanced 10 §6.1.
key = jax.random.key(42)
# Wrong: the same key used twice
w1 = random.normal(key, (3,))
w2 = random.normal(key, (3,))
print("Reused key -> the two sets of \"random\" numbers are identical:", jnp.array_equal(w1, w2))
assert jnp.array_equal(w1, w2)
# Right: split off independent subkeys
key, k1, k2 = random.split(key, 3)
w1, w2 = random.normal(k1, (3,)), random.normal(k2, (3,))
print("Split subkeys -> different:", not jnp.array_equal(w1, w2))
assert not jnp.array_equal(w1, w2)
# Derive keys from the step number with fold_in inside a loop
base = jax.random.key(0)
for step in range(3):
print(f"step {step}:", random.uniform(jax.random.fold_in(base, step), (2,)))
# Switching to a different distribution doesn't help: uniform and normal from the same key sort in exactly the same order (monotonically related)
u_same, z_same = random.uniform(key, (1000,)), random.normal(key, (1000,))
print("uniform and normal from the same key sort the same way:", bool(jnp.all(jnp.argsort(u_same) == jnp.argsort(z_same))))
assert jnp.all(jnp.argsort(u_same) == jnp.argsort(z_same))
Array attributes¶
Like NumPy arrays, JAX arrays have attributes such as shape, dtype, ndim, and size. JAX uses 32-bit precision by default (float32 / int32), which is usually faster on accelerators. Turning on 64-bit precision makes things slower and uses more memory.
x = jnp.arange(12).reshape((3, 4))
print(f"Array x:\n{x}")
print(f"Shape: {x.shape}") # tuple of dimension sizes (rows, columns)
print(f"Dtype: {x.dtype}") # element type, e.g. float32, int32
print(f"Number of dimensions: {x.ndim}") # number of axes (2 for a matrix)
print(f"Total elements: {x.size}") # total number of elements, 3 * 4 = 12
assert x.shape == (3, 4) and x.dtype == jnp.int32 and x.ndim == 2 and x.size == 12
Immutability and functional updates¶
JAX arrays are immutable. Trying to assign in place raises TypeError. The right approach is the .at[...] family of methods, which return a new array and leave the original unchanged.
⚠️ Note: The NumPy comparison code below uses
np.array. If you copy it out and run it on its own, remember toimport numpy as npfirst, or you'll get aNameError(the ❌ cell below reproduces this error in an empty namespace).
# ❌ Old style (outdated): the code uses np without import numpy as np, so running it directly raises NameError
course_src = '''
numpy_array = np.array([1, 2, 3])
numpy_array[0] = 100
'''
try:
exec(course_src, {}) # run in an empty namespace to simulate copying this code out and running it alone
course_ok = True
except NameError as e:
course_ok = False
print(f"Observed: NameError: {e}")
assert not course_ok
# ✅ Recommended: import NumPy first (this tutorial already does import numpy as np at the top)
import numpy as np
numpy_array = np.array([1, 2, 3])
numpy_array[0] = 100 # fine in NumPy
print(f"Modified NumPy array: {numpy_array}")
jax_array = jnp.array([1, 2, 3])
try:
# raises an error, because JAX arrays are immutable
jax_array[0] = 100
except TypeError as e:
print(f"\nError when trying to modify a JAX array in place: {e}")
# Use the functional "indexed update" syntax instead, which returns a new array
updated_jax_array = jax_array.at[0].set(100)
print(f"\nOriginal JAX array (unchanged): {jax_array}")
print(f"Updated JAX array (new object): {updated_jax_array}")
# Other kinds of updates are available too
incremented_array = jax_array.at[1].add(10) # add 10 to the element at index 1
print(f"Incremented array: {incremented_array}")
assert jnp.array_equal(jax_array, jnp.array([1, 2, 3]))
assert jnp.array_equal(updated_jax_array, jnp.array([100, 2, 3]))
assert jnp.array_equal(incremented_array, jnp.array([1, 12, 3]))
🔍 Extra (1.5-b): The full .at[] family and the "copy cost"¶
⚪ Optional: Read this if you need scatter-style updates (such as histogram counting) or care about the copy cost of
.at[].
- The methods:
set,add,subtract,multiply,divide,power,min,max,apply,get. The index can be an integer, a slice, an integer array, or a boolean mask. Boolean masks don't work insidejitbecause the result shape isn't fixed; usejnp.wherethere instead. - Repeated indices accumulate:
x.at[[0, 0]].add(1)adds 2 tox[0], while NumPy'sa[[0, 0]] += 1only adds 1 (to accumulate you neednp.add.at). Exercise E4 uses this property. - "Doesn't copying the whole array every time get slow?" Inside a
jit-compiled function, if the array being updated is an intermediate result that isn't used afterward, XLA usually optimizes.at[].setinto an in-place update with no extra copy. But if what you update is an input argument of the jit function, the caller still holds the original array, so XLA has to copy it first. To skip that copy, usedonate_argnumsto "donate" the input buffer (see thedonate_argnumsExtra in Chapter 2, section 2.6). Withoutjit, running one operation at a time, every update makes a copy. The cell below verifies this by inspecting the compiled HLO.
x = jnp.zeros(5)
print("set :", x.at[1:3].set(7.0))
print("multiply:", jnp.arange(5.).at[::2].multiply(10))
print("max :", jnp.arange(5.).at[:].max(2.0))
print("apply :", jnp.arange(5.).at[2].apply(jnp.sqrt))
# Repeated indices: JAX accumulates, NumPy doesn't
idx = jnp.array([0, 0, 2])
jax_acc = jnp.zeros(3).at[idx].add(1)
print("\nJAX .at[[0,0,2]].add(1):", jax_acc)
a_np = np.zeros(3); a_np[[0, 0, 2]] += 1
print("NumPy a[[0,0,2]] += 1 :", a_np)
b_np = np.zeros(3); np.add.at(b_np, [0, 0, 2], 1)
print("NumPy np.add.at :", b_np)
assert jnp.array_equal(jax_acc, jnp.array([2., 0., 1.]))
assert np.array_equal(a_np, [1., 0., 1.]) and np.array_equal(b_np, jax_acc)
# Inside jit, use jnp.where instead of boolean-mask assignment
relu = jax.jit(lambda v: jnp.where(v > 0, v, 0.0))
out = relu(jnp.array([-1.0, 2.0, -3.0, 4.0]))
print("\nReLU with jnp.where:", out)
assert jnp.array_equal(out, jnp.array([0., 2., 0., 4.]))
# In-place updates: check the compiled HLO for copy instructions
def hlo_has_copy(fn, *args):
return " copy(" in fn.lower(*args).compile().as_text()
v0 = jnp.zeros(1000)
assert not hlo_has_copy(jax.jit(lambda v: (v * 2).at[0].set(1.0)), v0) # updating an intermediate: in place
assert hlo_has_copy(jax.jit(lambda v: v.at[0].set(1.0)), v0) # updating an input argument: copies first
assert not hlo_has_copy(jax.jit(lambda v: v.at[0].set(1.0), donate_argnums=0), v0) # after donating the input: in place
print("\nHLO check: no copy when updating an intermediate; a copy when updating an input argument; no copy with donate_argnums")
Operations¶
JAX supports the same element-wise operations as NumPy, matrix multiplication (jnp.dot or @), and universal functions (jnp.sin, jnp.exp, and so on).
a = jnp.array([[1, 2], [3, 4]])
b = jnp.array([[5, 6], [7, 8]])
# Element-wise operations
print(f"a + b:\n{a + b}")
print(f"a * b:\n{a * b}") # element-wise multiplication
# Matrix multiplication
print(f"Matrix product (jnp.dot):\n{jnp.dot(a, b)}")
print(f"Matrix product (@):\n{a @ b}")
# Universal functions
print(f"Sine of a:\n{jnp.sin(a)}")
print(f"Exponential of a:\n{jnp.exp(a)}")
assert jnp.array_equal(a @ b, jnp.array([[19, 22], [43, 50]]))
assert np.allclose(jnp.exp(a), np.exp(np.asarray(a)))
Indexing and slicing¶
Basic indexing and slicing work just like in NumPy. The difference: a JAX slice returns a new array, not a view that shares memory the way NumPy does. (Since you can't modify in place anyway, whether it's a view doesn't matter semantically.)
data = jnp.arange(10)
print(f"Original data: {data}")
# Get a single element
print(f"Element at index 3: {data[3]}")
# Get a slice
print(f"Slice from index 2 to 5: {data[2:5]}")
# Multi-dimensional indexing
matrix = jnp.arange(9).reshape((3, 3))
print(f"Matrix:\n{matrix}")
print(f"Element at row 1, column 2: {matrix[1, 2]}")
print(f"First two rows:\n{matrix[:2, :]}")
print(f"First column:\n{matrix[:, 0]}")
assert data[3] == 3 and matrix[1, 2] == 5
assert jnp.array_equal(matrix[:, 0], jnp.array([0, 3, 6]))
Checking which device an array is on¶
To find out which device an array lives on, use the attribute x.device (returns a single device). If the array may be spread over several devices, use the method x.devices() (returns a set of devices).
⚠️ Note: Older code often writes
x.device(), sometimes with anexcept AttributeErrorfallback, on the grounds that "older JAX versions may not support.device()directly". The truth is the opposite: it was newer JAX that changeddevicefrom a method to an attribute. Callingx.device()in JAX 0.12 raisesTypeError: 'Device' object is not callable, whichexcept AttributeErrorcan't catch at all.
# ❌ Old style (outdated): x.device() is now an attribute; an except AttributeError fallback doesn't catch the new error
x = jnp.ones(3)
escaped = None
try:
try:
print(f"Device type of array x: {x.device().platform}")
except AttributeError:
print("Device info may need a specific context, or check jax.devices().") # fallback branch, never runs
except TypeError as e:
escaped = e
print(f"The TypeError was not caught by except AttributeError: {e}")
assert isinstance(escaped, TypeError)
# ✅ Recommended
x = jnp.ones(3)
print(f"x.device -> {x.device} (attribute, no parentheses)")
print(f"x.device.platform -> {x.device.platform}")
print(f"x.devices() -> {x.devices()} (method, returns a set of devices)")
print(f"Available devices: {jax.devices()}")
assert isinstance(x.device, jax.Device)
assert x.devices() == {x.device}
assert x.device.platform == jax.default_backend()
1.6 Device management: CPU, GPU, TPU¶
One big advantage of JAX: thanks to XLA compilation, the same NumPy-style code runs on CPU, GPU, and TPU without changes.
Listing available devices¶
When JAX starts, it automatically detects which hardware backends are available and picks one by priority, TPU > GPU > CPU by default. If it finds no accelerator, it falls back to the CPU.
# List all available devices
devices = jax.devices()
print(f"Available devices: {devices}")
# Default backend
print(f"Default backend: {jax.default_backend()}")
# Details for each device
for d in devices:
print(f" id={d.id}, platform={d.platform}, kind={d.device_kind}")
assert all(d.platform == jax.default_backend() for d in devices)
Typical output:
# CPU only
Available devices: [CpuDevice(id=0)]
Default backend: cpu
# With an NVIDIA GPU
Available devices: [CudaDevice(id=0)]
Default backend: gpu
# With a TPU
Available devices: [TpuDevice(id=0, ...), TpuDevice(id=1, ...), ...]
Default backend: tpu
Note: when an accelerator is present, jax.devices() lists only the devices of the default backend. To get CPU devices, ask for them separately with jax.devices("cpu").
Data placement¶
By default, JAX arrays are created on the default device (the first GPU, if you have one); check with the attribute x.device. Don't write x.device(); that style is outdated. See the note in 1.5 and "How to write x.device() today" below.
x = jnp.arange(10.0)
print(f"Array x is on: {x.device}")
assert x.device == jax.devices()[0]
How to write x.device() today¶
Older material often has a line like this:
print("Device:", random_array.device())
In newer versions it raises an error, because newer JAX changed Array.device from a method to an attribute (to match the Python array API standard), and calling it with parentheses raises TypeError. There are now three options; pick the one you need:
| Style | Returns | When to use |
|---|---|---|
random_array.device | A single Device | Most common; when the array lives on one device |
random_array.devices() | set[Device] | When the array may be sharded across several devices |
random_array.sharding | A Sharding object | When you need the full placement/sharding info (used in the parallelism chapters later) |
# ❌ Old style (outdated): Array.device changed from a method to an attribute; calling it with parentheses raises TypeError
random_array = jax.random.uniform(jax.random.key(42), shape=(2, 2))
try:
print("Device:", random_array.device())
old_api_works = True
except TypeError as e:
old_api_works = False
print(f"Observed: TypeError: {e}")
assert not old_api_works
# ✅ Recommended: three ways to look
print("random_array.device ->", random_array.device)
print("random_array.devices() ->", random_array.devices())
print("random_array.sharding ->", random_array.sharding)
assert random_array.devices() == {random_array.device}
assert random_array.sharding.device_set == random_array.devices()
assert random_array.sharding.is_fully_replicated # single-device array: all the data is on this one device
jax.device_put lets you place data on a specific device explicitly. The example below assumes several devices and uses devices[0] and devices[1]. On a machine with only one device, devices[1] doesn't exist, so it's wrapped in a try/except:
devices = jax.devices()
x = jnp.arange(10.0)
# Put it on the first device
x_dev0 = jax.device_put(x, devices[0])
print(f"x_dev0 is on: {x_dev0.device}")
# Put it on the second device (needs at least two devices)
try:
x_dev1 = jax.device_put(x, devices[1])
print(f"x_dev1 is on: {x_dev1.device}")
except IndexError:
print(f"Only {len(devices)} device(s), can't place on devices[1]")
# Put it on the CPU explicitly (useful when you have a GPU)
cpu_device = jax.devices("cpu")[0]
x_cpu = jax.device_put(x, cpu_device)
print(f"x_cpu is on: {x_cpu.device}")
assert x_cpu.device == cpu_device
# Without a device specified, new arrays go on the default device
y = jnp.ones(5)
print(f"y is on the default device: {y.device}")
# Asking for a backend that doesn't exist raises RuntimeError
try:
jax.devices("gpu")
except RuntimeError as e:
print(f"\njax.devices('gpu') raises: {e}")
Computing across devices¶
When the arrays in an operation live on different devices, what JAX does depends on whether each array is committed to a device:
- An array placed explicitly with
jax.device_put(x, device)is committed; - An array created the default way (for example
jnp.ones(4)) is uncommitted, and JAX moves it automatically to the device where the computation runs when needed (with a transfer cost); - If two arrays committed to different devices are used together, JAX does not move them for you; it raises
ValueError. You first have todevice_putthem onto the same device.
⚠️ Note: Computing across devices is not just "a bit more transfer overhead". In JAX 0.12, adding two arrays committed to different devices raises
ValueError: Received incompatible devices for jitted computationright away; JAX won't move the data for you.
This machine has only one CPU, so below we use the jax_num_cpu_devices setting to simulate 4 CPU devices for the demo. This setting must be applied before JAX initializes its backends, so it runs in a subprocess. The same trick is handy for debugging multi-device code on a laptop.
multi_dev_demo = '''
import jax
jax.config.update("jax_num_cpu_devices", 4) # simulate 4 CPU devices; must be set before using JAX
import jax.numpy as jnp
devs = jax.devices()
assert len(devs) == 4
print("Simulated devices:", devs)
x = jnp.arange(4.0)
a = jax.device_put(x, devs[1]) # committed to cpu:1
b = jax.device_put(x, devs[2]) # committed to cpu:2
print("a is on", a.device, ", b is on", b.device)
assert (a + 1).device == devs[1]
assert (a + jnp.ones(4)).device == devs[1] # the uncommitted array is moved to cpu:1 automatically
print("a + jnp.ones(4) (the uncommitted array is moved over automatically) is on", (a + jnp.ones(4)).device)
raised = False
try:
a + b
except ValueError as e:
raised = True
print("a + b raises ValueError:", str(e)[:70], "...")
assert raised
# The right way: move the data onto the same device explicitly first
c = a + jax.device_put(b, devs[1])
assert c.device == devs[1] and jnp.array_equal(c, 2 * x)
print("After moving to the same device, a + b =", c, ", on", c.device)
print("ALL_OK")
'''
r = subprocess.run([sys.executable, "-c", multi_dev_demo], capture_output=True, text=True)
print(r.stdout or r.stderr)
assert r.returncode == 0 and "ALL_OK" in r.stdout
Host and device¶
- Host: the CPU and its memory. Ordinary Python objects and NumPy arrays live in host memory.
- Device: an accelerator (GPU / TPU) and its memory. JAX arrays live in device memory.
The recommended workflow:
- Load and preprocess data on the host (usually with NumPy);
- Transfer the data to the device once with
jax.device_put(orjnp.asarray); - Do the core computation on the device with
jit/grad/vmapand friends; - Transfer results back to the host only when you need them (printing, saving, plotting).
Transfers between host and device are often the performance bottleneck, so move data as little as you can.
🔍 Extra (1.6)¶
⚪ Optional: Read this if you train on a GPU, need to control which device data lives on, or share a GPU with other frameworks.
(1) Device → host: use np.asarray(x) or jax.device_get(x) to bring data back to the host; for scalars use x.item() or float(x). Note that all of these block until the computation finishes. Calling them on every step of a training loop slows it down (for example, print(loss) on every step).
(2) Temporarily switching the default device: with jax.default_device(jax.devices("cpu")[0]): ... puts arrays created inside the block on the CPU, which is handy for keeping some small computations on the CPU.
(3) Two common environment variables (they must take effect before JAX initializes its backends; the safest option is to set them before starting Python):
JAX_PLATFORMS=cpu: force CPU only. Useful for debugging or comparing results when you have a GPU;XLA_PYTHON_CLIENT_PREALLOCATE=false: on GPU, JAX preallocates about 75% of GPU memory by default, which easily leads to OOM when sharing a GPU with PyTorch and the like. Setting this variable switches to on-demand allocation (you can also cap the fraction withXLA_PYTHON_CLIENT_MEM_FRACTION=.5).
x = jnp.arange(4.0) * 2
host_np = np.asarray(x)
host_get = jax.device_get(x)
print("np.asarray ->", type(host_np), host_np)
print("jax.device_get ->", type(host_get), host_get)
print("scalar .item() ->", x.sum().item(), type(x.sum().item()))
assert isinstance(host_get, np.ndarray) and isinstance(x.sum().item(), float)
with jax.default_device(jax.devices("cpu")[0]):
z = jnp.zeros(2)
print("Array created inside the default_device block is on:", z.device)
assert z.device == jax.devices("cpu")[0]
1.7 Hands-on: basic array operations¶
This section puts everything so far into practice: creating, inspecting, computing with, indexing, functionally updating, reducing, and reshaping arrays. Keep in mind the two key differences between JAX and NumPy: arrays are immutable, and random numbers need explicit keys.
Exercise 1: checking the environment and creating arrays¶
# Combined exercise: array creation, attributes, and devices
# numpy is usually for comparison, or for operations JAX doesn't support (already imported at the top)
print(f"JAX version: {jax.__version__}")
print(f"JAX default backend: {jax.default_backend()}")
# Check available devices (depends on your environment)
print(f"Available JAX devices: {jax.devices()}")
An array created from a list has type jax.Array (the concrete class shows up as jaxlib._jax.ArrayImpl). Whether on CPU, GPU, or TPU, the default float type is float32. For 64 bits you must turn on jax_enable_x64 (see 1.2).
⚠️ Note: Older material often says the output type is "usually
jaxlib.xla_extension.DeviceArrayor similar", and that "the default is 64-bit floats on CPU and 32-bit on GPU/TPU". Neither holds in JAX 0.12:DeviceArraywas removed long ago and the type isjax.Array; the default dtype on CPU is alsofloat32(verified withassertin the cell below).
# Create from a Python list
py_list = [1.0, 2.5, 3.0, 4.2]
jax_array_from_list = jnp.array(py_list)
print("JAX array created from a list:", jax_array_from_list)
print("Type:", type(jax_array_from_list))
print("Dtype:", jax_array_from_list.dtype)
# Check: the type is jax.Array, and the default on CPU is float32 too
assert isinstance(jax_array_from_list, jax.Array)
assert "DeviceArray" not in type(jax_array_from_list).__name__
assert jax_array_from_list.dtype == jnp.float32
# Create specific arrays
zeros_array = jnp.zeros((2, 3)) # shape (2 rows, 3 columns)
print("\nZeros array:\n", zeros_array)
ones_array = jnp.ones((3, 2), dtype=jnp.int32) # specify the dtype
print("\nOnes array (int32):\n", ones_array)
print("Dtype:", ones_array.dtype)
range_array = jnp.arange(0, 10, 2) # start, stop (exclusive), step
print("\nRange array:", range_array)
linspace_array = jnp.linspace(0, 1, 5) # start, stop (inclusive), number of points
print("\nEvenly spaced array:", linspace_array)
assert jnp.array_equal(range_array, jnp.array([0, 2, 4, 6, 8]))
assert jnp.allclose(linspace_array, jnp.array([0, .25, .5, .75, 1.]))
Exercise 2: random numbers and inspecting arrays¶
# Create a PRNG key
key = jax.random.key(42) # seed 42
# Generate uniform random numbers in [0, 1)
random_array = jax.random.uniform(key, shape=(2, 2))
print("Random array (uniform):\n", random_array)
# Important: to get new random numbers, you must "split" the key first
key, subkey = jax.random.split(key)
random_normal_array = jax.random.normal(subkey, shape=(3,)) # standard normal distribution
print("\nRandom array (normal):\n", random_normal_array)
assert ((random_array >= 0) & (random_array < 1)).all()
print("Inspecting random_array:")
print("Shape:", random_array.shape)
print("Size:", random_array.size) # total number of elements
print("Number of dimensions:", random_array.ndim)
print("Dtype:", random_array.dtype)
# Check which device the array is on (attribute, no parentheses; see 1.6 for the old .device() style)
print("Device:", random_array.device)
assert random_array.shape == (2, 2) and random_array.size == 4 and random_array.ndim == 2
assert random_array.dtype == jnp.float32
Exercise 3: arithmetic and immutability¶
Every operation returns a new array; the original array a stays unchanged.
a = jnp.array([1, 2, 3])
b = jnp.array([4, 5, 6])
# Element-wise operations
c_add = a + b
print("a + b =", c_add)
c_mul = a * b
print("a * b =", c_mul)
# Operations with scalars (broadcasting)
c_scalar_add = a + 10
print("a + 10 =", c_scalar_add)
c_scalar_mul = a * 2
print("a * 2 =", c_scalar_mul)
# Check that a is unchanged (immutability)
print("Original 'a':", a)
assert jnp.array_equal(a, jnp.array([1, 2, 3]))
assert jnp.array_equal(c_mul, jnp.array([4, 10, 18]))
# Matrix multiplication
mat_a = jnp.array([[1, 2], [3, 4]])
mat_b = jnp.array([[5, 6], [7, 8]])
mat_prod = mat_a @ mat_b # same as jnp.matmul(mat_a, mat_b)
print("Matrix product mat_a @ mat_b:\n", mat_prod)
assert jnp.array_equal(mat_prod, jnp.matmul(mat_a, mat_b))
assert np.array_equal(mat_prod, np.asarray(mat_a) @ np.asarray(mat_b))
Exercise 4: indexing, slicing, and functional updates¶
data = jnp.arange(12).reshape((3, 4))
print("Original data:\n", data)
# A single element
print("\nElement at row 1, column 2:", data[1, 2])
# A whole row
print("Row 0:", data[0, :])
# A whole column
print("Column 1:", data[:, 1])
# A sub-array
print("Sub-array (first 2 rows, columns 1-2):\n", data[:2, 1:3])
# Direct assignment isn't supported
try:
data[0, 0] = 99
except TypeError as e:
print("\nDirect assignment raises:", str(e).splitlines()[0])
# Functional update: returns a new array; this is the style that works with transformations like jit and grad
updated_data = data.at[0, 0].set(99)
print("\nUpdated data:\n", updated_data)
print("Original data (unchanged):\n", data)
assert data[0, 0] == 0 and updated_data[0, 0] == 99
assert jnp.array_equal(data[:2, 1:3], jnp.array([[1, 2], [5, 6]]))
Exercise 5: universal functions (ufuncs)¶
Universal functions like jnp.sin and jnp.exp act element-wise on arrays. The plot below shows values generated with jnp.linspace and then transformed by jnp.sin (matching the figure on the course page).
import matplotlib.pyplot as plt
x_vals = jnp.linspace(0, 2 * jnp.pi, 5)
print("x:", x_vals)
print("sin(x):", jnp.sin(x_vals))
print("exp(x):", jnp.exp(x_vals))
assert jnp.allclose(jnp.sin(x_vals), jnp.array([0., 1., 0., -1., 0.]), atol=1e-6)
# Plot the sin curve with denser samples and mark the 5 points above
x_dense = jnp.linspace(0, 2 * jnp.pi, 200)
fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x_dense, jnp.sin(x_dense), label="sin(x)")
ax.scatter(x_vals, jnp.sin(x_vals), color="C1", zorder=3, label="linspace(0, 2π, 5)")
ax.axhline(0, color="gray", lw=0.5)
ax.set_xlabel("x"); ax.set_ylabel("sin(x)"); ax.legend()
plt.tight_layout(); plt.show()
Exercise 6: reductions¶
jnp.sum, jnp.mean, jnp.max, and friends can reduce the whole array, or along a direction given by axis. For a 2D array, axis=0 collapses along the rows and gives a result per column; axis=1 collapses along the columns and gives a result per row.
m = jnp.arange(1, 7).reshape((2, 3))
print("Matrix m:\n", m)
print("\nSum of everything:", jnp.sum(m))
print("Sum per column (axis=0):", jnp.sum(m, axis=0))
print("Sum per row (axis=1):", jnp.sum(m, axis=1))
print("Mean:", jnp.mean(m))
print("Max per row (axis=1):", jnp.max(m, axis=1))
# The method style works too
print("m.sum(axis=0):", m.sum(axis=0))
assert jnp.sum(m) == 21 and jnp.mean(m) == 3.5
assert jnp.array_equal(jnp.sum(m, axis=0), jnp.array([5, 7, 9]))
assert jnp.array_equal(jnp.sum(m, axis=1), jnp.array([6, 15]))
Exercise 7: reshaping¶
reshape changes an array's shape without changing its data, and it also returns a new array. The total number of elements must stay the same. You can use -1 to let JAX infer one dimension.
v = jnp.arange(6)
print("Original:", v)
print("reshape to (2, 3):\n", v.reshape((2, 3)))
print("jnp.reshape to (3, 2):\n", jnp.reshape(v, (3, 2)))
print("Infer with -1, (3, -1) -> shape:", v.reshape((3, -1)).shape)
assert v.reshape((3, -1)).shape == (3, 2)
try:
v.reshape((4, 2))
except TypeError as e:
print("\nError when the total number of elements doesn't match:", str(e).splitlines()[0])
Extra exercises¶
The 4 extra exercises below cover cross-checking against NumPy, random number discipline, timing correctly, and practical use of functional updates. Try writing them yourself first, then look at the reference solutions.
Exercise E1: standardize rows and cross-check against NumPy
Given a random matrix X of shape (5, 4) (generated with jax.random.key(0)), write a function standardize_rows(X) that makes each row have mean 0 and standard deviation 1:
$$ Z_{ij} = \frac{X_{ij} - \mu_i}{\sigma_i + \varepsilon},\quad \varepsilon = 10^{-8} $$
Requirements: (1) use only jnp, broadcasting with keepdims=True; (2) write the same function in NumPy and check that the two agree; (3) check that each row's mean is about 0 and its standard deviation is about 1.
Self-check interface: define standardize_rows(X), returning a JAX array with the same shape as X.
# ✍️ Your answer: Exercise E1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise E1
try:
standardize_rows
except NameError as e:
print(f"⏳ Exercise E1: not answered yet: {e}")
else:
_X = (jax.random.uniform(jax.random.key(11), (6, 9)) * jnp.arange(1.0, 7.0)[:, None]
+ 5.0 * jnp.arange(6.0)[:, None]) # each row has a different scale and offset
_Z = standardize_rows(_X)
assert isinstance(_Z, jax.Array), "standardize_rows should use only jnp and return a JAX array"
assert _Z.shape == _X.shape, f"Output shape should match the input {_X.shape}, got {_Z.shape}"
_Zn = np.asarray(_Z, dtype=np.float64)
assert np.allclose(_Zn.mean(axis=1), 0, atol=1e-5), "Each row's mean should be about 0 (did you standardize by column?)"
assert np.allclose(_Zn.std(axis=1), 1, atol=1e-4), "Each row's standard deviation should be about 1"
assert np.allclose(standardize_rows(_X * 3.0 + 7.0), _Z, atol=1e-4), "Applying a·X+b (a>0) to the input should leave the result unchanged"
_C = np.asarray(standardize_rows(jnp.full((2, 4), 3.0)))
assert np.all(np.isfinite(_C)) and np.allclose(_C, 0, atol=1e-6), "A constant row should give all zeros: the ε in the denominator prevents dividing by 0"
print("✅ Exercise E1: self-check passed")
Exercise E2: random number discipline
- Use
jax.random.key(2024)as the main key and split it once into 3 subkeys, then generate: a standard normal matrix of shape(2, 3), uniform integers in[0, 10)of shape(5,)(jax.random.randint), and one random permutation ofjnp.arange(6)(jax.random.permutation). - Show reproducibility: redo it with the same main key and get exactly the same results.
- Show the danger of reusing a key: generate a normal matrix twice with the same subkey; the two are identical.
Self-check interface: define sample_all(seed) -> (normal matrix, uniform integers, random permutation), using jax.random.key(seed) as the main key.
# ✍️ Your answer: Exercise E2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise E2
try:
sample_all
except NameError as e:
print(f"⏳ Exercise E2: not answered yet: {e}")
else:
_n, _i, _p = sample_all(2024)
assert _n.shape == (2, 3) and jnp.issubdtype(_n.dtype, jnp.floating), f"The normal matrix should be a (2, 3) float array, got {_n.shape} {_n.dtype}"
assert _i.shape == (5,) and jnp.issubdtype(_i.dtype, jnp.integer), f"The uniform integers should be a (5,) integer array, got {_i.shape} {_i.dtype}"
assert bool(((_i >= 0) & (_i < 10)).all()), f"The integers should fall in [0, 10), got {_i}"
assert jnp.array_equal(jnp.sort(_p), jnp.arange(6)), f"The random permutation should contain each of 0..5 exactly once, got {_p}"
assert all(jnp.array_equal(u, v) for u, v in zip((_n, _i, _p), sample_all(2024))), "Two calls with the same seed should give exactly the same results"
assert not jnp.array_equal(_n, sample_all(7)[0]), "Different seeds should give different random numbers"
assert not jnp.array_equal(_n, jax.random.normal(jax.random.key(2024), (2, 3))), "Don't generate the normal matrix directly from the main key: the main key is only for splitting"
print("✅ Exercise E2: self-check passed")
Exercise E3: timing correctly -- NumPy vs. JAX (op by op) vs. JAX + jit
For the function $f(x) = \sum_i \left(\sin^2 x_i + \cos^2 x_i\right)\cdot e^{-x_i^2}$, compare how long three implementations take on a float32 vector of length $10^6$.
Requirements: (1) write a general timing function that warms up first (to exclude compile time), then repeats several times and takes the median; (2) the JAX versions must call .block_until_ready(); (3) measure the time of the first jit call (including compilation) separately; (4) check that all three give the same result.
Note: this machine has only a CPU, so the speedup comes mainly from
jit's operation fusion: several element-wise operations are merged into one kernel, which saves reading and writing intermediate arrays. On a GPU the gap is usually bigger. The exact numbers vary by machine, so don't memorize them. The dataxshould be passed to thejitfunction as an argument rather than captured by a closure.
Self-check interface: define a timing function bench(fn, *args) -> median time (milliseconds), plus three versions f_np(x), f_jnp(x), f_jit(x).
# ✍️ Your answer: Exercise E3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise E3
try:
bench, f_np, f_jnp, f_jit
except NameError as e:
print(f"⏳ Exercise E3: not answered yet: {e}")
else:
_x = jax.random.normal(jax.random.key(5), (100_000,))
_ref = float(np.exp(-np.asarray(_x, dtype=np.float64) ** 2).sum()) # sin²+cos²=1
for _name, _fn, _arg in [("f_np", f_np, np.asarray(_x)), ("f_jnp", f_jnp, _x), ("f_jit", f_jit, _x)]:
_r = float(_fn(_arg))
assert np.isclose(_r, _ref, rtol=1e-4), f"{_name} gives {_r:.3f}, which is wrong; it should be about {_ref:.3f}"
assert hasattr(f_jit, "lower"), "f_jit should be a function wrapped with jax.jit"
class _Async: # mimics an asynchronously dispatched result: it takes 30 ms to really finish
def block_until_ready(self):
time.sleep(0.03)
return self
_t = float(bench(lambda: _Async()))
assert _t >= 25, f"bench measured {_t:.2f}; it didn't wait for the result to really finish (or the unit isn't milliseconds)"
_state = {"n": 0}
def _noisy(): # the 1st call is slow (mimics compilation); after that, 1 in every 4 calls is occasionally slow
_state["n"] += 1
if _state["n"] == 1:
time.sleep(0.3)
elif _state["n"] % 4 == 0:
time.sleep(0.15)
_t = float(bench(_noisy))
assert _t < 50, f"bench measured {_t:.1f} ms: the result was pulled up by the first call or the occasional slow call; warm up first, then take the median"
print("✅ Exercise E3: self-check passed")
Exercise E4: counting a histogram with functional updates
Given labels = jax.random.randint(jax.random.key(7), (1000,), 0, 5) (1000 class labels from 0 to 4), count how often each class appears without a loop and without jnp.bincount, using only jnp.zeros and .at[].add, and:
- Check the result with
jnp.bincount(labels, length=5); - Compile the counting function with
jax.jit, run it again, and get the same result; - Explain: why does NumPy's
counts[labels] += 1give the wrong result?
Self-check interface: define count_labels(labels, num_classes=5) and its jit version count_jit (the self-check calls it like this: count_jit(labels, num_classes=7)).
# ✍️ Your answer: Exercise E4
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise E4
try:
count_labels, count_jit
except NameError as e:
print(f"⏳ Exercise E4: not answered yet: {e}")
else:
_lab = jax.random.randint(jax.random.key(123), (777,), 0, 7)
_ref = np.bincount(np.asarray(_lab), minlength=7)
_c = count_labels(_lab, num_classes=7)
assert _c.shape == (7,), f"With num_classes=7 it should return counts of length 7, got shape {_c.shape}"
assert np.array_equal(np.asarray(_c), _ref), f"Wrong counts: expected {_ref}, got {_c}"
assert int(count_labels(jnp.full((50,), 2), num_classes=3)[2]) == 50, "50 identical labels should count to 50: repeated indices must accumulate"
assert hasattr(count_jit, "lower"), "count_jit should be a function wrapped with jax.jit"
assert np.array_equal(np.asarray(count_jit(_lab, num_classes=7)), _ref), "The jit version's counts don't match bincount"
print("✅ Exercise E4: self-check passed")
Chapter 1 quiz¶
These 8 questions help you check how well you've got this chapter, with a focus on design trade-offs, common failures, and practical decisions. Each answer is folded under its question; answer it yourself first, then expand.
Q1. Which of these best describes JAX?
- A. A CPU array library that is faster than NumPy
- B. A numerical computing platform with a NumPy-like interface, built around composable function transformations (jit / grad / vmap, etc.), and compiled through XLA to CPU/GPU/TPU
- C. A deep learning framework with built-in neural network layers and optimizers
- D. A GPU version of NumPy with exactly the same interface and behavior as NumPy
Answer and explanation
B. A only covers one side. C is wrong: neural network layers and optimizers come from higher-level libraries like Flax and Optax, not JAX itself. D is wrong: the interface is similar, but there are important behavioral differences (immutability, float32 default, explicit random numbers, no error on out-of-bounds indexing, and so on).
Q2. What happens when you run x = jnp.array([1, 2, 3]); x[0] = 10?
- A.
xbecomes[10, 2, 3] - B. It raises
TypeError; usex = x.at[0].set(10)instead - C. It raises
IndexError - D. It silently fails and
xis unchanged
Answer and explanation
B. JAX arrays are immutable, so assigning in place raises TypeError. .at[0].set(10) returns a new array, and you have to assign it to a variable to "see" the update.
Q3. With the default configuration, what is jnp.array([1.0, 2.0], dtype=jnp.float64).dtype?
- A.
float64 - B.
float64on CPU,float32on GPU - C.
float32, with a warning; to get float64 you need to turn onjax_enable_x64at startup - D. It raises an exception
Answer and explanation
C. JAX disables 64-bit types by default, regardless of the device. Explicitly asking for float64 gets truncated to float32, with a UserWarning.
Q4. Which statement about the code below is correct?
key = jax.random.key(0)
a = jax.random.normal(key, (3,))
b = jax.random.normal(key, (3,))
- A.
aandbare two independent sets of random numbers - B.
aandbare identical; to get independent random numbers, calljax.random.split(key)first - C. The second call raises an error because the key has been "consumed"
- D. The value of
bdepends on global random state
Answer and explanation
B. JAX random functions are pure: the output depends only on the key. There is no global state, and keys are never "consumed". Reusing a key is the most common random-number bug.
Q5. What does the last line of this code print?
scale = 2.0
@jax.jit
def f(x): return x * scale
f(1.0)
scale = 100.0
print(f(1.0))
- A.
100.0 - B.
2.0 - C. An error
- D. It's undefined
Answer and explanation
B. jit traces the function on the first call, and the value of the global scale is baked into the result as a constant. Changing the global afterward doesn't trigger recompilation. This is why JAX asks for pure functions: everything that can change should be passed in as an argument.
Q6. What's wrong with timing a JAX matrix multiplication like this?
t0 = time.perf_counter(); y = x @ x; t = time.perf_counter() - t0
- A. Nothing
- B. JAX is lazily evaluated, so
yis never computed - C. JAX dispatches asynchronously, so this only measures dispatch time; write
(x @ x).block_until_ready()and warm up first - D. You should use
np.dotinstead
Answer and explanation
C. JAX isn't lazy; the computation really runs, but dispatch is asynchronous and Python returns right away. To measure the real time, you have to wait for the result to finish; with jit, you also need to exclude the compile time of the first call.
Q7. In JAX 0.12, what's the right way to check which device array x is on?
- A.
x.device() - B.
x.device(a single device) orx.devices()(a set of devices) - C.
jax.get_device(x) - D.
x.platform
Answer and explanation
B. x.device changed from a method to an attribute, and x.device() raises TypeError: 'Device' object is not callable. x.devices() returns a set, which suits arrays sharded across several devices; x.sharding gives the full sharding info.
Q8. Your dataset is 10 GB, held in NumPy arrays in host memory, and training runs on a GPU. Which approach makes the most sense?
- A. Pass the NumPy arrays straight to the jit function on every operation and let JAX move them automatically each time
- B. After preprocessing, move (a batch of) data to the device in one go with
jax.device_put, run the whole training step on the device, and bring only a few scalars back to the host when you need to log - C.
print(loss)on every training step so it's easy to watch - D. Convert the data to Python lists before passing it to JAX
Answer and explanation
B. Transfers between host and device are often the bottleneck, so minimize how often and how much you move. The problem with C is that printing on every step forces a sync and a transfer back to the host, which slows training down. D is the slowest.
Chapter summary¶
- JAX = NumPy-like interface + composable function transformations + XLA compilation.
jitcompiles for speed,graddoes automatic differentiation,vmapvectorizes automatically, and parallelism usesshard_map(pmapis a legacy API). - Pure functions are a prerequisite: transformations get a jaxpr by tracing the function. Side effects happen only during tracing, and globals get baked in as constants. Parameters and data should be passed in as function arguments. Use
jax.debug.printfor runtime printing. - Arrays are immutable: use
x.at[idx].set/add/...to produce a new array. When updating an intermediate insidejit, XLA usually optimizes it into an in-place update (updating an input argument needsdonate_argnums). Repeated indices accumulate. - 32-bit by default:
float32/int32, regardless of device. For 64 bits, runjax.config.update("jax_enable_x64", True)when the program starts (switching midway doesn't affect arrays already created). - Type and indexing pitfalls: Python scalars are weakly typed; integer overflow silently wraps around; out-of-bounds reads are clamped and out-of-bounds writes are dropped, with no error either way.
- Manage random numbers explicitly: use
jax.random.key(typed keys). Don't reuse a key;splitfirst. Exact random values aren't guaranteed to match across JAX versions. - It's asynchronous dispatch, not lazy evaluation: benchmarks must use
.block_until_ready()and warm up to exclude compile time. - The array type is
jax.Array: check devices withx.device(attribute),x.devices()(method), andx.sharding. - Device management: default priority TPU > GPU > CPU; place explicitly with
jax.device_put; arrays committed to different devices can't be used together directly; move data between host and device as little as possible. - Installation: JAX 0.12 needs Python ≥ 3.12;
pip install -U jax(CPU),"jax[cuda12]"/"jax[cuda13]"(NVIDIA GPU),"jax[tpu]"(TPU).
Cheat sheet¶
| API | What it does |
|---|---|
import jax.numpy as jnp | NumPy-like interface |
jnp.array / zeros / ones / arange / linspace | Create arrays |
jnp.asarray(np_arr) / np.asarray(x) | NumPy → JAX (copied to the device) / JAX → NumPy (read-only) |
isinstance(x, jax.Array) | Check whether something is a JAX array |
x.shape / dtype / ndim / size | Array attributes |
x.at[i].set(v) / .add(v) / .get(mode="fill") | Functional update / safe read |
jnp.where(cond, a, b) | Conditional selection (instead of boolean-mask assignment) |
jnp.sum / mean / max(x, axis=..., keepdims=...) | Reductions |
x.reshape(...) / jnp.reshape | Reshape (returns a new array) |
jax.random.key(seed) | Create a typed PRNG key |
jax.random.split(key, n) / fold_in(key, i) | Derive new keys |
jax.random.normal / uniform / randint / permutation | Sampling |
jax.random.key_data / wrap_key_data | Get a key's underlying data / wrap an old-style key |
jax.jit(f, static_argnames=...) | XLA just-in-time compilation |
jax.grad(f, argnums=...) / jax.value_and_grad(f) | Gradient / value and gradient |
jax.vmap(f, in_axes=...) | Automatic vectorization |
jax.shard_map | Multi-device parallelism (replaces the old jax.pmap) |
jax.make_jaxpr(f)(*args) | See the intermediate representation from tracing |
jax.debug.print("{}", x) | Print runtime values inside jit |
jax.tree.map(f, tree) / jax.tree.all | Map over every leaf of a pytree / check that all are true |
x.block_until_ready() | Wait for asynchronous computation to finish (a must for benchmarking) |
jax.devices(backend=None) / jax.default_backend() | List devices / default backend |
x.device / x.devices() / x.sharding | The array's device / set of devices / sharding info |
jax.device_put(x, device) / jax.device_get(x) | Put on a device / bring back to the host |
with jax.default_device(d): | Temporarily switch the default device |
jax.config.update("jax_enable_x64", True) | Enable 64-bit (set at startup) |
jax.config.update("jax_num_cpu_devices", n) | Simulate several CPU devices (set at startup) |
jax.print_environment_info() | Print version and device info |
Environment variable JAX_PLATFORMS=cpu | Force CPU only |
Environment variable XLA_PYTHON_CLIENT_PREALLOCATE=false | Turn off GPU memory preallocation |
Version notes¶
Below is a summary of the patterns and conclusions in this chapter to watch for under JAX 0.12. Each one is verified with assert at the matching place in the text.
| No. | Section | Key point | Where |
|---|---|---|---|
| 1 | 1.1 / 1.3 | Vectorization is not quantization | 1.1 What is JAX?; 1.3 Core design: function transformations |
| 2 | 1.1 / 1.2 / 1.3 | Use jax.shard_map for parallelism; the jax.pmap docstring marks it as legacy | 1.1 What is JAX?; 1.3 Core design: function transformations |
| 3 | 1.1 | The JAX docs live at docs.jax.dev, and the code at github.com/jax-ml/jax | 1.1 What is JAX? |
| 4 | 1.2 | JAX isn't lazily evaluated; it uses op-by-op asynchronous dispatch, with whole-function compilation under jit; benchmarks need .block_until_ready() | 1.2 JAX vs. NumPy |
| 5 | 1.2 | With the default config, jnp.array(..., dtype=jnp.float64) gives float32 with a warning; 64-bit needs jax.config.update("jax_enable_x64", True) | 1.2 JAX vs. NumPy |
| 6 | 1.2 | In the comparison table, JAX's execution model is "asynchronous dispatch; whole-function compilation under jit", and the parallel transformation is shard_map | 1.2 JAX vs. NumPy |
| 7 | 1.2 | Python scalars are weakly typed and don't promote arrays to 64 bits; integer overflow silently wraps around (NumPy raises) | 1.2 JAX vs. NumPy |
| 8 | 1.2 | Out-of-bounds reads are clamped and out-of-bounds writes are dropped, with no error; use .at[].get(mode="fill") when you need well-defined behavior | 1.2 JAX vs. NumPy |
| 9 | 1.3 | "Static" in jit means a static argument (declared with static_argnames), not a token | 1.3 Core design: function transformations |
| 10 | 1.3 | You have to actually call grad_fn / jit_grad_fn / batched_predict to see results; cross-check against the analytic gradient and a per-example loop, then assemble a training loop | 1.3 Core design: function transformations |
| 11 | 1.3 | print inside a jit function runs only during tracing, and globals become constants; use jax.debug.print for runtime printing | 1.3 Core design: function transformations |
| 12 | 1.4 | JAX 0.12 needs Python ≥ 3.12 | 1.4 Installation and setup |
| 13 | 1.4 | Install the CPU version with pip install -U jax; jaxlib is installed automatically | 1.4 Installation and setup |
| 14 | 1.4 | Install the GPU version with "jax[cuda12]" / "jax[cuda13]" (or the -local variants), no -f needed anymore; TPU uses "jax[tpu]"; Metal is an experimental plugin | 1.4 Installation and setup |
| 15 | 1.4 | The check script's jnp.dot(x, x) is 9.738718… on JAX 0.12 CPU (not 10.811179161071777); exact random values change with the version / RNG config | 1.4 Installation and setup |
| 16 | 1.4 / 1.5 / 1.7 | Create keys with jax.random.key(seed) (typed keys); it gives the same random numbers as jax.random.PRNGKey(seed) | 1.5 Working with JAX arrays |
| 17 | 1.5 | Random functions don't modify or "consume" keys; the problem is reusing the same key | 1.5 Working with JAX arrays |
| 18 | 1.5 | When run on its own, the NumPy comparison code needs import numpy as np first, or you get NameError | 1.5 Working with JAX arrays |
| 19 | 1.5 / 1.6 / 1.7 | Check devices with x.device (attribute) / x.devices() / x.sharding, no longer x.device() | 1.5 Working with JAX arrays; 1.6 Device management: CPU, GPU, TPU |
| 20 | 1.5 | Calling x.device() raises TypeError, which except AttributeError can't catch; just use the new style | 1.5 Working with JAX arrays |
| 21 | 1.6 | Using arrays committed to different devices together raises ValueError; there's no automatic transfer | 1.6 Device management: CPU, GPU, TPU |
| 22 | 1.7 | The array type is jax.Array (implementation class jaxlib._jax.ArrayImpl); DeviceArray has been removed | 1.7 Hands-on: basic array operations |
| 23 | 1.7 | The default is float32 on every device (CPU included); 64-bit needs jax_enable_x64 | 1.7 Hands-on: basic array operations |
| 24 | 1.3 | "Abstract values with only shape and dtype" is jit's behavior; with grad alone, tracers carry concrete values and Python if works (only jit(grad(f)) raises) | 1.3 Core design: function transformations |
Appendix: reference solutions¶
Write your own answer in the "✍️ Your answer" cell first and check it with the "🧪 Self-check". Once it passes, or if you're truly stuck, come here to compare. Running the solutions below in order overwrites any functions with the same names you defined in the answer cells.
Exercise E1¶
See "Extra exercises" in the main text for the problem.
# Reference solution: Exercise E1
def standardize_rows(X, eps=1e-8):
mu = jnp.mean(X, axis=1, keepdims=True) # shape (5, 1), broadcasts against (5, 4)
sigma = jnp.std(X, axis=1, keepdims=True)
return (X - mu) / (sigma + eps)
def standardize_rows_np(X, eps=1e-8):
mu = X.mean(axis=1, keepdims=True)
sigma = X.std(axis=1, keepdims=True)
return (X - mu) / (sigma + eps)
X = jax.random.normal(jax.random.key(0), (5, 4)) * 3 + 10
Z = standardize_rows(X)
Z_np = standardize_rows_np(np.asarray(X))
print("Matches NumPy:", np.allclose(Z, Z_np, atol=1e-5)) # computed in float32, so the tolerance is loosened to 1e-5
print("Row means:", jnp.round(Z.mean(axis=1), 5))
print("Row standard deviations:", jnp.round(Z.std(axis=1), 5))
assert np.allclose(Z, Z_np, atol=1e-5)
assert jnp.allclose(Z.mean(axis=1), 0, atol=1e-5) and jnp.allclose(Z.std(axis=1), 1, atol=1e-5)
Exercise E2¶
See "Extra exercises" in the main text for the problem.
# Reference solution: Exercise E2
def sample_all(seed):
k_normal, k_int, k_perm = jax.random.split(jax.random.key(seed), 3)
return (
jax.random.normal(k_normal, (2, 3)),
jax.random.randint(k_int, (5,), minval=0, maxval=10),
jax.random.permutation(k_perm, jnp.arange(6)),
)
n1, i1, p1 = sample_all(2024)
print("Normal matrix:\n", n1)
print("Uniform integers:", i1)
print("Random permutation:", p1)
assert ((i1 >= 0) & (i1 < 10)).all()
assert jnp.array_equal(jnp.sort(p1), jnp.arange(6)) # the permutation contains exactly 0..5
n2, i2, p2 = sample_all(2024)
reproducible = all(jnp.array_equal(u, v) for u, v in [(n1, n2), (i1, i2), (p1, p2)])
print("\nReproducible:", reproducible)
assert reproducible
k = jax.random.split(jax.random.key(2024), 3)[0]
reused_same = jnp.array_equal(jax.random.normal(k, (2, 3)), jax.random.normal(k, (2, 3)))
print("Reusing the same subkey gives identical results twice:", reused_same)
assert reused_same
Exercise E3¶
See "Extra exercises" in the main text for the problem.
# Reference solution: Exercise E3
def bench(fn, *args, n_warmup=2, n_repeat=10):
# Returns the median time (milliseconds). If fn's return value has block_until_ready, call it
def run():
out = fn(*args)
if hasattr(out, "block_until_ready"):
out.block_until_ready()
return out
for _ in range(n_warmup):
run()
times = []
for _ in range(n_repeat):
t0 = time.perf_counter()
run()
times.append(time.perf_counter() - t0)
return 1e3 * float(np.median(times))
def f_np(x):
return np.sum((np.sin(x) ** 2 + np.cos(x) ** 2) * np.exp(-x ** 2))
def f_jnp(x):
return jnp.sum((jnp.sin(x) ** 2 + jnp.cos(x) ** 2) * jnp.exp(-x ** 2))
f_jit = jax.jit(f_jnp)
x_jax = jax.random.normal(jax.random.key(0), (1_000_000,))
x_np = np.asarray(x_jax)
# First call: includes tracing + compilation
t0 = time.perf_counter()
f_jit(x_jax).block_until_ready()
first_call = 1e3 * (time.perf_counter() - t0)
t_np, t_jnp, t_jit = bench(f_np, x_np), bench(f_jnp, x_jax), bench(f_jit, x_jax)
print(f"NumPy : {t_np:7.2f} ms")
print(f"JAX op by op : {t_jnp:7.2f} ms")
print(f"JAX + jit : {t_jit:7.2f} ms (first call including compilation {first_call:.1f} ms)")
print(f"jit speedup over NumPy: {t_np / t_jit:.1f}x")
# Check: all three implementations agree; since sin²+cos²=1, the result should also be close to Σ exp(-x²)
r_np, r_jnp, r_jit = f_np(x_np), float(f_jnp(x_jax)), float(f_jit(x_jax))
print(f"Results: NumPy={r_np:.3f}, JAX={r_jnp:.3f}, jit={r_jit:.3f}")
assert np.isclose(r_np, r_jnp, rtol=1e-4) and np.isclose(r_jnp, r_jit, rtol=1e-4)
assert np.isclose(r_np, np.exp(-x_np.astype(np.float64) ** 2).sum(), rtol=1e-4)
Exercise E4¶
See "Extra exercises" in the main text for the problem.
# Reference solution: Exercise E4
labels = jax.random.randint(jax.random.key(7), (1000,), 0, 5)
def count_labels(labels, num_classes=5):
# .at[labels].add(1): repeated indices are accumulated one by one (scatter-add)
return jnp.zeros(num_classes, dtype=jnp.int32).at[labels].add(1)
counts = count_labels(labels)
print("Counts:", counts, "total:", counts.sum())
assert counts.sum() == 1000
assert jnp.array_equal(counts, jnp.bincount(labels, length=5))
# num_classes determines the output shape, so under jit it must be a static argument
count_jit = jax.jit(count_labels, static_argnames="num_classes")
assert jnp.array_equal(count_jit(labels), counts)
print("Matches bincount, and the result is unchanged under jit")
# NumPy's fancy-index += only takes effect once for repeated indices
counts_np = np.zeros(5, dtype=np.int32)
counts_np[np.asarray(labels)] += 1
print("\nResult of NumPy counts[labels] += 1:", counts_np, " <- at most 1 per class, which is wrong")
assert counts_np.max() == 1
print("Answer: NumPy first reads counts[labels], adds 1, then writes back, so writes to repeated indices overwrite each other; "
"JAX's .at[].add is a true scatter-add and accumulates. In NumPy, use np.add.at.")