JAX PRNG 设计#

我们希望 PRNG 设计能够

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

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

  3. 语义@jit 编译边界和设备后端无关

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

  5. 可并行化,因为它不会在本来没有数据依赖的随机函数调用之间增加序列约束,

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

  7. 符合 JAX 和 XLA 语义和设计理念(最终受其他实际考虑因素驱动)。

作为这些要求的推论,我们认为设计应该是函数式的。另一个推论是,至少在当前的硬件限制下,我们将在软件中实现 PRNG。

总而言之 JAX PRNG = Threefry 计数器 PRNG + 函数式数组导向的拆分模型

目录#

三种编程模型和示例程序#

这是一个**有状态全局** PRNG 的示例,类似于 Numpy 程序中常用的那种

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 函数调用中进行向量化(#4)(例如,使用 shape 参数的 rand()),我们放弃了这种顺序等效保证。本节讨论的三种编程模型中的任何一种都可以支持这种向量化,尽管它推动了下一节所述的基于计数器的 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)目标。

前两种模型的关键问题是序列化太多。为了减少顺序依赖,我们使用**函数式可拆分 PRNGs**。拆分是一种将新的 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*设计,特别是 Threefry 哈希函数,如《并行随机数:像 1, 2, 3 一样简单》中所述。我们使用计数器实现高效向量化:对于给定的密钥,我们可以通过将哈希函数映射到整数范围 [k + 1, …, k + sample_size] 来以向量化方式生成值数组。我们将密钥与哈希函数一起使用,以实现可拆分 PRNGs:也就是说,拆分是一种从现有密钥生成两个新密钥的方法。

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 完全不需要从代码中返回。

以下是如何使用 stax 神经网络构建库将此 PRNG 模型用于实现 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。