Repository navigation
NativeString: read the thread-local pool once per instance - #1353
Conversation
Benchmark:
|
| Scenario | Base req/s | Head req/s | Head / base | Rounds | Noise | Busy | CPU per request |
|---|---|---|---|---|---|---|---|
| http/hello-world | 141271 | 143123 | 1.013x (+1.3%) | 1.01 to 1.02 | ±0.9% | 100% / 100% | 7.07 / 6.98 us (0.987x) |
| http/headers 🏆 | 122140 | 127070 | 1.040x (+4.0%) | 1.03 to 1.05 | ±0.6% | 100% / 100% | 8.18 / 7.86 us (0.961x) |
| http/json-post | 110300 | 110680 | 1.004x (+0.4%) | 1.00 to 1.01 | ±0.7% | 100% / 100% | 9.06 / 9.03 us (0.996x) |
| http/cached | 157944 | 158296 | 1.002x (+0.2%) | 1.00 to 1.00 | ±0.5% | 100% / 100% | 6.33 / 6.32 us (0.998x) |
| ws/echo-20b | 146512 | 146377 | 0.999x (-0.1%) | 0.99 to 1.01 | ±1.4% | 100% / 100% | 6.82 / 6.83 us (1.001x) |
| ws/echo-4kb | 125450 | 125714 | 1.003x (+0.3%) | 1.00 to 1.01 | ±3.7% | 100% / 100% | 7.96 / 7.95 us (0.997x) |
4 rounds of 3s per scenario, each round loading base, head and a second process of each one, from a copy of its build, after the other, in an order that changes every round. "Head / base" is the median of the per-round ratios of req/s and "Rounds" their range. "Noise" is how far base/base and head/head, the same code on both sides, got from 1 in this same run: that is what the machine did, so a row is marked only when the median moved further than that, at least 2%, and every round moved the same way: 👀 slower, 🏆 faster. "Busy" is the cpu time of the server process over the round and "CPU per request" that time divided by the requests it answered, with the head/base ratio judged against its own same-code band and bold when it cleared it. When busy is well under 100% the load generator set the pace, the req/s are its and the cpu per request is the column to read. Only the ratios are comparable across runs, the absolute figures depend on the runner.
Node v26.10.0, AMD EPYC 9V74 80-Core Processor, 4 cores (server on cpu 0, load on 2,3), http_load_test and load_test with 100 connections.
|
You mind swapping |
|
This hack shouldn't be necessary with __thread |
|
Measured both, user-space instructions per request on the same JS handler (cork, writeStatus, two writeHeader, end), median of 6 rounds:
|
|
You can do whatever you think is best, buddy boy. But I think there are a few thread_local in uWS C++ also. |
|
Instructions because req/s of the same build swing 20% between rounds here, instructions repeat to 0.1%. The benchmark on this PR says the same: http/headers +4%.
The C++ thread_locals are not on the request path, only Loop::get() in HttpCache. |
|
We could also remove the |
| NativeString(Isolate *isolate, const Local<Value> &value) { | ||
| if (ref_count == 0) { | ||
| pool_offset = 0; // Reset the "stack" when entering the first scope | ||
| pool = &threadPool; |
There was a problem hiding this comment.
This destroys the thread locality. You set a global pointer to the thread local memory, but that destroys the very thread locality that was the point.
There was a problem hiding this comment.
pool is a member of each NativeString, not static. Each instance points to its own thread's pool.
|
I added a comment. And I loooked more into this. Both __thread and thread_local are basically free of cost if statically compiled as part of the main program (such as when using uWS in C++). When you compile it as a dynamic library (like a Node.js addon, it takes a different cost and needs the __tls_get_addr crap that you talked about). I checked the generated assembly, TLS in static executable is basically as fast as you can make it. Dynamic libraries suck for many reasons, not just TLS but also because of LTO and inlining. |
|
Yes, and uWS.js is always the dynamic case, so this PR is about that cost. @ValoChet good idea, then allocated can go too. I'll add it if Alex is fine with the rest. |

The NativeString pool is three thread_locals, one of them with a dynamic initializer, and each string touches them several times. In an addon every thread-local access goes through the dynamic linker (the TLS descriptor resolver on arm64, __tls_get_addr on x64): on a JS handler that does cork, writeStatus, two writeHeader and end, ld.so takes 1.96% of the core.
Fix: one thread-local struct, read once per instance and kept in a pointer.
Measured on arm64 (OCI A1, Node 26), server on one core, wrk -t2 -c128, 8 interleaved rounds, the build twice as A/A control: user-space instructions per request 12391 to 10582 (-14.6%, the two copies within 0.04%), CPU per request -1.4% / -3.6%, req/s +1.4% / +3.7%, ld.so 1.96% to 0.91% of the core. Responses are identical (small strings, one larger than the pool, 100 headers, non-ASCII).
On x64 (Node 24), same handler, two copies of the build: user-space instructions per request 10974 to 9738 and 9726 (-11.3% / -11.4%).