第 2 章 JAX 代码性能优化
课程:「JAX 高级编程:性能、优化与规模化」第 2 章 · 原课程链接:https://apxml.com/zh/courses/advanced-jax/chapter-2-optimizing-jax-code-performance/profiling-jax-code
运行环境:Python 3.14 · JAX / jaxlib 0.12.0.1 · 仅 CPU。GPU/TPU 相关内容只做讲解,所有能运行的结论都在 CPU 上验证过。
学习目标
- 能用
jax.profiler在 CPU 上生成 trace,并用 Perfetto / TensorBoard 查看;会用TraceAnnotation、named_scope给 trace 和 HLO 打标签。 - 读懂 jaxpr(
invars / constvars / eqns / outvars),能程序化遍历 jaxpr、统计原语,认识jit、custom_jvp_call、cond等高阶原语的嵌套。 - 掌握
jit(f).lower(...)→ StableHLO →.compile()→ 优化后 HLO 这条链路,会看cost_analysis()/memory_analysis(),会用XLA_FLAGS=--xla_dump_to转储 HLO。 - 理解行主序/列主序,以及
jnp.transpose在 XLA 里什么时候真的搬数据,什么时候被“吸收”。 - 定位并避免重复编译,会用持久化编译缓存让第二个进程直接命中缓存。
- 能在优化后的 HLO 里数 fusion,并解释融合为什么省内存带宽。
- 理解异步调度,正确计时,知道哪些操作会隐式同步。
- 用“实现 → 计时 → 看 jaxpr/HLO → 重构”的流程优化一个真实计算(两两距离矩阵)。
小节目录
| 小节 | 内容 | 课程页面 |
|---|---|---|
| 2.1 | 在 CPU、GPU 和 TPU 上分析 JAX 代码 | profiling-jax-code |
| 2.2 | 理解 JAX 计算图 (jaxpr) | understanding-jaxpr |
| 2.3 | XLA 编译的作用 | role-of-xla-compilation |
| 2.4 | 内存布局及其对性能的影响 | memory-layout-performance |
| 2.5 | 避免重复编译 | avoiding-recompilation |
| 2.6 | 算子合并与操作优化 | fusion-operator-optimization |
| 2.7 | 异步调度 | asynchronous-dispatch |
| 2.8 | 实践:优化数值计算 | practice-optimizing-computation |
| — | 动手练习 · 测验 · 更新说明 · 小结 · 速查表 |
阅读说明
- 本教程按课程第 2 章的 8 个网页整理,讲解和代码都用中文重新写过,并做了补充。过时或容易出错的写法放在
❌单元里,后面紧跟✅推荐写法。 - 所有结论都以 JAX 0.12.0.1 的运行结果为准。需要特别留意的地方用
> ⚠️ **注意**标出,章末的「本章更新说明」有汇总。
与入门课的对应关系
- 入门 02 JIT编译:
jit基础、重编译触发条件总表、jax_log_compiles、_cache_size()、AOT(lower/compile)入门、static_argnums、donate_argnums。本章不重复这些内容,只在它们的基础上往下讲(持久化缓存、explain_cache_misses、weak type、函数对象身份等)。 - 入门 01 JAX简介:异步调度与
block_until_ready的入门介绍。 - 入门 07 实战专题:实战中的性能排查流程。
⚠️ 本环境已知问题(务必注意):jaxlib 0.12.0.1 的 CPU 后端有一个悄悄算错的 bug:jit 函数通过闭包捕获一个较大的常量矩阵,拿它做 matmul 之后再 reduce,结果可能是错的。所以本教程里权重和数据一律作为函数参数传入,关键数值都用 NumPy float64 对拍。2.3 节会把这个 bug 当作“为什么要检查编译产物”的真实案例来剖析。
0. 环境与通用工具¶
下面这个单元定义全章共用的小工具:
bench:先预热,再多次计时取平均,每次都block_until_ready;clean_hlo:去掉 HLO 文本里的源码位置表和metadata,方便阅读;count_fusions:统计优化后 HLO 里fusion(...)指令的条数;CompileCounter:配合jax.log_compiles()统计真正发生的 XLA 编译次数,用来给重编译相关的结论写assert;run_py:在子进程里跑一段脚本。XLA_FLAGS只在进程启动时读取一次,持久化缓存要验证“第二个进程能否命中”,这两件事都只能在子进程里演示。
import os, re, sys, time, gzip, glob, json, logging, tempfile, textwrap, subprocess, functools
from collections import Counter
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
import jax.extend as je
import jaxlib
print("JAX", jax.__version__, "| jaxlib", jaxlib.__version__, "| backend:", jax.default_backend(), "|", jax.devices())
def bench(f, *args, n=20):
"""先预热(含编译),再测 n 次的平均耗时(秒);每次都 block_until_ready。"""
jax.block_until_ready(f(*args))
t0 = time.perf_counter()
for _ in range(n):
jax.block_until_ready(f(*args))
return (time.perf_counter() - t0) / n
_TABLE_HDR = re.compile(r'^(FileNames|FunctionNames|FileLocations|StackFrames)$')
def clean_hlo(text):
"""去掉 HLO 里的源码位置表、metadata 和空行,只留下计算本身。"""
out = []
for line in text.splitlines():
if _TABLE_HDR.match(line) or re.match(r'^\d+ ', line) or not line.strip():
continue
out.append(re.sub(r', metadata=\{[^}]*\}', '', line))
return "\n".join(out)
def count_fusions(hlo_text):
"""统计优化后 HLO 中 fusion 指令(= 融合后的“内核”)的条数。"""
return len(re.findall(r' fusion\(', hlo_text))
def compiled_hlo(f, *args, **jit_kw):
"""返回 jit(f) 针对 args 编译后的 HLO 文本。"""
return jax.jit(f, **jit_kw).lower(*args).compile().as_text()
class CompileCounter(logging.Handler):
"""with CompileCounter("f") as cc: ... 之后 cc.count 就是 jit(f) 真正编译的次数。"""
def __init__(self, name=None):
super().__init__()
self.name, self.records = name, []
def emit(self, record):
msg = record.getMessage()
if msg.startswith("Compiling jit("):
fn = msg[len("Compiling jit("):].split(")")[0]
if self.name is None or fn == self.name:
self.records.append(msg)
def __enter__(self):
self._logger = logging.getLogger("jax")
self._logger.addHandler(self)
# 暂时调高 jax 自带 StreamHandler 的级别,避免 log_compiles 刷屏
self._saved = [(h, h.level) for h in self._logger.handlers if h is not self]
for h, _ in self._saved:
h.setLevel(logging.ERROR)
self._ctx = jax.log_compiles(True)
self._ctx.__enter__()
return self
def __exit__(self, *exc):
self._ctx.__exit__(*exc)
for h, lvl in self._saved:
h.setLevel(lvl)
self._logger.removeHandler(self)
@property
def count(self):
return len(self.records)
SCRATCH = tempfile.mkdtemp(prefix="adv02_")
def run_py(src, env_extra=None, args=()):
"""把 src 写成脚本,在全新的 Python 子进程里运行,返回 stdout+stderr 文本。"""
path = os.path.join(SCRATCH, f"sub_{abs(hash(src)) % 10**8}.py")
with open(path, "w") as fh:
fh.write(textwrap.dedent(src))
env = dict(os.environ)
env.update(env_extra or {})
p = subprocess.run([sys.executable, path, *args], capture_output=True, text=True, env=env, timeout=300)
if p.returncode != 0:
raise RuntimeError(p.stdout + p.stderr)
return p.stdout + p.stderr
print("临时目录:", SCRATCH)
2.1 在 CPU、GPU 和 TPU 上分析 JAX 代码¶
jax.jit 只是加速的起点,想拿到最佳性能就得做性能分析(profiling)。同一段代码在 CPU 上很快,换到 GPU/TPU 上表现可能完全不同。原因有三个:硬件架构不同,内存带宽的限制不同,XLA 针对不同硬件做的优化也不同。
性能分析要回答的问题主要有五个:
- 哪些操作最耗时?
- 程序是计算受限(compute-bound),还是内存受限(memory-bound)?
- 加速器有没有被充分利用?有没有空闲的时间段?
- 有没有意外的宿主(host)↔ 设备(device)数据传输,拖慢了整体?
- 有没有过多的重新编译?
用 jax.profiler 收集 trace¶
最核心的 API 是 jax.profiler.start_trace(log_dir) 和 jax.profiler.stop_trace()。课程示例的步骤如下:
- 定义一个
@jax.jit函数complex_computation(x, y):先jnp.dot,再jnp.sin,最后返回jnp.mean(z * x + y); - 生成两个 2000×2000 的随机矩阵,用
jax.device_put放到设备上; - 先调用一次并
block_until_ready()做预热,把编译排除在 trace 之外; start_trace(log_dir)指定 trace 的保存目录;- 循环执行多次(课程用 5 次),让 trace 有代表性;
- 每次都
block_until_ready()。JAX 是异步调度的(见 2.7),不阻塞的话,Python 可能在计算完成之前就停止了 trace; - 用
stop_trace()结束并写盘。
旧写法用 jax.random.PRNGKey(0) 生成数据,而且让 x、y 共用同一个 key。下面先看这种旧写法,再给出推荐写法(两种 key 的区别入门见 入门 01 JAX简介「随机数」,深入见 进阶 10 随机数与分片)。
# ❌ 旧写法(过时):jax.random.PRNGKey 返回“原始 uint32 数组”形式的 key,0.12 推荐类型化 key
old_key = jax.random.PRNGKey(0)
new_key = jax.random.key(0)
print("PRNGKey(0):", old_key, "| dtype =", old_key.dtype, "| shape =", old_key.shape)
print("key(0) :", new_key.dtype, "| shape =", new_key.shape)
try:
# 类型化 key 不能被当成普通整数数组做算术,这能防止把 key 误当数据用
_ = new_key + 1
print("类型化 key 居然可以做加法?")
except Exception as e:
print("类型化 key 做算术会报错:", type(e).__name__)
assert old_key.dtype == jnp.uint32 and old_key.shape == (2,)
assert jnp.issubdtype(new_key.dtype, jax.dtypes.prng_key) and new_key.shape == ()
# ✅ 推荐写法:jax.random.key + split,x、y 用不同的子 key
@jax.jit
def complex_computation(x, y):
z = jnp.dot(x, y)
z = jnp.sin(z)
return jnp.mean(z * x + y)
kx, ky = jax.random.split(jax.random.key(0))
x = jax.device_put(jax.random.normal(kx, (2000, 2000)))
y = jax.device_put(jax.random.normal(ky, (2000, 2000)))
# 预热:触发编译,并等它真正完成
complex_computation(x, y).block_until_ready()
log_dir = tempfile.mkdtemp(prefix="jax_profiling_") # 原示例用 /tmp/jax_profiling
jax.profiler.start_trace(log_dir)
for _ in range(5):
result = complex_computation(x, y)
result.block_until_ready() # 重要:阻塞,确保计算在停止分析前完成
jax.profiler.stop_trace()
print("trace 已保存到:", log_dir)
files = sorted(p for p in glob.glob(log_dir + "/**", recursive=True) if os.path.isfile(p))
for p in files:
print(f" {os.path.relpath(p, log_dir):70s} {os.path.getsize(p):>8d} 字节")
assert any(p.endswith(".xplane.pb") for p in files)
assert any(p.endswith(".trace.json.gz") for p in files)
⚠️ 注意:trace 文件并不是一个
profile.pb。start_trace/trace会在log_dir/plugins/profile/<时间戳>/下生成两个文件:<主机名>.xplane.pb(XSpace protobuf,TensorBoard/XProf 读取的格式)和<主机名>.trace.json.gz(Chrome trace 格式)。如果传了create_perfetto_trace=True,还会多生成一个perfetto_trace.json.gz。上面的assert已经验证了前两个文件。
怎样查看 trace¶
| 工具 | 做法 | 说明 |
|---|---|---|
| Perfetto(推荐,零安装) | 打开 https://ui.perfetto.dev → Open trace file → 选 perfetto_trace.json.gz(或 *.trace.json.gz) | 纯浏览器本地解析,文件不会上传 |
create_perfetto_link=True | jax.profiler.trace(dir, create_perfetto_link=True) | 结束时打印一个链接,并阻塞等待浏览器打开,适合脚本,不适合在 notebook 里批量执行 |
| TensorBoard / XProf | pip install tensorboard xprof(旧名 tensorboard-plugin-profile),然后 tensorboard --logdir <log_dir>,打开 Profile 页签 | 只装 tensorboard 看不到 Profile 页签。本环境没有装 |
课程介绍了 TensorBoard Profile 里的三个视图:
- Trace Viewer(轨迹视图):信息量最大。
- 宿主线程:Python 调用、JAX 的调度开销、可能出现的 NumPy 操作。
- 设备流:例如
/GPU:0/stream:all、/TPU:0/stream:all。其中HtoD是宿主到设备的复制,DtoH是设备到宿主的复制。 - 看什么:设备流上的长内核(计算瓶颈)、大段空隙(设备空闲)、耗时很长的复制(数据传输瓶颈)。课程的示意时间轴里,GPU 计算流在 2.5–4 ms 之间有一段空隙,说明设备在空闲或者在等数据。
- Ops View(操作视图):按操作类型(如
dot_general、sin、reduce_mean)汇总总耗时、平均耗时和调用次数。 - Memory Viewer(内存视图):看内存分配随时间的变化。峰值过高或者频繁分配/释放都可能是问题。这个视图的可用性和详细程度因后端而异。
下面换一种写法:用上下文管理器 jax.profiler.trace,配合 TraceAnnotation(给宿主端的时间段命名)和 jax.named_scope(给计算图里的操作加名字前缀),然后在 notebook 里直接解析 trace 文件,确认这些标签真的写进去了。
@jax.jit
def annotated_step(x, y):
with jax.named_scope("my_matmul"): # 作用于 jaxpr/HLO 中的操作名
z = x @ y
with jax.named_scope("my_epilogue"):
out = jnp.mean(jnp.sin(z))
return out
a = jax.random.normal(kx, (512, 512)); b = jax.random.normal(ky, (512, 512))
annotated_step(a, b).block_until_ready() # 预热
log_dir2 = tempfile.mkdtemp(prefix="jax_trace_")
with jax.profiler.trace(log_dir2, create_perfetto_trace=True):
for i in range(3):
with jax.profiler.TraceAnnotation(f"step_{i}"): # 作用于宿主端时间线
annotated_step(a, b).block_until_ready()
files2 = sorted(os.path.relpath(p, log_dir2) for p in glob.glob(log_dir2 + "/**", recursive=True) if os.path.isfile(p))
print("\n".join(files2))
assert any(f.endswith("perfetto_trace.json.gz") for f in files2)
# trace.json.gz 是标准 Chrome trace 格式:{"traceEvents": [...]}
tj = glob.glob(log_dir2 + "/**/*.trace.json.gz", recursive=True)[0]
events = json.load(gzip.open(tj))["traceEvents"]
names = Counter(e["name"] for e in events if e.get("ph") == "X")
print("\n事件数最多的前 12 种:")
for k, v in names.most_common(12):
print(f" {v:4d} {k}")
assert all(names[f"step_{i}"] == 1 for i in range(3)) # TraceAnnotation 写进了 trace
assert names["PjitFunction(annotated_step)"] >= 3 # 每次调用 jit 函数的宿主端事件
# xplane.pb 也可以直接用 jax.profiler.ProfileData 解析(不需要 TensorBoard)
xp = glob.glob(log_dir2 + "/**/*.xplane.pb", recursive=True)[0]
pdata = jax.profiler.ProfileData.from_file(xp)
for plane in pdata.planes:
lines = list(plane.lines)
print(f"plane {plane.name!r}: {len(lines)} 条 line")
for ln in lines:
if "XLAExecute" in ln.name or ln.name == "":
evs = [e.name for e in ln.events]
kern = sorted({n for n in evs if "fusion" in n or n.startswith("wrapped")})
print(f" line {ln.name!r:28s} 事件 {len(evs):3d} 个;其中内核名: {kern}")
# named_scope 不改变计算,但会出现在 HLO 的 op_name 元数据里(调试/对照 trace 时很有用)
lowered = annotated_step.lower(a, b)
hlo_ops = re.findall(r'op_name="([^"]*my_[^"]*)"', lowered.compile().as_text())
print("\n带 named_scope 前缀的 HLO op_name:", sorted(set(hlo_ops)))
assert any("my_matmul" in s for s in hlo_ops) and any("my_epilogue" in s for s in hlo_ops)
assert "my_matmul" not in lowered.as_text() # 默认 StableHLO 文本不带位置信息
assert "my_matmul" in lowered.as_text(debug_info=True) # debug_info=True 才会显示
不同设备上的注意事项¶
- CPU:
jax.profiler能抓到 jit 编译后在 CPU 上跑的部分。纯 Python 部分(数据加载、jit 之外的预处理循环、脚本逻辑)仍然适合用cProfile分析。jit 函数和大量 Python 逻辑交替执行时,会引入额外开销。 - GPU:Trace Viewer 会区分计算流和复制流(PCIe 传输)。重点看
HtoD/DtoH花了多长时间,问自己两个问题:有没有搬运不必要的数据?数据能不能在 GPU 上留得更久?对 JAX 层面的优化来说,TensorBoard 一般就够了;要分析单个内核的细节,可以用 NVIDIA Nsight Systems / Nsight Compute。 - TPU:重点看 MXU(矩阵乘单元)的利用率百分比。为了让维度满足 MXU 的要求(如能被 128 整除),XLA 可能会做填充(padding),这部分有时会显示成开销。利用率低的原因可能是输入管线、宿主 CPU,也可能是这个计算本身不适合 TPU。在 TPU VM 上可以用 Google Cloud Profiler 看系统层面的信息。
常见瓶颈¶
- 宿主–设备数据传输:复制流里出现很长的
HtoD/DtoH条。对策是一次性把数据搬到设备上并尽量留在那里,同时做批处理。 - 小内核 / 启动延迟:设备流上有大量极短的条。XLA 会尽量融合,但计算仍可能被切得太碎。
- 设备空闲:计算流时间轴上有明显的空隙。可能是宿主供数据不够快、传输阻塞,或者存在同步点。
- 计算受限的操作:少数很长的条占满了计算流。这时可能需要换算法、换更高效的原语,或者考虑混合精度。
设备内存 profile¶
jax.profiler.save_device_memory_profile(path) 会把当前存活的设备缓冲区按分配位置(Python 调用栈)汇总,写成 pprof 格式(gzip 压缩的 protobuf)。先确认它在 CPU 上能不能用:
mem_path = os.path.join(SCRATCH, "memory.prof")
blob_before = jax.profiler.device_memory_profile() # 返回 bytes,不落盘
big = jnp.ones((4000, 4000)); big.block_until_ready() # 新增一个约 64 MB 的存活缓冲区
jax.profiler.save_device_memory_profile(mem_path) # 落盘版本
blob_after = open(mem_path, "rb").read()
print("CPU 上可用;文件大小:", os.path.getsize(mem_path), "字节;gzip 魔数:", blob_after[:2])
print("解压后 protobuf 大小:之前", len(gzip.decompress(blob_before)), "→ 之后", len(gzip.decompress(blob_after)))
assert blob_after[:2] == b"\x1f\x8b" # gzip 格式的 pprof
del big
print("\n查看方法: pprof -http=:8080", mem_path, " (或 go tool pprof ...)")
查看方法:用 Go 版 pprof(go install github.com/google/pprof@latest)执行 pprof -http=:8080 memory.prof,在 Flame Graph / Top 里看哪一行 Python 代码持有最多设备内存。它是某一时刻的快照,不是时间序列,适合排查“显存被谁占着”。
🔍 补充¶
⚪ 选读:要在 trace 里对照内核名、给训练步打标签时再看。
- CPU 上的 trace 只有
/host:CPU一个 plane。XLA 的执行线程叫tf_XLAExecute/…,内核名就是优化后 HLO 里 fusion 指令的名字(上面看到的ynn_fusion、wrapped_multiply等),所以 trace 里的名字可以直接回到 HLO 里查(见 2.3)。 TraceAnnotation标注的是宿主端时间段。named_scope标注的是计算图里的操作名,会进入 HLO 的op_name,在 GPU/TPU 的 trace 里也能看到。两者用途不同,不能互相替代。- 训练循环可以用
jax.profiler.StepTraceAnnotation("train", step_num=i),TensorBoard 会按 step 切分统计;@jax.profiler.annotate_function是TraceAnnotation的装饰器版本。 - trace 里必须先预热,否则第一步的追踪和编译会占掉大半时间线;trace 要尽量短,几步就够,否则文件很大、浏览器打不开。
2.2 理解 JAX 计算图 (jaxpr)¶
jit 会把函数编译到加速器上。中间有一步,是先把 Python 代码变成 jaxpr(JAX 程序表示):一种计算图的中间表示,随后再交给 XLA。理解 jaxpr,能帮你看清 JAX 到底追踪到了什么,也能帮你找到低效或重编译的根源。
jaxpr 是怎么来的:jit 函数第一次被某种输入类型/形状调用时,JAX 会做一次追踪(tracing)。它用追踪器(tracer)对象替换真实输入,然后记录这些 tracer 上执行的每一个**原语(primitive)**操作,得到的就是 jaxpr。这一步并不真正执行计算。
jaxpr 的四个特点:
- 函数式:它是纯函数,没有副作用。
- 静态类型:每个变量都有明确的形状和 dtype。
- 显式操作:jaxpr 由一串等式(equation)组成,每个等式把一个原语(如
add、sin、dot_general、reduce_sum)作用到变量或常量上。Python 的if/for在追踪时就被展开了,除非你用lax.cond、lax.scan这类 JAX 控制流原语。 - 中间表示:它介于 Python 源码和底层 XLA HLO 之间。
生成和检查 jaxpr¶
主要工具是 jax.make_jaxpr(f)(*示例输入)。课程示例如下:
def example_function(x, y):
a = jnp.sin(x)
b = jnp.cos(y)
return a + b * 2.0
kx_, ky_ = jax.random.split(jax.random.key(0)) # 用推荐的 jax.random.key(旧写法是 PRNGKey)
x_example = jax.random.normal(kx_, (10,))
y_example = jax.random.normal(ky_, (10,))
closed = jax.make_jaxpr(example_function)(x_example, y_example)
print(closed.jaxpr)
逐项解读:
{ lambda ; ... }:定义一个 lambda。分号左边是constvars(闭包常量),右边是invars(输入变量);a:f32[10] b:f32[10]:输入变量及其 dtype 和形状;let ... in ...:let后面每一行是一个等式(eqn),格式为输出变量:类型 = 原语[参数] 输入...;in (f,):输出变量outvars。
0.12 打印字面量时会带上类型,例如 mul d 2.0:f32[]。有些资料里写的是 mul d 2.0,两者只是显示上的差别。
make_jaxpr 返回的对象同时带着 jaxpr 本体和常量的真实值 consts。旧版本里它是 ClosedJaxpr,包着一个 Jaxpr,要用 .jaxpr 取出本体;JAX 0.12 已把两者合并成同一个 Jaxpr 类。jax.extend.core.ClosedJaxpr 只是 Jaxpr 的别名,.jaxpr 作为兼容属性保留(返回对象自身),.consts、.eqns 都可以直接访问。所以旧代码里的 closed.jaxpr.eqns 照样能用(下面的输出里,两者的类型名都是 Jaxpr)。下面用代码把这四个字段拆开看:
j = closed.jaxpr
print(type(closed).__name__, "→ .jaxpr 是", type(j).__name__)
print("invars :", [(str(v), v.aval.str_short()) for v in j.invars])
print("constvars:", j.constvars, "| consts:", closed.consts)
print("outvars :", [(str(v), v.aval.str_short()) for v in j.outvars])
for eqn in j.eqns:
print(f" eqn: {eqn.primitive.name:4s} in={[str(v) for v in eqn.invars]} out={[str(v) for v in eqn.outvars]} params={eqn.params}")
# 0.12:ClosedJaxpr 已并入 Jaxpr,.jaxpr 是返回自身的兼容属性
assert type(closed) is je.core.ClosedJaxpr is type(j) and closed.jaxpr is closed
assert [e.primitive.name for e in j.eqns] == ["sin", "cos", "mul", "add"]
assert len(j.invars) == 2 and len(j.outvars) == 1 and j.constvars == []
# 字面量 2.0 不是变量,而是 Literal
assert type(j.eqns[2].invars[1]).__name__ == "Literal"
constvars 从哪来:被函数闭包捕获的 JAX 数组,不是参数,而是常量,它们会出现在分号左边:
W_small = jnp.arange(9.0).reshape(3, 3) # 小矩阵,只为演示 jaxpr 结构
def uses_closure(v):
return jnp.dot(W_small, v) + jnp.pi
cj = jax.make_jaxpr(uses_closure)(jnp.ones(3))
print(cj)
print("constvars:", [v.aval.str_short() for v in cj.jaxpr.constvars], "| consts 形状:", [c.shape for c in cj.consts])
assert len(cj.jaxpr.constvars) == 1 and cj.consts[0].shape == (3, 3)
# Python 标量 jnp.pi 则直接成为字面量
assert type(cj.jaxpr.eqns[1].invars[1]).__name__ == "Literal"
遍历 jaxpr:统计原语,看清高阶原语的嵌套¶
eqns 是一个普通的 Python 列表,可以直接遍历。高阶原语(jit、cond、while、scan、custom_jvp_call、custom_vjp_call……)的 params 里还藏着子 jaxpr,需要递归进去。jax.extend.core.jaxprs_in_params(eqn.params) 会把这些子 jaxpr 找出来。
def count_primitives(jaxpr, counter=None, depth=0, show=False):
"""递归统计 jaxpr(含所有子 jaxpr)中的原语出现次数。"""
counter = Counter() if counter is None else counter
for eqn in jaxpr.eqns:
counter[eqn.primitive.name] += 1
subs = list(je.core.jaxprs_in_params(eqn.params))
if show and subs:
print(" " * depth + f"└ {eqn.primitive.name}(含 {len(subs)} 个子 jaxpr)")
for sub in subs:
count_primitives(sub, counter, depth + 1, show)
return counter
@jax.jit
def inner(x):
return jax.nn.relu(x) * 2.0
def outer(x):
return inner(x).sum() + jnp.logaddexp(x, 1.0).sum()
co = jax.make_jaxpr(outer)(jnp.ones(3))
print("顶层原语:", [e.primitive.name for e in co.jaxpr.eqns])
print("嵌套结构:")
cnt = count_primitives(co.jaxpr, show=True)
print("递归统计:", dict(cnt))
top = co.jaxpr.eqns[0]
assert top.primitive.name == "jit" and top.params["name"] == "inner"
# 0.12 中嵌套 jit 的原语名是 "jit"(旧版本叫 "pjit")
assert "pjit" not in cnt
# relu 本身是 custom_jvp 函数,其内部又包了一层 jit;logaddexp 也是 custom_jvp
assert cnt["custom_jvp_call"] == 2 and cnt["jit"] == 2 and cnt["max"] >= 1
jaxpr 为什么对优化很重要¶
- 追踪可见性:可以发现 JAX 是否追踪了比预期更多的计算。NumPy 和
jax.numpy之间的细微差别,可能产生不同的原语。 - 理解变换:对函数施加
jax.grad或jax.vmap之后再看 jaxpr,就能看到计算图被改成了什么样。 - 诊断重新编译:比较不同调用生成的 jaxpr,如果
eqns列表差别明显,说明计算图依赖了某种“动态”的东西。
⚠️ 注意:常能听到这样的说法:“jaxpr 结构在调用之间变化、导致
jax.jit重新编译,通常是因为操作序列依赖了参数的值(基于张量值的 Python 控制流)”。实际上,在jit里对被追踪参数的值做 Pythonif判断,根本没法追踪,会直接抛出TracerBoolConversionError,走不到“重新编译”这一步。触发重新编译的是缓存键的变化:输入的形状、dtype、weak type、static_argnums参数的值、函数对象本身等(见 2.5)。只有参数被标成静态时,才会出现“值不同 → jaxpr 不同 → 重新编译”。下面两个单元验证这一点。
- 连接 XLA:XLA 的优化(如融合)会受到输入 jaxpr 结构的影响。结构清晰、可预测的 jaxpr 更容易被优化。
# ❌ 常见误解:“依赖值的 Python 控制流会让 jaxpr 改变,从而触发重新编译”
@jax.jit
def value_branch(x, flag):
if flag > 0.5: # 对 tracer 求 bool
return jnp.sin(x)
return jnp.cos(x)
try:
value_branch(jnp.ones(3), 0.7)
caught = None
except Exception as e:
caught = type(e).__name__
print("实测:", caught, "|", str(e).splitlines()[0])
assert caught == "TracerBoolConversionError"
# ✅ 推荐写法:确实要让“值”决定图结构时,标为静态参数;此时不同的值 → 不同 jaxpr → 各自编译一次
def value_branch_static(x, flag):
return jnp.sin(x) if flag > 0.5 else jnp.cos(x)
jp_hi = jax.make_jaxpr(value_branch_static, static_argnums=1)(jnp.ones(3), 0.7)
jp_lo = jax.make_jaxpr(value_branch_static, static_argnums=1)(jnp.ones(3), 0.3)
print(jp_hi, jp_lo, sep="\n")
assert [e.primitive.name for e in jp_hi.jaxpr.eqns] == ["sin"]
assert [e.primitive.name for e in jp_lo.jaxpr.eqns] == ["cos"]
jit_static = jax.jit(value_branch_static, static_argnums=1)
with CompileCounter("value_branch_static") as cc:
for flag in [0.7, 0.7, 0.3, 0.3]:
jit_static(jnp.ones(3), flag)
print("4 次调用实际编译次数:", cc.count)
assert cc.count == 2
带控制流的例子:lax.cond¶
课程示例 conditional_function(use_sin, x) 里,pred = use_sin > 0.5,真分支是 jnp.sin,假分支是 jnp.cos,返回 lax.cond(pred, true_fun, false_fun, x)。lax.cond 的谓词必须是标量。分别用 0.7 和 0.3 追踪,可以看到:
- 两次得到的 jaxpr 结构完全相同。jaxpr 里不是只有被选中的那个分支,而是一个
cond原语,两个分支都封装在里面; - 只要两个分支的输入/输出类型和形状一致,谓词取什么值都不影响 jaxpr 的结构。这对 jit 很重要,因为同一份编译产物必须能处理任意一个分支。
def conditional_function(use_sin, x):
pred = use_sin > 0.5 # 标量布尔
def true_fun(v):
return jnp.sin(v)
def false_fun(v):
return jnp.cos(v)
return lax.cond(pred, true_fun, false_fun, x)
xs3 = jnp.arange(3.0)
jc_07 = jax.make_jaxpr(conditional_function)(0.7, xs3)
jc_03 = jax.make_jaxpr(conditional_function)(0.3, xs3)
print(jc_07)
assert str(jc_07) == str(jc_03) # 结构与谓词的值无关
cond_eqn = [e for e in jc_07.jaxpr.eqns if e.primitive.name == "cond"][0]
branch_prims = [[e.primitive.name for e in br.jaxpr.eqns] for br in cond_eqn.params["branches"]]
print("branches 顺序:", branch_prims)
prev = jc_07.jaxpr.eqns[1]
print("谓词先被转换:", prev.primitive.name, prev.params["new_dtype"])
assert branch_prims == [["cos"], ["sin"]] # 第 0 个分支是 false_fun
assert prev.primitive.name == "convert_element_type" and prev.params["new_dtype"] == jnp.int32
⚠️ 注意:
cond在 jaxpr 里并不是cond c true_branch false_branch b这种“先真分支、后假分支,直接用布尔谓词”的形式。布尔谓词会先经过convert_element_type转成 int32 索引,cond的参数是branches=(false_fun, true_fun)。也就是说,索引 0 对应假分支,索引 1 对应真分支,和lax.switch的规则一致。上面的assert已经验证过。
局限性¶
jaxpr 看不到 XLA 的最终优化(例如算子融合),也看不到目标加速器上的底层代码。它是理解和调试 JAX 内部机制的重要工具,但只是编译流程中的一个中间步骤。下一节讲怎样继续往下看。
🔍 补充¶
⚪ 选读:想看变换后的 jaxpr、或自己写 jaxpr 解释器时再看。
- 看变换做了什么:
jax.make_jaxpr(jax.grad(f))(x)能直接看到前向和反向的计算。下面这个例子里,前向的sin被保留下来,供反向复用(cos来自sin的导数)。 - jaxpr 可以当函数执行:
jax.extend.core.jaxpr_as_fun(closed_jaxpr)(*args)会逐条解释执行 jaxpr,适合写自定义解释器或做验证。 - 常见误区:jaxpr 里出现
f32[1000,1500,64]这种大中间量,不代表运行时一定会分配这么大的内存。XLA 融合之后可能根本不会把它物化出来,2.8 节会实测这一点。判断内存占用要看编译后的memory_analysis()。
def f_grad_demo(x):
return jnp.sum(jnp.sin(x) ** 2)
gj = jax.make_jaxpr(jax.grad(f_grad_demo))(jnp.ones(4))
print(gj)
prims = count_primitives(gj.jaxpr)
assert prims["sin"] == 1 and prims["cos"] == 1 # 反向用到 d sin = cos
# jaxpr_as_fun:把 ClosedJaxpr 当普通函数调用,结果应与原函数一致
fn_from_jaxpr = je.core.jaxpr_as_fun(gj)
np.testing.assert_allclose(fn_from_jaxpr(jnp.arange(4.0))[0], jax.grad(f_grad_demo)(jnp.arange(4.0)), rtol=1e-6)
print("jaxpr_as_fun 结果与 jax.grad 一致")
2.3 XLA 编译的作用¶
jax.jit 是入口,真正针对硬件做优化、生成代码的是 XLA(Accelerated Linear Algebra,加速线性代数)。XLA 是 Google 开发的领域专用编译器,专门优化以线性代数为主的数值计算,支持 CPU、GPU、TPU。可以把 jax.jit 理解成“把计算图交给 XLA”的机制,XLA 负责优化并生成可执行代码。
XLA 工作在一种高级中间表示 HLO(High Level Operations) 上。它先在 HLO 图上跑一系列与硬件无关和与硬件相关的优化遍(pass),再编译成目标设备的机器码。
⚠️ 注意:常见的说法是“JAX 把追踪得到的 jaxpr 转换为 HLO”,也有人把 HLO 解释成“高级优化器 IR”。准确地说,jaxpr 先被 lower 成 StableHLO(一种 MLIR 方言,
jit(f).lower(...).as_text()打印出来的就是stablehlo.*操作)。XLA 接手后再把它转换成 HLO,在 HLO 上做优化(.compile().as_text()打印的是以HloModule开头的优化后 HLO)。现行 XLA 文档里,HLO 的全称是 High Level Operations。下面的单元用assert验证了这两层文本的差别。
完整流程是:
$$ \text{Python 函数} \xrightarrow{\text{trace}} \text{jaxpr} \xrightarrow{\text{lower}} \text{StableHLO} \xrightarrow{\text{XLA 优化 pass}} \text{优化后 HLO} \xrightarrow{\text{后端}} \text{机器码} $$
对应的 AOT API 是 jax.jit(f).trace(...) → .lower(...) → .compile()。入门 02 JIT编译 已经介绍过这些阶段的拆分方法,这里重点放在读产物上。
def simple_computation(x, a, b):
y = jnp.sin(x)
z = a * y
w = z + b
return w
xs = jnp.ones((1000, 1000))
lowered = jax.jit(simple_computation).lower(xs, 2.0, 1.0)
stablehlo_txt = lowered.as_text()
print("==== StableHLO(lower 之后、XLA 优化之前)====")
print(stablehlo_txt)
compiled = lowered.compile()
opt_hlo = compiled.as_text()
print("==== 优化后的 HLO(compile 之后)====")
print(clean_hlo(opt_hlo))
assert "stablehlo.sine" in stablehlo_txt and "HloModule" not in stablehlo_txt
assert opt_hlo.startswith("HloModule")
# 三个逐元素操作(sin / mul / add)被融合成一条 fusion 指令
assert count_fusions(opt_hlo) == 1
print("优化后 fusion 条数:", count_fusions(opt_hlo))
这正是课程说的算子融合:未融合时需要 3 个内核,中间结果 y、z 都要写回内存。融合后只有一个内核,一次完成 sin、乘法、加法,中间值留在寄存器里,只写回最终的 w。
cost_analysis() 与 memory_analysis()¶
编译产物可以报告 XLA 估算的计算量和内存占用:
ca = compiled.cost_analysis()
ma = compiled.memory_analysis()
print("cost_analysis 类型:", type(ca).__name__)
for k in ["flops", "transcendentals", "bytes accessed"]:
print(f" {k:16s} = {ca[k]:,.0f}")
print("memory_analysis:")
for k in ["argument_size_in_bytes", "output_size_in_bytes", "temp_size_in_bytes", "alias_size_in_bytes"]:
print(f" {k:24s} = {getattr(ma, k):,}")
n_el = 1000 * 1000
assert ca["flops"] == 2 * n_el # 一次乘法 + 一次加法
assert ca["transcendentals"] == n_el # 一次 sin
# 融合后只读 x、写 w(外加两个标量):两遍 4MB
assert ca["bytes accessed"] == 2 * 4 * n_el + 8
assert ma.output_size_in_bytes == 4 * n_el and ma.temp_size_in_bytes == 0
cost_analysis()在 0.12 返回一个 dict(旧版本返回的是[dict]列表),key 包括flops、transcendentals、bytes accessed,以及按操作数细分的bytes accessed0{}等。它是 XLA 的静态估计,不是实测值。memory_analysis()返回CompiledMemoryStats,其中temp_size_in_bytes是中间缓冲区的大小。它是判断“大中间量有没有真正被物化”最直接的证据(2.6、2.8 会用到)。
XLA 的五类优化¶
- 算子融合:课程认为这是影响最大的优化,见上例和 2.6。
- 常量折叠:只依赖编译期常量的部分会在编译期直接算出来。
- 代数简化:例如把
x * 1.0化简成x。 - 布局优化:XLA 会根据计算和硬件选择数组在内存中的排列方式(见 2.4)。
- 目标相关的代码生成:做完与硬件无关的优化后,针对 CPU/GPU/TPU 生成底层代码。CPU 和 GPU 走 LLVM,TPU 用专用编译器。
先验证常量折叠和代数简化:
# 常量折叠:jnp.sin(2.0)、arange(4).sum() 只依赖常量 → 优化后 HLO 里已经算好
fold = lambda v: v * jnp.sin(2.0) + jnp.arange(4.0).sum()
v4 = jnp.ones(4)
low_txt = jax.jit(fold).lower(v4).as_text()
opt_txt = compiled_hlo(fold, v4)
print("StableHLO 里有 sine / reduce:", "stablehlo.sine" in low_txt, "stablehlo.reduce" in low_txt)
print("优化后 HLO 里有 sine / reduce:", " sine(" in opt_txt, " reduce(" in opt_txt)
assert "stablehlo.sine" in low_txt and " sine(" not in opt_txt and " reduce(" not in opt_txt
# 代数简化 ①:x * 1.0 + 0.0 → 直接复制 x
simp = clean_hlo(compiled_hlo(lambda v: v * 1.0 + 0.0, xs))
print("\nx*1.0+0.0 优化后:\n", simp.split("ENTRY")[-1])
assert " multiply(" not in simp and " add(" not in simp
# ❌ 常见误解:以为 XLA 会把 (x + y) - x 化简为 y
alg = clean_hlo(compiled_hlo(lambda p, q: (p + q) - p, xs, xs))
print("\n".join(l for l in alg.splitlines() if " add(" in l or " subtract(" in l))
try:
assert " subtract(" not in alg, "HLO 里仍然有 subtract"
print("被化简了")
except AssertionError as e:
print("实测:没有化简 ——", e)
# ✅ 正确理解:浮点下 (x+y)-x ≠ y(会丢失精度),XLA 默认不做这种不保值的改写
p_, q_ = jnp.float32(1e8), jnp.float32(1.0)
exact_y = q_
via_xla = jax.jit(lambda p, q: (p + q) - p)(p_, q_)
print("(1e8 + 1) - 1e8 =", via_xla, "(数学上应为 1)")
assert " subtract(" in alg and float(via_xla) != float(exact_y)
⚠️ 注意:
(x + y) - x → y常被当成代数简化的例子,顶多附一句“受浮点数影响,并非总是严格成立”。实际上 XLA 对这个表达式不做化简(JAX 0.12,CPU),优化后 HLO 里仍然有add和subtract。原因是在 IEEE 浮点下,这个改写会改变结果。例如(1e8+1)-1e8在 float32 下等于 0,而y是 1。XLA 的代数简化默认不做这类会改变 IEEE 结果的改写。上面看到被化简的x*1.0 → x、x+0.0 → x,都属于不改变数值的情形。
用 XLA_FLAGS=--xla_dump_to 转储每个阶段的 HLO¶
XLA_FLAGS 只在进程启动、XLA 初始化时读取一次,所以要在子进程里演示。转储目录里,每个被编译的模块都会生成一组文件,其中最常看的是:
*.before_optimizations.txt:XLA 接手时的 HLO,还没有融合;*.cpu_after_optimizations.txt:优化后的 HLO,与.compile().as_text()相同;*-buffer-assignment.txt/*-memory-usage-report.txt:缓冲区分配和内存报告。
dump_dir = os.path.join(SCRATCH, "xla_dump")
out = run_py("""
import jax, jax.numpy as jnp
def simple_computation(x, y):
return jnp.exp(jnp.log(x) + y)
x = jnp.ones((1000, 1000))
print(float(jax.jit(simple_computation)(x, x).sum()))
""", env_extra={"XLA_FLAGS": f"--xla_dump_to={dump_dir} --xla_dump_hlo_as_text"})
dumped = sorted(os.listdir(dump_dir))
mine = [f for f in dumped if "jit_simple_computation" in f]
print(f"共转储 {len(dumped)} 个文件,其中 jit_simple_computation 相关:")
print("\n".join(" " + f for f in mine))
before = open(os.path.join(dump_dir, [f for f in mine if f.endswith("before_optimizations.txt")][0])).read()
after = open(os.path.join(dump_dir, [f for f in mine if f.endswith("cpu_after_optimizations.txt")][0])).read()
print("\n---- before_optimizations(ENTRY 部分)----\n", clean_hlo(before).split("ENTRY")[-1])
print("---- after_optimizations(ENTRY 部分)----\n", clean_hlo(after).split("ENTRY")[-1])
assert count_fusions(before) == 0 and count_fusions(after) == 1
案例:为什么要检查编译产物——一个会悄悄算错的 CPU 后端 bug¶
课程说“检查 jaxpr 只能看到发给 XLA 的东西,看不到 XLA 优化的结果”。下面这个真实案例说明,有时候必须看优化后的 HLO 才能定位问题。
jaxlib 0.12.0.1 的 CPU 后端会把 matmul 加 reduce 这类模式交给 YNNPACK 库执行,在 HLO 里表现为 kind=kCustom、"kind":"__ynn_fusion" 的 fusion。测试发现:闭包捕获的常量矩阵作为 matmul 的左操作数、后面又接 sum 时,结果会悄悄出错。不报错,数字却是错的。
在本机另外用方阵 $n\times n$ 测过:常量作右操作数(x @ W),或者相乘之后不做 reduce,结果都正确。触发的尺寸阈值随形状变化,这组随机数下 $n=64$ 起就会出错。版本二分、float64 是否受影响、更窄的规避开关等汇总在 入门 00 目录与导读 的「本环境已知问题」,本节只做根因分析。
rng = np.random.default_rng(0)
n = 128
W_np = rng.standard_normal((n, n)).astype(np.float32)
X_np = rng.standard_normal((n, n)).astype(np.float32)
ref = float(np.sum(W_np.astype(np.float64) @ X_np.astype(np.float64))) # float64 参考值
W_const = jnp.asarray(W_np)
closure_fn = jax.jit(lambda x: jnp.sum(W_const @ x)) # W 作为闭包常量
param_fn = jax.jit(lambda x, w: jnp.sum(w @ x)) # W 作为参数传入
r_closure = float(closure_fn(jnp.asarray(X_np)))
r_param = float(param_fn(jnp.asarray(X_np), W_const))
print(f"float64 参考 = {ref:.4f}")
print(f"传参版本 = {r_param:.4f} 误差 {abs(r_param - ref):.2e}")
print(f"闭包版本 = {r_closure:.4f} 误差 {abs(r_closure - ref):.2e}")
print("闭包版本是否触发了 bug:", abs(r_closure - ref) > 1.0)
assert abs(r_param - ref) < 1e-2 # 传参版本永远可信
h_closure = clean_hlo(closure_fn.lower(jnp.asarray(X_np)).compile().as_text())
h_param = clean_hlo(param_fn.lower(jnp.asarray(X_np), W_const).compile().as_text())
def fused_body(h):
body = h.split("%fused_computation")[1]
return body[:body.index("\n}")]
print("==== 闭包版本的 fused_computation ====", fused_body(h_closure), sep="")
print("==== 传参版本的 fused_computation ====", fused_body(h_param), sep="")
print("ENTRY 指令:\n ", [l.strip() for l in h_closure.splitlines() if "ROOT %ynn" in l][0][:120])
assert '"kind":"__ynn_fusion"' in h_closure and '"kind":"__ynn_fusion"' in h_param
# 关键差异:闭包版本把 128×128 的常量直接嵌进了 YNN fusion 内部,并作为 dot 的左操作数
assert re.search(r"constant\.\d+ = f32\[128,128\]\{1,0\} constant\(\{\.\.\.\}\)", fused_body(h_closure))
assert "constant({...})" not in fused_body(h_param)
两份 HLO 的计算图完全一样,唯一的差别是:闭包版本里,128×128 的权重以 constant({...}) 的形式嵌在 __ynn_fusion 内部,并作为 dot 的左操作数;传参版本里它是 parameter。可以推断,问题出在 YNNPACK 处理“内嵌常量 + dot + reduce”的路径上,而不在 JAX 的追踪阶段,jaxpr 里看不出任何异常。
验证这个推断:在子进程里用 XLA 的调试选项 --xla_cpu_experimental_ynn_fusion_type=(设为空列表,即关闭 YNN 库融合)重新编译同一个闭包函数:
probe_src = """
import sys, numpy as np, jax, jax.numpy as jnp
rng = np.random.default_rng(0); n = 128
W = rng.standard_normal((n, n)).astype(np.float32); X = rng.standard_normal((n, n)).astype(np.float32)
Wc = jnp.asarray(W)
f = jax.jit(lambda x: jnp.sum(Wc @ x))
hlo = f.lower(jnp.asarray(X)).compile().as_text()
print(float(f(jnp.asarray(X))), float(np.sum(W.astype(np.float64) @ X.astype(np.float64))), "__ynn_fusion" in hlo)
"""
res_default = run_py(probe_src).split()
res_noynn = run_py(probe_src, env_extra={"XLA_FLAGS": "--xla_cpu_experimental_ynn_fusion_type="}).split()
print("默认设置 : 结果 %s 参考 %s 使用 YNN=%s" % tuple(res_default[-3:]))
print("关闭 YNN 融合: 结果 %s 参考 %s 使用 YNN=%s" % tuple(res_noynn[-3:]))
val, refv, ynn = float(res_noynn[-3]), float(res_noynn[-2]), res_noynn[-1]
assert ynn == "False" and abs(val - refv) < 1e-2 # 关闭 YNN 融合后闭包版本也正确
结论与工程经验:
- 大数组作为参数传入,不要闭包捕获。这样做既能避开这个 bug,也能避免把大常量嵌进可执行文件、拖慢编译(入门课 入门 02 JIT编译 中“全局变量”一节讲过)。
- jaxpr 正常不代表结果正确。数值可疑时,要对比优化后 HLO(
clean_hlo加 diff),并用 float64 的 NumPy 做对拍。 XLA_FLAGS的调试选项(--xla_dump_to、--xla_disable_hlo_passes=...、后端相关的--xla_cpu_*)是二分定位编译器问题的利器,但它们只在进程启动时生效。
对 JAX 开发者的意义(课程要点)¶
- 用
jax.numpy的向量化操作写代码,通常会得到可融合的操作序列,XLA 能有效优化; - 和把许多小函数分别 jit 相比,用
@jit包住一个较大的整体函数,通常能给 XLA 更大的优化空间; - 检查 jaxpr 能看到发给 XLA 的操作,但看不到优化结果,那要看优化后的 HLO;
- 借助 XLA,用 Python 写的高级数值程序也能达到接近手写 C++/CUDA 的性能。
🔍 补充¶
⚪ 选读:读 CPU 的 HLO 或用 cost_analysis 比较写法时参考。
- CPU 后端不只靠 LLVM。jaxlib 0.12 的 CPU 后端会把 dot、reduce、部分逐元素链路交给 YNNPACK 库(HLO 中显示为
kind=kCustom加__ynn_fusion),普通逐元素融合则是kind=kLoop,由 XLA 自己生成代码。读 CPU 的 HLO 和 trace 时,看到ynn_fusion不要意外。 lowered.as_text(debug_info=True)会带上源码位置;compiled.as_text()里的metadata={op_name=... stack_frame_id=...}可以把每条 HLO 指令追溯到 Python 源码行。cost_analysis()的flops是估算值。对dot按 $2MNK$ 计算,对逐元素操作按每元素 1 次计算,transcendentals单独统计。用它比较不同写法的“理论工作量”很方便(2.8 会用到)。
2.4 内存布局及其对性能的影响¶
程序的性能不只取决于做了哪些操作,还取决于数据从内存里读得有多高效。多维数组在线性内存中的排列方式叫内存布局(memory layout)。在 GPU、TPU 这类有专用内存访问硬件的加速器上,布局的影响尤其明显。
行主序与列主序¶
以矩阵 $A=\begin{pmatrix}1&2&3\\4&5&6\end{pmatrix}$(2×3)为例:
- 行主序(row-major,C 风格):线性内存中依次是
1, 2, 3, 4, 5, 6。这是 NumPy 的默认布局,JAX 通常也用它。 - 列主序(column-major,Fortran 风格):依次是
1, 4, 2, 5, 3, 6。
对 $m\times n$ 矩阵,元素 $(i,j)$ 的线性偏移量为:
$$ \text{offset}_{\text{row-major}}(i,j)= i\cdot n + j,\qquad \text{offset}_{\text{col-major}}(i,j)= i + j\cdot m . $$
推广到高维:行主序沿连续内存遍历时,最后一个索引变化最快;列主序则是第一个索引变化最快。
A = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)
A_F = np.asfortranarray(A)
print("行主序线性内存:", A.ravel(order="K"), " strides =", A.strides)
print("列主序线性内存:", A_F.ravel(order="K"), " strides =", A_F.strides)
assert A.ravel(order="K").tolist() == [1, 2, 3, 4, 5, 6]
assert A_F.ravel(order="K").tolist() == [1, 4, 2, 5, 3, 6]
# 偏移公式(以元素为单位)
m_, n_ = A.shape
for i in range(m_):
for j_ in range(n_):
assert A.strides[0] // 4 * i + A.strides[1] // 4 * j_ == i * n_ + j_
assert A_F.strides[0] // 4 * i + A_F.strides[1] // 4 * j_ == i + j_ * m_
为什么布局对硬件很重要¶
- CPU 缓存:数据按缓存行加载(常见为 64 字节)。顺序访问利用了空间局部性,缓存命中率高。行主序适合按行遍历,列主序适合按列遍历。
- GPU 内存合并(coalescing):同一个 warp 的线程如果访问全局内存中连续的位置,这些访问可以合并成一次宽事务;跨步访问则要拆成多次事务,有效带宽会大幅下降。在行主序下,沿最后一维访问(
A[i,j]、A[i,j+1]…)通常能合并,沿列访问(A[i,j]、A[i+1,j]…)通常是跨步访问。 - TPU:高带宽内存连着 MXU,数据按特定的分块(tiled)格式摆放时效率最高。XLA 的 TPU 后端会做布局转换来适配 MXU,但初始布局决定了这些转换要付出多少额外开销。
JAX 与 XLA 中的布局¶
JAX 数组默认是行主序,与 NumPy 一致。但在 jit 函数内部,接管的是 XLA。XLA 会做布局分配,可能为了后面的矩阵乘等操作,把中间数组改成更合适的布局(例如做一次转置),即使输入是行主序的。所以 jit 函数里每个中间数组的精确布局,你通常没法显式控制,一般也不需要控制。
在 HLO 里,布局写在类型后面的花括号中,例如 f32[2000,2000]{1,0}。这里列出的是 minor-to-major 顺序:{1,0} 表示第 1 维变化最快,也就是行主序。
M2 = jax.random.normal(jax.random.key(1), (2000, 2000))
print("jax.Array.format:", M2.format.layout)
h_id = compiled_hlo(lambda t: t * 2.0, M2)
print("HLO 入口布局:", re.search(r"entry_computation_layout=\{([^}]*\}[^}]*)\}", h_id).group(1))
assert M2.format.layout.major_to_minor == (0, 1) # 行主序:第 0 维最“慢”
assert "f32[2000,2000]{1,0}" in h_id # HLO 的 minor_to_major 写法
jnp.transpose 在 XLA 里怎么处理¶
jaxpr 里的 transpose 只是一个逻辑操作,XLA 会根据它的使用方式决定是否真的搬数据。下面分四种情况实测:
B2 = jax.random.normal(jax.random.key(2), (2000, 2000))
cases = {
"① 只做转置 a.T": (lambda a: a.T, (M2,)),
"② 转置后接逐元素 a.T*2+1": (lambda a: a.T * 2 + 1, (M2,)),
"③ 转置后接矩阵乘 a.T @ b": (lambda a, b: a.T @ b, (M2, B2)),
"④ 两次转置 a.T.T": (lambda a: a.T.T, (M2,)),
}
hlos = {}
for name, (fn, args) in cases.items():
jp = jax.make_jaxpr(fn)(*args)
h = clean_hlo(compiled_hlo(fn, *args))
hlos[name] = h
entry = [l.strip()[:110] for l in h.split("ENTRY")[-1].splitlines() if "ROOT" in l][0]
print(f"{name}\n jaxpr 中 transpose 次数: {count_primitives(jp.jaxpr)['transpose']}\n 优化后 ROOT: {entry}")
assert " transpose(" in hlos["① 只做转置 a.T"] # 真正搬数据(在 fusion 里)
assert count_fusions(hlos["② 转置后接逐元素 a.T*2+1"]) == 1 # 转置与逐元素融合成一个内核
assert " transpose(" not in hlos["③ 转置后接矩阵乘 a.T @ b"] # 转置被吸收进 dot 的收缩维度
assert "lhs_contracting_dims={0}" in hlos["③ 转置后接矩阵乘 a.T @ b"]
assert " transpose(" not in hlos["④ 两次转置 a.T.T"] # 两次转置互相抵消
可以得出以下几点:
- ① 单独的
a.T必须产生一个新的行主序数组,所以会有一次真正的数据搬运(transpose加copy,被融合成一个内核); - ② 转置后面接逐元素运算时,转置被融合进同一个内核,读的时候按转置后的索引读,不会单独多出一遍内存往返;
- ③
a.T @ b没有任何转置操作,XLA 直接把dot的收缩维度改成lhs_contracting_dims={0},矩阵乘内核按转置后的方式读取; - ④
a.T.T被代数简化掉,只剩一次copy。之所以还要复制,是因为输出不能和输入共用缓冲区,除非用donate_argnums。
所以在 jit 里写 x.T @ y、jnp.einsum,通常不必担心转置的开销。真正需要关心的是物化的转置(①),以及跨步访问。
连续访问与跨步访问的计时(CPU,只打印,不作结论)¶
sum_rows = jax.jit(lambda a: a.sum(axis=1)) # 沿最后一维:连续访问
sum_cols = jax.jit(lambda a: a.sum(axis=0)) # 沿第一维:跨步访问(XLA 可能会重排循环)
copy_c = jax.jit(lambda a: a * 1.0 + 0.0) # 纯复制(被化简为 copy)
copy_t = jax.jit(lambda a: a.T) # 物化转置
t_rows, t_cols = bench(sum_rows, M2), bench(sum_cols, M2)
t_copy, t_tr = bench(copy_c, M2), bench(copy_t, M2)
print(f"XLA sum(axis=1) {t_rows*1e3:7.3f} ms | sum(axis=0) {t_cols*1e3:7.3f} ms")
print(f"XLA 复制 {t_copy*1e3:7.3f} ms | 物化转置 {t_tr*1e3:7.3f} ms")
Mn = np.asarray(M2)
t0 = time.perf_counter(); [np.ascontiguousarray(Mn) .copy() for _ in range(10)]; t_np_c = (time.perf_counter()-t0)/10
t0 = time.perf_counter(); [np.ascontiguousarray(Mn.T) for _ in range(10)]; t_np_t = (time.perf_counter()-t0)/10
print(f"NumPy 连续复制 {t_np_c*1e3:7.3f} ms | 转置后变连续 {t_np_t*1e3:7.3f} ms")
print("(XLA 会为 reduce 选择更好的遍历顺序,所以两种 sum 的差距往往比朴素 C 循环小)")
实践中的考量(课程五条)¶
- 识别内存瓶颈:先用 profiler 判断是不是内存受限。大数组操作变慢,可能是访问模式不好,和布局有关。
- 理解硬件特性:在 GPU 上注意沿最后一维(行主序)访问才能合并;在 TPU 上,
jnp.einsum、jnp.matmul的某些形状和收缩维度更容易映射到硬件。 - 算法与数据组织:如果 GPU 上频繁按列访问,并且 profiling 证实这是瓶颈,可以考虑提前转置一次,但要把转置本身的成本算进去。
- 大数据传输:NumPy 可以表示列主序数组,传给 JAX 之前最好先确保是标准行主序,避免一开始就付出转换开销。下面实测这一点。
- 自定义操作:写自定义原语或接入外部代码时(课程第 5 章),需要自己负责目标后端的布局处理。
big_c = np.random.default_rng(0).random((3000, 3000), dtype=np.float32)
big_f = np.asfortranarray(big_c)
t_put_c = bench(lambda a: jax.device_put(a), big_c, n=10)
t_put_f = bench(lambda a: jax.device_put(a), big_f, n=10)
print(f"device_put 行主序 {t_put_c*1e3:8.3f} ms | 列主序 {t_put_f*1e3:8.3f} ms (约 {t_put_f/t_put_c:.0f}×)")
arr_from_f = jnp.asarray(big_f)
assert arr_from_f.format.layout.major_to_minor == (0, 1) # 进入 JAX 后统一是行主序
assert np.array_equal(np.asarray(arr_from_f), big_c) # 数值不变
🔍 补充¶
⚪ 选读:数据管线里大量从 NumPy 搬数据、或要控制输入输出布局时再看。
- CPU 上
device_put一个 C 连续、对齐良好的 NumPy 数组,可能是零拷贝的(耗时只有微秒级)。Fortran 序数组则必须先转换成行主序,需要一次完整的拷贝。上面测出来相差约两个数量级(具体倍数随机器和数组大小而变)。在数据管线里,用np.ascontiguousarray提前规整数组,是最便宜的优化之一。 x.format.layout可以看到一个jax.Array的物理布局。jax.jit的in_shardings/out_shardings也接受Format(from jax.experimental.layout import Format, Layout,例如Format(Layout.AUTO)),可以让 XLA 为输入输出自动选择布局(高级用法,主要在 TPU 上有收益)。- 判断转置“贵不贵”,要看优化后 HLO 里有没有被物化的
transpose,不要看 jaxpr。
2.5 避免重复编译¶
jit 通过 XLA 编译函数来提速,但编译本身有开销。JAX 先追踪 Python 函数,生成 jaxpr;XLA 再针对目标硬件,以及追踪时输入的形状和类型,把 jaxpr 编译成优化代码。
缓存机制:第一次用某种形状/类型调用 @jit 函数时,会追踪并编译,生成的可执行文件放进缓存。之后形状和类型相同的调用直接复用缓存,没有追踪和编译的开销。如果参数的特征变了,导致需要新的 jaxpr,就必须重新追踪、重新编译。这种情况如果频繁发生,比如在循环里,jit 带来的收益就会被抵消。
课程列出了重新编译的六种原因(细节以本节代码的运行结果为准):
- 参数形状:先用 (10,5),再用 (20,5);
- 参数 dtype:例如 float32 换成 float64(见下面的注意);
- 参数的 Python 类型:同一个位置一会儿传 int,一会儿传 float;
- 依赖值的 Python 控制流(见 2.2:对被追踪的值会直接报错,只有静态参数才会走到重新编译);
- 函数作为参数:每次调用时函数对象不同;
- 闭包与全局变量:它们的值影响图结构时(比如决定中间数组的大小)。
重编译触发条件的总表、jax_log_compiles 和 _cache_size() 的基本用法,见 入门 02 JIT编译 中的「重新编译触发条件总表」;诊断与治理的完整流程见 入门 07 实战专题 专题 1。本节验证课程的三段代码,并补充入门课没有讲过的内容。
第一段代码(代码块 A)想演示“size_param 取值改变时会重新编译”,但它没有把 size_param 标为静态:
# ❌ 旧写法(代码错误):被追踪参数上的 Python if 无法追踪,第一次调用就报错,根本谈不上“第三次调用重新编译”
@jax.jit
def example_function(x, size_param):
# Python 控制流依赖 size_param 的值
if size_param > 5:
return jnp.sum(x * 2.0)
else:
return jnp.sum(x + 1.0)
data = jax.random.normal(jax.random.key(0), (10,))
try:
example_function(data, 3).block_until_ready()
err_a = None
except Exception as e:
err_a = type(e).__name__
print("实测:", err_a)
assert err_a == "TracerBoolConversionError"
⚠️ 注意:代码块 A 不会出现“第一次和第三次调用明显更慢,第三次是因为
size_param的值改变了控制流路径”这种情况。它第一次调用就会抛出TracerBoolConversionError,因为在 jit 里普通参数都是 tracer,不能拿来做 Pythonif判断。想看到按值重新编译的效果,得用代码块 B 的static_argnums。
策略 1:静态参数(代码块 B)¶
参数的值会影响图结构时(形状、超参数、控制流),用 static_argnums / static_argnames 把它标成静态,JAX 会为每一种静态值组合单独编译一个版本。注意,静态参数的取值太多,就会编译出一大堆专用版本,增加编译时间和缓存占用。所以它只适合取值有限、决定图结构的参数。
# ✅ 推荐写法(课程代码块 B):标记为静态;用 CompileCounter 代替“看计时猜编译”
@jax.jit(static_argnums=(1,)) # 将 size_param(索引 1)标记为静态(0.12 支持这种装饰器工厂写法)
def example_function_static(x, size_param):
print(f"Compiling for size_param = {size_param}") # 只在追踪时执行
if size_param > 5:
return jnp.sum(x * 2.0)
else:
return jnp.sum(x + 1.0)
timings = []
with CompileCounter("example_function_static") as cc:
for sp in [3, 3, 7, 7]:
t0 = time.perf_counter()
example_function_static(data, sp).block_until_ready()
timings.append(time.perf_counter() - t0)
for sp, t in zip([3, 3, 7, 7], timings):
print(f"size_param={sp}: {t*1e3:8.3f} ms")
print("实际编译次数:", cc.count)
assert cc.count == 2 # 3 和 7 各编译一次
策略 2:保证输入的类型和形状一致¶
变长序列可以填充到固定的最大长度,再用掩码忽略填充部分(入门课 入门 02 JIT编译 有分桶填充的实战写法)。类型不一致时,在调用 jit 函数之前先显式转换 dtype。
⚠️ 注意:容易以为“float32 之后再传 float64 也可能触发重新编译”。在默认的
jax_enable_x64=False下,传入 NumPy float64 数组时,JAX 会先把它规范化成 float32,跟已有的 float32 缓存项一样,不会重新编译。只有开启 x64 之后,float64 才是一种独立的类型。真正容易踩坑的反而是 weak type:Python 标量1、1.0和jnp.float32(1)是三种不同的缓存键。
@jax.jit
def scale(v):
return v * 2
with CompileCounter("scale") as cc:
scale(jnp.ones(3, jnp.float32))
scale(np.ones(3, np.float64)) # x32 模式下被规范化为 float32
n_after_f64 = cc.count
scale(1) # int32, weak_type=True
scale(1.0) # float32, weak_type=True
scale(jnp.float32(1.0)) # float32, weak_type=False
for r in cc.records:
print(" ", r.split(" with global shapes and types ")[1].split(". Argument")[0])
print("float32 数组 + float64 NumPy 数组 → 编译次数:", n_after_f64, ";总编译次数:", cc.count)
assert n_after_f64 == 1 and cc.count == 4
assert not jax.config.jax_enable_x64
策略 3:用 JAX 控制流原语(代码块 C)¶
把依赖被追踪值的 Python if/for/while 换成 lax.cond、lax.scan、lax.while_loop,分支或循环会被嵌进 XLA 计算图,值变了也不需要重新编译。
@jax.jit
def example_function_lax(x, size_param_val):
# size_param_val 是 0 维数组(或可被追踪的 Python 标量)
pred = size_param_val > 5
def true_fun(operand):
return jnp.sum(operand * 2.0)
def false_fun(operand):
return jnp.sum(operand + 1.0)
return lax.cond(pred, true_fun, false_fun, x)
size_val_3, size_val_7 = jnp.array(3), jnp.array(7)
with CompileCounter("example_function_lax") as cc:
r3 = example_function_lax(data, size_val_3).block_until_ready()
r7 = example_function_lax(data, size_val_7).block_until_ready()
print("编译次数:", cc.count, "| r3 =", r3, "| r7 =", r7)
assert cc.count == 1
np.testing.assert_allclose(r3, np.sum(np.asarray(data) + 1.0), rtol=1e-5)
np.testing.assert_allclose(r7, np.sum(np.asarray(data) * 2.0), rtol=1e-5)
策略 4:管理好闭包与函数参数¶
- 不要在 jit 函数里引用会频繁变化、并且影响图结构的外部变量,把它们改成(必要时标为静态的)参数;
- 传函数时,每次都传同一个函数对象;
- 函数尽量定义在顶层,避免在循环里动态定义函数。
常有人说“functools.partial 有时有助于得到稳定的函数对象”。这句话得说得更准确一些:
def base(v, a):
return v * a
v3 = jnp.ones(3)
with CompileCounter("<lambda>") as c_lambda:
for _ in range(3):
jax.jit(lambda v: v * 2)(v3) # 每次都是新的 lambda 对象
with CompileCounter("base") as c_partial:
for _ in range(3):
jax.jit(functools.partial(base, a=2.0))(v3) # 每次都是新的 partial 对象
with CompileCounter("base") as c_rejit:
for _ in range(3):
jax.jit(base)(v3, 2.0) # 新的 jit 包装,但被包装的函数是同一个
p_once = jax.jit(functools.partial(base, a=2.0)) # partial 只创建一次
with CompileCounter("base") as c_once:
for _ in range(3):
p_once(v3)
print(f"循环内新建 lambda: {c_lambda.count} 次 | 循环内新建 partial: {c_partial.count} 次 | "
f"循环内 jax.jit(base): {c_rejit.count} 次 | partial 只建一次: {c_once.count} 次")
assert (c_lambda.count, c_partial.count, c_rejit.count, c_once.count) == (3, 3, 1, 1)
⚠️ 注意:
functools.partial每次调用都会创建一个新对象,而且没有定义按内容判等。所以在循环里写jax.jit(partial(f, a=...)),会每次都重新编译(3 次调用编译 3 次),和每次新建 lambda 一样。正确做法是只创建一次 partial 或 jit 包装,然后复用它;或者把绑定的值作为(静态)参数传入。有意思的是,jax.jit(base)就算在循环里反复创建,也能命中缓存,因为缓存是按底层函数对象索引的。
🔍 补充:jax_explain_cache_misses——让 JAX 告诉你为什么没命中缓存¶
⚪ 选读:排查“为什么又编译了”时再看;完整诊断流程在入门 07 专题 1。
jax_log_compiles 只告诉你“编译了”,jax_explain_cache_misses 会进一步对比最接近的一个旧缓存键,指出到底是哪个参数的什么属性变了。这里只演示形状变化一例;弱类型、pytree 结构变化,以及静态参数值变化时只输出 explanation unavailable 等情形,见 入门 07 实战专题 专题 1.4 和 进阶 08 调试工具链 §5.2。
@jax.jit
def explain_me(v, w):
return v @ w
class _Grab(logging.Handler):
def __init__(self):
super().__init__(); self.msgs = []
def emit(self, record):
self.msgs.append(record.getMessage())
grab = _Grab(); jl = logging.getLogger("jax"); jl.addHandler(grab)
saved = [(h, h.level) for h in jl.handlers if h is not grab]
for h, _ in saved: h.setLevel(logging.ERROR) # 不刷屏,只收集
jax.config.update("jax_explain_cache_misses", True)
try:
explain_me(jnp.ones((4, 4)), jnp.ones((4, 4)))
explain_me(jnp.ones((4, 4)), jnp.ones((4, 8))) # 只改了 w 的形状
finally:
jax.config.update("jax_explain_cache_misses", False)
for h, lvl in saved: h.setLevel(lvl)
jl.removeHandler(grab)
miss = [m for m in grab.msgs if "CACHE MISS" in m and "explain_me" in m]
print(miss[-1])
assert "at w, now f32[4,8] and before f32[4,4]" in miss[-1]
🔍 补充:持久化编译缓存——让第二个进程也不用重新编译¶
⚪ 选读:大模型、编译要几分钟、需要频繁重启进程时才用得上。
前面讨论的缓存都在内存里,进程一退出就没了。对大模型来说,每次重启脚本都要重新编译,可能要几分钟。持久化编译缓存会把 XLA 可执行文件写到磁盘上,之后的进程只要缓存键相同就直接加载。
| 配置项 | 作用 | 默认值 |
|---|---|---|
jax_compilation_cache_dir | 缓存目录(也可以用环境变量 JAX_COMPILATION_CACHE_DIR) | 未设置,也就是不启用 |
jax_persistent_cache_min_compile_time_secs | 编译时间超过这个值才写盘 | 1.0 秒,小函数默认不缓存 |
jax_persistent_cache_min_entry_size_bytes | 条目大小下限(-1 表示不限) | 0 |
缓存键包含:HLO 模块内容、jax/jaxlib 版本、XLA 编译选项(含 XLA_FLAGS)、设备类型等,所以升级 JAX 或者改了 flags,旧缓存会自动失效。
下面用两个独立的子进程、同一个缓存目录来验证:第一个进程 MISS 并写盘,第二个进程 HIT。
cache_dir = os.path.join(SCRATCH, "jax_cache")
cache_src = """
import sys, time, logging
import jax, jax.numpy as jnp
jax.config.update("jax_compilation_cache_dir", sys.argv[1])
jax.config.update("jax_persistent_cache_min_compile_time_secs", 0.0) # 演示用:小函数也写盘
jax.config.update("jax_persistent_cache_min_entry_size_bytes", -1)
logging.basicConfig(level=logging.WARNING, format="%(message)s")
logging.getLogger("jax._src.compiler").setLevel(logging.DEBUG) # 打印 HIT / MISS
def model(x):
for _ in range(20):
x = jnp.tanh(x @ x) + 0.1
return x
t0 = time.perf_counter()
exe = jax.jit(model).lower(jnp.ones((64, 64))).compile()
print("COMPILE_SECONDS", time.perf_counter() - t0)
"""
out1 = run_py(cache_src, args=(cache_dir,))
out2 = run_py(cache_src, args=(cache_dir,))
pick = lambda s: [l for l in s.splitlines() if "jit_model" in l or "COMPILE_SECONDS" in l]
print("---- 进程 1 ----", *pick(out1), sep="\n")
print("---- 进程 2 ----", *pick(out2), sep="\n")
print("\n缓存目录内容:", sorted(f[:40] + "…" for f in os.listdir(cache_dir) if f.endswith("-cache")))
assert "PERSISTENT COMPILATION CACHE MISS for 'jit_model'" in out1
assert "Persistent compilation cache hit for 'jit_model'" in out2
assert any(f.startswith("jit_model-") for f in os.listdir(cache_dir))
使用要点:
jax.config.update("jax_compilation_cache_dir", ...)必须在第一次编译之前调用,最好放在脚本最开头;- 默认 1 秒的阈值是有意设计的:小函数从磁盘读取的开销可能比重新编译还大;
- 多个进程或多台机器可以共享同一个目录(例如网络文件系统、GCS 路径),集群训练时常这样用;
- 持久化缓存只省掉 XLA 编译,追踪和 lowering 仍然会执行,所以 Python 层很慢的追踪(比如巨大的展开循环)它帮不上忙。
2.6 算子合并与操作优化¶
XLA 编译时最关键的优化之一是算子合并(operator fusion,也叫算子融合):把多个操作合并成一个更大的计算内核。理解它,能帮你读懂 profiling 结果,也能避免无意中阻止了这项优化。
什么是融合:核心是把一串有生产者–消费者关系的逐元素操作,合并成一个复合操作。课程示例:
# ❌ 旧写法(代码错误):x、y 用同一个 PRNGKey 生成 → 两个数组完全相同
key_old = jax.random.PRNGKey(0)
x_old = jax.random.uniform(key_old, (1000, 1000))
y_old = jax.random.uniform(key_old, (1000, 1000))
print("x 与 y 完全相同:", bool(jnp.array_equal(x_old, y_old)))
assert bool(jnp.array_equal(x_old, y_old))
# ✅ 推荐写法:jax.random.key + split
import jax
import jax.numpy as jnp
def simple_computation(x, y):
a = jnp.log(x)
b = a + y
c = jnp.exp(b)
return c
# JIT 编译函数
compiled_computation = jax.jit(simple_computation)
# 示例数据
kx, ky = jax.random.split(jax.random.key(0))
x = jax.random.uniform(kx, (1000, 1000))
y = jax.random.uniform(ky, (1000, 1000))
assert not bool(jnp.array_equal(x, y))
# 执行
result = compiled_computation(x, y).block_until_ready()
np.testing.assert_allclose(result, np.exp(np.log(np.asarray(x, np.float64)) + np.asarray(y, np.float64)), rtol=1e-5)
print("结果正确,形状", result.shape)
不融合时(例如在 GPU 上逐个执行),simple_computation 要启动三次内核:
- 从内存读
x,算log(x),结果写回a; - 读
a和y,算a + y,结果写回b; - 读
b,算exp(b),结果写回c。
每一步都要读写加速器的主存(如 GPU HBM),这种内存传输往往才是真正的瓶颈。XLA 在 HLO 上分析时发现(XLA 并不直接读 jaxpr,见 2.3 的编译链路),中间结果 a、b 只被下一个操作使用,于是把三步合成一个内核:一次性加载 x 和 y,在片上存储(寄存器/缓存)里算完 exp(log(x) + y),只把 c 写回一次。
设每个数组有 $N$ 个 float32 元素,粗略估计主存流量:
$$ \underbrace{(1+1) + (2+1) + (1+1)}_{\text{未融合:读+写}} = 7\times 4N \text{ 字节} \qquad\text{vs.}\qquad \underbrace{2 + 1}_{\text{融合}} = 3\times 4N \text{ 字节}. $$
融合的好处:
- 减少内存带宽占用(最显著):访问主存比访问寄存器/片上缓存慢几个数量级;
- 降低内核启动开销:在大计算里,这一项通常没有带宽重要,但对由大量小操作组成的函数,影响可能很明显;
- 提高指令级并行性:内核更大,硬件调度器就有更多相互独立的指令可以安排。
怎样观察融合(课程三种方法):① profiler 里加速器时间线上的内核数,少于源码里的操作数;② @jit 版本明显快于逐个执行各操作的总和;③ 检查 XLA HLO。下面直接用方法 ③,在优化后的 HLO 里数 fusion,并用 --xla_disable_hlo_passes=fusion 关掉融合 pass 作对比(同样要在子进程里做):
h_fused = compiled_hlo(simple_computation, x, y)
print("默认(融合):\n", clean_hlo(h_fused).split("ENTRY")[-1])
ca_fused = jax.jit(simple_computation).lower(x, y).compile().cost_analysis()
fusion_probe = """
import json, re, jax, jax.numpy as jnp, time
def simple_computation(x, y):
return jnp.exp(jnp.log(x) + y)
kx, ky = jax.random.split(jax.random.key(0))
x = jax.random.uniform(kx, (1000, 1000)); y = jax.random.uniform(ky, (1000, 1000))
c = jax.jit(simple_computation).lower(x, y).compile()
h = c.as_text()
f = jax.jit(simple_computation); f(x, y).block_until_ready()
t0 = time.perf_counter()
for _ in range(50): f(x, y).block_until_ready()
entry = [l.strip().split(", metadata")[0] for l in h.split("ENTRY")[-1].splitlines() if " fusion(" in l]
print("JSON" + json.dumps(dict(n_fusion=len(re.findall(r" fusion\\(", h)), entry=entry,
bytes=c.cost_analysis()["bytes accessed"],
temp=c.memory_analysis().temp_size_in_bytes,
t=(time.perf_counter() - t0) / 50)))
"""
def parse_json(out):
return json.loads([l for l in out.splitlines() if l.startswith("JSON")][-1][4:])
r_on = parse_json(run_py(fusion_probe))
r_off = parse_json(run_py(fusion_probe, env_extra={"XLA_FLAGS": "--xla_disable_hlo_passes=fusion"}))
print("关闭 fusion pass 后的 ENTRY 指令:")
for l in r_off["entry"]:
print(" ", l[:120])
print(f"\n{'':10s}{'fusion 数':>10s}{'bytes accessed':>18s}{'temp 缓冲区':>14s}{'耗时(ms)':>10s}")
for name, r in [("融合", r_on), ("不融合", r_off)]:
print(f"{name:10s}{r['n_fusion']:>10d}{r['bytes']:>18,.0f}{r['temp']:>14,d}{r['t']*1e3:>10.3f}")
N_el = 1000 * 1000
assert count_fusions(h_fused) == 1 and r_on["n_fusion"] == 1 and r_off["n_fusion"] == 3
assert r_on["bytes"] == 3 * 4 * N_el and r_off["bytes"] == 7 * 4 * N_el # 与上面的 3N vs 7N 估算完全一致
XLA 自己的 cost_analysis 给出的数字是 bytes accessed:融合时为 $3\times4N$,不融合时为 $7\times4N$,和上面手算的结果完全一致。还有一个细节:不融合时 temp 仍然是 0。这是因为 CPU 后端的缓冲区分配允许逐元素操作原地写进输出缓冲区(a、b 都复用了 c 的内存)。也就是说,不融合的代价主要是内存流量(多读写的 16 MB),而不一定是内存占用。
下面用课程的方法 ②,对比 eager(逐个操作执行)和 jit:
⚠️ 注意:不用
@jit时,中间结果并不会被“具化为完整的 NumPy 数组”。eager 模式下,每个jnp操作会被单独编译、单独调度,中间结果是留在设备上的jax.Array,不会回到宿主。eager 之所以慢,是因为每个操作都要完整地读写一遍内存,再加上每次调度的开销。
a_eager = jnp.log(x)
print("eager 中间结果类型:", type(a_eager).__name__, "| 所在设备:", a_eager.device)
assert isinstance(a_eager, jax.Array) and not isinstance(a_eager, np.ndarray)
t_eager = bench(simple_computation, x, y)
t_jit = bench(compiled_computation, x, y)
print(f"eager(3 次调度、3 个内核): {t_eager*1e3:.3f} ms | jit(1 个融合内核): {t_jit*1e3:.3f} ms | 比值 {t_eager/t_jit:.2f}×")
促进融合的建议(课程)¶
- 把逐元素操作写在一起:一串
jax.numpy逐元素操作,很容易被融合; - 避免“融合障碍”:强制同步、XLA 难以分析的复杂数据依赖、某些控制流,以及可融合操作之间显式的内存布局变化,都可能阻止相邻操作融合;
- 信任编译器:用标准库函数写清晰、数值正确的代码。手动“强制融合”通常适得其反。
下面观察两种常见的“边界”:显式屏障 lax.optimization_barrier,以及归约之后再广播(softmax 里的 max/sum):
mat = jax.random.normal(jax.random.key(3), (1000, 1000))
f_chain = lambda t: jnp.exp(jnp.log(jnp.abs(t) + 1.0) * 2.0)
f_barrier = lambda t: jnp.exp(lax.optimization_barrier(jnp.log(jnp.abs(t) + 1.0)) * 2.0)
def softmax_manual(t):
m = t.max(axis=-1, keepdims=True)
e = jnp.exp(t - m)
return e / e.sum(axis=-1, keepdims=True)
for name, fn in [("逐元素链", f_chain), ("中间加 optimization_barrier", f_barrier),
("手写 softmax", softmax_manual), ("jax.nn.softmax", jax.nn.softmax)]:
h = compiled_hlo(fn, mat)
kinds = re.findall(r'kind=(\w+)', h)
print(f"{name:28s} fusion 数 = {count_fusions(h)} kinds = {kinds}")
assert count_fusions(compiled_hlo(f_chain, mat)) == 1
assert count_fusions(compiled_hlo(f_barrier, mat)) == 2 # 屏障把一个内核切成两个
assert count_fusions(compiled_hlo(softmax_manual, mat)) > 1 # 归约 → 广播 处形成内核边界
np.testing.assert_allclose(f_barrier(mat), f_chain(mat), rtol=1e-6)
np.testing.assert_allclose(softmax_manual(mat), jax.nn.softmax(mat), rtol=1e-5, atol=1e-7)
🔍 补充¶
🟢 必读
- fusion 的种类:
kLoop是纯逐元素/广播融合;kInput是以归约为核心的融合(GPU 上常见);kOutput是以 dot/conv 为核心、带输出端逐元素操作的融合;kCustom是交给库或专用代码生成器实现的融合(CPU 上的__ynn_fusion、GPU 上的 Triton GEMM 融合等;GPU 上直接调用 cuBLAS 的矩阵乘显示为custom-call,不算 fusion)。在 CPU 上数到的 fusion 条数,大致就是 trace 里能看到的内核数。 - 归约是天然的内核边界:softmax 需要“先算完整行的 max,再逐元素处理”,所以很难做成一个内核。这也正是 FlashAttention 这类手写内核的价值所在(课程后面的 Pallas 章节会讲)。
lax.optimization_barrier的本意是阻止 XLA 跨越它做重排或合并,常用于控制重计算(remat)和调试,不要拿它当性能工具。- 判断融合有没有生效,最可靠的证据是
cost_analysis()["bytes accessed"](内存流量),比计时可靠,在 CPU 上尤其如此。memory_analysis().temp_size_in_bytes反映的是“有没有被物化的中间量”(2.8 节用它做了对照实验),但逐元素中间量可能原地复用输出缓冲区,所以 temp 为 0 不代表一定发生了融合。
2.7 异步调度¶
在 GPU/TPU 上优化 JAX 代码时,计时结果有时会快得出奇,原因就在于 JAX 的异步调度(asynchronous dispatch)。
和标准 Python 每一步都等执行完成不同,JAX 调用面向加速器的函数(尤其是 jit 函数)时,会依次做三件事:
- 追踪并编译(如果需要):第一次遇到某种形状/类型,或者触发了重新编译时;
- 调度:把编译好的内核放进加速器的执行队列;
- 立即返回控制权:不等设备上的计算结束,就把控制权交回 Python 解释器。
加速器在后台处理队列里的计算,Python 继续往下执行。这样,CPU 上的控制逻辑和加速器上的数值计算就可能并行。例如,GPU 处理当前批次时,CPU 可以同时加载、预处理下一批数据,从而提高整体吞吐量。
对基准测试的影响:直接用 time.time() / time.perf_counter() 测 JAX 调用,测到的往往不是真正的执行时间。课程代码块 A 是“简单计时”的反例:
# ❌ 错误写法:没有预热、没有 block_until_ready(常被说成“主要测到的是调度时间”)
@jax.jit
def compute_heavy(m):
return jnp.dot(m, m.T)
x_h = jax.random.normal(jax.random.key(0), (2000, 2000)) # 用推荐的 key 写法;规模比原示例的 4096×4096 小
with CompileCounter("compute_heavy") as cc:
start_time = time.perf_counter()
result = compute_heavy(x_h) # 控制权几乎立即返回?
end_time = time.perf_counter()
naive = end_time - start_time
print(f"简单计时: {naive:.6f} 秒;这次调用里发生的编译次数 = {cc.count}")
assert cc.count == 1 # 第一次调用包含了追踪 + 编译,并不只是“调度”
# ✅ 推荐写法:先预热(编译),再计时,并 block_until_ready
result_compiled = compute_heavy(x_h).block_until_ready() # 提前强制编译
t0 = time.perf_counter()
result = compute_heavy(x_h)
t_dispatch = time.perf_counter() - t0 # 只到“调度完成”
ready_right_after = result.is_ready()
result.block_until_ready() # 等待计算完成
t_total = time.perf_counter() - t0
print(f"首次调用(含编译): {naive*1e3:9.3f} ms")
print(f"预热后·仅调度 : {t_dispatch*1e3:9.3f} ms (调度后立刻 is_ready() = {ready_right_after})")
print(f"预热后·正确计时 : {t_total*1e3:9.3f} ms")
print(f"调度时间只占真实耗时的 {t_dispatch / t_total:.1%}")
assert result.is_ready()
⚠️ 注意:代码块 A 测到的并不“主要是把
jnp.dot调度到加速器上的时间”。它没有预热,第一次调用包含追踪和 XLA 编译(上面用CompileCounter验证过,确实编译了 1 次),所以这个数字既不是调度时间,也不是执行时间。只有预热之后,不加block_until_ready的计时才只反映调度时间。
block_until_ready() 可以在任意 jax.Array 上调用,它会阻塞 Python,直到产生这个数组的设备计算完成。还有一种写法是函数形式的 jax.block_until_ready(x):
# ❌ 常见误解:以为不带参数调用 jax.block_until_ready() 会等待所有设备上所有未完成的计算
try:
jax.block_until_ready()
err_bur = None
except TypeError as e:
err_bur = str(e)
print("实测 TypeError:", err_bur)
assert err_bur is not None and "missing 1 required positional argument" in err_bur
# ✅ 推荐写法:传入一个 pytree;它只等待这个 pytree 里的 jax.Array 叶子,并原样返回该 pytree
pending = {"a": compute_heavy(x_h), "b": [compute_heavy(x_h)[0]], "step": 7}
out_tree = jax.block_until_ready(pending)
assert out_tree is pending and out_tree["step"] == 7 # 非数组叶子原样保留
assert all(leaf.is_ready() for leaf in jax.tree.leaves(out_tree) if isinstance(leaf, jax.Array))
print("pytree 中所有数组都已就绪;返回的是同一个对象:", out_tree is pending)
⚠️ 注意:
jax.block_until_ready()不会等待“所有设备上所有”未完成的异步计算。不带参数调用会抛TypeError(缺少必需参数x)。对 pytree 调用时,它只等待这个 pytree 里的数组叶子,然后原样返回这个 pytree,不会等无关的计算。
何时用 block_until_ready()(课程):
- 基准测试:测量在加速器上运行的 JAX 代码时,必须使用;
- 调试:怀疑问题和执行时序有关时,用它让操作按确定的顺序完成;
- 同步点:后面的非 JAX 代码依赖完整结果时(保存到磁盘、转换成 NumPy……)。
隐式同步¶
有些操作需要宿主端的具体值,会自动等待计算完成:
- 复制到宿主:
np.asarray(arr)/np.array(arr)、.item()、float(arr)、.tolist(); - Python 控制流:
if arr > 0:、while ...,本质是bool(arr),需要具体值; - 打印:
print(arr)要格式化具体数值(下面有一个 0.12 的重要例外)。
逐一实测:先调度一个比较重的计算,然后立刻执行某个操作,看这个操作花了多久、执行后结果是否已经就绪:
heavy = jax.jit(lambda m: jnp.tanh(m @ m) @ m)
heavy(x_h).block_until_ready() # 预热
sync_ops = {
".item()": lambda r: r[0, 0].item(),
"float()": lambda r: float(r[0, 0]),
"np.asarray": lambda r: np.asarray(r),
"Python if": lambda r: bool(r[0, 0] > 0),
".tolist()": lambda r: r[:2, :2].tolist(),
}
print(f"{'操作':14s}{'调度(ms)':>10s}{'该操作(ms)':>12s}{'操作后 is_ready':>16s}")
for name, op in sync_ops.items():
t0 = time.perf_counter(); r = heavy(x_h); t1 = time.perf_counter()
op(r); t2 = time.perf_counter()
print(f"{name:14s}{(t1-t0)*1e3:>10.3f}{(t2-t1)*1e3:>12.3f}{str(r.is_ready()):>16s}")
assert r.is_ready() # 需要宿主值的操作返回时,计算必然已经完成
0.12 的重要例外:大数组的 print/repr 不会同步。 从 jax.Array.__repr__ 的实现可以看到,数组达到 1 MiB(nbytes >= 1 << 20)及以上时,只打印 Array(shape=..., dtype=...),不取值,所以不会等计算完成。小数组才会把值拷回宿主打印。
r_big = heavy(x_h) # 2000×2000 float32 = 16 MB
s_big = repr(r_big)
print("大数组 repr:", s_big, "| repr 之后 is_ready =", r_big.is_ready())
r_small = jax.jit(lambda m: (jnp.tanh(m @ m) @ m)[:2, :2])(x_h)
s_small = repr(r_small)
print("小数组 repr:", s_small.replace("\n", " "), "| repr 之后 is_ready =", r_small.is_ready())
assert s_big == "Array(shape=(2000, 2000), dtype=float32)"
assert r_small.is_ready() and "[[" in s_small
r_big.block_until_ready()
⚠️ 注意:“打印 JAX 数组会先复制到主机再格式化,所以会阻塞”,这只对小于 1 MiB 的数组成立。更大的数组
repr只显示形状和 dtype,既不取值,也不阻塞。
课程提醒:虽然隐式阻塞存在,但用它来做基准测试既不明确,又容易混淆,应该用 block_until_ready() 把同步点显式写出来。
利用异步:让宿主工作和设备计算重叠¶
def host_work():
# 模拟宿主端的数据预处理(纯 NumPy,不碰 JAX)
a = np.random.default_rng(0).random((800, 800))
return float((a @ a).sum())
t0 = time.perf_counter(); heavy(x_h).block_until_ready(); t_dev = time.perf_counter() - t0
t0 = time.perf_counter(); host_work(); t_host = time.perf_counter() - t0
t0 = time.perf_counter()
r_async = heavy(x_h) # 调度后立即返回
host_work() # 设备计算进行的同时,宿主做自己的事
r_async.block_until_ready()
t_overlap = time.perf_counter() - t0
print(f"设备 {t_dev*1e3:.1f} ms + 宿主 {t_host*1e3:.1f} ms = 串行 {(t_dev+t_host)*1e3:.1f} ms;重叠执行 {t_overlap*1e3:.1f} ms")
print("(纯 CPU 环境下“设备”和“宿主”共用 CPU 核心,重叠收益有限;在 GPU/TPU 上更明显)")
🔍 补充¶
🟢 必读
- 异步错误会“延迟”出现:设备端的运行时错误(如 OOM)可能要到下一次同步时才抛出,看起来像是后面某一行代码出了错。调试时可以在可疑位置加
block_until_ready()。 - 不要在训练循环里每步都
.item()。把 loss 写进日志时,可以每 N 步同步一次,或者先收集数组,最后再一次性jax.device_get,否则会让设备每一步都空等宿主。 - 调度队列不是无限长的:一次性调度太多步,会占用更多设备内存,存放待执行的输入输出。可以每隔若干步同步一次,限制在途的计算量。
- CPU 后端也是异步调度的(上面调度后立即检查
is_ready(),通常是False),可以用配置项jax_cpu_enable_async_dispatch(默认True,只作用于非并行计算)关闭。
2.8 实践:优化数值计算——两两距离矩阵¶
计算两组向量之间的两两欧氏距离是很常见的数值任务。本节把前面的原则用起来,目的不只是提速,更是理解为什么某些写法在 JAX/XLA 里更好。
问题设定:$X\in\mathbb{R}^{N\times d}$ 有 $N$ 个点,$Y\in\mathbb{R}^{M\times d}$ 有 $M$ 个点,求距离矩阵 $D\in\mathbb{R}^{N\times M}$:
$$ D_{ij}=\sqrt{\sum_{k=1}^{d}\left(X_{ik}-Y_{jk}\right)^2}. $$
(为了不和距离矩阵 $D$ 混淆,维度用小写 $d$ 表示。规模取 $N, M, d = 1000, 1500, 64$。)
本节按“实现 → 计时 → 看 jaxpr/HLO 证据 → 重构”的循环走四步:v0(vmap 的逐对写法)→ v1(课程基准:显式广播)→ v2(课程优化:矩阵代数)→ v3(修复 v2 的数值问题)。最后用 float64 对拍所有版本。
# ❌ 旧写法(代码错误):X、Y 用同一个 PRNGKey 生成 → X 恰好等于 Y 的前 1000 行
key_c = jax.random.PRNGKey(0)
N, M, Dd = 1000, 1500, 64
X_bad = jax.random.normal(key_c, (N, Dd))
Y_bad = jax.random.normal(key_c, (M, Dd))
same_prefix = bool(jnp.array_equal(X_bad, Y_bad[:N]))
print("X 是否等于 Y[:1000]:", same_prefix, "→ 距离矩阵的前 1000 条对角线全是 0,测试数据退化")
assert same_prefix
# ✅ 推荐写法:独立的子 key;数据显式放到设备上
kx, ky = jax.random.split(jax.random.key(0))
N, M, d = 1000, 1500, 64
X = jax.device_put(jax.random.normal(kx, (N, d)))
Y = jax.device_put(jax.random.normal(ky, (M, d)))
# float64 参考答案(NumPy)
X64, Y64 = np.asarray(X, np.float64), np.asarray(Y, np.float64)
D_ref = np.sqrt(((X64[:, None, :] - Y64[None, :, :]) ** 2).sum(-1))
def report(fn, *args):
"""编译 fn,返回 HLO 证据 + 计时 + 误差。"""
c = jax.jit(fn).lower(*args).compile()
h = c.as_text()
ca, ma = c.cost_analysis(), c.memory_analysis()
return dict(t=bench(jax.jit(fn), *args), fusions=count_fusions(h), dots=h.count(" dot("),
flops=ca["flops"], bytes=ca["bytes accessed"], temp=ma.temp_size_in_bytes, hlo=h)
results = {}
v0:逐对函数 + 两层 vmap¶
最“数学化”的写法:先写出单对点的距离,再用两层 vmap 扩展到所有点对(vmap 见入门课 入门 04 自动向量化vmap)。
def pair_dist(x, y):
return jnp.sqrt(jnp.sum((x - y) ** 2))
def pairwise_distance_v0(X, Y):
return jax.vmap(lambda x: jax.vmap(lambda y: pair_dist(x, y))(Y))(X)
jp0 = jax.make_jaxpr(pairwise_distance_v0)(X, Y)
print(jp0)
results["v0 vmap"] = report(pairwise_distance_v0, X, Y)
v1:课程基准——显式广播¶
课程说,在 JAX 里模仿 NumPy 的做法会用到显式广播:X[:, None, :] 变成 $(N,1,d)$,Y[None, :, :] 变成 $(1,M,d)$,相减得到 $(N,M,d)$ 的 diff,再沿最后一维求平方和、开方。
def pairwise_distance_v1(X, Y):
"""使用显式广播计算两两距离。"""
diff = X[:, None, :] - Y[None, :, :] # X → (N,1,D),Y → (1,M,D),diff → (N,M,D)
squared_dist = jnp.sum(diff ** 2, axis=-1) # (N,M)
return jnp.sqrt(squared_dist)
jp1 = jax.make_jaxpr(pairwise_distance_v1)(X, Y)
print(jp1)
big_vars = [str(v.aval.str_short()) for e in jp1.jaxpr.eqns for v in e.outvars if v.aval.shape == (N, M, d)]
print("jaxpr 中形状为 (N,M,d) 的中间变量:", big_vars)
results["v1 广播"] = report(pairwise_distance_v1, X, Y)
r0, r1 = results["v0 vmap"], results["v1 广播"]
print(f"v0 与 v1 优化后:fusion {r0['fusions']} vs {r1['fusions']};flops {r0['flops']:.3g} vs {r1['flops']:.3g};temp {r0['temp']} vs {r1['temp']} 字节")
# 对照组:用 optimization_barrier 强制把 diff 物化,看看 memory_analysis 会怎样反映
def pairwise_distance_v1_forced(X, Y):
diff = lax.optimization_barrier(X[:, None, :] - Y[None, :, :])
return jnp.sqrt(jnp.sum(diff ** 2, axis=-1))
m_forced = jax.jit(pairwise_distance_v1_forced).lower(X, Y).compile().memory_analysis()
print(f"对照:强制物化 diff 时 temp = {m_forced.temp_size_in_bytes/1e6:.0f} MB(= N·M·d·4 字节)")
assert len(big_vars) >= 2 # jaxpr 层面确实有 (N,M,d) 的中间量
assert m_forced.temp_size_in_bytes >= N * M * d * 4 # 对照组证明 temp 能检测到物化
assert r0["flops"] == r1["flops"] and r0["fusions"] == r1["fusions"] == 1 # vmap 写法与广播写法编译成同一种东西
assert r1["temp"] < 0.01 * N * M * d * 4 # 但优化后并没有物化 (N,M,d)
⚠️ 注意:容易以为 v1“显式创建了形状为 $(N,M,D)$ 的大中间数组
diff,示例规模下有 $1000\times1500\times64=9600$ 万个元素”,并把它当成 v1 慢的原因之一。实际情况(JAX 0.12,CPU)是:jaxpr 里确实有f32[1000,1500,64]的中间变量,但 XLA 把“广播 → 相减 → 平方 → 归约”融合成了一个内核。memory_analysis().temp_size_in_bytes为 0,远小于这个中间数组需要的 384 MB,bytes accessed也只是输入加输出。作为对照,用optimization_barrier强制物化时,temp 恰好是 384 MB(见上面的assert)。这个 384 MB 的数组从来没有被物化。 v1 慢,主要是因为用不上高度优化的矩阵乘内核(见 v2)。它的 FLOPs 也多一些(约 1.4 倍,见汇总表),但这解释不了汇总表里好几倍的耗时差距。另外,v0(两层 vmap)和 v1 编译出来的东西一样,vmap并不比手写广播慢。
v2:课程优化——矩阵代数恒等式¶
利用
$$ \lVert x_i-y_j\rVert^2=\lVert x_i\rVert^2-2\,x_i^\top y_j+\lVert y_j\rVert^2 , $$
主要计算量变成一次矩阵乘 $XY^\top$。其余都是逐元素操作,可以和矩阵乘或彼此融合。课程用 jnp.maximum(0.0, ...) 把浮点误差造成的微小负数截断为 0。
def pairwise_distance_v2(X, Y):
"""使用矩阵代数恒等式计算两两距离。"""
x_sq_norms = jnp.sum(X ** 2, axis=1) # (N,)
y_sq_norms = jnp.sum(Y ** 2, axis=1) # (M,)
dot_products = jnp.dot(X, Y.T) # (N,M)
# 为广播重塑范数:(N,1) 与 (1,M)
squared_dist = x_sq_norms[:, None] - 2 * dot_products + y_sq_norms[None, :]
squared_dist = jnp.maximum(0.0, squared_dist) # 截断浮点误差造成的微小负值
return jnp.sqrt(squared_dist)
results["v2 矩阵乘"] = report(pairwise_distance_v2, X, Y)
r2 = results["v2 矩阵乘"]
print(clean_hlo(r2["hlo"]).split("ENTRY")[-1])
print(f"flops: v1 {r1['flops']:.4g} → v2 {r2['flops']:.4g};dot 指令数 {r2['dots']}")
assert r2["dots"] == 1 and " transpose(" not in r2["hlo"] # Y.T 被吸收进 dot(见 2.4)
assert r2["flops"] < r1["flops"]
v3:v2 的数值陷阱与修复¶
光用 maximum 截断负值还不够,v2 还有一个灾难性抵消的问题。当点离原点很远、彼此又很近时,$\lVert x\rVert^2$、$\lVert y\rVert^2$、$2x^\top y$ 都是很大的数,而它们的差很小。float32 只有约 7 位有效数字,相减之后结果几乎全是误差。v1 是先相减再平方,不受这个问题影响。
修复方法很简单。距离有平移不变性,$\lVert (x-\mu)-(y-\mu)\rVert=\lVert x-y\rVert$。先减去一个公共中心 $\mu$(例如 $X$ 的均值),再用 v2 计算。额外开销只有 $O((N+M)d)$。
def pairwise_distance_v3(X, Y):
"""先平移到公共中心,再用矩阵恒等式:保留 v2 的速度,修复抵消误差。"""
mu = jnp.mean(X, axis=0)
return pairwise_distance_v2(X - mu, Y - mu)
results["v3 中心化+矩阵乘"] = report(pairwise_distance_v3, X, Y)
# 远离原点的数据:每个坐标 +100
Xo, Yo = X + 100.0, Y + 100.0
D_ref_o = np.sqrt(((np.asarray(Xo, np.float64)[:, None] - np.asarray(Yo, np.float64)[None]) ** 2).sum(-1))
err_o = {name: float(np.abs(np.asarray(jax.jit(fn)(Xo, Yo)) - D_ref_o).max())
for name, fn in [("v1", pairwise_distance_v1), ("v2", pairwise_distance_v2), ("v3", pairwise_distance_v3)]}
print("坐标整体 +100 后的最大绝对误差:", {k: f"{v:.2e}" for k, v in err_o.items()})
# 自身距离:对角线应为 0
diag_v2 = float(np.abs(np.diag(np.asarray(jax.jit(pairwise_distance_v2)(Xo, Xo)))).max())
diag_v3 = float(np.abs(np.diag(np.asarray(jax.jit(pairwise_distance_v3)(Xo, Xo)))).max())
print(f"d(x, x) 的最大值: v2 = {diag_v2:.3g}, v3 = {diag_v3:.3g}")
assert err_o["v2"] > 1e-2 and err_o["v1"] < 1e-4 and err_o["v3"] < 1e-4
assert diag_v3 < diag_v2
汇总:证据 + 计时 + 正确性¶
print(f"{'版本':18s}{'耗时(ms)':>10s}{'fusion':>8s}{'dot':>5s}{'GFLOP':>9s}{'bytes(MB)':>11s}{'temp(KB)':>10s}{'最大误差':>11s}")
fns = {"v0 vmap": pairwise_distance_v0, "v1 广播": pairwise_distance_v1,
"v2 矩阵乘": pairwise_distance_v2, "v3 中心化+矩阵乘": pairwise_distance_v3}
outs = {}
for name, r in results.items():
outs[name] = np.asarray(jax.jit(fns[name])(X, Y))
err = np.abs(outs[name] - D_ref).max()
print(f"{name:18s}{r['t']*1e3:>10.3f}{r['fusions']:>8d}{r['dots']:>5d}{r['flops']/1e9:>9.3f}"
f"{r['bytes']/1e6:>11.2f}{r['temp']/1e3:>10.1f}{err:>11.2e}")
base_t = results["v1 广播"]["t"]
print("\n相对 v1 的加速(CPU 计时,仅供参考,不作结论):",
{k: f"{base_t / r['t']:.1f}×" for k, r in results.items()})
# 最终正确性:所有版本都与 float64 参考一致,彼此也一致
for name, o in outs.items():
np.testing.assert_allclose(o, D_ref, rtol=1e-5, atol=1e-4, err_msg=name)
np.testing.assert_allclose(outs["v3 中心化+矩阵乘"], outs["v1 广播"], rtol=1e-5, atol=1e-4)
print("✅ 四个版本与 float64 参考答案一致")
为什么 v2/v3 更快¶
中间分配:容易以为 v1 的 $(N,M,d)$ 中间数组消耗了大量内存带宽。其实上面已经证明它被融合掉了,CPU 上没有物化。在 GPU 上,XLA 通常也会把它融合进归约内核(本环境只有 CPU,没有测过)。- 优化的内核:v2 依赖
jnp.dot(X, Y.T)。矩阵乘有高度优化的实现(CPU 上是 YNNPACK/Eigen,NVIDIA GPU 上是 cuBLAS,TPU 上是 MXU),能达到接近硬件峰值的算力;而 v1 的“相减–平方–求和”只是普通的归约循环。 - 工作量更少、更匹配硬件:v2 的 FLOPs 比 v1 少(见上表),并且把问题拆成了“矩阵乘加逐元素操作”这种硬件最擅长的组合。
课程总结的优化流程:
- 基准与 JIT:从清晰易读的版本(v1)开始,加上
jax.jit; - 基准测试:
block_until_ready()加计时器(notebook 里可以用%timeit),多次运行取平均; - 性能分析:用
jax.profiler.start_trace()/stop_trace()抓 trace,找出最耗时的部分;需要硬件级分析时用 Nsight Systems 等工具; - 检查 jaxpr(可选):用
jax.make_jaxpr看 XLA 优化之前的样子。本节的例子说明,还要再看优化后的 HLO 和memory_analysis,才能判断大中间量是否真的存在; - 提出假设并重构:例如“能不能改用更直接的矩阵乘?”;
- 重新基准测试:比较新旧版本,并验证结果一致。
“最好”的实现取决于具体维度 $(N,M,d)$ 和目标硬件。比如 $d$ 很小(2 或 3 维的点云)时,矩阵乘的优势会明显缩小:本机 CPU 粗测,$d=64$ 时 v2 比 v1 快约 10 倍,$d=3$ 时只快 2–3 倍(计时随机器而异)。v3 这一步还说明,优化不能以牺牲数值正确性为代价。
🔍 补充¶
🟢 必读
- 对距离求梯度要小心 $\sqrt{0}$:$\frac{d}{dz}\sqrt{z}$ 在 $z=0$ 处是无穷大,
jax.grad遇到自身距离(对角线)会得到nan。常见解法是“双where”技巧,见练习 4;原理见 入门 03 自动微分grad §3.8,更多写法(含custom_jvp)见 入门 07 实战专题 专题 2。 - 只需要比较距离大小时(kNN、聚类分配),不要开方:直接用平方距离,省掉
sqrt,也就没有了梯度问题。 - 规模再大就要分块:可以用
lax.map(带batch_size)按行块计算,控制每块的中间量。注意lax.map最后仍会拼出完整的 $N\times M$ 结果;如果连输出本身都装不下,就要在每个块里就地归约(例如只保留每行的最小距离或 top-k),不要返回完整的 $D$。
动手练习¶
下面 4 道练习都附有参考答案和 assert。建议先自己写,再去看参考答案对照。
练习 1(jaxpr 侦探):写一个函数 primitive_histogram(f, *args),返回 f 的 jaxpr 中(含所有子 jaxpr)每种原语的出现次数。对下面的 target 运行它,并回答:cond 出现几次?custom_jvp_call 出现几次?子 jaxpr 里一共有几个 sin?
def target(x, flag):
y = lax.cond(flag, jnp.sin, jnp.cos, x)
z = jax.jit(lambda t: jax.nn.relu(jnp.sin(t)))(y)
return jnp.sum(z)
自检接口:定义 primitive_histogram(f, *args),返回「原语名 → 出现次数」的 dict 或 Counter,要递归统计所有子 jaxpr(scan 体、while 的条件和循环体、内层 jit 等)。
# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
primitive_histogram
except NameError as e:
print(f"⏳ 练习 1 尚未作答:{e}")
else:
def _probe(x): # 一个新函数:scan、while、内层 jit 各一层嵌套
def body(c, _):
return jnp.sin(c) * 2.0, None
c, _ = lax.scan(body, x, None, length=3)
y, _ = lax.while_loop(lambda s: s[1] < 5, lambda s: (jnp.cos(s[0]), s[1] + 1), (c, 0))
return jax.jit(lambda t: jnp.exp(jnp.exp(t)))(y).sum()
_h = dict(primitive_histogram(_probe, jnp.ones(4)))
_expect = {"scan": 1, "sin": 1, "mul": 1, "while": 1, "lt": 1, "cos": 1,
"add": 1, "jit": 1, "exp": 2, "reduce_sum": 1}
for _name in ["sin", "cos", "lt", "exp"]:
assert _h.get(_name, 0) == _expect[_name], (
f"'{_name}' 应出现 {_expect[_name]} 次,实际 {_h.get(_name, 0)}:是否漏掉了某种子 jaxpr?")
assert _h == _expect, f"统计结果与期望不一致:\n 实际 {_h}\n 期望 {_expect}"
print("✅ 练习 1 自检通过")
练习 2(重编译预测):先不运行,预测下面的调用序列会让 g 编译几次,然后用 CompileCounter 验证。
@jax.jit(static_argnames="mode")
def g(x, scale, mode="a"):
return x * scale if mode == "a" else x + scale
g(jnp.ones(3), 2.0) # ①
g(jnp.ones(3), 3.0) # ②
g(jnp.ones(3), jnp.float32(3.0)) # ③
g(jnp.ones(3), 2.0, mode="b") # ④
g(np.ones(3, np.float64), 2.0) # ⑤
g(jnp.ones((3,)), 2) # ⑥
自检接口:在运行 CompileCounter 实测之前,先写下 predicted_compiles = <你预测的编译次数>。自检会用同样的调用序列独立实测,并与你的预测比较。
# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
predicted_compiles
except NameError as e:
print(f"⏳ 练习 2 尚未作答:{e}")
else:
@jax.jit(static_argnames="mode")
def _g_probe(x, scale, mode="a"): # 与题面相同的函数,换个名字以便独立计数
return x * scale if mode == "a" else x + scale
with CompileCounter("_g_probe") as _cc:
_g_probe(jnp.ones(3), 2.0)
_g_probe(jnp.ones(3), 3.0)
_g_probe(jnp.ones(3), jnp.float32(3.0))
_g_probe(jnp.ones(3), 2.0, mode="b")
_g_probe(np.ones(3, np.float64), 2.0)
_g_probe(jnp.ones((3,)), 2)
assert isinstance(predicted_compiles, int), "predicted_compiles 应为整数"
assert predicted_compiles == _cc.count, (
"预测的编译次数与实测不符。逐个调用想一想:哪些参数属于缓存键(形状、dtype、weak type、静态参数的值)?")
print("✅ 练习 2 自检通过")
练习 3(融合与屏障):写一个函数 fusion_report(f, *args),返回 (fusion 条数, bytes accessed)。对 h1(t) = jnp.tanh(t * 3.0 + 1.0) ** 2 和在中间插入 lax.optimization_barrier 的 h2 分别运行,验证:① h2 的 fusion 数比 h1 多;② h2 的 bytes accessed 至少多出一次中间数组的读和写;③ 两者数值相同。
自检接口:定义 fusion_report(f, *args),返回 (fusion 条数, bytes accessed);条数是整数,统计的是优化后的 HLO。
# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
fusion_report
except NameError as e:
print(f"⏳ 练习 3 尚未作答:{e}")
else:
_h1 = lambda t: jnp.tanh(t * 3.0 + 1.0) ** 2
_h2 = lambda t: jnp.tanh(lax.optimization_barrier(t * 3.0 + 1.0)) ** 2
_t = jax.random.normal(jax.random.key(123), (256, 384))
_nbytes = _t.size * 4
(_f1, _b1), (_f2, _b2) = fusion_report(_h1, _t), fusion_report(_h2, _t)
assert isinstance(_f1, (int, np.integer)) and isinstance(_f2, (int, np.integer)), "fusion 条数应为整数"
assert _f1 >= 1, f"h1 的 fusion 条数为 {_f1}:统计的是否是优化后的 HLO?"
assert _f2 > _f1, f"插入屏障后 fusion 应更多:h1={_f1},h2={_f2}"
assert _b1 >= 2 * _nbytes, f"h1 至少要读一次输入、写一次输出({2 * _nbytes:,} 字节),实际 {_b1:,.0f}"
assert _b2 - _b1 >= 2 * _nbytes, f"h2 应多出中间数组的一次写和一次读(≥ {2 * _nbytes:,} 字节),实际多 {_b2 - _b1:,.0f}"
_f1b, _b1b = fusion_report(_h1, jnp.ones((512, 384))) # 元素数翻倍
assert _f1b == _f1 and 1.8 < _b1b / _b1 < 2.2, f"元素数翻倍时 bytes accessed 应约翻倍,实际 {_b1:,.0f} → {_b1b:,.0f}"
print("✅ 练习 3 自检通过")
练习 4(可微的安全距离):pairwise_distance_v1(X, X) 的对角线是 0,jax.grad 会得到 nan。请写一个 safe_pairwise_distance,在距离为 0 处梯度取 0,其余地方与 v1 完全一致。要求:① 前向结果与 v1 一致;② 对 loss(X) = sum(D(X, X)) 求出的梯度全部有限;③ 与 float64 下的解析梯度一致。解析梯度为 $\nabla_{x_i}\sum_{a,b}\lVert x_a-x_b\rVert = 2\sum_{j\ne i}\frac{x_i-x_j}{\lVert x_i-x_j\rVert}$。
提示:“双 where”技巧是先把 0 替换成安全值再开方,然后把结果再 where 回 0。
自检接口:定义 safe_pairwise_distance(X, Y),X 形状 (N, D)、Y 形状 (M, D),返回 (N, M) 的距离矩阵。
# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
safe_pairwise_distance
except NameError as e:
print(f"⏳ 练习 4 尚未作答:{e}")
else:
_rng = np.random.default_rng(5)
_X = _rng.normal(size=(6, 4)).astype(np.float32); _X[3] = _X[1] # 有一对重复行:非对角线上也会出现 0 距离
_Y = _rng.normal(size=(5, 4)).astype(np.float32); _Y[2] = _X[0]
_D_ref = np.linalg.norm(_X.astype(np.float64)[:, None] - _Y.astype(np.float64)[None], axis=-1)
_D = safe_pairwise_distance(jnp.asarray(_X), jnp.asarray(_Y))
assert _D.shape == (6, 5), f"输出形状应为 (6, 5),实际是 {_D.shape}"
assert np.allclose(_D, _D_ref, atol=1e-5), "前向距离不对"
_gX = jax.jit(jax.grad(lambda Z: jnp.sum(safe_pairwise_distance(Z, Z))))(jnp.asarray(_X))
assert bool(jnp.isfinite(_gX).all()), "梯度中有 nan/inf:距离为 0 处的梯度没有处理好"
_Z = _X.astype(np.float64); _diff = _Z[:, None] - _Z[None]
_nrm = np.linalg.norm(_diff, axis=-1)
_unit = np.where(_nrm[..., None] > 0, _diff / np.where(_nrm > 0, _nrm, 1.0)[..., None], 0.0)
_g_ref = 2 * _unit.sum(axis=1) # 距离为 0 的项贡献 0
assert np.allclose(_gX, _g_ref, rtol=1e-4, atol=1e-4), "梯度与解析梯度不一致"
_gY = jax.grad(lambda Y: jnp.sum(safe_pairwise_distance(jnp.asarray(_X), Y)))(jnp.asarray(_Y))
assert bool(jnp.isfinite(_gY).all()), "对 Y 求导时出现 nan/inf(Y 中有一行与 X 的某行相同)"
print("✅ 练习 4 自检通过")
本章测验¶
1. 在 JAX 0.12 中,jax.make_jaxpr(f)(x) 返回什么类型?分号左边的变量叫什么、从哪里来?
答案与解析
返回 Jaxpr。0.12 已把 ClosedJaxpr 并入 Jaxpr(jax.extend.core.ClosedJaxpr 只是别名),.jaxpr 作为兼容属性保留、返回对象自身,.consts 是常量的真实值。{ lambda a:f32[3,3]; b:f32[3]. ... } 中,分号左边是 constvars,来自被函数闭包捕获的数组;分号右边是 invars(函数参数)。Python 标量则直接成为字面量 Literal。
2. jax.jit(f).lower(x).as_text() 和 jax.jit(f).lower(x).compile().as_text() 打印的分别是什么?哪个能看到融合?
答案与解析
前者是 StableHLO(MLIR,stablehlo.* 操作),是 XLA 优化之前的结果;后者是优化后的 HLO(以 HloModule 开头),其中能看到 fusion(...) 指令、布局 {1,0} 和 __ynn_fusion 之类的后端细节。只有后者能看到融合。
3. 下面哪个调用序列会让 jax.jit(lambda v: v * 2) 编译两次?(A) f(jnp.ones(3)); f(np.ones(3, np.float64)) (B) f(1.0); f(jnp.float32(1.0)) (C) f(jnp.ones(3)); f(jnp.zeros(3))
答案与解析
(B)。Python 的 1.0 是 weak-typed float32,jnp.float32(1.0) 是 strong-typed float32,缓存键不同。(A) 在默认的 x32 模式下,float64 会被规范化成 float32,命中缓存;(C) 只是值不同。
4. v1 的 diff 在 jaxpr 中是 f32[1000,1500,64](384 MB)。怎样确定它在运行时有没有被分配?
答案与解析
看编译产物:jax.jit(f).lower(...).compile().memory_analysis().temp_size_in_bytes(中间缓冲区的大小),以及 cost_analysis()["bytes accessed"]。实际 temp 远小于 384 MB,bytes 只相当于输入加输出,说明 diff 被融合掉了,没有物化。jaxpr 本身无法回答这个问题。
5. x.T @ y 在 jit 里会不会先物化一个转置矩阵?怎样验证?
答案与解析
不会。XLA 会把转置吸收进 dot 的收缩维度(lhs_contracting_dims={0}),优化后 HLO 里没有 transpose 指令。只有单独返回 x.T 这种必须产出新的行主序数组的情况,才会真正搬数据。
6. 下面的计时代码有什么问题?t0=time.perf_counter(); y=f(x); print(time.perf_counter()-t0)(f 是刚定义的 jit 函数)
答案与解析
有两个问题:① 没有预热,第一次调用包含追踪和 XLA 编译;② 没有 block_until_ready(),异步调度会让函数在计算完成之前就返回。正确做法是先 f(x).block_until_ready() 预热,再计时 f(x).block_until_ready(),并多次运行取平均。
7. 下面哪些操作一定会等待设备计算完成?np.asarray(y)、y.item()、if y > 0:、print(y)(y 为 2000×2000 float32)、jax.block_until_ready({"a": y})
答案与解析
np.asarray(y)、y.item()(前提是 y 是标量;否则需要先取元素)、if y > 0:(前提是标量)以及 jax.block_until_ready({"a": y}) 都会同步。print(y) 不会:在 JAX 0.12 中,≥ 1 MiB 的数组只打印 Array(shape=(2000, 2000), dtype=float32),不取值。
8. 想让第二次启动脚本时跳过 XLA 编译,需要哪些配置?为什么小函数默认没有写进磁盘缓存?
答案与解析
在脚本开头、第一次编译之前设置 jax.config.update("jax_compilation_cache_dir", path)(或环境变量 JAX_COMPILATION_CACHE_DIR)。jax_persistent_cache_min_compile_time_secs 默认是 1.0 秒,编译时间低于它的函数不会写盘,因为对小函数来说,读磁盘不一定比重新编译快。演示时可以把它设成 0。缓存键包含 jax/jaxlib 版本、XLA flags 和设备,任何一项变了都会 MISS。
本章更新说明¶
下面汇总本章在 JAX 0.12 下需要注意的写法和结论,每条都在正文对应位置用 assert 验证过。
| 编号 | 小节 | 要点 | 位置 |
|---|---|---|---|
| 1 | 2.1 / 2.5 / 2.6 / 2.7 / 2.8 | 生成随机数 key 用 jax.random.key(0)(类型化 key)配合 jax.random.split,不再用旧的 jax.random.PRNGKey(0) | 2.1 首个 ❌/✅ 单元,各节代码 |
| 2 | 2.1 | trace 文件不是 profile.pb,而是 <主机>.xplane.pb 和 <主机>.trace.json.gz,可选 perfetto_trace.json.gz;TensorBoard 需要另装 xprof 插件 | 2.1 文件列表单元后 |
| 3 | 2.2 | cond 的 jaxpr 里,谓词先 convert_element_type 成 int32,参数是 branches=(false_fun, true_fun) | 2.2 lax.cond 单元后 |
| 4 | 2.2 | 在 jit 里对被追踪值做 Python if 会直接抛 TracerBoolConversionError,不会导致重编译;重编译由缓存键变化触发 | 2.2「jaxpr 为什么重要」 |
| 5 | 2.3 | 编译链路是 jaxpr → StableHLO(MLIR)→ XLA HLO;HLO 的全称是 High Level Operations | 2.3 开头 |
| 6 | 2.3 | XLA 默认不把 (x+y)-x 化简成 y(IEEE 下不保值);x*1.0、x+0.0 会被化简 | 2.3 代数简化 ❌/✅ 单元 |
| 7 | 2.3 | CPU 后端不只经由 LLVM 生成代码:jaxlib 0.12 还会用 YNNPACK 库融合(__ynn_fusion),本环境中它在“闭包常量 dot + reduce”时会悄悄算错 | 2.3 案例 |
| 8 | 2.5 | 代码块 A(普通参数上的 Python if)第一次调用就抛 TracerBoolConversionError,谈不上“第三次调用重新编译” | 2.5 第一个 ❌ 单元 |
| 9 | 2.5 | 默认 x32 模式下,float64 输入会被规范化为 float32,不会重编译;weak type(1 / 1.0 / jnp.float32(1))才会 | 2.5 策略 2 |
| 10 | 2.5 | 循环里新建 jit(partial(...)) 每次都会编译;partial 只有创建一次再复用才有效 | 2.5 策略 4 |
| 11 | 2.6 | x、y 用同一个 key 生成会得到两个完全相同的数组,应该先 split | 2.6 ❌ 单元 |
| 12 | 2.6 | 不用 jit 时,中间结果是设备上的 jax.Array(逐操作调度),不是 NumPy 数组 | 2.6 eager 对比 |
| 13 | 2.7 | 没有预热的“简单计时”,第一次调用包含追踪和编译,测到的不只是调度时间 | 2.7 ❌ 计时单元 |
| 14 | 2.7 | jax.block_until_ready() 不带参数会抛 TypeError;对 pytree 只等待它自己的数组叶子,不会等所有设备上的所有计算 | 2.7 ❌/✅ 单元 |
| 15 | 2.7 | ≥ 1 MiB 的数组 print/repr 只显示形状和 dtype,不取值、不阻塞 | 2.7 隐式同步 |
| 16 | 2.8 | X、Y 用同一个 key 生成时 X == Y[:1000],测试数据退化 | 2.8 ❌ 单元 |
| 17 | 2.8 | v1 的 9600 万元素(384 MB)中间数组只存在于 jaxpr;XLA 融合后 temp_size 远小于 384 MB,没有物化 | 2.8 v1 后 |
| 18 | 2.8 | v2 只靠 maximum(0, ·) 不够:远离原点的数据会出现灾难性抵消(误差 > 1e-2),先中心化即可修复(v3) | 2.8 v3 |
| 19 | 2.6 | 融合不是 XLA 分析 jaxpr 决定的:jaxpr 先 lower 成 StableHLO,融合在 XLA 的 HLO 上完成(见 2.3 的 before/after_optimizations 对比) | 2.6 融合原理段落 |
本章小结¶
- 先测量,再优化:用
jax.profiler.trace抓 trace(先预热、每步block_until_ready),用 Perfetto 或 TensorBoard+XProf 查看;用TraceAnnotation标宿主端时间段,用named_scope标 HLO 操作名;用save_device_memory_profile看内存被谁占着(CPU 上也能用)。 - jaxpr 看的是“JAX 认为要算什么”:
invars/constvars/eqns/outvars都可以程序化遍历;高阶原语(jit、cond、custom_jvp_call……)里嵌着子 jaxpr。jaxpr 里的大中间量不代表运行时真的会分配这么大的内存。 - 优化后的 HLO 看的是“XLA 实际算什么”:
lower().as_text()是 StableHLO,compile().as_text()是优化后的 HLO;cost_analysis()和memory_analysis()给出 FLOPs、访存量和中间缓冲区大小;XLA_FLAGS=--xla_dump_to能转储每个阶段的产物。编译器也会出 bug,数值可疑时要看 HLO 并用 float64 对拍。 - 布局:JAX 默认行主序(HLO 中写作
{1,0})。jit 里的x.T @ y不会物化转置,单独的x.T会。Fortran 序的 NumPy 数组进入 JAX 时要付一次转换的代价。 - 重编译:缓存键由形状、dtype、weak type、静态参数的值和函数对象身份组成;用
jax_log_compiles/jax_explain_cache_misses诊断;持久化编译缓存可以让新进程直接命中。 - 融合是减少访存的主要手段:在 HLO 里数 fusion,用
bytes accessed量化收益。归约和optimization_barrier会形成内核边界。 - 异步调度:调用会立即返回;计时必须先预热,再
block_until_ready。.item()、np.asarray、Python 控制流会隐式同步,大数组的print不会。 - 优化流程:实现、计时、看 jaxpr/HLO、提出假设、重构、再计时,并且每一步都要验证正确性。2.8 节中 v2 的速度提升伴随着数值风险,v3 用中心化把它修复了。
速查表¶
| API | 作用 |
|---|---|
jax.profiler.trace(dir, create_perfetto_trace=True) | 上下文管理器形式的 trace,生成 xplane.pb / trace.json.gz / perfetto_trace.json.gz |
jax.profiler.start_trace(dir) / stop_trace() | 函数形式的 trace 开始/结束 |
jax.profiler.TraceAnnotation(name) | 在宿主时间线上标注一段区间 |
jax.profiler.StepTraceAnnotation(name, step_num=i) | 按训练步标注,TensorBoard 按 step 统计 |
jax.named_scope(name) | 给 jaxpr/HLO 的操作名加前缀(op_name 元数据) |
jax.profiler.save_device_memory_profile(path) | 保存设备内存快照(pprof 格式) |
jax.profiler.ProfileData.from_file(xplane) | 在 Python 里解析 xplane.pb |
jax.make_jaxpr(f, static_argnums=...)(*args) | 得到带 consts 的 Jaxpr(0.12 中 ClosedJaxpr 已并入 Jaxpr) |
cj.jaxpr.invars / constvars / eqns / outvars、cj.consts | jaxpr 的结构字段 |
jax.extend.core.jaxprs_in_params(eqn.params) | 取出高阶原语里的子 jaxpr |
jax.extend.core.jaxpr_as_fun(cj) | 把 jaxpr 当作函数执行 |
jax.jit(f).lower(*args).as_text() | StableHLO 文本(debug_info=True 带源码位置) |
lowered.compile().as_text() | 优化后的 HLO |
compiled.cost_analysis() | dict:flops / transcendentals / bytes accessed |
compiled.memory_analysis() | argument/output/temp_size_in_bytes 等 |
XLA_FLAGS=--xla_dump_to=DIR --xla_dump_hlo_as_text | 转储各阶段 HLO(进程启动前设置) |
XLA_FLAGS=--xla_disable_hlo_passes=fusion | 关闭融合 pass,用于对比 |
XLA_FLAGS=--xla_cpu_experimental_ynn_fusion_type= | 关闭 CPU 上的 YNNPACK 库融合(排查 jaxlib 0.12 的 bug) |
arr.format.layout | 查看数组的物理布局 |
with jax.log_compiles(True): / jax_log_compiles | 打印每一次 XLA 编译 |
jax.config.update("jax_explain_cache_misses", True) | 解释缓存未命中的原因 |
jax.config.update("jax_compilation_cache_dir", d) | 启用持久化编译缓存 |
jax_persistent_cache_min_compile_time_secs | 写盘的编译时间阈值(默认 1 s) |
@jax.jit(static_argnums=...) | 静态参数(装饰器工厂写法,0.12 可用) |
lax.optimization_barrier(x) | 阻止 XLA 跨越这个点做融合或重排 |
x.block_until_ready() / jax.block_until_ready(pytree) | 等待计算完成(必须传参数) |
x.is_ready() | 非阻塞地查询计算是否已完成 |
jax_cpu_enable_async_dispatch | 控制 CPU 后端是否异步调度 |
练习 1¶
题目见正文「动手练习」。
# 参考答案:练习 1
def primitive_histogram(f, *args):
def walk(jaxpr, c):
for eqn in jaxpr.eqns:
c[eqn.primitive.name] += 1
for sub in je.core.jaxprs_in_params(eqn.params):
walk(sub, c)
return c
return walk(jax.make_jaxpr(f)(*args).jaxpr, Counter())
def target(x, flag):
y = lax.cond(flag, jnp.sin, jnp.cos, x)
z = jax.jit(lambda t: jax.nn.relu(jnp.sin(t)))(y)
return jnp.sum(z)
hist = primitive_histogram(target, jnp.ones(4), True)
print(dict(hist))
assert hist["cond"] == 1
assert hist["custom_jvp_call"] == 1 # relu
assert hist["sin"] == 2 # cond 的真分支 1 个 + jit 内 1 个
assert hist["jit"] == 2 # 显式 jit 1 个 + relu 内部 1 个
练习 2¶
题目见正文「动手练习」。
# 参考答案:练习 2
predicted_compiles = 4 # 先写下预测,再用 CompileCounter 实测
@jax.jit(static_argnames="mode")
def g(x, scale, mode="a"):
return x * scale if mode == "a" else x + scale
with CompileCounter("g") as cc:
g(jnp.ones(3), 2.0) # ① 新:f32[3], f32[] weak, mode=a
g(jnp.ones(3), 3.0) # ② 命中:值变了,但类型没变
g(jnp.ones(3), jnp.float32(3.0)) # ③ 新:scale 不再是 weak type
g(jnp.ones(3), 2.0, mode="b") # ④ 新:静态参数的值变了
g(np.ones(3, np.float64), 2.0) # ⑤ 命中:x32 模式下 float64 被规范化为 float32
g(jnp.ones((3,)), 2) # ⑥ 新:scale 变成 int32 weak
print("编译次数:", cc.count)
for r in cc.records:
print(" ", r.split(" with global shapes and types ")[1].split(". Argument")[0])
assert cc.count == predicted_compiles == 4
练习 3¶
题目见正文「动手练习」。
# 参考答案:练习 3
def fusion_report(f, *args):
c = jax.jit(f).lower(*args).compile()
return count_fusions(c.as_text()), c.cost_analysis()["bytes accessed"]
h1 = lambda t: jnp.tanh(t * 3.0 + 1.0) ** 2
h2 = lambda t: jnp.tanh(lax.optimization_barrier(t * 3.0 + 1.0)) ** 2
t_in = jax.random.normal(jax.random.key(7), (512, 512))
(f1, b1), (f2, b2) = fusion_report(h1, t_in), fusion_report(h2, t_in)
print(f"h1: fusion={f1}, bytes={b1:,.0f} | h2: fusion={f2}, bytes={b2:,.0f}")
inter = t_in.size * 4
assert f2 > f1
assert b2 - b1 >= 2 * inter # 中间数组多一次写、一次读
np.testing.assert_allclose(h1(t_in), h2(t_in), rtol=1e-6)
练习 4¶
题目见正文「动手练习」。
# 参考答案:练习 4
def safe_pairwise_distance(X, Y):
sq = jnp.sum((X[:, None, :] - Y[None, :, :]) ** 2, axis=-1)
is_zero = sq == 0.0
safe_sq = jnp.where(is_zero, 1.0, sq) # 第一次 where:让 sqrt 的输入远离 0
return jnp.where(is_zero, 0.0, jnp.sqrt(safe_sq)) # 第二次 where:把结果放回 0
Xs = jax.random.normal(jax.random.key(11), (40, 3))
loss_naive = lambda Z: jnp.sum(pairwise_distance_v1(Z, Z))
loss_safe = lambda Z: jnp.sum(safe_pairwise_distance(Z, Z))
g_naive = jax.jit(jax.grad(loss_naive))(Xs)
g_safe = jax.jit(jax.grad(loss_safe))(Xs)
print("朴素版梯度含 nan:", bool(jnp.isnan(g_naive).any()), "| 安全版梯度全部有限:", bool(jnp.isfinite(g_safe).all()))
# float64 解析梯度
Z64 = np.asarray(Xs, np.float64)
diff64 = Z64[:, None, :] - Z64[None, :, :]
nrm = np.linalg.norm(diff64, axis=-1); np.fill_diagonal(nrm, np.inf)
g_ref = 2 * (diff64 / nrm[..., None]).sum(axis=1)
assert bool(jnp.isnan(g_naive).any())
assert bool(jnp.isfinite(g_safe).all())
np.testing.assert_allclose(safe_pairwise_distance(Xs, Xs), pairwise_distance_v1(Xs, Xs), atol=1e-6)
np.testing.assert_allclose(g_safe, g_ref, rtol=1e-4, atol=1e-4)
print("✅ 安全版前向一致、梯度与 float64 解析解一致")