)` **newtype** |
**Rust's newtype wrappers** give the strongest type safety โ `SingleCut` and `ParallelCut` are distinct types that **cannot be confused at compile time**. โ
---
class: nord-light, middle, center
## ๐ฌ Core Algorithm: The Ellipsoid Update
---
### ๐ฌ The Ellipsoid Update Formula
**The search space** is an ellipsoid:
$$ \mathcal{E} = \{x \mid (x - x_c)^T Q^{-1} (x - x_c) \le \kappa \} $$
**Given a cutting plane** $g^T(x - x_c) + \beta \le 0$, the update is:
$$
\begin{aligned}
\tilde{g} &= Q\,g \\
\omega &= g^T \tilde{g} \\
\tau^2 &= \kappa\,\omega \\
x_c &\leftarrow x_c - \frac{\rho}{\omega}\,\tilde{g} \\
Q &\leftarrow Q - \frac{\sigma}{\omega}\,\tilde{g}\,\tilde{g}^T \\
\kappa &\leftarrow \kappa \cdot \delta
\end{aligned}
$$
where $\rho, \sigma, \delta$ are **cut-type-dependent** parameters.
---
### ๐ฌ Rank-1 Matrix Update
The matrix update is a **symmetric rank-1 downdate**:
$$Q^+ = Q - \frac{\sigma}{\omega}\, (Qg)(Qg)^T$$
**Implementation comparison:**
```python
# ๐ Python โ numpy handles symmetry implicitly
self._mq -= (sigma / omega) * np.outer(grad_t, grad_t)
```
.pull-left[
```cpp
// โก C++ โ explicit symmetry preservation
for (size_t i = 0; i != n; ++i) {
const auto rQg = r * grad_t[i];
for (size_t j = 0; j != i; ++j) {
mq(i, j) -= rQg * grad_t[j];
mq(j, i) = mq(i, j); // mirror!
}
mq(i, i) -= rQg * grad_t[i];
}
```
]
.pull-right[
```rust
// ๐ฆ Rust โ explicit symmetry via data_mut
for j in 0..=i {
let idx = i * n + j;
self.mq.data_mut()[idx] -= update;
if i != j {
self.mq.data_mut()[j * n + i] = self.mq.data()[idx]; // mirror!
}
}
```
]
---
### ๐ก๏ธ Numerical Guard: The Omega Check
**Critical guard** โ all three implementations must handle $\omega = 0$ or denormal:
.font-sm[
| Implementation | $\omega = 0$ | Denormal $\omega$ |
|---------------|-------------|-------------------|
| ๐ Python | `NoEffect` (`== 0.0`) | `NoEffect` (`> tiny`) |
| โก C++ (original) | `Success` **bug** โ | โ **No guard** |
| โก C++ (patched) | `NoEffect` | `NoEffect` (`<= min()`) |
| ๐ฆ Rust (original) | โ **No guard** (NaN/Inf) | โ **No guard** |
| ๐ฆ Rust (patched) | `NoEffect` | `NoEffect` (`<= MIN_POSITIVE`) |
]
**Bug found:** Original C++ returned `Success` with zero displacement for $\omega = 0$. Rust had no guard at all โ `rho / 0.0` would silently produce Inf/NaN. ๐
---
class: nord-light, middle, center
## โ๏ธ Cut Types & Formulas
---
### โ๏ธ Cut Type Classification
Three fundamental cut types, each with two variants:
.mermaid[
graph TD
A["Cut Type"] --> B["Central Cut\nbeta = 0\npasses through center"]
A --> C["Bias (Deep) Cut\nbeta > 0\noffset from center"]
A --> D["Parallel Cut\ntwo parallel planes\nbeta0, beta1"]
B --> E["Standard"]
B --> F["Parallel central\n(beta1 > 0)"]
C --> G["Standard"]
C --> H["Parallel bias\n(beta0, beta1)"]
]
Each cut type returns $(\rho, \sigma, \delta)$ โ the **ellipsoid update parameters**.
---
### โ๏ธ Central Cut Formulas
**Passes through the ellipsoid center** ($\beta = 0$):
$$ \rho = \frac{\tau}{n+1}, \qquad
\sigma = \frac{2}{n+1}, \qquad
\delta = \frac{n^2}{n^2-1} $$
**Parallel central cut** (one central + one offset):
$$ \alpha^2 = \frac{\beta_1^2}{\tau^2}, \quad
k = \frac{n}{2}\alpha^2, \quad
r = k + \sqrt{k^2 + 1 - \alpha^2} $$
$$ \rho = \frac{\beta_1}{r+1}, \quad
\sigma = \frac{2}{r+1}, \quad
\delta = \frac{r}{r - 1/n} $$
---
### โ๏ธ Bias (Deep) Cut Formulas
**Offset from center** ($\beta > 0$):
$$ \eta = \tau + n\beta, \quad
\alpha = \beta / \tau $$
$$ \rho = \frac{\eta}{n+1}, \qquad
\sigma = \frac{2\eta}{(n+1)(\tau+\beta)}, \qquad
\delta = \frac{n^2}{n^2-1} \cdot \frac{\tau^2 - \beta^2}{\tau^2} $$
**Parallel bias cut** (two parallel planes at $\beta_0, \beta_1$):
The "fast" $\zeta$-formulation used by all three:
$$ \zeta_0 = \tau^2 - \beta_0^2,\quad
\zeta_1 = \tau^2 - \beta_1^2,\quad
\xi = \sqrt{\zeta_0\zeta_1 + \left(\tfrac{n}{2}(\beta_1^2-\beta_0^2)\right)^2} $$
---
### โ๏ธ Parallel Bias Cut โ Complete Formulas
All three implement the **same** $\zeta$-formulation:
**Computation:**
$$ \sigma = \frac{2\eta}{\tau^2 + \beta_0\beta_1 + \tfrac{n}{2}(\beta_0+\beta_1)^2 + \xi} $$
$$ \rho = \sigma \cdot \frac{\beta_0 + \beta_1}{2} $$
$$ \delta = \frac{n^2}{(n^2-1)\,\tau^2}\left(\frac{\zeta_0 + \zeta_1}{2} + \frac{\xi}{n}\right) $$
---
**Near-verbatim code across all three:**
```python
# ๐ Python
sigma = 2.0 * eta / (tsq + b0b1 + self._half_n * bsumsq + xi)
```
```cpp
// โก C++
const double sigma = 2.0 * eta / (tsq + b0b1 + this->_half_n * bsumsq + xi);
```
```rust
// ๐ฆ Rust
let sigma = 2.0 * eta / (tsq + b0b1 + self.half_n * bsumsq + xi);
```
---
### ๐๏ธ Variant Proliferation
Python has the most variants (4), C++ and Rust have 2 each:
| Variant | ๐ Python | โก C++ | ๐ฆ Rust |
|---------|-----------|-------|---------|
| `calc_parallel_bias_cut_fast` ($\zeta$-formula) | โ
| โ
`calc_parallel_cut_fast` | โ
|
| `calc_parallel_bias_cut_fast_old` (bavg/k) | โ
| โ
`calc_parallel_cut_fast_old` | โ
|
| `calc_parallel_bias_cut_fast2` (alt $\sigma$) | โ
| โ | โ |
| `calc_parallel_bias_cut_old` (expanded) | โ
| โ | โ |
> The $\zeta$-formula is the **canonical implementation** in all three. Python's extras are historical artifacts.
---
class: nord-light, middle, center
## ๐ก๏ธ Numerically Stable LDL$^T$ Variant
---
### ๐ก๏ธ LDL$^T$ Factorization
Instead of storing the full $Q$ matrix, the stable variant stores **LDL$^T$ factors**:
$$Q = \kappa \cdot L \cdot D \cdot L^T$$
where $L$ is unit lower-triangular and $D$ is diagonal.
**Forward/backward substitution replaces matrix-vector multiply:**
$$
\begin{aligned}
w &= L^{-1}g \\
z &= D^{-1}w \\
\omega &= \sum_i w_i z_i \\
q &= L^{-T}z \\
x_c &\leftarrow x_c - \frac{\rho}{\omega}\,q
\end{aligned}
$$
The rank-1 update modifies $D$ and $L$ directly (Gill, Murray, Wright 1981).
---
### ๐ก๏ธ Scratch Buffer Strategy
**After implementing Rust's pre-allocated approach in Python and C++:**
| Language | Before | After | Buffers |
|----------|--------|-------|---------|
| ๐ Python | 5 allocs/call โ | **0 allocs** โ
| `_inv_lower_g`, `_inv_diag_inv_lower_g`, `_g_t` |
| โก C++ | 3 allocs/call โ ๏ธ | **0 allocs** โ
| `_scratch`, `_z`, `_v` |
| ๐ฆ Rust | **0 allocs** โ
(reference) | โ | `inv_ml_g`, `inv_md_inv_ml_g`, `gg_t`, `g_t` |
---
Python (`EllStable._update_core`) now reuses **3 pre-allocated numpy arrays**:
```python
np.copyto(self._inv_lower_g, g) # w = L^{-1}g (forward sub)
np.copyto(self._inv_diag_inv_lower_g, w) # z = D^{-1}w
self._g_t = self._inv_diag_inv_lower_g # q = L^{-T}z, then v (rank-1)
```
Omega computed inline (`for i: omega += w_i * z_i`) โ no `gg_t` buffer needed. After back substitution, `_g_t` is **reused as working vector `v`** for the Gauss-Seidel rank-1 update.
C++ (`EllCore`) now uses **3 pre-allocated `valarray` buffers**:
```cpp
Vec _scratch; // w = L^{-1}g โ v (rank-1 update, after forward sub done)
Vec _z; // z = D^{-1}w (preserved for rank-1 update)
Vec _v; // q = L^{-T}z โ preserved for final g assignment
```
The non-stable `_update_core` also reuses `_v` instead of allocating `grad_t` per call.
---
class: nord-light, middle, center
## ๐ The Bug: Rust LDL$^T$ Rank-1 Update
---
### ๐ Bug Discovery
**Cross-validation revealed a discrepancy:**
| Test | Rust `Ell` | Rust `EllStable` (before) | Python `EllStable` |
|------|-----------|--------------------------|-------------------|
| Example 1 | **25 iters** | **58 iters** โ | **25 iters** โ
|
| Profit Oracle | **83 iters** | **94 iters** โ | **83 iters** โ
|
**Root cause:** Two bugs in `src/ell_stable.rs`:
1. **Back-substitution** read from overwritten lower triangle (scratch = $L_{ij}w_j$) instead of original upper triangle ($L_{ij}$)
2. **Rank-1 update** used constant values without maintaining a running vector $v$ (Gauss-Seidel pattern)
---
### ๐ Bug 1: Back-Substitution
**What the code did:**
```rust
// BEFORE (BUG): reads lower triangle โ overwritten with L[l,j] * w_j
self.g_t[i - 1] -= self.mq.at(j, i - 1) * self.g_t[j];
```
**What it should do:**
```rust
// AFTER (FIX): reads upper triangle โ original L[l,j]
self.g_t[i - 1] -= self.mq.at(i - 1, j) * self.g_t[j];
```
**Storage convention in Rust**
$L[i,j]$ in upper triangle at $(j,i)$
.font-sm[
| Position | Forward pass reads | Forward pass writes | Back-substitution reads |
|----------|-------------------|-------------------|----------------------|
| Upper $(j,i)$ | $L[i,j]$ (original) | untouched | โ **should read here** |
| Lower $(i,j)$ | โ | $L[i,j] \cdot w_j$ (scratch) | โ **was reading here** โ |
]
---
### ๐ Bug 2: Rank-1 Update โ Missing Running Vector $v$
**What the code did:**
```rust
// BEFORE: used constant gg_t[j] = w_j * z_j, no v tracking
for j in 0..last_idx {
let temp = oldt + self.gg_t[j]; // constant value
let beta2 = self.inv_md_inv_ml_g[j] / temp;
for l in (j + 1)..ndim {
self.mq.data_mut()[row_start_j + l] += beta2 * self.mq.at(l, j);
}
}
```
**What Python does (correct Gauss-Seidel pattern):**
```python
# Python: maintains running v vector = gradient, progressively transformed to w
v = g.copy() # start as gradient
for j in range(n):
p = v[j] # v[j] = w_j by now
newt = oldt + p * inv_diag_inv_lower_g[j] # = oldt + w_j * z_j
for k in range(j+1, n):
v[k] -= mq[j, k] # update v[k] โ becomes w_k
mq[k, j] += (z_j / newt) * v[k] # use UPDATED v[k]
```
---
### ๐ The Fix Applied
**Fixed rank-1 update in Rust:**
```rust
// AFTER: running vector v (reuses g_t buffer), matching Python algorithm
self.g_t.copy_from(grad); // v = gradient
for j in 0..ndim {
let p = self.g_t[j]; // v[j] = w_j (evolved from g_j)
let temp = self.inv_md_inv_ml_g[j]; // z_j = D_j^{-1} * w_j
let newt = oldt + p * temp;
let beta2 = temp / newt;
self.mq.data_mut()[j * n + j] *= oldt / newt; // D_j update
for l in (j + 1)..ndim {
self.g_t[l] -= self.mq.at(l, j); // v[l] -= L[l,j] * w_j (scratch)
self.mq.data_mut()[row_start_j + l] += beta2 * self.g_t[l]; // update L
}
oldt = newt;
}
```
---
**Result โ both bug fixes combined:**
| Variant | Before fix | After fix | Python reference |
|---------|-----------|----------|-----------------|
| Ex1 EllStable | 58 iters โ | **25 iters** โ
| 25 iters โ
|
| Profit Stable | 94 iters โ | **83 iters** โ
| 83 iters โ
|
| Max diff from Ell | ~0.43 โ | **7.34e-10** โ
| ~1e-10 โ
|
---
class: nord-light, middle, center
## โ
Cross-Validation Results
---
### โ
Full Test Suite โ All Passing
.font-sm[
| Project | Test count | Status |
|---------|-----------|--------|
| ๐ฆ **Rust** (`ellalgo-rs`) | 110 unit + 13 integration + 28 doc-tests | โ
**All passed** |
| ๐ **Python** (`ellalgo`) | 157 pytest | โ
**All passed** |
| โก **C++** (`ellalgo-cpp`) | 139 test cases, 414 assertions | โ
**All passed** |
]
**Identical results across projects:**
.font-sm[
| Oracle | Rust `Ell` | Rust `EllStable` (fixed) | Python `EllStable` | C++ |
|--------|-----------|------------------------|-------------------|-----|
| Example 1 | 25 iters โ
| **25 iters** โ
| 25 iters โ
| โ
feasible |
| Profit Oracle | 83 iters โ
| **83 iters** โ
| 83 iters โ
| โ
same test |
| Profit RB | 90 iters โ
| 90 iters โ
| 90 iters โ
| โ
same test |
| Profit Q | 29 iters โ
| 29 iters โ
| 29 iters โ
| โ
same test |
]
> **All three projects now produce bit-identical results** (within FP tolerance $\approx 10^{-9}$) ๐
---
class: nord-light, middle, center
## โก Benchmarks & Performance
---
### โก Profit Oracle Benchmark (Same Problem)
**After pre-allocated buffers in Python and C++:**
.font-sm[
| Variant | ๐ฆ Rust | โก C++ (MSVC) | ๐ Python |
|---------|:-------:|:-------------:|:---------:|
| **Profit (Ell)** | โ | **25.5 ยตs** โ77% | **922 ยตs** โ83% |
| **Profit (Stable)** | **22.5 ยตs** | **22.5 ยตs** โ86% | **819 ยตs** โ81% |
| **Profit RB** | โ | **28.2 ยตs** โ81% | **1,001 ยตs** โ81% |
| **Profit Q** | โ | **10.9 ยตs** โ75% | **350 ยตs** โ81% |
]
**Speedup from pre-allocated buffers:**
.font-sm[
| Language | Ell (non-stable) | Stable | RB | Q |
|----------|:----------------:|:------:|:--:|:-:|
| ๐ Python | 5,409โ**922** ยตs (5.9ร) | 4,400โ**819** ยตs (5.4ร) | 5,357โ**1,001** ยตs (5.4ร) | 1,829โ**350** ยตs (5.2ร) |
| โก C++ | 112โ**25.5** ยตs (4.4ร) | 166โ**22.5** ยตs (7.4ร) | 147โ**28.2** ยตs (5.2ร) | 44โ**10.9** ยตs (4.0ร) |
]
> ๐ก **Pre-allocated buffers close the gap:** Python is now **only ~36ร slower than C++** (was ~100ร), and C++ now **matches Rust** for the stable variant!
---
### โก Dimensional Scaling (Rust โ Quadratic Oracle)
| Dimension | Time (ยตs) | $O(n^2)$ scaling |
|-----------|:---------:|:----------------:|
| 10 | **0.226** | baseline |
| 50 | **4.18** | $\approx 18.5\times$ |
| 100 | **18.2** | $\approx 80.5\times$ |
The $O(n^2)$ scaling comes from the **rank-1 matrix update** โ the dominant operation:
- $Q \cdot g$ (matrix-vector): $n^2$
- $Q - (\sigma/\omega) \tilde{g} \tilde{g}^T$ (rank-1): $\frac{n(n+1)}{2}$
- Total: $\approx 1.5 n^2$ per iteration
---
### โก C++ Benchmark Summary
Built with `xmake -y -j 10` (release mode, AVX2 on Windows):
.font-sm[
| Benchmark | Before (ยตs) | After (ยตs) | Speedup |
|-----------|:-----------:|:----------:|:-------:|
| ELL_normal (Profit Ell) | 112 | **25.5** | **4.4ร** |
| ELL_stable (Profit Stable) | 166 | **22.5** | **7.4ร** |
| ELL_normal_rb (Profit RB) | 147 | **28.2** | **5.2ร** |
| ELL_stable_rb (Profit Stable RB) | 115 | **27.4** | **4.2ร** |
| ELL_normal_q (Profit Q, Ell) | 44 | **10.9** | **4.0ร** |
| ELL_stable_q (Profit Q, Stable) | 50 | **10.6** | **4.7ร** |
]
> **Pre-allocated buffers made C++ competitive with Rust** โ stable variant went from being 48% slower than non-stable to matching it (22.5 vs 25.5 ยตs). The `valarray` copy elision eliminates heap allocation overhead.
---
### โก Performance Comparison Summary
.mermaid[
graph LR
subgraph "Stable Variant โ After Scratch Buffers (Profit Oracle)"
A["Rust ๐ฆ\n22.5 ยตs"] --> B["1ร (baseline)"]
C["C++ โก\n22.5 ยตs"] --> D["1ร โ matches Rust!"]
E["Python ๐ old\n4400 ยตs"] --> F["195ร slower
(no scratch buffers)"]
G["Python ๐ new\n819 ยตs"] --> H["36ร slower
(scratch buffers: 5.4ร faster)"]
end
]
**Key insights:**
- Rust and C++ now **tied** for the stable variant at **22.5 ยตs** โ pre-allocated buffers eliminated C++'s `valarray` copy overhead
- ๐ Python improved **5.4ร** (from 4,400 ยตs to 819 ยตs) by adding 3 pre-allocated numpy arrays โ no more `g.copy()`, `inv_lower_g.copy()`, etc. per iteration.
Python still pays interpreter overhead but the **allocation tax is gone** โ 36ร slower vs 195ร slower before
- The non-stable C++ also improved 4.4ร (112โ25.5 ยตs) by reusing `_v` instead of allocating `grad_t` per call
---
class: nord-light, middle, center
## ๐ฏ Key Takeaways
---
### ๐ฏ Lessons Learned
1. **Same math, same results** โ All three implementations produce identical output when correct. Verified within FP tolerance $\approx 10^{-9}$ โ
2. **Pre-allocated buffers close the gap** โ Python and C++ adopted Rust's scratch buffer strategy. Python: **5.4ร faster** stable variant. C++: **7.4ร faster** stable variant. Rust's design became the reference for all three ๐
3. **C++ now matches Rust** โ After pre-allocated buffers, C++ stable variant runs at **22.5 ยตs**, tying Rust. The `valarray` heap-allocation overhead was the main bottleneck ๐๏ธ
4. **Python still pays interpreter overhead** โ Even with 0 allocations per call, Python runs 36ร slower than Rust/C++. But **36ร is much better than 195ร** before the buffer optimization ๐
5. **Numerical bugs are subtle** โ The LDL$^T$ rank-1 update bug was invisible until cross-validated against Python. Rust's `Ell` (non-stable) was correct all along, but `EllStable` had an insidious bug for months ๐
6. **Polyglot validation catches bugs** โ Having the same algorithm in three languages means you can cross-validate. Python caught Rust's bug, even though Python's own LDL$^T$ had no tests proving correctness.
---
### ๐ Final Scorecard
.font-sm[
| Metric | ๐ฆ Rust | โก C++ | ๐ Python |
|--------|:-------:|:------:|:---------:|
| **Raw speed** (Stable) | ๐ฅ **22.5 ยตs** | ๐ฅ **22.5 ยตs** | ๐ฅ 819 ยตs |
| **ML-style name** | `trait + newtype` โ
| `concept + overload` โ
| `isinstance` โ ๏ธ |
| **Error handling** | `Result<_, EllipsoidError>` ๐ฅ | `invalid_value` (NaN) ๐ฅ | `Optional[ArrayType]` ๐ฅ |
| **Pre-allocated buffers** | ๐ฅ 4 buffers (reference design) | ๐ฅ 3 buffers (ported) | ๐ฅ 3 buffers (ported) |
| **Allocations per `_update_core`** | **0** (always) | **0** (after port) | **0** (after port) |
| **Code readability** | traits ๐ฅ | templates ๐ฅ | duck typing ๐ฅ |
| **Build time** | 33s (debug) ๐ฅ | 29s (release) ๐ฅ | instant ๐ฅ |
]
**Bottom line:** Choose Rust for **safety + speed + reference design**, C++ for **speed + portability** (now matches Rust with scratch buffers), Python for **rapid prototyping + teaching** (36ร slower is fine for exploration) ๐ฏ
---
### ๐ฏ Recommendations
.mermaid[
graph LR
A{"Which ellipsoid variant\nto use?"}
A --> B{"Need numerical\nstability?"}
B -->|"No"| C["Use Ell\n+ direct Q update\nSimpler, faster"]
B -->|"Yes"| D["Use EllStable\n+ LDL^T factors\n(for ill-conditioned probs)"]
C --> E{"Language?"}
E -->|"Rust"| F["ellalgo-rs\ncrates.io"]
E -->|"C++"| G["ellalgo-cpp\nxmake / CMake"]
E -->|"Python"| H["ellalgo\npip install"]
]
**Production advice:**
- **Well-conditioned problems:** Use `Ell` (direct update) โ simpler, fewer moving parts
- **Ill-conditioned problems:** Use `EllStable` (LDL$^T$) โ ensure scratch buffers are pre-allocated!
- **Cross-language projects:** Keep a reference implementation to validate others. Rust makes a great reference design โ its 4-buffer scratch strategy is now ported to Python and C++ ๐ฆ
- **Performance:** Port the scratch buffer strategy first โ it's a **5โ7ร free speedup** with zero algorithmic changes
---
count: false
class: nord-dark, middle, center
# ๐ Thank You!
### *Questions?*
**Repository:** https://github.com/luk036/ellalgo-rs ๐ฆ
**Repository:** https://github.com/luk036/ellalgo-cpp โก
**Repository:** https://github.com/luk036/ellalgo ๐
**Slides:** https://luk036.github.io/cvx/ellalgo-py-rs-cpp-remark.html
**Comparison doc:** [compare-py-cpp-rs.md](https://github.com/luk036/ellalgo-rs/blob/main/compare-py-cpp-rs.md)
> ๐ก *"Make it work, make it right, make it fast โ and cross-validate in three languages."*