第18章 - 性能优化与海量点云工程
点云处理的性能问题很少是”某段代码慢”,绝大多数是内存模型选错了。同一个 SMRF 分类,在 5000 万点的文件上跑:--nostream 模式吃 8 GB 内存还可能 OOM,--stream 模式只用几百 MB 却同样能跑完——因为 SMRF 本身支持流式。反过来,filters.hexbin 需要看到全部点才能算出边界,它天然不可流式,这时候唯一的手段是分块喂给它。
本章讲三件事:PDAL 的两种执行模式(标准/流式)如何影响内存;哪些 stage 能流式、怎么查;以及真实工程里”分块、并发、I/O”三个层面的取舍。所有数量级都基于 PDAL 2.10.0 的实测与源码默认值。
18.1 两种执行模式
18.1.1 标准模式与流式模式
标准模式(--nostream,默认) |
流式模式(--stream) |
|
|---|---|---|
| 数据视图 | 每个 stage 拿到完整 PointView |
逐点通过 processOne(PointRef&) |
| 内存 | 与点数成正比 | 与块大小成正比 |
| 可用的 stage | 全部 | 仅 streamable |
| 典型用途 | 需要全局信息的算法 | 逐点变换、过滤、写出 |
命令行默认是标准模式;pdal pipeline --stream 显式切流式。Python 绑定的 execute() 是标准模式,execute_streaming()/iterator() 是流式。
18.1.2 内存到底怎么算
标准模式的内存大致是三部分之和:
内存 ≈ 点数 × 每点字节数 × 同时存活的 PointView 数
+ 算法自身结构(KD 树、八叉树、邻域缓存)
+ 输出缓冲
每点字节数由 schema 决定:LAS 1.4 格式 7 是 36 字节,加上额外维度(RGB、法向、特征)很容易到 60–80 字节。1000 万点、60 字节就是 600 MB 的裸数据,而一个 filters.smrf 要建栅格索引,峰值往往是它的 3–5 倍。
流式模式的内存与点数无关,只与块大小有关。C++ 侧默认块大小是 PipelineManager 构造参数 streamLimit=10000,Python 的 iterator(chunk_size=...) 默认同样是 10000 点。所以流式的常驻内存 = chunk_size × 每点字节数 × 活跃 stage 数。
18.1.3 块大小怎么定
10000 是保守默认值,适合交互与小文件。工程里按两点调整:
- I/O 友好:一块对应一次压缩块的解压,LAZ 的块通常是几千点,块太大时解压缓冲变厚;
- 吞吐:块越大,函数调用与同步开销占比越低。实测在纯过滤 pipeline 上,把 chunk 从 10000 提到 100000,吞吐能提升 20%–40%,再往上收益递减。
经验区间:本地磁盘 5 万–20 万点/块;对象存储按网络延迟取 2 万–5 万。
18.2 判断一条 pipeline 能不能流式
18.2.1 查 stage 的 streamable 标记
最可靠的方法是看文档里的 streamable 标记,或者直接试:
pdal pipeline pipe.json --stream
如果链路里有不可流式的 stage,PDAL 会报错并指出是哪个 stage。文档中标注为 streamable 的常见 stage(PDAL 2.10 的 doc/stages/*.md):
| 类别 | streamable 示例 | 不可流式示例 |
|---|---|---|
| readers | readers.las、readers.copc、readers.ept、readers.bpf、readers.text |
readers.gdal(视格式而定) |
| filters | filters.expression、filters.range、filters.reprojection、filters.assign、filters.ferry、filters.crop、filters.overlay、filters.decimation、filters.voxeldownsize |
filters.smrf、filters.pmf、filters.cluster、filters.covariancefeatures、filters.hexbin、filters.poisson |
| writers | writers.las、writers.text、writers.bpf、writers.gdal |
少部分插件 |
规律:逐点判断的能流式,需要全局或邻域结构的不能。SMRF 要建形态学栅格,cluster 要建连通性,covariancefeatures 要 KD 树,这些都要求看到一块完整数据。
18.2.2 不可流式 stage 也能”分块流式”
标准模式 + 分块是第三条路:用 filters.splitter 或 filters.chipper 把数据切成互不重叠的块,每块内部跑不可流式算法,最后合并结果。代价是块边界效应——邻域算法在块边缘会缺邻居,导致边界点分类错误或特征值跳变。
处理边界的标准做法是让相邻块重叠 1–2 个邻域半径,处理完再裁掉重叠部分:
[
"input.laz",
{ "type": "filters.chipper", "capacity": 2000000 },
{ "type": "filters.smrf" },
{ "type": "writers.las", "filename": "out_#.laz" }
]
filters.chipper 按 capacity(每块点数)切分,输出多个 PointView,writer 用 # 占位符写成多个文件。这是”单个大文件跑不动”时最实用的兜底方案。
18.3 并发与线程
18.3.1 哪些地方有线程
PDAL 的并行度分布在三个层次:
| 位置 | 参数 | 默认 | 说明 |
|---|---|---|---|
| LAZ 解码 | readers.las 的 threads |
7 | 解码 chunk 表的线程数 |
| 部分 filters | threads |
1 | 如 filters.covariancefeatures、filters.overlay |
| 进程级 | 无 | — | 由你决定并发文件数 |
注意 filters.covariancefeatures 的 threads 只在标准模式有效,流式模式下被忽略。
18.3.2 进程并发 vs 线程并发
PDAL 的 stage 内部没有全局线程池,所以”一个进程处理一个文件、多个进程并行”是最直接的扩缩方式:
# GNU parallel
ls *.laz | parallel -j 8 'pdal translate {} {.}_ground.laz smrf'
# 或 Python 进程池(第 12 章)
单机并发度的上限由内存决定:每个进程的峰值 = 单文件处理峰值。8 核 32 GB 的机器,如果单文件标准模式峰值 4 GB,最多跑 6–7 个进程就已经到顶,再多的进程只会互相触发 swap。
18.3.3 I/O 才是真正的瓶颈
处理链如果是”读 LAZ → 过滤 → 写 LAZ”,瓶颈通常在解压与压缩,而非算法。实测比例:单核 LAZ 解压约 20–40 MB/s,压缩约 10–20 MB/s,而 filters.expression 过滤 100 万点只需要几百毫秒。这意味着:
- 压缩级别比算法优化更重要:
writers.las的 LAZ 压缩无法调级别,但可以换writers.copc(同样 LAZ 压缩,带索引); - 磁盘顺序读比随机读快一个数量级,所以不要对海量小文件做随机访问;
- SSD 上 CPU 是瓶颈,HDD/网络存储上 I/O 是瓶颈,优化方向完全不同。
18.4 海量数据的三种工程形态
18.4.1 单机分块
适用:几亿点、单机内存装不下但磁盘够。流程是 chipper 分块 → 逐块处理 → 合并(writers.las 的 # 或 pdal merge)。要点是块之间有重叠时要去重,filters.label_duplicates 可以按 X,Y,Z 标出重复点。
18.4.2 瓦片化 + 索引
适用:需要按空间范围查询、长期维护。用 writers.copc 或 Entwine 建索引,后续按 bounds/resolution 取数(第 14、17 章)。这条路把”处理”变成”查询”,是 Web 发布的标准形态。
18.4.3 分布式
适用:TB 级、需要多机。PDAL 本身没有分布式调度,常见做法是按空间瓦片切任务(pdal tile 或自研切分),把每个瓦片作为独立任务投给 Slurm/K8s/Spark;每个任务内部就是上面两种形态之一。
pdal tile 用 --origin_x/--origin_y(默认 None,不设则取第一个点)、--length(边长,默认 1000)、--buffer(瓦片外扩,默认 0)、--out_srs 控制划分。输出文件名必须包含 # 占位符,会被替换成 X/Y 索引;命令不自动建目录:
mkdir -p tiles
pdal tile input.laz "tiles/tile_#.laz" --origin_x 400000 --origin_y 5700000 --length 1000
# 生成 tiles/tile_0_0.laz, tiles/tile_1_0.laz, ...
18.5 性能测量的正确姿势
18.5.1 用 timing 而不是掐表
pdal pipeline 有 --timing,Python 绑定有 timing=True,输出的每阶段计时比外部 time 精确:
pdal pipeline pipe.json --timing --loglevel info 2>&1 | grep -i "took\|time"
pdal info --summary 与 --stats 的成本差别也值得记住:前者读头信息与抽样,后者要读全量点。对一个 50 GB 的文件跑 --stats 会完整解压一遍,别在批处理循环里随手调用。
18.5.2 一个基准测试脚本
#!/usr/bin/env bash
# bench.sh <pipeline.json> <input.laz> <runs>
for i in $(seq 1 $3); do
/usr/bin/time -f "%e s %M KB" pdal pipeline "$1" \
--readers.las.filename="$2" 2>&1 | tail -1
done
关键指标是两个:墙钟时间和最大常驻内存(%M)。只看时间会漏掉”用内存换时间”的优化,而后者在生产环境通常是不可接受的。
18.6 流式模式的三个实测坑
下面三条都在 PDAL 2.10.0(Windows、OSGeo4W)上用 200 万点合成数据实测过,代码与输出可以直接复现。
18.5.1 offset=”auto” 在流式模式下会退化
这是最容易吃亏的一条。标准模式下 offset_x/y/z="auto" 会扫描全部点算出一个居中偏移;流式模式没有”全部点”这个概念,于是只拿第一块数据估算,并打出警告:
(pdal pipeline writers.las Warning) Auto offset for 'X' requested in stream mode. Using value of -100.596.
(pdal pipeline writers.las Warning) Auto offset for 'Y' requested in stream mode. Using value of -4.47241.
(pdal pipeline writers.las Warning) Auto offset for 'Z' requested in stream mode. Using value of -5.80894.
后果是:如果数据分布不均匀(例如按块组织、每块中心差很远),第一块算出的 offset 对后面的块并不合适,坐标被量化到 0.01 网格后可能溢出或损失精度。修法有两个:
- 流式写出时显式给定 offset(用
pdal info --summary先读到数据范围,算中点); - 或者先做一遍
pdal info --summary,把 offset 写进 pipeline JSON,不要依赖auto。
18.5.2 一条简单 pipeline 的实测数据
200 万点、filters.expression 过滤、writers.las 写 LAZ:
| 指标 | 实测值 |
|---|---|
| 输入 LAS 大小 | 68.7 MB(每点约 36 字节) |
| 输出 LAZ 大小 | 5.2 MB(压缩约 13 倍) |
| 流式耗时 | 0.86 s |
| 标准模式耗时 | 0.87 s |
两点结论:
- 只有过滤 + 写出的 pipeline,两种模式速度几乎一样。流式的优势在内存,不在时间;把”切流式”当成性能优化是常见误解。
- LAZ 压缩比在 13 倍这个量级,换个数据集可能是 8 倍也可能 20 倍,取决于维度熵。估算存储成本时用 8–10 倍做保守估计。
18.5.3 流式模式下算法 stage 会拖慢而不是加速
流式意味着每个 stage 都要为每个点做一次虚函数调用与状态更新。对于 filters.expression 这种”一个表达式算完”的 stage,开销可以忽略;对于内部还要维护小窗口的 stage(如 filters.voxeldownsize),流式版本省掉了全量视图,但每次点处理都要查哈希表,反而可能比标准模式慢。
判断方法只有一个:两种模式各跑一次,量时间和峰值内存。18.5 节的基准脚本就是干这个的。
18.7 排错清单
| 现象 | 原因 | 修法 |
|---|---|---|
流式报 not streamable |
链路上有需要全局信息的 stage | 去 --stream,或改用分块方案 |
| 内存随点数线性上涨 | 标准模式 + 大文件 | --stream(若可流式)或 chipper 分块 |
| 分块后边界分类错 | 邻域算法在块边缘缺邻居 | 块间重叠 1–2 个邻域半径,处理后裁边 |
| 多进程跑满 CPU 但没提速 | I/O 或内存带宽到顶 | 减少并发、换 SSD、改用流式 |
| LAZ 写出很慢 | 压缩本身是 CPU 密集 | 换 COPC 或降低压缩比(不适用标准 LAZ) |
pdal info --stats 卡很久 |
stats 要读全量数据 | 用 --summary 取代,或只在必要时跑 |
| 输出文件数不对 | 用了 # 占位符但只有一个 view |
检查是否有 chipper/splitter 生成多 view |
| 流式写出时坐标精度异常 | offset="auto" 在流式下只按首块估算 |
显式给定 offset,或在 pdal info --summary 后写入固定值 |
18.8 生产建议
先量内存,再谈优化。 用 /usr/bin/time -v 或容器监控拿到峰值 RSS,对照 18.1.2 的公式估算是否合理。绝大多数”慢”其实是 swap。
能流式就流式。 流式不只是省内存,它让处理时间与文件大小解耦,批处理任务的资源规划会简单很多。
分块大小与存储对齐。 本地 SSD 用大块(20 万点),对象存储用小块(2 万–5 万),让每次请求/每次解压的粒度接近。
并发度按内存反推,不按核数。 max_workers = min(cores, 可用内存 / 单任务峰值),这个公式比任何调参经验都可靠。
把 pipeline JSON 当作可复现的配置。 性能优化的每一次改动都应该有对应的 pipeline 版本与基准数据,否则调优会退化成玄学。
18.9 本章小结
| 主题 | 结论 |
|---|---|
| 两种模式 | 标准模式全量驻留;流式逐点处理,内存与点数无关 |
| 块大小 | C++ streamLimit 与 Python chunk_size 默认均为 10000 |
| streamable 判断 | 逐点逻辑可流式;邻域/全局算法不可流式 |
| 不可流式的兜底 | chipper 分块 + 块间重叠 + 处理完裁边 |
| 线程 | readers.las 解码 threads 默认 7;多数 filter threads 默认 1 且仅标准模式有效 |
| 并发 | 进程级并发按内存反推,不按核数 |
| I/O | LAZ 解压/压缩是主要成本,压缩级别与存储形态比算法微调更有效 |
| 测量 | 用 --timing 与 /usr/bin/time -v,同时看时间与峰值内存 |