Add trace_moment: faster tr(H^k) via identity-multiset enumeration (#80) - #91
Add trace_moment: faster tr(H^k) via identity-multiset enumeration (#80)#91khopade-works wants to merge 5 commits into
Conversation
fc0756b to
e710a84
Compare
…olasloizeau#80) Compute trace(o^k) by enumerating only the Pauli-string multisets whose product is the identity and summing their phases analytically, instead of building o^(k/2). The ordering phase factors as K_ref(M) * Dtilde(M), and terms that commute with all others peel off as binomials, leaving a small anticommuting-core DP. ~2.5x faster on the issue's Ising example (10-100x for odd moments). Adds tests, docs, and benchmarks; trace_product unchanged.
e710a84 to
bea9572
Compare
|
Nice! do you understand why it only speeds up the odd moments ? using PauliStrings
function heisenberg(N)
H = Operator(N)
for i in 1:N
H += "X", i, "X", mod1(i + 1, N)
H += "Y", i, "Y", mod1(i + 1, N)
H += "Z", i, "Z", mod1(i + 1, N)
end
return H
end
N = 20
H = heisenberg(N)
for k in 1:12
muk = trace_product(H, k; scale=1)
println("mu_", k, " = ", muk)
endIs it possible to generalize the method to using PauliStrings
function ising(h, N)
H = Operator(N)
for i in 1:N
H += h, "X", i
end
for i in 1:N
H += "Z", i, "Z", mod1(i + 1, N)
end
return H
end
N = 20
H = ising(0.5, N)
O = Operator(N) + ("X", 1)
for k in 1:14
muk = trace_product(H, k, O, 1; scale=1)
println("mu_", k, " = ", muk)
endmaybe the advantage it more obvious for problems like these ? |
Add trace_moment(A, k, B, l). The ordering phase factorizes per block as trace(A^k B^l) = scale * sum_R (-1)^ycount(R) gA(R) gB(R), so the smaller operator is tabulated by XOR and the other side uses the pruned multiset search. On the issue Ising example with O = X1 (N=20, k=1:14) this drops from ~75s to ~3s (~26x; k=14 alone ~175x) since H^k is never built. Adds tests and benchmarks.
|
Thanks for the thoughtful questions pushed a follow-up commit that generalizes Why TFIM odd moments look so fastOn TFIM, odd moments are exactly zero (even density of states ⇒ only even powers of the Hamiltonian contribute to the trace). For a model with nonzero odd moments, I ran your Heisenberg example (
Total k=1:12: 59.4s → 40.0s (1.48×). So nonzero odd moments still benefit, but the Heisenberg operator is denser (60 terms), so even-k moments can be slightly slower than
|
| k | trace_product (s) | trace_moment (s) | speedup |
|---|---|---|---|
| 14 | 44.2 | 0.25 | 175× |
| total | 75.4 | 2.9 | 26× |
Here the advantage is much clearer: trace_product must build (H^k) (and multiply by (O)), while trace_moment never materializes (H^k).
Happy to tune further for even-k / dense-operator cases (e.g. closed forms for small anticommuting cores) if that would be useful.
| extfactor::Base.RefValue{C} # external weight multiplying each leaf (used by trace(A^k B^l)) | ||
| acc::Base.RefValue{C} |
There was a problem hiding this comment.
If the struct is marked as mutable, these can probably just be of type C.
There was a problem hiding this comment.
Good catch switched to plain extfactor::C and acc::C on the mutable struct.
| # lowest active site of a Pauli string (1-based), or a large sentinel for the identity | ||
| @inline function _minsite(p::PauliString) | ||
| u = p.v | p.w | ||
| return iszero(u) ? typemax(Int) : trailing_zeros(u) + 1 |
There was a problem hiding this comment.
Why do you need to inflate the iszero(u) case? trailing_zeros(u) + 1 should result in a value between 1 and the number of bits, and is its maximal value iff u = 0, so that should already be the largest value I think?
There was a problem hiding this comment.
You're right. For u = 0, trailing_zeros(u) + 1 is already the maximum (bitwidth + 1), so the identity sorts last without an explicit sentinel. Simplified.
|
|
||
| # C(n, r) as an exact integer (the intermediate phase quantities are integers; this keeps the | ||
| # result exact for any `k` up to ~20, beyond which the moment itself overflows Float64 anyway) | ||
| function _binomial_int(n::Int, r::Int) |
There was a problem hiding this comment.
Dropped the custom helper; now uses Base.binomial (exact for the k range we care about).
|
|
||
| # whether two Pauli strings anticommute | ||
| @inline _anticommute(a::PauliString, b::PauliString) = | ||
| isodd(count_ones(a.v & b.w) + count_ones(a.w & b.v)) |
There was a problem hiding this comment.
Since this operation is generally useful, how about adding this to the operations.jl file, and having both commutes(a, b) and anticommutes defined?
I don't think there is a particular need to have this marked as @inline, the compiler is generally smart enough to inline this
There was a problem hiding this comment.
Moved to operations.jl as commutes / anticommutes (exported). Tests check consistency with commutator / anticommutator.
| end | ||
|
|
||
| # Signed sum over distinct orderings, Dtilde(M), via the anticommuting-core dynamic program. | ||
| function _dtilde!(ws::_MomentWS{P,Cc,T,C}, r::Int) where {P,Cc,T,C} |
There was a problem hiding this comment.
This function might benefit from splitting it up into some smaller functions with a bit more descriptive names, just to keep things somewhat easier to review
There was a problem hiding this comment.
Split into _peel_commuting_terms!, _anticommuting_core_size!, _core_ordering_phase_sum!.
| reach[i] = reach[i+1] | (strings[i].v | strings[i].w) | ||
| maxsup = max(maxsup, count_ones(strings[i].v | strings[i].w)) | ||
| end | ||
| maxsup = max(maxsup, 1) |
There was a problem hiding this comment.
If you start with maxsup = 1, 5 lines above this one, this max is no longer needed.
There was a problem hiding this comment.
maxsup now initializes to 1; redundant final max removed.
| # which costs `prod_{core} (m_j + 1)` (usually tiny for local operators). | ||
|
|
||
| # +-1 sign of prod(a, b): the phase of P_a * P_b = ksign * P_{a XOR b} | ||
| @inline _ksign(av::Unsigned, bw::Unsigned) = 1 - ((count_ones(av & bw) & 1) << 1) |
There was a problem hiding this comment.
This can probably move to the operations.jl file, since it is already used in the prod function as well. The @inline is likely not necessary, since the compiler should be smart enough to do it itself
There was a problem hiding this comment.
Extracted pauli_prod_phase in operations.jl; both prod overloads and _canonical_sign use it.
| # | ||
| # tr(H^k) = scale * sum_{M : XOR(M)=1} prod_i a_i^{m_i} * K_ref(M) * Dtilde(M), | ||
| # | ||
| # where `K_ref(M)` is the +-1 phase of the product taken in one fixed (canonical) order and |
There was a problem hiding this comment.
Do you mind documenting what the "canonical" order is you chose?
There was a problem hiding this comment.
Documented in the derivation block: operator terms sorted by _minsite; within a multiset, distinct terms in nondecreasing index with consecutive copies (DFS stack order).
| # return to the identity. Two prunes keep the search close to the number of valid multisets: | ||
| # (1) the remaining terms i:n can only touch sites in reach[i]; | ||
| # (2) clearing the s active sites of R needs at least ceil(s / maxsup) more terms. | ||
| function _moment_dfs!(ws::_MomentWS{P,Cc,T,C}, i::Int, Rv::T, Rw::T, rem::Int) where {P,Cc,T,C} |
There was a problem hiding this comment.
Could you elaborate a bit more about the choice of this structure for a DFS?
Is there a specific reason to not work with a more stack-based approach? There are several quantities that could be built and "unbuilt" in this way, which would reduce some of the storage requirements.
For example: reach[i + 1] can be computed from reach[i] since xor is invertible, and in general xor is faster than memory access, so it should be beneficial to simply increase/decrease that as you go through the search.
The same is probably through for some of the canonical_sign and coefficients, although I'm less sure about how much that actually matters (except for a very easy early cutoff in case of tiny coefficients).
I think I'm more used to seeing this explicit stack approach, where the vectors are really representing the state of the current search space. I'm not saying this approach is better or worse, mostly just interested in seeing if there was a deliberate design choice here.
There was a problem hiding this comment.
The search state is an explicit stack idx/mult/depth hold the current multiset. Recursion matches “pick multiplicity for term i, advance to i+1”. reach[i] is a one-time suffix-union table for O(1) support pruning at each node rather than incrementally maintaining it on backtrack (XOR is cheap; this avoids extra memory traffic on deep trees). An iterative frame stack would be equivalent; I went with recursion for clarity. Happy to switch if you prefer.
|
As a more general comment/question:
|
Good questions I looked at this on the TFIM example (N=20, k=14) and a few smaller sanity checks. What's dominating: On even moments where we don't get the "free zero" shortcut, _moment_dfs! is clearly the main cost most time is spent walking the search tree and hitting leaves. The per-leaf work (_canonical_sign + _dtilde!) is comparatively cheap for local Hamiltonians because the anticommuting core is usually tiny (often just a handful of terms). On dense models like Heisenberg that's less true, and I think that's part of why even-k moments there end up roughly parity with trace_product we pay enumeration overhead without the odd-k shortcut. Threading: I think there's a reasonable path here. The most obvious split is in trace_moment(A, k, B, l) where we already loop over target XORs from the smaller operator's table those iterations are independent and could run in parallel with thread-local accumulators. For plain trace_moment(H, k) you could also split at the top-level DFS branches (e.g. "use term 1 zero times" vs "use it once/more"), though the workload balance might be uneven depending on the operator. I haven't implemented this yet for small N the task overhead might not be worth it, but for the large-moment cases where this method is meant to shine it could help. BFS/DFS hybrid: That's an interesting idea and honestly it's partly what the 4-arg generalization is already doing we fully tabulate the smaller side (a BFS-style buildup of all size-l multisets grouped by XOR target), then DFS only the larger side conditioned on each target. For trace(H^k) alone, precomputing H^m for small m and using its XOR support to prune impossible branches earlier sounds plausible, especially for even k on dense operators where pure DFS is weakest. I haven't explored that yet; it'd be a natural next step if even-k performance on dense Hamiltonians becomes a bottleneck. Happy to dig deeper into any of these if useful for the PR. |
|
Hi, can you please make sure the tests are passing ? |
Resolve PauliStrings.jl export conflict with main (add!, scale, VectorInterface). Qualify PauliStrings.prod in test to avoid Base.prod dispatch.
|
@nicolasloizeau Fixed CI is green, Happy to make any more changes if required |
|
Nice, would it be also possible to have a translation symmetric version for |
|
@nicolasloizeau Added trace_moment for Operator{<:PauliStringTS} - uses resum + translation folding over representatives, matches trace_product on TFIM/Heisenberg/2D tests. |
Closes #80.
Summary
Adds
trace_moment(o::Operator, k; scale=0), which computestrace(o^k)by enumerating only the multisets of Pauli strings whose product is proportional to the identity and summing their phases analytically — without ever constructingo^(k/2).This implements the two pieces requested in #80:
Enumerate identity-yielding multisets. A pruned depth-first search over the operator's terms tracks the running XOR of the Pauli strings and keeps only branches that can still return to the identity. Pruning uses (a) the support reachable by the remaining terms and (b) a
ceil(active_sites / max_support)lower bound on the terms still needed. Sorting terms by their lowest active site makes the reach prune force low sites to resolve early, keeping the search close to the number of contributing multisets.Analytic per-multiset phase (near closed form). Reordering two Pauli strings only flips the product phase by the sign of their (anti)commutation, so the whole order dependence factors as
where
K_refis the sign of one canonical ordering (O(k)) andDtilde(M)depends only on the anticommutation graph of the distinct terms. Every term that commutes with all others peels off as a binomial coefficient, leaving a small "anticommuting core" handled by a tiny multiplicity dynamic program. This removes thek!factor of the naive ordered sum.trace_productis left unchanged;trace_momentis added as a new, non-breaking function.Performance (issue example: TFIM, N=20, k=1:14)
trace_producttrace_momentH^{(k+1)/2}; the multiset method gets them almost for free).H^7).k.Results agree with
trace_productto machine precision (Float64 accumulation only).Note: the
k!saving in the issue is relative to the naiven^ksum; relative to the currenttr(H^{k/2} H^{k/2})implementation the headline wins are odd moments, memory, and scaling, netting 2.5x on the example.What's included
src/moments.jl:trace_moment+ helpers, with a derivation comment block and docstring.src/PauliStrings.jl: exporttrace_moment.docs/src/docstrings.md: added under "Power and moments".test/algorithms.jl:trace_momenttest set comparing againsttrace_product(random 1-/2-local operators, the Ising model, complex/non-Hermitian coefficients, an explicit identity term, thescalekeyword,k=0, and error handling).benchmark/: a TFIM model andtrace_momentvstrace_productbenchmark entries.Test plan
Pkg.test()).trace_momenttest set passes.limitations
trace_momentcurrently targetsOperator(the type in the issue example); translation-symmetricOperatorTSstill uses the existing path.k~ 20, beyond which the moment value itself exceeds Float64 range.