Посібник з помилкового обміну та @Contended

1. Огляд

У цій статті ми побачимо, як іноді помилковий обмін може перетворити багатопоточність проти нас.

Спочатку ми почнемо трохи з теорії кешування та просторової локалізації. Потім ми перепишемо паралельну утиліту LongAdder і порівняємо її з реалізацією java.util.concurrent . Протягом усієї статті ми використовуватимемо контрольні результати на різних рівнях для вивчення ефекту неправдивого обміну.

Частина статті, пов’язана з Java, сильно залежить від розміщення пам’яті об’єктів. Оскільки ці деталі макету не є частиною специфікації JVM і залишаються на розсуд реалізатора, ми зосередимось лише на одній конкретній реалізації JVM: HotSpot JVM. Ми також можемо використовувати терміни JVM та HotSpot JVM як взаємозамінні у всій статті.

2. Рядок кеш-пам’яті та когерентність

Процесори використовують різні рівні кешування - коли процесор читає значення з основної пам'яті, він може кешувати це значення для поліпшення продуктивності.

Як виявляється, більшість сучасних процесорів не тільки кешують запитуване значення, але й кешують ще кілька значень поблизу . Ця оптимізація базується на ідеї просторового розташування та може значно покращити загальну продуктивність програм. Простіше кажучи, кеш-пам’ять процесора працює з точки зору рядків кеш-пам’яті, а не окремих значень кешування.

Коли кілька процесорів працюють в одному або сусідніх місцях пам'яті, вони можуть у підсумку використовувати один і той же рядок кешу . У таких ситуаціях важливо підтримувати ці перекриваються кеші в різних ядрах узгодженими один з одним. Акт збереження такої послідовності називається когерентністю кешу.

Існує досить багато протоколів для підтримки когерентності кешу між ядрами процесора. У цій статті ми поговоримо про протокол MESI.

2.1. Протокол MESI

У протоколі MESI кожен рядок кешу може знаходитися в одному з цих чотирьох різних станів: модифікований, ексклюзивний, спільний або недійсний. Слово MESI є абревіатурою цих держав.

Щоб краще зрозуміти, як працює цей протокол, розглянемо приклад. Припустимо, два ядра збираються читати з сусідніх місць пам'яті:

Ядро A зчитує значення a з основної пам'яті. Як показано вище, це ядро ​​отримує ще кілька значень з пам'яті і зберігає їх у рядку кешу. Тоді він позначає, що лінія кешу як ексклюзивна, оскільки ядро A є єдиним ядром, що працює на цій лінії кешу . Відтепер, коли це можливо, це ядро ​​буде уникати неефективного доступу до пам'яті, читаючи натомість із рядка кешу.

Через деякий час ядро B також вирішує прочитати значення b з основної пам'яті:

Оскільки a і b знаходяться так близько один до одного і знаходяться в одному рядку кешу, обидва ядра позначать свої рядки кешу як спільні .

Тепер припустимо, що ядро A вирішує змінити значення a :

Ядро A зберігає цю зміну лише у своєму буфері зберігання та позначає свою лінію кешу як змінену . Крім того, він передає цю зміну ядру B, і це ядро, в свою чергу, позначить свій рядок кешу як недійсний .

Ось як різні процесори забезпечують узгодженість їх кеш-пам’яті.

3. Помилковий спільний доступ

Тепер давайте подивимося, що станеться, коли ядро B вирішить перечитати значення b . Оскільки це значення останнім часом не змінювалось, ми можемо очікувати швидкого зчитування з рядка кешу. Однак природа спільної багатопроцесорної архітектури виправдовує це сподівання насправді.

Як уже згадувалося раніше, весь рядок кеш-пам’ятки був спільним між двома ядрами. Оскільки рядок кешу для ядра B зараз недійсний , йому слід знову прочитати значення b з основної пам'яті :

Як показано вище, зчитування того самого значення b з основної пам'яті є не єдиною неефективністю тут. Цей доступ до пам'яті змусить ядро A очистити свій буфер сховища, оскільки ядро B має отримати останнє значення . Після очищення та отримання значень обидва ядра знову отримають останню версію рядка кешу, позначену спільним станом:

Отже, це накладає пропуск кеш-пам'яті на одне ядро ​​і ранній змив буфера на інше, навіть незважаючи на те, що два ядра не працювали в одному і тому ж місці пам'яті . Це явище, відоме як помилковий обмін, може зашкодити загальній продуктивності, особливо коли частота пропусків кешу висока. Якщо бути більш конкретним, коли цей показник високий, процесори будуть постійно звертатися до основної пам'яті, а не читати з кеш-пам’яті.

4. Приклад: Динамічне смугове

Щоб продемонструвати, як помилковий обмін може вплинути на пропускну здатність або затримку програм, ми обдуримо в цьому розділі. Давайте визначимо два порожні класи:

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

Звичайно, порожні класи не так корисні, тому давайте скопіюємо в них трохи логіки.

Для нашого класу Striped64 ми можемо скопіювати все з класу java.util.concurrent.atomic.Striped64 і вставити в наш клас. Будь ласка, не забудьте також скопіювати оператори імпорту . Крім того, якщо ви використовуєте Java 8, нам слід переконатись замінити будь-який виклик методу sun.misc.Unsafe.getUnsafe () на власний:

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); } }

Ми не можемо викликати sun.misc.Unsafe.getUnsafe () із нашого завантажувача класів, тому нам доведеться знову обдурити за допомогою цього статичного методу. Однак, як і в Java 9, та сама логіка реалізована за допомогою VarHandles , тому нам не потрібно буде робити там нічого особливого, і достатньо буде простого копіювання.

Для класу LongAdder давайте скопіюємо все з класу java.util.concurrent.atomic.LongAdder і вставмо в наш. Знову ж таки, нам також слід скопіювати оператори імпорту .

Тепер давайте порівняємо ці два класи один з одним: наші власні LongAdder та java.util.concurrent.atomic.LongAdder.

4.1. Орієнтир

Щоб порівняти ці класи один з одним, давайте напишемо простий бенчмарк JMH:

@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(); } }

Якщо ми запустимо цей еталон з двома форками та 16 потоками в режимі тесту пропускної здатності (еквівалент передачі аргументів - -bm thrpt -f 2 -t 16 ″ ), тоді JMH надрукує таку статистику:

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.

Щоб зробити ситуацію більш конкретною, ми порівняли реалізацію LongAdder у Java з її копією та використали її результати як вихідну точку для наших досліджень продуктивності.

Крім того, ми використовували інструмент perf , щоб зібрати статистику про показники продуктивності запущеного додатка на Linux. Щоб побачити більше прикладів perf, настійно рекомендуємо прочитати блог Брендена Грега. Більше того, eBPF, доступний з версії 4.4 Kernel Linux, також може бути корисним у багатьох сценаріях відстеження та профілювання.

Як завжди, усі приклади доступні на GitHub.