Skip to content

[Native Image] Ristretto: type profile probabilities can sum above 1.0 (racy denominator in MethodProfile.TypeProfile.toTypeProfile) #14293

Description

@sgammon

Describe the Issue

MethodProfile.TypeProfile.toTypeProfile() can hand the compiler a JavaTypeProfile whose per-type
probabilities sum to slightly more than 1.0. JVMCI rejects that in AbstractJavaProfile's constructor
(jdk/vm/ci/meta/AbstractJavaProfile.java:51,
assert totalProbablility() >= 0 && totalProbablility() <= 1.0001), and the rejection reaches the user as

jdk.graal.compiler.java.BytecodeParser$BytecodeParserError: java.lang.AssertionError at parsing <method>

The compilation is aborted and the method stays interpreted. Enough of these in a row trip the
Systemic Graal compilation failure detected warning, which is how I noticed it.

This is a lost-update race, not floating-point accumulation. File and line references below are
against master at c6d3d9795c9, file
substratevm/src/com.oracle.svm.interpreter.metadata/src/com/oracle/svm/interpreter/metadata/profile/MethodProfile.java.

Mechanism

incrementTypeProfile (MethodProfile.java:730-752) writes two plain, non-atomic fields, in this order:

  • counts[i]++MethodProfile.java:745 — only on the path where the observed type matched slot i
  • counter++MethodProfile.java:751 — on every call ("Always update the total count, even if
    recording the type failed")

Slot claiming is done with a CAS, but neither counter update is atomic.

toTypeProfile() (MethodProfile.java:754-780) then computes, per type:

double p = counts[i];
p = p / counter;          // MethodProfile.java:766-767
totalProbability += p;

Two things go wrong:

  1. The denominator is re-read from the live field on every iteration. counter is still being
    incremented by profiling threads while the loop runs, so the probabilities that end up in one
    JavaTypeProfile are not even normalized against a common total.

  2. counter is the more contended of the two fields, so it loses proportionally more updates.
    Every profiled execution of the bci writes counter; a given counts[i] is written only by the
    executions that observed type i. On a polymorphic site under n threads, counter takes n-way
    contention while each counts[i] takes only its own share, so lost updates are biased against the
    denominator. Σ counts[i] > counter is therefore reachable, and then Σ p > 1.

Nothing downstream clamps it: notRecordedTypeProbability is floored at 0
(MethodProfile.java:775) but the over-unity ptypes array is passed to the JavaTypeProfile
constructor as-is.

getProbability (MethodProfile.java:713) divides by the same live counter and can likewise return
a probability above 1.

The observation

The recorded probability sum in the failure I hit was 1.0007639419404126. That is bit-exactly
1310.0 / 1309.0 — the only ratio of small integers in that neighbourhood that reproduces the value.
So the counts summed to 1310 while the denominator read 1309: a single lost increment on counter,
in a profile that had just matured past JITProfileMatureInvocationThreshold (default 1000,
substratevm/src/com.oracle.svm.interpreter.metadata/src/com/oracle/svm/interpreter/metadata/profile/InterpreterProfilingOptions.java:34).
One lost update, not an accumulation of rounding error.

Using the latest version of GraalVM can resolve many issues.

  • Yes. The failure was observed on Oracle GraalVM 25.2.4+7.1; the code above is current master
    (c6d3d9795c9) and is unchanged in the relevant respect.

GraalVM Version

Observed on Oracle GraalVM 25.2.4+7.1 (build 25.0.4+7-LTS-jvmci-25.2-b20).
Code inspected on oracle/graal master c6d3d9795c9.

Operating System and Version

linux-amd64 (Linux 6.6 kernel, WSL2).

Troubleshooting Confirmation

  • Yes. The failure is stderr-only and does not affect program output or exit code, so the
    user-visible troubleshooting steps do not apply; the diagnosis is from the profile code itself.

Run Command

Native image built with -H:+RuntimeClassLoading -H:+GraalJITCompileAtRuntime, running a
multi-threaded workload (JDK HttpServer plus HttpClient in one process) long enough for the
affected call sites to mature past the profiling threshold.

Expected Behavior

toTypeProfile() always produces a JavaTypeProfile whose per-type probabilities sum to at most 1.0,
regardless of concurrent profiling activity, so JVMCI's invariant holds and the compilation proceeds.

Actual Behavior

The sum can exceed 1.0. With assertions enabled the AbstractJavaProfile check fires, BytecodeParser
wraps it as BytecodeParserError, the compilation is discarded and the method stays interpreted.

In a product build the assertion is not active, so the malformed profile is not rejected at all — the
compiler consumes type probabilities that sum above 1. That is the more concerning half of this, since
it is silent.

Steps to Reproduce

I want to be straightforward about this: I do not have a reliable runnable reproducer, and I do not
think a short one exists.
The window is a lost update on a non-atomic long field, and the profile
has to be read out during the race.

  • The workload that produced the observation above was a long-running soak test on a pre-March-2026
    build. On current builds (25.2.4+, which include the branch-profile fix referenced below) that same
    workload no longer produces the failure, across runs spanning four orders of magnitude in size, so I
    cannot offer it as a reproducer for the type-profile half.
  • The defect is reproducible analytically from the code: construct any interleaving where a
    counter++ at :751 is lost (read-modify-write overlap between two threads) while both threads'
    counts[i]++ at :745 survive, or where two threads incrementing different slots each lose a
    counter update. The invariant Σ counts[i] <= counter, which toTypeProfile() depends on, is not
    enforced anywhere, and once it is violated by one the profile is permanently over unity for as long
    as the deficit persists.
  • A targeted test would be a unit test on TypeProfile that hammers incrementTypeProfile from
    several threads and asserts Σ counts <= counter (or asserts on toTypeProfile()'s sum directly)
    — it should fail today.

Additional Context

The BranchProfile sibling in the same file had the same defect class and was fixed in March:
GR-73833, PR #13127, commit c4c30930f84 (with 2ca0eb47317 doing the structural part: two
independent counters so the ratio is <= 1 by construction). TypeProfile was not touched by that
change and still has the original shape.

The comment at MethodProfile.java:762 says:

// taken from HotSpotMethodData.java#createTypeProfile - sync any bug fixes there

That is worth acting on here, because HotSpot's version does not have this problem for a structural
reason rather than a lucky one. HotSpotMethodData.getRawTypeProfile
(jdk/vm/ci/hotspot/HotSpotMethodData.java:459-492) reads the MDO row once into an immutable
RawItemProfile record (:446) and accumulates totalCount from exactly the counts it just read
(:479, :485) plus the row's counter value (:491). createTypeProfile (:495-513) then divides
by that single snapshot totalCount, so Σ counts <= totalCount holds by construction even though
the underlying MDO is being mutated concurrently. The port kept the arithmetic but not that property:
it reads the counts and the denominator from live fields, and re-reads the denominator per iteration.

Suggested fix shape, in the same spirit:

  1. Snapshot counts into a local array and counter into a local, once, before the loop.
  2. Derive a single denominator, e.g. long total = Math.max(localCounter, sum(localCounts)).
  3. Divide every entry by that one stable total.

That makes Σ p <= 1 hold by construction, costs one small allocation on a path that already
allocates the ProfiledType[], and keeps the ratios meaningful (an under-read counter just gets
corrected upward by the counts that were actually observed). getProbability at :713 should use the
same derived total.

Downstream tracking issue for the original observation: https://github.com/elide-dev/bali/issues/78

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions