Вступление
Для чего эта статья?
За относительно долгое время, что я провёл работая с Jass, я как и другие пользователи получил уйму информации из уст в уста о тех или иных функциях, методах или же проблемах, которые присутствуют в языке Jass. Однако, практически большая часть из этой информации не имела ни подтверждения, ни малейшего примера, фактически всё было основано на "наблюдении" и якобы "тестах". Потому, когда я дополнил начальную наработку MemHack, и добавил возможность делать бенчмарки, а также разобрал структуры игры и заручился CheatEngine для разбора байткода Jass, мне стало интересно проверить эти мифы на деле.
Потому - эта статья будет служить как фактическим доказательством или опровержения того или иного мифа исходя из реальных данных, основанных на реальных тестах, которые можно будет воспроизвести и убедиться в их действительности. Конечно же эта статья уже опоздала в плане своей полезности, но это не отменяет того, что для всё ещё заинтересованных личностей она будет очень и очень полезной.
Но всё же.. зачем?
Смысл относительно прост, по большей части я это делаю, чтобы не повторять одни и те же ответы каждый раз, когда спрашивают по тем или иным "проблемам", "утечкам", "разницы в скорости" и прочему. Ну и иметь показательный материал тех или иных заявлений, дабы каждый кто будет в чём-либо сомневаться, мог смело глянуть эту статью, а если интересующий его вопрос не найден, то запросить его к рассмотрению.
Список Мифов
Локальные хендлы всегда утекают!
раскрыть
Ранее считалось, что любая локальная сложная переменная (всё, что является handle, то бишь юниты, способности и так далее) утекают и затрачивают 4 байта памяти вне зависимости обнулена она или нет. Однако - это оказалось лишь на половину правдой, ибо утечка ЕСТЬ, однако лишь в случае, если мы СОЗДАЁМ ОБЪЕКТ и присваиваем её в локальную переменную и в конце кода мы не обнуляем как раз эту переменную. То бишь мы можем передать референс этой локальной переменной в глобальную, что позволит нам обнулить локальную переменную и фактически убрать утечку связанную со "сложными" локальными переменными.
Пример:
Это ВЫЗОВЕТ утечку.
function DoSomething takes nothing returns nothing
local unit u = CreateUnit( Player( 0 ), 'hpea', 0., 0., 270. )
....
endfunction
Однако, если же вы вызовете RemoveUnit( u ), то это не будет являться утечкой, так как юнит был обработан и удалён.
Это НЕ ВЫЗОВЕТ утечку
function DoSomething takes nothing returns nothing
local unit u = CreateUnit( Player( 0 ), 'hpea', 0., 0., 270. )
....
set u = null
endfunction
Это НЕ ВЫЗОВЕТ утечку
globals
unit uTemp = null
endglobals
function main takes nothing returns nothing
set uTemp = CreateUnit( Player( 0 ), 'hpea', 0., 0., 270. )
endfunction
function DoSomething takes nothing returns nothing
local unit u = uTemp
....
endfunction
Чтобы понять, почему же так, стоит взглянуть на байткод Jass, взглянем на вариант без и с утечкой.
Для начала, предоставлю скриншот того, как же я получаю JASM байткод, я написал функцию получения Джасс ноды из игры по её имени, то бишь это кусок из хештаблицы, в ней же хранится ссылка на байткод, что она выполняет по оффсету 0x18.
теперь рассмотрим один из вариантов:
Мы получаем байткод:
байткод
05070000
00000F80
0E290700
00000F7D
11290000
00000F80
0C000000
00000000
27000000
00000F80
0E290700
00000F7D
11290000
00000F80
0C000000
00000000
27000000
Что выдаёт нам:
Результат
0x05 = local | 0x07 = type где номер = тип, 0x07 = Handle | 0x00 = nothing | 0x00 = nothing
argument = variable id ( то бишь 0xF80 ) переменные в Jass индексируются и далее вызываются по индексу.
0x0E = getvar | 0x29 = register (номер регистра) | 0x07 = type | 0x00
argument = variable id ( то бишь 0xF7D )
0x11 = setvar | 0x29 = register | 0x00 | 0x00
argument = variable id ( то бишь 0xF80 )
0x0C = literal | 0x00 | 0x00 | 0x00 - ибо функция возвращает nothing, потому в R0 записывается 0.
0x27 = return
argument = variable id ( то бишь 0xF80 ) переменные в Jass индексируются и далее вызываются по индексу.
0x0E = getvar | 0x29 = register (номер регистра) | 0x07 = type | 0x00
argument = variable id ( то бишь 0xF7D )
0x11 = setvar | 0x29 = register | 0x00 | 0x00
argument = variable id ( то бишь 0xF80 )
0x0C = literal | 0x00 | 0x00 | 0x00 - ибо функция возвращает nothing, потому в R0 записывается 0.
0x27 = return
Если же Вам всё это ничего не говорит (большинству скорее всего это ничего и не скажет), это банально значит, что локальные переменные достаточно близки к аргументам функций по логике, а аргументы обнулять не нужно. Однако если же был создан новый объект, то без обнуления локальной переменной внутриигровой счётчик хендлов повысится на 1 единицу.
Простой ответ - сами переменные не утекают ни при каких обстоятельствах и они преобразуются в нечто статичное, и им выделена память единожды. Потому, утечки вызываются фактически банальной проблемой логики в самом коде, то бишь вы создали хендл, а затем его не удалили и так может повторяться уйму раз. Но это не утечка, ибо тогда по этой логике call CreateUnit и так далее - утечки, ибо этот юнит просто создан и дальше нам нужно его найти, чтобы убить или удалить и так далее.
Видео примеры
Пример с юнитом:
Пример с локацией:
Пример с группой:
Примеры с аргументами функций:
1)
1)
2)
Подводя итоги, можно смело сказать, что никакого "ужаса" от использования локалок попросту нету, конечно же это не отменяет факта, что даже их стоит использовать с умом и их обнуление просто на просто необходимо, чтобы занятые регистры были очищены когда функция закончится и будет выполнена 0x27 (return) что повлечёт очистку стека. Да и фактически нет никакого оправдания тем, кто не обнуляет локальные "сложные" переменные, ибо их обнуление не добавляет нагрузки на JassVM и фактически уменьшает возможные утечки.
Хотелось бы внести поправку/дополнение (за что спасибо PTR153), в случае если вы создаёте объект и присваиваете его локалке, или же создаёте какой-либо объект внутри функции и присваиваете его локальной переменной, то её ОБЯЗАТЕЛЬНО нужно обнулить, в случае если созданный объект не был удалён и вы продолжаете его использовать, то всё что нужно - это присвоить его какой-нибудь глобальной переменной и вернуть её.
Краткий пример
function TestFunctionEx takes nothing returns nothing
local location loc = Location( .0, .0 )
set Loc = loc
set loc = null
call RemoveLocation( Loc )
endfunction
То бишь логика в том, что мы можем подать объект из локальной переменной в глобальную и далее уже без утечки провести с ней операцию.
Нативка X быстрее нативки Y!
раскрыть
Я думаю многие частенько встречались с обсуждениями скоростей нативок, и чаще всего под "удар" попадала GetUnitX против GetWidgetX, и общепринятно, что GetWidgetX быстрее чем GetUnitX. Однако, так ли это? Так давай же узнаем!
Для начала, я предложу Вам посмотреть псевдокод game.dll обоих функций:
Как видите, он фактически идентичен, CUnit является расширением CWidget, однако обработка позиции делается всегда на уровне CWidget, а так как это расширенный класс, то разницы в скорости не может быть физически. Собственно потому и получается вот так:
Обе функции заняли 5 мс на 10000 повторов, значит реальная задержка одного повтора 500 наносекунд +- 1-2 наносекунды погрешности. Конечно гигантский плюс GetWidgetX в её универсальности, ибо её можно применить ко всем видам виджетов без типизации, однако на этом её плюсы заканчиваются и в остальном она идентична GetUnitX.
Хештаблица медленная!
раскрыть
Это пожалуй один из самых ужасных мифов, которые просто на просто вводят меня в бешенство. Конечно же, этот миф "двоякий", ибо вопрос заключается в том, что в понятии "медленная" и в сравнении с чем? Зачастую те, кто используют vJass структуры, сравнивают хещтаблицу с этими псевдо-структурами, опираясь на то, что обращение к массиву - всегда быстрее чем обращение к хештаблице. Перед тем, как опровергнуть этот миф, я обязан согласиться с тем, что обращение к массиву конечно же быстрее, ибо JASM байткод вызывает 0x10 (getvar[]) и фактически берёт из уже "вшитого" списка функций по индексу, то бишь то, что делает хештаблица, только на более "низком" уровне.
Чтобы провести анализ скорости, давайте возьмём обычный массив чисел и число из хештаблицы и посмотрим эти варианты. Для простоты тестов, я не буду использовать StringHash для создания ключей, скорость с ними будет показан позже, и пример "ускорения" хештаблицы для особых фанатиков псевдоскорости.
Как мы видим getvar[] и setvar[] у массивов равен по скорости, точно так же как и Save равен по скорости Load у хештаблиц, потому далее будут сравниваться именно скорости загрузок (ибо мы чаще загружаем, чем сохраняем данные).
Смею предположить, что просмотрев примеры, будет вопрос "но тут же сравниваются нулевые ключи с последним индексом массива, так нечестно!", а что если я Вам скажу, что разница в значении числа создаёт минимальную погрешность и не более? Не верится? Так давай те же проверим!
Как видите, скорость не изменилась, однако, если мы теперь рассмотрим варианты со StringHash, то разница будет более заметной. Рассмотрим в начале самый распространённый вариант применения (который в целом я использую у себя в Jass коде).
Ох как! Разница почти в 4.5 раз. Однако, не забывайте, что фактическая разница исчисляется на 1 вызов и не берёт во внимание таймер перебора всех значений в структуре, то бишь это сравнение строго одного массива и значения в хеше. Рассмотрим их фактические задержки, 3 мс / 10000 = 300 наносекунд и 13 мс / 10000 = 1300 наносекунд, то бишь борьба идёт за 1000 наносекунд, что является 0.001 мс или же 0.000001 секунды. Потому мне лично сложно представить, где эта разница даст о себе знать и как часто делается 10000 повторов или же вызовов чего-либо.
А теперь рассмотрим вариант "ускорения" хештаблицы, я думаю Вы прекрасно догадываетесь как же это можно сделать, но всё же рассмотрим это!
Как видите, разница в скорости упала вновь до +- прежнего значения с погрешность, что в итоге делает разницу вновь лишь в 2 раза, однако даже так - эта разница слишком минимальна, чтобы это вызывало каке-либо проблемы. Для сравнения, средняя функция мемхака порой в 20 раз медленнее нативок, но это не делает эти функции абсолютно непригодными для использования, ведь так?
Хештаблица занимает слишком много памяти игры!
раскрыть
Спасибо JackFastGame за напоминание об этом мифе. Для того, чтобы была возможность сравнивать или же оценивать занимает ли тот или иной объект много места, нужно найти его структуру и посмотреть, как игра работает с ними. Потому в начале стоит рассмотреть их фактические изначальные размеры, которые выделяет игра.
Для прояснения, игра создаёт менеджер Хештаблиц единожды, каждая хештаблица (всего их может быть 256) фактически - это дополнение внутриигровой хештаблицы (ибо не стоит забывать, что абсолютно все объекты в игре загружаются игрой из хештаблицы. Это в целом Вы можете увидеть достаточно подробно в МемХаке, где достаточно часто считываются объекты через их хешключи.
Пример такого в игре:
Но что же тогда делает хештаблица, которую мы можем создать в Jass? Она является ничем иным, как "дочерней" таблицей главной таблицы, которой выделено 0-255 ключей, то бишь 256 индексов, что можно увидеть тут:
Собственно, если хештаблица не имеет никаких вписанных в неё значений, то её "изначальный" вес будет равен 0x28 байтам, далее каждый новый "хешключ" (который состоит из родительского и дочернего ключа) будет добавлять по 4 байта (то бишь никакого отличия от массивов). Исключение - это "стринг", там каждая буква = один байт, что опять же идентично обычным переменным.
Подводя итоги, можно смело сказать, что хештаблица не представляет собой какую-то громоздкую систему и её "засорение" всегда будет на руках её пользователя, однако хештаблица нам позволяет динамически очищать память, путём RemoveSavedHandle и прочего или же полной очистки дочерних ключей или же всей хештаблицы, что освободит всю занятую ей память.
Deg2Rad и Rad2Deg медленные!
раскрыть
Это как вы скорее всего и догадались очередной миф, который базируется вокруг "идеи", по которой прямое умножение джасс переменных должно быть быстрее нативок, однако это не так. Давайте начнём с примеров:
Давайте теперь рассмотрим Rad2Deg:
Результат одинаковый в обоих случаях, но почему же? Для этого нам нужно посмотреть в обработчик JASM байткода, который ссылается на опкод 0x22 (multiplication):
А теперь посмотрим на Deg2Rad:
В итоге получается, что умножение фактически превосходит вызов нативки по общим операциям, но всё это сводится к погрешности, которая банально делает эти операции равными. Такая же учесть следует и за Pow( dx, 2 ) в сравнении с dx * dx, они тоже равны по скорости.
Юниты жрут больше фпса чем Эффекты!
раскрыть
С выходом Warcraft 3 Reforged и с появлением API для эффектов, начал зарождаться вопрос, а что же в итоге эффективнее и меньше нагружает игру? Отвечу кратко, эффекты конечно же нагружают меньше, ввиду того, что эффект - это расширение CSpriteUber, а CUnit расширяет CWidget, который хранит не только CSpriteUber, а так же круги выделения, полосу здоровья, и прочую информацию. Говоря проще разница структур фактически колоссальная.
Однако, если вы думаете, что итог очевиден, то вы ошибаетесь. На деле игре фактически до шляпы, ибо без наведения мышки на юнита/выделения и если полосы здоровья скрыты, то нужная мощь GPU используется одинаково, как доказательство, как обычно предъявляю скриншоты.
Подводя итоги, можно смело сказать, что фактически гигантской или же ощутимой разницы просто нет и мне не понятно откуда вообще взялся миф об этом. Но надеюсь это более чем опровергает этот миф.
Операция not true медленнее чем true == false (или схожие аналоги)
раскрыть
Данный миф существует с давних-давних времён, а если ещё точнее образовался с hiveworkshop, когда некто пытался замерять задержку исполнения функций (сама библиотека для измерений была в целом неточная) и множество тестов показали, что булевые операции с not оказывались медленнее чем == true или == false.
На деле это конечно же неправда и как минимум идёт в разрез с обще поставленной логикой, а если точнее - чем меньше байткода, тем быстрее он выполнится. Посмотрим же на приведённый пример.
Пример
На скриншоте мы видим две булевые операции not true и true == false, рассмотрим байткод:
Байткод (not true):
0C510800 - literal
00000001 - value
25610000 - operator not
00000000
2A510000 - jump if
00001483 - register
16000000 - call jass
00000A93 - jass function
2B000000 - jmp
00001484 - register
28000000 - label
00001483 - register
28000000 - label
00001483 - register
0C000000 - literal
00000000
27000000 - retn
00000001 - value
25610000 - operator not
00000000
2A510000 - jump if
00001483 - register
16000000 - call jass
00000A93 - jass function
2B000000 - jmp
00001484 - register
28000000 - label
00001483 - register
28000000 - label
00001483 - register
0C000000 - literal
00000000
27000000 - retn
Байткод (true == false):
0C510800 - literal
00000001 - va
13510000 - push 0x51
00000000
14530000 - pop 0x53
00000000
1A535352 - eq 0x53 0x53 0x52
00000000
2A530000 - jmpf
00001483 - where to
16000000 - call jass
00000A93 - func id
2B000000 - jmp
00001484 - where to
28000000 - label
00001483 - id
28000000 - label
00001484 - id
0C000000 - literal
00000000 - retval
27000000 - ret
00000001 - va
13510000 - push 0x51
00000000
14530000 - pop 0x53
00000000
1A535352 - eq 0x53 0x53 0x52
00000000
2A530000 - jmpf
00001483 - where to
16000000 - call jass
00000A93 - func id
2B000000 - jmp
00001484 - where to
28000000 - label
00001483 - id
28000000 - label
00001484 - id
0C000000 - literal
00000000 - retval
27000000 - ret
Как вы можете увидеть, операция через not меньше не только по байткоду, но и по выделенным "данным", ибо в варианте true == false мы выделяем 2 литерала, один под true, другой под false и третий под результат, а для получения результата используется дополнительная команда eq (equals - равно).
Собственно выходит, что чем короче булевые операции, чем меньше в целом действий, тем она будет быстрее (что как обычно - логично).
TriggerAddAction утекает!
раскрыть
Очередной древний миф, который опять же пробрался к нам с далёких просторов hiveworkshop и был так же "перепроверен" на xgm. Однако, хоть этот миф и правдив, но правда немного в другом и виной тому, как обычно "божественные пальчики" программистов Blizzard Entertainment.
Начну с краткого, нет, сама TriggerAddAction не при чём, виной всему TriggerClearActions и DestroyTrigger, которые просто на просто не имеют кода деструктора самого Action, а вот TriggerRemoveAction его имеет! А это значит, если вы сохраните triggeraction в переменную и удалите его через TriggerRemoveAction, то никакой "утечки" не будет.
Перейдём к самим тестам!
Проверка "утечек"
Посмотрим результат после DestroyTrigger:
Давайте же посмотрим строго на TriggerAddAction и что же будет после DestroyTrigger:
Результат после DestroyTrigger
TriggerClearConditions
TriggerClearActions
Ничего не заметили? Если заметили, то можете похлопать себя по плечу, я горжусь вами! А если нет, то я вас понимаю, ибо разница очень и очень МАЛЕНЬКАЯ и заключается как раз в
(*(void (__stdcall **)(int, int))(**(uint32_t **)((signed int)result <= 0 ? 8 : result + 2) + 0x5C))( ); // CAgentWar3::Destroy
которой тут просто... нет... а это значит, что хендл triggeraction просто на просто не удаляется и остаётся в памяти игры.
Итог: утекает не TriggerAddAction, а отсутствие удаление деструктора triggeraction в DestroyTrigger и TriggerClearActions.
KillSoundWhenDone очищает sound хендл!
раскрыть
Не секрет, что в огромном количестве карт используются звуки, однако правильно ли они используются, почти во всех случаях - нет. Однако, виноваты в этом далеко не картоделы, а в очередной раз сами Blizzard.
Каламбур KillSoundWhenDone связан с тем, что да, оно удаляет выполняемый звук, однако оно не удаляет сам хендл, то бишь CSoundWar3 и так как нет нативки на прямую установку/замену звука, то остаётся висячий хендл.
Каламбур KillSoundWhenDone связан с тем, что да, оно удаляет выполняемый звук, однако оно не удаляет сам хендл, то бишь CSoundWar3 и так как нет нативки на прямую установку/замену звука, то остаётся висячий хендл.
Посмотрим на счётчик хендлов без и с KillSoundWhenDone:
Как видите на уровне хендлов разницы нет вообще, однако KillSoundWhenDone убивает сам звук, что всё-таки освободит память занятую непосредственно звуком.
А теперь посмотрим мою функцию RemoveSound, которая будет доступна в UjAPI начиная с 1.0.24.92 версии.
По итогу, получается что используя стандартный метод вызова звука, а точнее:
function PlaySound takes string soundName returns nothing
local sound soundHandle = CreateSound(soundName, false, false, true, 12700, 12700, "")
call StartSound(soundHandle)
call KillSoundWhenDone(soundHandle)
endfunction
Или же метод без утечки индекса:
function PlaySoundEx takes string soundName returns nothing
local sound soundHandle = CreateSound(soundName, false, false, true, 12700, 12700, "")
call StartSound(soundHandle)
call KillSoundWhenDone(soundHandle)
set soundHandle = null
endfunction
Мы всё-равно не решаем проблему утечки CSoundWar3, а это значит, что единственное верное решение (по-крайней мере на ванилле/рефе) - это каждый раз создавать уникальный звук для героя и т.д. и не удалять его через KillSoundWhenDone, а останавливать через StopSound( s, false, true ) и использовать его вновь, а не создавать новый, надясь на то, что он будет таки очищен.
Информация:
Что нужно для проверки скорости функций и прочего?
Всё достаточно просто, просто скачайте мой MemHackAPI отсюда: xgm.guru/p/wc3/memoryhackapi
В папке UselessTesting будет триггер Testing в нём есть функция TestBenchmarking, в первый и второй цикл вставьте функции, что вы хотите сравнить. Когда оба цикла завершается, будет высвечено сколько каждый из циклов занял в мс (миллисекунды). Оба цикла делаются 10000 раз, чтобы получить большее число без умножений и неточностей. Собственно, чтобы получить одиночную задержку, надо разделить полученное число на 10000 или установленное Вами значение.
В папке UselessTesting будет триггер Testing в нём есть функция TestBenchmarking, в первый и второй цикл вставьте функции, что вы хотите сравнить. Когда оба цикла завершается, будет высвечено сколько каждый из циклов занял в мс (миллисекунды). Оба цикла делаются 10000 раз, чтобы получить большее число без умножений и неточностей. Собственно, чтобы получить одиночную задержку, надо разделить полученное число на 10000 или установленное Вами значение.
Заключение:
За свою долгую историю, Warcraft 3 пожалуй создал пожалуй самое мощное начало для мододелов, что повлекло за собой уйму отдельных игр, которые ранее были картами в этой игре. Зачастую, многие создатели (включая меня) сильно недооценивали Jass в целом и всячески пытались найти тот или иной повод упрекнуть его в той или иной проблеме, не проведя нужных проверок, чтобы выявить реальную причину.
К моему большому сожалению, по сей день большинство картоделов опираются на те или иные мифы, которые услышали на Hive, а некоторые услышали что-то на xgm и других сайтах, и вроде как не от каких-либо случайных людей, а по факту проверенных, однако даже они могут ошибаться.
Потому, я очень надеюсь, что даже бегло прочитав мою статью, она развеет сложившиеся ложные впечатления о Jass и эти вопросы навсегда отпадут.
Ред. PT153