version: packed integer comparison key — 2.1–5.2x faster warm resolutions - #33
Conversation
Computes a fixed-size 4x uint64 encoding of the PEP 440 comparison key at Parse time and compares packable versions with integer comparisons instead of the allocating, interface-dispatched path through part.Parts. Packable = epoch 0, no local label, <=6 release segments (trailing zeros stripped) each < 2^32, preN < 2^20, postN < 2^14, devN < 2^25 (wide enough for YYYYMMDD nightly datestamps). Against the production PyPI index (7,666,849 version occurrences across 932,861 packages) 97.26% of versions pack; the index contains zero local labels. Also replaces Parts.Padding in the fallback path with padParts, which never appends into a shared backing array, fixing the data race that made by-value copies of a Version unsafe to compare from two goroutines (rstudio/go-version#5 territory) -- a parsed Version is now safely shareable across goroutines. Verified against pypa/packaging 26.2: 204,288-version generated grid and 634,150 unique production versions, adjacent-pair plus ~4M sampled-pair differential each, zero mismatches, zero parse divergences, and both implementations reject the identical 37 malformed corpus strings. Microbenchmark: ~15ns/0 allocs vs 2.3-2.9us/25-28 allocs per Compare. End to end (go-pyresolver resolver benchmark, production snapshot): 2.1-5.2x faster warm resolutions, 3.1-11.8x cold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fast path rendered BOTH versions to fresh strings on every call -- two bytes.Buffer round trips through fmt -- to answer only the equality case. With the packed key in place it can only serve pairs where at least one side is unpackable, and for those the key comparison returns 0 for exactly the same inputs without the allocations. Measured alone against go-pyresolver's resolver benchmark (production snapshot, 10x, M4 Max): 1.18-1.51x warm across the seven entries. Measured together with the packed key it is subsumed; kept as its own commit so the number stays attributable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The packed key is a second encoding of PEP 440 ordering truth alongside the general cmpkey path. TestPackedAgreesWithSlowPath holds the two encodings together; this adds the check that holds either of them to the reference implementation, without needing Python in CI: - a 204,288-version generated grid crossing every packed-field limit (set-equality-checked against the generator, so extending the grid without regenerating the fixture fails loudly), and - 10,064 unique versions sampled from the production PyPI index. Both ranked by pypa/packaging 26.2; every adjacent pair in rank order plus ~2.7M sampled pairs must agree with Compare exactly. Negative control: swapping the packed pre-release classes for a and b makes the test fail with pinpointed pairs. Also the CHANGELOG entries for the packed key, the fast-path removal, and the shared-Version data-race fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The set-equality check compared counts and membership in one direction, so a fixture with duplicate lines could mask missing boundary entries while passing. Assert both directions explicitly and reject duplicates. Found by RoboRev on the fixture commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // array. Compare now pads into fresh slices (padParts), making a Version | ||
| // safely shareable. Run under -race; the unpackable locals force the general | ||
| // path where the race lived. | ||
| func TestCompareSharedVersionCopiesRace(t *testing.T) { |
There was a problem hiding this comment.
🟡 Medium
This regression test only fails under -race (the CHANGELOG's new "safe to share across goroutines" guarantee rests on it), but CI runs plain go test ./... (.github/workflows/ci.yml line 24) — so a later revert of padParts back to Parts.Padding would sail through CI green.
Consider adding a go test -race ./... step (or a scoped -race -run TestCompareSharedVersionCopiesRace if the full run is too slow; the three packed tests took ~49s under -race locally).
| // paths. The packed path only claims pairs where both sides are packable; for | ||
| // those it must agree exactly with the general path. | ||
| // | ||
| // The full grid is 153,216 versions; all pairs would be 2.3e10 comparisons. |
There was a problem hiding this comment.
⚪ Low
Stale counts from the 6-locals grid: gridStrings() now generates 204,288 strings (verified against the fixture line count), so all pairs would be ~4.2e10, not 2.3e10.
| // The full grid is 153,216 versions; all pairs would be 2.3e10 comparisons. | |
| // The full grid is 204,288 versions; all pairs would be 4.2e10 comparisons. |
|
|
||
| ## [Unreleased] | ||
|
|
||
| ### Changed |
There was a problem hiding this comment.
⚪ Low
CLAUDE.md's changelog convention enumerates the sections as Breaking, Added, Fixed, Notes — ### Changed is new to this file. Intentional? If a Changed section is the right home for behavior-preserving performance notes (it does read naturally here), consider adding it to the CLAUDE.md list so the next entry doesn't have to guess; otherwise Notes is the documented fit.
Review findings from the unbriefed seat on #33: - TestCompareSharedVersionCopiesRace only fails under -race, and CI ran plain go test only, so a revert of padParts back to the in-place Parts.Padding would have passed CI green. Add a -race step. - The sampled-pairs comment still described the pre-fixture 153,216- version grid; it is 204,288 since the locals dimension grew to match the frozen fixture. - CHANGELOG: 'Changed' is not one of this repo's enumerated sections; the perf entries belong under 'Notes'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All three review findings addressed in 537c012: CI now runs 'go test -race ./...' as its own step (the shareability guarantee is otherwise unguarded — good catch, that was the only thing standing behind the CHANGELOG claim), the stale 153,216-version grid comment now reads 204,288/~4.2e10, and the CHANGELOG entries moved from 'Changed' to the repo's enumerated 'Notes' section. |
Code review findings on #33, in decreasing order of consequence: - The agreement test was 99% tautological: only packable x packable pairs discriminate the two encodings, and the strided sample held 154 packable of 1548. The sample is now drawn packable-first (1200 packable + 400 unpackable of a seeded shuffle), which is both stricter and cheaper. - The stride also silently collapsed the locals grid dimension -- gcd(132, 8) = 4 sampled only 2 of 8 local labels, excluding exactly the numeric-vs-alphanumeric cases added for the #19369 bug class. Both tests now sample by seeded shuffle, which has no phase to lock onto. - The w3 shifts were six hand-summed expressions; widening any field would silently shift preClass off the word (4<<62 == 0), collapsing 'no pre-release' into the dev-only class. Shifts are now derived from the widths and compile-time guards pin the total to exactly 64 bits, packedMaxSegments to the unrolled 6, and preClassInf inside its field. - packVersion packed an empty release as version 0's key; only the zero-value struct literal kept that latent. It now refuses, so the invariant no longer depends on Parse being the only constructor. - The epoch guard's e != 0 clause was unreachable (smallUint with limit 0 already implies it) and disguised which check rejects nonzero epochs; replaced with a direct sign check. - The grid and fixture gained '0!' (a written zero epoch must pack) and 7-8-segment releases whose trailing zeros strip back into range; both packability decisions were previously asserted only as booleans, never as orderings. Fixture regenerated: 338,688 versions ranked by pypa/packaging 26.2, 0 rejected. - SortedVersions.Less copied four ~392-byte structs per comparison to read 33 bytes of packed key; Compare's implementation moved to an internal pointer-core (compareVersions) that Less calls directly. Public API unchanged. - CHANGELOG no longer claims unpackable comparisons are 'exactly as before': ordering is unchanged, but the equal-pair case of local- labeled versions is somewhat slower without the String fast path. - NOTICE records the provenance of the ranked fixtures, matching the precedent set for tags/testdata golden fixtures. Deferred, noted for the record: cmpkey is now dead weight at Parse time for the ~97% of versions that pack, but making it lazy interacts with the zero-value guard ordering in compareVersions and belongs in its own change with its own measurements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Second review pass (code-review, high effort) findings addressed in 8c7c544:
Deferred with rationale: lazy cmpkey (dead work at Parse for packable versions — real, but it interacts with the zero-value guard ordering and deserves its own change and measurements); trimmedRelease's stale caches (pre-existing, String-only use); cmp.Compare dedup and fixture memory retention (test-only, below the bar). End-to-end numbers re-verified after the refactor: app-set 14.1 ms, wide-versions 15.2/15.3 ms warm — unchanged from before the hardening pass. |
The numbers that justify choosing packing over the cheap fixes belong where a consumer will look for them, not only in the PR discussion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a packed integer comparison key to
version.Version: a fixed-size 4× uint64 encoding of the PEP 440 comparison key, computed once atParsetime. When both versions carry one — 97.26% of the 7,666,849 version occurrences in the production PyPI index —Compareis a handful of integer comparisons with zero allocations instead of the allocating, interface-dispatched walk throughpart.Parts. Unpackable versions (nonzero epoch, local label, >6 release segments after trailing-zero strip, a segment ≥ 2^32, or oversized pre/post/dev numbers) fall back to the general path unchanged.Three commits, deliberately separate:
version/packed.go), wired intoParseandCompare, plus a fallback-path fix that makesCompareread-only on both receivers: padding now copies (padParts) instead of appending into the spare capacityParts.Normalizeleaves behind, which was a data race when two goroutines compared by-value copies of oneVersion. A parsedVersionis now safe to share across goroutines;TestCompareSharedVersionCopiesRacefails under-racewith the old padding and passes with the new (verified both ways).String() == String()equality fast path inCompare. With the packed key in place it could only serve pairs with an unpackable side, and the key comparison answers those without rendering two strings. Separate commit so its standalone value (1.18–1.51x warm, measured alone) stays attributable.Compareis pinned in CI against orderings produced by pypa/packaging 26.2 — a 204,288-version generated grid crossing every packed-field limit (set-equality-checked against the in-repo generator) and 10,064 unique versions sampled from the production PyPI index. Adjacent pairs across the whole order plus ~2.7M sampled pairs must agree exactly. This test is the only thing in CI holding either ordering encoding to the reference implementation — do not delete or weaken it. Negative control: swapping the packed pre-release classes foraandbfails the test with pinpointed pairs.Why packing, and not just the cheap fixes
Profiling go-pyresolver showed
version.Compareat 81–85% of warm resolve time. Three remedies were measured independently againstBenchmarkResolveWarm(production snapshot, basego-pyresolver@baa7af1, M4 Max, 10x, avg of 2 runs):reflect.DeepEqualin go-versionString()fast pathPacked beats A3 everywhere, and packed+A1 measured identical to packed alone — packable pairs never reach
Parts.Compare, so thereflect.DeepEqualremoval buys nothing once packing is in. A1 therefore stays out of this PR: it lives inrstudio/go-version(a different repo, with an unmerged related PR #5 already open there) and is noted here as measured-and-deferred at 1.39–2.05x standalone. Cold resolutions improve 3.1–11.8x (wide-versions 240 → 20.4 ms). Warm app-set allocations drop 1.50M → 276k. Resolution outputs are byte-identical across all variants. Microbenchmark: ~15 ns / 0 allocs vs 2.3–2.9 µs / 25–28 allocs perCompare.Correctness evidence
TestPackedAgreesWithSlowPathholds the packed and general paths together across the grid;TestPackedLimitspins every packability boundary on both sides.go test ./...andgo test -race ./...;golangci-lintv2.11.2 clean.1.0,1.0.0,1.0.0.0,1.00compare equal — trailing-zero stripping plus zero-padding to fixed width is order-isomorphic to cmpkey's stripped-tuple comparison, and the equal-rank fixture groups cover it.Provenance note (uv-pep440)
I read
astral-sh/uv'suv-pep440version.rsonly after this layout was designed, implemented, and validated against pypa/packaging — under an explicit one-repo permission granted for this work, uv being Apache-2.0/MIT dual-licensed and already credited in this repository's NOTICE for other packages. The layout here differs materially from uv'sVersionSmall: uv packs into a single u64 (release ≤4 segments with seg0 ≤ u16 and segs 1–3 ≤ u8, at most one of pre/post/dev, pre number < 64, post/dev < 256); this PR uses 4× uint64 with six 32-bit segments and three concurrent suffix fields, sized from a measured corpus distribution (the wide 25-bit dev field exists becausedevYYYYMMDDnightlies are the most common large suffix). uv's exact bounds would fit 86.5% of our production index; this layout fits 97.26%. Nothing — no layout, bit allocation, threshold, or code — was adopted from uv, so no NOTICE entry was added. If review later tightens the layout toward theirs, add the entry then.Size
Versiongrows 352 → 392 bytes. A prior experiment shrinking a consumer-side key struct 416 → 72 bytes moved end-to-end numbers by nothing, so the growth is expected to be equally invisible; the end-to-end numbers above are measured with the growth included.🤖 Generated with Claude Code