如何提高我的Julia程序在处理优秀数字时的性能?

8

我一直在尝试使用Perl程序计算优秀数字。虽然我的解决方案运行时间可以接受,但我认为另一种语言,特别是为数值计算设计的语言,可能会更快。一个朋友建议使用Julia,但我看到的性能很差,肯定是我做错了什么。我已经查看了性能提示,但没有看到应该改进的地方:

digits = int( ARGS[1] )

const k = div( digits, 2 )

for a = ( 10 ^ (k - 1) ) : ( 10 ^ (k) - 1 )
    front = a * (10 ^ k + a)

    root = floor( front ^ 0.5 )

    for b = ( root - 1 ): ( root + 1 )
        back = b * (b - 1);
        if back > front
            break
        end
        if log(10,b) > k
            continue
        end
        if front == back
            @printf "%d%d\n" a b
        end
    end
 end

我有一个等效的 C 程序,比 Julia page 上提到的因子 2 快一个数量级(尽管关于 Julia 速度的大多数 Stackoverflow 问题似乎都指出该页面上的基准测试存在缺陷):
而我编写的未经优化的纯 Perl 只需要一半的时间:
use v5.20;

my $digits = $ARGV[0] // 2;
die "Number of digits must be even and non-zero! You said [$digits]\n"
    unless( $digits > 0 and $digits % 2 == 0 and int($digits) eq $digits );

my $k  = ( $digits / 2 );

foreach my $n ( 10**($k-1) .. 10**($k) - 1 ) {
    my $front = $n*(10**$k + $n);
    my $root = int( sqrt( $front ) );

    foreach my $try ( $root - 2 .. $root + 2 ) {
        my $back = $try * ($try - 1);
        last if length($try) > $k;
        last if $back > $front;
        # say "\tn: $n back: $back try: $try front: $front";
        if( $back == $front ) {
            say "$n$try";
            last;
            }
        }
    }

我正在使用预编译的Julia,因为我无法编译源代码(但我没有尝试过第一次失败后再试)。我想这是其中的一部分。
此外,我发现任何Julia程序的启动时间约为0.7秒(请参见Slow Julia Startup Time),这意味着等价的编译C程序可以在Julia完成一次运行前运行约200次。随着运行时间的增加(digits值较大),启动时间变得越来越少,我的Julia程序仍然非常慢。
我还没有涉及到非常大的数字部分(20+位优秀数字),我没有意识到Julia在处理这些数字方面并不比大多数其他语言更好。

这是我的C代码,与我开始时有些不同。我的生疏、不优雅的C技能与我的Perl基本相同。

#include <math.h>
#include <stdio.h>
#include <stdlib.h>

int main( int argc, char *argv[] ) {
    long 
        k, digits,
        start, end,
        a, b,
        front, back,
        root
        ;

    digits = atoi( argv[1] );

    k = digits / 2;

    start = (long) pow(10, k - 1);
    end   = (long) pow(10, k);

    for( a = start; a < end; a++ ) {
        front = (long) a * ( pow(10,k) + a );
        root  = (long) floor( sqrt( front ) );

        for( b = root - 1; b <= root + 1; b++ ) {
            back = (long) b * ( b - 1 );
            if( back > front )  { break; }
            if( log10(b) > k ) { continue; }
            if( front == back ) {
                printf( "%ld%ld\n", a, b );
                }
            }
        }

    return 0;
    }

3
请提供您的实际基准测试时间,并描述如何获取它们。 - jpmc26
1
此外,将您的代码封装在一个函数中,正如@VincentZoonekynd所建议的那样,在我的机器上可以提高四倍的速度。虽然还不是您想要的性能,但已经接近了。 - Colin T Bowers
另外,出于好奇,我用ProfileView快速查看了函数形式。在我的机器上,不成比例(对我而言)的运行时间花费在log(10,b)上。如果b始终为正数,则可能有一个宏可用于跳过log函数中的域检查,尽管我认为这不会带来太大的差异。这不是一个答案,但这可能会指引您朝着进一步减少运行时间的正确方向前进。 - Colin T Bowers
1
哦...你知道吗?Julia语言的对数函数运行速度慢的问题是Github上issue #8869。所以我猜你在这里看到的可能只是一些问题,希望能在v1.0中得到解决。 - Colin T Bowers
5
“将代码放在函数中并没有让我看到任何加速” - 这很奇怪。对我来说,从全局范围到局部范围提供了很大的加速。要进一步分析,您需要提供更多关于您如何运行代码的详细信息(也许您已经在局部范围内)......“我猜答案是Julia很慢”-这是从一个非常具体的示例得出的非常笼统的结论,而且该语言还处于测试版。我相信您并不是那个意思,但这听起来有点挑衅。 - Colin T Bowers
显示剩余2条评论
4个回答

19

我已经对您的代码(brian.jl)进行了基准测试,与以下代码进行了比较,该代码试图对您的代码进行最小更改并遵循Julian风格:

function excellent(digits)
    k = div(digits, 2)
    l = 10 ^ (k - 1)
    u = (10 ^ k) - 1

    for a in l:u
        front = a * (10 ^ k + a)
        root = isqrt(front)
        for b = (root - 1):(root + 1)
            back = b * (b - 1)
            back > front && break
            log10(b) > k && continue
            front == back && println(a,b)
        end
    end
end
excellent(int(ARGS[1]))

ul分开是出于个人偏好,以提高可读性。基准是,在我的计算机上,Julia的启动时间为:

$ time julia -e ''
real    0m0.248s
user    0m0.306s
sys 0m0.091s

因此,如果每次从冷启动开始执行Julia的计算时间大约为0.3秒,则在这个阶段,Julia可能不是一个很好的选择。我将16传递给脚本,并得到以下结果:

$ time julia brian.jl 16
1045751633986928
1140820035650625
3333333466666668

real    0m15.973s
user    0m15.691s
sys     0m0.586s

并且

$ time julia iain.jl 16
1045751633986928
1140820035650625
3333333466666668

real    0m9.691s
user    0m9.839s
sys     0m0.155s

这段代码的限制在于,如果digits>=20,我们将超出Int64的存储范围。由于性能原因,Julia不会自动将整数类型提升为任意精度整数。我们可以利用对问题的了解来解决这个问题,方法是将最后一行更改为:
digits = int(ARGS[1])
excellent(digits >= 20 ? BigInt(digits) : digits)

我们可以免费获得BigInt版本的excellent,这很好。暂且不考虑这个,我发现在对我的版本进行分析时,大约74%的时间用于计算log10,其次是约19%的时间用于isqrt。我通过将最后一行替换为以下内容来进行此分析:

excellent(4)  # Warm up to avoid effects of JIT
@profile excellent(int(ARGS[1]))
Profile.print()

现在,如果我们想尝试一些小的算法改变,根据我们从分析器中所了解到的信息,我们可以用ndigits(b) > k && continue替换log10行(它只是检查实际数字的位数),这将给我们带来以下结果。
$ time julia iain.jl 16
1045751633986928
1140820035650625
3333333466666668

real    0m3.634s
user    0m3.785s
sys     0m0.153s

这将平衡点从isqrt变为约56%,而从ndigits变为约28%。进一步挖掘这56%中的内容,其中大约一半的时间用于执行this line,这似乎是一种相当合理的算法,因此任何改进都可能会改变比较的本质,因为它实际上是完全不同的方法。使用@code_native调查机器代码往往表明没有其他奇怪的事情发生,尽管我没有深入研究。

如果我允许自己进行一些较小的算法改进,我可以从root+1开始,只对ndigits进行一次检查,即

for a in l:u
    front = a * (10^k + a)
    root = isqrt(front)
    b = root + 1
    ndigits(b) > k && continue
    front == b*(b-1) && println(a,b)
    b = root
    front == b*(b-1) && println(a,b)
    b = root - 1
    front == b*(b-1) && println(a,b)
end

这让我降到了

real    0m2.901s
user    0m3.050s
sys     0m0.154s

(我并不确定第二个和第三个等式检查是必要的,但我试图尽量减少差异!)最后,我想通过预计算10^k(即k10 = 10^k)来获得额外的速度,这似乎在每次迭代中都会重新计算。通过这种方式,我可以达到以下结果:

real    0m2.518s
user    0m2.670s
sys     0m0.153s

这比原始代码好了20倍,相当不错。


1
我们可以获得额外的精度并等待数天以获得答案,但这不仅适用于Julia。 :) - brian d foy
我使用了内置的分析器 - 我会更新答案。 - IainDunning

12
我对Perl如何从这段代码中获得如此出色的性能感到好奇,因此我觉得应该进行比较。由于问题中Perl和Julia版本的代码在控制流和操作方面存在一些看似不必要的差异,所以我将每个版本都移植到另一种语言,并进行了比较。我还编写了第五个Julia版本,使用更具惯用性的数值函数,但与问题中的Perl版本具有相同的控制流结构。
第一个变体本质上是来自问题的Perl代码,但包装在一个函数中:
sub perl1 {
    my $k = $_[0];
    foreach my $n (10**($k-1) .. 10**($k)-1) {
        my $front = $n * (10**$k + $n);
        my $root = int(sqrt($front));

        foreach my $t ($root-2 .. $root+2) {
            my $back = $t * ($t - 1);
            last if length($t) > $k;
            last if $back > $front;
            if ($back == $front) {
                print STDERR "$n$t\n";
                last;
            }
        }
    }
}

接下来,我将其翻译成Julia,保持相同的控制流程和使用相同的操作-在外部循环中取front的平方根的整数向下取整,并在内部循环中取t的“字符串化”长度。
function julia1(k)
    for n = 10^(k-1):10^k-1
        front = n*(10^k + n)
        root = floor(Int,sqrt(front))

        for t = root-2:root+2
            back = t * (t - 1)
            length(string(t)) > k && break
            back > front && break
            if back == front
                println(STDERR,n,t)
                break
            end
        end
    end
end

这是问题的Julia代码,经过一些微小的格式调整,并封装在一个函数中:

function julia2(k)
    for a = 10^(k-1):10^k-1
        front = a * (10^k + a)
        root = floor(front^0.5)

        for b = root-1:root+1
            back = b * (b - 1);
            back > front && break
            log(10,b) > k && continue
            if front == back
                @printf STDERR "%d%d\n" a b
                # missing break?
            end
        end
    end
end

我将其翻译回Perl,保持与Perl代码相同的控制流结构,并在外部循环中使用root的0.5次幂的floor操作,在内部循环中使用以10为底的对数:

sub perl2 {
    my $k = $_[0];
    foreach my $a (10**($k-1) .. 10**($k)-1) {
        my $front = $a * (10**$k + $a);
        my $root = int($front**0.5);

        foreach my $b ($root-1 .. $root+1) {
            my $back = $b * ($b - 1);
            last if $back > $front;
            next if log($b)/log(10) > $k;
            if ($front == $back) {
                print STDERR "$a$b\n"
            }
        }
    }
}

最后,我写了一个Julia版本,其控制流结构与问题的Perl版本相同,但使用更具代表性的数字操作——isqrtndigits函数:

function julia3(k)
    for n = 10^(k-1):10^k-1
        front = n*(10^k + n)
        root = isqrt(front)

        for t = root-2:root+2
            back = t * (t - 1)
            ndigits(t) > k && break
            back > front && break
            if back == front
                println(STDERR,n,t)
                break
            end
        end
    end
end

据我所知(我以前做了很多Perl编程,但已经有一段时间了),这两个操作都没有Perl版本,因此没有相应的perl3变体。我分别使用Perl 5.18.2和Julia 0.3.9运行了所有五个变体,分别对2、4、6、8、10、12和14位数字进行了十次计时。以下是计时结果:

median execution time in seconds vs. digits

横轴是所需数字的位数,纵轴是计算每个函数所需的中位时间(单位:秒)。纵轴采用对数刻度(Gadfly 的 Cairo 后端存在一些呈现错误,因此上标看起来并不高)。我们可以看到,除了非常少的数字位数(2),所有三种 Julia 变体都比两种 Perl 变体快 - julia3 比其他任何东西都要快得多。有多快?以下是其他四个变体相对于julia3的比较结果(非对数刻度):

time relative to julia3 vs. digits

这里的x轴是请求的数字位数,y轴是每个变体比julia3慢多少倍。正如您在此处所看到的,我无法复制问题中声称的Perl性能 - Perl代码不是比Julia快2倍,而是比julia3慢7到40倍,并且在任何非平凡数字位数下至少比最慢的Julia变体慢2倍。我没有使用Perl 5.20进行测试 - 或许有人可以通过使用更新的Perl运行这些基准测试来查看是否解释了不同的结果?运行基准测试的代码可以在这里找到:excellent.plexcellent.jl。我是这样运行它们的:

cat /dev/null >excellent.csv
for d in 2 4 6 8 10 12 14; do
    perl excellent.pl $d >>excellent.csv
    julia excellent.jl $d >>excellent.csv
done

我使用this Julia脚本分析了生成的excellent.csv文件。
最后,如评论中所提到的,使用BigIntInt128是在Julia中探索更大的优秀数字的选项。然而,这需要一些注意编写通用算法。以下是一个可通用工作的第四个变体:
function julia4(k)
    ten = oftype(k,10)
    for n = ten^(k-1):ten^k-1
        front = n*(ten^k + n)
        root = isqrt(front)

        for t = root-2:root+2
            back = t * (t - 1)
            ndigits(t) > k && break
            back > front && break
            if back == front
                println(STDERR,n,t)
                break
            end
        end
    end
end

这与julia3相同,但通过将10转换为其参数的类型来处理通用整数类型。然而,由于算法呈指数级增长,因此对于任何比14位数字大得多的数字,计算仍需要非常长的时间:
julia> @time julia4(int128(10)) # digits = 20
21733880705143685100
22847252005297850625
23037747345324014028
23921499005444619376
24981063345587629068
26396551105776186476
31698125906461101900
33333333346666666668
34683468346834683468
35020266906876369525
36160444847016852753
36412684107047802476
46399675808241903600
46401324208242096401
48179452108449381525
elapsed time: 2260.27479767 seconds (5144 bytes allocated)

这个程序可以工作,但是等待37分钟有点长。使用更快的编程语言只能让速度倍增,比如在这种情况下可以提高40倍,但这只能多几个数字。要探索更大的优秀数字,你需要研究更好的算法。


2
是的,问题中的Perl和Julia版本非常不同。这就是为什么我进行了双向移植并进行了多方比较的原因。 - StefanKarpinski
3
“@briandfoy - 你写道“我写的未经优化的纯Perl版本运行时间是Julia版本的一半”。这个比较是和什么做的,如果不是和Julia版本比较的话?” - StefanKarpinski
2
与 C 的比较也很有趣。我期望在 Julia 中调用与 C 中相同的函数将产生相同的性能。julia3 变体比 Perl 快 40 倍,这是正确的方向。如果您也发布 C 代码,我们可以将其添加到这些基准测试中。 - StefanKarpinski
3
这是一个很好的比较。做得很好。话虽如此,我认为@briandfoy所有的性能问题都源于将Julia代码作为独立脚本外部计时,包括启动和编译时间。 Julia处于这种奇怪的中间地带;你不会在C基准测试中包含编译时间,但由于我们还没有支持缓存或输出已编译的二进制文件,因此包含它对于Julia来说并不完全不合理。 - mbauman
1
说实话,我认为log()的性能与此有很大关系,而且我使用了一些需要进行隐式转换的函数。我发布了我的C代码,这是从我的Perl直接移植过来的。 - brian d foy
显示剩余2条评论

4
当我使用ifloor(未在Mathematical Operations and Elementary Functions中列出)而不是floor时,我获得了很大的加速。用isqrt替换任意一个floor也会显示相同的加速效果。我没有看到这方面的文档记录。
现在我看到了我所期望的性能,尽管在我的Mac上,似乎Julia无法启动k = 10。 BigInt可以解决这个问题,但性能很差。我看Julia的原因之一是我希望它可以轻松处理更大的数字,所以我将继续研究这个问题。
如Colin在评论中指出的那样,其余预期速度可能隐藏在对数算法的实现中。
我很享受学习这门语言,也许等它成熟后我会再试试。

最后一点建议:您很可能可以从julia-users小组中获得进一步的优化。核心开发人员定期监视帖子,并非常重视有关性能的问题... - Colin T Bowers
5
ifloor在0.3文档(当前版本)中,但已在开发版本(您正在查看的文档)中被弃用,建议使用floor(Int,x) - Simon Byrne
这是0.3手册的链接:http://julia.readthedocs.org/en/release-0.3/manual/mathematical-operations/ - IainDunning

2
您可以尝试将您的代码放入一个函数中。
function excellent(k)
  for a = ( 10 ^ (k - 1) ) : ( 10 ^ (k) - 1 )
    front = a * (10 ^ k + a)
    root = ifloor( sqrt(front) ) # floor() returns a double

    for b = ( root - 1 ): ( root + 1 )
      back = b * (b - 1);
      if back > front
        break
      end
      if log(10,b) > k
        continue
      end
      if front == back
        @printf "%d%d\n" a b
      end
   end
  end
end

@time excellent(7)
## 33333346666668
## 48484848484848
## elapsed time: 1.451842881 seconds (14680 bytes allocated)

对于任意精度的数字,您可以使用 BigInt(例如,ten = BigInt("10")),但性能会下降...


我尝试将代码放入一个函数中,但时间并没有改变。1.45秒非常非常慢。 - brian d foy
这段代码在k > 8时对我无效。我收到了一个域错误。 - brian d foy
这也没有从命令行获取数字的位数,这是程序非常有用的一部分。 - brian d foy
ifloor更快。 - brian d foy
1
@briandfoy isqrt可能会对这一步有所帮助。 - Colin T Bowers
1
我删除了你程序中的命令行参数部分,因为我看不出任何可以改进它的明显方法。 函数的参数是k,即数字的一半: 7 对应于 14 个数字,你的 Perl 程序在我的机器上运行了 15 秒。 DomainError 是由整数溢出引起的:front 变成负数,而平方根失败。 对于 k>9,你可以使用更长的整数:Int128 只慢 3 倍,可以允许两倍的数字。 - Vincent Zoonekynd

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接