Status: current · RTX 4090 (sm_89), CUDA 13.2, RelWithDebInfo · measured 2026-09-05; sm_89 tables transcribed 2026-10-06, sm_120 tables deep
Routing and measurements for the seven level-3 ops. symm, syrk, syr2k and trmm choose from tuned tables. hemm, herk and her2k choose by fixed rules in the cuBLAS backend. The sm_89 table rows for the four are transcribed from the deleted hand-written gates (at ff340fc6) and are untimed; the sm_120 tables are deep-measured (tuned/README.md).
src/ops/<op>/choice.hh lists the families. src/ops/<op>/<op>.cc validates shapes (throwing std::invalid_argument on every backend), returns a no-op event for an empty problem, and calls select::run, which takes the first runnable entry of the nearest row of tuned/<op>.<device>.txt. Kernel selection tables lists the families per op.
Every native family requires B == Backend::CUDA, d.is_gpu, homogeneous operands, extents and batch >= 1, and batch <= 65535 (the CUDA grid z; 65536 throws Number of work-groups exceed limit). The vendor family needs d.has_vendor and refuses heterogeneous operands, since each vendor loop runs every item at the top-level extents.
| op | spelling | driver | further can_run terms | dtypes |
|---|---|---|---|---|
| symm | expand | expand_mirrored, then the public gemm | max_wg >= 256; expansion_fits (src/ops/symm/symm.cc:52-56) | float, double |
| symm | vendor | backend::symm_vendor: ?symm per item | float, double | |
| syrk | gram | syrk_gram_tiles: one tile covers C | n <= kGramMaxTile (128); max_wg >= gram_threads(n); tile fits SLM (src/ops/syrk/syrk.cc:58-59) | float, double |
| syrk | triangular | syrk_triangular_tiles: 128x128x8 tiles of one half | max_wg >= 256; T(T+1)/2 <= 65535, T = ceil(n/128) (src/ops/syrk/syrk.cc:62-63) | float |
| syrk | vendor | backend::syrk_vendor: ?syrk per item | float, double | |
| syr2k | triangular | syr2k_triangular_tiles: both products fused | max_wg >= 256; tile ceiling (src/ops/syr2k/syr2k.cc:53-55); a real ConjTrans is folded to Trans | float |
| syr2k | vendor | backend::syr2k_vendor: ?syr2k per item | float, double | |
| trmm | triangular | trmm_triangular_tiles: row tiles that skip the zero half | max_wg >= 256; Side::Left; trmm_tile_groups <= 65535 (src/ops/trmm/trmm.cc:56-61) | all four |
| trmm | expand | expand_triangular, then gemm at beta 0 | max_wg >= 256; expansion_fits (src/ops/trmm/trmm.cc:63-68) | all four |
| trmm | vendor | backend::trmm_vendor: cublas?trmm per item | all four |
A real ConjTrans is Trans to both syrk tile kernels, so the exact trans key keeps it on the vendor. The last resort when nothing runs is expand, vendor (symm), triangular, gram, vendor (syrk), triangular, vendor (syr2k) and expand, triangular, vendor (trmm).
The sm_89 tables (tuned/{symm,syrk,syr2k,trmm}.sm_89.txt: float and double for the three real-only ops, all four dtypes for trmm) are transcribed at ff340fc6. Per grid cell they put the old Auto choice first. The sm_120 tables are deep-measured, with transcribed rows kept where the lattice measured nothing near. From the sm_89 rows:
| op, dtype | ranked first |
|---|---|
| syrk float | gram at every n <= 128; triangular on every sq row above n = 128, and on tall/wide rows with n >= 257, k >= 8 and batch x T(T+1)/2 >= 160; vendor on the other tall/wide rows and every trans C row |
| syrk double | gram at every n <= 128 (N and T); vendor everywhere else |
| syr2k float | triangular at batch >= 2; vendor at batch 1 |
| syr2k double | vendor (the only candidate) |
| symm float | expand on sq rows with batch >= 4 or max(m, n) >= 256; vendor on the other sq rows and every tall/wide row |
| symm double | vendor everywhere, expand second |
| trmm, all four | triangular on every side=L row, expand on every side=R row, vendor last |
A vendor-free build therefore serves every float syrk GPU shape, double syrk at n <= 128, float syr2k at batch 1, float and double symm, and trmm in every dtype. The transcription matches the old Auto choice on 100% of 2500-3000 random off-grid points per op, dtype and device.
BATCHLAS_<OP>_ROUTE takes auto, native, vendor or a spelling from the table above. A spelling that is not compiled for the dtype, or cannot run the shape, throws std::invalid_argument. Pin words are listed at the level-3 pin words.
These three have no choice.hh and no table. Their entry points (src/ops/level3/level3.cc) call backend::<op>_vendor directly, or throw NoRouteError when no level-3 library is compiled in. The cuBLAS backend then picks between one batched GEMM into scratch and the per-item vendor loop:
BATCHLAS_EXPAND_ROUTE=expand|loop overrides the preference, not the fit. The host backend runs the per-item cblas_?hemm/?herk/?her2k loops; ROCm instantiates none of the three.
gram and triangular admit non-float scalars, so double syrk at n <= 128 and trmm in every dtype run vendor-free; syr2k has no non-float family. level3_tile_substitution_enabled (src/select/vendor.hh:96-98) is deliberately kept float-or-cuBLAS, because sytrd_blocked, ortho, ormqr_blocked and the coverage census read it; widening it would move their routes.
Up to ff340fc6 these four ops chose by hand-written gates in src/backends/*_custom_dispatch.cc. The gates were gate-only, so the transcriber evaluated the whole old decision per grid cell. The tables hold the effective window, not the predicates.
The windows below were measured against the old gates. The transcribed sm_89 tables reproduce them cell by cell.
128x128x8 tiles over the triangular tile set. Grid: float, n 64..2048 x batch 1..512, against the full n x n batched GEMM.
| table edge | admit side | bracketing non-winner |
|---|---|---|
| T >= 3 tiles a side (n >= 257) | 1.45x at n=512 batch 512; 1.63x at n=1024 batch 64; 1.71x at n=2048 batch 16 | n = 256: 0.84x–1.22x (depends on the grid's wave boundary) |
| batch x T(T+1)/2 >= 160 | won from 168 blocks up | 144 blocks 1.14x slower; 136 blocks 1.25x slower |
| k | does not enter | not swept here (experiments/syrk_kskew.sh) |
At n = 256 a diagonal tile is computed whole and then masked, so only one tile in four is saved; from n = 384 every saturated shape won. The grid must fill the 128 SMs (two 256-thread blocks each), so below ~160 blocks the route lost. The k >= 8 edge is the old gate's kTriangularTileK, not a measured one.
The sq form key carries the old cuBLASDx-sized heuristic (min_dim*2 >= max_dim && tiled_work >= 8). One cublasSsyrk launch per batch member costs about 9 us, so every sq row from n = 129 up ranks triangular first. The two boundary records differ: the exploration note gives 0.89x at n = 256, 1.28x at 512, 1.57x at 1024; the old gate's comment gives 0.84x–1.22x at 256, 1.45x at 512 batch 512, 1.63x at 1024 batch 64. Neither holds a cell in 257 <= n <= 383, which the rows admit.
A single-tile kernel sized to n: one shared tile covers all of C, so A crosses the bus once. It serves n <= 128, and the rows rank gram first there in float and double. Float, ms (m is the reduction depth, before is the host loop over cublasSsyrk):
| m | n | batch | gemm | before | now | vs gemm | vs before |
|---|---|---|---|---|---|---|---|
| 256 | 32 | 2048 | 0.334 | 33.588 | 0.0780 | 4.29x | 431x |
| 1024 | 64 | 1024 | 0.668 | 60.397 | 0.3326 | 2.01x | 182x |
| 1024 | 128 | 512 | 0.409 | 30.296 | 0.4146 | 0.99x | 73x |
At n = 32 the kernel reads 71 MB in 78 us (933 GB/s, the memory roofline). At n = 128 the prize is 1.37x, not 2x. Double, same shapes (gemm / before / now, ms): 0.901 / 115.40 / 0.837 (1.08x vs gemm), 3.444 / 112.45 / 1.934 (1.78x), 13.72 / 110.91 / 6.521 (2.10x). The win grows in double because FP64 at 1/64 rate makes the Gram product compute bound.
One pass fusing both rank-k products into the same accumulators. Grid: float, n 8..3072 x k 4..2048 x batch 1..1024. Batch is the only key that decides, because the vendor route is one launch per batch member.
Issuing the two products sequentially rather than interleaved is worth 1.53x (3.34 vs 5.11 ms at n=512 batch 512).
The old gate admitted the tile kernel only for m <= 64 || m >= 512, read off a trmm-vs-gemm column. The real choice is between the tile kernel and the expansion. Float, saturating batch, ms:
| m | nC | batch | tile | expand + gemm |
|---|---|---|---|---|
| 128 | 512 | 1024 | 0.698 | 0.784 |
| 256 | 256 | 512 | 0.536 | 0.692 |
| 256 | 1024 | 256 | 0.915 | 0.855 |
The gate cost up to 1.29x on the shapes it meant to protect. The single 7% loss is left unfitted. Every side=L row of every trmm table ranks triangular first.
This is the caller's question, whether to spell a product as trmm at all. Ratios are trmm against the GEMM spelling of the same product; bold is a win.
| m | nC | batch | float | double | complex<float> | complex<double> |
|---|---|---|---|---|---|---|
| 32 | 256 | 2048 | 1.13x | 1.29x | 0.88x | 1.05x |
| 128 | 512 | 1024 | 1.03x | 1.48x | 0.69x | 1.42x |
| 256 | 1024 | 256 | 0.91x | 1.77x | 0.93x | 1.71x |
| 512 | 512 | 128 | 1.15x | 1.91x | 1.10x | 1.85x |
| 1024 | 1024 | 32 | 1.26x | 2.02x | 1.23x | 1.95x |
Measured against a per-batch loop over the vendor's triangular primitive: float cublas?symm over n 16..2048 x batch 1..512, and complex64 cublas?hemm over n 16..512 x batch 1..16. The expansion wins 1.2x–72x everywhere except batch <= 2 && n <= 128, where the call is launch-bound and the expansion loses by up to 2.5x.
The shipped edge is more conservative than that loss region: kExpandMinBatch = 4 and kExpandMinDim = 256 (src/kernels/common/triangular_expand.hh:28-29), used by the float symm rows and by hemm_vendor. Batch 3 at every n, and batch <= 2 with 129 <= n <= 255, take the loop although they lie outside the loss region. That slack is deliberate; the exploration numbers would support 2 and 128. symm ranks vendor first on every tall and wide row (no bracketing cell exists). Double symm ranks vendor first everywhere, and expand for double is unmeasured.
trmm's expand is first on every Right row, because cublas?trmm has a flat ~110 us floor: 1.15x–162x over 49 square cells and 1.22x–32x over 64 skewed cells. No cell goes the other way.
Both replace a per-batch vendor loop with one strided-batched GEMM into scratch plus a fold, because cuBLAS has no batched ?herk or ?her2k. The rule is a conjunction, not a disjunction. herk's predicate is batch >= 4 && n <= 768; the mirrored expansion's is batch >= 4 || max_dim >= 256. The expansion has no large-n ceiling because it is one bandwidth-bound kernel. herk's GEMM computes both triangles and keeps one, so it needs the ceiling where one cublas?herk already saturates the device.
| op | predicate | admit side | bracketing non-winner |
|---|---|---|---|
| herk | batch >= 4 && n <= 768 | 1.6x–72x for batch >= 4 at n <= 512 | batch <= 2 is a wash or a loss at every n; 0.82x–0.93x from n = 896 up |
| her2k | batch >= 2 \|\| n >= 128 | 1.4x–128x | batch 1 at n <= 64 only: 0.74x at n = 32, 0.89x at n = 64 |
Grid: complex64, n 32..1024 x batch 1..256 (experiments/herk_crossover.sh). Gaps: batch 3 is unmeasured (the grid steps 1, 2, 4, 8, 16, 64, 256); 769 <= n <= 895 is unmeasured, since the wash band runs to 768 and the loss starts at 896; the batch-1 side of her2k's n >= 128 has no quoted cell. her2k_gemm_preferred lives in src/expansion_budget.hh so that sytrd_blocked.cc can evaluate the whole route condition (her2k_takes_gemm_route) before it calls her2k.
The reasons behind the tile kernels' shapes. The headers keep the invariants and point here.
src/ops/syrk/kernels/syrk_gram_tiles.hh: a single-tile batched SYRK for the tall skinny A and small square C that ortho supplies.
src/ops/syrk/kernels/syrk_triangular_tiles.hh and src/ops/syr2k/kernels/syr2k_triangular_tiles.hh, sharing the grid in triangular_tiles.hh.
src/ops/trmm/kernels/trmm_triangular_tiles.hh.
trmm_row_tile trades arithmetic against re-reading B: each row tile stages its own copy of the reduction range, up to (R+1)/2 times. Float is bandwidth bound (ridge near 40 flop per byte), so the widest tile that fits wins. Double and complex run at 1/64 rate (ridge near 1.4), so the narrowest tile wins.
Tile16 against tile32, through ormqr_blocked_benchmark (n 256/512, batch 128-256, ib 16/32/64):
| type | tile16 / tile32 | verdict |
|---|---|---|
| double | 1.007x - 1.022x | wins at every ib, including 64 |
| complex<double> | 0.995x - 1.040x | wins to ib 32, a wash at 64 |
| complex<float> | 0.993x - 1.026x | wins to ib 32, a wash at 64 |
| float | 0.966x - 0.997x | loses everywhere (the B re-read) |
Shipped thresholds: wide types take 16 through m = 64; complex takes 16 only through m = 32; float takes 32 through m = 32, 64 through m = 512, and 128 above. Wide types never take 128: an 8x8 tile in complex<double> is 256 accumulator registers, and 256 threads x 256 is the whole 65,536 a work-group gets, so the runtime rejects the launch. BATCHLAS_TRMM_TILE_M pins the tile for sweeps.
src/kernels/common/triangular_tiles.hh, shared by every tile kernel.
A specialised level-3 op beats the GEMM it replaces only when it reaches a batched native kernel. Where the row or rule does not reach one, the op becomes a host loop one to two orders of magnitude slower at batch 1024 and up. Check which family ran with BATCHLAS_SELECT_TRACE=1.
src/ops/ortho/ortho.cc builds C = A^H A three times. Plain syrk was a 70–100x regression at these shapes, because a skinny Gram matrix failed both routers and dropped to one cublasSsyrk per batch member (m=256 k=32 batch 2048: GEMM 0.350 ms, syrk 33.73 ms). That motivated syrk_gram_tiles. Once it existed the substitution was taken: at k = 32 float 1.450 -> 0.895 ms (1.62x), and ShiftChol3 1.998 -> 1.298 ms (1.54x) at m = 1024 batch 512. gram_max_k is 64 for float and 128 for double (src/ops/ortho/ortho.cc:144): float k=128 is 0.96x, double k=128 is 1.34x. svqb_alg keeps its GEMM, since a one-triangle result would multiply uninitialised workspace. The precision table is at the Gram matrix through syrk, per precision.
Built and rejected. In complex float the conjugating Gram route loses to GEMM-plus-Hermitian-fold at every shape: 0.217 vs 0.206 ms at n=32 batch 2048, and 2.08 vs 1.57 ms at n=128 batch 512. A complex multiply is four real ones, so herk is compute bound where real syrk is bandwidth bound. The route stays reachable as BATCHLAS_SYRK_ROUTE=gram so the conjugation stays under test.
Before the tile kernel, trmm for W2 = T^H W1 lost at every shape. Re-measured with the tile kernel against BATCHLAS_ORMQR_WY=gemm (ABBA order, Side::Left, ConjTrans, two agreeing passes), gemm/trmm over all cells:
| type | gemm/trmm | verdict |
|---|---|---|
| float | 1.006x–1.046x | wins everywhere |
| double | 1.004x–1.016x | wins everywhere; newly enabled |
| complex<float> | 0.944x–0.995x | loses everywhere; excluded |
| complex<double> | 0.958x–1.010x | loses at ib = 16; excluded |
| netlib float / netlib double | 0.336x–1.199x / 0.379x–1.064x | excluded (per-batch cblas loops both ways) |
wy_trmm_applicable (src/ops/ormqr/kernels/ormqr_blocked.cc:52) is therefore per type, plus ib <= 64. The effect on syev is small (1.036x at n=64 batch 2048, 1.003x at n=512). The 16-row tile moved double to 1.013x–1.036x and closed complex<double>'s ib = 16 hole to parity; it is kept for double.
The 128-wide Gram tile first split each thread's 8 rows into two 4-wide bands 64 apart, as the square kernels do. That is incompatible with taking the triangle at thread-tile granularity: thread (0,1) owns element (64,4), in the lower triangle while its tile is not, so nothing wrote it. It was silent and appeared only at n > 64. SyrkTest.NarrowShapesMatchGemmReference (tests/syrk_tests.cc:79) sweeps n in {24,32,48,64,96,128} x trans x uplo at k = 200 and mirrors both sides before comparing.
HerkTest could not catch conjugating the wrong operand, which returns conj(C): still Hermitian and consistent. HerkTest.MatchesGemmReference (tests/herk_tests.cc:265) compares against a GEMM and fails when the conjugation flips.
Other trmm tests used RandomTriangular, already zeroed opposite the triangle, which passes an implementation that ignores uplo and diag. TrmmTest.IgnoresUnreferencedTriangleAndUnitDiagonal (tests/trmm_tests.cc:116) poisons the forbidden storage and compares against a GEMM on the clean A, over both sides, uplo, diag, three transposes and ragged shapes. On CUDA it re-runs with BATCHLAS_EXPAND_MAX_BYTES=0 to reach the cublas?trmm loop.
SytrdBlockedTest.TrailingUpdateRoutesAgree (n=320, nb=32) checks the syr2k route against the GEMM route. Forcing alpha = -0.5 fails it (worst eigenvalue error 2.777 against a 3.2e-3 bound; the GEMM route is at 2.6e-6). The assertion is relative: 4 * (GEMM route error) + 8 eps ||A||. The same change removed a symmetrize pass: float n=512 batch 1024 goes 263.97 -> 227.51 ms (nb=16). The gate stays CUDA with float or complex<float> (rank2k_trailing_update_supported, sytrd_blocked.cc:785-787). In double it inverts: 7.56 vs 58.52 ms at n2=256 batch 1024 (7.7x slower), because double syr2k has only the per-batch vendor loop.
The generic fallbacks in src/extensions/syrk.cc and syr2k.cc once wrote both triangles and overwrote the half the caller owns. Both files are deleted: no backend instantiated them, and they redefined the public templates. fold_symmetric_product_into_triangle (src/ops/potrf/kernels/symmetric_product_fold.hh, still used by potrf_blocked) folds only the named triangle: C(i,j) := product(i,j) + beta*C(i,j). With beta == 0, C is not read, so a poisoned C cannot produce NaN.
cuBLASLt, reached through cublas?her2k, reads the host alpha with a 16-byte aligned vector load. std::complex<double> is 8-byte aligned, so the call faults when the address lands at 8 mod 16. It is shape-dependent and reproduces against cuBLAS 13.2 with no BatchLAS present. The fix is alignas(16) T alpha_aligned = alpha in her2k_vendor (cublas.cc).
Each item is a decision a future edit could undo by accident.
src/backends/level3_shape.hh holds the shape contracts once: squareness first, then batch, then shapes. Only the exception type differs by backend (template parameter E): cuBLAS and rocBLAS throw std::invalid_argument, netlib's host tasks throw std::runtime_error.
src/kernels/common/triangular_expand.hh and src/expansion_budget.hh. Consumers are symm's and trmm's expand families (expansion_fits in can_run) and the fixed rules of hemm_vendor, herk_vendor and her2k_vendor.
src/extensions/trmm.cc ended its recursion at n <= 256 in a plain gemm with beta = 1 on the diagonal block, reading A's unreferenced triangle. It was never instantiated by any backend and is deleted; every trmm goes through src/ops/trmm/trmm.cc.
Raw data is at the git tag perf-evidence/vendor-independence (git show <tag>:<path>). Per-cell CSV captures were not committed; the numbers on this page come from the distilled notes and the route comments.
| topic | path |
|---|---|
| syrk gram tiles; trmm tiles; per-dtype tables; the 16-row tile; ortho wiring; ormqr WY; syev traces | experiments/TRMM_SYRK_BATCHED_KERNELS.md |
| the 73-call-site substitution survey; sytrd syr2k trailing update; ortho gram and WY-factor rejections | experiments/GEMM_TO_LEVEL3_SURVEY.md |
| syrk and syr2k crossover drivers | experiments/syrk_sweep.sh, experiments/syrk_kskew.sh, experiments/syr2k_sweep.sh |
| herk/her2k expand-vs-loop driver; the exclusive-GPU guard | experiments/herk_crossover.sh, experiments/gpu_guard.sh |
| Design pass; vendor-free failing set and NoRouteError census | WP1_LEVEL3_SPEC.md, VENDOR_FREE_BASELINE.md |