Алгоритмы важны. Но реализовать их можно очень по-разному.


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


Любите оптимизации, специализированные структуры данных и трюки с битами? Тогда скорее под кат!



Интро


Алгоритмы на сегодня: A-star и greedy BFS. Оба отлично разобраны в статье Introduction to the A* Algorithm.


Поиск пути мне понадобился в игре Roboden. Написана она на Go с использованием движка Ebitengine, соответственно и библиотеку я искал для этого языка.


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


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


Существующие библиотеки


Открываем awesome-ebitengine и ищем модули для поиска пути:



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


Напишем бенчмарки и измерим производительность этих пакетов. Бенчмарки отличаются своими картами проходимости: от открытых пространств до лабиринтов. Каждый бенчмарк выполняет поиск на матрице размером 50x50 клеток.


Библиотека Тест no_walls Тест simple_wall Тест multi_wall
astar 948367 ns 1554290 ns 1842812 ns
go-astar 453939 ns 939300 ns 1032581 ns
tile 107632 ns 169613 ns 182342 ns
grid 1816039 ns 1154117 ns 1189989 ns
paths 6588751 ns 5158604 ns 6114856 ns

Для скромного поля в 50x50 клеток это плохие результаты.


А что там по выделению памяти?


Библиотека Тест no_walls Тест simple_wall Тест multi_wall
astar 337336 B 511908 B 722690 B
go-astar 43653 B 93122 B 130731 B
tile 123118 B 32950 B 65763 B
grid 996889 B 551976 B 740523 B
paths 235168 B 194768 B 230416 B

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


Библиотека Тест no_walls Тест simple_wall Тест multi_wall
astar 2008 3677 3600
go-astar 529 1347 1557
tile 3 3 3
grid 2976 1900 1759
paths 7199 6368 7001

Приятно удивляет только kelindar/tile, но к нему мы ещё вернёмся.


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


Детали бенчмарков для нердов


Характеристики машины, на которых запускались замеры:


OS: Linux Mint 21.1
CPU: x86-64 12th Gen Intel(R) Core(TM) i5-1235U
Tools used: “go test -bench”, benchstat
Turbo boost: disabled (intel_pstate/no_turbo=1)
Go version: devel go1.21-c30faf9c54

Бенчмарки запускались через обычный go test bench -benchmem, а результаты анализировались с помощью qbenchstat.




Первым разбираемым нами алгоритмом будет greedy BFS, так как для него я придумал больше оптимизаций, а звёздочка будет в самом конце.


Больше всего влияния на производительность оказывают:


  • Матрица проходимости
  • Представление результата поиска пути
  • Priority queue для frontier
  • Ассоциативный массив для хранения посещённых клеток

Матрица проходимости


Элементы матрицы будем называть клетками. У каждой клетки есть координата внутри матрицы.


type GridCoord struct {
  X int
  Y int
}

Grid map отображает рельеф местности с точки зрения алгоритма поиска пути. В самом простом виде каждая клетка хранит значение-перечисление тайла.


type CellType int

// У нас будет всего три вида тайлов.
const (
  CellPlain CellType = iota // 0
  CellSand                  // 1
  CellLava                  // 2
)

Три тайла можно уместить в два бита. Если каждая клетка в матрице занимает всего два бита, тогда карта из 3600 (60x60) клеток будет весить 900 байт.


type Grid struct {
  numCols uint
  numRows uint

  // Храним по 4 клетки в каждом байте.
  bytes []byte
}

Доступ к индивидуальной клетке будет требовать дополнительной арифметики.


func (g *Grid) GetCellTile(c GridCoord) uint8 {
  x := uint(c.X)
  y := uint(c.Y)
  if x >= g.numCols || y >= g.numRows {
    return 0 // Координата лежит вне матрицы
  }
  i := y*g.numCols + x
  byteIndex := i / 4
  shift := (i % 4) * 2
  return (g.bytes[byteIndex] >> shift) & 0b11
}

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

Разные виды юнитов могут иметь отличающиеся категории движения. Кто-то может перемещаться только по равнине, другие ещё и по пустыне, а летуны могут пересекать даже лаву.


Создавать по отдельному Grid'у для каждой категории — перерасход памяти и дублирование источников истины.


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


type GridLayer [4]byte

// i - это значение клетки, от 0 до 3.
func (l GridLayer) Get(i int) byte {
  return l[i]
}

От вставляемого компилятором неявного bound check можно избавиться:


