Skip to content

refresh README benchmarks + cross-order table (issue #65)#131

Merged
espg merged 7 commits into
mainfrom
claude/65-cross-order-bench
Jul 22, 2026
Merged

refresh README benchmarks + cross-order table (issue #65)#131
espg merged 7 commits into
mainfrom
claude/65-cross-order-bench

Conversation

@espg

@espg espg commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Closes #65
Refs #48

What / approach

Per @espg's decisions on the issue (2026-07-22):

  1. Table home — cross-order table lives in docs/ with a README pointer (README stays lean).
  2. README figures — the stale Rust-vs-Python table is retired ("there are no python functions to compare against anymore"; the Rust extension is the sole runtime path per CLAUDE.md §7) and replaced with absolute cross-order numbers. The headline bench is raw throughput: millions of morton indices/sec for encode (geo2mort) and decode (mort2geo).
  3. Gating on Mixed-order support in the geo kernels (issue #116) #122 — moot; Mixed-order support in the geo kernels (issue #116) #122 has landed, so numbers are generated on post-Mixed-order support in the geo kernels (issue #116) #122 main.

New self-writing generator bench_cross_order.py mirrors the existing bench_matrix.py precedent: fixed-seed inputs, prints a markdown table and rewrites it in place between <!-- BENCH_CROSS_ORDER:START/END --> markers into a new docs/benchmarks.md page. Columns: encode (M idx/s), decode (M idx/s), coverage (cells / ms) at orders {4, 12, 18, 29}.

Sample of the generated table (real numbers from a run on the built extension in this worktree — timings are machine/run dependent):

| order | encode (M idx/s) | decode (M idx/s) | coverage (cells / ms) |
|--:|--:|--:|--:|
| 4  | 44.0 | 20.8 | 1c / 0ms |
| 12 | 30.4 | 17.2 | 4c / 0ms |
| 18 | 26.3 | 15.0 | 186c / 1ms |
| 29 | 22.6 | 11.6 | 419,292c / 3076ms |

Coverage scoping (said out loud, not silently capped): flat morton_coverage scales as ~4**order along the boundary and exhausts memory well before order 29 (measured: 78.5M cells at order 18 on a 2° box). The cross-order coverage column therefore uses the compact morton_coverage_moc on a small fixed ~0.01° (~1 km) box, deliberately chosen so order 29 stays tractable, and the doc/script both explain this and point to docs/coverage_methods.md for the flat-vs-MOC / budget trade-offs on real polygons.

Phases

  • Phase 1 — cross-order generator: bench_cross_order.py + docs/benchmarks.md (table page).
  • Phase 2 — refresh existing numbers: retired the README Rust-vs-Python table (README.md ~30-38), replaced with headline absolute throughput + a pointer to docs/benchmarks.md; regenerated the docs/coverage_methods.md matrix via bench_matrix.py (timings refreshed; cell counts byte-identical, as expected).
  • Phase 3 — reproducibility note: added a "Regenerating benchmarks" section to docs/benchmarks.md documenting the two one-line commands (mirrors how bench_matrix.py is documented).
  • Folded in self-review: corrected the README "roughly flat" wording to "within roughly 2×" to match the linked table.

How tested

  • Built the extension in the worktree (maturin develop --release) before measuring; all numbers are real measured output, none fabricated.
  • flake8 mortie --select=E9,F63,F7,F82 clean; bench_cross_order.py clean under --select and --max-line-length=88.
  • Ran both generators end-to-end — each prints and rewrites its marker block (docs/benchmarks.md, docs/coverage_methods.md).
  • pytest -v: 708 passed, 11 skipped, 1 pre-existing failure unrelated to this PR — see status comment. This PR changes no mortie/ source (diff is README + docs + one new root-level bench script no test imports), so it introduces no test regression.

Questions for review

  • Script location. The issue text says benchmarks/bench_cross_order.py, but its structural twin bench_matrix.py actually sits at the repo root (run as python bench_matrix.py, writes into docs/); the benchmarks/ dir holds CodSpeed/pytest bench tests, not marker-writing generators. I placed the new generator at root beside bench_matrix.py to match the real precedent. Happy to move it to benchmarks/ if you prefer.
  • Coverage method for the cross-order column. I used morton_coverage_moc (compact) rather than flat morton_coverage, since flat is intractable past ~order 15. Reasonable? (flat vs MOC is already covered in docs/coverage_methods.md.)
  • No unit test for the generator. bench_matrix.py has no accompanying test — it's a dev script — so I mirrored that (no behavioral test for bench_cross_order.py). Say the word if you'd like a marker-regeneration smoke test.
  • CodSpeed cross-order cases. The plan floated adding order-parametrized cases to benchmarks/test_bench_cpu.py so CodSpeed tracks the cross-order dimension too. Out of scope for this PR unless you want it — flagging so it doesn't get lost.

@espg espg added the implement label Jul 22, 2026
Comment thread bench_cross_order.py

@espg espg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Phase 1 fresh-context self-review (diff vs CLAUDE.md + issue #65 acceptance criteria). Findings inline. Summary:

  • One actionable, diff-scoped item: document that the coverage ms is a single cold sample (the encode/decode columns are warm median-of-5). Will fold a one-line note into the doc during Phase 2/3.
  • Two confirmations (loop-variable closure is safe here; throughput denominator is correct).

No blockers for advancing to Phase 2. Acceptance-criteria check: generator is committed and self-writing between markers (mirrors bench_matrix.py), cell counts deterministic, timings disclaimed — all met for the cross-order table itself. README retire + bench_matrix.py regen land in Phase 2; reproducibility note in Phase 3.


Generated by Claude Code

Comment thread bench_cross_order.py Outdated
# coverage: mixed-order cover of the small fixed box (rep=1, no warmup —
# order 29 is seconds-scale).
t_cov, cov = timed(
lambda: mortie.morton_coverage_moc(BOX_LAT, BOX_LON, order=order), 1)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Methodology asymmetry: encode/decode are timed warm as median-of-5, but coverage is a single cold rep=1 sample with no warmup. The coverage ms is therefore noisier and not strictly comparable to the encode/decode columns. That's a deliberate trade (order-29 coverage is ~3 s, so median-of-5 would add ~15 s to the run), and timings are already disclaimed as machine/run-dependent — but the doc's "Notes on the numbers" should say the coverage timing is a single sample so nobody over-reads a 5% wobble between runs. Cell counts are unaffected (deterministic). Folding a one-line note into the Phase 2/3 doc edit.


Generated by Claude Code

Comment thread bench_cross_order.py
for order in ORDERS:
# encode: geo2mort(lat, lon, order) -> uint64 morton array
morton = mortie.geo2mort(lat, lon, order=order) # warmup + decode input
t_enc, _ = timed(lambda: mortie.geo2mort(lat, lon, order=order), 5)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

The lambdas close over the loop variable order (and morton), which is a classic late-binding footgun — but here it's safe: timed() invokes the lambda synchronously within the same iteration before order advances, so each measurement uses the intended order. Noting it only so a future reader doesn't "fix" it into a bug; no change needed. Same pattern as bench_matrix.py's lambda: fn(la, lo, order).


Generated by Claude Code

Comment thread bench_cross_order.py
# decode: mort2geo(morton) -> (lat, lon)
mortie.mort2geo(morton) # warmup
t_dec, _ = timed(lambda: mortie.mort2geo(morton), 5)
dec_mps = N / t_dec / 1e6

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Throughput uses N (element count) for both encode and decode, which is correct: mort2geo returns a 2-tuple of N-length arrays, so N morton indices are decoded per call regardless of the tuple shape. M idx/s = N / median_seconds / 1e6 is the honest per-index rate. Good — just confirming the denominator isn't accidentally double-counting the lat+lon outputs.


Generated by Claude Code

@espg espg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Phase 2 fresh-context self-review (README retire + docs/coverage_methods.md regen).

  • Actionable inline: the "roughly flat across orders 4–29" wording contradicts the linked table (~2x drop). Fixing in a follow-up commit.
  • docs/coverage_methods.md: diff is timings-only — the four cell-count columns are byte-identical to the prior committed matrix, which is the right invariant (counts deterministic, ms machine-dependent). The static flood-fill prose above the table (2,989 ms / 45.8 s / "~40–60x") is unchanged and out of scope here.
  • README no longer quotes hand-pasted absolute numbers (only "tens of millions … per second"), so it won't go stale as the docs table is regenerated — matches @espg's "keep the README lean" decision.

Generated by Claude Code

Comment thread README.md Outdated
Python implementation to fall back on — so performance is reported as **absolute
throughput** rather than a speedup ratio. Encoding (`geo2mort`) and decoding
(`mort2geo`) run at **tens of millions of morton indices per second** on one
core, roughly flat across orders 4–29.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

"roughly flat across orders 4–29" overstates it — the generated table this line points to shows encode 44.0 -> 22.6 M idx/s and decode 20.8 -> 11.6 M idx/s from order 4 to 29, i.e. about a 2x drop, not flat. A reader who clicks through will notice the mismatch. Softening to "within roughly 2x across orders 4–29" in a follow-up so the README claim matches the linked numbers.


Generated by Claude Code

@codspeed-hq

codspeed-hq Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 67 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing claude/65-cross-order-bench (8c270dd) with main (527d039)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@espg espg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

Phase 3 fresh-context self-review (reproducibility section in docs/benchmarks.md).

  • Mirrors the bench_matrix.py documentation pattern; both commands are the real one-liners that rewrite their target docs between markers — verified end-to-end in this run.
  • Also folded in the Phase 1 finding here: the notes now state encode/decode are warm median-of-five and the coverage ms is a single cold sample.
  • One non-blocking observation inline (build needs a virtualenv; deferred to BUILDING.md).

All three phases complete. Remaining follow-up before handing off: the Phase 2 "roughly flat" wording fix (committing now).


Generated by Claude Code

Comment thread docs/benchmarks.md
```bash
python bench_cross_order.py # this page: cross-order encode/decode/coverage
python bench_matrix.py # docs/coverage_methods.md: coverage-method matrix
```

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

The reproducibility section mirrors how bench_matrix.py is documented in coverage_methods.md (self-writing between markers, run from repo root) — good, that was the acceptance criterion. One honesty gap worth a follow-up: maturin develop --release requires an active virtualenv (it aborts with "Couldn't find a virtualenv" otherwise), which bit me setting this up. BUILDING.md is linked and should cover it, so I'm leaving the one-liner as-is rather than duplicating build prerequisites here — flagging only so it's a conscious choice, not an oversight.


Generated by Claude Code

@espg

espg commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Status: all three phases complete and pushed; awaiting review. Marking waiting, keeping the PR in draft.

Local gate results

  • flake8 mortie --select=E9,F63,F7,F82 — clean. bench_cross_order.py clean under --select and the --max-line-length=88 style pass.
  • pytest708 passed, 11 skipped, 1 failed.

Pre-existing failure flag (not introduced here, not fixed here per CLAUDE.md §4):

  • Failing test: mortie/tests/test_mixed_order.py::TestResolutionCrossCheck::test_orders_of_indexes_order2res.
  • Root cause: mortie/tools.py:37 order2res does float(np.sqrt(area)) where area is an array (called on orders_of(words)), which raises TypeError: only 0-dimensional arrays can be converted to Python scalars under numpy 2.x (my env: numpy 2.4.6; the pin is numpy>=1.20). There is also a divide by zero RuntimeWarning at the same spot.
  • This PR touches no mortie/ source — the diff is README.md, docs/benchmarks.md, docs/coverage_methods.md, and the new root-level bench_cross_order.py (which no test imports) — so it cannot be the cause and introduces no regression.
  • Note: the failure I was pointed at (test_spec_page.py … test_order29_string_parses_to_area_word, main is red: test_spec_page tie-break test predates #120's p-suffix rendering #123) passed in my environment, while this different one surfaced — consistent with a numpy-version-dependent pre-existing redness on main, not anything in this change. Flagging for visibility; happy to open a separate issue for the order2res array-input bug if you want it tracked (that's a side-effecting action, so leaving it to you).

Self-reviews for all three phases are posted inline above (prefixed 🤖 *from Claude (review)*); the one actionable finding (README "roughly flat" wording) is folded into the last commit.


Generated by Claude Code

@espg espg added the waiting label Jul 22, 2026
@espg espg mentioned this pull request Jul 22, 2026
@espg

espg commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Two follow-ups in ad10c6f, prompted by the "MOC looks 2× slower" read on the first table.

1. It wasn't a regression — the number was a cold-call artifact. I built the pre-#103 baseline (faa2410, 2026-07-05) and current main and timed morton_coverage_moc on four polygon classes (mid-lat ring, hi-lat span, 400-vertex scalloped, near-pole) at orders 7/9/11. They're within run-to-run noise everywhere, with matching cell counts:

polygon order pre-#103 current
mid-lat ring 11 3.78 ms 3.19 ms
scalloped (400-vtx) 11 11.91 ms 11.67 ms
near-pole 11 5.41 ms 4.62 ms

The #106 exact-predicate swap (issue #103, 2026-07-10) did cost coverage briefly, but the next commit 2f0439d ("recover coverage perf after issue #103 predicate swap") clawed it back to parity. The residual boundary over-refinement cost that remains is the pure-conservatism node_straddles refinement already tracked in #90 / measured in #112 — not new.

2. The apparent 2× came from this bench's coverage column, now fixed. It timed morton_coverage_moc with rep=1 and no warm-up on a tiny ~0.01° box. The first parallel call in a process pays a one-time rayon-threadpool + cold-cache cost that is ~5× the warm time on a small cover (measured: 1.07 ms cold vs 0.21 ms warm at order 11), and the tiny box means fixed overhead dominates. The commit:

  • Warms the extension once before timing, then takes a warm median (5 reps at cheap orders; a single call at order 29, which is seconds-scale). Cell counts are unchanged (deterministic) — only the timing basis moved to steady state.
  • Adds a First-call warm-up section to docs/benchmarks.md with the measured cold/warm ratios across operations, and a production-facing warm-up note to docs/coverage_methods.md (warm once at startup if first-call latency matters).

The ratio is inversely proportional to per-call work, which is worth knowing on its own: geo2mort 1M is ~1.05× cold, mort2geo 1M ~1.5×, small coverage ~5×. So batch encode/decode barely notices; small covers / single points / tight loops do.

Gates: flake8 bench_cross_order.py clean at --select=E9,F63,F7,F82 and --max-line-length=88; regenerated the table on a clean build of this branch. Still draft + waiting.

Separately: this doesn't touch the "should benchmarks auto-update on release" question — my read is timings should not be auto-written into docs from shared CI runners (run-to-run variance exceeds what we're measuring); deterministic cell counts can be, and CodSpeed (benchmarks/test_bench_cpu.py, already per-PR) is the right home for tracked timing history. Happy to open that as its own discuss if you want to pursue it.


Generated by Claude Code

@espg

espg commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Follow-up in 4037726: docs/coverage_methods.md now carries a measured cold-vs-warm table for morton_coverage_moc, not just a prose note. bench_matrix.py already warmed before timing (so the matrix was steady-state), so I added a BENCH_WARMUP self-writing block beside it.

Because the first-call cost is process-global and one-time — only the very first parallel call is cold; every later matrix cell is already warm — a per-cell "cold" column across the matrix would be misleading. So each warm-up row is measured as a genuine first call in its own fresh subprocess, contrasting cover sizes:

MOC cover cold (first call) warm ratio
~1 km box, order 11 1.1 ms 0.2 ms 6.2x
81.6k-vert basin, order 10 178.6 ms 159.7 ms 1.1x

That's the actual insight: the penalty is inversely proportional to cover size — dominates a tiny cover, vanishes on a realistic basin. The matrix note now states its timings are the warm median.

flake8 bench_matrix.py --select=E9,F63,F7,F82 clean; new code is 88-col clean (the four --max-line-length=88 hits are the pre-existing METHODS lambda lines, untouched by this change). Regenerated on a clean build of the branch. Still draft + waiting.

If you did want a literal per-row cold column in the matrix instead, I can do it, but it would have to spawn one subprocess per cell (12 methods × 4 cases = a much slower regen) and most rows would just re-show the same ~1× — flagging the tradeoff rather than assuming.


Generated by Claude Code

Comment thread bench_matrix.py
@espg
espg marked this pull request as ready for review July 22, 2026 20:33
@espg
espg merged commit f3b2bcc into main Jul 22, 2026
15 checks passed
@espg
espg deleted the claude/65-cross-order-bench branch July 22, 2026 20:33
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.60%. Comparing base (8a67647) to head (8c270dd).
⚠️ Report is 19 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #131      +/-   ##
==========================================
+ Coverage   94.38%   94.60%   +0.21%     
==========================================
  Files           9        9              
  Lines        1318     1371      +53     
==========================================
+ Hits         1244     1297      +53     
  Misses         74       74              
Flag Coverage Δ
unittests 94.60% <ø> (+0.21%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 1 file with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update f773873...8c270dd. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v1.0: refresh README benchmarks + add a cross-order benchmark table

2 participants