跳转至

NaN 数据的渲染语义与性能代价

摘要 —— 科学数据里的缺失值几乎总是以 IEEE-754 的 NaN 形态进入绘图管线:传感器掉线、除零、插值外推失败、上游管道的显式空洞。渲染器面对 NaN 只有三种可选语义:把折线在缺口处切段(matplotlib 的选择)、跳过该样本并把两侧连起来、或把它当 0 画;三者的视觉含义完全不同。逐行阅读 QWT7 的 qwt_point_mapper.cpp 与 qwt_plot_curve.cpp,可以给出五种绘制属性(ClipPolygons 无过滤 / FilterPoints / FilterPointsAggressive / FilterPointsPixel / FilterPointsLTTB)在 NaN 数据上的确切输出语义:QWT7 实现的是跳过语义,所有 Lines 类路径都输出一条连续折线,NaN 段被一条弦"桥接"过去,而不是切段;这一结论由 Python 逐行重实现与真实库的 3090 组多边形哈希比对(100% 一致)和 960×560 离屏渲染的逐像素视觉扫描(与理想切段参照相差 0.23%–3.80% 像素)共同证实。性能侧,boundingRect 冷扫描是一次 \(O(n)\) 顺序读,100 万点约 12–22 ms;SIMD argmin/argmax 路径在出现任何 NaN 时全局退化为标量,但在帧级测量中这个"悬崖"被数据缩减收益与光栅化主导项掩盖(100k 点 delta = −0.29,1M 点 delta = +0.03,与 nanperf 示例的结论一致)。

摘要 — 核心结论

  • QWT7 的 NaN 语义是跳过而非切段:14 种 NaN 模式 × 5 种绘制属性的语义矩阵中,所有 Lines 类路径对每个内部 NaN 段都产生恰好 1 条桥接弦(run 模式 3 个缺口 → 3 条桥接弦,五种模式无一例外);只有 Dots / Sticks 这类逐样本风格天然正确。
  • 语义重实现交叉验证:Python 逐行重实现与 qwtplot.dll 真实输出在 15 个定向案例 + 250 个随机案例 × 2 种画布 × 6 条路径上共 3090/3090(100%)多边形哈希一致,90 组定向案例逐顶点比特一致。
  • 视觉伪差(与"NaN 处断开、其余不变"的理想渲染逐像素比对,960×560):内部缺口模式下 FilterPointsAggressive 最接近理想(0.23%–1.05% 差异像素),FilterPointsLTTB 偏离最大(2.50%–3.80%),其中约 2.7 个百分点来自桶边界重排而非桥接。
  • 性能:1M 点 Middle 案例,帧时间(min-of-7)随 NaN 比例从 0% 到 50% 总体下降(个别 1%/5% 格点在测量噪声内回升):无过滤 173.7→86.5 ms、FilterPointsLTTB 26.3→13.1 ms;boundingRect 冷扫描 21.7→11.5 ms;全 NaN 数据帧时间仅 0.6–1.7 ms,画布空白、坐标轴回落到 0–1000。
  • 失效清单首条之外还有两个实测陷阱,都落在 LTTB 的 MinMax 桶原始指针路径上(写作时它就是 FilterPointsLTTB 的实现,v7.5.0 起移到 FilterPointsMinMaxLTTB):预扫描只查 y,x 为 NaN 而 y 有限的样本若恰好是桶极值,会以 qRound(NaN)==0(实测,MSVC x64 + Qt 5.15)落到画布第 0 列形成伪尖峰;+Inf 能穿过只查 isnan 的预扫描进入 SIMD 路径,产出 INT_MIN 像素坐标。两条均已在 v7.5.0 修复(见第 5 节更新记录);v7.5.0 引入的真 LTTB(默认 FilterPointsLTTB)原始指针分支仍是 y-only isnan 判定,进入前同样要清洗 x 与 Inf。

1. 引言:NaN 从哪来,渲染器该做什么

1.1 三类来源

工程数据里的 NaN 是一种常态的编码方式,不是异常。传感器在采样间隔内掉线,采集端写入 qQNaN() 占位;物理量相除时分母过零,IEEE-754 规定结果为 ±Inf 或 NaN;数据清洗管道把离群点替换成显式空洞,下游用 NaN 表示"这里没有测量"。三种来源的共同点是:NaN 携带位置信息(第 \(i\) 个样本缺失)与缺失信息(值未知),任何把 NaN 当数值处理的渲染都会同时丢掉这两者。

1.2 渲染语义的形式化

设样本序列 \(P=\{(x_i,y_i)\}_{i=0}^{N-1}\),记有限样本的极大连续段(run)为 \(S_1,S_2,\dots,S_k\)。理想切段语义定义为:渲染算子 \(\mathcal{R}\) 满足

\[ \mathcal{R}_{\text{ideal}}(P) = \bigcup_{j=1}^{k} \mathcal{R}(S_j) \]

即每个连续段独立渲染、段间无任何连接图元;缺失段在画布上留下空白。这是 matplotlib 折线的行为,也是人眼对"数据缺失"的直觉解读。跳过语义则把 NaN 样本从序列中删除后整体渲染:

\[ \mathcal{R}_{\text{skip}}(P) = \mathcal{R}\left( \bigcup_{j} S_j \text{(按原序拼接)} \right) \]

拼接会在相邻段之间引入一条桥接弦,即连接 \(S_j\) 末点与 \(S_{j+1}\) 首点的线段,它在数据上没有任何依据。画零语义(把 NaN 当 0)连位置信息都破坏,不在讨论范围。三种语义的差异在缺口两侧的 \(y\) 值相差越大、缺口越宽时越刺眼:桥接弦可能横跨半个画布,把两段无关的信号焊在一起。

QWT7 的选择是跳过语义。文档没有这样声明,但 src/plot/qwt_point_mapper.cpp 五条映射路径逐行可见这一事实:每条路径的主循环都是 if (qwt_is_nan_or_inf(sample)) continue;,删除样本后继续向同一条 QPolygonF 追加顶点。第 3 节逐路径给出证据,第 4 节用量化实验回答"桥接弦到底画错了多少像素"。

1.3 折线之外:NaN 在其它图元里的语义

同一份 isfinite 检查在管线的其它分支里承担不同语义,对照阅读有助于理解"跳过"在 QWT 里是一般原则而非折线特例。QwtPlotSpectrogram 把 NaN 单元渲染为透明像素(hasGaps && qwt_is_nan_or_inf(value) 时写 0,qwt_plot_spectrogram.cpp 598/620 行;早版本地位级 qwtIsNaN 已移除,现谓词连 Inf 一并拦截),并提供 QwtRasterData::WithoutGaps 标志让无缺口数据跳过逐单元检查。栅格语义里"缺口即透明"天然成立,因为像素之间没有连接关系。CONREC 等高线算法用累加和检测 NaN(qwt_is_nan_or_inf(zSum) 则跳过该单元,qwt_raster_data.cpp 373 行,同样兼拦 Inf),利用的是 NaN 的加法吸收律。散点与符号路径(qwtToPointsFiltered、drawSymbols)跳过 NaN 即正确语义,与 Dots/Sticks 同理。只有存在"连接"概念的图元,即折线与阶梯,才会暴露跳过与切段的分歧,这正是本文的范围。

1.4 与既有文档的分工

使用指南 NaN 数据处理 回答"怎么设置、怎么生成数据";曲线降采样 的第 5.3、5.4 节已经给出 MinMax 桶的三重路径与 NaN 预扫描的结构图。本文给出原理视角:把 IEEE-754 比较语义在渲染管线每一层的传播写成可验证的命题,并用可复现实验给出语义矩阵、视觉伪差与性能代价。

2. IEEE-754 比较语义与管线传播

2.1 有序谓词:NaN 在比较中"隐身"

