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.
| benchmark | pperl | pole/pperl, pinned | unpinned |
|---|---|---|---|
| pidigits 10,000 | 1.12 s | 7.33x | 7.21x |
| binary-trees 21 | 4.51 s | 3.12x | 6.41x |
| fasta 25M | 3.47 s | 1.88x | 2.06x |
| reverse-complement 25M | 0.87 s | 1.53x (python3) | 1.48x |
| spectral-norm 5,500 | 2.19 s | 1.26x | 1.26x |
| mandelbrot 16,000 | 12.13 s | 1.20x | 1.57x |
| regex-redux 5M, Peta::PCRE2 | 3.09 s | 1.07x (python3) | 1.25x |
| fannkuch-redux 12 | 36.69 s | 1.02x | 1.57x |
| n-body 50M | 5.87 s | 1.01x | 1.03x |
| k-nucleotide 25M, Peta::Dict | 38.98 s | 0.99x | 0.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:
| function | arity | speedup |
|---|---|---|
| fib | 1 | 63.5x |
| dist | 2 | 51.7x |
| ackermann(2,k) | 2, recursive | 62.9x |
| Tak | 3, recursive | 36.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,transposeanddummyare views as upstream's are, andglue/appendon a lazy slice no longer abort. - Loading
POSIX,PDL::Lite,PDL::LiteForPDL::MatrixOpsunder-wprinted 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.
Is all development done with help of AI? Are there sources how you leverage it?
Thanks