JAX PRNG 设计#
我们希望 PRNG 设计具有以下特点:
富有表现力,即使用方便,且不会限制用户编写具有所需精确行为的数值程序的能力,
能够以独立于后端的方式实现可重复的程序执行,
具有不受
@jit
编译边界和设备后端影响的语义,能够使用 SIMD 硬件向量化生成数组值,
具有可并行化性,即不会在本来没有数据依赖的随机函数调用之间添加排序约束,
可以扩展到多副本、多核和分布式计算,
符合 JAX 和 XLA 语义和设计理念(这些理念最终由其他实际考虑驱动)。
作为这些的必然结果,我们认为设计应该是函数式的。另一个必然结果是,至少在当前硬件限制下,我们将用软件实现 PRNG。
简而言之,JAX PRNG = Threefry 计数器 PRNG + 一个面向函数式数组的拆分模型
目录#
三种编程模型和玩具示例程序#
这是一个有状态的全局 PRNG 的玩具示例,类似于 Numpy 程序中常用的 PRNG
def foo(): return bar() + baz()
def bar(): return rand(RNG, (3, 4))
def baz(): return rand(RNG, (3, 4))
def main():
global RNG
RNG = RandomState(0)
return foo()
为了实现此处的复现性,我们需要控制 bar() 和 baz() 的求值顺序,即使它们之间没有显式的数据依赖关系。这种源于复现性 (#2) 的排序要求违反了并行性 (#5),并且不符合 JAX 或 XLA 的函数式语义 (#6),在这些语义中,子表达式可以以任何顺序进行求值。即使我们不要求复现性,从而允许任何求值顺序,由于需要更新共享状态,跨调用进行并行化 (#5) 仍然会变得困难。此外,由于需要在 Python 和任何已编译的代码中访问和维护相同的 PRNG 状态,因此此模型很可能会导致工程上的挑战,从而无法实现编译不变性 (#3) 和扩展到多个副本 (#6)。最后,表达能力有限 (#1),因为 foo() 无法在不影响其自身(隐式)PRNG 状态的情况下调用 bar() 或 baz()。
该模型是否支持向量化 (#4) 取决于一些额外的细节。在 Numpy 中,PRNG 向量化受到顺序等效保证的限制
In [1]: rng = np.random.RandomState(0)
In [2]: rng.randn(2)
Out[2]: array([1.76405235, 0.40015721])
In [3]: rng = np.random.RandomState(0)
In [4]: np.stack([rng.randn() for _ in range(2)])
Out[4]: array([1.76405235, 0.40015721])
为了允许在生成数组的原始 PRNG 函数调用(例如,具有 shape 参数的 rand())中进行向量化 (#4),我们放弃了这种顺序等效保证。任何本节中讨论的三种编程模型都可以支持这种向量化,尽管它促使我们根据下一节中描述的基于计数器的 PRNG 来实现。
有状态的 PRNG 用户编程模型并不理想。这是一个函数式模型的示例,但缺少一个我们称之为拆分的关键要素
def foo(rng_1):
y, rng_2 = baz(rng_1)
z, rng_3 = bar(rng_2)
return y + z, rng_3
def bar(x, rng):
val, new_rng = rand(rng, (3, 4))
return val, new_rng
def baz(x, rng):
val, new_rng = rand(rng, (3, 4))
return val, new_rng
def main():
foo(RandomState(0))
此模型显式地通过所有生成随机值的函数(原始函数或非原始函数)传递 PRNG 状态:也就是说,每个随机函数都必须接受和返回状态。现在,在 foo() 中对 baz() 的调用和对 bar() 的调用之间存在显式的数据依赖关系,因此数据流(以及排序)是显式的,并且与 JAX 的现有语义 (#7) 相符,这与之前的模型不同。这种显式传递还可以使语义对编译边界保持不变 (#3)。
显式传递对程序员来说很不方便。但更糟糕的是,它实际上并没有提高表达能力 (#1):foo() 仍然无法在保持自身 PRNG 状态的情况下调用 bar() 或 baz()。在不了解其调用者或它们调用的子例程的情况下,函数必须防御性地在任何地方传入和返回 rng 状态。此外,它也没有提高并行化 (#5) 或扩展到多个副本 (#6) 的前景,因为一切仍然是顺序的,即使排序在函数式编程意义上是显式的。
简而言之,通过显式传递状态使代码具有函数式功能不足以实现我们的表达能力 (#1) 和性能 (#5, #6) 目标。
之前两种模型中的关键问题是排序过多。为了减少顺序依赖的数量,我们使用函数式可拆分 PRNG。拆分是一种将新的 PRNG 状态“派生”为两个 PRNG 状态的机制,同时保持通常所需的 PRNG 属性(两个新流在计算上是可并行化的,并产生独立的随机值,即它们的行为类似于多流)。
def foo(rng_1):
rng_2, rng_3 = split(rng_1, 2)
return bar(rng_2) + baz(rng_3)
def bar(x, rng):
return rand(rng, (3, 4))
def baz(x, rng):
return rand(rng, (3, 4))
def main():
foo(RandomState(0))
需要注意的一些要点
对 bar() 和 baz() 的调用之间没有顺序依赖关系,它们可以以任何顺序进行求值,而不会影响结果的值,从而解决了剩余的性能目标 (#5, #6),
函数不需要返回 PRNG 的更新版本,并且可以轻松地调用随机子例程而不会影响现有的 PRNG 状态,从而提高了其他函数式模型的表达能力 (#1)。
该示例没有显示,但作为选择 (2) 的结果,推进 PRNG 状态的唯一方法是调用 split()。也就是说,我们有两种方法可以实现 (1),它们的区别在于它们是否让用户程序负担显式调用 split() 的责任,如上面的示例中所示,或者是否让用户程序负担显式传递的责任。我们更喜欢前者,即显式拆分版本,因为我们可以很容易地根据它实现显式传递版本。
设计#
我们可以使用基于计数器的 PRNG 设计,特别是 Threefry 哈希函数,如并行随机数:像 1、2、3 一样简单中所述。我们使用计数器来实现高效的向量化:对于给定的密钥,我们可以通过将哈希函数映射到整数范围 [k + 1, …, k + sample_size] 上,以向量化的方式生成一系列值。我们使用密钥和哈希函数来实现可拆分 PRNG:也就是说,拆分是一种从现有密钥生成两个新密钥的方法。
type Sample = Int256
type Key = Sample -- important identification for splitting
type Count = Int32
hash :: Key -> Count -> Int256 -- output type equal to Key and Sample
split :: Key -> (Key, Key)
split key = (hash key 0, hash key 1)
draw_samples :: Key -> Int -> [Sample]
draw_samples key n = map (hash key) [1..n]
令人惊讶的是,抽取样本与拆分非常相似!关键在于输出类型的差异(即使类型是相同的):在一种情况下,该值将用于形成感兴趣的随机样本(例如,将随机位转换为表示随机正态分布的 Float),而在另一种情况下,该值将用作进一步哈希的密钥。
哈希函数参数(类型为 Key 和 Count)中的不对称性在于,后者是微不足道的,并且通过任意数量来推进在计算上很便宜,因为我们只需要增加整数值,而前者只能通过哈希来推进。这就是我们使用 count 参数进行向量化的原因。
更实际的用户程序示例#
这是在主机上进行训练循环时,当步骤需要 PRNG 时(可能用于 dropout 或 VAE 训练)可能的样子
rng = lax.rng.new_rng()
for i in xrange(num_steps):
rng, rng_input = lax.rng.split(rng)
params = compiled_update(rng_input, params, next(batches))
请注意,我们让用户负担显式拆分 rng 的责任,但 rng 根本不需要从代码中返回。
这是我们如何使用此 PRNG 模型和 stax 神经网络构建器库来实现 dropout
def Dropout(rate, mode='train'):
def init_fun(input_shape):
return input_shape, ()
def apply_fun(rng, params, inputs):
if mode == 'train':
keep = lax.random.bernoulli(rng, rate, inputs.shape)
return np.where(keep, inputs / rate, 0)
else:
return inputs
return init_fun, apply_fun
此处的 rng 值只是用于哈希的密钥,而不是特殊对象。rng 参数传递给每个 apply_fun,因此需要在带有拆分的串行和并行组合器中处理它
def serial(*layers):
init_funs, apply_funs = zip(*layers)
def init_fun(input_shape):
...
def apply_fun(rng, params, inputs):
rngs = split(rng, len(layers))
for rng, param, apply_fun in zip(rngs, params, apply_funs):
inputs = apply_fun(rng, param, inputs)
return inputs
return init_fun, apply_fun
def parallel(*layers):
init_funs, apply_funs = zip(*layers)
def init_fun(input_shape):
...
def apply_fun(rng, params, inputs):
rngs = split(rng, len(layers))
return [f(r, p, x) for f, r, p, x in zip(apply_funs, rngs, params, inputs)]
return init_fun, apply_fun
这里我们使用了一个简单的扩展版本 split,可以生成多个副本。
权衡和替代方案#
我们没有利用任何设备硬件 PRNG
我们目前没有对所有后端的硬件 PRNG 状态进行足够的控制。
即使我们有,它也会依赖于后端,我们可能需要在随机调用之间引入顺序依赖关系,以确保确定性的排序,从而确保可复现性。
我们不知道有任何工作负载会导致软件 PRNG 成为瓶颈。
我们可以考虑提供一个额外的 API,允许访问硬件 PRNG,以便用户放弃其他期望值(例如严格的可复现性)。
我们放弃了顺序等效保证,即在一个调用中创建一个随机数组会产生与一次创建一个展平数组的随机元素相同的值。
此属性可能与向量化(高优先级)不兼容。
我们不知道有任何用户或示例认为此属性很重要。
用户可以在此 API 之上编写一个层来提供此保证。
我们无法完全遵循
numpy.random
API。