Skip to content

perf(jvm): stop merging a jvm_artifact's transitive jars into its entry - #23737

Merged
jgranstrom merged 2 commits into
pantsbuild:mainfrom
jgranstrom:jg/scalac-classpath-dedupe
Oct 29, 2026
Merged

jgranstrom merged 2 commits into
pantsbuild:mainfrom
jgranstrom:jg/scalac-classpath-dedupe

Conversation

@jgranstrom

@jgranstrom jgranstrom commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

jvm_artifact classpath entries held their transitive jars in a single digest. So a widely depended-on jar, like scala-library, was included in several classpath entry digests. These duplicates were then materialized to sandboxes, such as for compile. The classpath also included the duplicates. Their paths included the fingerprint of the merged digest, so caches in for example scalac held the duplicate in memory. That also grew by how many artifacts pulled in the same jars.

This separates them. So a jvm_artifact only holds its own jar in its classpath entry digest, and a new field exports is added which refers to the transitive jars by their own classpath entries. Anything that previously merged into the classpath entries now gets exported instead.

export_closure, closure, args and immutable_inputs(_args) include each export right after its exporter, to retain the same jar order. args and immutable_inputs(_args) take transitive=True to cover the closure in one walk. Equality uses the same trick as CoarsenedTarget and BuildGoPackageRequest to only visit each pair once, as it now represents this transitive graph instead of pre-merging it.

On a relatively large scala codebase, 57% of the compile classpath was duplicates. This removes all of them. The scalac nailgun JVMs held about 60% less heap after compiling, and across repeated compiles their jar index grew about 40% less. Immutable inputs dropped from 1.9GB to 0.6GB of disk. Cold compiles were slightly faster, and cached compiles are unchanged.

A side-effect fix is that deploy_jar no longer gets duplicates of the same jars via this mechanism, so its duplicate_policy outcomes behave more as expected. Before, a jar could conflict with itself just because two artifacts depended on it. That is covered in release notes.

Plugins reading a classpath entry digest or filenames directly no longer see exported jars, also covered in the release notes.

Disclosure: Claude used extensively to test, verify, benchmark and iterate on implementation.

A `jvm_artifact`'s classpath entry was one digest merging its own jar
with all of its transitive jars, so a jar that many artifacts depend
on, such as scala-library, was on a compile's classpath once per
artifact that pulled it in, each copy under a different path. Every
copy is materialized on disk, and scalac indexes each path separately
and keeps that index for the life of a nailgun JVM.

This keeps those jars apart: an artifact's entry holds only its own
jar and lists its transitive jars' entries, which were already fetched
one per jar, in a new `exports` field. Targets without sources,
`resources` and Java sources with inferred exports likewise export the
entries they used to merge.
`with_exports`, `closure`, `args` and `immutable_inputs(_args)` include
each export right after its exporter and list each path once; `args`
and `immutable_inputs(_args)` take `transitive=True` to cover the
closure in one walk. Because entries are now shared by many classpaths,
equality visits each pair of shared entries once, like
`CoarsenedTarget`'s, and the hash is cached.

On a large Scala codebase, 57% of compile classpath paths were
duplicates; now none are. The scalac nailgun JVMs held about 60% less
heap after compiling it, and across repeated compiles their jar index
grew about 40% less. Immutable inputs took 0.6 GB of disk instead of
1.9 GB. Cold compiles were slightly faster, and warm runs, compile
results and test results are unchanged. `deploy_jar` no longer receives
a jar twice, which changes some `duplicate_policy` outcomes; the release
notes cover them.
@jgranstrom
jgranstrom marked this pull request as ready for review October 26, 2026 12:32
@jgranstrom
jgranstrom requested review from benjyw and tdyas October 26, 2026 13:09

@tdyas tdyas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Very nice! I just had comments about the naming of ClasspathEntry.with_exports and ClasspathEntry.closure.

Comment thread src/python/pants/jvm/compile.py Outdated
@classmethod
def closure(cls, roots: Iterable[ClasspathEntry]) -> Iterator[ClasspathEntry]:
"""All ClasspathEntries reachable from the given roots."""
def with_exports(cls, entries: Iterable[ClasspathEntry]) -> Iterator[ClasspathEntry]:

@tdyas tdyas Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The with prefix make me think that this function would have modified an attribute on an existing class, not that it yields an iterator. Maybe associate the name with iterators? Maybe iter_with_exports or iterate_with_exports?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, renamed based on some prior naming conventions I saw with <variant>_closure


visited = set()
@classmethod
def closure(cls, roots: Iterable[ClasspathEntry]) -> Iterator[ClasspathEntry]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also,. the closure name does not seem to fit with with_exports. Maybe consider giving them a common naming convention?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, renamed in 54dc46a

@jgranstrom

Copy link
Copy Markdown
Contributor Author

Very nice! I just had comments about the naming of ClasspathEntry.with_exports and ClasspathEntry.closure.

Yeah very fair point. 54dc46a renames the with_export one to export_closure instead. That gives symmetry with closure and export_closure, matching other similar apis like CoarsenedTargets closure/coarsened_closure.

@jgranstrom
jgranstrom requested a review from tdyas October 27, 2026 10:31
@jgranstrom
jgranstrom merged commit 9f52888 into pantsbuild:main Oct 29, 2026
28 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.

2 participants