Krzysztof Kaczyński
Krzysztof Kaczyński

Reputation: 5071

How to measure total memory used by a single function

I wonder how to measure total memory used by the function in JavaScript. I found that there is an API performance.memory. I have noticed that usedJSHeapSize is not only for the single page but can also share summary between other tabs. Let's assume that in my case, I have always opened only one tab, so I should measure memory of only this one tab I am interested in. I also read that I should wait some time after function execution to let garbage collector clear all unused memory. In the end, I came up with below a solution, but I do not know is it a good idea and is the way how I measure memory correct. If you have any thoughts about that, let me know please.

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function fun1() {
  // function logic...
}

function fun2() {
  // function logic...
}


function measureMemory(functionToMeasure) {
  const memoryStart = performance.memory.usedJSHeapSize;
  functionToMeasure();
  const memoryEnd = performance.memory.usedJSHeapSize;
  return memoryEnd - memoryStart;
}


async function runBenchmark() {
  const totalMemoryUsedByFun1 = measureMemory(fun1);
  // I wait 2min to be sure garbage collector clean all unused memory
  await wait(120000);
  const totalMemoryUsedByFun2 = measureMemory(fun2);
}

What do you think, is it a good approach/idea of measuring memory used by function? Does 2min is enough time for garbage-collector to clear unused memory?

Example of what I am worried:

function fun1() {
  // alocates 1000KB
}

function fun2() {
  // alocates 300KB
}
// initial heap size = 0
const memoryUsedByFun1 = measureMemory(fun1());
// Heap size is equal 1000KB
// Now let's say I do not wait for GC and only 500KB have been released
// heap size before measuring fun2 is equal to 500KB
const memoryUsedByFun2 = measureMemory(fun2());
// While fun2 is running, 500KB of fun1 allocations have been released
// So now if I do memoryStart - memoryEnd 
// Where memory start was 500KB and memory end is equal to 300KB 
// because 500KB was released while computing fun2 
// I will get result 500KB - 300KB = 200KB 
// What is not true because fun2 allocates 300KB)

I also have seen that there is such API measureUserAgentSpecificMemory(), but I think this API is not working at all even after turning on #experimental-web-platform-features

Upvotes: 4

Views: 1200

Answers (1)

jmrk
jmrk

Reputation: 40511

(V8 developer here.)

You're absolutely right to be worried; this isn't going to work.

One reason is that performance.memory is (intentionally) not precise enough to support this pattern. Open the DevTools console and see for yourself:

> var before = performance.memory.usedJSHeapSize; 
  new Array(1000);
  console.log(performance.memory.usedJSHeapSize - before);
< 0

(You'd expect to get 4024 bytes for that array, plus possibly a bit more for the act of retrieving and printing the heap size.)

Another reason is that there's no way to prevent the GC from collecting short-lived garbage while your function is still running.

The proposed measureUserAgentSpecificMemory API isn't going to address either of these points, as its description informs you.

Manually controlling GC cycles is generally a good idea for "lab condition" tests like this, but waiting two minutes isn't an effective way to achieve that. In colloquial terms, imagine the following conversation: "Yo, GC, we got a minute of idle time, wanna do some work?" "Nah, only 500KB allocated since the last time I ran, it'd be a waste of CPU cycles to check if those are garbage already."
Instead, run V8 with --expose-gc to get a gc() function you can call whenever you want. In combination with --trace-gc, that's the best way I can think of to accomplish something like what you're trying to do here.

Obligatory disclaimer: you do not want to trigger GC cycles manually in production code; this is only useful for creating very specific situations for targeted testing.

For example, take code like:

function f1() {
  for (let i = 0; i < 1000; i++) new Array(1000);
}
gc();
console.log(">>> START");
f1();
gc();
console.log(">>> END");

And run that in V8's d8 shell or in node with --expose-gc --trace-gc. You'll get something like:

[14531:0x556bc769a540]        2 ms: Mark-sweep 0.9 (1.9) -> 0.5 (2.9) MB, 0.6 / 0.0 ms  (average mu = 1.000, current mu = 1.000) testing; GC in old space requested
>>> START
[14531:0x556bc769a540]        3 ms: Scavenge 1.6 (2.9) -> 0.6 (2.9) MB, 0.0 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
[14531:0x556bc769a540]        3 ms: Scavenge 1.6 (2.9) -> 0.6 (2.9) MB, 0.0 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
[14531:0x556bc769a540]        3 ms: Scavenge 1.6 (2.9) -> 0.6 (2.9) MB, 0.0 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
[14531:0x556bc769a540]        3 ms: Scavenge 1.6 (2.9) -> 0.6 (2.9) MB, 0.0 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
[14531:0x556bc769a540]        3 ms: Mark-sweep 0.6 (2.9) -> 0.5 (2.9) MB, 0.2 / 0.0 ms  (average mu = 0.765, current mu = 0.765) testing; GC in old space requested
>>> END

Then add up the differences between the respective heap sizes after one cycle and at the start of the next, e.g. for the first pair take the 1.6 from "Scavenge 1.6 ... -> ..." and subtract the 0.5 from the previous "Mark-sweep ... -> 0.5".
In total, that's (1.6 - 0.5) + (1.6 - 0.6) + (1.6 - 0.6) + (1.6 - 0.6) + (0.6 - 0.6) == 4.1 MB, which is reasonably close to the 4.02 MB needed by the arrays. (I'm not sure where the additional 0.08 MB are coming from; might be a measurement/rounding artifact.)


It's unfortunate that you didn't describe why you care about this in the first place; it might be a less-than-promising approach to solve whatever your original problem is. One thing to keep in mind is that there is no single answer to "how much does this function allocate?", because (1) JavaScript engines will sometimes allocate objects for internal metadata (which might happen in one run of your function but not in another), and (2) after a function gets optimized, it often allocates less than its unoptimized version did (because certain allocations can sometimes be optimized out).

A consequence of the former is that you may easily get confused by apparently-inconsistent measurement results; a consequence of the latter is that when you try to make your performance-sensitive code as close to allocation-free as possible, but you're only looking at unoptimized execution, you may end up wasting time on things that don't matter any more once the optimizer kicks in. (Not to mention the fact that avoiding allocations might not be the most impactful optimization anyway, so driving down the "how much gets allocated" metric isn't useful unless profiling says it is.)

Upvotes: 4

Related Questions