Memoize parsed versions in index.RSFIndex, not just version keys - #45
Merged
Conversation
jonyoder
force-pushed
the
perf/parsed-version-memo
branch
5 times, most recently
from
August 14, 2026 19:36
9c159ee to
2fd79c8
Compare
RSFIndex.Versions memoized the sorted order of a package's version keys and re-parsed every one of them on every call. It held strings because it had to: until go-python-packaging v0.6.0 a version.Version could not be shared between goroutines, since Version.Compare padded the shorter operand's release segment with append into spare capacity a by-value copy shares. v0.6.0 fixed that -- packable versions never touch part.Parts, and the fallback pads by copying -- and this takes the memo the fix unblocks. Warm resolution is 1.13x to 3.23x faster and allocates 41% to 94% fewer objects against the production snapshot, measured at base 11da678 over nine interleaved rounds. Cold does not improve, as expected: the first call already parsed the keys in order to sort them. candvers, metadata and the pin set are identical on every corpus entry, and 4,007 resolutions produce byte-identical transcripts with zero one-sided timeout exclusions.⚠️ These figures are larger than the same change measured against 6c13230 (1.24x and 2.27x). #46 shrank the denominator rather than the parse: parseKeys went from 47.4% of wide-versions' Resolve to 69.1%. Neither figure is quotable against the other's base. The returned slice is a COPY of the memo's, because cmd/pyresolve's `versions` subcommand sorts the result in place. Measured at -0.3% to +9.7% warm against a variant that shares -- higher than the "under 2%" this was scoped with, for the same denominator reason. It is also redundant on this module's own resolution path, since provider hands the result straight to candidate.Rank, which copies again; it protects the exported contract, and an internal accessor that skips it is a follow-up worth taking at that price. The cost is retained heap rather than churn, measured rather than estimated: a warmed index keeps roughly three times as much alive after a resolve (app-set 0.35 -> 0.99 MB, wide-versions 1.31 -> 4.41 MB), identical at both bases. The memo is left unbounded to match the blob cache's rationale, and the code now says plainly that a bound is a prerequisite for embedding this in a long-lived server. index/shared_memo_test.go shares one warmed memo across eight goroutines over fixtures chosen for the padding hazard's actual shape, covering both of v0.6.0's safety arguments and also driving version.ReleaseKey, which #46 made a second reader of a shared parsed version. Every fixture reports WARNING: DATA RACE under a v0.5.0 pin, 20 fresh processes each. That guard runs in CI on the -race step #44 added, which this is rebased onto. Without it these tests are a green no-op: measured on a tree with the defensive copy deleted, the pre-existing concurrency check catches it 8 times in 20 under -race and 0 times in 20 without. Sweeps the rationales that asserted the memo was impossible, in index/rsfindex.go, index/mock.go, provider/provider.go and resolver/bench_test.go. The CHANGELOG diff is pure insertion; no dated section is touched.
jonyoder
force-pushed
the
perf/parsed-version-memo
branch
from
August 14, 2026 19:57
2fd79c8 to
ab741fb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #42. Base:
61029b6(#44). The performance figures below were measured at11da678, the commit immediately before it; #44 is test-and-CI only and changes no code on any measured path, so they carry. Everything before that was re-measured from scratch after #46 moved the base — see Measurement provenance.index.RSFIndexmemoized the sorted order of a package's version keys and re-parsed every one of them on everyVersionscall. It held strings because it had to: until go-python-packaging v0.6.0 a parsedversion.Versioncould not be shared between goroutines, becauseVersion.Comparepadded the shorter operand's release segment withappendinto spare capacity a by-value copy shares. v0.6.0 fixed that — packable versions never touchpart.Parts, and the fallback pads by copying — and #41 deliberately left the memo for its own measurement. This is that change.Roughly fifteen lines in
RSFIndex.Versions. The rest is measurement, concurrency coverage, and the sweep of the internal rationales that asserted the memo was impossible.What it buys
Profiled at the base commit as a share of
resolver.Resolve's own cumulative cost,parseKeyswas 22.7% of a warmapp-setresolution and 69.1% of a warmwide-versionsone, and 58.4% and 94.8% of the objects those resolutions allocated.Medians of nine interleaved rounds, 500 iterations, production snapshot (932,861 packages), Apple M4 Max:
single-no-depssmall-treeextrasapp-setwide-versionsbacktrackingunsatisfiable6c13230this was 1.24x onapp-setand 2.27x onwide-versions. #46 removed a render-and-split frompep440set.Contains, which does not make the parse cheaper — it makes the parse a bigger share of what is left.parseKeyswent from 47.4% ofwide-versions'Resolveto 69.1% on exactly that account. The two changes are not additive in either direction, and a figure from one base is not quotable against the other.Amdahl reconciles almost exactly where the parse dominates: removing 69.1% predicts 3.24x,
wide-versionsmeasures 3.23x. Onapp-setit under-predicts — 22.7% predicts 1.29x against 1.37x measured — because the change also removes 58.4% of the objects allocated.Cold does not improve: 0.96x to 1.01x, allocation flat to four significant figures. Expected — the first call per package already parsed its keys in order to sort them, so there is nothing to save, and what is left is the copy this now makes on the way out.
candvers,metadataand the pin set are identical on every entry.The RFD 0001 Phase 3 warm gate (under 1 ms) is now met by 4 of 7 against 3 of 7 at the base —
extrascrosses at 0.836 ms.unsatisfiableis not measurably faster stands and is not superseded. That note is aboutContainscalls, which that entry makes few of. Its 1.47x here is a different mechanism — it parses version keys like everything else, and that is what this removes.Parsed versions of every package ever asked about now live for the life of the index, where they used to be transient garbage. Measured by a new
TestIndexRetainedHeapAfterResolve(GPR_RETAIN=1) as the live heap one warmedRSFIndexkeeps alive after a resolution completes — medians of five interleaved rounds, agreeing to the centibyte, and identical at both bases (it is a live-heap measurement, so it does not move with the base or with load):single-no-depssmall-treeextrasapp-setwide-versionsbacktrackingunsatisfiableRoughly triple, on closures of seven to eighteen packages. The memo is left unbounded, following the
decodedblob cache's rationale ("a long-lived server process resolving arbitrary requests would want a bound; that is not this").Saying the other half plainly, because that rationale has already been true of the CLI and quietly wrong for a server once before: this memo multiplies the per-package retention, and a bound over the index's caches is a prerequisite for embedding
RSFIndexin a long-lived server process. "Bounded by the corpus" is a property of the key set, not of the memory, and the corpus is 932,861 packages. That is written beside the memo inrsfindex.go, along with the asymmetry a reviewer surfaced:versionPlanForbuilds the same plan on theMetadatapath, so a consumer that only callsMetadatapays the full retention increase and gets none of the speedup.Equivalence
Re-run at this base. 4,007 resolutions against the production snapshot — the seven corpus entries plus 4,000 sampled package names, seed 1, 8-second per-case deadline — produce byte-identical transcripts: identical pins, identical decision ORDER, identical activated extras, identical failure report text.
Concurrency
index/shared_memo_test.gowarms the memo and has eight goroutines share it, over four fixtures covering both of v0.6.0's independent safety arguments — the packed integer key, and thepadPartsfallback that a local label, a non-zero epoch or a long release segment forces. Which path each fixture takes is asserted by allocation count. Each fixture reportsWARNING: DATA RACEunder a v0.5.0 pin, 20 fresh processes each, 80 for 80.It also drives
version.ReleaseKey, which #46 made a second reader of a shared parsed version on the hot path (pep440set.verPos.init). That is safe — gpp clips the release slice it returns for exactly this hazard — but it was safe by argument only until this test drove it.✅ The guard runs in CI. This was raised while #44 was still open: gpr CI ran plain
go test ./..., under which these tests are a green no-op, so merging ahead of #44 would have shipped a guard that could not fire. #44 has now merged and this is rebased on top of it (61029b6), so theRacestep covers this file. Measured, on a tree with the defensive copy deleted: the pre-existing concurrency test catches it 8 in 20 under-raceand 0 in 20 without it — which is why the step, not the test, is the load-bearing part.#44's new
TestResolutionTranscriptMatchesGoldengate also passes unchanged on this branch (transcript[fast]: 125 cases … 45 discriminating), which is a second, CI-enforced equivalence check on top of the 4,007-case run below.First pass (three findings, all fixed):
TestMemoIsSafeUnderConcurrentUsereports a race in 8 of 20 fresh processes. My original claim came from a single process — which is exactly the race-detector stack-deduplication trap I documented in the same file for other people. Reproduced independently and by me. Corrected inshared_memo_test.go,mock.go, the CHANGELOG and here, and reconciled with the "lottery" framing: 8-in-20 is not a second guard, it is a flake generator pointed at something real.pep440setnow callsversion.ReleaseKey(gpp v0.7.0), so pinning back gives four compile errors, not race reports. The file now says where the verification was run (6c13230), and that repeating it also requires revertingpep440set— rather than leaving executable-looking instructions that cannot execute.provider.gonamedreleaseKeyas "the next real win"; perf(pep440set): take the release group key from gpp, not from a render #46 deleted that symbol and shipped that win. Replaced with a re-profiled figure against the tree that actually exists (rankedVersions27.5% ofResolveon warmwide-versions,candidate.Rank17.4% inside it) and a warning to re-profile before acting on it.Also corrected: the allocation discriminator's stated cause. A general-path pair whose release lengths already match still allocates 17 per comparison, so the allocations are
key.compareboxing into interfaces, notpadPartscopying. The guard is sound; the explanation was not.Second pass, after the rebase onto #44 (no correctness defect in the code path; everything below is prose or coverage, and all of it is fixed):
-benchmemfigures inpeak_heap_test.go's doc were an unlabelled survivor from the old base (24%/61% against the CHANGELOG's 55.4%/94.4%). Corrected, with the base named.ReleaseKeyassertions — which cannot be covered by it, sinceversion.ReleaseKeydoes not exist before gpp v0.7.0. The scope is now stated exactly: the comparison half was demonstrated to detect; theReleaseKeyhalf rests on gpp's three-index slicing plus being driven under-race. The stated cost of repeating the run was also understated — this file's ownReleaseKeycalls have to come out too, not justpep440set's.index/shared_version_test.go— Make the correctness evidence reproducible in CI, and pin the shareability guarantee #44's own tests for the same hazard one level down. That is the same omission class this PR is correcting. Fixed in both places, along with a contradiction betweenmock.go("pep440set passes") andshared_memo_test.go("pep440set does not compile"): both are true, of different trees, and each now says which.t.Fatalfprecondition that aborts before the concurrent phase. OnlyTestVersionsMemoIsNotAliasedByTheCallerobserves the defect through the exported API. Said so.order/versionsparallelism was enforced by a comment and by two appends sitting next to each other. Now enforced byTestVersionPlanHalvesStayParallel, element-wise across every fixture package. Mutation-tested: reversingplan.versionsrelative toplan.order(same length, wrong pairing — which a length check passes) is CAUGHT, and nilling it is CAUGHT.TestIndexRetainedHeapAfterResolvetookexcerptfrombenchSnapshotTand ignored it, so running it withoutPYPIRSF_TEST_FILEsilently measured the 139-package excerpt and produced numbers unlike the table. It now logs which corpus it measured, first line, with the package count.shared_version_test.gois real — same four fixture triples, same three shape assertions — and is now acknowledged in the file, along with what each covers that the other does not and the drift risk of keeping two copies in one package.The returned slice is a copy
cmd/pyresolve'sversionssubcommand sorts the result ofVersionsin place, so handing back the memo's own slice would be a silent breaking change.wide-versions(+9.7%) andbacktracking(+8.1%). Against6c13230the same copy measured −0.1% to +4.0%, and the brief for this work said "under 2%, effectively free". The copy costs the same microseconds; warm resolution got faster, so those microseconds are now a larger fraction. That cost is inside every figure in the table above.providerhands the result straight tocandidate.Rank, which copies unconditionally, so a resolution copies twice. The copy is there for the exported contract.provideris not the only library-side caller —FilteredIndex.VersionsandMultiIndex.Versionsalso call it, andFilteredIndexreturns the inner index's slice by reference when its policy filters neither pre-releases nor files. Routing a no-copy accessor through that fast path would hand the memo's own slice to an arbitrary caller: exactly the silent breaking change the copy prevents. Nothing is broken today —RSFIndex.Versionsreturns a fresh copy thatFilteredIndexthen passes through — but the follow-up has a prerequisite, and the code now names it.TestVersionsMemoIsNotAliasedByTheCallerwas written for this day and could not fail until now;TestVersionsNeverReturnsTheMemosSliceis new and covers the first-call path, which that one cannot reach.index/. Nothing outside notices —cmd/pyresolve's own tests included, andcmd/pyresolveis the caller the copy exists for.Sweep
Issue #42's second half: the rationales asserting a parsed
version.Versioncannot be shared are corrected inindex/rsfindex.go(the memo bullet,cloneMetadata,findEqualKey),index/mock.go,provider/provider.goandresolver/bench_test.go. Dated CHANGELOG sections are untouched — the diff onCHANGELOG.mdis pure insertion.Two findings from the sweep:
cloneMetadata'sVersionsubstitution is not removable, which Take the parsed-version memo the v0.6.0 race fix unblocks, and sweep the rationales it invalidates #42 asked about. It had a second reason all along:buildMetadatanever setsVersion, so the memo holds a zero value and the caller's own version is the only thing on offer.index/mock.gocreditedresolver/concurrency_test.gowith finding the original race. It found it in a variant where the mock stored parsed values; it does not guard the hazard on shipped code, and under a v0.5.0 pin it passes.Follow-ups, deliberately not here
Provider's ranked-list memo onto the index — the same v0.6.0 fix unblocks it. Ceiling iscandidate.Rank, 17.4% of warmwide-versions'Resolveafter this change, and only across resolutions. Kept out because a second effect would confound this one's measurement.Versions, now that the copy measures up to 9.7% and the only library caller copies again immediately.findEqualKeycould probeplan.versionsinstead of re-parsingplan.order. Only log2(n) probes, but it would also close theMetadata-only retention asymmetry above.Measurement provenance
Every figure comes from a session run after two invalidations, and I am stating both because a number's provenance is part of the number:
6c13230to11da678. The full A/B was re-run again rather than carried forward, which is what surfaced the larger speedups and the higher copy cost.The published session is nine interleaved rounds, base and candidate alternating within each round, with the 1-minute load average recorded next to every round. This host runs several agents; load ranged from 16 to 112 across the session, and an intermediate five-round attempt was discarded outright because per-round spreads reached 213% and a cold column cannot be read through that. Nine rounds and a median are what stand behind these figures, not a quiet machine — the per-round series is in the session log.
🤖 Generated with Claude Code