JAX 内部机制:jaxpr 语言#
Jaxpr 是 JAX 的程序内部中间表示(IR)。它们是显式类型化的、函数式的、一阶的,并且处于代数范式(ANF)中。
从概念上讲,可以将 JAX 转换(例如 jax.jit() 或 jax.grad())理解为:首先通过追踪(trace)将需要转换的 Python 函数专门化为一种小巧且行为良好的中间形式,然后利用转换特定的解释规则对该中间形式进行解释。
JAX 之所以能在如此小的软件包中集成如此强大的功能,原因之一在于它从一个熟悉且灵活的编程接口(Python 加 NumPy)入手,利用实际的 Python 解释器来完成大部分繁重的工作,从而将计算的本质提炼为一种具有有限高阶特性的简单静态类型表达式语言。
这种语言就是 jaxpr 语言。jaxpr 项的语法如下:
jaxpr ::=
{ lambda <binder> , ... .
let <eqn>
...
in ( <atom> , ... ) }
binder ::= <var>:<array_type>
var ::= a | b | c | ...
atom ::= <var> | <literal>
literal ::= <int32> | <int64> | <float32> | <float64>
eqn ::= <binder> , ... = <primitive> [ <params> ] <atom> , ...
并非所有 Python 程序都能以这种方式处理,但事实证明,许多科学计算和机器学习程序是可以的。
在继续之前,请记住并非所有的 JAX 转换都会字面上物化上述的 jaxpr。其中一些(例如微分或批处理)会在追踪过程中增量应用转换。尽管如此,如果想要了解 JAX 在内部是如何工作的,或者想要利用 JAX 追踪的结果,理解 jaxpr 是非常有用的。
jax.core.ClosedJaxpr#
一个 jaxpr 实例表示一个带有一个或多个类型化参数(输入变量)和一个或多个类型化结果的函数。结果仅依赖于输入变量;不存在从外层作用域捕获的自由变量。输入和输出都有类型,在 JAX 中这些类型表示为抽象值。
代码中有两种相关的 jaxpr 表示形式:jax.core.Jaxpr 和 jax.core.ClosedJaxpr。jax.core.ClosedJaxpr 表示一个部分应用的 jax.core.Jaxpr,这是你使用 jax.make_jaxpr() 来检查 jaxpr 时所获得的结果。它具有以下字段:
jaxpr:是一个jax.core.Jaxpr,表示函数实际的计算内容(如下所述)。consts是常量列表。
ClosedJaxpr 中最有趣的部分是实际的执行内容,表现为使用以下语法打印的 jax.core.Jaxpr:
jaxpr ::= { lambda Var* ; Var+.
let Eqn*
in [Expr+] }
其中
jaxpr 的参数显示为两组由
;分隔的变量列表。第一组变量是为了代表被提升(hoisted)出来的常量而引入的。它们被称为
constvars,在jax.core.ClosedJaxpr中,consts字段保存了相应的值。第二组变量被称为
invars,对应于所追踪的 Python 函数的输入。
Eqn*是一个方程列表,定义了指向中间表达式的中间变量。每个方程定义了一个或多个变量,作为对某些原子表达式应用原语的结果。每个方程仅使用输入变量和先前方程定义的中间变量。Expr+:是 jaxpr 的输出原子表达式(字面量或变量)列表。
方程的打印格式如下:
Eqn ::= let Var+ = Primitive [ Param* ] Expr+
其中
Var+是一个或多个中间变量,定义为原语调用的输出(某些原语可以返回多个值)。Expr+是一个或多个原子表达式,每个表达式要么是变量,要么是字面常量。一个特殊的变量unitvar或字面量unit(打印为*)表示在计算的后续部分中不需要且已被省略的值。也就是说,unit 只是占位符。Param*是原语的零个或多个命名参数,打印在方括号内。每个参数显示为Name = Value。
大多数 jaxpr 原语是一阶的(它们仅接受一个或多个 Expr 作为参数)。
Primitive := add | sub | sin | mul | ...
最常见的 jaxpr 原语记录在 jax.lax 模块中。
例如,下面是为 func1 函数生成的 jaxpr:
from jax import make_jaxpr
import jax.numpy as jnp
def func1(first, second):
temp = first + jnp.sin(second) * 3.
return jnp.sum(temp)
print(make_jaxpr(func1)(jnp.zeros(8), jnp.ones(8)))
{ lambda ; a:f32[8] b:f32[8]. let
c:f32[8] = sin b
d:f32[8] = mul c 3.0:f32[]
e:f32[8] = add a d
f:f32[] = reduce_sum[axes=(0,) out_sharding=None] e
in (f,) }
这里没有 constvars,a 和 b 是输入变量,它们分别对应于 first 和 second 函数参数。标量字面量 3.0 被保留在内联中。reduce_sum 原语除了操作数 e 之外,还有命名参数 axes 和 input_shape。
请注意,尽管执行调用 JAX 的程序会构建 jaxpr,但 Python 级别的控制流和 Python 级别的函数会正常执行。这意味着仅仅因为 Python 程序包含函数和控制流,生成的 jaxpr 并不一定需要包含控制流或高阶特性。
例如,当追踪 func3 函数时,JAX 会将对 inner 的调用和条件语句 if second.shape[0] > 4 进行内联,并产生与之前相同的 jaxpr:
def func2(inner, first, second):
temp = first + inner(second) * 3.
return jnp.sum(temp)
def inner(second):
if second.shape[0] > 4:
return jnp.sin(second)
else:
assert False
def func3(first, second):
return func2(inner, first, second)
print(make_jaxpr(func3)(jnp.zeros(8), jnp.ones(8)))
{ lambda ; a:f32[8] b:f32[8]. let
c:f32[8] = sin b
d:f32[8] = mul c 3.0:f32[]
e:f32[8] = add a d
f:f32[] = reduce_sum[axes=(0,) out_sharding=None] e
in (f,) }
处理 pytree#
在 jaxpr 中没有元组类型;相反,原语接受多个输入并产生多个输出。当处理具有结构化输入或输出的函数时,JAX 会将其展平,在 jaxpr 中它们表现为输入和输出列表。有关更多详细信息,请参阅 Pytrees 教程。
例如,下面的代码生成的 jaxpr 与你之前看到的完全相同(有两个输入变量,对应于输入元组的每个元素):
def func4(arg): # The `arg` is a pair.
temp = arg[0] + jnp.sin(arg[1]) * 3.
return jnp.sum(temp)
print(make_jaxpr(func4)((jnp.zeros(8), jnp.ones(8))))
{ lambda ; a:f32[8] b:f32[8]. let
c:f32[8] = sin b
d:f32[8] = mul c 3.0:f32[]
e:f32[8] = add a d
f:f32[] = reduce_sum[axes=(0,) out_sharding=None] e
in (f,) }
常量变量 (vars)#
jaxpr 中的某些值是常量,因为它们的值不依赖于 jaxpr 的参数。当这些值是标量时,它们直接在 jaxpr 方程中表示。非标量数组常量则被提升到顶层 jaxpr,对应于常量变量(“constvars”)。这些 constvars 与其他 jaxpr 参数(“invars”)的区别仅在于簿记约定。
高阶 JAX 原语#
Jaxpr 包含几个高阶 JAX 原语。它们更复杂,因为它们包含子 jaxpr。
cond 原语(条件分支)#
JAX 会追踪正常的 Python 条件分支。要捕获用于动态执行的条件表达式,必须使用 jax.lax.switch() 和 jax.lax.cond() 构造函数,它们的签名如下:
lax.switch(index: int, branches: Sequence[A -> B], operand: A) -> B
lax.cond(pred: bool, true_body: A -> B, false_body: A -> B, operand: A) -> B
这两者都会在内部绑定一个名为 cond 的原语。jaxpr 中的 cond 原语反映了 lax.switch() 更通用的签名:它接受一个表示要执行分支索引的整数(会限制在有效的索引范围内)。
例如
from jax import lax
def one_of_three(index, arg):
return lax.switch(index, [lambda x: x + 1.,
lambda x: x - 2.,
lambda x: x + 3.],
arg)
print(make_jaxpr(one_of_three)(1, 5.))
{ lambda ; a:i32[] b:f32[]. let
c:i32[] = convert_element_type[new_dtype=int32 weak_type=False] a
d:i32[] = clamp 0:i32[] c 2:i32[]
e:f32[] = cond[
branches=(
{ lambda ; f:f32[]. let g:f32[] = add f 1.0:f32[] in (g,) }
{ lambda ; h:f32[]. let i:f32[] = sub h 2.0:f32[] in (i,) }
{ lambda ; j:f32[]. let k:f32[] = add j 3.0:f32[] in (k,) }
)
] d b
in (e,) }
cond 原语有若干参数:
branches是对应于分支泛函(branch functionals)的 jaxpr。在此示例中,这些泛函每个都接受一个对应于x的输入变量。linear是一个布尔元组,在内部被自动微分机制用于编码哪些输入参数在条件分支中被线性使用。
上述 cond 原语的实例接受两个操作数。第一个(d)是分支索引,然后 b 是将传递给分支索引所选定的 branches 中任何一个 jaxpr 的操作数(arg)。
另一个示例,使用 jax.lax.cond():
from jax import lax
def func7(arg):
return lax.cond(arg >= 0.,
lambda xtrue: xtrue + 3.,
lambda xfalse: xfalse - 3.,
arg)
print(make_jaxpr(func7)(5.))
{ lambda ; a:f32[]. let
b:bool[] = ge a 0.0:f32[]
c:i32[] = convert_element_type[new_dtype=int32 weak_type=False] b
d:f32[] = cond[
branches=(
{ lambda ; e:f32[]. let f:f32[] = sub e 3.0:f32[] in (f,) }
{ lambda ; g:f32[]. let h:f32[] = add g 3.0:f32[] in (h,) }
)
] c a
in (d,) }
在这种情况下,布尔谓词被转换为整数索引(0 或 1),branches 是分别对应于 false 和 true 分支泛函的 jaxpr,顺序如此。同样,每个函数接受一个输入变量,分别对应于 xfalse 和 xtrue。
下面的示例展示了一个更复杂的情况,即分支泛函的输入是一个元组,并且 false 分支泛函包含一个被提升为 constvar 的常量 jnp.ones(1)。
def func8(arg1, arg2): # Where `arg2` is a pair.
return lax.cond(arg1 >= 0.,
lambda xtrue: xtrue[0],
lambda xfalse: jnp.array([1]) + xfalse[1],
arg2)
print(make_jaxpr(func8)(5., (jnp.zeros(1), 2.)))
{ lambda a:i32[1]; b:f32[] c:f32[1] d:f32[]. let
e:bool[] = ge b 0.0:f32[]
f:i32[] = convert_element_type[new_dtype=int32 weak_type=False] e
g:f32[1] = cond[
branches=(
{ lambda ; h:i32[1] i:f32[1] j:f32[]. let
k:f32[1] = convert_element_type[new_dtype=float32 weak_type=True] h
l:f32[1] = add k j
in (l,) }
{ lambda ; m:i32[1] n:f32[1] o:f32[]. let in (n,) }
)
] f a c d
in (g,) }
while 原语#
就像条件分支一样,Python 循环在追踪期间会被内联。如果你想捕获一个用于动态执行的循环,你必须使用几个特殊操作中的一个:jax.lax.while_loop()(一个原语)和 jax.lax.fori_loop()(生成 while_loop 原语的辅助函数)。
lax.while_loop(cond_fun: (C -> bool), body_fun: (C -> C), init: C) -> C
lax.fori_loop(start: int, end: int, body: (int -> C -> C), init: C) -> C
在上述签名中,C 代表循环“进位”(carry)值的类型。例如,这是一个 fori_loop 的示例:
import numpy as np
def func10(arg, n):
ones = jnp.ones(arg.shape) # A constant.
return lax.fori_loop(0, n,
lambda i, carry: carry + ones * 3. + arg,
arg + ones)
print(make_jaxpr(func10)(np.ones(16), 5))
{ lambda ; a:f32[16] b:i32[]. let
c:f32[16] = broadcast_in_dim 1.0:f32[]
d:f32[16] = add a c
_:i32[] _:i32[] e:f32[16] = while[
body_jaxpr={ lambda ; f:f32[16] g:f32[16] h:i32[] i:i32[] j:f32[16]. let
k:i32[] = add h 1:i32[]
l:f32[16] = mul f 3.0:f32[]
m:f32[16] = add j l
n:f32[16] = add m g
in (k, i, n) }
body_nconsts=2
cond_jaxpr={ lambda ; o:i32[] p:i32[] q:f32[16]. let
r:bool[] = lt o p
in (r,) }
cond_nconsts=0
] c a 0:i32[] b d
in (e,) }
while 原语接受 5 个参数:c a 0 b d,具体如下:
cond_jaxpr的 0 个常量(因为cond_nconsts为 0)body_jaxpr的 2 个常量(c和a)进位初始值的 3 个参数
scan 原语#
JAX 支持一种针对数组元素(具有静态已知形状)的特殊循环形式。迭代次数固定这一事实使得这种形式的循环易于反向微分。此类循环使用 jax.lax.scan() 函数构造:
lax.scan(body_fun: (C -> A -> (C, B)), init_carry: C, in_arr: Array[A]) -> (C, Array[B])
这是根据 Haskell 类型签名编写的:C 是 scan 进位的类型,A 是输入数组元素的类型,B 是输出数组元素的类型。
对于该示例,考虑下面的 func11 函数:
def func11(arr, extra):
ones = jnp.ones(arr.shape) # A constant
def body(carry, aelems):
# carry: running dot-product of the two arrays
# aelems: a pair with corresponding elements from the two arrays
ae1, ae2 = aelems
return (carry + ae1 * ae2 + extra, carry)
return lax.scan(body, 0., (arr, ones))
print(make_jaxpr(func11)(np.ones(16), 5.))
{ lambda ; a:f32[16] b:f32[]. let
c:f32[16] = broadcast_in_dim 1.0:f32[]
d:f32[] e:f32[16] = scan[
jaxpr={ lambda ; f:f32[] g:f32[] h:f32[] i:f32[]. let
j:f32[] = mul h i
k:f32[] = convert_element_type[new_dtype=float32 weak_type=False] g
l:f32[] = add k j
m:f32[] = convert_element_type[new_dtype=float32 weak_type=False] f
n:f32[] = add l m
in (n, g) }
length=16
num_carry=1
num_consts=1
reverse=False
unroll=1
] b 0.0:f32[] a c
in (d, e) }
linear 参数描述了每个输入变量在主体中是否保证被线性使用。一旦 scan 通过线性化,更多的参数将变为线性的。
scan 原语接受 4 个参数:b 0.0 a c,其中:
一个是主体(body)的自由变量
一个是进位的初始值
接下来的 2 个是 scan 操作的数组
(p)jit 原语#
call 原语源于 JIT 编译,它封装了一个子 jaxpr 以及指定计算应在其上运行的后端和设备的参数。例如:
from jax import jit
def func12(arg):
@jit
def inner(x):
return x + arg * jnp.ones(1) # Include a constant in the inner function.
return arg + inner(arg - 2.)
print(make_jaxpr(func12)(1.))
{ lambda ; a:f32[]. let
b:f32[] = sub a 2.0:f32[]
c:f32[1] = jit[
name=inner
jaxpr={ lambda ; a:f32[] b:f32[]. let
d:f32[1] = broadcast_in_dim 1.0:f32[]
e:f32[] = convert_element_type[new_dtype=float32 weak_type=False] a
f:f32[1] = mul e d
g:f32[] = convert_element_type[new_dtype=float32 weak_type=False] b
c:f32[1] = add g f
in (c,) }
] a b
h:f32[] = convert_element_type[new_dtype=float32 weak_type=False] a
i:f32[1] = add h c
in (i,) }