From cfb663ff8d1697591f415d0f045b1587a7bd5575 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 10:54:57 +0000 Subject: [PATCH 1/7] phase 1 of issue #65 --- bench_cross_order.py | 98 ++++++++++++++++++++++++++++++++++++++++++++ docs/benchmarks.md | 57 ++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 bench_cross_order.py create mode 100644 docs/benchmarks.md diff --git a/bench_cross_order.py b/bench_cross_order.py new file mode 100644 index 0000000..92fd4ab --- /dev/null +++ b/bench_cross_order.py @@ -0,0 +1,98 @@ +"""Cross-order throughput benchmark — writes the table straight into +docs/benchmarks.md between the BENCH_CROSS_ORDER markers (and prints it). + + python bench_cross_order.py + +Self-contained: fixed-seed coordinate arrays, no external data. Reports raw +throughput (millions of morton indices per second) for encode (`geo2mort`) and +decode (`mort2geo`), plus mixed-order coverage (`morton_coverage_moc`) timing, +at representative orders. Throughput/timings are machine/run dependent; cell +counts are deterministic (fixed seed / fixed polygon). + +Note on the coverage column: coverage cost scales with the polygon's *boundary +length measured in cells* (~2**order per boundary edge for the MOC), so a fixed +polygon covered at order 29 explodes. The coverage input is therefore a small +fixed ~0.01 degree (~1 km) box, chosen so order 29 stays tractable; see +docs/coverage_methods.md for the flat-vs-MOC / precision / budget trade-offs on +real-world polygons. Flat `morton_coverage` is not benchmarked cross-order +because it scales as ~4**order along the boundary and exhausts memory well +before order 29. +""" +import time +from pathlib import Path +import numpy as np +import mortie + +DOC = Path("docs/benchmarks.md") +START, END = "", "" + +SEED = 20260722 +N = 1_000_000 # coordinates encoded/decoded per order +ORDERS = [4, 12, 18, 29] + +# Small fixed box (~0.01 degree ~ 1 km) for the coverage column; deliberately +# small so order-29 MOC coverage stays tractable (see module docstring). +BOX_LAT = np.array([10.0, 10.0, 10.01, 10.01]) +BOX_LON = np.array([20.0, 20.01, 20.01, 20.0]) + + +def coords(n=N, seed=SEED): + """Fixed-seed lat/lon arrays covering the whole sphere.""" + rng = np.random.default_rng(seed) + lat = rng.uniform(-89.0, 89.0, n) + lon = rng.uniform(-180.0, 180.0, n) + return lat, lon + + +def timed(fn, rep): + ts, out = [], None + for _ in range(rep): + t0 = time.perf_counter() + out = fn() + ts.append(time.perf_counter() - t0) + return float(np.median(ts)), out + + +def build_table(): + lat, lon = coords() + rows = ["| order | encode (M idx/s) | decode (M idx/s) | coverage (cells / ms) |", + "|--:|--:|--:|--:|"] + 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) + enc_mps = N / t_enc / 1e6 + + # decode: mort2geo(morton) -> (lat, lon) + mortie.mort2geo(morton) # warmup + t_dec, _ = timed(lambda: mortie.mort2geo(morton), 5) + dec_mps = N / t_dec / 1e6 + + # 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) + + rows.append(f"| {order} | {enc_mps:,.1f} | {dec_mps:,.1f} | " + f"{len(cov):,}c / {t_cov * 1e3:.0f}ms |") + return "\n".join(rows) + + +def main(): + table = build_table() + print(table) + doc = DOC.read_text() + if START not in doc or END not in doc: + raise SystemExit(f"markers {START} / {END} not found in {DOC}") + pre = doc[: doc.index(START) + len(START)] + post = doc[doc.index(END):] + note = (f"Encode/decode throughput measured over {N:,} fixed-seed coordinates; " + "coverage over a fixed ~0.01 degree box. `M idx/s` = millions of morton " + "indices per second, `c` = cell count, `ms` = milliseconds. Throughput " + "and timings are machine/run dependent; cell counts are deterministic.") + DOC.write_text(f"{pre}\n\n{table}\n\n{note}\n\n{post}") + print(f"\nwrote table into {DOC}") + + +if __name__ == "__main__": + main() diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..8d5316b --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,57 @@ +# Benchmarks + +Absolute throughput for the core morton operations across representative HEALPix +orders. `mortie`'s morton core is Rust-only — there is no Python fallback to +compare against (the Rust extension is the sole runtime path), so these are raw +numbers, not a speedup ratio. + +The two headline operations are the coordinate <-> morton conversions: + +- **encode** — `geo2mort(lats, lons, order)`: lat/lon -> `uint64` morton indices. +- **decode** — `mort2geo(morton)`: morton indices -> lat/lon of the cell centre. + +Both are reported as **millions of morton indices per second** (`M idx/s`), +measured over a fixed-seed array of one million coordinates spanning the sphere. +Higher is better; the order-encoded morton means throughput is nearly flat +across orders (the work is the same per element). + +The **coverage** column times `morton_coverage_moc` — the compact mixed-order +polygon cover — on a small fixed box. It scales with the polygon's *boundary +length in cells* rather than element count, so unlike encode/decode it grows +steeply with order; see [coverage_methods.md](coverage_methods.md) for the +flat-vs-MOC / precision / budget trade-offs and a per-method matrix. + +## Cross-order throughput + +The table below is regenerated by `bench_cross_order.py` (run from the repo +root) — it writes itself in place between the markers: + + + +| 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 | + +Encode/decode throughput measured over 1,000,000 fixed-seed coordinates; coverage over a fixed ~0.01 degree box. `M idx/s` = millions of morton indices per second, `c` = cell count, `ms` = milliseconds. Throughput and timings are machine/run dependent; cell counts are deterministic. + + + +### Notes on the numbers + +- **Cell/coverage counts are deterministic** (a fixed seed and a fixed polygon); + **throughput and timings are machine/run dependent** — treat them as + order-of-magnitude, not reproducible constants. +- **encode/decode are element-wise**, so `M idx/s` is roughly order-independent; + the coordinate array is one million points and each order re-encodes the same + points. +- **Coverage is deliberately scoped small.** The coverage input is a fixed + ~0.01 degree (~1 km) box, chosen so order 29 stays tractable. Coverage cost + grows with boundary length measured in cells (~2\*\*order per boundary edge for + the MOC), so a large real-world polygon covered at order 29 would be far more + expensive — cover it at a working order or with a `max_cells=` budget instead + (see [coverage_methods.md](coverage_methods.md)). Flat `morton_coverage` is not + benchmarked cross-order because it scales as ~4\*\*order along the boundary and + exhausts memory well before order 29. From 020c5f49eddb6694f960e3ecab96c7937e6174b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 10:58:27 +0000 Subject: [PATCH 2/7] phase 2 of issue #65 --- README.md | 17 ++++++++++------- docs/coverage_methods.md | 8 ++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4720434..8fe813d 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,16 @@ series — are documented in ## Performance -Mortie uses **Rust-accelerated** morton indexing functions for high performance. The Rust implementation provides dramatic speedups: - -| Dataset Size | Rust | Python (reference) | Speedup | -|--------------|------|--------------------|---------| -| 1,000 values | 1.93 ms | 4.14 ms | **2.1x** | -| 100,000 values | 1.85 ms | 410.59 ms | **222x** | -| 1.2M coordinates | 102.51 ms | 5.1 sec | **50x** | +Mortie's morton core is a Rust extension and the sole runtime path — there is no +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. + +See **[docs/benchmarks.md](docs/benchmarks.md)** for the full cross-order table +(raw encode / decode throughput and coverage timing at orders 4 / 12 / 18 / 29), +regenerated in place by a committed script. Cell counts there are deterministic; +timings are machine/run dependent. Pre-built wheels are available for Linux, macOS, and Windows. The Rust extension is required and is included in all pip-installed wheels. diff --git a/docs/coverage_methods.md b/docs/coverage_methods.md index f85eda7..223f387 100644 --- a/docs/coverage_methods.md +++ b/docs/coverage_methods.md @@ -128,10 +128,10 @@ it writes itself in place between the markers: | verts | order | flat | MOC | MOC tol 0.5° | MOC tol 0.05° | MOC budget 2k | MOC budget 500 | |--:|--:|--|--|--|--|--|--| -| 81,595 | 8 | 883c / 49ms | 196c / 55ms | 79c / 58ms | 196c / 60ms | 196c / 49ms | 196c / 49ms | -| 81,595 | 10 | 12,461c / 45ms | 1,058c / 53ms | 79c / 38ms | 1,058c / 62ms | 867c / 58ms | 200c / 59ms | -| 81,595 | 12 | 191,710c / 73ms | 5,146c / 77ms | 79c / 49ms | 2,039c / 61ms | 867c / 72ms | 200c / 61ms | -| 1,000,000 | 10 | 12,461c / 1128ms | 1,058c / 1258ms | 79c / 946ms | 1,058c / 1127ms | 867c / 1505ms | 200c / 1362ms | +| 81,595 | 8 | 883c / 109ms | 196c / 105ms | 79c / 99ms | 196c / 111ms | 196c / 128ms | 196c / 133ms | +| 81,595 | 10 | 12,461c / 119ms | 1,058c / 120ms | 79c / 105ms | 1,058c / 118ms | 867c / 133ms | 200c / 125ms | +| 81,595 | 12 | 191,710c / 136ms | 5,146c / 135ms | 79c / 101ms | 2,039c / 122ms | 867c / 137ms | 200c / 123ms | +| 1,000,000 | 10 | 12,461c / 1995ms | 1,058c / 1748ms | 79c / 1842ms | 1,058c / 1780ms | 867c / 2304ms | 200c / 2250ms | `c` = cell count, `ms` = milliseconds (machine/run dependent; cell counts are deterministic). From bf2665c6972f3f3c9fcc66921fae70df6030992d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 10:58:56 +0000 Subject: [PATCH 3/7] phase 3 of issue #65 --- docs/benchmarks.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 8d5316b..e29876d 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -46,7 +46,10 @@ Encode/decode throughput measured over 1,000,000 fixed-seed coordinates; coverag order-of-magnitude, not reproducible constants. - **encode/decode are element-wise**, so `M idx/s` is roughly order-independent; the coordinate array is one million points and each order re-encodes the same - points. + points. Each throughput figure is the warm median of five timed runs. +- **The coverage `ms` is a single cold sample** (`rep=1`, no warmup — order 29 is + seconds-scale), so it wobbles more run-to-run than the encode/decode columns; + the cell count beside it is exact and reproducible. - **Coverage is deliberately scoped small.** The coverage input is a fixed ~0.01 degree (~1 km) box, chosen so order 29 stays tractable. Coverage cost grows with boundary length measured in cells (~2\*\*order per boundary edge for @@ -55,3 +58,20 @@ Encode/decode throughput measured over 1,000,000 fixed-seed coordinates; coverag (see [coverage_methods.md](coverage_methods.md)). Flat `morton_coverage` is not benchmarked cross-order because it scales as ~4\*\*order along the boundary and exhausts memory well before order 29. + +## Regenerating benchmarks + +Both benchmark tables regenerate themselves in place, between HTML-comment +markers, on the current build. Rebuild the Rust extension first +(`maturin develop --release`, see [../BUILDING.md](../BUILDING.md)), then run +from the repo root: + +```bash +python bench_cross_order.py # this page: cross-order encode/decode/coverage +python bench_matrix.py # docs/coverage_methods.md: coverage-method matrix +``` + +Each script prints its table and rewrites its target doc between the markers, so +committing the doc after a run keeps the published numbers current. Cell counts +are deterministic; timings are machine dependent — regenerate on the machine you +want to quote. From d9796667240c5e96e54660a5905fc38e585a750d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 11:01:38 +0000 Subject: [PATCH 4/7] address self-review: correct README throughput wording (issue #65) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8fe813d..5299e3f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Mortie's morton core is a Rust extension and the sole runtime path — there is 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. +core, staying within roughly 2× across orders 4–29. See **[docs/benchmarks.md](docs/benchmarks.md)** for the full cross-order table (raw encode / decode throughput and coverage timing at orders 4 / 12 / 18 / 29), From ad10c6f4d0118ab087a1207f9b9218fad04c2cfe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 19:57:27 +0000 Subject: [PATCH 5/7] warm coverage bench timing; document first-call warm-up penalty (issue #65) --- bench_cross_order.py | 13 ++++++++++--- docs/benchmarks.md | 38 +++++++++++++++++++++++++++++++------- docs/coverage_methods.md | 9 +++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/bench_cross_order.py b/bench_cross_order.py index 92fd4ab..1190554 100644 --- a/bench_cross_order.py +++ b/bench_cross_order.py @@ -55,6 +55,11 @@ def timed(fn, rep): def build_table(): lat, lon = coords() + # Warm the extension once before timing anything: the first parallel call + # spins up the rayon threadpool and touches cold caches -- a one-time cost + # that is ~5x the warm time on a small cover (and negligible on a large + # batch). We report steady-state, so a throwaway call absorbs it here. + mortie.morton_coverage_moc(BOX_LAT, BOX_LON, order=6) rows = ["| order | encode (M idx/s) | decode (M idx/s) | coverage (cells / ms) |", "|--:|--:|--:|--:|"] for order in ORDERS: @@ -68,10 +73,12 @@ def build_table(): t_dec, _ = timed(lambda: mortie.mort2geo(morton), 5) dec_mps = N / t_dec / 1e6 - # coverage: mixed-order cover of the small fixed box (rep=1, no warmup — - # order 29 is seconds-scale). + # coverage: warmed above; median of a few reps at cheap orders, a + # single timed call at order 29 (seconds-scale, one sample is plenty). + reps = 1 if order >= 24 else 5 t_cov, cov = timed( - lambda: mortie.morton_coverage_moc(BOX_LAT, BOX_LON, order=order), 1) + lambda: mortie.morton_coverage_moc(BOX_LAT, BOX_LON, order=order), + reps) rows.append(f"| {order} | {enc_mps:,.1f} | {dec_mps:,.1f} | " f"{len(cov):,}c / {t_cov * 1e3:.0f}ms |") diff --git a/docs/benchmarks.md b/docs/benchmarks.md index e29876d..d34e14a 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -30,10 +30,10 @@ root) — it writes itself in place between the markers: | 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 | +| 4 | 47.9 | 13.1 | 1c / 0ms | +| 12 | 38.9 | 10.5 | 4c / 0ms | +| 18 | 29.9 | 9.3 | 186c / 1ms | +| 29 | 26.9 | 7.6 | 419,292c / 4306ms | Encode/decode throughput measured over 1,000,000 fixed-seed coordinates; coverage over a fixed ~0.01 degree box. `M idx/s` = millions of morton indices per second, `c` = cell count, `ms` = milliseconds. Throughput and timings are machine/run dependent; cell counts are deterministic. @@ -47,9 +47,10 @@ Encode/decode throughput measured over 1,000,000 fixed-seed coordinates; coverag - **encode/decode are element-wise**, so `M idx/s` is roughly order-independent; the coordinate array is one million points and each order re-encodes the same points. Each throughput figure is the warm median of five timed runs. -- **The coverage `ms` is a single cold sample** (`rep=1`, no warmup — order 29 is - seconds-scale), so it wobbles more run-to-run than the encode/decode columns; - the cell count beside it is exact and reproducible. +- **The coverage `ms` is a warm median** (five reps at cheap orders; a single + timed call at order 29, which is seconds-scale) taken after a throwaway warm-up + call — see *First-call warm-up* below. The cell count beside it is exact and + reproducible. - **Coverage is deliberately scoped small.** The coverage input is a fixed ~0.01 degree (~1 km) box, chosen so order 29 stays tractable. Coverage cost grows with boundary length measured in cells (~2\*\*order per boundary edge for @@ -59,6 +60,29 @@ Encode/decode throughput measured over 1,000,000 fixed-seed coordinates; coverag benchmarked cross-order because it scales as ~4\*\*order along the boundary and exhausts memory well before order 29. +### First-call warm-up + +The **first** call into the extension pays a one-time cost — the `rayon` +threadpool spins up and caches are cold — that later calls do not. The size of +the penalty is inversely proportional to the per-call work, so it matters most +for small, latency-sensitive calls: + +| operation (measured) | cold (first call) | warm (steady state) | ratio | +|---|--:|--:|--:| +| `morton_coverage_moc`, ~1 km box, order 11 | ~1.1 ms | ~0.2 ms | ~5x | +| `mort2geo`, 1M indices, order 12 | ~196 ms | ~132 ms | ~1.5x | +| `geo2mort`, 1M indices, order 12 | ~47 ms | ~44 ms | ~1.05x | + +So a big batch encode/decode barely notices it, but a small cover (or a single +point, or a tight per-call loop) can run several times slower on its first +invocation. Two practical consequences: + +- **When benchmarking**, make one throwaway call before timing (this page's + generator warms the extension once up front, then reports the steady state). +- **In production**, if first-call latency matters (a request path, an + interactive tool), warm the functions you use once at startup with a small + throwaway call; steady-state throughput is what the table above reports. + ## Regenerating benchmarks Both benchmark tables regenerate themselves in place, between HTML-comment diff --git a/docs/coverage_methods.md b/docs/coverage_methods.md index 223f387..b1b1613 100644 --- a/docs/coverage_methods.md +++ b/docs/coverage_methods.md @@ -10,6 +10,15 @@ one-time `O(V)` setup and per-boundary-cell work grows with local edge density but far more gently than the old `O(cells × vertices)` flood-fill (a 1M-vertex polygon covers ~40× faster); see the benchmark matrix below. +> **First-call warm-up.** The first coverage call in a process spins up the +> `rayon` threadpool and runs on cold caches — a one-time cost that is a large +> fraction of the runtime for a *small* cover (up to ~5× the warm time), though +> negligible for a large one. If first-call latency matters (a request path or +> interactive tool), warm it once at startup with a throwaway call — +> e.g. `morton_coverage_moc(box_lats, box_lons, order=6)` — before the calls you +> care about. Steady-state timings are what [benchmarks.md](benchmarks.md) +> reports. + Two output shapes and two adaptive stop criteria are available. ## Output shapes From 40377263e962447048b54bfdba24cbda4c222e40 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:08:12 +0000 Subject: [PATCH 6/7] add measured cold-vs-warm MOC table to coverage_methods (issue #65) --- bench_matrix.py | 64 +++++++++++++++++++++++++++++++++++----- docs/coverage_methods.md | 40 +++++++++++++++++++------ 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/bench_matrix.py b/bench_matrix.py index 82a992c..df03200 100644 --- a/bench_matrix.py +++ b/bench_matrix.py @@ -9,6 +9,9 @@ flood-fill baseline is not reproduced here (that code was removed) — it lives as static prose in the doc. """ +import json +import subprocess +import sys import time from pathlib import Path import numpy as np @@ -17,6 +20,7 @@ DATA = Path("mortie/tests/Ant_Grounded_DrainageSystem_Polygons.txt") DOC = Path("docs/coverage_methods.md") START, END = "", "" +WSTART, WEND = "", "" def basin(n_verts=None): @@ -70,18 +74,62 @@ def build_table(): return "\n".join(rows) +# A genuine "cold" (first-call) measurement can only be the *first* parallel call +# in a process — the rayon threadpool and caches warm globally after that. So each +# warm-up row is measured in its own fresh subprocess. Contrasts a tiny cover +# (fixed startup dominates) with the full basin (startup amortized away). +WARMUP_CASES = [("~1 km box", "box", 11), ("81.6k-vert basin", "basin", 10)] + +_PROBE = """ +import time, json, sys, numpy as np, mortie +kind, order = sys.argv[1], int(sys.argv[2]) +if kind == "box": + la = np.array([10.0, 10.0, 10.01, 10.01]); lo = np.array([20.0, 20.01, 20.01, 20.0]) +else: + d = np.loadtxt("mortie/tests/Ant_Grounded_DrainageSystem_Polygons.txt") + b = d[d[:, 2] == 1]; la, lo = b[:, 0].copy(), b[:, 1].copy() +t = time.perf_counter(); mortie.morton_coverage_moc(la, lo, order=order) +cold = (time.perf_counter() - t) * 1e3 +ts = [] +for _ in range(7): + t = time.perf_counter(); mortie.morton_coverage_moc(la, lo, order=order) + ts.append((time.perf_counter() - t) * 1e3) +print(json.dumps({"cold": cold, "warm": float(np.median(ts))})) +""" + + +def warmup_table(): + rows = ["| MOC cover | cold (first call) | warm (steady state) | ratio |", + "|--|--:|--:|--:|"] + for label, kind, order in WARMUP_CASES: + out = subprocess.run([sys.executable, "-c", _PROBE, kind, str(order)], + capture_output=True, text=True, check=True) + d = json.loads(out.stdout) + rows.append(f"| {label}, order {order} | {d['cold']:.1f} ms | " + f"{d['warm']:.1f} ms | {d['cold'] / d['warm']:.1f}x |") + return "\n".join(rows) + + +def replace_block(doc, start, end, body): + if start not in doc or end not in doc: + raise SystemExit(f"markers {start} / {end} not found in {DOC}") + return f"{doc[: doc.index(start) + len(start)]}\n\n{body}\n\n{doc[doc.index(end):]}" + + def main(): table = build_table() print(table) + warmup = warmup_table() + print("\n" + warmup) + note = ("`c` = cell count, `ms` = milliseconds. Matrix timings are the warm " + "median (each method is called once to warm up, then timed); see the " + "first-call warm-up table for the one-time cold cost. Timings are " + "machine/run dependent; cell counts are deterministic.") doc = DOC.read_text() - if START not in doc or END not in doc: - raise SystemExit(f"markers {START} / {END} not found in {DOC}") - pre = doc[: doc.index(START) + len(START)] - post = doc[doc.index(END):] - note = ("`c` = cell count, `ms` = milliseconds (machine/run dependent; cell " - "counts are deterministic).") - DOC.write_text(f"{pre}\n\n{table}\n\n{note}\n\n{post}") - print(f"\nwrote table into {DOC}") + doc = replace_block(doc, START, END, f"{table}\n\n{note}") + doc = replace_block(doc, WSTART, WEND, warmup) + DOC.write_text(doc) + print(f"\nwrote tables into {DOC}") if __name__ == "__main__": diff --git a/docs/coverage_methods.md b/docs/coverage_methods.md index b1b1613..322aa8b 100644 --- a/docs/coverage_methods.md +++ b/docs/coverage_methods.md @@ -12,12 +12,13 @@ polygon covers ~40× faster); see the benchmark matrix below. > **First-call warm-up.** The first coverage call in a process spins up the > `rayon` threadpool and runs on cold caches — a one-time cost that is a large -> fraction of the runtime for a *small* cover (up to ~5× the warm time), though -> negligible for a large one. If first-call latency matters (a request path or +> fraction of the runtime for a *small* cover (several times the warm time), +> though negligible for a large one. If first-call latency matters (a request path or > interactive tool), warm it once at startup with a throwaway call — > e.g. `morton_coverage_moc(box_lats, box_lons, order=6)` — before the calls you -> care about. Steady-state timings are what [benchmarks.md](benchmarks.md) -> reports. +> care about. The *First-call warm-up cost* table under the benchmark matrix +> below measures it on a real MOC cover; steady-state timings are what the matrix +> and [benchmarks.md](benchmarks.md) report. Two output shapes and two adaptive stop criteria are available. @@ -137,15 +138,36 @@ it writes itself in place between the markers: | verts | order | flat | MOC | MOC tol 0.5° | MOC tol 0.05° | MOC budget 2k | MOC budget 500 | |--:|--:|--|--|--|--|--|--| -| 81,595 | 8 | 883c / 109ms | 196c / 105ms | 79c / 99ms | 196c / 111ms | 196c / 128ms | 196c / 133ms | -| 81,595 | 10 | 12,461c / 119ms | 1,058c / 120ms | 79c / 105ms | 1,058c / 118ms | 867c / 133ms | 200c / 125ms | -| 81,595 | 12 | 191,710c / 136ms | 5,146c / 135ms | 79c / 101ms | 2,039c / 122ms | 867c / 137ms | 200c / 123ms | -| 1,000,000 | 10 | 12,461c / 1995ms | 1,058c / 1748ms | 79c / 1842ms | 1,058c / 1780ms | 867c / 2304ms | 200c / 2250ms | +| 81,595 | 8 | 883c / 148ms | 196c / 148ms | 79c / 132ms | 196c / 136ms | 196c / 182ms | 196c / 178ms | +| 81,595 | 10 | 12,461c / 168ms | 1,058c / 162ms | 79c / 139ms | 1,058c / 156ms | 867c / 200ms | 200c / 179ms | +| 81,595 | 12 | 191,710c / 195ms | 5,146c / 199ms | 79c / 137ms | 2,039c / 172ms | 867c / 188ms | 200c / 175ms | +| 1,000,000 | 10 | 12,461c / 2893ms | 1,058c / 2820ms | 79c / 2302ms | 1,058c / 2759ms | 867c / 3208ms | 200c / 2997ms | -`c` = cell count, `ms` = milliseconds (machine/run dependent; cell counts are deterministic). +`c` = cell count, `ms` = milliseconds. Matrix timings are the warm median (each method is called once to warm up, then timed); see the first-call warm-up table for the one-time cold cost. Timings are machine/run dependent; cell counts are deterministic. +### First-call warm-up cost + +The matrix timings above are the **warm** (steady-state) median. The **first** +`morton_coverage_moc` call in a process additionally pays a one-time cost (the +`rayon` threadpool spins up, caches are cold). Because that cost is fixed, its +weight is inversely proportional to the cover's size — it dominates a tiny cover +and vanishes on a large one. Measured as a genuine first call (each row in its +own fresh process): + + + +| MOC cover | cold (first call) | warm (steady state) | 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 | + + + +So a realistic basin cover barely notices it, but a small cover runs several +times slower on its first call — warm once at startup (above) if that matters. + ### Reading the matrix - **MOC vs. flat:** identical coverage, far fewer cells — at order 12 the flat From 8c270dd6e15f1f9b1c09b6fa5221f92d9feacd13 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:24:05 +0000 Subject: [PATCH 7/7] sort import blocks in bench scripts (issue #65) --- bench_cross_order.py | 2 ++ bench_matrix.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/bench_cross_order.py b/bench_cross_order.py index 1190554..564568f 100644 --- a/bench_cross_order.py +++ b/bench_cross_order.py @@ -20,7 +20,9 @@ """ import time from pathlib import Path + import numpy as np + import mortie DOC = Path("docs/benchmarks.md") diff --git a/bench_matrix.py b/bench_matrix.py index df03200..ea84604 100644 --- a/bench_matrix.py +++ b/bench_matrix.py @@ -14,7 +14,9 @@ import sys import time from pathlib import Path + import numpy as np + import mortie DATA = Path("mortie/tests/Ant_Grounded_DrainageSystem_Polygons.txt")