IEEE-754 规定除 != 外的比较对 NaN 一律返回假:\(\mathrm{NaN} < x\)、\(\mathrm{NaN} > x\)、\(\mathrm{NaN} \le x\) 均为 false。这条规则在渲染管线里逐层传播,效果各不相同:

  • boundingRect 层(src/core/qwt_series_data.cpp 的 qwtBoundingRectT,217–261 行):Qwt 6 时代依赖 qMin/qMax 扩展边界,NaN 使比较全假、边界停止扩展或污染为无效值;QWT7 改为显式 isSampleNanOrInf(sample) 跳过(两个循环各一处 continue,236–237 与 248–249 行),包围矩形只由有限样本决定。全 NaN 序列返回无效矩形 QRectF(1,1,-2,-2)(实测 boundingRect_allNan_valid=0、x=1、width=-2)。
  • 自动缩放层(两处机理不同):QwtPlot::rescaleAxes()(src/plot/qwt_plot.cpp 1908–1911 行)在合并包围矩形前用 qwt_is_nan_or_inf 检查四边有限性;replot 时的自动缩放走 QwtPlot::updateAxes()(src/plot/qwt_plot_axis.cpp 865–869 行),只按 width()/height() >= 0 合并区间。全 NaN 序列的无效矩形 (1,1,-2,-2) 宽高均为负被整体跳过,轴区间保持无效,回落到 AxisData 的默认范围 0–1000(同文件 43–44 行,实测 autoscale_allNan_x_upper=1000),画布上什么都不画。
  • SIMD argmin/argmax 层(AVX2 内核自 v7.5.1 起独立在 src/core/qwt_simd_argminmax_avx2.cpp,主循环 64–79 行;调度与 NaN 兜底在 src/core/qwt_simd_argminmax.cpp):比较用有序谓词 _CMP_LT_OQ / _CMP_GT_OQ(72/76 行),NaN 永远赢不了比较、进不了索引向量;水平归约与标量收尾同样用有序比较。因此"NaN 被天然忽略"在值语义上成立。

各层使用的 NaN 判定谓词并不统一,这张表是后文失效清单的索引:

层 谓词 检查对象 Inf 是否拦截
qwtBoundingRectT / 曲线风格 / Quad / Pixel / 通用桶路径 qwt_is_nan_or_inf = !isfinite x 与 y 双坐标 是
MinMax 预扫描(725–731 行,v7.5.0 起) !std::isfinite 仅 y 是(仅对 y;x 不查)
MinMax 标量桶循环(680–687 行,v7.5.0 起) !std::isfinite x 与 y 双坐标 是
真 LTTB 原始指针分支(qwtLttbReduce 的 mapSample,915–922 行) std::isnan 仅 y(x 不查) 否(±Inf 与 x 的非有限值均可进入)
qwtSimdArgMinMax 兜底(170–172 行) std::isnan(result.minVal/maxVal) 归约结果 部分(结果仍为 NaN 时返回哨兵 {0,0,DBL_MAX,-DBL_MAX})

谓词强度从左到右递减:双坐标 isfinite 是最强形式,单坐标 isnan 是最弱形式。第 5 节清单第 3、4 条曾是最初两行的直接后果(v7.5.0 已把 MinMax 侧补齐);真 LTTB 原始指针分支留下的同款单坐标 isnan 是这段历史的延续,使用前仍需按 6.4 节清洗。

2.2 索引语义不可靠:为什么还需要预扫描

值语义正确不等于 (值, 索引) 对正确。AVX2 主循环里维护跑者值的指令是 _mm256_min_pd(minVec, vals),而 MINPD 在任一操作数为 NaN 时返回第二操作数:若某 lane 读到 NaN,该 lane 的跑者值变成 NaN;下一轮 vals < NaN 为假,索引向量不更新,但跑者值又被 _mm256_min_pd(NaN, vals) 恢复成新值,于是出现"值是新的、索引是旧的"的错配,最终输出的极值点会把一个样本的 \(y\) 与另一个样本的 \(x\) 焊在一起。这正是 qwtMinMaxBucketReduce 在原始指针快速路径前先做非有限 y 预扫描(qwt_point_mapper.cpp 725–731 行)的原因:预扫描发现任何非有限 y 就整段切换到标量桶循环(reduceBucketScalar,671–716 行;整段调用在 765–769 行),用一次 \(O(n)\) 顺序读换掉每桶的分支与错配风险;x 的非有限值不在预扫描内,改由 SIMD 路径每桶的后置校验拦截(747–753 行)。预扫描带 break 短路:无非有限 y 的数据付全量 \(O(n)\) 代价,命中的数据只付到第一个命中为止的代价,这个不对称在第 4.4 节的帧时间曲线里看得见。

用一个 8 样本桶把错配过程走一遍(单 lane 视角,min 侧):

轮次 vals cmp = vals < minVec(LT_OQ) minVec(MINPD 后) minIdx
0 5.0 真(5 < DBL_MAX) 5.0 0
1 NaN 假 NaN(MINPD 返回第二操作数) 0
2 3.0 假(3 < NaN 为假!) 3.0 0 ← 应为 2
3 4.0 假 3.0 0

第 2 轮的真最小值 3.0 进入了跑者值,却因与 NaN 的有序比较为假而丢掉了自己的索引;桶结束时输出 (值=3.0, 索引=0),而索引 0 处的样本值是 5.0。在 qwtMinMaxBucketReduce 的 SIMD 分支里,这个错配会直接变成 raw.x[minI] 与 r.minVal 的错配,即一个几何上不存在的数据点。预扫描把整条路径从"每桶防御"改为"一次判定、全段换路",是典型的用一遍顺序扫描消除热循环分支的工程取舍。

flowchart TD
    A["桶循环开始<br/>numBuckets = max(2, 2W)"] --> B{"线性映射且<br/>可提取原始 Y 指针?"}
    B -->|"否"| C["通用路径<br/>sample(i) 逐点<br/>isfinite 双坐标检查"]
    B -->|"是"| D["非有限 y 预扫描<br/>i in visFrom..visTo<br/>!isfinite(raw.y[i]) 短路 break"]
    D --> E{"区间内存在非有限 y?"}
    E -->|"否"| F["SIMD 快速路径<br/>qwtSimdArgMinMax 每桶一次<br/>+ 每桶 x 后置校验兜底"]
    E -->|"是"| G["标量快速路径<br/>原始指针 + !isfinite 双坐标检查<br/>x 与 y 非有限样本都跳过"]
    F --> H["每桶输出 1-2 个极值点<br/>全 NaN 桶不输出"]
    G --> H
    C --> H

2.3 取整函数:NaN 与 Inf 的最终落点

映射后的像素坐标要过取整函数,NaN/Inf 在这里获得确定的(但出乎意料的)整数值。Qt 5.15 的 qRound(double) 实现为 d >= 0 ? int(d+0.5) : int(d - double(int(d-1)) + 0.5) + int(d-1),在 MSVC x64 上 int(NaN) 经 cvttsd2si 得到"整数不确定值" INT_MIN,两次 INT_MIN 相加按 int32 回绕为 0。实测(nan_mapper_check probes):

输入 qRound 结果(MSVC x64, Qt 5.15.16) 渲染后果
NaN 0 伪顶点落在画布第 0 列
+Inf -2147483648(INT_MIN) 飞出画布,被 ClipPolygons 裁掉
-Inf 0 伪顶点落在画布第 0 列

浮点版取整 qwtRoundValueF(floor(v+0.5) / ceil(v-0.5))则让 NaN 原样穿过:QPolygonF 里会留下 (NaN, NaN) 顶点。这两张表是第 5 节失效清单里两条陷阱的算术根源。

flowchart TD
    A["QwtSeriesData&lt;QPointF&gt;<br/>含 NaN/Inf 样本"] --> B["qwtBoundingRectT<br/>isfinite 跳过,全 NaN 得无效矩形"]
    B --> C{"序列含有限样本?"}
    C -->|"否"| D["轴回落 0..1000<br/>画布空白"]
    C -->|"是"| E["drawLines / drawSteps / drawDots / drawSticks<br/>各自跳过 NaN"]
    E --> F{"QwtPointMapper 标志优先级<br/>Pixel &gt; LTTB &gt; MinMax &gt; Quad &gt; WeedOut"}
    F --> G["qwtPixelColumnReduce<br/>continue 跳过,空列不输出"]
    F --> H["qwtMinMaxBucketReduce<br/>非有限 y 预扫描 -&gt; SIMD(+x 后置校验) / 标量 / 通用"]
    F --> I["qwtMapPointsQuad<br/>首非 NaN 扫描 + continue"]
    F --> J["qwtToPolylineFiltered / qwtToPoints<br/>continue 跳过"]
    G --> K["单条 QPolygonF<br/>NaN 段被桥接弦跨越"]
    H --> K
    I --> K
    J --> K

3. 五条路径的 NaN 处理逐一剖析

3.0 用户属性到映射器标志

QwtPlotCurve::drawLines(qwt_plot_curve.cpp 583–698 行)把用户级属性翻译为映射器标志:FilterPoints → WeedOutPoints;FilterPointsAggressive → WeedOutIntermediatePoints(仅当整数对齐 doAlign 为真,609–612 行)+ WeedOutPoints;FilterPointsPixel → PixelColumnReduce;FilterPointsLTTB → LTTBReduce(v7.5.0 起为真 LTTB;v7.4.2 及之前它映射到 MinMaxReduce);FilterPointsMinMaxLTTB → MinMaxReduce(614–619 行)。toPolygonF 的优先级为 PixelColumnReduce > LTTBReduce > MinMaxReduce > WeedOutIntermediatePoints > WeedOutPoints(qwt_point_mapper.cpp 1502–1503 行)。下文按此顺序剖析;3.5 节与 3.8 节语义矩阵中的 lttb/lttbgen 指 MinMax 桶路径(本文全部 NaN 测量都由它测得,harness 的模式名沿用至今),模式名沿用 nanperf 示例的 FilterModes.h。

3.1 无过滤(ClipPolygons only):qwtToPoints

qwtToPoints(1135–1194 行)是最朴素的路径:遍历 [from,to],qwt_is_nan_or_inf(sample) 为真则 continue,否则变换并按 RoundPoints 取整追加。无效包围矩形分支在追加后统一 resize(numPoints)(1190 行,其上一行 1189 行的注释明确写着"加入 NaN 检查后这里也必须 resize")。语义:删除 NaN、保留顺序、一条折线,桥接弦数等于内部 NaN 段数。全 NaN 输入得到空多边形。

3.2 FilterPoints:qwtToPolylineFiltered

qwtToPolylineFiltered(1222–1268 行)先做首非 NaN 扫描:

1
2
3
4
5
6
7
8
realFrom = from; sample0 = series->sample(from)
while (realFrom < to && qwt_is_nan_or_inf(sample0)): realFrom++, sample0 = series->sample(realFrom)
if realFrom >= to && qwt_is_nan_or_inf(sample0): return 空多边形   # 全 NaN 早退(v7.5.0 起)
points[0] = round(transform(sample0))          # 有限样本,无需再检查 sample0
for i = realFrom+1 .. to:
    if qwt_is_nan_or_inf(sample(i)): continue  # 跳过,不切段
    p = round(transform(sample(i)))
    if points[pos] != p: points[++pos] = p     # 去连续像素重复

写作时这里与 Quad 路径不同、没有全 NaN 早退:若整个序列都是 NaN,循环停在 realFrom == to,points[0] 由最后一个样本构造,x 有限、y 为 NaN 时得到 (899, NaN) 这样的单顶点多边形(语义矩阵 all 行:points 模式 1 顶点、1 个垃圾坐标)。单顶点折线在光栅器下不画图,视觉扫描 all 行差异为 0,但这个多边形非空,是第 5 节清单里的第 5 条。v7.5.0 起已补上与 Quad 路径相同的全 NaN 早退(1245–1248 行):全 NaN 输入返回空多边形,"空多边形 = 无数据"的约定恢复;矩阵 all 行的 1 顶点是修复前的快照值(回归测试 tests/nan_curve_test/filterPoints_allNan_emptyPolygon)。

3.3 FilterPointsAggressive:qwtMapPointsQuad

两遍 Quad 归并(218–345 行):第一遍在序列上跳过 NaN 并做首非 NaN 扫描,有全 NaN 早退(235–236 行 if (realFrom >= to && qwt_is_nan_or_inf(sample0)) return polyline;);第二遍在整数多边形上再归并。NaN 样本在第一遍被 continue 删除,因此同一像素列/行内的极值统计只看见有限样本;跨缺口的连接由两遍归并后的折线自然给出,仍是桥接。方向探针 qwtProbeOrientation(82–110 行)对 x 含 NaN 的序列会因 (NaN > prev) != isIncreasing 判为 Vertical,改变两遍归并的先后顺序:sweep B 里 xynan/xyinter 案例的 aggressive 帧时间(3.57/3.60 ms)比 middle(4.70 ms)低约 24%,来源就是这个路径翻转导致的输出顶点数变化,而非工作量减少。

3.4 FilterPointsPixel:qwtPixelColumnReduce

像素列分桶(422–535 行):主循环 continue 跳过 NaN 后按列累积 first/min/max/last;输出阶段 count == 0 的列整体不输出。于是 NaN 段对应的列是空列,折线从缺口左邻列直接连到右邻列,桥接弦在这里表现为一条横跨空列的长弦。列内亚像素摆位(+0.2/+0.25/+0.75/+0.8,508–531 行)与 曲线降采样 第 4.4 节所述一致,NaN 不改变摆位规则,只改变哪些列非空。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# qwtPixelColumnReduce 的 NaN 行为(线性快速路径,451-471 行)
visFrom, visTo = findVisibleRange(xMap, series, from, to)   # x-NaN 可能使其退回全区间
bins[col] = {firstY, minY, maxY, lastY, count} for col in 0..W-1
for i in visFrom .. visTo:
    if qwt_is_nan_or_inf(sample(i)): continue               # 双坐标检查
    col = qRound(x*cnv+xOff) - xMin
    if col out of [0, W): continue
    bins[col] 累积 first/min/max/last                        # NaN 不占列
for col in 0..W-1:
    if bins[col].count == 0: continue                        # 空列 -> 缺口
    输出 (x+0.25, minY), (x+0.75, maxY) [+ 边界 first/last]
# 结果:单条折线,空列两侧的列顶点被一条弦连接

空列不输出这一设计在"无 NaN 的稀疏采样"场景下是优点(列间不插伪点),但在 NaN 场景下它把缺口变成了不可见的缺口:折线拓扑上仍然连通,只是少了几列顶点。视觉扫描里 pixel 模式在 run 模式的 0.785% 差异像素,主体就是这几条跨空列的弦。

3.5 FilterPointsLTTB / FilterPointsMinMaxLTTB:qwtMinMaxBucketReduce 的三重路径

桶化骨架见 曲线降采样 第 4、5.3 节。命名说明:v7.4.2 及之前 FilterPointsLTTB 映射到 MinMax 桶算法;v7.5.0 起 FilterPointsLTTB 改为真 LTTB(QwtPointMapper::LTTBReduce、qwtLttbReduce()),MinMax 桶算法移到新属性 FilterPointsMinMaxLTTB(MinMaxReduce)。本节、3.8 节矩阵与第 4 节性能数据中的 lttb/lttbgen 都是 MinMax 桶路径(本文全部 NaN 测量由它测得,harness 的模式名保留至今)。此处只补 NaN 视角的三处细节,先看当前标量桶循环的骨架(reduceBucketScalar,671–716 行),三个要点都藏在它的谓词里:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# qwtMinMaxBucketReduce 原始指针标量循环 reduceBucketScalar(v7.5.0 起)
for b in 0 .. numBuckets-1:
    [start, end] = 桶 b 的样本索引区间(int(b*s) 截断)
    minIdx = maxIdx = -1
    for i in start .. min(end, visTo):
        y = raw.y[i]
        if !isfinite(y): continue        # 只查 y 的有限性;Inf 也被拦截  <-- 清单 #4 修复
        x = raw.x[i](无 x 指针时用索引 i)
        if !isfinite(x): continue        # x、y 双坐标跳过,与通用路径对齐  <-- 清单 #3 修复
        有序比较更新 min/max(首现索引语义)
    if minIdx >= 0: 按 minIdx/maxIdx 顺序输出 1-2 个顶点(屏幕坐标由已校验的 x/y 计算)
# SIMD 路径(预扫描未命中)同构:每桶一次 qwtSimdArgMinMax,输出前对胜出样本的
# x 做后置校验(hasRawX 时),命中非有限 x 则整桶改走 reduceBucketScalar  <-- 清单 #3 修复
# 预扫描(725–731 行)只查 y:!std::isfinite(raw.y[i]),命中即整段切换标量循环

在此骨架上:其一,预扫描只查 y(725–731 行 !std::isfinite(raw.y[i])):x 为 NaN 而 y 有限的样本不会触发整段标量回退,改由 SIMD 路径的每桶 x 后置校验(747–753 行)在它胜出时整桶重算,预扫描保持单数组内存流量的取舍见第 5 节更新记录。其二,标量循环对 x、y 双坐标跳过(680–687 行):写作时 x-NaN 样本照常参与桶内极值竞争,一旦胜出,其屏幕 x 由 qRound(raw.x[minIdx]*xCnv+xOff) 计算,即 qRound(NaN)=0,伪顶点钉在第 0 列;探针实验(y 在 x-NaN 处人为置为全局最大)实测 xnan_lttb_fakeLeft=1,多边形证据为顶点 (0,21)(修复前快照);同数据的通用路径(lttbgen,770–815 行,用 qwt_is_nan_or_inf 双坐标检查)为 0。语义矩阵里 xyinter 案例的 lttb 顶点数 3567 对 lttbgen 的 3215、多出的 352 个顶点正是这类伪点(同为快照值)。其三,全 NaN 桶不输出(minIdx < 0 时跳过 emit),但桶号不重排:NaN 样本仍占据桶预算,缺口两侧的有限样本可能被划进同一个桶,极值选择因此与切段渲染不同,这是第 4.3 节 LTTB 视觉差异偏大的第二个来源。+Inf 写作时能穿过只查 isnan 的预扫描,在 SIMD 路径里赢得 _CMP_GT_OQ 比较,屏幕 y 变成 qRound(±Inf)=0 或 INT_MIN(inf 案例 lttb 垃圾坐标数 1,修复前快照);v7.5.0 起预扫描拦 Inf,x 的非有限值由后置校验兜底。最后要注意 v7.5.0 引入的真 LTTB 原始指针分支仍只用 std::isnan(y) 判定样本有效性(qwtLttbReduce 的 mapSample,915–922 行;通用分支用 qwt_is_nan_or_inf):±Inf y 与 x 的非有限值仍会进入选择,首/末有效样本若 x 非有限还会带着 NaN/Inf 坐标落到输出,清单 #3/#4 的同类谓词在新默认路径上延续,6.2/6.4 节的数据卫生建议对默认模式依旧成立。桶数回退阈值 numPoints <= 2*numBuckets(642 行)按含 NaN 的总可见样本数计(numPoints = visTo - visFrom + 1,635 行,NaN 样本同样计入),NaN 比例不改变 numPoints,因此不影响桶化/Quad 的路径选择。

