Добавь газку: +200% производительности

    Привет, Хабр.

    В прошлый раз я рассказывал тебе, как мы написали Raw конвертер на JavaScript, а ты сказал мне, что он работает медленно. Сегодня я хочу рассказать о том, как мы ускорили наш raw.pics.io почти в 3 раза. Я не буду постить простыни кода с описанием каждого шага, постараюсь рассказать в общем виде о подходах к оптимизации, которые мы использовали. Также я решил не писать о доступе к DOM, уменьшении количества HTTP-запросов, склеивании и минификации файлов, опциях сжатия на сервере и т.д. Все это техническая работа, о которой написали хорошо и много.

    Как все начиналось


    Мы начали с определения критических участков кода, которые занимают основную часть времени выполнения. Практически всегда в программах есть гадкие куски, которые тормозят больше всего — вот с них и стоит начать. Обычно разработчик примерно представляет, куда уходит основное время в его коде, и найти то, что тормозит, не составляет особого труда. Нужно только захотеть. Помимо сакральных знаний мы использовали профайлер и замеряли время выполнения с помощью console.time(). Кроме того, cейчас в браузерах для этих целей появился удобный объект performance.

    Оптимизация на уровне технологий


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

    SIMD (Single Instructions Multiple Data)

    Используя MMX и SSE можно добиться существенного ускорения за счет быстрого исполнения операций над векторами. Такая штука уже есть во многих языках, а недавно Intel представила расширение языка, добавляющее такие возможности в JavaScript. Эта новость несказанно обрадовала нас. Мы поискали и даже нашли реализацию в ночной сборке Firefox. Но радость была преждевременной. После пары тестов мы поняли, что сейчас это невозможно использовать. Реализация SIMD, которую мы попробовали, на данный момент работает очень медленно. Мы продолжаем следить за развитием, но эта технология пока ещё достаточно сырая для использования.

    WebCL

    Ещё одна потенциально интересная технология, которую мы очень хотим использовать — это WebCL. Пару месяцев назад Khronos Group опубликовали первую спецификацию, описывающюю использование мощностей GPU для параллельных вычислений в браузере. Звучит здорово, но пока доступно только в виде синтетических тестов для специально собранных версий браузеров, или в виде плагинов. Надеюсь, удастся потестировать ближе к концу года. Пока неприменимо для использования вообще. =(

    WebWorkers

    В итоге задача дебайеризации отлично легла на WebWorkers, работать с которыми оказалось очень удобно и просто. Мы делим целое изображение на куски и обрабатываем каждый в своем потоке. Сперва мы написали свою обертку для управления WebWorker’ами, а сейчас используем библиотеку Parallel.js. Пришлось позаботится о том, чтобы нарезать, а потом склеивать эти куски. Нужно помнить о том, что на инициализацию каждого воркера уходит какое-то время и есть сложности с передачей некоторых типов данных в worker и обратно. Но основная проблема: вебворкеры не очень удобно дебажить. Cейчас такая возможность есть только в Chrome. Ещё один интересный вопрос: как определить оптимальное количество воркеров? Мы нашли корреляцию с количеством ядер в системе, но в браузерах до сих пор нет возможности определить их число.

    В общем, из всего найденного самыми рабочими оказались WebWorkers, которые теперь трудятся у нас в raw.pics.io.

    Оптимизация на уровне алгоритмов


    Одним из важных подходов является подбор правильной архитектуры и быстрых алгоритмов. Мы обрабатываем массивы из 20 000 000+ элементов, а значит, ускорение обработки каждого из элементов уменьшает общее время выполнения. Естественно, ускорение или избавление от ненужных операций может сильно помочь. Вот почему мы много анализировали и выбирали подходящие алгоритмы интерполяции, переписывали их по несколько раз, заменяли математические операции на битовые сдвиги и удаляли лишние проверки и условия. Эти милисекунды на большом количестве итераций дали существенную экономию. Например, мы смогли увеличить скорость работы алгоритма благодаря тому, что не обрабатывали незначимые участки фотографии (неактивные участки сенсора камеры) и убрали лишние проверки, которые не влияли на результат.

    Оптимизация структур и типов данных


    Типизированные массивы

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

    Простые структуры

    Самая первая версия нашего декодера содержала красивые структуры данных, с иерархией классов и кучей модулей. Хотя это и было правильно с точки зрения ООП, но тормозило при инициализации и доступе к объектам. После того, как мы взглянули на это со стороны и ещё раз обдумали структуру, я упростил всё до нескольких модулей. Такая денормализация уменьшила количество модулей и связей между ними, что немного усложнило понимание, но это было сделано осознанно (для целей производительности).

    Оптимизация на уровне языка


    У Nicholas Zakas есть отличная серия посвященная производительности JavaScript. Я не буду рассматривать всё, что мы делали, а опишу основное. Тормозящий код складывается (получается произведением =)) из стоимости выполнения операции и количества таких операций. И если мы не можем уменьшить количество итераций, то стоит уменьшить стоимость каждой операции. На каждом шаге у нас происходил вызов функции, а то и двух. Вызов функции достаточно дорогая операция и имеет смысл избегать их внутри больших циклов, так как это всегда сильно затратно. В JavaScript нет механизма (как в C++), который позволит сказать компилятору, что эту функцию нужно вставить inline, поэтому мы денормализовали код и избавились от этих вызовов. Стало менее читаемо, но более быстро. Благодаря такому подходу мы достаточно сильно увеличили производительность больших циклов.

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

    // обычный цикл
    for(var i=0; i<items.length; i++) {
      // код здесь...
    }
    
    // такой цикл работает быстрее
    var size = items.length;
    for(var i=0;i<size; i++){
      // код здесь...
    }


    Вообще подобных оптимизаций достаточно много и все они очень похожи, но суть одна — уменьшить стоимость операции.

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

    Оптимизация на уровне платформы


    Реализации JavaScript движков разнятся от браузера к браузеру и один и тот же код работает по-разному в Chrome и FF. Я не буду касаться этого, так как мы ещё не занимались такого рода оптимизациями, но уверен, что можно писать код более приспособленный к конкретному браузеру. Если у кого-то есть мысли на этот счет, пожалуйста, прокомментируйте.

    Оптимизация восприятия


    Самое интересное, что можно сделать — показать пользователю результат как можно быстрее. Это тоже некая оптимизация, которая может «подарить» вам несколько секунд, пока пользователь грустит и смотрит на крутящийся индикатор выполнения. Мы можем показать какой-то промежуточный результат, маленькие кусочки карты или предварительно загруженное изображение низкого качества — нужно дать пользователю возможность начать усваивать информацию хоть в каком-то виде. Например, Pinterest до того, как прогрузит изображения, показывает прямоугольники залитые усредненным цветом конкретного изображения. За счет этого создается эффект, что он работает быстрее. У нас тоже самое можно сделать, показывая embedded JPEG, и заменяя его на результат обработки raw данных.

    Когда заканчивать


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

    Послесловие


    Полезные инструменты

    Зачастую стоит выбирать между скоростью выполнения и затратами на память. И хотя сейчас память уже достаточно дешевая, всё ещё бывают случаи когда ее не хватает. Проследить за тем, кто сколько памяти расходует помогает профайлер. Но и его иногда не хватает, чтобы понять что происходит. Google Chrome предоставляет отличные инструменты для разработчика. Chrome можно запустить с интересными флагами, что иногда очень кстати. Например так можно получить доступ к объекту memory и методу gc(), которые обычно недоступны. А так — перенаправить все ошибки прямо в терминал и найти много интересного. По адресу chrome://about доступен полный список встроенных в Chrome утилит, которые могут здорово помочь при отладке и разработке.

    Как тестировать варианты

    Когда вы нашли один тормозящий кусочек и есть несколько вариантов оптимизации, нужно понять, какой из них будет работать быстрее. Вместо того, чтобы писать несколько вариантов, полезно прогнать маленькие синтетические тесты. Мы используем jsperf.com. Такие бенчмарки хорошо дают понять, какой из выбранных подходов будет наиболее оптимальным. А если поискать, то можно найти много готовых тестов и переписать/дописать их для своего случая.

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

    TopTechPhoto

    35,00

    Компания

    Поделиться публикацией

    Похожие публикации

    Комментарии 44
      –5
      Что-то в это

      // обычный цикл
      for(var i=0; i<items.length; i++) {
        // код здесь...
      }
      
      // такой цикл работает быстрее
      var size = items.length;
      for(var i=0;i<size; i++){
        // код здесь...
      }
      


      слабо верится. И JSPerf — не аргумент.

      Вся остальная статья предельно логична. Другого и не ожидалось. Rivertrail по идее ожидается в ES7 или ES8, т.е. году в 2016 примерно. И то, не понятно, что с ним будет теперь, когда Брендан — основной пропагандист SIMD в JS — ушел из Mozilla.
        0
        это будет так если не оптимизирован доступ к .length для цикла.
        0
        А что аргумент, в таком случае?
          +5
          Аргумент может звучать только так:

          «Мы профилировали наше приложение и после N запусков профайлера и выполнения одного и того же сценария получили следующие данные о времени выполнения. Мы оптимизировали самые проблемные кучки и после повторных серий замеров производительности получили такой-то прирост. В частности, для нашего сценария вынесение вычисления длинны массива за цикл принесло на X% меньшее выполнение программы.»


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

          В сочетании с тем, что большинство разработчиков JS-движков считают 99% бенчмарков на JSPerf невалидными, я склонен подвергать сомнению вывод, содержащийся в статье. Если бы вместо ссылки на JSPerf были ссылки на результаты запуска профайлера для pics.io до и после, я бы согласился.
            +2
            Вот хороший доклад Славы Егорова — инженера Google V8 & Dart VM team — где он как раз и говорит о плохих бенчмарках и JSPerf.

              0
              В данном конкретном случае это не так. items нельзя соптимизировать, items может меняться. JS — это такой язык, что получение значение i может привести к изменению длины items. JIT старается оптимизировать то что точно даст на выходе тот же результат, но это не про циклы.
                0
                items нельзя соптимизировать


                можно

                JS — это такой язык, что получение значение i может привести к изменению длины items.


                typeof i = "number"
                


                Нет такого метода, хука или чего-то такого, что может вызвать какой-то код при обращении к значению числа. Против смены длины, также как против смены значения/типа JIT ставит гард в SSA-код. Так что оптимизировать доступ к полю, превратить его в константу и вынести инвариант за границы цикла можно. V8, SpiderMonkey, JSC и Chakra это делают.
                  0
                  Вариант 1. items.length — геттер
                  Вариант 2. items.length — объект с valueOf
                  Вариант 3. Это js, давно нам JIT запретил поменять сам Number.prototype.valueOf? (про i)

                  Чисто логически это два разных кода — в одном мы говорим что нам надо проиттерироваться до 10, а в другом что до длины массива, получаемой на каждом шагу. Может быть случай когда надо действительно делать это до начальной размерности массива, хотя он при этом растёт. Лично получал полтора года назад эффект от вынесение получение длины за сравнение, но там был двумерный массив и вложенность циклом достигала трёх.
                    0
                    также как против смены значения/типа JIT ставит гард в SSA-код

                    Где можно посмотреть/почитать об этом?
              0
              слабо верится. И JSPerf — не аргумент.

              Я вас не обманываю!
                +1
                // такой цикл работает быстрее
                var size = items.length;
                for(var i=0;i<size; i++){
                  // код здесь...
                }
                

                можно записать чуть компактнее, хотя смысл не меняется:
                
                for(var i=0, size = items.length; i<size; i++){
                  // код здесь...
                }
                
                0
                слабо верится

                Это же классика для многих языков: в первом варианте размер повторно вычисляется при каждой итерации, во втором примере размер заранее известен.
                При современных реалиях правда разница будет ощутима при действительно больших массивах данных.
                  +4
                  «При современных реалиях» многие компиляторы сами делают эту оптимизацию.
                    0
                    Ога. И именно по этому во многих языках в тестах 2–ой вариант оказывается быстрее.
                      0
                      Не поэтому, очевидно.
                        0
                        К чему тогда
                        многие компиляторы сами делают эту оптимизацию.
                        ?
                          0
                          Если компилятор сам делает эту оптимизацию, то производительность обоих вариантов в тестах будет идентичной.
                            0
                            особо умные компиляторы ещё будут делать проверку на выход индекса из диапазона, а ещё более умные-умные не будут делать её для i < items.length, так как там выйти из диапазона нельзя. тогда 2-й окажется быстрее. но, в JS я бы не надеялся.
                    –1
                    «В современных» — это каких? Даже в PHP, где до сих пор нет нормальной поддержки юникода (да, я знаю про mbstring), это и то оптимизировано давно. А уж в остальных языках и подавно. Высчитывать длину *массива* каждый раз — это ппц. Не знаю что надо выкурить разработчику языка, чтобы это не кешировать.

                    Конкретно тут, скорее всего, оптимизация есть за счет уменьшения времени доступа. К локальной переменной быстрее, чем к свойству. Учитывая фразу «обрабатываем массивы по 20000000+ элементов», может быть актуально.
                      +2
                      «В современных» — это каких?

                      Это относилось к реалиям (подразумевалось производительность современных РС), а не к языкам.

                      Даже в PHP, где до сих пор нет нормальной поддержки юникода (да, я знаю про mbstring), это и то оптимизировано давно.

                      Тесты упрямо не хотят это доказывать. Разница по производительности ~8 раз в пользу последнего.
                    –1
                    Эх, пока не будет инструментов для редактирования, смысла большого в этом сервисе нет. Вшитая в равку jpg превьюшка выглядит лучше, чем проявленный без профилей камеры raw.
                    При редкатировании, кстати, можно проявлять не всю равку целиком (для превью), а уменьшенный вариант, как это и делают рав конверторы.
                      0
                      Как разные конверторы уменьшают raw?
                      Расскажите, мы как раз думаем над похожим подходом!
                        0
                        Посмотрите, например на dcraw, баловался с ним, когда хотел сделать похожий сервис, но с обработкой на стороне сервера. Там можно проявлять не целиком, а в половину размера (или в четверть), что соотвественно быстрее и как раз годится для превью во время редактирования. А при финальном сохранении уже работать с полноразмерным изображением. Так же поступают и «большие» конверторы, типа лайтрума.
                        Профили камер бы еще в идеале добавить, их можно взять из открытых проектов, типа darktable.
                          0
                          Взяли из darktable + из rawspeed + сами нагенерировали!
                            0
                            Да, конечно. Я поинтересовался, потому что подумал, что вы знаете как это происходит! Просто выбрасываются какие-то строки/столбцы? Или какой-то более грамотный ресемплинг?

                            dcraw можно посмотреть в код, а Lightroom?
                              0
                              Нет, к сожалению, до исходников руки не дошли, другие задачи были.
                              У лайтрума во что то там внутреннее конвертируется, посмотреть, например, на их smartpreview.
                                0
                                Smart Preview у Lightroom — это DNG уменьшенный в несколько раз и пожатый с потерей качества.
                          +1
                          В прошлый раз вас хвалил, и сейчас похвалю, действительно стало ещё быстрее. Насчёт в 3 раза, не заметил, но процентов на 50 точно.
                          Тестовый DNG (26 Мб, 22 Мпикс) — ~3 секунды загрузка, ~5 секунд обработка
                          Тестовый CR2 (30 Мб, 22 Мпикс) — ~3 секунды загрузка, ~7 секунд обработка
                          По-моему вполне приличные цифры для онлайн-инструмента
                            0
                            Да про скорость я вообще то ничего не сказал. Учитывая то, что проявка raw это достаточно ресурсоемкая задача, проявка на JavaScript за 7 секунд это очень и очень хороший результат.
                            Но сама по себе проявка без редактирования не нужна, проще вытащить вшитую jpg и получить по дефолту лучшую картинку, да и если на то пошло дело, конвертировать можно и в самом фотоаппарате, даже с какими то минимальными настройками.
                            И как только дело дойдет до настроек (банально баланс белого и пр.), то тут то и начнутся затыки — при изменении настроек, хочется видеть результат быстро а не ждать по 5-7 секунд. Я и посоветовал приглядеться к проявке не полного файла.
                              0
                              Согласен, но как я понял, это только ранняя стадия разработки. Насчёт проявки неполного файла, интересное решение сделали в Lightroom mobile, когда работа ведётся в рамках Smart Preview, а потом синхронизируется с десктопом.
                                0
                                SmartPreview кроме как на ipad удобно использовать при переносе на другой компьютер: создал каталог, перенес на флешке или через облако на другое рабочее место и правь на здоровье ) В lightroom mobile достаточно скудные возможности проявки (
                              0
                              Будем ещё быстрее!
                              0
                              Не во всех камерах есть полноразмерные превью. А в некоторых их вообще нет.
                              0
                              Как разные конверторы уменьшают raw?
                              Расскажите, мы как раз думаем над похожим подходом!
                                +1
                                Реализации JavaScript движков разнятся от браузера к браузеру и один и тот же код работает по-разному в Chrome и FF.
                                Если у кого-то есть мысли на этот счет, пожалуйста, прокомментируйте.


                                Три года назад занимался оптимизацией кроссбраузерной монструозной страницы (100 000 ДОМ-элементов).

                                Разница была заметна по-большей части в работе с добавлением нод в ДОМ:
                                • innerHTML = в Chrome начинал безбожно тупить с ростом количества нод — пришлось делать вставку чанками.
                                • innerHTML = одного большого куска HTML в IE6,7 оказался быстрее всех остальных способов
                                • documentFragment в Firefox оказался быстрее всех остальных способов


                                К тому же [].join('') для конкатенации строк быстрее в IE, а Chrome и Firefox оптимизируют str += str.

                                mikenerevarin может рассказать про прочие хардкорные оптимизации.
                                  +2
                                  У ребят узкое место — явно не DOM, а работа с бинарными данными. Поэтому для них важен параллелизм данных, типизированные хранилища и упрощение структур данных для снижения нагрузки на GC.

                                  Это не отменяет полезности вашего комментария.
                                    0
                                    С тех пор много воды утекло. Например, действительно была быстрее вставка DOM-узлы в Вебките, но затем в движке исправили баг и innerHTML ускорился. Однако, одно дело просто вставить узлы, другое, если надо ещё добавить обработчики событий — тогда генерация с навешиванием обработчиков по ходу дела выходит быстрее чем вставка через innerHTML и поиск и работу с DOM-узлами. По крайней мере, если верить автору basis.js.

                                    Вообще, многие моменты были оптимизированы в браузерах. Многие микрооптимизации, которые советовали ранее, сейчас практически бесполезны, а иногда даже медленнее работают как в случае с .join(), если верить тестам (что сложно корректно проверить, так как движок может преоптимизировать то же сложение строк, и время этой преоптимизации не будет корректно учтено).
                                      0
                                      Да, вы правы. Именно для этого я уточнил, что эти вещи были актуальны три года назад.
                                    0
                                    // такой цикл работает быстрее
                                    var size = items.length;
                                    for(var i=0;i<size; i++){
                                      // код здесь...
                                    }
                                    


                                    Попробуйте так:
                                    for(var i=items.length;i--;){
                                      // код здесь...
                                    }
                                    


                                    Но если важен порядок обработки — придется сортировку у массивов менять (поскольку i итерируется не вверх, а вниз). На моём компе при суммировании 300 тысяч элементов первый вариант выдаёт 18-20мс, второй — 16-18мс.
                                      0
                                      Где могли так и сделали!
                                      0
                                      Подскажите, пожалуйста, за отсутствием каких возможностей IE11 переадресовывается на raw.pics.io/ancient?
                                      Спасибо! =)
                                        0
                                        Мы не успеваем протестировать в IE11, Opera и некоторых других браузерах. Возможно. всё будет OK, а возможно нет.

                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                      Самое читаемое