Русский
Русский
English
Статистика
Реклама

Игра жизнь

Перевод Создание образа Мона Лизы в Игре Жизнь

18.03.2021 12:07:58 | Автор: admin

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


На загрузку этого видео может уйти несколько секунд. Рекомендую прищуриться так удастся лучше разглядеть результат:)

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

Недавно мне попалась одноименная статья Кевина Галлигана, которая натолкнула меня на мысль, что можно добиться аналогичных результатов, но спомощью другого подхода. Что если вместо решателя задач выполнимости булевых формул применить определенный эвристический алгоритм, который сможет запрограммировать огромный мир Игры Жизнь так, чтобы спустя несколько поколений формировалось изображение?

Этого реально добиться другими путями. Например, поместить натюрморты (устойчивые фигуры) в определенных пикселях, как описано в вопросе на Codegolf.

Мой же замысел был отобразить картину Мона Лизы для одного кадра/поколения Жизни без натюрморта.

Алгоритм


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

Вот весь алгоритм:

best_score := infinity    target := mona lisa with dimensions m x n    canvas := random matrix of m x n    best_result := canvas    do        modified_canvas := Copy of canvas with a single random cell inverted        nth_modified_canvas := Run N generations of Game of Life modified_canvas        Compute a score of how close nth_modified_canvas is with target        if score < best_score then        best_score := score            best_result := modified_canvas        canvas := best_result    while(max_iterations limit passed or best_score < threshold)


Изначально я создал прототип для выполнения на одном ядре.

def modify(canvas, shape):    x,y = shape    px = int(np.random.uniform(x+1))-1    py = int(np.random.uniform(y+1))-1    canvas[px][py] = not canvas[px][py]    return canvasdef rmse(predictions,targets):    return np.sqrt(np.mean((predictions-targets)**2))while best_score>limit:    canvases = np.tile(np.copy(best_seed), (batch_size, 1, 1))    rms_errors = []    for canvas in range(len(canvases)):        canvases[canvas] = modify(states[state], (m,n))        rmse_val = rmse(target, nth_generation(np.copy(canvases[canvas])))        rms_errors.append(rmse_val)    lowest = min(rms_errors)    if lowest < best_score:        best_score = lowest        best_result = canvases[rms_errors.index(lowest)]

Принцип работы поиска с восхождением к вершине заключается в определении ближайшего к текущему соседнего состояния с наименьшей ошибкой по сравнению с target_state, представляющим Мона Лизу. Ближайший сосед на каждом шаге находится через создание копии лучшего текущего решения и инвертирования случайной клетки. Столь небольшое изменение не вызывает риска выхода из локального минимума. В качестве метрики для сравнения лучшего состояния с целевым используется среднеквадратичная ошибка (RMSE).

Спустя несколько дней CPU-вычислений я смог получить изображение, напоминающее Мона Лизу, через воспроизведение 4 поколений Жизни.


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

Препроцессинг


В качестве целевого изображения Мона Лизы, с которым алгоритм сравнивает случайное состояние, использовалась версия среднего разрешения, взятая из Википедии и преобразованная в черно-белую форму с помощью инструкции Python Imaging Library Image.open('target.png').convert('L').


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


Можно было вообще не округлять и сравнивать с версией в оттенках серого, но есть способ получше.

Состояния Сада Эдема


Не каждая случайная матрица 0 и 1 представляет допустимое состояние Игры Жизнь. Состояния, которые никогда не могут быть n-поколением (n>0) любого клеточного автомата называются Сад Эдема.

Практически нереально, чтобы наша округленная черно-белая вариация Мона Лизы оказалась возможным поколением Жизни. Поэтому остается искать решение, которое окажется просто максимально приближенным цели.


Это часть 4-го поколения только что подготовленного состояния

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


1-битная версия Мона Лизы

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

Получить такой вариант изображения можно с помощью функции дизеринга Флойда-Стейнберга библиотеки PIL, использовав инструкцию Image.open('target.png').convert('1').


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

Векторизация с помощью JAX


Версия для одного ядра CPU без использования векторизации оказывается чрезвычайно медленной. Я пробовал выполнять ее и на своем Core i7 восьмого поколения, и на машинах Google Colab, но для получения похожего на цель результата приходится всякий раз ждать часы или даже дни, в зависимости от разрешения.

К счастью, эта задача отлично поддается распараллеливанию.



JAX это библиотека Python, которая позволяет использовать numpy-версию и компилировать ее в высоко векторизованный код, который можно выполнять на графических и тензорных процессорах. В нашем случае нужно переработать этот алгоритм конкретно для графического (GPU).

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

Мы экструдируем target (Мона Лиза) и canvas (начальное случайное состояние) в 3-е измерение, в котором они формируют многослойные тензорные структуры (loafs) с длиной равной batch_size.




Прежде чем переходить к циклу восхождения к вершине, мы устанавливаем best_canvas как начальный случайный холст.

Дополнительно для каждой итерации этого цикла нужно производить случайный тензор, называемый мутатором (той же формы, что target), со следующим свойством: в каждом слое должна присутствовать только одна 1, расположенная в случайном месте.


Вот пример мутатора формы 5, 3, 2, где 5 это размер batch-size:

array([[[1, 0],        [0, 0],        [0, 0]],       [[1, 0],        [0, 0],        [0, 0]],       [[0, 0],        [0, 1],        [0, 0]],       [[0, 1],        [0, 0],        [0, 0]],       [[1, 0],        [0, 0],        [0, 0]]])


Идея в том, чтобы в каждом цикле с помощью мутатора вычислять ближайший набор соседних с best_canvas состояний: canvas = (best_canvas + mutator)%2.

Мы вычисляем N поколений Игры в каждом слое этого модифицируемого холста, после чего определяем показатель RMSE (среднее вычисляется только для слоя) холста N-поколения в отношении к Мона Лизе и находим слой с наименьшей ошибкой. Далее этот слой экструдируется и устанавливается как best_canvas, после чего данный цикл повторяется в течение конечного числа итераций.

Код


Весь код этого проекта доступен на GitHub. В текущем же разделе я поясню, что в нем означает каждый блок. Если вы хотите сразу увидеть результаты, просто промотайте к концу статьи. Основа проекта, а именно функция Игры Жизнь взята из этого поста.

Выражаю благодарность Бояну Николичу. Я следовал его условию импорта jax.numpy в качестве N, а jax.lax в качестве L.

%matplotlib inline import jaxN=jax.numpyL=jax.laxfrom jax.experimental import loopsfrom jax import opsimport matplotlib.pyplot as pltimport numpy as onpimport timefrom PIL import Image from google.colab import files

Далее выполняем wget Мона Лизы:

!wget -O target.png https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg/483px-Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg?download

Это скромная версия шириной в 483px.

batch_size = 100image_file = Image.open("target.png")image_file = image_file.convert('1')lisa = N.array(image_file, dtype=N.int32)width,height = lisa.shapelisa_loaf = onp.repeat(lisa[onp.newaxis, :, :,], batch_size, axis = 0)

Здесь выполняется дизеринг изображения Мона Лизы и его экструдирование до глубины равной batch_size:

key = jax.random.PRNGKey(42)canvas_loaf = jax.random.randint(key, (batch_size, width, height), 0, 2, dtype= N.int32) #для тестов инициализируем случайное изображение Лизы

Здесь создается начальное значение JAX PRNG (вскоре я поясню, о чем речь), а также начальный случайный canvas_loaf с целыми числами 0 и 1.

@jax.jitdef rgen(a):    # Это сокращение превосходит численностью соседей живых клеток, так как включает в себя #саму центральную клетку. Чтобы это исправить, нужно вычесть массив    nghbrs=L.reduce_window(a, 0, L.add, (3,3), (1,1), "SAME")-a    birth=N.logical_and(a==0, nghbrs==3)    underpop=N.logical_and(a==1, nghbrs<2)    overpop=N.logical_and(a==1, nghbrs>3)    death=N.logical_or(underpop, overpop)    na=L.select(birth,                N.ones(a.shape, N.int32),                a)    na=L.select(death,                N.zeros(a.shape, N.int32),                na)    return navectorized_rgen = jax.vmap(rgen)@jax.jitdef nv_rgen(state):  for _ in range(n_generations):      state = vectorized_rgen(state)  return state

Пояснение функции rgen, которая воспроизводит одно поколение Жизни, можете найти в статье упомянутого мной Бояна Николича.

jax.vmap позволяет создать функцию, которая отображает входную функцию на оси аргументов (выполняет векторизацию). Это дает возможность воспроизвести поколение Жизни для каждого слоя холста.

nv_rgen воспроизводит на холсте N-поколений Жизни.

Помимо этого, декоратор @jax.jit обуславливает JIT-компиляцию данной функции (Just-in-time, то есть компиляцию в процессе выполнения программы). Не уверен, произошли ли в данном случае какие-либо улучшения, так как nv_rgen просто складывается из других функций, также компилируемых при выполнении.

def mutate_nj(b, w, h, subkey):  a = jax.random.normal(subkey, (b, w, h))  return (a == a.max(axis=(1,2))[:,None,None]).astype(int)mutate = jax.jit(mutate_nj, static_argnums=(0,1,2))


mutate_nj (nj означает без JIT) генерирует тензор мутатора с помощью jax.random.normal, устанавливая для каждого слоя максимум одну 1 и остальные 0. Аргумент subkey я объясню чуть позже.

Далее мы JIT-компилируем эту функцию как mutate, а также отмечаем аргументы b, w, h как статические, указывая компилятору, что в процессе выполнения они изменяться не будут.

def rmse_nj(original, canvas, b, w, h):  return N.sqrt(N.mean(L.reshape((original-canvas)**2,(b,w*h)) , axis=1))rmse = jax.jit(rmse_nj, static_argnums=(2,3,4))


rmse говорит сама за себя. Единственное существенное отличие от CPU-версии в том, что среднее вычисляется по 1-й оси (оси длины экструдированной структуры).

def hill_climb(original, canvas, prng_key, iterations):  with loops.Scope() as s:    s.best_score = N.inf    s.best_canvas = canvas    s.canvas = canvas    s.prng_key = prng_key    for run in s.range(iterations):      s.prng_key, subkey = jax.random.split(s.prng_key)      s.canvas+=modify(batch_size, width, height, subkey)      s.canvas%=2      rmse_vals = rmse(original, nv_rgen( s.canvas ), batch_size, width, height)      curr_min = N.min(rmse_vals)      for _ in s.cond_range(curr_min < s.best_score):        s.best_score = curr_min        s.best_canvas = N.repeat((s.canvas[N.argmin(rmse_vals)])[N.newaxis, :, :,], batch_size, axis = 0)      s.canvas = s.best_canvas    return s.canvas

hill_climb является основной функцией программы. Это одна большая конструкция цикла JAX. Можно было использовать здесь стандартные циклы Python, но нужно по максимуму задействовать возможности JAX.

Циклы в JAX (модуль jax.experimental.loops) это функции, представляющие синтаксический сахар, например lax.fori_loop и lax.cond. Циклы lax (фактически, циклы XLA), которые содержат больше нескольких инструкций и вложения, становятся очень сложными. Тем не мене в JAX они приближены к стандартным циклам Python. Единственная загвоздка в том, что состояние цикла, то есть все, что изменяется по ходу итераций, должно храниться как член его области. В нашем случае сюда относятся best_score, best_canvas, временный холст, где мы воспроизводим Жизнь, а также ключ PRNG.

JAX PRNG


В NumPy для всех функций, предполагающих наличие случайных чисел, используется управляемый генератор псевдослучайных чисел (PRNG). То есть NumPy полностью отвечает за определение для него начального числа (seed) и последующую обработку состояния. Как я понимаю, при параллельном выполнении, а также в ситуациях, требующих большого количества случайных чисел, этот метод имеет свои недостатки. С его помощью сложно обеспечить достаточную энтропию для производства нужного количества случайных значений.

В отличие от NumPy, JAX не управляет случайной генерацией чисел. Каждой функции jax.random в качестве первого аргумента необходимо предоставлять текущее состояние PRNG, и при каждом выполнении одной из этих функций состояние PRNG должно обновляться через jax.random.split.

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

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

cond_range


Почему в JAX условные конструкции также являются циклами? Честно, я не совсем это понимаю. У cond_range должна быть возможность выводить стандартное логическое значение вместо итератора длиной 0/1, но по какой-то причине спроектирована конструкция именно так.

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

Спустя конечное число итераций мы получаем состояние Жизни, которое через N поколений формирует образ Мона Лизы.

Итоги


Выполнение ~1000 итераций для изображения шириной 483px на GPU Google Colab занимает всего ~40 секунд. Если сравнивать с версией программы для CPU, которая даже изображение меньшего разрешения обрабатывала несколько часов, можно считать, что цели мне достичь удалось.


Состояние Жизни, дающее наибольшее сходство с целевым изображением, было достигнуто через ~23 000 итераций, что заняло 10 минут. После 23 000 повторений прирост качества прекращается, и даже при достижении 100 000 итераций видимых улучшений не наблюдается.
Также отмечу, что изображения, получаемые при меньшем числе поколений, как и ожидалось, проявляют большее сходство с целью.

Мона Лиза, 10 поколений:


Тестовый образец шахматной доски, 7 поколений:


Тестовый образец текста, 5 поколений:


Давид (работа Микеланджело), 3 поколения:


Луна, 7 поколений (http://personeltest.ru/aways/unsplash.com/photos/pd4lo70LdbI):


Нил Армстронг, 7 поколений:



Заключение


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

Джон Нортон Конвей (26 декабря 1937 11 апреля 2020), RIP:



Подробнее..

Game of Life с битовой магией, многопоточностью и на GPU

03.07.2020 20:16:17 | Автор: admin

Всем привет!


Недавняя статья на Хабре в очередной раз показала неостывающий интерес к игре Жизнь в частности и всевозможным оптимизациям в общем. Статья и комментарии к ней, особенно любопытство к вычислениям на GPU, вдохновили меня на то, чтобы поделиться своими изысканиями на данном поприще и, забегая вперёд, скажу, что повествование пойдёт о расчётах на GPU, битовой магии, многопоточности и огромных полях для игры Жизнь, порядка миллиарда клеток.



О себе


Пару слов о себе и проекте. Вот уже несколько лет моим хобби и pet-проектом является написание игры Жизнь, в ходе которого я разбираюсь в интересующих меня темах. Так, сперва я сделал её с помощью библиотеки glut и прикрутил многопользовательский режим, будучи вдохновлённым архитектурой peer-to-peer. А около двух лет назад решил начать всё с нуля, используя Qt и вычисления на GPU.

Масштабы


Идея нового проекта заключалась в том, чтобы сделать кроссплатформенное приложение, в первую очередь для мобильных устройств, в котором пользователи смогут окунуться в игру Жизнь, познакомиться с разнообразием всевозможных паттернов, наблюдать за причудливыми формами и комбинациями этой игры, выбрав скорость от 1 до 100 мс на шаг и задав размер игрового поля вплоть до 32'768 x 32'768, то есть 1'073'741'824 клетки.

На всякий случай напомню об игре Жизнь. Игра происходит на плоскости, поделённой на множество клеток. Клетки квадратные, соответственно, для каждой клетки есть 8 соседей. Каждая клетка может быть либо живой, либо мёртвой, то есть пустой. В рамках игры пользователь заполняет клетки жизнью, выставляя на поле заинтересовавшие его комбинации клеток паттерны.
Далее процесс шаг за шагом развивается по заданным правилам:
  • в пустой клетке, рядом с которой ровно 3 живые клетки, зарождается жизнь
  • если у живой клетки есть 2 или 3 живых соседки, то эта клетка продолжает жить
  • если у живой клетки меньше 2 или больше 3 живых соседки, клетка умирает

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

В моей реализации поле зациклено, то есть после 32'767-й клетки вновь следует 0-я. Таким образом, перемещающиеся паттерны способны обогнуть поле и оказаться в точке, где они были размещены изначально. Забавный эффект случается с паттерном планёрное ружьё, когда выпущенные им планёры в конечном итоге врезаются в само ружьё и разрушают его, этакое самоубийство в мире клеточных автоматов:

Gosper glider gun


Архитектура


Что касается реализации, то в первую очередь хочется отметить, что каждая клетка в игровом поле представляет собой всего лишь 1 бит в памяти. Так, при выборе размера игрового поля в памяти выделяется буфер размером n^2 / 8 байт. Это улучшает локальность данных и снижает объём потребляемой памяти, а главное позволяет применить ещё более хитроумные оптимизации, о которых поговорим ниже. Игровое поле всегда квадратное и его сторона всегда степени 2, в частности затем, чтобы без остатка осуществлять деление на 8.

Архитектурно выделяются два слоя, ответственные за вычисления:
  • низкий уровень платформозависимый; на текущий момент существует реализация на Metal API графическое API от Apple позволяет задействовать GPU на устройствах от Apple, а также fallback-реализация на CPU. Одно время существовала версия на OpenCL. Планируется реализация на Vulkan API для запуска на Android
  • высокий уровень кроссплатформенный; по сути класс-шаблонный метод, делегирующий реализацию нужных методов на низкий уровень

Низкий уровень


Задача низкого уровня заключается непосредственно в расчёте состояния игрового поля и устроена следующим образом. В памяти хранятся два буфера n^2 / 8 байт. Первый буфер служит как input текущее состояние игрового поля. Второй буфер как output, в который записывается новое состояние игрового поля в процессе расчётов. По завершении вычислений достаточно сделать swap буферов и следующий шаг игры готов. Такая архитектура позволяет распараллелить расчёты в силу константности input. Дело в том, что каждый поток может независимо обрабатывать клетки игрового поля.

За счёт многопоточности GPU-подход добивается максимальной эффективности. На CPU также происходит распараллеливание, но, соответственно, с гораздо меньшим числом потоков и меньшей эффективностью, что отражено в секции бенчмарков ниже. Сам алгоритм идентичен для всех реализаций и заключается в следующем.

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

[........]


А участок игрового поля с интересующим нас байтом и его восемью соседними байтами изобразим так:

[........][........][........]
[........][********][........]
[........][........][........]


Исходя из рисунка, можно догадаться, что все соседние клетки можно уложить в один uint64_t (назовём его neighbours), а ещё в одном uint8_t (назовём его self), будет храниться информация о соседях в самом обрабатываемом байте.

Рассмотрим на примере расчёт 0-го бита целевого байта. Звёздочкой отметим интересующий бит, а нулём соседние для него биты:

[.......0][00......][........]
[.......0][*0......][........]
[.......0][00......][........]


Пример посложнее. Здесь звёздочкой отмечены 0-й, 3-й и 7-й биты целевого байта и соответствующими числами соседние биты:

[.......0][00333.77][7.......]
[.......0][*03*3.7*][7.......]
[.......0][00333.77][7.......]


Думаю, кто-то из читателей уже догадался, в чём заключается магия. Имея указанную информацию, остаётся лишь составить битовые маски для каждого бита целевого байта и применить их к neighbours и self соответственно. Результатом станут 2 значения, сумма единичных бит которых будет характеризовать число соседей, что можно интерпретировать как правила игры Жизнь: 2 или 3 бита для поддержания жизни в живой клетке и 3 бита для зарождения новой жизни в пустой клетке. В противном случае клетка остаётся/становится пустой.

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

После заполнения всего выходного буфера игровое поле считается рассчитанным.
Код Metal-шейдера по обработке 1 байта
// Бросается в глаза C++-подобный синтаксис#include <metal_stdlib>#include <metal_integer>using namespace metal;// Вспомогательные функции для вычисления позиции клеткиushort2 pos(uint id) {   return ushort2(id % WIDTH, id / HEIGHT); }uint idx(ushort2 pos) {   return pos.x + pos.y * HEIGHT; }ushort2 loopPos(short x, short y) {   return ushort2((x + WIDTH) % WIDTH, (y + HEIGHT) % HEIGHT); }// Битовые маски для вычисления соседей интересующего битаtemplate<uint Bit> struct Mask {   constant constexpr static uint c_n_e_s_w = 0x70007 << (Bit - 1);   constant constexpr static uint c_nw_ne_se_sw = 0x0;   constant constexpr static uint c_self = 0x5 << (Bit - 1); };template<> struct Mask<0> {   constant constexpr static uint c_n_e_s_w = 0x80030003;   constant constexpr static uint c_nw_ne_se_sw = 0x80000080;   constant constexpr static uint c_self = 0x2; };template<> struct Mask<7> {   constant constexpr static uint c_n_e_s_w = 0xC001C0;   constant constexpr static uint c_nw_ne_se_sw = 0x10100;   constant constexpr static uint c_self = 0x40; };// Для указанного бита функция вычисляет состояние клетки в зависимости от её соседей, применяя соответствующие биту маскиtemplate<uint Bit>uint isAlive(uint self, uint n_e_s_w, uint nw_ne_se_sw) {   /*  [.......0][00333.77][7.......]  [.......0][*03*3.7*][7.......]  [.......0][00333.77][7.......]  */  // До определённой версии в Metal не было 64-битного целого, поэтому составляются две маски  uint neighbours = popcount(Mask<Bit>::c_n_e_s_w & n_e_s_w)     + popcount(Mask<Bit>::c_nw_ne_se_sw & nw_ne_se_sw)     + popcount(Mask<Bit>::c_self & self);   return static_cast<uint>((self >> Bit & 1) == 0     ? neighbours == 3     : neighbours == 2 || neighbours == 3) << Bit;}// Язык Metal даже умеет в шаблонную магиюtemplate<uint Bit>uint calculateLife(uint self, uint n_e_s_w, uint nw_ne_se_sw) {   return isAlive<Bit>(self, n_e_s_w, nw_ne_se_sw)     | calculateLife<Bit - 1>(self, n_e_s_w, nw_ne_se_sw); }template<>uint calculateLife<0>(uint self, uint n_e_s_w, uint nw_ne_se_sw){  return isAlive<0>(self, n_e_s_w, nw_ne_se_sw); }// Главная функция compute-шейдера. На вход подаются два буфера, о которых речь шла выше - константный input и output, а также id - координата целевого байтаkernel void lifeStep(constant uchar* input [[buffer(0)]],                             device uchar* output [[buffer(1)]],                             uint id [[thread_position_in_grid]]) {   ushort2 gid = pos(id * 8);   // Вычисляем соседние байты  uint nw = idx(loopPos(gid.x - 8, gid.y + 1));   uint n  = idx(loopPos(gid.x,     gid.y + 1));   uint ne = idx(loopPos(gid.x + 8, gid.y + 1));   uint e  = idx(loopPos(gid.x + 8, gid.y    ));   uint se = idx(loopPos(gid.x + 8, gid.y - 1));   uint s  = idx(loopPos(gid.x    , gid.y - 1));   uint sw = idx(loopPos(gid.x - 8, gid.y - 1));   uint w  = idx(loopPos(gid.x - 8, gid.y    ));   // Вычисляем байт с целевым битом  uint self = static_cast<uint>(input[id]);   // Подготавливаем битовые маски с соседями  // north_east_south_west  uint n_e_s_w = static_cast<uint>(input[n >> 3]) << 0 * 8     | static_cast<uint>(input[e >> 3]) << 1 * 8     | static_cast<uint>(input[s >> 3]) << 2 * 8     | static_cast<uint>(input[w >> 3]) << 3 * 8;   // north-west_north-east_south-east_south-west  uint nw_ne_se_sw = static_cast<uint>(input[nw >> 3]) << 0 * 8     | static_cast<uint>(input[ne >> 3]) << 1 * 8     | static_cast<uint>(input[se >> 3]) << 2 * 8     | static_cast<uint>(input[sw >> 3]) << 3 * 8;     // В этой строчке рассчитываются все 8 клеток обрабатываемого байта  output[id] = static_cast<uchar>(calculateLife<7>(self, n_e_s_w, nw_ne_se_sw)); };


Высокий уровень


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

Отрисовка клеток выполняется средствами Qt, а именно посредством заполнения пикселей в QImage. Интерфейс выполнен в QML. Пиксели заполняются лишь для небольшой области видимого игроку игрового поля. Таким образом удаётся избежать лишних затрат ресурсов на отрисовку.

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

Бенчмарки


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

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

MacBook Pro 2014
Processor 2,6 GHz Dual-Core Intel Core i5
Memory 8 GB 1600 MHz DDR3
Graphics Intel Iris 1536 MB

GPU реализация
1024 2048 4096 8192 16384 32768
Низкий уровень (min) 0 0 2 9 43 170
Высокий уровень (min) 0 0 0 1 12 55
100 шагов 293 446 1271 2700 8603 54287
Время на шаг (avg) 3 4 13 27 86 542

CPU реализация
1024 2048 4096 8192 16384 32768
Низкий уровень (min) 3 25 117 552 2077 21388
Высокий уровень (min) 0 0 0 1 4 51
100 шагов 944 3894 15217 65149 231260 -
Время на шаг (avg) 9 39 152 651 2312 -

MacBook Pro 2017
Processor 2.8 GHz Intel Core i7
Memory 16 GB 2133 MHz LPDDR3
Graphics Intel HD Graphics 630 1536 MB

GPU реализация
1024 2048 4096 8192 16384 32768
Низкий уровень (min) 0 0 0 2 8 38
Высокий уровень (min) 0 0 0 0 3 13
100 шагов 35 67 163 450 1451 5886
Время на шаг (avg) 0 1 2 5 15 59

CPU реализация
1024 2048 4096 8192 16384 32768
Низкий уровень (min) 1 7 33 136 699 2475
Высокий уровень (min) 0 0 0 0 3 18
100 шагов 434 1283 4262 18377 79656 264711
Время на шаг (avg) 4 13 43 184 797 2647

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

Итог


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

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

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

Спасибо за внимание! Буду рад любым комментариям к статье и к проекту:
Код на GitHub.

Код Metal реализации нижнего уровня

Код CPU реализации нижнего уровня

Код верхнего уровня
Подробнее..

Conways Game of life на Python

13.12.2020 00:13:26 | Автор: admin

Это мой первый пост, где я хочу рассказать про самый известный клеточный автомат "Игра жизнь", а также напишем её на Python с использованием графики Pygame.

Conways Game of life ( по русски 'Игра жизнь' ) - клеточный автомат, придуманный Джоном Конвеем в далеком 1970 году.

Правила очень просты, вся игра происходит в 2D пространстве (плоскости) на которой могут быть 2 типа клеток "Живые" - 0 и "Пустые" -1. Основные правила жизни клетки таковы Birth3 Survive23, что значит клетка рождается при трёх соседях и выживает при двух или трёх, иначе умирает.

Определения кол-ва соседей происходит по соседству Мура.

Небольшая предыстория создания с Википедии.

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

Впервые описание этой игры было опубликовано в октябрьском (1970 год) выпуске журнала Scientific American, в рубрике Математические игры Мартина Гарднера (Martin Gardner)

Уверен, вам надоела вся эта теория, приступаем к написанию симуляции на Python/Pygame

Надеюсь у вас уже установлен Python, если же нет то вперёд устанавливать.

Для графики скачайте библиотеку pygame с помощью команды в терминале "pip install pygame" или же "pip3 install pygame" (если у вас высвечивается ошибка "pip не является внешней или внутренней командой" используя каждую команду ,скорее всего вы не поставили PATH при установке Python)

Для тестирования графики попробуем данный код, создающий сетку во всё окно

# Импортыimport pygame as pfrom pygame.locals import *# Константы цветов RGBBLACK = (0 , 0 , 0)WHITE = (255 , 255 , 255)# Создаем окноroot = p.display.set_mode((1000 , 500))# Основной циклwhile 1:    # Заполняем экран белым цветом    root.fill(WHITE)        # Рисуем сетку    for i in range(0 , root.get_height() // 20):        p.draw.line(root , BLACK , (0 , i * 20) , (root.get_width() , i * 20))    for j in range(0 , root.get_width() // 20):        p.draw.line(root , BLACK , (j * 20 , 0) , (j * 20 , root.get_height()))    # Нужно чтобы виндовс не думал что программа "не отвечает"    for i in p.event.get():        if i.type==QUIT:          quit()    p.display.update()

А теперь сделаем список клеток и функцию определения кол-ва соседей

Как работает определение кол-ва соседей
  1. Создаем систему соседства system

  2. Создаем переменную счетчик count

  3. Проходимся по каждому элементу системы

  4. Если сосед клетки по системе "живой", увеличиваем counter.

  5. Возвращаем count

# 2х мерный список с помощью генераторных выраженийcells=[ [0 for j in range(root.get_width()//20)] for i in range(root.get_height()//20)]cells2=cells# Функция определения кол-ва соседейdef near(pos: list , system=[[-1 , -1] , [-1 , 0] , [-1 , 1] , [0 , -1] , [0 , 1] , [1 , -1] , [1 , 0] , [1 , 1]]):    count = 0    for i in system:        if cells[(pos[0] + i[0]) % len(cells)][(pos[1] + i[1]) % len(cells[0])]:            count += 1    return count

И так, теперь сделаем основную логику.

    # Проходимся по всем клеткам    for i in range(len(cells)):        for j in range(len(cells[0])):            # Если клетка жива            if cells[i][j]:                # Если у соседей не 2 или 3 соседа                if near([i , j]) not in (2 , 3):                    cells2[i][j] = 0                    continue                # В ином случае                cells2[i][j] = 1                continue            # Если клетка мертва и у неё 3 соседа                if near([i , j]) == 3:                cells2[i][j] = 1                continue            # В противном случае                cells2[i][j] = 0    cells = cells2
Полный код
# Импортыimport timeimport pygame as pimport randomfrom pygame.locals import *# Константы цветов RGBBLACK = (0 , 0 , 0)WHITE = (255 , 255 , 255)# Создаем окноroot = p.display.set_mode((1000 , 500))# 2х мерный список с помощью генераторных выраженийcells = [[random.choice([0 , 1]) for j in range(root.get_width() // 20)] for i in range(root.get_height() // 20)]# Функция определения кол-ва соседейdef near(pos: list , system=[[-1 , -1] , [-1 , 0] , [-1 , 1] , [0 , -1] , [0 , 1] , [1 , -1] , [1 , 0] , [1 , 1]]):    count = 0    for i in system:        if cells[(pos[0] + i[0]) % len(cells)][(pos[1] + i[1]) % len(cells[0])]:            count += 1    return count# Основной циклwhile 1:    # Заполняем экран белым цветом    root.fill(WHITE)    # Рисуем сетку    for i in range(0 , root.get_height() // 20):        p.draw.line(root , BLACK , (0 , i * 20) , (root.get_width() , i * 20))    for j in range(0 , root.get_width() // 20):        p.draw.line(root , BLACK , (j * 20 , 0) , (j * 20 , root.get_height()))   # Нужно чтобы виндовс не думал что программа "не отвечает"    for i in p.event.get():        if i.type == QUIT:            quit()    # Проходимся по всем клеткам    for i in range(0 , len(cells)):        for j in range(0 , len(cells[i])):            print(cells[i][j],i,j)            p.draw.rect(root , (255 * cells[i][j] % 256 , 0 , 0) , [i * 20 , j * 20 , 20 , 20])    # Обновляем экран    p.display.update()    cells2 = [[0 for j in range(len(cells[0]))] for i in range(len(cells))]    for i in range(len(cells)):        for j in range(len(cells[0])):            if cells[i][j]:                if near([i , j]) not in (2 , 3):                    cells2[i][j] = 0                    continue                cells2[i][j] = 1                continue            if near([i , j]) == 3:                cells2[i][j] = 1                continue            cells2[i][j] = 0    cells = cells2
Проверка кодаПроверка кода

Все получилось, скорость тоже не расстраивает.

В следующих статьях попробуем реализовать модификации игры "Жизнь".

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru