Проблемы кэшируемой памяти

Раскрывается приём работы с кэш-памятью, обеспечивающий эффективную работу приложения по критерию быстродействия в многопоточном сценарии.

Проблематика

Наличие кэшируемой памяти увеличивает быстродействие обработки данных, но усложняет работу системы при многопоточной обработке. Неоптимальная работа с кэшируемой памятью может сильно снизить эффективность параллельной обработки.

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

Распространенной проблемой кэш-памяти является так называемое ложное разделение данных (false sharing). Проблема связана с тем, что потоки работают с разными переменными, которые в оперативной памяти расположены физически близко. Дело в том, что в кэш-память загружается не конкретная переменная, а блок памяти (строка кэша), содержащая необходимую переменную. Размер строки кэша может составлять 64, 128, 512 байт (чтобы узнать размер строки кэша процессора, можно воспользоваться бесплатной утилитой CPU-Z). Если в одной строке кэша расположены несколько переменных, используемых в разных потоках, то в кэш-память каждого процессора будет загружена одна и та же строка. При изменении в одном потоке своей переменной, содержимое кэш-памяти других процессоров считается недействительным и требует обновления.

В качестве иллюстрации проблемы ложного разделения рассмотрим следующий фрагмент. В программе объявлена структура, содержащая несколько полей.

struct data 
{
  int x;
  int y;
}

Первый поток работает только с полем x, второй поток работает только с полем y. Таким образом, разделения данных и проблемы гонки данных между потоками нет. Но последовательное расположение в памяти структуры data, приводит к тому, что в кэш-память одного и другого процессора загружается строка размером 64 байт, содержащая 4-х байтовые поля x и y. При изменении поля в одном потоке происходит обновление строки кэша в другом потоке.

// Поток №1
for(int i=0; i<N; i++)
  data1.x++; 
// Поток №2
for(int i=0; i<N; i++)
  data1.y++;

Решение

Чтобы избежать последовательного расположения полей x и y в памяти, можно использовать дополнительные промежуточные поля.

Другой подход заключается в явном выравнивании полей в памяти с помощью атрибута FieldOffsetAttribute, который определен в пространстве System.Runtime.InteropServices:

// Явное выравнивание в памяти
[StructLayout(LayoutKind.Explicit)] 
struct data 
{
  [FieldOffset(0)] public int x; 
  [FieldOffset(64)] public int y; 
}

При достаточно большом значении N, разница в быстродействии кода с разделением кэша и без разделения может достигать 1.5-2 раз.

Все же самым эффективным решением при независимой обработке полей структуры будет применение локальных переменных внутри каждого потока. Разница в быстродействии может достигать нескольких десятков.