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

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

Чтобы тренировать потоки Java 8, я попытался преобразовать следующий вложенный цикл в API потока Java 8. Он вычисляет наибольшую сумму суммы a ^ b (a, b < 100) и принимает ~ 0.135s на моем 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 с?

Я знаю, что microbenchmarks хрупкие, а parallelism стоит только для больших проблем, но для CPU, даже 0.1s - это вечность, правильно?

Обновление

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

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

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

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

Кроме того, сокращение числа parallelism до количества ядер процессора (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();
4b9b3361

Ответ 1

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

: 3192
lambda: 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

Ответ 2

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

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

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

Петли довольно хорошо оптимизированы, так как до разработки Java. Их можно очень эффективно сопоставить с оборудованием. Потоки являются довольно новыми и не так сильно оптимизированы.

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

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