Разбираемся с модулем FFI::Platypus::Lang::Fortran или сколько cpu time мы можем сэкономить на вычислительных задачах. Оценочные бенчмарки.
опубликован: 2024-07-12 19:21
последняя редакция: 2024-07-13 13:15

Вызываем фортран из перла

Как известно, слабое место Перла - математические вычисления. Меня всегда утешал тот факт, что в моих реальных задачах всякой арифметики не так уж и много. Но вот недавно задумался - а насколько медленно считает Перл на самом деле? И что, если его сравнить с признанным вычислительным лидером - фортраном?
Сейчас, благодаря модулю FFI::Platypus, подключить многие языки к своему коду не такая уж и проблема. Давайте попробуем и посмотрим, в каких ситуациях имеет смысл переключаться на Fortran.
Все эксперименты я проводил на стареньком компьютере под линуксом, Debian 12. Потребуется установить пакет gfortran - я не думаю, что он самый быстрый из существующих фортранов, но самый доступный в моей ситуации. Итак, конфигурация тестового стенда такая:
$cat /proc/cpuinfo
# Intel(R) Core(TM) i3-4130 CPU @ 3.40GHz

$cat /proc/meminfo
# MemTotal:        8041264 kB
# MemFree:         1913888 kB
# MemAvailable:    3822652 kB

$perl -v
# This is perl 5, version 36, subversion 0 (v5.36.0) built for x86_64-linux-gnu-thread-multi

$gfortran --version
# GNU Fortran (Debian 12.2.0-14) 12.2.0


Попытка 1
Для начала попробуем сравнить какую-нибудь стандартную функцию, скажем синус. Помимо скрипта с перловым тестом, подготовим фортранную библиотеку, которую будем дёргать из этого теста. Если вы пока не знакомы с языком Fortran - не беда. Примеры будут выглядеть понятными.
c       исходный файл: testlib.f
c       компиляция: gfortran -shared testlib.f -o testlib.so
c test1

        double precision function f77_sin(x)
        double precision x
        f77_sin = dsin(x)
        end function f77_sin

Если всё прошло успешно, то в нашей рабочей директории должен появиться библиотечный файл testlib.so, который мы будем вызывать из перла.

Теперь напишем тестовый perl-скрипт. Экспериментальным путём определил, что для сравнения скоростей мне требуется по крайней мере 10 млн итераций. Ещё обратите внимание, что при вызове фортранной функции я указал в качестве аргумента не скаляр, а ссылку на него. Из прилагаемой документации на metacpan-е этот момент был для меня не слишком очевиден.
#! /usr/bin/perl
use strict;
use warnings;

use FFI::Platypus 2.00;
use Benchmark;

my $ffi = FFI::Platypus->new(
  api  => 2,
  lang => 'Fortran',
  lib  => './testlib.so',
);

$ffi->attach(f77_sin => ['double*'] => 'double');

my $cycles = 10_000_000;
my $arg = 0.5;

sub perl_sin {
    return sin($_[0]);
}

my $res = undef;
my $t0 = Benchmark->new;

for (1..$cycles) {
    $res = perl_sin($arg);
}

my $t1 = Benchmark->new;
my $td = timediff($t1, $t0);
print "Perl:",timestr($td),"\n";
print "Result: $res\n\n";

#########################

$res = undef;
$t0 = Benchmark->new;

for (1..$cycles) {
    $res = f77_sin(\$arg);
}

$t1 = Benchmark->new;
$td = timediff($t1, $t0);
print "Fortran:",timestr($td),"\n";
print "Result: $res\n";

Результат выполнения нашего теста такой:
Perl: 2 wallclock secs ( 1.30 usr +  0.00 sys =  1.30 CPU)
Result: 0.479425538604203

Fortran: 2 wallclock secs ( 2.26 usr +  0.00 sys =  2.26 CPU)
Result: 0.479425538604203

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

Попытка 2
Усложним немного задачу. Из школьного курса математики многие помнят, что синус в квадрате плюс косинус в квадрате всегда даёт единицу, для любого аргумента. Проверим это.
В наш фортранный исходник добавим следующие строчки:
c       исходный файл: testlib.f
c test2
        double precision function f77_sin2cos2(x)
        double precision x
        f77_sin2cos2 = dsin(x)**2 + dcos(x)**2
        end function f77_sin2cos2

.. и заново перекомпилируем библиотеку gfortran -shared testlib.f -o testlib.so

Создадим новый тест:
#! /usr/bin/perl
use strict;
use warnings;

use FFI::Platypus 2.00;
use Benchmark;

my $ffi = FFI::Platypus->new(
  api  => 2,
  lang => 'Fortran',
  lib  => './testlib.so',
);

$ffi->attach(f77_sin2cos2 => ['double*'] => 'double');

my $cycles = 10_000_000;
my $arg = 0.5;

sub perl_sin2cos2 {
    return sin($_[0])**2 + cos($_[0])**2;
}

my $res = undef;
my $t0 = Benchmark->new;

for (1..$cycles) {
    $res = perl_sin2cos2($arg);
}

my $t1 = Benchmark->new;
my $td = timediff($t1, $t0);
print "Perl:",timestr($td),"\n";
print "Result: $res\n\n";

#########################

$res = undef;
$t0 = Benchmark->new;

for (1..$cycles) {
    $res = f77_sin2cos2(\$arg);
}

$t1 = Benchmark->new;
$td = timediff($t1, $t0);
print "Fortran:",timestr($td),"\n";
print "Result: $res\n";

И результат:
Perl: 3 wallclock secs ( 2.67 usr +  0.00 sys =  2.67 CPU)
Result: 1

Fortran: 2 wallclock secs ( 2.36 usr +  0.00 sys =  2.36 CPU)
Result: 1

Как видно, минимальное усложнение нашей задачи уже выводит фортран на первое место, хоть и чуть-чуть. Впрочем, пока повода замещать им перловую функцию не видно.

Попытка 3
Попробуем теперь сделать ещё один тест - на этот раз посчитаем полином восьмого порядка (произвольный, взятый из головы)
Итак,
c       исходный файл: testlib.f
c test3
        double precision function f77_polynom(x)
        double precision x
        f77_polynom = 100.d0 -5.d0*x + 7.d0*x**2 - x**3 - 6.5d0*x**4
     &      + 3.14d0*x**5 - 7.d0*x**6 - 6.4d0*x**7 - 0.1d0*x**8
        end function f77_polynom

Тестовый скрипт:
#! /usr/bin/perl
use strict;
use warnings;

use FFI::Platypus 2.00;
use Benchmark;

my $ffi = FFI::Platypus->new(
  api  => 2,
  lang => 'Fortran',
  lib  => './testlib.so',
);

$ffi->attach(f77_polynom => ['double*'] => 'double');

my $cycles = 10_000_000;
my $arg = 0.5;

sub perl_polynom {
    return 100 -5*$_[0] + 7*$_[0]**2 - $_[0]**3 - 6.5*$_[0]**4
        + 3.14*$_[0]**5 - 7*$_[0]**6 - 6.4*$_[0]**7 - 0.1*$_[0]**8;
}

my $res = undef;
my $t0 = Benchmark->new;

for (1..$cycles) {
    $res = perl_polynom($arg);
}

my $t1 = Benchmark->new;
my $td = timediff($t1, $t0);
print "Perl:",timestr($td),"\n";
print "Result: $res\n\n";

#########################

$res = undef;
$t0 = Benchmark->new;

for (1..$cycles) {
    $res = f77_polynom(\$arg);
}

$t1 = Benchmark->new;
$td = timediff($t1, $t0);
print "Fortran:",timestr($td),"\n";
print "Result: $res\n";

И результат:
Perl: 6 wallclock secs ( 5.94 usr +  0.00 sys =  5.94 CPU)
Result: 98.657109375

Fortran: 2 wallclock secs ( 2.45 usr +  0.00 sys =  2.45 CPU)
Result: 98.657109375


Ну, наконец-то! Фортран всё-таки уделал Перл в два с хвостиком раза. Судя по всему, во всех тестах где-то 2 фортранных CPU - это накладные расходы на его вызов. Но вы помните, что мы прокручиваем 10 млн циклов теста? При единичном вызове функции во всех случаях величина CPU в районе абсолютного нуля.

Попытка 4
Давайте, для очистки совести, последний тест прогоним в другом режиме: десять миллионов циклов закрутим не внутри перла, а внутри фортрана и поймём, сколько стоит многократный вызов. А чтобы корректно сравнивать - перловую функцию также вызовем однократно, а цикл будет внутри функции:
c       исходный файл: testlib.f
c test4
        double precision function f77_cycpolynom(x)
        double precision x, res

        do i = 1, 10000000
            res = 100.d0 -5.d0*x + 7.d0*x**2 - x**3 - 6.5d0*x**4
     &          + 3.14d0*x**5 - 7.d0*x**6 - 6.4d0*x**7 - 0.1d0*x**8
        end do

        f77_cycpolynom = res
        end function f77_cycpolynom

Тестовый скрипт примет такую форму:
#! /usr/bin/perl
use strict;
use warnings;

use FFI::Platypus 2.00;
use Benchmark;

my $ffi = FFI::Platypus->new(
  api  => 2,
  lang => 'Fortran',
  lib  => './testlib.so',
);

$ffi->attach(f77_cycpolynom => ['double*'] => 'double');

my $cycles = 10_000_000;
my $arg = 0.5;

sub perl_polynom {
    my $res;
    for (1..$cycles) {
        $res = 100 -5*$_[0] + 7*$_[0]**2 - $_[0]**3 - 6.5*$_[0]**4
            + 3.14*$_[0]**5 - 7*$_[0]**6 - 6.4*$_[0]**7 - 0.1*$_[0]**8;
    }
    return $res
}

my $res = undef;
my $t0 = Benchmark->new;

$res = perl_polynom($arg);

my $t1 = Benchmark->new;
my $td = timediff($t1, $t0);
print "Perl:",timestr($td),"\n";
print "Result: $res\n\n";

#########################

$res = undef;
$t0 = Benchmark->new;

$res = f77_cycpolynom(\$arg);

$t1 = Benchmark->new;
$td = timediff($t1, $t0);
print "Fortran:",timestr($td),"\n";
print "Result: $res\n";

Итоговый результат:
Perl: 5 wallclock secs ( 4.83 usr +  0.00 sys =  4.83 CPU)
Result: 98.657109375

Fortran: 0 wallclock secs ( 0.26 usr +  0.00 sys =  0.26 CPU)
Result: 98.657109375

Преимущество фортрана при подсчёте полинома выросло с двух раз до почти двадцати!

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