Комментарии 39
Быстро преобразовать данные через map, filtes, foreach и др., и подставить в нужное место всегда выгодно с точки зрения компактности и аккуратности кода.
Проблемы возникают, когда коллекция становится больше некоторого значения по количеству элементов.
Учитывая тему поста, я бы не стал мешать forEach в одну кучу с map и filter. На вопрос "что быстрее: циклы vs стрелочные функции" без уточнений я бы с циклами сравнивал только forEach.
А ещё в JS не так давно map и прочее завезли на итераторах: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/map (наконец-то). Это к вопросу о выделении памяти.
дилетантский вопрос, разве лямбда функции (map, filtes, foreach) не содержат внутри себя циклы? почему они быстрее?
Почему вы решили, что они быстрее? Автор же об этом и пишет: он заменил два последовательных цикла одним и получил прирост скорости, и так прямо русским по белому и написал: "В результате общий цикл обыгрывает все парные лямбды".
Т.е. если говорить об изначальном вопросе (что быстрее циклы или стрелки), то условия эксперимента не корректные. В первом случае автор прогнал 2 последовательных цикла, во втором-один. Вместо чистого эксперимента сделана оптимизация кода и сравнение с оригинальной версией.
Я так понимаю, что его идея в том, что с мапом циклы не объединишь. Но со временем там что-то сразу совсем не так - непонятно откуда в первом случае взялись 21мс, взяты слишком маленькие массивы и походу в JS есть и другие факторы, вот покрутил "первый случай" в цикле (массивы увеличены до 1М элементов):
% node loop-speed-test.js
Выведем время для Первого случая - map
31
Выведем время для Первого случая - map
36
Выведем время для Первого случая - map
36
Выведем время для Первого случая - map
98
Выведем время для Первого случая - map
596
Выведем время для Первого случая - map
27
Выведем время для Первого случая - map
34
Выведем время для Первого случая - map
35
Выведем время для Первого случая - map
32
Выведем время для Первого случая - map
30
Выведем время для Первого случая - map
35
Выведем время для Первого случая - map
332
Выведем время для Первого случая - map
548Ничо так разбросец.
Я писал безотносительно размера массивов и способов замера времени.
Сравнивать быстродействие надо на одинаковых сценариях. А то, что при использовании массива можно сценарий оптимизировать- ну этот сценарий можно, другой нельзя будет.
Сценарий как раз одинаковый - множим элементы в массивах одной размерности. А выводы некорректные (хотя случайно и правильные). Кстати, добил код автора чтобы он 20 раз прокрутил цикл с лямбдами, одинарным циклом и двумя последовательными. Девиации в продолжительности с лямбдами составили от 25 до 588, для одинарного цикла - от 7 до 14, для двойного цикла - от 8 до 16. Лямбды явно медленнее да ещё их время исполнения дико варьирует.
Религия запрещает лямбдой вычислять два элемента за раз?
они и не могут быть быстрее, т.к. map/filter/foreach будет вызывать функцию. То, что она безымянная и объявлена хитрым сокращенным образом ничего не меняет.
А раз это вызов функции, то должны присутствовать ассемблерные перелести подготовки (упаковка аргументов в соответствующие регистры процессора, сдвиг позиции в стеке, создание области видимости переменных), сам прыжок на функцию (который может быть совсем не одним переходом), выполнение полезного кода, затем прыжок обратно.
Тогда как в цикле присутствует только выполнение полезного кода.
В компилируемых языках можно заставить компилятор включить тексты функций в места их вызовов (inline оптимизация). В случае с jit-компиляторами у меня сомнения, что удастся им это объяснить. А ждать от интерпретируемых языков такого вообще не стоит.
В интерпретируемом языке map/filter/foreach – это один вызов интерпретатора, у которого дальнейший цикл крутится уже внутри его собственного скомпилированного кода; в то время как оператор цикла – это вызовы интерпретатора на каждом шаге цикла. Поэтому скорее можно ожидать, что для интерпретатора функции высшего порядка при прочих равных условиях (а не так, как у автора) будут эффективнее цикла.
А для оптимизирующего компилятора это одно и то же.
(define n 10000000)
(define v (make-vector n 1))
(define l (vector->list v))
(define (loop-mode)
(let
((v1 (make-vector n)))
(do ((i 0 (+ i 1))) ((>= i n) v1)
(vector-set! v1 i (cons (* 2 (vector-ref v i))
(* 4 (vector-ref v i)))))))
(define (map-mode)
(map (lambda (x) (cons (* x 2) (* x 4))) l))(begin (time (loop-mode)) #!void)
(time (loop-mode))
1.450953 secs real time
1.449960 secs cpu time (1.209880 user, 0.240080 system)
1 collection accounting for 0.382245 secs real time (0.147441 user, 0.234461 system)
1039997608 bytes allocated
526 minor faults
no major faults(begin (time (map-mode)) #!void)
(time (map-mode))
0.941165 secs real time
0.940383 secs cpu time (0.788342 user, 0.152041 system)
2 collections accounting for 0.527198 secs real time (0.382315 user, 0.144481 system)
1954345472 bytes allocated
3211 minor faults
no major faultsЭто в режиме интерпретации. При компиляции, конечно, loop-mode выигрывает:
(time (loop-mode))
0.146607 secs real time
0.146481 secs cpu time (0.127825 user, 0.018656 system)
1 collection accounting for 0.085572 secs real time (0.080117 user, 0.005399 system)
560000112 bytes allocated
29164 minor faults
no major faults(time (map-mode))
0.426168 secs real time
0.425797 secs cpu time (0.384492 user, 0.041305 system)
2 collections accounting for 0.281391 secs real time (0.270081 user, 0.011088 system)
1574838304 bytes allocated
85327 minor faults
no major faultsЗамечания:
Здесь результаты приведены для процессора Apple M4 Pro. В архитектуре Intel, вероятно, работа с массивом будет иметь дополнительное преимущество против списка (что, однако, не имеет прямого отношения к теме обсуждения).
Я использовал интерпретатор и компилятор языка Gambit Scheme, так как для Javascript мне не известен эффективный компилятор.
Поскольку цикл эффективно работает только с массивом, а функция map требует список, то для двух вариантов пришлось применить разные структуры данных.
Да, скорее всего вы правы. Учитывая, что у нас тут реальная нагрузка состоит из очень простой арифметической операции, то конструкция цикла, реализованная на низкоуровневом языке (внутри которой должны быть арифметические операции, сравнение и прыжок) может и существенно ускорить относительно ее исполнения чистым интерпретированием.
а разве console.log( у первого случая бесплатный? Ведь он тоже в учет времени попал
Это сильно.
Не объявлять переменные (тем самым, мутировать глобальный объект / вытаскивать значения из него - у нас же не строгий код).
Сравнивать разные операции - мутацию объектов из оригинального массива в
forи то же самое плюс создание новых массивов из них в.map, да ещё и с выводом в консоль новых массивов, вместо.forEach.Мерить производительность в консоли, не учитывать разные компиляторы для разных уровней оптимизации движка.
Мерить производительность только в одном браузере - при том, что в каждом из браузерных движков это реализовано по своему.
Использовать
new Date().getTime()(даже неDate.now()!) для измерения отрезка порядка 2мс - есть жеconsole.time/performance.now.
И все этого для того, чтобы сделать вывод, что два запуска map по N элементов (т.е. 2N итераций) дольше, чем один for по всем (то есть N операций). Это действительно сильный вывод, надо это всегда держать в уме, это ведь на весь мир проецируется. Например, два ведра воды, неожиданно, тяжелее, чем одно ведро. Думаю развернуть это на серию статей...
на самом деле это довольно хороший камень в огород разработчиков компиляторов и языков. По-идее высокоуровневая оптимизация должна преобразовать арифметические лямбды в инлайны, также указать на более сложные что имеется стек помимо всего прочего. Плюс скрытая память для мутабельных объектов, включая списки и итерация по последовательной или произвольной индексации / хеши для множеств. Вообщем эта проблема обратной связи от компилятора-интерпретатора к пользователю на конструкциях верхнего уровня. Помимо error/warning/remark должен быть долгожданный proposition и вскрытие внутренней структуры объектов вместо голого байткода. Тогда и консоль будет условно бесплатная в виде счётчика или массива вместо вызовов системных функций. Вот все эти tricks and tips и нужно скармливать как можно скорее ИИ чтобы это не держать в голове.
Уберите камень из огорода, компиляторы этим занимаются. Но не всегда это нужно.
Хоть js и не компилирует, авторы js могут отправить ноду улучшать код. Но я сомневаюсь, что вы будете рады ждать двухчасовую оптимизацию кода страницы, чтоб он выполнял цикл на 200 наносекунд быстрее. А анализ возможности оптимизаций - увы, довольно сложная работа в вычислительном плане.
JavaScript, напомню – это чтобы кнопочка синим светилась, когда товара в корзине нет, и зелёным – когда есть.
У языков, предназначенных для написания большого кода, всё отлично инлайнится.
Исторически, JS - это плагин для веб-поведения который прикрутили к голому HTML. Хотя насколько помню были попытки сразу сделать возможность передачи двоичной разметки с байткодом реализующим и CSS и JS и Cookies и HTTPS и многое другое вместе взятое. Но так как под Netscape Navigator уже тогда начали появляться аддоны + с лёгкой руки Adobe и Sun Microsystems то W3C застолбили по инерции текстовый режим ещё и с парными тегами, усложнив при этом загрузку страницы с отсутствующими элементами, хотя вполне возможно было создать WebASM, провести двоичную унификацию или что-то подобное уже тогда. Поэтому, на Big Red Button тратится больше тактов чем для решения дифур схемы с десятком транзисторов в MicroCAP (эх, хороший был симулятор). Рассматривать производительность в браузере в этом случае это всё равно что помогать комитету добивать эту тему до логического завершения в плане стандартизации зоопарка исторически сложившихся протоколов. А что там будет под катом JVM/LLVM или даже аппаратные интерпретаторы из разряда Java на ПЛИС это уже вопрос десятый, важна поддержка GPU, нативной многопоточки, взаимосвязи между ними (вкладками), управление песочницей и пользовательскими данными. Браузер по сложности уже почти миниатюрная ОС и виртуальная машина, зачем делать ОС в ОС, когда проще использовать уже готовую ОС и её инфраструктуру, заточенную нативным образом под Web без промежуточных костылей, на уровне POSIX, но это уже другой уровень .
Чтобы не попадать в ситуацию, когда сравнивается тёплое с мягким, можно использовать любой вменяемый профайлер. Если мы говорим про JVM-based языки, то async-profiler. Посмотрите что именно у вас занимает время, и всё встанет на свои места. Окажется, что по понятным причинам, цикл, написанный корректно, абсолютно всегда быстрее лямбд (как минимум в указанных языках). Единственное возможное исключение: когда JIT смог соптимизировать лямбды до полностью эквивалентного состояния, но даже в этом случае, это зачастую хрупкое и шаткое состояние, любой чих (даже далеко от предполагаемого места) может его сломать.
Там должна быть песочница с чистым холодным процессорным временем и гарантией того что это влезет в его кэш. Если JVM разрастается вне кэша то на этой границе производительность может ложиться в разы, это что касается как раз этих мелких внутренних циклов/лямбд. Произвольный доступ вместо последовательного для любой блочной памяти или кэша это катастрофа а виртуальная машина своим жиром делает именно так. Поэтому такие тесты обязательно прогонять на N от 0 до миллиона чтобы посмотреть что творится "внизу" ступеньки. Отсюда и горячие точки подлежащие оптимизации. Плюс многопроцессорные различные мьютексы и потокобезопасность тоже может клацать когда речь идёт о консолях и прочим I/O со 100500 параллель открытых вкладок.
Простите, у вас тоже как-то всё в кучу.
песочница с чистым холодным процессорным временем и гарантией того что это влезет в его кэш
Это - что? И в какой кэш? В JVM используется много разных интересных оптимизаций, улучшающих как локальность данных, так и локальность кода. Плюс в продакшене у вас будет не песочница, а реальность, и интерференции с реальностью всё сильно меняют. Пример: моно/дуо/мегаморфизм, из-за которых JIT в бенчмарках показывает такой же результирующий код с лямбдами, как без них, но в реальности часто всё сыпется. Поэтому к измерениях в песочницах нужно относиться с соответствующим скепсисом, а мерить нужно не только CPU-time, но и Wall-time тоже, артефакты бывают разными.
Произвольный доступ вместо последовательного для любой блочной памяти или кэша это катастрофа а виртуальная машина своим жиром делает именно так.
Ещё раз к локальности данных. Как раз перемещающий GC (т.е. любой современный GC) в среднем по больнице обеспечивает прекрасную локальность по данным, они чаще всего физически лежат рядом. Исключение: плохо сконфигуринованные low-latency GC, они часто вымывают кэши постоянным сканом памяти. Если на это нужны гарантии - этого тоже можно добиться для экстремальных случаев, работая с памятью напрямую (массивы и/или off-heap решения, коих тоже минимум два в стандартной библиотеке). Работая просто с массивами, когда данных прям много, мне удавалось доходить до того, что узким местом являлось, например, предсказание ветвлений (не из-за JVM, а из-за обычного пользовательского кода).
тесты обязательно прогонять на N от 0 до миллиона чтобы посмотреть что творится "внизу" ступеньки
Тесты нужно не просто прогонять на 0 ... (очень много). Нюансов вагон и тележка. Тестировать перформанс вообще очень сложно, но начать можно и даже с wall-clock. Лучше тестировать плохо, чем не тестировать вообще. Но да, для Java есть state of the art решения, вроде JMH, которые большУю часть головной боли берут на себя.
С другой стороны, профилировать намного проще. Потому что только в реальном кейсе можно понять, а что вообще вам нужно. Ускорить холодный старт? Обеспечить высокую среднюю производительность? Гарантировать отсутствие деградации в плохих случаях? И так далее. Затем, насмотревшись на профиля, придет и некоторое базовое понимание "законов перформанса" для конкретно ваших случаев. Ну и ещё раз про проще: при профилировании "кнопочка нажаль картинка получиль", думать почти не нужно, всё видно, и всё реально, а не фантомные боли неправильно сконфигурированной тестовой системы.
Вообще говоря можно обойтись без тестирования производительности на заданной платформе (не путать с тестированием надёжности и ресурсов), если имеются эвристики и модели поведения. Сама программа не такая уж и замороченная чтобы не сгенерировать теоретические оценки для идеального случая, т. к. известно сколько потребляет та или иная операция вне зависимости компилируемая она или интерпретируемая, особенно в однопоточке. Алгоритм оценивается как отметка по пятибальной шкале грубо говоря O(1), O(n), , O(n log n) и всё в таком духе, умножение на константу в этом случае (как и в примере выше) уже считается "тонкой доводкой" под конкретную реализацию. Если эта зависимость ломается без изменения алгоритма на каком-то N или испытывает несколько "перегибов", значит с огромной долей вероятности вмешиваются внешние факторы ну или сама задача нелинейная а значит с масштабируемостью там будет много вопросов. Тестирование кеша и предсказаний в какой то мере можно обеспечить средствами из разряда Meltdown и Spectre, может даже использовать их для обратной связи мониторя что влезает а что нет. Но опять-таки там на практике всё ограничивается объёмом в пару мегабайт. Поэтому локальность данных и кода это всё-таки довольно ощутимая вещь при разработке того что должно уйти в реалтайм и эти точки "перегиба" могут оказать решающее значение если это множество экземпляров например для микросервисов. И если тут отсутствует какая-либо языковая, библиотечная или IDE поддержка с точки зрения профилирования этого дела, то собственно никакого прогресса в этом деле не обнаруживается и получается парадокс, синтаксический сахар вроде современный, а методы и подходы к дебагу/профайлингу из начала 90-х.
Когда задают такой вопрос на собеседовании, это не на "подумать", это на понимание того, что у вас обозначено "Как-то работает под капотом". И правильный ответ (вне зависимости, как он сформулирован) - это "всё зависит от реализации IEnumerable в конкретной коллекции".
Ну вообще-то нет. Это только самый верхний слой вопроса. Дальше неплохо понимать, во что этот код переваривается после JIT. Дальше было бы неплохо понимать, а как с данными, расположенными таким образом, работает процессор и кэши. Вообще неплохо было бы понимать, что не O-нотацией единой живёт производительность, и что то, что написано в исходниках стандартной библиотеки зачастую совсем не то, что будет реально исполняться (интринсики, несколько уровней JIT и т.д.).
Быстрее всего $mol 😁
В стародавние времена, в таких случаях приводили соответствующий код на ассемблере и искали разницу. А тут ещё языки интерпретируемые, JIT и прочая и прочая... :-)
с отладкой в тетрадке и указанием на полях сколько машинных инструкций занимает команда в зависимости от операндов и даже результата флагов знака, переноса и нуля. А если количество обращения превысило порог то и цикл refresh DRAM подавай
Ну сейчас ассемблер - это тоже очень промежуточное представление, не всегда чисто по ассемблеру можно разницу понять. А JIT таки выдает ассемблерные листинги по самому первому запросу, было бы кому попросить)
Если вы заменили два цикла одним, то почему не заменили две лямбды одной?
Вы на кой черт сравниваете мутацию существующего массива с созданием нового? Почему у вас "простейший способ" изменить элементы массива - использовать map? Вы когда используете метод, игнорируя возвращаемое значение, хоть бы на секунду задумались, что делаете
Да уж, интриги не случалось. Зачем вообще такое писать? Хотя блин! Зачем вообще я это комментирую?
Наверное хорошо задумываться о производительности разных средств языка, пусть даже начиная с такого блина комом. Советую посмотреть доклады на конференциях от создателей движков вроде V8 и послушать, что они думают про любые наконеночные бенчмарки.

Оптимизация кода. Что быстрее: циклы vs стрелочные функции. Простая задача с собеседования