Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
145 changes: 143 additions & 2 deletions jitAudit/eaOffTest/src/D1EAOffSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@ 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}).
* 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.
*
* <p>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:'''
*
Expand All @@ -31,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.
*
* <p>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
* <em>enabled</em>, 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 <em>on</em>
* 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
Expand Down Expand Up @@ -80,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") {
Expand Down Expand Up @@ -123,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") {
Expand Down Expand Up @@ -150,11 +226,76 @@ 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") {
val arr = Array.tabulate(N)(i => i % 1000)
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
58 changes: 56 additions & 2 deletions jitAudit/package.mill
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,43 @@ 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 <intrinsic id='_Vector...'>} entries, and D5 counts {@code <uncommon_trap>} 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 <intrinsic>} 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 <intrinsic>} carries no method attribution, so attributing one to a kernel needs a stack-tracking parser
* over the {@code <task>}/{@code <parse>} 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.
*
* 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
* {@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:
Expand All @@ -51,8 +88,8 @@ object `package` extends ScalaModule:
}
end test

/** EA-off cross-check for D1: runs the same fourteen kernels as D1Suite 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
Expand All @@ -61,6 +98,23 @@ object `package` extends ScalaModule:
* that EA would otherwise scalarise — making the regression visible here even if D1Suite (with EA enabled) continues
* to pass.
*
* ==How this relates to the unbuilt D2==
*
* 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.
*
* 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.
*
* 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}
*/
object eaOffTest extends ScalaTests, TestModule.Munit:
Expand Down
Loading
Loading