Как стать автором
Обновить

«Дело было вечером, делать было нечего» или краткая история о сравнении производительности языков программирования

Время на прочтение2 мин
Количество просмотров27K
Всего голосов 48: ↑32 и ↓16+16
Комментарии98

Комментарии 98

PHP вообще один из самых быстрых интерпретируемых языков

Ну тогда и Java тоже называйте «интерпретируемым» языком. Ведь в ней, как и в PHP:
  • Исходный код компилируется в байт-код
  • Байт-код выполняется на виртуальной машине
  • Есть JIT в нативный код
Ну это вы уже слишком…

Ну а в чем принципиальная разница? Кроме того, что PHP не компилирует неиспользуемые файлы?

Да одна только «сильная» типизации чего стоит
И как связана сильная (а может вы про статическую всё-таки?) типизация с противопоставлением «интерпретатор — компилятор»?

PHP на данный момент это язык со слабой динамической типизацией, стандартная реализация которого является компилируемой.
PHP на данный момент это язык со слабой динамической типизацией

Так было лет 10 назад. Сейчас это язык с опциональной сильной и опциональной статической на уровне объектной модели.


Т.е. это вообще единственный ЯП, который я знаю, где можно самостоятельно выбрать как писать.

Опциональная сильная статическая — это вы ровно про один кейс с дефлотным значением типизированного свойства? ))

Ага. Ну а чем не кейс сильной и статической?))


С другой стороны, если совсем упороться...


Лучше сюда не смотреть. Берегите нервы
function &int(int $initial = 0): int
{
    static $values = [];
    $values[] = new class($initial) {
        public function __construct(public int &$initial) {}
    };
    return $initial;
}

$int = &int(42);
$int = 'sdsd'; // Error: Cannot assign string
Интересно, есть ли возможность это обойти?

Я имею в виду, написать что-то типа
class Foo {
  private Collection $prop = [];
}

при условии, что конструктор класса Collection может принять массив, как аргумент?

Может через атрибуты как-то попробовать, придумать какой-нибудь #[Init]?

Но аргументы атрибутов тоже должны быть компайл-тайм константами…
Но аргументы атрибутов тоже должны быть компайл-тайм константами…

И это ограничение тоже можно обойти...


Вам недостаточно было предыдущего спойлера?
$fp = fopen('php://memory', 'rb');
$fp = stream_context_set_option($fp, [
    'php' => [ 'some' => new AnyNonConstantValue() ]
]);

define('VALUE', $fp);

//

class Some
{
    #[AttributeName(value: VALUE)]
    public Collection $prop;
}

// Теперь внутри value атрибута будет лежать ресурс, внутри которого лежит любой произвольный объект.
// Такие дела...

Ну вообще, даже такие "грязные хаки" можно вполне легально использовать. Например, вот в этой либе https://github.com/phpfn/symbol подобный приём используется для реализации псевдотипа "символов", который в свою очередь используется для реализации функционала каррирования, пайпинга и частичного применения (На нынешних обсуждениях RFC для подобного функционала на уровне языка предлагается использовать литерал ?).

Найдите соответствующие опции JVM, реально для интерпретации, и запустите ваш class ('байт-код') - можно будет очень-очень долго ждать (но это будет 'выполнение байт-кода').

В современных JVM давно по умолчанию JIT. И ещё не поленитесь реализовать 'прогрев' теста, там есть профайлер. Удачи...

Ну тогда и Java тоже называйте «интерпретируемым» языком.

И это будет неправдой. Просто потому что у Java языка есть своя спецификация а у байткода JVM своя и по сути это две разные вещи.
Кстати сам байткод это не обязательно продукт компиляции Java кода и он по сути является интерпретируемым «языком»(ну если ассемблер называют языком программирования то почему бы и байткод так не называть?). А вот язык Java требует обязательную стадию компиляции так что назвать его интерпретируемым не получится.
У языка PHP своя спецификация и у байт-кода ZE своя. И это две разные вещи.
Сам байт-код является по сути «интерпретируемым» на виртуальной машине.
И можно себе представить любой другой язык, который компилируется в оп-коды ZE.
Язык PHP требует обязательную стадию компиляции. И назвать его интерпретируемым не получится.

Так в чем разница?
Если вы компилите PHP в байткод а потом запускаете этот байткод на интерпретаторе то можете назвать PHP компилируемым, почему нет. Но 1) наверняка это было не всегда и первоначальные PHP движки интерпретировали скрипт построчно 2) компиляция в промежуточный байткод это часть процесса интерпретации в отличие от java где это делается двумя разными процессами.
Согласно en.wikipedia.org/wiki/Interpreter_(computing):
An interpreter generally uses one of the following strategies for program execution:
  1. Parse the source code and perform its behavior directly;
  2. Translate source code into some efficient intermediate representation and immediately execute this;
  3. Explicitly execute stored precompiled code[1] made by a compiler which is part of the interpreter system.

В случае PHP это чаще всего пункт номер два, возможно в прошлом первый пункт. Если имеется в виду имплементация с промежуточной компиляцией с помощью отдельной тулзы(возможно HipHop так работает, я не спец) то ее можно и нужно называть компилируемой.
В случае Java ни один из этих пунктов не подходит, ее компилятор не является частью интерпретатора. Если существует имплементация JDK которая работает без использования javac то ее можно и нужно называть интерпретируемой.
1) так было до PHP3
2) была такая штука, как zend optimizer, не знаю текущего состояния.
И это условно из коробки.
Теперь есть ещё OPcache и preload.
И еще JIT
А какая версия Ruby тестировалась? 2.x или 3?
upd: в гитхабе нашел ответ на свой вопрос: 2.7.0

Тогда вопрос почему не 3? которая вроде как быстрее 2?
В основном ставил то, что было в репе убунты по умолчанию. Хорошо что сказали, добавлю.

Несмотря на недочеты (маленькое время теста, отсутствие прогрева, маленькая выборка...), это отличный пост и очень интересный график.

Еще не придумал как лаконично прогревать данный тест без большой обвязки. Что касается малого времени тесты — долговато ждать приходилось для медленных кейсов. Но я что-нибудь придумаю

Скорее всего, никак — это специфично для платформы. тот же JMH для джавы, поэтому текущие результаты прекрасны "как есть" — время инициализации приложения и выполнения простого расчета.

Искал в табличке результаты Haskell.

Значит скоро появится

Хорошо, жду. Накинул звезду.

Поправьте, пожалуйста, этот код. Так как вы нигде не указали ни одного типа, все числа дефолтятся до Integer aka BigInt, что абсолютно убивает производительность.


ghc c -Wall предупреждает о дефолтинге:


PrimesSlow.hs:24:16: warning: [-Wtype-defaults]
    • Defaulting the following constraints to type ‘Integer’
    <...>
   |
24 |             if j == 2
   |                ^^^^^^

PrimesSlow.hs:31:28: warning: [-Wtype-defaults]
    • Defaulting the following constraints to type ‘Integer’
    <...>
   |
31 |     let primeNumberCount = firstOrDefault args 100
   |                            ^^^^^^^^^^^^^^^^^^^^^^^

PrimesSlow.hs:34:47: warning: [-Wtype-defaults]
    • Defaulting the following constraints to type ‘Integer’
    <...>
   |
34 |     putStrLn ("The latest prime number: " ++ (show number))
   |                                               ^^^^^^^^^^^

Поправить можно просто написав сигнатуры с Int


Исправленный код
import System.Environment (getArgs)

firstOrDefault :: [String] -> Int -> Int
firstOrDefault args defaultValue =
    if length args == 1
        then read (head args)
        else defaultValue

getJ :: Int -> Int -> Int -> Int
getJ j i number =
    if i > number
        then j
        else if number `rem` i == 0
                then getJ (j + 1) (i + 1) number
                else getJ j (i + 1) number

getNumber :: Int -> Int -> Int
getNumber primeNumberCount number =
    if primeNumberCount == 0
        then number
        else do
            let numberInc = number + 1
            let j = getJ 0 1 numberInc
            if j == 2
                then getNumber (primeNumberCount - 1) numberInc
                else getNumber primeNumberCount numberInc

main :: IO ()
main = do
    args <- getArgs
    let primeNumberCount = firstOrDefault args 100

    let number = getNumber primeNumberCount 0
    putStrLn ("The latest prime number: " ++ (show number))
Исправил. Такая мелочь, а какой профит:
-real    1m0.961s
-user    1m0.849s
-sys     0m0.108s
+real    0m18.971s
+user    0m18.965s
+sys     0m0.000s


Спасибо.

Хм, меня наоборот удивляет всего трехкратное ускорение. У меня под рукой только ноутбучный райзен, для бенчмарков такое себе, но на нем после этого изменения хаскель выполняется в районе c++/asm.


Результаты на моем железе
> docker build -t benchmark . > /dev/null && \
> docker run --rm benchmark ghc --version && echo '' && echo '' && \
> docker run --rm --volume $(pwd):/app benchmark ghc -O /app/prime-number/haskell/cmd.hs -o /app/prime-number/haskell/cmd.hs_bin > /dev/null && \
> docker run --rm --volume $(pwd):/app benchmark bash -c 'time /app/prime-number/haskell/cmd.hs_bin 7000'

The Glorious Glasgow Haskell Compilation System, version 8.6.5

The latest prime number: 70657

real    0m9.872s
user    0m9.863s
sys     0m0.003s

> docker build -t benchmark . > /dev/null && \
> docker run --rm benchmark nasm --version && echo '' && echo '' && \
> docker run --rm --volume $(pwd):/app benchmark nasm -f elf -Ox /app/prime-number/assembler/cmd.asm && \
> docker run --rm --volume $(pwd):/app benchmark ld -O3 -m elf_i386 /app/prime-number/assembler/cmd.o -o /app/prime-number/assembler/cmd.asm_bin && \
> docker run --rm --volume $(pwd):/app benchmark bash -c 'time /app/prime-number/assembler/cmd.asm_bin 7000'

NASM version 2.14.02

The latest prime number: 000070657

real    0m9.867s
user    0m9.860s
sys     0m0.001s

> docker build -t benchmark . > /dev/null && \
> docker run --rm benchmark g++ --version && echo '' && echo '' && \
> docker run --rm --volume $(pwd):/app benchmark g++ -O0 /app/prime-number/c++/cmd.cpp -o /app/prime-number/c++/cmd.cpp_bin && \
> docker run --rm --volume $(pwd):/app benchmark bash -c 'time /app/prime-number/c++/cmd.cpp_bin 7000'

