Skip to content

Wasm: Implement PriorityQueue without js.Array in Wasm backend #5168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

tanishiking
Copy link
Contributor

@tanishiking tanishiking commented May 15, 2025

Simliar to ju.ArrayList, this commit adds a Wasm version of internal storage implementation to remove JS interop for better performance.


Branched from #5164

This implementation uses the same technique as parseFloat to simplify having the two implementation for different platforms. see the conversation #5164 (comment)
Also, this is a draft PR for now, because without linkTimeIf it seems that the optimizer fails to de-virtualize the function calls of methods on InnerArrayImpl. Waiting for it
5e842d8


// The index 0 is not used; the root is at index 1.
// This is standard practice in binary heaps, to simplify arithmetics.
private var _size = 1
Copy link
Member

Choose a reason for hiding this comment

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

Something went wrong during the refactoring, because this state variable was moved to the global impl object. 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, thanks, moved back to the PriorityQueue

@tanishiking tanishiking force-pushed the priorityqueue-wasm branch 4 times, most recently from 6f8093e to ea2171f Compare May 17, 2025 04:36
@tanishiking tanishiking marked this pull request as ready for review May 17, 2025 05:09
@tanishiking tanishiking force-pushed the priorityqueue-wasm branch from ea2171f to bca2c21 Compare May 23, 2025 13:31
@tanishiking tanishiking force-pushed the priorityqueue-wasm branch from bca2c21 to da5c453 Compare June 6, 2025 05:49
@tanishiking tanishiking requested a review from sjrd June 6, 2025 05:50
Copy link
Member

@sjrd sjrd left a comment

Choose a reason for hiding this comment

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

Partial review. We need to address the _size issue before going further.

Comment on lines 338 to 337

private sealed abstract class InnerArrayImpl {
Copy link
Member

Choose a reason for hiding this comment

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

This should be in the companion object of PriorityQueue, so that instances do not depend on the enclosing PriorityQueue object.

Suggested change
private sealed abstract class InnerArrayImpl {
}
object PriorityQueue {
private sealed abstract class InnerArrayImpl {

Copy link
Member

Choose a reason for hiding this comment

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

Ah! I now understand that they actually refer to the enclosing object for _size. 😱 That's super confusing: the dependencies on inner are made explicit (params), but those on _size are implicit (scope).

We should also pass _size explicitly. But then that poses an issue for methods that need to return a new Repr and a new size. 😖

Here is a crazy idea: we have an unused slot at index 0 of the array. What if we use it to store the size on Wasm? It would be boxed, but unless it exceeds 2^30, it will fit in a (ref i31), so no JS interop should occur. (and if it does exceed 2^30, I don't think that's going to be the bottleneck :p).

This way we still have a single Repr that embodies both the size and the capacity, on Wasm as well as on JS.

Copy link
Contributor Author

@tanishiking tanishiking Jun 12, 2025

Choose a reason for hiding this comment

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

Thanks! I think it's reasonable technique to re-use the free space to store size information 👍
Here I make a prototype implementation for moving InnerArrayImpl to companion object, and store size to array(0) 0832ba7

* the function calls.
*/

private val innerImpl: InnerArrayImpl =
Copy link
Member

Choose a reason for hiding this comment

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

It is unfortunate to store this field inside instances of PriorityQueue. I think this can be avoided by putting this private val in the companion object of PriorityQueue.

If my prediction is correct, that companion will entirely disappear after optimizations.

import java.lang.Utils.roundUpToPowerOfTwo

import scala.annotation.tailrec
Copy link
Member

Choose a reason for hiding this comment

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

Imports of scala.annotation.* always come first (after scala.language.* imports, if any):

Suggested change
import java.lang.Utils.roundUpToPowerOfTwo
import scala.annotation.tailrec
import scala.annotation.tailrec
import java.lang.Utils.roundUpToPowerOfTwo

Comment on lines 102 to 103
// The index 0 is not used; the root is at index 1.
// This is standard practice in binary heaps, to simplify arithmetics.
Copy link
Member

Choose a reason for hiding this comment

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

We should keep these two lines as comments on private var inner. They apply to JS as well.

Also, that means that the notion of "capacity" is a skewed in this class. The array should have 1 more elements than the capacity, for "capacity" being the external notion of capacity.

Put differently, it is probably easier to think of "capacity" as the internal notion of capacity, which is 1 more than the "public capacity". The public capacity only shows up in def this(initialCapacity) and def this(c: Collection).

{
if (initialCapacity < 1)
throw new IllegalArgumentException
initialCapacity
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
initialCapacity
initialCapacity + 1 // index 0 is unused

@@ -47,47 +62,74 @@ class PriorityQueue[E] private (
NaturalComparator.select(c.comparator().asInstanceOf[Comparator[_ >: E]])
case _ =>
NaturalComparator
}, internal = true)
}, internal = true, roundUpToPowerOfTwo(c.size()))
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
}, internal = true, roundUpToPowerOfTwo(c.size()))
}, internal = true, roundUpToPowerOfTwo(c.size() + 1)) // index 0 is unused

addAll(c)
}

def this(c: PriorityQueue[_ <: E]) = {
this(c.comp.asInstanceOf[Comparator[_ >: E]], internal = true)
this(c.comp.asInstanceOf[Comparator[_ >: E]], internal = true, roundUpToPowerOfTwo(c.size()))
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
this(c.comp.asInstanceOf[Comparator[_ >: E]], internal = true, roundUpToPowerOfTwo(c.size()))
this(c.comp.asInstanceOf[Comparator[_ >: E]], internal = true,
roundUpToPowerOfTwo(c.size() + 1)) // index 0 is unused

addAll(c)
}

def this(sortedSet: SortedSet[_ <: E]) = {
this(NaturalComparator.select(
sortedSet.comparator().asInstanceOf[Comparator[_ >: E]]),
internal = true)
internal = true,
roundUpToPowerOfTwo(sortedSet.size()))
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
roundUpToPowerOfTwo(sortedSet.size()))
roundUpToPowerOfTwo(sortedSet.size() + 1)) // index 0 is unused

Comment on lines +110 to +93
if (LinkingInfo.isWebAssembly) {
val minCapacity = innerImpl.length(inner) + 1
if (innerImpl.capacity(inner) < minCapacity)
inner = innerImpl.resized(inner, minCapacity)
}
Copy link
Member

Choose a reason for hiding this comment

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

This can be moved to JArrayImpl.push.

The use-case of this function is for ensuring the size of internal buffers (e.g. internal Array of `ju.ArrayList`).
Current js.Array-based PriorityQueue implementation on Wasm requires JS-interop for every operation, and JS-interop is very slow.
We use a `linkTimeIf` to select a `scala.Array`-based implementation of internal data structure for PriorityQueue, based on wether it is on JS or WebAssembly.
@tanishiking
Copy link
Contributor Author

tanishiking commented Jun 12, 2025

I measured performance of PriorityQueue using a simple priority-queue micro benchmark with fastLinkJS. It does add/peek/poll 10000 numbers (in order from 0-10000, maybe inappropriate for priorityqueue.)

download

The performance change on wasm (red and purple bars) was okay: add/peek gets a bit slower (probably because of array expansion), but the overall performance with poll is much faster. 🎉

The problem is the execution performance on JS.
As you can see from the blue (main) and orange (this change) graphs, the performance on add/peek is about 10 times slower, and poll is also degraded by 2 times.

null checks on innnerImpl

One problem is that, with linktimeIf, the null check on innerImpl has inserted everywhere $n($m_ju_PriorityQueue$().ju_PriorityQueue$__f_java$util$PriorityQueue$$innerImpl); The diff between main vs this branch, generated from the Scala code below: https://gist.github.com/tanishiking/9d0e24de18e994cae5e8742aa9749d98#file-foo-diff

Interestingly, without linkeTimeIf on innerImpl private val innerImpl: InnerArrayImpl = InnerArrayImpl.JSArrayImpl (green bar), innerImpl gets inlined and there's no null check on it. diff: https://gist.github.com/tanishiking/9d0e24de18e994cae5e8742aa9749d98#file-foo2-diff

linktimeIf should generate the same code as the innerImpl = InnerArrayImpl.JSArrayImpl.

package helloworld

import java.util.PriorityQueue

object HelloWorld {
  def main(args: Array[String]): Unit = {
    val pq = new PriorityQueue[Int]()
    for (element <- List(1,2,3,4,5)) {
      pq.add(element)
    }
  }
}

It's still slower

With private val innerImpl: InnerArrayImpl = InnerArrayImpl.JSArrayImpl, the performance gets better (green bar), but it's still much slower than main.

I'm not sure why it's much slower with this diff 🤔 https://gist.github.com/tanishiking/9d0e24de18e994cae5e8742aa9749d98#file-foo2-diff

@tanishiking tanishiking force-pushed the priorityqueue-wasm branch 2 times, most recently from 471d8b6 to 2ee44af Compare June 12, 2025 07:55
Also, store the size of array in the first element of Array
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