通过部分求值选择保存中间值还是在求导时重算
JAX 的 checkpoint 把函数表示成 Jaxpr,在求导变换中根据策略保留必要中间值,并为其余计算生成重算部分。
工程问题#
反向自动微分需要前向计算的中间值。全部保存会占用较多设备内存,全部重算又可能重复执行代价很高的矩阵乘法;选择还必须受反向计算真正用到哪些值约束。
实际实现#
checkpoint 在此版本默认路径中绑定 remat_p。其部分求值规则把 Jaxpr 拆为已知计算和延后计算,调用保存策略后继续做死代码删除,只保留延后部分需要的 residuals。已知部分计算出输出与 residuals,未知部分重新绑定 remat_p,并携带这些 residuals 和仍需使用的输入。
处理流程#
- 把被 checkpoint 包装的函数跟踪为 Jaxpr,并绑定保存策略
- 按已知输入和策略拆出前向可计算部分与延后部分
- 删除延后部分及前向部分中无用的计算与 residuals
- 保存剩余 residuals;求导时用保留输入和这些值执行所需重算
源码片段#
固定版本 · 原文摘录1322remat3 = bool_state(1323 name='jax_remat3',1324 default=False,1325 upgrade=True,1326 help='If True, embrace the future of remat.',1327 include_in_jit_key=True,1328 include_in_trace_context=True,1329)固定提交将 remat3 默认设为 False,因此下文分析 remat_p 分支;运行环境显式打开开关时不适用这条内部调用链。
378 jaxpr, consts, out_tree = _trace_to_jaxpr(fun_, in_tree, tuple(in_avals), debug)379 if config.mutable_array_checks.value:380 api_util._check_no_aliased_closed_over_refs(debug, consts, args_flat)381 if isinstance(prevent_cse, tuple):382 cse_args = (tuple(args), kwargs) if kwargs else tuple(args)383 cse = (False,) * len(consts) + tuple(broadcast_prefix(prevent_cse, cse_args))384 else:385 cse = prevent_cse386 out_flat = remat_p.bind(387 *consts, *args_flat, jaxpr=jaxpr, prevent_cse=cse, differentiated=False,388 policy=policy)389 return tree_unflatten(out_tree, out_flat)函数被跟踪为 Jaxpr,随后连同 policy 绑定 remat_p;它不是运行时直接删除任意中间张量的钩子。
83def dots_with_no_batch_dims_saveable(prim, *args, **params) -> bool:84 """This is a useful heuristic for transformers."""85 if prim is lax_internal.dot_general_p:86 (_, _), (lhs_b, rhs_b) = params['dimension_numbers']87 if not lhs_b and not rhs_b:88 return True8990 if prim.name == "scaled_matmul_wrapper": # avoid cudnn import91 return args[0].shape[0] == 19293 return False一个具体策略允许保存没有批维的 dot 结果,并对该版本的 scaled_matmul_wrapper 单独判断;这里只是可保存性启发式。
586def remat_partial_eval(trace: pe.JaxprTrace, *tracers: core.Tracer,587 jaxpr: core.Jaxpr, prevent_cse, **params):588 assert not jaxpr.constvars589 disallowed_effects = effects.remat_allowed_effects.filter_not_in(jaxpr.effects)590 if disallowed_effects:591 raise NotImplementedError(592 f'Effects not supported in AD of `checkpoint`/`remat`: {disallowed_effects}')593 policy = params['policy'] or nothing_saveable594 in_unknowns = [not t.is_known() for t in tracers]595 jaxpr_known, jaxpr_staged, out_unknowns, out_inst, num_res = \596 pe.partial_eval_jaxpr_custom(597 jaxpr, in_unknowns, [True] * len(in_unknowns), False, False, policy)598599 # DCE jaxpr_staged, keeping only instantiated outputs which are unknown600 _, out_inst_unknown = partition_list(out_inst, out_unknowns)601 jaxpr_unknown, in_used_staged = pe.dce_jaxpr(jaxpr_staged, out_inst_unknown)602 used_res, in_used_staged = split_list(in_used_staged, [num_res])603604 # DCE jaxpr_known, keeping all known outputs but discarding dce'd res605 out_used_known = [True] * (len(out_unknowns) - sum(out_unknowns)) + used_res606 jaxpr_known, in_used_known = pe.dce_jaxpr(jaxpr_known, out_used_known)607 num_res = sum(used_res)按策略部分求值,然后两次进行死代码删除:先知道延后部分用到哪些 residuals,再清理前向保存集合。
609 # To avoid precision mismatches in fwd and bwd passes due to XLA excess610 # precision, insert explicit x = reduce_precision(x, **finfo(x.dtype)) calls611 # on producers of any residuals. See https://github.com/jax-ml/jax/pull/22244.612 jaxpr_known_ = _insert_reduce_precision(jaxpr_known, num_res)613614 # Compute known outputs and residuals (hoisted out of remat primitive)615 _, in_consts_ = unzip2(t.pval for t in tracers if t.pval.is_known())616 _, in_consts = partition_list(in_used_known, in_consts_)617 out_consts = core.eval_jaxpr(jaxpr_known_, (), *in_consts)618 out_knowns, residuals = split_list(out_consts, [len(out_consts)-num_res])精度规整后执行已知部分,分离出已知输出与确实保留的 residuals。
620 # set up unknown outputs with a recipe to call remat621 res_tracers = map(trace.new_instantiated_const, residuals)622 _, tracers_staged = partition_list(in_used_staged, tracers)623 in_jaxpr_tracers = res_tracers + map(trace.instantiate_const, tracers_staged) # pyrefly: ignore[bad-argument-type]624 out_jaxpr_tracers = [pe.JaxprTracer(trace, pe.PartialVal.unknown(x.aval), None)625 for x in jaxpr_unknown.outvars]626 if isinstance(prevent_cse, tuple):627 _, prevent_cse_ = partition_list(in_used_staged, prevent_cse)628 prevent_cse = (True,) * len(res_tracers) + tuple(prevent_cse_)629 new_params = dict(params, jaxpr=jaxpr_unknown, differentiated=True,630 prevent_cse=prevent_cse)631 recipe = pe.new_eqn_recipe(trace, in_jaxpr_tracers, out_jaxpr_tracers, remat_p,632 new_params, core.positional_effects(jaxpr_unknown),633 source_info_util.current())把 residuals 与仍需使用的输入组装为新的 remat_p 方程;延后部分的执行依赖在这里明确记录。
片段保持固定提交的连续原文;完整源码与许可证保存在本地。每段说明交代所在分支,省略部分可通过出处查看。
收益与取舍#
工程分析带来的收益
- 策略可以允许保留矩阵乘法结果,同时重算较便宜的中间计算
- 死代码删除使“允许保存”不等于“必须保存”,减少无用 residuals
付出的代价
- 省内存需要付出重算成本,策略不是全局最优内存规划;还需针对模型和硬件测量
- 部分副作用不支持 remat;源码显式拒绝不允许的 effects
- 本提交会对部分 residual 的生产者插入精度规整,避免前后向重算的精度不一致;不能把 checkpoint 简化为丢掉张量后原样再跑一次
可比较的选择
不使用 checkpoint让自动微分保存所需线性化中间值,重算较少,但显存占用可能较高。
中间值转存主机内存用设备与主机之间的数据传输换取设备内存,成本与重算策略不同;JAX 另有 offload 策略。
版本与适用范围#
对应固定提交 efe43f1 的 remat_p 路径;config.py 中 jax_remat3 默认 False。启用 jax_remat3 后会走另一个实现,本文不混用两条调用链。checkpoint 策略决定可保存性,不保证固定节省比例。
参考来源#
源码、官方文档与原始论文jax/_src/ad_checkpoint.py · checkpoint.fun_remat
相关实现与比较#
1 个相关案例PyTorch 的非重入 activation checkpoint 在反向传播需要中间张量时重新执行对应前向片段,以额外计算换取更低的激活存储需求。