Ностальгирую тут немного...
Вспоминаю еще те времена, когда учился на старших курсах в СМИ, ныне СибГИУ. Студенты обычных специальностей, типа металлургов или металловедов вешались, обучаясь информатики. Уж не помню как тогда кафедра эта называлась, на которой мучили студентов, потом она долго была кафедрой прикладной информатики.
И обучали там студентов-металлургов, литейщиков и прочих в те далекие времена... программированию. В СССР, а потом и России компьютеризация только начиналось, компьютеров-то толком не было, не говоря уже о прикладных программах. Поэтому программировали все подряд.
Одна из задачек для студентов там была про поиск пути в лабиринте. Металловедам и строителям это было просто невыносимо скучно и непонятно, а нам, обучающимся именно программированию, было страсть как интересно.
Интернетов тогда в Сибири не было, да наверно и во всей России тоже, хорошей специализированной литературы также не хватало, поэтому алгоритмы мы придумывали сами. Для этой задачки самый оригинальный вариант решения придумал Алексей Щелоков, с очень забавной биологической аналогией.
В реальности же это был старый добрый, сто лет как известный волновой алгоритм.
Я потом с ним много изгалялся. В контексте биологического описания реализовывал его с помощью ООП, хотя по сути волнового алгоритма никакой ООП там не требуется, гораздо эффективнее просто поиск в ширину на массиве.
Кстати, тогда же я и выяснил, что выделение динамической памяти под NT происходит гораздо, наверное, на два порядка, медленнее, чем на Windows 9x.
Поэтому когда выяснилось, что операции 4 КиБ блоками при копировании файла сильно загружает процессорное ядро, я предложил, что эта та самая "неэффективность" системных вызовов на платформе NT.
Но думаю, я был не прав. Все же с времен NT много воды утекло, думаю, сейчас большинство системных вызовов происходит гораздо быстрее. Так что наиболее вероятная причина, как заметил Иван Колесников, в издержках межпоточной синхронизации. Надо будет потестить, но пока руки не доходят.
В общем, ностальгируя накидал быстренько программку поиска пути в лабиринте на окрестностях фон Неймана. Сначала хотел решить задачу в квадрате размером 100 000, но потом понял, что для решения памяти не хватит, и остановился на 40 000.
Правда, надо было еще лабиринт каким-то образом сделать. Сделал случайным образом. Лабиринт получился не очень красивым, но зато достаточно сложным для решения волновым алгоритмом.
Мой рабочий FX-4350 на архитектуре PileDriver и жестких дисках загрузил лабиринт примерно за 40 секунд, а нашел кратчайший путь за 52.4 секунды.
А вот Ryzen 7 5800X на архитектуре Zen 3 и с SSD на PCI Gen 3 x4 сделал те же операции за 22.5 и 21.5 секунды.
По сравнению с началом 90-х годов прошлого века эта, конечно, потрясающая производительность. Тогда и 100х100 лабиринт не сильно быстро решался.
Удивило лишь то, что замена HDD на SSD не сильно ускорила процесс загрузки. Честно говоря, я ожидал большего.
Может быть, когда станет совсем скучно, попробую оптимизировать эту программку. Первое, что мне приходит в голову – это мой любимый ассемблер.
Второе, но более важное: модификация поиска в ширину, что бы в первую очередь просматривать горизонталь (строки), а лишь затем двигаться по вертикали. Можно рассчитывать на увеличение производительности за счет того, что обрабатываемая часть массива уже подгружена в кэш.
Было интересно прочитать Ваши воспоминания, пишите еще :)))
ОтветитьУдалить> Удивило лишь то, что замена HDD на SSD не сильно ускорила процесс загрузки.
А как на диске лабиринт хранится? Если матрицей 40K*40K, то это всего 200МБ (если один бит на ячейку) или 1,6ГБ (если один байт), HDD (~180МБ/с) должен загрузить за ~1 сек (1 бит на ячейку) или 9 сек (1 байт на ячейку), интересно куда уходит 22.5 секунды не проверяли? Это прям дисковые операции? А если загрузить 2 раза подряд, 2-я загрузка должна быть из системного кэша, она существенно быстрее?
> что бы в первую очередь просматривать горизонталь
Еще можно попробовать сохранить лабиринт используя https://en.wikipedia.org/wiki/Z-order_curve, в теории такой порядок должен быть дружественным к кэшу.
> Если матрицей 40K*40K, то это всего 200МБ (если один бит на ячейку)...
УдалитьНет, так просматривать неудобно, что там получилось.
Поэтому хранится в виде строк: ' ' - свободный проход, #255 - стена. Поэтому файл получается примерно 1.5 ГиБ. Ну и преобразование всего этого дела в более подходящий массив тоже занимает время. Так как изначально планировал решать задачу большей размерности, то размер элемента массива взял больше, чем нужно для этой задачи. Думаю, если изменить размер, то в два раза легко ускорится.
> ...в теории такой порядок должен быть дружественным к кэшу.
Да, забавный порядок. Но пока что-то ускорение у меня в голове не складывается. В случае окрестности фон Неймана, например, для точки с координатами (3, 3) ближайшие соседи (кроме соседа слева) окажутся как-то далековато друг от друга, что затруднит их попадание в кэш. Сосед сверху будет по адресу -2, справа +11, а снизу +22.
> что затруднит их попадание в кэш
УдалитьИдеала нет, но +22 довольно близко для prefetch, думаю процессор подгрузит.
Решил протестировать на квадратном лабиринте размером 32К, так как для степеней 2-ки Z-order порядок намного проще сгенерировать.
Сохранил лабиринт также как и Вы в текстовом виде, написал по быстренькому на Java, без особых ухищрений, так что сами цифры думаю мало что значат, скорее их отношение друг к другу.
После загрузки, состояние одной ячейки описывается 32-битным int, а лабиринт целиком:
- 2d: массив из указателей на массивы
- flat: одномерный массив: сначала 1-я строка, далее 2-я и т.д.
- z-order: одномерный массив в z-order curve порядке.
Загрузка с диска (точнее из файлового кэша) занимает 7-8 секунд, z-преобразование еще 6-7 секунд , по хорошему его нужно делать перед сохранением, но я делал после загрузки.
Скорость поиска очень сильно зависит от лабиринта, пока я попробовал 2-варианта:
- A - коридор в одну клетку, длина пути 55М, макс. размер фронта поиска всего 118
- B - сетка из комнат 100*100 с небольшим числом проходов, длина пути 65K, макс. фронт: 33K
Собственно мои результаты поиска (без учета z трансформации):
- A: 2d=24 сек., flat=20 сек., z-order=16 сек.
- B: 2d=64 сек., flat=51 сек., z-order=30 сек.
Очень интересные результаты у тебя получились!
Удалить> z-преобразование еще 6-7 секунд , по хорошему его нужно делать перед сохранением, но я делал после загрузки.
Видимо, его нужно относить ко времени загрузки. Фактически это ведь преобразование исходных данных в удобную для решения форму.
> Скорость поиска очень сильно зависит от лабиринта, пока я попробовал 2-варианта:
У меня один вариант и это сильно разряженный лабиринт. Я бы даже назвал его не лабиринтом, а большим сосновым лесом ) Но, видимо, для волнового алгоритма он самый сложный получается. Сложнее, видимо, просто пустое поле. ) Давай ради интереса обменяемся лабиринтами, что бы потестить на своих вариантах программы. Вот мой, специально сгенерировал 32768х32768: https://disk.yandex.ru/d/q8K0n9DIaXoFMw.
Начало в (1, 0), конец - в (32767, 32766). Наикратчайший путь - 70921. Кстати, я вот на 100% не уверен, что нашел самый короткий путь.
Честно говоря, удивлен, что массив указателей на одномерные массивы работает медленнее, что линейный массив. Ведь для адресации варианта с указателями достаточно загрузить указатель, затем одной командой рассчитать адрес элемента. А для линейного массива в общем случае нужно перед расчетом эффективного адреса еще выполнить умножение и сложение. Впрочем, для данного алгоритма можно ограничится сложениями, наверное.
В случае Delphi все осложняется тем, что даже в 64-битной версии до сих действует ограничение на размер массива (не важно, сколько у него размерностей) в 2 ГиБ. Это ограничение можно обойти на ассемблере, но я пока до него еще не добрался.
Результат с Z-ордером тоже удивил. Ведь основная проблема, мне казалось, состояла в том, что длинный фронт волны плохо ложился на кэш. Похоже, реорганизация расположения в памяти в порядке Мортона очень сильно улучшает локальность рядом расположенных на плоскости данных. И это не смотря на то, что, видимо, приходится пересчитывать x и у координаты в d-координату Z-массива? Очень, очень интересно получается.
> Фактически это ведь преобразование исходных данных в удобную для решения форму.
УдалитьДа наверное Вы правы, но что если пользователь задает координаты начала и конца, а программка находит кратчайший путь, далее пользователь изменяет координаты и повторяет поиск? :)))
> У меня один вариант и это сильно разряженный лабиринт
Спасибо что поделились, мои результаты по Вашему лабиринту: 2d=37 сек., flat=28 сек., z-order=20 сек.
Я правда чуток изменил его: чтобы не обрабатывать выход за границы (что не тривиально для z-order), я подразумеваю что вокруг сплошная стена, поэтому начало в (1, 1), а конец в (32766, 32766).
> Наикратчайший путь - 70921
Моя реализация нашла 70918, но я убрал одну клетку в начале и одну в конце и того 70920. Возможно мы по разному считаем путь до начальной клетки? Я начинаю с 0.
Размер фронта - 18K
Мои лабиринты: https://www.dropbox.com/s/z420ztj2c0gwmfc/maze-20220611.zip (чуток другие символы и окончания строк):
- начало в (1, 1)
- конец: A - (1229, 21481), B - (32766, 32766)
> удивлен, что массив указателей на одномерные массивы работает медленнее
По моему ожидаемо, массив из 32К 64-bit указателей большой для L1 кэша и очень горячий, плюс prefetch совсем не работает.
> Впрочем, для данного алгоритма можно ограничится сложениями
Ага, но даже с умножением думаю будет быстрее чем 2-х мерный массив.
> до сих действует ограничение на размер массива
В Java увы тоже правда на число элементов в массиве: индекс знаковый 32-bit int
> очень сильно улучшает локальность рядом расположенных на плоскости данных
Думаю да.
> видимо, приходится пересчитывать x и у координаты в d-координату Z-массива
Немного хитрее: начало, конец и 4 смещения вначале пересчитываются один раз из декартовых в z-координату и реализована функция по сложению/вычитанию 2-х z-координат на 1-м сравнении и 7 битовых операциях. Преобразование из декартовых в z-координату более сложное: 17 битовых и 9 сдвигов.
> функция по сложению/вычитанию 2-х z-координат на 1-м сравнении и 7 битовых операциях.
Удалитьи еще 2-х сложениях
Сейчас запустил еще раз по Вашему лабиринту и получил несколько другой результат: 2d=33 сек., flat=22 сек., z-order=18 сек., нестабильно как-то.
Удалить>... но что если пользователь задает координаты начала и конца,
УдалитьЧто это меняет? Исходные данные (то есть лабиринт) не поменялись, соответственно, не меряем время загрузки и преобразования к z-порядку, так ведь?
> чтобы не обрабатывать выход за границы (что не тривиально для z-order),
Это существенная разница, так как я проверяю. Получается, в случае z-order алгоритм не совсем универсальный получается.
> Возможно мы по разному считаем путь до начальной клетки?
Да, я с единицы, так что все сходится )
> Ага, но даже с умножением думаю будет быстрее чем 2-х мерный массив.
Зависит от языка программирования и типа данных. В Delphi статический многомерный массив - это непрерывная область памяти. Там единственный выигрыш можно получить, если переходишь от строчке к строчке последовательно, тогда операцию умножения при индексации многомерного массива можно заменить сложением. К сожалению, из-за ограничения на размер я такой тип не смог использовать, хотя 32К туда бы точно влезло, но не больше.
> реализована функция по сложению/вычитанию 2-х z-координат на 1-м сравнении и 7 битовых операциях...
Я сильно удивлен. Несмотря на такую сложность z-order выигрывает?
> ...результат: 2d=33 сек., flat=22 сек., z-order=18 сек.
Интересно, если запустить поиск в обратном направлении, результат будет тот же? И нужно ли при этом применять новый z-порядок?
> Что это меняет?
УдалитьТогда z-преобразование можно выполнить один раз для нескольких поисков.
> Это существенная разница, так как я проверяю
Я просто поленился: цикл по 4-м фиксированным смещениям было быстрее написать чем 4 блока с немного разными условиями :) Добавил проверку границ ко всем 3-м реализациям, стало даже быстрее, особенно для z-order, так как раздельная реализация переходов позволила упростить пересчет индекса.
> В Delphi статический многомерный массив - это непрерывная область памяти.
Про 2-х мерный массив здесь я имел ввиду массив из указателей на массив.
> Несмотря на такую сложность z-order выигрывает?
Для Вашего лабиринта BFS посещает всего 737М ячеек, если все решение занимает 20 сек, то это целых 27 нс. на ячейку или 135 тактов при 5 ГГц, это довольно много, явно все математические операции занимают меньше времени, узкое место память, а не математика.
> если запустить поиск в обратном направлении
Запустил, z-порядок не изменял, на время выполнения повлияло только для массива из массивов, да и то не особо значительно.
Новые результаты:
- Чутка оптимизировал чтение, теперь оно занимает 3.2 - 4.5 сек, а z-преобразование где-то 2 сек.
- A: 2d = 16, flat = 14, z-order = 11 сек.
- B: 2d = 42, flat = 21, z-order = 11 сек.
- kvy: 2d = 27, flat = 20, z-order = 14 сек.
- kvy-reversed: 2d = 32, flat = 21, z-order = 14 сек.
Тестирую на i9-9980HK
И в дополнение время решения Вашего лабиринта на Apple M1, 3.2 ГГц частота, Mac Book Air с пассивным охлаждением:
Удалить- загрузка: 2.5 - 4 сек.
- z-преобразование: ~1 сек.
- 2d = 26, flat = 12, z-order = 12 сек.
В этом процессоре кэш намного больше: L1 - 128KB per core, L2 - 12MB, L3 - 16MB, пропускная способность памяти вроде 68GB/s, похоже память перестала быть узким местом и от z-order нет никакого эффекта.
> Тогда z-преобразование можно выполнить один раз для нескольких поисков.
УдалитьАга. Но вроде как основная цель исследовать скорость волнового алгоритма. Замер времени загрузки (и преобразования) - это вторичный параметр, чисто оценить, как влияют разные дисковые устройства на скорость загрузки.
Ну вот и тоже сподобился оттестировать квадратные лабиринты размером 32 Ки , на своем стареньком FX-4350. Загрузка файла в среднем занимает чуть больше 30 сек. Я ничего не оптимизировал тут вообще, просто построчное читаю текстовый файл и его разбираю, файлы хранится на древненьком HDD. Теперь результаты:
УдалитьA: 17 сек
B: 58 сек
kvy, и в прямом и в обратном направлении: около 33 сек.
Использование Z-order, конечно, впечатляет в некоторых случаях. Правда, у меня и процессор раза так в два, наверное, помедленнее. Любопытно, даст ли что-нибудь оптимизация под ассемблер.
И есть еще идея: что будет, если вместо Z-order перед каждым шагом сортировать фронт волны так, что бы читать данные из памяти в максимально последовательном порядке?
> просто построчное читаю текстовый файл и его разбираю
УдалитьТоже самое, просто изначально делал чуть-чуть кривовато :) Я тут задумался, Вы ведь ReadLn используете? Судя по ее API она читает посимвольно пока не встретит перевод строки чтобы ничего лишнего не прочитать, так как обратно не вернуть, или где-то буфер чтения неявно присутствует? В Java явно создаешь BufferedReader, который собственно читает блоками пока не встретит перевод строки, а остаток вернет в следующей строке. По другому я не могу объяснить почему чтение такое медленное.
> перед каждым шагом сортировать фронт волны
Попробовал, у меня вышло аж 41 сек.
Да, я использую Readln. Честно говоря, ни разу не задумывался, как она работает. Думал, что буферизирует, потому что совершенно точно, если считывать посимвольно с помощью Read, получается на порядок медленнее. Разобрать, как она работает, достаточно сложно, потому что по сути является псевдопроцедурой, компилятор вроде генерирует индивидуальный код из вызовов простых функций для каждого ее варианта.
УдалитьВозможно, ты прав, вроде у них в библиотеке есть и поновее варианты с буферизированным текстовым вводом. Надо будет попробовать.
> Попробовал, у меня вышло аж 41 сек.
Тоже попробовал. Получилось слегка дольше. Видимо, при недостаточной производительности памяти смысла в таком подходе нет.
Покопался в исходниках Free Pascal, исходников Delphi не нашел, они все еще закрыты? В общем буфер действительно есть, он внутри TextFile, но по дефолту всего 128 байт! К счастью https://docwiki.embarcadero.com/Libraries/Sydney/en/System.SetTextBuf позволяет его увеличить, я бы попробовал что-нибудь в районе 4-8КБ.
Удалить> исходников Delphi не нашел, они все еще закрыты?
УдалитьПочему? Библиотеки давным-давно открыты, лежат в дистрибутиве. Сложность в том, что непонятно, в какие вызовы превращает компилятор Readln. Можно в режиме отладки смотреть, конечно, но это сильно муторно. Нашел в библиотеках некую функцию _readln, которая, возможно используется компилятором, там буфер 128 байт и он жестко зашит, возможности поменять нет.
Попробую переписать на буферизированном файловом потоке, посмотрим, что получится.