Skip to content

Resource Benchmark

Nothing else published about this server says what it costs to run. This page does: resident memory, processor time, goroutine count and per-method latency, measured on both transports across the settings that move them.

Six axes, chosen because they are the ones that move the numbers:

AxisValuesWhy it matters
Transportstdio, HTTPstdio gives every client its own process; HTTP serves everyone from one, pooling credential state per token and one built catalog per configuration
Tool surfacedynamic, meta, individualThe dynamic surface registers two tools and the individual one roughly a thousand
Concurrent clients8 processes on stdio, 64 credentials on HTTPOn HTTP the pool holds one entry per token, so this is the axis a shared deployment grows along
Parallel requests2 to 4 in flight per clientSeparates the cost of having clients from the cost of them all calling at once
Telemetryoff, onExporting is work the server would not otherwise do
Credentials at scale1 to 1000 on HTTP, one series per surfaceA shared deployment meets hundreds of tokens; the series measures the slope and the latency knee instead of multiplying a per-token figure

Four metrics, taken from the operating system and from the wire rather than from inside the program:

  • Resident set, in mebibytes, at four moments: idle, with one client, with every client, and at its peak under load. It is read from the kernel rather than from Go’s own heap accounting, because the resident set is what a container limit is measured against: the two differ by more than a factor of two, and only one of them gets a process killed.
  • Processor time, split into what building the surfaces cost and what serving the requests cost. The load figure is a percentage of one core, so above 100% means several cores were busy.
  • Goroutine count, at rest, with every client attached.
  • Latency percentiles, per MCP method, from the client’s side of the wire.

The concurrency series records, at each credential count: the resident set over the steady phase, as a mean and a peak; the settled live heap and the settled resident set, read once that phase has stopped; processor time per completed call; the p50 and p99 of tools/call and of tools/list; the goroutine count; the pool counters the driver knows (the entries admitted and the capacity it sized the pool to); and a CPU profile and a heap profile of the server, kept beside the record for the analysis rather than committed.

The real binary, built from the current checkout and started the way a client starts it, on both transports. A harness that reassembled the server from the same packages would be reporting on its own copy, and would miss the readiness gate, the authentication chain, the pool and the flag parsing, which is most of what an operator is sizing for. GitLab is stood in by an HTTP server inside the harness, answering the version and user endpoints the server probes while it builds a pool entry and 404 for the scope and tier probes, which every caller reads as “this instance will not say”. A run therefore needs no instance, no credentials and no network, and two people re-measuring compare this server rather than their GitLab installations. The OTLP collector is stood in the same way, so the telemetry scenarios measure what exporting costs rather than what a collector does with the payload. The tool surface is passed explicitly rather than read from the environment, so a developer machine and CI measure the same thing, and the environment a measured process inherits is stripped of every variable this server reads.

A few details change what the numbers mean:

  • The rate limiter is off (--rate-limit-rps=0) in every scenario on this page. HTTP mode defaults to ten requests a second, which a benchmark firing parallel requests would spend its time being refused by, so a capacity figure measured with it on would be the cost of saying no. Refusals are a different measurement: test/e2e/http checks that they happen, and make bench-fairness measures whether they help anybody, in an opt-in mode that publishes nothing here.
  • The harness speaks JSON-RPC directly, not through the Go SDK. From protocol 2026-07-28 a client may serve tools/list from its own cache, and the SDK client does: measured through it, a listing that costs the server hundreds of milliseconds returns in microseconds without a request ever leaving the process. What is published here is the work the server does; a caching client pays it less often, and never less than once per session.
  • Protocol 2026-07-28 has no handshake. It removed initialize and ping, so the first thing a client waits for is its first real request, and the startup figures are anchored on the first tools/list.
  • The point scenarios take the goroutine count from a traceback signal. They start the server without its profile listener, so the process is the one an operator runs, and the only way to ask is SIGQUIT with GOTRACEBACK=all, which prints every goroutine’s stack and ends the process; it is the last thing each scenario does. The series reads the count off --pprof-addr instead, which leaves the process alive for the next step.
  • Percentiles are nearest-rank, so every published percentile is a value that was actually observed rather than one interpolated between two that were.

