JDK-8277175 : Add a parallel multiply method to BigInteger#6409
JDK-8277175 : Add a parallel multiply method to BigInteger#6409kabutz wants to merge 14 commits into
Conversation
…cations to run in parallel
|
👋 Welcome back kabutz! A progress list of the required criteria for merging this PR into |
Webrevs
|
|
It isn't clear to me that parallelMultiply should be a public method as opposed to an implementation detail. |
Hi Joe, thanks for responding. I would have preferred for it to be an implementation detail, but I thought it best to keep things consistent with other parts of the JDK. For example:
One could do it as an implementation detail, with a special flag to turn off the functionality. But that would mean that the parallelMultiply() would be on for all multiplications in a system. I would expect that some organizations would for whatever reason not want to use more cores than absolutely necessary? |
Hi Heinz, As you cite, there are a few other cases in the JDK API were a second "parallelFoo" method is exposed. However, I don't think those precedents would necessarily mandate a parallelMultiply method in BigInteger. Without a separate method, there is a question of tuning of course. |
Hi Joe, I guess with sorting it makes sense to have two different methods, because it is usually quite a bit slower to parallel sort an already sorted array. Similarly, if the array is short, we don't want to unnecessarily default to trying to do it in parallel. This is different. By the time we are doing a Toom-Cook calculation, we already have thousands of bits that we are multiplying together, thus a rather large number. In all likelihood doing the multiplication in parallel will always be faster, provided that we have enough cores. In that case though, we would need a fall-back in case we have no threads in the common FJP. We could decide that when the multiply() is called and then do the calculation sequentially. We probably also need to be able to turn it off entirely. Perhaps with something like -Djava.math.BigInteger.disableParallelMultiply=true. Heinz |
|
Mailing list message from Simon Roberts on core-libs-dev: Is there not also an architectural consideration here? I'm inclined to On Tue, Nov 16, 2021 at 1:20 PM kabutz <duke at openjdk.java.net> wrote: -- |
Exactly, which is why we would need a kill-switch (or an on-switch) if we don't have a public parallelMultiply() method. It would then be either on or off for all multiplies, albeit only for large numbers. |
|
I would be wary to make any API use multiple threads behind the scenes without the user explicitly asking for it. While latency of the given operation might improve in isolation, parallelization always incur some (often significant) additional cost of computation. This might reduce power efficiency, reduce CPU availability for other tasks, and play tricks with scalability. Cost: On my system Scalability: To simulate how well the solution scales you could try running the I'd favor a public (Nit: avoid appending flags to microbenchmarks that aren't strictly necessary for the tests, or such that can be reasonably expected to be within bounds on any test system. I didn't have 16Gb of free RAM.) |
+1 on everything you said. We tell people not to oversaturate their CPUs because latencies then go out of the window then. It is then not helpful when the JDK internals automagically oversaturates the machine for you even though you didn't ask for it. And I can imagine a class like BigInteger being used in latency critical applications. It definitely ought to be an opt in, as opposed to a global opt out behind some flag that 99.9% of users dont know about and shouldn't have to know about, to do the expected thing. Principle of least surprise holds IMHO. Also, this is seemingly only a performance win if the rest of the machine is idle. It is far from obvious that the idling machine is in greater need of better performance, compared to the dangerously saturated machine. My gut feeling would be that it is the other way around. That might be another reason why it might not be a suitable default behaviour IMO. |
|
I also do not like potentially non-obvious default behavior, nor a command line flag, nor a (static) setting on Would adding a parameter in the nature of |
For the parallel supported features we added in Java 8 we made it explicit for the reasons you and others have stated.
Kind of but i would recommend not doing it. That's hard to express in a manner that developers will choose appropriate values across all deployments. This is why you don't see such configuration for parallel streams or the parallel array operations. It's controlled by the common pool parallelism configuration (developers have learnt the trick of running within some constrained F/J task to limit parallelism but that is unsupported behaviour). The closest we get to anything configurable is a parallelism threshold for methods on -- I would like to get a sense of how common it might be that developers operate on very large numbers that this becomes worthwhile while supporting. The implementation looks reasonable and quite cleverly minimal, but I think it would be useful to get a sense of whether the recursion goes beyond some proportion of # runtime processors after which there is likely no point in creating more recursive tasks e.g. from the size in bits of the inputs can we determine a useful approximate threshold? |
|
Mailing list message from Brian Burkhalter on core-libs-dev: On Nov 17, 2021, at 11:14 AM, Paul Sandoz <psandoz at openjdk.java.net<mailto:psandoz at openjdk.java.net>> wrote: Would adding a parameter in the nature of `maxProcessors` make any sense? Kind of but i would recommend not doing it. That's hard to express in a manner that developers will choose appropriate values across all deployments. This is why you don't see such configuration for parallel streams or the parallel array operations.[?] Thanks for the background. ? That sounds like a good idea. |
Furthermore, since it uses the common FJP by default, any simultaneous parallel execution (parallel sort, parallel streams, etc.) would decrease the performance.
The reason I would prefer a public method to a flag is that it might be useful to enable parallel multiply on a case-by-case basis. With a flag, it is an all-or-nothing approach. If it has to be a flag, then I'd agree that we should have to opt it in.
Thanks, will change that. Fun fact - the book Optimizing Java has a graph in the introduction that refers to some tests I did on Fibonacci a long time ago. The GC was dominating because the spaces were too small. However, in that case I was calculating Fibonacci of 1 billion. For "just" 100m, we don't need as much memory. Here is the amount of memory that each of the Fibonacci calculations allocate. For the 100m calculation, the resident set size for multiply() is about 125mb and for parallelMultiply() about 190mb. |
Considering how much memory is allocated by BigInteger, I'd be surprised if latency critical applications used it. Besides, we are talking about rather large calculations, with numbers that are thousands of bits large. But of course I agree that opt-in would be better.
This is why I tried to keep the behaviour similar to what we are used to - parallel streams have to be explicitly opted in and I hardly ever do it in real code. Same with parallelSort() |
The "unsupported behavior" is described in the fork() method of ForkJoinTask: "Arranges to asynchronously execute this task in the pool the current task is running in, if applicable, or using the {@link ForkJoinPool#commonPool()} if not {@link #inForkJoinPool}" So yes, there is that workaround if someone really wants more threads for the calculation. Or they can increase the common pool parallelism with the system property.
AFAIK that concurrencyLevel is not really used anymore for the CHM since Java 8, except to have at least that many initial bins:
I will look at this and get back to you. Usually by the time we get to Toom Cook 3, the chunks that we are working with are so large that the RecursiveTask costs are probably not significant in comparison. But I will check. |
|
One concern I had was what would happen when the common pool parallelism was set to 0. In the parallelSort mechanism, they only sort in parallel if the parallelism is at least 2. Thus a parallel sort on a dual-core machine (without hyperthreading) will run sequentially. However, the parallelBigInteger seems to work OK with different common pool sizes. We always have one thread more than the common pool parallelism working, since the thread that calls parallelMultiply() also does calculations: Showing only the cases for Fibonacci(100m): |
|
To add my 2c IMO a parallel version of this type absolutely must be opt-in. There are simply far too many side-effects of using the FJP and multiple threads to perform the calculation in parallel as if it is just a minor implementation detail. A clear API is 1000x better than a "kill switch". And yes you may still need to expose some kind of tuning knob. David |
Yes, it must be opt-in. However I'm not sure that a tuning knob will be necessary. BigInteger has thresholds for using different multiply algorithms and these are also not configurable. |
|
Mailing list message from Bernd Eckenfels on core-libs-dev: What about a new API multiply method which takes an forkjoinpool, and only if that is used/specified it will use the parallel mode (and only if Notsitzes threshold applies?). Most of the pool tuning can then done with this argument. It also avoids surprise threads. Gruss On Thu, 18 Nov 2021 07:26:45 GMT, David Holmes <dholmes at openjdk.org> wrote:
Yes, it **must** be opt-in. However I'm not sure that a tuning knob will be necessary. BigInteger has thresholds for using different multiply algorithms and these are also not configurable. ------------- PR: https://git.openjdk.java.net/jdk/pull/6409 |
|
Mailing list message from Remi Forax on core-libs-dev: ----- Original Message -----
You don't need it, here is the usual trick if you want to specify a specific fork join pool
regards, |
|
Mailing list message from Bernd Eckenfels on core-libs-dev: Yes but that does not help with the decision if parallel should be used or not. But yes, if it is generally not wanted to make the pool explicite a simple parallel signature without argument would also work to make the decision explicite (I.e. new api). That won?t automatically tune the performance but it does allow users to use it - majority would be crypto anyway where it can be used by the JCE and JSSE (maybe?). Gruss -- ----- Original Message -----
You don't need it, here is the usual trick if you want to specify a specific fork join pool
regards, |
| for (int n = 0; n <= 10; n++) { | ||
| BigInteger fib = fibonacci(n, BigInteger::multiply); | ||
| System.out.printf("fibonacci(%d) = %d%n", n, fib); | ||
| } |
There was a problem hiding this comment.
I think we can remove this and the loop block at #70-80, since we have the performance test. After that we are good.
I'm working on some results for the question by Joe about the latency vs CPU usage for the parallelMultiply() vs multiply() methods. It wasn't so easy, because measuring a single thread is easier than all of the FJP threads. But I have a nice benchmark that I'm running now. I had to write my own harness and not use JMH, because I don't think that JMH can test at that level. I'm also measuring object allocation. Furthermore, I'm testing against all Java versions going back to Java 8, to make sure that we don't get any surprises. Here is my version: I will upload the results for all the Java versions later, and will also submit the benchmark. |
|
I have added a benchmark for checking performance difference between sequential and parallel multiply of very large Mersenne primes using BigInteger. We want to measure real time, user time, system time and the amount of memory allocated. To calculate this, we create our own thread factory for the common ForkJoinPool and then use that to measure user time, cpu time and bytes allocated. We use reflection to discover all methods that match "*ultiply", and use them to multiply two very large Mersenne primes together. Results on a 1-6-2 machine running Ubuntu linuxMemory allocation increased from 83.9GB to 84GB, for both the sequential and parallel versions. This is an increase of just 0.1%. On this machine, the parallel version was 3.8x faster in latency (real time), but it used 2.7x more CPU resources. Testing multiplying Mersenne primes of 2^57885161-1 and 2^82589933-1 openjdk version "18-internal" 2022-03-15openjdk version "1.8.0_302"openjdk version "9.0.7.1"openjdk version "10.0.2" 2018-07-17openjdk version "11.0.12" 2021-07-20 LTSopenjdk version "12.0.2" 2019-07-16openjdk version "13.0.9" 2021-10-19openjdk version "14.0.2" 2020-07-14openjdk version "15.0.5" 2021-10-19openjdk version "16.0.2" 2021-07-20openjdk version "17" 2021-09-14 |
|
@kabutz thanks for the additional testing, kind of what we intuitively expected. Can you please update the specification in response to Joe's comment? Generally for parallel constructs we try to say as little as possible with regards to latency, CPU time, and memory. The first two are sort of obvious, the later less so for the developer. From your results I think can say a little more. Here a suggestive update addressing Joe's comments: /**
* Returns a BigInteger whose value is {@code (this * val)}.
* When both {@code this} and {@code val} are large, typically
* in the thousands of bits, parallel multiply might be used.
* This method returns the exact same mathematical result as {@link #multiply}.
*
* @implNote This implementation may offer better algorithmic
* performance when {@code val == this}.
*
* @implNote Compared to {@link #multiply} this implementation's parallel multiplication algorithm
* will use more CPU resources to compute the result faster, with a relatively small increase memory
* consumption.
*
* @param val value to be multiplied by this BigInteger.
* @return {@code this * val}
* @see #multiply
*/ |
Yes, my intention is that the parallelMultiply spec give some guidance to the user on when to use it and warning about the consequences of doing so (same answer, should be in less time, but more compute and possibly a bit more memory). |
|
The multiply() and parallelMultiply() use the exact same amount of memory now. However, they both use a little bit more than the previous multiply() method when the numbers are very large. We tried various approaches to keep the memory usage the same for non-parallel multiply(), but the solutions were not elegant. Since the small memory increase is only when the object allocation is huge, the extra memory did not make a difference. For small numbers, multiply() and parallelMultiply() are exactly the same as the old multiply(). multiply() thus has the same latency and CPU consumption as before. A question about wording of the @implNote. In multiply() they say: "An implementation may offer better algorithmic ...", but we changed this to "This implementation may offer better algorithmic ..." I've kept it as "This implementation may ...", but what is the better way of writing such implementation notes? |
| * @implNote Compared to {@link #multiply}, this implementation's | ||
| * parallel multiplication algorithm will use more CPU resources | ||
| * to compute the result faster, with no increase in memory | ||
| * consumption. |
There was a problem hiding this comment.
The implNote should cover a space of possible parallel multiply implementations so it doesn't have to be updated as often as the implementation is tuned or adjusted. So I'd prefer to have a statement like "may use more memory" even if the current implementation doesn't actually use more memory. If there are any "contraindications" on when to use the method, they could be listed here too.
There was a problem hiding this comment.
@kabutz I approved, but can you address Joe's comment, then i will update the CSR.
I usually refer to "This implementation or this method" mostly out of habit of writing a bunch of these notes in |
That makes sense - thank you so much for your patience :-) |
|
@kabutz This change now passes all automated pre-integration checks. ℹ️ This project also has non-automated pre-integration requirements. Please see the file CONTRIBUTING.md for details. After integration, the commit message for the final commit will be: You can use pull request commands such as /summary, /contributor and /issue to adjust it as needed. At the time when this comment was updated there had been 1033 new commits pushed to the
As there are no conflicts, your changes will automatically be rebased on top of these commits when integrating. If you prefer to avoid this automatic rebasing, please check the documentation for the /integrate command for further details. As you do not have Committer status in this project an existing Committer must agree to sponsor your change. Possible candidates are the reviewers of this PR (@PaulSandoz) but any other Committer may sponsor as well. ➡️ To flag this PR as ready for integration with the above commit message, type |
|
/integrate |
|
/sponsor |
|
Going to push as commit 83ffbd2.
Your commit was automatically rebased without conflicts. |
|
@PaulSandoz @kabutz Pushed as commit 83ffbd2. 💡 You may see a message that your pull request was closed with unmerged commits. This can be safely ignored. |
BigInteger currently uses three different algorithms for multiply. The simple quadratic algorithm, then the slightly better Karatsuba if we exceed a bit count and then Toom Cook 3 once we go into the several thousands of bits. Since Toom Cook 3 is a recursive algorithm, it is trivial to parallelize it. I have demonstrated this several times in conference talks. In order to be consistent with other classes such as Arrays and Collection, I have added a parallelMultiply() method. Internally we have added a parameter to the private multiply method to indicate whether the calculation should be done in parallel.
The performance improvements are as should be expected. Fibonacci of 100 million (using a single-threaded Dijkstra's sum of squares version) completes in 9.2 seconds with the parallelMultiply() vs 25.3 seconds with the sequential multiply() method. This is on my 1-8-2 laptop. The final multiplications are with very large numbers, which then benefit from the parallelization of Toom-Cook 3. Fibonacci 100 million is a 347084 bit number.
We have also parallelized the private square() method. Internally, the square() method defaults to be sequential.
Some benchmark results, run on my 1-6-2 server:
We can see that for larger calculations (fib 100m), the execution is 2.7x faster in parallel. For medium size (fib 10m) it is 1.873x faster. And for small (fib 1m) it is roughly the same. Considering that the fibonacci algorithm that we used was in itself sequential, and that the last 3 calculations would dominate, 2.7x faster should probably be considered quite good on a 1-6-2 machine.
Progress
Issues
Reviewers
Reviewing
Using
gitCheckout this PR locally:
$ git fetch https://git.openjdk.java.net/jdk pull/6409/head:pull/6409$ git checkout pull/6409Update a local copy of the PR:
$ git checkout pull/6409$ git pull https://git.openjdk.java.net/jdk pull/6409/headUsing Skara CLI tools
Checkout this PR locally:
$ git pr checkout 6409View PR using the GUI difftool:
$ git pr show -t 6409Using diff file
Download this PR as a diff file:
https://git.openjdk.java.net/jdk/pull/6409.diff