Fil-C on Nix

Filnix wiki

php.md

PHP on Fil-C

PHP 8.5, its native extensions and their C/C++ libraries, compiled with Fil-C. Your PHP application does not need a rewrite. A memory error in the interpreter, an extension or a library stops the worker instead of corrupting memory. This is the existing PHP implementation with targeted porting changes, not a new PHP runtime.

The default is PHP 8.5.12. pkgsFilc.php, php-dist and php-docker use it; explicit 8.4 and 7.4 packages remain available. For an experimental JIT evaluation, start with the LLVM backend on staging, not the older C-emitting backend.

Try it

nix build .#pkgsFilc.php     # PHP 8.5, Nixpkgs' default extensions
nix build .#php-dist        # php-8.5.12-filc-x86_64-linux.tar.xz
nix build .#php-docker      # OCI image, tagged filnix-php:8.5.12

Configure a binary cache first. The tarball runs on x86-64 Linux without Nix: unpack it anywhere and use its bin/php or bin/php-fpm. It carries its own runtime and libraries. See the bundled README for configuration. Images and tarballs from a previous release do not change when the Nix default changes; check the release assets for the published version, or build the outputs above.

nix build .#php-docker && docker load < result
docker run --rm -p 9000:9000 -v "$PWD:/var/www/html" \
  filnix-php:8.5.12 php-fpm

Port 9000 is FastCGI, not HTTP: put nginx or Caddy in front, as with php:fpm. For a development server, use php -S with docker run --init. The NixOS module can switch existing FPM pools and supported web applications to Fil-C with filc.phpfpm.enable = true.

withExtensions and buildEnv work as in Nixpkgs. The distribution adds APCu, Redis, Memcached, MongoDB, Imagick and other commonly used extensions to the defaults. Extensions and their libraries must also be Fil-C builds; binary PECL downloads cannot be loaded. See the extension list. PHP 8.5 does not include the pinned igbinary, memcache or Phalcon versions: they do not build against PHP 8.5 natively either.

The JIT on real applications

A small internal wiki or blog is a plausible evaluation target, not a proven production capacity claim. WordPress, MediaWiki and DokuWiki work with the interpreter and the checked LLVM JIT. What matters is warmed response latency, available CPU and memory, and whether you can keep cold workers out of traffic—not the speedup on a numeric loop.

The HTTP service benchmark measures nginx → PHP-FPM → SQLite and reads each complete response over loopback. It separates an ordinary article view from a mixed request load, and separates cold warm-up from warmed measurements. No nginx full-page cache bypasses PHP. These are server response times, not browser page loads: network latency, TLS and fetching images/stylesheets are additional work.

Measured HTTP responses

Three-round medians on Chapel, November 5, 2026: Core i7-10700K, two FPM workers, 120 requests per load per round. Article latency uses one client; mixed throughput and p95 use four concurrent clients.

Site / runtime Article p50 / p95 Mixed req/s Mixed p95
MediaWiki — native 57 / 80 ms 47.4 116 ms
MediaWiki — Fil-C interpreter 184 / 197 ms 11.1 472 ms
MediaWiki — Fil-C LLVM JIT 209 / 229 ms 8.4 643 ms
WordPress — native 46 / 48 ms 47.1 111 ms
WordPress — Fil-C interpreter 277 / 286 ms 7.2 762 ms
WordPress — Fil-C LLVM JIT 265 / 274 ms 7.1 790 ms

These are PHP 8.5.12 builds from the measured snapshot, with both Fil-C variants on staging and the tracked system allocator. They are not measurements of the released-toolchain default. Initial warm-up is excluded; occasional later trace compilation remains included in measured loads. The shared LLVM object cache is off. See the measurement notes for settings and round-to-round spread.

For these small fixtures, a warmed article is a few hundred milliseconds, not one or two seconds. But the two-worker pool has modest throughput, and concurrent requests queue. This makes an internal site worth trying; it does not establish capacity for a medium-traffic WordPress or small VM. The LLVM JIT did not consistently improve CMS performance. Keep the interpreter as a baseline rather than assuming the JIT is necessary.

The earlier “1.3 seconds for MediaWiki” was the sum of medians for twelve different requests, not one page. Those comparisons and the 4.7× LLVM speedup on CPU-bound Zend/bench.php belong in the detailed measurement record, not a server capacity estimate.

Budget for warm-up and memory

In the Chapel run, interpreter workers held roughly 0.5–0.6 GiB RSS, versus about 1 GiB or more with LLVM tracing. These are worker RSS figures, not a whole container's memory limit; shared pages mean they cannot simply be summed to obtain unique pool memory.

The two-worker pools took roughly 8–10 minutes to complete their first mixed pass with LLVM, versus about 4 seconds interpreted. This is a pool warm-up measurement, not the time for one page. The requests that trigger compilation wait, and restarting or recycling workers brings the cost back. Warm every worker before admitting traffic, keep spare capacity, and test worker replacement as well as steady state. Compilation stops PHP's execution timer, but not a reverse proxy's timeout.

There is an opt-in shared LLVM object cache, but it is off by default and has a documented security caveat. Do not enable PHP_JIT_LLVM_CACHE_SIZE merely to reproduce a faster start. It reduces duplicate compilation, not the measured steady-state cost.

For deployment sizing, replay your own traffic and check p95 latency at your expected concurrency. The supplied fixtures are small, SQLite-backed sites, not a large MediaWiki installation or a plugin-heavy WordPress with remote MySQL. Full-page caches can greatly reduce PHP traffic but change what is being measured. A benchmark at saturation is a capacity sample, not a safe operating target or a promise about a VM.

LLVM JIT: the experimental path forward

nix build .#php85-jit
result/bin/php -d opcache.enable_cli=1 -d opcache.jit=tracing \
  -d opcache.jit_buffer_size=64M script.php

php85-jit uses pkgsFilcStaging.php85-jit, built against unreleased upstream Fil-C work plus our additions, pinned in lib/filc-staging.json. It is separate from the released-toolchain PHP 8.5 default. LLVM JIT support must stay on staging until a Fil-C release includes the needed API; changing PHP's default version does not promote that toolchain. The php85-staging-dist tarball and php85-staging-docker image are also interpreter builds; they do not include this opt-in JIT package.

OPcache still finds hot code and builds its intermediate representation. The new backend turns it into LLVM IR and calls Fil-C's in-process compiler. KillUB and FilPizlonator check the generated code just as they check compiled C; no external compiler process runs. Side traces can link to each other. This preserves the memory-safety boundary, not PHP semantic correctness: a JIT bug can still give a wrong result or crash a worker. Application body comparisons caught bugs that HTTP status checks and the JIT test suite missed.

Use opcache.jit=tracing for services. function compiles every function of a loaded file; 1235 compiles hot whole functions and took 13 minutes on MediaWiki in the earlier experiments. A one-shot script often loses to compilation overhead. Keep the interpreter as a baseline: LLVM is the JIT to evaluate, but it is not automatically faster for every service.

Early experiment: the C-emitting backend

The first backend printed C, ran Fil-C clang and loaded the result. That established that OPcache could generate checked code without rewriting PHP, but external compilation is expensive and its side traces do not link. It is superseded as the starting point for new evaluations by the LLVM backend, not removed. PHP_JIT_BACKEND=c selects it on the 8.5 JIT build; the historical php-jit output still names the 8.4 experiment. Its persistent disk cache and implementation details are in the port notes.

Security and operational differences

There is direct exploit evidence, not just synthetic bounds tests: the adapted StealMyBitcoin chain succeeds against native vulnerable PHP 8.4.25 and stops at a Fil-C capability trap before the first heap leak against the same vulnerable PHP on Fil-C. That does not establish protection against every exploit route. See the reproduction and limits.

Which PHP?

Build Nix output Purpose
8.5.12, released toolchain pkgsFilc.php / pkgsFilc.php85, php-dist, php-docker Default; interpreter and OPcache, no PHP JIT
8.5.12, staging LLVM JIT php85-jit Experimental checked tracing JIT; evaluate separately
8.4.27 pkgsFilc.php84 Explicit compatibility option
7.4.33 pkgsFilc.php74, php74-dist End-of-life migration aid, not a supported PHP
8.4.25 php84_25, php84_25-dist, php84_25-docker Deliberately vulnerable exploit reproduction only

Further evidence