Несколько месяцев назад в новостях всплыла потрясающая статья [переводы на Хабре: один и второй] о Grand Theft Auto Online.
Советую прочитать статью целиком, но если вкратце, GTA Online имела внезапно квадратичную производительность при парсинге большого JSON-блоба (из-за многократных вызовов
strlen
); после устранения этой ошибки время загрузки уменьшилось почти на 70%.Это вызвало оживлённые дискуссии: в этом виноват C? Или, возможно, "web shit"? Или капитализм и его стимулы?
Однако все были солидарны в одном: они бы ни за что не написали подобной глупости.
(Вы уже чувствуете, что надвигается?)
Одним из моих побочных проектов является высокопроизводительная программа для просмотра 3D-моделей под названием Erizo.
Благодаря продуманному коду она открывает 97-мегабайтный двоичный файл STL на Macbook Pro 2013 года всего за 165 миллисекунд. Это потрясающая скорость.
Из соображений совместимости я написал небольшой парсер и для ASCII STL.
ASCII STL — это формат обычного текста с плохой спецификацией, который выглядит вот так:
solid cube_corner
facet normal 0.0 -1.0 0.0
outer loop
vertex 0.0 0.0 0.0
vertex 1.0 0.0 0.0
vertex 0.0 0.0 1.0
endloop
endfacet
facet normal 0.0 0.0 -1.0
outer loop
vertex 0.0 0.0 0.0
vertex 0.0 1.0 0.0
vertex 1.0 0.0 0.0
endloop
endfacet
...
endsolid
Я написал чрезвычайно надёжный парсер, добавив в комментарий такое описание:
/* Самый либеральный парсер ASCII STL: игнорирует всё, кроме
* слова 'vertex', а затем одно за другим считывает три значения float. */
Загрузка ASCII STL всегда казалась немного медленной, но я предполагал, что причина этого в неэффективном текстовом формате.
(Тучи сгущаются.)
За несколько дней произошло несколько событий:
- Впервые за несколько лет я вернулся к старому коду Erizo, чтобы устранить ошибку фокусировки на macOS
- Опубликовали статью про GTA Online
- Из последовавшей дискуссии я узнал, что
парсинг может быть квадратичным из-за многократных вызовов sscanf
- Я заметил, что загрузка ASCII STL была очень медленной.
Вот логи загрузки 1,5-мегабайтного ASCII STL метками времени (в секундах):
[erizo] (0.000000) main.c:10 | Startup!
[erizo] (0.162895) window.c:91 | Created window
[erizo] (0.162900) window.c:95 | Made context current
[erizo] (0.168715) window.c:103 | Initialized GLEW
[erizo] (0.178329) window.c:91 | Created window
[erizo] (0.178333) window.c:95 | Made context current
[erizo] (1.818734) loader.c:109 | Parsed ASCII STL
[erizo] (1.819471) loader.c:227 | Workers have deduplicated vertices
[erizo] (1.819480) loader.c:237 | Got 5146 vertices (7982 triangles)
[erizo] (1.819530) loader.c:240 | Waiting for buffer...
[erizo] (1.819624) loader.c:326 | Allocated buffer
[erizo] (1.819691) loader.c:253 | Sent buffers to worker threads
[erizo] (1.819883) loader.c:258 | Joined worker threads
[erizo] (1.819887) loader.c:279 | Loader thread done
[erizo] (1.821291) instance.c:32 | Showed window
С момента запуска до отображения окна прошло больше 1,8 секунды!
Посмотрев на парсер ASCII свежим взглядом, я увидел, что причина очевидна:
/* The most liberal ASCII STL parser: Ignore everything except
* the word 'vertex', then read three floats after each one. */
const char VERTEX_STR[] = "vertex ";
while (1) {
data = strstr(data, VERTEX_STR);
if (!data) {
break;
}
/* Skip to the first character after 'vertex' */
data += strlen(VERTEX_STR);
for (unsigned i=0; i < 3; ++i) {
SKIP_WHILE(isspace);
float f;
const int r = sscanf(data, "%f", &f);
ABORT_IF(r == 0 || r == EOF, "Failed to parse float");
if (buf_size == buf_count) {
buf_size *= 2;
buffer = (float*)realloc(buffer, buf_size * sizeof(float));
}
buffer[buf_count++] = f;
SKIP_WHILE(!isspace);
}
}
Можно заметить, что в коде есть
sscanf
, считывающая одно значение float из начала потока данных и каждый раз проверяющая длину всей строки.Да, я совершил ту же ошибку, что и программисты, работавшие над GTA Online: написал внезапно квадратичный парсер!
Замена вызова
sscanf
на вызов strtof
снизила время загрузки почти в 10 раз: с 1,8 секунды до 199 миллисекунд.[erizo] (0.000000) main.c:10 | Startup!
[erizo] (0.178082) window.c:91 | Created window
[erizo] (0.178086) window.c:95 | Made context current
[erizo] (0.184226) window.c:103 | Initialized GLEW
[erizo] (0.194469) window.c:91 | Created window
[erizo] (0.194472) window.c:95 | Made context current
[erizo] (0.196126) loader.c:109 | Parsed ASCII STL
[erizo] (0.196866) loader.c:227 | Workers have deduplicated vertices
[erizo] (0.196871) loader.c:237 | Got 5146 vertices (7982 triangles)
[erizo] (0.196921) loader.c:240 | Waiting for buffer...
[erizo] (0.197013) loader.c:326 | Allocated buffer
[erizo] (0.197082) loader.c:253 | Sent buffers to worker threads
[erizo] (0.197303) loader.c:258 | Joined worker threads
[erizo] (0.197306) loader.c:279 | Loader thread done
[erizo] (0.199328) instance.c:32 | Showed window
Это стало идеальным напоминанием о том, что даже если программируешь много лет, ловушки находятся всегда. В
документации sscanf
не указана её временная сложность, поэтому это особо хитрый пистолет для выстрела себе в ногу, и мне кажется, что не один я блуждал во тьме невежества.Возможно, вы сами не столкнётесь с подобным напоминанием, но всякий раз, когда вы будете читать потрясающую историю о плохом коде, помните — это может случиться и с вами!
(Очевидно, мораль истории такова: не используйте
sscanf
для многократного парсинга одиночных токенов из начала строки; уверен, у вас всё будет нормально, если вы просто избежите этого.)На правах рекламы
VDSina предлагает мощные и недорогие VPS с посуточной оплатой. Интернет-канал для каждого сервера — 500 Мегабит, защита от DDoS-атак включена в тариф, возможность установить Windows, Linux или вообще ОС со своего образа, а ещё очень удобная панель управления серверами собственной разработки. Обязательно попробуйте!
Andy_Big
Программист вдруг обнаружил, что сотни тысяч вызовов sscanf() в цикле — это не очень быстро? Или о чем статья?
Mingun
Вызов любой функции сотни тысяч раз в цикле не очень быстро (все конечно зависит от того, что такое "быстро"). Статья о том, что глядя на этот код вовсе не подозреваешь, что здесь что-то не так. В самом деле, нафига вам знать длину строки, чтобы определить, где конец
float
? Думаю, чтение документации на cppreference могло бы предостеречь, ноAndy_Big
Но функция анализа и разбора строки — это же вроде бы очевидно, что прям совсем не быстро :) Это же не функция min().
Весьма спорно. Для меня, например, очевидно, что разбор текстового файла в сотни тысяч строк с вызовом для каждой строки по три раза sscanf() — это пипец как не быстро.
Кроме того: strlen(VERTEX_STR); — серьезно? Каждый раз вызов strlen() для одной и той же константной строки? У меня серьезные сомнения в том, что квалификация этого программиста позволяет ему писать статьи-наставления :)
halfcupgreentea
А компилятор не умеет такое оптимизировать? Или он не знает, что strlen чистая функция?
vkni
Да, согласно godbolt'у в свежих версиях самых распространённых компиляторов при полной оптимизации просто пишет число 7 в регистр. А вот все остальные вызовы остаются на своих местах.
Но это всё равно хрупкость — не будем же мы после любой серьёзной правки каждый раз проверять за компилятором, оптимизировал ли он это место или нет.
slonopotamus
После каждой правки не будем, но за 7 лет как-то можно было найти время разобраться с проблемой, на которую массово жаловались игроки.
foxin
Рискую нарваться на минусы, но: Нет, нельзя было
Поясняю:
уже этого достаточно, чтобы задача на эту штуку (если бы она была раньше) шла бы не выше среднего уровня северити, а то и минор. потому что работает, потому что не постоянно мешает, потому что есть куча других задач, где "игрок не может купить что-то" или "при нажатии на кнопку ничего не происходит, а должно бы", которые точно блокируют часть геймплея.
Но, допустим, что задача была заведена 3 года назад, валялась минором, пришел новый программист, и чтобы ввести его в курс дел — решили на пару недель его отправить на фикс миноров: и с кодом немного познакомится, и с процессами в компании. И вот берет он эту задачу, тратит Х часов, находит и исправляет проблему, проходит ревью, и вот наступил момент — надо решить, в какой из следующих релизов пойдет задача. В ближайший через 1.5 месяца? Нее, там уже код фриз, только крит баги можно вливать. Во второй следующий? Нет-нет, там скоуп уже сформирован, выделены QA и perf-QA, бОльший скоуп они взять не смогут. В третий следующий? Погоди-ка, ты поменял функцию parse_json, так? Но она же еще используется при загрузке метаданных, и при подгрузке списка файлов обновления, и еще в 4 местах. Это надо все проверить, регресс тесты, перф тесты… В тот релиз скорее всего не получится, там будет огромный апдейт, добавляются 5 новых островов, будет не до того. Вот, в 6й следующий релиз будет самое оно, через год и 8 месяцев, там пока не сильно много фичей запланировано.
(спустя два года)
Задача перенеслась еще на 4 версии вперед, но однажды её точно зарелизят...
netch80
Отличный пример, почему feature-based релизы толстых проектов проигрывают time-based релизам :) В time-based, какое из подмножества 100500 правок собралось в те 105, которые попали в конкретную версию, уже не так важно — прогресс идёт своим чередом.
А обновления игры это отличный случай как раз для time-based.
ParAmbula
Иногда бывает очень познавательно скомпилировать программу разными компиляторами и посмотреть на полученный машинный код. Ведь не просто так получилось, что у нас есть несколько компиляторов, а не один.
Tomok
del
mymedia
А что такого? Компилятор даже при отключенных оптимизациях заменяет подобный вызов на константу. В данном случае 7.
Andy_Big
Смотря какой компилятор.
InterceptorTSK
вы может и удивитесь, но даже применение объектов причём якобы быстрых [например структур] — это на самом деле не быстро и нафиг не нужно
максимальная быстрота достигается когда вообще убран всякий оверхед
например у меня есть статическая бд со строками коих там я даже не знаю сколько, десятки миллионов — порядок такой
так вот на шарпе [внезапно!] написан коннектор до этой бд, оттуда дёргаются указатели [внезапно!] а не какие то нахрен не нужные строчки, и даже не массивы, потому что это тоже оверхед
всё потому что эти строчки далее в кол-ве 100-300 шт. комбинируются уже в итоговые строчки, которые как раз таки и нужны на выходе
понятное дело что если тягать всё это на указателях и смещениях без объектов и по сути рулить указателями и собирать через это всё дело итоговые наборы байт — то работать оно будет астрономически быстро
а оно так и работает))) 300 миллионов обращений в мою базу и сборка 10 миллионов строк происходит за секунду на одном ядре
я и память этой базы расшарил если что, несколько коннекторов пользуются всем этим делом враз
причём сам коннектор в общем то тривиален, а вот сборка самих баз для коннектора — это ужас и кошмар, т.е. например 50 таблиц работающие исключительно на смещениях друг от друга это полнейшая жесть, в реализации оного вы погрязните навсегда
такие дела и это реальность
ну а вообще — это всего лишь подготовительный этап для создания ещё более забавных бд, где строк вообще нет) я там буду юзать абстракции, которые являются словоформами из зализняка, однако же это всё в итоге как то в строки превращать нужно и очень быстро, потому и озадачились в общем то тривиальной проблемкой, т.е. как из базы где условный десяток миллионов подстрок собрать строку и эта строка состоит ну например из 100 тыщ этих подстрок из базы
п.с.: не продаётся ни за какие деньги, даже за очень очень страшные деньги, НЕА
vkni
Ну у меня сразу работает ассоциация слова «парсер» с parser/lexer generators, parser combinators. Слова «ASCII» и «97 Mb» — сразу намёк на быстрые генераторы, в частности Ragel, а если и тут скорости не хватает, надо читать статьи про самописные парсеры.
А функции на C работают с локалями => есть хорошие шансы просесть по скорости + получить скрытую проблему когда локаль, скажем, русская с "," вместо ".".
— Я склонен полагать, что просто это место не было когда-то узким. Ну а сейчас, с оптимизацией других мест, этот парсер стал достаточно критичным по скорости. Вообще программа адски быстрая, судя по статье, а формат лишь один из многих поддерживаемых.
netch80
Если локализация явно не активирована чем-то вроде setlocale(LC_ALL, "") — то таких проблем не будет.
Но, да, есть проблема принципиальная: нельзя уточнить локаль для конкретной операции. (ios_base::imbue конечно поможет, но тут другой оверхед.)
littorio
Но здесь sscanf, который как минимум формат должен распарсить. Это всё-таки универсальная функция для разбора строк с указанным любым форматом, возможностью получения нескольких значений разом, и т.п. Она как минимум должна формат-строку (неизменную!) каждый раз парсить, внутри наверняка оверхед для матчинга формата на вход и т.п.
Какой-нибудь специализированный strtof здесь явно удобнее. Просто странно — если программа пишется на расслабоне, с формат-строками, то откуда претензии к скорости? А если заморочились с оптимизацией, руками прописан поиск ключевых слов, все эти SKIP_WHILE(isspace), и т.п. — то и чтение float'а должно быть оптимизированным. И strlen(VERTEX_STR) я бы в константу вынес, от греха.
rozalba
Статья о том, что даже опытный разработчик — все еще человек и может что-то упустить из виду, и что зачастую бутылочные горлышки или некоторые баги очевидными становятся только когда ты их уже нашел, а не когда изначально писал этот код.
Andy_Big
Ну тогда ждем следующих статей о том, что небо голубое, а огонь обжигает :)
foxin
Ну слушайте, если бы вам было 20 лет — вы бы эти статьи читали как детектив.
Было бы 30 — "ну да, надо бы проверить свой код, а заодно на ревью обращать внимание на такое".
Но вам за 40, и такие статьи для вас как "небо голубое".
Оставьте возможность двадцатилетним — учиться, а тридцатилетним — не забывать, что они еще с очень многими вещами не сталкивались =)
XenRE
Что-то упистить из виду или ошибиться может каждый, однако положить болт на очевидные, 100% воспроизводимые проблемы своего приложения, и даже не попытаться выяснить и устранить причины этих проблем может только тот, кому пофиг на результаты своего труда.
foxin
Вы так говорите, как будто никогда не работали в режими аврала.
И вы точно не работали в геймдеве. Если такое случится — вы точно не будете больше никогда оставлять такие комментарии.
XenRE
Проблема известна минимум два года, пруф:
www.reddit.com/r/gtaonline/comments/9vgo0g/how_the_fuck_are_20_minute_load_times_acceptable
За это время не удосужились выделить несколько часов на исправление? А самим разработчикам интересно каждый раз втыкать по 10 минут на лоадинг скрин?
BingoBongo
ждем статью расследование: как я заменил std::vector на std::list для ускорения программы в 1000 раз!
foxin
И это будет хорошо, потому что 99% кейсов у программистов на плюсах — коллекции до пары тысяч элементов, и разницы vector vs list — незаметно, а возможность ошибиться и научиться новому — у них появится только после своей ошибки либо после такой статьи.
BingoBongo