Kemarin saya menemukan artikel oleh Christoph Nahr berjudul ".NET Struct Performance" yang membandingkan beberapa bahasa (C ++, C #, Java, JavaScript) untuk metode yang menambahkan dua titik struct (double
tupel).
Ternyata, versi C ++ membutuhkan waktu sekitar 1000ms untuk dieksekusi (1e9 iterations), sementara C # tidak bisa di bawah ~ 3000ms pada mesin yang sama (dan berkinerja lebih buruk di x64).
Untuk mengujinya sendiri, saya mengambil kode C # (dan sedikit disederhanakan untuk memanggil hanya metode di mana parameter diteruskan oleh nilai), dan menjalankannya pada mesin i7-3610QM (dorongan 3.1Ghz untuk inti tunggal), RAM 8GB, Win8. 1, menggunakan .NET 4.5.2, RELEASE build 32-bit (x86 WoW64 karena OS saya 64-bit). Ini adalah versi yang disederhanakan:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}
public static void Main()
{
Point a = new Point(1, 1), b = new Point(1, 1);
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
}
Dengan Point
didefinisikan secara sederhana:
public struct Point
{
private readonly double _x, _y;
public Point(double x, double y) { _x = x; _y = y; }
public double X { get { return _x; } }
public double Y { get { return _y; } }
}
Menjalankannya menghasilkan hasil yang mirip dengan yang ada di artikel:
Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms
Pengamatan aneh pertama
Karena metode ini harus sebaris, saya bertanya-tanya bagaimana kode akan bekerja jika saya menghapus struct sama sekali dan hanya memasukkan semuanya bersama-sama:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
public static void Main()
{
// not using structs at all here
double ax = 1, ay = 1, bx = 1, by = 1;
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
{
ax = ax + by;
ay = ay + bx;
}
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
ax, ay, sw.ElapsedMilliseconds);
}
}
Dan mendapatkan hasil yang hampir sama (sebenarnya 1% lebih lambat setelah beberapa kali percobaan ulang), yang berarti bahwa JIT-ter tampaknya melakukan pekerjaan yang baik dengan mengoptimalkan semua pemanggilan fungsi:
Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms
Ini juga berarti bahwa tolok ukur tampaknya tidak mengukur struct
kinerja apa pun dan sebenarnya hanya tampak mengukur dasardouble
aritmatika (setelah yang lainnya dioptimalkan).
Hal-hal aneh
Sekarang sampai pada bagian yang aneh. Jika saya hanya menambahkan stopwatch lain di luar loop (ya, saya mempersempitnya ke langkah gila ini setelah beberapa percobaan ulang), kode berjalan tiga kali lebih cepat :
public static void Main()
{
var outerSw = Stopwatch.StartNew(); // <-- added
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
outerSw.Stop(); // <-- added
}
Result: x=1000000001 y=1000000001, Time elapsed: 961 ms
Konyol! Dan itu tidak seperti ituStopwatch
memberi saya hasil yang salah karena saya dapat dengan jelas melihat bahwa itu berakhir setelah satu detik.
Adakah yang bisa memberi tahu saya apa yang mungkin terjadi di sini?
(Memperbarui)
Berikut adalah dua metode dalam program yang sama, yang menunjukkan bahwa alasannya bukan JIT:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}
public static void Main()
{
Test1();
Test2();
Console.WriteLine();
Test1();
Test2();
}
private static void Test1()
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
private static void Test2()
{
var swOuter = Stopwatch.StartNew();
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
swOuter.Stop();
}
}
Keluaran:
Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms
Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms
Ini pastebin. Anda perlu menjalankannya sebagai rilis 32-bit di .NET 4.x (ada beberapa pemeriksaan dalam kode untuk memastikan ini).
(Perbarui 4)
Mengikuti komentar @ usr tentang jawaban @Hans, saya memeriksa pembongkaran yang dioptimalkan untuk kedua metode, dan mereka agak berbeda:
Hal ini tampaknya menunjukkan bahwa perbedaan mungkin disebabkan oleh penyusun bertingkah lucu dalam kasus pertama, daripada perataan bidang ganda?
Juga, jika saya menambahkan dua variabel (offset total 8 byte), saya masih mendapatkan peningkatan kecepatan yang sama - dan sepertinya tidak lagi terkait dengan penyejajaran bidang yang disebutkan oleh Hans Passant:
// this is still fast?
private static void Test3()
{
var magical_speed_booster_1 = "whatever";
var magical_speed_booster_2 = "whatever";
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
GC.KeepAlive(magical_speed_booster_1);
GC.KeepAlive(magical_speed_booster_2);
}
sumber
double
variabel lokal , tidakstruct
s, jadi saya telah mengesampingkan inefisiensi pemanggilan metode / tata letak struct.Jawaban:
Pembaruan 4 menjelaskan masalah: dalam kasus pertama, JIT menyimpan nilai yang dihitung (
a
,b
) di tumpukan; dalam kasus kedua, JIT menyimpannya di register.Bahkan,
Test1
bekerja lambat karenaStopwatch
. Saya menulis patokan minimal berikut berdasarkan BenchmarkDotNet :Hasil di komputer saya:
Seperti yang bisa kita lihat:
WithoutStopwatch
bekerja dengan cepat (karenaa = a + b
menggunakan register)WithStopwatch
bekerja lambat (karenaa = a + b
menggunakan tumpukan)WithTwoStopwatches
bekerja dengan cepat lagi (karenaa = a + b
menggunakan register)Perilaku JIT-x86 bergantung pada sejumlah besar kondisi yang berbeda. Untuk beberapa alasan, stopwatch pertama memaksa JIT-x86 menggunakan tumpukan, dan stopwatch kedua memungkinkannya menggunakan register lagi.
sumber
Stopwatch
sebenarnya berjalan lebih cepat . Tetapi jika Anda menukar urutan pemanggilannya dalamMain
metode, maka metode lain akan dioptimalkan.Ada cara yang sangat sederhana untuk selalu mendapatkan versi "cepat" dari program Anda. Project> Properties> Build tab, hapus centang pada opsi "Lebih suka 32-bit", pastikan bahwa pemilihan target Platform adalah AnyCPU.
Anda benar-benar tidak suka 32-bit, sayangnya selalu diaktifkan secara default untuk proyek C #. Secara historis, toolset Visual Studio bekerja jauh lebih baik dengan proses 32-bit, masalah lama yang telah dihilangkan oleh Microsoft. Saatnya untuk menghapus opsi itu, VS2015 secara khusus membahas beberapa blok jalan terakhir ke kode 64-bit dengan jitter x64 baru dan dukungan universal untuk Edit + Lanjutkan.
Cukup obrolan, apa yang Anda temukan adalah pentingnya keselarasan variabel. Prosesor sangat mempedulikannya. Jika sebuah variabel tidak sejajar dalam memori maka prosesor harus melakukan pekerjaan ekstra untuk mengacak byte untuk mendapatkan mereka dalam urutan yang benar. Ada dua masalah ketidaksejajaran yang berbeda, salah satunya adalah di mana byte masih berada di dalam satu baris cache L1, yang memerlukan siklus tambahan untuk menggesernya ke posisi yang benar. Dan yang lebih buruk, yang Anda temukan, di mana sebagian byte berada dalam satu baris cache dan sebagian lagi di baris lain. Itu membutuhkan dua akses memori terpisah dan merekatkannya bersama. Tiga kali lebih lambat.
Tipe
double
danlong
adalah pembuat masalah dalam proses 32-bit. Mereka berukuran 64-bit. Dan bisa jadi tidak sejajar dengan 4, CLR hanya bisa menjamin keselarasan 32-bit. Bukan masalah dalam proses 64-bit, semua variabel dijamin diselaraskan ke 8. Juga alasan yang mendasari mengapa bahasa C # tidak dapat menjanjikan mereka untuk menjadi atom . Dan mengapa array ganda dialokasikan di Large Object Heap ketika mereka memiliki lebih dari 1000 elemen. LOH memberikan jaminan penyelarasan 8. Dan menjelaskan mengapa menambahkan variabel lokal menyelesaikan masalah, referensi objek adalah 4 byte sehingga memindahkan variabel ganda sebesar 4, sekarang membuatnya selaras. Kebetulan.Kompiler 32-bit C atau C ++ melakukan pekerjaan ekstra untuk memastikan bahwa double tidak dapat disejajarkan. Bukan masalah yang sederhana untuk dipecahkan, tumpukan dapat menjadi tidak sejajar ketika sebuah fungsi dimasukkan, mengingat bahwa satu-satunya jaminan adalah bahwa ia selaras dengan 4. Prolog dari fungsi tersebut perlu melakukan pekerjaan ekstra untuk membuatnya sejajar dengan 8. Trik yang sama tidak berfungsi dalam program yang dikelola, pengumpul sampah sangat peduli tentang di mana tepatnya variabel lokal berada dalam memori. Diperlukan agar dapat menemukan bahwa objek di heap GC masih direferensikan. Itu tidak dapat menangani dengan baik variabel seperti yang dipindahkan oleh 4 karena tumpukan tidak selaras saat metode dimasukkan.
Ini juga masalah mendasar dengan kegugupan .NET yang tidak mendukung instruksi SIMD dengan mudah. Mereka memiliki persyaratan penyelarasan yang jauh lebih kuat, jenis yang tidak dapat diselesaikan oleh prosesor dengan sendirinya. SSE2 membutuhkan penyelarasan 16, AVX membutuhkan penyelarasan 32. Tidak bisa mendapatkan itu dalam kode yang dikelola.
Last but not least, perhatikan juga bahwa ini membuat kinerja program C # yang berjalan dalam mode 32-bit sangat tidak dapat diprediksi. Saat Anda mengakses double atau long yang disimpan sebagai kolom di objek, kinerja dapat berubah secara drastis saat pengumpul sampah memadatkan heap. Yang memindahkan objek dalam memori, bidang seperti itu sekarang bisa tiba-tiba menjadi salah / sejajar. Sangat acak tentu saja, bisa sangat membingungkan :)
Tidak ada perbaikan sederhana kecuali satu, kode 64-bit adalah masa depan. Hapus pemaksaan jitter selama Microsoft tidak mengubah template proyek. Mungkin versi selanjutnya saat mereka merasa lebih percaya diri dengan Ryujit.
sumber
Mempersempitnya (tampaknya hanya memengaruhi runtime CLR 4.0 32-bit).
Perhatikan penempatan
var f = Stopwatch.Frequency;
make all the difference.Lambat (2700ms):
Cepat (800ms):
sumber
Stopwatch
juga mengubah kecepatan secara drastis. Mengubah tanda tangan metodeTest1(bool warmup)
dan menambahkan kondisional dalamConsole
output:if (!warmup) { Console.WriteLine(...); }
juga memiliki efek yang sama (tersandung pada ini saat membangun pengujian saya untuk merepro masalah).Tampaknya ada beberapa bug di Jitter karena perilakunya bahkan lebih aneh. Perhatikan kode berikut:
Ini akan berjalan dalam
900
ms, sama seperti casing stopwatch luar. Namun, jika kami menghapusif (!warmup)
kondisi tersebut, ini akan berjalan dalam3000
ms. Yang lebih aneh lagi, kode berikut juga akan berjalan dalam900
ms:Catatan Saya telah menghapus
a.X
dana.Y
referensi dariConsole
output.Saya tidak tahu apa yang sedang terjadi, tetapi ini berbau cukup buggy bagi saya dan ini tidak terkait dengan memiliki luar
Stopwatch
atau tidak, masalahnya tampaknya sedikit lebih umum.sumber
a.X
dana.Y
, compiler mungkin bebas untuk mengoptimalkan hampir semua yang ada di dalam loop, karena hasil operasi tidak digunakan.a.X
dana.Y
tidak membuatnya berjalan lebih cepat daripada ketika Anda memasukkanif (!warmup)
kondisi atau OPouterSw
, yang berarti tidak mengoptimalkan apa pun, itu hanya menghilangkan bug apa pun yang membuat kode berjalan pada kecepatan suboptimal (3000
ms bukan900
ms).warmup
itu benar, tetapi dalam kasus bahwa garis bahkan tidak dicetak, sehingga kasus di mana ia tidak bisa dicetak sebenarnya referensia
. Saya tetap ingin memastikan bahwa saya selalu mereferensikan hasil perhitungan di suatu tempat di dekat akhir metode, setiap kali saya melakukan benchmarking.