第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的线程):直接处理MapAPI,持有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 |