Wydajność Javy a C++

1

Cześć, zrobiłem prosty test. Porównałem czasy wykonywania się programów napisanych w C++ oraz w Javie. W obu programach został napisany algorytm obliczania pierwiastka kwadratowego z wykorzystaniem metody Herona. Z tego co wiem to C++ używa się do aplikacji w których kluczową rolę pełni wydajność. Zawsze myślałem, że Java jest wolniejsza, ale ma bardzo dużo zastosowań, jednak z tego co mi wyszło to jednak taka wolna nie jest.
program w Javie:

public class Test {
	public static void main(String[] args) {
		double square=0d;
		long before=0l, now=0l;
		before = System.nanoTime();
		square = squareRoot(90, 100000000, 20);
		now = System.nanoTime();
		System.out.println("SquareRoot:  " + square + ", elapsed time: " +(now-before)/1000000d + "ms.");
		
	}
	
	static double squareRoot(double number, int iteration, double condition) {
		double xi=condition;
		double xi1=0.0;
		
		for (int i=0; i<iteration; i++) {
			xi1 = 1.0/2.0 * (xi + number/xi);
			xi = xi1;
		}
		
		return xi1;
	}
}

Program w C++:

double squareRoot(double number, int iteration, double condition) {
	double xi=condition;
	double xi1=0.0;

	for (int i=0; i<iteration; i++) {
	xi1 = 1.0/2.0 * (xi + number/xi);
	xi = xi1;
	}

	return xi1;
}

int main()
{
	double square = 0;
	auto start = chrono::steady_clock::now();
	
	square = squareRoot(90, 100000000, 20);

	auto end = chrono::steady_clock::now();

	cout << "Elapsed time in milliseconds : "
		<< chrono::duration_cast<chrono::milliseconds>(end - start).count()
		<< " ms, and squareRoot is: " <<square<< endl;

	cout << "Elapsed time in seconds : "
		<< chrono::duration_cast<chrono::seconds>(end - start).count()
		<< " sec, and squareRoot is: " <<square;

	return 0;
}

Dla 100000000 iteracji, do obliczenia pierwiastka kwadratowego z liczby 90 z warunkiem początkowym 20 obliczenia w Javie zajęły średnio 815 ms. Dla C++ było to średnio 1350 ms czyli bardzo duża różnica. Skąd taka rozbieżność?

0

A włączyłeś optymalizacje w C++ podczas kompilacji?

0

Nie ogarniam za bardzo C++, co masz na myśli mówiąc optymalizacja C++ podczas kompilacji?

To znaczy zbuduj w trybie release z optymalizacją O3 jeśli używasz kompilatora g++ lub O2 jeśli budujesz z użyciem kompilatora od microsoftu.

Z tego co wiem to C++ używa się do aplikacji w których kluczową rolę pełni wydajność

Tak. Szkopuł w tym, że trzeba wiedzieć w jaki sposób C++ użyć żeby tą wysoką wydajność otrzymać. Jeśli chodzi o pierwiastek kwadratowy to od lat 90 stosuje się różne haki żeby przyspieszyć obliczenia.

0

screenshot-20200113113814.png

Włączyłem i dalej jest to samo

0

To znaczy, że użyty przez Ciebie algorytm jest szybszy w Javie w Twoim środowisku. Teraz spróbuj innego algorytmu, np Babylonian method. Jeśli nadal Java będzie szybsza i będziesz się zastanawiał po co ludziom C++ to wiedz, że z poziomu C++ łatwo można użyć sprzętowego fsqrt, który zgaduję, że będzie najszybszy.

EDIT
Kurczę już widzę, jak @katelx albo @Wibowit tu wparowuje, żeby mnie z ziemią zrównać. Wiem, że w Javie można pisać mega szybkie apki, nie trzeba mnie uświadamiać.

1

Szybciej działa, po zlikwidowaniu nawiasu w pętli (u mnie ok. 15%), po skompilowaniu z optymalizacjami (-O3):
Java: 788.80936ms;
C++: 737 ms.
Z takim krótkim kodem JIT sobie radzi równie dobrze, jak gcc, i w sumie to nie ma co się dziwić.
@infantylny, jaki Masz kompilator C++? U mnie: g++-8 -std=c++17 -O3 -Wall -pedantic

EDYCJA: Dla klarowności kompilowany kod w pętli:

    xi1 = 1.0/2.0 * xi + 1.0/2 * number/xi;
    xi = xi1;
0

Dobra, a jaki będziesz miał wynik jak do tej funkcji zmienna i będzie typu register?

double squareRoot(double number, int iteration, double condition) {
    double xi=condition;
    double xi1=0.0;
    
    register int i;
    for (i=0; i<iteration; i++) {
    xi1 = 1.0/2.0 * (xi + number/xi);
    xi = xi1;
    }

    return xi1;
}
10

Zawsze myślałem, że Java jest wolniejsza

To nie takie proste ;)
Java generalnie może być wolniejsza, bo wymaga interpretowania bytecode'u w runtime, ale z drugiej strony dzięki temu może optymalizować kod nie tylko w czasie kompilacji (jak C) ale też w czasie wykonania (tzw. JIT). W efekcie w zależności od sytuacji, może się okazać szybsza.

Przykład poglądowy: wyobraź sobie, że masz program który wiele razy wykonuje dzielenie x/y. Na poziomie kompilacji C nie wiele tu może wyczarować, ot na poziomie asemblera będzie tam div. Ale wyobraź sobie, że uruchamiasz taki program z y=2. Teraz takie dzielenie można by wykonać dużo szybciej jednym shiftem, zamiast kosztownego diva. No ale dla C jest już za późno, kod już wygenerowany i tylko się wykonuje. Ale dla JITa może nie być jeszcze za późno, bo on działa w runtime i może wygenerować zoptymalizowany kod maszynowy w trakcie działania programu.

2

Szkoda, że akurat jestem zasypany pracą. Fajny rak w niektórych postach. Taki nie za łatwy do usunięcia.

@Shalom true, ale warto dodać, że od jakiegoś czasu w standardzie można kompilować javę do native, statycznie... i taki kod zwykle jest wolniejszy w dłuższej perspektywie, bo nie ma tych jitowych optymalizacji. (Ale programik szybciej startuje, wiec czasem ma to sens).

0

U mnie czas wyszedł jednakowy (różnica nieistotna):
https://wandbox.org/permlink/WtAuAW7PncjFZqEo
https://wandbox.org/permlink/296GfkgSwBVKgE6F

Co do tłumaczenia bytcode -> kod maszynowy wykorzystujący w pełni procesor:
dobranie właściwej architektury w C++ daje pomijalnie mały zysk: https://wandbox.org/permlink/4HbGXqg2rdPIjYBh (dodatkowy przełącznik: użyj instrukcji specyficznych dla bieżącego procesora).

7

No tak mierząc czas, to daleko nie zajedziemy. Z kilku powodów, a najważniejszy to błędy zegara oraz sposób dostępu do wyjścia.

public class Test {
   public static void main(String[] args) {
      double square=0d;
      long before=0l, now=0l;
      square = squareRoot(90, 100000000, 20);
  }

  static double squareRoot(double number, int iteration, double condition) {
      double xi=condition;
      double xi1=0.0;
      for (int i=0; i<iteration; i++) {
         xi1 = 1.0/2.0 * (xi + number/xi);
         xi = xi1;
     }
     return xi1;
  }
}
#include <iostream>  
#include <chrono>     

using namespace std; 

double squareRoot(double number, int iteration, double condition) {

  double xi=condition;
  double xi1=0.0;
  for (int i=0; i<iteration; i++) {
   xi1 = 1.0/2.0 * (xi + number/xi);
   xi = xi1;
  }
  return xi1;
}

int main()
{
   double square = 0;
   square = squareRoot(90, 100000000, 20);
   return 0;
}

W ten sposób eliminujemy z pomiaru błędy związane z czasem działania programu. W przypadku javy należy dorzucić jeszcze czas potrzebny na uruchomienie JVM, więc pomiary in-code raczej nie będą miarodajne. Proponuję więc inne podejście i użycie pomiarów out-code z użyciem unixowego time. Realizowane wg poniżeszego scenariusza:

#!/bin/bash

echo 'Informacje o g++'
g++ -v

echo -e "\n"

echo 'Informacje o Javac i Java'
javac -version
java -version


echo -e "\n"
echo 'Program w C++'
echo 'Kompilacja bez opcji'

time g++ test.c
echo 'Uruchomienie programu w C++'
time ./a.out


echo -e "\n"
echo 'Program w Java'
echo 'Kompilacja'
time javac Test.java
echo 'Uruchomienie programu w Java'
time java Test


echo -e "\n"
echo -e "Usuwam pliki wykonywalne"
rm a.out Test.class


echo -e "\n"
echo 'Kompilacja w C++ z użyciem opcji -O3'
time g++ -O3 test.c
echo 'Uruchomienie programu w C++ skompilowanego z opcją -O3'
time ./a.out

Wyniki

Informacje o g++ 
Using built-in specs.
COLLECT_GCC=g++
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper
OFFLOAD_TARGET_NAMES=nvptx-none
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 7.4.0-1ubuntu1~18.04.1' --with-bugurl=file:///usr/share/doc/gcc-7/README.Bugs --enable-languages=c,ada,c++,go,brig,d,fortran,objc,obj-c++ --prefix=/usr --with-gcc-major-version-only --program-suffix=-7 --program-prefix=x86_64-linux-gnu- --enable-shared --enable-linker-build-id --libexecdir=/usr/lib --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --with-sysroot=/ --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-gnu-unique-object --disable-vtable-verify --enable-libmpx --enable-plugin --enable-default-pie --with-system-zlib --with-target-system-zlib --enable-objc-gc=auto --enable-multiarch --disable-werror --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32
--enable-multilib --with-tune=generic --enable-offload-targets=nvptx-none --without-cuda-driver --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu
Thread model: posix
gcc version 7.4.0 (Ubuntu 7.4.0-1ubuntu1~18.04.1)


Informacje o Javac i Java
javac 11.0.5
openjdk version "11.0.5" 2019-10-15
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.5+10)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.5+10, mixed mode)


Program w C++
Kompilacja bez opcji

real    0m0,236s
user    0m0,205s
sys     0m0,030s
Uruchomienie programu w C++

real    0m0,812s
user    0m0,811s
sys     0m0,000s


Program w Java
Kompilacja

real    0m0,413s
user    0m0,810s
sys     0m0,072s
Uruchomienie programu w Java

real    0m0,608s
user    0m0,629s
sys     0m0,000s


Usuwam pliki wykonywalne


Kompilacja w C++ z użyciem opcji -O3

real    0m0,209s
user    0m0,196s
sys     0m0,013s
Uruchomienie programu w C++ skompilowanego z opcją -O3

real    0m0,001s
user    0m0,001s
sys     0m0,000s

Wygląda lepiej, prawda?
Ale coś tu jest nie tak…

GCC w czasie kompilacji z opcją -O3 wylicza, że wynik squareRoot jest nieużywany i można go usunąć. Przepisujemy zatem nasze programy do postaci:

public class Test {
   public static void main(String[] args) {
      double square=0d;
      long before=0l, now=0l;
      square = squareRoot(90, 100000000, 20);
      System.out.println(square);
  }

  static double squareRoot(double number, int iteration, double condition) {
      double xi=condition;
      double xi1=0.0;
      for (int i=0; i<iteration; i++) {
         xi1 = 1.0/2.0 * (xi + number/xi);
         xi = xi1;
     }
     return xi1;
  }
}
#include <iostream>  
#include <chrono>     

using namespace std; 

double squareRoot(double number, int iteration, double condition) {

  double xi=condition;
  double xi1=0.0;
  for (int i=0; i<iteration; i++) {
   xi1 = 1.0/2.0 * (xi + number/xi);
   xi = xi1;
  }
  return xi1;
}

int main()
{
   double square = 0;
   square = squareRoot(90, 100000000, 20);
   cout << square;
   return 0;
}

i teraz porównajmy wyniki:

Program w C++
Kompilacja bez opcji

real    0m0,239s
user    0m0,203s
sys     0m0,036s
Uruchomienie programu w C++
9.48683
real    0m0,800s
user    0m0,798s
sys     0m0,000s


Program w Java
Kompilacja

real    0m0,416s
user    0m0,827s
sys     0m0,086s
Uruchomienie programu w Java
9.486832980505138

real    0m0,605s
user    0m0,615s
sys     0m0,016s


Usuwam pliki wykonywalne


Kompilacja w C++ z użyciem opcji -O3

real    0m0,229s
user    0m0,197s
sys     0m0,026s
Uruchomienie programu w C++ skompilowanego z opcją -O3
9.48683
real    0m0,545s
user    0m0,544s
sys     0m0,000s

I teraz czasy są już poprawne. Co zatem robi -O3? Ano w takim prostym programie nie musi nic robić. Na koniec dla porównania czas działania programów z O0-3 i Ofast, który pokaże na którym poziomie zachodzi optymalizacja:

Kompilacja i uruchomienie z O0

real    0m0,238s
user    0m0,198s
sys     0m0,041s
9.48683
real    0m0,806s
user    0m0,805s
sys     0m0,000s
Kompilacja i uruchomienie z O1

real    0m0,211s
user    0m0,183s
sys     0m0,028s
9.48683
real    0m0,544s
user    0m0,544s
sys     0m0,000s
Kompilacja i uruchomienie z O2

real    0m0,224s
user    0m0,202s
sys     0m0,022s
9.48683
real    0m0,548s
user    0m0,548s
sys     0m0,000s
Kompilacja i uruchomienie z O3

real    0m0,217s
user    0m0,181s
sys     0m0,035s
9.48683
real    0m0,553s
user    0m0,547s
sys     0m0,000s
Kompilacja i uruchomienie z Ofast

real    0m0,221s
user    0m0,184s
sys     0m0,036s
9.48683
real    0m0,549s
user    0m0,545s
sys     0m0,004s

Do poczytania co jaka opcja robi https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html

edit:
@Wibowit słusznie zauważył, że trzeba by jeszcze sprawdzić ile zajmie uruchomienie pustej klasy:

public class Empty{
  public static void main(String[] args){}
}

Wyniki:

real    0m0,059s
user    0m0,084s
sys     0m0,000s
2

Przy okazji temat microbenchmarking np.JMH, kontra zwyczajne mierzenie czasu

0 użytkowników online, w tym zalogowanych: 0, gości: 0