14.2 CPU 与 GPU 的差异
站内 /graphics-api-modern-comparison/ 那篇从工程与生态的角度比 API;这一节要的是数值:同一份几何、同一个光照公式、同一套顶点属性,把它交给 CPU 定点与交给浏览器 WebGL2,结果会差多少?差在哪儿?只说「GPU 与 CPU 有差异」是没有信息量的——差异必须能归因,否则既不能修,也不能声明容差。
三类差异与三个可分离实验
差异可以拆成三类,每一类配一个只动一个变量的实验,其余全部冻结:
| 类别 | 唯一变量 | 可分离实验 | 量什么 |
|---|---|---|---|
| ① 光栅化规则 | 覆盖判据 / 屏幕坐标位数 | 三边 >= 0 ↔ 三边 > 0 ↔ 顶左规则;Q6 逐级量化到 1/2^k px | 覆盖差、重复覆盖、2×2 微四边形 |
| ② 插值方式 | 仿射 ↔ 透视校正 | 同一组边函数权重,只换插值公式 | 法线差、MAE、PSNR |
| ③ 浮点精度 | Q6/Q16.16 ↔ float32 | 几何、覆盖、插值方式全同,只换数的表示 | MAE、PSNR、覆盖归属差 |
「可分离」的含义是:任何一个实验里,只有一行代码在变。做不到这一点,量出来的差值就是三个原因混在一起的总和,无法归因。
实验一:覆盖规则与子像素精度
真实场景上三种规则给出的覆盖完全相同(各 43731 像素,覆盖差 0),差别只在「重复覆盖」的计数:>= 0 与顶左规则都是 3538 个像素被两个三角形覆盖过,> 0 是 3537。真实场景量不出覆盖差别,是因为没有一条共享边正好穿过像素格心——所以还要一个 2×2 像素的微四边形,把对角线故意放在两个格心上:inclusive 覆盖 4 个像素但 2 个重复覆盖,exclusive 覆盖 2 个、留下 2 个空洞,只有顶左规则给出 4 个覆盖、0 重复、0 空洞。
子像素精度是另一根轴:屏幕 Q6 坐标每少一位,覆盖就往「量化后的位置」漂一点。1/1 px 差 179 个像素,1/16 px(OpenGL 只保证这个)差 10 个,1/64 px(本节点位)差 0。
实验二与实验三:插值方式、数的表示
透视校正的公式是 a = Σ(w_i·a_i/w_i) / Σ(w_i/w_i),屏幕空间仿射是 a = Σ(w_i·a_i) / Σ w_i。两者只在近处大三角形上分得出差别:本场景的裁剪空间 w 跨度 18.08×,仿射与透视的法线平均差 299.5 Q16.16(约 0.0046),最大差 3635(0.0555)。这是一类真实存在的、不来自精度的差异——即使两侧都是无限精度的实数,它也在。
第三类才是数的表示。CPU 走 Q6(屏幕)+ Q16.16(深度与法线)+ 8 位通道,中间量全部整数、移位截断;GPU 的顶点与片元着色器走 float32。两者的几何与覆盖完全一样(共用同一份边函数权重),唯一变量是浮点格式。逐位一致在数学上不可能,所以这一类只能用声明容差收口。
{
"name": "lantern-host-difference",
"subpixelSweepBits": [0, 1, 2, 3, 4, 5, 6],
"subpixelOpenGlGuaranteeBits": 4,
"tolerance": {
"maeMax": 1.0,
"psnrMinDb": 30.0,
"coverageDiffPermilleMax": 20.0,
"significantChannelDiff": 2
},
"negativeHalfColor": true,
"note": "阈值口径取自 BRIEF §10.1:MAE <= 1.0 / PSNR >= 30 dB / 覆盖归属差异 <= 20‰"
}
三条容差不是拍的:MAE ≤ 1.0 卡住「平均每个通道差不到 1 个灰阶」,PSNR ≥ 30 dB 卡住「差异不集中在少数像素上」,覆盖归属差异 ≤ 20‰ 卡住「可见性本身有没有漂」。significantChannelDiff = 2 是「显著差异」的门槛——1 个灰阶的差在 8 位量化里本来就无法避免。
完整脚本
#!/usr/bin/env python3
# file: lantern/sim/cg_trace_14_2_difference.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_14_2_difference.py
"""14.2《CPU 与 GPU 的差异》:把「同一场景、同一光照公式、同一套顶点属性」在 CPU 定点与
浏览器 WebGL2 之间的差异**拆成可归因的三类**,每类配一个只动一个变量的可分离实验。
① **光栅化规则**——覆盖判据从三边 `>= 0` 换成三边 `> 0`(无补偿的半开区间)与按顶左
规则逐边选,真实场景上量覆盖差,再用一个 2×2 像素的微四边形(对角线正好穿过格心)
把三种规则逼出来;子像素上把屏幕 Q6 坐标逐级量化到 1/2^k 像素后重算覆盖,k=6 是本
节点位(1/64 px),k=4 是 OpenGL 只保证的 1/16 px。
② **插值方式**——屏幕空间仿射 `Σ w_i·n_i / Σ w_i` 对透视校正 `Σ (w_i·n_i/w_i) / Σ (w_i/w_i)`。
③ **浮点精度**——CPU 定点(Q6 + Q16.16 + `>> 16`)对 float32:几何、覆盖、插值方式全同。
三条声明容差取自 BRIEF §10.1:MAE ≤ 1.0、PSNR ≥ 30 dB、覆盖归属差异 ≤ 20‰,并配负例
(片元颜色 ×1/2)证明判据有区分力。产物见 `out/cg_trace_14_2_*.{json,csv,rgb}`。
"""
import json
import os
import sys
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cglib # noqa: E402
import cgshade # noqa: E402
import render_shade # noqa: E402
import cg_trace_14_common as C # noqa: E402
CONFIG = os.path.join(ROOT, "config", "cg_trace_14_2_difference.json")
OUT = os.path.join(ROOT, "out")
# 卷二逐像素基线(out/cg_shade_baseline.json):本节 CPU 仿射参考必须逐字节复现它
SHADE_BASELINE = "0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de"
def main():
spec = cglib.load_spec()
cfg = json.load(open(CONFIG, encoding="utf-8"))
width, height = spec["width"], spec["height"]
bg = spec["backgroundQ8"]
os.makedirs(OUT, exist_ok=True)
tris, ws, _total, dropped = C.build_scene(spec, width, height)
assert tris == render_shade.build_scene_shaded(spec)[0], "场景构建与卷二必须逐字同构"
# CPU 定点 · 屏幕空间仿射:必须逐字节复现卷二逐像素基线
shade = C.make_shader(spec)
ref, _rd, ref_cov, _rc = cgshade.rasterize_shaded(tris, width, height, bg, shade)
ref_digest = C.digest_u8(ref)
covered, owner, W0, W1, W2, _depth, cover_count, culled = C.rasterize(tris, width, height)
cov_match = bool(np.array_equal(covered, ref_cov))
base_covered = int(covered.sum())
n_aff, a_aff = C.attributes_for_pixels(tris, ws, owner, W0, W1, W2, "affine")
n_per, a_per = C.attributes_for_pixels(tris, ws, owner, W0, W1, W2, "persp")
img_affine = C.shade_normals(spec, n_aff, a_aff, owner, bg)
img_persp = C.shade_normals(spec, n_per, a_per, owner, bg)
img_f32 = C.shade_float32(spec, tris, owner, W0, W1, W2, bg)
assert C.digest_u8(img_affine) == ref_digest, "自研光栅化器 + 逐像素着色必须复现卷二基线"
m = owner >= 0
d_n = np.abs(n_aff - n_per)[m]
ndiff = {"meanAbsQ16": round(float(d_n.mean()), 4), "maxAbsQ16": int(d_n.max()),
"maxAbsOfOne": round(float(d_n.max()) / C.ONE, 6),
"pixelsWithAnyDiff": int((d_n.max(axis=1) > 0).sum())}
# ---- ① 光栅化规则 ----
cov_excl, _oe, *_e, cnt_excl, _ce = C.rasterize(tris, width, height, rule="exclusive")
cov_tl, own_tl, *_t, cnt_tl, _ct = C.rasterize(tris, width, height, rule="topleft")
rules = {
"inclusive": {"covered": base_covered, "doubleCovered": int((cover_count > 1).sum())},
"exclusive": {"covered": int(cov_excl.sum()),
"coverageDiffPixels": int((cov_excl ^ covered).sum()),
"doubleCovered": int((cnt_excl > 1).sum())},
"topleft": {"covered": int(cov_tl.sum()),
"coverageDiffPixels": int((cov_tl ^ covered).sum()),
"ownerDiffPixels": int((own_tl != owner).sum()),
"doubleCovered": int((cnt_tl > 1).sum())},
"microQuad": C.micro_quad(),
}
# ---- ① 子像素精度扫描 ----
sweep = []
for k in cfg["subpixelSweepBits"]:
cov_k, *_r, cnt_k, _ck = C.rasterize(tris, width, height, keep_bits=k)
diff = int((cov_k ^ covered).sum())
sweep.append({"keepBits": k, "resolutionPx": f"1/{1 << k}", "covered": int(cov_k.sum()),
"coverageDiffPixels": diff,
"coverageDiffPermilleOfCovered": round(diff * 1000.0 / base_covered, 4),
"doubleCovered": int((cnt_k > 1).sum())})
og = cfg["subpixelOpenGlGuaranteeBits"]
cov_gl = C.rasterize(tris, width, height, keep_bits=og)[0]
sub_at_gl = next(r for r in sweep if r["keepBits"] == og)
# 第 14.1 节那 4 个分歧像素在两类实验里的表现
probe = os.path.join(OUT, "cg_trace_14_1_two_paths.json")
diverg = json.load(open(probe, encoding="utf-8"))["divergent"] if os.path.isfile(probe) else []
probes = [{"px": r["px"], "py": r["py"],
"exclusiveFlipped": bool(cov_excl[r["py"], r["px"]] != covered[r["py"], r["px"]]),
"topLeftFlipped": bool(cov_tl[r["py"], r["px"]] != covered[r["py"], r["px"]]),
"subpixel1_16Flipped": bool(cov_gl[r["py"], r["px"]] != covered[r["py"], r["px"]])}
for r in diverg]
# ---- ② / ③ 度量 ----
metrics = {"affine_vs_persp": C.compare(img_affine, img_persp, bg),
"affine_vs_float32": C.compare(img_affine, img_f32, bg),
"persp_vs_float32": C.compare(img_persp, img_f32, bg)}
metrics["affine_vs_halfcolor"] = C.compare(img_affine, np.clip(img_affine // 2, 0, 255), bg)
tol = cfg["tolerance"]
af, ap = metrics["affine_vs_float32"], metrics["affine_vs_persp"]
neg = metrics["affine_vs_halfcolor"]
wvals = [w for wv in ws for w in wv]
stats = {
"width": width, "height": height,
"scene": {"trianglesDrawn": len(tris), "dropped": dropped, "culled": int(culled)},
"clipWRange": {"minQ16": min(wvals), "maxQ16": max(wvals),
"ratio": round(max(wvals) / min(wvals), 4)},
"cpuAffine": {"coveredPixels": base_covered,
"coveragePermilleOfCanvas": round(base_covered * 1000.0 / (width * height), 4),
"sha256": ref_digest, "matchesVolume2Baseline": ref_digest == SHADE_BASELINE,
"coverageMatchesOwnerRasterizer": cov_match},
"normalDifferenceAffineVsPersp": ndiff,
"class1_rasterRules": {"rules": rules, "subpixelSweep": sweep, "divergentPixelProbes": probes},
"metrics": metrics, "tolerance": tol,
"verdict": {
"affine_vs_float32_within_mae": af["mae"] <= tol["maeMax"],
"affine_vs_float32_within_psnr": af["psnrDb"] >= tol["psnrMinDb"],
"affine_vs_float32_within_coverage": af["coverageDiffPermille"] <= tol["coverageDiffPermilleMax"],
"affine_vs_persp_within_mae": ap["mae"] <= tol["maeMax"],
"affine_vs_persp_within_psnr": ap["psnrDb"] >= tol["psnrMinDb"],
"negative_rejected": (neg["mae"] > tol["maeMax"] and neg["psnrDb"] < tol["psnrMinDb"]),
},
}
with open(os.path.join(OUT, "cg_trace_14_2_difference.json"), "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
fh.write("\n")
for name, img in (("affine", img_affine), ("persp", img_persp), ("f32", img_f32)):
C.save_rgb(os.path.join(OUT, f"cg_trace_14_2_{name}.rgb"), img)
# W 层页面用的顶点属性:每顶点 10 个整数(屏幕 Q6 + 深度 + 法线 + albedo + 1/w)
with open(os.path.join(OUT, "cg_trace_14_2_rtris.json"), "w", encoding="utf-8") as fh:
json.dump(C.w_page(spec, tris, ws, width, height, bg), fh, separators=(",", ":"))
cglib.write_csv(
os.path.join(OUT, "cg_trace_14_2_metrics.csv"),
["pair", "diff_pixels", "diff_permille", "significant_pixels", "mae", "psnr_db",
"max_channel_diff", "coverage_diff_pixels", "coverage_diff_permille"],
[[n, v["diffPixels"], v["diffPermille"], v["significantPixels"], v["mae"], v["psnrDb"],
v["maxChannelDiff"], v["coverageDiffPixels"], v["coverageDiffPermille"]]
for n, v in metrics.items()])
cglib.write_csv(
os.path.join(OUT, "cg_trace_14_2_subpixel.csv"),
["keep_bits", "resolution_px", "covered", "coverage_diff_pixels",
"coverage_diff_permille_of_covered", "double_covered"],
[[r["keepBits"], r["resolutionPx"], r["covered"], r["coverageDiffPixels"],
r["coverageDiffPermilleOfCovered"], r["doubleCovered"]] for r in sweep])
cglib.write_csv(
os.path.join(OUT, "cg_trace_14_2_rules.csv"),
["rule", "covered", "coverage_diff_pixels", "owner_diff_pixels", "double_covered"],
[[k, v["covered"], v.get("coverageDiffPixels", 0), v.get("ownerDiffPixels", 0),
v["doubleCovered"]] for k, v in rules.items() if k != "microQuad"])
print("== 14.2 CPU 与 GPU 的差异 ==")
print(f"几何 : {len(tris)} 个三角形绘制(剔除 {culled},丢弃 {dropped})")
print(f"裁剪空间 w : {min(wvals)} ~ {max(wvals)} Q16.16"
f"(跨度 {stats['clipWRange']['ratio']}×)")
print(f"CPU 仿射基线 : {base_covered} 覆盖 sha256 {ref_digest[:16]}…"
f" 复现卷二基线 {stats['cpuAffine']['matchesVolume2Baseline']}"
f" 自研光栅器覆盖一致 {cov_match}")
print("① 光栅化规则(真实场景 320×240):")
print(f" 三边 >= 0 : 覆盖 {base_covered} 重复覆盖 {rules['inclusive']['doubleCovered']}")
print(f" 三边 > 0 : 覆盖 {rules['exclusive']['covered']}"
f" 覆盖差 {rules['exclusive']['coverageDiffPixels']}"
f" 重复覆盖 {rules['exclusive']['doubleCovered']}")
print(f" 顶左规则 : 覆盖 {rules['topleft']['covered']}"
f" 覆盖差 {rules['topleft']['coverageDiffPixels']}"
f" 归属差 {rules['topleft']['ownerDiffPixels']}"
f" 重复覆盖 {rules['topleft']['doubleCovered']}")
print("① 光栅化规则(2×2 微四边形,对角线穿过格心):")
for k, v in rules["microQuad"].items():
print(f" {k:10s} 覆盖 {v['covered']} 空洞 {v['holes']}"
f" 重复覆盖 {v['doubleCovered']} 覆盖次数网格 {v['coverGrid']}")
print(f"① 子像素精度扫描(覆盖差 / {base_covered} 个覆盖像素):")
print(" " + " | ".join(f"1/{1 << r['keepBits']} px {r['coverageDiffPixels']}"
f"({r['coverageDiffPermilleOfCovered']:.4f}‰)" for r in sweep[:4]))
print(" " + " | ".join(f"1/{1 << r['keepBits']} px {r['coverageDiffPixels']}"
f"({r['coverageDiffPermilleOfCovered']:.4f}‰)" for r in sweep[4:]))
print(f" OpenGL 只保证 1/16 px:差 {sub_at_gl['coverageDiffPixels']} 像素;"
f"14.1 的 4 个分歧像素翻面情况 " +
" ".join(f"({r['px']},{r['py']})={r['subpixel1_16Flipped']}" for r in probes))
print(f"② 法线(仿射 vs 透视): 平均差 {ndiff['meanAbsQ16']} Q16.16,最大 {ndiff['maxAbsQ16']}"
f"({ndiff['maxAbsOfOne']} of 1.0),有差像素 {ndiff['pixelsWithAnyDiff']}")
for name, v in metrics.items():
print(f" {name:22s}: 差 {v['diffPixels']:5d} 像素({v['diffPermille']:8.4f}‰)"
f" 显著(>2) {v['significantPixels']:5d} MAE {v['mae']:8.4f}"
f" PSNR {v['psnrDb']:7.2f} dB 最大通道差 {v['maxChannelDiff']:3d}"
f" 覆盖差 {v['coverageDiffPixels']}")
print(f"判据 : {json.dumps(stats['verdict'], ensure_ascii=False)}")
return 0
if __name__ == "__main__":
sys.exit(main())
跑一次:
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_14_2_difference.py
== 14.2 CPU 与 GPU 的差异 ==
几何 : 896 个三角形绘制(剔除 290,丢弃 0)
裁剪空间 w : 167561 ~ 3029109 Q16.16(跨度 18.0776×)
CPU 仿射基线 : 43731 覆盖 sha256 0d997343ca547ed5… 复现卷二基线 True 自研光栅器覆盖一致 True
① 光栅化规则(真实场景 320×240):
三边 >= 0 : 覆盖 43731 重复覆盖 3538
三边 > 0 : 覆盖 43731 覆盖差 0 重复覆盖 3537
顶左规则 : 覆盖 43731 覆盖差 0 归属差 0 重复覆盖 3538
① 光栅化规则(2×2 微四边形,对角线穿过格心):
inclusive 覆盖 4 空洞 0 重复覆盖 2 覆盖次数网格 [2, 1, 1, 2]
exclusive 覆盖 2 空洞 2 重复覆盖 0 覆盖次数网格 [0, 1, 1, 0]
topleft 覆盖 4 空洞 0 重复覆盖 0 覆盖次数网格 [1, 1, 1, 1]
① 子像素精度扫描(覆盖差 / 43731 个覆盖像素):
1/1 px 179(4.0932‰) | 1/2 px 76(1.7379‰) | 1/4 px 48(1.0976‰) | 1/8 px 30(0.6860‰)
1/16 px 10(0.2287‰) | 1/32 px 2(0.0457‰) | 1/64 px 0(0.0000‰)
OpenGL 只保证 1/16 px:差 10 像素;14.1 的 4 个分歧像素翻面情况 (142,94)=False (177,94)=False (69,105)=False (307,114)=True
② 法线(仿射 vs 透视): 平均差 299.5069 Q16.16,最大 3635(0.055466 of 1.0),有差像素 43730
affine_vs_persp : 差 32830 像素(427.4740‰) 显著(>2) 2166 MAE 0.4008 PSNR 56.07 dB 最大通道差 5 覆盖差 0
affine_vs_float32 : 差 43402 像素(565.1302‰) 显著(>2) 0 MAE 0.4950 PSNR 54.24 dB 最大通道差 2 覆盖差 0
persp_vs_float32 : 差 39795 像素(518.1641‰) 显著(>2) 5873 MAE 0.6754 PSNR 51.54 dB 最大通道差 5 覆盖差 0
affine_vs_halfcolor : 差 76800 像素(1000.0000‰) 显著(>2) 76800 MAE 25.0472 PSNR 20.16 dB 最大通道差 110 覆盖差 33069
判据 : {"affine_vs_float32_within_mae": true, "affine_vs_float32_within_psnr": true, "affine_vs_float32_within_coverage": true, "affine_vs_persp_within_mae": true, "affine_vs_persp_within_psnr": true, "negative_rejected": true}
五个数字怎么读
① 的两种规则在真实场景上覆盖差 0,只在微四边形上分出胜负。 这恰恰说明「覆盖规则」这一类差异平时是隐形的:896 个三角形、43731 个覆盖像素里没有一个共享边穿过格心,直到你人为造出穿过格心的对角线,>= 0 与顶左规则的重复覆盖才从 2 掉到 0。判据要能测出「平时测不出」的差别,就必须造出极端样例,否则容差只是自我安慰。
② 的仿射对透视:MAE 0.4008、PSNR 56.07 dB、最大通道差 5。 差 32830 个像素(427‰)却有 43730 个像素的法线不同——说明大量像素差在 1 个灰阶以内,被 8 位量化吃掉了。透视校正不是「更准一点点」,它在近处是定性的差别:w 跨度 18.08×,仿射插值会把近处的纹理/法线明显拉错。
③ 的定点对 float32:MAE 0.4950、PSNR 54.24 dB、最大通道差只有 2,显著像素 0。 差 43402 个像素(565‰),但没有一个像素的通道差超过 2——分布是「遍地 1 个灰阶」,不是「少数像素差很多」。这正是定点舍入的特征,也是为什么容差里 MAE 与 PSNR 要同时给:单看 MAE 会以为差异很大,单看 PSNR 会以为差异很小。
负例:片元颜色整体 ×1/2,MAE 25.0472、PSNR 20.16 dB、覆盖差 33069。 同一组容差(MAE ≤ 1.0、PSNR ≥ 30 dB)当场拒绝它。判据必须能拒绝一个明显错的输入,否则「通过」不说明任何事。
CPU 仿射基线逐字节复现卷二:0d997343ca547ed5…,覆盖与自研光栅器一致。 这一步是整节的锚:如果自研光栅化器不能逐字节复现卷二,那么后面所有「差异」都可能只是我自己写错了。
W 层核对:浏览器里的同一条管线
W 层把同一份顶点属性(S 层落盘的 out/cg_trace_14_2_rtris.json)喂给 WebGL2,顶点着色器把 n/w、albedo/w、1/w 作为 varying 交给硬件做仿射插值,片元里再做一次除法重建透视校正——这样顶点位置与 CPU 逐位同源,硬件插值的差异被隔离到「数的表示」这一项:
// snippet: 节选自 lantern/web/verify_trace14.mjs
const p3 = stats(gl, cpuAffine, covGl); // ③ 只差数的表示(两侧都是仿射)
const p2 = stats(gl, glP, covGlP); // ② 只差插值方式(两侧都在 GPU)
const p3iso = stats(gl, cpuF32, covGl); // ③ 隔离项:GPU float32 vs CPU float32
// 本机 headless 后端是 ANGLE/SwiftShader **软件**实现,不是真实 GPU:
// 差异不得归因于硬件。逐位一致在数学上不可能,故只用对舍入不敏感的统计量。
实测:GPU 仿射对 CPU 定点仿射 MAE 0.5067、PSNR 47.03 dB、覆盖归属差 6 像素(0.0781‰),三条容差全过;隔离项 GPU float32 对 CPU float32 的 MAE 掉到 0.0191,证明残余差异确实来自数的表示而不是着色器写错了。25 个显著差异像素里有 19 个是「两边都覆盖、但选中的三角形不同」——这是深度并列时的裁决差,不是着色差。负例(片元颜色 ×0.5)MAE 21.3528、PSNR 18.90 dB,被同一组容差拒绝。后端如实打印为 ANGLE/SwiftShader 软件实现,本节的任何结论都不涉及真实硬件。
阅读导航:上一节:14.1 两条路径的等价与分歧 · 下一节:14.3 一致性的判据 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。