From 6f57cb23c0653f90bcc40f67e181023397a583b2 Mon Sep 17 00:00:00 2001 From: Alek Date: Tue, 14 Jul 2026 18:20:50 -0700 Subject: [PATCH] Fix CI: bounded hexbin payloads + pyplot per-build overhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from #37 kept CI red on main and every open PR: Hexbin (benchmarks/test_codspeed_kernels.py::test_first_payload_hexbin_core_2d): _emit_hexbin expanded every cell into six fan triangles across seven f32 columns (168 B/cell — 3.17 MB at gridsize=128, 2x the raw input; #45 had loosened the benchmark bound to accept it). Hexagons all share one geometry, so ship centers plus one color value per cell (12 B/cell, 227 KB) and expand locally in each renderer: instanced triangles in the WebGL client (_buildHexbinMark), one polygon per cell in SVG, vectorized triangles in the raster exporter. Headless-Chromium readback is pixel-identical to the old wire shape. The benchmark bound is restored to grid-bounded centers. Perf guardrail (tests/pyplot/test_perf_guardrail.py, 10k case): pyplot's fixed per-build cost had grown to ~2x the declarative baseline locally. Removed per-build revalidation and O(n) temporaries: - styles.compile_axis_style / compile_mark_style and _validate.style_mapping now memoize per exact input dict — the same few rc-derived chrome dicts were re-parsed through the native CSS grammar on every build. - The rc chrome snapshot (_load_rc_chrome) is cached per (RcParams.version, dpi); RcParams now versions its mutations. - plot() gap/path detection drops an astype copy + diff + compare for the native is_sorted kernel and skips the isfinite mask entirely for clean data via a sum probe (non-finite anywhere poisons the sum). Local ratio at 10k: 2.03x -> ~1.6x; 100k: 1.26x. Full suite, ruff, ty, render smoke pass; hexbin WebGL/SVG/PNG verified. --- benchmarks/test_codspeed_kernels.py | 7 +- js/src/50_chartview.js | 54 +++++++++ js/src/55_marks.js | 2 +- python/xy/_payload.py | 42 ++----- python/xy/_raster.py | 34 +++++- python/xy/_svg.py | 71 ++++++++++-- python/xy/_validate.py | 31 ++++++ python/xy/pyplot/_axes.py | 166 ++++++++++++++++++---------- python/xy/pyplot/_rc.py | 31 +++++- python/xy/static/index.js | 49 +++++++- python/xy/static/standalone.js | 49 +++++++- python/xy/styles.py | 47 ++++++++ tests/pyplot/test_perf_guardrail.py | 11 +- tests/test_plot_families.py | 9 +- 14 files changed, 483 insertions(+), 120 deletions(-) diff --git a/benchmarks/test_codspeed_kernels.py b/benchmarks/test_codspeed_kernels.py index 335ed884..332a5a28 100644 --- a/benchmarks/test_codspeed_kernels.py +++ b/benchmarks/test_codspeed_kernels.py @@ -876,9 +876,12 @@ def test_first_payload_hexbin_core_2d(benchmark, medium_data): payload_bytes = benchmark(_hexbin_payload, x, y) grid_height = max(2, int(HEXBIN_GRIDSIZE / np.sqrt(3.0))) max_cells = (HEXBIN_GRIDSIZE + 1) * (grid_height + 1) + HEXBIN_GRIDSIZE * grid_height - # Each cell is six triangles with six coordinate and one color f32 buffer. - max_payload_bytes = max_cells * 6 * 7 * np.dtype(np.float32).itemsize + # Each cell ships as a center (x, y) plus one color value; renderers + # expand the shared hexagon geometry locally, so the payload stays + # grid-bounded and far below the raw input. + max_payload_bytes = max_cells * 3 * np.dtype(np.float32).itemsize assert 0 < payload_bytes <= max_payload_bytes + assert payload_bytes < x.nbytes + y.nbytes def test_first_payload_errorbar_large(benchmark, data): diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index fe38c492..75e0b358 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -1540,6 +1540,60 @@ class ChartView { g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); } + // Hexbin ships cell centers plus one color value per cell; every hexagon + // shares the same geometry (style hex_dx/hex_dy), so the six-triangle fan + // expands here instead of on the wire. Vertices stay in the centers' + // encoded space: stored = (value - offset) * scale, so a data-space delta + // scales by meta.scale and the center columns' metas serve every vertex. + // The ring must match HEX_RING in python/xy/_svg.py. + _buildHexbinMark(g, t, buffer) { + const cx = this._columnView(buffer, this.spec.columns[t.x]); + const cy = this._columnView(buffer, this.spec.columns[t.y]); + const xMeta = { ...this.spec.columns[t.x] }; + const yMeta = { ...this.spec.columns[t.y] }; + const n = Math.min(cx.length, cy.length); + const style = t.style || {}; + const dx = (Number(style.hex_dx) || 0) * (xMeta.scale || 1); + const dy = (Number(style.hex_dy) || 0) * (yMeta.scale || 1); + const ringX = [0, dx / 2, dx / 2, 0, -dx / 2, -dx / 2, 0]; + const ringY = [-dy / 3, -dy / 6, dy / 6, dy / 3, dy / 6, -dy / 6, -dy / 3]; + const parts = {}; + for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) parts[name] = new Float32Array(n * 6); + for (let i = 0; i < n; i++) { + const px = cx[i], py = cy[i]; + for (let k = 0; k < 6; k++) { + const j = i * 6 + k; + parts.x0[j] = px; + parts.y0[j] = py; + parts.x1[j] = px + ringX[k]; + parts.y1[j] = py + ringY[k]; + parts.x2[j] = px + ringX[k + 1]; + parts.y2[j] = py + ringY[k + 1]; + } + } + for (const name of ["x0", "x1", "x2"]) { + g[name + "Meta"] = { ...xMeta }; + g[name + "Buf"] = this._upload(parts[name]); + } + for (const name of ["y0", "y1", "y2"]) { + g[name + "Meta"] = { ...yMeta }; + g[name + "Buf"] = this._upload(parts[name]); + } + g.n = n * 6; + g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); + g.colorMode = 0; + if (t.color && (t.color.mode === "continuous" || t.color.mode === "categorical")) { + g.colorMode = t.color.mode === "continuous" ? 1 : 2; + const cval = this._columnView(buffer, this.spec.columns[t.color.buf]); + const expanded = new Float32Array(n * 6); + for (let i = 0; i < n; i++) expanded.fill(cval[i], i * 6, i * 6 + 6); + g.cBuf = this._upload(expanded); + g.lut = t.color.mode === "continuous" ? this._lut(t.color.colormap) : this._paletteLut(t.color.palette); + } + g.meshStrokeWidth = Number(style.stroke_width) || 0; + g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); + } + _buildAreaMark(g, t, buffer) { const x = this._columnView(buffer, this.spec.columns[t.x]); const y = this._columnView(buffer, this.spec.columns[t.y]); diff --git a/js/src/55_marks.js b/js/src/55_marks.js index 719c86b4..5971839d 100644 --- a/js/src/55_marks.js +++ b/js/src/55_marks.js @@ -166,7 +166,7 @@ const MARK_KINDS = { triangle_mesh: MESH_MARK, error_band: AREA_MARK, hexbin: { - build: (view, g, t, buffer) => view._buildMeshMark(g, t, buffer), + build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); diff --git a/python/xy/_payload.py b/python/xy/_payload.py index 97803584..8cbc1e60 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -414,50 +414,24 @@ def _emit_hexbin( sel = self._finite_sel(t, xv, yv) if sel is not None: xv, yv = xv[sel], yv[sel] - style = self._default_styled(t) - dx = float(style.pop("hex_dx")) - dy = float(style.pop("hex_dy")) - # Six center-fan triangles per occupied cell preserve a true data-space - # tessellation at every aspect ratio and in every renderer. - offsets = np.asarray( - [ - (0.0, -dy / 3.0), - (dx / 2.0, -dy / 6.0), - (dx / 2.0, dy / 6.0), - (0.0, dy / 3.0), - (-dx / 2.0, dy / 6.0), - (-dx / 2.0, -dy / 6.0), - (0.0, -dy / 3.0), - ], - dtype=np.float64, - ) - cx = np.repeat(xv, 6) - cy = np.repeat(yv, 6) - x1 = (xv[:, None] + offsets[:-1, 0]).reshape(-1) - y1 = (yv[:, None] + offsets[:-1, 1]).reshape(-1) - x2 = (xv[:, None] + offsets[1:, 0]).reshape(-1) - y2 = (yv[:, None] + offsets[1:, 1]).reshape(-1) + # Cells ship as centers plus one scalar color value. Every hexagon + # shares the same geometry (style hex_dx/hex_dy), so each renderer + # expands the six-triangle fan locally and the wire cost stays + # O(cells), not O(cells x vertices x channels) (§29). entry = { "id": t.id, "kind": t.kind, "name": t.name, - "style": style, + "style": self._default_styled(t), "tier": "direct", "n_points": t.n_points, "n_marks": int(len(xv)), "x_axis": t.x_axis, "y_axis": t.y_axis, - "x0": pw.ship_values(cx), - "y0": pw.ship_values(cy), - "x1": pw.ship_values(x1), - "y1": pw.ship_values(y1), - "x2": pw.ship_values(x2), - "y2": pw.ship_values(y2), + "x": pw.ship_values(xv), + "y": pw.ship_values(yv), } - triangle_sel = np.repeat( - np.arange(len(t.x), dtype=np.intp) if sel is None else np.asarray(sel), 6 - ) - entry["color"], _size = self._ship_channels(t, triangle_sel, pw.ship_scalar, pw.ship_u8) + entry["color"], _size = self._ship_channels(t, sel, pw.ship_scalar, pw.ship_u8) return entry def _emit_histogram( diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 18ee8b4a..8f125287 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -35,6 +35,7 @@ _step_arrays, _tick_text, axis_ticks, + hexbin_ring, layout, ) @@ -586,7 +587,7 @@ def render_raster( elif kind == "scatter": _emit_scatter(cmd, t, blob, cols, sx, sy, style, color) elif kind == "hexbin": - _emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color) + _emit_hexbin(cmd, t, blob, cols, sx, sy, style, color) elif kind in {"errorbar", "stem", "box_whisker", "box_median", "contour", "segments"}: _emit_segments(cmd, t, blob, cols, sx, sy, style, color) elif kind in ("bar", "column") and t.get("bar"): @@ -1002,12 +1003,9 @@ def _emit_segments(cmd, t, blob, cols, sx, sy, style, color): cmd.segments(sx(x0), sy(y0), sx(x1), sy(y1), width, colors) -def _emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color): - vertices = [_column(blob, cols[t[name]]) for name in ("x0", "y0", "x1", "y1", "x2", "y2")] - n = min(len(values) for values in vertices) +def _mesh_fill_rgba(t, blob, cols, n, style, color): ch = t.get("color") or {} fill_op = _fill_opacity(style) - stroke_op = _stroke_opacity(style) fills = np.empty((n, 4), dtype=np.uint8) fills[:, 3] = max(0, min(255, int(round(fill_op * 255)))) if ch.get("mode") == "continuous": @@ -1019,6 +1017,32 @@ def _emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color): fills[:, :3] = palette_rgb[codes % len(palette_rgb)] else: fills[:, :3] = _parse_color(_css(ch.get("color"), color))[:3] + return fills + + +def _emit_hexbin(cmd, t, blob, cols, sx, sy, style, color): + """Expand shipped cell centers into the six-triangle hexagon fan locally + (the payload carries centers only — see _payload._emit_hexbin).""" + cx = _column(blob, cols[t["x"]]) + cy = _column(blob, cols[t["y"]]) + n = min(len(cx), len(cy)) + ring_x, ring_y = hexbin_ring(style) + ring_x, ring_y = np.append(ring_x, ring_x[0]), np.append(ring_y, ring_y[0]) + x0 = np.repeat(sx(cx[:n]), 6) + y0 = np.repeat(sy(cy[:n]), 6) + x1 = np.asarray(sx(cx[:n, None] + ring_x[None, :-1]), dtype=np.float64).reshape(-1) + y1 = np.asarray(sy(cy[:n, None] + ring_y[None, :-1]), dtype=np.float64).reshape(-1) + x2 = np.asarray(sx(cx[:n, None] + ring_x[None, 1:]), dtype=np.float64).reshape(-1) + y2 = np.asarray(sy(cy[:n, None] + ring_y[None, 1:]), dtype=np.float64).reshape(-1) + fills = np.repeat(_mesh_fill_rgba(t, blob, cols, n, style, color), 6, axis=0) + cmd.triangles(x0, y0, x1, y1, x2, y2, fills, 0.0, (0, 0, 0, 0)) + + +def _emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color): + vertices = [_column(blob, cols[t[name]]) for name in ("x0", "y0", "x1", "y1", "x2", "y2")] + n = min(len(values) for values in vertices) + stroke_op = _stroke_opacity(style) + fills = _mesh_fill_rgba(t, blob, cols, n, style, color) x0, y0, x1, y1, x2, y2 = vertices sw = float(style.get("stroke_width", 0.0)) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 62e6892c..5453a5a1 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1132,7 +1132,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str: marks.append(_scatter_marks(t, blob, cols, sx, sy, style, color)) elif kind == "hexbin": - marks.append(_triangle_mesh_marks(t, blob, cols, sx, sy, style, color)) + marks.append(_hexbin_marks(t, blob, cols, sx, sy, style, color)) elif kind in {"errorbar", "stem", "box_whisker", "box_median", "contour", "segments"}: marks.append(_segment_marks(t, blob, cols, sx, sy, style, color)) @@ -1470,23 +1470,72 @@ def _scatter_marks( return "".join(out) -def _triangle_mesh_marks( - t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str -) -> str: - vertices = [_column(blob, cols[t[name]]) for name in ("x0", "y0", "x1", "y1", "x2", "y2")] - n = min(len(values) for values in vertices) +# The hexagon ring around a hexbin cell center, as fractions of the cell +# pitch (style hex_dx/hex_dy). Shared by the SVG and raster exporters; the JS +# client mirrors it in _buildHexbinMark (js/src/50_chartview.js) — keep them +# in sync. +HEX_RING = ( + (0.0, -1.0 / 3.0), + (0.5, -1.0 / 6.0), + (0.5, 1.0 / 6.0), + (0.0, 1.0 / 3.0), + (-0.5, 1.0 / 6.0), + (-0.5, -1.0 / 6.0), +) + + +def hexbin_ring(style: dict) -> tuple[np.ndarray, np.ndarray]: + """Data-space hexagon vertex offsets (6) for a hexbin trace's cell pitch.""" + ring = np.asarray(HEX_RING, dtype=np.float64) + return ring[:, 0] * float(style.get("hex_dx", 0.0)), ring[:, 1] * float( + style.get("hex_dy", 0.0) + ) + + +def _mesh_fills(t: dict, blob: bytes, cols: list, n: int, fallback: str) -> list[str]: + """Per-mark CSS fill colors from a trace's color channel (n marks).""" color_ch = t.get("color") or {} mode = color_ch.get("mode") if mode == "continuous": values = _column(blob, cols[color_ch["buf"]])[:n] rgb = _lut(color_ch.get("colormap", "viridis"), values) - fills = [f"rgb({r},{g},{b})" for r, g, b in rgb] - elif mode == "categorical": + return [f"rgb({r},{g},{b})" for r, g, b in rgb] + if mode == "categorical": codes = _column(blob, cols[color_ch["buf"]])[:n].astype(int) palette = color_ch.get("palette") or DEFAULT_PALETTE - fills = [palette[code % len(palette)] for code in codes] - else: - fills = [_css(color_ch.get("color"), fallback)] * n + return [palette[code % len(palette)] for code in codes] + return [_css(color_ch.get("color"), fallback)] * n + + +def _hexbin_marks( + t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str +) -> str: + """One hexagon polygon per cell, expanded locally from shipped centers.""" + cx = _column(blob, cols[t["x"]]) + cy = _column(blob, cols[t["y"]]) + n = min(len(cx), len(cy)) + fills = _mesh_fills(t, blob, cols, n, fallback) + ring_x, ring_y = hexbin_ring(style) + xs = np.asarray(sx(cx[:n, None] + ring_x[None, :]), dtype=np.float64) + ys = np.asarray(sy(cy[:n, None] + ring_y[None, :]), dtype=np.float64) + fill_op = _fill_opacity(style) + group_attr = f' fill-opacity="{_num(fill_op)}"' if fill_op < 1 else "" + out = [f""] + for i in range(n): + points = " ".join( + f"{_num(float(x))},{_num(float(y))}" for x, y in zip(xs[i], ys[i], strict=True) + ) + out.append(f'') + out.append("") + return "".join(out) + + +def _triangle_mesh_marks( + t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str +) -> str: + vertices = [_column(blob, cols[t[name]]) for name in ("x0", "y0", "x1", "y1", "x2", "y2")] + n = min(len(values) for values in vertices) + fills = _mesh_fills(t, blob, cols, n, fallback) fill_op = _fill_opacity(style) stroke_op = _stroke_opacity(style) diff --git a/python/xy/_validate.py b/python/xy/_validate.py index 3ef9b885..b5e04374 100644 --- a/python/xy/_validate.py +++ b/python/xy/_validate.py @@ -221,7 +221,38 @@ def _css_property_name(key: str) -> str: return key.replace("_", "-") +# Validated style dicts, keyed by their exact items. Chart chrome ships the +# same few style dicts on every build (theme fonts, rc-derived title/tick +# slots), and validating one is pure — so each distinct dict validates once +# per process, not once per chart build (the pyplot perf guardrail counts on +# this). Only successful validations are cached; the type names keep e.g. +# True from hitting a cached 1 (equal, same hash, different verdict). +_STYLE_MAPPING_CACHE: dict[tuple, dict[str, str | int | float]] = {} + + def style_mapping(value: dict[str, Any], label: str) -> dict[str, str | int | float]: + if isinstance(value, dict): + try: + key = ( + label, + tuple((k, v, type(v).__name__) for k, v in value.items()), + ) + cached = _STYLE_MAPPING_CACHE.get(key) + except TypeError: # unhashable value: validate (and likely raise) below + key = cached = None + if cached is not None: + return dict(cached) + else: + key = None + out = _validate_style_mapping(value, label) + if key is not None: + if len(_STYLE_MAPPING_CACHE) > 4096: + _STYLE_MAPPING_CACHE.clear() + _STYLE_MAPPING_CACHE[key] = dict(out) + return out + + +def _validate_style_mapping(value: dict[str, Any], label: str) -> dict[str, str | int | float]: from . import kernels if not isinstance(value, dict): diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index c5380752..3a2f532c 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -25,7 +25,7 @@ from ._fmt import parse_fmt from ._mathtext import mathtext_to_unicode from ._plot_types import PlotTypeMixin -from ._rc import rcParams +from ._rc import RcParams, rcParams from ._ticker import AutoLocator, NullLocator, ScalarFormatter, as_formatter from ._transforms import Bbox, CoordinateTransform, IdentityTransform from ._translate import ( @@ -51,6 +51,66 @@ # on this staying O(1) per process). _component_cache: dict[tuple, Any] = {} +# rc-derived chrome styling per (RcParams.version, dpi); see _load_rc_chrome. +_rc_chrome_cache: dict[tuple, dict[str, Any]] = {} + + +def _rc_chrome_snapshot(dpi: float) -> dict[str, Any]: + cycle = rcParams["axes.prop_cycle"].by_key().get("color", []) + family = rcParams["font.family"] + family = family if isinstance(family, str) else ", ".join(map(str, family)) + if family == "sans-serif": + family = "DejaVu Sans, sans-serif" + theme_tokens = { + "plot_background": resolve_color(rcParams["axes.facecolor"]), + "axis_color": resolve_color(rcParams["axes.edgecolor"]), + "text_color": resolve_color( + rcParams["axes.labelcolor"] + if rcParams["axes.titlecolor"] == "auto" + else rcParams["axes.titlecolor"] + ), + } + return { + "prop_cycle": [ + resolved for color in cycle if (resolved := resolve_color(color)) is not None + ], + "grid_color": resolve_color(rcParams["grid.color"]) or _MPL_GRID_COLOR, + "theme_tokens": theme_tokens, + "theme_style": { + "font-family": family, + "font-size": f"{_font_size(rcParams['font.size'], rcParams['font.size'], dpi):g}px", + }, + "chrome_styles": { + "title": { + "font-size": ( + f"{_font_size(rcParams['axes.titlesize'], rcParams['font.size'], dpi):g}px" + ), + "color": theme_tokens["text_color"], + }, + "axis_title": { + "font-size": ( + f"{_font_size(rcParams['axes.labelsize'], rcParams['font.size'], dpi):g}px" + ), + "color": resolve_color(rcParams["axes.labelcolor"]), + }, + "tick_label": { + "font-size": ( + f"{_font_size(rcParams['xtick.labelsize'], rcParams['font.size'], dpi):g}px" + ), + "color": resolve_color( + rcParams["xtick.color"] + if rcParams["xtick.labelcolor"] == "inherit" + else rcParams["xtick.labelcolor"] + ), + }, + }, + "hidden_spines": { + side + for side in ("left", "bottom", "top", "right") + if not bool(rcParams[f"axes.spines.{side}"]) + }, + } + def _identity_transform() -> Any: return IdentityTransform() @@ -472,54 +532,27 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: # -- lifecycle ----------------------------------------------------------- def _load_rc_chrome(self) -> None: - """Snapshot the rcParams-derived color cycle, theme, and chrome styles.""" + """Snapshot the rcParams-derived color cycle, theme, and chrome styles. + + The derived snapshot is a pure function of (rcParams state, figure + dpi), so it is computed once per rc state and shared across axes — + per-axes recomputation was measurable in the shim's fixed build cost + (tests/pyplot/test_perf_guardrail.py). Only the members that axes + mutate in place (theme tokens, hidden spines) are copied per axes. + """ dpi = float(self.figure._dpi if self.figure._dpi is not None else rcParams["figure.dpi"]) - cycle = rcParams["axes.prop_cycle"].by_key().get("color", []) - self._prop_cycle = [ - resolved for color in cycle if (resolved := resolve_color(color)) is not None - ] - self._grid_color = resolve_color(rcParams["grid.color"]) or _MPL_GRID_COLOR - self._theme_tokens = { - "plot_background": resolve_color(rcParams["axes.facecolor"]), - "axis_color": resolve_color(rcParams["axes.edgecolor"]), - "text_color": resolve_color( - rcParams["axes.labelcolor"] - if rcParams["axes.titlecolor"] == "auto" - else rcParams["axes.titlecolor"] - ), - } - family = rcParams["font.family"] - family = family if isinstance(family, str) else ", ".join(map(str, family)) - if family == "sans-serif": - family = "DejaVu Sans, sans-serif" - self._theme_style = { - "font-family": family, - "font-size": f"{_font_size(rcParams['font.size'], rcParams['font.size'], dpi):g}px", - } - title_color = self._theme_tokens["text_color"] - self._chrome_styles = { - "title": { - "font-size": f"{_font_size(rcParams['axes.titlesize'], rcParams['font.size'], dpi):g}px", - "color": title_color, - }, - "axis_title": { - "font-size": f"{_font_size(rcParams['axes.labelsize'], rcParams['font.size'], dpi):g}px", - "color": resolve_color(rcParams["axes.labelcolor"]), - }, - "tick_label": { - "font-size": f"{_font_size(rcParams['xtick.labelsize'], rcParams['font.size'], dpi):g}px", - "color": resolve_color( - rcParams["xtick.color"] - if rcParams["xtick.labelcolor"] == "inherit" - else rcParams["xtick.labelcolor"] - ), - }, - } - self._hidden_spines = { - side - for side in ("left", "bottom", "top", "right") - if not bool(rcParams[f"axes.spines.{side}"]) - } + key = (RcParams.version, dpi) + snapshot = _rc_chrome_cache.get(key) + if snapshot is None: + if len(_rc_chrome_cache) > 64: + _rc_chrome_cache.clear() + snapshot = _rc_chrome_cache[key] = _rc_chrome_snapshot(dpi) + self._prop_cycle = snapshot["prop_cycle"] + self._grid_color = snapshot["grid_color"] + self._theme_tokens = dict(snapshot["theme_tokens"]) # set_facecolor mutates + self._theme_style = snapshot["theme_style"] + self._chrome_styles = snapshot["chrome_styles"] + self._hidden_spines = set(snapshot["hidden_spines"]) # spines API mutates def _invalidate(self) -> None: host = self._y2_of or self @@ -898,21 +931,40 @@ def _plot_series( if dash is not None: entry_kwargs["dash"] = dash numeric_x = np.asarray(x) + # finite_pairs stays None for gap-free data: a non-finite value + # anywhere leaves the sum non-finite (inf - inf is nan), so the + # common clean case skips the per-element isfinite mask. Finite + # data whose sum overflows merely takes the exact scan. + finite_pairs = None try: - finite_pairs = np.isfinite(np.asarray(x, dtype=np.float64)) & np.isfinite( - np.asarray(y, dtype=np.float64) - ) + xv64 = np.asarray(x, dtype=np.float64) + yv64 = np.asarray(y, dtype=np.float64) + if not np.isfinite(xv64.sum() + yv64.sum()): + finite_pairs = np.isfinite(xv64) + finite_pairs &= np.isfinite(yv64) except (TypeError, ValueError): - finite_pairs = np.ones(len(x), dtype=bool) - has_gaps = not bool(np.all(finite_pairs)) + pass + has_gaps = finite_pairs is not None and not bool(np.all(finite_pairs)) + if not has_gaps: + finite_pairs = None + from xy import kernels + + # Native sortedness instead of an astype copy + diff + compare — + # this runs on every plot() call, and O(n) temporaries here are + # exactly what the perf guardrail exists to catch. For gap-free + # data (the only case where the value matters — has_gaps routes + # to segments regardless) `not is_sorted` == any(diff < 0). preserve_path = ( - numeric_x.ndim == 1 + not has_gaps + and numeric_x.ndim == 1 and len(numeric_x) > 1 and np.issubdtype(numeric_x.dtype, np.number) - and np.any(np.diff(numeric_x.astype(np.float64)) < 0) + and not kernels.is_sorted(np.asarray(numeric_x, dtype=np.float64)) ) if preserve_path or has_gaps: xv, yv = np.asarray(x), np.asarray(y) + if finite_pairs is None: # parametric path, no gaps + finite_pairs = np.ones(len(xv), dtype=bool) keep = finite_pairs[:-1] & finite_pairs[1:] segment_kwargs = { key: value @@ -3757,13 +3809,13 @@ def _build_chart(self, width: int, height: int) -> Any: if self._grid_axis != "both": tokens = dict(theme_tokens) tokens["grid_color"] = "transparent" - children.append(fc.theme(style=self._theme_style, **tokens)) # ty: ignore[invalid-argument-type] + children.append(fc.theme(style=self._theme_style, **tokens)) elif self._grid_color == _MPL_GRID_COLOR: children.append(_cached_theme(self._grid, theme_tokens, self._theme_style)) else: tokens = dict(theme_tokens) tokens["grid_color"] = self._grid_color if self._grid else "transparent" - children.append(fc.theme(style=self._theme_style, **tokens)) # ty: ignore[invalid-argument-type] + children.append(fc.theme(style=self._theme_style, **tokens)) self._chart = fc.chart( *children, title=self._title, diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index d8f8a649..9403fe8c 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -71,9 +71,38 @@ def by_key(self) -> dict[str, list[str]]: class RcParams(dict): - """Dict with matplotlib's validate-on-set flavor, reduced to warn-on-unknown.""" + """Dict with matplotlib's validate-on-set flavor, reduced to warn-on-unknown. + + ``version`` increments on every mutation so derived snapshots (the axes' + rc-chrome styling) can be cached per rc state instead of recomputed for + every axes (see Axes._load_rc_chrome). + """ + + version: int = 0 + + def __delitem__(self, key: str) -> None: + type(self).version += 1 + super().__delitem__(key) + + def clear(self) -> None: + type(self).version += 1 + super().clear() + + def pop(self, *args: Any) -> Any: + type(self).version += 1 + return super().pop(*args) + + def popitem(self) -> tuple[Any, Any]: + type(self).version += 1 + return super().popitem() + + def setdefault(self, key: str, default: Any = None) -> Any: + if key not in self: + self[key] = default + return self[key] def __setitem__(self, key: str, value: Any) -> None: + type(self).version += 1 if key not in _DEFAULTS and key not in _warned: _warned.add(key) warnings.warn( diff --git a/python/xy/static/index.js b/python/xy/static/index.js index a51bfcff..579bb9d0 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -2966,6 +2966,53 @@ const style = t.style || {}; g.meshStrokeWidth = Number(style.stroke_width) || 0; g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); } +_buildHexbinMark(g, t, buffer) { +const cx = this._columnView(buffer, this.spec.columns[t.x]); +const cy = this._columnView(buffer, this.spec.columns[t.y]); +const xMeta = { ...this.spec.columns[t.x] }; +const yMeta = { ...this.spec.columns[t.y] }; +const n = Math.min(cx.length, cy.length); +const style = t.style || {}; +const dx = (Number(style.hex_dx) || 0) * (xMeta.scale || 1); +const dy = (Number(style.hex_dy) || 0) * (yMeta.scale || 1); +const ringX = [0, dx / 2, dx / 2, 0, -dx / 2, -dx / 2, 0]; +const ringY = [-dy / 3, -dy / 6, dy / 6, dy / 3, dy / 6, -dy / 6, -dy / 3]; +const parts = {}; +for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) parts[name] = new Float32Array(n * 6); +for (let i = 0; i < n; i++) { +const px = cx[i], py = cy[i]; +for (let k = 0; k < 6; k++) { +const j = i * 6 + k; +parts.x0[j] = px; +parts.y0[j] = py; +parts.x1[j] = px + ringX[k]; +parts.y1[j] = py + ringY[k]; +parts.x2[j] = px + ringX[k + 1]; +parts.y2[j] = py + ringY[k + 1]; +} +} +for (const name of ["x0", "x1", "x2"]) { +g[name + "Meta"] = { ...xMeta }; +g[name + "Buf"] = this._upload(parts[name]); +} +for (const name of ["y0", "y1", "y2"]) { +g[name + "Meta"] = { ...yMeta }; +g[name + "Buf"] = this._upload(parts[name]); +} +g.n = n * 6; +g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); +g.colorMode = 0; +if (t.color && (t.color.mode === "continuous" || t.color.mode === "categorical")) { +g.colorMode = t.color.mode === "continuous" ? 1 : 2; +const cval = this._columnView(buffer, this.spec.columns[t.color.buf]); +const expanded = new Float32Array(n * 6); +for (let i = 0; i < n; i++) expanded.fill(cval[i], i * 6, i * 6 + 6); +g.cBuf = this._upload(expanded); +g.lut = t.color.mode === "continuous" ? this._lut(t.color.colormap) : this._paletteLut(t.color.palette); +} +g.meshStrokeWidth = Number(style.stroke_width) || 0; +g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); +} _buildAreaMark(g, t, buffer) { const x = this._columnView(buffer, this.spec.columns[t.x]); const y = this._columnView(buffer, this.spec.columns[t.y]); @@ -5957,7 +6004,7 @@ segments: SEGMENT_MARK, triangle_mesh: MESH_MARK, error_band: AREA_MARK, hexbin: { -build: (view, g, t, buffer) => view._buildMeshMark(g, t, buffer), +build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 46b2f1ff..f4695828 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -2967,6 +2967,53 @@ const style = t.style || {}; g.meshStrokeWidth = Number(style.stroke_width) || 0; g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); } +_buildHexbinMark(g, t, buffer) { +const cx = this._columnView(buffer, this.spec.columns[t.x]); +const cy = this._columnView(buffer, this.spec.columns[t.y]); +const xMeta = { ...this.spec.columns[t.x] }; +const yMeta = { ...this.spec.columns[t.y] }; +const n = Math.min(cx.length, cy.length); +const style = t.style || {}; +const dx = (Number(style.hex_dx) || 0) * (xMeta.scale || 1); +const dy = (Number(style.hex_dy) || 0) * (yMeta.scale || 1); +const ringX = [0, dx / 2, dx / 2, 0, -dx / 2, -dx / 2, 0]; +const ringY = [-dy / 3, -dy / 6, dy / 6, dy / 3, dy / 6, -dy / 6, -dy / 3]; +const parts = {}; +for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) parts[name] = new Float32Array(n * 6); +for (let i = 0; i < n; i++) { +const px = cx[i], py = cy[i]; +for (let k = 0; k < 6; k++) { +const j = i * 6 + k; +parts.x0[j] = px; +parts.y0[j] = py; +parts.x1[j] = px + ringX[k]; +parts.y1[j] = py + ringY[k]; +parts.x2[j] = px + ringX[k + 1]; +parts.y2[j] = py + ringY[k + 1]; +} +} +for (const name of ["x0", "x1", "x2"]) { +g[name + "Meta"] = { ...xMeta }; +g[name + "Buf"] = this._upload(parts[name]); +} +for (const name of ["y0", "y1", "y2"]) { +g[name + "Meta"] = { ...yMeta }; +g[name + "Buf"] = this._upload(parts[name]); +} +g.n = n * 6; +g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); +g.colorMode = 0; +if (t.color && (t.color.mode === "continuous" || t.color.mode === "categorical")) { +g.colorMode = t.color.mode === "continuous" ? 1 : 2; +const cval = this._columnView(buffer, this.spec.columns[t.color.buf]); +const expanded = new Float32Array(n * 6); +for (let i = 0; i < n; i++) expanded.fill(cval[i], i * 6, i * 6 + 6); +g.cBuf = this._upload(expanded); +g.lut = t.color.mode === "continuous" ? this._lut(t.color.colormap) : this._paletteLut(t.color.palette); +} +g.meshStrokeWidth = Number(style.stroke_width) || 0; +g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); +} _buildAreaMark(g, t, buffer) { const x = this._columnView(buffer, this.spec.columns[t.x]); const y = this._columnView(buffer, this.spec.columns[t.y]); @@ -5958,7 +6005,7 @@ segments: SEGMENT_MARK, triangle_mesh: MESH_MARK, error_band: AREA_MARK, hexbin: { -build: (view, g, t, buffer) => view._buildMeshMark(g, t, buffer), +build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); diff --git a/python/xy/styles.py b/python/xy/styles.py index 797a678e..08620bc5 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -60,6 +60,41 @@ ) +# Compiled style dicts keyed by their exact input items. Charts rebuild with +# the same handful of style dicts (rc-derived axis chrome, repeated mark +# styles), and compilation — normalization, native CSS parsing, px/opacity +# checks — is a pure function of (kind, label, dict), so each distinct input +# compiles once per process instead of once per chart build (the pyplot perf +# guardrail counts on this). Only successful compilations are cached; the +# type names keep e.g. True from hitting a cached 1 (equal, same hash, +# different verdict). +_COMPILE_CACHE: dict[tuple, dict[str, Any]] = {} + + +def _compile_cached(cache_kind: str, label: str, value: Any, compute: Any) -> dict[str, Any]: + if value is None or isinstance(value, dict): + try: + items = ( + () if value is None else tuple((k, v, type(v).__name__) for k, v in value.items()) + ) + key = (cache_kind, label, items) + hit = _COMPILE_CACHE.get(key) + except TypeError: # unhashable value: compile (and likely raise) below + key = hit = None + if hit is not None: + # Fresh top-level dict and fresh lists (dasharrays): callers own + # their copy outright, nothing aliases the cache. + return {k: list(v) if isinstance(v, list) else v for k, v in hit.items()} + else: + key = None + out = compute() + if key is not None: + if len(_COMPILE_CACHE) > 4096: + _COMPILE_CACHE.clear() + _COMPILE_CACHE[key] = {k: list(v) if isinstance(v, list) else v for k, v in out.items()} + return out + + def normalize_css_style(value: StyleMapping | None, label: str = "style") -> dict[str, StyleValue]: """Validate and canonicalize a CSS declaration mapping. @@ -211,6 +246,12 @@ def compile_mark_style( ) -> dict[str, Any]: """Compile standard CSS declarations into a mark builder's keyword args.""" label = label or f"{kind} style" + return _compile_cached( + f"mark:{kind}", label, value, lambda: _compile_mark_style(kind, value, label) + ) + + +def _compile_mark_style(kind: str, value: StyleMapping | None, label: str) -> dict[str, Any]: style = normalize_css_style(value, label) supported = set(_supported_mark_style_properties(kind)) unknown = sorted(set(style) - supported) @@ -277,6 +318,12 @@ def compile_axis_style( The more explicit ``tick_label_*`` keys are retained for the pyplot adapter and can differ from the tick-mark paint. """ + return _compile_cached("axis", label, value, lambda: _compile_axis_style(value, label)) + + +def _compile_axis_style( + value: StyleMapping | None, label: str = "axis style" +) -> dict[str, StyleValue]: style = normalize_css_style(value, label) supported = ( _AXIS_COLOR_PROPERTIES diff --git a/tests/pyplot/test_perf_guardrail.py b/tests/pyplot/test_perf_guardrail.py index bec02dd9..488c7c5e 100644 --- a/tests/pyplot/test_perf_guardrail.py +++ b/tests/pyplot/test_perf_guardrail.py @@ -1,11 +1,12 @@ """The shim's speed promise, as a test: pyplot builds the same chart the declarative API builds, plus only figure-lifecycle bookkeeping. -Locally measured (2026-07-10, M-series): +9% at 10k points, +2% at 100k, -+0.6% at 1M. The gate uses generous headroom plus a small absolute allowance -for sub-millisecond timer jitter on CI runners — it exists to catch structural -regressions (an O(n) copy or per-build revalidation sneaking into the shim), -not to re-measure the margin. +Locally measured (2026-07-14, M-series): +60% at 10k points, +26% at 100k — +the 10k number is ~50us of fixed per-figure bookkeeping over an ~85us +baseline, not O(n) work. The gate uses generous headroom plus a small +absolute allowance for sub-millisecond timer jitter on CI runners — it exists +to catch structural regressions (an O(n) copy or per-build revalidation +sneaking into the shim), not to re-measure the margin. """ from __future__ import annotations diff --git a/tests/test_plot_families.py b/tests/test_plot_families.py index 0183abaa..fb73fc05 100644 --- a/tests/test_plot_families.py +++ b/tests/test_plot_families.py @@ -60,8 +60,13 @@ def test_hexbin_is_screen_bounded_and_contour_emits_isolines() -> None: assert hex_spec["traces"][0]["kind"] == "hexbin" ny = int(32 / np.sqrt(3.0)) assert hex_spec["traces"][0]["n_marks"] <= (32 + 1) * (ny + 1) + 32 * ny - assert all(key in hex_spec["traces"][0] for key in ("x0", "y0", "x1", "y1", "x2", "y2")) - assert "x" not in hex_spec["traces"][0] + # Centers plus one color value per cell; renderers expand the shared + # hexagon geometry (style hex_dx/hex_dy) locally instead of shipping + # six vertices per cell across seven columns. + assert all(key in hex_spec["traces"][0] for key in ("x", "y", "color")) + assert "x0" not in hex_spec["traces"][0] + assert hex_spec["traces"][0]["style"]["hex_dx"] > 0 + assert hex_spec["traces"][0]["style"]["hex_dy"] > 0 assert contour_spec["traces"][0]["kind"] == "contour" assert contour_spec["traces"][0]["n_marks"] > 0