Skip to content

Limit the default thread pool to the allowed CPUs on Linux (not Android) - #24395

Merged
shoumikhin merged 18 commits into
pytorch:mainfrom
shoumikhin:threadpool-zero-core-fallback
Nov 2, 2026
Merged

shoumikhin merged 18 commits into
pytorch:mainfrom
shoumikhin:threadpool-zero-core-fallback

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

On Linux, the default ExecuTorch thread pool can start one thread per CPU in the machine, even when the process may only use a few, as under taskset or in a container with a CPU set. The extra threads compete for those CPUs and made a small XNNPACK model 1.4x slower.

$ taskset -c 0-3 python -c "from executorch.extension.pybindings import portable_lib as m; print(m._threadpool_get_thread_count())"
168   # before, on an x86 machine with 168 CPUs
4     # after

Why it happens

The pool size comes from get_num_performant_cores() or, in Buck builds, the logical core count. Neither looks at the affinity mask. On x86 the first returns 0 (unknown), so with standard glibc pthreadpool_create(0) counts every online CPU and skips the 63-thread cap. On ARM servers with one core type, it counts every core.

What this change does

On Linux other than Android, the default pool never starts more threads than the CPUs in the affinity mask, and uses that number when the core count is unknown. get_num_performant_cores() applies the same limit, so runners that use it follow the mask too. In CMake builds, x86 Linux hosts with more than 63 allowed CPUs now get 63 threads by default, as ARM servers did, and the FAQ shows how to set the count in C++. Android and other systems keep their old sizing, because Android moves apps between CPU sets after the pool is sized.

The new get_num_allowed_cpus() counts the CPUs in the mask on Linux, even past 1024. The /sys probes now run only on ARM Linux. With an unknown core count, the Gemma 4 runner uses the default pool size, not hardware_concurrency(), still capped at 8. Reading ARM core IDs no longer throws, as it did on AmpereOne.

Tests

  • A new test limits a process to one CPU before the pool exists, and checks for one thread and, where known, one performance core. It runs on Linux other than Android and fails without this change.
  • ParallelAdd and ParallelReduce now pass when only one CPU is allowed.
  • Ran the threadpool tests on Linux x86_64 and aarch64 (ASAN and UBSAN on aarch64), and compiled the sources for macOS and iOS.
  • Pool size with taskset -c 0-3: 168 to 4 on x86, 14 to 4 on aarch64.
  • On a busy machine, that model took 70 instead of 98 ms with 4 CPUs. With no limit, an LLM-style prefill took 94 ms with 63 threads and 109 with 168, or 98 and 102 ms when less busy. No idle machine with over 63 CPUs was free.

get_num_performant_cores() reads two files that only ARM systems have:
the SoC image version under /sys/devices/soc0, to spot a phone model
with a known core count, and each CPU's MIDR register, to find small ARM
cores. On x86 and other CPUs neither file exists. Each call logs the
failed reads, including "Failed to open midr file", and returns 0,
meaning the count is unknown.

Skip both probes on those CPUs and return 0 directly. The result is the
same and those log lines are gone. ARM runs both probes as before.
…re count is unknown

On x86 Linux the default thread pool starts one thread per CPU in the
machine, even when the process may only run on a few of them, for
example under taskset or in a container with a CPU set. The extra
threads compete for the same CPUs and slow CPU inference down.

The pool size comes from get_num_performant_cores(), which returns 0
when it cannot tell fast cores from slow ones, as on x86. The 0 was
passed on to pthreadpool, which then counted every online CPU. That
count ignores the CPU affinity mask, and it also skips the 63-thread
cap applied here.

On Linux, when the count is unknown, use the number of CPUs in the
affinity mask of the thread that creates the pool, which the pool's
threads inherit, and then apply the cap. A machine with more than 63
CPUs and no limit now gets 63 threads instead of one per CPU. The
threadpool.h notes describe the fallback.

Nothing else changes. A known performance core count, which ARM usually
has, is used as before. Android keeps pthreadpool's count, because it
moves apps between CPU sets as they go to the background and back,
while the pool is sized only once. Other systems keep pthreadpool's
count as well. It is the right one there: on Emscripten, for example,
pthreadpool can only make a pool of one thread.

The new test sizes the pool in a fresh process limited to one CPU and
expects one thread. It fails without this change.
@shoumikhin shoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Nov 1, 2026
Copilot AI balanced review requested due to automatic review settings November 1, 2026 01:09
@pytorch-bot

pytorch-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24395

Note: Links to docs will display an error until the docs builds have been completed.

❌ You can merge normally! (6 Unrelated Failures), 2 Unclassified Failures

As of commit 180db83 with merge base 05092c1 (image):

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

BROKEN TRUNK - The following jobs failed but were present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 1, 2026

Copilot AI left a comment

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.

🟡 Changes recommended

The regression test can falsely pass for incorrect pool sizes because Linux truncates exit statuses to eight bits, so the child must compare the count with 1 and return distinct success/failure codes.

1 open issue
What changed in this PR

This PR makes ExecuTorch’s default thread pool respect Linux CPU affinity when the performance-core count is unknown, avoiding excess threads in CPU-restricted environments.

Changes:

  • Uses the allowed CPU count as the fallback on non-Android Linux, then applies the existing 63-thread cap.
  • Skips ARM-only core detection on other architectures.
  • Documents the fallback and adds a single-CPU affinity regression test.
File Description
extension/​threadpool/​threadpool.h Documents the Linux affinity fallback.
extension/​threadpool/​threadpool.cpp Adds affinity-based default pool sizing.
extension/​threadpool/​test/​threadpool_test.cpp Tests default sizing in a process restricted to one CPU.
extension/​threadpool/​cpuinfo_utils.cpp Guards ARM-specific core detection by architecture.

Review effort: Balanced

Comment on lines +216 to +238
// The pool is created once per process, so it is checked in a fresh one whose
// exit code is the pool's thread count. Failing to limit the CPUs aborts, so
// it cannot pass for a pool size.
TEST(ThreadPoolDeathTest, DefaultSizeHonorsCpuAffinity) {
executorch::runtime::runtime_init();

if (::executorch::extension::cpuinfo::get_num_performant_cores() != 0) {
GTEST_SKIP() << "The performance core count sizes the pool here.";
}
::testing::GTEST_FLAG(death_test_style) = "threadsafe";
EXPECT_EXIT(
{
cpu_set_t cpus;
CPU_ZERO(&cpus);
CPU_SET(sched_getcpu(), &cpus);
if (sched_setaffinity(0, sizeof(cpus), &cpus) != 0) {
std::abort();
}
std::exit(static_cast<int>(
::executorch::extension::threadpool::get_threadpool()
->get_thread_count()));
},
ExitedWithCode(1),

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.

Thanks, good point. This is fixed in a later commit. The child process no longer returns the thread count as its exit status. It prints the pool size and the performance core count, exits with success, and the test matches the exact text "pool 1 ...". A pool of 257 threads now fails the test.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 19m 48s —— View job


Needs Discussion: the affinity fallback only runs when the core count is 0, so a known count still ignores taskset and cpusets: on homogeneous aarch64 Linux servers and in every Buck build (which uses ALL_LOGICAL_CORES). The other findings are minor.

Detailed review

PR Review: #24395

Summary

On non-Android Linux, the PR sizes the default pool by the affinity mask when get_num_performant_cores() returns 0, and it stops running the ARM-only sysfs probes on other CPUs.

The main problem is scope. A known count is never clamped by the mask, so the oversubscription fixed here for x86 CMake builds is still there on aarch64 Linux servers and in Buck builds.

The minor findings:

  • hosts with more than 1024 possible CPUs fall back to the old behavior
  • one example runner overrides the new default
  • two existing tests now fail on one CPU
  • the docs describe the fallback inaccurately

The cpuinfo and pthreadpool submodules aren't checked out in this run, so statements about how they behave come from their known behavior, not from reading their source.

Code Quality

  • extension/threadpool/cpuinfo_utils.cpp:63, 150, 200: the new guards check the CPU type, but the files they read exist only on Linux. On macOS and iOS arm64 and on Windows on ARM, _get_model_specific_num_cores() still runs on every call and logs "Reading file /sys/devices/soc0/image_version" and "Failed to open midr file …". That is the same noise this commit removes on other CPUs. Adding defined(__linux__) && to the guards still covers Android, and the result elsewhere stays 0. Fix this →

Testing

  • extension/threadpool/test/threadpool_test.cpp:214-215, 222: the new test only runs in x86 Linux CMake builds.
    • Its guard requires EXECUTORCH_THREADPOOL_USE_PERFORMANCE_CORES, which only CMake defines (extension/threadpool/CMakeLists.txt:26-31). Buck's threadpool_lib exports only -DET_USE_THREADPOOL (targets.bzl:36-38), so Buck compiles the test out.
    • It skips wherever the core count is known, which includes aarch64.
    • If known counts are clamped as suggested under Performance, the skip, the cpuinfo_utils.h include and the macro guard can all go.
    • If the guard is dropped without that change, test/targets.bzl:17-19 also needs //executorch/extension/threadpool:cpuinfo_utils, because that target is only a private dependency of threadpool_lib. Low confidence: this depends on Buck header visibility, and it wasn't built here.
  • extension/threadpool/test/threadpool_test.cpp:100, 145: ParallelAdd and ParallelReduce now fail when only one CPU is allowed. Both still call EXPECT_GT(get_thread_count(), 1). Before this change they passed under taskset -c 0 ctest, docker --cpuset-cpus=0, or a single-CPU CI container; now they fail there. The PR description mentions this, but the tests don't check for it. Add a GTEST_SKIP() when the pool has fewer than two threads. Fix this →

API Design

  • extension/threadpool/threadpool.h:29-30 (and threadpool.cpp:130-133): "the number of CPUs this process may run on" doesn't describe what the code reads.

    • sched_getaffinity(0, …) returns the calling thread's mask. It is read once, by whichever thread first calls get_threadpool(), and never again. The comment's reason for leaving out Android (cpusets change after the pool is sized) partly applies to Linux too, for example when the Kubernetes static CPU manager resizes the shared pool.
    • CPU quotas (docker --cpus, Kubernetes limits.cpu) don't change the mask. This is the most common container limit, and the pool still gets min(online, 63) threads under it.
    • Accurate wording would be something like "the size of the affinity mask (taskset, cpusets; not CPU quotas) of the thread that first calls get_threadpool()".
    • docs/source/using-executorch-faqs.md:44 also still says the default is one thread per core.

    Fix this →

Performance

  • extension/threadpool/threadpool.cpp:165-167: a known core count is never clamped by the affinity mask. The fallback only runs when result == 0, which leaves out two common cases:

    • Homogeneous aarch64 Linux servers (Graviton, Ampere, Grace). There uarch_count == 1, so _get_num_performant_cores() (cpuinfo_utils.cpp:118-143) returns every core, because cpuinfo counts processors from sysfs rather than from the mask. Under taskset -c 0-3 on a 64-core host the pool still gets 63 threads for 4 CPUs, which is the slowdown this PR fixes on x86. The description confirms that the aarch64 pool size is unchanged.
    • Every Buck build. threadpool_lib exports no size flag (targets.bzl:36-38), so threadpool.cpp:35-39 picks ALL_LOGICAL_CORES, where cpuinfo_get_processors_count() is never 0. The new fallback never runs there.

    The only reason the PR gives for not clamping is Android, and get_num_allowed_cpus() already returns 0 there. Suggested change:

    const auto allowed = get_num_allowed_cpus();
    if (allowed > 0) {
      result = result == 0 ? allowed : std::min(result, allowed);
    }

    If this goes where the current check is, it also clamps ALL_LOGICAL_CORES and changes the pool size for Buck users. That should be an explicit decision. The example runners call _unsafe_reset_threadpool(get_num_performant_cores()) directly (for example examples/models/llama/main.cpp:160-163), so they would bypass a clamp that lives only in get_threadpool(). Fix this →

  • extension/threadpool/threadpool.cpp:136-137: on hosts with more than 1024 possible CPUs, the pool silently goes back to the old uncapped size.

    • A fixed-size cpu_set_t holds 1024 CPUs, and sched_getaffinity fails with EINVAL when the kernel's nr_cpu_ids is larger. get_num_allowed_cpus() then returns 0, and the pool gets every online CPU with no cap, on exactly the largest hosts.
    • Fix: use a CPU_ALLOC mask that grows on EINVAL, and count it with CPU_COUNT_S.
    • Separately, on other platforms (for example Intel macOS, Windows x86, Android x86) the count can still be 0, so they still skip the cap that the comment at lines 169-176 calls unconditional. Using cpuinfo_get_processors_count() when the count is still 0 would apply the cap there too.

    Fix this →

  • examples/models/gemma4/e2e_runner.cpp:59-61: the gemma4 runner overrides the new default. When the core count is 0, the runner resets the pool to std::min(std::thread::hardware_concurrency(), 8u). With glibc, hardware_concurrency() counts every online CPU, so under taskset -c 0-3 on a large x86 host it runs 8 threads on 4 CPUs. Capping get_threadpool()->get_thread_count() at 8 instead keeps the runner's limit and respects the mask. Fix this →

  • extension/threadpool/threadpool.cpp:177-178: the effect of the new cap on large, unrestricted x86 hosts isn't measured. With more than 63 allowed CPUs, the pool now drops to 63 threads (168 → 63 in the description), but the timings only cover 4 and 16 CPUs. Please add a measurement of the unrestricted case on a large model, or mention the drop in the release notes.

Recommendation

Needs Discussion. There are no blockers, but the scope should be settled before merging: should known counts also be clamped by the mask, including under ALL_LOGICAL_CORES (and so every Buck build)? That change is a few lines, and it would also let the new test run on aarch64 and in Buck. The other findings are minor.

A cpu_set_t holds 1024 CPUs. On a host whose kernel allows more,
sched_getaffinity() rejects it with EINVAL, so the allowed-CPU count
was 0 and the pool fell back to pthreadpool's count, ignoring both the
mask and the 63-thread cap.

Allocate the mask with CPU_ALLOC and double its size until the kernel
accepts it.
Until now only an unknown core count was replaced by the number of CPUs
in the affinity mask. A known count was used as is, so homogeneous ARM
servers, and builds that size the pool by all logical cores (the Buck
default), still started one thread per CPU in the machine under taskset
or a CPU set.

On Linux other than Android, take the smaller of the core count and the
number of CPUs in the mask, and use the mask alone when the count is
unknown. When the process may run on every online CPU, a known count
stays as it was, so big.LITTLE systems keep their performance core
count.

The new test now runs in every Linux build, not only in x86 CMake ones.
ParallelAdd and ParallelReduce expect more than one thread only when the
process may use more than one CPU, so they pass under a one-CPU limit.
The threadpool.h notes and the FAQ now say what the pool reads.
The soc0 image version and the MIDR registers are read from /sys, which
only Linux has. On macOS and iOS on ARM, and on Windows on ARM, the
probes still ran, failed, logged the failure, and could only return 0.

Run them only on ARM Linux, which still includes Android. The core
counts on the other systems do not change.
…s unknown

When get_num_performant_cores() returns 0, the runner reset the pool to
std::min(std::thread::hardware_concurrency(), 8). hardware_concurrency()
counts every online CPU, so under taskset the runner started more
threads than the process may use, undoing the default pool's limit.

Cap the default pool's own size at 8 instead. It keeps the runner's
limit and follows the CPU affinity mask.
The default pool now stays within the CPU affinity mask, but the example
runners and other callers size the pool themselves with
get_num_performant_cores(), which ignored the mask. On ARM Linux they
still started one thread per core under taskset or a CPU set.

Move the allowed-CPU count into cpuinfo_utils as
get_num_allowed_cpus(), next to get_num_performant_cores(), and have
the latter return the smaller of its count and the allowed CPUs on
Linux other than Android. An unknown count (0) stays 0, so callers keep
falling back to the default pool. When the process may run on every
online CPU, the count is unchanged.

The affinity test now also checks get_num_performant_cores() and limits
itself to its current CPU with a mask sized for any CPU index.
ParallelAdd and ParallelReduce read the allowed CPUs from
get_num_allowed_cpus(), which handles hosts with more than 1024 CPUs.
The threadpool.h notes now say that the mask is read once, so the pool
does not follow later changes to it, and that a child created by fork()
keeps its parent's size. The FAQ now says that the default is one
thread per performance core where core types are known and one per
logical core otherwise, limited on Linux by the allowed CPUs and 63,
and that CPU quotas are not detected.
…where it sizes threads

get_num_allowed_cpus() returned 0 on Android to keep thread counts there
unchanged, which mixed a sizing rule into a query whose name promises
the CPU count. It now returns the count in the affinity mask on every
Linux system, Android included, and 0 only where the mask is not
available. Its comment says why the mask is a poor size for a pool on
Android. get_num_performant_cores() and the default pool apply the
limit everywhere except Android. Thread counts are the same as before.

The get_num_performant_cores() comment now also lists the other cases
that return 0, and the Gemma 4 runner comment says the default pool
follows the mask on Linux other than Android only.
The death test now prints the pool size and the performance core count,
and matches them exactly, so a failure shows which one was wrong, and a
pool that failed to start cannot pass. It also uses gtest's default
fork style: the threadsafe style runs the child on a small clone() stack
that ASAN overflows now and then. The fork style works here because
gtest runs death tests before the other tests, so the forked child
creates the pool itself.

ParallelAdd and ParallelReduce count the allowed CPUs with
sched_getaffinity() themselves instead of asking the code under test.
Under --gtest_repeat, later runs of the death test fork after other tests
created the pool, and the child keeps that pool's size, so the check
fails although the code is right. Only the first run checks the pool
now. The threadsafe death test style would start a fresh process
instead, but it runs gtest's child on a small clone() stack that ASAN
overflows now and then.

ParallelAdd and ParallelReduce also keep checking for more than one
thread when the affinity mask cannot be read, as on other systems.
…d that the mask is read once

The comment in get_threadpool() now says the pool follows the affinity
mask of the thread that creates it, which the pool's threads inherit.
The get_num_performant_cores() comment says the count is capped at the
allowed CPUs without checking which cores the mask allows, and the FAQ
says the limit is read once, from the thread that first uses the thread
pool.
…d cap as current in the FAQ

The death test accepted a performance core count of 0 or 1 in the child,
so on ARM a limit that wrongly dropped the count to 0 still passed. The
parent now works out which one to expect: 1 when the count is known, 0
when it is not.

The FAQ now says the default is "currently" at most 63 threads, because
that cap comes from a sanitizer limit the code may drop later.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 12:38
@shoumikhin shoumikhin changed the title Size the default thread pool by the allowed CPUs on Linux when the core count is unknown Limit the default thread pool to the allowed CPUs on Linux (not Android) Nov 1, 2026

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks, this is updated:

  • Known core counts are now limited by the affinity mask too, on Linux other than Android and in both sizing modes. get_num_performant_cores() applies the same limit, so the example runners that size the pool with it follow taskset and CPU sets. When every CPU is online and allowed, the count is unchanged. Android keeps its old sizing, because it moves apps between CPU sets as they go to the background and back, after the pool is sized.
  • The test now runs in every Linux build other than Android and also checks get_num_performant_cores(). ParallelAdd and ParallelReduce expect more than one thread only when more than one CPU is allowed. On Linux, get_num_allowed_cpus() reads the mask on machines of any size. The /sys probes run only on ARM Linux. When the core count is unknown, the Gemma 4 runner caps the default pool size at 8 instead of using hardware_concurrency(). threadpool.h now says which mask is read and when, and the comment in threadpool.cpp names the same mask. The FAQ now describes the default thread count. Both the FAQ and threadpool.h say that CPU quotas such as docker --cpus are not detected.
  • The 63-thread cap now also applies on x86 Linux. With no CPU limit on a busy 168-CPU machine, 63 threads were faster than 168: 36 against 54 ms for a small XNNPACK model, and 94 against 110 ms for an LLM-style prefill (two transformer blocks, width 2048, 256 tokens). Other systems still pass 0 when the core count is unknown, to keep this change to Linux. On Emscripten, a larger count would also make pthreadpool's shim, which only accepts 0 or 1 threads, return no pool.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 21m 37s —— View job


Approve: two minor items remain. The 63-thread cap on unrestricted x86 Linux hosts was only measured on a busy machine, and the Android exception is written twice under guards that don't mention Linux.

Detailed review

PR Review: #24395

Summary

On Linux other than Android, the PR limits the default thread pool and get_num_performant_cores() to the number of CPUs in the affinity mask, and uses that number when the core count is unknown. Neither finding below blocks merging. Nothing was built or run for this review.

Code Quality

  • extension/threadpool/threadpool.cpp:149 and extension/threadpool/cpuinfo_utils.cpp:241: the Android exception is written out twice, and neither guard mentions Linux.

    • Both blocks use #if !defined(__ANDROID__). Their comments, threadpool.h and the test all say "Linux other than Android", and the test's guards are defined(__linux__) && !defined(__ANDROID__) (threadpool_test.cpp:21, 83, 91, 257).
    • Off Linux, both blocks still compile. They do nothing only because get_num_allowed_cpus() returns 0 there.
    • The reason for excluding Android is given three times: above both blocks and in the get_num_allowed_cpus() comment (cpuinfo_utils.h:26-27).
    • Use the test's guard in both places, and keep the Android explanation only in cpuinfo_utils.h.

    Fix this →

Performance

  • extension/threadpool/threadpool.cpp:153: the 63-thread cap on unrestricted x86 Linux hosts was measured only on a busy shared machine.
    • With no CPU limit, the unknown count (0) now becomes the size of the mask and then hits the cap at line 166.
    • This affects CMake builds, including the wheel. Their default is EXECUTORCH_THREADPOOL_USE_PERFORMANCE_CORES (extension/threadpool/CMakeLists.txt:26-31). On a 192-vCPU host, the default drops from 192 threads to 63.
    • On a busy machine, other load competes with the pool's threads, and that favors fewer threads. On an idle dedicated host, a compute-bound prefill may run faster with all 192.
    • Please add one measurement from an idle host. If that isn't possible, say in the release note that hosts with more than 63 CPUs now get 63 threads by default, and point to the FAQ's instructions for setting the count.

Recommendation

Approve. Neither finding blocks merging. Both can be handled here or in a follow-up.

Both places that apply the affinity limit now use the same guard as the
test, Linux other than Android, instead of only excluding Android and
relying on get_num_allowed_cpus() returning 0 elsewhere. The reason for
leaving Android out is now given only in the get_num_allowed_cpus()
comment, and <algorithm> moved to the Linux-only includes it serves.
populate_available_cpu_mids() parsed each midr_el1 file with std::stoi.
A MIDR whose implementer byte is 0x80 or higher, such as AmpereOne
(0xC0), is above INT_MAX, so std::stoi threw std::out_of_range and the
process ended the first time the thread pool or a runner asked for the
performance core count. std::stoul reads the full value.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 17:08

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 22m 40s —— View job


Approve: two minor items remain. On Linux the pool now stays small for the rest of the process if the CPU set grows after the pool is first used, and a test helper still can't read the mask on hosts with more than 1024 possible CPUs.

Detailed review

PR Review: #24395

Summary

On Linux other than Android, the PR limits the default thread pool and get_num_performant_cores() to the CPUs in the affinity mask. When the core count is unknown, it uses the mask's CPU count instead. There are two minor findings and no blockers.

What this review could not check:

  • Nothing was built or run.
  • The cpuinfo and pthreadpool submodules aren't checked out here.
  • The Buck-only fb/threadpool_use_n_threads_test.cpp is built into the same test target but isn't in this checkout.

Testing

  • extension/threadpool/test/threadpool_test.cpp:84-85: may_use_more_than_one_cpu() still reads the mask into a fixed 1024-CPU cpu_set_t.

    • get_num_allowed_cpus() (cpuinfo_utils.cpp:217-237) and print_counts_on_one_cpu() (line 100) both use CPU_ALLOC instead.
    • When the kernel has more than 1024 possible CPUs, sched_getaffinity fails with EINVAL and the helper returns true. ParallelAdd and ParallelReduce then expect more than one thread, even though the pool correctly has one.
    • Trigger: running the tests under a one-CPU mask on such a host.
    • Fix: return false when the call fails (== 0 && instead of != 0 ||). The check is then skipped there instead of failing, and the count stays independent of the code under test.

    Fix this →

Performance

  • extension/threadpool/threadpool.cpp:143-153: on Linux, if the CPU set grows after the pool is first used, the pool stays at its smaller size for the rest of the process.

    • Before, the pool was sized by the machine's core count. When a cgroup cpuset grows, the kernel widens every thread's mask, so the existing workers could spread onto the new CPUs. Now the size is fixed when the pool is first created (static const int num_threads, line 135).
    • Triggers:
      • Containers in the shared pool of the Kubernetes static CPU manager. Kubelet shrinks and regrows their cpuset as pods with exclusive CPUs start and stop.
      • docker update --cpuset-cpus, or systemctl set-property … AllowedCPUs=, on a running process.
      • Possibly intel_lpmd on idle Intel laptops (low confidence, not verified here).
    • This is the reason given for leaving Android out (cpuinfo_utils.h:26-27), and the PR doesn't say why Linux is different.
    • threadpool.h:34-39 and the FAQ say the mask is read once. They don't say that the pool can end up smaller than the CPUs available later, or what to do about it.
    • The FAQ only shows how to set the count from C++, and says the pip package doesn't include the header it needs. Python users can call portable_lib._unsafe_reset_threadpool(n).
    • Please say why Linux is treated differently, for example that a CPU set that later grows is rarer than one that stays small. Also tell users whose CPU set can grow to set the count explicitly.

    Fix this →

Recommendation

Approve. Both findings are minor, and either can be handled here or in a follow-up.

…4 CPUs

may_use_more_than_one_cpu() read the mask into a fixed cpu_set_t, which
the kernel rejects when it has more possible CPUs than the set holds. It
then assumed more than one CPU, so ParallelAdd and ParallelReduce failed
on such a host under a one-CPU mask. It now grows the mask the same way
get_num_allowed_cpus() does.

threadpool.h also says what to do when the mask grows after the pool is
created: call _unsafe_reset_threadpool().
…grow

The previous note told users to call _unsafe_reset_threadpool() after the
mask grows. Loaded XNNPACK delegates keep the pthreadpool they were
loaded with, so resetting the pool at that point leaves them with a freed
pool. The note now says to set the needed count before loading a model,
which matches the FAQ.
std::stoul still threw std::invalid_argument when a midr_el1 file opened
but read back empty or not as hex. std::strtoul with an end check treats
that the same as a file that does not open: the probe gives up and the
performance core count stays unknown. ExecuTorch does not use
exceptions.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 18:58

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 23m 32s —— View job


Approve: one low-confidence item remains. The new death test calls EXPECT_EXIT directly, so unlike tests that use ET_EXPECT_DEATH, it is not skipped in @mode/dbgo-cov builds.

Detailed review

PR Review: #24395

Summary

On Linux other than Android, the PR caps the default thread pool and get_num_performant_cores() at the number of CPUs in the affinity mask, and uses that number when the core count is unknown. One low-confidence item remains, and nothing blocks merging.

What this review could not check:

  • Nothing was built or run, and the cpuinfo and pthreadpool submodules aren't checked out.
  • The Buck-only fb/threadpool_use_n_threads_test.cpp is built into the same test binary but isn't in this checkout. If it creates the pool before the new death test runs, for example from a static initializer, the death test sees that pool's size.

Infrastructure

  • extension/threadpool/test/threadpool_test.cpp:288: the new death test isn't skipped in coverage builds (low confidence).

    • test/utils/DeathTest.h:25-34 turns ET_EXPECT_DEATH into a no-op when ET_BUILD_MODE_COV is set. This works around death tests segfaulting under @mode/dbgo-cov (T124640221). 38 test files use the wrapper, but this test calls EXPECT_EXIT directly.
    • The wrapper only fits tests that expect the process to die, so this test needs its own guard. For example, add !ET_BUILD_MODE_COV to its #if at line 271.
    • Why confidence is low: nothing in this checkout sets ET_BUILD_MODE_COV, and five other test files call EXPECT_DEATH directly (for example runtime/core/test/device_allocator_test.cpp). Whether this matters depends on whether internal coverage runs still hit T124640221.

    Fix this →

Recommendation

Approve. The only finding is low confidence, and one internal coverage run would settle it.

@shoumikhin
shoumikhin merged commit e68a824 into pytorch:main Nov 2, 2026
252 of 262 checks passed
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@pytorchbot cherry-pick --onto release/1.6 -c critical

@pytorchbot

Copy link
Copy Markdown
Collaborator

Cherry picking #24395

The cherry pick PR is at #24449 and it is recommended to link a critical cherry pick PR with an issue.

Details for Dev Infra team Raised by workflow job

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants