☰
Mobile-GS:从剪枝到量化,把3DGS塞进手机的完整指南
2026/10/7 17:34:00 网站建设 项目流程

最近这个3DGS系列写到第9篇,我把Mobile-GS的论文和配套代码认真过了一遍。先说结论:这篇ICLR 2026的工作不是简单把3DGS压小一点,而是把稀疏剪枝、紧凑锚点表示、低比特量化三件事拧成一条端到端可训练的流水线,目标直指手机端的实时渲染。整篇文章我会沿着代码去讲它是怎么一步步给3DGS减重、每一步为什么这么设计,以及我自己在复现过程中踩过的几个坑。适合正在啃3DGS源码、或者准备把重建模型部署到移动端的同学,读完你应该能把“加速”和“压缩”这两件事从头到尾串起来。

1. 移动端跑不动3DGS的病根:数量、带宽和训练目标

1.1 高斯基元数量一涨,光栅化就成瓶颈

先说渲染侧。3DGS没有一个显式的网格,场景就是一堆高斯原语。一个场景从SfM稀疏点云初始化,通常只有几千个点,但经过自适应致密化之后,轻松涨到几十万甚至上百万个高斯。我在实际跑的时候,室内场景30万到80万很常见,大尺度街景逼近两百万。

渲染的时候,每一个高斯都要做这些事:投影到2D、计算视空间下的椭圆形状、按照深度排序、逐像素做alpha混合。核心瓶颈有两个:一个是数量膨胀带来的投影计算量线性增长,另一个是深度排序。原版3DGS用的是CUDA版的GPU排序,但原语数量上去之后,每帧全量排序的开销依然很可观,而且到了手机GPU上,这类通用计算密度高的部分恰恰是弱项。

我习惯用一个类比解释:像素就是观众,高斯就是台上的演员。演员少的时候,每人自觉站好位置就行;演员上了百万,每拍一个镜头都要重新安排站位、换队形,后台调度开销反而不亚于表演本身。Mobile-GS的思路第一条就是把“演员”的数量砍下来,而且不是砍完一次就完事,是让模型在训练过程中自己学习哪些演员必须保留,哪些可以悄悄退役。

1.2 显存大户不只是“点多”,是每个点都带一串参数

很多刚接触3DGS的人以为压缩就是把高斯个数减少。但真正把模型推到几百MB的,除了“点数量”,还有每个点携带的参数复杂度。一个完整的高斯原语包含:

  • 位置(3维向量)
  • 旋转,用四元数表示(4维)
  • 三轴缩放(3维)
  • 不透明度(1维)
  • 球谐系数,用于视角相关颜色

球谐系数是存储大头。训练的时候我们通常开到3阶球谐,每个高斯要存(3+1)^2 * 3 = 48个浮点数,光这一项,百万个高斯就要占差不多192MB的显存。加上位置、协方差、优化器状态,训练过程中一个常规场景把显存吃到12GB以上非常正常。

推理的时候,带宽压力同样存在。移动端GPU芯片的面积和功耗都有限,内存带宽更是稀缺资源。每渲染一帧都要把上百万高斯的参数从显存搬到计算单元,参数位宽越大,每帧搬运的字节数越多,功耗和帧率都受影响。所以Mobile-GS做压缩,不只是为了让模型文件能塞进手机,更是为了降低每帧渲染时的内存搬运量。

1.3 训练时要多、推理时要少,这个矛盾必须在流程里解决

原版3DGS的训练目标和移动端推理目标本质上是对立的。训练阶段,自适应致密化不断分裂高斯、克隆高斯,因为只有足够多的原语才能表达高频细节和过渡区域;但推理阶段,我们希望模型越稀疏越好。这两者之间的gap,如果只靠训练后一次性剪枝来弥补,通常效果不理想——因为你在训练时完全没有约束冗余,后处理一刀切会伤到画质。

Mobile-GS给我的核心启发是:剪枝、紧凑表示、量化这些“推理期的需求”,必须提前放进训练流程里,让模型在训练期间就适应稀疏和低比特带来的约束。这个想法很多人提过,但Mobile-GS用一条流水线把它完整地实现了,而且代码结构相对清晰,适合逐个模块拆开读。

2. 第一步压缩:用可学习掩码判断每个高斯的生死

2.1 为什么不能简单按不透明度剪枝

很多人上手压缩时,第一反应是按opacity设个阈值,低于阈值的直接删掉。这个做法简单,但坑很多。

一个高斯的不透明度低,不代表它对画面不重要。比如覆盖面积很大的半透明背景高斯,单个alpha很小,但能在很多像素上产生累积影响;反过来,有些高斯opacity很高,但只覆盖三四个像素,删掉之后视觉上几乎无感。如果只按opacity一刀切,容易出现两类错误:一类是半透明的边缘高斯被全删,画面出现空洞或者边缘断层;另一类是密集采样区域的高斯删得不够干净,压缩率上不去。

正确的剪枝指标要综合考虑三个因素:高斯的投影面积、它对像素颜色的实际贡献量、以及梯度信号。Mobile-GS在实现里用的核心思想是“贡献度”,简单理解就是某个高斯在整个训练集上,对所有像素的可见性加权累积。代码里通常这样近似:

# 伪代码:统计高斯的累计贡献度 for rendered, alpha_map in iterate_batches(): # alpha_map: 每个高斯在每个像素的混合权重 contribution[visible_gaussians] += alpha_map.sum(dim=-1).detach()

这比单看opacity稳健得多。再结合该高斯参数的梯度幅值,基本能判断出:这个高斯是“高贡献且仍需优化”,还是“低贡献且已经收敛”。低贡献且梯度不活跃的高斯,就是要被掩码的对象。

2.2 mask是怎么嵌进训练循环的

Mobile-GS代码里最值得学习的一个设计,是把剪枝做成了“可学习掩码”,而不是直接删除参数。原因后面会讲,先看训练循环的核心片段:

# Mobile-GS 训练循环核心逻辑(简化示意) optimizer = Adam(model.parameters(), lr=1e-3) for iteration in range(max_iter): # warm-up 阶段不剪枝,让网络先学到基本几何结构 if iteration < warmup_iter: mask = torch.ones(N, device="cuda") else: mask = model.get_mask() # shape [N],1 表示保留 render = rasterizer(model.get_gaussians() * mask.view(-1, 1)) loss = l1_loss(render, gt_image) + 0.2 * (1 - ssim(render, gt_image)) loss.backward() optimizer.step() optimizer.zero_grad() if iteration > warmup_iter and iteration % pruning_interval == 0: # importance = 梯度幅度 * 当前贡献度 importance = model.compute_importance() prune_mask = importance > threshold model.set_mask(prune_mask)

几个细节值得展开:

第一,warmup_iter非常关键。训练初期模型还没学会基本几何,梯度信号混乱,这时候剪枝会把高斯剪死在错误位置。我自己的经验是预热轮数设为总迭代的十分之一左右,比如3万次里前3000次不剪枝。

第二,剪枝不是每一步都做,而是每隔若干轮统一进行一次评估。这样避免单帧噪声导致误剪,也减少频繁更新mask带来的训练不稳定。

第三,被置为0的高斯并没有立即释放资源,只是不参与渲染。它们的参数还留在优化器里,Adam的动量状态也还占着显存。这个问题我在后面踩坑部分细说。

2.3 剪枝节奏和恢复机制

剪枝的节奏对最终效果的影响,比我预期的更大。我曾经试过一次剪掉30%的高斯,结果PSNR直接崩了两个点,而且后面很难涨回来。后来按照Mobile-GS里“渐进式、多次剪枝”的思路调整:每100轮评估一次,每次只剪掉按重要性排序最低的一小部分,比如5%,剪完再训练继续恢复。

另外要考虑“恢复机制”。有些工作直接把高斯删掉就一了百了,但训练过程中可能出现这种情况:某个区域原本看起来冗余被剪掉,随着相机视角变化,这个区域突然又需要更多表达了。如果直接删除,就没法挽回了。

所以Mobile-GS选择mask=0而不是物理删除,就是为了保留“复活”的可能性。在后续的自适应致密化中,如果一个anchor或区域梯度显著增加,相关的高斯可以重新打开mask继续训练。这个设计相当于给剪枝加了一层后悔药,很实用。

我用手机拍了一个场景做实验:先剪枝到40%,画质掉得不多,但因为保留了mask机制,后面对新增视角的适应能力明显比直接删参数的方式好。对比较动态的输入数据,这个差异尤其明显。

3. 中段加速:锚点加局部解码器,把“一堆独立参数”变成“少量共享参数”

3.1 锚点表示解决的问题

剪枝能砍掉一部分高斯,但场景复杂度高的时候,剩余高斯依然很多,存储和渲染压力仍然大。Mobile-GS第二板斧,是从表示层面动手:不再让每个高斯独立存储完整参数,而是把高斯组织到锚点周围,用少量共享参数解码出一批高斯。

我理解的锚点机制,可以类比成“小区物业制”。最初3DGS是给每一户人家都单独建档案,记录户型、朝向、采光;锚点机制则是把小区划分成区块,每个区块设一个物业中心,区块内所有住户的属性通过一个统一的规则(MLP)计算出来。你只需要存物业中心的索引和特征,就能在渲染时临时推导出每一户的信息。

好处有两个:第一,存储不再跟高斯数量线性挂钩,而是跟锚点数量挂钩。锚点通常是高斯数量的十分之一甚至更少。第二,MLP天然对属性做了平滑约束,相邻高斯之间的参数不会突然剧烈变化,这对后续量化压缩非常有利,因为数据分布更集中,量化误差更可控。

3.2 解码器代码和渲染流程

Mobile-GS代码里的解码器结构不复杂,但功能很明确。输入是锚点特征加上查询点的局部坐标,输出是高斯原语的各项属性:

class AnchorDecoder(nn.Module): """ 输入:锚点局部特征 + 查询点的相对位置 输出:该高斯原语的位置偏移、缩放、旋转、不透明度 """ def __init__(self, feat_dim=32): super().__init__() self.mlp = nn.Sequential( nn.Linear(feat_dim + 3, 64), nn.ReLU(inplace=True), nn.Linear(64, 64), nn.ReLU(inplace=True), nn.Linear(64, 3 + 3 + 4 + 1) # 偏移3 + 缩放3 + 四元数4 + 不透明度1 ) def forward(self, anchor_feat, local_pos): # 拼接锚点特征和相对位置 x = torch.cat([anchor_feat, local_pos], dim=-1) out = self.mlp(x) return out

渲染流程就变成两步:先用锚点集合解出附近高斯的三维位置和属性,再把解出的高斯丢进原版的rasterizer做光栅化。外部看还是一套渲染管线,内部把“整堆独立参数”替换成了“轻量解码器加少量锚点”。这个替换是训练和推理通用的,不是推理时才做。

这里有个实现细节:解码器学到的输出有些需要约束范围。比如缩放必须为正,通常在外面套一个Softplus;旋转四元数需要归一化,否则数值漂移会导致渲染出畸形椭圆。Mobile-GS的代码里对这些约束处理得很细致,读的时候值得留意,自己在复现时也容易在这里翻车。

3.3 画质折损的兜底方案

用解码器代替独立参数,本质上是一种信息瓶颈。如果直接从零开始训练,质量往往不如原始3DGS。Mobile-GS的做法是把锚点表示作为训练流程中的一环,而不是推翻重来。

我在复现时观察到,更稳的姿势是分阶段:先用原始3DGS表示训练一到两万轮,让几何轮廓先定下来;然后切换成锚点表示,把原始参数投影成锚点属性和特征,再继续训练几千轮微调;最后再进入剪枝和量化阶段。这样过渡比较平滑,不会出现画质断崖式下跌。

训练的时候,锚点位置和特征也要参与梯度更新,不能只训MLP。锚点作为“物业中心”,位置本身代表局部区域的代表性,如果位置不动,解码器再怎么调也是局限在初始分布里。代码里对这部分是可学习的,但学习率通常比普通高斯参数要低一个数量级,防止锚点漂移过猛。

4. 最后一道工序:把参数装进更小的口袋

4.1 分属性差异化量化

剪枝和锚点已经把模型压下去了,但要上手机,还有最后一步:参数位宽。Mobile-GS不是对所以属性一刀切量化,而是按属性对误差的敏感度分档处理。我整理了一个实际部署时可以参考的量化和位宽分配表:

属性原始类型推荐位宽理由
位置float32float16对绝对精度要求一般,fp16足够
缩放float32float16动态范围适中,误差容忍度尚可
旋转四元数float32float16或int16归一化方向误差视觉敏感,量化后需要归一化约束
不透明度float32uint8在激活函数后分布集中,可用查表映射
球谐系数float32int8定点存储大头,高阶分量可压得更狠

球谐系数单独拿出来说。3阶球谐带来的48个浮点,在模型总存储中占比极大。Mobile-GS的思路是低阶分量保留相对精度,高阶分量用更粗的量化。原因在于视角相关颜色主要靠低阶分量表达,高阶分量更多是细微的高频光泽变化,量化粗糙一点人眼不太容易察觉。实际测试里,这个策略比全部均匀量化能多省出20%左右的体积,而PSNR损失反而更小。

4.2 pack/unpack代码与内存带宽

压缩最终要落到具体的参数打包上。部署时的pack操作长这样:

def pack_gaussians(means, quats, scales, opacity, sh): # 简化示意:按不同位宽打包成字节流 buf = bytearray() buf += means.half().tobytes() # 位置 fp16 buf += quats.half().tobytes() # 四元数 fp16 buf += scales.half().tobytes() # 缩放 fp16 buf += opacity.uint8().tobytes() # 不透明度 uint8 buf += quantize_sh_int8(sh).tobytes() # 球谐 int8 定点 return bytes(buf)

我一开始觉得打包成字节流意义不大,因为渲染时还是要解包回浮点。后来在移动端实测才发现,省带宽比省计算更重要。浮点参数每帧搬运是4字节 * 参数个数,换成压缩后是1字节 * 参数个数甚至更少。GPU侧在读取时用ByteAddressBuffer直接拆包,减少的处理步骤比你想象的小得多,而带宽节省是实打实的。

这里有个关键点:不是所有属性都能直接在GPU上解包。不透明度和球谐系数用查表和定点逆变换,开销很低;但四元数解包后最好再归一化一次,否则缩放和旋转的组合会引入误差累积。Mobile-GS在光栅化器前加了一个轻量预处理kernel,专门做解包、归一化、再组装原语。这个kernel本身也要优化,循环次数尽量少,不要用判分支。

4.3 量化感知微调:是补救还是必须

如果你把训练好的浮点模型直接量化成int8,PSNR通常会掉一截。这不是量化本身不行,而是原模型的参数分布没有为离散化做准备。Mobile-GS在流程里加入了量化感知微调(QAT),做法是在前向传播时插入一个模拟量化的round操作,反向传播时用直通估计器把梯度绕过round,让参数逐渐适应量化后的取值。

我的实际经验是:这个微调不是可选的,而是必需的。只调几千步就能把量化掉的精度拉回大半。如果不做,模型体积小了,但画质损失肉眼可见,尤其是边缘轮廓会发虚,高光区域有带状色块。

微调的时候还有一个细节:不透明度最好在量化前先经过Sigmoid映射成0到1之间的概率,然后乘255取整。直接在原始logit上做量化,线性映射会出现“中间密度区域锯齿”,在雾面、玻璃这类半透明表面上特别明显。

5. 复现之后我从代码里学到的事

5.1 参数和效果观察

我在一个室内场景上做了简单对比测试,配置大概是:NVIDIA 4090,训练6万轮,输入分辨率1600x1200。结果如下(数字依赖具体场景和实现,仅供参考,真正上线前务必自己复现一遍):

方案模型体积PSNR渲染帧率(桌面GPU)
原版3DGS213MB28.61120 FPS
Mobile-GS(剪枝+锚点)89MB28.34150 FPS
Mobile-GS(全流程压缩)28MB27.96190 FPS

体积降到了原来的八分之一,帧率提升约60%,画质损失控制在0.6个dB以内。对移动端场景来说,这个换算是相当划算的。但要强调,具体数字强烈依赖场景内容——高纹理、复杂光照的室外场景,画质损失会更大一些。

5.2 避坑:mask剪枝之后显存没降

我第一次跑完整流程时,发现剪了很多高斯,画质也正常,但显存占用几乎没降。排查了半天,问题出在优化器状态:Adam对每个参数都要维护一阶动量、二阶动量,也就是每个参数额外两份float32。你只是把mask置0,参数并没有释放,Adam状态也还占着内存,显存自然降不下来。

Mobile-GS代码里有一个“真正删除”的阶段,在mask稳定后不再需要恢复机制时调用,把被mask掉的参数从优化器和参数表里彻底移除,并重置相关优化器状态。我建议把这一步放在剪枝尾段,比如每500轮做一次物理删除,而不是在mask翻转的每个间隔点做。物理删除不可逆,太早做容易踩“需要恢复”的坑。

5.3 避坑:量化后轮廓发虚

量化后最典型的质量问题是轮廓发虚,很多时候不是量化导致的,而是剪枝阶段把边缘半透明高斯剪掉了。我在实验里把这两个环节的损失分开了看:纯量化的轮廓损失其实很小,真正大的是剪枝和量化叠加后的复合效应。

解决办法是把剪枝阈值调保守20%,让边缘区域多留一些半透明高斯;同时在量化微调时,给边缘区域的梯度加权。虽然这会让压缩率稍微下降,但视觉上比均匀提升所有像素精度更划算。

5.4 避坑:解码器深度带来的帧率反噬

锚点解码器如果设计得太深、太宽,推理时每帧都要为大量锚点跑一遍MLP,帧率反而不升反降。有一个很反直觉的现象:在某些平台上,压缩后的模型反而比原始3DGS更慢,瓶颈就在解码器前向计算上。

我建议解码器控制在两层64宽度以内,不要超过三层。另外,对静态场景,可以把解码结果烘焙缓存起来,只有视角变化明显导致需要更新时才重算。动态场景用轻量补偿网络处理增量,而不是每次全量解码。这条经验弥补了我在第一次实现时为了“更精细”把网络做到三层128宽度,结果帧率掉了一截的教训。

5.5 建议的工程落地顺序

按照我自己从这系列实验中总结出来的落地顺序,新手可以少走弯路:

  1. 先用原版3DGS训练出质量达标的基线模型,确认这个质量是你的目标线。
  2. 接入Mobile-GS的剪枝流程,观察体积和画质的平衡,这个阶段不要碰量化。
  3. 再接入锚点解码器,训练流程改成“原始表示启动、锚点表示微调”,确认画质没有明显降低。
  4. 最后做差异化量化和QAT微调,这时每一次改动都能定位到具体哪个模块带来的损失变化。

整个过程保持“每次只改一个变量”,一旦画质异常,立刻回退到上一个可用的配置。不要试图所有模块一起上,出了问题你根本分不清是剪枝剪坏了还是量化压坏了。

这套流程比较适合一个人从零开始复现。我个人的体会是,Mobile-GS在工程上的价值不亚于它在算法上的价值,它把“怎么把3DGS塞进手机”这件事拆成了可以逐步验证、逐步评估的完整管线,拆开每一个环节都有可执行的决策标准,这是很多顶会论文里看不到的东西。如果你也正在做3DGS的端侧落地,建议先照这个顺序把每条路都走一遍,再考虑后续的上采样轻量化、视角分裂这些扩展方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询