From f4136f791137bf16d5af9546cd90c7a5b71ac8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=99=8E=E9=B8=A3?= Date: Fri, 10 Jul 2026 20:48:44 +0800 Subject: [PATCH] perf: optimize JVM array hashing Motivation: HashCode.hash iterates JVM arrays through java.lang.reflect.Array, adding reflective per-element access and boxing in a shared utility. Modification: Dispatch all nine JVM array kinds to typed while loops. Add a directional test that uses the original reflective algorithm as the parity oracle across primitive, reference, empty, null-element, and nested arrays. Add a JMH benchmark for primitive and reference arrays. Result: Hash values remain compatible while JDK 17 JMH measurements improve by roughly 39x to 135x for the measured 16- and 256-element arrays. Tests: - JDK 17 Scala 2.13 HashCodeSpec: 3 tests passed - JDK 17 Scala 3.3.8 HashCodeSpec: 3 tests passed - JDK 17 JMH: Int[16] 824.277 to 6.106 ns/op; Int[256] 12749.433 to 183.033 ns/op; AnyRef[16] 635.492 to 13.945 ns/op; AnyRef[256] 9491.604 to 240.387 ns/op - sbt +headerCheckAll and checkCodeStyle passed - scalafmt --list --mode diff-ref=origin/main passed - git diff --check passed - qodercli formal stdout review found no must-fix findings References: Refs #3300 --- .../org/apache/pekko/util/HashCodeSpec.scala | 81 +++++++++++++++++++ .../org/apache/pekko/util/HashCode.scala | 65 ++++++++++++++- .../pekko/util/HashCodeArrayBenchmark.scala | 46 +++++++++++ 3 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 actor-tests/src/test/scala/org/apache/pekko/util/HashCodeSpec.scala create mode 100644 bench-jmh/src/main/scala/org/apache/pekko/util/HashCodeArrayBenchmark.scala diff --git a/actor-tests/src/test/scala/org/apache/pekko/util/HashCodeSpec.scala b/actor-tests/src/test/scala/org/apache/pekko/util/HashCodeSpec.scala new file mode 100644 index 00000000000..e46af45329a --- /dev/null +++ b/actor-tests/src/test/scala/org/apache/pekko/util/HashCodeSpec.scala @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.pekko.util + +import java.lang.reflect.{ Array => JArray } + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class HashCodeSpec extends AnyWordSpec with Matchers { + + private def fold(seed: Int, values: Seq[Any]): Int = + values.foldLeft(seed) { (result, value) => + HashCode.hash(result, value) + } + + private def legacyHash(seed: Int, value: Any): Int = value match { + case array: AnyRef if array.getClass.isArray => + var result = seed + var index = 0 + while (index < JArray.getLength(array)) { + result = legacyHash(result, JArray.get(array, index)) + index += 1 + } + result + case _ => HashCode.hash(seed, value) + } + + "HashCode.hash" must { + "preserve hashing semantics for every JVM array kind" in { + val seed = HashCode.SEED + val cases = Seq[(AnyRef, Seq[Any])]( + Array(true, false, true) -> Seq(true, false, true), + Array('a', 'b') -> Seq('a', 'b'), + Array[Short](1, -2) -> Seq[Short](1, -2), + Array(1, -2, 3) -> Seq(1, -2, 3), + Array(1L, Long.MaxValue) -> Seq(1L, Long.MaxValue), + Array(1.25f, Float.NaN) -> Seq(1.25f, Float.NaN), + Array(1.25d, Double.NaN) -> Seq(1.25d, Double.NaN), + Array[Byte](1, -2) -> Seq[Byte](1, -2), + Array[AnyRef]("a", Integer.valueOf(3)) -> Seq("a", Integer.valueOf(3)), + Array.empty[Int] -> Seq.empty, + Array.empty[AnyRef] -> Seq.empty) + + cases.foreach { case (array, elements) => + val legacyResult = legacyHash(seed, array) + legacyResult should ===(fold(seed, elements)) + HashCode.hash(seed, array) should ===(legacyResult) + } + } + + "preserve the existing failure for null reference-array elements" in { + intercept[IllegalArgumentException] { + HashCode.hash(HashCode.SEED, Array[AnyRef](null)) + }.getMessage should include("Unexpected hash parameter: null") + } + + "preserve recursive hashing for nested arrays" in { + val seed = HashCode.SEED + val afterNestedInts = fold(seed, Seq(1, 2, 3)) + val expected = HashCode.hash(afterNestedInts, "tail") + + HashCode.hash(seed, Array[AnyRef](Array(1, 2, 3), "tail")) should ===(expected) + } + } +} diff --git a/actor/src/main/scala/org/apache/pekko/util/HashCode.scala b/actor/src/main/scala/org/apache/pekko/util/HashCode.scala index fa52f1ff0fb..ded3f0bba2c 100644 --- a/actor/src/main/scala/org/apache/pekko/util/HashCode.scala +++ b/actor/src/main/scala/org/apache/pekko/util/HashCode.scala @@ -14,7 +14,6 @@ package org.apache.pekko.util import java.lang.{ Double => JDouble, Float => JFloat } -import java.lang.reflect.{ Array => JArray } /** * Set of methods which allow easy implementation of hashCode. @@ -47,7 +46,7 @@ object HashCode { var result = seed if (value eq null) result = hash(result, 0) else if (!isArray(value)) result = hash(result, value.hashCode()) - else for (id <- 0 until JArray.getLength(value)) result = hash(result, JArray.get(value, id)) // is an array + else result = hashArray(result, value) result case unexpected => throw new IllegalArgumentException(s"Unexpected hash parameter: $unexpected") // will not happen, for exhaustiveness check @@ -61,5 +60,67 @@ object HashCode { private def firstTerm(seed: Int): Int = PRIME * seed private def isArray(anyRef: AnyRef): Boolean = anyRef.getClass.isArray + private def hashArray(seed: Int, value: AnyRef): Int = { + var result = seed + value match { + case array: Array[AnyRef] => + var index = 0 + while (index < array.length) { + result = hash(result, array(index)) + index += 1 + } + case array: Array[Boolean] => + var index = 0 + while (index < array.length) { + result = hash(result, array(index)) + index += 1 + } + case array: Array[Char] => + var index = 0 + while (index < array.length) { + result = hash(result, array(index)) + index += 1 + } + case array: Array[Short] => + var index = 0 + while (index < array.length) { + result = hash(result, array(index)) + index += 1 + } + case array: Array[Int] => + var index = 0 + while (index < array.length) { + result = hash(result, array(index)) + index += 1 + } + case array: Array[Long] => + var index = 0 + while (index < array.length) { + result = hash(result, array(index)) + index += 1 + } + case array: Array[Float] => + var index = 0 + while (index < array.length) { + result = hash(result, array(index)) + index += 1 + } + case array: Array[Double] => + var index = 0 + while (index < array.length) { + result = hash(result, array(index)) + index += 1 + } + case array: Array[Byte] => + var index = 0 + while (index < array.length) { + result = hash(result, array(index)) + index += 1 + } + case unexpected => + throw new IllegalArgumentException(s"Unexpected array hash parameter: $unexpected") + } + result + } private val PRIME = 37 } diff --git a/bench-jmh/src/main/scala/org/apache/pekko/util/HashCodeArrayBenchmark.scala b/bench-jmh/src/main/scala/org/apache/pekko/util/HashCodeArrayBenchmark.scala new file mode 100644 index 00000000000..9b10bba701b --- /dev/null +++ b/bench-jmh/src/main/scala/org/apache/pekko/util/HashCodeArrayBenchmark.scala @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.pekko.util + +import java.util.concurrent.TimeUnit + +import org.openjdk.jmh.annotations._ + +@State(Scope.Benchmark) +@BenchmarkMode(Array(Mode.AverageTime)) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +class HashCodeArrayBenchmark { + + @Param(Array("16", "256")) + var size: Int = _ + + private var ints: Array[Int] = _ + private var refs: Array[AnyRef] = _ + + @Setup + def setup(): Unit = { + ints = Array.tabulate(size)(identity) + refs = Array.tabulate[AnyRef](size)(index => Integer.valueOf(index)) + } + + @Benchmark + def hashIntArray(): Int = HashCode.hash(HashCode.SEED, ints) + + @Benchmark + def hashReferenceArray(): Int = HashCode.hash(HashCode.SEED, refs) +}