4. 自动微分与vmap/pmap:grad、vmap、pmap、lax.scan等函数式变换的深度解析与RL应用场景
说实话,JAX 最让我着迷的地方,就是它这套函数式变换的思维。你想想看,在 PyTorch 或者 TF 里,我们习惯了“构建图 -> 执行图”这种模式。但 JAX 不一样,它把梯度计算、向量化、并行化、循环展开这些操作,都变成了纯粹的函数变换。说白了,就是“输入一个函数,输出一个新函数”。
这一章,我们就来啃下 JAX 强化学习落地的核心四件套:grad、vmap、pmap 和 lax.scan。我会结合我在实际 RL 项目里踩过的坑,给你讲透它们到底怎么用,以及为什么说它们是 RL 工程的加速器。
4.1 grad:不只是求导,更是策略梯度的引擎
先聊 grad。在 RL 里,我们几乎每天都在跟梯度打交道——策略梯度、Q-learning 的 TD 误差、Actor-Critic 的更新。JAX 的 grad 用起来非常直接:
import jax
import jax.numpy as jnp
def policy_loss(params, obs, actions, advantages):
logits = policy_network(params, obs)
log_probs = jax.nn.log_softmax(logits)
selected_log_probs = log_probs[jnp.arange(len(actions)), actions]
return -jnp.mean(selected_log_probs * advantages)
# 一行代码搞定梯度计算
loss_and_grad = jax.value_and_grad(policy_loss)
loss, grads = loss_and_grad(params, obs, actions, advantages)
嗯,这里要注意一个细节:grad 默认只对第一个参数求导。如果你的网络参数是嵌套的 dict 或者 tuple,它也能完美处理。我刚开始用的时候,总担心它会不会把学习率或者环境状态也求导了——放心,它只追踪通过 jnp 操作的计算路径。
grad,而是用 value_and_grad。因为很多时候你既需要 loss 值做日志记录,又需要梯度做更新。一次调用,两个结果,省一次前向传播的开销。
4.2 vmap:隐式批量化,告别手写 for 循环
接下来是 vmap。这个函数我愿称之为“RL 工程师的救星”。
你想想看,在强化学习中,我们经常需要同时处理多个环境实例(并行采样)。传统做法是写一个 for 循环,把每个环境的 obs 塞进网络。但 vmap 让你可以写一个处理单个样本的函数,然后自动把它向量化成处理批量样本的函数。
def single_forward(params, obs):
# 假设 obs 是 (obs_dim,) 的一维向量
return policy_network(params, obs)
# 自动变成批量版本
batch_forward = jax.vmap(single_forward, in_axes=(None, 0), out_axes=0)
# 现在可以一次传入 (batch_size, obs_dim) 的 obs
logits = batch_forward(params, obs_batch)
in_axes=(None, 0) 的意思是:第一个参数(params)在所有样本间共享(不拆分),第二个参数(obs)沿着第 0 维拆分。这个参数配置我经常搞混,我的记忆方法是:None 表示“不拆分”,数字表示“沿着哪个轴拆分”。
in_axes=(None, 0, 0),把 hidden_state 也沿着 batch 维度拆分。
4.3 pmap:多卡并行,从单机到分布式
pmap 是 vmap 的分布式版本。它把计算拆分到多个设备(GPU/TPU)上执行。在 RL 中,当你需要大规模并行采样时,pmap 就是你的利器。
# 假设你有 8 个 GPU
def update_step(params, batch):
loss, grads = value_and_grad(policy_loss)(params, batch)
return opt_update(params, grads)
# 每个 GPU 处理一部分 batch
parallel_update = jax.pmap(update_step, axis_name='devices')
# 注意:params 需要在所有设备上复制
replicated_params = jax.device_replicate(params)
replicated_batch = jax.device_put(batch, jax.local_devices())
new_params = parallel_update(replicated_params, replicated_batch)
这里有个关键点:pmap 返回的结果也是分布在多个设备上的。你需要用 jax.device_get 或者跨设备规约操作(比如 jax.lax.psum)来聚合结果。我刚开始用 pmap 时,经常忘记做梯度聚合,导致每个 GPU 各自为政,训练完全乱套。
jax.lax.pmean 对所有设备的梯度求平均,再统一更新参数。这样既保证了数据并行,又保持了参数的一致性。
4.4 lax.scan:优雅地展开时间序列
最后是 lax.scan。这个函数在 RL 中处理时间序列时特别有用。比如 RNN 的 rollout、TD(lambda) 的计算、或者 GAE(广义优势估计)的递推。
scan 本质上是一个函数式的 for 循环。它接受一个初始状态和一个序列,然后依次执行函数,同时传递状态。
def compute_gae(carry, transition):
# carry: (last_gae, last_value)
# transition: (reward, value, done)
last_gae, last_value = carry
reward, value, done = transition
# GAE 递推公式
delta = reward + 0.99 * last_value * (1 - done) - value
gae = delta + 0.99 * 0.95 * last_gae * (1 - done)
return (gae, value), gae
# 从后往前扫描
_, advantages = jax.lax.scan(
compute_gae,
(0.0, 0.0), # 初始 carry
reversed_transitions, # 时间序列,从 T 到 0
reverse=True # 从后往前
)
你看,用 scan 写 GAE 非常简洁,而且它是完全向量化的,比手写 for 循环快得多。我之前的项目里,用 scan 替换了原来的 Python for 循环后,GAE 的计算速度提升了将近 10 倍。
lax.scan 要求所有通过 scan 传递的数组在时间维度上大小固定。如果你的轨迹长度不固定(比如 early termination),需要先 padding 到最大长度,然后用 mask 处理。我一般用 jnp.where 配合 done 标志来屏蔽无效的时间步。
4.5 知识体系总览
为了让你更直观地理解这四者的关系,我画了一张图:
4.6 实战组合:一个完整的 PPO 更新步骤
最后,我把这四个变换组合起来,给你看一个真实的 PPO 更新流程。这基本上是我在项目中用的模板:
# 1. 定义单步更新
def single_update(carry, transition):
params, opt_state = carry
obs, action, reward, done, value = transition
# 计算 loss 和梯度
loss, grads = value_and_grad(policy_loss)(params, obs, action, reward)
# 更新参数
updates, opt_state = optimizer.update(grads, opt_state, params)
params = optax.apply_updates(params, updates)
return (params, opt_state), loss
# 2. 用 scan 展开时间序列
def trajectory_update(params, opt_state, trajectory):
# trajectory 是 (T, B, ...) 形状
(params, opt_state), losses = jax.lax.scan(
single_update,
(params, opt_state),
trajectory
)
return params, opt_state, losses
# 3. 用 vmap 处理多个环境
batch_update = jax.vmap(trajectory_update, in_axes=(None, None, 1))
# 4. 用 pmap 分布到多个 GPU
distributed_update = jax.pmap(batch_update, axis_name='devices')
# 实际调用
new_params, new_opt_state, all_losses = distributed_update(
jax.device_replicate(params),
jax.device_replicate(opt_state),
jax.device_put(trajectory_batch, jax.local_devices())
)
你看,通过组合这四个变换,我们把一个复杂的分布式 PPO 更新写成了清晰、可组合的函数式代码。每个变换只负责一件事,组合起来却能处理极其复杂的计算图。
好了,这一章的内容就到这里。记住这四个变换的核心思想:grad 负责微分,vmap 负责批量,pmap 负责分布式,scan 负责时序。把它们用好,你的 RL 代码会既快又清晰。