Русский
Русский
English
Статистика
Реклама

Оптимизация кода

Любовь. Python. C. Доклад Яндекса

29.01.2021 10:18:54 | Автор: admin
Что связывает языки Python и C++? Как извлечь из этого выгоду лично для себя? На большой конференции Pytup Александр Букин показал способы, благодаря которым можно оптимизировать свой код, а также выбирать и эффективно использовать сторонние библиотеки.

Всем привет, меня зовут Александр Букин, я разрабатываю Яндекс.Погоду. Вы еще можете знать меня как сооснователя Pytup. Также я состою в программных комитетах таких классных конференций, как PyCon.ru и YaTalks.

Сегодня мы с вами поговорим о любви Python и C++. А точнее о том, что же их объединяет, эти два прекрасных языка и, на самом деле, еще C, и что мы можем почерпнуть из этих прекрасных отношений.

Дисклеймер: название доклада это отсылка к прекрасному сериалу Любовь. Смерть. Роботы на Netflix. Всем очень советую.

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



Этот герой был силен, он не по годам хорошо осваивал силу. У него было много мидихлориан. А еще все знали, что этот парень очень быстрый.



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



Но незадолго до этого он успел дать жизнь новому прекрасному герою, нашему любимому Python.



Как мы знаем, их родство C и Python прямое. И Python какое-то время был разделен с отцом. Он развивался немного отдельно, хотя отец, конечно, оказывал на него большое влияние. И не все у них хорошо складывалось. Иногда ему даже было сложно принять, что C++ его папа. Он не всегда с этим хорошо мирился. Шло время, и ему все-таки удалось найти общий язык с отцом. Объединив усилия, найдя эту любовь, Python и C++ вместе одолели зло во вселенной. Их общая мощь оказалась так велика, что никто не мог удержать их.



Я считаю, это прекрасно. Давайте поймем, почему для нас, для разработчиков, это может быть полезно, а не только интересно. Как я и упомянул, C++ всегда был очень быстр, а Python не всегда. Скорее он даже прославился тем, что его производительность в важных задачах иногда страдает. Это как раз то, чем C++ может помочь ускорить ваш код, некоторые части, которые вы в своих сервисах вызываете действительно часто. Давайте выясним, как это сделать.

Во-первых, дисклеймер: никакой C++ не ускорит код твой лучше тебя. Прежде чем попробовать техники, которые мы обсудим дальше, всегда нужно помнить, что начинаем мы с кода, который написали. Скорее всего, его можно оптимизировать прямо сейчас, просто пройдясь по нему. И прежде чем пробовать сишные хаки, попробуйте просто сделать алгоритм лучше, причесать свой текущий питонячий код. Но если вы это уже сделали, или в вас просто силен дух авантюризма, то приглашаю вас в удивительный мир ускорения Python.



Так как Python написан на C, у него есть довольно большое количество способов интеграции с ним. Сегодня мы рассмотрим два из них Cython и Native Extension на C/C++. Подробнее остановимся именно на нативных расширениях. Да, там есть еще подмножество. Но, во-первых, эти два мои любимые. Во-вторых, целого доклада не хватит рассказывать про каждый из них.

Небольшое введение про Cython. Это довольно популярная технология: например, gevent написан с помощью него. Он легко интегрируется в сборку проекта, именно поэтому он мне нравится. У Cython достаточно хорошая документация. Еще для разработчика есть полезная вещь cythonize. Это оборачивание вашей функции вызов этой функции, который в среднем на синтетических тестах даже дает ускорение в выполнении кода до двух раз.


Ссылка со слайда

Понятно, что есть и минусы. Во-первых, хотя Cython, в отличие от других способов, предлагает вам писать на близком к CPython синтаксисе, все равно в мелочах они отличаются. И иногда это мешает. Во-вторых, когда вам всё-таки приходится переключаться на C, его необходимо знать. Но здесь, кстати, необязательно знать его всегда. Конечно же, присутствует наш любимый Segmentation Fault, который можно словить, если плохо поработать с памятью уже в сыром C. Из этого же растут ноги сложностей в дебаггинге. Но если вам не хочется очень глубоко погружаться в C, а хочется попробовать ускорить свою технологию прямо сейчас, Cython хороший выбор.

Но мы, как говорится, верим в абсолют, поэтому абсолютная сила это абсолютная скорость. И мы хотим сильно ускорить выполнение, поэтому полезем внутрь.


Ссылка со слайда

Полезть внутрь это как раз использовать нативный Extension. Несмотря на то, что это не самая простая технология, она родная. Ее документация есть прямо в доке Python. Она тоже легко интегрируется в сборку проекта и позволяет создавать свои типы данных. Здесь вам уже никак не обойти необходимость знания C и написания на нем кода. Здесь придется, если вы будете делать что-то сложное, поработать с памятью и поотлаживаться.



Маленький пример. Вот минимальный файл spammodule.c, в котором описывается, как ни странно, модуль спама. Как мы видим, мы подключаем заголовочный файл include Python.h, который понадобится нам для любого модуля. И описываем наш модуль. Говорим, какое у него будет имя, и описываем функцию его инициализации PyInit_spam. Дальше вызываем PyModule_Create, который либо возвращает null, либо возвращает модуль.



Чтобы все это заработало, необходимо всё-таки сбилдить наш модуль. Для этого можно воспользоваться setuptools. Мы пишем небольшой setup.py, в котором указываем, что нам нужен Extension. Говорим, как его называть и откуда брать исходники. Запускаем setup.py build, setup.py install. Можно импортить, можно использовать.

Это пример для C. Пример для С++ выглядит очень похоже.



Просто пишем исходник на плюсах, добавляем sources spammodule.cpp, и указываем, что язык у нас тоже С++. Поздравляю, вы прекрасны. Всего лишь нужно в разделе вашего файла .c или .cpp написать валидный, хорошо работающий, правильно интерпретирующий работу с памятью код на C и на плюсах. Возможно, вы к этому не очень готовы. Может быть, вам просто лень это делать, или вы думаете: я же разработчик, а разработчик не делает своих велосипедов. Наверное, раз это такой родной и давно используемый механизм, уже есть кто-то, кто это сделал. И да, уже есть.

Давайте посмотрим парочку примеров. Например, есть ujson.



Что мы делаем часто, как все разработчики? Перекладываем джейсончики.

Эта библиотека используется довольно легко. У нее есть стандартные функции dumps и loads. И внутри она реализована как раз с помощью Extension. Там парочка C-файлов и оберточка сверху.

Давайте не будем верить мне на слово, что простое добавление C-кода ускоряет файлики. Давайте это побенчмаркаем, посериализуем, десериализуем их. Все измерения, которые я дальше буду показывать, в миллисекундах.

Для начала возьмем не очень большой файл, который возвращает API Twitter, JSON, размером 600 килобайт.


Ссылка со слайда

Я взял за основу две очень популярные Python-библиотеки: json и simplejson. Мы получаем, что сериализация и десериализация в районе 3,5 миллисекунд у json и 3,15 у simplejson. Выглядит довольно быстро. Как вы думаете, за сколько это сделает ujson? Допустим, он сделает это за 3 секунды ровно. Может быть, за 2,9. Но на самом деле прирост будет больше. Я добавил ссылку на бенчмарк, и сериализация заняла практически 2 миллисекунды. Как мы видим, прирост в полтора раза, довольно неплохо. Но конечно, хотелось бы большего.


Ссылка со слайда

Возьмем файлик побольше canada.json. Это файл с геоточками на 2 мегабайта. Видим, что тот же simplejson уже работает не так однозначно, ему потребовалось целых 80 миллисекунд на сериализацию. json немножко получше. Но ujson здесь вырывается вперед гораздо сильнее на большом количестве данных, и мы уже получаем прирост в четыре с половиной раза относительно simplejson с сериализацией и в три раза относительно json. Отличный результат.

Я дам вам насладиться этой скоростью. Но есть и ложка дегтя. Понятно, что библиотека ujson не во всем хороша с точки зрения совместимости. Она не поддерживает все типы данных, которые можно сериализовать. Если вы найдете на ее GitHub, то увидите, что там довольно много issues, иногда есть утечки памяти, иногда ошибки. Надо помнить, что ничто не идеально, и смотреть, подходит ли данная библиотека для вашего конкретного варианта.

Но если у вас где-то сериализуется json и ограничения этой библиотеки вам подходят, попробуйте. Возможно, вы будете приятно удивлены.



Посмотрим на задачу, которая часто встречается и которая в Python исторически не очень быстро работает, парсинг дат. Есть такая библиотека ciso8601. Она тоже написана с помощью C Extension. Вот так выглядит ее использование. Довольно просто. Есть функция parse_datetime, в которой вы передаете строчку в одном из поддерживаемых форматов. Это парсится в стандартный datetime object. Даже поддерживает тайм-зоны.

Давайте тоже побенчмаркаем. Парсить мы будем вот такую строчку: 2014-01-09T21:48:00. Все измерения, которые получим, будут в микросекундах.



Здесь добавилась еще версия Python. Будет интересно посмотреть на разных версиях, как оно работает. Я взял за основу python-dateutil, который фактически является расширенной стандартной библиотекой datetime, и популярный, но написанный на Python str2date.

python-dateutil на версии Python 3.8 делает это за 122 микросекунды видите, необычно, что он чуть замедлился относительно 3.7. Гораздо быстрее, на порядок, делает это str2date. Что же может нам предложить ciso? Наверное, будет одна микросекунда.


Ссылка со слайда

На самом деле будет меньше одной микросекунды, очень быстро. И даже в одном из худших случаев с версией 3.8 это опережает оригинальный datetime парсер в 600 раз. Если мы возьмем версию 2.7, это будет практически в 1000 раз быстрее.

Это уже производит гораздо более сильное впечатление, чем наш предыдущий бенчмарк с json. И мы задаемся вопросом: наверное, что-то не так, что-то в datetime работает нехорошо. На самом деле нет. Просто у этой библиотеки тоже есть свои минусы с точки зрения поддержки форматов и дебаггинга, если что-то пошло не так.

Но давайте взглянем на нее чуть подробнее как на хороший пример C Extension. Вот небольшой проектик. Тут много файликов, но самый важный module.c. В этом файлике находится весь код этой библиотеки. Это всего один файлик, и он всего на 586 строчек. Второй важный файлик setup.py. Помните, мы вначале рассматривали spammodule.c. Это точно такая же схема. Есть один C-файлик и один setup.py. Как мы видим, внутри он, конечно, поразвернутее, но вот эта строчка Дай мне, пожалуйста, Extension, обзови его вот так, возьми у него исходники присутствует и здесь.



Ребята большие молодцы, что не стали усложнять. Это помогает. Так мы затронули то, что, как говорится, можно сделать одной левой. Вы уже сейчас можете просто взять, поменять пару строчек в своем коде, там, где работаете с датами с json, и получить прирост. Я считаю, это замечательно.

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

Немножко о том, где еще можно узнать, что такое расширение для Python, почему они хороши и как его ускорить. В 2018 году у нас на Pytup выступал Костя Гуков, который рассказывал про расширение на Rust. Там он показывал расширения, которые тоже парсят даты. Забегая вперед, скажу, что они медленнее, чем написанные на C, но тоже очень быстрые. Антон Патрушев на PyCon тоже рассказывал про расширение на Rust:

Смотреть видео

Как видите, это сейчас довольно популярно. И если вдруг C вам не по душе, а хочется написать расширение, можно посмотреть в эту сторону. Позже Кирилл еще расскажет, как ускорить не конкретно свой код или функцию, а весь Python. И будет ли он ускоряться в принципе в ближайшем будущем. Всем советую послушать.

Давайте подведем небольшие итоги:

  • Не забывайте оптимизировать свой код на Python. Это первое, что нужно делать всегда, когда кажется, что что-то может происходить быстрее.
  • Если вы провели оптимизацию, попробуйте сторонние библиотеки, которые содержат написанное с помощью C и C++ Extension или с помощью Rust. Возвращаясь немного назад, ujson не самая быстрая библиотека, есть быстрее: orjson, njson. Попробуйте их.
  • И если ваша задача довольно узкая или вам хочется сделать что-то свое, пишите свои расширения, изучайте новые языки для этих расширений. Развивайтесь.

May the Force be with you, друзья.

Подробнее..

Как перезапустить закон Мура программными методами. Ускорение софта в тысячи раз

03.08.2020 12:13:23 | Автор: admin
Профессор Никлаус Вирт был прав. Создатель языка Pascal, соавтор технологии структурного программирования, лауреат премии Тьюринга в 1995 году заметил:

Замедление программ происходит куда быстрее, чем ускорение компьютеров


С тех пор это высказывание считается законом Вирта. Он фактически нивелирует закон Мура, согласно которому количество транзисторов в процессорах удваивается примерно с 1965 года. Вот что пишет Вирт в статье Призыв к стройному софту:

Около 25 лет назад интерактивный текстовый редактор умещался всего в 8000 байт, а компилятор в 32 килобайта, тогда как их современные потомки требуют мегабайтов. Стало ли всё это раздутое программное обеспечение быстрее? Нет, совсем наоборот. Если бы не в тысячу раз более быстрое железо, то современное программное обеспечение было бы совершенно непригодным.

С этим трудно не согласиться.

Ожирение софта


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

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

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

Те времена прошли.

С появлением языков более высокого уровня, таких как Java, Ruby, PHP и Javascript, к 1995 году, когда Вирт написал свою статью, программирование стало более абстрактным. Новые языки значительно облегчали программирование и многое брали на себя. Они были объектно-ориентированными и поставлялись в комплекте с с такими вещами, как IDE и сборка мусора.

Программистам стало легче жить, но за всё приходится платить. Чем легче жить, тем меньше думать. Примерно в середине 90-х программисты перестали думать о качестве своих программ, пишет разработчик Робин Мартин в своей статье Никлаус Вирт был прав, и в этом проблема. В то же время началось широкое использование библиотек, функциональность которых всегда намного больше, чем необходимо для конкретной программы.

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

Проблема не кажется такой уж большой, но в реальности она серьёзнее, чем вы можете подумать. Например, Никола Дуза написал простое приложение для ведения списка дел. Оно работает в вашем браузере с HTML и Javascript. Как вы думаете, сколько зависимостей оно использовало? 13 000. Тринадцать. Тысяч. Пруф.

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

Это означает, что проблема, о которой предупреждал Никлаус Вирт в 1995 году, со временем только усугубится.

Что делать?

Робин Мартин предполагает, что хороший способ приступить к решению проблемы разделить библиотеки. Вместо того, чтобы создавать одну большую библиотеку, которая делает всё возможное, просто создать много библиотек.

Таким образом, программист должен только выбрать библиотеки, которые ему действительно нужны, игнорируя функциональные возможности, которые он не собирается использовать. Мало того, что он сам устанавливает меньше зависимостей, но и в используемых библиотеках тоже будет меньше своих зависимостей.

Конец закона Мура


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

Хотя хоронить его рано. Есть ряд новых технологий, которые обещают прийти на смену кремниевой микроэлектронике. Например, Intel, Samsung и другие компании экспериментируют с транзисторами на углеродных наноструктурах (нанонитях), а также с фотонными чипами.


Эволюция транзисторов. Иллюстрация: Samsung

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

Методы ускорения программного обеспечения


Недавно в журнале Science была опубликована интересная статья учёных из лаборатории компьютерных наук и искусственного интеллекта Массачусетского технологического института (CSAIL MIT). Они выделяют три приоритетные области для дальнейшего ускорения вычислений:

  • лучшее программное обеспечение;
  • новые алгоритмы;
  • более оптимизированное железо.

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

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

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

Во многих случаях производительность действительно можно повысить в тысячи раз, и это не преувеличение. В качестве примера исследователи приводят перемножение двух матриц 40964096. Они начали с реализации на Python как одного из самых популярных языков высокого уровня. Например, вот реализация в четыре строки на Python 2:

for i in xrange(4096):for j in xrange(4096):for k in xrange(4096):C[i][j] += A[i][k] * B[k][j]

В коде три вложенных цикла, а алгоритм решения основан на школьной программе по алгебре.

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

Версия Реализация Время выполнения (с) GFLOPS Абсолютное ускорение Относительное ускорение Процент от пиковой производительности
1 Python 25552,48 0,005 1 0,00
2 Java 2372,68 0,058 11 10,8 0,01
3 C 542,67 0,253 47 4,4 0,03
4 Параллельные циклы 69,80 1,97 366 7,8 0,24
5 Парадигма Разделяй и властвуй 3,80 36,18 6727 18,4 4,33
6 + векторизация 1,10 124,91 23224 3,5 14,96
7 + интристики AVX 0,41 337,81 52806 2,7 40,45

Переход на более эффективный язык программирования уже кардинально повышает скорость выполнения кода. Например, программа на Java будет выполняться в 10,8 раз быстрее, а программа на С ещё в 4,4 раза быстрее, чем на Java. Таким образом, переход с Python на C означает повышение скорости выполнения программы в 47 раз.

И это только начало оптимизации. Если писать код с учётом особенностей аппаратного обеспечения, на котором он будет выполняться, то можно повысить скорость ещё в 1300 раз. В данном эксперименте код сначала запустили параллельно на всех 18 ядрах CPU (версия 4), затем использовали иерархию кэшей процессора (версия 5), добавили векторизацию (версия 6) и применили специфические инструкции Advanced Vector Extensions (AVX) в версии 7. Последняя оптимизированная версия кода выполняется уже не 7 часов, а всего 0,41 секунды, то есть более чем в 60 000 раз быстрее оригинального кода на Python.

Более того, на графической карте AMD FirePro S9150 тот же код выполняется всего за 70 мс, то есть в 5,4 раза быстрее, чем самая оптимизированная версия 7 на процессоре общего назначения, и в 360 000 раз быстрее, чем версия 1.

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

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


Эффективность алгоритмов для решения задачи о максимальном потоке на графе с n=1012 вершин и m=n11 рёбер

Таким образом, улучшение алгоритмов тоже вносит свой вклад в то, чтобы эмулировать закон Мура программным путём.

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

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

Тренд на параллелизацию виден уже сейчас. Как показано на диаграмме, в последние годы производительность CPU растёт исключительно благодаря увеличению количества ядер.


Производительность по тесту SPECint отдельных ядер, а также одно- и многоядерных процессоров с 1985 по 2015 годы. В качестве базовой единицы взят микропроцессор 80386 DX образца 1985 года

Для операторов дата-центров даже минимальное улучшение производительности ПО может означать большую финансовую выгоду. Неудивительно, что сейчас инициативы по разработке собственных специализированных CPU ведут такие компании как Google и Amazon. Первая выпустила тензорные (нейронные) процессоры Google TPU, а в дата-центрах Amazon работают чипы AWS Graviton.

За лидерами отрасли со временем могут последовать владельцы других ЦОД, чтобы не проиграть конкурентам в эффективности.

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

Рост производительности потребует новых инструментов, языков программирования и аппаратного обеспечения, чтобы обеспечить более эффективное проектирование с прицелом на скорость, говорит профессор Чарльз Лейзерсон, соавтор научной работы. Это также означает, что программистам следует лучше понимать, как сочетается софт, алгоритмы и аппаратное обеспечение, а не рассматривать их по отдельности.

С другой стороны, инженеры экспериментируют с технологиями, которые могут обеспечить дальнейший рост производительности CPU. Это квантовые вычисления, 3D-компоновка, микросхемы со сверхпроводимостью, нейроморфные вычисления, использование графена вместо кремния и др. Но пока эти технологии на стадии экспериментов.

Если производительность CPU в самом деле перестанет расти, то мы окажемся в совершенно другой реальности. Возможно, нам в действительно придётся пересмотреть наши приоритеты в программировании, а специалисты по ассемблеру станут на вес золота.



На правах рекламы


Нужен мощный сервер? Наша компания предлагает эпичные серверы - виртуальные серверы с CPU AMD EPYC, частота ядра CPU до 3.4 GHz. Максимальная конфигурация впечатлит любого 128 ядер CPU, 512 ГБ RAM, 4000 ГБ NVMe.

Подробнее..

Перевод Компилятор всё оптимизирует? Ну уж нет

17.06.2021 12:20:28 | Автор: admin
Многие программисты считают, что компиляторы это волшебные чёрные ящики, на вход в которые можно подать хаотичный код, а на выходе получить красивый оптимизированный двоичный файл. Доморощенные философы часто начинают рассуждать о том, какие фишки языка или флаги компилятора следует использовать, чтобы раскрыть всю мощь магии компилятора. Если вы когда-нибудь видели кодовую базу GCC, то и в самом деле могли поверить, что он выполняет какие-то волшебные оптимизации, пришедшие к нам из иных миров.

Тем не менее, если вы проанализируете результаты работы компиляторов, то узнаете, что они не очень-то хорошо справляются с оптимизацией вашего кода. Не потому, что пишущие их люди не знают, как генерировать эффективные команды, а просто потому, что компиляторы способны принимать решения только в очень малой части пространства задач. [В своём докладе Data Oriented Design (2014 год) Майк Эктон сообщил, что в проанализированном фрагменте кода компилятор теоретически может оптимизировать лишь 10% задачи, а 90% он оптимизировать не имеет никакой возможности. Если бы вам интересно было узнать больше о памяти, то стоит прочитать статью What every programmer should know about memory. Если вам любопытно, какое количество тактов тратят конкретные команды процессора, то изучите таблицы команд процессоров]

Чтобы понять, почему волшебные оптимизации компилятора не ускорят ваше ПО, нужно вернуться назад во времени, к той эпохе, когда по Земле ещё бродили динозавры, а процессоры были чрезвычайно медленными. На графике ниже показаны относительные производительности процессоров и памяти в разные годы (1980-2010 гг.). [Информация взята из статьи Pitfalls of object oriented programming Тони Альбрехта (2009 год), слайд 17. Также можно посмотреть его видео
(2017 год) на ту же тему.]


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

  • В 1980 году задержка ОЗУ составляла примерно 1 такт процессора
  • В 2010 году задержка ОЗУ составляла примерно 400 тактов процессора

Ну и что же? Мы тратим чуть больше тактов на загрузку из памяти, но компьютеры всё равно намного быстрее, чем раньше. Какая разница, сколько тактов мы тратим?

Ну, как ни грустно осознавать, но даже если у нас будут более качественные компиляторы и мощное железо, скорость ПО значительно не повысится, потому что ПО такое медленное не из-за них. Главная современная проблема использование процессоров не во всю их мощь.

В таблице ниже указаны параметры задержки самых распространённых операций. [Таблица взята из книги Systems Performance: Enterprise and the cloud (2nd Edition 2020).] В столбце Задержка в масштабе указана задержка в значениях, которые проще понимать людям.

Событие Задержка Задержка в масштабе
1 такт ЦП 0,3 нс 1 с
Доступ к кэшу L1 0,9 нс 3 с
Доступ к кэшу L2 3 нс 10 с
Доступ к кэшу L3 10 нс 33 с
Доступ к основной памяти 100 нс 6 мин
Ввод-вывод SSD 10-100 мкс 9-90 ч
Ввод-вывод жёсткого диска 1-10 мс 1-12 месяцев

Посмотрев на столбец задержек в масштабе, мы быстро поймём, что доступ к памяти затратен, и что в случае подавляющего большинства приложений процессор просто бездельничает, ожидая ответа от памяти. [Узким местом не всегда является память. Если вы записываете или считываете много данных, то узким местом, скорее всего, будет жёсткий диск. Если вы рендерите большой объём данных на экране, то узким местом может стать GPU.]

На то есть две причины:

  1. Языки программирования, которые мы используем и сегодня, создавались во времена, когда процессоры были медленными, а задержки памяти не были такими критичными.
  2. Best practices отрасли по-прежнему связаны с объектно-ориентированным программированием, которое показывает на современном оборудовании не очень высокую производительность.

Языки программирования


Язык Время создания
C 1975 год
C++ 1985 год
Python 1989 год
Java 1995 год
Javascript 1995 год
Ruby 1995 год
C# 2002 год

Перечисленные выше языки программирования придуманы более 20 лет назад, и принятые их разработчиками проектные решения, например, глобальная блокировка интерпретатора Python или философия Java всё это объекты, в современном мире неразумны. [Все мы знаем, какой бардак представляет собой C++. И да, успокойтесь, я знаю, что в списке нет вашего любимого нишевого языка, а C# всего 19 лет.] Оборудование подверглось огромным изменениям, у процессоров появились кэши и многоядерность, однако языки программирования по-прежнему основаны на идеях, которые уже не истинны.

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

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

Да, компьютеры чрезвычайно быстры, но только если вы пишете ПО таким образом, что оно хорошо взаимодействует с железом. На одном и том же оборудовании вы может работать и очень плавная 3D-игра и заметно лагающий MS Word. Очевидно, что проблема здесь не в оборудовании и что мы можем выжать из него гораздо больше, чем среднестатистическое приложение.

Совершенствовалось оборудование, но не языки


Ещё одна проблема современных языков программирования в том, что они недалеко ушли от программирования в стиле C. Не скажу, что есть что-то плохое в написании циклов for, но становится всё сложнее и сложнее использовать весь потенциал процессоров и в то же время управлять сложностью современных программ.

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

Объяснение будет долгим, но давайте начнём с примера:

Представьте, что мы симулируем колонию муравьёв. Административный отдел колонии был уничтожен атакой муравьеда, поэтому он не знает, сколько муравьёв-воинов осталось живо в колонии.

Поможем нашему муравью-администратору посчитать муравьёв-воинов!

Вот пример того, как большинство программистов, включая и меня, писало бы код решения этой задачи. Он написан на типичном объектно-ориентированном энтерпрайзном уничтожителе кэша:

class Ant {    public String name = "unknownAnt";    public String color = "red";    public boolean isWarrior = false;    public int age = 0;}// shh, it's a tiny ant colonyList<Ant> antColony = new ArrayList<>(100);// fill the colony with ants// count the warrior antslong numOfWarriors = 0;for (Ant ant : antColony) {    if (ant.isWarrior) {         numOfWarriors++;    }}

Представленное решение компактно и понятно. К сожалению, на современном оборудовании производительность его невысока.

Каждый раз, когда мы запрашиваем байт памяти, отсутствующий в одном из кэшей процессора, из основной памяти запрашивается вся строка кэша, даже если нам нужен только 1 байт. Так как получение данных из основной памяти довольно затратная операция (см. выше таблицу задержек), нам бы хотелось, чтобы количество таких операций было минимальным. Этого можно достичь двумя способами:

  1. Уменьшив объём данных, которые нужно получать для нашей задачи.
  2. Храня необходимые данные в соседних блоках, чтобы полностью использовать строки кэша.

В приведённом выше примере мы будем считать, что из памяти запрашиваются следующие данные (я предполагаю, что используются compressed oops; поправьте меня, если это не так):

+ 4 байта на ссылку имени
+ 4 байта на ссылку цвета
+ 1 байт на флаг воина
+ 3 байта заполнителя
+ 4 байта на integer возраста
+ 8 байт на заголовки класса
---------------------------------
24 байта на каждый экземпляр муравья


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

Если учесть, что в современных процессорах строка кэша имеет размер 64 байта, то мы можем получать не больше 2,6 экземпляра муравьёв на строку кэша. Так как этот пример написан на языке Java, в котором всё это объекты, находящиеся где-то в куче, то мы знаем, что экземпляры муравьёв могут находиться в разных строках кэша. [Если распределить все экземпляры одновременно, один за другим, то есть вероятность, что они будут расположены один за другим и в куче, что ускорит итерации. В общем случае лучше всего заранее распределить все данные при запуске, чтобы экземпляры не разбросало по всей куче, однако если вы работаете с managed-языком, то сложно будет понять, что сделают сборщики мусора в фоновом режиме. Например, JVM-разработчики утверждают, что распределение мелких объектов и отмена распределения сразу после их использования обеспечивает бОльшую производительность, чем хранение пула заранее распределённых объектов. Причина этого в принципах работы сборщиков мусора, учитывающих поколения объектов.]

В наихудшем случае экземпляры муравьёв не распределяются один за другим и мы можем получать только по одному экземпляру на каждую строку кэша. Это значит, что для обработки всей колонии муравьёв нужно обратиться к основной памяти 100 раз, и что из каждой полученной строки кэша (64 байта) мы используем только 1 байт. Другими словами, мы отбрасываем 98% полученных данных. Это довольно неэффективный способ пересчёта муравьёв.

Ускоряем работу


Можем ли мы переписать нашу программу так, чтобы запросы к памяти были более рациональными, и таким образом повысить производительность программы? Да, можем.

Мы используем максимально наивный Data Oriented Design. Вместо моделирования муравьёв по отдельности мы смоделируем целую колонию за раз:

class AntColony {    public int size = 0;    public String[] names = new String[100];    public String[] colors = new String[100];    public int[] ages = new int[100];    public boolean[] warriors = new boolean[100];    // I am aware of the fact that this array could be removed    // by splitting the colony in two (warriors, non warriors),    // but that is not the point of this story.    //     // Yes, you can also sort it and enjoy in an additional     // speedup due to branch predictions.}AntColony antColony_do = new AntColony();// fill the colony with ants and update size counter// count the warrior antslong numOfWarriors = 0;for (int i = 0; i < antColony_do.size; i++) {    boolean isWarrior = antColony_do.warriors[i];    if (isWarrior) {        numOfWarriors++;    }}

Эти два примера алгоритмически эквивалентны (O(n)), но ориентированное на данные решение превосходит по производительности объектно-ориентированное. Почему?

Потому что ориентированный на данные пример получает меньше данных, и эти данные запрашиваются соседними блоками мы получаем 64 флагов воинов за раз и не тратим ресурсы впустую. Так как мы получаем только нужные нам данные, нам достаточно обратиться к памяти всего два раза, а не по разу для каждого муравья (100 раз). Это намного более эффективный способ подсчёта муравьёв-воинов.

Я выполнил бенчмарки производительности при помощи тулкита Java Microbenchmark Harness (JMH), их результаты показаны в таблице ниже (измерения выполнялись на Intel i7-7700HQ с частотой 3,80 ГГц). Чтобы не загромождать таблицу, я не указал доверительные интервалы, но вы можете выполнить собственные бенчмарки, скачав и запустив код бенчмарка.

Задача (размер колонии) ООП DOD Ускорение
countWarriors (100) 10 874 045 операций/с 19 314 177 операций/с 78%
countWarriors (1000) 1 147 493 операций/с 1 842 812 операций/с 61%
countWarriors (10000) 102 630 операций/с 185 486 операций/с 81%

Сменив точку зрения на решение задачи, нам удалось добиться огромного ускорения. Стоит также учесть, что в нашем примере класс муравьёв довольно мал, поэтому данные с большой вероятностью останутся в одном из кэшей процессора и разница между двумя вариантами не так заметна (согласно таблице задержек, доступ к памяти из кэша в 30-100 раз быстрее).

Даже несмотря на то, что представленные выше примеры написаны в свободном стиле всё является публичным, ориентированное на данные проектирование не запрещает использовать корпоративный подход со строгим контролем. Вы можете делать любые поля private и final, создавать геттеры и сеттеры, реализовывать интерфейсы, если вам так нравится.

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

Где есть один, будет несколько.

Майк Эктон

Но постойте! Почему ООП настолько популярно, если имеет такую низкую производительность?

  1. Нагрузка часто зависит от ввода-вывода (по крайней мере, в бэкенде серверов), который примерно в 1000 раз медленнее доступа к памяти. Если вы записываете много данных на жёсткий диск, то улучшения, внесённые в структуру памяти, могут и почти не повлиять на показатели.
  2. Требования к производительности большинства корпоративного ПО чудовищно низки, и с ними справится любой старый код. Это ещё называют синдромом клиент за это не заплатит.
  3. Идеи в нашей отрасли движутся медленно, и сектанты ПО отказываются меняться. Всего 20 лет назад задержки памяти не были особой проблемой, и best practices пока не догнали изменения в оборудовании.
  4. Большинство языков программирования поддерживает такой стиль программирования, а концепцию объектов легко понять.
  5. Ориентированный на данные способ программирования тоже обладает собственным множеством проблем.

Проблемы: допустим, нам нужно найти, сколько в колонии красных муравьёв возрастом старше 1 года. В объектно-ориентированном подходе эта задача решается довольно просто:

long numOfChosenAnts = 0;for (Ant ant : antColony) {    if (ant.age > 1 && "red".equals(ant.color)) {        numOfChosenAnts++;     }}

При ориентированном на данные подходе всё становится чуть более сложным, ведь нам нужно быть аккуратными с тем, как происходит итерация по массивам (нужно использовать элемент, расположенный по тому же индексу):

long numOfChosenAnts = 0;for (int i = 0; i < antColony.size; i++) {    int age = antColony.ages[i];    String color = antColony.colors[i];    if (age > 1 && "red".equals(color)) {        numOfChosenAnts++;    }}

А теперь представьте, что кому-то нужно отсортировать всех муравьёв в колонии на основании их имени, а затем что-то сделать с отсортированными данными (например, посчитать всех красных муравьёв из первых 10% отсортированных данных. У муравьёв могут быть странные правила, не судите их строго). При объектно-ориентированном решении мы можем просто использовать функцию сортировки из стандартной библиотеки. При ориентированном на данные способе придётся сортировать массив имён, но в то же самое время сортировать все остальные массивы на основании того, как перемещаются индексы массива имён (мы предполагаем, что нам важно, какие цвет, возраст и флаг воина связаны с именем муравья). [Также можно скопировать массив имён, отсортировать их и найти соответствующее имя в исходном неотсортированном массиве имён, чтобы получить индекс соответствующего элемента. Получив индекс элемента в массиве, можно делать с ним что угодно, но подобные операции поиска выполнять кропотливо. Кроме того, если массивы большие, то такое решение будет довольно медленным. Понимайте свои данные! Также выше не упомянута проблема вставки или удаления элементов в середине массива. При добавлении или удалении элемента из середины массива обычно требуется копировать весь изменённый массив в новое место в памяти. Копирование данных медленный процесс, и если не быть внимательным при копировании данных, может закончиться память. Если порядок элементов в массивах не важен, можно также заменить удалённый элемент последним элементом массива и уменьшить внутренний счётчик, учитывающий количество активных элементов в группе. При переборе таких элементов в этой ситуации мы, по сути, будем перебирать только активную часть группы. Связанный список не является разумным решением этой задачи, потому что данные не расположены в соседних фрагментах, из-за чего перебор оказывается очень медленным (плохое использование кэша).]

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

Пока единственным языком программирования с поддержкой подобных безумных преобразований данных является JAI, но, к сожалению, он пока находится в состоянии закрытого бета-тестирования и недоступен широкой публике. Стоит поразмыслить, почему такая функциональность не встроена в наш современные языки программирования.

Best practices


Если вы когда-нибудь работали в энтерпрайзе и засовывали нос в его кодовую базу, то, вероятнее всего, видели огромную кучу классов с множественными полями и интерфейсами. Большинство ПО по-прежнему пишут подобным образом, потому что из-за влияния прошлого в таком стиле программирования достаточно легко разобраться. Кроме того, те, кто работает с большими кодовыми базами естественным образом тяготеют к знакомому стилю, который видят каждый день. [См. также On navigating a large codebase]

Для смены концепций подхода к решению задач требуется больше усилий, чем для подражания: Используй const, и компилятор всё оптимизирует! Никто не знает, что сделает компилятор, и никто не проверяет, что он сделал.

Компилятор это инструмент, а не волшебная палочка!

Майк Эктон

Я написал эту статью потому, что многие программисты считают, что нужно быть гением или знать тайные компиляторные трюки, чтобы писать высокопроизводительный код. Чаще всего достаточно простых рассуждений о данных, движущихся в вашей системе. [Это не значит, что код всегда будет прост и лёгок для понимания код эффективного умножения матриц довольно запутан.]. В сложной программе сделать это не всегда просто, но и этому тоже должен узнать каждый, если хочет потратить время на изучение подобных концепций.

Если вы хотите больше узнать об этой теме, то прочитайте книгу Data-Oriented Design и остальные ссылки, которые приведены в статье в квадратных скобках.

[БОНУС] Статья, описывающая проблемы объектно-ориентированного программирования:
Data-Oriented Design (Or Why You Might Be Shooting Yourself in The Foot With OOP).
Подробнее..

Перевод Дискретный арктангенс в процессоре NES

06.11.2020 10:14:52 | Автор: admin

Геймплей моей игры Star Versus основан на поворотах. Объекты отслеживают направление, в котором они смотрят, и на каждом цикле движка двигаются в этом направлении. В такой ситуации требуется много тригонометрии, большую часть которой можно ради эффективности вычислить заранее, однако иногда это невозможно. В частности, паре геймплейных элементов нужно находить арктангенсы, и делать это быстро.


Определение арктангенса: в прямоугольном треугольнике arctan вычисляет один из непрямых углов, используя в качестве входных данных длину стороны, противоположной этому углу, разделённую на длину прилежащей стороны. В случае Star Versus сторонами треугольника являются расстояния X/Y между двумя объектами, например, снарядом и кораблём, а угол это направление, в котором должен двигаться первый, чтобы достичь второго.



Поначалу арктангенс нужен был для того, чтобы понять, когда один корабль ударяет по другому своим мечом. Мечи не имеют собственной информации о коллизиях, из-за необходимости анимаций они существуют исключительно как артефакты рендеринга. Поэтому они используют код, распознающий коллизии между двумя кораблями с увеличенным хитбоксом для учёта радиуса удара меча. Это позволяет движку экономить драгоценное время процессора, вычисляя дельты X/Y всего один раз. Однако для этого нужно гарантировать, что размахивающий мечом смотрит в нужном направлении, а не совершает удар в сторону от цели, и здесь нам пригождается арктангенс.

Реализация


Важным упрощением в Star Versus является то, что направление не непрерывно, а дискретно. Объекты могут двигаться только в 24 возможных направлениях. Перемещение вправо соответствует 0, вверх 6, влево 12, а вниз 18. Каждый инкремент направления представляет собой угол в / 24 радиан. ( это фундаментальная константа окружности, равная 6.2831853)

После выполнения кода распознавания коллизий у нас уже есть дельты позиций X/Y для функции arctan. Нам достаточно одних знаков, чтобы понять, в каком из четырёх квадрантов находится результат, поэтому остальная часть кода должна выяснить при помощи абсолютных значений дельт, какое из 6 направлений квадранта является правильным.

Процессор 6502 слишком медленный, чтобы вычислять arctan при помощи стандартных способов, например рядов Тейлора, но поскольку результат должен быть правильным в рамках / 24, мы можем сжульничать и использовать аппроксимацию. В целом план заключался в том, чтобы сначала найти соотношение X/Y, затем представить набор линий, разделяющих пространство в соответствии с возможными направлениями, затем найти наклон этих линий, и сравнить соотношение, чтобы понять, к какому из направлений угол ближе всего.


Нам нужно быть внимательными к тем углам, которые равномерно делят пространство на области, окружающие направления, которые должен возвращать arctan. Это /48, 3/48, 5/48, 11/48. Тангенс каждого из них равен:

tan( 1 * / 48) = 0.131652497587
tan( 3 * / 48) = 0.414213562373
tan( 5 * / 48) = 0.767326987979
tan( 7 * / 48) = 1.30322537284
tan( 9 * / 48) = 2.41421356237
tan(11 * / 48) = 7.59575411273

Поскольку у процессора 6502 нет команд умножения и деления, дробные значения нежелательны. Однако у него есть битовые сдвиги, с помощью которых можно малозатратно делить или умножать на 2. К счастью, три значения тангенсов выше диагонали довольно близки к 1.25, 2.5 и 7.5, а эти значения довольно легко найти при помощи битовых сдвигов [1]. Другие углы ниже диагонали просто являются их отражениями, поэтому мы можем найти их, поменяв местами X и Y.



Сравнивая соотношение X/Y с этими значениями, мы получим номер области от 0 и 3. Будет ли эта область находиться над диагональю, зависит от того, поменяли ли мы местами X и Y. Вот псевдокод алгоритма:

small, large = x, yif y < x:    small, large = y,xhalf = small / 2// compare to 2.5 slopeif small * 2 + half > large:    // compare to 1.25    quarter = half / 2    if small + quarter > large:        region = 1    else:        region = 0else:  // compare to 7.5  if small * 8 - half > large:    region = 3  else:    region = 2// Use region, whether X/Y were swapped, and quadrant in a lookup table.

Полный код на ассемблере выложен здесь вместе с юнит-тестами, использующими nes_unit_testing.

Применение


После реализации arctan он применяется во всех аспектах игры. Кроме коллизий мечей нужно также рассчитывать движение бомб, которые должны попадать в свои цели. Для этого они периодически проверяют направление в сторону движения к другому кораблю, и изменяя его, если отклонились от курса [2]. Почти так же arctan используется при стрейфе по кругу, поворачивая корабль в направлении другого корабля, пока он движется вбок, создавая круговое движение. Хотя реализация arctan неидеальна, на практике погрешность достаточно мала, поэтому самонаведение бомб и круговой стрейф работают хорошо.

При создании кампании на одного игрока arctan очень пригодился для создания вражеского ИИ. В базовом поведении врагов присутствует стрельба по игроку, и arctan определяет, как это нужно делать. У разных врагов есть различные паттерны разлёта пуль, но все они основаны на arctan.



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

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

Примечания


[1] Умножение меньшего числа вместо деления большего имеет и ещё одно полезное преимущество оно позволяет избежать числовых искажений, снижая вероятность ошибок.

[2] Самонаводящиеся снаряды не меняют своё направление в каждом кадре и не меняют его, если слишком отклонились от цели. В противном случае они были бы слишком точными, что отрицательно сказалось бы на игровом балансе.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru