Новая реализация map в Go 1.24: Смотрим под капот

от автора

Введение

В версии Go 1.24 разработчики кардинально изменили внутреннюю реализацию map, перейдя с традиционного механизма цепочек бакетов на Swiss Table. Этот новый подход улучшает производительность, снижает использование памяти и делает операции с map более эффективными. В этой статье мы не будем смотреть принципиальную разницу в подходах, это вы можете прочитать в оригинальной статье или переводе на хабре. Я же хочу быстро посмотреть изменения в коде, включая создание map, поиск элементов, обработку коллизий и выделение памяти.

Создание map

Старая реализация (до Go 1.24)

В предыдущих версиях Go структура hmap управляла массивом бакетов, в которых хранились ключи и значения. Вот основные поля структуры:

// hmap в Go <= 1.23  type hmap struct {     count       int     B           uint8   // log2(число бакетов)     buckets     unsafe.Pointer     oldbuckets  unsafe.Pointer     noverflow   uint16  // количество overflow-бакетов     ... }

При создании map через make(map) выполнялась функция makemap, которая выделяла массив бакетов размером 2^B:

// Старый runtime.makemap B := uint8(0) for overLoadFactor(hint, B) {     B++ } h.B = B if h.B != 0 {     h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)     if nextOverflow != nil {         h.extra = new(mapextra)         h.extra.nextOverflow = nextOverflow     } }

Эта модель предусматривала жестко заданный размер бакетов и overflow-цепочки для разрешения коллизий.

Новая реализация (Go 1.24)

В новой версии Go хранилище map управляется структурой Map, в которой бакеты заменены на группы, а вместо B и buckets теперь используется директория таблиц:

// Новая структура Map в Go 1.24 type Map struct {     used         uint64     seed         uint64     dirPtr       unsafe.Pointer  // Указатель на массив таблиц     dirLen       uint64     globalDepth  uint8     globalShift  uint8     writing      bool     clearSeq     uint32 }

Вместо фиксированного массива бакетов теперь возможны несколько таблиц, управляющихся через директорию (dirPtr). При создании маленьких map (до 8 элементов) аллокация памяти откладывается до первой вставки. В остальных случаях используется следующий алгоритм:

// Новый maps.NewMap if hint <= abi.SwissMapGroupSlots {     return m  // малый словарь – отложим аллокацию до вставки } … dirSize := (uint64(targetCapacity) + maxTableCapacity - 1) / maxTableCapacity dirSize, _ = alignUpPow2(dirSize) directory := make([]*table, dirSize) for i := range directory {     directory[i] = newTable(mt, uint64(targetCapacity)/dirSize, i, m.globalDepth) } m.dirPtr = unsafe.Pointer(&directory[0]) m.dirLen = len(directory)

Теперь, вместо выделения единого массива бакетов, создается несколько таблиц в зависимости от требуемой емкости.

Поиск элементов в map

Старая реализация (до Go 1.24)

Ранее поиск ключа использовал комбинацию хеша и цепочек бакетов. Индекс бакета определялся младшими битами хеша, а tophash (старшие 8 бит) помогал ускорить фильтрацию записей:

hash := t.Hasher(key, uintptr(h.hash0)) b := (*bmap)(add(h.buckets, (hash & m)*uintptr(t.BucketSize))) ... for ; b != nil; b = b.overflow(t) {     for i := uintptr(0); i < abi.OldMapBucketCount; i++ {         if b.tophash[i] != top {             if b.tophash[i] == emptyRest {                 break             }             continue         }         if t.Key.Equal(key, add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))) {             return valuePointer, true         }     } } return nil, false  // не найден

Поиск мог затрагивать несколько бакетов, если ключ находился в overflow-бакете.

Новая реализация (Go 1.24)

В новой реализации используется открытая адресация и пробирование. Хеш делится на H1 (57 бит) и H2 (7 бит). H1 определяет группу, H2 сохраняется в контрольном байте и позволяет быстро фильтровать записи:

// Новый поиск в Swiss Table (Go 1.24) h2 := uint8(h2(hash)) ctrls := *g.ctrls() for i := uintptr(0); i < abi.SwissMapGroupSlots; i++ {     c := uint8(ctrls)     ctrls >>= 8     if c != h2 {         continue     }     slotKey := g.key(typ, i)     if typ.Key.Equal(key, slotKey) {         return slotKey, g.elem(typ, i), true     } } return nil, nil, false

Swiss Table позволяет проверять 8 записей за шаг, что ускоряет поиск.

Разрешение коллизий

До Go 1.24: overflow-бакеты

При коллизиях использовались overflow-бакеты:

type bmap struct {     tophash [8]uint8     keys    [8]keyType     values  [8]valueType     overflow *bmap }

Go 1.24: пробирование

Теперь используется квадратичное пробирование: при коллизии ключ ищется в следующей группе, а удаленные элементы помечаются специальным значением (deleted), чтобы не разрывать цепочку поиска.

Итоги

  • В новой реализации map используется Swiss Table, что значительно улучшает производительность и снижает потребление памяти.

  • В поиске применяется пробирование, а не цепочки overflow-бакетов.

  • Коллизии теперь решаются с помощью открытой адресации, а не выделения новых бакетов.

  • При росте map теперь могут добавляться новые таблицы вместо удвоения всей структуры.

Рисунок 1. Результаты бенчмарков

Рисунок 1. Результаты бенчмарков

Если посмотреть на результаты бенчмарков, то можем увидеть благодаря этим изменениям, операции с map стали быстрее на 10-20%.


ссылка на оригинал статьи https://habr.com/ru/articles/890744/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *