55from multiprocessing import get_context , synchronize
66from multiprocessing .shared_memory import SharedMemory
77from types import SimpleNamespace
8- from typing import TYPE_CHECKING
8+ from typing import TYPE_CHECKING , Any , cast
99from unittest .mock import Mock
1010
1111import proclimits
1919
2020if TYPE_CHECKING :
2121 from collections .abc import Callable
22+ from multiprocessing .context import ForkContext , ForkServerContext , SpawnContext
23+ from multiprocessing .sharedctypes import Synchronized
24+
25+ _Context = ForkContext | ForkServerContext | SpawnContext
2226
2327HOST_TOTAL_BYTES = 8 * 1024 ** 3
2428HOST_AVAILABLE_BYTES = 3 * 1024 ** 3
@@ -424,11 +428,159 @@ def test_log_resource_limits_lets_a_failing_sensor_surface(monkeypatch: pytest.M
424428 snapshot .assert_called_once ()
425429
426430
431+ _EXTRA_MEMORY_SIZE = 1024 * 1024 * 100 # 100 MB
432+ """The memory each child of the estimation test below allocates on top of its baseline."""
433+
434+ _START_METHODS = ['fork' , 'forkserver' , 'spawn' ]
435+ """Every start method `multiprocessing` offers on Linux.
436+
437+ The default differs between interpreters - `fork` up to Python 3.13 and `forkserver` from 3.14 on - and the methods
438+ differ in how much memory the children share with the rest of the process tree, which is exactly what the estimation
439+ has to account for. The children below are module-level functions so that every method can pickle them.
440+ """
441+
442+
443+ def _get_context (start_method : str ) -> _Context :
444+ """Get the context of a start method, typed as the concrete context so that its process type is known."""
445+ return cast ('_Context' , get_context (start_method ))
446+
447+
448+ def _idle_child (ready : synchronize .Barrier , measured : synchronize .Barrier ) -> None :
449+ """A child without additional memory, used to estimate the baseline memory usage of any child process."""
450+ ready .wait ()
451+ measured .wait ()
452+
453+
454+ def _own_memory_child (ready : synchronize .Barrier , measured : synchronize .Barrier ) -> None :
455+ """A child allocating additional memory of its own, shared with no other process."""
456+ memory = SharedMemory (size = _EXTRA_MEMORY_SIZE , create = True )
457+ assert memory .buf is not None
458+ fill_buffer (memory .buf , _EXTRA_MEMORY_SIZE )
459+ print (f'Using the memory... { memory .buf [- 1 ]} ' )
460+ ready .wait ()
461+ measured .wait ()
462+ memory .close ()
463+ memory .unlink ()
464+
465+
466+ def _shared_memory_child (ready : synchronize .Barrier , measured : synchronize .Barrier , memory : SharedMemory ) -> None :
467+ """A child mapping additional memory that the parent and its siblings map as well."""
468+ assert memory .buf is not None
469+ # Fault every page in: untouched pages never enter the RSS (hiding the overcount this test guards against) and
470+ # are reclaimed first under memory pressure, which drops them from every mapper's PSS.
471+ page_sum = sum (memory .buf [::4096 ])
472+ print (f'Using the memory... { page_sum } ' )
473+ ready .wait ()
474+ measured .wait ()
475+
476+
477+ def _get_additional_memory_estimation_while_running_processes (
478+ ctx : _Context , * , target : Callable , count : int , use_shared_memory : bool = False
479+ ) -> float :
480+ """Estimate how much memory one child of the given kind adds to the process tree, in MB."""
481+ processes = []
482+ ready = ctx .Barrier (parties = count + 1 )
483+ measured = ctx .Barrier (parties = count + 1 )
484+ shared_memory : SharedMemory | None = None
485+ memory_before = get_memory_info ().current_size
486+
487+ if use_shared_memory :
488+ shared_memory = SharedMemory (size = _EXTRA_MEMORY_SIZE , create = True )
489+ assert shared_memory .buf is not None
490+ fill_buffer (shared_memory .buf , _EXTRA_MEMORY_SIZE )
491+ extra_args = [shared_memory ]
492+ else :
493+ extra_args = []
494+
495+ for _ in range (count ):
496+ p = ctx .Process (target = target , args = [ready , measured , * extra_args ])
497+ p .start ()
498+ processes .append (p )
499+
500+ ready .wait ()
501+ memory_during = get_memory_info ().current_size
502+ measured .wait ()
503+
504+ for p in processes :
505+ p .join ()
506+
507+ if shared_memory :
508+ shared_memory .close ()
509+ shared_memory .unlink ()
510+
511+ return (memory_during - memory_before ).to_mb () / count
512+
513+
514+ def _estimate_memory_of_children (start_method : str , expectation_met : Synchronized [Any ]) -> None :
515+ """Run the estimation rounds in a process of its own, reporting through `expectation_met`.
516+
517+ Measuring from a fresh process keeps the readings clear of whatever else the test runner has going on.
518+ """
519+ ctx = _get_context (start_method )
520+ children_count = 4
521+ # Memory calculation is not exact, so allow for some tolerance.
522+ test_tolerance = 0.3
523+ measurement_rounds = 3
524+
525+ # Some start methods launch long-lived helper processes (the fork server, the resource tracker) on first use.
526+ # They are part of the process tree and so part of the estimate, and started inside a round they would inflate
527+ # the baseline for every child in it. Start them ahead of the measurements.
528+ # The barriers are kept referenced here: `start()` drops its own reference to the arguments once they are sent,
529+ # and a collected barrier takes its semaphore with it before the child gets to rebuild it.
530+ ready = ctx .Barrier (parties = 1 )
531+ measured = ctx .Barrier (parties = 1 )
532+ warm_up = ctx .Process (target = _idle_child , args = [ready , measured ])
533+ warm_up .start ()
534+ warm_up .join ()
535+ assert warm_up .exitcode == 0
536+
537+ # Under memory pressure the kernel reclaims cold pages, which silently leave the PSS readings and skew a round's
538+ # deltas, so a distorted round is re-measured. A genuine overcount of shared memory misses the expectation
539+ # several times over in every round, so the retries cannot mask it.
540+ for _ in range (measurement_rounds ):
541+ additional_memory_simple_child = _get_additional_memory_estimation_while_running_processes (
542+ ctx , target = _idle_child , count = children_count
543+ )
544+ additional_memory_extra_memory_child = (
545+ _get_additional_memory_estimation_while_running_processes (
546+ ctx , target = _own_memory_child , count = children_count
547+ )
548+ - additional_memory_simple_child
549+ )
550+ additional_memory_shared_extra_memory_child = (
551+ _get_additional_memory_estimation_while_running_processes (
552+ ctx , target = _shared_memory_child , count = children_count , use_shared_memory = True
553+ )
554+ - additional_memory_simple_child
555+ )
556+
557+ memory_estimation_difference_ratio = (
558+ abs ((additional_memory_shared_extra_memory_child * children_count ) - additional_memory_extra_memory_child )
559+ / additional_memory_extra_memory_child
560+ )
561+
562+ if memory_estimation_difference_ratio < test_tolerance :
563+ expectation_met .value = True
564+ break
565+
566+ print (
567+ f'{ additional_memory_shared_extra_memory_child = } \n '
568+ f'{ children_count = } \n '
569+ f'{ additional_memory_extra_memory_child = } \n '
570+ f'{ memory_estimation_difference_ratio = } '
571+ )
572+
573+
427574# The estimation is asserted on absolute memory readings, which hold only as long as nothing else on the machine makes
428575# the kernel reclaim the pages allocated below. Running alongside the other test workers is enough to break that.
429576@pytest .mark .run_alone
430577@pytest .mark .skipif (sys .platform != 'linux' , reason = 'Improved estimation available only on Linux' )
431- def test_memory_estimation_does_not_overestimate_due_to_shared_memory () -> None :
578+ # Forking the multi-threaded test runner is deprecated since Python 3.12. It is deliberate here - `fork` is what
579+ # production runs under up to Python 3.13 - and the forked process uses no threads, so the deadlock it warns about
580+ # cannot happen.
581+ @pytest .mark .filterwarnings ('ignore:This process .* is multi-threaded, use of fork:DeprecationWarning' )
582+ @pytest .mark .parametrize ('start_method' , _START_METHODS )
583+ def test_memory_estimation_does_not_overestimate_due_to_shared_memory (start_method : str ) -> None :
432584 """Test that memory usage estimation is not overestimating memory usage by counting shared memory multiple times.
433585
434586 In this test, the parent process is started and its memory usage is measured in situations where it is running
@@ -438,121 +590,18 @@ def test_memory_estimation_does_not_overestimate_due_to_shared_memory() -> None:
438590 additional_memory_size_estimate_per_shared_memory_child * number_of_sharing_children_processes is approximately
439591 equal to additional_memory_size_estimate_per_unshared_memory_child where the additional shared memory is exactly
440592 the same as the unshared memory.
441- """
442-
443- ctx = get_context ('fork' )
444- estimated_memory_expectation = ctx .Value ('b' , False ) # noqa: FBT003 # Common usage pattern for multiprocessing.Value
445-
446- def parent_process () -> None :
447- extra_memory_size = 1024 * 1024 * 100 # 100 MB
448- children_count = 4
449- # Memory calculation is not exact, so allow for some tolerance.
450- test_tolerance = 0.3
451- measurement_rounds = 3
452-
453- def no_extra_memory_child (ready : synchronize .Barrier , measured : synchronize .Barrier ) -> None :
454- ready .wait ()
455- measured .wait ()
456-
457- def extra_memory_child (ready : synchronize .Barrier , measured : synchronize .Barrier ) -> None :
458- memory = SharedMemory (size = extra_memory_size , create = True )
459- assert memory .buf is not None
460- fill_buffer (memory .buf , extra_memory_size )
461- print (f'Using the memory... { memory .buf [- 1 ]} ' )
462- ready .wait ()
463- measured .wait ()
464- memory .close ()
465- memory .unlink ()
466-
467- def shared_extra_memory_child (
468- ready : synchronize .Barrier , measured : synchronize .Barrier , memory : SharedMemory
469- ) -> None :
470- assert memory .buf is not None
471- # Fault every page in: untouched pages never enter the RSS (hiding the overcount this test guards
472- # against) and are reclaimed first under memory pressure, which drops them from every mapper's PSS.
473- page_sum = sum (memory .buf [::4096 ])
474- print (f'Using the memory... { page_sum } ' )
475- ready .wait ()
476- measured .wait ()
477-
478- def get_additional_memory_estimation_while_running_processes (
479- * , target : Callable , count : int = 1 , use_shared_memory : bool = False
480- ) -> float :
481- processes = []
482- ready = ctx .Barrier (parties = count + 1 )
483- measured = ctx .Barrier (parties = count + 1 )
484- shared_memory : SharedMemory | None = None
485- memory_before = get_memory_info ().current_size
486-
487- if use_shared_memory :
488- shared_memory = SharedMemory (size = extra_memory_size , create = True )
489- assert shared_memory .buf is not None
490- fill_buffer (shared_memory .buf , extra_memory_size )
491- extra_args = [shared_memory ]
492- else :
493- extra_args = []
494-
495- for _ in range (count ):
496- p = ctx .Process (target = target , args = [ready , measured , * extra_args ])
497- p .start ()
498- processes .append (p )
499-
500- ready .wait ()
501- memory_during = get_memory_info ().current_size
502- measured .wait ()
503-
504- for p in processes :
505- p .join ()
506-
507- if shared_memory :
508- shared_memory .close ()
509- shared_memory .unlink ()
510-
511- return (memory_during - memory_before ).to_mb () / count
512-
513- # Under memory pressure the kernel reclaims cold pages, which silently leave the PSS readings and skew a
514- # round's deltas, so a distorted round is re-measured. A genuine overcount of shared memory misses the
515- # expectation several times over in every round, so the retries cannot mask it.
516- for _ in range (measurement_rounds ):
517- additional_memory_simple_child = get_additional_memory_estimation_while_running_processes (
518- target = no_extra_memory_child , count = children_count
519- )
520- additional_memory_extra_memory_child = (
521- get_additional_memory_estimation_while_running_processes (
522- target = extra_memory_child , count = children_count
523- )
524- - additional_memory_simple_child
525- )
526- additional_memory_shared_extra_memory_child = (
527- get_additional_memory_estimation_while_running_processes (
528- target = shared_extra_memory_child , count = children_count , use_shared_memory = True
529- )
530- - additional_memory_simple_child
531- )
532593
533- memory_estimation_difference_ratio = (
534- abs (
535- (additional_memory_shared_extra_memory_child * children_count )
536- - additional_memory_extra_memory_child
537- )
538- / additional_memory_extra_memory_child
539- )
540-
541- if memory_estimation_difference_ratio < test_tolerance :
542- estimated_memory_expectation .value = True
543- break
544-
545- print (
546- f'{ additional_memory_shared_extra_memory_child = } \n '
547- f'{ children_count = } \n '
548- f'{ additional_memory_extra_memory_child = } \n '
549- f'{ memory_estimation_difference_ratio = } '
550- )
594+ The children share a different amount of memory with the parent under each start method - copy-on-write pages of
595+ the whole interpreter under `fork`, pages of the fork server under `forkserver`, only the shared libraries and
596+ the explicitly shared block under `spawn` - so the estimation is checked under every one of them.
597+ """
598+ ctx = _get_context (start_method )
599+ expectation_met = ctx .Value ('b' , False ) # noqa: FBT003 # Common usage pattern for multiprocessing.Value
551600
552- process = ctx .Process (target = parent_process )
601+ process = ctx .Process (target = _estimate_memory_of_children , args = [ start_method , expectation_met ] )
553602 process .start ()
554603 process .join ()
555604
556- assert estimated_memory_expectation .value , (
557- 'Estimated memory usage for process with shared memory does not meet the expectation.'
605+ assert expectation_met .value , (
606+ f 'Estimated memory usage for process with shared memory does not meet the expectation under { start_method } .'
558607 )
0 commit comments