Как стать автором
Обновить

История о том, что не надо делать во время разработки

Время на прочтение6 мин
Количество просмотров10K
Пролог: Для начала я расскажу о проекте, чтобы были представления о том как мы работали над проектом и для воссоздания той боли, которую мы чувствовали.

Я как разработчик вступил в проект в 2015-2016 точно не помню, но он работал 2-3 года ранее. Проект был очень популярен в своей сфере, а именно игровых серверов. Как странно не звучало, но проекты по игровым серверам ведутся и по сей день, недавно вакансии видел и чуток поработал в одной команде. Поскольку игровые сервера строятся на уже созданной игре, следовательно для разработки используется скриптовый язык который встроен в движок игры.

Мы разрабатываем почти с нуля проект на Garry’s Mod (Gmod), важно подметить, что на момент написания статьи Гарри создает уже новый проект S&Box на движке Unreal Engine. Мы же до сих пор сидим на Source.
Который вообще не подходит для нашей тематики сервера.
image

«Чем страшна ваша история?» — спросите вы.

У нас сильная тематика игрового сервера, а именно «Сталкер» и еще с элементами ролевых игр (РП), сразу встает вопрос — «А как все реализовать это на одном сервере?».

Учитывая что Source движок старый (2013 версии используется в Gmod еще и 32 битный), большие карты не сделаешь, малые ограничения на количество Entity, Mesh и много чего.
Кто работал на движке, поймет.
Получается, задача вообще невыполнима, сделать чистый мультиплеерный сталкер с квестами, RPG-элементами из самого оригинала и желательно малый сюжет.

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

image

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

Вроде мощный сервер мог спокойно обрабатывать запросы и держать весь Gamemode.

Простое описание gamemode
Так называется комплекс скриптов написанный для описания механики самого сервера
Например: хотим тематику нынче популярных «Королевских битв», значит и название соответствовать должно и механика игры тоже. «Спавн игроков в самолете, можно подбирать вещи, игроки могут общаться, нельзя надевать на себя больше 1 шлема и т.д.» — все это описывается механикой игры на сервере.

Лаги были как на серверной стороне из-за большого количества игроков, так как один игрок съедает много оперативной памяти порядка 80-120 мб (не считая еще предметов в инвентаре, навыков и т.д.), так и на клиентской стороне было сильное понижение фпс.

Мощности ЦП не хватало на обработку физики, приходилось меньше использовать объекты имеющие физические свойства.

Так еще вдобавок были наши самописные скрипты, которые вообще никак не были оптимизированы.

image

Во первых мы конечно же почитали статьи по оптимизации в Lua. Даже доходило до суицидатого что хотели писать DLL на C++, но проблема возникла в скачивании DLL с сервера клиентами. С помощью C++ для DLL можно написать спокойно перехватывающий данные программу, разработчики Gmod добавили расширение в исключения для скачивания клиентами (безопасность, хотя на самом деле никогда ее и не было). Хотя было бы удобно и Gmod стал бы гибче, но опаснее.

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

Если вы пытались писать в Gmod, то вы прекрасно знаете что есть библиотека встроенная под названием math.

И самые медленные функции в ней конечно же math.Clamp и math.Round.

Покопавшись в коде людей было замечено что функциями кидались в разные стороны, почти везде она применяется, но неправильно!

Давайте уже к практике. К примеру мы хотим округлить координаты вектора позиции для перемещения энтити (к примеру игрока).

local x = 12.5
local y = 14.9122133
local z = 12.111
LocalPlayer():SetPos( Vector( Math.Round(x), Math.Round(y), Math.Round(z) )

3 сложных функции округления, но ничего серьезного, если конечно не в цикле и не часто используемый, а вот Clamp еще тяжелее.

Следущий код часто используется в проектах и никто ничего не хочет менять.

self:setLocalVar("hunger", math.Clamp(current + 1, 0, 100))

К примеру self указывает на объект игрока и у него есть придуманная нами локальная переменная, которая при перезаходе на сервер обнуляется, math.Clamp по сути как цикл, делает плавное присвоение, любят плавный интерфейс делать на Clamp.

Проблемы возникают когда это работает на каждом игроке, который заходит на сервер. Редкий случай, но если на сервер зайдет сразу 5-15 (зависит от конфигурации сервера) в один момент времени и у всех начнет работать эта маленькая и простая функция, то на сервере будут хорошие задержки ЦП. Все еще хуже если math.Clamp в цикле.

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

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

local value = math.Clamp(current + 1, 0, 100)
self:setLocalVar("hunger", value)

Все хорошо, стали смотреть дальше, что да как устроено. В итоге мы завелись манией все оптимизировать.

Мы заметили что стандартный цикл for медленный и мы решили придумать свой велосипед который будет быстрее (про блэкджек не забыли) и тут началась самая дичь.

image

СПОЙЛЕР
У нас даже получилось сделать быстрый самый цикл на Lua Gmod, но при условии что элементов должно быть больше 100.

Судя по затратам времени на наш цикл и использование его в коде, мы зря пытались это делать поскольку он нашел применение только в спавне на карте аномалий после выброса и очистки их.
И так к коду. К примеру надо найти все энтити с названием в начале anom, такое имя класса имеют у нас аномалии.

Вот for нормального скриптера на Lua Gmod:

local anomtable = ents.FindByClass("anom_*")
for k, v in pairs(anomtable) do
v:Remove()
end

Вот for курильщика:

Сразу видно что такой г*внокод будет медленнее стандартного «for in pairs», но как оказалось нет.

local b, key = ents.FindByClass("anom_*"), nil
repeat
	key = next(b, key)
	b[key]:Remove()
until key != nil


Для полного анализа этих вариантов циклов их надо перевести в обычный Lua скрипт.
К примеру anomtable будет иметь 5 элементов.
Удаление заменим обычным сложением. Главное посмотреть разницу в количестве инструкций между двумя вариантами реализации цикла for.

Ванильный цикл:

local anomtable = { 1, 2, 3, 4, 5 }
for k, v in pairs(anomtable) do
v = v + 1
end

Наш велик:

local b, key = { 1, 2, 3, 4, 5 }, nil
repeat
	key = next(b, key)
	b[key] = b[key] + 1
until key ~= nil

Давайте же посмотрим на интерпретаторский код (подобие ассемблера, высокоуровневым программистом смотреть под спойлер не рекомендуется).

На всякий случай уберите джунов от экранов. Я предупреждал.

Дизассемблер ванильного цикла
; Name:	 for1.lua
; Defined at line: 0
; #Upvalues:       0
; #Parameters:     0
; Is_vararg:       2
; Max Stack Size:  7

  1 [-]: NEWTABLE  R0 5 0       ; R0 := {}
  2 [-]: LOADK     R1 K0        ; R1 := 1
  3 [-]: LOADK     R2 K1        ; R2 := 2
  4 [-]: LOADK     R3 K2        ; R3 := 3
  5 [-]: LOADK     R4 K3        ; R4 := 4
  6 [-]: LOADK     R5 K4        ; R5 := 5
  7 [-]: SETLIST   R0 5 1       ; R0[(1-1)*FPF+i] := R(0+i), 1 <= i <= 5
  8 [-]: GETGLOBAL R1 K5        ; R1 := pairs
  9 [-]: MOVE      R2 R0        ; R2 := R0
 10 [-]: CALL      R1 2 4       ; R1,R2,R3 := R1(R2)
 11 [-]: JMP       13           ; PC := 13
 12 [-]: ADD       R5 R5 K0     ; R5 := R5 + 1
 13 [-]: TFORLOOP  R1 2         ; R4,R5 :=  R1(R2,R3); if R4 ~= nil then begin PC = 12; R3 := R4 end
 14 [-]: JMP       12           ; PC := 12
 15 [-]: RETURN    R0 1         ; return


Дизассемблер велосипедного цикла
; Name:  for2.lua
; Defined at line: 0
; #Upvalues:       0
; #Parameters:     0
; Is_vararg:       2
; Max Stack Size:  6

  1 [-]: NEWTABLE  R0 5 0       ; R0 := {}
  2 [-]: LOADK     R1 K0        ; R1 := 1
  3 [-]: LOADK     R2 K1        ; R2 := 2
  4 [-]: LOADK     R3 K2        ; R3 := 3
  5 [-]: LOADK     R4 K3        ; R4 := 4
  6 [-]: LOADK     R5 K4        ; R5 := 5
  7 [-]: SETLIST   R0 5 1       ; R0[(1-1)*FPF+i] := R(0+i), 1 <= i <= 5
  8 [-]: LOADNIL   R1 R1        ; R1 := nil
  9 [-]: GETGLOBAL R2 K5        ; R2 := next
 10 [-]: MOVE      R3 R0        ; R3 := R0
 11 [-]: MOVE      R4 R1        ; R4 := R1
 12 [-]: CALL      R2 3 2       ; R2 := R2(R3,R4)
 13 [-]: MOVE      R1 R2        ; R1 := R2
 14 [-]: GETTABLE  R2 R0 R1     ; R2 := R0[R1]
 15 [-]: ADD       R2 R2 K0     ; R2 := R2 + 1
 16 [-]: SETTABLE  R0 R1 R2     ; R0[R1] := R2
 17 [-]: EQ        1 R1 K6      ; if R1 == nil then PC := 9
 18 [-]: JMP       9            ; PC := 9
 19 [-]: RETURN    R0 1         ; return


Неопытный просто взглянув скажет что обычный цикл быстрее поскольку инструкций меньше (15 против 19).

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

Во втором варианте способ иначе, который больше основывается на память, он получает таблицу изменяет элемент, устанавливает таблицу, проверяет на наличие nil и опять вызывает.
Второй наш цикл быстрый по причине того что в одной инструкции слишком много условий и действий ( R4,R5 := R1(R2,R3); if R4 ~= nil then begin PC = 12; R3 := R4 end ) из-за этого она очень много жрет употребляет кушает тактов ЦП на выполнение, прошлый опять же больше завязан на памяти.

Инструкция forloop при большом количестве элементов сдается, нашему циклу по скорости прохода всех элементов. Связано это что обращение напрямую по адресу быстрее, меньше всяких плюшек от pairs. (А еще у нас нет отрицания)
Вообще по секрету, любое использование отрицания в коде замедляет его, это уже проверено тестами и временем. Отрицательная логика будет медленнее работать поскольку в ALU процессора есть отдельный вычислительный блок «инвертор», для работы унарного операнда (not, !) надо обращаться к инвертору и это займет дополнительное время.
Вывод: Все стандартное не всегда лучше, свои велосипеды могут принести пользу, но опять же на реальном проекте не стоит придумывать их, если вам важна скорость выхода в релиз. У нас в итоге полная разработка идет с 2014 и по сей день, этакий еще один «ждалкер». Хотя вроде обычный игровой сервер который ставится за 1 день и настраивается полностью под игру за 2 дня, но вносить что-то новое это надо уметь.

Этот долгострой-проект еще увидел вторую версию себя где оптимизации очень много в коде, но про другие оптимизации я расскажу в следующих статьях. Поддержите критикой или комментарием, поправьте если ошибаюсь.
Теги:
Хабы:
0
Комментарии9

Публикации

Истории

Работа

Ближайшие события