Hashmap по версии Golang вместе с реализацией на дженериках

b89101badc588827ea9f0e815610e72c.png

Привет. Сегодня рассмотрим такую интересную структуру данных как hashmap, а именно ее реализацию в Go. Вкратце разберем что такое hashmap, как это выглядит под капотом Go 1.19. Посмотрим отличия реализации с Java и Python. Реализуем hashmap из-под капота с помощью дженериков.

Что такое hashmap

Для тех, кто еще не знаком с hashmap, прикладываю информацию из Википедии:
Hashmap — это структура данных, которая позволяет хранить пары ключ-значение. Ключи в hashmap уникальные. Можно представить как обычный массив, в котором для индекса можем использовать не только целые числа, но и, например, строки.

Сложность:

Операция

Средняя

Худшая

Вставка

O(1)

O(n)

Поиск

O(1)

O(n)

Удаление

O(1)

O(n)

Память

O(n)

O(n)

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

  • Хэш-функция (Hash function). Под ней понимают функцию, которая принимает значение (ключ) неопределенного размера и возвращает значение фиксированной длины. В случае c Go она возвращает uint64. Одно из главных свойств — стабильность. Для одного и того же переданного значения она должна возвращать один и тот же результат;

  • Бакет (Bucket/Slot). Так называемая структура данных, в которой хранятся ключи и значения в мапе. В некоторых реализациях hashmap в одном бакете хранится одно значение, а в других — несколько. Например, в Go данные внутри бакета хранятся в массиве, и в одном бакете может быть до восьми элементов;

  • Коллизия (Collision). Так как хэш-функция не идеальна, передав в нее два разных значения мы можем получить один и тот же результат. В случае с бакетами нам нужно два разных значения положить в один и тот же бакет. Это называется коллизией. Для реализации hashmap необходимо иметь алгоритм их разрешения. Существует несколько таких алгоритмов (стратегий):

    • Closed addressing. Храним элементы с одинаковым хэшем с помощью дополнительных структур данных, таких как: связный список, двоичное дерево, массив и др. Используется в следующих языках: Go, Java, Scala;

    • Open addressing. В бакете хранится только одно значение. При возникновении коллизии выбирается следующий свободный бакет по какой-либо формуле. Такая стратегия используется в Python, Ruby, Rust, C++ и др;

    • Perfect hashing. Выбирается такая хэш-функция, при которой не будет коллизий. Подбирается для статичного, заранее известного набора ключей.

  • Фактор заполненности мапы (Overload factor). Это число (порог), превысив которое считается, что нужно увеличить количество бакетов (обычно вдвое) для сохранения константной скорости O(1)

Как hashmap реализована в Go

Упрощенный вариант работы hashmap в Go.Упрощенный вариант работы hashmap в Go.

Рассмотрим по шагам упрощенный принцип работы hashmap. Для примера будем хранить оценки фильмов в формате название-оценка:

  • Передаем ключ "The Matrix" в хэш-функцию. Получаем uint64 — 18002143618149980261;

  • Вычисляем маску для наших бакетов. Она равна n-1, где n — количество бакетов. В примере 4 бакета, а маска равна 3;

  • Вычисляем номер бакета, в котором сохраним наше значение. Для этого используем битовое «и»: hash & mask == 18002143618149980261 & 3 == 01 & 11(отбросили нули) = 01, что рано 1 в десятичной системе счисления;

  • Идем в бакет по индексу 1 и перебором проверяем массив на наличие нашего ключа. Если находим совпадение по ключу, то перезаписываем значение, иначе добавляем в первое свободное место;

Под капотом структуры мапы и бакета выглядят так:
runtime/map.go

// A header for a Go map.
type hmap struct {
	count     int // размер мапы. используется функцией len()
	flags     uint8
	B         uint8  // log_2 количества бакетов. Для 8 бакетов B=3, для 16 B=4 и так далее.
	noverflow uint16 // примерное число переполненных бакетов
	hash0     uint32 // seed для хэш-функции, генерируется при создании мапы. нужен для рандомизации хэш-функции

	buckets    unsafe.Pointer // ссылка на массив из 2^B бакетов; nil, если count==0
	oldbuckets unsafe.Pointer // ссылка на массив предыдущих бакетов. в процессе роста мапы здесь будет массив старых бакетов, откуда потихоньку будут переноситься значения в новые бакеты.
	nevacuate  uintptr        // количество "эвакуированных" бакетов.

	extra *mapextra // опциональные поля
}

// A bucket for a Go map.
type bmap struct {
	tophash [bucketCnt]uint8 // массив tophash
	// После массива tophash идет массив размера bucketCnt ключей и массив размера bucketCnt элементов.
}

В структуре бакета явно не описаны поля ключей, значений и ссылка на overflow бакет. Для получения этих значений используется адресная арифметика через unsafe.Pointer.

Интересности реализации:

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

  • При отсутствии элемента возвращается нулевое значение для типа. Вторым параметром можно получить флаг наличия элемента по ключу;

  • Нельзя получить адрес элемента. Потому что при росте мапы оно переедет в другой бакет и адрес у него, соответственно, поменяется;

  • Мапа не безопасна для конкурентного использования. Для этого можно использовать обертку из sync.Map или мьютекс;

  • Порядок итерации не сохраняется. При каждой новой итерации мапы последовательность возвращаемых элементов может отличаться. Под капотом каждый раз выбирается рандомный бакет, с которого начинается итерация. Для сохранения нужного порядка придется сохранять ключи в отдельном массиве и итерироваться по нему;

  • При каждом создании мапы генерируется seed для рандомизации хэш-функции. Это сделано для безопасности, так как зная хэш-функцию можно подобрать такие ключи, что все значения попадут в один бакет и мы получим линейную скорость поиска;

  • При коллизиях используется стратегия сlosed addressing. Мы перебираем все ячейки бакета (их 8) и ищем первое свободное место;

  • OverloadFactor равен 6.5 (~81% заполненности бакетов). Когда бакеты в среднем заполнены больше чем на 6.5 элементов, тригерится рост мапы, и все элементы перемещаются в новые бакеты, которых создается в два раза больше.

  • При росте элементы переносятся в новые бакеты постепенно, а не все сразу;

Некоторые отличия от реализаций в других языках

Python 3.6+. Подробнее можно посмотреть в очень интересном (правда) докладе Raymond Hettinger Modern Python Dictionaries (2017)

  • Сохраняется последовательность вставки;

  • При коллизиях свободный бакет ищется с помощью стратегии open addressing. Для оптимизации поиска свободного бакета используются следующие формулы: j = ((5*j) + 1) mod 2**i и j = (5*j) + 1 + perturb;

  • Данные хранятся отдельно от бакетов. Сами бакеты используются только как указатели (индексы) на данные. Выглядит это примерно так:

  indices =  [None, 1, None, None, None, 0, None, 2]
  entries =  [[-9092791511155847987, 'timmy', 'red'],
              [-8522787127447073495, 'barry', 'green'],
              [-6480567542315338377, 'guido', 'blue']]

Java. Разбор можно почитать в статье Внутренняя работа HashMap в Java:

  • При коллизиях используется стратегия сlosed addressing, но вместо массивов используется связный список. Также когда длина списка больше восьми он превращается в TreeMap. Это дает скорость поиска O(logn)вместо O(n)как в случае со связным списком или массивом;

  • Все ключи должны быть объектами. Для этого примитивные типы (boolean, int, char, float и т.д) должны быть сконвертированы в объекты через boxing;

  • LoadFactor по умолчанию — 75%. При создании мапы можно указать свое значение параметра.

Также небольшое сравнение с реализациями Java и C++ можно посмотреть у Dave Cheney.

Кодирование

Реализуем базово hashmap из исходников Go 1.19. Не будем учитывать рост мапы, конкурентное обращение и возможность использовать разные типы для ключей.

Начнем с ведер

7dc8143ebe09a53c5d8372ff5a49ff84.png

Определим структуру бакета.

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

const bucketSize = 8 // размер массивов внутри бакета

type bucket[T any] struct {
	tophash [bucketSize]uint8 // массив первых восьми бит от хэша ключа; некоторые значения используем как флаги, подробности ниже.

	keys   [bucketSize]string // массив ключей
	values [bucketSize]T // массив значений

	overflow *bucket[T] // ссылка на бакет в случае переполнения текущего
}

Про tophash

В массиве с tophash мы резервируем несколько значений для дальнейшего использования. Ко всем значениям tophash, которые меньше minTopHash будем прибавлять его.

const (
	emptyRest  = 0 // эта и все следующие ячейки с бОльшим индексом пустые
	emptyCell  = 1 // ячейка пустая
	minTopHash = 3 // минимальное значение tophash означающее что в ячейке есть значение
)

По умолчанию, в Go массив заполнен нулевыми значениями, соответственно для tophash массива, который типа uint8, будут нули. При добавлении или получении элемента из бакета в первую очередь ориентируемся на массив tophash, а не массив ключей. Это позволяет оптимизировать поиск внутри бакета.

Добавляем элемент в мапу

Алгоритм добавления элемента:

  • В цикле ищем совпадения по tophash и ключу или свободное место. Сохраняем данные и возвращаем соответствующий флаг для подсчета элементов в мапе.

  • Если не нашли нужного места, то повторяем упражнения на overflow бакете.

// Добавляем значение в бакет.
// Если переданные ключ уже присутствует в бакете, то значение перезапишется.
// Если бакет переполнен, то значение сохраняется в бакете overflow.
func (b *bucket[T]) Put(key string, topHash uint8, value T) (isAdded bool) {
	var insertIdx *int

	for i := range b.tophash {
		// сравниваем tophash а не ключ, т.к. это позволяет нам использовать флаги и это работает быстрее
		if b.tophash[i] != topHash {
			if b.tophash[i] == emptyRest {
				insertIdx = new(int)
				*insertIdx = i
				break
			}

			if insertIdx == nil && isCellEmpty(b.tophash[i]) {
				insertIdx = new(int)
				*insertIdx = i
			}
			continue
		}

		// tophash значения не уникальны, по этому при совпадении проверяем дополнительно и сам ключ
		if b.keys[i] != key {
			continue
		}

		b.values[i] = value
		return false
	}

	// если индекс для вставки не определен, то проверяем overflow бакет
	if insertIdx == nil {
		if b.overflow == nil {
			b.overflow = &Bucket[T]{}
		}

		return b.overflow.Put(key, topHash, value)
	}

	// сохраняем ключ, значение и tophash
	b.keys[*insertIdx] = key
	b.values[*insertIdx] = value
	b.tophash[*insertIdx] = topHash

	// возвращаем признак успеха. по нему калькулируем количество элементов в мапе.
	return true
}

// проверяем что значение tophash <= чем зарезервированное значение для пустой ячейки
func isCellEmpty(val uint8) bool {
	return val <= emptyCell
}

Получаем элемент

Алгоритм поиска элемента такой же как и в методе Put. Дополнительно возвращаем флаг наличия элемента в бакете.

func (b bucket[T]) Get(key string, topHash uint8) (T, bool) {
	for i := range b.tophash {
		if b.tophash[i] != topHash {
			// константа означает что все следующие ячейки пустые -- выходим из цикла.
			if b.tophash[i] == emptyRest {
				break
			}
			continue
		}

		if !isCellEmpty(b.tophash[i]) && b.keys[i] == key {
			return b.values[i], true
		}
	}

	// проверим бакет overflow
	if b.overflow != nil {
		return b.overflow.Get(key, topHash)
	}

	return *new(T), false
}

Удаляем элемент

Вместо фактического удаления просто помечаем ячейку пустой.

// Delete - удаляет элемент по переданному ключу
func (b *bucket[T]) Delete(key string, topHash uint8) (deleted bool) {
	for i := range b.tophash {
		if b.tophash[i] != topHash {
			// если встречаем флаг все след. ячейки пустые, то просто выходим из функции
			if b.tophash[i] == emptyRest {
				return false
			}
			continue
		}

		// дополнительно проверяем совпадение по ключу, т.к. tophash не уникальный
		if b.keys[i] == key {
			b.tophash[i] = emptyCell
			return true
		}
	}

	// проверяем overflow бакет
	if b.overflow != nil {
		return b.overflow.Delete(key, topHash)
	}

	return false
}

Структура мапы

Храним размер мапы, количество бакетов, seed для хэш-функции и слайс самих бакетов.

// hmap - структура мапы
type hmap[T any] struct {
	len  int // количество реальных значений в мапе
	b    uint8 // log_2 от количества бакетов
	seed uint64 // начальный хэш

	buckets []Bucket[T] // слайс бакетов
}

// интерфейс hashmap, методы которого реализуем
type Hashmap[T any] interface {
	Get(key string) T
	Get2(key string) (T, bool)
	Put(key string, value T)
	Delete(key string)
	Range(f func(k string, v T) bool)
	Len() int
}

При инициализации генерируем seed и создаем нужное количество бакетов.

const (
	// Максимальное среднее количество элементов в бакете которое тригерит рост мапы - 6.5
	// Представлен как loadFactorNum/loadFactorDen, для того чтобы производить вычисления через int.
	loadFactorNum = 13
	loadFactorDen = 2

    ptrSize = 4 << (^uintptr(0) >> 63) // размер указателя. используется для вычисления tophash
)

func New[T any](size int) Hashmap[T] {
	h := new(hmap[T])

	h.seed = generateSeed()

	// тот самый log_2 от количества элементов
	B := uint8(0)
	// увеличиваем B пока средняя заполненность бакетов > loadFactor 
	for overLoadFactor(size, B) {
		B++
	}
	h.b = B

	h.buckets = make([]bucket[T], bucketsNum(h.b))

	return h
}

// функция определяет будут ли бакеты, для size количества элементов, загружены больше чем loadFactor в среднем.
// этой же функцией потом будем определять нужно ли начать рост мапы
func overLoadFactor(size int, b uint8) bool {
	return size > bucketSize && uint64(size) > loadFactorNum*(bucketsNum(b)/loadFactorDen)
}

// здесь b - log_2 от количества элементов
func bucketsNum(b uint8) uint64 {
	return 1 << b
}

Get/Set/Delete

Основную логику мы заложили в модели бакета. Остается только вычислить tophash и номер бакета. Для put/delete учитываем изменение количества хранимых элементов.

// Get - возвращает значение по ключу key
// вернется нулевое значение типа  если по ключу key не существует значение.
func (h hmap[T]) Get(key string) T {
	tophash, targetBucket := h.locateBucket(key)

    v, _ := h.buckets[targetBucket].Get(key, tophash)
    return v
}

// Get2 - возвращает значение по ключу key и флаг наличия этого значения в мапе
// вернет нулевое значение типа  и false если по ключу key не существует значения.
func (h hmap[T]) Get2(key string) (T, bool) {
	tophash, targetBucket := h.locateBucket(key)

    return h.buckets[targetBucket].Get(key, tophash)
}

// Put - добавляет элемент в мапу
func (h hmap[T]) Put(key string, value T) {
	tophash, targetBucket := h.locateBucket(key)

	if h.buckets[targetBucket].Put(key, tophash, value) {
		h.len++
	}
}

// Delete - удаляем элемент из мапы по переданному ключу
func (h hmap[T]) Delete(key string) {
	tophash, targetBucket := h.locateBucket(key)
	if h.buckets[targetBucket].Delete(key, tophash){
		h.len--
	}
}

Вычисление номера бакета и tophash:

// locateBucket - возвращает индекс бакета и tophash
func (h hmap[T]) locateBucket(key string) (tophash uint8, targetBucket uint64) {
	hash := hash(key, h.seed)
	tophash = topHash(hash)
	mask := bucketMask(h.b)

	targetBucket = hash & mask

	return tophash, targetBucket
}

// возвращает первые 8 бит от значения
func topHash(val uint64) uint8 {
	tophash := uint8(val >> (ptrSize*8 - 8))
	if tophash < minTopHash {
		tophash += minTopHash
	}
	return tophash
}

// bucketMask возвращает маску бакетов
func bucketMask(b uint8) uint64 {
	return bucketsNum(b) - 1
}

// для хэширования используется алгоритм wyhash
func hash(key string, seed uint64) uint64 {
	return wyhash.Hash([]byte(key), seed)
}

Итерация

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

// Range - итерация по всем значениям с вызовом переданной функции
func (m hmap[T]) Range(f func(key string, value T) bool) {
	for i := range m.randRangeSequence() {
		bucket := &m.buckets[i]
		for bucket != nil {
			for j, th := range bucket.tophash {
				// идет к след. бакету если видим флаг о пустых ячейках после индекса j
				if th == emptyRest {
					continue
				}
				// если в ячейке есть значение, то передаем в функцию
				if th >= minTopHash {
					// прерываем итерацию если получили false
					if !f(bucket.keys[j], bucket.values[j]) {
						return
					}
				}
			}
			// проверяем overflow бакеты
			bucket = bucket.overflow
		}
	}
}

// формируем последовательность индексов для начала поиска со случайного бакета.
func (m hmap[T]) randRangeSequence() []int {
	i := rand.Intn(len(m.buckets))

	seq := make([]int, 0, len(m.buckets))
	for len(seq) != len(m.buckets) {
		seq = append(seq, i)
		i++
		if i >= len(m.buckets) {
			i = 0
		}
	}

	return seq
}

Тесты, бенчмарки

Исходники тестов можно посмотреть на Github. Ради интереса напишем бенчмарки для сравнения с мапой из коробки.
При сравнении методов Put и Get в пределах выделенной памяти разница достигает 20%:

var sizes = []int{128, 1024, 8192}
func BenchmarkGet(b *testing.B) {
	for _, n := range sizes {
		// выделяем память под n элементов
		mm := New[int64](n)
		for i := 0; i < n; i++ {
			mm.Put(fmt.Sprintf("key__%d", i), int64(i)*2)
		}

		b.Run(fmt.Sprintf("generic-map_%d", n), func(b *testing.B) {
			var got int64
			j := 0
			for i := 0; i < b.N; i++ {
				if j > n {
					j = 0
				}
				got = mm.Get(fmt.Sprintf("key__%d", j))
				j++
			}
			_ = got
		})
	}
	// такой же код для std мапы 
}
go test . -run=^$ -bench . -benchmem
goos: darwin
goarch: arm64
pkg: github.com/w1kend/go-map
BenchmarkGet/generic-map_128-8          12884936                85.63 ns/op            8 B/op          1 allocs/op
BenchmarkGet/generic-map_1024-8         13559966                87.57 ns/op           14 B/op          1 allocs/op
BenchmarkGet/generic-map_8192-8         11720404               101.1 ns/op            22 B/op          1 allocs/op
BenchmarkGet/std-map_128-8              17141264                70.01 ns/op            8 B/op          1 allocs/op
BenchmarkGet/std-map_1024-8             16442701                72.42 ns/op           14 B/op          1 allocs/op
BenchmarkGet/std-map_8192-8             14521720                81.84 ns/op           22 B/op          1 allocs/op
BenchmarkPut/generic-map_128-8          16028941                74.27 ns/op            8 B/op          1 allocs/op
BenchmarkPut/generic-map_1024-8         15961150                75.22 ns/op           14 B/op          1 allocs/op
BenchmarkPut/generic-map_8192-8         12941882                85.13 ns/op           22 B/op          1 allocs/op
BenchmarkPut/std-map_128-8              16949132                70.37 ns/op            8 B/op          1 allocs/op
BenchmarkPut/std-map_1024-8             16461930                71.99 ns/op           14 B/op          1 allocs/op
BenchmarkPut/std-map_8192-8             14040560                82.28 ns/op           22 B/op          1 allocs/op

Переполнение мапы. Выделяем память на 1000 элементов и заполняем до 10 000, 100 000 и 1 000 000 элементов:

func BenchmarkPutWithOverflow(b *testing.B) {
	startSize := 1_000
	targetSize := []int{10_000, 100_000, 1_000_000}

	for _, n := range targetSize {
		mm := New[int64](startSize)
		b.Run(fmt.Sprintf("generic-map-with-overflow__%d", n), func(b *testing.B) {
			j := 0
			for i := 0; i < b.N; i++ {
				if j > n {
					j = 0
				}
				mm.Put(fmt.Sprintf("key__%d", j), int64(j))
				j++
			}
		})
	}

	for _, n := range targetSize {
		stdm := make(map[string]int64, startSize)
		b.Run(fmt.Sprintf("std-map-with-evacuation__%d", n), func(b *testing.B) {
			j := 0
			for i := 0; i < b.N; i++ {
				if j > n {
					j = 0
				}
				stdm[fmt.Sprintf("key__%d", j)] = int64(j)
				j++
			}
		})
	}
}

go test . -run=^$ -bench ^BenchmarkPutWithOverflow$ -benchmem
goos: darwin
goarch: arm64
pkg: github.com/w1kend/go-map
BenchmarkPutWithOverflow/generic-map-with-overflow__10000-8             11472094               104.9 ns/op            23 B/op          1 allocs/op
BenchmarkPutWithOverflow/generic-map-with-overflow__100000-8             3440781               344.7 ns/op            23 B/op          1 allocs/op
BenchmarkPutWithOverflow/generic-map-with-overflow__1000000-8            1000000              8376 ns/op              57 B/op          3 allocs/op
BenchmarkPutWithOverflow/std-map-with-evacuation__10000-8               14312827                83.43 ns/op           23 B/op          1 allocs/op
BenchmarkPutWithOverflow/std-map-with-evacuation__100000-8              12691999                90.62 ns/op           23 B/op          1 allocs/op
BenchmarkPutWithOverflow/std-map-with-evacuation__1000000-8              7283215               168.7 ns/op            23 B/op          1 allocs/op

Пока что такой бенчмарк не особо информативный, так как, очевидно, что результаты будут хуже. Интересно на сколько:

Увеличение элементов с 1000 до

Std map

Generic map

Разница

10 000

83.43 ns/op

104.9 ns/op

1.25

100 000

90.62 ns/op

344.7 ns/op

3.8

1 000 000

168.7 ns/op

8376 ns/op

49.65

На этом все, спасибо за внимание. Ставьте лайки, подписывайтесь на гитхаб.
В следующей части:

  • Научим мапу расти;

  • Замерим производительность при росте;

  • Добавим дженерик ключи;

  • Подумаем про конкурентное обращение.

И, наверное, стоит добавить что смысл данной статьи именно показать как hashmap реализована в Go под капотом, и показать более читаемый пример реализации на самом Go.

Ссылки

Исходники
Картинки гоферов
Гоферы от Ashley McNamara

Go:
GopherCon 2016: Keith Randall — Inside the Map Implementation
How the Go runtime implements maps efficiently (without generics)

Python:
Raymond Hettinger Modern Python Dictionaries A confluence of a dozen great ideas PyCon 2017 Raymond Hettinger. More compact dictionaries with faster iteration

Java:
Внутренняя работа HashMap в Java
The Java HashMap Under the Hood

Лекция cs166 stanford про Liner probing
An Analysis of Hash Map Implementations in Popular Languages

© Habrahabr.ru