Skip to content

Commit 3d6229c

Browse files
committed
Improve the CPU pinning logic for BatchExecutor.
- De-prioritize core 0 to decrease the probability of clashing with OS house-keeping - Pin hyper-threaded cores first if we run out of physical cores, and never wrap around the list (leave the over-subscribed rest workers unpinned)
1 parent c107563 commit 3d6229c

2 files changed

Lines changed: 100 additions & 34 deletions

File tree

src/util/BatchExecutor.cpp

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,41 @@
99
#include <string>
1010

1111
#ifdef __linux__
12+
#include <algorithm>
1213
#include <fstream>
14+
#include <map>
1315
#include <pthread.h>
1416
#include <sched.h>
15-
#include <unordered_set>
1617
#endif
1718

1819
namespace stellar
1920
{
2021

2122
namespace
2223
{
23-
// Returns one logical CPU id per physical core, restricted to the CPUs this
24-
// process is actually allowed to run on. Empty on failure or on non-Linux
25-
// platforms.
26-
std::vector<unsigned>
27-
physicalCoreRepresentatives()
24+
struct CpuPinning
25+
{
26+
// Allowed logical CPUs in pinning-preference order.
27+
std::vector<unsigned> order;
28+
// Number of distinct physical cores found in the allowed logical CPUs.
29+
// First `physicalCoreCount` entries of `order` are guaranteed to be on
30+
// distinct physical cores by the pinning order assignment procedure.
31+
size_t physicalCoreCount{0};
32+
};
33+
34+
// Returns every logical CPU this process is allowed to run on, ordered by
35+
// pinning preference:
36+
// - One logical CPU per physical core in ascending order, but with the core
37+
// hosting logical CPU 0 shifted to be the last core in the order (as the OS
38+
// tends to concentrate housekeeping work on CPU 0)
39+
// - The remaining hyper-thread siblings follow, one tier of siblings at a
40+
// time in the same order as the physical core order above
41+
// - Within the CPU-0 core, CPU 0 itself is demoted behind its siblings, so it
42+
// ends up as the very last CPU we pin.
43+
// Empty on failure or on non-Linux platforms.
44+
CpuPinning
45+
pinnedCpuOrder()
2846
{
29-
std::vector<unsigned> res;
3047
#ifdef __linux__
3148
cpu_set_t affinity;
3249
CPU_ZERO(&affinity);
@@ -37,12 +54,7 @@ physicalCoreRepresentatives()
3754
return {};
3855
}
3956

40-
// Walk the allowed CPUs in ascending order and keep the first (hence
41-
// smallest) allowed sibling of each physical core. thread_siblings_list
42-
// begins with the smallest sibling id, which is a stable per-core
43-
// identifier independent of the affinity mask, so it lets us collapse the
44-
// logical CPUs of one core down to a single representative.
45-
std::unordered_set<unsigned> seenCores;
57+
std::map<unsigned, std::vector<unsigned>> cpusByCore;
4658
for (unsigned cpu = 0; cpu < CPU_SETSIZE; ++cpu)
4759
{
4860
if (!CPU_ISSET(cpu, &affinity))
@@ -60,20 +72,58 @@ physicalCoreRepresentatives()
6072
// pinning rather than guess.
6173
return {};
6274
}
63-
if (seenCores.insert(coreId).second)
75+
cpusByCore[coreId].push_back(cpu);
76+
}
77+
78+
// Order the cores with the CPU-0-hosting core last.
79+
std::vector<std::vector<unsigned> const*> coreOrder;
80+
coreOrder.reserve(cpusByCore.size());
81+
size_t maxSiblings = 0;
82+
for (auto const& [coreId, cpus] : cpusByCore)
83+
{
84+
if (coreId != 0)
6485
{
65-
res.push_back(cpu);
86+
coreOrder.push_back(&cpus);
6687
}
88+
maxSiblings = std::max(maxSiblings, cpus.size());
6789
}
90+
auto core0 = cpusByCore.find(0);
91+
if (core0 != cpusByCore.end())
92+
{
93+
// Demote CPU 0 behind its own hyper-thread siblings so it becomes the
94+
// very last CPU we pin.
95+
auto& core0Cpus = core0->second;
96+
releaseAssert(!core0Cpus.empty());
97+
std::rotate(core0Cpus.begin(), core0Cpus.begin() + 1, core0Cpus.end());
98+
coreOrder.push_back(&core0Cpus);
99+
}
100+
101+
// Emit the CPUs tier by tier: the first sibling of every core, then the
102+
// second siblings, and so on.
103+
std::vector<unsigned> res;
104+
for (size_t rank = 0; rank < maxSiblings; ++rank)
105+
{
106+
for (auto const* cpus : coreOrder)
107+
{
108+
if (rank < cpus->size())
109+
{
110+
res.push_back((*cpus)[rank]);
111+
}
112+
}
113+
}
114+
return {std::move(res), cpusByCore.size()};
115+
#else
116+
return {};
68117
#endif
69-
return res;
70118
}
71119
} // namespace
72120

73121
BatchExecutor::BatchExecutor()
74122
{
75-
mPinCpus = physicalCoreRepresentatives();
76-
if (mPinCpus.empty())
123+
auto pinning = pinnedCpuOrder();
124+
mPinCpuOrder = std::move(pinning.order);
125+
mPhysicalCoreCount = pinning.physicalCoreCount;
126+
if (mPinCpuOrder.empty())
77127
{
78128
CLOG_WARNING(
79129
Perf,
@@ -82,10 +132,10 @@ BatchExecutor::BatchExecutor()
82132
}
83133
else
84134
{
85-
for (size_t i = 0; i < mPinCpus.size(); ++i)
135+
for (size_t i = 0; i < mPinCpuOrder.size(); ++i)
86136
{
87-
CLOG_DEBUG(Perf, "Batch worker physical core {} maps to CPU {}", i,
88-
mPinCpus[i]);
137+
CLOG_DEBUG(Perf, "Batch worker {} pins to CPU {}", i,
138+
mPinCpuOrder[i]);
89139
}
90140
}
91141
}
@@ -120,20 +170,31 @@ void
120170
BatchExecutor::pinWorker(size_t index)
121171
{
122172
#ifdef __linux__
123-
if (mPinCpus.empty())
173+
if (mPinCpuOrder.empty())
124174
{
125175
return;
126176
}
127-
if (index >= mPinCpus.size())
177+
if (index >= mPinCpuOrder.size())
178+
{
179+
CLOG_WARNING(
180+
Perf,
181+
"Not enough logical CPU cores to pin batch executor worker {} - "
182+
"the network configuration demands more cores than available. "
183+
"This may significantly reduce the performance of the node.",
184+
index);
185+
return;
186+
}
187+
if (index >= mPhysicalCoreCount)
128188
{
129189
CLOG_WARNING(
130190
Perf,
131-
"Not enough physical cores to pin batch executor worker {} - the "
132-
"network configuration demands more physical cores than available. "
133-
"This may reduce the performance of the node.",
191+
"Not enough physical CPU cores to pin batch executor "
192+
"worker {} - the network configuration demands more physical "
193+
"cores than available. Using hyper-thread sibling "
194+
"instead. This may reduce the performance of the node.",
134195
index);
135196
}
136-
unsigned cpu = mPinCpus.at(index % mPinCpus.size());
197+
unsigned cpu = mPinCpuOrder.at(index);
137198
cpu_set_t set;
138199
CPU_ZERO(&set);
139200
CPU_SET(cpu, &set);

src/util/BatchExecutor.h

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ namespace stellar
2626
//
2727
// Use this to speed up trivially parallelizable compute-heavy workloads.
2828
//
29-
// The executor pins the worker threads to physical cores. It may only run
29+
// The executor pins the worker threads to CPUs, spreading them across physical
30+
// cores before resorting to hyper-thread siblings. It may only run
3031
// a single batch of tasks at a time, and there should be only a single executor
3132
// instance per app, or else the execution would have unexpectedly degraded
3233
// performance due to oversubscription of physical cores.
@@ -65,20 +66,24 @@ class BatchExecutor : private NonMovableOrCopyable
6566
// Task execution loop running on every worker thread.
6667
void workerLoop(size_t index, uint64_t initialGeneration);
6768
// Grows the pool to at least `count` workers, pinning each new one to a
68-
// physical core if possible.
69+
// CPU if possible.
6970
void ensureWorkers(size_t count);
70-
// Pins worker `index` to its designated physical core. No-op on non-Linux
71-
// platforms or when the CPU topology is unavailable.
71+
// Pins worker `index` to its designated CPU. No-op on non-Linux
72+
// platforms, when the CPU topology is unavailable, or when there are more
73+
// workers than available CPUs.
7274
void pinWorker(size_t index);
7375

7476
// Plain std::mutex (rather than the annotated wrapper) as it has to work
7577
// with std::condition_variable.
7678
std::mutex mMutex;
7779
std::condition_variable mCondition;
7880
std::vector<std::thread> mWorkers;
79-
// One logical CPU per physical core; empty if pinning is unavailable. Set
80-
// once in the constructor and immutable afterwards.
81-
std::vector<unsigned> mPinCpus;
81+
// All allowed logical CPUs in pinning-preference order.
82+
std::vector<unsigned> mPinCpuOrder;
83+
// Number of distinct physical cores found in the allowed logical CPUs.
84+
// First `mPhysicalCoreCount` entries of `mPinCpuOrder` are guaranteed to be
85+
// on distinct physical cores by the pinning order assignment procedure.
86+
size_t mPhysicalCoreCount{0};
8287

8388
// Task that is being currently executed by the workers.
8489
std::function<void(size_t)> const* mRunTask{nullptr};

0 commit comments

Comments
 (0)