sun, 22:41 at the minemoon, waxing crescent, 36% lit - 22:41 at the mine(spicemine)水木金森

sign inEN日本

mélangej · ABCL on Jooby and Netty

Performance

Six servers, one page, the same bytes. Everything here was measured with the code in this repository and you can re-run it: core/bench/run-all.sh.

Read the CPU ms/req column first. It is the only figure here that transfers to your machine.

Method

Setup. Apple M5 Pro, 18 cores, 48 GB, macOS. OpenJDK 17.0.19 for every JVM server, SBCL 2.5.9, rustc stable. Loopback, keep-alive, 50 connections, 10 seconds per run, 5 runs, plus a whole discarded warm-up run. The generator is wrk, which is threaded C and therefore not the thing being measured.

Re-run 2026-11-01 (spicemine 0.71.0; the first run was in October, at 0.10.0). The machine was not idle - load averages between 6 and 21 from other work - so spreads reach 10%, and a difference smaller than that is not a ranking. One fix to the instrument came out of the re-run: the Java control started with whatever java was first on PATH (JDK 11 on this machine) while the other JVM servers honoured JAVA_HOME; the harness now runs them all on one JDK and prints which.

Each server renders the same 35 KB page — a 212-row table built per request, never cached — and the harness diffs every server's body against the Java one and refuses to print numbers if they disagree. One server runs at a time, each with its own heap cap, below a memory floor that aborts the run.

The six: mélangej (ABCL on Jooby/Netty, fasl-compiled), Java (the same Jooby on the same Netty), JSP (Jetty 12 + Apache Jasper), actix-web (Rust, native code), Hunchentoot (SBCL) and ningle/Clack (SBCL).

core/bench/README.md has the methodology in full, including how the load generator's own ceiling and the JVM's warm-up are kept out of the numbers.

The 35 KB page

Every server unconstrained, each sizing its own pools from 18 cores:

                        req/s      p50      p99   CPU ms/req
  ----------------------------------------------------------
  actix-web (Rust)    132 162   0.34ms   0.46ms      0.045
  Java (Jooby/Netty)  107 902   0.32ms   2.48ms      0.084
  JSP (Jetty/Jasper)   88 371   0.49ms   0.71ms      0.127
  mélangej (ABCL)      12 894   2.30ms  115.6ms      1.465
  Hunchentoot (SBCL)   10 719   3.61ms   86.6ms      1.534
  ningle/Clack (SBCL)   8 424   4.79ms   89.6ms      1.907

mélangej is level with the best of the SBCL servers. 1.2× Hunchentoot and 1.5× ningle on throughput, and — the part that travels — 1.47 CPU-ms per request against Hunchentoot's 1.53 and ningle's 1.91: inside this run's spread of Hunchentoot, ahead of ningle. ABCL compiling to JVM bytecode holds its own against SBCL compiling to native code on this workload, because the workload is string building and the JVM is good at it. Its p99 is the worst of the three.

The October run had mélangej at 1.5× Hunchentoot and the cheapest Lisp by a third. mélangej's own figure has not moved (1.50 then, 1.47 now); the SBCL servers measured cheaper on the same SBCL, so the earlier gap was mostly the noise of that run. Quote the CPU column with its spread, not the ranking.

Against native code it is 33× behind actix and 17× behind Java. That gap is real and no amount of emitter tuning closes it.

What a 2-vCPU node will actually do

Throughput on an 18-core laptop answers a question nobody deploying this has. The transferable number is CPU time per request: a 2-vCPU host has 2000 CPU-ms per second to spend.

                      CPU ms/req   2 vCPU    4 vCPU
  --------------------------------------------------
  actix-web                0.045   44 731    89 463
  Java                     0.084   23 757    47 514
  JSP                      0.127   15 729    31 458
  mélangej                 1.465    1 365     2 730
  Hunchentoot              1.534    1 303     2 607
  ningle/Clack             1.907    1 048     2 097

Those are upper bounds: perfect parallelism, no memory-bandwidth contention, no noisy neighbour, and a free network, which loopback is and your VPS is not.

To check the estimate rather than trust it, the October run was repeated with every pool-sizing server given a budget of two I/O threads. For mélangej the estimate predicted 1,337 rps and the measurement gave 2,805 — the right order, conservative by about 2×, because per-request CPU cost itself falls when there is less thread contention (1.50 CPU-ms unconstrained, 1.02 at two threads).

So: the CPU estimate is good to about a factor of two; throughput on the measuring machine is wrong by a factor of fifty. Use the CPU column.

Two results from the constrained pass are worth knowing. Giving the JVM servers fewer threads made them faster — Java went 117,597 → 156,185 — because the generator and the server stop competing for cores. And Hunchentoot is thread-per-connection, so it does not size by cores and is not constrained by that budget; it would spawn a thread per connection on a 2-vCPU host too. Its row in that pass is unconstrained and should be compared only on CPU-ms.

Static files, which are most of the requests

A real site serves far more files than rendered pages. Three assets, each server using the static handler its own ecosystem would use — Jooby's AssetHandler for Java and mélangej, Jetty's DefaultServlet for JSP, actix-files, Hunchentoot's folder dispatcher, Lack's static middleware. page.html is byte-identical to what /page?rows=212 renders, which makes the last row of this table the whole point:

                        req/s      p50      p99      MB/s
  --------------------------------------------------------
  JSP (Jetty)          112 626   0.48ms   1.07ms    3 762
  Java (Jooby)          74 719   0.66ms   1.21ms    2 496
  mélangej (Jooby)      72 056   0.67ms   1.24ms    2 407
  actix-web             57 351   0.88ms   1.33ms    1 915
  Hunchentoot           50 285   0.80ms   8.84ms    1 679
  ningle/Clack          29 444   1.13ms  36.09ms      983

  mélangej, RENDERED     2 743  18.20ms  19.58ms       92

Serving the file is 26× faster than rendering the same bytes. Identical output, 0.67 ms against 18.2 ms. Anything you can turn into a file, turn into a file.

On static content mélangej is within 4% of Java, because the Lisp is not in the path: both are the same AssetHandler on the same Netty, so this measures Netty. That is the useful thing about the result — it says the static half of a mélangej application has no Lisp tax at all, and it is 1.4× Hunchentoot and 2.4× ningle with a tail that is not close (p99 1.24 ms against 8.84 and 36.09).

Two results worth knowing. Jetty's DefaultServlet is the fastest static server here by 50%, which is what twenty-five years of people serving files with it buys. And actix-files is slower than the JVM servers — 57,351 against 74,719 on the 35 KB file, and on a 256 KB PNG 26,656 against 57,267 — so the stack that won every dynamic comparison loses this one; the JVM servers are reaching a zero-copy path it is not.

None of which is the constraint

At 2,407 MB/s that 35 KB file is moving 19 Gbit/s, because loopback has no network in it. A 1 Gbit NIC is 125 MB/s. Every server here saturates any network you would put in front of it, by a factor of twenty or more.

So the reason to put nginx or a CDN in front of spicemine is caching, TLS termination and edge locality — not throughput. And the reason to care about the 26× is not the file serving; it is that it prices the rendering.

Dispatch is nearly free; rendering is the whole cost

A constant 2-byte response, two I/O threads:

                        req/s   CPU ms/req
  ----------------------------------------
  actix-web             338 850      0.008
  Java                  312 115      0.011
  mélangej (ABCL)       207 056      0.026
  JSP                   205 200      0.024
  Hunchentoot (SBCL)    179 591      0.056
  ningle/Clack (SBCL)    91 844      0.114

mélangej is level with JSP and twice Hunchentoot here. Crossing from Netty into ABCL and back costs about 0.026 CPU-ms, which is noise. Every Lisp penalty in the previous table is paid in rendering, not in the HTTP path or the interop boundary — which is worth knowing, because rendering is the part we control.

Three ways to build the same page

Since rendering is the cost, it is worth knowing what each of our three options costs. Same page, same bytes, same process, two I/O threads:

                           req/s   CPU ms/req   vs concat
  --------------------------------------------------------
  string concatenation     2 805        1.020        1.0x
  .html template           1 596        1.757        1.7x
  s-expression emitter       484        5.800        5.7x

The emitter is the expensive one, and it is the one the UI uses. The cost is allocation rather than algorithm — it streams correctly and is linear in the size of the page. Most of it was removable: escaping now writes straight to the output stream in runs of safe characters instead of building a fresh string per text node, tag names are memoised instead of being downcased on every tag and attribute, and format is gone from the hot path. That took the emitter from 11.1 to 5.8 CPU-ms, with all 23 escaping tests unchanged.

It is still 5.7× concatenation, and the .html template engine is 3.3× faster than the emitter because it compiles each template to bytecode once. If a page is hot and its markup is static, the template is the faster tool; the emitter earns its place by being composable and by escaping structurally rather than by reminding you.

A caveat that matters: this is a pathological page — 212 rows, 848 cells, no caching. Real spicemine pages are a tenth of it.

Memory

Peak RSS while serving the 35 KB page, with Netty's pools left at their default size and then capped to the host's actual vCPU count:

                              default pools   io=2 worker=2
  mélangej (ABCL)                 1 599 MB         584 MB (October)
  Java (Jooby/Netty)                670 MB            -
  JSP (Jetty)                       525 MB            -
  SBCL (both servers)               609 MB            -
  actix-web                          14 MB            -

The mélangej number is a configuration, not a floor. Netty sizes its pools from the core count of the machine it finds itself on; on 18 cores that is 455 MB of thread-stack reservation and enough in-flight work to keep the collector on a 1.2 GB heap. Capped to two I/O and two worker threads, the same server runs at the same throughput in 584 MB, and a real instance serving pages sits at 511 MB; the whole of spicemine 0.71.0, just started at io=2 worker=2 on JDK 17, is 377 MB. A 1 GB host is enough. Chapter 10 has the settings.

Against actix at 14 MB the ratio is still roughly 35×, and that is the price of this architecture, stated plainly.

Compile your Lisp

ABCL interprets code that arrives through load of a .lisp file:

                       interpreted    compiled
  render 35 KB page       23.3 ms       7.5 ms    3.1x
  GET /                2 008 req/s   7 041 req/s  3.5x
  GET /api/v0/ping    47 776 req/s  58 867 req/s  1.2x

The whole tree compiles in about 7 seconds - 27 files on 0.71.0, where it was 1.2 seconds for 16 in October; the files grew with the service. compile-all.lisp does it in load order — a macro must exist before the file that uses it is compiled, not merely before it runs — and boot.lisp prefers NAME.abcl, falling back to NAME.lisp so a working tree still runs with no compile step.

This is not a footnote, and it is the easiest thing in the system to forget: nothing fails, the server simply runs at a third of its speed.

What it costs before it serves a request

Throughput is not the only price a stack charges. Three more, measured the same way.

Dependencies and artifact size

                        declared   total units        artifact        runtime
  ----------------------------------------------------------------------------
  mélangej (ABCL)        4 coords   29 jars            40 MB jars      JVM
                                    + 0 Lisp systems   + 1,964 KB Lisp
  Java (Jooby/Netty)     2 coords   22 jars            19 MB           JVM
  JSP (Jetty/Jasper)     3 coords   20 jars             9 MB           JVM
  actix-web (Rust)       2 crates  160 crates           3 MB binary    none
  Hunchentoot (SBCL)     1 system   22 ASDF systems    51 MB tree      SBCL
  ningle/Clack (SBCL)    3 systems  57 ASDF systems    51 MB tree      SBCL

Two crates declared, 160 resolved. That is the Rust number, and it is the ordinary experience of the ecosystem rather than anything unusual about actix — a server that returns a string pulls in a hundred and sixty packages, each with its own maintainer, release cadence and audit surface. It buys a 3 MB binary that needs no runtime installed, which is the best artifact in the table by a wide margin.

Three systems declared, 57 resolved on the ningle side. Worth stating plainly because the Lisp ecosystem's reputation is for being small: a routing framework on Clack is a 57-system graph, mostly written and tested on SBCL. That is the transitive-dependency argument from chapter 1 with a number attached, and it applies to Lisp as much as to anyone.

mélangej has the largest JVM artifact and the smallest dependency graph: 28 jars, of which 22 are the framework and six are per-feature (JGit, sqlite-jdbc, postgres), and zero Lisp systems. The footnote it needs is that this is paid for in source: it is why the project contains its own Markdown renderer, JSON codec, HTML emitter and migration machinery. The dependency count is low because the line count is not.

Compile time

                         compile    on 2 cores   note
  ---------------------------------------------------------------------------
  mélangej (ABCL)          ~7 s        -        27 files to fasls (1.3 s at 16)
                         (+8.5 s)                ABCL boots before it compiles
  Java (Jooby/Netty)       0.3 s        -        one file; framework is prebuilt
  JSP (Jetty/Jasper)       N/A          -        deferred - see below
  actix-web (Rust)        22.7 s      41 s       clean target, LTO, 160 crates
  Hunchentoot + ningle     9.8 s        -        336 fasls, once per install
   + clack + lack                                0.3 s to load them afterwards

JSP has no ahead-of-time compile, but it is not free. Jasper compiles each page to a servlet on its first request: measured, the first GET /page took 0.220 s and the second took 0.002 s — a 110× first-hit penalty, once per page per deploy. "N/A" in that column means deferred, not absent, and the cost falls on whoever makes the first request after a restart.

Rust is the slow one, and the gap widens on small hardware. 22.7 s on eighteen cores, 41 s on two — and two cores is the deployment target this chapter keeps coming back to. The JVM and Lisp numbers barely move with core count because there is far less to compile.

The mélangej figure needs its asterisk: compiling the eighteen files it had then took 1.7 s, but the task took about ten seconds because ABCL had to boot before it could compile anything, and that boot was the same 8.5 s as every cold start of the time.

From an empty cache

How long before each stack builds at all on a machine that has never seen it. Each assumes its own toolchain is already installed, which is not the same baseline, so read the sizes more than the seconds:

                      fetch    downloaded   of which
  --------------------------------------------------------------------
  JVM (Gradle)         29 s      330 MB     290 MB Gradle itself,
                                            39 MB of jars
  actix (Cargo)         3.4 s    140 MB     160 crates of sources
  SBCL (Quicklisp)        -       51 MB     already-installed dist tree

The Gradle number is mostly not the dependencies. 290 MB of the 330 is the Gradle distribution; the jars this project actually needs are 39 MB. That is a one-time cost on a build machine and zero on the server, because the deployed artifact is a directory of jars with no package manager on the far side — which is the thing that lets deploy.sh be forty lines.

Cargo's 3.4 s for 140 MB is the fastest fetch here; crates.io is very good at this. It is also 140 MB of other people's source code to compile, which is where the 41 s on two cores comes from.

Startup, which is still the real difference

  SBCL, saved core                0.027 s   (October)
  actix-web                       0.004 s   (October)
  Java (Jooby/Netty)              0.18  s   (October)
  spicemine 0.71.0 on mélangej    2.37  s   JDK 17, launch to first answered ping
     bare ABCL                      0.66 s
     + load 27 compiled files       0.55 s
     + start the service            1.16 s   database, personas, Jooby and Netty
  the same on JDK 26              2.27  s
  the same, JDK 26 + AOT cache    1.69  s   (see below)

  what it used to spend, and no longer does:
     + (require :abcl-contrib)      +5.8 s
     + (require :jss)               +2.0 s

Medians of ten starts on a data directory already migrated, measured 2026-11-01 (load average about 4). In October, at 20 files, the whole was 2.0 s; the service has grown since, and more than half of today's 2.37 s - loading our files and starting the service - is ours to work on, not the JVM's.

It was 9.4 seconds and 8.2 of those were two require forms. ABCL loading its own contrib system through ASDF, so that one reader macro — jss's #"method" — would be available. Nothing we wrote made it slower and nothing we could write would have made it faster.

The fix was to stop using the reader macro. In jss's own source, #"foo" is (apply #'jcall "foo" object args), so all 113 sites became (java:jcall "foo" object args), which is ABCL's own package and needs no require at all. Both requires went with it. Nothing else in the tree wanted abcl-contrib.

