GEMM/SYRK: per-call malloc of the packing buffers page-faults on every call at some sizes under glibc, up to 2.2x slower
When the GEBP packing buffers of a product exceed EIGEN_STACK_ALLOCATION_LIMIT (128 KiB), general_matrix_matrix_product and the SYRK path allocate them with scratch_malloc on every call and free them on return (GeneralMatrixMatrix.h:313-314, GeneralMatrixMatrixTriangular.h:98-99). Under glibc, at some sizes that memory goes back to the operating system on every free, so every call page-faults its packing buffers in again: a double GEMM at n = 192 takes 112 minor faults per call and runs at 0.6 to 0.78 of its rate at n = 184 or 200. Pinning glibc's thresholds (MALLOC_MMAP_THRESHOLD_ and MALLOC_TRIM_THRESHOLD_) removes the faults and the slowdown completely. Which sizes are hit depends on the blocking sizes and on the process's earlier allocations, which glibc's dynamic mmap and trim thresholds follow, so the same product can be fast in one program and slow in another.
Measurements
Square C.noalias() += A * B and C.selfadjointView<Lower>().rankUpdate(A) on MatrixXd, best of five rounds; minor page faults per call from getrusage over 20 calls. Default glibc malloc against MALLOC_MMAP_THRESHOLD_=33554432 MALLOC_TRIM_THRESHOLD_=1073741824 (fixed thresholds also turn off glibc's dynamic adjustment). Master 210e0a609, one core, taskset-pinned.
| Host, build | n | kc, mc, nc | default: GEMM / SYRK GFLOP/s | faults per call | fixed thresholds: GEMM / SYRK |
|---|---|---|---|---|---|
| Arm Cortex-X925, GCC 13.3, NEON | 144 | 144, 144, 144 | 55.7 / 39.3 | 49 | 73.8 / 59.4 |
| 160 | 160, 84, 160 | 74.0 / 59.7 | 0 | 72.2 / 60.1 | |
| 192 | 192, 192, 192 | 57.5 / 41.7 | 112 | 74.7 / 62.3 | |
| 200 | 200, 102, 200 | 73.8 / 63.4 | 0 | 75.1 / 63.1 | |
| 240 | 240, 240, 240 | 59.3 / 44.8 | 193 | 75.1 / 64.8 | |
| 448 | 448, 448, 224 | 75.1 / 52.5 | 752 (SYRK) | 75.7 / 69.0 | |
| 512 | 512, 512, 256 | 74.1 / 53.3 | 992 (SYRK) | 74.4 / 68.4 | |
| 640 | 328, 640, 320 | 75.3 / 71.8 | 0 | 75.2 / 71.3 | |
| Arm Cortex-X925, GCC 13.3, SVE at 128 bits | 192 | 192, 192, 192 | 60.3 / 43.5 | 112 | 78.6 / 65.8 |
| 512 | 512, 512, 256 | 78.4 / 53.6 | 992 (SYRK) | 77.6 / 69.2 | |
AMD Ryzen 7 7800X3D, GCC 15.2, -mavx2 -mfma |
144 | 144, 144, 144 | 37.6 / 24.8 | 49 | 64.4 / 56.0 |
| 176 | 176, 96, 176 | 43.0 / 31.0 | 61 | 64.7 / 57.7 | |
| 192 | 192, 192, 192 | 38.8 / 26.4 | 112 | 65.0 / 57.5 | |
| 200 | 200, 108, 200 | 65.0 / 58.4 | 0 | 65.1 / 58.3 | |
| 240 | 240, 240, 240 | 41.1 / 28.7 | 193 | 64.3 / 58.7 |
Every row with faults recovers fully under fixed thresholds; every row without faults is unchanged. No float cell faulted on the Cortex-X925, and an AVX-512 build on the x86 host showed no step.
The published cells
The comparison harness binaries behind the DGX Spark pages, re-run on the Cortex-X925 on the cells that dipped there; median of three repetitions, GFLOP/s:
| Cell | Binary and cells in the process | default | fixed thresholds |
|---|---|---|---|
| GEMM, double, n = 192 | SVE build, Eigen cells only (as in the SVE passes) | 59.1 | 79.1 |
| GEMM, double, n = 192 | NEON build, Eigen and Arm Performance Libraries cells, n = 160, 192, 200 only | 56.0 | 73.5 |
| SYRK, float, n = 384 | SVE / NEON | 108.2 / 104.5 | 148.7 / 143.2 |
| SYRK, float, n = 768 | SVE / NEON | 126.3 / 119.1 | 157.0 / 151.3 |
| SYRK, double, n = 512 | SVE / NEON | 54.7 / 51.7 | 71.9 / 67.4 |
The neighboring Eigen cells (GEMM at n = 160 and 200; SYRK at n = 256, 500 and 1000 in float and 384, 500 and 768 in double) and the library's cells move by 1.2 % or less.
Where it shows on the website
- Zen 5 AVX2: GEMM in double at n = 192 at 41 GFLOP/s against 75 at n = 160 and 200; SYRK at 27 against 62 and 68 at n = 128 and 200.
- DGX Spark (libeigen/libeigen.gitlab.io!17): the n = 192 step of GEMM and SYRK on the SVE page and the SYRK dips at n = 384 and 768 (float) and 512 (double) on both pages are this effect (table above). In the full campaign runs the SVE processes hit the n = 192 step and the NEON processes did not; the NEON binary hits it as well when it runs those cells alone, so the difference was allocation history, not the instruction set.
- NVIDIA Grace runs with 64 KiB pages, a sixteenth of the faults, and shows the n = 192 step only faintly (GEMM in double, NEON build: 38 against 40).
Possible directions
- Keep the packing buffers alive between calls: a per-thread, grow-only workspace that
gemm_blocking_spaceborrows when it has no buffer of its own. The cost is memory held per thread (mc * kc + kc * ncscalars, which grows with the problem), and it has to stay correct for nested and parallel products. - Or allocate through a size-class cache in
scratch_mallocfor buffers above the stack limit. - Until then, users who call mid-size products in a loop can avoid it with
mallopt(M_MMAP_THRESHOLD, ...)andmallopt(M_TRIM_THRESHOLD, ...), or the environment variables above.
Reproducer
#include <Eigen/Dense>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <sys/resource.h>
using namespace Eigen;
long minorFaults() {
rusage u;
getrusage(RUSAGE_SELF, &u);
return u.ru_minflt;
}
int main() {
for (int n : {184, 192, 200}) {
MatrixXd A = MatrixXd::Random(n, n), B = MatrixXd::Random(n, n), C = MatrixXd::Zero(n, n);
C.noalias() += A * B; // warm-up
const int calls = 200;
const long f0 = minorFaults();
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < calls; ++i) C.noalias() += A * B;
const double t = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count() / calls;
std::printf("n = %d %.1f GFLOP/s %.1f minor faults per call\n", n, 2.0 * n * n * n / t * 1e-9,
double(minorFaults() - f0) / calls);
}
}Run it as is and again with MALLOC_MMAP_THRESHOLD_=33554432 MALLOC_TRIM_THRESHOLD_=1073741824. On an NVIDIA Grace core (64 KiB pages, GCC 13.3) it reports 0, 7 and 6 faults per call at n = 184, 192 and 200 (36.5 and 37.5 GFLOP/s at 192 and 200 against 38.6 and 39.6 with fixed thresholds): with this program's allocation history n = 200 is hit as well.