From 725b16e8e492846ca86cdeed34f19034142d6654 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:54:17 +0000 Subject: [PATCH 01/15] feat(matrix): add asFloat() conversion to a float matrix The acceleration backends that follow compute in double precision only. asFloat() makes that widening available as ordinary, catchable userland code instead of an implicit side effect of picking a driver. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- src/Matrix.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Matrix.php b/src/Matrix.php index a83779f..0985793 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -153,6 +153,28 @@ public function toArray(): array return $this->matrix; } + /** + * Returns a copy of this matrix with every cell converted to a float + * + * The accelerated backends compute in double precision only, so this conversion makes explicit — in ordinary, + * catchable userland code — what those drivers do to an integer matrix internally. + * + * @return self Matrix with the same dimensions, holding floats + */ + public function asFloat(): self + { + $result = []; + foreach ($this->matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = (float) $cellValue; + } + $result[] = $resultRow; + } + + return new self($result); + } + /** * Performs multiplication of two matrices * From 9db027c6b1f0808685c8f192067525fee309e643 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:54:17 +0000 Subject: [PATCH 02/15] test(tests): cover asFloat() conversion to a float matrix Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- .../testCanCastMatrixToFloatMatrix.phpt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/Functional/testCanCastMatrixToFloatMatrix.phpt diff --git a/tests/Functional/testCanCastMatrixToFloatMatrix.phpt b/tests/Functional/testCanCastMatrixToFloatMatrix.phpt new file mode 100644 index 0000000..28dc48f --- /dev/null +++ b/tests/Functional/testCanCastMatrixToFloatMatrix.phpt @@ -0,0 +1,34 @@ +--TEST-- +Matrix can be converted to a float matrix with asFloat() +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +asFloat()->toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(1) + [1]=> + float(2) + } + [1]=> + array(2) { + [0]=> + float(3) + [1]=> + float(4.5) + } +} From 8ccdb353c09db623f5c1766ec3298d626d0bd2e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:58:52 +0000 Subject: [PATCH 03/15] feat(backend): extract pure-PHP maths into PhpBackend behind a Backends registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arithmetic of Matrix moves, unchanged down to its iteration order, into an interchangeable driver: PhpBackend. Matrix keeps the validation, the dimension guards and the hooks, and asks the registry which driver should carry out an operation. The registry validates a selection eagerly in userland — an unusable driver discovered inside an FFI callback would be a fatal error, not an exception — and defaults to routing that keeps every all-integer operation on pure PHP. Nothing observable changes yet: only one driver is registered. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- bootstrap.php | 6 + src/Backend/BackendInterface.php | 118 ++++++++ src/Backend/BackendNotAvailableException.php | 24 ++ src/Backend/Backends.php | 293 +++++++++++++++++++ src/Backend/PhpBackend.php | 150 ++++++++++ src/Matrix.php | 112 +++---- 6 files changed, 630 insertions(+), 73 deletions(-) create mode 100644 src/Backend/BackendInterface.php create mode 100644 src/Backend/BackendNotAvailableException.php create mode 100644 src/Backend/Backends.php create mode 100644 src/Backend/PhpBackend.php diff --git a/bootstrap.php b/bootstrap.php index 88f8601..1f6afe2 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -10,6 +10,7 @@ */ declare(strict_types=1); +use Lisachenko\NativePhpMatrix\Backend\Backends; use Lisachenko\NativePhpMatrix\Matrix; use ZEngine\Core; use ZEngine\Reflection\ReflectionClass as ReflectionClassEx; @@ -24,3 +25,8 @@ // Activate extensions for the Matrix class as it provides $matrixClassReflection = new ReflectionClassEx(Matrix::class); $matrixClassReflection->installExtensionHandlers(); + +// Apply the backend pinned by the environment, if any. This runs here, in plain userland code, precisely because +// it can fail: an unknown or unusable driver name throws a catchable exception now instead of surfacing as an +// engine-level fatal error later, from inside the operator hooks where exceptions may not cross the FFI boundary +Backends::bootFromEnvironment(); diff --git a/src/Backend/BackendInterface.php b/src/Backend/BackendInterface.php new file mode 100644 index 0000000..e28499f --- /dev/null +++ b/src/Backend/BackendInterface.php @@ -0,0 +1,118 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +/** + * Contract of an interchangeable matrix arithmetic driver + * + * A backend is a numeric kernel and nothing else: it receives plain arrays with the dimensions already known and + * returns a plain array of the same shape. Validation, dimension checks and the object identity remain the + * responsibility of {@see \Lisachenko\NativePhpMatrix\Matrix}, so a driver never has to construct one. + * + * Two rules bind every implementation: + * + * - **Hook safety.** These methods are reached from the `do_operation` handler, which runs inside an FFI callback + * where a thrown exception becomes an engine-level fatal error. Report an unusable driver from + * {@see self::isAvailable()} — which must swallow its own failures and return false — instead of throwing from + * an operation. The registry validates a selection eagerly, in ordinary userland code, for the same reason. + * - **Accelerated drivers are float-only.** Hardware kernels compute in double precision, so a driver that is not + * the pure-PHP one casts integer input to float and returns floats. Automatic routing takes that into account + * and keeps all-integer arithmetic on the pure-PHP driver. + */ +interface BackendInterface +{ + /** + * Tells whether this driver can be used in the current environment + * + * Implementations probe their libraries here — loading them and running a real, minimal operation, so that a + * missing symbol is discovered now rather than inside an engine hook. Failures are swallowed: an unusable + * driver reports false, it never throws. + */ + public function isAvailable(): bool; + + /** + * Adds two matrices of the same shape element-wise + * + * @param non-empty-list> $left Left operand cells + * @param non-empty-list> $right Right operand cells, same shape as the left one + * @param positive-int $rows Number of rows in both operands + * @param positive-int $columns Number of columns in both operands + * + * @return non-empty-list> Sum of both operands + */ + public function sum(array $left, array $right, int $rows, int $columns): array; + + /** + * Subtracts the right matrix from the left one element-wise + * + * @param non-empty-list> $left Left operand cells + * @param non-empty-list> $right Right operand cells, same shape as the left one + * @param positive-int $rows Number of rows in both operands + * @param positive-int $columns Number of columns in both operands + * + * @return non-empty-list> Difference of both operands + */ + public function subtract(array $left, array $right, int $rows, int $columns): array; + + /** + * Multiplies two matrices with matching inner dimensions + * + * @param non-empty-list> $left Left operand cells, shaped rows × inner + * @param non-empty-list> $right Right operand cells, shaped inner × columns + * @param positive-int $rows Number of rows of the left operand + * @param positive-int $inner Shared dimension: left columns and right rows + * @param positive-int $columns Number of columns of the right operand + * + * @return non-empty-list> Product, shaped rows × columns + */ + public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array; + + /** + * Multiplies every cell by a scalar value + * + * @param non-empty-list> $matrix Operand cells + * @param int|float $value Multiplier + * @param positive-int $rows Number of rows in the operand + * @param positive-int $columns Number of columns in the operand + * + * @return non-empty-list> Scaled cells + */ + public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array; + + /** + * Divides every cell by a scalar value + * + * @param non-empty-list> $matrix Operand cells + * @param int|float $value Divider + * @param positive-int $rows Number of rows in the operand + * @param positive-int $columns Number of columns in the operand + * + * @return non-empty-list> Divided cells + */ + public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array; + + /** + * Raises every cell to the power of a scalar value + * + * BLAS has no exponentiation primitive, so accelerated drivers implement this one with a float loop. It is + * part of the contract only to keep their float-only promise for every operator the class overloads. + * + * @param non-empty-list> $matrix Operand cells + * @param int|float $value Exponent + * @param positive-int $rows Number of rows in the operand + * @param positive-int $columns Number of columns in the operand + * + * @return non-empty-list> Exponentiated cells + */ + public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array; +} diff --git a/src/Backend/BackendNotAvailableException.php b/src/Backend/BackendNotAvailableException.php new file mode 100644 index 0000000..621fdbb --- /dev/null +++ b/src/Backend/BackendNotAvailableException.php @@ -0,0 +1,24 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use RuntimeException; + +/** + * Thrown when a known matrix backend cannot be used in the current environment + * + * This is a selection-time failure — it happens in ordinary userland code, either from an explicit + * {@see Backends::use()} call or while booting the selection from the environment, and it is therefore + * catchable. Backends never report unavailability from inside an engine hook. + */ +final class BackendNotAvailableException extends RuntimeException {} diff --git a/src/Backend/Backends.php b/src/Backend/Backends.php new file mode 100644 index 0000000..7f893fe --- /dev/null +++ b/src/Backend/Backends.php @@ -0,0 +1,293 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use function array_keys; +use function getenv; +use function implode; + +use InvalidArgumentException; + +use function is_string; +use function sprintf; + +use Throwable; + +use function trim; + +/** + * Registry of matrix arithmetic drivers and the policy that picks one per operation + * + * Drivers are registered under a short name with a lazy factory, so requiring this package never loads a shared + * library. Selection happens once, in userland: either through {@see self::use()} or from the + * `NATIVE_PHP_MATRIX_BACKEND` environment variable read while booting. Both validate eagerly and throw a + * catchable exception, which is the entire point — an invalid selection discovered later, inside the operator + * hooks, would surface as an engine-level fatal error instead. + * + * The default selection is `auto`, whose rules are deliberately boring and predictable: + * + * - an operand contains a float **and** an accelerated CPU driver probed successfully → that driver; + * - anything else, including every all-integer operation → the pure-PHP driver, whose results are bit-identical + * to the ones this library produced before drivers existed; + * - a GPU driver is never chosen automatically: moving data to a device is a decision, not a default. + */ +final class Backends +{ + /** + * Name of the environment variable that pins the driver for the whole process + */ + public const string ENVIRONMENT_VARIABLE = 'NATIVE_PHP_MATRIX_BACKEND'; + + /** + * Selection that routes every operation by operand types and driver availability + */ + public const string AUTO = 'auto'; + + /** + * Name of the always-available pure PHP driver + */ + public const string PHP = 'php'; + + /** + * Registered driver factories, keyed by driver name + * + * Null until the built-in drivers are registered, which happens on first use. + * + * @var array|null + */ + private static ?array $factories = null; + + /** + * Instantiated drivers, keyed by driver name + * + * @var array + */ + private static array $instances = []; + + /** + * Cached result of the availability probe of every driver that has been asked about + * + * @var array + */ + private static array $availability = []; + + /** + * Currently selected driver name, or {@see self::AUTO} + */ + private static string $selected = self::AUTO; + + /** + * Selects the driver to use for every following operation + * + * @param string $name Driver name, or {@see self::AUTO} to restore automatic routing + * + * @throws InvalidArgumentException When no driver is registered under that name + * @throws BackendNotAvailableException When the driver is known but unusable in this environment + */ + public static function use(string $name): void + { + if ($name === self::AUTO) { + self::$selected = self::AUTO; + + return; + } + + $factories = self::factories(); + if (!isset($factories[$name])) { + throw new InvalidArgumentException(sprintf( + 'Unknown matrix backend "%s", registered ones are: %s', + $name, + implode(', ', self::registered()), + )); + } + if (!self::probe($name)) { + throw new BackendNotAvailableException(sprintf( + 'Matrix backend "%s" is registered but not available in this environment', + $name, + )); + } + + self::$selected = $name; + } + + /** + * Registers a third-party driver under the given name, replacing an earlier one + * + * The factory is called at most once, and only when the driver is actually selected or probed, therefore this + * method never throws: a driver that cannot load reports it from {@see BackendInterface::isAvailable()}. + * + * @param string $name Short driver name, usable in {@see self::use()} and the env variable + * @param callable(): BackendInterface $factory Lazy factory producing the driver + */ + public static function register(string $name, callable $factory): void + { + $factories = self::factories(); + $factories[$name] = $factory; + self::$factories = $factories; + + unset(self::$instances[$name], self::$availability[$name]); + } + + /** + * Returns the current selection: a driver name or {@see self::AUTO} + */ + public static function active(): string + { + return self::$selected; + } + + /** + * Returns the names of every registered driver, whether usable here or not + * + * @return list + */ + public static function registered(): array + { + return array_keys(self::factories()); + } + + /** + * Returns the names of the drivers that are usable in this environment + * + * Probing loads the libraries and runs a minimal real operation with each of them, so the answer cannot + * disagree with what an operation would do a moment later. Results are cached for the process. + * + * @return list + */ + public static function available(): array + { + $available = []; + foreach (self::registered() as $name) { + if (self::probe($name)) { + $available[] = $name; + } + } + + return $available; + } + + /** + * Restores the pristine state: automatic routing and the built-in drivers only + * + * Third-party registrations, driver instances and cached probe results are dropped. + */ + public static function reset(): void + { + self::$factories = null; + self::$instances = []; + self::$availability = []; + self::$selected = self::AUTO; + } + + /** + * Applies the selection pinned by the environment, if there is one + * + * Called from the package bootstrap — that is ordinary userland code running long before any operator hook, so + * a bad value fails loudly and catchably right where the environment is wrong. + * + * @throws InvalidArgumentException When the variable names an unknown driver + * @throws BackendNotAvailableException When the named driver is unusable in this environment + */ + public static function bootFromEnvironment(): void + { + $name = getenv(self::ENVIRONMENT_VARIABLE); + if (!is_string($name) || trim($name) === '') { + return; + } + + self::use(trim($name)); + } + + /** + * Returns the driver that must carry out an operation with the given operand types + * + * This is the hot path of every overloaded operator and therefore never throws: an explicit selection was + * validated when it was made, and automatic routing has the always-available pure-PHP driver to fall back on. + * + * @param bool $operandsContainFloats Whether any operand of the operation holds a float + */ + public static function resolveFor(bool $operandsContainFloats): BackendInterface + { + if (self::$selected !== self::AUTO) { + return self::instance(self::$selected); + } + + // Automatic routing never sends integers to an accelerated driver, because those compute in double + // precision and would turn an exact integer result into a float. Operations on floats are the ones open + // to acceleration; no accelerated driver is registered in this build, so all of them stay on pure PHP + return self::instance(self::PHP); + } + + /** + * Runs the availability probe of a driver once and remembers the answer + * + * Every failure mode — a missing factory, a factory that blows up, a library that is not installed — collapses + * into "not available", because this question is asked in contexts that must not fail. + * + * @param string $name Registered driver name + */ + private static function probe(string $name): bool + { + if (isset(self::$availability[$name])) { + return self::$availability[$name]; + } + if (!isset(self::factories()[$name])) { + return self::$availability[$name] = false; + } + + try { + $available = self::instance($name)->isAvailable(); + } catch (Throwable) { + $available = false; + } + + return self::$availability[$name] = $available; + } + + /** + * Returns the driver registered under the given name, creating it on first request + * + * @param string $name Registered driver name + */ + private static function instance(string $name): BackendInterface + { + if (isset(self::$instances[$name])) { + return self::$instances[$name]; + } + + $factory = self::factories()[$name] ?? null; + if ($factory === null) { + // Unknown names cannot reach the operators: use() and bootFromEnvironment() reject them upfront, so + // the pure-PHP driver is the only sensible answer left for a name that disappeared from the registry + return self::$instances[$name] = new PhpBackend(); + } + + return self::$instances[$name] = $factory(); + } + + /** + * Returns the factory table, registering the built-in drivers on first access + * + * @return array + */ + private static function factories(): array + { + if (self::$factories === null) { + self::$factories = [ + self::PHP => static fn(): BackendInterface => new PhpBackend(), + ]; + } + + return self::$factories; + } +} diff --git a/src/Backend/PhpBackend.php b/src/Backend/PhpBackend.php new file mode 100644 index 0000000..8011d3a --- /dev/null +++ b/src/Backend/PhpBackend.php @@ -0,0 +1,150 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use function array_column; +use function array_keys; + +/** + * Reference driver: the original interpreted PHP arithmetic + * + * The loop bodies are the ones this library shipped before the drivers existed, kept verbatim down to the + * iteration order. That matters for more than nostalgia: PHP arithmetic preserves integers, and summing the + * products of a row in a different order can change the last bits of a float result. This driver is always + * available, it is the fallback of every other one, and it is the only driver that returns integers. + */ +final class PhpBackend implements BackendInterface +{ + /** + * Pure PHP is available wherever this library runs + */ + public function isAvailable(): bool + { + return true; + } + + /** + * {@inheritDoc} + */ + public function sum(array $left, array $right, int $rows, int $columns): array + { + $result = []; + foreach ($left as $rowIndex => $row) { + $anotherRow = $right[$rowIndex]; + $resultRow = []; + foreach ($row as $columnIndex => $cellValue) { + $resultRow[] = $cellValue + $anotherRow[$columnIndex]; + } + $result[] = $resultRow; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function subtract(array $left, array $right, int $rows, int $columns): array + { + $result = []; + foreach ($left as $rowIndex => $row) { + $anotherRow = $right[$rowIndex]; + $resultRow = []; + foreach ($row as $columnIndex => $cellValue) { + $resultRow[] = $cellValue - $anotherRow[$columnIndex]; + } + $result[] = $resultRow; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + { + // Columns of the multiplier are extracted only once, they are reused for every row of the left operand + $multiplierColumns = []; + foreach (array_keys($right[0]) as $column) { + $multiplierColumns[] = array_column($right, $column); + } + + $result = []; + foreach ($left as $rowItems) { + $resultRow = []; + foreach ($multiplierColumns as $columnItems) { + $cellValue = 0; + foreach ($rowItems as $key => $value) { + $cellValue += $value * $columnItems[$key]; + } + + $resultRow[] = $cellValue; + } + $result[] = $resultRow; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + $result = []; + foreach ($matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = $cellValue * $value; + } + $result[] = $resultRow; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + $result = []; + foreach ($matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = $cellValue / $value; + } + $result[] = $resultRow; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + $result = []; + foreach ($matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = $cellValue ** $value; + } + $result[] = $resultRow; + } + + return $result; + } +} diff --git a/src/Matrix.php b/src/Matrix.php index 0985793..1ff7725 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -12,13 +12,11 @@ namespace Lisachenko\NativePhpMatrix; -use function array_column; use function array_filter; use const ARRAY_FILTER_USE_KEY; use function array_is_list; -use function array_keys; use function count; use function get_mangled_object_vars; use function implode; @@ -31,6 +29,7 @@ use function is_numeric; use function is_string; +use Lisachenko\NativePhpMatrix\Backend\Backends; use LogicException; use function sprintf; @@ -77,14 +76,27 @@ final class Matrix implements /** * Total number of rows in this matrix + * + * @var positive-int */ private readonly int $rows; /** * Total number of columns in this matrix + * + * @var positive-int */ private readonly int $columns; + /** + * Whether at least one cell of this matrix is a float + * + * Collected while the cells are validated anyway, because the automatic backend routing needs the answer for + * every operation: accelerated drivers compute in double precision, so an all-integer operation has to stay + * on the pure-PHP driver to keep returning integers. + */ + private readonly bool $containsFloats; + /** * Matrix constructor * @@ -99,7 +111,8 @@ public function __construct(array $matrix) throw new InvalidArgumentException('Matrix should be a list of rows with sequential keys, starting from 0'); } - $columns = null; + $columns = null; + $containsFloats = false; foreach ($matrix as $rowIndex => $row) { if (!is_array($row) || !array_is_list($row)) { throw new InvalidArgumentException( @@ -120,12 +133,14 @@ public function __construct(array $matrix) sprintf('Matrix value at [%d][%d] should be either an int or a float', $rowIndex, $columnIndex), ); } + $containsFloats = $containsFloats || is_float($value); } } - $this->matrix = $matrix; - $this->rows = count($matrix); - $this->columns = count($matrix[0]); + $this->matrix = $matrix; + $this->rows = count($matrix); + $this->columns = count($matrix[0]); + $this->containsFloats = $containsFloats; } public function getRows(): int @@ -188,27 +203,15 @@ public function multiply(self $multiplier): self throw new InvalidArgumentException('Inconsistent matrix supplied'); } - // Columns of the multiplier are extracted only once, they are reused for every row of the left operand - $multiplierColumns = []; - foreach (array_keys($multiplier->matrix[0]) as $column) { - $multiplierColumns[] = array_column($multiplier->matrix, $column); - } - - $result = []; - foreach ($this->matrix as $rowItems) { - $resultRow = []; - foreach ($multiplierColumns as $columnItems) { - $cellValue = 0; - foreach ($rowItems as $key => $value) { - $cellValue += $value * $columnItems[$key]; - } + $backend = Backends::resolveFor($this->containsFloats || $multiplier->containsFloats); - $resultRow[] = $cellValue; - } - $result[] = $resultRow; - } - - return new self($result); + return new self($backend->multiply( + $this->matrix, + $multiplier->matrix, + $this->rows, + $this->columns, + $multiplier->columns, + )); } /** @@ -220,16 +223,9 @@ public function multiply(self $multiplier): self */ public function divideByScalar(int|float $value): self { - $result = []; - foreach ($this->matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue / $value; - } - $result[] = $resultRow; - } + $backend = Backends::resolveFor($this->containsFloats || is_float($value)); - return new self($result); + return new self($backend->divideByScalar($this->matrix, $value, $this->rows, $this->columns)); } /** @@ -241,16 +237,9 @@ public function divideByScalar(int|float $value): self */ public function multiplyByScalar(int|float $value): self { - $result = []; - foreach ($this->matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue * $value; - } - $result[] = $resultRow; - } + $backend = Backends::resolveFor($this->containsFloats || is_float($value)); - return new self($result); + return new self($backend->multiplyByScalar($this->matrix, $value, $this->rows, $this->columns)); } /** @@ -262,16 +251,9 @@ public function multiplyByScalar(int|float $value): self */ public function powByScalar(int|float $value): self { - $result = []; - foreach ($this->matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue ** $value; - } - $result[] = $resultRow; - } + $backend = Backends::resolveFor($this->containsFloats || is_float($value)); - return new self($result); + return new self($backend->powByScalar($this->matrix, $value, $this->rows, $this->columns)); } /** @@ -287,17 +269,9 @@ public function sum(self $value): self throw new InvalidArgumentException('Inconsistent matrix supplied'); } - $result = []; - foreach ($this->matrix as $rowIndex => $row) { - $anotherRow = $value->matrix[$rowIndex]; - $resultRow = []; - foreach ($row as $columnIndex => $cellValue) { - $resultRow[] = $cellValue + $anotherRow[$columnIndex]; - } - $result[] = $resultRow; - } + $backend = Backends::resolveFor($this->containsFloats || $value->containsFloats); - return new self($result); + return new self($backend->sum($this->matrix, $value->matrix, $this->rows, $this->columns)); } /** @@ -313,17 +287,9 @@ public function subtract(self $value): self throw new InvalidArgumentException('Inconsistent matrix supplied'); } - $result = []; - foreach ($this->matrix as $rowIndex => $row) { - $anotherRow = $value->matrix[$rowIndex]; - $resultRow = []; - foreach ($row as $columnIndex => $cellValue) { - $resultRow[] = $cellValue - $anotherRow[$columnIndex]; - } - $result[] = $resultRow; - } + $backend = Backends::resolveFor($this->containsFloats || $value->containsFloats); - return new self($result); + return new self($backend->subtract($this->matrix, $value->matrix, $this->rows, $this->columns)); } /** From aa9117ea2de5510903647a4092226ca943b2f91e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:00:39 +0000 Subject: [PATCH 04/15] test(tests): cover backend selection, registration and env-var routing Selection failures are asserted with try/catch on purpose: they are raised in userland, unlike the dimension mismatches raised inside the hooks, which can only be matched inside fatal-error output. The third-party driver test follows a registered stub all the way from the "+" operator, and the auto-routing test pins the rule that all-integer arithmetic stays on pure PHP even where acceleration is installed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- tests/Functional/include/MarkerBackend.inc | 78 +++++++++++++++++++ .../Functional/include/UnavailableBackend.inc | 58 ++++++++++++++ .../testAutoKeepsIntMatricesOnPhpBackend.phpt | 46 +++++++++++ .../Functional/testBackendDefaultsToAuto.phpt | 22 ++++++ .../testBackendEnvVarSelectsPhpBackend.phpt | 34 ++++++++ .../testCanRegisterThirdPartyBackend.phpt | 38 +++++++++ ...estFailsOnSelectingUnavailableBackend.phpt | 36 +++++++++ .../testFailsOnSelectingUnknownBackend.phpt | 27 +++++++ ...tFailsOnUnknownBackendFromEnvironment.phpt | 25 ++++++ 9 files changed, 364 insertions(+) create mode 100644 tests/Functional/include/MarkerBackend.inc create mode 100644 tests/Functional/include/UnavailableBackend.inc create mode 100644 tests/Functional/testAutoKeepsIntMatricesOnPhpBackend.phpt create mode 100644 tests/Functional/testBackendDefaultsToAuto.phpt create mode 100644 tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt create mode 100644 tests/Functional/testCanRegisterThirdPartyBackend.phpt create mode 100644 tests/Functional/testFailsOnSelectingUnavailableBackend.phpt create mode 100644 tests/Functional/testFailsOnSelectingUnknownBackend.phpt create mode 100644 tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt diff --git a/tests/Functional/include/MarkerBackend.inc b/tests/Functional/include/MarkerBackend.inc new file mode 100644 index 0000000..62c628a --- /dev/null +++ b/tests/Functional/include/MarkerBackend.inc @@ -0,0 +1,78 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +use Lisachenko\NativePhpMatrix\Backend\BackendInterface; +use Lisachenko\NativePhpMatrix\Backend\PhpBackend; + +/** + * Third-party driver stub that marks the cells it computes + * + * A real out-of-tree driver would call into its own library here. This one adds an unmistakable offset to every + * sum instead, so that seeing the marker in the result of `$a + $b` proves the operator hook went all the way + * through the registry into a driver that this package does not ship. Every other operation is delegated. + */ +final class MarkerBackend implements BackendInterface +{ + public const int MARKER = 1000; + + private PhpBackend $delegate; + + public function __construct() + { + $this->delegate = new PhpBackend(); + } + + public function isAvailable(): bool + { + return true; + } + + public function sum(array $left, array $right, int $rows, int $columns): array + { + $result = []; + foreach ($left as $rowIndex => $row) { + $anotherRow = $right[$rowIndex]; + $resultRow = []; + foreach ($row as $columnIndex => $cellValue) { + $resultRow[] = $cellValue + $anotherRow[$columnIndex] + self::MARKER; + } + $result[] = $resultRow; + } + + return $result; + } + + public function subtract(array $left, array $right, int $rows, int $columns): array + { + return $this->delegate->subtract($left, $right, $rows, $columns); + } + + public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + { + return $this->delegate->multiply($left, $right, $rows, $inner, $columns); + } + + public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + return $this->delegate->multiplyByScalar($matrix, $value, $rows, $columns); + } + + public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + return $this->delegate->divideByScalar($matrix, $value, $rows, $columns); + } + + public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + return $this->delegate->powByScalar($matrix, $value, $rows, $columns); + } +} diff --git a/tests/Functional/include/UnavailableBackend.inc b/tests/Functional/include/UnavailableBackend.inc new file mode 100644 index 0000000..b3e9d8f --- /dev/null +++ b/tests/Functional/include/UnavailableBackend.inc @@ -0,0 +1,58 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +use Lisachenko\NativePhpMatrix\Backend\BackendInterface; + +/** + * Driver that reports itself unusable, the way a driver whose library is missing does + * + * Its operations are never reachable: an unavailable driver cannot be selected, and automatic routing skips it. + * Failing this deterministically, without depending on which acceleration libraries the machine happens to have, + * is exactly why this stub exists. + */ +final class UnavailableBackend implements BackendInterface +{ + public function isAvailable(): bool + { + return false; + } + + public function sum(array $left, array $right, int $rows, int $columns): array + { + return $left; + } + + public function subtract(array $left, array $right, int $rows, int $columns): array + { + return $left; + } + + public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + { + return $left; + } + + public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + return $matrix; + } + + public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + return $matrix; + } + + public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + return $matrix; + } +} diff --git a/tests/Functional/testAutoKeepsIntMatricesOnPhpBackend.phpt b/tests/Functional/testAutoKeepsIntMatricesOnPhpBackend.phpt new file mode 100644 index 0000000..2b4a509 --- /dev/null +++ b/tests/Functional/testAutoKeepsIntMatricesOnPhpBackend.phpt @@ -0,0 +1,46 @@ +--TEST-- +Automatic routing keeps all-integer arithmetic on the pure PHP backend +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- +toArray()); +var_dump(($matrixA * 3)->toArray()); +?> +--EXPECT-- +string(4) "auto" +array(1) { + [0]=> + array(2) { + [0]=> + int(6) + [1]=> + int(8) + } +} +array(1) { + [0]=> + array(2) { + [0]=> + int(6) + [1]=> + int(9) + } +} diff --git a/tests/Functional/testBackendDefaultsToAuto.phpt b/tests/Functional/testBackendDefaultsToAuto.phpt new file mode 100644 index 0000000..cc804cc --- /dev/null +++ b/tests/Functional/testBackendDefaultsToAuto.phpt @@ -0,0 +1,22 @@ +--TEST-- +Matrix arithmetic defaults to automatic backend routing with pure PHP always available +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- + +--EXPECT-- +string(4) "auto" +bool(true) diff --git a/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt b/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt new file mode 100644 index 0000000..2acf802 --- /dev/null +++ b/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt @@ -0,0 +1,34 @@ +--TEST-- +The NATIVE_PHP_MATRIX_BACKEND environment variable pins the backend at bootstrap +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=php +--FILE-- +toArray()); +?> +--EXPECT-- +string(3) "php" +array(1) { + [0]=> + array(2) { + [0]=> + float(2) + [1]=> + float(3) + } +} diff --git a/tests/Functional/testCanRegisterThirdPartyBackend.phpt b/tests/Functional/testCanRegisterThirdPartyBackend.phpt new file mode 100644 index 0000000..47be954 --- /dev/null +++ b/tests/Functional/testCanRegisterThirdPartyBackend.phpt @@ -0,0 +1,38 @@ +--TEST-- +A third-party backend can be registered and serves the overloaded operators +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + new MarkerBackend()); +Backends::use('marker'); +var_dump(Backends::active()); + +// The engine dispatches "+" to the do_operation hook, which resolves the driver through the registry: seeing the +// marker offset in the result proves the whole path, from the operator down to an out-of-tree driver +$matrixA = new Matrix([[1, 2]]); +$matrixB = new Matrix([[10, 20]]); +var_dump(($matrixA + $matrixB)->toArray()); +?> +--EXPECT-- +string(6) "marker" +array(1) { + [0]=> + array(2) { + [0]=> + int(1011) + [1]=> + int(1022) + } +} diff --git a/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt b/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt new file mode 100644 index 0000000..f2c543e --- /dev/null +++ b/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt @@ -0,0 +1,36 @@ +--TEST-- +Selecting a registered backend that is unavailable fails with a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- + new UnavailableBackend()); + +var_dump(in_array('unavailable', Backends::registered(), true)); +var_dump(in_array('unavailable', Backends::available(), true)); + +try { + Backends::use('unavailable'); +} catch (BackendNotAvailableException $exception) { + echo $exception->getMessage(), PHP_EOL; +} +var_dump(Backends::active()); +?> +--EXPECT-- +bool(true) +bool(false) +Matrix backend "unavailable" is registered but not available in this environment +string(4) "auto" diff --git a/tests/Functional/testFailsOnSelectingUnknownBackend.phpt b/tests/Functional/testFailsOnSelectingUnknownBackend.phpt new file mode 100644 index 0000000..3fd7a2c --- /dev/null +++ b/tests/Functional/testFailsOnSelectingUnknownBackend.phpt @@ -0,0 +1,27 @@ +--TEST-- +Selecting a backend that is not registered fails with a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- +getMessage(), PHP_EOL; +} +var_dump(Backends::active()); +?> +--EXPECTF-- +Unknown matrix backend "quantum", registered ones are: %s +string(4) "auto" diff --git a/tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt b/tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt new file mode 100644 index 0000000..4c46a70 --- /dev/null +++ b/tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt @@ -0,0 +1,25 @@ +--TEST-- +An unknown backend in the environment fails while booting, as a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=quantum +--FILE-- +getMessage(), PHP_EOL; +} +?> +--EXPECTF-- +InvalidArgumentException +Unknown matrix backend "quantum", registered ones are: %s From 713a60910b0a65d862850c092ad03332440ac55c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:04:40 +0000 Subject: [PATCH 05/15] feat(backend): add OpenBLAS driver with dgemm/daxpy/dscal over FFI Matrix multiplication becomes a cblas_dgemm call, addition and subtraction a cblas_daxpy over the flattened cells, and the scalar operations a cblas_dscal. The library is declared inline and loaded lazily from a list of LP64 sonames; the ILP64 build is deliberately never probed, its wider integers would corrupt every dimension argument. The driver proves itself with a real 1x1 multiplication before reporting that it is available, because a missing symbol discovered inside an operator hook would be a fatal error rather than an exception. Automatic routing picks it for float operands only, wrapped so that a failure at operation time recomputes in pure PHP. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- .php-cs-fixer.dist.php | 9 +- composer.json | 14 +- phpstan.dist.neon | 16 ++ src/Backend/AcceleratedBackendTrait.php | 143 +++++++++++++ src/Backend/Backends.php | 49 ++++- src/Backend/BlasBackend.php | 256 ++++++++++++++++++++++++ src/Backend/FallbackBackend.php | 116 +++++++++++ 7 files changed, 592 insertions(+), 11 deletions(-) create mode 100644 src/Backend/AcceleratedBackendTrait.php create mode 100644 src/Backend/BlasBackend.php create mode 100644 src/Backend/FallbackBackend.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index aa86055..4e76f5b 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -14,7 +14,14 @@ $finder = PhpCsFixer\Finder::create() ->in([__DIR__ . '/src']) ->name('*.php') - ->append([__FILE__, __DIR__ . '/bootstrap.php']); + ->append([__FILE__, __DIR__ . '/bootstrap.php']) + // Test fixtures are ordinary PHP holding the shared backend stubs and SKIPIF probes; the ".inc" suffix only + // keeps them out of the PHPUnit suite, which collects ".phpt" files + ->append( + PhpCsFixer\Finder::create() + ->in([__DIR__ . '/tests/Functional/include']) + ->name('*.inc'), + ); return (new PhpCsFixer\Config()) ->setRiskyAllowed(true) diff --git a/composer.json b/composer.json index a0033e9..4ff5b23 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,12 @@ "name": "lisachenko/native-php-matrix", "description": "PHP extension that provides Matrix class powered with overloaded operators", "type": "library", - "keywords": ["ffi", "matrix", "operator-overloading", "z-engine"], + "keywords": [ + "ffi", + "matrix", + "operator-overloading", + "z-engine" + ], "license": "MIT", "authors": [ { @@ -22,11 +27,16 @@ "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^12.5" }, + "suggest": { + "lib-openblas": "Install the OpenBLAS shared library (Debian/Ubuntu: libopenblas0, Homebrew: openblas) to enable the CPU-accelerated \"blas\" backend" + }, "autoload": { "psr-4": { "Lisachenko\\NativePhpMatrix\\": "src/" }, - "files": ["bootstrap.php"] + "files": [ + "bootstrap.php" + ] }, "scripts": { "test": "phpunit", diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 487ad35..eee027f 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -5,3 +5,19 @@ parameters: - src - bootstrap.php treatPhpDocTypesAsCertain: false + ignoreErrors: + # FFI buffers are indexable at runtime — that is the whole point of a "double[n]" allocation — but PHPStan + # models FFI\CData as a plain object with neither ArrayAccess nor a known cell type. Reading a cell is + # therefore "mixed" to it, and writing one degrades the variable to "mixed" as well. Both are confined to + # the two helpers of the accelerated drivers that touch raw memory at all + - + identifier: offsetAccess.nonOffsetAccessible + path: src/Backend/AcceleratedBackendTrait.php + - + identifier: return.type + path: src/Backend/AcceleratedBackendTrait.php + # The cblas_* methods exist only after FFI::cdef() has parsed the inline C declarations of this driver, so + # no static analysis can see them on the FFI instance + - + identifier: method.notFound + path: src/Backend/BlasBackend.php diff --git a/src/Backend/AcceleratedBackendTrait.php b/src/Backend/AcceleratedBackendTrait.php new file mode 100644 index 0000000..6162a02 --- /dev/null +++ b/src/Backend/AcceleratedBackendTrait.php @@ -0,0 +1,143 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use function array_map; + +use FFI; +use FFI\CData; + +use function range; + +/** + * Shared plumbing of the drivers that hand their arithmetic to a numeric library + * + * Every such library speaks contiguous double precision memory, while this package speaks lists of rows. The two + * conversions live here, together with the one operation no BLAS implementation provides. + * + * The conversion is also where the float-only promise of these drivers is kept: packing casts each cell to a + * double, and unpacking returns floats, so an integer matrix that reaches an accelerated driver comes back as a + * float one. Automatic routing avoids that by keeping integer operations on the pure-PHP driver; an explicit + * selection accepts it deliberately. + */ +trait AcceleratedBackendTrait +{ + /** + * {@inheritDoc} + */ + public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + // Exponentiation is not part of BLAS: it stays an ordinary loop, in floats, so that an accelerated driver + // answers every overloaded operator with the same cell type + $result = []; + foreach ($matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = ((float) $cellValue) ** $value; + } + $result[] = $resultRow; + } + + return $result; + } + + /** + * Copies a matrix into a freshly allocated row-major buffer of doubles + * + * The buffer is owned by PHP: it is released when the returned handle goes out of scope, which is why no + * driver in this package frees host memory by hand. + * + * @param non-empty-list> $matrix Cells to copy + * @param positive-int $rows Number of rows + * @param positive-int $columns Number of columns + * + * @return CData Owned `double[rows * columns]` buffer holding the cells row by row + */ + private function packRowMajor(array $matrix, int $rows, int $columns): CData + { + $buffer = $this->allocate($rows * $columns); + $this->fillRowMajor($buffer, $matrix); + + return $buffer; + } + + /** + * Writes the cells of a matrix into a buffer, row by row + * + * @param CData $buffer Buffer with room for every cell + * @param non-empty-list> $matrix Cells to write + */ + private function fillRowMajor(CData $buffer, array $matrix): void + { + $offset = 0; + foreach ($matrix as $row) { + foreach ($row as $cellValue) { + $buffer[$offset++] = (float) $cellValue; + } + } + } + + /** + * Allocates an owned, zero-filled buffer of doubles + * + * @param positive-int $count Number of doubles to reserve + * + * @return CData Owned `double[count]` buffer + */ + private function allocate(int $count): CData + { + return $this->library()->new('double[' . $count . ']'); + } + + /** + * Reads a row-major buffer of doubles back into a list of rows + * + * @param CData $buffer Buffer holding at least rows * columns doubles + * @param positive-int $rows Number of rows to read + * @param positive-int $columns Number of columns to read + * + * @return non-empty-list> Cells of the computed matrix + */ + private function unpackRows(CData $buffer, int $rows, int $columns): array + { + return array_map( + fn(int $row): array => $this->unpackRow($buffer, $row * $columns, $columns), + range(0, $rows - 1), + ); + } + + /** + * Reads a single row of doubles out of a row-major buffer + * + * @param CData $buffer Buffer holding the cells + * @param int $offset Index of the first cell of the row + * @param positive-int $columns Number of cells to read + * + * @return non-empty-list Cells of one row + */ + private function unpackRow(CData $buffer, int $offset, int $columns): array + { + return array_map( + static fn(int $column): float => $buffer[$offset + $column], + range(0, $columns - 1), + ); + } + + /** + * Returns the loaded library this driver computes with + * + * Declared here because the shared plumbing allocates its buffers through the very FFI binding the driver + * loaded; every driver using this trait provides it. + */ + abstract private function library(): FFI; +} diff --git a/src/Backend/Backends.php b/src/Backend/Backends.php index 7f893fe..d0bde46 100644 --- a/src/Backend/Backends.php +++ b/src/Backend/Backends.php @@ -58,6 +58,11 @@ final class Backends */ public const string PHP = 'php'; + /** + * Name of the OpenBLAS CPU driver + */ + public const string BLAS = 'blas'; + /** * Registered driver factories, keyed by driver name * @@ -86,6 +91,11 @@ final class Backends */ private static string $selected = self::AUTO; + /** + * Driver that automatic routing uses for operations involving floats, resolved once per process + */ + private static ?BackendInterface $automaticFloatBackend = null; + /** * Selects the driver to use for every following operation * @@ -136,6 +146,7 @@ public static function register(string $name, callable $factory): void self::$factories = $factories; unset(self::$instances[$name], self::$availability[$name]); + self::$automaticFloatBackend = null; } /** @@ -183,10 +194,11 @@ public static function available(): array */ public static function reset(): void { - self::$factories = null; - self::$instances = []; - self::$availability = []; - self::$selected = self::AUTO; + self::$factories = null; + self::$instances = []; + self::$availability = []; + self::$selected = self::AUTO; + self::$automaticFloatBackend = null; } /** @@ -223,9 +235,29 @@ public static function resolveFor(bool $operandsContainFloats): BackendInterface } // Automatic routing never sends integers to an accelerated driver, because those compute in double - // precision and would turn an exact integer result into a float. Operations on floats are the ones open - // to acceleration; no accelerated driver is registered in this build, so all of them stay on pure PHP - return self::instance(self::PHP); + // precision and would turn an exact integer result into a float + if (!$operandsContainFloats) { + return self::instance(self::PHP); + } + + return self::$automaticFloatBackend ??= self::resolveAutomaticFloatBackend(); + } + + /** + * Picks the driver that automatic routing uses for float operands + * + * Only CPU drivers take part — sending data to a GPU is a decision, not a default — and the winner is wrapped + * so that a hardware failure at operation time degrades into a pure-PHP recomputation instead of a fatal + * error inside an engine hook. + */ + private static function resolveAutomaticFloatBackend(): BackendInterface + { + $php = self::instance(self::PHP); + if (self::probe(self::BLAS)) { + return new FallbackBackend(self::instance(self::BLAS), $php); + } + + return $php; } /** @@ -284,7 +316,8 @@ private static function factories(): array { if (self::$factories === null) { self::$factories = [ - self::PHP => static fn(): BackendInterface => new PhpBackend(), + self::PHP => static fn(): BackendInterface => new PhpBackend(), + self::BLAS => static fn(): BackendInterface => new BlasBackend(), ]; } diff --git a/src/Backend/BlasBackend.php b/src/Backend/BlasBackend.php new file mode 100644 index 0000000..7fb626d --- /dev/null +++ b/src/Backend/BlasBackend.php @@ -0,0 +1,256 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use FFI; +use FFI\Exception as FFIException; +use Throwable; + +/** + * CPU driver backed by an OpenBLAS shared library, reached through FFI + * + * The three CBLAS entry points below cover every operator this package overloads except exponentiation: + * `cblas_dgemm` multiplies two matrices, `cblas_daxpy` adds a scaled vector to another one — matrix addition and + * subtraction are exactly that over the flattened cells — and `cblas_dscal` scales a vector in place. No header + * file is involved: the declarations are inline, and the CBLAS enums travel as the plain integers they are. + * + * The library is loaded lazily, and only ever the LP64 build. An ILP64 one — `libopenblas64*`, where the integer + * arguments are 64 bit wide — must never be probed here: its ABI does not match these declarations and the + * dimensions would arrive at the kernel as garbage. + */ +final class BlasBackend implements BackendInterface +{ + use AcceleratedBackendTrait; + + /** + * CBLAS layout: cells are stored row by row + */ + private const int CBLAS_ROW_MAJOR = 101; + + /** + * CBLAS transposition: use the operand as it is stored + */ + private const int CBLAS_NO_TRANS = 111; + + /** + * Shared library names to try, in order of preference + * + * Only LP64 builds are listed, see the class docblock. The Homebrew locations are spelled out because macOS + * does not search /opt/homebrew from the default loader path. + * + * @var list + */ + private const array SHARED_LIBRARIES = [ + 'libopenblas.so.0', + 'libopenblas.so', + 'libopenblas.dylib', + '/opt/homebrew/opt/openblas/lib/libopenblas.dylib', + '/usr/local/opt/openblas/lib/libopenblas.dylib', + ]; + + /** + * Inline declarations of the CBLAS subset this driver calls + * + * The `int` parameters are the CBLAS enums and the LP64 `blasint` dimensions. + */ + private const string DECLARATIONS = <<<'CDEF' + void cblas_dgemm(int layout, int transa, int transb, int m, int n, int k, double alpha, + const double* a, int lda, const double* b, int ldb, double beta, double* c, int ldc); + void cblas_daxpy(int n, double alpha, const double* x, int incx, double* y, int incy); + void cblas_dscal(int n, double alpha, double* x, int incx); + CDEF; + + /** + * Loaded library, or null while it has not been loaded yet + */ + private ?FFI $library = null; + + /** + * Cached answer of the availability probe: null while the question has not been asked + */ + private ?bool $available = null; + + /** + * Loads OpenBLAS and multiplies a 1×1 matrix with it + * + * The probe is a real call on purpose. A library that loads but exports no `cblas_dgemm`, or one built for a + * different ABI, would otherwise be discovered inside an operator hook, where the failure is fatal. + */ + public function isAvailable(): bool + { + if ($this->available !== null) { + return $this->available; + } + + try { + $library = $this->library(); + $left = $this->packRowMajor([[3.0]], 1, 1); + $right = $this->packRowMajor([[4.0]], 1, 1); + $product = $this->allocate(1); + $library->cblas_dgemm( + self::CBLAS_ROW_MAJOR, + self::CBLAS_NO_TRANS, + self::CBLAS_NO_TRANS, + 1, + 1, + 1, + 1.0, + $left, + 1, + $right, + 1, + 0.0, + $product, + 1, + ); + + $this->available = $product[0] === 12.0; + } catch (Throwable) { + $this->available = false; + } + + return $this->available; + } + + /** + * {@inheritDoc} + */ + public function sum(array $left, array $right, int $rows, int $columns): array + { + return $this->axpy($left, $right, $rows, $columns, 1.0); + } + + /** + * {@inheritDoc} + */ + public function subtract(array $left, array $right, int $rows, int $columns): array + { + return $this->axpy($left, $right, $rows, $columns, -1.0); + } + + /** + * {@inheritDoc} + */ + public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + { + $a = $this->packRowMajor($left, $rows, $inner); + $b = $this->packRowMajor($right, $inner, $columns); + $product = $this->allocate($rows * $columns); + + // beta = 0.0 is specified to ignore the previous contents of C instead of scaling them, so the product + // buffer needs no initialisation — and FFI::new() hands out zero-filled memory anyway + $this->library()->cblas_dgemm( + self::CBLAS_ROW_MAJOR, + self::CBLAS_NO_TRANS, + self::CBLAS_NO_TRANS, + $rows, + $columns, + $inner, + 1.0, + $a, + $inner, + $b, + $columns, + 0.0, + $product, + $columns, + ); + + return $this->unpackRows($product, $rows, $columns); + } + + /** + * {@inheritDoc} + */ + public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + return $this->scal($matrix, $rows, $columns, (float) $value); + } + + /** + * {@inheritDoc} + */ + public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + // BLAS scales, it does not divide. Multiplying by the reciprocal is exact for powers of two and within one + // unit in the last place otherwise — the price of this driver, alongside the cast of every cell to double. + // Dividing by zero raises the very same DivisionByZeroError the pure-PHP driver raises + return $this->scal($matrix, $rows, $columns, 1.0 / $value); + } + + /** + * Computes `left ± right` by flattening both operands into vectors + * + * @param non-empty-list> $left Left operand cells + * @param non-empty-list> $right Right operand cells + * @param positive-int $rows Number of rows in both operands + * @param positive-int $columns Number of columns in both operands + * @param float $alpha Scale applied to the right operand: 1.0 or -1.0 + * + * @return non-empty-list> Result cells + */ + private function axpy(array $left, array $right, int $rows, int $columns, float $alpha): array + { + $count = $rows * $columns; + $vector = $this->packRowMajor($right, $rows, $columns); + $result = $this->packRowMajor($left, $rows, $columns); + + // daxpy accumulates into its second vector: result = alpha * right + left + $this->library()->cblas_daxpy($count, $alpha, $vector, 1, $result, 1); + + return $this->unpackRows($result, $rows, $columns); + } + + /** + * Scales every cell of a matrix by a factor + * + * @param non-empty-list> $matrix Operand cells + * @param positive-int $rows Number of rows + * @param positive-int $columns Number of columns + * @param float $alpha Scale factor + * + * @return non-empty-list> Result cells + */ + private function scal(array $matrix, int $rows, int $columns, float $alpha): array + { + $result = $this->packRowMajor($matrix, $rows, $columns); + $this->library()->cblas_dscal($rows * $columns, $alpha, $result, 1); + + return $this->unpackRows($result, $rows, $columns); + } + + /** + * Returns the loaded OpenBLAS binding, loading it on first use + * + * @throws BackendNotAvailableException When none of the candidate library names could be loaded + */ + private function library(): FFI + { + if ($this->library !== null) { + return $this->library; + } + + foreach (self::SHARED_LIBRARIES as $sharedLibrary) { + try { + return $this->library = FFI::cdef(self::DECLARATIONS, $sharedLibrary); + } catch (FFIException) { + // This name is simply not installed here, try the next one + continue; + } + } + + throw new BackendNotAvailableException( + 'OpenBLAS could not be loaded, tried: ' . implode(', ', self::SHARED_LIBRARIES), + ); + } +} diff --git a/src/Backend/FallbackBackend.php b/src/Backend/FallbackBackend.php new file mode 100644 index 0000000..6ec5b0a --- /dev/null +++ b/src/Backend/FallbackBackend.php @@ -0,0 +1,116 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use Throwable; + +/** + * Driver decorator that degrades to another driver instead of failing an operation + * + * Automatic routing uses it to keep the engine hooks safe. An accelerated driver reports its availability after a + * real probe, but hardware can still fail later — a device is reset, a queue dies, a library is unloaded — and by + * then the call is deep inside an FFI callback where a thrown exception is an unrecoverable fatal error. Catching + * the failure here and recomputing with the fallback keeps the operator working: catching *inside* the hook is + * perfectly legal, only exceptions crossing the callback boundary are fatal. + * + * This decorator is deliberately not used for an explicit selection. Asking for a specific driver and silently + * getting another one's results — integers instead of floats, at a different speed — would hide exactly what the + * caller wanted to control. + */ +final class FallbackBackend implements BackendInterface +{ + public function __construct( + private readonly BackendInterface $primary, + private readonly BackendInterface $fallback, + ) {} + + /** + * Reports availability of the primary driver, the fallback is available by definition + */ + public function isAvailable(): bool + { + return $this->primary->isAvailable(); + } + + /** + * {@inheritDoc} + */ + public function sum(array $left, array $right, int $rows, int $columns): array + { + try { + return $this->primary->sum($left, $right, $rows, $columns); + } catch (Throwable) { + return $this->fallback->sum($left, $right, $rows, $columns); + } + } + + /** + * {@inheritDoc} + */ + public function subtract(array $left, array $right, int $rows, int $columns): array + { + try { + return $this->primary->subtract($left, $right, $rows, $columns); + } catch (Throwable) { + return $this->fallback->subtract($left, $right, $rows, $columns); + } + } + + /** + * {@inheritDoc} + */ + public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + { + try { + return $this->primary->multiply($left, $right, $rows, $inner, $columns); + } catch (Throwable) { + return $this->fallback->multiply($left, $right, $rows, $inner, $columns); + } + } + + /** + * {@inheritDoc} + */ + public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + try { + return $this->primary->multiplyByScalar($matrix, $value, $rows, $columns); + } catch (Throwable) { + return $this->fallback->multiplyByScalar($matrix, $value, $rows, $columns); + } + } + + /** + * {@inheritDoc} + */ + public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + try { + return $this->primary->divideByScalar($matrix, $value, $rows, $columns); + } catch (Throwable) { + return $this->fallback->divideByScalar($matrix, $value, $rows, $columns); + } + } + + /** + * {@inheritDoc} + */ + public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + try { + return $this->primary->powByScalar($matrix, $value, $rows, $columns); + } catch (Throwable) { + return $this->fallback->powByScalar($matrix, $value, $rows, $columns); + } + } +} From 6ffb579c50fbb2bb1dfc2f709cab92088dc1ecd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:06:01 +0000 Subject: [PATCH 06/15] test(tests): cover the blas backend behaviours and php-parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every operator gets a file pinned to the blas driver through --ENV--, including the integer input that comes back as floats — the visible half of the float-only rule. The parity test computes the same 8x8 product on both drivers and compares them exactly: integral float cells keep every partial sum representable, so a difference in summation order may not become a difference in the result. The shared SKIPIF probe asks the registry for availability instead of guessing, so a skip can never disagree with what the test would have done. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- tests/Functional/include/skipif_blas.inc | 27 ++++++++++ .../testBlasAddsMatricesAsFloats.phpt | 38 ++++++++++++++ .../testBlasDividesMatrixByScalar.phpt | 42 +++++++++++++++ .../testBlasMatchesPhpBackendResults.phpt | 52 +++++++++++++++++++ .../testBlasMultipliesFloatMatrices.phpt | 41 +++++++++++++++ .../testBlasMultipliesMatrixByScalar.phpt | 40 ++++++++++++++ .../testBlasPowsMatrixByScalar.phpt | 41 +++++++++++++++ .../Functional/testBlasSubtractsMatrices.phpt | 41 +++++++++++++++ 8 files changed, 322 insertions(+) create mode 100644 tests/Functional/include/skipif_blas.inc create mode 100644 tests/Functional/testBlasAddsMatricesAsFloats.phpt create mode 100644 tests/Functional/testBlasDividesMatrixByScalar.phpt create mode 100644 tests/Functional/testBlasMatchesPhpBackendResults.phpt create mode 100644 tests/Functional/testBlasMultipliesFloatMatrices.phpt create mode 100644 tests/Functional/testBlasMultipliesMatrixByScalar.phpt create mode 100644 tests/Functional/testBlasPowsMatrixByScalar.phpt create mode 100644 tests/Functional/testBlasSubtractsMatrices.phpt diff --git a/tests/Functional/include/skipif_blas.inc b/tests/Functional/include/skipif_blas.inc new file mode 100644 index 0000000..60c24c7 --- /dev/null +++ b/tests/Functional/include/skipif_blas.inc @@ -0,0 +1,27 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +use Lisachenko\NativePhpMatrix\Backend\Backends; + +// The probe asks the registry rather than looking for a file: Backends::available() loads the library and runs a +// real operation with it, so a skip here can never disagree with what the test itself would do a moment later. +// CI installs every acceleration library precisely so that this never fires — a skip there means a broken image +try { + include __DIR__ . '/../../../vendor/autoload.php'; + $isAvailable = in_array(Backends::BLAS, Backends::available(), true); +} catch (Throwable) { + $isAvailable = false; +} + +if (!$isAvailable) { + echo 'skip OpenBLAS is not available in this environment'; +} diff --git a/tests/Functional/testBlasAddsMatricesAsFloats.phpt b/tests/Functional/testBlasAddsMatricesAsFloats.phpt new file mode 100644 index 0000000..dc087da --- /dev/null +++ b/tests/Functional/testBlasAddsMatricesAsFloats.phpt @@ -0,0 +1,38 @@ +--TEST-- +The blas backend adds matrices and returns floats even for integer input +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(1) { + [0]=> + array(3) { + [0]=> + float(5) + [1]=> + float(7) + [2]=> + float(9) + } +} diff --git a/tests/Functional/testBlasDividesMatrixByScalar.phpt b/tests/Functional/testBlasDividesMatrixByScalar.phpt new file mode 100644 index 0000000..3a1a4d7 --- /dev/null +++ b/tests/Functional/testBlasDividesMatrixByScalar.phpt @@ -0,0 +1,42 @@ +--TEST-- +The blas backend divides a matrix by a scalar by scaling with its reciprocal +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(0.5) + [1]=> + float(1.5) + } + [1]=> + array(2) { + [0]=> + float(2.5) + [1]=> + float(3.5) + } +} diff --git a/tests/Functional/testBlasMatchesPhpBackendResults.phpt b/tests/Functional/testBlasMatchesPhpBackendResults.phpt new file mode 100644 index 0000000..f56f016 --- /dev/null +++ b/tests/Functional/testBlasMatchesPhpBackendResults.phpt @@ -0,0 +1,52 @@ +--TEST-- +The blas backend produces the same product as the pure PHP backend +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray() === $actual->toArray()); +var_dump($expected == $actual); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/tests/Functional/testBlasMultipliesFloatMatrices.phpt b/tests/Functional/testBlasMultipliesFloatMatrices.phpt new file mode 100644 index 0000000..42325ac --- /dev/null +++ b/tests/Functional/testBlasMultipliesFloatMatrices.phpt @@ -0,0 +1,41 @@ +--TEST-- +The blas backend multiplies float matrices with cblas_dgemm +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(19) + [1]=> + float(22) + } + [1]=> + array(2) { + [0]=> + float(43) + [1]=> + float(50) + } +} diff --git a/tests/Functional/testBlasMultipliesMatrixByScalar.phpt b/tests/Functional/testBlasMultipliesMatrixByScalar.phpt new file mode 100644 index 0000000..3876ef3 --- /dev/null +++ b/tests/Functional/testBlasMultipliesMatrixByScalar.phpt @@ -0,0 +1,40 @@ +--TEST-- +The blas backend multiplies a matrix by a scalar with cblas_dscal +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(3) + [1]=> + float(5) + } + [1]=> + array(2) { + [0]=> + float(7) + [1]=> + float(9) + } +} diff --git a/tests/Functional/testBlasPowsMatrixByScalar.phpt b/tests/Functional/testBlasPowsMatrixByScalar.phpt new file mode 100644 index 0000000..3d5bf95 --- /dev/null +++ b/tests/Functional/testBlasPowsMatrixByScalar.phpt @@ -0,0 +1,41 @@ +--TEST-- +The blas backend raises a matrix to a power in floats +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(4) + [1]=> + float(9) + } + [1]=> + array(2) { + [0]=> + float(16) + [1]=> + float(25) + } +} diff --git a/tests/Functional/testBlasSubtractsMatrices.phpt b/tests/Functional/testBlasSubtractsMatrices.phpt new file mode 100644 index 0000000..2776a5d --- /dev/null +++ b/tests/Functional/testBlasSubtractsMatrices.phpt @@ -0,0 +1,41 @@ +--TEST-- +The blas backend subtracts matrices with a negatively scaled cblas_daxpy +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(9) + [1]=> + float(6) + } + [1]=> + array(2) { + [0]=> + float(3) + [1]=> + float(0) + } +} From c607eee0e00c1a169543c8ad1a7f840313511975 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:11:27 +0000 Subject: [PATCH 07/15] feat(backend): add CLBlast/OpenCL GPU driver with selectable device type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLBlast is a BLAS written in OpenCL, which makes it the portable way to reach a GPU from PHP: the same driver runs on NVIDIA, AMD and Intel hardware, on the integrated GPU of a laptop, and — through a runtime such as PoCL — on the CPU, which is how the GPU code path can be exercised without a GPU at all. NATIVE_PHP_MATRIX_CL_DEVICE picks the device type, defaulting to the GPU. Both APIs come from a single FFI::cdef against CLBlast, whose own link to the OpenCL library resolves the cl* symbols. Device memory is not reference counted by PHP, so every buffer is released in a finally block; host buffers are left to PHP. Like the CPU driver, this one proves itself with a real multiplication before reporting that it is available, and it is never selected automatically. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- composer.json | 3 +- phpstan.dist.neon | 8 + src/Backend/Backends.php | 10 +- src/Backend/ClblastBackend.php | 712 +++++++++++++++++++++++++++++++++ 4 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 src/Backend/ClblastBackend.php diff --git a/composer.json b/composer.json index 4ff5b23..8643327 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "phpunit/phpunit": "^12.5" }, "suggest": { - "lib-openblas": "Install the OpenBLAS shared library (Debian/Ubuntu: libopenblas0, Homebrew: openblas) to enable the CPU-accelerated \"blas\" backend" + "lib-openblas": "Install the OpenBLAS shared library (Debian/Ubuntu: libopenblas0, Homebrew: openblas) to enable the CPU-accelerated \"blas\" backend", + "lib-clblast": "Install CLBlast and an OpenCL runtime (Debian/Ubuntu: libclblast1, ocl-icd-libopencl1 plus a vendor ICD or pocl-opencl-icd) to enable the GPU-accelerated \"clblast\" backend" }, "autoload": { "psr-4": { diff --git a/phpstan.dist.neon b/phpstan.dist.neon index eee027f..1239118 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -21,3 +21,11 @@ parameters: - identifier: method.notFound path: src/Backend/BlasBackend.php + # Same for the OpenCL and CLBlast entry points of the GPU driver, plus the pseudo-property FFI exposes on + # scalar handles: both exist only once the C declarations have been parsed at runtime + - + identifier: method.notFound + path: src/Backend/ClblastBackend.php + - + identifier: property.notFound + path: src/Backend/ClblastBackend.php diff --git a/src/Backend/Backends.php b/src/Backend/Backends.php index d0bde46..ea18552 100644 --- a/src/Backend/Backends.php +++ b/src/Backend/Backends.php @@ -63,6 +63,11 @@ final class Backends */ public const string BLAS = 'blas'; + /** + * Name of the CLBlast GPU driver + */ + public const string CLBLAST = 'clblast'; + /** * Registered driver factories, keyed by driver name * @@ -316,8 +321,9 @@ private static function factories(): array { if (self::$factories === null) { self::$factories = [ - self::PHP => static fn(): BackendInterface => new PhpBackend(), - self::BLAS => static fn(): BackendInterface => new BlasBackend(), + self::PHP => static fn(): BackendInterface => new PhpBackend(), + self::BLAS => static fn(): BackendInterface => new BlasBackend(), + self::CLBLAST => static fn(): BackendInterface => new ClblastBackend(), ]; } diff --git a/src/Backend/ClblastBackend.php b/src/Backend/ClblastBackend.php new file mode 100644 index 0000000..f83ff13 --- /dev/null +++ b/src/Backend/ClblastBackend.php @@ -0,0 +1,712 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use FFI; +use FFI\CData; +use FFI\Exception as FFIException; + +use function get_debug_type; +use function getenv; +use function implode; +use function is_int; +use function is_string; + +use RuntimeException; + +use function sprintf; +use function strtolower; + +use Throwable; + +use function trim; + +/** + * GPU driver backed by CLBlast on top of OpenCL, reached through FFI + * + * CLBlast is a BLAS written in OpenCL, which makes it the portable way to reach a GPU from PHP: the same driver + * runs on NVIDIA, AMD and Intel hardware, on the integrated GPU of a laptop, and — through an OpenCL runtime such + * as PoCL — on the CPU, which is how the GPU code path is exercised in continuous integration. + * + * Everything is declared in a single FFI::cdef against the CLBlast library: the loader resolves the `cl*` symbols + * through the OpenCL library CLBlast itself is linked against, so no second binding is needed. The OpenCL handles + * are opaque pointers here, as they are in C. + * + * Two behaviours deserve to be spelled out. The device type is chosen with the `NATIVE_PHP_MATRIX_CL_DEVICE` + * variable (`gpu` by default, `cpu` or `all`), and this driver is never selected automatically — copying data + * across a bus is a decision, not a default. And unlike host memory, device buffers are not garbage collected: + * every one of them is released in a `finally` block, whatever happens to the operation. + */ +final class ClblastBackend implements BackendInterface +{ + use AcceleratedBackendTrait; + + /** + * Name of the environment variable choosing the OpenCL device type: `gpu`, `cpu` or `all` + */ + public const string DEVICE_ENVIRONMENT_VARIABLE = 'NATIVE_PHP_MATRIX_CL_DEVICE'; + + /** + * OpenCL device type bits + */ + private const int CL_DEVICE_TYPE_CPU = 2; + private const int CL_DEVICE_TYPE_GPU = 4; + private const int CL_DEVICE_TYPE_ALL = 0xFFFFFFFF; + + /** + * OpenCL buffer allocated for reading and writing by kernels + */ + private const int CL_MEM_READ_WRITE = 1; + + /** + * OpenCL status of a successful call + */ + private const int CL_SUCCESS = 0; + + /** + * OpenCL flag requesting a blocking transfer + */ + private const int CL_TRUE = 1; + + /** + * CLBlast layout: cells are stored row by row + */ + private const int CLBLAST_ROW_MAJOR = 101; + + /** + * CLBlast transposition: use the operand as it is stored + */ + private const int CLBLAST_NO_TRANS = 111; + + /** + * Maximum number of platforms and devices inspected while looking for a usable one + */ + private const int MAX_ENTRIES = 16; + + /** + * Shared library names to try, in order of preference + * + * @var list + */ + private const array SHARED_LIBRARIES = [ + 'libclblast.so.1', + 'libclblast.so', + 'libclblast.dylib', + '/opt/homebrew/opt/clblast/lib/libclblast.dylib', + '/usr/local/opt/clblast/lib/libclblast.dylib', + ]; + + /** + * Inline declarations of CLBlast and of the OpenCL subset needed to feed it + * + * The OpenCL handles are opaque pointers, exactly as the OpenCL headers define them, and the dimensions are + * `size_t` as CLBlast expects. `clCreateCommandQueueWithProperties` is the OpenCL 2.0 entry point, with the + * deprecated 1.x one declared next to it for runtimes that only export the older name. + */ + private const string DECLARATIONS = <<<'CDEF' + typedef void* cl_platform_id; + typedef void* cl_device_id; + typedef void* cl_context; + typedef void* cl_command_queue; + typedef void* cl_mem; + typedef void* cl_event; + + int clGetPlatformIDs(unsigned int num_entries, cl_platform_id* platforms, unsigned int* num_platforms); + int clGetDeviceIDs(cl_platform_id platform, unsigned long long device_type, unsigned int num_entries, + cl_device_id* devices, unsigned int* num_devices); + cl_context clCreateContext(void* properties, unsigned int num_devices, cl_device_id* devices, + void* pfn_notify, void* user_data, int* errcode_ret); + cl_command_queue clCreateCommandQueueWithProperties(cl_context context, cl_device_id device, + void* properties, int* errcode_ret); + cl_command_queue clCreateCommandQueue(cl_context context, cl_device_id device, + unsigned long long properties, int* errcode_ret); + cl_mem clCreateBuffer(cl_context context, unsigned long long flags, size_t size, void* host_ptr, + int* errcode_ret); + int clEnqueueWriteBuffer(cl_command_queue queue, cl_mem buffer, unsigned int blocking_write, size_t offset, + size_t size, const void* ptr, unsigned int num_events_in_wait_list, + const cl_event* event_wait_list, cl_event* event); + int clEnqueueReadBuffer(cl_command_queue queue, cl_mem buffer, unsigned int blocking_read, size_t offset, + size_t size, void* ptr, unsigned int num_events_in_wait_list, + const cl_event* event_wait_list, cl_event* event); + int clFinish(cl_command_queue queue); + int clReleaseMemObject(cl_mem memobj); + int clReleaseCommandQueue(cl_command_queue queue); + int clReleaseContext(cl_context context); + + int CLBlastDgemm(int layout, int a_transpose, int b_transpose, size_t m, size_t n, size_t k, double alpha, + const cl_mem a_buffer, size_t a_offset, size_t a_ld, + const cl_mem b_buffer, size_t b_offset, size_t b_ld, double beta, + cl_mem c_buffer, size_t c_offset, size_t c_ld, + cl_command_queue* queue, cl_event* event); + int CLBlastDaxpy(size_t n, double alpha, const cl_mem x_buffer, size_t x_offset, size_t x_inc, + cl_mem y_buffer, size_t y_offset, size_t y_inc, cl_command_queue* queue, cl_event* event); + int CLBlastDscal(size_t n, double alpha, cl_mem x_buffer, size_t x_offset, size_t x_inc, + cl_command_queue* queue, cl_event* event); + CDEF; + + /** + * Loaded library, or null while it has not been loaded yet + */ + private ?FFI $library = null; + + /** + * Cached answer of the availability probe: null while the question has not been asked + */ + private ?bool $available = null; + + /** + * OpenCL context for the selected device, kept for the lifetime of the process + */ + private ?CData $context = null; + + /** + * Single-element array holding the command queue, as CLBlast takes a pointer to it + */ + private ?CData $queue = null; + + /** + * Loads CLBlast, initialises a device and multiplies a 1×1 matrix on it + * + * The probe runs a real kernel because everything up to that point can succeed on a machine that still cannot + * compute: an OpenCL runtime with no device, a driver that fails to build kernels, a device that is busy. All + * of it collapses into "unavailable" here, where the answer is still a return value and not a fatal error. + */ + public function isAvailable(): bool + { + if ($this->available !== null) { + return $this->available; + } + + try { + $product = $this->gemm([[3.0]], [[4.0]], 1, 1, 1); + $this->available = $product === [[12.0]]; + } catch (Throwable) { + $this->available = false; + } + + return $this->available; + } + + /** + * {@inheritDoc} + */ + public function sum(array $left, array $right, int $rows, int $columns): array + { + return $this->axpy($left, $right, $rows, $columns, 1.0); + } + + /** + * {@inheritDoc} + */ + public function subtract(array $left, array $right, int $rows, int $columns): array + { + return $this->axpy($left, $right, $rows, $columns, -1.0); + } + + /** + * {@inheritDoc} + */ + public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + { + return $this->gemm($left, $right, $rows, $inner, $columns); + } + + /** + * {@inheritDoc} + */ + public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + return $this->scal($matrix, $rows, $columns, (float) $value); + } + + /** + * {@inheritDoc} + */ + public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + { + // CLBlast scales just like CBLAS does, so this is a multiplication by the reciprocal — exact for powers of + // two, within one unit in the last place otherwise. A zero divisor raises DivisionByZeroError right here, + // exactly as the pure-PHP driver would + return $this->scal($matrix, $rows, $columns, 1.0 / $value); + } + + /** + * Multiplies two matrices on the device + * + * @param non-empty-list> $left Left operand cells, shaped rows × inner + * @param non-empty-list> $right Right operand cells, shaped inner × columns + * @param positive-int $rows Number of rows of the left operand + * @param positive-int $inner Shared dimension + * @param positive-int $columns Number of columns of the right operand + * + * @return non-empty-list> Product cells + */ + private function gemm(array $left, array $right, int $rows, int $inner, int $columns): array + { + $library = $this->library(); + $queue = $this->queue(); + + $hostA = $this->packRowMajor($left, $rows, $inner); + $hostB = $this->packRowMajor($right, $inner, $columns); + $hostC = $this->allocate($rows * $columns); + $buffers = []; + + try { + $bufferA = $buffers[] = $this->createBuffer($rows * $inner); + $bufferB = $buffers[] = $this->createBuffer($inner * $columns); + $bufferC = $buffers[] = $this->createBuffer($rows * $columns); + + $this->write($bufferA, $hostA, $rows * $inner); + $this->write($bufferB, $hostB, $inner * $columns); + // beta is 0.0, so the previous contents of C are not part of the result — but uninitialised device + // memory multiplied by zero is not guaranteed to be zero on every runtime, so C is written as well + $this->write($bufferC, $hostC, $rows * $columns); + + $this->check($library->CLBlastDgemm( + self::CLBLAST_ROW_MAJOR, + self::CLBLAST_NO_TRANS, + self::CLBLAST_NO_TRANS, + $rows, + $columns, + $inner, + 1.0, + $bufferA, + 0, + $inner, + $bufferB, + 0, + $columns, + 0.0, + $bufferC, + 0, + $columns, + $queue, + null, + ), 'CLBlastDgemm'); + + $this->check($library->clFinish($this->commandQueue()), 'clFinish'); + $this->read($bufferC, $hostC, $rows * $columns); + + return $this->unpackRows($hostC, $rows, $columns); + } finally { + $this->release($buffers); + } + } + + /** + * Computes `left ± right` on the device over the flattened cells + * + * @param non-empty-list> $left Left operand cells + * @param non-empty-list> $right Right operand cells + * @param positive-int $rows Number of rows in both operands + * @param positive-int $columns Number of columns in both operands + * @param float $alpha Scale applied to the right operand: 1.0 or -1.0 + * + * @return non-empty-list> Result cells + */ + private function axpy(array $left, array $right, int $rows, int $columns, float $alpha): array + { + $library = $this->library(); + $queue = $this->queue(); + $count = $rows * $columns; + + $hostX = $this->packRowMajor($right, $rows, $columns); + $hostY = $this->packRowMajor($left, $rows, $columns); + $buffers = []; + + try { + $bufferX = $buffers[] = $this->createBuffer($count); + $bufferY = $buffers[] = $this->createBuffer($count); + + $this->write($bufferX, $hostX, $count); + $this->write($bufferY, $hostY, $count); + + // daxpy accumulates into its second vector: y = alpha * x + y + $this->check($library->CLBlastDaxpy( + $count, + $alpha, + $bufferX, + 0, + 1, + $bufferY, + 0, + 1, + $queue, + null, + ), 'CLBlastDaxpy'); + + $this->check($library->clFinish($this->commandQueue()), 'clFinish'); + $this->read($bufferY, $hostY, $count); + + return $this->unpackRows($hostY, $rows, $columns); + } finally { + $this->release($buffers); + } + } + + /** + * Scales every cell of a matrix on the device + * + * @param non-empty-list> $matrix Operand cells + * @param positive-int $rows Number of rows + * @param positive-int $columns Number of columns + * @param float $alpha Scale factor + * + * @return non-empty-list> Result cells + */ + private function scal(array $matrix, int $rows, int $columns, float $alpha): array + { + $library = $this->library(); + $queue = $this->queue(); + $count = $rows * $columns; + + $host = $this->packRowMajor($matrix, $rows, $columns); + $buffers = []; + + try { + $buffer = $buffers[] = $this->createBuffer($count); + $this->write($buffer, $host, $count); + + $this->check($library->CLBlastDscal($count, $alpha, $buffer, 0, 1, $queue, null), 'CLBlastDscal'); + $this->check($library->clFinish($this->commandQueue()), 'clFinish'); + $this->read($buffer, $host, $count); + + return $this->unpackRows($host, $rows, $columns); + } finally { + $this->release($buffers); + } + } + + /** + * Allocates a device buffer able to hold the given number of doubles + * + * @param positive-int $count Number of doubles + * + * @return CData Device buffer handle + */ + private function createBuffer(int $count): CData + { + $library = $this->library(); + $status = $library->new('int'); + $buffer = $library->clCreateBuffer( + $this->context(), + self::CL_MEM_READ_WRITE, + $this->bytes($count), + null, + FFI::addr($status), + ); + $this->check($this->cell($status), 'clCreateBuffer'); + + return $this->handle($buffer); + } + + /** + * Copies a host buffer into a device buffer and waits for the copy to complete + * + * @param CData $buffer Device buffer + * @param CData $host Host buffer holding at least count doubles + * @param positive-int $count Number of doubles to copy + */ + private function write(CData $buffer, CData $host, int $count): void + { + $this->check($this->library()->clEnqueueWriteBuffer( + $this->commandQueue(), + $buffer, + self::CL_TRUE, + 0, + $this->bytes($count), + $host, + 0, + null, + null, + ), 'clEnqueueWriteBuffer'); + } + + /** + * Copies a device buffer back into a host buffer and waits for the copy to complete + * + * @param CData $buffer Device buffer + * @param CData $host Host buffer with room for count doubles + * @param positive-int $count Number of doubles to copy + */ + private function read(CData $buffer, CData $host, int $count): void + { + $this->check($this->library()->clEnqueueReadBuffer( + $this->commandQueue(), + $buffer, + self::CL_TRUE, + 0, + $this->bytes($count), + $host, + 0, + null, + null, + ), 'clEnqueueReadBuffer'); + } + + /** + * Releases device buffers + * + * Device memory is not reference counted by PHP, so every buffer is released explicitly, from a `finally` + * block, whether the operation succeeded or not. + * + * @param list $buffers Device buffers to release + */ + private function release(array $buffers): void + { + foreach ($buffers as $buffer) { + $this->library()->clReleaseMemObject($buffer); + } + } + + /** + * Returns the number of bytes occupied by the given number of doubles + * + * @param positive-int $count Number of doubles + */ + private function bytes(int $count): int + { + return $count * FFI::sizeof($this->library()->new('double')); + } + + /** + * Returns the pointer to the command queue that CLBlast entry points expect + */ + private function queue(): CData + { + return $this->handle($this->library()->cast('cl_command_queue*', FFI::addr($this->queueHolder()))); + } + + /** + * Returns the command queue handle itself, as the OpenCL entry points expect + */ + private function commandQueue(): CData + { + return $this->handle($this->queueHolder()[0]); + } + + /** + * Returns the single-element array holding the command queue, initialising the device on first use + * + * @throws BackendNotAvailableException When no platform, device, context or queue could be obtained + */ + private function queueHolder(): CData + { + if ($this->queue !== null) { + return $this->queue; + } + + $library = $this->library(); + $device = $this->findDevice(); + $status = $library->new('int'); + + // The pointer is taken before the handle is stored, so that the array is still typed when it is cast + $devices = $library->new('cl_device_id[1]'); + $devicePointer = $library->cast('cl_device_id*', FFI::addr($devices)); + $devices[0] = $device; + + $context = $library->clCreateContext( + null, + 1, + $devicePointer, + null, + null, + FFI::addr($status), + ); + if ($this->cell($status) !== self::CL_SUCCESS) { + throw new BackendNotAvailableException( + sprintf('OpenCL context could not be created, status %s', get_debug_type($this->cell($status))), + ); + } + $this->context = $this->handle($context); + + $queue = $library->clCreateCommandQueueWithProperties($context, $device, null, FFI::addr($status)); + if ($this->cell($status) !== self::CL_SUCCESS) { + // OpenCL 1.x runtimes only export the deprecated entry point + $queue = $library->clCreateCommandQueue($context, $device, 0, FFI::addr($status)); + } + $this->check($this->cell($status), 'clCreateCommandQueue'); + + $holder = $library->new('cl_command_queue[1]'); + $holder[0] = $this->handle($queue); + + return $this->queue = $this->handle($holder); + } + + /** + * Returns the OpenCL context, initialising the device on first use + */ + private function context(): CData + { + $this->queueHolder(); + if ($this->context === null) { + throw new BackendNotAvailableException('OpenCL context is not initialised'); + } + + return $this->context; + } + + /** + * Finds the first device of the requested type, across every OpenCL platform + * + * @throws BackendNotAvailableException When no platform reports a device of that type + */ + private function findDevice(): CData + { + $library = $this->library(); + $deviceType = $this->deviceType(); + + $platforms = $library->new('cl_platform_id[' . self::MAX_ENTRIES . ']'); + $platformCount = $library->new('unsigned int'); + $this->check($library->clGetPlatformIDs( + self::MAX_ENTRIES, + $library->cast('cl_platform_id*', FFI::addr($platforms)), + FFI::addr($platformCount), + ), 'clGetPlatformIDs'); + + for ($platform = 0; $platform < $this->counter($platformCount); $platform++) { + $devices = $library->new('cl_device_id[' . self::MAX_ENTRIES . ']'); + $deviceCount = $library->new('unsigned int'); + $status = $library->clGetDeviceIDs( + $platforms[$platform], + $deviceType, + self::MAX_ENTRIES, + $library->cast('cl_device_id*', FFI::addr($devices)), + FFI::addr($deviceCount), + ); + if ($status === self::CL_SUCCESS && $this->counter($deviceCount) > 0) { + return $this->handle($devices[0]); + } + } + + throw new BackendNotAvailableException('No OpenCL device of the requested type was found'); + } + + /** + * Returns the OpenCL device type asked for by the environment + * + * An unrecognised value falls back to the GPU rather than failing: this is read while probing availability, + * which is a context that may not throw. + */ + private function deviceType(): int + { + $requested = getenv(self::DEVICE_ENVIRONMENT_VARIABLE); + + return match (is_string($requested) ? strtolower(trim($requested)) : '') { + 'cpu' => self::CL_DEVICE_TYPE_CPU, + 'all' => self::CL_DEVICE_TYPE_ALL, + default => self::CL_DEVICE_TYPE_GPU, + }; + } + + /** + * Fails when an OpenCL or CLBlast call did not report success + * + * The status arrives untyped because the entry points come from parsed C declarations rather than from a PHP + * class, so anything other than the integer CL_SUCCESS — including a value of an unexpected type — is a + * failure worth reporting. + * + * @param mixed $status Status returned by the call + * @param string $call Name of the call, for the message + * + * @throws RuntimeException When the status is not CL_SUCCESS + */ + private function check(mixed $status, string $call): void + { + if ($status !== self::CL_SUCCESS) { + throw new RuntimeException(sprintf( + '%s() failed with status %s', + $call, + is_int($status) ? (string) $status : get_debug_type($status), + )); + } + } + + /** + * Narrows a value produced by a parsed C declaration to an FFI handle + * + * Declarations parsed at runtime carry no types a static analyser can see, so every handle crossing back into + * PHP is checked once, here, instead of being assumed. + * + * @param mixed $value Value returned by an FFI call or read out of a buffer + * + * @throws BackendNotAvailableException When the value is not an FFI handle + */ + private function handle(mixed $value): CData + { + if (!$value instanceof CData) { + throw new BackendNotAvailableException( + sprintf('OpenCL returned %s where a handle was expected', get_debug_type($value)), + ); + } + + return $value; + } + + /** + * Reads the value out of a scalar FFI cell + * + * The single place in this driver that touches the pseudo-property FFI exposes on scalar handles; everything + * else works with the value it returns. + * + * @param CData $scalar Scalar handle written by an OpenCL call + * + * @return mixed Value held by the cell + */ + private function cell(CData $scalar): mixed + { + return $scalar->cdata; + } + + /** + * Reads a counter written by an OpenCL enumeration call + * + * @param CData $scalar Scalar handle holding an unsigned int + * + * @return int<0, max> Number of entries reported, or zero when the runtime wrote something unexpected + */ + private function counter(CData $scalar): int + { + $value = $this->cell($scalar); + + return is_int($value) && $value > 0 ? $value : 0; + } + + /** + * Returns the loaded CLBlast binding, loading it on first use + * + * The `cl*` symbols are resolved through the OpenCL library CLBlast is linked against, which is why a single + * binding is enough for both APIs. + * + * @throws BackendNotAvailableException When none of the candidate library names could be loaded + */ + private function library(): FFI + { + if ($this->library !== null) { + return $this->library; + } + + foreach (self::SHARED_LIBRARIES as $sharedLibrary) { + try { + return $this->library = FFI::cdef(self::DECLARATIONS, $sharedLibrary); + } catch (FFIException) { + // This name is simply not installed here, try the next one + continue; + } + } + + throw new BackendNotAvailableException( + 'CLBlast could not be loaded, tried: ' . implode(', ', self::SHARED_LIBRARIES), + ); + } +} From 9e001a4db84424950b57ed2f15a1f08cb2a16dec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:13:27 +0000 Subject: [PATCH 08/15] test(tests): cover the clblast backend behaviours and php-parity The GPU driver gets the same treatment as the CPU one, including the parity test that compares an 8x8 product computed on the device against the interpreted result. Its SKIPIF probe initialises OpenCL for exactly the device type NATIVE_PHP_MATRIX_CL_DEVICE selects, so a skip always means what it says. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- tests/Functional/include/skipif_blas.inc | 3 +- tests/Functional/include/skipif_clblast.inc | 28 ++++++++++ .../testClblastAddsMatricesAsFloats.phpt | 37 +++++++++++++ .../testClblastMatchesPhpBackendResults.phpt | 52 +++++++++++++++++++ .../testClblastMultipliesFloatMatrices.phpt | 41 +++++++++++++++ .../testClblastMultipliesMatrixByScalar.phpt | 40 ++++++++++++++ 6 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/Functional/include/skipif_clblast.inc create mode 100644 tests/Functional/testClblastAddsMatricesAsFloats.phpt create mode 100644 tests/Functional/testClblastMatchesPhpBackendResults.phpt create mode 100644 tests/Functional/testClblastMultipliesFloatMatrices.phpt create mode 100644 tests/Functional/testClblastMultipliesMatrixByScalar.phpt diff --git a/tests/Functional/include/skipif_blas.inc b/tests/Functional/include/skipif_blas.inc index 60c24c7..1c9e0f2 100644 --- a/tests/Functional/include/skipif_blas.inc +++ b/tests/Functional/include/skipif_blas.inc @@ -22,6 +22,7 @@ try { $isAvailable = false; } +// PHPUnit prints everything after "skip" minus two characters, assuming a "- " separator: keep it there if (!$isAvailable) { - echo 'skip OpenBLAS is not available in this environment'; + echo 'skip - OpenBLAS is not available in this environment'; } diff --git a/tests/Functional/include/skipif_clblast.inc b/tests/Functional/include/skipif_clblast.inc new file mode 100644 index 0000000..e109814 --- /dev/null +++ b/tests/Functional/include/skipif_clblast.inc @@ -0,0 +1,28 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +use Lisachenko\NativePhpMatrix\Backend\Backends; + +// Probing through the registry initialises OpenCL for the very device type NATIVE_PHP_MATRIX_CL_DEVICE selects and +// runs a real kernel on it, so this skip and the test itself can never disagree. CI installs an OpenCL runtime and +// pins the device type, so a skip there means a broken image rather than a missing GPU +try { + include __DIR__ . '/../../../vendor/autoload.php'; + $isAvailable = in_array(Backends::CLBLAST, Backends::available(), true); +} catch (Throwable) { + $isAvailable = false; +} + +// PHPUnit prints everything after "skip" minus two characters, assuming a "- " separator: keep it there +if (!$isAvailable) { + echo 'skip - CLBlast with a usable OpenCL device is not available in this environment'; +} diff --git a/tests/Functional/testClblastAddsMatricesAsFloats.phpt b/tests/Functional/testClblastAddsMatricesAsFloats.phpt new file mode 100644 index 0000000..bd87481 --- /dev/null +++ b/tests/Functional/testClblastAddsMatricesAsFloats.phpt @@ -0,0 +1,37 @@ +--TEST-- +The clblast backend adds matrices and returns floats even for integer input +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=clblast +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(1) { + [0]=> + array(3) { + [0]=> + float(5) + [1]=> + float(7) + [2]=> + float(9) + } +} diff --git a/tests/Functional/testClblastMatchesPhpBackendResults.phpt b/tests/Functional/testClblastMatchesPhpBackendResults.phpt new file mode 100644 index 0000000..8439b3a --- /dev/null +++ b/tests/Functional/testClblastMatchesPhpBackendResults.phpt @@ -0,0 +1,52 @@ +--TEST-- +The clblast backend produces the same product as the pure PHP backend +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=clblast +--SKIPIF-- + +--FILE-- +toArray() === $actual->toArray()); +var_dump($expected == $actual); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/tests/Functional/testClblastMultipliesFloatMatrices.phpt b/tests/Functional/testClblastMultipliesFloatMatrices.phpt new file mode 100644 index 0000000..08451c2 --- /dev/null +++ b/tests/Functional/testClblastMultipliesFloatMatrices.phpt @@ -0,0 +1,41 @@ +--TEST-- +The clblast backend multiplies float matrices on an OpenCL device +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=clblast +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(19) + [1]=> + float(22) + } + [1]=> + array(2) { + [0]=> + float(43) + [1]=> + float(50) + } +} diff --git a/tests/Functional/testClblastMultipliesMatrixByScalar.phpt b/tests/Functional/testClblastMultipliesMatrixByScalar.phpt new file mode 100644 index 0000000..671307e --- /dev/null +++ b/tests/Functional/testClblastMultipliesMatrixByScalar.phpt @@ -0,0 +1,40 @@ +--TEST-- +The clblast backend scales a matrix by a scalar on an OpenCL device +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=clblast +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(3) + [1]=> + float(5) + } + [1]=> + array(2) { + [0]=> + float(7) + [1]=> + float(9) + } +} From fed7f2965ba63df6c060313311f9d499de3cf4f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:13:49 +0000 Subject: [PATCH 09/15] ci: install acceleration libraries and run backend-pinned suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite fails on skipped tests, so the libraries are installed rather than the tests being allowed to skip: a skip in CI now means a broken image. PoCL supplies an OpenCL device on the CPU, which lets a runner without a GPU still execute the GPU code path — that is what the new gpu-path job does, with everything pinned to the clblast driver. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3be3e9..00d9119 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,19 @@ jobs: fail-fast: false matrix: php: ['8.4', '8.5'] + # The suite fails on skipped tests, so every acceleration library is installed here: a skipped backend test + # means a broken environment rather than an absent GPU. PoCL provides the OpenCL device, on the CPU + env: + NATIVE_PHP_MATRIX_CL_DEVICE: cpu steps: - uses: actions/checkout@v7 + - name: Install acceleration libraries + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libopenblas0 libclblast1 ocl-icd-libopencl1 pocl-opencl-icd + - name: Set up PHP uses: shivammathur/setup-php@v2 with: @@ -37,6 +47,46 @@ jobs: - name: Run test suite run: composer test + # Backend-pinned tests carry their own --ENV--, which overrides this one: what the second run really covers + # is every other test computing on the pure-PHP driver, with automatic routing switched off + - name: Run test suite on the pure PHP backend + run: composer test + env: + NATIVE_PHP_MATRIX_BACKEND: php + + gpu-path: + name: GPU code path (CLBlast on PoCL) + runs-on: ubuntu-latest + # The device code path gets its own red/green signal, on the CPU device PoCL provides, so a broken GPU driver + # is visible without reading through the rest of the suite. Only the driver's own tests run: pinning an + # accelerated backend process-wide turns integer results into floats by design, which is precisely what the + # tests asserting integer output would report as a failure + env: + NATIVE_PHP_MATRIX_BACKEND: clblast + NATIVE_PHP_MATRIX_CL_DEVICE: cpu + OPENBLAS_NUM_THREADS: '1' + steps: + - uses: actions/checkout@v7 + + - name: Install acceleration libraries + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libopenblas0 libclblast1 ocl-icd-libopencl1 pocl-opencl-icd + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: ffi + ini-values: ffi.enable=1, zend.assertions=1, opcache.jit=off + coverage: none + + - uses: ramsey/composer-install@v4 + + # PHPUnit's --filter matches test method names, which .phpt files do not have, so the files are named + - name: Run the clblast backend tests + run: vendor/bin/phpunit tests/Functional/testClblast*.phpt + static-analysis: name: PHPStan (level max, PHP ${{ matrix.php }}) runs-on: ubuntu-latest From b68666a6f86df19e8c61fe582f8c423b8b390f3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:19:12 +0000 Subject: [PATCH 10/15] feat(backend): add benchmark CLI comparing drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script times the whole PHP-level operation — packing, the kernel call, reading the result back and validating it into a new Matrix — because that is what a caller actually pays, and it is what decides where the crossover sits. A warm-up run is discarded so that the kernels CLBlast compiles for the device are not charged to the first measurement, and the median of several runs is reported. --markdown prints a table ready to paste into the README, with the machine and the exact command above it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- .php-cs-fixer.dist.php | 2 +- bench/benchmark.php | 474 +++++++++++++++++++++++++++++++++++++++++ composer.json | 2 + phpstan.dist.neon | 7 + 4 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 bench/benchmark.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 4e76f5b..bcd7bb6 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -12,7 +12,7 @@ HEADER; $finder = PhpCsFixer\Finder::create() - ->in([__DIR__ . '/src']) + ->in([__DIR__ . '/src', __DIR__ . '/bench']) ->name('*.php') ->append([__FILE__, __DIR__ . '/bootstrap.php']) // Test fixtures are ordinary PHP holding the shared backend stubs and SKIPIF probes; the ".inc" suffix only diff --git a/bench/benchmark.php b/bench/benchmark.php new file mode 100644 index 0000000..934f779 --- /dev/null +++ b/bench/benchmark.php @@ -0,0 +1,474 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Bench; + +use function array_filter; +use function array_map; +use function array_slice; +use function array_values; +use function count; +use function explode; +use function file_get_contents; +use function getenv; +use function getopt; +use function hrtime; +use function implode; +use function in_array; + +use InvalidArgumentException; + +use function is_array; +use function is_string; + +use Lisachenko\NativePhpMatrix\Backend\BackendNotAvailableException; +use Lisachenko\NativePhpMatrix\Backend\Backends; +use Lisachenko\NativePhpMatrix\Matrix; + +use function max; +use function mt_getrandmax; +use function mt_rand; +use function mt_srand; +use function number_format; + +use const PHP_EOL; + +use function php_uname; + +use const PHP_VERSION; + +use function preg_match; +use function printf; +use function range; +use function sort; +use function sprintf; +use function str_repeat; +use function trim; + +require __DIR__ . '/../vendor/autoload.php'; + +/** + * Compares the matrix backends on the operations that dominate machine learning workloads + * + * The point of this script is not to prove that native code is faster than an interpreter — it is, by orders of + * magnitude — but to show where the crossover sits for *this* library, packing and unpacking included. Every + * measurement therefore times the whole PHP-level operation: converting the rows into a buffer, calling the + * kernel, reading the result back and validating it into a new Matrix. That is what a caller actually pays. + */ +final class Benchmark +{ + /** + * Matrix dimensions measured when --sizes is not given + * + * @var list + */ + private const array DEFAULT_SIZES = [64, 128, 256, 512]; + + /** + * Backends measured when --backends is not given + * + * @var list + */ + private const array DEFAULT_BACKENDS = [Backends::PHP, Backends::BLAS, Backends::CLBLAST]; + + /** + * Operations measured when --ops is not given + * + * @var list + */ + private const array DEFAULT_OPERATIONS = ['gemm', 'add', 'scal']; + + /** + * Number of timed repetitions when --repeat is not given + */ + private const int DEFAULT_REPEAT = 5; + + /** + * @param list $sizes Square matrix dimensions to measure + * @param list $backends Backend names to measure + * @param list $operations Operation names to measure + * @param int $repeat Number of timed repetitions per combination + * @param bool $markdown Whether to print a table ready to paste into the README + * @param list $arguments Arguments this run was started with, echoed in the report + */ + public function __construct( + private readonly array $sizes, + private readonly array $backends, + private readonly array $operations, + private readonly int $repeat, + private readonly bool $markdown, + private readonly array $arguments, + ) {} + + /** + * Builds a benchmark from the command line arguments + */ + public static function fromCommandLine(): self + { + $options = getopt('', ['sizes:', 'backends:', 'ops:', 'repeat:', 'markdown', 'help']); + if ($options === false) { + echo self::usage(); + + exit(1); + } + if (isset($options['help'])) { + echo self::usage(); + + exit(0); + } + + $sizes = []; + foreach (self::listOption($options, 'sizes', array_map(strval(...), self::DEFAULT_SIZES)) as $raw) { + $size = (int) $raw; + if ($size > 0) { + $sizes[] = $size; + } + } + + return new self( + $sizes, + self::listOption($options, 'backends', self::DEFAULT_BACKENDS), + self::listOption($options, 'ops', self::DEFAULT_OPERATIONS), + max(1, (int) self::scalarOption($options, 'repeat', (string) self::DEFAULT_REPEAT)), + isset($options['markdown']), + self::commandLineArguments(), + ); + } + + /** + * Returns the arguments this process was started with, without the script name + * + * @return list + */ + private static function commandLineArguments(): array + { + $arguments = []; + $argv = $_SERVER['argv'] ?? []; + if (is_array($argv)) { + foreach (array_slice($argv, 1) as $argument) { + if (is_string($argument)) { + $arguments[] = $argument; + } + } + } + + return $arguments; + } + + /** + * Runs every requested combination and prints the report + */ + public function run(): void + { + $this->printEnvironment(); + + /** @var array> $measurements Milliseconds, keyed by "op:size" then backend */ + $measurements = []; + $available = []; + + foreach ($this->backends as $backend) { + try { + Backends::use($backend); + } catch (BackendNotAvailableException $exception) { + echo sprintf('Skipping backend "%s": %s', $backend, $exception->getMessage()), PHP_EOL; + + continue; + } + $available[] = $backend; + + foreach ($this->operations as $operation) { + foreach ($this->sizes as $size) { + $milliseconds = $this->measure($operation, $size); + $measurements[$operation . ':' . $size][$backend] = $milliseconds; + if (!$this->markdown) { + printf( + '%-8s %-8s %5d %10s ms%s' . PHP_EOL, + $backend, + $operation, + $size, + number_format($milliseconds, 3), + $operation === 'gemm' ? sprintf(' %8s GFLOP/s', number_format($this->gflops($size, $milliseconds), 2)) : '', + ); + } + } + } + } + + Backends::use(Backends::AUTO); + + if ($this->markdown) { + $this->printMarkdown($measurements, $available); + } + } + + /** + * Times one operation at one size and returns the median duration in milliseconds + * + * A warm-up run is discarded first: it absorbs the one-off costs — the OpenCL kernels CLBlast compiles for the + * device, the first touch of every page — that would otherwise be charged to the first measurement. + * + * @param string $operation Operation name + * @param positive-int $size Square matrix dimension + */ + private function measure(string $operation, int $size): float + { + $left = $this->randomMatrix($size); + $right = $this->randomMatrix($size); + + $this->execute($operation, $left, $right); + + $durations = []; + for ($run = 0; $run < $this->repeat; $run++) { + $start = hrtime(true); + $this->execute($operation, $left, $right); + $durations[] = (hrtime(true) - $start) / 1_000_000; + } + + sort($durations); + + return $durations[(int) (count($durations) / 2)]; + } + + /** + * Performs one operation + * + * @param string $operation Operation name + * @param Matrix $left Left operand + * @param Matrix $right Right operand + */ + private function execute(string $operation, Matrix $left, Matrix $right): void + { + match ($operation) { + 'gemm' => $left * $right, + 'add' => $left + $right, + 'scal' => $left * 2.5, + default => throw new InvalidArgumentException(sprintf('Unknown operation "%s"', $operation)), + }; + } + + /** + * Builds a square matrix of reproducible pseudo-random floats + * + * @param positive-int $size Square matrix dimension + * + * @return Matrix + */ + private function randomMatrix(int $size): Matrix + { + mt_srand(42 + $size); + + return new Matrix(array_map( + fn(): array => $this->randomRow($size), + range(1, $size), + )); + } + + /** + * Builds one row of pseudo-random floats between zero and one + * + * @param positive-int $size Number of cells + * + * @return non-empty-list + */ + private function randomRow(int $size): array + { + return array_map( + static fn(): float => mt_rand() / mt_getrandmax(), + range(1, $size), + ); + } + + /** + * Returns the effective rate of a square multiplication, which performs 2n³ floating point operations + * + * @param positive-int $size Square matrix dimension + * @param float $milliseconds Measured duration + */ + private function gflops(int $size, float $milliseconds): float + { + if ($milliseconds <= 0.0) { + return 0.0; + } + + return 2.0 * $size ** 3 / ($milliseconds / 1000) / 1_000_000_000; + } + + /** + * Prints what the numbers below were measured on + */ + private function printEnvironment(): void + { + $lines = [ + 'PHP ' . PHP_VERSION . ' on ' . php_uname('s') . ' ' . php_uname('m'), + 'CPU: ' . $this->cpuModel(), + 'OpenCL device type: ' . ($this->openClDevice() ?? 'gpu (default)'), + 'Repetitions: ' . $this->repeat . ' (median reported, one warm-up discarded)', + 'Reproduce: php bench/benchmark.php ' . implode(' ', $this->arguments), + ]; + + foreach ($lines as $line) { + echo($this->markdown ? '_' . $line . '_' . PHP_EOL . PHP_EOL : $line . PHP_EOL); + } + } + + /** + * Prints the measurements as a markdown table + * + * @param array> $measurements Milliseconds, keyed by "op:size" then backend + * @param list $available Backends that could actually be measured + */ + private function printMarkdown(array $measurements, array $available): void + { + $header = ['Operation', 'Size']; + foreach ($available as $backend) { + $header[] = '`' . $backend . '`'; + } + if (in_array(Backends::PHP, $available, true)) { + foreach (array_slice($available, 1) as $backend) { + $header[] = '`' . $backend . '` speed-up'; + } + } + + echo '| ' . implode(' | ', $header) . ' |' . PHP_EOL; + echo '|' . str_repeat(' --- |', count($header)) . PHP_EOL; + + foreach ($this->operations as $operation) { + foreach ($this->sizes as $size) { + $row = [$this->operationLabel($operation), $size . '×' . $size]; + $cell = $measurements[$operation . ':' . $size] ?? []; + + foreach ($available as $backend) { + $milliseconds = $cell[$backend] ?? null; + $row[] = $milliseconds === null + ? '—' + : number_format($milliseconds, 2) . ' ms' + . ($operation === 'gemm' + ? sprintf(' (%s GFLOP/s)', number_format($this->gflops($size, $milliseconds), 1)) + : ''); + } + + $reference = $cell[Backends::PHP] ?? null; + if ($reference !== null) { + foreach (array_slice($available, 1) as $backend) { + $milliseconds = $cell[$backend] ?? null; + $row[] = $milliseconds === null || $milliseconds <= 0.0 + ? '—' + : '×' . number_format($reference / $milliseconds, 1); + } + } + + echo '| ' . implode(' | ', array_map('strval', $row)) . ' |' . PHP_EOL; + } + } + } + + /** + * Returns the human-readable name of an operation + * + * @param string $operation Operation name + */ + private function operationLabel(string $operation): string + { + return match ($operation) { + 'gemm' => 'Multiplication `$a * $b`', + 'add' => 'Addition `$a + $b`', + 'scal' => 'Scaling `$a * 2.5`', + default => $operation, + }; + } + + /** + * Reads the CPU model from the kernel, when it exposes one + */ + private function cpuModel(): string + { + $information = @file_get_contents('/proc/cpuinfo'); + if (is_string($information) && preg_match('/^model name\s*:\s*(.+)$/m', $information, $matches) === 1) { + return trim($matches[1]); + } + + return php_uname('m') . ' (model unknown)'; + } + + /** + * Returns the requested OpenCL device type, if the environment pins one + */ + private function openClDevice(): ?string + { + $device = getenv('NATIVE_PHP_MATRIX_CL_DEVICE'); + + return is_string($device) && trim($device) !== '' ? trim($device) : null; + } + + /** + * Splits a comma separated option into a list, falling back to a default + * + * @param array $options Parsed options + * @param string $name Option name + * @param list $fallback Value to use when the option is absent + * + * @return list + */ + private static function listOption(array $options, string $name, array $fallback): array + { + $value = self::scalarOption($options, $name, null); + if ($value === null) { + return $fallback; + } + + $items = array_values(array_filter( + array_map(trim(...), explode(',', $value)), + static fn(string $item): bool => $item !== '', + )); + + return $items === [] ? $fallback : $items; + } + + /** + * Returns a single option value + * + * @param array $options Parsed options + * @param string $name Option name + * @param string|null $fallback Value to use when the option is absent + */ + private static function scalarOption(array $options, string $name, ?string $fallback): ?string + { + $value = $options[$name] ?? null; + if (is_array($value)) { + $value = $value[count($value) - 1] ?? null; + } + + return is_string($value) ? $value : $fallback; + } + + /** + * Returns the usage text + */ + private static function usage(): string + { + return <<<'USAGE' + Usage: php bench/benchmark.php [options] + + --sizes=64,128,256,512 Square matrix dimensions to measure + --backends=php,blas,clblast Backends to measure, unavailable ones are reported and skipped + --ops=gemm,add,scal Operations to measure + --repeat=5 Timed repetitions per combination, the median is reported + --markdown Print a table ready to paste into the README + --help Show this text + + The OpenCL device type of the clblast backend is chosen with NATIVE_PHP_MATRIX_CL_DEVICE=gpu|cpu|all. + + USAGE; + } +} + +Benchmark::fromCommandLine()->run(); diff --git a/composer.json b/composer.json index 8643327..933e584 100644 --- a/composer.json +++ b/composer.json @@ -41,12 +41,14 @@ }, "scripts": { "test": "phpunit", + "bench": "php bench/benchmark.php", "phpstan": "phpstan analyse", "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix" }, "scripts-descriptions": { "test": "Run the test suite", + "bench": "Compare the matrix backends on the operations of a neural network layer", "phpstan": "Run static analysis at the maximum level", "cs:check": "Check coding standards without fixing", "cs:fix": "Fix coding standards" diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 1239118..2629608 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -3,6 +3,7 @@ parameters: phpVersion: 80400 paths: - src + - bench - bootstrap.php treatPhpDocTypesAsCertain: false ignoreErrors: @@ -29,3 +30,9 @@ parameters: - identifier: property.notFound path: src/Backend/ClblastBackend.php + # The whole point of this package is that the engine dispatches "*" and "+" on a Matrix through handlers + # installed at runtime. No static analyser can know that, so the benchmark — the only place that uses the + # operators rather than the methods behind them — reports them as invalid operations + - + identifier: binaryOp.invalid + path: bench/benchmark.php From 4cc7cfbc88aa09aa9d95e857c59e89ce89b760aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:21:25 +0000 Subject: [PATCH 11/15] docs: document acceleration drivers, benchmarks and backend conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README gains the driver table, the selection API, the install lines and the two rules a caller has to know — accelerated drivers are float-only, and auto keeps integers on pure PHP — followed by an AI/ML section built around the operation inference actually spends its time in. The benchmark table is measured, not claimed, and it is honest in both directions: matrix multiplication reaches x132 at 1024x1024, while element-wise operations are slower through a driver than through the interpreter, because there is no O(n^3) work to amortise the marshalling. Both tables are in the README, with the hardware and the command that produced them. CLAUDE.md gains the backend architecture map, its three non-negotiable rules, the backend test conventions and the policy that system libraries are optional runtime dependencies rather than Composer requirements. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- CLAUDE.md | 124 ++++++++++++++++++++++++++++++++++++++++++++++++------ README.md | 111 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 220 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9944bc8..58a350f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,8 +72,16 @@ php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit tests/Functional/testC composer phpstan # PHPStan at level max composer cs:check # coding standards, PER-CS2.0 composer cs:fix # apply the fixes +composer bench # not a gate: compares the drivers, see bench/benchmark.php ``` +PHPStan analyses `src`, `bench` and `bootstrap.php`. The only ignored errors are the +FFI ones — methods that exist solely after `FFI::cdef()` has parsed the inline C, the +pseudo-property on scalar handles, index access on `FFI\CData`, and the operators of +the benchmark, which no analyser can know the engine dispatches. Each entry in +`phpstan.dist.neon` is scoped to a path, carries an identifier and a comment; there is +no baseline. Keep it that way — new ignores need the same justification. + `Matrix` is declared `@template-covariant T of int|float`. **Keep the generics honest.** Arithmetic widens: dividing or exponentiating a `Matrix` can produce floats, so those results are `Matrix`, never `Matrix`. Do not annotate @@ -131,22 +139,107 @@ Rules for a new test: - One behaviour per file. Failure cases (incompatible dimensions, unsupported operator combinations) get their own files. +### Backend tests + +- Pin the driver with an `--ENV--` section (`NATIVE_PHP_MATRIX_BACKEND=blas`), which + is merged into the child's environment, not substituted for it — the CI-wide + `NATIVE_PHP_MATRIX_CL_DEVICE` still reaches the test. +- Guard a driver-specific test with the shared probe: + `--SKIPIF--` including `include/skipif_blas.inc` or `include/skipif_clblast.inc`. + Those ask `Backends::available()`, which loads the library and runs a real + operation, so a skip can never disagree with the test. PHPUnit prints the skip + message minus two characters, hence the `skip - ` prefix. +- `.inc` files under `tests/Functional/include/` hold the shared probes and the + backend stubs. The suite collects `.phpt` only, so they are never run as tests, but + php-cs-fixer does check them. +- Use **integral-valued float fixtures and small dimensions**. Every partial sum then + stays exactly representable in double precision, so a driver that accumulates in a + different order still has to produce an identical result — which is what the + `*MatchesPhpBackendResults` tests assert. For division, use power-of-two divisors: + BLAS scales by the reciprocal, and only then is `x * (1/s)` exactly `x / s`. +- **A test's expectation may never depend on the ambient environment.** A `.phpt` + file that asserts the *default* selection pins `NATIVE_PHP_MATRIX_BACKEND=` (empty, + which means "unset") in its `--ENV--`, so it keeps asserting the default even when + the whole suite is run with a backend pinned. Conversely, the classic operator tests + deliberately keep asserting integer results under the default routing: running the + entire suite with `NATIVE_PHP_MATRIX_BACKEND=blas` or `=clblast` makes them fail, + and that is the float-only rule working as documented, not a regression. Those two + drivers are exercised by their own pinned tests, which is also why the `gpu-path` CI + job names the CLBlast test files instead of running everything. +- `failOnSkipped="true"` is set, so a skip fails the suite. That is deliberate: CI + installs every acceleration library, so a skip there means a broken image. Locally, + without the libraries, run + `php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit --do-not-fail-on-skipped`. + +The acceleration libraries CI installs, and what a local machine needs for the full +suite: + +```bash +sudo apt-get install -y libopenblas0 libclblast1 ocl-icd-libopencl1 pocl-opencl-icd +``` + +PoCL provides an OpenCL device on the CPU, which is how the GPU code path is covered +on runners that have no GPU; the `gpu-path` job pins +`NATIVE_PHP_MATRIX_BACKEND=clblast` and `NATIVE_PHP_MATRIX_CL_DEVICE=cpu`. + ## Repository map ``` -src/Matrix.php the entire library: the maths plus the __doOperation/__compare hooks -bootstrap.php Core::init() then installExtensionHandlers() on Matrix — order matters; - runs automatically via Composer's "files" autoload -tests/Functional/*.phpt the functional suite, one behaviour per file -phpunit.xml.dist PHPUnit 12 config (suite points at tests/, suffix .phpt) -phpstan.dist.neon static analysis config, level max -.php-cs-fixer.dist.php coding standards config (PER-CS2.0) -.github/workflows/ci.yml jobs: tests, static-analysis, coding-standards — PHP 8.4 and 8.5 +src/Matrix.php validation, dimensions, the __doOperation/__compare hooks; the arithmetic + itself is delegated to a backend driver +src/Backend/ the interchangeable drivers and the registry that picks one +tests/Functional/*.phpt the functional suite, one behaviour per file +tests/Functional/include/ shared SKIPIF probes and backend stubs (.inc — never collected as tests) +bench/benchmark.php driver comparison CLI, "composer bench" +bootstrap.php Core::init(), installExtensionHandlers() on Matrix, then + Backends::bootFromEnvironment() — order matters; runs automatically via + Composer's "files" autoload +phpunit.xml.dist PHPUnit 12 config (suite points at tests/, suffix .phpt) +phpstan.dist.neon static analysis config, level max +.php-cs-fixer.dist.php coding standards config (PER-CS2.0) +.github/workflows/ci.yml jobs: tests, gpu-path, static-analysis, coding-standards — PHP 8.4 and 8.5 +``` + +`src/Matrix.php` is still the centre of the library. There is no framework here to +hide behind: a change to a hook signature or to `bootstrap.php`'s ordering affects +every operator at once. + +## Backend architecture + +`Matrix` no longer does the arithmetic itself. It validates, checks dimensions, and +asks `Backends::resolveFor()` which driver should compute — drivers receive plain +arrays with the dimensions alongside them and return plain arrays. + +``` +src/Backend/BackendInterface.php the driver contract: six operations, plus isAvailable() +src/Backend/Backends.php registry, selection and the auto-routing policy +src/Backend/PhpBackend.php the original interpreted loops, verbatim +src/Backend/BlasBackend.php OpenBLAS over FFI (CPU) +src/Backend/ClblastBackend.php CLBlast over OpenCL (GPU, or CPU via PoCL) +src/Backend/AcceleratedBackendTrait.php packing, unpacking and the pow loop shared by both +src/Backend/FallbackBackend.php decorator: degrade to another driver instead of failing +src/Backend/BackendNotAvailableException.php catchable, thrown at selection time only ``` -`src/Matrix.php` is the whole library. There is no framework here to hide behind: a -change to a hook signature or to `bootstrap.php`'s ordering affects every operator -at once. +Three rules govern this part of the codebase, and none of them is negotiable: + +- **Hook safety.** Anything reachable from an operation runs inside an FFI callback, + where a thrown exception becomes `Fatal error: Throwing from FFI callbacks is not + allowed`. Drivers therefore report their unusability from `isAvailable()`, which + swallows its own failures, and selection is validated eagerly in userland — + `Backends::use()` and `bootFromEnvironment()`. Under `auto`, a driver that fails at + operation time is caught by `FallbackBackend` and the result is recomputed in pure + PHP. Catching *inside* a hook is fine; only crossing the boundary is fatal. +- **Accelerated drivers are float-only.** They compute in double precision, so they + cast integer cells and return floats. Never "fix" that by rounding results back to + integers. +- **Auto-routing keeps integers on pure PHP.** `auto` may use an accelerated CPU + driver when an operand contains a float; an all-integer operation always takes the + `php` path, so its results stay bit-identical to what this library returned before + drivers existed. A GPU is never chosen automatically. + +An availability probe performs a real 1×1 operation, so it cannot disagree with what +an operation would do a moment later. Probes are cached per process. ## Hook contracts @@ -177,13 +270,14 @@ at once. ``` feat(matrix): support element-wise exponentiation by scalar +feat(backend): add OpenBLAS driver with dgemm/daxpy/dscal over FFI fix(bootstrap): install create_object handler before do_operation test(tests): cover division by zero ci: run the suite on PHP 8.4 and 8.5 with ffi.enable=1 docs: rewrite the README in the z-engine style ``` -Scopes in use: `matrix`, `bootstrap`, `tests`, `ci`, `docs`. +Scopes in use: `matrix`, `backend`, `bootstrap`, `tests`, `ci`, `docs`. Code style is **PER-CS2.0**, applied by php-cs-fixer. Run `composer cs:fix` before proposing a change rather than hand-formatting. @@ -200,3 +294,9 @@ proposing a change rather than hand-formatting. for it, and never one without the other. - When z-engine ships stable releases for the supported minors, the constraint and the root stability flags should be tightened in a single change. +- **System libraries are never Composer requirements.** OpenBLAS, CLBlast and an + OpenCL runtime are optional, discovered at runtime by the drivers that need them, + and listed under `suggest`. The package must install and its suite must pass — with + `--do-not-fail-on-skipped` — on a machine that has none of them; the pure-PHP driver + is always available. Add a new accelerated driver the same way: lazy load, probe, + degrade. diff --git a/README.md b/README.md index 359638f..7ac9f45 100644 --- a/README.md +++ b/README.md @@ -43,15 +43,117 @@ Operator-level failures are a different story, and it is worth being blunt about Generics are honest about arithmetic: `Matrix` divided by `2` is a `Matrix`, because PHP's `/` widens. Nothing pretends to preserve `T` where the maths does not. +## ⚡ Acceleration + +The operators are the interesting part; the arithmetic underneath them is now interchangeable. `Matrix` asks a registry which **driver** should carry out an operation and hands it plain arrays — validation, dimensions and object identity never leave the class. Three drivers ship with the package: + +| Driver | Runs on | Uses | Notes | +|---|---|---|---| +| `php` | the interpreter | ordinary PHP loops | Always available, the only driver that returns **integers** | +| `blas` | CPU | OpenBLAS `cblas_dgemm` / `cblas_daxpy` / `cblas_dscal` over FFI | Double precision, needs the OpenBLAS shared library | +| `clblast` | GPU | [CLBlast](https://github.com/CNugteren/CLBlast) on OpenCL | NVIDIA, AMD, Intel — including laptop iGPUs — or the CPU through PoCL | + +Same interface for everyone else: `cuBLAS`, a `ggml` driver for the Vulkan route, and Metal are all a `BackendInterface` implementation away — **PRs welcome**. + +### Choosing a driver + +```bash +NATIVE_PHP_MATRIX_BACKEND=blas php your-script.php # php | blas | clblast | auto (default) +NATIVE_PHP_MATRIX_CL_DEVICE=cpu php your-script.php # gpu (default) | cpu | all — clblast only +``` + +```php +use Lisachenko\NativePhpMatrix\Backend\Backends; + +Backends::available(); // ['php', 'blas'] — probed, not guessed +Backends::use('blas'); // InvalidArgumentException / BackendNotAvailableException, both catchable +Backends::register('cublas', static fn (): BackendInterface => new CuBlasBackend()); +``` + +Selection is validated **eagerly**, in userland — an unknown or unusable driver throws where you can still catch it, instead of surfacing as a fatal error from inside an operator hook later. Availability is not a guess either: probing loads the library and runs a real 1×1 multiplication with it. + +### Installing the libraries + +```bash +# Debian / Ubuntu +sudo apt-get install libopenblas0 # blas +sudo apt-get install libclblast1 ocl-icd-libopencl1 # clblast, plus your vendor's ICD +sudo apt-get install pocl-opencl-icd # ...or PoCL, an OpenCL runtime on the CPU + +# macOS +brew install openblas clblast +``` + +### Two rules worth knowing + +**Accelerated drivers are float-only.** Hardware kernels compute in double precision, so `blas` and `clblast` cast integer cells to floats and return a `Matrix` — `new Matrix([[1, 2]]) + new Matrix([[3, 4]])` gives `[[4.0, 6.0]]` on them. Use `$matrix->asFloat()` when you want that conversion to be explicit in your own code. + +**`auto` is deliberately boring.** It picks OpenBLAS when the operation involves floats and OpenBLAS is loadable, keeps every all-integer operation on the pure-PHP driver so integers stay integers, and never selects a GPU on your behalf — moving data across a bus is a decision, not a default. If an accelerated driver fails mid-operation, `auto` recomputes in pure PHP rather than taking down the request. + +For web SAPIs, set `OPENBLAS_NUM_THREADS=1`: an OpenBLAS thread pool per PHP-FPM worker is rarely what you want. + +### Where the crossover is + +Acceleration is not free: every operation copies cells into a buffer and reads them back, and the result is validated into a new `Matrix`. That overhead is proportional to the number of cells, while the gain is proportional to the work — so it pays off exactly where the work grows faster than the data. + +- **Matrix multiplication** does O(n³) work over O(n²) cells. It wins from about 64×64 upwards, and the gap widens with every size. +- **Element-wise operations** (`+`, `-`, scaling) do O(n²) work over O(n²) cells. There is nothing for a kernel to amortise, and the pure-PHP driver is *faster* than any driver that has to marshal buffers first — see the numbers below. Pin `NATIVE_PHP_MATRIX_BACKEND=php` if that is all your workload does. + +## 🤖 Built for the AI/ML era + +> *The syntax of the paper, the speed of the metal, in the language of the web.* + +A neural network layer is one line of linear algebra, and with real operators it is one line of PHP: + +```php +$logits = $input * $weights + $bias; // dgemm on your CPU or GPU, dispatched by the Zend Engine +``` + +Inference is dominated by exactly this product. So is a semantic search that scores an embedding against a matrix of documents, so is re-ranking in a RAG pipeline, so is a recommender scoring a user vector against a catalogue. All of them are matrix multiplications — the operation this library hands to OpenBLAS or to your GPU. + +That matters for PHP specifically. PHP still runs a large share of the web — WordPress alone is around 43% of it — and until now anything resembling machine learning meant a Python sidecar, a paid API, or shipping your users' data to somebody else's inference endpoint. This runs **in-process**, in the language the application is already written in. + +The ecosystem agrees on the route: [Rindow Math Matrix](https://github.com/rindow/rindow-math-matrix) reaches OpenBLAS and CLBlast the same way, [TransformersPHP](https://github.com/CodeWithKyrian/transformers-php) runs ONNX models, and [RubixML/Tensor](https://github.com/RubixML/Tensor) is a compiled extension. What none of them have is the operators themselves: here `$a * $b` **is** the multiplication, dispatched by the engine, not a method call dressed up as one. And because the GPU path is OpenCL rather than CUDA, it reaches the integrated GPU in a laptop as readily as a datacentre card. + +### Measured numbers + +Matrix multiplication — the operation inference actually spends its time in: + +| Operation | Size | `php` | `blas` | `clblast` | `blas` speed-up | `clblast` speed-up | +| --- | --- | --- | --- | --- | --- | --- | +| Multiplication `$a * $b` | 64×64 | 3.67 ms (0.1 GFLOP/s) | 0.41 ms (1.3 GFLOP/s) | 0.86 ms (0.6 GFLOP/s) | ×8.9 | ×4.3 | +| Multiplication `$a * $b` | 128×128 | 28.02 ms (0.1 GFLOP/s) | 1.49 ms (2.8 GFLOP/s) | 2.38 ms (1.8 GFLOP/s) | ×18.8 | ×11.8 | +| Multiplication `$a * $b` | 256×256 | 223.88 ms (0.1 GFLOP/s) | 5.75 ms (5.8 GFLOP/s) | 9.08 ms (3.7 GFLOP/s) | ×38.9 | ×24.7 | +| Multiplication `$a * $b` | 512×512 | 1,814.76 ms (0.1 GFLOP/s) | 27.49 ms (9.8 GFLOP/s) | 39.52 ms (6.8 GFLOP/s) | ×66.0 | ×45.9 | +| Multiplication `$a * $b` | 1024×1024 | 15,026.38 ms (0.1 GFLOP/s) | 113.61 ms (18.9 GFLOP/s) | 225.65 ms (9.5 GFLOP/s) | ×132.3 | ×66.6 | + +Element-wise operations, where the honest answer is that acceleration costs more than it saves: + +| Operation | Size | `php` | `blas` | `clblast` | `blas` speed-up | `clblast` speed-up | +| --- | --- | --- | --- | --- | --- | --- | +| Addition `$a + $b` | 64×64 | 0.15 ms | 0.37 ms | 0.95 ms | ×0.4 | ×0.2 | +| Addition `$a + $b` | 512×512 | 8.41 ms | 25.27 ms | 28.48 ms | ×0.3 | ×0.3 | +| Scaling `$a * 2.5` | 64×64 | 0.12 ms | 0.31 ms | 0.78 ms | ×0.4 | ×0.2 | +| Scaling `$a * 2.5` | 512×512 | 6.91 ms | 24.45 ms | 22.00 ms | ×0.3 | ×0.3 | + +PHP 8.5.9 on Linux x86_64, Intel® Xeon® @ 2.10 GHz (shared cloud container), OpenBLAS 0.3.26, CLBlast 1.6.2 on PoCL 5.0 with `NATIVE_PHP_MATRIX_CL_DEVICE=cpu`. Median of 5 runs, one warm-up discarded, timing the whole PHP-level operation including packing, unpacking and validation. + +**These numbers are from a shared container without a GPU** — the `clblast` column is CLBlast running on the CPU through PoCL, which is a portability proof, not a GPU benchmark. On real hardware the GPU column is a different story, and the OpenBLAS column depends heavily on core count. Measure your own: + +```bash +composer bench +composer bench -- --sizes=64,128,256,512,1024 --ops=gemm --repeat=5 --markdown +``` + ## How it works Three moving parts, in order: 1. **`bootstrap.php`** is registered in Composer's `files` autoload, so it runs the moment you `require vendor/autoload.php`. It calls `ZEngine\Core::init()`, which validates that the FFI struct definitions match the exact PHP build you are running. 2. It then calls `installExtensionHandlers()` on `ZEngine\Reflection\ReflectionClass` for `Matrix`, wiring the class's `create_object`, `do_operation` and `compare` slots to the engine trampolines. Order matters — `create_object` allocates the memory the other handlers live in. -3. **`Matrix::__doOperation()`** and **`Matrix::__compare()`** are static hooks. The engine hands them a `DoOperationHook` / `CompareValuesHook` describing the opcode and both operands; they dispatch to ordinary, boring, pure-PHP methods (`sum()`, `subtract()`, `multiply()`, `multiplyByScalar()`, `divideByScalar()`, `powByScalar()`, `equals()`). +3. **`Matrix::__doOperation()`** and **`Matrix::__compare()`** are static hooks. The engine hands them a `DoOperationHook` / `CompareValuesHook` describing the opcode and both operands; they dispatch to ordinary, boring methods (`sum()`, `subtract()`, `multiply()`, `multiplyByScalar()`, `divideByScalar()`, `powByScalar()`, `equals()`), which ask the backend registry which driver should do the arithmetic. -The maths is plain PHP. The magic is only in getting the engine to call it. +The maths is plain PHP — or plain BLAS, if you asked for it. The magic is only in getting the engine to call it. ## Requirements @@ -174,7 +276,9 @@ Tracked as [GitHub issues](https://github.com/lisachenko/native-php-matrix/issue - **`count($matrix)`** — row count through `Countable`, installed at the engine level - **`foreach` iteration** — row-by-row traversal via the `get_iterator` handler - **Friendly `var_dump()`** — a `get_debug_info` handler that prints the matrix instead of its internals -- **FFI BLAS backend** — hand multiplication off to a real BLAS library for performance, keeping the pure-PHP path as the fallback +- ~~**FFI BLAS backend**~~ — **done**: see [⚡ Acceleration](#-acceleration), with OpenBLAS on the CPU and CLBlast on the GPU behind an interchangeable driver interface +- **More BLAS coverage** — transposition, `gemv`, in-place accumulation and the fused `$input * $weights + $bias` of an inference layer +- **More drivers** — cuBLAS, a `ggml` driver for the Vulkan route, Metal — same `BackendInterface`, PRs welcome ## Contributing @@ -184,6 +288,7 @@ The repository ships an agent/contributor contract in **[CLAUDE.md](CLAUDE.md)** composer test # PHPUnit 12, .phpt functional suite composer phpstan # static analysis at level max composer cs:check # coding standards (PER-CS2.0); composer cs:fix to apply +composer bench # compare the drivers on your own hardware ``` ## License From 5373e1b542dd79915bb26fb3ca12257201a93d49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:29:10 +0000 Subject: [PATCH 12/15] style: call global functions unqualified instead of importing them The import lists had grown to sixty "use function" and "use const" lines across six files, long enough in benchmark.php and Matrix.php to push the actual class imports off the screen. PHP resolves an unqualified global function through the same fallback whether or not it was imported, so the lines bought nothing but vertical space. Every one of them is dropped and the call sites left unqualified. Only classes, interfaces, traits and enums keep a "use" statement. The php-cs-fixer config neither adds nor removes these imports, so the convention survives "composer cs:fix" instead of being undone by it, and CLAUDE.md now states it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- CLAUDE.md | 7 +++++ bench/benchmark.php | 37 ------------------------- src/Backend/AcceleratedBackendTrait.php | 4 --- src/Backend/Backends.php | 10 ------- src/Backend/ClblastBackend.php | 13 --------- src/Backend/PhpBackend.php | 3 -- src/Matrix.php | 20 ------------- 7 files changed, 7 insertions(+), 87 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 58a350f..e12f33c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,6 +282,13 @@ Scopes in use: `matrix`, `backend`, `bootstrap`, `tests`, `ci`, `docs`. Code style is **PER-CS2.0**, applied by php-cs-fixer. Run `composer cs:fix` before proposing a change rather than hand-formatting. +**Global functions and constants are never imported.** Call `count()`, `sprintf()`, +`is_int()` and friends unqualified, and write `PHP_EOL` or `ARRAY_FILTER_USE_KEY` +as they are — no `use function` or `use const` lines anywhere. Only classes, +interfaces, traits and enums get a `use` statement. The import lists were pure +noise, and the fixer neither adds nor removes these imports, so the convention is +stable under `composer cs:fix`. + ## Dependency policy - `lisachenko/z-engine` is required as **`8.4.x-dev || 8.5.x-dev`** — one dev line diff --git a/bench/benchmark.php b/bench/benchmark.php index 934f779..d54a04b 100644 --- a/bench/benchmark.php +++ b/bench/benchmark.php @@ -12,48 +12,11 @@ namespace Lisachenko\NativePhpMatrix\Bench; -use function array_filter; -use function array_map; -use function array_slice; -use function array_values; -use function count; -use function explode; -use function file_get_contents; -use function getenv; -use function getopt; -use function hrtime; -use function implode; -use function in_array; - use InvalidArgumentException; - -use function is_array; -use function is_string; - use Lisachenko\NativePhpMatrix\Backend\BackendNotAvailableException; use Lisachenko\NativePhpMatrix\Backend\Backends; use Lisachenko\NativePhpMatrix\Matrix; -use function max; -use function mt_getrandmax; -use function mt_rand; -use function mt_srand; -use function number_format; - -use const PHP_EOL; - -use function php_uname; - -use const PHP_VERSION; - -use function preg_match; -use function printf; -use function range; -use function sort; -use function sprintf; -use function str_repeat; -use function trim; - require __DIR__ . '/../vendor/autoload.php'; /** diff --git a/src/Backend/AcceleratedBackendTrait.php b/src/Backend/AcceleratedBackendTrait.php index 6162a02..656a0e9 100644 --- a/src/Backend/AcceleratedBackendTrait.php +++ b/src/Backend/AcceleratedBackendTrait.php @@ -12,13 +12,9 @@ namespace Lisachenko\NativePhpMatrix\Backend; -use function array_map; - use FFI; use FFI\CData; -use function range; - /** * Shared plumbing of the drivers that hand their arithmetic to a numeric library * diff --git a/src/Backend/Backends.php b/src/Backend/Backends.php index ea18552..887ae3a 100644 --- a/src/Backend/Backends.php +++ b/src/Backend/Backends.php @@ -12,19 +12,9 @@ namespace Lisachenko\NativePhpMatrix\Backend; -use function array_keys; -use function getenv; -use function implode; - use InvalidArgumentException; - -use function is_string; -use function sprintf; - use Throwable; -use function trim; - /** * Registry of matrix arithmetic drivers and the policy that picks one per operation * diff --git a/src/Backend/ClblastBackend.php b/src/Backend/ClblastBackend.php index f83ff13..4a52d1c 100644 --- a/src/Backend/ClblastBackend.php +++ b/src/Backend/ClblastBackend.php @@ -15,22 +15,9 @@ use FFI; use FFI\CData; use FFI\Exception as FFIException; - -use function get_debug_type; -use function getenv; -use function implode; -use function is_int; -use function is_string; - use RuntimeException; - -use function sprintf; -use function strtolower; - use Throwable; -use function trim; - /** * GPU driver backed by CLBlast on top of OpenCL, reached through FFI * diff --git a/src/Backend/PhpBackend.php b/src/Backend/PhpBackend.php index 8011d3a..044dcd3 100644 --- a/src/Backend/PhpBackend.php +++ b/src/Backend/PhpBackend.php @@ -12,9 +12,6 @@ namespace Lisachenko\NativePhpMatrix\Backend; -use function array_column; -use function array_keys; - /** * Reference driver: the original interpreted PHP arithmetic * diff --git a/src/Matrix.php b/src/Matrix.php index 1ff7725..fe2a61a 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -12,29 +12,9 @@ namespace Lisachenko\NativePhpMatrix; -use function array_filter; - -use const ARRAY_FILTER_USE_KEY; - -use function array_is_list; -use function count; -use function get_mangled_object_vars; -use function implode; - use InvalidArgumentException; - -use function is_array; -use function is_float; -use function is_int; -use function is_numeric; -use function is_string; - use Lisachenko\NativePhpMatrix\Backend\Backends; use LogicException; - -use function sprintf; -use function str_starts_with; - use ZEngine\ClassExtension\Hook\CastObjectHook; use ZEngine\ClassExtension\Hook\CastType; use ZEngine\ClassExtension\Hook\CompareValuesHook; From 89dd925c7c7c8c2f33f98d0aef7ef8ef7f099e3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:56:04 +0000 Subject: [PATCH 13/15] feat(matrix)!: store matrices as native float64 buffers A Matrix no longer holds a PHP array of rows. It owns a single contiguous, row-major "double[rows * columns]" allocation, which is the shape a BLAS kernel, an OpenCL buffer and an interpreted loop can all read without a conversion in between. The constructor validates and converts in one pass, writing each cell straight into the buffer where FFI performs the int-to-double conversion natively, so there is no intermediate array and no second loop. That removes the userland cast loop entirely: asFloat() is gone, and so is the containsFloats flag it existed to compensate for. Cells are float64, period. Integers are still accepted as input and stored as the doubles they convert to, the way numpy.array([[1, 2]]) yields dtype=float64. The driver contract changes with it. Operations take the operand buffers and return a freshly allocated one, so nothing is packed on the way in or unpacked on the way out: - BlasBackend calls cblas_dgemm/daxpy/dscal directly on the stored buffers. The only copy left is the one the accumulating kernels force, a single memcpy, instead of a cell-by-cell conversion of both operands and of the result. - PhpBackend runs the same loops over buffer offsets, keeping its accumulation order so that it and the accelerated drivers still agree bit for bit. - ClblastBackend uploads straight from the stored buffer and reads back into the buffer that becomes the result. - AcceleratedBackendTrait shrinks to the one operation no BLAS provides. Two consequences follow. Automatic routing no longer needs to know the operand types: with marshalling gone there is no operation that is cheaper interpreted, so "auto" uses OpenBLAS for every operation when it is available and pure PHP otherwise, and a GPU is still never chosen for you. And equals() compares the two buffers with a single memcmp, which is bit-exact and documented as such. The driver names become a string-backed Driver enum instead of loose class constants; the registry still accepts plain strings, because a third-party driver registered through register() cannot be a case of it. Every test now expects floats, which is what finally lets the whole suite pass under every pinned backend rather than only under the default routing. BREAKING CHANGE: matrix cells are float64 only. Integer input is accepted but stored and returned as float, so "new Matrix([[1, 2]]) + new Matrix([[3, 4]])" now yields [[4.0, 6.0]] instead of [[4, 6]]. Matrix::asFloat() is removed, it no longer means anything. The Matrix generic is removed for the same reason. BackendInterface operations take and return FFI\CData buffers instead of arrays, so out-of-tree drivers must be updated. Backends::resolveFor(bool) is replaced by Backends::resolve(), and the Backends::PHP/BLAS/CLBLAST/AUTO constants by the Driver enum, which Backends::active() now returns for built-in drivers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- bench/benchmark.php | 15 +- phpstan.dist.neon | 33 ++- src/Backend/AcceleratedBackendTrait.php | 120 +------- src/Backend/BackendInterface.php | 104 ++++--- src/Backend/Backends.php | 119 ++++---- src/Backend/BlasBackend.php | 116 ++++---- src/Backend/ClblastBackend.php | 95 +++--- src/Backend/Driver.php | 70 +++++ src/Backend/FallbackBackend.php | 19 +- src/Backend/Float64Buffer.php | 132 +++++++++ src/Backend/PhpBackend.php | 122 ++++---- src/Matrix.php | 275 +++++++++++------- tests/Functional/include/MarkerBackend.inc | 29 +- .../Functional/include/UnavailableBackend.inc | 29 +- tests/Functional/include/skipif_blas.inc | 3 +- tests/Functional/include/skipif_clblast.inc | 3 +- .../testAutoKeepsIntMatricesOnPhpBackend.phpt | 46 --- .../Functional/testBackendDefaultsToAuto.phpt | 2 +- .../testBackendEnvVarSelectsPhpBackend.phpt | 2 +- tests/Functional/testCanAddMatrices.phpt | 6 +- .../Functional/testCanCastMatrixToArray.phpt | 12 +- .../testCanCastMatrixToFloatMatrix.phpt | 34 --- .../testCanDivideMatrixByNumber.phpt | 6 +- .../testCanMultiplyCompatibleMatrices.phpt | 2 +- .../testCanMultiplyMatrixByNumber.phpt | 6 +- .../testCanMultiplyNumberByMatrix.phpt | 6 +- .../Functional/testCanPowMatrixByNumber.phpt | 6 +- .../testCanRegisterThirdPartyBackend.phpt | 4 +- tests/Functional/testCanSubtractMatrices.phpt | 6 +- ...estFailsOnSelectingUnavailableBackend.phpt | 2 +- .../testFailsOnSelectingUnknownBackend.phpt | 2 +- .../testKeepsDefaultDebugOutput.phpt | 6 +- tests/Functional/testMatrixStoresFloat64.phpt | 59 ++++ 33 files changed, 840 insertions(+), 651 deletions(-) create mode 100644 src/Backend/Driver.php create mode 100644 src/Backend/Float64Buffer.php delete mode 100644 tests/Functional/testAutoKeepsIntMatricesOnPhpBackend.phpt delete mode 100644 tests/Functional/testCanCastMatrixToFloatMatrix.phpt create mode 100644 tests/Functional/testMatrixStoresFloat64.phpt diff --git a/bench/benchmark.php b/bench/benchmark.php index d54a04b..a708338 100644 --- a/bench/benchmark.php +++ b/bench/benchmark.php @@ -15,6 +15,7 @@ use InvalidArgumentException; use Lisachenko\NativePhpMatrix\Backend\BackendNotAvailableException; use Lisachenko\NativePhpMatrix\Backend\Backends; +use Lisachenko\NativePhpMatrix\Backend\Driver; use Lisachenko\NativePhpMatrix\Matrix; require __DIR__ . '/../vendor/autoload.php'; @@ -41,7 +42,7 @@ final class Benchmark * * @var list */ - private const array DEFAULT_BACKENDS = [Backends::PHP, Backends::BLAS, Backends::CLBLAST]; + private const array DEFAULT_BACKENDS = [Driver::Php->value, Driver::Blas->value, Driver::Clblast->value]; /** * Operations measured when --ops is not given @@ -166,7 +167,7 @@ public function run(): void } } - Backends::use(Backends::AUTO); + Backends::use(Driver::Auto); if ($this->markdown) { $this->printMarkdown($measurements, $available); @@ -205,8 +206,8 @@ private function measure(string $operation, int $size): float * Performs one operation * * @param string $operation Operation name - * @param Matrix $left Left operand - * @param Matrix $right Right operand + * @param Matrix $left Left operand + * @param Matrix $right Right operand */ private function execute(string $operation, Matrix $left, Matrix $right): void { @@ -223,7 +224,7 @@ private function execute(string $operation, Matrix $left, Matrix $right): void * * @param positive-int $size Square matrix dimension * - * @return Matrix + * @return Matrix */ private function randomMatrix(int $size): Matrix { @@ -295,7 +296,7 @@ private function printMarkdown(array $measurements, array $available): void foreach ($available as $backend) { $header[] = '`' . $backend . '`'; } - if (in_array(Backends::PHP, $available, true)) { + if (in_array(Driver::Php->value, $available, true)) { foreach (array_slice($available, 1) as $backend) { $header[] = '`' . $backend . '` speed-up'; } @@ -319,7 +320,7 @@ private function printMarkdown(array $measurements, array $available): void : ''); } - $reference = $cell[Backends::PHP] ?? null; + $reference = $cell[Driver::Php->value] ?? null; if ($reference !== null) { foreach (array_slice($available, 1) as $backend) { $milliseconds = $cell[$backend] ?? null; diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 2629608..6a75985 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -9,14 +9,43 @@ parameters: ignoreErrors: # FFI buffers are indexable at runtime — that is the whole point of a "double[n]" allocation — but PHPStan # models FFI\CData as a plain object with neither ArrayAccess nor a known cell type. Reading a cell is - # therefore "mixed" to it, and writing one degrades the variable to "mixed" as well. Both are confined to - # the two helpers of the accelerated drivers that touch raw memory at all + # therefore "mixed" to it, writing one degrades the variable to "mixed" as well, and any arithmetic on a + # cell inherits that "mixed". Every entry below is one of those three shapes, confined to the files that + # walk raw memory: the cell loops of the drivers, the storage of Matrix and the buffer helper itself - identifier: offsetAccess.nonOffsetAccessible path: src/Backend/AcceleratedBackendTrait.php - identifier: return.type path: src/Backend/AcceleratedBackendTrait.php + - + identifier: binaryOp.invalid + path: src/Backend/AcceleratedBackendTrait.php + # The pure-PHP driver is nothing but those loops now that cells live in a buffer rather than in arrays + - + identifier: offsetAccess.nonOffsetAccessible + path: src/Backend/PhpBackend.php + - + identifier: return.type + path: src/Backend/PhpBackend.php + - + identifier: binaryOp.invalid + path: src/Backend/PhpBackend.php + # Matrix writes its cells into the buffer as it validates them, and reads them back in toArray() + - + identifier: offsetAccess.nonOffsetAccessible + path: src/Matrix.php + - + identifier: assign.propertyType + path: src/Matrix.php + # FFI::new() is callable statically, although the stubs declare it as an instance method, and the single + # cell accessors of the buffer helper index a CData like the C array it is + - + identifier: method.staticCall + path: src/Backend/Float64Buffer.php + - + identifier: return.type + path: src/Backend/Float64Buffer.php # The cblas_* methods exist only after FFI::cdef() has parsed the inline C declarations of this driver, so # no static analysis can see them on the FFI instance - diff --git a/src/Backend/AcceleratedBackendTrait.php b/src/Backend/AcceleratedBackendTrait.php index 656a0e9..d8e4ebc 100644 --- a/src/Backend/AcceleratedBackendTrait.php +++ b/src/Backend/AcceleratedBackendTrait.php @@ -12,128 +12,32 @@ namespace Lisachenko\NativePhpMatrix\Backend; -use FFI; use FFI\CData; /** - * Shared plumbing of the drivers that hand their arithmetic to a numeric library + * The little that the drivers handing their arithmetic to a numeric library still share * - * Every such library speaks contiguous double precision memory, while this package speaks lists of rows. The two - * conversions live here, together with the one operation no BLAS implementation provides. + * This trait used to carry the packing and unpacking between lists of rows and contiguous doubles. Both are gone: + * matrices are stored as contiguous doubles now, so an operand is passed to a kernel exactly as it lies in memory + * and the result buffer becomes the storage of the new matrix without being read cell by cell first. * - * The conversion is also where the float-only promise of these drivers is kept: packing casts each cell to a - * double, and unpacking returns floats, so an integer matrix that reaches an accelerated driver comes back as a - * float one. Automatic routing avoids that by keeping integer operations on the pure-PHP driver; an explicit - * selection accepts it deliberately. + * What remains is the one operation no BLAS implementation provides. */ trait AcceleratedBackendTrait { /** * {@inheritDoc} */ - public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData { - // Exponentiation is not part of BLAS: it stays an ordinary loop, in floats, so that an accelerated driver - // answers every overloaded operator with the same cell type - $result = []; - foreach ($matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = ((float) $cellValue) ** $value; - } - $result[] = $resultRow; + // Exponentiation is not part of BLAS, and CLBlast has no kernel for it either: it stays an ordinary loop + // over the cells, so that an accelerated driver still answers every operator this package overloads + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $matrix[$cell] ** $value; } return $result; } - - /** - * Copies a matrix into a freshly allocated row-major buffer of doubles - * - * The buffer is owned by PHP: it is released when the returned handle goes out of scope, which is why no - * driver in this package frees host memory by hand. - * - * @param non-empty-list> $matrix Cells to copy - * @param positive-int $rows Number of rows - * @param positive-int $columns Number of columns - * - * @return CData Owned `double[rows * columns]` buffer holding the cells row by row - */ - private function packRowMajor(array $matrix, int $rows, int $columns): CData - { - $buffer = $this->allocate($rows * $columns); - $this->fillRowMajor($buffer, $matrix); - - return $buffer; - } - - /** - * Writes the cells of a matrix into a buffer, row by row - * - * @param CData $buffer Buffer with room for every cell - * @param non-empty-list> $matrix Cells to write - */ - private function fillRowMajor(CData $buffer, array $matrix): void - { - $offset = 0; - foreach ($matrix as $row) { - foreach ($row as $cellValue) { - $buffer[$offset++] = (float) $cellValue; - } - } - } - - /** - * Allocates an owned, zero-filled buffer of doubles - * - * @param positive-int $count Number of doubles to reserve - * - * @return CData Owned `double[count]` buffer - */ - private function allocate(int $count): CData - { - return $this->library()->new('double[' . $count . ']'); - } - - /** - * Reads a row-major buffer of doubles back into a list of rows - * - * @param CData $buffer Buffer holding at least rows * columns doubles - * @param positive-int $rows Number of rows to read - * @param positive-int $columns Number of columns to read - * - * @return non-empty-list> Cells of the computed matrix - */ - private function unpackRows(CData $buffer, int $rows, int $columns): array - { - return array_map( - fn(int $row): array => $this->unpackRow($buffer, $row * $columns, $columns), - range(0, $rows - 1), - ); - } - - /** - * Reads a single row of doubles out of a row-major buffer - * - * @param CData $buffer Buffer holding the cells - * @param int $offset Index of the first cell of the row - * @param positive-int $columns Number of cells to read - * - * @return non-empty-list Cells of one row - */ - private function unpackRow(CData $buffer, int $offset, int $columns): array - { - return array_map( - static fn(int $column): float => $buffer[$offset + $column], - range(0, $columns - 1), - ); - } - - /** - * Returns the loaded library this driver computes with - * - * Declared here because the shared plumbing allocates its buffers through the very FFI binding the driver - * loaded; every driver using this trait provides it. - */ - abstract private function library(): FFI; } diff --git a/src/Backend/BackendInterface.php b/src/Backend/BackendInterface.php index e28499f..a92f1de 100644 --- a/src/Backend/BackendInterface.php +++ b/src/Backend/BackendInterface.php @@ -12,12 +12,26 @@ namespace Lisachenko\NativePhpMatrix\Backend; +use FFI\CData; + /** * Contract of an interchangeable matrix arithmetic driver * - * A backend is a numeric kernel and nothing else: it receives plain arrays with the dimensions already known and - * returns a plain array of the same shape. Validation, dimension checks and the object identity remain the - * responsibility of {@see \Lisachenko\NativePhpMatrix\Matrix}, so a driver never has to construct one. + * A backend is a numeric kernel and nothing else: it receives the operands as raw row-major `double[]` buffers + * with the dimensions already known, and returns a freshly allocated buffer of the same kind. Validation, + * dimension checks and the object identity remain the responsibility of + * {@see \Lisachenko\NativePhpMatrix\Matrix}, so a driver never has to construct one. + * + * The operands are the buffers the matrices are actually stored in, handed over without a copy. That is the whole + * point of the shape: a PHP array would have to be packed into contiguous doubles before any kernel could touch + * it and unpacked afterwards, which for an element-wise operation costs more than the arithmetic itself. Two + * obligations come with the privilege: + * + * - **Operands are read-only.** They belong to the matrices the caller still holds. A kernel that accumulates + * into one of its arguments — `daxpy` and `dscal` both do — copies it into the result buffer first + * ({@see Float64Buffer::copyOf()}) and works on the copy. + * - **The result is a fresh allocation.** Never return an operand, and never return a buffer that outlives the + * call in some driver-owned cache: the returned buffer becomes the storage of a new matrix. * * Two rules bind every implementation: * @@ -25,9 +39,9 @@ * where a thrown exception becomes an engine-level fatal error. Report an unusable driver from * {@see self::isAvailable()} — which must swallow its own failures and return false — instead of throwing from * an operation. The registry validates a selection eagerly, in ordinary userland code, for the same reason. - * - **Accelerated drivers are float-only.** Hardware kernels compute in double precision, so a driver that is not - * the pure-PHP one casts integer input to float and returns floats. Automatic routing takes that into account - * and keeps all-integer arithmetic on the pure-PHP driver. + * - **Everything is float64.** There is no integer path left to preserve: a matrix stores double precision cells, + * every driver reads and writes double precision cells, and the pure-PHP driver produces bit-identical results + * to the accelerated ones for values that are exactly representable. */ interface BackendInterface { @@ -43,76 +57,76 @@ public function isAvailable(): bool; /** * Adds two matrices of the same shape element-wise * - * @param non-empty-list> $left Left operand cells - * @param non-empty-list> $right Right operand cells, same shape as the left one - * @param positive-int $rows Number of rows in both operands - * @param positive-int $columns Number of columns in both operands + * @param CData $left Left operand cells, row-major `double[rows * columns]` + * @param CData $right Right operand cells, same shape as the left one + * @param positive-int $rows Number of rows in both operands + * @param positive-int $columns Number of columns in both operands * - * @return non-empty-list> Sum of both operands + * @return CData Freshly allocated `double[rows * columns]` holding the sum */ - public function sum(array $left, array $right, int $rows, int $columns): array; + public function sum(CData $left, CData $right, int $rows, int $columns): CData; /** * Subtracts the right matrix from the left one element-wise * - * @param non-empty-list> $left Left operand cells - * @param non-empty-list> $right Right operand cells, same shape as the left one - * @param positive-int $rows Number of rows in both operands - * @param positive-int $columns Number of columns in both operands + * @param CData $left Left operand cells, row-major `double[rows * columns]` + * @param CData $right Right operand cells, same shape as the left one + * @param positive-int $rows Number of rows in both operands + * @param positive-int $columns Number of columns in both operands * - * @return non-empty-list> Difference of both operands + * @return CData Freshly allocated `double[rows * columns]` holding the difference */ - public function subtract(array $left, array $right, int $rows, int $columns): array; + public function subtract(CData $left, CData $right, int $rows, int $columns): CData; /** * Multiplies two matrices with matching inner dimensions * - * @param non-empty-list> $left Left operand cells, shaped rows × inner - * @param non-empty-list> $right Right operand cells, shaped inner × columns - * @param positive-int $rows Number of rows of the left operand - * @param positive-int $inner Shared dimension: left columns and right rows - * @param positive-int $columns Number of columns of the right operand + * @param CData $left Left operand cells, row-major `double[rows * inner]` + * @param CData $right Right operand cells, row-major `double[inner * columns]` + * @param positive-int $rows Number of rows of the left operand + * @param positive-int $inner Shared dimension: left columns and right rows + * @param positive-int $columns Number of columns of the right operand * - * @return non-empty-list> Product, shaped rows × columns + * @return CData Freshly allocated `double[rows * columns]` holding the product */ - public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array; + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData; /** * Multiplies every cell by a scalar value * - * @param non-empty-list> $matrix Operand cells - * @param int|float $value Multiplier - * @param positive-int $rows Number of rows in the operand - * @param positive-int $columns Number of columns in the operand + * @param CData $matrix Operand cells, row-major `double[rows * columns]` + * @param float $value Multiplier + * @param positive-int $rows Number of rows in the operand + * @param positive-int $columns Number of columns in the operand * - * @return non-empty-list> Scaled cells + * @return CData Freshly allocated `double[rows * columns]` holding the scaled cells */ - public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array; + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData; /** * Divides every cell by a scalar value * - * @param non-empty-list> $matrix Operand cells - * @param int|float $value Divider - * @param positive-int $rows Number of rows in the operand - * @param positive-int $columns Number of columns in the operand + * @param CData $matrix Operand cells, row-major `double[rows * columns]` + * @param float $value Divider + * @param positive-int $rows Number of rows in the operand + * @param positive-int $columns Number of columns in the operand * - * @return non-empty-list> Divided cells + * @return CData Freshly allocated `double[rows * columns]` holding the divided cells */ - public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array; + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData; /** * Raises every cell to the power of a scalar value * - * BLAS has no exponentiation primitive, so accelerated drivers implement this one with a float loop. It is - * part of the contract only to keep their float-only promise for every operator the class overloads. + * BLAS has no exponentiation primitive, so accelerated drivers implement this one with a loop over the cells. + * It is part of the contract so that every operator the class overloads has a driver-level counterpart. * - * @param non-empty-list> $matrix Operand cells - * @param int|float $value Exponent - * @param positive-int $rows Number of rows in the operand - * @param positive-int $columns Number of columns in the operand + * @param CData $matrix Operand cells, row-major `double[rows * columns]` + * @param float $value Exponent + * @param positive-int $rows Number of rows in the operand + * @param positive-int $columns Number of columns in the operand * - * @return non-empty-list> Exponentiated cells + * @return CData Freshly allocated `double[rows * columns]` holding the exponentiated cells */ - public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array; + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData; } diff --git a/src/Backend/Backends.php b/src/Backend/Backends.php index 887ae3a..1f54f0f 100644 --- a/src/Backend/Backends.php +++ b/src/Backend/Backends.php @@ -16,7 +16,7 @@ use Throwable; /** - * Registry of matrix arithmetic drivers and the policy that picks one per operation + * Registry of matrix arithmetic drivers and the policy that picks one * * Drivers are registered under a short name with a lazy factory, so requiring this package never loads a shared * library. Selection happens once, in userland: either through {@see self::use()} or from the @@ -24,12 +24,18 @@ * catchable exception, which is the entire point — an invalid selection discovered later, inside the operator * hooks, would surface as an engine-level fatal error instead. * - * The default selection is `auto`, whose rules are deliberately boring and predictable: + * Built-in drivers are addressed by a {@see Driver} case, third-party ones by the string they were registered + * under; every method that names a driver accepts either. * - * - an operand contains a float **and** an accelerated CPU driver probed successfully → that driver; - * - anything else, including every all-integer operation → the pure-PHP driver, whose results are bit-identical - * to the ones this library produced before drivers existed; - * - a GPU driver is never chosen automatically: moving data to a device is a decision, not a default. + * The default selection is {@see Driver::Auto}, and now that matrices are stored as native float64 buffers its + * rules fit in two lines: + * + * - an accelerated CPU driver probed successfully → that driver, for **every** operation. There is no longer a + * marshalling cost that could make an element-wise operation cheaper in the interpreter, and no integer + * semantics left to protect; + * - nothing available → the pure-PHP driver, which needs no library and computes the same values. + * + * A GPU driver is still never chosen automatically: moving data across a bus is a decision, not a default. */ final class Backends { @@ -38,26 +44,6 @@ final class Backends */ public const string ENVIRONMENT_VARIABLE = 'NATIVE_PHP_MATRIX_BACKEND'; - /** - * Selection that routes every operation by operand types and driver availability - */ - public const string AUTO = 'auto'; - - /** - * Name of the always-available pure PHP driver - */ - public const string PHP = 'php'; - - /** - * Name of the OpenBLAS CPU driver - */ - public const string BLAS = 'blas'; - - /** - * Name of the CLBlast GPU driver - */ - public const string CLBLAST = 'clblast'; - /** * Registered driver factories, keyed by driver name * @@ -82,27 +68,28 @@ final class Backends private static array $availability = []; /** - * Currently selected driver name, or {@see self::AUTO} + * Name of the currently selected driver, or the value of {@see Driver::Auto} */ - private static string $selected = self::AUTO; + private static string $selected = Driver::Auto->value; /** - * Driver that automatic routing uses for operations involving floats, resolved once per process + * Driver that automatic routing resolved to, remembered for the process */ - private static ?BackendInterface $automaticFloatBackend = null; + private static ?BackendInterface $automaticBackend = null; /** * Selects the driver to use for every following operation * - * @param string $name Driver name, or {@see self::AUTO} to restore automatic routing + * @param Driver|string $driver Built-in driver, a registered third-party name, or {@see Driver::Auto} * - * @throws InvalidArgumentException When no driver is registered under that name + * @throws InvalidArgumentException When no driver is registered under that name * @throws BackendNotAvailableException When the driver is known but unusable in this environment */ - public static function use(string $name): void + public static function use(Driver|string $driver): void { - if ($name === self::AUTO) { - self::$selected = self::AUTO; + $name = Driver::nameOf($driver); + if ($name === Driver::Auto->value) { + self::$selected = $name; return; } @@ -131,25 +118,29 @@ public static function use(string $name): void * The factory is called at most once, and only when the driver is actually selected or probed, therefore this * method never throws: a driver that cannot load reports it from {@see BackendInterface::isAvailable()}. * - * @param string $name Short driver name, usable in {@see self::use()} and the env variable + * @param Driver|string $driver Name usable in {@see self::use()} and in the env variable * @param callable(): BackendInterface $factory Lazy factory producing the driver */ - public static function register(string $name, callable $factory): void + public static function register(Driver|string $driver, callable $factory): void { + $name = Driver::nameOf($driver); $factories = self::factories(); $factories[$name] = $factory; self::$factories = $factories; unset(self::$instances[$name], self::$availability[$name]); - self::$automaticFloatBackend = null; + self::$automaticBackend = null; } /** - * Returns the current selection: a driver name or {@see self::AUTO} + * Returns the current selection + * + * Built-in selections come back as the matching {@see Driver} case, a third-party one as the string it was + * registered under — the same shape {@see self::use()} accepts. */ - public static function active(): string + public static function active(): Driver|string { - return self::$selected; + return Driver::resolveName(self::$selected); } /** @@ -189,11 +180,11 @@ public static function available(): array */ public static function reset(): void { - self::$factories = null; - self::$instances = []; - self::$availability = []; - self::$selected = self::AUTO; - self::$automaticFloatBackend = null; + self::$factories = null; + self::$instances = []; + self::$availability = []; + self::$selected = Driver::Auto->value; + self::$automaticBackend = null; } /** @@ -212,44 +203,38 @@ public static function bootFromEnvironment(): void return; } - self::use(trim($name)); + // A built-in name arrives as its case, anything else stays a string so that a driver registered by the + // application is just as selectable from the environment as the ones shipped here + self::use(Driver::resolveName(trim($name))); } /** - * Returns the driver that must carry out an operation with the given operand types + * Returns the driver that must carry out an operation * * This is the hot path of every overloaded operator and therefore never throws: an explicit selection was * validated when it was made, and automatic routing has the always-available pure-PHP driver to fall back on. - * - * @param bool $operandsContainFloats Whether any operand of the operation holds a float */ - public static function resolveFor(bool $operandsContainFloats): BackendInterface + public static function resolve(): BackendInterface { - if (self::$selected !== self::AUTO) { + if (self::$selected !== Driver::Auto->value) { return self::instance(self::$selected); } - // Automatic routing never sends integers to an accelerated driver, because those compute in double - // precision and would turn an exact integer result into a float - if (!$operandsContainFloats) { - return self::instance(self::PHP); - } - - return self::$automaticFloatBackend ??= self::resolveAutomaticFloatBackend(); + return self::$automaticBackend ??= self::resolveAutomaticBackend(); } /** - * Picks the driver that automatic routing uses for float operands + * Picks the driver automatic routing uses * * Only CPU drivers take part — sending data to a GPU is a decision, not a default — and the winner is wrapped * so that a hardware failure at operation time degrades into a pure-PHP recomputation instead of a fatal * error inside an engine hook. */ - private static function resolveAutomaticFloatBackend(): BackendInterface + private static function resolveAutomaticBackend(): BackendInterface { - $php = self::instance(self::PHP); - if (self::probe(self::BLAS)) { - return new FallbackBackend(self::instance(self::BLAS), $php); + $php = self::instance(Driver::Php->value); + if (self::probe(Driver::Blas->value)) { + return new FallbackBackend(self::instance(Driver::Blas->value), $php); } return $php; @@ -311,9 +296,9 @@ private static function factories(): array { if (self::$factories === null) { self::$factories = [ - self::PHP => static fn(): BackendInterface => new PhpBackend(), - self::BLAS => static fn(): BackendInterface => new BlasBackend(), - self::CLBLAST => static fn(): BackendInterface => new ClblastBackend(), + Driver::Php->value => static fn(): BackendInterface => new PhpBackend(), + Driver::Blas->value => static fn(): BackendInterface => new BlasBackend(), + Driver::Clblast->value => static fn(): BackendInterface => new ClblastBackend(), ]; } diff --git a/src/Backend/BlasBackend.php b/src/Backend/BlasBackend.php index 7fb626d..38a825d 100644 --- a/src/Backend/BlasBackend.php +++ b/src/Backend/BlasBackend.php @@ -13,6 +13,7 @@ namespace Lisachenko\NativePhpMatrix\Backend; use FFI; +use FFI\CData; use FFI\Exception as FFIException; use Throwable; @@ -24,6 +25,13 @@ * subtraction are exactly that over the flattened cells — and `cblas_dscal` scales a vector in place. No header * file is involved: the declarations are inline, and the CBLAS enums travel as the plain integers they are. * + * Nothing is marshalled. A matrix already stores its cells as the contiguous, row-major `double[]` block CBLAS + * wants, so an operand is handed to the kernel as the pointer it already is, and the buffer the kernel writes + * becomes the storage of the resulting matrix. The only copy left is the one the accumulating kernels force: + * `daxpy` and `dscal` write into an operand, so the operand is `memcpy`-ed into the result buffer first and the + * kernel is pointed at the copy — one bulk copy per operation instead of a cell-by-cell conversion of both + * operands on the way in and of the result on the way out. + * * The library is loaded lazily, and only ever the LP64 build. An ILP64 one — `libopenblas64*`, where the integer * arguments are 64 bit wide — must never be probed here: its ABI does not match these declarations and the * dimensions would arrive at the kernel as garbage. @@ -93,28 +101,14 @@ public function isAvailable(): bool } try { - $library = $this->library(); - $left = $this->packRowMajor([[3.0]], 1, 1); - $right = $this->packRowMajor([[4.0]], 1, 1); - $product = $this->allocate(1); - $library->cblas_dgemm( - self::CBLAS_ROW_MAJOR, - self::CBLAS_NO_TRANS, - self::CBLAS_NO_TRANS, - 1, - 1, - 1, - 1.0, - $left, - 1, - $right, - 1, - 0.0, - $product, - 1, - ); + $left = Float64Buffer::allocate(1); + $right = Float64Buffer::allocate(1); + + Float64Buffer::write($left, 0, 3.0); + Float64Buffer::write($right, 0, 4.0); - $this->available = $product[0] === 12.0; + $product = $this->multiply($left, $right, 1, 1, 1); + $this->available = Float64Buffer::read($product, 0) === 12.0; } catch (Throwable) { $this->available = false; } @@ -125,30 +119,29 @@ public function isAvailable(): bool /** * {@inheritDoc} */ - public function sum(array $left, array $right, int $rows, int $columns): array + public function sum(CData $left, CData $right, int $rows, int $columns): CData { - return $this->axpy($left, $right, $rows, $columns, 1.0); + return $this->axpy($left, $right, $rows * $columns, 1.0); } /** * {@inheritDoc} */ - public function subtract(array $left, array $right, int $rows, int $columns): array + public function subtract(CData $left, CData $right, int $rows, int $columns): CData { - return $this->axpy($left, $right, $rows, $columns, -1.0); + return $this->axpy($left, $right, $rows * $columns, -1.0); } /** * {@inheritDoc} */ - public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData { - $a = $this->packRowMajor($left, $rows, $inner); - $b = $this->packRowMajor($right, $inner, $columns); - $product = $this->allocate($rows * $columns); + $product = Float64Buffer::allocate($rows * $columns); // beta = 0.0 is specified to ignore the previous contents of C instead of scaling them, so the product - // buffer needs no initialisation — and FFI::new() hands out zero-filled memory anyway + // buffer needs no initialisation — and FFI::new() hands out zero-filled memory anyway. Both operands are + // passed straight through: they are already the row-major doubles dgemm expects $this->library()->cblas_dgemm( self::CBLAS_ROW_MAJOR, self::CBLAS_NO_TRANS, @@ -157,76 +150,73 @@ public function multiply(array $left, array $right, int $rows, int $inner, int $ $columns, $inner, 1.0, - $a, + $left, $inner, - $b, + $right, $columns, 0.0, $product, $columns, ); - return $this->unpackRows($product, $rows, $columns); + return $product; } /** * {@inheritDoc} */ - public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData { - return $this->scal($matrix, $rows, $columns, (float) $value); + return $this->scal($matrix, $rows * $columns, $value); } /** * {@inheritDoc} */ - public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData { // BLAS scales, it does not divide. Multiplying by the reciprocal is exact for powers of two and within one - // unit in the last place otherwise — the price of this driver, alongside the cast of every cell to double. - // Dividing by zero raises the very same DivisionByZeroError the pure-PHP driver raises - return $this->scal($matrix, $rows, $columns, 1.0 / $value); + // unit in the last place otherwise — the price of this driver. Dividing by zero raises the very same + // DivisionByZeroError the pure-PHP driver raises + return $this->scal($matrix, $rows * $columns, 1.0 / $value); } /** - * Computes `left ± right` by flattening both operands into vectors + * Computes `left ± right` over the flattened cells of both operands * - * @param non-empty-list> $left Left operand cells - * @param non-empty-list> $right Right operand cells - * @param positive-int $rows Number of rows in both operands - * @param positive-int $columns Number of columns in both operands - * @param float $alpha Scale applied to the right operand: 1.0 or -1.0 + * @param CData $left Left operand cells + * @param CData $right Right operand cells + * @param positive-int $count Number of cells in both operands + * @param float $alpha Scale applied to the right operand: 1.0 or -1.0 * - * @return non-empty-list> Result cells + * @return CData Freshly allocated buffer holding the result */ - private function axpy(array $left, array $right, int $rows, int $columns, float $alpha): array + private function axpy(CData $left, CData $right, int $count, float $alpha): CData { - $count = $rows * $columns; - $vector = $this->packRowMajor($right, $rows, $columns); - $result = $this->packRowMajor($left, $rows, $columns); - - // daxpy accumulates into its second vector: result = alpha * right + left - $this->library()->cblas_daxpy($count, $alpha, $vector, 1, $result, 1); + // daxpy accumulates into its second vector, which must therefore be a buffer this driver owns: the left + // operand is copied once and the kernel adds the untouched right operand into the copy + $result = Float64Buffer::copyOf($left, $count); + $this->library()->cblas_daxpy($count, $alpha, $right, 1, $result, 1); - return $this->unpackRows($result, $rows, $columns); + return $result; } /** * Scales every cell of a matrix by a factor * - * @param non-empty-list> $matrix Operand cells - * @param positive-int $rows Number of rows - * @param positive-int $columns Number of columns - * @param float $alpha Scale factor + * @param CData $matrix Operand cells + * @param positive-int $count Number of cells + * @param float $alpha Scale factor * - * @return non-empty-list> Result cells + * @return CData Freshly allocated buffer holding the scaled cells */ - private function scal(array $matrix, int $rows, int $columns, float $alpha): array + private function scal(CData $matrix, int $count, float $alpha): CData { - $result = $this->packRowMajor($matrix, $rows, $columns); - $this->library()->cblas_dscal($rows * $columns, $alpha, $result, 1); + // dscal scales in place, so it is pointed at a copy rather than at the operand the caller still holds + $result = Float64Buffer::copyOf($matrix, $count); + $this->library()->cblas_dscal($count, $alpha, $result, 1); - return $this->unpackRows($result, $rows, $columns); + return $result; } /** diff --git a/src/Backend/ClblastBackend.php b/src/Backend/ClblastBackend.php index 4a52d1c..5459fc7 100644 --- a/src/Backend/ClblastBackend.php +++ b/src/Backend/ClblastBackend.php @@ -175,8 +175,14 @@ public function isAvailable(): bool } try { - $product = $this->gemm([[3.0]], [[4.0]], 1, 1, 1); - $this->available = $product === [[12.0]]; + $left = Float64Buffer::allocate(1); + $right = Float64Buffer::allocate(1); + + Float64Buffer::write($left, 0, 3.0); + Float64Buffer::write($right, 0, 4.0); + + $product = $this->gemm($left, $right, 1, 1, 1); + $this->available = Float64Buffer::read($product, 0) === 12.0; } catch (Throwable) { $this->available = false; } @@ -187,23 +193,23 @@ public function isAvailable(): bool /** * {@inheritDoc} */ - public function sum(array $left, array $right, int $rows, int $columns): array + public function sum(CData $left, CData $right, int $rows, int $columns): CData { - return $this->axpy($left, $right, $rows, $columns, 1.0); + return $this->axpy($left, $right, $rows * $columns, 1.0); } /** * {@inheritDoc} */ - public function subtract(array $left, array $right, int $rows, int $columns): array + public function subtract(CData $left, CData $right, int $rows, int $columns): CData { - return $this->axpy($left, $right, $rows, $columns, -1.0); + return $this->axpy($left, $right, $rows * $columns, -1.0); } /** * {@inheritDoc} */ - public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData { return $this->gemm($left, $right, $rows, $inner, $columns); } @@ -211,41 +217,45 @@ public function multiply(array $left, array $right, int $rows, int $inner, int $ /** * {@inheritDoc} */ - public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData { - return $this->scal($matrix, $rows, $columns, (float) $value); + return $this->scal($matrix, $rows * $columns, $value); } /** * {@inheritDoc} */ - public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData { // CLBlast scales just like CBLAS does, so this is a multiplication by the reciprocal — exact for powers of // two, within one unit in the last place otherwise. A zero divisor raises DivisionByZeroError right here, // exactly as the pure-PHP driver would - return $this->scal($matrix, $rows, $columns, 1.0 / $value); + return $this->scal($matrix, $rows * $columns, 1.0 / $value); } /** * Multiplies two matrices on the device * - * @param non-empty-list> $left Left operand cells, shaped rows × inner - * @param non-empty-list> $right Right operand cells, shaped inner × columns - * @param positive-int $rows Number of rows of the left operand - * @param positive-int $inner Shared dimension - * @param positive-int $columns Number of columns of the right operand + * The operands are uploaded straight from the buffers the matrices are stored in, and the result is read back + * into the buffer that becomes the storage of the new matrix: the host side of this transfer costs nothing + * beyond the transfer itself. + * + * @param CData $left Left operand cells, row-major `double[rows * inner]` + * @param CData $right Right operand cells, row-major `double[inner * columns]` + * @param positive-int $rows Number of rows of the left operand + * @param positive-int $inner Shared dimension + * @param positive-int $columns Number of columns of the right operand * - * @return non-empty-list> Product cells + * @return CData Freshly allocated buffer holding the product */ - private function gemm(array $left, array $right, int $rows, int $inner, int $columns): array + private function gemm(CData $left, CData $right, int $rows, int $inner, int $columns): CData { $library = $this->library(); $queue = $this->queue(); - $hostA = $this->packRowMajor($left, $rows, $inner); - $hostB = $this->packRowMajor($right, $inner, $columns); - $hostC = $this->allocate($rows * $columns); + $hostA = $left; + $hostB = $right; + $hostC = Float64Buffer::allocate($rows * $columns); $buffers = []; try { @@ -284,7 +294,7 @@ private function gemm(array $left, array $right, int $rows, int $inner, int $col $this->check($library->clFinish($this->commandQueue()), 'clFinish'); $this->read($bufferC, $hostC, $rows * $columns); - return $this->unpackRows($hostC, $rows, $columns); + return $hostC; } finally { $this->release($buffers); } @@ -293,22 +303,22 @@ private function gemm(array $left, array $right, int $rows, int $inner, int $col /** * Computes `left ± right` on the device over the flattened cells * - * @param non-empty-list> $left Left operand cells - * @param non-empty-list> $right Right operand cells - * @param positive-int $rows Number of rows in both operands - * @param positive-int $columns Number of columns in both operands - * @param float $alpha Scale applied to the right operand: 1.0 or -1.0 + * @param CData $left Left operand cells + * @param CData $right Right operand cells + * @param positive-int $count Number of cells in both operands + * @param float $alpha Scale applied to the right operand: 1.0 or -1.0 * - * @return non-empty-list> Result cells + * @return CData Freshly allocated buffer holding the result */ - private function axpy(array $left, array $right, int $rows, int $columns, float $alpha): array + private function axpy(CData $left, CData $right, int $count, float $alpha): CData { $library = $this->library(); $queue = $this->queue(); - $count = $rows * $columns; - $hostX = $this->packRowMajor($right, $rows, $columns); - $hostY = $this->packRowMajor($left, $rows, $columns); + // The result is read back into the host buffer that seeded Y, so Y must be a buffer this driver owns + // rather than the left operand the caller still holds + $hostX = $right; + $hostY = Float64Buffer::copyOf($left, $count); $buffers = []; try { @@ -335,7 +345,7 @@ private function axpy(array $left, array $right, int $rows, int $columns, float $this->check($library->clFinish($this->commandQueue()), 'clFinish'); $this->read($bufferY, $hostY, $count); - return $this->unpackRows($hostY, $rows, $columns); + return $hostY; } finally { $this->release($buffers); } @@ -344,20 +354,19 @@ private function axpy(array $left, array $right, int $rows, int $columns, float /** * Scales every cell of a matrix on the device * - * @param non-empty-list> $matrix Operand cells - * @param positive-int $rows Number of rows - * @param positive-int $columns Number of columns - * @param float $alpha Scale factor + * @param CData $matrix Operand cells + * @param positive-int $count Number of cells + * @param float $alpha Scale factor * - * @return non-empty-list> Result cells + * @return CData Freshly allocated buffer holding the scaled cells */ - private function scal(array $matrix, int $rows, int $columns, float $alpha): array + private function scal(CData $matrix, int $count, float $alpha): CData { $library = $this->library(); $queue = $this->queue(); - $count = $rows * $columns; - $host = $this->packRowMajor($matrix, $rows, $columns); + // Scaling reads back into the host buffer it uploaded, so it works on a copy of the operand + $host = Float64Buffer::copyOf($matrix, $count); $buffers = []; try { @@ -368,7 +377,7 @@ private function scal(array $matrix, int $rows, int $columns, float $alpha): arr $this->check($library->clFinish($this->commandQueue()), 'clFinish'); $this->read($buffer, $host, $count); - return $this->unpackRows($host, $rows, $columns); + return $host; } finally { $this->release($buffers); } @@ -463,7 +472,7 @@ private function release(array $buffers): void */ private function bytes(int $count): int { - return $count * FFI::sizeof($this->library()->new('double')); + return Float64Buffer::bytes($count); } /** diff --git a/src/Backend/Driver.php b/src/Backend/Driver.php new file mode 100644 index 0000000..fc7b20f --- /dev/null +++ b/src/Backend/Driver.php @@ -0,0 +1,70 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +/** + * The drivers this package ships, plus the sentinel that stands for automatic routing + * + * These four names used to be loose string constants on {@see Backends}, which made every selection a string + * comparison that no analyser could check. As an enum they are a closed type: `Backends::use(Driver::Blas)` cannot + * be misspelled, and a `match` over a driver is exhaustive. + * + * The registry still accepts plain strings, because {@see Backends::register()} exists precisely so that drivers + * this package knows nothing about can be plugged in, and those names cannot be cases of this enum. The rule is + * therefore: built-in drivers are addressed by a case, out-of-tree drivers by their registered string. + */ +enum Driver: string +{ + /** + * Routing that picks the driver per environment rather than per call site + */ + case Auto = 'auto'; + + /** + * The always-available driver that computes in interpreted PHP + */ + case Php = 'php'; + + /** + * The OpenBLAS CPU driver + */ + case Blas = 'blas'; + + /** + * The CLBlast GPU driver, never selected automatically + */ + case Clblast = 'clblast'; + + /** + * Returns the registry name of a selection given as either a case or a third-party string + * + * @param self|string $driver Built-in driver, or the name a third-party driver was registered under + */ + public static function nameOf(self|string $driver): string + { + return $driver instanceof self ? $driver->value : $driver; + } + + /** + * Returns the case matching a registry name, or the name itself when no case does + * + * Used wherever a name arrives as text — the environment variable, a registered driver — so that built-in + * selections are reported as the enum while third-party ones keep their string. + * + * @param string $name Registry name of a driver + */ + public static function resolveName(string $name): self|string + { + return self::tryFrom($name) ?? $name; + } +} diff --git a/src/Backend/FallbackBackend.php b/src/Backend/FallbackBackend.php index 6ec5b0a..61f5065 100644 --- a/src/Backend/FallbackBackend.php +++ b/src/Backend/FallbackBackend.php @@ -12,6 +12,7 @@ namespace Lisachenko\NativePhpMatrix\Backend; +use FFI\CData; use Throwable; /** @@ -23,9 +24,11 @@ * the failure here and recomputing with the fallback keeps the operator working: catching *inside* the hook is * perfectly legal, only exceptions crossing the callback boundary are fatal. * + * Recomputing is safe because a driver may not write to its operands: whatever the primary managed to do before + * it failed, it did to a buffer of its own, so the fallback is handed the same untouched cells. + * * This decorator is deliberately not used for an explicit selection. Asking for a specific driver and silently - * getting another one's results — integers instead of floats, at a different speed — would hide exactly what the - * caller wanted to control. + * getting another one's results, at a different speed, would hide exactly what the caller wanted to control. */ final class FallbackBackend implements BackendInterface { @@ -45,7 +48,7 @@ public function isAvailable(): bool /** * {@inheritDoc} */ - public function sum(array $left, array $right, int $rows, int $columns): array + public function sum(CData $left, CData $right, int $rows, int $columns): CData { try { return $this->primary->sum($left, $right, $rows, $columns); @@ -57,7 +60,7 @@ public function sum(array $left, array $right, int $rows, int $columns): array /** * {@inheritDoc} */ - public function subtract(array $left, array $right, int $rows, int $columns): array + public function subtract(CData $left, CData $right, int $rows, int $columns): CData { try { return $this->primary->subtract($left, $right, $rows, $columns); @@ -69,7 +72,7 @@ public function subtract(array $left, array $right, int $rows, int $columns): ar /** * {@inheritDoc} */ - public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData { try { return $this->primary->multiply($left, $right, $rows, $inner, $columns); @@ -81,7 +84,7 @@ public function multiply(array $left, array $right, int $rows, int $inner, int $ /** * {@inheritDoc} */ - public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData { try { return $this->primary->multiplyByScalar($matrix, $value, $rows, $columns); @@ -93,7 +96,7 @@ public function multiplyByScalar(array $matrix, int|float $value, int $rows, int /** * {@inheritDoc} */ - public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData { try { return $this->primary->divideByScalar($matrix, $value, $rows, $columns); @@ -105,7 +108,7 @@ public function divideByScalar(array $matrix, int|float $value, int $rows, int $ /** * {@inheritDoc} */ - public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData { try { return $this->primary->powByScalar($matrix, $value, $rows, $columns); diff --git a/src/Backend/Float64Buffer.php b/src/Backend/Float64Buffer.php new file mode 100644 index 0000000..d70c07d --- /dev/null +++ b/src/Backend/Float64Buffer.php @@ -0,0 +1,132 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use FFI; +use FFI\CData; + +/** + * The storage every matrix and every driver in this package speaks: a flat, row-major block of float64 cells + * + * A `double[rows * columns]` allocation is what a BLAS kernel, an OpenCL buffer and an interpreted loop can all + * read without a conversion in between. Keeping matrices in that shape from the moment they are constructed is + * what removes the pack/unpack step that used to be charged to every single operation. + * + * The buffers are allocated through the plain `FFI::new()`, not through a driver's own binding, on purpose: a + * `double` is a `double` in every FFI type context, so a buffer allocated here is accepted by the CBLAS entry + * points of one driver and by the OpenCL transfer calls of another, and the pure-PHP driver — which loads no + * library at all — can allocate its results the same way. The allocation is owned by PHP, so it is released with + * the handle and no matrix ever frees host memory by hand. + */ +final class Float64Buffer +{ + /** + * Size of a single cell in bytes, resolved once from the running platform rather than assumed + */ + private static ?int $cellSize = null; + + /** + * Allocates an owned, zero-filled buffer of float64 cells + * + * An allocation that does not succeed cannot produce a usable matrix, so nothing is caught here: FFI raises, + * or the declared return type rejects whatever came back instead. Under automatic routing that failure is + * still contained, because {@see FallbackBackend} recomputes the operation on the pure-PHP driver. + * + * @param positive-int $count Number of cells to reserve + * + * @return CData Owned `double[count]` buffer + */ + public static function allocate(int $count): CData + { + return FFI::new('double[' . $count . ']'); + } + + /** + * Allocates a buffer holding a copy of the given one + * + * Kernels such as `daxpy` and `dscal` accumulate into an operand instead of writing to a separate output, so a + * driver that must not touch the operand it was handed copies it here first. The copy is a single `memcpy`, + * which is what makes it acceptable in the hot path. + * + * @param CData $source Buffer holding at least count cells + * @param positive-int $count Number of cells to copy + * + * @return CData Owned `double[count]` buffer with the same contents + */ + public static function copyOf(CData $source, int $count): CData + { + $copy = self::allocate($count); + FFI::memcpy($copy, $source, self::bytes($count)); + + return $copy; + } + + /** + * Tells whether two buffers hold bit-identical cells + * + * The comparison is a `memcmp` over the raw cells, which is stricter than `==` on the values it stands for: + * `-0.0` does not match `0.0` although PHP considers them equal, and a `NAN` cell matches another `NAN` with + * the same bit pattern although PHP considers no `NAN` equal to anything. Both follow from comparing storage + * rather than numbers, and both are documented on {@see \Lisachenko\NativePhpMatrix\Matrix::equals()}. + * + * @param CData $left Left buffer + * @param CData $right Right buffer, holding at least as many cells + * @param positive-int $count Number of cells to compare + */ + public static function identical(CData $left, CData $right, int $count): bool + { + return FFI::memcmp($left, $right, self::bytes($count)) === 0; + } + + /** + * Reads a single cell out of a buffer + * + * The hot loops of the drivers index their buffers directly, because a call per cell would dominate them. + * This accessor exists for the places where clarity wins over the last nanosecond — the availability probes, + * which touch exactly one cell — and it keeps the raw indexing they would otherwise need out of those files. + * + * @param CData $buffer Buffer holding the cell + * @param int $offset Zero-based cell index + */ + public static function read(CData $buffer, int $offset): float + { + return $buffer[$offset]; + } + + /** + * Writes a single cell into a buffer + * + * @param CData $buffer Buffer to write into + * @param int $offset Zero-based cell index + * @param float $value Value to store + */ + public static function write(CData $buffer, int $offset, float $value): void + { + $buffer[$offset] = $value; + } + + /** + * Returns the number of bytes occupied by the given number of cells + * + * @param positive-int $count Number of cells + * + * @return positive-int Size in bytes + */ + public static function bytes(int $count): int + { + self::$cellSize ??= FFI::sizeof(FFI::new('double')); + + /** @var positive-int */ + return $count * self::$cellSize; + } +} diff --git a/src/Backend/PhpBackend.php b/src/Backend/PhpBackend.php index 044dcd3..967ce66 100644 --- a/src/Backend/PhpBackend.php +++ b/src/Backend/PhpBackend.php @@ -12,13 +12,21 @@ namespace Lisachenko\NativePhpMatrix\Backend; +use FFI\CData; + /** - * Reference driver: the original interpreted PHP arithmetic + * Reference driver: the arithmetic carried out by the interpreter itself + * + * The loop bodies are the ones this library shipped before the drivers existed; what changed is what they walk + * over. Cells now live in a flat `double[]` buffer rather than in nested arrays, so the loops index offsets + * instead of dereferencing rows, and the multiplication keeps its original accumulation order — summing the + * products of a row in a different order can change the last bits of a float result, and the parity tests hold + * this driver and the accelerated ones to the same answer. * - * The loop bodies are the ones this library shipped before the drivers existed, kept verbatim down to the - * iteration order. That matters for more than nostalgia: PHP arithmetic preserves integers, and summing the - * products of a row in a different order can change the last bits of a float result. This driver is always - * available, it is the fallback of every other one, and it is the only driver that returns integers. + * Reading and writing a cell through `FFI\CData` is slower than touching a PHP array element, which makes this + * driver slower than it used to be on paper. That is a deliberate trade: it is the fallback that must work with + * no library installed at all, while every operation that matters for speed is now handed to a kernel without a + * conversion in between. */ final class PhpBackend implements BackendInterface { @@ -33,16 +41,12 @@ public function isAvailable(): bool /** * {@inheritDoc} */ - public function sum(array $left, array $right, int $rows, int $columns): array + public function sum(CData $left, CData $right, int $rows, int $columns): CData { - $result = []; - foreach ($left as $rowIndex => $row) { - $anotherRow = $right[$rowIndex]; - $resultRow = []; - foreach ($row as $columnIndex => $cellValue) { - $resultRow[] = $cellValue + $anotherRow[$columnIndex]; - } - $result[] = $resultRow; + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $left[$cell] + $right[$cell]; } return $result; @@ -51,16 +55,12 @@ public function sum(array $left, array $right, int $rows, int $columns): array /** * {@inheritDoc} */ - public function subtract(array $left, array $right, int $rows, int $columns): array + public function subtract(CData $left, CData $right, int $rows, int $columns): CData { - $result = []; - foreach ($left as $rowIndex => $row) { - $anotherRow = $right[$rowIndex]; - $resultRow = []; - foreach ($row as $columnIndex => $cellValue) { - $resultRow[] = $cellValue - $anotherRow[$columnIndex]; - } - $result[] = $resultRow; + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $left[$cell] - $right[$cell]; } return $result; @@ -69,26 +69,33 @@ public function subtract(array $left, array $right, int $rows, int $columns): ar /** * {@inheritDoc} */ - public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData { - // Columns of the multiplier are extracted only once, they are reused for every row of the left operand - $multiplierColumns = []; - foreach (array_keys($right[0]) as $column) { - $multiplierColumns[] = array_column($right, $column); + // The multiplier is consumed column by column, and a column of a row-major buffer is strided: consecutive + // cells sit $columns doubles apart, so at any real size every read is a cache miss. Transposing it once, + // for O(inner * columns), is the buffer-level form of the array_column() this loop used to do — after it + // both operands are walked contiguously in the inner loop + $transposed = Float64Buffer::allocate($inner * $columns); + for ($row = 0; $row < $inner; $row++) { + $rowOffset = $row * $columns; + for ($column = 0; $column < $columns; $column++) { + $transposed[$column * $inner + $row] = $right[$rowOffset + $column]; + } } - $result = []; - foreach ($left as $rowItems) { - $resultRow = []; - foreach ($multiplierColumns as $columnItems) { - $cellValue = 0; - foreach ($rowItems as $key => $value) { - $cellValue += $value * $columnItems[$key]; + $result = Float64Buffer::allocate($rows * $columns); + for ($row = 0; $row < $rows; $row++) { + $leftOffset = $row * $inner; + $resultOffset = $row * $columns; + for ($column = 0; $column < $columns; $column++) { + $columnOffset = $column * $inner; + // Summation order is unchanged, which is what keeps this driver bit-identical to the kernels + $cellValue = 0.0; + for ($step = 0; $step < $inner; $step++) { + $cellValue += $left[$leftOffset + $step] * $transposed[$columnOffset + $step]; } - - $resultRow[] = $cellValue; + $result[$resultOffset + $column] = $cellValue; } - $result[] = $resultRow; } return $result; @@ -97,15 +104,12 @@ public function multiply(array $left, array $right, int $rows, int $inner, int $ /** * {@inheritDoc} */ - public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData { - $result = []; - foreach ($matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue * $value; - } - $result[] = $resultRow; + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $matrix[$cell] * $value; } return $result; @@ -114,15 +118,12 @@ public function multiplyByScalar(array $matrix, int|float $value, int $rows, int /** * {@inheritDoc} */ - public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData { - $result = []; - foreach ($matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue / $value; - } - $result[] = $resultRow; + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $matrix[$cell] / $value; } return $result; @@ -131,15 +132,12 @@ public function divideByScalar(array $matrix, int|float $value, int $rows, int $ /** * {@inheritDoc} */ - public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData { - $result = []; - foreach ($matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue ** $value; - } - $result[] = $resultRow; + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $matrix[$cell] ** $value; } return $result; diff --git a/src/Matrix.php b/src/Matrix.php index fe2a61a..b26e540 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -12,9 +12,12 @@ namespace Lisachenko\NativePhpMatrix; +use FFI\CData; use InvalidArgumentException; use Lisachenko\NativePhpMatrix\Backend\Backends; +use Lisachenko\NativePhpMatrix\Backend\Float64Buffer; use LogicException; +use ReflectionClass; use ZEngine\ClassExtension\Hook\CastObjectHook; use ZEngine\ClassExtension\Hook\CastType; use ZEngine\ClassExtension\Hook\CompareValuesHook; @@ -32,11 +35,15 @@ /** * Simple class Matrix powered by custom operator handlers * - * Note about the generic parameter: arithmetic honestly widens the cell type. Even for a `Matrix` a division - * or an exponentiation may produce floats, therefore every operation returns a `Matrix` instead of - * pretending that the original `T` is preserved. + * Cells are float64, always. A matrix does not hold a PHP array of rows: it owns a single, contiguous, row-major + * `double[rows * columns]` allocation, which is the shape a BLAS kernel, an OpenCL buffer and an interpreted loop + * can all read without a conversion in between. Integers are accepted as input and stored as the doubles they + * convert to, the way `numpy.array([[1, 2]])` yields `dtype=float64` — so `new Matrix([[1, 2]])->toArray()` + * gives `[[1.0, 2.0]]`, and every operation returns floats whichever driver computed it. * - * @template-covariant T of int|float + * That storage is what lets the operators hand their operands to a driver as raw pointers. Nothing is packed on + * the way in and nothing is unpacked on the way out: the buffer a kernel writes becomes the storage of the matrix + * the operator returns. */ final class Matrix implements ObjectCastInterface, @@ -48,39 +55,53 @@ final class Matrix implements use ObjectCreateTrait; /** - * Matrix cells, stored as a list of rows + * Reflection used to build a matrix around a buffer a driver has just written * - * @var non-empty-list> + * Resolved once for the process. Bypassing the constructor is not an optimisation trick here but the point: + * the cells of a computed result are already valid float64 in the right layout, and re-validating them would + * mean reading every one of them back into PHP. + * + * @var ReflectionClass|null */ - private readonly array $matrix; + private static ?ReflectionClass $reflection = null; /** - * Total number of rows in this matrix + * Cells of this matrix, row after row, as float64 * - * @var positive-int + * The allocation is owned by PHP and released together with this object, which is why no matrix ever frees + * host memory by hand. + * + * The three properties below are written exactly once — by the constructor, or by {@see self::fromBuffer()} + * for a result a driver has just computed — and never again; nothing in this class mutates a matrix in place. + * They are not declared `readonly` only because that second initialisation path assigns them from a static + * method rather than from the constructor, which the attribute forbids. */ - private readonly int $rows; + private CData $buffer; /** - * Total number of columns in this matrix + * Total number of rows in this matrix * * @var positive-int */ - private readonly int $columns; + private int $rows; /** - * Whether at least one cell of this matrix is a float + * Total number of columns in this matrix * - * Collected while the cells are validated anyway, because the automatic backend routing needs the answer for - * every operation: accelerated drivers compute in double precision, so an all-integer operation has to stay - * on the pure-PHP driver to keep returning integers. + * @var positive-int */ - private readonly bool $containsFloats; + private int $columns; /** * Matrix constructor * - * @param non-empty-list> $matrix Rectangular list of rows, each one holding numeric cells + * Validation and conversion are the same pass: each cell is checked and then written straight into the + * freshly allocated buffer, where FFI performs the int-to-double conversion natively. There is no + * intermediate array and no second loop over the cells. + * + * @param non-empty-list> $matrix Rectangular list of rows, each one holding numeric cells + * + * @throws InvalidArgumentException When the argument is not a non-empty, rectangular list of numeric rows */ public function __construct(array $matrix) { @@ -91,36 +112,30 @@ public function __construct(array $matrix) throw new InvalidArgumentException('Matrix should be a list of rows with sequential keys, starting from 0'); } - $columns = null; - $containsFloats = false; + $rows = count($matrix); + $columns = count(self::validateRow($matrix[0], 0)); + $buffer = Float64Buffer::allocate($rows * $columns); + $offset = 0; + foreach ($matrix as $rowIndex => $row) { - if (!is_array($row) || !array_is_list($row)) { - throw new InvalidArgumentException( - sprintf('Matrix row %d should be a list of values with sequential keys, starting from 0', $rowIndex), - ); - } - if ($row === []) { - throw new InvalidArgumentException('Matrix should contain at least one column'); - } - if ($columns === null) { - $columns = count($row); - } elseif (count($row) !== $columns) { + $cells = self::validateRow($row, $rowIndex); + if (count($cells) !== $columns) { throw new InvalidArgumentException('All matrix rows should have the same number of columns'); } - foreach ($row as $columnIndex => $value) { - if (!is_int($value) && !is_float($value)) { + foreach ($cells as $columnIndex => $cellValue) { + if (!is_int($cellValue) && !is_float($cellValue)) { throw new InvalidArgumentException( sprintf('Matrix value at [%d][%d] should be either an int or a float', $rowIndex, $columnIndex), ); } - $containsFloats = $containsFloats || is_float($value); + // Assigning into a double cell is where an int becomes a float, natively + $buffer[$offset++] = $cellValue; } } - $this->matrix = $matrix; - $this->rows = count($matrix); - $this->columns = count($matrix[0]); - $this->containsFloats = $containsFloats; + $this->buffer = $buffer; + $this->rows = $rows; + $this->columns = $columns; } public function getRows(): int @@ -139,43 +154,37 @@ public function isSquare(): bool } /** - * Returns an underlying representation of this matrix + * Returns the cells of this matrix as a list of rows * - * @return non-empty-list> - */ - public function toArray(): array - { - return $this->matrix; - } - - /** - * Returns a copy of this matrix with every cell converted to a float - * - * The accelerated backends compute in double precision only, so this conversion makes explicit — in ordinary, - * catchable userland code — what those drivers do to an integer matrix internally. + * The rows are materialised from the buffer on demand — this is the boundary between the native storage and + * ordinary PHP, so a caller that only wants to compute never pays for it. * - * @return self Matrix with the same dimensions, holding floats + * @return non-empty-list> Cells, row after row */ - public function asFloat(): self + public function toArray(): array { $result = []; - foreach ($this->matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = (float) $cellValue; + $offset = 0; + for ($row = 0; $row < $this->rows; $row++) { + $cells = []; + for ($column = 0; $column < $this->columns; $column++) { + $cells[] = $this->buffer[$offset++]; } - $result[] = $resultRow; + $result[] = $cells; } - return new self($result); + /** @var non-empty-list> */ + return $result; } /** * Performs multiplication of two matrices * - * @param self $multiplier Right operand + * @param self $multiplier Right operand * - * @return self Product of two matrices + * @return self Product of two matrices + * + * @throws InvalidArgumentException When the inner dimensions do not match */ public function multiply(self $multiplier): self { @@ -183,65 +192,69 @@ public function multiply(self $multiplier): self throw new InvalidArgumentException('Inconsistent matrix supplied'); } - $backend = Backends::resolveFor($this->containsFloats || $multiplier->containsFloats); - - return new self($backend->multiply( - $this->matrix, - $multiplier->matrix, + return self::fromBuffer( + Backends::resolve()->multiply( + $this->buffer, + $multiplier->buffer, + $this->rows, + $this->columns, + $multiplier->columns, + ), $this->rows, - $this->columns, $multiplier->columns, - )); + ); } /** * Performs division by scalar value * * @param int|float $value Divider - * - * @return self */ public function divideByScalar(int|float $value): self { - $backend = Backends::resolveFor($this->containsFloats || is_float($value)); - - return new self($backend->divideByScalar($this->matrix, $value, $this->rows, $this->columns)); + return self::fromBuffer( + Backends::resolve()->divideByScalar($this->buffer, (float) $value, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Performs multiplication by scalar value * * @param int|float $value Multiplier - * - * @return self */ public function multiplyByScalar(int|float $value): self { - $backend = Backends::resolveFor($this->containsFloats || is_float($value)); - - return new self($backend->multiplyByScalar($this->matrix, $value, $this->rows, $this->columns)); + return self::fromBuffer( + Backends::resolve()->multiplyByScalar($this->buffer, (float) $value, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Performs exponential expression by scalar value * * @param int|float $value Exponent - * - * @return self */ public function powByScalar(int|float $value): self { - $backend = Backends::resolveFor($this->containsFloats || is_float($value)); - - return new self($backend->powByScalar($this->matrix, $value, $this->rows, $this->columns)); + return self::fromBuffer( + Backends::resolve()->powByScalar($this->buffer, (float) $value, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Performs addition of two matrices * - * @param self $value Right operand + * @param self $value Right operand + * + * @return self Sum of two matrices * - * @return self Sum of two matrices + * @throws InvalidArgumentException When the dimensions do not match */ public function sum(self $value): self { @@ -249,17 +262,21 @@ public function sum(self $value): self throw new InvalidArgumentException('Inconsistent matrix supplied'); } - $backend = Backends::resolveFor($this->containsFloats || $value->containsFloats); - - return new self($backend->sum($this->matrix, $value->matrix, $this->rows, $this->columns)); + return self::fromBuffer( + Backends::resolve()->sum($this->buffer, $value->buffer, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Performs subtraction of two matrices * - * @param self $value Right operand + * @param self $value Right operand * - * @return self Difference of two matrices + * @return self Difference of two matrices + * + * @throws InvalidArgumentException When the dimensions do not match */ public function subtract(self $value): self { @@ -267,31 +284,30 @@ public function subtract(self $value): self throw new InvalidArgumentException('Inconsistent matrix supplied'); } - $backend = Backends::resolveFor($this->containsFloats || $value->containsFloats); - - return new self($backend->subtract($this->matrix, $value->matrix, $this->rows, $this->columns)); + return self::fromBuffer( + Backends::resolve()->subtract($this->buffer, $value->buffer, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Checks if the given matrix equals to another one * - * @param self $another Another matrix + * The cells are compared as storage, with a single `memcmp` over both buffers rather than a loop. That is + * bit-exact, and deliberately stricter than `==` on the numbers the bits stand for in two corner cases: + * a `-0.0` cell does not match a `0.0` one, and two `NAN` cells with the same bit pattern do match. Both + * follow from comparing memory, and neither can arise from the integral values these matrices usually hold. + * + * @param self $another Another matrix */ public function equals(self $another): bool { if ($another->rows !== $this->rows || $another->columns !== $this->columns) { return false; } - foreach ($this->matrix as $rowIndex => $row) { - $anotherRow = $another->matrix[$rowIndex]; - foreach ($row as $columnIndex => $cellValue) { - if ($cellValue !== $anotherRow[$columnIndex]) { - return false; - } - } - } - return true; + return Float64Buffer::identical($this->buffer, $another->buffer, $this->rows * $this->columns); } /** @@ -299,7 +315,7 @@ public function equals(self $another): bool * * @param DoOperationHook $hook Instance of current hook * - * @return self Result of operation + * @return self Result of operation */ public static function __doOperation(DoOperationHook $hook): self { @@ -453,13 +469,62 @@ public static function __getFields(GetPropertiesForHook $hook): array return get_mangled_object_vars($object); } + /** + * Builds a matrix around a buffer that already holds valid cells + * + * Every operation ends here: a driver returns the buffer it computed into, and that buffer becomes the + * storage of the result without a single cell crossing back into PHP. The constructor is bypassed because + * its job — validating input and converting it to float64 — is already done by construction. + * + * @param CData $buffer Row-major `double[rows * columns]` the caller hands over ownership of + * @param positive-int $rows Number of rows the buffer holds + * @param positive-int $columns Number of columns the buffer holds + */ + private static function fromBuffer(CData $buffer, int $rows, int $columns): self + { + self::$reflection ??= new ReflectionClass(self::class); + + $matrix = self::$reflection->newInstanceWithoutConstructor(); + $matrix->buffer = $buffer; + $matrix->rows = $rows; + $matrix->columns = $columns; + + return $matrix; + } + + /** + * Checks that one row of the constructor argument is a non-empty list, and returns it + * + * @param mixed $row Candidate row, straight out of the argument + * @param int $rowIndex Position of the row, for the message + * + * @return non-empty-list The row itself, once it is known to be one + * + * @throws InvalidArgumentException When the row is not a non-empty list + */ + private static function validateRow(mixed $row, int $rowIndex): array + { + if (!is_array($row) || !array_is_list($row)) { + throw new InvalidArgumentException( + sprintf('Matrix row %d should be a list of values with sequential keys, starting from 0', $rowIndex), + ); + } + if ($row === []) { + throw new InvalidArgumentException('Matrix should contain at least one column'); + } + + return $row; + } + /** * Returns a human-readable representation of this matrix, one row per line */ private function toString(): string { $rows = []; - foreach ($this->matrix as $row) { + foreach ($this->toArray() as $row) { + // Integral floats stringify without a fractional part, so a matrix of whole numbers still reads + // as "[1, 2, 3]" rather than "[1.0, 2.0, 3.0]" $rows[] = '[' . implode(', ', $row) . ']'; } diff --git a/tests/Functional/include/MarkerBackend.inc b/tests/Functional/include/MarkerBackend.inc index 62c628a..7e00a9d 100644 --- a/tests/Functional/include/MarkerBackend.inc +++ b/tests/Functional/include/MarkerBackend.inc @@ -10,7 +10,9 @@ */ declare(strict_types=1); +use FFI\CData; use Lisachenko\NativePhpMatrix\Backend\BackendInterface; +use Lisachenko\NativePhpMatrix\Backend\Float64Buffer; use Lisachenko\NativePhpMatrix\Backend\PhpBackend; /** @@ -19,6 +21,9 @@ use Lisachenko\NativePhpMatrix\Backend\PhpBackend; * A real out-of-tree driver would call into its own library here. This one adds an unmistakable offset to every * sum instead, so that seeing the marker in the result of `$a + $b` proves the operator hook went all the way * through the registry into a driver that this package does not ship. Every other operation is delegated. + * + * It also stands for the buffer contract: the operands are read, never written, and the result is a buffer this + * driver allocated itself. */ final class MarkerBackend implements BackendInterface { @@ -36,42 +41,38 @@ final class MarkerBackend implements BackendInterface return true; } - public function sum(array $left, array $right, int $rows, int $columns): array + public function sum(CData $left, CData $right, int $rows, int $columns): CData { - $result = []; - foreach ($left as $rowIndex => $row) { - $anotherRow = $right[$rowIndex]; - $resultRow = []; - foreach ($row as $columnIndex => $cellValue) { - $resultRow[] = $cellValue + $anotherRow[$columnIndex] + self::MARKER; - } - $result[] = $resultRow; + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $left[$cell] + $right[$cell] + self::MARKER; } return $result; } - public function subtract(array $left, array $right, int $rows, int $columns): array + public function subtract(CData $left, CData $right, int $rows, int $columns): CData { return $this->delegate->subtract($left, $right, $rows, $columns); } - public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData { return $this->delegate->multiply($left, $right, $rows, $inner, $columns); } - public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData { return $this->delegate->multiplyByScalar($matrix, $value, $rows, $columns); } - public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData { return $this->delegate->divideByScalar($matrix, $value, $rows, $columns); } - public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData { return $this->delegate->powByScalar($matrix, $value, $rows, $columns); } diff --git a/tests/Functional/include/UnavailableBackend.inc b/tests/Functional/include/UnavailableBackend.inc index b3e9d8f..f408016 100644 --- a/tests/Functional/include/UnavailableBackend.inc +++ b/tests/Functional/include/UnavailableBackend.inc @@ -10,14 +10,17 @@ */ declare(strict_types=1); +use FFI\CData; use Lisachenko\NativePhpMatrix\Backend\BackendInterface; +use Lisachenko\NativePhpMatrix\Backend\Float64Buffer; /** * Driver that reports itself unusable, the way a driver whose library is missing does * * Its operations are never reachable: an unavailable driver cannot be selected, and automatic routing skips it. * Failing this deterministically, without depending on which acceleration libraries the machine happens to have, - * is exactly why this stub exists. + * is exactly why this stub exists. The bodies still honour the contract — a fresh buffer, never an operand — so + * that the stub cannot become a counter-example to it. */ final class UnavailableBackend implements BackendInterface { @@ -26,33 +29,33 @@ final class UnavailableBackend implements BackendInterface return false; } - public function sum(array $left, array $right, int $rows, int $columns): array + public function sum(CData $left, CData $right, int $rows, int $columns): CData { - return $left; + return Float64Buffer::copyOf($left, $rows * $columns); } - public function subtract(array $left, array $right, int $rows, int $columns): array + public function subtract(CData $left, CData $right, int $rows, int $columns): CData { - return $left; + return Float64Buffer::copyOf($left, $rows * $columns); } - public function multiply(array $left, array $right, int $rows, int $inner, int $columns): array + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData { - return $left; + return Float64Buffer::allocate($rows * $columns); } - public function multiplyByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData { - return $matrix; + return Float64Buffer::copyOf($matrix, $rows * $columns); } - public function divideByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData { - return $matrix; + return Float64Buffer::copyOf($matrix, $rows * $columns); } - public function powByScalar(array $matrix, int|float $value, int $rows, int $columns): array + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData { - return $matrix; + return Float64Buffer::copyOf($matrix, $rows * $columns); } } diff --git a/tests/Functional/include/skipif_blas.inc b/tests/Functional/include/skipif_blas.inc index 1c9e0f2..a7e6a76 100644 --- a/tests/Functional/include/skipif_blas.inc +++ b/tests/Functional/include/skipif_blas.inc @@ -11,13 +11,14 @@ declare(strict_types=1); use Lisachenko\NativePhpMatrix\Backend\Backends; +use Lisachenko\NativePhpMatrix\Backend\Driver; // The probe asks the registry rather than looking for a file: Backends::available() loads the library and runs a // real operation with it, so a skip here can never disagree with what the test itself would do a moment later. // CI installs every acceleration library precisely so that this never fires — a skip there means a broken image try { include __DIR__ . '/../../../vendor/autoload.php'; - $isAvailable = in_array(Backends::BLAS, Backends::available(), true); + $isAvailable = in_array(Driver::Blas->value, Backends::available(), true); } catch (Throwable) { $isAvailable = false; } diff --git a/tests/Functional/include/skipif_clblast.inc b/tests/Functional/include/skipif_clblast.inc index e109814..65f34c5 100644 --- a/tests/Functional/include/skipif_clblast.inc +++ b/tests/Functional/include/skipif_clblast.inc @@ -11,13 +11,14 @@ declare(strict_types=1); use Lisachenko\NativePhpMatrix\Backend\Backends; +use Lisachenko\NativePhpMatrix\Backend\Driver; // Probing through the registry initialises OpenCL for the very device type NATIVE_PHP_MATRIX_CL_DEVICE selects and // runs a real kernel on it, so this skip and the test itself can never disagree. CI installs an OpenCL runtime and // pins the device type, so a skip there means a broken image rather than a missing GPU try { include __DIR__ . '/../../../vendor/autoload.php'; - $isAvailable = in_array(Backends::CLBLAST, Backends::available(), true); + $isAvailable = in_array(Driver::Clblast->value, Backends::available(), true); } catch (Throwable) { $isAvailable = false; } diff --git a/tests/Functional/testAutoKeepsIntMatricesOnPhpBackend.phpt b/tests/Functional/testAutoKeepsIntMatricesOnPhpBackend.phpt deleted file mode 100644 index 2b4a509..0000000 --- a/tests/Functional/testAutoKeepsIntMatricesOnPhpBackend.phpt +++ /dev/null @@ -1,46 +0,0 @@ ---TEST-- -Automatic routing keeps all-integer arithmetic on the pure PHP backend ---INI-- -ffi.enable=1 -opcache.jit=off -error_reporting=E_ALL & ~E_DEPRECATED ---ENV-- -NATIVE_PHP_MATRIX_BACKEND= ---FILE-- -toArray()); -var_dump(($matrixA * 3)->toArray()); -?> ---EXPECT-- -string(4) "auto" -array(1) { - [0]=> - array(2) { - [0]=> - int(6) - [1]=> - int(8) - } -} -array(1) { - [0]=> - array(2) { - [0]=> - int(6) - [1]=> - int(9) - } -} diff --git a/tests/Functional/testBackendDefaultsToAuto.phpt b/tests/Functional/testBackendDefaultsToAuto.phpt index cc804cc..75374dd 100644 --- a/tests/Functional/testBackendDefaultsToAuto.phpt +++ b/tests/Functional/testBackendDefaultsToAuto.phpt @@ -18,5 +18,5 @@ var_dump(Backends::active()); var_dump(in_array('php', Backends::available(), true)); ?> --EXPECT-- -string(4) "auto" +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Auto) bool(true) diff --git a/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt b/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt index 2acf802..46540c0 100644 --- a/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt +++ b/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt @@ -22,7 +22,7 @@ $matrixB = new Matrix([[0.5, 0.5]]); var_dump(($matrixA + $matrixB)->toArray()); ?> --EXPECT-- -string(3) "php" +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Php) array(1) { [0]=> array(2) { diff --git a/tests/Functional/testCanAddMatrices.phpt b/tests/Functional/testCanAddMatrices.phpt index 5148f64..1f36944 100644 --- a/tests/Functional/testCanAddMatrices.phpt +++ b/tests/Functional/testCanAddMatrices.phpt @@ -22,10 +22,10 @@ array(1) { [0]=> array(3) { [0]=> - int(5) + float(5) [1]=> - int(7) + float(7) [2]=> - int(9) + float(9) } } diff --git a/tests/Functional/testCanCastMatrixToArray.phpt b/tests/Functional/testCanCastMatrixToArray.phpt index 5f7ee05..4899f0b 100644 --- a/tests/Functional/testCanCastMatrixToArray.phpt +++ b/tests/Functional/testCanCastMatrixToArray.phpt @@ -20,19 +20,19 @@ array(2) { [0]=> array(3) { [0]=> - int(1) + float(1) [1]=> - int(2) + float(2) [2]=> - int(3) + float(3) } [1]=> array(3) { [0]=> - int(4) + float(4) [1]=> - int(5) + float(5) [2]=> - int(6) + float(6) } } diff --git a/tests/Functional/testCanCastMatrixToFloatMatrix.phpt b/tests/Functional/testCanCastMatrixToFloatMatrix.phpt deleted file mode 100644 index 28dc48f..0000000 --- a/tests/Functional/testCanCastMatrixToFloatMatrix.phpt +++ /dev/null @@ -1,34 +0,0 @@ ---TEST-- -Matrix can be converted to a float matrix with asFloat() ---INI-- -ffi.enable=1 -opcache.jit=off -error_reporting=E_ALL & ~E_DEPRECATED ---FILE-- -asFloat()->toArray()); -?> ---EXPECT-- -array(2) { - [0]=> - array(2) { - [0]=> - float(1) - [1]=> - float(2) - } - [1]=> - array(2) { - [0]=> - float(3) - [1]=> - float(4.5) - } -} diff --git a/tests/Functional/testCanDivideMatrixByNumber.phpt b/tests/Functional/testCanDivideMatrixByNumber.phpt index ca78738..90a8be2 100644 --- a/tests/Functional/testCanDivideMatrixByNumber.phpt +++ b/tests/Functional/testCanDivideMatrixByNumber.phpt @@ -21,10 +21,10 @@ array(1) { [0]=> array(3) { [0]=> - int(1) + float(1) [1]=> - int(2) + float(2) [2]=> - int(3) + float(3) } } diff --git a/tests/Functional/testCanMultiplyCompatibleMatrices.phpt b/tests/Functional/testCanMultiplyCompatibleMatrices.phpt index 5f1b6a6..12a7e21 100644 --- a/tests/Functional/testCanMultiplyCompatibleMatrices.phpt +++ b/tests/Functional/testCanMultiplyCompatibleMatrices.phpt @@ -22,6 +22,6 @@ array(1) { [0]=> array(1) { [0]=> - int(32) + float(32) } } diff --git a/tests/Functional/testCanMultiplyMatrixByNumber.phpt b/tests/Functional/testCanMultiplyMatrixByNumber.phpt index 54e97da..c431c6a 100644 --- a/tests/Functional/testCanMultiplyMatrixByNumber.phpt +++ b/tests/Functional/testCanMultiplyMatrixByNumber.phpt @@ -21,10 +21,10 @@ array(1) { [0]=> array(3) { [0]=> - int(3) + float(3) [1]=> - int(6) + float(6) [2]=> - int(9) + float(9) } } diff --git a/tests/Functional/testCanMultiplyNumberByMatrix.phpt b/tests/Functional/testCanMultiplyNumberByMatrix.phpt index d152d36..c7c192f 100644 --- a/tests/Functional/testCanMultiplyNumberByMatrix.phpt +++ b/tests/Functional/testCanMultiplyNumberByMatrix.phpt @@ -21,10 +21,10 @@ array(1) { [0]=> array(3) { [0]=> - int(3) + float(3) [1]=> - int(6) + float(6) [2]=> - int(9) + float(9) } } diff --git a/tests/Functional/testCanPowMatrixByNumber.phpt b/tests/Functional/testCanPowMatrixByNumber.phpt index c250588..895fc5a 100644 --- a/tests/Functional/testCanPowMatrixByNumber.phpt +++ b/tests/Functional/testCanPowMatrixByNumber.phpt @@ -21,10 +21,10 @@ array(1) { [0]=> array(3) { [0]=> - int(1) + float(1) [1]=> - int(4) + float(4) [2]=> - int(9) + float(9) } } diff --git a/tests/Functional/testCanRegisterThirdPartyBackend.phpt b/tests/Functional/testCanRegisterThirdPartyBackend.phpt index 47be954..6902efb 100644 --- a/tests/Functional/testCanRegisterThirdPartyBackend.phpt +++ b/tests/Functional/testCanRegisterThirdPartyBackend.phpt @@ -31,8 +31,8 @@ array(1) { [0]=> array(2) { [0]=> - int(1011) + float(1011) [1]=> - int(1022) + float(1022) } } diff --git a/tests/Functional/testCanSubtractMatrices.phpt b/tests/Functional/testCanSubtractMatrices.phpt index 4c6ac21..745035e 100644 --- a/tests/Functional/testCanSubtractMatrices.phpt +++ b/tests/Functional/testCanSubtractMatrices.phpt @@ -22,10 +22,10 @@ array(1) { [0]=> array(3) { [0]=> - int(3) + float(3) [1]=> - int(3) + float(3) [2]=> - int(3) + float(3) } } diff --git a/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt b/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt index f2c543e..258ab11 100644 --- a/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt +++ b/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt @@ -33,4 +33,4 @@ var_dump(Backends::active()); bool(true) bool(false) Matrix backend "unavailable" is registered but not available in this environment -string(4) "auto" +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Auto) diff --git a/tests/Functional/testFailsOnSelectingUnknownBackend.phpt b/tests/Functional/testFailsOnSelectingUnknownBackend.phpt index 3fd7a2c..cc9597a 100644 --- a/tests/Functional/testFailsOnSelectingUnknownBackend.phpt +++ b/tests/Functional/testFailsOnSelectingUnknownBackend.phpt @@ -24,4 +24,4 @@ var_dump(Backends::active()); ?> --EXPECTF-- Unknown matrix backend "quantum", registered ones are: %s -string(4) "auto" +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Auto) diff --git a/tests/Functional/testKeepsDefaultDebugOutput.phpt b/tests/Functional/testKeepsDefaultDebugOutput.phpt index 77ec5ef..59b3fa8 100644 --- a/tests/Functional/testKeepsDefaultDebugOutput.phpt +++ b/tests/Functional/testKeepsDefaultDebugOutput.phpt @@ -12,11 +12,14 @@ use Lisachenko\NativePhpMatrix\Matrix; include __DIR__ . '/../../vendor/autoload.php'; +// The cells live in a native buffer now, so the property table shows a CData handle where it used to show an +// array of rows. What the test is about is unchanged: the engine's own visibility markers survive the hook $matrix = new Matrix([[1, 2]]); ob_start(); var_dump($matrix); $output = ob_get_clean(); -var_dump(str_contains($output, '["matrix":"Lisachenko\NativePhpMatrix\Matrix":private]')); +var_dump(str_contains($output, '["buffer":"Lisachenko\NativePhpMatrix\Matrix":private]')); +var_dump(str_contains($output, 'object(FFI\CData:double[2])')); var_dump(str_contains($output, '["rows":"Lisachenko\NativePhpMatrix\Matrix":private]')); var_dump(str_contains($output, '["columns":"Lisachenko\NativePhpMatrix\Matrix":private]')); ?> @@ -24,3 +27,4 @@ var_dump(str_contains($output, '["columns":"Lisachenko\NativePhpMatrix\Matrix":p bool(true) bool(true) bool(true) +bool(true) diff --git a/tests/Functional/testMatrixStoresFloat64.phpt b/tests/Functional/testMatrixStoresFloat64.phpt new file mode 100644 index 0000000..4d3d4ad --- /dev/null +++ b/tests/Functional/testMatrixStoresFloat64.phpt @@ -0,0 +1,59 @@ +--TEST-- +Matrix accepts integer literals and stores every cell as float64 +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +toArray()); + +// Which means the result of an operation is float whichever driver computed it, with no widening left to do +var_dump(($matrix * 2)->toArray()); + +// Integral floats still stringify without a fractional part, so a matrix of whole numbers reads as before +echo (string) new Matrix([[1, 2, 3]]), "\n"; +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(1) + [1]=> + float(2) + } + [1]=> + array(2) { + [0]=> + float(3) + [1]=> + float(4.5) + } +} +array(2) { + [0]=> + array(2) { + [0]=> + float(2) + [1]=> + float(4) + } + [1]=> + array(2) { + [0]=> + float(6) + [1]=> + float(9) + } +} +[1, 2, 3] From 97f81c33bb33d4cade62acd64863a6322056f2d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:56:05 +0000 Subject: [PATCH 14/15] ci: matrix the gpu job over both PHP minors and run the whole suite The gpu-path job hardcoded php-version 8.4 while every other job builds its PHP version from a matrix, so the device code path was only ever exercised on one of the two supported minors. It now carries the same strategy.matrix.php as the tests job and reads ${{ matrix.php }}, with the minor in the job name. It also stops naming individual test files. That workaround existed because pinning an accelerated backend process-wide turned integer results into floats and broke every test asserting integers; with cells stored as float64 there are no integer expectations left, so the entire suite passes pinned to clblast and the job simply runs "composer test" on the device. Dependabot already tracks the github-actions ecosystem weekly alongside the daily composer updates, so the action pins in this workflow keep updating on their own and no change is needed there. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- .github/workflows/ci.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00d9119..4f2f59b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,16 @@ jobs: NATIVE_PHP_MATRIX_BACKEND: php gpu-path: - name: GPU code path (CLBlast on PoCL) + name: GPU code path (CLBlast on PoCL, PHP ${{ matrix.php }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] # The device code path gets its own red/green signal, on the CPU device PoCL provides, so a broken GPU driver - # is visible without reading through the rest of the suite. Only the driver's own tests run: pinning an - # accelerated backend process-wide turns integer results into floats by design, which is precisely what the - # tests asserting integer output would report as a failure + # is visible without reading through the rest of the suite. Now that matrix cells are float64 whichever driver + # computes them, pinning an accelerated backend no longer changes any expectation, so this job runs the entire + # suite on the device instead of only the driver's own tests env: NATIVE_PHP_MATRIX_BACKEND: clblast NATIVE_PHP_MATRIX_CL_DEVICE: cpu @@ -76,16 +80,15 @@ jobs: - uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: ${{ matrix.php }} extensions: ffi ini-values: ffi.enable=1, zend.assertions=1, opcache.jit=off coverage: none - uses: ramsey/composer-install@v4 - # PHPUnit's --filter matches test method names, which .phpt files do not have, so the files are named - - name: Run the clblast backend tests - run: vendor/bin/phpunit tests/Functional/testClblast*.phpt + - name: Run test suite on the clblast backend + run: composer test static-analysis: name: PHPStan (level max, PHP ${{ matrix.php }}) From 0871415a6b4d84d2e6122732fada75579f4601b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 17:03:09 +0000 Subject: [PATCH 15/15] docs: rewrite acceleration guide for native float64 buffers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README still described the design this branch replaced: plain arrays handed to drivers, an integer-preserving pure-PHP driver, a float-only asterisk on the accelerated ones, and a crossover section explaining why element-wise operations were slower accelerated than interpreted. None of that is true any more. It now documents the storage — one contiguous double[rows * columns] per matrix, integers accepted as input and stored as float64 the way numpy reports dtype=float64 — and states the single remaining routing rule, that auto uses OpenBLAS for every operation when it is loadable and never picks a GPU for you. The crossover section is replaced by an explanation of why the crossover is gone. Both benchmark tables are re-measured on the new storage. Element-wise addition at 512x512 went from 25.27 ms to 2.15 ms on blas, turning a x0.3 penalty into a x6.1 win, and multiplication at 1024x1024 from 113.61 ms to 22.67 ms now that the kernel is nearly all that is being timed. The pure-PHP column got slower in the same measurements, because walking FFI\CData costs more than walking a PHP array, and the text says so rather than quoting only the flattering half. CLAUDE.md follows: the generics section becomes the float64 storage rule, the backend rules gain the read-only-operands contract, and the test conventions now require float expectations everywhere and a suite that stays green under every pinned backend. The code-reviewer agent carried the old generics rule as a blocking check and is updated with it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE --- .claude/agents/code-reviewer.md | 14 ++++--- CLAUDE.md | 66 +++++++++++++++++++------------ README.md | 70 +++++++++++++++++++++------------ 3 files changed, 94 insertions(+), 56 deletions(-) diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index ab149ca..61bce54 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -40,13 +40,17 @@ Review against the checklist below, in this order of severity. memory corruption. - CI must run on PHP 8.4 with `ffi.enable=1` and `opcache.jit=off`. -## 3. PHPStan generics honesty (blocking) +## 3. float64 storage honesty (blocking) -`Matrix` is `@template-covariant T of int|float`. +`Matrix` has no generic parameter: cells are `float`, stored in a native +`double[rows * columns]` buffer. -- Arithmetic that can widen the element type — division, exponentiation, and any mixed - int/float input — must be annotated as returning `Matrix`, not `Matrix`. -- Flag any annotation that claims to preserve `T` through an operation whose maths does not. +- Flag any reintroduced `Matrix` / `Matrix` annotation, or any signature claiming a + method returns integer cells. An integer literal in the constructor is input syntax only. +- Flag userland cast loops over cells. The constructor writing an int into a double slot is + the only conversion there should be; anything else is the loop the buffers exist to remove. +- Drivers must treat their operand buffers as read-only and return a freshly allocated one. + Returning an operand, or writing into one, is a blocking finding. - New code must be clean at PHPStan level max; suppressing with `@phpstan-ignore` needs an inline justification. diff --git a/CLAUDE.md b/CLAUDE.md index e12f33c..e7e967a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,11 +82,12 @@ the benchmark, which no analyser can know the engine dispatches. Each entry in `phpstan.dist.neon` is scoped to a path, carries an identifier and a comment; there is no baseline. Keep it that way — new ignores need the same justification. -`Matrix` is declared `@template-covariant T of int|float`. **Keep the generics -honest.** Arithmetic widens: dividing or exponentiating a `Matrix` can produce -floats, so those results are `Matrix`, never `Matrix`. Do not annotate -a method as preserving `T` to make PHPStan quiet — if the maths does not preserve -the type, the signature must not claim it does. +`Matrix` carries **no generic parameter**. Cells are float64 and nothing else, so +there is no `T` to preserve or widen and `toArray()` returns +`non-empty-list>` unconditionally. Do not reintroduce +`Matrix`: an integer literal in the constructor is input syntax, not a cell +type, and a signature promising integers back would be a lie the storage cannot +keep. ## Anatomy of a `.phpt` test @@ -157,15 +158,19 @@ Rules for a new test: different order still has to produce an identical result — which is what the `*MatchesPhpBackendResults` tests assert. For division, use power-of-two divisors: BLAS scales by the reciprocal, and only then is `x * (1/s)` exactly `x / s`. +- **Every cell expectation is a float.** `var_dump()` of a result prints `float(5)`, + never `int(5)`, whichever driver computed it. A test asserting `int(...)` for a + matrix cell is wrong by construction. +- **The suite must pass under every pinned backend.** Running it with + `NATIVE_PHP_MATRIX_BACKEND=php`, `=blas` or `=clblast` has to be as green as the + default run — that is the point of having one cell type, and it is why the + `gpu-path` CI job runs `composer test` outright instead of naming the CLBlast test + files. If pinning a backend breaks a test, the test encodes a driver-specific + assumption and needs fixing, not an exclusion. - **A test's expectation may never depend on the ambient environment.** A `.phpt` file that asserts the *default* selection pins `NATIVE_PHP_MATRIX_BACKEND=` (empty, which means "unset") in its `--ENV--`, so it keeps asserting the default even when - the whole suite is run with a backend pinned. Conversely, the classic operator tests - deliberately keep asserting integer results under the default routing: running the - entire suite with `NATIVE_PHP_MATRIX_BACKEND=blas` or `=clblast` makes them fail, - and that is the float-only rule working as documented, not a regression. Those two - drivers are exercised by their own pinned tests, which is also why the `gpu-path` CI - job names the CLBlast test files instead of running everything. + the whole suite is run with a backend pinned. - `failOnSkipped="true"` is set, so a skip fails the suite. That is deliberate: CI installs every acceleration library, so a skip there means a broken image. Locally, without the libraries, run @@ -185,8 +190,8 @@ on runners that have no GPU; the `gpu-path` job pins ## Repository map ``` -src/Matrix.php validation, dimensions, the __doOperation/__compare hooks; the arithmetic - itself is delegated to a backend driver +src/Matrix.php the float64 buffer, validation, dimensions, the __doOperation/__compare hooks; + the arithmetic itself is delegated to a backend driver src/Backend/ the interchangeable drivers and the registry that picks one tests/Functional/*.phpt the functional suite, one behaviour per file tests/Functional/include/ shared SKIPIF probes and backend stubs (.inc — never collected as tests) @@ -198,6 +203,7 @@ phpunit.xml.dist PHPUnit 12 config (suite points at tests/, suffix . phpstan.dist.neon static analysis config, level max .php-cs-fixer.dist.php coding standards config (PER-CS2.0) .github/workflows/ci.yml jobs: tests, gpu-path, static-analysis, coding-standards — PHP 8.4 and 8.5 +.github/dependabot.yml composer daily, github-actions weekly ``` `src/Matrix.php` is still the centre of the library. There is no framework here to @@ -207,21 +213,23 @@ every operator at once. ## Backend architecture `Matrix` no longer does the arithmetic itself. It validates, checks dimensions, and -asks `Backends::resolveFor()` which driver should compute — drivers receive plain -arrays with the dimensions alongside them and return plain arrays. +asks `Backends::resolve()` which driver should compute — drivers receive the operand +**buffers** with the dimensions alongside them and return a freshly allocated buffer. ``` src/Backend/BackendInterface.php the driver contract: six operations, plus isAvailable() src/Backend/Backends.php registry, selection and the auto-routing policy -src/Backend/PhpBackend.php the original interpreted loops, verbatim -src/Backend/BlasBackend.php OpenBLAS over FFI (CPU) +src/Backend/Driver.php string-backed enum naming the built-in drivers plus "auto" +src/Backend/Float64Buffer.php allocate / copy / compare the double[] blocks everything speaks +src/Backend/PhpBackend.php the interpreted loops, now over buffer offsets +src/Backend/BlasBackend.php OpenBLAS over FFI (CPU), called on the stored buffers src/Backend/ClblastBackend.php CLBlast over OpenCL (GPU, or CPU via PoCL) -src/Backend/AcceleratedBackendTrait.php packing, unpacking and the pow loop shared by both +src/Backend/AcceleratedBackendTrait.php the pow loop, the one operation no BLAS provides src/Backend/FallbackBackend.php decorator: degrade to another driver instead of failing src/Backend/BackendNotAvailableException.php catchable, thrown at selection time only ``` -Three rules govern this part of the codebase, and none of them is negotiable: +Four rules govern this part of the codebase, and none of them is negotiable: - **Hook safety.** Anything reachable from an operation runs inside an FFI callback, where a thrown exception becomes `Fatal error: Throwing from FFI callbacks is not @@ -230,13 +238,19 @@ Three rules govern this part of the codebase, and none of them is negotiable: `Backends::use()` and `bootFromEnvironment()`. Under `auto`, a driver that fails at operation time is caught by `FallbackBackend` and the result is recomputed in pure PHP. Catching *inside* a hook is fine; only crossing the boundary is fatal. -- **Accelerated drivers are float-only.** They compute in double precision, so they - cast integer cells and return floats. Never "fix" that by rounding results back to - integers. -- **Auto-routing keeps integers on pure PHP.** `auto` may use an accelerated CPU - driver when an operand contains a float; an all-integer operation always takes the - `php` path, so its results stay bit-identical to what this library returned before - drivers existed. A GPU is never chosen automatically. +- **Everything is float64.** A matrix stores `double` cells, every driver reads and + writes `double` cells. There is no integer path to preserve and no cast to make: + the constructor is the only place a value changes type, and it does so by writing + an int into a double slot, which FFI converts natively. Never add a userland cast + loop — that is exactly what the buffers exist to eliminate. +- **Operands are read-only, results are fresh.** The buffers a driver receives are + the storage of matrices the caller still holds. A kernel that accumulates into an + argument (`daxpy`, `dscal`) must copy it into the result buffer first, with + `Float64Buffer::copyOf()`. Never return an operand as the result. This is also what + makes `FallbackBackend`'s recomputation safe. +- **Auto-routing uses BLAS for everything, never a GPU.** `auto` picks the OpenBLAS + driver whenever it probes available, for every operation including element-wise + ones, and the pure-PHP driver otherwise. A GPU is never chosen automatically. An availability probe performs a real 1×1 operation, so it cannot disagree with what an operation would do a moment later. Probes are cached per process. diff --git a/README.md b/README.md index 7ac9f45..fc36d88 100644 --- a/README.md +++ b/README.md @@ -35,21 +35,33 @@ The trick is [lisachenko/z-engine](https://github.com/lisachenko/z-engine), whic | ➗ | **Scalar division** | `$a / 2` | | 🔺 | **Scalar exponentiation** — element-wise power | `$a ** 2` | | 🟰 | **Equality** — strict element-wise comparison | `$a == $b`, `$a != $b` | -| 🛡️ | **Type-safe by design** — PHPStan generics `Matrix` / `Matrix`, checked at level max | `Matrix` | +| 🧬 | **float64 storage** — cells live in a native `double[]`, not a PHP array | `new Matrix([[1, 2]])` | -Every operation returns a **new** `Matrix`; the class is `final` and its state is `readonly`, so nothing is ever mutated in place. The constructor validates that you passed a rectangular list of rows holding only `int`/`float` cells and raises a catchable `InvalidArgumentException` when it does not. +Every operation returns a **new** `Matrix`; the class is `final` and nothing is ever mutated in place. The constructor validates that you passed a rectangular list of rows holding only `int`/`float` cells and raises a catchable `InvalidArgumentException` when it does not. Operator-level failures are a different story, and it is worth being blunt about it: a dimension mismatch (`InvalidArgumentException`) or an unimplemented operand combination such as `$a - 2` (`LogicException`) is raised *inside an FFI callback*, and PHP does not allow an exception to cross that boundary. The engine prints the exception and then halts with `Fatal error: Throwing from FFI callbacks is not allowed`. You cannot `try`/`catch` it — check your dimensions before you multiply. -Generics are honest about arithmetic: `Matrix` divided by `2` is a `Matrix`, because PHP's `/` widens. Nothing pretends to preserve `T` where the maths does not. +### Cells are float64 + +A `Matrix` does not hold an array of rows. It owns one contiguous, row-major `double[rows * columns]` allocation — the exact shape a BLAS kernel or an OpenCL buffer wants — so an operator can hand its operands to a driver as raw pointers, with nothing packed on the way in and nothing unpacked on the way out. + +Integers are accepted as input and stored as the doubles they convert to, which is what `numpy.array([[1, 2]])` does when it reports `dtype=float64`: + +```php +$m = new Matrix([[1, 2], [3, 4]]); +$m->toArray(); // [[1.0, 2.0], [3.0, 4.0]] — ints in, float64 out +echo $m; // [1, 2] / [3, 4] — integral floats still print without decimals +``` + +So there is one cell type and one set of results, whichever driver computed them. Equality is a bit-exact `memcmp` over the two buffers rather than a loop. ## ⚡ Acceleration -The operators are the interesting part; the arithmetic underneath them is now interchangeable. `Matrix` asks a registry which **driver** should carry out an operation and hands it plain arrays — validation, dimensions and object identity never leave the class. Three drivers ship with the package: +The operators are the interesting part; the arithmetic underneath them is now interchangeable. `Matrix` asks a registry which **driver** should carry out an operation and hands it the operand buffers themselves — validation, dimensions and object identity never leave the class. Three drivers ship with the package: | Driver | Runs on | Uses | Notes | |---|---|---|---| -| `php` | the interpreter | ordinary PHP loops | Always available, the only driver that returns **integers** | +| `php` | the interpreter | ordinary PHP loops over the buffer | Always available, needs no shared library | | `blas` | CPU | OpenBLAS `cblas_dgemm` / `cblas_daxpy` / `cblas_dscal` over FFI | Double precision, needs the OpenBLAS shared library | | `clblast` | GPU | [CLBlast](https://github.com/CNugteren/CLBlast) on OpenCL | NVIDIA, AMD, Intel — including laptop iGPUs — or the CPU through PoCL | @@ -64,12 +76,17 @@ NATIVE_PHP_MATRIX_CL_DEVICE=cpu php your-script.php # gpu (default) | cpu | ```php use Lisachenko\NativePhpMatrix\Backend\Backends; +use Lisachenko\NativePhpMatrix\Backend\Driver; Backends::available(); // ['php', 'blas'] — probed, not guessed -Backends::use('blas'); // InvalidArgumentException / BackendNotAvailableException, both catchable +Backends::use(Driver::Blas); // InvalidArgumentException / BackendNotAvailableException, both catchable +Backends::active(); // Driver::Blas Backends::register('cublas', static fn (): BackendInterface => new CuBlasBackend()); +Backends::use('cublas'); // third-party drivers are named by the string they registered under ``` +The drivers this package ships are a `Driver` enum, so they cannot be misspelled; a driver you register yourself keeps the arbitrary string you chose, and every method that names a driver accepts either. + Selection is validated **eagerly**, in userland — an unknown or unusable driver throws where you can still catch it, instead of surfacing as a fatal error from inside an operator hook later. Availability is not a guess either: probing loads the library and runs a real 1×1 multiplication with it. ### Installing the libraries @@ -84,20 +101,19 @@ sudo apt-get install pocl-opencl-icd # ...or Po brew install openblas clblast ``` -### Two rules worth knowing +### The rule worth knowing -**Accelerated drivers are float-only.** Hardware kernels compute in double precision, so `blas` and `clblast` cast integer cells to floats and return a `Matrix` — `new Matrix([[1, 2]]) + new Matrix([[3, 4]])` gives `[[4.0, 6.0]]` on them. Use `$matrix->asFloat()` when you want that conversion to be explicit in your own code. +**`auto` is deliberately boring.** It picks OpenBLAS whenever OpenBLAS is loadable, for *every* operation, and falls back to the pure-PHP driver when it is not. It never selects a GPU on your behalf — moving data across a bus is a decision, not a default. If an accelerated driver fails mid-operation, `auto` recomputes in pure PHP rather than taking down the request. -**`auto` is deliberately boring.** It picks OpenBLAS when the operation involves floats and OpenBLAS is loadable, keeps every all-integer operation on the pure-PHP driver so integers stay integers, and never selects a GPU on your behalf — moving data across a bus is a decision, not a default. If an accelerated driver fails mid-operation, `auto` recomputes in pure PHP rather than taking down the request. +That rule used to have two exceptions. Both are gone: there are no integer semantics left to protect, and no marshalling cost that could make an element-wise operation cheaper in the interpreter. For web SAPIs, set `OPENBLAS_NUM_THREADS=1`: an OpenBLAS thread pool per PHP-FPM worker is rarely what you want. -### Where the crossover is +### Why acceleration pays off everywhere now -Acceleration is not free: every operation copies cells into a buffer and reads them back, and the result is validated into a new `Matrix`. That overhead is proportional to the number of cells, while the gain is proportional to the work — so it pays off exactly where the work grows faster than the data. +It did not always. When a matrix was a PHP array, every operation had to copy its cells into a buffer and read the result back, and that overhead was proportional to the number of cells while the gain was proportional to the work. Multiplication does O(n³) work over O(n²) cells and could absorb it; element-wise operations do O(n²) work over O(n²) cells and could not, so `+`, `-` and scaling were genuinely *slower* accelerated than interpreted. -- **Matrix multiplication** does O(n³) work over O(n²) cells. It wins from about 64×64 upwards, and the gap widens with every size. -- **Element-wise operations** (`+`, `-`, scaling) do O(n²) work over O(n²) cells. There is nothing for a kernel to amortise, and the pure-PHP driver is *faster* than any driver that has to marshal buffers first — see the numbers below. Pin `NATIVE_PHP_MATRIX_BACKEND=php` if that is all your workload does. +Storing cells in the buffer removes that overhead rather than amortising it. `cblas_daxpy` is now called on the memory the matrices already occupy, so element-wise operations are simply a kernel call and win too — see the numbers below. ## 🤖 Built for the AI/ML era @@ -121,22 +137,26 @@ Matrix multiplication — the operation inference actually spends its time in: | Operation | Size | `php` | `blas` | `clblast` | `blas` speed-up | `clblast` speed-up | | --- | --- | --- | --- | --- | --- | --- | -| Multiplication `$a * $b` | 64×64 | 3.67 ms (0.1 GFLOP/s) | 0.41 ms (1.3 GFLOP/s) | 0.86 ms (0.6 GFLOP/s) | ×8.9 | ×4.3 | -| Multiplication `$a * $b` | 128×128 | 28.02 ms (0.1 GFLOP/s) | 1.49 ms (2.8 GFLOP/s) | 2.38 ms (1.8 GFLOP/s) | ×18.8 | ×11.8 | -| Multiplication `$a * $b` | 256×256 | 223.88 ms (0.1 GFLOP/s) | 5.75 ms (5.8 GFLOP/s) | 9.08 ms (3.7 GFLOP/s) | ×38.9 | ×24.7 | -| Multiplication `$a * $b` | 512×512 | 1,814.76 ms (0.1 GFLOP/s) | 27.49 ms (9.8 GFLOP/s) | 39.52 ms (6.8 GFLOP/s) | ×66.0 | ×45.9 | -| Multiplication `$a * $b` | 1024×1024 | 15,026.38 ms (0.1 GFLOP/s) | 113.61 ms (18.9 GFLOP/s) | 225.65 ms (9.5 GFLOP/s) | ×132.3 | ×66.6 | +| Multiplication `$a * $b` | 64×64 | 10.71 ms (0.0 GFLOP/s) | 0.06 ms (8.8 GFLOP/s) | 0.75 ms (0.7 GFLOP/s) | ×180.7 | ×14.2 | +| Multiplication `$a * $b` | 128×128 | 83.63 ms (0.1 GFLOP/s) | 0.15 ms (27.9 GFLOP/s) | 1.31 ms (3.2 GFLOP/s) | ×557.2 | ×63.8 | +| Multiplication `$a * $b` | 256×256 | 670.35 ms (0.1 GFLOP/s) | 0.46 ms (73.6 GFLOP/s) | 3.47 ms (9.7 GFLOP/s) | ×1,471.3 | ×193.0 | +| Multiplication `$a * $b` | 512×512 | 5,413.33 ms (0.0 GFLOP/s) | 3.59 ms (74.8 GFLOP/s) | 21.76 ms (12.3 GFLOP/s) | ×1,508.2 | ×248.7 | +| Multiplication `$a * $b` | 1024×1024 | 43,561.10 ms (0.0 GFLOP/s) | 22.67 ms (94.7 GFLOP/s) | 159.40 ms (13.5 GFLOP/s) | ×1,921.4 | ×273.3 | -Element-wise operations, where the honest answer is that acceleration costs more than it saves: +The `blas` column is now the kernel and almost nothing else — 1024×1024 went from 113.61 ms to 22.67 ms once the operands stopped being packed and unpacked around it. The `php` column moved the opposite way for the same reason, and the ratios should be read with that in mind: they are large partly because the interpreted fallback got slower. + +Element-wise operations, which used to be the honest caveat of this table and are not one any more — with the cells already in the buffer, `cblas_daxpy` is called on the memory the matrices occupy and there is nothing left to marshal: | Operation | Size | `php` | `blas` | `clblast` | `blas` speed-up | `clblast` speed-up | | --- | --- | --- | --- | --- | --- | --- | -| Addition `$a + $b` | 64×64 | 0.15 ms | 0.37 ms | 0.95 ms | ×0.4 | ×0.2 | -| Addition `$a + $b` | 512×512 | 8.41 ms | 25.27 ms | 28.48 ms | ×0.3 | ×0.3 | -| Scaling `$a * 2.5` | 64×64 | 0.12 ms | 0.31 ms | 0.78 ms | ×0.4 | ×0.2 | -| Scaling `$a * 2.5` | 512×512 | 6.91 ms | 24.45 ms | 22.00 ms | ×0.3 | ×0.3 | +| Addition `$a + $b` | 64×64 | 0.21 ms | 0.05 ms | 0.71 ms | ×4.0 | ×0.3 | +| Addition `$a + $b` | 512×512 | 13.14 ms | 2.15 ms | 7.97 ms | ×6.1 | ×1.6 | +| Scaling `$a * 2.5` | 64×64 | 0.16 ms | 0.07 ms | 0.53 ms | ×2.3 | ×0.3 | +| Scaling `$a * 2.5` | 512×512 | 10.23 ms | 1.82 ms | 2.92 ms | ×5.6 | ×3.5 | + +PHP 8.5.9 on Linux x86_64, Intel® Xeon® @ 2.80 GHz (shared cloud container), OpenBLAS 0.3.26, CLBlast 1.6.2 on PoCL 5.0 with `NATIVE_PHP_MATRIX_CL_DEVICE=cpu`. Median of 5 runs, one warm-up discarded, timing the whole PHP-level operation from operator to finished `Matrix`. -PHP 8.5.9 on Linux x86_64, Intel® Xeon® @ 2.10 GHz (shared cloud container), OpenBLAS 0.3.26, CLBlast 1.6.2 on PoCL 5.0 with `NATIVE_PHP_MATRIX_CL_DEVICE=cpu`. Median of 5 runs, one warm-up discarded, timing the whole PHP-level operation including packing, unpacking and validation. +For scale, the same 512×512 addition on the previous array-backed storage took 25.27 ms on `blas` and 8.41 ms on `php`: moving the cells into a native buffer made the accelerated path about **twelve times** faster and turned a ×0.3 penalty into a ×6.1 win. The `php` column moved the other way — walking `FFI\CData` costs more than walking a PHP array — which is the trade this design makes deliberately: the fallback driver gets slower so that every other driver stops paying for conversion. **These numbers are from a shared container without a GPU** — the `clblast` column is CLBlast running on the CPU through PoCL, which is a portability proof, not a GPU benchmark. On real hardware the GPU column is a different story, and the OpenBLAS column depends heavily on core count. Measure your own: @@ -151,7 +171,7 @@ Three moving parts, in order: 1. **`bootstrap.php`** is registered in Composer's `files` autoload, so it runs the moment you `require vendor/autoload.php`. It calls `ZEngine\Core::init()`, which validates that the FFI struct definitions match the exact PHP build you are running. 2. It then calls `installExtensionHandlers()` on `ZEngine\Reflection\ReflectionClass` for `Matrix`, wiring the class's `create_object`, `do_operation` and `compare` slots to the engine trampolines. Order matters — `create_object` allocates the memory the other handlers live in. -3. **`Matrix::__doOperation()`** and **`Matrix::__compare()`** are static hooks. The engine hands them a `DoOperationHook` / `CompareValuesHook` describing the opcode and both operands; they dispatch to ordinary, boring methods (`sum()`, `subtract()`, `multiply()`, `multiplyByScalar()`, `divideByScalar()`, `powByScalar()`, `equals()`), which ask the backend registry which driver should do the arithmetic. +3. **`Matrix::__doOperation()`** and **`Matrix::__compare()`** are static hooks. The engine hands them a `DoOperationHook` / `CompareValuesHook` describing the opcode and both operands; they dispatch to ordinary, boring methods (`sum()`, `subtract()`, `multiply()`, `multiplyByScalar()`, `divideByScalar()`, `powByScalar()`, `equals()`), which ask the backend registry which driver should do the arithmetic and pass it the operand buffers. The maths is plain PHP — or plain BLAS, if you asked for it. The magic is only in getting the engine to call it.