Repository navigation
perf: add contiguous fast path to complex add/subtract fallback loop - #278
intel-python-devops wants to merge 6 commits into
Conversation
|
unsure if auto-generated PRs like this should be putting these changes into the CHANGELOG or not. Thoughts? |
Tough call, I would say ideally yes but I would want to make sure we have some kind of anti-AI-slop-text skill or guard before that |
- Take the flat loop only when operands are disjoint or exactly in-place; accumulate (out == in1 + 1) goes back to the strided loop. Under GCC, NPY_PRAGMA_VECTOR (ivdep) gave wrong accumulate results. - Drop the pragma; it did not change icx codegen and added two -Wpass-failed warnings for CDOUBLE. - Give out == in1 and out == in2 their own loops so the runtime alias check does not send in-place calls to the scalar fallback. - Add tests for the contiguous and accumulate paths, and a changelog entry.
There was a problem hiding this comment.
Requesting changes. The new loops are correct, but measurements on a Lunar Lake host show two slowdowns against main, and the benchmark table requested earlier is no longer in the description. The inline comment on mkl_umath_loops.c.src has a patch that removes both slowdowns; it was measured below as V3b.
Correctness: no issues. This PR passes the full suite (412 tests), bitwise comparisons against NumPy covering every aliasing combination, n = 0 to 200003, ±0, inf and NaN, and an accumulate/cumsum check. That holds on icx, gcc, gcc -mavx2 and clang 23. The original GCC accumulate bug is fixed, and test_accumulate_complex fails on a GCC build of acced6b, as stated.
Setup. Core Ultra 5 236V, Lunar Lake, Ubuntu 25.10. conda-forge icx 2026.1.1, NumPy 2.3.5, MKL 2026.1.0 (gnu_thread). Every build uses the same command with conda-build's default CFLAGS (-march=nocona -mtune=haswell -O2 …). Runs are pinned to P-core 2 with the performance governor and turbo off, and builds are interleaved: 21 reps for the inner loop (each build's libmkl_umath_loops.so loaded with dlopen) and 11 reps through NumPy (equal-length site paths). Results are bootstrap 95% CIs, judged against a main-vs-main noise band of ±3%. A confirmation pass at the default turbo settings gave the same verdicts.
Results. Time relative to main, so below 1 is faster. Ranges span sizes and both complex dtypes, and bold marks slower than main beyond the noise band.
- V2 = this PR at 2bbe407.
- V3a = V2 + Fix A.
- V3b = V2 + Fix A + Fix B, the inline patch.
| Path | V2 | V3a | V3b |
|---|---|---|---|
out=c, f(a,a,out=c), NumPy, n ≥ 512 |
0.60–0.99 | 0.60–0.99 | 0.60–0.99 |
out=a / out=b, NumPy, n ≥ 512 |
0.58–0.94 | 0.58–0.88 | 0.58–0.95 |
out=c, inner loop, n ≥ 512 |
0.40–1.01 | 0.40–1.01 | 0.41–1.01 |
All modes except z += z, NumPy, n ≤ 64 |
0.96–1.02 | 0.96–1.02 | 0.96–1.03 |
f(a,a,out=a) (z += z), NumPy, n ≥ 512 |
1.23–1.86 | 0.41–0.85 | 0.41–0.86 |
f(a,a,out=a), NumPy, n = 64 |
1.12–1.15 | 0.97–1.02 | 0.97–1.03 |
f(a,a,out=a), inner loop, n ≥ 512 |
0.99–1.68 | 0.32–0.50 | 0.32–0.50 |
Strided / scalar-broadcast operands (unchanged BINARY_LOOP), inner loop, n = 64–1000 |
0.95–1.13 | 0.95–1.13 | 0.96–1.02 |
| Same, n = 8 | 0.94–1.19 | 0.96–1.19 | 0.96–1.06 (+0.3–0.7 ns) |
NumPy complex64 a - scalar, n = 1000 |
1.035 [1.031, 1.038] | 1.077 [1.069, 1.078] | 1.015 [1.009, 1.026] |
| oneMKL path, n = 100001 and 1e6, 1 and 4 threads | 0.98–1.02 | 0.98–1.02 | 0.98–1.02 |
accumulate / cumsum, n = 1000 |
1.00 | 1.00 | 1.00 |
| npbench mandelbrot2 M / L | 0.99 / 1.00 | 0.99 / 1.00 | 0.99 / 1.00 |
An earlier run on V2 measured further legacy paths, all within noise: complex multiply, add/subtract reduce, real float32/float64 add/subtract/multiply/divide, sin/exp/log/sqrt, the full ASV suite (174 benchmarks) and npbench.
Causes. Both are visible in the disassembly.
z += z. All three pointers are equal, so theout == in1loop reads through two pointers that alias. icx's runtime overlap check fails, and it runs its scalar fallback one element at a time. Fix A gives this case a single-pointer loop, which vectorizes (addps/addpd) with no overlap check.- Strided / scalar-broadcast operands. The
BINARY_LOOPin the same functions wasn't edited, but adding the flat loops changed its register allocation. Fix B moves the flat loops into aNPY_NOINLINEhelper, which restoresmain's code for that loop. Instruction counts for the loop body / remainder loop:
| Function | main | V2 | V3a | V3b |
|---|---|---|---|---|
| CFLOAT_add | 27 / 15 | 29 / 15 | 29 / 15 | 27 / 15 |
| CFLOAT_subtract | 29 / 15 | 35 / 24 (+imul) |
35 / 24 | 27 / 15 |
| CDOUBLE_add | 33 / 15 | 35 / 15 | 35 / 15 | 33 / 15 |
| CDOUBLE_subtract | 35 / 15 | 41 / 24 (+imul) |
41 / 24 | 33 / 15 |
What V3b still costs.
- In the bare inner loop at n ≤ 8, +0.3–2.5 ns per call for the extra function call. This doesn't show through NumPy.
- complex64 subtract
out=bat n = 512–4096 is up to 18% behind V2's speedup through NumPy, though still 0.60–0.95×main. The vector loops are instruction-identical to V2's, so this looks like a code-placement effect.
The dropped benchmark table, checked. It used speedup (higher is better):
| Claim | Measured |
|---|---|
In-place out=a / out=b 1.2–2.1x at n = 1024 / 16384 / 100000 |
Confirmed: 1.13–1.60x through NumPy, 1.17–2.41x inner loop. complex128 at n = 100000 is 1.13–1.18x. |
| complex64 add out-of-place 1.48x at n = 1024 | Confirmed: 1.49x |
| complex64 add out-of-place 1.94x at n = 16384 | Not reproduced: 1.54–1.61x on this host |
| ~1.0x at n = 64 | Confirmed |
| mandelbrot2 1.01x | Confirmed |
Requested changes
- Apply the inline patch (V3b), or an equivalent that removes both slowdowns. Add
z += zand strided/scalar-broadcast rows to the benchmark table. - Restore the Benchmarks section of the description. It was added in the 2026-10-09 18:14 edit and removed in the 18:42 edit, so "The complex128 gains below" now refers to nothing. Also, "bitwise comparison" is really exact value equality.
- Tests: see the inline suggestions for
in1 is in2coverage (includingz += z), n = 10**5 (the largest size this branch handles), and an optional signed-zero test.
FYI, outside this PR:
- No CI job builds with GCC, so
test_accumulate_complexcan only catch the original bug on a local GCC build. - The GitHub Actions
test_windowsjobs run zero tests.conda activatewithoutcallends the cmd step before pytest starts; see thetest_windows (3.13)log on this PR.
… line, add a z += z loop and tests
|
Re-measured at 1346ce2 (e5bf8d2 plus the merge of Setup. Core Ultra 5 236V, Ubuntu 25.10. conda-forge icx 2026.1.1, NumPy 2.5.4, MKL 2026.1.0 ( Gains. Time relative to
At n = 64, complex128 is 0.96–0.99 through NumPy and 0.45–0.59 in the inner loop. Unchanged paths.
The inner-loop spread on strided and scalar operands (at most +1.4% at n ≥ 64, +0.5 ns at n = 8) is code placement: the decoy build below moves unrelated cases by up to ±2% as well. What's left: complex64 at n = 8–64 through NumPy. These calls are 1.00–1.04 against I don't think this needs a change. It's at most 4% on calls that are almost all NumPy overhead, the gain starts between n = 64 and n = 512, and a size threshold would still keep ~1% from the layout change while giving up the complex128 gain at n = 64. It may be worth a row in the description's table. |
|
Lets wait for #292 merged first to have the ASV benchmark results |
Note
This pull request is AI-generated. Please review thoroughly.
Adds a contiguous fast path to the complex
add/subtractloops (CFLOAT,CDOUBLE) for calls that don't take the oneMKL VM path, i.e. contiguous arrays withn <= VML_ASM_THRESHOLD(100000). Before this change those calls fell through to the stridedBINARY_LOOP.can_vectorize: contiguous, and each input either disjoint from the output or exactly the output. Partially overlapping operands, such asaccumulate/cumsum(out == in1 + 1), still takeBINARY_LOOP.2*nscalars. Since +/- is componentwise and each element is computed independently, results equal the previous loop's for the layouts this branch accepts.NPY_NOINLINEhelper,contig_<kind>_<TYPE>, so the stridedBINARY_LOOPin the caller compiles exactly as onmain. Inlining them changed its register allocation and slowed strided and scalar-broadcast operands by a few percent.out is in1,out is in2, andout is in1 is in2(z += z) each get their own loop. With a single three-pointer loop, icx's runtime alias check seesout == inas an overlap and runs its scalar fallback, which made in-place calls slower thanmain; gcc and clang vectorize the three-pointer loop either way.NPY_PRAGMA_VECTORis#pragma GCC ivdepunder GCC, which is unsafe for partial overlap. Under icx it did not change codegen and added-Wpass-failedwarnings forCDOUBLE. The icx warning count is unchanged frommain(99, of which 91 are-Wpass-failed).Codegen (icx 2026.1.1, conda-forge CFLAGS
-march=nocona -mtune=haswell -O2), from the disassembly oflibmkl_umath_loops.so:addps/subps), with a runtime dependence check on the two-pointer loops.addpd/subpd). The complex128 gains below come from that.Benchmarks
Time relative to
main, so below 1 is faster. Ranges span the sizes listed and both complex dtypes, foraddandsubtract, callingmkl_umath._ufuncsdirectly.Setup: Core i7-1185G7 (Tiger Lake) under WSL2, conda-forge icx 2026.1.1, NumPy 2.5.3, MKL 2026.1.0 (
gnu_thread), conda-forge default CFLAGS,MKL_NUM_THREADS=1, pinned to one core withtaskset. Three builds (main, the previous revision 2bbe407, this revision) were run interleaved over 8 rounds; each number is the per-build minimum over rounds (min of 9 repeats each) divided by themainminimum. A duplicatemainbuild run the same way gives the noise floor: 0.85–1.14 across all cases, 0.94–1.08 for 90% of them. WSL2 does not expose the CPU governor, so turbo stayed on.out=c(disjoint), n = 512–100000out=a/out=b(in-place), n = 512–100000f(a, a, out=c), n = 512–100000f(a, a, out=a)(z += z), n = 512–100000a[::2]operands (unchangedBINARY_LOOP), n = 8–100000BINARY_LOOP), n = 8–100000accumulate, n = 1000By dtype for this revision at n = 512–100000: complex64
out=c0.45–0.89, in-place 0.43–0.89,z += z0.52–0.79; complex128out=c0.60–0.89, in-place 0.55–0.84,z += z0.37–0.73. Thez += zslowdown of 2bbe407 was complex64 only (1.20–1.89); complex128 was 0.95–1.15. The strided, scalar-broadcast, oneMKL andaccumulaterows for this revision are inside the noise floor. The few-percent regression on strided and scalar-broadcast operands that @antonwolfy measured for 2bbe407 is below what this host can resolve.Tests
test_complex_contigcovers out-of-place,out=aandout=bat n = 1, 7, 100, 1001 and 10**5 (the largest size this branch handles) with exact value equality against NumPy (assert_array_equal).test_complex_contig_same_inputcoversin1 is in2, both out-of-place andz += z.test_complex_contig_signed_zerochecks every (±0, ±0) pair by sign bit, sinceassert_array_equaltreats -0.0 and 0.0 as equal.test_accumulate_complexcovers the partial-overlap path; it fails on a GCC build of an earlier revision of this PR (acced6b) and passes with this one.The full suite passes on builds with icx 2026.1.1 and gcc 16.2 (conda-forge).