第16章 - 性能调优与内存管理

16.1 先建立成本模型:一帧的时间花在哪

调优之前得知道瓶颈在哪。MapLibre Native 一帧的 CPU 成本分三段,源码里对应三组可测指标(include/mln/gfx/rendering_stats.hpp):

阶段 指标字段 谁在干
布局与 bucket 构建 无直接字段,看 worker 占用 4 个 worker 线程(每个 Style 一个 ThreadPool)
编码(提交 draw call) encodingTime 主/渲染线程
GPU 执行 renderingTime GPU,主线程等待

其余字段直接给出资源量:numDrawCalls(当前帧 draw call 数)、numCreatedTextures / numActiveTextures(纹理创建与净数量)、numBuffers / numVertexBuffers / numIndexBuffers、memTextures / memBuffers(估算的显存占用,字节)、stencilClears / stencilUpdates。

做性能排查时先看 numDrawCalls:中端 Android 上单帧超过 600 就是危险信号,超过 1000 基本必然掉帧。draw call 数量主要由图层数、瓦片数、以及是否触发 stencil 决定,不是由要素数直接决定。

16.2 线程与并发

ARCHITECTURE.md 的线程模型写得很清楚:

  • 主线程(或创建 Map 的线程):直接处理 Map API,持有 Style,负责渲染。
  • worker 线程:每个 Style 四个,做瓦片解析、文本整形、几何处理。
  • FileSource 线程:网络与 SQLite I/O。

四个 worker 是硬编码的。多开几个 Map 实例不等于多几倍 worker——每个实例各自四个,同时渲染 N 张图就有 4N 个 worker 在抢 CPU。服务端批量出图要限并发,原因就在这里。

FileSource 的网络并发是另一个维度:DEFAULT_MAXIMUM_CONCURRENT_REQUESTS = 20(util/constants.hpp)。20 个并发请求打一个源站,如果源站有限流,表现是大量 429。这时调小并发比加机器有效。

// 运行时改并发(HTTP 源支持该属性)
auto source = mln::FileSourceManager::get()->getFileSource(
    mln::FileSourceType::Network, resourceOptions);
source->setProperty("max-concurrent-requests", mapbox::base::Value(8));

16.3 预取与缓存

DEFAULT_PREFETCH_ZOOM_DELTA = 4:当前级别附近,前 4 级的瓦片会被预取。放大时命中缓存,代价是启动阶段多取一批瓦片。移动流量敏感的产品把这个值调小(或关掉预取),启动更快、流量更省。

环境缓存上限 50 MB 是 DEFAULT_MAX_CACHE_SIZE,第 11 章讲过淘汰细节。从性能角度只有一句话:缓存命中比任何渲染优化都便宜。一次磁盘读是微秒级,一次网络请求是几十到几百毫秒。

Resource::Priority 有两个值:Regular 与 Low。过期的瓦片在重验证时被降到 Low,不会跟当前视口正在等的瓦片抢并发名额。自己实现 FileSource 时要尊重这个字段,否则首屏会被后台重验证拖慢。

16.4 渲染侧的四个主要开销

16.4.1 draw call 数量

每个图层每种瓦片一个 draw call。压缩办法是把同源同类型的图层合并——fill 要素过多时拆成多个图层反而更慢。symbol 图层的 draw call 数与文字/图标的 bucket 数相关,不是按要素算。

16.4.2 纹理与图集

numCreatedTextures 与 numActiveTextures 的差值就是泄漏信号:持续增长而不回落,说明纹理没释放。图标与文字走动态图集(dynamic_texture_atlas.hpp),图集碎片化会让同样的图标占更多纹理页。频繁 addImage / removeImage 会造成碎片,能预置就预置。

16.4.3 stencil 与 overdraw

stencilClears 与 stencilUpdates 高说明模板缓冲被反复重置。多层半透明叠加、大范围 fill 就是典型场景。减少重叠面积、把不透明图层放在下面,能直接降这两项。

16.4.4 符号布局

文本整形是 CPU 密集操作:要素数 × 字符数 × 换行组合。symbol 图层的 text-allow-overlap、icon-allow-overlap 为 false 时会做碰撞检测,每次相机变化重算。把不参与碰撞的图层开 allow-overlap 是常见优化。

16.5 内建统计:把 RenderingStats 接出来

RenderingStats 是结构体,字段随渲染逐帧更新。MapObserver 或 RendererObserver 都能拿到它,各平台也透了对应的 API(Android 的 rendering_stats.hpp 在 JNI 层有包装)。直接读的典型用法是做一个调试 HUD:

class StatsObserver final : public mln::RendererObserver {
public:
    void onDidFinishRenderingFrame(const mln::gfx::RenderingStats& stats) override {
        if (++frameCount % 30 != 0) return;   // 每 30 帧打一次,避免日志拖慢渲染
        std::cout << "frames=" << stats.numFrames
                  << " drawCalls=" << stats.numDrawCalls
                  << " enc_ms=" << stats.encodingTime * 1000
                  << " gpu_ms=" << stats.renderingTime * 1000
                  << " tex=" << stats.numActiveTextures
                  << " memTexKB=" << stats.memTextures / 1024
                  << " memBufKB=" << stats.memBuffers / 1024
                  << " stencilClears=" << stats.stencilClears << '\n';
    }
    int frameCount = 0;
};

判读规则(来自字段语义,不是猜测):

现象 含义 动作
encodingTime 高、renderingTime 低 CPU 编码瓶颈 减少 draw call、合并图层
encodingTime 低、renderingTime 高 GPU 瓶颈 降分辨率、减 overdraw
numDrawCalls 随相机移动暴涨 瓦片数过多 调 maxzoom、加 bounds
numActiveTextures 单调上升 纹理泄漏或图集碎片 查 addImage/removeImage 配对
memTextures 接近显存上限 纹理太多 降 pixelRatio、减图标尺寸

16.6 Tracy:看 worker 在干什么

MLN_USE_TRACY=ON 打开 Tracy 插桩(docs/mdbook/src/profiling/tracy-profiling.md)。核心宏在 include/mln/util/instrumentation.hpp:

宏 作用
MLN_TRACE_ZONE(label) 记录一段作用域的起止时间
MLN_TRACE_FUNC() 等价于 MLN_TRACE_ZONE(__FUNCTION__)
MLN_TRACE_GL_ZONE(label) GPU 侧区间(仅 OpenGL)

Metal 与 Vulkan 的 GPU 区间目前没有 Tracy 支持,需要各自的厂商工具。Tracy 的默认行为是把未被消费的事件留在内存里,低内存设备上要定义 TRACY_ON_DEMAND,只在 profiler 连着时才记录。

cmake --preset linux-opengl -DMLN_USE_TRACY=ON   # 生成带插桩的构建
cmake --build build-linux-opengl --target mbgl-glfw

构建完成后启动 Tracy Profiler 并连接该进程,即可看到 zone 时间线。

Tracy 里最有用的是看 worker 线程是否被某个瓦片卡住:一个 300 ms 的 parse 区间意味着后面的瓦片全在排队,用户会看到局部空白。

16.7 内存:三处最容易失控

瓦片 bucket。每个可见瓦片的几何数据(bucket)都驻留内存,直到瓦片离开视口且过了保留期。缩得很远再放大,桶会重建。控制手段是限制同时可见的图层数与源数,不是调某个参数。

图片与图集。DEFAULT_ON_DEMAND_IMAGES_CACHE_SIZE = 100 * 8192 字节(约 800 KB),这是 onStyleImageMissing 动态加载图片的缓存上限。图标多而杂的样式会很快触顶,然后反复加载同一张图。

离线数据库的页缓存。SQLite 打开时用默认页缓存,离线包几百 MB 时这部分内存不小。PRAGMA cache_size 可以在自己的 FileSource 里调,但要注意和其他进程争内存。

Android 上还有一层:MapView.onLowMemory() 必须调用,它会让 core 释放可回收的缓存。不调就是「App 在后台被系统杀掉」的常见原因。

16.8 基准测试与回归

benchmark/ 目录用 Google Benchmark,构建出 mbgl-benchmark-test,入口链是 platform/default/src/mln/benchmark/main.cpp → benchmark/src/mln/benchmark/benchmark.cpp。iOS 有独立的 BenchmarkApp,Android 没有基准测试。

渲染性能的回归主要靠 render-test 的 metrics:渲染测试除了比对图片,还能记录 FPS、内存、GPU 分配等指标(docs/mdbook/src/render-tests.md 明确写了「allow a wide variety of operations and probes (which write out metrics) for things like GPU memory allocations, memory usage, network requests, FPS」)。这是唯一在 CI 里能挡住性能退化的机制。

