Skip to content
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

JDK-8277095 : Empty streams create too many objects #6275

Closed
wants to merge 11 commits into from

Conversation

kabutz
Copy link
Contributor

@kabutz kabutz commented Nov 5, 2021

This is a draft proposal for how we could improve stream performance for the case where the streams are empty. Empty collections are common-place. If we iterate over them with an Iterator, we would have to create one small Iterator object (which could often be eliminated) and if it is empty we are done. However, with Streams we first have to build up the entire pipeline, until we realize that there is no work to do. With this example, we change Collection#stream() to first check if the collection is empty, and if it is, we simply return an EmptyStream. We also have EmptyIntStream, EmptyLongStream and EmptyDoubleStream. We have taken great care for these to have the same characteristics and behaviour as the streams returned by Stream.empty(), IntStream.empty(), etc.

Some of the JDK tests fail with this, due to ClassCastExceptions (our EmptyStream is not an AbstractPipeline) and AssertionError, since we can call some methods repeatedly on the stream without it failing. On the plus side, creating a complex stream on an empty stream gives us upwards of 50x increase in performance due to a much smaller object allocation rate. This PR includes the code for the change, unit tests and also a JMH benchmark to demonstrate the improvement.


Progress

  • Change must not contain extraneous whitespace
  • Commit message must refer to an issue
  • Change must be properly reviewed

Issue

Reviewing

Using git

Checkout this PR locally:
$ git fetch https://git.openjdk.java.net/jdk pull/6275/head:pull/6275
$ git checkout pull/6275

Update a local copy of the PR:
$ git checkout pull/6275
$ git pull https://git.openjdk.java.net/jdk pull/6275/head

Using Skara CLI tools

Checkout this PR locally:
$ git pr checkout 6275

View PR using the GUI difftool:
$ git pr show -t 6275

Using diff file

Download this PR as a diff file:
https://git.openjdk.java.net/jdk/pull/6275.diff

@bridgekeeper
Copy link

bridgekeeper bot commented Nov 5, 2021

👋 Welcome back kabutz! A progress list of the required criteria for merging this PR into master will be added to the body of your pull request. There are additional pull request commands available for use with this pull request.

@openjdk
Copy link

openjdk bot commented Nov 5, 2021

@kabutz The following label will be automatically applied to this pull request:

  • core-libs

When this pull request is ready to be reviewed, an "RFR" email will be sent to the corresponding mailing list. If you would like to change these labels, use the /label pull request command.

@pavelrappo
Copy link
Member

Streams are closeable, and a terminal operation may be invoked on a given stream only once. Thus, shouldn't the third line in both of the examples below throw IllegalStateException?

    Stream<Object> empty = Stream.empty();
    System.out.println(empty.count());
    System.out.println(empty.count());

    Stream<Object> empty = Stream.empty();
    empty.close();
    System.out.println(empty.count());

@pavelrappo
Copy link
Member

I don't think that we can remove all the state from an empty stream, but we can likely make it smaller.

@kabutz
Copy link
Contributor Author

kabutz commented Nov 6, 2021

Streams are closeable, and a terminal operation may be invoked on a given stream only once. Thus, shouldn't the third line in both of the examples below throw IllegalStateException?

    Stream<Object> empty = Stream.empty();
    System.out.println(empty.count());
    System.out.println(empty.count());

    Stream<Object> empty = Stream.empty();
    empty.close();
    System.out.println(empty.count());

That would be fairly easy to solve by having two instances of the EmptyStream. The terminal operations would return the terminal operation that throws IllegalStateExceptions.

@@ -740,6 +740,7 @@ default boolean removeIf(Predicate<? super E> filter) {
* @since 1.8
*/
default Stream<E> stream() {
if (isEmpty()) return Stream.empty();
Copy link
Member

@pavelrappo pavelrappo Nov 6, 2021

Choose a reason for hiding this comment

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

The net effect of this change might depend on your workload. If you call stream() on empty collections that have cheap isEmpty(), this change will likely improve performance and reduce waste. However, this same change might do the opposite if some of your collections aren't empty or have costly isEmpty(). It would be good to have benchmarks for different workloads.

Copy link
Contributor

Choose a reason for hiding this comment

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

wouldn't this make streams no longer lazy if the collection is empty?

        List<String> list = new ArrayList<>();
        Stream<String> stream = list.stream();

        list.addAll(List.of("one", "two", "three"));

        stream.forEach(System.out::println); // prints one two three

Copy link
Contributor

Choose a reason for hiding this comment

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

(immutable collections could override stream() instead, since they don't have that problem)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The net effect of this change might depend on your workload. If you call stream() on empty collections that have cheap isEmpty(), this change will likely improve performance and reduce waste. However, this same change might do the opposite if some of your collections aren't empty or have costly isEmpty(). It would be good to have benchmarks for different workloads.

Yes, I also thought about the cost of isEmpty() on concurrent collections. There are four concurrent collections that have a linear time cost size() method: CLQ, CLD, LTQ and CHM. However, in each of these cases, the isEmpty() method has constant time cost. There might be collections defined outside the JDK where this could be the case.

However, I will extend the benchmark to include a few of those cases too, as well as different sizes and collection sizes.

Thank you so much for your input.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

wouldn't this make streams no longer lazy if the collection is empty?

        List<String> list = new ArrayList<>();
        Stream<String> stream = list.stream();

        list.addAll(List.of("one", "two", "three"));

        stream.forEach(System.out::println); // prints one two three

I did not consider this case, thank you for bringing it up. I have always found this behaviour a bit strange and have never used it "in the real world". It is also not consistent between collections. Here is an example with four collections: ArrayList, CopyOnWriteArrayList, ConcurrentSkipListSet and ArrayBlockingQueue:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Supplier;
import java.util.stream.IntStream;

public class LazyStreamDemo {
    public static void main(String... args) {
        List<Supplier<Collection<String>>> suppliers =
                List.of(ArrayList::new, // fast-fail
                        CopyOnWriteArrayList::new, // snapshot
                        ConcurrentSkipListSet::new, // weakly-consistent
                        () -> new ArrayBlockingQueue<>(10) // weakly-consistent
                );
        for (Supplier<Collection<String>> supplier : suppliers) {
            Collection<String> c = supplier.get();
            System.out.println(c.getClass());
            IntStream stream = c.stream()
                    .sorted()
                    .filter(Objects::nonNull)
                    .mapToInt(String::length)
                    .sorted();

            c.addAll(List.of("one", "two", "three", "four", "five"));
            System.out.println("stream = " + Arrays.toString(stream.toArray()));
        }
    }
}

The output is:

class java.util.ArrayList
stream = [3, 3, 4, 4, 5]
class java.util.concurrent.CopyOnWriteArrayList
stream = []
class java.util.concurrent.ConcurrentSkipListSet
stream = []
class java.util.concurrent.ArrayBlockingQueue
stream = [3, 3, 4, 4, 5]

At least with the EmptyStream we would have consistent output of always []

Copy link
Contributor

Choose a reason for hiding this comment

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

@kabutz I agree that i wouldn't consider it clean code to use a stream like i put into the example. I only brought it up because it might break existing code, since i think streams are expected to be lazy. Interesting to see that they are in fact not lazy in all situations - i put that into my notes.

Copy link
Contributor

@anthonyvdotbe anthonyvdotbe Nov 10, 2021

Choose a reason for hiding this comment

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

Edit: actually I think the implementation of Collection::stream could simply be changed to:

default Stream<E> stream() {
    var spliterator = spliterator();
    if(spliterator.hasCharacteristics(Spliterator.IMMUTABLE | Spliterator.CONCURRENT) && isEmpty()) {
        return Stream.empty();
    }
    return StreamSupport.stream(spliterator, false);
}

Note that the spliterators of methods such as Collections::emptyList, List::of, List::copyOf, Set::of, ... currently don't have the IMMUTABLE characteristic though, so they should be updated.


The Javadoc for java.util.stream is clear though:

Traversal of the pipeline source does not begin until the terminal operation of the pipeline is executed.

and further on says:

Spliterators for mutable data sources have an additional challenge; timing of binding to the data, since the data could change between the time the spliterator is created and the time the stream pipeline is executed. Ideally, a spliterator for a stream would report a characteristic of IMMUTABLE or CONCURRENT; if not it should be late-binding.

which explains why the collections in java.util.concurrent (whose spliterators have one of those characteristics) need not be late-binding.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @anthonyvdotbe I believe that would satisfy the lazy binding, and then we should increase the use of the IMMUTABLE characteristic where it makes sense.

@bourgesl
Copy link
Contributor

bourgesl commented Nov 7, 2021

Looks a good idea to reduce the pipeline overhead !

@kabutz
Copy link
Contributor Author

kabutz commented Nov 7, 2021

I've added far more JMH tests to check for stream size of [0, 1, 10, 100], then different types of streams, from minimal to complex, then five different collections ArrayList, ConcurrentLinkedQueue, ConcurrentSkipListSet, CopyOnWriteArrayList, ConcurrentHashMap.newKeySet() and lastly with Stream.empty() that has the new behavior and StreamSupport.stream(...) that was the old behavior.

With all this multiplication of test options, it will take a couple of days to run on my server. Will let you know if anything surprising pops up.

@kabutz
Copy link
Contributor Author

kabutz commented Nov 12, 2021

Thanks for your excellent suggestions. It seemed that we cannot get away from making some objects. I've got a new version in the works that makes EmptyStream instances each time, with their own state. The performance is still good. For a simple stream pipeline, it is roughly twice as fast for an empty stream. There is no noticeable slow-down for non-empty streams.

EmptyStreams now follow the same behavior as we would get if we created the stream with StreamSupport.stream(collection.spliterator(), false). For example, we cannot call filter() / map() etc. twice on the same stream. We can call unordered() twice on the same stream, but only if it was not ORDERED to begin with.

Streams are not created lazily yet in my updated version, but I'm hoping it is a step in the right direction.

I'm running an extensive JMH suite overnight to compare object allocation rates and throughput for the streams.

@kabutz
Copy link
Contributor Author

kabutz commented Nov 13, 2021

Here are the maximum throughput numbers for the JMH benchmark for various lengths, collections, type of stream decorations and whether it is the old style stream creation or the new EmptyStream. We also see the speedup with the new system. There are some strange effects where we see small differences once we get past 0 length, which is most likely to be explained because of noise in the benchmark.

The speedup in the benchmark for empty streams seems to be from about 2x for minimal stream decoration through to about 9x for the more complex kind.

a_length, b_typeOfCollection, c_typeOfStreamDecoration, d_streamCreation, max op/ms, speedup
0, ArrayList, minimal, old, 20972.400
0, ArrayList, minimal, new, 44866.020, 2.13x
0, ArrayList, basic, old, 19219.077
0, ArrayList, basic, new, 47574.102, 2.47x
0, ArrayList, complex, old, 9425.247
0, ArrayList, complex, new, 44850.655, 4.75x
0, ArrayList, crossover, old, 4749.495
0, ArrayList, crossover, new, 44550.110, 9.37x
0, ConcurrentLinkedQueue, minimal, old, 20146.717
0, ConcurrentLinkedQueue, minimal, new, 36154.586, 1.79x
0, ConcurrentLinkedQueue, basic, old, 18107.648
0, ConcurrentLinkedQueue, basic, new, 36094.319, 1.99x
0, ConcurrentLinkedQueue, complex, old, 9033.936
0, ConcurrentLinkedQueue, complex, new, 36089.520, 3.99x
0, ConcurrentLinkedQueue, crossover, old, 4555.928
0, ConcurrentLinkedQueue, crossover, new, 35932.132, 7.88x
0, ConcurrentSkipListSet, minimal, old, 20391.479
0, ConcurrentSkipListSet, minimal, new, 40259.694, 1.97x
0, ConcurrentSkipListSet, basic, old, 18616.301
0, ConcurrentSkipListSet, basic, new, 40914.132, 2.19x
0, ConcurrentSkipListSet, complex, old, 9853.569
0, ConcurrentSkipListSet, complex, new, 40150.606, 4.07x
0, ConcurrentSkipListSet, crossover, old, 4632.691
0, ConcurrentSkipListSet, crossover, new, 40151.534, 8.66x
0, CopyOnWriteArrayList, minimal, old, 19952.257
0, CopyOnWriteArrayList, minimal, new, 36884.796, 1.84x
0, CopyOnWriteArrayList, basic, old, 18228.390
0, CopyOnWriteArrayList, basic, new, 36993.146, 2.02x
0, CopyOnWriteArrayList, complex, old, 9635.404
0, CopyOnWriteArrayList, complex, new, 36862.714, 3.82x
0, CopyOnWriteArrayList, crossover, old, 4609.905
0, CopyOnWriteArrayList, crossover, new, 36043.686, 7.81x
0, ConcurrentHashMap, minimal, old, 20158.432
0, ConcurrentHashMap, minimal, new, 42272.848, 2.09x
0, ConcurrentHashMap, basic, old, 19308.950
0, ConcurrentHashMap, basic, new, 42475.514, 2.19x
0, ConcurrentHashMap, complex, old, 9864.942
0, ConcurrentHashMap, complex, new, 42195.862, 4.27x
0, ConcurrentHashMap, crossover, old, 4721.981
0, ConcurrentHashMap, crossover, new, 42055.634, 8.90x

1, ArrayList, minimal, old, 18999.510
1, ArrayList, minimal, new, 19116.471, 1.00x
1, ArrayList, basic, old, 17698.417
1, ArrayList, basic, new, 17246.340, .97x
1, ArrayList, complex, old, 6788.291
1, ArrayList, complex, new, 7456.593, 1.09x
1, ArrayList, crossover, old, 3829.277
1, ArrayList, crossover, new, 3798.721, .99x
1, ConcurrentLinkedQueue, minimal, old, 18535.251
1, ConcurrentLinkedQueue, minimal, new, 18661.126, 1.00x
1, ConcurrentLinkedQueue, basic, old, 12795.769
1, ConcurrentLinkedQueue, basic, new, 16666.953, 1.30x
1, ConcurrentLinkedQueue, complex, old, 5890.533
1, ConcurrentLinkedQueue, complex, new, 6541.393, 1.11x
1, ConcurrentLinkedQueue, crossover, old, 3615.558
1, ConcurrentLinkedQueue, crossover, new, 3738.650, 1.03x
1, ConcurrentSkipListSet, minimal, old, 18574.394
1, ConcurrentSkipListSet, minimal, new, 17797.298, .95x
1, ConcurrentSkipListSet, basic, old, 16500.841
1, ConcurrentSkipListSet, basic, new, 15993.662, .96x
1, ConcurrentSkipListSet, complex, old, 6101.255
1, ConcurrentSkipListSet, complex, new, 6196.274, 1.01x
1, ConcurrentSkipListSet, crossover, old, 3965.991
1, ConcurrentSkipListSet, crossover, new, 3819.579, .96x
1, CopyOnWriteArrayList, minimal, old, 18597.613
1, CopyOnWriteArrayList, minimal, new, 18700.468, 1.00x
1, CopyOnWriteArrayList, basic, old, 16826.926
1, CopyOnWriteArrayList, basic, new, 12694.816, .75x
1, CopyOnWriteArrayList, complex, old, 7089.013
1, CopyOnWriteArrayList, complex, new, 6919.550, .97x
1, CopyOnWriteArrayList, crossover, old, 4100.437
1, CopyOnWriteArrayList, crossover, new, 3773.781, .92x
1, ConcurrentHashMap, minimal, old, 13868.779
1, ConcurrentHashMap, minimal, new, 13791.894, .99x
1, ConcurrentHashMap, basic, old, 12667.393
1, ConcurrentHashMap, basic, new, 9863.169, .77x
1, ConcurrentHashMap, complex, old, 5885.494
1, ConcurrentHashMap, complex, new, 5412.376, .91x
1, ConcurrentHashMap, crossover, old, 3323.164
1, ConcurrentHashMap, crossover, new, 3317.024, .99x

10, ArrayList, minimal, old, 17633.130
10, ArrayList, minimal, new, 17561.076, .99x
10, ArrayList, basic, old, 10293.698
10, ArrayList, basic, new, 9284.825, .90x
10, ArrayList, complex, old, 3757.234
10, ArrayList, complex, new, 3805.699, 1.01x
10, ArrayList, crossover, old, 2355.630
10, ArrayList, crossover, new, 2290.404, .97x
10, ConcurrentLinkedQueue, minimal, old, 13414.620
10, ConcurrentLinkedQueue, minimal, new, 9801.745, .73x
10, ConcurrentLinkedQueue, basic, old, 7155.959
10, ConcurrentLinkedQueue, basic, new, 8075.986, 1.12x
10, ConcurrentLinkedQueue, complex, old, 3343.567
10, ConcurrentLinkedQueue, complex, new, 3516.111, 1.05x
10, ConcurrentLinkedQueue, crossover, old, 2288.908
10, ConcurrentLinkedQueue, crossover, new, 2269.025, .99x
10, ConcurrentSkipListSet, minimal, old, 12725.965
10, ConcurrentSkipListSet, minimal, new, 12450.308, .97x
10, ConcurrentSkipListSet, basic, old, 9063.070
10, ConcurrentSkipListSet, basic, new, 8798.386, .97x
10, ConcurrentSkipListSet, complex, old, 3588.505
10, ConcurrentSkipListSet, complex, new, 3940.039, 1.09x
10, ConcurrentSkipListSet, crossover, old, 2045.747
10, ConcurrentSkipListSet, crossover, new, 2266.930, 1.10x
10, CopyOnWriteArrayList, minimal, old, 13787.391
10, CopyOnWriteArrayList, minimal, new, 13914.875, 1.00x
10, CopyOnWriteArrayList, basic, old, 8697.165
10, CopyOnWriteArrayList, basic, new, 10222.606, 1.17x
10, CopyOnWriteArrayList, complex, old, 3614.631
10, CopyOnWriteArrayList, complex, new, 3406.912, .94x
10, CopyOnWriteArrayList, crossover, old, 2325.973
10, CopyOnWriteArrayList, crossover, new, 2345.024, 1.00x
10, ConcurrentHashMap, minimal, old, 9591.056
10, ConcurrentHashMap, minimal, new, 9145.649, .95x
10, ConcurrentHashMap, basic, old, 6243.206
10, ConcurrentHashMap, basic, new, 7104.218, 1.13x
10, ConcurrentHashMap, complex, old, 3024.104
10, ConcurrentHashMap, complex, new, 2937.575, .97x
10, ConcurrentHashMap, crossover, old, 2082.300
10, ConcurrentHashMap, crossover, new, 1893.942, .90x

100, ArrayList, minimal, old, 4263.776
100, ArrayList, minimal, new, 4305.783, 1.00x
100, ArrayList, basic, old, 1615.447
100, ArrayList, basic, new, 2075.383, 1.28x
100, ArrayList, complex, old, 557.891
100, ArrayList, complex, new, 544.918, .97x
100, ArrayList, crossover, old, 428.983
100, ArrayList, crossover, new, 427.370, .99x
100, ConcurrentLinkedQueue, minimal, old, 1712.969
100, ConcurrentLinkedQueue, minimal, new, 1695.046, .98x
100, ConcurrentLinkedQueue, basic, old, 1681.057
100, ConcurrentLinkedQueue, basic, new, 1515.314, .90x
100, ConcurrentLinkedQueue, complex, old, 608.716
100, ConcurrentLinkedQueue, complex, new, 601.803, .98x
100, ConcurrentLinkedQueue, crossover, old, 414.658
100, ConcurrentLinkedQueue, crossover, new, 420.125, 1.01x
100, ConcurrentSkipListSet, minimal, old, 2938.635
100, ConcurrentSkipListSet, minimal, new, 3037.409, 1.03x
100, ConcurrentSkipListSet, basic, old, 1434.130
100, ConcurrentSkipListSet, basic, new, 1560.646, 1.08x
100, ConcurrentSkipListSet, complex, old, 599.722
100, ConcurrentSkipListSet, complex, new, 608.418, 1.01x
100, ConcurrentSkipListSet, crossover, old, 401.228
100, ConcurrentSkipListSet, crossover, new, 400.425, .99x
100, CopyOnWriteArrayList, minimal, old, 3778.017
100, CopyOnWriteArrayList, minimal, new, 3781.097, 1.00x
100, CopyOnWriteArrayList, basic, old, 2146.891
100, CopyOnWriteArrayList, basic, new, 2149.660, 1.00x
100, CopyOnWriteArrayList, complex, old, 631.061
100, CopyOnWriteArrayList, complex, new, 621.835, .98x
100, CopyOnWriteArrayList, crossover, old, 424.637
100, CopyOnWriteArrayList, crossover, new, 425.788, 1.00x
100, ConcurrentHashMap, minimal, old, 1222.579
100, ConcurrentHashMap, minimal, new, 1221.157, .99x
100, ConcurrentHashMap, basic, old, 851.419
100, ConcurrentHashMap, basic, new, 859.273, 1.00x
100, ConcurrentHashMap, complex, old, 386.231
100, ConcurrentHashMap, complex, new, 391.380, 1.01x
100, ConcurrentHashMap, crossover, old, 343.521
100, ConcurrentHashMap, crossover, new, 339.079, .98x

@@ -5444,7 +5444,9 @@ public static void parallelSetAll(double[] array, IntToDoubleFunction generator)
* @since 1.8
*/
public static <T> Stream<T> stream(T[] array, int startInclusive, int endExclusive) {
return StreamSupport.stream(spliterator(array, startInclusive, endExclusive), false);
var spliterator = spliterator(array, startInclusive, endExclusive);
if (startInclusive == endExclusive) return Stream.empty();
Copy link
Member

Choose a reason for hiding this comment

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

Can't we just add the if statement to before the spliterator is computed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @liach for the suggestion. The spliterator serves the purpose of checking the arguments. For example, if array is null, the method should throw a NPE. Similarly, startInclusive and endExclusive have to be in the range of the array length. If we swap around the lines, someone could call stream(null, -100, -100) and it would happily return an empty stream.

Furthermore, the empty streams can inherit characteristics of a spliterator. In the code you pointed out, I don't do that, but will change that. It makes it much easier to keep the empty streams consistent with their previous behavior. One might argue that it is pointless to call distinct() sorted() and unordered() on an empty stream and expect it to change, but it was easy enough to implement so that we can keep consistency FWIW.

@kabutz kabutz changed the title Faster empty streams JDK-8277095 : Empty streams create too many objects Nov 15, 2021
@openjdk openjdk bot added the rfr Pull request is ready for review label Nov 15, 2021
@mlbridge
Copy link

mlbridge bot commented Nov 15, 2021

Webrevs

@marschall
Copy link
Contributor

I have a similar project at empty-streams. A couple of notes:

  1. I found the need for streams to be stateful. I had need for the following state:
    1. closed
    2. ordered
    3. parallel
    4. sorted
    5. closeHandler
    6. comparator (on EmptyStream)
      A shared instance can not be used because of #close.
  2. I have a PrimitiveIterator that short circuits #next and #forEachRemaining as well.
  3. I made many methods just return this after checking for operated on and closed:
    1. #filter #map, #flatMap, #mapMulti, #distinct, #peek, #limit, #skip, #dropWhile, #takeWhile.
    2. These do a state change state as well #sequential, #parallel, #unordered, #sorted, #onClose.
  4. I made my EmptyBaseStream implement BaseStream and make EmptyIntLongDoubleStream extend from this class as IntLongDoubleStream all extend BaseStream. This allowed me to move the following methods up in the hierarchy #isParallel , #onClose, #sequential, #parallel, #unordered.
  5. Is there any reason why you make #parallel "bail out" to StreamSupport rather than just do a state change?

@marschall
Copy link
Contributor

3. I made many methods just return `this` after checking for operated on and closed:

Reading the Javadoc again I'm not sure this is allowed. The method Javadoc doesn't clearly say it but the package Javadoc for intermediate operations says a new stream is returned.

@kabutz
Copy link
Contributor Author

kabutz commented Nov 15, 2021

I have a similar project at empty-streams. A couple of notes:

  1. I found the need for streams to be stateful. I had need for the following state:

    1. closed
    2. ordered
    3. parallel
    4. sorted

I found the need for ALL the Spliterator characteristics, plus some others that I interleaved between the Spliterator bits. That way I could fit them into a single int.

Parallel was too tricky though, so in that case I return a new stream built with the standard StreamSupport(spliterator, true).

  1. closeHandler

Similarly when someone calls closeHandler(), I also return a new stream built with StreamSupport.

  1. comparator (on EmptyStream)

Yes, we need this for TreeSet and ConcurrentSkipListSet. I was hoping to avoid it, but there is no way around if we want to be completely correct.

  A shared instance can not be used because of `#close`.

Agreed. I fixed that.

  1. I have a PrimitiveIterator that short circuits #next and #forEachRemaining as well.

Oh that's a good suggestion - thanks.

  1. I made many methods just return this after checking for operated on and closed:

    1. #filter #map, #flatMap, #mapMulti, #distinct, #peek, #limit, #skip, #dropWhile, #takeWhile.

I now always return a new EmptyStream. Fortunately escape analysis gets rid of a lot of objects.

  1. These do a state change state as well #sequential, #parallel, #unordered, #sorted, #onClose.

unordered() only does a state change if it currently ORDERED, otherwise it is correct to return this. The logic is convoluted and weird though. I have a more thorough test case that I need to incorporate into my EmptyStreamTest and which tests all the JDK collections, including the wrapper and immutable collections.

  1. I made my EmptyBaseStream implement BaseStream and make EmptyIntLongDoubleStream extend from this class as IntLongDoubleStream all extend BaseStream. This allowed me to move the following methods up in the hierarchy #isParallel , #onClose, #sequential, #parallel, #unordered.

Interesting idea. I'm not sure if that would work for me. I will have a look if I can also do this, but I doubt it. A lot of the methods, especially the primitive ones, are repeats of each other. It might be really nice to put that into a common superclass.

  1. Is there any reason why you make #parallel "bail out" to StreamSupport rather than just do a state change?

I tried to keep the characteristics identical to what we had before and parallel seemed particularly challenging to get right. I might attempt this again at a later stage, but for now I want to keep it like it is. I don't think parallel() is the most common use case, except with very large collections perhaps. Even though I thought I was careful with the characteristics, I still have a few edge cases I need to iron out.

@kabutz
Copy link
Contributor Author

kabutz commented Nov 15, 2021

3. I made many methods just return `this` after checking for operated on and closed:

Reading the Javadoc again I'm not sure this is allowed. The method Javadoc doesn't clearly say it but the package Javadoc for intermediate operations says a new stream is returned.

Yes, I also returned "this" initially to avoid object allocation, but fortunately escape analysis helps get rid of them if they are indeed unnecessary. My object allocation for the new empty streams is minimal.

The one thing that is still hindering me is the lazy creation of streams.

$ jshell
|  Welcome to JShell -- Version 11.0.12
|  For an introduction type: /help intro

jshell> var list = new ArrayList<Integer>()
list ==> []

jshell> var stream = list.stream()
stream ==> java.util.stream.ReferencePipeline$Head@cb5822

jshell> Collections.addAll(list, 1,2,3)
$3 ==> true

jshell> stream.forEach(System.out::print)
123
jshell> 

Does anyone use this "feature"? I hope not, but unfortunately it's the current behavior and we probably have to support it :-(

…improved unordered()

Added StreamSupport.empty for primitive spliterators and use that in Arrays.stream()
@kabutz
Copy link
Contributor Author

kabutz commented Nov 16, 2021

  1. I have a PrimitiveIterator that short circuits #next and #forEachRemaining as well.

Oh that's a good suggestion - thanks.

After looking at this, I decided I'd rather not short-circuit the methods, since they have checking code that I would have to duplicate.

@bridgekeeper
Copy link

bridgekeeper bot commented Dec 14, 2021

@kabutz This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply add a new comment to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@kabutz
Copy link
Contributor Author

kabutz commented Dec 15, 2021

Add another comment to keep this active.

@bridgekeeper
Copy link

bridgekeeper bot commented Jan 12, 2022

@kabutz This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply add a new comment to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@kabutz
Copy link
Contributor Author

kabutz commented Jan 12, 2022

Another comment

@bridgekeeper
Copy link

bridgekeeper bot commented Feb 9, 2022

@kabutz This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply add a new comment to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@kabutz
Copy link
Contributor Author

kabutz commented Feb 10, 2022

Still wondering whether this can ever join the JDK

@bridgekeeper
Copy link

bridgekeeper bot commented Mar 10, 2022

@kabutz This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply add a new comment to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@muescha
Copy link

muescha commented Mar 30, 2022

satisfy the bot with a comment - we are on day 20 now :)
@kabutz i hope that was ok?

@bridgekeeper
Copy link

bridgekeeper bot commented Apr 28, 2022

@kabutz This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply add a new comment to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@bridgekeeper
Copy link

bridgekeeper bot commented May 26, 2022

@kabutz This pull request has been inactive for more than 8 weeks and will now be automatically closed. If you would like to continue working on this pull request in the future, feel free to reopen it! This can be done using the /open pull request command.

@bridgekeeper bridgekeeper bot closed this May 26, 2022
@j3graham
Copy link

I hope this PR isn’t abandoned. It’s the kind of optimization I would hope steams could do - even if this was initially only applied to arrays and immutable lists.

One minor suggestion would be to move the implementations into a new package-visible EmptyStreams class to reduce churn in the already large Streams

* @param spliterator a {@code Spliterator.OfInt} describing the stream elements
* @return a new sequential empty {@code IntStream}
*/
public static IntStream emptyIntStream(Spliterator.OfInt spliterator) {

Choose a reason for hiding this comment

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

In the interest of not adding new public APIs, can these be removed in favour of eg, IntStream.empty()? It wouldn’t take the spliterator, but perhaps it isn’t necessary.

@rose00
Copy link
Contributor

rose00 commented Jul 21, 2022

I agree it’s the “kind of” optimization that would be nice. “Kind of”. Personally I would be happier to see complexity like this added that would help a larger class of common streams.

It’s a harder problem, and I know this is case of “the best is the enemy of the good”, but I think a stream which has less content bulk than pipeline phases (according to some heuristic weighting) might possibly be handled better by dumping the elements into an Object array and running each phase in sequence over that array, rather than composing a “net result of all phases” object and then running it over the few elements. Stream object creation can be reduced, perhaps, by building the stream around a small internal buffer that collects pipeline phases (and their lambdas), by side effect. The terminal operation runs this Rube-Goldberg contraption in an interpretive manner over the elements. An advantage would arise if the contraption were smaller and simpler than a fully-composed stream of today, and the optimizations lost by having an interpreter instead of a specialized object ness were insignificant due to the small bulk of the stream source.

If such an optimization really works, it would automatically handle the zero-elements case, but would also cover lots of use cases (in the JDK code even) where streams are used for their notational convenience, rather than their ability to process many elements efficiently.

I looked at this for a few days, several months ago. I solved enough problems to see that there is a long tail of difficulties in stream implementation! (Many are noted in this PR thread.) I noticed that some pipeline phases can expand the “bulk” (flatMap and friends). Bulk-reducing phases (filter) are not a problem, for already-small streams. (These issues do not arise for truly empty streams.) For expansion there would have to be an option to inflate a previously-small stream to use the general paths. Another issue is avoiding running any lambdas until the terminal operation, which means capturing them in some orderly fashion. Again, if a bizarre pipeline structure shows up, inflation is an option. And for truly empty streams some or all of the pipeline structure can be just dropped on the floor, as this PR proposes.

In the end, I think the best leverage will come from a completely different set of techniques, from whatever allows us to do “loop customization”. By loop customization which I mean the ability to compile the loop in the terminal operation separately for each “interestingly distinct” combination of pipeline components, in such a way that the loop can constant-fold their lambdas, the shape of the stream source, and anything else that affects loop code quality. That technique should apply well regardless of “bulk”, since the most complex object allocation should happen during specialization, which is a link-time operation, instead of every time a stream is created.

Current state of the art uses mega-inlining, which has complex limitations and failure modes. (It utterly fails for parallel streams.) When we get to specialized generics, I hope to take another run at the problem, so that the type of each pipeline lambda “feeds” into a specialization syndrome that the JVM “sees” distinct from other specializations, and can optimize separately. (Making streams be value classes could help also.) I guess all that might be “the impossible dream being the enemy of the best”.

Anyway, FWIW…

@kabutz
Copy link
Contributor Author

kabutz commented Jul 21, 2022

Indeed, this was a nice experiment, but it would be even better to solve the issues in a more general way. It also turned into a rather complex change that modified a bit how streams currently work. Even though it took a couple of weeks of work, especially in creating the unit tests, I am 100% in favour of mothballing this. Until we solve it, there is an easy workaround that should also work in the future. Just check ahead of time whether the collection is empty, and in that case, don't create a stream.

@j3graham
Copy link

Sounds reasonable. I’ve been seeing streams being used because of them being a convenient api more, which made me wonder more about how well they dealt with optimizations. I appreciate the extra insight.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
core-libs [email protected] rfr Pull request is ready for review
Development

Successfully merging this pull request may close these issues.

10 participants