3.6 曲线风格层:Steps / Dots / Sticks

drawSteps(qwt_plot_curve.cpp 871–963 行)跳过 NaN 后按"角点 + 样本点"交替构造阶梯多边形。默认竖直 orientation 下角点取 \((x_{j-1}, y_j)\)(907–918 行),于是缺口处的阶梯表现为:在缺口前最后一个有限样本的 x 上先做竖直跳变,再以缺口后的 y 水平铺过整个缺口。像素探针证实:steps_bridge_tread_upper=1、steps_bridge_riser=1、steps_gap_at_pre_level=0,阶梯在缺口上方水平跨越、竖直边提前到缺口左缘,与理想切段(两者都不画)相反。(v7.5.0 起 BreakAtNan 属性可把 Lines/Steps 切成逐段渲染,默认关闭,见第 5 节更新记录。)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# drawSteps 的缺口几何(888-945 行,inverted = 默认竖直 orientation)
retained = [i for i in from..to if isfinite(x[i]) and isfinite(y[i])]   # NaN 跳过
pts = []
for k, i in enumerate(retained):
    (xi, yi) = round(transform(x[i], y[i]))
    if k > 0:
        pts += inverted ? (pts[-1].x, yi)      # 竖直跳变发生在"上一个"样本的 x
                          : (xi, pts[-1].y)    # 水平铺垫发生在"当前"样本的 x
    pts += (xi, yi)
# 缺口 [a,b) 的净效果:在 x_{a-1} 处竖直跳到 y_b,再以 y_b 水平铺满 [x_{a-1}, x_b]

drawSticks(710–756 行)与 drawDots 的 MinimizeMemory 分支(828–846 行)逐样本独立画图元,跳过 NaN 即正确语义,探针 sticks_at_nan=0 / sticks_at_valid=1、dots_minmem_at_nan=0 / dots_minmem_at_valid=1 证实。使用指南中"Sticks/Steps/MinimizeMemory 不检查 NaN"的表格写于更早版本;当前源码自提交 17d0d13(fix(nan): add NaN skip to 4 missing paths in QwtPlotCurve)起已在这四处补上跳过检查(drawSticks 739 行、drawDots 834 行、drawSteps 897 行、drawLines 走映射器),但跳过语义本身未变。

3.7 可见区间二分:x-NaN 的保守行为

qwtFindVisibleRange(349–418 行)用至多 50 个探针点验证单调性,任一探针命中 NaN 即放弃收窄、退回全区间。若孤立 x-NaN 躲过探针,二分搜索里的 NaN 比较一律为假,等价于把该样本当作"在可见范围内":搜索只会过扫(多包含画布外样本),不会截断数据。探针实验在 10 万点、x-NaN 位于 12345/50000/77777 三处、全幅与缩放(30000–60000)两种轴范围下测得多边形 x 覆盖 0.25–899.75(pixel)与 0–899(lttb),即画布全宽,无截断。

3.8 语义矩阵

定向案例(N=8000,900×500 画布,y 尺度取有限样本极值)的完整矩阵由 nan_experiment.py 打印;下表摘出顶点数与桥接数两列。bridged 为跨越内部 NaN 段的多边形段数,intGaps 为内部 NaN 段数。

模式 baseline isolated run periodic xynan xyinter all
none 顶点数 8000 7992 7040 7200 7200 7200 0
points 顶点数 7296 7290 6423 6582 6565 6565 1(NaN 顶点)
aggressive 顶点数 2022 2021 1785 1993 1820 1820 0
pixel 顶点数 1800 1800 1594 1799 1622 1622 0
lttb 顶点数 3572 3572 3142 3366 3215 3567 0
lttbgen 顶点数 3572 3572 3142 3366 3215 3215 0
模式 isolated (8 缺口) run (3 缺口) periodic (159 缺口) extremum (4 缺口) leading/trailing (0 内部缺口)
none / points / aggressive / pixel / lttb / lttbgen 8/8 3/3 159/159 4/4 0/0
steps(风格) 8/8 3/3 159/159 4/4 0/0
dots / sticks(风格) 0/8 0/3 0/159 0/4 0/0

表中 bridged/intGaps 的分子分母在所有 Lines 类路径与 Steps 上恒等:每个内部缺口恰好被一条弦跨越,没有模式切段,也没有模式漏段(首尾缺口不产生桥接,只裁短折线,故 leading/trailing 为 0)。顶点数表中的 lttb 为 MinMax 桶原始指针路径、lttbgen 为同算法的通用 sample() 路径(命名见 3.5 节);xyinter 的 3567 对 3215 即清单第 3 条,v7.5.0 已修复,表中为修复前快照。12 点工作示例(y = 1,2,NaN,0,NaN,NaN,3,NaN,2,1,NaN,0,12×8 画布)把这一点压缩到一行:五种 Lines 模式输出完全相同的 7 顶点折线 (0,5)(1,2)(3,7)(6,0)(8,2)(9,5)(11,7),索引 1 与 3 之间的 NaN 被一条从行 2 到行 7 的弦跨越;Steps 输出 13 顶点,在 x=1 处先竖直跳到行 7 再水平铺过缺口。

切段行为示意:理想切段参照与五种模式的实际输出,灰色带为 NaN 段,红色为桥接弦

4. 实验

4.1 方法与可复现性

三组实验的脚本与原始数据全部入库:docs/assets/algorithms/nan_experiment.py(Python 逐行重实现 + 语义矩阵 + 出图)、nan_mapper_check.cpp(真实库语义 harness:定向矩阵、随机战役、离屏视觉扫描、行为探针)、nan_bench.cpp(性能扫描,镜像 nanperf 的 NanDataGenerator 与 BenchmarkRunner 分解)、nan_results.csv(原始测量)、nan_mapper_hashes.csv(交叉验证哈希)。Python 重实现与 C++ 的对应是逐行的:两种取整函数量化方式(qwtToPoints 系用 transform() 的 \(p_1+(v-t_1)c\) 形式,Quad/Pixel/MinMax 线性快速路径用 \(vc + (p_1 - t_1 c)\) 形式)、qwtFindVisibleRange 的探针与两次二分、Quad 两遍归并的 flush 顺序、MinMax 的桶边界 int(b*s) 截断与 (值,索引) 首次出现语义、Qt 5.15 qRound 的 int32 回绕,全部按源码复刻;唯一例外是 Quad 的首顶点:C++ 用 qwtRoundValue(xMap.transform(...))(qwt_point_mapper.cpp 242 行),Python 端统一走线性快速路径的 fast() 形式(nan_experiment.py 273 行),两者在测试信号上数值等价,由哈希 100% 一致佐证;跨语言数据一律使用只含加减乘除的有理信号(超越函数在不同 libm 间不保证比特一致),随机战役共享 xorshift64* 流。交叉验证规模:15 个定向案例 × 6 条路径 + 250 个随机案例 × 2 种画布(40×30 与 900×500)× 6 条路径 = 3090 组多边形哈希,一致率 100.00%;另有 90 组定向案例做逐顶点比特比对(含 (899,NaN) 退化顶点)全部一致,该比对为一次性人工核验过程:入库的 nan_mapper_hashes.csv 只存顶点数与量化哈希,C++ harness 端 %.17g 精度的 V 顶点记录未入库,需重编译重跑 harness 才能再生。入库哈希是 v7.5.0 修复前的运行记录:按当前库重跑,矩阵 all 行的 points、xyinter 的 lttb 等案例会因清单 #3/#5 的修复而改变。视觉扫描用 QwtPlotRenderer 离屏渲染 960×560 图像:实际渲染为单条含 NaN 曲线,理想参照为按有限段拆分后的多条曲线(同模式属性、同笔宽、同抗锯齿),逐像素比较 ARGB。

语义 harness 提供四个子命令:nan_mapper_check matrix 输出定向案例的完整顶点序列与多边形哈希;random <count> <seed> 跑随机战役(数据与哈希同流输出,Python 端用同种子再生数据后比对);visual <outdir> 做离屏视觉扫描并把 run、xyinter 两个模式的渲染 PNG 落盘供人工复核;probes 跑行为探针(Steps/Sticks/Dots 像素探针、boundingRect 与自动缩放边界、x-NaN/Inf 伪顶点、可见区间截断)。QwtPointMapper 在 qwt_point_mapper.h 中以 QWT_EXPORT 导出,因此 Lines 类路径的语义验证直接驱动映射器、不经过像素猜测;Steps/Sticks/Dots 位于 QwtPlotCurve 私有绘制函数内,改由"渲染后探针像素"做行为级验证(在 1:1 画布抓取图上检查特定数据坐标邻域是否被涂色)。nan_bench 分 sweep A(NaN 比例 × 点数 × 模式主扫描)、B(nanperf 八案例 × 模式)、C(全 NaN/空数据退化)与 single(单配置加严复测)四个入口。

4.2 语义验证结果

语义矩阵(3.8 节两表)给出正确性核心结论:跳过语义在五条路径上无一例外,桥接弦数恒等于内部缺口数;Dots/Sticks 因逐样本独立而天然正确;全 NaN 输入下除 points 留下一个 NaN 单顶点外全部输出空多边形(该单顶点是 v7.5.0 修复前的快照,见 3.2 节)。随机战役(NaN 比例 0–60%、x-NaN 比例 0–10%、含 4% 全 NaN 案例)把边界情况(空段、单点段、x/y 交替 NaN、桶回退阈值两侧)都压进了 100% 一致率里。

随机战役的构造刻意覆盖路径分支的边界:样本数 3–502,使 40×30 画布的桶回退阈值 \(2N_b=160\) 落在取值区间内部(桶化与 Quad 回退两侧都被压到),而 900×500 画布的阈值 3600 恒高于样本数(MinMax 内部的 Quad 回退分支每例必走);定向案例的 N=8000 则覆盖真正的桶化路径。y-NaN 比例 0–60% 均匀取样、x-NaN 比例 0–10%、每 25 例插入一个全 NaN 案例。每例在两种画布下各跑 6 条路径(5 种用户模式 + 强制通用路径的 lttbgen),哈希对 (顶点数, 量化顶点序列模 \(2^{32}-5\)) 做双重比较,顶点数单独比较是为了让"空多边形 vs 单 NaN 顶点"这类退化差异无法被哈希巧合掩盖。250 例 × 2 画布 × 6 路径 = 3000 组随机比较加 90 组定向比较,全部一致;定向案例另做逐顶点 %.17g 级比对,含 (899, NaN) 退化顶点在内 90/90 比特一致(一次性人工比对:入库哈希产物不含原始顶点序列,该项无法直接复核,见 4.1)。

说明 — 交叉验证的边界

哈希一致证明 Python 重实现与真实库在映射层输出上逐比特相同;它不覆盖光栅化(QPainter)、裁剪(QwtClipper)与布局。后三者的 NaN 相关行为由 4.3 节的离屏渲染扫描与 4.2 节的行为探针独立覆盖。

4.3 视觉正确性扫描

固定信号(N=20000,含 4 处 +2.4 窄尖峰与一处 −3.1 深谷)× 11 种 NaN 模式 × 5 种模式,与理想切段渲染的差异像素占比(%):

模式 none points aggressive pixel lttb
baseline(无 NaN) 0.000 0.000 0.000 0.000 0.000
isolated 1.385 1.587 0.502 1.030 2.504
run 2.004 1.116 0.229 0.785 3.627
leading 0.000 0.000 0.000 0.000 2.713
trailing 0.000 0.000 0.000 0.000 2.700
all 0.000 0.000 0.000 0.000 0.000
extremum 2.104 1.692 0.624 1.004 3.798
periodic 2.371 2.558 1.054 2.996 3.322
xynan 0.035 1.121 0.258 0.528 2.848
xyinter 0.035 1.121 0.258 0.528 2.975
spikenan 1.113 1.183 0.440 0.836 2.953

视觉伪差热图:与理想切段渲染的差异像素占比(数据源为本节视觉扫描表,非 3.8 节语义矩阵)

读表需要三层解读。第一层,baseline 与 all 全零是 sanity:无 NaN 时实际渲染与单段理想渲染逐像素相同,全 NaN 时两边都是空白画布。第二层,含内部缺口的模式里所有模式都非零,桥接弦就是差异主体;aggressive 最低(0.23%–1.05%),因为 Quad 归并把缺口两侧压进同列后弦长最短;none 最高之一(1.1%–2.4%),因为它保留全部有限样本、弦以原始斜率横穿缺口。第三层是 LTTB 的两个额外项:leading/trailing 没有内部缺口、其余四种模式差异为 0,LTTB 却有 2.7%。这不是桥接,而是桶边界重排:理想参照对 18000 个有限样本分约 1750–1800 桶,实际曲线对 20000 个样本(含 NaN 占位)分同样多的桶(桶数为画布像素宽的 2 倍;画布宽度当时未单独入库,只能按 960 像素宽的渲染图扣除边距估算),桶边界错位使极值选择不同;isolated/run 等模式的 LTTB 差异里也叠加了同样的重排项(理想参照的短段还会触发 n <= 2N_b 的 Quad 回退)。xynan/xyinter 的 none 列 0.035% 也不全来自拆分点处的线帽/线连接:其差异包围盒(列 454–546、行 281–283)呈横向窄带,表明桥接弦本体(实际渲染用一条弦跨过拆分点,理想参照在此处是两个断开的端点)约占一半,其余为线帽/线连接的光栅差异,量级可忽略。

差异像素的空间分布(nan_results.csv 的 vis 行带差异包围盒)支持上述归因:run 模式下 none 的差异包围盒为列 269–959、行 247–522,横跨三个缺口及其右侧信号区,最大通道偏差 224/255,桥接弦整条画满;aggressive 的包围盒在 run 模式收窄到列 270–768(第三条弦被列归并吸收进相邻列的竖直笔画),偏差仍为满幅。periodic 模式(视觉扫描的 N=20000,约 400 个宽 5 样本的周期缺口,CSV 中理想参照 idealSegs=400、内部缺口 399 个)下 pixel 升到 3.00%,超过 none 的 2.37%:空列密度高时,+0.25/+0.75 亚像素摆位让每列的竖直笔画与理想参照逐列错开半个子像素,抗锯齿混合后整条曲线的像素都参与差异,这是度量与摆位规则的叠加,不是新的语义错误。

结论按问题"哪个模式在 NaN 数据上画错了"表述:所有 Lines 模式都画了数据不存在的弦,LTTB 除此之外还因桶重排改变有限区域的形状。若业务要求"缺口即空白",做法是在数据端切段(第 6 节)或开启 BreakAtNan(v7.5.0 起,Lines/Steps);若只要求"缺口不刺眼",aggressive 的列归并把弦压进竖直笔画、视觉差异最小,但代价是 曲线降采样 第 6.3 节讨论过的列归并保真度损失。两个目标不可兼得,选择权在数据语义一侧而不在渲染器一侧。

4.4 性能扫描

协议:900×500 画布、固定轴范围(X 0..N−1、Y −1.5..1.5)、ImmediatePaint、离屏平台;boundingRect 为 5 次冷缓存扫描(每次前 setSamples 失效缓存)取最小;replot 为 3 帧热身 + 7 帧计时取最小与中位,批内 (中位−最小)/最小 > 30% 时追加批次(至多 4 轮)。环境:i7-14700, Intel UHD 770, Qt 5.15.16, QWT Release (MSVC2019 /O2), Windows 10;绝对数值随硬件变化、相对关系稳定。

帧时间(replot,min-of-7,ms),Middle 案例:

NaN% 100k none points aggressive pixel lttb 1M none points aggressive pixel lttb
0 18.77 17.62 5.03 9.85 6.81 173.67 159.48 25.16 81.66 26.25
1 19.73 18.04 6.94 9.85 5.28 159.25 145.02 22.62 75.59 24.69
5 18.65 17.42 5.34 9.87 5.25 162.95 145.54 22.93 75.50 23.04
10 17.21 14.72 4.84 8.89 4.81 147.95 129.30 20.82 69.09 22.76
25 15.53 13.36 4.15 8.30 4.29 124.22 113.65 20.29 59.24 18.13
50 9.81 9.44 3.38 6.96 3.42 86.50 83.65 15.31 42.54 13.06

boundingRect 冷扫描(min-of-5,ms,五种模式的 min–max 区间):

NaN% 100k 1M
0 2.0–2.3 19.6–21.7
10 1.8–2.7 16.6–19.6
50 1.3–1.4 11.5–12.8
100(全 NaN,sweep C) 0.51–0.56 未测
空数据(sweep C) 0.0000 未测

boundingRect 的测量口径需要单独说明:它测的是冷缓存扫描:每次计时前调用 setSamples 使 QwtPlotSeriesItem 的缓存矩形失效,因此数字代表"数据刚更新时"的一次性代价,而不是每帧代价。表格给出五种模式的最小–最大区间而非单值,是因为该扫描与绘制属性无关(同一段 qwtBoundingRectT 代码),模式间的差异纯粹是缓存状态噪声;区间宽度在 1M 点约 2 ms,正是内存系统噪声的量级。nanperf 在 GUI 场景下报告 100k 点约 8.6 ms,与离屏测量的约 2 ms 相差四倍,差异来自面板布局与事件循环的缓存状态;两者对 NaN 比例的响应方向一致(nanperf 99% NaN 时降到 3.2 ms,本文 50% 时降到 1.3–1.4 ms、全 NaN 0.5 ms)。

NaN 比例-帧时间曲线族(按模式分组,对数轴)

成本分解:boundingRect 预扫描、SIMD 悬崖与数据缩减

三个读数。其一,帧时间随 NaN 比例总体下降(个别 1%/5% 格点在测量噪声内回升,见上表 1M none 1%→5%),各模式归一化曲线接近但不完全重合(上方成本分解图 fig_nan_breakdown 的 panel (c):50% 处 none/points/pixel/lttb 约降到 0.50,aggressive 略高、约 0.61):NaN 的净效应是数据缩减,更少的有限样本要变换、光栅化、裁剪。1M 点无过滤模式从 173.7 ms 降到 86.5 ms(0→50%),LTTB 从 26.3 ms 降到 13.1 ms。其二,boundingRect 的 O(n) 扫描同样吃数据缩减:1M 点从约 21 ms 降到约 12 ms,因为被跳过的样本不参与 min/max 扩展;全 NaN 时 100k 点仅 0.5 ms(只做 isfinite 判断),空数据为 0。其三,SIMD 悬崖在帧级不可见:按 nanperf 的 analyze() 口径,100k 点 LTTB 的 0%→1% 比率为 0.776、其余模式均值比率 1.064,delta = −0.29(LTTB 从数据缩减中获益更多);1M 点比率 0.941 对 0.915,delta = +0.03(边际)。100k 点甚至出现反直觉的一格:LTTB 在 0%(SIMD 路径)比 1%(标量路径)慢 1.5 ms,因为桶大小约 55 样本时,每桶一次 qwtSimdArgMinMax 调用的向量装载与水平归约固定开销超过并行收益(曲线降采样 5.4 节对这一阈值有同款论证),再叠加 0% 时预扫描无法短路的全量 \(O(n)\) 读。帧时间由光栅化主导(1M 无过滤 173.7 ms 中映射只占一小部分),桶归约的 SIMD/标量差被埋在里面;要观察纯归约代价需微基准,本文不越界下结论。

分布案例扫描(sweep B,100k 点、10% NaN,replot min,ms):

案例 none points aggressive pixel lttb
baseline 18.20 17.41 5.17 9.59 5.26
leading 15.66 14.53 4.27 9.03 4.73
leadtrail 16.25 15.55 4.72 8.95 4.42
middle 16.59 15.67 4.70 9.09 4.82
trailing 16.99 15.48 4.74 9.42 4.60
xynan 16.20 14.87 3.57 8.60 4.61
xyinter 15.70 15.87 3.60 9.11 5.13
allnan 1.58 1.79 1.44 1.31 0.69

分布案例帧时间:缺口位置影响小,x-NaN 只在翻转代码路径处产生差异

缺口位置(前导/中段/尾部/首尾)对帧时间的影响在 ±10% 内,与 nanperf 的"位置不影响性能"结论一致;x-NaN 两案例的差异来自 3.3 节的方向探针翻转(aggressive)与 3.5 节的伪顶点额外光栅(xyinter 的 lttb 比 middle 慢约 6%)。退化数据(sweep C):全 NaN 的帧时间 0.63–1.72 ms、空数据 0.54–0.56 ms,即"只画坐标轴"的底噪;结合 3.1 节的无效包围矩形与 0–1000 轴回落,全 NaN 曲线的完整行为是空白画布、默认轴与不崩溃。

换算成帧率(fps = 1000/帧时间最小值)便于与 nanperf 的表格对齐:100k 点 10% NaN 时 lttb 约 208 fps、none 约 58 fps;1M 点 0% NaN 时 lttb 约 38 fps、none 约 5.8 fps;1M 点 50% NaN 时 lttb 约 77 fps。全 NaN 的 100k 曲线 lttb 约 1576 fps,此时帧时间已完全是轴与画布清空的底噪,与数据量无关。nanperf 在 GUI 线程、带网格与面板布局下报告的绝对值(100k 无 NaN lttb 约 12.4 ms)高于离屏单曲线数值(5.26 ms),差异来自场景构成而非管线语义;两组的模式间排序与帧时间随 NaN 比例下降的总体趋势一致。

4.5 测量完整性说明

计时期间机器上另有并行基准任务。首轮 sweep A 有 2 个配置批内 spread 超 30%(1M pixel 0%、1M none 10%),按协议追加批次重测;随后一次完整复跑(本文采用为主数据)全部 110 行 spread < 25%,且与单配置加严复测(sweep=S 行)相互印证:pixel 1M 0% 共 3 组 min-of 测量 76.3/81.7/85.2 ms(两次复测加主数据,区间 76.3–85.2),none 1M 10% 的复测只有 1 组、连同主数据共 2 组测量 138.9/147.9 ms。唯一未收敛的是 lttb 1M 0%:主会话 min-of 26.3 ms,两次独立复测的 sweep=S 行留下 31.8/34.9 ms 与 35.4/37.3 ms 两组读数(两列口径并列,不复用全文的 min-of 主口径);批次级原始数据未入库,批内 spread 无法复核,仅能给出两复测行的 (中位数−最小值)/最小值 = 6.8% 与 11.5%,两个会话簇各自内部相对稳定、簇间明显分离,归因于并发负载下的时钟状态;min-of 协议保留安静会话值,nan_results.csv 以 sweep=S 行保留复测记录,读者可自行取保守值。相对关系(模式间排序、帧时间随 NaN 比例下降的总体趋势)在所有会话中一致。

5. 讨论:失效模式

更新记录(v7.5.0)

本文是 2026-10 的时点快照,正文结论与测量数字保持原样;下列清单中三项已在其后修复、一项已有库级出口:

  • 命名变更先行:FilterPointsLTTB 自 v7.5.0 起映射到真 LTTB(QwtPointMapper::LTTBReduce、qwtLttbReduce()),本文分析的 MinMax 桶算法移到新属性 FilterPointsMinMaxLTTB(MinMaxReduce)。下文第 3 条所称"LTTB 原始指针路径"指 MinMax 桶路径(v7.4.2 及之前 FilterPointsLTTB 即该实现),3.5/3.8 节的 lttb 模式与第 4 节性能数据同理。真 LTTB 的原始指针分支(qwtLttbReduce 的 mapSample,915–922 行)仍只用 std::isnan(y) 判定样本,x 非有限或 ±Inf y 会进入选择;MinMax 侧已修的 #3/#4 同类谓词在新默认路径上延续,6.2/6.4 节的清洗建议对默认模式依旧成立。
  • #3(LTTB x-NaN 伪顶点)、#4(Inf 穿透预扫描)已修复:预扫描谓词改为 !isfinite(y)(拦截 Inf);x 的非有限值由 SIMD 路径的每桶后置校验拦截(仅当存在原始 x 指针 hasRawX),命中桶用标量循环重算(reduceBucketScalar(b));标量桶循环对 x、y 双坐标 !isfinite 跳过,原始指针路径与通用路径语义对齐,不再产出 (0, y) 伪顶点与 INT_MIN 垃圾坐标(回归测试 tests/nan_curve_test 的 lttb_xNan_noPhantomVertex/lttb_inf_noGarbageCoordinates)。注:曾实测"预扫描同时查 x"方案使 1M 干净点 lttb 帧时间回归 +11%(双数组内存流量),故采用桶级后置校验方案。
  • #5(FilterPoints 全 NaN 单顶点)已修复:qwtToPolylineFiltered 增加全 NaN 早退(1245–1248 行),全 NaN 输入返回空多边形,"空多边形 = 无数据"约定恢复(回归测试 filterPoints_allNan_emptyPolygon)。
  • #1/#2(桥接语义)新增库级出口:QwtPlotCurve::BreakAtNan 曲线属性(qwt_plot_curve.h 177 行,默认关闭)把 Lines/Steps 切换为切段语义——每个有限段独立渲染/填充/平滑(drawLines 682–697 行、drawSteps 947–962 行、drawDots 填充分支 807–822 行),缺口即空白;数据端切段(第 6.1 节)仍然可用。有限段索引缓存在 finiteSampleRuns()(qwt_plot_curve.cpp 119–158 行),随数据变更失效。
  • #6/#7/#8 维持原状:#6 为桶化降采样数学固有,#7 无视觉损失,#8 的使用指南滞后内容已随修复同步(中英两版)。
