Repository navigation
libuvcpp: move the pin to 1.7.2-dev, and answer the JSON profiles with the framework's standard serializer - #1568
Conversation
`build_async_db_json` is still there and still answers whenever the writer declines; what changes is which one is on the hot path. The handler now walks the rows through `uvcpp_json_writer` -- the streaming constructor `/json` has used since af2807d -- reading the nine columns by position rather than by name, and copying the `tags` cell's JSONB literal through instead of parsing it into a DOM and dumping it back out. Same items, same DOM, same work per request; what changes is that a tree and a throwaway `dump()` string stop existing. Criterion, measured on this entry before writing this commit: server user instructions per request, callgrind, this entry under `taskset -c 0-8 valgrind --tool=callgrind`, 2000 requests at `-c 32 -m 1` against `/async-db?min=10&max=50&limit=20`, an idle leg subtracted: dom 1,563,529,631 Ir = 781,765 Ir/req writer 687,329,150 Ir = 343,665 Ir/req -56.04% At ~0.335 ns per instruction that is 262 us -> 115 us of user CPU per request. The two largest single items that leave the profile: `parse_tags` + `dump()` for the tags column (13.8% of the profile's client CPU) and the 200 column name lookups per response (12.4%); `dump_escaped` alone was 10.12% of the DOM path's self cost. Both numbers were measured before this commit, on the same rig. Determinism rather than a sample: the line `libuvcpp: /async-db answers from the writer path` is in the server log of the measured leg, which means the writer reproduced the reference bytes on the first non-empty response and answered every request after it -- 2000/2000 2xx, 0 failed. Re-asking the same query returns bytes identical to the first response, and the committed file was re-run end to end after the last edit. Why the bytes do not move, independent of the proof: the writer may answer only after `async_db_json` has compared its output against `build_async_db_json`'s on a live response, and a single difference keeps the entry on the DOM path for the life of the process -- the same shape af2807d put in front of `/json`. The `tags` walker is the one place where the argument is stronger than "the responses we sampled agree": it accepts only a flat array of strings whose bytes are all printable ASCII other than `"` and `\`, which is exactly the set both serializers copy through unchanged (RFC 8259 escapes only `"`, `\` and the bytes below 0x20), and it declines everything else -- a number, a nested value, an escape, a byte >= 0x80 -- to the DOM path, where `parse_tags`' "a value that will not parse becomes []" rule lives. An empty result set proves nothing and does not set the flag, so the proof runs on the first response that actually carries a row. The pin does not move: `uvcpp_db_row::operator[](size_t)` and `uvcpp_json_writer` are both in 6eede33 (v1.7.0), so this is an entry-side change only and needs no library release. Scope: the async-db profile is Reference-only, so this is that profile's `cpu_per_req_us` column and not the composite. The measured half is the user-mode half; the platform's number also carries system time, which this change does not touch.
`8150720` is the library's `master` after the `v1.7.0` release plus two
commits. Both were measured locally against the previous pin, and the criteria
are deterministic counters rather than board readings:
* `ebeaa20` -- `-1.90%` user-mode instructions per request on the h1 shape
(22,459.6 -> 22,032.4 Ir, callgrind). The path normalisation collapsed into
one function with a fast path for the already-normal form, so the routing
table, the streaming table and the WebSocket table cannot disagree about
what path they are looking at. `-1.90%` of the h1 profiles' user-mode time
is about `0.35%` of their end-to-end time, which is smaller than the spread
between two platform readings of the same profile.
* `8150720` -- `204.78 -> 102.77` `malloc`s per response on the async-db
shape. The result-set vectors now reserve, which `PQntuples`/`PQnfields`
let them do exactly, so the 1-2-4-8 growth of 20 rows x 9 cells is gone.
`async-db` is Reference-only: this one lands in its `cpu_per_req_us` (user
plus system, and the change is on the user side) and nowhere else.
So neither is offered as a predicted board movement -- both are accounted for
as "the counter certainly went down". The Dockerfile comment says so where it
records the pin history, along with what the distance from `v1.7.0` to this pin
actually is: one documentation commit plus these two.
The rest of the range from `6eede33` (the previous pin) to here is the `v1.7.0`
release work -- build system, packaging, release docs -- which is already
justified in the block above and changes no code on this entry's paths.
Two things in the entry README were true when the pin was `v1.6.1` and are not
true now, so they are corrected rather than left:
* "libuvcpp 1.6.x" is `1.7.x`.
* the reason given for building the library from source was that the release
package has both remote database backends off. At `v1.6.1` that was right --
all six legs of the release workflow passed
`-DUVCPP_ENABLE_DB_MYSQL=OFF -DUVCPP_ENABLE_DB_PGSQL=OFF`. At `1.7.0` all
six pass `=ON` with `-DUVCPP_DB_RUNTIME_LOAD=ON`, which is why the same
`ldd` allow-list still passes: the backends are there, loaded at run time.
The reason now is that the pin is a development revision and a release
package exists only for a tag.
The `/async-db` section also described `tags` as re-parsed into a DOM and dumped
back out. The entry no longer does that (`9257623`, the commit below): it walks
the array literal and copies the strings through, declining anything it cannot
reproduce byte for byte and falling back to the DOM path for that response. The
section now says that, and says that the writer path is proved against the
reference bytes on the first non-empty response before it is allowed to answer.
|
/benchmark -f libuvcpp --save Two changes in this run, both recorded in the Dockerfile's pin block:
Being straight about what to expect: neither library change is offered as a |
|
👋 Benchmark request received. A collaborator will review and approve the run. |
Benchmark ResultsFramework:
Full log |
`json-compressed` is the one profile in this suite scored on bytes -- `effectiveRps = rps * (minBpr / myBpr)^2` -- and it is the one profile whose request asks for both encoders (`Accept-Encoding: gzip, br`, byte for byte in `requests/json-gzip-*.raw`). The library has carried a brotli encoder since 1.7.0 behind `UVCPP_ENABLE_BROTLI`, default OFF, so until now that request was answered with gzip and the entry competed on the byte term with a compressor it had left on the bench. This is the framework's own response-compression middleware, not a tuned library: the same `uvcpp_http_server` filter, the same per-request `Accept-Encoding` negotiation, at the library's own default quality. That is what keeps it inside the entry's declared `mode: standard`, whose rule for this profile reads "Must use the framework standard JSON serialization and the framework or engine's built-in response compression (middleware, filter, or equivalent)" -- and the profile's own title names brotli. Nothing here picks an encoder or a level per profile: a client that asks for gzip alone still gets gzip, and a client that sends no `Accept-Encoding` still gets no `Content-Encoding`. What it is worth, from numbers this entry already has. The three json-comp bodies (4190/6707/8397 B) go from 970/1330/1567 B under gzip to 856/1182/1389 B under brotli q5 -- the encoder's own table in `web/uvcpp_http_compress.h`, whose default quality is the maximum of that table's score model -- and brotli's tie-break over gzip at equal `q` is the rank rule in `web/uvcpp_http_compress.cpp`. Over the profile's round-robin that is ~1142 B of body against ~1288 B, which against the measured bpr of 1429 (140 B of headers) is a byte term of (1429/1283)^2 ~ x1.24. The cost is ~+31.5 us per response, and json-comp runs at 101% of its CPU budget (6476.7% of 6400%), so it pays for that in requests per second: 199.0/230.5 ~ 0.86. Net ~x1.07 on both json-comp profiles. The blast radius is the other half of the argument, because the same flag also answers the h2 and h3 load generators, which ask for `br;q=1, gzip;q=0.8`. Those profiles are not CPU-bound -- gateway-64 sits at 3723% of budget, gateway-h3 at 3685%, production-stack at 3165%, static-h2 at 2463% -- so they absorb the compression without giving up requests, and the 7 `/json` and 3 `/async-db` URIs of gateway's 20-URI mix come back ~11% smaller. `json-tls`, `json-h2c`, `async-db` and the h1 baseline profiles send no `Accept-Encoding` at all. The static legs are unaffected by this flag in either direction: they serve the `.br` twins `data/static` already ships, and that selection is runtime, in `http_pick_precompressed_variants()`. Build side: `libbrotli-dev` in the build stage, `libbrotli1` in the runtime one. The encoder is the third shared library the build cannot absorb -- zlib and libuv are fetched and linked statically, this one is taken from the system -- so `libuvcpp.so` gains `libbrotlienc.so.1` and `libbrotlidec.so.1` in its `DT_NEEDED`, and without them the server does not start at all. On noble both files are `libbrotli1`; Debian's split names (`libbrotlienc1` and friends) are not packages here, which is the kind of mistake that only surfaces as a failed image build. The pin does not move: `UVCPP_ENABLE_BROTLI` and `k_brotli_quality_default` are both in 8150720 (1.7.2-dev), so this is an entry-side change only.
|
You need to use a proper json parser (for example nlohmann/json) for tests that do json serialization, unless you classify as an engine. |
Both stacks put a stock `caddy:2-alpine` at the edge. On both, the edge was the
bottleneck and Caddy was why: over the profiles' fixed HTTP/1.1 upstream hop it
costs several times what nginx does per request. This replaces it with stock
`nginx:1.27-alpine` in ../proxy/ and ../proxy-production/, and the Caddyfiles
become gateway-h3's alone, in ../proxy-h3/.
What the two edges cost, measured on this machine at each profile's own
operating point, same rig, same backend, A B A B (all four on a core budget the
rig splits off with taskset; the harness's own numbers are in the platform run
that follows):
gateway-64, 512 conns x m32, 4 edge cores:
Caddy 14,999 / 16,015 rps edge 396-400% of 400%, 306 us/req
nginx 32,462 / 37,754 rps edge 345-377%, 86 us/req
production-stack, c=256 reads m16 + c=32 writes m8, 4 edge cores:
Caddy 8,620 / 8,377 rps edge 359-361%, 425-440 us/req
Caddy + a pooled auth upstream
9,457 rps edge 359%, 392 us/req
nginx 39,495 / 39,346 rps edge 372-375%, 100 us/req
The edge is pinned at its core budget in every arm, so this is an edge-limited
number rather than a backend one: on gateway-64 the proxy was at 96% of its 32
CPUs while the server sat at 23%, and with nginx the two come out balanced. The
best Caddy arm is still 4.1x behind on throughput and 3.9x behind on edge CPU
per request; the platform's own cross-entry figures agree (372-404 us/req for
the three Caddy `gateway-64-512` edges against 100-110 for the three nginx ones).
Why this is the compliant direction and not just the faster one. The gateway
rules list "proxy choice" among the things entries compete on and their example
compose uses nginx; `production-stack`'s architecture diagram names nginx and
`auth_request`, and its rules put JWT verification at the edge behind exactly
that. Neither stack is h3, so unlike the gateway pair this one needs no Caddy
twin -- h3 is the single profile that cannot follow, because h3 needs QUIC and
nginx has no HTTP/3 without a custom build against a QUIC library.
Two things the rig turned up that the old Caddyfiles asserted or configured
wrong, both now recorded in ../proxy-production/nginx.conf:
- The Caddyfile claimed the server "never" receives the Authorization header.
It does. Dumped from a header-dumping backend behind each edge: Caddy's
forward_auth passes the bearer token through alongside the X-User-Id it
copied. The nginx config drops it deliberately (proxy_set_header
Authorization ""), which is the tighter reading of "verification happens at
the edge" -- the token cannot be re-verified downstream -- and keeps ~200 B
off every /api/* request. Neither behavior is a rule violation; the comment
that claimed otherwise was simply false.
- That Caddyfile pooled the backend upstream but never configured a transport
for its forward_auth upstream. Counted at the authsvc: Caddy opened 0.77
connections per auth subrequest (21,115 for 27,510) against 0.024 for
nginx's `keepalive 256` pool (2,997 for 122,818), which is most of why its
auth leg cost 96 us/req where nginx's costs 20. Giving the Caddyfile the
same pool lifted it from 8.4k to 9.5k rps -- real, and nowhere near enough
to change the conclusion.
Verified locally before pushing, since the platform queue is long:
- `nginx -t` on both configs through the rig's path substitutions; `caddy
validate` on ../proxy-h3/Caddyfile; all three compose files parsed and each
resolves to a build context that now holds a Dockerfile plus its config.
- The full functional set `_validate_production_stack` runs, on both edges:
15/15 each -- h2 negotiated, static Content-Type/size, /public/baseline and
/public/json/25, 401 on all four unauthenticated /api/ cases, /api/items/1
id=1, cache-aside MISS then HIT, POST 204, POST invalidation MISS, /api/me
id=42 (which is also the proof that X-User-Id survives the hop).
- Every path the profile touches dumped from both edges and compared byte for
byte: same status and same body throughout. Headers differ only where the
gateway swap already documented (content-encoding br->gzip on the
precompressed twin, Caddy's accept-ranges/alt-svc, text/javascript vs
application/javascript). The one behavioral difference is a path matching
none of the three prefixes -- Caddy answered an empty 200, nginx answers
404 -- and nothing reads it: the load URIs cover /static/, /public/ and
/api/ only, and neither the edge nor the server declares a healthcheck.
Not verified here, and the first thing CI should say: the images themselves.
There is no docker on this machine, so both Dockerfiles were written from the
base images' documented contents and the configs were only exercised as
processes on the host.
The 2026-10-15 validate-gateway run failed four production-stack checks -- the three auth-wall 401s plus /api/items/1 -- and the response carries `server: uvcpp`, so the server answered them itself. The harness runs a compose stack's checks while the entry's own standalone container is still up: `--network host` and /certs mounted, because this entry subscribes to an h2 test, so uvcpp binds 0.0.0.0:8443 as well -- one SO_REUSEPORT socket per loop, 64 of them on the runner. `listen 8443` put the edge in the same reuseport group, so the kernel hashed localhost:8443 across the edge and the server, and every request that landed on the server got its 8443 listener's 404 for /api/* (those routes live on h1 only, by design, main.cc:1924). Reproduced on the rig against a 64-socket wildcard holder: 3/24 requests reach the edge with the wildcard listen, 24/24 with the two loopbacks, and the full production-stack check set passes with the holder up. Caddy never had this because `https://localhost:8443` is a hostname it resolves into these same two binds, and a specific bind wins the listener lookup outright -- the wildcard socket is then never chosen at all. gateway-64's edge has the same wildcard and its checks cannot see the split: /static/*, /json, /async-db and /baseline2 are exactly the paths the standalone entry answers too. Both edges get the fix.
MDA2AV on MDA2AV#1568: "You need to use a proper json parser (for example nlohmann/json) for tests that do json serialization, unless you classify as an engine." That is the rule this entry was on the wrong side of. Where it comes from: implementation-rules/frameworks/standard.md:55 says to use the ecosystem's production-grade library for a task like JSON serialization and that "Experimental or hand-rolled alternatives solely for benchmark performance are not permitted"; json-compressed/implementation.md's type-rule for `standard` asks for "the framework standard JSON serialization"; and tuned.md lists "Alternative JSON serializers" among the things *Tuned* allows. `uvcpp_json_writer` -- a public, documented API of this library, but an alternative serializer -- is a Tuned technique, and meta.json declares `mode: standard`. So it is not this entry's to answer these profiles with. /json, /public/json/:count and /async-db now build an `nlohmann::json` DOM per request from the live data and hand it to `resp.json()`, which is the framework's own `uvcpp_json_dump()` (`src/webapp/uvcpp_web_response.cpp:470`). Nothing is pre-rendered or cached. Reading goes the same way: parsing is `uvcpp_json_parse` and field access is `uvcpp_json_get_string`/`_get_int`, not nlohmann's `parse`/`value()`/`[]` -- the library publishes those accessors precisely so a handler cannot throw out of a libuv callback. The hand-written payload literals are gone with them: `{}`, `{"error":"unauthorized"}` and the empty `/async-db` result set are built by the serializer like every other body. What this gives up, in the entry's own measurements: at the 1.7.2-dev pin, /json/50?m=6 cost 63.2 us of server CPU per request through the writer against 176.0 us through the DOM path on the same 6 pinned cores, and the writer path also skipped the per-item DOM copy. The rules decide this, not the measurement, so all three profiles take the DOM path. What it does not give up: a single response byte that any check looks at. Both binaries were run side by side on the same machine -- 49 URLs covering every serialization route, both dataset routes, the five /async-db shapes and /api/* -- and every body compared after canonicalisation: 0 mismatches, /json/50?m=6 at 8397 bytes on both. The only raw difference is top-level key order, which is now the sorted order nlohmann's default `std::map` object produces, and is the order this entry's reference path and /api/items have always used. scripts/validate.sh parses every JSON body with Python's `json` and diffs fields against data/dataset.json (json-comp at 1515-1575, json-h2c at 1930-1960, json-tls at 1655+, async-db at 2180-2219); no check compares a body byte for byte. The 401, 503, malformed-body and non-object-body paths were exercised by hand afterwards and are unchanged.
|
Done — thank you for catching it. You're right that this doesn't belong in a
The cost, so it is on the record rather than discovered later: at this pin Both binaries were then run side by side on the same machine over 49 URLs (every serialization route, both dataset routes, five |
The batch, and why each member is taken, is written out in the Dockerfile. In one line each: `e716010` stops building a log line nobody installed a callback to receive (106.16/106.15 -> 102.18/102.22 allocations per response, A B B A); `3cbfc9a` lets one QUIC datagram carry STREAM frames from several streams (sendmsg per request 1.0316/1.0360 -> 0.0428/0.0561); `370d6ef` sizes each QUIC send chunk from what that write needs instead of reserving 64 KiB flat (72 374.8 -> 7 507.5 bytes allocated per request, -89.6%, peak RSS 91 -> 50 MiB); and `02ae418` lets `resp.json()` take the dumped body instead of copying it (-1.0000 memcpy per request, -8 397 bytes copied per request -- the body's own length -- and -0.83% instructions per request on /json/50?m=6). Every one of those is a local deterministic counter, not an on-board measurement, and none is offered as a predicted board movement. What this revision is actually for is the entry side, which is not in the pin: the fifth run measured `uvcpp_json_writer` (its `async-db-1024` row at 271.85 us of CPU per response against the fourth run's 407.2), and this entry answers those profiles through the framework's standard serializer again (9964faf). So the fifth run's `json-*` and `async-db` rows are not this entry's baseline; the fourth run's are. Validated locally before asking for a run, on this machine, against the pin's own library built here: the entry's five listeners up with the whole fragment suite 9/9 and the WebSocket suite 7/7; `/json/50?m=6` 8 397 bytes plain and 1 393 bytes under `Accept-Encoding: gzip, br` (gzip alone 1 567); the `.br` twin of a static file served with `Content-Encoding: br`; and the two h3 shapes at 783 971 req/s (baseline) and 17 219 req/s (static) with 0 `5xx` between them.
|
Pin moved to /benchmark -f libuvcpp --save What is in the pinFour code commits past
What is not in the pin, and is the reason this run is worth takingThe entry side. None of the above is offered as a predicted movement for any row. Local validation, against this pin's own library built here
The library side of the pin is |
|
👋 Benchmark request received. A collaborator will review and approve the run. |
Benchmark ResultsFramework:
Full log |
|
One more
Measured on the profile's own request shapes, intra-process paired A/B so machine drift cancels (the control arm, the same setting against itself, reads 0.999):
The last column is the profile's own Which way it lands is genuinely unclear to me:
So: is choosing a non-default compression level through the framework's own documented API a |
The sixth run turned the framework's brotli encoder on to see what the
profile's own `Accept-Encoding: gzip, br` would negotiate; it negotiates
`br`, and the run measured what that costs.
`json-compressed` is the one profile in this repository scored on response
bytes (`effectiveRps = rps * (minBpr / bpr)^2`). Against the fifth run,
whose tree answered with gzip, `json-comp-4096` went 335 926 -> 50 389 rps
(-85.0%) with `CPU/req` 186.4 -> 1 238.2 us, and `json-comp-16384`
330 411 -> 59 118 (-82.1%) with 196.9 -> 1 036.5 us. Both runs saturate the
same 62 cores (62.4 / 61.3), so that is cost per request. The batch's other
three `json-*` profiles pay 58-66 us each for the standard serializer they
share with these two, which leaves about 990 us of the increase on the
encoder. On the same request measured here with the server pinned to 8
cores, only `Accept-Encoding` swapped, gzip is 257 us and `br` 508 us -- and
the `br` arm takes 72.43 minor faults per request, the encoder's ~700 KB of
state being returned to the kernel and re-faulted between requests. The
library's `master` removes that churn (`0aaa415`, one commit past this pin),
which takes `br` to 385 us; 385 against 257 is still 1.5x, and `br` only
buys `(1429.2 / 1288.4)^2 = 1.23` on the byte term that the profile scores.
So it is a net loss on the only term it can move.
Nothing else is given up. `Accept-Encoding` is still honoured per request;
every client here that offers `br` also accepts gzip (`gzip, br` on
json-comp, `br;q=1, gzip;q=0.8` on the h2 and h3 generators); the static
legs' `.br` twins are chosen at runtime by
`http_pick_precompressed_variants()` and are unaffected by this flag; and
the profile's own guide asks for what it now gets -- its `standard=` rule
wants "the framework or engine's built-in response compression (middleware,
filter, or equivalent)", its `tuned=` rule only adds "as long as the output
is valid gzip or brotli", and the worked example under "Expected response"
answers a request that carried `Accept-Encoding: gzip, br` with
`Content-Encoding: gzip`. Brotli appears in the page's title; no rule there
prefers it.
`frameworks/libuvcpp/{Dockerfile,README.md,meta.json}` carry the flag, the
two package drops and the rationale; `site/data/frameworks.json` is
`scripts/rebuild_site_data.py` re-run over the new `meta.json`. The pin is
unchanged at `02ae418`, so this is one variable against the sixth run.
`uvcpp_static_server` defaults to `uvcpp_static_revalidate::POOL`, so every
`static-h3` request queued to the libuv thread pool, woke a worker, ran the
`stat` that decides whether the cached file is still good, and came back
through `after_work`. The h1 and h2 static legs have been on `LOOP_STAT`
since `af3b4fc` -- they get it through `kStaticOpts.revalidate` -- but this
leg is a plain `uvcpp_http_server` with no `uvcpp_web_static_options` to
receive it, so it never got the choice at all. It is one line here.
Measured on the entry built twice, one line apart, same library, same ext4
corpus, the profile's own URIs and the profile's own `Accept-Encoding:
br;q=1, gzip;q=0.8` (`--alpn-list=h3 -c 64 -m 64 -t 8 -D 5s`, three reps a
side, interleaved, server on 8 cores and the generator on 8 disjoint ones):
rps 29 434 -> 38 280 (rep range 29 129-30 454 against
33 855-42 360 -- even the worst `loopstat`
rep beats the best `pool` one)
us/req 216.8 -> 193.3
The mechanism is the library's `doc/benchmark.md` section 4.8, which ran the
same A/B on the same `uvcpp_static_server`: `futex`/req 6.28 -> 0.66, with
`statx`/req 2.24 -> 2.24 as the control -- the mtime/size check still happens
on every request, only the thread it happens on moved. Tail latency improved
there as well (p50 63 661 -> 56 896 us, p99 170 526 -> 140 458 us).
That tail latency is the risk this option carries, and the reason the
library default stays `POOL`: on a FUSE or network mount one `stat` is ~46 us
and blocking a loop thread on it -- which on this leg also carries QUIC's own
timers and retransmits -- is the wrong trade. The platform bind-mounts the
dataset into the container (`scripts/lib/framework.sh`), so this leg is on
the same local disk the h1/h2 legs are already doing this on.
The pin is unchanged by this commit, which is one variable on its own; the
pin move is the commit after it, so the next run carries both.
The pin is the tip of the library's `master` as of now. Two of the three commits change code and one is documentation (`b7cae85`, the sixth run's readings), and neither of the two touches a public header -- `git diff --stat 02ae418..1f6a8fb -- include/ 'src/**/*.h'` is empty -- so the entry needs no code change for it. `0aaa415` gives the brotli encoder the ~700 KiB of state it needs per request out of a thread-local arena instead of the heap. brotli has no `BrotliEncoderReset()`, so that state was created and destroyed on every request, two of its blocks are 256 KiB, and those are above glibc's 128 KiB threshold, so `free` handed them back to the kernel for the next request to fault in again: `72.43 -> 0.00` minor faults per request and `508 -> 385` us of CPU per request on the local `br` arm, with a gzip arm as the negative control (`+0.8%`, no faults either way) and compressed bytes bit-for-bit identical. This build cannot be moved by any of it: the arena, the encoder call and the frame in between all sit under `#if UVCPP_BROTLI_ENABLE` in `src/web/uvcpp_http_compress.cpp`, and this entry passes `-DUVCPP_ENABLE_BROTLI=OFF`. It comes along because a pin is a commit and not a patch list. `1f6a8fb` moves the two tables behind the static handler's `LOOP_STAT` cache lookup to a front copy per loop, so the thread that reads them is the thread that wrote them. Before it every loop read the global tables under one shared lock, and at `1 / held` that lock admitted 240 000-270 000 requests a second -- the order the board's `static-h2` rows sit at (~235 000) while the same round's `json-tls-4096` spends 63.3 cores at a lower per-request cost, so the static profiles were short of parallelism and not of work. The counters, A B B A on one entry binary with only the `.so` swapped: `mutex_lock` per request `2.03 / 2.04 -> 0.03 / 0.02`, time held `3 679 / 4 171 -> 969 / 1 259` ns, time waiting `7 761 / 9 368 -> 183 / 239` ns; `collect_hints()` disappears from the per-call-site table and `lookup_cache()` falls to 0.0041 per request. The local end-to-end moved `+19.6%` (109 997 -> 131 608 req/s, two interleaved rounds on 9 cores with the generator on the same machine), which is offered as direction only. This is the h1/h2 static handler; the h3 leg goes through `uvcpp_static_server` and does not take this path. README: the pin is now twelve commits past `v1.7.0`, not three. That sentence was written when the pin was `8150720` and was not updated by the `02ae418` move (`git rev-list --count v1.7.0..1f6a8fb` is 12). Both criteria are local deterministic counters measured against the previous revision; neither is offered as a predicted board movement.
The h3 commit and this branch's `main.cc` comment both cited `af3b4fc` for when the h1/h2 static legs started validating the static cache on the loop thread instead of through the pool. `af3b4fc` is where the two lines (`o.precompressed = true`, `o.revalidate = uvcpp_web_revalidate::LOOP_STAT`) entered this branch's history -- PR MDA2AV#1553 brought the gateway work in -- but they were not introduced there: `git log -S 'o.revalidate' --all -- frameworks/libuvcpp/main.cc` names `7095e92`, "Validate the static cache on the loop thread instead of through the pool" (2026-10-14), and `7095e92` is an ancestor of `ae437de`, the tree the fourth run measured and which carries both lines at `main.cc:196` and `:211`. So the legs have been on it since `7095e92`; `main.cc` now says so, and the Dockerfile says both. No behaviour change: `main.cc` differs by one SHA inside a comment.
|
/benchmark -f libuvcpp --save Three changes on top of the sixth run's tree, all of them measured on this machine before asking for a run.
No public header changed between the old pin and the new one, so the pin move needed no code change; the |
|
👋 Benchmark request received. A collaborator will review and approve the run. |
Benchmark ResultsFramework:
Full log |
The seventh run carried `1f6a8fb` and its own regression is what this batch
answers: `echo-ws-pipeline-4096` came back `-6.8%` in rate and `+13.8%` in CPU
per request, the largest single-profile regression in that round.
* `239fef0` moves the buffer allocator's free list out of the blocks and into
per-thread tables. The family serves 1-7 byte blocks, and the old scheme put
the "next block" pointer in the block's first 8 bytes, so a block smaller
than that could never be cached -- four blocks per WebSocket echo message
went to `malloc` every time and none was ever reused. Message-allocations per
message `11.255 -> 6.255` at `PIPE=1`, `3.692 -> 0.567` at `PIPE=16`, and
`0.775 -> 0.730` us per message end to end at `PIPE=16`.
* `2872c20` gives the TCP read buffer a per-thread table of exact-size slots,
so a `pipeline = 1` profile no longer costs a 64 KiB `malloc`/`free` pair per
request. callgrind on this entry: `/json/50?m=6` `-16.57%` instructions,
`/baseline11` `-1.36%`; end to end `-14.1%` on `/json/50?m=6`, `-8.9%` on the
h2c leg, `baseline11` neutral.
`239fef0` changes the layout of `uvcpp_malloc_block_cache` in the public
`uvcpp_alloc.h`, but the entry does not include that header, so no entry-side
change comes with it. Full criteria, the rigs and the A/B readings are in the
library's `doc/benchmark.md` §4.19 and §4.20 and in each commit's message.
…kend The pin moves to `134f34d`, the tip of the library's `master`, and it moves for this one thing: `a063645` gives the library's yyjson module a construction side and a write-back through yyjson's own serializer, and without it the handlers below cannot exist. The same range carries two other things. `638512b` takes three copies off the h1 dynamic hot path in the library -- a library-side change, so not a like-for-like factor on this entry's h1 rows against the seventh run's -- and the library's fourth database backend (`1607dff`, ODBC, PR MDA2AV#36) lands with its CI and packaging, which is off in the flag set here. The two JSON handlers no longer build an `nlohmann::json` DOM per request. They build a `uvcpp_json_builder` tree and hand it to `uvcpp_json_write`, which is yyjson's own serializer (`yyjson_mut_val_write_buf`), and those bytes go to `resp.json_str()` by move exactly as before. The dataset is parsed once at startup by `uvcpp_json_parse` instead of being held as an `nlohmann::json`; that parse happens before the first request is accepted, so it is in neither measurement below. **The response bytes did not change.** Same library, two `main.cc`, 17 URLs -- `/json/0` through `/json/50` with and without `m`, `count` past the end of the dataset, one negative `count`, one `m` of a million, and five `/async-db` ranges against the seeded Postgres: every body `cmp`-equal, every status line identical, every `Content-Type` identical. That criterion is what makes this safe to ship, and it is not free. A builder keeps insertion order where the DOM path got sorted order for nothing from `std::map`, so the key order is spelled out in the code; and the nested `rating` object is rebuilt key by key, because the data file lists it `score, count` while the response has always answered `count, score`. Getting either wrong would have been a silently different body, not a crash. Measured on the local rig, one server binary per arm, only `main.cc` swapped, arms alternating over four rounds, the client at the shape these profiles actually use -- `-c 1024 -m 1`, because `scripts/lib/profiles.sh` has `json-comp`, `json-tls` and `async-db` at pipeline 1, and a depth past the library's documented per-connection in-flight cap answers `503` (a sweep of `-m` 1/2/4/8/16/32 gave 0/0/0/0/12 336/11 096 of them): `/json/50?m=6` 228.60 -> 125.31 us of CPU per request 60 350 -> 89 605 req/s (four-round ranges 228.60-303.57 against 125.31-156.23 us -- they do not overlap) `/async-db` `min=1&max=500&limit=20` 258.62 -> 158.95 us 30 794 -> 41 120 req/s (258.62-319.94 against 158.95-194.29) Both arms answered zero non-2xx in every round. Both arms also run the same library, so `638512b` and the ODBC batch are held constant and are in neither column. The compliance argument is that this is the framework's own bundled backend, not an entry-side substitution. `standard.md` requires a production-grade library where the ecosystem has one and forbids hand-rolled alternatives built for the benchmark, with one exception -- "If the framework itself bundles or officially recommends a specific library, that library is acceptable" -- and yyjson is a library this framework bundles: all six release legs pass `-DUVCPP_ENABLE_YYJSON=ON` in their package builds, so this is the released library's own configuration. The entry takes it by turning that flag on rather than by adding a dependency. What the entry does *not* do is reach into yyjson directly: it goes through `uvcpp_json_parse` / `uvcpp_json_builder` / `uvcpp_json_write`, so main.cc does not include `<yyjson.h>` and would not notice if the backend under those three names changed. `UVCPP_ENABLE_DB_ODBC=OFF` is stated in the flag set rather than left to the default, and the default is the reason: it is ON at this pin and needs `sql.h` plus a driver manager. CMake would force it off itself and say so in a configure-time warning, but a flag set that names it is one whose result does not depend on what the image happens to have installed. README.md and meta.json follow the change, and the Dockerfile's comments say why the pin moved, what the criterion was, and what the two arms measured. Verified before the push: the four CI legs on `134f34d` (Linux, macOS, MSVC, MinGW-w64) are green; the yyjson FetchContent path (tag 0.13.0) and a `--depth 1` fetch of the pin SHA both reproduce; and the whole entry was prevalidated locally against the same tree -- h2c/json-h2c/grpc/h1 15 assertions pass, `baseline-h2` 3 395 100 requests, `baseline-h3` 1 777 674, `static-h2` 46 413, `static-h3` 53 271, `gateway-64` 54 200, `gateway-h3` 46 909, all with zero 4xx and zero 5xx, the 12 gateway assertions and the 15 production-stack assertions pass, and the h3 legs' own `json` body assertion reads back count and item count correctly from the new writer's output.
|
/benchmark -f libuvcpp --save The pin moves to What this answers. The review here on 2026-10-15 was that this entry needs "a proper json parser (for example nlohmann/json) for tests that do json serialization". All three JSON routes went to nlohmann at that point, which was the safe answer and cost what it cost. This is the same requirement met by the other route the framework offers, and it is the reason for the pin move: Why this is not the thing that was objected to. yyjson is a third-party production library that this framework bundles — all six release legs pass The response bytes did not change. Last time the comparison had to be after canonicalisation, because the swap changed top-level key order. This time it is a byte comparison and it passes: same library, two Measured. One server binary per arm, only
Both arms answered zero non-2xx in every round. Both arms run the same library, so What else is in the pin. Three things, and only the first is this change's:
Checked before asking. The four CI legs on |
|
👋 Benchmark request received. A collaborator will review and approve the run. |
Benchmark ResultsFramework:
Full log |
|
The eighth run is in (
The json gain is the serializer change and nothing else: the entry-side A/B on this machine measured
Everything else is drift. The full sweep's median is +3.7%, and the four static profiles — which this change does not touch at all — came back between -0.4% and +0.5%. That is the control: the +3-9% seen on untouched profiles is the machine, not the code. The pin also carries the library's own One reading note for the bot's table above: its Δ RPS column is against the results currently on The entry is also unchanged since the validate on |
|
Everything from our side is in. The eighth run above is green across all 42 profiles (zero non-2xx), and this branch has not changed since So this is ready to merge whenever you decide. We deliberately keep the 1.7.3 work off this branch: once 1.7.2 lands, we will open a separate PR for the next version, with its own pin bump and its own run. That keeps this diff reviewable exactly as it is, and it means nothing here needs to move while you review. |
Description
Two changes, both about what this entry costs per request.
The pin moves from
6eede33to8150720(1.7.2-dev), which is thev1.7.0tag plus one documentation commit and two that change code:
ebeaa20— the path normalisation collapsed into one function. Therouting table, the streaming table and the WebSocket table used to each spell
out
web_collapse_slashes(web_url_decode(...))and rely on three comments tostay in agreement. It is now one public function with a fast path for a path
that is already normal, so "these three tables see the same path" holds by
construction. Criterion: 22,459.6 → 22,032.4 instructions per request
(
-1.90%, callgrind, h1 shape).8150720— the result-set allocation account. The async-db responsematerialises 20 rows × 9 cells, and
204.78of itsmallocs per responsewere not holding data — they were
std::vectorgrowing 1-2-4-8 (103.00+6.00per request, 56% of allmallocs by callgraph edge). Both sizes arereported by
PQntuples/PQnfields, so the vectors now reserve and the countis 204.78 → 102.77. Criterion: the
malloccounter, same entry binary,only the
.soswapped, three runs per arm, reproducible.The entry change is
9964faf, and it is a correction rather than anoptimisation. On review (MDA2AV, 2026-10-15) this entry was answering
/json,/public/json/:countand/async-dbwithuvcpp_json_writer. That writer isan alternative JSON serializer;
implementation-rules/frameworks/tuned.mdlists "Alternative JSON serializers" among the things Tuned allows, while
this entry declares
mode: standard, wheretest-profiles/h1/json-compressed/implementation.mdasks for "the frameworkstandard JSON serialization" and
standard.md:55rules out a hand-rolledalternative kept for benchmark performance. So all three now build an
nlohmann::jsonDOM per request out of the live data and hand it toresp.json(), which is the framework's ownuvcpp_json_dump(). Reading goesthe same way —
uvcpp_json_parseand theuvcpp_json_get_*accessors insteadof nlohmann's
parse/value()/[], the accessors the library publishesprecisely so a handler cannot throw out of a libuv callback — and the
hand-written payload literals (
{},{"error":"unauthorized"}, the empty/async-dbresult set) are gone with them. The flattened-dataset pre-read, theflattened fast path and the writer's self-check are all deleted; there is one
serializer and one path now.
Cost, stated plainly: at this pin
/json/50?m=6was 63.2 us of server CPUper request through the writer against 176.0 us through the DOM, and the
writer also skipped the per-item DOM copy.
json-comp,json-tlsandjson-h2cwill therefore read lower than the 2026-10-15 run. That is given updeliberately — the rules decide this, not the measurement.
Correctness of the swap: both binaries run side by side on the same machine
over 49 URLs (every serialization route, both dataset routes, five
/async-dbshapes,
/api/*), every body compared after canonicalisation — 0mismatches,
/json/50?m=6at 8397 bytes on both. The only raw difference istop-level key order, which is now the sorted order nlohmann's default
std::mapobject produces and is the order this entry's reference path and/api/itemshave always used.scripts/validate.shparses every JSON body anddiffs fields; no check compares a body byte for byte. The 401, 503,
malformed-body and non-object-body paths were exercised separately and are
unchanged.
What this is not
Neither library change is offered as a predicted board movement.
-1.90%ofthe h1 profiles' user-mode time is about
0.35%of their end-to-end time, whichis smaller than the spread between two readings of the same profile; and
async-dbis Reference-only, so it contributes nothing to a score and shows uponly in its own
cpu_per_req_us(user plus system, where the change is on theuser side). Both are accounted for as "the counter certainly went down". The
Dockerfile says the same where it records the pin history — and now also says
that the writer paragraphs below that point are the record of why the pin moved
when it did, not a description of what this entry does.
Guards on the library side: the
/async-dbresponse bodies are byte-identicalacross the arms, the Postgres suite's output is byte-identical across the arms,
and the full suite is 127/127 on the Linux tree.