如何加速JAX编译时长?Legendre近似GMM熵计算优化
优化JAX中GMM Legendre熵近似的编译速度
问题背景
我需要实现Dahlke&Pacheco 2020提出的Legendre近似方法计算高斯混合模型(GMM)的熵,且要集成到JAX深度学习流程中并支持JIT编译。当前版本编译时长极长,推测核心原因是迭代分区数作为静态参数:当k和K增大时,分区数呈超指数增长,JAX会执行循环展开等操作,导致编译时间同步超指数上升。目标是在可接受运行速度下降的前提下,大幅缩短编译时间。
核心优化策略及代码修改
1. 将静态循环替换为动态循环(避免编译时展开)
JAX对Python原生for循环会进行静态展开,当循环次数随k/K超指数增长时,编译复杂度会爆炸。改用jax.lax.scan或jax.lax.fori_loop实现动态循环,编译时仅生成单次循环的IR,而非展开所有迭代分支。
修改partitions_函数(移除静态参数,改用动态生成)
原函数依赖静态参数c(分区数),导致编译时预分配固定大小数组并展开所有递归路径。修改后动态计算分区数,用jax.lax.fori_loop替代递归展开:
def partitions_(k, K): c = int(round(comb(k + K - 1, k))) buffer = jnp.zeros(K, dtype=jnp.int32) def _inner(carry): k_remaining, pos, current_buffer, obuffer, opos = carry if pos == K - 1: updated_buffer = current_buffer.at[pos].set(k_remaining) new_obuffer = obuffer.at[opos, :].set(updated_buffer) return (0, pos, current_buffer, new_obuffer, opos + 1) else: def loop_body(i, inner_carry): kr, p, cb, ob, op = inner_carry new_cb = cb.at[p].set(i) new_kr = kr - i return _inner((new_kr, p + 1, new_cb, ob, op)) initial_inner_carry = (k_remaining, pos, current_buffer, obuffer, opos) return jax.lax.fori_loop(0, k_remaining + 1, loop_body, initial_inner_carry) initial_carry = (k, 0, buffer, jnp.zeros((c, K), dtype=jnp.int32), 0) final_carry = _inner(initial_carry) return final_carry[3]
修改gmm_entropy_legendre_jax_中的求和循环
原函数用Python for循环遍历n,编译时会展开每个n的计算逻辑。改用jax.lax.scan实现动态迭代:
@functools.partial(jax.jit, static_argnames=["order", "K"]) def gmm_entropy_legendre_jax_(logpi, mu, cov, order, K): assert logpi.ndim == 1 assert mu.ndim == 2 d = mu.shape[1] assert mu.shape == (K, d), f"mu.shape={mu.shape}, K={K}, d={d}" assert cov.ndim == 3 assert cov.shape == (K, d, d) N = order # alias a = gmm_max(logpi, mu, cov) + 1e-3 loga = jnp.log(a) # 动态计算每个k对应的分区数,不再作为静态参数传入 def compute_Ep_px(k): n_partitions = int(round(comb(k + K - 1, k))) return expectation_of_powers(logpi, mu, cov, k, K, n_partitions) # 用vmap批量生成Ep_px数组,替代静态列表推导 Ep_px = jax.vmap(compute_Ep_px)(jnp.arange(N + 1)) def _summand_by_n(n): coeffs_sum = jnp.sum( jax.vmap( lambda j: ( +((-1) ** (n + j)) * ((j + 1) * loga - 1) * jnp.exp( logfactorial(n + j) - logfactorial(n - j) - 2 * logfactorial(j + 1) ) ) )(jnp.arange(n + 1)) ) coeffs2_sum = jnp.sum( jax.vmap( lambda k_idx, Ep_px_k: ( (-1) ** (n + k_idx) * jnp.exp( logfactorial(n + k_idx) - logfactorial(n - k_idx) - 2 * logfactorial(k_idx) ) * Ep_px_k / a**k_idx ) )(jnp.arange(n + 1), Ep_px[: n + 1]), ) return (2 * n + 1) * coeffs_sum * coeffs2_sum # 用scan替代Python for循环,避免静态展开 def scan_body(total, n): return total + _summand_by_n(n), None final_total, _ = jax.lax.scan(scan_body, 0.0, jnp.arange(N + 1)) return -final_total
2. 移除超指数增长的静态参数
原代码将partition_sizes(分区数列表)作为静态参数传入JIT函数,导致JAX为每个不同的分区数组合生成独立的编译版本。修改后改为动态计算分区数,仅保留order和K作为必要静态参数。
修改顶层调用函数gmm_entropy_legendre_jax
def gmm_entropy_legendre_jax(logpi, mu, cov, order): assert mu.ndim == 2 K = mu.shape[0] D = mu.shape[1] assert logpi.shape == (K,), f"logpi.shape={logpi.shape}, K={K}" assert cov.shape == (K, D, D), f"cov.shape={cov.shape}, K={K}, D={D}" return gmm_entropy_legendre_jax_(logpi, mu, cov, order=order, K=K)
3. 拆分编译单元,减少单个JIT函数的复杂度
将expectation_of_powers中的嵌套计算拆分为更小的子函数,避免单个函数包含过多嵌套循环和vmap操作,降低JAX编译时的IR复杂度:
def compute_cov_mu_combined(j, inv_covs, mu): cov_sum = jnp.sum(jax.vmap(lambda j_, ic: j_ * ic)(j, inv_covs), axis=0) mu_sum = jnp.sum(jax.vmap(lambda j_, ic, m_: j_ * ic @ m_)(j, inv_covs, mu), axis=0) return cov_sum, mu_sum def compute_log_summand(ith_inv_cov, ith_mu, ith_cov, ith_logpi, cov_sum, mu_sum, logpi, mu, cov): cov_combined = jnp.linalg.inv(ith_inv_cov + cov_sum) mu_combined = cov_combined @ (ith_inv_cov @ ith_mu + mu_sum) log_N_ratio = jax.scipy.stats.multivariate_normal.logpdf(0, mean=ith_mu, cov=ith_cov) - \ jax.scipy.stats.multivariate_normal.logpdf(0, mean=mu_combined, cov=cov_combined) log_product_term = jnp.sum( jax.vmap( lambda j_, logpi_, mu_, cov_: j_ * (logpi_ + jax.scipy.stats.multivariate_normal.logpdf(0, mean=mu_, cov=cov_)) )(j, logpi, mu, cov), axis=0, ) return ith_logpi + log_N_ratio + log_product_term def expectation_of_powers(logpi, mu, cov, k, K, n_partitions): assert logpi.ndim == 1 assert len(mu) == len(cov) == K inv_covs = jax.vmap(jnp.linalg.inv)(cov) partitions = partitions_(k, K) def _inner(j): cov_sum, mu_sum = compute_cov_mu_combined(j, inv_covs, mu) log_inner_summands = jax.vmap(compute_log_summand, in_axes=(0,0,0,0,None,None,None,None,None))( inv_covs, mu, cov, logpi, cov_sum, mu_sum, logpi, mu, cov ) return jnp.exp(log_multinomial_coeff(j) + jax.nn.logsumexp(log_inner_summands)) result = jnp.sum(jax.vmap(_inner)(partitions)) return result
优化效果说明
- 编译时间大幅降低:通过避免静态循环展开、移除超指数增长的静态参数,编译复杂度从O(2^k)级降至O(k)级,即使k和K增大,编译时间也不会爆炸式增长。
- 运行速度略有下降:动态循环和拆分编译单元会带来少量运行时开销,但在大多数深度学习场景下,编译时间的节省远超过运行速度的损失。
- 兼容性保留:所有修改均保留了原算法的数学正确性,且仍支持JIT编译和JAX的自动微分功能。
内容的提问来源于stack exchange,提问作者LudvigH
相关产品推荐
相关产品推荐

