Статьи

Использование JavaScript Profiler для удовольствия и прибыли: пример HTML5 Canvas

Это краткое введение в то, как и зачем использовать профилировщики производительности, которые являются частью многих современных браузеров.

 

Профилировщики JavaScript — это функции браузера или надстройки, которые позволяют вам определять, какие части вашего кода работают быстро или медленно. Они могут помочь вам определить ненужный или посторонний код, который необходимо реорганизовать. Их также можно использовать для сравнения двух или более способов выполнения одной и той же задачи или для определения того, является ли определенный эффект причиной медлительности веб-приложения.

 

Моими примерами будут простые фрагменты кода Canvas на основе HTML5, но это руководство не требует предварительного знания Canvas, а принципы универсальны. Я буду использовать профилировщик IE9, потому что он один из самых многофункциональных, но тесты можно проводить на других профилировщиках.

 

Доступ к профилировщику можно получить через инструменты разработчика, которые запускаются нажатием клавиши F12 в большинстве браузеров.

 

Profilers are the easiest way to measure JavaScript performance. There are other very good options for testing performance, such as the benchmark.js library which is used in sites like jsperf.com, but these can take a while to set up. With a profiler, there’s no need for special code or any setup phase, all you need to do is click start, let some code execute, then click stop, and the results are collected for you. Here’s a very simple page that we’ll use as an example:

<!DOCTYPE HTML>
<html>
<body>
<canvas id="canvas1" width="400" height="400" style="border: 1px solid gray;"></canvas>
<script>
  var can = document.getElementById('canvas1');
  var ctx = can.getContext('2d');
  
  // call the function "drawSomeThings" every 60 milliseconds
  setInterval(drawSomeThings, 60);

  function drawSomeThings() {
    ctx.clearRect(0,0,400,400);
    for (var i = 0; i < 200; i++) {
      ctx.fillStyle = 'orange';
      ctx.font = '32pt serif';
      ctx.fillText("This is some text!", Math.random() * 400, Math.random() *400);
    }
    for (var i = 0; i < 200; i++) {
      ctx.fillStyle = 'lightblue';
      ctx.fillRect(Math.random() * 400, Math.random() * 400, 20, 20);
    }
  }
</script>
</body>
</html>


This page doesn’t do anything fancy, it just draws 400 lines of text and 400 rectangles every 60 milliseconds. It looks ugly when drawn, but the point is to simulate larger projects such as canvas games or animated webapps that might have a large number of moving entities that need to be redrawn often.

 

When you go to the “Profiler” tab of Developer Tools you’ll notice a “Start profiling” button. One of two general ways to create useful profiler data is to press “Start profiling”, reload a page, and then press “Stop profiling” to gather information about the page load. The other way is to have the page already loaded with some animation or event running and to simply press “Start profiling,” let it gather data for a few seconds, and then press “Stop profiling” when we think we have enough information captured. We’re going to do just that, and with the page above loaded we press the start button, wait a few seconds before pressing stop and get results like this:

The profiler shows the name of each function and how many times that function was called. It also shows the inclusive time, which is the time in milliseconds spent inside of a function and all of that function’s children, as well as the exclusive time, which is the time spent solely in that function. In this example the inclusive time and exclusive time are identical for all except drawSomeThings, because that’s the only function that has children. All of the other functions are built-in canvas context functions.

 

There’s another way to view the same data, if you switch “Current view” to “Call tree” instead of “Functions” you’ll find it looks like this:

This representation more clearly illustrates that every single function called was called inside of drawSomeThings. This can be very useful for distinguishing where and how much certain functions are being called from different locations. You can also see in this second view how the inclusive time of drawSomeThings is simply its exclusive time plus the inclusive time of all its child functions.

 

Let’s go back to the first view and see what we can discern. Before we look at the numbers I should note that I turned on an additional column. Often you want to compare two functions to see if one is faster but looking at inclusive and exclusive time will only tell you how much time all the calls to that function took up together, not the average time they take on each run. Luckily for us the IE9 profiler will provide us with this information: simply right click on the page and check off “Avg time (ms)”  under “Add/Remove columns” to get the same data I have here.

 

It’s very clear that calls to fillText take the longest time to complete, much longer than calls to fillRect. In fact, it looks like merely setting the font takes longer than drawing all those rects!

 

From the numbers we can notice things that might be a place for potential improvement. Setting the font 17,200 times takes longer than drawing 21,500 rects, but why are we setting the font so much? In general, it’s good to sort the list of functions by how often they are called (the “Count” column) and ask yourself if any of them ought not to be called so much. In our little program the font never changes, so setting the font only once is certainly one area for performance improvement. We could also note that everything drawn is just two colors; the text is always orange and the rectangles are always light blue. We certainly shouldn’t need to call fillStyle as much as we are. So lets refactor the code to set the font only once at the start and to only set the fillStyle once before the loops that draw the text and rects.

  function drawSomeThings() {
    ctx.clearRect(0,0,400,400);
    ctx.fillStyle = 'orange'; // set just once here, outside of the loop
    for (var i = 0; i < 200; i++) {
      ctx.fillText("This is some text!", Math.random() * 400, Math.random() *400);
    }
    ctx.fillStyle = 'lightblue'; // set just once here, outside of the loop
    for (var i = 0; i < 250; i++) {
      ctx.fillRect(Math.random() * 400, Math.random() * 400, 20, 20);
    }
  }

With this we can profile again. Letting it run for a few seconds we get:

fillStyle is now called far less, and font isn’t there at all because it was called only once as the page was loading (Before I clicked “Start profiling”). The average time of a call to drawSomethings has gone down from 25.53ms to 23.79ms. Not a huge, since it is a very simple example, but its quite possibly noticeable on slower machines and tablets and will do for our demonstration.

Now when we look at the results we see that the only really slow thing in the entire execution is fillText. If this were a real webapp and we were looking for better performance, we’d start brainstorming right now. Do we really need to draw so much text? Can we do something else that achieves the same effect?

 

As it turns out, drawing images is a lot faster than drawing text on a Canvas and so drawing pre-made images of text can be a huge performance boost if the text in your app doesn’t change much. This isn’t meant to be about Canvas performance specifically so I won’t go into the details, but I explored that concept a bit in a blog post a while ago here.

 

Now let’s consider the other typical way of using the profiler, pressing start before the page is loaded. Suppose we have this scenario: We’re making a Canvas app and we have to draw many objects, but depending on where the user has moved, not all of the objects in the app may be visible on the Canvas. This scenario comes up in games where characters scroll off-screen and in charting applications where some (possibly most) nodes and links are completely off the screen. We probably want to skip drawing something if it is going to be drawn entirely off-screen, but we aren’t yet sure, after all what if doing the bounds calculation to test if something is visible on-screen is slower than just drawing the thing in the first place? So we’ll devise a test to see: We will draw an image a large number of times, equally on-screen and off-screen, but before we draw each one we will test to see if the image’s coordinates are inside of the canvas. If the image is outside of the canvas bounds, and therefore off-screen, we will skip the call to drawImage.

 

The code for such a test is below. In this example we will use an image of a cat for realism. As we all know, if an image is being rendered on the Internet there is a high probably that it is of a cat, and we can only assume that most canvas apps on the web will have something to do with cats.

  var can = document.getElementById('canvas1');
  var ctx = can.getContext('2d');
  var WIDTH = can.width;
  var HEIGHT = can.width;
  
  var img = new Image(); // image is 220x181
  var imgwidth = 220;
  var imgheight = 181;
  // When the image is done loading this function gets called
  img.onload = function() {
    // for each iteration draw the image 160000 times,
    // with 80000 of them being off screen and 80000 being on screen
    var x = 800, y = 800;
    for (var j = 0; j < 160000; j++) {
      if (rectInCanvas(x, y, imgwidth, imgheight)) {
        ctx.drawImage(img, x, y);
      }
      x--;
      y--;
      if (x === 0) x = 800;
      if (y === 0) y = 800;
    }
  }
  img.src ="http://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Felis_silvestris_-_July_2007-1.jpg/220px-Felis_silvestris_-_July_2007-1.jpg";
  
  // returns true if the given rect is inside of the canvas bounds
  function rectInCanvas(x, y, w, h) {
    w += x;
    if (x > WIDTH || 0 > w) return false;
    h += y;
    if (y > HEIGHT || 0 > h) return false;
    return true;
  }

Unlike running the previous code, for this one we want to press start before the page is loaded, then reload the page, wait for it to complete, and finally press stop. Since everything is executed on page load this sort of profiling is easier to compare consistently, because unlike the example on a timer, all of the code will execute the same number of times with each page load.

 

This code will try to draw the cat image 160,000 times, checking every time to see if it is in bounds. We happen to know that it will be out of bounds 80,000 times, so it will only really call drawImage 80,000 times. So here we profile to test whether bounds-checking is worth it, or if there is no difference, or worse, slowing us down! What are the results?

I’m using the “Call tree” view here because the page load contains a lot of minor stuff I don’t want to bother looking at, so this view lets me parse the needed information quicker. We’re only concerned with the onload function here, and none of the window script block stuff. It’s not a big deal here, but for larger projects you can see how this organization will let you hone in on the important parts of your code that you want to test.

 

The total page load time was about 0.45 seconds, with the 80,000 calls to drawImage taking up almost all of it, and all 160,000 calls to rectInCanvas taking just 7 milliseconds! It looks like using rectInCanvas to avoid the additional 80,000 calls to drawImage is definitely worth it. We can probably assume that using rectInCanvas is cutting the page load time is half. But for the sake of thoroughness lets see the results if I don’t use rectInCanvas at all. I made an identical file but took out the rectInCanvas check and try. This one will call drawImage 160,000 times.

 

A nice feature of many profilers is the ability to go back to previous tests, so if you ever want to do several tests and compare the results across them, the data for each is recorded for you. In IE9 that feature is on the screen as the “Reports” drop-down — you can see the words “Report 1” on the previous screen-shot. That drop-down list will be saving all of our runs. So now that I’ve modified the code and start the test again we will be generating Report 2. Since 80,000 calls to drawImage took 416ms, we would expect 160,000 calls to take around 832ms. Profiling the code, this is what we get:

Strangely enough, loading the page took almost the exact same time! It looks like the average time to complete a drawImage call is much less this time around. The reason for this is probably that IE9 internally optimizes drawImage calls when nothing is drawn on a screen in an attempt to be clever and eke out a bit more performance. Good on them, but we should still check bounds with a method like rectInCanvas because it is slightly faster and because not all browsers may make this kind of internal optimization.

 

Like doing any good analysis, profiling is best when your results are repeated and averaged, and you should profile a few times (generating a few reports) to get an idea of what the average time is. Especially at with small differences like in my examples, it’s possible that the discrepancies were normal variations due to other things going on with the browser or computer. All of my example numbers above are actually the most-average report out of 4 or 5 reports of generating the same numbers. If you want meaningful numbers, especially with smaller numbers, you should always do the same.

 

That should give you a decent idea of how to begin using profilers to speed up your code. In an era where people will press the back button if your page load takes 0.5 seconds longer than average, your JavaScript can never be too fast!