Skip to content

Speed up calibrator hot paths 12–21× with bit-identical results - #20

Merged
soodoku merged 2 commits into
mainfrom
claude/vibrant-heisenberg-osft1o
Sep 29, 2026
Merged

soodoku merged 2 commits into
mainfrom
claude/vibrant-heisenberg-osft1o

Conversation

@soodoku

@soodoku soodoku commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A profile-driven pass over time, space and import cost. Every output is bit-for-bit identical to main.

What profiling found. A single update took ~550 µs whether it saw 1 label or 100. Over 90% of that went to scikit-learn's IsotonicRegression wrapper: five check_array calls, dataframe sniffing, parameter validation, a sort of already-sorted bins, and building an interpolator, all around a PAV step on 20 numbers.

Change Why
Fit with scipy.optimize.isotonic_regression (SciPy's public PAV function, C++) Same solve, same results, without the estimator wrapper: 506 → 6 µs for B=20. Requires SciPy ≥ 1.12, the version that added it.
Large batches find bins and interpolate by direct calculation on the evenly spaced grid Replaces an O(log B) binary search per value with O(1). One comparison against the same edge array fixes rounding, so results match np.digitize / np.interp exactly. Below 1,024 / 2,048 values the NumPy routines are used, because they're faster there.
Lazy-load names that need scikit-learn (PEP 562) A process that only runs StreamingIsotonicCalibrator never imports scikit-learn.
BatchCalibrator stores labels as uint8 16 → 9 bytes per retained observation; labels become float again only for fitting.

Changes after review:

  • Removed a private copy of the Brier formula; compare_prequential is back to main exactly.
  • Removed a chunked-history scheme for BatchCalibrator. Evicting the oldest chunk was O(window) per update, and per-chunk overhead could exceed the data it held.
  • Replaced the string-built import probe with a real function.

Receipts: speed

python -m benchmarks.speed (new, reproducible), main vs this branch on the same machine, BLAS pinned to one thread. Values are medians; the machine is shared, so expect ±20% noise.

Path main this PR
update, B=20, 1 label 609 µs 45 µs 13×
update, B=1000, 10k labels 1,743 µs 170 µs 10×
half-life calibrate, B=100, 100 values 590 µs 29 µs 21×
calibrate, B=1000, 10k values 820 µs 142 µs 5.8×
calibrate, 1–100 values 6–9 µs 6–9 µs parity
import streamcal 1,140 ms 78 ms 15×

Receipts: space

main this PR
Peak memory, import streamcal + first update 148 MB 75 MB
BatchCalibrator history per observation 16 B 9 B
StreamingIsotonicCalibrator state 40B + 8 bytes unchanged

Correction. An earlier version of this description said peak memory was unchanged at 159 MB. That was a measurement error: on Linux a forked child's peak RSS starts from its parent's, and the benchmark had loaded scikit-learn before spawning its probes. benchmarks/speed.py now probes first.

Receipts: identical outputs

  • Differential test: the same randomized workloads ran through main and this branch in separate processes, and every output was compared bitwise. That's 1,199,512 values, 0 differences. It covers:
    • streaming calibrator: 300 streams, bins 1–1000, decay and half-life modes, prior 0/1/10, exact edges and extremes
    • batch calibrator: isotonic, sigmoid and temperature, with windows and refit cadences
    • compare_prequential: every score field
  • tests/test_kernels.py (new) keeps this in CI:
    • both grid kernels vs NumPy on every edge and center ±1 float step for 73 bin counts
    • the size switches on both sides of their thresholds
    • windowed history with one-byte labels vs a direct refit
    • history not aliasing caller arrays
    • importing the streaming calibrator not loading scikit-learn
    • every public name resolving

Compiled code and .pyc

Measured, not adopted:

  • streamcal's own .pyc: no measurable effect on import.
  • Dependencies' bytecode: this is what matters. On first import after an install without precompiled bytecode (uv's default), main took 5.2 s instead of 1.1 s. Production images should build with UV_COMPILE_BYTECODE=1.
  • What's left in an update: a 1-label update is ~98 thin NumPy calls (~45 µs). A compiled kernel could reach single-digit µs, but:
    • Numba adds LLVM (~100 MB) and JIT warm-up.
    • Cython or C needs per-platform wheels.
    • mypyc doesn't remove NumPy call overhead.

Validation

ruff check, ruff format --check, pyright src, pydoclint src all clean. pytest: 228 passed. sphinx-build -W passes and uv build succeeds.

🤖 Generated with Claude Code

https://claude.ai/code/session_011pXaJ5DLex7BrdUyfTqjL4

Profiling showed each update spent over 90% of its ~550 us inside
scikit-learn's IsotonicRegression wrapper (input validation, dataframe
sniffing, interpolator construction) around a PAV step on B numbers.

- Fit with scipy.optimize.isotonic_regression (C++ PAVA, SciPy >= 1.12).
  Update and half-life calibrate: 11-27x faster.
- Large batches locate bins and interpolate by direct calculation on the
  evenly spaced grid, corrected against the same edge arrays, instead of
  a binary search: O(1) per value, identical to np.digitize/np.interp.
  Small batches keep NumPy's lower fixed cost.
- Lazy-load names that need scikit-learn (PEP 562), so importing the
  streaming calibrator no longer loads it: 1.1 s to 0.1 s.
- BatchCalibrator keeps history in chunks joined at refit, with one-byte
  labels: no-refit updates stop copying all history (265x at 1M rows)
  and history drops from 16 to 9 bytes per row.
- compare_prequential scores batches with sklearn's exact two-column
  Brier expression instead of re-validating each one: 18x at batch 1.

Every output was checked bit for bit against main on 1.2M values from
randomized workloads; tests/test_kernels.py keeps the equivalences in CI
and benchmarks/speed.py reproduces the timings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pXaJ5DLex7BrdUyfTqjL4
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T02:28:19.079648Z 19d8299 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e992eb5ae3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/streamcal/batch.py Outdated
while excess > 0:
oldest = self._probability_chunks[0].size
if oldest <= excess:
del self._probability_chunks[0], self._outcome_chunks[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid shifting every retained chunk during head eviction

When a rolling calibrator receives small updates and refit_every exceeds window_size, the full window can contain one chunk per update. Once full, every subsequent update executes these front deletions, which shift all remaining list entries and make each nominally constant-size update O(window_size); dropping many chunks for a large incoming batch can become quadratic. Use a deque, a moving head index, or periodic chunk compaction so window eviction does not shift every retained chunk.

Useful? React with 👍 / 👎.

Comment thread src/streamcal/batch.py Outdated
Comment on lines +173 to +175
return sum(
chunk.nbytes for chunk in self._probability_chunks + self._outcome_chunks
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count retained backing arrays in history_bytes

When a rolling window removes less than half of its oldest chunk, _drop_head intentionally leaves a view backed by the larger original array, but this property counts only the view's nbytes. For example, inserting 10 rows into a window of 8 keeps 90 bytes allocated while reporting 72, so the public memory diagnostic understates retained storage. Either copy after trimming or traverse each chunk's base allocation as retained_array_bytes does.

Useful? React with 👍 / 👎.

- Score each prequential batch with brier_score again rather than a
  private copy of its formula.
- Drop the chunked BatchCalibrator history. Evicting the oldest chunk
  was O(window) per update, and per-chunk object overhead could exceed
  the data it held. Keep only one-byte labels (16 -> 9 bytes per row).
- Replace the string-built import probe in benchmarks/speed.py with a
  real function run via --probe-import. Import NumPy inside main() and
  probe before loading anything heavy: on Linux a forked child's peak
  RSS starts from the parent's, which had inflated the earlier figure.
- Remove an unneeded __future__ import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pXaJ5DLex7BrdUyfTqjL4
@soodoku soodoku changed the title Speed up calibrator hot paths 11–27× with bit-identical results Speed up calibrator hot paths 12–21× with bit-identical results Sep 29, 2026
@soodoku
soodoku merged commit 1df1671 into main Sep 29, 2026
11 checks passed
@soodoku
soodoku deleted the claude/vibrant-heisenberg-osft1o branch September 29, 2026 03:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants