JAX PRNG 设计#

我们希望 PRNG 的设计能够:

  1. 具有表达性,即方便使用,并且不会限制用户编写具有精确所需行为的数值程序的能力;

  2. 以独立于后端的方式实现可复现的程序执行;

  3. 具有不受 @jit 编译边界和设备后端影响的语义;

  4. 能够使用 SIMD 硬件向量化生成数组值

  5. 可并行化的,即它不会在原本没有数据依赖的随机函数调用之间增加排序约束;

  6. 可扩展到多副本、多核和分布式计算

  7. 符合 JAX 和 XLA 的语义和设计理念(这些理念最终也是由其他实际考虑驱动的)。

作为这些的必然结果,我们认为该设计应该是函数式的。另一个必然结果是,至少在当前的硬件限制下,我们将用软件实现 PRNG。

简而言之,JAX PRNG = Threefry 计数器 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 函数调用(例如,使用形状参数的 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))

需要注意的一些要点:

  1. 对 bar() 和 baz() 的调用之间没有顺序依赖关系,它们可以以任意顺序求值而不影响结果的值,这解决了剩余的性能目标(#5,#6);

  2. 函数不需要返回 PRNG 的更新版本,并且可以直接调用随机子程序而不影响现有的 PRNG 状态,从而提高了其他函数式模型的表达性(#1)。

该示例没有显示,但作为选择 (2) 的结果,推进 PRNG 状态的唯一方法是调用 split()。也就是说,我们有两种方法可以实现 (1),它们的不同之处在于,它们是使用户程序负担显式调用 split() 的责任,如上述示例中所示,还是使用户程序负担显式传递的责任。我们更喜欢前者,即显式拆分的版本,因为我们可以很容易地在此基础上实现显式传递版本。

设计#

我们可以使用基于计数器的 PRNG 设计,特别是 并行随机数:像 1、2、3 一样容易中描述的 Threefry 哈希函数。我们使用计数器来实现高效的向量化:对于给定的密钥,我们可以通过将哈希函数映射到一系列整数 [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]

令人惊讶的是,抽取样本与拆分非常相似!关键在于输出类型的差异(即使类型被标识):在一种情况下,该值用于形成感兴趣的随机样本(例如,将随机位转换为表示随机正态分布的浮点数),而在另一种情况下,该值用作进一步哈希的密钥。

哈希函数参数中的不对称性,类型为 Key 和 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 的简单扩展版本,可以生成多个副本。

权衡和替代方案#

  1. 我们没有利用任何设备硬件 PRNG

    • 我们目前没有对所有后端的硬件 PRNG 状态进行足够的控制。

    • 即使我们这样做了,它也将依赖于后端,并且我们可能必须在随机调用之间引入顺序依赖关系,以确保确定性的排序,从而确保可复现性。

    • 我们不知道有任何工作负载会导致软件 PRNG 成为瓶颈。

    • 我们可以考虑提供一个额外的 API,允许访问硬件 PRNG,以供想要放弃其他期望(如严格可复现性)的用户使用。

  2. 我们放弃了顺序等效保证,在这种保证中,在一个调用中创建随机数组会产生与一次创建一个随机元素的扁平数组相同的值。

    • 此属性可能与向量化(高优先级)不兼容。

    • 我们不知道有任何用户或示例认为此属性很重要。

    • 用户可以在此 API 之上编写一个层来提供此保证。

  3. 我们无法完全遵循 numpy.random API。