ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Mobile-GS:从剪枝到量化,把3DGS塞进手机的完整指南

Mobile-GS:从剪枝到量化,把3DGS塞进手机的完整指南 最近这个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阶球谐每个高斯要存(31)^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(), lr1e-3) for iteration in range(max_iter): # warm-up 阶段不剪枝让网络先学到基本几何结构 if iteration warmup_iter: mask torch.ones(N, devicecuda) 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选择mask0而不是物理删除就是为了保留“复活”的可能性。在后续的自适应致密化中如果一个anchor或区域梯度显著增加相关的高斯可以重新打开mask继续训练。这个设计相当于给剪枝加了一层后悔药很实用。我用手机拍了一个场景做实验先剪枝到40%画质掉得不多但因为保留了mask机制后面对新增视角的适应能力明显比直接删参数的方式好。对比较动态的输入数据这个差异尤其明显。3. 中段加速锚点加局部解码器把“一堆独立参数”变成“少量共享参数”3.1 锚点表示解决的问题剪枝能砍掉一部分高斯但场景复杂度高的时候剩余高斯依然很多存储和渲染压力仍然大。Mobile-GS第二板斧是从表示层面动手不再让每个高斯独立存储完整参数而是把高斯组织到锚点周围用少量共享参数解码出一批高斯。我理解的锚点机制可以类比成“小区物业制”。最初3DGS是给每一户人家都单独建档案记录户型、朝向、采光锚点机制则是把小区划分成区块每个区块设一个物业中心区块内所有住户的属性通过一个统一的规则MLP计算出来。你只需要存物业中心的索引和特征就能在渲染时临时推导出每一户的信息。好处有两个第一存储不再跟高斯数量线性挂钩而是跟锚点数量挂钩。锚点通常是高斯数量的十分之一甚至更少。第二MLP天然对属性做了平滑约束相邻高斯之间的参数不会突然剧烈变化这对后续量化压缩非常有利因为数据分布更集中量化误差更可控。3.2 解码器代码和渲染流程Mobile-GS代码里的解码器结构不复杂但功能很明确。输入是锚点特征加上查询点的局部坐标输出是高斯原语的各项属性class AnchorDecoder(nn.Module): 输入锚点局部特征 查询点的相对位置 输出该高斯原语的位置偏移、缩放、旋转、不透明度 def __init__(self, feat_dim32): super().__init__() self.mlp nn.Sequential( nn.Linear(feat_dim 3, 64), nn.ReLU(inplaceTrue), nn.Linear(64, 64), nn.ReLU(inplaceTrue), 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 量化感知微调是补救还是必须如果你把训练好的浮点模型直接量化成int8PSNR通常会掉一截。这不是量化本身不行而是原模型的参数分布没有为离散化做准备。Mobile-GS在流程里加入了量化感知微调QAT做法是在前向传播时插入一个模拟量化的round操作反向传播时用直通估计器把梯度绕过round让参数逐渐适应量化后的取值。我的实际经验是这个微调不是可选的而是必需的。只调几千步就能把量化掉的精度拉回大半。如果不做模型体积小了但画质损失肉眼可见尤其是边缘轮廓会发虚高光区域有带状色块。微调的时候还有一个细节不透明度最好在量化前先经过Sigmoid映射成0到1之间的概率然后乘255取整。直接在原始logit上做量化线性映射会出现“中间密度区域锯齿”在雾面、玻璃这类半透明表面上特别明显。5. 复现之后我从代码里学到的事5.1 参数和效果观察我在一个室内场景上做了简单对比测试配置大概是NVIDIA 4090训练6万轮输入分辨率1600x1200。结果如下数字依赖具体场景和实现仅供参考真正上线前务必自己复现一遍方案模型体积PSNR渲染帧率桌面GPU原版3DGS213MB28.61120 FPSMobile-GS剪枝锚点89MB28.34150 FPSMobile-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 建议的工程落地顺序按照我自己从这系列实验中总结出来的落地顺序新手可以少走弯路先用原版3DGS训练出质量达标的基线模型确认这个质量是你的目标线。接入Mobile-GS的剪枝流程观察体积和画质的平衡这个阶段不要碰量化。再接入锚点解码器训练流程改成“原始表示启动、锚点表示微调”确认画质没有明显降低。最后做差异化量化和QAT微调这时每一次改动都能定位到具体哪个模块带来的损失变化。整个过程保持“每次只改一个变量”一旦画质异常立刻回退到上一个可用的配置。不要试图所有模块一起上出了问题你根本分不清是剪枝剪坏了还是量化压坏了。这套流程比较适合一个人从零开始复现。我个人的体会是Mobile-GS在工程上的价值不亚于它在算法上的价值它把“怎么把3DGS塞进手机”这件事拆成了可以逐步验证、逐步评估的完整管线拆开每一个环节都有可执行的决策标准这是很多顶会论文里看不到的东西。如果你也正在做3DGS的端侧落地建议先照这个顺序把每条路都走一遍再考虑后续的上采样轻量化、视角分裂这些扩展方向。
返回列表