Skip to content

perf(corekit): resolve a ModuleDescriptor through sys.modules, not import_module (#574) - #586

Merged
JarryShaw merged 2 commits into
mainfrom
perf/574-default-descriptor-resolution
Sep 21, 2026
Merged

JarryShaw merged 2 commits into
mainfrom
perf/574-default-descriptor-resolution

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Proposed by @Ts-Boom in #563. The profiling that found this, and the observation that a registry miss re-resolves its module on every frame, are theirs. #563 was declined for the shape of its cache rather than for the finding; this is the version #574 asked for, and the miss-path measurement in it reproduces exactly as they described.

Fixes #574.

The defect

A next layer code nobody registered resolves to the fallback ModuleDescriptor the registry's default factory produces — normally Raw — and ProtocolBase._lookup_next_layer deliberately does not write that back. Recording a miss in a class-level defaultdict is the defect #425/#428 fixed at this layer and #560 fixed at the schema layer, so not writing it back is correct. The cost is that every unrecognised frame resolved the same descriptor again, each time re-entering importlib.import_module for a module sys.modules already held.

Measured ModuleDescriptor.klass resolutions per extraction, in a warmed process:

capture frames klass reads import_module calls, before after
http.pcap 1117 0 0 0
ipv4.pcap 4 4 4 0
many_interfaces.pcapng 64 48 48 0

The change

pcapkit/corekit/module.py:68-80ModuleDescriptor.klass reads sys.modules first and enters importlib.import_module only when the module is not loaded yet, or when the loaded module does not have the attribute. That second case is a module whose body is still executing — a circular import, or another thread part way through importing it — which is exactly what import_module's per-module lock exists to wait for, so behaviour is preserved rather than traded away. A name that genuinely does not exist still raises AttributeError with the same message.

pcapkit/protocols/protocol.py:1373-1381 — a docstring paragraph on _lookup_next_layer recording why no memo lives there, so the next reader does not add one.

Invalidation: there is no memo, deliberately

The class is re-read with getattr on every access. That is the whole design: sys.modules is the module cache, and it is the only one whose invalidation the interpreter maintains. Every alternative caches a class object, and every cached class object goes stale:

  • importlib.reload re-executes the module body into the same module object, so the class it defines is a new object while sys.modules[name] is unchanged. A memo validated against the module object's identity would not even notice.
  • sys.modules.pop() followed by a re-import replaces the module object outright.

Reproduced on this branch with #563's _MODULE_CACHE reapplied — test_no_stale_class_survives_a_module_reload fails with <class 'pcapkit.protocols.misc.raw.Raw'> is not <class 'pcapkit.protocols.misc.raw.Raw'>, which is the owner's reproduction on #563 turned into a test.

Two shapes #574 raised as candidates were tried and rejected, both proven to break the guards:

Measurement, and what it does not show

Method: PYTHONSAFEPATH=1, worktree root prepended to sys.path, pcapkit.__file__ asserted to be inside the worktree before anything else is imported; .venv/bin/python 3.14.7. Microbenchmarks are timeit, best of 5 × 200k. Call counts are exact, from a counting wrapper around importlib.import_module.

before after
ModuleDescriptor.klass 435.8 ns 117.4 ns
_lookup_next_layer, miss 883.4 ns 526.0 ns
_lookup_next_layer, hit 124.3 ns 127.0 ns (noise)
registry.default_factory() alone 242.2 ns 250.5 ns (untouched)

Whole-extract() wall clock shows nothing, and I am not going to claim otherwise. 15 runs each:

capture before, median after, median stdev
http.pcap 540.07 ms 539.52 ms ~1.9 ms
ipv4.pcap 1.414 ms 1.463 ms ~0.03–0.13 ms
many_interfaces.pcapng 37.09 ms 37.39 ms ~0.43 ms

The mechanism predicts ~17 µs of saving on many_interfaces.pcapng (48 × 357 ns) — 25× smaller than that capture's run-to-run stdev, and the measured median moved the wrong way by less than one stdev. So the change is taken for its shape, not its speed.

#563's "~40% of cumulative time" does not reproduce, and #575's warning holds: _import_next_layer is a recursive-descent dispatcher, so its 90.2% cumulative time on http.pcap is the whole nested parse beneath it, while its self time is 0.58%. aenum.extend_enum at 16.7% self time is where the real time is.

Tests

coverage run -m pytest (never pytest-cov), on pcapkit/corekit/module.py:

statements missed branches partial cover
before 11 0 0 0 100%
after 17 0 2 0 100%

Six more statements and two new branch arcs, all exercised; 1 test case became 8 across the two files.

Proven to fail on the unfixed tree (exit code read from a file, not from the printed summary):

FAILED tests/corekit/test_module.py::ModuleDescriptorTests::test_klass_reads_an_already_loaded_module_out_of_sys_modules
FAILED tests/protocols/test_dispatch_default_resolution_unit.py::DefaultDescriptorResolutionTests::test_repeated_miss_does_not_re_enter_the_import_machinery
==================== 2 failed, 6 passed, 1 warning in 2.11s ====================
E       AssertionError: Lists differ: ['pcapkit.protocols.misc.raw', 'pcapkit.pr[103 chars]raw'] != []
E       First list contains 5 additional elements.

— five import_module calls for five lookups of an already-imported module. With the fix: 8 passed.

The three guards pass on main as well, because main is not stale-prone; what gives them teeth is that each fails against the design it exists to rule out, shown above.

Found, not fixed

The function-level from ... import NoPayload statements on the protocol layer — one of which is _import_next_layer's length == 0 fast path, and which #563 also cached — execute 890 times on http.pcap at ~162 ns each, against ~58 ns for the sys.modules equivalent. That is ~92 µs on a ~540 ms extraction, so it is left alone rather than paid for with a second resolution path. Worth noting that at 162 ns a function-level from X import Y is nowhere near as expensive as #563's write-up assumed; import_module at 436 ns was the costly one.

Checks

  • mypy --follow-imports=silent --ignore-missing-imports pcapkit/corekit/module.py — clean.
  • pylint with the project's Makefile flags — 10.00/10.
  • isort -l100 -ppcapkit --check-only — clean.
  • vermin — minimum 3.6, unchanged.
  • Rebased onto origin/main; one commit.
  • CHANGELOG.md is generated, so the entry is written in docs/source/changelog/1.5.0.rst and CHANGELOG.md regenerated with python util/changelog_md.py; --check is in step. The first push failed that gate because the entry used :data:/:func:/:class:/:file: roles, which the six conversion rules do not cover — rewritten in the supported literal subset.

JarryShaw added a commit that referenced this pull request Sep 21, 2026
…port_module (#574) (#586)

Proposed by @Ts-Boom in #563.

A next layer code nobody registered resolves to the fallback ModuleDescriptor
the registry's default factory produces, and _lookup_next_layer deliberately
does not write that back -- recording a miss in a class-level defaultdict is
the defect #425/#428 fixed at this layer and #560 fixed at the schema layer.
So every unrecognised frame resolved the same descriptor again: 48 of the 52
ModuleDescriptor.klass resolutions an extraction of many_interfaces.pcapng
performs, each re-entering importlib.import_module for a module sys.modules
already held.

- ModuleDescriptor.klass now reads sys.modules first, and enters
  import_module only when the module is not loaded yet -- or when the loaded
  module does not have the attribute, which is a body still executing (a
  circular import, or another thread part way through importing it) and is
  what import_module's per-module lock exists to wait for. Measured on
  CPython 3.14.7: klass 436 -> 117 ns, the whole miss path 883 -> 526 ns,
  and import_module calls during extract() 48 -> 0 on many_interfaces.pcapng
  and 4 -> 0 on ipv4.pcap. The hit path is untouched (124 -> 127 ns, inside
  noise), since it is already memoised by the registry write-back.
- Nothing memoises the resolved class, which is the deliberate part. The
  class is re-read with getattr on every access, so sys.modules stays the
  only module cache in play and its invalidation is the interpreter's:
  importlib.reload rebinds the class inside the same module object, and
  sys.modules.pop() replaces the object outright. #563's class-level
  _MODULE_CACHE followed neither, and an instance built from the class it
  kept fails isinstance against the live one.
- Records in _lookup_next_layer's docstring why no memo lives there, so the
  next reader does not add one.

Honest about the scale: this is not measurable in extract() wall clock. 48
avoided calls is ~17 us against a ~37 ms extraction, two orders of magnitude
inside this host's single-digit-millisecond run-to-run variance, and repeated
A/B pairs flipped sign. #563's "~40% of cumulative time" does not reproduce:
_import_next_layer's self time is 0.58% against its 90.2% cumulative, because
it is a recursive-descent dispatcher that has the whole nested parse beneath
it. aenum.extend_enum at 16.7% self time is where the real time is (#575).

Also found, not fixed here: the function-level `from ... import NoPayload`
statements on the protocol layer, one of which is _import_next_layer's
length == 0 fast path, run 890 times on http.pcap at ~162 ns against ~58 ns
for the sys.modules equivalent -- ~92 us on a ~540 ms extraction, so left
alone rather than paid for with a second resolution path.

Adds tests/protocols/test_dispatch_default_resolution_unit.py, and four cases
to tests/corekit/test_module.py. The two that assert the saving fail on the
pre-fix code (5 import_module calls for 5 lookups); the three guards fail
against the designs this one rejects -- the reload guard against #563's cache,
and all three against writing the resolved fallback back under the missed code.

Closes #574.
@JarryShaw
JarryShaw force-pushed the perf/574-default-descriptor-resolution branch from 5c8bc21 to 524d246 Compare September 21, 2026 20:56
…port_module (#574) (#586)

Proposed by @Ts-Boom in #563.

A next layer code nobody registered resolves to the fallback ModuleDescriptor
the registry's default factory produces, and _lookup_next_layer deliberately
does not write that back -- recording a miss in a class-level defaultdict is
the defect #425/#428 fixed at this layer and #560 fixed at the schema layer.
So every unrecognised frame resolved the same descriptor again: 48 of the 52
ModuleDescriptor.klass resolutions an extraction of many_interfaces.pcapng
performs, each re-entering importlib.import_module for a module sys.modules
already held.

- ModuleDescriptor.klass now reads sys.modules first, and enters
  import_module only when the module is not loaded yet -- or when the loaded
  module does not have the attribute, which is a body still executing (a
  circular import, or another thread part way through importing it) and is
  what import_module's per-module lock exists to wait for. Measured on
  CPython 3.14.7: klass 436 -> 117 ns, the whole miss path 883 -> 526 ns,
  and import_module calls during extract() 48 -> 0 on many_interfaces.pcapng
  and 4 -> 0 on ipv4.pcap. The hit path is untouched (124 -> 127 ns, inside
  noise), since it is already memoised by the registry write-back.
- Nothing memoises the resolved class, which is the deliberate part. The
  class is re-read with getattr on every access, so sys.modules stays the
  only module cache in play and its invalidation is the interpreter's:
  importlib.reload rebinds the class inside the same module object, and
  sys.modules.pop() replaces the object outright. #563's class-level
  _MODULE_CACHE followed neither, and an instance built from the class it
  kept fails isinstance against the live one.
- Records in _lookup_next_layer's docstring why no memo lives there, so the
  next reader does not add one.

Honest about the scale: this is not measurable in extract() wall clock. 48
avoided calls is ~17 us against a ~37 ms extraction, two orders of magnitude
inside this host's single-digit-millisecond run-to-run variance, and repeated
A/B pairs flipped sign. #563's "~40% of cumulative time" does not reproduce:
_import_next_layer's self time is 0.58% against its 90.2% cumulative, because
it is a recursive-descent dispatcher that has the whole nested parse beneath
it. aenum.extend_enum at 16.7% self time is where the real time is (#575).

Also found, not fixed here: the function-level `from ... import NoPayload`
statements on the protocol layer, one of which is _import_next_layer's
length == 0 fast path, run 890 times on http.pcap at ~162 ns against ~58 ns
for the sys.modules equivalent -- ~92 us on a ~540 ms extraction, so left
alone rather than paid for with a second resolution path.

Adds tests/protocols/test_dispatch_default_resolution_unit.py, and four cases
to tests/corekit/test_module.py. The two that assert the saving fail on the
pre-fix code (5 import_module calls for 5 lookups); the three guards fail
against the designs this one rejects -- the reload guard against #563's cache,
and all three against writing the resolved fallback back under the missed code.

Closes #574.
@JarryShaw
JarryShaw force-pushed the perf/574-default-descriptor-resolution branch from 524d246 to b6c314f Compare September 21, 2026 21:05
@JarryShaw

Copy link
Copy Markdown
Owner Author

❌ NEEDS CHANGES at head sha 524d246161d019e47470dc78b2fac8e42ad7c2a4: the new changelog entry at docs/source/changelog/1.5.0.rst (added lines ~423-448) uses :data:, :func:, :class: and :file: interpreted-text roles that util/changelog_md.py's six conversion rules do not cover (only rule 2 handles :rfc:); rewrite those spans as double-backtick literals per house style, run python util/changelog_md.py to regenerate CHANGELOG.md, confirm python util/changelog_md.py --check exits 0, and amend the existing commit (keep the Fixes #574 line and the @Ts-Boom credit).

@JarryShaw

Copy link
Copy Markdown
Owner Author

Reviewer: Sonnet; PR authored on Opus 5.

Falsify-not-bless pass on PR #586 (perf/574-default-descriptor-resolution, head 524d246161d019e47470dc78b2fac8e42ad7c2a4), closing #574.

1. The behavioral claim, re-derived from the code itself (no RFC applies here)

Read git diff origin/main 524d246 -- pcapkit/corekit/module.py pcapkit/protocols/protocol.py directly rather than trusting the PR prose:

  • ModuleDescriptor.klass now does sys.modules.get(self.module) first; if present it does getattr(module, self.name) and only falls through to importlib.import_module(self.module) on AttributeError (a partially-initialized module) or when the module isn't loaded at all. Confirmed: this preserves the original AttributeError message for a genuinely-missing name, because the final line is still getattr(importlib.import_module(...), self.name) — same call shape as the pre-fix code.
  • No class-level cache is added anywhere. getattr runs fresh on every access, so sys.modules/importlib.reload stay the only invalidation path. Confirmed by inspection of the diff — there is no _MODULE_CACHE-shaped attribute anywhere in the change, unlike the declined perf: Optimize Protocol extraction by caching dynamically imported modules in ProtocolBase #563.
  • protocol.py's change is docstring-only (10 added lines on _lookup_next_layer), no behavior change. Confirmed by reading the diff.

2. Tests: proven to fail without the fix, independently, not taken on the PR's word

Checked out 524d246 into a throwaway worktree (/tmp/review-586, removed after review), ran the new/changed tests as shipped:

tests/corekit/test_module.py tests/protocols/test_dispatch_default_resolution_unit.py
8 passed, 1 warning in 2.07s   (exit 0)

Then git checkout origin/main -- pcapkit/corekit/module.py pcapkit/protocols/protocol.py (source only, tests untouched) and re-ran the same two files:

FAILED tests/corekit/test_module.py::ModuleDescriptorTests::test_klass_reads_an_already_loaded_module_out_of_sys_modules
FAILED tests/protocols/test_dispatch_default_resolution_unit.py::DefaultDescriptorResolutionTests::test_repeated_miss_does_not_re_enter_the_import_machinery
2 failed, 6 passed, 1 warning in 2.22s   (exit 1)

This matches the PR body's own reported "2 failed, 6 passed" exactly. Verdict: the two new regression tests genuinely pin the fix; they do not pass either way. I did not separately re-verify the two reload-invalidation tests and the write-back guard test against a re-applied #563-style cache (the PR's own claim for that specific reproduction) — noting that as unverified by me, not confirmed. What I did verify directly: test_klass_follows_a_rebound_class_rather_than_keeping_the_first_one and test_no_stale_class_survives_a_module_reload both pass against the actual (cache-free) fix, which is the property the PR needs, independent of whether #563's specific cache shape also fails them.

3. Fixes #574 literal line

Present, verbatim, as its own line in the PR body: Fixes #574. — confirmed via gh pr view 586 --json body. Closes the right issue.

4. CI, read correctly (CheckRun tally, not a jq select over the raw list; StatusContext excluded from the tally)

Via GraphQL on the head commit, contexts filtered on __typename == "CheckRun" (24 total):

  • SUCCESS (8): Analyze, Compat Python 3.10/3.11/3.12/3.13/3.14/3.15, CodeQL
  • SKIPPED (2): Docs test gate, Gate (full suite, Python 3.14)
  • FAILURE (1): Changelog drift (job 106513933934)
  • IN_PROGRESS / no conclusion yet (13): Python 3.10-3.15 (6), Integration Python 3.10-3.15 (6), deploy-pages (1)

statusCheckRollup.state on the head commit: FAILURE. The one StatusContext, pyup.io/safety-ci, reports SUCCESS this time — not the forever-pending state that has wasted ticks elsewhere in this programme, and correctly excluded from the CheckRun tally regardless. No force-push has happened on this branch (single commit, mergeable: MERGEABLE), so there's no CANCELLED/FAILURE conflation risk here.

I independently reproduced the Changelog drift failure rather than accepting the log excerpt at face value. cd into the checked-out worktree and ran util/changelog_md.py --check directly against .venv/bin/python (3.14.7):

ResidualMarkupError: .../docs/source/changelog/1.5.0.rst uses reStructuredText the six
conversion rules do not cover, so CHANGELOG.md would carry it through as literal text:
  line 46: an unconverted interpreted-text role: ':data:`...`'
  line 46: an unconverted interpreted-text role: ':func:`...`'
  line 46: an unconverted interpreted-text role: ':class:`...`'
  line 46: an unconverted interpreted-text role: ':file:`...`'  (x2)
  line 46: an unconverted interpreted-text role: ':data:`...`' (x2 more)
  line 46: an unconverted interpreted-text role: ':func:`...`' (x2 more)

9 hits total, byte-for-byte reproduction of the failing job's report. Traced the cause to util/changelog_md.py's own module docstring: of six mechanical rules, only rule 2 handles an interpreted-text role, and only :rfc: at that ( :rfc:NNNN -> IETF link). grep -n ':data:\|:func:\|:class:\|:file:' on the PR's 1.5.0.rst shows the new entry (added at lines 423-448) uses :data: x3, :func: x3, :class: x1, :file: x2 — none covered, all left as literal, unconverted :role:text`` in CHANGELOG.md, which is exactly what the guard in `util/changelog_md.py`'s `residual()`/`render()` exists to catch before it ships to a GitHub release body and PyPI. This is a real, deterministic defect in the new changelog entry, not CI flakiness and not `CHANGELOG.md` drifting on its own — the check fails before it gets far enough to diff anything.

(Note: the reported "line 46" doesn't correspond to line 46 of the actual .rst file — the new entry starts at line 423. The script evidently numbers residual-markup hits relative to something other than the whole file; I did not chase that discrepancy further since it doesn't change the substance of the finding, confirmed independently as above.)

5. Squash-merge caveat

N/A yet — PR is open, mergeable: MERGEABLE, one commit, not merged. Noting per standing instruction that git merge-base --is-ancestor will never be the right tool to confirm this one merged later, given squash-merge.

Disagreements / open items

  • The fix itself (the sys.modules-first read in ModuleDescriptor.klass) is sound and its regression tests are genuine, verified by direct revert-and-rerun.
  • The blocking issue is entirely in the changelog entry's markup, not in the source or test changes. It needs a rewrite to the double-backtick house style (see surrounding entries in the same file, e.g. sys.modules used as plain code-span elsewhere in the file) followed by regenerating CHANGELOG.md.
  • 13 CheckRuns are still in progress; this verdict is not contingent on them since the Changelog drift failure is deterministic and independent of interpreter version.
  • I did not reproduce the perf: Optimize Protocol extraction by caching dynamically imported modules in ProtocolBase #563-cache-reapplied failure reported in the PR body (see §2) — flagging as unverified rather than folding it into either a pass or a fail.

@JarryShaw

Copy link
Copy Markdown
Owner Author

✅ GOOD TO GO at head sha b6c314fa5a7e181c146ddf5fca3643917b5f6a40: the amend is changelog-only (confirmed by diffing 524d246 against b6c314fa5 directly — only CHANGELOG.md and docs/source/changelog/1.5.0.rst changed), all nine unconverted-role hits are gone (grep -c ':data:\|:func:`|:class:`|:file:`'on the new1.5.0.rstreturns0), and python util/changelog_md.py --checknow exits 0 in a fresh worktree at this head. Superseding my earlier **❌ NEEDS CHANGES** at524d246`.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Reviewer: Sonnet; PR authored on Opus 5. Re-point of my review from head 524d246161d019e47470dc78b2fac8e42ad7c2a4 to the force-pushed b6c314fa5a7e181c146ddf5fca3643917b5f6a40.

What carries over unchanged, and what I actually re-checked

The source (pcapkit/corekit/module.py, pcapkit/protocols/protocol.py) and both test files are byte-identical between the two heads — confirmed with git diff 524d246161d019e47470dc78b2fac8e42ad7c2a4 b6c314fa5a7e181c146ddf5fca3643917b5f6a40, which touches only CHANGELOG.md and docs/source/changelog/1.5.0.rst, nothing else. So my earlier §1 (code re-derivation) and §2 (revert-and-rerun of the two regression tests, 2 failed, 6 passed without the fix / 8 passed with it) stand without re-running them — a changelog-only amend cannot change source or test behavior. I did not re-run pytest for this pass; I did independently re-verify everything the changelog fix itself touches, rather than taking the force-push on trust:

  • git diff 524d246161d019e47470dc78b2fac8e42ad7c2a4 b6c314fa5a7e181c146ddf5fca3643917b5f6a40 --statCHANGELOG.md | 2 +-, docs/source/changelog/1.5.0.rst | 49 ++++++++++++++++++++---------------------. Confirmed changelog-only myself rather than assuming it from the CI conclusion.
  • git show b6c314fa5:docs/source/changelog/1.5.0.rst | grep -c ':data:\|:func:`|:class:`|:file:`'→ **0**. All nine hits from my original review (three:data:, three :func:, one :class:, two :file:, at lines 423-448) are gone — every role converted to a double-backtick literal, not just the single :data:` the failing job log happened to name.
  • Checked out b6c314fa5 into a throwaway worktree and ran python util/changelog_md.py --check directly: /tmp/review-586v2/CHANGELOG.md is in step with /tmp/review-586v2/docs/source/changelog/1.5.0.rst, exit 0.
  • CI on this head: Changelog drift CheckRun is now SUCCESS (was FAILURE at 524d246). Full CheckRun tally at time of writing (24 total): 10 SUCCESS (Analyze, Compat Python 3.10-3.15, Changelog drift, CodeQL), 2 SKIPPED (Docs test gate, Gate (full suite, Python 3.14)), 8 IN_PROGRESS, 4 QUEUED, 0 FAILURE. statusCheckRollup.state is PENDING, not FAILURE — the only prior blocker is cleared and nothing new has failed. pyup.io/safety-ci (StatusContext, excluded from the CheckRun tally) reads SUCCESS.

Verdict basis

The blocking issue from my first pass is resolved and independently confirmed at the source (grep + --check exit 0), not merely inferred from CI going green. The 12 still-pending CheckRuns (Python/Integration matrix + deploy-pages when I first looked, since resolved) are the same version matrix that was already 6/6 green on Compat and unaffected by a changelog-only change; I am not re-litigating the source correctness already established in my 524d246 appendix. GOOD TO GO.

@JarryShaw
JarryShaw merged commit ec28418 into main Sep 21, 2026
23 of 24 checks passed
@JarryShaw
JarryShaw deleted the perf/574-default-descriptor-resolution branch September 21, 2026 22:08
@JarryShaw JarryShaw added the perf Pull requests that improve performance (perf: subject prefix) label Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

perf Pull requests that improve performance (perf: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memoise the default ModuleDescriptor on a registry miss, instead of re-resolving it per frame (from #563)

1 participant