# 失效 触发条件 实测证据 影响
1 桥接弦:NaN 段被一条数据不存在的线段跨越 任何内部 NaN 段 + 任何 Lines 模式/Steps 语义矩阵中 bridged 恒等于 intGaps;视觉差异 0.23%–3.80% 缺口被"焊死",误读为连续信号
2 Steps 的桥接几何更糟:竖直跳变提前到缺口左缘 Steps + 内部缺口 探针 tread=1, riser=1, pre_level=0 阶梯在缺口前就换层
3 LTTB 原始指针路径的 x-NaN 伪顶点:预扫描与标量桶循环只查 y,x-NaN 样本胜出桶极值时屏幕 x = qRound(NaN) = 0(v7.5.0 已修复) x 含 NaN 且该样本为桶极值 xnan_lttb_fakeLeft=1,多边形含 (0,21);xyinter 顶点 3567 vs 通用路径 3215(均为修复前快照) 画布左缘出现伪尖峰
4 Inf 穿过只查 isnan 的预扫描进入 SIMD 路径(v7.5.0 已修复) y 含 ±Inf inf 案例 lttb 垃圾坐标 1;qRound(+Inf)=INT_MIN、qRound(-Inf)=0 顶/底边缘伪线或飞点
5 FilterPoints 全 NaN 输出单顶点 (x, NaN) 多边形(v7.5.0 已修复) 全 NaN + FilterPoints 矩阵 all 行 points:1 顶点、1 垃圾 多边形非空,下游若假设"空=无数据"会误判
6 LTTB 桶重排:NaN 占桶预算,有限区域形状随缺口位置变化 任何 NaN + LTTB leading/trailing 视觉差异 2.7%(无桥接仍非零) 与"逐段渲染"不等价,导出/比对场景需注意
7 可见区间二分对躲过探针的孤立 x-NaN 过扫 稀疏 x-NaN + 缩放轴 探针:覆盖仍为画布全宽,无截断 仅多余计算,无视觉损失
8 文档滞后:使用指南称 Sticks/Steps/MinimizeMemory 不查 NaN — 当前源码四处均有 isSampleNanOrInf 检查(提交 17d0d13) 仅文档问题,语义仍为跳过

清单第 1、2 条是语义选择而非缺陷:跳过语义是 QWT7 相对 Qwt 6 的改进方向(6 时代 NaN 会产出野像素坐标),切段语义从未被承诺。第 3、4 条曾真正值得上报维护者:它们让"检查了 NaN"的路径仍然产出几何上不存在的顶点;两者的修复成本都很低(第 3 条:预扫描与标量桶循环各加一次 x 的 isfinite 检查,或改用 qwt_is_nan_or_inf 对 (x,y) 对判断;第 4 条:预扫描谓词从 isnan 换成 !isfinite),已在 v7.5.0 按此落地:第 4 条直接换谓词,第 3 条因性能取舍改用每桶 x 后置校验。遗留问题:v7.5.0 引入的真 LTTB(现默认 FilterPointsLTTB)原始指针分支仍只查 isnan(y),#3/#4 同类样本仍会进入它的选择(见更新记录首条),在这一路径修复前,x 含 NaN 或 ±Inf 的数据进入任何 LTTB 桶算法前都应先清洗。第 5 条无害但破坏"空多边形 = 无数据"的下游约定,值得在 API 文档里写明(v7.5.0 已用空多边形早退修复)。第 6 条是桶化降采样的固有性质(桶边界依赖样本计数),切段渲染与整段渲染在数学上就不等价,只能靠文档告知。第 7 条经探针确认无视觉损失,记录在案即可。第 8 条提醒读者:本文与使用指南之间的滞后结论不止一处,风格覆盖相关内容在指南中出现两处(绘制风格覆盖表及其警告、"最佳实践"的选择绘制风格段),自动缩放代码片段也与当前源码不符,均以当前源码为准(本次修订已同步更正指南)。

与 nanperf 示例的对照:nanperf 的 8 案例矩阵(Baseline/Leading/LeadingTrailing/Middle/Trailing/XyNan/XyInterleavedNan/AllNan)被 sweep B 原样镜像,其 BenchmarkRunner 的 boundingRect/replot/fps 三段分解被 nan_bench.cpp 继承;SIMD 悬崖 delta 口径由本文按同一公式在 nan_results.csv 上复算(4.4 节其三,nan_bench.cpp 本身不含 analyze 代码)。本文在其上扩展了 isolated/periodic/extremum/xonly/inf/spikenan 六种语义模式、视觉逐像素扫描与 1M 点规模。nanperf 文档记录的 delta(1% 时 −0.19、99% 时 +0.03)与本文(100k 时 −0.29、1M 时 +0.03)同号同量级,"SIMD 悬崖可忽略、数据缩减主导"的结论在两套独立实现上互证。差异点在于本文把"可忽略"的机理说得更细:悬崖被掩盖不是因为标量回退便宜,而是因为帧时间的光栅化主导项与预扫描短路效应同向抵消了它。

跳过语义也有实际收益。单条 QPolygonF 让后续管线保持简单:ClipPolygons 的一次裁剪、渲染平滑(高斯/Savitzky-Golay,见曲线平滑篇)的连续邻域窗口、Fitted 属性的曲线拟合,都假设输入是一条连通折线;切段语义要求这些环节全部改成"逐段处理 + 段间不滤波",否则平滑核会跨越缺口把两侧信号互相污染,桥接弦至少让平滑窗口的行为与无 NaN 时一致(代价是缺口两侧各损失半个窗口的保真)。性能上,跳过语义的映射成本与 NaN 位置无关(sweep B 的 ±10%),实现上五条路径只需一个 continue,不需要维护段表与段间状态。因此 QWT7 用缺口可见性换了管线统一性,这个交换对监控大屏类场景划算、对科学出版类场景不划算,判断标准是读者会不会把桥接弦读成数据。

警告 — 缺口不等于空白

在 QWT7 中,含 NaN 的折线不会在缺口处留白(除非开启 BreakAtNan,它只覆盖 Lines/Steps)。任何把 NaN 当"断开"解读的下游(报表、告警截图、论文图)都需要在数据端显式切段、开启 BreakAtNan,或接受桥接弦并在图注中说明。

6. 代码实践

6.1 数据端:让缺口成为缺口的显式办法

v7.5.0 起也可以开启曲线属性 BreakAtNan 让 Lines/Steps 在渲染端逐段切分(见第 5 节更新记录);本节给出数据端的做法,它不依赖属性、对所有绘制模式与所有管线阶段生效。要理想切段语义,就在喂给曲线之前按 NaN 把序列切成多段、每段一条曲线,这正是视觉扫描的理想参照的构造方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <qwt_plot.h>
#include <qwt_plot_curve.h>
#include <cmath>

// Split a NaN-marked series into one curve per finite run: gap semantics
// independent of the BreakAtNan attribute and of the paint mode.
void attachSplit(QwtPlot* plot, const QVector<double>& x, const QVector<double>& y)
{
    int start = -1;
    for (int i = 0; i <= x.size(); ++i) {
        const bool finite = (i < x.size()) && std::isfinite(x[i]) && std::isfinite(y[i]);
        if (finite && start < 0) {
            start = i;
        } else if (!finite && start >= 0) {
            QwtPlotCurve* c = new QwtPlotCurve();
            c->setSamples(x.mid(start, i - start), y.mid(start, i - start));
            c->attach(plot);          // 每段独立降采样、独立裁剪
            start = -1;
        }
    }
}

代价是段数增多时 item 管理成本与每段独立的降采样预算(短段会落回 Quad 归并);段数在百级以内时预期无感知差异(本文未对多段 item 开销做定量测量)。若业务能接受桥接(例如缺口宽度小于一个像素列),单曲线 + 跳过语义更省内存。

6.2 模式选择与数据卫生

场景 建议
缺口必须显示为空白 数据端切段(6.1)或开启 BreakAtNan 曲线属性(Lines/Steps,v7.5.0 起);模式任选
接受桥接、追求帧率 默认 FilterPointsLTTB;NaN 比例高时收益更大(50% 时 1M 点 13.1 ms)
缺口两侧电平接近、弦不刺眼 FilterPointsAggressive(视觉差异最低 0.23%–1.05%)
x 坐标可能含 NaN 先清洗 x:MinMax 桶的原始指针路径曾对 x-NaN 产伪顶点(清单 #3,v7.5.0 已修复);真 LTTB(默认 FilterPointsLTTB)的原始指针分支仍只查 isnan(y),x 非有限样本会进入选择;清洗后最稳妥
数据可能含 ±Inf 用 qwtRemoveNanOrInf 清洗:MinMax 预扫描自 v7.5.0 起拦 ±Inf(清单 #4 已修复),真 LTTB 原始指针分支仍不拦 ±Inf;清洗是最省事的防御
全 NaN / 空序列 无需特殊处理:包围矩形无效、轴回落 0–1000、画布空白、不崩溃;但自动轴无意义,建议显式 setAxisScale
Sticks / Dots 跳过即正确语义,可直接使用;Steps 有桥接(清单 #2)

使用指南 NaN 数据处理 的"最佳实践"一节给出 qQNaN() 标记缺失样本与 boundingRect 自动跳过的教程视角;本文第 2、3 节解释这些行为背后的比较语义,第 5 节清单是它的边界补充。两篇的结论在"跳过"这一点上完全一致;指南曾滞后的内容涉及风格覆盖相关的两处(覆盖表及其警告、最佳实践段)与自动缩放代码片段(清单 #8,本次修订已同步更正),均以当前源码为准。

6.3 性能侧实践

boundingRect 的冷扫描代价(1M 点约 12–22 ms)只在数据变化时发生一次(QwtPlotSeriesItem 缓存),滚动窗口场景无需担心;但若每帧重建 series 对象(流式数据的常见写法),这笔 \(O(n)\) 会进每帧预算,此时 NaN 比例越高反而越便宜(预扫描短路 + 跳过扩展)。需要每帧重算且数据无 NaN 时,保持默认即可;数据含 NaN 且使用 LTTB 时,记住标量桶循环与 SIMD 桶循环在帧级几乎同价(4.4 节其三),不必为"保住 SIMD"而清洗 NaN。

6.4 进入 LTTB 原始指针路径前的数据卫生

针对清单第 3、4 条,最省事的防御是在 setSamples 前把 x 与 y 按 (x,y) 对联合清洗。core 模块的 qwtRemoveNanOrInfCopy(src/core/qwt_math.h)是对单个容器过滤 NaN/Inf 元素的模板:对 x、y 两个容器分别调用时,两边删除的元素个数一旦不同,索引对齐立即被破坏,这是最常见的错误写法。正确做法是双坐标谓词的联合过滤循环:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <qwt_math.h>
#include <qwt_plot_curve.h>

// Sanitize before feeding the LTTB raw-pointer path: drop every sample whose
// x OR y is NaN/Inf, filtered jointly over (x, y) pairs so the two vectors
// stay index-aligned. This kills the phantom-vertex and Inf hazards (items
// 3, 4 of the failure list). Gaps are still bridged afterwards - split first
// (6.1) if gap semantics are required.
QVector<double> xs, ys;
xs.reserve(xRaw.size());
ys.reserve(yRaw.size());
for (int i = 0; i < xRaw.size(); ++i) {
    if (!qwt_is_nan_or_inf(xRaw[i]) && !qwt_is_nan_or_inf(yRaw[i])) {
        xs.append(xRaw[i]);
        ys.append(yRaw[i]);
    }
}
curve->setSamples(xs, ys);

等价地,也可以把数据组织成 QVector<QPointF> 后直接调用 qwtRemoveNanOrInfCopy:其谓词落到 qwt_is_nan_or_inf(const QPointF&) 双坐标重载上,天然按 (x,y) 对过滤,这正是该重载存在的理由。这种清洗的语义边界也需说明:它删除"x 或 y 任一为 NaN/Inf 的样本",对 y-only 缺口而言正是切段语义的反面(删除后两侧仍会被桥接),它解决的是伪顶点与 Inf 问题,不是缺口语义问题。若数据源保证 x 单调有限(绝大多数时序采集如此),只需确认 y 无 Inf 即可避开第 4 条,第 3 条自然不触发。

7. 小结

QWT7 把 NaN 从"崩溃源"(Qwt 6 的污染包围矩形与野像素)治理成了"被跳过的样本":五条映射路径、四种曲线风格、包围矩形与自动缩放全链路都有显式的 isfinite 检查,这套治理的代价是语义选择:跳过而非切段。语义矩阵、3090 组交叉验证与逐像素视觉扫描把这一选择的后果量化到像素:每个内部缺口在所有 Lines 模式上恰好产生一条桥接弦,与理想切段渲染的差异在 0.23%–3.80% 像素之间,LTTB 还叠加桶重排项。性能侧温和得多:NaN 的净效应是数据缩减,帧时间与预扫描成本都随 NaN 比例下降,SIMD 悬崖在帧级被掩盖(delta −0.29 / +0.03)。边界仍然清晰:需要"缺口即空白"的读者可以开启 BreakAtNan(Lines/Steps,v7.5.0 起)或在数据端切段;x 含 NaN 或 Inf 的数据在进入任何桶算法(含真 LTTB)的原始指针路径前必须清洗。跳过语义是否是 QWT 的长期答案(v7.5.0 已在曲线属性层给出切段开关 BreakAtNan,映射器层仍无切段标志)是设计问题,这里只把现状与代价测量清楚。

  • 语义判断:QWT7 的 NaN 处理是"防崩溃治理"而非"缺口语义设计";把它当 matplotlib 式切段使用会产生系统性误读,桥接弦在宽缺口、大电平差的场景下不可接受。
  • 正确性边界:跳过语义在 Dots/Sticks 上恰好正确、在 Lines/Steps 上恰好错误、在 LTTB 上还多一层桶重排偏差;同一份数据换风格即换语义,接口层没有统一承诺。
  • 性能边界:NaN 不引入任何超线性代价,预扫描与桶循环都是 \(O(n)\) 且常数小;帧时间收益来自数据缩减,1M 点 50% NaN 时各模式加速 1.6–2.0 倍(aggressive 1.64、points 1.91、pixel 1.92、lttb 2.01、none 2.01)。
  • 测量边界:帧级测量无法分离 SIMD 与标量桶归约的纯差值(光栅化主导),这里只报告帧级事实与机理假设;lttb 1M/0% 存在 26 vs 32–37 ms 的会话级双簇(复测读数 31.8/34.9、35.4/37.3),已按 min-of 协议取值并保留复测行。
  • 维护边界:清单第 3、4 条(x-NaN 伪顶点、Inf 穿透预扫描)已在 v7.5.0 修复;但真 LTTB(现默认 FilterPointsLTTB)的原始指针分支仍保留同款 y-only isnan 判定(见第 5 节更新记录),进入前必须清洗 x 与 ±Inf;第 6 条(桶重排)是桶化降采样的数学必然,值得文档。

8. 参考文献

  1. IEEE Computer Society. IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019):有序/无序比较谓词与 NaN 传播规则。
  2. QWT7 源码:src/plot/qwt_point_mapper.cpp(qwtToPolylineFiltered 1222–1268、qwtMapPointsQuad 218–345、qwtFindVisibleRange 349–418、qwtPixelColumnReduce 422–535、qwtMinMaxBucketReduce 625–863、qwtLttbReduce 877–1068、qwtToPoints 1135–1194、toPolygonF 1493–1530)。
  3. QWT7 源码:src/plot/qwt_plot_curve.cpp(drawLines 583–698、drawSticks 710–756、drawDots 768–858、drawSteps 871–963)、src/core/qwt_series_data.cpp(qwtBoundingRectT 217–261)、src/core/qwt_simd_argminmax_avx2.cpp(AVX2 内核与 _CMP_LT_OQ 有序谓词)、src/core/qwt_simd_argminmax.cpp(SSE4.2/标量路径与运行时派发)。
  4. QWT7 示例:examples/2D/nanperf(NanDataGenerator 八案例矩阵、FilterModes.h 五模式、BenchmarkRunner 的 boundingRect/replot/fps 分解与 SIMD 悬崖 delta 分析)。
  5. 本文配套实验:docs/assets/algorithms/nan_experiment.py、nan_mapper_check.cpp、nan_bench.cpp、nan_results.csv、nan_mapper_hashes.csv。
  6. 提交 17d0d13 "fix(nan): add NaN skip to 4 missing paths in QwtPlotCurve":Sticks/Steps/Dots-MinimizeMemory 四处跳过检查的引入点。

配套阅读: LTTB 降采样算法原理与 QWT7 的 MinMax 桶实现 讲三重路径与桶预算的来源;NaN 数据处理(使用指南) 给出教程视角的设置与数据准备。