# теория · шаг 3 из 6
Профиль хода: кто съедает бюджет и как он растёт
Коротко
Собранный ход надо измерить по модулям - иначе оптимизируют то, что первым попалось на глаза. На карте 40
× 40 со 140 отрядами перебор пар в модуле обзора - 9730 проверок, 60% хода; равномерная сетка сокращает
его до 2762. Но одна строка профиля не говорит, что будет при росте игры. Тест удвоения из раздела 25
отвечает: удвой размер и посмотри, во сколько раз выросла работа, - показатель степени равен логарифму
отношения. Перебор растёт с p ≈ 2, и сетка на фиксированной карте - тоже: плотность растёт вместе с
отрядами. Только если карта растёт вместе с отрядами, сетка даёт p ≈ 1. Сетка выигрывает константу, а
степень зависит от данных.
Если ты профилируешь ход по модулям и оцениваешь степень роста тестом удвоения, листай до «Теперь сам».
Как думаешь, что ускорять
Ход сервера не укладывается в бюджет. В нём два модуля: поле расстояний для пути и поиск «кто рядом» для обзора. Поле - обход всей карты, обзор - все пары отрядов. Что ускорять первым?
Ответь себе до чтения. Интуиция говорит «обход карты - он большой». Но обход карты 40 × 40 стоит 6400
проверок соседей, а пары 140 отрядов - 140 · 139 / 2 = 9730. Тот, кто растёт квадратично, обгоняет того,
кто растёт линейно, уже при сотне объектов. Ответ даёт не интуиция, а счётчик по модулям - профиль.
На что это похоже
Семейный бюджет. «Мы много тратим на продукты» - ощущение. Выписка по категориям за месяц показывает, что половина денег ушла на подписки, о которых все забыли. А сравнение двух месяцев показывает, какая категория растёт быстрее остальных, - её и нужно трогать первой, даже если сейчас она не самая большая.
Механизм: профиль и тест удвоения
Профиль - работа каждого модуля за ход: счётчик операций или время. Для сервера на Python это счётчики, встроенные в код, или профилировщик, который сам считает время по функциям. Профиль отвечает на вопрос «где сейчас уходит бюджет», и оптимизировать имеет смысл только модуль с большой долей: ускорение модуля, который занимает 5% хода, даже в бесконечность сэкономит 5%.
Профиль одного размера ничего не говорит о росте. Тест удвоения из раздела 25: замерь работу при
размере n и 2n. Если работа растёт как n^p, отношение - 2^p, и p = log₂(отношения). Три размера
подряд дают две оценки p - если они близки, степень найдена. Это замер, а не доказательство: он видит
рост на тех данных, на которых его делали.
Степень зависит не только от алгоритма, но и от того, КАК растут данные. Сетка проверяет пары внутри соседних корзин; в корзине в среднем столько отрядов, какова плотность. Если карта фиксирована, а отрядов вдвое больше, плотность вдвое выше, и сетка растёт квадратично - как перебор, только с меньшей константой. Если карта растёт вместе с отрядами, плотность постоянна, и сетка растёт линейно.
Правило
Оптимизируй модуль, который профиль называет самым дорогим, и сначала тот, что растёт быстрее всех: тест
удвоения даёт степень роста p = log₂(отношения). Степень зависит от того, как растут данные, - замеряй
рост так, как он будет расти в игре.
Разбор: профиль хода по модулям
Ход: поле расстояний от одной цели и обзор «кто рядом» - перебором и сеткой. Счётчик у каждого модуля свой.
import random
N = 40 # карта N × N
rabota = {}
def schet(modul, k):
rabota[modul] = rabota.get(modul, 0) + k
def pole(cel):
# модуль пути: поле расстояний от цели, обход в ширину
dist = [-1] * (N * N)
dist[cel] = 0
och = [cel]
golova = 0
while golova < len(och):
v = och[golova]
golova = golova + 1
for u in [v - N, v + N, v - 1, v + 1]:
schet("путь", 1)
if 0 <= u < N * N and (u // N == v // N or u % N == v % N):
if dist[u] == -1:
dist[u] = dist[v] + 1
och.append(u)
return dist
def ryadom_pary(otryady):
# модуль обзора: все пары ближе 4 клеток - перебором
par = 0
for i in range(len(otryady)):
for j in range(i + 1, len(otryady)):
schet("обзор", 1)
a = otryady[i]
b = otryady[j]
if max(abs(a // N - b // N), abs(a % N - b % N)) <= 4:
par = par + 1
return par
def ryadom_setka(otryady):
# тот же модуль на равномерной сетке 8 × 8
kor = {}
for i in range(len(otryady)):
k = (otryady[i] // N) // 8 * 100 + (otryady[i] % N) // 8
if k not in kor:
kor[k] = []
kor[k].append(i)
par = 0
for i in range(len(otryady)):
r = (otryady[i] // N) // 8
c = (otryady[i] % N) // 8
for dr in [-1, 0, 1]:
for dc in [-1, 0, 1]:
for j in kor.get((r + dr) * 100 + c + dc, []):
if j > i:
schet("обзор", 1)
a = otryady[i]
b = otryady[j]
if max(abs(a // N - b // N),
abs(a % N - b % N)) <= 4:
par = par + 1
return par
def hod(n_otryadov, setka):
random.seed(n_otryadov)
otryady = []
for i in range(n_otryadov):
# клетки отрядов могут совпасть - для замера цены это неважно
otryady.append(random.randint(0, N * N - 1))
pole(N * N // 2) # одна общая цель - одно поле
if setka:
ryadom_setka(otryady)
else:
ryadom_pary(otryady)
for sposob in [["обзор перебором:", False], ["обзор сеткой: ", True]]:
rabota["путь"] = 0
rabota["обзор"] = 0
hod(140, sposob[1])
vsego = rabota["путь"] + rabota["обзор"]
print(sposob[0], "путь", rabota["путь"], "| обзор", rabota["обзор"],
"| доля обзора", round(100 * rabota["обзор"] / vsego), "%")
обзор перебором: путь 6400 | обзор 9730 | доля обзора 60 %
обзор сеткой: путь 6400 | обзор 2762 | доля обзора 30 %
Профиль назвал обзор: 60% хода, хотя обход карты казался тяжелее. Сетка сократила обзор в три с половиной раза, и его доля упала до 30%. Теперь дороже путь - и следующей оптимизацией будет он, если она вообще понадобится. Граница правила: счётчик операций - модель цены, а не время. Операции разных модулей стоят по-разному: проверка соседа в обходе дешевле, чем пара с вычислением расстояния. Для решения «что ускорять» этой модели обычно хватает, для обещания «уложимся в 100 мс» нужен замер времени.
Разбор: тест удвоения в трёх мирах
Работа обзора на 60, 120 и 240 отрядах: перебор на карте 64 × 64, сетка на той же карте и сетка на карте, которая растёт вместе с отрядами, чтобы плотность не менялась.
import math
import random
def ryadom_pary(otryady, N):
# работа перебора - число проверенных пар; само сравнение
# расстояний здесь опущено, нам нужна только цена
proverok = 0
for i in range(len(otryady)):
for j in range(i + 1, len(otryady)):
proverok = proverok + 1
return proverok
def ryadom_setka(otryady, N):
# равномерная сетка 8 × 8: пары только из своей и соседних корзин;
# тоже считаем одни проверки
kor = {}
for i in range(len(otryady)):
k = (otryady[i] // N) // 8 * 1000 + (otryady[i] % N) // 8
if k not in kor:
kor[k] = []
kor[k].append(i)
proverok = 0
for i in range(len(otryady)):
r = (otryady[i] // N) // 8
c = (otryady[i] % N) // 8
for dr in [-1, 0, 1]:
for dc in [-1, 0, 1]:
for j in kor.get((r + dr) * 1000 + c + dc, []):
if j > i:
proverok = proverok + 1
return proverok
def zamer(n, N, setka):
random.seed(n)
otryady = []
for i in range(n):
# клетки отрядов могут совпасть - для замера цены это неважно
otryady.append(random.randint(0, N * N - 1))
if setka:
return ryadom_setka(otryady, N)
return ryadom_pary(otryady, N)
# 60, 120, 240 отрядов. Тест удвоения: если работа ~ n^p, при
# удвоении n она растёт в 2^p раз, значит, p = log2(отношения)
sposoby = [["перебор, карта 64: ", False, False],
["сетка, карта 64: ", True, False],
["сетка, карта растёт:", True, True]]
for s in sposoby:
prosh = 0
stroka = s[0]
for n in [60, 120, 240]:
N = 64
if s[2]:
N = round(64 * math.sqrt(n / 60)) # плотность постоянна
r = zamer(n, N, s[1])
stroka = stroka + " " + str(r)
if prosh > 0:
p = round(math.log2(r / prosh), 1)
stroka = stroka + " (p≈" + str(p) + ")"
prosh = r
print(stroka)
перебор, карта 64: 1770 7140 (p≈2.0) 28680 (p≈2.0)
сетка, карта 64: 216 925 (p≈2.1) 3497 (p≈1.9)
сетка, карта растёт: 216 452 (p≈1.1) 970 (p≈1.1)
Перебор растёт квадратично, p = 2,0 - по формуле n(n − 1)/2. Сетка на фиксированной карте - тоже
квадратично, p ≈ 2: проверок в восемь раз меньше, но при удвоении отрядов их становится вчетверо больше.
Только сетка на растущей карте растёт линейно, p ≈ 1,1. Это граница правила из урока о структурах,
увиденная числами: гарантия сетки держится на плотности. Для игры вывод прямой - если отрядов становится
больше, а карта та же, сетка отодвинет проблему в восемь раз, но не снимет; если с ростом игроков растёт
и карта, сетка решает задачу.
Что ломается без этого
Оптимизация по ощущению. Неделю ускоряли обход карты, а ход тормозил из-за пар в обзоре.
Замер одного размера. На тестовом сервере обзор - 10% хода. Через месяц игроков втрое больше, и квадратичный модуль - 70%.
Сетка «решила проблему». На запуске сетка дала восьмикратный выигрыш; через полгода отрядов на той же карте стало в четыре раза больше - и ход снова не укладывается, хотя «структура уже правильная».
Зачем это в работе
Профилирование по компонентам и тест удвоения - основа любой работы с производительностью: запросов к базе, обработки очередей, ответа веб-сервиса. Профиль говорит, где горит сейчас; удвоение - где загорится при росте; а вопрос «как именно будут расти данные» превращает оба замера в прогноз.
Теперь сам
- При 100 отрядах модуль стоит 2000 операций, при 200 - 8100. Какова степень роста? Сколько будет при 400?
- Модуль занимает 10% хода. Его ускорили в 10 раз. Насколько быстрее стал ход?
- Почему сетка на фиксированной карте растёт квадратично, если внутри неё нет перебора всех пар?
Ответ. 1. Отношение 4,05, p = log₂ 4,05 ≈ 2. При 400 - около 4 · 8100 ≈ 32 400.
-
Ход был
0,9 + 0,1 = 1, стал0,9 + 0,01 = 0,91- время хода на 9% меньше (скорость выше на1 / 0,91 − 1 ≈ 10%). -
Внутри корзины она перебирает пары, а отрядов в корзине при фиксированной карте пропорционально
n. Пар на корзину -n², деленное на квадрат числа корзин, а число корзин постоянно: всего снова порядкаn².
Главное
Профиль говорит, какой модуль дорог сейчас, тест удвоения - какой станет дорогим при росте. И степень роста зависит от того, как растут данные, а не только от алгоритма.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий