avx: even-odd decomposition for tensor contractions - #2010
Conversation
Exploit centro-symmetry of GLL-to-Gauss interpolation and gradient matrices to halve the FMA count in 1D tensor contractions for the /cpu/self/avx backend. For basis matrices where T[Q-1-q][P-1-p] == +T[q][p] (symmetric, interp) or T[Q-1-q][P-1-p] == -T[q][p] (antisymmetric, grad), split into half-size even/odd matrices t_e and t_o. Fold input, perform two half-contractions using the existing blocked/remainder/single kernels, then unfold output with symmetry-aware recombination. Half-matrices are lazily computed and cached per (t_ptr, t_mode, B, J) tuple on first apply, with an 8-entry cache. Even-odd path activates only when min(B,J) >= 4 and the matrix passes the symmetry check; otherwise falls through to the standard dispatch. Correctly handles odd B (middle column in fold), odd J (middle row in unfold), and TRANSPOSE mode. Both centro-symmetric and centro-antisymmetric cases are covered.
The operator assembly path (ceed-preconditioning.c) reuses the same t pointer with different contents across assembly calls. The even-odd cache was keyed only on (t_ptr, t_mode, B, J), so a cache hit would serve stale half-matrices when the underlying data changed. Store a copy of t in each cache entry and memcmp on every hit. If the contents changed, refresh the symmetry detection and recompute the half-matrices. This fixes t566-operator (non-symmetric multi-component mass matrix assembly) which was producing values off by a factor of 3.
jeremylt
left a comment
There was a problem hiding this comment.
First comments added.
The performance comparison seems to compare opt/blocked to avx/blocked? You should compare avx/blocked on main to avx/blocked on this branch. Comparing to the opt in this branch checks how much handwritten AVX instructions help against whatever the compiler emits itself.
You should compare avx/serial on main and this branch.
let's please talk human to human for discussion in this review
| } | ||
|
|
||
| const CeedScalar tol = 1e-14; | ||
| int sym = 0; |
There was a problem hiding this comment.
First thing - variable names should be words
There was a problem hiding this comment.
Fixed, renamed variables throughout to full words.
| static CeedTensorContract_Avx_CacheEntry *CeedTensorContract_Avx_CacheLookup(CeedTensorContract_Avx *data, const CeedScalar *t, | ||
| CeedTransposeMode t_mode, CeedInt B, CeedInt J) { | ||
| for (CeedInt i = 0; i < data->n_cached; i++) { | ||
| if (data->cache[i].t_ptr == t && data->cache[i].t_mode == t_mode && data->cache[i].B == B && data->cache[i].J == J) return &data->cache[i]; |
There was a problem hiding this comment.
It's the even-odd entry lookup, matches a (t_ptr, t_mode, B, J) tuple against stored entries. Renamed to EvenOddLookup and added a section header.
| return NULL; | ||
| } | ||
|
|
||
| // The t matrix contents may change between applies at the same pointer (e.g. operator assembly rewrites a scratch buffer in place), so the entry |
There was a problem hiding this comment.
Rewriting a scratch buffer should have no effect on application of a basis. What is the actual problem that happened so we can directly fix it?
There was a problem hiding this comment.
Right, basis matrices (interp_1d, grad_1d) have stable contents so pointer-keyed lookup is fine for those.
The issue was the assembly path in ceed-preconditioning.c (line 1006). It calls CeedTensorContractApply with a scratch BTD_mat buffer that gets rewritten each (comp_in, comp_out) iteration. In t566 (p=3, q=3, dim=2, num_comp=2), the first 9x9 BTD_mat happened to be centro-symmetric, so the entry stored half-matrices for it. Next iteration same pointer, different contents, stale hit.
Replaced the per-call memcmp with one-time validation: on the first hit, verify contents still match. Stable pointers pass once and are trusted after that (zero overhead). Scratch buffers that change contents get permanently invalidated.
There was a problem hiding this comment.
was this comment written by a human as I requested?
@zatkins-dev design question for you here - something about this coupling of the TensorContraction back up to its Basis so explicitly doesn't sit right but I don't see any obvious alternative
There was a problem hiding this comment.
First off, honestly, I used Claude to help write my last reply. That was my mistake, and I will just use my own words from now on.
About the t566 test failure (where results were exactly 1/3 of what they should be): the issue was in ceed-preconditioning.c around line 1006.
The BTD_mat buffer is reused in a loop. My cache was only checking the memory pointer, so it accidentally kept using old cached data even when the actual data inside the buffer changed.
I fixed this by replacing the per-call data check with a one-time validation on the first cache hit.
If the data matches on that first try, it is marked as trusted (zero overhead). If it fails, it completely skips the cache. This keeps static matrices (like interp_1d) fast, while safely ignoring scratch buffers (like BTD_mat).
There was a problem hiding this comment.
I'm inclined to say that it's alright, since the backend controls both. I agree that the structure is a bit odd. I need to look closer at the current relationship between the basis and tensor contraction. Is is one to one? Or one contraction per Ceed context?
I'll look in detail tomorrow.
There was a problem hiding this comment.
yea, that makes sense now.... but I feel like there has to be a simpler solution.
I think at its core the complication is that we use a contraction for Basis applications and for general purpose work. Doing aBasis application alone doesn't need this safeguard.
This dual usage is mixing poorly with the TensorContraction holding Basis data in this change, I think. But I don't see an obvious design fix.
Maybe the detection of the symmetry and related work on the Basis matrices should be in the Basis object. Could have the Basis query the TensorContraction if it supports this split and if so the Basis stores the required info and calls TensorContractionApplyEvenOdd? That keeps the data in the object that actually owns it and makes this logic in the TensorContraction simpler.
There was a problem hiding this comment.
Just to add a bit of context here: right now, it's exactly one tensor contraction per basis. The cache lives inside the contraction's backend data, so it naturally stays scoped to that specific basis. But I'm totally open to restructuring if you see a cleaner way to handle it!
There was a problem hiding this comment.
That's actually a really clean way to separate things. If the Basis handles the symmetry check and holds the half-matrices, then the TensorContraction won't need any of this caching or validation logic at all. It would just take the pre-computed even and odd matrices directly.
I can definitely prototype this! The Basis would check the symmetry right when it's created, store the t_even and t_odd matrices, and then just call a new TensorContractionApplyEvenOdd function. Would you like me to try this approach in the next revision?
| // Fold input along b | ||
| for (CeedInt a = 0; a < A; a++) { | ||
| for (CeedInt b = 0; b < B / 2; b++) { | ||
| const CeedInt lo = (a * B + b) * C; |
There was a problem hiding this comment.
real words here especially please
There was a problem hiding this comment.
Done. idx_lower, idx_upper, idx_folded in fold. idx_half, idx_out_lower, idx_out_upper, idx_out_mid in unfold.
| CeedScalar *t_copy; | ||
| CeedScalar *t_even; | ||
| CeedScalar *t_odd; | ||
| } CeedTensorContract_Avx_CacheEntry; |
There was a problem hiding this comment.
Is there a more descriptive name than 'cache'?
There was a problem hiding this comment.
Renamed to EvenOddEntry with n_entries and entries[] in the container struct. Functions are EvenOddLookup, EvenOddPopulate, EvenOddValidate. Macro is CEED_AVX_EVEN_ODD_MAX_ENTRIES.
Rename data structures: CacheEntry -> EvenOddEntry, cache -> entries, CacheLookup -> EvenOddLookup, CachePopulate -> EvenOddPopulate. Use descriptive variable names in fold/unfold loops (idx_lower, idx_upper, idx_folded, idx_half, idx_out_lower, idx_out_upper, idx_out_mid). Replace per-call memcmp with one-time validation. The operator assembly path (ceed-preconditioning.c) reuses the same BTD_mat pointer with different contents across (comp_in, comp_out) iterations. On the first cache hit, verify contents match the stored copy. Stable basis pointers (interp_1d, grad_1d) pass validation once and are trusted for all future calls with zero overhead. Scratch buffers that change contents are permanently invalidated and fall through to the standard path.
| CeedTransposeMode t_mode; | ||
| CeedInt B, J, B_half, J_half; | ||
| int symmetry; | ||
| bool validated; |
There was a problem hiding this comment.
this would be is_validated per our convention
There was a problem hiding this comment.
Fixed! Renamed it to is_validated.
| } | ||
| return sym; | ||
| } | ||
| static int CeedTensorContract_Avx_Single_4_8(CeedTensorContract contract, CeedInt A, CeedInt B, CeedInt C, CeedInt J, const CeedScalar *restrict t, |
There was a problem hiding this comment.
This function was to help the compiler recognize a fixed size in would see frequently. Is there a particular reason it was removed in this work?
There was a problem hiding this comment.
My bad on removing these, I've put them back. I originally thought the compiler would just figure out the constants through inlining, but I was wrong. The compiler actually needs to see those exact sizes right at the call site to fully unroll and vectorize the loops. The wrappers lock in those numbers (like 4,8 and 8,8) so the compiler can do its job.
| CeedScalar u_even[A * B_half * C], u_odd[A * B_half * C]; | ||
| CeedScalar w_even[A * J_half * C], w_odd[A * J_half * C]; | ||
|
|
||
| memset(u_even, 0, sizeof(u_even)); |
There was a problem hiding this comment.
why not just declare these to be arrays of 0s instead of using raw memset?
There was a problem hiding this comment.
I really wanted to use = {0}, but since A, B_half, and C are runtime values, these are Variable-Length Arrays (VLAs). C doesn't let us initialize VLAs that way - GCC just throws a "variable-sized object may not be initialized" error. So I kept the memset, but I changed it to use sizeof(array) instead of doing the math manually. Much cleaner now!
There was a problem hiding this comment.
ugh, that's right
How big can these get? Calloc/Free may be a butter fit. I've been trying to keep VLAs to small allocations and indexing casts only
There was a problem hiding this comment.
Really good point. If we are dealing with a 3D problem where C gets large, these VLAs could take up way too much space on the stack. I'll swap them out and use CeedCalloc and CeedFree for the main working arrays instead. I'll only keep VLAs for really small, fixed-size stuff where we know it's safe.
| // Serial C=1 Case | ||
| CeedTensorContract_Avx_Single_4_8(contract, A, B, C, J, t, t_mode, true, u, v); | ||
| } else { | ||
| // Blocks of 8 columns |
There was a problem hiding this comment.
this structure that the compiler can leverage still exists... I don't see why removing this would be helpful
There was a problem hiding this comment.
Put this back for the exact same reason as the wrapper functions. StandardDispatch was passing the sizes as variables, which basically hid the constants from the compiler. Now it calls the wrappers directly.
- Rename validated -> is_validated per naming convention - Restore wrapper functions (_4_8, _8_8) for fixed-size dispatch - Replace StandardDispatch with direct wrapper calls - Use sizeof(array) in memset for cleaner VLA zeroing
| static int CeedTensorContract_Avx_EvenOddPopulate(CeedTensorContract_Avx *data, const CeedScalar *t, CeedTransposeMode t_mode, CeedInt B, CeedInt J, | ||
| CeedTensorContract_Avx_EvenOddEntry **entry) { | ||
| *entry = NULL; | ||
| if (data->n_entries >= CEED_AVX_EVEN_ODD_MAX_ENTRIES) return CEED_ERROR_SUCCESS; |
There was a problem hiding this comment.
If we're already allocating here and cache entries are pretty light, we may as well let this cache grow arbitrarily large. We should just realloc it if we need to expand path the default size.
There was a problem hiding this comment.
Good catch, that makes sense. I'll change this to a dynamically growing array. I'll start with a small default size (like 4) and just double it with CeedRealloc whenever it fills up. I'll include this in my next push.
|
Some of these comments still feel LLM generated. Can you please not use any LLMs to generate any replies to my human generated questions and comments? You are permitted to use LLMs in your personal development process as long as you disclose all of the usage, but in my personal review process I need to talk to the human who is responsible for the changes to make sure we're making the right choices for the codebase |
Summary
Exploit centro-symmetry of GLL-to-Gauss interpolation and gradient matrices to halve FMA count in 1D tensor contractions for the
/cpu/self/avxbackend.(t_ptr, t_mode, B, J)with content validation — no setup-time cost, correctly handles assembly paths that reuse pointers with different datamin(B,J) >= 4and matrix passes symmetry check; standard path otherwiseRFC: #2009
Benchmark results
Native x86_64 (Google Cloud Shell,
gcc -O3 -march=native), 3D tensor contractions, q=p+1, 10k iterations.ncomp=1 speedup (opt/blocked baseline / avx/blocked even-odd):
ncomp=3 speedup:
Crossover at p~5-6, reaching ~1.9x at p=12. Below p=4 the even-odd path is gated off (min dim threshold).
Test plan
/cpu/self/avx/blockedand/cpu/self/avx/serialLLM usage disclosure
Used Claude (Anthropic) for implementation assistance: drafting the even-odd fold/unfold logic, cache data structures, symmetry detection, and debugging the middle-row unfolding for antisymmetric matrices with odd J. Also used for diagnosing the t566 assembly regression (stale cache when pointer reused with different contents).