Вложенные циклы Java 8 с потоками и производительностью

Чтобы попрактиковаться в потоках Java 8, я попытался преобразовать следующий вложенный цикл в потоковый API Java 8. Он вычисляет наибольшую сумму цифр a^b (a,b ‹ 100) и занимает ~0,135 с на моем Core i5 760.

public static int digitSum(BigInteger x)
{
    int sum = 0;
    for(char c: x.toString().toCharArray()) {sum+=Integer.valueOf(c+"");}
    return sum;
}

@Test public void solve()
    {
        int max = 0;
        for(int i=1;i<100;i++)
            for(int j=1;j<100;j++)
                max = Math.max(max,digitSum(BigInteger.valueOf(i).pow(j)));
        System.out.println(max);
    }

Мое решение, которое, как я ожидал, будет быстрее из-за параллелизма, на самом деле заняло 0,25 с (0,19 с без parallel()):

int max =   IntStream.range(1,100).parallel()
            .map(i -> IntStream.range(1, 100)
            .map(j->digitSum(BigInteger.valueOf(i).pow(j)))
            .max().getAsInt()).max().getAsInt();

Мои вопросы

  • правильно ли я сделал преобразование или есть лучший способ преобразовать вложенные циклы в потоковые вычисления?
  • почему потоковый вариант намного медленнее старого?
  • почему оператор parallel() на самом деле увеличил время с 0,19 до 0,25 с?

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

Обновить

Я измеряю с помощью среды Junit 4 в Eclipse Kepler (показывает время, затраченное на выполнение теста).

Мои результаты для a,b‹1000 вместо 100:

  • традиционная петля 186s
  • последовательный поток 193s
  • параллельный поток 55s

Обновление 2 Замена sum+=Integer.valueOf(c+""); на sum+= c - '0'; (спасибо, Питер!) сократила целые 10 секунд параллельного метода, доведя его до 45 секунд. Не ожидал такого большого влияния на производительность!

Кроме того, уменьшение параллелизма до количества ядер ЦП (4 в моем случае) не дало многого, поскольку сократило время только до 44,8 с (да, это добавляет a и b = 0, но я думаю, что это не повлияет на производительность большая):

int max = IntStream.range(0, 3).parallel().
          .map(m -> IntStream.range(0,250)
          .map(i -> IntStream.range(1, 1000)
          .map(j->.digitSum(BigInteger.valueOf(250*m+i).pow(j)))
          .max().getAsInt()).max().getAsInt()).max().getAsInt();

person Konrad Höffner    schedule 23.02.2014    source источник
comment
Как вы измеряете? Как вы заметили, без надлежащего ухода результаты микротеста могут ввести в заблуждение.   -  person assylias    schedule 23.02.2014
comment
Я бы заменил sum+=Integer.valueOf(c+""); на sum+= c - '0';, так будет намного быстрее.   -  person Peter Lawrey    schedule 23.02.2014
comment
FWIW вы можете заменить цикл в digitSum потоком, используя метод CharSequence.chars(). Это позволяет избежать выделения массива символов.   -  person Stuart Marks    schedule 23.02.2014


Ответы (2)


Я создал быстрый и грязный микротест на основе вашего кода. Результаты:

контур: 3192
лямбда: 3140
лямбда параллельно: 868

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

public static void main(String[] args) {
    int sum = 0;

    //warmup
    for (int i = 0; i < 100; i++) {
        solve();
        solveLambda();
        solveLambdaParallel();
    }

    {
        long start = System.nanoTime();
        for (int i = 0; i < 100; i++) {
            sum += solve();
        }
        long end = System.nanoTime();
        System.out.println("loop: " + (end - start) / 1_000_000);
    }
    {
        long start = System.nanoTime();
        for (int i = 0; i < 100; i++) {
            sum += solveLambda();
        }
        long end = System.nanoTime();
        System.out.println("lambda: " + (end - start) / 1_000_000);
    }
    {
        long start = System.nanoTime();
        for (int i = 0; i < 100; i++) {
            sum += solveLambdaParallel();
        }
        long end = System.nanoTime();
        System.out.println("lambda parallel : " + (end - start) / 1_000_000);
    }
    System.out.println(sum);
}

