Skip to content

feat(backend): CPU/GPU acceleration drivers for matrix arithmetic - #19

Draft
lisachenko wants to merge 15 commits into
masterfrom
claude/matrix-php-ml-acceleration-crv7cc
Draft

feat(backend): CPU/GPU acceleration drivers for matrix arithmetic#19
lisachenko wants to merge 15 commits into
masterfrom
claude/matrix-php-ml-acceleration-crv7cc

Conversation

@lisachenko

@lisachenko lisachenko commented Aug 13, 2026

Copy link
Copy Markdown
Owner

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 + $bias now runs on OpenBLAS or a GPU while remaining ordinary PHP.

Following review, the storage model was rewritten: a Matrix is 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, so auto can accelerate every operation and the suite passes under every backend.

⚠️ BREAKING CHANGE

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]]) reporting dtype=float64 is the same bargain.

(new Matrix([[1, 2]]) + new Matrix([[3, 4]]))->toArray();
// before: [[4, 6]]      after: [[4.0, 6.0]]
echo new Matrix([[1, 2]]);   // still "[1, 2]" — integral floats stringify without decimals
Removed / changed Replacement
Matrix::asFloat() gone — every matrix is already float64
@template-covariant T of int|float, Matrix<int> gone — there is one cell type
BackendInterface array operands and return FFI\CData buffers in, a freshly allocated buffer out
Backends::resolveFor(bool) Backends::resolve() — routing no longer inspects operand types
Backends::PHP / BLAS / CLBLAST / AUTO constants Driver string-backed enum; Backends::active() returns it for built-ins

Out-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

Matrix keeps 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 a Matrix.

Driver Runs on Uses
php interpreter the original loops, now over buffer offsets, same summation order
blas CPU OpenBLAS cblas_dgemm / cblas_daxpy / cblas_dscal called directly on the stored buffers
clblast GPU CLBlast over OpenCL — NVIDIA, AMD, Intel iGPUs, or the CPU through PoCL
Backends::available();        // ['php', 'blas'] — probed, not guessed
Backends::use(Driver::Blas);  // catchable at selection time
Backends::active();           // Driver::Blas
Backends::register('cublas', static fn (): BackendInterface => new CuBlasBackend());
Backends::use('cublas');      // third-party drivers keep their registered string

NATIVE_PHP_MATRIX_BACKEND=php|blas|clblast|auto pins one process-wide, NATIVE_PHP_MATRIX_CL_DEVICE=gpu|cpu|all picks the OpenCL device type.

Semantics decided here

  • Everything is float64. The constructor is the only place a value changes type, and it does so by writing an int into a double slot while it validates — FFI converts natively, so there is no userland cast loop anywhere in the package.
  • Zero marshalling. Operands are handed to kernels as the pointers they already are. The only copy left is the one accumulating kernels force: a single memcpy into the result buffer, instead of a cell-by-cell conversion of both operands in and the result out.
  • Hook safety. Everything reachable from an operation runs inside an FFI callback, where a thrown exception is Fatal error: Throwing from FFI callbacks is not allowed. Selection is validated eagerly, in userland; availability is reported from a non-throwing isAvailable(); under auto a driver that fails mid-operation is caught and recomputed in pure PHP.
  • Availability is proven, not guessed. Probing loads the library and runs a real 1×1 multiplication.
  • auto is 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 a memcmp. Bit-exact over both buffers, documented as stricter than == for -0.0 and NAN.
  • Device memory is released explicitly. cl_mem is not reference counted by PHP: every buffer is freed in a finally.
  • Only LP64 OpenBLAS sonames are probed — an ILP64 build would silently corrupt every dimension argument.

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, blas and clblast — instead of only under default routing. Tests that existed solely to describe integer semantics (testAutoKeepsIntMatricesOnPhpBackend, the asFloat() test) are removed; testMatrixStoresFloat64 replaces them.

Determinism is by construction: integral-valued float fixtures with small dimensions keep every partial sum exactly representable, which is what lets testBlasMatchesPhpBackendResults and testClblastMatchesPhpBackendResults compare 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 under NATIVE_PHP_MATRIX_BACKEND=php.
  • gpu-path (8.4, 8.5): now takes its PHP version from a matrix like every other job, and runs composer test in 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.
  • Dependabot already tracks the github-actions ecosystem weekly alongside the daily composer updates, so the action pins here keep updating on their own.
  • static-analysis and coding-standards unchanged; 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 bench times the whole PHP-level operation, from the operator to a finished Matrix.

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

Element-wise operations — the caveat this PR used to carry, now gone:

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

Against the previous array-backed storage, on the same container:

before after
Addition 512×512, blas 25.27 ms (×0.3 — slower than the interpreter) 2.15 ms (×6.1)
Scaling 512×512, blas 24.45 ms (×0.3) 1.82 ms (×5.6)
Multiplication 1024×1024, blas 113.61 ms (18.9 GFLOP/s) 22.67 ms (94.7 GFLOP/s)

The php column moved the other way — walking FFI\CData costs 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. The clblast column is a portability proof, not a GPU benchmark.

Docs

README documents the float64 storage, the Driver enum, 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

  • Suite green on PHP 8.5.9 in all four modes — default, =php, =blas, =clblast on PoCL — 37 tests each.
  • composer phpstan (level max, no baseline) and composer cs:check clean; composer cs:fix idempotent.
  • No segfaults or bus errors at any point.

🤖 Generated with Claude Code

https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE


Generated by Claude Code

claude added 11 commits August 13, 2026 19:54
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
…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
Comment thread src/Backend/Backends.php Outdated
Comment thread src/Matrix.php Outdated
Comment thread README.md
Comment thread README.md Outdated
Comment thread .github/workflows/ci.yml
Comment thread .github/workflows/ci.yml Outdated
Comment thread src/Backend/BackendInterface.php Outdated
Comment thread src/Backend/Backends.php Outdated
claude added 4 commits August 14, 2026 16:29
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants