🔪 JAX - 那些“硬核”之处 🔪#
在意大利乡村漫步时,人们会毫不犹豫地告诉你 JAX 拥有 “una anima di pura programmazione funzionale”(一颗纯函数式编程的灵魂)。
JAX 是一种用于表达和组合数值程序转换的语言。JAX 还能够为 CPU 或加速器(GPU/TPU)编译数值程序。JAX 非常适合许多数值和科学程序,但前提是它们必须遵循我们下面描述的某些约束。
import numpy as np
from jax import jit
from jax import lax
from jax import random
import jax
import jax.numpy as jnp
🔪 纯函数#
JAX 的转换和编译设计为仅适用于函数式纯粹的 Python 函数:所有输入数据都通过函数参数传递,所有结果都通过函数结果输出。一个纯函数在用相同的输入调用时,总是会返回相同的结果。
以下是一些非函数式纯粹的函数的示例,JAX 对它们的行为与 Python 解释器不同。请注意,JAX 系统不保证这些行为;正确使用 JAX 的方式是仅在函数式纯粹的 Python 函数上使用它。
def impure_print_side_effect(x):
print("Executing function") # This is a side-effect
return x
# The side-effects appear during the first run
print ("First call: ", jit(impure_print_side_effect)(4.))
# Subsequent runs with parameters of same type and shape may not show the side-effect
# This is because JAX now invokes a cached compilation of the function
print ("Second call: ", jit(impure_print_side_effect)(5.))
# JAX re-runs the Python function when the type or shape of the argument changes
print ("Third call, different type: ", jit(impure_print_side_effect)(jnp.array([5.])))
Executing function
First call: 4.0
Second call: 5.0
Executing function
Third call, different type: [5.]
g = 0.
def impure_uses_globals(x):
return x + g
# JAX captures the value of the global during the first run
print ("First call: ", jit(impure_uses_globals)(4.))
g = 10. # Update the global
# Subsequent runs may silently use the cached value of the globals
print ("Second call: ", jit(impure_uses_globals)(5.))
# JAX re-runs the Python function when the type or shape of the argument changes
# This will end up reading the latest value of the global
print ("Third call, different type: ", jit(impure_uses_globals)(jnp.array([4.])))
First call: 4.0
Second call: 5.0
Third call, different type: [14.]
g = 0.
def impure_saves_global(x):
global g
g = x
return x
# JAX runs once the transformed function with special Traced values for arguments
print ("First call: ", jit(impure_saves_global)(4.))
print ("Saved global: ", g) # Saved global has an internal JAX value
First call: 4.0
Saved global: JitTracer<~float32[]>
一个 Python 函数即使在其内部实际使用了有状态对象,也可以是函数式纯粹的,只要它不读取或写入外部状态。
def pure_uses_internal_state(x):
state = dict(even=0, odd=0)
for i in range(10):
state['even' if i % 2 == 0 else 'odd'] += x
return state['even'] + state['odd']
print(jit(pure_uses_internal_state)(5.))
50.0
不建议在任何你想 jit 的 JAX 函数或任何控制流原语中使用迭代器。原因是迭代器是一个 Python 对象,它引入了状态来检索下一个元素。因此,它与 JAX 的函数式编程模型不兼容。在下面的代码中,有一些尝试将迭代器与 JAX 错误使用的示例。其中大多数返回错误,但有些则产生意外结果。
import jax.numpy as jnp
from jax import make_jaxpr
# lax.fori_loop
array = jnp.arange(10)
print(lax.fori_loop(0, 10, lambda i,x: x+array[i], 0)) # expected result 45
iterator = iter(range(10))
print(lax.fori_loop(0, 10, lambda i,x: x+next(iterator), 0)) # unexpected result 0
# lax.scan
def func11(arr, extra):
ones = jnp.ones(arr.shape)
def body(carry, aelems):
ae1, ae2 = aelems
return (carry + ae1 * ae2 + extra, carry)
return lax.scan(body, 0., (arr, ones))
make_jaxpr(func11)(jnp.arange(16), 5.)
# make_jaxpr(func11)(iter(range(16)), 5.) # throws error
# lax.cond
array_operand = jnp.array([0.])
lax.cond(True, lambda x: x+1, lambda x: x-1, array_operand)
iter_operand = iter(range(10))
# lax.cond(True, lambda x: next(x)+1, lambda x: next(x)-1, iter_operand) # throws error
45
0
🔪 原地更新#
在 NumPy 中,你习惯于这样做:
numpy_array = np.zeros((3,3), dtype=np.float32)
print("original array:")
print(numpy_array)
# In place, mutating update
numpy_array[1, :] = 1.0
print("updated array:")
print(numpy_array)
original array:
[[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]]
updated array:
[[0. 0. 0.]
[1. 1. 1.]
[0. 0. 0.]]
然而,如果我们尝试对 jax.Array 进行原地索引更新,我们会得到一个错误!(☉_☉)
%xmode Minimal
Exception reporting mode: Minimal
jax_array = jnp.zeros((3,3), dtype=jnp.float32)
# In place update of JAX's array will yield an error!
jax_array[1, :] = 1.0
TypeError: 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://jax.net.cn/en/latest/_autosummary/jax.numpy.ndarray.at.html
如果我们尝试进行 __iadd__ 风格的原地更新,我们会得到与 NumPy 不同的行为!(☉_☉) (☉_☉)
jax_array = jnp.array([10, 20])
jax_array_new = jax_array
jax_array_new += 10
print(jax_array_new) # `jax_array_new` is rebound to a new value [20, 30], but...
print(jax_array) # the original value is unodified as [10, 20] !
numpy_array = np.array([10, 20])
numpy_array_new = numpy_array
numpy_array_new += 10
print(numpy_array_new) # `numpy_array_new is numpy_array`, and it was updated
print(numpy_array) # in-place, so both are [20, 30] !
[20 30]
[10 20]
[20 30]
[20 30]
那是因为 NumPy 定义了 __iadd__ 来执行原地修改。相比之下,jax.Array 没有定义 __iadd__,所以 Python 将 jax_array_new += 10 视为 jax_array_new = jax_array_new + 10 的语法糖,重新绑定变量而不修改任何数组。
允许原地修改变量会使程序分析和转换变得困难。JAX 要求程序是纯函数。
相反,JAX 提供了一个使用 JAX 数组上的 .at 属性进行的函数式数组更新。
️⚠️ 在 jit 代码和 lax.while_loop 或 lax.fori_loop 内部,切片的大小不能是参数值的函数,而只能是参数形状的函数——切片起始索引没有此类限制。有关此限制的更多信息,请参阅下面的控制流部分。
数组更新: x.at[idx].set(y)#
例如,上面的更新可以这样写:
jax_array = jnp.zeros((3,3), dtype=jnp.float32)
updated_array = jax_array.at[1, :].set(1.0)
print("updated array:\n", updated_array)
updated array:
[[0. 0. 0.]
[1. 1. 1.]
[0. 0. 0.]]
JAX 的数组更新函数与 NumPy 版本不同,它们是“出值”操作。也就是说,更新后的数组会作为新数组返回,并且原始数组不会被更新修改。
print("original array unchanged:\n", jax_array)
original array unchanged:
[[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]]
然而,在jit 编译的代码中,如果 x.at[idx].set(y) 的输入值 x 未被重用,编译器会将数组更新优化为原地执行。
通过其他操作更新数组#
索引数组更新不限于仅仅覆盖值。例如,我们可以执行以下索引加法:
print("original array:")
jax_array = jnp.ones((5, 6))
print(jax_array)
new_jax_array = jax_array.at[::2, 3:].add(7.)
print("new array post-addition:")
print(new_jax_array)
original array:
[[1. 1. 1. 1. 1. 1.]
[1. 1. 1. 1. 1. 1.]
[1. 1. 1. 1. 1. 1.]
[1. 1. 1. 1. 1. 1.]
[1. 1. 1. 1. 1. 1.]]
new array post-addition:
[[1. 1. 1. 8. 8. 8.]
[1. 1. 1. 1. 1. 1.]
[1. 1. 1. 8. 8. 8.]
[1. 1. 1. 1. 1. 1.]
[1. 1. 1. 8. 8. 8.]]
有关索引数组更新的更多详细信息,请参阅 .at 属性的文档。
🔪 越界索引#
在 NumPy 中,你习惯于在索引数组越界时抛出错误,如下所示:
np.arange(10)[11]
IndexError: index 11 is out of bounds for axis 0 with size 10
然而,从在加速器上运行的代码中抛出错误可能很困难或不可能。因此,JAX 必须为越界索引选择某种非错误行为(类似于浮点运算中的 NaN)。当索引操作是数组索引更新时(例如 index_add 或 scatter 类原语),越界索引处的更新将被跳过;当操作是数组索引检索时(例如 NumPy 索引或 gather 类原语),索引会被裁剪到数组边界,因为必须返回某个值。例如,来自此索引操作的将是数组的最后一个值:
jnp.arange(10)[11]
Array(9, dtype=int32)
如果你想对越界索引的行为进行更精细地控制,你可以使用 ndarray.at 的可选参数;例如:
jnp.arange(10.0).at[11].get()
Array(9., dtype=float32)
jnp.arange(10.0).at[11].get(mode='fill', fill_value=jnp.nan)
Array(nan, dtype=float32)
请注意,由于索引检索的这种行为,像 jnp.nanargmin 和 jnp.nanargmax 这样的函数会为由 NaN 组成的切片返回 -1,而 NumPy 会抛出错误。
另请注意,由于上述两种行为并非互为逆运算,因此反向模式自动微分(它将索引更新转换为索引检索,反之亦然)不会保留越界索引的语义。因此,将 JAX 中的越界索引视为一种未定义行为可能是个好主意。
🔪 非数组输入:NumPy vs. JAX#
NumPy 通常乐于接受 Python 列表或元组作为其 API 函数的输入:
np.sum([1, 2, 3])
np.int64(6)
JAX 偏离了这一点,通常会返回一个有用的错误:
jnp.sum([1, 2, 3])
TypeError: sum requires ndarray or scalar arguments, got <class 'list'> at position 0.
这是一个有意的设计选择,因为将列表或元组传递给追踪的函数可能导致难以检测的静默性能下降。
例如,考虑以下允许列表输入的 jnp.sum 的宽松版本:
def permissive_sum(x):
return jnp.sum(jnp.array(x))
x = list(range(10))
permissive_sum(x)
Array(45, dtype=int32)
输出是我们期望的,但这隐藏了潜在的性能问题。在 JAX 的追踪和 JIT 编译模型中,Python 列表或元组的每个元素都被视为一个单独的 JAX 变量,并被单独处理并推送到设备。这可以从上面 permissive_sum 函数的 jaxpr 中看出:
make_jaxpr(permissive_sum)(x)
{ lambda ; a:i32[] b:i32[] c:i32[] d:i32[] e:i32[] f:i32[] g:i32[] h:i32[] i:i32[]
j:i32[]. let
k:i32[] = convert_element_type[new_dtype=int32 weak_type=False] a
l:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] k
m:i32[] = convert_element_type[new_dtype=int32 weak_type=False] b
n:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] m
o:i32[] = convert_element_type[new_dtype=int32 weak_type=False] c
p:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] o
q:i32[] = convert_element_type[new_dtype=int32 weak_type=False] d
r:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] q
s:i32[] = convert_element_type[new_dtype=int32 weak_type=False] e
t:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] s
u:i32[] = convert_element_type[new_dtype=int32 weak_type=False] f
v:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] u
w:i32[] = convert_element_type[new_dtype=int32 weak_type=False] g
x:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] w
y:i32[] = convert_element_type[new_dtype=int32 weak_type=False] h
z:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] y
ba:i32[] = convert_element_type[new_dtype=int32 weak_type=False] i
bb:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] ba
bc:i32[] = convert_element_type[new_dtype=int32 weak_type=False] j
bd:i32[1] = broadcast_in_dim[
broadcast_dimensions=()
shape=(1,)
sharding=None
] bc
be:i32[10] = concatenate[dimension=0] l n p r t v x z bb bd
bf:i32[] = reduce_sum[axes=(0,) out_sharding=None] be
in (bf,) }
列表的每个条目都被视为单独的输入,导致追踪和编译开销随列表的大小线性增长。为了避免此类意外,JAX 避免了对列表和元组的隐式转换。
如果你想将元组或列表传递给 JAX 函数,你可以先将其显式转换为数组:
jnp.sum(jnp.array(x))
Array(45, dtype=int32)
🔪 随机数#
JAX 的伪随机数生成在重要方面与 NumPy 不同。有关快速教程,请参阅 伪随机数。有关更多详细信息,请参阅 伪随机数 教程。
🔪 控制流#
已移至 JIT 中的控制流和逻辑运算符。
🔪 动态形状#
在 jax.jit、jax.vmap、jax.grad 等转换中使用的 JAX 代码要求所有输出数组和中间数组都具有静态形状:也就是说,形状不能依赖于其他数组中的值。
例如,如果你要实现自己的 jnp.nansum 版本,你可能会从这样的内容开始:
def nansum(x):
mask = ~jnp.isnan(x) # boolean mask selecting non-nan values
x_without_nans = x[mask]
return x_without_nans.sum()
在 JIT 和其他转换之外,这可以按预期工作:
x = jnp.array([1, 2, jnp.nan, 3, 4])
print(nansum(x))
10.0
如果你尝试将 jax.jit 或其他转换应用于此函数,它将出错:
jax.jit(nansum)(x)
NonConcreteBooleanIndexError: Array boolean indices must be concrete; got bool[5]
See https://jax.net.cn/en/latest/errors.html#jax.errors.NonConcreteBooleanIndexError
问题在于 x_without_nans 的大小依赖于 x 中的值,这是另一种说法,即它的大小是动态的。在 JAX 中,通常可以通过其他方式来解决需要动态大小数组的问题。例如,在这里可以使用 jnp.where 的三参数形式将 NaN 值替换为零,从而计算出相同的结果,同时避免动态形状:
@jax.jit
def nansum_2(x):
mask = ~jnp.isnan(x) # boolean mask selecting non-nan values
return jnp.where(mask, x, 0).sum()
print(nansum_2(x))
10.0
在其他出现动态形状数组的情况下,也可以采取类似的技巧。
🔪 NaN#
调试 NaN#
如果你想追踪函数或梯度中 NaN 的出现位置,可以通过以下方式打开 NaN 检查器:
设置
JAX_DEBUG_NANS=True环境变量;在你的主文件中顶部附近添加
jax.config.update("jax_debug_nans", True);将
jax.config.parse_flags_with_absl()添加到你的主文件中,然后使用命令行标志如--jax_debug_nans=True设置选项;
这将导致计算在产生 NaN 时立即报错。启用此选项会为 XLA 生成的每个浮点类型值添加一个 NaN 检查。这意味着在 @jit 之外的每个原始操作,值都会被拉回主机并作为 ndarray 进行检查。对于 @jit 代码,每个 @jit 函数的输出都会被检查,如果存在 NaN,它将以反优化(de-optimized)的逐操作模式重新运行该函数,有效地一次去除一个 @jit 层。
可能会出现棘手的情况,例如只在 @jit 下才出现的 NaN,而在反优化模式下却不产生。在这种情况下,你会看到一个警告消息打印出来,但代码会继续执行。
如果在梯度评估的后向传递中生成了 NaN,当堆栈跟踪中的一个异常被抛出时,你会在 backward_pass 函数中,它本质上是一个简单的 jaxpr 解释器,反向遍历原始操作序列。在下面的示例中,我们使用命令行 env JAX_DEBUG_NANS=True ipython 启动了一个 ipython repl,然后运行了这个:
In [1]: import jax.numpy as jnp
In [2]: jnp.divide(0., 0.)
---------------------------------------------------------------------------
FloatingPointError Traceback (most recent call last)
<ipython-input-2-f2e2c413b437> in <module>()
----> 1 jnp.divide(0., 0.)
.../jax/jax/numpy/lax_numpy.pyc in divide(x1, x2)
343 return floor_divide(x1, x2)
344 else:
--> 345 return true_divide(x1, x2)
346
347
.../jax/jax/numpy/lax_numpy.pyc in true_divide(x1, x2)
332 x1, x2 = _promote_shapes(x1, x2)
333 return lax.div(lax.convert_element_type(x1, result_dtype),
--> 334 lax.convert_element_type(x2, result_dtype))
335
336
.../jax/jax/lax.pyc in div(x, y)
244 def div(x, y):
245 r"""Elementwise division: :math:`x \over y`."""
--> 246 return div_p.bind(x, y)
247
248 def rem(x, y):
... stack trace ...
.../jax/jax/interpreters/xla.pyc in handle_result(device_buffer)
103 py_val = device_buffer.to_py()
104 if np.any(np.isnan(py_val)):
--> 105 raise FloatingPointError("invalid value")
106 else:
107 return Array(device_buffer, *result_shape)
FloatingPointError: invalid value
生成的 NaN 被捕获了。通过运行 %debug,我们可以获得一个事后调试器。这同样适用于 @jit 下的函数,如下面的示例所示:
In [4]: from jax import jit
In [5]: @jit
...: def f(x, y):
...: a = x * y
...: b = (x + y) / (x - y)
...: c = a + 2
...: return a + b * c
...:
In [6]: x = jnp.array([2., 0.])
In [7]: y = jnp.array([3., 0.])
In [8]: f(x, y)
Invalid value encountered in the output of a jit function. Calling the de-optimized version.
---------------------------------------------------------------------------
FloatingPointError Traceback (most recent call last)
<ipython-input-8-811b7ddb3300> in <module>()
----> 1 f(x, y)
... stack trace ...
<ipython-input-5-619b39acbaac> in f(x, y)
2 def f(x, y):
3 a = x * y
----> 4 b = (x + y) / (x - y)
5 c = a + 2
6 return a + b * c
.../jax/jax/numpy/lax_numpy.pyc in divide(x1, x2)
343 return floor_divide(x1, x2)
344 else:
--> 345 return true_divide(x1, x2)
346
347
.../jax/jax/numpy/lax_numpy.pyc in true_divide(x1, x2)
332 x1, x2 = _promote_shapes(x1, x2)
333 return lax.div(lax.convert_element_type(x1, result_dtype),
--> 334 lax.convert_element_type(x2, result_dtype))
335
336
.../jax/jax/lax.pyc in div(x, y)
244 def div(x, y):
245 r"""Elementwise division: :math:`x \over y`."""
--> 246 return div_p.bind(x, y)
247
248 def rem(x, y):
... stack trace ...
当这段代码在 @jit 函数的输出中看到 NaN 时,它会调用反优化代码,因此我们仍然可以得到清晰的堆栈跟踪。我们可以使用 %debug 运行事后调试器来检查所有值,以找出错误。
⚠️ 如果你没有在调试,不应该开启 NaN 检查器,因为它可能会引入大量的设备-主机往返和性能回归!
⚠️ NaN 检查器不适用于 pmap。要调试 pmap 代码中的 NaN,可以尝试将 pmap 替换为 vmap。
🔪 双精度 (64位)#
目前,JAX 默认强制使用单精度数字,以缓解 NumPy API 倾向于主动提升操作数为 double 的问题。这对于许多机器学习应用来说是期望的行为,但它可能会让你感到意外!
x = random.uniform(random.key(0), (1000,), dtype=jnp.float64)
x.dtype
/tmp/ipykernel_1918/1258726447.py:1: UserWarning: Explicitly requested dtype float64 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.
x = random.uniform(random.key(0), (1000,), dtype=jnp.float64)
dtype('float32')
要使用双精度数字,你需要在启动时设置 jax_enable_x64 配置变量。
有几种方法可以做到这一点:
你可以通过设置环境变量
JAX_ENABLE_X64=True来启用 64 位模式。你可以在启动时手动设置
jax_enable_x64配置标志:# again, this only works on startup! import jax jax.config.update("jax_enable_x64", True)
你可以使用
absl.app.run(main)解析命令行标志:import jax jax.config.config_with_absl()
如果你想让 JAX 为你解析 absl,即你不想执行
absl.app.run(main),你可以改用:import jax if __name__ == '__main__': # calls jax.config.config_with_absl() *and* runs absl parsing jax.config.parse_flags_with_absl()
请注意,#2-#4 对 JAX 的任何配置选项都适用。
然后,我们可以确认 x64 模式已启用,例如:
import jax
import jax.numpy as jnp
from jax import random
jax.config.update("jax_enable_x64", True)
x = random.uniform(random.key(0), (1000,), dtype=jnp.float64)
x.dtype # --> dtype('float64')
注意事项#
⚠️ XLA 在所有后端上都不支持 64 位卷积!
🔪 与 NumPy 的其他不同之处#
虽然 jax.numpy 尽最大努力复制 NumPy API 的行为,但确实存在一些角落情况,其行为不同。上述各节详细讨论了许多此类情况;此处列出了一些其他已知 API 存在差异的地方。
对于二元运算,JAX 的类型提升规则与 NumPy 使用的规则略有不同。有关更多详细信息,请参阅 类型提升语义。
在执行不安全类型转换(即目标 dtype 无法表示输入值时的转换)时,JAX 的行为可能取决于后端,并且通常可能与 NumPy 的行为不同。NumPy 允许通过
casting参数(请参阅np.ndarray.astype)来控制这些场景下的结果;JAX 不提供任何此类配置,而是直接继承 XLA:ConvertElementType 的行为。这是一个不安全类型转换的示例,其结果在 NumPy 和 JAX 之间存在差异:
>>> np.arange(254.0, 258.0).astype('uint8') array([254, 255, 0, 1], dtype=uint8) >>> jnp.arange(254.0, 258.0).astype('uint8') Array([254, 255, 255, 255], dtype=uint8)
这种不匹配通常发生在将极端值从浮点类型转换为整数类型或反之亦然时。
在操作亚正规(subnormal)浮点数时,JAX 操作在某些后端上使用零刷新(flush-to-zero)语义。例如:
>>> import jax.numpy as jnp >>> subnormal = jnp.float32(1E-45) >>> subnormal # subnormals are representable Array(1.e-45, dtype=float32) >>> subnormal + 0 # but are flushed to zero within operations Array(0., dtype=float32)
亚正规值的详细操作语义通常因后端而异。
完。#
如果此处未涵盖的内容让你痛苦不堪,请告诉我们,我们将扩展这些入门建议!