public static int digitSum(BigInteger x) {
    int sum = 0;
    for (char c : x.toString().toCharArray()) {
        sum += Integer.valueOf(c + "");
    }
    return sum;
}

public static int solve() {
    int max = 0;
    for (int i = 1; i < 100; i++) {
        for (int j = 1; j < 100; j++) {
            max = Math.max(max, digitSum(BigInteger.valueOf(i).pow(j)));
        }
    }
    return max;
}

public static int solveLambda() {
    return  IntStream.range(1, 100)
            .map(i -> IntStream.range(1, 100).map(j -> digitSum(BigInteger.valueOf(i).pow(j))).max().getAsInt())
            .max().getAsInt();
}

public static int solveLambdaParallel() {
    return  IntStream.range(1, 100)
            .parallel()
            .map(i -> IntStream.range(1, 100).map(j -> digitSum(BigInteger.valueOf(i).pow(j))).max().getAsInt())
            .max().getAsInt();
}

Я также запускал его с помощью jmh, который более надежен, чем ручные тесты. Результаты согласуются с приведенными выше (микросекунды на вызов):

Benchmark                                Mode   Mean        Units
c.a.p.SO21968918.solve                   avgt   32367.592   us/op
c.a.p.SO21968918.solveLambda             avgt   31423.123   us/op
c.a.p.SO21968918.solveLambdaParallel     avgt   8125.600    us/op
person assylias    schedule 23.02.2014
comment
Мне было бы интересно посмотреть, что получится, если запустить тесты в обратном порядке. - person Peter Lawrey; 23.02.2014
comment
@PeterLawrey Те же результаты (лямбда-параллель: 836, лямбда: 3124, цикл: 3184) - person assylias; 23.02.2014
comment
Интересные результаты! Может, у меня тоже из-за Intel Turbo Boost (автоматический разгон при использовании только одного ядра)? Однако я не уверен, действительно ли Junit настолько ненадежен в хронометраже, потому что я повторял его несколько раз и всегда получал одинаковые результаты. - person Konrad Höffner; 23.02.2014
comment
@kirdie junit надежен, но не разогревает код для вас. Прогретый код может быть во много раз быстрее, чем при первом запуске. - person Peter Lawrey; 23.02.2014
comment
@delive нет! - бывает быстрее для этого конкретного кода, но это не всегда верно. - person assylias; 21.12.2015
comment

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

Одно большое отличие в вашем циклическом коде заключается в том, что ваш рабочий набор очень мал. Вы рассматриваете только одну максимальную сумму цифр за раз. Это означает, что код является дружественным к кэшу, и у вас есть очень недолговечные объекты. В случае с stream() вы создаете коллекции, для которых в любой момент времени больше рабочего набора, используя больше кеша, с большими накладными расходами. Я ожидаю, что ваше время GC будет больше и/или чаще.

почему потоковый вариант намного медленнее старого?

Циклы довольно хорошо оптимизированы, поскольку существовали еще до разработки Java. Они могут быть очень эффективно сопоставлены с оборудованием. Потоки довольно новые и не так сильно оптимизированы.

почему оператор parallel() на самом деле увеличил время с 0,19 до 0,25 с?

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

person Peter Lawrey    schedule 23.02.2014
comment
Хм, но, глядя на мой код, я не вижу никакого общего ресурса, я просто использую библиотеку Java или я что-то упустил? - person Konrad Höffner; 23.02.2014
comment
@kirdie У вас есть аппаратные ресурсы, которыми вы делитесь. например ваш кеш L3, возможно, кеш L1/L2, ваша основная память и сборщик мусора могут сыграть свою роль. - person Peter Lawrey; 23.02.2014