[Перевод] Что такое False Sharing кэша и как решить (пример Golang)

Go

Прежде чем объяснять ложное совместное использование кеша, необходимо кратко описать, как работает кеш в архитектуре ЦП.

Наименьшая единица кеша в ЦП — это строка кеша (общий размер строки кеша в ЦП сегодня составляет 64 байта). Таким образом, когда ЦП считывает переменную из памяти, он будет считывать все переменные рядом с этой переменной. Рисунок 1 представляет собой простой пример:

图1

Когда core1 считывает переменную a из памяти, он также считывает переменную b в кэш. (Кстати, я думаю, что основная причина, по которой ЦП считывает переменные пакетами из памяти, основана на теории пространственной локальности: когда ЦП обращается к переменной, он может вскоре прочитать переменную рядом с ней.) : Для теории пространственной локализации см.эта статья)

В этой архитектуре кэша есть проблема: если переменная существует в двух строках кэша на разных ядрах ЦП, как показано на рисунке 2:

图2

Когда Core1 обновляет переменную A:

图3

Когда core2 читает переменную b, кэш core2 пропускается, даже если переменная b не была изменена. Таким образом, core2 перезагружает все переменные в строке кэша из памяти, как показано на рисунке 4:

图4

Это ложное совместное использование кеша: одно ядро ​​ЦП, обновляющее переменную, заставляет другие ядра ЦП обновлять кеш. И все мы знаем, что чтение переменной процессора из кеша намного быстрее, чем чтение переменной из памяти. Таким образом, хотя эта переменная всегда присутствует в многоядерных процессорах, это может существенно повлиять на производительность.

Распространенным решением этой проблемы является заполнение кеша: заполнение бессмысленных переменных между переменными. Сделайте так, чтобы переменная занимала строку кэша ядра ЦП сама по себе, чтобы при обновлении других ядер другие переменные не заставляли это ядро ​​перезагружать переменную из памяти.

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

Это структура с тремя переменными uint64,

type NoPad struct {
	a uint64
	b uint64
	c uint64
}

func (myatomic *NoPad) IncreaseAllEles() {
	atomic.AddUint64(&myatomic.a, 1)
	atomic.AddUint64(&myatomic.b, 1)
	atomic.AddUint64(&myatomic.c, 1)
}

Вот еще одна структура, я использую [8]uint64 для заполнения кеша:

type Pad struct {
	a   uint64
	_p1 [8]uint64
	b   uint64
	_p2 [8]uint64
	c   uint64
	_p3 [8]uint64
}

func (myatomic *Pad) IncreaseAllEles() {
	atomic.AddUint64(&myatomic.a, 1)
	atomic.AddUint64(&myatomic.b, 1)
	atomic.AddUint64(&myatomic.c, 1)
}

Затем напишите простой код для запуска теста:

func testAtomicIncrease(myatomic MyAtomic) {
	paraNum := 1000
	addTimes := 1000
	var wg sync.WaitGroup
	wg.Add(paraNum)
	for i := 0; i < paraNum; i++ {
		go func() {
			for j := 0; j < addTimes; j++ {
				myatomic.IncreaseAllEles()
			}
			wg.Done()
		}()
	}
	wg.Wait()

}
func BenchmarkNoPad(b *testing.B) {
	myatomic := &NoPad{}
	b.ResetTimer()
	testAtomicIncrease(myatomic)
}

func BenchmarkPad(b *testing.B) {
	myatomic := &Pad{}
	b.ResetTimer()
	testAtomicIncrease(myatomic)
}

Результаты тестов с использованием MacBook Air 2014 года следующие:

$> go test -bench=.
BenchmarkNoPad-4 2000000000 0.07 ns/op
BenchmarkPad-4 2000000000 0.02 ns/op
PASS
ok 1.777s

Результаты тестов показывают, что он повышает производительность с 0,07 нс/операцию до 0,02 нс/операцию, что является большим улучшением.

Вы также можете проверить это на других языках, таких как Java, и я уверен, что вы получите такие же результаты.

Прежде чем применять это к своему коду, вы должны знать два важных момента:

  1. Убедитесь, что размер строки кэша ЦП в вашей системе: это связано с размером заполнения кэша, который вы используете.
  2. Заполнение большего количества переменных означает потребление большего количества ресурсов памяти. Запустите тесты в своем сценарии, чтобы убедиться, что такое потребление памяти того стоит.

Весь мой пример кодаGitHubначальство.