The Measurements section below is regenerated by one command, make bench-resources, which runs the benchmark and redraws the charts and tables from the record it writes; the prose around it is written by hand. The machine, the build and the method are named in the text under the heading, and every chart repeats along its bottom edge the part of that a figure read away from this page cannot do without: how many logical processors measured, on which operating system and kernel, and when.

This page is a snapshot, and only a snapshot. A new measurement on the reference host replaces these values wholesale: no “before” column, no ratio against an earlier run, no history. A figure compared against a run whose code no longer exists says nothing about the instance you are sizing. What moved between measurements, and why, is kept in Resource Hot Spots, and what a change was for in its ADR.

The point scenarios run 64 credentials on HTTP and 8 processes on stdio, admitting them one at a time and then loading all of them at once. 64 sits past the point where the host saturates and below the pool’s own default bound of 100, so the scenario runs the shipped default and measures real contention; a point scenario passes no pool flag, and nothing it admitted is evicted before it is measured. 8 on stdio, because each one is a whole process with its own catalog and eight concurrent clients is a busy developer machine. The point scenarios stay out of the series’ way: one to a thousand credentials is the series’ question, and they exist for the axes it does not measure at all, which are stdio, telemetry, resources/list latency, the goroutine count of a process with no profile listener, and the serial ramp itself.

A shared deployment meets hundreds, so the benchmark also runs a concurrency series per surface: one HTTP process, given 1, 2, 5, 10, 20, 50, 100, 200, 500 and 1000 distinct credentials in turn, each warmed with a tools/list, and measured over a ten-second steady phase at each count in which every credential keeps calling tools/call and tools/list. Each step records the resident set (mean and peak), processor time per call, the p50 and p99 of both methods and the goroutine count, and captures a CPU and a heap profile of the server through its --pprof-addr listener, which the analysis reads and the site does not publish.

The series process is started with --pprof-addr on a loopback port the driver picks and --max-http-clients set to the largest count, so nothing a step admitted is evicted before the next step measures it. -clients replaces the list of counts, and -quick uses 1, 2 and 5. At each count the driver:

  1. Admits the new credentials, eight at a time, each warmed with one cold tools/list so the pool holds a built entry for it.
  2. Runs a steady phase of ten seconds (-step-duration) in which every credential keeps the scenario’s parallelism in flight, each worker alternating tools/call and tools/list. A fixed duration rather than a fixed call count, because a CPU profile is taken over wall-clock seconds and a count that took seconds at one credential would take unbounded time once latency degrades, which is the region the series exists to reach. Every per-call figure divides by the calls that actually completed.
  3. Profiles the server while it serves: a CPU profile over the first 80% of the phase and, as the phase ends, a heap profile taken after a collection and the goroutine total. Profiles land under bench/profiles/<scenario>/<clients>.cpu.pb.gz and <clients>.heap.pb.gz (-profiles), which git ignores.
  4. Takes a settled reading: with the load stopped, the process is left alone for half a second, a collection is forced, and the live heap is read with the resident set beside it. It is the same measurement the end-to-end test TestSharedServer_LiveHeapDoesNotGrowWithTheNumberOfCredentials makes on every push, taken here at every step of the ladder.

Telemetry stays off for the series, so it measures the same server the point scenarios do.

Each step is then measured a second time, with the load stopped. The two readings answer different questions: the resident set over the steady phase is what N credentials cost while all of them are calling, and the settled reading, taken after the load ends with a collection forced, is what they cost to hold. The first is measured in megabytes per credential and the second in kilobytes, so one is never a substitute for the other. The settled resident set is recorded beside the settled heap and lags it, because freeing a heap does not hand the pages back: Go’s scavenger returns them on its own schedule.

On the reference host the two are not close. At a thousand credentials on the dynamic surface the peak resident set was 1454 MiB against a settled live heap of 88.3 MiB, a factor of sixteen. The heap profiles say what the difference is made of, read first step against last. On dynamic the entries that build the catalog do not move: toolutil.cloneSchemaMap holds 10.0 MiB at one credential and 10.0 MiB at a thousand, and so do the search index, the action catalog and the schema maps beneath them. What grows is the connections and the credentials: the bufio reader and writer of each open socket, absent from the profile at one credential and 35.6 MiB across the four thousand held at the last step, and the GitLab client of each pool entry, 5.0 MiB across a thousand of them. The rest of the gap never reaches the settled figure at all, because it is the JSON of the calls that were in flight when the load stopped.

A series stops early rather than take the host down: before each step the resident set it would reach is estimated from the steps so far, a straight line fitted through their peaks (with a single step to go on, the extrapolation is proportional, which errs toward stopping early), and the rest of the list is skipped when the estimate exceeds the memory budget: -memory-budget, in MiB, or 80% of the host’s available memory (MemAvailable in /proc/meminfo) read when the run starts. A host that does not report it runs with no budget, and the series says so. A step whose tools/call p99 passes 30 seconds is the last one run, since past that a client has given up and the next step would measure timeouts rather than the server. Where a series stopped, and why, is printed under its table. In the run published here none of them stopped: all three reached a thousand credentials.

Measured on Intel(R) Core(TM) i5-14400, 16 logical CPUs, 62 GiB RAM, linux/amd64, kernel 6.12.105-production+truenas, go1.27.1, build 2.7.6-0.20260906081503-18bed59da189 (18bed59d), 2026-09-06T08:24:43Z. 3 rounds per method, resident set sampled every 100 ms.

Grouped bars comparing resident memory across the dynamic, meta and individual surfaces on both transports.
Lines showing resident memory growing as each new credential builds its own catalog in the HTTP pool.
Grouped bars on a log scale comparing process readiness, the first cold tools/list and a warm one.
Grouped bars on a log scale comparing resources/list, tools/call and tools/list latency across transports and surfaces.
Lines on a log scale of credentials showing each surface's peak resident memory per step and the count each series stopped at.
Lines on log scales showing the tools/call p50 and p99 per surface as the credential count grows.
Lines on a log scale of credentials showing the processor time per call for each surface.

Memory, goroutines and processor time per scenario

Section titled “Memory, goroutines and processor time per scenario”
ScenarioClientsIdleOne clientAll clientsPer extra clientPeakGoroutinesCPU, % of one core
stdio, dynamic8n/a106849106995341039%
stdio, meta8n/a107857107101836794%
stdio, individual8n/a27721342652291361120%
stdio, dynamic, telemetry8n/a1118931121048431080%
http, dynamic64351081180375291974%
http, meta64371121180324293928%
http, individual6436282248-114221651147%
http, dynamic, telemetry643811312203833001018%
ScenarioProcess readyFirst tools/listWarm tools/list (p50)tools/list payload
stdio, dynamic0.263511.912 KB
stdio, meta0.1734142598 KB
stdio, individual0.1812793793.2 MB
stdio, dynamic, telemetry0.243331.712 KB
http, dynamic593192112 KB
http, meta51324270599 KB
http, individual51127723943.2 MB
http, dynamic, telemetry513192012 KB
ScenarioMethodCallp50p90p99Max
stdio, dynamicresources/listsmallest listing0.657.67.77.7
stdio, dynamictools/callgitlab_find_action30414949
stdio, dynamictools/listwhole surface1.96.06.26.2
stdio, metaresources/listsmallest listing0.640.991.21.2
stdio, metatools/callgitlab_server (status)1.7404141
stdio, metatools/listwhole surface42515555
stdio, individualresources/listsmallest listing0.570.780.930.93
stdio, individualtools/callgitlab_server_status0.871.41.81.8
stdio, individualtools/listwhole surface379405424424
stdio, dynamic, telemetryresources/listsmallest listing0.790.870.950.95
stdio, dynamic, telemetrytools/callgitlab_find_action30475050
stdio, dynamic, telemetrytools/listwhole surface1.72.32.62.6
http, dynamicresources/listsmallest listing33488085
http, dynamictools/callgitlab_find_action213307314314
http, dynamictools/listwhole surface21353838
http, metaresources/listsmallest listing10556266
http, metatools/callgitlab_server (status)23313334
http, metatools/listwhole surface270388403404
http, individualresources/listsmallest listing9.8133030
http, individualtools/callgitlab_server_status8.4161818
http, individualtools/listwhole surface2394285529292949
http, dynamic, telemetryresources/listsmallest listing14535961
http, dynamic, telemetrytools/callgitlab_find_action213301314315
http, dynamic, telemetrytools/listwhole surface20353637

http, dynamic surface: 4 in flight per credential, 10 s per step

Section titled “http, dynamic surface: 4 in flight per credential, 10 s per step”
CredentialsResident, meanResident, peakSettled heapSettled residentCPU per callCallstools/call p50tools/call p99tools/list p50tools/list p99Goroutines
114315439.01067.394689511140.963.418
217219239.11077.6331107013181.14.722
521723239.21097.9101368423433.91636
1024127739.51107.97113660431187.55356
2028936440.01157.84314254752741114796
5041448141.51227.6291677715176611471216
10054760844.01337.63917482269157815956416
200869107048.91577.674178514063572201757816
5001958308964.02327.8511718791193135241882015
1000897145488.32889.1761611931068857221663574015

Fitted across these steps: the peak resident set under load grows 1.92 MiB per credential, and the settled live heap, read with the load stopped and a collection forced, grows 50.6 KiB per credential. The first is what a credential costs while it and every other one is calling; the second is what it costs to hold. The settled resident set lags both, because Go returns freed pages to the operating system on its own schedule. Every planned step ran, up to 1000 credentials.

http, meta surface: 4 in flight per credential, 10 s per step

Section titled “http, meta surface: 4 in flight per credential, 10 s per step”
CredentialsResident, meanResident, peakSettled heapSettled residentCPU per callCallstools/call p50tools/call p99tools/list p50tools/list p99Goroutines
113414632.693.08.01463230.974.0111620
216217932.794.78.00195441.45.3152024
518520332.894.78.512108106.920294936
1019922633.196.08.4891102516535111756
2023225733.61008.23411486341469124796
5029838835.21077.95312021138451165530216
10029540637.91168.51711842349775348700416
20037653143.21358.4581242774714816481250816
50051468959.51848.5991400814143252176330772015
1000749100983.52438.3301613017916808318060284015

Fitted across these steps: the peak resident set under load grows 0.81 MiB per credential, and the settled live heap, read with the load stopped and a collection forced, grows 52.6 KiB per credential. The first is what a credential costs while it and every other one is calling; the second is what it costs to hold. The settled resident set lags both, because Go returns freed pages to the operating system on its own schedule. Every planned step ran, up to 1000 credentials.

http, individual surface: 2 in flight per credential, 10 s per step

Section titled “http, individual surface: 2 in flight per credential, 10 s per step”
CredentialsResident, meanResident, peakSettled heapSettled residentCPU per callCallstools/call p50tools/call p99tools/list p50tools/list p99Goroutines
127329688.1186111.9082621.14.915126818
229633888.3183116.9384801.16.916420120
542953088.4191133.5647661.31126229126
1053867388.5194137.591855176145459336
20731107388.8200134.65995338257784131956
501081130389.7211127.56811106880416853502116
1001266172491.1215126.6721292213257228287144216
2002244327494.0248122.50214992393428543012328415
50025223427103277110.33023612851164311082181631015
10003330467711732698.265363749741655916796252292015

Fitted across these steps: the peak resident set under load grows 4.27 MiB per credential, and the settled live heap, read with the load stopped and a collection forced, grows 29.8 KiB per credential. The first is what a credential costs while it and every other one is calling; the second is what it costs to hold. The settled resident set lags both, because Go returns freed pages to the operating system on its own schedule. Every planned step ran, up to 1000 credentials.

stdio. One process per client, and the process builds its catalog as soon as it starts, so there is no idle state to save memory in. Between 106 and 277 MiB per process depending on the surface, with peaks under load a little higher. Nothing is shared between processes, so the cost is a straight line in the client count: a developer machine running one client will not notice, and the “all clients” column is what eight of them weigh together.

HTTP. Start from 35 to 38 MiB for a process serving nobody, then add one catalog per distinct configuration, built by the first credential that asks for it and shared by every other credential of that configuration: the server is built once per configuration shape (ADR-0020), so what a credential holds is its client, its rate-limit bucket, its listen counter and its watchers. That is what the “per extra client” column reports: 0.15, 0.09 and -0.53 MiB on dynamic, meta and individual, which is nothing above the noise of a resident-set reading, across a ladder that admits 64 credentials one at a time. What is left to size for is the concurrent work, which is the peak column beside it: 324 to 1422 MiB with all 64 calling.

There are two axes, and only one of them is the pool. Holding a credential costs 50.6 KiB of settled live heap on dynamic, 52.6 KiB on meta and 29.8 KiB on individual, so a thousand admitted credentials add 49, 51 and 29 MiB, for a settled live heap of 88.3, 83.5 and 117.4 MiB and a settled resident set of 0.28, 0.24 and 0.32 GiB. Serving them is where an instance runs out: with every credential holding two to four requests in flight, the peak resident set grows 1.92, 0.81 and 4.27 MiB per credential, and at the thousand-credential step it stood at 1.4, 1.0 and 4.6 GiB.

A practical rule for HTTP: budget for concurrent callers, at 1 to 4 MiB each depending on the surface, on top of 40 MiB for the process and one catalog per configuration. --max-http-clients bounds pooled credentials and is not a memory setting: at 50 KiB each, its default of 100 is five mebibytes. Shorten --pool-idle-timeout if you want entries reclaimed sooner, but expect the memory back in kilobytes rather than gigabytes: what the timeout still bounds is the live GitLab clients and watchers an entry holds.

There is no floor, only a client count. “512 MiB is enough” is the shape of advice this page cannot give on its own, because any fixed figure has a number of concurrently calling credentials hidden inside it. Read the peak column of the scenario or series step that matches your surface and your expected concurrency, and give the container that plus a margin for the pages Go has not returned yet. Sizing a Deployment does that arithmetic for twenty, a hundred and a thousand callers at once.

Throughput. In the series, the calls completed per ten-second step climb steeply up to about five credentials on dynamic and meta and slowly after it: a call costs about 8 ms of processor time, so the host’s threads are the ceiling. On dynamic the step completes 13,684 calls at five credentials, about 1,370 a second, and 16,777 to 17,851 between fifty and two hundred, roughly 1,700 to 1,800 a second, which at 7.6 to 7.7 ms a call is about thirteen to fourteen of the sixteen threads; on meta it completes 10,810 at five and 11,000 to 12,400 from ten to two hundred. Latency past that point is queueing, with the p50 of tools/call on dynamic climbing from 11 ms at one credential to 269 ms at a hundred and 3.1 seconds at a thousand. individual is a different shape: a tools/call costs about a millisecond, but a call there averages 120 ms of processor time, because every second call is a tools/list serialising three megabytes of schemas, so a single credential with two requests in flight already keeps three cores busy, and its tools/list p50 is 151 ms before any contention exists at all.

A step at the top of the ladder outlasts its ten seconds, which is worth knowing before dividing the call count by the step duration. A worker finishes the call it has in flight rather than abandoning it, and at the last steps one call takes tens of seconds: individual at a thousand credentials completed 3,637 calls at 98.3 ms of processor time each, which is 357 core-seconds and cannot fit in ten seconds of a sixteen-thread host. The per-call figures are sound, because both sides of the division come from the same window; the window is simply longer than the step duration names.

Where the memory that grows with the credential count actually goes, read from the heap and CPU profiles the series writes, is worked through in Resource Hot Spots, which also keeps the history this page does not.

The process is ready in milliseconds and the surface behind it is not. On HTTP /health answers in 51 to 59 ms while the pool holds nothing; the first credential of a configuration builds its catalog, and that is where the wait is: 0.32 seconds on dynamic, 0.32 on meta and 1.28 on individual on the host measured. On stdio the process is executed in well under a millisecond and starts building immediately, so its first request waits on the same work, at 0.35, 0.34 and 1.28 seconds.

That wait is per configuration rather than per credential, since the built server is shared, and it is paid once per configuration for the life of the process: a configuration’s server is dropped only when its registration fails (ADR-0020, NEG-004), never because its entries were reclaimed. A credential arriving after --pool-idle-timeout or the pool’s size bound took its entry pays only its own entry build, the credential probe, the tier and the scopes, and then a warm listing. Two consequences: a client that gives up before its first call answers has not hit a hung server, and an HTTP deployment pays the wait once per configuration, not once per entry reclaimed.

Once warm, the picture inverts. A tools/list costs 1.9 to 21 ms on dynamic, 42 to 270 ms on meta and 0.38 to 2.4 seconds on individual, which is the cost of serializing 12 KB, 599 KB and 3.2 MB respectively, the higher end of each pair being HTTP with all 64 credentials calling. A client speaking protocol 2026-07-28 may serve repeat listings from its own cache and pay this less often, but it pays it at least once per session.

Exporting over OTLP costs a handful of goroutines and measurable processor time, and no material memory. It is off by default; see OpenTelemetry for what it exports and where it goes.

  • A real GitLab. Every tool call answered here is answered on loopback, so the latency figures are the server’s own overhead with the instance’s contribution removed. Add your own instance’s round trip to them.
  • A client’s cache. The published tools/list latency is what the server spends, not what a 2026-07-28 client waits for on a repeated call.
  • Anything but this machine. One host, one operating system, one Go toolchain, all named in the generated text above. The shape of the numbers should carry to other machines; the absolute values will not.
  • Disk and network. Nothing here writes to disk in the hot path, and the only network is loopback.
  • Pool evictions. The series sizes the pool to its largest step, so none happen, and the counters it records say so by construction. What an evicted and rebuilt entry costs is the cold tools/list the ramp measures.
  • The driver’s own load. The driver and the server share the host, and at a thousand credentials the driver is reading thousands of responses a second. The server’s figures are its own (resident set and processor time are read per process), but the latencies include whatever the driver added by being busy.
Terminal window
make bench-resources # measure, then redraw everything
make bench-resources-render # redraw from the committed record, no measuring
make check-bench-resources # verify the charts and tables match the record

The eight point scenarios take five to ten minutes on the reference host: the stdio half spends most of it building one catalog per process, which is the cost being measured rather than overhead of the harness, and the HTTP half spends it serving 64 credentials at once. The series then take as long as the host’s memory lets them, up to a quarter of an hour per surface where a thousand credentials fit, less where the budget stops them earlier. For a faster check while changing the harness itself, measure a smoke matrix into a record of its own and draw nothing:

Terminal window
go run ./cmd/bench_resources/ -quick -json /tmp/bench.json \
-profiles /tmp/bench-profiles -no-render

A partial matrix refuses to write to the published record: replacing measured numbers with a smoke test would leave the documentation presenting them as the figures. A -clients list of your own is partial for the same reason.

Measuring on one host, rendering on another

Section titled “Measuring on one host, rendering on another”

The series at 500 and 1000 credentials needs tens of gigabytes, which is not always the machine the documentation is written on. The driver therefore has a measure-only mode that reads nothing from the repository. Build both binaries in a checkout, copy them to the host that has the memory, and run:

Terminal window
go build -o dist/bench_resources ./cmd/bench_resources
go build -o dist/gitlab-mcp-server ./cmd/server
# On the measuring host, with no Go toolchain and no checkout:
./bench_resources -binary ./gitlab-mcp-server \
-json /var/tmp/bench/resource-benchmark.json \
-profiles /var/tmp/bench/profiles \
-no-render

Then copy the record back over site/src/data/resource-benchmark.json and redraw from it with make bench-resources-render; the profiles stay on the measuring host, or beside the record under bench/profiles/, which git ignores. Two things about the measuring host: the driver and the server each hold one socket per request in flight, so at a thousand credentials with four in flight each the open-file limit has to allow several thousand descriptors (Go raises the soft limit to the hard one on its own, so ulimit -Hn is the figure to check); and the budget is read from the host’s available memory when the run starts, so stop whatever else is using it first, or pass -memory-budget explicitly.

-held <counts> measures the other shape of request: one the server holds open because GitLab has not answered. It is the measurement the held-call ceiling was sized from. Every other mode drives requests the server answers as fast as it can, so none of them sees what a held call costs or what a process holding too many does. The stand-in GitLab holds every project read a step sends it, headers first (the server gives GitLab a minute to answer with headers, and would abandon and resend a read held before them), so each step is a known number of calls to the dynamic surface’s gitlab_execute_action running project.get, spread round robin across -held-credentials, held at once and sampled while they are held.

A step is sampled once every call it offered is either held at the stand-in or has come back, or once that count has stopped moving for three seconds (a process out of descriptors accepts no more connections, so some calls are neither), or after two minutes. The sample is the process’s open descriptors (Linux only, from /proc/<pid>/fd; elsewhere recorded as unknown with a note), its goroutines and live heap from the profiling listener, its resident set, and a /health probe on a connection of its own, since a probe riding an old keep-alive connection would answer for a process that no longer accepts one. Each step then releases the hold and files every call as served, refused (a 503, the status the held-call ceiling answers a 2026-07-28 client with, its first text kept) or failed, and prices one held call as the step’s growth over the idle process divided by the calls held.

-held-nofile <n> starts the server through /bin/sh -c 'ulimit -n "$0" && exec "$@"', which sets the soft and the hard limit together and puts the server in the shell’s place, so the process sampled is the server. Setting the hard limit is the point: the Go runtime raises the soft limit to the hard one before the server starts, so lowering only the soft limit would measure nothing. The server sizes its held-call ceiling from that limit, 192 under 1024. The mode writes bench/held.json, which is not committed, draws nothing, and is refused if -held-json names the published record. make bench-held runs the ladder the ceiling was measured with, under a limit of 1024.

FlagTypeDefaultDescription
-heldstring""Comma-separated counts of calls held open at once, ascending; empty runs the matrix
-held-credentialsint1Credentials the calls are spread across
-held-nofileint0Descriptor limit the server is started under; 0 inherits this process’s (not on Windows)
-held-jsonstringbench/held.jsonDocument to write; refused if it names the published record
Terminal window
# What a held call costs, and what the process does past a descriptor limit
go run ./cmd/bench_resources/ -held 100,192,250,500,1000,2000,4000 -held-nofile 1024
go run ./cmd/bench_resources/ -held 100,192,250,500,1000,2000,4000 -held-nofile 1024 -held-credentials 100

-sessions <counts> measures what the stateful transport keeps: a session. It is the measurement the session ceiling was sized from. The server is started with --stateless=false, and each step opens a known number of sessions at once, spread round robin across -sessions-credentials, each an initialize on protocol 2025-11-25 followed by notifications/initialized. With -sessions-stream, every session also opens its standalone stream, the GET a stateful client holds open for the server’s notifications, and keeps it open for the step. Before the first step each credential opens a session, lists its tools and deletes it, so the pool holds a built entry for every credential and the entry is not priced as part of the sessions.

A step is sampled the way a held step is (the same settling rule, the same figures, the same /health probe on a connection of its own), while every session it opened sits idle. It then pings every session the server gave an id, each ping waiting no longer than the /health probe does, and counts the ones that answered: that is the number of sessions the server holds, seen from outside, and the count stops at the first ping that got no answer at all, since a process that accepted no connection will not accept the next. Every open is filed as opened, refused (a 503, the status the session ceiling answers with, its first text kept) or failed, one session is priced as the step’s growth over the idle process divided by the sessions open at the sample, and every stream is closed and every session deleted before the next step.

-sessions-nofile <n> starts the server under a descriptor limit exactly as -held-nofile does. The server sizes its session ceiling from the held-call ceiling that limit gives it, 96 under 1024. The mode writes bench/sessions.json, which is not committed, draws nothing, and is refused if -sessions-json names the published record; it cannot be combined with -held or -fairness. make bench-sessions runs the ladder the ceiling was measured with, under a limit of 1024 and with every session’s stream open.

FlagTypeDefaultDescription
-sessionsstring""Comma-separated counts of sessions open at once, ascending; empty runs the matrix
-sessions-credentialsint1Credentials the sessions are spread across
-sessions-nofileint0Descriptor limit the server is started under; 0 inherits this process’s (not on Windows)
-sessions-streamboolfalseOpen each session’s standalone stream and hold it open for the step
-sessions-jsonstringbench/sessions.jsonDocument to write; refused if it names the published record
Terminal window
# What a stateful session costs, with and without its standalone stream
go run ./cmd/bench_resources/ -sessions 100,500,1000,2000,4000
go run ./cmd/bench_resources/ -sessions 50,96,100,500,1000,2000,4000 -sessions-nofile 1024 -sessions-stream

The rest of the command’s flags, and the fairness mode, are in the command reference.