feat(backend): CPU/GPU acceleration drivers for matrix arithmetic - #19
Draft
lisachenko wants to merge 15 commits into
Draft
feat(backend): CPU/GPU acceleration drivers for matrix arithmetic#19lisachenko wants to merge 15 commits into
lisachenko wants to merge 15 commits into
Conversation
The acceleration backends that follow compute in double precision only. asFloat() makes that widening available as ordinary, catchable userland code instead of an implicit side effect of picking a driver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
…ds registry The arithmetic of Matrix moves, unchanged down to its iteration order, into an interchangeable driver: PhpBackend. Matrix keeps the validation, the dimension guards and the hooks, and asks the registry which driver should carry out an operation. The registry validates a selection eagerly in userland — an unusable driver discovered inside an FFI callback would be a fatal error, not an exception — and defaults to routing that keeps every all-integer operation on pure PHP. Nothing observable changes yet: only one driver is registered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Selection failures are asserted with try/catch on purpose: they are raised in userland, unlike the dimension mismatches raised inside the hooks, which can only be matched inside fatal-error output. The third-party driver test follows a registered stub all the way from the "+" operator, and the auto-routing test pins the rule that all-integer arithmetic stays on pure PHP even where acceleration is installed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Matrix multiplication becomes a cblas_dgemm call, addition and subtraction a cblas_daxpy over the flattened cells, and the scalar operations a cblas_dscal. The library is declared inline and loaded lazily from a list of LP64 sonames; the ILP64 build is deliberately never probed, its wider integers would corrupt every dimension argument. The driver proves itself with a real 1x1 multiplication before reporting that it is available, because a missing symbol discovered inside an operator hook would be a fatal error rather than an exception. Automatic routing picks it for float operands only, wrapped so that a failure at operation time recomputes in pure PHP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Every operator gets a file pinned to the blas driver through --ENV--, including the integer input that comes back as floats — the visible half of the float-only rule. The parity test computes the same 8x8 product on both drivers and compares them exactly: integral float cells keep every partial sum representable, so a difference in summation order may not become a difference in the result. The shared SKIPIF probe asks the registry for availability instead of guessing, so a skip can never disagree with what the test would have done. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
CLBlast is a BLAS written in OpenCL, which makes it the portable way to reach a GPU from PHP: the same driver runs on NVIDIA, AMD and Intel hardware, on the integrated GPU of a laptop, and — through a runtime such as PoCL — on the CPU, which is how the GPU code path can be exercised without a GPU at all. NATIVE_PHP_MATRIX_CL_DEVICE picks the device type, defaulting to the GPU. Both APIs come from a single FFI::cdef against CLBlast, whose own link to the OpenCL library resolves the cl* symbols. Device memory is not reference counted by PHP, so every buffer is released in a finally block; host buffers are left to PHP. Like the CPU driver, this one proves itself with a real multiplication before reporting that it is available, and it is never selected automatically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The GPU driver gets the same treatment as the CPU one, including the parity test that compares an 8x8 product computed on the device against the interpreted result. Its SKIPIF probe initialises OpenCL for exactly the device type NATIVE_PHP_MATRIX_CL_DEVICE selects, so a skip always means what it says. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The suite fails on skipped tests, so the libraries are installed rather than the tests being allowed to skip: a skip in CI now means a broken image. PoCL supplies an OpenCL device on the CPU, which lets a runner without a GPU still execute the GPU code path — that is what the new gpu-path job does, with everything pinned to the clblast driver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The script times the whole PHP-level operation — packing, the kernel call, reading the result back and validating it into a new Matrix — because that is what a caller actually pays, and it is what decides where the crossover sits. A warm-up run is discarded so that the kernels CLBlast compiles for the device are not charged to the first measurement, and the median of several runs is reported. --markdown prints a table ready to paste into the README, with the machine and the exact command above it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The README gains the driver table, the selection API, the install lines and the two rules a caller has to know — accelerated drivers are float-only, and auto keeps integers on pure PHP — followed by an AI/ML section built around the operation inference actually spends its time in. The benchmark table is measured, not claimed, and it is honest in both directions: matrix multiplication reaches x132 at 1024x1024, while element-wise operations are slower through a driver than through the interpreter, because there is no O(n^3) work to amortise the marshalling. Both tables are in the README, with the hardware and the command that produced them. CLAUDE.md gains the backend architecture map, its three non-negotiable rules, the backend test conventions and the policy that system libraries are optional runtime dependencies rather than Composer requirements. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
lisachenko
commented
Aug 13, 2026
lisachenko
commented
Aug 13, 2026
lisachenko
commented
Aug 14, 2026
lisachenko
commented
Aug 14, 2026
lisachenko
commented
Aug 14, 2026
lisachenko
commented
Aug 14, 2026
The import lists had grown to sixty "use function" and "use const" lines across six files, long enough in benchmark.php and Matrix.php to push the actual class imports off the screen. PHP resolves an unqualified global function through the same fallback whether or not it was imported, so the lines bought nothing but vertical space. Every one of them is dropped and the call sites left unqualified. Only classes, interfaces, traits and enums keep a "use" statement. The php-cs-fixer config neither adds nor removes these imports, so the convention survives "composer cs:fix" instead of being undone by it, and CLAUDE.md now states it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
A Matrix no longer holds a PHP array of rows. It owns a single contiguous, row-major "double[rows * columns]" allocation, which is the shape a BLAS kernel, an OpenCL buffer and an interpreted loop can all read without a conversion in between. The constructor validates and converts in one pass, writing each cell straight into the buffer where FFI performs the int-to-double conversion natively, so there is no intermediate array and no second loop. That removes the userland cast loop entirely: asFloat() is gone, and so is the containsFloats flag it existed to compensate for. Cells are float64, period. Integers are still accepted as input and stored as the doubles they convert to, the way numpy.array([[1, 2]]) yields dtype=float64. The driver contract changes with it. Operations take the operand buffers and return a freshly allocated one, so nothing is packed on the way in or unpacked on the way out: - BlasBackend calls cblas_dgemm/daxpy/dscal directly on the stored buffers. The only copy left is the one the accumulating kernels force, a single memcpy, instead of a cell-by-cell conversion of both operands and of the result. - PhpBackend runs the same loops over buffer offsets, keeping its accumulation order so that it and the accelerated drivers still agree bit for bit. - ClblastBackend uploads straight from the stored buffer and reads back into the buffer that becomes the result. - AcceleratedBackendTrait shrinks to the one operation no BLAS provides. Two consequences follow. Automatic routing no longer needs to know the operand types: with marshalling gone there is no operation that is cheaper interpreted, so "auto" uses OpenBLAS for every operation when it is available and pure PHP otherwise, and a GPU is still never chosen for you. And equals() compares the two buffers with a single memcmp, which is bit-exact and documented as such. The driver names become a string-backed Driver enum instead of loose class constants; the registry still accepts plain strings, because a third-party driver registered through register() cannot be a case of it. Every test now expects floats, which is what finally lets the whole suite pass under every pinned backend rather than only under the default routing. BREAKING CHANGE: matrix cells are float64 only. Integer input is accepted but stored and returned as float, so "new Matrix([[1, 2]]) + new Matrix([[3, 4]])" now yields [[4.0, 6.0]] instead of [[4, 6]]. Matrix::asFloat() is removed, it no longer means anything. The Matrix<T> generic is removed for the same reason. BackendInterface operations take and return FFI\CData buffers instead of arrays, so out-of-tree drivers must be updated. Backends::resolveFor(bool) is replaced by Backends::resolve(), and the Backends::PHP/BLAS/CLBLAST/AUTO constants by the Driver enum, which Backends::active() now returns for built-in drivers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The gpu-path job hardcoded php-version 8.4 while every other job builds its PHP
version from a matrix, so the device code path was only ever exercised on one of
the two supported minors. It now carries the same strategy.matrix.php as the
tests job and reads ${{ matrix.php }}, with the minor in the job name.
It also stops naming individual test files. That workaround existed because
pinning an accelerated backend process-wide turned integer results into floats
and broke every test asserting integers; with cells stored as float64 there are
no integer expectations left, so the entire suite passes pinned to clblast and
the job simply runs "composer test" on the device.
Dependabot already tracks the github-actions ecosystem weekly alongside the
daily composer updates, so the action pins in this workflow keep updating on
their own and no change is needed there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The README still described the design this branch replaced: plain arrays handed to drivers, an integer-preserving pure-PHP driver, a float-only asterisk on the accelerated ones, and a crossover section explaining why element-wise operations were slower accelerated than interpreted. None of that is true any more. It now documents the storage — one contiguous double[rows * columns] per matrix, integers accepted as input and stored as float64 the way numpy reports dtype=float64 — and states the single remaining routing rule, that auto uses OpenBLAS for every operation when it is loadable and never picks a GPU for you. The crossover section is replaced by an explanation of why the crossover is gone. Both benchmark tables are re-measured on the new storage. Element-wise addition at 512x512 went from 25.27 ms to 2.15 ms on blas, turning a x0.3 penalty into a x6.1 win, and multiplication at 1024x1024 from 113.61 ms to 22.67 ms now that the kernel is nearly all that is being timed. The pure-PHP column got slower in the same measurements, because walking FFI\CData costs more than walking a PHP array, and the text says so rather than quoting only the flattering half. CLAUDE.md follows: the generics section becomes the float64 storage rule, the backend rules gain the read-only-operands contract, and the test conventions now require float expectations everywhere and a suite that stays green under every pinned backend. The code-reviewer agent carried the old generics rule as a blocking check and is updated with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Delivers the roadmap's "FFI BLAS backend" item as an interchangeable driver architecture: the operators stay exactly where they are, the arithmetic behind them becomes pluggable.
$logits = $input * $weights + $biasnow runs on OpenBLAS or a GPU while remaining ordinary PHP.Following review, the storage model was rewritten: a
Matrixis now a native float64 buffer, and drivers receive that buffer rather than a PHP array. That removed the marshalling this PR originally had to apologise for, soautocan accelerate every operation and the suite passes under every backend.Matrix cells are float64 only. A matrix owns one contiguous, row-major
double[rows * columns]allocation instead of a PHP array of rows. Integers are still accepted as input and stored as the doubles they convert to —numpy.array([[1, 2]])reportingdtype=float64is the same bargain.Matrix::asFloat()@template-covariant T of int|float,Matrix<int>BackendInterfacearray operands and returnFFI\CDatabuffers in, a freshly allocated buffer outBackends::resolveFor(bool)Backends::resolve()— routing no longer inspects operand typesBackends::PHP/BLAS/CLBLAST/AUTOconstantsDriverstring-backed enum;Backends::active()returns it for built-insOut-of-tree drivers must be updated to the buffer contract. Two obligations come with it: operands are read-only (a kernel that accumulates into an argument, like
daxpy, copies it first) and the result is a fresh allocation — that is also what makes the automatic fallback safe to re-run.Architecture
Matrixkeeps the validation, the dimension guards and the engine hooks; it asks a registry which driver should compute and hands it the operand buffers. Drivers never see aMatrix.phpblascblas_dgemm/cblas_daxpy/cblas_dscalcalled directly on the stored buffersclblastNATIVE_PHP_MATRIX_BACKEND=php|blas|clblast|autopins one process-wide,NATIVE_PHP_MATRIX_CL_DEVICE=gpu|cpu|allpicks the OpenCL device type.Semantics decided here
memcpyinto the result buffer, instead of a cell-by-cell conversion of both operands in and the result out.Fatal error: Throwing from FFI callbacks is not allowed. Selection is validated eagerly, in userland; availability is reported from a non-throwingisAvailable(); underautoa driver that fails mid-operation is caught and recomputed in pure PHP.autois boring on purpose. OpenBLAS whenever it is loadable, for every operation, pure PHP otherwise, and never a GPU on its own. Both former exceptions — the integer path and the element-wise penalty — no longer exist.equals()is amemcmp. Bit-exact over both buffers, documented as stricter than==for-0.0andNAN.cl_memis not reference counted by PHP: every buffer is freed in afinally.Tests
The suite is one behaviour per
.phpt, all with the mandatory three-line--INI--. Every cell expectation is now a float, which is what finally lets the whole suite pass under every pinned backend — default,php,blasandclblast— instead of only under default routing. Tests that existed solely to describe integer semantics (testAutoKeepsIntMatricesOnPhpBackend, theasFloat()test) are removed;testMatrixStoresFloat64replaces them.Determinism is by construction: integral-valued float fixtures with small dimensions keep every partial sum exactly representable, which is what lets
testBlasMatchesPhpBackendResultsandtestClblastMatchesPhpBackendResultscompare an 8×8 product exactly against the interpreted one despite a different summation order. Divisions use power-of-two divisors, because BLAS scales by the reciprocal.CI
tests(8.4, 8.5): installs the acceleration libraries, runs the suite under default routing and underNATIVE_PHP_MATRIX_BACKEND=php.gpu-path(8.4, 8.5): now takes its PHP version from a matrix like every other job, and runscomposer testin full on PoCL's CPU device instead of naming the CLBlast files — the workaround that existed only because pinning a backend used to break integer expectations.github-actionsecosystem weekly alongside the daily composer updates, so the action pins here keep updating on their own.static-analysisandcoding-standardsunchanged; PHPStan stays at level max with no baseline, and every ignore is a genuine FFI modelling gap scoped to a path with an identifier and a comment.Benchmarks
composer benchtimes the whole PHP-level operation, from the operator to a finishedMatrix.phpblasclblastblasspeed-upclblastspeed-up$a * $b$a * $b$a * $b$a * $b$a * $bElement-wise operations — the caveat this PR used to carry, now gone:
phpblasclblastblasspeed-upclblastspeed-up$a + $b$a + $b$a * 2.5$a * 2.5Against the previous array-backed storage, on the same container:
blasblasblasThe
phpcolumn moved the other way — walkingFFI\CDatacosts more than walking a PHP array — so the ratios are large partly because the fallback got slower. That is the deliberate trade: the driver that needs no library at all absorbs the cost so every other driver stops paying for conversion.PHP 8.5.9, Linux x86_64, Intel® Xeon® @ 2.80 GHz (shared cloud container, no GPU), OpenBLAS 0.3.26, CLBlast 1.6.2 on PoCL 5.0 with
NATIVE_PHP_MATRIX_CL_DEVICE=cpu. Median of 5, one warm-up discarded. Theclblastcolumn is a portability proof, not a GPU benchmark.Docs
README documents the float64 storage, the
Driverenum, and the single remaining routing rule; its crossover section is replaced by an explanation of why the crossover is gone. CLAUDE.md's generics section becomes the float64 storage rule, the backend rules gain the read-only-operands contract, and the test conventions require float expectations and a suite that stays green under every pinned backend.Verification
=php,=blas,=clblaston PoCL — 37 tests each.composer phpstan(level max, no baseline) andcomposer cs:checkclean;composer cs:fixidempotent.🤖 Generated with Claude Code
https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Generated by Claude Code