Performance tuning¶
Kinetis exposes three capacity knobs that multiply into one deployment envelope: worker thread count, per-thread connection pool width, and the database server’s own limits behind them. This page covers how they interact, what saturates first under each workload shape, and which numbers to read before turning anything. Database-side tuning itself belongs to your database’s documentation; what’s here is the interaction surface. The figures quoted come from load testing on an 8-vCPU application host against a dedicated MySQL 8.4 host with sub-millisecond round trips.
Read Runtime Adapters’s “Sizing FrankenPHP’s worker threads”
and Persistence’s “Sizing maxConnections under worker mode”
first. This page builds on both.
The connection budget¶
Under FrankenPHP worker mode, every worker thread builds its own connection pool, so the deployment’s ceiling is a product, not a single number:
worker_threads × maxConnections ≤ comfortably under the DB's max_connections
Pool width comes from DB_MAX_CONNECTIONS (connection-scoped, like
every DB_* key) or the $poolOptions argument to
SqlConnectionFactory::fromConfig(); worker thread count from your
runtime’s own worker setting.
“Comfortably” is doing real work in that sentence: leave room for
migrations, monitoring agents, a second app instance, and anything
else that connects. A concrete working point: on 8 vCPUs, 20 worker
threads × 12 connections = 240 against MySQL’s default
max_connections of ~1000 is the joint optimum across read-heavy and
write-heavy routes. Overshooting the budget (64 threads × 20
connections = 1,280 against the same limit) is not a performance
problem but a correctness one: the surplus connection attempt is
rejected, and the failure arrives mid-request.
Two subtler ceilings sit below max_connections:
mysqli’s poll limit. The native MySQL driver waits on queries via
mysqli_poll(), which is built on the Cselect()call inside ext-mysqlnd and cannot track file descriptors numbered above 1024. This is separate from — and not fixed by — the Revolt event-loop driver extensions described in Runtime Adapters; it binds on the process-wide fd table, which under FrankenPHP also holds the Go server’s own client sockets. What decides whether a connection is pollable is its descriptor number at connect time: a pool connecting lazily under load opens sockets numbered after every client socket the server already holds, so even a within-budget pool can land past the ceiling mid-burst — the failure arrives asmysqli_poll()warnings and failed queries under concurrency, never at low load. Warm the pool at worker boot instead (DB_WARM_CONNECTIONS, orwarmConnectionsin$poolOptions— see Persistence): connections opened before traffic exists claim low numbers and keep them for the process’s lifetime. Keepworker threads × maxConnectionsunder ~1000 so a fully warmed pool fits below the ceiling at all.Prepared-statement count (MySQL). The PDO drivers cache prepared statements per connection (see Persistence), and MySQL caps server-side statements globally via
max_prepared_stmt_count(default 16,382). The relevant product isconnections × distinct statements per connection— generous headroom at the budgets above, worth knowing about at very wide ones.
Under PHP-FPM the same budget exists with different names: one PDO
connection per FPM worker, so pm.max_children is your connection
count. An FPM pool of 128 static workers is 128 connections.
Observe before you tune¶
The knobs only help if you turn the one that’s actually binding, and the binding constraint is a property of your routes, not of the framework. Three numbers, all cheap to get during a load test, answer “who saturates first”:
Application CPU (
topon the app host). If it’s pegged, more threads won’t help — the work itself needs to get cheaper.Database CPU (
topon the DB host). If it’s pegged while the app idles, no application-side knob will move throughput; see the write-heavy section below.In-flight database work (
SHOW GLOBAL STATUS LIKE 'Threads_running'on MySQL,SELECT count(*) FROM pg_stat_activity WHERE state = 'active'on Postgres). Tens of running — not merely connected — threads on a modest DB host means the database is queueing your queries internally; sending more concurrency adds latency, not throughput.
Read all three before turning anything: “add more threads” and “the CPU must be full somewhere” are both instincts that measurement regularly contradicts.
Tuning by workload shape¶
Read-heavy fan-out (many independent SELECTs per request via
concurrently()): this shape rewards width on both axes — threads at
2–3× vCPUs, and a pool wide enough that a typical request’s fan-out
doesn’t queue inside it. A 20-query route keeps gaining from pool
width all the way to 20 connections per thread; reads scale until one
of the two CPUs is genuinely full.
Write-heavy (per-row UPDATE/INSERT fan-outs): the opposite.
The database’s write path — row locks, index maintenance, the redo
log and its group commit — saturates long before either CPU count
suggests. Past roughly 8 connections per thread, additional write
concurrency reduces total throughput while the DB host runs at ~95%
CPU. Capping how many writes each individual request has in flight
does not help — contention tracks total in-flight writes across
all requests, not any one request’s share — so the levers for
DB-bound write throughput are on the database side: hardware, schema,
batching semantics, and the durability trade-offs of parameters like
innodb_flush_log_at_trx_commit or Postgres’s synchronous_commit,
which are business decisions about crash-loss tolerance, not free
speed.
Mixed (most real applications): budget for the write ceiling, spend the remainder on read width. The 20 × 12 working point above comes from exactly this compromise — read fan-out routes want 20 connections, write routes want 8, and 12 holds both within a few percent of their individual bests. When one route class truly dominates your traffic, bias toward its optimum; the numbers are close enough that precision beyond “width for reads, restraint for writes” is noise.
What not to tune¶
DB_DRIVER. Theautosplit (native under worker mode, PDO under FPM) is itself a measured optimum: forcing PDO under worker mode measures ~20% slower on fan-out routes, and forcing the async driver under FPM pays client CPU for overlap that boot-and-die lifetimes can’t use.The mysqli poll window. The native MySQL driver’s 1 ms poll quantum looks like an obvious latency suspect against a sub-millisecond database; it isn’t — measured throughput is insensitive to it.
Event-loop driver extensions.
ext-event/ext-uvexist for the fd-numbering ceiling (Runtime Adapters), not throughput; measured performance is identical when both work.
See also¶
Runtime Adapters — worker-thread sizing and the event-loop fd ceiling this page’s budget interacts with.
Persistence — the drivers,
maxConnections, and the prepared-statement cache.Concurrency — what
concurrently()does and what it costs.