From 9ee2e4500f5c69c3a1d3e8510febd1b7ca2f58c3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 18:06:39 -0400 Subject: [PATCH] refactor: unify compiler preparation through nominal operations (#229) --- .../README.md | 136 +++++++++++ .../observations.csv | 11 + .../output_parity.csv | 11 + .../ownership_decision.md | 118 ++++++++++ .../probe_spawn_cache.py | 99 ++++++++ .../provenance.json | 56 +++++ .../shared_environment.json | 105 +++++++++ .../shared_mcp_health.json | 45 ++++ .../shared_requirements.txt | 178 ++++++++++++++ .../spawn_cache_hits.json | 137 +++++++++++ openhcs/core/autoregister_preparation.py | 33 +-- openhcs/core/callable_contract.py | 82 +------ openhcs/core/processing_preparation.py | 218 ++++++++++++++++++ openhcs/core/steps/function_runtime.py | 21 +- tests/unit/test_fork_compiler_preparation.py | 38 +-- tests/unit/test_processing_preparation.py | 207 +++++++++++++++++ 16 files changed, 1362 insertions(+), 133 deletions(-) create mode 100644 benchmark/results/perf_nominal_preparation_20260929/README.md create mode 100644 benchmark/results/perf_nominal_preparation_20260929/observations.csv create mode 100644 benchmark/results/perf_nominal_preparation_20260929/output_parity.csv create mode 100644 benchmark/results/perf_nominal_preparation_20260929/ownership_decision.md create mode 100644 benchmark/results/perf_nominal_preparation_20260929/probe_spawn_cache.py create mode 100644 benchmark/results/perf_nominal_preparation_20260929/provenance.json create mode 100644 benchmark/results/perf_nominal_preparation_20260929/shared_environment.json create mode 100644 benchmark/results/perf_nominal_preparation_20260929/shared_mcp_health.json create mode 100644 benchmark/results/perf_nominal_preparation_20260929/shared_requirements.txt create mode 100644 benchmark/results/perf_nominal_preparation_20260929/spawn_cache_hits.json create mode 100644 openhcs/core/processing_preparation.py create mode 100644 tests/unit/test_processing_preparation.py diff --git a/benchmark/results/perf_nominal_preparation_20260929/README.md b/benchmark/results/perf_nominal_preparation_20260929/README.md new file mode 100644 index 0000000000..01533e3100 --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/README.md @@ -0,0 +1,136 @@ +# Nominal compiler preparation and cross-process kernel reuse + +The compiler now consumes declared preparation operations. A public ABC owns +successful completion and failed-operation retry; module and callable hook leaves +retain their distinct identities and inherit hook execution. Registry membership +still comes from the existing registry discovery owner. A generic cache batch +deduplicates operations before checking each operation's public child eligibility. +Parent preparation remains necessary after child cache population. + +This is the user-requested structural dependency of further scheduling and cache +work. **It does not make LLVM or numerical execution faster.** The removed paths +were duplicate completion implementations and a registry-specific scheduler; +the backend's separate specialization cache retains its independent lifetime. + +The same-source-path, same-cache warm replay on +`ExampleImagingFlowCytometryObjectsInGrid`, one well and one CPU thread, was: + +| Preparation implementation | Compilation | Execution job | Total observation | +| --- | ---: | ---: | ---: | +| Baseline | 3.183 s | 20.630 s | 26.532 s | +| Nominal operations | 3.211 s | 20.618 s | 26.553 s | + +These single observations establish exercised behavior and show no material +change in this pair; they are not a statistical equivalence or speedup claim. +Earlier and later candidate warm observations are retained rather than discarded. +A deliberately empty-cache candidate took 26.311 s to compile and 50.926 s total. +The few-second cold-compilation target remains open. Preparation accounts for +23.712 of 24.655 seconds in the diagnostic trace. Nested kernel profiling found +5.679 seconds in the 2D intensity quantile kernel, including 4.413 seconds in its +partition helper. Merely reorganizing its caller cannot erase that LLVM cost. + +Every retained single-well run produced the identical 11,550,064-byte output, +SHA-256 `3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be`. +The two-well output has its own hash and is not asserted byte-equal to a one-well +plate. Numerical kernels and scientific output declarations were not changed. + +## Compiled kernels are already reusable with spawn + +Two genuinely fresh spawned processes used the same populated `NUMBA_CACHE_DIR`. +Each observed **12 dispatcher disk-cache hits and zero misses** while preparing +the intensity and shape families. Family hydration took 0.357 and 0.353 seconds; +starting both processes, importing their dependencies, hydrating and joining took +1.819 seconds total. The parent did not prepare those families in memory, so this +is disk reuse rather than inherited fork state. + +An ordinary two-well, two-process-worker spawn pipeline also completed: +compilation 3.651 s, execution job 28.581 s, total 34.869 s. Its endpoint log +contains 139 cache-data loads and zero cache writes. This supports reuse of the +observed cache-enabled kernels; it does not prove that every possible execution +path avoids JIT or that uncached Numba functions never compile. Issue #162 remains +open for the broader preparation/timing boundary. + +The single-worker resource is an inline lane. A configured `fork` or `spawn` +start method does not itself establish that an execution child was created. +The separately retained `spawn-configured-single-inline-lane` observation is +explicitly excluded from the proof of actual spawned execution. Cold compiler +cache population can still create up to four fork children independently of +the one-thread execution setting. + +Numba's existing cache validates code, signatures and compatible runtime/CPU +identity. Keep one persistent cache for compatible runs; changing the cache root +for a deliberately cold measurement is a separate workload. No second kernel +cache or handwritten specialization roster was introduced here. + +## Source and validation + +The ownership receipt is [ownership_decision.md](ownership_decision.md). +NRA was fetched into a clean isolated worktree at `0844525`, preserving the user's +dirty original checkout. Its packaged `nra-refactoring` skill was read; the exact +named `refactor-audit` skill was not found and is not claimed to have been used. + +The class-first census covered all 696 original modules and 5,014 classes, with +5,002 canonical projections. After introducing the operation module it covered +697 modules and 5,022 classes, with 5,010 projections. The same 12 conditional or +function-local classes remain OPEN. The full semantic scan exceeded its explicit +165-second budget. Completed bounded raw scans covered four before/five after +modules and retained the same two unrelated heuristic leads: a constructor +validation check and a runtime output-context condition. Neither was deleted. +There were no mapping/record-shape findings in those bounded scans. This is not a +complete whole-package semantic audit. + +NRA applied exact authored target patches and the new-file declaration through +revision-checked transactions. Configured guards reject the retired helper calls +and scheduler attributes. A subsequent refinement had source preflight but no +equivalence guard suite. Syntax and guard results are distinct from behavior: +**294 local tests passed**, including real fork PIDs, parent readiness, hook +replacement, delayed metadata effects, failed-hook retry, equivalent bound-method +identity, and a new operation without a scheduler roster. Black passed all six +changed files; Ruff introduced zero new findings (37 baseline, 36 candidate), +and the new module/tests pass Ruff cleanly. + +Benchmarked ownership baseline: `213439a04` plus the recorded candidate source +hashes. Latest-main validation also includes `8b090e2e9`. The raw observations, +run-input fingerprints and paths are in [observations.csv](observations.csv), +output hashes in [output_parity.csv](output_parity.csv), and source/scan evidence +in [provenance.json](provenance.json). The retained cache counters are +[spawn_cache_hits.json](spawn_cache_hits.json). Run +[probe_spawn_cache.py](probe_spawn_cache.py) with the same populated cache and +source path to repeat the bounded spawn probe. + +Latest main exposes new PolyStore cache declarations through the MCP/UI +child-environment helper. The old shared editable lacked them and raised +`AttributeError`; the ordinary ZMQ benchmark did not call that helper and still +succeeded. An initial isolated pinned-dependency replay is retained as historical +evidence, rather than used as a substitute for updating the shared installation. + +The shared OpenHCS checkout was subsequently synchronized to main `8b090e2e9`, +preserving its previous branch. All eight clean dependency checkouts now match +main's recorded revisions, including PolyStore +`c16b8fc99a07f17e92e5fbf46f312d73cc9c46d7`. They and OpenHCS were reinstalled +editable with an eager upgrade to the latest versions allowed by their declared +requirements (`dev,gui,cellprofiler-compat,bioformats`). `pip check` passes. +Actual loaded paths and revisions are in [shared_environment.json](shared_environment.json), +resolved versions in [shared_requirements.txt](shared_requirements.txt). +A real fresh stdio MCP health request succeeded with the shared interpreter; +its receipt is [shared_mcp_health.json](shared_mcp_health.json). + +All 294 compiler tests passed again after the upgrade. Environment validation +found an obsolete main test expecting four keys instead of six; PR #231 updates +that expectation using dependency declarations and verifies their consumption in +a fresh child. Its 32 focused tests pass. This establishes the environment and +MCP startup portion of #224, not its full installed plate-inspection/concurrency +acceptance; that issue remains open. + +The updated shared-environment replay took **3.267 s compilation, 19.349 s +execution, and 25.377 s total**, with the identical output hash above. The +remaining 2.762 s includes fresh endpoint lifecycle, submission and observation, +result loading, shutdown and benchmark bookkeeping outside the two server jobs. +This later environment observation is not a controlled speedup comparison +against the earlier environment; the same-environment A/B remains the pair above. + +Source workspace staging lies outside the observation timer. Execution measures +the completed server pipeline job; total includes fresh endpoint lifecycle, +compilation, execution and observation work. Native CellProfiler was not rerun +for this architecture-only migration, and no new relative-to-CP speedup is +claimed. The preceding measured native scaling report remains separate. diff --git a/benchmark/results/perf_nominal_preparation_20260929/observations.csv b/benchmark/results/perf_nominal_preparation_20260929/observations.csv new file mode 100644 index 0000000000..ffbb1feb65 --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/observations.csv @@ -0,0 +1,11 @@ +cohort,source,cache_state,cache_directory,raw_run_root,case_name,mode_name,worker_count,well_count,compile_seconds,prepare_seconds,execute_seconds,total_seconds,wells_per_second,successful_wells,native_single_sample_execution_seconds,projected_native_execution_seconds,projected_execution_speedup,peak_memory_mb,status,memory_limit_mb,error_message,execution_route,server_lifecycle,run_input_sha256 +baseline-same-path-warm,213439a04 baseline,warm,/tmp/openhcs-cold-candidate-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-same-path-baseline-warm-1w-20260929,ExampleImagingFlowCytometryObjectsInGrid,1w_1t,1,1,3.1833879947662354,0.0,20.63007354736328,26.531713547185063,0.04847292462162887,1,,,,2308.515625,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,56671861f2d84eefe7b80a5278cbf0c842b03f5dc13c15a6f90240c9362c608f +candidate-same-path-warm,213439a04 plus candidate,warm,/tmp/openhcs-cold-candidate-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-same-path-candidate-warm-1w-20260929,ExampleImagingFlowCytometryObjectsInGrid,1w_1t,1,1,3.2108519077301025,0.0,20.618300437927246,26.55347270704806,0.048500602802377724,1,,,,2317.30859375,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,14c776ebd700f8092717ae302d76f12f9d49229b78e459574319742d016769bb +candidate-initial-warm,213439a04 plus candidate,warm,/tmp/openhcs-cold-candidate-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-candidate-warm-1w-20260929,ExampleImagingFlowCytometryObjectsInGrid,1w_1t,1,1,3.207834482192993,0.0,21.487576484680176,27.44281652290374,0.04653851962844493,1,,,,2301.0546875,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,14c776ebd700f8092717ae302d76f12f9d49229b78e459574319742d016769bb +candidate-cold,213439a04 plus candidate,cold,/tmp/openhcs-nominal-preparation-cold-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-candidate-cold-1w-20260929,ExampleImagingFlowCytometryObjectsInGrid,1w_1t,1,1,26.31134343147278,0.0,21.744935035705566,50.925896398257464,0.045987720743151564,1,,,,2380.2109375,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,14c776ebd700f8092717ae302d76f12f9d49229b78e459574319742d016769bb +candidate-main-sync-warm,8b090e2e9 plus candidate,warm,/tmp/openhcs-cold-candidate-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-final-warm-1w-20260929,ExampleImagingFlowCytometryObjectsInGrid,1w_1t,1,1,3.2015676498413086,0.0,21.58715009689331,27.405890737194568,0.04632385449267404,1,,,,2315.8984375,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,897fe1816b7dbec3029855bdd4382ecac904731606c8dbf1982a0868c47361f3 +reference-other-path-warm,de23449a42f89a9aaa13218090ef3fb8c26d46a1,warm,/tmp/openhcs-cold-control-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-control-warm-1w-20260929,ExampleImagingFlowCytometryObjectsInGrid,1w_1t,1,1,3.2724735736846924,0.0,20.43328022956848,26.395260882098228,0.04893976829784409,1,,,,2311.75390625,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,e3349933445c478ef6724ffbc7af831df5dea6436c8be5c3619af52ed51dc0d5 +spawn-two-process-workers,f75f766743460d2cf2902d8c0535126d95405e60,warm,/tmp/openhcs-cold-candidate-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-shared-kernel-cache-warm-spawn-2w2p-20260929,ExampleImagingFlowCytometryObjectsInGrid,2w_2c,2,2,3.651456832885742,0.0,28.58085823059082,34.86922286078334,0.06997690495729583,2,,,,4453.078125,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,a0291d20a2c0b4c1b2c4593abcbacc66a797ee39540f7f397a436b06980bbaf4 +spawn-configured-single-inline-lane,f75f766743460d2cf2902d8c0535126d95405e60,warm,/tmp/openhcs-cold-candidate-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-shared-kernel-cache-warm-spawn-1w-20260929,ExampleImagingFlowCytometryObjectsInGrid,1w_1c,1,1,3.2073373794555664,0.0,21.001794815063477,26.93312729895115,0.047614978091432114,1,,,,2331.06640625,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,ae431c1a3e80403dcdefcf129e21e1af8b27a1710a3bd78ab9bb4a73c12c0fdc +candidate-isolated-pinned-polystore-warm,8b090e2e9 plus candidate,warm,/tmp/openhcs-cold-candidate-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-pinned-polystore-warm-1w-20260929,ExampleImagingFlowCytometryObjectsInGrid,1w_1t,1,1,3.208029270172119,0.0,19.639253616333008,25.521860189270228,0.05091843201048887,1,,,,2287.37109375,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,897fe1816b7dbec3029855bdd4382ecac904731606c8dbf1982a0868c47361f3 +candidate-shared-updated-warm,8b090e2e9 plus candidate,warm,/tmp/openhcs-cold-candidate-20260929-c1,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-shared-updated-warm-1w-20260929,ExampleImagingFlowCytometryObjectsInGrid,1w_1t,1,1,3.2669453620910645,0.0,19.34896206855774,25.377457005903125,0.051682358798201905,1,,,,2361.796875,success,,,ordinary-zmq-outcomes-v1,fresh-per-observation,897fe1816b7dbec3029855bdd4382ecac904731606c8dbf1982a0868c47361f3 diff --git a/benchmark/results/perf_nominal_preparation_20260929/output_parity.csv b/benchmark/results/perf_nominal_preparation_20260929/output_parity.csv new file mode 100644 index 0000000000..0e7270d3b2 --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/output_parity.csv @@ -0,0 +1,11 @@ +cohort,output,bytes,sha256 +baseline-same-path-warm,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-same-path-baseline-warm-1w-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_1/workers_1/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,11550064,3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be +candidate-same-path-warm,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-same-path-candidate-warm-1w-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_1/workers_1/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,11550064,3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be +candidate-initial-warm,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-candidate-warm-1w-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_1/workers_1/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,11550064,3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be +candidate-cold,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-candidate-cold-1w-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_1/workers_1/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,11550064,3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be +candidate-main-sync-warm,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-final-warm-1w-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_1/workers_1/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,11550064,3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be +reference-other-path-warm,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-control-warm-1w-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_1/workers_1/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,11550064,3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be +spawn-two-process-workers,/home/ts/code/projects/openhcs-benchmark-runs/perf-shared-kernel-cache-warm-spawn-2w2p-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_2/workers_2/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,23069855,d6dea1dbb74b9e4992735794375731c3153943877ad48f5485a780da2bd80cf3 +spawn-configured-single-inline-lane,/home/ts/code/projects/openhcs-benchmark-runs/perf-shared-kernel-cache-warm-spawn-1w-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_1/workers_1/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,11550064,3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be +candidate-isolated-pinned-polystore-warm,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-pinned-polystore-warm-1w-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_1/workers_1/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,11550064,3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be +candidate-shared-updated-warm,/home/ts/code/projects/openhcs-benchmark-runs/perf-nominal-preparation-shared-updated-warm-1w-20260929/ExampleImagingFlowCytometryObjectsInGrid/wells_1/workers_1/images_ExampleImagingFlowCytometryObjectsInGrid_source_workspace_well_throughput/results/BF_cells_on_grid.csv,11550064,3e00436ae1500047fd2605021ae055492b17dbe0b4aa4ddbe2f62af6f8e073be diff --git a/benchmark/results/perf_nominal_preparation_20260929/ownership_decision.md b/benchmark/results/perf_nominal_preparation_20260929/ownership_decision.md new file mode 100644 index 0000000000..9d2f9fe694 --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/ownership_decision.md @@ -0,0 +1,118 @@ +# Compiler preparation ownership decision + +OpenHCS source: `f75f766743460d2cf2902d8c0535126d95405e60`. +Advisor source: `0844525` in an isolated clean worktree; the user's dirty +`/home/ts/code/NominalRefactorAdvisor` checkout is preserved. +Guidance: packaged `skills/nra-refactoring/SKILL.md`. The exact requested +`refactor-audit` skill has not been located; its identity remains OPEN. + +## Admitted intent and measured payoff + +The user authorizes a nominal compiler refactor with shared inherited algorithms, +elimination of duplicated decisions, and subsequent caching to approach a few +seconds of compilation. Preserve processing outputs and eager preparation before +timed execution. This admits the following bounded ownership decision without +requiring another permission request; it does not prove behavioral equivalence. + +The real compiler trace spends 23.712278 of 24.654537 seconds in preparation. +Planning accounts for less than one second and is rejected as the dominant route. +Child intensity and shape warmups take 11.15 and 10.61 seconds; subsequent parent +primary-object and shape hooks take 6.59 and 3.13 seconds. A serial kernel probe +also identifies 5.679 seconds of 2D quantile compilation, including 4.413 seconds +in its partition helper. These are nested inclusive times, not additive costs. + +This migration is an explicitly requested structural dependency of changing +preparation scheduling and artifact reuse. It claims no immediate LLVM speedup. +Its production consumer is the existing compiled-context preparation boundary. +Unconditional forking of warm preparation was already rejected by replay: +0.338 seconds serial versus 0.704 seconds fork plus parent hydration. + +## Determining declarations and executable consumers + +- `callable_contract.py:147`: `CompilerPreparedAutoRegisterFamily` determines + family preparation and child-cache eligibility. Its backend implementations + remain the authority; the core does not inspect provider names or Numba flags. +- `autoregister_preparation.py:96`: registry ownership is derived from module + declarations and deduplicated by actual registry identity. Public discovery + retains unrelated registry discovery, while preparation filters nominally + before touching lazy registries. Export this existing operation for consumers; + do not reconstruct it elsewhere. +- `callable_contract.py:1570` and metadata declarations: callable/module hooks + remain explicit callable metadata. A module may have no hook, and a callable + may have no module. Metadata boundary validation remains required. +- `callable_contract.py:1669,1716`: two paths repeat completion checking, + execution and successful completion marking. Their identity policies differ: + module hooks use callable object identity; callable hooks use declaring + callable name plus hook module/qualname, including equivalent bound methods. +- `function_runtime.py:247,1683,1689`: runtime callable resolution is bound to + each compiled contract. Parent preparation follows child cache population and + remains in invocation order. These actual consumers must use the shared + preparation contract after migration. +- `_backend.py:418`: the registry-snapshot kernel cache has a different lifetime + and invalidation contract from core hook completion. It remains independently + owned by the backend; it is not an obsolete duplicate readiness authority. + +## Required and forbidden pairs (R*) + +| Implementation fact | Consumer | Required answer / admitted owner | +|---|---|---| +| Process-local preparation completion | Module registry, module hook, callable hook | Shared `PreparationOperation.prepare` algorithm; mark only after success | +| Hook execution | Module and callable hook operations | Inherited declared-hook execution; leaves supply identity policy only | +| Module registry membership | Parent module preparation and child projection | Existing `AutoRegisterRegistryPreparation.module_registry_owners` derived discovery | +| Persistent cache child eligibility | Generic child scheduler | Family's public `can_prepare_in_child`, default opt-out | +| Parent readiness | Compiled invocation consumer | Parent operations execute after cache children; child success never marks parent ready | +| Runtime adapter callable identity | Invocation resolver | Existing exact compiled-contract cache; preparation keys must not replace it | +| Backend specialization readiness | Backend consumers | Existing registry snapshot cache; core completion must not invalidate it | + +Forbidden: a second hook completion set; a second registry scan or class roster; +backend/provider case switches in the compiler scheduler; accepting a child result +as parent readiness; swallowed child or hook failures; new optional-import catches; +pre-validating later hooks before preceding family/module effects; moving a cache +hit into the execution timer to manufacture faster compilation. + +Preparation keys are internal value-only hashable identities. No consumer decodes +their tuple fields or dispatches on them; no enum or extra identity-class roster is +needed. Concrete operation classes retain the distinct behavior and lifecycle. +The operation ABC owns the shared completion algorithm. The existing module and +callable declarations determine operation membership; the batch owns scheduling. + +## Migration and new-case experiment + +Introduce a preparation operation ABC with shared completion and failure semantics; +derive module-registry, registry-family and declared-hook operations; share hook +execution through inheritance, retaining module/callable identity policies. +Create callable operations lazily so registry -> module hook -> callable hook +effect and validation order stays intact. Move the sole child scheduler to a +generic cache batch over operation projections. Migrate the real compiler boundary +and existing public helper entry points. Delete the old private completion set, +duplicated module/callable implementations and family-specific scheduler. + +A newly declared compiler-prepared registry family must participate without +editing scheduler membership. A new hook type must inherit the common execution +algorithm and need only supply its distinguishing identity. Test actual compiler +consumer order, equivalent bound-method identities, hook replacement, failed-hook +retry, invalid-hook validation after prior effects, fork child PIDs and registry +deduplication. Preserve unsupported-fork short-circuit and warm-cache opt-out. + +## Coverage and proof limits + +The NRA syntax census covers all 696 original production modules and 5,014 original +classes; 5,002 join the compact class projection. The 12 conditional/function-local +unprojected declarations remain OPEN in the saved census, outside this migration. +The full semantic scan with all-package context exceeded the explicit 165-second +shell budget (exit 124); retain its empty output and stderr as timeout evidence. +A completed two-module focused raw scan reported zero findings in 0.783 seconds; +that is not a global ownership or equivalence proof. A four-module raw scan is +pending. No raw mapping/record finding is promoted from an incomplete scan. + +The migration requires authored nominal operations and lazy metadata effects; +NRA has no synthesized equivalence recipe for this behavior. Use its supported +exact target patches and new-file operation for a source-checked transaction, +label the body changes authored, inspect one combined diff, and validate behavior +through the existing and new consumer tests plus ordinary cold/warm benchmark +output parity. Do not describe syntax preflight or an empty guard suite as proof. + +OPEN: exhaustive dynamic monkeypatch/alias behavior, independent native execution +equivalence proof, whole-package detector completion, exact refactor-audit skill +identity, general cold kernel compilation below a few seconds, and issue #162's +remaining lazy-JIT execution boundaries. None is claimed complete by this refactor. diff --git a/benchmark/results/perf_nominal_preparation_20260929/probe_spawn_cache.py b/benchmark/results/perf_nominal_preparation_20260929/probe_spawn_cache.py new file mode 100644 index 0000000000..76935e2163 --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/probe_spawn_cache.py @@ -0,0 +1,99 @@ +"""Observe dispatcher cache hits in genuinely fresh spawned interpreters.""" + +import json +import multiprocessing +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, "/home/ts/code/projects/openhcs-compile-perf") + + +def prepare(queue): + from numba import config + from numba.core.registry import CPUDispatcher + + from openhcs.processing.backends.cellprofiler.intensity import ( + ObjectIntensityBackendStrategy, + ) + from openhcs.processing.backends.cellprofiler.shape import ( + ShapeMeasurementBackendStrategy, + ) + + started = time.perf_counter() + ObjectIntensityBackendStrategy.prepare_registered_family() + ShapeMeasurementBackendStrategy.prepare_registered_family() + preparation_seconds = time.perf_counter() - started + dispatchers = {} + for name, module in tuple(sys.modules.items()): + if module is None or not name.startswith("openhcs."): + continue + for value in vars(module).values(): + if isinstance(value, CPUDispatcher): + dispatchers[id(value)] = value + stats = [ + { + "callable": f"{dispatcher.py_func.__module__}.{dispatcher.py_func.__qualname__}", + "cache_hits": sum(dispatcher.stats.cache_hits.values()), + "cache_misses": sum(dispatcher.stats.cache_misses.values()), + } + for dispatcher in dispatchers.values() + if dispatcher.signatures + ] + queue.put( + { + "pid": os.getpid(), + "parent_pid": os.getppid(), + "cache_directory": config.CACHE_DIR, + "preparation_seconds": preparation_seconds, + "cache_hits": sum(row["cache_hits"] for row in stats), + "cache_misses": sum(row["cache_misses"] for row in stats), + "dispatchers": stats, + } + ) + + +if __name__ == "__main__": + context = multiprocessing.get_context("spawn") + queue = context.SimpleQueue() + children = [context.Process(target=prepare, args=(queue,)) for _ in range(2)] + started = time.perf_counter() + for child in children: + child.start() + rows = [queue.get() for child in children] + for child in children: + child.join() + if child.exitcode != 0: + raise RuntimeError(f"spawned preparation exited {child.exitcode}") + queue.close() + elapsed = time.perf_counter() - started + assert len({row["pid"] for row in rows}) == 2 + assert all(row["cache_hits"] > 0 and row["cache_misses"] == 0 for row in rows), rows + destination = Path( + "/home/ts/code/projects/openhcs-benchmark-runs/perf-shared-kernel-cache-spawn-dispatcher-hits-20260929.json" + ) + destination.write_text( + json.dumps( + { + "start_method": "spawn", + "parent_pid": os.getpid(), + "total_seconds": elapsed, + "workers": rows, + }, + indent=2, + ) + + "\n" + ) + print(destination) + print( + json.dumps( + { + "total_seconds": elapsed, + "workers": [ + {key: value for key, value in row.items() if key != "dispatchers"} + for row in rows + ], + } + ) + ) diff --git a/benchmark/results/perf_nominal_preparation_20260929/provenance.json b/benchmark/results/perf_nominal_preparation_20260929/provenance.json new file mode 100644 index 0000000000..09c682423d --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/provenance.json @@ -0,0 +1,56 @@ +{ + "benchmarked_base": "213439a04", + "synced_base": "875332be1", + "candidate_source_sha256": { + "openhcs/core/autoregister_preparation.py": "49762654d81fbe90073930950cb27c94b3552f529e34c5e0afa860ab0d27bdf0", + "openhcs/core/callable_contract.py": "2a2373ff5b0d733f536ce04a06d4490d69d125496cc32de2342f82d77fbea469", + "openhcs/core/processing_preparation.py": "3bcb8adb4bf6ea8f917e265aee5144ecb8f6c8a538131e3830d6a2e233d82496", + "openhcs/core/steps/function_runtime.py": "cb5da938b25e74c46b90f152f9caedafbe21efd9aefc37dc58b5f80cc9337efe" + }, + "advisor_revision": "0844525", + "before_class_census": { + "modules": 696, + "original_classes": 5014, + "projected_classes": 5002, + "open_classes": 12 + }, + "after_class_census": { + "modules": 697, + "original_classes": 5022, + "projected_classes": 5010, + "open_classes": 12 + }, + "full_semantic_scan": { + "shell_budget_seconds": 165, + "exit_code": 124, + "completed": false + }, + "bounded_raw_scan": { + "before_modules": 4, + "after_modules": 5, + "before_findings": 2, + "after_findings": 2, + "global_ownership_proof": false + }, + "validation": { + "unit_tests": 294, + "black_changed_files": 6, + "ruff_baseline_findings": 37, + "ruff_candidate_findings": 36, + "ruff_new_findings": 0 + }, + "nra_artifact_root": "/home/ts/code/projects/openhcs-benchmark-runs", + "no_numerical_algorithm_change": true, + "shared_environment_update": { + "checkout_revision": "8b090e2e9", + "polystore_revision": "c16b8fc99a07f17e92e5fbf46f312d73cc9c46d7", + "resolver_upgrade_strategy": "eager", + "pip_check_passed": true, + "actual_fresh_mcp_health_passed": true, + "environment_tests_passed": 32, + "compiler_tests_passed_after_upgrade": 294, + "package_provenance": "shared_environment.json", + "frozen_versions": "shared_requirements.txt", + "mcp_health": "shared_mcp_health.json" + } +} diff --git a/benchmark/results/perf_nominal_preparation_20260929/shared_environment.json b/benchmark/results/perf_nominal_preparation_20260929/shared_environment.json new file mode 100644 index 0000000000..347307022f --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/shared_environment.json @@ -0,0 +1,105 @@ +{ + "packages": [ + { + "distribution": "openhcs", + "version": "0.8.6", + "loaded_file": "/home/ts/code/projects/openhcs/openhcs/__init__.py", + "revision": "8b090e2e9a1a1550a01b7096c3f6cdf7d65ab2f6", + "direct_url": null + }, + { + "distribution": "objectstate", + "version": "1.1.8", + "loaded_file": "/home/ts/code/projects/openhcs/external/ObjectState/src/objectstate/__init__.py", + "revision": "b0004daf3223326ac78689ba7b2e68abc18ae298", + "direct_url": { + "dir_info": { + "editable": true + }, + "url": "file:///home/ts/code/projects/openhcs/external/ObjectState" + } + }, + { + "distribution": "python-introspect", + "version": "0.1.14", + "loaded_file": "/home/ts/code/projects/openhcs/external/python-introspect/src/python_introspect/__init__.py", + "revision": "4e8f8bde5f750d9a50eacac80acd3806123addd8", + "direct_url": null + }, + { + "distribution": "metaclass-registry", + "version": "0.2.1", + "loaded_file": "/home/ts/code/projects/openhcs/external/metaclass-registry/src/metaclass_registry/__init__.py", + "revision": "ca0a87e873f929b311a87a4d60cd3bfba315dbcf", + "direct_url": { + "dir_info": { + "editable": true + }, + "url": "file:///home/ts/code/projects/openhcs/external/metaclass-registry" + } + }, + { + "distribution": "arraybridge", + "version": "0.3.4", + "loaded_file": "/home/ts/code/projects/openhcs/external/arraybridge/src/arraybridge/__init__.py", + "revision": "fddd9857bcd21468d20bc8547a769e0c6c9f1906", + "direct_url": { + "dir_info": { + "editable": true + }, + "url": "file:///home/ts/code/projects/openhcs/external/arraybridge" + } + }, + { + "distribution": "pycodify", + "version": "0.1.3", + "loaded_file": "/home/ts/code/projects/openhcs/external/pycodify/src/pycodify/__init__.py", + "revision": "792b03413331fdf107e369c924c257a2f0b16126", + "direct_url": { + "dir_info": { + "editable": true + }, + "url": "file:///home/ts/code/projects/openhcs/external/pycodify" + } + }, + { + "distribution": "polystore", + "version": "0.2.19", + "loaded_file": "/home/ts/code/projects/openhcs/external/PolyStore/src/polystore/__init__.py", + "revision": "c16b8fc99a07f17e92e5fbf46f312d73cc9c46d7", + "direct_url": null + }, + { + "distribution": "pyqt-reactive", + "version": "0.3.24", + "loaded_file": "/home/ts/code/projects/openhcs/external/pyqt-reactive/src/pyqt_reactive/__init__.py", + "revision": "a51c3d965f8ffdefe95a2b4b37347a85341d483a", + "direct_url": { + "dir_info": { + "editable": true + }, + "url": "file:///home/ts/code/projects/openhcs/external/pyqt-reactive" + } + }, + { + "distribution": "zmqruntime", + "version": "0.2.24", + "loaded_file": "/home/ts/code/projects/openhcs/external/zmqruntime/src/zmqruntime/__init__.py", + "revision": "1d3b32f4fcead23d5d2079c975eb2ae7646a1478", + "direct_url": { + "dir_info": { + "editable": true + }, + "url": "file:///home/ts/code/projects/openhcs/external/zmqruntime" + } + } + ], + "child_environment_keys": [ + "OPENHCS_CPU_ONLY", + "OPENHCS_HEADLESS", + "NUMBA_CACHE_DIR", + "OPENHCS_USE_THREADING", + "POLYSTORE_IMAGEJ_CACHE_ROOT", + "POLYSTORE_IMAGEJ_ALLOW_DOWNLOAD" + ] +} diff --git a/benchmark/results/perf_nominal_preparation_20260929/shared_mcp_health.json b/benchmark/results/perf_nominal_preparation_20260929/shared_mcp_health.json new file mode 100644 index 0000000000..dd0c13f0b3 --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/shared_mcp_health.json @@ -0,0 +1,45 @@ +{ + "errors": [], + "results": [ + { + "mcp_error": false, + "payloads": [ + { + "automatic_recovery_on_reconnect": false, + "errors": [], + "installation_pointer_available": null, + "installation_pointer_changed_since_import": false, + "installation_pointer_path": null, + "missing_packaged_resource_paths": [], + "openhcs_version": "0.8.6", + "packaged_resource_count": 56, + "packaged_resources_ready": true, + "reconnect_owner": null, + "reconnect_required": false, + "recovery_reason": "current", + "restart_command": [], + "restart_command_is_stable": false, + "restart_hint": null, + "restart_required": false, + "retry_after_reconnect": false, + "schema_version": "openhcs.agent.v1", + "server_current_mtime_ns": 1790621723552943921, + "server_import_mtime_ns": 1790621723552943921, + "server_process_id": 1412761, + "server_source_changed_since_import": false, + "server_source_path": "/home/ts/code/projects/openhcs/openhcs/mcp/server.py", + "service": "openhcs.mcp", + "stale_source_paths": [], + "started_at_unix": 1790719298.8357024, + "status": "ok", + "warnings": [] + } + ], + "tool": "openhcs_health_check" + } + ], + "server": { + "command": "/home/ts/code/projects/openhcs/.venv/bin/python", + "module": "openhcs.mcp" + } +} diff --git a/benchmark/results/perf_nominal_preparation_20260929/shared_requirements.txt b/benchmark/results/perf_nominal_preparation_20260929/shared_requirements.txt new file mode 100644 index 0000000000..bab650b797 --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/shared_requirements.txt @@ -0,0 +1,178 @@ +abi3info==2026.9.25 +aiobotocore==3.9.1 +aiohappyeyeballs==2.7.1 +aiohttp==3.14.3 +aioitertools==0.13.0 +aiosignal==1.4.0 +annotated-types==0.8.0 +anyio==4.15.1 +array-api-compat==1.15.0 +-e git+https://github.com/OpenHCSDev/arraybridge.git@fddd9857bcd21468d20bc8547a769e0c6c9f1906#egg=arraybridge&subdirectory=../../../external/arraybridge +ast_serialize==0.11.2 +attrs==26.1.0 +black==26.5.1 +botocore==1.43.75 +cattrs==26.2.1 +centrosome==1.3.4 +certifi==2026.7.22 +cffi==2.1.1 +charset-normalizer==3.5.1 +cjdk==0.5.0 +click==8.5.0 +cloudpickle==3.1.2 +colorama==0.4.6 +contourpy==1.4.0 +coverage==7.16.2 +cryptography==50.0.1 +cycler==0.12.1 +dask==2026.8.0 +defusedxml==0.7.1 +Deprecated==3.0.0 +deprecation==2.1.0 +dill==0.4.1 +distinctipy==1.3.4 +docstring_parser==0.18.0 +docutils==0.23 +donfig==0.8.1.post1 +et_xmlfile==2.0.0 +execnet==2.1.2 +fonttools==4.66.1 +frozenlist==1.8.0 +fsspec==2026.9.0 +genbadge==1.1.3 +google-crc32c==1.9.0 +GPUtil==1.4.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +httpx-sse==0.4.3 +idna==3.20 +imagecodecs==2026.8.16 +ImageIO==2.38.0 +imglyb==2.1.0 +iniconfig==2.3.0 +jgo==3.1.0 +jmespath==1.1.0 +joblib==1.6.0 +jpype1==1.7.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kaitaistruct==0.11 +kiwisolver==1.5.1 +labeling==0.1.15 +lazy-loader==0.6 +librt==0.16.0 +llvmlite==0.49.0 +locket==1.0.0 +magicgui==0.10.2 +mahotas==1.4.19 +markdown-it-py==4.2.0 +matplotlib==3.11.2 +mcp==1.30.0 +mdurl==0.1.2 +-e git+https://github.com/OpenHCSDev/metaclass-registry.git@ca0a87e873f929b311a87a4d60cd3bfba315dbcf#egg=metaclass_registry&subdirectory=../../../external/metaclass-registry +msgspec==0.22.0 +Multi-Template-Matching==2.0.1 +multidict==6.9.1 +mypy==2.3.1 +mypy_extensions==1.1.0 +narwhals==2.26.0 +networkx==3.7 +numba==0.67.0 +numcodecs==0.17.0 +numpy==2.5.3 +-e git+https://github.com/OpenHCSDev/ObjectState.git@b0004daf3223326ac78689ba7b2e68abc18ae298#egg=objectstate&subdirectory=../../../external/ObjectState +ome-zarr==0.19.2 +ome-zarr-models==1.8.1 +opencv-python-headless==5.0.0.93 +openhcs==0.8.6 +openpyxl==3.1.5 +packaging==26.3 +pandas==3.0.6 +partd==1.4.2 +pathspec==1.1.1 +pefile==2024.8.26 +pillow==11.3.0 +pip==26.2.1 +platformdirs==4.12.2 +pluggy==1.6.0 +-e git+https://github.com/OpenHCSDev/PolyStore.git@c16b8fc99a07f17e92e5fbf46f312d73cc9c46d7#egg=polystore&subdirectory=../../../external/PolyStore +portalocker==4.4.0 +progressbar2==4.6.0 +propcache==0.5.4 +psutil==7.2.2 +psygnal==0.16.1 +-e git+https://github.com/OpenHCSDev/pycodify.git@792b03413331fdf107e369c924c257a2f0b16126#egg=pycodify&subdirectory=../../../external/pycodify +pyconify==0.2.1 +pycparser==3.0 +pydantic==2.12.5 +pydantic-settings==2.15.0 +pydantic-zarr==0.10.0 +pydantic_core==2.41.5 +pyelftools==0.33 +Pygments==2.21.0 +pyimagej==1.7.0 +PyJWT==2.15.1 +PyOpenGL==3.1.10 +pyparsing==3.3.3 +-e git+https://github.com/OpenHCSDev/PyQT-reactive.git@a51c3d965f8ffdefe95a2b4b37347a85341d483a#egg=pyqt_reactive&subdirectory=../../../external/pyqt-reactive +PyQt6==6.11.0 +PyQt6-QScintilla==2.14.1 +PyQt6-Qt6==6.11.2 +PyQt6_sip==13.12.0 +pyqtgraph==0.14.0 +pytest==9.1.1 +pytest-asyncio==1.4.0 +pytest-cov==7.1.0 +pytest-qt==4.5.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.3 +-e git+https://github.com/OpenHCSDev/python-introspect.git@4e8f8bde5f750d9a50eacac80acd3806123addd8#egg=python_introspect&subdirectory=../../../external/python-introspect +python-multipart==0.0.32 +python-utils==4.0.1 +pytokens==0.4.1 +PyWavelets==1.10.0 +PyYAML==6.0.3 +pyzmq==27.2.0 +QtPy==2.4.3 +rangehttpserver==1.4.0 +referencing==0.37.0 +requests==2.34.2 +requests-cache==1.1.1 +rich==13.6.0 +roifile==2026.9.22 +rpds-py==2026.6.3 +ruff==0.16.9 +s3fs==2026.9.0 +scikit-image==0.26.0 +scikit-learn==1.9.1 +scipy==1.18.1 +scyjava==1.12.5 +semver==3.1.0 +setuptools==84.0.0 +six==1.17.0 +skan==0.13.1 +sse-starlette==3.5.0 +starlette==1.7.0 +superqt==0.8.2 +threadpoolctl==3.7.0 +tifffile==2026.9.20 +tomli_w==1.2.0 +tomlkit==0.15.1 +toolz==1.1.0 +tqdm==4.70.1 +transformnd==0.7.2 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +url-normalize==3.0.1 +urllib3==2.8.0 +uvicorn==0.54.0 +watchdog==6.0.0 +wcag-contrast-ratio==0.9 +wrapt==2.5.0 +xarray==2026.7.0 +xlsxwriter==3.2.9 +yarl==1.25.1 +zarr==3.4.0 +-e git+https://github.com/OpenHCSDev/ZMQRuntime.git@1d3b32f4fcead23d5d2079c975eb2ae7646a1478#egg=zmqruntime&subdirectory=../../../external/zmqruntime diff --git a/benchmark/results/perf_nominal_preparation_20260929/spawn_cache_hits.json b/benchmark/results/perf_nominal_preparation_20260929/spawn_cache_hits.json new file mode 100644 index 0000000000..39aa70ecf8 --- /dev/null +++ b/benchmark/results/perf_nominal_preparation_20260929/spawn_cache_hits.json @@ -0,0 +1,137 @@ +{ + "start_method": "spawn", + "parent_pid": 1255845, + "total_seconds": 1.8188385558314621, + "workers": [ + { + "pid": 1255847, + "parent_pid": 1255845, + "cache_directory": "/tmp/openhcs-cold-candidate-20260929-c1", + "preparation_seconds": 0.3565232176333666, + "cache_hits": 12, + "cache_misses": 0, + "dispatchers": [ + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_quantiles_grouped_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_scan_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_foreground_edges_3d_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_scan_3d_sparse_batch_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_quantiles_3d_sparse_batch_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.analysis.region_properties._dense_label_region_properties_2d_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.label_geometry._numpy124_aquicksort_indices_numba", + "cache_hits": 2, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.label_geometry._feret_diameters_from_labels_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.shape._radius_features_from_labels_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.shape._distance_to_label_edge_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.shape._has_touching_foreground_labels_numba", + "cache_hits": 1, + "cache_misses": 0 + } + ] + }, + { + "pid": 1255848, + "parent_pid": 1255845, + "cache_directory": "/tmp/openhcs-cold-candidate-20260929-c1", + "preparation_seconds": 0.35315418988466263, + "cache_hits": 12, + "cache_misses": 0, + "dispatchers": [ + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_quantiles_grouped_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_scan_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_foreground_edges_3d_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_scan_3d_sparse_batch_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.intensity_object_quantiles_numba._object_intensity_quantiles_3d_sparse_batch_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.analysis.region_properties._dense_label_region_properties_2d_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.label_geometry._numpy124_aquicksort_indices_numba", + "cache_hits": 2, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.label_geometry._feret_diameters_from_labels_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.shape._radius_features_from_labels_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.shape._distance_to_label_edge_numba", + "cache_hits": 1, + "cache_misses": 0 + }, + { + "callable": "openhcs.processing.backends.cellprofiler.shape._has_touching_foreground_labels_numba", + "cache_hits": 1, + "cache_misses": 0 + } + ] + } + ] +} diff --git a/openhcs/core/autoregister_preparation.py b/openhcs/core/autoregister_preparation.py index 22d480707a..30bd69b3d1 100644 --- a/openhcs/core/autoregister_preparation.py +++ b/openhcs/core/autoregister_preparation.py @@ -2,9 +2,7 @@ from __future__ import annotations -import multiprocessing import sys -from concurrent.futures import ProcessPoolExecutor from dataclasses import dataclass from functools import lru_cache from types import ModuleType @@ -45,33 +43,6 @@ def discover_module_registries( """Force registry discovery without invoking backend family warmups.""" return cls._scan_module_registries(modules, prepare_families=False) - @classmethod - def prepare_module_caches_in_children( - cls, - modules: Iterable[ModuleType | None], - ) -> None: - """Populate eligible cold compiler caches before normal parent preparation.""" - if "fork" not in multiprocessing.get_all_start_methods(): - return - families = tuple( - family - for family in cls._unique_module_registry_families( - modules, compiler_prepared_only=True - ) - if family.can_prepare_in_child() - ) - if len(families) < 2: - return - with ProcessPoolExecutor( - max_workers=min(4, len(families)), - mp_context=multiprocessing.get_context("fork"), - ) as executor: - futures = tuple( - executor.submit(family.prepare_registered_family) for family in families - ) - for future in futures: - future.result() - @classmethod def _scan_module_registries( cls, @@ -81,7 +52,7 @@ def _scan_module_registries( ) -> AutoRegisterRegistryPreparationReport: """Scan module registries and optionally prepare module-owned families.""" report = AutoRegisterRegistryPreparationReport() - for candidate in cls._unique_module_registry_families( + for candidate in cls.module_registry_owners( modules, compiler_prepared_only=prepare_families ): registry = candidate.__registry__ @@ -93,7 +64,7 @@ def _scan_module_registries( return report @classmethod - def _unique_module_registry_families( + def module_registry_owners( cls, modules: Iterable[ModuleType | None], *, diff --git a/openhcs/core/callable_contract.py b/openhcs/core/callable_contract.py index db07094deb..64c7b38d8e 100644 --- a/openhcs/core/callable_contract.py +++ b/openhcs/core/callable_contract.py @@ -7,15 +7,13 @@ from __future__ import annotations import dataclasses -import importlib import inspect from abc import ABC, abstractmethod -from collections.abc import Callable, Hashable, Iterable, MutableMapping +from collections.abc import Callable, Iterable, MutableMapping from dataclasses import MISSING, asdict, dataclass, fields, is_dataclass from enum import Enum -from functools import lru_cache, wraps +from functools import wraps from pathlib import Path -from threading import Lock from types import MappingProxyType from typing import ( TYPE_CHECKING, @@ -66,8 +64,6 @@ CallableNamespace = Mapping[str, Any] _EnumT = TypeVar("_EnumT", bound=Enum) -_prepared_callable_keys: set[tuple[str, str, Hashable]] = set() -_prepared_callable_lock = Lock() CallableRuntimeCacheKey = int @@ -1667,80 +1663,24 @@ def preserves_primary_image_carrier(func: Any) -> Any: def prepare_processing_callable(func: Any) -> None: - """Run an optional callable preparation hook before timed data processing.""" - projection = CallableProjection.from_callable(func) - if projection.module_name is not None: - prepare_module_autoregister_families(projection.module_name) - _prepare_processing_module(projection.module_name) - - prepare = projection.namespace.get(FunctionContractAttribute.processing_prepare) - if prepare is None: - return - if not callable(prepare): - raise TypeError( - f"{projection.name!r}.{FunctionContractAttribute.processing_prepare} must be " - f"callable, got {type(prepare).__name__}." - ) - if projection.module_name is None: - module_label = "" - else: - module_label = projection.module_name - prepare_key = ( - "callable", - f"{module_label}.{projection.name}", - _prepare_callable_identity(prepare), - ) - with _prepared_callable_lock: - if prepare_key in _prepared_callable_keys: - return - prepare() - with _prepared_callable_lock: - _prepared_callable_keys.add(prepare_key) + """Prepare declarations through their shared process-local operation contract.""" + from openhcs.core.processing_preparation import CallablePreparation + + CallablePreparation.from_callable(func).prepare() def reset_processing_callable_preparation_cache() -> None: """Clear process-local preparation caches for deterministic tests and tooling.""" - with _prepared_callable_lock: - _prepared_callable_keys.clear() - prepare_module_autoregister_families.cache_clear() - from openhcs.core.autoregister_preparation import AutoRegisterRegistryPreparation - - AutoRegisterRegistryPreparation.cached_module_registry_families.cache_clear() + from openhcs.core.processing_preparation import PreparationOperation - -def _prepare_callable_identity(prepare: Callable[..., Any]) -> tuple[str, str]: - """Return a stable identity for process-local prepare-hook caching.""" - return str(prepare.__module__), str(prepare.__qualname__) - - -def _prepare_processing_module(module_name: str) -> None: - """Run an optional module-level preparation hook exactly once.""" - module = importlib.import_module(module_name) - prepare = vars(module).get(FunctionContractAttribute.processing_prepare) - if prepare is None: - return - if not callable(prepare): - raise TypeError( - f"Module {module_name!r}.{FunctionContractAttribute.processing_prepare} " - "must be callable, " - f"got {type(prepare).__name__}." - ) - prepare_key = ("module", module_name, id(prepare)) - with _prepared_callable_lock: - if prepare_key in _prepared_callable_keys: - return - prepare() - with _prepared_callable_lock: - _prepared_callable_keys.add(prepare_key) + PreparationOperation.reset() -@lru_cache(maxsize=None) def prepare_module_autoregister_families(module_name: str) -> None: - """Prepare AutoRegisterMeta families imported by a callable module.""" - module = importlib.import_module(module_name) - from openhcs.core.autoregister_preparation import AutoRegisterRegistryPreparation + """Prepare the module's declared registry obligation once in this process.""" + from openhcs.core.processing_preparation import ModuleRegistryPreparation - AutoRegisterRegistryPreparation.prepare_module_registered_families((module,)) + ModuleRegistryPreparation(module_name).prepare() def _is_function_reference(func: Any) -> bool: diff --git a/openhcs/core/processing_preparation.py b/openhcs/core/processing_preparation.py new file mode 100644 index 0000000000..c4cd7a5b7b --- /dev/null +++ b/openhcs/core/processing_preparation.py @@ -0,0 +1,218 @@ +"""Declared processing preparation and its process-local readiness.""" + +from __future__ import annotations + +import importlib +import multiprocessing +from abc import ABC, abstractmethod +from collections.abc import Callable, Hashable, Iterable, Iterator +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass +from threading import Lock +from typing import ClassVar + +from openhcs.core.autoregister_preparation import AutoRegisterRegistryPreparation +from openhcs.core.callable_contract import ( + CallableMetadataReader, + CallableProjection, + CompilerPreparedAutoRegisterFamily, +) +from openhcs.core.function_contract_metadata import FunctionContractAttribute + + +class PreparationOperation(ABC): + """Share successful completion while retaining each obligation's identity.""" + + _completed: ClassVar[set[Hashable]] = set() + _lock: ClassVar[Lock] = Lock() + + @property + @abstractmethod + def identity(self) -> Hashable: + """Return the process-local obligation, independent of runtime adapters.""" + + @abstractmethod + def execute(self) -> None: + """Perform this operation in the current process.""" + + def cache_operations(self) -> tuple[PreparationOperation, ...]: + """Project cache work without claiming parent readiness.""" + return (self,) + + def can_prepare_in_child(self) -> bool: + """Opt out unless this operation declares persistent child-safe work.""" + return False + + def prepare(self) -> None: + """Skip completed work and leave failed operations eligible for retry.""" + identity = self.identity + with self._lock: + if identity in self._completed: + return + self.execute() + with self._lock: + self._completed.add(identity) + + @classmethod + def reset(cls) -> None: + """Clear core readiness, preserving independently owned kernel caches.""" + with cls._lock: + cls._completed.clear() + AutoRegisterRegistryPreparation.cached_module_registry_families.cache_clear() + + +@dataclass(frozen=True, slots=True) +class RegistryFamilyPreparation(PreparationOperation): + """Execute a family's declared warmup and project its admitted child work.""" + + family: type[CompilerPreparedAutoRegisterFamily] + + @property + def identity(self) -> Hashable: + return ("registry-family", id(self.family.__registry__)) + + def execute(self) -> None: + self.family.prepare_registered_family() + + def can_prepare_in_child(self) -> bool: + return self.family.can_prepare_in_child() + + +@dataclass(frozen=True, slots=True) +class ModuleRegistryPreparation(PreparationOperation): + """Derive the same registry owners for parent preparation and child caches.""" + + module_name: str + + @property + def identity(self) -> Hashable: + return ("module-registry", self.module_name) + + def execute(self) -> None: + AutoRegisterRegistryPreparation.prepare_module_registered_families( + (importlib.import_module(self.module_name),) + ) + + def cache_operations(self) -> tuple[PreparationOperation, ...]: + return tuple( + RegistryFamilyPreparation(family) + for family in AutoRegisterRegistryPreparation.module_registry_owners( + (importlib.import_module(self.module_name),), + compiler_prepared_only=True, + ) + ) + + +@dataclass(frozen=True, slots=True) +class DeclaredHookPreparation(PreparationOperation): + """Own hook execution once; contextual leaves supply only identity policy.""" + + hook: Callable[..., object] + + def execute(self) -> None: + self.hook() + + +@dataclass(frozen=True, slots=True) +class ModuleHookPreparation(DeclaredHookPreparation): + module_name: str + + @property + def identity(self) -> Hashable: + return ("module", self.module_name, id(self.hook)) + + +@dataclass(frozen=True, slots=True) +class CallableHookPreparation(DeclaredHookPreparation): + module_name: str | None + function_name: str + + @property + def identity(self) -> Hashable: + module_label = "" if self.module_name is None else self.module_name + return ( + "callable", + f"{module_label}.{self.function_name}", + (str(self.hook.__module__), str(self.hook.__qualname__)), + ) + + +@dataclass(frozen=True, slots=True) +class CallablePreparation: + """Project declared operations lazily in their original effect order.""" + + projection: CallableProjection + + @classmethod + def from_callable(cls, func: Callable[..., object]) -> CallablePreparation: + return cls(CallableProjection.from_callable(func)) + + def operations(self) -> Iterator[PreparationOperation]: + """Read later hook declarations only after preceding operations finish.""" + module_name = self.projection.module_name + if module_name is not None: + yield ModuleRegistryPreparation(module_name) + module = importlib.import_module(module_name) + hook = CallableMetadataReader( + vars(module), f"Module {module_name}" + ).optional_callable(FunctionContractAttribute.processing_prepare) + if hook is not None: + yield ModuleHookPreparation(hook, module_name) + hook = CallableMetadataReader( + self.projection.namespace, self.projection.name + ).optional_callable(FunctionContractAttribute.processing_prepare) + if hook is not None: + yield CallableHookPreparation(hook, module_name, self.projection.name) + + def prepare(self) -> None: + for operation in self.operations(): + operation.prepare() + + def cache_sources(self) -> tuple[PreparationOperation, ...]: + module_name = self.projection.module_name + if module_name is None: + return () + return (ModuleRegistryPreparation(module_name),) + + +@dataclass(frozen=True, slots=True) +class PreparationCacheBatch: + """Schedule derived cache operations without interpreting backend families.""" + + preparations: tuple[PreparationOperation, ...] + + @classmethod + def from_callables( + cls, callables: Iterable[Callable[..., object]] + ) -> PreparationCacheBatch: + sources = { + operation.identity: operation + for func in callables + for operation in CallablePreparation.from_callable(func).cache_sources() + } + return cls(tuple(sources.values())) + + def populate_child_caches(self) -> None: + if "fork" not in multiprocessing.get_all_start_methods(): + return + operations = { + operation.identity: operation + for preparation in self.preparations + for operation in preparation.cache_operations() + } + children = tuple( + operation + for operation in operations.values() + if operation.can_prepare_in_child() + ) + if len(children) < 2: + return + with ProcessPoolExecutor( + max_workers=min(4, len(children)), + mp_context=multiprocessing.get_context("fork"), + ) as executor: + futures = tuple( + executor.submit(operation.execute) for operation in children + ) + for future in futures: + future.result() diff --git a/openhcs/core/steps/function_runtime.py b/openhcs/core/steps/function_runtime.py index 0c55aff064..f1011d28ae 100644 --- a/openhcs/core/steps/function_runtime.py +++ b/openhcs/core/steps/function_runtime.py @@ -8,7 +8,6 @@ from functools import singledispatch import logging import os -import importlib import time from collections.abc import Sequence as SequenceABC from dataclasses import dataclass, field, replace @@ -43,7 +42,6 @@ ArtifactSpecRef, ) from openhcs.core.callable_contract import ( - CallableProjection, CallableRuntimeCacheKey, prepare_processing_callable, ) @@ -1712,22 +1710,13 @@ def prepare_compiled_context_callables( groups.append(group) prepared_invocation_count += len(group.invocations) prepared_group_keys.add(prepare_key) - module_names = dict.fromkeys( - projection.module_name + from openhcs.core.processing_preparation import PreparationCacheBatch + + PreparationCacheBatch.from_callables( + invocation.contract.resolve_canonical_raw_callable() for group in groups for invocation in group.invocations - for projection in ( - CallableProjection.from_callable( - invocation.contract.resolve_canonical_raw_callable() - ), - ) - if projection.module_name is not None - ) - from openhcs.core.autoregister_preparation import AutoRegisterRegistryPreparation - - AutoRegisterRegistryPreparation.prepare_module_caches_in_children( - importlib.import_module(name) for name in module_names - ) + ).populate_child_caches() for group in groups: # Parent preparation loads child-produced machine code and owns every # process-local hook/cache that execution workers inherit. diff --git a/tests/unit/test_fork_compiler_preparation.py b/tests/unit/test_fork_compiler_preparation.py index db24d1df45..c4f146de28 100644 --- a/tests/unit/test_fork_compiler_preparation.py +++ b/tests/unit/test_fork_compiler_preparation.py @@ -23,6 +23,7 @@ CompiledFunctionPattern, FunctionInvocationKey, ) +from openhcs.core.processing_preparation import PreparationCacheBatch from openhcs.core.steps.function_runtime import prepare_compiled_context_callables from openhcs.processing.backends.cellprofiler._backend import ( CellProfilerBackendStrategyMixin, @@ -55,6 +56,10 @@ class _SecondCacheFamily(_FirstCacheFamily): __registry__: ClassVar[dict] = {} +def _processing_callable(image): + return image + + @pytest.mark.skipif( "fork" not in multiprocessing.get_all_start_methods(), reason="fork required" ) @@ -70,11 +75,8 @@ def test_child_preparation_deduplicates_registries_and_propagates_failure( lambda module: (_FirstCacheFamily, _FirstCacheFamily, _SecondCacheFamily) ), ) - import sys - - AutoRegisterRegistryPreparation.prepare_module_caches_in_children( - (sys.modules[__name__],) - ) + batch = PreparationCacheBatch.from_callables((_processing_callable,)) + batch.populate_child_caches() assert {path.name for path in tmp_path.iterdir()} == { "_FirstCacheFamily", "_SecondCacheFamily", @@ -83,9 +85,7 @@ def test_child_preparation_deduplicates_registries_and_propagates_failure( monkeypatch.setattr(_SecondCacheFamily, "fail", True) with pytest.raises(RuntimeError, match="preparation failed"): - AutoRegisterRegistryPreparation.prepare_module_caches_in_children( - (sys.modules[__name__],) - ) + batch.populate_child_caches() def test_backend_child_preparation_requires_empty_explicit_cpu_cache( @@ -111,13 +111,19 @@ def test_backend_child_preparation_requires_empty_explicit_cpu_cache( def test_platform_without_fork_keeps_parent_preparation_path(monkeypatch): monkeypatch.setattr(multiprocessing, "get_all_start_methods", lambda: ["spawn"]) - def modules(): + def discover(module): raise AssertionError( "unsupported child preparation must not discover registries" ) - yield - AutoRegisterRegistryPreparation.prepare_module_caches_in_children(modules()) + monkeypatch.setattr( + AutoRegisterRegistryPreparation, + "module_registry_families", + staticmethod(discover), + ) + PreparationCacheBatch.from_callables( + (_processing_callable,) + ).populate_child_caches() def test_compiled_context_preparation_runs_parent_hook_after_children( @@ -149,12 +155,14 @@ def process(image): step_plans={0: SimpleNamespace(step_index=0, compiled_function_pattern=pattern)} ) - def prepare_children(modules): - events.append(tuple(module.__name__ for module in modules)) + def prepare_children(batch): + events.append( + tuple(preparation.module_name for preparation in batch.preparations) + ) monkeypatch.setattr( - AutoRegisterRegistryPreparation, - "prepare_module_caches_in_children", + PreparationCacheBatch, + "populate_child_caches", prepare_children, ) reset_processing_callable_preparation_cache() diff --git a/tests/unit/test_processing_preparation.py b/tests/unit/test_processing_preparation.py new file mode 100644 index 0000000000..b987c241e2 --- /dev/null +++ b/tests/unit/test_processing_preparation.py @@ -0,0 +1,207 @@ +"""Preparation preserves declaration effects and independent cache lifetimes.""" + +import multiprocessing +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType + +import pytest + +from openhcs.core.autoregister_preparation import AutoRegisterRegistryPreparation +from openhcs.core.callable_contract import ( + prepare_processing_callable, + reset_processing_callable_preparation_cache, +) +from openhcs.core.function_contract_metadata import FunctionContractAttribute +from openhcs.core.processing_preparation import ( + CallablePreparation, + PreparationCacheBatch, + PreparationOperation, +) + + +@dataclass(frozen=True) +class AdditionalChildCachePreparation(PreparationOperation): + name: str + output_directory: Path + + @property + def identity(self): + return (type(self), self.name) + + def execute(self): + (self.output_directory / self.name).write_text(str(os.getpid())) + + def can_prepare_in_child(self): + return True + + +@pytest.fixture +def declared_module(monkeypatch): + module = ModuleType("_openhcs_preparation_test") + monkeypatch.setitem(sys.modules, module.__name__, module) + reset_processing_callable_preparation_cache() + yield module + reset_processing_callable_preparation_cache() + + +def declare_process(module): + def process(image): + return image + + process.__module__ = module.__name__ + return process + + +def test_hooks_are_read_after_preceding_registry_and_module_effects( + monkeypatch, declared_module +): + events = [] + process = declare_process(declared_module) + process.__dict__[FunctionContractAttribute.processing_prepare] = "invalid" + + def prepare_callable(): + events.append("callable") + + def prepare_module(): + events.append("module") + process.__dict__[FunctionContractAttribute.processing_prepare] = ( + prepare_callable + ) + + def prepare_registries(modules): + events.append(tuple(module.__name__ for module in modules)) + declared_module.__dict__[FunctionContractAttribute.processing_prepare] = ( + prepare_module + ) + + monkeypatch.setattr( + AutoRegisterRegistryPreparation, + "prepare_module_registered_families", + prepare_registries, + ) + prepare_processing_callable(process) + prepare_processing_callable(process) + assert events == [(declared_module.__name__,), "module", "callable"] + + +def test_invalid_callable_hook_fails_after_module_preparation(declared_module): + events = [] + process = declare_process(declared_module) + declared_module.__dict__[FunctionContractAttribute.processing_prepare] = ( + lambda: events.append("module") + ) + process.__dict__[FunctionContractAttribute.processing_prepare] = 42 + with pytest.raises(TypeError, match="must be callable"): + prepare_processing_callable(process) + assert events == ["module"] + + +def test_failed_hook_remains_retryable_then_success_is_shared(declared_module): + attempts = [] + process = declare_process(declared_module) + + def prepare(): + attempts.append(len(attempts)) + if len(attempts) == 1: + raise RuntimeError("not ready") + + process.__dict__[FunctionContractAttribute.processing_prepare] = prepare + with pytest.raises(RuntimeError, match="not ready"): + CallablePreparation.from_callable(process).prepare() + prepare_processing_callable(process) + prepare_processing_callable(process) + assert attempts == [0, 1] + + +def test_replaced_module_hook_keeps_its_distinct_identity(declared_module): + events = [] + process = declare_process(declared_module) + first = lambda: events.append("first") + second = lambda: events.append("second") + declared_module.__dict__[FunctionContractAttribute.processing_prepare] = first + prepare_processing_callable(process) + declared_module.__dict__[FunctionContractAttribute.processing_prepare] = second + prepare_processing_callable(process) + prepare_processing_callable(process) + assert events == ["first", "second"] + + +def test_callable_without_module_runs_only_its_declared_hook(declared_module): + events = [] + process = declare_process(declared_module) + process.__module__ = None + process.__dict__[FunctionContractAttribute.processing_prepare] = ( + lambda: events.append("callable") + ) + prepare_processing_callable(process) + prepare_processing_callable(process) + assert events == ["callable"] + assert PreparationCacheBatch.from_callables((process,)).preparations == () + + +def test_cache_batch_deduplicates_modules_before_discovery( + monkeypatch, declared_module +): + calls = [] + process = declare_process(declared_module) + + def discover(module): + calls.append(module.__name__) + return () + + monkeypatch.setattr( + AutoRegisterRegistryPreparation, + "module_registry_families", + staticmethod(discover), + ) + monkeypatch.setattr( + "openhcs.core.processing_preparation.multiprocessing.get_all_start_methods", + lambda: ["fork"], + ) + PreparationCacheBatch.from_callables((process, process)).populate_child_caches() + assert calls == [declared_module.__name__] + + +def test_new_operation_inherits_readiness_without_a_scheduler_roster(declared_module): + events = [] + + class AdditionalPreparation(PreparationOperation): + @property + def identity(self): + return type(self) + + def execute(self): + events.append("prepared") + + AdditionalPreparation().prepare() + AdditionalPreparation().prepare() + assert events == ["prepared"] + assert not AdditionalPreparation().can_prepare_in_child() + PreparationOperation.reset() + AdditionalPreparation().prepare() + assert events == ["prepared", "prepared"] + + +@pytest.mark.skipif( + "fork" not in multiprocessing.get_all_start_methods(), reason="fork required" +) +def test_new_cache_operation_runs_in_children_and_still_prepares_parent( + declared_module, tmp_path +): + operations = tuple( + AdditionalChildCachePreparation(name, tmp_path) for name in ("first", "second") + ) + PreparationCacheBatch(operations).populate_child_caches() + assert all( + int((tmp_path / operation.name).read_text()) != os.getpid() + for operation in operations + ) + for operation in operations: + operation.prepare() + assert all( + int((tmp_path / operation.name).read_text()) == os.getpid() + for operation in operations + )