./build/mbgl-render-test-runner --manifestPath metrics/linux-clang8-release-style.json \
  --update metrics

--update metrics 会把实测值写回期望文件。做性能优化的 PR 里不要顺手提交 metrics 更新——评审看不到「为什么这个数变了」,容易漏掉真实退化。

16.9 排错

首屏白屏 2 秒以上:先看是不是在等样式 JSON。样式里有很多 sprite / glyphs 引用时,它们和瓦片抢同一个 20 并发池。把 max-concurrent-requests 调到 8,让瓦片先走。

滑动时周期性卡顿:瓦片解析占满 4 个 worker。检查 maxzoom 是否过大(放大时同时解析大量父级瓦片)、是否开了过多数据驱动属性。fill 图层的 fill-color 用 expression 会显著增加 bucket 构建时间。

内存随使用时长单调上涨:按顺序查三处——numActiveTextures(纹理)、memBuffers(几何)、离线数据库文件句柄。前两者靠 stats,第三者看文件描述符数。

onLowMemory 之后崩溃:某些实现里在 onLowMemory 之后继续用旧的纹理句柄。正确做法是 onLowMemory 只触发回收,不动业务状态;下一次渲染 core 会自己重建。

服务端出图越来越慢:Map 实例复用太久。瓦片缓存、符号布局缓存都会累积,定期 release() 重建。批量任务用「每 N 张重建一次」的策略。

GPU 内存超限被杀(iOS):memTextures 超过设备给单个 App 的预算。降 pixelRatio(每降一档纹理内存降到 1/4)、减小 sprite 尺寸、减少同时可见的 raster 图层。

16.10 参数速查

参数 位置 默认值 调优方向
worker 线程数 ARCHITECTURE.md 4 / 每个 Style 不可配,靠限制实例数
DEFAULT_MAXIMUM_CONCURRENT_REQUESTS util/constants.hpp 20 源站限流时调小
DEFAULT_PREFETCH_ZOOM_DELTA util/constants.hpp 4 省流量时调小
DEFAULT_MAX_CACHE_SIZE util/constants.hpp 50 MB 磁盘换速度时调大
DEFAULT_ON_DEMAND_IMAGES_CACHE_SIZE util/constants.hpp 100 * 8192 字节 图标多时调大
DEFAULT_MAX_ZOOM util/constants.hpp 22 源的 maxzoom 别超过它
MAX_ZOOM util/constants.hpp 25.5 相机硬上限
DEFAULT_TRANSITION_DURATION util/constants.hpp 300 ms 过渡动画时长,调小省 CPU
MLN_USE_TRACY CMake OFF 需要 profile 时开
TRACY_ON_DEMAND 编译宏 未定义 低内存设备定义它

16.11 生产建议

先降 draw call,再谈 GPU 优化。移动端最常见的瓶颈是 CPU 编码阶段,而不是像素填充。把图层数压到 20 以内、关掉不用的源,比任何着色器优化都有效。

给每个设备档位定 pixelRatio。低端机强制 1.0,中端 1.5,高端才用 2.0。纹理内存是 ratio² 关系,2.0 相对 1.0 是 4 倍——这是唯一一个能一次性把显存砍到 1/4 的开关。

把 onLowMemory 当必选项。Android 的 MapView 生命周期方法必须全部实现转发,onLowMemory() 尤其。省下这几行代码的代价是后台被杀。

服务端出图用进程级并发而不是线程。一个进程一个 Map,render 完 release;并发靠进程数控制,别指望单进程多实例共享缓存。

性能预算写进 CI。用 render-test 的 metrics 卡住关键指标(首帧时间、draw call 数、纹理内存),退化才拦得住。人工回归一定会被跳过。

16.12 本章要点

结论 依据
每个 Style 固定 4 个 worker 线程 ARCHITECTURE.md 线程模型
网络并发默认 20,预取跨度 4 级 util/constants.hpp
RenderingStats 直接给出 draw call / 纹理 / buffer / 显存估算 include/mln/gfx/rendering_stats.hpp
CPU 瓶颈看 encodingTime,GPU 瓶颈看 renderingTime 同上字段注释
Tracy 只支持 OpenGL 的 GPU 区间,Metal/Vulkan 要用厂商工具 tracy-profiling.md
动态图片缓存上限约 800 KB(100 * 8192) util/constants.hpp
性能回归只能靠 render-test 的 metrics 拦住 render-tests.md