• История одной оптимизации

    Летом мы запустили новый купонный проект BigBuzzy. Таких проектов к тому времени было довольно много и чтобы выделиться из толпы мы решили немного поменять бизнес-модель: вместо одного предложения в день выдавать четыре. Но, как это обычно бывает, аппетит приходит во время еды, поэтому уже спустя несколько месяцев на главной странице красовалось не 4, а 30 предложений.

    И мы сразу же начали получать жалобы о жутких тормозах на главной странице. На поиск и устранение проблем у меня ушло два дня. О том, как находились узкие места и будет сегодняшний рассказ. А заодно научимся пользоваться инструментами вроде Web Inspector’s Timeline (если вы их ещё не освоили).

    Поиск проблемы

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

    01-timers

    Самые большие проблемы наблюдались в Firefox: загрузка процессора на главной странице доходила до 70%. Поэтому я начал рассматривать скрипт таймера под микроскопом, а именно в Web Inspector, который по умолчанию входит в состав браузеров Safari и Chrome. Вообще, многие ребята довольно снисходительно относятся к этому инструменту, продолжая по привычке работать в Firebug’е, а зря. Лично для меня Web Inspector стал основным инструментом для отладки: выглядит он приятнее и содержит ряд полезных нововведений.

    Исследуем узкие места

    Так как сам скрипт таймера довольно простой, то не было смысла заниматься его профилированием — проблема явно где-то в reflow и repaint. Поэтому скрипт нужно исследовать через Timeline:

    02-timeline

    Полагаю, что многие читатели ещё ни разу не сталкивались с этим инструментом, поэтому принцип его работы и поиска проблем опишу в небольшом уроке. Стоит отметить, что Web Inspector в Chrome немного круче, чем в Safari, поэтому рекомендую пользоваться первым браузером.

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

    Открываем шаблон в браузере и запускаем Web Inspector, вкладка Timeline. На странице есть красный квадратик и кнопка «Test». Чтобы начать исследование, нужно нажать на кнопку записи вкладки Timeline 03-rec, а потом нажать на кнопку «Test» в основном окне браузера. Наш квадратик посинел и стал больше по высоте, а в Timeline записались следующие события:

    04-test1

    Первые три записи относятся непосредственно к кнопке, которую нажали: применили псевдо-класс :active (Recalculate style), отобразили изменения на экране (Paint), вернули кнопку в исходное состояние, убрав :active (Recalculate style). После того, как пользователь отпустил кнопку мышки сработало событие click, и именно оно и всё, что ниже, нас будет интересовать.

    Во время клика сработал следующий скрипт:

    function test() {
    	var el = document.getElementById('test');
    	el.style.backgroundColor = 'blue';
    	el.style.height = '100px';
    }
    

    Ничего особенного: просто получили ссылку на элемент и поменяли у него цвет фона и высоту. Этот процесс был отображён на временной шкале: пересчитали стили (Recalculate style), пересчитали геометрию объектов (Layout) и отобразили изменения (Paint).

    Как видите, несмотря на то, что мы поменяли два СSS-свойства, пересчёт стилей произошёл всего один раз. Поменяем скрипт:

    function test() {
    	el.style.backgroundColor = 'blue';
    	var height = el.offsetHeight;
    	el.style.width = '100px';
    }
    

    Между присваиванием новых стилей мы решили получить высоту объекта. Но временная шкала сильно преобразилась:

    05-test2

    Теперь у нас уже два события Recalculate style, а у самого события click появилась группировка (треугольник слева от жёлтой полоски), которая указывает, какие именно события произошли во время клика.

    Этот небольшой пример указывает на две очень важные особенности браузеров — это откладывание перерисовки на момент выхода из функции (первый пример) и существование определённых свойств у элемента, которые принудительно вызывают пересчёт стилей (далее restyle; второй пример). О существовании особых свойств, вызывающих restyle, думаю, многие уже знали: это свойства вроде offsetLeft/Right/Width/Height, clientLeft/Right/Width/Height и так далее. Во втором примере, после установки свойства backgroundColor браузер пометил дерево элементов как требующего пересчёта стилей. А обращение к offsetHeight принудительно вызвало этот пересчёт. Затем мы установили свойство width, которое отложило пересчёт стилей, геометрии и отображения на момент выхода из потока JS-функций.

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

    Для любителей jQuery более красноречивым будет вот такой пример:

    function test() {
    	var e = $('#test');
    	var width = e.css('width');
    	if (width == '50px')
    		e.css('width', '100px');
    
    	var height = e.css('height');
    	if (height == '50px')
    		e.css('height', '100px');
    }
    

    Вот его шкала:

    06-test3

    Как видите, помимо лишнего Recalculate style появился Layout (reflow), что сделало выполнение скрипта более медленным. Если немного оптимизировать, переместив получение высоты выше в коде:

    function test() {
    	var e = $('#test');
    	var width = e.css('width'),
    		height = e.css('height');
    
    	if (width == '50px')
    		e.css('width', '100px');
    
    	if (height == '50px')
    		e.css('height', '100px');
    }
    

    …получим совершенно иную картину:

    07-test4

    Лишний Layout (помимо Recalculate style) объясняется тем, что jQuery каждый раз при получении CSS-свойств вызывал window.getComputedStyle(), который принудительно запускает reflow. Справедливости ради стоит отметить, что в функции jQuery.css() есть оптимизация, которая сначала проверяет наличие запрашиваемого свойства в element.style и если его там нет, вызывает window.getComputedStyle(). Но в любом случае, лучше всегда разделять чтение и изменение свойств.

    Таймеры

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

    function test() {
    	var el = document.getElementById('test');
    	setTimeout(function(){
    		el.style.backgroundColor = 'blue';
    	}, 10);
    	setTimeout(function(){
    		el.style.width = '100px';
    	}, 10);
    }
    

    08-test5

    У обоих таймеров одинаковый период ожидания и момент исполнения. На шкале видно, что после каждого таймера был запущен пересчёт стилей. Но в реальности момент исполнения будет далеко не всегда одинаковым. Поэтому поменяем задержку у последнего таймера — поставим 11 мс вместо 10 мс:

    function test() {
    	var el = document.getElementById('test');
    	setTimeout(function(){
    		el.style.backgroundColor = 'blue';
    	}, 10);
    	setTimeout(function(){
    		el.style.width = '100px';
    	}, 11);
    }
    

    И мы видим, что на шкале появился дополнительный repaint:

    09-test6

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

    Итак, суммируя всё вышесказанное, для оптимизации я

    • разделил чтение и запись CSS-свойств;
    • дополнительно сделал кэширование текущих значений анимации, чтобы меньше обращаться к элементам;
    • заменил несколько таймаутов на один.

    В итоге в Firefox нагрузка на процессор снизилась… всего на 10%. Вообще, это было крайне странно: даже при наличии всего одного анимированного таймера на странице Firefox грузил процессор на 60%, при том что Webkit грузил всего на 5%. Нужно копать дальше.

    Влияние вёрстки на производительность

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

    Так как все restyle и reflow процессы я оптимизировал, проблема явно была где-то в repaint. Вспомнил, что в Firefox 3.5 появилось событие mozAfterRepaint, которое позволяет увидеть области, которые были перерисованы во время repaint. Для удобства было поставлено расширение Firebug Paint Events, которое позволяет отслеживать перерисовки экрана.

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

    10-bbz

    Я специально оставил только 8 из 30 предложений, чтобы картинка не распирала страницу, но смысл, думаю, ясен: во время анимации даже одного таймера перерисовывалось примерно 90% страницы, 15 раз в секунду. И это при условии, что у цифр таймера указан position:absolute, а у их контейнера overflow:hidden. То есть сама анимация по определению никак не могла повлиять на области вне контейнера (на скриншоте обозначен синим прямоугольником), но перерисовывалась почти вся страница.

    Около часа мне понадобилось на то, чтобы найти причину такого странного поведения. Ей оказалось… свойство float:left у одного из контейнеров. Как только я заменял его на float:none нагрузка на процессор падала ниже 10% (с float:left была около 60%).

    Проблема проявляется стабильно, причём не только в Firefox, но и в Opera и IE8. Я сделал простую демку, где можно в живую увидеть эту проблему. В ней всего несколько блоков, однако у них указан box-shadow — очень тяжелое в плане нагрузки на процессор CSS-свойство. В правом верхнем углу есть кнопка, которая всего лишь переключает float у контейнера. Понаблюдайте за нагрузкой на процессор при разных состояниях кнопки, а также за областью перерисовки.

    В общем виде проблему можно описать так:

    Repaint срабатывает на контейнере самого дальнего родителя, у которого указан float:left|right.

    Схематично это выглядит так:

    11-tree

    Причём проблема не только во float. Я перепробовал различные варианты горизонатльной группировки блоков: display:inline-block, display:table-cell, таблицы и даже новомодные flex box — во всех случаях проблема оставалось. Помогало только абсолютное позиционирование боковых блоков.

    В общем виде я проблему решил: поставил боковую панель в коде перед основным контейнером и только ей указал float. Основной контейнер был без float и repaint происходил именно там, где нужно. Однако на живом сайте решить проблему не удалось, так как на большинстве страниц стояли clearfix-элементы, из-за которых макет разваливался. Поэтому пришлось пока отключить анимацию с таймеров 🙁

    ***

    Честно говоря, после таких браузерных крендебобелей на всякие пузомерки типа Peackeeper, которыми так хвастаются разработчики с каждым новым релизом своего браузера, без слёз смотреть не получается. Поэтому мой вам совет: заранее узнавайте о всех интерактивных элементах на странице, не увлекайтесь новым CSS3, продумывайте рост сайта заранее и пользуйтесь правильными инструментами для отладки производительности — тогда будет вам счастье и высокая производительность.

    Если хотите узнать больше о профилировании производительности, очень рекомендую статью Стояна Стефанова на эту тему.

  • 40 комментариев

    1. 30 ноября 2010

      Спасибо за статью, очень познавательно.

    2. Андрей Ситник
      30 ноября 2010

      А зачем было ставить setTimeout на 10мс? 1000мс более чем достаточно для данной задачи — конечно может быть небольшое отставание, но для данной задачи оно не важно.

      Или же в setTimout рисовалась анимация? Ну по-моему, пусть лучше уж jQuery этим занимается, тем более, что она сама организует единый setTimeout на всю анимацию.

    3. Андрей Ситник
      30 ноября 2010

      Кстати, хорошо что убрал анмиацию. Для подобной фоновой анимации slide-эффект не подходит — слишком отвлекает. Тут лучше fade (он отвлекает даже меньше, чем без анимации). Но fade имеет смысл на картинках и больших вещах, а не на маленьких цифрах.

    4. Андрей Ситник
      30 ноября 2010

      Меня что смущает в анимации — при определённых ситуация в WebKit есть проблемы: Safari выключает сглаживание шрифта, а Chrome оставляет шлейф от букв в один пиксель длинной перед самым концом анимации. Тоже наверняка связано с repaint.

    5. 30 ноября 2010

      Поставил багу с картинками и примерами в наш трекер, думаю соответствующие люди заинтересуются. Вот только удивительно почему браузеры столь одинаково реагируют на такую парадоксальную вещь. Те же Opera и Firefox вырастают примерно в 5 раз от состояния покоя по CPU.

      ps: твой WP-Spamfree идиот и дважды отказал мне наличии куков и JS в браузере (Safari 5)

    6. 30 ноября 2010

      А если таймеры показывать из iframe?

    7. 30 ноября 2010

      Отличная статья, спасибо! Я увлекаюсь написанием всяких скриптов — буду знать, куда копать.

    8. Чистяков Денис
      30 ноября 2010

      Спасибо за статью, интересно, наглядно и познавательно, но как-то правда бесперспективно (( как то ждал серебряной пули в конце, а тут… да еще и во всех современных, но хорошо что замечено и проанализировано, кто предупрежден тот вооружен.
      Я правильно понял, что имеется ввиду не просто применение CSS3, а его применение в тех местах где может происходить его repaint?

    9. 30 ноября 2010

      О Chrome Developers Tool, которую разрабатывают в Петербурге, рассказывал один из руководителей его разработки на GDD. Действительно мощный инструмент, многих вещей нет ни в одном браузере, и предвидятся ещё больше крутых штук.

      В одной из своих заметок о float и overflow я высказал предположение ( http://habrahabr.ru/blogs/css/48429/ в конце), что overflow может положительно сказаться на производительности, потому что создаётся контекст форматирования, который ограничивает действия элементов самим собой. Ты как-то можешь это прокомментировать?

    10. 30 ноября 2010

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

    11. Kolyaj
      30 ноября 2010

      Действительно непонятно, зачем ставить 10 мс задержки в setTimeout. Особенно учитывая, что в Firefox минимально возможная задержка в среднем 11 мс, а в IE аж 15 мс.
      Зачастую тормоза пропадают при выставлении задержки в 30-50 мс, плавность анимации при этом не сильно страдает. Впрочем общий таймер на все анимации в любом случае полезен.

    12. 30 ноября 2010

      «В итоге в Firefox нагрузка на процессор снизилась… всего 10%».

      До прочтения следующего абзаца кажется, что стало всего 10%, а не снизилась на 10%.

    13. 30 ноября 2010

      Спасибо, это, действительно, круто. Жду с нетерпением, когда у самого высвободится время, чтобы заняться таким же низкоуровневым профилированием 🙂

    14. Сергей Чикуенок
      30 ноября 2010

      А зачем было ставить setTimeout на 10мс?

      Это просто демонстрация

      Кстати, хорошо что убрал анмиацию. Для подобной фоновой анимации slide-эффект не подходит — слишком отвлекает.

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

      Меня что смущает в анимации — при определённых ситуация в WebKit есть проблемы: Safari выключает сглаживание шрифта, а Chrome оставляет шлейф от букв в один пиксель длинной перед самым концом анимации.

      Такие проблемы есть с инпользованием -webkit-transform, opacity и флэша. Частичное решение проблемы я описывал тут.

      А если таймеры показывать из iframe?

      Может быть поможет, но сам iframe тоже не дешёвая штука. Надо тестировать.

      Я правильно понял, что имеется ввиду не просто применение CSS3, а его применение в тех местах где может происходить его repaint?

      Да, правильно. CSS3 тут приведён как пример сильно нагружающих процессор свойств. На бигбаззи их мало, но всё равно тормозит за счёт большого объёма информации. Я, например, частенько вместо box-shadow использую border-image с картинкой тени потому что работает намного быстрее.

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

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

      Действительно непонятно, зачем ставить 10 мс задержки в setTimeout. Особенно учитывая, что в Firefox минимально возможная задержка в среднем 11 мс, а в IE аж 15 мс.

      А у Chrome это около 2 мс. В любом случае, простое снижение задержки не решает проблему в общем виде: браузер будет работать с той скоростью, с которой может.

    15. Кирилл
      30 ноября 2010

      вот и я говорю, что float надо остерегаться. круто, что я нашел подтверждение

    16. GreLI
      30 ноября 2010

      Свойства задающие ширину по содержимому вроде float, display:inline-block или таблицы (кроме table-layout:fixed) неизбежно затратны по производительности из-за того, что они вынуждают рекурсивно обрабатывать содержимое.
      В данном случае недоработка браузеров в том, что эта обработка происходит даже тогда, когда в принципе это можно избежать, зная что изменения происходят в абсолютно позиционированном содержимом, не влияющем на окружение. Нельзя назвать это багом, так как браузеры не обязаны проводить такую оптимизацию изначально (она была бы преждевременной), но доработать это поведение стоит.
      В качестве хака решением для повышения производительности в данном случае был бы вынос счётчика повыше к <body> при помощи яваскрипта, но в таком случае возникают проблемы с изменением размеров окна браузера и резиновой вёрсткой, особенно когда есть минимальное и максимальное значения ширины.

    17. 1 декабря 2010

      А мне вот интересно — провел я исследование с помощью Web Inspector, нашел где Chrome делает лишние перерисовки… А в каком нибудь другом браузере получил обратный эффект т.к. там механизм инициирования перерисовки другой. Возможен такой ход событий или общие принципы у всех движков одинаковые?

    18. Сергей Чикуенок
      1 декабря 2010

      Общие принципы у браузеров одинаковые. Я сомневаюсь, что если вы оптимизируете узкие места в Chrome, то в каком-нибудь Firefox будет работать медленнее. У разных браузеров может быть разный набор триггеров для reflow/repaint, и разные алгоритмы определения области перерисовки

    19. 1 декабря 2010

      получилось замкнуть перерисовку на .timer-wrap указав ему {position:absolute}

    20. 100grammist
      1 декабря 2010

      GreLI, большое спасибо за комментарий!
      До сих пор не понимал почему динамические таблицы (table-layout:auto) так грузят браузер. Теперь буду начеку 🙂

    21. Сергей Чикуенок
      1 декабря 2010

      Глеб, а данном случае это помогло, но на самом бигбаззи — нет. Всё равно repaint происходит на всём контейнере страницы

    22. 2 декабря 2010

      Спасибо! Основной урок для меня — проверять производительность скриптов на таймлайне. Не очень были понятны термины «reflow» и «repaint». (С термином «restyle» таких проблем не возникло, он обозначает изменение стилей элемента.) Откуда эти термины происходят?

    23. Сергей Чикуенок
      2 декабря 2010

      reflow: пересчёт геометрии объектов (поменяли размер объекта — пересчитали размеры и координаты остальных)
      repaint: перерисовка объектов и/или страницы

    24. 2 декабря 2010

      Еще раз спасибо. Но все же интересно откуда взялась эта терминология?

    25. 2 декабря 2010

      Сильно… Спасибо за еще одну очень полезную статью. Теперь я точно знаю почему так тормозит LookAtMe =)

    26. Shock
      3 декабря 2010

      Столкнулся с подобной штукой при разработке libcanvas.com. В Хроме (в Опере и Фоксе не наблюдалось) значительно падала скорость перерисовки содержимого Canvas на странице с тяжёлой версткой (помогало отключение position: relative родителю элемента canvas).

    27. 8 декабря 2010

      Спасибо за экстеншен, будет очень полезен.

    28. Юлия
      16 декабря 2010

      Однако на живом сайте решить проблему не удалось, так как на большинстве страниц стояли clearfix-элементы, из-за которых макет разваливался.

      поставьте на основной контейнер overflow:hidden — это отменит обтекание в большинстве браузеров и clear элементы не будут действовать на флоатнутый сайдбар. а для ie нужно поставить либо ширину, если можете, либо высоту. поскольку высоту в px не задашь, напишите height:1%. по идее должно помочь

    29. Сергей Чикуенок
      16 декабря 2010

      overflow:hidden — это первое, что я хотел сделать, но у контента слишком много выносных элементов: http://bigbuzzy.ru/catalog/christmastree/

    30. GreLI
      21 декабря 2010

      А display:inline-block не помогал?

    31. Сергей Чикуенок
      21 декабря 2010

      В конце статьи:

      Причём проблема не только во float. Я перепробовал различные варианты горизонатльной группировки блоков: display:inline-block, display:table-cell, таблицы и даже новомодные flex box — во всех случаях проблема оставалось. Помогало только абсолютное позиционирование боковых блоков.

    32. 27 декабря 2010

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

    33. 5 января 2011

      Очень познавательные у Вас статьи. Спасибо. так подробно и профессионально обзоры написаны, а главное от них польща!

    34. 12 января 2011

      именно в ff браузерные анимации всегда вызывали больше всего сложностей
      спасибо за статью, перешлю сотрудникам

    35. 21 января 2011

      Замечательная статья, а главное все по полочкам разложено!

    36. 26 января 2011

      отличная статья! молодец!

    37. Олег
      2 февраля 2011

      Не знаю, кто это «мы», но вашему дизайнеру точно пора по голове настучать: http://oleg-istomin.livejournal.com/22864.html

    38. Андрей
      23 февраля 2011

      А если вместо таймеров повесить заглушки, сам контент таймеров вынести в самое начало кода со стилями position:absolute, и по onLoad, onResize перемещать абсолютно-позиционируемые таймеры в смещения заглушек в документе? От влияния родителей по идее избавит

    39. Александр
      24 февраля 2011

      Интересная статья, спасибо.

      В Хроме тоже проявляются подобные проблемы? Как я ни извращался, не смог нагрузить Хромом процессор больше, чем на 1%. Ваш сайт так же не нагрузил процессор (3%). То есть, лишние перерисовки есть, а нагрузки нет.

      Проверял на слабеньком IdeaPad S10, win7, Chrome stable и Chrome dev 11.

    40. Олег
      6 мая 2011

      Сергей, понимаю, что прошу сделаться экстрасенсом, но может подскажите в чем может быть проблема, я делаю сайт на котором будет навигация, похожая на http://www.mosgorreklama.ru/ , но у меня не перетаскиванием, а по принципу «куда мышь повел, туда экран и полетел», используются таймеры для анимации. Так вот, в Firefox 4, 3.6 присутствуют сильные лаги при перемещении слоя, может есть какие-то особенности движка, на которые стоит обратить внимание? Спасибо.