Несколько месяцев назад в новостях всплыла потрясающая статья [переводы на Хабре: один и второй] о 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 или вообще ОС со своего образа, а ещё очень удобная панель управления серверами собственной разработки. Обязательно попробуйте!
Автор: Mikhail