Panduan untuk Berbagi Palsu dan @Contended

1. Ikhtisar

Dalam artikel ini, kita akan melihat bagaimana terkadang berbagi palsu dapat mengubah multithreading melawan kita.

Pertama, kita akan memulai dengan sedikit teori cache dan lokalitas spasial. Kemudian kita akan menulis ulang utilitas serentak LongAdder dan membandingkannya dengan implementasi java.util.concurrent . Di sepanjang artikel ini, kami akan menggunakan hasil benchmark di berbagai tingkat untuk menyelidiki efek dari berbagi palsu.

Bagian artikel terkait Java sangat bergantung pada tata letak memori objek. Karena detail tata letak ini bukan bagian dari spesifikasi JVM dan diserahkan kepada kebijaksanaan implementor, kami hanya akan fokus pada satu implementasi JVM tertentu: HotSpot JVM. Kami juga dapat menggunakan istilah JVM dan HotSpot JVM secara bergantian di seluruh artikel.

2. Garis Cache dan Koherensi

Prosesor menggunakan tingkat cache yang berbeda - saat prosesor membaca nilai dari memori utama, prosesor mungkin menyimpan nilai tersebut untuk meningkatkan kinerja.

Ternyata, sebagian besar prosesor modern tidak hanya menyimpan nilai yang diminta, tetapi juga menyimpan beberapa nilai yang berdekatan . Pengoptimalan ini didasarkan pada gagasan lokalitas spasial dan secara signifikan dapat meningkatkan kinerja aplikasi secara keseluruhan. Sederhananya, cache prosesor bekerja dalam hal baris cache, bukan nilai tunggal yang dapat disimpan dalam cache.

Saat beberapa prosesor beroperasi di lokasi memori yang sama atau berdekatan, mereka mungkin akan berbagi baris cache yang sama . Dalam situasi seperti itu, penting untuk menjaga agar cache yang tumpang tindih di inti yang berbeda tetap konsisten satu sama lain. Tindakan menjaga konsistensi seperti itu disebut koherensi cache.

Ada beberapa protokol untuk menjaga koherensi cache antara inti CPU. Pada artikel ini, kita akan membahas tentang protokol MESI.

2.1. Protokol MESI

Dalam protokol MESI, setiap baris cache dapat berada di salah satu dari empat status berbeda berikut: Dimodifikasi, Eksklusif, Dibagikan, atau Tidak Valid. Kata MESI adalah singkatan dari negara-negara bagian ini.

Untuk lebih memahami bagaimana protokol ini bekerja, mari kita telusuri contohnya. Misalkan dua inti akan membaca dari lokasi memori terdekat:

Core A membaca nilai a dari memori utama. Seperti yang ditunjukkan di atas, inti ini mengambil beberapa nilai lagi dari memori dan menyimpannya ke dalam baris cache. Kemudian menandai baris cache itu sebagai eksklusif karena inti A adalah satu-satunya inti yang beroperasi pada baris cache ini . Mulai sekarang, jika memungkinkan, inti ini akan menghindari akses memori yang tidak efisien dengan membaca dari baris cache.

Setelah beberapa saat, inti B juga memutuskan untuk membaca nilai b dari memori utama:

Karena a dan b sangat dekat satu sama lain dan berada di baris cache yang sama, kedua inti akan menandai baris cache mereka sebagai dibagikan .

Sekarang, misalkan inti A memutuskan untuk mengubah nilai a :

Inti A menyimpan perubahan ini hanya di buffer penyimpanannya dan menandai baris cache-nya sebagai diubah . Juga, ini mengkomunikasikan perubahan ini ke inti B, dan inti ini akan, pada gilirannya, menandai baris cache-nya sebagai tidak valid .

Begitulah cara prosesor yang berbeda memastikan bahwa cache mereka koheren satu sama lain.

3. Berbagi Palsu

Sekarang, mari kita lihat apa yang terjadi ketika inti B memutuskan untuk membaca kembali nilai b . Karena nilai ini tidak berubah baru-baru ini, kami mungkin mengharapkan pembacaan cepat dari baris cache. Namun, sifat dari arsitektur multiprosesor bersama membatalkan harapan ini dalam kenyataan.

Seperti yang disebutkan sebelumnya, seluruh baris cache dibagikan di antara dua inti. Karena garis cache untuk inti B adalah valid sekarang, harus membaca nilai b dari memori utama lagi :

Seperti yang ditunjukkan di atas, membaca nilai b yang sama dari memori utama bukan satu-satunya inefisiensi di sini. Akses memori ini akan memaksa inti A untuk membersihkan buffer penyimpanannya, karena inti B perlu mendapatkan nilai terbaru . Setelah membersihkan dan mengambil nilai, kedua inti akan berakhir dengan versi baris cache terbaru yang diberi tag dalam status bersama lagi:

Jadi, ini memaksakan kehilangan cache ke satu inti dan buffer awal mengalir ke inti lainnya, meskipun kedua inti tersebut tidak beroperasi di lokasi memori yang sama . Fenomena ini, yang dikenal sebagai berbagi palsu, dapat merusak kinerja secara keseluruhan, terutama bila tingkat cache yang terlewat tinggi. Untuk lebih spesifiknya, ketika kecepatan ini tinggi, prosesor akan terus-menerus menjangkau memori utama daripada membaca dari cache mereka.

4. Contoh: Garis Dinamis

Untuk mendemonstrasikan bagaimana berbagi palsu dapat mempengaruhi throughput atau latensi aplikasi, kita akan melakukan cheat di bagian ini. Mari tentukan dua kelas kosong:

abstract class Striped64 extends Number {} public class LongAdder extends Striped64 implements Serializable {}

Tentu saja, kelas kosong tidak begitu berguna, jadi mari kita salin-tempel beberapa logika ke dalamnya.

Untuk kelas Striped64 kita, kita dapat menyalin semuanya dari kelas java.util.concurrent.atomic.Striped64 dan menempelkannya ke kelas kita. Harap pastikan untuk menyalin pernyataan impor juga. Selain itu, jika menggunakan Java 8, kita harus memastikan untuk mengganti panggilan apa pun ke metode sun.misc.Unsafe.getUnsafe () menjadi yang khusus:

private static Unsafe getUnsafe() { try { Field field = Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { throw new RuntimeException(e); } }

Kita tidak bisa memanggil sun.misc.Unsafe.getUnsafe () dari aplikasi kita classloader, jadi kita harus menipu lagi dengan metode statis ini. Pada Java 9, bagaimanapun, logika yang sama diimplementasikan menggunakan VarHandles , jadi kita tidak perlu melakukan sesuatu yang khusus di sana, dan cukup salin-tempel saja sudah cukup.

Untuk kelas LongAdder , mari salin semuanya dari kelas java.util.concurrent.atomic.LongAdder dan tempelkan ke kelas kita. Sekali lagi, kita juga harus menyalin pernyataan import .

Sekarang, mari kita tolok ukur kedua kelas ini satu sama lain: LongAdder kustom dan java.util.concurrent.atomic.LongAdder.

4.1. Tolok ukur

Untuk membandingkan kelas-kelas ini satu sama lain, mari tulis patokan JMH sederhana:

@State(Scope.Benchmark) public class FalseSharing { private java.util.concurrent.atomic.LongAdder builtin = new java.util.concurrent.atomic.LongAdder(); private LongAdder custom = new LongAdder(); @Benchmark public void builtin() { builtin.increment(); } @Benchmark public void custom() { custom.increment(); } }

Jika kita menjalankan benchmark ini dengan dua fork dan 16 thread dalam mode benchmark throughput (setara dengan meneruskan argumen - -bm thrpt -f 2 -t 16 ″ ), maka JMH akan mencetak statistik ini:

Benchmark Mode Cnt Score Error Units FalseSharing.builtin thrpt 40 523964013.730 ± 10617539.010 ops/s FalseSharing.custom thrpt 40 112940117.197 ± 9921707.098 ops/s

The result doesn't make sense at all. The JDK built-in implementation dwarfs our copy-pasted solution by almost 360% more throughput.

Let's see the difference between latencies:

Benchmark Mode Cnt Score Error Units FalseSharing.builtin avgt 40 28.396 ± 0.357 ns/op FalseSharing.custom avgt 40 51.595 ± 0.663 ns/op

As shown above, the built-in solution also has better latency characteristics.

To better understand what's so different about these seemingly identical implementations, let's inspect some low-level performance monitoring counters.

5. Perf Events

To instrument low-level CPU events, such as cycles, stall cycles, instructions per cycle, cache loads/misses, or memory loads/stores, we can program special hardware registers on the processors.

As it turns out, tools like perf or eBPF are already using this approach to expose useful metrics. As of Linux 2.6.31, perf is the standard Linux profiler capable of exposing useful Performance Monitoring Counters or PMCs.

So, we can use perf events to see what’s going on at the CPU level when running each of these two benchmarks. For instance, if we run:

perf stat -d java -jar benchmarks.jar -f 2 -t 16 --bm thrpt custom

Perf will make JMH run the benchmarks against the copy-pasted solution and print the stats:

161657.133662 task-clock (msec) # 3.951 CPUs utilized 9321 context-switches # 0.058 K/sec 185 cpu-migrations # 0.001 K/sec 20514 page-faults # 0.127 K/sec 0 cycles # 0.000 GHz 219476182640 instructions 44787498110 branches # 277.052 M/sec 37831175 branch-misses # 0.08% of all branches 91534635176 L1-dcache-loads # 566.227 M/sec 1036004767 L1-dcache-load-misses # 1.13% of all L1-dcache hits

The L1-dcache-load-misses field represents the number of cache misses for the L1 data cache. As shown above, this solution has encountered around one billion cache misses (1,036,004,767 to be exact). If we gather the same stats for the built-in approach:

161742.243922 task-clock (msec) # 3.955 CPUs utilized 9041 context-switches # 0.056 K/sec 220 cpu-migrations # 0.001 K/sec 21678 page-faults # 0.134 K/sec 0 cycles # 0.000 GHz 692586696913 instructions 138097405127 branches # 853.812 M/sec 39010267 branch-misses # 0.03% of all branches 291832840178 L1-dcache-loads # 1804.308 M/sec 120239626 L1-dcache-load-misses # 0.04% of all L1-dcache hits

We would see that it encounters a lot fewer cache misses (120,239,626 ~ 120 million) compared to the custom approach. Therefore, the high number of cache misses might be the culprit for such a difference in performance.

Let's dig even deeper into the internal representation of LongAdder to find the actual culprit.

6. Dynamic Striping Revisited

The java.util.concurrent.atomic.LongAdder is an atomic counter implementation with high throughput. Instead of just using one counter, it's using an array of them to distribute the memory contention between them. This way, it will outperform the simple atomics such as AtomicLong in highly contended applications.

The Striped64 class is responsible for this distribution of memory contention, and this is how thisclass implements those array of counters:

@jdk.internal.vm.annotation.Contended static final class Cell { volatile long value; // omitted } transient volatile Cell[] cells;

Each Cell encapsulates the details for each counter. This implementation makes it possible for different threads to update different memory locations. Since we're using an array (that is, stripes) of states, this idea is called dynamic striping. Interestingly, Striped64 is named after this idea and the fact that it works on 64-bit data types.

Anyway, the JVM may allocate those counters near each other in the heap. That is, a few those counters will be in the same cache line. Therefore, updating one counter may invalidate the cache for nearby counters.

The key takeaway here is, the naive implementation of dynamic striping will suffer from false sharing. However, by adding enough padding around each counter, we can make sure that each of them resides on its cache line, thus preventing the false sharing:

As it turns out, the @jdk.internal.vm.annotation.Contended annotation is responsible for adding this padding.

The only question is, why didn't this annotation work in the copy-pasted implementation?

7. Meet @Contended

Java 8 introduced the sun.misc.Contended annotation (Java 9 repackaged it under the jdk.internal.vm.annotation package) to prevent false sharing.

Basically, when we annotate a field with this annotation, the HotSpot JVM will add some paddings around the annotated field. This way, it can make sure that the field resides on its own cache line. Moreover, if we annotate a whole class with this annotation, the HotSopt JVM will add the same padding before all the fields.

The @Contended annotation is meant to be used internally by the JDK itself. So by default, it doesn't affect the memory layout of non-internal objects. That's the reason why our copy-pasted adder doesn't perform as well as the built-in one.

To remove this internal-only restriction, we can use the -XX:-RestrictContended tuning flag when rerunning the benchmark:

Benchmark Mode Cnt Score Error Units FalseSharing.builtin thrpt 40 541148225.959 ± 18336783.899 ops/s FalseSharing.custom thrpt 40 546022431.969 ± 16406252.364 ops/s

As shown above, now the benchmark results are much closer, and the difference probably is just a bit of noise.

7.1. Padding Size

By default, the @Contended annotation adds 128 bytes of padding. That's mainly because the cache line size in many modern processors is around 64/128 bytes.

This value, however, is configurable through the -XX:ContendedPaddingWidth tuning flag. As of this writing, this flag only accepts values between 0 and 8192.

7.2. Disabling the @Contended

It's also possible to disable the @Contended effect via the -XX:-EnableContended tuning. This may prove to be useful when the memory is at a premium and we can afford to lose a bit (and sometimes a lot) of performance.

7.3. Use Cases

After its first release, the @Contended annotation has been used quite extensively to prevent false sharing in JDK's internal data structures. Here are a few notable examples of such implementations:

  • The Striped64 class to implement counters and accumulators with high throughput
  • The Thread class to facilitate the implementation of efficient random number generators
  • The ForkJoinPool work-stealing queue
  • The ConcurrentHashMap implementation
  • The dual data structure used in the Exchanger class

8. Conclusion

In this article, we saw how sometimes false sharing might cause counterproductive effects on the performance of multithreaded applications.

Untuk membuat masalah lebih konkret, kami melakukan benchmark penerapan LongAdder di Java terhadap salinannya dan menggunakan hasilnya sebagai titik awal untuk investigasi kinerja kami.

Selain itu, kami menggunakan alat kinerja untuk mengumpulkan beberapa statistik tentang metrik kinerja dari aplikasi yang berjalan di Linux. Untuk melihat lebih banyak contoh kinerja, sangat disarankan untuk membaca blog Branden Greg. Selain itu, eBPF, tersedia pada Linux Kernel versi 4.4, juga dapat berguna dalam banyak skenario penelusuran dan profil.

Seperti biasa, semua contoh tersedia di GitHub.