2、JAX基础入门:jnp.array与np.array的区别、设备内存管理、随机数生成、函数式纯化原则

好,咱们正式开始JAX的实战之旅。这一节我打算把JAX最基础、也最容易踩坑的几个点讲透。你可能会觉得「不就是数组操作吗,能有什么花样?」——嗯,我当初也是这么想的,结果第一次写JAX代码就翻车了。

2.1 jnp.array vs np.array:不只是换个名字

先看个最直观的例子。你用NumPy写习惯了,可能会觉得JAX的数组就是换个包名。其实不然。

import numpy as np
import jax.numpy as jnp
import jax

# 看起来一样?
a_np = np.array([1, 2, 3])
a_jnp = jnp.array([1, 2, 3])

print(type(a_np))   # <class 'numpy.ndarray'>
print(type(a_jnp))  # <class 'jax.Array'>

类型不同只是表象。真正的区别在于:jnp.array是不可变的。你没看错,JAX的数组一旦创建就不能原地修改。

⚠️ 注意: 以下代码在JAX中会报错:
a = jnp.array([1, 2, 3])
a[0] = 100  # TypeError: JAX arrays are immutable

我刚开始用JAX时,习惯性地写了个 arr[idx] = new_val,结果程序直接崩了。后来才意识到,JAX的设计哲学就是「函数式」,所有操作都返回新数组,不修改原数据。

另一个关键区别是数据类型默认行为。NumPy默认用64位,JAX默认用32位。这在精度敏感的场景下要特别注意。

print(np.array([1.0]).dtype)   # float64
print(jnp.array([1.0]).dtype)  # float32

我个人习惯在写JAX代码时,显式指定dtype,避免精度问题带来的bug。

2.2 设备内存管理:.device_buffer 的秘密

JAX的数组默认放在哪里?答案是:GPU或TPU上。如果你有加速器,JAX会自动把数据放上去。这和NumPy完全不一样——NumPy的数据永远在CPU内存里。

看个例子:

import jax
import jax.numpy as jnp

# 检查默认设备
print(jax.devices())  # 比如 [TFRT_CPU_0] 或 [gpu:0]

# 创建数组
x = jnp.array([1, 2, 3])
print(x.device())  # 显示在哪个设备上

# 查看底层缓冲区
print(x.device_buffer)  # <DeviceArray at 0x...>

.device_buffer 这个属性,说白了就是JAX数组在设备上的「真实地址」。你想想看,如果数据在GPU上,你直接拿CPU代码去读,肯定出问题。

💡 小技巧: 需要把数据从设备拷回CPU时,用 jax.device_get(x) 或直接 np.array(x)。千万别用 x.device_buffer 去手动操作,容易搞乱内存。

我曾经在调试一个分布式训练任务时,发现某个中间结果死活不对。查了半天,原来是两个数组一个在GPU:0上,一个在GPU:1上,操作时触发了隐式数据传输。嗯,从那以后我养成了习惯:每次创建数组都检查一下 .device()

2.3 随机数生成:jax.random 的正确打开方式

JAX的随机数生成和NumPy完全不同。NumPy里你写 np.random.randn() 就行,JAX不行——它需要你显式传入一个随机数状态(key)

import jax.random as jrandom

# 错误写法(别学我)
# x = jrandom.normal(shape=(3,))  # TypeError: missing required argument: key

# 正确写法
key = jrandom.PRNGKey(42)
x = jrandom.normal(key, shape=(3,))
print(x)

为什么要这么麻烦?因为JAX要求纯函数——同样的输入必须产生同样的输出。如果随机数生成器内部维护一个隐式状态,那就破坏了纯函数原则。

这里有个坑:不要重复使用同一个key

key = jrandom.PRNGKey(42)

# 错误:两次用同一个key,结果一样
a = jrandom.normal(key, shape=(3,))
b = jrandom.normal(key, shape=(3,))
print(a == b)  # True

# 正确:用 split 分裂出新的key
key, subkey = jrandom.split(key)
a = jrandom.normal(key, shape=(3,))
b = jrandom.normal(subkey, shape=(3,))
print(a == b)  # False

我个人习惯在函数开头先 key, *subkeys = jrandom.split(key, num=N),一次性分好所有子key,后面直接用。这样既清晰又安全。

2.4 函数式纯化原则:JAX的灵魂

前面其实一直在提「纯函数」。到底什么是JAX的纯函数原则?说白了就两条:

  1. 无副作用:函数不修改外部状态
  2. 引用透明:相同输入永远得到相同输出

看个对比:

# NumPy风格(有副作用)
def add_one_np(arr):
    arr += 1  # 修改了原数组
    return arr

# JAX风格(纯函数)
def add_one_jax(arr):
    return arr + 1  # 返回新数组,原数组不变

你可能会问:「这有什么好处?」好处太大了。JAX的 jitvmapgrad 等所有高级功能,都依赖这个纯函数假设。如果你写了不纯的函数,JAX编译出来的结果可能完全不对。

🔑 核心原则: 在JAX中,永远不要依赖或修改全局变量、不要使用Python的随机状态、不要原地修改数组。把每个函数都当作「输入→输出」的黑盒来写。

我记得有次帮同事调试一个梯度计算错误的问题。他的损失函数里用了一个全局计数器来记录训练步数——这在PyTorch里完全没问题,但在JAX里,grad 会把这个计数器当作常量处理,导致梯度完全算错。改掉这个「不纯」的习惯后,问题立刻解决了。

2.5 知识体系总览

下面这张图把这一节的核心逻辑串起来了。你可以看到,所有知识点都围绕「函数式纯化」这个核心展开。

函数式纯化原则 jnp.array vs np.array 不可变 · 默认float32 · 设备感知 设备内存管理 .device_buffer · 显式传输 随机数生成 PRNGKey · split · 显式状态 纯函数实践 无副作用 · 引用透明 JAX基础入门 · 知识体系结构图

从这张图可以看得很清楚:jnp.array的不可变性、设备内存的显式管理、随机数生成器的状态传递,这三者都是「函数式纯化」这个核心原则的具体体现。你只要抓住这个核心,JAX的其他高级特性(jit、vmap、grad等)理解起来就会顺畅很多。


📌 本节要点回顾:
  • jnp.array不可变,默认float32,操作返回新数组
  • 数据默认在加速器上,用 .device() 检查,用 device_get 取回
  • 随机数必须用 PRNGKey,用 split 分裂,不要重复使用
  • 所有函数保持纯函数风格——无副作用、引用透明

好了,这一节的内容就到这里。记住这些基础,后面我们写自定义算子时,你会感谢自己现在打下的基础。

专注资料整理