pperl 0.6.20 - The Player of Games

pperl 0.6.20 is released, and readers of the two previous posts - Shall we play a game? and Doing a REJIT - know what that means. This post carries the Benchmarks Game scoreboard. It also covers a native Memoize whose cache hit is compiled into the caller, a GPU binding on the wgpu crate for the other kind of games, and a few smaller items.

The scoreboard

The house rules are those of the first post, and every program stays within the algorithms the Benchmarks Game accepts in some language. All of ours are single-threaded.

CPU seconds at the contest sizes, every output byte-identical to the pole's. The pole is the fastest scripted-language entry, node.js unless noted, pinned to four physical cores as on the Benchmarks Game machine, which has no hyperthreading. The unpinned column is given for reference: there node's threaded programs spread onto hyperthread siblings and read differently from day to day. Ratios are pole/pperl, so above 1 pperl is faster.

benchmarkpperlpole/pperl, pinnedunpinned
pidigits 10,0001.12 s7.33x7.21x
binary-trees 214.51 s3.12x6.41x
fasta 25M3.47 s1.88x2.06x
reverse-complement 25M0.87 s1.53x (python3)1.48x
spectral-norm 5,5002.19 s1.26x1.26x
mandelbrot 16,00012.13 s1.20x1.57x
regex-redux 5M, Peta::PCRE23.09 s1.07x (python3)1.25x
fannkuch-redux 1236.69 s1.02x1.57x
n-body 50M5.87 s1.01x1.03x
k-nucleotide 25M, Peta::Dict38.98 s0.99x0.99x

k-nucleotide's 0.99x is against the pole's recorded time; timed back to back with node, pperl took 37.92 s and node 40.42 to 42.39 s.

binary-trees went from 26.1 s to 4.5 s in one day. When one compiled sub builds a tree and the next call in the same statement only walks it, no Perl code can ever see that tree, so the JIT builds it in a bump arena, one small record per node, and frees it with a single store. Every node is still built and walked, and the program is unchanged; any failure discards the arena and re-runs the statement interpreted. It is the freedom V8 takes with objects that do not escape.

mandelbrot went from 31.1 s to 19.4 s once four pixels are computed side by side in SIMD vectors, and to 12.1 s once an interval-arithmetic proof, built on the loop's own escape test, replaces the per-result overflow checks with one guard per group of pixels.

fasta and fannkuch-redux were ordinary compiler work. fasta's generator ran in doubles because a constant carried a floating-point value from an earlier division, where perl5 itself takes the integer path. fannkuch-redux now processes permutations in pairs, as the C and Lua entries do, and a range proof removes the bounds and overflow checks from its swap loop.

regex-redux is won with whatever gun is available, here Peta::PCRE2, a native binding to libpcre2 and the same means the fastest Python entry uses. The ReJIT gun announced in Doing a REJIT is not quite there: it ships in this release, on by default (--no-regex-jit turns it off), and takes the built-in engine from 8,853M to 1,703M instructions on that post's measurement input. That is 5.2x faster than the stock regex engine, against the 3-5x the post predicted; at contest size the built-in engine runs at 0.88x of the pole.

k-nucleotide took a new gun. The published Perl entry counts k-mers as strings in a hash and stops at 0.63x however well the loop compiles, because the time goes into string hashing. node packs each k-mer into a 2-bit-per-base integer and counts in a Map, and the rules allow us the same algorithm. Peta::Dict is that dictionary for Perl: tie my %h, 'Peta::Dict' binds a hash to an open-addressed table whose integer-key accesses the JIT compiles to an in-register probe, the shape that also carries Memoize below. $h{5} is still $h{"5"}, and on perl5 the same source runs on a pure-Perl fallback with identical output.

mandelbrot through PDL was the other gun we had to test, and it is the slower one: at N=2000 pperl with its native PDL retires 14.7G instructions, perl5 with XS PDL 19.0G, the JIT-compiled scalar loop 4.9G. A vector formulation pays all 50 iterations for every pixel, where the scalar loop leaves at the escape.

JIT latency is a bonus on top. A small loop run briefly measures compile time rather than compiled code, and there pperl is far ahead: 141M instructions against node's 1,597M on a 120x120 mandelbrot, and any short loop, synthetic or not, shows the same. V8 spends its longer compile on more optimisation, which is what the table above measures. We aim for both: short compile latency and compiled substrate efficiency.

A dead end: asynchronous compilation

On short runs the JIT compile is a large share of the run time, so we built the usual answer: hand the hot loop to a compiler thread and keep interpreting until the code is ready. It worked, passed the JIT suites, and was reverted, because it never won. At mandelbrot 16,000 the asynchronous build took 141 s against 131 s compiling synchronously, and no smaller size won either.

The result is counterintuitive only at first. While the compile runs, the only useful work the program has is the very loop being compiled, so the choice is between stalling and interpreting that loop. Both spend the compile's wall time; the iterations interpreted meanwhile are iterations the compiled code would have run many times faster, and polling for the finished code costs a little on top. The compile stall was a cost to shrink and not time to fill, since compiled code is so much faster that one wants it as soon as possible. Asynchronous compilation pays where the program has other compiled code to run in the meantime, which a single dominant hot loop never has.

Memoize, faster

Memoize is now a native module, and the JIT knows it. memoize('f') installs a memo CV in place of f; a compiled caller probes the cache in-register and only leaves compiled code on a miss, so a hit costs about 42 instructions per call and never builds an argument list. Source is unchanged, use Memoize; memoize('fib'); as ever.

Hit cost against perl 5.44 with CPAN Memoize, final PGO build:

functionarityspeedup
fib163.5x
dist251.7x
ackermann(2,k)2, recursive62.9x
Tak3, recursive36.7x

The geometric mean is 52.5x. The compiled path covers numeric arguments of arity 1 to 4, including nested calls, calls in return position, self-recursion and calls from outside the function. A miss-dominated run, ackermann computed once, gains 7.5x, which is the algorithm and not the cache. Non-numeric arguments take the ordinary path and behave as CPAN Memoize does, warnings included, and deep memoized recursion stays compiled with perl's recursion warning intact. A register cache for string keys is specified and deferred, since the payoff is small today.

One divergence is deliberate. CPAN Memoize keys its cache on join chr(28), @_, and a number stringifies to 15 significant digits, so the arguments 0.30000000000000004 and 0.3, two different doubles, both become the key "0.3": once f(0.3) is cached, f(0.30000000000000004) returns that result, which is the wrong answer for its argument. The compiled path keys on the raw bits of each double, which are exactly what the pure callee receives, so a hit always returns what the call would have computed. The reverse case is harmless: 1 and "1.0" are two Memoize entries and one here, and the numeric body cannot tell them apart. We do not emulate the collision.

Perl and 3D graphics

The other kind of games needs a window, a renderer and a GPU.

Peta::WebGPU is a native binding to the Rust crate wgpu, which drives Vulkan, Metal, DX12 and GL behind the WebGPU API. It computes and it draws: adapters and limits as the card reports them, buffers, WGSL shaders, compute dispatch and read-back, and render pipelines and render passes into a surface taken from an SDL2 window. Validation errors, which wgpu reports out of band, arrive as Perl exceptions carrying wgpu's own text, and a panic inside the crate becomes a croak instead of taking the interpreter down. A PDL ndarray uploads straight into a GPU buffer without a pack step. The module sits behind the cargo feature webgpu, off by default, since it links a GPU stack into the binary.

my $win  = SDL2::Window->new(title => 'triangle', w => 800, h => 600);
my $gpu  = Peta::WebGPU::Device->new(window => $win);
my $surf = $gpu->surface;

my $frame = $surf->next_frame or next;
$gpu->render_pass($frame, clear => [0.1, 0.2, 0.3, 1])
    ->pipeline($pipe)->vertices($verts)->draw(3)->end;
$frame->present;

Underneath it the 2D substrate has become real: native bindings to SDL2 (renderer, surfaces, SDL2::Image), GD, Cairo and Imager. The documentation follows the code. The 2D graphics guide has been re-anchored on SDL2, is available in all 41 locales, and closes with a capstone that is a complete small game: paddle, ball, score, keyboard control and a steady frame rate, in plain Perl on the SDL2 binding. The new 3D graphics guide runs from foundations and a software rasteriser through shading, meshes, model formats, modelling operations and scene graphs to the GPU, and closes with three capstones: a world generator, a modeller and a 3D game. Every example was run before its prose was written. Both guides are at https://perl.petamem.com/docs/eng/.

Other tidbits

  • With --no-jit, the plain interpreter is now about 20% faster than perl 5.44. The JIT numbers above are on top of that and not a substitute for it.
  • PDL is measured against perl5 running PDL's own test suite, and the native tree went from twelve mismatches to none. Exports now come from the sub-modules as upstream arranges them, the broadcast dimension engine is wired in, xchg, transpose and dummy are views as upstream's are, and glue/append on a lazy slice no longer abort.
  • Loading POSIX, PDL::Lite, PDL::LiteF or PDL::MatrixOps under -w printed hundreds of redefinition warnings perl5 does not print; they are gone.
  • A loop that cannot repay its compile time is no longer compiled, which removes a 4-8x penalty on short-running scripts.

Statistics

pperl 0.6.20 represents approximately 2 months of development since 0.6.8 and contains approximately 3,743,000 lines of changes across 42,000 files from 2 authors.

Excluding auto-generated files, documentation, assets, vendored modules and tooling, there were approximately 454,000 lines of changes to 1,700 .rs, .pl, .pm and .t files.

Get 0.6.20 here: https://perl.petamem.com/

  • Richard C. Jelinek, PetaMem s.r.o.

1 Comment

Is all development done with help of AI? Are there sources how you leverage it?
Thanks

Leave a comment

About PetaMem

user-pic All things Perl.