diff --git a/CHANGELOG.md b/CHANGELOG.md index b64223e4..82f041fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,50 @@ in the README). same validated style properties, so an explicit `style=` still wins and specs that don't use them are byte-identical. +### Changed +- Peak memory cut on four paths, with byte-identical output everywhere (89 + payload/export/view fingerprints pinned before and after): + - The indexed-palette PNG encoder stages one scanline buffer and hands it to + zlib directly instead of building a per-row `bytes` list, joining it, and + narrowing an `intp`-per-pixel `np.unique` inverse. A 1800×840 export peaks + at 6 MB instead of 50 MB (33 MB instead of 274 MB at 4K) and encodes ~1.8x + faster; the truecolor branch drops ~17%. + - Full-column color quantization (density mean-color planes, `direct_rgba` + channels, u8 live-wire channels) runs chunk-bounded and in place. Its chunk + was 4M rows, so every real column still paid the one-shot peak: a 2.1M-row + continuous color channel resolved in 44 MB and now resolves in 7 MB, taking + a colored 2.1M-point first paint from 181 MB to 147 MB of RSS. + - Direct-tier scatter/line/area payloads skip the all-visible row mask. Zone + maps already count NaN *and* ±inf as null, so on linear axes with no nulls + the mask is provably all-true; it was three O(N) passes and two N-byte + temporaries per build. Emit is 16–35% faster, and area no longer allocates + an identity index vector (nor gathers animation keys through it). + - SVG documents assemble in one flat join with block-buffered markers, and the + native rasterizer borrows the display list instead of freezing a `bytes` + copy. A 100k-point SVG export peaks at 27 MB instead of 39 MB. + - The JPEG encoder streams instead of exploding: the entropy packer works in + bounded bit passes (it cost 17 bytes per output *bit*, so a 2.8 MB stream + peaked over 1.5 GB), the YCbCr planes are released as they are consumed, the + quantize chain rounds in place via `trunc(x + copysign(0.5, x))`, and the + per-component token fields are freed as they are gathered. A 1800x840 export + peaks at 48 MB instead of 108 (photographic: **400 MB instead of 1516 MB**) + and is 5-19% faster. + - Standalone HTML export joins the document once from parts, with every large + string — the client bundle, the spec, each base64 chunk — as its own part, so + the join copies it exactly once. Previously the chunks were folded through a + `"\n".join(...)` and then into an f-string, duplicating 4/3 of the payload. A + 1M-point export peaks at 33 MB instead of 41; a small export (where the + ~330 KB client bundle is the document) is ~2% faster. + - The native rasterizer's serial point and segment passes no longer + materialize an `0..n` index vector to read back in order (4 bytes per mark), + and the per-point density quantizer keeps one temporary instead of three. +- `memory_report()` reports `capacity_bytes` per column and + `canonical_capacity_bytes` per store, and builds `resident_array_bytes` from + the capacity total. A streamed column's `values` is a prefix view of its + capacity-doubling buffer, so up to half of what it holds was invisible to the + report (§27: if a number isn't in the report, it isn't real). Figures that + never appended report exactly what they did before. + ### Fixed - The colorbar stringified its colormap, so a custom ramp reached it as an unparseable name and silently painted viridis while the marks beside it diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 19985bca..fbbddcf8 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -1871,8 +1871,10 @@ def memory_report(self) -> dict[str, Any]: 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) + # Capacity, not live length: a streamed column's growth-buffer slack is + # resident RAM (§27), and equals `canonical_bytes` when nothing appended. report["resident_array_bytes"] = ( - report["canonical_bytes"] + report["canonical_capacity_bytes"] + report["channel_bytes"] + report["pyramid_bytes"] + report["bin_color_bytes"] diff --git a/python/xy/_jpeg.py b/python/xy/_jpeg.py index 2a08b526..222eaf0c 100644 --- a/python/xy/_jpeg.py +++ b/python/xy/_jpeg.py @@ -20,7 +20,9 @@ from __future__ import annotations +import itertools import struct +from typing import Optional import numpy as np @@ -168,6 +170,9 @@ def _dct_matrix() -> np.ndarray: # searchsorted keeps this exact where a float log2 could round at the edges. _POW2 = np.int64(1) << np.arange(12, dtype=np.int64) +#: Placeholder that releases a token field's storage while its list slot lives on. +_EMPTY_I64 = np.empty(0, dtype=np.int64) + def _bit_size(magnitude: np.ndarray) -> np.ndarray: return np.searchsorted(_POW2, magnitude, side="right").astype(np.int64) @@ -255,22 +260,61 @@ def _component_tokens( return merged[0], merged[1], merged[2], merged[3], merged[4], merged[5] +#: Bits packed per `_pack_entropy` pass. The exploded form costs 17 bytes per +#: output *bit* (two int64 index vectors plus the bit itself), so packing a whole +#: image at once peaked at ~130 MB for a 1 MB entropy stream. Bit packing is not +#: chunk-independent — group boundaries land mid-byte — so each pass carries its +#: leftover bits into the next one, which keeps the byte stream identical. +_ENTROPY_BIT_CHUNK = 1 << 18 + + def _pack_entropy(chunk: np.ndarray, nbits: np.ndarray) -> bytes: """MSB-first bit packing of (value, bit-count) chunks, with 1-padding to a - byte boundary and 0x00 stuffing after every 0xFF (both per T.81).""" + byte boundary and 0x00 stuffing after every 0xFF (both per T.81). + + Packed in bounded passes: token groups of at most `_ENTROPY_BIT_CHUNK` bits, + with sub-byte remainders carried across group boundaries so the emitted bytes + are exactly those of a single whole-image pass. Byte stuffing is per-byte + local, so it applies per group. + """ total = int(nbits.sum()) - start = np.cumsum(nbits) - nbits - idx = np.repeat(np.arange(chunk.size, dtype=np.int64), nbits) - offset = np.arange(total, dtype=np.int64) - np.repeat(start, nbits) - bits = ((chunk[idx] >> (nbits[idx] - 1 - offset)) & 1).astype(np.uint8) - pad = (-total) % 8 - if pad: - bits = np.concatenate((bits, np.ones(pad, dtype=np.uint8))) - stream = np.packbits(bits) - ff = np.flatnonzero(stream == 0xFF) - if ff.size: - stream = np.insert(stream, ff + 1, np.uint8(0)) - return stream.tobytes() + cum = np.cumsum(nbits) + # Token index at each bit-budget boundary: a group is [t0, t1) tokens, and a + # single token never spans groups (its bits stay contiguous, as before). + edges = np.searchsorted( + cum, np.arange(_ENTROPY_BIT_CHUNK, total, _ENTROPY_BIT_CHUNK), side="left" + ) + bounds = [0, *(int(e) + 1 for e in edges), chunk.size] + parts: list[bytes] = [] + carry = np.empty(0, dtype=np.uint8) + for t0, t1 in itertools.pairwise(bounds): + if t1 <= t0: + continue + values = chunk[t0:t1] + widths = nbits[t0:t1] + group_total = int(widths.sum()) + start = np.cumsum(widths) - widths + idx = np.repeat(np.arange(values.size, dtype=np.int64), widths) + offset = np.arange(group_total, dtype=np.int64) - np.repeat(start, widths) + bits = ((values[idx] >> (widths[idx] - 1 - offset)) & 1).astype(np.uint8) + if carry.size: + bits = np.concatenate((carry, bits)) + whole = (bits.size // 8) * 8 + if t1 == chunk.size: + # Final group: 1-pad the tail to a byte boundary (T.81). + pad = (-bits.size) % 8 + if pad: + bits = np.concatenate((bits, np.ones(pad, dtype=np.uint8))) + whole = bits.size + carry = np.empty(0, dtype=np.uint8) + else: + carry = bits[whole:].copy() + stream = np.packbits(bits[:whole]) + ff = np.flatnonzero(stream == 0xFF) + if ff.size: + stream = np.insert(stream, ff + 1, np.uint8(0)) + parts.append(stream.tobytes()) + return b"".join(parts) def _headers(h: int, w: int, qy_zz: np.ndarray, qc_zz: np.ndarray) -> bytes: @@ -310,6 +354,26 @@ def _headers(h: int, w: int, qy_zz: np.ndarray, qc_zz: np.ndarray) -> bytes: return b"\xff\xd8" + app0 + dqt + sof0 + dht + sos +def _gathered(fields: list[list[np.ndarray]], index: int, order: np.ndarray) -> np.ndarray: + """One token field, concatenated across components into MCU order. + + The per-component pieces are released as they are joined, so a field costs + one joined copy plus the gathered result rather than both plus the parts — + each is an int64 per token, and a photographic image has millions. `parts` + is dropped explicitly rather than left to the frame: a local stays alive + until the function returns, so `return np.concatenate(parts)[order]` would + hold the pieces, the join, *and* the gather at once. + """ + parts = [f[index] for f in fields] + for f in fields: + f[index] = _EMPTY_I64 + joined = np.concatenate(parts) + del parts + gathered = joined[order] + del joined + return gathered + + def encode(rgba: np.ndarray, *, quality: int = 90) -> bytes: """Encode an `(h, w, 4)` RGBA (alpha ignored) or `(h, w, 3)` RGB uint8 image as a baseline JFIF JPEG. Deterministic for identical input.""" @@ -338,6 +402,10 @@ def encode(rgba: np.ndarray, *, quality: int = 90) -> bytes: y = 0.299 * r + 0.587 * g + 0.114 * b - 128.0 cb = -0.168736 * r - 0.331264 * g + 0.5 * b cr = 0.5 * r - 0.418688 * g - 0.081312 * b + # The interleaved f32 source is dead once the planes exist, and it is three + # floats per pixel — hand it back before the per-component pipeline runs. + # (`r`/`g`/`b` are views into it, so all four references have to go.) + del rgb, r, g, b qy = _scaled_quant(_QUANT_LUMA, quality) qc = _scaled_quant(_QUANT_CHROMA, quality) @@ -346,37 +414,77 @@ def encode(rgba: np.ndarray, *, quality: int = 90) -> bytes: pad_h, pad_w = (-h) % 8, (-w) % 8 h8, w8 = h + pad_h, w + pad_w - fields: list[tuple[np.ndarray, ...]] = [] + fields: list[list[np.ndarray]] = [] keys: list[np.ndarray] = [] - for comp, (plane, q_zz) in enumerate(zip((y, cb, cr), (qy_zz, qc_zz, qc_zz), strict=True)): + # Planes are released as they are consumed (one float per pixel each), so the + # pipeline holds one component's working set instead of all three. + planes: list[Optional[np.ndarray]] = [y, cb, cr] + del y, cb, cr + for comp, q_zz in enumerate((qy_zz, qc_zz, qc_zz)): + plane = planes[comp] + planes[comp] = None + assert plane is not None # Edge replication avoids the ringing a zero/black pad would inject # into every border block. padded = np.pad(plane, ((0, pad_h), (0, pad_w)), mode="edge") + del plane blocks = padded.reshape(h8 // 8, 8, w8 // 8, 8).swapaxes(1, 2).reshape(-1, 8, 8) + del padded coef = _DCT @ blocks @ _DCT.T - scaled = coef.reshape(-1, 64)[:, _ZIGZAG] / q_zz.astype(np.float32) + del blocks + # The zigzag gather is a fresh array, so the divide, the round, and the + # sign restore all run in place on it: same f32 operations in the same + # order as the one-shot chain, five fewer full-size temporaries. + scaled = coef.reshape(-1, 64)[:, _ZIGZAG] + del coef + scaled /= q_zz.astype(np.float32) # Round half away from zero: any deterministic tie rule is valid JPEG; - # this one matches the common integer implementations. - quant = (np.sign(scaled) * np.floor(np.abs(scaled) + 0.5)).astype(np.int64) + # this one matches the common integer implementations. `trunc(x + ±0.5)` + # is that rule in three in-place passes and one temporary, where + # `sign(x) * floor(|x| + 0.5)` took four passes and four full-size + # temporaries. It is exact, not approximate: IEEE addition is + # sign-symmetric, so for x < 0 the sum is the exact negation of + # `|x| + 0.5`, and truncation toward zero then matches the floor of that + # magnitude. The ±0 cases land on ±0.0 either way and integer-cast to 0. + bias = np.copysign(np.float32(0.5), scaled) + scaled += bias + del bias + np.trunc(scaled, out=scaled) + quant = scaled.astype(np.int64) + del scaled # Exact-math AC magnitudes cap at 1020 (category 10); clamp is one-LSB # insurance against float rounding ever minting category 11, which the # baseline AC tables cannot code. quant[:, 1:] = np.clip(quant[:, 1:], -1023, 1023) dc_tbl, ac_tbl = (0, 1) if comp == 0 else (2, 3) toks = _component_tokens(quant, dc_tbl, ac_tbl) + del quant # 4:4:4 → one block per component per MCU, so the MCU-interleaved # order Y, Cb, Cr is a stable sort on (block, component, in-block seq). keys.append(((toks[0] * 3 + comp) << 8) | toks[1]) - fields.append(toks) + # Only the four emitted fields outlive the key: the block and in-block + # sequence numbers are folded into it, and on a photographic image each + # dropped field is an int64 per token. + fields.append(list(toks[2:])) + del toks order = np.argsort(np.concatenate(keys), kind="stable") - tbl = np.concatenate([f[2] for f in fields])[order] - sym = np.concatenate([f[3] for f in fields])[order] - ampl = np.concatenate([f[4] for f in fields])[order] - abits = np.concatenate([f[5] for f in fields])[order] + keys.clear() + tbl = _gathered(fields, 0, order) + sym = _gathered(fields, 1, order) + ampl = _gathered(fields, 2, order) + abits = _gathered(fields, 3, order) + del fields code = _HUFF_CODES[tbl, sym] clen = _HUFF_LENS[tbl, sym] - # Huffman code then amplitude bits, as one ≤27-bit chunk per token. - entropy = _pack_entropy((code << abits) | ampl, clen + abits) + del tbl, sym + # Huffman code then amplitude bits, as one ≤27-bit chunk per token. Both + # gathers are fresh arrays, so the combine runs in place. + code <<= abits + code |= ampl + clen += abits + del ampl, abits + entropy = _pack_entropy(code, clen) + del code, clen return _headers(h, w, qy_zz, qc_zz) + entropy + b"\xff\xd9" diff --git a/python/xy/_native.py b/python/xy/_native.py index b9c07c41..79d5b16b 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -17,7 +17,7 @@ import os import sys from pathlib import Path -from typing import Optional +from typing import Any, Optional import numpy as np import numpy.typing as npt @@ -3093,8 +3093,11 @@ def _byte_span_arrays(spans): # noqa: ANN001, ANN202 - private ctypes adapter return arenas, pointers, lengths -def rasterize_spans(cmds: bytes, spans, w: int, h: int) -> npt.NDArray[np.uint8]: # noqa: ANN001 - """Paint a display list borrowing multiple call-scoped byte arenas.""" +def rasterize_spans(cmds: Any, spans, w: int, h: int) -> npt.NDArray[np.uint8]: # noqa: ANN001 + """Paint a display list borrowing multiple call-scoped byte arenas. + + `cmds` is any read-only-safe buffer (`bytes`, `bytearray`, `memoryview`): + it is borrowed through `np.frombuffer`, never copied.""" w = _positive_int(w, "raster width") h = _positive_int(h, "raster height") buf = np.frombuffer(cmds, dtype=np.uint8) @@ -3115,8 +3118,9 @@ def rasterize_spans(cmds: bytes, spans, w: int, h: int) -> npt.NDArray[np.uint8] return out -def rasterize_png_spans(cmds: bytes, spans, w: int, h: int) -> bytes: # noqa: ANN001 - """Paint and encode a display list borrowing multiple byte arenas.""" +def rasterize_png_spans(cmds: Any, spans, w: int, h: int) -> bytes: # noqa: ANN001 + """Paint and encode a display list borrowing multiple byte arenas + (`cmds` is borrowed, as in `rasterize_spans`).""" w = _positive_int(w, "raster width") h = _positive_int(h, "raster height") buf = np.frombuffer(cmds, dtype=np.uint8) diff --git a/python/xy/_payload.py b/python/xy/_payload.py index 20ca1c53..98d3dea0 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -403,6 +403,29 @@ def _finite_sel(t: Trace, xv: np.ndarray, yv: np.ndarray) -> np.ndarray | None: return None return np.flatnonzero(np.isfinite(xv) & np.isfinite(yv)) + def _visible_mask_needed( + self, + t: Trace, + *, + prefiltered: bool, + base_column: Optional[Column] = None, + ) -> bool: + """Whether `_log_visible_mask` can drop any row for this trace. + + The mask is three O(N) passes plus two N-byte temporaries, and on the + common shape (linear axes, no nulls) it is provably all-True: zone maps + count NaN *and* ±inf as null (§22/§19), so a null-free column has no + row for `isfinite` to reject, and `prefiltered` rows already went + through `_finite_sel`. Only a log axis (which additionally rejects + non-positive values) or a baseline column outside the x/y zone maps can + actually remove something. + """ + if self._axis_scale(t.x_axis) == "log" or self._axis_scale(t.y_axis) == "log": + return True + if base_column is not None and base_column.zone.null_count: + return True + return not prefiltered and bool(t.x.zone.null_count or t.y.zone.null_count) + def _log_visible_mask( self, t: Trace, @@ -410,6 +433,14 @@ def _log_visible_mask( yv: np.ndarray, base: Optional[np.ndarray] = None, ) -> np.ndarray: + """Rows this trace may actually ship. + + Paired with `_visible_mask_needed`, which decides whether calling this + can drop anything at all: **a new rejection rule here needs a matching + condition there**, or the emitters will skip the mask on data it would + now reject. (`tests/test_figure.py` pins one case per rule; the + predicate going stale is otherwise silent.) + """ mask = np.isfinite(xv) & np.isfinite(yv) if self._axis_scale(t.x_axis) == "log": mask &= xv > 0 @@ -483,7 +514,7 @@ def _emit_line( sel = self._finite_sel(t, xv, yv) if sel is not None: xv, yv = xv[sel], yv[sel] - if len(xv): + if len(xv) and self._visible_mask_needed(t, prefiltered=sel is not None): finite = self._log_visible_mask(t, xv, yv) if not bool(np.all(finite)): sel = np.flatnonzero(finite) if sel is None else sel[finite] @@ -507,9 +538,11 @@ def _emit_area( tier, (xv, yv, bv) = self._m4_decimate( t, xr, px_width, t.x.values, t.y.values, t.base.values ) - sel = np.flatnonzero(self._log_visible_mask(t, xv, yv, bv)) - if len(sel) != len(xv): - xv, yv, bv = xv[sel], yv[sel], bv[sel] + sel = None + if self._visible_mask_needed(t, prefiltered=False, base_column=t.base): + sel = np.flatnonzero(self._log_visible_mask(t, xv, yv, bv)) + if len(sel) != len(xv): + xv, yv, bv = xv[sel], yv[sel], bv[sel] entry = self._base_entry(t, pw, xv, yv, tier, self._default_styled(t)) if tier == "decimated": entry["decimation_px"] = int(px_width) @@ -535,7 +568,7 @@ def _emit_scatter( sel = self._finite_sel(t, xv, yv) if sel is not None: xv, yv = xv[sel], yv[sel] - if len(xv): + if len(xv) and self._visible_mask_needed(t, prefiltered=sel is not None): visible = self._log_visible_mask(t, xv, yv) if not bool(np.all(visible)): sel = np.flatnonzero(visible) if sel is None else sel[visible] diff --git a/python/xy/_png.py b/python/xy/_png.py index 6f5d02c8..4ed8f48b 100644 --- a/python/xy/_png.py +++ b/python/xy/_png.py @@ -9,6 +9,13 @@ one byte per pixel instead of four shrinks native-PNG exports several-fold. Falls back to truecolor otherwise. +Both stage the filtered scanlines in a single NumPy buffer and hand that buffer +straight to zlib. The image is large (4 bytes per pixel, ×4 again at scale 2), +so every avoided intermediate is a full frame of peak RSS: building the rows as +one array instead of a list of per-row `bytes` drops two full copies (the row +objects and their join), and compressing the buffer directly drops a third +(`tobytes`). + This balanced/indexed path stays pure Python/stdlib. The separate latency-first `xy.pyplot` path fuses rasterization with the Rust PNG encoder. """ @@ -28,36 +35,59 @@ def _chunk(tag: bytes, data: bytes) -> bytes: _SIG = b"\x89PNG\r\n\x1a\n" _COMPRESSION_LEVEL = 6 +# Row-block length for the palette-index lookup. `np.unique(return_inverse=True)` +# would hand back one intp per pixel (8 bytes/px — twice the image itself) only +# to be narrowed to u8; searching the ≤256-entry palette per block writes the +# final bytes in place with a bounded temporary instead. +_INDEX_ROW_BLOCK = 64 + + +def _filtered_rows(h: int, stride: int) -> np.ndarray: + """An `(h, stride + 1)` scanline buffer with the per-row filter byte set to + 0 (PNG filter type "None"), ready for the pixel columns to be filled.""" + rows = np.empty((h, stride + 1), dtype=np.uint8) + rows[:, 0] = 0 + return rows def png_truecolor( - w: int, h: int, rgba: bytes, *, compression_level: int = _COMPRESSION_LEVEL + w: int, + h: int, + rgba: bytes | bytearray | memoryview | np.ndarray, + *, + compression_level: int = _COMPRESSION_LEVEL, ) -> bytes: - """RGBA8 PNG (color type 6). `rgba` is row-major `w*h*4` bytes, top row first.""" + """RGBA8 PNG (color type 6). `rgba` is row-major `w*h*4` bytes, top row + first. + + Any buffer works and none is copied: `bytes`/`bytearray`/`memoryview`, or a + **C-contiguous** uint8 array (pass `np.ascontiguousarray` if that is not + guaranteed — a strided view would be read in memory order, not row order). + Only the first `w * h * 4` bytes are read. + """ ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) stride = w * 4 - raw = b"".join(b"\x00" + rgba[y * stride : (y + 1) * stride] for y in range(h)) + rows = _filtered_rows(h, stride) + rows[:, 1:] = np.frombuffer(rgba, dtype=np.uint8, count=h * stride).reshape(h, stride) return ( _SIG + _chunk(b"IHDR", ihdr) - + _chunk(b"IDAT", zlib.compress(raw, compression_level)) + + _chunk(b"IDAT", zlib.compress(rows, compression_level)) + _chunk(b"IEND", b"") ) -def _png_indexed(w: int, h: int, idx: np.ndarray, palette: np.ndarray) -> bytes: - """Indexed PNG (color type 3). `idx` is `(h, w)` uint8 palette indices; - `palette` is `(n, 4)` uint8 RGBA. `tRNS` carries per-entry alpha.""" +def _png_indexed(w: int, h: int, rows: np.ndarray, palette: np.ndarray) -> bytes: + """Indexed PNG (color type 3). `rows` is the `(h, w + 1)` filtered scanline + buffer holding palette indices; `palette` is `(n, 4)` uint8 RGBA. `tRNS` + carries per-entry alpha.""" ihdr = struct.pack(">IIBBBBB", w, h, 8, 3, 0, 0, 0) plte = palette[:, :3].astype(np.uint8).tobytes() trns = palette[:, 3].astype(np.uint8).tobytes() - rows = np.concatenate( - [np.zeros((h, 1), dtype=np.uint8), idx.astype(np.uint8)], axis=1 - ) # a 0 filter byte per scanline out = _SIG + _chunk(b"IHDR", ihdr) + _chunk(b"PLTE", plte) # tRNS may omit trailing opaque (255) entries; keep it simple and always emit. out += _chunk(b"tRNS", trns) - out += _chunk(b"IDAT", zlib.compress(rows.tobytes(), _COMPRESSION_LEVEL)) + _chunk(b"IEND", b"") + out += _chunk(b"IDAT", zlib.compress(rows, _COMPRESSION_LEVEL)) + _chunk(b"IEND", b"") return out @@ -77,10 +107,17 @@ def encode(img: np.ndarray) -> bytes: # image is too, so the truecolor result is identical either way. probe = keys[:: keys.size // 65_536] if np.unique(probe).size > 256: - return png_truecolor(w, h, np.ascontiguousarray(img).tobytes()) - palette_keys, inverse = np.unique(keys, return_inverse=True) + return png_truecolor(w, h, flat) + palette_keys = np.unique(keys) if palette_keys.size <= 256: palette = palette_keys.view(np.uint8).reshape(-1, 4) - idx = inverse.astype(np.uint8).reshape(h, w) - return _png_indexed(w, h, idx, palette) - return png_truecolor(w, h, np.ascontiguousarray(img).tobytes()) + # `searchsorted` over the sorted palette is exactly `np.unique`'s + # inverse for values that are present (all of them, by construction), + # written straight into the scanline buffer a row-block at a time. + rows = _filtered_rows(h, w) + keyed = keys.reshape(h, w) + for start in range(0, h, _INDEX_ROW_BLOCK): + end = start + _INDEX_ROW_BLOCK + rows[start:end, 1:] = np.searchsorted(palette_keys, keyed[start:end]).astype(np.uint8) + return _png_indexed(w, h, rows, palette) + return png_truecolor(w, h, flat) diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 5733b54a..9928b3ec 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1032,9 +1032,13 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: from . import _native spans = (blob, *borrowed) + # The command buffer ships as a borrowed buffer, not a `bytes` copy: the + # ctypes seam wraps it with `np.frombuffer` and the native rasterizer only + # reads it, so freezing it would duplicate a display list that is O(marks) + # (megabytes on a direct-tier scatter) for nothing. if fast_png: - return _native.rasterize_png_spans(bytes(cmd.buf), spans, w_px, h_px) - return _native.rasterize_spans(bytes(cmd.buf), spans, w_px, h_px) + return _native.rasterize_png_spans(cmd.buf, spans, w_px, h_px) + return _native.rasterize_spans(cmd.buf, spans, w_px, h_px) def _emit_line( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 1227afa2..c39b11af 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1647,7 +1647,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str: ) elif kind == "scatter": - marks.append(_scatter_marks(t, blob, cols, trace_sx, trace_sy, style, color)) + marks.extend(_scatter_marks(t, blob, cols, trace_sx, trace_sy, style, color)) elif kind == "hexbin": marks.append(_hexbin_marks(t, blob, cols, trace_sx, trace_sy, style, color)) @@ -1872,17 +1872,29 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'' ) - return ( - f'' - f"{defs}" - f"{backgrounds}" - f"{''.join(grid)}" - f'{"".join(marks)}' - f"{baselines}" - f'{"".join(labels)}' - f"{''.join(chrome)}" - f"" + # One flat join over the pieces rather than nested `join`s inside an + # f-string: the mark list is the whole document for a per-point chart (tens + # of MB at 100k markers), and joining it separately would materialize a + # second full copy of it before the result string is built. + return "".join( + [ + f'', + defs, + backgrounds, + "", + *grid, + "", + f'', + *marks, + "", + baselines, + f'', + *labels, + "", + *chrome, + "", + ] ) @@ -2080,9 +2092,18 @@ def read(index: int) -> np.ndarray: ) +#: Markers per emitted string block. One SVG element per point means the mark +#: list is the document, and a list of N short strings costs ~50 bytes of object +#: header each on top of the markup — 40% overhead at 100k points, live at the +#: same time as the joined result. Collapsing every block keeps the per-object +#: overhead bounded while staying a single linear pass (byte-identical output: +#: concatenation is associative). +_SVG_MARK_BLOCK = 4096 + + def _scatter_marks( t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str -) -> str: +) -> list[str]: xv = _column(blob, cols[t["x"]]) yv = _column(blob, cols[t["y"]]) px, py = sx(xv), sy(yv) @@ -2150,9 +2171,10 @@ def read(index: int) -> np.ndarray: if grouped_alpha: fill_group = float(scalar_artist) * _fill_opacity(style, 1.0) stroke_group = float(scalar_artist) * _stroke_opacity(style, 1.0) - out = [f''] + blocks = [f''] else: - out = [""] + blocks = [""] + out: list[str] = [] for i in range(n): fill = face_rgba[i] fill_value = ( @@ -2197,8 +2219,13 @@ def read(index: int) -> np.ndarray: out.append( builder(float(px[i]), float(py[i]), marker_radius) + f"{fill_attr}{stroke_attr}/>" ) - out.append("") - return "".join(out) + if len(out) >= _SVG_MARK_BLOCK: + blocks.append("".join(out)) + out.clear() + if out: + blocks.append("".join(out)) + blocks.append("") + return blocks _SYMBOL_NAMES = ( diff --git a/python/xy/channels.py b/python/xy/channels.py index e24ac1f4..3c459a54 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -626,9 +626,21 @@ def quantize_unit_u8(values: npt.NDArray[np.float64], domain: tuple[float, float The lossy sibling of :func:`normalize_to_unit`, for wire paths where the value is only ever a GPU LUT/ramp coordinate (a colormap texture has 256 texels; a size ramp spans ~16 px) and is never read back into a displayed - number — 75% less traffic than f32, same rendered output (§29).""" - unit = normalize_to_unit(values, domain) - return np.rint(np.clip(unit, 0.0, 1.0) * 255.0).astype(np.uint8) + number — 75% less traffic than f32, same rendered output (§29). + + Chunk-bounded like the other quantizers (`_QUANTIZE_CHUNK`): the arithmetic + is element-wise and stays in f32 exactly as the one-shot chain did, so the + bytes are identical while the transient stays independent of N.""" + out = np.empty(len(values), dtype=np.uint8) + for start in range(0, len(values), _QUANTIZE_CHUNK): + end = start + _QUANTIZE_CHUNK + # Fresh f32 kernel output: clip/scale/round can all run in place on it. + unit = normalize_to_unit(values[start:end], domain) + np.clip(unit, 0.0, 1.0, out=unit) + unit *= 255.0 + np.rint(unit, out=unit) + out[start:end] = unit.astype(np.uint8) + return out def colormap_lut_rgba8(colormap: Colormap) -> npt.NDArray[np.uint8]: @@ -723,7 +735,15 @@ def bins_mean_color(cc: Optional[ColorChannel]) -> bool: # (~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 +# +# The chunk is sized so a whole pass (one f32 normalize output + one f64 stage +# array + the u8 slice) stays inside a core's private cache rather than +# streaming through DRAM: at 2^18 rows that is ~3 MB of live temporary, versus +# ~50 MB at 2^22, where "chunked" still meant a 4M-row f64 pipeline for every +# real-world column (a 2.1M-row colored trace fit in a single chunk and paid +# the full one-shot peak). Bigger chunks buy nothing — the per-chunk Python +# overhead is already amortized thousands of elements ago. +_QUANTIZE_CHUNK = 1 << 18 def _quantized_lut_idx(values: npt.NDArray[np.float64], domain: tuple[float, float]) -> np.ndarray: @@ -732,12 +752,17 @@ def _quantized_lut_idx(values: npt.NDArray[np.float64], domain: tuple[float, flo 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.""" + O(chunk) + the N-byte output. The ×255/`rint` stages run in place on the + widened copy, so one f64 chunk buffer serves the whole pipeline.""" 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) + # `normalize_to_unit` hands back a fresh f32 kernel output, so the + # widened copy below is ours to mutate. + scaled = np.asarray(normalize_to_unit(values[start:end], domain), dtype=np.float64) + scaled *= 255.0 + np.rint(scaled, out=scaled) + out[start:end] = scaled.astype(np.uint8) return out @@ -746,8 +771,11 @@ def _quantized_rgba8(values: npt.NDArray[np.float64]) -> np.ndarray: 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) + # `clip` copies the input rows; the rest of the chain reuses that copy. + seg = np.clip(values[start:end], 0.0, 1.0) + seg *= 255.0 + np.rint(seg, out=seg) + out[start:end] = seg.astype(np.uint8) return out @@ -826,9 +854,9 @@ def ship_channels( `quantize_continuous` ships continuous color/size as u8 LUT coordinates (`dtype: "u8"` marker) instead of unit f32. Live-interaction paths opt in: their hover/pick answers come from the server's canonical columns, so the - quantization is invisible. The build path must NOT opt in — it retains the - shipped columns CPU-side (`_cpu.color`/`_cpu.size`) and denormalizes them - for tooltip readouts, where 8-bit steps would show as wrong digits. + quantization is invisible. The build path must NOT opt in — the client keeps + the shipped columns CPU-side and denormalizes them for tooltip readouts, + where 8-bit steps would show as wrong digits. Returns (color_spec, size_spec).""" cc = trace.color_ch or ColorChannel(mode="constant", constant=None) color_spec = ship_color_channel( @@ -865,7 +893,10 @@ def ship_color_channel( if rgba is None: raise ValueError("direct RGBA color channel missing values") values = rgba if sel is None else rgba[sel] - packed = np.rint(np.clip(values, 0.0, 1.0) * 255.0).astype(np.uint8) + # Same per-element chain as the historical one-shot expression, but + # chunk-bounded: a full-column direct-RGBA trace otherwise holds three + # 32-bytes-per-point f64 temporaries at once (§27). + packed = _quantized_rgba8(values) color_spec["buf"] = ship_u8(packed.reshape(-1)) color_spec["n"] = int(len(values)) elif cc.mode == "match_fill": diff --git a/python/xy/columns.py b/python/xy/columns.py index 83d94d45..7d37a745 100644 --- a/python/xy/columns.py +++ b/python/xy/columns.py @@ -209,6 +209,17 @@ class Column: def __len__(self) -> int: return len(self.values) + @property + def capacity_bytes(self) -> int: + """Bytes this column actually holds, slack included. + + `append` keeps values as a prefix view of a capacity-doubling buffer, so + after a stream of appends the allocation can be up to twice + `values.nbytes`. The memory report needs the real number (§27). + """ + grow = getattr(self, "_grow", None) + return int(grow.nbytes if grow is not None else self.values.nbytes) + @property def zone(self) -> ZoneMaps: """Materialize deferred statistics at most once.""" @@ -456,23 +467,36 @@ def memory_report(self) -> dict[str, Any]: OS as a reclaimable cache, so they do not sit in the process's resident set the way an in-RAM column does. ``canonical_bytes`` therefore stays the honest RAM-resident canonical figure (unchanged for all-RAM - figures, where ``canonical_mapped_bytes`` is 0).""" + figures, where ``canonical_mapped_bytes`` is 0). + + A streamed column's values are a prefix *view* of its capacity-doubling + growth buffer (`Column.append`), so up to half of what it holds is slack + that `values.nbytes` cannot see. That slack is resident RAM like any + other allocation, so each column also reports ``capacity_bytes`` and the + report totals them as ``canonical_capacity_bytes`` — equal to + ``canonical_bytes`` for every never-appended figure, and the number the + resident total is built from (channels already report their own growth + buffers this way).""" resident = 0 + resident_capacity = 0 mapped = 0 columns = [] for c in self._columns: nbytes = int(c.values.nbytes) + capacity = int(c.capacity_bytes) memmapped = _ooc.is_memmapped(c.values) if memmapped: mapped += nbytes else: resident += nbytes + resident_capacity += capacity columns.append( { "id": c.id, "kind": c.kind, "len": len(c), "bytes": nbytes, + "capacity_bytes": capacity, "backing": "memmap" if memmapped else "ram", "ingest_copies": c.ingest_copies, "null_count": c.zone.null_count, @@ -480,6 +504,7 @@ def memory_report(self) -> dict[str, Any]: ) return { "canonical_bytes": resident, + "canonical_capacity_bytes": resident_capacity, "canonical_mapped_bytes": mapped, "columns": columns, } diff --git a/python/xy/export.py b/python/xy/export.py index 86abafbc..f44ef604 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -21,6 +21,8 @@ from typing import TYPE_CHECKING, Any, Optional, SupportsFloat, SupportsIndex, cast if TYPE_CHECKING: + from collections.abc import Iterator + from ._figure import Figure @@ -168,6 +170,21 @@ def _javascript_for_inline_script(source: str) -> str: return source.replace(" "Iterator[str]": + """`_base64_chunks` as a generator, for callers that consume chunks once. + + The encoded text is 4/3 of the payload, so materializing the list *and* the + string built from it doubles that; yielding lets each chunk be wrapped and + released as it is produced. + """ + if not blob: + return + view = memoryview(blob) + step = _B64_CHUNK_BYTES + for i in range(0, len(view), step): + yield base64.b64encode(view[i : i + step]).decode("ascii") + + def _base64_chunks(blob: bytes) -> list[str]: """Base64 the payload as 3-byte-aligned chunks (see `_B64_CHUNK_BYTES`). @@ -175,11 +192,7 @@ def _base64_chunks(blob: bytes) -> list[str]: interior `=` padding and decodes to an exact byte length — letting the client reassemble one contiguous buffer without tracking base64 boundaries. A memoryview avoids copying the (potentially huge) blob per slice.""" - if not blob: - return [] - view = memoryview(blob) - step = _B64_CHUNK_BYTES - return [base64.b64encode(view[i : i + step]).decode("ascii") for i in range(0, len(view), step)] + return list(_iter_base64_chunks(blob)) # Inline decoder for the chunked base64 payload. Prefers the native Uint8Array @@ -310,10 +323,16 @@ def to_html( # valid JS string literal verbatim and can never close the ' for c in _base64_chunks(blob) - ) - doc = f""" + # Assembled as parts joined once. Every large string — the client bundle, the + # spec, each base64 chunk — is its own part, so the join copies it exactly + # once and nothing is formatted into an intermediate first. Interpolating one + # of them costs a second full copy of it: folding the chunks through + # `"\n".join(...)` duplicated 4/3 of the payload, and (measured on + # `test_html_export_line`, where the ~330 KB client bundle *is* the document) + # interpolating `client_js` into a header part cost ~10% of a small export. + blob_len = len(blob) + parts = [ + f""" @@ -325,18 +344,32 @@ def to_html( {_custom_css_block(custom_css)}
- + -{chunk_scripts} -') + del blob + parts.append("\n -""" +""") + doc = "".join(parts) + del parts if path is not None: _atomic_write_text(path, doc) return doc diff --git a/python/xy/facets.py b/python/xy/facets.py index fdebc087..3a1db023 100644 --- a/python/xy/facets.py +++ b/python/xy/facets.py @@ -338,10 +338,12 @@ def to_png( data = ( encode_png(canvas) if optimize + # The encoder borrows the frame buffer; `tobytes` would add a + # whole extra canvas to peak RSS at every export scale. else png_truecolor( canvas.shape[1], canvas.shape[0], - np.ascontiguousarray(canvas).tobytes(), + np.ascontiguousarray(canvas), compression_level=1, ) ) @@ -397,7 +399,7 @@ def to_image( return png_truecolor( canvas.shape[1], canvas.shape[0], - np.ascontiguousarray(canvas).tobytes(), + np.ascontiguousarray(canvas), compression_level=1, ) canvas = self._compose_rgba(scale, background) diff --git a/python/xy/interaction.py b/python/xy/interaction.py index 14f336fb..940eef02 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -749,8 +749,12 @@ def _quantize_dval(dval: np.ndarray) -> np.ndarray: The value only weights the density→points intensity handoff (§5) — 256 levels exceed what the crossfade can show, at a quarter of the f32 wire - bytes (§29).""" - return np.rint(np.clip(dval, 0.0, 1.0) * 255.0).astype(np.uint8) + bytes (§29). `clip` copies the window once and the scale/round run in place + on that copy, so the pass costs one temporary instead of three.""" + out = np.clip(dval, 0.0, 1.0) + out *= 255.0 + np.rint(out, out=out) + return out.astype(np.uint8) def _has_point_channels(t: "Trace") -> bool: diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 313aadf4..ac556dd2 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -975,6 +975,13 @@ Rules that make the mode targets in §2 real: API call that returns the freed bytes and records the trace as `degraded` — visible in the debug HUD and in `chart.memory_report()`, which itemizes all five classes per trace. If a memory number isn't in the report, it isn't real. + Corollary for growth buffers: a streamed column's `values` is a prefix *view* of a + capacity-doubling allocation (§5), so `values.nbytes` under-reports what the process + holds by up to 2x. Every column therefore reports `capacity_bytes` alongside + `bytes`, the store totals them as `canonical_capacity_bytes`, and + `resident_array_bytes` is built from the capacity total — equal to `canonical_bytes` + for any figure that never appended. Continuous channels already reported their own + growth buffers this way; columns now match. 5. **Canonical may be out-of-core (native `mmap`).** The "mmap (native)" cell in the table above is realized: a canonical column may be backed by a disk `np.memmap` instead of RAM. Because a memmap is a transparent `ndarray` — same dedup key, same diff --git a/src/raster.rs b/src/raster.rs index 5da85840..331bf07a 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -1608,8 +1608,7 @@ struct PointsBatch<'a> { fn paint_points(cv: &mut Canvas, batch: &PointsBatch, threads: usize) { if threads <= 1 { - let indices: Vec = (0..batch.n as u32).collect(); - paint_points_band(&mut cv.surface(), batch, &indices); + paint_points_band(&mut cv.surface(), batch, 0..batch.n); return; } paint_banded( @@ -1621,13 +1620,17 @@ fn paint_points(cv: &mut Canvas, batch: &PointsBatch, threads: usize) { let ext = rr + batch.sw + 1.0; Some((cy - ext, cy + ext)) }, - |sf, indices| paint_points_band(sf, batch, indices), + |sf, indices| paint_points_band(sf, batch, indices.iter().map(|&i| i as usize)), ); } -fn paint_points_band(sf: &mut Surface, b: &PointsBatch, indices: &[u32]) { - for &i in indices { - let i = i as usize; +/// `items` is the mark order to paint: a band's bucketed indices when fanned +/// out, or a plain `0..n` range when serial. Taking an iterator rather than a +/// slice keeps the serial path from materializing an `0..n` index vector it +/// would only read back in order (4 bytes per mark, megabytes on a direct-tier +/// scatter); monomorphization makes both callers cost the same per mark. +fn paint_points_band(sf: &mut Surface, b: &PointsBatch, items: impl IntoIterator) { + for i in items { let (cx, cy, rr) = (f32_at(b.xs, i), f32_at(b.ys, i), f32_at(b.rs, i)); // NaN coordinates poison the whole framebuffer via NaN-vs-clip // comparisons; skip them (the payload ships only finite marks, this @@ -1825,8 +1828,7 @@ struct SegmentsBatch<'a> { fn paint_segments(cv: &mut Canvas, batch: &SegmentsBatch, threads: usize) { if threads <= 1 { - let indices: Vec = (0..batch.n as u32).collect(); - paint_segments_band(&mut cv.surface(), batch, &indices); + paint_segments_band(&mut cv.surface(), batch, 0..batch.n); return; } paint_banded( @@ -1838,13 +1840,18 @@ fn paint_segments(cv: &mut Canvas, batch: &SegmentsBatch, threads: usize) { let ext = batch.width * 0.5 + 1.0; Some((ay.min(by) - ext, ay.max(by) + ext)) }, - |sf, indices| paint_segments_band(sf, batch, indices), + |sf, indices| paint_segments_band(sf, batch, indices.iter().map(|&i| i as usize)), ); } -fn paint_segments_band(sf: &mut Surface, sb: &SegmentsBatch, indices: &[u32]) { - for &i in indices { - let i = i as usize; +/// `items` is the mark order to paint; see `paint_points_band` on why this is +/// an iterator rather than a slice. +fn paint_segments_band( + sf: &mut Surface, + sb: &SegmentsBatch, + items: impl IntoIterator, +) { + for i in items { let a = (f32_at(sb.x0s, i), f32_at(sb.y0s, i)); let b = (f32_at(sb.x1s, i), f32_at(sb.y1s, i)); if !(a.0.is_finite() && a.1.is_finite() && b.0.is_finite() && b.1.is_finite()) { diff --git a/tests/test_figure.py b/tests/test_figure.py index 8cd467c2..6c594cdc 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -2231,3 +2231,114 @@ def test_line_dash_presets_and_custom() -> None: Figure().line([0.0, 1.0], [0.0, 1.0], dash=[5.0]) with pytest.raises(ValueError, match=r"dash\[1\]"): Figure().line([0.0, 1.0], [0.0, 1.0], dash=[5.0, -1.0]) + + +# -------------------------------------------------------------------------- +# Row-dropping conditions behind `_payload._visible_mask_needed` +# -------------------------------------------------------------------------- +# The direct-tier emitters skip the visible-row mask when it is provably +# all-true (linear axes, no nulls — zone maps count NaN *and* ±inf as null). +# These pin the three cases where a row really must be dropped, so the +# predicate cannot be tightened past what the mask actually rejects. Without +# them the log and baseline branches are invisible to CI: removing either one +# left the whole suite green. + + +def test_log_axis_drops_nonpositive_rows_from_the_payload(): + """A log axis rejects <= 0, which no zone-map null count can predict.""" + x = np.array([1.0, -2.0, 3.0, 0.0, 5.0]) + y = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + fig = Figure().scatter(x, y) + fig.set_axis("x", type_="log") + spec, _blob = fig.build_payload() + trace = spec["traces"][0] + assert trace["tier"] == "direct" + assert trace["n_marks"] == 3 + # shipped rows translate back to the canonical rows that survived (§17). + np.testing.assert_array_equal(fig.traces[0].shipped_sel, [0, 2, 4]) + + +def test_log_y_axis_drops_nonpositive_rows_for_a_line(): + x = np.array([1.0, 2.0, 3.0, 4.0]) + y = np.array([5.0, 0.0, -1.0, 8.0]) + fig = Figure().line(x, y) + fig.set_axis("y", type_="log") + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 2 + + +def test_log_axis_with_nonfinite_and_nonpositive_rows(): + """Both rejection rules at once: the null count exists *and* a log axis.""" + x = np.array([1.0, np.nan, 4.0, -3.0, np.inf, 9.0]) + y = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + fig = Figure().scatter(x, y) + fig.set_axis("x", type_="log") + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 3 + np.testing.assert_array_equal(fig.traces[0].shipped_sel, [0, 2, 5]) + + +def test_symlog_axis_keeps_nonpositive_rows(): + """symlog exists to show zero and negatives — it must drop nothing.""" + x = np.array([-5.0, 0.0, 5.0]) + y = np.array([1.0, 2.0, 3.0]) + fig = Figure().scatter(x, y) + fig.set_axis("x", type_="symlog", constant=1.0) + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 3 + + +def test_area_drops_rows_whose_baseline_is_null(): + """The baseline column has its own zone maps; x/y nulls cannot cover it.""" + x = np.arange(5.0) + upper = np.array([2.0, 3.0, 4.0, 5.0, 6.0]) + lower = np.array([1.0, np.nan, 1.0, np.inf, 1.0]) + fig = Figure().error_band(x, lower, upper) + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 3 + + +def test_area_with_finite_baseline_keeps_every_row(): + x = np.arange(4.0) + fig = Figure().area(x, np.array([1.0, 2.0, 3.0, 4.0]), base=0.5) + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 4 + + +# -------------------------------------------------------------------------- +# memory_report: growth-buffer capacity +# -------------------------------------------------------------------------- + + +def test_memory_report_counts_growth_buffer_capacity() -> None: + """A streamed column holds its capacity, not just its length (§27). + + `values` is a prefix view of a capacity-doubling buffer, so `values.nbytes` + under-reports resident RAM by up to 2x after a stream of appends. The + capacity total is what `resident_array_bytes` is built from. + """ + n = 10_000 + x = np.arange(n, dtype=np.float64) + fig = Figure().line(x, np.sin(x)) + before = fig.memory_report() + # Nothing appended: capacity is exactly the live length, as it always was. + assert before["canonical_capacity_bytes"] == before["canonical_bytes"] == 2 * n * 8 + assert before["resident_array_bytes"] == 2 * n * 8 + assert all(c["capacity_bytes"] == c["bytes"] for c in before["columns"]) + + for step in range(1, 21): + tail = np.arange(n + (step - 1) * 100, n + step * 100, dtype=np.float64) + fig.append(0, tail, np.sin(tail)) + after = fig.memory_report() + + grown = 2 * (n + 20 * 100) * 8 + assert after["canonical_bytes"] == grown # live values, as before + # The doubling buffer holds strictly more than the live values, and the + # report says so rather than hiding the slack. + assert after["canonical_capacity_bytes"] > after["canonical_bytes"] + assert after["resident_array_bytes"] == after["canonical_capacity_bytes"] + for column, source in zip(after["columns"], fig.store.columns, strict=True): + assert column["capacity_bytes"] >= column["bytes"] + assert column["capacity_bytes"] == source.capacity_bytes + # Amortized growth is bounded: never more than double the live length. + assert column["capacity_bytes"] <= 2 * column["bytes"] diff --git a/tests/test_jpeg.py b/tests/test_jpeg.py index 75b4af0e..490389e1 100644 --- a/tests/test_jpeg.py +++ b/tests/test_jpeg.py @@ -170,3 +170,121 @@ def test_marker_structure(): assert markers.index(0xE0) < markers.index(0xDB) < markers.index(0xC0) assert markers.index(0xC4) < markers.index(0xDA) assert markers[0] == 0xE0 and segments[0][1][:5] == b"JFIF\x00" + + +# --- entropy packer: the chunk-boundary carry (`_ENTROPY_BIT_CHUNK`) --------- +# +# `_pack_entropy` packs the bitstream in bounded passes, which means group +# boundaries land mid-byte and each pass has to carry its leftover bits into the +# next one. Nothing in the image-level tests above reaches that code: their +# entropy streams are orders of magnitude shorter than one group, so the carry +# would stay dead while every large export silently corrupted. These tests pin +# the contract directly, against a bit-by-bit statement of the T.81 rule. +# +# What they deliberately do NOT pin is *where* the splits land: packing is a +# concatenation with carries, so any grouping of the same tokens emits the same +# bytes. Moving a split point by a token is a refactor, and these tests are +# expected to stay green through one; dropping the carry, padding a non-final +# group, or skipping its byte stuffing are the failures they exist to catch +# (each was checked by mutating the implementation). + + +def reference_pack(chunk: np.ndarray, nbits: np.ndarray) -> bytes: + """T.81 entropy packing written as the obvious Python loop. + + MSB-first per token, 1-padded to a byte boundary, 0x00 stuffed after every + 0xFF. Deliberately naive: it is the oracle, so it must be readable rather + than fast, and it must not share structure with the implementation. + """ + bits: list[int] = [] + for value, width in zip(chunk.tolist(), nbits.tolist(), strict=True): + for shift in range(width - 1, -1, -1): + bits.append((value >> shift) & 1) + while len(bits) % 8: + bits.append(1) + out = bytearray() + for start in range(0, len(bits), 8): + byte = 0 + for bit in bits[start : start + 8]: + byte = (byte << 1) | bit + out.append(byte) + if byte == 0xFF: + out.append(0x00) + return bytes(out) + + +def token_stream(total_bits: int, seed: int) -> tuple[np.ndarray, np.ndarray]: + """Random (value, width) tokens covering at least `total_bits` bits. + + Widths span 1..27 — the real range, a Huffman code of up to 16 bits plus up + to 11 amplitude bits — so group boundaries fall at every possible offset + within a token rather than at a fixed stride. + """ + rng = np.random.default_rng(seed) + widths = rng.integers(1, 28, size=total_bits // 14 + 8).astype(np.int64) + keep = int(np.searchsorted(np.cumsum(widths), total_bits)) + 1 + widths = widths[: min(keep, widths.size)] + values = (rng.integers(0, 1 << 30, size=widths.size).astype(np.int64)) & ( + (np.int64(1) << widths) - 1 + ) + return values, widths + + +@pytest.mark.parametrize("group_bits", [8, 9, 64, 97, 1024]) +def test_entropy_packer_carries_across_group_boundaries(monkeypatch, group_bits): + """Tiny groups make every pass end mid-byte, in every alignment.""" + monkeypatch.setattr(_jpeg, "_ENTROPY_BIT_CHUNK", group_bits) + values, widths = token_stream(6 * 1024, seed=group_bits) + assert int(widths.sum()) > 4 * group_bits # several boundaries, not one + assert _jpeg._pack_entropy(values, widths) == reference_pack(values, widths) + + +def test_entropy_packer_is_transparent_at_the_production_group_size(): + """The shipped `_ENTROPY_BIT_CHUNK`, over a stream that crosses it twice.""" + values, widths = token_stream(3 * _jpeg._ENTROPY_BIT_CHUNK, seed=11) + assert int(widths.sum()) > 2 * _jpeg._ENTROPY_BIT_CHUNK + assert _jpeg._pack_entropy(values, widths) == reference_pack(values, widths) + + +@pytest.mark.parametrize( + "values,widths", + [ + ([], []), # no tokens at all + ([1], [1]), # a single sub-byte token: pure padding path + ([0xFF], [8]), # exactly one byte, and it needs stuffing + ([0xFF, 0xFF], [8, 8]), # consecutive stuffed bytes + ([0x7FFFFFF], [27]), # the widest real token + ], +) +def test_entropy_packer_degenerate_streams(values, widths): + v = np.asarray(values, dtype=np.int64) + w = np.asarray(widths, dtype=np.int64) + assert _jpeg._pack_entropy(v, w) == reference_pack(v, w) + + +def test_entropy_packer_stuffing_survives_a_boundary(monkeypatch): + """A 0xFF byte formed from bits that straddle two passes still stuffs.""" + monkeypatch.setattr(_jpeg, "_ENTROPY_BIT_CHUNK", 5) + # 5-bit groups, all-ones: every byte is 0xFF and no byte is group-aligned. + v = np.full(8, 0b11111, dtype=np.int64) + w = np.full(8, 5, dtype=np.int64) + packed = _jpeg._pack_entropy(v, w) + assert packed == reference_pack(v, w) + assert packed.count(b"\xff\x00") == packed.count(b"\xff") # every FF stuffed + + +def test_large_image_entropy_matches_a_single_pass(monkeypatch): + """End-to-end: chunking changes nothing on a real token distribution. + + Noise maximizes nonzero AC coefficients, so this image's entropy stream + crosses the production group size many times — unlike every other image in + this file. Encoding it again with the packer forced into one pass is the + strongest available statement that the carry is exact. + """ + rng = np.random.default_rng(5) + img = rng.integers(0, 256, (320, 400, 4), dtype=np.uint8) + chunked = _jpeg.encode(img, quality=90) + monkeypatch.setattr(_jpeg, "_ENTROPY_BIT_CHUNK", 1 << 62) + assert _jpeg.encode(img, quality=90) == chunked + # ...and the stream really was long enough to exercise several groups. + assert len(chunked) * 8 > 4 * (1 << 18)