Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[p5.js 2.0 Beta Bug Report]: Rendering significantly slower in 2.0 #7539

Closed
2 of 17 tasks
aferriss opened this issue Feb 12, 2025 · 16 comments
Closed
2 of 17 tasks

[p5.js 2.0 Beta Bug Report]: Rendering significantly slower in 2.0 #7539

aferriss opened this issue Feb 12, 2025 · 16 comments

Comments

@aferriss
Copy link
Contributor

aferriss commented Feb 12, 2025

Most appropriate sub-area of p5.js?

  • Accessibility
  • Color
  • Core/Environment/Rendering
  • Data
  • DOM
  • Events
  • Image
  • IO
  • Math
  • Typography
  • Utilities
  • WebGL
  • Build process
  • Unit testing
  • Internationalization
  • Friendly errors
  • Other (specify if possible)

p5.js version

2.0.0 beta 2

Web browser and version

Chrome 132.0.6834.111

Operating system

MacOSX Sequoia 15.2

Steps to reproduce this

Both webGL and canvas renderer performance seems to have dropped between 1.11.3 and 2.0.0 beta 2 significantly. In the attached sketch, I try to draw 1000 particles. In 1.11.3, I'm able to achieve a smooth 60fps. In 2.0, I barely crack 20fps with webGL, 40fps for canvas. In fact in 1.11.3 I can render 3500 particles in webGL still at 60fps. You can comment between the two libraries in the index.html file.

There are some other issues mentioning webGL losing perf from around a year ago #6743 , I didn't see anything more recent but since this seemed like (to me) a common use case it felt worth bringing up again, especially since the canvas renderer appears to be significantly slower as well.

Steps:

  1. Try to draw 1000 shapes in 1.11.3, then do the same in 2.0.0 beta 2
  2. Observe the performance difference

Snippet:

webgl: https://editor.p5js.org/aferriss/sketches/_x59zc5AH
canvas: https://editor.p5js.org/aferriss/sketches/lFcjKYZKM

let renderWidth = 2000;
let renderHeight = 2000;

let fbo;
let nParticles = 1000;
let particles = [];


class Particle {
    constructor(x, y, z, size) {
        this.x = x;
        this.y = y;
        this.z = z;
        this.size = size;
        this.color = color(random(0, 255), random(0, 255), random(0, 255));
    }

    update() {
        this.z += 2;
        if (this.z > 800) {
            this.z = -100;
        }
    }

    draw() {
        push();
        translate(this.x, this.y, this.z);
        rectMode(CENTER);
        fill(this.color);
        noStroke();
        rect(0, 0, this.size, this.size);
        pop();
    }
}
let fontLoaded = false;

function setup() {

    createCanvas(renderWidth/4, renderWidth/4, WEBGL);
    let fboOptions = {
        width: renderWidth,
        height: renderHeight,
        depth: true,
        alpha: true,
    };
    fbo = createFramebuffer(fboOptions);
    pixelDensity(1);

    for (let i = 0; i < nParticles; i++) {
        particles.push(
            new Particle(
                random(-renderWidth / 2, renderWidth / 2), 
                random(-renderHeight / 2, renderHeight / 2), 
                random(-100, 800), 
                random(10, 20)
            )
        );
    }

    loadFont("Arial.ttf", font => {
        textFont(font);
        textSize(20);
        textAlign(LEFT, TOP);
        fontLoaded = true;
    });
}

function draw() {
    // render some particles into an fbo
    // fbo is not necessary, you can remove and see the same issue
    fbo.begin();
      push();
      clear();
      background(255);
      for(let i = 0; i < particles.length; i++) {
          particles[i].update();
          particles[i].draw();
      }
      pop();
    fbo.end();
  
    image(fbo, -width/2, -height/2, width, height);
    
     // draw fps
    if (fontLoaded) {
        push();
        fill(255, 0, 0);
        text("FPS: " + frameRate().toFixed(2), -width/2 + 20, -height/2 + 20);
        pop();
    }
}

@aferriss aferriss changed the title [p5.js 2.0 Beta Bug Report]: WebGL Significantly slower in 2.0 [p5.js 2.0 Beta Bug Report]: Rendering Significantly slower in 2.0 Feb 12, 2025
@aferriss aferriss changed the title [p5.js 2.0 Beta Bug Report]: Rendering Significantly slower in 2.0 [p5.js 2.0 Beta Bug Report]: Rendering significantly slower in 2.0 Feb 12, 2025
@ksen0
Copy link
Member

ksen0 commented Feb 12, 2025

After looking into this, I think FES updates might be playing a big role.

I tried both sketches with p5.disableFriendlyErrors = true; to see if FES checks could be part of it.

  • For the WebGL sketch, when I leave it running, the FPS first hovers at 15/14 but then seems to gradually decrease, so there does seem to be some specific WebGL issue here. In the WebGL sketch, the FES being on makes the FPS hover around 10/15, so there's definitely some influence from FES on performance, but it's not the only thing.

  • However for Canvas, even when I leave it running, without FES on the FPS stays around 30 (same as for 1.x in my test), so it seems like the FES is the main source of the change. Maybe there's other influences, but this seems to be accounting for the most noticeable part of it.

cc @limzykenneth

@aferriss
Copy link
Contributor Author

@ksen0 Thanks for looking into it! I think there still could be something going on with the canvas mode. Try cranking the amount of particles up to 10,000. 1.11.3 runs that around 30-40fps, while 2.0 drops down to around 5, even with fes disabled.

@davepagurek
Copy link
Contributor

@limzykenneth I did some profiling and it looks like the culprit is push() and the updated implementation that clones properties:

Image

I see structuredClone in there, which is used in p5.Color, but I'm not sure if that on its own is the main issue or if it's just cloning more things than before.

We talked before about not cloning everything, just doing a shallow copy, and cloning when a property is modified. This is still feasible but will take a bit of an audit of the codebase to make sure we've converted everything. Could be worth it though?

@davepagurek
Copy link
Contributor

Also @ksen0 how much of a performance decrease do you see in WebGL over time?

I wasn't able to reproduce the same behaviour on my mac, and in Chrome's memory debugger I see the memory wiggling between 74 and 78 mb but not slowly increasing, so I wonder if that's something like the computer getting hot and throttling? Not sure though, let me know if you or anyone else sees something like memory increasing over time!

@davepagurek
Copy link
Contributor

So structuredClone is definitely a big issue. I tried replacing the colorMaxes clone method with one that doesn't use that implementation, and I went from 9fps to 20fps:
https://editor.p5js.org/davepagurek/sketches/abmzdzWMI

That said, 1.x still runs at 60fps, and the flame graph still shows push and pop taking up most of the time:
Image

@aferriss
Copy link
Contributor Author

Thanks @davepagurek for taking a look!

@limzykenneth
Copy link
Member

limzykenneth commented Feb 12, 2025

There are a few things that I found that can optimize things a bit more. structuredClone that @davepagurek found is one of them and I'll need to possibly think of alternative for those in the two instances where they are used.

The other one is the color toString method which uses color.js serialize which is slower than needed, I'm thinking of memoizing it but also not sure if it would solve everything. Will look into it as well.

this.updateShapeVertexProperties(); and this.updateShapeProperties(); are being called in the renderer pop function and they for some reason are relatively heavy, can they not be called so often? I'm seeing call to these methods in other places in the renderer as well which can cause these to be called multiple times per draw call. @davepagurek Not sure if you can look into it?

this._cachedFillStyle = this.drawingContext.fillStyle; and this._cachedStrokeStyle = this.drawingContext.strokeStyle; take a bit too much time in the renderer2D's pop function which may be due to accessing the drawing context's internal getter method. If possible it would be good to refactor that out.

We talked before about not cloning everything, just doing a shallow copy, and cloning when a property is modified. This is still feasible but will take a bit of an audit of the codebase to make sure we've converted everything. Could be worth it though?

@davepagurek I'm thinking is it possible to implement a copy on write algorithm in JS?

@davepagurek
Copy link
Contributor

I tried not doing anything fancy yet but just manually copying on write in here: #7543

  • In the WebGL test (https://editor.p5js.org/davepagurek/sketches/g_VvRD_OaT) on Chrome this goes from 20fps to 60fps by not cloning matrices all the time
  • In 2D mode (https://editor.p5js.org/davepagurek/sketches/MBrz_2-lZ) for me this is still at around 11fps on Chrome
    • Adding p5.Renderer.prototype.updateShapeProperties = () => {} to disable it (and same for shape properties) doesn't move the needle for me
    • Commenting out the fill() call brings it up to 20fps, which is better but still not all the way there. I think @limzykenneth the serialize method is a good candidate for optimization, but it looks like there's still something else up in addition to that.

The other change since 1.x is that shapes are created on a Path2D and then drawn, which might be the source of some of the slowdown. I'll test that next.

Image

@davepagurek
Copy link
Contributor

Another update:

The path thing is actually only different in beginShape/endShape, so the rect performance is likely not different in 2.x.

I tried commenting out the fill() and setting a fill color outside of the loop, and adding this to disable the extra push/pop overhead from accessing the fill/stroke styles:

p5.Renderer2D.prototype.push = function() {
  this.drawingContext.save()
  p5.Renderer.prototype.push.call(this)
}
p5.Renderer2D.prototype.pop = function() {
  p5.Renderer.prototype.pop.call(this)
  this.drawingContext.restore()
}

...but this still maxes out at 20fps for me. If I avoid calling the base renderer's push/pop at all:

p5.Renderer2D.prototype.push = function() { this.drawingContext.save() }
p5.Renderer2D.prototype.pop = function() { this.drawingContext.restore() }

...then this brings the frame rate up to match and exceed that in 1.x.

I tried replacing the p5.Renderer.prototype.push.call with just the shallow clone, and interestingly, the way we do the clone affects the fps a lot in chrome:

const currentStates = Object.assign({}, this.states); // 18fps

const currentStates = { ...this.states }; // 26fps

// 33fps
const currentStates = {}
for (const key in this.states) {
  currentStates[key] = this.states[key]
}

It's kind of wild to me that those have such different frame rates. In Firefox it's all equivalent for me, and I still hit 60fps for any of those. Meanwhile, in Chrome, not trying to clone this.states at all gets 60fps, and doing the for loop drops me down to 30. Is this some weird Chrome bug I'm encountering?? Does this happen to anyone else? https://editor.p5js.org/davepagurek/sketches/RJIS2dIVZ

@limzykenneth
Copy link
Member

limzykenneth commented Feb 12, 2025

I have an implementation of memoized color serialization that seems to work and it doesn't seem to affect memory performance while getting an extra 25-30ms per frame. I'll refine it a bit before pushing it.

@davepagurek I was just about to say, I'm also seeing for some reason Object.assign is quite slow in Chrome. Edit: it seems someone saw the same as well: https://stackoverflow.com/questions/78897580/performance-difference-between-chrome-and-firefox-object-assign-spread-adding

@davepagurek
Copy link
Contributor

davepagurek commented Feb 12, 2025

I wonder if we're hitting an issue with the size of the object. I tried commenting out a bunch of properties from Renderer.states here and I gained 10fps of performance on my machine: https://editor.p5js.org/davepagurek/sketches/ai5-rZ8LO

Edit: maybe if we namespace a bunch of the properties we can get around this issue? Although if we have this.states.font.family now, we'll have to shallow copy this.states.font when we want to set the family

@limzykenneth
Copy link
Member

It could be from what I glance from online that Object.assign iterate through keys so the more keys the more operation is needs to do.

I'm thinking of somehow instead of copying (even shallow copying) the entire state object into the stack, if we can just copy the states that have changed perhaps that can help?

Namespace can maybe help and I think the shallower the state object is the better although I'm not sure if it would work since this is pretty weird behavior anyway.

@davepagurek
Copy link
Contributor

Here's one more demo that uses some Object.defineProperty getters/setters to keep track of what has been changed. Still without per-particle fill, this hits ~38fps for me. Better than before, but still not at the 60 I get on 1.x. https://editor.p5js.org/davepagurek/sketches/GjXb-TDzJS

@davepagurek
Copy link
Contributor

Ok it looks like, without per-particle fills, we're back to equivalent performance! https://editor.p5js.org/davepagurek/sketches/XAQwXN6OR

To summarize, the updates I had to include:

  • states is now a class that tracks diffs
    • To update state, you have to call setState(key, value) with a new object
  • pop() now only applies the values that have been changed
  • updateShapeVertexProperties now only updates if the relevant properties have been modified
  • I put the cached fill/stroke values into states so that we don't have to grab them again from drawingContext

@limzykenneth
Copy link
Member

@davepagurek Nice, do make a PR and I can try it out locally as well. I have the color serialization one almost done, just want to try some other optimization and I'll do a PR for that tomorrow as well.

@davepagurek
Copy link
Contributor

I'm going to close this one for now since we've addressed the main things. We'll release a new beta version soon, and then feel free to make new issues if more perf issues come up!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants