Skip to content

Commit e34f8cf

Browse files
authored
Keep a loaded XNNPACK method's thread pool alive, and ship threadpool.h in the wheel (#24770)
### What is wrong today **From Python**, `_unsafe_reset_threadpool(n)` is the only way to set the CPU thread count. Calling it after an XNNPACK method is loaded can make later runs crash or hang: ```python method = Runtime.get().load_program(pte.buffer).load_method("forward") _unsafe_reset_threadpool(16) for _ in range(20): method.execute([x]) # segfault, abort, or hang ``` The reset frees the pthreadpool, which XNNPACK got from `get_pthreadpool()` at load time and keeps using. On Android, a second `Module` with another thread count resets the pool and can crash the first one. **From C++**, an app built against the pip package cannot set the count: `get_threadpool()->_unsafe_reset_threadpool(n)` is declared in `threadpool.h`, which the wheel does not ship. ### What this change does - `ThreadPool` keeps its pthreadpool in a `std::shared_ptr`, returned by the new `get_shared_pthreadpool()`. The XNNPACK executor holds it, so the XNNPACK part of a method loaded before a reset keeps the old pool until the method is destroyed. Later methods and kernels use the new pool. - Both getters read the pointer under a new mutex that a reset also takes, so the XNNPACK delegate can load a method during a reset on another thread. - A `fork()` child does not destroy inherited pools, whose threads stayed in the parent. - The wheel ships `threadpool.h`. It declares the pthreadpool type itself and includes `pthreadpool.h` only where it exists, so it works from the wheel, and existing code that calls pthreadpool keeps compiling. - The docs say to set the count before loading, how to reload a method, and that resetting during a `CpuBackend` load or run is unsafe. ### Tests - New `XNNCompilerTest.RuntimeKeepsItsThreadpoolAcrossReset` runs an XNNPACK graph across a reset, checks the output, and checks that destroying the delegate frees the old pool. New `threadpool_test.cpp` tests cover a held pool across a reset, readers during resets, and a release in a forked child. `.ci/scripts/wheel/test_cpp_sdk.py` now also links a program calling `_unsafe_reset_threadpool` against the installed wheel. Each fails without its part of the fix (the readers test under ThreadSanitizer). - Ran on Linux x86_64: thread pool and XNNPACK C++ tests (plain, AddressSanitizer with UndefinedBehaviorSanitizer, ThreadSanitizer), native CPU engine tests, Python tests for pybindings, XNNPACK and LLM attention custom ops, and the C++ SDK checks on a wheel of this branch. On Linux aarch64: thread pool C++ tests in the same modes. - Every failing test also fails on main, or needs network access the test machine lacked. No macOS or Windows wheel was built. - On main, the Python code above crashed or hung in most runs. It passes now. Fixes the crash in #24765.
1 parent 4f73732 commit e34f8cf

11 files changed

Lines changed: 297 additions & 29 deletions

File tree

‎.ci/scripts/wheel/test_cpp_sdk.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,14 @@ def test_every_shipped_header_compiles(work_dir: Path) -> None:
12331233
assert (
12341234
headers
12351235
), f"no headers found under {include_root}, so this check would prove nothing"
1236+
# Everything ships under include/executorch. A header at the top of the include directory is on
1237+
# every consumer's include path, where it can shadow the consumer's own copy of that library.
1238+
outside = [
1239+
header.relative_to(include_root).as_posix()
1240+
for header in headers
1241+
if header.relative_to(include_root).parts[0] != "executorch"
1242+
]
1243+
assert not outside, f"the wheel ships headers outside include/executorch: {outside}"
12361244

12371245
# The same include directories the CMake package exports, since that is what a consumer gets.
12381246
includes = [
@@ -1499,6 +1507,12 @@ def test_shipped_headers_have_implementations(work_dir: Path) -> None:
14991507
"int main() { return get_thread_count() > 0 && "
15001508
"parallel_for(0, 1, 1, [](int64_t, int64_t) {}) ? 0 : 1; }\n"
15011509
),
1510+
# The call the documentation gives for setting the thread count.
1511+
"extension/threadpool/threadpool.h": (
1512+
"#include <executorch/extension/threadpool/threadpool.h>\n"
1513+
"using namespace executorch::extension::threadpool;\n"
1514+
"int main() { return get_threadpool()->_unsafe_reset_threadpool(2) ? 0 : 1; }\n"
1515+
),
15021516
}
15031517

15041518
includes = [

‎backends/xnnpack/runtime/XNNCompiler.cpp‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2490,13 +2490,15 @@ ET_NODISCARD Error XNNCompiler::compileModel(
24902490
: nullptr;
24912491
}
24922492

2493+
executor->threadpool_ =
2494+
::executorch::extension::threadpool::get_shared_pthreadpool();
24932495
// NOLINTBEGIN(facebook-hte-NullableDereference) - weights cache is allowed to
24942496
// be null
24952497
status = xnn_create_runtime_v4(
24962498
subgraph.get(),
24972499
weights_cache_ptr,
24982500
workspace,
2499-
::executorch::extension::threadpool::get_pthreadpool(),
2501+
executor->threadpool_.get(),
25002502
runtime_flags,
25012503
&runtime_ptr);
25022504
// NOLINTEND(facebook-hte-NullableDereference)

‎backends/xnnpack/runtime/XNNExecutor.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ class XNNExecutor {
3535
// we need to hold onto the buffers to keep them alive.
3636
std::vector<executorch::runtime::FreeableBuffer> unpacked_buffers_;
3737

38+
// Keeps runtime_'s threadpool alive across thread count resets. Declared
39+
// before runtime_, so it outlives it.
40+
std::shared_ptr<pthreadpool> threadpool_;
41+
3842
std::unique_ptr<xnn_runtime, decltype(&xnn_delete_runtime)> runtime_{
3943
nullptr,
4044
&xnn_delete_runtime};

‎backends/xnnpack/test/runtime/test_xnnexecutor.cpp‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include <executorch/backends/xnnpack/runtime/XNNExecutor.h>
1010
#include <executorch/backends/xnnpack/runtime/XNNPACKBackend.h>
1111
#include <executorch/backends/xnnpack/serialization/schema_generated.h>
12+
#include <executorch/extension/threadpool/threadpool.h>
1213
#include <executorch/runtime/core/exec_aten/testing_util/tensor_factory.h>
1314
#include <executorch/runtime/platform/runtime.h>
1415
#include <flatbuffers/flatbuffers.h>
@@ -205,6 +206,81 @@ TEST(XNNCompilerTest, Bf16BatchMatrixMultiplyOutputWithoutDims) {
205206
backend->destroy(handle.get());
206207
}
207208

209+
TEST(XNNCompilerTest, RuntimeKeepsItsThreadpoolAcrossReset) {
210+
runtime::runtime_init();
211+
auto* backend = runtime::get_backend_class(xnnpack_backend_key);
212+
ASSERT_NE(backend, nullptr);
213+
auto* threadpool = extension::threadpool::get_threadpool();
214+
struct RestoreThreadCount {
215+
extension::threadpool::ThreadPool* threadpool;
216+
uint32_t threads;
217+
~RestoreThreadCount() {
218+
// @lint-ignore CLANGTIDY facebook-hte-Deprecated
219+
threadpool->_unsafe_reset_threadpool(threads);
220+
}
221+
} restore{threadpool, static_cast<uint32_t>(threadpool->get_thread_count())};
222+
// @lint-ignore CLANGTIDY facebook-hte-Deprecated
223+
ASSERT_TRUE(threadpool->_unsafe_reset_threadpool(2));
224+
225+
flatbuffers::FlatBufferBuilder builder;
226+
std::vector<flatbuffers::Offset<fb_xnnpack::XValue>> values;
227+
for (uint32_t id = 0; id < 3; ++id) {
228+
const auto tensor = fb_xnnpack::CreateXNNTensorValue(
229+
builder,
230+
fb_xnnpack::XNNDatatype::xnn_datatype_fp32,
231+
1,
232+
builder.CreateVector<uint32_t>({4}),
233+
0,
234+
id,
235+
id == 2 ? XNN_VALUE_FLAG_EXTERNAL_OUTPUT
236+
: XNN_VALUE_FLAG_EXTERNAL_INPUT,
237+
id);
238+
values.push_back(fb_xnnpack::CreateXValue(
239+
builder, fb_xnnpack::XValueUnion::XNNTensorValue, tensor.Union()));
240+
}
241+
const auto add = fb_xnnpack::Create_XNNNode2x1(builder, 0, 1, 2);
242+
const auto node = fb_xnnpack::CreateXNode(
243+
builder, fb_xnnpack::XNodeUnion::XNNAdd, add.Union());
244+
const auto graph = fb_xnnpack::CreateXNNGraph(
245+
builder,
246+
builder.CreateString("0"),
247+
builder.CreateVector(&node, 1),
248+
builder.CreateVector(values),
249+
3,
250+
builder.CreateVector<uint32_t>({0, 1}),
251+
builder.CreateVector<uint32_t>({2}));
252+
fb_xnnpack::FinishXNNGraphBuffer(builder, graph);
253+
254+
std::array<uint8_t, 4096> memory{};
255+
runtime::MemoryAllocator allocator(memory.size(), memory.data());
256+
runtime::BackendInitContext context(&allocator);
257+
runtime::FreeableBuffer processed(
258+
builder.GetBufferPointer(), builder.GetSize(), nullptr);
259+
auto handle = backend->init(context, &processed, {});
260+
ASSERT_TRUE(handle.ok());
261+
const std::weak_ptr<pthreadpool> loaded_with =
262+
extension::threadpool::get_shared_pthreadpool();
263+
264+
// @lint-ignore CLANGTIDY facebook-hte-Deprecated
265+
ASSERT_TRUE(threadpool->_unsafe_reset_threadpool(3));
266+
ASSERT_FALSE(loaded_with.expired());
267+
TensorFactory<executorch::aten::ScalarType::Float> tf;
268+
EValue a(tf.make({4}, {1, 2, 3, 4}));
269+
EValue b(tf.make({4}, {10, 20, 30, 40}));
270+
EValue out(tf.zeros({4}));
271+
std::array<EValue*, 3> args{&a, &b, &out};
272+
runtime::BackendExecutionContext execution_context;
273+
EXPECT_EQ(
274+
backend->execute(execution_context, handle.get(), {args.data(), 3}),
275+
Error::Ok);
276+
const float* sum = out.toTensor().const_data_ptr<float>();
277+
EXPECT_EQ(
278+
std::vector<float>(sum, sum + 4), (std::vector<float>{11, 22, 33, 44}));
279+
280+
backend->destroy(handle.get());
281+
EXPECT_TRUE(loaded_with.expired());
282+
}
283+
208284
} // namespace executorch::backends::xnnpack::delegate
209285

210286
TEST(XNNExecutorTest, ArgumentWithTooManyDimensions) {

‎backends/xnnpack/test/targets.bzl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def define_common_targets():
3232
"//executorch/runtime/core/exec_aten/util:scalar_type_util",
3333
"//executorch/backends/xnnpack:xnnpack_backend",
3434
"//executorch/backends/xnnpack/serialization:xnnpack_flatbuffer_header",
35+
"//executorch/extension/threadpool:threadpool",
3536
],
3637
)
3738

‎docs/source/using-executorch-faqs.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,12 @@ Ensure the model is delegated. If not targeting a specific accelerator, use the
4343

4444
Thread count can have a significant impact on CPU performance. The optimal thread count may depend on the model and application. By default, ExecuTorch uses one thread per performance core where it can tell core types apart, and one per logical core otherwise. On Linux other than Android, it also uses no more threads than the CPUs the process may run on, for example under `taskset` or in a container with a CPU set, and currently at most 63. It reads that limit once, from the thread that first uses the thread pool. CPU quotas, such as `docker --cpus`, are not detected. Consider setting the thread count to cores / 2, or just set to 4 on mobile CPUs.
4545

46-
Thread count can be set with the following function. Ensure this is done prior to loading or running a model.
46+
Thread count can be set with the following function. Call it before loading a model. Methods loaded earlier keep their XNNPACK work on the old thread count, and their `CpuBackend` work fails to run. To load such a method again, call `Module::unload_method()` before `load_method()`, or create a new `Module`. Changing the thread count while a method that uses `CpuBackend` is loading or running is not safe.
4747
```
4848
::executorch::extension::threadpool::get_threadpool()->_unsafe_reset_threadpool(num_threads);
4949
```
50-
This needs `extension/threadpool/threadpool.h`, which a source build provides. The pip
51-
package does not publish it, because the header holds a `pthreadpool` member by value and
52-
the wheel vendors no pthreadpool headers to compile it against.
50+
It is declared in `executorch/extension/threadpool/threadpool.h`. The pip package ships
51+
this header, so a C++ application built against the package can call it.
5352

5453
For a deeper investigation into model performance, ExecuTorch supports operator-level performance profiling. See [Using the ExecuTorch Developer Tools to Profile a Model](tutorials/devtools-integration-tutorial) <!-- @lint-ignore --> for more information.
5554

‎extension/pybindings/pybindings.pyi‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,16 @@ def _reset_profile_results() -> None:
462462

463463
@experimental("This API is experimental and subject to change without notice.")
464464
def _unsafe_reset_threadpool(num_threads: int) -> None:
465-
"""
465+
"""Sets the number of threads in the CPU thread pool.
466+
467+
Call it before loading methods. A method loaded earlier keeps its XNNPACK
468+
parts on the old thread pool until the method is destroyed, while its other
469+
kernels use the new count and its ``CpuBackend`` parts fail to run. A
470+
``Program`` from ``executorch.runtime`` and an ``ExecuTorchModule`` keep the
471+
methods they loaded, so load the method from a new one to use the new count.
472+
Calling it while a ``CpuBackend`` part is loading or running is not safe.
473+
A count of 0, or the current count, changes nothing.
474+
466475
.. warning::
467476
468477
This API is experimental and subject to change without notice.

‎extension/threadpool/test/threadpool_test.cpp‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@
1010
#include <executorch/extension/threadpool/threadpool.h>
1111
#include <executorch/runtime/platform/runtime.h>
1212

13+
#include <atomic>
1314
#include <mutex>
1415
#include <numeric>
1516
#include <random>
17+
#include <thread>
1618

1719
#include <executorch/extension/threadpool/threadpool_guard.h>
1820

1921
#include <gtest/gtest.h>
2022

2123
#if defined(__linux__) && !defined(__ANDROID__)
2224
#include <sched.h>
25+
#include <sys/wait.h>
26+
#include <unistd.h>
2327

2428
#include <cerrno>
2529
#include <cstdio>
@@ -131,6 +135,16 @@ bool may_use_more_than_one_cpu() {
131135
std::exit(EXIT_SUCCESS);
132136
}
133137
#endif
138+
139+
// Puts back the thread count a test changed, also when the test fails early.
140+
struct RestoreThreadCount {
141+
::executorch::extension::threadpool::ThreadPool* threadpool;
142+
uint32_t threads;
143+
~RestoreThreadCount() {
144+
// @lint-ignore CLANGTIDY facebook-hte-Deprecated
145+
threadpool->_unsafe_reset_threadpool(threads);
146+
}
147+
};
134148
} // namespace
135149

136150
TEST(ThreadPoolTest, ParallelAdd) {
@@ -268,6 +282,64 @@ TEST(TestNoThreadPoolGuard, TestRunWithGuard) {
268282
ASSERT_EQ(inner, 6);
269283
}
270284

285+
TEST(ThreadPoolTest, ResetKeepsSharedPthreadpoolUntilReleased) {
286+
executorch::runtime::runtime_init();
287+
auto* threadpool = ::executorch::extension::threadpool::get_threadpool();
288+
const RestoreThreadCount restore{
289+
threadpool, static_cast<uint32_t>(threadpool->get_thread_count())};
290+
// @lint-ignore CLANGTIDY facebook-hte-Deprecated
291+
ASSERT_TRUE(threadpool->_unsafe_reset_threadpool(2));
292+
auto held = ::executorch::extension::threadpool::get_shared_pthreadpool();
293+
ASSERT_EQ(held.get(), ::executorch::extension::threadpool::get_pthreadpool());
294+
EXPECT_EQ(held.use_count(), 2);
295+
const std::weak_ptr<pthreadpool> released = held;
296+
297+
// @lint-ignore CLANGTIDY facebook-hte-Deprecated
298+
ASSERT_TRUE(threadpool->_unsafe_reset_threadpool(3));
299+
EXPECT_NE(
300+
::executorch::extension::threadpool::get_shared_pthreadpool().get(),
301+
held.get());
302+
std::vector<uint8_t> ran(64);
303+
pthreadpool_parallelize_1d(
304+
held.get(),
305+
[](void* context, size_t item) {
306+
static_cast<uint8_t*>(context)[item] = 1;
307+
},
308+
ran.data(),
309+
ran.size(),
310+
0);
311+
EXPECT_EQ(ran, std::vector<uint8_t>(64, 1));
312+
313+
held.reset();
314+
EXPECT_TRUE(released.expired());
315+
}
316+
317+
TEST(ThreadPoolTest, PthreadpoolGettersDuringReset) {
318+
executorch::runtime::runtime_init();
319+
auto* threadpool = ::executorch::extension::threadpool::get_threadpool();
320+
const RestoreThreadCount restore{
321+
threadpool, static_cast<uint32_t>(threadpool->get_thread_count())};
322+
// Reads the pool on other threads, as delegate loads do, during resets.
323+
std::atomic<bool> done{false};
324+
std::thread shared_loader([&done] {
325+
while (!done) {
326+
::executorch::extension::threadpool::get_shared_pthreadpool();
327+
}
328+
});
329+
std::thread raw_loader([&done] {
330+
while (!done) {
331+
::executorch::extension::threadpool::get_pthreadpool();
332+
}
333+
});
334+
for (uint32_t i = 0; i < 100; ++i) {
335+
// @lint-ignore CLANGTIDY facebook-hte-Deprecated
336+
EXPECT_TRUE(threadpool->_unsafe_reset_threadpool(2 + i % 2));
337+
}
338+
done = true;
339+
shared_loader.join();
340+
raw_loader.join();
341+
}
342+
271343
#if defined(__linux__) && !defined(__ANDROID__)
272344
// gtest runs death tests before the other tests, so the forked child creates
273345
// the pool itself, and the CPU limit it sets does not reach the other tests.
@@ -288,4 +360,31 @@ TEST(ThreadPoolDeathTest, DefaultSizeHonorsCpuAffinity) {
288360
EXPECT_EXIT(
289361
print_counts_on_one_cpu(), ExitedWithCode(EXIT_SUCCESS), expected);
290362
}
363+
364+
TEST(ThreadPoolTest, ForkedChildReleasesInheritedSharedPthreadpool) {
365+
executorch::runtime::runtime_init();
366+
auto* threadpool = ::executorch::extension::threadpool::get_threadpool();
367+
const RestoreThreadCount restore{
368+
threadpool, static_cast<uint32_t>(threadpool->get_thread_count())};
369+
// Destroying an inherited pool crashes once its thread stacks overflow
370+
// glibc's 40 MiB stack cache; 32 threads do with stacks as small as 1 MiB.
371+
// @lint-ignore CLANGTIDY facebook-hte-Deprecated
372+
ASSERT_TRUE(threadpool->_unsafe_reset_threadpool(32));
373+
auto held = ::executorch::extension::threadpool::get_shared_pthreadpool();
374+
// @lint-ignore CLANGTIDY facebook-hte-Deprecated
375+
ASSERT_TRUE(threadpool->_unsafe_reset_threadpool(2));
376+
// The child holds the last reference to a threadpool whose threads stayed
377+
// in the parent. Not a death test, whose "threadsafe" style would start the
378+
// child over with a threadpool of its own.
379+
const pid_t child = fork();
380+
ASSERT_NE(child, -1);
381+
if (child == 0) {
382+
held.reset();
383+
std::_Exit(EXIT_SUCCESS);
384+
}
385+
int status = 0;
386+
ASSERT_EQ(waitpid(child, &status, 0), child);
387+
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == EXIT_SUCCESS)
388+
<< "wait status " << status;
389+
}
291390
#endif

0 commit comments

Comments
 (0)