Skip to content

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

Merged
lisachenko merged 22 commits into
masterfrom
claude/matrix-php-ml-acceleration-crv7cc
Oct 19, 2026
Merged

lisachenko merged 22 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 twice. First a Matrix became a native float64 buffer, which removed the marshalling the accelerated drivers paid on every operation — but made the pure-PHP driver walk FFI\CData, two to three times slower than the array loops it replaced. So now storage follows the driver: native buffers under blas/clblast, PHP float arrays under php, each driver computing on the form it reads fastest. The suite passes under every backend.

⚠️ BREAKING CHANGE

Matrix cells are float64 only. 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. Whether those doubles sit in a double[rows * columns] buffer or in a list of rows of PHP floats depends on the driver active when the matrix is built; the values are identical either way.

(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 with six array operations BackendInterface is the root (isAvailable()); a driver implements BufferBackendInterface (FFI\CData in, fresh buffer out) or ArrayBackendInterface (float rows in, float rows out)
Backends::resolveFor(bool) Backends::resolve() — returns the storage-typed driver, cached until the selection changes
Backends::PHP / BLAS / CLBLAST / AUTO constants Driver string-backed enum; Backends::active() returns it for built-ins

Out-of-tree drivers declare their storage by picking one of the two contracts; Backends::use() rejects a driver that implements neither. Two obligations come with either: operands are read-only (a buffer kernel that accumulates into an argument, like daxpy, copies it first) and the result is freshly computed — 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 operands in the storage that driver declares. Drivers never see a Matrix.

Driver Runs on Storage Uses
php interpreter PHP float arrays the original foreach loops, verbatim, same summation order
blas CPU native buffers OpenBLAS cblas_dgemm / cblas_daxpy / cblas_dscal called directly on the stored buffers
clblast GPU native buffers 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; storage follows the driver. The constructor picks the form of the active driver and is the only place a value changes type — writing an int into a double slot (buffer) or appending it as (float) (rows), in the same pass that validates it. No cast loop anywhere else; the array multiplication's accumulator starts at 0.0.
  • Zero marshalling when operands match their driver — which is always, unless the driver changed after they were built. Buffer operands are handed to kernels as the pointers they are; array operands are walked by foreach. A mismatched operand is converted once, in bulk (pack/FFI::memcpy, FFI::string/unpack — no PHP loop over cells), and the result takes the computing driver's form.
  • 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 on the array-native PHP driver.
  • Availability is proven, not guessed. Probing loads the library and runs a real 1×1 multiplication.
  • auto is boring on purpose. OpenBLAS (on buffers) whenever it is loadable, for every operation, pure PHP (on arrays) otherwise, and never a GPU on its own.
  • equals() is bit-exact in every form. memcmp for two buffers, packed doubles for two arrays, a buffer conversion for mixed operands — all 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 master's two-line --INI-- (ffi.enable=1, opcache.jit=off). Every cell expectation is a float, which is what lets the whole suite pass under every pinned backend — default, php, blas and clblast. Tests that existed solely to describe integer semantics (testAutoKeepsIntMatricesOnPhpBackend, the asFloat() test) are removed; testMatrixStoresFloat64 replaces them.

Storage-following-driver is covered by testStorageFollowsDriver, testCanOperateAcrossDriverSwitch (operations and == on matrices built under the other driver), testComparesCellsBitExactly, testAutoRecomputesInPhpWhenAcceleratedDriverFails (a failing buffer driver under auto, recomputed on arrays) and testFailsOnSelectingBackendWithoutStorage. testKeepsDefaultDebugOutput now pins NATIVE_PHP_MATRIX_BACKEND=php, because what the property table shows depends on the driver — a test whose output exposes storage must not depend on whether OpenBLAS is installed.

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, 8.6): installs the acceleration libraries, runs the suite under default routing and under NATIVE_PHP_MATRIX_BACKEND=php.
  • gpu-path (8.4, 8.5, 8.6): runs composer test in full on PoCL's CPU device with NATIVE_PHP_MATRIX_BACKEND=clblast.
  • static-analysis now also runs on 8.6; coding-standards stays on 8.4. The 8.6 legs are set up exactly as z-engine's own CI sets up its 8.6 line (plain php-version: '8.6', same ini values) and are required, as they are there; 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 — the pure-PHP driver no longer needs any, it computes on typed arrays.

Benchmarks

composer bench times the whole PHP-level operation, from the operator to a finished Matrix, with the operands built under the driver being measured.

Operation Size php blas clblast blas speed-up clblast speed-up
Multiplication $a * $b 64×64 6.42 ms (0.1 GFLOP/s) 0.04 ms (12.6 GFLOP/s) 0.46 ms (1.1 GFLOP/s) ×154.2 ×13.9
Multiplication $a * $b 128×128 31.41 ms (0.1 GFLOP/s) 0.08 ms (50.5 GFLOP/s) 0.71 ms (5.9 GFLOP/s) ×378.5 ×44.0
Multiplication $a * $b 256×256 245.67 ms (0.1 GFLOP/s) 0.32 ms (105.5 GFLOP/s) 2.11 ms (15.9 GFLOP/s) ×772.2 ×116.6
Multiplication $a * $b 512×512 2,007.76 ms (0.1 GFLOP/s) 2.35 ms (114.0 GFLOP/s) 17.41 ms (15.4 GFLOP/s) ×852.9 ×115.3
Multiplication $a * $b 1024×1024 15,608.82 ms (0.1 GFLOP/s) 19.64 ms (109.4 GFLOP/s) 124.43 ms (17.3 GFLOP/s) ×794.8 ×125.4

Element-wise operations:

Operation Size php blas clblast blas speed-up clblast speed-up
Addition $a + $b 64×64 0.10 ms 0.03 ms 0.50 ms ×3.2 ×0.2
Addition $a + $b 512×512 4.98 ms 1.27 ms 5.53 ms ×3.9 ×0.9
Scaling $a * 2.5 64×64 0.08 ms 0.04 ms 0.45 ms ×1.8 ×0.2
Scaling $a * 2.5 512×512 3.39 ms 1.15 ms 1.96 ms ×2.9 ×1.7

The php column is the original array loops again, so every speed-up above is measured against the interpreter at its best. Against the previous buffer-only storage, where the interpreter had to walk FFI\CData — same container, same --backends=php --sizes=64,128,256,512 invocation:

php driver buffer storage (FFI\CData) array storage
Multiplication 128×128 58.77 ms 27.72 ms ×2.1
Multiplication 256×256 484.95 ms 228.49 ms ×2.1
Multiplication 512×512 4,146.31 ms 1,848.79 ms ×2.2
Addition 512×512 9.46 ms 5.12 ms ×1.8
Scaling 512×512 6.76 ms 3.43 ms ×2.0

The accelerated drivers lose nothing for it: their matrices are still buffers, handed to the kernels without a conversion.

PHP 8.5.11, Linux x86_64, Intel® Xeon® @ 2.10 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 two storage forms, the Driver enum, the two driver contracts and the single remaining routing rule, with the tables above. CLAUDE.md's backend map lists the storage contracts, the float64 rule becomes "everything is float64, and storage follows the driver", and the test conventions require float expectations, a suite that stays green under every pinned backend, and a pinned backend for any test whose output exposes storage.

Merged with master

Master's stable z-engine requirement (~8.4.2 || ~8.5.0, no root stability flags) and its two-line --INI-- convention are merged in; every test this PR adds follows it. Dropping the E_DEPRECATED suppression surfaced a real deprecation in this PR's code — static FFI::new() — fixed by allocating from an FFI::cdef() scope rather than restoring the suppression.

PHP 8.6 (z-engine 8.6.x-dev)

z-engine's master is its 8.6 line ("php": "~8.6.0", branch-aliased 8.6.x-dev), so PHP 8.6 is added together with the z-engine line built for it: the requirement is now ~8.4.2 || ~8.5.0 || 8.6.x-dev, still exactly one line per running minor, and the Core::init() guard is untouched.

  • The explicit dev constraint is its own root stability flag, so this package's installs resolve master on 8.6 without minimum-stability: dev. That flag covers the whole package, though, and would otherwise let PHP 8.5 resolve the 8.5.x-dev branch over the 8.5.0 tag — the root now carries "prefer-stable": true, which keeps 8.4 and 8.5 on their stable tags.
  • Stability flags do not propagate: until z-engine tags 8.6, a consumer on PHP 8.6 must require lisachenko/z-engine:8.6.x-dev at its own root (README Requirements/Installation say so). 8.4/8.5 consumers need nothing extra.
  • When z-engine tags 8.6, 8.6.x-dev becomes ~8.6.0 and prefer-stable goes, in a single change (CLAUDE.md Dependency policy).

Verification

  • Suite green on PHP 8.5.11 in all four modes — default, =php, =blas, =clblast on PoCL — 42 tests each.
  • CI green on the head commit: tests and GPU path on PHP 8.4, 8.5 and 8.6 (8.6.0-dev nightly, z-engine dev-master; 8.4 and 8.5 still lock the 8.4.2 / 8.5.0 tags), PHPStan level max (no baseline) on all three, coding standards; composer cs:fix idempotent.
  • No segfaults or bus errors at any point.

🤖 Generated with Claude Code

https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE

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 Resolved
Comment thread src/Matrix.php Resolved
Comment thread README.md Resolved
Comment thread README.md Resolved
Comment thread .github/workflows/ci.yml Resolved
Comment thread .github/workflows/ci.yml Resolved
Comment thread src/Backend/BackendInterface.php Resolved
Comment thread src/Backend/Backends.php Resolved
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
@lisachenko
lisachenko marked this pull request as ready for review October 19, 2026 21:07
Reconciles the branch with the two changes master gained since it forked:

- stable z-engine: composer.json takes master's "~8.4.2 || ~8.5.0" requirement
  without the root stability flags, keeping the branch's suggest entries and
  bench script; CLAUDE.md drops the "tighten once z-engine ships stable
  releases" bullet that requirement fulfils.
- two-line --INI--: every .phpt carries only ffi.enable and opcache.jit,
  including the 18 backend tests the branch added; CLAUDE.md combines master's
  two-line text with the branch's backend sections, and its anatomy example
  now asserts float cells like the test it quotes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Calling FFI::new() statically has been deprecated since PHP 8.3. The
suite's error_reporting suppression used to hide it; with that line gone
from every .phpt, the deprecation was prepended to the output of 30 tests.
Float64Buffer now allocates through a lazily created, empty FFI::cdef()
scope, and the PHPStan ignore for the static call is removed with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
…g the driver

Walking FFI\CData cell by cell made the pure-PHP driver two to three times
slower than the array loops it replaced. A matrix now stores its cells in
the form of the driver active when it is built: nested PHP float arrays
under the php driver, which computes with the original array loops again,
and native float64 buffers under blas and clblast, which keep their
zero-marshalling kernels.

- BackendInterface is the common root; a driver declares its storage by
  implementing ArrayBackendInterface or BufferBackendInterface, and
  Backends::use() rejects one that implements neither. resolve() returns
  the storage-typed driver and caches it until the selection changes.
- An operand built under another driver is converted once for that
  operation, in bulk (pack/memcpy, FFI::string/unpack); the result takes
  the computing driver's form. FallbackBackend recomputes a failed buffer
  operation on the array-native php driver the same way.
- equals() stays bit-exact in every combination: memcmp for buffers,
  packed doubles for rows, a buffer conversion for mixed operands.
- Cells remain float64 everywhere: the constructor casts while it
  validates, and the multiplication accumulator starts at 0.0.

The debug-output test pins the php driver, since what the property table
shows now depends on whether OpenBLAS is installed. New tests cover
storage following the driver, operations and comparisons across a driver
switch, bit-exact comparison, the fallback recomputation and the
rejection of a storage-less driver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
README explains the two storage forms — native buffers for the
accelerated drivers, PHP float arrays for the interpreter — and replaces
the "the fallback got slower, deliberately" note with the measured
result of giving the php driver its arrays back: on the same container,
512x512 multiplication 4,146 ms -> 1,849 ms, addition 9.46 -> 5.12 ms,
scaling 6.76 -> 3.43 ms. Both benchmark tables are re-measured.

CLAUDE.md gains the storage contracts in the backend map, the
storage-follows-driver float64 rule and the test convention that a
fixture exposing storage pins its backend; the reviewer checklist and the
benchmark's own docblock follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The agent template and rules still taught int(...) expectations and PHP's
int/float division rules for matrix cells, which contradicts the float64
storage: every cell dumps as float(...) whichever driver computed it. The
template expectation, the pre-writing checklist and the "types matter"
rule now state the float64 reality, and a new rule covers pinning a
backend when a test's output exposes the storage form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Require lisachenko/z-engine as "~8.4.2 || ~8.5.0 || 8.6.x-dev". z-engine's
master is its 8.6 line (php ~8.6.0, branch-aliased 8.6.x-dev), so PHP 8.6
is added together with the z-engine line built for it, and Composer still
resolves exactly one line per running minor.

The explicit dev constraint sets a root stability flag for the whole
package, which would also admit the 8.4/8.5 development branches and, on
PHP 8.5, resolve 8.5.x-dev over the 8.5.0 tag. "prefer-stable": true keeps
8.4 and 8.5 on their stable tags; minimum-stability stays "stable".

Document the third minor, the "tighten to ~8.6.0 when tagged" rule, and
that consumers on PHP 8.6 must require z-engine 8.6.x-dev at their own
root, because stability flags do not propagate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Add 8.6 to the tests, gpu-path and static-analysis matrices. The legs are
set up the way z-engine's own CI sets up its 8.6 line (plain php-version
'8.6', same ini values) and, as there, they are required rather than
allowed to fail. coding-standards stays pinned to 8.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
@lisachenko
lisachenko merged commit 7b0281d into master Oct 19, 2026
10 checks passed
@lisachenko
lisachenko deleted the claude/matrix-php-ml-acceleration-crv7cc branch October 19, 2026 22:05
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