Reduce static TLS from 856 to 16 bytes with a heap-allocated per-thread struct - #58
Conversation
|
|
||
| #define v_w_from__u1_bet(AA) \ | ||
| v = beta * log(u1 / (1.0 - u1)); \ | ||
| v = st->beta * log(u1 / (1.0 - u1));\ |
There was a problem hiding this comment.
How about doing beta = st->beta after loading st? That would reduce the diff size and reduce the risk of conflicts when updating Rmath version.
There was a problem hiding this comment.
Copying in doesn't quite work here: these are a cache that survives across calls (keyed on olda/oldb), so without a copy-back before every return qsame would just serve stale coefficients. rhyper and rpois have a lot of exits and I would rather not depend on getting that right.
So instead I aliased the fields back to the upstream names at the fetch site:
struct rbeta_state *st = &Rmath_tls_get()->rbeta;
#define beta st->beta
#define olda st->oldawith #undefs after the function. The bodies are byte-identical to R's now, which helps more than copy-in would have: upstream lines removed by the patch went 245 -> 38 (rhyper.c: 92 -> 10), and they are all in the declaration block, so upstream algorithm changes can no longer conflict. Patch went 1447 -> 804 lines.
If a future R version adds a local with a colliding name, the alias expands into it and it fails to compile at that line, which seems like the right failure mode. Noted in the README.
Does not work for signrank.c/wilcox.c since the state is shared with w_init_maybe/csignrank/cwilcox and has to be passed as a parameter there.
Stream is byte-identical (288k variates over all algorithm branches, clang and gcc-15).
There was a problem hiding this comment.
Nice. Is it really useful to call #undef at the end of the file though? For .c file (which are not #included anywhere), does it make any difference?
There was a problem hiding this comment.
Good point, they're at EOF in every one of those files so they guard nothing — dropped.
8bb97e5 to
a648656
Compare
Review feedback from @nalimilan on #58. Alias the per-thread state back to upstream's identifiers with a block of `#define`s at each fetch site, instead of rewriting every use as `st->field`. The generator bodies are now byte-identical to R's, so the upstream lines the patch removes drop from 245 to 38 and an R release that edits an algorithm no longer conflicts. A future release adding a local that collides with an alias fails to compile at that line rather than changing behaviour silently. Report a failed container allocation with MATHLIB_ERROR, as signrank.c and wilcox.c already do for their tables. ML_WARN_return_NAN was not merely weaker, it was silent: ML_WARNING is gated on `x > ME_DOMAIN`, so with ME_DOMAIN it expands to nothing. Rmath_tls_get() can no longer return NULL, which removes the error path from all seven call sites. Drop src/sunif.c from patches/thread-local.patch. R ships it under src/nmath/standalone/, which `make update` excludes, so the hunk was re-applied to an already-patched file -- `patch` reported "Reversed (or previously applied) patch detected" and non-interactively assumed -R, undoing the _Thread_local on the seed. Document the rule in the README. Assert the static TLS segment size in CI, which the README already claimed. Also: spaces not tabs and NULL not 0 in rmath_tls.c; correct the claim that the read-only coefficient tables are `const static` (fact[] in rpois.c is, the rexpm1() coefficients in toms708.c are plain `static`). Verified: RNG stream over 288000 variates and d/p/q signrank+wilcox are byte-identical to master under both clang and gcc-15; heap flat across 2000 thread create/join cycles; the allocation-failure path exits 1 under an interposed failing calloc; the patch applies with no prompts and reproduces the tree byte-for-byte in all 128 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed 9f0ecb5. Two things in there that you did not ask for:
Non-interactively CI now asserts the static TLS size, which the README claimed but nothing enforced. Verification, all re-run after the rewrite:
|
PR #50 made the generators' setup caches _Thread_local to fix data races between threads, which gave libRmath-julia an 856-byte static TLS segment. glibc draws a dlopen'd module's TLS block from a fixed ~1664-byte process-wide surplus shared with every other shared library, so consuming half of it made unrelated libraries fail to load with "cannot allocate memory in static TLS block" (issue #56). Move the state to the heap behind a single thread-local pointer. Each generator keeps its own struct, nested in one Rmath_tls container so there is one allocation, one free and one cleanup registration. Field names are unchanged from upstream R to keep patches/thread-local.patch mechanical. Cleanup is automatic and there is no new public API: rmath_tls.c registers a thread-exit destructor via pthread_key_create() on POSIX and FlsAlloc() on Windows. Windows deliberately avoids pthreads, which on mingw-w64 would add a runtime dependency on libwinpthread-1.dll that Rmath_jll does not ship -- the same class of load failure this change fixes. Each struct is initialised by a hook in its owning .c file, transcribing the initialisers deleted from the declarations, so a future R release that changes a sentinel shows both halves in one patch hunk. Verified: the generated stream is byte-identical over 288k variates across all algorithm branches, and TLS storage drops from 832 to 48 bytes (macOS __thread_data + __thread_bss; 77 thread-local variables down to 8). Every field moved was confirmed to be written by const-qualifying it and checking the compiler rejects the assignment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These two differ from the generators: the state is a pointer to a table that is itself on the heap, so the container now owns a second level of allocation. Rmath_signrank_state_free() and Rmath_wilcox_state_free() keep the deep-free logic in the file that knows the table's shape -- wilcox's is a jagged three-level array -- rather than in rmath_tls.c, and the thread-exit destructor releases the tables before the container. Neither gets an init hook. They had no initialisers upstream and they depend on calloc()'s zeroing: a NULL w means "not allocated yet", and csignrank() uses w[0] == 1. as its "table already built" flag. This also fixes a per-thread leak. PR #50 made these _Thread_local, which turned what upstream R treats as a deliberate process-wide cache into per-thread tables -- for wilcox, 51x51 pointers (~21 KB) plus every row cached on demand -- with no automatic release. Thread exit now frees them. Static TLS storage is now 16 bytes, down from 856: the Rmath_tls pointer plus sunif.c's seed, which stays _Thread_local because it is exactly the size of the pointer that would replace it, sits on the hottest path, and is reached through a void set_seed() that could not report an allocation failure. Verified: the RNG stream is unchanged, and d/p/q signrank and wilcox are byte-identical across a schedule that straddles WILCOX_MAX, revisits smaller sizes to exercise the reallocation paths, and calls the public free functions mid-run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wilcox_free() routed through a w_free_maybe() helper that only freed when m or n exceeded WILCOX_MAX (50). But w_init_maybe() floors both allocated_m and allocated_n at WILCOX_MAX, so for the usual m,n <= 50 the condition never held and the public wilcox_free() silently did nothing. That is reasonable upstream, where w[][][] is a process-wide cache worth keeping. Since PR #50 made it _Thread_local it is per-thread, so a caller asking for it to be freed should get it freed. Call the deep-free directly; the helper had no other caller and is removed. Measured: dwilcox(5, 10, 10) plus dsignrank(5, 20) allocate 25536 bytes, of which the two free functions now release 24624. Results are unaffected -- the table transparently rebuilds -- and d/p/q values remain byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The %.o: %.c rule had no header prerequisites, so editing a header rebuilt nothing. That was survivable while the headers were stable and `make update` overwrote whole files, but rmath_tls.h is included by seven .c files and declares Rmath_tls_ptr as _Thread_local: a partial rebuild links objects that disagree about whether to reach it through TLS, which links fine and then segfaults on the first call. Found the hard way while testing this branch. Generate .d files with -MMD -MP and include them. Touching rmath_tls.h now rebuilds its eight dependents, and nmath.h rebuilds 119. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerate patches/thread-local.patch against pristine R 4.4.1 sources and verify the round trip: applying it to the extracted nmath sources reproduces the working tree byte-for-byte in all eight files. Give the README a "Per-thread state" section explaining why the state is on the heap, and the two rules for touching it: new mutable per-thread state goes in Rmath_tls with an init hook in the owning .c file, and read-only tables stay const static. Records the const-qualifier trick for telling the two apart, including clang's 20-diagnostic default that hides some. Add a test that separate threads with separate parameters reproduce what each would draw alone, using the library's own seeded generator since Julia's rand is shared across threads. Verified to fail when the container pointer is made process-wide. CI now runs with four threads; it was single-threaded, so the pre-existing threaded testset never actually exercised concurrency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback from @nalimilan on #58. Alias the per-thread state back to upstream's identifiers with a block of `#define`s at each fetch site, instead of rewriting every use as `st->field`. The generator bodies are now byte-identical to R's, so the upstream lines the patch removes drop from 245 to 38 and an R release that edits an algorithm no longer conflicts. A future release adding a local that collides with an alias fails to compile at that line rather than changing behaviour silently. Report a failed container allocation with MATHLIB_ERROR, as signrank.c and wilcox.c already do for their tables. ML_WARN_return_NAN was not merely weaker, it was silent: ML_WARNING is gated on `x > ME_DOMAIN`, so with ME_DOMAIN it expands to nothing. Rmath_tls_get() can no longer return NULL, which removes the error path from all seven call sites. Drop src/sunif.c from patches/thread-local.patch. R ships it under src/nmath/standalone/, which `make update` excludes, so the hunk was re-applied to an already-patched file -- `patch` reported "Reversed (or previously applied) patch detected" and non-interactively assumed -R, undoing the _Thread_local on the seed. Document the rule in the README. Assert the static TLS segment size in CI, which the README already claimed. Also: spaces not tabs and NULL not 0 in rmath_tls.c; correct the claim that the read-only coefficient tables are `const static` (fact[] in rpois.c is, the rexpm1() coefficients in toms708.c are plain `static`). Verified: RNG stream over 288000 variates and d/p/q signrank+wilcox are byte-identical to master under both clang and gcc-15; heap flat across 2000 thread create/join cycles; the allocation-failure path exits 1 under an interposed failing calloc; the patch applies with no prompts and reproduces the tree byte-for-byte in all 128 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3bec48a to
9f0ecb5
Compare
They sit at end-of-file in all five generators, so the aliases they undo are already dead where they are undefined -- nothing is protected. No .c file is #included anywhere in src/ and there is no unity build, so there is no cross-translation-unit leakage either. The compile-error guarantee comes from the #define, not the #undef: a future R release adding a local named `beta` inside rbeta() expands to `double st->beta;` and fails to compile with or without them. Code added *below* the function would fail too, since `st` is not in scope there. Meanwhile each block cost a patch hunk anchored on the last three lines of a generator function (@@ -133,3 +149,11 @@ and friends), so an upstream edit to the tail of rbeta()/rhyper()/... conflicted on `make update` for no benefit -- exactly the cost the aliasing is there to avoid. Two hunks per generator now instead of three, and 99 fewer lines of patch. The five object files are byte-identical to before, as they must be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up JuliaStats/Rmath-julia#58, which moves the per-thread generator state off static TLS onto the heap (TLS segment 856 -> 16 bytes) and fixes JuliaStats/Rmath-julia#56. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to #57. Fixes #56.
PR #50 made the generators' setup caches
_Thread_localto fix data races, which gavelibRmath-juliaan 856-byte static TLS segment. glibc draws adlopen'd module's TLS block from a fixed ~1664-byte process-wide surplus shared with every other shared library, so consuming half of it made unrelated libraries fail to load:The
glibc.rtld.optional_static_tlstunable that would raise the surplus only exists from glibc 2.32, so it does not help the affected users (#56 reports glibc 2.27).Approach
Move the state to the heap, in one
Rmath_tlscontainer reached through a single thread-local pointer. 856 → 16 bytes: that pointer, plussunif.c's seed.Each generator keeps its own struct, nested in one container so there is one allocation, one free and one cleanup registration. Field names are unchanged from upstream so re-applying
patches/thread-local.patchaftermake updatestays mechanical.sunif.c'sI1/I2stay_Thread_localdeliberately: 8 bytes is exactly the size of the pointer that would replace it (zero gain), it is the hottest path in the library, andset_seed()returnsvoidso it could not report an allocation failure.Every field moved was confirmed to be written — by
const-qualifying each one and checking the compiler rejects the assignment. All 74 qualify; the read-only coefficient tables stayconst static, per the rule #57 added to the README.Cleanup is automatic, with no new public API
include/Rmath.his untouched.src/rmath_tls.cregisters a thread-exit destructor viapthread_key_create()on POSIX andFlsAlloc()on Windows.Windows avoids pthreads deliberately: on mingw-w64
-lpthreadresolves through winpthreads and adds a runtime dependency onlibwinpthread-1.dll, whichRmath_jlldoes not ship (its Yggdrasil recipe declares no dependencies) — the same class of load failure this PR fixes. On every Unix target-lpthreadis free:libpthread.so.0is part of glibc itself (folded into libc from 2.34), musl's is an empty stub, macOS has it in libSystem and FreeBSD in base.Two pre-existing bugs fixed along the way
wilcox_free()never freed anything for m,n ≤ 50. It routed through a helper guarded onm > WILCOX_MAX || n > WILCOX_MAX, butw_init_maybe()floors bothallocated_*atWILCOX_MAX, so the test never fired. Harmless upstream where the table is a process-wide cache; a ~25 kB per-thread leak since Improve thread-safety #50 made it_Thread_local.src/Makefilehad no header dependencies. Editing a header rebuilt nothing. Survivable while the headers were stable, butrmath_tls.his included by seven.cfiles and declaresRmath_tls_ptras_Thread_local: a partial rebuild links objects that disagree about whether to reach it through TLS, which links fine and then segfaults on the first call. Found the hard way on this branch.Verification
d/p/q signrank/wilcoxbyte-identical, including through the reallocation paths and the public*_free()calls.*_free()releases 24 624 of the 25 536 bytes allocated; heap stays flat across 2000 thread create/join cycles, against 49.7 MB leaked with the destructor disabled.make updateround-trip: the regenerated patch applies to pristine R 4.4.1 sources and reproduces the working tree byte-for-byte in all eight files._Thread_localaccess is a thunk call, sorhyperwent from 23 per invocation to one, measuring 30–38% faster. Expect roughly neutral on Linux, where GCC already hoists one__tls_get_addrper function.Notes for review
rhyperreuses the cachedsas a scratch temporary (s = ym / ynin branch III's large case), so that slot never needed caching. The aliasing is preserved exactly rather than "fixed", to keep the stream identical.test.jltestset was verified to fail when the container pointer is made process-wide. CI now runs with four threads; it was single-threaded, so the pre-existing threaded testset never actually exercised concurrency.🤖 Generated with Claude Code