Search code examples
javascriptperformancerandomgarbage-collectionv8

Why does Math.random() (in Chrome) allocate memory that needs cleanup by the Garbage Collector (gc)?


Story

During some tests for a performance critical code, I observed a side-effect of Math.random() that I do not understand. I am looking for

  • some deep technical explanation
  • a falsification for my test (or expectation)
  • link to a V8 problem/bug ticket

Problem

It looks like that calling Math.random() allocates some memory that needs to be cleaned up by the Gargabe Collector (gc).

Test: With Math.random()

    const numberOfWrites = 100;
    const obj = {
        value: 0
    };

    let i = 0;

    function test() {
        for(i = 0; i < numberOfWrites; i++) {
            obj.value = Math.random();
        }
    }

    window.addEventListener('DOMContentLoaded', () => {
        setInterval(() => {
             test();
        }, 10);
    });

Observation 1: Chrome profile

Chrome: 95.0.463869, Windows 10, Edge: 95.0.1020.40

Running this code in the browser and record a perfromance profile will result in a classic memory zig-zag

Memory profile of Math.random() test

Obersation 2: Firefox

Firefox Developer: 95, Windows 10

No Garbage Collection (CC/GCMinor) detected - memory quite linear

Workarounds

crypto.getRandomValues()

Replace Math.random() with a large enough array of pre-calculated random numbers using self.crypto.getRandomValues`.


Solution

  • (V8 developer here.)

    Yes, this is expected. It's a (pretty fundamental) design decision, not a bug, and not strictly related to Math.random(). V8 "boxes" floating-point numbers as objects on the heap. That's because it uses 32 bits per field in an object, which obviously isn't enough for a 64-bit double, and a layer of indirection solves that.

    There are a number of special cases where this boxing can be avoided:

    • in optimized code, for values that never leave the current function.
    • for numbers whose values are sufficiently small integers ("Smis", signed 31-bit integer range).
    • for elements in arrays that have only ever seen numbers as elements (e.g. [1, 2.5, NaN], but not [1, true, "hello"]).
    • possibly other cases that I'm not thinking of right now. Also, all these internal details can (and do!) change over time.

    Firefox uses a fundamentally different technique for storing internal references. The benefit is that it avoids having to box numbers, the drawback is that it uses more memory for things that aren't numbers. Neither approach is strictly better than the other, it's just a different tradeoff.

    Generally speaking you're not supposed to have to worry about this, it's just your JavaScript engine doing its thing :-)

    Problem: Running this code in the browser and record a performance profile will result in a classic memory zig-zag

    Why is that a problem? That's how garbage-collected memory works. (Also, just to put things in perspective: the GC only spends ~0.3ms every ~8s in your profile.)

    Workaround: Replace Math.random() with a large enough array of pre-calculated random numbers using self.crypto.getRandomValues`.

    Replacing tiny short-lived HeapNumbers with a big and long-lived array doesn't sound like a great way to save memory.

    If it really matters, one way to avoid boxing of numbers is to store them in arrays instead of as object properties. But before going through hard-to-maintain contortions in your code, be sure to measure whether it really matters for your app. It's easy to demonstrates huge effects in a microbenchmark, it's rare to see it have much impact in real apps.