slide2vec 5.9.0__tar.gz → 5.9.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. {slide2vec-5.9.0 → slide2vec-5.9.2}/PKG-INFO +2 -1
  2. {slide2vec-5.9.0 → slide2vec-5.9.2}/README.md +1 -0
  3. {slide2vec-5.9.0 → slide2vec-5.9.2}/pyproject.toml +2 -2
  4. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/__init__.py +1 -1
  5. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/data/tile_reader.py +7 -7
  6. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/prism.py +2 -0
  7. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/batching.py +24 -15
  8. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/persistence.py +35 -47
  9. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/PKG-INFO +2 -1
  10. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/SOURCES.txt +4 -0
  11. slide2vec-5.9.2/tests/test_benchmark_tooling.py +193 -0
  12. slide2vec-5.9.2/tests/test_inference_benchmark.py +140 -0
  13. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_pooled_geometry.py +33 -0
  14. slide2vec-5.9.2/tests/test_prism.py +52 -0
  15. slide2vec-5.9.2/tests/test_process_list_performance.py +54 -0
  16. slide2vec-5.9.2/tests/test_runtime_batching.py +117 -0
  17. slide2vec-5.9.0/tests/test_runtime_batching.py +0 -53
  18. {slide2vec-5.9.0 → slide2vec-5.9.2}/LICENSE +0 -0
  19. {slide2vec-5.9.0 → slide2vec-5.9.2}/setup.cfg +0 -0
  20. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/__main__.py +0 -0
  21. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/api.py +0 -0
  22. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/artifacts.py +0 -0
  23. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/cli.py +0 -0
  24. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/configs/__init__.py +0 -0
  25. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/configs/default.yaml +0 -0
  26. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/configs/resources.py +0 -0
  27. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/data/__init__.py +0 -0
  28. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/data/dataset.py +0 -0
  29. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/data/tile_store.py +0 -0
  30. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/__init__.py +0 -0
  31. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/dense_image_worker.py +0 -0
  32. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/dense_worker.py +0 -0
  33. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/direct_embed_worker.py +0 -0
  34. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/image_worker.py +0 -0
  35. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/pipeline_worker.py +0 -0
  36. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/worker_entry.py +0 -0
  37. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/__init__.py +0 -0
  38. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/base.py +0 -0
  39. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/__init__.py +0 -0
  40. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/conch.py +0 -0
  41. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/dinov2.py +0 -0
  42. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/genbio.py +0 -0
  43. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/gigapath.py +0 -0
  44. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/gpfm.py +0 -0
  45. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/hibou.py +0 -0
  46. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/hoptimus.py +0 -0
  47. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/isight.py +0 -0
  48. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/lunit.py +0 -0
  49. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/midnight.py +0 -0
  50. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/__init__.py +0 -0
  51. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/blocks.py +0 -0
  52. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/case.py +0 -0
  53. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/loading.py +0 -0
  54. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/slide.py +0 -0
  55. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/types.py +0 -0
  56. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/mstar.py +0 -0
  57. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/musk.py +0 -0
  58. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/phikon.py +0 -0
  59. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/prism2.py +0 -0
  60. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/prost40m.py +0 -0
  61. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/rudolfv2.py +0 -0
  62. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/titan.py +0 -0
  63. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/uni.py +0 -0
  64. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/virchow.py +0 -0
  65. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/waiv.py +0 -0
  66. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/registry.py +0 -0
  67. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/validation.py +0 -0
  68. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/inference.py +0 -0
  69. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/progress.py +0 -0
  70. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/__init__.py +0 -0
  71. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/artifacts_collect.py +0 -0
  72. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/cpu_budget.py +0 -0
  73. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_encode.py +0 -0
  74. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_encoder_input.py +0 -0
  75. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_image_reading.py +0 -0
  76. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_image_recipe.py +0 -0
  77. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_image_shard.py +0 -0
  78. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_image_stage.py +0 -0
  79. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_regions.py +0 -0
  80. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_shard.py +0 -0
  81. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_sliding.py +0 -0
  82. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_stage.py +0 -0
  83. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/distributed.py +0 -0
  84. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/distributed_stage.py +0 -0
  85. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/effective_encoder_input.py +0 -0
  86. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/embedding.py +0 -0
  87. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/embedding_persist.py +0 -0
  88. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/embedding_pipeline.py +0 -0
  89. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/encoder_input_contract.py +0 -0
  90. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/hierarchical.py +0 -0
  91. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/image_shard.py +0 -0
  92. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/image_specs.py +0 -0
  93. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/image_stage.py +0 -0
  94. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/manifest.py +0 -0
  95. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/model_settings.py +0 -0
  96. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/patient_pipeline.py +0 -0
  97. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/persist_callbacks.py +0 -0
  98. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/pooled_encoder_input.py +0 -0
  99. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/preprocessing.py +0 -0
  100. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/process_list.py +0 -0
  101. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/progress_bridge.py +0 -0
  102. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/registry.py +0 -0
  103. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/serialization.py +0 -0
  104. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/sharding.py +0 -0
  105. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/slide_encode.py +0 -0
  106. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/tiling.py +0 -0
  107. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/tiling_pipeline.py +0 -0
  108. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/types.py +0 -0
  109. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/worker_io.py +0 -0
  110. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/__init__.py +0 -0
  111. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/config.py +0 -0
  112. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/coordinates.py +0 -0
  113. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/log_utils.py +0 -0
  114. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/tiling_io.py +0 -0
  115. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/utils.py +0 -0
  116. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/dependency_links.txt +0 -0
  117. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/entry_points.txt +0 -0
  118. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/not-zip-safe +0 -0
  119. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/requires.txt +0 -0
  120. {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/top_level.txt +0 -0
  121. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_architecture_runtime_split.py +0 -0
  122. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_attention_extraction.py +0 -0
  123. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_encode_kit.py +0 -0
  124. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_encoder_input.py +0 -0
  125. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_extraction.py +0 -0
  126. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_image_reading.py +0 -0
  127. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_image_resume.py +0 -0
  128. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_image_shard.py +0 -0
  129. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_image_stage.py +0 -0
  130. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_regions.py +0 -0
  131. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_shard.py +0 -0
  132. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_sliding.py +0 -0
  133. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_source_spacing.py +0 -0
  134. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_stage.py +0 -0
  135. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_worker.py +0 -0
  136. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dinov2_natimage.py +0 -0
  137. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_capabilities.py +0 -0
  138. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_input_contract.py +0 -0
  139. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_plugins.py +0 -0
  140. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_provider_failures.py +0 -0
  141. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_registry.py +0 -0
  142. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_gpfm_genbio_heavy.py +0 -0
  143. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_hs2p_package_cutover.py +0 -0
  144. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_image_shard.py +0 -0
  145. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_image_stage.py +0 -0
  146. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_isight.py +0 -0
  147. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_load_model_hf_auth.py +0 -0
  148. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_mascaret.py +0 -0
  149. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_on_slide_persisted.py +0 -0
  150. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_output_consistency.py +0 -0
  151. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_patch_size_metadata.py +0 -0
  152. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_patient_manifest.py +0 -0
  153. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_phaet.py +0 -0
  154. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_pooled_encoder_input.py +0 -0
  155. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_prism2.py +0 -0
  156. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_progress.py +0 -0
  157. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_regression_core.py +0 -0
  158. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_regression_inference.py +0 -0
  159. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_regression_models.py +0 -0
  160. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_rudolfv2.py +0 -0
  161. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_sharding.py +0 -0
  162. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_slide_coordinate_preparation.py +0 -0
  163. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_soma_migration.py +0 -0
  164. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_tile_store.py +0 -0
  165. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_tiling_pipeline.py +0 -0
  166. {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_titan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide2vec
3
- Version: 5.9.0
3
+ Version: 5.9.2
4
4
  Summary: Embedding of whole slide images with Foundation Models
5
5
  Author-email: Clément Grisi <clement.grisi@radboudumc.nl>
6
6
  License-Expression: Apache-2.0
@@ -276,4 +276,5 @@ docker run --rm -it \
276
276
  - [API guide](https://clemsgrs.github.io/slide2vec/api.html)
277
277
  - [CLI guide](https://clemsgrs.github.io/slide2vec/cli.html)
278
278
  - [Model zoo](https://clemsgrs.github.io/slide2vec/models.html)
279
+ - [Performance benchmarks and QA](docs/performance.md)
279
280
  - [`tutorials/api_walkthrough.ipynb`](tutorials/api_walkthrough.ipynb) for a notebook walkthrough of the API
@@ -160,4 +160,5 @@ docker run --rm -it \
160
160
  - [API guide](https://clemsgrs.github.io/slide2vec/api.html)
161
161
  - [CLI guide](https://clemsgrs.github.io/slide2vec/cli.html)
162
162
  - [Model zoo](https://clemsgrs.github.io/slide2vec/models.html)
163
+ - [Performance benchmarks and QA](docs/performance.md)
163
164
  - [`tutorials/api_walkthrough.ipynb`](tutorials/api_walkthrough.ipynb) for a notebook walkthrough of the API
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slide2vec"
7
- version = "5.9.0"
7
+ version = "5.9.2"
8
8
  description = "Embedding of whole slide images with Foundation Models"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -180,7 +180,7 @@ no_implicit_reexport = true
180
180
  max-line-length = 160
181
181
 
182
182
  [tool.bumpver]
183
- current_version = "5.9.0"
183
+ current_version = "5.9.2"
184
184
  version_pattern = "MAJOR.MINOR.PATCH"
185
185
  commit = false
186
186
  tag = false
@@ -27,7 +27,7 @@ from slide2vec.encoders import (
27
27
  )
28
28
 
29
29
 
30
- __version__ = "5.9.0"
30
+ __version__ = "5.9.2"
31
31
 
32
32
  __all__ = [
33
33
  "Model",
@@ -510,14 +510,14 @@ def _unfold_region_tensor_uint8(region_tensor: torch.Tensor, tile_size: int) ->
510
510
  return torch.empty((0, 0, 3, tile_size, tile_size), dtype=torch.uint8)
511
511
  if int(region_tensor.shape[-1]) % tile_size != 0 or int(region_tensor.shape[-2]) % tile_size != 0:
512
512
  raise ValueError("Region tensor dimensions must be divisible by the tile size")
513
- unfolded = torch.nn.functional.unfold(
514
- region_tensor.to(torch.float32),
515
- kernel_size=tile_size,
516
- stride=tile_size,
513
+ batch, channels, height, width = region_tensor.shape
514
+ # Disjoint tiles need only a layout change. Keep pixels as bytes instead of
515
+ # materializing float regions and im2col buffers, then converting them back.
516
+ return (
517
+ region_tensor.reshape(batch, channels, height // tile_size, tile_size, width // tile_size, tile_size)
518
+ .permute(0, 2, 4, 1, 3, 5)
519
+ .reshape(batch, -1, channels, tile_size, tile_size)
517
520
  )
518
- unfolded = unfolded.transpose(1, 2)
519
- reshaped = unfolded.reshape(region_tensor.shape[0], -1, region_tensor.shape[1], tile_size, tile_size)
520
- return reshaped.round().clamp(0, 255).to(torch.uint8)
521
521
 
522
522
 
523
523
  def _area_resize_tile_batch(
@@ -46,5 +46,7 @@ class PrismSlideEncoder(SlideEncoder):
46
46
  ) -> torch.Tensor:
47
47
  if tile_features.ndim == 2:
48
48
  tile_features = tile_features.unsqueeze(0)
49
+ # Stored feature precision can differ from PRISM's weights; CPU has no autocast.
50
+ tile_features = tile_features.to(dtype=self._model.dtype)
49
51
  reprs = self._model.slide_representations(tile_features)
50
52
  return reprs["image_embedding"].squeeze(0)
@@ -110,7 +110,8 @@ class BatchPrefetcher:
110
110
  self.loaded = loaded
111
111
  self.batch_preprocessor = batch_preprocessor
112
112
  self.copy_stream = self._make_copy_stream()
113
- self._pinned_host_buffer = None
113
+ self.defer_preload = self.copy_stream is not None and batch_preprocessor is not None
114
+ self._exhausted = False
114
115
  self._next_batch: PreparedBatch | None = None
115
116
  self._preload()
116
117
 
@@ -132,18 +133,10 @@ class BatchPrefetcher:
132
133
  return image
133
134
  if image.device.type != "cpu" or image.is_pinned():
134
135
  return image
135
- if (
136
- self._pinned_host_buffer is None
137
- or tuple(self._pinned_host_buffer.shape) != tuple(image.shape)
138
- or self._pinned_host_buffer.dtype != image.dtype
139
- ):
140
- self._pinned_host_buffer = torch.empty(
141
- image.shape,
142
- dtype=image.dtype,
143
- pin_memory=True,
144
- )
145
- self._pinned_host_buffer.copy_(image)
146
- return self._pinned_host_buffer
136
+ # A shared staging buffer could be overwritten by the next preload while
137
+ # its asynchronous H2D copy is still pending. PyTorch tracks the lifetime
138
+ # of each pinned allocation until the transfer completes.
139
+ return image.pin_memory()
147
140
 
148
141
  def _prepare_batch(self, image):
149
142
  preprocess_start = time.perf_counter()
@@ -160,10 +153,13 @@ class BatchPrefetcher:
160
153
  return prepared, preprocess_ms
161
154
 
162
155
  def _preload(self) -> None:
156
+ if self._exhausted or self._next_batch is not None:
157
+ return
163
158
  wait_start = time.perf_counter()
164
159
  try:
165
160
  batch = next(self.iterator)
166
161
  except StopIteration:
162
+ self._exhausted = True
167
163
  self._next_batch = None
168
164
  return
169
165
  loader_wait_ms = (time.perf_counter() - wait_start) * 1000.0
@@ -206,6 +202,7 @@ class BatchPrefetcher:
206
202
  return self
207
203
 
208
204
  def __next__(self) -> PreparedBatch:
205
+ self._preload()
209
206
  if self._next_batch is None:
210
207
  raise StopIteration
211
208
  current = self._next_batch
@@ -213,8 +210,12 @@ class BatchPrefetcher:
213
210
  ready_start = time.perf_counter()
214
211
  current_stream = torch.cuda.current_stream(device=self.loaded.device)
215
212
  current_stream.wait_stream(self.copy_stream)
213
+ if torch.is_tensor(current.image) and current.image.is_cuda:
214
+ current.image.record_stream(current_stream)
216
215
  current.ready_wait_ms = (time.perf_counter() - ready_start) * 1000.0
217
- self._preload()
216
+ self._next_batch = None
217
+ if not self.defer_preload:
218
+ self._preload()
218
219
  return current
219
220
 
220
221
 
@@ -254,8 +255,16 @@ def iter_forward_batches(
254
255
  image = prepared_batch.image
255
256
  _record_encoder_input_size(loaded, image)
256
257
  forward_start = time.perf_counter()
257
- embedding = loaded.model.encode_tiles(image).detach().cpu()
258
+ embedding = loaded.model.encode_tiles(image).detach()
258
259
  forward_ms = (time.perf_counter() - forward_start) * 1000.0
260
+ if prefetcher.defer_preload:
261
+ # Launch CUDA work before blocking on the next reader batch. CPU
262
+ # and itemwise transforms retain their original execution order.
263
+ prefetcher._preload()
264
+ result_start = time.perf_counter()
265
+ embedding = embedding.cpu()
266
+ # Residual host time excludes the overlapping preload, not GPU time.
267
+ forward_ms += (time.perf_counter() - result_start) * 1000.0
259
268
  current_indices = torch.as_tensor(prepared_batch.indices, dtype=torch.long).detach().cpu()
260
269
  processed += int(embedding.shape[0])
261
270
  batch_index += 1
@@ -230,44 +230,42 @@ def update_process_list_after_embedding(
230
230
  feature_success_ids = {slide.sample_id for slide in successful_slides}
231
231
  feature_success_keys = set(feature_path_by_key)
232
232
  annotation_aware = any(annotation is not None for _, annotation in feature_success_keys)
233
+ successful_ids = {slide.sample_id for slide in successful_slides}
233
234
  row_annotations = _row_annotation_series(df)
234
- for slide in successful_slides:
235
- mask = df["sample_id"].astype(str) == slide.sample_id
236
- if annotation_aware:
237
- # Resolve each (sample_id, annotation) row independently so per-class paths and
238
- # statuses don't bleed across the slide's other annotation rows. An incremental
239
- # update may contain only one finished annotation, so unmatched siblings stay
240
- # untouched until their own artifact is available.
241
- for annotation in _row_annotations(row_annotations, mask):
242
- # ``== None`` is element-wise False in pandas, so match the flat None key
243
- # via isna() and real classes via equality.
244
- if annotation is None:
245
- row_mask = mask & row_annotations.isna()
246
- else:
247
- row_mask = mask & (row_annotations == annotation)
248
- key = (slide.sample_id, annotation)
249
- mapped_feature_path = feature_path_by_key.get(key)
250
- if mapped_feature_path is not None:
251
- df.loc[row_mask, "feature_status"] = "success"
252
- df.loc[row_mask, "feature_path"] = mapped_feature_path
253
- df.loc[row_mask, "encoder_name"] = encoder_name
254
- df.loc[row_mask, "output_variant"] = output_variant
255
- df.loc[row_mask, "feature_kind"] = feature_kind
256
- if include_slide_embeddings and key in slide_success_keys:
257
- df.loc[row_mask, "aggregation_status"] = "success"
258
- else:
259
- feature_status = "success" if slide.sample_id in feature_success_ids else "error"
260
- df.loc[mask, "feature_status"] = feature_status
261
- mapped_feature_path = feature_path_by_key.get((slide.sample_id, None))
262
- if mapped_feature_path is not None:
263
- df.loc[mask, "feature_path"] = mapped_feature_path
264
- df.loc[mask, "encoder_name"] = encoder_name
265
- df.loc[mask, "output_variant"] = output_variant
266
- df.loc[mask, "feature_kind"] = feature_kind
267
- if include_slide_embeddings:
268
- df.loc[mask, "aggregation_status"] = (
269
- "success" if slide.sample_id in slide_success_ids else "error"
270
- )
235
+ status_rows, statuses = [], []
236
+ feature_rows, feature_paths = [], []
237
+ aggregation_rows, aggregation_statuses = [], []
238
+ # Visit each CSV row once, including duplicate rows. The old per-slide masks
239
+ # repeatedly converted/scanned the whole table during every checkpoint flush.
240
+ for index, sample_id, annotation in zip(df.index, df["sample_id"].astype(str), row_annotations):
241
+ if sample_id not in successful_ids:
242
+ continue
243
+ key = (sample_id, _normalized_annotation(annotation) if annotation_aware else None)
244
+ mapped_feature_path = feature_path_by_key.get(key)
245
+ if not annotation_aware or mapped_feature_path is not None:
246
+ status_rows.append(index)
247
+ statuses.append("success" if sample_id in feature_success_ids else "error")
248
+ if mapped_feature_path is not None:
249
+ feature_rows.append(index)
250
+ feature_paths.append(mapped_feature_path)
251
+ # An incremental annotation update leaves unfinished sibling classes alone.
252
+ if include_slide_embeddings and (not annotation_aware or key in slide_success_keys):
253
+ aggregation_rows.append(index)
254
+ aggregation_statuses.append("success" if sample_id in slide_success_ids else "error")
255
+
256
+ if status_rows:
257
+ df.loc[status_rows, "feature_status"] = statuses
258
+ if feature_rows:
259
+ # Empty CSV columns are inferred as floats. Explicit object columns also
260
+ # allow provenance to be filled on pandas versions that reject upcasting.
261
+ for column in ("feature_path", "encoder_name", "output_variant", "feature_kind"):
262
+ df[column] = df[column].astype(object)
263
+ df.loc[feature_rows, "feature_path"] = feature_paths
264
+ df.loc[feature_rows, "encoder_name"] = encoder_name
265
+ df.loc[feature_rows, "output_variant"] = output_variant
266
+ df.loc[feature_rows, "feature_kind"] = feature_kind
267
+ if aggregation_rows:
268
+ df.loc[aggregation_rows, "aggregation_status"] = aggregation_statuses
271
269
  atomic_write_dataframe_csv(df, process_list_path)
272
270
 
273
271
 
@@ -291,13 +289,3 @@ def _row_annotation_series(df: pd.DataFrame) -> pd.Series:
291
289
  if "annotation" not in df.columns:
292
290
  return pd.Series([np.nan] * len(df), index=df.index, dtype=object)
293
291
  return df["annotation"].map(lambda value: _normalized_annotation(value) or np.nan)
294
-
295
-
296
- def _row_annotations(row_annotations: pd.Series, mask: pd.Series) -> list[str | None]:
297
- """Distinct normalized annotations present in the masked rows (``None`` for flat rows)."""
298
- seen: list[str | None] = []
299
- for value in row_annotations[mask].tolist():
300
- normalized = None if value is None or (isinstance(value, float) and pd.isna(value)) else value
301
- if normalized not in seen:
302
- seen.append(normalized)
303
- return seen
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide2vec
3
- Version: 5.9.0
3
+ Version: 5.9.2
4
4
  Summary: Embedding of whole slide images with Foundation Models
5
5
  Author-email: Clément Grisi <clement.grisi@radboudumc.nl>
6
6
  License-Expression: Apache-2.0
@@ -276,4 +276,5 @@ docker run --rm -it \
276
276
  - [API guide](https://clemsgrs.github.io/slide2vec/api.html)
277
277
  - [CLI guide](https://clemsgrs.github.io/slide2vec/cli.html)
278
278
  - [Model zoo](https://clemsgrs.github.io/slide2vec/models.html)
279
+ - [Performance benchmarks and QA](docs/performance.md)
279
280
  - [`tutorials/api_walkthrough.ipynb`](tutorials/api_walkthrough.ipynb) for a notebook walkthrough of the API
@@ -111,6 +111,7 @@ slide2vec/utils/tiling_io.py
111
111
  slide2vec/utils/utils.py
112
112
  tests/test_architecture_runtime_split.py
113
113
  tests/test_attention_extraction.py
114
+ tests/test_benchmark_tooling.py
114
115
  tests/test_dense_encode_kit.py
115
116
  tests/test_dense_encoder_input.py
116
117
  tests/test_dense_extraction.py
@@ -134,6 +135,7 @@ tests/test_gpfm_genbio_heavy.py
134
135
  tests/test_hs2p_package_cutover.py
135
136
  tests/test_image_shard.py
136
137
  tests/test_image_stage.py
138
+ tests/test_inference_benchmark.py
137
139
  tests/test_isight.py
138
140
  tests/test_load_model_hf_auth.py
139
141
  tests/test_mascaret.py
@@ -144,7 +146,9 @@ tests/test_patient_manifest.py
144
146
  tests/test_phaet.py
145
147
  tests/test_pooled_encoder_input.py
146
148
  tests/test_pooled_geometry.py
149
+ tests/test_prism.py
147
150
  tests/test_prism2.py
151
+ tests/test_process_list_performance.py
148
152
  tests/test_progress.py
149
153
  tests/test_regression_core.py
150
154
  tests/test_regression_inference.py
@@ -0,0 +1,193 @@
1
+ """Benchmark configuration stays compatible with the public pipeline API."""
2
+
3
+ import importlib
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+
10
+ @pytest.mark.parametrize("module_name,builder,reuses_coordinates", [
11
+ ("benchmark_end_to_end_paths", "_build_pipeline_from_config_dict", False),
12
+ ("benchmark_tile_read_strategies", "_build_pipeline_from_config_dict", True),
13
+ ("benchmark_embedding_throughput", "_build_model_pipeline_from_config", True),
14
+ ])
15
+ def test_benchmark_config_preserves_requested_pipeline_settings(module_name, builder, reuses_coordinates):
16
+ module = importlib.import_module(f"scripts.{module_name}")
17
+ pipeline = getattr(module, builder)({
18
+ "output_dir": "/tmp/benchmark-config-only",
19
+ "device": "cpu",
20
+ "model": {"name": "phikonv2", "batch_size": 8},
21
+ "speed": {"num_dataloader_workers": 2, "precision": "fp32"},
22
+ "tiling": {
23
+ "backend": "openslide",
24
+ "params": {"requested_spacing_um": 0.5, "requested_tile_size_px": 224},
25
+ "masks": {"min_coverage": {"tissue": 0.3}},
26
+ "preview": {"save": False},
27
+ },
28
+ })
29
+ assert pipeline.execution.batch_size == 8
30
+ assert pipeline.execution.num_workers_per_gpu == 2
31
+ assert pipeline.execution.num_gpus == 1
32
+ assert pipeline.execution.precision == "fp32"
33
+ assert pipeline.preprocessing.backend == "openslide"
34
+ assert pipeline.preprocessing.requested_tile_size_px == 224
35
+ assert pipeline.preprocessing.requested_spacing_um == 0.5
36
+ assert pipeline.preprocessing.masks["min_coverage"]["tissue"] == 0.3
37
+ assert pipeline.preprocessing.preview["save_mask_preview"] is False
38
+ assert pipeline.preprocessing.read_coordinates_from == (
39
+ Path("/tmp/benchmark-config-only/coordinates") if reuses_coordinates else None
40
+ )
41
+
42
+
43
+ @pytest.mark.parametrize("module_name,builder", [
44
+ ("benchmark_end_to_end_paths", "_build_pipeline_from_config_dict"),
45
+ ("benchmark_tile_read_strategies", "_build_pipeline_from_config_dict"),
46
+ ("benchmark_embedding_throughput", "_build_model_pipeline_from_config"),
47
+ ])
48
+ @pytest.mark.parametrize("process_rows", [
49
+ "broken,error,,10\n",
50
+ "broken,success,error,10\n",
51
+ "empty,success,,0\n",
52
+ ])
53
+ def test_benchmark_harness_rejects_failed_or_empty_work(tmp_path, monkeypatch, module_name, builder, process_rows):
54
+ import json
55
+ from types import SimpleNamespace
56
+
57
+ module = importlib.import_module(f"scripts.{module_name}")
58
+ (tmp_path / "process_list.csv").write_text(
59
+ "sample_id,tiling_status,feature_status,num_tiles\n" + process_rows
60
+ )
61
+ config_path = tmp_path / "config.yaml"
62
+ config_path.write_text(f"output_dir: {tmp_path}\ncsv: unused.csv\n")
63
+ pipeline = SimpleNamespace(run=lambda **kwargs: SimpleNamespace(tile_artifacts=[object()], slide_artifacts=[]))
64
+ monkeypatch.setattr(module, builder, lambda config: pipeline)
65
+ args = SimpleNamespace(
66
+ harness_config=config_path, config_file=config_path,
67
+ metrics_json=tmp_path / "metrics.json", progress_jsonl=tmp_path / "progress.jsonl",
68
+ )
69
+
70
+ assert module._run_internal_harness(args) == 1
71
+ metrics = json.loads(args.metrics_json.read_text())
72
+ assert metrics["success"] is False
73
+ assert metrics["tiles_per_second"] == 0.0
74
+
75
+
76
+ def test_benchmark_accepts_completed_hierarchical_artifacts():
77
+ from types import SimpleNamespace
78
+ from scripts.benchmark_common import validate_completed_work
79
+
80
+ validate_completed_work(
81
+ {"failed_slides": 0, "total_tiles": 4},
82
+ SimpleNamespace(tile_artifacts=[], slide_artifacts=[], hierarchical_artifacts=[object()]),
83
+ )
84
+
85
+
86
+ @pytest.mark.skipif(
87
+ os.environ.get("SLIDE2VEC_PERF_SMOKE") != "1",
88
+ reason="Set SLIDE2VEC_PERF_SMOKE=1 for the real WSI reader and CPU encoder smoke test",
89
+ )
90
+ def test_benchmark_real_fixture_cpu_pipeline(tmp_path, monkeypatch):
91
+ """Run real reading, batching, persistence and metrics without pretrained weights."""
92
+ import json
93
+ from types import SimpleNamespace
94
+
95
+ import torch
96
+ from torchvision import transforms
97
+ import yaml
98
+
99
+ from slide2vec.api import Model
100
+ from slide2vec.runtime.types import LoadedModel
101
+ from scripts.benchmark_end_to_end_paths import _run_internal_harness
102
+
103
+ class MeanEncoder:
104
+ encoder = SimpleNamespace(pretrained_cfg={})
105
+
106
+ def encode_tiles(self, image):
107
+ return image.mean(dim=(-2, -1))
108
+
109
+ loaded = LoadedModel(
110
+ name="phikonv2", level="tile", model=MeanEncoder(),
111
+ transforms=transforms.Compose([transforms.ToTensor()]),
112
+ feature_dim=3, device=torch.device("cpu"),
113
+ )
114
+ monkeypatch.setattr(Model, "_load_backend", lambda self: loaded)
115
+ monkeypatch.setattr(Model, "_load_backend_without_transform", lambda self: loaded)
116
+ fixtures = Path(__file__).parent / "fixtures" / "input"
117
+ manifest = tmp_path / "slides.csv"
118
+ manifest.write_text(
119
+ f'sample_id,image_path,mask_path\ntest-wsi,{fixtures / "test-wsi.tif"},{fixtures / "test-mask.tif"}\n'
120
+ )
121
+ config = {
122
+ "csv": str(manifest), "output_dir": str(tmp_path), "device": "cpu",
123
+ "model": {"name": "phikonv2", "batch_size": 32},
124
+ "speed": {"num_dataloader_workers": 0, "num_preprocessing_workers": 1, "num_gpus": 1, "precision": "fp32"},
125
+ "tiling": {
126
+ "backend": "openslide", "mask_backend": "asap", "on_the_fly": True, "use_supertiles": False,
127
+ "params": {"requested_spacing_um": 0.5, "requested_tile_size_px": 224, "tolerance": 0.07},
128
+ "preview": {"save": False},
129
+ },
130
+ }
131
+ config_path = tmp_path / "config.yaml"
132
+ config_path.write_text(yaml.safe_dump(config))
133
+ args = SimpleNamespace(
134
+ harness_config=config_path, metrics_json=tmp_path / "metrics.json", progress_jsonl=tmp_path / "progress.jsonl",
135
+ )
136
+ original_threads = torch.get_num_threads()
137
+ try:
138
+ torch.set_num_threads(1)
139
+ assert _run_internal_harness(args) == 0
140
+ finally:
141
+ torch.set_num_threads(original_threads)
142
+ metrics = json.loads(args.metrics_json.read_text())
143
+ assert metrics["success"] is True
144
+ assert metrics["failed_slides"] == 0
145
+ assert metrics["total_tiles"] == 474
146
+ assert metrics["tile_artifacts"] == 1
147
+ assert metrics["timed_batches"] == 15
148
+ features = torch.load(tmp_path / "tile_embeddings" / "test-wsi.pt", weights_only=True)
149
+ assert features.shape == (474, 3)
150
+ assert bool(torch.isfinite(features).all())
151
+
152
+
153
+ @pytest.mark.parametrize("module_name,builder,expected_workers", [
154
+ ("benchmark_end_to_end_paths", "_build_pipeline_from_config_dict", 2),
155
+ ("benchmark_tile_read_strategies", "_build_pipeline_from_config_dict", 2),
156
+ ("benchmark_embedding_throughput", "_build_model_pipeline_from_config", 7),
157
+ ])
158
+ def test_benchmark_worker_override_beats_legacy_config(module_name, builder, expected_workers):
159
+ module = importlib.import_module(f"scripts.{module_name}")
160
+ pipeline = getattr(module, builder)({
161
+ "output_dir": "/tmp/benchmark-config-only", "device": "cpu",
162
+ "model": {"name": "phikonv2"},
163
+ "speed": {"num_dataloader_workers": 2, "num_workers_embedding": 7},
164
+ })
165
+ assert pipeline.execution.num_workers_per_gpu == expected_workers
166
+
167
+
168
+ def test_runtime_benchmark_checks_expected_pixels_and_rejects_changed_baseline(tmp_path, monkeypatch):
169
+ import hashlib
170
+ import json
171
+ import sys
172
+ from scripts import benchmark_runtime
173
+
174
+ output = tmp_path / 'result.json'
175
+ arguments = ['benchmark_runtime.py', '--case', 'hierarchical', '--regions', '1',
176
+ '--region-size', '4', '--tile-size', '2', '--repeat', '1', '--output', str(output)]
177
+ monkeypatch.setattr(sys, 'argv', arguments)
178
+ benchmark_runtime.main()
179
+ report = json.loads(output.read_text())
180
+ # Four RGB tiles, channel-first inside each tile, row-major across the region.
181
+ expected_pixels = bytes([
182
+ 0, 3, 12, 15, 1, 4, 13, 16, 2, 5, 14, 17,
183
+ 6, 9, 18, 21, 7, 10, 19, 22, 8, 11, 20, 23,
184
+ 24, 27, 36, 39, 25, 28, 37, 40, 26, 29, 38, 41,
185
+ 30, 33, 42, 45, 31, 34, 43, 46, 32, 35, 44, 47,
186
+ ])
187
+ assert report['output_sha256'] == hashlib.sha256(expected_pixels).hexdigest()
188
+ baseline = tmp_path / 'before.json'
189
+ report['output_sha256'] = 'changed-output'
190
+ baseline.write_text(json.dumps(report))
191
+ monkeypatch.setattr(sys, 'argv', [*arguments, '--compare', str(baseline)])
192
+ with pytest.raises(ValueError, match='Baseline output differs'):
193
+ benchmark_runtime.main()
@@ -0,0 +1,140 @@
1
+ """Behavioral checks for the fixed-coordinate inference measurement workflow."""
2
+ from types import SimpleNamespace
3
+
4
+ import pytest
5
+
6
+
7
+ @pytest.mark.parametrize("field,value,message", [
8
+ ("batch_size", 0, "batch_size must be positive"),
9
+ ("modes", ["unknown"], "Unknown inference mode"),
10
+ ("cache_policy", "server-cold", "Unknown cache policy"),
11
+ ])
12
+ def test_inference_benchmark_rejects_invalid_workloads(field, value, message):
13
+ from scripts.benchmark_inference import run_inference_benchmark
14
+
15
+ args = SimpleNamespace(batch_size=2, tile_size=224, repeat=1, warmup=0,
16
+ workers=0, threads=1, modes=["cached"], cache_policy="warm")
17
+ setattr(args, field, value)
18
+ with pytest.raises(ValueError, match=message):
19
+ run_inference_benchmark(args)
20
+
21
+
22
+ def test_inference_modes_preserve_coordinates_batches_and_embeddings(tmp_path, monkeypatch):
23
+ import itertools
24
+ import json
25
+ import numpy as np
26
+ import torch
27
+ from torchvision.transforms import Compose, ToTensor
28
+ import slide2vec.inference
29
+ import slide2vec.data.tile_reader
30
+ from slide2vec.runtime.types import LoadedModel
31
+ from scripts.benchmark_inference import run_inference_benchmark
32
+ import scripts.benchmark_inference as benchmark
33
+
34
+ snapshots = itertools.cycle([
35
+ {"parent_minor_faults": 10, "parent_major_faults": 1,
36
+ "cgroup_memory_psi_us": {"some": 100, "full": 50}, "host_memory_psi_us": None},
37
+ {"parent_minor_faults": 17, "parent_major_faults": 3,
38
+ "cgroup_memory_psi_us": {"some": 120, "full": 55}, "host_memory_psi_us": None},
39
+ ])
40
+ monkeypatch.setattr(benchmark, "_resource_snapshot", lambda: next(snapshots))
41
+
42
+ class Encoder:
43
+ def encode_tiles(self, images):
44
+ return images.mean(dim=(2, 3))
45
+
46
+ class Reader:
47
+ def read_region(self, location, level, size):
48
+ return np.full((size[1], size[0], 3), location[0], dtype=np.uint8)
49
+
50
+ def close(self):
51
+ pass
52
+
53
+ monkeypatch.setattr(slide2vec.inference, "load_model", lambda **kwargs: LoadedModel(
54
+ name="phikonv2", level="tile", model=Encoder(), transforms=Compose([ToTensor()]),
55
+ feature_dim=3, device=torch.device("cpu"),
56
+ ))
57
+ monkeypatch.setattr(slide2vec.data.tile_reader, "_open_wsi_backend", lambda *a: Reader())
58
+ slide = tmp_path / "slide.tif"
59
+ slide.write_bytes(b"mock slide")
60
+ coordinates = tmp_path / "coordinates.npz"
61
+ np.savez(coordinates, x=np.array([255, 0, 255]), y=np.array([0, 0, 224]))
62
+ output = tmp_path / "result.json"
63
+ args = SimpleNamespace(
64
+ model="phikonv2", slide=slide, coordinates=coordinates, tile_size=224,
65
+ batch_size=2, workers=0, backend="openslide", modes=["model-only", "cached", "wsi"],
66
+ repeat=1, warmup=0, threads=1, output=output, profile=False,
67
+ use_supertiles=False, cache_policy="fresh-reader", device="cpu", precision="fp32",
68
+ )
69
+ result = run_inference_benchmark(args)
70
+ assert set(result["modes"]) == {"model-only", "cached", "wsi"}
71
+ expected = torch.tensor([[1., 1., 1.], [0., 0., 0.], [1., 1., 1.]])
72
+ for mode, measured in result["modes"].items():
73
+ assert len(measured["samples_seconds"]) == 1
74
+ assert measured["max_abs_error"] == 0
75
+ assert measured["sample_resources"] == [{
76
+ "parent_minor_faults": 7, "parent_major_faults": 2,
77
+ "cgroup_memory_psi_us": {"some": 20, "full": 5}, "host_memory_psi_us": None,
78
+ }]
79
+ torch.testing.assert_close(torch.load(measured["embeddings_path"], weights_only=True), expected)
80
+ assert json.loads(output.read_text())["parameters"]["num_tiles"] == 3
81
+ args.compare = output
82
+ args.output = tmp_path / "after.json"
83
+ comparison = run_inference_benchmark(args)
84
+ for measured in comparison["modes"].values():
85
+ assert measured["baseline_max_abs_error"] == 0
86
+ assert "speedup" in measured
87
+ baseline_payload = (tmp_path / "result-cached.pt").read_bytes()
88
+ args.output = tmp_path / "result.txt"
89
+ with pytest.raises(ValueError, match="preserve the baseline embeddings"):
90
+ run_inference_benchmark(args)
91
+ assert (tmp_path / "result-cached.pt").read_bytes() == baseline_payload
92
+
93
+
94
+ @pytest.mark.parametrize("available", [True, False])
95
+ def test_resource_snapshot_reads_pressure_totals_or_reports_unavailable(monkeypatch, available):
96
+ import resource
97
+ from pathlib import Path
98
+ from scripts.benchmark_inference import _resource_snapshot
99
+
100
+ def usage(_):
101
+ if not available:
102
+ raise OSError("unavailable")
103
+ return SimpleNamespace(ru_minflt=13, ru_majflt=2)
104
+
105
+ def read(path):
106
+ if not available:
107
+ raise OSError("unavailable")
108
+ if str(path) == "/sys/fs/cgroup/memory.pressure":
109
+ return "some avg10=0.00 avg60=0.00 avg300=0.00 total=123\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=45\n"
110
+ assert str(path) == "/proc/pressure/memory"
111
+ return "some avg10=0.00 total=789\nfull avg10=0.00 total=67\n"
112
+
113
+ monkeypatch.setattr(resource, "getrusage", usage)
114
+ monkeypatch.setattr(Path, "read_text", read)
115
+ assert _resource_snapshot() == {
116
+ "parent_minor_faults": 13 if available else None,
117
+ "parent_major_faults": 2 if available else None,
118
+ "cgroup_memory_psi_us": {"some": 123, "full": 45} if available else None,
119
+ "host_memory_psi_us": {"some": 789, "full": 67} if available else None,
120
+ }
121
+
122
+
123
+ def test_inference_comparison_rejects_different_coordinates_before_model_load(tmp_path):
124
+ import json
125
+ import numpy as np
126
+ from scripts.benchmark_inference import run_inference_benchmark
127
+
128
+ coordinates = tmp_path / "coordinates.npz"
129
+ np.savez(coordinates, x=np.array([0]), y=np.array([0]))
130
+ slide = tmp_path / "slide.tif"
131
+ slide.write_bytes(b"mock slide")
132
+ baseline = tmp_path / "baseline.json"
133
+ baseline.write_text(json.dumps({"parameters": {"coordinates_sha256": "different"}}))
134
+ with pytest.raises(ValueError, match="different parameters"):
135
+ run_inference_benchmark(SimpleNamespace(
136
+ model="never-load-this-model", slide=slide, coordinates=coordinates, tile_size=224,
137
+ batch_size=2, workers=0, backend="openslide", modes=["wsi"],
138
+ repeat=1, warmup=0, threads=1, output=tmp_path / "after.json", profile=False,
139
+ use_supertiles=False, cache_policy="warm", device="cpu", precision="fp32", compare=baseline,
140
+ ))