From 2e4b8ad613f0f84c9c5e95fcbe24021093720680 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Fri, 24 Jul 2026 14:04:46 -0700 Subject: [PATCH] Stop sorting a whole column to size error-bar caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_first_payload_errorbar_large` is the most expensive benchmark in the suite (~557 ms on CodSpeed). Two thirds of it is work neither answer needs. `_auto_cap_size` wants the median adjacent gap between distinct positions, and reached for `np.unique` — which sorts unconditionally, an O(N log N) pass over the full column. Error-bar positions are usually an ordered independent variable, so one O(N) diff both proves the column is already non-decreasing and yields those gaps directly; only an out-of-order column still pays for the sort. Same distinct values in the same order, so the median is identical. `_zero_baseline_anchor` compacted `base`/`value` down to their finite rows before asking three all-or-nothing questions about them, allocating and copying two full columns per axis per build. Each question is really "does any row violate this?", which the finite mask answers in place. NaN rows are excluded by the mask exactly as the compaction excluded them. Measured on the benchmark's shape (1M points, yerr=1.0, decimated tier), min-of-3: first payload 48.07 ms -> 14.09 ms -70.7% _auto_cap_size 27.48 ms -> 2.92 ms -89.4% (1M sorted positions) _zero_baseline 4.44 ms -> 1.99 ms -55.2% (3M rows) Both sit on real build paths, not just the benchmark: cap sizing runs for every auto-cap error bar, and the zero-baseline probe runs for every bar and histogram build. `_auto_cap_size` returns identically over sorted, unsorted, descending, duplicate-heavy, all-equal, single, empty, NaN-bearing and -0.0-bearing columns; `_zero_baseline_anchor` over all-positive, all-negative, mixed, nonzero-base, NaN-bearing, all-NaN and all-zero columns. --- python/xy/_figure.py | 14 ++++++++------ python/xy/marks.py | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 4252f430..80ea37bc 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -1075,16 +1075,18 @@ def _zero_baseline_anchor(self, axis_id: str) -> Optional[str]: continue base = t.x0.values if axis == "x" else t.y0.values value = t.x1.values if axis == "x" else t.y1.values + # Ask the questions as masked predicates rather than compacting + # `base`/`value` down to their finite rows first: the compaction + # allocates and copies two full columns per axis per build, and + # every question here is a single "does any row violate this?". finite = np.isfinite(base) & np.isfinite(value) - if not np.any(finite): + if not finite.any(): continue - base = base[finite] - value = value[finite] - if not np.all(base == 0.0): + if (finite & (base != 0.0)).any(): continue - if np.all(value >= 0.0): + if not (finite & (value < 0.0)).any(): return "lo" - if np.all(value <= 0.0): + if not (finite & (value > 0.0)).any(): return "hi" return None diff --git a/python/xy/marks.py b/python/xy/marks.py index aad4d409..1db275ab 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -1001,8 +1001,21 @@ def _auto_cap_size(positions: np.ndarray) -> float: 0.25x the median adjacent spacing of the distinct finite positions along the cap's axis; 0.4 when fewer than two are distinct (no spacing exists). + + The positions an error bar carries are usually an ordered independent + variable, and `np.unique` sorts unconditionally — an O(N log N) pass over + the whole column to answer a question about adjacent gaps. One O(N) diff + both proves the column is already non-decreasing and yields those gaps + directly; only an out-of-order column pays for the sort. Same distinct + values in the same order either way, so the median is identical. """ - distinct = np.unique(positions[np.isfinite(positions)]) + finite = positions[np.isfinite(positions)] + if len(finite) >= 2: + gaps = np.diff(finite) + if (gaps >= 0.0).all(): + positive = gaps[gaps != 0.0] + return 0.25 * float(np.median(positive)) if len(positive) else 0.4 + distinct = np.unique(finite) if len(distinct) < 2: return 0.4 return 0.25 * float(np.median(np.diff(distinct)))