diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index ab149ca..61bce54 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -40,13 +40,17 @@ Review against the checklist below, in this order of severity. memory corruption. - CI must run on PHP 8.4 with `ffi.enable=1` and `opcache.jit=off`. -## 3. PHPStan generics honesty (blocking) +## 3. float64 storage honesty (blocking) -`Matrix` is `@template-covariant T of int|float`. +`Matrix` has no generic parameter: cells are `float`, stored in a native +`double[rows * columns]` buffer. -- Arithmetic that can widen the element type — division, exponentiation, and any mixed - int/float input — must be annotated as returning `Matrix`, not `Matrix`. -- Flag any annotation that claims to preserve `T` through an operation whose maths does not. +- Flag any reintroduced `Matrix` / `Matrix` annotation, or any signature claiming a + method returns integer cells. An integer literal in the constructor is input syntax only. +- Flag userland cast loops over cells. The constructor writing an int into a double slot is + the only conversion there should be; anything else is the loop the buffers exist to remove. +- Drivers must treat their operand buffers as read-only and return a freshly allocated one. + Returning an operand, or writing into one, is a blocking finding. - New code must be clean at PHPStan level max; suppressing with `@phpstan-ignore` needs an inline justification. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3be3e9..4f2f59b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,19 @@ jobs: fail-fast: false matrix: php: ['8.4', '8.5'] + # The suite fails on skipped tests, so every acceleration library is installed here: a skipped backend test + # means a broken environment rather than an absent GPU. PoCL provides the OpenCL device, on the CPU + env: + NATIVE_PHP_MATRIX_CL_DEVICE: cpu steps: - uses: actions/checkout@v7 + - name: Install acceleration libraries + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libopenblas0 libclblast1 ocl-icd-libopencl1 pocl-opencl-icd + - name: Set up PHP uses: shivammathur/setup-php@v2 with: @@ -37,6 +47,49 @@ 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'] + # 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 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 9944bc8..e7e967a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,13 +72,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 @@ -131,22 +140,120 @@ 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. +- `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 buffer, validation, dimensions, the __doOperation/__compare hooks; + the arithmetic itself is delegated to a backend driver +src/Backend/ the interchangeable drivers and the registry that picks one +tests/Functional/*.phpt the functional suite, one behaviour per file +tests/Functional/include/ shared SKIPIF probes and backend stubs (.inc — never collected as tests) +bench/benchmark.php driver comparison CLI, "composer bench" +bootstrap.php Core::init(), installExtensionHandlers() on Matrix, then + Backends::bootFromEnvironment() — order matters; runs automatically via + Composer's "files" autoload +phpunit.xml.dist PHPUnit 12 config (suite points at tests/, suffix .phpt) +phpstan.dist.neon static analysis config, level max +.php-cs-fixer.dist.php coding standards config (PER-CS2.0) +.github/workflows/ci.yml jobs: tests, gpu-path, static-analysis, coding-standards — PHP 8.4 and 8.5 +.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 operand +**buffers** with the dimensions alongside them and return a freshly allocated buffer. + +``` +src/Backend/BackendInterface.php the driver contract: six operations, plus isAvailable() +src/Backend/Backends.php registry, selection and the auto-routing policy +src/Backend/Driver.php string-backed enum naming the built-in drivers plus "auto" +src/Backend/Float64Buffer.php allocate / copy / compare the double[] blocks everything speaks +src/Backend/PhpBackend.php the interpreted loops, now over buffer offsets +src/Backend/BlasBackend.php OpenBLAS over FFI (CPU), called on the stored buffers +src/Backend/ClblastBackend.php CLBlast over OpenCL (GPU, or CPU via PoCL) +src/Backend/AcceleratedBackendTrait.php the pow loop, the one operation no BLAS provides +src/Backend/FallbackBackend.php decorator: degrade to another driver instead of failing +src/Backend/BackendNotAvailableException.php catchable, thrown at selection time only +``` + +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.** A matrix stores `double` cells, every driver reads and + writes `double` cells. There is no integer path to preserve and no cast to make: + the constructor is the only place a value changes type, and it does so by writing + an int into a double slot, which FFI converts natively. Never add a userland cast + loop — that is exactly what the buffers exist to eliminate. +- **Operands are read-only, results are fresh.** The buffers a driver receives are + the storage of matrices the caller still holds. A kernel that accumulates into an + argument (`daxpy`, `dscal`) must copy it into the result buffer first, with + `Float64Buffer::copyOf()`. Never return an operand as the result. This is also what + makes `FallbackBackend`'s recomputation safe. +- **Auto-routing uses BLAS for everything, never a GPU.** `auto` picks the OpenBLAS + driver whenever it probes available, for every operation including element-wise + ones, and the pure-PHP driver otherwise. A GPU is never chosen automatically. + +An availability probe performs a real 1×1 operation, so it cannot disagree with what +an operation would do a moment later. Probes are cached per process. ## Hook contracts @@ -177,17 +284,25 @@ 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.x-dev || 8.5.x-dev`** — one dev line @@ -200,3 +315,9 @@ proposing a change rather than hand-formatting. for it, and never one without the other. - When z-engine ships stable releases for the supported minors, the constraint and the root stability flags should be tightened in a single change. +- **System libraries are never Composer requirements.** OpenBLAS, CLBlast and an + OpenCL runtime are optional, discovered at runtime by the drivers that need them, + and listed under `suggest`. The package must install and its suite must pass — with + `--do-not-fail-on-skipped` — on a machine that has none of them; the pure-PHP driver + is always available. Add a new accelerated driver the same way: lazy load, probe, + degrade. diff --git a/README.md b/README.md index 359638f..fc36d88 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,135 @@ The trick is [lisachenko/z-engine](https://github.com/lisachenko/z-engine), whic | ➗ | **Scalar division** | `$a / 2` | | 🔺 | **Scalar exponentiation** — element-wise power | `$a ** 2` | | 🟰 | **Equality** — strict element-wise comparison | `$a == $b`, `$a != $b` | -| 🛡️ | **Type-safe by design** — PHPStan generics `Matrix` / `Matrix`, checked at level max | `Matrix` | +| 🧬 | **float64 storage** — cells live in a native `double[]`, not a PHP array | `new Matrix([[1, 2]])` | -Every operation returns a **new** `Matrix`; the class is `final` and its state is `readonly`, so nothing is ever mutated in place. The constructor validates that you passed a rectangular list of rows holding only `int`/`float` cells and raises a catchable `InvalidArgumentException` when it does not. +Every operation returns a **new** `Matrix`; the class is `final` and nothing is ever mutated in place. The constructor validates that you passed a rectangular list of rows holding only `int`/`float` cells and raises a catchable `InvalidArgumentException` when it does not. Operator-level failures are a different story, and it is worth being blunt about it: a dimension mismatch (`InvalidArgumentException`) or an unimplemented operand combination such as `$a - 2` (`LogicException`) is raised *inside an FFI callback*, and PHP does not allow an exception to cross that boundary. The engine prints the exception and then halts with `Fatal error: Throwing from FFI callbacks is not allowed`. You cannot `try`/`catch` it — check your dimensions before you multiply. -Generics are honest about arithmetic: `Matrix` divided by `2` is a `Matrix`, because PHP's `/` widens. Nothing pretends to preserve `T` where the maths does not. +### Cells are float64 + +A `Matrix` does not hold an array of rows. It owns one contiguous, row-major `double[rows * columns]` allocation — the exact shape a BLAS kernel or an OpenCL buffer wants — so an operator can hand its operands to a driver as raw pointers, with nothing packed on the way in and nothing unpacked on the way out. + +Integers are accepted as input and stored as the doubles they convert to, which is what `numpy.array([[1, 2]])` does when it reports `dtype=float64`: + +```php +$m = new Matrix([[1, 2], [3, 4]]); +$m->toArray(); // [[1.0, 2.0], [3.0, 4.0]] — ints in, float64 out +echo $m; // [1, 2] / [3, 4] — integral floats still print without decimals +``` + +So there is one cell type and one set of results, whichever driver computed them. Equality is a bit-exact `memcmp` over the two buffers rather than a loop. + +## ⚡ Acceleration + +The operators are the interesting part; the arithmetic underneath them is now interchangeable. `Matrix` asks a registry which **driver** should carry out an operation and hands it the operand buffers themselves — validation, dimensions and object identity never leave the class. Three drivers ship with the package: + +| Driver | Runs on | Uses | Notes | +|---|---|---|---| +| `php` | the interpreter | ordinary PHP loops over the buffer | Always available, needs no shared library | +| `blas` | CPU | OpenBLAS `cblas_dgemm` / `cblas_daxpy` / `cblas_dscal` over FFI | Double precision, needs the OpenBLAS shared library | +| `clblast` | GPU | [CLBlast](https://github.com/CNugteren/CLBlast) on OpenCL | NVIDIA, AMD, Intel — including laptop iGPUs — or the CPU through PoCL | + +Same interface for everyone else: `cuBLAS`, a `ggml` driver for the Vulkan route, and Metal are all a `BackendInterface` implementation away — **PRs welcome**. + +### Choosing a driver + +```bash +NATIVE_PHP_MATRIX_BACKEND=blas php your-script.php # php | blas | clblast | auto (default) +NATIVE_PHP_MATRIX_CL_DEVICE=cpu php your-script.php # gpu (default) | cpu | all — clblast only +``` + +```php +use Lisachenko\NativePhpMatrix\Backend\Backends; +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, and falls back to the pure-PHP driver when it is not. It never selects a GPU on your behalf — moving data across a bus is a decision, not a default. If an accelerated driver fails mid-operation, `auto` recomputes in pure PHP rather than taking down the request. + +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. + +## 🤖 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 | 10.71 ms (0.0 GFLOP/s) | 0.06 ms (8.8 GFLOP/s) | 0.75 ms (0.7 GFLOP/s) | ×180.7 | ×14.2 | +| Multiplication `$a * $b` | 128×128 | 83.63 ms (0.1 GFLOP/s) | 0.15 ms (27.9 GFLOP/s) | 1.31 ms (3.2 GFLOP/s) | ×557.2 | ×63.8 | +| Multiplication `$a * $b` | 256×256 | 670.35 ms (0.1 GFLOP/s) | 0.46 ms (73.6 GFLOP/s) | 3.47 ms (9.7 GFLOP/s) | ×1,471.3 | ×193.0 | +| Multiplication `$a * $b` | 512×512 | 5,413.33 ms (0.0 GFLOP/s) | 3.59 ms (74.8 GFLOP/s) | 21.76 ms (12.3 GFLOP/s) | ×1,508.2 | ×248.7 | +| Multiplication `$a * $b` | 1024×1024 | 43,561.10 ms (0.0 GFLOP/s) | 22.67 ms (94.7 GFLOP/s) | 159.40 ms (13.5 GFLOP/s) | ×1,921.4 | ×273.3 | + +The `blas` column is now the kernel and almost nothing else — 1024×1024 went from 113.61 ms to 22.67 ms once the operands stopped being packed and unpacked around it. The `php` column moved the opposite way for the same reason, and the ratios should be read with that in mind: they are large partly because the interpreted fallback got slower. + +Element-wise operations, which used to be the honest caveat of this table and are not one any more — with the cells already in the buffer, `cblas_daxpy` is called on the memory the matrices occupy and there is nothing left to marshal: + +| Operation | Size | `php` | `blas` | `clblast` | `blas` speed-up | `clblast` speed-up | +| --- | --- | --- | --- | --- | --- | --- | +| Addition `$a + $b` | 64×64 | 0.21 ms | 0.05 ms | 0.71 ms | ×4.0 | ×0.3 | +| Addition `$a + $b` | 512×512 | 13.14 ms | 2.15 ms | 7.97 ms | ×6.1 | ×1.6 | +| Scaling `$a * 2.5` | 64×64 | 0.16 ms | 0.07 ms | 0.53 ms | ×2.3 | ×0.3 | +| Scaling `$a * 2.5` | 512×512 | 10.23 ms | 1.82 ms | 2.92 ms | ×5.6 | ×3.5 | + +PHP 8.5.9 on Linux x86_64, Intel® Xeon® @ 2.80 GHz (shared cloud container), OpenBLAS 0.3.26, CLBlast 1.6.2 on PoCL 5.0 with `NATIVE_PHP_MATRIX_CL_DEVICE=cpu`. Median of 5 runs, one warm-up discarded, timing the whole PHP-level operation from operator to finished `Matrix`. + +For scale, the same 512×512 addition on the previous array-backed storage took 25.27 ms on `blas` and 8.41 ms on `php`: moving the cells into a native buffer made the accelerated path about **twelve times** faster and turned a ×0.3 penalty into a ×6.1 win. The `php` column moved the other way — walking `FFI\CData` costs more than walking a PHP array — which is the trade this design makes deliberately: the fallback driver gets slower so that every other driver stops paying for conversion. + +**These numbers are from a shared container without a GPU** — the `clblast` column is CLBlast running on the CPU through PoCL, which is a portability proof, not a GPU benchmark. On real hardware the GPU column is a different story, and the OpenBLAS column depends heavily on core count. Measure your own: + +```bash +composer bench +composer bench -- --sizes=64,128,256,512,1024 --ops=gemm --repeat=5 --markdown +``` ## How it works @@ -49,9 +171,9 @@ 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 operand buffers. -The maths is plain PHP. The magic is only in getting the engine to call it. +The maths is plain PHP — or plain BLAS, if you asked for it. The magic is only in getting the engine to call it. ## Requirements @@ -174,7 +296,9 @@ Tracked as [GitHub issues](https://github.com/lisachenko/native-php-matrix/issue - **`count($matrix)`** — row count through `Countable`, installed at the engine level - **`foreach` iteration** — row-by-row traversal via the `get_iterator` handler - **Friendly `var_dump()`** — a `get_debug_info` handler that prints the matrix instead of its internals -- **FFI BLAS backend** — hand multiplication off to a real BLAS library for performance, keeping the pure-PHP path as the fallback +- ~~**FFI BLAS backend**~~ — **done**: see [⚡ Acceleration](#-acceleration), with OpenBLAS on the CPU and CLBlast on the GPU behind an interchangeable driver interface +- **More BLAS coverage** — transposition, `gemv`, in-place accumulation and the fused `$input * $weights + $bias` of an inference layer +- **More drivers** — cuBLAS, a `ggml` driver for the Vulkan route, Metal — same `BackendInterface`, PRs welcome ## Contributing @@ -184,6 +308,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..a708338 --- /dev/null +++ b/bench/benchmark.php @@ -0,0 +1,438 @@ + + * + * 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 where the crossover sits for *this* library, packing and unpacking included. Every + * measurement therefore times the whole PHP-level operation: converting the rows into a buffer, calling the + * kernel, reading the result back and validating it into a new Matrix. That is what a caller actually pays. + */ +final class Benchmark +{ + /** + * Matrix dimensions measured when --sizes is not given + * + * @var list + */ + private const array DEFAULT_SIZES = [64, 128, 256, 512]; + + /** + * Backends measured when --backends is not given + * + * @var list + */ + private const array DEFAULT_BACKENDS = [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 a0033e9..933e584 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,12 @@ "name": "lisachenko/native-php-matrix", "description": "PHP extension that provides Matrix class powered with overloaded operators", "type": "library", - "keywords": ["ffi", "matrix", "operator-overloading", "z-engine"], + "keywords": [ + "ffi", + "matrix", + "operator-overloading", + "z-engine" + ], "license": "MIT", "authors": [ { @@ -22,20 +27,28 @@ "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" diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 487ad35..6a75985 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -3,5 +3,65 @@ 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 loops of the drivers, the storage of Matrix and the buffer helper itself + - + identifier: offsetAccess.nonOffsetAccessible + path: src/Backend/AcceleratedBackendTrait.php + - + identifier: return.type + path: src/Backend/AcceleratedBackendTrait.php + - + identifier: binaryOp.invalid + path: src/Backend/AcceleratedBackendTrait.php + # The pure-PHP driver is nothing but those loops now that cells live in a buffer rather than in arrays + - + identifier: offsetAccess.nonOffsetAccessible + path: src/Backend/PhpBackend.php + - + identifier: return.type + path: src/Backend/PhpBackend.php + - + identifier: binaryOp.invalid + path: src/Backend/PhpBackend.php + # Matrix writes its cells into the buffer as it validates them, and reads them back in toArray() + - + identifier: offsetAccess.nonOffsetAccessible + path: src/Matrix.php + - + identifier: assign.propertyType + path: src/Matrix.php + # FFI::new() is callable statically, although the stubs declare it as an instance method, and the single + # cell accessors of the buffer helper index a CData like the C array it is + - + identifier: method.staticCall + path: src/Backend/Float64Buffer.php + - + identifier: return.type + path: src/Backend/Float64Buffer.php + # The cblas_* methods exist only after FFI::cdef() has parsed the inline C declarations of this driver, so + # no static analysis can see them on the FFI instance + - + 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/BackendInterface.php b/src/Backend/BackendInterface.php new file mode 100644 index 0000000..a92f1de --- /dev/null +++ b/src/Backend/BackendInterface.php @@ -0,0 +1,132 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use FFI\CData; + +/** + * Contract of an interchangeable matrix arithmetic driver + * + * A backend is a numeric kernel and nothing else: it receives the operands as raw row-major `double[]` buffers + * with the dimensions already known, and returns a freshly allocated buffer of the same kind. Validation, + * dimension checks and the object identity remain the responsibility of + * {@see \Lisachenko\NativePhpMatrix\Matrix}, so a driver never has to construct one. + * + * The operands are the buffers the matrices are actually stored in, handed over without a copy. That is the whole + * point of the shape: a PHP array would have to be packed into contiguous doubles before any kernel could touch + * it and unpacked afterwards, which for an element-wise operation costs more than the arithmetic itself. Two + * obligations come with the privilege: + * + * - **Operands are read-only.** They belong to the matrices the caller still holds. A kernel that accumulates + * into one of its arguments — `daxpy` and `dscal` both do — copies it into the result buffer first + * ({@see Float64Buffer::copyOf()}) and works on the copy. + * - **The result is a fresh allocation.** Never return an operand, and never return a buffer that outlives the + * call in some driver-owned cache: the returned buffer becomes the storage of a new matrix. + * + * Two rules bind every implementation: + * + * - **Hook safety.** These methods are reached from the `do_operation` handler, which runs inside an FFI callback + * where a thrown exception becomes an engine-level fatal error. Report an unusable driver from + * {@see self::isAvailable()} — which must swallow its own failures and return false — instead of throwing from + * an operation. The registry validates a selection eagerly, in ordinary userland code, for the same reason. + * - **Everything is float64.** There is no integer path left to preserve: a matrix stores double precision cells, + * every driver reads and writes double precision cells, and the pure-PHP driver produces bit-identical results + * to the accelerated ones for values that are exactly representable. + */ +interface BackendInterface +{ + /** + * Tells whether this driver can be used in the current environment + * + * Implementations probe their libraries here — loading them and running a real, minimal operation, so that a + * missing symbol is discovered now rather than inside an engine hook. Failures are swallowed: an unusable + * driver reports false, it never throws. + */ + public function isAvailable(): bool; + + /** + * Adds two matrices of the same shape element-wise + * + * @param 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/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..1f54f0f --- /dev/null +++ b/src/Backend/Backends.php @@ -0,0 +1,307 @@ + + * + * 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 now that matrices are stored as native float64 buffers its + * rules fit in two lines: + * + * - an accelerated CPU driver probed successfully → that driver, for **every** operation. There is no longer a + * marshalling cost that could make an element-wise operation cheaper in the interpreter, and no integer + * semantics left to protect; + * - nothing available → the pure-PHP driver, which needs no library and computes the same values. + * + * A GPU driver is still never chosen automatically: moving data across a bus is a decision, not a default. + */ +final class Backends +{ + /** + * 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 automatic routing resolved to, remembered for the process + */ + private static ?BackendInterface $automaticBackend = null; + + /** + * Selects the driver to use for every following operation + * + * @param Driver|string $driver Built-in driver, a registered third-party name, or {@see Driver::Auto} + * + * @throws InvalidArgumentException When no driver is registered under that name + * @throws 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; + + return; + } + + $factories = self::factories(); + if (!isset($factories[$name])) { + throw new InvalidArgumentException(sprintf( + 'Unknown matrix backend "%s", registered ones are: %s', + $name, + implode(', ', self::registered()), + )); + } + if (!self::probe($name)) { + throw new BackendNotAvailableException(sprintf( + 'Matrix backend "%s" is registered but not available in this environment', + $name, + )); + } + + self::$selected = $name; + } + + /** + * Registers a third-party driver under the given name, replacing an earlier one + * + * The factory is called at most once, and only when the driver is actually selected or probed, therefore this + * method never throws: a driver that cannot load reports it from {@see BackendInterface::isAvailable()}. + * + * @param 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::$automaticBackend = 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::$automaticBackend = 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 + * + * This is the hot path of every overloaded operator and therefore never throws: an explicit selection was + * validated when it was made, and automatic routing has the always-available pure-PHP driver to fall back on. + */ + public static function resolve(): BackendInterface + { + if (self::$selected !== Driver::Auto->value) { + return self::instance(self::$selected); + } + + return self::$automaticBackend ??= 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 the winner is wrapped + * so that a hardware failure at operation time degrades into a pure-PHP recomputation instead of a fatal + * error inside an engine hook. + */ + private static function resolveAutomaticBackend(): BackendInterface + { + $php = self::instance(Driver::Php->value); + if (self::probe(Driver::Blas->value)) { + return new FallbackBackend(self::instance(Driver::Blas->value), $php); + } + + return $php; + } + + /** + * Runs the availability probe of a driver once and remembers the answer + * + * Every failure mode — a missing factory, a factory that blows up, a library that is not installed — collapses + * into "not available", because this question is asked in contexts that must not fail. + * + * @param string $name Registered driver name + */ + private static function probe(string $name): bool + { + if (isset(self::$availability[$name])) { + return self::$availability[$name]; + } + if (!isset(self::factories()[$name])) { + return self::$availability[$name] = false; + } + + try { + $available = self::instance($name)->isAvailable(); + } catch (Throwable) { + $available = false; + } + + return self::$availability[$name] = $available; + } + + /** + * Returns the driver registered under the given name, creating it on first request + * + * @param string $name Registered driver name + */ + private static function instance(string $name): BackendInterface + { + if (isset(self::$instances[$name])) { + return self::$instances[$name]; + } + + $factory = self::factories()[$name] ?? null; + if ($factory === null) { + // Unknown names cannot reach the operators: use() and bootFromEnvironment() reject them upfront, so + // the pure-PHP driver is the only sensible answer left for a name that disappeared from the registry + return self::$instances[$name] = new PhpBackend(); + } + + return self::$instances[$name] = $factory(); + } + + /** + * Returns the factory table, registering the built-in drivers on first access + * + * @return array + */ + private static function factories(): array + { + if (self::$factories === null) { + self::$factories = [ + 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..38a825d --- /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 already stores its cells as the contiguous, row-major `double[]` block CBLAS + * wants, so an operand is handed to the kernel as the pointer it already is, and the buffer the kernel writes + * becomes the storage of the resulting matrix. The only copy left is the one the accumulating kernels force: + * `daxpy` and `dscal` write into an operand, so the operand is `memcpy`-ed into the result buffer first and the + * kernel is pointed at the copy — one bulk copy per operation instead of a cell-by-cell conversion of both + * operands on the way in and of the result on the way out. + * + * The library is loaded lazily, and only ever the LP64 build. An ILP64 one — `libopenblas64*`, where the integer + * arguments are 64 bit wide — must never be probed here: its ABI does not match these declarations and the + * dimensions would arrive at the kernel as garbage. + */ +final class BlasBackend implements BackendInterface +{ + use AcceleratedBackendTrait; + + /** + * CBLAS layout: cells are stored row by row + */ + private const int CBLAS_ROW_MAJOR = 101; + + /** + * CBLAS transposition: use the operand as it is stored + */ + private const int CBLAS_NO_TRANS = 111; + + /** + * Shared library names to try, in order of preference + * + * Only LP64 builds are listed, see the class docblock. The Homebrew locations are spelled out because macOS + * does not search /opt/homebrew from the default loader path. + * + * @var list + */ + private const array SHARED_LIBRARIES = [ + 'libopenblas.so.0', + 'libopenblas.so', + 'libopenblas.dylib', + '/opt/homebrew/opt/openblas/lib/libopenblas.dylib', + '/usr/local/opt/openblas/lib/libopenblas.dylib', + ]; + + /** + * Inline declarations of the CBLAS subset this driver calls + * + * The `int` parameters are the CBLAS enums and the LP64 `blasint` dimensions. + */ + private const string DECLARATIONS = <<<'CDEF' + void cblas_dgemm(int layout, int transa, int transb, int m, int n, int k, double alpha, + const double* a, int lda, const double* b, int ldb, double beta, double* c, int ldc); + void cblas_daxpy(int n, double alpha, const double* x, int incx, double* y, int incy); + void cblas_dscal(int n, double alpha, double* x, int incx); + CDEF; + + /** + * Loaded library, or null while it has not been loaded yet + */ + private ?FFI $library = null; + + /** + * Cached answer of the availability probe: null while the question has not been asked + */ + private ?bool $available = null; + + /** + * Loads OpenBLAS and multiplies a 1×1 matrix with it + * + * The probe is a real call on purpose. A library that loads but exports no `cblas_dgemm`, or one built for a + * different ABI, would otherwise be discovered inside an operator hook, where the failure is fatal. + */ + public function isAvailable(): bool + { + if ($this->available !== null) { + return $this->available; + } + + try { + $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/ClblastBackend.php b/src/Backend/ClblastBackend.php new file mode 100644 index 0000000..5459fc7 --- /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 BackendInterface +{ + use AcceleratedBackendTrait; + + /** + * Name of the environment variable choosing the OpenCL device type: `gpu`, `cpu` or `all` + */ + public const string DEVICE_ENVIRONMENT_VARIABLE = 'NATIVE_PHP_MATRIX_CL_DEVICE'; + + /** + * OpenCL device type bits + */ + private const int CL_DEVICE_TYPE_CPU = 2; + private const int CL_DEVICE_TYPE_GPU = 4; + private const int CL_DEVICE_TYPE_ALL = 0xFFFFFFFF; + + /** + * OpenCL buffer allocated for reading and writing by kernels + */ + private const int CL_MEM_READ_WRITE = 1; + + /** + * OpenCL status of a successful call + */ + private const int CL_SUCCESS = 0; + + /** + * OpenCL flag requesting a blocking transfer + */ + private const int CL_TRUE = 1; + + /** + * CLBlast layout: cells are stored row by row + */ + private const int CLBLAST_ROW_MAJOR = 101; + + /** + * CLBlast transposition: use the operand as it is stored + */ + private const int CLBLAST_NO_TRANS = 111; + + /** + * Maximum number of platforms and devices inspected while looking for a usable one + */ + private const int MAX_ENTRIES = 16; + + /** + * Shared library names to try, in order of preference + * + * @var list + */ + private const array SHARED_LIBRARIES = [ + 'libclblast.so.1', + 'libclblast.so', + 'libclblast.dylib', + '/opt/homebrew/opt/clblast/lib/libclblast.dylib', + '/usr/local/opt/clblast/lib/libclblast.dylib', + ]; + + /** + * Inline declarations of CLBlast and of the OpenCL subset needed to feed it + * + * The OpenCL handles are opaque pointers, exactly as the OpenCL headers define them, and the dimensions are + * `size_t` as CLBlast expects. `clCreateCommandQueueWithProperties` is the OpenCL 2.0 entry point, with the + * deprecated 1.x one declared next to it for runtimes that only export the older name. + */ + private const string DECLARATIONS = <<<'CDEF' + typedef void* cl_platform_id; + typedef void* cl_device_id; + typedef void* cl_context; + typedef void* cl_command_queue; + typedef void* cl_mem; + typedef void* cl_event; + + int clGetPlatformIDs(unsigned int num_entries, cl_platform_id* platforms, unsigned int* num_platforms); + int clGetDeviceIDs(cl_platform_id platform, unsigned long long device_type, unsigned int num_entries, + cl_device_id* devices, unsigned int* num_devices); + cl_context clCreateContext(void* properties, unsigned int num_devices, cl_device_id* devices, + void* pfn_notify, void* user_data, int* errcode_ret); + cl_command_queue clCreateCommandQueueWithProperties(cl_context context, cl_device_id device, + void* properties, int* errcode_ret); + cl_command_queue clCreateCommandQueue(cl_context context, cl_device_id device, + unsigned long long properties, int* errcode_ret); + cl_mem clCreateBuffer(cl_context context, unsigned long long flags, size_t size, void* host_ptr, + int* errcode_ret); + int clEnqueueWriteBuffer(cl_command_queue queue, cl_mem buffer, unsigned int blocking_write, size_t offset, + size_t size, const void* ptr, unsigned int num_events_in_wait_list, + const cl_event* event_wait_list, cl_event* event); + int clEnqueueReadBuffer(cl_command_queue queue, cl_mem buffer, unsigned int blocking_read, size_t offset, + size_t size, void* ptr, unsigned int num_events_in_wait_list, + const cl_event* event_wait_list, cl_event* event); + int clFinish(cl_command_queue queue); + int clReleaseMemObject(cl_mem memobj); + int clReleaseCommandQueue(cl_command_queue queue); + int clReleaseContext(cl_context context); + + int CLBlastDgemm(int layout, int a_transpose, int b_transpose, size_t m, size_t n, size_t k, double alpha, + const cl_mem a_buffer, size_t a_offset, size_t a_ld, + const cl_mem b_buffer, size_t b_offset, size_t b_ld, double beta, + cl_mem c_buffer, size_t c_offset, size_t c_ld, + cl_command_queue* queue, cl_event* event); + int CLBlastDaxpy(size_t n, double alpha, const cl_mem x_buffer, size_t x_offset, size_t x_inc, + cl_mem y_buffer, size_t y_offset, size_t y_inc, cl_command_queue* queue, cl_event* event); + int CLBlastDscal(size_t n, double alpha, cl_mem x_buffer, size_t x_offset, size_t x_inc, + cl_command_queue* queue, cl_event* event); + CDEF; + + /** + * Loaded library, or null while it has not been loaded yet + */ + private ?FFI $library = null; + + /** + * Cached answer of the availability probe: null while the question has not been asked + */ + private ?bool $available = null; + + /** + * OpenCL context for the selected device, kept for the lifetime of the process + */ + private ?CData $context = null; + + /** + * Single-element array holding the command queue, as CLBlast takes a pointer to it + */ + private ?CData $queue = null; + + /** + * Loads CLBlast, initialises a device and multiplies a 1×1 matrix on it + * + * The probe runs a real kernel because everything up to that point can succeed on a machine that still cannot + * compute: an OpenCL runtime with no device, a driver that fails to build kernels, a device that is busy. All + * of it collapses into "unavailable" here, where the answer is still a return value and not a fatal error. + */ + public function isAvailable(): bool + { + if ($this->available !== null) { + return $this->available; + } + + try { + $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..61f5065 --- /dev/null +++ b/src/Backend/FallbackBackend.php @@ -0,0 +1,119 @@ + + * + * 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 another driver instead of failing an operation + * + * Automatic routing uses it to keep the engine hooks safe. An accelerated driver reports its availability after a + * real probe, but hardware can still fail later — a device is reset, a queue dies, a library is unloaded — and by + * then the call is deep inside an FFI callback where a thrown exception is an unrecoverable fatal error. Catching + * the failure here and recomputing with the fallback keeps the operator working: catching *inside* the hook is + * perfectly legal, only exceptions crossing the callback boundary are fatal. + * + * Recomputing is safe because a driver may not write to its operands: whatever the primary managed to do before + * it failed, it did to a buffer of its own, so the fallback is handed the same untouched cells. + * + * This decorator is deliberately not used for an explicit selection. Asking for a specific driver and silently + * getting another one's results, at a different speed, would hide exactly what the caller wanted to control. + */ +final class FallbackBackend implements BackendInterface +{ + public function __construct( + private readonly BackendInterface $primary, + private readonly BackendInterface $fallback, + ) {} + + /** + * Reports availability of the primary driver, the fallback is available by definition + */ + public function isAvailable(): bool + { + return $this->primary->isAvailable(); + } + + /** + * {@inheritDoc} + */ + public function sum(CData $left, CData $right, int $rows, int $columns): CData + { + try { + return $this->primary->sum($left, $right, $rows, $columns); + } catch (Throwable) { + return $this->fallback->sum($left, $right, $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 $this->fallback->subtract($left, $right, $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 $this->fallback->multiply($left, $right, $rows, $inner, $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 $this->fallback->multiplyByScalar($matrix, $value, $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 $this->fallback->divideByScalar($matrix, $value, $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 $this->fallback->powByScalar($matrix, $value, $rows, $columns); + } + } +} diff --git a/src/Backend/Float64Buffer.php b/src/Backend/Float64Buffer.php new file mode 100644 index 0000000..d70c07d --- /dev/null +++ b/src/Backend/Float64Buffer.php @@ -0,0 +1,132 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Lisachenko\NativePhpMatrix\Backend; + +use FFI; +use FFI\CData; + +/** + * The storage every matrix and every driver in this package speaks: a flat, row-major block of float64 cells + * + * A `double[rows * columns]` allocation is what a BLAS kernel, an OpenCL buffer and an interpreted loop can all + * read without a conversion in between. Keeping matrices in that shape from the moment they are constructed is + * what removes the pack/unpack step that used to be charged to every single operation. + * + * The buffers are allocated through the plain `FFI::new()`, not through a driver's own binding, on purpose: a + * `double` is a `double` in every FFI type context, so a buffer allocated here is accepted by the CBLAS entry + * points of one driver and by the OpenCL transfer calls of another, and the pure-PHP driver — which loads no + * library at all — can allocate its results the same way. The allocation is owned by PHP, so it is released with + * the handle and no matrix ever frees host memory by hand. + */ +final class Float64Buffer +{ + /** + * Size of a single cell in bytes, resolved once from the running platform rather than assumed + */ + private static ?int $cellSize = null; + + /** + * Allocates an owned, zero-filled buffer of float64 cells + * + * An allocation that does not succeed cannot produce a usable matrix, so nothing is caught here: FFI raises, + * or the declared return type rejects whatever came back instead. Under automatic routing that failure is + * still contained, because {@see FallbackBackend} recomputes the operation on the pure-PHP driver. + * + * @param positive-int $count Number of cells to reserve + * + * @return CData Owned `double[count]` buffer + */ + public static function allocate(int $count): CData + { + return FFI::new('double[' . $count . ']'); + } + + /** + * Allocates a buffer holding a copy of the given one + * + * Kernels such as `daxpy` and `dscal` accumulate into an operand instead of writing to a separate output, so a + * driver that must not touch the operand it was handed copies it here first. The copy is a single `memcpy`, + * which is what makes it acceptable in the hot path. + * + * @param CData $source Buffer holding at least count cells + * @param positive-int $count Number of cells to copy + * + * @return CData Owned `double[count]` buffer with the same contents + */ + public static function copyOf(CData $source, int $count): CData + { + $copy = self::allocate($count); + FFI::memcpy($copy, $source, self::bytes($count)); + + return $copy; + } + + /** + * Tells whether two buffers hold bit-identical cells + * + * The comparison is a `memcmp` over the raw cells, which is stricter than `==` on the values it stands for: + * `-0.0` does not match `0.0` although PHP considers them equal, and a `NAN` cell matches another `NAN` with + * the same bit pattern although PHP considers no `NAN` equal to anything. Both follow from comparing storage + * rather than numbers, and both are documented on {@see \Lisachenko\NativePhpMatrix\Matrix::equals()}. + * + * @param CData $left Left buffer + * @param CData $right Right buffer, holding at least as many cells + * @param positive-int $count Number of cells to compare + */ + public static function identical(CData $left, CData $right, int $count): bool + { + return FFI::memcmp($left, $right, self::bytes($count)) === 0; + } + + /** + * Reads a single cell out of a buffer + * + * The hot loops of the drivers index their buffers directly, because a call per cell would dominate them. + * This accessor exists for the places where clarity wins over the last nanosecond — the availability probes, + * which touch exactly one cell — and it keeps the raw indexing they would otherwise need out of those files. + * + * @param CData $buffer Buffer holding the cell + * @param int $offset Zero-based cell index + */ + public static function read(CData $buffer, int $offset): float + { + return $buffer[$offset]; + } + + /** + * Writes a single cell into a buffer + * + * @param CData $buffer Buffer to write into + * @param int $offset Zero-based cell index + * @param float $value Value to store + */ + public static function write(CData $buffer, int $offset, float $value): void + { + $buffer[$offset] = $value; + } + + /** + * Returns the number of bytes occupied by the given number of cells + * + * @param positive-int $count Number of cells + * + * @return positive-int Size in bytes + */ + public static function bytes(int $count): int + { + self::$cellSize ??= FFI::sizeof(FFI::new('double')); + + /** @var positive-int */ + return $count * self::$cellSize; + } +} diff --git a/src/Backend/PhpBackend.php b/src/Backend/PhpBackend.php new file mode 100644 index 0000000..967ce66 --- /dev/null +++ b/src/Backend/PhpBackend.php @@ -0,0 +1,145 @@ + + * + * 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; + +/** + * Reference driver: the arithmetic carried out by the interpreter itself + * + * The loop bodies are the ones this library shipped before the drivers existed; what changed is what they walk + * over. Cells now live in a flat `double[]` buffer rather than in nested arrays, so the loops index offsets + * instead of dereferencing rows, and the multiplication keeps its original accumulation order — summing the + * products of a row in a different order can change the last bits of a float result, and the parity tests hold + * this driver and the accelerated ones to the same answer. + * + * Reading and writing a cell through `FFI\CData` is slower than touching a PHP array element, which makes this + * driver slower than it used to be on paper. That is a deliberate trade: it is the fallback that must work with + * no library installed at all, while every operation that matters for speed is now handed to a kernel without a + * conversion in between. + */ +final class PhpBackend implements BackendInterface +{ + /** + * Pure PHP is available wherever this library runs + */ + public function isAvailable(): bool + { + return true; + } + + /** + * {@inheritDoc} + */ + public function sum(CData $left, CData $right, int $rows, int $columns): CData + { + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $left[$cell] + $right[$cell]; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function subtract(CData $left, CData $right, int $rows, int $columns): CData + { + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $left[$cell] - $right[$cell]; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData + { + // The multiplier is consumed column by column, and a column of a row-major buffer is strided: consecutive + // cells sit $columns doubles apart, so at any real size every read is a cache miss. Transposing it once, + // for O(inner * columns), is the buffer-level form of the array_column() this loop used to do — after it + // both operands are walked contiguously in the inner loop + $transposed = Float64Buffer::allocate($inner * $columns); + for ($row = 0; $row < $inner; $row++) { + $rowOffset = $row * $columns; + for ($column = 0; $column < $columns; $column++) { + $transposed[$column * $inner + $row] = $right[$rowOffset + $column]; + } + } + + $result = Float64Buffer::allocate($rows * $columns); + for ($row = 0; $row < $rows; $row++) { + $leftOffset = $row * $inner; + $resultOffset = $row * $columns; + for ($column = 0; $column < $columns; $column++) { + $columnOffset = $column * $inner; + // Summation order is unchanged, which is what keeps this driver bit-identical to the kernels + $cellValue = 0.0; + for ($step = 0; $step < $inner; $step++) { + $cellValue += $left[$leftOffset + $step] * $transposed[$columnOffset + $step]; + } + $result[$resultOffset + $column] = $cellValue; + } + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $matrix[$cell] * $value; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $matrix[$cell] / $value; + } + + return $result; + } + + /** + * {@inheritDoc} + */ + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $matrix[$cell] ** $value; + } + + return $result; + } +} diff --git a/src/Matrix.php b/src/Matrix.php index a83779f..b26e540 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -12,30 +12,12 @@ 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\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 +35,15 @@ /** * Simple class Matrix powered by custom operator handlers * - * Note about the generic parameter: arithmetic honestly widens the cell type. Even for a `Matrix` a division - * or an exponentiation may produce floats, therefore every operation returns a `Matrix` instead of - * pretending that the original `T` is preserved. + * Cells are float64, always. A matrix does not hold a PHP array of rows: it owns a single, contiguous, row-major + * `double[rows * columns]` allocation, which is the shape a BLAS kernel, an OpenCL buffer and an interpreted loop + * can all read without a conversion in between. Integers are accepted as input and stored as the doubles they + * convert to, the way `numpy.array([[1, 2]])` yields `dtype=float64` — so `new Matrix([[1, 2]])->toArray()` + * gives `[[1.0, 2.0]]`, and every operation returns floats whichever driver computed it. * - * @template-covariant T of int|float + * That storage is what lets the operators hand their operands to a driver as raw pointers. Nothing is packed on + * the way in and nothing is unpacked on the way out: the buffer a kernel writes becomes the storage of the matrix + * the operator returns. */ final class Matrix implements ObjectCastInterface, @@ -69,26 +55,53 @@ final class Matrix implements use ObjectCreateTrait; /** - * Matrix cells, stored as a list of rows + * Reflection used to build a matrix around a buffer a driver has just written + * + * 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 + * + * The allocation is owned by PHP and released together with this object, which is why no matrix ever frees + * host memory by hand. + * + * The three properties below are written exactly once — by the constructor, or by {@see self::fromBuffer()} + * for a result a driver has just computed — and never again; nothing in this class mutates a matrix in place. + * They are not declared `readonly` only because that second initialisation path assigns them from a static + * method rather than from the constructor, which the attribute forbids. + */ + private CData $buffer; /** * 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: each cell is checked and then written straight into the + * freshly allocated buffer, where FFI performs the int-to-double conversion natively. There is no + * intermediate array and no second loop over the cells. + * + * @param non-empty-list> $matrix Rectangular list of rows, each one holding numeric cells + * + * @throws InvalidArgumentException When the argument is not a non-empty, rectangular list of numeric rows */ public function __construct(array $matrix) { @@ -99,33 +112,30 @@ public function __construct(array $matrix) throw new InvalidArgumentException('Matrix should be a list of rows with sequential keys, starting from 0'); } - $columns = null; + $rows = count($matrix); + $columns = count(self::validateRow($matrix[0], 0)); + $buffer = Float64Buffer::allocate($rows * $columns); + $offset = 0; + foreach ($matrix as $rowIndex => $row) { - if (!is_array($row) || !array_is_list($row)) { - throw new InvalidArgumentException( - sprintf('Matrix row %d should be a list of values with sequential keys, starting from 0', $rowIndex), - ); - } - if ($row === []) { - throw new InvalidArgumentException('Matrix should contain at least one column'); - } - if ($columns === null) { - $columns = count($row); - } elseif (count($row) !== $columns) { + $cells = self::validateRow($row, $rowIndex); + if (count($cells) !== $columns) { throw new InvalidArgumentException('All matrix rows should have the same number of columns'); } - foreach ($row as $columnIndex => $value) { - if (!is_int($value) && !is_float($value)) { + foreach ($cells as $columnIndex => $cellValue) { + if (!is_int($cellValue) && !is_float($cellValue)) { throw new InvalidArgumentException( sprintf('Matrix value at [%d][%d] should be either an int or a float', $rowIndex, $columnIndex), ); } + // 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->buffer = $buffer; + $this->rows = $rows; + $this->columns = $columns; } public function getRows(): int @@ -144,21 +154,37 @@ public function isSquare(): bool } /** - * Returns an underlying representation of this matrix + * Returns the cells of this matrix as a list of rows + * + * The rows are materialised from the buffer on demand — this is the boundary between the native storage and + * ordinary PHP, so a caller that only wants to compute never pays for it. * - * @return non-empty-list> + * @return non-empty-list> Cells, row after row */ public function toArray(): array { - return $this->matrix; + $result = []; + $offset = 0; + for ($row = 0; $row < $this->rows; $row++) { + $cells = []; + for ($column = 0; $column < $this->columns; $column++) { + $cells[] = $this->buffer[$offset++]; + } + $result[] = $cells; + } + + /** @var non-empty-list> */ + return $result; } /** * Performs multiplication of two matrices * - * @param self $multiplier Right operand + * @param self $multiplier Right operand + * + * @return self Product of two matrices * - * @return self Product of two matrices + * @throws InvalidArgumentException When the inner dimensions do not match */ public function multiply(self $multiplier): self { @@ -166,98 +192,69 @@ 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; - } - - return new self($result); + return self::fromBuffer( + Backends::resolve()->multiply( + $this->buffer, + $multiplier->buffer, + $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; - } - - return new self($result); + return self::fromBuffer( + Backends::resolve()->divideByScalar($this->buffer, (float) $value, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Performs multiplication by scalar value * * @param int|float $value Multiplier - * - * @return self */ public function multiplyByScalar(int|float $value): self { - $result = []; - foreach ($this->matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue * $value; - } - $result[] = $resultRow; - } - - return new self($result); + return self::fromBuffer( + Backends::resolve()->multiplyByScalar($this->buffer, (float) $value, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Performs exponential expression by scalar value * * @param int|float $value Exponent - * - * @return self */ public function powByScalar(int|float $value): self { - $result = []; - foreach ($this->matrix as $row) { - $resultRow = []; - foreach ($row as $cellValue) { - $resultRow[] = $cellValue ** $value; - } - $result[] = $resultRow; - } - - return new self($result); + return self::fromBuffer( + Backends::resolve()->powByScalar($this->buffer, (float) $value, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Performs addition of two matrices * - * @param self $value Right operand + * @param self $value Right operand + * + * @return self Sum of two matrices * - * @return self Sum of two matrices + * @throws InvalidArgumentException When the dimensions do not match */ public function sum(self $value): self { @@ -265,25 +262,21 @@ 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; - } - - return new self($result); + return self::fromBuffer( + Backends::resolve()->sum($this->buffer, $value->buffer, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Performs subtraction of two matrices * - * @param self $value Right operand + * @param self $value Right operand + * + * @return self Difference of two matrices * - * @return self Difference of two matrices + * @throws InvalidArgumentException When the dimensions do not match */ public function subtract(self $value): self { @@ -291,39 +284,30 @@ 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; - } - - return new self($result); + return self::fromBuffer( + Backends::resolve()->subtract($this->buffer, $value->buffer, $this->rows, $this->columns), + $this->rows, + $this->columns, + ); } /** * Checks if the given matrix equals to another one * - * @param self $another Another matrix + * The cells are compared as storage, with a single `memcmp` over both buffers rather than a loop. That is + * bit-exact, and deliberately stricter than `==` on the numbers the bits stand for in two corner cases: + * a `-0.0` cell does not match a `0.0` one, and two `NAN` cells with the same bit pattern do match. Both + * follow from comparing memory, and neither can arise from the integral values these matrices usually hold. + * + * @param self $another Another matrix */ public function equals(self $another): bool { if ($another->rows !== $this->rows || $another->columns !== $this->columns) { return false; } - foreach ($this->matrix as $rowIndex => $row) { - $anotherRow = $another->matrix[$rowIndex]; - foreach ($row as $columnIndex => $cellValue) { - if ($cellValue !== $anotherRow[$columnIndex]) { - return false; - } - } - } - return true; + return Float64Buffer::identical($this->buffer, $another->buffer, $this->rows * $this->columns); } /** @@ -331,7 +315,7 @@ public function equals(self $another): bool * * @param DoOperationHook $hook Instance of current hook * - * @return self Result of operation + * @return self Result of operation */ public static function __doOperation(DoOperationHook $hook): self { @@ -485,13 +469,62 @@ public static function __getFields(GetPropertiesForHook $hook): array return get_mangled_object_vars($object); } + /** + * Builds a matrix around a buffer that already holds valid cells + * + * Every operation ends here: a driver returns the buffer it computed into, and that buffer becomes the + * storage of the result without a single cell crossing back into PHP. The constructor is bypassed because + * its job — validating input and converting it to float64 — is already done by construction. + * + * @param CData $buffer Row-major `double[rows * columns]` the caller hands over ownership of + * @param positive-int $rows Number of rows the buffer holds + * @param positive-int $columns Number of columns the buffer holds + */ + private static function fromBuffer(CData $buffer, int $rows, int $columns): self + { + self::$reflection ??= new ReflectionClass(self::class); + + $matrix = self::$reflection->newInstanceWithoutConstructor(); + $matrix->buffer = $buffer; + $matrix->rows = $rows; + $matrix->columns = $columns; + + return $matrix; + } + + /** + * Checks that one row of the constructor argument is a non-empty list, and returns it + * + * @param mixed $row Candidate row, straight out of the argument + * @param int $rowIndex Position of the row, for the message + * + * @return non-empty-list The row itself, once it is known to be one + * + * @throws InvalidArgumentException When the row is not a non-empty list + */ + private static function validateRow(mixed $row, int $rowIndex): array + { + if (!is_array($row) || !array_is_list($row)) { + throw new InvalidArgumentException( + sprintf('Matrix row %d should be a list of values with sequential keys, starting from 0', $rowIndex), + ); + } + if ($row === []) { + throw new InvalidArgumentException('Matrix should contain at least one column'); + } + + return $row; + } + /** * Returns a human-readable representation of this matrix, one row per line */ private function toString(): string { $rows = []; - foreach ($this->matrix as $row) { + foreach ($this->toArray() as $row) { + // Integral floats stringify without a fractional part, so a matrix of whole numbers still reads + // as "[1, 2, 3]" rather than "[1.0, 2.0, 3.0]" $rows[] = '[' . implode(', ', $row) . ']'; } diff --git a/tests/Functional/include/MarkerBackend.inc b/tests/Functional/include/MarkerBackend.inc new file mode 100644 index 0000000..7e00a9d --- /dev/null +++ b/tests/Functional/include/MarkerBackend.inc @@ -0,0 +1,79 @@ + + * + * 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\BackendInterface; +use Lisachenko\NativePhpMatrix\Backend\Float64Buffer; +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 buffer contract: the operands are read, never written, and the result is a buffer this + * driver allocated itself. + */ +final class MarkerBackend implements BackendInterface +{ + public const int MARKER = 1000; + + private PhpBackend $delegate; + + public function __construct() + { + $this->delegate = new PhpBackend(); + } + + public function isAvailable(): bool + { + return true; + } + + public function sum(CData $left, CData $right, int $rows, int $columns): CData + { + $count = $rows * $columns; + $result = Float64Buffer::allocate($count); + for ($cell = 0; $cell < $count; $cell++) { + $result[$cell] = $left[$cell] + $right[$cell] + self::MARKER; + } + + return $result; + } + + public function subtract(CData $left, CData $right, int $rows, int $columns): CData + { + return $this->delegate->subtract($left, $right, $rows, $columns); + } + + public function multiply(CData $left, CData $right, int $rows, int $inner, int $columns): CData + { + return $this->delegate->multiply($left, $right, $rows, $inner, $columns); + } + + public function multiplyByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + return $this->delegate->multiplyByScalar($matrix, $value, $rows, $columns); + } + + public function divideByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + return $this->delegate->divideByScalar($matrix, $value, $rows, $columns); + } + + public function powByScalar(CData $matrix, float $value, int $rows, int $columns): CData + { + return $this->delegate->powByScalar($matrix, $value, $rows, $columns); + } +} diff --git a/tests/Functional/include/UnavailableBackend.inc b/tests/Functional/include/UnavailableBackend.inc new file mode 100644 index 0000000..f408016 --- /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\BackendInterface; +use Lisachenko\NativePhpMatrix\Backend\Float64Buffer; + +/** + * Driver that reports itself unusable, the way a driver whose library is missing does + * + * Its operations are never reachable: an unavailable driver cannot be selected, and automatic routing skips it. + * Failing this deterministically, without depending on which acceleration libraries the machine happens to have, + * is exactly why this stub exists. The bodies still honour the contract — a fresh buffer, never an operand — so + * that the stub cannot become a counter-example to it. + */ +final class UnavailableBackend implements BackendInterface +{ + 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/testBackendDefaultsToAuto.phpt b/tests/Functional/testBackendDefaultsToAuto.phpt new file mode 100644 index 0000000..75374dd --- /dev/null +++ b/tests/Functional/testBackendDefaultsToAuto.phpt @@ -0,0 +1,22 @@ +--TEST-- +Matrix arithmetic defaults to automatic backend routing with pure PHP always available +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- + +--EXPECT-- +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..46540c0 --- /dev/null +++ b/tests/Functional/testBackendEnvVarSelectsPhpBackend.phpt @@ -0,0 +1,34 @@ +--TEST-- +The NATIVE_PHP_MATRIX_BACKEND environment variable pins the backend at bootstrap +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=php +--FILE-- +toArray()); +?> +--EXPECT-- +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..dc087da --- /dev/null +++ b/tests/Functional/testBlasAddsMatricesAsFloats.phpt @@ -0,0 +1,38 @@ +--TEST-- +The blas backend adds matrices and returns floats even for integer input +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(1) { + [0]=> + array(3) { + [0]=> + float(5) + [1]=> + float(7) + [2]=> + float(9) + } +} diff --git a/tests/Functional/testBlasDividesMatrixByScalar.phpt b/tests/Functional/testBlasDividesMatrixByScalar.phpt new file mode 100644 index 0000000..3a1a4d7 --- /dev/null +++ b/tests/Functional/testBlasDividesMatrixByScalar.phpt @@ -0,0 +1,42 @@ +--TEST-- +The blas backend divides a matrix by a scalar by scaling with its reciprocal +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(0.5) + [1]=> + float(1.5) + } + [1]=> + array(2) { + [0]=> + float(2.5) + [1]=> + float(3.5) + } +} diff --git a/tests/Functional/testBlasMatchesPhpBackendResults.phpt b/tests/Functional/testBlasMatchesPhpBackendResults.phpt new file mode 100644 index 0000000..f56f016 --- /dev/null +++ b/tests/Functional/testBlasMatchesPhpBackendResults.phpt @@ -0,0 +1,52 @@ +--TEST-- +The blas backend produces the same product as the pure PHP backend +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray() === $actual->toArray()); +var_dump($expected == $actual); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/tests/Functional/testBlasMultipliesFloatMatrices.phpt b/tests/Functional/testBlasMultipliesFloatMatrices.phpt new file mode 100644 index 0000000..42325ac --- /dev/null +++ b/tests/Functional/testBlasMultipliesFloatMatrices.phpt @@ -0,0 +1,41 @@ +--TEST-- +The blas backend multiplies float matrices with cblas_dgemm +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(19) + [1]=> + float(22) + } + [1]=> + array(2) { + [0]=> + float(43) + [1]=> + float(50) + } +} diff --git a/tests/Functional/testBlasMultipliesMatrixByScalar.phpt b/tests/Functional/testBlasMultipliesMatrixByScalar.phpt new file mode 100644 index 0000000..3876ef3 --- /dev/null +++ b/tests/Functional/testBlasMultipliesMatrixByScalar.phpt @@ -0,0 +1,40 @@ +--TEST-- +The blas backend multiplies a matrix by a scalar with cblas_dscal +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(3) + [1]=> + float(5) + } + [1]=> + array(2) { + [0]=> + float(7) + [1]=> + float(9) + } +} diff --git a/tests/Functional/testBlasPowsMatrixByScalar.phpt b/tests/Functional/testBlasPowsMatrixByScalar.phpt new file mode 100644 index 0000000..3d5bf95 --- /dev/null +++ b/tests/Functional/testBlasPowsMatrixByScalar.phpt @@ -0,0 +1,41 @@ +--TEST-- +The blas backend raises a matrix to a power in floats +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(4) + [1]=> + float(9) + } + [1]=> + array(2) { + [0]=> + float(16) + [1]=> + float(25) + } +} diff --git a/tests/Functional/testBlasSubtractsMatrices.phpt b/tests/Functional/testBlasSubtractsMatrices.phpt new file mode 100644 index 0000000..2776a5d --- /dev/null +++ b/tests/Functional/testBlasSubtractsMatrices.phpt @@ -0,0 +1,41 @@ +--TEST-- +The blas backend subtracts matrices with a negatively scaled cblas_daxpy +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=blas +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(9) + [1]=> + float(6) + } + [1]=> + array(2) { + [0]=> + float(3) + [1]=> + float(0) + } +} diff --git a/tests/Functional/testCanAddMatrices.phpt b/tests/Functional/testCanAddMatrices.phpt index 5148f64..1f36944 100644 --- a/tests/Functional/testCanAddMatrices.phpt +++ b/tests/Functional/testCanAddMatrices.phpt @@ -22,10 +22,10 @@ array(1) { [0]=> array(3) { [0]=> - int(5) + float(5) [1]=> - int(7) + float(7) [2]=> - int(9) + float(9) } } diff --git a/tests/Functional/testCanCastMatrixToArray.phpt b/tests/Functional/testCanCastMatrixToArray.phpt index 5f7ee05..4899f0b 100644 --- a/tests/Functional/testCanCastMatrixToArray.phpt +++ b/tests/Functional/testCanCastMatrixToArray.phpt @@ -20,19 +20,19 @@ array(2) { [0]=> array(3) { [0]=> - int(1) + float(1) [1]=> - int(2) + float(2) [2]=> - int(3) + float(3) } [1]=> array(3) { [0]=> - int(4) + float(4) [1]=> - int(5) + float(5) [2]=> - int(6) + float(6) } } diff --git a/tests/Functional/testCanDivideMatrixByNumber.phpt b/tests/Functional/testCanDivideMatrixByNumber.phpt index ca78738..90a8be2 100644 --- a/tests/Functional/testCanDivideMatrixByNumber.phpt +++ b/tests/Functional/testCanDivideMatrixByNumber.phpt @@ -21,10 +21,10 @@ array(1) { [0]=> array(3) { [0]=> - int(1) + float(1) [1]=> - int(2) + float(2) [2]=> - int(3) + float(3) } } diff --git a/tests/Functional/testCanMultiplyCompatibleMatrices.phpt b/tests/Functional/testCanMultiplyCompatibleMatrices.phpt index 5f1b6a6..12a7e21 100644 --- a/tests/Functional/testCanMultiplyCompatibleMatrices.phpt +++ b/tests/Functional/testCanMultiplyCompatibleMatrices.phpt @@ -22,6 +22,6 @@ array(1) { [0]=> array(1) { [0]=> - int(32) + float(32) } } diff --git a/tests/Functional/testCanMultiplyMatrixByNumber.phpt b/tests/Functional/testCanMultiplyMatrixByNumber.phpt index 54e97da..c431c6a 100644 --- a/tests/Functional/testCanMultiplyMatrixByNumber.phpt +++ b/tests/Functional/testCanMultiplyMatrixByNumber.phpt @@ -21,10 +21,10 @@ array(1) { [0]=> array(3) { [0]=> - int(3) + float(3) [1]=> - int(6) + float(6) [2]=> - int(9) + float(9) } } diff --git a/tests/Functional/testCanMultiplyNumberByMatrix.phpt b/tests/Functional/testCanMultiplyNumberByMatrix.phpt index d152d36..c7c192f 100644 --- a/tests/Functional/testCanMultiplyNumberByMatrix.phpt +++ b/tests/Functional/testCanMultiplyNumberByMatrix.phpt @@ -21,10 +21,10 @@ array(1) { [0]=> array(3) { [0]=> - int(3) + float(3) [1]=> - int(6) + float(6) [2]=> - int(9) + float(9) } } diff --git a/tests/Functional/testCanPowMatrixByNumber.phpt b/tests/Functional/testCanPowMatrixByNumber.phpt index c250588..895fc5a 100644 --- a/tests/Functional/testCanPowMatrixByNumber.phpt +++ b/tests/Functional/testCanPowMatrixByNumber.phpt @@ -21,10 +21,10 @@ array(1) { [0]=> array(3) { [0]=> - int(1) + float(1) [1]=> - int(4) + float(4) [2]=> - int(9) + float(9) } } diff --git a/tests/Functional/testCanRegisterThirdPartyBackend.phpt b/tests/Functional/testCanRegisterThirdPartyBackend.phpt new file mode 100644 index 0000000..6902efb --- /dev/null +++ b/tests/Functional/testCanRegisterThirdPartyBackend.phpt @@ -0,0 +1,38 @@ +--TEST-- +A third-party backend can be registered and serves the overloaded operators +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + new MarkerBackend()); +Backends::use('marker'); +var_dump(Backends::active()); + +// The engine dispatches "+" to the do_operation hook, which resolves the driver through the registry: seeing the +// marker offset in the result proves the whole path, from the operator down to an out-of-tree driver +$matrixA = new Matrix([[1, 2]]); +$matrixB = new Matrix([[10, 20]]); +var_dump(($matrixA + $matrixB)->toArray()); +?> +--EXPECT-- +string(6) "marker" +array(1) { + [0]=> + array(2) { + [0]=> + float(1011) + [1]=> + float(1022) + } +} diff --git a/tests/Functional/testCanSubtractMatrices.phpt b/tests/Functional/testCanSubtractMatrices.phpt index 4c6ac21..745035e 100644 --- a/tests/Functional/testCanSubtractMatrices.phpt +++ b/tests/Functional/testCanSubtractMatrices.phpt @@ -22,10 +22,10 @@ array(1) { [0]=> array(3) { [0]=> - int(3) + float(3) [1]=> - int(3) + float(3) [2]=> - int(3) + float(3) } } diff --git a/tests/Functional/testClblastAddsMatricesAsFloats.phpt b/tests/Functional/testClblastAddsMatricesAsFloats.phpt new file mode 100644 index 0000000..bd87481 --- /dev/null +++ b/tests/Functional/testClblastAddsMatricesAsFloats.phpt @@ -0,0 +1,37 @@ +--TEST-- +The clblast backend adds matrices and returns floats even for integer input +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=clblast +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(1) { + [0]=> + array(3) { + [0]=> + float(5) + [1]=> + float(7) + [2]=> + float(9) + } +} diff --git a/tests/Functional/testClblastMatchesPhpBackendResults.phpt b/tests/Functional/testClblastMatchesPhpBackendResults.phpt new file mode 100644 index 0000000..8439b3a --- /dev/null +++ b/tests/Functional/testClblastMatchesPhpBackendResults.phpt @@ -0,0 +1,52 @@ +--TEST-- +The clblast backend produces the same product as the pure PHP backend +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=clblast +--SKIPIF-- + +--FILE-- +toArray() === $actual->toArray()); +var_dump($expected == $actual); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/tests/Functional/testClblastMultipliesFloatMatrices.phpt b/tests/Functional/testClblastMultipliesFloatMatrices.phpt new file mode 100644 index 0000000..08451c2 --- /dev/null +++ b/tests/Functional/testClblastMultipliesFloatMatrices.phpt @@ -0,0 +1,41 @@ +--TEST-- +The clblast backend multiplies float matrices on an OpenCL device +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=clblast +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(19) + [1]=> + float(22) + } + [1]=> + array(2) { + [0]=> + float(43) + [1]=> + float(50) + } +} diff --git a/tests/Functional/testClblastMultipliesMatrixByScalar.phpt b/tests/Functional/testClblastMultipliesMatrixByScalar.phpt new file mode 100644 index 0000000..671307e --- /dev/null +++ b/tests/Functional/testClblastMultipliesMatrixByScalar.phpt @@ -0,0 +1,40 @@ +--TEST-- +The clblast backend scales a matrix by a scalar on an OpenCL device +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=clblast +--SKIPIF-- + +--FILE-- +toArray()); +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(3) + [1]=> + float(5) + } + [1]=> + array(2) { + [0]=> + float(7) + [1]=> + float(9) + } +} diff --git a/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt b/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt new file mode 100644 index 0000000..258ab11 --- /dev/null +++ b/tests/Functional/testFailsOnSelectingUnavailableBackend.phpt @@ -0,0 +1,36 @@ +--TEST-- +Selecting a registered backend that is unavailable fails with a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- + new UnavailableBackend()); + +var_dump(in_array('unavailable', Backends::registered(), true)); +var_dump(in_array('unavailable', Backends::available(), true)); + +try { + Backends::use('unavailable'); +} catch (BackendNotAvailableException $exception) { + echo $exception->getMessage(), PHP_EOL; +} +var_dump(Backends::active()); +?> +--EXPECT-- +bool(true) +bool(false) +Matrix backend "unavailable" is registered but not available in this environment +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Auto) diff --git a/tests/Functional/testFailsOnSelectingUnknownBackend.phpt b/tests/Functional/testFailsOnSelectingUnknownBackend.phpt new file mode 100644 index 0000000..cc9597a --- /dev/null +++ b/tests/Functional/testFailsOnSelectingUnknownBackend.phpt @@ -0,0 +1,27 @@ +--TEST-- +Selecting a backend that is not registered fails with a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND= +--FILE-- +getMessage(), PHP_EOL; +} +var_dump(Backends::active()); +?> +--EXPECTF-- +Unknown matrix backend "quantum", registered ones are: %s +enum(Lisachenko\NativePhpMatrix\Backend\Driver::Auto) diff --git a/tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt b/tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt new file mode 100644 index 0000000..4c46a70 --- /dev/null +++ b/tests/Functional/testFailsOnUnknownBackendFromEnvironment.phpt @@ -0,0 +1,25 @@ +--TEST-- +An unknown backend in the environment fails while booting, as a catchable exception +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--ENV-- +NATIVE_PHP_MATRIX_BACKEND=quantum +--FILE-- +getMessage(), PHP_EOL; +} +?> +--EXPECTF-- +InvalidArgumentException +Unknown matrix backend "quantum", registered ones are: %s diff --git a/tests/Functional/testKeepsDefaultDebugOutput.phpt b/tests/Functional/testKeepsDefaultDebugOutput.phpt index 77ec5ef..59b3fa8 100644 --- a/tests/Functional/testKeepsDefaultDebugOutput.phpt +++ b/tests/Functional/testKeepsDefaultDebugOutput.phpt @@ -12,11 +12,14 @@ use Lisachenko\NativePhpMatrix\Matrix; include __DIR__ . '/../../vendor/autoload.php'; +// The cells live in a native buffer now, so the property table shows a CData handle where it used to show an +// array of rows. What the test is about is unchanged: the engine's own visibility markers survive the hook $matrix = new Matrix([[1, 2]]); ob_start(); var_dump($matrix); $output = ob_get_clean(); -var_dump(str_contains($output, '["matrix":"Lisachenko\NativePhpMatrix\Matrix":private]')); +var_dump(str_contains($output, '["buffer":"Lisachenko\NativePhpMatrix\Matrix":private]')); +var_dump(str_contains($output, 'object(FFI\CData:double[2])')); var_dump(str_contains($output, '["rows":"Lisachenko\NativePhpMatrix\Matrix":private]')); var_dump(str_contains($output, '["columns":"Lisachenko\NativePhpMatrix\Matrix":private]')); ?> @@ -24,3 +27,4 @@ var_dump(str_contains($output, '["columns":"Lisachenko\NativePhpMatrix\Matrix":p bool(true) bool(true) bool(true) +bool(true) diff --git a/tests/Functional/testMatrixStoresFloat64.phpt b/tests/Functional/testMatrixStoresFloat64.phpt new file mode 100644 index 0000000..4d3d4ad --- /dev/null +++ b/tests/Functional/testMatrixStoresFloat64.phpt @@ -0,0 +1,59 @@ +--TEST-- +Matrix accepts integer literals and stores every cell as float64 +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +toArray()); + +// Which means the result of an operation is float whichever driver computed it, with no widening left to do +var_dump(($matrix * 2)->toArray()); + +// Integral floats still stringify without a fractional part, so a matrix of whole numbers reads as before +echo (string) new Matrix([[1, 2, 3]]), "\n"; +?> +--EXPECT-- +array(2) { + [0]=> + array(2) { + [0]=> + float(1) + [1]=> + float(2) + } + [1]=> + array(2) { + [0]=> + float(3) + [1]=> + float(4.5) + } +} +array(2) { + [0]=> + array(2) { + [0]=> + float(2) + [1]=> + float(4) + } + [1]=> + array(2) { + [0]=> + float(6) + [1]=> + float(9) + } +} +[1, 2, 3]