Choosing between OpenMP and oneTBB when linking oneMKL is an architectural choice between coordinated work-sharing and dynamic work-stealing. OpenMP routinely wins on large, regular dense kernels like DGEMM due to coordinated cache and memory reuse, but it breaks application composability when nested in larger parallel frameworks. oneTBB provides composable worker pools across complex software stacks, but on multi-socket or multi-NUMA systems it leaves 20–35% of peak performance on the table. Here, I investigate the computational mechanisms behind that gap across three common workloads, and show how to recover OpenMP-level throughput inside oneTBB without dropping into a secondary threading runtime.
Unless stated otherwise, all benchmarks were run on a Google Cloud h3-standard-88 VM featuring 88 physical Intel Xeon Platinum 8481C cores across four 22-core NUMA nodes, private 2 MiB L2 caches, and two 105 MiB shared last-level caches. SMT was disabled in the guest environment. Executables were built against oneMKL 2026.1 using either mkl_intel_thread with Intel libiomp5, or mkl_tbb_thread with oneTBB 2023.1. Matrices were touched in parallel prior to timed regions, and reported values reflect medians across warm runs.
Here, work-sharing and work-stealing refer to the execution models used by the two oneMKL backends:
I built a benchmark imitating the task structure of a supernodal factorization to find where this trade-off shifts in practice. It consists of eight levels. Each task performs a dense Cholesky factorization (DPOTRF) followed by a level-3 Schur complement update (DGEMM). The task count halves at each level while matrix dimensions scale upward, with matrix orders spread deterministically by ±30% within each level to introduce realistic load imbalance:
| Level | Tasks | Matrix order range | Mean order | Standard deviation | Nominal inner threads |
|---|---|---|---|---|---|
| 0 | 128 | 448–832 | 640.0 | 111.8 | 1 |
| 1 | 64 | 639–1,187 | 913.0 | 160.6 | 1 |
| 2 | 32 | 911–1,693 | 1,302.0 | 232.7 | 2 |
| 3 | 16 | 1,299–2,413 | 1,856.0 | 342.3 | 5 |
| 4 | 8 | 1,854–3,442 | 2,648.0 | 519.7 | 11 |
| 5 | 4 | 2,643–4,909 | 3,776.0 | 844.6 | 22 |
| 6 | 2 | 3,769–7,000 | 5,384.5 | 1,615.5 | 44 |
| 7 | 1 | 7,680 | 7,680.0 | 0.0 | 88 |
OpenMP executed outer tasks using schedule(dynamic, 1) with thread-local oneMKL allocations:
#pragma omp parallel for schedule(dynamic, 1)
for (int i = 0; i < tasks.size(); ++i) {
mkl_set_num_threads_local(inner_threads);
factor_and_update(tasks[i]);
}
The oneTBB implementation submitted outer tasks to a single tbb::task_group, allowing nested mkl_tbb_thread sub-tasks to run on the global worker pool without a hard outer/inner boundary.
OpenMP’s dynamic schedule balances the outer calls, but it cannot redistribute an inner oneMKL team among those calls. TBB exposes both levels to one scheduler, so idle capacity can move across the entire nested workload.
| Level | Tasks | Inner threads | OpenMP oneMKL (s) | oneTBB oneMKL (s) | oneTBB time change |
|---|---|---|---|---|---|
| 0 | 128 | 1 | 0.0590 | 0.0590 | −0.1% |
| 1 | 64 | 1 | 0.1105 | 0.0898 | −18.7% |
| 2 | 32 | 2 | 0.1843 | 0.1567 | −15.0% |
| 3 | 16 | 5 | 0.2168 | 0.2036 | −6.1% |
| 4 | 8 | 11 | 0.2462 | 0.2872 | +16.6% |
| 5 | 4 | 22 | 0.3160 | 0.2995 | −5.2% |
| 6 | 2 | 44 | 0.4279 | 0.4646 | +8.6% |
| 7 | 1 | 88 | 0.3422 | 0.5209 | +52.2% |
| Complete traversal | Median time (s) | Relative time |
|---|---|---|
Intel OpenMP + mkl_intel_thread | 1.906 | 1.00× |
oneTBB + mkl_tbb_thread | 2.096 | 1.10× |
The crossover is distinct. Across Levels 0–3, oneTBB wins by up to 18.7% because dynamic work-stealing smooths out iteration imbalance that fixed OpenMP inner-team reservations cannot absorb. Once the hierarchy narrows to Levels 4–7, inner-kernel compute efficiency begins to dominate outer imbalance.
At Level 7 (the single dense root), there is no outer imbalance left to absorb. OpenMP’s coordinated kernel outperforms oneTBB by 52.2%. That single root node accounts for nearly the entire 10% deficit across the full tree traversal.
xGEMM: Root Cause and Packing RecoveryOn a single square DGEMM at n = 15,840, OpenMP oneMKL finished in 1.455 s while oneTBB oneMKL required 1.945 s—a 33.7% penalty.
I first suspected scheduler overhead or task over-decomposition. I profiled on-CPU stack samples with perf record -e cpu-clock:u -F 99 -g:
| Implementation | Packed compute | Copy/packing | No-copy compute | Runtime/other | Median wall time |
|---|---|---|---|---|---|
| OpenMP oneMKL | 93.61% | 4.46% | — | 1.93% | 1.455 s |
| oneTBB oneMKL | — | 0.00% | 99.86% | 0.14% | 1.945 s |
| Manually packed oneTBB | 94.48% | 4.75% | — | 0.77% | 1.462 s |
Sampling found little visible scheduler time, while task instrumentation found exactly 88 TBB tasks. The decisive difference was kernel selection: OpenMP executed mkl_blas_avx512_dgemm_kernel_0 alongside packing helpers (dgemm_dcopy_*), while oneTBB spent 99.86% of its execution time in mkl_blas_avx512_dgemm_kernel_nocopy_nn_b0/b1.
The oneTBB backend was not using packing.
This choice fits the runtime architecture. Bounded shared operand packing is fundamentally a work-sharing pattern: workers cooperatively fill a shared panel buffer, synchronize at a barrier, compute against that buffer, and synchronize again before overwriting it:
parallel_region(workers, [&](int worker) {
for (KPanel k : k_panels) {
pack_assigned_part(worker, k, shared_panel);
barrier();
compute_assigned_tiles(worker, shared_panel);
barrier();
}
});
Global barriers constrain a work-stealing scheduler because waiting participants cannot service unrelated work. They buy coordinated reuse at the cost of composability. The no-copy kernel preserves task independence, but gives up the packed microkernel’s performance on this large matrix.
My first attempt imposed work-sharing inside TBB using oneMKL’s public packing interface. I divided matrix B into column panels, packed those panels across parallel TBB tasks, and dispatched long-lived row workers against the packed representations:
struct PackedPanel {
int column, width;
aligned_buffer bytes; // cblas_dgemm_pack_get_size(...)
};
// Epoch 1: pack B panels in parallel
tbb::parallel_for(0, panel_count, [&](int p) {
cblas_dgemm_pack(CblasRowMajor, CblasBMatrix, CblasNoTrans,
rows_per_owner, panels[p].width, K, 1.0,
&B[panels[p].column], ldb, panels[p].bytes.data());
});
// Epoch 2: row owners compute against each packed panel
tbb::parallel_for(0, machine_threads, [&](int owner) {
auto [r0, r1] = equal_row_slab(owner, machine_threads, M);
for (const PackedPanel& panel : panels)
cblas_dgemm_compute(/* A[r0:r1, :] * packed(panel) -> C slab */);
});
Implementation, n=15,840 | Panel count / width | Pack (s) | Compute (s) | Total (s) | Versus OpenMP |
|---|---|---|---|---|---|
| OpenMP oneMKL | Full matrix | — | 1.480 | 1.480 | Baseline |
| TBB shared pack | 4 / 3,960 | 0.159 | 1.474 | 1.634 | +10.4% |
| TBB shared pack | 11 / 1,440 | 0.087 | 1.411 | 1.495 | +1.0% |
| TBB shared pack | 22 / 720 | 0.074 | 1.420 | 1.494 | +0.9% |
| TBB shared pack | 88 / 180 | 0.028 | 1.702 | 1.731 | +17.0% |
Panel widths between 720 and 1,440 columns recovered OpenMP-level wall time (1.494 s). However, because oneMKL’s public packing interface (cblas_dgemm_pack) is intended for persistent reuse across multiple GEMM calls rather than transient panel streaming, retaining these full panels required 2.154 GiB of explicit buffer memory.
That memory cost led me to implement transient panel cycling using OpenBLAS’s internal packing routines and microkernels. I distributed 88 long-lived row-owner tasks across four NUMA-constrained tbb::task_arena instances, 22 per node.
For each K panel, the row owners packed their private A slabs, cooperatively packed a single shared B panel, synchronized through a quickly written CyclicBarrier, and executed the microkernel:
tbb::parallel_for(row_owners, [&](Owner owner) {
for (KPanel k : k_panels) {
pack_private_A(owner.rows, k, owner.packed_A);
pack_assigned_B_columns(owner, k, shared_packed_B);
barrier.arrive_and_wait();
openblas_microkernel(owner.rows, all_columns, k.width,
owner.packed_A, shared_packed_B,
C[owner.rows]);
barrier.arrive_and_wait();
}
});
n=15,840 implementation | Median time | Explicit packed storage |
|---|---|---|
| OpenMP oneMKL | 1.443 s | — (55.5 MiB internal) |
| oneTBB oneMKL, no-copy | 1.978 s | — |
| OpenBLAS microkernel, global TBB tasks | 1.592 s | 247.5 MiB |
| OpenBLAS microkernel, NUMA TBB arenas | 1.453 s | 247.5 MiB |
The smaller buffers reduced explicit packed storage from 2.154 GiB to 247.5 MiB. With NUMA task arenas, execution time reached 1.453 s, within 0.7% of OpenMP oneMKL.
xGEMV: Locality Fencing and Intra-Node StealingWhile GEMM possesses sufficient arithmetic intensity to partially mask NUMA traversal penalties, GEMV is strictly memory-bandwidth bound. Evaluating a 131,072 × 16,384 double-precision matrix (17.18 GB) across the four NUMA nodes highlights how placement dictates performance:
| Mode | Page distribution by node | Time (s) | Effective bandwidth |
|---|---|---|---|
| OpenMP oneMKL, node-0 first touch | 8192 / 0 / 0 / 0 | 0.1599 | 107.4 GB/s |
| OpenMP oneMKL, distributed first touch | 2048 / 2048 / 2048 / 2048 | 0.0420 | 409.5 GB/s |
| oneTBB oneMKL, node-0 first touch | 8192 / 0 / 0 / 0 | 0.5955 | 28.8 GB/s |
| oneTBB arenas + single-thread calls | 2048 / 2048 / 2048 / 2048 | 0.0487 | 352.6 GB/s |
| oneTBB oneMKL inside 4 NUMA arenas | 2048 / 2048 / 2048 / 2048 | 0.0451 | 380.7 GB/s |
Distributed page placement is mandatory for full-system bandwidth: it yields a 3.81× speedup under OpenMP.
I reproduced this placement in oneTBB with four tbb::task_arena instances. I gave each arena an explicit NUMA constraint, first-touched its row block there, and enqueued local GEMV operations:
for (int node = 0; node < numa_nodes; ++node) {
tbb::task_arena::constraints c;
c.set_numa_id(node).set_max_concurrency(cores_on(node));
arenas[node] = std::make_unique<tbb::task_arena>(c);
arenas[node]->execute([&] {
first_touch_rows(A, y, node_row_range(node));
});
}
for (int node = 0; node < numa_nodes; ++node) {
for (int part = 0; part < calls_per_node; ++part) {
arenas[node]->enqueue([&, node, part] {
auto rows = split(node_row_range(node), calls_per_node, part);
cblas_dgemv(/* A[rows, :] * x -> y[rows] */);
});
}
}
wait_for_all_arena_calls();
A tbb::task_scheduler_observer recorded every scheduler entry during nested oneMKL work and found zero entries on the wrong NUMA node.
Because assigning exactly one call per arena resulted in slight tail latencies across NUMA node completions, introducing moderate intra-node over-decomposition provided local stealing slack:
| oneMKL calls per NUMA arena | Time (s) | Effective bandwidth |
|---|---|---|
| 1 | 0.0440 | 390.2 GB/s |
| 2 | 0.0435 | 395.0 GB/s |
| 4 | 0.0431 | 398.2 GB/s |
| 8 | 0.0438 | 392.1 GB/s |
| Distributed OpenMP oneMKL | 0.0422 | 406.9 GB/s |
Across these runs, four tasks per NUMA node reached 398–401 GB/s, within 1.4–2.1% of OpenMP.
OpenMP-oneMKL composes dynamic outer scheduling with internally work-shared kernels, whereas oneTBB-oneMKL exposes both layers to one scheduler. Coordinated work-sharing simplifies ownership and reuse. A unified scheduler makes it easier to redistribute resources across nested workloads.
A library can support three operational tiers within oneTBB:
task_arena instances with intra-node over-decomposition for bandwidth-bound operations.Across both experiments, the useful pattern was to share across expensive locality boundaries and steal within them.