Skip to content

Commit 63c0697

Browse files
authored
Stop sorting a whole column to size error-bar caps (#264)
`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.
1 parent 119a1ce commit 63c0697

2 files changed

Lines changed: 22 additions & 7 deletions

File tree

‎python/xy/_figure.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,16 +1075,18 @@ def _zero_baseline_anchor(self, axis_id: str) -> Optional[str]:
10751075
continue
10761076
base = t.x0.values if axis == "x" else t.y0.values
10771077
value = t.x1.values if axis == "x" else t.y1.values
1078+
# Ask the questions as masked predicates rather than compacting
1079+
# `base`/`value` down to their finite rows first: the compaction
1080+
# allocates and copies two full columns per axis per build, and
1081+
# every question here is a single "does any row violate this?".
10781082
finite = np.isfinite(base) & np.isfinite(value)
1079-
if not np.any(finite):
1083+
if not finite.any():
10801084
continue
1081-
base = base[finite]
1082-
value = value[finite]
1083-
if not np.all(base == 0.0):
1085+
if (finite & (base != 0.0)).any():
10841086
continue
1085-
if np.all(value >= 0.0):
1087+
if not (finite & (value < 0.0)).any():
10861088
return "lo"
1087-
if np.all(value <= 0.0):
1089+
if not (finite & (value > 0.0)).any():
10881090
return "hi"
10891091
return None
10901092

‎python/xy/marks.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1001,8 +1001,21 @@ def _auto_cap_size(positions: np.ndarray) -> float:
10011001
10021002
0.25x the median adjacent spacing of the distinct finite positions along
10031003
the cap's axis; 0.4 when fewer than two are distinct (no spacing exists).
1004+
1005+
The positions an error bar carries are usually an ordered independent
1006+
variable, and `np.unique` sorts unconditionally — an O(N log N) pass over
1007+
the whole column to answer a question about adjacent gaps. One O(N) diff
1008+
both proves the column is already non-decreasing and yields those gaps
1009+
directly; only an out-of-order column pays for the sort. Same distinct
1010+
values in the same order either way, so the median is identical.
10041011
"""
1005-
distinct = np.unique(positions[np.isfinite(positions)])
1012+
finite = positions[np.isfinite(positions)]
1013+
if len(finite) >= 2:
1014+
gaps = np.diff(finite)
1015+
if (gaps >= 0.0).all():
1016+
positive = gaps[gaps != 0.0]
1017+
return 0.25 * float(np.median(positive)) if len(positive) else 0.4
1018+
distinct = np.unique(finite)
10061019
if len(distinct) < 2:
10071020
return 0.4
10081021
return 0.25 * float(np.median(np.diff(distinct)))

0 commit comments

Comments
 (0)