This note proposes a faster JAX forward/backward algorithm than the current
stack_sort_merge and search_update_merge paths.
The main target is not only semantic cleanup. The main target is to reduce the amount of full-array sorting and reshuffling done at every Pauli rotation step.
Current shape:
- split one operator into two rotated branches
- concatenate both branches
- lexicographically sort all rows
- merge duplicates with
segment_sum - sort again by coefficient magnitude
- keep a power-of-two prefix
Strengths:
- simple merge logic after the split
- easy to reason about threshold-by-magnitude
- easy to compute truncation info from the tail
Weaknesses:
- it pays for two global reorderings of the whole state:
- one
lexsort - one
argsort(-abs(c))
- one
- it always materializes both rotated branches before merging
- it moves a lot of memory even when only part of the operator changes
Current shape:
- keep the stored operator lexicographically sorted
- generate conjugated partner rows
- find partners with binary-search-style duplicate lookup
- update existing coefficients in place
- append only missing partner rows
- lexsort the merged result again
Strengths:
- avoids explicitly splitting into two full branch operators
- uses the sorted structure of the stored state
- naturally fits the "search/update" idea
Weaknesses:
- still does full-array concatenation and lexsort
- still rebuilds and resorts the full merged state each step
- current truncation handling is semantically different from
stack_sort_merge
The main bottlenecks are the global data-movement operations, not the small masking logic:
jnp.lexsort(...)over all rowsjnp.argsort(-jnp.abs(c_concat))over all coefficients- full-array
concatenate(...) segment_sum(...)over a fully materialized concatenated array- scatter/repack of all
xzrows - shape changes across powers of two, which trigger recompiles in several jitted functions
In short:
- sorting the whole state is expensive
- rebuilding the whole state is expensive
- changing array shapes is expensive
Any faster design should reduce at least one of those three.
The current two methods each preserve only one useful property:
stack_sort_mergepreserves coefficient-ranked storagesearch_update_mergepreserves lexicographically sorted storage
For performance, lexicographically sorted storage is more useful than coefficient-ranked storage, because:
- partner lookup depends on row identity, not on coefficient order
- duplicate merging depends on row identity, not on coefficient order
- keeping lexicographic order avoids one global reorder in future steps
So the next algorithm should start from the search_update_merge idea, not from
the stack_sort_merge idea.
search_update_compact
Keep the operator in lexicographic order permanently, and avoid global coefficient sorting. Instead, do:
- update matched rows in place
- append only genuinely new rows
- merge duplicates only for the appended part versus the base part
- compact the state with a selection-based cutoff instead of full sorting by magnitude
The key change is:
- do not rank the entire operator by
|c| - only decide which rows to keep when storage pressure requires compaction
For one rotation step:
- start from a lexicographically sorted
(xz_array, c_array) - compute conjugated partner rows
xz_array_conj - find existing partners with
find_row_duplications(...) - update coefficients of existing rows directly
- build only the missing inserted rows
- sort the inserted rows lexicographically
- merge the base rows and inserted rows with a two-way merge
- if the state size is still under capacity, stop here
- if the state size exceeds capacity, compact using a selection-style cutoff
The important change is that step 7 is not "concatenate everything and lexsort again". It should be a merge of two already sorted arrays:
- base rows remain lexicographically sorted
- inserted rows are sorted once
- then merged like a merge-sort merge
That should be cheaper than a full lexsort of the whole state.
Use the same structural plan as forward:
- update
c_array - update
grad_c_array - build only missing inserted rows for both value and grad
- sort inserted rows once
- merge base and inserted arrays together
- compact only if capacity pressure requires it
This keeps value and gradient arrays structurally aligned without a second independent ordering pass.
If performance matters, compaction should not be:
- full
argsort(-abs(c))of the whole state every step
Instead, it should be something closer to:
- if
num_terms <= max_num_str, do no compaction - if
num_terms > max_num_str, estimate a cutoff with selection / top-k - keep all rows above that cutoff
- break ties only if needed
The main point is to separate:
- normal update steps
- occasional compaction steps
Most steps should not pay the price of globally re-ranking the whole operator.
Compared with stack_sort_merge, this proposal removes:
- the explicit two-branch materialization
- the full lexsort of the concatenated two-branch state
- the full global sort by coefficient magnitude on every step
Compared with the current search_update_merge, this proposal reduces:
- full-state lexsort after every append
- unnecessary treatment of all rows as newly reorderable data
The intended pattern is:
- small inserted set
- large stable base set
- merge small sorted delta into large sorted base
This is the usual place where a delta-based algorithm wins.
Instead of:
- concatenate base and inserted rows
- lexsort the whole thing
we would want:
- inserted rows sorted once
- merge two already sorted arrays
This is probably the most important structural change.
Shape changes currently cause recompiles across powers of two.
A faster version should try to keep one fixed capacity for longer stretches:
- allocate a fixed working capacity
- update within that capacity
- only grow when truly necessary
This can reduce recompilation churn.
If truncation remains "soft", then:
- threshold stats should be tracked separately
- physical compaction should happen only when capacity requires it
This avoids turning the threshold into a global reorder trigger.
Instead of compacting every step:
- compact only when the number of live rows exceeds a target capacity
- otherwise keep the lexicographically sorted live state as-is
This should save work in long runs where growth is gradual.
This design is not free.
It is more complex than either current path because it depends on:
- a stable sorted-state invariant
- a fast merge of base and inserted rows
- delayed compaction
If compaction uses a cutoff rather than full sort, tie behavior near the boundary must be defined carefully.
A theoretically good algorithm can still be bad in JAX if it introduces:
- too much control flow
- hard-to-fuse scatter/gather patterns
- shape polymorphism that forces recompilation
So the design has to stay "array programming friendly".
The real target metric is GPU performance, but current development happens on CPU.
That means CPU benchmarking is useful but limited:
- CPU measurements are good for correctness work
- CPU measurements are good for spotting obvious full-sort / full-copy bottlenecks
- CPU measurements are good for comparing rough scaling trends
But CPU measurements are not the final truth for JAX performance, because the tradeoffs can flip on GPU:
- GPU often prefers large fused array operations
- GPU is more sensitive to shape churn and recompilation
- GPU can punish irregular update-heavy logic more than CPU
- a method that looks slightly better on CPU may still lose on GPU
So CPU should be treated as a development filter, not as the final winner selection environment.
I would not replace either current path immediately.
Instead I would prototype a third path in stages:
- keep the current
search_update_mergeupdate logic - replace full concat+lexsort with "sort inserted rows + merge two sorted arrays"
- benchmark that alone
- only then test lazy compaction
- only then test selection-based cutoff instead of full coefficient sort
This isolates which change actually buys performance.
During development:
- use CPU benchmarks to reject obviously bad designs
- use CPU benchmarks to track scaling and recompilation behavior
- do not treat CPU timings as the final ranking between close candidates
Before choosing the long-term algorithm:
- run the final benchmark on GPU
- compare end-to-end runtime, not only micro-kernels
- include compile time and steady-state runtime separately
- compare memory growth and shape stability as well as raw speed
If the priority is performance, keep search_update_merge semantically aligned
with stack_sort_merge for threshold and max_num_str truncation, but do not
require it to use the same internal ordering or full-array sort strategy.
I would instead:
- keep
search_update_mergeas the semantic starting point - move toward a
search_update_compactdesign - reduce full-array sorts first
- reduce recompiles second
- revisit truncation semantics only after the performance shape is settled
The main bet is simple:
- the next speedup is more likely to come from "delta merge + lazy compaction" than from another variation of "materialize everything, then globally sort it".