Skip to content

perf: tail-only f32 re-encode on streaming append - #314

Open
FarhanAliRaza wants to merge 2 commits into
mainfrom
perf/tail-only-append-encode
Open

perf: tail-only f32 re-encode on streaming append#314
FarhanAliRaza wants to merge 2 commits into
mainfrom
perf/tail-only-append-encode

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What

Streaming appends re-encoded 100% of every shipped geometry column per tick — an O(N) f64→f32 native pass plus an N-sized allocation per column — even though the sticky ship offset (Column.suggest_offset) keeps the encoding prefix-stable and the browser client already uploads only the tail (bufferSubData). This is item 1 of the 2026-07 Abseil perf audit.

This PR adds the kernel-side twin of the client's tail upload: a per-column whole-column f32 encode cache (Column.encoded_f32) served by an identity fast path in _PayloadWriter.ship, so each tick encodes only the appended rows.

How

  • Cache keyed on (offset, scale, values base pointer), held in a capacity-doubling f32 buffer mirroring Column.append's growth buffer.
  • Column.append re-keys the cache across its own growth-buffer migrations (prefix preserved byte-for-byte) and drops it on external rebinding.
  • Invalidation (offset recenter, scale change, shrink) re-encodes fully into a fresh buffer — previously shipped split-payload memoryviews (widget reopen state) are never overwritten.
  • Filtered/decimated/masked ships (values is not col.values) bypass the cache unchanged.
  • +4 B/point resident RAM per shipped column, itemized in memory_report() per column and totaled as encode_cache_bytes (§27: if it isn't in the report it isn't real).

Numbers

  • New CodSpeed benchmark test_stream_scatter_append_direct (direct tier pinned via density=False): 495µs → 205µs per 1k-row append at 100k points.
  • 8 direct traces × 199k points (append re-emits every trace): 8.96ms → 0.64ms per tick (~14x).
  • test_stream_line_append is expected to move less: decimated lines ship M4 views that bypass the cache by design.

Tests

  • test_append_reencodes_only_the_tail — spies on kernels.encode_f32; fails on main (4097-row full re-encode per 1-row append), passes here.
  • test_append_cached_encode_matches_full_reencode — cached bytes are identical to a from-scratch encode.
  • test_shipped_buffers_survive_later_appends_and_recenter — shipped zero-copy views stay byte-stable across tail extensions and cache invalidation.
  • test_memory_report_itemizes_encode_cache_bytes.
  • Existing prefix-stability suite (test_append_keeps_offsets_sticky_and_prefixes_byte_identical etc.) passes unchanged; scripts/append_stream_smoke.py passes end-to-end.

Spec

  • spec/design-dossier.md §27 memory rules: encode-cache corollary.
  • spec/design/wire-protocol.md §4: kernel-side tail-only re-encode note.
  • spec/benchmarks/results.md: streaming_updates row now inventories the CodSpeed streaming benchmarks.

Summary by CodeRabbit

  • Performance

    • Streamlined streaming appends by reusing warmed column encodings and encoding only newly appended rows when offsets/scales remain compatible.
    • Kept direct-tier behavior stable during tail-only updates, with trace point counts growing as expected.
  • Monitoring

    • Memory reports now include encoding-cache usage (with per-column and total accounting).
  • Documentation

    • Updated memory and append/wire-protocol specs plus benchmark documentation to reflect the new caching and tail-only encoding behavior.
  • Tests

    • Added streaming append payload-prefix stability checks, cache accounting assertions, and a new CodSpeed direct-tier benchmark.

Every append tick re-encoded 100% of every shipped geometry column
(O(N) f64->f32 pass + N-sized alloc per column per tick) even though the
sticky ship offset keeps the encoding prefix-stable and the client only
uploads the tail.

Cache the whole-column f32 encoding on Column (encoded_f32: offset/scale/
base-pointer keyed, capacity-doubling buffer mirroring Column.append) and
serve identity whole-column ships from it, encoding only the appended rows.
Invalidation is conservative: offset/scale changes, shrinks, or external
rebinding re-encode fully into a fresh buffer so previously shipped
split-payload views are never overwritten; append re-keys the cache across
its own growth-buffer migrations. Filtered/decimated/masked ships bypass
the cache unchanged.

- +4 B/point resident RAM per shipped column, itemized in memory_report()
  as encode_cache_bytes (per column and totaled)
- pinned by tests/test_streaming.py::test_append_reencodes_only_the_tail
  plus byte-identity, shipped-buffer aliasing, and memory-report tests
- new CodSpeed benchmark test_stream_scatter_append_direct (direct tier
  pinned via density=False): 495us -> 205us per 1k-row append at 100k pts;
  8 direct traces x 199k pts: 8.96ms -> 0.64ms per tick
- spec: design-dossier §27 memory rules corollary, wire-protocol §4
  kernel-side tail-only note, benchmarks results streaming_updates row
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 18d0a2f8-c087-48a0-9b84-8c57e33a9baa

📥 Commits

Reviewing files that changed from the base of the PR and between ae32d9a and 1c5d68a.

📒 Files selected for processing (1)
  • python/xy/columns.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • python/xy/columns.py

📝 Walkthrough

Walkthrough

Streaming append payloads now cache whole-column f32 encodings, incrementally encode appended rows, invalidate caches when required, report cache memory, and validate prefix stability through tests and benchmarks.

Changes

Streaming encode cache

Layer / File(s) Summary
Cached encoding and invalidation
python/xy/columns.py, spec/design-dossier.md, spec/design/wire-protocol.md
Columns cache relative f32 encodings, preserve or invalidate caches across appends, and expose cache memory in memory_report(). Design specifications document tail-only encoding and cache rules.
Payload shipping and append validation
python/xy/_payload.py, tests/test_streaming.py
Whole-column shipments reuse cached encodings, while tests verify tail-only re-encoding, byte equivalence, buffer stability, recentering, and cache accounting.
Streaming benchmark coverage
benchmarks/test_codspeed_kernels.py, spec/benchmarks/results.md
Adds a direct-tier scatter append benchmark and records expanded streaming append benchmark coverage.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Figure.append
  participant _PayloadWriter.ship
  participant Column
  participant kernels.encode_f32
  Figure.append->>_PayloadWriter.ship: ship whole-column values
  _PayloadWriter.ship->>Column: request cached f32 encoding
  Column-->>_PayloadWriter.ship: return cached prefix and encoded tail
  _PayloadWriter.ship->>kernels.encode_f32: encode fallback or appended rows
  kernels.encode_f32-->>Figure.append: return payload buffers and metadata
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main change: tail-only f32 re-encoding during streaming appends.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/tail-only-append-encode

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 103 untouched benchmarks
🆕 1 new benchmark
⏩ 2 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
🆕 test_stream_scatter_append_direct N/A 6.2 ms N/A

Comparing perf/tail-only-append-encode (1c5d68a) with main (56a7a90)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/xy/columns.py (1)

332-353: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle external rebinding before re-keying the cache.

If _grow already exists, values is rebound, and a cache is warmed for the rebound array, Line 349 matches old_ptr and re-keys that cache to the stale _grow buffer. The next payload combines the rebound encoded prefix with stale canonical values. Detect that values no longer aliases _grow, rebuild growth storage from current values, and invalidate the cache.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/xy/columns.py` around lines 332 - 353, Update the growth logic around
the cache handling in the column append method to detect when an existing _grow
buffer no longer aliases self.values before re-keying _enc_cache. Rebuild _grow
from the current values, preserve the required capacity and data, and invalidate
the cache when external rebinding is detected; only re-key a cache after
confirming the growth buffer remains the active backing storage.
🧹 Nitpick comments (1)
tests/test_streaming.py (1)

439-453: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the replacement payload is correct after recentering.

This only proves bufs1 was not mutated. A fresh buffer containing stale pre-recenter bytes would still pass. Retain the final msg, buffers and compare each final column buffer with kernels.encode_f32(col.values, offset, scale).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_streaming.py` around lines 439 - 453, The test
test_shipped_buffers_survive_later_appends_and_recenter must also validate the
replacement payload after recentering, not only preservation of bufs1. Retain
the final msg and buffers from the last append, obtain each column’s values and
encoding metadata from the final payload, and assert every final buffer equals
kernels.encode_f32(col.values, offset, scale).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/xy/columns.py`:
- Around line 299-305: Update the cache-building logic around the full and tail
encode paths in python/xy/columns.py lines 299-305 to use the established
finite-value plus validity representation instead of storing raw potentially
non-finite f32 output; apply this consistently to both buf[:n] and buf[n_old:n]
updates. In python/xy/_payload.py lines 92-100, use the cache only when it
provides equivalent validity semantics to the normal geometry encoder; otherwise
preserve the existing safe encoding path.

In `@spec/design-dossier.md`:
- Around line 997-1003: The capacity-cost documentation needs to distinguish
allocated f32 cache capacity from current point count and tail work. In
spec/design-dossier.md:997-1003, update the whole-column cache description to
state 4 B per allocated f32 capacity slot and identify encode_cache_bytes as the
actual resident allocation, including minimum allocation and doubling behavior.
In spec/design/wire-protocol.md:271-278, state that tail encoding is O(appended
rows) while buffer maintenance, including resize copies, is amortized.

---

Outside diff comments:
In `@python/xy/columns.py`:
- Around line 332-353: Update the growth logic around the cache handling in the
column append method to detect when an existing _grow buffer no longer aliases
self.values before re-keying _enc_cache. Rebuild _grow from the current values,
preserve the required capacity and data, and invalidate the cache when external
rebinding is detected; only re-key a cache after confirming the growth buffer
remains the active backing storage.

---

Nitpick comments:
In `@tests/test_streaming.py`:
- Around line 439-453: The test
test_shipped_buffers_survive_later_appends_and_recenter must also validate the
replacement payload after recentering, not only preservation of bufs1. Retain
the final msg and buffers from the last append, obtain each column’s values and
encoding metadata from the final payload, and assert every final buffer equals
kernels.encode_f32(col.values, offset, scale).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ce503fd-3ecc-4a8a-b1fe-72acd7dcb24d

📥 Commits

Reviewing files that changed from the base of the PR and between 56a7a90 and ae32d9a.

📒 Files selected for processing (7)
  • benchmarks/test_codspeed_kernels.py
  • python/xy/_payload.py
  • python/xy/columns.py
  • spec/benchmarks/results.md
  • spec/design-dossier.md
  • spec/design/wire-protocol.md
  • tests/test_streaming.py

Comment thread python/xy/columns.py
Comment on lines +299 to +305
if n > n_old:
buf[n_old:n] = kernels.encode_f32(self.values[n_old:], offset, scale)
self._enc_cache = (offset, scale, n, ptr, buf)
return buf[:n]
buf = np.empty(max(n, 1024), dtype=np.float32)
buf[:n] = kernels.encode_f32(self.values, offset, scale)
self._enc_cache = (offset, scale, n, ptr, buf)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Keep cached geometry finite before it reaches the wire. The cache stores raw f32 output and the payload path ships it directly, allowing NaNs from canonical columns into vertex buffers.

  • python/xy/columns.py#L299-L305: encode invalid values using the established finite-value plus validity representation for full and tail cache updates.
  • python/xy/_payload.py#L92-L100: use the cache only after it has equivalent validity semantics to the normal geometry encoder; otherwise retain the safe path.
📍 Affects 2 files
  • python/xy/columns.py#L299-L305 (this comment)
  • python/xy/_payload.py#L92-L100
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/xy/columns.py` around lines 299 - 305, Update the cache-building logic
around the full and tail encode paths in python/xy/columns.py lines 299-305 to
use the established finite-value plus validity representation instead of storing
raw potentially non-finite f32 output; apply this consistently to both buf[:n]
and buf[n_old:n] updates. In python/xy/_payload.py lines 92-100, use the cache
only when it provides equivalent validity semantics to the normal geometry
encoder; otherwise preserve the existing safe encoding path.

