Revert normalize() performance changes (gh-143658)#541
Conversation
|
Here's the performance diff I see locally: |
e072b29 to
89e9eb7
Compare
7c0d919 to
0f88c48
Compare
89e9eb7 to
73efee2
Compare
|
Looking at the Ubuntu 3.14 run, I see something similar: So the performance differences are real, but still in the nsec range (sub micro-second benefit). Let's see if we can quantify the value proposition, specifically by estimating the number of times this call is made globally. If we can't identify a real value proposition, the simpler implementation should prevail. |
|
Following up on the value-proposition question, here's what I can piece together about how often 1. What the upstream change was justified onThe CPython PRs justify the change with a throughput micro-benchmark, not a workload: python/cpython#143660 normalizes all 8,344,947 PyPI names in 5.15 s → 1.38 s (3.7×); #144083 just compares the three implementations across Python versions. The rationale was delegated to the blog — but the blog's real-world numbers are about 2. Where
|
| Cold operation | normalize calls |
|---|---|
list(distributions()) |
95 (≈ N) |
version('pip') / metadata('pip') |
96 (≈ N+1) |
entry_points() |
190 (≈ 2N) |
3× version() in one process |
100 (index reused) |
The structural point: even a single version() lookup normalizes all installed names. Cost is O(N in the environment) per process, so normalize_perf (one call) understates per-process cost by ~N×.
3. Per-call delta
Cleanly (locally bound, 4 representative names, Apple Silicon / 3.14): re.sub 529 ns → .lower().replace() 121 ns, Δ ≈ 414 ns/call (4.4×). Consistent with my earlier ~240–310 ns here and CPython's ~450 ns.
4. …but the operations are I/O-bound, so it's invisible
Cold operation wall-time vs. the normalize budget inside it (same machine, N=94):
| Operation | cold wall time | normalize budget |
share |
|---|---|---|---|
list(distributions()) |
1.09 ms | ~11 µs | ~1% |
entry_points() |
3.28 ms | ~23 µs | ~0.7% |
version('pip') |
6.70 ms | ~11 µs | ~0.2% |
The saving (Δ × N) is ~2–4% of discovery at most, <1% of entry_points/version. The rest is listdir/stat on site-packages and reading entry_points.txt/METADATA.
This matches the exercises.py suite: on the revert, the only benchmark that moved was normalize_perf itself (332 ns / 420 ns in my two runs). discovery (~173 µs), cached/uncached distribution (~169/273 µs), and entry_points() (~2.3 ms) did not register a change — the micro-op disappears into filesystem noise.
5. The pip case, specifically (since it's the obvious "hot caller")
I instrumented Prepared.normalize and ran real pip commands (pip uses the importlib backend here):
| pip invocation | normalize calls |
|---|---|
pip list |
94 |
pip freeze |
94 |
pip install --dry-run … pytest |
94 |
pip show pip |
0 |
pip list --outdated --no-index |
0 |
So pip pays ~N calls once per invocation that enumerates the environment (via importlib.metadata.distributions()), and zero in its resolution/name-matching hot loop — that runs on packaging.canonicalize_name (92 call sites in pip/_internal), a different function that was optimized separately (pypa/packaging#1030). The "large resolution" scenario the blog uses to motivate the change does not exercise importlib.metadata.normalize at all. For pip, this is a flat ~94 × 414 ns ≈ 40 µs, once, inside a multi-hundred-ms process.
6. Aggregate, in the wild (Fermi — assumptions explicit)
Anchoring on real data instead of guessing: normalizing all 8.3 M PyPI names saves ~3.78 s (the CPython benchmark delta). So you'd have to normalize the entirety of PyPI ~960 times to recoup one CPU-hour.
If I push a global figure anyway:
- (A) typical env N ≈ 100 distributions (range ~15–300);
- (B) a process touching
importlib.metadatapays ~1–2 N ≈ 150 calls (measured); - (C) ~10⁸–10⁹ such invocations/day globally (pip enumerations, test sessions, and CLI/app startups doing
entry_points()/version()); - with the pip correction from §5: pip contributes ~N once per enumerating command, not per resolution step.
→ ~5×10¹²–5×10¹³ calls/yr → at 414 ns, ~24 to ~240 CPU-days per year spread across all Python usage on Earth. Real, but per-invocation it's microseconds, inside operations already costing milliseconds of I/O.
Conclusion
This is a genuine 4.4× speedup of a function that isn't on any measurable hot path. Its cost is O(N) per process, yet still 1–4% of the I/O-bound operation containing it and invisible in every operation-level benchmark; even pip's dependency resolution doesn't route through it. The aggregate global saving is plausibly tens of CPU-days/year under generous assumptions — genuine but negligible per user, and unmeasurable against filesystem cost. Absent a concrete hot-path use case (which the blog, the CPython issue, and the reviews did not produce), I lean toward the simpler re.sub for readability/maintainability, keeping the performance and unit tests so the trade-off stays measurable.
Happy to be shown a workload where this actually matters.
Measurements gathered with Claude Code; methods/scripts available on request.
|
@henryiii and @hugovk , I don't feel like the justification case is strong, so I'd like to revert this to its prior, direct, more concise, single-expression, debt-free implementation using higher-level constructs. I'll let this sit for a while to give a chance to make the case (DO NOT MERGE before 2026-08-31), but my instinct is that we should only hyper optimize where the case is clear. |
Reverts the
importlib_metadata/__init__.pyPrepared.normalize()changes introduced by the merge 8c5d91b — thestr.translate/str.replaceoptimizations from python/cpython#143660 (3e7f3ac) and python/cpython#144083 (001db0d), along with 27169dc's docstring note — restoring the originalre.sub-based implementation:Only
__init__.pyis touched; the behavioral tests added alongside those commits remain and pass against this implementation.Closes #540
🤖 Generated with Claude Code