Golang продолжает развиваться. Изначальные проектные решения ставятся под сомнения, а новые вызовы заставляют язык меняться: дженерики, итераторы, новая имплементация мап. Однако, даже нововведения приходят к нам не такими, как в других языках. Вспомните обсуждения сразу после релиза тех же дженериков. На Go, как мне кажется, в большинстве своём пишут люди, пришедшие из прочих языков, у кого Golang не первый ЯП. Они привыкли к другому подходу работы с абстракциями. И им порой не хватает того, что предлагает язык Гофера. Swiss Tables — попытка быть в тренде.

С вами Кирилл Кузин — ведущий подкастов про IT на канале gIT, где вместе с коллегами по цеху рассматриваем индустрию под разными углами, открывая новые горизонты для вас и самих себя. А работаю ведущим разработчиком в Ви.Tech — IT-дочке ВсеИнструменты.ру. Там мы с командой пишем внутренние системы на Go под задачи бизнеса и по ходу дела разбираемся, как наши инструменты устроены и как реально влияют на процесс разработки.

В этой статье речь пойдёт о новых мапах в версии Go 1.24, реализованных по принципу Swiss Tables — швейцарских таблиц. Попробуем найти ответы на вопросы о том, почему мапы изменились, что лежит в основе новой реализации и как к ней пришли.

Чтобы в этом разобраться, вернёмся в прошлое, когда появилась необходимость изменений внутри самого Go. В статье мы не будем погружаться слишком глубоко. Я также предполагаю, что читатели знакомы со старой реализацией мапы в Golang, и имеют поверхностное представление о том, что собой представляют Swiss Tables как концепция в Rust и C++. Если нет — рекомендую к просмотру мой доклад на Golang Conf X (YouTube, RuTube, VK), где я провёл ретроспективу и разбор швейцарских таблиц, а также сослался на более фундаментальный доклад ребят из Abseil, разработавших новый подход к работе хеш-таблиц («Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step»).

Однако, всё же акцентируем внимание на нескольких ключевых идеях каноничных швейцарских таблиц, чтобы у нас был общий контекст (все они также описаны в статье Abseil):

  • Два массива — реализация предполагает массив с ключами и значениями, а также массив с метаданными. Последний хранит в своих ячейках состояния ассоциированных по индексу ключей-значений: ячейка массива с данными занята, свободна или же свободна, но помечена заглушкой (tombstone), что удалена. Последнее состояние позволяет не разрушать цепочки пробирования.

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

  • Коллизия — ситуация, при которой два разных ключа имеют один и тот же хеш.

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

  • Использование SIMD — для ускорения операций с массивом метаданных.

Сконцентрируемся на том, как мы получили именно такой код в исходниках, каким мы его увидели в версии 1.24, на чём он основан и почему реализация в новой версии может показаться неаккуратной. Ведь изначально переход реализации мап на швейцарские таблицы казался отличной идеей. Да и сейчас такой кажется.

Проблемы старой реализации мап

Дело в том, что с первых релизов Go Highload сильно изменился, как и работа интернета в целом. А количество пользователей у сервисов росло бешеными темпами вместе с распространением сетей по планете. Как итог — безумное количество данных и возросшие скорости работы с ними. Golang попал в окно возможностей, потому что позволяет реализовывать ожидания бизнеса в условиях быстро меняющихся потребностей клиентов.

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

  • Неоптимальное использование памяти. Каждая новая аллокация памяти для мапы требует объём в два раза больше текущего. Для больших in-memory кэшей это вызов, потому что с ростом x2 значительная часть памяти в конце концов может оказаться аллоцированной, но неиспользуемой.

  • Эвакуация данных при росте мапы хоть и выполняется постепенно, но бьёт по CPU пиками нагрузки. В системах с миллионами записей это может привести к ощутимому замедлению работы.

  • Рост количества данных увеличивает вероятность коллизий, а с ними начинает замедляться работа, в том числе из-за использования метода цепочек при разрешении коллизий в конкретном бакете.

  • Последовательное пробирование на первом этапе таких операций, как вставка, удаление и изменение данных, — простое и действенное решение, к которому стремится язык. Только вместе с тем меняется скорость работы алгоритма мапы: вместо O(1) получаем O(n) в худшем случае.

  • Бакеты, из которых состоит мапа, разбросаны по куче, а значит происходит overhead по CPU, так как доступ к памяти — случайный. Приходится скакать по памяти и страдать от кэш-промахов, чтобы найти нужный элемент по ключу. На больших объёмах данных это снова аффектит скорость выполнения программ.

  • Рост нагрузки на GC при огромном количестве бакетов, особенно в случае удаления данных во время эвакуации.

Возможно, этот список не полон, и его можно уточнить, но остаётся факт — развитие интернета потребовало поиска нового решения. Как следствие — изменения мап. И, как уже понятно, одним из подобных решений стала концепция Swiss Tables, зарекомендовавшая себя в C++. Однако, первыми в публичном поле на неё обратили внимание не разработчики языка Golang, а комьюнити. В августе 2022 появилось предложение по изменению внутреннего механизма работы мапы. И пока идея доковыляла до конечной реализации, то же сообщество постаралось предложить собственное видение того, как могли бы выглядеть реализации Swiss Tables с учётом возможностей языка на тот момент. Рассмотрим две библиотеки:

  • DoltHub / swiss — первая попытка применить новую идею;

  • Cockroach / swiss — логичное развитие на основе интерпретации DoltHub.

Я предлагаю изучить каждую из них, чтобы понять, как идея Swiss Tables эволюционировала в Golang и что мы получили в итоге. И начнём с Dolthub.

Swiss Tables в DoltHub

Как выглядит эта мапа через призму швейцарских таблиц:

type Map[K comparable, V any] struct {
   ctrl     []metadata
   groups   []group[K, V]
   hash     maphash.Hasher[K]
   resident uint32
   dead     uint32
   limit    uint32
}

Это обычная структура, принятая в Golang, но с рядом интересных деталей:

  • Во-первых, метаданные представлены как слайс массивов, где каждый массив связан со своей группой данных. Как водится, количество пар ключ-значение и битов в метаданных, константа groupSize — восемь:

type metadata [groupSize]int8
  • Во-вторых, сами данные разбиваются на группы по восемь ключей-значений. Таким образом остаётся соответствие индекса массива с метаданными и индекса группы, к которой массив относится:

type group[K comparable, V any] struct {
   keys   [groupSize]K
   values [groupSize]V
}
  • В-третьих, самое интересное, ребята не стали реализовывать свою хеш-функцию, а воспользовались уже существующей, вытянув её из внутреннего представления мапы в исходном коде Go. Избыточно говорить, что при таком подходе есть риск со временем поломать зависимости при попытке через интерфейс скопировать один в один нужную структуру и вытащить оттуда то, что вас интересует. Не делайте так. Тем более, что то самое представление уже удалили из исходного кода.

type hashfn func(unsafe.Pointer, uintptr) uintptr

func getRuntimeHasher[K comparable]() (h hashfn) {
   a := any(make(map[K]struct{}))
   i := (*mapiface)(unsafe.Pointer(&a))
   h = i.typ.hasher

   return
}

Помимо внутреннего устройства мап, нас в первую очередь интересуют три основные операции, которые реализуются на хеш-таблицах: получение данных (get), вставка или изменение данных (put) и их удаление (delete). Предлагаю начать с операции Get().

func (m *Map[K, V]) Get(key K) (value V, ok bool) {
   hi, lo := splitHash(m.hash.Hash(key))
   g := probeStart(hi, len(m.groups))
   for {
      matches := metaMatchH2(&m.ctrl[g], lo) 
                                             
      for matches != 0 { 
         s := nextMatch(&matches)
         if key == m.groups[g].keys[s] {
            value, ok = m.groups[g].values[s], true
            return
         }
      }
      
      matches = metaMatchEmpty(&m.ctrl[g])
      if matches != 0 { 
         ok = false
         return
      }
      g += 1 
      if g >= uint32(len(m.groups)) {
         g = 0
      }
   }
}

По факту, тут выполняется несколько этапов работы с данными и самой мапой:

  1. Оптимизация поиска группы, с которой начнём искать нужные данные через старшие биты хеша. Так начинается процесс пробирования. Подобная оптимизация с частью битов хеша есть и в старых мапах.

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

  3. Проверка полученных совпадений по метаданным на то, что имеется совпадение у какого-то конкретного сохраненного в мапе ключа с ключом из запроса.

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

  5. Если пустой ячейки нет, то последовательно выбирается следующая группа и так до момента, пока либо алгоритм не пройдёт все группы, либо не найдёт интересующий ключ и значение.

Операция вставки значения выглядит почти также, за исключением первых строчек:

if m.resident >= m.limit { 
      m.rehash(m.nextSize()) 
}

Если мапе не хватает места, так как количество данных превысило установленный лимит, происходит её увеличение, то есть — рехеширование. И операция Put() нам ценна как раз этим.

func (m *Map[K, V]) rehash(n uint32) {
   groups, ctrl := m.groups, m.ctrl 
   m.groups = make([]group[K, V], n)
   m.ctrl = make([]metadata, n)

   for i := range m.ctrl {
      m.ctrl[i] = newEmptyMetadata()
   }

   m.hash = maphash.NewSeed(m.hash)
   m.limit = n * maxAvgGroupLoad
   m.resident, m.dead = 0, 0

   for g := range ctrl { 
      for s := range ctrl[g] {
         c := ctrl[g][s]
         if c == empty || c == tombstone { 
                                           
            continue
         }
         m.Put(groups[g].keys[s], groups[g].values[s])
      }
   }
}

При рехешировании создаётся новая мапа. Её ёмкость увеличивается в два раза, а цепочки пробирования пересоздаются. В новую структуру попадают только те ячейки, для которых в массиве метаданных существует пометка в виде бита о том, что ключ/значение правда существуют. Пустая  ячейка  без значения, но помеченная в качестве заглушки (чтобы не сломать цепочку пробирования), не переносится.

Метод удаления данных рассмотрим лишь частично:

for {
      matches := metaMatchH2(&m.ctrl[g], lo)
      for matches != 0 {
         s := nextMatch(&matches)
         if key == m.groups[g].keys[s] {
            ok = true
            if metaMatchEmpty(&m.ctrl[g]) != 0 { 
               m.ctrl[g][s] = empty
               m.resident--
            } else { 
               m.ctrl[g][s] = tombstone
               m.dead++
            }
            var k K
            var v V
            m.groups[g].keys[s] = k
            m.groups[g].values[s] = v
            return
         }
      }

Здесь и скрывается логика проставления tombstone (заглушек) в цепочках пробирования. Всё снова сводится к тому, заполнена ли группа полностью.

По итогу реализация библиотеки Swiss Tables выглядит довольно просто за счёт структуры, последовательного пробирования по группам и внутри них, а также имеет следующие очевидные плюсы:

  • load factor (тот самый, который в старых мапах Golang ~82%) — здесь все 87%;

  • неплохой перенос основных концепций Swiss Tables в плоскость Go;

  • использование SIMD через ручной код ассемблера.

Но есть и минусы:

  • очень простая реализация, которая затрагивает при рехешировании весь объём мапы (этим же страдает старая мапа);

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

Теперь предлагаю посмотреть на вторую библиотеку — Swiss Tables от Cockroach.

Swiss Tables в Cockroach: что изменили

Здесь код значительно сложнее, а модель включает несколько уровней: мапу, бакеты, группы и слоты:

type Map[K comparable, V any] struct {
   hash hashFn
   seed uintptr

   allocator Allocator[K, V]

   bucket0 bucket[K, V]
   dir     unsafeSlice[bucket[K, V]]
   used    int
   
   globalShift       uint32
   maxBucketCapacity uint32
   _                 noCopy
}

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

  • bucket0 — тот самый бакет, с которого начинается мапа, «нулевой пациент». Разросшись до непотребного размера, он дробится и все его части уезжают в следующее поле dir, а поле bucket0 становится nil.

  • dir — слайс, ячейки которого уже помогают общаться с бакетами, когда их больше одного. Будем называть каталогом бакетов.

  • used — количество занятых ячеек в мапе по всем бакетам.

Особняком стоит поле hash, в котором живёт хеш-функция. Реализовали ли Cockroach свою? Нет, она получена тем же способом, что и в DoltHub, о чём в коде оставлено прямое замечание:

// https://github.com/dolthub/maphash provided the
// inspiration and general implementation technique.
func getRuntimeHasher[K comparable]() hashFn {
   a := any((map[K]struct{})(nil))

   return (*rtEface)(unsafe.Pointer(&a)).typ.Hasher
}

Преемственность, как она есть. И не сказать, чтобы хорошая. Дальше перейдём на уровень ниже — к бакетам.

type bucket[K comparable, V any] struct {
   groups    unsafeSlice[Group[K, V]]
   groupMask uint32

   capacity   uint32
   used       uint32
   growthLeft uint32

   localDepth uint32
   
   index uint32
}

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

type Group[K comparable, V any] struct {
   ctrls ctrlGroup
   slots slotGroup[K, V]
}
type slot[K comparable, V any] struct {
   key   K
   value V
}

Заметно, что идеи Dolthub преобразились, усложнились и теперь работают иначе, визуально более приближенно к тому, что из себя представляли мапы до версии 1.24. Но возникает закономерный вопрос — каков механизм роста мапы? В старой реализации и в реализации Dolthub рост затрагивал мапу целиком. Тут, обращаясь к документации, механизм иной. Он называется Extendible Hashing и работает с полем dir.

Разберёмся, что из себя представляет этот каталог бакетов:

Представим, что в мапе есть три бакета. И каждый имеет доселе неизведанную характеристику — localDepth, или локальную глубину (уж простите меня за дословный перевод). По сути — это счётчик того, сколько раз данный бакет и его потомки/родители разделялись надвое. У самой мапы есть свойство globalDepth, или глобальная глубина, которая подсказывает максимальную локальную глубину бакетов. Другими словами — сколько раз дробился самый используемый бакет (и его потомки). Здесь работает несколько правил:

  • глобальная глубина не может быть меньше максимальной локальной глубины, поэтому обязательно возможности инкрементируется, если максимальная локальная глубина становится больше по значению;

  • количество ячеек в dir равно степени двойки и длина адреса ячейки равна глобальной глубине;

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

Если в последний бакет добавить ещё данных, превысив их количество, которое он может содержать, бакет разобьётся надвое.

Локальная глубина бакета и его нового собрата увеличится на единицу, как и глобальная глубина. Dir увеличится по степени двойки и перераспределит указатели ячеек на бакеты по новому. Вот и весь механизм роста. Он потрясающ тем, что не затрагивает мапу целиком, а только одну её часть.

Теперь перейдём к реализации методов. Delete() и Get() опустим — они по логике похожи на то, что мы уже видели у Dolthub. А у метода Put() интересно рассмотреть, как он рехеширует бакет программно.

func (b *bucket[K, V]) rehash(m *Map[K, V]) {
   if b.capacity > groupSize && b.tombstones() >= b.capacity/3 {
      b.rehashInPlace(m) 
                         
      return
   }

   newCapacity := 2 * b.capacity
   if newCapacity > m.maxBucketCapacity {
      b.split(m)       return
   }

   b.resize(m, newCapacity) 
}

Ребята из Cockroach реализовали целых три стратегии рехеширования:

  • Первая стратегия предлагает перестроить существующий бакет, если количество заглушек внутри него более трети от всей ёмкости. Эта оптимизация ведёт к тому, что существующий бакет перестраивает цепочки пробирования, избавляясь от неиспользуемых для хранения данных заглушек. Считаю, что ход отличный.

  • Вторая стратегия предлагает разделение бакета пополам в случае, если достигнут лимит по максимальной ёмкости.

  • Третий классический ход — увеличить ёмкость бакета в два раза.

На моём оборудовании (Mac Air M4 16 Gb), при работе с мапой на строках в лёгком (6 элементов), среднем (> 8 тысяч элементов) и тяжёлом весе (> 4 млн. элементов), реализация Cockroach выиграла и у Dolthub, и у старых гошных мап по скорости работы. По памяти, к сожалению, тесты не проводил.

Результаты при малом количестве кэш-промахов:

Dolthub

Cockroach

Go-Maps

String, 6 эл.

14.57 ns/op

10.85 ns/op

11.29 ns/op

String, 8192 эл.

20.69 ns/op

14.78 ns/op

37.38 ns/op

String, 4 194 304 эл.

92.67 ns/op

76.38 ns/op

81.31 ns/op

Результаты при большом количестве кэш-промахов, что наиболее ценно:

Dolthub

Cockroach

Go-Maps

String, 6 эл.

10.26 ns/op

8.141 ns/op

9.023 ns/op

String, 8192 эл.

13.20 ns/op

11.50 ns/op

15.75 ns/op

String, 4194304 эл.

26.24 ns/op

43.34 ns/op

70.84 ns/op

Единственный минус у Cockroach — это отсутствие SIMD, но в Golang это можно исправить так же, как это сделали в DoltHub.

Что попало в Go 1.24

Так как логика библиотеки Cockroach очень схожа с тем, что уже есть в исходном коде языка, то она и легла в основу новых мап в Golang. В этом и заключается прелесть: разработчики языка по сути переиспользовали наиболее удачный вариант от комьюнити, слегка доработав его под свои ограничения. Всё осталось таким же: и иерархия (только бакеты поменяли на таблицы); и работа с разделением бакетов — один в один. Даже код методов одинаков. Посмотрите сами на эту мапу:

type Map struct {
   used uint64
   seed uintptr

   
   dirPtr unsafe.Pointer
   dirLen int

   
   globalDepth uint8
   globalShift uint8

   writing uint8

   clearSeq uint64
}

Отличия есть, но концептуально — это Cockroach. И именно поэтому я предложу вам сконцентрироваться не на исходном коде новых мап, потому что эволюцию мы уже проследили и понимаем, как они выглядят, а на интересные решения внутри.

И буду честен — готовясь к конференции, я ещё не до конца понимал причины принятия тех или иных решений. Сейчас уже могу ответить на некоторые вопросы «почему». А на остальные приглашаю ответить в комментариях.

Во-первых, по неведомой мне изначально причине, код мап разбит на несколько пакетов с путями:

  • src/runtime/map_noswiss.go

  • src/runtime/map_swiss.go

  • src/internal/runtime/maps/runtime_swiss.go

И вроде бы логично, что мы оставляем старую реализацию для сохранения обратной совместимости. Но почему новая мапа разъехалась на два файла — немного не понятно. А на самом деле, она разъехалась на все три. Есть ещё src/internal/runtime/maps/map.go.

При этом, если посмотреть в исходный код методов, то удивляет, что методы Get() и Put() описаны в одном месте, а логика Delete() тянется из последнего упомянутого мной файла (в котором, кстати, есть реализация и прочих методов, впрочем, неиспользуемых).

К этому у меня был большой вопрос. И по поводу кода методов Get() и Put() этот вопрос остаётся — зачем? А вот на счёт разных файлов в отдельных частях кодовой базы всё немного понятнее. Причин для такого решения несколько. И одна из них, как мы любим, историческая. Связана с директивой go:linkname, которую использовали некоторые библиотеки, чтобы залезть во внутренности рантайма. Теперь просто так не поменять контракты и реализацию. Приходится постепенно вводить изменения, максимально их контролируя и вынося из ядра языка.

Также, как известно, пакет runtime компилируется по особому, и, в частности, не использует race-detector. Старые мапы жили самодостаточно и без него — как и без тестов. Чилили в рантайме. Но, кажется, что разработчики языка решили изменить подход, вынеся отдельную логику во вне, чтобы иметь возможность тестировать изменения и контролировать ключевые аспекты работы новых мап. А уже их реализация будет подключаться и в рантайм (метод Delete(), как пример), и в будущем в остальные части исходного кода. Скорее всего это будет вообще отдельная библиотека, доступная извне.

Как видно, причин сделать некоторое количество файлов в разных частях кода, да ещё где-то через go:linkname к старой реализации — вынужденный компромисс, ввиду особенностей развития Golang. И подобная топология точно должна со временем измениться.

Ещё одна странность — вольность с константами. Разработчики взяли одну из важнейших вещей в виде ограничения на максимальную ёмкость таблиц maxTableCapacity, скажем так, с потолка. Обычно на ревью за такое бьют по рукам. Более странно, что с такой опцией мапы включены по дефолту. Для себя я не нашёл никаких объяснений на этот счёт. Может быть, вы можете подсказать, зачем было делать именно так?

И это ещё не всё. Методы keys и values остались висеть не имплементированными. Судя по issue, функции заезжали для работы с итераторами, но в конечном итоге убрали их реализацию и пока не почистили окончательно.

Теперь посмотрим на рехеширование внутри новой реализации мап:

func (t *table) rehash(typ *abi.SwissMapType, m *Map) {
   newCapacity := 2 * t.capacity
   if newCapacity <= maxTableCapacity {
      t.grow(typ, m, newCapacity)
      return
   }

   t.split(typ, m) 
}

К сожалению, здесь не нашлось места для рехеширования по месту — механизма, который учитывает количество заглушек внутри конкретной таблицы, как это реализовано у Cockroach. Разработчики честно признаются в комментариях к коду и в issue, что такой подход трудно реализовать.

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

Другие отличия Swiss Tables от канонической реализации

А чем ещё Swiss Tables в Golang отличается от канонической реализации? Прежде всего тем, что SIMD поддерживается лишь частично. В рантайме и стандартной библиотеке можно найти SIMD-инструкции, но это пока только ручной ассемблер, который, при желании, можно написать самому — и для x86, и для Neon на ARM. Но полноценной поддержки таких оптимизаций, особенно для новых мап, пока нет. Здесь есть куда расти.

Поэтому если вдруг вы не захотите использовать новые мапы и продолжите работать со старым, проверенным инструментом, то можете указать глобальную переменную GOEXPERIMENT=noswissmap, и будет вам счастье. Никаких новых неизвестных багов. Только старые и известные.

Бенчмарки: кто быстрее

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

Swiss

Cockroach

Go-Maps

String, 6 эл.

10.06 ns/op

10.85 ns/op

11.29 ns/op

String, 8192 эл.

16.00 ns/op

14.78 ns/op

37.38 ns/op

String, 4194304 эл.

129.5 ns/op

76.38 ns/op

81.31 ns/op

Тут с большим количеством кэш-промахов:

Swiss

Cockroach

Go-Maps

String, 6 эл.

11.49 ns/op

8.141 ns/op

9.023 ns/op

String, 8192 эл.

13.11 ns/op

11.50 ns/op

15.75 ns/op

String, 4194304 эл.

49.54 ns/op

43.34 ns/op

70.84 ns/op

Оглядываясь на всё выше написанное – во многом повторяющее мой доклад на Golang Conf – не могу сказать, что релиз получился совсем уж сырым. Шероховатости есть, как и вопросы к реализации, но в целом это обычная инженерная задача: внести изменения в уже работающую систему, не сломав её. На процесс повлияли и окружение, и фраза «так исторически сложилось». Так что в этой статье я говорю тому себе, что выступал на конференции — нормально, и так сойдёт!

При этом очевидно, что у разработчиков языка есть огромный потенциал для оптимизации и дальнейшей модернизации швейцарских таблиц. Как минимум, нужно догнать библиотеку Cockroach в быстродействии. Так что главный вопрос остаётся открытым: готовы ли мы доверять новой версии мап прямо сейчас? Или пока предпочтём остаться на старой реализации?

Как бы то ни было, язык развивается, следит за трендами и старается идти в ногу со временем. И я уверен, что команда разработки допилит всё, что сейчас не очень нравится комьюнити. В конце концов в этом и смысл. Правда?

А мы теперь понимаем, откуда получили то, что получили, какие этапы осмысления прошли швейцарские таблицы до того, как попали в исходный код. На этом спасибо за ваше внимание. И у меня остался к вам ещё один вопрос: нравятся новые мапы в Go?

Комментарии (1)


  1. sl4mmer
    13.08.2025 10:33

    Лично по моим ощущениям, стоило бы еще подержать в экспериментал и допилить реализацию