Performance under load
On this page
- How it was measured
- Cost per request
- Mixed pages, 64 connections
- Query heavy pages (N+1 and transactions), 64 connections
- One uncaught bug hit again and again, 64 connections
- Errors
- Every request a new kind of error, 64 connections (one run)
- Bottlenecks, peak and soak
- What BxLens itself counted
- Memory and threads over the soak
- The server's own log
- One request while the worker pool is full
- What this means
- Caveats
- Repeat it
Performance under load
We pushed a server hard with and without BxLens to answer one question: is BxLens the bottleneck? The short answer:
- BxLens adds a fixed cost to every request. On the demo pages, which are very cheap (2 to 3 ms of CPU each),
lightwith the bar off added about 1.2 ms of CPU per request and the bar added about 1 ms more. In percent of throughput that looks large on pages this small. On a page that spends 50 ms of CPU, the same 1 to 5 ms is 2% to 10% (arithmetic, not a measurement). - It does not become the bottleneck when something else is. With a slow database query, a full worker pool, or a flood of identical or unique errors, the throughput is set by the application and BxLens stays out of the way. The work queue never dropped or failed an event in any run.
- It stays bounded. The error flood guard held, the issue count stopped at its cap, and memory and threads were flat over a four minute soak with a few MB retained after a full garbage collection.
For production use collect.level light and turn the bar off. See Running BxLens in production. Older figures at low concurrency are on Performance.
How it was measured
- Machine. A shared cloud container with 4 vCPU and 16 GB RAM. The load client runs on the same machine as the server and takes cores from it, so read the numbers as relative, never as capacity.
- Server. OpenJDK 21, BoxLang 1.19.0-snapshot on MiniServer, the demo harness (
harness/), derby in memory. BoxLang+ features are on for every BxLens setting. - Settings. No module installed, then
lightandfullwith the bar off and on. The bar adds a script tag and a snapshot to HTML pages. - Traffic. Closed loop clients (each waits for its answer) at 64 connections for 30 seconds after an 8 second warm up, unless a scenario says otherwise. Pages are ones that behave the same without the module.
- Repeats. Three rounds, every setting once per round, the order rotated each round so that drift of a shared machine hits every setting alike. Tables show the median and, in brackets, the lowest to the highest of the three. Scenarios marked "one run" ran once, so no spread is shown.
- Cost per request. Server process CPU and allocation over JMX divided by the requests served.
| Scenario | What it does |
|---|---|
mixed | Seven pages: plain, N+1 queries, a transaction, function calls, cache, forms, session. |
sql | The N+1 and the transaction pages only. |
same | One uncaught error again and again. |
unique | Every request a different uncaught error (400 kinds). |
slowsql | A query that burns CPU in the database, 32 connections. |
stall | Pages that hold their worker thread for about 10 seconds, 128 connections. |
peak | The mixed pages at 256 connections. |
soak | Four minutes of mixed pages plus errors at 32 connections, with heap, threads and GC sampled. |
Cost per request
Mixed pages, 64 connections
| Setting | Runs | Requests/s | p50 ms | p99 ms | CPU ms per request | Allocated KB per request | Requests/s against no module |
|---|---|---|---|---|---|---|---|
| No module | 3 | 946 (806 to 1090) | 50 (43 to 57) | 222 (204 to 318) | 2.98 (2.60 to 3.25) | 315 (314 to 316) | |
| light, bar off | 3 | 694 (683 to 816) | 69 (61 to 70) | 280 (224 to 292) | 4.17 (3.64 to 4.28) | 378 (377 to 379) | -27 % |
| light, bar on | 3 | 595 (518 to 703) | 84 (73 to 96) | 324 (260 to 388) | 5.07 (4.36 to 5.73) | 454 (451 to 458) | -37 % |
| full, bar off | 3 | 647 (609 to 709) | 77 (70 to 82) | 288 (251 to 303) | 4.60 (4.23 to 4.93) | 501 (501 to 502) | -32 % |
| full, bar on | 3 | 542 (496 to 561) | 101 (96 to 112) | 304 (302 to 327) | 5.73 (5.61 to 6.30) | 652 (651 to 657) | -43 % |
Query heavy pages (N+1 and transactions), 64 connections
| Setting | Runs | Requests/s | p50 ms | p99 ms | CPU ms per request | Allocated KB per request | Requests/s against no module |
|---|---|---|---|---|---|---|---|
| No module | 3 | 979 (953 to 996) | 63 (58 to 63) | 154 (144 to 158) | 2.66 (2.63 to 2.77) | 425 (425 to 425) | |
| light, bar off | 3 | 657 (594 to 672) | 95 (91 to 104) | 218 (205 to 227) | 4.62 (4.40 to 5.08) | 478 (475 to 481) | -33 % |
| light, bar on | 3 | 527 (442 to 553) | 121 (117 to 142) | 236 (213 to 301) | 5.76 (5.56 to 6.78) | 578 (576 to 591) | -46 % |
| full, bar off | 3 | 502 (463 to 509) | 127 (123 to 138) | 242 (234 to 254) | 6.09 (6.03 to 6.59) | 787 (786 to 787) | -49 % |
| full, bar on | 3 | 388 (353 to 440) | 165 (144 to 181) | 292 (253 to 313) | 7.95 (7.14 to 8.84) | 910 (901 to 922) | -60 % |
One uncaught bug hit again and again, 64 connections
| Setting | Runs | Requests/s | p50 ms | p99 ms | CPU ms per request | Allocated KB per request | Requests/s against no module |
|---|---|---|---|---|---|---|---|
| No module | 3 | 1338 (1269 to 1417) | 40 (38 to 42) | 142 (142 to 161) | 1.93 (1.86 to 2.07) | 305 (305 to 305) | |
| light, bar off | 3 | 1096 (932 to 1128) | 50 (47 to 57) | 168 (167 to 189) | 2.47 (2.35 to 2.82) | 331 (331 to 333) | -18 % |
| light, bar on | 3 | 1031 (904 to 1129) | 51 (47 to 60) | 180 (168 to 189) | 2.60 (2.39 to 2.93) | 332 (331 to 333) | -23 % |
| full, bar off | 3 | 986 (913 to 1034) | 54 (51 to 59) | 185 (176 to 190) | 2.74 (2.64 to 2.99) | 366 (365 to 366) | -26 % |
| full, bar on | 3 | 931 (882 to 972) | 59 (57 to 61) | 188 (183 to 197) | 3.04 (2.85 to 3.09) | 367 (365 to 368) | -30 % |
What these say:
- The pages cost 2 to 3 ms of CPU without the module, so a fixed 1 to 3 ms is a large share. Read the CPU ms per request columns, not the percentages.
lightwith the bar off adds about 1.2 ms (mixed), 2.0 ms (query heavy) and 0.5 ms (one error). The bar adds about 0.9 to 1.1 ms more on the pages that render it (the error page does not).fullcosts most on query heavy pages (collecting the caller and bound values of every statement): 3.4 ms with the bar off and 5.3 ms with the bar on, over the 2.7 ms baseline.- These are higher than the 0.05 to 0.4 ms measured with 4 connections for Performance. At 64 connections on 4 shared cores, the JVM also spends time on queuing, garbage collection and the background worker, and the client competes for the same cores. Both are real; the lower figure is for an idle machine and the higher for a saturated one.
Errors
Every request a new kind of error, 64 connections (one run)
| Setting | Runs | Requests/s | p50 ms | p99 ms | CPU ms per request | Allocated KB per request | Requests/s against no module |
|---|---|---|---|---|---|---|---|
| No module | 1 | 936 | 59 | 207 | 2.99 | 316 | |
| light, bar off | 1 | 947 | 59 | 188 | 3.10 | 344 | +1 % |
| full, bar on | 1 | 722 | 79 | 237 | 4.06 | 383 | -23 % |
The flood guard keeps this cheap: at most 1000 events per second are counted, only 20 new issues per minute are opened (after that they go into one overflow issue) and the issue list stops at 200. In the runs the issue count stayed at 21 (20 new plus the overflow issue) after a unique error run and at 48 to 52 after the soak, and the console stayed responsive. Error events dropped by the flood guard below is by design.
Bottlenecks, peak and soak
| Scenario | Setting | Requests | Requests/s | p50 ms | p99 ms | max ms | Failed requests (client) |
|---|---|---|---|---|---|---|---|
| peak-c256 | No module | 32302 | 1076 | 218 | 587 | 872 | 0 |
| peak-c256 | light, bar off | 31405 | 1047 | 225 | 615 | 955 | 0 |
| peak-c256 | full, bar on | 16332 | 544 | 452 | 963 | 1229 | 0 |
| slowsql-c32 | No module | 64 | 3 | 11431 | 13446 | 13446 | 0 |
| slowsql-c32 | light, bar off | 58 | 3 | 11834 | 13979 | 13979 | 0 |
| slowsql-c32 | full, bar on | 40 | 2 | 13308 | 14484 | 14484 | 0 |
| stall-c128 | No module | 288 | 10 | 13608 | 13863 | 13869 | 0 |
| stall-c128 | light, bar off | 288 | 10 | 13610 | 13811 | 13850 | 0 |
| stall-c128 | full, bar on | 288 | 10 | 13628 | 13869 | 13923 | 0 |
| soak-c32 | No module | 379190 | 1580 | 18 | 59 | 159 | 0 |
| soak-c32 | light, bar off | 335094 | 1396 | 20 | 67 | 257 | 0 |
| soak-c32 | full, bar on | 280588 | 1169 | 24 | 79 | 225 | 0 |
- peak (256 connections, one run per setting):
lightwith the bar off was within the noise of no module (about 3% lower).fullwith the bar on served about half as many requests per second, which is the same per request cost as above, now with the cores saturated. - slowsql: the database burns the CPU and the rate is 2 to 3 requests per second for everyone. With
fulland the bar on, 40 requests finished in the window against 64 without the module. This is one run, and the difference is the extra CPU competing with a CPU bound query, so do not read more into it than "BxLens adds CPU, and a CPU bound server notices". - stall: every setting served the same 288 requests in the same time (p50 13.6 s). The app held its threads, and BxLens added nothing.
- soak: throughput without the module was 1580 requests per second, 1396 with
lightand the bar off and 1169 withfulland the bar on. No failed requests.
What BxLens itself counted
| Setting | After | Work queue waiting | Events dropped by the queue | Events failed | Error issues | Error events counted | Error events dropped by the flood guard | Statements kept |
|---|---|---|---|---|---|---|---|---|
| light, bar off | after-same | 0 | 0 | 0 | 1 | 33949 | 0 | 3 |
| light, bar off | after-unique | 0 | 0 | 0 | 21 | 33338 | 0 | 3 |
| light, bar off | after-peak | 0 | 0 | 0 | 21 | 33338 | 0 | 4 |
| light, bar off | after-soak | 0 | 0 | 0 | 52 | 277863 | 45006 | 4 |
| light, bar on | after-same | 0 | 0 | 0 | 1 | 37729 | 2748 | 3 |
| full, bar off | after-same | 0 | 0 | 0 | 1 | 35982 | 194 | 3 |
| full, bar on | after-same | 0 | 0 | 0 | 1 | 35708 | 0 | 3 |
| full, bar on | after-unique | 0 | 0 | 0 | 21 | 25665 | 0 | 3 |
| full, bar on | after-peak | 0 | 0 | 0 | 21 | 25665 | 0 | 4 |
| full, bar on | after-soak | 0 | 0 | 0 | 48 | 260493 | 7499 | 4 |
The work queue never had anything waiting, and dropped or failed nothing, in any run. Events dropped by the flood guard are errors it chose not to record, not a failure. "Statements kept" is the number of different statements in the statistics.
Memory and threads over the soak
| Setting | Heap used, lowest to highest (MB) | Heap after a full GC at the end (MB) | Threads | Time in garbage collection |
|---|---|---|---|---|
| No module | 46 to 327 | 32 | 79 to 83 | 0.7 % |
| light, bar off | 131 to 473 | 43 | 89 to 95 | 0.8 % |
| full, bar on | 92 to 514 | 44 | 88 to 94 | 1.0 % |
Heap use rises and falls with the garbage collector in all three settings. What the server holds after a full collection at the end is 11 to 12 MB more with BxLens (32 MB without, 43 and 44 MB with), the thread count is about 10 higher (BxLens worker and stream threads) and the share of time in garbage collection goes from 0.7% to 0.8% and 1.0%. Nothing grew during the run.
The server's own log
| Scenario | Setting | Log written (MB) |
|---|---|---|
| unique-c64 | No module | 121 |
| same-c64 | No module | 173 |
| soak | No module | 1178 |
| unique-c64 | light, bar off | 120 |
| same-c64 | light, bar off | 146 |
| soak | light, bar off | 1040 |
| same-c64 | light, bar on | 136 |
| same-c64 | full, bar off | 137 |
| unique-c64 | full, bar on | 96 |
| same-c64 | full, bar on | 129 |
| soak | full, bar on | 902 |
Uncaught errors write a stack trace to the server log whether BxLens is installed or not (a gigabyte over four minutes of soak). Rotate or cap that log on a server that can fail at this rate. It is not caused by BxLens.
One request while the worker pool is full
The console runs inside the same server, so with every worker thread busy it waits like any other request. In the stall run the console overview took 13.5 s while the pool was full, the same as an app page (13.3 s without the module), and 15 to 25 ms five seconds after the stall ended. Use the monitoring that runs outside the server (a health probe, JMX, your platform) to tell you that the pool is full; BxLens shows what filled it once a worker is free. The raw results of this run are in tools/perf/results/load-report/.
What this means
- Pages that spend most of their time in your code or in the database see a small relative cost. Pages that are nearly free see a large one, because the cost is fixed per request.
- Running the console with
collect.levellightand the bar off is the cheapest setting that still records requests, queries, errors and the history. - The bar and
fullcollection are for development and for looking into a problem. Switch them on for a while and off again. - Errors, however many and however varied, are bounded in work, memory and disk.
Caveats
- One shared 4 vCPU machine, the client on the same cores, a demo app. Absolute numbers will differ on yours, and three runs per cell is few: the spread in brackets is large for some cells (mixed pages with no module ran from 806 to 1090 requests per second).
- Scenarios
unique,peak,slowsql,stallandsoakran once per setting, and only for no module,lightwith the bar off andfullwith the bar on. Thestandardlevel was not part of this run; it is close tolightin an earlier run. - A closed loop client slows down when the server does, so the latency figures understate what an open arrival rate would show.
- Garbage collection, JIT and a noisy neighbour move single runs by tens of percent.
Repeat it
Everything is in tools/perf/ (see its README). Build the module first, then:
./gradlew shadowJar --offline -q
RESULTS_DIR=/tmp/lens-load tools/perf/stress-rounds.sh # about an hour, resumes where it stopped
java -jar harness/.cache/boxlang-*.jar tools/perf/stress-summary.bxs /tmp/lens-load # the tables on this page
tools/perf/stress.sh <setting> [scenarios] runs one setting. console-health.bxs and stress-summary.bxs are BoxLang scripts. Run on a quiet machine with the load client elsewhere if you want numbers to quote.