diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index 132bee4..d70c34f 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -32,22 +32,37 @@ Review against the checklist below, in this order of severity. ## 2. Version pins (blocking) -- `composer.json` must keep `"php": "^8.4"` and `"lisachenko/z-engine": "~8.4.2 || ~8.5.0"` - — one stable z-engine line per supported PHP minor, resolved by Composer against the - running PHP. +- `composer.json` must keep `"php": "^8.4"` and + `"lisachenko/z-engine": "~8.4.2 || ~8.5.0 || 8.6.x-dev"` — one z-engine line per + supported PHP minor, resolved by Composer against the running PHP: stable tags for 8.4 + and 8.5, the `8.6.x-dev` development line for 8.6 until z-engine tags it (then it + becomes `~8.6.0` and `"prefer-stable": true` is dropped in the same change). The root + `"prefer-stable": true` is what keeps 8.4/8.5 on their tags while the dev flag is + present; a diff that removes it before then, or adds `"minimum-stability": "dev"`, is + rejected. - Any diff that widens either constraint beyond the minors z-engine has definitions for (`>=8.4`, a `^8.4` z-engine constraint that would span future lines), or bypasses `Core::init()`'s version guard, is rejected outright. Offsets into engine structures are minor-version specific; loosening the pin trades a clear error for memory corruption. -- CI must run on PHP 8.4 and 8.5 with `ffi.enable=1` and `opcache.jit=off`. +- CI must run on PHP 8.4, 8.5 and 8.6 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 the form of the driver +active when the matrix was built — a native `double[rows * columns]` buffer for a +`BufferBackendInterface` driver, a list of rows of PHP floats for an `ArrayBackendInterface` +one. -- 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 converting each cell while it + validates it is the only conversion there should be; an array driver that lets an int into + a result row (an accumulator seeded with `0`) breaks the float64 promise. +- Flag storage conversions on the hot path. Converting between buffer and rows is only for an + operand built under another driver, and only through `Float64Buffer::fromRows()` / + `toRows()` — never a PHP loop over the cells, never for operands that already match. +- Drivers must treat their operands as read-only and return freshly computed cells. + 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/agents/phpt-author.md b/.claude/agents/phpt-author.md index 380d51a..a06bb43 100644 --- a/.claude/agents/phpt-author.md +++ b/.claude/agents/phpt-author.md @@ -10,8 +10,9 @@ You write `.phpt` functional tests for `lisachenko/native-php-matrix`. Tests liv ## Before writing Read `src/Matrix.php` to confirm the exact behaviour you are covering — which operand -orders `__doOperation` accepts, which exception type is thrown, and whether the result -elements come out as `int` or `float`. Read one or two existing tests in +orders `__doOperation` accepts and which exception type is thrown. Matrix cells are +float64 and nothing else, so every cell in a result dumps as `float(...)`, whichever +driver computed it and whatever literals went in. Read one or two existing tests in `tests/Functional/` for the house style. Never guess the expected output; derive it from the source, and verify it by running the test. @@ -40,11 +41,11 @@ array(1) { [0]=> array(3) { [0]=> - int(2) + float(2) [1]=> - int(4) + float(4) [2]=> - int(6) + float(6) } } ``` @@ -73,9 +74,16 @@ array(1) { - **`--EXPECT--` over `--EXPECTREGEX--`.** Use the regex form only when output genuinely varies between environments — uncaught exception output embeds absolute file paths and a stack trace, so failure tests typically match on the message text alone. -- **Types matter.** `var_dump()` distinguishes `int(1)` from `float(1)`. PHP's `/` returns - an `int` only when the division is exact; `**` with a negative or fractional exponent - returns `float`. Get this right or the test is worthless. +- **Every cell expectation is a float.** `var_dump()` distinguishes `int(1)` from + `float(1)`, and matrix cells are always the latter — integer literals in the + constructor are input syntax, not a cell type. A test asserting `int(...)` for a + matrix cell is wrong by construction. Use integral-valued fixtures so the printed + form stays exact (`float(6)`, not `float(6.0000000000000004)`) under every driver. +- **A test whose output exposes the storage pins a backend.** Storage follows the + active driver (PHP rows under `php`, a native buffer under `blas`/`clblast`), so + anything that dumps the object itself — rather than `toArray()` — must pin + `NATIVE_PHP_MATRIX_BACKEND` in `--ENV--` or its expectation depends on which + libraries the machine happens to have. - Keep matrices small — 1×3, 2×2, 3×1. The test documents a behaviour; it is not a benchmark. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 925e269..b0459e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,13 @@ on: permissions: contents: read -# z-engine tracks one PHP minor per release line; the "~8.4.2 || ~8.5.0" constraint -# lets Composer resolve the stable line matching each runner's PHP, so the suite -# runs on PHP 8.4 and 8.5 in parallel. +# z-engine tracks one PHP minor per release line; the "~8.4.2 || ~8.5.0 || 8.6.x-dev" +# constraint lets Composer resolve the line matching each runner's PHP, so the suite +# runs on PHP 8.4, 8.5 and 8.6 in parallel. 8.4 and 8.5 resolve stable tags; 8.6 +# resolves z-engine's master (branch-aliased 8.6.x-dev) until z-engine tags 8.6. The +# 8.6 legs are set up exactly as z-engine's own CI sets up its 8.6 line (plain +# php-version '8.6', which setup-php serves pre-release builds for too) and, like +# there, they are required, not allowed to fail. jobs: tests: name: Tests (PHP ${{ matrix.php }}) @@ -19,10 +23,20 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.4', '8.5'] + php: ['8.4', '8.5', '8.6'] + # 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,13 +51,56 @@ 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, PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5', '8.6'] + # 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. 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 + 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: ${{ matrix.php }} + extensions: ffi + ini-values: ffi.enable=1, zend.assertions=1, opcache.jit=off + coverage: none + + - uses: ramsey/composer-install@v4 + + - name: Run test suite on the clblast backend + run: composer test + static-analysis: name: PHPStan (level max, PHP ${{ matrix.php }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php: ['8.4', '8.5'] + php: ['8.4', '8.5', '8.6'] steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index aa86055..bcd7bb6 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -12,9 +12,16 @@ HEADER; $finder = PhpCsFixer\Finder::create() - ->in([__DIR__ . '/src']) + ->in([__DIR__ . '/src', __DIR__ . '/bench']) ->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/CLAUDE.md b/CLAUDE.md index ef770ff..824350a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,21 +9,22 @@ readable PHP; the only unusual part is how the engine is convinced to call it. ## The one rule that is non-negotiable: PHP minor and z-engine line move together -`composer.json` requires **`php: ^8.4`**, and PHP 8.4 and 8.5 are supported **in +`composer.json` requires **`php: ^8.4`**, and PHP 8.4, 8.5 and 8.6 are supported **in parallel** — each minor riding its own z-engine line. z-engine reads engine structures (`zend_class_entry`, `zval`, `zend_object_handlers`) by byte offset, and those offsets change on every PHP minor release. Running against the wrong minor does not throw a nice exception; it reads and writes the wrong memory. -That is why z-engine is required as **`~8.4.2 || ~8.5.0`**: Composer resolves the -release line matching the running PHP (`8.4.x` on PHP 8.4, `8.5.x` on PHP 8.5 — each -tag declares its own `~8.4.0`/`~8.5.0` platform requirement, so only one line can -ever satisfy a given runtime). `ZEngine\Core::init()` (called from `bootstrap.php`) -enforces the exact match and aborts with a clear message. **Never "fix" an -initialization failure by loosening the constraints past the minors z-engine has -definitions for, skipping `Core::init()`, or defeating the guard.** If the -environment's PHP does not satisfy `^8.4` (8.4 or 8.5), the environment is wrong — -say so and stop. +That is why z-engine is required as **`~8.4.2 || ~8.5.0 || 8.6.x-dev`**: Composer +resolves the line matching the running PHP (`8.4.x` on PHP 8.4, `8.5.x` on PHP 8.5, +z-engine `master` — branch-aliased `8.6.x-dev` — on PHP 8.6). Each line declares its +own `~8.4.0`/`~8.5.0`/`~8.6.0` platform requirement, so only one line can ever satisfy +a given runtime. 8.4 and 8.5 are stable tags; 8.6 rides the development line until +z-engine tags it. `ZEngine\Core::init()` (called from `bootstrap.php`) enforces the +exact match and aborts with a clear message. **Never "fix" an initialization failure +by loosening the constraints past the minors z-engine has definitions for, skipping +`Core::init()`, or defeating the guard.** If the environment's PHP does not satisfy +`^8.4` (8.4, 8.5 or 8.6), the environment is wrong — say so and stop. ## Running tests @@ -80,13 +81,22 @@ 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 ``` -`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. +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` 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 @@ -116,11 +126,11 @@ array(1) { [0]=> array(3) { [0]=> - int(5) + float(5) [1]=> - int(7) + float(7) [2]=> - int(9) + float(9) } } ``` @@ -138,22 +148,138 @@ 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`. +- **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. +- **A test whose output exposes the storage pins a backend.** Storage follows the + driver, so what `var_dump($matrix)` or a scope-bound property read shows depends on + which driver was active when the matrix was built — and under `auto`, on whether + OpenBLAS happens to be installed. Such a test pins one in `--ENV--` + (`testKeepsDefaultDebugOutput.phpt` pins `php`) or selects drivers explicitly. +- `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 the float64 cells (a buffer or PHP rows, following the driver), 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, 8.5 and 8.6 +.github/dependabot.yml composer daily, github-actions weekly ``` -`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. +`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::resolve()` which driver should compute — drivers receive the operands +in the **storage form they declare**, with the dimensions alongside them, and return +freshly computed cells in that same form. + +``` +src/Backend/BackendInterface.php the common root: isAvailable(), and the rule to implement one storage contract +src/Backend/BufferBackendInterface.php six operations over row-major double[] buffers (blas, clblast, fallback) +src/Backend/ArrayBackendInterface.php six operations over lists of rows of PHP floats (php) +src/Backend/Backends.php registry, selection, the auto-routing policy, the cached resolve() +src/Backend/Driver.php string-backed enum naming the built-in drivers plus "auto" +src/Backend/Float64Buffer.php allocate / copy / compare double[] blocks, and convert them to and from rows +src/Backend/PhpBackend.php the original interpreted array loops, verbatim, over float rows +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 the pow loop, the one operation no BLAS provides +src/Backend/FallbackBackend.php decorator: recompute a failed buffer operation on the array-native php driver +src/Backend/BackendNotAvailableException.php catchable, thrown at selection time only +``` + +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 + 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. +- **Everything is float64, and storage follows the driver.** Every cell is a double: + stored as a native buffer under the accelerated drivers (`BufferBackendInterface`) + and as PHP float arrays under the php driver (`ArrayBackendInterface`). The + constructor picks the form of the driver active at that moment and is the only + place a value changes type — it writes an int into a double slot, which FFI + converts natively, or appends it to its row as `(float)`, in the same pass that + validates it. Never add a cast loop anywhere else, and never let an int into a + result row (an accumulator starts at `0.0`). Storage converts only at driver-switch + boundaries: an operand built under another driver is converted once for that + operation, in bulk (`Float64Buffer::fromRows()` / `toRows()`), never cell by cell in + PHP and never on the path of an operation whose operands already match. +- **Operands are read-only, results are fresh.** The operands a driver receives are + the storage of matrices the caller still holds. A buffer 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. Arrays are copied + on write by PHP, but the rule is the same. 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. The + answer is cached by `Backends::resolve()` until the selection or the registry + changes, because the constructor asks it as well as every operator. + +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 @@ -184,27 +310,51 @@ 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. +**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.2 || ~8.5.0`** — one **stable** release - line per supported PHP minor, resolved by Composer to match the running PHP. The - tilde is deliberate: it admits patch releases within a line (`8.4.3`, `8.5.1`) but - never the next minor line, which would be built for a PHP this package does not - claim to support. -- The root `composer.json` no longer carries `"minimum-stability": "dev"` / - `"prefer-stable": true` — nothing in `require` is a development branch any more, - and consumers no longer need those flags to install this package's dependencies. +- `lisachenko/z-engine` is required as **`~8.4.2 || ~8.5.0 || 8.6.x-dev`** — one line + per supported PHP minor, resolved by Composer to match the running PHP. 8.4 and 8.5 + are **stable** release lines; the tilde is deliberate: it admits patch releases + within a line (`8.4.3`, `8.5.1`) but never the next minor line, which would be built + for a PHP this package does not claim to support. +- 8.6 rides z-engine's **development line**, `8.6.x-dev` (`master`, branch-aliased), + because z-engine has not tagged 8.6 yet. When it does, replace `8.6.x-dev` with the + tag's tilde constraint (`~8.6.0`) **and** drop `"prefer-stable"` in a single change. +- An explicit dev constraint carries its own root stability flag, so this package's + own installs (and CI) resolve `master` on PHP 8.6 without `"minimum-stability": + "dev"`. That flag applies to the whole package, though, so it would also admit the + `8.4`/`8.5` development branches on those minors — which is why the root carries + `"prefer-stable": true`: it keeps PHP 8.4 and 8.5 on their stable tags. Do not add + `"minimum-stability": "dev"`. +- **Stability flags do not propagate to consumers.** Until the 8.6 tag exists, a + project on PHP 8.6 must itself require `lisachenko/z-engine:8.6.x-dev` at its root + (or set `"minimum-stability": "dev"` with `"prefer-stable": true`). Consumers on 8.4 + and 8.5 need nothing extra. - PHP stays at `^8.4`, in lockstep with the set of z-engine lines this package tracks: a new PHP minor is added here only together with the z-engine line built for it, and never one without the other. +- **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 0363b5a..39d4dda 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/lisachenko/native-php-matrix/ci.yml?branch=master&label=CI)](https://github.com/lisachenko/native-php-matrix/actions/workflows/ci.yml) [![GitHub release](https://img.shields.io/github/release/lisachenko/native-php-matrix.svg)](https://github.com/lisachenko/native-php-matrix/releases/latest) -[![PHP Version](https://img.shields.io/badge/php-8.4%20%7C%208.5-8892BF.svg)](https://php.net/) +[![PHP Version](https://img.shields.io/badge/php-8.4%20%7C%208.5%20%7C%208.6-8892BF.svg)](https://php.net/) [![License](https://img.shields.io/packagist/l/lisachenko/native-php-matrix.svg)](https://packagist.org/packages/lisachenko/native-php-matrix) [![PHPStan](https://img.shields.io/badge/PHPStan-level%20max-brightgreen.svg)](https://phpstan.org/) @@ -35,13 +35,142 @@ 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** — a native `double[]` for the accelerated drivers, PHP float arrays for the interpreter | `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 + +Every cell is a double, whatever you passed in and whichever driver computes. *Where* those doubles live follows the driver: + +- under an accelerated driver a `Matrix` owns one contiguous, row-major `double[rows * columns]` allocation — the exact shape a BLAS kernel or an OpenCL buffer wants — so an operator hands its operands to the driver as raw pointers, with nothing packed on the way in and nothing unpacked on the way out; +- under the pure-PHP driver it keeps a list of rows of PHP floats, which the interpreter walks two to three times faster than it walks `FFI\CData`. + +The form is chosen once, by the constructor, from the driver active at that moment. A matrix built before a switch to another driver still works with it: its cells are converted once for that operation, in bulk, and the result is stored in the form of the driver that computed it. + +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 and whichever form holds them. Equality is bit-exact in every form: a `memcmp` over two buffers, the packed doubles of each row for two arrays. + +## ⚡ 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 the operands in the storage that driver declares — validation, dimensions and object identity never leave the class. Three drivers ship with the package: + +| Driver | Runs on | Uses | Notes | +|---|---|---|---| +| `php` | the interpreter | the original `foreach` loops over PHP float arrays | 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 | + +Same interface for everyone else: `cuBLAS`, a `ggml` driver for the Vulkan route, and Metal are all a `BufferBackendInterface` implementation away — or an `ArrayBackendInterface` one, for a driver that computes on PHP arrays. **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; +use Lisachenko\NativePhpMatrix\Backend\Driver; + +Backends::available(); // ['php', 'blas'] — probed, not guessed +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 + +```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 +``` + +### The rule worth knowing + +**`auto` is deliberately boring.** It picks OpenBLAS whenever OpenBLAS is loadable, for *every* operation, on matrices stored as native buffers, and falls back to the pure-PHP driver on PHP arrays 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. + +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. + +### Why acceleration pays off everywhere now + +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. + +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. + +The same reasoning, applied the other way round, is why the pure-PHP driver keeps arrays. A buffer is the right storage for a kernel and the wrong one for the interpreter: every `FFI\CData` cell access goes through FFI's type machinery, which made the interpreted loops two to three times slower when they had to walk buffers. So storage follows the driver, and each one computes on the form it reads fastest. The only conversion left is the one a driver switch makes unavoidable, for the matrices built before it. + +## 🤖 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 | 6.42 ms (0.1 GFLOP/s) | 0.04 ms (12.6 GFLOP/s) | 0.46 ms (1.1 GFLOP/s) | ×154.2 | ×13.9 | +| Multiplication `$a * $b` | 128×128 | 31.41 ms (0.1 GFLOP/s) | 0.08 ms (50.5 GFLOP/s) | 0.71 ms (5.9 GFLOP/s) | ×378.5 | ×44.0 | +| Multiplication `$a * $b` | 256×256 | 245.67 ms (0.1 GFLOP/s) | 0.32 ms (105.5 GFLOP/s) | 2.11 ms (15.9 GFLOP/s) | ×772.2 | ×116.6 | +| Multiplication `$a * $b` | 512×512 | 2,007.76 ms (0.1 GFLOP/s) | 2.35 ms (114.0 GFLOP/s) | 17.41 ms (15.4 GFLOP/s) | ×852.9 | ×115.3 | +| Multiplication `$a * $b` | 1024×1024 | 15,608.82 ms (0.1 GFLOP/s) | 19.64 ms (109.4 GFLOP/s) | 124.43 ms (17.3 GFLOP/s) | ×794.8 | ×125.4 | + +The `blas` column is the kernel and almost nothing else: the operands are handed over as the buffers they are stored in, and the buffer the kernel writes becomes the result. The `php` column is the original array loops, walking PHP arrays — so every speed-up here is measured against the interpreter at its best, not against an interpreter slowed down by FFI. + +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.10 ms | 0.03 ms | 0.50 ms | ×3.2 | ×0.2 | +| Addition `$a + $b` | 512×512 | 4.98 ms | 1.27 ms | 5.53 ms | ×3.9 | ×0.9 | +| Scaling `$a * 2.5` | 64×64 | 0.08 ms | 0.04 ms | 0.45 ms | ×1.8 | ×0.2 | +| Scaling `$a * 2.5` | 512×512 | 3.39 ms | 1.15 ms | 1.96 ms | ×2.9 | ×1.7 | + +PHP 8.5.11 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 from operator to finished `Matrix`. + +Storage follows the driver, and these tables show both halves of why. Buffers are what make the accelerated columns a kernel call with nothing to marshal. Arrays are what keep the `php` column fast: when every matrix was a buffer and the interpreter had to walk `FFI\CData`, the same container measured 4,146 ms for a 512×512 multiplication, 9.46 ms for the addition and 6.76 ms for the scaling — against 1,849 ms, 5.12 ms and 3.43 ms with the array loops back, about **twice as fast** across the board. Neither side pays for the other: the only conversion is the one a driver switch forces on the matrices built before it. + +**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 @@ -49,16 +178,17 @@ 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 and pass it the operands in the storage form it declares. -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 -- **PHP `^8.4`** — 8.4 and 8.5 are supported in parallel. Z-Engine reads engine structures by byte offset and those offsets change on every PHP minor release, so each minor rides its own Z-Engine line; `Core::init()` refuses to boot on a mismatch rather than corrupting memory. +- **PHP `^8.4`** — 8.4, 8.5 and 8.6 are supported in parallel. Z-Engine reads engine structures by byte offset and those offsets change on every PHP minor release, so each minor rides its own Z-Engine line; `Core::init()` refuses to boot on a mismatch rather than corrupting memory. - **`ext-ffi` enabled**, with `ffi.enable=1` for CLI usage. - **x64, non-thread-safe (NTS)** build — the same platform limitations as [Z-Engine](https://github.com/lisachenko/z-engine#requirements--support-matrix). -- The **matching Z-Engine minor release line**. Composer resolves it for you from the `~8.4.2 || ~8.5.0` constraint; mixing a Z-Engine built for another minor is not a configuration choice, it is undefined behaviour. +- The **matching Z-Engine minor release line**. Composer resolves it for you from the `~8.4.2 || ~8.5.0 || 8.6.x-dev` constraint; mixing a Z-Engine built for another minor is not a configuration choice, it is undefined behaviour. +- **On PHP 8.6 only:** Z-Engine has no stable 8.6 tag yet, so 8.6 rides its `8.6.x-dev` development line, and your project must allow that line at its root (see [Installation](#installation)). ## Installation @@ -66,7 +196,15 @@ The maths is plain PHP. The magic is only in getting the engine to call it. composer require lisachenko/native-php-matrix:dev-master ``` -This package requires Z-Engine as `~8.4.2 || ~8.5.0` — z-engine minors track PHP minors and are not interchangeable, so Composer resolves the release line matching your PHP automatically (`8.4.x` on PHP 8.4, `8.5.x` on PHP 8.5). Both are **stable tags**, so no `minimum-stability` tweak is needed for them. +This package requires Z-Engine as `~8.4.2 || ~8.5.0 || 8.6.x-dev` — z-engine minors track PHP minors and are not interchangeable, so Composer resolves the line matching your PHP automatically (`8.4.x` on PHP 8.4, `8.5.x` on PHP 8.5, `8.6.x-dev` on PHP 8.6). The 8.4 and 8.5 lines are **stable tags**, so no `minimum-stability` tweak is needed for them. + +**On PHP 8.6**, Z-Engine is still a development branch until it tags 8.6, and Composer only honours stability flags written in *your* root `composer.json` — not the ones a dependency declares. Require the line explicitly alongside this package: + +```bash +composer require lisachenko/native-php-matrix:dev-master lisachenko/z-engine:8.6.x-dev +``` + +(or set `"minimum-stability": "dev"` with `"prefer-stable": true` in your project). Once Z-Engine tags 8.6, this package tightens its constraint to that tag and the extra requirement can be dropped. native-php-matrix itself is not tagged yet, which is why the command above asks for `dev-master`; that one dev constraint is explicit, so Composer accepts it without a root stability change. Once a native-php-matrix release is tagged, this collapses to a plain `composer require lisachenko/native-php-matrix`. @@ -167,7 +305,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 @@ -177,6 +317,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 diff --git a/bench/benchmark.php b/bench/benchmark.php new file mode 100644 index 0000000..27572b2 --- /dev/null +++ b/bench/benchmark.php @@ -0,0 +1,439 @@ + + * + * 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 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'; + +/** + * 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 what a caller of *this* library actually pays. Every measurement therefore times the + * whole PHP-level operation, from the operator hook through the registry and the driver to a finished Matrix. + * The operands are built after the driver is selected, so each driver computes on the storage it declares — + * native buffers for the accelerated ones, PHP arrays for the interpreter — as a real application's would. + */ +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 = [Driver::Php->value, Driver::Blas->value, Driver::Clblast->value]; + + /** + * 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(Driver::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(Driver::Php->value, $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[Driver::Php->value] ?? 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/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/composer.json b/composer.json index 3cbae7f..252cd95 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": [ { @@ -13,32 +18,41 @@ "require": { "php": "^8.4", "ext-ffi": "*", - "lisachenko/z-engine": "~8.4.2 || ~8.5.0" + "lisachenko/z-engine": "~8.4.2 || ~8.5.0 || 8.6.x-dev" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.75", "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", + "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": { "Lisachenko\\NativePhpMatrix\\": "src/" }, - "files": ["bootstrap.php"] + "files": [ + "bootstrap.php" + ] }, "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" }, "config": { "sort-packages": true - } + }, + "prefer-stable": true } diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 487ad35..1df2c40 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -3,5 +3,52 @@ parameters: phpVersion: 80400 paths: - src + - bench - 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, 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 loop of the accelerated drivers, the buffer storage of Matrix and the buffer + # helper itself. The pure-PHP driver needs none of them: it computes on typed PHP arrays + - + identifier: offsetAccess.nonOffsetAccessible + path: src/Backend/AcceleratedBackendTrait.php + - + identifier: return.type + path: src/Backend/AcceleratedBackendTrait.php + - + identifier: binaryOp.invalid + path: src/Backend/AcceleratedBackendTrait.php + # Matrix writes its cells into a buffer as it validates them, when the active driver stores buffers + - + identifier: offsetAccess.nonOffsetAccessible + path: src/Matrix.php + - + identifier: assign.propertyType + path: src/Matrix.php + # The single cell accessors of the buffer helper index a CData like the C array it is + - + 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 + - + 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 + # 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 diff --git a/src/Backend/AcceleratedBackendTrait.php b/src/Backend/AcceleratedBackendTrait.php new file mode 100644 index 0000000..d8e4ebc --- /dev/null +++ b/src/Backend/AcceleratedBackendTrait.php @@ -0,0 +1,43 @@ + + * + * 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\CData; + +/** + * The little that the drivers handing their arithmetic to a numeric library still share + * + * 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. + * + * What remains is the one operation no BLAS implementation provides. + */ +trait AcceleratedBackendTrait +{ + /** + * {@inheritDoc} + */ + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + // 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; + } +} diff --git a/src/Backend/ArrayBackendInterface.php b/src/Backend/ArrayBackendInterface.php new file mode 100644 index 0000000..acd8b1b --- /dev/null +++ b/src/Backend/ArrayBackendInterface.php @@ -0,0 +1,102 @@ + + * + * 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; + +/** + * Storage contract of a driver that computes on PHP arrays + * + * The interpreter reads and writes the elements of a packed PHP array faster than it reads and writes the cells of + * an `FFI\CData` buffer — every buffer access goes through FFI's type machinery, an array access does not. A driver + * whose arithmetic is interpreted PHP therefore declares this contract, and the matrices built while it is active + * keep their cells as lists of rows of floats, ready to be walked by `foreach`. + * + * The operands are the arrays the matrices are stored in. PHP copies arrays on write, so a driver cannot damage + * them by accident, and the returned rows become the storage of the result as they are. They must keep the + * float64 promise: every cell a float, never an int — a driver that starts an accumulator at `0` instead of `0.0` + * would leak an integer into a matrix that is documented to hold none. + */ +interface ArrayBackendInterface extends BackendInterface +{ + /** + * Adds two matrices of the same shape element-wise + * + * @param non-empty-list> $left Left operand rows + * @param non-empty-list> $right Right operand rows, 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 rows + * @param non-empty-list> $right Right operand rows, 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 rows, shaped rows × inner + * @param non-empty-list> $right Right operand rows, 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 rows + * @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 + */ + public function multiplyByScalar(array $matrix, float $value, int $rows, int $columns): array; + + /** + * Divides every cell by a scalar value + * + * @param non-empty-list> $matrix Operand rows + * @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 + */ + public function divideByScalar(array $matrix, float $value, int $rows, int $columns): array; + + /** + * Raises every cell to the power of a scalar value + * + * @param non-empty-list> $matrix Operand rows + * @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 + */ + public function powByScalar(array $matrix, float $value, int $rows, int $columns): array; +} diff --git a/src/Backend/BackendInterface.php b/src/Backend/BackendInterface.php new file mode 100644 index 0000000..1110a5a --- /dev/null +++ b/src/Backend/BackendInterface.php @@ -0,0 +1,54 @@ + + * + * 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; + +/** + * Common root of every matrix arithmetic driver + * + * A backend is a numeric kernel and nothing else: it receives the operands with the dimensions already known and + * returns freshly computed cells. Validation, dimension checks and the object identity remain the responsibility + * of {@see \Lisachenko\NativePhpMatrix\Matrix}, so a driver never has to construct one. + * + * What a driver does not implement here is its **storage contract**, and it must implement exactly one: + * + * - {@see BufferBackendInterface} — the operands arrive as row-major `double[]` buffers and the result is one. That + * is the form a native library reads without a conversion, and the one the accelerated drivers declare. + * - {@see ArrayBackendInterface} — the operands arrive as lists of rows of PHP floats and the result is one. That + * is the form the interpreter walks fastest, and the one the pure-PHP driver declares. + * + * A matrix stores its cells in the form the driver active at construction declares, so the operands of an + * operation are normally already in the right shape. Only when the driver changed in between — an explicit switch, + * or {@see FallbackBackend} recomputing a failed accelerated operation in PHP — is an operand converted, once, for + * that operation. A registered driver that implements neither contract cannot be selected. + * + * Two rules bind every implementation, whatever its storage: + * + * - **Hook safety.** The operations 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. + * - **Everything is float64.** There is no integer path to preserve: a buffer holds doubles and a row holds PHP + * floats, which are doubles too, and the pure-PHP driver produces bit-identical results to the accelerated ones + * for values that are exactly representable. + */ +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; +} 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..46e598e --- /dev/null +++ b/src/Backend/Backends.php @@ -0,0 +1,357 @@ + + * + * 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 InvalidArgumentException; +use Throwable; + +/** + * 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 + * `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. + * + * 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. + * + * The default selection is {@see Driver::Auto}, and because a matrix stores its cells in the form of the driver + * active when it is built, its rules fit in two lines: + * + * - an accelerated CPU driver probed successfully → that driver, for **every** operation, on matrices stored as + * native buffers. There is no marshalling cost that could make an element-wise operation cheaper in the + * interpreter, and no integer semantics to protect; + * - nothing available → the pure-PHP driver, on matrices stored as PHP arrays it walks natively, computing the + * same values. + * + * A GPU driver is still never chosen automatically: moving data across a bus is a decision, not a default. + * + * The answer of {@see self::resolve()} is cached until the selection or the registry changes, because it is asked + * twice per operation — once when the operands were built, once when the operator runs. + */ +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'; + + /** + * 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 = []; + + /** + * Name of the currently selected driver, or the value of {@see Driver::Auto} + */ + private static string $selected = Driver::Auto->value; + + /** + * Driver that the current selection resolved to, remembered until the selection or the registry changes + */ + private static ArrayBackendInterface|BufferBackendInterface|null $resolved = null; + + /** + * Selects the driver to use for every following operation + * + * Matrices built from now on store their cells in the form this driver declares. Matrices that already exist + * keep theirs, and are converted for an operation only when their form differs from the driver's. + * + * @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, or it declares no storage + * @throws BackendNotAvailableException When the driver is known but unusable in this environment + */ + public static function use(Driver|string $driver): void + { + $name = Driver::nameOf($driver); + if ($name === Driver::Auto->value) { + self::$selected = $name; + self::$resolved = null; + + 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, + )); + } + if (self::withStorage(self::instance($name)) === null) { + throw new InvalidArgumentException(sprintf( + 'Matrix backend "%s" implements neither %s nor %s, so no matrix can be stored for it', + $name, + ArrayBackendInterface::class, + BufferBackendInterface::class, + )); + } + + self::$selected = $name; + self::$resolved = null; + } + + /** + * 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 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(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::$resolved = null; + } + + /** + * 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(): Driver|string + { + return Driver::resolveName(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 = Driver::Auto->value; + self::$resolved = null; + } + + /** + * 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; + } + + // 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, or whose storage a new matrix must use + * + * This is the hot path of every overloaded operator and of every constructor, 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. The storage contract the driver implements is what the caller dispatches on. + */ + public static function resolve(): ArrayBackendInterface|BufferBackendInterface + { + return self::$resolved ??= self::resolveSelection(); + } + + /** + * Turns the current selection into the driver that carries it out + */ + private static function resolveSelection(): ArrayBackendInterface|BufferBackendInterface + { + if (self::$selected !== Driver::Auto->value) { + // use() refuses a driver without a storage contract, so the fallback below is never taken in practice + return self::withStorage(self::instance(self::$selected)) ?? new PhpBackend(); + } + + return self::resolveAutomaticBackend(); + } + + /** + * Picks the driver automatic routing uses + * + * Only CPU drivers take part — sending data to a GPU is a decision, not a default — and a buffer driver 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. The built-in names can be re-registered, which is why every answer is + * checked against the storage contracts instead of assumed. + */ + private static function resolveAutomaticBackend(): ArrayBackendInterface|BufferBackendInterface + { + $php = self::withStorage(self::instance(Driver::Php->value)); + $fallback = $php instanceof ArrayBackendInterface ? $php : new PhpBackend(); + + if (self::probe(Driver::Blas->value)) { + $blas = self::withStorage(self::instance(Driver::Blas->value)); + if ($blas instanceof BufferBackendInterface) { + return new FallbackBackend($blas, $fallback); + } + if ($blas !== null) { + return $blas; + } + } + + return $php ?? $fallback; + } + + /** + * Returns the driver if it implements one of the two storage contracts, null otherwise + * + * @param BackendInterface $backend Driver to check + */ + private static function withStorage(BackendInterface $backend): ArrayBackendInterface|BufferBackendInterface|null + { + if ($backend instanceof ArrayBackendInterface || $backend instanceof BufferBackendInterface) { + return $backend; + } + + return null; + } + + /** + * 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 = [ + Driver::Php->value => static fn(): BackendInterface => new PhpBackend(), + Driver::Blas->value => static fn(): BackendInterface => new BlasBackend(), + Driver::Clblast->value => static fn(): BackendInterface => new ClblastBackend(), + ]; + } + + return self::$factories; + } +} diff --git a/src/Backend/BlasBackend.php b/src/Backend/BlasBackend.php new file mode 100644 index 0000000..8604e6e --- /dev/null +++ b/src/Backend/BlasBackend.php @@ -0,0 +1,246 @@ + + * + * 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 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. + * + * Nothing is marshalled. A matrix built while this driver is active 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. + */ +final class BlasBackend implements BufferBackendInterface +{ + 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 { + $left = Float64Buffer::allocate(1); + $right = Float64Buffer::allocate(1); + + Float64Buffer::write($left, 0, 3.0); + Float64Buffer::write($right, 0, 4.0); + + $product = $this->multiply($left, $right, 1, 1, 1); + $this->available = Float64Buffer::read($product, 0) === 12.0; + } catch (Throwable) { + $this->available = false; + } + + return $this->available; + } + + /** + * {@inheritDoc} + */ + public function sum(CData $left, CData $right, int $rows, int $columns): CData + { + return $this->axpy($left, $right, $rows * $columns, 1.0); + } + + /** + * {@inheritDoc} + */ + public function subtract(CData $left, CData $right, int $rows, int $columns): CData + { + return $this->axpy($left, $right, $rows * $columns, -1.0); + } + + /** + * {@inheritDoc} + */ + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData + { + $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. 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, + self::CBLAS_NO_TRANS, + $rows, + $columns, + $inner, + 1.0, + $left, + $inner, + $right, + $columns, + 0.0, + $product, + $columns, + ); + + return $product; + } + + /** + * {@inheritDoc} + */ + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + return $this->scal($matrix, $rows * $columns, $value); + } + + /** + * {@inheritDoc} + */ + 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. 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` over the flattened cells of both operands + * + * @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 CData Freshly allocated buffer holding the result + */ + private function axpy(CData $left, CData $right, int $count, float $alpha): CData + { + // 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 $result; + } + + /** + * Scales every cell of a matrix by a factor + * + * @param CData $matrix Operand cells + * @param positive-int $count Number of cells + * @param float $alpha Scale factor + * + * @return CData Freshly allocated buffer holding the scaled cells + */ + private function scal(CData $matrix, int $count, float $alpha): CData + { + // 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 $result; + } + + /** + * 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/BufferBackendInterface.php b/src/Backend/BufferBackendInterface.php new file mode 100644 index 0000000..145ef35 --- /dev/null +++ b/src/Backend/BufferBackendInterface.php @@ -0,0 +1,108 @@ + + * + * 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\CData; + +/** + * Storage contract of a driver that computes on native float64 buffers + * + * 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 a native 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. + */ +interface BufferBackendInterface extends BackendInterface +{ + /** + * Adds two matrices of the same shape element-wise + * + * @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 CData Freshly allocated `double[rows * columns]` holding the sum + */ + public function sum(CData $left, CData $right, int $rows, int $columns): CData; + + /** + * Subtracts the right matrix from the left one element-wise + * + * @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 CData Freshly allocated `double[rows * columns]` holding the difference + */ + public function subtract(CData $left, CData $right, int $rows, int $columns): CData; + + /** + * Multiplies two matrices with matching inner dimensions + * + * @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 CData Freshly allocated `double[rows * columns]` holding the product + */ + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData; + + /** + * Multiplies every cell by a scalar value + * + * @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 CData Freshly allocated `double[rows * columns]` holding the scaled cells + */ + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData; + + /** + * Divides every cell by a scalar value + * + * @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 CData Freshly allocated `double[rows * columns]` holding the divided cells + */ + 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 loop over the cells. + * It is part of the contract so that every operator the class overloads has a driver-level counterpart. + * + * @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 CData Freshly allocated `double[rows * columns]` holding the exponentiated cells + */ + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData; +} diff --git a/src/Backend/ClblastBackend.php b/src/Backend/ClblastBackend.php new file mode 100644 index 0000000..e2a0df0 --- /dev/null +++ b/src/Backend/ClblastBackend.php @@ -0,0 +1,708 @@ + + * + * 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 RuntimeException; +use Throwable; + +/** + * 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 BufferBackendInterface +{ + 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 { + $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; + } + + return $this->available; + } + + /** + * {@inheritDoc} + */ + public function sum(CData $left, CData $right, int $rows, int $columns): CData + { + return $this->axpy($left, $right, $rows * $columns, 1.0); + } + + /** + * {@inheritDoc} + */ + public function subtract(CData $left, CData $right, int $rows, int $columns): CData + { + return $this->axpy($left, $right, $rows * $columns, -1.0); + } + + /** + * {@inheritDoc} + */ + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData + { + return $this->gemm($left, $right, $rows, $inner, $columns); + } + + /** + * {@inheritDoc} + */ + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + return $this->scal($matrix, $rows * $columns, $value); + } + + /** + * {@inheritDoc} + */ + 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); + } + + /** + * Multiplies two matrices on the device + * + * 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 CData Freshly allocated buffer holding the product + */ + private function gemm(CData $left, CData $right, int $rows, int $inner, int $columns): CData + { + $library = $this->library(); + $queue = $this->queue(); + + $hostA = $left; + $hostB = $right; + $hostC = Float64Buffer::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 $hostC; + } finally { + $this->release($buffers); + } + } + + /** + * Computes `left ± right` on the device over the flattened cells + * + * @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 CData Freshly allocated buffer holding the result + */ + private function axpy(CData $left, CData $right, int $count, float $alpha): CData + { + $library = $this->library(); + $queue = $this->queue(); + + // 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 { + $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 $hostY; + } finally { + $this->release($buffers); + } + } + + /** + * Scales every cell of a matrix on the device + * + * @param CData $matrix Operand cells + * @param positive-int $count Number of cells + * @param float $alpha Scale factor + * + * @return CData Freshly allocated buffer holding the scaled cells + */ + private function scal(CData $matrix, int $count, float $alpha): CData + { + $library = $this->library(); + $queue = $this->queue(); + + // 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 { + $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 $host; + } 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 Float64Buffer::bytes($count); + } + + /** + * 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), + ); + } +} 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 new file mode 100644 index 0000000..387cea6 --- /dev/null +++ b/src/Backend/FallbackBackend.php @@ -0,0 +1,156 @@ + + * + * 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\CData; +use Throwable; + +/** + * Driver decorator that degrades to the pure-PHP 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. + * + * Towards the matrices this decorator is a buffer driver, like the primary it wraps, so the matrices built under + * automatic routing store buffers and the primary receives them without a conversion. The fallback computes on + * PHP arrays, which is the price of the failure path alone: the operands are converted to rows for it and its + * result back into a buffer. 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 conversion reads untouched cells. + * + * This decorator is deliberately not used for an explicit selection. Asking for a specific driver and silently + * getting another one's results, at a different speed, would hide exactly what the caller wanted to control. + */ +final class FallbackBackend implements BufferBackendInterface +{ + public function __construct( + private readonly BufferBackendInterface $primary, + private readonly ArrayBackendInterface $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(CData $left, CData $right, int $rows, int $columns): CData + { + try { + return $this->primary->sum($left, $right, $rows, $columns); + } catch (Throwable) { + return Float64Buffer::fromRows( + $this->fallback->sum( + Float64Buffer::toRows($left, $rows, $columns), + Float64Buffer::toRows($right, $rows, $columns), + $rows, + $columns, + ), + $rows * $columns, + ); + } + } + + /** + * {@inheritDoc} + */ + public function subtract(CData $left, CData $right, int $rows, int $columns): CData + { + try { + return $this->primary->subtract($left, $right, $rows, $columns); + } catch (Throwable) { + return Float64Buffer::fromRows( + $this->fallback->subtract( + Float64Buffer::toRows($left, $rows, $columns), + Float64Buffer::toRows($right, $rows, $columns), + $rows, + $columns, + ), + $rows * $columns, + ); + } + } + + /** + * {@inheritDoc} + */ + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData + { + try { + return $this->primary->multiply($left, $right, $rows, $inner, $columns); + } catch (Throwable) { + return Float64Buffer::fromRows( + $this->fallback->multiply( + Float64Buffer::toRows($left, $rows, $inner), + Float64Buffer::toRows($right, $inner, $columns), + $rows, + $inner, + $columns, + ), + $rows * $columns, + ); + } + } + + /** + * {@inheritDoc} + */ + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + try { + return $this->primary->multiplyByScalar($matrix, $value, $rows, $columns); + } catch (Throwable) { + return Float64Buffer::fromRows( + $this->fallback->multiplyByScalar(Float64Buffer::toRows($matrix, $rows, $columns), $value, $rows, $columns), + $rows * $columns, + ); + } + } + + /** + * {@inheritDoc} + */ + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + try { + return $this->primary->divideByScalar($matrix, $value, $rows, $columns); + } catch (Throwable) { + return Float64Buffer::fromRows( + $this->fallback->divideByScalar(Float64Buffer::toRows($matrix, $rows, $columns), $value, $rows, $columns), + $rows * $columns, + ); + } + } + + /** + * {@inheritDoc} + */ + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + try { + return $this->primary->powByScalar($matrix, $value, $rows, $columns); + } catch (Throwable) { + return Float64Buffer::fromRows( + $this->fallback->powByScalar(Float64Buffer::toRows($matrix, $rows, $columns), $value, $rows, $columns), + $rows * $columns, + ); + } + } +} diff --git a/src/Backend/Float64Buffer.php b/src/Backend/Float64Buffer.php new file mode 100644 index 0000000..fde50e4 --- /dev/null +++ b/src/Backend/Float64Buffer.php @@ -0,0 +1,227 @@ + + * + * 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 the accelerated drivers speak: a flat, row-major block of float64 cells + * + * A `double[rows * columns]` allocation is what a BLAS kernel and an OpenCL transfer read without a conversion in + * between. A matrix built while such a driver is active is kept in that shape from the moment it is constructed, + * which removes the pack/unpack step that would otherwise be charged to every single operation. Under the + * pure-PHP driver a matrix keeps PHP rows instead; the two conversions between the forms live here as well, for + * the rare operation whose operand was built under another driver. + * + * The buffers are allocated through a bare `FFI::cdef()` scope of their own, not through a driver's 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 scope declares nothing and binds no library; it + * exists because calling `FFI::new()` statically is deprecated since PHP 8.3. 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; + + /** + * Empty FFI scope the buffers are allocated from, created on first use + */ + private static ?FFI $scope = 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 self::scope()->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; + } + + /** + * Allocates a buffer holding the cells of the given rows + * + * This is one of the two storage conversions, used only where a matrix stored as PHP rows meets a driver that + * computes on buffers — never on the path of an operation whose operands already match their driver. It has no + * loop over the cells in PHP: the rows are packed into their native byte representation and copied in bulk. + * + * @param non-empty-list> $rows Rows of float cells + * @param positive-int $count Total number of cells the rows hold + * + * @return CData Owned `double[count]` buffer, row-major + */ + public static function fromRows(array $rows, int $count): CData + { + $buffer = self::allocate($count); + $packed = self::packRows($rows); + FFI::memcpy($buffer, $packed, strlen($packed)); + + return $buffer; + } + + /** + * Returns the cells of a buffer as a list of rows of floats + * + * The other storage conversion, and the boundary between a buffer and ordinary PHP: the bytes are read out in + * one call and unpacked natively, so the rows never cost a PHP-level loop over the cells either. + * + * @param CData $buffer Row-major buffer holding at least rows × columns cells + * @param positive-int $rows Number of rows to read + * @param positive-int $columns Number of columns to read + * + * @return non-empty-list> Cells, row after row + */ + public static function toRows(CData $buffer, int $rows, int $columns): array + { + $cells = unpack('d*', FFI::string($buffer, self::bytes($rows * $columns))); + + /** @var non-empty-list> */ + return array_chunk(is_array($cells) ? array_values($cells) : [], $columns); + } + + /** + * Tells whether two lists of rows hold bit-identical cells + * + * The array counterpart of {@see self::identical()}, with the same deliberately storage-level semantics: the + * cells are compared as the doubles they are packed into, so `-0.0` does not match `0.0` and a `NAN` matches a + * `NAN` with the same bit pattern, exactly as a `memcmp` over two buffers decides. + * + * @param non-empty-list> $left Left rows + * @param non-empty-list> $right Right rows, of the same shape + */ + public static function identicalRows(array $left, array $right): bool + { + foreach ($left as $rowIndex => $row) { + if (pack('d*', ...$row) !== pack('d*', ...$right[$rowIndex])) { + return false; + } + } + + return true; + } + + /** + * 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(self::scope()->new('double')); + + /** @var positive-int */ + return $count * self::$cellSize; + } + + /** + * Packs rows of floats into their native, machine-order double representation + * + * @param non-empty-list> $rows Rows of float cells + */ + private static function packRows(array $rows): string + { + $packed = ''; + foreach ($rows as $row) { + $packed .= pack('d*', ...$row); + } + + return $packed; + } + + /** + * Returns the empty FFI scope buffers are allocated from + * + * An empty `FFI::cdef()` still knows every built-in C type, which is all a `double[n]` allocation needs. + */ + private static function scope(): FFI + { + return self::$scope ??= FFI::cdef(); + } +} diff --git a/src/Backend/PhpBackend.php b/src/Backend/PhpBackend.php new file mode 100644 index 0000000..7c181e9 --- /dev/null +++ b/src/Backend/PhpBackend.php @@ -0,0 +1,153 @@ + + * + * 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; + +/** + * Reference driver: the original interpreted PHP arithmetic, over PHP arrays + * + * The loop bodies are the ones this library shipped before the drivers existed, kept verbatim down to the + * iteration 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 single + * adaptation is the accumulator of the multiplication, which starts at `0.0`: cells are float64, and an integer + * seed would be the one place an int could slip into a result. + * + * The driver declares {@see ArrayBackendInterface}, so the matrices built while it is active store their cells as + * nested PHP arrays and these loops walk them natively. A `foreach` over a packed array is the fastest thing the + * interpreter does; reading the same cells through `FFI\CData` costs two to three times as much, which is why the + * storage follows the driver instead of the driver following one storage. + */ +final class PhpBackend implements ArrayBackendInterface +{ + /** + * 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.0; + foreach ($rowItems as $key => $value) { + $cellValue += $value * $columnItems[$key]; + } + + $resultRow[] = $cellValue; + } + $result[] = $resultRow; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function multiplyByScalar(array $matrix, 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, 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, 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 a83779f..dc0fddf 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -12,30 +12,13 @@ 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; - +use FFI\CData; 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\ArrayBackendInterface; +use Lisachenko\NativePhpMatrix\Backend\Backends; +use Lisachenko\NativePhpMatrix\Backend\Float64Buffer; use LogicException; - -use function sprintf; -use function str_starts_with; - +use ReflectionClass; use ZEngine\ClassExtension\Hook\CastObjectHook; use ZEngine\ClassExtension\Hook\CastType; use ZEngine\ClassExtension\Hook\CompareValuesHook; @@ -53,11 +36,20 @@ /** * 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. 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 + * Where those doubles live follows the driver. Under an accelerated driver a matrix owns a single, contiguous, + * row-major `double[rows * columns]` allocation, which a BLAS kernel or an OpenCL transfer reads as the raw + * pointer it is: nothing is packed on the way in, nothing is unpacked on the way out, and the buffer a kernel + * writes becomes the storage of the matrix the operator returns. Under the pure-PHP driver a matrix keeps a list of + * rows of PHP floats instead, because the interpreter walks an array two to three times faster than it walks + * `FFI\CData`. The form is chosen once, by the constructor, from the driver active at that moment. + * + * An operand whose form does not match the driver of an operation — it was built before a switch to another + * driver — is converted once for that operation, in bulk, and the result is stored in the form of the driver that + * computed it. Observable behaviour is the same in both forms: the cells are the same doubles. */ final class Matrix implements ObjectCastInterface, @@ -69,26 +61,57 @@ final class Matrix implements use ObjectCreateTrait; /** - * Matrix cells, stored as a list of rows + * Reflection used to build a matrix around cells a driver has just computed + * + * 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 non-empty-list> + * @var ReflectionClass|null */ - private readonly array $matrix; + private static ?ReflectionClass $reflection = null; + + /** + * Cells of this matrix, row after row, as float64 + * + * Either a row-major `double[rows * columns]` buffer, for the drivers that compute on native memory, or a list + * of rows of PHP floats, for the driver that computes in the interpreter — see the class docblock. A buffer 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::fromCells()} + * 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. + * + * @var CData|non-empty-list> + */ + private CData|array $cells; /** * Total number of rows in this matrix + * + * @var positive-int */ - private readonly int $rows; + private int $rows; /** * Total number of columns in this matrix + * + * @var positive-int */ - private readonly int $columns; + 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, in the storage form of the active driver: each cell is checked + * and then either written straight into a freshly allocated buffer, where FFI performs the int-to-double + * conversion natively, or appended to its row as a PHP float. There is no intermediate array and no second + * loop over the cells in either form. + * + * @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) { @@ -99,33 +122,32 @@ public function __construct(array $matrix) throw new InvalidArgumentException('Matrix should be a list of rows with sequential keys, starting from 0'); } - $columns = null; + $rows = count($matrix); + $columns = count(self::validateRow($matrix[0], 0)); + + $this->rows = $rows; + $this->columns = $columns; + + // The registry caches its answer, so asking it for every new matrix costs a static property read + if (Backends::resolve() instanceof ArrayBackendInterface) { + $this->cells = self::validateIntoRows($matrix, $columns); + + return; + } + + $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) { - 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)) { - throw new InvalidArgumentException( - sprintf('Matrix value at [%d][%d] should be either an int or a float', $rowIndex, $columnIndex), - ); + foreach (self::validateCells($row, $rowIndex, $columns) as $columnIndex => $cellValue) { + if (!is_int($cellValue) && !is_float($cellValue)) { + throw self::invalidCell($rowIndex, $columnIndex); } + // 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->cells = $buffer; } public function getRows(): int @@ -144,21 +166,27 @@ public function isSquare(): bool } /** - * Returns an underlying representation of this matrix + * Returns the cells of this matrix as a list of rows + * + * A matrix stored as rows hands them out as they are — PHP copies an array on write, so the caller cannot + * reach the storage through it. A buffer is unpacked on demand: that is the boundary between native memory and + * ordinary PHP, so a caller that only wants to compute never pays for it. * - * @return non-empty-list> + * @return non-empty-list> Cells, row after row */ public function toArray(): array { - return $this->matrix; + return $this->asRows(); } /** * 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 { @@ -166,98 +194,99 @@ 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]; - } - - $resultRow[] = $cellValue; - } - $result[] = $resultRow; + $backend = Backends::resolve(); + if ($backend instanceof ArrayBackendInterface) { + return self::fromCells( + $backend->multiply($this->asRows(), $multiplier->asRows(), $this->rows, $this->columns, $multiplier->columns), + $this->rows, + $multiplier->columns, + ); } - return new self($result); + return self::fromCells( + $backend->multiply($this->asBuffer(), $multiplier->asBuffer(), $this->rows, $this->columns, $multiplier->columns), + $this->rows, + $multiplier->columns, + ); } /** * Performs division by scalar value * * @param int|float $value Divider - * - * @return 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::resolve(); + if ($backend instanceof ArrayBackendInterface) { + return self::fromCells( + $backend->divideByScalar($this->asRows(), (float) $value, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } - return new self($result); + return self::fromCells( + $backend->divideByScalar($this->asBuffer(), (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 { - $result = []; - foreach ($this->matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue * $value; - } - $result[] = $resultRow; + $backend = Backends::resolve(); + if ($backend instanceof ArrayBackendInterface) { + return self::fromCells( + $backend->multiplyByScalar($this->asRows(), (float) $value, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } - return new self($result); + return self::fromCells( + $backend->multiplyByScalar($this->asBuffer(), (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 { - $result = []; - foreach ($this->matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue ** $value; - } - $result[] = $resultRow; + $backend = Backends::resolve(); + if ($backend instanceof ArrayBackendInterface) { + return self::fromCells( + $backend->powByScalar($this->asRows(), (float) $value, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } - return new self($result); + return self::fromCells( + $backend->powByScalar($this->asBuffer(), (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 { @@ -265,25 +294,30 @@ 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::resolve(); + if ($backend instanceof ArrayBackendInterface) { + return self::fromCells( + $backend->sum($this->asRows(), $value->asRows(), $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } - return new self($result); + return self::fromCells( + $backend->sum($this->asBuffer(), $value->asBuffer(), $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 { @@ -291,39 +325,43 @@ 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::resolve(); + if ($backend instanceof ArrayBackendInterface) { + return self::fromCells( + $backend->subtract($this->asRows(), $value->asRows(), $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } - return new self($result); + return self::fromCells( + $backend->subtract($this->asBuffer(), $value->asBuffer(), $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: a single `memcmp` over two buffers, the packed doubles of each row for + * two lists of rows, and a matrix of each form converts the rows side into a buffer first. All three are + * 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; - } - } + if (is_array($this->cells) && is_array($another->cells)) { + return Float64Buffer::identicalRows($this->cells, $another->cells); } - return true; + return Float64Buffer::identical($this->asBuffer(), $another->asBuffer(), $this->rows * $this->columns); } /** @@ -331,7 +369,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 { @@ -409,7 +447,7 @@ public static function __compare(CompareValuesHook $hook): int * Unlike the operation and comparison hooks this handler never throws: it runs inside an FFI * callback, and PHP escalates any exception crossing that boundary into an engine-level * fatal error. Cast types that are not implemented here defer to the default engine behaviour - * via {@see CastObjectHook::proceed()} — on the z-engine dev lines this package tracks that + * via {@see CastObjectHook::proceed()} — on the z-engine release lines this package tracks that * fall-through behaves exactly like an uninstalled handler, so failed numeric casts propagate * to the engine caller, which emits its own warning and substitutes the value 1. * @@ -485,13 +523,148 @@ public static function __getFields(GetPropertiesForHook $hook): array return get_mangled_object_vars($object); } + /** + * Builds a matrix around cells a driver has just computed + * + * Every operation ends here: a driver returns the cells it computed, in its own storage form, and they become + * the storage of the result as they are — a buffer without a single cell crossing back into PHP, rows without + * a single cell being copied. The constructor is bypassed because its job — validating input and converting it + * to float64 — is already done by construction. + * + * @param CData|non-empty-list> $cells Cells the caller hands over ownership of + * @param positive-int $rows Number of rows the cells form + * @param positive-int $columns Number of columns the cells form + */ + private static function fromCells(CData|array $cells, int $rows, int $columns): self + { + self::$reflection ??= new ReflectionClass(self::class); + + $matrix = self::$reflection->newInstanceWithoutConstructor(); + $matrix->cells = $cells; + $matrix->rows = $rows; + $matrix->columns = $columns; + + return $matrix; + } + + /** + * Returns the cells of this matrix as rows, converting a buffer if that is how they are stored + * + * @return non-empty-list> + */ + private function asRows(): array + { + $cells = $this->cells; + + return is_array($cells) ? $cells : Float64Buffer::toRows($cells, $this->rows, $this->columns); + } + + /** + * Returns the cells of this matrix as a buffer, converting rows if that is how they are stored + */ + private function asBuffer(): CData + { + $cells = $this->cells; + + return $cells instanceof CData ? $cells : Float64Buffer::fromRows($cells, $this->rows * $this->columns); + } + + /** + * Validates the constructor argument into rows of PHP floats, the storage of the pure-PHP driver + * + * @param non-empty-list $matrix Rows of the constructor argument + * @param positive-int $columns Number of columns every row must have + * + * @return non-empty-list> + * + * @throws InvalidArgumentException When a row or a cell is invalid + */ + private static function validateIntoRows(array $matrix, int $columns): array + { + $result = []; + foreach ($matrix as $rowIndex => $row) { + $resultRow = []; + foreach (self::validateCells($row, $rowIndex, $columns) as $columnIndex => $cellValue) { + if (!is_int($cellValue) && !is_float($cellValue)) { + throw self::invalidCell($rowIndex, $columnIndex); + } + // Appending as a float is where an int becomes one: the single conversion this storage makes + $resultRow[] = (float) $cellValue; + } + $result[] = $resultRow; + } + + /** @var non-empty-list> */ + return $result; + } + + /** + * Checks that one row of the constructor argument has the expected shape, and returns its cells + * + * @param mixed $row Candidate row, straight out of the argument + * @param int $rowIndex Position of the row, for the message + * @param positive-int $columns Number of columns every row must have + * + * @return non-empty-list Cells of the row, not validated yet + * + * @throws InvalidArgumentException When the row is not a list of the expected length + */ + private static function validateCells(mixed $row, int $rowIndex, int $columns): array + { + $cells = self::validateRow($row, $rowIndex); + if (count($cells) !== $columns) { + throw new InvalidArgumentException('All matrix rows should have the same number of columns'); + } + + return $cells; + } + + /** + * Builds the exception for a cell that is neither an int nor a float + * + * @param int $rowIndex Row of the cell + * @param int $columnIndex Column of the cell + */ + private static function invalidCell(int $rowIndex, int $columnIndex): InvalidArgumentException + { + return new InvalidArgumentException( + sprintf('Matrix value at [%d][%d] should be either an int or a float', $rowIndex, $columnIndex), + ); + } + + /** + * 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 new file mode 100644 index 0000000..a2efecb --- /dev/null +++ b/tests/Functional/include/MarkerBackend.inc @@ -0,0 +1,80 @@ + + * + * 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\ArrayBackendInterface; +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. + * + * It also stands for the array storage contract: matrices built while it is active store rows of floats, the + * operands arrive as those rows, and the result is a list of rows of floats this driver built itself. + */ +final class MarkerBackend implements ArrayBackendInterface +{ + public const float MARKER = 1000.0; + + 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) { + $resultRow = []; + foreach ($row as $columnIndex => $cellValue) { + $resultRow[] = $cellValue + $right[$rowIndex][$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, float $value, int $rows, int $columns): array + { + return $this->delegate->multiplyByScalar($matrix, $value, $rows, $columns); + } + + public function divideByScalar(array $matrix, float $value, int $rows, int $columns): array + { + return $this->delegate->divideByScalar($matrix, $value, $rows, $columns); + } + + public function powByScalar(array $matrix, 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..ef0ecf3 --- /dev/null +++ b/tests/Functional/include/UnavailableBackend.inc @@ -0,0 +1,61 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +use FFI\CData; +use Lisachenko\NativePhpMatrix\Backend\BufferBackendInterface; +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. 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 BufferBackendInterface +{ + public function isAvailable(): bool + { + return false; + } + + public function sum(CData $left, CData $right, int $rows, int $columns): CData + { + return Float64Buffer::copyOf($left, $rows * $columns); + } + + public function subtract(CData $left, CData $right, int $rows, int $columns): CData + { + return Float64Buffer::copyOf($left, $rows * $columns); + } + + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData + { + return Float64Buffer::allocate($rows * $columns); + } + + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + return Float64Buffer::copyOf($matrix, $rows * $columns); + } + + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + return Float64Buffer::copyOf($matrix, $rows * $columns); + } + + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + return Float64Buffer::copyOf($matrix, $rows * $columns); + } +} diff --git a/tests/Functional/include/skipif_blas.inc b/tests/Functional/include/skipif_blas.inc new file mode 100644 index 0000000..a7e6a76 --- /dev/null +++ b/tests/Functional/include/skipif_blas.inc @@ -0,0 +1,29 @@ + + * + * 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; +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(Driver::Blas->value, 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 - 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..65f34c5 --- /dev/null +++ b/tests/Functional/include/skipif_clblast.inc @@ -0,0 +1,29 @@ + + * + * 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; +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(Driver::Clblast->value, 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/testAutoRecomputesInPhpWhenAcceleratedDriverFails.phpt b/tests/Functional/testAutoRecomputesInPhpWhenAcceleratedDriverFails.phpt new file mode 100644 index 0000000..627bc87 --- /dev/null +++ b/tests/Functional/testAutoRecomputesInPhpWhenAcceleratedDriverFails.phpt @@ -0,0 +1,90 @@ +--TEST-- +Automatic routing recomputes an operation on the pure PHP driver when the accelerated driver fails at operation time +--INI-- +ffi.enable=1 +opcache.jit=off +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- + new FailingBufferBackend()); + +// Matrices built now are buffers, so the fallback has to read them as rows and hand a buffer back. Non-square +// shapes make a transposed dimension in that conversion visible +$left = new Matrix([[1, 2, 3], [4, 5, 6]]); +$right = new Matrix([[1, 0], [0, 1], [1, 1]]); + +// An exception crossing the operator hook would be a fatal error: reaching the dumps proves it was caught +var_dump(($left * $right)->toArray()); +var_dump(($left + $left)->toArray() === ($left * 2)->toArray()); +var_dump(($left - $left) == new Matrix([[0, 0, 0], [0, 0, 0]])); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(4) + [1]=> + float(5) + } + [1]=> + array(2) { + [0]=> + float(10) + [1]=> + float(11) + } +} +bool(true) +bool(true) diff --git a/tests/Functional/testBackendDefaultsToAuto.phpt b/tests/Functional/testBackendDefaultsToAuto.phpt new file mode 100644 index 0000000..2f1c1a1 --- /dev/null +++ b/tests/Functional/testBackendDefaultsToAuto.phpt @@ -0,0 +1,21 @@ +--TEST-- +Matrix arithmetic defaults to automatic backend routing with pure PHP always available +--INI-- +ffi.enable=1 +opcache.jit=off +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- + +--EXPECT-- +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Auto) +bool(true) diff --git a/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt b/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt new file mode 100644 index 0000000..ab4620e --- /dev/null +++ b/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt @@ -0,0 +1,33 @@ +--TEST-- +The NATIVE_PHP_MATRIX_BACKEND environment variable pins the backend at bootstrap +--INI-- +ffi.enable=1 +opcache.jit=off +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=php +--FILE-- +toArray()); +?> +--EXPECT-- +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Php) +array(1) { + [0]=> + array(2) { + [0]=> + float(2) + [1]=> + float(3) + } +} diff --git a/tests/Functional/testBlasAddsMatricesAsFloats.phpt b/tests/Functional/testBlasAddsMatricesAsFloats.phpt new file mode 100644 index 0000000..74a1de8 --- /dev/null +++ b/tests/Functional/testBlasAddsMatricesAsFloats.phpt @@ -0,0 +1,37 @@ +--TEST-- +The blas backend adds matrices and returns floats even for integer input +--INI-- +ffi.enable=1 +opcache.jit=off +--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..4ed413b --- /dev/null +++ b/tests/Functional/testBlasDividesMatrixByScalar.phpt @@ -0,0 +1,41 @@ +--TEST-- +The blas backend divides a matrix by a scalar by scaling with its reciprocal +--INI-- +ffi.enable=1 +opcache.jit=off +--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..fa91cb8 --- /dev/null +++ b/tests/Functional/testBlasMatchesPhpBackendResults.phpt @@ -0,0 +1,51 @@ +--TEST-- +The blas backend produces the same product as the pure PHP backend +--INI-- +ffi.enable=1 +opcache.jit=off +--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..bfced24 --- /dev/null +++ b/tests/Functional/testBlasMultipliesFloatMatrices.phpt @@ -0,0 +1,40 @@ +--TEST-- +The blas backend multiplies float matrices with cblas_dgemm +--INI-- +ffi.enable=1 +opcache.jit=off +--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..42b06cb --- /dev/null +++ b/tests/Functional/testBlasMultipliesMatrixByScalar.phpt @@ -0,0 +1,39 @@ +--TEST-- +The blas backend multiplies a matrix by a scalar with cblas_dscal +--INI-- +ffi.enable=1 +opcache.jit=off +--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..acbcd3f --- /dev/null +++ b/tests/Functional/testBlasPowsMatrixByScalar.phpt @@ -0,0 +1,40 @@ +--TEST-- +The blas backend raises a matrix to a power in floats +--INI-- +ffi.enable=1 +opcache.jit=off +--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..a43a619 --- /dev/null +++ b/tests/Functional/testBlasSubtractsMatrices.phpt @@ -0,0 +1,40 @@ +--TEST-- +The blas backend subtracts matrices with a negatively scaled cblas_daxpy +--INI-- +ffi.enable=1 +opcache.jit=off +--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) + } +} diff --git a/tests/Functional/testCanAddMatrices.phpt b/tests/Functional/testCanAddMatrices.phpt index 24d47ed..cc00049 100644 --- a/tests/Functional/testCanAddMatrices.phpt +++ b/tests/Functional/testCanAddMatrices.phpt @@ -21,10 +21,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 2305f3a..e0bc4f1 100644 --- a/tests/Functional/testCanCastMatrixToArray.phpt +++ b/tests/Functional/testCanCastMatrixToArray.phpt @@ -19,19 +19,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/testCanDivideMatrixByNumber.phpt b/tests/Functional/testCanDivideMatrixByNumber.phpt index 9d8bd84..79ee910 100644 --- a/tests/Functional/testCanDivideMatrixByNumber.phpt +++ b/tests/Functional/testCanDivideMatrixByNumber.phpt @@ -20,10 +20,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 a53cae2..c2de7f6 100644 --- a/tests/Functional/testCanMultiplyCompatibleMatrices.phpt +++ b/tests/Functional/testCanMultiplyCompatibleMatrices.phpt @@ -21,6 +21,6 @@ array(1) { [0]=> array(1) { [0]=> - int(32) + float(32) } } diff --git a/tests/Functional/testCanMultiplyMatrixByNumber.phpt b/tests/Functional/testCanMultiplyMatrixByNumber.phpt index 76c0e92..2248087 100644 --- a/tests/Functional/testCanMultiplyMatrixByNumber.phpt +++ b/tests/Functional/testCanMultiplyMatrixByNumber.phpt @@ -20,10 +20,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 494c811..5e2666c 100644 --- a/tests/Functional/testCanMultiplyNumberByMatrix.phpt +++ b/tests/Functional/testCanMultiplyNumberByMatrix.phpt @@ -20,10 +20,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/testCanOperateAcrossDriverSwitch.phpt b/tests/Functional/testCanOperateAcrossDriverSwitch.phpt new file mode 100644 index 0000000..4e287ba --- /dev/null +++ b/tests/Functional/testCanOperateAcrossDriverSwitch.phpt @@ -0,0 +1,112 @@ +--TEST-- +Matrices built under one driver can be operated on and compared under another +--INI-- +ffi.enable=1 +opcache.jit=off +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=php +--SKIPIF-- + +--FILE-- +toArray()); +var_dump(($rows * $buffer)->toArray()); + +// The pure-PHP driver computes, so the buffer operand is converted into rows once for each operation +Backends::use(Driver::Php); +var_dump(($buffer - $rows)->toArray()); +var_dump(($buffer / 2)->toArray()); + +// Equality does not depend on the storage either +var_dump($rows == new Matrix([[1, 2], [3, 4]])); +Backends::use(Driver::Blas); +var_dump($rows == new Matrix([[1, 2], [3, 4]])); +var_dump($rows == $buffer); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(6) + [1]=> + float(8) + } + [1]=> + array(2) { + [0]=> + float(10) + [1]=> + float(12) + } +} +array(2) { + [0]=> + array(2) { + [0]=> + float(19) + [1]=> + float(22) + } + [1]=> + array(2) { + [0]=> + float(43) + [1]=> + float(50) + } +} +array(2) { + [0]=> + array(2) { + [0]=> + float(4) + [1]=> + float(4) + } + [1]=> + array(2) { + [0]=> + float(4) + [1]=> + float(4) + } +} +array(2) { + [0]=> + array(2) { + [0]=> + float(2.5) + [1]=> + float(3) + } + [1]=> + array(2) { + [0]=> + float(3.5) + [1]=> + float(4) + } +} +bool(true) +bool(true) +bool(false) diff --git a/tests/Functional/testCanPowMatrixByNumber.phpt b/tests/Functional/testCanPowMatrixByNumber.phpt index f294423..3ffe0ef 100644 --- a/tests/Functional/testCanPowMatrixByNumber.phpt +++ b/tests/Functional/testCanPowMatrixByNumber.phpt @@ -20,10 +20,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 new file mode 100644 index 0000000..56005f5 --- /dev/null +++ b/tests/Functional/testCanRegisterThirdPartyBackend.phpt @@ -0,0 +1,37 @@ +--TEST-- +A third-party backend can be registered and serves the overloaded operators +--INI-- +ffi.enable=1 +opcache.jit=off +--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]=> + float(1011) + [1]=> + float(1022) + } +} diff --git a/tests/Functional/testCanSubtractMatrices.phpt b/tests/Functional/testCanSubtractMatrices.phpt index 9360532..7f6e8c0 100644 --- a/tests/Functional/testCanSubtractMatrices.phpt +++ b/tests/Functional/testCanSubtractMatrices.phpt @@ -21,10 +21,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/testClblastAddsMatricesAsFloats.phpt b/tests/Functional/testClblastAddsMatricesAsFloats.phpt new file mode 100644 index 0000000..fa9855c --- /dev/null +++ b/tests/Functional/testClblastAddsMatricesAsFloats.phpt @@ -0,0 +1,36 @@ +--TEST-- +The clblast backend adds matrices and returns floats even for integer input +--INI-- +ffi.enable=1 +opcache.jit=off +--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..4f82337 --- /dev/null +++ b/tests/Functional/testClblastMatchesPhpBackendResults.phpt @@ -0,0 +1,51 @@ +--TEST-- +The clblast backend produces the same product as the pure PHP backend +--INI-- +ffi.enable=1 +opcache.jit=off +--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..564c1a6 --- /dev/null +++ b/tests/Functional/testClblastMultipliesFloatMatrices.phpt @@ -0,0 +1,40 @@ +--TEST-- +The clblast backend multiplies float matrices on an OpenCL device +--INI-- +ffi.enable=1 +opcache.jit=off +--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..e95b26c --- /dev/null +++ b/tests/Functional/testClblastMultipliesMatrixByScalar.phpt @@ -0,0 +1,39 @@ +--TEST-- +The clblast backend scales a matrix by a scalar on an OpenCL device +--INI-- +ffi.enable=1 +opcache.jit=off +--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) + } +} diff --git a/tests/Functional/testComparesCellsBitExactly.phpt b/tests/Functional/testComparesCellsBitExactly.phpt new file mode 100644 index 0000000..353a372 --- /dev/null +++ b/tests/Functional/testComparesCellsBitExactly.phpt @@ -0,0 +1,27 @@ +--TEST-- +Matrices are compared by the bits of their float64 cells, whichever storage holds them +--INI-- +ffi.enable=1 +opcache.jit=off +--FILE-- + +--EXPECT-- +bool(true) +bool(false) +bool(true) diff --git a/tests/Functional/testFailsOnSelectingBackendWithoutStorage.phpt b/tests/Functional/testFailsOnSelectingBackendWithoutStorage.phpt new file mode 100644 index 0000000..5b3dd78 --- /dev/null +++ b/tests/Functional/testFailsOnSelectingBackendWithoutStorage.phpt @@ -0,0 +1,37 @@ +--TEST-- +Selecting a registered backend that implements no storage contract fails with a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- + new StoragelessBackend()); + +try { + Backends::use('storageless'); +} catch (InvalidArgumentException $exception) { + echo $exception->getMessage(), PHP_EOL; +} +var_dump(Backends::active()); +?> +--EXPECT-- +Matrix backend "storageless" implements neither Lisachenko\NativePhpMatrix\Backend\ArrayBackendInterface nor Lisachenko\NativePhpMatrix\Backend\BufferBackendInterface, so no matrix can be stored for it +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Auto) diff --git a/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt b/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt new file mode 100644 index 0000000..55119ea --- /dev/null +++ b/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt @@ -0,0 +1,35 @@ +--TEST-- +Selecting a registered backend that is unavailable fails with a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +--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 +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Auto) diff --git a/tests/Functional/testFailsOnSelectingUnknownBackend.phpt b/tests/Functional/testFailsOnSelectingUnknownBackend.phpt new file mode 100644 index 0000000..f9acd40 --- /dev/null +++ b/tests/Functional/testFailsOnSelectingUnknownBackend.phpt @@ -0,0 +1,26 @@ +--TEST-- +Selecting a backend that is not registered fails with a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- +getMessage(), PHP_EOL; +} +var_dump(Backends::active()); +?> +--EXPECTF-- +Unknown matrix backend "quantum", registered ones are: %s +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Auto) diff --git a/tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt b/tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt new file mode 100644 index 0000000..54f3634 --- /dev/null +++ b/tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt @@ -0,0 +1,24 @@ +--TEST-- +An unknown backend in the environment fails while booting, as a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=quantum +--FILE-- +getMessage(), PHP_EOL; +} +?> +--EXPECTF-- +InvalidArgumentException +Unknown matrix backend "quantum", registered ones are: %s diff --git a/tests/Functional/testKeepsDefaultDebugOutput.phpt b/tests/Functional/testKeepsDefaultDebugOutput.phpt index fad83cf..97215f5 100644 --- a/tests/Functional/testKeepsDefaultDebugOutput.phpt +++ b/tests/Functional/testKeepsDefaultDebugOutput.phpt @@ -3,6 +3,8 @@ Debugging a Matrix keeps the default engine property table with visibility marke --INI-- ffi.enable=1 opcache.jit=off +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=php --FILE-- \n array(1) {")); var_dump(str_contains($output, '["rows":"Lisachenko\NativePhpMatrix\Matrix":private]')); var_dump(str_contains($output, '["columns":"Lisachenko\NativePhpMatrix\Matrix":private]')); ?> diff --git a/tests/Functional/testMatrixStoresFloat64.phpt b/tests/Functional/testMatrixStoresFloat64.phpt new file mode 100644 index 0000000..98c4164 --- /dev/null +++ b/tests/Functional/testMatrixStoresFloat64.phpt @@ -0,0 +1,58 @@ +--TEST-- +Matrix accepts integer literals and stores every cell as float64 +--INI-- +ffi.enable=1 +opcache.jit=off +--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] diff --git a/tests/Functional/testStorageFollowsDriver.phpt b/tests/Functional/testStorageFollowsDriver.phpt new file mode 100644 index 0000000..8b2ed33 --- /dev/null +++ b/tests/Functional/testStorageFollowsDriver.phpt @@ -0,0 +1,60 @@ +--TEST-- +A matrix stores its cells in the form of the driver active when it is built, and results in the form of the driver that computed them +--INI-- +ffi.enable=1 +opcache.jit=off +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=php +--SKIPIF-- + +--FILE-- + get_debug_type($matrix->cells), null, Matrix::class); + +Backends::use(Driver::Php); +$rows = new Matrix([[1, 2], [3, 4]]); +var_dump($storageOf($rows)); +var_dump($storageOf($rows * $rows)); + +Backends::use(Driver::Blas); +$buffer = new Matrix([[1, 2], [3, 4]]); +var_dump($storageOf($buffer)); +var_dump($storageOf($buffer * $buffer)); + +// The form is invisible from the outside: the same cells, the same products, whichever driver stored them +var_dump($rows->toArray() === $buffer->toArray()); +var_dump(($buffer * $buffer)->toArray()); +?> +--EXPECT-- +string(5) "array" +string(5) "array" +string(9) "FFI\CData" +string(9) "FFI\CData" +bool(true) +array(2) { + [0]=> + array(2) { + [0]=> + float(7) + [1]=> + float(10) + } + [1]=> + array(2) { + [0]=> + float(15) + [1]=> + float(22) + } +}