Skip to content

ζ on the GPU: a phase portrait ​

The two-argument zeta is a sheet when you plot its height. But its real character shows when you fix ss and sweep the second argument aa across the whole complex plane and colour each point by the value there — a domain colouring:

  • hue is the phase arg⁡ζ(s,a)\arg \zeta(s, a), so a full turn of colour circles a zero or a pole;
  • brightness rises with ∣ζ(s,a)∣|\zeta(s, a)| — dark at zeros, blazing at poles;
  • faint bands mark each doubling of magnitude.

Every pixel below runs the same Euler–Maclaurin kernel as the CPU path — @enumeratio/analytic's exported zetaWGSL, evaluated per pixel, per frame, on the GPU, through the general <notatio-complex-plot> element. Both conventions are drawn at once from the same ss, so the difference between them is a glance rather than a toggle. Drag to pan, ⌘/Ctrl + scroll to zoom; hit ▶ to sweep Im⁡(s)\operatorname{Im}(s) up the critical strip.

Both conventions, swept together

One pair of sliders drives both portraits, so the only difference on screen is the convention.

HurwitzZeta(s, a) — poles at every nonpositive integer

Zeta(s, a) — the generalized convention, no poles

source · editable

What to look for ​

HurwitzZeta. Along the negative real axis the poles at a=0,−1,−2,…a = 0, -1, -2, \ldots flare white, each wrapped in a full pinwheel of phase. Between them the colour wheels turn the other way around the zeros.

Zeta. In the lower portrait the poles are simply gone. The generalized zeta drops the singular (n+a)=0(n + a) = 0 term, so it is finite at the nonpositive integers — the left half-plane reorganizes into a clean periodic lattice. That is the one real difference between the two conventions, and with both drawn from the same ss it is the only thing that differs between the two pictures.

Hatched. Below the real axis ζ(s,a)\zeta(s, a) is far smaller than the terms it sums: for Im⁡(s)=t\operatorname{Im}(s) = t the value is about e−t∣arg⁡a∣e^{-t|\arg a|} times its largest term (across the cut along a≤0a \le 0 the two sides differ by that factor). The GPU's f32 sum cancels away its digits there, so the portrait declines: where the kernel finds its terms more than 100 times the sum it draws a grey hatch rather than a hue. The test against the f64 kernel holds the drawn pixels to a relative error under 0.05 (a phase error under 0.05 rad) and finds the upper half never hatched; the CPU kernel, and the mpmath oracle it matches, are the way to read the hatched region.

s up the critical strip. Play the sisi slider: at large imaginary part the portrait develops the fine oscillatory texture that makes zeta on the critical line so delicate. (The GPU works in f32, so this is a portrait, not a proof — the CPU kernel and the mpmath/Wolfram oracles remain the source of truth for accuracy.)

Why it's fast ​

Two compilers are at work. ce.compile(expr, { target: "wgsl" }) normally can't emit these heads — compute-engine doesn't know HurwitzZeta — so @enumeratio/analytic registers a compile handler that emits a call into zetaWGSL. That path is real-scalar, which suits a surface plot and not a portrait, where the phase is the picture; so a second, complex lowering emits the same kernels as vec2f, and that is what colours the plane above.

What keeps it interactive is that the sliders move a literal, not the expression's shape. Numeric literals compile to uniform slots, and the pipeline is cached on the shape, so dragging Im⁡(s)\operatorname{Im}(s) re-uploads a few bytes rather than rebuilding a shader — about 0.1 ms of work per frame outside the GPU. That headroom is why the value can be interpolated between slider steps instead of jumping: the slider is quantised and fires below the refresh rate, and the frames in between are free.