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

  1. 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.
  2. Know how jax.numpy is like NumPy and how it differs, especially immutability, the float32 default, explicit random keys, and asynchronous dispatch.
  3. Understand the "pure functions + function transformations" design and the constraints that tracing brings.
  4. Install and verify JAX, and check and control which device (CPU / GPU / TPU) an array lives on.
  5. Create, inspect, compute with, index, functionally update, reduce, and reshape arrays with confidence.

In this chapter

SectionContents
1.1 What is JAX?What it is, its two pillars, the XLA compilation pipeline
1.2 JAX vs. NumPySimilarities, five key differences, when to use which
1.3 Core design: function transformationsPure functions, grad / jit / vmap / parallelism and how they compose
1.4 Installation and setupInstalling and verifying on CPU / GPU / TPU
1.5 Working with JAX arraysCreation, random numbers, attributes, immutability, operations, indexing
1.6 Device management: CPU, GPU, TPUListing devices, device_put, host vs. device memory
1.7 Hands-on: basic array operationsBasic exercises + 4 extra exercises
Chapter 1 quizQuiz questions, answers, and explanations
Chapter summary & cheat sheetKey points, API quick reference
Version notesPatterns 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()}")
Python : 3.14.7
JAX    : 0.12.0.1
NumPy  : 2.5.4
Default backend: cpu
Devices: [CpuDevice(id=0)]

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.

TransformationWhat it does
jax.jitJust-In-Time compilation: uses XLA to compile the function into machine code optimized for the target hardware, cutting Python overhead and fusing operations
jax.gradAutomatic differentiation: returns a new function that computes the gradient of the original
jax.vmapAutomatic vectorization: turns a function that "handles one example" into one that "handles a batch", with no hand-written loop
jax.shard_mapMulti-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.pmap as the fourth main transformation. In JAX 0.12, the jax.pmap docstring opens with "Old way of doing parallel map. Use jax.shard_map instead.". It still runs, but it is a legacy API. In new code, use jax.shard_map, or jax.sharding together with jit for 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 address jax.readthedocs.io redirects there), and the code lives at github.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__
Old way of doing parallel map. Use :py:func:`jax.shard_map` instead.

🔍 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_jaxpr again 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))
jaxpr of f:
{ lambda ; a:f32[]. let
    b:f32[] = sin a
    c:f32[] = integer_pow[y=2] b
    d:f32[] = add c 1.0:f32[]
  in (d,) }
jaxpr of grad(f) (the extra cos and multiplications are the chain rule):
{ lambda ; a:f32[]. let
    b:f32[] = sin a
    c:f32[] = cos a
    d:f32[] = integer_pow[y=2] b
    e:f32[] = mul 2.0:f32[] b
    _:f32[] = add d 1.0:f32[]
    f:f32[] = mul 1.0:f32[] e
    g:f32[] = mul f c
  in (g,) }

1.2 JAX vs. NumPy

jax.numpy is deliberately designed to look like NumPy, so people who know NumPy can start almost for free. Let's look at the similarities first, then the key differences.

Similarities: a familiar API

# 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
JAX devices: [CpuDevice(id=0)]
NumPy array: [1. 2. 3.], type: <class 'numpy.ndarray'>
JAX array: [1. 2. 3.], type: <class 'jaxlib._jax.ArrayImpl'>
# 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)
NumPy result:
[[2 3 4]
 [7 8 9]]
JAX result:
[[2 3 4]
 [7 8 9]]
Results close: True

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]))
Modified NumPy array: [100   2   3]

Error when modifying a JAX array in place: JAX arrays are immutable and do not support in-place item assignment. Instead of x[idx] = y, use x = x.at[idx].set(y) or another .at[] method: https://docs.jax.dev/en/latest/_autosummary/jax.numpy.ndarray.at.html

Original JAX array (unchanged): [1 2 3]
Updated JAX array (new object): [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
No wait (dispatch only):    0.186 ms
block_until_ready:   17.188 ms
Takeaway: benchmarks must use .block_until_ready(), or they badly underestimate the time.

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 you float32, not float64, along with UserWarning: 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
Default NumPy float type: float64
Default JAX float type: 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)
Requested float64, actual dtype: float32
Observed warning: Explicitly requested dtype float64 requested in array is not available, and will be truncated to dtype float32. To enable more dtypes, set the jax_enable_x64 configuration option or the JAX_ENABLE_X64 shell environment variable. See https://github.com/jax-ml/jax#current-gotchas for more.
# ✅ 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
With x64 on, explicit float64: float64
With x64 on, default float: float64 , default int: int64

x64 turned on midway: array created before float32 , array created after float64

Comparison table

FeatureNumPyJAX
API—Closely mirrors NumPy
MutabilityMutableImmutable
HardwareCPU onlyCPU / GPU / TPU
Execution modelSynchronous, one op at a timeOne op at a time with asynchronous dispatch; whole function compiled into an XLA program under jit
Function transformationsNonejit / grad / vmap / shard_map
Default precision64-bit32-bit (on every device; 64-bit needs jax_enable_x64)
Random numbersImplicit global statePRNG keys passed explicitly
Main goalGeneral numerical computingHigh-performance, differentiable numerical computing
State handlingSide effects allowedPrefers pure functions with state passed explicitly

⚠️ Note: The execution model is "asynchronous dispatch", not "lazy evaluation"; the parallel transformation is shard_map, no longer pmap. Comparison tables in older material often fill these two cells with "lazy, often via JIT" and jit, 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. An int32 array + 1.5 gives float32, while NumPy would give float64.
  • Integer overflow doesn't raise: In NumPy 2, an int8 array + 300 raises OverflowError. JAX truncates (wraps) 300 to int8 and 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))
NumPy: int64 array + 1.5 -> float64
JAX  : int32 array + 1.5 -> float32

NumPy int8 + 300 -> OverflowError: Python integer 300 out of bounds for int8
JAX   int8 + 300 -> [45 46 47] (300 is truncated to 44, so the result is wrong)

(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
NumPy out of bounds: IndexError: index 10 is out of bounds for axis 0 with size 5
JAX a[10]            -> 4   (clamped to the last element)
JAX a.at[10].set(99) -> [0 1 2 3 4]   (update dropped)
JAX 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
After modifying the NumPy source array, the JAX array is still: [1. 2.]
Is np.asarray(jax array) writable: False
Write error: assignment destination is read-only

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:

  1. Output depends only on explicit inputs: the same inputs always give the same output;
  2. 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, since pmap is 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]))
Loss: 24.050903
Gradients (a dict with the same structure as params): {'b': Array(-3.3830914, dtype=float32, weak_type=True), 'w': Array([-10.886865 ,   7.0327234], dtype=float32)}
JIT gradients: {'b': Array(-3.3830914, dtype=float32, weak_type=True), 'w': Array([-10.886865 ,   7.0327234], dtype=float32)}
Single-example output shape: () batch output shape: (8,)

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)
step   0  loss = 9.48560
step  50  loss = 0.00500
step 100  loss = 0.00500
step 150  loss = 0.00500
step 200  loss = 0.00500
Learned parameters: {'b': Array(0.4340359, dtype=float32, weak_type=True), 'w': Array([ 3.0370548, -1.928047 ], dtype=float32)} 
True parameters: w = [3, -2], b = 0.5

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. With grad alone, a value-dependent Python if differentiates just fine; only once you wrap it in jit do you get TracerBoolConversionError (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")
Call 1 (shape (2,))
  [Python print] tracing, x = JitTracer(float32[2])
  [debug.print] runtime x = [1. 1.]
Call 2 (shape (2,))
  [debug.print] runtime x = [1. 1.]
Call 3 (shape (3,))
  [Python print] tracing, x = JitTracer(float32[3])
  [debug.print] runtime x = [1. 1. 1.]
counter = 2 (called 3 times, but traced only 2 times)
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
With scale=2: 2.0
After changing scale to 100: 2.0  <- still 2; it uses the old value from tracing time

(3) A few practical details about grad (you'll need them soon):

  • jax.grad requires the function to return a scalar. By default it differentiates with respect to argument 0; use argnums=(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; grads above is an example.
  • In training you usually want both the loss value and the gradient. jax.value_and_grad computes 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])
Loss: 24.050903
Gradients: {'b': Array(-3.3830914, dtype=float32, weak_type=True), 'w': Array([-10.886865 ,   7.0327234], dtype=float32)}
Shape of the gradient w.r.t. inputs: (8, 2)
Error when differentiating a non-scalar output: Gradient only defined for scalar-output functions. Output had shape: (8,). To get the gradient, reduce the output to a scalar with output.sum(), or use jax.jacobian to differentiate a function with a non-scalar output.

(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):

PlatformCommandNotes
CPU (Linux / macOS / Windows)pip install -U jaxjaxlib is a dependency of jax and gets installed automatically
NVIDIA GPU, CUDA 12pip install -U "jax[cuda12]"Ships the CUDA / cuDNN runtime through pip; you only need the NVIDIA driver installed
NVIDIA GPU, CUDA 13pip install -U "jax[cuda13]"Same as above; needs a newer driver
CUDA installed locallypip install -U "jax[cuda12-local]" / "jax[cuda13-local]"Uses your local CUDA; making sure the versions match is up to you
Google Cloud TPU VMpip install -U "jax[tpu]"Usually preinstalled on Colab's TPU runtime
Apple Silicon GPU (Metal)pip install jax-metalExperimental plugin that supports only some operations and is very strict about the JAX version; while learning, just use the CPU
Windows + NVIDIA GPUInstall inside WSL2 the Linux wayNative Windows supports CPU only

⚠️ Note: Older tutorials often say "requires Python 3.7+", use pip install --upgrade jax jaxlib for CPU, and pip install --upgrade "jax[cuda11_cudnn82]" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.html for GPU. All of that is outdated now. JAX 0.12's package metadata (see the cell below) shows: the requirement is Requires-Python >=3.12; the available extras no longer include cuda11_cudnn82 (CUDA 11 is no longer supported, and you no longer need -f to point at a download page); and you don't need to list jaxlib separately.

# 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
jax version: 0.12.0.1
Requires-Python: >=3.12
Available install extras (pip install 'jax[xxx]'): ['minimum-jaxlib', 'cpu', 'ci', 'tpu', 'cuda', 'cuda12', 'cuda13', 'cuda12-local', 'cuda13-local', 'rocm7-local', 'oneapi', 'k8s', 'xprof']

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.811179161071777 as the result. On JAX 0.12 (CPU), the same seed gives 9.738718.... Even switching jax_threefry_partitionable back to False only gives 2.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
Value given in the original example: 10.811179161071777
Actual output                      : 9.738718032836914
# ✅ 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)
JAX found these devices:
0: CPU (cpu)

Ran a simple JAX operation successfully. Result: 9.738718032836914

Default backend: cpu

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
jax_threefry_partitionable=True : dot(x, x) = 9.738718032836914
jax_threefry_partitionable=False: dot(x, x) = 2.6585559844970703
Value given in the original example   : 10.811179161071777

🔍 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()
jax:    0.12.0.1
jaxlib: 0.12.0.1
numpy:  2.5.4
python: 3.14.7 (main, Aug  5 2026, 10:29:49) [Clang 21.0.0 (clang-2100.1.1.101)]
device info: cpu-1, 1 local devices"
process_count: 1
platform: uname_result(system='Darwin', node='Mac.galaxus.box', release='27.0.0', version='Darwin Kernel Version 27.0.0: Tue Aug 11 21:06:40 PDT 2026; root:xnu-13432.1.9~1/RELEASE_ARM64_T6050', machine='arm64')

1.5 Working with JAX arrays

JAX arrays (of type jax.Array) are the basic data structure of JAX computation. jax.numpy (conventionally imported as jnp) offers an interface almost identical to NumPy's for creating and manipulating them.

Creating arrays

# 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}")
JAX version: 0.12.0.1
Array created from a list: [1. 2. 3.]
Type: <class 'jaxlib._jax.ArrayImpl'>
2D array:
[[1 2]
 [3 4]]
# 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
Zeros array:
[[0. 0. 0.]
 [0. 0. 0.]]
Ones array: [1 1 1]
Ones array dtype: int32
arange array: [0 1 2 3 4]
linspace array: [0.   0.25 0.5  0.75 1.  ]

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.split to 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 is jax.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)
Initial key: key<fry>, underlying data [0 0]
Normal random numbers:
[[ 1.6226422   2.0252647 ]
 [-0.43359444 -0.07861735]]

Key after splitting: [1797259609 2579123966]
Subkey: [ 928981903 3453687069]
Uniform random numbers: [0.00729382 0.02089119 0.5814265 ]

Same normal random numbers:
[[ 1.6226422   2.0252647 ]
 [-0.43359444 -0.07861735]]

How jax.random.key differs from jax.random.PRNGKey

jax.random.PRNGKey(0) (old)jax.random.key(0) (new, recommended)
Return valueA 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 keyssplit(k, 3) → (3, 2), with an extra "implementation detail" dimensionsplit(k, 3) → (3,), an intuitive shape
Switching the RNG algorithmPRNGKey(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 dataIt already is the datajax.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
PRNGKey(0): [0 0], dtype=uint32, shape=(2,)
key(0)    : dtype=key<fry>, shape=()
key_data(key(0)) = [0 0]
Same random numbers: True
split(PRNGKey, 3).shape = (3, 2)
split(key, 3).shape     = (3,)
Old-style key + 1 = [1 1] (silently succeeds, which is dangerous)
New-style key + 1 raises: add does not accept dtypes key<fry>, int32.
PRNGKey(0, impl='rbg'): uint32 (4,) | key(0, impl='rbg'): key<rbg>
Array([-0.25581604,  1.177591  ], dtype=float32)

🔍 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))
Reused key -> the two sets of "random" numbers are identical: True
Split subkeys -> different: True
step 0: [0.8423141  0.18237865]
step 1: [0.00729382 0.02089119]
step 2: [0.9024495  0.91229284]
uniform and normal from the same key sort the same way: True

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
Array x:
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
Shape: (3, 4)
Dtype: int32
Number of dimensions: 2
Total elements: 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 to import numpy as np first, or you'll get a NameError (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
Observed: NameError: name 'np' is not defined
# ✅ 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]))
Modified NumPy array: [100   2   3]

Error when trying to modify a JAX array in place: JAX arrays are immutable and do not support in-place item assignment. Instead of x[idx] = y, use x = x.at[idx].set(y) or another .at[] method: https://docs.jax.dev/en/latest/_autosummary/jax.numpy.ndarray.at.html

Original JAX array (unchanged): [1 2 3]
Updated JAX array (new object): [100   2   3]
Incremented 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 inside jit because the result shape isn't fixed; use jnp.where there instead.
  • Repeated indices accumulate: x.at[[0, 0]].add(1) adds 2 to x[0], while NumPy's a[[0, 0]] += 1 only adds 1 (to accumulate you need np.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[].set into 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, use donate_argnums to "donate" the input buffer (see the donate_argnums Extra in Chapter 2, section 2.6). Without jit, 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")
set     : [0. 7. 7. 0. 0.]
multiply: [ 0.  1. 20.  3. 40.]
max     : [2. 2. 2. 3. 4.]
apply   : [0.        1.        1.4142135 3.        4.       ]

JAX  .at[[0,0,2]].add(1): [2. 0. 1.]
NumPy a[[0,0,2]] += 1   : [1. 0. 1.]
NumPy np.add.at          : [2. 0. 1.]
ReLU with jnp.where: [0. 2. 0. 4.]
HLO 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)))
a + b:
[[ 6  8]
 [10 12]]
a * b:
[[ 5 12]
 [21 32]]
Matrix product (jnp.dot):
[[19 22]
 [43 50]]
Matrix product (@):
[[19 22]
 [43 50]]
Sine of a:
[[ 0.84147096  0.9092974 ]
 [ 0.14112    -0.7568025 ]]
Exponential of a:
[[ 2.7182817  7.389056 ]
 [20.085537  54.598152 ]]

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]))
Original data: [0 1 2 3 4 5 6 7 8 9]
Element at index 3: 3
Slice from index 2 to 5: [2 3 4]
Matrix:
[[0 1 2]
 [3 4 5]
 [6 7 8]]
Element at row 1, column 2: 5
First two rows:
[[0 1 2]
 [3 4 5]]
First column:
[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 an except AttributeError fallback, on the grounds that "older JAX versions may not support .device() directly". The truth is the opposite: it was newer JAX that changed device from a method to an attribute. Calling x.device() in JAX 0.12 raises TypeError: 'Device' object is not callable, which except AttributeError can'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)
The TypeError was not caught by except AttributeError: 'jaxlib._jax.Device' object is not callable
# ✅ 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()
x.device           -> cpu:0 (attribute, no parentheses)
x.device.platform  -> cpu
x.devices()        -> {CpuDevice(id=0)} (method, returns a set of devices)
Available devices: [CpuDevice(id=0)]

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)
Available devices: [CpuDevice(id=0)]
Default backend: cpu
  id=0, platform=cpu, kind=cpu

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]
Array x is on: cpu: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:

StyleReturnsWhen to use
random_array.deviceA single DeviceMost common; when the array lives on one device
random_array.devices()set[Device]When the array may be sharded across several devices
random_array.shardingA Sharding objectWhen 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
Observed: TypeError: 'jaxlib._jax.Device' object is not callable
# ✅ 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
random_array.device    -> cpu:0
random_array.devices() -> {CpuDevice(id=0)}
random_array.sharding  -> SingleDeviceSharding(device=CpuDevice(id=0), memory_kind=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}")
x_dev0 is on: cpu:0
Only 1 device(s), can't place on devices[1]
x_cpu is on: cpu:0
y is on the default device: cpu:0

jax.devices('gpu') raises: Unknown backend: 'gpu' requested, but no platforms that are instances of gpu are present. Platforms present are: cpu

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 to device_put them 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 computation right 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
Simulated devices: [CpuDevice(id=0), CpuDevice(id=1), CpuDevice(id=2), CpuDevice(id=3)]
a is on cpu:1 , b is on cpu:2
a + jnp.ones(4) (the uncommitted array is moved over automatically) is on cpu:1
a + b raises ValueError: Received incompatible devices for jitted computation. Got argument x o ...
After moving to the same device, a + b = [0. 2. 4. 6.] , on cpu:1
ALL_OK

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:

  1. Load and preprocess data on the host (usually with NumPy);
  2. Transfer the data to the device once with jax.device_put (or jnp.asarray);
  3. Do the core computation on the device with jit / grad / vmap and friends;
  4. 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 with XLA_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]
np.asarray     -> <class 'numpy.ndarray'> [0. 2. 4. 6.]
jax.device_get -> <class 'numpy.ndarray'> [0. 2. 4. 6.]
scalar .item() -> 12.0 <class 'float'>
Array created inside the default_device block is on: 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()}")
JAX version: 0.12.0.1
JAX default backend: cpu
Available JAX devices: [CpuDevice(id=0)]

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.DeviceArray or similar", and that "the default is 64-bit floats on CPU and 32-bit on GPU/TPU". Neither holds in JAX 0.12: DeviceArray was removed long ago and the type is jax.Array; the default dtype on CPU is also float32 (verified with assert in 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.]))
JAX array created from a list: [1.  2.5 3.  4.2]
Type: <class 'jaxlib._jax.ArrayImpl'>
Dtype: float32

Zeros array:
 [[0. 0. 0.]
 [0. 0. 0.]]

Ones array (int32):
 [[1 1]
 [1 1]
 [1 1]]
Dtype: int32

Range array: [0 2 4 6 8]

Evenly spaced array: [0.   0.25 0.5  0.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()
Random array (uniform):
 [[0.48870957 0.6797972 ]
 [0.6162715  0.5610161 ]]

Random array (normal):
 [ 0.60576403  0.7990441  -0.908927  ]
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
Inspecting random_array:
Shape: (2, 2)
Size: 4
Number of dimensions: 2
Dtype: float32
Device: cpu:0

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]))
a + b = [5 7 9]
a * b = [ 4 10 18]
a + 10 = [11 12 13]
a * 2 = [2 4 6]
Original 'a': [1 2 3]
# 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))
Matrix product mat_a @ mat_b:
 [[19 22]
 [43 50]]

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]]))
Original data:
 [[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
Element at row 1, column 2: 6
Row 0: [0 1 2 3]
Column 1: [1 5 9]
Sub-array (first 2 rows, columns 1-2):
 [[1 2]
 [5 6]]

Direct assignment raises: JAX arrays are immutable and do not support in-place item assignment. Instead of x[idx] = y, use x = x.at[idx].set(y) or another .at[] method: https://docs.jax.dev/en/latest/_autosummary/jax.numpy.ndarray.at.html
Updated data:
 [[99  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
Original data (unchanged):
 [[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]

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()
x: [0.        1.5707964 3.1415927 4.712389  6.2831855]
sin(x): [ 0.0000000e+00  1.0000000e+00 -8.7422777e-08 -1.0000000e+00
  1.7484555e-07]
exp(x): [  1.          4.8104777  23.140694  111.31778   535.49176  ]
No description has been provided for this image

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]))
Matrix m:
 [[1 2 3]
 [4 5 6]]

Sum of everything: 21
Sum per column (axis=0): [5 7 9]
Sum per row (axis=1): [ 6 15]
Mean: 3.5
Max per row (axis=1): [3 6]
m.sum(axis=0): [5 7 9]

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])
Original: [0 1 2 3 4 5]
reshape to (2, 3):
 [[0 1 2]
 [3 4 5]]
jnp.reshape to (3, 2):
 [[0 1]
 [2 3]
 [4 5]]
Infer with -1, (3, -1) -> shape: (3, 2)

Error when the total number of elements doesn't match: cannot reshape array of shape (6,) (size 6) into shape (4, 2) (size 8)

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 E1: not answered yet: name 'standardize_rows' is not defined

Exercise E2: random number discipline

  1. 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 of jnp.arange(6) (jax.random.permutation).
  2. Show reproducibility: redo it with the same main key and get exactly the same results.
  3. 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 E2: not answered yet: name 'sample_all' is not defined

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 data x should be passed to the jit function 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 E3: not answered yet: name 'bench' is not defined

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:

  1. Check the result with jnp.bincount(labels, length=5);
  2. Compile the counting function with jax.jit, run it again, and get the same result;
  3. Explain: why does NumPy's counts[labels] += 1 give 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")
⏳ Exercise E4: not answered yet: name 'count_labels' is not defined

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. x becomes [10, 2, 3]
  • B. It raises TypeError; use x = x.at[0].set(10) instead
  • C. It raises IndexError
  • D. It silently fails and x is 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. float64 on CPU, float32 on GPU
  • C. float32, with a warning; to get float64 you need to turn on jax_enable_x64 at 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. a and b are two independent sets of random numbers
  • B. a and b are identical; to get independent random numbers, call jax.random.split(key) first
  • C. The second call raises an error because the key has been "consumed"
  • D. The value of b depends 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 y is 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.dot instead
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) or x.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. jit compiles for speed, grad does automatic differentiation, vmap vectorizes automatically, and parallelism uses shard_map (pmap is 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.print for runtime printing.
  • Arrays are immutable: use x.at[idx].set/add/... to produce a new array. When updating an intermediate inside jit, XLA usually optimizes it into an in-place update (updating an input argument needs donate_argnums). Repeated indices accumulate.
  • 32-bit by default: float32 / int32, regardless of device. For 64 bits, run jax.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; split first. 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 with x.device (attribute), x.devices() (method), and x.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

APIWhat it does
import jax.numpy as jnpNumPy-like interface
jnp.array / zeros / ones / arange / linspaceCreate 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 / sizeArray 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.reshapeReshape (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 / permutationSampling
jax.random.key_data / wrap_key_dataGet 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_mapMulti-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.allMap 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.shardingThe 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=cpuForce CPU only
Environment variable XLA_PYTHON_CLIENT_PREALLOCATE=falseTurn 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.SectionKey pointWhere
11.1 / 1.3Vectorization is not quantization1.1 What is JAX?; 1.3 Core design: function transformations
21.1 / 1.2 / 1.3Use jax.shard_map for parallelism; the jax.pmap docstring marks it as legacy1.1 What is JAX?; 1.3 Core design: function transformations
31.1The JAX docs live at docs.jax.dev, and the code at github.com/jax-ml/jax1.1 What is JAX?
41.2JAX 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
51.2With 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
61.2In the comparison table, JAX's execution model is "asynchronous dispatch; whole-function compilation under jit", and the parallel transformation is shard_map1.2 JAX vs. NumPy
71.2Python scalars are weakly typed and don't promote arrays to 64 bits; integer overflow silently wraps around (NumPy raises)1.2 JAX vs. NumPy
81.2Out-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 behavior1.2 JAX vs. NumPy
91.3"Static" in jit means a static argument (declared with static_argnames), not a token1.3 Core design: function transformations
101.3You 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 loop1.3 Core design: function transformations
111.3print inside a jit function runs only during tracing, and globals become constants; use jax.debug.print for runtime printing1.3 Core design: function transformations
121.4JAX 0.12 needs Python ≥ 3.121.4 Installation and setup
131.4Install the CPU version with pip install -U jax; jaxlib is installed automatically1.4 Installation and setup
141.4Install the GPU version with "jax[cuda12]" / "jax[cuda13]" (or the -local variants), no -f needed anymore; TPU uses "jax[tpu]"; Metal is an experimental plugin1.4 Installation and setup
151.4The 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 config1.4 Installation and setup
161.4 / 1.5 / 1.7Create 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
171.5Random functions don't modify or "consume" keys; the problem is reusing the same key1.5 Working with JAX arrays
181.5When run on its own, the NumPy comparison code needs import numpy as np first, or you get NameError1.5 Working with JAX arrays
191.5 / 1.6 / 1.7Check 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
201.5Calling x.device() raises TypeError, which except AttributeError can't catch; just use the new style1.5 Working with JAX arrays
211.6Using arrays committed to different devices together raises ValueError; there's no automatic transfer1.6 Device management: CPU, GPU, TPU
221.7The array type is jax.Array (implementation class jaxlib._jax.ArrayImpl); DeviceArray has been removed1.7 Hands-on: basic array operations
231.7The default is float32 on every device (CPU included); 64-bit needs jax_enable_x641.7 Hands-on: basic array operations
241.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)
Matches NumPy: True
Row means: [ 0.  0. -0.  0. -0.]
Row standard deviations: [1. 1. 1. 1. 1.]

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
Normal matrix:
 [[ 0.41325292  1.0792645   1.3977896 ]
 [ 1.1918181  -2.0255563   1.004913  ]]
Uniform integers: [8 9 1 6 9]
Random permutation: [0 1 4 3 2 5]
Reproducible: True
Reusing the same subkey gives identical results twice: True

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)
NumPy          :    4.21 ms
JAX op by op   :    2.82 ms
JAX + jit      :    0.27 ms   (first call including compilation 10.6 ms)
jit speedup over NumPy: 15.8x
Results: NumPy=577623.250, JAX=577623.250, jit=577623.250

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.")
Counts: [192 204 201 191 212] total: 1000
Matches bincount, and the result is unchanged under jit

Result of NumPy counts[labels] += 1: [1 1 1 1 1]   <- at most 1 per class, which is wrong
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.