Source: Coding guidelines

Comment thread spec/design-dossier.md
Comment on lines +997 to +1003
Second corollary: every whole-column geometry ship keeps the encoded f32 result as
a per-column cache (`Column.encoded_f32`) so a streaming append re-encodes only the
appended tail (§5) — +4 B/point of resident RAM per shipped column (even when the
canonical column is memmapped), itemized per column and totaled as
`encode_cache_bytes`. It is a rebuildable derived cache under rule 1: any
offset/scale change or values rebinding drops it for a full re-encode into a fresh
buffer, never overwriting bytes a previously shipped split payload still borrows.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document capacity-doubling costs accurately. The f32 cache has a minimum allocation and doubles on growth, so memory and CPU costs are amortized rather than fixed per current point/per tick.

  • spec/design-dossier.md#L997-L1003: describe 4 B per allocated f32 capacity slot and refer to encode_cache_bytes for actual resident allocation.
  • spec/design/wire-protocol.md#L271-L278: state that tail encoding is O(appended rows), while buffer maintenance is amortized due to resize copies.
📍 Affects 2 files
  • spec/design-dossier.md#L997-L1003 (this comment)
  • spec/design/wire-protocol.md#L271-L278
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@spec/design-dossier.md` around lines 997 - 1003, The capacity-cost
documentation needs to distinguish allocated f32 cache capacity from current
point count and tail work. In spec/design-dossier.md:997-1003, update the
whole-column cache description to state 4 B per allocated f32 capacity slot and
identify encode_cache_bytes as the actual resident allocation, including minimum
allocation and doubling behavior. In spec/design/wire-protocol.md:271-278, state
that tail encoding is O(appended rows) while buffer maintenance, including
resize copies, is amortized.

Source: Coding guidelines

CodSpeed flagged 12-33% regressions on first-build benchmarks: the cold
encode path allocated a slack cache buffer and memcpy'd the encode output
into it — an extra N-f32 alloc + copy per geometry column on every fresh
build. Adopt the exact-size encode output itself as the cache buffer
instead, making the cold path identical in cost to the pre-cache code;
the doubling slack starts on the first append (which always grows once).

@Alek99 Alek99 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found three issues in the current head (1c5d68a):

  1. High — rebinding can corrupt cached geometry (python/xy/columns.py:353-355). When _grow exists and values was externally rebound, a cache warmed on the rebound array satisfies cache[3] == old_ptr. append() then restores the stale _grow prefix and re-keys the rebound encoding to that pointer. I reproduced a cached payload whose prefix differed from a fresh encode of col.values. Please confirm values still aliases _grow; otherwise rebuild growth storage from the current values and invalidate the cache.

  2. Medium — resident_array_bytes omits the encode cache (python/xy/_figure.py:1876-1881). encode_cache_bytes is itemized but is not included in the combined resident total. For a 256-point scatter, the report returned 4096 canonical-capacity bytes, 2048 encode-cache bytes, and only 4096 resident-array bytes.

  3. Medium — the memory/complexity specification counts live points instead of allocated capacity (spec/design-dossier.md:997-1003, spec/design/wire-protocol.md:272-278). The f32 buffer doubles capacity, so 257 live entries can retain 512 slots—nearly 8 B/live point, not +4 B/point. The per-tick O(appended rows) claim should also be described as amortized because a growth event copies the prefix.

Validation: tests/test_streaming.py passed (28 tests), and Ruff check/format passed. The broader figure/scatter run had 238 passes and one unrelated local Chromium screenshot failure.

@masenf

masenf commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

This seems like a non-trivial amount of extra memory usage to maintain the cache. At 4-8 B / point, we're talking hundreds of megabytes in large plots.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants