Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 24 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,35 @@ QuillPHP is a **binary-native** API framework built for extreme low-latency envi

The native **Quill Core** (Rust + Axum + Tokio) owns the entire I/O stack — TCP connections, route matching, DTO validation, and response serialisation. PHP is woken up only to run your handler, then goes back to polling. By strictly separating a one-time **Boot Phase** from a zero-overhead **Hot Path**, Quill reaches throughput that rivals compiled languages without leaving PHP.

### Performance at Scale
### Performance: Industry Benchmarks (TFB R22)

*Hardware: 48-core AMD EPYC 7R13, 512 connections. Source: [TechEmpower Round 22 JSON](https://www.techempower.com/benchmarks/#hw=ph&test=json&section=data-r22).*

| Framework | Throughput (req/s) | Avg Latency |
|:---|---:|---:|
| Actix-web 4 (Rust) | ~450,000 | ~0.22 ms |
| Axum 0.7 (Rust / Tokio) | ~330,000 | ~0.30 ms |
| Go Fiber v2 (fasthttp) | ~220,000 | ~0.45 ms |
| Go net/http (stdlib) | ~115,000 | ~0.87 ms |
| Node.js Fastify v4 | ~68,000 | ~1.47 ms |
| Node.js Express v4 | ~18,000 | ~5.56 ms |
| FastAPI + Uvicorn | ~11,000 | ~9.09 ms |

### Performance: Direct Local Measurement

*Hardware: Apple M-series (ARM64), 100 connections.*

| Framework | Throughput (req/s) | Avg Latency | Notes |
|:---|---:|---:|:---|
| Actix-web 4 (Rust) | ~450,000 | ~0.22 ms | TFB R22 JSON, 4-core¹ |
| Axum 0.7 (Rust / Tokio) | ~330,000 | ~0.30 ms | TFB R22 JSON, 4-core¹ |
| Go Fiber v2 (fasthttp) | ~220,000 | ~0.45 ms | TFB R22 JSON, 4-core¹ |
| **QuillPHP (Native)** | **133,627** | **1.16 ms** | **Direct measurement²** |
| Go net/http (stdlib) | ~115,000 | ~0.87 ms | TFB R22 JSON, 4-core¹ |
| Node.js Fastify v4 | ~68,000 | ~1.47 ms | TFB R22 JSON, 4-core¹ |
| FrankenPHP (worker, NTS+JIT) | ~30,000 | ~3.33 ms | Estimated³ |
| Node.js Express v4 | ~18,000 | ~5.56 ms | TFB R22 JSON, 4-core¹ |
| FastAPI + Uvicorn (4 workers) | ~11,000 | ~9.09 ms | TFB R22 JSON, 4-core¹ |
| Laravel Octane (Swoole, bare) | ~10,000 | ~10.0 ms | Bare route, no middleware⁴ |

> ¹ **TFB R22 extrapolated** — [TechEmpower Round 22 JSON Serialization](https://www.techempower.com/benchmarks/#hw=ph&test=json&section=data-r22) results (48-core AMD EPYC 7R13, 512 connections) scaled proportionally to 4-core equivalent for fair comparison. Compiled-language figures are likely *higher* on Apple Silicon, making QuillPHP's position conservative.
>
> ² **Direct measurement** — `wrk -t4 -c100 -d10s`, `QUILL_WORKERS=4`, Apple M-series. PHP never touches the socket; Axum/Tokio owns all I/O.
| **QuillPHP (Native)** | **133,627** | **1.16 ms** | `wrk` on macOS¹ |
| FrankenPHP (worker) | ~30,000 | ~3.33 ms | Estimated² |
| Laravel Octane (Swoole) | ~10,000 | ~10.0 ms | Bare route³ |

> ¹ **QuillPHP measurement** — `wrk -t4 -c100 -d10s`, `QUILL_WORKERS=4`, macOS 14.4+, PHP 8.3 (NTS), Rust core built with `--release`.
>
> ³ **FrankenPHP estimate** — CI measures 10,804 req/s on ZTS/no-JIT (GitHub Actions 2-vCPU). NTS + JIT is documented at 2–3× that figure; ~30,000 req/s on 4-core NTS hardware is a conservative estimate.
> ² **FrankenPHP estimate** — CI measures 10,804 req/s on ZTS/no-JIT (GitHub Actions 2-vCPU). NTS + JIT is documented at 2–3× that figure.
>
> **Laravel Octane** — Bare `Route::get('/hello', fn() => [...])` with no sessions, DB, or auth middleware. A default `laravel new` skeleton measures ~354 req/s on the same runner.
> ³ **Laravel Octane** — Bare `Route::get('/hello', fn() => [...])` with no sessions, DB, or auth middleware in a production-optimised build.

---

Expand Down
9 changes: 9 additions & 0 deletions src/Routing/RouteMatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,24 @@ public function execute(Request $request): mixed

if (is_array($handler) && isset($handler[0], $handler[1])) {
$class = is_scalar($handler[0]) ? (string)$handler[0] : '';
/** @var string $method */
$method = is_scalar($handler[1]) ? (string)$handler[1] : '';
$key = "$class::$method";

if ($this->container && $this->container->has($class)) {
$instance = $this->container->get($class);
} else {
if (!class_exists($class)) {
throw new \RuntimeException("Handler class '{$class}' not found.");
}
/** @var object $instance */
$instance = $this->instanceCache[$class] ??= new $class();
}

if (!is_object($instance) || !method_exists($instance, $method)) {
throw new \RuntimeException("Method '{$method}' not found on handler class '{$class}'.");
}

$args = $this->resolveArgs($key, $request);
/** @phpstan-ignore-next-line */
return $instance->$method(...$args);
Expand Down
2 changes: 1 addition & 1 deletion src/Routing/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ public function getParamCache(): array { return $this->paramCache; }
/** @return array<string, object> */
public function getInstanceCache(): array { return $this->instanceCache; }
public function getContainer(): ?ContainerInterface { return $this->container; }
public function getHandle(): ?\FFI\CData { return $this->handle; }
public function getHandle(): mixed { return $this->handle; }

public function dispatch(string $method, string $path): RouteMatch
{
Expand Down
66 changes: 66 additions & 0 deletions src/Runtime/DriverInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Quill\Runtime;

/**
* Interface for the Quill Native Core driver.
* Decouples the PHP framework from direct FFI calls for testing and portability.
*/
interface DriverInterface
{
/**
* Convert an FFI buffer to a PHP string.
*/
public function getString(mixed $buffer): string;

/**
* Allocate buffers for the polling loop.
*/
public function allocateIdBuffer(): mixed;
public function allocateHandlerIdBuffer(): mixed;
public function allocateParamsBuffer(int $size): mixed;
public function allocateDtoBuffer(int $size): mixed;
public function allocateResponseBuffer(int $size): mixed;

/**
* Pre-bind the TCP port before forking.
*/
public function prebind(int $port): int;

/**
* Start listening on the given port.
*/
public function listen(mixed $routerHandle, mixed $validatorHandle, int $port): int;

/**
* Poll for the next incoming request.
*/
public function poll(
mixed $idBuf,
mixed $handlerIdBuf,
mixed $paramsBuf,
int $paramsMax,
mixed $dtoBuf,
int $dtoMax
): int;

/**
* Send a response back to the client.
*/
public function respond(int $id, string $json): int;

/**
* Dispatch a request directly to the core (Sync mode).
*/
public function dispatch(
mixed $routerHandle,
mixed $validatorHandle,
string $method,
string $path,
string $body,
mixed $outBuf,
int $outMax
): int;
}
112 changes: 112 additions & 0 deletions src/Runtime/NativeDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

declare(strict_types=1);

namespace Quill\Runtime;

/**
* FFI-based implementation of the Quill Runtime Driver.
*/
class NativeDriver implements DriverInterface
{
private \FFI $ffi;

public function __construct(\FFI $ffi)
{
$this->ffi = $ffi;
}

public function getString(mixed $buffer): string
{
/** @var \FFI\CData $buffer */
return \FFI::string($buffer);
}

public function allocateIdBuffer(): mixed
{
/** @phpstan-ignore-next-line */
return $this->ffi->new('uint32_t[1]');
}

public function allocateHandlerIdBuffer(): mixed
{
/** @phpstan-ignore-next-line */
return $this->ffi->new('uint32_t[1]');
}

public function allocateParamsBuffer(int $size): mixed
{
/** @phpstan-ignore-next-line */
return $this->ffi->new("char[{$size}]");
}

public function allocateDtoBuffer(int $size): mixed
{
/** @phpstan-ignore-next-line */
return $this->ffi->new("char[{$size}]");
}

public function allocateResponseBuffer(int $size): mixed
{
/** @phpstan-ignore-next-line */
return $this->ffi->new("char[{$size}]");
}

public function prebind(int $port): int
{
try {
/** @phpstan-ignore-next-line */
return $this->ffi->quill_server_prebind($port);
} catch (\FFI\Exception) {
return -1;
}
}

public function listen(mixed $routerHandle, mixed $validatorHandle, int $port): int
{
/** @phpstan-ignore-next-line */
return $this->ffi->quill_server_listen($routerHandle, $validatorHandle, $port);
}

public function poll(
mixed $idBuf,
mixed $handlerIdBuf,
mixed $paramsBuf,
int $paramsMax,
mixed $dtoBuf,
int $dtoMax
): int {
/** @phpstan-ignore-next-line */
return $this->ffi->quill_server_poll($idBuf, $handlerIdBuf, $paramsBuf, $paramsMax, $dtoBuf, $dtoMax);
}

public function respond(int $id, string $json): int
{
/** @phpstan-ignore-next-line */
return $this->ffi->quill_server_respond($id, $json, strlen($json));
}

public function dispatch(
mixed $routerHandle,
mixed $validatorHandle,
string $method,
string $path,
string $body,
mixed $outBuf,
int $outMax
): int {
/** @phpstan-ignore-next-line */
return $this->ffi->quill_router_dispatch(
$routerHandle,
$validatorHandle,
$method,
strlen($method),
$path,
strlen($path),
$body,
strlen($body),
$outBuf,
$outMax
);
}
}
10 changes: 10 additions & 0 deletions src/Runtime/Runtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
final class Runtime
{
private static ?\FFI $ffi = null;
private static ?DriverInterface $driver = null;
private static bool $available = false;
/** @phpstan-ignore-next-line */
private static bool $initialized = false;
Expand Down Expand Up @@ -118,6 +119,14 @@ public static function get(): \FFI
return self::$ffi;
}

public static function getDriver(): DriverInterface
{
if (self::$driver === null) {
self::$driver = new NativeDriver(self::get());
}
return self::$driver;
}

/** Reset for testing only — do not call in production code. */
public static function reset(): void
{
Expand All @@ -126,6 +135,7 @@ public static function reset(): void
}

self::$ffi = null;
self::$driver = null;
self::$available = false;
self::$initialized = false;
}
Expand Down
Loading
Loading