Подтвердить что ты не робот

Почему моя jamba лямбда с фиктивным назначением намного быстрее, чем без нее?

Я знаю, что суждение о микробизнесе Java чрезвычайно чревато, но я вижу нечто странное, и я хотел бы получить объяснение.

Обратите внимание, что я не использую JMH для этого. Я знаю об этом, но я не хотел идти на эту длину для этого.

Я приведу весь образец кода, но, короче говоря, когда я тестирую производительность этих двух методов

private FooPrime[] testStreamToArray(ArrayList<Foo> fooList) {
    return (FooPrime[]) fooList.stream().
                map(it -> {
                    return new FooPrime().gamma(it.getAlpha() + it.getBeta());
                }).
                toArray(FooPrime[]::new);
}

private FooPrime[] testStreamToArray2(ArrayList<Foo> fooList) {
    return (FooPrime[]) fooList.stream().
                map(it -> {
                    int stuff = it.getAlpha().length();
                    return new FooPrime().gamma(it.getAlpha() + it.getBeta());
                }).
                toArray(FooPrime[]::new);
}

Я нахожу очень неожиданные результаты. В примере с большим кодом я измеряю четыре разных способа сделать это, и первые три очень близки по производительности. Все они запускают около 50 тыс. Нс на итерацию. Тем не менее, второй пример кода последовательно проходит чуть меньше половины этой суммы. Это так. Это не медленнее, это довольно быстро.

В последнем прогоне отображаются такие номера:

manualcopy:54575 ns
toarray:53617 ns
streamtoarray:52990 ns
streamtoarray2:24217 ns

Каждый пробег имеет номера, подобные этим.

Теперь я предоставил весь класс и базовый класс. Обратите внимание, что у меня есть "разминка", когда я запускаю тестируемые методы несколько тысяч раз перед началом таймингов. Также обратите внимание, что хотя это и работает "testStreamToArray2" в последний раз, я также попытался переместить этот блок в первый тест, и числа выходят примерно одинаково. Прокомментированные строки там, чтобы убедить меня, что методы на самом деле что-то делают (тайминги все еще примерно совпадают с теми строками, которые не были прокомментированы).

package timings;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class ListToArrayOfPrimesTiming {

    public static void main(String[] args) {
        ListToArrayOfPrimesTiming tests = new ListToArrayOfPrimesTiming(args);
        tests.go();
    }

    public ListToArrayOfPrimesTiming(String[] args) { }

    private void go() {

        final ArrayList<Foo> fooList    = new ArrayList<>();

        for (int ctr = 0; ctr < 1000; ++ ctr) {
            fooList.add(new Foo().alpha("a" + ctr).beta("b" + ctr));
        }

        for (int ctr = 0; ctr < 20000; ++ ctr) {
            testManualCopy(fooList);
            testToArray(fooList);
            testStreamToArray(fooList);
            testStreamToArray2(fooList);
        }

        int iters   = 100000;

//      Set<Integer> lengths    = new HashSet<>();
//      Set<FooPrime>   distinctFooPrimes   = new HashSet<>();
//      lengths.clear();
//      distinctFooPrimes.clear();

        new TimingContainer(iters, "manualcopy", new TimingTest() {
            @Override
            public void run() {
                FooPrime[] fooPrimeArray = testManualCopy(fooList);
//              lengths.add(fooPrimeArray.length);
//              distinctFooPrimes.add(fooPrimeArray[0]);
            }
        }).run();

//      System.out.println("lengths[" + lengths + "]");
//      lengths.clear();
//      System.out.println("distinctFooPrimes[" + distinctFooPrimes + "]");
//      distinctFooPrimes.clear();

        new TimingContainer(iters, "toarray", new TimingTest() {
            @Override
            public void run() {
                FooPrime[] fooPrimeArray = testManualCopy(fooList);
//              lengths.add(fooPrimeArray.length);
//              distinctFooPrimes.add(fooPrimeArray[0]);
            }
        }).run();

//      System.out.println("lengths[" + lengths + "]");
//      lengths.clear();
//      System.out.println("distinctFooPrimes[" + distinctFooPrimes + "]");
//      distinctFooPrimes.clear();

        new TimingContainer(iters, "streamtoarray", new TimingTest() {
            @Override
            public void run() {
                FooPrime[] fooPrimeArray = testStreamToArray(fooList);
//              lengths.add(fooPrimeArray.length);
//              distinctFooPrimes.add(fooPrimeArray[0]);
            }
        }).run();

//      System.out.println("lengths[" + lengths + "]");
//      lengths.clear();
//      System.out.println("distinctFooPrimes[" + distinctFooPrimes + "]");
//      distinctFooPrimes.clear();

        new TimingContainer(iters, "streamtoarray2", new TimingTest() {
            @Override
            public void run() {
                FooPrime[] fooPrimeArray = testStreamToArray2(fooList);
//              lengths.add(fooPrimeArray.length);
//              distinctFooPrimes.add(fooPrimeArray[0]);
            }
        }).run();

//      System.out.println("lengths[" + lengths + "]");
//      lengths.clear();
//      System.out.println("distinctFooPrimes[" + distinctFooPrimes + "]");
//      distinctFooPrimes.clear();
    }

    private FooPrime[] testManualCopy(ArrayList<Foo> fooList) {
        FooPrime[] fooPrimeArray    = new FooPrime[fooList.size()];
        int index = -1;
        for (Foo foo: fooList) {
            ++ index;
            fooPrimeArray[index]    = new FooPrime().gamma(foo.getAlpha() + foo.getBeta());
        }
        return fooPrimeArray;
    }

    private FooPrime[] testToArray(ArrayList<Foo> fooList) {
        List<FooPrime>  fooPrimeList    = new ArrayList<>();
        for (Foo foo: fooList) {
            fooPrimeList.add(new FooPrime().gamma(foo.getAlpha() + foo.getBeta()));
        }
        return fooPrimeList.toArray(new FooPrime[fooList.size()]);
    }

    private FooPrime[] testStreamToArray(ArrayList<Foo> fooList) {
        return (FooPrime[]) fooList.stream().
                    map(it -> {
                        return new FooPrime().gamma(it.getAlpha() + it.getBeta());
                    }).
                    toArray(FooPrime[]::new);
    }

    private FooPrime[] testStreamToArray2(ArrayList<Foo> fooList) {
        return (FooPrime[]) fooList.stream().
                    map(it -> {
                        int stuff = it.getAlpha().length();
                        return new FooPrime().gamma(it.getAlpha() + it.getBeta());
                    }).
                    toArray(FooPrime[]::new);
    }

    public static FooPrime fooToFooPrime(Foo foo) {
        return new FooPrime().gamma(foo.getAlpha() + foo.getBeta());
    }

    public static class Foo {
        private String alpha;
        private String beta;

        public String getAlpha() { return alpha; }
        public String getBeta() { return beta; }

        public void setAlpha(String alpha) { this.alpha = alpha; }
        public void setBeta(String beta) { this.beta = beta; }

        public Foo alpha(String alpha) { this.alpha = alpha; return this; }
        public Foo beta(String beta) { this.beta = beta; return this; }
    }

    public static class FooPrime {
        private String gamma;

        public String getGamma() { return gamma; }

        public void setGamma(String gamma) { this.gamma = gamma; }

        public FooPrime gamma(String gamma) { this.gamma = gamma; return this; }

        @Override
        public int hashCode() {
            final int prime = 31;
            int result = 1;
            result = prime * result + ((gamma == null) ? 0 : gamma.hashCode());
            return result;
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj)
                return true;
            if (obj == null)
                return false;
            if (getClass() != obj.getClass())
                return false;
            FooPrime other = (FooPrime) obj;
            if (gamma == null) {
                if (other.gamma != null)
                    return false;
            } else if (!gamma.equals(other.gamma))
                return false;
            return true;
        }

        @Override
        public String toString() {
            return "FooPrime [gamma=" + gamma + "]";
        }
    }
}

И базовый класс:

package timings;

public class TimingContainer {
    private int         iterations;
    private String      label;
    private TimingTest  timingTest;

    public TimingContainer(int iterations, String label, TimingTest timingTest) {
        this.iterations = iterations;
        this.label      = label;
        this.timingTest = timingTest;
    }

    public void run() {
        long startTime  = System.nanoTime();
        for (int ctr = 0; ctr < iterations; ++ ctr) {
            timingTest.randomize();
            timingTest.run();
        }
        long    endTime = System.nanoTime();
        long    totalns = (endTime - startTime);
        System.out.println(label + ":" + (totalns / iterations) + " ns");
    }
}
4b9b3361

Ответ 1

(пересмотренный ответ.)

Бенчмаркинг в Java затруднен. Тем не менее, давайте бросим JMH на это... Я поместил ваш тест в JMH (см. http://github.com/lemire/microbenchmarks).

Это соответствующие методы...

    public FooPrime[] basicstream(BenchmarkState s) {
            return (FooPrime[]) s.fooList.stream().map(it -> {
                    return new FooPrime().gamma(it.getAlpha() + it.getBeta());
            }).toArray(FooPrime[]::new);
    }

    public FooPrime[] tweakedbasicstream(BenchmarkState s) {
            return (FooPrime[]) s.fooList.stream().map(it -> {
                    int stuff = it.getAlpha().length();
                    return new FooPrime().gamma(it.getAlpha() + it.getBeta());
            }).toArray(FooPrime[]::new);
    }

И вот результат моего запуска...

git clone https://github.com/lemire/microbenchmarks.git
cd microbenchmarks
mvn clean install
java -cp target/microbenchmarks-0.0.1-jar-with-dependencies.jar me.lemire.microbenchmarks.mysteries.MysteriousLambda
Benchmark                                      Mode  Samples      Score    Error  Units
m.l.m.m.MysteriousLambda.basicstream           avgt        5  17013.784 ± 46.536  ns/op
m.l.m.m.MysteriousLambda.tweakedbasicstream    avgt        5  16240.451 ± 67.884  ns/op

Как ни странно, кажется, что две функции не работают с одинаковой средней скоростью, существует довольно значительная разница. И это при использовании JMH, довольно хорошей основы для бенчмаркинга.

Сначала я подумал, что ваши две части кода логически эквивалентны, но это не так. Похоже, что бесполезный метод доступа по длине заставляет код генерировать исключение, когда возвращаемый объект String имеет значение null.

Итак, это действительно ближе к следующему фрагменту кода...

    @Benchmark
    public FooPrime[] nullbasicstream(BenchmarkState s) {
            return (FooPrime[]) s.fooList.stream().map(it -> {
                    if( it.getAlpha() == null) throw new NullPointerException();
                    return new FooPrime().gamma(it.getAlpha() + it.getBeta());
            }).toArray(FooPrime[]::new);
    }

И это даже быстрее, чем ваша измененная функция...

Benchmark                                      Mode  Samples      Score    Error  Units
m.l.m.m.MysteriousLambda.basicstream           avgt        5  17013.784 ± 46.536  ns/op
m.l.m.m.MysteriousLambda.nullbasicstream       avgt        5  15983.762 ± 92.593  ns/op
m.l.m.m.MysteriousLambda.tweakedbasicstream    avgt        5  16240.451 ± 67.884  ns/op

Почему это может быть?

Давайте обойдемся в программирование потока Java 8 и напишем функцию глупым старым способом с нулевой проверкой и без нее:

    @Benchmark
    public FooPrime[] basicsum(BenchmarkState s) {
            int howmany = s.fooList.size();
            FooPrime[] answer = new FooPrime[s.fooList.size()];
            for(int k = 0; k < howmany ; ++k ) {
                    Foo x = s.fooList.get(k);
                    answer[k] = new FooPrime(x.getAlpha() + x.getBeta());
            }
            return answer;
    }

    @Benchmark
    public FooPrime[] basicsumnull(BenchmarkState s) {
            int howmany = s.fooList.size();
            FooPrime[] answer = new FooPrime[s.fooList.size()];
            for(int k = 0; k < howmany ; ++k ) {
                    Foo x = s.fooList.get(k);
                    if(x.getAlpha() == null) throw new NullPointerException();
                    answer[k] = new FooPrime(x.getAlpha() + x.getBeta());
            }
            return answer;
    }

И именно так мы получаем лучшую производительность...

 m.l.m.m.MysteriousLambda.basicstream                        avgt        5  17019.730 ±  61.982  ns/op
 m.l.m.m.MysteriousLambda.nullbasicstream                    avgt        5  16019.332 ±  62.831  ns/op
 m.l.m.m.MysteriousLambda.basicsum                           avgt        5  15635.474 ± 119.890  ns/op
 m.l.m.m.MysteriousLambda.basicsumnull                       avgt        5  14342.016 ± 109.958  ns/op

Но преимущество нулевой проверки остается.

Ok. Давайте сравним только строковые суммы, без каких-либо других (без специального класса). Имеем как стандартную сумму, так и сумму, предшествующую нулевой проверке:

    @Benchmark
    public void stringsum(BenchmarkState s) {
            for(int k = 0; k < s.N; ++k) s.list3[k] = s.list1[k] + s.list2[k];
    }


    @Benchmark
    public void stringsum_withexcept(BenchmarkState s) {
            for(int k = 0; k < s.N; ++k) {
                    if(s.list1[k] == null) throw new NullPointerException();
                    s.list3[k] = s.list1[k] + s.list2[k];
            }
    }

Мы получаем, что нулевая проверка замедляет нас...

    m.l.m.m.StringMerge.stringsum               avgt        5  27011.111 ±  4.077  ns/op
    m.l.m.m.StringMerge.stringsum_withexcept    avgt        5  28387.825 ± 82.523  ns/op

Ответ 2

Основываясь на ответе @DanielLemire, у меня есть идея, которая может принести нам немного больше (не окончательное объяснение, но слишком длинное для комментария). В

int stuff = it.getAlpha().length();
return new FooPrime().gamma(it.getAlpha() + it.getBeta());

соответствующие части

if (it.getAlpha() == null) throw new NullPointerException();
String s = it.getAlpha() + it.getBeta()

где я ввел s для результата конкатенации. Переписывая его немного, мы получаем

String a = it.getAlpha();
if (a == null) throw new NullPointerException();
String b = it.getBeta();
String s = (a == null ? "null" : a) + (b == null ? "null" : b);

Первая проверка a == null делает вторую проверку излишней. javac переводит конкатенацию строк с помощью StringBuilder. Это достаточно хорошо для интерпретатора и распознается JIT-компилятором, который также распознает лишнюю проверку. Там много специального корпуса для наиболее часто используемых моделей, и не все из них оптимизированы одинаково хорошо. Я бы не удивился, если бы это было причиной.

Другая возможная причина заключается в том, что код бросания NPE может привести к чему-то вроде

if (a == null) goto AWAY;
String s = a + (b == null ? "null" : b);

где полученный машинный код существенно короче, так как обработка нулевого случая перемещается AWAY на какой-то исключительный путь. Фактически, все, что нужно для нулевой проверки, разыменовывает указатель, который в любом случае выполняется при копировании содержимого a в s. Когда он null, тогда система виртуальной памяти генерирует SIGSEGV, который обрабатывается где-то на исключительном пути. На быстром пути ничего нет. Тело цикла короче и может быть оптимизировано лучше (например, более развертка цикла).