From 717b3ee1dc2d00911855ae295b5ade668fe0929b Mon Sep 17 00:00:00 2001 From: Petr Tsymbarovich Date: Mon, 24 Aug 2026 12:30:17 +0300 Subject: [PATCH] feat(benches): add cross-target performance harness --- .gitattributes | 1 + benches/.gitignore | 2 + benches/fixtures/soilgrids.tar.zst | 3 + benches/prepare_dataset.py | 628 ++++++++++++++++++ benches/ptfkit-native/.gitignore | 1 + benches/ptfkit-native/CMakeLists.txt | 26 + benches/ptfkit-native/src/bench.c | 134 ++++ benches/ptfkit-native/src/bench.cpp | 115 ++++ benches/ptfkit-native/src/npy.h | 55 ++ benches/ptfkit-py/.gitignore | 1 + benches/ptfkit-py/pyproject.toml | 17 + .../src/ptfkit_py_benchmarks/__init__.py | 86 +++ benches/ptfkit-py/uv.lock | 92 +++ benches/ptfkit-rs/.gitignore | 1 + benches/ptfkit-rs/Cargo.lock | 177 +++++ benches/ptfkit-rs/Cargo.toml | 12 + benches/ptfkit-rs/src/main.rs | 161 +++++ benches/ruff.toml | 8 + benches/run.py | 137 ++++ benches/tasks.toml | 26 + mise.toml | 1 + 21 files changed, 1684 insertions(+) create mode 100644 benches/.gitignore create mode 100644 benches/fixtures/soilgrids.tar.zst create mode 100755 benches/prepare_dataset.py create mode 100644 benches/ptfkit-native/.gitignore create mode 100644 benches/ptfkit-native/CMakeLists.txt create mode 100644 benches/ptfkit-native/src/bench.c create mode 100644 benches/ptfkit-native/src/bench.cpp create mode 100644 benches/ptfkit-native/src/npy.h create mode 100644 benches/ptfkit-py/.gitignore create mode 100644 benches/ptfkit-py/pyproject.toml create mode 100644 benches/ptfkit-py/src/ptfkit_py_benchmarks/__init__.py create mode 100644 benches/ptfkit-py/uv.lock create mode 100644 benches/ptfkit-rs/.gitignore create mode 100644 benches/ptfkit-rs/Cargo.lock create mode 100644 benches/ptfkit-rs/Cargo.toml create mode 100644 benches/ptfkit-rs/src/main.rs create mode 100644 benches/ruff.toml create mode 100755 benches/run.py create mode 100644 benches/tasks.toml diff --git a/.gitattributes b/.gitattributes index 6313b56..5152e36 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text=auto eol=lf +benches/fixtures/soilgrids.tar.zst filter=lfs diff=lfs merge=lfs -text diff --git a/benches/.gitignore b/benches/.gitignore new file mode 100644 index 0000000..bbff557 --- /dev/null +++ b/benches/.gitignore @@ -0,0 +1,2 @@ +.cache/ +fixtures/soilgrids/ diff --git a/benches/fixtures/soilgrids.tar.zst b/benches/fixtures/soilgrids.tar.zst new file mode 100644 index 0000000..9217250 --- /dev/null +++ b/benches/fixtures/soilgrids.tar.zst @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf6ac39126f66b9d7eaef0601600d346e9cedbb5834d71e66df6adc8d9a22592 +size 135739762 diff --git a/benches/prepare_dataset.py b/benches/prepare_dataset.py new file mode 100755 index 0000000..0c2b22d --- /dev/null +++ b/benches/prepare_dataset.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.14" +# dependencies = [ +# "httpx2>=2.12,<3", +# "numpy>=2.0,<3", +# "rasterio>=1.4,<2", +# ] +# /// + +"""Prepare a SoilGrids-based performance benchmark dataset for ptfkit. + +The output is a tar.zst archive containing aligned, little-endian float64 NPY 1.0 +arrays. Network access, raster decoding, sampling, unit conversion, and compression +happen only here; benchmark harnesses only need to unpack and read NPY files. + +This dataset is intended exclusively as a realistic performance workload. It is not +a validation dataset for the scientific applicability or accuracy of any PTF. + +Run directly with uv: + + uv run prepare_dataset.py + +Example: + uv run prepare_dataset.py \ + --output benchmarks/data/soilgrids.tar.zst \ + --samples 16777216 + +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import tarfile +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import httpx2 +import numpy as np +import rasterio + + +WCS_URL: Final = 'https://maps.isric.org/mapserv' +SOILGRIDS_CRS: Final = 'http://www.opengis.net/def/crs/EPSG/0/152160' + +DEPTHS: Final = ( + '0-5cm', + '5-15cm', + '15-30cm', + '30-60cm', + '60-100cm', + '100-200cm', +) + +# Fixed benchmark crop in the native SoilGrids Interrupted Goode Homolosine CRS. +# This is the Ghana example extent used by the SoilGrids documentation. The region +# itself has no scientific significance for this benchmark. +DEFAULT_BOUNDS: Final = (-337500.0, 527500.0, 152500.0, 1242500.0) + +DEFAULT_SAMPLES: Final = 1 << 24 +DEFAULT_SEED: Final = 0x5054464B # "PTFK" + + +@dataclass(frozen=True) +class Column: + source: str + unit: str + scale: float + positive: bool = False + description: str = '' + + def convert(self, values: np.ndarray) -> np.ndarray: + return np.asarray(values, dtype=np.float64) * self.scale + + +# Scale factors convert the integer SoilGrids map values directly into units that +# are convenient for ptfkit benchmark inputs. +COLUMNS: Final[dict[str, Column]] = { + 'sand': Column( + source='sand', + unit='%', + scale=1.0 / 10.0, + positive=True, + description='Sand content by mass.', + ), + 'silt': Column( + source='silt', + unit='%', + scale=1.0 / 10.0, + positive=True, + description='Silt content by mass.', + ), + 'clay': Column( + source='clay', + unit='%', + scale=1.0 / 10.0, + positive=True, + description='Clay content by mass.', + ), + 'bulk_density': Column( + source='bdod', + unit='g/cm^3', + scale=1.0 / 100.0, + positive=True, + description='Bulk density of the fine earth fraction.', + ), + # SoilGrids SOC is stored in dg/kg. Dividing by 100 converts directly to + # g/100g (%). + 'organic_carbon': Column( + source='soc', + unit='%', + scale=1.0 / 100.0, + description='Soil organic carbon by mass.', + ), + # SoilGrids water-content rasters are stored in 10^-3 cm^3/cm^3. + 'theta_33': Column( + source='wv0033', + unit='cm^3/cm^3', + scale=1.0 / 1000.0, + description='Volumetric water content at 33 kPa.', + ), + 'theta_1500': Column( + source='wv1500', + unit='cm^3/cm^3', + scale=1.0 / 1000.0, + description='Volumetric water content at 1500 kPa.', + ), +} + +DEFAULT_COLUMNS: Final = tuple(COLUMNS) + + +def parse_args() -> argparse.Namespace: + def positive_int(value: str) -> int: + parsed = int(value) + if parsed > 0: + return parsed + msg = f'{value!r} is not a positive integer' + raise argparse.ArgumentTypeError(msg) + + parser = argparse.ArgumentParser( + description='Prepare a compressed SoilGrids benchmark corpus for ptfkit.', + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + '--output', + type=Path, + default=Path('fixtures/soilgrids.tar.zst'), + help='Output tar.zst archive.', + ) + parser.add_argument( + '--cache-dir', + type=Path, + default=Path('.cache/ptfkit-soilgrids'), + help='Cache downloaded WCS GeoTIFF subsets.', + ) + parser.add_argument( + '--samples', + type=positive_int, + default=DEFAULT_SAMPLES, + help='Number of aligned samples to write.', + ) + parser.add_argument( + '--seed', + type=positive_int, + default=DEFAULT_SEED, + help='Deterministic sampling seed.', + ) + parser.add_argument( + '--depths', + nargs='+', + choices=DEPTHS, + default=list(DEPTHS), + help='SoilGrids depth intervals to sample.', + ) + parser.add_argument( + '--columns', + nargs='+', + choices=tuple(COLUMNS), + default=list(DEFAULT_COLUMNS), + help='Columns to include in the archive.', + ) + parser.add_argument( + '--bounds', + nargs=4, + type=float, + metavar=('XMIN', 'YMIN', 'XMAX', 'YMAX'), + default=DEFAULT_BOUNDS, + help='Subset bounds in SoilGrids EPSG:152160.', + ) + parser.add_argument( + '--zstd-level', + type=int, + default=19, + choices=range(1, 20), + help='Zstandard compression level.', + ) + parser.add_argument( + '--force-download', + action='store_true', + help='Ignore cached WCS GeoTIFFs and download them again.', + ) + return parser.parse_args() + + +def make_client() -> httpx2.Client: + return httpx2.Client( + headers={'User-Agent': 'ptfkit-benchmark-dataset-preparer/1'}, + transport=httpx2.HTTPTransport(retries=5), + ) + + +def cache_key(bounds: tuple[float, float, float, float]) -> str: + payload = json.dumps( + { + 'bounds': bounds, + 'crs': SOILGRIDS_CRS, + 'prediction': 'mean', + }, + sort_keys=True, + separators=(',', ':'), + ).encode() + return hashlib.sha256(payload).hexdigest()[:16] + + +def coverage_id(source: str, depth: str) -> str: + return f'{source}_{depth}_mean' + + +def download_coverage( + client: httpx2.Client, + source: str, + depth: str, + bounds: tuple[float, float, float, float], + destination: Path, + *, + force: bool, +) -> None: + if destination.exists() and not force: + return + + destination.parent.mkdir(parents=True, exist_ok=True) + partial = destination.with_suffix(destination.suffix + '.part') + partial.unlink(missing_ok=True) + + xmin, ymin, xmax, ymax = bounds + params = ( + ('map', f'/map/{source}.map'), + ('SERVICE', 'WCS'), + ('VERSION', '2.0.1'), + ('REQUEST', 'GetCoverage'), + ('COVERAGEID', coverage_id(source, depth)), + ('FORMAT', 'GEOTIFF_INT16'), + ('SUBSET', f'X({xmin:g},{xmax:g})'), + ('SUBSET', f'Y({ymin:g},{ymax:g})'), + ('SUBSETTINGCRS', SOILGRIDS_CRS), + ('OUTPUTCRS', SOILGRIDS_CRS), + ) + + print(f'Downloading {coverage_id(source, depth)}') + with client.stream( + 'GET', WCS_URL, params=params, timeout=httpx2.Timeout(30, connect=600) + ) as response: + response.raise_for_status() + + content_type = response.headers.get('content-type', '').lower() + if 'xml' in content_type or content_type.startswith('text/'): + message = next(response.iter_text(4000)).strip() + msg = f'SoilGrids WCS returned {content_type!r} instead of GeoTIFF:\n{message}' + raise SystemExit(msg) + + with partial.open('wb') as file: + for chunk in response.iter_bytes(chunk_size=1024 * 1024): + if chunk: + file.write(chunk) + + # Open the result before moving it into the cache so a WCS exception document + # or truncated download cannot poison subsequent runs. + if err := is_partial_invalid(partial): + partial.unlink(missing_ok=True) + raise SystemExit(err) + + partial.replace(destination) + + +def is_partial_invalid(path: Path) -> str | None: + with rasterio.open(path) as dataset: + if dataset.count != 1: + return f'Expected one raster band in {path}, got {dataset.count}' + if dataset.width <= 0 or dataset.height <= 0: + return f'Invalid raster dimensions in {path}' + return None + + +def layer_paths( + cache_dir: Path, + sources: tuple[str, ...], + depth: str, + bounds: tuple[float, float, float, float], +) -> dict[str, Path]: + root = cache_dir / cache_key(bounds) + return {source: root / f'{coverage_id(source, depth)}.tif' for source in sources} + + +def validate_grid(paths: dict[str, Path]) -> tuple[int, int]: + expected_shape: tuple[int, int] | None = None + expected_transform = None + expected_crs = None + + for source, path in paths.items(): + with rasterio.open(path) as dataset: + shape = (dataset.height, dataset.width) + if expected_shape is None: + expected_shape = shape + expected_transform = dataset.transform + expected_crs = dataset.crs + continue + + if shape != expected_shape: + msg = f'Raster grid mismatch for {source}: {shape} != {expected_shape}' + raise SystemExit(msg) + if dataset.transform != expected_transform: + msg = f'Raster transform mismatch for {source}' + raise SystemExit(msg) + if dataset.crs != expected_crs: + msg = f'Raster CRS mismatch for {source}' + raise SystemExit(msg) + + if expected_shape is None: + raise RuntimeError + + return expected_shape + + +def valid_mask( + paths: dict[str, Path], + source_rules: dict[str, bool], +) -> np.ndarray: + mask: np.ndarray | None = None + + for source, path in paths.items(): + with rasterio.open(path) as dataset: + values = dataset.read(1, masked=True) + current = ~np.ma.getmaskarray(values) + if source_rules[source]: + current &= np.asarray(values.data) > 0 + + if mask is None: + mask = np.array(current, dtype=np.bool_, copy=True) + else: + mask &= current + + if mask is None: + raise RuntimeError + + return mask + + +def select_indices( + mask: np.ndarray, + count: int, + rng: np.random.Generator, +) -> np.ndarray: + valid = np.flatnonzero(mask.reshape(-1)) + if valid.size <= count: + return valid + + positions = rng.choice(valid.size, size=count, replace=False, shuffle=False) + return valid[positions] + + +def open_output_arrays( + directory: Path, + columns: tuple[str, ...], + samples: int, +) -> dict[str, np.memmap]: + arrays: dict[str, np.memmap] = {} + for name in columns: + arrays[name] = np.lib.format.open_memmap( + directory / f'{name}.npy', + mode='w+', + dtype=np.dtype(' int: + stop = start + selected.size + + by_source: dict[str, list[str]] = {} + for name in columns: + by_source.setdefault(COLUMNS[name].source, []).append(name) + + for source, names in by_source.items(): + with rasterio.open(paths[source]) as dataset: + raw = dataset.read(1).reshape(-1)[selected] + + for name in names: + outputs[name][start:stop] = COLUMNS[name].convert(raw) + + return stop + + +def prepare_arrays( + *, + directory: Path, + cache_dir: Path, + columns: tuple[str, ...], + depths: tuple[str, ...], + bounds: tuple[float, float, float, float], + samples: int, + seed: int, + force_download: bool, +) -> dict: + sources = tuple(dict.fromkeys(COLUMNS[name].source for name in columns)) + source_rules = { + source: any(COLUMNS[name].positive for name in columns if COLUMNS[name].source == source) + for source in sources + } + + outputs = open_output_arrays(directory, columns, samples) + rng = np.random.default_rng(seed) + written = 0 + depth_counts: dict[str, int] = {} + + try: + with make_client() as session: + for depth_index, depth in enumerate(depths): + remaining = samples - written + if remaining == 0: + break + + depths_left = len(depths) - depth_index + target = math.ceil(remaining / depths_left) + paths = layer_paths(cache_dir, sources, depth, bounds) + + for source, path in paths.items(): + download_coverage( + session, + source, + depth, + bounds, + path, + force=force_download, + ) + + shape = validate_grid(paths) + mask = valid_mask(paths, source_rules) + selected = select_indices(mask, target, rng) + + if selected.size == 0: + print(f'{depth}: no common valid pixels, skipping') + depth_counts[depth] = 0 + continue + + written = fill_slice( + outputs, + columns, + paths, + selected, + written, + ) + depth_counts[depth] = int(selected.size) + print( + f'{depth}: selected {selected.size:,} / ' + f'{mask.size:,} pixels from {shape[1]}x{shape[0]} grid', + ) + + if written != samples: + msg = ( + f'Only {written:,} common valid samples were available, ' + f'but {samples:,} were requested. Increase --bounds, add depths, ' + 'or reduce --samples.' + ) + raise SystemExit(msg) + + for output in outputs.values(): + output.flush() + finally: + outputs.clear() + + return { + 'format_version': 1, + 'purpose': 'ptfkit performance benchmarks', + 'source': { + 'dataset': 'SoilGrids', + 'service': 'WCS 2.0.1', + 'endpoint': WCS_URL, + 'prediction': 'mean', + }, + 'sampling': { + 'samples': samples, + 'seed': seed, + 'depths': list(depths), + 'samples_per_depth': depth_counts, + 'bounds_epsg_152160': list(bounds), + }, + 'storage': { + 'array_format': 'NPY 1.0', + 'dtype': ' None: + with directory.joinpath('manifest.json').open('w', encoding='utf-8') as file: + json.dump(manifest, file, indent=2) + + +def add_deterministic_file( + archive: tarfile.TarFile, + path: Path, + arcname: str, +) -> None: + stat = path.stat() + info = tarfile.TarInfo(arcname) + info.size = stat.st_size + info.mode = 0o644 + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = '' + info.gname = '' + + with path.open('rb') as file: + archive.addfile(info, file) + + +def compress_dataset( + directory: Path, + output: Path, + *, + level: int, +) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + partial = output.with_suffix(output.suffix + '.part') + partial.unlink(missing_ok=True) + + print(f'Compressing {output}') + with tarfile.open(partial, mode='w:zst', level=level) as archive: # ty: ignore[invalid-argument-type] + for path in sorted(directory.iterdir(), key=lambda item: item.name): + if path.is_file(): + add_deterministic_file(archive, path, path.name) + + partial.replace(output) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as file: + while chunk := file.read(8 * 1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def format_bytes(size: int) -> str: + value = float(size) + for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'): + if value < 1024.0 or unit == 'TiB': # noqa: PLR2004 + return f'{value:.1f} {unit}' + value /= 1024.0 + raise AssertionError + + +def main() -> None: + args = parse_args() + + output = args.output.resolve() + cache_dir = args.cache_dir.resolve() + columns = tuple(dict.fromkeys(args.columns)) + depths = tuple(dict.fromkeys(args.depths)) + bounds = tuple(args.bounds) + + xmin, ymin, xmax, ymax = bounds + if xmin >= xmax or ymin >= ymax: + msg = 'bounds must satisfy XMIN < XMAX and YMIN < YMAX' + raise SystemExit(msg) + + with tempfile.TemporaryDirectory(prefix='ptfkit-soilgrids-') as temporary: + dataset_dir = Path(temporary) / 'soilgrids' + dataset_dir.mkdir() + + manifest = prepare_arrays( + directory=dataset_dir, + cache_dir=cache_dir, + columns=columns, + depths=depths, + bounds=bounds, + samples=args.samples, + seed=args.seed, + force_download=args.force_download, + ) + write_manifest(dataset_dir, manifest) + compress_dataset(dataset_dir, output, level=args.zstd_level) + + print(f'Wrote {output}') + print(f'Size: {format_bytes(output.stat().st_size)}') + print(f'SHA-256: {sha256(output)}') + + +if __name__ == '__main__': + main() diff --git a/benches/ptfkit-native/.gitignore b/benches/ptfkit-native/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/benches/ptfkit-native/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/benches/ptfkit-native/CMakeLists.txt b/benches/ptfkit-native/CMakeLists.txt new file mode 100644 index 0000000..e3eaede --- /dev/null +++ b/benches/ptfkit-native/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.28) + +set( + CMAKE_EXPERIMENTAL_CXX_IMPORT_STD + "f35a9ac6-8463-4d38-8eec-5d6008153e7d" +) + +project(ptfkit-native-benchmarks VERSION "0.1.0" LANGUAGES C CXX) + +set(BUILD_TESTING OFF) + +include(FetchContent) + +FetchContent_Declare( + ptfkit + SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../targets/ptfkit-native" +) +FetchContent_MakeAvailable(ptfkit) + +add_executable(ptfkit-c-benchmarks src/bench.c) +target_link_libraries(ptfkit-c-benchmarks PRIVATE ptfkit::c) + +add_executable(ptfkit-cpp-benchmarks src/bench.cpp) +target_link_libraries(ptfkit-cpp-benchmarks PRIVATE ptfkit::cpp) +target_compile_features(ptfkit-cpp-benchmarks PRIVATE cxx_std_26) +set_target_properties(ptfkit-cpp-benchmarks PROPERTIES CXX_MODULE_STD ON) diff --git a/benches/ptfkit-native/src/bench.c b/benches/ptfkit-native/src/bench.c new file mode 100644 index 0000000..da88b25 --- /dev/null +++ b/benches/ptfkit-native/src/bench.c @@ -0,0 +1,134 @@ +#include +#include +#include + +#include "npy.h" + +#include +#include +#include +#include + +static long long elapsed_ns(struct timespec started, struct timespec finished) { + return (long long)(finished.tv_sec - started.tv_sec) * 1000000000LL + + (finished.tv_nsec - started.tv_nsec); +} + +static void print_record(const char *name, size_t samples, long long nanoseconds) { + printf("{\"target\":\"c\",\"case\":\"%s\",\"samples\":%zu,\"elapsed_ns\":%lld}\n", name, + samples, nanoseconds); +} + +static void observe(const double *values, size_t samples) { + double total = 0.0; + for (size_t index = 0; index < samples; ++index) { + total += values[index]; + } + volatile double observed = total; + (void)observed; +} + +int main(int argc, char **argv) { + if (argc < 3 || argc > 4) { + fprintf(stderr, "usage: ptfkit-c-benchmarks DATASET WARMUPS [LIMIT]\n"); + return 2; + } + const char *dataset = argv[1]; + const size_t warmups = strtoull(argv[2], NULL, 10); + const size_t limit = argc == 4 ? strtoull(argv[3], NULL, 10) : 0; + char path[4096]; + size_t samples; + snprintf(path, sizeof(path), "%s/sand.npy", dataset); + double *sand = load_npy_f64(path, &samples); + snprintf(path, sizeof(path), "%s/silt.npy", dataset); + double *silt = load_npy_f64(path, &samples); + snprintf(path, sizeof(path), "%s/clay.npy", dataset); + double *clay = load_npy_f64(path, &samples); + snprintf(path, sizeof(path), "%s/bulk_density.npy", dataset); + double *bulk_density = load_npy_f64(path, &samples); + snprintf(path, sizeof(path), "%s/organic_carbon.npy", dataset); + double *organic_carbon = load_npy_f64(path, &samples); + if (sand == NULL || silt == NULL || clay == NULL || bulk_density == NULL || + organic_carbon == NULL) { + fprintf(stderr, "failed to load benchmark inputs\n"); + return 1; + } + if (limit != 0) { + samples = limit; + } + + double *infiltration = malloc(samples * sizeof(*infiltration)); + double *mayr_a_hc = malloc(samples * sizeof(*mayr_a_hc)); + double *mayr_b_hc = malloc(samples * sizeof(*mayr_b_hc)); + double *mayr_theta_s = malloc(samples * sizeof(*mayr_theta_s)); + double *li_theta_s = malloc(samples * sizeof(*li_theta_s)); + double *li_a_vg = malloc(samples * sizeof(*li_a_vg)); + double *li_n_vg = malloc(samples * sizeof(*li_n_vg)); + double *li_k_sat = malloc(samples * sizeof(*li_k_sat)); + struct timespec started; + struct timespec finished; + + for (size_t iteration = 0; iteration <= warmups; ++iteration) { + clock_gettime(CLOCK_MONOTONIC, &started); + for (size_t index = 0; index < samples; ++index) { + infiltration[index] = + calc_ptf_dharumarajan2019_infiltration(sand[index], silt[index], clay[index]); + } + clock_gettime(CLOCK_MONOTONIC, &finished); + observe(infiltration, samples); + if (iteration == warmups) { + print_record("dharumarajan2019_infiltration", samples, elapsed_ns(started, finished)); + } + } + for (size_t iteration = 0; iteration <= warmups; ++iteration) { + clock_gettime(CLOCK_MONOTONIC, &started); + for (size_t index = 0; index < samples; ++index) { + const mayr1999_ptf_result result = calc_ptf_mayr1999( + sand[index], silt[index], clay[index], bulk_density[index], organic_carbon[index]); + mayr_a_hc[index] = result.a_hc; + mayr_b_hc[index] = result.b_hc; + mayr_theta_s[index] = result.theta_s; + } + clock_gettime(CLOCK_MONOTONIC, &finished); + observe(mayr_a_hc, samples); + observe(mayr_b_hc, samples); + observe(mayr_theta_s, samples); + if (iteration == warmups) { + print_record("mayr1999", samples, elapsed_ns(started, finished)); + } + } + for (size_t iteration = 0; iteration <= warmups; ++iteration) { + clock_gettime(CLOCK_MONOTONIC, &started); + for (size_t index = 0; index < samples; ++index) { + const li2007_ptf_result result = calc_ptf_li2007( + sand[index], silt[index], clay[index], bulk_density[index], organic_carbon[index]); + li_theta_s[index] = result.theta_s; + li_a_vg[index] = result.a_vg; + li_n_vg[index] = result.n_vg; + li_k_sat[index] = result.k_sat; + } + clock_gettime(CLOCK_MONOTONIC, &finished); + observe(li_theta_s, samples); + observe(li_a_vg, samples); + observe(li_n_vg, samples); + observe(li_k_sat, samples); + if (iteration == warmups) { + print_record("li2007", samples, elapsed_ns(started, finished)); + } + } + + free(sand); + free(silt); + free(clay); + free(bulk_density); + free(organic_carbon); + free(infiltration); + free(mayr_a_hc); + free(mayr_b_hc); + free(mayr_theta_s); + free(li_theta_s); + free(li_a_vg); + free(li_n_vg); + free(li_k_sat); + return 0; +} diff --git a/benches/ptfkit-native/src/bench.cpp b/benches/ptfkit-native/src/bench.cpp new file mode 100644 index 0000000..59a38c7 --- /dev/null +++ b/benches/ptfkit-native/src/bench.cpp @@ -0,0 +1,115 @@ +#include "npy.h" + +import std; + +import ptfkit.li2007; +import ptfkit.mayr1999; +import ptfkit.dharumarajan2019; + +namespace { + +namespace fs = std::filesystem; + +struct FreeDeleter { + void operator()(double *values) const { std::free(values); } +}; + +using NpyBuffer = std::unique_ptr; + +void print_record(std::string_view name, std::size_t samples, auto elapsed) { + auto ns = std::chrono::duration_cast(elapsed).count(); + std::println("{{\"target\":\"cpp\",\"case\":\"{}\",\"samples\":{},\"elapsed_ns\":{}}}", name, + samples, ns); +} + +void observe(const std::vector &values) { + double total = 0.0; + for (double value : values) { + total += value; + } + volatile double observed = total; + (void)observed; +} + +} // namespace + +int main(int argc, char *argv[]) { + if (argc < 3 || argc > 4) { + std::println(stderr, "usage: ptfkit-cpp-benchmarks DATASET WARMUPS [LIMIT]"); + return 2; + } + + const fs::path dataset = argv[1]; + const auto warmups = static_cast(std::stoull(argv[2])); + + std::size_t samples; + const auto load_input = [&dataset, &samples](const char *name) { + return NpyBuffer{load_npy_f64((dataset / name).c_str(), &samples)}; + }; + auto sand = load_input("sand.npy"); + auto silt = load_input("silt.npy"); + auto clay = load_input("clay.npy"); + auto bulk_density = load_input("bulk_density.npy"); + auto organic_carbon = load_input("organic_carbon.npy"); + const auto limit = argc == 4 ? static_cast(std::stoull(argv[3])) : samples; + + std::vector infiltration(limit); + std::vector mayr_a_hc(limit); + std::vector mayr_b_hc(limit); + std::vector mayr_theta_s(limit); + std::vector li_theta_s(limit); + std::vector li_a_vg(limit); + std::vector li_n_vg(limit); + std::vector li_k_sat(limit); + + for (std::size_t iteration = 0; iteration <= warmups; ++iteration) { + const auto started = std::chrono::steady_clock::now(); + for (std::size_t index = 0; index < limit; ++index) { + infiltration[index] = ptfkit::dharumarajan2019::calc_ptf_dharumarajan2019_infiltration( + sand[index], silt[index], clay[index]); + } + const auto elapsed = std::chrono::steady_clock::now() - started; + observe(infiltration); + if (iteration == warmups) { + print_record("dharumarajan2019_infiltration", limit, elapsed); + } + } + for (std::size_t iteration = 0; iteration <= warmups; ++iteration) { + const auto started = std::chrono::steady_clock::now(); + for (std::size_t index = 0; index < limit; ++index) { + const auto result = ptfkit::mayr1999::calc_ptf_mayr1999( + sand[index], silt[index], clay[index], bulk_density[index], organic_carbon[index]); + mayr_a_hc[index] = result.a_hc; + mayr_b_hc[index] = result.b_hc; + mayr_theta_s[index] = result.theta_s; + } + const auto elapsed = std::chrono::steady_clock::now() - started; + observe(mayr_a_hc); + observe(mayr_b_hc); + observe(mayr_theta_s); + if (iteration == warmups) { + print_record("mayr1999", limit, elapsed); + } + } + for (std::size_t iteration = 0; iteration <= warmups; ++iteration) { + const auto started = std::chrono::steady_clock::now(); + for (std::size_t index = 0; index < limit; ++index) { + const auto result = ptfkit::li2007::calc_ptf_li2007( + sand[index], silt[index], clay[index], bulk_density[index], organic_carbon[index]); + li_theta_s[index] = result.theta_s; + li_a_vg[index] = result.a_vg; + li_n_vg[index] = result.n_vg; + li_k_sat[index] = result.k_sat; + } + const auto elapsed = std::chrono::steady_clock::now() - started; + observe(li_theta_s); + observe(li_a_vg); + observe(li_n_vg); + observe(li_k_sat); + if (iteration == warmups) { + print_record("li2007", limit, elapsed); + } + } + + return 0; +} diff --git a/benches/ptfkit-native/src/npy.h b/benches/ptfkit-native/src/npy.h new file mode 100644 index 0000000..16c00f0 --- /dev/null +++ b/benches/ptfkit-native/src/npy.h @@ -0,0 +1,55 @@ +#ifndef PTFKIT_BENCH_NPY_H +#define PTFKIT_BENCH_NPY_H + +#include +#include +#include + +static inline double *load_npy_f64(const char *path, size_t *length) { + FILE *file = fopen(path, "rb"); + if (file == NULL) { + return NULL; + } + + unsigned char prefix[10]; + if (fread(prefix, 1, sizeof(prefix), file) != sizeof(prefix)) { + fclose(file); + return NULL; + } + + const size_t header_length_size = prefix[6] == 1 ? 2 : 4; + unsigned char header_length_bytes[4] = {0}; + if (fread(header_length_bytes, 1, header_length_size, file) != header_length_size) { + fclose(file); + return NULL; + } + const uint32_t header_length = header_length_bytes[0] | (header_length_bytes[1] << 8) | + (header_length_bytes[2] << 16) | (header_length_bytes[3] << 24); + if (fseek(file, (long)header_length, SEEK_CUR) != 0) { + fclose(file); + return NULL; + } + + const long data_start = ftell(file); + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return NULL; + } + const long data_end = ftell(file); + if (data_start < 0 || data_end < data_start) { + fclose(file); + return NULL; + } + *length = (size_t)(data_end - data_start) / sizeof(double); + double *values = (double *)malloc(*length * sizeof(*values)); + if (values == NULL || fseek(file, data_start, SEEK_SET) != 0 || + fread(values, sizeof(*values), *length, file) != *length) { + free(values); + fclose(file); + return NULL; + } + fclose(file); + return values; +} + +#endif diff --git a/benches/ptfkit-py/.gitignore b/benches/ptfkit-py/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/benches/ptfkit-py/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/benches/ptfkit-py/pyproject.toml b/benches/ptfkit-py/pyproject.toml new file mode 100644 index 0000000..666f6f0 --- /dev/null +++ b/benches/ptfkit-py/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["uv_build>=0.12.3,<0.13.0"] +build-backend = "uv_build" + +[project] +name = "ptfkit-py-benchmarks" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [ + "ptfkit", +] + +[project.scripts] +ptfkit-py-benchmarks = "ptfkit_py_benchmarks:main" + +[tool.uv.sources] +ptfkit = { path = "../../targets/ptfkit-py" } diff --git a/benches/ptfkit-py/src/ptfkit_py_benchmarks/__init__.py b/benches/ptfkit-py/src/ptfkit_py_benchmarks/__init__.py new file mode 100644 index 0000000..7f0cde6 --- /dev/null +++ b/benches/ptfkit-py/src/ptfkit_py_benchmarks/__init__.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path + +import numpy as np +from ptfkit.dharumarajan2019 import calc_ptf_dharumarajan2019_infiltration +from ptfkit.li2007 import Li2007PTFResult, calc_ptf_li2007 +from ptfkit.mayr1999 import Mayr1999PTFResult, calc_ptf_mayr1999 + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument('dataset', type=Path) + parser.add_argument('warmups', type=int) + parser.add_argument('limit', type=int, nargs='?') + return parser.parse_args() + + +def print_record(case: str, samples: int, elapsed_ns: int) -> None: + print( + json.dumps( + { + 'target': 'python', + 'case': case, + 'samples': samples, + 'elapsed_ns': elapsed_ns, + } + ) + ) + + +def main() -> None: + arguments = parse_arguments() + dataset_dir: Path = arguments.dataset + sand = np.load(dataset_dir / 'sand.npy') + silt = np.load(dataset_dir / 'silt.npy') + clay = np.load(dataset_dir / 'clay.npy') + bulk_density = np.load(dataset_dir / 'bulk_density.npy') + organic_carbon = np.load(dataset_dir / 'organic_carbon.npy') + samples = arguments.limit or len(sand) + sand, silt, clay = sand[:samples], silt[:samples], clay[:samples] + bulk_density, organic_carbon = bulk_density[:samples], organic_carbon[:samples] + + infiltration = np.empty(samples, dtype=np.float64) + for iteration in range(arguments.warmups + 1): + started = time.perf_counter_ns() + calc_ptf_dharumarajan2019_infiltration(sand=sand, silt=silt, clay=clay, out=infiltration) + elapsed_ns = time.perf_counter_ns() - started + if iteration == arguments.warmups: + _ = infiltration.sum() + print_record('dharumarajan2019_infiltration', samples, elapsed_ns) + + mayr_out = Mayr1999PTFResult(*(np.empty(samples, dtype=np.float64) for _ in range(3))) + for iteration in range(arguments.warmups + 1): + started = time.perf_counter_ns() + calc_ptf_mayr1999( + sand=sand, + silt=silt, + clay=clay, + bulk_density=bulk_density, + organic_carbon=organic_carbon, + out=mayr_out, + ) + elapsed_ns = time.perf_counter_ns() - started + if iteration == arguments.warmups: + _ = sum(output.sum() for output in mayr_out) + print_record('mayr1999', samples, elapsed_ns) + + li_out = Li2007PTFResult(*(np.empty(samples, dtype=np.float64) for _ in range(4))) + for iteration in range(arguments.warmups + 1): + started = time.perf_counter_ns() + calc_ptf_li2007( + sand=sand, + silt=silt, + clay=clay, + bulk_density=bulk_density, + soil_organic_matter=organic_carbon, + out=li_out, + ) + elapsed_ns = time.perf_counter_ns() - started + if iteration == arguments.warmups: + _ = sum(output.sum() for output in li_out) + print_record('li2007', samples, elapsed_ns) diff --git a/benches/ptfkit-py/uv.lock b/benches/ptfkit-py/uv.lock new file mode 100644 index 0000000..b182576 --- /dev/null +++ b/benches/ptfkit-py/uv.lock @@ -0,0 +1,92 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "ptfkit" +source = { directory = "../../targets/ptfkit-py" } +dependencies = [ + { name = "numpy" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=2.0,<3" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = "~=9.1" }, + { name = "pytest-cov", specifier = "~=7.0" }, +] + +[[package]] +name = "ptfkit-py-benchmarks" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "ptfkit" }, +] + +[package.metadata] +requires-dist = [{ name = "ptfkit", directory = "../../targets/ptfkit-py" }] diff --git a/benches/ptfkit-rs/.gitignore b/benches/ptfkit-rs/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/benches/ptfkit-rs/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/benches/ptfkit-rs/Cargo.lock b/benches/ptfkit-rs/Cargo.lock new file mode 100644 index 0000000..17e5de1 --- /dev/null +++ b/benches/ptfkit-rs/Cargo.lock @@ -0,0 +1,177 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "npyz" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a01a88a1d2f89d362475ad16f34de3994f7c86b12259236f5d33537b040bcd4" +dependencies = [ + "byteorder", + "num-bigint", + "py_literal", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptfkit" +version = "0.2.0" + +[[package]] +name = "ptfkit-rs-benchmarks" +version = "0.1.0" +dependencies = [ + "npyz", + "ptfkit", +] + +[[package]] +name = "py_literal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102df7a3d46db9d3891f178dcc826dc270a6746277a9ae6436f8d29fd490a8e1" +dependencies = [ + "num-bigint", + "num-complex", + "num-traits", + "pest", + "pest_derive", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/benches/ptfkit-rs/Cargo.toml b/benches/ptfkit-rs/Cargo.toml new file mode 100644 index 0000000..76b7390 --- /dev/null +++ b/benches/ptfkit-rs/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ptfkit-rs-benchmarks" +version = "0.1.0" +edition = "2024" + +[features] +default = ["inline"] +inline = ["ptfkit/inline"] + +[dependencies] +npyz = "0.9.1" +ptfkit = { path = "../../targets/ptfkit-rs", default-features = false } diff --git a/benches/ptfkit-rs/src/main.rs b/benches/ptfkit-rs/src/main.rs new file mode 100644 index 0000000..e1ab19f --- /dev/null +++ b/benches/ptfkit-rs/src/main.rs @@ -0,0 +1,161 @@ +use std::error::Error; +use std::fs::File; +use std::hint::black_box; +use std::path::Path; +use std::time::Instant; + +struct Arguments { + dataset: String, + warmups: usize, + limit: Option, +} + +fn parse_arguments() -> Result> { + let arguments: Vec<_> = std::env::args().skip(1).collect(); + if !(2..=3).contains(&arguments.len()) { + return Err("usage: ptfkit-rs-benchmarks DATASET WARMUPS [LIMIT]".into()); + } + Ok(Arguments { + dataset: arguments[0].clone(), + warmups: arguments[1].parse()?, + limit: arguments.get(2).map(|value| value.parse()).transpose()?, + }) +} + +fn load_array(dataset: &Path, name: &str) -> Result, Box> { + let file = File::open(dataset.join(format!("{name}.npy")))?; + Ok(npyz::NpyFile::new(file)?.into_vec::()?) +} + +fn print_record(case: &str, samples: usize, elapsed_ns: u128) { + println!( + r#"{{"target":"rust","case":"{case}","samples":{samples},"elapsed_ns":{elapsed_ns}}}"# + ); +} + +fn observe(values: &[f64]) { + black_box(values.iter().sum::()); +} + +fn main() -> Result<(), Box> { + let arguments = parse_arguments()?; + let dataset = Path::new(&arguments.dataset); + let sand = load_array(dataset, "sand")?; + let silt = load_array(dataset, "silt")?; + let clay = load_array(dataset, "clay")?; + let bulk_density = load_array(dataset, "bulk_density")?; + let organic_carbon = load_array(dataset, "organic_carbon")?; + let samples = arguments.limit.unwrap_or(sand.len()); + let sand = &sand[..samples]; + let silt = &silt[..samples]; + let clay = &clay[..samples]; + let bulk_density = &bulk_density[..samples]; + let organic_carbon = &organic_carbon[..samples]; + + let mut infiltration = vec![0.0; samples]; + for _ in 0..arguments.warmups { + for index in 0..samples { + infiltration[index] = ptfkit::dharumarajan2019::calc_ptf_dharumarajan2019_infiltration( + sand[index], + silt[index], + clay[index], + ); + } + observe(&infiltration); + } + let started = Instant::now(); + for index in 0..samples { + infiltration[index] = ptfkit::dharumarajan2019::calc_ptf_dharumarajan2019_infiltration( + sand[index], + silt[index], + clay[index], + ); + } + let elapsed_ns = started.elapsed().as_nanos(); + observe(&infiltration); + print_record("dharumarajan2019_infiltration", samples, elapsed_ns); + + let mut mayr_a_hc = vec![0.0; samples]; + let mut mayr_b_hc = vec![0.0; samples]; + let mut mayr_theta_s = vec![0.0; samples]; + for _ in 0..arguments.warmups { + for index in 0..samples { + let result = ptfkit::mayr1999::calc_ptf_mayr1999( + sand[index], + silt[index], + clay[index], + bulk_density[index], + organic_carbon[index], + ); + mayr_a_hc[index] = result.a_hc; + mayr_b_hc[index] = result.b_hc; + mayr_theta_s[index] = result.theta_s; + } + observe(&mayr_a_hc); + observe(&mayr_b_hc); + observe(&mayr_theta_s); + } + let started = Instant::now(); + for index in 0..samples { + let result = ptfkit::mayr1999::calc_ptf_mayr1999( + sand[index], + silt[index], + clay[index], + bulk_density[index], + organic_carbon[index], + ); + mayr_a_hc[index] = result.a_hc; + mayr_b_hc[index] = result.b_hc; + mayr_theta_s[index] = result.theta_s; + } + let elapsed_ns = started.elapsed().as_nanos(); + observe(&mayr_a_hc); + observe(&mayr_b_hc); + observe(&mayr_theta_s); + print_record("mayr1999", samples, elapsed_ns); + + let mut li_theta_s = vec![0.0; samples]; + let mut li_a_vg = vec![0.0; samples]; + let mut li_n_vg = vec![0.0; samples]; + let mut li_k_sat = vec![0.0; samples]; + for _ in 0..arguments.warmups { + for index in 0..samples { + let result = ptfkit::li2007::calc_ptf_li2007( + sand[index], + silt[index], + clay[index], + bulk_density[index], + organic_carbon[index], + ); + li_theta_s[index] = result.theta_s; + li_a_vg[index] = result.a_vg; + li_n_vg[index] = result.n_vg; + li_k_sat[index] = result.k_sat; + } + observe(&li_theta_s); + observe(&li_a_vg); + observe(&li_n_vg); + observe(&li_k_sat); + } + let started = Instant::now(); + for index in 0..samples { + let result = ptfkit::li2007::calc_ptf_li2007( + sand[index], + silt[index], + clay[index], + bulk_density[index], + organic_carbon[index], + ); + li_theta_s[index] = result.theta_s; + li_a_vg[index] = result.a_vg; + li_n_vg[index] = result.n_vg; + li_k_sat[index] = result.k_sat; + } + let elapsed_ns = started.elapsed().as_nanos(); + observe(&li_theta_s); + observe(&li_a_vg); + observe(&li_n_vg); + observe(&li_k_sat); + print_record("li2007", samples, elapsed_ns); + Ok(()) +} diff --git a/benches/ruff.toml b/benches/ruff.toml new file mode 100644 index 0000000..765dc16 --- /dev/null +++ b/benches/ruff.toml @@ -0,0 +1,8 @@ +extend = "../ruff.toml" +target-version = "py314" + +[lint] +ignore = [ + "D102", "D107", + "T201", +] diff --git a/benches/run.py b/benches/run.py new file mode 100755 index 0000000..ee1f582 --- /dev/null +++ b/benches/run.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.14" +# /// + +from __future__ import annotations + +import argparse +import json +import shlex +import statistics +import subprocess +import sys +import tarfile +from collections import defaultdict +from pathlib import Path + + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Iterable + + type BenchmarkRecord = dict[str, int | str] + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description='Run cross-target ptfkit benchmarks.') + parser.add_argument('--warmups', type=int, default=3) + parser.add_argument('--iterations', type=int, default=10) + parser.add_argument('--limit', type=int) + return parser.parse_args() + + +def extract_dataset(root: Path) -> Path: + fixtures_dir = root / 'fixtures' + archive = fixtures_dir / 'soilgrids.tar.zst' + target_dir = fixtures_dir / 'soilgrids' + + if not target_dir.joinpath('manifest.json').is_file(): + print('Extracting', archive) + with tarfile.open(archive, 'r:zst') as tar: + tar.extractall(target_dir, filter='data') + + return target_dir + + +class Runner: + __slots__ = ('_dataset_dir', '_limit', '_warmups') + + def __init__( + self, + dataset_dir: Path, + arguments: argparse.Namespace, + ) -> None: + self._dataset_dir = shlex.quote(str(dataset_dir)) + self._warmups = str(arguments.warmups) + limit: int | None = arguments.limit + self._limit = None if limit is None else str(limit) + + def __call__(self, name: str, command: Path, iteration: int) -> Iterable[BenchmarkRecord]: + args = [shlex.quote(str(command)), self._dataset_dir, self._warmups] + if limit := self._limit: + args.append(limit) + # Commands come from the fixed target map below. + completed = subprocess.run( # noqa: S603 + args, + text=True, + capture_output=True, + check=False, + ) + if completed.returncode: + print(completed.stderr, file=sys.stderr, end='') + raise SystemExit(completed.returncode) + if completed.stderr: + print(completed.stderr, file=sys.stderr, end='') + + for line in completed.stdout.splitlines(): + record: BenchmarkRecord = json.loads(line) + record['target'] = name + record['iteration'] = iteration + yield record + + +def print_table(case: str, records: list[BenchmarkRecord]) -> None: + grouped: dict[str, list[float]] = defaultdict(list) + samples = int(records[0]['samples']) + for record in records: + grouped[str(record['target'])].append(float(record['elapsed_ns'])) + c_mean = statistics.mean(grouped['c']) + print(f'\n{case} ({samples:,} samples)') + header = ( + f'{"target":<14} {"median ms":>11} {"mean ms":>11} {"stddev ms":>11} ' + f'{"min ms":>11} {"max ms":>11} {"ns/element":>12} ' + f'{"M elements/s":>14} {"% of C":>8}' + ) + print(header) + for target, elapsed in grouped.items(): + mean = statistics.mean(elapsed) + stddev = statistics.stdev(elapsed) if len(elapsed) > 1 else 0.0 + print( + f'{target:<14} {statistics.median(elapsed) / 1e6:11.3f} {mean / 1e6:11.3f} ' + f'{stddev / 1e6:11.3f} {min(elapsed) / 1e6:11.3f} {max(elapsed) / 1e6:11.3f} ' + f'{mean / samples:12.2f} {samples * 1e3 / mean:14.2f} {mean / c_mean * 1e2:8.2f}x' + ) + + +def main() -> None: + arguments = parse_arguments() + root = Path(__file__).parent.relative_to(Path.cwd()) + dataset_dir = extract_dataset(root) + run_target = Runner(dataset_dir, arguments) + rs_prefix = root.joinpath('ptfkit-rs', 'target') + native_prefix = root.joinpath('ptfkit-native', 'build') + targets = [ + ('c', native_prefix / 'ptfkit-c-benchmarks'), + ('cpp', native_prefix / 'ptfkit-cpp-benchmarks'), + ('python', root.joinpath('ptfkit-py', '.venv', 'bin', 'ptfkit-py-benchmarks')), + ('rust-inline', rs_prefix.joinpath('inline', 'release', 'ptfkit-rs-benchmarks')), + ('rust-no-inline', rs_prefix.joinpath('no-inline', 'release', 'ptfkit-rs-benchmarks')), + ] + records = [] + for iteration in range(arguments.iterations): + print(f'Iteration{iteration + 1: 3}: ', end='') + offset = iteration % len(targets) + for name, command in targets[offset:] + targets[:offset]: + print(name, end=' ', flush=True) + records.extend(run_target(name, command, iteration)) + print() + by_case: dict[str, list[BenchmarkRecord]] = defaultdict(list) + for record in records: + by_case[str(record['case'])].append(record) + for case, case_records in by_case.items(): + print_table(case, case_records) + + +if __name__ == '__main__': + main() diff --git a/benches/tasks.toml b/benches/tasks.toml new file mode 100644 index 0000000..bb3a4f7 --- /dev/null +++ b/benches/tasks.toml @@ -0,0 +1,26 @@ +["bench:native-build"] +dir = "benches" +run = [ + "cmake -S ptfkit-native -B ptfkit-native/build -GNinja -DCMAKE_BUILD_TYPE=Release", + "cmake --build ptfkit-native/build", +] + +["bench:python-build"] +dir = "benches/ptfkit-py" +run = "uv sync" + +["bench:rust-build"] +dir = "benches/ptfkit-rs" +run = [ + "CARGO_TARGET_DIR=target/inline cargo build --release --locked", + "CARGO_TARGET_DIR=target/no-inline cargo build --release --no-default-features --locked", +] + +["bench:build"] +depends = ["bench:native-build", "bench:python-build", "bench:rust-build"] + +["bench:run"] +depends = ["bench:build"] +dir = "benches" +run = "uv run --no-project run.py" +output = "interleave" diff --git a/mise.toml b/mise.toml index dc9b044..b4d1a34 100644 --- a/mise.toml +++ b/mise.toml @@ -13,6 +13,7 @@ prek = "0.4.12" [task_config] includes = [ + "benches/tasks.toml", "docs/tasks.toml", "codegen/tasks.toml", "targets/ptfkit-native/tasks.toml",