第 1 章 JAX 简介
课程:《JAX 入门》(ApX Machine Learning)第 1 章 · 原课程链接:https://apxml.com/zh/courses/getting-started-with-jax/chapter-1-introduction-to-jax 笔记环境:Python 3.14 · JAX 0.12.0.1 · 仅 CPU。 正文和代码都按 JAX 0.12 的实际行为来写。过时的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法,两边都用
assert验证;需要特别留意的地方用「⚠️ 注意」标出,章末的「本章更新说明」做了汇总。「🔍 补充」是延伸内容。
学习目标
- 说清 JAX 是什么:类 NumPy 接口 + 可组合函数变换(
jit/grad/vmap/shard_map)的数值计算平台,底层由 XLA 编译。 - 掌握
jax.numpy和 NumPy 的异同,尤其是不可变性、默认float32、显式随机数密钥和异步派发。 - 理解“纯函数 + 函数变换”的设计思路,以及追踪(tracing)带来的约束。
- 会安装、验证 JAX,会查看和控制数组所在的设备(CPU / GPU / TPU)。
- 熟练地创建、检查、运算、索引、函数式更新、规约和重塑数组。
本章目录
| 小节 | 内容 |
|---|---|
| 1.1 JAX 是什么? | 定位、两大支柱、XLA 编译流程 |
| 1.2 JAX 对比 NumPy | 相似之处、五大差异、何时用哪个 |
| 1.3 核心设计理念:函数变换 | 纯函数、grad / jit / vmap / 并行及其组合 |
| 1.4 安装与设置 | CPU / GPU / TPU 安装与验证 |
| 1.5 使用 JAX 数组 | 创建、随机数、属性、不可变性、运算、索引 |
| 1.6 设备管理:CPU、GPU、TPU | 查看设备、device_put、主机与设备内存 |
| 1.7 动手练习:基本数组操作 | 基础练习 + 4 道补充练习 |
| 第 1 章测验 | 测验题、答案与解析 |
| 本章小结 & 速查表 | 要点清单、API 速查 |
| 本章更新说明 | 本章在 JAX 0.12 下需要注意的写法和结论 |
讲解按课程要点用中文重新组织过。示例代码尽量沿用课程的变量名和数值,全部在本机实际运行过。
# 全章通用的导入与环境信息
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"默认后端: {jax.default_backend()}")
print(f"设备列表: {jax.devices()}")
1.1 JAX 是什么?¶
JAX 是 Google 开发的 Python 库,主打高性能数值计算,在机器学习研究里用得特别多。它的长处是能充分利用 GPU、TPU 这类加速器。与其把 JAX 看成“又一个数组库”,不如把它看成一个平台:你用熟悉的数组语法写函数,再用 JAX 提供的“变换”去改造这些函数。
JAX 靠两根支柱撑起来:
1. 熟悉的 NumPy 接口
jax.numpy 模块(习惯上导入为 jnp)几乎照搬了 NumPy 的 API。会写 NumPy,上手 JAX 就很快。很多现成的 NumPy 代码,把 np 换成 jnp 就能跑。
2. 可组合的函数变换
这才是 JAX 真正厉害的地方。所谓变换,就是“接收一个函数、返回一个新函数”的高阶函数:
| 变换 | 作用 |
|---|---|
jax.jit | 即时编译(Just-In-Time):用 XLA 把函数编译成针对目标硬件优化过的机器码,减少 Python 开销、融合算子 |
jax.grad | 自动微分:返回一个计算原函数梯度的新函数 |
jax.vmap | 自动向量化(vectorization):把“处理单个样本”的函数变成“处理一批样本”的函数,不用手写循环 |
jax.shard_map | 多设备并行:把计算按数据切分到多个设备上执行(SPMD) |
⚠️ 注意:“向量化”的英文是 vectorization,别和 quantization(量化)弄混。量化是降低数值精度的另一项技术,和
vmap没有关系。⚠️ 注意:旧资料常把
jax.pmap列为第四种主要变换。在 JAX 0.12 里,jax.pmap的文档开头就写着 “Old way of doing parallel map. Usejax.shard_mapinstead.”。它还能运行,但已经是旧接口了。新代码请用jax.shard_map,或者用jax.sharding配合jit自动分片(下方单元验证)。
这些变换可以任意组合,比如 jax.jit(jax.grad(f))、jax.vmap(jax.grad(f))。
整体流程:JAX 拿到用 jax.numpy 写的 Python 函数,先通过追踪把它变成中间表示(jaxpr),再交给 XLA(Accelerated Linear Algebra)编译器,最后针对 CPU / GPU / TPU 生成优化过的代码并执行。
Python 函数 (jnp) ──追踪──▶ jaxpr ──降级──▶ StableHLO ──XLA 编译──▶ CPU / GPU / TPU 机器码
函数式风格:JAX 的变换最适合用在纯函数上。纯函数的输出只由输入决定,也没有副作用(不改全局变量、不做 I/O)。JAX 不会强迫你写纯函数,但如果函数不纯,变换后的行为可能出乎你的意料。带状态的程序(比如模型参数、优化器状态)怎么处理,第 6 章再展开。
⚠️ 注意:JAX 文档已经搬到
docs.jax.dev(旧地址jax.readthedocs.io会自动跳转),代码仓库在github.com/jax-ml/jax。
# 自证:jax.shard_map 是当前推荐的并行接口,jax.pmap 的文档已标注为旧接口
print(jax.pmap.__doc__.strip().splitlines()[0])
assert hasattr(jax, "shard_map")
assert "shard_map" in jax.pmap.__doc__
🔍 补充(1.1):亲眼看看 jaxpr¶
⚪ 选读:想直观看到变换在内部生成了什么时看;第 2、3 章还会反复用到
make_jaxpr。
前面提到了“中间表示”,它到底长什么样?用 jax.make_jaxpr 就能直接看到 JAX 把函数追踪成了什么。看懂 jaxpr,是理解 jit、grad 这些变换最好的入口。grad 本身也只是一次函数变换,输出的仍然是一个 jaxpr。
# 查看 JAX 追踪得到的中间表示 jaxpr
def f(x):
return jnp.sin(x) ** 2 + 1.0
print("f 的 jaxpr:")
print(jax.make_jaxpr(f)(1.0))
print("grad(f) 的 jaxpr(多出了 cos 和乘法,就是链式法则):")
print(jax.make_jaxpr(jax.grad(f))(1.0))
# 自证: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))
# 检查可用设备(CPU 始终存在)
print(f"JAX 设备: {jax.devices()}")
# NumPy 数组创建
np_array = np.array([1.0, 2.0, 3.0])
print(f"NumPy 数组: {np_array}, 类型: {type(np_array)}")
# JAX 数组创建:类型统一用 jax.Array 识别(具体实现类是 ArrayImpl)
jnp_array = jnp.array([1.0, 2.0, 3.0])
print(f"JAX 数组: {jnp_array}, 类型: {type(jnp_array)}")
assert isinstance(jnp_array, jax.Array)
assert not isinstance(jnp_array, np.ndarray) # JAX 数组不是 NumPy 数组
# 基本运算的写法完全一样
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 # 与 NumPy 语法相同
print(f"NumPy 结果:\n{c_np}")
print(f"JAX 结果:\n{c_jnp}")
# 比较结果是否接近(比较浮点结果时很有用)
print(f"结果接近: {np.allclose(c_np, c_jnp)}")
assert np.allclose(c_np, c_jnp)
关键差异¶
差异 1:不可变性(最重要)
NumPy 数组是可变的,可以原地改元素。JAX 数组是不可变的,一旦创建就不能改。想“修改”,就得用索引更新语法 x.at[idx].set(value) 生成一个新数组。为什么这么设计?因为 jit、grad 这些变换要求函数是纯的。如果数组能被原地改写,追踪和求导的结果就靠不住了。
np_array = np.array([1, 2, 3])
np_array[0] = 100 # 在 NumPy 中没问题
print(f"修改后的 NumPy 数组: {np_array}")
jnp_array = jnp.array([1, 2, 3])
try:
# 在 JAX 中会抛出 TypeError
jnp_array[0] = 100
except TypeError as e:
print(f"\n原地修改 JAX 数组出错: {e}")
# JAX 的做法:用 .at[index].set(value) 创建一个带新值的新数组
updated_jnp_array = jnp_array.at[0].set(100)
print(f"\n原始 JAX 数组(未改变): {jnp_array}")
print(f"更新后的 JAX 数组(新对象): {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]))
差异 2:硬件加速 NumPy 只能在 CPU 上跑。JAX 可以在 CPU、GPU、TPU 上跑,而且通常会自动把数据放到最好的可用设备上。
差异 3:执行模型(异步派发 + 可选的整体编译) NumPy 的每个操作都是同步的,算完才返回。JAX 不加 jit 时也是一个操作一个操作地立即执行,但派发是异步的:Python 拿到结果数组的句柄就马上返回,设备在后台慢慢算。等你真正需要数值时(print、转成 NumPy、调用 .block_until_ready()),才会停下来等它算完。加上 jax.jit 之后,整个函数会先编译成一个 XLA 程序再执行。
这一点直接影响测速:不调用 .block_until_ready(),量到的只是派发时间。
⚠️ 注意:常有人把 JAX 说成“惰性求值”,这不准确。JAX 不会把计算攒起来、等你要结果时才算。每个操作都会立刻派发执行,只是不等它算完就返回。在本机 CPU 上(JAX 0.12),派发只要零点几毫秒,等计算完成则要十几毫秒,差了大约两个数量级(具体数值随机器和负载变化),见下方单元。
# 演示异步派发:不等待 vs 等待计算完成
x = jax.random.normal(jax.random.key(0), (2000, 2000))
_ = (x @ x).block_until_ready() # 预热,排除首次调用开销
t0 = time.perf_counter()
y = x @ x # 只是派发,立即返回
t_dispatch = time.perf_counter() - t0
y.block_until_ready() # 等它算完,避免影响下一次计时
t0 = time.perf_counter()
y = (x @ x).block_until_ready() # 等待真正算完
t_real = time.perf_counter() - t0
print(f"不等待(仅派发): {t_dispatch * 1e3:8.3f} ms")
print(f"block_until_ready: {t_real * 1e3:8.3f} ms")
print("结论:测速必须用 .block_until_ready(),否则会严重低估耗时。")
assert t_dispatch < t_real
差异 4:函数变换 jit、grad、vmap 以及并行变换(shard_map)是 JAX 独有的,NumPy 里没有对应的东西。
差异 5:类型与精度 JAX 默认用 32 位类型(float32 / int32),在哪种设备上都一样。NumPy 默认是 64 位(float64 / int64)。这么做是因为加速器上 32 位运算快得多,也更省内存。
默认配置下,JAX 是禁用 64 位类型的。就算你显式写 dtype=jnp.float64,拿到的也只是 float32,外加一条警告。想用 64 位,要在程序启动时、创建任何数组之前执行 jax.config.update("jax_enable_x64", True)(或者设置环境变量 JAX_ENABLE_X64=1)。中途再打开不会报错,但之前创建的数组还是 float32,新旧数组混在一起,精度就不一致了(下方单元验证)。如果只想让一小段代码临时用 64 位,可以写 with jax.enable_x64(True):。
混合类型运算时的类型提升规则也和 NumPy 不太一样,见下方补充。
⚠️ 注意:默认配置下,
jnp.array([1.0, 2.0], dtype=jnp.float64)得到的不是 float64,而是float32,同时会弹出UserWarning: Explicitly requested dtype float64 ... will be truncated to dtype float32(JAX 0.12)。
# 检查默认浮点类型
np_float = np.array([1.0]).dtype
jnp_float = jnp.array([1.0]).dtype
print(f"默认 NumPy 浮点类型: {np_float}")
print(f"默认 JAX 浮点类型: {jnp_float}")
assert np_float == np.float64
assert jnp_float == jnp.float32
# ❌ 旧写法(过时):默认配置下 dtype=jnp.float64 不会生效,会被截断为 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"指定 float64,实际 dtype: {jnp_float64.dtype}")
except Exception as e:
print(f"出错: {type(e).__name__}: {e}")
for w in caught:
print(f"实测警告: {w.message}")
assert jnp_float64.dtype == jnp.float32
assert any("truncated to dtype float32" in str(w.message) for w in caught)
# ✅ 推荐写法:在程序启动时开启 jax_enable_x64
# 该配置要在创建任何数组之前设置;为了不影响本 notebook 的其他单元,这里在子进程里演示
x64_demo = '''
import jax
jax.config.update("jax_enable_x64", True) # 必须在创建数组之前设置
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("开启 x64 后,显式 float64:", a.dtype)
print("开启 x64 后,默认浮点:", jnp.array([1.0]).dtype, ",默认整数:", 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
# 对照:中途才开启 x64 —— 不报错,但开启前创建的数组仍是 float32
late_demo = '''
import jax, jax.numpy as jnp
early = jnp.ones(2) # 开启 x64 之前创建
jax.config.update("jax_enable_x64", True) # 故意晚一步开启
late = jnp.ones(2, dtype=jnp.float64)
assert early.dtype == jnp.float32 and late.dtype == jnp.float64
print("中途开启 x64:之前创建的数组", early.dtype, ",之后创建的数组", 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
总结对比表¶
| 特性 | NumPy | JAX |
|---|---|---|
| API | — | 高度模仿 NumPy |
| 可变性 | 可变 | 不可变 |
| 硬件 | 仅 CPU | CPU / GPU / TPU |
| 执行模型 | 逐操作同步执行 | 逐操作异步派发;jit 后整体编译为 XLA 程序 |
| 函数变换 | 无 | jit / grad / vmap / shard_map |
| 默认精度 | 64 位 | 32 位(与设备无关,64 位需 jax_enable_x64) |
| 随机数 | 全局隐式状态 | 显式传递的 PRNG 密钥 |
| 主要目标 | 通用数值计算 | 高性能、可微分的数值计算 |
| 状态处理 | 允许副作用 | 倾向纯函数、显式传递状态 |
⚠️ 注意:执行模型是“异步派发”,不是“惰性求值”;并行变换用
shard_map,不再是pmap。旧资料的对比表里常把这两格写成“惰性,常通过 JIT”和jit, grad, vmap, pmap,前面已经分别讲过。
什么时候用哪个? 两者完全可以在同一个项目里共存。数据加载、预处理、后处理、和其他库打交道,用普通 NumPy 就好。需要加速、求导、向量化或并行的核心计算,交给 jax.numpy。
🔍 补充(1.2):另外三个容易踩坑的行为差异¶
🟢 必读
(1) 类型提升的两个坑。
- 弱类型(weak type):Python 标量(
1.5、2)参与运算时是“弱类型”,不会把数组提升到 64 位。int32数组 +1.5得到的是float32,NumPy 则会得到float64。 - 整数溢出不报错:NumPy 2 遇到
int8数组 +300会抛OverflowError。JAX 会把 300 按int8截断(回绕),然后悄悄给你一个错误的结果。
# 弱类型:Python 标量不会把精度提到 64 位
print("NumPy: int64 数组 + 1.5 ->", (np.array([1, 2, 3]) + 1.5).dtype)
print("JAX : int32 数组 + 1.5 ->", (jnp.array([1, 2, 3]) + 1.5).dtype)
assert (jnp.array([1, 2, 3]) + 1.5).dtype == jnp.float32
# 整数溢出:NumPy 报错,JAX 静默回绕
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 被截断为 44,结果是错的!)")
assert jnp.array_equal(wrapped, jnp.array([45, 46, 47], dtype=jnp.int8))
(2) 越界索引不报错。 NumPy 越界会抛 IndexError。JAX 为了能在加速器上编译,不做运行时边界检查:读取时会把下标截断到合法范围,.at[].set 越界则直接丢掉这次更新。这是最容易埋 bug 的差异之一。需要明确的越界行为时,用 .at[i].get(mode="fill", fill_value=...)。
a = jnp.arange(5)
try:
np.arange(5)[10]
except IndexError as e:
print(f"NumPy 越界: IndexError: {e}")
print(f"JAX a[10] -> {a[10]} (被截断到最后一个元素)")
print(f"JAX a.at[10].set(99) -> {a.at[10].set(99)} (更新被丢弃)")
print(f"JAX a.at[10].get(mode='fill', fill_value=-1) -> {a.at[10].get(mode='fill', fill_value=-1)}")
assert a[10] == 4
assert jnp.array_equal(a.at[10].set(99), a)
assert a.at[10].get(mode="fill", fill_value=-1) == -1
(3) NumPy ↔ JAX 互转的拷贝语义。 jnp.asarray(np_arr) 和 jnp.array(np_arr) 都会把数据拷到设备上,之后改 NumPy 数组不会影响 JAX 数组。反过来,np.asarray(jax_arr) 得到的 NumPy 数组是只读的;需要可写副本时用 np.array(jax_arr)。
src = np.array([1.0, 2.0])
j = jnp.asarray(src)
src[0] = 99.0
print(f"修改 NumPy 源数组后,JAX 数组仍是: {j}")
assert j[0] == 1.0
back = np.asarray(jnp.ones(2))
print(f"np.asarray(jax 数组) 是否可写: {back.flags.writeable}")
try:
back[0] = 5
except ValueError as e:
print(f"写入报错: {e}")
assert not back.flags.writeable
assert np.array(jnp.ones(2)).flags.writeable # np.array 得到可写副本
1.3 核心设计理念:函数变换¶
JAX 的核心是函数变换。它没有发明一套全新的数据结构或编程模型,而是像一个“元库”,直接作用在你已有的 Python / NumPy 风格代码上。
函数式编程与纯函数¶
JAX 的设计源自函数式编程。纯函数满足两点:
- 输出只看显式输入:相同的输入永远得到相同的输出;
- 没有副作用:不改全局变量、不改输入、不做 I/O(打印、写文件等)。
纯函数很容易被分析、优化、编译、求导和并行化。JAX 做变换时,会拿特殊的**追踪器(tracer)**当输入,把函数执行一遍,记下里面的运算序列(计算图),然后对这张图做变换。
主要变换¶
jax.jit:用 XLA 即时编译,在 GPU / TPU 上提速尤其明显;jax.grad:返回一个计算梯度的新函数;jax.vmap:自动向量化(vectorization),把单样本函数变成批处理函数,通常不用再写循环;jax.shard_map:跨多个设备并行执行(SPMD,单程序多数据)。
⚠️ 注意:和 1.1 一样,向量化是 vectorization,不是 quantization;并行请用
jax.shard_map,pmap是旧接口。
变换的厉害之处在于可组合:可以对梯度函数做 JIT,也可以对包含梯度计算的函数做向量化。
示例:一个线性模型¶
模型与损失(均方误差):
$$ \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):
# 一个简单的线性模型
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) # 均方误差
# grad:对 loss_fn 关于第一个参数 (params) 求导
grad_fn = jax.grad(loss_fn)
# jit:编译梯度函数
jit_grad_fn = jax.jit(grad_fn)
# vmap:假设 predict 处理单个输入,vmap 让它能处理一批输入
# 映射 inputs 参数的第 0 轴,params 在所有样本间共享 (None)
batched_predict = jax.vmap(predict, in_axes=(None, 0))
到这里我们只是定义了变换后的函数,还没有真正调用它们。(比如 print(batched_predict),打印出来的只是一个函数对象。)下面造一点数据,把三个函数真正跑起来,再用解析梯度验证 grad 的结果。
对 MSE 求导,可以得到解析梯度:
$$ \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) $$
注意:参数(params)和数据都是作为参数传进函数的,而不是被函数闭包捕获。这是写 JAX 函数的基本规矩。
# 构造一个小数据集:y = 3*x0 - 2*x1 + 0.5 + 噪声
key = jax.random.key(0)
k_x, k_noise = jax.random.split(key)
inputs = jax.random.normal(k_x, (8, 2)) # 8 个样本,每个 2 维特征
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_fn(params, inputs, targets))
grads = grad_fn(params, inputs, targets)
print("梯度(与 params 结构相同的字典):", grads)
jit_grads = jit_grad_fn(params, inputs, targets)
print("JIT 梯度:", jit_grads)
# 自证 1:jit 前后结果一致
assert jax.tree.all(jax.tree.map(jnp.allclose, grads, jit_grads))
# 自证 2:与解析梯度一致
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 版本:单样本 predict 作用在一批输入上
true_params = {'w': true_w, 'b': jnp.array(true_b)}
single = predict(true_params, inputs[0]) # 单个样本 -> 标量
batch = batched_predict(true_params, inputs) # 一批样本 -> (8,)
print("单样本输出形状:", single.shape, "批输出形状:", batch.shape)
# 自证 3:vmap 结果与逐个样本循环一致
assert batch.shape == (8,)
assert jnp.allclose(batch, jnp.stack([predict(true_params, xi) for xi in inputs]))
把这几个变换拼起来,就是一个完整的梯度下降训练循环。这正是“可组合”的意义:
@jax.jit
def update(params, inputs, targets, lr=0.1):
grads = jax.grad(loss_fn)(params, inputs, targets)
# jax.tree.map 对参数字典中的每一项做同样的更新
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("学到的参数:", p, "\n真实参数: w = [3, -2], b = 0.5")
# 自证:收敛到噪声水平,参数接近真实值
assert loss_fn(p, inputs, targets) < 0.01
assert jnp.allclose(p['w'], true_w, atol=0.15)
追踪是什么意思:JAX 做变换时,会用追踪器代替真实输入,把函数执行一遍。
以 jit 为例,追踪器只带抽象值,也就是形状和 dtype,不带具体数值。例外是被声明为静态参数(static argument,即 jax.jit(f, static_argnames=...))的参数。单独用 grad 时就不一样了:追踪器仍然带着具体数值,所以 Python if 在 grad 下照样能用(第 3 章 3.7 详细讲)。
jit 只在第一次遇到某种输入形状/dtype 时追踪。所以函数里的副作用(打印、改全局变量)可能只发生一次,变换后的函数就会和你预想的不一样。数组不可变,也正是纯函数要求的体现。
⚠️ 注意:“只用抽象值追踪”说的是
jit,不是所有变换都这样。单独用grad时,依赖数值的 Pythonif可以正常求导,套上jit才会报TracerBoolConversionError(下方补充 (1) 的单元验证)。
⚠️ 注意:有的中文资料把这里译成“标记 (token) 为静态”,其实说的是把参数声明为静态参数(static argument)。
🔍 补充(1.3)¶
🟢 必读
(1) 亲眼看看“副作用只在追踪时发生”。 下面这个函数里的 print,只在第一次用某种形状/类型调用时执行(也就是追踪的时候)。之后命中编译缓存,就不再打印了。输入形状一变,又会重新追踪。想在编译后的函数里打印运行时的数值,要用 jax.debug.print。
counter = {"n": 0}
@jax.jit
def impure(x):
print(" [Python print] 正在追踪, x =", x) # 追踪时执行,x 是一个 Tracer
jax.debug.print(" [debug.print] 运行时 x = {}", x) # 每次运行都会执行
counter["n"] += 1 # 副作用:只在追踪时发生
return x * 2
print("第 1 次调用 (形状 (2,))"); impure(jnp.ones(2)).block_until_ready()
print("第 2 次调用 (形状 (2,))"); impure(jnp.ones(2)).block_until_ready()
print("第 3 次调用 (形状 (3,))"); impure(jnp.ones(3)).block_until_ready()
print(f"counter = {counter['n']}(调用了 3 次,但只追踪了 2 次)")
assert counter["n"] == 2
# 对照:“只带形状和 dtype 的抽象值”是 jit 的行为;单独 grad 时追踪器带具体数值
def py_if(x):
return x * x if x > 0 else -x
assert jax.grad(py_if)(-2.0) == -1.0 # 单独 grad:Python if 正常
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:抽象值无法转成 bool
print("单独 grad 时 Python if 可用;jit 下报 TracerBoolConversionError")
(2) 不纯函数的经典翻车:读取全局变量。 追踪时,全局变量的值被当成常量编进了编译结果。之后再改这个全局变量,jit 函数是看不到的。所以,所有会变的量都要作为参数显式传进去。
scale = 2.0
@jax.jit
def scaled(x):
return x * scale # 读取全局变量 —— 不纯
print("scale=2 时:", scaled(jnp.array(1.0)))
scale = 100.0
print("把 scale 改成 100 后:", scaled(jnp.array(1.0)), " <- 仍然是 2,用的是追踪时的旧值")
assert scaled(jnp.array(1.0)) == 2.0
# 正确做法:把会变化的量作为参数传入
scaled_ok = jax.jit(lambda x, s: x * s)
assert scaled_ok(jnp.array(1.0), scale) == 100.0
(3) grad 的几个实用细节(马上就会用到):
jax.grad要求函数返回标量。默认对第 0 个参数求导,用argnums=(0, 1)可以同时对多个参数求导。- 参数可以是字典、列表这类嵌套结构(JAX 叫它 pytree),梯度会按同样的结构返回,上面的
grads就是例子。 - 训练时通常既要损失值又要梯度,用
jax.value_and_grad一次算完,省得重复做前向计算。
loss_val, grads2 = jax.value_and_grad(loss_fn)(params, inputs, targets)
print("损失:", loss_val)
print("梯度:", grads2)
assert jnp.allclose(loss_val, loss_fn(params, inputs, targets))
# 对多个参数求导:argnums=(0, 1) 同时对 params 和 inputs 求导
g_params, g_inputs = jax.grad(loss_fn, argnums=(0, 1))(params, inputs, targets)
print("对 inputs 的梯度形状:", g_inputs.shape)
assert g_inputs.shape == inputs.shape
# 非标量输出会报错
try:
jax.grad(predict)(params, inputs)
except TypeError as e:
print("非标量输出求导报错:", str(e).splitlines()[0])
1.4 安装与设置¶
环境要求:JAX 0.12 需要 Python ≥ 3.12。建议用虚拟环境,免得和系统里的其他包冲突:
# 方式一:venv
python3.12 -m venv jax-env
source jax-env/bin/activate # Windows: jax-env\Scripts\activate
# 方式二:conda
conda create -n jax-env python=3.12
conda activate jax-env
安装命令(以 JAX 官方安装说明 为准):
| 平台 | 命令 | 说明 |
|---|---|---|
| CPU(Linux / macOS / Windows) | pip install -U jax | jaxlib 是 jax 的依赖,会自动装上 |
| NVIDIA GPU,CUDA 12 | pip install -U "jax[cuda12]" | 通过 pip 自带 CUDA / cuDNN 运行库,本机只要装好 NVIDIA 驱动 |
| NVIDIA GPU,CUDA 13 | pip install -U "jax[cuda13]" | 同上,需要较新的驱动 |
| 本机已自行安装 CUDA | pip install -U "jax[cuda12-local]" / "jax[cuda13-local]" | 用本机的 CUDA,版本是否匹配要自己保证 |
| Google Cloud TPU VM | pip install -U "jax[tpu]" | Colab 的 TPU 运行时一般已经预装 |
| Apple Silicon GPU(Metal) | pip install jax-metal | 实验性插件,只支持部分算子,对 JAX 版本要求很严,学习阶段建议直接用 CPU |
| Windows + NVIDIA GPU | 在 WSL2 中按 Linux 方式安装 | 原生 Windows 只支持 CPU |
⚠️ 注意:旧教程里常见这些写法:“需要 Python 3.7+”,CPU 用
pip install --upgrade jax jaxlib,GPU 用pip install --upgrade "jax[cuda11_cudnn82]" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.html。它们现在都过时了。从 JAX 0.12 的包元数据(见下方单元)可以看到:要求是Requires-Python >=3.12;可用的扩展里已经没有cuda11_cudnn82(CUDA 11 不再支持,也不用再拿-f指定下载页);jaxlib也不用单独写出来。
# 自证:从已安装的 jax 包元数据读出 Python 版本要求和可用的安装扩展
import importlib.metadata as md_
meta = md_.metadata("jax")
extras = meta.get_all("Provides-Extra")
print("jax 版本:", meta["Version"])
print("Requires-Python:", meta["Requires-Python"])
print("可用的安装扩展 (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 会作为依赖自动安装
验证安装:跑一个小脚本,确认 JAX 能找到设备、能完成一次计算。下面先跑一遍课程示例里的旧写法(用 PRNGKey),再给出改用 jax.random.key 的推荐版本。脚本最后打印的数值是一组随机数的平方和,具体是多少取决于 JAX 版本和 RNG 配置,和别人不一样很正常。
⚠️ 注意:课程示例给出的结果是
10.811179161071777。在 JAX 0.12(CPU)上,同一个种子得到的是9.738718...。就算把jax_threefry_partitionable改回False,也只能得到2.658...,都对不上(下方单元验证)。JAX 只保证同一版本、同一配置下可以复现,所以写测试时别把随机数的具体数值写死。
# ❌ 旧写法(过时):旧式 PRNGKey;另外,示例输出 10.811179161071777 在 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"原示例给出的值: {COURSE_VALUE}")
print(f"实测输出 : {y_course}")
except Exception as e:
print(f"出错: {type(e).__name__}: {e}")
assert abs(y_course - COURSE_VALUE) > 1e-3 # 示例里的数值复现不了
# ✅ 推荐写法:安装验证脚本(改用 jax.random.key)
try:
devices = jax.devices()
print("JAX 检测到以下设备:")
for i, device in enumerate(devices):
print(f"{i}: {device.platform.upper()} ({device.device_kind})")
# 测试一个简单计算
key = jax.random.key(0)
x = jax.random.normal(key, (10,))
y = jnp.dot(x, x)
print(f"\n成功执行了一个简单的 JAX 操作。结果:{y}")
# 检查默认后端
print(f"\n默认后端:{jax.default_backend()}")
except Exception as e:
print("JAX 验证过程中发生错误:")
print(e)
print("\n请检查安装步骤,特别是 GPU 驱动 / CUDA 版本(如果适用)。")
# 自证:结果与 NumPy 独立计算一致;新旧密钥写法得到的随机数相同
assert np.isclose(float(y), np.dot(np.asarray(x), np.asarray(x)), rtol=1e-5)
assert np.isclose(float(y), y_course)
可能的输出有三种:只有 CPU(0: CPU (cpu),默认后端 cpu);单块 GPU(如 0: GPU (NVIDIA ...),默认后端 gpu);多块 GPU 或 TPU(会列出多个设备)。
# 补充演示:随机数的具体数值依赖 RNG 配置(在子进程中切换 jax_threefry_partitionable)
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"原示例给出的值 : {COURSE_VALUE}")
assert values[True] != values[False] # 配置不同,随机数不同
assert all(abs(v - COURSE_VALUE) > 1e-3 for v in values.values()) # 都对不上示例里的数值
🔍 补充(1.4):排查环境问题¶
⚪ 选读:环境出问题、需要提 issue 或求助时再看。
jax.print_environment_info() 会打印 jax / jaxlib / numpy / Python 版本和设备信息。提 issue 或求助时,直接贴它的输出最省事。
jax.print_environment_info()
# 创建数组
print(f"JAX 版本: {jax.__version__}")
# 从列表创建数组
python_list = [1.0, 2.0, 3.0]
jax_array_from_list = jnp.array(python_list)
print(f"从列表创建的数组: {jax_array_from_list}")
print(f"类型: {type(jax_array_from_list)}")
assert isinstance(jax_array_from_list, jax.Array)
# 从嵌套列表创建二维数组
python_nested_list = [[1, 2], [3, 4]]
jax_2d_array = jnp.array(python_nested_list)
print(f"二维数组:\n{jax_2d_array}")
# 全零数组
zeros_array = jnp.zeros((2, 3)) # 形状 (2 行, 3 列)
print(f"全零数组:\n{zeros_array}")
# 全一数组
ones_array = jnp.ones((3,), dtype=jnp.int32) # 形状 (3,),整数类型
print(f"全一数组: {ones_array}")
print(f"全一数组数据类型: {ones_array.dtype}")
# 等差整数序列
range_array = jnp.arange(5) # 类似 Python 的 range(5) -> [0, 1, 2, 3, 4]
print(f"arange 数组: {range_array}")
# 线性等分
linspace_array = jnp.linspace(0, 1, 5) # 从 0 到 1(包含端点)的 5 个点
print(f"linspace 数组: {linspace_array}")
assert zeros_array.dtype == jnp.float32 and ones_array.dtype == jnp.int32
随机数¶
JAX 的随机数和 NumPy 很不一样。NumPy 靠一个藏起来的全局状态,每次调用 np.random.rand() 都会悄悄更新它。这是副作用,和纯函数的思路冲突。JAX 改用显式的伪随机数生成器(PRNG)密钥:
- 用
jax.random.key(seed)创建密钥; - 随机函数接收一个密钥,输出只由密钥决定,不会修改密钥,也没有全局状态;
- 同一个密钥 → 同样的随机数(可复现);
- 需要新的随机数时,用
jax.random.split把密钥拆分出新的子密钥。
⚠️ 注意:很多旧代码用
random.PRNGKey(0)创建密钥。这个旧接口还能用,但官方已经建议新代码改用jax.random.key(0)。两者生成的随机数完全一样,区别见下方对比。另外,“这个操作会消耗密钥”的说法容易误导人:随机函数不会改变、也不会“用掉”密钥。真正的规矩是:同一个密钥不要再拿去生成另一组独立的随机数。
from jax import random
# 创建初始密钥(推荐写法),种子通常是整数
key = random.key(0)
print(f"初始密钥: {key.dtype},底层数据 {random.key_data(key)}")
# 生成正态分布随机数。随机函数“使用”密钥,但不会修改它
normal_random_numbers = random.normal(key, shape=(2, 2))
print(f"正态随机数:\n{normal_random_numbers}")
# 要生成更多随机数,先拆分密钥
key, subkey = random.split(key) # 得到一个新密钥和一个子密钥
uniform_random_numbers = random.uniform(subkey, shape=(3,))
print(f"\n拆分后的密钥: {random.key_data(key)}")
print(f"子密钥: {random.key_data(subkey)}")
print(f"均匀随机数: {uniform_random_numbers}")
# 用“相同”的原始密钥再调用一次,结果完全相同 —— 体现纯度和可复现性
same_normal_numbers = random.normal(random.key(0), shape=(2, 2))
print(f"\n相同的正态随机数:\n{same_normal_numbers}")
assert jnp.allclose(normal_random_numbers, same_normal_numbers)
jax.random.key 与 jax.random.PRNGKey 的区别
jax.random.PRNGKey(0)(旧) | jax.random.key(0)(新,推荐) | |
|---|---|---|
| 返回值 | 普通 uint32 数组,形状 (2,) | 类型化密钥数组,dtype 为 key<fry>,形状 () |
| 能否误当成数据参与运算 | 能(key + 1 不报错,容易出隐蔽 bug) | 不能,会直接报错 |
| 批量密钥的形状 | split(k, 3) → (3, 2),多一个“实现细节”维度 | split(k, 3) → (3,),形状直观 |
| 切换 RNG 算法 | PRNGKey(0, impl="rbg") 也能指定,但得到的只是 uint32[4] 原始数据,算法信息不随数组保存,之后每次使用都得靠全局配置或自己记住(否则直接报错) | key(0, impl="rbg"),算法记录在 dtype 里(key<rbg>),之后自动按它生成 |
| 取出底层数据 | 本身就是数据 | jax.random.key_data(k) |
需要和旧代码互通时,用 jax.random.wrap_key_data(raw) 把旧式 uint32 数组包装成新式密钥。随机数管理的深入内容(各 PRNG 算法、序列化、scan/vmap 中的 key 管理)见 进阶 10 随机数与分片。
# key 与 PRNGKey 对比演示(除 1.4 的 ❌ 对照单元外,PRNGKey 只出现在这里)
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)}")
# 生成的随机数相同
same = jnp.array_equal(random.normal(old_key, (3,)), random.normal(new_key, (3,)))
print("随机数相同:", same)
# 批量拆分后的形状
print("split(PRNGKey, 3).shape =", random.split(old_key, 3).shape)
print("split(key, 3).shape =", random.split(new_key, 3).shape)
# 旧式密钥能被误当成数据运算,新式密钥会报错
print("旧式 key + 1 =", old_key + 1, "(静默成功,很危险)")
try:
new_key + 1
except TypeError as e:
print("新式 key + 1 报错:", 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) # 新旧互转
# 两种写法都能指定 RNG 算法,但只有类型化密钥会把算法记在 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,)) # 原始数据不带算法信息:按默认 threefry 解释,形状对不上
raw_ok = True
except TypeError:
raw_ok = False
assert not raw_ok
random.normal(typed_rbg, (2,)) # 类型化密钥自带算法信息,直接可用
🔍 补充(1.5-a):随机数的头号易错点——密钥复用¶
🟢 必读
同一个密钥不要用两次。 用同一个密钥、同样的分布和形状生成两组“独立”的随机数,它们其实一模一样。就算换成不同的分布,两组数也来自同一串随机比特,彼此高度相关(比如同一个密钥的 normal 和 uniform 结果是单调对应的),一样不独立。
规范的做法是:每次要用随机数之前先 split,用子密钥生成随机数,把拆出来的新主密钥继续往下传,旧密钥不再使用。一次要多个密钥时用 split(key, n)。在循环里也可以用 jax.random.fold_in(key, step) 按步数派生密钥。更多“复用导致相关”的例子见 进阶 10 §6.1。
key = jax.random.key(42)
# 错误:同一个密钥用了两次
w1 = random.normal(key, (3,))
w2 = random.normal(key, (3,))
print("复用密钥 -> 两组“随机”数完全相同:", jnp.array_equal(w1, w2))
assert jnp.array_equal(w1, w2)
# 正确:拆分出独立的子密钥
key, k1, k2 = random.split(key, 3)
w1, w2 = random.normal(k1, (3,)), random.normal(k2, (3,))
print("拆分子密钥 -> 不同:", not jnp.array_equal(w1, w2))
assert not jnp.array_equal(w1, w2)
# 循环中用 fold_in 按步数派生密钥
base = jax.random.key(0)
for step in range(3):
print(f"step {step}:", random.uniform(jax.random.fold_in(base, step), (2,)))
# 换成不同的分布也不行:同一密钥的 uniform 与 normal 排序完全一致(单调对应)
u_same, z_same = random.uniform(key, (1000,)), random.normal(key, (1000,))
print("同一密钥的 uniform 与 normal 排序一致:", bool(jnp.all(jnp.argsort(u_same) == jnp.argsort(z_same))))
assert jnp.all(jnp.argsort(u_same) == jnp.argsort(z_same))
数组属性¶
和 NumPy 一样,JAX 数组有 shape、dtype、ndim、size 这些属性。JAX 默认用 32 位精度(float32 / int32),在加速器上通常更快。打开 64 位精度会变慢,也更占内存。
x = jnp.arange(12).reshape((3, 4))
print(f"数组 x:\n{x}")
print(f"形状: {x.shape}") # 各维度大小组成的元组 (行数, 列数)
print(f"数据类型: {x.dtype}") # 元素类型,例如 float32、int32
print(f"维度数量: {x.ndim}") # 轴的数量(矩阵为 2)
print(f"元素总数: {x.size}") # 元素总数 3 * 4 = 12
assert x.shape == (3, 4) and x.dtype == jnp.int32 and x.ndim == 2 and x.size == 12
不可变性与函数式更新¶
JAX 数组不可变。想原地赋值会抛 TypeError。正确做法是用 .at[...] 这一系列方法,它们返回新数组,原数组保持不变。
⚠️ 注意:下面的 NumPy 对照代码用到了
np.array。如果把它单独复制出去运行,记得先import numpy as np,不然会报NameError(下方 ❌ 单元在一个空白命名空间里复现了这个错误)。
# ❌ 旧写法(过时):代码用到 np 却没有 import numpy as np,直接运行报 NameError
course_src = '''
numpy_array = np.array([1, 2, 3])
numpy_array[0] = 100
'''
try:
exec(course_src, {}) # 在空白命名空间中执行,模拟把这段代码单独复制出去运行
course_ok = True
except NameError as e:
course_ok = False
print(f"实测: NameError: {e}")
assert not course_ok
# ✅ 推荐写法:先导入 NumPy(本教程开头已经统一 import numpy as np)
import numpy as np
numpy_array = np.array([1, 2, 3])
numpy_array[0] = 100 # NumPy 中正常
print(f"修改后的 NumPy 数组: {numpy_array}")
jax_array = jnp.array([1, 2, 3])
try:
# 会报错,因为 JAX 数组不可变
jax_array[0] = 100
except TypeError as e:
print(f"\n尝试原地修改 JAX 数组时出错: {e}")
# 改用函数式的“索引更新”语法,返回新数组
updated_jax_array = jax_array.at[0].set(100)
print(f"\n原始 JAX 数组(未改变): {jax_array}")
print(f"更新后的 JAX 数组(新对象): {updated_jax_array}")
# 还可以做更多种类的更新
incremented_array = jax_array.at[1].add(10) # 索引 1 处的元素加 10
print(f"递增后的数组: {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]))
🔍 补充(1.5-b):.at[] 的完整家族与“拷贝开销”¶
⚪ 选读:需要做 scatter 类更新(如直方图计数)或关心
.at[]的拷贝开销时看。
- 方法一览:
set、add、subtract、multiply、divide、power、min、max、apply、get。索引可以是整数、切片、整数数组或布尔掩码。布尔掩码在jit里不能用,因为结果形状不固定,这时改用jnp.where。 - 重复下标会累加:
x.at[[0, 0]].add(1)会让x[0]加 2;而 NumPy 的a[[0, 0]] += 1只加 1(想累加得用np.add.at)。练习 E4 会用到这个性质。 - “每次都拷贝整个数组,会不会很慢?” 在
jit编译的函数里,如果被更新的是中间结果、之后不再使用,XLA 通常会把.at[].set优化成原地更新,不会多拷贝。但如果被更新的是 jit 函数的输入参数,调用方手里还拿着原数组,XLA 只能先拷贝一份。想省掉这次拷贝,得用donate_argnums把输入缓冲区“捐赠”出去(见第 2 章 2.6 的donate_argnums补充)。不加jit、逐个操作执行时,每次更新都会拷贝。下方单元通过检查编译后的 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))
# 重复下标:JAX 累加,NumPy 不累加
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)
# jit 内用 jnp.where 代替布尔掩码赋值
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("\njnp.where 实现 ReLU:", out)
assert jnp.array_equal(out, jnp.array([0., 2., 0., 4.]))
# 原地更新:检查编译后的 HLO 里有没有 copy 指令
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) # 更新中间结果:原地
assert hlo_has_copy(jax.jit(lambda v: v.at[0].set(1.0)), v0) # 更新输入参数:先拷贝
assert not hlo_has_copy(jax.jit(lambda v: v.at[0].set(1.0), donate_argnums=0), v0) # 捐赠输入后:原地
print("\nHLO 检查:更新中间结果无 copy;更新输入参数有 copy;donate_argnums 后无 copy")
运算¶
JAX 支持和 NumPy 一样的元素级运算、矩阵乘法(jnp.dot 或 @)以及通用函数(jnp.sin、jnp.exp 等)。
a = jnp.array([[1, 2], [3, 4]])
b = jnp.array([[5, 6], [7, 8]])
# 元素级运算
print(f"a + b:\n{a + b}")
print(f"a * b:\n{a * b}") # 元素级乘法
# 矩阵乘法
print(f"矩阵积 (jnp.dot):\n{jnp.dot(a, b)}")
print(f"矩阵积 (@):\n{a @ b}")
# 通用函数
print(f"a 的正弦:\n{jnp.sin(a)}")
print(f"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)))
索引与切片¶
基本索引和切片和 NumPy 一样。区别在于:JAX 的切片返回的是新数组,不像 NumPy 那样返回共享内存的视图。(反正也不能原地改,是不是视图在语义上并不重要。)
data = jnp.arange(10)
print(f"原始数据: {data}")
# 获取单个元素
print(f"索引 3 处的元素: {data[3]}")
# 获取切片
print(f"从索引 2 到 5 的切片: {data[2:5]}")
# 多维索引
matrix = jnp.arange(9).reshape((3, 3))
print(f"矩阵:\n{matrix}")
print(f"第 1 行第 2 列的元素: {matrix[1, 2]}")
print(f"前两行:\n{matrix[:2, :]}")
print(f"第一列:\n{matrix[:, 0]}")
assert data[3] == 3 and matrix[1, 2] == 5
assert jnp.array_equal(matrix[:, 0], jnp.array([0, 3, 6]))
查看数组所在设备¶
想知道数组在哪个设备上,用属性 x.device(返回单个设备)。如果数组可能分布在多个设备上,用方法 x.devices()(返回设备集合)。
⚠️ 注意:旧代码里常见
x.device()这种写法,还会配一个except AttributeError兜底,理由是“较旧的 JAX 版本可能无法直接使用.device()”。实际情况正好相反:是新版 JAX 把device从方法改成了属性。在 JAX 0.12 里调用x.device(),抛出的是TypeError: 'Device' object is not callable,except AttributeError根本接不住。
# ❌ 旧写法(过时):x.device() 已改为属性;用 except AttributeError 兜底也接不住新的错误
x = jnp.ones(3)
escaped = None
try:
try:
print(f"数组 x 的设备类型: {x.device().platform}")
except AttributeError:
print("设备信息可能需要特定上下文或检查 jax.devices()。") # 兜底分支,不会被执行
except TypeError as e:
escaped = e
print(f"TypeError 没有被 except AttributeError 接住:{e}")
assert isinstance(escaped, TypeError)
# ✅ 推荐写法
x = jnp.ones(3)
print(f"x.device -> {x.device}(属性,不加括号)")
print(f"x.device.platform -> {x.device.platform}")
print(f"x.devices() -> {x.devices()}(方法,返回设备集合)")
print(f"可用设备: {jax.devices()}")
assert isinstance(x.device, jax.Device)
assert x.devices() == {x.device}
assert x.device.platform == jax.default_backend()
# 列出所有可用设备
devices = jax.devices()
print(f"可用设备: {devices}")
# 默认后端
print(f"默认后端: {jax.default_backend()}")
# 每个设备的详细信息
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)
典型输出:
# 仅 CPU
可用设备: [CpuDevice(id=0)]
默认后端: cpu
# 有 NVIDIA GPU
可用设备: [CudaDevice(id=0)]
默认后端: gpu
# 有 TPU
可用设备: [TpuDevice(id=0, ...), TpuDevice(id=1, ...), ...]
默认后端: tpu
注意:有加速器时,jax.devices() 只列出默认后端的设备。CPU 设备要用 jax.devices("cpu") 单独拿。
数据放置¶
默认情况下,JAX 数组创建在默认设备上(有 GPU 就在第一块 GPU 上),用属性 x.device 查看。别写成 x.device(),这个写法已经过时了,见 1.5 的说明和下面的“x.device() 现在怎么写”。
x = jnp.arange(10.0)
print(f"数组 x 位于: {x.device}")
assert x.device == jax.devices()[0]
x.device() 现在怎么写¶
旧资料里常见这样一行:
print("设备:", random_array.device())
在新版里它会报错,因为新版 JAX 把 Array.device 从方法改成了属性(为了和 Python 数组 API 标准 保持一致),加括号调用会报 TypeError。现在有三种写法,按需要选:
| 写法 | 返回 | 什么时候用 |
|---|---|---|
random_array.device | 单个 Device | 最常用;数组只在一个设备上时 |
random_array.devices() | set[Device] | 数组可能分片到多个设备上时 |
random_array.sharding | Sharding 对象 | 需要完整的放置/分片信息时(后面的并行章节会用到) |
# ❌ 旧写法(过时):Array.device 已从方法改为属性,加括号调用会报 TypeError
random_array = jax.random.uniform(jax.random.key(42), shape=(2, 2))
try:
print("设备:", random_array.device())
old_api_works = True
except TypeError as e:
old_api_works = False
print(f"实测: TypeError: {e}")
assert not old_api_works
# ✅ 推荐写法:三种查看方式
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 # 单设备数组:完整数据都在这一个设备上
用 jax.device_put 可以把数据显式放到指定设备上。下面的示例假设有多个设备,用了 devices[0] 和 devices[1]。在只有一个设备的机器上 devices[1] 不存在,所以这里用 try/except 包了一下:
devices = jax.devices()
x = jnp.arange(10.0)
# 放到第一个设备
x_dev0 = jax.device_put(x, devices[0])
print(f"x_dev0 位于: {x_dev0.device}")
# 放到第二个设备(需要至少两个设备)
try:
x_dev1 = jax.device_put(x, devices[1])
print(f"x_dev1 位于: {x_dev1.device}")
except IndexError:
print(f"只有 {len(devices)} 个设备,无法放到 devices[1]")
# 显式放到 CPU(有 GPU 时很有用)
cpu_device = jax.devices("cpu")[0]
x_cpu = jax.device_put(x, cpu_device)
print(f"x_cpu 位于: {x_cpu.device}")
assert x_cpu.device == cpu_device
# 不指定设备时,新数组在默认设备上
y = jnp.ones(5)
print(f"y 位于默认设备: {y.device}")
# 查询不存在的后端会抛 RuntimeError
try:
jax.devices("gpu")
except RuntimeError as e:
print(f"\njax.devices('gpu') 报错: {e}")
跨设备运算¶
参与运算的数组如果在不同设备上,JAX 怎么处理,要看数组有没有被**提交(committed)**到某个设备:
- 用
jax.device_put(x, device)显式放置的数组是 committed 的; - 默认创建的数组(比如
jnp.ones(4))是 uncommitted 的,JAX 会在需要时把它自动搬到运算所在的设备(有传输开销); - 两个 committed 到不同设备的数组直接运算,JAX 不会自动搬运,而是抛
ValueError。得先用device_put把它们放到同一个设备上。
⚠️ 注意:跨设备运算不只是“多一点传输开销”那么简单。在 JAX 0.12 里,两个 committed 到不同设备的数组相加,会直接抛
ValueError: Received incompatible devices for jitted computation,JAX 不会替你搬数据。
本机只有一个 CPU,下面用 jax_num_cpu_devices 配置模拟出 4 个 CPU 设备来演示。这个配置必须在 JAX 初始化后端之前设置,所以放到子进程里跑。这个技巧也很适合在笔记本电脑上调试多设备代码。
multi_dev_demo = '''
import jax
jax.config.update("jax_num_cpu_devices", 4) # 模拟 4 个 CPU 设备,必须在使用 JAX 之前设置
import jax.numpy as jnp
devs = jax.devices()
assert len(devs) == 4
print("模拟的设备:", devs)
x = jnp.arange(4.0)
a = jax.device_put(x, devs[1]) # committed 到 cpu:1
b = jax.device_put(x, devs[2]) # committed 到 cpu:2
print("a 在", a.device, ",b 在", b.device)
assert (a + 1).device == devs[1]
assert (a + jnp.ones(4)).device == devs[1] # uncommitted 数组被自动搬到 cpu:1
print("a + jnp.ones(4)(uncommitted 数组被自动搬过来)在", (a + jnp.ones(4)).device)
raised = False
try:
a + b
except ValueError as e:
raised = True
print("a + b 报错 ValueError:", str(e)[:70], "...")
assert raised
# 正确做法:先把数据显式搬到同一设备
c = a + jax.device_put(b, devs[1])
assert c.device == devs[1] and jnp.array_equal(c, 2 * x)
print("搬到同一设备后 a + b =", c, ",位于", c.device)
print("ALL_OK")
'''
r = subprocess.run([sys.executable, "-c", multi_dev_demo], capture_output=True, text=True)
print(r.stdout or r.stderr)
assert r.returncode == 0 and "ALL_OK" in r.stdout
主机与设备¶
- 主机(host):CPU 和它的内存。普通 Python 对象、NumPy 数组都放在主机内存里。
- 设备(device):加速器(GPU / TPU)和它的显存。JAX 数组放在设备内存里。
推荐的工作流:
- 在主机上加载、预处理数据(常用 NumPy);
- 用
jax.device_put(或jnp.asarray)把数据一次性传到设备; - 在设备上用
jit/grad/vmap等完成核心计算; - 只在需要时把结果传回主机(打印、保存、画图)。
主机和设备之间的传输经常是性能瓶颈,能少搬就少搬。
🔍 补充(1.6)¶
⚪ 选读:在 GPU 上训练、需要控制数据放在哪个设备或与其他框架共用显卡时看。
(1) 设备 → 主机: 用 np.asarray(x) 或 jax.device_get(x) 把数据传回主机;标量用 x.item() 或 float(x)。注意这些操作都会同步等待计算完成。在训练循环里每一步都调用,会拖慢速度(比如每步都 print(loss))。
(2) 临时切换默认设备: with jax.default_device(jax.devices("cpu")[0]): ...,块里新建的数组会放在 CPU 上,适合把某些小计算留在 CPU。
(3) 两个常用环境变量(要在 JAX 初始化后端之前生效,最稳妥的是在启动 Python 之前就设好):
JAX_PLATFORMS=cpu:强制只用 CPU。有 GPU 时可以用来调试或对比结果;XLA_PYTHON_CLIENT_PREALLOCATE=false:JAX 在 GPU 上默认会预分配约 75% 显存,和 PyTorch 等共用一块 GPU 时很容易 OOM。设置这个变量就能改成按需分配(也可以用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("标量 .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("default_device 块内创建的数组位于:", z.device)
assert z.device == jax.devices("cpu")[0]
# 综合练习:数组的创建、属性与设备
# numpy 通常用于对比,或用于 JAX 不支持的操作(已在开头导入)
print(f"JAX 版本: {jax.__version__}")
print(f"JAX 默认后端: {jax.default_backend()}")
# 检查可用设备(取决于你的环境)
print(f"可用 JAX 设备: {jax.devices()}")
从列表创建的数组,类型是 jax.Array(具体实现类显示为 jaxlib._jax.ArrayImpl)。不管在 CPU、GPU 还是 TPU 上,默认浮点类型都是 float32。要 64 位就必须打开 jax_enable_x64(见 1.2)。
⚠️ 注意:旧资料里常说输出类型“通常是
jaxlib.xla_extension.DeviceArray或类似类型”,还说“CPU 上默认 64 位浮点,GPU/TPU 上默认 32 位”。这两点在 JAX 0.12 里都不成立:DeviceArray早就移除了,类型是jax.Array;CPU 上默认 dtype 也是float32(下方单元用assert验证)。
# 从 Python 列表创建
py_list = [1.0, 2.5, 3.0, 4.2]
jax_array_from_list = jnp.array(py_list)
print("从列表创建的 JAX 数组:", jax_array_from_list)
print("类型:", type(jax_array_from_list))
print("数据类型:", jax_array_from_list.dtype)
# 自证:类型是 jax.Array,CPU 上默认也是 float32
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
# 创建特定数组
zeros_array = jnp.zeros((2, 3)) # 形状 (2 行, 3 列)
print("\n零数组:\n", zeros_array)
ones_array = jnp.ones((3, 2), dtype=jnp.int32) # 指定数据类型
print("\n全一数组 (int32):\n", ones_array)
print("数据类型:", ones_array.dtype)
range_array = jnp.arange(0, 10, 2) # 起始值, 停止值(不含), 步长
print("\n范围数组:", range_array)
linspace_array = jnp.linspace(0, 1, 5) # 起始值, 停止值(含), 点数
print("\n等差数组:", 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.]))
练习 2:随机数与数组检查¶
# 创建 PRNG 密钥
key = jax.random.key(42) # 种子为 42
# 生成 [0, 1) 均匀分布随机数
random_array = jax.random.uniform(key, shape=(2, 2))
print("随机数组 (均匀分布):\n", random_array)
# 重要提示:要获得新的随机数,必须先“拆分”密钥
key, subkey = jax.random.split(key)
random_normal_array = jax.random.normal(subkey, shape=(3,)) # 标准正态分布
print("\n随机数组 (正态分布):\n", random_normal_array)
assert ((random_array >= 0) & (random_array < 1)).all()
print("检查 random_array:")
print("形状:", random_array.shape)
print("大小:", random_array.size) # 元素总数
print("维度数:", random_array.ndim)
print("数据类型:", random_array.dtype)
# 检查数组所在的设备(属性,不加括号;旧写法 .device() 的对照见 1.6)
print("设备:", 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
练习 3:算术运算与不可变性¶
所有运算都返回新数组,原数组 a 保持不变。
a = jnp.array([1, 2, 3])
b = jnp.array([4, 5, 6])
# 按元素运算
c_add = a + b
print("a + b =", c_add)
c_mul = a * b
print("a * b =", c_mul)
# 与标量运算(广播)
c_scalar_add = a + 10
print("a + 10 =", c_scalar_add)
c_scalar_mul = a * 2
print("a * 2 =", c_scalar_mul)
# 检查 a 是否保持不变(不可变性)
print("原始 'a':", a)
assert jnp.array_equal(a, jnp.array([1, 2, 3]))
assert jnp.array_equal(c_mul, jnp.array([4, 10, 18]))
# 矩阵乘法
mat_a = jnp.array([[1, 2], [3, 4]])
mat_b = jnp.array([[5, 6], [7, 8]])
mat_prod = mat_a @ mat_b # 等价于 jnp.matmul(mat_a, mat_b)
print("矩阵乘积 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))
练习 4:索引、切片与函数式更新¶
data = jnp.arange(12).reshape((3, 4))
print("原始数据:\n", data)
# 单个元素
print("\n第 1 行第 2 列的元素:", data[1, 2])
# 整行
print("第 0 行:", data[0, :])
# 整列
print("第 1 列:", data[:, 1])
# 子数组
print("子数组(前 2 行,第 1~2 列):\n", data[:2, 1:3])
# 直接赋值不被支持
try:
data[0, 0] = 99
except TypeError as e:
print("\n直接赋值报错:", str(e).splitlines()[0])
# 函数式更新:返回新数组,这种写法才能和 jit、grad 等变换兼容
updated_data = data.at[0, 0].set(99)
print("\n更新后的数据:\n", updated_data)
print("原始数据(未变):\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]]))
练习 5:通用函数(ufunc)¶
jnp.sin、jnp.exp 这类通用函数会逐元素作用在数组上。下图画的是用 jnp.linspace 生成、再经过 jnp.sin 变换的数值(对应课程页面上的插图)。
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)
# 用更密的采样点画出 sin 曲线,并标出上面 5 个点
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()
练习 6:规约操作¶
jnp.sum、jnp.mean、jnp.max 等可以对整个数组规约,也可以用 axis 指定方向。对二维数组来说,axis=0 是沿行方向压缩,得到每列的结果;axis=1 是沿列方向压缩,得到每行的结果。
m = jnp.arange(1, 7).reshape((2, 3))
print("矩阵 m:\n", m)
print("\n全部求和:", jnp.sum(m))
print("每列求和 (axis=0):", jnp.sum(m, axis=0))
print("每行求和 (axis=1):", jnp.sum(m, axis=1))
print("均值:", jnp.mean(m))
print("每行最大值 (axis=1):", jnp.max(m, axis=1))
# 方法写法同样可用
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]))
练习 7:重塑¶
reshape 改变数组形状,但不改变数据,同样返回新数组。元素总数必须保持不变。可以用 -1 让 JAX 自动推断某一维。
v = jnp.arange(6)
print("原始:", v)
print("reshape 为 (2, 3):\n", v.reshape((2, 3)))
print("jnp.reshape 为 (3, 2):\n", jnp.reshape(v, (3, 2)))
print("用 -1 自动推断 (3, -1) -> 形状:", v.reshape((3, -1)).shape)
assert v.reshape((3, -1)).shape == (3, 2)
try:
v.reshape((4, 2))
except TypeError as e:
print("\n元素总数不匹配时报错:", str(e).splitlines()[0])
补充练习¶
下面 4 道补充练习,覆盖和 NumPy 对拍、随机数纪律、正确测速,以及函数式更新的实战用法。建议先自己写,再看参考答案。
练习 E1:行标准化,并与 NumPy 对拍
给定形状 (5, 4) 的随机矩阵 X(用 jax.random.key(0) 生成),写一个函数 standardize_rows(X),把每一行变成均值 0、标准差 1:
$$ Z_{ij} = \frac{X_{ij} - \mu_i}{\sigma_i + \varepsilon},\quad \varepsilon = 10^{-8} $$
要求:(1) 只用 jnp,利用 keepdims=True 做广播;(2) 用 NumPy 写一个同样的函数,验证两者一致;(3) 验证每行均值约为 0、标准差约为 1。
自检接口:定义 standardize_rows(X),返回与 X 同形状的 JAX 数组。
# ✍️ 作答区:练习 E1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 E1
try:
standardize_rows
except NameError as e:
print(f"⏳ 练习 E1 尚未作答:{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]) # 每行的尺度、偏移都不同
_Z = standardize_rows(_X)
assert isinstance(_Z, jax.Array), "standardize_rows 应只用 jnp 实现,返回 JAX 数组"
assert _Z.shape == _X.shape, f"输出形状应与输入相同 {_X.shape},实际 {_Z.shape}"
_Zn = np.asarray(_Z, dtype=np.float64)
assert np.allclose(_Zn.mean(axis=1), 0, atol=1e-5), "每一行的均值应约为 0(是不是按列做了标准化?)"
assert np.allclose(_Zn.std(axis=1), 1, atol=1e-4), "每一行的标准差应约为 1"
assert np.allclose(standardize_rows(_X * 3.0 + 7.0), _Z, atol=1e-4), "对输入做 a·X+b(a>0)后,结果应保持不变"
_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), "常数行应得到全 0:分母里的 ε 用来防止除以 0"
print("✅ 练习 E1 自检通过")
练习 E2:随机数纪律
- 用
jax.random.key(2024)作为主密钥,一次split出 3 个子密钥,分别生成:形状(2, 3)的标准正态矩阵、形状(5,)的[0, 10)均匀整数(jax.random.randint)、对jnp.arange(6)的一次随机排列(jax.random.permutation)。 - 证明可复现:用同一主密钥重做一遍,结果完全相同。
- 证明密钥复用的危害:用同一个子密钥生成两次正态矩阵,它们完全相同。
自检接口:定义 sample_all(seed) -> (正态矩阵, 均匀整数, 随机排列),以 jax.random.key(seed) 为主密钥。
# ✍️ 作答区:练习 E2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 E2
try:
sample_all
except NameError as e:
print(f"⏳ 练习 E2 尚未作答:{e}")
else:
_n, _i, _p = sample_all(2024)
assert _n.shape == (2, 3) and jnp.issubdtype(_n.dtype, jnp.floating), f"正态矩阵应为 (2, 3) 浮点数组,实际 {_n.shape} {_n.dtype}"
assert _i.shape == (5,) and jnp.issubdtype(_i.dtype, jnp.integer), f"均匀整数应为 (5,) 整数数组,实际 {_i.shape} {_i.dtype}"
assert bool(((_i >= 0) & (_i < 10)).all()), f"整数应落在 [0, 10),实际 {_i}"
assert jnp.array_equal(jnp.sort(_p), jnp.arange(6)), f"随机排列应恰好包含 0..5 各一次,实际 {_p}"
assert all(jnp.array_equal(u, v) for u, v in zip((_n, _i, _p), sample_all(2024))), "同一个 seed 两次调用,结果应完全相同"
assert not jnp.array_equal(_n, sample_all(7)[0]), "不同的 seed 应得到不同的随机数"
assert not jnp.array_equal(_n, jax.random.normal(jax.random.key(2024), (2, 3))), "正态矩阵不应直接用主密钥生成:主密钥只用来 split"
print("✅ 练习 E2 自检通过")
练习 E3:正确地测速——NumPy vs JAX(逐操作)vs JAX + jit
对函数 $f(x) = \sum_i \left(\sin^2 x_i + \cos^2 x_i\right)\cdot e^{-x_i^2}$,在长度 $10^6$ 的 float32 向量上比较三种实现的耗时。
要求:(1) 写一个通用的计时函数,先预热(排除编译时间),再重复多次取中位数;(2) JAX 版本必须调用 .block_until_ready();(3) 单独测出 jit 首次调用(含编译)的耗时;(4) 验证三者结果一致。
说明:本机只有 CPU,加速主要来自
jit的算子融合:多个逐元素运算合并成一个内核,省掉了中间数组的读写。在 GPU 上差距通常更大。具体数字随机器而变,不用死记。数据x要作为参数传给jit函数,而不是被闭包捕获。
自检接口:定义计时函数 bench(fn, *args) -> 中位数耗时(毫秒),以及三个版本 f_np(x)、f_jnp(x)、f_jit(x)。
# ✍️ 作答区:练习 E3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 E3
try:
bench, f_np, f_jnp, f_jit
except NameError as e:
print(f"⏳ 练习 E3 尚未作答:{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} 的结果 {_r:.3f} 不对,应约为 {_ref:.3f}"
assert hasattr(f_jit, "lower"), "f_jit 应是 jax.jit 包装后的函数"
class _Async: # 模拟异步派发的结果:要等 30 ms 才真正算完
def block_until_ready(self):
time.sleep(0.03)
return self
_t = float(bench(lambda: _Async()))
assert _t >= 25, f"bench 测得 {_t:.2f},没有等结果真正算完(或单位不是毫秒)"
_state = {"n": 0}
def _noisy(): # 第 1 次调用很慢(模拟编译),之后每 4 次有 1 次偶发变慢
_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 测得 {_t:.1f} ms:结果被首次调用或偶发的慢调用拉高了,应先预热、再取中位数"
print("✅ 练习 E3 自检通过")
练习 E4:用函数式更新实现直方图计数
给定 labels = jax.random.randint(jax.random.key(7), (1000,), 0, 5)(1000 个 0~4 的类别标签),不用循环、不用 jnp.bincount,只用 jnp.zeros 和 .at[].add 统计每个类别出现的次数,并:
- 用
jnp.bincount(labels, length=5)验证结果; - 把计数函数用
jax.jit编译后再跑一次,结果不变; - 解释:为什么换成 NumPy 的
counts[labels] += 1会得到错误结果?
自检接口:定义 count_labels(labels, num_classes=5),以及它的 jit 版本 count_jit(自检会这样调用:count_jit(labels, num_classes=7))。
# ✍️ 作答区:练习 E4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 E4
try:
count_labels, count_jit
except NameError as e:
print(f"⏳ 练习 E4 尚未作答:{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"num_classes=7 时应返回长度 7 的计数,实际形状 {_c.shape}"
assert np.array_equal(np.asarray(_c), _ref), f"计数不对:期望 {_ref},实际 {_c}"
assert int(count_labels(jnp.full((50,), 2), num_classes=3)[2]) == 50, "50 个相同标签的计数应为 50:重复的下标必须累加"
assert hasattr(count_jit, "lower"), "count_jit 应是 jax.jit 包装后的函数"
assert np.array_equal(np.asarray(count_jit(_lab, num_classes=7)), _ref), "jit 版本的计数与 bincount 不一致"
print("✅ 练习 E4 自检通过")
第 1 章测验¶
下面 8 道题帮你检查本章的掌握情况,侧重设计权衡、常见故障和实际决策。答案折叠在每题下方,先自己作答再展开。
Q1. 下面哪一项最准确地描述了 JAX?
- A. 一个比 NumPy 更快的 CPU 数组库
- B. 一个提供类 NumPy 接口、以可组合函数变换(jit / grad / vmap 等)为核心、通过 XLA 编译到 CPU/GPU/TPU 的数值计算平台
- C. 一个深度学习框架,内置神经网络层和优化器
- D. NumPy 的 GPU 版本,接口和行为与 NumPy 完全相同
答案与解析
B。 A 只说了一面。C 不对,神经网络层、优化器由 Flax、Optax 等上层库提供,JAX 本身不带。D 不对,接口相似,但行为有重要差异(不可变、默认 float32、显式随机数、越界不报错等)。
Q2. 执行 x = jnp.array([1, 2, 3]); x[0] = 10 会怎样?
- A.
x变成[10, 2, 3] - B. 抛出
TypeError,应改用x = x.at[0].set(10) - C. 抛出
IndexError - D. 悄悄失败,
x不变
答案与解析
B。 JAX 数组不可变,原地赋值会抛 TypeError。.at[0].set(10) 返回新数组,要把它赋给变量才能“看到”更新。
Q3. 默认配置下,jnp.array([1.0, 2.0], dtype=jnp.float64).dtype 是?
- A.
float64 - B. 在 CPU 上是
float64,在 GPU 上是float32 - C.
float32,并发出警告;要得到 float64 需要在启动时开启jax_enable_x64 - D. 抛出异常
答案与解析
C。 JAX 默认禁用 64 位类型,和设备无关。显式要 float64 会被截断成 float32,并给出 UserWarning。
Q4. 关于下面的代码,说法正确的是?
key = jax.random.key(0)
a = jax.random.normal(key, (3,))
b = jax.random.normal(key, (3,))
- A.
a和b是两组独立的随机数 - B.
a和b完全相同;要得到独立随机数应先jax.random.split(key) - C. 第二次调用会报错,因为密钥已被“消耗”
- D.
b的值取决于全局随机状态
答案与解析
B。 JAX 的随机函数是纯函数,输出只由密钥决定。没有全局状态,也不会“消耗”密钥。复用密钥是最常见的随机数 bug。
Q5. 下面这段代码最后一行输出什么?
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. 报错
- D. 不确定
答案与解析
B。 jit 在第一次调用时追踪函数,全局变量 scale 的值被当成常量编进了结果。之后再改全局变量,不会触发重新编译。这就是 JAX 要求纯函数的原因:所有会变的量都应该作为参数传进去。
Q6. 用下面的代码测 JAX 矩阵乘法的耗时,问题在哪?
t0 = time.perf_counter(); y = x @ x; t = time.perf_counter() - t0
- A. 没有问题
- B. JAX 是惰性求值,
y根本没有被计算 - C. JAX 异步派发,这里只测到派发时间;应写
(x @ x).block_until_ready(),并且先预热 - D. 应该改用
np.dot
答案与解析
C。 JAX 不是惰性的,计算确实会执行,但派发是异步的,Python 立刻就返回了。要测真实耗时,必须等结果算完;用 jit 时还要排除首次调用的编译时间。
Q7. 在 JAX 0.12 中,查看数组 x 所在设备的正确写法是?
- A.
x.device() - B.
x.device(单个设备)或x.devices()(设备集合) - C.
jax.get_device(x) - D.
x.platform
答案与解析
B。 x.device 已经从方法改成了属性,x.device() 会抛 TypeError: 'Device' object is not callable。x.devices() 返回集合,适合分片到多个设备上的数组;x.sharding 给出完整的分片信息。
Q8. 数据集有 10 GB,放在主机内存的 NumPy 数组里,训练在 GPU 上进行。下面哪种做法最合理?
- A. 每次运算都把 NumPy 数组直接传给 jit 函数,让 JAX 每次自动搬运
- B. 预处理完成后用
jax.device_put把(一个批次的)数据一次性传到设备,在设备上完成整个训练步,只在需要记录日志时把少量标量传回主机 - C. 每个训练步都
print(loss),方便观察 - D. 把数据转成 Python 列表再传给 JAX
答案与解析
B。 主机和设备之间的传输经常是瓶颈,要尽量减少搬运的次数和数据量。C 的问题是每步打印都会强制同步,还要把数据传回主机,拖慢训练。D 最慢。
本章小结¶
- JAX = 类 NumPy 接口 + 可组合函数变换 + XLA 编译。
jit编译加速,grad自动微分,vmap自动向量化,并行用shard_map(pmap是旧接口)。 - 纯函数是前提:变换靠追踪函数得到 jaxpr。副作用只在追踪时发生,全局变量会被当成常量编进结果。参数和数据都应该作为函数参数传入。运行时打印用
jax.debug.print。 - 数组不可变:用
x.at[idx].set/add/...生成新数组。在jit里更新中间结果时,XLA 通常会优化成原地更新(更新输入参数要配合donate_argnums)。重复下标会累加。 - 默认 32 位:
float32/int32,和设备无关。要 64 位,应在程序启动时执行jax.config.update("jax_enable_x64", True)(中途切换不影响已经创建的数组)。 - 类型与索引陷阱:Python 标量是弱类型;整数溢出会悄悄回绕;越界读被截断、越界写被丢弃,都不报错。
- 随机数显式管理:用
jax.random.key(类型化密钥)。同一个密钥不要复用,先split。随机数的具体数值不保证跨 JAX 版本一致。 - 是异步派发,不是惰性求值:测速必须
.block_until_ready(),还要预热,排除编译时间。 - 数组类型是
jax.Array:设备用x.device(属性)、x.devices()(方法)、x.sharding查看。 - 设备管理:默认 TPU > GPU > CPU;用
jax.device_put显式放置;committed 到不同设备的数组不能直接运算;尽量少在主机和设备之间搬数据。 - 安装:JAX 0.12 需要 Python ≥ 3.12;
pip install -U jax(CPU)、"jax[cuda12]"/"jax[cuda13]"(NVIDIA GPU)、"jax[tpu]"(TPU)。
速查表¶
| API | 作用 |
|---|---|
import jax.numpy as jnp | 类 NumPy 接口 |
jnp.array / zeros / ones / arange / linspace | 创建数组 |
jnp.asarray(np_arr) / np.asarray(x) | NumPy → JAX(拷贝到设备)/ JAX → NumPy(只读) |
isinstance(x, jax.Array) | 判断是否为 JAX 数组 |
x.shape / dtype / ndim / size | 数组属性 |
x.at[i].set(v) / .add(v) / .get(mode="fill") | 函数式更新 / 安全读取 |
jnp.where(cond, a, b) | 条件选择(替代布尔掩码赋值) |
jnp.sum / mean / max(x, axis=..., keepdims=...) | 规约 |
x.reshape(...) / jnp.reshape | 重塑(返回新数组) |
jax.random.key(seed) | 创建类型化 PRNG 密钥 |
jax.random.split(key, n) / fold_in(key, i) | 派生新密钥 |
jax.random.normal / uniform / randint / permutation | 采样 |
jax.random.key_data / wrap_key_data | 取出密钥底层数据 / 包装旧式密钥 |
jax.jit(f, static_argnames=...) | XLA 即时编译 |
jax.grad(f, argnums=...) / jax.value_and_grad(f) | 梯度 / 值和梯度 |
jax.vmap(f, in_axes=...) | 自动向量化 |
jax.shard_map | 多设备并行(取代旧的 jax.pmap) |
jax.make_jaxpr(f)(*args) | 查看追踪得到的中间表示 |
jax.debug.print("{}", x) | 在 jit 内打印运行时值 |
jax.tree.map(f, tree) / jax.tree.all | 对 pytree 的每个叶子做映射 / 全部为真 |
x.block_until_ready() | 等待异步计算完成(测速必备) |
jax.devices(backend=None) / jax.default_backend() | 查看设备 / 默认后端 |
x.device / x.devices() / x.sharding | 数组所在设备 / 设备集合 / 分片信息 |
jax.device_put(x, device) / jax.device_get(x) | 放到设备 / 取回主机 |
with jax.default_device(d): | 临时切换默认设备 |
jax.config.update("jax_enable_x64", True) | 启用 64 位(启动时设置) |
jax.config.update("jax_num_cpu_devices", n) | 模拟多个 CPU 设备(启动时设置) |
jax.print_environment_info() | 打印版本与设备信息 |
环境变量 JAX_PLATFORMS=cpu | 强制只用 CPU |
环境变量 XLA_PYTHON_CLIENT_PREALLOCATE=false | 关闭 GPU 显存预分配 |
本章更新说明¶
下面汇总本章在 JAX 0.12 下需要注意的写法和结论,每条都在正文对应位置用 assert 验证过。
| 编号 | 小节 | 要点 | 位置 |
|---|---|---|---|
| 1 | 1.1 / 1.3 | 向量化的英文是 vectorization,不是 quantization(量化) | 1.1 JAX 是什么?;1.3 核心设计理念:函数变换 |
| 2 | 1.1 / 1.2 / 1.3 | 并行用 jax.shard_map;jax.pmap 的文档已标注为旧接口 | 1.1 JAX 是什么?;1.3 核心设计理念:函数变换 |
| 3 | 1.1 | JAX 文档地址是 docs.jax.dev,代码仓库在 github.com/jax-ml/jax | 1.1 JAX 是什么? |
| 4 | 1.2 | JAX 不是惰性求值,而是逐操作异步派发;jit 后整体编译;测速要 .block_until_ready() | 1.2 JAX 对比 NumPy |
| 5 | 1.2 | 默认配置下 jnp.array(..., dtype=jnp.float64) 得到 float32 并警告;要 64 位需 jax.config.update("jax_enable_x64", True) | 1.2 JAX 对比 NumPy |
| 6 | 1.2 | 对比表中,JAX 的执行模型是“异步派发;jit 后整体编译”,并行变换是 shard_map | 1.2 JAX 对比 NumPy |
| 7 | 1.2 | Python 标量是弱类型,不会把数组提升到 64 位;整数溢出悄悄回绕(NumPy 报错) | 1.2 JAX 对比 NumPy |
| 8 | 1.2 | 越界读被截断、越界写被丢弃,都不报错;需要明确行为时用 .at[].get(mode="fill") | 1.2 JAX 对比 NumPy |
| 9 | 1.3 | jit 里说的“静态”指静态参数(static argument,用 static_argnames 声明),不是 token | 1.3 核心设计理念:函数变换 |
| 10 | 1.3 | grad_fn / jit_grad_fn / batched_predict 要真正调用才看得到结果;用解析梯度、逐样本循环对拍,并拼成训练循环 | 1.3 核心设计理念:函数变换 |
| 11 | 1.3 | jit 函数里的 print 只在追踪时执行,全局变量会被当成常量;运行时打印用 jax.debug.print | 1.3 核心设计理念:函数变换 |
| 12 | 1.4 | JAX 0.12 需要 Python ≥ 3.12 | 1.4 安装与设置 |
| 13 | 1.4 | CPU 版安装用 pip install -U jax,jaxlib 会自动装上 | 1.4 安装与设置 |
| 14 | 1.4 | GPU 版安装用 "jax[cuda12]" / "jax[cuda13]"(或 -local 变体),不再需要 -f;TPU 用 "jax[tpu]";Metal 是实验性插件 | 1.4 安装与设置 |
| 15 | 1.4 | 验证脚本 jnp.dot(x, x) 在 JAX 0.12 CPU 上是 9.738718…(不是 10.811179161071777);随机数的具体数值随版本 / RNG 配置变化 | 1.4 安装与设置 |
| 16 | 1.4 / 1.5 / 1.7 | 创建密钥推荐用 jax.random.key(seed)(类型化密钥),和 jax.random.PRNGKey(seed) 生成的随机数相同 | 1.5 使用 JAX 数组 |
| 17 | 1.5 | 随机函数不会修改或“消耗”密钥;问题在于同一个密钥不应复用 | 1.5 使用 JAX 数组 |
| 18 | 1.5 | NumPy 对照代码单独运行时要先 import numpy as np,否则 NameError | 1.5 使用 JAX 数组 |
| 19 | 1.5 / 1.6 / 1.7 | 查看设备用 x.device(属性)/ x.devices() / x.sharding,不再是 x.device() | 1.5 使用 JAX 数组;1.6 设备管理:CPU、GPU、TPU |
| 20 | 1.5 | 调用 x.device() 抛的是 TypeError,except AttributeError 兜不住;直接用新写法 | 1.5 使用 JAX 数组 |
| 21 | 1.6 | committed 到不同设备的数组直接运算会抛 ValueError,不会自动传输 | 1.6 设备管理:CPU、GPU、TPU |
| 22 | 1.7 | 数组类型是 jax.Array(实现类 jaxlib._jax.ArrayImpl),DeviceArray 已移除 | 1.7 动手练习:基本数组操作 |
| 23 | 1.7 | 所有设备上默认都是 float32(CPU 也一样),64 位需 jax_enable_x64 | 1.7 动手练习:基本数组操作 |
| 24 | 1.3 | “只带形状和 dtype 的抽象值”是 jit 的行为;单独 grad 时追踪器带具体数值,Python if 可用(jit(grad(f)) 才报错) | 1.3 核心设计理念:函数变换 |
练习 E1¶
题目见正文「补充练习」。
# 参考答案 E1
def standardize_rows(X, eps=1e-8):
mu = jnp.mean(X, axis=1, keepdims=True) # 形状 (5, 1),可与 (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("与 NumPy 一致:", np.allclose(Z, Z_np, atol=1e-5)) # float32 计算,容差放宽到 1e-5
print("每行均值:", jnp.round(Z.mean(axis=1), 5))
print("每行标准差:", 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)
练习 E2¶
题目见正文「补充练习」。
# 参考答案 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("正态矩阵:\n", n1)
print("均匀整数:", i1)
print("随机排列:", p1)
assert ((i1 >= 0) & (i1 < 10)).all()
assert jnp.array_equal(jnp.sort(p1), jnp.arange(6)) # 排列包含且仅包含 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("\n可复现:", 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("复用同一子密钥的两次结果相同:", reused_same)
assert reused_same
练习 E3¶
题目见正文「补充练习」。
# 参考答案 E3
def bench(fn, *args, n_warmup=2, n_repeat=10):
# 返回中位数耗时(毫秒)。fn 的返回值若有 block_until_ready 就调用它
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)
# 首次调用:包含追踪 + 编译
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 逐操作 : {t_jnp:7.2f} ms")
print(f"JAX + jit : {t_jit:7.2f} ms (首次调用含编译 {first_call:.1f} ms)")
print(f"jit 相对 NumPy 加速: {t_np / t_jit:.1f}x")
# 自证:三种实现结果一致;sin²+cos²=1,所以结果也应接近 Σ exp(-x²)
r_np, r_jnp, r_jit = f_np(x_np), float(f_jnp(x_jax)), float(f_jit(x_jax))
print(f"结果: 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)
练习 E4¶
题目见正文「补充练习」。
# 参考答案 E4
labels = jax.random.randint(jax.random.key(7), (1000,), 0, 5)
def count_labels(labels, num_classes=5):
# .at[labels].add(1):重复的下标会被逐个累加(scatter-add)
return jnp.zeros(num_classes, dtype=jnp.int32).at[labels].add(1)
counts = count_labels(labels)
print("计数:", counts, "总和:", counts.sum())
assert counts.sum() == 1000
assert jnp.array_equal(counts, jnp.bincount(labels, length=5))
# num_classes 决定输出形状,jit 时必须是静态参数
count_jit = jax.jit(count_labels, static_argnames="num_classes")
assert jnp.array_equal(count_jit(labels), counts)
print("与 bincount 一致,jit 后结果不变")
# NumPy 的花式索引 += 遇到重复下标只生效一次
counts_np = np.zeros(5, dtype=np.int32)
counts_np[np.asarray(labels)] += 1
print("\nNumPy counts[labels] += 1 的结果:", counts_np, " <- 每类最多只加 1,是错的")
assert counts_np.max() == 1
print("答:NumPy 先取出 counts[labels]、加 1、再写回,重复下标的写入互相覆盖;"
"JAX 的 .at[].add 是真正的 scatter-add,会累加。NumPy 中应使用 np.add.at。")