BlitzVM
v0.1 research preview

research projectPHP for nowNode.js-compatible later

A preemptive runtime for server-side languages.

Every PHP shop hits this eventually: one heavy request ties up a worker and everyone queued behind it waits. BlitzVM compiles hot PHP to machine code, and once a request has had its 10 ms while others are waiting, it gets paused so the rest keep moving. Each request still starts with clean state, and the PHP you write is the same PHP you'd write for php-fpm.

  • PHP 8 front end
  • self-specializing AST
  • preemptive scheduler
  • per-request isolation
  • epoll HTTP server
02

The numbers

All of this ran on one 8-core Linux box with the same PHP files on every server. Everything gets 15 seconds of warm-up before I measure, and where my own runs disagreed I used the slower one. The full setup is in section 07.

What happens to a quick request while a slow one is running

one worker, a 4 s CPU loop, then a hello request. Drawn to scale.

0 s1 s2 s3 s4 s
Fig. 1With one worker, php-fpm can't answer until the loop is done. BlitzVM pauses the loop at its next safepoint and serves the hello request straight away.
03

You still write plain PHP

Swoole is fast, but you pay for it in how you write code. Your app turns into a long-running server, requests share its memory, and a CPU-heavy handler blocks whichever worker it lands on. I wanted to keep the php-fpm model exactly as it is and do the scheduling underneath it.

BlitzVM report.php, runs per request
<?php
// same file you'd put on php-fpm
$report = build_report($_GET['id']);
// heavy, gets paused every 10 ms

usleep(200000);
// gives up the CPU while it waits

header('Content-Type: text/html');
echo render($report);
// and it's all gone after this
Swoole server.php, one process for all requests
<?php
$server = new Swoole\Http\Server('0.0.0.0', 8080);
$server->set(['hook_flags' => SWOOLE_HOOK_ALL]);
$app = bootstrap();  // shared by every request

$server->on('request', function ($req, $res) use ($app) {
    $report = build_report($req->get['id']);
    // blocks this worker until it returns
    $res->end(render($report));
    // globals and statics carry over
});
$server->start();
Same model as php-fpmOne file per request. $_GET, echo, header() and exit work the way you'd expect. No bootstrap file, no request objects.
No coroutinesYou don't write go(), channels or wait groups. A blocking call is just a blocking call, and the scheduler gives the CPU to someone else while it waits.
Nothing leaks between requestsEvery request starts with a fresh context, so there's no state to reset afterwards. That reset step is exactly what makes Laravel Octane fiddly.
No yielding by handYou don't have to chop a slow loop into pieces or push it off to a task worker. It gets paused at the next safepoint either way.

What about Swoole's preemptive mode?

Swoole does have one. Set 'enable_preemptive_scheduler' => true and coroutines get time-sliced every 10 ms. In my tests that answers the hello request about as fast as BlitzVM does, so on that one number it's a tie.

Turning it on also means a coroutine can be paused in the middle of any loop, while every coroutine in the worker shares the app's globals and statics. So a static cache or a singleton that's halfway through being set up can be seen by another request mid-update. It's a big gun, always pointed at your feet. Miss one lock and you get a bug that only shows up under load and never on your laptop. The bigger costs don't go away either: you still have the long-running server, the state you have to reset between requests, and no JIT.

BlitzVM pauses requests at the same kind of points. The difference is that BlitzVM requests work like classic php-fpm: they don't share anything, so there's nothing for them to race on.

To be clear, Laravel doesn't run yet. It needs classes and require, which are next on the list in section 06. When it does run, it should run unchanged.

04

Why it's fast

Most of BlitzVM is my own code: the parser, the interpreter nodes, the scheduler, the isolation and the HTTP layer. For turning hot code into machine code I lean on the Graal compiler, the way plenty of languages lean on LLVM. These are the four things that actually move the numbers.

4.1

The interpreter rewrites itself, then Graal compiles it

PHP goes through a hand-written lexer and a Pratt parser and comes out as a tree of nodes. Each node watches the types that flow through it and specializes. If a + has only ever seen integers, it ends up as one machine add plus an overflow check. If it ever does overflow, it switches to floats, which is what PHP 8 does anyway.

When a function gets hot, Graal compiles the interpreter and the specialized tree together into machine code (partial evaluation). Call sites remember what they call, so small functions get inlined into their callers. That's where the wins over PHP's tracing JIT come from: recursion, integer loops, and code split into lots of little helpers.

// the integer case: one add, one overflow branch
@Specialization(rewriteOn = ArithmeticException.class)
long doLong(long a, long b) { return Math.addExact(a, b); }

@Specialization
double doDouble(double a, double b) { return a + b; }
4.2

Preemption that keeps working after the JIT kicks in

There are N CPU slots, and a request needs one to run. A ticker looks at the slots every 5 ms. If a request has had its 10 ms and someone is waiting, the scheduler queues an action on that request's thread. The action fires at the next safepoint and the slot goes to whoever is next in line.

Those safepoint checks are also in the compiled code, at loop back-edges and calls, so a hot loop can't get away from them. php-fpm can't do this at all, since each process serves one request to the end. Swoole, by default, only switches coroutines at I/O.

4.3

One engine, a fresh context per request

All requests share one engine, which keeps the parsed trees and the machine code. Each request gets its own context with its own globals. So you get php-fpm's shared-nothing behaviour without parsing or compiling anything twice.

CPU time is counted per request as well. If a script blows through its budget it gets cancelled with a normal PHP fatal error, and the process (and everyone else) carries on.

4.4

Netty does the HTTP and never runs PHP

HTTP parsing is Netty on native epoll. The event-loop threads only touch sockets, and every request goes to a worker that waits for a CPU slot. It's one process and one hop. php-fpm needs nginx, FastCGI and a pool process to do the same job.

Pipelined requests on a connection are answered in order, and the connection stops reading while one is in flight. It's a cheap bit of backpressure against clients that try to queue up a pile of work.

05

How it fits together

Everything inside the dashed line is BlitzVM. The PHP front end builds its trees on Truffle's node framework, and Graal compiles the hot ones. The bottom row, the scheduler and the per-request contexts, knows nothing about PHP. That's the reason the Node.js plan is realistic.

BlitzVM architecture Inside BlitzVM: the PHP language implementation, the lexer and parser, the self-specializing tree, the Netty front end, the scheduler and the request contexts. The Graal compiler compiles and Truffle executes the tree. HTTP requests enter through Netty, wait for a CPU slot in the scheduler, and run in an isolated context. The scheduler preempts running code at safepoints. Graal compiler JIT back end Truffle node framework BLITZVM Specializing AST typed fast paths rewrites Lexer + Pratt parser hand-written PHP language semantics, 179 builtins copy-on-write arrays PHP source HTTP clients HTTP front end Netty, native epoll Scheduler N slots · 10 ms quantum Request contexts isolated, one per request compiles executes implements builds uses parses runs in preempts at safepoints
Fig. 5Top half is compilation, bottom half is the path a request takes. The amber boxes are the parts section 04 talks about.
Parsed onceEach file's tree is cached on the shared engine until the file changes.
Compiled onceMachine code belongs to the engine, so the next request to a hot page is already compiled.
Thrown away afterGlobals, the output buffer, headers and the CPU budget all live in the request's context, and the context is dropped when the request ends.
06

What's next

None of the scheduling or isolation code is PHP-specific. I started with PHP because it's easy to measure: php-fpm and Swoole are well understood, and the request model is simple. Node.js-compatible environments are the bigger target.

working

PHP 8 core

Functions with defaults and by-reference args, copy-on-write arrays, PHP 8 type juggling and int overflow, alternative syntax, templates, superglobals, headers and status codes, and 179 builtins. I check the output against real PHP 8.5 with a conformance suite.

prototype

Virtual threads for waiting requests

Request contexts on Java virtual threads, so a request that's sleeping or waiting costs next to nothing. In the prototype, 2,000 requests that each sleep 200 ms finish in . On a 64-thread pool they take .

next

Classes, closures and require

What's missing before real apps, Laravel included, can run.

next

Builtins as compiled nodes

Right now strlen, count, microtime and friends are plain Java calls, so the JIT can't see inside them. Turning the hot ones into nodes fixes that.

planned

Node.js-compatible environments

JavaScript on the same core. Event-loop code that hogs the CPU gets time-sliced like everything else, and each request gets its own context.

07

How I measured

machine
8 cores, Linux 6.8, load generator and servers on the same host
blitzvm
v0.1 on JDK 25 with the Graal compiler from GraalVM Community 25.5
php-fpm
PHP 8.5 with opcache, static pool, nginx in front. Compute runs use opcache.jit=tracing, set at process start and confirmed with opcache_get_status()
swoole
Swoole 6.2 on PHP 8.4, default configuration with SWOOLE_HOOK_ALL. PHP disables its JIT when Swoole is loaded
load
Closed loop, 32 keep-alive connections over raw sockets, 15 s warm-up, 10 s measured
workers
4 for throughput (FPM children, Swoole workers, BlitzVM CPU slots), 1 for the latency scenario
compute
Time measured inside the script with microtime; 12 runs per workload, one worker, median of the last three. Kernels in www/compute.php
api request
compute.php?k=api: usleep(20000), then the pixel loop on 300 × 300; 15 s warm-up, 10 s measured
latency
Hello requests every 300 ms while a 4 s CPU loop holds the only worker; load-generator start-up excluded
08

References

Where the ideas came from, and the work this builds on.

  1. truffleT. Würthinger et al. Self-Optimizing AST Interpreters. DLS 2012.Nodes that rewrite themselves to the types they see. Section 4.1 is this idea applied to PHP.
  2. graalvmT. Würthinger et al. One VM to Rule Them All. Onward! 2013.The case for building language runtimes on a shared VM and compiler.
  3. partial evaluationT. Würthinger et al. Practical Partial Evaluation for High-Performance Dynamic Language Runtimes. PLDI 2017.How Graal turns an interpreter plus a tree into machine code.
  4. originA. Bertschi. graalphp: a PHP implementation built on GraalVM. Undergraduate thesis, ETH Zurich, 2020.Showed that PHP on Truffle could work.
  5. truffle docsTruffle language implementation frameworkIncluding thread-local actions and safepoints, which the scheduler is built on.
  6. parsingV. R. Pratt. Top Down Operator Precedence. POPL 1973.The expression parser in BlitzVM's front end.
  7. javaJEP 444: Virtual Threads and JEP 491: Synchronize Virtual Threads without PinningThe basis of the virtual-thread prototype in section 06.
  8. httpNettyThe HTTP front end, on its native epoll transport.
  9. compared withPHP RFC: JIT and SwoolePHP's tracing JIT and Swoole are what the numbers in section 02 are measured against.
09

Code Access

The repo is private for now, because I'm writing up the scheduling and isolation work as papers first. If you're reviewing, researching or evaluating this, ask and I'll give you access.

ByAvinash D'Silva