Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,34 @@ in the README).
to the internal engine object.

### Changed
- **Colored huge-scatter builds are peak-memory-bounded (LOD doc §4.4).**
The mean-color feature's one-time costs no longer scale peak RSS with N ×
temporaries: the full-column color-source quantize now runs chunked
(bitwise-identical math, transient temporaries bounded by
`channels._QUANTIZE_CHUNK` instead of several × N — ~20 GB at 1e9 rows
before), the colored pyramid's build scan sheds workers so its 40 B/cell
accumulators stay inside a 1 GiB budget (an adaptive 8192² base level
builds serial: one 2.7 GB accumulator, not four), and no-rescan traces
(huge or out-of-core) resolve without retaining the per-row idx — after
the pyramid exists, every interactive reply composes prebuilt color
planes, so retention was resident cost with no consumer. Measured at 205M
rows: the first colored `density_view` now adds 0.96 GB of peak RSS over
the canonical columns instead of 3.96 GB, and builds ~35% faster; a
colored 1e9-point build's transient peak drops to at-or-below the
count-only build's own fan-out, restoring 1B-point capability wherever
count-only main could run.
- **Bin-color resolution is resolved once per trace, never per request
(LOD doc §2).** `density_view` used to quantize the *entire* color column
into the kernel's LUT-index/RGBA source on every reply — an O(N) NumPy pass
with multi-GB temporaries that cost the 100M-point FastAPI drilldown demo
1.3–7 s per request, ~10–100× the actual tier work, even for pyramid and
points-band replies that never consume it. The resolution is now cached on
the trace (`interaction.trace_bin_colors`; a rebuildable §27 derived buffer,
itemized as `memory_report()["bin_color_bytes"]`, invalidated by appends)
and materialized only by the branches that feed `bin_2d_mean_color`. On the
demo host every drilldown request now completes in 0.02–0.45 s with
byte-identical replies; `test_adaptive_drilldown_cycle_mean_color` guards
the contract in CodSpeed.
- **No sampled points above the resolution of the graph (#225).** Interactive
`density_view` replies no longer ship a point-sample overlay: a fixed-size
sample above the drill budget reads as individual data points at a zoom
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"name": "Adaptive scatter drilldown",
"why": "The large-data claim needs a credible path from overview to exact visible points.",
"metrics": "visible-query latency, tier-switch latency, exact-point recovery, badge accuracy",
"harness": "benchmarks/test_codspeed_kernels.py::test_adaptive_drilldown_cycle",
"harness": "benchmarks/test_codspeed_kernels.py::test_adaptive_drilldown_cycle and ::test_adaptive_drilldown_cycle_mean_color (channel-bearing: per-request cost must exclude full-column color work)",
"status": "tracked",
"goal": "Exact points when visible count is under budget; sampled/density with explicit counts otherwise.",
},
Expand Down
112 changes: 98 additions & 14 deletions benchmarks/test_codspeed_kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,29 @@ def compatibility_kernel_data() -> dict[str, np.ndarray]:
}


def _expect_drill_window(fig: Figure, x: np.ndarray, y: np.ndarray) -> None:
"""Stash the deep view's expected drill window and count on the figure.

A drill reply ships the widest ALIGNED window around the view that still
fits the budget (LOD doc T13), so `visible` counts the SHIPPED window —
the reply's own `x_range`/`y_range` — not the raw view. Alignment is a
pure function of the extent and the view's span bucket, so every
benchmark iteration resolves to the same window and the same exact count;
reading both from a warm reply keeps the assertions exact without
hardcoding the pad ladder.
"""
deep, _ = fig.density_view(0, 0.0, 10.0, 0.0, 10.0, GRID_W, GRID_H)
trace = deep["traces"][0]
assert trace["mode"] == "points"
(wx0, wx1), (wy0, wy1) = trace["x_range"], trace["y_range"]
assert wx0 <= 0.0 and wx1 >= 10.0 and wy0 <= 0.0 and wy1 >= 10.0
fig._benchmark_deep_window = (wx0, wx1, wy0, wy1)
fig._benchmark_deep_expected = int(
np.count_nonzero((x >= wx0) & (x <= wx1) & (y >= wy0) & (y <= wy1))
)
assert fig._benchmark_deep_expected == trace["visible"]


@pytest.fixture(scope="module")
def drilldown_figure() -> Figure:
rng = np.random.default_rng(17)
Expand All @@ -235,22 +258,33 @@ def drilldown_figure() -> Figure:
# Warm the lazily-built pyramid so CodSpeed tracks interactive viewport
# refresh cost, not one-time index construction.
fig.density_view(0, 0.0, 100.0, 0.0, 100.0, GRID_W, GRID_H)
deep, _ = fig.density_view(0, 0.0, 10.0, 0.0, 10.0, GRID_W, GRID_H)
_expect_drill_window(fig, x, y)
fig.density_view(0, 0.0, 100.0, 0.0, 100.0, GRID_W, GRID_H)
return fig

# The drill ships the widest ALIGNED window around the view that still
# fits the budget (LOD doc T13), so the count oracle is the SHIPPED
# window's, taken from the warm-up reply — alignment is deterministic,
# every benchmark iteration resolves to the same window.
trace = deep["traces"][0]
assert trace["mode"] == "points"
(wx0, wx1), (wy0, wy1) = trace["x_range"], trace["y_range"]
assert wx0 <= 0.0 and wx1 >= 10.0 and wy0 <= 0.0 and wy1 >= 10.0
fig._benchmark_deep_window = (wx0, wx1, wy0, wy1)
fig._benchmark_deep_expected = int(
np.count_nonzero((x >= wx0) & (x <= wx1) & (y >= wy0) & (y <= wy1))
)
assert fig._benchmark_deep_expected == trace["visible"]

@pytest.fixture(scope="module")
def colored_drilldown_figure() -> Figure:
"""The drilldown workload with a continuous color channel — the FastAPI
100M-demo shape, where every grid reply carries the mean-color plane
(LOD doc §2) and drills restore per-point channels.

Warm calls build the colored pyramid AND the trace's cached full-column
color resolution (`interaction.trace_bin_colors`), so the measured region
tracks steady-state per-request cost. That cost must stay free of any
O(N) full-column channel pass: before the resolution was cached, every
reply of every tier re-quantized the whole color column, costing the 100M
drilldown demo 1-2 s per request against ~10-400 ms of real tier work.
"""
rng = np.random.default_rng(23)
x = rng.uniform(0.0, 100.0, DRILL_N).astype(np.float64, copy=False)
y = rng.uniform(0.0, 100.0, DRILL_N).astype(np.float64, copy=False)
color = np.hypot(x - 50.0, y - 50.0)
fig = xy.chart(xy.scatter(x=x, y=y, color=color, colormap="viridis", density=True)).figure()

fig.density_view(0, 0.0, 100.0, 0.0, 100.0, GRID_W, GRID_H)
_expect_drill_window(fig, x, y)
fig.density_view(0, 0.0, 100.0, 0.0, 100.0, GRID_W, GRID_H)
return fig


Expand Down Expand Up @@ -1112,6 +1146,56 @@ def test_adaptive_drilldown_cycle(benchmark, drilldown_figure):
assert result > DRILL_N


def _colored_drilldown_cycle(fig: Figure) -> int:
wide, wide_buffers = fig.density_view(0, 0.0, 100.0, 0.0, 100.0, GRID_W, GRID_H)
# ~12.5% of the uniform domain: between the drill budget and the pyramid
# margin, so this window takes the exact re-bin plus its mean-color scan.
mid, mid_buffers = fig.density_view(0, 0.0, 39.0, 0.0, 32.0, GRID_W, GRID_H)
deep, deep_buffers = fig.density_view(0, 0.0, 10.0, 0.0, 10.0, GRID_W, GRID_H)

wide_trace = wide["traces"][0]
mid_trace = mid["traces"][0]
deep_trace = deep["traces"][0]
assert wide_trace["mode"] == "density"
assert str(wide_trace.get("binning", "")).startswith("pyramid-L")
assert wide_trace["density"].get("color_agg") == "mean" # prebuilt planes rode along
assert mid_trace["mode"] == "density"
assert mid_trace["binning"] == "exact"
assert mid_trace["density"].get("color_agg") == "mean" # cached-idx mean-color scan
assert deep_trace["mode"] == "points"
# T13: the shipped window is the deterministic padded aligned superset of
# the view, so both the window and its exact count repeat every cycle.
assert tuple(deep_trace["x_range"]) == fig._benchmark_deep_window[:2]
assert tuple(deep_trace["y_range"]) == fig._benchmark_deep_window[2:]
assert deep_trace["visible"] == fig._benchmark_deep_expected
assert deep_trace["color"]["mode"] == "continuous" # drill restores channels

back, back_buffers = fig.density_view(0, 0.0, 100.0, 0.0, 100.0, GRID_W, GRID_H)
back_trace = back["traces"][0]
assert back_trace["mode"] == "density"
assert str(back_trace.get("binning", "")).startswith("pyramid-L")
return (
int(wide_trace["visible"])
+ int(mid_trace["visible"])
+ int(deep_trace["visible"])
+ int(back_trace["visible"])
+ sum(len(buffer) for buffer in wide_buffers + mid_buffers + deep_buffers + back_buffers)
)


def test_adaptive_drilldown_cycle_mean_color(benchmark, colored_drilldown_figure):
"""The drilldown cycle on a channel-bearing trace (the FastAPI demo shape).

Regression tripwire for per-request full-column channel work: every reply
here must reuse the trace's cached bin-color resolution (LOD doc §2), so
the cycle costs the tier work itself — pyramid compose, one exact re-bin
with its mean-color scan, one drill gather. Re-resolving the channel per
request multiplies this row by the column length and shows up as a
step-function regression."""
result = benchmark(_colored_drilldown_cycle, colored_drilldown_figure)
assert result > DRILL_N


def test_stratified_sample_mask(benchmark):
"""Materialized-id category-stratified sampler used by viewport subsets."""
n = 1_000_000
Expand Down
8 changes: 8 additions & 0 deletions examples/fastapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ on first use:
XY_LIVE_POINTS=1000000 uv run uvicorn app:app
```

Note the drill-to-points behavior is scale-gated by the engine
(`xy.config.PYRAMID_NO_RESCAN_ROWS`, 200M): above that row count — or for
disk-backed columns — every zoom is answered from the density pyramid,
upsampled at its floor, and exact points never ship (the O(N) window rescan
that drilling requires is forbidden in that regime; LOD doc §28). The badge
reads `… density · aggregate floor` when a view is in that state, so a 1B-row
run that "never reaches points" is the recorded contract, not a bug.

## Layout

| File | Role |
Expand Down
18 changes: 17 additions & 1 deletion examples/fastapi/live_drilldown.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,23 @@ def live_drilldown_html() -> str:
const trace = message.traces && message.traces[0];
if (trace && trace.mode && message.seq === view.seq) {{
const visible = Number(trace.visible || 0).toLocaleString();
statusEl.textContent = trace.mode === "points" ? `${{visible}} points` : `${{visible}} density`;
if (trace.mode === "points") {{
statusEl.textContent = `${{visible}} points`;
statusEl.title = "";
}} else {{
// Surface the engine's recorded tier decision (§28): past
// PYRAMID_NO_RESCAN_ROWS (or for out-of-core columns) every zoom is
// served from the pyramid — upsampled at its floor — and exact
// points are unavailable BY DESIGN, however deep the window. Without
// this note that regime reads as a drilldown that mysteriously
// refuses to resolve.
const floored = String(trace.binning || "").endsWith("-upsampled");
statusEl.textContent = `${{visible}} density${{floored ? " · aggregate floor" : ""}}`;
statusEl.title = floored
? "No-rescan regime (source rows exceed the exact-rescan bound): "
+ "zooms are served from the density pyramid and exact points never ship."
: "";
}}
}}
}} catch (err) {{
statusEl.textContent = "offline";
Expand Down
6 changes: 5 additions & 1 deletion python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1830,8 +1830,12 @@ def memory_report(self) -> dict[str, Any]:
n_total = sum(t.n_points for t in self.traces) or 1
report["transport_bytes_per_point"] = len(blob) / n_total
report["pyramid_bytes"] = interaction.pyramid_report_bytes(self)
report["bin_color_bytes"] = interaction.bin_color_cache_bytes(self)
report["resident_array_bytes"] = (
report["canonical_bytes"] + report["channel_bytes"] + report["pyramid_bytes"]
report["canonical_bytes"]
+ report["channel_bytes"]
+ report["pyramid_bytes"]
+ report["bin_color_bytes"]
)
report["backend"] = kernels.BACKEND
return report
Expand Down
6 changes: 4 additions & 2 deletions python/xy/_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import numpy as np

from . import _native, channels, kernels, lod
from . import _native, channels, interaction, kernels, lod
from ._trace import Trace
from .columns import Column
from .config import (
Expand Down Expand Up @@ -1062,7 +1062,9 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d
else channels.DEFAULT_COLORMAP
)
dropped_channels = list(t.per_item_channel_names())
bin_colors = channels.resolve_bin_colors(t.color_ch, None, DEFAULT_PALETTE)
# Cached full-column resolution (LOD doc §2): the O(N) quantize pass
# is shared with the pyramid build and every later grid reply.
bin_colors = interaction.trace_bin_colors(t)
density = {
"buf": pw.ship_u8(encoded_grid),
"w": w,
Expand Down
9 changes: 9 additions & 0 deletions python/xy/_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ class Trace:
drill_history: dict[int, Any] = field(
default_factory=dict, init=False, repr=False, compare=False
)
# Full-column kernel color source for mean-color density binning (LOD doc
# §2), managed by `interaction.trace_bin_colors`: None = never resolved,
# 0 = resolved and not applicable (no channel / constant), otherwise the
# `resolve_bin_colors` kwargs dict. Resolving quantizes every canonical row
# — an O(N) pass — while the result is a pure function of the immutable
# channel values and their global domain, so it is computed once and shared
# by every grid consumer (pyramid build, exact re-bins, first-paint emit).
# A rebuildable derived cache (§27); appends invalidate it.
_bin_colors: Any = field(default=None, init=False, repr=False, compare=False)
# Count-pyramid cache (§5 Tier 3), managed by `interaction.py`: None =
# never tried, 0 = tried and not applicable, otherwise the native handle.
# The finalizer frees the native side when the trace is collected.
Expand Down
58 changes: 51 additions & 7 deletions python/xy/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,49 @@ def bins_mean_color(cc: Optional[ColorChannel]) -> bool:
return cc is not None and cc.mode in ("continuous", "categorical", "direct_rgba")


# Chunk length for full-column color-source quantization. The math is
# element-wise, so chunking changes nothing but the transient footprint: a
# one-shot pipeline materializes several full-length f64 temporaries at once
# (~20 GB at 1e9 rows — the difference between a colored billion-point build
# fitting in RAM or not), while chunked passes keep every temporary at chunk
# size and the only N-sized allocation is the u8 result.
_QUANTIZE_CHUNK = 1 << 22


def _quantized_lut_idx(values: npt.NDArray[np.float64], domain: tuple[float, float]) -> np.ndarray:
"""Continuous values -> u8 LUT texel indices, chunk-bounded temporaries.

Per-element math is exactly the historical one-shot chain —
`normalize_to_unit` (f32), widen to f64, ×255, `rint`, cast u8 — applied
per chunk, so results are bitwise identical while peak memory stays
O(chunk) + the N-byte output."""
out = np.empty(len(values), dtype=np.uint8)
for start in range(0, len(values), _QUANTIZE_CHUNK):
end = start + _QUANTIZE_CHUNK
unit = normalize_to_unit(values[start:end], domain)
out[start:end] = np.rint(np.asarray(unit, dtype=np.float64) * 255.0).astype(np.uint8)
return out


def _quantized_rgba8(values: npt.NDArray[np.float64]) -> np.ndarray:
"""Float RGBA rows -> straight-alpha RGBA8, chunk-bounded temporaries."""
out = np.empty(values.shape, dtype=np.uint8)
for start in range(0, len(values), _QUANTIZE_CHUNK):
end = start + _QUANTIZE_CHUNK
seg = values[start:end]
out[start:end] = np.rint(np.clip(seg, 0.0, 1.0) * 255.0).astype(np.uint8)
return out


def _folded_codes_u8(codes: np.ndarray, n_palette: int) -> np.ndarray:
"""Wide categorical codes -> u8 palette rows (mod fold), chunk-bounded."""
out = np.empty(len(codes), dtype=np.uint8)
for start in range(0, len(codes), _QUANTIZE_CHUNK):
end = start + _QUANTIZE_CHUNK
out[start:end] = (codes[start:end] % n_palette).astype(np.uint8)
return out


def resolve_bin_colors(cc: Optional[ColorChannel], sel: Any, palette: list[str]) -> Optional[dict]:
"""Kernel color source for mean-color density binning (LOD doc §2).

Expand All @@ -579,18 +622,17 @@ def resolve_bin_colors(cc: Optional[ColorChannel], sel: Any, palette: list[str])
if rgba is None:
raise ValueError("direct RGBA color channel missing values")
values = rgba if sel is None else rgba[sel]
return {"rgba": np.rint(np.clip(values, 0.0, 1.0) * 255.0).astype(np.uint8)}
return {"rgba": _quantized_rgba8(values)}
if cc.mode == "continuous":
values = cc.values
domain = cc.domain
if values is None or domain is None:
raise ValueError("continuous color channel missing values or domain")
vals = values if sel is None else values[sel]
# Same normalization the wire ships, quantized to the nearest of the
# client's 256 LUT texels.
unit = normalize_to_unit(vals, domain)
idx = np.rint(np.asarray(unit, dtype=np.float64) * 255.0).astype(np.uint8)
return {"idx": idx, "lut": colormap_lut_rgba8(cc.colormap)}
# client's 256 LUT texels (chunked: full-column calls keep transient
# temporaries chunk-bounded instead of several × N).
return {"idx": _quantized_lut_idx(vals, domain), "lut": colormap_lut_rgba8(cc.colormap)}
code_values = cc.codes
categories = cc.categories
if code_values is None or categories is None:
Expand All @@ -601,8 +643,10 @@ def resolve_bin_colors(cc: Optional[ColorChannel], sel: Any, palette: list[str])
# >256 categories ship wide codes; palette colors repeat every
# len(palette) categories, so folding the codes onto the base palette
# bins each point with exactly the color it draws with.
folded = (codes % len(palette)).astype(np.uint8)
return {"idx": folded, "lut": palette_rgba8(palette, len(palette))}
return {
"idx": _folded_codes_u8(codes, len(palette)),
"lut": palette_rgba8(palette, len(palette)),
}


def ship_channels(
Expand Down
Loading
Loading