From 8a6671356d3fd5074818f116f8d35f63e2823d8e Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 4 Aug 2026 11:56:31 +0200 Subject: [PATCH 1/5] Record that the size annotations are bytecode proxies for a machine-code budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-only. A LogCompilation probe of the jitAudit kernels (Microsoft OpenJDK 25.0.4, x86-64, 8-lane double) contradicts three claims the repo currently makes. The measurements, reading `stub_offset - insts_offset` off each c2 : Object. 1 bytecode -> 208 bytes machine code doublearrays.clamp! 180 -> 1728 (9.6x, 69% of 2500) doublearrays.meanAndVarianceTwoPass 248 -> 1688 (6.8x, 68%) vecxt.all.clamp! (export forwarder) 11 -> 1696 (154x, 68%) ~200 bytes of fixed overhead plus 7-10x the bytecode for vectorised code with a masked tail. At that ratio `InlineSmallCode` (2500, machine code) is reached at roughly 260-300 bytecodes — *below* `FreqInlineSize` (325, bytecode). For a SIMD kernel the machine-code limit binds first. HotPath.java claimed check C2 meant "C2 will inline it into its callers once it is hot". It does not: that is a necessary condition, not a sufficient one, and the doc now says so and points at D2 for the compiled size. Thin.java asserted a 35-bytecode budget as the thing that makes the public API zero-cost at a cold call site. `vecxt.all.clamp!` satisfies it by a factor of three while being 1696 bytes of machine code. Forwarders are where the ratio is most extreme, precisely because the body they forward to gets pulled in — and the `vecxt.all` forwarders are excluded from the baseline by `primaryAnnotated`, so their compiled size is unmeasured twice over. The blog gets a new subsection under the threshold table, "The limit that is not measured in bytecodes", because every threshold in that table is a bytecode count and this one is not. Also records that `FreqInlineSize` and `InlineSmallCode` are `C2 pd product` — platform-dependent — so two of the four budgets the page relies on are properties of the machine rather than of HotSpot. And that C1/C2/C3 read bytecode and therefore cannot see any of this. `doublearrays.variance(mode)` gets the specific consequence written down: its `@AllocFree` zero depends on C2 inlining `meanAndVarianceTwoPass`, which is at 68% of the limit. If that crosses, the pair escapes and the symptom is a D1 failure complaining about allocation rather than about inlining. Two things deliberately not claimed. The ratio is one workload on one CPU at one lane width, so the direction is established and the crossover point approximate. And whether HotSpot's MaxTrivialSize/MaxInlineSize fast paths let a small callee bypass the InlineSmallCode veto is unverified — both docs say so rather than guessing, because it decides whether the forwarder finding is a curiosity or a hazard. Co-Authored-By: Claude Opus 5 --- site/docs/blog/2026-07-28-Inlining.md | 35 +++++++++++++++++-- vecxt/src-jvm/doublearrays.scala | 11 ++++++ .../java/vecxt/annotations/HotPath.java | 21 +++++++++-- .../src-jvm/java/vecxt/annotations/Thin.java | 18 ++++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/site/docs/blog/2026-07-28-Inlining.md b/site/docs/blog/2026-07-28-Inlining.md index e8182935..57726fe5 100644 --- a/site/docs/blog/2026-07-28-Inlining.md +++ b/site/docs/blog/2026-07-28-Inlining.md @@ -65,6 +65,31 @@ Two things this also corrects: `MaxInlineLevel` (15 frames) is the related limit: Panama's Vector API works by inlining a deep chain of `@ForceInline` intrinsics, and when that chain runs out of depth `DoubleVector` stops being register-resident and becomes a heap allocation per operation. The practical effect of bloat is usually inward rather than outward — a method already carrying expanded loop bodies has a large IR graph and no room left to absorb the inlining Panama depends on. +### The limit that is not measured in bytecodes + +Every threshold above is a bytecode count, which is why static analysis can enforce them. There is a fifth one that is not, and for SIMD kernels it is the *tightest* of them. + +`InlineSmallCode` (2500) applies to a callee that has **already been compiled**: if its nmethod's machine code exceeds the limit, C2 will not inline it into a new caller, however hot that caller is. Nothing about bytecode size predicts this, because the ratio between the two is not a constant. + +Measured on a `LogCompilation` run of the `jitAudit` kernels — Microsoft OpenJDK 25.0.4, x86-64, 8-lane double species — reading `stub_offset - insts_offset` off each `c2` `` element: + +| method | bytecodes | machine code | ratio | % of `InlineSmallCode` | +|---|---|---|---|---| +| `Object.` | 1 | 208 | — | 8% | +| `doublearrays.clamp!` | 180 | 1728 | 9.6× | 69% | +| `doublearrays.meanAndVarianceTwoPass` | 248 | 1688 | 6.8× | 68% | +| `vecxt.all.clamp!` (export forwarder) | 11 | 1696 | 154× | 68% | + +Roughly 200 bytes of fixed overhead, plus 7–10× the bytecode for vectorised code with a masked tail. + +**At that ratio `InlineSmallCode` is reached at around 260–300 bytecodes, which is below `FreqInlineSize`.** So for a SIMD kernel the 325-byte budget is not the binding constraint, and a kernel that satisfies it can still be one C2 declines to inline. The `@HotPath` annotation asserts the bytecode budget because that is what bytecode analysis can see; it should be read as necessary rather than sufficient. + +The forwarder row is the one worth staring at. `vecxt.all.clamp!` is an eleven-bytecode `export`, and its compiled form is 1696 bytes because C2 inlined the kernel into it. A budget expressed in bytecodes — which is what `@Thin` asserts — cannot see that at all. + +Two things are deliberately not claimed here. The ratio is one workload on one CPU at one lane width, so treat the direction as established and the crossover point as approximate. And whether HotSpot's `MaxTrivialSize`/`MaxInlineSize` fast paths let a *small* callee bypass the `InlineSmallCode` veto is unverified — if they do not, the forwarder row describes a real hazard rather than a curiosity. + +Enforcing this needs the compiled size, so it belongs to the dynamic tier: check D2 of [#105](https://github.com/Quafadas/vecxt/issues/105), which reads it from `LogCompilation` output. + ## Where `inline` is not negotiable ### Higher-order functions @@ -283,7 +308,9 @@ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:+PrintIntrinsics \ `PrintIntrinsics` is the important one. Losing intrinsification is the failure mode that does not break a single test. -Thresholds quoted here are HotSpot defaults and do not transfer to OpenJ9 or GraalVM native-image. `MaxInlineSize` and `FreqInlineSize` are visible via `java -XX:+PrintFlagsFinal -version | grep -i inline`; `HugeMethodLimit` is a develop flag and will not appear there, though `DontCompileHugeMethods` will. +Thresholds quoted here are HotSpot defaults and do not transfer to OpenJ9 or GraalVM native-image. `MaxInlineSize`, `FreqInlineSize` and `InlineSmallCode` are visible via `java -XX:+PrintFlagsFinal -version | grep -i inline`; `HugeMethodLimit` is a develop flag and will not appear there, though `DontCompileHugeMethods` will. + +That listing also records which of them are portable. `MaxInlineSize` and `MaxInlineLevel` are plain `C2 product` flags, but `FreqInlineSize` and `InlineSmallCode` are `C2 pd product` — platform-dependent, so both can legitimately differ on another architecture. Two of the four budgets this page relies on are properties of the machine, not of HotSpot. ## What is enforced, and what is still convention @@ -297,4 +324,8 @@ The rule above is a convention. Three parts of it are now checked on every PR by The rest — closure identity, compile-time constants — is still convention. C5 checks it and is Phase 3. -One thing the checks cannot see, and it matters for reading this page: an `inline def` body is expanded into its callers rather than emitted, so it has no bytecode of its own. A generic `inline def` is audited only through whatever non-inline callers exist. That is why `@HotPath` and `@Thin` are defined as properties of *emitted* methods, and why putting one on an `inline def` is a build failure rather than a no-op. +Two things the checks cannot see, and both matter for reading this page. + +An `inline def` body is expanded into its callers rather than emitted, so it has no bytecode of its own. A generic `inline def` is audited only through whatever non-inline callers exist. That is why `@HotPath` and `@Thin` are defined as properties of *emitted* methods, and why putting one on an `inline def` is a build failure rather than a no-op. + +And `bytecodeAudit` reads bytecode, so none of C1/C2/C3 can see the `InlineSmallCode` limit described above. The two budgets those checks enforce are proxies for a machine-code constraint that is, for vectorised kernels, tighter than either of them. A passing `@HotPath` therefore means "inside the bytecode budget", not "C2 will inline this" — the stronger reading needs D2. diff --git a/vecxt/src-jvm/doublearrays.scala b/vecxt/src-jvm/doublearrays.scala index 7e29b66a..ed4b2119 100644 --- a/vecxt/src-jvm/doublearrays.scala +++ b/vecxt/src-jvm/doublearrays.scala @@ -522,6 +522,17 @@ object doublearrays: * [[vecxt.MeanAndVariance]] replaced the tuple for the second reason and fixes this one as a side effect: a field * read off a `final class` with primitive fields is an `invokevirtual`, not an unbox. `@AllocFree` records the * measured zero so a future restructuring that lets the result escape is caught rather than discovered. + * + * ==What the `@AllocFree` here is load-bearing on== + * + * The zero depends on C2 inlining `meanAndVarianceTwoPass` into this method, because that is where the + * `MeanAndVariance` is constructed and escape analysis only runs after C2's own inlining. A `LogCompilation` run + * puts that method at 1688 bytes of machine code — 68% of `InlineSmallCode` (2500), the budget above which C2 + * declines to inline an already-compiled callee. Nothing measures that number today; see check D2. + * + * So if `meanAndVarianceTwoPass` grows past the limit, the pair starts escaping, and the symptom is *this* test + * failing with a message about allocation rather than about inlining. If that happens, look at the callee's + * compiled size before looking at anything here. */ @Thin @AllocFree diff --git a/vecxt/src-jvm/java/vecxt/annotations/HotPath.java b/vecxt/src-jvm/java/vecxt/annotations/HotPath.java index d5e9eda1..4bf3c0ef 100644 --- a/vecxt/src-jvm/java/vecxt/annotations/HotPath.java +++ b/vecxt/src-jvm/java/vecxt/annotations/HotPath.java @@ -17,9 +17,24 @@ * *

What the audit asserts about a {@code @HotPath} method (check C2 of * #105): its emitted bytecode fits inside - * HotSpot's {@code FreqInlineSize}, so C2 will inline it into its callers once it is hot. A kernel - * larger than that budget is not inlined however hot it gets, which costs the surrounding loop the - * optimisations that only happen across an inlined boundary. + * HotSpot's {@code FreqInlineSize}. A kernel larger than that budget is not inlined however hot it + * gets, which costs the surrounding loop the optimisations that only happen across an inlined + * boundary. + * + *

That is a necessary condition, not a sufficient one. An earlier version of this note + * claimed the check meant "C2 will inline it into its callers once it is hot". It does not. + * {@code InlineSmallCode} (2500) applies to an already-compiled callee and is measured in + * machine code, not bytecode — and for vectorised kernels the observed expansion is 7–10×, + * so 2500 bytes of machine code is reached at roughly 260–300 bytecodes, below + * {@code FreqInlineSize}'s 325. A kernel can satisfy this annotation and still be one C2 declines + * to inline. + * + *

Bytecode analysis cannot see that, so C2 asserts what it can. The compiled size is check D2's + * business: it reads {@code stub_offset - insts_offset} off {@code LogCompilation}'s {@code c2} + * {@code } elements. Until D2 lands, a kernel in the upper part of the + * {@code FreqInlineSize} range should be treated as unverified rather than safe. See + * {@code site/docs/blog/2026-07-28-Inlining.md}, "The limit that is not measured in bytecodes", + * for the measurements. * *

Only meaningful on a method that is actually emitted. An {@code inline def} body is * expanded into its callers instead of being compiled on its own, so it has no bytecode to measure diff --git a/vecxt/src-jvm/java/vecxt/annotations/Thin.java b/vecxt/src-jvm/java/vecxt/annotations/Thin.java index 74ab2e6f..589ed6fb 100644 --- a/vecxt/src-jvm/java/vecxt/annotations/Thin.java +++ b/vecxt/src-jvm/java/vecxt/annotations/Thin.java @@ -15,6 +15,24 @@ * called. That is the property that makes the public API zero-cost at a cold or lukewarm call * site, where {@code FreqInlineSize} does not apply yet. * + *

The budget is in bytecodes and says nothing about the compiled form. Measured on a + * {@code LogCompilation} run: {@code vecxt.all.clamp!} is an eleven-bytecode {@code export} + * forwarder whose {@code c2} nmethod is 1696 bytes of machine code, because C2 inlined the kernel + * into it — 68% of {@code InlineSmallCode}. It would satisfy this annotation's 35-byte budget by a + * factor of three while being one of the largest compiled methods in the library. + * + *

Two reasons that matters more here than for {@link HotPath}. Forwarders are where the + * bytecode-to-machine-code ratio is most extreme, precisely because the body they forward to gets + * pulled in. And the {@code vecxt.all} export forwarders are excluded from the checked-in baseline + * by {@code Audit.primaryAnnotated} — deliberately, to keep the baseline readable — so their + * compiled size is unmeasured twice over. + * + *

Unverified, and worth confirming in the HotSpot source before relying on either answer: whether + * the {@code MaxTrivialSize}/{@code MaxInlineSize} fast paths let a small callee bypass the + * {@code InlineSmallCode} veto. If they do, the forwarder above is a curiosity. If they do not, a + * small forwarder with a large nmethod stops being inlinable, and this annotation is asserting the + * wrong quantity for exactly the methods it was written for. + * *

It also asserts the method contains no backward branch. A loop in a forwarder means the method * does per-element work, so the annotation is simply the wrong one — {@link HotPath} is. * From cb715aa8abe353125525a084edc67285d3664bef Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 4 Aug 2026 15:09:05 +0200 Subject: [PATCH 2/5] Record that D2 and D5 were deliberately not built, and widen D1 to match @AllocFree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, one story: the LogCompilation-parsing checks are not being built, so the annotations and docs that pointed at them as future work are corrected, and the check that does cover the same ground gets its population widened. == D2 and D5 will not be built == The probe settled feasibility and then undermined the case. is emitted and self-describing, and a Mill test fork produces a complete log — so D2 is buildable. But the vocabulary is JDK-internal with no compatibility contract (the plan's guessed inline_fail strings were wrong in five places), and the drift is indistinguishable from the regression: a renamed intrinsic id shrinks the observed set, which is exactly what losing vectorisation looks like, with no invariant to violate and no cross-check available. The line that fell out of it is which API a check depends on. D1 uses ThreadMXBean, D3 uses VectorSpecies, D4 uses arithmetic, eaOffTest uses a product-grade -XX: flag — all public and contractual. D2 and D5 would have depended on the compiler's diagnostic output instead. That is the dividing line, not the amount of work. Recorded in jitAudit/package.mill rather than left as an absence, because an unexplained gap in a checklist reads as an oversight. The forward references written in the previous commit — HotPath.java's "check D2's business", the variance comment's "see check D2", the blog's pointer — now say the limit is documented and unenforced, and name what covers it indirectly. == eaOffTest is what replaces D2, and it is switched off == Reading it properly while answering "do these modules earn their keep": D1 with EA enabled cannot distinguish "the SIMD intrinsics were applied" from "they were not, and EA scalarised the software-path objects instead" — both read as zero. The EA-off scope can, because on the software path with EA off the objects reach the heap. That is D2's question answered from an observable consequence rather than from XML, it is already written, and its CI step is commented out. Its scaladoc now says so. Nothing removed. == D1 widened from 20 to 29 kernels == Every @AllocFree method now has a test behind it. The nine added: floatarrays clamp!, +=(Array[Float]), *=(Array[Float]), *=(Float) intarrays minSIMD, maxSIMD, +=(Array[Int]), -=(Array[Int]), -=(Int) An annotation with no test is an assertion nobody has checked, which is how intarrays.dot carried @AllocFree while allocating a dead array per call. The class doc now states that coverage is every annotated method and that the two are kept in step by hand — and drops the brittle counts that have needed correcting twice. Also notes why the kernel lists cannot be factored into a shared collection: assertAllocFree must stay inline so each call site gets a monomorphic measurement loop, and driving them from a List[() => Unit] would reintroduce the megamorphic Function0.apply() dispatch that stops C2 inlining through to the Vector API calls. The duplication is load-bearing. Co-Authored-By: Claude Opus 5 --- jitAudit/eaOffTest/src/D1EAOffSuite.scala | 7 +- jitAudit/package.mill | 52 +++++++++++++- jitAudit/test/src/D1Suite.scala | 69 +++++++++++++++++-- site/docs/blog/2026-07-28-Inlining.md | 10 ++- vecxt/src-jvm/doublearrays.scala | 9 +-- .../java/vecxt/annotations/HotPath.java | 17 +++-- 6 files changed, 144 insertions(+), 20 deletions(-) diff --git a/jitAudit/eaOffTest/src/D1EAOffSuite.scala b/jitAudit/eaOffTest/src/D1EAOffSuite.scala index b59d7f3c..dcb23daa 100644 --- a/jitAudit/eaOffTest/src/D1EAOffSuite.scala +++ b/jitAudit/eaOffTest/src/D1EAOffSuite.scala @@ -5,7 +5,12 @@ import vecxt.all.{*, given} /** D1 EA-off cross-check — guards against undetected SIMD→software regression. * - * Runs the same fourteen kernels as {@link D1Suite} with escape analysis disabled ({@code -XX:-DoEscapeAnalysis}). + * Runs a subset of {@link D1Suite}'s kernels with escape analysis disabled ({@code -XX:-DoEscapeAnalysis}). The two + * lists have drifted — D1Suite covers every {@code @AllocFree} method and this one predates several of them. They + * cannot be factored into a shared collection: {@code assertAllocFree} has to stay {@code inline} so each call site + * gets a monomorphic measurement loop, and driving the kernels from a {@code List[() => Unit]} would reintroduce the + * megamorphic {@code Function0.apply()} dispatch that stops C2 inlining through to the Vector API calls. So parity is + * maintained by hand or not at all. * This provides a check that is strictly stronger than D1Suite alone, for the following reason: * * '''Original design intent vs. reality:''' diff --git a/jitAudit/package.mill b/jitAudit/package.mill index 67e18ed5..f7248ca6 100644 --- a/jitAudit/package.mill +++ b/jitAudit/package.mill @@ -26,6 +26,42 @@ import mill.*, scalalib.* * produce results that look like allocation from interpreted code. {@code --add-modules jdk.incubator.vector} exposes * the Vector API. * + * ==What was deliberately not built: D2 and D5== + * + * The plan specified two further checks in this tier, both of which work by running with + * {@code -XX:+LogCompilation} and parsing the XML: D2 confirms Vector API intrinsics were applied by looking for + * {@code } entries, and D5 counts {@code } and recompilation events. Neither + * is implemented, and the decision is not "not yet" — it is that the cost/benefit does not work. Recorded here rather + * than left as an absence, because an unexplained gap in a checklist reads as an oversight. + * + * A probe run settled the feasibility question and then undermined the case: + * + * - The format is available. {@code } is emitted, self-describing ({@code id='_VectorBinaryOp'}), and a + * Mill test fork produces a complete log. So D2 is buildable. + * - But the vocabulary is JDK-internal with no compatibility contract, and the plan's own guessed list of + * {@code inline_fail} reason strings was wrong in five places when checked against a real log. Intrinsic ids drift + * the same way. + * - The drift is indistinguishable from the regression. A renamed intrinsic id makes the observed set shrink, and a + * shrinking set is exactly what losing vectorisation looks like. There is no invariant to violate and no + * cross-check, so the check cannot tell a JDK upgrade from a real finding. + * - {@code } carries no method attribution, so attributing one to a kernel needs a stack-tracking parser + * over the {@code }/{@code } nesting — several hundred lines whose input has no contract. + * - And pinning the JDK to protect the check inverts the relationship, while the Vector API is still an incubator + * module whose finalisation will force these kernels to be rewritten anyway. + * + * The line that fell out of it: every check in this tier that is worth having depends on a *public* API — D1 on + * {@code ThreadMXBean}, D3 on {@code VectorSpecies}, D4 on arithmetic, and {@code eaOffTest} on a product-grade + * {@code -XX:} flag. D2 and D5 are the two that would have depended on the JVM's diagnostic output instead. That is + * the dividing line, not the amount of work. + * + * What replaces D2 is already here and cheaper: {@code eaOffTest} answers the same question — did the SIMD intrinsics + * actually apply — from the observable consequence rather than from the compiler's log. See its Scaladoc below. + * + * One thing was learned and kept: bytecode size predicts machine-code size poorly (measured 7–10× for vectorised code + * with a masked tail), so {@code InlineSmallCode} can bind before {@code FreqInlineSize} does. That is recorded in + * {@code HotPath.java}, {@code Thin.java} and the inlining blog post, where it costs nothing to maintain. Banking the + * finding without building the instrument was the point. + * * Usage: {@code ./mill jitAudit.test} (D1/D3/D4/D6) and {@code ./mill jitAudit.eaOffTest} (EA-off cross-check) */ object `package` extends ScalaModule: @@ -51,7 +87,7 @@ object `package` extends ScalaModule: } end test - /** EA-off cross-check for D1: runs the same fourteen kernels as D1Suite with escape analysis disabled ({@code + /** EA-off cross-check for D1: runs a subset of D1Suite's kernels with escape analysis disabled ({@code * -XX:-DoEscapeAnalysis}). * * HotSpot Vector API intrinsics lower Vector API calls to SIMD machine instructions at the {@code VectorSupport} @@ -61,6 +97,20 @@ object `package` extends ScalaModule: * that EA would otherwise scalarise — making the regression visible here even if D1Suite (with EA enabled) continues * to pass. * + * ==This is what replaces D2== + * + * Worth stating plainly, because the scope is easy to mistake for a nice-to-have and is currently commented out of + * the CI workflow. D1 with EA on cannot distinguish "the SIMD intrinsics were applied" from "they were not, and + * EA scalarised the software-path objects instead" — both read as zero. This scope can: on the software path with + * EA off, the objects reach the heap and the allocation is visible. + * + * That is the same question D2 was specified to answer by parsing {@code -XX:+LogCompilation} output, answered from + * an observable consequence and a product-grade {@code -XX:} flag instead of from a JDK-internal XML vocabulary. It + * is already written, and it does not need a parser anyone has to maintain across JDK upgrades. + * + * Two known gaps. The kernel list here has drifted behind D1Suite's, and the CI step is disabled — so today it + * protects nothing. Both are worth fixing before reaching for anything more elaborate. + * * Run with: {@code ./mill jitAudit.eaOffTest} */ object eaOffTest extends ScalaTests, TestModule.Munit: diff --git a/jitAudit/test/src/D1Suite.scala b/jitAudit/test/src/D1Suite.scala index 68c65969..6be7c973 100644 --- a/jitAudit/test/src/D1Suite.scala +++ b/jitAudit/test/src/D1Suite.scala @@ -16,12 +16,18 @@ import vecxt.all.{*, given} * unscalarised vector per call is roughly {@code 64 − burst/reps ≈ 49 bytes/op} on CI (with the observed burst of ~1.5 * MB over 100 000 iterations), still comfortably above the 8-byte threshold. * - * Tests that return a value (the six pure reductions: {@code sumSIMD}, {@code productSIMD}) store their result into a - * {@code @volatile} field so that C2 cannot dead-code-eliminate the computation. Without a sink, a kernel whose result - * is discarded is pure (no observable side effects) and C2 is free to eliminate the entire loop body, giving zero - * measured allocation not because the vectors were scalarized but because nothing ran. The thirteen in-place mutations - * ({@code +=}, {@code -=}, {@code abs!}, etc.) write to an array, which is a visible side effect, so they are not - * exposed to this hazard. + * Tests that return a value (the reductions — {@code sumSIMD}, {@code productSIMD}, {@code dot}, {@code minSIMD}, + * {@code maxSIMD}, {@code variance}) store their result into a {@code @volatile} field so that C2 cannot + * dead-code-eliminate the computation. Without a sink, a kernel whose result is discarded is pure (no observable side + * effects) and C2 is free to eliminate the entire loop body, giving zero measured allocation not because the vectors + * were scalarized but because nothing ran. The in-place mutations ({@code +=}, {@code -=}, {@code abs!}, etc.) write + * to an array, which is a visible side effect, so they are not exposed to this hazard. + * + *

Coverage is deliberately every method carrying {@code @AllocFree}, and the two are kept in step by hand: an + * annotation with no test here is an assertion nobody has checked, which is how {@code intarrays.dot} carried + * {@code @AllocFree} through two releases while allocating a dead array per call, and how {@code **!} kept it until + * #110 measured it. The list is written out rather than driven from a collection on purpose — see the note on + * {@code assertAllocFree} below. * * {@code assertAllocFree} is declared {@code inline} so that each call site gets its own specialised measurement loop * inside {@code AllocMeter.measureAlloc}. Without inlining the body would be dispatched through a shared megamorphic @@ -179,6 +185,30 @@ class D1Suite extends FunSuite: assertAllocFree("floatarrays.abs!")(arr.`abs!`) } + test("D1: floatarrays.clamp!") { + val arr = Array.tabulate(N)(i => (i % 10).toFloat) + assertAllocFree("floatarrays.clamp!")(arr.`clamp!`(2.0f, 7.0f)) + } + + test("D1: floatarrays.+=(Array[Float])") { + val arr = Array.fill(N)(1.0f) + val arr2 = Array.fill(N)(0.0f) // adding zero keeps the values stable across the measurement windows + assertAllocFree("floatarrays.+=(Array[Float])")(arr += arr2) + } + + test("D1: floatarrays.*=(Array[Float])") { + val arr = Array.fill(N)(2.0f) + // See doublearrays.*= — a multiplier of 1.0 avoids driving the array to denormals and then to + // zero partway through, which would make most of the measured workload operate on zeros. + val arr2 = Array.fill(N)(1.0f) + assertAllocFree("floatarrays.*=(Array[Float])")(arr *= arr2) + } + + test("D1: floatarrays.*=(Float)") { + val arr = Array.fill(N)(2.0f) + assertAllocFree("floatarrays.*=(Float)")(arr *= 1.0f) + } + // ── Int ───────────────────────────────────────────────────────────────────── test("D1: intarrays.sumSIMD") { @@ -195,4 +225,31 @@ class D1Suite extends FunSuite: assertAllocFree("intarrays.dot") { intSink = arr.dot(arr2) } } + test("D1: intarrays.minSIMD") { + val arr = Array.tabulate(N)(i => i % 1000) + assertAllocFree("intarrays.minSIMD") { intSink = arr.minSIMD } + } + + test("D1: intarrays.maxSIMD") { + val arr = Array.tabulate(N)(i => i % 1000) + assertAllocFree("intarrays.maxSIMD") { intSink = arr.maxSIMD } + } + + test("D1: intarrays.+=(Array[Int])") { + val arr = Array.fill(N)(1) + val arr2 = Array.fill(N)(0) // adding zero keeps the values from overflowing across the windows + assertAllocFree("intarrays.+=(Array[Int])")(arr += arr2) + } + + test("D1: intarrays.-=(Array[Int])") { + val arr = Array.fill(N)(1) + val arr2 = Array.fill(N)(0) + assertAllocFree("intarrays.-=(Array[Int])")(arr -= arr2) + } + + test("D1: intarrays.-=(Int)") { + val arr = Array.fill(N)(1) + assertAllocFree("intarrays.-=(Int)")(arr -= 0) + } + end D1Suite diff --git a/site/docs/blog/2026-07-28-Inlining.md b/site/docs/blog/2026-07-28-Inlining.md index 57726fe5..7810fd0e 100644 --- a/site/docs/blog/2026-07-28-Inlining.md +++ b/site/docs/blog/2026-07-28-Inlining.md @@ -88,7 +88,9 @@ The forwarder row is the one worth staring at. `vecxt.all.clamp!` is an eleven-b Two things are deliberately not claimed here. The ratio is one workload on one CPU at one lane width, so treat the direction as established and the crossover point as approximate. And whether HotSpot's `MaxTrivialSize`/`MaxInlineSize` fast paths let a *small* callee bypass the `InlineSmallCode` veto is unverified — if they do not, the forwarder row describes a real hazard rather than a curiosity. -Enforcing this needs the compiled size, so it belongs to the dynamic tier: check D2 of [#105](https://github.com/Quafadas/vecxt/issues/105), which reads it from `LogCompilation` output. +Enforcing this needs the compiled size, which only the JVM's own compilation log carries — and the check that would have read it (D2 of [#105](https://github.com/Quafadas/vecxt/issues/105)) was deliberately not built. The reasoning is in `jitAudit/package.mill`; the short version is that intrinsic ids and inline-failure strings are JDK-internal, drift between releases, and drift in a way indistinguishable from the regression the check is looking for. + +So this limit is documented and unenforced. What covers it indirectly is the allocation measurement: a kernel that stops being inlined also stops having its `Vector` temporaries scalarised, which shows up as bytes per operation. That catches the consequence rather than the cause, and only for kernels annotated `@AllocFree`. ## Where `inline` is not negotiable @@ -328,4 +330,8 @@ Two things the checks cannot see, and both matter for reading this page. An `inline def` body is expanded into its callers rather than emitted, so it has no bytecode of its own. A generic `inline def` is audited only through whatever non-inline callers exist. That is why `@HotPath` and `@Thin` are defined as properties of *emitted* methods, and why putting one on an `inline def` is a build failure rather than a no-op. -And `bytecodeAudit` reads bytecode, so none of C1/C2/C3 can see the `InlineSmallCode` limit described above. The two budgets those checks enforce are proxies for a machine-code constraint that is, for vectorised kernels, tighter than either of them. A passing `@HotPath` therefore means "inside the bytecode budget", not "C2 will inline this" — the stronger reading needs D2. +And `bytecodeAudit` reads bytecode, so none of C1/C2/C3 can see the `InlineSmallCode` limit described above. The two budgets those checks enforce are proxies for a machine-code constraint that is, for vectorised kernels, tighter than either of them. A passing `@HotPath` therefore means "inside the bytecode budget", not "C2 will inline this". + +The dynamic tier could close that gap and deliberately does not. `jitAudit` implements D1 (allocation per `@AllocFree` kernel), D3 (species reporting), D4 (SIMD-vs-scalar differential) and D6 (harness canary), plus an escape-analysis-off cross-check. It does **not** implement the two checks that would have parsed `-XX:+LogCompilation` output — D2, intrinsic confirmation, and D5, deopt churn. The line is which API a check depends on: `ThreadMXBean`, `VectorSpecies`, arithmetic and a product-grade `-XX:` flag are public and contractual; the compiler's diagnostic XML is neither, and its vocabulary drifts in a way that cannot be told apart from the regression being looked for. `jitAudit/package.mill` has the full reasoning and the measurements that led to it. + +The honest summary of the page, then: the static checks enforce bytecode budgets that are necessary but not sufficient, the dynamic checks catch the consequences of losing vectorisation rather than the cause, and the gap between them is documented rather than closed. diff --git a/vecxt/src-jvm/doublearrays.scala b/vecxt/src-jvm/doublearrays.scala index ed4b2119..fcb3d013 100644 --- a/vecxt/src-jvm/doublearrays.scala +++ b/vecxt/src-jvm/doublearrays.scala @@ -528,11 +528,12 @@ object doublearrays: * The zero depends on C2 inlining `meanAndVarianceTwoPass` into this method, because that is where the * `MeanAndVariance` is constructed and escape analysis only runs after C2's own inlining. A `LogCompilation` run * puts that method at 1688 bytes of machine code — 68% of `InlineSmallCode` (2500), the budget above which C2 - * declines to inline an already-compiled callee. Nothing measures that number today; see check D2. + * declines to inline an already-compiled callee. Nothing measures that number, and nothing will: check D2 would + * have, and was deliberately not built (see `jitAudit/package.mill`). * - * So if `meanAndVarianceTwoPass` grows past the limit, the pair starts escaping, and the symptom is *this* test - * failing with a message about allocation rather than about inlining. If that happens, look at the callee's - * compiled size before looking at anything here. + * So if `meanAndVarianceTwoPass` grows past the limit, the pair starts escaping, and the symptom is D1 failing on + * *this* method with a message about allocation rather than about inlining. That indirection is the accepted cost + * of not building D2. If it happens, look at the callee's compiled size before looking at anything here. */ @Thin @AllocFree diff --git a/vecxt/src-jvm/java/vecxt/annotations/HotPath.java b/vecxt/src-jvm/java/vecxt/annotations/HotPath.java index 4bf3c0ef..84e83fdc 100644 --- a/vecxt/src-jvm/java/vecxt/annotations/HotPath.java +++ b/vecxt/src-jvm/java/vecxt/annotations/HotPath.java @@ -29,12 +29,17 @@ * {@code FreqInlineSize}'s 325. A kernel can satisfy this annotation and still be one C2 declines * to inline. * - *

Bytecode analysis cannot see that, so C2 asserts what it can. The compiled size is check D2's - * business: it reads {@code stub_offset - insts_offset} off {@code LogCompilation}'s {@code c2} - * {@code } elements. Until D2 lands, a kernel in the upper part of the - * {@code FreqInlineSize} range should be treated as unverified rather than safe. See - * {@code site/docs/blog/2026-07-28-Inlining.md}, "The limit that is not measured in bytecodes", - * for the measurements. + *

Bytecode analysis cannot see that, so C2 asserts what it can, and nothing measures the + * compiled size. Check D2 would have — by reading {@code stub_offset - insts_offset} off + * {@code LogCompilation}'s {@code c2} {@code } elements — and was deliberately not built; + * {@code jitAudit/package.mill} records why. So a kernel in the upper part of the + * {@code FreqInlineSize} range is unverified rather than safe, and will stay that way. + * + *

What does cover it, indirectly: a kernel that stops being inlined also stops having its + * {@code Vector} temporaries scalarised, which {@code jitAudit}'s D1 and its EA-off cross-check + * measure as allocation. That catches the consequence rather than the cause, and only for kernels + * carrying {@link AllocFree}. See {@code site/docs/blog/2026-07-28-Inlining.md}, "The limit that is + * not measured in bytecodes", for the measurements behind this note. * *

Only meaningful on a method that is actually emitted. An {@code inline def} body is * expanded into its callers instead of being compiled on its own, so it has no bytecode to measure From 5b46737bdd2eb6cc4edb3d415bb19819f29ec120 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:21:20 +0000 Subject: [PATCH 3/5] [autofix.ci] apply automated fixes --- jitAudit/eaOffTest/src/D1EAOffSuite.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jitAudit/eaOffTest/src/D1EAOffSuite.scala b/jitAudit/eaOffTest/src/D1EAOffSuite.scala index dcb23daa..68bba6dd 100644 --- a/jitAudit/eaOffTest/src/D1EAOffSuite.scala +++ b/jitAudit/eaOffTest/src/D1EAOffSuite.scala @@ -10,8 +10,8 @@ import vecxt.all.{*, given} * cannot be factored into a shared collection: {@code assertAllocFree} has to stay {@code inline} so each call site * gets a monomorphic measurement loop, and driving the kernels from a {@code List[() => Unit]} would reintroduce the * megamorphic {@code Function0.apply()} dispatch that stops C2 inlining through to the Vector API calls. So parity is - * maintained by hand or not at all. - * This provides a check that is strictly stronger than D1Suite alone, for the following reason: + * maintained by hand or not at all. This provides a check that is strictly stronger than D1Suite alone, for the + * following reason: * * '''Original design intent vs. reality:''' * From bf98ad65fc05870efc5844dff1f2d102299783ac Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 4 Aug 2026 15:29:01 +0200 Subject: [PATCH 4/5] Re-enable the EA-off cross-check, give it a canary, bring it to parity with D1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reason it sat commented out since #110 turns out to be structural rather than incidental: it had no canary. Every assertion in it reads "still zero with EA off", which is precisely what a run with EA still *on* produces — so the scope passed whether or not -XX:-DoEscapeAnalysis reached the JVM. That is not a check, and switching it off was the right call at the time. The canary was available for free and nobody had noticed. `variance(mode)` is the one kernel in D1Suite whose zero comes from escape analysis rather than from intrinsification: it reads one field out of the MeanAndVariance that meanAndVarianceTwoPass returns and discards the other, so the object is dead and EA removes it. That object is an ordinary final class, not a Vector, so nothing intrinsifies it away — with EA off it must reach the heap. Asserting that it *does* allocate proves the flag took effect, and a failure there says every other assertion in the scope is passing for the wrong reason. That is also why it is the canary rather than a 29th kernel assertion: asserting "≤ 8 bytes/op with EA off" for an EA-dependent kernel would be asserting the opposite of what the flag does. The distinction the whole scope rests on is intrinsification-eliminated (survives EA-off) versus EA-eliminated (does not), and variance is the only member of the second category. Coverage 14 -> 28 kernels plus the canary, so every @AllocFree method is now asserted in both scopes. The drift is worth noting as a hazard in itself: this suite sat at fourteen while D1Suite grew to twenty-nine, and the lists cannot be factored into a shared collection because the assertion helpers must stay inline for each call site to get a monomorphic measurement loop. Docs corrected in two places where I had overstated this scope's reach. It relies on EA being what rescues a software-path kernel, and that is not always so — D6's canary is a software-path kernel that allocates with EA *enabled*, so D1Suite catches that one unaided. What this scope adds is the narrower set of fallbacks whose objects happen not to escape. Cheap and worth having; not the full substitute for confirming intrinsification that "what replaces D2" implied. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 19 +-- jitAudit/eaOffTest/src/D1EAOffSuite.scala | 150 +++++++++++++++++++++- jitAudit/package.mill | 32 +++-- 3 files changed, 172 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49d2b9f5..79961cc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,14 +97,17 @@ jobs: if: matrix.project == 'jvm' run: ./mill jitAudit.test - # D1 EA-off cross-check: runs the same kernels with -XX:-DoEscapeAnalysis. HotSpot Vector - # API intrinsics bypass heap allocation independently of EA, so kernels still show ~0 - # bytes/op. The value: if any kernel regresses to the software fallback path (SIMD - # intrinsics not applied), EA-off reveals it — software-path Vector objects are not - # scalarised without EA, making the regression visible here even if D1Suite still passes. - # - name: JIT audit EA-off cross-check (SIMD intrinsics independent of EA) - # if: matrix.project == 'jvm' - # run: ./mill jitAudit.eaOffTest + # D1 EA-off cross-check: the same kernels under -XX:-DoEscapeAnalysis. HotSpot Vector API + # intrinsics bypass heap allocation independently of EA, so an intrinsified kernel still + # shows ~0 bytes/op. The value: a kernel that regressed to the software fallback path + # allocates real objects, and EA normally scalarises those — so D1Suite reads a pass while + # this step fails. + # + # Was commented out from #110 until #121. It had no canary, so it passed whether or not the + # flag reached the JVM, which is not a check. It has one now — see D1EAOffSuite. + - name: JIT audit EA-off cross-check (SIMD intrinsics independent of EA) + if: matrix.project == 'jvm' + run: ./mill jitAudit.eaOffTest - name: Upload bytecode audit report if: always() && matrix.project == 'jvm' diff --git a/jitAudit/eaOffTest/src/D1EAOffSuite.scala b/jitAudit/eaOffTest/src/D1EAOffSuite.scala index 68bba6dd..a0786080 100644 --- a/jitAudit/eaOffTest/src/D1EAOffSuite.scala +++ b/jitAudit/eaOffTest/src/D1EAOffSuite.scala @@ -5,13 +5,15 @@ import vecxt.all.{*, given} /** D1 EA-off cross-check — guards against undetected SIMD→software regression. * - * Runs a subset of {@link D1Suite}'s kernels with escape analysis disabled ({@code -XX:-DoEscapeAnalysis}). The two - * lists have drifted — D1Suite covers every {@code @AllocFree} method and this one predates several of them. They - * cannot be factored into a shared collection: {@code assertAllocFree} has to stay {@code inline} so each call site - * gets a monomorphic measurement loop, and driving the kernels from a {@code List[() => Unit]} would reintroduce the - * megamorphic {@code Function0.apply()} dispatch that stops C2 inlining through to the Vector API calls. So parity is - * maintained by hand or not at all. This provides a check that is strictly stronger than D1Suite alone, for the - * following reason: + * Runs {@link D1Suite}'s kernels with escape analysis disabled ({@code -XX:-DoEscapeAnalysis}), plus a canary for the + * flag itself. This is a check D1Suite cannot make, for the reason set out below. + * + *

Coverage is every {@code @AllocFree} kernel except {@code variance(mode)}, which appears here as the flag canary + * instead — see its comment. The two suites cannot be factored into a shared collection: the assertion helpers have to + * stay {@code inline} so each call site gets a monomorphic measurement loop, and driving the kernels from a + * {@code List[() => Unit]} would reintroduce the megamorphic {@code Function0.apply()} dispatch that stops C2 inlining + * through to the Vector API calls. So the two lists are kept in step by hand, and drifting apart is a real hazard: + * this suite sat at fourteen kernels while D1Suite grew to twenty-nine. * * '''Original design intent vs. reality:''' * @@ -36,6 +38,19 @@ import vecxt.all.{*, given} * D1EAOffSuite asserts the same ≤ 8 bytes threshold under EA-off: if a kernel has regressed to the software fallback * path, D1EAOffSuite fails here while D1Suite would continue to pass. * + *

How much that is worth depends on how reliably EA rescues a software-path kernel, and the honest answer is "not + * always". D6's canary — species from a method parameter — is a software-path kernel that allocates with EA + * enabled, so D1Suite catches it unaided. The gap this suite closes is therefore narrower than "detects lost + * intrinsification": it is the fallbacks whose objects happen not to escape, which EA removes and D1Suite then reads + * as a pass. Worth having, and cheap, but not a substitute for confirming intrinsification directly. + * + * '''Why the flag canary is not optional:''' + * + * Every kernel assertion here reads "still zero with EA off", which is exactly what a run with EA still on + * would produce. Without a test that fails when the flag is absent, this entire scope passes whether or not + * {@code -XX:-DoEscapeAnalysis} reached the JVM — the same "silently became a no-op" hazard D6 exists to prevent for + * D1Suite. The canary is the first test in the file. + * * '''DCE guard:''' * * Pure reductions store their results into {@code @volatile} fields, preventing C2 from dead-code-eliminating the @@ -85,6 +100,48 @@ class D1EAOffSuite extends FunSuite: ) end assertAllocFreeWithEAOff + /** The inverted assertion, used by exactly one test: the flag canary below. + * + * Every other assertion in this suite reads "still zero with EA off", which is indistinguishable from "the flag was + * silently dropped and EA is on". This is the assertion that tells those two apart. + */ + private inline def assertAllocatesWithEAOff(label: String)(inline body: => Unit): Unit = + val total = AllocMeter.measureAlloc(Warmup, Reps)(body) + if total < 0L && sys.env.contains("CI") then + fail(s"[D1-EAOff] $label: CI detected but ThreadMXBean allocation tracking is unavailable.") + end if + assume(total >= 0L, s"[D1-EAOff] skip $label — ThreadMXBean allocation tracking not available on this JVM") + val perOp = total.toDouble / Reps + assert( + perOp > Eps, + s"D1-EAOff github.com/Quafadas/vecxt/issues/105: $label allocated ${perOp.toLong} bytes/op with " + + s"-XX:-DoEscapeAnalysis, and was expected to allocate. This is the flag canary, not a kernel check: " + + s"the allocation it looks for is one escape analysis would have removed, so measuring zero here means " + + s"EA is still on and -XX:-DoEscapeAnalysis did not take effect. Every other assertion in this suite " + + s"is then passing for the wrong reason. Check this scope's forkArgs before believing any of them." + ) + end assertAllocatesWithEAOff + + // ── The flag canary ───────────────────────────────────────────────────────── + + /** Proves `-XX:-DoEscapeAnalysis` reached the JVM. Without this the whole scope is unfalsifiable — the absence of + * such a check is the most likely reason the CI step for it sat commented out. + * + * `variance(mode)` is the one kernel in D1Suite whose zero comes from escape analysis rather than from + * intrinsification. Its body reads one field out of the [[vecxt.MeanAndVariance]] that `meanAndVarianceTwoPass` + * returns and discards the other, so the object is dead and EA removes it — D1Suite measures 0 bytes/op. That object + * is an ordinary `final class`, not a `Vector`, so nothing intrinsifies it away. With EA off it must reach the heap. + * + * Which is also why it is absent from the kernel assertions below rather than merely inverted here: asserting + * "≤ 8 bytes/op with EA off" for an EA-dependent kernel would be asserting the opposite of what the flag does. + */ + test("D1-EAOff canary: doublearrays.variance(mode) must allocate with EA off") { + val arr = Array.tabulate(N)(i => (i % 100).toDouble) + assertAllocatesWithEAOff("doublearrays.variance(mode)") { + doubleSink = arr.variance(VarianceMode.Population) + } + } + // ── Double ────────────────────────────────────────────────────────────────── test("D1-EAOff: doublearrays.sumSIMD") { @@ -128,6 +185,20 @@ class D1EAOffSuite extends FunSuite: assertAllocFreeWithEAOff("doublearrays.fillLinspace")(fillLinspace(dest, 0.0, 1.0)) } + // The two in-place unary kernels. NEG and ABS are intrinsified lanewise operations and the masked + // tail's VectorMask is `_VectorFromBitsCoerced`, so nothing here depends on EA — which is exactly + // what makes them worth asserting with EA off. The transcendentals (exp!, log!, …) are not + // annotated @AllocFree and so are not measured in either suite. + test("D1-EAOff: doublearrays.-!") { + val arr = Array.fill(N)(1.0) + assertAllocFreeWithEAOff("doublearrays.-!")(arr.`-!`) + } + + test("D1-EAOff: doublearrays.abs!") { + val arr = Array.tabulate(N)(i => if i % 2 == 0 then i.toDouble else -i.toDouble) + assertAllocFreeWithEAOff("doublearrays.abs!")(arr.`abs!`) + } + // ── Float ─────────────────────────────────────────────────────────────────── test("D1-EAOff: floatarrays.sumSIMD") { @@ -155,6 +226,38 @@ class D1EAOffSuite extends FunSuite: assertAllocFreeWithEAOff("floatarrays.-=(Float)")(arr -= 0.1f) } + test("D1-EAOff: floatarrays.-!") { + val arr = Array.fill(N)(1.0f) + assertAllocFreeWithEAOff("floatarrays.-!")(arr.`-!`) + } + + test("D1-EAOff: floatarrays.abs!") { + val arr = Array.tabulate(N)(i => if i % 2 == 0 then i.toFloat else -i.toFloat) + assertAllocFreeWithEAOff("floatarrays.abs!")(arr.`abs!`) + } + + test("D1-EAOff: floatarrays.clamp!") { + val arr = Array.tabulate(N)(i => (i % 10).toFloat) + assertAllocFreeWithEAOff("floatarrays.clamp!")(arr.`clamp!`(2.0f, 7.0f)) + } + + test("D1-EAOff: floatarrays.+=(Array[Float])") { + val arr = Array.fill(N)(1.0f) + val arr2 = Array.fill(N)(0.0f) + assertAllocFreeWithEAOff("floatarrays.+=(Array[Float])")(arr += arr2) + } + + test("D1-EAOff: floatarrays.*=(Array[Float])") { + val arr = Array.fill(N)(2.0f) + val arr2 = Array.fill(N)(1.0f) + assertAllocFreeWithEAOff("floatarrays.*=(Array[Float])")(arr *= arr2) + } + + test("D1-EAOff: floatarrays.*=(Float)") { + val arr = Array.fill(N)(2.0f) + assertAllocFreeWithEAOff("floatarrays.*=(Float)")(arr *= 1.0f) + } + // ── Int ───────────────────────────────────────────────────────────────────── test("D1-EAOff: intarrays.sumSIMD") { @@ -162,4 +265,37 @@ class D1EAOffSuite extends FunSuite: assertAllocFreeWithEAOff("intarrays.sumSIMD") { intSink = arr.sumSIMD } } + test("D1-EAOff: intarrays.dot") { + val arr = Array.tabulate(N)(i => i % 100) + val arr2 = Array.tabulate(N)(i => (i + 1) % 100) + assertAllocFreeWithEAOff("intarrays.dot") { intSink = arr.dot(arr2) } + } + + test("D1-EAOff: intarrays.minSIMD") { + val arr = Array.tabulate(N)(i => i % 1000) + assertAllocFreeWithEAOff("intarrays.minSIMD") { intSink = arr.minSIMD } + } + + test("D1-EAOff: intarrays.maxSIMD") { + val arr = Array.tabulate(N)(i => i % 1000) + assertAllocFreeWithEAOff("intarrays.maxSIMD") { intSink = arr.maxSIMD } + } + + test("D1-EAOff: intarrays.+=(Array[Int])") { + val arr = Array.fill(N)(1) + val arr2 = Array.fill(N)(0) + assertAllocFreeWithEAOff("intarrays.+=(Array[Int])")(arr += arr2) + } + + test("D1-EAOff: intarrays.-=(Array[Int])") { + val arr = Array.fill(N)(1) + val arr2 = Array.fill(N)(0) + assertAllocFreeWithEAOff("intarrays.-=(Array[Int])")(arr -= arr2) + } + + test("D1-EAOff: intarrays.-=(Int)") { + val arr = Array.fill(N)(1) + assertAllocFreeWithEAOff("intarrays.-=(Int)")(arr -= 0) + } + end D1EAOffSuite diff --git a/jitAudit/package.mill b/jitAudit/package.mill index f7248ca6..d881a073 100644 --- a/jitAudit/package.mill +++ b/jitAudit/package.mill @@ -54,8 +54,9 @@ import mill.*, scalalib.* * {@code -XX:} flag. D2 and D5 are the two that would have depended on the JVM's diagnostic output instead. That is * the dividing line, not the amount of work. * - * What replaces D2 is already here and cheaper: {@code eaOffTest} answers the same question — did the SIMD intrinsics - * actually apply — from the observable consequence rather than from the compiler's log. See its Scaladoc below. + * Part of D2's ground is covered already, and more cheaply: {@code eaOffTest} asks whether the SIMD intrinsics + * actually applied by looking at an observable consequence rather than at the compiler's log. Only part — see that + * scope's Scaladoc for what it does and does not reach, and for why its CI step had been switched off. * * One thing was learned and kept: bytecode size predicts machine-code size poorly (measured 7–10× for vectorised code * with a masked tail), so {@code InlineSmallCode} can bind before {@code FreqInlineSize} does. That is recorded in @@ -87,8 +88,8 @@ object `package` extends ScalaModule: } end test - /** EA-off cross-check for D1: runs a subset of D1Suite's kernels with escape analysis disabled ({@code - * -XX:-DoEscapeAnalysis}). + /** EA-off cross-check for D1: runs D1Suite's kernels with escape analysis disabled ({@code -XX:-DoEscapeAnalysis}), + * plus a canary for the flag itself. * * HotSpot Vector API intrinsics lower Vector API calls to SIMD machine instructions at the {@code VectorSupport} * layer, bypassing heap allocation entirely and independently of EA. Correctly intrinsified kernels therefore still @@ -97,19 +98,22 @@ object `package` extends ScalaModule: * that EA would otherwise scalarise — making the regression visible here even if D1Suite (with EA enabled) continues * to pass. * - * ==This is what replaces D2== + * ==How this relates to the unbuilt D2== * - * Worth stating plainly, because the scope is easy to mistake for a nice-to-have and is currently commented out of - * the CI workflow. D1 with EA on cannot distinguish "the SIMD intrinsics were applied" from "they were not, and - * EA scalarised the software-path objects instead" — both read as zero. This scope can: on the software path with - * EA off, the objects reach the heap and the allocation is visible. + * It covers part of the same ground, from an observable consequence and a product-grade {@code -XX:} flag rather + * than from a JDK-internal XML vocabulary. D1 with EA on cannot distinguish "the SIMD intrinsics were applied" + * from "they were not, and EA scalarised the software-path objects instead" — both read as zero. This scope can. * - * That is the same question D2 was specified to answer by parsing {@code -XX:+LogCompilation} output, answered from - * an observable consequence and a product-grade {@code -XX:} flag instead of from a JDK-internal XML vocabulary. It - * is already written, and it does not need a parser anyone has to maintain across JDK upgrades. + * Only part, though, and the earlier framing of it as "what replaces D2" was too strong. It relies on EA being what + * rescues a software-path kernel, and that is not always so: D6's canary is a software-path kernel that allocates + * with EA *enabled*, so D1Suite catches that one unaided. What this scope adds is the fallbacks whose objects happen + * not to escape. Cheap and worth having; not a full substitute for confirming intrinsification directly, which + * nothing here does. * - * Two known gaps. The kernel list here has drifted behind D1Suite's, and the CI step is disabled — so today it - * protects nothing. Both are worth fixing before reaching for anything more elaborate. + * It sat commented out of CI from #110, and the reason turned out to be structural: it had no canary. Every + * assertion in it reads "still zero with EA off", which is precisely what a run with EA still on produces — so the + * scope passed whether or not the flag reached the JVM. #121 adds a canary that fails when the flag is absent, + * brings the kernel list up to parity with D1Suite, and re-enables the CI step. * * Run with: {@code ./mill jitAudit.eaOffTest} */ From c92b598e934b2a94a0f66bf29fac56bdd29f4d87 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:34:27 +0000 Subject: [PATCH 5/5] [autofix.ci] apply automated fixes --- jitAudit/eaOffTest/src/D1EAOffSuite.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jitAudit/eaOffTest/src/D1EAOffSuite.scala b/jitAudit/eaOffTest/src/D1EAOffSuite.scala index a0786080..1a3bc806 100644 --- a/jitAudit/eaOffTest/src/D1EAOffSuite.scala +++ b/jitAudit/eaOffTest/src/D1EAOffSuite.scala @@ -132,8 +132,8 @@ class D1EAOffSuite extends FunSuite: * returns and discards the other, so the object is dead and EA removes it — D1Suite measures 0 bytes/op. That object * is an ordinary `final class`, not a `Vector`, so nothing intrinsifies it away. With EA off it must reach the heap. * - * Which is also why it is absent from the kernel assertions below rather than merely inverted here: asserting - * "≤ 8 bytes/op with EA off" for an EA-dependent kernel would be asserting the opposite of what the flag does. + * Which is also why it is absent from the kernel assertions below rather than merely inverted here: asserting "≤ 8 + * bytes/op with EA off" for an EA-dependent kernel would be asserting the opposite of what the flag does. */ test("D1-EAOff canary: doublearrays.variance(mode) must allocate with EA off") { val arr = Array.tabulate(N)(i => (i % 100).toDouble)