Skip to content

version: packed integer comparison key — 2.1–5.2x faster warm resolutions - #33

Merged
jonyoder merged 7 commits into
mainfrom
spike/packed-version-key
Aug 14, 2026
Merged

version: packed integer comparison key — 2.1–5.2x faster warm resolutions#33
jonyoder merged 7 commits into
mainfrom
spike/packed-version-key

Conversation

@jonyoder

Copy link
Copy Markdown
Collaborator

Adds a packed integer comparison key to version.Version: a fixed-size 4× uint64 encoding of the PEP 440 comparison key, computed once at Parse time. When both versions carry one — 97.26% of the 7,666,849 version occurrences in the production PyPI index — Compare is a handful of integer comparisons with zero allocations instead of the allocating, interface-dispatched walk through part.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:

  1. The packed key (version/packed.go), wired into Parse and Compare, plus a fallback-path fix that makes Compare read-only on both receivers: padding now copies (padParts) instead of appending into the spare capacity Parts.Normalize leaves behind, which was a data race when two goroutines compared by-value copies of one Version. A parsed Version is now safe to share across goroutines; TestCompareSharedVersionCopiesRace fails under -race with the old padding and passes with the new (verified both ways).
  2. Removal of the String() == String() equality fast path in Compare. 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.
  3. Frozen conformance fixtures: Compare is 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 for a and b fails the test with pinpointed pairs.

Why packing, and not just the cheap fixes

Profiling go-pyresolver showed version.Compare at 81–85% of warm resolve time. Three remedies were measured independently against BenchmarkResolveWarm (production snapshot, base go-pyresolver@baa7af1, M4 Max, 10x, avg of 2 runs):

entry base ms packed A1: drop reflect.DeepEqual in go-version A2: drop String() fast path A3: A1+A2
single-no-deps 1.750 0.368 (4.8x) 1.57x 1.51x 3.11x
small-tree 4.492 2.134 (2.1x) 1.39x 1.21x 1.88x
extras 10.19 3.55 (2.9x) 1.56x 1.25x 2.23x
app-set 69.73 14.54 (4.8x) 1.92x 1.29x 3.03x
wide-versions 81.75 15.85 (5.2x) 2.05x 1.29x 3.45x
backtracking 6.55 1.74 (3.8x) 1.62x 1.24x 2.44x
unsatisfiable 0.762 0.370 (2.1x) 1.40x 1.18x 1.70x

Packed beats A3 everywhere, and packed+A1 measured identical to packed alone — packable pairs never reach Parts.Compare, so the reflect.DeepEqual removal buys nothing once packing is in. A1 therefore stays out of this PR: it lives in rstudio/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 per Compare.

Correctness evidence

  • Differential against pypa/packaging 26.2 (the behavioural reference): the 204,288-version grid and all 634,150 unique production-index versions, adjacent-pair plus ~4M sampled-pair checks each — zero mismatches, zero parse divergences, and both implementations reject the identical 37 malformed corpus strings.
  • TestPackedAgreesWithSlowPath holds the packed and general paths together across the grid; TestPackedLimits pins every packability boundary on both sides.
  • Full conformance suite (PEP 440/508 tables) green, go test ./... and go test -race ./...; golangci-lint v2.11.2 clean.
  • Canonicalization holds: 1.0, 1.0.0, 1.0.0.0, 1.00 compare 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's uv-pep440 version.rs only 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's VersionSmall: 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 because devYYYYMMDD nightlies 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

Version grows 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

jonyoder and others added 4 commits August 14, 2026 07:55
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>
Comment thread version/packed_test.go
// 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) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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).

Comment thread version/packed_test.go Outdated
// 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Comment thread CHANGELOG.md Outdated

## [Unreleased]

### Changed

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@jonyoder

Copy link
Copy Markdown
Collaborator Author

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>
@jonyoder

Copy link
Copy Markdown
Collaborator Author

Second review pass (code-review, high effort) findings addressed in 8c7c544:

  • Agreement test was 99% tautological (only 154 of 1548 sampled versions were packable, and only packable x packable pairs discriminate the encodings) — now samples 1200 packable + 400 unpackable from a seeded shuffle.
  • The deterministic stride collapsed the locals grid dimension (gcd(132,8)=4 sampled 2 of 8 local labels) — both tests now shuffle-sample.
  • w3 shifts are now derived from field widths with compile-time guards pinning the layout to exactly 64 bits, packedMaxSegments to the unrolled 6, and preClassInf inside its field — widening a field now stops the build instead of silently collapsing pre-release classes.
  • packVersion refuses an empty release (was: packed it as version 0's key, latent only because Parse is the sole constructor).
  • Dead 'e != 0' epoch clause replaced with a direct sign check.
  • Grid + fixture extended with '0!' and 7-8-segment trailing-zero releases; regenerated at 338,688 versions ranked by packaging 26.2, 0 rejected.
  • SortedVersions.Less now compares through an internal pointer-core instead of copying four ~392-byte structs per comparison; public API unchanged.
  • CHANGELOG claim scoped: unpackable ordering unchanged, but equal local-labeled pairs are somewhat slower without the String fast path.
  • NOTICE records the ranked fixtures' pypa/packaging provenance, matching the tags/testdata precedent.

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>
@jonyoder
jonyoder merged commit be7a0bf into main Aug 14, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant