Windows上Julia代码比Python慢了一倍并且在MacOS上正好反过来?

我正在写一个量子多体计算中的精确对角化的程序,因为Python过慢所以转向Julia,以下是我的两个程序,其中Julia是按照Python写的。

结果发现在Windows系统上当sparse_hamiltonian()和Hamiltonian()第二个参数传入14时,python用时4s,限制OpenBLAS线程后5s,Julia用时5s,当传入16时,python用时70s,限制OpenBLAS线程后120s,Julia用时166s,并且第二次运行158s,第三次150s。我的代码耗时主要是写矩阵的部分以及对矩阵进行对角化两部分。详细看了一下用时其中写矩阵python限制OpenBLAS线程是24s, julia是26s,那么np.linalg.eigvalsh和eigvals函数分别是100s, 140s。我也尝试将Julia的矩阵写成Symmetric(Hamiltonian(1,16))或者是Hermitan(Hamiltonian(1,16))的形式,但反而更慢了。当用Mac OS系统跑完全相同的代码的时候,情况正好反过来,python用时8min(限制OpenBLAS线程后),不限制跑用了7min09s,Julia用时60s。

我的Windows系统是Windows10,AMD Ryzen5 4600H, 1650, python 3.7.12, NumPy 1.21.6,Julia 1.8.5; Mac OS是Ventura 13.2.1,M1 Max,numpy 1.24.2, python 3.10.9, julia 1.8.5。目前我的Linux系统机器正在升级,暂时跑不了代码。

请问为什么:

  1. 我的程序Windows系统上Julia比Python慢?不仅慢在对于数组的遍历与读写上,而且在矩阵的对角化上。如果我想优化,还可以怎么优化,我理解的Julia应该是默认可以跑多线程,但是似乎在Windows上为单核。(很抱歉,我还没有学习Julia性能部分。是需要提前声明变量吗?)
  2. 为什么在Mac OSJulia比Python快?

1.21.6的numpy.show_config()如下,

blas_info:
    libraries = ['cblas', 'blas', 'cblas', 'blas', 'cblas', 'blas']
    library_dirs = ['D:/Anaconda3/envs/quantum\\Library\\lib']
    include_dirs = ['D:/Anaconda3/envs/quantum\\Library\\include']
    language = f77
    define_macros = [('HAVE_CBLAS', None)]
blas_opt_info:
    define_macros = [('NO_ATLAS_INFO', 1), ('HAVE_CBLAS', None)]
    libraries = ['cblas', 'blas', 'cblas', 'blas', 'cblas', 'blas']
    language = f77
lapack_info:
    libraries = ['lapack', 'blas', 'lapack', 'blas']
    language = f77
lapack_opt_info:
    libraries = ['lapack', 'blas', 'lapack', 'blas', 'cblas', 'blas', 'cblas', 'blas', 'cblas', 'blas']
    language = f77
    define_macros = [('NO_ATLAS_INFO', 1), ('HAVE_CBLAS', None)]
Supported SIMD extensions in this NumPy install:
    baseline = SSE,SSE2,SSE3
    found = SSSE3,SSE41,POPCNT,SSE42,AVX,F16C,FMA3,AVX2
    not found = AVX512F,AVX512CD,AVX512_SKX,AVX512_CLX,AVX512_CNL

LinearAlgebra.BLAS.get_config()如下:

LinearAlgebra.BLAS.LBTConfig
Libraries:
└ [ILP64] libopenblas64_.dll

哈密顿量形式为:

H=\sum_i [\omega_i n_i+V(n_i-1/2)(n_{i+1}-1/2)+c^{\dagger}_ic_{i+1}+c^{\dagger}_{i+1}c_{i}+c^{\dagger}_{i+2}c_{i}+c^{\dagger}_ic_{i+2}]

where on-site potentials \omega_i are independent Gaussian random numbers with mean zero and variance W^2

import os
import numpy as np
import scipy.sparse as sp
import datetime

os.environ["OMP_NUM_THREADS"] = "1" # export OMP_NUM_THREADS=4
os.environ["OPENBLAS_NUM_THREADS"] = "1" # export OPENBLAS_NUM_THREADS=4 
os.environ["NUMEXPR_NUM_THREADS"] = "1" # export NUMEXPR_NUM_THREADS=6

t0 = datetime.datetime.now()
#这一步是生成所有占据数为N/2的基态,比如N=4的话就是即0011,0101,0110,1001,1010,1100一系列的态。最终返回一个列表,里面都是二次量子化基矢,以列表的形式存储。
def half_filling(N):
    basis = []
    for x in range(0, 2**N):
        xb = bin(x)[2:].zfill(N)
        if xb.count('1') == N//2:
            basis.append(list(map(int, xb)))
    return basis

#这一部分是写哈密顿量,具体来说是用前文生成的半填充的基矢来展开哈密顿量,

def sparse_hamiltonian(W,N,t=1,tprime=1,V=2):
    basis=half_filling(N) 
    basis=[list(i) for i in basis]
    L=len(basis) 
    H=sp.dok_matrix((L,L))
    omegalis=np.ones(N) #这里应为np.normal(0,W**2)
    
    for row in range(L): 
        rowstate=basis[row]
        temp=0
#先写对角元素
        for i in range(N):
                    if rowstate[i]==1:
                        temp+=omegalis[i]
                        if rowstate[(i+1)%N]==1:
                            temp+=V/4
                        else:
                            temp-=V/4
                    else:
                        if rowstate[(i+1)%N]==1:
                            temp-=V/4
                        else:
                            temp+=V/4
        H[row,row]=temp
#再写非对角元素,因为c_i^{\dagger}c_{i+1}保持粒子数守恒所以作用后的基仍在我们已经写好的基矢空间内。遍历每一个基矢作为矩阵的横坐标,如果是比如0110态,就找对应的1010态作为纵坐标,以此类推。如果你不熟悉,可以参见Sandvik的精确对角化教程。
        for j in range(N):
            if rowstate[j] == 0 and rowstate[(j+1)%N] == 1:
               newcolumnstate = rowstate.copy()
               newcolumnstate[j] = 1
               newcolumnstate[(j+1)%N] = 0
               column = basis.index(newcolumnstate)
               H[row, column] += t
            elif rowstate[j] == 0 and rowstate[(j+2)%N] == 1:
                newcolumnstate = rowstate.copy()
                newcolumnstate[j] = 1
                newcolumnstate[(j+2)%N] = 0
                column = basis.index(newcolumnstate)
                H[row, column] += tprime
    H = H + H.T
    for i in range(L):
        H[i, i] /= 2
    return H.toarray()

energy=np.linalg.eigvalsh(sparse_hamiltonian(1,16))
print(datetime.datetime.now()-t0)
using SparseArrays
using LinearAlgebra

function half_filling(N)
    basis = []
    for x in range(0, 2^N-1)
        xb = string(x, base=2, pad=N)
        if count('1', xb) == N÷2
            push!(basis, map(x -> parse(Int64, x), split(xb, "")))
        end
    end
    return basis
end

function Hamiltonian(W,N,t=1,tprime=1,V=2)
    basis=half_filling(N)
    L=length(basis)
    H=spzeros(L,L)
    omegalis=ones(N)
    for row in 1:L
        rowstate=basis[row]
        temp=0
        for i in 1:N
            if rowstate[i]==1
                temp+=omegalis[i]
                if rowstate[mod1(i+1,N)]==1
                    temp+=V/4
                else
                    temp-=V/4
                end
            else
                if rowstate[mod1(i+1,N)]==1
                    temp-=V/4
                else
                    temp+=V/4
                end
            end
        H[row,row]=temp
        end
        for j in 1:N
            if rowstate[j]==0 && rowstate[mod1(j+1,N)]==1
                newcolumnstate=copy(rowstate)
                newcolumnstate[j]=1
                newcolumnstate[mod1(j+1,N)]=0
                column = findfirst(x -> isequal(x, newcolumnstate), basis)
                H[row,column]+=t

            elseif rowstate[j] == 0 && rowstate[mod1(j+2,N)] == 1
                newcolumnstate = copy(rowstate)
                newcolumnstate[j] = 1
                newcolumnstate[mod1(j+2,N)] = 0
                column = findfirst(x -> isequal(x, newcolumnstate), basis)
                H[row, column] += tprime
            end
        end
    end
    H=H+transpose(H)
    for k in 1:L
        H[k,k]/=2 
    end
    return Matrix(H)
end

@time eigvals(Hamiltonian(1,16))

有朋友帮我用Intel i9-11900H试了一下,用openblas in numpy 5min, numpy 开mkl不限制线程33s,限制后要66s。计算julia如果用openblas也是140s, 如果用MKL是37s,加了BLAS.set_num_threads(1)之后,变成了231.028044 seconds。 我apple 1.21.6numpy用的是veclib,要7min09s,如果用openblas的numpy就只需要2min了。另一个问题是如果mac用scipy.linalg.eigvalsh更快,只需要1min。但是我在AMD上的话,反而是np比sp快(AMD的sp的计算选择线程为446最快,不限制线程或者是别的线程都要慢)。我以为M1max 有amx所以苹果自己弄得加速库会快一些,,,)。 所以现在问题就变成了:amd对openblas支持看起来很好,但是Julia的LinearAlgebra反而却很慢. 所以我试了一下设置多线程,LinearAlgebra.BLAS.set_num_threads(4),第一次151s,第二次148s,还是很慢,所以请问有什么办法能解决一下吗。

顺手把题主的诸多benchmark 做成表格,降低一下理解成本。

题主设备

win: Windows10,AMD Ryzen5 4600H, 1650, python 3.7.12, NumPy 1.21.6,Julia 1.8.5

mac: Ventura 13.2.1,M1 Max,numpy 1.24.2, python 3.10.9, julia 1.8.5

numpy julia
openblas(mac) 2min 140s
openblas(mac,1thread) 8min
veclib(mac) 7min09s
openblas(win) 66s/70s 231s
openblas(win,1 thread) 120s ~160s

题主朋友设备: Intel i9-11900H

numpy julia
openblas 5min 140s
mkl 33s 37s
mkl(1 thread) 66s 231s

尝试把答主的问题拆分为两部分:

  1. 在准备哈密顿量矩阵时,Julia 的循环要比python 慢?
  2. 在求本征值问题时,该如何理解和比较多种条件下的benchmark ?

先试着回答下第1 个问题:

比较准备哈密顿量的两种代码,我注意到寻找新base 的位置这部分逻辑,Python 代码使用了basis.index(newcolumnstate) ,是list索引;而Julia 代码使用的是 findfirst(x -> isequal(x, newcolumnstate), basis),是通用查找算法。这两者的性能是不一样的。同时,julia版本的half_filling(N) 返回的是Vector{Any} ,在Julia中,包含{Any} 的类型性能是极差的。只需稍作改动,我们记录基的字符串, 在构造哈密顿量时,也是查找和比较字符串:

function half_filling_v2(N)
    @assert mod(N,2) == 0
    
    N2 = N ÷ 2
    basis = String[]

    for x in range(0, 2^N-1)
        xb = string(x, base=2, pad=N)
        if count('1', xb) == N2 
            push!(basis, xb)
        end
    end
    return basis
end

function Hamiltonian_v2(W,N,t=1,tprime=1,V=2)
    basis = half_filling_v2(N)

    L = length(basis)
    H = spzeros(L,L)
    omegalis=ones(N)
    for row in 1:L
        rowstate=basis[row]
        temp=0
        for i in 1:N
            if rowstate[i]=='1'
                temp+=omegalis[i]
                if rowstate[mod1(i+1,N)]=='1'
                    temp+=V/4
                else
                    temp-=V/4
                end
            else
                if rowstate[mod1(i+1,N)]=='1'
                    temp-=V/4
                else
                    temp+=V/4
                end
            end
        H[row,row]=temp
        end
        for j in 1:N
            if rowstate[j]=='0' && rowstate[mod1(j+1,N)]=='1'
                statearray = collect(rowstate)
                statearray[j] = '1'
                statearray[mod1(j+1,N)] = '0'
                newcolumnstate = join(statearray)
                column = findfirst(x -> isequal(x, newcolumnstate), basis)
                H[row,column]+=t

            elseif rowstate[j] == '0' && rowstate[mod1(j+2,N)] == '1'
                statearray = collect(rowstate)
                statearray[j] = '1'
                statearray[mod1(j+2,N)] = '0'
                newcolumnstate = join(statearray)
                column = findfirst(x -> isequal(x, newcolumnstate), basis)
                H[row, column] += tprime
            end
        end
    end
    H=H+transpose(H)
    for k in 1:L
        H[k,k]/=2 
    end
    return Matrix(H)
end

我这边的结果显示提升了1倍性能(preH 为题主的原始方法, preH_v2 是我上面这段改编代码):

julia> @timeit to "preH" H  = Hamiltonian(1, 16);
julia> @timeit to "preH_v2" H2 = Hamiltonian_v2(1, 16);
julia> to
 ────────────────────────────────────────────────────────────────────
                            Time                    Allocations      
                   ───────────────────────   ────────────────────────
 Tot / % measured:      95.4s /  34.1%           10.7GiB /  98.1%

 Section   ncalls     time    %tot     avg     alloc    %tot      avg
 ────────────────────────────────────────────────────────────────────
 preH           1    21.0s   64.4%   21.0s   9.25GiB   87.8%  9.25GiB
 preH_v2        1    11.6s   35.6%   11.6s   1.28GiB   12.2%  1.28GiB
 ────────────────────────────────────────────────────────────────────

也是从这里发现,题主的原始方法还有大量的内存分配,这也许正是其比较慢的原因。

然后试着回答下第 2 个问题:

还要明确的是,大多数时候,对于本征值问题,我们实际在比较的是MKL和OpenBLAS。而OpenBLAS的性能一直弱于MKL。我注意到,在题主朋友设备的测试中,都使用MKL的情况下,numpy 和 Julia 的用时是差不多的,也几乎是最优的性能。如果性能有差异,可能是在工程实现上都特殊的考量,调用不同的lapack 函数,没有特化到在具体情况中性能最优的函数。所以,这某种意义上也不能算是Julia 自身的问题。

关于编写高性能代码,题主可以先看看 Julia 文档的 Performance Tips,然后了解一些硬件相关的知识,比如jakobnissen的 hardware_introduction 或者我翻译的中文版 hardware_introduction

5 个赞

感谢大佬的认真阅读与指正,经过大佬的优化后的代码确实快了一倍,这应该就是指定类型之后的加速。然后其实那个basis列表里的base代表的字符串的基,转化为十进制后是已经排序好了的,比如是,

6-element Vector{String}:
 "0011"
 "0101"
 "0110"
 "1001"
 "1010"
 "1100"

所以经过你的提醒,我将findfirst函数换成了searchsortedfirst,一下子又快了15倍,原先要30s的矩阵读写部分的代码现在只需要1s。

很抱歉我没有计算机科学相关专业背景所以对硬件相关知识不是很了解,您还提到我有大量的内存分配也导致了比较慢,这句话我不是很懂,难道是说我先写了spzeros(),所以访问矩阵的时候需要遍历很多次。应该先写完矩阵元,然后再填入吗。那我想到的也只是写一个空列表然后push!(),或者在python里,list.append(),但是感觉这种操作没有numpy的array优化,似乎没有快多少。我用python试了一下好像还是先写np.zeros((2N,2N))快。

您提到内存分配,还有一个问题,我记得C或者C++好像都可以提前分配内存空间来避免随机访问,(常量空间还是什么),Julia或者Python中也有这种操作吗,还是说已经避免了。

关于第二个问题,我可以在Julia中指定某些参数让他能选择性能最优的函数吗,因为经过测试感觉AMD+OpenBLAS还是蛮能打的:)

我后来去了解了一下,numpy.zeros(N) 创建的数组已经分配了一整块连续的内存空间,并且元素的类型是已知的。相比之下,使用空列表并在每次迭代中使用 append 操作会导致动态分配内存,每次迭代都会重新分配和复制列表的内容。所以我的问题并不准确,但是我还是好奇我有大量的内存分配是指什么?

不是大佬嘞,可以共同学习。

对。是排序好的,但是写成整数就能直接看不出来粒子数占据了。这也侧面说明,高性能优化的最终代码版本可能跟我们的原始想法相差甚远,可读性可能很不好。

不是。我猜想是这里有大量动态分配的内存,但经验有限,没定位出具体的位置。

前天搞错了一些事,不敢妄下结论,现在弄清楚了。可以断定这里内存分配的来源就是

  1. half_filling 里的 push!
  2. H=H+transpose(H) 这步 += 运算不是 in-place 操作,transpose(H)会分配内存,+= 运算也会分配内存
  3. 最后一步的 Matrix(H)

提前分配内存是为了避免内存碎片导致的访问速度降低。Julia也是可以提前分配内存的, 也通常是更好的实践,比如 half_filling 还可以像下面这样改:

function half_filling_v3(N)
    @assert mod(N,2) == 0
    
    N2 = N ÷ 2
    num = factorial(N) ÷ (factorial(N2) * factorial(N2))
    basis = Vector{String}(undef, num)

    i = 1
    for x in range(0, 2^N-1)
        xb = string(x, base=2, pad=N)
        if count('1', xb) == N2 
            basis[i] = xb
            i = i + 1
        end
    end
  end

从 eigen.jl 中的 symmetriceigen.jl#L83 来看,eigen.jl 包装的是syevr 。

你可以对相关 LAPACK 函数做一下 benchmark,然后参考 LinearAlgebra.jl 试试自己包一层 openblas api 。

另外, 下面是我在AMD 设备上对Julia代码做的benchmark:

openblas mkl
eigvals 142s 24s

和题主朋友的 benchmark 结果基本相似。所以最简单的选择,性能也最好的选择是使用不限制线程的MKL。

1 个赞