Skip to content

NativeString: read the thread-local pool once per instance - #1353

Merged
uNetworkingAB merged 1 commit into
uNetworking:masterfrom
nigrosimone:native-string-one-tls-access
Oct 8, 2026
Merged

uNetworkingAB merged 1 commit into
uNetworking:masterfrom
nigrosimone:native-string-one-tls-access

Conversation

@nigrosimone

@nigrosimone nigrosimone commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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%).

@nigrosimone
nigrosimone marked this pull request as ready for review October 7, 2026 18:16
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

Benchmark: 7b20085 against 27c8955

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.

@uNetworkingAB

Copy link
Copy Markdown
Contributor

You mind swapping thread_local for __thread and testing that?

@uNetworkingAB

Copy link
Copy Markdown
Contributor

This hack shouldn't be necessary with __thread

@nigrosimone

Copy link
Copy Markdown
Contributor Author

Measured both, user-space instructions per request on the same JS handler (cork, writeStatus, two writeHeader, end), median of 6 rounds:

arm64 x64
master 12396 10967
this PR (thread_local read once) -14.6% -11.4%
__thread only -11.3% -8.9%
this PR + __thread (and insideCorkCallback) -16.4% -12.8%

__thread alone takes the init wrapper away, but every access still goes through the TLS resolver; reading once cuts the accesses. Together they are best on both. The catch: __thread needs a trivial type, so the pool becomes a buffer allocated on first use and not freed when a thread exits (128 KB per worker thread), unless we free it from an environment cleanup hook. Want me to push the combined version?

@uNetworkingAB

Copy link
Copy Markdown
Contributor

user-space instructions per request

what

@uNetworkingAB

Copy link
Copy Markdown
Contributor

You can do whatever you think is best, buddy boy. But I think there are a few thread_local in uWS C++ also.

@nigrosimone

nigrosimone commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

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%.

__thread: on insideCorkCallback no change, on the pool +1.9% but then the pool is never freed. I keep the PR as is.

The C++ thread_locals are not on the request path, only Loop::get() in HttpCache.

@ValoChet

ValoChet commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

We could also remove the *pool allocation from the binary path and only allocate it on the string path.
It would also remove the need for the allocated boolean, as *pool could be used for the test instead.

Comment thread src/Utilities.h
NativeString(Isolate *isolate, const Local<Value> &value) {
if (ref_count == 0) {
pool_offset = 0; // Reset the "stack" when entering the first scope
pool = &threadPool;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pool is a member of each NativeString, not static. Each instance points to its own thread's pool.

@uNetworkingAB uNetworkingAB added the invalid This doesn't seem right label Oct 8, 2026
@uNetworkingAB

Copy link
Copy Markdown
Contributor

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.

@nigrosimone

Copy link
Copy Markdown
Contributor Author

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.

@uNetworkingAB
uNetworkingAB merged commit 9f90f38 into uNetworking:master Oct 8, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

invalid This doesn't seem right

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants