FIX: kernprof erroring out in non--l, non--b mode in Python 3.12+#326
Conversation
|
Hi @Erotemic, just fixed the bug we went over the past few days; please review. Cheers EDIT: weird, I assumed that the coverage would go up... anyway EDIT 2: and do we need a CHANGELOG note for that? EDIT 3: nvm just pushed the CHANGELOG entry. Should've done it before writing the PR so that CI isn't run twice – sorry about that. EDIT 4: just realized that we aren't including |
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #326 +/- ##
==========================================
+ Coverage 61.32% 61.82% +0.50%
==========================================
Files 11 12 +1
Lines 848 854 +6
Branches 186 186
==========================================
+ Hits 520 528 +8
+ Misses 274 272 -2
Partials 54 54
Continue to review full report in Codecov by Sentry.
🚀 New features to boost your workflow:
|
kernprof.ContextualProfile.runctx() fixkernprof erroring out in non--l, non -b mode in Python 3.12+
kernprof erroring out in non--l, non -b mode in Python 3.12+kernprof erroring out in non--l, non--b mode in Python 3.12+
| except ImportError: | ||
| try: | ||
| from lsprof import Profile | ||
| from lsprof import Profile # type: ignore[assignment,no-redef] |
There was a problem hiding this comment.
This line is never going to run, right? I'm trying to find when lsprof even existed under that namespace, but even as far back as 2.6 I only see _lsprof. Unless I'm missing something, maybe lets just remove this case to simplify the logic.
There was a problem hiding this comment.
Since the Python docs say
cProfile is recommended for most users; it’s a C extension with reasonable overhead that makes it suitable for profiling long-running programs. Based on
lsprof, contributed by Brett Rosen and Ted Czotter.
I guess it's just a fallback for when lsprof used to be its own thing, before it was "vendor-ed into" cPython (and nowhere else to be found). But given we're on 3.9 anyways I guess it should be safe to drop it...
| tool_id = sys.monitoring.get_tool(profiler_id) | ||
| if tool_id is not None: | ||
| assert tool_id == 'cProfile' | ||
| sys.monitoring.free_tool_id(profiler_id) |
There was a problem hiding this comment.
I'm trying to understand why cProfile has already been registered. Is it just because when we are running tests, multiple profilers get created?
Perhaps a better solution would be to ensure that we are cleaning up the profilers after we are done?
There was a problem hiding this comment.
Probably figured it out:
- We call
prof.runctx()in the code. - Since we haven't overriden
.runctx(), it defers tocProfile.Profile.runctx(), which calls.enable()as the first thing it does. - And
cProfile.Profile.enable()is inherited from_lsprof.Profiler.enable(), which callssys.monitoring.use_tool_id(2, 'cProfile'). - In fact,
cProfile.Profile.runctx(),.runcall(), and.__enter__()all starts with an.enable()call.
So the solution is to override the aforementioned methods (.runctx() and .runcall(), but probably just the former because the latter isn't used anyway; and we're already supplying an .__enter__()) in ContextualProfile with their equivalents in line_profiler.line_profiler.LineProfiler, which uses the .enable_by_count() and .disable_by_count() idioms rather than the bare .enable() and .disable(). Looking at the class body of ContextualProfile, there are already several methods labelled with a decade-old comment to the effect of # FIXME: refactor so that both LineProfiler and ContextualProfile can use the same implementation, like .wrap_function(), .wrap_generator(), and .__call__(). So we can either:
- Add another to the pile, or
- Do as the comments suggested and refactor
ContextualProfilecompletely, perhaps with aline_profiler.utils.ProfilerMixinfor the common methods.
I'd also like to note that there's probably no extra cleanup needed on our (i.e. line_profiler's) end that we aren't already doing, because it uses a separate implementation of .enable() (see _line_profiler.pyx) which never registered itself with sys.monitoring. However, it may be worth considering actually doing so to comply with standards:
cdef class LineProfiler:
def enable(self):
try:
sys.monitoring.use_tool_id(sys.PROFILER_ID, 'line_profiler')
except AttributeError: # Python < 3.12
pass
PyEval_SetTrace(python_trace_callback, self)
cpdef disable(self):
try:
sys.monitoring.free_tool_id(sys.PROFILER_ID)
except AttributeError: # Python < 3.12
pass
self._c_last_time[threading.get_ident()].clear()
unset_trace()There was a problem hiding this comment.
If we were to go down the refactor route, maybe we should also consider dropping all those conditional and try-except imports of line_profiler in kernprof – since we've been distributing kernprof with line_profiler for ages, maybe it no longer makes sense to cater to imaginary use-cases where the one is available while the other isn't?
There was a problem hiding this comment.
That would require a major version bump. It's niche, but still a code path you could envision a path to hit.
There was a problem hiding this comment.
To the earlier comment: fantastic work. I agree that overloading runctx makes sense. I also think we should register ourselves to comply with standards.
If outdated constructs are getting in your way, we can major version bump if needed.
There was a problem hiding this comment.
That would require a major version bump. It's niche, but still a code path you could envision a path to hit.
Yikes, I missed your reply before pushing the new changes. Sorry about that.
Maybe I can move the module containing the mixin class to the top-level (where kernprof.py lives) and have both kernprof.ContextualProfile and line_profiler.line_profiler.LineProfiler inherit from that instead? That stops kernprof from becoming dependent on line_profiler, but then again whatever new module does become a new dependency, which may require the version bump anyway.
Or would it be acceptable w/o a major version bump if we put the mixin class in kernprof and have line_profiler.line_profiler.LineProfiler import and inherit from that instead?
There was a problem hiding this comment.
I also think we should register ourselves to comply with standards.
Did some more tests with the sys.monitoring thing, but it unexpectedly broke a test: specifically tests/test_complex_case.py::test_varied_complex_invocations(), which does something fancy with coroutines. It seems that with concurrency we have some consistency issues with LineProfiler.enable_count, causing double .enable(), which sys.monitoring.use_tool_id() now turns into an error.
That in itself is probably worth further investigation, but for the time being it's probably for the best that we keep the Cython code on this PR untouched, and spin that off into another PR (will write soon), since the fix seems nontrivial?
EDIT: just fixed that in #327.
There was a problem hiding this comment.
Yikes, I missed your reply before pushing the new changes. Sorry about that.
I'm looking at the diff, and it seems like the code can be consolidated by quite a bit if we move kernprof into line_profiler itself.
The main thing that makes me hesitant to more tightly couple line_profiler and kernprof is that the README states:
kernprof is a single-file pure Python script and does not require a compiler
If kernprof has advertised itself like this for over a decade, it seems like someone might be using it like that. On the other hand: is anyone actually using it like that? And perhaps the single-file thing isn't important, but the pure-Python piece is. In that case we could integrate kernprof into line_profiler directly, and it will keep the same API via modifying entry_points.console_scripts.
Not sure if I can summon @rkern, but I'd be interested in an opinion.
There was a problem hiding this comment.
Installation was more of a Wild West back then. I noted it as single-file, pure Python script in case folks wanted to download it by itself and use it as a driver for cProfile. Nowadays, people pip install everything, and the hassle of distributing code that needs compilation is significantly lessened. And there are likely other tools for driving cProfile.
Do whatever you think is best. The motivation behind that statement is a decade or so gone.
There was a problem hiding this comment.
Thanks both for the feedback.
In this case, is it advisable that I:
- Roll back the mixin refactoring on this PR and just patch
kernprof.ContextualProfile.runctx()in the interest of small diffs – and leave the refactoring for later, or - Push further with the refactoring, moving
kernprof.pyintoline_profilerand providing it as an entry-point console script on installation?
One corner case though is exhibited by tests/test_kernprof.py::TestKernprof, where ContextualProfile is imported from kernprof. It's dubious whether it's a use-case that exists in the wild and that we want to cater to; but if it does and/or we do, we'll have to implement a shim that makes kernprof import-able, much like how setuptools shims a .pth file on install to make setuptools._distutils visible to the import system as distutils. Or maybe there's a simpler way via package discovery that I haven't thought of yet, since in setuptools' case they needed their vendored-in distutils to override the stdlib one, and thus the hack that they explicitly advised against using.
Another thing that I've been thinking about is the code consolidation. While LineProfiler and ContextualProfile share certain implementations, they are still separate classes – and thus it's probably better to have them pull the common methods from the mixin than to have one depend on the other, even if we do migrate kernprof...
|
Just went ahead and went through with the refactoring.
EDIT: EDIT 2: formatting EDIT 3: Just realized I forgot to inherit from EDIT 4: EDIT 5: The coverage report is somewhat suspicious. In #327 I didn't touch
NoteSpecifically, # cProfile.Profile
def do_stuff_cprofile(self: cProfile.Profile, ...):
self.enable()
try:
...
finally:
self.disable()
# profile.Profile
def do_stuff_profile(self: profile.Profile, ...):
self.set_cmd(...)
sys.setprofile(self.dispatcher)
try:
...
finally:
sys.setprofile(None)It's tempting to supply |
|
Since the previous comment is already getting too long, I'm breaking it off here. Summary
What happenedSo
|
|
I've always had issues with test coverage in this module. The goal here is not to be 100%, it's to provide reasonable quality control. Sure it would be better if we had a bigger number, but if the tests are running, then any step further is a nice-to-have. If you do manage to figure out the cause for missing coverage or how to fix it I would be very interested, but the priority doesn't need to be high. If this classmethod issue was a bug in previous versions, then we can mark it as a known issue. What is important here is that there are no regressions and that new code is reasonably tested. |
|
By turning on dynamic contexts, it seems that only these tests are (reported as) actively contributing to coverage:
... which is absolutely preposterous. Many of the tests in Possible known issue?And according to Issue #974 apparently this has been known behavior for years, that function and class definition are counted as "covered" once they are executed, i.e. at definition time – which reads like it renders the coverage percentage a useless metric, at least for any part of the code that isn't inside an Why do only two tests have attributed lines?The only tractable difference between the tests which contributed to coverage and the majority that did seems to be that those two tests define the entities they are profiling within the tests themselves... ... at least that was my theory until I took the function/class definitions outside of those tests, cleared all caches and reran everything, and the results stayed the same. I'm completely baffled. Even more inconsistenciesAnother weird behavior is how the coverage keeps flip-flopping between 57 and 63%, with the lower coverage being shown approximately 1/4-th of the time. Apparently it is the lines in the It is probably less of an issue in CI because the coverages are combined across runs, which makes it less probable that all runs "rolled low" and caused an apparent coverage drop. But I guess it can still happen if one is unlucky enough... |
tests/test_explicit_profile.py::test_explicit_profile_with_kernprof()
Added failing subtest to show problematic behavior when using
`kernprof.ContextualProfile.runctx()` (i.e. when running `kernprof`
with neither the `-l`/`--line-by-line` nor the `-b`/`--builtin` flag
kernprof.py
<Imports>
Added type comment so that `mypy` stops complaining
ContextualProfile
enable_by_count()
Added safeguard against the 'Another profiling tool is
already active' ValueError in Python >= 3.12
ensure_tool_id_availability()
Said safeguard
line_profiler/line_profiler.py[i]::LineProfiler
Moved common methods like `.__call__()`, `.run[ctx,call]()`, and
their dependencies to `line_profiler/profiler_mixin.py[i]`
line_profiler/profiler_mixin.py[i]::ByCountProfilerMixin
New mixin class for common methods shared by
`line_profiler.line_profiler.LineProfiler` and
`kernprof.ContextualProfile`
line_profiler/profiler_mixin.py[i]::ByCountProfilerMixin.__call__()
Replaced self-rolled generator check with
`inspect.isgeneratorfunction()`
kernprof.py
Profile
Removed outdated import check for `lsprof.Profile`
ContextualProfile
- Now a `line_profiler.ByCountProfilerMixin` subclass
- Removed unused `.ensure_tool_id_availability()` definition
- Removed duplicate definitions for methods defined in
`ByCountProfilerMixin`
- `.__init__()` now raises an error when inheriting from
`profile.Profile` because said base class is incompatible
anyway (cannot be `.enable()`-ed or `.disable()`-ed)
Erotemic
left a comment
There was a problem hiding this comment.
Finally got some time to review this in details. I think I've understood the problem and I see the reasoning for all of the changes.
After addressing the following comments we can merge and finally get to your main PR.
kernprof.py
ContextualProfile
- Now explicitly calling `Profile.__init__()` in `.__init__()`
- Removed conditional redefinition of `.__init__()`
- Now explicitly defining `.__call__()` due to refactoring of
`ByCountProfilerMixin`
main()
Moved error raised by `ContextualProfile.__init__()` when
inheriting from `profile.Profile` into the function body
line_profiler/line_profiler.py::LineProfiler.__call__()
Now calling `.wrap_callable()` instead of
`ByCountProfilerMixin.__call__()`
line_profiler/profiler_mixin.py[i]::ByCountProfilerMixin
Renamed method `.__call__()` to `.wrap_callable()`

Synopsis
When writing #323, we noticed that the one of the new tests are failing in Python 3.12 and above, and accidentally discovered that the
prof.runctx()code path is currently untested. Said code path is executed whenkernprofis run with neither the-b/--builtinnor-l/--line-by-lineoptions.Further investigation revealed that the introduction of
sys.monitoringand tooling registration is to blame, especially these lines inlsprof.cof the Python source code, corresponding tocProfile.Profile.enable()– which causeskernprof.ContextualProfile.enable_by_count()to fail when first called, because the profiling tool'cProfile'has already been registered tosys.monitoring.use_tool_id().This PR adds a check which frees the tool ID if registered, so that it can be re-registered by.enable()when.enable_by_count()is called.EDIT 22 Mar: This PR refactors
kernprof.ContextualProfileso that:.run(),.runctx(), and.runcall()methods no longer directly call.enable()and.disable(), but.enable_by_count()and.disable_by_count()instead;line_profiler.LineProfilerare moved into a separate mix-in class, which both now inherit from..__init__()now raises an error whencProfile.Profileis unavailable andprofile.Profileis loaded as a base class instead, because of API incompatibilities (see comment below).EDIT 1 Apr: the error raised by
ContextualProfile.__init__()has been migrated tokernprof.main().Caveats
When messing around with the code, the following potential bug is noted;
but since it may be a while before we can come up with a robust fix, I decided to let that be and maybe fix/write an issue later.Specifically,
profile.Profilemay be imported as a substitute forcProfile.Profile. However, the former is not fully compatible withkernprof.ContextualProfile, because it cannot be.enable()-ed nor.disable()-ed.A "fix" seems easy – just don't call those methods if we don't have them – but IDK about the full ramifications of that.EDIT 22 Mar: see above edit.