インフィニットループ 技術ブログ

2022年06月06日 (月)

著者 : h-sato

PHPのparallelとFFIを試してみよう!

こんにちは、仙台支社のあっきーです。
PHPにはマルチスレッド処理が出来る拡張モジュール parallel、 C言語関数を呼び出すことができるFFI があります。parallel、FFIは手軽に試してみることが出来ます。マンデルブロ集合を計算するプログラムを題材にしてparallel、FFIを使うプログラムを書いて、実行時間がどの程度変化するか試してみました。

要約です

マンデルブロ集合を計算するプログラムを parallel 、FFIを使って書いてみました!
parallel、FFIを使用しないコードと比較して、手元での計算時間は以下のような感じになりました。

書き方計算時間
普通に書いた66.99秒
少し効率的に書いた9.84秒
parallelを使う1.39秒
ffi を使う(Rust)0.65秒
ffi を使う(Rustでマルチスレッド)0.25秒

準備

parallel と FFI を使用するには事前準備が必要になります。dockerの場合は以下のような準備が必要です。gd はマンデルブロ集合を画像に保存するためにインストールしています。

FROM php:7.4-zts

RUN apt-get update && apt-get install -y zlib1g-dev libpng-dev libffi-dev
RUN pecl install parallel \
      && docker-php-ext-enable parallel \
      && docker-php-ext-configure ffi --with-ffi \
      && docker-php-ext-install ffi \
      && docker-php-ext-install -j$(nproc) gd

普通に書いた

各書き方に共通して使用するlib.phpとmain.phpを用意しました。lib.phpには複素数を表すComplexクラスやマンデルブロ集合を計算する処理を書いています。普通に書いてみたら実行時間は66.99秒でした。

<?php
// lib.php
class Complex
{
    public float $re;
    public float $im;

    function __construct(float $re, float $im)
    {
        $this->re = $re;
        $this->im = $im;
    }

    public function add(Complex $other): Complex
    {
        return new Complex($this->re + $other->re, $this->im + $other->im);
    }

    public function mul(Complex $other): Complex
    {
        return new Complex(
            $this->re * $other->re - $this->im * $other->im,
            $this->re * $other->im + $this->im * $other->re
        );
    }

    public function squareNorm()
    {
        return $this->re * $this->re + $this->im * $this->im;
    }

    public function __toString()
    {
        return '(' . $this->re . ' x ' . $this->im . ')';
    }
}

function calcDiverge(Complex $c): int
{
    $z = new Complex(0, 0);
    for ($i = 0; $i < 255; $i++) {
        if ($z->squareNorm() > 4) return $i;

        $z = $z->mul($z)->add($c);
    }
    return 255;
}

function convertPoint(array $bounds, array $pixel, Complex $lower_left, Complex $upper_right): Complex
{
    $complex_width = $upper_right->re - $lower_left->re;
    $complex_height = $upper_right->im - $lower_left->im;
    return new Complex(
        $lower_left->re + $complex_width * ($pixel[0] / $bounds[0]),
        $lower_left->im + $complex_height * ($pixel[1] / $bounds[1]),
    );
}

function saveToFile(array $pixels, int $width, int $height, string $filename)
{
    $im = imagecreatetruecolor($width, $height);

    $colors = [];
    for ($i = 0; $i <= 255; $i++) {
        $colors[$i] = imagecolorallocate($im, $i, $i, $i);
    }

    for ($y = 0; $y < $height; $y++) {
        for ($x = 0; $x < $width; $x++) {
            $diverge_count = $pixels[$x + $y * $width];
            $color = $colors[255 - $diverge_count];
            imagesetpixel($im, $x, $height - $y - 1, $color);
        }
    }

    imagepng($im, $filename);
}

function calcMandelbrot(array $bounds, int $width, int $height, Complex $lower_left, Complex $upper_right): array
{
    $pixels = [];
    for ($y = 0; $y < $height; $y++) {
        for ($x = 0; $x < $width; $x++) {
            $c = convertPoint($bounds, [$x, $y], $lower_left, $upper_right);
            $pixels[$x + $y * $width] = calcDiverge($c);
        }
    }
    return $pixels;
}
<?php
// main.php
require_once dirname(__FILE__) . '/lib.php';

use parallel\{Future, Runtime};

$width = 300 * 4;
$height = 300 * 4;
$bounds = [$width, $height];

$lower_left = new Complex(-0.5877, 0.4527);
$upper_right = new Complex(-0.4597, 0.5807);

$start_time = microtime(true);

$pixels = calcMandelbrot($bounds, $width, $height, $lower_left, $upper_right);

$end_time = microtime(true);
echo ($end_time - $start_time) . " 秒\n";

saveToFile($pixels, $width, $height, 'mandelbrot.png');

少し効率的に書いた

上の処理はマンデルブロ集合の発散を判定する処理でオブジェクトをたくさん生成していそうです。
発散を判定する関数を以下に書き直してみたら、計算時間は66.99秒から9.84秒ほどに改善されました。変更した箇所を抜粋します。

// lib.php

function calcDiverge(float $c_re, float $c_im): int
{
    $z_re = $z_im = 0;
    for ($i = 0; $i < 255; $i++) {
        $square_norm = $z_re * $z_re + $z_im * $z_im;
        if ($square_norm > 4) return $i;

        $next_re = $z_re * $z_re - $z_im * $z_im + $c_re;
        $next_im = $z_re * $z_im + $z_im * $z_re + $c_im;

        $z_re = $next_re;
        $z_im = $next_im;
    }
    return 255;
}


function calcMandelbrot(array $bounds, int $width, int $height, Complex $lower_left, Complex $upper_right): array
{
    $pixels = [];
    for ($y = 0; $y < $height; $y++) {
        for ($x = 0; $x < $width; $x++) {
            $c = convertPoint($bounds, [$x, $y], $lower_left, $upper_right);
            $pixels[$x + $y * $width] = calcDiverge($c->re, $c->im);
        }
    }
    return $pixels;
}

parallelを使う

parallelを使ってマルチスレッド化しました。計算時間は9.84秒から1.39秒ほどに改善されました!変更した箇所を抜粋します。

// lib.php

function calcMandelbrotParallel(array $bounds, int $width, int $height, Complex $lower_left, Complex $upper_right, int $num_threads, int $i): array
{
    $pixels = [];
    for ($y = 0; $y < $height; $y++) {
        if ($y % $num_threads !== $i) continue;

        for ($x = 0; $x < $width; $x++) {
            $c = convertPoint($bounds, [$x, $y], $lower_left, $upper_right);
            $pixels[$x + $y * $width] = calcDiverge($c->re, $c->im);
        }
    }
    return $pixels;
}
// main.php

function calcMandelbrot(array $bounds, int $width, int $height, Complex $lower_left, Complex $upper_right)
{
    $task = static function ($bounds, $width, $height, Complex $lower_left, Complex $upper_right, $num_threads, $i): array {
        return calcMandelbrotParallel($bounds, $width, $height, $lower_left, $upper_right, $num_threads, $i);
    };

    $num_threads = 8;

    /** @var Future[] $futures */
    $futures = [];
    for ($i = 0; $i < $num_threads; $i++) {
        $thread = new Runtime(dirname(__FILE__) . '/lib.php');
        $futures[] = $thread->run($task, [$bounds, $width, $height, $lower_left, $upper_right, $num_threads, $i]);
    }

    $pixels = [];
    foreach ($futures as $future) {
        $sub_pixels = $future->value();
        foreach ($sub_pixels as $k => $v) $pixels[$k] = $v;
    }
    return $pixels;
}

FFI を使う(Rust)

次にFFIを使ってみました。計算時間は1.39秒から0.65秒ほどに改善されました!

Rust側のコードについて

まずはRustが使えるようにRust用のDockerfileを用意します。

FROM rust:1.61.0
WORKDIR /usr/src/myapp

イメージを作成してRustのプロジェクトを作成します。プロジェクトの作成にはcargoコマンドを使用します。

docker build -t my-rust-app .
docker run --rm -v "$PWD":/usr/src/myapp my-rust-app cargo new ffi_mandelbrot --lib
# windows で git bash 上から実行するときは "$PWD" の前にスラッシュをつける
# docker run --rm -v /"$PWD":/usr/src/myapp my-rust-app cargo new ffi_mandelbrot --lib

コマンドを実行すると以下のようなディレクトリ構成でプロジェクトが生成されます。

ffi_mandelbrot/
  ├ Cargo.toml
  └ src/
     └ lib.rs

Cargo.toml を以下のように編集します。

[package]
name = "ffi_mandelbrot"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

lib.rs を以下のように編集します。

use std::os::raw::c_float;
use std::slice;

fn calc_diverge(c_re: f64, c_im: f64) -> u8 {
    let mut z_re = 0.0;
    let mut z_im = 0.0;
    for i in 0..255 {
        let square_norm = z_re * z_re + z_im * z_im;
        if square_norm > 4.0 {
            return i as u8;
        }

        let next_re = z_re * z_re - z_im * z_im + c_re;
        let next_im = z_re * z_im + z_im * z_re + c_im;

        z_re = next_re;
        z_im = next_im;
    }
    255
}

fn convert_point(bounds: (usize, usize), pixel: (usize, usize), lower_left: (f64, f64), upper_right: (f64, f64)) -> (f64, f64) {
    let width = upper_right.0 - lower_left.0;
    let height = upper_right.1 - lower_left.1;
    (
        lower_left.0 + width * (pixel.0 as f64 / bounds.0 as f64),
        lower_left.1 + height * (pixel.1 as f64 / bounds.1 as f64)
    )
}

fn calc_mandelbrot(pixels: &mut [u8], bounds: (usize, usize), lower_left: (f64, f64), upper_right: (f64, f64)) {
    for px_y in 0..bounds.1 {
        for px_x in 0..bounds.0 {
            let point = convert_point(bounds, (px_x, px_y), lower_left, upper_right);
            pixels[bounds.0 * px_y + px_x] = calc_diverge(point.0, point.1);
        }
    }
}

#[no_mangle]
pub extern "C" fn main(data_ptr: *mut u8, len: usize, pixel_width: usize, pixel_height: usize, lower_left_x: c_float, lower_left_y: c_float, upper_right_x: c_float, upper_right_y: c_float) {
    let pixels = unsafe {
        slice::from_raw_parts_mut(data_ptr, len)
    };

    calc_mandelbrot(pixels, (pixel_width, pixel_height), (lower_left_x as f64, lower_left_y as f64), (upper_right_x as f64, upper_right_y as f64));
}

編集後、ビルドを行います。

cd ffi_mandelbrot
docker run --rm  -v "$PWD":/usr/src/myapp my-rust-app cargo build --release
# windows で git bash 上から実行するときは "$PWD" の前にスラッシュをつける

ffi_mandelbrot/target/release/libffi_mandelbrot.so に共有ライブラリが生成されます。

PHP側のコードについて

main.phpの場所にlibffi_mandelbrot.soをコピーしてこれをPHP側で使用するようにコードを修正します。

// main.php

function calcMandelbrot(array $bounds, int $width, int $height, Complex $lower_left, Complex $upper_right)
{
    $path = './libffi_mandelbrot.so';
    $signature = 'void main(char *data_ptr,int len,int pixel_width,int pixel_height,float lower_left_x,float lower_left_y,float upper_right_x,float upper_right_y);';
    $ffi = FFI::cdef($signature, $path);
    $pixels = $ffi->new('char[' . ($width * $height) . ']');
    $ffi->main($pixels, count($pixels), $width, $height, $lower_left->re, $lower_left->im, $upper_right->re, $upper_right->im);

    $result = [];
    foreach ($pixels as $p) $result[] = ord($p);

    return $result;
}

FFI を使う(Rustでマルチスレッド)

Rustのコードをマルチスレッドにしてみたところ、計算時間は0.65秒から0.25秒ほどに改善されました!
コードは長いので省略しますが、やってることはparallelで処理した内容と同じことをRustでも同様に書いています。(配列のチャンク事にマルチスレッドでマンデルブロ集合の発散判定の計算を行う)

まとめ

色々な方法でマンデルブロ集合の計算をおこないました。計算時間がかかる処理をparallel等で書き直すと目に見えて早くなるので楽しいです。インフィニットループでは、技術に熱意のあるエンジニアを募集しています。詳しくはこちらの求人詳細をご覧ください → 採用情報ページ (仙台支社特設ページはこちら

ブログ記事検索

このブログについて

このブログは、札幌市・仙台市の「株式会社インフィニットループ」が運営する技術ブログです。 お仕事で使えるITネタを社員たちが発信します!