Казалось бы, совершенно непонятно, зачем живым людям в 2021 году решать задачу под названием «печатаем обычное вещественное число». Вроде бы это должно быть уже решено — причём примерно в тот момент, когда эти вещественные числа изобрели. Но оказывается, что нет.
Привет, меня зовут Андрей, я занимаюсь инфраструктурой поиска в Авито и сегодня расскажу, зачем это вообще нужно — печатать вещественные числа. Какие есть методы (один) решения этой боевой задачи и как это получилось у нас в проекте, в рамках наших очень странных требований. А также, зачем таки подобное, хм, умеренно эзотерическое знание, может когда-то понадобиться и вам. На каком бы вы языке не писали. Read on!
Почти во всех языках, почти в любом методе (вывода текста) и почти на всех платформах, рано или поздно, мы докопаемся до задачи распечатать вещественное число. И использовать мы будем при этом float или double (32 или 64 битный). С вероятностью 90% мы обязательно провалимся до glibc и вызова printf(). Выглядеть это будет примерно так:
Если мы просто %f напишем, а не %.6f, это не поменяет вообще ничего. Результат будет тот же. Для особо въедливых скажу, что версии компилятора и библиотеки, конечно, важны, но не в этом случае. К сожалению, это нормально: printf устроен и работает именно так.
Думаете, что это не про вас? Зря. Даже если вы пишете на Python, Perl, PHP, Golang, Node.JS и местами CSS в HTML устно декодируете, этот кейс всё равно про вас. Рано или поздно вы обязательно рискуете нарваться на printf(), который дойдет до вас с самого нижнего уровня, от сишной библиотеки. Например, так выглядит современный Perl:
Кроме распечатки есть ещё условный scanf (либо scanf(), atof() или strtod()). Это не совсем «сканирование», а обратная конвертация из текстовой строки в тот самый 32-битный либо 64-битный float, который давно стандартизирован и аппаратно везде поддерживается.
Эта плеяда функций в любой современной С++ библиотеке реализована одинаково — есть некая внутренняя функция, которая делает основную конверсию из строчки во float, а внутри будет всё равно исполняться один и тот же код. Только немного меняется снаружи интерфейс вызова: одна функция — чтобы сканировать по какому-то шаблону, другая — чтобы нуль-терминированную строку трансформировать не во float, а в double.
Вы снова думаете, что это всё не про вас? Скорее всего, зря. Потому что трансформация строки во float почти во всех языках рано или поздно провалится куда-то в дебри той самой сишной библиотеки.
Assert и три вопроса
Предполагаю, что всем тестировщикам должно быть сразу понятно, о чём речь, но, тем не менее, переведу на русский.
assert(atof(zprintf(x)) == x)
Возьмём какую-то функцию zprintf, которая берёт число и возвращает строку. Именно z, потому что sprintf возвращает не строку, а абсолютно бесполезную информацию — то ли количество напечатанных аргументов, то ли длину буфера. Я им (возвращаемым из sprintf значением) никогда не пользуюсь и, честно говоря, не помню.
Вопрос №1: мы берём какое-то число x, печатаем его в строку, после чего используем условный printf внутри zprintf с точностью до сигнатуры (впрочем, можем и библиотечный sprintf использовать), а потом конвертируем его обратно в число. Сойдутся ли у нас результаты, желательно побитово?
assert(zprintf(atof(s)) == s)
Вопрос №2: такой же, но в обратную сторону. Берём некую строку, в которой предположительно число в корректной форме, конвертируем эту строку в число, после чего число обратно распечатываем. Сойдётся или нет?
strlen(zprintf(x)) хоть минимальна?
Вопрос №3: а если мы printf просто дёрнем с неким стандартным форматом типа %f или %g, то длина строки, которую нам выдал стандартный printf — будет минимальной возможной или нет? Или он туда чепуху какую-то допишет?
Понятное дело, что как только мы число в строку печатаем, в неё можно много нулей в конец приписать, по меньшей мере после точки. А если нет точки, можно приписать точку, после которой приписать много нулей. Но длина от этого не будет минимальной.
Если немного задуматься над этими вопросами и, не дай бог, после этого ещё проделать какие-то тесты, вскрывается бездна. Оказывается, что несмотря на то, что почти всегда эти штуки работают ожидаемо, даже на float можно умудриться сделать так, чтобы scanf() или atof() от обычного printf дали не такой результат, как мы ожидаем.
Нет, речь даже не идёт о каких-то странных или специальных конструкциях как signaling not-a-number. Иногда мы можем получить чушь на обычных числах, на обычных floats. То есть печатаешь, например, 0.1234567, а оно в последнем разряде ошибается, потом сканирует обратно «не так», и так далее. Таких ошибок немного, но они есть.
Почему так?
IEEE754, biatch
Когда в 1985 году приняли стандарт IEEE754, то в него зашили вполне понятную и логичную структуру вещественного числа:
float = 1:8:23, double = 1:11:52
Магические константы 1, 8, 23, суммирующиеся в 32 (и 1, 11, 52, суммирующиеся в 64) значат, что мы из 32 бит отдаем:
-
1 бит на знак;
-
8 бит на степень, которая от -127 до +126 (неожиданно +127 зарезервирован на что-то ещё);
-
23 бита на так называемую мантиссу, некоторую дробную часть.
При этом мантисса приписывается к единичке. То есть, даже если в нижних 23 битах стоят все нолики, тем не менее, это не всегда ноль, нам надо вычислить математически float из этой битовой структуры. Получается, компьютер десятичную дробь переводит в двоичную, и из-за этой конвертации у нас и происходят ошибки округления, по существу.
Второй важный момент: во float вообще довольно немного точности, он весь 32-битный. А всего 23 явных бита в мантиссе дают, с учетом неявной единички перед мантиссой, 224 разных возможных варианта точного заполнения нижних бит. Поэтому в фокусе printf("%f", 16777217.0f) число 16777217 — это именно 224 + 1, оно не случайное какое-то.
И это уже интересная головоломка, потому что напечататься может по-разному. Самое типичное — округление вверх и мы получаем 16777218, хотя иногда это может быть 16777216. Но вы гарантированно никогда не увидите в конце цифру 7, потому что на неё не хватит точности. Как и в предыдущем примере с 123.456001, где разрядов хоть и много, но их тоже начинает не хватать.
Проблема
Неизбежная потеря точности — это, как ни странно, не совсем проблема именно потому, что она неизбежна. Проблемой является то, что, во-первых, нет roundtrip — в обе стороны. Иными словами, можно подобрать такое число, напечатав которое и потом отпарсив результат, вы получите другое число.
Во-вторых, иногда проблемой является то, что нет минимальности. Вы печатаете 1.2, а оно вам печатает 1.2 и много ноликов на конце. И даже в языке Си, несмотря на то, что его уже давно разрабатывают, нет никаких специальных модификаторов в этом самом printf.
А в третьих, оно ещё и тормозит, особенно в некоторых краевых случаях, когда туда передали не совсем обычное число, а некоторые спецзначения. Например, так называемые денормализованные (denormal) числа, или два вида бесконечности (±∞), или разные виды NaN, внутри которых есть ещё некие номера, чтобы коды ошибок передавать. Хотя последнее (коды ошибок) обычно не используется, потому что почти никто NaN до сих пор полноценно не поддерживает.
Я протестировал миллион printf. Хотя нельзя просто взять и посчитать, сколько стоит 1M printf, потому что это очень сильно зависит от того, какие конкретно значения вы печатаете. Тупо равномерно генерировать их обычно нельзя, потому что 90% того, что мы равномерно выбрали из какого-то своего диапазона, например, от 1 до 100 млн (а возможный диапазон float куда больше!), попадет в диапазон от 10 000 000 до 999 999 999 включительно. То есть гигантское количество этих равномерно распределенных чисел окажется «длинным», и вместо равномерных длин эффективно протестируется самый «длинный» вариант из тех, которые у нас есть.
В общем, какая-то экстенсивная бенчмарк-сюита для float — это довольно много возможных (в разных приложениях) вариантов распределения чисел, но я ограничился несколькими простыми. Можно генерить равномерную экспоненту или «короткие» int’ы от 1 до миллиона или 10 миллионов. Можно генерить достаточно коротенькие дроби в диапазоне от 0 до 1, или наоборот, супер-маленькие числа в диапазоне от 1-32 до 1-38. Эти варианты я и смешал. И, конечно, надо отметить, что на этот «бенчмарк на миллион» будут влиять OS и язык, а после них — стандартная библиотека.
Так как между процессорами достаточно современной мобилы и сервера мы уже не увидим отличий частоты на порядок, то я просто на ноутбуке (3 Ггц, под Виндой, Visual Studio) намерил несколькими разными тестами порядка 250-280 msec / 1M printf.
Казалось бы, не такая уж гигантская скорость, но и так сойдёт. Но один миллион printf для нас — не такая уж редкая задача. Представьте, что мы экспортируем жалкие 10 миллионов объектов, в которых внутри json на тысячу разных float значений. Или даже проще — json, внутри которых какой-нибудь всего один вектор embedding’ов, зато на 1024 компоненты. И вот на такие задачи мы вдруг потратим 2500 или более секунд — почти час! — чтобы всего лишь отформатировать 1024D вектора. Конечно, вектора большие, но это не так много данных, чтобы мы целый час их форматировали и всё это время тормозили в printf.
Как решать?
Неужели человечество ничего не придумало за последние 50 лет, чтобы побороть эту проблематику? Как выяснилось — нет, почти все алгоритмы являются так или иначе вариациями на тему «Дракона». Про драконье семейство я расскажу чуть позже, потому что сначала я проверил, могу ли я написать код сам.
Можно ли рукой?
Можно, но не нужно. Потому что int напечатать довольно просто, это абсолютно тривиальная задача. Но напечатать float оказалось непросто от слова совсем. Это и не работает как следует, и тормозит неплохо.
Первой наивной реализацией, которая мне пришла в голову, было сделать так же, как с int — берём и что-то печатаем помаленьку, пока очередной разряд из нашего числа не вылезет. У меня получилось так:
Как ни странно, этот код работает для некоторых значений. Проблема только в том, что он работает: а) только для некоторых (не всех!) значений, и б) термоядерно медленно просто, за счет того, что делает массу глупых и ненужных операций с float.
Но хуже то, что у этого кода плохо вообще всё. И операции очень медленные (ceil, log, pow). И минус мы никак не обрабатываем, так же как и числа меньше 1 (например, 0.0123). И также не обрабатываем специальные случаи (den, inf, nan). И — главное! — печатаем мусорок.
Мой код распечатал такое число: 123.45600128173828125, то есть массу бреда. Даже самый плохой printf такого не печатает никогда, printf %.6f хотя бы ограничится лишними 001. Понятное дело, если мы то же самое сделаем — например, в цикле вставим ограничение не на 32 разряда, а на p+6 — то получим тот же самый результат, но у нас появятся дополнительные усложнения.
Почему так получается, откуда весь этот ад лезет? Это довольно интересный момент, я долго думал, втыкать его в статью или нет, но всё-таки решил воткнуть.
Он вам не мусорок!
Он реально выдает вам абсолютно математически точное значение, которое мы на самом деле и храним: 123.45600128173828125. Обозначим его как V. За счет того, что у нас любое float число представляется как достаточно длинная, но конечная двоичная дробь, у нас и будет такая адская простыня.
Но дальше — хуже. Мы же технически не можем точно представить в рамках модели 32-битного float все доступные значения десятичных дробей. Мы можем точно сохранить само значение V. Мы можем перед ним сохранить значение 123.455989360809326171875 (обозначим его P), если один битик в мантиссе на единицу уменьшим. И можем сохранить 123.456013202667236328125 (обозначим его N) — после него. Подчеркиваю важный момент — мы никогда точным образом не можем сохранить значение типа 123.45599, то есть какую-то «короткую» десятичную дробь. Мы вот можем сохранить только значение P, потом V (посередине), а затем N — previous, value, next.
Дальше встает вопрос: получается, что мы не можем сохранить точно никакое значение, а любые промежуточные значения должны куда-то прибить. Конечно, довольно логичный вариант — «snapping» к ближайшему минимальному значению.
И как это делать? Очевидно, x ∈ [(v + p)/2, (v + n)/2) вполне работает. Но получается, что абсолютно любые числа, которые от (v + p)/2 до (v + n)/2 — это числа, и как их ни округляй, нормальный scanf или atof должен их привести всё равно к V.
То есть если любой printf (как и любой другой метод или вызов для печати float), выберет любое из всех этих возможных чисел в этом диапазоне — неважно как выберет, хоть рандомом — он сделает нечто вполне корректное. Да, в нижних разрядах от вызова к вызову он будет выдавать натурально полный мусор (при выборе рандомом-то!) и, тем не менее, это будет корректно. Потому что этот мусор будет генериться в незначащих разрядах, а потом, при дальнейшей обработке, при парсинге обратно, мы его проигнорируем.
Но, понятное дело, этим заниматься неохота, а хочется напечатать самую короткую версию. Здесь это — 123.456. И если внимательно посмотреть, то между 123.456 и 123.456001 (который выдал printf), нет никаких отличий. Вот какой метод округления при парсинге этого числа не выбери, 001 в хвосте нет никакого смысла писать. Если мы выберем метод округления по самому ближнему значению, то округлимся до исходного V. Если окажется посередине — опять округлимся к нему.
Почему я на этом заостряю внимание? Получается, что printf, когда напечатал лишний 001, сделал полную дурость. Может, просто из простоты реализации, но он сильно некомпактно представил число, напечатав 3 мусорных разряда.
Так я внезапно изобрел требование «компактности / минимальности / полноты», как это называют в научных работах. Полнота определяется «как для всего диапазона возможных значений мы всегда даем абсолютно минимальную строчку на выходе». А компактность в первом приближении можно вычислить следующим образом. Во float, честно говоря, бывает минимум 6 значащих разрядов, максимум 9. Если просто ограничиться всегда распечаткой 9 разрядов для float и 18 разрядов для double, то нам этого абсолютно всегда хватит.
Тем не менее вывод очевиден: писать код руками смысла нет. Как видите, это вовсе не распечатка int, это довольно непросто, так как нужно обработать много разных пограничных случаев и нужно умудриться это сделать быстро. Но люди думают за нас!
Семейство драконов
К несчастью, такое впечатление, что printf (и всё, что поверх него) застыл в развитии с 1991 года, даже на достаточно свежих компиляторах (точнее, библиотеках). Ещё тогда некий Стил, а вслед за ним некий Гей (это фамилия) изобрели пару каноничных реализаций: Dragon4 и dtoa.c. Я написал выше 12 строк кода, и он обработал только один случай, который уже немалую часть возможных значений, на самом деле, закроет. Но чтобы обработать всё остальное, парням в канонической реализации 30 лет назад потребовалось 6000 строк — это довольно много.
Как ни странно, исследования в 1991 году не остановились, и многие придумали другие алгоритмы распечатки float. Например, Grisu [Loitsch10], Ryu [Adams18] и erthink [Yuriev18]. Интересно, что названия происходят от имен известных драконов, так как слово Dragon уже занято, но по-другому эти алгоритмы не назвать.
Естественно, на практике победил самый проверенный временем алгоритм — это Grisu. На самом деле это даже не один, а целое семейство алгоритмов. Статья 2010 года описывает не один алгоритм, а три одновременно: один — базовый proof of concept, а дальше уже варианты под названием версия 2 и версия 3, которые можно и в продакшен использовать. Эти алгоритмы внедряют либо ручками в разные проекты типа RapidJSON (2015), либо через библиотеку fmtlib (2019).
Сишная библиотека fmtlib — это продвинутая замена printf, scanf и прочих. Через это она протекла в массу разных проектов, часть из которых вы можете знать. Те, что я знаю, это Ceph, ClickHouse, Kodi, MongoDB. И, наконец, 30 декабря 2020 года закончились 11-летние мытарства C++20 по сертификации. Вместе с ним внесён в стандарт и std::format, входящий в C++20, который по большому счету на fmtlib и основан.
Замечу, что в семействе Grisu есть маленький нюанс для ценителей. Когда вы распечатываете double, то примерно в половине процентов случаев оценочно он не минимален, а печатает 1-2 лишних разряда. То есть всё-таки печатает не 123.456, а 123.4560, например, или ещё хуже, 123.45601. Иногда бывает, с кем не случается.
Казалось бы, задача решена — мы погрузились в ад, но быстро вынырнули. У нас есть несколько алгоритмов и готовых библиотек, плюс есть ещё готовый доступный код (Grisu, Ryu, erthink), который можно взять и использовать. Для Grisu есть несколько реализаций, например, в RapidJSON, в fmtlib и ещё много где. Даже в Swift своя собственная реализация, очень похожая именно на Grisu.
Причём разработчики алгоритмов смотрят в довольно логичном порядке на требования:
Но у нас в Авито при реализации без осложнений не обошлось, и мне не удалось просто взять и использовать библиотеки совсем без изменений.
У нас, как обычно, свой путь
Есть два режима чтения того, что мы печатаем. Первый — то, что мы печатаем, читает исключительно машина. В этот момент нам действительно важны только roundtrip и компактность, чтобы поменьше битиков по сети передавать в текстовом виде.
Второй режим наступает в момент, когда напечатанное ещё и человек читает. Поэтому в список требований (Roundtrip, Compactness, Performance) добавилось моё требование под названием читаемость.
Readability
Интуитивно я хотел, чтобы даже простыню float’ов (в JSON в строчку или иконостасом построчно) было бы удобно читать глазами. На это, по-моему, никто особо не смотрит, за исключением, может быть, fmtlib или «пользователей» (программных) fmtlib. Но с ним я справиться (в нужных ограничениях) не смог.
Приведу пример, что я имею в виду под читаемостью. В каждый конкретный момент у вас есть чуть ли не бесконечное количество методов напечатать float. Выборы, которые приходится реально принимать, выглядят так:
В экспоненциальной форме всегда есть больше одного варианта в зависимости от того, где точку поставить, как экспоненту написать, или, в конце концов, напечатать e18 или e+18. При этом e18, в принципе, вполне корректная распечатка, но когда у вас в каком-то дампе сигналов ранжирования достаточно много этих e, то плюсик очень, очень хорошо помогает читаемости.
А ещё есть знаковые NaN, которые, во-первых, отрицательные, во-вторых, сигналящие, а в третьих, внутри к нему ещё приписан payload 1234 — и в стандарте всё занесено именно так. То есть, если в сопроцессоре, который обрабатывает float, произошла какая-то ошибка, то код этой ошибки мы записываем в наш NaN, и прямо в формате float какой-то диапазон таких спецзначений зашит, с кодами ошибок! Если делать всё абсолютно кошерно, то надо как-то и его сохранять. К счастью, для практических целей хотя бы это нужно невыразимо редко.
Поэтому для реализации readability я сформулировал, исходя из своего личного чувства прекрасного, три основных базовых правила:
Первое означает, что пока я, как человек, могу читать достаточно короткое число (например, до трех лишних нулей в «конце» или в «начале»), то я бы хотел оставаться в десятичной форме, а после уже давайте пойдем в экспоненциальную форму.
Во втором требование e+ может показаться странным, но для чтения глазами гораздо удобней, когда плюс прописан явно. Глаз об этот плюсик спотыкается, вы видите экспоненциальную форму и становится намного понятнее, что это за число.
Третье правило тоже может показаться странным. Но для лучшей наглядности с одной стороны и для interoperability «с самим собой», с другой стороны (тут речь про другие немного тонкие места в нашем поисковом движке Sphinx) — я хочу, чтобы даже «целые» числа, сохраненные в вещественном формате, были отформатированы на 2 байта длиннее, чем абсолютно компактно, и в конец дописывать «.0» вот эти.
Помимо требований к результату, у меня ещё было требование под названием «вообще-то я НЕ ХОЧУ писать код!» к самому процессу. Потому что я бы с большим удовольствием вместо printf написал xprintf, чтобы он был у меня в стандартной библиотеке. Увы, «просто взять либу» мне не удалось.
Compact
Плохой новостью для меня оказалось, что компактность (за которую топят все алгоритмы) не всегда совместима с читабельностью. Моё чувство прекрасного и моё ощущение, как оно «читаемо» — радикально другое по сравнению с авторами алгоритмов. Конечно, всегда можно поспорить, но на лично мой взгляд в таком виде легче прочитать число, его не нужно в уме декодировать:
Хорошей новостью для меня стало то, что мне удалось даже с моими странными требованиями воспользоваться библиотеками почти без модификаций.
Кодер обязан быть ленив!
Возможно, я просто плохо справился с {fmt}, но увы, мне пришлось патчить код. И, раз уж пришлось этим заниматься, я задумался также о балансе LoC vs perf, то есть чтобы было не только предельно малое количество строк кода, но и performance заодно улучшить.
В итоге вышло так:
Для float я взял Ryu, просто потому, чтобы она была самая компактная. После того как я вытянул из нее всё нужное и затащил какое-то количество строк под свои патчи, вся реализация влезла в 520 строк. Понятно, что 500 строк от автора Ryu и 20 строк моих. Но мне даже этого делать не хотелось, хоть и пришлось.
Для double я просто выпилил из RapidJSON все нужные мне инклюдики, и написал оберточную функцию буквально на один экран — 50 строчек. После этого зацепил «форматировалку» из RapidJSON. Что интересно, идея с оберткой для float не удалась, потому что нужно было написать 150-200 строк с ужасными макросами, после чего Ryu будет перформить всё равно лучше. Написал черновик, померил и выкинул.
В результате получилось значительно компактнее, чем в канонической реализации — 600 «моих» строк, и всего 55 Кб, а не 150. За патчи, конечно, было довольно страшно, но я просто полным перебором проверил все 4 млрд floats. Все нужные требования выполнялись, roundtrip был, всё хорошо. А с double ничего проверять не стал — в случае чего, прочитаю bug report в RapidJSON и обновлю версию.
Что с perforfmance?
Производительность, конечно, во всей этой эпопее была последним интересующим меня элементом, но если бы код ещё и замедлился, то это было бы совершенно неприемлемо. Тем не менее с performance всё вышло довольно неплохо:
Можно сравнить с бенчмарками RapidJSON, изменений никаких. С float у меня получилось, что читаемость сожрала 20% производительности по сравнению с ванильной реализацией Ryu. Но зато на этих отметках уже не тормозит. Можно вместо патча на 40 строк сделать патч на 200 строк, позаниматься оптимизацией моей ветки кода и за счет этого всего потерять всего 5% производительности, а не 20%. Но этим я уже заниматься не стал, потому что 10 млрд векторов (а не 10 млн) мы всё-таки или не печатаем совсем, или делаем это крайне редко.
Даже с 20% «тормозов» относительно идеала у меня уже получилось порядка 30 мс на миллион вызовов, а не исходных 250-280 мс. То есть профит составил 4-15 раз — разброс зависит от того, какие конкретно числа печатаем (мало разрядов, много разрядов, inf/NaN, и так далее).
Итого, или Что делать простому человеку?
Если возможно, то НЕ будьте как я — это реально была безумная эпопея. Но если всё же хотите, то вот что вам стоит знать.
Во-первых, небесполезно знать про то, что есть roundtrip issues. Что в стандартных библиотеках до сих пор (до 2021 года!) есть кейсы, когда вы печатаете и парсите одно число, а на выходе получаете другое. До сих пор!
Во-вторых, неплохо знать про performance issues. Что, оказывается, можно затормозить на, казалось бы, банальной вещи — мы просто печатаем числа. Человечество давно должно было эту задачу решить! В стандартной библиотеке реализация у вас может тормозить в 10 раз по сравнению со среднепотолочной внешней. А уже если у вас на входе пришли кривые данные, которые парсятся в какие-нибудь NaN, denormal, inf, то стандартная библиотека, особенно неудачная, может тормозить и в сотни раз.
Если вы в что-то подобное упираетесь, и это в-третьих, берите fmtlib или обертку над fmtlib, доступную для вашего целевого языка и вашей среды.
Четвертое: тестируйте не только лишь диапазон 1…10. С float, оказывается, нельзя бездумно взять и проверить равномерный диапазон от 1 до 10 или равномерный диапазон от 1 до 10 млн. С ними довольно много тестов приходится иногда делать на очень разные штуки: «А что, если у нас такая дробь или эдакая дробь? А что, если очень маленькие числа? А что, если очень большие числа?» Сильно погружаться в эти тесты, на мой взгляд, не стоит, но, тем не менее, хотя бы пару-тройку таких кейсов проверить нужно.
И последнее: в особых странных случаях, вроде моих сугубо личных упоротых требований к «читаемости», таки придётся писать код :-P Но я надеюсь, что вам не придётся. Надеюсь, что в конце концов всё закончится просто заменой printf на xprintf, fmtlib или std::format.
Вот так, оказывается, непросто можно распечатать float.
Видео моего доклада на эту тему:
Конференция об автоматизации тестирования TestDriven Conf 2022 пройдёт в Москве, 28-29 апреля 2022 года. Кроме хардкора об автоматизации и разработки в тестировании, будут и вещи, полезные в обычной работе.
Расписание уже готово, а купить билет можно здесь. До повышения цены осталось 19 дней!
Автор: Andrew Aksyonoff