- return l[i]
+ return l[i & 0b11]

Теперь мы можем расширять два бита в один байт. Договоримся, что нулевое значение — отсутствие прохода. Любое другое значение для greedy BFS будет означать допустимость перемещения с ценой 1, а конкретные веса будут учитываться только в реализации A*.


Создадим слои для разных категорий перемещения:


var NormalLayer = pathing.GridLayer{
  CellPlain: 1, // Перемещение разрешено
  CellSand:  0, // Нельзя перемещаться
  CellLava:  0, // Нельзя перемещаться
}

var FlyingLayer = pathing.GridLayer{
  CellPlain: 1, // Перемещение разрешено
  CellSand:  1, // Перемещение разрешено
  CellLava:  1, // Перемещение разрешено
}

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


Преимущества этой матрицы:


  • 2 бита на клетку
  • С помощью слоёв можно использовать одну матрицу для разных юнитов

Помимо очевидной экономии памяти, компактный размер означает дружбу с кэш-линиями. Допустим, размер кэш линии равен 64 байтам. Паттерны доступа к матрице таковы, что всегда извлекаются соседи текущей клеточки. Клетки слева и справа будут находиться рядом, возможно даже в рамках того же байта. Клетка ниже и выше будут требовать чтения следующих строк матрицы. Чем меньше памяти мы тратим на клетку, тем выше шанс, что 2*numCols ячеек окажутся внутри одной кэш-линии.


Здесь можно было бы ещё попробовать Morton order. В теории, это могло бы сделать доступ более cache-friendly, хотя операция чтения стала бы более дорогой.

Репрезентация пути



Существующие библиотеки идут по наиболее интуитивному пути (хе-хе) — хранение маршрута как набора точек.


Возьмём для примера эту карту:



При построении пути от {0,1} к {2,3} мы получим такой набор точек:


{0,0} {1,0} {2,0} {2,1} {2,2} {2,3}

А вот другой способ представить этот же путь:


Up Right Right Down Down Down

Нам будет достаточно перечисления из четырёх значений:


type Direction int

const (
  DirRight Direction = iota
  DirDown
  DirLeft
  DirUp
)

Уже догадались, что произойдёт дальше? 4 значения — два бита.



Так как каждый шаг настолько компактен, мы можем поместить в uint64 целых 32 шага. Этого нам не хватит, поэтому возьмём пару uint64. Вместо 64 шагов мы сможем уместить только 56, потому что два байта нам нужно будет зарезервировать под пару служебных полей итератора пути.


const (
  gridPathBytes  = (16 - 2)          // Два байта служебных
  gridPathMaxLen = gridPathBytes * 4 // 56
)

type GridPath struct {
  bytes [gridPathBytes]byte
  len   byte
  pos   byte
}

GridPath занимает 16 байт, а в памяти будет выглядеть примерно так:



Элементы пути не имеют смысла по отдельности, а вот если последовательно выполнить все шаги, то мы придём к точке назначения. Из-за этого я называю эти шаги дельтами.


Свойства GridPath:


  • Пути длиной до 56 клеток (будет важно далее)
  • Не нужно аллокаций в куче — это 16 байтов на стеке (или в регистрах)
  • Value-семантика, обычное присваивание выполняет глубокое копирование

В новых версиях Go всё больше аргументов передаются через регистры и текущий Go tip в godbolt генерирует одну инструкцию MOVUPS для передачи GridPath.


Очередь с приоритетами


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


  • Push(coord, p) — добавить координату в очередь
  • Pop() -> (coord, p) — извлечь координату с минимальным p
  • Reset() — очистить очередь, память переиспользовать

Приоритет p зависит от алгоритма. Для greedy BFS это Manhattan distance от coord к финишу.


По этому описанию нам могла бы подойти minheap. Для A* мы так и поступим, но greedy BFS можно ускорить ещё сильнее, если взять особую bucket queue. Её и разберём.


Если каждый "приоритет" является отдельным бакетом, тогда добавление в такой контейнер очень приятное: нужно всего лишь сделать добавление в список этого бакета.


А вот извлечение из бакетной очереди требует изобретательности. В самом базовом варианте вам нужно перебирать бакеты до тех пор, пока не найдётся непустой. Лорд ужаса из WarCraft III сказал бы: "Дурацкий план."


Вспомним, что максимальная длина пути равна 56. Так как 56<64, uint64 хватит для хранения состояния всех бакетов.


type priorityQueue[T any] struct {
  buckets [64][]T
  mask    uint64
}

Операция добавления в очередь тривиальна:


func (q *priorityQueue[T]) Push(priority int, value T) {
  // Убираем bound check через операцию &, этот трюк вы уже видели.
  i := uint(priority) & 0b111111
  q.buckets[i] = append(q.buckets[i], value)
  q.mask |= 1 << i
}

Изначально, маска равна 0. При добавлении элемента в бакет, который до этого был пустым, бит в нужном смещении будет выставлен в 1.


Извлечение элемента немного сложнее, но тоже очень эффективное:


func (q *priorityQueue[T]) Pop() T {
  // TrailingZeros64 на amd64 - это пара машинных инструкций (BSF и CMOV).
  // За эти две инструкции мы находим необходимый
  // нам индекс непустого бакета.
  i := uint(bits.TrailingZeros64(q.mask))
  if i < uint(len(q.buckets)) {
    // Эти дви строчки извлекают последний элемент из бакета (pop).
    e := q.buckets[i][len(q.buckets[i])-1]
    q.buckets[i] = q.buckets[i][:len(q.buckets[i])-1]
    if len(q.buckets[i]) == 0 {
      // Если бакет стал пустым, зануляем нужный бит в маске.
      q.mask &^= 1 << i
    }
    return e
  }

  // Очередь пустая, возвращаем zero value.
  var x T
  return x
}

Осталось реализовать Reset. Самый прямолинейный способ — это реслайсинг каждого бакета. Память будет доступна для следующего использования, а настоящего зануления нам всё равно не нужно.


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


func (q *priorityQueue[T]) Reset() {
  mask := q.mask
  // Начинаем очищение с первого непустого бакета,
  // пропуская все пустые "справа".
  offset := uint(bits.TrailingZeros64(mask))
  mask >>= offset
  i := offset
  // Когда "слева" от окна все бакеты будут пустыми, цикл завершится.
  for mask != 0 {
    if i < uint(len(q.buckets)) {
      q.buckets[i] = q.buckets[i][:0]
    }
    mask >>= 1
    i++
  }
  q.mask = 0
}

Особая магия в том, что для пустой очереди Reset будет делать реслайсинг 0 раз. Для единственного заполненного бакета нужно будет обработать лишь его. Для большего количества бакетов нужно будет пройти окно между ними.


В качестве бонуса, посмотрите как элегантно можно сделать операцию IsEmpty:


func (q *priorityQueue[T]) IsEmpty() bool {
  return q.mask == 0
}

И как после такого не любить пакет math/bits?



Хранение посещённых координат


Семантически, нам нужна map[GridCoord]T, только быстрая и с возможностью повторно использовать выделенную память.


GridCoord — это структура из двух полей, но её можно ужать до одного числа.


func packCoord(numCols int, c GridCoord) uint {
  return uint((c.Y * numCols) + c.X)
}

Так как максимальная длина пути равна 56, область поиска пути никогда не выйдет за пределы квадрата 56*2+1. Если мы будем преобразовывать координаты поиска из глобальных в локальные, то диапазон значений, возвращаемый packCoord будет равен [0-12544].


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


Если взять вместо слайса sparse map, то получим очень эффективный Reset за счёт более медленных Get и Set.


Лучшее от двух миров в данной задаче даёт generations map, о которой я недавно писал на Хабре. Эту структуру данных и выберем.


А что там со звездочкой?


Чтобы превратить greedy BFS в A* нам потребуется несколько изменений:


  • Две generations map вместо одной
  • Min heap вместо bucket queue
  • Немного другая эвристика для p

В случае звёздочки алгоритм будет учитывать цену перемещения (нам это ничего не стоит).


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


Как выбрать между звёздочкой и greedy bfs для вашей задачи? Почти всегда A* будет предпочтительным вариантом, но greedy bfs работает немного быстрее и требует меньше рабочей памяти.


Greedy BFS довольно часто находит почти оптимальный путь, хотя очень сложный лабиринт может его расстроить. В случаях, когда звёздочка и greedy BFS оба находят оптимальные пути, обычно они отличаются. Используя это в игре, можно создавать менее предсказуемые маршруты для юнитов: часть маршрутов строить через A*, а другую — через greedy BFS.


Если взять примеры из моей любимой статьи, то greedy BFS должен проигрывать на примере ниже. Однако мой greedy BFS работает слегка иначе и, на моих тестах показывает себя весьма неплохо. Всего в паре тестов greedy BFS строил менее оптимальный маршрут, чем A*:



Побеждаем в бенчмарках


Используя все рецепты выше, собираем свою библиотеку поиска пути.


Библиотека Тест no_walls Тест simple_wall Тест multi_wall
pathing 3525 ns 6353 ns 16927 ns
pathing A* 20140 ns 35846 ns 44756 ns
astar 948367 ns 1554290 ns 1842812 ns
go-astar 453939 ns 939300 ns 1032581 ns
tile 107632 ns 169613 ns 182342 ns
grid 1816039 ns 1154117 ns 1189989 ns
paths 6588751 ns 5158604 ns 6114856 ns

pathing быстрее библиотеки paths примерно в 2000 раз! A* работает ощутимо медленнее greedy BFS, но всё ещё быстрее остальных реализаций.


Теперь аллокации:


Библиотека Тест no_walls Тест simple_wall Тест multi_wall
pathing 0 0 0
pathing A* 0 0 0
astar 2008 3677 3600
go-astar 529 1347 1557
tile 3 3 3
grid 2976 1900 1759
paths 7199 6368 7001

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


Получилось в тысячи раз быстрее и без аллокаций (zero alloc). Оно того стоило.


Разбор kelindar/tile (опционально!)


В kelindar/tile мы увидели любопытные результаты: всего 3 аллокации, а выделено при этом ~100000 байтов. Давайте разбираться.


Для frontier там используется кастомный heap. Вместо выделения нового контейнера каждый раз, автор использует sync.Pool для переиспользования. Отсюда малое количество аллокаций в среднем — контейнеры переиспользуются.


Для тайлов используются страницы из блоков 3х3. Звучит любопытно, но реальный выигрыш от этого пока не ясен, надо исследовать. Потенциальный минус этой схемы — она может вызывать лишнюю работу с обработкой сразу трёх "страниц".


Промежуточные результаты хранятся в map, но создаётся она с разумной предварительной подсказкой размера: πr^2. Так автор в удачном сценарии получает лишь одну аллокацию для map, но она будет огромной (выделяем всегда для худшего случая).


Результат возвращается в виде слайса точек, отсюда ещё одна аллокация.




Выводы


Алгоритмы важны, однако реализация — тоже. Иногда серия микрооптимизаций ведёт к феноменальному эффекту.


У моей библиотеки поиска пути есть два основных ограничения:


  1. Максимальная длина пути — 56 шагов
  2. На одной карте проходимости может быть только 4 вида тайлов (2 бита)

Однако с ними можно жить:


  1. Частичные пути можно объединять в один
  2. Отдельные enum'ы для биомов могут отложить проблему

Понравилась статья? У нас в сообществе разработки игр на Go всегда весело! Присоединяйтесь, будем создавать игрушки на нашем любимом языке программирования вместе.


Полезные ссылки:


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


  1. quasilyte Автор
    11.10.2023 22:31
    +10

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

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

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


    1. astec
      11.10.2023 22:31
      +3

      Крутая работа. Не знаю что вы выкинули, но читалось хорошо.


      1. quasilyte Автор
        11.10.2023 22:31
        +2

        Из относительно полезного, из статьи ушло (что смог вспомнить):

        • Совсем не разбираю альтернативы для размеров клеток в Grid (можно их по 1 или 4 бита делать)

        • Не расписываю как вместо 56 шагов получить 112 (взять 4 uint64)

        • Иллюстрации как работает bucket queue через серию push и pop с дампом состояния

        • Как эмулировать диагональные ходы, не меняя сам алгоритм

        • На пальцах разобранный способ строить маршрут, длиннее 56 шагов (инкрементально)

        • Краткое описание принципов работы generations map (для этого отдельная статья написана)

        • Описание того, почему container/heap такой плохой и почему надо брать генерики для minheap

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


        1. quasilyte Автор
          11.10.2023 22:31

          Не удержался и добавил краткий разбор github.com/kelindar/tile в самом конце. Эту библиотеку мне скинули уже после написания статьи. Эта библиотека во многом сделана гораздо лучше остальных рассматриваемых. Например, там хотя бы не используют container/heap, а ещё map с хорошим хинтом размера создают.


    1. marperia
      11.10.2023 22:31
      +1

      без лишней скромности, читалось как будто автор из вайред или хакерньюс на окладе

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


      1. quasilyte Автор
        11.10.2023 22:31
        +1

        А графики и обложку сами или знакомый дизайнер?

        Часть иллюстраций сделана через скриншоты слайдов из Google Slides. :)

        Остальное через GIMP. Мне этот редактор очень не нравится, но я к нему привык. Не рекомендую.

        Самая последняя картинка повторяет стиль иллюстрации из этой статьи. Я накидал такую "карту" по 1 пикселю на клетку, потом заскейлил и добавил сетку. :)


  1. Tnr88
    11.10.2023 22:31
    +1

    сравните с какойто нормальной с++ библиотекой. Будет довольно интересно посмотреть.


    1. quasilyte Автор
      11.10.2023 22:31

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

      Наверное вопрос скорее в том, есть ли на плюсах более хитрые либы для поиска пути. Скорее всего, есть (сомневаюсь, что я что-то принципиально новое делал), но это надо будет несколько просмотреть, собрать, позапускать. Мне это будет сделать значительно сложнее, чем практикующему плюсовику (я уже больше года на C++ не пишу). И на Rust стоит поискать, там же всё blazing fast. :D

      Из любопытства можно добавить в сравнения реализацию AStar2D из Godot, она тоже на C++ написана.


  1. datacompboy
    11.10.2023 22:31
    +1

    А почему остановились на 56 шагах а не 57? там два бита в размере совершенно никому не нужны же, плюс маскировка нужна только пока размер маленький...


    1. quasilyte Автор
      11.10.2023 22:31

      А может так и сделаю, не знаю пока. +1 сегмент пути звучит мелочью, с другой стороны там не так трудно это добавить.


  1. gchebanov
    11.10.2023 22:31

    Очень сомнительное ограничение в 56 на путь, так-то змейка на карте в 60x60 что-то порядка 1700 дает. В мире плюсов аллокацию из библиотеки можно избежать output iterator-ом, там такое на шаблонах бесплатно, в го так не выйдет.


    1. quasilyte Автор
      11.10.2023 22:31

      Но ведь никто не заставляет брать именно такие значения. Я взял самый простой вариант, который работал для меня. Чтобы и исполнялось быстро, и код был несложным (маски в uint64 понять проще, чем в абстрактном bitset, который нужно ещё эффективно реализовать).

      Вся идея выражается в:

      • Вместо координат {x,y} мы храним дельты, они компактные

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

      Вместо 16 байтов под путь можно взять 32 и будет уже 112 сегментов.
      А в bucket queue под это нужно будет взять пару uint64 под маску (так как встроенного int128_t в Go нет).
      Я описал несколько принципов, которые можно крутить как хочется, но они в любом случае лучше, чем, например, массив для точек. Если на каком-то языке это можно ещё эффективнее реализовать - это же прекрасно.

      Отдельно придётся проверять, не будет ли такой крупный (100+ бакетов) bucket queue уже хуже, чем minheap через один массив. Я таких замеров не делал, но перерасход памяти там будет расти с увеличением количества бакетов, а каждый дополнительный байт в маске может выражаться в более медленных push/pop.

      А если оставаться в рамках 56 шагов, то размер клетки может быть разный, в либе есть маппинг из world координат (позиций) в клеточные. Можно матрицы разной гранулярности - мелкая сетка типа 32x32 для локального обхода препятствий и матрица покрупнее, чтобы какой-то более масштабный маршрут строить между ними.

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


    1. quasilyte Автор
      11.10.2023 22:31

      И второй способ, который подойдёт для случаев, когда у нас очень много повторяющихся шагов типа up up up up.

      Вместо 2 битов используем 3 бита на одну "единицу" информации. Дополнительный бит используем как префикс. И дальше выбираем под нужду, как кодировать там информацию. Для случая, когда повторы направлений очень частые, можно сделать такой encoding:

      000 left
      001 right
      010 down
      011 up
      100 left+repeat
      101 right+repeat
      110 down+repeat
      111 up+repeat
      

      Для случая +repeat следующие 3 бита содержат количество+3 повторений (потому что 0 не имеет смысла, а 1-2 проще/эффективнее кодировать через отдельные 3 бита):

      000 => 3 шага
      001 => 4 шага
      010 => 5 шагов
      011 => 6 шагов
      100 => 7 шагов
      101 => 8 шагов
      110 => 9 шагов
      111 => 10 шагов
      

      Так получится за 6 бит сохранить 10 шагов (в 3 раза больше).

      Хотя тот же A* любит строить лесенки вида up left up left, и с ним это будет реже полезно.


  1. lisonok10
    11.10.2023 22:31
    -1

    Удалите статью мне лень её читать


    1. quasilyte Автор
      11.10.2023 22:31

      Удалил.


  1. konstantin-s-yakovlev
    11.10.2023 22:31

    А JPS не пробовали запилить/включить в бенчмаркинг? Интересно было бы посмотреть на результаты.


    1. quasilyte Автор
      11.10.2023 22:31

      А что это такое? :)


  1. konstantin-s-yakovlev
    11.10.2023 22:31
    +1

    Jump point search - алгоритм поиска путей на сетчатых графах (который скипает много промежуточных вычислений по сравнению с A* и за счет этого на многих картах (но не на всех) оказывается ощутимо быстрее).


    1. quasilyte Автор
      11.10.2023 22:31

      О таком раньше не слышал, спасибо.

      Я в целом на эту тему уже несколько недель убил (сравнения, своя библиотека, статьи и доклады), поэтому в ближайшом времени точно не буду ещё глубже уходить. Мне же для игрушки это нужно было, следовательно делать игрушки важнее, чем посвятить свою жизнь задаче поиска путей. :D

      Выбор библиотек на Go был не очень хорош, поэтому пришлось своё писать. Результаты сейчас достаточно удовлетворительны.

      Так что я был бы очень признателен, если бы кто-то принял эстафету.


  1. stasenso
    11.10.2023 22:31

    Меня тоже не покидает ощущение, что чем выше язык, тем больше ресурсы используются на переливание из пустого в порожнее. Однажды увидел тут на Хабре фрактал (множество) Мандельброта реализованный на чистом C. Чтобы понять, насколько быстрый алгоритм реализован там, запилил свой на Асм, с распараллеливанием расчётов в SIMD инструкциях процессора и в 8 потоков. Так вот, даже по сравнению с языком C, выигрыш был 5-6 раз. И это с выводом на экран. (Возможно, когда разгребу дела, выложу процесс написания и результат). Сам собой напрашивается вопрос - не многовато ли мы используем энергии процессоров просто на отопление помещений без какой-либо полезной работы.. Особенно это заметно на всяких, прости г-ди, python...


    1. quasilyte Автор
      11.10.2023 22:31
      +1

      А вы статью то читали? :)

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

      И да, даже на вашем любимом асме их можно применить и будет буст по сравнению с более прямолинейным подходом.

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

      1. Есть сотни способов представить матрицу проходимости и я показал один из них, который достаточно компактный и простой в реализации. В статье ещё есть ссылка на "страничный" подход и на Morton space-filling curve. Это всё потому, что да, иногда в либах используют и менее компактные, и менее эффективные структуры. Это не вина Go, на любом языке люди готовы везде воткнуть hashmap или обычный 2D array.

      2. В 99% библиотек путь возвращается как набор точек, а не как value-объект из дельт. Этот принцип можно применить в любом языке и выигрыш будет везде.

      3. Аналогично с generations map (ссылка есть в статье). Я вообще пока не нашёл, чтобы где-то применяли эту структуру. Чаще всего используют просто map. Кто-то берёт sparse map, но он тоже не оптимален.

      4. Бакетная очередь приоритетов с битовой маской тоже не самая частая структура. У неё есть несколько вариаций, но используют её крайне редко в том числе из-за её ограничений. Но в задаче поиска пути её применить можно и буст ощутимый. Это тоже в любом языке будет работать, где у нас есть доступ к интринсикам или функциям типа "верни индекс первого ненулевого бита" (в Go есть такой интринсик, в C/C++ и подавно, а на асме можно напрямую инструкции вызвать, которые я в статье упомянул).


      1. stasenso
        11.10.2023 22:31
        +1

        Конечно читал, просто именно про алгоритмическую оптимизацию сказать было нечего, а про ускорение работы программ в целом я любитель поговорить:)


        1. quasilyte Автор
          11.10.2023 22:31
          +1

          Мне самому только дай повод за оптимизации сесть. :D
          Так что понимаю.


  1. massrez
    11.10.2023 22:31

    Я просто оставлю это здесь https://youtu.be/4lbYrgoEE-g. Быстрый поиск пути в динамических условиях. Свяжитесь со мной если интересно. Автор молодец, проделал серьезную и кропотливую работу.