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

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

Прежде чем приступить к изложению сути темы, расскажу историю из практики. Когда-то я занимался автоматизацией такого страшного явления как “аттестация педагогических работников”. Суть заключалась в том, чтобы разработать систему, куда доблестные труженики образования загрузят документы, подтверждающие успехи в работе - грамоты, дипломы учеников, и далее по списку. Стек: PHP+Python (если хотите знать, что тут делает Python - почитайте первую статью), JS (jQuery) на фронте. 

Конечно же, никакого ТЗ не было, порядок внесения изменений напоминал постройку железной дороги прямо перед движущимся паровозом. Закономерным следствием такого подхода стали некоторые “особенности” в работе системы. Так, например, список отправленных на рассмотрение портфолио для администратора формировался несколько минут. Однако это всех устраивало, потому что “внесение изменений может что-то сломать, а так оно работает пусть медленно, зато предсказуемо”. Оно и по сей день работает медленно, но предсказуемо.

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

Один таких интерфейсов, для повышения быстродействия - FFI. Суть FFI (Foreign Function Interface или интерфейс внешней функции), как явления, состоит в том, что языки программирования, имеющие данный интерфейс, получают возможность загружать общие библиотеки (.dll или .so), вызывать C-функции, получать доступ к структурам.

Для чего это нужно? Прежде всего, FFI кроме PHP, есть еще в Python и Ruby, что позволяет:

  • Использовать функции из общих библиотек, без необходимости переписывания алгоритмов;

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

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

Как это использовать? Для начала, версия php должна быть собрана с поддержкой ffi, в php.ini директива ffi.enable должна быть установлена в значение true.

Конечно же, чтобы вызывать функции из какой-либо библиотеки, нам понадобится эта самая библиотека. Можно использовать как системные, так и самописные библиотеки.

Стоит отдельно проговорить варианты загрузки и использования функций. Дело в том, что, для использования функций из .so или .dll библиотек, необходимо вызвать метод FFI::cdef(), и передать в него два параметра - определение экспортируемых функций и путь до библиотеки. Либо, с помощью FFI::load() передать путь к заголовочному файлу (.h), содержащему необходимую информацию. Оба метода вернут объект, через который можно вызывать экспортированные библиотечные функции так, как если бы это были методы класса.

FFI vs Pure PHP

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

<?php

function fib($n)
{
    if ($n === 1 || $n === 2) {
        return 1;
    }
    return fib($n - 1) + fib($n - 2);
}

$start = microtime(true);
for ($i = 0; $i < 1000000; $i++) {
    fib(12);
}

echo '[PHP] execution time, seconds: '.(microtime(true) - $start).PHP_EOL;
[PHP] execution time, seconds: 8.611447095871

Реализация на PHP выполняет вычисление в цикле, общее время работы составило 8.6 секунд. Что с FFI?

Для реализации подобной функции на FFI был выбран go, тулкит которого умеет собрать библиотеку.

package main

import (
	"C"
)

//export fibonacci
func fibonacci(n C.int) C.int {
	if n < 2 {
		return 1
	}

	return fibonacci(n-2) + fibonacci(n-1)
}

func main() {}

Сборка библиотеки производится командой:

go build -o libfib.so -buildmode c-shared fibonacci.go

Чтобы функции, реализованные в go, можно было использовать через FFI в PHP необходимо:

  • Импортировать модуль C

  • пометить параметры и возвращаемые значения соответствующими типами C

  • Пометить функцию экспортируемой (директива //export func_name)

После сборки библиотеки, ее можно подключать:

<?php

function ffi_fib(): void
{
	$ffi = FFI::cdef("int fibonacci(int n);", __DIR__."/libfib.so");
	$start = microtime(true);
	for ($i = 0; $i < 1000000; $i++) {
    	$ffi->fibonacci(12);
	}

	echo '[Go-FFI] execution time, seconds: '.(microtime(true) - $start).PHP_EOL;
}

ffi_fib();

Чтобы использовать собранную библиотеку, нужно проделать следующие шаги:

  • Вызвать FFI::cdef с двумя параметрами:

    • Определение экспортированной функции

    • Путь к библиотеке

  • В переменной $ffi появится хэндл на библиотеку, через эту переменную становится возможным вызывать функции, передавая в нее переменные, и получая результат. О “правильном” преобразовании типов позаботится PHP.

Результат выполнения:

[Go-FFI] execution time, seconds: 2.6853828430176

Сравним результаты:

Весьма неплохо, 2.6 против 8.6 - почти в 3 раза быстрее. Но PHP не так-то прост. Собственно, основные трудозатраты при выполнении “чистой PHP-версии” пришлись на трансляцию кода. Чтобы в этом убедиться - можно запустить скрипт с параметрами, обеспечивающими jit-компиляцию, и загружающими кешированный байт-код виртуальной машины PHP.

php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=128M index.php
[PHP] execution time, seconds: 2.3679571151733
[Go-FFI] execution time, seconds: 2.6448538303375

Теперь время выполнения сократилось, даже по сравнению с FFI, который в этом свете выглядит не так уж и красиво. Так нужен ли FFI? PHP-разработчики дружно пересевшие на Go\Rust\C++ и пилящие любую сколь-нибудь трудозатратную логику на этих языках программирования - картина, достойная кисти Босха.

Функции разные нужны, функции разные важны

Зайдем с другой стороны - оставим на время языки вроде Rust и C++, взглянем детальнее на Go. Что может дать Go, чего нельзя сделать на PHP? На ум сразу приходят варианты, связанные с многопоточным или асинхронным программированием, например опрос множества endpoint`ов по протоколу HTTP. 

Наиболее показательной в этом контексте будет такая задача. Допустим, у нас есть несколько внешних api-методов, которые нам требуется время от времени опрашивать. Решение такой задачи на чистом PHP либо займет много времени (запросы будут идти по очереди), либо будет связана с необходимостью опереться на дополнительную инфраструктуру, которая, в свою очередь, обеспечит распараллеливание запросов.

Попробуем решить задачу с помощью FFI. Что нужно сделать:

  • Передать в библиотечную функцию список URL для опроса;

  • Опросить URL по списку, с упором на максимальное быстродействие;

  • Вернуть результат в PHP-скрипт, который займется интерпретацией результатов.

Для начала реализуем библиотечную функцию, выполняющую опрос некоторого списка URL. Для упрощения передачи аргументов и значений будем считать, что аргумент это строка, содержащая разделенный запятыми список URL для проверки. Значение также выдается в виде строки, содержащей JSON с результатами опроса.

package main

import (
    "C"
    "encoding/json"
    "net/http"
    "strings"
)

//export getStatus
func getStatus(urls *C.char) *C.char {
    urlList := strings.Split(C.GoString(urls), ",")
    c := make(chan urlStatus)
    for _, url := range urlList {
   	 go checkUrl(url, c)
    }
    result := make([]urlStatus, len(urlList))
    for i, _ := range result {
   	 result[i] = <-c
    }

    data, err := json.Marshal(result)
    if err != nil {
   	 return C.CString("")
    } 
    return C.CString(string(data))
}

func checkUrl(url string, c chan urlStatus) {
    _, err := http.Get(url)
    if err != nil {
   	 c <- urlStatus{url, false}
    } else {
   	 c <- urlStatus{url, true}
    }
}

func main() {}

type urlStatus struct {
    Url	string `json:"url"`
    Status bool   `json:"status"`
}

Осталось запустить и проверить работу, для сравнения - сделаем то же самое на PHP, без сторонних библиотек.

<?php

$urlsToTest = [
	'https://google.com',
	'https://mail.ru',
	'https://not-exists.com.ru.gov',
	'https://vk.com',
	'https://habr.com',
	'https://kremlin.ru'
];

$startPure = microtime(true);

foreach($urlsToTest as $url) {
	try {
    	@file_get_contents($url);
	} catch (\Exception $ex) {}
}

echo '[Pure PHP] execution time, seconds: '.(microtime(true) - $startPure).PHP_EOL;

$ffi = FFI::cdef("char* getStatus(char* data);", __DIR__."/httpasync.so");
$start = microtime(true);
$result = FFI::string($ffi->getStatus(implode(',',$urlsToTest)));

echo '[Go-FFI] execution time, seconds: '.(microtime(true) - $start).PHP_EOL;

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

[Pure PHP] execution time, seconds: 62.797710180283
[Go-FFI] execution time, seconds: 30.010102033615

Результат выдается в виде строки, содержащей JSON-сериализованную структуру с результатами опроса.

string(263) "[{"url":"https://not-exists.com.ru.gov","status":false},{"url":"https://mail.ru","status":true},{"url":"https://vk.com","status":true},{"url":"https://habr.com","status":true},{"url":"https://google.com","status":true},{"url":"https://kremlin.ru","status":false}]"

Ну и чтобы быть уж совсем честным - PHP с JIT показывает время, менее чем на секунду меньшее, в отличие от некешированного оригинала.

Сравним результаты:

[Pure PHP] execution time, seconds: 61.922262907028
[Go-FFI] execution time, seconds: 30.003116846085

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

Вывод

FFI в PHP (да и в других языках) это технология, позволяющая: 

  • “срезать углы” на тяжелых вычислениях, которые, из-за особенностей языка не могут быть оптимизированы штатными средствами;

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

Однако ее использование в среднестатистическом проекте повлечет за собой дополнительные расходы, связанные с сокрытием некоторой части логики в черный ящик. Таким образом Минздрав имени Лердорфа предупреждает - применять для симптоматической терапии, серебряной пулей не является.

Также в преддверии старта курса PHP Developer. Professional от OTUS хочу пригласить всех на бесплатный мастер-класс: "Элементы DDD в PHP". Зарегистрироваться на мастер-класс можно по ссылке ниже.

Комментарии (9)


  1. tregubovand
    27.09.2022 18:49
    +10

    PHP конечно страдает от отсутсвия нативной паралельности и наверняка есть много задач в которых FFI спасает. Но все-таки для приведенного примера гораздо проще было бы использовать традиционный подход - засунуть все урлы в curl_multi и читать их по готовности, думаю тогда разница по времени будет значительно менее заметна.


    1. iexx
      27.09.2022 22:59
      +3

      ну и даже на PHP уже давно есть асинхронное программирование с промисами


      1. shushu
        28.09.2022 08:15

        Это вы про swoole?


        1. patricksafarov
          28.09.2022 14:03
          +3

          Не только, есть react-php, есть amphp.


    1. zorn-v42
      29.09.2022 06:51

      Вот блин, зарегался для того чтобы сказать, что пример не удачный - юзай curl )

      Да ни от чего он не спасает. Или вы реально думаете, что те кто написал гавнокод на пхп, сделают мастерские вещи на си/раст/го/whatever ?

      Скорее накостылят костылей накостыльных потому что "начальство сказало".


  1. ivegner
    28.09.2022 12:48
    +2

    Тема stream_select не раскрыта.


  1. mixsture
    28.09.2022 13:30
    +3

    Имхо, очень узкоспециализированная штука вышла. Как показывают примеры, где php+jit обгоняют вариант с ffi — там скорее всего медленная передача параметров в/из с какой-нибудь непростой трансляцией типов. Т.е. применять в случаях где часто-часто вызывается функция невыгодно.
    А после какого-то порога, где функция вызывается редко, но делает долгие и тяжелые вычисления — гораздо больше плюсов даст создание интерфейса через http, позволив отвязать язык/окружение вызывающей стороны от исполняющей. Между этими порогами остается не такой уж большой простор для применения ffi.

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


  1. LevOrdabesov
    28.09.2022 19:36
    +1

    КДПВ суровое, конечно.


  1. Geekzik
    30.09.2022 14:39

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