Performance under load

On this page

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), light with 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 light and full with 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.
ScenarioWhat it does
mixedSeven pages: plain, N+1 queries, a transaction, function calls, cache, forms, session.
sqlThe N+1 and the transaction pages only.
sameOne uncaught error again and again.
uniqueEvery request a different uncaught error (400 kinds).
slowsqlA query that burns CPU in the database, 32 connections.
stallPages that hold their worker thread for about 10 seconds, 128 connections.
peakThe mixed pages at 256 connections.
soakFour minutes of mixed pages plus errors at 32 connections, with heap, threads and GC sampled.

Cost per request

Mixed pages, 64 connections

SettingRunsRequests/sp50 msp99 msCPU ms per requestAllocated KB per requestRequests/s against no module
No module3946 (806 to 1090)50 (43 to 57)222 (204 to 318)2.98 (2.60 to 3.25)315 (314 to 316)
light, bar off3694 (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 on3595 (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 off3647 (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 on3542 (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

SettingRunsRequests/sp50 msp99 msCPU ms per requestAllocated KB per requestRequests/s against no module
No module3979 (953 to 996)63 (58 to 63)154 (144 to 158)2.66 (2.63 to 2.77)425 (425 to 425)
light, bar off3657 (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 on3527 (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 off3502 (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 on3388 (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

SettingRunsRequests/sp50 msp99 msCPU ms per requestAllocated KB per requestRequests/s against no module
No module31338 (1269 to 1417)40 (38 to 42)142 (142 to 161)1.93 (1.86 to 2.07)305 (305 to 305)
light, bar off31096 (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 on31031 (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 off3986 (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 on3931 (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.
  • light with 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).
  • full costs 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)

SettingRunsRequests/sp50 msp99 msCPU ms per requestAllocated KB per requestRequests/s against no module
No module1936592072.99316
light, bar off1947591883.10344+1 %
full, bar on1722792374.06383-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

ScenarioSettingRequestsRequests/sp50 msp99 msmax msFailed requests (client)
peak-c256No module3230210762185878720
peak-c256light, bar off3140510472256159550
peak-c256full, bar on1633254445296312290
slowsql-c32No module6431143113446134460
slowsql-c32light, bar off5831183413979139790
slowsql-c32full, bar on4021330814484144840
stall-c128No module288101360813863138690
stall-c128light, bar off288101361013811138500
stall-c128full, bar on288101362813869139230
soak-c32No module379190158018591590
soak-c32light, bar off335094139620672570
soak-c32full, bar on280588116924792250
  • peak (256 connections, one run per setting): light with the bar off was within the noise of no module (about 3% lower). full with 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 full and 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 light and the bar off and 1169 with full and the bar on. No failed requests.

What BxLens itself counted

SettingAfterWork queue waitingEvents dropped by the queueEvents failedError issuesError events countedError events dropped by the flood guardStatements kept
light, bar offafter-same00013394903
light, bar offafter-unique000213333803
light, bar offafter-peak000213333804
light, bar offafter-soak00052277863450064
light, bar onafter-same00013772927483
full, bar offafter-same0001359821943
full, bar onafter-same00013570803
full, bar onafter-unique000212566503
full, bar onafter-peak000212566504
full, bar onafter-soak0004826049374994

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

SettingHeap used, lowest to highest (MB)Heap after a full GC at the end (MB)ThreadsTime in garbage collection
No module46 to 3273279 to 830.7 %
light, bar off131 to 4734389 to 950.8 %
full, bar on92 to 5144488 to 941.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

ScenarioSettingLog written (MB)
unique-c64No module121
same-c64No module173
soakNo module1178
unique-c64light, bar off120
same-c64light, bar off146
soaklight, bar off1040
same-c64light, bar on136
same-c64full, bar off137
unique-c64full, bar on96
same-c64full, bar on129
soakfull, bar on902

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.level light and the bar off is the cheapest setting that still records requests, queries, errors and the history.
  • The bar and full collection 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, stall and soak ran once per setting, and only for no module, light with the bar off and full with the bar on. The standard level was not part of this run; it is close to light in 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.