g++ (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

The latest prime number: 70657

real    0m9.850s
user    0m9.841s
sys     0m0.002s

Если у Вас все еще "дело вечером, делать нечего", не могли бы Вы приложить вывод


ghc -O /app/prime-number/haskell/cmd.hs -o /app/prime-number/haskell/cmd.hs_bin -ddump-simpl -ddump-stg -ddump-cmm -ddump-asm -fforce-recomp -dsuppress-all -dno-suppress-idinfo -dno-suppress-type-signatures
/app/prime-number/haskell/cmd.hs_bin 7000 +RTS -s

Было бы интересно попробовать разобраться, в чем там дело.


Неплохо бы на всякий случай попробовать компилировать с -O2 вместо -O, хотя у меня это вроде ни на что не влияет.


Еще можно вместо возврата j из getJ сразу сравнивать j с двойкой и вызывать getNumber. Этим убирается нехвостовой вызов getJ из getNumber, ассемблер становится куда красивее, но разницы по времени у себя я опять же не заметил. Ну и не знаю, не слишком ли это оптимизация под конкретный язык. Хотя как вообще определять что тут оптимизированная версия, а что нет, если обе серьезно отличаются от оригинала, так как циклов нет.


Оптимизираовнный код
import System.Environment ( getArgs )

getNumber :: Int -> Int -> Int
getNumber primeNumberCount number = if primeNumberCount == 0 then number else calcJAndRecurse 0 1
  where
    numberInc = number + 1

    calcJAndRecurse :: Int -> Int -> Int
    calcJAndRecurse j i
      | i > numberInc          = getNumber (primeNumberCount - if j == 2 then 1 else 0) numberInc
      | numberInc `rem` i == 0 = calcJAndRecurse (j + 1) (i + 1)
      | otherwise              = calcJAndRecurse j       (i + 1)

main :: IO ()
main = do
  args <- getArgs
  let primeNumberCount = if null args then 100 else read $ head args
  putStrLn $ "The latest prime number: " <> show (getNumber primeNumberCount 0)
The Glorious Glasgow Haskell Compilation System, version 8.6.5

The latest prime number: 70657
58,440 bytes allocated in the heap
3,576 bytes copied during GC
44,672 bytes maximum residency (1 sample(s))
24,960 bytes maximum slop
0 MB total memory in use (0 MB lost due to fragmentation)

Tot time (elapsed) Avg pause Max pause
Gen 0 0 colls, 0 par 0.000s 0.000s 0.0000s 0.0000s
Gen 1 1 colls, 0 par 0.000s 0.000s 0.0001s 0.0001s

INIT time 0.000s ( 0.000s elapsed)
MUT time 18.919s ( 18.923s elapsed)
GC time 0.000s ( 0.000s elapsed)
EXIT time 0.000s ( 0.000s elapsed)
Total time 18.920s ( 18.924s elapsed)

%GC time 0.0% (0.0% elapsed)

Alloc rate 3,088 bytes per MUT second

Productivity 100.0% of total user, 100.0% of total elapsed

real 0m18.925s
user 0m18.917s
sys 0m0.004s

Спасибо, но это только вывод выполнения, а тут самое интересное — дампы компилятора. Вы похоже их по привычке отправили в /dev/null (

Да, не знаю даже что и думать. Асм совпадает один-в-один, так что если только какое-нибудь выравнивание влияет, но я что-то сомневаюсь что этим можно объяснить такую огромную разницу.


Если Вам все это еще не надоело, было бы интересно посмотреть что сделает llvm бекенд, и как будет вести себя мой слегка оптимизированный код выше.


llvm можно поставить аптом,


RUN apt install -y llvm-10

И запускать как-то так:


docker build -t benchmark . > /dev/null && \
docker run --rm benchmark ghc --version && echo '' && echo '' && \
docker run --rm --volume $(pwd):/app benchmark ghc -O2 /app/prime-number/haskell/cmd.hs -o /app/prime-number/haskell/cmd.hs_bin -fllvm -pgmlo=opt-10 -pgmlc=llc-10 -optlo="-O3" -optlc="-O3" > /dev/null && \
docker run --rm --volume $(pwd):/app benchmark bash -c 'time /app/prime-number/haskell/cmd.hs_bin 7000'

PS: Было бы неплохо если кто-нибудь еще прогнал бенчи у себя на машине, а то мне уже начинает казаться что у меня какой-то специальный процессор для хаскеля стоит

Там надо 4 варианта: {opt, unopt}* {lazy, strict}

НЛО прилетело и опубликовало эту надпись здесь

Про Crystal не забудь пожалуйста

Не знаю откуда у вас мифы про медленный PHP, он давно самый быстрый из интерпретируемых
Быстрее JS?
откуда у вас мифы про медленный PHP

Со времен четвертой версии это тянется. До сих пор адептов того времени полно.
Python медленнее даже php 5.6. Вот это я не знал
Питонист поставил минус)

Недавно была задача (помогал человеку с дипломом) - переписать через зад сделанную прогу с шарпа (которого, кстати, нет в статье) на питон. Прога состояла чисто из циклов и проверок if-else. При этом я умудрился убрать кучу ненужного кода и провёл все оптимизации, какие смог. Всё равно итоговое детище работало раз в 10 медленнее изначального...

Чисто теоретически — возможно-ли оптимизировать компилятор, чтобы он выдавал наиболее оптимальный код именно для таких простейших тестов? При этом для реально существующих задач код оставался неоптимальным.

Компилятор имеет разные уровни оптимизации, чем больше он знает тем агрессивнее может оптимизировать код. Поэтому во всех компилируемых языках есть подсказки для компилятора. вроде #[inline] в Rust. Если компилятору удается понять что какой то кусок кода можно вычислить и заменить на число он это сделает. Тот же Rust нужно аккуратно бенчить на простых примерах.

Отлично, теперь я знаю, на чем искать простые числа

НЛО прилетело и опубликовало эту надпись здесь
ассемблер будет быстрее Си

Вот как раз насчет таких заблуждений я и написал данную статью. Разница между ассемблером и Си — на уровне погрешности (если брать в сравнение результаты с флагами оптимизации). Конечно, если использовать особенности языка и аппаратной платформы, то здесь без вариантов. Но не стоит забывать что и на Си можно использовать свои лазейки. А время затраченное не написание кода — просто не сравнимо.

Современные компиляторы могут делать удивительные вещи.

PS. Первоначальный вариант на Assembler/NASM был медленнее Си где-то на 15%. И все из-за того что в регистрах были не те переменные.
НЛО прилетело и опубликовало эту надпись здесь
Что-то я не понял. Какое количество чисел искалось в тесте? Дефолтное 100 пролетает незаметно для time -p. Вот что выдает оптимизированный С код.
$ time -p ./prime 100
The latest prime number: 541
real 0.00
user 0.00
sys 0.00
$ time -p ./prime 1000
The latest prime number: 7919
real 0.08
user 0.06
sys 0.00
$ time -p ./prime 10000
The latest prime number: 104729
real 5.30
user 5.30
sys 0.00
собрано так
$ icc -O3 -xHost -qopt-report=5 -qopt-report-phase=vec prime.cpp -o prime
вот что репортит компилятор
LOOP BEGIN at prime.cpp(16,9)
remark #15305: vectorization support: vector length 8
remark #15309: vectorization support: normalized vectorization overhead 0.317
remark #15355: vectorization support: j is int type reduction [ prime.cpp(14,15) ]
remark #15300: LOOP WAS VECTORIZED
remark #15475: — begin vector cost summary — remark #15476: scalar cost: 30
remark #15477: vector cost: 10.250
remark #15478: estimated potential speedup: 2.830
remark #15482: vectorized math library calls: 1
remark #15488: — end vector cost summary — LOOP END
Как видим векторизация цикла хорошо ускоряет код. Это то, что в основном недоступно в скриптовых языках.
Современные компиляторы (нативных языков типа С или Фортрана) непросто обогнать даже на ассемблере. Те времена, когда можно было легко получить 15% давно в прошлом.
НЛО прилетело и опубликовало эту надпись здесь
Исправил методику.
НЛО прилетело и опубликовало эту надпись здесь
Дефолтное 100 я использую для проверки что алгоритм корректно работает (лень писать каждый раз при тестах). Что касается тестов, то там используется 7000.

Что касается 15% — все именно так и есть.
C# — очень даже хотелось бы увидеть в рейтинге
В графике приводится Java и PHP (JIT), но сравнивается с интерпретированием Lua и Python.
Для более корректного сравнения стоит добавить тесты с использованием luajit и PyPy (даже без изменений кода)
на моей машине запуск lua кода через luajit работает в 5 раз быстрее интерпретатора (9s vs 46s) и всего в 2 раза медленнее С кода (4.5s)
Добавил LuaJIT и PyPy.
На моей машине почему LuaJIT получил быстрее С/C++ и Asm.
Пока не могу понять в чем фишка. Есть идеи?
из наиболее вероятных: luajit версия была запущена с 5000 первым аргументом. но даже в таком случае на моей машине тесты (luajit test.lua 5000 vs c_ver 7000) давали разницу 5%, но не 33%.
хотя если так, то ответ был бы другой.

у меня luajit быстрее С с -O3 никогда не считал такие вот числодробилки (интегралы, сортировки) быстрее. на трех разных машинах.

я начал было думать, что в luajit-2.1.0-beta3 какие-то особые оптимизации по сравнению с luajit-2.0.4, установил, попробовал — также
почитал про кэши, про предсказания ветвлений в процессорах, прикинул кол-во операций (2.5млрд взятия остатков, чуть больше сложений) — отбросил эти мысли. если процессор хорошо оптимизирует переходы и встраивается в вычисления (сам выполняет преждевременный выход из цикла, когда нет смысла дальше считать), то ускорение должно быть сильно больше, чем получено в итоге.

могу лишь предложить протестировать в одинаково холодных условиях С optimized и luajit версии, желательно с разницей во времени в несколько минут.
про авторазгон частоты процессора ничего не читал, но подозреваю что такое может существовать и сильно влиять на результаты времени вычислений.
нет, с 7000 все запускаю
решил проверить на двух VPS'ках:

первая VPS:
:$ lscpu
Architecture: i686
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 40 bits physical, 48 bits virtual
CPU(s): 1
On-line CPU(s) list: 0
Thread(s) per core: 1
Core(s) per socket: 1
Socket(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 45
Model name: Intel(R) Xeon(R) CPU E5-2620 0 @ 2.00GHz


Assembler/NASM — 15.0s
Assembler/NASM (optimized compilation) — 15.0s
Lua — 1m49s
Lua (LuaJIT) — 26s

вторая VPS:
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 48 bits physical, 48 bits virtual
CPU(s): 2
On-line CPU(s) list: 0,1
Thread(s) per core: 2
Core(s) per socket: 1
Socket(s): 1
NUMA node(s): 1
Vendor ID: AuthenticAMD
CPU family: 23
Model: 1
Model name: AMD EPYC 7571


Assembler/NASM — 14.0s
Assembler/NASM (optimized compilation) — 14.0s
Lua — 1m20s
Lua (LuaJIT) — 8s

Если в системе только 1cpu — все норм, LuaJIT не быстрее Assembler, но теряется все тесты с *optimized compilation* — вообще без эфекта на производительность (вроме раста)

Как только 2+ cpu — LuaJIT становится быстрее Assembler. Все остальные JIT — норм. Но всегда работает только одно ядро (--cpus=«1» — не влияет)

PS. Первая мысть у меня — *предсказания ветвлений*, но пока не знаю как проверить или опровергнуть эту теорию (больно странный результат). Прогретые кеши не дадут такого прироста к скорости
такое ощущение, что компилятор (или процессор) делают два предсказания-оптимизации:
1. первые несколько четных значений они реально вычисляют и видя что четные значения не влияют на primeNumberCount и видя, что цикл внутри одинаковый, а считать ещё долго, выкидывают проверку четных чисел
2. остаток деления нечетного числа на четное не может быть 0, сколько ни старайся.
в итоге и получается, что без этих оптимизаций luajit работает в 2 раза медленнее, а с ними в 2 раза быстрее.
если бы дело было только в оптимизациях процессора, то тогда результаты по ассемблеру на втором процессоре показали бы более привлекательные результаты. но если дело только в компиляторе, то почему он не оптимизирует этот же алгоритм в первом случае? и на других машинах?
могу предположить, что дело в векторизации (которую делает luajit после анализа), но в этих делах моя «экспертность» заканчивается и дальше начинаются «а вот может быть вот так, а может и не так. а может по-другому»

На месте разработчиков компиляторов я сделал бы такие же оптимизации. Хоть это и очень редкая задача. такое должно, по-хорошему, оптимизироваться на уровне исходного кода программистами…
итогового ответа у меня пока нет, нужны более осведомленные люди или какое-то время, пока я не разберусь (а разобраться в этом интересно)
чтобы внутренние проверки для четных чисел не были пропущены, могу предложить вот такое:
C++
g++ test.c -Ofast -std=c++11 -o test

#include <stdio.h>
#include <stdlib.h>
#include <cstdint>

int main( int argc, char *argv[] )
{
    uint16_t primeNumberCount = 100;
    if (argc == 2) {
        primeNumberCount = atoi(argv[1]);
    }
    uint32_t keeper = 0;
    uint32_t number = 0;
    while (primeNumberCount > 0) {
        ++number;

        uint16_t j = 0;
        for (uint32_t i = 1; i <= number; ++i) {
            if (number % i == 0) {
                ++j;
            }
        }
        if (j % 2 == 1) {
            ++keeper;
        }
        if (j == 2) {
            --primeNumberCount;
        }
    }

    printf("The latest prime number: %d\n", number);
    printf("Keeper: %d\n", keeper); // usage
}


Lua
local primeNumberCount = 100;
if #arg == 1 then
    primeNumberCount = tonumber(arg[1]);
end

local number = 0;
local keeper = 0
while primeNumberCount > 0 do
    number = number + 1

    local j = 0;
    for i = 1, number do
        if number % i == 0 then
            j = j + 1;
        end
    end
    if (j % 2) == 1 then
	keeper = keeper + 1
    end
    if j == 2 then
        primeNumberCount = primeNumberCount - 1;
    end
end

print('The latest prime number: ', number);
print('keeper ', keeper); -- usage


для сохранения бессмысленных проверок четных чисел на простоту (кроме 2) можно сделать нечто такое:
C++
#include <stdio.h>
#include <stdlib.h>
#include <cstdint>

int main( int argc, char *argv[] )
{
    uint16_t primeNumberCount = 100;
    if (argc == 2) {
        primeNumberCount = atoi(argv[1]);
    }
    uint32_t keeper = 0;
    uint32_t number = 0;
    uint32_t meanless = 0;
    while (primeNumberCount > 0) {
        ++number;

        uint16_t j = 0;
        if (number % 2 == 1) {
            for (uint32_t i = 1; i <= number; ++i) {
                if (number % i == 0) {
                    ++j;
                }
            }
        } else {
            for (uint32_t i = 1; i <= number; ++i) {
                if (number % i == 0) {
                    ++j;
                } else {
                    ++meanless;
                }
            }
        }
        if (j % 2 == 1) {
            ++keeper;
        }
        if (j == 2) {
            --primeNumberCount;
        }
    }

    printf("The latest prime number: %d\n", number);
    printf("Keeper: %d\n", keeper); // usage
    printf("Meow: %d\n", meanless); // usage
}


Lua
local primeNumberCount = 100
if #arg == 1 then
    primeNumberCount = tonumber(arg[1])
end

local number = 0
local keeper = 0
local meanless = 0
while primeNumberCount > 0 do
    number = number + 1

    local j = 0
    if number % 2 == 1 then
        for i = 1, number do
            if number % i == 0 then
                j = j + 1
            end
        end
    else
        for i = 1, number do
            if number % i == 0 then
                j = j + 1
            else
                meanless = meanless + 1
            end
        end
    end
    if (j % 2) == 1 then
        keeper = keeper + 1
    end
    if j == 2 then
        primeNumberCount = primeNumberCount - 1
    end
end

print('The latest prime number: ', number)
print('keeper: ', keeper) -- usage
print('Meow: ', meanless) -- usage


на скорость вычислений это особо не влияет, ибо нахождение остатка более тяжелая операция, чем сложение. времена выполнения у меня всё те же, прибавилось максимум мс 80-100 (2-3%)

вывод для С. У luajit время 19.5с
The latest prime number: 70657
Keeper: 265
Meow: 1247527516

real	0m9.723s
user	0m9.636s
sys	0m0.000s

если в таком случае на обоих процессорах luajit будет в 2 раза медленее С++, то да, luajit хорошо оптимизирует операции, чтобы не считать лишнего. если будет та же магия, то видимо это связано с оптимизациями для конкретного процессора.

P.S. на что способны предсказатели ветвлений я не знаю. если они умеют оптимизировать и такой странный код, то честь и хвала им.

Немного появилось свободного времени.

По поводу вашего последнего примера - AMD Ryzen 7 4800HS резулльтаты следующие:

c++ - 8.504s
luajit - 8.846s

Я несколько дней переписывал тест, так что бы исключить лишние вычисления для обоих ЯП. Перепробовал кучу вариантов оптимизаций, но все мимо. Luajit всегда был был быстрее C++.

Потом дело дошло до флагов оптимизации Luajit - http://luajit.org/running.html

Все что удалось получить на данный момент:

docker build -t benchmark . > /dev/null && \
docker run --rm benchmark g++ --version && echo '' && echo '' && \
docker run --rm --volume $(pwd):/app benchmark g++ -O0 /app/prime-number/c++/cmd.cpp -o /app/prime-number/c++/cmd.cpp_bin && \
docker run --rm --volume $(pwd):/app benchmark bash -c 'time /app/prime-number/c++/cmd.cpp_bin 7000'

g++ (Ubuntu 10.3.0-1ubuntu1) 10.3.0
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.



The latest prime number: 70657

real    0m8.467s
user    0m8.462s
sys     0m0.005s
docker build -t benchmark . > /dev/null && \
docker run --rm benchmark luajit -v && echo '' && echo '' && \
docker run --rm --volume $(pwd):/app benchmark bash -c 'time luajit -O3,-loop /app/prime-number/lua/cmd.lua 7000'

LuaJIT 2.1.0-beta3 -- Copyright (C) 2005-2017 Mike Pall. http://luajit.org/


The latest prime number:        70657

real    0m14.579s
user    0m14.572s
sys     0m0.000s

Основное влияние на производительнось дает этот флаг: loop

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

Эти тесты не корректные, потому что тут учитывается не только время вычислений, но и время старта приложения. Нужно в коде считать время вычислений и выводить в конце выполнения.
Вообще ни слова о том, как тестировалось. Методика есть?
А что именно, кстати, тестировалось? Вот Питон — это время работы бинарного файла, или время работы бинарного файла плюс компиляция, или же это вообще вместе с запуском CPython?
А файловый кэш прогревали?
А докер на каком образе собирали? Потому что образ Ubuntu и Alpine в некоторых тестах совсем разную производительность показывают.
Вся инфа о том как запускалось и с какими версиями есть на гитхабе
Я так и не смог понять почему time снаружи контейнера, а не внутри. Это же вносит серьёзную погрешность на малых временах.
Теперь уже внутри, спасибо
С Rust(optimized) кажется ошибка. Запускается с rustc -O то есть с opt-level=2 при том что cargo run --release (рекомендуемая команда запуска) по-умолчанию выставляет opt-level=3. При том что С++ собирается с -Ofast который оптимизирует сильнее -OO3.
Исправил на -C opt-level=3
Для python существует компилятор Numba, который именно для таких числодробилок предназначен.
Модикации минимальные:
1) код нужно оформить как отдельную функцию (это само по себе приносит прирост в 1.5 раза примерно и является хорошим стилем оформления кода)
2) применить декоратор njit
3) желательно использовать внутренний цикл while (опционально)
Время исполнения на i3-8100 (3.6 ГГц) — 2.6-2.7 с.
код
import time
from numba import njit
primeNumberCount = 5000
@njit
def find_primes(primeNumberCount):
    number = 0
    while (primeNumberCount > 0):
        number += 1
        j = 0
        i = 0
        while i <(number + 1):
            i+=1
            if (number % i == 0):
                j += 1
        if (j == 2):
            primeNumberCount -= 1
    return number

t0 = time.time()
number = find_primes(primeNumberCount)
print(time.time() - t0)
print ('The latest prime number: ', number)


Еще вопрос. У вас указан процессор i5-9400, для которого применяется турбобуст. Фиксировалась ли при тестировании частота?
GO 1.13? Серьёзно? Он вышел в августе 2019, то есть ему почти 2 года

Это что угодно, но не бенчмаркинг.

Судя по потому что пхп с джитом и без на числодробилке показал одинаковый результат, методика тестирования действительно хромает

Разные результаты:
  1. PHP 8.0 — 37.559s
  2. PHP 8.0 (JIT) — 30.414s

А должна быть примерно в 2 раза. Вот, например, результаты на моей машине этого же кода (добавил только таймер для получения времени).


Для 1000 итераций:


  1. PHP 8.0 — 0.62016010284424s
  2. PHP 8.0 JIT — 0.380774974823s

Для 7000 итераций (как у автора):


  1. PHP 8.0 — 53.366537094116s
  2. PHP 8.0 JIT — 29.954167842865s
Многое зависит от железа:
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 44 bits physical, 48 bits virtual
CPU(s): 16
On-line CPU(s) list: 0-15
Thread(s) per core: 2
Core(s) per socket: 8
Socket(s): 1
NUMA node(s): 1
Vendor ID: AuthenticAMD
CPU family: 23
Model: 96
Model name: AMD Ryzen 7 4800HS with Radeon Graphics


  1. php8 — 0m25.576s
  2. php8 (jit) — 0m8.511s

Ну вот, тем более. У вас вот разница в 3+ раза. В любом случае отличие в 15% в исходном материале больше похоже на погрешность и/или артефакты, нежели на реальное включение/отключение JIT.

Я вот тоже удивился такой маленькой разнице. Уж на такой синтетике JIT должен просто летать. Результаты на Macbook Pro 2020 (2GHz i5):

JIT: 0m8.558s
не JIT: 0m25.604s
смотреть на такой неэффективный алгоритм сил не было.
1. зачем проверять четные числа на простоту? половину вычислений можно убрать
2. если число не делится ни на какое (кроме 1) до квадратного корня из этого числа, то число не будет делиться ни на что после (кроме самого числа). на больших числах это убирает огромное кол-во проверок.
3. зачем проверять деление всех чисел до корня, если составные числа среди оставшихся нечетных чисел встречаются довольно часто, а количество проверок при этом большое. можно каждые, например, 20 проверок делать ещё одну: если число уже составное, дальше не проверять.

с такими оптимизациями алгоритм для 5000 простых чисел выдает результат в 400 раз быстрее: luajit, 9.3s vs 0.020s; C, 4.5s vs 0.012s. Для 500000 С показывает 5.3s, luajit — 9.35s, неоптимизированные версии
для таких значений запускать не буду (она для С, чувствую, будет считать несколько дней, не говоря уже про вызов такого кода в режиме интерпретирования...)

код на C
gcc test.c -o test -Ofast -march=native -lm

#include <stdio.h>
#include <stdlib.h>
#include <math.h>

int main( int argc, char *argv[] )
{
    unsigned primeNumberCount = 100-2;
    if (argc == 2) {
        primeNumberCount = atoi(argv[1])-2;
    }
            
    unsigned number = 3;
    unsigned count = 20;
    while (primeNumberCount > 0) {
        number+=2;
            
        register unsigned j = 0;
        unsigned i = 1;
        unsigned max = floor(sqrt(number))+1;
        while (i<max) {
            unsigned num = ((max-i)<count ? (max-i) : ((max<count) ? max : count));
            
            for (unsigned k = 0; k < num; ++i, ++k) {
                if (number % i == 0) {
                    ++j;
                }
            }
            if (j>1) {
                break;
            }
        }

        if (j == 1) {
            --primeNumberCount;
        }
    }

    printf("The latest prime number: %u\n", number);
}


код на Lua
local primeNumberCount = 100-2 -- 2 и 3 не проверять
if #arg == 1 then
    primeNumberCount = tonumber(arg[1])-2
end

local number = 3 -- 2 и 3 не проверять
local count = 20
while primeNumberCount > 0 do
    number = number + 2 -- пропускать четные

    local j = 0
	local i = 1
	local max = math.floor(math.sqrt(number))+1 -- исключить лишние проверки
	while i<max do
		local num = ((max-i)<count and (max-i) or ((max<count) and max or count))
		for k = 1, num do
		    if number % i == 0 then
		        j = j + 1
		    end
			i = i + 1
		end
		if j>1 then -- если уже понятно, что число составное, то перейти к следующему
			break
		end
	end

    if j == 1 then
        primeNumberCount = primeNumberCount - 1
    end
end

print('The latest prime number: ', number)

ну и финальная оптимизация: сохранять все найденные простые числа в список, чтобы не проверять делимость на составные числа
теперь можно считать и 3.000.000 первых простых чисел с помощью Lua (и luajit) за 20 секунд, а не 5.000 за 46с

ясное дело что статья писалась для сравнения порядков скорости разных ЯП, но такие комментарии лишь напоминание, что кроме выбора ЯП большую роль играют и оптимизации исходного кода
код на C++
#include <math.h>
#include <iostream>
#include <vector>

int main( int argc, char *argv[] )
{
    uint32_t primeNumberCount = 100-2;
    if (argc == 2) {
        primeNumberCount = atoi(argv[1])-2;
    }
    
    std::vector<uint32_t> primes{2,3};
    primes.reserve(primeNumberCount);
    uint32_t number = 3;
    while (primeNumberCount > 0) {
        number+=2;

        const uint32_t max = floor(sqrt(number));
        bool isPrime(true);
        for (auto& i: primes) {
            if (number % i == 0 ){
                isPrime = false;
                break;
            }
            if (i>max) {
                break;
            }
        }
        if (isPrime){
            --primeNumberCount;
            primes.push_back(number);
        }

    }

    std::cout << "The latest prime number: " << number << std::endl;
}


код на Lua
local primeNumberCount = 100-2
if #arg == 1 then
    primeNumberCount = tonumber(arg[1])-2
end

local number = 3
local count = 20
local primes = {2,3}
while primeNumberCount > 0 do
    number = number + 2

    local max = math.floor(math.sqrt(number))
    local isPrime = true
    for _,i in ipairs(primes) do
        if number%i == 0 then
            isPrime = false
            break
        end
        if i>max then
            break
        end
    end

    if isPrime then
        primeNumberCount = primeNumberCount - 1
        table.insert(primes, number)
    end
end

print('The latest prime number: ', number)

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

Но смысл данного кода не в том что бы он работал максимально быстр, а в том что бы его можно было максимально быстро воспроизвести на других ЯП.

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

тогда к чему писать какой-то синтетический алгоритм? можно написать print(«hello, world») и делать замеры на этом
простые синтетические тесты плохи тем, что они не показывают как язык/инструмент будет справляться с реальной задачей, а не с какой-то одноразовой (кому нужно вычислять простые числа больше одного раза?).
при этом сложные синтетические тесты требуют много времени на подготовку и как минимум среднюю квалификацию по каждому ЯП/инструменту, чтобы суметь его более-менее грамотно реализовать — это всё долго и дорого, но это лучше будет демонстрировать способность языков программирования решать широкий класс задач.

изначальный код автора демонстрирует только как разные ЯП справляются с сомнительной полезности однотипными вычислениями. ЯП умеют только это? много ли алгоритмов решения реальных задач требуют только такие вычисления и ничего более?

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

а про регистры: в этом случае можно компилировать и без этого ключевого слова (для этой задачи), тесты на этой задаче показали, что компилятор справляется и без моих подсказок ему. ну и: а почему это стандартные средства языка — чит код? в этой задаче все подходящие условия для использования этого функционала? значит надо использовать!

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

я бы никаких нападок не делал на использованный метод автора (даже при большой неэффективности), если бы он, например, считал интеграл по методу Симпсона с каким-то мелким шагом; если бы был реализован алгоритм размытия гаусса и обработка двумерного массива (например, шум Перлина); или был бы реализован например такой алгоритм: массив заполняется ГПСЧ (одинаковым для всех языков), один проход по циклу фильтрует массив (создавая новый) из чисел в каком-то диапазоне значений из первого массива, второй проход цикла сортировал бы его и, например, выводил бы 20 наибольших (а-ля бэк магазина, HR или риелторского агенства).
это больше похоже на реальный код + затрагивает больше возможностей языка.

хотя конечно простенькие вычисления термодинамические, квантовохимические или квантовофизические были бы максимально похожи на реальное использование числодробительных алгоритмов, чем вот это вот всё, но кто их будет реализовывать…
n this test, the framework's ORM is used to fetch all rows from a database table containing an unknown number of Unix fortune cookie messages (the table has 12 rows, but the code cannot have foreknowledge of the table's size). An additional fortune cookie message is inserted into the list at runtime and then the list is sorted by the message text. Finally, the list is delivered to the client using a server-side HTML template. The message text must be considered untrusted and properly escaped and the UTF-8 fortune messages must be rendered properly.


Если вы можете хоть что-то из перечисленного воспроизвести для Assembler/NASM, то я очень сильно вам завидую. Ибо я не в состоянии даже убрать лишние нули в выводе числа для asm: 000070657
Да и основной посыл теста в том, что он одинаков для любого ЯП. А реализовать сложный тест на разных ЯП — не так просто.
Полез в след за автором ковыряться и неожиданно для себя обнаружил что если код на python обернуть в функцию и вызвать эту функцию то код работает в 1,67 раз быстрее (на 40%).
Если код завернутый в функцию запустить через:
— pypy3 будет в 8 раз быстрее от исходного
— mypyc всего в 5 раз быстрее
— nuitka всего в 2

ПХПшники реально молодцы что смогли затащить jit в основную кодовую базу
Да, попробуйте еще numba и после этого замените внутренний for на while — тоже прирост есть.
numba это узкоспециализированная штука, не работает в реальности почти никогда. pypy и mypyc это практически полная замена. без модификации кода. смысла менять код для данного бенчмарка нет. так то я и на чистом питоне смогу переписать и получить прирост в несколько раз. я скорее хотел посмотреть как далеко продвинулись разные проекты по глобальному ускорению питона
Соглашусь отчасти, так как алгоритм который использует сложные структуры данных, даже встроенные, зачастую не заведется на numba. Но вот если вы имеете функцию с вложенными циклами, множеством if|else и у вас все переменные — числа или массивы чисел с неизменной длинной — numba в таком случае сработает почти всегда. То есть работает она с узким подмножеством средств языка, но работает довольно хорошо. Данный алгоритм реализуется именно такими средствами, так что именно сюда подходит неплохо, как мне кажется.
Впрочем, спасибо за подсказки альтернатив, про mypyc почти не слышал до этого, про nuitka слышал в роли предшественника numba, нужно будет попробовать.
Эмм… А какую долю в каждом из тестов занимает парсинг командной строки?
Бенчи точно меряют то, что заявлено?

Разница в работе программы написанной на С/C++ и Assembler/NASM в районе 15%

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

P.S. Почему в C++ тесте используется std::atoi ? В 17-м стандарте ввели std::from_chars, который намного быстрее. Уже поддерживается реализациями stdlibc++ от MSVC (с 16-й версии) и GCC (с 12-й версии)

По поводу atoi и from_char: я не настолько слежу за стандартами C++, просто не знал что такое есть (теперь знаю, спасибо). Но я не думаю что замена даст прирост, так как это желается лишь один раз. Основная нагрузка в матиматике и циклах

Интересно. Интересно, а чего раст так плохо себя показал?
Мне вот долгое время не давало покоя то, что руби оказался вот прям настолько сильно хуже NodeJS.
И судя по всему ноду спас хитрый компилятор либо что-то еще.
Чисто по приколу написал 2 простых файла на создание классов, запихивание их в массив и т.д.

И NodeJs оказался почти в 2 раза медленнее рубей, это магия…
На моем компе нода выдала результат за 5+ секунд, а руби уложились в 2.8

Если кому интересно, то вот эти два файла:
gist.github.com/Zig1375/a972207158edb523ce04219d84885469

PS:
NodeJS 12 (для работы нужна 12, по этому тестил на ней)
Ruby 3.0.1
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории