Skip to content

fastCbrt passes a double 1/3 where Math::powSafe takes a float exponent - 3.2% of inputs diverge #163

Description

@wormeyman

fastCbrt passes a JavaScript double 1/3 into fastPow:

export function fastPow(x: number, p: number): number {
  return fastPow2(f32(p * fastLog2(x)));   // p is a double here
}
export function fastCbrt(x: number): number {
  return fastPow(x, 1 / 3);                // 0.3333333333333333, not f32
}

The game's exponent is a float, so the multiply happens at single precision.

Read from the binary, not inferred

The symbol is __ZN4Math7powSafeEff = Math::powSafe(float, float) at 0x102955a88 in
the 2.1.12 arm64 binary - both parameters are float. The non-integral-exponent path
inlines fastapprox log2 (the same 0xbfbfbf75 / 0x3eb444f9 / 0xbfdce9a3 constants we
already carry) and then multiplies by the exponent at single precision:

102955ba8: fdiv  s2, s3, s2
102955bac: fadd  s0, s0, s2      ; s0 = fastlog2(x)
102955bb0: fmul  s0, s0, s1      ; <-- s1 is the exponent, SINGLE precision

f32(1/3) is 0.3333333432674408; the double is 0.3333333333333333. They are not equal,
so f32(double * log2) and f32(f32 * log2) can round to different f32 values.

Measured divergence

Current fastCbrt(x) vs fastPow(x, Math.fround(1/3)), 200,000 log-uniform samples over
x in [1e1, 1e8] (the band resource quantities live in):

6430 / 200000 = 3.215% of inputs differ.
Worst relative 2.867e-5, worst absolute 7.813e-3, at x ~= 2.023e7.

One rival explanation, tested and REFUTED

Math::powSafe also has an integral-exponent fast path (fcvtzs/scvtf round-trip at
0x102955ab0, then exponentiation by squaring) that skips fastapprox entirely. Our
fastPow has two other call sites that pass an integer octaves
(multioctaveNoise.ts:73, trees/treeField.ts:109), so the obvious worry is that the
multioctave RMS normalisation should be using exact squaring.

It should not. Swapping the norm to f32 exponentiation-by-squaring and re-measuring
against oracle-multioctave.seed123456.json (7 cases x 38 positions, persistences
0.45/0.5/0.65/0.7/0.9 - i.e. covering the non-power-of-two cases where the two differ most):

norm worstNear worstFar
fastPow (current) 2.630e-5 1.170e-4
exponentiation by squaring 5.332e-4 3.385e-4
gate < 5e-5 < 2e-4

Squaring is 20x worse and fails both gates. So the noise machine's normalisation does
not route through Math::powSafe, and fastPow's current behaviour is correct at both
of its integer-exponent call sites. This issue is therefore only about the 1/3
exponent in fastCbrt.

What is still open

Whether the resource subsystem (spot_height / blob_amplitude / the spot-selection cone
radius - a different code path from the noise-expression evaluator) actually calls
Math::powSafe. The measurement above says the noise machine does not; it says nothing
about the resource autoplacer. Settle that before changing fastCbrt, because the fix is
only correct if powSafe is the function on that path.

The fix, if confirmed, is one character-class change: fastPow(x, f32(1 / 3)).

Related: the fastApprox per-op rounding change in #161, and #162 on why a
tolerance-based suite cannot see any of this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions