Skip to content

build the bundled onetbb on windows when rtools has no onetbb - #269

Merged
kevinushey merged 13 commits into
masterfrom
feature/windows-bundled-onetbb
Jul 28, 2026
Merged

build the bundled onetbb on windows when rtools has no onetbb#269
kevinushey merged 13 commits into
masterfrom
feature/windows-bundled-onetbb

Conversation

@kevinushey

Copy link
Copy Markdown
Contributor

Fixes two Windows linking problems, reported against 6.1.0 on R 4.2 / Rtools42
after rstan 2.32.7 stopped building there. It built fine against 5.1.11-2.

Background

Rtools42 ships Intel TBB 2017 (TBB_INTERFACE_VERSION 9107) in
x86_64-w64-mingw32.static.posix: static libraries only, no oneapi/, no
tbb/version.h. Since 6.0.0, configure adopts an Rtools TBB purely on the
presence of a libtbb* archive, so that tree gets put on downstream include
paths. Two things then break:

  1. Compile. StanHeaders uses tbb::this_task_arena::isolate
    unconditionally (map_rect_concurrent.hpp:54, reduce_sum.hpp:257). TBB
    2017 defines __TBB_TASK_ISOLATION as
    (__TBB_CPF_BUILD||TBB_PREVIEW_TASK_ISOLATION), i.e. 0, and gates isolate
    on it. Forcing the declaration into view would not help either: nm on
    libtbb_static.a shows no isolate_within_arena.

  2. Link. tbbLdFlags() returned only -L<libs> -lRcppParallel on Windows,
    so downstream packages could only resolve TBB symbols that
    RcppParallel.dll happened to re-export. rstan takes all of its PKG_LIBS
    from RcppParallelLibs() and never spells -ltbb itself, hence
    undefined reference to tbb::internal::task_scheduler_observer_v3::observe(bool).

For contrast, 5.1.11-2 bundled TBB 2019, which enables
__TBB_TASK_ISOLATION by default. Neither release has tbb/version.h, so
-DTBB_INTERFACE_NEW was absent in both cases -- the 2019 -> 2017 downgrade is
what actually broke Stan, not the interface macro.

Changes

  • Only adopt the Rtools TBB when it is oneTBB; otherwise build the bundled
    oneTBB from sources. Rtools42 provides cmake 3.24.1 in the same directory as
    gcc, so this needs no new tooling. It is built static, so RcppParallel.dll
    takes exactly the shape it does with an Rtools oneTBB -- oneTBB marks its
    entry points __declspec(dllexport) on mingw (detail/_export.h), so the
    whole-archive re-export of tbbmalloc carries over unchanged.
  • Toolchains that can build neither (Rtools40: no TBB, no cmake) still fall
    back to tinythread. A missing or too-old cmake is only an error off Windows.
  • RcppParallelLibs() offers the stub library again, after -lRcppParallel so
    the package's own exports still take precedence.

Verified locally

  • Configure decision logic across six simulated toolchains. Rtools43+ output
    is byte-identical to before this PR.
  • Full macOS install (bundled oneTBB build) plus all 9 package tests.
  • The new downstream check runs green on macOS.

What this PR needs CI to answer

None of the Windows behaviour could be tested locally:

  1. Does oneTBB 2022 compile with Rtools42's GCC 10.4? -mwaitpkg is gated to

    = 11, but -fstack-clash-protection applies at >= 8 and -mrtm is
    unconditional on x86.

  2. Is -G "MSYS Makefiles" the right generator under Rtools? Least confident
    choice here.
  3. Do the bundled archives come out as libtbb12.a / libtbbmalloc.a?
  4. Does the locally-built static archive re-export through RcppParallel.dll
    the way the Rtools prebuilt one does?

The CI additions target exactly these: an R 4.2 Windows job, plus a
downstream job on R 4.2 and release that compiles and loads a translation
unit using this_task_arena::isolate and task_scheduler_observer. Plain
R CMD check would not have caught the rstan breakage -- RcppParallel
installed perfectly well throughout.

A full rstan build is the real acceptance test and has not been run.

tbbLdFlags() returned only '-L<libs> -lRcppParallel' on Windows, leaving
downstream packages to resolve TBB symbols against whichever objects
happened to be pulled out of the static TBB when RcppParallel.dll was
linked. That export surface is incidental rather than declared, and
packages taking all of their PKG_LIBS from RcppParallelLibs() (e.g. rstan)
have no other way to ask for TBB.

Offer the stub library as well, after '-lRcppParallel' so the package's own
exports still take precedence and no dependency on the stub is recorded
unless something actually needs it. RcppParallel 5.1.11 and earlier appended
'-ltbb -ltbbmalloc' here for the same reason.
Rtools42 ships Intel TBB 2017, and configure adopted it purely on the
presence of a libtbb* archive. Downstream packages then compiled against
those headers, which is not viable: StanHeaders uses
tbb::this_task_arena::isolate unconditionally, TBB 2017 still gates it
behind TBB_PREVIEW_TASK_ISOLATION, and its library doesn't export
isolate_within_arena, so forcing the declaration into view wouldn't link
either. rstan 2.32.7 consequently stopped building on R 4.2 / Windows.

Only adopt the Rtools TBB when it is oneTBB, and otherwise build the
bundled copy from sources, as we already do elsewhere. Rtools42 provides
cmake 3.24.1 in the same directory as gcc, so this needs no new tooling.
It is built as a static library so that RcppParallel.dll takes exactly the
shape it does with an Rtools oneTBB -- oneTBB marks its entry points
__declspec(dllexport) on mingw, so the whole-archive re-export of tbbmalloc
carries over unchanged.

Toolchains that can build neither (e.g. Rtools40, which has no TBB and no
cmake) still fall back to tinythread: a missing or too-old cmake is only an
error off Windows, where the bundled build is the sole option.

Also add CI covering this. R CMD check gains an R 4.2 Windows job, and a new
'downstream' job compiles and loads a translation unit against the flags
RcppParallel advertises, exercising both this_task_arena::isolate and
task_scheduler_observer. R CMD check alone would not have caught the rstan
breakage: RcppParallel installed perfectly well throughout.
@kevinushey
kevinushey force-pushed the feature/windows-bundled-onetbb branch from 438257d to e13d475 Compare July 27, 2026 23:34
The default shell on Windows runners is PowerShell, where 'r' is an alias
for Invoke-History; 'R CMD INSTALL' was parsed as 'Invoke-History CMD
INSTALL' and failed before the job did anything useful.
oneTBB probes the GNU assembler version by compiling /dev/null, which does
not exist when a native Windows cmake drives mingw gcc. The compiler fails
with 'no input files', the version regex doesn't match, and math() then
chokes on the error text -- taking the whole configure down:

  CMake Error at cmake/compilers/GNU.cmake:65 (math):
    math cannot parse the expression: "g++.exe: error: /dev/null: No such
    file or directory ... * 1000 + ..."

MXE doesn't hit this because it cross-builds oneTBB from a POSIX host.

Use NUL on Windows, and treat an unparseable probe as an assembler too old
for waitpkg rather than a fatal error -- it only guards an optimization, and
Rtools42's GCC 10.4 is below the GCC 11 floor for -mwaitpkg regardless.
GCC 10.4 aborts compiling oneTBB's task_dispatcher.h with the flag on:

  internal compiler error: in seh_emit_stackalloc, at config/i386/winnt.c:1056
    370 |                 do_throw_noexcept([] { throw; });

a known GCC defect on mingw's SEH targets. It is a hardening flag rather
than a correctness one, so leave it off there.

Also fold the earlier /dev/null fix into a single patches/mingw_gnu_cmake.diff,
as both make oneTBB's GNU.cmake usable for a native mingw build.
@kevinushey
kevinushey force-pushed the feature/windows-bundled-onetbb branch from 961cd0f to 00076d9 Compare July 27, 2026 23:49
The bundled oneTBB carries a local 'provided for backwards compatibility'
block declaring tbb::internal::task_scheduler_observer_v3 and
tbb::interface6::task_scheduler_observer (added in c276b03; not upstream).
tbb-compat.cpp declares them too, which was fine while the stub was only
ever built against the pristine headers Rtools ships -- but building the
bundled oneTBB on Windows makes the two collide:

  tbb-compat.cpp:25:7: error: redefinition of
    'class tbb::internal::task_scheduler_observer_v3'

Have the bundled header advertise what it provides, and skip the stub's own
declarations when it does. Either way the stub still exports the out-of-line
observe(), which is the whole reason it exists.

Also spell that definition __TBB_EXPORTED_METHOD, matching its declaration
here and in the old TBB ABI; the two attributes are both no-ops on the
Windows targets we build for, so this is consistency rather than a fix.
The bundled oneTBB carries a local (non-upstream) definition of
tbb::internal::task_scheduler_observer_v3::observe in observer_proxy.cpp,
alongside the declarations in task_scheduler_observer.h. Once the bundled
copy is actually built on Windows, libtbb12.a and tbb-compat.o both define
it and the stub fails to link:

  libtbb12.a(observer_proxy.cpp.obj): multiple definition of
    'tbb::internal::task_scheduler_observer_v3::observe(bool)'

The stub has to own that definition on Windows, because R CMD SHLIB builds
the DLL's export list from the objects it compiles -- a definition sitting
in the archive would never make it into tmp.def. So leave it out of the
library there; other platforms keep it, where it serves binaries built
against RcppParallel 5.x.

Also stop trusting R CMD SHLIB's exit code: it reported success through the
link failure above, so the install completed and shipped a package with no
tbb.dll -- precisely the failure mode that packages linking '-ltbb' only
hit at load time. Check for the artifact, and for the copy, instead.
The bundled oneTBB carries local declarations and a definition of the old
ABI's task_scheduler_observer, added in c276b03 so that binaries built
against RcppParallel 5.x keep working. Neither had a file in patches/, and
tools/tbb/update-tbb.R wipes src/tbb wholesale -- so the next oneTBB bump
would have dropped them silently, breaking the Windows tbb.dll stub in a
way that is hard to trace back.

Verified by applying the patch to a pristine oneTBB 2022.0.0 tree and
confirming the result matches what we ship.
The 'downstream' job stands in for rstan in the normal CI run: it compiles
a translation unit using the two TBB APIs StanHeaders needs, in seconds
rather than the best part of an hour. This runs the real thing, on demand,
for when that's worth waiting for -- before a release, or after touching
the Windows TBB setup.

workflow_dispatch inputs choose the R version (4.2 by default, the one whose
Rtools has no oneTBB), the runner, and whether to additionally compile and
fit a tiny Stan model. The last exercises the TBB runtime at run time, not
just the headers and link line, so it's opt-in.
Drop V8 and withr: rstan 2.32 replaced V8 with QuickJSR, and V8 failing to
install on an old toolchain would have failed the job for reasons that have
nothing to do with RcppParallel. The list now mirrors rstan's Imports plus
LinkingTo.

Install StanHeaders and rstan with dependencies = FALSE, and assert
afterwards that RcppParallel is still the .9000 build from this branch --
otherwise dependency resolution could quietly replace it with CRAN's, and
the run would report on the released package instead of this one.
Comparing src/tbb against a pristine oneTBB 2022.0.0 turns up 41 modified
files. Most are workarounds for over-zealous CRAN checks -- commented-out
'#pragma warning' / '#pragma GCC diagnostic' directives and the like -- and
are not worth carrying. Seven are not:

- src/tbb/def/{lin32,lin64,mac64}-tbb.def add
  tbb::internal::task_scheduler_observer_v3::observe to the export list,
  under a comment reading 'Needed by rstan', along with std:: exception
  typeinfo and destructors for backwards compatibility. These are the
  Linux/macOS counterpart of the Windows tbb.dll stub, so losing them would
  break rstan on exactly the platforms where it currently works.

- The four CMakeLists.txt files drop VERSION / SOVERSION, so the build emits
  unversioned libraries. install.libs.R's versionBundledTbbLibraries() then
  re-creates the 'libtbb.so.2' layout (#260); the two have to stay in step.

Both patches verified by applying them to a pristine tree and confirming
every touched file matches what we ship.
@kevinushey
kevinushey merged commit 222d02e into master Jul 28, 2026
8 checks passed
kevinushey added a commit that referenced this pull request Jul 28, 2026
* build the bundled onetbb as a shared library on windows

Windows was the only platform where TBB was not a shared library loaded at
runtime, and nearly every Windows-specific wart traced back to that. Use the
bundled oneTBB there too, built shared, shipped as tbb.dll / tbbmalloc.dll and
linked exactly as on other platforms.

Rtools ships static libraries only, so adopting its TBB meant linking it into
RcppParallel.dll. That made the TBB version -- and ABI -- a property of the
user's toolchain (Intel TBB 2017 on Rtools42, oneTBB later), and left
downstream packages with no TBB library to link against. The workarounds
accumulated: re-exporting the whole tbbmalloc archive so the allocator could be
resolved (#262), a tbb.dll stub so packages linking -ltbb could load (#249,
#250), building that stub differently for legacy toolchains (#258), offering it
from RcppParallelLibs() so rstan could link at all (#269), and a library lookup
that searched for archives never installed with the package (#270).

Worse, the stub linked the TBB archives itself, so it was a second complete
copy of the oneTBB scheduler. Both it and RcppParallel.dll export the same 81
tbb::detail::r1:: entry points, both are loaded in every session, and which one
a downstream package binds to is up to the linker. Two schedulers with separate
thread pools and separate global state is a correctness problem, not a linking
inconvenience.

This removes:

- the Rtools TBB detection in configure.R, and with it the TBB_NAME derivation
  from archive names and the tbb12 special case
- the --whole-archive tbbmalloc re-export, which only existed because there was
  no DLL to link
- BUILD_SHARED_LIBS=0 for Windows
- src/tbb-compat/ and buildTbbStub() entirely, along with the #ifndef _WIN32
  guard in observer_proxy.cpp and the __TBB_LEGACY_..._PROVIDED macro that let
  the stub tell which headers it was compiling against
- the Windows branches in .install.libs(), loadTbbLibrary(), tbbLdFlags() and
  tbbLibraryPath(), and the Windows-first load order in .onLoad()

Notes on the pieces that are not just deletions:

- mingw CMake would name a shared build libtbb.dll; patch PREFIX to "" so the
  runtime is tbb.dll, the name binaries built against 5.1.11 and earlier
  import. The import library keeps its lib prefix, so -ltbb still resolves.
- the legacy task_scheduler_observer_v3::observe definition now applies on
  Windows too. mingw has no .def file, so its export comes from a dllexport
  directive: the declaration gains TBB_EXPORT, which is empty when consuming
  the headers.
- .onLoad loads tbb before RcppParallel on every platform now, since
  RcppParallel.dll imports from tbb.dll and the package library directory is
  not on the DLL search path.
- tbbLdFlags() keeps -lRcppParallel on Windows: isProcessForkedChild is
  compiled into RcppParallel.dll, and Windows has no lazy binding to resolve it
  at load time.

The downstream CI check now asserts the property this is all for: the
downstream library's TBB symbols come from exactly one module, that module is
tbb.dll, and RcppParallel.dll imports from it rather than carrying its own copy.

* fix the shared tbb link on rtools42 and aarch64 windows

Two failures from the first CI run, both only reachable now that TBB is linked
as a shared library on Windows -- a static build has no link step.

Rtools42 (gcc 10): the TBB link failed with undefined references to
__stack_chk_fail, __strncpy_chk and __strncat_chk. TBB compiles with
-fstack-protector-strong and _FORTIFY_SOURCE, and on mingw those helpers live
in libssp rather than libgcc; newer toolchains pull it in implicitly, Rtools42
does not. Pass -lssp via CMAKE_SHARED_LINKER_FLAGS, mirroring the -lssp that
configure.R already adds for RcppParallel's own link.

aarch64 Windows: 'lld: error: unknown argument: -z'. Rtools45-aarch64 drives
clang with lld, and Clang.cmake adds -Wl,-z,relro,-z,now for any non-Apple
target. GNU.cmake already guards this with NOT MINGW; give Clang.cmake the
equivalent guard.

windows-latest (release) passed on the first run, so the shared mingw build,
the PREFIX "" naming and the reordered load in .onLoad() all work.

* link libssp via TBB_COMMON_LINK_LIBS rather than the shared linker flags

TBB compiles with -fstack-protector-strong and _FORTIFY_SOURCE=2, and on
mingw the helpers those need (__stack_chk_fail, __stack_chk_guard,
__strncpy_chk, ...) live in libssp rather than libgcc. Rtools42 (gcc 10)
does not pull it in implicitly, so the link fails with undefined
references to them.

Passing -lssp through CMAKE_SHARED_LINKER_FLAGS was not enough: those
flags are emitted before the objects, and GNU ld only resolves symbols
that are already undefined when it reaches a library, so it was
discarded. tbb and tbbmalloc happened to survive that because
tbb_handle_ipo gives them LTO; tbbmalloc_proxy has no such call and
failed to link. Putting ssp in TBB_COMMON_LINK_LIBS routes it through
target_link_libraries instead, which lands after the objects, and covers
all three targets.

* fix the RcppParallel.dll half of the downstream import check

Two reasons it was never actually running, both visible in the CI log
even though the job passed:

- the path was built with paste0("libs", .Platform$r_arch), giving
  'libsx64' rather than 'libs/x64'. system.file() returned "" for that,
  so objdump was handed '/RcppParallel.dll' and reported "No such file".
  Use archSystemFile(), which the package already has for this, and fail
  loudly if the library still can't be found.

- on aarch64 the runner has an x86_64 objdump from C:/mingw64 ahead of
  Rtools' own on the PATH, and it cannot read an aarch64 PE, so both
  halves of the check skipped there. Try the objdump sitting beside the
  compiler first, and fall back through the remaining candidates.

* locate objdump via the compiler R actually builds with

The previous attempt looked beside Sys.which("gcc"/"clang"), but on the
aarch64 runner those resolve to C:/mingw64 -- the same x86_64 toolchain
that leads the PATH -- so both candidates were the one objdump that
cannot read an aarch64 PE, and the check still skipped there. They were
also not deduplicated, differing only in slash direction.

'R CMD config CC' names the compiler that produced the library, so its
objdump can read it by construction. Try that first, and normalize the
candidates so one objdump is not tried under two spellings.

* don't emit -ltbb when no tbb was installed

The Windows branch of tbbLdFlags() named the TBB libraries
unconditionally, dropping the file.exists() guard the previous stub-based
code had. On a build that fell back to tinythread -- configure.R sets
TBB_ENABLED = FALSE only there, when cmake is missing and TBB_LIB is
unset -- RcppParallelLibs() then emitted

    -L<libs> -lRcppParallel -L<lib> -ltbb -ltbbmalloc

for libraries that were never built or shipped, so a downstream package
would fail to link with "cannot find -ltbb". CxxFlags() correctly reports
-DRCPP_PARALLEL_USE_TBB=0 in that configuration, so the package is not
even using TBB.

Name them only when TBB is enabled and the library actually resolves.

Also drop an unverified aside from NEWS: Rtools42 and Rtools45 are
confirmed to ship cmake, Rtools40 was not checked.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant