Skip to content

Commit 4f54a94

Browse files
excelle08meta-codesync[bot]
authored andcommitted
Cap PyTorch thread pools to avoid nproc^2 GlobalCPUThread explosion (#716)
Summary: Pull Request resolved: #716 Two layers needed to prevent libtorch's thread-pool explosion under concurrent forward() invocations: 1. dlrm.cpp: at::set_num_interop_threads(1) called in the DLRM constructor BEFORE loadModel + warmup. Caps libtorch's native parallel backend pool. 2. run.sh: OMP_NUM_THREADS=1 added to the leaf launch env. Caps libtorch's OpenMP parallel backend pool — our internal libtorch build uses OpenMP for tensor ops, which at::set_num_interop_threads does NOT cover. Also: per-thread JIT Module clone in dlrm.cpp. The shared pimpl_->model.forward() was racing under concurrent invocation from multiple GlobalCPUThread workers, producing SIGSEGV in je_large_dalloc → torch::autograd::autogradNotImplementedFallbackImpl → at::arange → JIT interpreter. Each ThreadState now owns a deep clone (via Module::clone()), so concurrent forward() touches disjoint interpreter state. Without these three fixes, on BGM (176 logical cores) the leaf process accumulated 30,976 = nproc^2 threads named "GlobalCPUThread" (folly NamedThreadFactory pool name, comm-truncated to 15 chars), all stuck in __futex_wait, eventually triggering kernel-scheduler thrash and the cascading deadlock that pinned the driver's inflight count at the connection cap (sent_qps=0 forever). t15 measured per-instance thread count drop: 30,976 → 88. t17 confirmed zero SIGSEGV across 16 iters at qps=80 (vs 6/16 crashes without the Module clone). Multi-iter qps=80 stability went from 1/4 balanced to 2/4 (rtptest3440 s=0.025) and 4/4 (rtptest3424 s=0.10). Reviewed By: YifanYuan3 Differential Revision: D105659812
1 parent 06207d0 commit 4f54a94

2 files changed

Lines changed: 69 additions & 11 deletions

File tree

  • packages/feedsim

packages/feedsim/run.sh

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,8 +711,15 @@ main() {
711711
echo "RPC fanout: forced OFF via LEAFNODE_USE_LEGACY_SLEEP=1 (legacy sleep path)"
712712
fi
713713

714+
# OMP_NUM_THREADS=1: cap PyTorch's OpenMP parallel backend pool to
715+
# 1 thread. at::set_num_threads(1) only affects libtorch's native
716+
# parallel backend; OpenMP-backed builds (which Meta's internal
717+
# libtorch may use) read OMP_NUM_THREADS directly. Without this,
718+
# each ThriftSrv.IO worker calling forward() spawns nproc OMP
719+
# threads, accumulating to nproc^2 GlobalCPUThread-named threads
720+
# (= 7,744 on BGM per-instance after taskset). See t14 progress log.
714721
# shellcheck disable=SC2086
715-
env $preload_env MALLOC_CONF=narenas:20,dirty_decay_ms:5000 build/workloads/ranking/LeafNodeRank \
722+
env $preload_env OMP_NUM_THREADS=1 MALLOC_CONF=narenas:20,dirty_decay_ms:5000 build/workloads/ranking/LeafNodeRank \
716723
--port="$port" \
717724
--monitor_port="$monitor_port" \
718725
--graph_scale=21 \

packages/feedsim/third_party/src/workloads/ranking/dwarfs/dlrm.cpp

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,21 @@ namespace dwarfs {
2828
struct DLRM::Impl {
2929
torch::jit::script::Module model;
3030

31-
// Per-thread state for synthetic feature generation
31+
// Per-thread state for synthetic feature generation + an isolated
32+
// JIT Module clone. torch::jit::Module::forward() is NOT thread-safe
33+
// when called concurrently on the same Module instance — the JIT
34+
// interpreter mutates internal state (e.g. interpreter stack,
35+
// intermediate tensors) and concurrent invocations race on the
36+
// backing allocator, producing SIGSEGV in je_large_dalloc. Each
37+
// worker thread owns a deep-cloned Module via model_copy, so
38+
// concurrent forward() calls touch disjoint state. See t16
39+
// SIGSEGV crash (2026-05-18) for the original failure.
3240
struct ThreadState {
3341
std::mt19937 rng;
3442
std::normal_distribution<float> dense_dist{0.0f, 1.0f};
3543
std::vector<float> dense_buffer;
3644
std::vector<int64_t> sparse_buffer;
45+
std::unique_ptr<torch::jit::script::Module> model_copy;
3746
};
3847
std::vector<std::unique_ptr<ThreadState>> thread_states;
3948

@@ -51,7 +60,8 @@ struct DLRM::Impl {
5160
unsigned seed,
5261
int batch_size,
5362
int num_dense_features,
54-
int num_sparse_features) {
63+
int num_sparse_features,
64+
bool clone_model) {
5565
thread_states.resize(num_threads);
5666

5767
std::lock_guard<std::mutex> lock(thread_id_lifo_mutex);
@@ -76,6 +86,15 @@ struct DLRM::Impl {
7686
state->dense_buffer.resize(batch_size * num_dense_features);
7787
state->sparse_buffer.resize(batch_size * num_sparse_features);
7888

89+
// Deep-clone the JIT Module so this thread's forward() touches
90+
// disjoint interpreter state. clone() copies submodules + tensors;
91+
// copy() would share them and re-introduce the race.
92+
// Skipped when the parent DLRM has no model loaded (test paths).
93+
if (clone_model) {
94+
state->model_copy = std::make_unique<torch::jit::script::Module>(
95+
model.clone());
96+
}
97+
7998
thread_states[i] = std::move(state);
8099
thread_id_lifo.push_back(i);
81100
}
@@ -152,6 +171,20 @@ DLRM::DLRM(const DLRMParams& params, int num_thread_instances, unsigned seed)
152171
// Set number of inference threads
153172
at::set_num_threads(params_.num_threads);
154173

174+
// Cap inter-op thread pool to 1. Without this, PyTorch's inter-op pool
175+
// defaults to available_concurrency() (= nproc on a leaf process). At
176+
// model warmup + on first forward() from each ThriftSrv.IO worker,
177+
// libtorch lazily spawns nproc inter-op threads — which adds up to
178+
// nproc^2 GlobalCPUThread-named threads (= 30,976 on BGM with 176
179+
// logical cores), all stuck in __futex_wait, eventually deadlocking
180+
// the kernel scheduler. See progress log 2026-05-15 (t12/t13).
181+
//
182+
// libtorch treats set_num_interop_threads as call-once-per-process:
183+
// subsequent invocations throw. Guard so a second DLRM construction
184+
// (unit tests, re-init) doesn't crash.
185+
static std::once_flag interop_threads_once;
186+
std::call_once(interop_threads_once, [] { at::set_num_interop_threads(1); });
187+
155188
// Enable JIT optimizations
156189
torch::jit::setGraphExecutorOptimize(true);
157190

@@ -169,13 +202,16 @@ DLRM::DLRM(const DLRMParams& params, int num_thread_instances, unsigned seed)
169202
}
170203
}
171204

172-
// Initialize per-thread state
205+
// Initialize per-thread state. Deep-clones the JIT Module per thread
206+
// when model is loaded, to avoid concurrent forward() race on shared
207+
// interpreter state (see ThreadState comment + t16 SIGSEGV).
173208
pimpl_->initializeThreadState(
174209
num_thread_instances,
175210
seed,
176211
params_.batch_size,
177212
params_.num_dense_features,
178-
params_.num_sparse_features);
213+
params_.num_sparse_features,
214+
model_loaded_);
179215

180216
// Warmup
181217
if (model_loaded_) {
@@ -191,12 +227,13 @@ int DLRM::infer(int num_inferences, int batch_size) {
191227
}
192228

193229
int thread_id = pimpl_->get_avail_thread_id();
194-
SCOPE_EXIT { pimpl_->put_avail_thread_id(thread_id); };
195-
230+
// Validate before arming SCOPE_EXIT so a bogus id doesn't get pushed back
231+
// into thread_id_lifo (would silently corrupt the free-list).
196232
if (thread_id < 0 ||
197233
thread_id >= static_cast<int>(pimpl_->thread_states.size())) {
198234
throw std::out_of_range("Invalid thread_id: " + std::to_string(thread_id));
199235
}
236+
SCOPE_EXIT { pimpl_->put_avail_thread_id(thread_id); };
200237

201238
int total_predictions = 0;
202239

@@ -210,13 +247,15 @@ int DLRM::infer(int num_inferences, int batch_size) {
210247
params_.num_sparse_features,
211248
params_.embedding_table_sizes);
212249

213-
// Run inference
250+
// Run inference on the per-thread Module clone (not the shared
251+
// pimpl_->model) to avoid concurrent forward() race.
214252
std::vector<torch::jit::IValue> inputs;
215253
inputs.push_back(dense_tensor);
216254
inputs.push_back(sparse_tensor);
217255

218256
torch::NoGradGuard no_grad;
219-
auto output = pimpl_->model.forward(inputs).toTensor();
257+
auto& thread_model = *pimpl_->thread_states[thread_id]->model_copy;
258+
auto output = thread_model.forward(inputs).toTensor();
220259

221260
// Count predictions (simulating actual work with the output)
222261
total_predictions += output.numel();
@@ -234,6 +273,17 @@ int DLRM::inferWithFeatures(
234273
throw std::runtime_error("Model not loaded");
235274
}
236275

276+
// Acquire a thread_id so we can use this thread's Module clone. Same
277+
// race avoidance as DLRM::infer.
278+
int thread_id = pimpl_->get_avail_thread_id();
279+
// Validate before arming SCOPE_EXIT so a bogus id doesn't get pushed back
280+
// into thread_id_lifo (would silently corrupt the free-list).
281+
if (thread_id < 0 ||
282+
thread_id >= static_cast<int>(pimpl_->thread_states.size())) {
283+
throw std::out_of_range("Invalid thread_id: " + std::to_string(thread_id));
284+
}
285+
SCOPE_EXIT { pimpl_->put_avail_thread_id(thread_id); };
286+
237287
int total_predictions = 0;
238288

239289
for (int i = 0; i < num_inferences; ++i) {
@@ -249,13 +299,14 @@ int DLRM::inferWithFeatures(
249299
{static_cast<int64_t>(batch_size), params_.num_sparse_features},
250300
torch::kInt64);
251301

252-
// Run inference
302+
// Run inference on this thread's Module clone.
253303
std::vector<torch::jit::IValue> inputs;
254304
inputs.push_back(dense_tensor);
255305
inputs.push_back(sparse_tensor);
256306

257307
torch::NoGradGuard no_grad;
258-
auto output = pimpl_->model.forward(inputs).toTensor();
308+
auto& thread_model = *pimpl_->thread_states[thread_id]->model_copy;
309+
auto output = thread_model.forward(inputs).toTensor();
259310

260311
// Count predictions (simulating actual work with the output)
261312
total_predictions += output.numel();

0 commit comments

Comments
 (0)