1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 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%.

Сетка «решила проблему». На запуске сетка дала восьмикратный выигрыш; через полгода отрядов на той же карте стало в четыре раза больше - и ход снова не укладывается, хотя «структура уже правильная».

Зачем это в работе

Профилирование по компонентам и тест удвоения - основа любой работы с производительностью: запросов к базе, обработки очередей, ответа веб-сервиса. Профиль говорит, где горит сейчас; удвоение - где загорится при росте; а вопрос «как именно будут расти данные» превращает оба замера в прогноз.

Теперь сам

  1. При 100 отрядах модуль стоит 2000 операций, при 200 - 8100. Какова степень роста? Сколько будет при 400?
  2. Модуль занимает 10% хода. Его ускорили в 10 раз. Насколько быстрее стал ход?
  3. Почему сетка на фиксированной карте растёт квадратично, если внутри неё нет перебора всех пар?

Ответ. 1. Отношение 4,05, p = log₂ 4,05 ≈ 2. При 400 - около 4 · 8100 ≈ 32 400.

  1. Ход был 0,9 + 0,1 = 1, стал 0,9 + 0,01 = 0,91 - время хода на 9% меньше (скорость выше на 1 / 0,91 − 1 ≈ 10%).

  2. Внутри корзины она перебирает пары, а отрядов в корзине при фиксированной карте пропорционально n. Пар на корзину - n², деленное на квадрат числа корзин, а число корзин постоянно: всего снова порядка n².

Главное

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

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

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