Skip to content

Conversation

@ytdHuang
Copy link
Member

@ytdHuang ytdHuang commented Dec 16, 2025

Checklist

Thank you for contributing to QuantumToolbox.jl! Please make sure you have finished the following tasks before opening the PR.

  • Please read Contributing to Quantum Toolbox in Julia.
  • Any code changes were done in a way that does not break public API.
  • Appropriate tests were added and tested locally by running: make test.
  • Any code changes should be julia formatted by running: make format.
  • All documents (in docs/ folder) related to code changes were updated and able to build locally by running: make docs.
  • (If necessary) the CHANGELOG.md should be updated (regarding to the code changes) and built by running: make changelog.

Request for a review after you have completed all the tasks. If you have not finished them all, you can also open a Draft Pull Request to let the others know this on-going work.

Description

This is an extended fix for PR #613 .

@codecov
Copy link

codecov bot commented Dec 16, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.26%. Comparing base (0c77d51) to head (1eb3b38).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #614      +/-   ##
==========================================
+ Coverage   92.28%   93.26%   +0.98%     
==========================================
  Files          50       50              
  Lines        3615     3624       +9     
==========================================
+ Hits         3336     3380      +44     
+ Misses        279      244      -35     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Updated calculation to improve numerical stability and changed division operator for normalization.
data = [exp(-β * ComplexF64(j)) for j in 0:(N-1)]
function thermal_dm(N::Int, n::T; sparse::Union{Bool,Val} = Val(false)) where {T<:Real}
exp_minus_β = @. 1 - (1 / (n + 1)) # use "1-1/(n+1)" instead of "n/(n+1)" for numerical stability
P_n = _complex_float_type(T)[exp_minus_β^j for j in 0:(N-1)]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately this is way slower than the exp version. thermal_dm(3000, 0.2; sparse=Val(true)) becomes 50% slower than before.

Copy link
Member Author

@ytdHuang ytdHuang Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this implementation gives correct results for all known extreme cases. I think this is more important.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And that's why I was talking about branching on the finiteness of beta. Since precomputing the normalization factor is problematic for dealing with nbar=Inf, simply replacing the first element in the original array already works and is faster, i.e.

    data =     P_n = _complex_float_type(T)[j == 0 && !isfinite(β) ? one(β) : exp(-β * j) for j in 0:(N-1)]

This is doing basically the same thing as the slower version since the pow function fallbacks to log and exp.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I didn't fully understand your point in the beginning

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the other PR @yuyichao what saying that perhaps it is better to preallocate the vector and then filling, to avoid comprehension. Is it still true here?

I also would avoid the use of the Boltzmann function we have just introduced.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The avoiding comprehension part was mainly to not write too much code in the comprehension (for coding style only). It is true that comprehension can basically only be as performant as loop and not better but that's not too important.

The code in the loop (and therefore would've been in comprehension) was more complex in the other PR since I wanted to reuse the result for the summation computation. However, since the analytical expression to calculate the sum had issue with infinite nbar and is therefore disgarded, that's less of a problem (granted 1 + 1/n is already computed but that has always been the case) and the code in the loop is fairly simple for the current version.

data = [exp(-β * ComplexF64(j)) for j in 0:(N-1)]
function thermal_dm(N::Int, n::T; sparse::Union{Bool,Val} = Val(false)) where {T<:Real}
exp_minus_β = @. 1 - (1 / (n + 1)) # use "1-1/(n+1)" instead of "n/(n+1)" for numerical stability
P_n = _complex_float_type(T)[exp_minus_β^j for j in 0:(N-1)]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And that's why I was talking about branching on the finiteness of beta. Since precomputing the normalization factor is problematic for dealing with nbar=Inf, simply replacing the first element in the original array already works and is faster, i.e.

    data =     P_n = _complex_float_type(T)[j == 0 && !isfinite(β) ? one(β) : exp(-β * j) for j in 0:(N-1)]

This is doing basically the same thing as the slower version since the pow function fallbacks to log and exp.

function thermal_dm(N::Int, n::T; sparse::Union{Bool,Val} = Val(false)) where {T<:Real}
exp_minus_β = 1 - (1 / (n + 1)) # use "1-1/(n+1)" instead of "n/(n+1)" for numerical stability (works for n=0 and Inf)
P_n = _complex_float_type(T)[exp_minus_β^j for j in 0:(N-1)]
P_n /= sum(P_n)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's wrong with the in-place modification?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JET.jl reports some issues, so I keep it as P_n /= sum(P_n) instead of ./=

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What’s the issue? Why is it recommending allocating a new array?

Copy link
Member Author

@ytdHuang ytdHuang Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It relates to something else in our package

I prefer to keep it as it is for now.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you mean that this use is perfectly fine?

I still don't understand why such a clear cut and simple optimization is problematic. I literally can't see any reason for the non-inplace version to be better. If there is one, it would be good to state it and maybe mention in a comment.

Copy link
Member Author

@ytdHuang ytdHuang Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I also don't understand. It somehow makes the compiler feel like we are making the Qobj as a 3D-array.

I even think that the issue reported by JET.jl is not a problem from our package.

Anyway, I would prefer to merge this bug fix first, we can improve those things in a future and separate PR if necessary.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that we should avoid allocating extra vectors unnecessary. We should understand what is going on here.

@yuyichao
Copy link

And ref #614 (comment) which was prematurely marked as resolved.

Can you make sure thermal_dm(10, ForwardDiff.Dual(0.1, (1.0, 1.0))) actually works? AFAICT it works on master but doesn't with this PR anymore.

@ytdHuang
Copy link
Member Author

And ref #614 (comment) which was prematurely marked as resolved.

Can you make sure thermal_dm(10, ForwardDiff.Dual(0.1, (1.0, 1.0))) actually works? AFAICT it works on master but doesn't with this PR anymore.

yes, I've just confirmed in my local repo.

@yuyichao
Copy link

yes, I've just confirmed in my local repo.

By which you mean it didn't work and had to be fixed.

Yeah, I also don't understand. It somehow makes the compiler feel like we are making the Qobj as a 3D-array.
I even think that the issue reported by JET.jl is not a problem from our package.
Anyway, I would prefer to merge this bug fix first, we can improve those things in a future and separate PR if necessary.

How did you concluded that it's the line I was talking about that's triggering the issue?

I don't know if you had any other local JET runs but from the CI I assume you were talking about https://github.com/qutip/QuantumToolbox.jl/actions/runs/20274818814 and https://github.com/qutip/QuantumToolbox.jl/actions/runs/20274756492. Both of which only shows issue for thermal_dm and not enr_thermal_dm so it seems that the ./= change wasn't the issue since both functions had it.

There was also not an attempt to apply only the ./= changes to see if that indeed was the change that is causing the issue. There's always other unrelated changes that was bundled together so at least on the CI there isn't any proof that this change was the culprit. In fact, and to be more specific, both commits that triggers the issue, 5d52016 and c3cc529 contains a unnecessary use of @. when computing exp_minus_β and none of the succeeded runs contain that line. It should still be correct, which is why I didn't bother to mention previously, thinking it's just the local style, but is probably way more likely to confuse any analyzer than making a change in-place, which is strictly less work for the analyzer.

@ytdHuang
Copy link
Member Author

ytdHuang commented Dec 17, 2025

yes, I've just confirmed in my local repo.

By which you mean it didn't work and had to be fixed.

Yeah, I also don't understand. It somehow makes the compiler feel like we are making the Qobj as a 3D-array.
I even think that the issue reported by JET.jl is not a problem from our package.
Anyway, I would prefer to merge this bug fix first, we can improve those things in a future and separate PR if necessary.

How did you concluded that it's the line I was talking about that's triggering the issue?

I don't know if you had any other local JET runs but from the CI I assume you were talking about https://github.com/qutip/QuantumToolbox.jl/actions/runs/20274818814 and https://github.com/qutip/QuantumToolbox.jl/actions/runs/20274756492. Both of which only shows issue for thermal_dm and not enr_thermal_dm so it seems that the ./= change wasn't the issue since both functions had it.

There was also not an attempt to apply only the ./= changes to see if that indeed was the change that is causing the issue. There's always other unrelated changes that was bundled together so at least on the CI there isn't any proof that this change was the culprit. In fact, and to be more specific, both commits that triggers the issue, 5d52016 and c3cc529 contains a unnecessary use of @. when computing exp_minus_β and none of the succeeded runs contain that line. It should still be correct, which is why I didn't bother to mention previously, thinking it's just the local style, but is probably way more likely to confuse any analyzer than making a change in-place, which is strictly less work for the analyzer.

Yes, but I tried for the latest commit and still faced that issue, and after I changed ./= into /=, JET.jl test passes.

@ytdHuang
Copy link
Member Author

ytdHuang commented Dec 17, 2025

The issue of ./= raised by JET.jl should not relate to this PR. Thus, I prefer to fix it in a separate one.

@yuyichao
Copy link

yuyichao commented Dec 17, 2025

There’s simply not an issue and fwiw the previous version attempted to use the broadcast version (it’s doing ./) as well so the inplace one is closer to what the previous one was doing. It is the version you have right now that’s making unnecessary and unrelated change by introducing an array division rather than a broadcast compared to what the code used to do.

And what I was saying was that you have not even tried to make the trivial change, and because of that draw the wrong conclusion that there’s an issue at all. (I.e. it seems that JET have never raised any issues about ./= ever)

@yuyichao
Copy link

And while in general I agree with not introducing change to unrelated parts, this is not that. The previous version of code does not contain a call to vector scalar division, but an element wise broadcast. It is therefore closer to the broadcasted version that I was asking about.

Alternatively, if you want to restore it to the previous version, I.e, P = P ./ sum(P), I can accept that any change to that line is a separate matter.

@albertomercurio
Copy link
Member

It seems that @yuyichao is looking for performance improvement and support to BigFloat or Dual numbers.

Would the following case be better?

function thermal_dm(N::Int, n::Tn; sparse::Union{Bool,Val} = Val(false)) where {Tn<:Real}
    (n > 0) || throw(DomainError(n, "The argument n must be positive."))

    T = _complex_float_type(Tn)
    
    # Directly compute exp(-β) = n/(1+n) without logarithm
    # This is more efficient and avoids numerical issues
    # Derived from: β = log(1/n + 1) => exp(-β) = n/(1+n)
    # For n → ∞ (infinite temperature), exp(-β) → 1 (maximally mixed state)
    exp_minus_β = isinf(n) ? one(T) : T(n) / (one(T) + T(n))
    
    # Compute partition function using geometric series formula
    # Z = sum_{j=0}^{N-1} exp(-β*j) = (1 - exp(-β*N)) / (1 - exp(-β))
    Z = if abs(one(real(T)) - real(exp_minus_β)) < eps(Tn)
        T(N)
    else
        (one(T) - exp_minus_β^N) / (one(T) - exp_minus_β)
    end
    
    # Create diagonal elements with normalized probabilities
    scale = one(T) / Z
    data = [exp_minus_β^j * scale for j in 0:(N-1)]
    
    if getVal(sparse)
        return QuantumObject(spdiagm(0 => data), Operator(), N)
    else
        return QuantumObject(diagm(0 => data), Operator(), N)
    end
end

It is faster than the main branch and it correctly handles thermal_dm(3, Inf) or thermal_dm(3, nextfloat(0.0)).

It also handles BigFloats, but I haven't tried with Dual numbers.

@ytdHuang
Copy link
Member Author

ytdHuang commented Dec 18, 2025

But the current code already supports all different input cases: 0, Inf, BigFloat, nextfloat(0.0), ForwardDiff.Dual

@yuyichao
Copy link

But the current code already supports all different input cases: 0, Inf, BigFloat, nextfloat(0.0), ForwardDiff.Dual

Yeah that's right. Apart from the non-existing ./= issue that should be fixed and some test needed to be added (e.g. for denormal floating point numbers) this version should good.

@albertomercurio
Copy link
Member

But the current code already supports all different input cases: 0, Inf, BigFloat, nextfloat(0.0), ForwardDiff.Dual

Yes, what I mean is that, since we also want to optimize. Let's choose the most optimal one. Which one is faster?

@albertomercurio
Copy link
Member

I have opened an issue on JET.jl about that weird behavior aviatesk/JET.jl#790

Let's see if they can give us an answer, otherwise we can proceed I would say

return QuantumObject(diagm(0 => P), Operator(), N)
end
end
Boltzmann_weight::T, E::Int) where {T<:Real} = (E != 0 || isfinite(β)) ? exp(-β * E) : one(T)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you rename is with boltzmann_weight or even _boltzmann_weight?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok
I will do it tomorrow.

_complex_float_type(::Type{Complex{Float32}}) = ComplexF32
_complex_float_type(::Type{Complex{Float64}}) = ComplexF64
_complex_float_type(T::Type{<:Complex}) = T # Allow other untracked Complex types, like ForwardDiff.Dual
_complex_float_type(T::Type{<:Real}) = Complex{T} # Allow other untracked Complex types, like ForwardDiff.Dual
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we tested this case here? I'm not sure doing just Complex{T} would work.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I test it by myself and it works.

Do you want me to add ForwardDiff.Dual into thermal_dm test

in this case, we need to add ForwardDiff into test's Project.toml

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have it in the autodiff tests. We could add some tests about the derivative of the photon number of a thermal state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants