Почему моя 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");
}
}
Ответы
Ответ 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, который обрабатывается где-то на исключительном пути. На быстром пути ничего нет. Тело цикла короче и может быть оптимизировано лучше (например, более развертка цикла).