Это очень хорошой case для оптимизации. Алгоритм крайне прост и его знают все. Но сколько можно сделать!

1. Julia, попытка первая и наивная

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

Наша первая реализация:

global size = 10000
global board=zeros(Int8, size, size) 
global helper=zeros(Int8, size, size)

function step0()
  global size, board, helper
  for x in 1:size
    left = ((x == 1) ? size : x-1)
    lower = size
    right = ((x == size) ? 1 : x+1)
    for y in 1:size
      upper = ((y == size) ? 1 : y+1)
      # get the number of neighbours
      nb = board[left, upper] + board[x, upper] + board[right, upper] + 
                board[left, y] +                            board[right, y] + 
                board[left, lower] + board[x, lower]      + board[right, lower]
      cell = board[x, y]
      w = 0
      if (nb == 3) && (cell == 0) 
        w = 1 # new born
      elseif (nb < 2) && (cell == 1) 
        w = 1 # die lonely
      elseif (nb > 3) && (cell == 1) 
        w = 1 # die from owercrowding
      end
      helper[x, y] = w
      lower = y
    end
  end
  
  # now move modifications back
  Threads.@threads for x in 1:size
    for y in 1:size
	  if helper[x,y] > 0 
	     board[x,y] = 1 - board[x,y]
	  end
    end
  end
  
  return
end

Дает время 85.486411 seconds (1.02 G allocations: 18.188 GiB, 1.83% gc time). Что? Лишь немного быстрее Python?

Мы совершили страшную ошибку. Подсказкой является большое количество allocations. Дело в том, что в Julia переменные могут иметь любой тип. Поэтому, когда функция использует глобальные переменные, то она не может быть уверена в их типе, и вынуждена вставлять проверки run time. Правильным будет передавать все через параметры:

2. Первая правильная реализация

function step1(size, board, helper)
  for x in 1:size
    left = ((x == 1) ? size : x-1)
    lower = size
    right = ((x == size) ? 1 : x+1)
    for y in 1:size
      upper = ((y == size) ? 1 : y+1)
      # get the number of neighbours
      nb = board[left, upper] + board[x, upper] + board[right, upper] + 
                board[left, y] +                            board[right, y] + 
                board[left, lower] + board[x, lower]      + board[right, lower]
      cell = board[x, y]
      w = 0
      if (nb == 3) && (cell == 0) 
        w = 1 # new born
      elseif (nb < 2) && (cell == 1) 
        w = 1 # die lonely
      elseif (nb > 3) && (cell == 1) 
        w = 1 # die from owercrowding
      end
      helper[x, y] = w
      lower = y
    end
  end
  
  # now move modifications back
  for x in 1:size
    for y in 1:size
	  if helper[x,y] > 0 
	     board[x,y] = 1 - board[x,y]
	  end
    end
  end
  
  return
end

1.882592 seconds

Нет никаких аллокаций. Вот это уже другое дело. Мы достигли скорости 53M клеток в секунду (напоминаю, это AMD Ryzen 5 3600X 6-Core Processor 3.79 GHz)

3. Multi-threading

К счастью, часто распараллелить программу на Julia элементарно - мы просто добавим Threads@threadsперед циклами.

function step2(size, board, helper)
  Threads.@threads for x in 1:size
    left = ((x == 1) ? size : x-1)
    lower = size
    right = ((x == size) ? 1 : x+1)
    for y in 1:size
      upper = ((y == size) ? 1 : y+1)
      # get the number of neighbours
      nb = board[left, upper] + board[x, upper] + board[right, upper] + 
                board[left, y] +                            board[right, y] + 
                board[left, lower] + board[x, lower]      + board[right, lower]
      cell = board[x, y]
      w = 0
      if (nb == 3) && (cell == 0) 
        w = 1 # new born
      elseif (nb < 2) && (cell == 1) 
        w = 1 # die lonely
      elseif (nb > 3) && (cell == 1) 
        w = 1 # die from owercrowding
      end
      helper[x, y] = w
      lower = y
    end
  end
  
  # now move modifications back
  Threads.@threads for x in 1:size
    for y in 1:size
	  if helper[x,y] > 0 
	     board[x,y] = 1 - board[x,y]
	  end
    end
  end
  
  return
end

Здесь мы пользуется тем, что циклы не пересекаются в памяти по записи. Тестировать будем на одном треде и с --threads 12:

1 thread: 1.981022 seconds (12 allocations: 1.375 KiB)

12 threads: 0.447547 seconds (124 allocations: 13.438 KiB)

Мы достигли скорости 223M клеток в секунду

4. inbounds, отмена проверки индексов массива

Так как мы абсолютно уверены в себе, давайте выключим проверки:

function step3(size, board, helper)
  Threads.@threads for x in 1:size
    left = ((x == 1) ? size : x-1)
    lower = size
    right = ((x == size) ? 1 : x+1)
    for y in 1:size
      upper = ((y == size) ? 1 : y+1)
      # get the number of neighbours
      @inbounds nb = board[left, upper] + board[x, upper] + board[right, upper] + 
                board[left, y] +                            board[right, y] + 
                board[left, lower] + board[x, lower]      + board[right, lower]
      @inbounds cell = board[x, y]
      w = 0
      if (nb == 3) && (cell == 0) 
        w = 1 # new born
      elseif (nb < 2) && (cell == 1) 
        w = 1 # die lonely
      elseif (nb > 3) && (cell == 1) 
        w = 1 # die from owercrowding
      end
      @inbounds helper[x, y] = w
      lower = y
    end
  end
  
  # now move modifications back
  Threads.@threads for x in 1:size
    for y in 1:size
	  @inbounds if helper[x,y] > 0 
	     @inbounds board[x,y] = 1 - board[x,y]
	  end
    end
  end
  
  return
end

1 thread: 1.584128 seconds (12 allocations: 1.375 KiB)

12 threads: 0.348541 seconds (127 allocations: 13.531 KiB)

Мы достигли скорости 286M клеток в секунду

5. Cache-friendly code

А что если мы поменяем везде индексы x,y на y,x?

function step4(size, board, helper)
  Threads.@threads for x in 1:size
    left = ((x == 1) ? size : x-1)
    lower = size
    right = ((x == size) ? 1 : x+1)
    for y in 1:size
      upper = ((y == size) ? 1 : y+1)
      # get the number of neighbours
      @inbounds nb = board[upper, left] + board[upper, x] + board[upper, right] + 
                board[y, left] +                            board[y, right] + 
                board[lower, left] + board[lower, x]      + board[lower, right]				
      @inbounds cell = board[y, x]
      w = 0
      if (nb == 3) && (cell == 0) 
        w = 1 # new born
      elseif (nb < 2) && (cell == 1) 
        w = 1 # die lonely
      elseif (nb > 3) && (cell == 1) 
        w = 1 # die from owercrowding
      end
      @inbounds helper[y, x] = w
      lower = y
    end
  end
  
  # now move modifications back
  Threads.@threads for x in 1:size
    for y in 1:size
	  @inbounds if helper[y,x] > 0 
	     @inbounds board[y,x] = 1 - board[y,x]
	  end
    end
  end
  
  return
end

1 threads: 0.842495 seconds (12 allocations: 1.375 KiB)

12 threads: 0.113791 seconds (125 allocations: 13.469 KiB)

Мы достигли скорости 879M клеток в секунду

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

6. Убираем первый if

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

	  if helper[y,x] > 0 
	     board[y,x] = 1 - board[y,x]
	  end

ужасен. у этого IF нет 'правильной' стороны. но как вообще мне пришла в голову идея в helper записывать признак, что клетка поменялась? Просто я DBA и подсознательно оптимизировал объем UPDATE. А этого здесь делать не надо. В helper будем просто записывать новое состояние:

function step5(size, board, helper)
  Threads.@threads for x in 1:size
    left = ((x == 1) ? size : x-1)
    lower = size
    right = ((x == size) ? 1 : x+1)
    for y in 1:size
      upper = ((y == size) ? 1 : y+1)
      # get the number of neighbours
      @inbounds nb = board[upper, left] + board[upper, x] + board[upper, right] + 
                board[y, left] +                            board[y, right] + 
                board[lower, left] + board[lower, x]      + board[lower, right]				
      @inbounds cell = board[y, x]
      w = cell
      if (nb == 3) && (cell == 0) 
        w = 1 # new born
      elseif (nb < 2) && (cell == 1) 
        w = 0 # die lonely
      elseif (nb > 3) && (cell == 1) 
        w = 0 # die from owercrowding
      end
      @inbounds helper[y, x] = w
      lower = y
    end
  end
  
  # now move modifications back
  Threads.@threads for x in 1:size
    for y in 1:size
      @inbounds board[y,x] = helper[y,x]
    end
  end
  
  return
end

1 threads: 0.725642 seconds (12 allocations: 1.375 KiB)

12 threads: 0.089742 seconds (148 allocations: 16.094 KiB)

Мы достигли скорости 1.114B (миллиардов) клеток в секунду. Рубеж миллиарда пройден!

7. Убираем главный IF

Раз это так помогло, то давайте избавимся от главного IF, заменив его lookup - таблицей. Заодно программа станем много короче:

function step6(size, board, helper)
  #                                no cell  born           | live   2  3   4
  lookup = zeros(Int8, 18).= [0, 0, 0, 1, 0, 0, 0, 0, 0,      0, 0, 1, 1, 0, 0, 0, 0, 0]
  Threads.@threads for x in 1:size
    left = ((x == 1) ? size : x-1)
    lower = size
    right = ((x == size) ? 1 : x+1)
    for y in 1:size
      upper = ((y == size) ? 1 : y+1)
      # get the number of neighbours
      @inbounds nb = board[upper, left] + board[upper, x] + board[upper, right] + 
                board[y, left] +                            board[y, right] + 
                board[lower, left] + board[lower, x]      + board[lower, right]				
      @inbounds cell = board[y, x]
	  @inbounds helper[y, x] = lookup[ cell*9 + nb+1]
      lower = y
    end
  end
  
  # now move modifications back
  Threads.@threads for x in 1:size
    for y in 1:size
      @inbounds board[y,x] = helper[y,x]
    end
  end
  
  return
end

1 threads: 0.256231 seconds (14 allocations: 1.656 KiB)

12 threads: 0.053782 seconds (151 allocations: 16.406 KiB)

Мы достигли скорости 1.859B (миллиардов) клеток в секунду

8. Избавляемся от последних IF

У нас остались IF в виде ? выражений для проверки границ тора. В принципе, эти условия можно переписать как арифметические операции с модулями, но увы, это лишь замедляет программу. Поэтому надо либо считать, что границы засеены нулями и считать все, кроме границы, либо написать специальный код для расчета границ.

Для краткости кода я остановился на первом решении:

function step7(size, board, helper)
  #                                no cell  born           | live   2  3   4
  lookup = zeros(Int8, 18).= [0, 0, 0, 1, 0, 0, 0, 0, 0,      0, 0, 1, 1, 0, 0, 0, 0, 0]
  sizem1 = size - 1
  Threads.@threads for x in 2:sizem1
    left = x-1
    right = x+1
    for y in 2:sizem1
      upper = y+1
      lower = y-1
      # get the number of neighbours
      @inbounds nb = board[upper, left] + board[upper, x] + board[upper, right] + 
                board[y, left] +                            board[y, right] + 
                board[lower, left] + board[lower, x]      + board[lower, right]				
      @inbounds cell = board[y, x]
	  @inbounds helper[y, x] = lookup[ cell*9 + nb+1]
    end
  end
  
  # now move modifications back
  Threads.@threads for x in 1:size
    for y in 1:size
      @inbounds board[y,x] = helper[y,x]
    end
  end
  
  return
end

1 threads: 0.164568 seconds (14 allocations: 1.656 KiB)

12 threads: 0.036595 seconds (151 allocations: 16.406 KiB)

Мы достигли скорости 2.732B (миллиардов) клеток в секунду

9. Векторные операции

Импортируем пакет LoopVectorization, в котором макро@turboпереписывает код цикла так, чтобы использовались векторные операции. При этом важно:

  • В цикле не должно быть никаких if (в том числе ?)

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

  • Возможны любые сюрпризы

  • Так как любые IF запрещены, то@inboundsподразумевается всегда

function step8(size, board, helper)
  lookup = zeros(Int8, 18).= [0, 0, 0, 1, 0, 0, 0, 0, 0,      0, 0, 1, 1, 0, 0, 0, 0, 0]
  sizem1 = size - 1
  Threads.@threads for x in 2:sizem1
    left = x-1
    right = x+1
    @turbo for y in 2:sizem1
      upper = y+1
      lower = y-1
      nb = board[upper, left] + board[upper, x] + board[upper, right] + 
                board[y, left] +                            board[y, right] + 
                board[lower, left] + board[lower, x]      + board[lower, right]				
      cell = board[y, x]
	  helper[y, x] = lookup[ cell*9 + nb+1]
    end
  end
  
  Threads.@threads for x in 1:size
    @turbo for y in 1:size
      board[y,x] = helper[y,x]
    end
  end
end

1 threads: 0.069448 seconds (14 allocations: 1.656 KiB)

12 threads: 0.021376 seconds (154 allocations: 16.500 KiB)

Но увы! Результат оказывается неправильным. Все таки первое выражение оказывается слишком сложным для векторизации. Второе@turboработает корректно, но не дает прироста производительности. Однако сама идея 'переписывающих макросов' замечательная!

10. Уменьшение чтений

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

function step9(size, board, helper)
  lookup = zeros(Int8, 18).= [0, 0, 0, 1, 0, 0, 0, 0, 0,   0, 0, 1, 1, 0, 0, 0, 0, 0]
  sizem1 = size - 1
  Threads.@threads for x in 2:sizem1
    left = x-1
    right = x+1
	cell_left_lower = cell_lower = cell_right_upper = 0
	@inbounds cell_left = board[2, left]
	@inbounds cell_right = board[2, right]
	cell = board[2, x]
    @inbounds for y in 2:sizem1
      upper = y+1
      lower = y-1
	  cell_left_upper = board[upper, left]
	  cell_right_upper = board[upper, right]
	  cell_upper = board[upper, x]
      nb = cell_left_upper + cell_upper + cell_right_upper + 
             cell_left +           cell_right + 
             cell_left_lower + cell_lower + cell_right_upper				
	  helper[y, x] = lookup[ cell*9 + nb+1]
	  # shift
	  cell_left_lower = cell_left
	  cell_lower = cell
	  cell_right_lower = cell_right
	  cell_left = cell_left_upper
	  cell = cell_upper
	  cell_right = cell_right_upper
    end
  end
  
  Threads.@threads for x in 1:size
    for y in 1:size
      @inbounds board[y,x] = helper[y,x]
    end
  end
end

Как ни странно. стало хуже:

1 threads: 0.188678 seconds (14 allocations: 1.656 KiB)

12 threads: 0.040889 seconds (152 allocations: 16.438 KiB)

А у вас есть идеи, почему стало хуже?

11. Храним 8 клеток в байте

Если мы хотим обработать сразу 8 бит, то итоговое состояние зависит от : данных 8 клеток, 8 клеток над и под ними (итого 24), и дополнительных границ слева и справа по три клетки, что дает 30 бит и lookup таблицу в 1Gb - не так много по современным меркам, но потребуется битовая магия:

global size = 10000 # x
global size8 = trunc(Int, size/8) # y
global board=zeros(UInt8, size8, size) 
global helper=zeros(UInt8, size8, size)
global lookup=zeros(UInt8, 256*256*256*64)

unction step10(size8, size, board, helper, lookup)
  sizem1 = size - 1
  size8m1 = size8 - 1
  Threads.@threads for x in 2:sizem1
    left = x-1
    right = x+1
	cell_left_lower = cell_lower = cell_right_upper = cell_right_lower = 0
	@inbounds cell_left = board[2, left]
	@inbounds cell_right = board[2, right]
	cell = board[2, x]
    @inbounds for y in 2:size8m1
      upper = y+1
      lower = y-1
	  cell_left_upper = board[upper, left]
	  cell_right_upper = board[upper, right]
	  cell_upper = board[upper, x]

	  helper[y, x] = lookup[cell | (cell_upper<<8) | (cell_lower<<16) +
	    ((cell_left_upper&1)<<24) | 
        ((cell_left&1)<<25) |
        ((cell_left_lower&1)<<26) |      # lower bit, &1
	    ((cell_right_upper&0x80)<<20) | ((cell_right&0x80)<<21) 
        | ((cell_right_lower&0x80)<<22) # high bit, &127
		]
	  # shift
	  cell_left_lower = cell_left
	  cell_lower = cell
	  cell_right_lower = cell_right
	  cell_left = cell_left_upper
	  cell = cell_upper
	  cell_right = cell_right_upper
    end
  end
  
  Threads.@threads for x in 1:size
    for y in 1:size8
      @inbounds board[y,x] = helper[y,x]
    end
  end
end

1 threads: 0.015258 seconds (12 allocations: 1.375 KiB)

12 threads: 0.003306 seconds (149 allocations: 16.125 KiB)

30.248B клеток в секунду. Вау.

12. 32 бита сразу.

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

      helper[y,x] = 	  
  	    lookup[(cell&0xFF) | ((cell_upper&0xFF)<<8) | ((cell_lower&0xFF)<<16) +
	      ((cell_left_upper&1)<<24) | ((cell_left&1)<<25) | ((cell_left_lower&1)<<26) |  
	      ((cell_upper&0x100)<<19) | ((cell&0x100)<<20) | ((cell_lower&0x100)<<21) 
	   	  ] |
  	    (lookup[(cell&0xFF00>>8) | ((cell_upper&0xFF00)) | ((cell_lower&0xFF00)<<8) +
	      ((cell_upper&0x80)<<17) | ((cell_left&0x80)<<18) | ((cell_lower&0x80)<<19) |  
	      ((cell_upper&0x10000)<<12) | ((cell&0x10000)<<13) | ((cell_lower&0x10000)<<14) 
	   	  ] >> 8) |
  	    (lookup[(cell&0xFF0000>>16) | ((cell_upper&0xFF0000)>>8) | ((cell_lower&0xFF0000)) +
	      ((cell_upper&0x8000)<<9) | ((cell_left&0x8000)<<10) | ((cell_lower&0x8000)<<11) |  
	      ((cell_upper&0x1000000)<<4) | ((cell&0x1000000)<<5) | ((cell_lower&0x1000000)<<6) 
	   	  ] >> 16) |
  	    (lookup[(cell&0xFF000000>>24) | ((cell_upper&0xFF000000)>>16) | ((cell_lower&0xFF000000)>>8) +
	      ((cell_upper&0x800000)<<1) | ((cell_left&0x800000)<<2) | ((cell_lower&0x800000)<<3) |  
	      ((cell_right_upper&0x80000000)>>4) | ((cell_right&80000000)>>3) | ((cell_right_lower&80000000)>>2) 
	   	  ] >> 24) 

Но это примерно в 10 раз медленнее....Слищком много битовых операций для упаковки/распаковки...

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

@Tiriet
обработка клеток требует последовательного чтения данных из памяти- на каждом шаге клетка считывается из памяти один раз и записывается один раз. массив хелпер можно рассматривать как промежуточный слой, и на первом шаге "cell" считаем основным, а "helper"- вспомогательным, а на втором шаге- меняем их местами, тогда нам не нужно будет на каждом прогоне перемещать данные из хелпера в целл.

Это дает:

1 threads: 0.014437 seconds (6 allocations: 704 bytes)

12 threads: 0.002506 seconds (78 allocations: 8.172 KiB)

39.99B клеток в секунду.

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


  1. eandr_67
    25.12.2022 19:24
    +2

    На каждом шаге достаточно анализировать только клетки, изменившее своё состояние на предыдущем шаге, и соседние с ними 8 клеток. Это многократно меньше клеток всго игрового поля.

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

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

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


    1. Tzimie Автор
      25.12.2022 19:31
      +3

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


      1. JKot
        26.12.2022 00:25

        В таком случае хороший прирост производительности можно получить из texture swizzle pattern для улучшения кэш когерентности соседних ячеек.


      1. Tiriet
        26.12.2022 07:49
        +1

        повторюсь, мне кажется, что узкое место- гигабайтная lookup-таблица, к ней постоянно идет обращение, и она не влезает в кэш, поэтому идут частые промахи кеша и тормоза на случайное чтение из ОЗУ, а не на обработке битовых операций в ядрах CPU.


        1. Tzimie Автор
          26.12.2022 11:13
          +1

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


          1. Tiriet
            26.12.2022 12:35

            аргумент. значит, надо бенч пускать не просто "абыкак", а на поле, заполненным каким-то псевдослучайным шумом (условно, 10% живых клеток, например) и на поле, заполненном пачкой разнонаправленных глайдеров и других "живых тварей". и сравнивать производительности в этих случаях. :-). и мы плавно приходим выводу, что "а че мы вообще меряем-то?".


    1. Tiriet
      26.12.2022 07:44
      +1

      обработка клеток требует последовательного чтения данных из памяти- на каждом шаге клетка считывается из памяти один раз и записывается один раз. массив хелпер можно рассматривать как промежуточный слой, и на первом шаге "cell" считаем основным, а "helper"- вспомогательным, а на втором шаге- меняем их местами, тогда нам не нужно будет на каждом прогоне перемещать данные из хелпера в целл. А вот обращения к lookup-таблице происходят рандомно при обработке каждой пачки клеток. Из этого я делаю вывод, что производительность представленного кода упирается не в битовые операции не в математику вообще, а в долгий доступ к памяти при обработке lookup-таблицы. Ускорение можно получить, если размер lookup-таблицы будет меньше 32МБ- тогда она будет влезать целиком в кэш авторского процессора, обращение к ней будет быстрым, и скорость обработки будет близка к теоретическому пределу шины памяти- порядка 50ГБ/с, что при 8клеток на байт и двух операциях памяти (чтение и запись) за ход должны давать порядка 200В клеток в секунду.

      высокая скорость обработки, полученная автором, КМК, объясняется тем, что основное количество клеток поля пустые (забиты нулем), и вычисленный по ним адрес в лукап-таблице- тоже нулевой, поэтому обращение идет чаще всего к одному и тому же участку lookup-таблицы. Он постоянно лежит в кэше процессора (возможно даже- не вылезает из L1) и быстро отвечает, а когда попадаются живые клетки- то приходится прыгать по всему гигабайту этой таблицы и вываливаться за пределы кэша- в L2, L3 или вообще в ОЗУ, что очень медленно.

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


      1. Tzimie Автор
        26.12.2022 11:15

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


        1. Tiriet
          26.12.2022 12:50
          +1

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


          1. Tzimie Автор
            26.12.2022 13:00

            Да, безусловно


      1. Tzimie Автор
        27.12.2022 19:59

        Добавил последнюю главку по чередованию шагов.


  1. celen
    25.12.2022 20:33
    +3

    Спасибо, очень интересно!

    В step9 происходит 3 чтения из массива на каждом шаге, но зато есть 6 чтений-переписываний переменных "кэша". А поскольку чтение из массива по индексу это O(1), то, видимо, шило поменяли на мыло.

    Всё это время над задачей трудился ваш AMD Ryzen 5 3600X 6-Core Processor 3.79 GHz. А как можно было бы запустить этот ваш код на видеокарте?

    Ну, и можно вспомнить про ультимативный https://en.wikipedia.org/wiki/Hashlife , опирающий на свойство игры после первых нескольких сотен шагов локально-упрощаться почти всюду до комбинаций осцилляторов и неподвижных конфигураций.


    1. Tzimie Автор
      25.12.2022 20:42
      +1

      В Julia есть макросы и под GPU, но там все не так просто как я понимаю


  1. erley
    25.12.2022 20:48
    +5

    Чёрт побери, обожаю такие вещи!
    С одной стороны вроде язык высокого уровня, но можно вполне себе управлять обработкой на низком уровне (=> нет искусственных запретов спускаться на нижние уровни абстракции).
    Задачка как я понимаю автора зацепила, целый цикл статей получился :-)
    Для продолжения темы оптимизации - тут уже выше спрашивают как это всё можно замутить на GPU, ведь с такими размерностями этот подход прям напрашивается.
    Очень интересно как Julia работает в такой среде, буду ждать продолжения!


  1. Deosis
    26.12.2022 11:28
    +1

    Я тоже пробовал различные варианты оптимизации Жизни:

    1. Хранение и обработка блоков 4 на 4.

      Таблица поиска занимает всего 64 кб и содержит значение 4 центральных клеток.

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

    2. Разделение на четные и нечетные шаги.

      При этом на нечетном шаге все поле сдвинуто по диагонали.

    3. Флаги изменений.

      На поле меняется не очень много ячеек, и проверка флагов выгоднее чем честное вычисление нового состояния.

    4. Рекурсивное хранение флагов изменений.

      Можно пропустить большие участки стабилизировавшегося поля.

    Последний результат: желудь на поле 512 на 512, 1200 шагов расчитываются за 1,5 мс.

    Вариант на SIMD без этих оптимизаций в 10 раз медленнее.


  1. lea
    26.12.2022 11:56
    +1

    А если через FFT сделать? По сути расчет кол-ва "живых" соседей есть свёртка игрового поля с простым ядром.


    1. celen
      26.12.2022 12:19
      +3

      Кстати, уже есть на хабре https://habr.com/ru/post/180135/


  1. siyet
    27.12.2022 16:57
    +1

    Интересно, что можно выжать из Python в рамках этой же задачи.

    Надо будет попробовать заюзать Cython + Pandas и посмотреть во что выльется по скорости.


  1. Deosis
    28.12.2022 13:57
    +1

    nb = cell_left_upper + cell_upper + cell_right_upper + cell_left + cell_right + cell_left_lower + cell_lower + cell_right_upper

    При проверке производительности различных оптимизаций нужно ещё проверять их корректность. В какой-то момент правая верхняя ячейка стала учитываться 2 раза.


    1. Tzimie Автор
      28.12.2022 14:59

      Да, я ошибку нашел потом, а в статье не исправил. Вам сюда за внимательность) оставлю ошибку в статье, пусть потренирует внимательность)