From e5fddfca2e610a80a9b93c3db9cd42b5a247deb5 Mon Sep 17 00:00:00 2001 From: Jonas Teuwen Date: Mon, 27 Jul 2026 13:28:11 +0200 Subject: [PATCH] feat: python >= 3.12 stable ABI GitOrigin-RevId: 9f02be8643aa77bc8d3ba4f2c82aaaf18d2a501f --- .bazelrc | 10 + .github/workflows/release.yml | 47 +--- BUILD.bazel | 17 -- MODULE.bazel | 14 +- README.md | 25 +- benchmarks/cache_benchmark.cpp | 207 ---------------- docs/source/caching.rst | 239 ++++++++----------- docs/source/guides/packages_and_releases.rst | 19 +- include/fastslide/c/registry.h | 35 --- include/fastslide/c/slide_reader.h | 58 ----- include/fastslide/slide_options.h | 30 ++- meson.build | 26 +- package/Dockerfile | 7 - pyproject.toml | 10 +- python/BUILD.bazel | 57 +++-- rust/fastslide-sys/src/lib.rs | 36 --- rust/fastslide/src/lib.rs | 40 +--- rust/fastslide/src/reader.rs | 117 --------- rust/fastslide/src/registry.rs | 38 --- src/c/cache_c_api_test.cpp | 133 ----------- src/c/registry.cpp | 65 ----- src/c/slide_reader.cpp | 74 ------ src/python/_fastslide.pyi | 21 +- src/python/fastslide.cpp | 92 ++----- tests/meson.build | 12 - tests/python/cache_test.py | 58 ----- tools/artifacts/cli.py | 11 +- tools/artifacts/specs.py | 10 +- tools/artifacts/wheels.py | 67 +++--- tools/build_wheels.py | 13 +- tools/versioned_py_wheel.bzl | 116 ++++----- 31 files changed, 321 insertions(+), 1383 deletions(-) delete mode 100644 benchmarks/cache_benchmark.cpp delete mode 100644 src/c/cache_c_api_test.cpp diff --git a/.bazelrc b/.bazelrc index e1a0e95..f24f097 100644 --- a/.bazelrc +++ b/.bazelrc @@ -22,6 +22,16 @@ build --incompatible_default_to_explicit_init_py build --cxxopt=-std=c++20 build --host_cxxopt=-std=c++20 +# ============================================================================= +# Python Stable ABI (nanobind) +# ============================================================================= +# Build the nanobind extension against CPython's stable ABI (abi3), floored at +# 3.12. A single `cp312-abi3` extension then runs on every CPython >= 3.12, so +# dev/test builds here exercise the exact artifact shipped in the wheel. The +# wheel target re-pins this via its Starlark transition so the artifact is +# correct even in a host workspace whose .bazelrc lacks this flag. +build --@nanobind_bazel//:py-limited-api=cp312 + # ============================================================================= # Release Config (opt + strip) # ============================================================================= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 394860d..12d534a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -358,9 +358,10 @@ jobs: package-dir: . output-dir: artifacts/wheels env: - # cp310 has no official Windows ARM64 CPython; cibuildwheel skips it - # there automatically, so the same build set is safe for both arches. - CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" + # A single stable-ABI (abi3) build on CPython 3.12 produces one + # cp312-abi3 wheel that runs on every CPython >= 3.12 (meson.build sets + # limited_api = '3.12'). + CIBW_BUILD: "cp312-*" CIBW_ARCHS_WINDOWS: ${{ matrix.cibw_arch }} CIBW_BUILD_VERBOSITY: "1" # Force MSVC. The windows-2022/windows-11-arm images ship MinGW GCC @@ -533,30 +534,8 @@ jobs: cp package/Dockerfile package/deb_smoke.cpp smoke_ctx/ cp debs/*.deb smoke_ctx/ ls -l smoke_ctx - # Retry the build: the arm64 image installs deps from ports.ubuntu.com, - # which intermittently times out from the CI network. --no-cache on - # retries forces a fresh apt-get update instead of reusing a failed - # layer. - attempts=3 - for attempt in $(seq 1 "${attempts}"); do - echo "::group::docker build (attempt ${attempt}/${attempts})" - cache_flag="" - if [[ "${attempt}" -gt 1 ]]; then - cache_flag="--no-cache" - fi - if docker build ${cache_flag} --platform ${{ matrix.docker_platform }} \ - -t fastslide-deb-smoke smoke_ctx; then - echo "::endgroup::" - exit 0 - fi - echo "::endgroup::" - echo "docker build failed on attempt ${attempt}/${attempts}" - if [[ "${attempt}" -lt "${attempts}" ]]; then - sleep $((attempt * 15)) - fi - done - echo "docker build failed after ${attempts} attempts" >&2 - exit 1 + docker build --platform ${{ matrix.docker_platform }} \ + -t fastslide-deb-smoke smoke_ctx # TEMPORARILY DISABLED: the source build pulls simpletiff from its pinned git # wrap (subprojects/simpletiff.wrap @ 3886522), which predates the # -before- fix and so fails to compile under GCC. Re-enable @@ -586,8 +565,8 @@ jobs: # shell: bash # run: | # set -euo pipefail - # uv python install 3.11 - # uv venv --python 3.11 .venv + # uv python install 3.12 + # uv venv --python 3.12 .venv # uv pip install --python .venv/bin/python --no-binary fastslide dist/*.tar.gz # .venv/bin/python tools/smoke_test_python.py smoke-wheels: @@ -597,7 +576,9 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + # One cp312-abi3 wheel per platform, imported on every supported CPython + # (3.12-3.14) to prove the stable-ABI tag actually loads across versions. + python: ["3.12", "3.13", "3.14"] target: - platform: linux_x86_64 runner: ubuntu-24.04 @@ -614,12 +595,6 @@ jobs: runner: windows-2022 - platform: windows_arm64 runner: windows-11-arm - # No official CPython 3.10 for Windows ARM64, so no cp310 win_arm64 wheel - # is built; drop that smoke combination. - exclude: - - python: "3.10" - target: - platform: windows_arm64 steps: - name: Checkout uses: actions/checkout@v5 diff --git a/BUILD.bazel b/BUILD.bazel index 88fc844..c1a661e 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1567,27 +1567,10 @@ cc_library( visibility = ["//visibility:public"], deps = [ ":fastslide_lib", - ":runtime_global_cache_manager", ":runtime_png_decoder", ], ) -# C API tile-cache tests. Require a real slide via FASTSLIDE_BENCHMARK_FILE; -# skipped otherwise. Run with: -# FASTSLIDE_BENCHMARK_FILE=/abs/path/CMU-3.ndpi \ -# bazelisk test //:cache_c_api_test --test_output=all -cc_test( - name = "cache_c_api_test", - size = "small", - srcs = ["src/c/cache_c_api_test.cpp"], - deps = [ - ":fastslide_c", - ":formats_ndpitiff", - "@googletest//:gtest", - "@googletest//:gtest_main", - ], -) - # Slide information tool cc_binary( name = "fastslidetool", diff --git a/MODULE.bazel b/MODULE.bazel index 9f4eeb3..834c73f 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -140,29 +140,27 @@ bazel_dep(name = "aspect_rules_js", version = "2.9.2", dev_dependency = True) bazel_dep(name = "buildifier_prebuilt", version = "8.0.3", dev_dependency = True) bazel_dep(name = "toolchains_llvm", version = "1.7.0", dev_dependency = True) +# The Python bindings ship a single stable-ABI (abi3) wheel floored at CPython +# 3.12, so only the 3.12 toolchain is registered. The extension built against it +# runs unchanged on every CPython >= 3.12. python = use_extension("@rules_python//python/extensions:python.bzl", "python") python.toolchain( is_default = True, - python_version = "3.11", + python_version = "3.12", ) -python.toolchain(python_version = "3.9") -python.toolchain(python_version = "3.10") -python.toolchain(python_version = "3.12") -python.toolchain(python_version = "3.13") -python.toolchain(python_version = "3.14") pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") pip.parse( enable_implicit_namespace_pkgs = True, hub_name = "fastslide_pip", - python_version = "3.11", + python_version = "3.12", requirements_darwin = "//:requirements_darwin.txt", requirements_lock = "//:requirements_linux.txt", ) pip.parse( enable_implicit_namespace_pkgs = True, hub_name = "fastslide_docs_pip", - python_version = "3.11", + python_version = "3.12", requirements_darwin = "//docs:requirements_darwin.txt", requirements_lock = "//docs:requirements_linux.txt", ) diff --git a/README.md b/README.md index 4f94cec..b10506d 100644 --- a/README.md +++ b/README.md @@ -125,16 +125,17 @@ bazelisk test //... ##### Building Python wheels with Bazel -Wheels are platform-specific because they bundle the native C++ extension. Each -Python version has its own Bazel target: `//python:fastslide_wheel_cp310` through -`//python:fastslide_wheel_cp314` (Python 3.10–3.14). Unlike the Meson path, -Bazel can also cross-compile wheels for other platforms. +Wheels are platform-specific because they bundle the native C++ extension. A +single Bazel target, `//python:fastslide_wheel`, builds one stable-ABI wheel +tagged `cp312-abi3`; because it targets CPython's stable ABI, that wheel runs +on every CPython >= 3.12. Unlike the Meson path, Bazel can also cross-compile +wheels for other platforms. **Current platform** — build on the host OS/arch without cross-compilation: ```bash -# Example: Python 3.11 wheel for the machine you are on. -bazelisk build //python:fastslide_wheel_cp311 +# The cp312-abi3 wheel for the machine you are on. +bazelisk build //python:fastslide_wheel ``` The `.whl` file appears under `bazel-bin/python/`. @@ -143,9 +144,9 @@ The `.whl` file appears under `bazel-bin/python/`. hermetic toolchains (`--config=hermetic` in `.bazelrc`): ```bash -# Example: Linux x86_64 wheel for Python 3.11, e.g. from macOS. +# Example: Linux x86_64 wheel, e.g. from macOS. bazelisk build --config=hermetic --platforms=//platforms:linux_x86_64 \ - //python:fastslide_wheel_cp311 + //python:fastslide_wheel ``` Supported platform keys: `linux_x86_64`, `linux_arm64`, `darwin_x86_64`, @@ -153,14 +154,14 @@ Supported platform keys: `linux_x86_64`, `linux_arm64`, `darwin_x86_64`, architecture from macOS, the native toolchain is used instead of hermetic Zig. **Batch builds** — `tools/build_wheels.py` drives Bazel for multiple platforms -and Python versions and copies wheels into `artifacts/wheels/`: +and copies wheels into `artifacts/wheels/`: ```bash -# All supported platforms and Python versions. +# All supported platforms. python tools/build_wheels.py -# Subset, e.g. one platform and one Python tag. -python tools/build_wheels.py --platform linux_x86_64 --python cp311 +# Subset, e.g. one platform. +python tools/build_wheels.py --platform linux_x86_64 # Continue after individual failures. python tools/build_wheels.py --keep-going diff --git a/benchmarks/cache_benchmark.cpp b/benchmarks/cache_benchmark.cpp deleted file mode 100644 index 4c81c70..0000000 --- a/benchmarks/cache_benchmark.cpp +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright 2025 Jonas Teuwen. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Benchmark for the internal tile cache: compares repeated overlapping -// region reads with and without a decode cache attached. Overlapping windows -// deliberately land inside the same native tile-grid cells so a cache turns -// the repeated decodes into hits. -// -// Provide a slide via the environment variable, e.g.: -// FASTSLIDE_BENCHMARK_FILE=/abs/path/CMU-3.ndpi \ -// bazelisk run @fastslide/benchmarks:cache_benchmark - -#include -#include -#include -#include -#include -#include -#include - -#include "aifocore/status/result.h" -#include "benchmark/benchmark.h" -#include "fastslide/runtime/lru_tile_cache.h" -#include "fastslide/runtime/reader_registry.h" -#include "fastslide/slide_reader.h" - -namespace { - -// Slide path from FASTSLIDE_BENCHMARK_FILE (default: "CMU-3.ndpi", expected to -// be resolvable from the working directory; prefer an absolute path). -const char* GetBenchmarkFilePath() { - const char* env_path = std::getenv("FASTSLIDE_BENCHMARK_FILE"); - return env_path != nullptr ? env_path : "CMU-3.ndpi"; -} - -// A single read window at a given level. -struct Window { - uint32_t x; - uint32_t y; - uint32_t width; - uint32_t height; -}; - -// Build a set of overlapping windows anchored near the origin of `level` that -// all fall within a 2x2 native-tile footprint, so repeated reads reuse the -// same decoded tiles. `window` is the read size (e.g. 256), `step` the offset -// stride (e.g. 64) producing heavy overlap. -std::vector BuildOverlappingWindows(uint32_t level_width, - uint32_t level_height, - uint32_t tile_size, uint32_t window, - uint32_t step) { - std::vector windows; - // Footprint spanning two native tiles in each dimension (bounded by level). - const uint32_t span = std::min(2 * tile_size, level_width); - const uint32_t span_y = std::min(2 * tile_size, level_height); - for (uint32_t oy = 0; oy + window <= span_y; oy += step) { - for (uint32_t ox = 0; ox + window <= span; ox += step) { - windows.push_back(Window{ox, oy, window, window}); - } - } - if (windows.empty()) { - // Level smaller than a single window: fall back to one clamped read. - windows.push_back(Window{0, 0, std::min(window, level_width), - std::min(window, level_height)}); - } - return windows; -} - -// Opens the benchmark slide and caches the level-0 geometry. A fresh reader is -// created per fixture instance so cache attachment is isolated between the -// no-cache and with-cache variants. -class ReaderContext { - public: - bool Open() { - auto reader_or = fastslide::runtime::GetGlobalRegistry().CreateReader( - GetBenchmarkFilePath()); - if (!reader_or.ok()) { - return false; - } - reader_ = std::move(reader_or.value()); - - auto level_info_or = reader_->GetLevelInfo(0); - if (!level_info_or.ok()) { - return false; - } - const auto level_info = level_info_or.value(); - level_width_ = level_info.dimensions[0]; - level_height_ = level_info.dimensions[1]; - - const auto tile = reader_->GetTileSize(); - tile_size_ = tile[0] != 0 ? tile[0] : 256; - return true; - } - - fastslide::SlideReader* reader() const { return reader_.get(); } - - uint32_t level_width() const { return level_width_; } - - uint32_t level_height() const { return level_height_; } - - uint32_t tile_size() const { return tile_size_; } - - private: - std::unique_ptr reader_; - uint32_t level_width_{0}; - uint32_t level_height_{0}; - uint32_t tile_size_{256}; -}; - -// Reads every window once and returns bytes processed, or -1 on error. -int64_t ReadWindows(const fastslide::SlideReader& reader, - const std::vector& windows) { - int64_t total_bytes = 0; - for (const auto& w : windows) { - fastslide::RegionSpec region{ - .top_left = {w.x, w.y}, .size = {w.width, w.height}, .level = 0}; - auto result = reader.ReadRegion(region); - if (!result.ok()) { - return -1; - } - total_bytes += static_cast(w.width) * w.height * sizeof(uint32_t); - } - return total_bytes; -} - -// Common driver: reads `windows` every iteration. When `capacity_bytes > 0` a -// per-reader LRU cache is attached, so the first iteration warms it and later -// iterations should hit. -void RunOverlapping(benchmark::State& state, size_t capacity_bytes) { - ReaderContext ctx; - if (!ctx.Open()) { - state.SkipWithError( - "Failed to open slide (set FASTSLIDE_BENCHMARK_FILE to an absolute " - "path to a supported slide)"); - return; - } - - std::shared_ptr cache; - if (capacity_bytes > 0) { - auto cache_or = fastslide::runtime::LRUTileCache::Create(capacity_bytes); - if (!cache_or.ok()) { - state.SkipWithError("Failed to create tile cache"); - return; - } - cache = std::move(cache_or.value()); - ctx.reader()->SetCache(cache); - } - - const uint32_t window = static_cast(state.range(0)); - const uint32_t step = std::max(1, window / 4); - const std::vector windows = BuildOverlappingWindows( - ctx.level_width(), ctx.level_height(), ctx.tile_size(), window, step); - - int64_t total_bytes = 0; - for (auto _ : state) { - const int64_t bytes = ReadWindows(*ctx.reader(), windows); - if (bytes < 0) { - state.SkipWithError("Failed to read region"); - break; - } - total_bytes += bytes; - } - - state.SetItemsProcessed(state.iterations() * - static_cast(windows.size())); - state.SetBytesProcessed(total_bytes); - state.counters["windows"] = static_cast(windows.size()); - if (cache) { - const auto stats = cache->GetStats(); - state.counters["hit_ratio"] = stats.hit_ratio; - state.counters["hits"] = static_cast(stats.hits); - state.counters["misses"] = static_cast(stats.misses); - } -} - -void BM_OverlappingReads_NoCache(benchmark::State& state) { - RunOverlapping(state, 0); -} - -void BM_OverlappingReads_WithCache(benchmark::State& state) { - // 1 GiB is ample to hold the small overlapping footprint's native tiles. - RunOverlapping(state, static_cast(1) << 30); -} - -BENCHMARK(BM_OverlappingReads_NoCache) - ->Arg(256) - ->Arg(512) - ->Unit(benchmark::kMicrosecond); -BENCHMARK(BM_OverlappingReads_WithCache) - ->Arg(256) - ->Arg(512) - ->Unit(benchmark::kMicrosecond); - -} // namespace - -BENCHMARK_MAIN(); diff --git a/docs/source/caching.rst b/docs/source/caching.rst index c8c4062..3c76446 100644 --- a/docs/source/caching.rst +++ b/docs/source/caching.rst @@ -68,14 +68,21 @@ System Components │ - Configurable capacity │ └─────────────────────────────────────┘ │ - │ attached via + │ injected via ▼ ┌─────────────────────────────────────┐ - │ SlideReader::SetCache(cache) │ - │ - Opt-in per reader │ - │ - nullptr disables caching │ + │ ReaderDependencies │ + │ - tile_cache │ + │ - enable_caching │ └──────────────┬──────────────────────┘ - │ used by + │ passed to + ▼ + ┌─────────────────────────────────────┐ + │ Format Plugins │ + │ - CreateMrxsReader() │ + │ - CreateAperioReader() │ + └──────────────┬──────────────────────┘ + │ creates ▼ ┌─────────────────────────────────────┐ │ Slide Readers │ @@ -119,29 +126,49 @@ The simplest and most efficient approach: #include "fastslide/runtime/global_cache_manager.h" #include "fastslide/runtime/reader_registry.h" + #include "fastslide/runtime/reader_dependencies.h" // Configure global cache at application startup (2 GiB) auto& cache_manager = fastslide::GlobalCacheManager::Instance(); cache_manager.SetCapacityBytes(static_cast(2) << 30); - // Create a reader and attach the shared global cache. - auto reader = fastslide::runtime::GetGlobalRegistry() - .CreateReader("slide.mrxs").value(); - reader->SetCache(cache_manager.GetCache()); - - // First read - cache miss, decodes from disk - auto region1 = reader->ReadRegion({ - .top_left = {1000, 2000}, .size = {512, 512}, .level = 0}); + // Register formats + fastslide::ReaderRegistry registry; + registry.RegisterFormat( + fastslide::formats::mrxs::CreateMrxsFormatDescriptor()); + registry.RegisterFormat( + fastslide::formats::aperio::CreateAperioFormatDescriptor()); - // Second read - cache hit, no disk I/O! - auto region2 = reader->ReadRegion({ - .top_left = {1000, 2000}, .size = {512, 512}, .level = 0}); + // Create reader with global cache (automatic injection) + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); + auto reader_or = registry.CreateReader("slide.mrxs", deps); - // Check cache statistics - auto stats = cache_manager.GetStats(); - std::cout << "Cache hits: " << stats.hits << "\n"; - std::cout << "Cache misses: " << stats.misses << "\n"; - std::cout << "Hit ratio: " << (stats.hit_ratio * 100.0) << "%\n"; + if (reader_or.ok()) { + auto reader = std::move(*reader_or); + + // First read - cache miss, decodes from disk + auto region1 = reader->ReadRegion({ + .top_left = {1000, 2000}, + .size = {512, 512}, + .level = 0 + }); + + // Second read - cache hit, no disk I/O! + auto region2 = reader->ReadRegion({ + .top_left = {1000, 2000}, + .size = {512, 512}, + .level = 0 + }); + + // Check cache statistics + auto stats = cache_manager.GetStats(); + std::cout << "Cache hits: " << stats.hits << "\n"; + std::cout << "Cache misses: " << stats.misses << "\n"; + std::cout << "Hit ratio: " << (stats.hit_ratio * 100.0) << "%\n"; + std::cout << "Memory: " + << (stats.memory_usage_bytes / 1024.0 / 1024.0) + << " MB\n"; + } Per-Reader Cache ---------------- @@ -152,83 +179,17 @@ For isolated caching between readers: #include "fastslide/runtime/lru_tile_cache.h" - // Create a custom cache for this reader (512 MiB) - auto cache = fastslide::LRUTileCache::Create( - static_cast(512) << 20).value(); - - // Attach it to the reader. - auto reader = registry.CreateReader("slide.mrxs").value(); - reader->SetCache(cache); - -C API -===== - -The C API exposes the same cache, so C and Rust consumers get decode reuse -without reimplementing tile-grid-aware caching: - -.. code-block:: c - - #include "fastslide/c/fastslide.h" - - fastslide_registry_initialize(); - - // Per-reader cache (256 MiB); 0 opens without a cache. - FastSlideSlideReader* reader = - fastslide_create_reader_with_cache("slide.svs", (size_t)256 << 20); - - // ... or attach later / switch to the shared global cache: - fastslide_slide_reader_set_cache(reader, (size_t)512 << 20); - fastslide_global_cache_set_capacity_bytes((size_t)2 << 30); - fastslide_slide_reader_use_global_cache(reader); - - FastSlideCacheStats stats; - if (fastslide_slide_reader_get_cache_stats(reader, &stats)) { - printf("hit ratio: %.1f%%\n", stats.hit_ratio * 100.0); + // Create custom cache for this reader (512 MiB) + auto cache_or = fastslide::LRUTileCache::Create( + static_cast(512) << 20); + if (!cache_or.ok()) { + // Handle error + return cache_or.status(); } - fastslide_slide_reader_free(reader); - -Rust API -======== - -The ``fastslide`` crate surfaces the cache on ``SlideReader``: -.. code-block:: rust - - use fastslide::{SlideReader, set_global_cache_capacity}; - - // Per-reader cache (256 MiB). - let reader = SlideReader::open_with_cache("slide.svs", 256 << 20)?; - assert!(reader.is_cache_enabled()); - - // Or attach after opening / use the shared global cache. - reader.set_cache(512 << 20)?; - set_global_cache_capacity(2 << 30)?; - reader.use_global_cache()?; - - if let Some(stats) = reader.cache_stats() { - println!("hit ratio: {:.1}%", stats.hit_ratio * 100.0); - } - -Python API -========== - -``FastSlide.from_file_path`` accepts a ``cache`` argument (an int byte -capacity, a ``CacheManager``/``TileCache``, or ``None``): - -.. code-block:: python - - import fastslide - - # Per-slide LRU cache (256 MiB). - with fastslide.FastSlide.from_file_path("slide.svs", cache=256 << 20) as slide: - slide.read_region((0, 0), 0, (256, 256)) - slide.read_region((0, 0), 0, (256, 256)) # served from cache - print(slide.cache_stats.hit_ratio) - - # Share the process-wide global cache across slides. - fastslide.GlobalCacheManager.instance().set_capacity_bytes(2 << 30) - with fastslide.FastSlide.from_file_path("slide.svs") as slide: - slide.use_global_cache() + // Inject via dependencies + auto deps = fastslide::ReaderDependencies::WithCache(*cache_or); + auto reader_or = registry.CreateReader("slide.mrxs", deps); Use Cases for Per-Reader Cache ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -245,11 +206,13 @@ To disable caching entirely: .. code-block:: cpp - // Option 1: Never attach a cache (default) - readers decode every tile. - auto reader = registry.CreateReader("slide.mrxs").value(); + // Option 1: No cache in dependencies (default) + auto reader_or = registry.CreateReader("slide.mrxs"); - // Option 2: Detach an existing cache. - reader->SetCache(nullptr); + // Option 2: Explicitly disable + fastslide::ReaderDependencies deps; + deps.enable_caching = false; + auto reader_or = registry.CreateReader("slide.mrxs", deps); Cache Statistics ---------------- @@ -359,9 +322,9 @@ Configure a large cache for interactive panning and zooming: fastslide::ReaderRegistry registry; registry.RegisterFormat(/* ... */); - // Create reader and attach the global cache - auto reader = registry.CreateReader("slide.svs").value(); - reader->SetCache(cache.GetCache()); + // Create reader with global cache + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); + auto reader = registry.CreateReader("slide.svs", deps).value(); // User interaction loop for (const auto& pan_event : user_interactions) { @@ -385,24 +348,26 @@ Separate caches for training and validation: // Training cache (~4 GiB) auto train_cache = fastslide::LRUTileCache::Create(static_cast(4) << 30).value(); + fastslide::ReaderDependencies train_deps; + train_deps.tile_cache = train_cache; // Validation cache (~1 GiB) auto val_cache = fastslide::LRUTileCache::Create(static_cast(1) << 30).value(); + fastslide::ReaderDependencies val_deps; + val_deps.tile_cache = val_cache; - // Create readers with isolated caches (attach the shared cache per reader) + // Create readers with isolated caches std::vector> train_readers; for (const auto& path : training_slides) { - auto reader = registry.CreateReader(path).value(); - reader->SetCache(train_cache); - train_readers.push_back(std::move(reader)); + train_readers.push_back( + registry.CreateReader(path, train_deps).value()); } std::vector> val_readers; for (const auto& path : validation_slides) { - auto reader = registry.CreateReader(path).value(); - reader->SetCache(val_cache); - val_readers.push_back(std::move(reader)); + val_readers.push_back( + registry.CreateReader(path, val_deps).value()); } // Training loop with separate cache statistics @@ -586,12 +551,12 @@ The cache is thread-safe and can be accessed from multiple threads: auto& cache_mgr = fastslide::GlobalCacheManager::Instance(); cache_mgr.SetCapacityBytes(static_cast(8) << 30); // 8 GiB + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); + // Create multiple readers sharing the same cache std::vector> readers; for (const auto& path : slide_paths) { - auto reader = registry.CreateReader(path).value(); - reader->SetCache(cache_mgr.GetCache()); - readers.push_back(std::move(reader)); + readers.push_back(registry.CreateReader(path, deps).value()); } // Process in parallel - cache is thread-safe @@ -611,9 +576,10 @@ If caching isn't providing benefits: .. code-block:: cpp - // Check that a cache is attached to the reader - if (!reader->IsCacheEnabled()) { - std::cerr << "ERROR: No cache attached to reader!\n"; + // Check that cache is enabled + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); + if (!deps.HasCache()) { + std::cerr << "ERROR: Cache not available!\n"; } // Verify cache is being used @@ -686,8 +652,7 @@ Best Practices .. code-block:: cpp - auto& cache = fastslide::GlobalCacheManager::Instance(); - reader->SetCache(cache.GetCache()); + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); 3. **Monitor Statistics Periodically** @@ -749,7 +714,7 @@ Implement custom caching strategies by inheriting from ``ITileCache``: // Use custom cache auto custom_cache = std::make_shared(); - reader->SetCache(custom_cache); + auto deps = fastslide::ReaderDependencies::WithCache(custom_cache); Distributed Caching ------------------- @@ -834,36 +799,18 @@ C++ Classes Stats GetStats() const override; aifocore::Status SetCapacityBytes(size_t capacity_bytes); -``SlideReader`` (cache methods) - Caching is attached per reader; there is no dependency-injection struct. +``ReaderDependencies`` + Dependency injection container. .. code-block:: cpp - void SetCache(std::shared_ptr cache); // nullptr disables - std::shared_ptr GetCache() const; - bool IsCacheEnabled() const; - -C API ------ - -Declared in ``fastslide/c/slide_reader.h`` and ``fastslide/c/registry.h``: - -.. code-block:: c - - FastSlideSlideReader* fastslide_create_reader_with_cache( - const char* file_path, size_t cache_capacity_bytes); - int fastslide_slide_reader_set_cache( - FastSlideSlideReader* reader, size_t capacity_bytes); // 0 disables - int fastslide_slide_reader_use_global_cache(FastSlideSlideReader* reader); - int fastslide_slide_reader_is_cache_enabled( - const FastSlideSlideReader* reader); - void fastslide_slide_reader_clear_cache(FastSlideSlideReader* reader); - int fastslide_slide_reader_get_cache_stats( - const FastSlideSlideReader* reader, FastSlideCacheStats* out_stats); - - int fastslide_global_cache_set_capacity_bytes(size_t capacity_bytes); - int fastslide_global_cache_get_stats(FastSlideCacheStats* out_stats); - void fastslide_global_cache_clear(void); + static ReaderDependencies WithGlobalCache(); + static ReaderDependencies WithCache( + std::shared_ptr cache); + + std::shared_ptr tile_cache; + bool enable_caching = true; + bool HasCache() const; Python Classes -------------- diff --git a/docs/source/guides/packages_and_releases.rst b/docs/source/guides/packages_and_releases.rst index dce92f6..63d5bb8 100644 --- a/docs/source/guides/packages_and_releases.rst +++ b/docs/source/guides/packages_and_releases.rst @@ -75,9 +75,10 @@ so no native path configuration is needed. Python wheels ------------- -The Python package is distributed as platform wheels (one per -platform/CPython version, cp310--cp314) built with Bazel and **published to -PyPI**, so consumers just:: +The Python package is distributed as a single stable-ABI (abi3) wheel per +platform, tagged ``cp312-abi3`` and built against CPython's stable ABI so it +runs unchanged on every CPython >= 3.12. Wheels are built with Bazel and +**published to PyPI**, so consumers just:: pip install fastslide @@ -130,7 +131,7 @@ a faithful preview of the real release. To build wheels locally (optionally narrowing the platform/Python matrix):: - python3 tools/build_wheels.py --platform darwin_aarch64 --python cp311 + python3 tools/build_wheels.py --platform darwin_aarch64 # -> artifacts/wheels/*.whl ``publish_java_artifacts.py`` also attaches any wheels in ``artifacts/wheels`` @@ -180,12 +181,12 @@ single aggregate **GitHub Release**: smx["darwin x86_64 (Rosetta) / aarch64"] swx["windows_x86_64 / windows-2022"] end - subgraph bw [build-wheels: cp310-cp314 per platform] + subgraph bw [build-wheels: one cp312-abi3 wheel per platform] wl["linux x86_64/arm64"] wm["darwin x86_64/aarch64"] ww["windows_x86_64"] end - subgraph sw [smoke-wheels: import + open sample, every platform x cp310-cp314] + subgraph sw [smoke-wheels: import + open sample, every platform x cp312-cp314] swl["linux x86_64/arm64"] swm["darwin x86_64 (Rosetta) / aarch64"] sww["windows_x86_64 / windows-2022"] @@ -217,8 +218,10 @@ Why the split: - **Smoke tests always run on the real target runner** (including native Windows). The Java smoke needs only a JDK + the JARs; the wheel smoke (``smoke-wheels``) installs the built wheel into a fresh ``uv`` venv on each - platform x CPython (3.10--3.14), then ``import fastslide`` and opens the - bundled sample (``tools/smoke_test_python.py``). No Bazel -- so both validate + platform x CPython (3.12--3.14), then ``import fastslide`` and opens the + bundled sample (``tools/smoke_test_python.py``). Importing the one abi3 wheel + on every version proves the stable-ABI tag actually loads. No Bazel -- so both + validate the exact artifact a consumer would load. PyPI/TestPyPI publishing ``needs:`` the wheel smoke, so broken wheels never reach an index. diff --git a/include/fastslide/c/registry.h b/include/fastslide/c/registry.h index e9ab672..4417c1a 100644 --- a/include/fastslide/c/registry.h +++ b/include/fastslide/c/registry.h @@ -72,41 +72,6 @@ typedef struct { FASTSLIDE_API FastSlideSlideReader* fastslide_create_reader_with_options( const char* file_path, const FastSlideOpenOptions* options); -/// @brief Create a slide reader with a per-reader LRU tile cache attached. -/// -/// Equivalent to `fastslide_create_reader` followed by -/// `fastslide_slide_reader_set_cache(reader, cache_capacity_bytes)`. A -/// `cache_capacity_bytes` of 0 behaves like `fastslide_create_reader` (no -/// cache). -/// -/// @param file_path Path to slide file -/// @param cache_capacity_bytes Cache capacity in bytes (0 = no cache) -/// @return Slide reader handle or NULL on failure -FASTSLIDE_API FastSlideSlideReader* fastslide_create_reader_with_cache( - const char* file_path, size_t cache_capacity_bytes); - -// Global tile cache - -/// @brief Resize the process-wide global tile cache. -/// -/// Replaces the global cache with a new LRU cache of the requested capacity, -/// dropping any currently cached tiles. Readers attached via -/// `fastslide_slide_reader_use_global_cache` share this cache. -/// -/// @param capacity_bytes New global cache capacity in bytes (must be > 0) -/// @return 1 on success, 0 on failure. -FASTSLIDE_API int fastslide_global_cache_set_capacity_bytes( - size_t capacity_bytes); - -/// @brief Read the global tile cache's statistics. -/// @param out_stats Output statistics (must be non-null) -/// @return 1 on success, 0 on invalid arguments. -FASTSLIDE_API int fastslide_global_cache_get_stats( - FastSlideCacheStats* out_stats); - -/// @brief Clear all tiles from the process-wide global tile cache. -FASTSLIDE_API void fastslide_global_cache_clear(void); - // Utility functions /// @brief Get supported file extensions diff --git a/include/fastslide/c/slide_reader.h b/include/fastslide/c/slide_reader.h index 91e4562..6c24e80 100644 --- a/include/fastslide/c/slide_reader.h +++ b/include/fastslide/c/slide_reader.h @@ -428,64 +428,6 @@ FASTSLIDE_API int fastslide_slide_reader_enable_icc_transform( FastSlideSlideReader* reader, FastSlideColorSpace target_space, FastSlideRenderingIntent intent, int use_lut); -// Tile caching - -/// @brief Snapshot of an internal tile cache's statistics. -/// -/// Mirrors the C++ `fastslide::runtime::ITileCache::Stats` type. `hit_ratio` -/// is in [0, 1]; it is 0 when no lookups have happened yet. -typedef struct { - size_t capacity_bytes; ///< Configured cache capacity in bytes. - size_t size; ///< Number of tiles currently cached. - size_t hits; ///< Cumulative cache hits. - size_t misses; ///< Cumulative cache misses. - double hit_ratio; ///< hits / (hits + misses), or 0 if none. - size_t memory_usage_bytes; ///< Approximate bytes of decoded tile data held. -} FastSlideCacheStats; - -/// @brief Attach a per-reader LRU tile cache (decode reuse) to the reader. -/// -/// The reader caches decoded native tiles so overlapping or repeated -/// `read_region` calls that map to the same tile-grid cell avoid re-decoding. -/// Caching is opt-in: readers created via `fastslide_create_reader` have no -/// cache until this is called. -/// -/// @param reader Slide reader handle -/// @param capacity_bytes Cache capacity in bytes; 0 disables and detaches any -/// existing cache. -/// @return 1 on success, 0 on failure (e.g. allocation failure). -FASTSLIDE_API int fastslide_slide_reader_set_cache(FastSlideSlideReader* reader, - size_t capacity_bytes); - -/// @brief Attach the process-wide global tile cache to the reader. -/// -/// All readers that call this share one cache instance (see -/// `fastslide_global_cache_set_capacity_bytes`). Prefer this when opening many -/// slides that should share a single memory budget. -/// -/// @param reader Slide reader handle -/// @return 1 on success, 0 on failure. -FASTSLIDE_API int fastslide_slide_reader_use_global_cache( - FastSlideSlideReader* reader); - -/// @brief Whether the reader currently has a tile cache attached. -/// @param reader Slide reader handle -/// @return 1 if a cache is attached, 0 otherwise (including null reader). -FASTSLIDE_API int fastslide_slide_reader_is_cache_enabled( - const FastSlideSlideReader* reader); - -/// @brief Clear all tiles from the reader's cache (no-op if none attached). -/// @param reader Slide reader handle -FASTSLIDE_API void fastslide_slide_reader_clear_cache( - FastSlideSlideReader* reader); - -/// @brief Read the reader cache's statistics. -/// @param reader Slide reader handle -/// @param out_stats Output statistics (must be non-null) -/// @return 1 on success, 0 if no cache is attached or on invalid arguments. -FASTSLIDE_API int fastslide_slide_reader_get_cache_stats( - const FastSlideSlideReader* reader, FastSlideCacheStats* out_stats); - // Memory management /// @brief Free slide reader handle diff --git a/include/fastslide/slide_options.h b/include/fastslide/slide_options.h index 727c7f5..ca37f72 100644 --- a/include/fastslide/slide_options.h +++ b/include/fastslide/slide_options.h @@ -16,12 +16,17 @@ #define AIFO_FASTSLIDE_INCLUDE_FASTSLIDE_SLIDE_OPTIONS_H_ #include +#include #include +#include #include "fastslide/utilities/colors.h" namespace fastslide { +// Forward declarations +class TileCache; + /// @brief Color space for image data enum class ColorSpace { kRGB, ///< Standard RGB color space @@ -51,6 +56,7 @@ enum class RenderingIntent { /// Example usage: /// @code /// DependencyBundle deps; +/// deps.tile_cache = std::make_shared(1024 * 1024 * 1024); // 1GB /// deps.background_color = ColorRGB{255, 255, 255}; // White background /// /// SlideOpenOptions options; @@ -59,6 +65,12 @@ enum class RenderingIntent { /// auto reader = registry.CreateReader("slide.mrxs", options); /// @endcode struct DependencyBundle { + /// @brief Optional tile cache for decoded tiles + /// + /// Readers can use this cache to store decoded tiles for faster access. + /// If nullptr, readers should manage their own caching or disable caching. + std::shared_ptr tile_cache; + /// @brief Background color for empty regions /// /// Used when filling regions that don't have tile data (e.g., sparse MRXS @@ -89,16 +101,26 @@ struct DependencyBundle { /// Example usage: /// @code /// SlideOpenOptions options; +/// options.enable_caching = true; +/// options.cache_size_mb = 512; /// options.dependencies.max_threads = 4; /// /// auto reader = SlideReaderRegistry::GetInstance().CreateReader( /// "slide.svs", options); /// @endcode -/// -/// @note Tile caching is configured separately via the reader's `SetCache` -/// (see `fastslide::runtime::LRUTileCache` / -/// `fastslide::runtime::GlobalCacheManager`), not through this struct. struct SlideOpenOptions { + /// @brief Enable internal tile caching + /// + /// If true and no external cache is provided via dependencies, the reader + /// should create its own internal cache. + bool enable_caching = true; + + /// @brief Cache size in megabytes + /// + /// Hint for cache size if the reader creates its own cache. Ignored if + /// an external cache is provided via dependencies. + uint32_t cache_size_mb = 256; + /// @brief Read-only mode /// /// If true, the reader should open files in read-only mode and not attempt diff --git a/meson.build b/meson.build index f60b8fc..0cc5dc7 100644 --- a/meson.build +++ b/meson.build @@ -268,6 +268,23 @@ if get_option('build_python') py = import('python').find_installation(pure : false) nanobind_dep = dependency('nanobind') + # Build against CPython's stable ABI (abi3), floored at 3.12: `limited_api` + # defines Py_LIMITED_API, tags the module `_fastslide.abi3.so`/`.pyd`, and on + # Windows links python3.lib. A single wheel then runs on every CPython >= 3.12. + # + # The nanobind WrapDB dep pulls py.dependency() in transitively, which on + # Windows drags python3XX.lib onto the link line and defeats the limited API + # (Meson's /NODEFAULTLIB only suppresses the pragma-injected default). Keep + # nanobind's sources/includes/compile args, drop its transitive link args; + # nanobind adds none of its own on Windows. Pass py.dependency() explicitly so + # Meson finds it as a direct dep and swaps in the limited-API copy. + if host_machine.system() == 'windows' + nanobind_ext_dep = nanobind_dep.partial_dependency( + compile_args : true, includes : true, sources : true) + else + nanobind_ext_dep = nanobind_dep + endif + py.extension_module('_fastslide', [ 'src/python/fastslide.cpp', @@ -275,8 +292,9 @@ if get_option('build_python') 'src/python/reader.cpp', ], include_directories : inc, - dependencies : [nanobind_dep, fastslide_dep], + dependencies : [py.dependency(), nanobind_ext_dep, fastslide_dep], cpp_args : fastslide_cpp_args, + limited_api : '3.12', install : true, subdir : 'fastslide') @@ -336,12 +354,6 @@ if get_option('build_c_api') cpp_args : fastslide_cpp_args, install : true) endif - - # Dependency object so tests (and other consumers) can link the C API. - fastslide_c_dep = declare_dependency( - include_directories : inc, - link_with : fastslide_c_lib, - dependencies : [fastslide_dep]) endif # --------------------------------------------------------------------------- diff --git a/package/Dockerfile b/package/Dockerfile index e48f178..6389062 100644 --- a/package/Dockerfile +++ b/package/Dockerfile @@ -11,13 +11,6 @@ # docker build --platform linux/amd64 -t fastslide-deb-smoke . FROM ubuntu:24.04 -# Harden apt against transient mirror flakiness. The arm64 image resolves to -# ports.ubuntu.com (amd64 uses archive.ubuntu.com), which intermittently times -# out from CI networks and would otherwise fail the very first apt-get update. -# Retrying each fetch and bounding the connect timeout lets a hiccup self-heal. -RUN printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\nAcquire::https::Timeout "30";\n' \ - > /etc/apt/apt.conf.d/80-fastslide-retries - RUN apt-get update \ && apt-get install -y --no-install-recommends g++ pkg-config ca-certificates \ && rm -rf /var/lib/apt/lists/* diff --git a/pyproject.toml b/pyproject.toml index d2b5ef8..dfd04c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,7 @@ [build-system] -requires = ["meson-python>=0.15.0", "ninja", "nanobind>=2.0.0"] +# meson-python >= 0.17 for robust limited-API (abi3) wheel handling (it refuses +# limited-API + free-threaded builds rather than emitting a broken wheel). +requires = ["meson-python>=0.17.0", "ninja", "nanobind>=2.0.0"] build-backend = "mesonpy" [project] @@ -11,7 +13,9 @@ authors = [ {name = "Jonas Teuwen", email = "j.teuwen@nki.nl"} ] license = {text = "Apache-2.0"} -requires-python = ">=3.10" +# Floored at 3.12: the extension is built against CPython's stable ABI (abi3), +# so a single cp312-abi3 wheel serves every CPython >= 3.12. +requires-python = ">=3.12" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", @@ -19,8 +23,6 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Programming Language :: C++", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", diff --git a/python/BUILD.bazel b/python/BUILD.bazel index e3cfc79..e057cb4 100644 --- a/python/BUILD.bazel +++ b/python/BUILD.bazel @@ -2,18 +2,19 @@ This BUILD file defines: - py_library: The FastSlide Python package with native extension -- versioned_py_wheel: Platform-specific wheels for distribution (Python 3.10-3.13) +- versioned_py_wheel: One platform-specific stable-ABI (abi3) wheel, floored at + CPython 3.12 (tag cp312-abi3), that runs on every CPython >= 3.12. -To build a wheel for a specific Python version and platform: +To build the wheel for a platform: bazelisk build --platforms=//platforms:darwin_aarch64 \\ - //python:fastslide_wheel_cp311 + //python:fastslide_wheel -To build all wheels, use the provided script: - python aifo/fastslide/tools/build_artifacts.py --bazel bazelisk +To build wheels for all platforms, use the provided script: + python aifo/fastslide/tools/build_wheels.py --bazel bazelisk -The wheels are platform-specific because they include native C++ extensions. -Each versioned_py_wheel target uses a Starlark transition to compile the -extension against the correct Python headers for its target version. +The wheels are platform-specific because they include a native C++ extension. +The versioned_py_wheel target uses a Starlark transition to compile the +extension against the CPython 3.12 toolchain and the stable ABI. Built wheels are collected into: aifo/fastslide/artifacts/wheels/ @@ -21,7 +22,7 @@ Built wheels are collected into: load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_library") load("@rules_multirun//:defs.bzl", "multirun") -load("//tools:versioned_py_wheel.bzl", "multi_version_py_wheel") +load("//tools:versioned_py_wheel.bzl", "versioned_py_wheel") # Platform-specific compatibility - supports Linux, macOS, and Windows COMPATIBLE_TARGETS = select({ @@ -51,10 +52,12 @@ FASTSLIDE_VERSION = "0.8.0" # applies a Starlark transition that selects the correct Python toolchain, so # the extension is rebuilt with the correct Python headers for each version. +# The stable-ABI extension (`_fastslide.abi3.so`) is the artifact shipped in the +# cp312-abi3 wheel; CPython recognizes the `.abi3.so` suffix as importable. genrule( name = "copy_extension_so", - srcs = ["//src/python:_fastslide.so"], - outs = ["fastslide/_fastslide.so"], + srcs = ["//src/python:_fastslide.abi3.so"], + outs = ["fastslide/_fastslide.abi3.so"], cmd = "cp $< $@", ) @@ -98,34 +101,30 @@ alias( ) # ============================================================================== -# Build wheels for multiple Python versions +# Build the stable-ABI (abi3) wheel # ============================================================================== -# multi_version_py_wheel applies a Starlark configuration transition per -# Python version, ensuring native extensions are compiled against the matching -# Python headers. - -PYTHON_VERSIONS = { - "3.10": "cp310", - "3.11": "cp311", - "3.12": "cp312", - "3.13": "cp313", - "3.14": "cp314", -} - -multi_version_py_wheel( +# versioned_py_wheel applies a Starlark configuration transition that builds the +# native extension against the CPython 3.12 toolchain and the stable ABI, then +# stamps the wheel `cp312-abi3-`. That single wheel runs on every +# CPython >= 3.12. + +versioned_py_wheel( + name = "fastslide_wheel", + abi = "abi3", author = "Jonas Teuwen", author_email = "j.teuwen@nki.nl", - default_version = "3.11", distribution = "fastslide", homepage = "https://github.com/NKI-AI/fastslide", license = "Apache-2.0", - name_prefix = "fastslide_wheel", platform = WHEEL_PLATFORM, - python_requires = ">=3.10", - python_versions = PYTHON_VERSIONS, + py_limited_api = "cp312", + python_requires = ">=3.12", + python_tag = "cp312", + python_version = "3.12", strip_path_prefixes = ["python/"], summary = "High-performance, thread-safe digital pathology slide reader", target_compatible_with = COMPATIBLE_TARGETS, version = FASTSLIDE_VERSION, + visibility = ["//visibility:public"], deps = [":fastslide"], ) diff --git a/rust/fastslide-sys/src/lib.rs b/rust/fastslide-sys/src/lib.rs index b289838..840958a 100644 --- a/rust/fastslide-sys/src/lib.rs +++ b/rust/fastslide-sys/src/lib.rs @@ -239,18 +239,6 @@ pub struct FastSlideOpenOptions { pub icc_use_lut: c_int, } -/// Tile cache statistics mirroring `FastSlideCacheStats` in `slide_reader.h`. -#[repr(C)] -#[derive(Debug, Clone, Copy)] -pub struct FastSlideCacheStats { - pub capacity_bytes: usize, - pub size: usize, - pub hits: usize, - pub misses: usize, - pub hit_ratio: c_double, - pub memory_usage_bytes: usize, -} - // =========================================================================== // C API // =========================================================================== @@ -273,13 +261,6 @@ unsafe extern "C" { file_path: *const c_char, options: *const FastSlideOpenOptions, ) -> *mut FastSlideSlideReader; - pub fn fastslide_create_reader_with_cache( - file_path: *const c_char, - cache_capacity_bytes: usize, - ) -> *mut FastSlideSlideReader; - pub fn fastslide_global_cache_set_capacity_bytes(capacity_bytes: usize) -> c_int; - pub fn fastslide_global_cache_get_stats(out_stats: *mut FastSlideCacheStats) -> c_int; - pub fn fastslide_global_cache_clear(); pub fn fastslide_registry_get_supported_extensions( registry: *mut FastSlideRegistry, extensions: *mut *mut *mut c_char, @@ -460,23 +441,6 @@ unsafe extern "C" { use_lut: c_int, ) -> c_int; - // ---- slide_reader.h: tile caching ---- - pub fn fastslide_slide_reader_set_cache( - reader: *mut FastSlideSlideReader, - capacity_bytes: usize, - ) -> c_int; - pub fn fastslide_slide_reader_use_global_cache( - reader: *mut FastSlideSlideReader, - ) -> c_int; - pub fn fastslide_slide_reader_is_cache_enabled( - reader: *const FastSlideSlideReader, - ) -> c_int; - pub fn fastslide_slide_reader_clear_cache(reader: *mut FastSlideSlideReader); - pub fn fastslide_slide_reader_get_cache_stats( - reader: *const FastSlideSlideReader, - out_stats: *mut FastSlideCacheStats, - ) -> c_int; - // ---- slide_image.h: per-image (per-series) API ---- pub fn fastslide_slide_image_free(image: *mut FastSlideSlideImage); pub fn fastslide_slide_image_get_level_count(image: *const FastSlideSlideImage) -> c_int; diff --git a/rust/fastslide/src/lib.rs b/rust/fastslide/src/lib.rs index 7bae851..b5143d8 100644 --- a/rust/fastslide/src/lib.rs +++ b/rust/fastslide/src/lib.rs @@ -58,11 +58,8 @@ pub use metadata::{ Bounds, ChannelMetadata, ColorRgb, Coordinate, DataType, Dimensions, ImageFormat, LevelInfo, PlanarConfig, RegionSpec, SlideProperties, StackInfo, }; -pub use reader::{CacheStats, ColorSpace, OpenOptions, RenderingIntent, SlideReader}; -pub use registry::{ - c_api_version, clear_global_cache, global_cache_stats, is_supported, - set_global_cache_capacity, supported_extensions, version, -}; +pub use reader::{ColorSpace, OpenOptions, RenderingIntent, SlideReader}; +pub use registry::{c_api_version, is_supported, supported_extensions, version}; pub use slide_image::SlideImage; #[cfg(test)] @@ -134,37 +131,4 @@ mod tests { assert_eq!(info.z_spacing_um, Some(0.5)); assert_eq!(info.t_interval_s, None); } - - // Reads a 256x256 level-0 region twice and checks the cache serves the - // second read. Skipped unless FASTSLIDE_BENCHMARK_FILE points at a slide. - #[test] - fn cache_reuses_decoded_tiles() { - let Ok(path) = std::env::var("FASTSLIDE_BENCHMARK_FILE") else { - eprintln!("skipping: FASTSLIDE_BENCHMARK_FILE not set"); - return; - }; - - let reader = SlideReader::open_with_cache(&path, 256 << 20) - .expect("open_with_cache should succeed"); - assert!(reader.is_cache_enabled()); - - let region = RegionSpec::new( - Coordinate { x: 0, y: 0 }, - Dimensions { - width: 256, - height: 256, - }, - 0, - ); - - let first = reader.read_region(®ion).expect("first read"); - let second = reader.read_region(®ion).expect("second read"); - assert_eq!(first.data(), second.data(), "cached read must be identical"); - - let stats = reader.cache_stats().expect("cache stats"); - assert!(stats.hits > 0, "repeated read should hit the cache"); - - reader.disable_cache().expect("disable"); - assert!(!reader.is_cache_enabled()); - } } diff --git a/rust/fastslide/src/reader.rs b/rust/fastslide/src/reader.rs index 45b657d..4419633 100644 --- a/rust/fastslide/src/reader.rs +++ b/rust/fastslide/src/reader.rs @@ -149,38 +149,6 @@ impl OpenOptions { } } -/// Statistics for a reader's (or the global) internal tile cache. -/// -/// The analogue of the C++ `fastslide::runtime::ITileCache::Stats`. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct CacheStats { - /// Configured cache capacity in bytes. - pub capacity_bytes: usize, - /// Number of tiles currently cached. - pub size: usize, - /// Cumulative cache hits. - pub hits: usize, - /// Cumulative cache misses. - pub misses: usize, - /// `hits / (hits + misses)`, or `0.0` when there have been no lookups. - pub hit_ratio: f64, - /// Approximate bytes of decoded tile data currently held. - pub memory_usage_bytes: usize, -} - -impl From for CacheStats { - fn from(stats: sys::FastSlideCacheStats) -> Self { - Self { - capacity_bytes: stats.capacity_bytes, - size: stats.size, - hits: stats.hits, - misses: stats.misses, - hit_ratio: stats.hit_ratio, - memory_usage_bytes: stats.memory_usage_bytes, - } - } -} - /// A whole-slide image reader. /// /// Open one with [`SlideReader::open`] (the analogue of the C++ @@ -236,32 +204,6 @@ impl SlideReader { }) } - /// Open a slide file with a per-reader LRU tile cache attached. - /// - /// The reader caches decoded native tiles so overlapping or repeated - /// [`SlideReader::read_region`] calls that map to the same tile-grid cell - /// avoid re-decoding. A `capacity_bytes` of `0` opens without a cache - /// (identical to [`SlideReader::open`]). - /// - /// Initializes the format registry on first use. - pub fn open_with_cache(path: impl AsRef, capacity_bytes: usize) -> Result { - ensure_initialized(); - - let path = path.as_ref(); - let c_path = CString::new(path.to_string_lossy().as_bytes()) - .map_err(|_| Error::new("open_with_cache", "path contains an interior NUL byte"))?; - - // SAFETY: `c_path` is a valid NUL-terminated string for the call. - let ptr = - unsafe { sys::fastslide_create_reader_with_cache(c_path.as_ptr(), capacity_bytes) }; - if ptr.is_null() { - return Err(Error::last("open_with_cache")); - } - Ok(Self { - inner: Arc::new(ReaderHandle { ptr }), - }) - } - fn ptr(&self) -> *const sys::FastSlideSlideReader { self.inner.ptr } @@ -313,65 +255,6 @@ impl SlideReader { Ok(()) } - /// Attach a per-reader LRU tile cache of the given byte capacity. - /// - /// A `capacity_bytes` of `0` detaches any existing cache. Replaces any - /// cache previously attached (including the global cache). - pub fn set_cache(&self, capacity_bytes: usize) -> Result<()> { - let ok = unsafe { sys::fastslide_slide_reader_set_cache(self.inner.ptr, capacity_bytes) }; - if ok == 0 { - return Err(Error::last("set_cache")); - } - Ok(()) - } - - /// Detach any tile cache from this reader. - pub fn disable_cache(&self) -> Result<()> { - self.set_cache(0) - } - - /// Attach the process-wide global tile cache to this reader. - /// - /// All readers sharing the global cache draw from one memory budget; see - /// [`crate::set_global_cache_capacity`]. - pub fn use_global_cache(&self) -> Result<()> { - let ok = unsafe { sys::fastslide_slide_reader_use_global_cache(self.inner.ptr) }; - if ok == 0 { - return Err(Error::last("use_global_cache")); - } - Ok(()) - } - - /// Whether a tile cache is currently attached to this reader. - #[must_use] - pub fn is_cache_enabled(&self) -> bool { - unsafe { sys::fastslide_slide_reader_is_cache_enabled(self.ptr()) != 0 } - } - - /// Clear all tiles from this reader's cache (no-op if none attached). - pub fn clear_cache(&self) { - unsafe { sys::fastslide_slide_reader_clear_cache(self.inner.ptr) }; - } - - /// Statistics for this reader's tile cache, or `None` if no cache is - /// attached. - #[must_use] - pub fn cache_stats(&self) -> Option { - let mut stats = sys::FastSlideCacheStats { - capacity_bytes: 0, - size: 0, - hits: 0, - misses: 0, - hit_ratio: 0.0, - memory_usage_bytes: 0, - }; - let ok = unsafe { sys::fastslide_slide_reader_get_cache_stats(self.ptr(), &mut stats) }; - if ok == 0 { - return None; - } - Some(stats.into()) - } - /// Number of pyramid levels of the primary image. #[must_use] pub fn level_count(&self) -> i32 { diff --git a/rust/fastslide/src/registry.rs b/rust/fastslide/src/registry.rs index fb649b5..bf0565f 100644 --- a/rust/fastslide/src/registry.rs +++ b/rust/fastslide/src/registry.rs @@ -21,8 +21,6 @@ use std::sync::Once; use fastslide_sys as sys; -use crate::error::{Error, Result}; -use crate::reader::CacheStats; use crate::util::{collect_strings, cstr_to_string}; static INIT: Once = Once::new(); @@ -74,39 +72,3 @@ pub fn is_supported(path: impl AsRef) -> bool { }; unsafe { sys::fastslide_is_supported(c_path.as_ptr()) != 0 } } - -/// Resize the process-wide global tile cache. -/// -/// Replaces the global cache with a new LRU cache of the requested capacity, -/// dropping any currently cached tiles. Readers attached via -/// [`crate::SlideReader::use_global_cache`] share this cache. -pub fn set_global_cache_capacity(capacity_bytes: usize) -> Result<()> { - let ok = unsafe { sys::fastslide_global_cache_set_capacity_bytes(capacity_bytes) }; - if ok == 0 { - return Err(Error::last("set_global_cache_capacity")); - } - Ok(()) -} - -/// Statistics for the process-wide global tile cache. -#[must_use] -pub fn global_cache_stats() -> Option { - let mut stats = sys::FastSlideCacheStats { - capacity_bytes: 0, - size: 0, - hits: 0, - misses: 0, - hit_ratio: 0.0, - memory_usage_bytes: 0, - }; - let ok = unsafe { sys::fastslide_global_cache_get_stats(&mut stats) }; - if ok == 0 { - return None; - } - Some(stats.into()) -} - -/// Clear all tiles from the process-wide global tile cache. -pub fn clear_global_cache() { - unsafe { sys::fastslide_global_cache_clear() }; -} diff --git a/src/c/cache_c_api_test.cpp b/src/c/cache_c_api_test.cpp deleted file mode 100644 index 858fc79..0000000 --- a/src/c/cache_c_api_test.cpp +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2025 Jonas Teuwen. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Correctness tests for the C API tile-cache surface. These require a real -// slide, provided via FASTSLIDE_BENCHMARK_FILE; the tests are skipped when it -// is unset so the suite stays hermetic by default. - -#include -#include -#include - -#include "fastslide/c/fastslide.h" -#include "gtest/gtest.h" - -namespace { - -const char* BenchmarkFile() { - return std::getenv("FASTSLIDE_BENCHMARK_FILE"); -} - -// Reads a 256x256 level-0 region and returns its raw bytes, or empty on error. -std::vector ReadRegionBytes(FastSlideSlideReader* reader) { - FastSlideImage* image = fastslide_slide_reader_read_region_coords( - reader, /*x=*/0, /*y=*/0, /*width=*/256, /*height=*/256, /*level=*/0, - /*z=*/0, /*t=*/0); - if (image == nullptr) { - return {}; - } - const uint8_t* data = fastslide_image_get_data(image); - const size_t size = fastslide_image_get_size_bytes(image); - std::vector bytes; - if (data != nullptr && size > 0) { - bytes.assign(data, data + size); - } - fastslide_image_free(image); - return bytes; -} - -class CacheCApiTest : public ::testing::Test { - protected: - void SetUp() override { - if (BenchmarkFile() == nullptr) { - GTEST_SKIP() << "Set FASTSLIDE_BENCHMARK_FILE to a supported slide to " - "run the C API cache tests."; - } - ASSERT_EQ(fastslide_registry_initialize(), 1); - } -}; - -TEST_F(CacheCApiTest, CacheProducesIdenticalPixelsAndHits) { - FastSlideSlideReader* reader = fastslide_create_reader_with_cache( - BenchmarkFile(), /*cache_capacity_bytes=*/static_cast(256) << 20); - ASSERT_NE(reader, nullptr) << fastslide_get_last_error(); - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 1); - - const std::vector first = ReadRegionBytes(reader); - ASSERT_FALSE(first.empty()); - - FastSlideCacheStats after_first{}; - ASSERT_EQ(fastslide_slide_reader_get_cache_stats(reader, &after_first), 1); - EXPECT_GT(after_first.misses, 0u) - << "cold read should populate the cache (misses)"; - - const std::vector second = ReadRegionBytes(reader); - ASSERT_EQ(first.size(), second.size()); - EXPECT_EQ(first, second) << "cached read must be byte-identical"; - - FastSlideCacheStats after_second{}; - ASSERT_EQ(fastslide_slide_reader_get_cache_stats(reader, &after_second), 1); - EXPECT_GT(after_second.hits, 0u) - << "repeated read of the same tiles should hit the cache"; - - fastslide_slide_reader_free(reader); -} - -TEST_F(CacheCApiTest, NoCacheReportsDisabled) { - FastSlideSlideReader* reader = fastslide_create_reader(BenchmarkFile()); - ASSERT_NE(reader, nullptr) << fastslide_get_last_error(); - - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 0); - - FastSlideCacheStats stats{}; - EXPECT_EQ(fastslide_slide_reader_get_cache_stats(reader, &stats), 0) - << "no cache attached: stats query should fail"; - - fastslide_slide_reader_free(reader); -} - -TEST_F(CacheCApiTest, SetCacheZeroDisables) { - FastSlideSlideReader* reader = fastslide_create_reader_with_cache( - BenchmarkFile(), static_cast(64) << 20); - ASSERT_NE(reader, nullptr) << fastslide_get_last_error(); - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 1); - - EXPECT_EQ(fastslide_slide_reader_set_cache(reader, 0), 1); - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 0); - - fastslide_slide_reader_free(reader); -} - -TEST_F(CacheCApiTest, GlobalCacheConfigurable) { - ASSERT_EQ( - fastslide_global_cache_set_capacity_bytes(static_cast(128) << 20), - 1); - fastslide_global_cache_clear(); - - FastSlideSlideReader* reader = fastslide_create_reader(BenchmarkFile()); - ASSERT_NE(reader, nullptr) << fastslide_get_last_error(); - ASSERT_EQ(fastslide_slide_reader_use_global_cache(reader), 1); - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 1); - - (void)ReadRegionBytes(reader); - (void)ReadRegionBytes(reader); - - FastSlideCacheStats global_stats{}; - ASSERT_EQ(fastslide_global_cache_get_stats(&global_stats), 1); - EXPECT_EQ(global_stats.capacity_bytes, static_cast(128) << 20); - - fastslide_slide_reader_free(reader); -} - -} // namespace diff --git a/src/c/registry.cpp b/src/c/registry.cpp index d1948b9..dfa01c6 100644 --- a/src/c/registry.cpp +++ b/src/c/registry.cpp @@ -13,8 +13,6 @@ #include #include -#include "fastslide/runtime/cache_interface.h" -#include "fastslide/runtime/global_cache_manager.h" #include "fastslide/runtime/plugin_loader.h" #include "fastslide/runtime/reader_registry.h" #include "fastslide/slide_reader.h" @@ -176,69 +174,6 @@ FastSlideSlideReader* fastslide_create_reader_with_options( return reader; } -FastSlideSlideReader* fastslide_create_reader_with_cache( - const char* file_path, size_t cache_capacity_bytes) { - FastSlideSlideReader* reader = fastslide_create_reader(file_path); - if (reader == nullptr) { - return nullptr; - } - - if (cache_capacity_bytes != 0) { - if (!fastslide_slide_reader_set_cache(reader, cache_capacity_bytes)) { - // Cache allocation failed; do not hand back a reader without the - // caching the caller explicitly requested. - fastslide_slide_reader_free(reader); - return nullptr; - } - } - - return reader; -} - -// Global tile cache - -namespace { - -FastSlideCacheStats GlobalCacheStatsToC( - const fastslide::runtime::ITileCache::Stats& stats) { - FastSlideCacheStats out; - out.capacity_bytes = stats.capacity_bytes; - out.size = stats.size; - out.hits = stats.hits; - out.misses = stats.misses; - out.hit_ratio = stats.hit_ratio; - out.memory_usage_bytes = stats.memory_usage_bytes; - return out; -} - -} // namespace - -int fastslide_global_cache_set_capacity_bytes(size_t capacity_bytes) { - fastslide_clear_last_error(); - const auto status = - fastslide::runtime::GlobalCacheManager::Instance().SetCapacityBytes( - capacity_bytes); - if (!status.ok()) { - fastslide_set_last_error(std::string(status.message()).c_str()); - return 0; - } - return 1; -} - -int fastslide_global_cache_get_stats(FastSlideCacheStats* out_stats) { - if (out_stats == nullptr) { - fastslide_set_last_error("out_stats cannot be null"); - return 0; - } - *out_stats = GlobalCacheStatsToC( - fastslide::runtime::GlobalCacheManager::Instance().GetStats()); - return 1; -} - -void fastslide_global_cache_clear(void) { - fastslide::runtime::GlobalCacheManager::Instance().Clear(); -} - // Utility functions int fastslide_registry_get_supported_extensions(FastSlideRegistry* registry, diff --git a/src/c/slide_reader.cpp b/src/c/slide_reader.cpp index 103b017..9f39df2 100644 --- a/src/c/slide_reader.cpp +++ b/src/c/slide_reader.cpp @@ -15,9 +15,6 @@ #include #include "fastslide/c/image.h" -#include "fastslide/runtime/cache_interface.h" -#include "fastslide/runtime/global_cache_manager.h" -#include "fastslide/runtime/lru_tile_cache.h" #include "fastslide/slide_reader.h" #include "internal/debug.h" #include "internal/error.h" @@ -942,77 +939,6 @@ int fastslide_slide_reader_enable_icc_transform( return 1; } -namespace { - -FastSlideCacheStats CacheStatsToC( - const fastslide::runtime::ITileCache::Stats& stats) { - FastSlideCacheStats out; - out.capacity_bytes = stats.capacity_bytes; - out.size = stats.size; - out.hits = stats.hits; - out.misses = stats.misses; - out.hit_ratio = stats.hit_ratio; - out.memory_usage_bytes = stats.memory_usage_bytes; - return out; -} - -} // namespace - -int fastslide_slide_reader_set_cache(FastSlideSlideReader* reader, - size_t capacity_bytes) { - FASTSLIDE_REQUIRE_READER(reader, 0); - - if (capacity_bytes == 0) { - reader->reader->SetCache(nullptr); - return 1; - } - - auto cache_or = fastslide::runtime::LRUTileCache::Create(capacity_bytes); - if (!cache_or.ok()) { - SetLastError(std::string(cache_or.status().message()).c_str()); - return 0; - } - reader->reader->SetCache(std::move(cache_or.value())); - return 1; -} - -int fastslide_slide_reader_use_global_cache(FastSlideSlideReader* reader) { - FASTSLIDE_REQUIRE_READER(reader, 0); - reader->reader->SetCache( - fastslide::runtime::GlobalCacheManager::Instance().GetCache()); - return 1; -} - -int fastslide_slide_reader_is_cache_enabled( - const FastSlideSlideReader* reader) { - FASTSLIDE_REQUIRE_READER(reader, 0); - return reader->reader->IsCacheEnabled() ? 1 : 0; -} - -void fastslide_slide_reader_clear_cache(FastSlideSlideReader* reader) { - if (!reader || !reader->reader) { - return; - } - auto cache = reader->reader->GetCache(); - if (cache) { - cache->Clear(); - } -} - -int fastslide_slide_reader_get_cache_stats(const FastSlideSlideReader* reader, - FastSlideCacheStats* out_stats) { - FASTSLIDE_REQUIRE_READER(reader, 0); - FASTSLIDE_REQUIRE_NOT_NULL(out_stats, "out_stats", 0); - - auto cache = reader->reader->GetCache(); - if (!cache) { - SetLastError("reader has no cache attached"); - return 0; - } - *out_stats = CacheStatsToC(cache->GetStats()); - return 1; -} - void fastslide_slide_reader_free(FastSlideSlideReader* reader) { delete reader; } diff --git a/src/python/_fastslide.pyi b/src/python/_fastslide.pyi index 2a11b0e..e243b38 100644 --- a/src/python/_fastslide.pyi +++ b/src/python/_fastslide.pyi @@ -303,11 +303,7 @@ class SlideImages: class FastSlide: @staticmethod - def from_file_path( - file_path: object, - apply_icc: bool = False, - cache: object | None = None, - ) -> FastSlide: + def from_file_path(file_path: object, apply_icc: bool = False) -> FastSlide: """Create FastSlide from file path (accepts str or pathlib.Path) Args: @@ -315,9 +311,6 @@ class FastSlide: apply_icc: When True and the slide has an embedded ICC profile, read_region returns sRGB-corrected pixels (perceptual intent). Slides without a profile are returned unchanged. - cache: Optional tile cache to attach. Accepts an int byte capacity - (a new per-slide LRU cache), a CacheManager, a TileCache, or - None to disable caching. """ @staticmethod @@ -496,7 +489,7 @@ class FastSlide: """Get the best level for a given downsample factor""" def set_cache(self, cache: object) -> None: - """Set cache (accepts int bytes, TileCache, CacheManager, or None to disable).""" + """Set cache (accepts TileCache, CacheManager, or None to disable).""" def get_cache(self) -> TileCache: """Get current cache""" @@ -505,16 +498,6 @@ class FastSlide: def cache_enabled(self) -> bool: """True if caching is enabled""" - def use_global_cache(self) -> None: - """Attach the process-wide global tile cache to this slide.""" - - def clear_cache(self) -> None: - """Clear all tiles from this slide's cache (no-op if none attached).""" - - @property - def cache_stats(self) -> RuntimeCacheStats | None: - """Cache statistics, or None if no cache is attached.""" - def close(self) -> None: """Close the slide reader and release resources""" diff --git a/src/python/fastslide.cpp b/src/python/fastslide.cpp index 99c0ace..c151805 100644 --- a/src/python/fastslide.cpp +++ b/src/python/fastslide.cpp @@ -63,32 +63,6 @@ void ThrowPyErrorFromStatus(const aifocore::Status& status) { throw std::runtime_error(status.ToString()); } -/// @brief Resolve a Python cache argument into an ITileCache. -/// -/// Accepts `None` (no cache), an `int` byte capacity (a new per-slide -/// `LRUTileCache`), a `CacheManager`, or a `TileCache`. Raises on an invalid -/// capacity or unsupported type. -std::shared_ptr ResolveCacheObject( - const nb::object& cache) { - if (cache.is_none()) { - return nullptr; - } - if (nb::isinstance(cache)) { - const auto capacity_bytes = nb::cast(cache); - auto cache_or = fastslide::runtime::LRUTileCache::Create(capacity_bytes); - if (!cache_or.ok()) { - ThrowPyErrorFromStatus(cache_or.status()); - } - return std::move(cache_or.value()); - } - if (nb::isinstance(cache)) { - auto manager = - nb::cast>(cache); - return manager ? manager->GetCache() : nullptr; - } - return nb::cast>(cache); -} - /// @brief Build a zero-copy numpy view of an Image's pixel buffer. /// /// The returned `nb::ndarray` keeps `image_handle` alive via nanobind's @@ -444,8 +418,7 @@ NB_MODULE(_fastslide, m) { nb::class_(m, "FastSlide") .def_static( "from_file_path", - [](const nb::object& file_path, bool apply_icc, - const nb::object& cache) { + [](const nb::object& file_path, bool apply_icc) { std::string path_str; if (nb::isinstance(file_path)) { path_str = nb::cast(file_path); @@ -456,11 +429,7 @@ NB_MODULE(_fastslide, m) { } else { path_str = nb::cast(file_path); } - auto slide = FastSlide::FromFilePath(path_str, apply_icc); - if (slide && !cache.is_none()) { - slide->SetCache(ResolveCacheObject(cache)); - } - return slide; + return FastSlide::FromFilePath(path_str, apply_icc); }, "Create FastSlide from file path (accepts str or pathlib.Path)\n\n" "Args:\n" @@ -468,12 +437,8 @@ NB_MODULE(_fastslide, m) { " apply_icc: When True and the slide has an embedded ICC\n" " profile, read_region returns sRGB-corrected pixels\n" " (perceptual intent). Slides without a profile are\n" - " returned unchanged.\n" - " cache: Optional tile cache to attach. Accepts an int byte\n" - " capacity (a new per-slide LRU cache), a CacheManager, a\n" - " TileCache, or None to disable caching.", - nb::arg("file_path"), nb::arg("apply_icc") = false, - nb::arg("cache").none() = nb::none()) + " returned unchanged.", + nb::arg("file_path"), nb::arg("apply_icc") = false) .def_static("from_uri", &FastSlide::FromUri, "Create FastSlide from URI (future)", nb::arg("uri")) @@ -628,47 +593,30 @@ NB_MODULE(_fastslide, m) { // Cache management. // - // `set_cache` accepts an int byte capacity (a new per-slide LRU cache), - // a `TileCache`, a `CacheManager`, or None. A single entrypoint - // dispatches on argument type so callers do not need to unwrap the - // manager themselves. + // `set_cache` accepts either a `TileCache` or a `CacheManager`. The + // single Python entrypoint dispatches on argument type so callers do + // not need to unwrap the manager themselves. .def( "set_cache", [](FastSlide& self, const nb::object& cache) { - self.SetCache(ResolveCacheObject(cache)); + if (cache.is_none()) { + self.SetCache(nullptr); + return; + } + if (nb::isinstance(cache)) { + auto manager = nb::cast>(cache); + self.SetCache(manager ? manager->GetCache() : nullptr); + return; + } + self.SetCache( + nb::cast>( + cache)); }, - "Set cache (accepts int bytes, TileCache, CacheManager, or None to " - "disable).", + "Set cache (accepts TileCache, CacheManager, or None to disable).", nb::arg("cache").none()) .def("get_cache", &FastSlide::GetCache, "Get current cache") .def_prop_ro("cache_enabled", &FastSlide::IsCacheEnabled, "True if caching is enabled") - .def( - "use_global_cache", - [](FastSlide& self) { - self.SetCache( - fastslide::runtime::GlobalCacheManager::Instance().GetCache()); - }, - "Attach the process-wide global tile cache to this slide.") - .def( - "clear_cache", - [](FastSlide& self) { - if (auto cache = self.GetCache()) { - cache->Clear(); - } - }, - "Clear all tiles from this slide's cache (no-op if none attached).") - .def_prop_ro( - "cache_stats", - [](FastSlide& self) -> nb::object { - auto cache = self.GetCache(); - if (!cache) { - return nb::none(); - } - return nb::cast(cache->GetStats()); - }, - "Cache statistics (RuntimeCacheStats), or None if no cache is " - "attached.") // Resource management .def("close", &FastSlide::Close, diff --git a/tests/meson.build b/tests/meson.build index c7f53c8..f09d0ee 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -68,16 +68,4 @@ if gtest_dep.found() 'FASTSLIDE_DICOM_TESTDATA_DIR' : meson.current_source_dir() / '../src/readers/dicom/testdata', }) - - # C API tile-cache test. Links the C API library; the test itself skips - # unless FASTSLIDE_BENCHMARK_FILE points at a supported slide. - if get_option('build_c_api') - cache_c_api = executable('cache_c_api_test', - '../src/c/cache_c_api_test.cpp', - include_directories : test_inc, - dependencies : [fastslide_c_dep, gtest_dep], - cpp_args : ['-D__ANSI__']) - test('cache_c_api_test', cache_c_api, - env : {'TEST_TMPDIR' : meson.current_build_dir()}) - endif endif diff --git a/tests/python/cache_test.py b/tests/python/cache_test.py index 75933a2..6be73b2 100644 --- a/tests/python/cache_test.py +++ b/tests/python/cache_test.py @@ -683,64 +683,6 @@ def test_multiple_cache_managers_independence(self) -> None: assert new_stats3.capacity_bytes == 300 -class TestFromFilePathCache: - """Test the `from_file_path(cache=...)` kwarg and slide cache accessors. - - These require a real slide, provided via FASTSLIDE_BENCHMARK_FILE, and are - skipped otherwise so the suite stays hermetic by default. - """ - - @pytest.fixture - def slide_path(self) -> str: - import os - - path = os.environ.get("FASTSLIDE_BENCHMARK_FILE") - if not path or not os.path.exists(path): - pytest.skip("Set FASTSLIDE_BENCHMARK_FILE to a supported slide.") - return path - - def test_open_with_int_capacity_caches_reads(self, slide_path: str) -> None: - """`cache=` attaches an LRU cache and repeated reads hit it.""" - import numpy as np - - with fastslide.FastSlide.from_file_path(slide_path, cache=256 << 20) as slide: - assert slide.cache_enabled - assert slide.cache_stats is not None - - region1 = slide.read_region((0, 0), 0, (256, 256)).numpy() - region2 = slide.read_region((0, 0), 0, (256, 256)).numpy() - - assert np.array_equal(region1, region2) - assert slide.cache_stats.hits > 0 - - def test_open_without_cache_reports_disabled(self, slide_path: str) -> None: - """Default open attaches no cache.""" - with fastslide.FastSlide.from_file_path(slide_path) as slide: - assert not slide.cache_enabled - assert slide.cache_stats is None - - def test_clear_cache_resets_entries(self, slide_path: str) -> None: - """`clear_cache` empties the attached cache.""" - with fastslide.FastSlide.from_file_path(slide_path, cache=64 << 20) as slide: - slide.read_region((0, 0), 0, (256, 256)) - assert slide.cache_stats.size > 0 - - slide.clear_cache() - assert slide.cache_stats.size == 0 - - def test_use_global_cache(self, slide_path: str) -> None: - """`use_global_cache` attaches the shared singleton cache.""" - global_cache = fastslide.GlobalCacheManager.instance() - global_cache.set_capacity_bytes(128 << 20) - global_cache.clear() - - with fastslide.FastSlide.from_file_path(slide_path) as slide: - slide.use_global_cache() - assert slide.cache_enabled - slide.read_region((0, 0), 0, (256, 256)) - assert global_cache.get_stats().capacity_bytes == 128 << 20 - - # Test configuration and utilities diff --git a/tools/artifacts/cli.py b/tools/artifacts/cli.py index 12c28e5..04713b5 100644 --- a/tools/artifacts/cli.py +++ b/tools/artifacts/cli.py @@ -5,7 +5,7 @@ import argparse from . import jars, wheels -from .specs import PLATFORMS, PY_TAG_TO_VERSION +from .specs import PLATFORMS def main() -> None: @@ -22,13 +22,6 @@ def main() -> None: choices=sorted(PLATFORMS.keys()), help="Platform(s) to build. Defaults to all supported platforms.", ) - parser.add_argument( - "--python", - action="append", - dest="python_tags", - choices=sorted(PY_TAG_TO_VERSION.keys()), - help="Python tag(s) to build (e.g. cp311). Defaults to all supported tags.", - ) parser.add_argument( "--keep-going", action="store_true", @@ -54,7 +47,6 @@ def main() -> None: args = parser.parse_args() platforms = args.platforms or list(PLATFORMS.keys()) - python_tags = args.python_tags or list(PY_TAG_TO_VERSION.keys()) exit_code = 0 @@ -62,7 +54,6 @@ def main() -> None: wheels_rc = wheels.build_wheels( bazel_cmd=args.bazel, platforms=platforms, - python_tags=python_tags, keep_going=args.keep_going, extra_bazel_args=args.bazel_args, ) diff --git a/tools/artifacts/specs.py b/tools/artifacts/specs.py index bffd1c7..14c5680 100644 --- a/tools/artifacts/specs.py +++ b/tools/artifacts/specs.py @@ -60,10 +60,6 @@ class PlatformSpec: } -PY_TAG_TO_VERSION: dict[str, str] = { - "cp310": "3.10", - "cp311": "3.11", - "cp312": "3.12", - "cp313": "3.13", - "cp314": "3.14", -} +# The Python bindings ship a single stable-ABI (abi3) wheel floored at CPython +# 3.12 (tag cp312-abi3), which runs on every CPython >= 3.12. +ABI3_PY_TAG: str = "cp312" diff --git a/tools/artifacts/wheels.py b/tools/artifacts/wheels.py index 5876eb6..7e8a660 100644 --- a/tools/artifacts/wheels.py +++ b/tools/artifacts/wheels.py @@ -7,7 +7,7 @@ import subprocess from . import common -from .specs import PLATFORMS, PY_TAG_TO_VERSION, PlatformSpec +from .specs import PLATFORMS, PlatformSpec ARTIFACT_DIR = common.WORKSPACE_ROOT / "artifacts" / "wheels" @@ -37,7 +37,6 @@ def build_wheels( *, bazel_cmd: str, platforms: list[str], - python_tags: list[str], keep_going: bool, extra_bazel_args: list[str], ) -> int: @@ -46,6 +45,8 @@ def build_wheels( common.ensure_dir(ARTIFACT_DIR) is_macos = _is_macos_host() + target = "//python:fastslide_wheel" + for platform_key in platforms: if platform_key not in PLATFORMS: raise ValueError(f"Unsupported platform '{platform_key}'. Supported: {', '.join(sorted(PLATFORMS))}") @@ -62,51 +63,39 @@ def build_wheels( extra_bazel_args=extra_bazel_args, ) - for py_tag in python_tags: - if py_tag not in PY_TAG_TO_VERSION: - raise ValueError( - f"Unsupported python tag '{py_tag}'. Supported: {', '.join(sorted(PY_TAG_TO_VERSION))}" - ) - - # Build all Python versions for this platform in a single Bazel - # invocation so Bazel can parallelise the four transitioned configs. - targets = [f"//python:fastslide_wheel_{t}" for t in python_tags] - tags_label = ", ".join(python_tags) - print(f"\n▶︎ Building {tags_label} wheels for {platform_key} with {bazel_cmd}") + # A single stable-ABI (cp312-abi3) wheel per platform. + print(f"\n▶︎ Building cp312-abi3 wheel for {platform_key} with {bazel_cmd}") try: - common.run([bazel_cmd, "build", *bazel_flags, *targets], env=env) + common.run([bazel_cmd, "build", *bazel_flags, target], env=env) except subprocess.CalledProcessError: - for t in python_tags: - failures.append(f"{platform_key}:{t}") + failures.append(platform_key) print(f"❌ Build failed for {platform_key}") if not keep_going: return 1 continue - for py_tag in python_tags: - target = f"//python:fastslide_wheel_{py_tag}" - try: - files = common.cquery_target_files( - bazel_cmd=bazel_cmd, - target=target, - bazel_flags=bazel_flags, - env=env, - ) - copied_any = False - for f in files: - if f.suffix != ".whl": - continue - dst = common.copy_to_dir(f, ARTIFACT_DIR, mode=0o644) - print(f" ✔ {py_tag} -> {dst}") - copied_any = True - if not copied_any: - raise FileNotFoundError(f"No .whl outputs found for {target} (got {len(files)} files)") - except Exception as e: - failures.append(f"{platform_key}:{py_tag}") - print(f" ❌ Error collecting {py_tag} wheel for {platform_key}: {e}") - if not keep_going: - return 1 + try: + files = common.cquery_target_files( + bazel_cmd=bazel_cmd, + target=target, + bazel_flags=bazel_flags, + env=env, + ) + copied_any = False + for f in files: + if f.suffix != ".whl": + continue + dst = common.copy_to_dir(f, ARTIFACT_DIR, mode=0o644) + print(f" ✔ {platform_key} -> {dst}") + copied_any = True + if not copied_any: + raise FileNotFoundError(f"No .whl outputs found for {target} (got {len(files)} files)") + except Exception as e: + failures.append(platform_key) + print(f" ❌ Error collecting wheel for {platform_key}: {e}") + if not keep_going: + return 1 if failures: print("\nCompleted with failures:") diff --git a/tools/build_wheels.py b/tools/build_wheels.py index e83760f..a561ffa 100644 --- a/tools/build_wheels.py +++ b/tools/build_wheels.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -"""Build FastSlide wheels for multiple platforms and Python versions.""" +"""Build the FastSlide stable-ABI (cp312-abi3) wheel for one or more platforms.""" from __future__ import annotations import argparse from artifacts import wheels -from artifacts.specs import PLATFORMS, PY_TAG_TO_VERSION +from artifacts.specs import PLATFORMS def main() -> None: @@ -23,13 +23,6 @@ def main() -> None: choices=sorted(PLATFORMS.keys()), help="Platform(s) to build. Defaults to all supported platforms.", ) - parser.add_argument( - "--python", - action="append", - dest="python_tags", - choices=sorted(PY_TAG_TO_VERSION.keys()), - help="Python tag(s) to build (e.g. cp311). Defaults to all supported tags.", - ) parser.add_argument( "--keep-going", action="store_true", @@ -45,13 +38,11 @@ def main() -> None: args = parser.parse_args() platforms = args.platforms or list(PLATFORMS.keys()) - python_tags = args.python_tags or list(PY_TAG_TO_VERSION.keys()) raise SystemExit( wheels.build_wheels( bazel_cmd=args.bazel, platforms=platforms, - python_tags=python_tags, keep_going=args.keep_going, extra_bazel_args=args.bazel_args, ) diff --git a/tools/versioned_py_wheel.bzl b/tools/versioned_py_wheel.bzl index a5a50fb..16d1319 100644 --- a/tools/versioned_py_wheel.bzl +++ b/tools/versioned_py_wheel.bzl @@ -1,26 +1,27 @@ -"""Versioned py_wheel rule with Python version transitions. +"""Versioned py_wheel rule with a Python version + stable-ABI transition. The standard `py_wheel` rule only stamps the wheel tag (e.g. cp312) but does NOT transition its dependency graph to actually compile native extensions -against the corresponding Python headers. This module provides +against the corresponding Python toolchain. This module provides `versioned_py_wheel`, a macro that wraps `py_wheel` with a Starlark -configuration transition so that the entire dep tree — including cc_binary -targets from pybind_extension / cython_rules — is built against the correct -Python toolchain. +configuration transition so that the entire dep tree — including the +nanobind_extension cc_binary — is built against the correct Python toolchain +AND against the requested stable-ABI (abi3) level. + +Pinning `@nanobind_bazel//:py-limited-api` in the transition (rather than only +in `.bazelrc`) makes the wheel correct even when fastslide is a non-root +dependency in a host workspace whose `.bazelrc` does not set the flag. Usage in a BUILD file: - load("//tools:versioned_py_wheel.bzl", "multi_version_py_wheel") + load("//tools:versioned_py_wheel.bzl", "versioned_py_wheel") - multi_version_py_wheel( - name_prefix = "my_wheel", - python_versions = { - "3.10": "cp310", - "3.11": "cp311", - "3.12": "cp312", - "3.13": "cp313", - "3.14": "cp314", - }, + versioned_py_wheel( + name = "my_wheel", + python_version = "3.12", + python_tag = "cp312", + py_limited_api = "cp312", + abi = "abi3", distribution = "my_package", deps = [":my_lib"], ... @@ -30,12 +31,18 @@ Usage in a BUILD file: load("@rules_python//python:packaging.bzl", "py_wheel") def _python_version_transition_impl(_settings, attr): - return {"@rules_python//python/config_settings:python_version": attr.python_version} + return { + "@rules_python//python/config_settings:python_version": attr.python_version, + "@nanobind_bazel//:py-limited-api": attr.py_limited_api, + } _python_version_transition = transition( implementation = _python_version_transition_impl, inputs = [], - outputs = ["@rules_python//python/config_settings:python_version"], + outputs = [ + "@rules_python//python/config_settings:python_version", + "@nanobind_bazel//:py-limited-api", + ], ) def _versioned_py_wheel_rule_impl(ctx): @@ -47,22 +54,33 @@ _versioned_py_wheel_rule = rule( attrs = { "wheel": attr.label(mandatory = True, cfg = _python_version_transition), "python_version": attr.string(mandatory = True), + "py_limited_api": attr.string(mandatory = True), "_allowlist_function_transition": attr.label( default = "@bazel_tools//tools/allowlists/function_transition_allowlist", ), }, ) -def versioned_py_wheel(*, name, python_version, python_tag, visibility = None, **wheel_kwargs): - """Creates a py_wheel whose entire dep graph is built for the given Python version. +def versioned_py_wheel( + *, + name, + python_version, + python_tag, + py_limited_api, + visibility = None, + **wheel_kwargs): + """Creates a py_wheel whose dep graph is built for a Python version + stable ABI. All keyword arguments except ``name``, ``python_version``, ``python_tag``, - and ``visibility`` are forwarded verbatim to ``py_wheel``. + ``py_limited_api``, and ``visibility`` are forwarded verbatim to ``py_wheel``. Args: name: Target name for the versioned wheel. - python_version: The major.minor Python version (e.g. "3.12"). + python_version: The major.minor Python version to build against + (e.g. "3.12"). python_tag: The wheel python tag (e.g. "cp312"). + py_limited_api: The `@nanobind_bazel//:py-limited-api` value to pin for + the extension build (e.g. "cp312"). visibility: Bazel visibility for the outer target. **wheel_kwargs: Arguments forwarded to py_wheel. """ @@ -79,60 +97,6 @@ def versioned_py_wheel(*, name, python_version, python_tag, visibility = None, * name = name, wheel = ":" + inner_name, python_version = python_version, + py_limited_api = py_limited_api, visibility = visibility, ) - -def multi_version_py_wheel(*, name_prefix, python_versions, default_version = "3.11", visibility = None, **wheel_kwargs): - """Creates versioned_py_wheel targets for multiple Python versions at once. - - This macro generates one ``versioned_py_wheel`` target per entry in - *python_versions*, plus an alias ```` pointing at the - *default_version* wheel, plus ``all_wheels`` / per-version filegroups. - - Because it lives in a ``.bzl`` file, it can accept ``**wheel_kwargs`` - (which BUILD files cannot). - - Args: - name_prefix: Prefix for target names (e.g. "dlup_wheel" → - "dlup_wheel_cp312"). - python_versions: Dict mapping ``"major.minor"`` → ``"cpXYZ"`` tag, - e.g. ``{"3.10": "cp310", "3.11": "cp311", ...}``. - default_version: Which version the ```` alias points to. - visibility: Bazel visibility for the generated targets. - **wheel_kwargs: All remaining keyword arguments are forwarded to every - ``py_wheel`` instantiation (distribution, deps, platform, …). - """ - wheel_targets = [] - default_tag = None - - for version, tag in python_versions.items(): - target_name = "{}_{}".format(name_prefix, tag) - versioned_py_wheel( - name = target_name, - python_version = version, - python_tag = tag, - visibility = visibility, - **wheel_kwargs - ) - wheel_targets.append(target_name) - if version == default_version: - default_tag = tag - - native.alias( - name = name_prefix, - actual = ":{}_{}".format(name_prefix, default_tag or python_versions.values()[0]), - visibility = visibility, - ) - - native.filegroup( - name = "all_wheels", - srcs = [":{}".format(t) for t in wheel_targets], - visibility = visibility, - ) - - for version, tag in python_versions.items(): - native.filegroup( - name = "wheels_python{}".format(version.replace(".", "")), - srcs = [":{}_{}".format(name_prefix, tag)], - visibility = visibility, - )