FEAT: extend profiling to child processes#431
FEAT: extend profiling to child processes#431TTsangSC wants to merge 79 commits intopyutils:mainfrom
Conversation
|
Did some more tests on local post-#428-merge, maybe it is just legacy Python and dependency versions causing the issues. Will just rebase, force-push, and see what happens. |
2cd2ed4 to
f9a37af
Compare
- `line_profiler/curated_profiling.py`
New module for setting up profiling in a curated environment
- `ClassifiedPreimportTargets.from_targets()`
Method for creating a `ClassifiedPreimportTargets` instance,
facilitating writing pre-import modules in a replicable and portable
manner
- `ClassifiedPreimportTargets.write_preimport_module()`
Method for writing a pre-import module based on an instance;
also fixed bug where the body of the written module was intercepted
without appearing in the debug output
- `kernprof.py`
- `_gather_preimport_targets()`
Migrated to `line_profiler.curated_profiling`
- `_write_preimports()`
Now using the new `ClassifiedPreimportTargets` class, moving esp.
the logic to the `write_preimport_module()` method
- `kernprof.py::_manage_profiler` `line_profiler/curated_profiling.py::CuratedProfilerContext` New context-manager classes for handling profiler setup and teardown - `kernprof.py::_pre_profile()` Refactored into the above context managers and other private functions (`_prepare_profiler()`, `_prepare_exec_script()`)
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
New class for passing info onto child processes so that profiling
can resume there
line_profiler/pth_hook.py
New submodule for the .pth-file-based solution to propagating
profiling into child processes:
write_pth_hook()
In the main process, write the temporary .pth file to be loaded
in child processes
load_pth_hook()
Called by the .pth in child process, loading the cache and
setting up profiling based thereon
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
Added new `.profile_imports` attribute to correspond to `kernprof`'s
`--prof-imports` flag
line_profiler/_child_process_profiling/meta_path_finder.py
New submodule defining the `RewritingFinder` class, a meta path
finder which rewrites a single module on import
line_profiler/_child_process_profiling/pth_hook.py
write_pth_hook()
Now also handling the `os.fork()` patching/wrapping
_setup_in_child_process()
Now creating a `RewritingFinder` to mirror what
`~.autoprofile.autoprofile.run()` does in the main process
.
line_profiler/_child_process_profiling/cache::LineProfilingCache
Refactored `.load()`
line_profiler/_child_process_profiling/multiprocessing_patches.py
New submodule for applying patches to the `multiprocessing`
package, so that profiling is automatically set up in child
processes created by it
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
<general>
Added debug logging to various methods
gather_stats()
New method for gathering profiling stats from child processes
inject_env_vars()
New method for injecting `.environ` into `os.environ`
line_profiler/line_profiler.py::LineStats
get_empty_instance()
New convenience method for creating an empty instance
from_files()
Added new argument `on_defective` to allow for processing a
group of files that cannot all be correctly read
line_profiler/rc/line_profiler.toml::[tool.line_profiler.kernprof]
Added new key-value pair `prof-child-procs` for the default value
of `kernprof --prof-child-procs`
kernprof.py
- New boolean flags
`[--prof-child-procs[=...] | --no-prof-child-procs]` for
controlling whether to set up profiling in child processes
- Fixed bug in `_manage_profiler.__exit__()` where
`CuratedProfilerContext.uninstall()` can be skipped if the
preceding code raises an error
kernprof.py::_prepare_child_profiling_cache()
- Now respecting ${LINE_PROFILER_KEEP_TEMPDIRS}
- Now setting `LineProfilingCache.debug`
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
- Added new attributes `.debug` and `._debug_log`
- Now diverting debug messages to log files in `.cache_dir`
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
add_cleanup()
Now deferring to a `._add_cleanup()` method which allows for
cleanup-function prioritization
_debug_output()
Fixed type-checking
line_profiler/_child_process_profiling/multiprocessing_patches.py
::apply()
Added debug output before `_setup_in_child_process()` is called to
help with tracing
line_profiler/_child_process_profiling/pth_hook.py
load_pth_hook()
_wrap_os_fork()
Added debug output before `_setup_in_child_process()` is called
to help with tracing
_setup_in_child_process()
- `wrap_os_fork` now defaults to false
- `prof.dump_stats()` now has increased priority over other
callbacks (doesn't seem to help with the malformed prof files
though...)
- Child-process profiling output now written to a less
randomized filename to facilitate debugging
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
profiler
New attribute for the profiler instance
copy(..., inherit_profiler=...)
New argument for inheriting the `.profiler`
load()
Now keeping track of the loaded instance and returning it in
subsequent calls
line_profiler/_child_process_profiling/multiprocessing_patches.py
::apply(..., lp_cache=None)
- If the `LineProfilingCache.load()`-ed instance is consistent with
that loaded from `cache_path`, the former is used
- Added more debugging output
line_profiler/_child_process_profiling/pth_hook.py
load_pth_hook()
Added more debugging output
_wrap_os_fork()
Updated debugging output
_setup_in_child_process()
- Now returning a boolean (whether setup has been newly done)
- Now setting `.profiler` of the cache instance
- Added moew debugging output
kernprof.py::_manage_profiler.__enter__()
Updated so that the created `LineProfilingCache` instance carries a
`.rewrite_module`
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
Added an optional `.rewrite_module` attribute
line_profiler/_child_process_profiling/import_machinery.py
::RewritingFinder.find_spec()
Now looking at `.lp_cache.rewrite_module` (where available) to check
for specs to return
line_profiler/_child_process_profiling/
cache.py::LineProfilingCache
_replace_loaded_instance()
New convenience method for an instance in a fork to replace
the instance to be `.load()`-ed
_consistent_with_loaded_instance
New attribute for checking whether the instance is
consistent with what would have been `.load()`-ed
multiprocessing_patches.py
bootstrap(..., lp_cache=...)
Can now be `None`, which defers the `.load()`-ing of the
cache instance
apply()
- Streamlined logic for retrieving the loaded instance
- Now using the above deferred loading whenever appropriate,
so that cleanup and profiling is preserved in forked
processes
pth_hook.py::_wrap_os_fork()
Now using `._replace_loaded_instance()`, so that future calls to
`.load()` in the forked process retrieves the newly-created
instance
kernprof.py::_prepare_child_profiling_cache()
- Updated call to `[...].multiprocessing_patches.apply()`
- Now always setting up the created instance as the one returned by
further calls to `.load()`
line_profiler/_child_process_profiling/multiprocessing_patches.py
PickleHook
- Refactored to contain no instance variables
- Now always using `LineProfilingCache.load()` to retrieve the
appropriate cache instance
bootstrap()
Removed argument `lp_cache`
get_preparation_data()
Removed arguemnt `cache_path`
apply()
- Removed argument `cache_path`
- Argument `lp_cache` now required
- Simplified implementation
line_profiler/_child_process_profiling/import_machinery.py
Removed
line_profiler/_child_process_profiling/pth_hook.py
::_setup_in_child_process()
No longer set up the `RewritingFinder` because messing with the
import system doesn't help with propagating autoprofiling rewrites
to child processes...
kernprof.py
_dump_filtered_stats()
Fixed bug where if no tempfile remains, the `extra_line_stats`
are not merged into the dumped stats
_prepare_child_profiling_cache()
Now setting the `.profiler` of the returned cache object
line_profiler/_child_process_profiling/multiprocessing_patches.py
::_apply_mp_patches()
- Added debugging output for the patches
- Now patching the copy of `runpy` imported by
`multiprocessing.runpy`
line_profiler/_child_process_profiling/pth_hook.py
_wrap_os_fork()
No longer creating a new `LineProfiler` instance (helps with
handling forked processes)
_setup_in_child_process(..., prof=...)
New argument for avoiding instantiating a new profiler when not
necessary (e.g. in a forked process)
line_profiler/_child_process_profiling/runpy_patches.py
New submodule for the aforementioned patching of `runpy`
tests/test_child_procs.py
test_running_multiproc_literal_code()
New test paralleling `test_running_multiproc_{script,module}` to
test `kernprof -c ...`
test_multiproc_script_sanity_check()
- Refactored parameters for better `pytest` output
- Added testing for running the code with `python -c ...`
<Misc>
- Added CLI argument `--local` to the profiled module to toggle
between a locally-defined summing function and an imported one
- Refactored how the test modules are injected
- Added debugging output to `subprocess.run()` calls
- Added provisional support for examining the profiling data
tests/test_child_procs.py::test_multiproc_script_sanity_check()
Now parametrized to test passing the function defined in the test
module itself to `multiprocessing`
tests/test_child_procs.py
test_running_multiproc_{module,literal_code}()
Integrated into `test_running_multiproc_script()`
test_running_multiproc_script()
Extended parametrization
tests/test_child_procs.py
test_profiling_multiproc_script()
Test parallel to `test_running_multiproc_script()`, checking
whether we are correctly profiling the child processes
<General>
- Added more docs
- Updated dummy parameter names
_ext_module, _test_module
- Refactored how the fixtures are set up
- Module names now randomized and clash-proof via `uuid.uuid4()`
_run_subproc()
- Moved code outputting captured streams from
`_run_test_module()` to here
- Added timing code
tests/test_child_procs.py
TEST_MODULE_BODY, [_]test_module()
Added CLI flag to select `multiprocessing` start methods
_Params
New convenience class for test parametrization
test_multiproc_script_sanity_check()
- Streamlined parametrization (15 subtests -> 10)
- Added subtests for various `multiprocessing` start methods
test_multiproc_script_sanity_check()
- Streamlined parametrization (24 subtests -> 21)
- Added subtests for various `multiprocessing` start methods
tests/test_child_procs.py
test_module(), ext_module()
Updated so that we can toggle for the function sent to
`multiprocessing` to raise an error with the `--force-failure`
CLI flag
_run_test_module()
- Now raising a new `ResultMismatch` error class (instead of
using base assertions) for:
- If `test_module()` writes the wrong number to stdout
- If `nhits` are provided and the profiling results differ
therefrom
- Added argument `fail` for using the aforementioned
`--force-failure` flag
test_multiproc_script_sanity_check()
Now also chceking the cases where the test module is run with
`--force-failure`
test_profiling_multiproc_script()
Now also chceking the cases where the test module is run with
`--force-failure`
(FIXME: profiling bugged when the function errors out, and
doesn't fail with a consistent pattern)
|
Added a ton of logging/debug messages, a few debug-only config options and a
Footnotes
|
|
WRT to the existing multithreaded case, IIRC the main point of that is just to ensure we don't hang. I could be misremembering. For the forkserver issue, from what I understand that's a long lived process, so maybe some forkerserve state debugging (not sure if your code does this or not, I haven't looked at it yet) def debug_forkserver_state(label):
import os
from multiprocessing import forkserver
fs = forkserver._forkserver # private CPython state
pid = getattr(fs, "_forkserver_pid", None)
# "Did *this process* already know about / launch a forkserver?"
known_forkserver_pid = pid
known_forkserver_started = pid is not None
# "Was *this current process* itself started by a forkserver?"
started_by_forkserver = forkserver.get_inherited_fds() is not None
# Best-effort liveness check for the known forkserver PID, if any.
# This is optional, and mostly useful in the parent/originating process.
forkserver_pid_alive = None
if pid is not None:
try:
os.kill(pid, 0)
except OSError:
forkserver_pid_alive = False
else:
forkserver_pid_alive = True
print(
f"[{label}] "
f"pid={os.getpid()} ppid={os.getppid()} "
f"known_forkserver_pid={known_forkserver_pid!r} "
f"known_forkserver_started={known_forkserver_started} "
f"forkserver_pid_alive={forkserver_pid_alive} "
f"started_by_forkserver={started_by_forkserver}"
)And maybe also check if the main module attributes differ in any meaningful way? getattr(sys.modules['__main__'].__spec__, 'name', None)
getattr(sys.modules['__main__'], '__file__', None)
multiprocessing.get_start_method()But IDK, your guess is probably better than mine at this point. |
line_profiler/_child_process_profiling/cache.py
::LineProfilingCache._method_wrapper
New decorator (relocated from `~.multiprocessing_patches`)
line_profiler/_child_process_profiling/multiprocessing_patches.py
Replaced uses of `@_method_wrapper` with
`@LineProfilingCache._method_wrapper`
line_profiler/_child_process_profiling/threading_patches.py::apply()
New function for patching `threading.Thread` to set up thread
profiling: when the thread object `.run()`s the target function, the
profiler's `.enable_count` is synced to that of the starting thread.
This fixes the bug where calls to a profiled function on new threads
may not be profiled despite profiling being currently on
tests/test_child_procs.py
create_cache()
Now also writing the profiler `id()` in the output
test_cache_setup_main_process()
Updated tested list of patched attributes
test_apply_mp_patches()
Updated preserved attributes
line_profiler/_child_process_profiling/threading_patches.py
SHOULD_PATCH_THREADING
Alias to `line_profiler._line_profiler.USE_LEGACY_TRACE`
apply()
Now a no-op if `SHOULD_PATCH_THREADING` is false (because the
fix only seems to work when using `LINE_PROFILER_CORE=ctrace`,
and causes flakiness otherwise)
tests/test_child_procs.py
test_cache_setup_main_process()
Now only checking for `threading` patches if
`SHOULD_PATCH_THREADING` is true
test_apply_mp_patches()
Now only protecting against `threading` patches if
`SHOULD_PATCH_THREADING` is true
|
Thanks for the input! May have to take a closer look at the I have a piece of good news and a half:
Footnotes
|
|
Figured it out, the issue is that:
I think the bug is fixed (will push shortly), but I've noticed significant performance regression (about 100% slowdown of |
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
_setup_in_main_process()
Refactored part into `._setup_common()`
_setup_in_child_process()
Now calling `._setup_common()`, making sure that the fork-server
process is also set up for profiling
_wrap_os_hook()
`os.fork()` wrapper now writing debug-log messages indicating
the parent and child PIDs
line_profiler/_child_process_profiling/multiprocessing_patches.py
PickleHook, wrap_get_preparation_data()
Removed no-unused code
tee_log()
Updated call signature
apply()
Added argument `reboot_forkserver`
_apply_mp_patches()
- Updated call signature
- Refactored internals
- Now rebooting the fork-server process to make sure that it is
set up for profiling
tests/test_child_procs.py
test_cache_setup_main_process()
No longer checking patching/restoration of
`multiprocessing.spawn.get_preparation_data()` because it is no
longer patched
test_apply_mp_patches()
No longer checking the output
`multiprocessing.spawn.get_preparation_data()` because it is no
longer patched
FIXME: performance regression in newer Python versions...
|
Tests seem flaky... there is no good reason that 6e60a6a failed on building for |
conftest.py
Installs the marker `@pytest.mark.retry(...)` so that certain tests
can be retried
TODO: tests
tests/conftest.py::pytest_configure()
Fixed formatting of the line added by `config.addinivalue_line()` so
that usage of the marker does not result in a
`PytestUnknownMarkWarning`
tests/test_retry_tests.py
test_fixture_scoping()
Test that `@pytest.mark.retry(reset_fixtures=...)` is handled
correctly
test_fixture_teardown()
Test that stale fixtures created by reruns are torn down
correctly
test_exception_restrictions()
Test that `@pytest.mark.retry(exceptions=...)` is handled
correctly
tests/conftest.py::_RetryHelper._reset_between_retries()
Now calling `FixtureDef.finish()` on the cleaned-up function-scoped
fixtures
tests/test_retry_tests.py::test_fixture_teardown()
Added check that the teardown events happen in the expected order
(old fixture value torn down before the new value is used)
tests/test_child_procs.py
ResultMismatch
compare()
New helper method for raising an error
pytest_runtest_makereport()
New helper method for truncating the traceback so that it
stops at the frame calling `.compare()`
test_apply_mp_patches()
test_profiling_multiproc_script()
Added `@pytest.mark.retry` decorators to deal with flakiness
test_profiling_bare_python()
- Added `@pytest.mark.retry` decorators to deal with flakiness
- Added timeout (5 s) to the `subprocess.run()` call
line_profiler/_child_process_profiling/multiprocessing_patches.py
::_Poller.__doc__
Increased timeout threshold on the passing test statement because
something in CI seems to be slowing code execution of each polling
loop down
tests/conftest.py::_RetryEntry._name_prefixes
Now gracefully handling when the curdir isn't a parent of the file
the test function is defined in
line_profiler/_child_process_profiling/multiprocessing_patches.py
_Poller
__doc__
- Lengthened timeout in passing case (again) since it is
slow in CI
- Extended example to test these components:
- `.poll_while()`
- `.with_timeout(None, ...)`
- `on_timeout='ignore'`
- `on_timeout='warn'`
__init__()
Minor refactoring to hack coverage
__enter__()
Now issuing a `_Poller.TimeoutWarning`
TimeoutWarning
New warning subclass
wrap_bootstrap()
`# nocover` since `coverage` is having trouble tracing calls in
child processes
_apply_patches_generic(), _apply_mp_patches()
Added `# nocover` to unlikely exception branches
line_profiler/cleanup.py::Cleanup
New base class for objects that do cleanup
TODO: migrate `CuratedProfileContext` and `LineProfilingCache` to
this class
line_profiler/line_profiler_utils.py
Migrated `block_indent()` and `make_tempfile()` from
`line_profiler/_child_process_profiling/misc_utils.py` to here
line_profiler/_threading_patches.py
Moved from
`line_profiler/_child_process_profiling/threading_patches.py`
because the patch is needed for correctness even if not profiling
child-process-based parallelism
TODO: fix and adapt and use it with `CuratedProfilerContext`
line_profiler/_threading_patches.py
make_thread_init_wrapper()
Refactored from `wrap_init()`
apply()
Changed call signature (non-backwards compatible)
line_profiler/curated_profiling.py::CuratedProfilerContext
<General>
Now inheriting from `~.cleanup.Cleanup`
install(), uninstall()
- Simplified implementations
- Added handling for `threading` patching
__enter__()
Now returning the instance to be consistent with the base class
line_profiler/_child_process_profiling/cache.py
Removed code applying (the now-nonexistent)
`~.threading_patches.apply()`
line_profiler/cleanup.py::Cleanup.make_tempfile()
Updated default formatting for the debug-logging message
line_profiler/_child_process_profiling/
_cache_logging.py
Updated import location of `block_indent()`
cache.py
- Removed duplicate code (previously migrated to
`line_profiler/cleanup.py`)
- Made `LineProfilingCache` a `@final` class and a
`line_profiler.cleanup.Cleanup` subclass
- Updated signature of `LineProfilingCache.inject_env_vars()`
- Removed `LineProfilingCache._add_cleanup()`
pth_hook.py
Minor internal refactoring
kernprof.py
_touch_tempfile()
Now an imported alias for
`line_profiler.line_profiler_utils.make_tempfile()`
_manage_profiler.__enter__(), .__exit__()
Simplified implementations making use of
`CuratedProfilerContext.[add_]cleanup()`
line_profiler/_child_process_profiling/misc_utils.py
Deleted because its functionalities have been migrated to
`line_profiler.line_profiler_utils`
tests/test_child_procs.py
test_cache_cleanup_order()
Removed test because it is covered by
`Cleanup.add_cleanup_with_priority()`'s doctest
test_cache_setup_main_process()
No longer checking the patching of `threading` because it is no
longer handled by `LineProfilingCache._setup_in_main_process()`
test_cache_setup_child()
No longer protecting against the patching of `threading` because
it is no longer handled by
`LineProfilingCache._setup_in_main_process()`
line_profiler/rc/line_profiler.toml
::[tool.line_profiler.child_processes]
New subtable for options related to child-processing profiling:
- [pth_files]
New subtable for options for .pth-file creation
- [multiprocessing]
Migrated from `[tool.line_profiler.multiprocessing]`
line_profiler/_child_process_profiling/pth_hook.py::write_pth_hook()
Now loading affixes to the .pth-filename stem from `cache.config`
line_profiler/_child_process_profiling/multiprocessing_patches.py
_get_config_cached()
Updated load location of config options
_apply_mp_patches()
Renamed (currently not explicitly used) argument `debug` ->
`intercept_mp_logs`
tests/test_child_procs.py::test_apply_mp_patches()
Updated .toml file written to align with the new config locations
line_profiler/_child_process_profiling/pth_hook.py::load_pth_hook()
- Added `# nocover` directive to unlikely `except` clause
- Now patching `load_pth_hook.called` with the `cache` where
possible
tests/test_child_procs.py
ext_module_object
New fixture for actually loading the module object represented
by the `ext_module` fixture
another_pid
Convenience fixture for a dummy PID
_preserve_attributes
.fetch_current_values(), .compare_with_current_values()
New convenience methods for comparing the preserved vs.
current attributes of multiple objects
test_cache_setup_main_process()
Simplified implementation with the `_preserve_attributes`
methods
test_cache_setup_child()
Now using the new `ext_module_object` fixture for the profiled
function
test_load_pth_hook()
New unit test for `load_pth_hook()` to boost coverage of
`pth_hook.py`
test_apply_mp_patches()
Refactored to:
- Use the new `ext_module_object` fixture for the profiled
function
- Test cases where the parallel workload fails
TODO:
`@LineProfilingCache._method_wrapper` and `wrap_terminate()` still
not covered; try to tweak`test_apply_mp_patches()` to cover them
tests/test_child_procs.py
TEST_MODULE_TEMPLATE, test_module
Updated code to allow for calling `sum_in_child_procs()` with
the 'dummy' argument and switching to
`multiprocessing.dummy.Pool`
test_module_object
`ModuleType` fixture representing the content of the above
test_apply_mp_patches()
- New parametrization `nprocs` for the number of child
processes/threads created
- Updated implementation to use
`test_module_object.sum_in_child_procs()`
After the changes, `coverage` seem to fare much better in
tracing `multiprocessing_patches.py` and `cache.py`.
line_profiler/_child_process_profiling/multiprocessing_patches.py
::_apply_mp_patches()
Refactored internals; existence of `multiprocessing.spawn` and
`multiprocessing.forkserver` now checked at import time
tests/test_child_procs.py
_preserve_attributes.compare_with_current_values()
- Argument `assert_true` can now take a mapping to which the
result is compared
- Now explicitly constructing an error message when the
assertion is false, instead of relying on `pytest`'s assertion
rewriting
_GLOBAL_PATCHES
Now protecting against changes to additional attributes
patched_attributes
Wrapper fixture around `_GLOBAL_PATCHES` which excludes
nonexistent names (e.g. `os.fork()` on Windows)
test_cache_dump_load()
test_load_pth_hook()
Now making sure that all cache instances created are cleaned up,
not just those created by the `create_cache()` fixture
test_cache_setup_main_process()
Minor refactoring
line_profiler/rc/line_profiler.toml
::[tool.line_profiler.child_processes.multiprocessing]
New key-value pair `catch_sigterm` for whether to catch SIGTERM in
child processes started by `multiprocessing`
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
_setup_common()
Updated call signature
_handle_sigterm(), _wrap_sigterm()
New helper methods for dealing with `SIGTERM` (esp. in child
processes created by `multiprocessing`)
line_profiler/_child_process_profiling/multiprocessing_patches.py
wrap_terminate()
Now writing debug messages to the log if the poller timed out
and raised an error
wrap_bootstrap()
Now setting the SIGTERM handler before running the workload
tests/test_child_procs.py::patched_attributes
Fixed bug where objects with no attributes to be patched remain in
the returned mapping
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
cleanup()
Now thread-locked to ensure that the signal handler doesn't call
`Cleanup.cleanup()` in the middle of another call thereto
_handle_signal()
Refactored from `._handle_sigterm()`
_add_signal_handler()
- Refactored from `._wrap_sigterm()`
- Now a no-op on Windows
line_profiler/_child_process_profiling/multiprocessing_patches.py
Updated correspondingly
line_profiler/curated_profiling.py::CuratedProfilerContext
install()
Now handling profiler disabling as a regular cleanup callback
uninstall()
- Simplifed implementation
- Now always calling `.cleanup()`
_disable_profiler()
New helper method for disabling the profiler (code
previosuly lived in `.uninstall()`)
line_profiler/_child_process_profiling/cache.py::LineProfilingCache
_cleanup_stacks
Removed unused attribute (superseded by `._contexts`)
cleanup()
Added argument `new_thread` for controlling whether to spin up a
new thread for do cleanup (note that the profiler has a
different `.enable_count` on each thread, so cleaning up on a
separate thread may impact proper profiler cleanup)
copy()
- Simplified implementation
- Removed unused arguments `inherit_cleanups` and
`inherit_profiler`
_setup_in_child_process()
Now intercepting the debug-log messages of the created
`CuratedProfilerContext`
_handle_signal()
Now calling `.cleanup()` with `new_thread=True`
tests/test_child_procs.py
create_cache()
Fixed typo in doc
test_cache_setup_main_process()
- Removed reference to unused/indirectly-used `curated_profiler`
fixture
- Now explicitly checking for the disabling of the profiler
This PR adds support for
kernprofto profile code execution in child Python processes, building on ongoing work (see Credits).Usage
The EXPERIMENTAL new flags
--no-prof-child-procsand--prof-child-procs[=...]are added to kernprof. By setting--prof-child-procsto true, child Python processes created by the profiled process are also profiled:1Note how the
sum_worker()calls are profiled:Highlights
os.system()andsubprocess.run()multiprocessing2multiprocessing"start methods" ('fork','forkserver', and'spawn') tested to be compatible, where available on the platform--preimportsor via test-code rewriting) replicated in child processesExplanation
line_profiler._child_process_profiling.cache.LineProfilingCache) is created by the main process, containing session config information (e.g. values for--prod-modand--preimports) so that profiling can be replicated in child processes..pthfile is created; Python processes inheriting the right environment will thus go through profiling setup, while those without the env var (and just happens to share the Python executable) will be minimally affected.os.fork()(where available) is patched with a wrapper which ensures consistent global states.coverage.multiproc, variousmultiprocessingcomponents are patched (line_profiler._child_process_profiling.multiprocessing_patches.apply()) so that child processes can retrieve the cache and explicitly cleanup before exiting. Patches are inherited by forked child processes and reapplied by spawned ones.multiprocessingchild processes are allowed to fully clean up and write their profiling, even when the parallel workload errors out,3 additional patches are made tomultiprocessing.kernprofthen gather and merge with the profiling result in main process.Code changes
New code (click to expand)
line_profiler/_threading_patches.pyNew submodule patching
threadingfor the consistent gathering of profiling data between tracing modes.apply():When legacy tracing is used (Python < 3.12 or
LINE_PROFILER_CORE=ctrace), patchthreading.Thread.__init__()so that the profiler starts with the same.enable_counton the new thread as with the parent thread; this is necessary for correctness because the profiler may otherwise not be enabled on the new thread.line_profiler/cleanup.pyNew submodule defining the
Cleanupclass, which handles various setup/cleanup tasks like:line_profiler/curated_profiling.pyNew submodule containing mostly relocated code from
kernprof, so that child processes can more easily reestablish profiling:ClassifiedPreimportTargets:Object resolving and classifying the
--prof-mods, and writing a corresponding preimport moduleCuratedProfilerContext:Context manager managing the state of the
LineProfiler, e.g.:line_profiler.profileon startupthreading(see_threading_patchesabove) so that the profiler stays enabled on newly spawned threads.enable_counts on teardownline_profiler/_child_process_profiling/New private subpackage for maintaining the states, setting up the hooks, and performing the patches which makes it possible to profile child processes:
cache.py::LineProfilingCache:"Session state" object. It:
Can be auto-(de-)serialized in the main and child processes based on env-var values, managing setup (module patches, profiler curation, eager pre-imports) and cleanup (tempfile management, dumping and gathering of profiling results) in each process.
Injects the following environment variables, which are inherited by child processes:
${LINE_PROFILER_PROFILE_CHILD_PROCESSES_CACHE_PID}: main-process PID${LINE_PROFILER_PROFILE_CHILD_PROCESSES_CACHE_DIR_<PID>}: location of the cache directoryFrom the combination of both, child processes can retrieve the cache by calling
.load().multiprocessing_patches.py::apply():Apply patches to these
multiprocessingmodule components so that profiling results are properly gathered on child-process exit:Process(read:multiprocessing.process.BaseProcess):._bootstrap():Wrapped call to:
LineProfilingCache.cleanup()on exit.SIGTERMhandler to do the above as a failsafe..terminate():Wrapped call to poll on the child process, and soft-block (with a timeout) until it is deleted.
spawn.runpy:Replaced with a localized, patched clone of
runpy(seerunpy_patches.pybelow). This is necessary for profiling to function in non-eager-preimports mode (--no-preimports).forkserver.ForkServer:Global instance rebooted if
reboot_forkserver=Trueso that child processes fork with the profiling infrastructure set up properly.pth_hooks.py:Facilities for effecting profiling-code execution in child processes by injecting a temporary
.pthfile into the current venv. This module is kept as minimal as possible to minimize the amount of startup code run as the mere result of having said.pthfile.write_pth_hook():Set up a
.pthfile under the directorysysconfig.get_path('purelib')which callsload_pth_hook()(see below). The.pthfile will be cleaned up by the supplied cache object.load_pth_hook():For processes inheriting a matching "parent PID" from the environment (see
LineProfilingCacheabove), load the cache and set up theLineProfilerinstance used, like how the mainkernprofprocess does.runpy_patches.py::create_runpy_wrapper():Make a clone of the
runpymodule which checks if the code executed is the code to be profiled; if so, it goes through the same code-rewriting facilities thatline_profiler.autoprofile.autoprofile.run()uses to set up profiling.threading_patches.py::apply():Patch
threading.Thread.__init__()so that when using legacy tracing the profiler is suitably enabled on newly-spawn thread, depending on whether it is enabled on the current thread.tests/conftest.pyAdd the
@pytest.mark.retrymarker so that we can retry flaky tests._RetryEntry:Object containing info on retried test items so that they can be summarized when the session ends.
_RetryHelper:Object implementing the retrying of failing test functions (
pytest.Function) and the teardown of function-scoped fixtures between retries.pytest_pyfunc_call():Hook implementation for instantiating a helper to handle test rerunning if necessary.
get_helper():Instantiate from a
pytest.Functionmarked with@pytest.mark.retry(...).pytest_addhooks():Hook implementation for registering
_RetryHelperas a plugin.pytest_configure():Hook implementation for defining the
@pytest.mark.retrymarker, so that it doesn't produce apytest.PytestUnknownMarkWarningwhen used.pytest_terminal_summary():Hook implementation for writing a summary section if any test is retried.
tests/test_retry_tests.pyTest that the
@pytest.mark.retrymarker:test_fixture_scoping()), esp. the teardown on non-persisted function-scoped fixtures between retries (test_fixture_teardown())test_exception_restrictions())pytest.PytestUnknownMarkWarningwhen used.tests/test_child_procs.py_ModuleFixture:Helper object which handles:
tests/test_cython.py::propose_name()) to avoid clashes; andsys.pathandos.environ['PYTHONPATH']._Params:Helper object which handles concatenation and Cartesian products of parametrizations.
ext_module:New
_ModuleFixturerepresenting a module defining the sum function used bytest_modulewhen run without the--localflag._run_subproc():New wrapper around
subprocess.run()which provide extra debugging output (standard streams, timing info, etc.)line_profiler._child_processing_profilingcomponents, or as close as is possible thereto:test_runpy_patches():Test the functionality of
~.runpy_patches.create_runpy_wrapper().test_cache_dump_load():Test the functionalities of
~.cache.LineProfilingCache.dump()and.load().test_cache_setup_main_process():Test the functionality of
~.cache.LineProfilingCache._setup_in_main_process().test_cache_setup_child():Test the functionality of
~.cache.LineProfilingCache._setup_in_child_process().test_load_pth_hook():Test the functionality of
~.pth_hook.load_pth_hook().test_apply_mp_patches():Test the functionality of
~.multiprocessing_patches.apply().test_profiling_multiproc_script():"Main" new test for running the
test_module(see Modified Code) withkernprof --prof-child-procs; heavily parametrized to check for profiling-result correctness in different contexts:run_func: execution modes (kernprof <script>,kernprof -m <module>, andkernprof -c <code>)prof_child_procs; whether to use child-process profiling (--[no-]prof-child-procs)preimports: eager vs. on-import profiling (--[no-]preimports)use_local_func: whether the parallel workload is locally defined in the executed code or imported from external modulesfail: whether the parallel workload errors outstart_method:multiprocessing"start methods" ('fork','forkserver', and'spawn')test_profiling_bare_python():New test for profiling child processes where the code run by
kernprof --prof-child-procsdoesn't directly invokemultiprocessing, but spins up another Python process that does (viaos.system()orsubprocess.run()).Modified code (click to expand)
line_profiler/line_profiler.py::LineStats.get_empty_instance():New convenience class method for creating an instance with no profiling data and the platform-appropriate
.unit..from_files():Added new argument
on_defective: Literal['ignore', 'warn', 'error'], allowing for passing over bad files (e.g. empty ones) with optional warnings. The old behavior (on_defective='error') remains the default.line_profiler/rc/line_profiler.toml[tool.line_profiler.kernprof]:New key-value pair
prof-child-procsfor the default of thekernprof --[no-]prof-child-procsflag.[tool.line_profiler.multiprocessing]:New key-value pairs for controlling the application of the
.multiprocessing_patches:intercept_logs(bool):Whether to patch methods like
multiprocessing.util.debug()to also capture themultiprocessinglogs.polling:Behavior for the polling for child-process termination:
cooldown(float):Time (s) to wait between polls.
timeout(float):Timeout (s) for the overall polling.
on_timeout(Literal['error', 'warn', 'ignore']):What to do when the polling timed out.
The
multiprocssingtable and its contents are as of yet considered private implementation details.kernprof.py_add_core_parser_arguments():Now adding the new
--[no-]prof-child-procsflags to the parser._write_preimports():Refactored to use the new/relocated facilities at
line_profiler.curated_profiling._dump_filtered_stats():extra_line_stats: LineStats | Noneallows for handling and combining the profiling stats gathered elsewhere (e.g. child processes)._dump_filtered_line_stats()which it now calls._manage_profiler:Context manager refactored from the old
_pre_profile()for more Pythonic handling of setups and teardowns._prepare_child_profiling_cache()._prepare_profiler(),_prepare_exec_script())._post_profile()on context exit so that we no longer have to explicitlytry: ... finally: ...in_main_profile()._post_profile():extra_line_stats: LineStats | Noneallows for handling and combining the profiling stats gathered elsewhere (e.g. child processes).line_profiler.curated_profiling.tests/test_child_procs.pytest_module:Pathfixture into a_ModuleFixture(see above in New Code).--start-methodselects a specificmultiprocessing"start method".--localtoggles between using a sum function defined locally intest_moduleor the one defined externally inext_module(see New Code).--force-failuretoggles whether the sum function should return normally or raise an error._run_as_{script,module}():_run_as_literal_code()to also testkernprof -c ....test_moduleas a_ModuleFixtureinstead of a path, and handling its installation._run_test_module():run_module = partial(_run_test_module, _run_as_module), etc. now available for more convenient testing ofkernprofexecution modes as test parametrization.profiled_code_is_tempfile: boolhelps with constructing thekernprofcommand line in cases where the code is anonymous (kernprof -c ...).use_local_func: bool,fail: bool, andstart_method: Literal['fork', 'forkserver', 'spawn'] | Noneallows for fuzzing code execution with the aforementionedtest_moduleCLI flags (resp.--local,--force-failure, and--start-method).nhits: dict[str, int] | None, when provided, checks that the line-hit stats are as expected (all calls traced with--prof-child-procs, only those in the main process without).failis true, thekernprofsubprocess should fail..pthfiles created bykernprof --prof-child-procsshould be cleaned up.nhits(where available).test_multiproc_script_sanity_check():use_local_func,fail, andstart_method, to ensure that the test script is fully functional in vanilla Python.as_module: boolwithrun_func: Callable[..., CompletedProcess], allowing for more flexible testing of execution modes (python ...,python -m ..., and the newpython -cadded via the aforementioned_run_as_literal_code()).test_running_multiproc_script():New parametrization
run_funcallows for absorbing the oldtest_running_multiproc_module()into the same test as additional parametrization, as well as testingkernprof -c.Caveats
The temporary
.pthfile created is course benign and as mentioned tries to be as out of the way as possible, but I just figured that the use of.pthfiles should be called out, given their recent spotlight in a CVE vulnerability.Since the
.pthfile is written tosys.get_path('purelib'), it depends on said directory being writable. If we aren't in a venv or a similarly isolated environment (which is increasingly unlikely nowadays), all processes using the system Python will have to import and runline_profiler._child_process_profiling.load_pth_hook(). Though the function itself should quit rather quickly when we're not in a child process, it may still entail loading a significant portion ofline_profilerintosys.modules.Blocking
Process.terminate()3 is a bit dodgy:To avoid uncontrolled hot-looping
.poll()-ing for the activity of the child-process PID, I'm just using a 1/32-s cooldown.After all, there's a reason
Process.terminate()justSIGTERMs the child process with reckless regard – children which errored out are sporadically stuck in an unclean state, and the polling may enter a deadlock. To guard against that I added:SIGTERMis sent anywaykernprof --prof-child-procsor equivalent code:subprocess.run().This seems to be enough to both get rid of the deadlocks in tests and preserve profiling data... but the problem is that for child processes to deadlock AT ALL, their cleanup routines must have (of yet) failed to complete, and thus there is still a risk of profiling data not being written. So there's probably either some race conditions hidden by the delays, or an error in how the PID statuses are detected.
(Note however that on non-Windows platforms we can and do just setup a
SIGTERMhandler,4 which ensures that cleanup finishes before the child succumbs to termination. But yeah on Windows it's just the polling and the delays keeping us afloat.)Apparently
coveragegets by alright by only patchingProcess._bootstrap(), without the above termination issue. Gotta figure out why...TODO
line_profiler._child_process_profilingto become public API?Credits
pytest-autoprofile, which in turn was based on the solution implemented incoverage.multiproc.LineStatsaggregation #380kernprofto always run code insys.modules['__main__']'s namespace #423Notes
Welp. This took way longer than I expected. The main friction points were that:
line_profiler._child_process_profiling.cache.LineProfilingCacheclass tackles this issue.pytest-autoprofile::tests/test_subprocess.py::_test_inner()), but apparently I only made the tests fail there, not the parallel functions themselves. Had to do some rather hacky stuff to circumvent that (see Caveats)...3Footnotes
Note however that the equivalent vanilla Python command (
python -c ...) would error out, because functions sent tomultiprocessingmust be pickle-able and thus reside in a physical file. This is sidestepped bykernprof's always writing code received bykernprof -c ...and... | kernprof -to a tempfile (ENH: auto-profilestdinor literal snippets #338). ↩In the test suite we're only testing process creation with the most common
multiprocessing[.get_context(...)].Pool. However, since none of the patched components are specific tomultiprocessing.pool, it should also work with other model of parallelism built with the components ofmultiprocessing. ↩From the docs for
mulitprocessing.Process.terminate():Note that exit handlers and finally clauses, etc., will not be executed. Normally this doesn't matter, but if the parallelly-executed function errors out,multiprocessinghas a bad habit of just.terminate()-ing child processes without allowing for enough time to run cleanup, leading to incomplete profiling data. Hence the only workaround seems to be interceptingProcess.terminate()calls and blocking them where appropriate. ↩ ↩2 ↩3Handling
SIGTERMon Windows is generally noted to be inconsistent. See e.g. thiscoverage.control.Coverage._init_for_start()comment and this SO discussion that it refers to. ↩