slide2vec 6.0.0__tar.gz → 6.0.1__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 (173) hide show
  1. {slide2vec-6.0.0 → slide2vec-6.0.1}/PKG-INFO +1 -1
  2. {slide2vec-6.0.0 → slide2vec-6.0.1}/pyproject.toml +3 -3
  3. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/__init__.py +5 -1
  4. slide2vec-6.0.1/slide2vec/_cufile.py +21 -0
  5. slide2vec-6.0.1/slide2vec/cufile.json +5 -0
  6. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/distributed/__init__.py +17 -2
  7. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/distributed/direct_embed_worker.py +1 -1
  8. slide2vec-6.0.1/slide2vec/distributed/pin_gpu.py +47 -0
  9. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/distributed/pipeline_worker.py +1 -1
  10. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/distributed/worker_entry.py +14 -5
  11. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_image_stage.py +1 -0
  12. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_stage.py +1 -0
  13. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/distributed.py +19 -3
  14. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/distributed_stage.py +2 -0
  15. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/image_stage.py +1 -0
  16. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec.egg-info/PKG-INFO +1 -1
  17. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec.egg-info/SOURCES.txt +6 -1
  18. slide2vec-6.0.1/tests/test_cufile_compat_mode.py +50 -0
  19. slide2vec-6.0.1/tests/test_worker_gpu_pinning.py +234 -0
  20. {slide2vec-6.0.0 → slide2vec-6.0.1}/LICENSE +0 -0
  21. {slide2vec-6.0.0 → slide2vec-6.0.1}/README.md +0 -0
  22. {slide2vec-6.0.0 → slide2vec-6.0.1}/setup.cfg +0 -0
  23. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/__main__.py +0 -0
  24. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/api.py +0 -0
  25. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/artifacts.py +0 -0
  26. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/cli.py +0 -0
  27. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/configs/__init__.py +0 -0
  28. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/configs/default.yaml +0 -0
  29. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/configs/resources.py +0 -0
  30. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/data/__init__.py +0 -0
  31. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/data/dataset.py +0 -0
  32. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/data/tile_reader.py +0 -0
  33. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/data/tile_store.py +0 -0
  34. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/distributed/dense_image_worker.py +0 -0
  35. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/distributed/dense_worker.py +0 -0
  36. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/distributed/image_worker.py +0 -0
  37. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/__init__.py +0 -0
  38. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/base.py +0 -0
  39. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/__init__.py +0 -0
  40. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/conch.py +0 -0
  41. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/dinov2.py +0 -0
  42. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/dinov3.py +0 -0
  43. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/genbio.py +0 -0
  44. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/gigapath.py +0 -0
  45. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/gpfm.py +0 -0
  46. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/hibou.py +0 -0
  47. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/hoptimus.py +0 -0
  48. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/isight.py +0 -0
  49. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/lunit.py +0 -0
  50. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/midnight.py +0 -0
  51. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/moozy/__init__.py +0 -0
  52. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/moozy/blocks.py +0 -0
  53. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/moozy/case.py +0 -0
  54. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/moozy/loading.py +0 -0
  55. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/moozy/slide.py +0 -0
  56. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/moozy/types.py +0 -0
  57. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/mstar.py +0 -0
  58. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/musk.py +0 -0
  59. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/phikon.py +0 -0
  60. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/prism.py +0 -0
  61. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/prism2.py +0 -0
  62. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/prost40m.py +0 -0
  63. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/rudolfv2.py +0 -0
  64. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/titan.py +0 -0
  65. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/uni.py +0 -0
  66. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/virchow.py +0 -0
  67. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/models/waiv.py +0 -0
  68. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/registry.py +0 -0
  69. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/encoders/validation.py +0 -0
  70. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/inference.py +0 -0
  71. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/progress.py +0 -0
  72. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/__init__.py +0 -0
  73. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/artifacts_collect.py +0 -0
  74. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/batching.py +0 -0
  75. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/cpu_budget.py +0 -0
  76. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_encode.py +0 -0
  77. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_encoder_input.py +0 -0
  78. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_image_reading.py +0 -0
  79. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_image_recipe.py +0 -0
  80. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_image_shard.py +0 -0
  81. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_regions.py +0 -0
  82. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_shard.py +0 -0
  83. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/dense_sliding.py +0 -0
  84. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/effective_encoder_input.py +0 -0
  85. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/embedding.py +0 -0
  86. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/embedding_persist.py +0 -0
  87. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/embedding_pipeline.py +0 -0
  88. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/encoder_input_contract.py +0 -0
  89. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/hierarchical.py +0 -0
  90. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/image_shard.py +0 -0
  91. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/image_specs.py +0 -0
  92. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/manifest.py +0 -0
  93. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/model_settings.py +0 -0
  94. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/patient_pipeline.py +0 -0
  95. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/persist_callbacks.py +0 -0
  96. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/persistence.py +0 -0
  97. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/pooled_encoder_input.py +0 -0
  98. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/preprocessing.py +0 -0
  99. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/process_list.py +0 -0
  100. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/progress_bridge.py +0 -0
  101. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/registry.py +0 -0
  102. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/serialization.py +0 -0
  103. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/sharding.py +0 -0
  104. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/slide_encode.py +0 -0
  105. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/tiling.py +0 -0
  106. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/tiling_pipeline.py +0 -0
  107. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/types.py +0 -0
  108. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/runtime/worker_io.py +0 -0
  109. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/utils/__init__.py +0 -0
  110. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/utils/config.py +0 -0
  111. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/utils/coordinates.py +0 -0
  112. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/utils/log_utils.py +0 -0
  113. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/utils/tiling_io.py +0 -0
  114. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec/utils/utils.py +0 -0
  115. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec.egg-info/dependency_links.txt +0 -0
  116. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec.egg-info/entry_points.txt +0 -0
  117. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec.egg-info/not-zip-safe +0 -0
  118. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec.egg-info/requires.txt +0 -0
  119. {slide2vec-6.0.0 → slide2vec-6.0.1}/slide2vec.egg-info/top_level.txt +0 -0
  120. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_architecture_runtime_split.py +0 -0
  121. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_attention_extraction.py +0 -0
  122. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_benchmark_tooling.py +0 -0
  123. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_encode_kit.py +0 -0
  124. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_encoder_input.py +0 -0
  125. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_extraction.py +0 -0
  126. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_image_reading.py +0 -0
  127. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_image_resume.py +0 -0
  128. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_image_shard.py +0 -0
  129. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_image_stage.py +0 -0
  130. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_regions.py +0 -0
  131. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_shard.py +0 -0
  132. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_sliding.py +0 -0
  133. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_source_spacing.py +0 -0
  134. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_stage.py +0 -0
  135. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dense_worker.py +0 -0
  136. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dinov2_natimage.py +0 -0
  137. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_dinov3_natimage.py +0 -0
  138. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_encoder_capabilities.py +0 -0
  139. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_encoder_input_contract.py +0 -0
  140. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_encoder_plugins.py +0 -0
  141. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_encoder_preprocessing.py +0 -0
  142. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_encoder_provider_failures.py +0 -0
  143. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_encoder_registry.py +0 -0
  144. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_gpfm_genbio_heavy.py +0 -0
  145. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_hs2p_package_cutover.py +0 -0
  146. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_image_shard.py +0 -0
  147. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_image_stage.py +0 -0
  148. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_inference_benchmark.py +0 -0
  149. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_isight.py +0 -0
  150. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_load_model_hf_auth.py +0 -0
  151. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_mascaret.py +0 -0
  152. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_on_slide_persisted.py +0 -0
  153. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_output_consistency.py +0 -0
  154. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_patch_size_metadata.py +0 -0
  155. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_patient_manifest.py +0 -0
  156. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_phaet.py +0 -0
  157. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_pooled_encoder_input.py +0 -0
  158. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_pooled_geometry.py +0 -0
  159. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_prism.py +0 -0
  160. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_prism2.py +0 -0
  161. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_process_list_performance.py +0 -0
  162. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_progress.py +0 -0
  163. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_regression_core.py +0 -0
  164. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_regression_inference.py +0 -0
  165. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_regression_models.py +0 -0
  166. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_rudolfv2.py +0 -0
  167. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_runtime_batching.py +0 -0
  168. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_sharding.py +0 -0
  169. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_slide_coordinate_preparation.py +0 -0
  170. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_soma_migration.py +0 -0
  171. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_tile_store.py +0 -0
  172. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_tiling_pipeline.py +0 -0
  173. {slide2vec-6.0.0 → slide2vec-6.0.1}/tests/test_titan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide2vec
