Почему этот Java-код на 6 раз быстрее, чем идентичный код С#?

У меня есть несколько различных решений Project Euler problem 5, но разница во времени между двумя языками/платформами в этой конкретной интриге реализации меня. Я не делал никакой оптимизации с флагами компилятора, просто javac (через командную строку) и csc (через Visual Studio).

Здесь код Java. Он заканчивается в 55 мс.

public class Problem005b
{
    public static void main(String[] args)
    {
        long begin = System.currentTimeMillis();
        int i = 20;
        while (true)
        {
            if (
                    (i % 19 == 0) &&
                    (i % 18 == 0) &&
                    (i % 17 == 0) &&
                    (i % 16 == 0) &&
                    (i % 15 == 0) &&
                    (i % 14 == 0) &&
                    (i % 13 == 0) &&
                    (i % 12 == 0) &&
                    (i % 11 == 0)
                )
            {
                break;
            }
            i += 20;
        }
        long end = System.currentTimeMillis();
        System.out.println(i);
        System.out.println(end-begin + "ms");
    }
}

Вот идентичный код С#. Он заканчивается в 320 мс

using System;

namespace ProjectEuler05
{
    class Problem005
    {
        static void Main(String[] args)
        {
            DateTime begin = DateTime.Now;
            int i = 20;
            while (true)
            {
                if (
                        (i % 19 == 0) &&
                        (i % 18 == 0) &&
                        (i % 17 == 0) &&
                        (i % 16 == 0) &&
                        (i % 15 == 0) &&
                        (i % 14 == 0) &&
                        (i % 13 == 0) &&
                        (i % 12 == 0) &&
                        (i % 11 == 0)
                    )
                    {
                        break;
                    }
                i += 20;
            }
            DateTime end = DateTime.Now;
            TimeSpan elapsed = end - begin;
            Console.WriteLine(i);
            Console.WriteLine(elapsed.TotalMilliseconds + "ms");
        }
    }
}

Ответ 1

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

Прежде всего, убедитесь, что затраты, связанные с запуском сборки Debug, загружают символы pdb, как вы это делали.

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

Далее вам нужно иметь дело с конкретным поведением платформы. Если вы находитесь на 64-битной машине Windows, вы можете работать в режиме 32 бит или 64 бит. В 64-битном режиме JIT во многом отличается, часто меняя полученный результирующий код. В частности, и я бы угадал уместно, вы получаете доступ к дважды большому количеству регистров общего назначения.

В этом случае внутренняя часть цикла, наивно переведенная в машинный код, должна будет загружать в регистры константы, используемые в модульных тестах. Если в цикле недостаточно всего необходимого, тогда он должен вытолкнуть их из памяти. Даже из кеша уровня 1 это будет значительным хитом по сравнению с сохранением всех в регистре.

В VS 2010 MS изменилась цель по умолчанию от anycpu до x86. У меня нет ничего похожего на ресурсы или клиент, знающие MSFT, поэтому я не буду пытаться догадаться об этом. Однако любой, кто смотрит на что-либо вроде анализа производительности, который вы делаете, должен, безусловно, попробовать оба.

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

Есть несколько вещей, о которых я думаю, было бы интересно для оптимизирующего компилятора.

  • Те, о которых уже упоминалось:
    • Опция lcm интересна, но я не вижу, чтобы писатель-компилятор беспокоился.
    • сокращение деления на умножение и маскирование.
      • Я не знаю достаточно об этом, но другие люди пробовали, отметили, что они значительно улучшают делитель на более свежих чипах Intel.
      • Возможно, вы могли бы организовать что-то сложное, используя SSE2.
      • Конечно, операция modulo 16 созрела для преобразования в маску или сдвиг.
    • Компилятор может заметить, что ни один из тестов не имеет побочных эффектов.
      • он мог бы умозрительно попытаться оценить несколько из них сразу, на суперскаровом процессоре это могло бы накачивать вещи довольно быстро, но в значительной степени будет зависеть от того, насколько хорошо компоновщик компоновщик взаимодействует с механизмом выполнения OO.
    • Если давление в регистре было жестким, вы могли бы реализовать константы как одну переменную, установленную в начале каждого цикла, а затем увеличивать по мере продвижения.

Это все догадки, и их следует рассматривать как простуда. Если вы хотите узнать, разобрать его.

Ответ 2

  • Для выполнения временного кода вы должны использовать класс StopWatch.
  • Кроме того, вы должны учитывать JIT, время выполнения и т.д., поэтому пусть тестовый запуск выполняется достаточное количество раз (например, 10 000, 100 000 раз) и получает какое-то среднее значение. Важно несколько раз запустить код, не. Поэтому напишите метод и зациклируйте основной метод, чтобы получить ваши измерения.
  • удалите всю отладочную информацию из сборок и позвольте коду работать автономно в сборке выпуска

Ответ 3

Возможно несколько оптимизаций. Возможно, Java JIT выполняет их, а CLR - нет.

Оптимизация # 1:

(x % a == 0) && (x % b == 0) && ... && (x % z == 0)

эквивалентно

(x % lcm(a, b, ... z) == 0)

Итак, в вашем примере цепочка сравнения может быть заменена на

if (i % 232792560 == 0) break;

(но, конечно, если вы уже вычислили LCM, вам не составит труда запустить программу в первую очередь!)

Оптимизация # 2:

Это также эквивалентно:

if (i % (14549535 * 16)) == 0 break;

или

if ((i % 16 == 0) && (i % 14549535 == 0)) break;

Первое деление можно заменить маской и сравнить с нулем:

if (((i & 15) == 0) && (i % 14549535 == 0)) break;

Второе деление можно заменить умножением на модулярный обратный:

final long LCM = 14549535;
final long INV_LCM = 8384559098224769503L; // == 14549535**-1 mod 2**64
final long MAX_QUOTIENT = Long.MAX_VALUE / LCM;
// ...
if (((i & 15) == 0) &&
    (0 <= (i>>4) * INV_LCM) &&
    ((i>>4) * INV_LCM < MAX_QUOTIENT)) {
    break;
}

Несколько маловероятно, что JIT использует это, но это не так надуманно, как вы могли подумать - некоторые компиляторы C реализуют вычитание указателей таким образом.

Ответ 5

Возможно, потому, что построение объектов DateTime намного дороже, чем System.currentTimeMillis.

Ответ 6

Это слишком короткая задача для правильного выбора времени. Вам нужно запустить как минимум 1000 раз и посмотреть, что произойдет. Похоже, вы используете их из командной строки, и в этом случае вы, возможно, сравниваете компиляторы JIT для обоих. Попробуйте поместить обе кнопки в простой графический интерфейс и пропустите эту кнопку за несколько сотен раз, прежде чем вернуть прошедшее время. Даже игнорируя компиляцию JIT, время может быть отброшено гранулярностью планировщика ОС.

О, и из-за JIT... только считайте SECOND результат нажатия кнопки.:)

Ответ 7

В Java я бы использовал System.nanoTime(). Любой тест, который занимает менее 2 секунд, должен выполняться дольше. Стоит отметить, что Java довольно хорош в оптимизации неэффективного кода или кода, который ничего не делает. Более интересным тестом было бы, если бы вы оптимизировали код.

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

Вы хотите получить продукт из коэффициентов от 11 до 20, которые составляют 2,2,2,2,3,3,5,7,11,13,17,19. Умножьте их вместе, и у вас есть ответ.

Ответ 8

(Перемещено из OP)

Изменение цели с x86 на anycpu снизило среднее время выполнения до 84 мс за ход, начиная с 282 мс. Может быть, я должен разделить это на вторую нить?

UPDATE:
Благодаря Femaref ниже, кто указал на некоторые проблемы тестирования, и действительно, после того, как он выполнил свои предложения, времена были ниже, что указывает на то, что время настройки VM было значительным в Java, но, вероятно, не в С#. В С# это были символы отладки, которые были значительными.

Я обновил свой код, чтобы запустить каждый цикл 10000 раз, и выводит только средний мс в конце. Единственное существенное изменение, которое я сделал, это версия С#, где я переключился на [StopWatch class] [3] для большего разрешения. Я застрял с миллисекундами, потому что это достаточно хорошо.

Результаты:
Изменения в тестировании не объясняют, почему Java (все еще) намного быстрее, чем С#. Производительность С# была лучше, но это можно объяснить полностью, удалив символы отладки. Если вы читаете [Mike Two] [4], и я обмениваюсь комментариями, прилагаемыми к этому OP, вы увидите, что я получил ~ 280 мс в среднем за пять прогонов кода С#, просто переключившись с Debug на Release.

Числа:

  • 10 000 циклов подсчета немодифицированного кода Java дали мне в среднем 45 мс (от 55 мс).
  • 10 000 циклов подсчета кода С# с использованием класса StopWatch дали мне в среднем 282 мс (по сравнению с 320 мс).

Все это оставляет разницу необъяснимой. Фактически, дифференциал ухудшился. Java перешла от ~ 5.8x быстрее до ~ 6.2x быстрее.