SBCL still starts 75× faster, and that is still the number that decides whether mélangej fits a problem: a long-lived service restarted on deploy, fine; a scale-to-zero function or a CLI that boots a server per invocation, wrong shape. But a two-second restart is a deploy nobody schedules around, where a nine-second one is. Chapter 10 covers upgrading a running image instead of restarting it, which matters less now than it did.

The JVM's own AOT cache

JDK 25 and later can record what a training run loaded and linked, and map it at the next start (-XX:AOTCacheOutput= once, then -XX:AOTCache=; JEPs 483 and 514). Trained on one run that served a few pages, spicemine started in 1.69 s against 2.27 s on the same JDK 26 - a quarter off, for a 95 MB file and two flags. It caches the JVM's and Jooby's classes; ABCL defines our compiled Lisp as classes at run time, which the cache does not hold, so the 0.55 s of loading our files stays. The training run warns that Netty's optional native-TLS classes cannot be cached, which is harmless. Production runs JDK 17, which has no such cache; this is a reason to move, not a setting to turn on today.

Lisp on Java, Java on Lisp: OpenLDK

OpenLDK (Anthony Green, October 2026) is mélangej's mirror image: Java on Common Lisp. It reads JVM bytecode, translates each method to Lisp the first time it is called, and lets SBCL compile that to native code, with Java objects in the Lisp heap. Its published numbers are for one thing - starting a small Spring Boot command-line app and running a short task, on an Intel Core Ultra 7 255H - and its author says plainly that for general Java execution HotSpot will be much faster. There are no throughput figures.

                                         median    peak RSS
  HotSpot, fat jar                      2 138 ms     266 MB
  HotSpot + JDK AOT cache                 759 ms     181 MB
  HotSpot + Spring AOT + AOT cache        595 ms     157 MB
  GraalVM native image                     54 ms      41 MB
  OpenLDK, warm image                   1 262 ms     450 MB
  OpenLDK, snapshot image                  84 ms     175 MB

(Their table, quoted; not re-measured here.) What it says about this stack:

  • The two directions pay the same toll the other way round. Code compiled by a runtime built for another language is slower than that language on its own runtime: Java on OpenLDK is slower than HotSpot, and Lisp on the JVM is 17× behind plain Java on the page above. Neither is surprising, and neither project hides it.
  • Their big startup win is an image; ABCL cannot save one. OpenLDK's 84 ms is SBCL saving an initialized Spring context and restarting from it. ABCL has no save-lisp-and-die. The JVM-side equivalents are, in order of how far they go: the AOT cache (measured above, a quarter off), CRaC (a checkpoint of a running JVM restored in milliseconds - Linux only, needs a CRaC-built JDK, and like OpenLDK's snapshot it does not keep threads, so Netty would have to be stopped and started around it; not tried here), and GraalVM native image (closed world - ABCL compiles and defines classes at run time, which a native image forbids, so it is not available to us at all).
  • A direct comparison is possible but has not been made. OpenLDK lists ABCL among the languages it runs, so mélangej could in principle run on SBCL by way of OpenLDK: Lisp, on Java, on Lisp. That would put the same framework and the same page on two runtimes. It needs Linux (OpenLDK's development target), JDK 25, and OpenLDK itself, and Netty's threads are the likely trouble. It is a question worth an afternoon, not a plan.

What it means in practice

The instance serving this book hosts repositories for a handful of people on a small VPS. Two vCPUs give it something like 1,300-2,800 renders per second of the worst page in the system, and the real pages are a tenth the size. Static assets it will serve at tens of thousands per second, which is more than its network can carry.

So the order of things it will run out of is: network, then disk, then the version-control engines, then rendering.

The measurement exists so that sentence is a number instead of a feeling — and so that when it stops being true, we find out by re-running a script rather than by reading a complaint.

Re-running this

core/gradlew -p core installDist
core/bench/run-all.sh                 # defaults: 212 rows, 50 conn, 8s x 3
core/bench/run-all.sh 212 50 8 3 2 2  # ... with a 2-core budget

It starts one server at a time, caps each JVM, refuses to run below a memory floor, verifies every payload against the Java reference, and keeps the server logs. If your numbers differ from these, yours are the ones about your machine.

← DeploymentWho it is for →