3
- Version: 6.0.0
3
+ Version: 6.0.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slide2vec"
7
- version = "6.0.0"
7
+ version = "6.0.1"
8
8
  description = "Embedding of whole slide images with Foundation Models"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -149,7 +149,7 @@ zip-safe = false
149
149
  include = ["slide2vec*"]
150
150
 
151
151
  [tool.setuptools.package-data]
152
- slide2vec = ["py.typed"]
152
+ slide2vec = ["py.typed", "cufile.json"]
153
153
  "slide2vec.configs" = ["*.yaml", "models/*.yaml", "preprocessing/*.yaml"]
154
154
 
155
155
  [tool.pytest.ini_options]
@@ -180,7 +180,7 @@ no_implicit_reexport = true
180
180
  max-line-length = 160
181
181
 
182
182
  [tool.bumpver]
183
- current_version = "6.0.0"
183
+ current_version = "6.0.1"
184
184
  version_pattern = "MAJOR.MINOR.PATCH"
185
185
  commit = false
186
186
  tag = false
@@ -1,3 +1,7 @@
1
+ from slide2vec._cufile import disable_cufile_compat_mode
2
+
3
+ disable_cufile_compat_mode() # Before anything can import cucim.
4
+
1
5
  from slide2vec.api import (
2
6
  DenseImageOptions,
3
7
  DenseOptions,
@@ -27,7 +31,7 @@ from slide2vec.encoders import (
27
31
  )
28
32
 
29
33
 
30
- __version__ = "6.0.0"
34
+ __version__ = "6.0.1"
31
35
 
32
36
  __all__ = [
33
37
  "Model",
@@ -0,0 +1,21 @@
1
+ """Keep ``import cucim`` from opening a CUDA context on every visible GPU (issue #326).
2
+
3
+ cuCIM opens the cuFile (GPUDirect Storage) driver when it is imported. Without the
4
+ ``nvidia-fs`` kernel module the driver falls back to "compat mode", which still creates a
5
+ ~520 MiB CUDA context on each visible device although every read then goes through plain
6
+ POSIX I/O. Pointing cuFile at a config that disallows compat mode makes the driver open fail
7
+ instead: cuCIM reads through POSIX exactly as before, and no context is created. Where
8
+ ``nvidia-fs`` is loaded the driver opens as usual and GDS keeps working.
9
+
10
+ cuFile reads ``CUFILE_ENV_PATH_JSON`` once, when cuCIM is imported, so this must run before
11
+ that import: ``slide2vec/__init__.py`` calls it first. A value the user already set wins.
12
+ """
13
+
14
+ import os
15
+ from pathlib import Path
16
+
17
+ CUFILE_ENV = "CUFILE_ENV_PATH_JSON"
18
+
19
+
20
+ def disable_cufile_compat_mode(environ=os.environ) -> None:
21
+ environ.setdefault(CUFILE_ENV, str(Path(__file__).with_name("cufile.json")))
@@ -0,0 +1,5 @@
1
+ {
2
+ "properties": {
3
+ "allow_compat_mode": false
4
+ }
5
+ }
@@ -2,6 +2,8 @@ import os
2
2
 
3
3
  import torch
4
4
 
5
+ from slide2vec.distributed.worker_entry import PINNED_ENV
6
+
5
7
  _RANK = -1
6
8
  _WORLD_SIZE = -1
7
9
  _LOCAL_RANK = -1
@@ -43,6 +45,19 @@ def get_local_rank() -> int:
43
45
  return _LOCAL_RANK
44
46
 
45
47
 
48
+ def get_device_ordinal() -> int:
49
+ """
50
+ Returns:
51
+ The CUDA ordinal of this process's GPU: 0 once :mod:`slide2vec.distributed.pin_gpu`
52
+ has left it the only visible device, else the local rank.
53
+ """
54
+ return _device_ordinal(get_local_rank())
55
+
56
+
57
+ def _device_ordinal(local_rank: int) -> int:
58
+ return 0 if os.environ.get(PINNED_ENV) == "1" else local_rank
59
+
60
+
46
61
  def get_local_size() -> int:
47
62
  """
48
63
  Returns:
@@ -181,7 +196,7 @@ def enable(
181
196
 
182
197
  Args:
183
198
  set_cuda_current_device: If True, call torch.cuda.set_device() to set the
184
- current PyTorch CUDA device to the one matching the local rank.
199
+ current PyTorch CUDA device to this rank's GPU (see get_device_ordinal()).
185
200
  overwrite: If True, overwrites already set variables. Else fails.
186
201
  """
187
202
 
@@ -192,7 +207,7 @@ def enable(
192
207
  torch_env.export(overwrite=overwrite)
193
208
 
194
209
  if set_cuda_current_device:
195
- torch.cuda.set_device(torch_env.local_rank)
210
+ torch.cuda.set_device(_device_ordinal(torch_env.local_rank))
196
211
 
197
212
  _RANK = torch_env.rank
198
213
  _WORLD_SIZE = torch_env.world_size
@@ -42,7 +42,7 @@ def main(argv=None) -> int:
42
42
  model_spec = dict(request["model"])
43
43
  model = Model.from_preset(
44
44
  model_spec["name"],
45
- device=f"cuda:{local_rank}",
45
+ device=f"cuda:{distributed.get_device_ordinal()}",
46
46
  output_variant=model_spec.get("output_variant"),
47
47
  allow_non_recommended_settings=bool(model_spec["allow_non_recommended_settings"]),
48
48
  )
@@ -0,0 +1,47 @@
1
+ """Bootstrap that shows a torchrun worker only its own GPU, then runs the worker (issue #326).
2
+
3
+ ``import cucim`` opens a CUDA context on every visible device, so a rank that can see its
4
+ siblings' GPUs holds an idle context on each of them. CUDA reads ``CUDA_VISIBLE_DEVICES``
5
+ once, when it initialises, and ``import slide2vec`` already initialises it (``transformers``
6
+ calls ``torch.cuda.is_available()`` at import). A worker module therefore cannot narrow its
7
+ own visibility: ``python -m slide2vec.distributed.<worker>`` has imported the package before
8
+ the worker's first line runs. torchrun runs this file **by path** instead, so the narrowing
9
+ happens before anything is imported. Keep this file free of slide2vec and torch imports.
10
+
11
+ Only for workers that run no collectives: a pinned rank cannot see its siblings' GPUs.
12
+
13
+ Usage: ``torchrun ... pin_gpu.py <worker module> [worker args...]``
14
+ """
15
+
16
+ import os
17
+ import runpy
18
+ import sys
19
+
20
+ PINNED_ENV = "SLIDE2VEC_WORKER_GPU_PINNED" # Mirrored in worker_entry.PINNED_ENV.
21
+
22
+
23
+ def pin_to_own_gpu(environ) -> None:
24
+ """Narrow ``CUDA_VISIBLE_DEVICES`` to the ``LOCAL_RANK``-th GPU this process inherited."""
25
+ local_rank = int(environ["LOCAL_RANK"])
26
+ visible = [gpu.strip() for gpu in environ.get("CUDA_VISIBLE_DEVICES", "").split(",") if gpu.strip()]
27
+ if visible and local_rank >= len(visible):
28
+ raise RuntimeError(
29
+ f"LOCAL_RANK={local_rank} but CUDA_VISIBLE_DEVICES lists only {len(visible)} GPU(s)"
30
+ )
31
+ environ["CUDA_VISIBLE_DEVICES"] = visible[local_rank] if visible else str(local_rank)
32
+ environ[PINNED_ENV] = "1"
33
+
34
+
35
+ def main() -> None:
36
+ pin_to_own_gpu(os.environ)
37
+ module, *worker_args = sys.argv[1:]
38
+ # Prefer the package that supplied this bootstrap over a competing checkout in cwd.
39
+ # Keep cwd importable without changing how relative data and output paths resolve.
40
+ package_root = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", ".."))
41
+ sys.path[:1] = [package_root, os.getcwd()]
42
+ sys.argv = [module, *worker_args]
43
+ runpy.run_module(module, run_name="__main__", alter_sys=True)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -36,7 +36,7 @@ def main(argv=None) -> int:
36
36
  model_spec = dict(request["model"])
37
37
  model = Model.from_preset(
38
38
  model_spec["name"],
39
- device=f"cuda:{local_rank}",
39
+ device=f"cuda:{distributed.get_device_ordinal()}",
40
40
  output_variant=model_spec.get("output_variant"),
41
41
  allow_non_recommended_settings=bool(model_spec["allow_non_recommended_settings"]),
42
42
  )
@@ -18,6 +18,10 @@ from contextlib import nullcontext
18
18
  from dataclasses import dataclass
19
19
 
20
20
 
21
+ #: Set by :mod:`slide2vec.distributed.pin_gpu` once this process sees only its own GPU.
22
+ PINNED_ENV = "SLIDE2VEC_WORKER_GPU_PINNED"
23
+
24
+
21
25
  @dataclass(frozen=True, kw_only=True)
22
26
  class WorkerRank:
23
27
  """This process's place in the torchrun world: which shard, and which GPU."""
@@ -26,12 +30,14 @@ class WorkerRank:
26
30
  global_rank: int
27
31
  #: Number of shards the work list is cut into.
28
32
  world_size: int
29
- #: Local device ordinal, i.e. ``cuda:<local_rank>``.
33
+ #: This process's index among the ranks on its node; names it in progress labels.
30
34
  local_rank: int
35
+ #: CUDA ordinal of this rank's GPU: 0 once the process is pinned to it, else ``local_rank``.
36
+ device_ordinal: int
31
37
 
32
38
  @property
33
39
  def device(self) -> str:
34
- return f"cuda:{self.local_rank}"
40
+ return f"cuda:{self.device_ordinal}"
35
41
 
36
42
 
37
43
  def worker_args_parser(module: str, *, add_help: bool = True) -> argparse.ArgumentParser:
@@ -46,12 +52,15 @@ def resolve_worker_rank() -> WorkerRank:
46
52
  """Read this process's rank from the env torchrun exports.
47
53
 
48
54
  Defaults to a single in-process rank when the module is launched bare, so a worker can
49
- be run by hand (or in a test) without a torchrun agent.
55
+ be run by hand (or in a test) without a torchrun agent. A worker launched through
56
+ :mod:`slide2vec.distributed.pin_gpu` sees only its own GPU, which is then ``cuda:0``.
50
57
  """
58
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
51
59
  return WorkerRank(
52
60
  global_rank=int(os.environ.get("RANK", "0")),
53
61
  world_size=int(os.environ.get("WORLD_SIZE", "1")),
54
- local_rank=int(os.environ.get("LOCAL_RANK", "0")),
62
+ local_rank=local_rank,
63
+ device_ordinal=0 if os.environ.get(PINNED_ENV) == "1" else local_rank,
55
64
  )
56
65
 
57
66
 
@@ -82,6 +91,6 @@ def worker_progress_context(request: dict, *, rank: WorkerRank):
82
91
  return nullcontext()
83
92
  return activate_progress_reporter(
84
93
  JsonlProgressReporter(
85
- progress_events_path, rank=rank.global_rank, progress_label=rank.device
94
+ progress_events_path, rank=rank.global_rank, progress_label=f"cuda:{rank.local_rank}"
86
95
  )
87
96
  )
@@ -275,6 +275,7 @@ def _run_dense_images_distributed(
275
275
  request_path.write_text(json.dumps(request, indent=2, sort_keys=True), encoding="utf-8")
276
276
  run_torchrun_worker(
277
277
  module="slide2vec.distributed.dense_image_worker",
278
+ pin_gpus=True, # No collectives: each rank needs only its own GPU.
278
279
  num_gpus=execution.num_gpus,
279
280
  output_dir=out_dir,
280
281
  request_path=request_path,
@@ -297,6 +297,7 @@ def _run_dense_distributed(model, specs, *, dense, execution, out_dir) -> None:
297
297
  request_path.write_text(json.dumps(request, indent=2, sort_keys=True), encoding="utf-8")
298
298
  run_torchrun_worker(
299
299
  module="slide2vec.distributed.dense_worker",
300
+ pin_gpus=True, # No collectives: each rank needs only its own GPU.
300
301
  num_gpus=execution.num_gpus,
301
302
  output_dir=out_dir,
302
303
  request_path=request_path,
@@ -158,18 +158,28 @@ def run_torchrun_worker(
158
158
  output_dir: Path,
159
159
  request_path: Path,
160
160
  failure_title: str,
161
+ pin_gpus: bool = False,
161
162
  progress_events_path: Path | None = None,
162
163
  progress_event_callback: Callable[[Any], None] | None = None,
163
164
  popen_factory=subprocess.Popen,
164
165
  ) -> None:
166
+ """Run ``module`` once per GPU under torchrun.
167
+
168
+ ``pin_gpus`` shows each rank only its own GPU (see :mod:`slide2vec.distributed.pin_gpu`).
169
+ Use it for workers that run no collectives.
170
+ """
171
+ if pin_gpus:
172
+ # By path, not ``-m``: the bootstrap must run before the slide2vec package is imported.
173
+ worker = [str(Path(__file__).resolve().parents[1] / "distributed" / "pin_gpu.py"), module]
174
+ else:
175
+ worker = ["-m", module]
165
176
  command = [
166
177
  sys.executable,
167
178
  "-m",
168
179
  "torch.distributed.run",
169
180
  "--standalone",
170
181
  f"--nproc_per_node={num_gpus}",
171
- "-m",
172
- module,
182
+ *worker,
173
183
  "--output-dir",
174
184
  str(output_dir),
175
185
  "--request-path",
@@ -188,9 +198,15 @@ def run_torchrun_worker(
188
198
  previous_sigterm = signal.signal(signal.SIGTERM, _raise_on_sigterm)
189
199
  except (ValueError, OSError):
190
200
  previous_sigterm = None
201
+ # Workers keep the parent's working directory, so a relative output_dir (and every path
202
+ # derived from it: the request, the tiling artifacts, the progress log) means the same
203
+ # place in both. PYTHONPATH, not cwd, is what lets an uninstalled checkout be imported.
204
+ package_root = str(Path(__file__).resolve().parents[2])
205
+ env = dict(os.environ)
206
+ env["PYTHONPATH"] = os.pathsep.join(filter(None, [package_root, env.get("PYTHONPATH")]))
191
207
  process = popen_factory(
192
208
  command,
193
- cwd=str(Path(__file__).resolve().parents[2]),
209
+ env=env,
194
210
  stdout=subprocess.PIPE,
195
211
  stderr=subprocess.PIPE,
196
212
  text=True,
@@ -153,6 +153,7 @@ def run_distributed_embedding_stage(
153
153
  output_dir=output_dir,
154
154
  request_path=request_path,
155
155
  failure_title="Distributed feature extraction failed",
156
+ pin_gpus=True,
156
157
  progress_events_path=progress_events_path,
157
158
  progress_event_callback=on_progress_event,
158
159
  popen_factory=Popen,
@@ -190,6 +191,7 @@ def run_distributed_direct_embedding_stage(
190
191
  output_dir=output_dir,
191
192
  request_path=request_path,
192
193
  failure_title="Distributed direct embedding failed",
194
+ pin_gpus=True,
193
195
  progress_events_path=progress_events_path,
194
196
  popen_factory=Popen,
195
197
  )
@@ -147,6 +147,7 @@ def _run_images_distributed(model, specs, *, execution, out_dir) -> None:
147
147
  request_path.write_text(json.dumps(request, indent=2, sort_keys=True), encoding="utf-8")
148
148
  run_torchrun_worker(
149
149
  module="slide2vec.distributed.image_worker",
150
+ pin_gpus=True, # No collectives: each rank needs only its own GPU.
150
151
  num_gpus=execution.num_gpus,
151
152
  output_dir=out_dir,
152
153
  request_path=request_path,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide2vec
3
- Version: 6.0.0
3
+ Version: 6.0.1
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
@@ -3,9 +3,11 @@ README.md
3
3
  pyproject.toml
4
4
  slide2vec/__init__.py
5
5
  slide2vec/__main__.py
6
+ slide2vec/_cufile.py
6
7
  slide2vec/api.py
7
8
  slide2vec/artifacts.py
8
9
  slide2vec/cli.py
10
+ slide2vec/cufile.json
9
11
  slide2vec/inference.py
10
12
  slide2vec/progress.py
11
13
  slide2vec.egg-info/PKG-INFO
@@ -27,6 +29,7 @@ slide2vec/distributed/dense_image_worker.py
27
29
  slide2vec/distributed/dense_worker.py
28
30
  slide2vec/distributed/direct_embed_worker.py
29
31
  slide2vec/distributed/image_worker.py
32
+ slide2vec/distributed/pin_gpu.py
30
33
  slide2vec/distributed/pipeline_worker.py
31
34
  slide2vec/distributed/worker_entry.py
32
35
  slide2vec/encoders/__init__.py
@@ -113,6 +116,7 @@ slide2vec/utils/utils.py
113
116
  tests/test_architecture_runtime_split.py
114
117
  tests/test_attention_extraction.py
115
118
  tests/test_benchmark_tooling.py
119
+ tests/test_cufile_compat_mode.py
116
120
  tests/test_dense_encode_kit.py
117
121
  tests/test_dense_encoder_input.py
118
122
  tests/test_dense_extraction.py
@@ -163,4 +167,5 @@ tests/test_slide_coordinate_preparation.py
163
167
  tests/test_soma_migration.py
164
168
  tests/test_tile_store.py
165
169
  tests/test_tiling_pipeline.py
166
- tests/test_titan.py
170
+ tests/test_titan.py
171
+ tests/test_worker_gpu_pinning.py
@@ -0,0 +1,50 @@
1
+ """``import cucim`` must not open a CUDA context on every visible GPU (issue #326).
2
+
3
+ Without ``nvidia-fs``, cuFile's compat mode creates an idle context per device at import.
4
+ slide2vec points cuFile at a config that disallows compat mode, before cucim can load.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import slide2vec
17
+ from slide2vec import _cufile
18
+
19
+
20
+ def test_packaged_config_disallows_compat_mode():
21
+ config = json.loads(Path(slide2vec.__file__).with_name("cufile.json").read_text())
22
+ assert config == {"properties": {"allow_compat_mode": False}}
23
+
24
+
25
+ def test_cufile_is_pointed_at_the_packaged_config():
26
+ environ: dict[str, str] = {}
27
+ _cufile.disable_cufile_compat_mode(environ)
28
+ assert Path(environ[_cufile.CUFILE_ENV]) == Path(slide2vec.__file__).with_name("cufile.json")
29
+
30
+
31
+ def test_a_config_the_user_chose_is_left_alone():
32
+ environ = {_cufile.CUFILE_ENV: "/etc/my-cufile.json"}
33
+ _cufile.disable_cufile_compat_mode(environ)
34
+ assert environ[_cufile.CUFILE_ENV] == "/etc/my-cufile.json"
35
+
36
+
37
+ def test_package_import_sets_it_before_any_other_import():
38
+ # cuFile reads the variable once, when cucim is imported; nothing may get there first.
39
+ first, second = ast.parse(Path(slide2vec.__file__).read_text()).body[:2]
40
+ assert isinstance(first, ast.ImportFrom) and first.module == "slide2vec._cufile"
41
+ assert isinstance(second, ast.Expr) and second.value.func.id == "disable_cufile_compat_mode"
42
+
43
+
44
+ def test_importing_slide2vec_exports_the_variable():
45
+ env = {k: v for k, v in os.environ.items() if k != _cufile.CUFILE_ENV}
46
+ out = subprocess.run(
47
+ [sys.executable, "-c", f"import os, slide2vec; print(os.environ['{_cufile.CUFILE_ENV}'])"],
48
+ env=env, capture_output=True, text=True, check=True, cwd=Path(slide2vec.__file__).parents[1],
49
+ ).stdout.strip().splitlines()[-1]
50
+ assert Path(out).name == "cufile.json" and Path(out).is_file()
@@ -0,0 +1,234 @@
1
+ """Each sharded torchrun worker must see only its own GPU (issue #326).
2
+
3
+ ``import cucim`` opens a CUDA context on every visible device, so a rank that can see its
4
+ siblings' GPUs holds an idle context on each of them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import subprocess
14
+ import sys
15
+
16
+ import pytest
17
+
18
+ from slide2vec.distributed import pin_gpu, worker_entry
19
+ from slide2vec.distributed.worker_entry import resolve_worker_rank
20
+
21
+
22
+ def test_rank_sees_only_its_own_gpu():
23
+ environ = {"LOCAL_RANK": "2"}
24
+ pin_gpu.pin_to_own_gpu(environ)
25
+ assert environ["CUDA_VISIBLE_DEVICES"] == "2"
26
+ assert environ[pin_gpu.PINNED_ENV] == "1"
27
+
28
+
29
+ def test_rank_picks_its_entry_of_an_inherited_gpu_list():
30
+ environ = {"LOCAL_RANK": "1", "CUDA_VISIBLE_DEVICES": "GPU-aaa, GPU-bbb,GPU-ccc"}
31
+ pin_gpu.pin_to_own_gpu(environ)
32
+ assert environ["CUDA_VISIBLE_DEVICES"] == "GPU-bbb"
33
+
34
+
35
+ def test_more_ranks_than_visible_gpus_is_an_error():
36
+ with pytest.raises(RuntimeError, match="LOCAL_RANK=2"):
37
+ pin_gpu.pin_to_own_gpu({"LOCAL_RANK": "2", "CUDA_VISIBLE_DEVICES": "0,1"})
38
+
39
+
40
+ def test_bootstrap_imports_nothing_that_could_initialise_cuda():
41
+ # It runs by path, before the package import that freezes CUDA's visible-device list.
42
+ tree = ast.parse(Path(pin_gpu.__file__).read_text())
43
+ imported = {
44
+ alias.name.split(".")[0]
45
+ for node in ast.walk(tree) if isinstance(node, ast.Import)
46
+ for alias in node.names
47
+ } | {
48
+ (node.module or "").split(".")[0]
49
+ for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)
50
+ }
51
+ assert imported == {"os", "runpy", "sys"}
52
+
53
+
54
+ def test_pinned_worker_uses_cuda_0_but_keeps_its_rank_label(monkeypatch):
55
+ assert worker_entry.PINNED_ENV == pin_gpu.PINNED_ENV
56
+ monkeypatch.setenv("RANK", "3")
57
+ monkeypatch.setenv("WORLD_SIZE", "4")
58
+ monkeypatch.setenv("LOCAL_RANK", "3")
59
+ monkeypatch.setenv(worker_entry.PINNED_ENV, "1")
60
+ rank = resolve_worker_rank()
61
+ assert rank.device == "cuda:0"
62
+ assert (rank.global_rank, rank.local_rank) == (3, 3)
63
+
64
+
65
+ def test_unpinned_worker_uses_its_local_rank_device(monkeypatch):
66
+ monkeypatch.setenv("LOCAL_RANK", "3")
67
+ monkeypatch.delenv(worker_entry.PINNED_ENV, raising=False)
68
+ assert resolve_worker_rank().device == "cuda:3"
69
+
70
+
71
+ @pytest.mark.parametrize("pin_gpus", [True, False])
72
+ def test_launcher_runs_the_bootstrap_by_path_only_when_pinning(tmp_path, pin_gpus):
73
+ from slide2vec.runtime.distributed import run_torchrun_worker
74
+
75
+ commands = []
76
+
77
+ class _Done:
78
+ pid = None
79
+ stdout = stderr = None
80
+
81
+ def __init__(self, command, **kwargs):
82
+ commands.append(command)
83
+
84
+ def poll(self):
85
+ return 0
86
+
87
+ def wait(self, timeout=None):
88
+ return 0
89
+
90
+ run_torchrun_worker(
91
+ module="slide2vec.distributed.dense_worker", num_gpus=4, output_dir=tmp_path,
92
+ request_path=tmp_path / "request.json", failure_title="failed",
93
+ pin_gpus=pin_gpus, popen_factory=_Done,
94
+ )
95
+ (command,) = commands
96
+ tail = command[command.index("--nproc_per_node=4") + 1:]
97
+ if pin_gpus:
98
+ assert Path(tail[0]) == Path(pin_gpu.__file__).resolve()
99
+ assert tail[1] == "slide2vec.distributed.dense_worker"
100
+ else:
101
+ assert tail[:2] == ["-m", "slide2vec.distributed.dense_worker"]
102
+
103
+
104
+ @pytest.mark.parametrize("pinned, ordinal", [(True, 0), (False, 3)])
105
+ def test_enable_binds_a_pinned_rank_to_cuda_0(monkeypatch, pinned, ordinal):
106
+ # pipeline_worker and direct_embed_worker go through distributed.enable(); once pinned,
107
+ # cuda:<local_rank> no longer exists for any rank but 0.
108
+ import torch
109
+
110
+ import slide2vec.distributed as distributed
111
+
112
+ for name in ("_RANK", "_WORLD_SIZE", "_LOCAL_RANK", "_LOCAL_WORLD_SIZE"):
113
+ monkeypatch.setattr(distributed, name, -1)
114
+ for key in ("RANK", "WORLD_SIZE", "LOCAL_RANK", "LOCAL_WORLD_SIZE"):
115
+ monkeypatch.setenv(key, "4" if "SIZE" in key else "3")
116
+ if pinned:
117
+ monkeypatch.setenv(worker_entry.PINNED_ENV, "1")
118
+ else:
119
+ monkeypatch.delenv(worker_entry.PINNED_ENV, raising=False)
120
+ monkeypatch.setattr(distributed, "_restrict_print_to_main_process", lambda: None)
121
+ bound = []
122
+ monkeypatch.setattr(torch.cuda, "set_device", bound.append)
123
+
124
+ distributed.enable(overwrite=True)
125
+
126
+ assert bound == [ordinal]
127
+ assert distributed.get_device_ordinal() == ordinal
128
+ assert distributed.get_local_rank() == 3
129
+
130
+
131
+ @pytest.mark.parametrize("worker", ["pipeline_worker", "direct_embed_worker"])
132
+ def test_enable_based_workers_build_the_model_on_the_device_ordinal(worker):
133
+ source = (Path(pin_gpu.__file__).parent / f"{worker}.py").read_text()
134
+ assert 'device=f"cuda:{distributed.get_device_ordinal()}"' in source
135
+ assert 'progress_label=f"cuda:{local_rank}"' in source
136
+
137
+
138
+ def test_every_torchrun_stage_pins_its_workers():
139
+ runtime = Path(pin_gpu.__file__).resolve().parents[1] / "runtime"
140
+ launches = [
141
+ node
142
+ for path in runtime.glob("*.py")
143
+ for node in ast.walk(ast.parse(path.read_text()))
144
+ if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "run_torchrun_worker"
145
+ ]
146
+ assert len(launches) == 5
147
+ for call in launches:
148
+ (pin,) = [kw.value for kw in call.keywords if kw.arg == "pin_gpus"]
149
+ assert pin.value is True
150
+
151
+
152
+ def test_workers_keep_the_parents_working_directory(tmp_path, monkeypatch):
153
+ # A relative output_dir must mean the same place in the parent and in every rank, so the
154
+ # checkout is made importable through PYTHONPATH rather than by moving the workers' cwd.
155
+ import os
156
+
157
+ import slide2vec
158
+ from slide2vec.runtime.distributed import run_torchrun_worker
159
+
160
+ monkeypatch.setenv("PYTHONPATH", "/somewhere/else")
161
+ launches = []
162
+
163
+ class _Done:
164
+ pid = None
165
+ stdout = stderr = None
166
+
167
+ def __init__(self, command, **kwargs):
168
+ launches.append((command, kwargs))
169
+
170
+ def poll(self):
171
+ return 0
172
+
173
+ def wait(self, timeout=None):
174
+ return 0
175
+
176
+ monkeypatch.chdir(tmp_path)
177
+ run_torchrun_worker(
178
+ module="slide2vec.distributed.dense_worker", num_gpus=2, output_dir=Path("out"),
179
+ request_path=Path("out") / "request.json", failure_title="failed", popen_factory=_Done,
180
+ )
181
+ ((command, kwargs),) = launches
182
+ assert kwargs.get("cwd") is None
183
+ assert command[command.index("--output-dir") + 1] == "out"
184
+ package_root = str(Path(slide2vec.__file__).resolve().parents[1])
185
+ assert kwargs["env"]["PYTHONPATH"].split(os.pathsep) == [package_root, "/somewhere/else"]
186
+
187
+
188
+ def test_bootstrap_prefers_its_checkout_and_keeps_relative_paths(tmp_path):
189
+ # Minimal competing packages let a fresh interpreter exercise import precedence
190
+ # without loading CUDA or model dependencies.
191
+ package = tmp_path / "parent-checkout" / "slide2vec"
192
+ bootstrap = package / "distributed" / "pin_gpu.py"
193
+ bootstrap.parent.mkdir(parents=True)
194
+ bootstrap.write_text(Path(pin_gpu.__file__).read_text())
195
+ (package / "__init__.py").write_text("")
196
+
197
+ work_dir = tmp_path / "other-checkout"
198
+ shadow_package = work_dir / "slide2vec"
199
+ shadow_package.mkdir(parents=True)
200
+ (shadow_package / "__init__.py").write_text(
201
+ "raise AssertionError('worker imported the competing checkout')\n"
202
+ )
203
+ (work_dir / "input.txt").write_text("relative input")
204
+ (work_dir / "out").mkdir()
205
+ (work_dir / "out" / "request.json").write_text(
206
+ json.dumps({"input": "input.txt", "output": "out/report.json"})
207
+ )
208
+ # This module is only available in the working directory, which must remain
209
+ # importable after the parent's package root.
210
+ (work_dir / "probe_worker.py").write_text(
211
+ "import json, os, sys\n"
212
+ "from pathlib import Path\n"
213
+ "import slide2vec\n"
214
+ "request = json.loads(Path(sys.argv[1]).read_text())\n"
215
+ "Path(request['output']).write_text(json.dumps({\n"
216
+ " 'package': slide2vec.__file__,\n"
217
+ " 'cwd': os.getcwd(),\n"
218
+ " 'input': Path(request['input']).read_text(),\n"
219
+ " 'visible': os.environ['CUDA_VISIBLE_DEVICES'],\n"
220
+ "}))\n"
221
+ )
222
+ env = dict(os.environ)
223
+ env.update(LOCAL_RANK="1", CUDA_VISIBLE_DEVICES="2,3", PYTHONPATH=str(package.parent))
224
+ result = subprocess.run(
225
+ [sys.executable, str(bootstrap), "probe_worker", "out/request.json"],
226
+ cwd=work_dir, env=env, capture_output=True, text=True, timeout=15,
227
+ )
228
+ assert result.returncode == 0, result.stderr
229
+ assert json.loads((work_dir / "out" / "report.json").read_text()) == {
230
+ "package": str(package / "__init__.py"),
231
+ "cwd": str(work_dir),
232
+ "input": "relative input",
233
+ "visible": "3",
234
+ }