lock(queue_mutex_); // ๐ด LOCK
tasks_.emplace(packaged); // ๐ข HEAP ALLOC
}
condition_.notify_one(); // ๐ด SYSCALL
```
**Per-task cost: ~1-5 ยตs** on Windows
For degree-8 (4 roots, 6 iterations) = **24 heap allocs + 24 locks + 24 syscalls**
The actual computation for each root is ~3 ยตs. **Overhead equals computation!** ๐ฑ
---
### Killer #2: Coeffs Copied Per Root ๐
The `horner()` function **corrupts** the coefficients array โ it does synthetic division in-place:
```cpp
// Inside per-root task:
local_coeffs = coeffs; // copy full polynomial
auto vA = horner(local_coeffs, degree, vri); // โ ๏ธ modifies local_coeffs!
```
For FIR degree-48 (49 coefficients ร 8 bytes = 392 bytes):
- **Copy per root**: 24 roots ร 392 bytes = 9.4 KB per iteration
- **Copy per task**: 24 copies ร 6 iterations = 144 copies total
All that copying **just to throw it away** after each Horner call.
---
### Killer #3: Data Races ๐๏ธ๐ฅ
The original MT code reads `vrs[jdx]` while other threads **write** to it:
```cpp
pool.enqueue([&, idx]() {
// ...
for (auto jdx = 0U; jdx < num_roots; ++jdx) {
if (jdx == idx) continue;
const auto& vrj = vrs[jdx]; // ๐ reads from OTHER threads
suppress_old(vA, vA1, vri, vrj);
}
vrs[idx] -= delta_scalar(...); // ๐ writes own
});
```
.pull-left[
.mermaid[
graph LR
T0["Thread 0\nwrites vrs[0]"] -.->|"reads โ"| T1["Thread 1\nwrites vrs[1]"]
T1 -.->|"reads โ"| T0
T2["Thread 2\nwrites vrs[2]"] -.->|"reads โ"| T0
T0 -.->|"reads โ"| T2
style T0 fill:#f44336,color:#fff
style T1 fill:#ff9800
style T2 fill:#2196f3,color:#fff
]
]
.pull-right[
No mutex, no snapshot โ **undefined behavior** that happened to work on x86-64. ๐ฌ
]
---
### The Overhead Equation ๐
For degree-8 (4 roots, 6 iterations):
$$\text{Total} = \sum_{i=1}^{24} \left(\text{task\_overhead}_i + \text{computation}_i\right)$$
$$\text{task\_overhead} \approx 1\text{โ}5\,\mu\text{s},\quad \text{computation} \approx 3\,\mu\text{s}$$
$$\text{Speedup} = \frac{\text{serial\_time}}{\text{parallel\_time}} \approx \frac{4 \times 3 \times 6}{24 \times (3 + 3)} \approx \frac{72}{144} = 0.5\textbf{x}$$
**With 22 threads, we get 0.5ร speedup** โ that's *negative* scaling! ๐
---
class: nord-light, middle, center
## ๐ง Part 3: The Fixes
---
### Fix #1: Batch Scheduling ๐ฆ
**Before**: 1 task per root = N tasks
**After**: 1 task per chunk = T tasks (T = thread count)
```cpp
const auto num_threads = min(pool_size, num_roots);
const auto chunk_size = (num_roots + num_threads - 1) / num_threads;
for (auto t = 0U; t < num_threads; ++t) {
auto start = t * chunk_size;
auto end = min(start + chunk_size, num_roots);
results.emplace_back(pool.enqueue([&, start, end]() {
double max_tol = 0.0;
for (auto idx = start; idx < end; ++idx) {
max_tol = max(max_tol, process_root(idx));
}
return max_tol;
}));
}
```
.pull-left[
.mermaid[
graph LR
Before["24 tasks\n24 enqueue calls"] -->|"6ร fewer"| After["4 tasks\n4 enqueue calls"]
style Before fill:#f44336,color:#fff
style After fill:#4caf50,color:#fff
]
]
.pull-right[
For FIR degree-48: **24 tasks โ 4 tasks** (22 threads, 24 roots). Each task processes 6 roots sequentially within the chunk.
]
---
### Fix #2: Jacobi Snapshot Iteration ๐ธ
Replace **Gauss-Seidel** (read live values) with **Jacobi** (read snapshot):
```cpp
// Gauss-Seidel: reads vrs[jdx] as updated by other threads โ DATA RACE โ
// Jacobi: reads vrs_snapshot[jdx] from previous iteration โ SAFE โ
auto vrs_snapshot = vrs; // copy once per iteration
for (auto idx = start; idx < end; ++idx) {
const auto& vri = vrs_snapshot[idx]; // read from snapshot
// ... compute update ...
for (auto jdx = 0U; jdx < num_roots; ++jdx) {
if (jdx == idx) continue;
const auto& vrj = vrs_snapshot[jdx]; // โ
ALL read from same snapshot
suppress_old(vA, vA1, vri, vrj);
}
vrs[idx] -= delta_scalar(...); // write to live array
}
```
.mermaid[
graph LR
GS["Gauss-Seidel\nRead live values\nโ Data race"] -->|"vs"| J["Jacobi\nRead snapshot\nโ
Safe & parallel"]
style GS fill:#f44336,color:#fff
style J fill:#4caf50,color:#fff
]
---
### Fix #3: Coeffs Copy Per Root (Bugfix!) ๐
The `horner()` function **corrupts** the coefficients array:
```cpp
auto vA = horner(coeffs, degree, vri);
// After this call, coeffs is DESTROYED โ ๏ธ
auto vB = horner(coeffs, degree, vrj); // ๐ฅ Uses corrupted data!
```
**Fix**: Each root gets its own copy:
```cpp
for (auto idx = start; idx < end; ++idx) {
auto local_coeffs = coeffs; // โ
fresh copy per root
auto vA = horner(local_coeffs, degree, vri); // โ
local copy is safe
auto vA1 = horner(local_coeffs, degree-2, vri);
// ... rest of root processing ...
}
```
> The original MT code also had this bug! With 1 task = 1 root, the single use per task masked it. Batch scheduling exposed it. ๐
---
### Fix #4: Sequential Path for Small Problems ๐ถ
For ≤ 4 roots, **skip the thread pool entirely**:
```cpp
if (num_roots <= 4) {
// Direct sequential loop โ no futures, no pool, no snapshot
for (auto niter = 0U; niter != max_iters; ++niter) {
auto tolerance = 0.0;
for (auto idx = 0U; idx != num_roots; ++idx) {
// ... same as pbairstow_even_st ...
}
if (tolerance < options.tolerance) return {niter, true};
}
return {max_iters, false};
}
```
No heap allocations, no mutexes, no condition variables โ **runs at ST speed** for small problems.
The parallel path is only activated when there are enough roots to amortize the overhead.
---
### The Final Architecture ๐๏ธ
.mermaid[
graph LR
I["pbairstow_even_mt()\nentry"] --> Q{"num_roots\n> 4 ?"}
Q -->|"No (โค 4)"| S["Sequential path\nGauss-Seidel loop\nโ
No thread pool\nโ
No snapshot"]
Q -->|"Yes"| P["Parallel path\nBatch scheduling\nโ
Fewer tasks\nโ
Jacobi snapshot"]
S --> D["Return result"]
P --> D
style I fill:#ff9800
style S fill:#4caf50,color:#fff
style P fill:#2196f3,color:#fff
style D fill:#9c27b0,color:#fff
]
**Key insight**: Parallelism is only beneficial when the work per-thread exceeds the overhead of creating it. For small problems, **sequential is optimal** โ the "MT" function becomes an "auto" function. ๐ง
---
class: nord-light, middle, center
## ๐ Part 4: Results
---
### Benchmark Results: Small Problems (4 roots) ๐
Degree-8 polynomial โ **before vs after**:
.pull-left[
.mermaid[
graph LR
A1["Autocorr_MT\nBefore: 116 ยตs"] -->|"27ร faster"| A2["After: 4.3 ยตs"]
P1["PBairstow_MT\nBefore: 334 ยตs"] -->|"29ร faster"| P2["After: 11.5 ยตs"]
AB1["Aberth_MT\nBefore: 583 ยตs"] -->|"~same"| AB2["After: 593 ยตs"]
style A1 fill:#f44336,color:#fff
style A2 fill:#4caf50,color:#fff
style P1 fill:#f44336,color:#fff
style P2 fill:#4caf50,color:#fff
style AB1 fill:#ff9800
style AB2 fill:#4caf50,color:#fff
]
]
.pull-right[
| Benchmark | Before | After | Speedup |
|-----------|--------|-------|---------|
| Autocorr_MT | 116 ยตs | **4.3 ยตs** | **27ร** ๐ |
| PBairstow_MT | 334 ยตs | **11.5 ยตs** | **29ร** ๐ |
| Aberth_MT | 583 ยตs | 593 ยตs | ~same |
Now runs **as fast as ST** for small problems!
]
---
### Benchmark Results: Large Problems (24 roots) ๐
FIR degree-48 polynomial:
.pull-left[
.mermaid[
graph LR
F1["FIR_Aberth_MT\nBefore: 4.0 ms"] -->|"2.9ร faster"| F2["After: 1.37 ms"]
F3["FIR_Aberth_ST\n~0.6 ms"] ---|"MT is 2.3ร ST"| F2
style F1 fill:#f44336,color:#fff
style F2 fill:#2196f3,color:#fff
style F3 fill:#4caf50,color:#fff
]
]
.pull-right[
| Benchmark | Before | After | Speedup |
|-----------|--------|-------|---------|
| FIR_Aberth_MT | 4.0 ms | **1.37 ms** | **2.9ร** ๐ |
Now MT is only **2.3ร ST** instead of **6.2ร ST**. Still slower due to Gauss-Seidel โ Jacobi convergence difference, but dramatically improved.
]
---
### Rust vs C++ Comparison ๐ฆโก
**Rust** uses Rayon โ already efficient, but same principles apply:
.mermaid[
graph TD
subgraph "Degree-8 (4 roots)"
R8["Rust MT penalty\n17-22ร"] -->|"Better baseline"| C8["C++ MT penalty\n23-35ร"]
end
subgraph "FIR deg48 (24 roots)"
R48["Rust MT penalty\n1.5ร"] -->|"Rayon is efficient"| C48["C++ MT penalty\nbefore: 6.2ร\nafter: 2.3ร"]
end
style R8 fill:#ff9800
style C8 fill:#f44336,color:#fff
style R48 fill:#4caf50,color:#fff
style C48 fill:#2196f3,color:#fff
]
| Language | Deg8 penalty | FIR48 penalty | Threading |
|----------|-------------|---------------|-----------|
| **Rust** ๐ฆ | 17-22ร | 1.5ร | Rayon work-stealing |
| **C++ (before)** โก | 23-35ร | 6.2ร | `thread_pool` + `std::future` |
| **C++ (after)** โก | ~same as ST | 2.3ร | Batch + sequential path |
Rayon's work-stealing is inherently more efficient than hand-rolled `std::future` per task.
---
### Key Takeaways ๐ผ๏ธ
.mermaid[
graph TD
Q["Why was MT slower\nthan ST?"] --> R1["๐ฏ Per-task overhead\n(heap alloc + lock +\nsyscall per root)"]
Q --> R2["๐ Unnecessary copies\n(coeffs re-copied for\nevery root)"]
Q --> R3["๐๏ธ Data races\n(reading live values\nfrom other threads)"]
Q --> R4["๐ Horner corruption\n(batch scheduling\nexposed the bug)"]
style Q fill:#ff9800,stroke:#e65100,stroke-width:3px
style R1 fill:#f44336,color:#fff
style R2 fill:#2196f3,color:#fff
style R3 fill:#ff9800
style R4 fill:#9c27b0,color:#fff
]
---
### Lessons Learned ๐ง
1. **Measure before optimizing** ๐ โ The 23-35ร slowdown was hiding in plain sight
2. **Thread pools aren't free** ๐ฐ โ Each `enqueue()` has real cost (heap + lock + syscall)
3. **Batch your tasks** ๐ฆ โ N tasks for N work items is wasteful. Use chunks!
4. **Snapshots fix races** ๐ธ โ Jacobi iteration is trivially parallelizable
5. **Know when NOT to parallelize** ๐ซ โ For ≤ 4 roots, sequential is optimal
6. **Let the data flow** ๐ โ `horner()` destroys its input. Design APIs that make this obvious.
> **The best optimization is knowing when to do nothing.** ๐ง
---
### Future Work ๐ญ
.mermaid[
graph LR
F1["Apply to Rust\nginger-rs"] --> F2["Batch scheduling\nwith rayon::par_chunks"]
F2 --> F3["Auto-select\nsequential path"]
F3 --> F4["Even better\nRust MT perf"]
style F1 fill:#2196f3,color:#fff
style F2 fill:#ff9800
style F3 fill:#4caf50,color:#fff
style F4 fill:#9c27b0,color:#fff
]
- **Apply same optimizations to Rust** โ `par_chunks_mut()` instead of `par_iter_mut()`
- **Larger problem sizes** โ At degree 200+, parallel speedup becomes real
- **Pipeline parallelism** โ Process multiple independent polynomials in parallel
- **GPU acceleration** โ For very large polynomials, CUDA/ROCm may win
---
count: false
class: nord-dark, middle, center
## ๐ Q&A
**ST vs MT: Parallelizing Polynomial Root-Finding**
*Questions?* ๐ค
---
count: false
class: nord-dark, middle, center
# ๐ Thank You!
## Parallelize Smarter! โก ๐งฎ ๐