Часть III. Оборудование.

  • Как узнать размер кеша L1, используя только Java.

Очень сложно узнать свойства оборудования из кода Java. Потому что между нашим скомпилированным кодом и оборудованием, таким как JVM и JIT-компилятор, существует множество абстрактных слоев. Как мы знаем, в Java нет API для работы с низкоуровневыми объектами, такими как кэш L1, строка кэша и т. Д. Есть два способа узнать размер кеша ЦП с помощью Java.

  1. Через JNI вызывается низкоуровневый код (не чистая Java)
  2. Собираем статистику и на ее основе делаем выводы.

Почему мы не можем просто запустить конкретный код и измерить его? Из-за оптимизации JIT-компилятора нам нужно собрать некоторую статистику. После компиляции нашего Java-кода в байт-код мы можем запускать этот код в JVM. Более того, JVM может и будет перекомпилировать наш байтовый код в машинные инструкции, чтобы повысить производительность часто выполняемых фрагментов кода. В JVM есть счетчик запущенного кода. Если определенная часть кода выполнялась более N раз, JVM перекомпилирует этот фрагмент в аппаратные машинные инструкции. Как мы знаем, байт-код интерпретируется медленнее, чем перекомпилированный. Это означает, что нам нужен какой-то цикл, который запускает наш код до тех пор, пока JIT не перекомпилирует наш код в машинные инструкции. То есть я пишу функцию, которая определяет время доступа к элементу массива и запускаю ее 10 раз. Я предполагаю, что выходы в начале должны колебаться и оставаться более стабильными к концу. После этого я рассмотрю последние результаты, так как они более точные, и на основании этого сделаю вывод.
Перед проведением эксперимента мне нужно сделать некоторые предположения:

  1. Размер строки кэша на машине, на которой мы будем запускать наш код, равен 64 кБ. Практически у всех современных процессоров строка кэша равна 64 КБ.
  2. Когда вы пытаетесь прочитать некоторые данные из массива, с точки зрения оптимизации, контроллер кеша загружает данные и их соседей в кеш процессора. Теоретически, если вы читаете какие-то данные, есть большая вероятность, что вы, вероятно, сможете прочитать следующий элемент в будущем. Этот принцип называется Местность ссылки. Я предполагаю, что ваш процессор поддерживает эту технику. Практически все современные процессоры поддерживают эту технику. Благодаря этой технике попадание в кэш требует минимальных затрат времени.

Поясним эту диаграмму. На этом рисунке показано, что у нас есть один Арифметико-логический блок (АЛУ). И кэш L1 между ALU и RAM. Что мы знаем? Мы знаем, что ALU читает данные из ближайшей кеш-памяти. Кроме того, мы знаем, что кеш L1 хранит данные в отличие от ОЗУ (по битам). Кэш L1 имеет дело со строками кэша. Как только мы читаем первый элемент массива, другая часть массива также зеркалируется в кэше L1 (на основе принципа локальности ссылки). Первое чтение данных достаточно дорогое, потому что ALU пытается прочитать данные из кеша L1, при этом в L1 нет необходимых данных (Cache miss), а Cache Controller переходит в RAM, получает необходимые данные и записывает в L1. кеш.

byte[] array = new byte[128 * 1024];

На этом этапе мы объявили байтовый массив размером 128 Кбайт. Мы увеличим размер до 128 КБ с шагом 8 КБ. Я предполагаю, что размер кеша L1 меньше 128 КБ.

for (int len = 8192; len <= array.length; len += 8192) {
   ....
}

Я буду повторять каждый шаг 10 000 раз.

for (int i = 0; i < 10000; i++) {
    for (int k = 0; k < len; k += 64) {
        array[k] = 1;
    }
}

Вы заметили, что k + = 64? У нас есть байтовый массив. То есть каждый элемент массива занимает 1 байт, в то время как размер строки кэша в современных процессорах составляет 64 байта. Чтобы всегда попадать в новую строку кэша, мы пропускаем 64 байта и читаем следующие данные.

Вы заметили, что k + = 64? У нас есть байтовый массив. Это означает, что каждый элемент массива занимает 1 байт, в то время как размер строки кэша в современных процессорах составляет 64 байта. Чтобы всегда попадать в новую строку кэша, мы пропускаем 64 байта и читаем следующие данные.

На рисунке выше показана ситуация, когда размер считываемых данных меньше размера кэша L1. Каждый раз, когда мы пытаемся прочитать какие-либо данные в заданном диапазоне массива, ALU считывает их из кеша L1. В этом случае время чтения достаточно мало и занимает почти одинаковое время для любой операции чтения данных. Представим, что размер кэша L1 составляет 16 КБ, в первом цикле мы работаем с данными 8 КБ. 8 КБ данных помещается в кэш L1, и во время цикла L1 не становится недействительным (потому что мы не работаем с другими данными из кучи во время цикла). Переходим к следующей итерации. Вторая петля. Постараемся работать с данными размером до 16кБ. В кэш L1 помещается 16 КБ данных. И нет необходимости снова признавать недействительным. В результате мы можем видеть почти одинаковое время работы для них обоих.

Как только мы начинаем работать с данными, превышающими размер кеша L1. Каждый раз, когда ALU пытается получить несуществующие данные в кэше L1, контроллер кеша удаляет самую раннюю строку кэша и переносит данные из ОЗУ. Как вы знаете, это дорогостоящая операция и займет немного больше времени.
Весь код:

public class CacheLine {


    public static void main(String[] args) {
        CacheLine cacheLine = new CacheLine();
        cacheLine.startTesting();
    }

    private void startTesting() {
        byte[] array = new byte[128 * 1024];
        for (int testIndex = 0; testIndex < 10; testIndex++) {
            testMethod(array);
            System.out.println("--------- // ---------");
        }

    }

    private void testMethod(byte[] array) {
        for (int len = 8192; len <= array.length; len += 8192) {

            long t0 = System.nanoTime();
            for (int i = 0; i < 10000; i++) {
                for (int k = 0; k < len; k += 64) {
                    array[k] = 1;
                }
            }

            long dT = System.nanoTime() - t0;
            System.out.println("len: " + len/1024 + " dT: " + dT + " dT/stepCount: " + (dT) / len);
        }
    }

Результат:

len: 8 dT: 6713868 dT/stepCount: 819
len: 16 dT: 13893531 dT/stepCount: 847
len: 24 dT: 3767246 dT/stepCount: 153
len: 32 dT: 4835914 dT/stepCount: 147
len: 40 dT: 17192074 dT/stepCount: 419
len: 48 dT: 29916555 dT/stepCount: 608
len: 56 dT: 27022154 dT/stepCount: 471
len: 64 dT: 31322649 dT/stepCount: 477
len: 72 dT: 31709804 dT/stepCount: 430
len: 80 dT: 38674516 dT/stepCount: 472
len: 88 dT: 55381082 dT/stepCount: 614
len: 96 dT: 44973626 dT/stepCount: 457
len: 104 dT: 44094401 dT/stepCount: 414
len: 112 dT: 48243842 dT/stepCount: 420
len: 120 dT: 52118701 dT/stepCount: 424
len: 128 dT: 56539132 dT/stepCount: 431
 — — — — — // — — — — -
.....8 more logs.....
 — — — — — // — — — — -
len: 8 dT: 899320 dT/stepCount: 109
len: 16 dT: 1899883 dT/stepCount: 115
len: 24 dT: 2636621 dT/stepCount: 107
len: 32 dT: 3644196 dT/stepCount: 111
len: 40 dT: 16665546 dT/stepCount: 406
len: 48 dT: 19825289 dT/stepCount: 403
len: 56 dT: 23232781 dT/stepCount: 405
len: 64 dT: 28438350 dT/stepCount: 433
len: 72 dT: 32435851 dT/stepCount: 439
len: 80 dT: 35778581 dT/stepCount: 436
len: 88 dT: 38699155 dT/stepCount: 429
len: 96 dT: 39914075 dT/stepCount: 406
len: 104 dT: 44077004 dT/stepCount: 413
len: 112 dT: 50235949 dT/stepCount: 438
len: 120 dT: 51788150 dT/stepCount: 421
len: 128 dT: 53638625 dT/stepCount: 409
 — — — — — // — — — — -

Здорово. Мы сделали это. Теперь, после некоторого цикла, JNI решил перекомпилировать наш код, и мы можем более точно измерить время выполнения.

При анализе журнала можно заметить, что до 32 Кбайт время доступа было практически таким же. Стартовое время доступа 40кБ увеличено почти в 4 раза. Это означает, что мы перестали попадать в кеш. Основываясь на этом эксперименте, мы можем сказать, что размер кэша L1 моего ЦП равен 32 КБ.

Вывод:
В: можно ли узнать размер кеша L1 с помощью чистой Java? Да, мы можем
A: Да, это так. Но это недостаточно эффективно, и для этой задачи лучше использовать более низкоуровневые инструменты.

Предыдущий пост