Optimizing your (ZF) web application

Over at IDG, we're currently working on moving all our applications from a php4 codebase to php5 (as everyone should). In the process we're also porting all of our applications to Zend Framework (talking about migrations ;)). After finishing our first project, and moving it over the performance testing phase and all, we encountered quite a few issues. There are some typical things you can avoid, so bare with me.

A few days ago, I found this very interesting blogpost by Till, which shows a lot of common pitfalls for optimizing your ZF application. While we don't have the visitor numbers he has for this particular project, we also have a lot less hardware to work with. Let me emphasize that. We have a lot less hardware to work with.

For this particular application, we have two, dualcore, 2ghz machines available, with 2GB of memory, and some default scsi hd hardware (nothing fancy). Above on that, the 2GB memory is shared with an apache 1.3 instance, using up about 1.4GB of memory. So we end up with about 600MB for our second apache2 and php5 instance. Not much I can tell you :)

This post is divided in a few topics:

Calculating what you need

Before optimizing your application, you might want to calculate the amount of throughput you actually need for the application. In this case, we had about 3.000.000 pageviews, and about 250.000 unique visitors per month. You want to optimize for the peak performances you are going to need. We divided the pageviews by 28 (least amount of days), which got us about 108.000 pageviews per day, dividing that by 24, 60 and 60, gives us the least amount of throughput you are going to need for your application:

3.000.000 / 28 / 24 / 60 / 60 = 1.24 request/sec

The same calculation can be done for the concurrent users:

250.000 / 28 / 24 / 60 / 60 = 0,1 concurrent user

So, not a whole lot :) But. We need the application to outperform the current stats. And we want to have the application primed for higher traffic. On top of that, these numbers are (again), the least amount of traffic you are going to get. On peekmoments (look into your site analytics for those), you'll get much higher demands. Figure out what your timescale is in which you are receiving your visitors. For this particular project, the most active hours are between 11.00 (11 am) and 22.00 (11 pm). So that's 11 hours instead of 24:

3.000.000 / 28 / 11 / 60 / 60 = 2.7 request/sec
250.000 / 28 / 11 / 60 / 60 = 0,2 concurrent user

So that's the actual number you want to go for. Because we wanted to scale up for larger traffic, we've set our goal higher: 10 req/s and 100 concurrent users.

The actual benchmarking

As you can read in Till's blogpost, you can easily test with apacheBench (ab) on the commandline. Be sure to test this on a line you know isn't capped or cached. The best way to test is to benchmark on the local machine. But you might not be in the position to actually run it locally. @mathieuk pointed me to Siege, which can do a lot more in-depth testing (multiple url's and such), which might come in handy. I'll stick with ApacheBench for this blogpost, but be sure to check it out.

To test the performance, we start off with an easy test, to get a baseline result. 1000 pageviews on the homepage, by 100 concurrent users:

BASH:
  1. ab -n 1000 -c 100 http://www.example.com/

(don't forget the trailing slash)

You'll get a lot of info about the test. Be sure to run the same test every time after you optimized some code, so you know if you've already reached your goal, and you can actually compare the statistics. The number you want to look for is:

BASH:
  1. Requests per second:    4413.30 [#/sec] (mean)

Or whatever your number is. (btw: that's the actual output for webwereld.nl)

General Optimizations

Right, let's get to business. but before that, there are a couple of really simple things you should do for every web application you built:

Make sure AllowOverride None is set

Setup the ZF rewrite rules in your virtual host config (or httpd config). You don't want to parse a simple .htaccess file for every request. It shouldn't change that much, so no need to have it in your application anyway. (or setup your virtualhosts over svn and deploy those as well, works like a charm for us)

Make sure all your images are served via a CDN

Setting up a DNS alias for static files is the easiest. Serve all your images, stylesheets and all through that static host, you can even have server[1-4].static.* for your hostname, and serve them over the same server. Your browserclients will love you for that. (no more then 4 cdn servers though, as Yahoo describes).
Be sure to serve your static files through a small webserver instance, look at lighttpd, nginx or similair, or setup a very tiny small apache binary for serving the static files.
Don't forget to add caching and gzip-ing. On a linux/apache machine, we use the following rules for our static domains:

APACHE:
  1. # Also see: http://developer.yahoo.com/performance/rules.html#gzip
  2. AddOutputFilterByType DEFLATE text/css application/x-javascript application/javascript
  3.  
  4. # Use expires headers for images
  5. # Also see: http://developer.yahoo.com/performance/rules.html#expires
  6. ExpiresActive On
  7. ExpiresByType   image/gif          A604800
  8. ExpiresByType   image/jpeg         A604800
  9. ExpiresByType   image/png          A604800
  10. ExpiresDefault                     A604800
  11.  
  12. # ETags are bad!
  13. # Also see: http://developer.yahoo.com/performance/rules.html#etags
  14. FileETag None

Install Yslow for firebug

Yahoo has lots of interesting suggestions that can teach you a lot about client side optimizing your code, which is usually a lot less work then optimizing a backend :)

Install APC (or similair)

Really.

The Real Stuff (tm)

After setting up these issues, we can finally get into the ZF optimizations. We'll use php's apd module for that, and use pprofp to test the performance of our application.

As you can read on the APD page, you need to setup the apd_set_pprofp_trace(); from the point you want to start parsing. In our ZF application, you might want to put on the very first line (after the opening tag ofcourse), of the index.php file in your public dir.

After that, call the page once, a pprofp trace file will be written to disk with all the output you are going to need. To see a quick overview of the calls that are the most expensive in your application, you can call pprofp with the -R option, and you'll get something like this:

TEXT:
  1. Trace for /webdocs/com.example.www/releases/20081102125639/public/index.php
  2. Total Elapsed Time = 0.99
  3. Total System Time  = 0.19
  4. Total User Time    = 0.60
  5.  
  6.  
  7.          Real         User        System             secs/    cumm
  8. %Time (excl/cumm)  (excl/cumm)  (excl/cumm) Calls    call    s/call  Memory Usage Name
  9. --------------------------------------------------------------------------------------
  10. 140.9 0.00 1.39  0.00 0.79  0.00 0.25    28  0.0000   0.0496            0 include
  11. 100.0 0.00 0.99  0.00 0.60  0.00 0.19     1  0.0000   0.9857            0 main
  12. 94.0 0.00 0.93  0.00 0.56  0.00 0.17     1  0.0000   0.9267            0 Zend_Controller_Front->dispatch
  13. 92.7 0.00 0.91  0.00 0.56  0.00 0.16     1  0.0000   0.9133            0 Zend_Controller_Dispatcher_Standard->dispatch
  14. 87.8 0.00 0.87  0.00 0.53  0.00 0.15     1  0.0000   0.8658            0 PageController->dispatch
  15. 81.3 0.00 0.80  0.00 0.49  0.00 0.13     6  0.0000   0.1335            0 Smarty->fetch
  16. 80.3 0.00 0.79  0.00 0.48  0.00 0.13     1  0.0000   0.7918            0 Zend_Controller_Action_HelperBroker->notifyPostDispatch
  17. 80.3 0.00 0.79  0.00 0.48  0.00 0.13     1  0.0000   0.7916            0 Zend_Controller_Action_Helper_ViewRenderer->postDispatch
  18. 80.3 0.00 0.79  0.00 0.48  0.00 0.13     1  0.0000   0.7913            0 Zend_Controller_Action_Helper_ViewRenderer->render
  19. 80.0 0.00 0.79  0.00 0.48  0.00 0.13     1  0.0000   0.7890            0 Zend_Controller_Action_Helper_ViewRenderer->renderScript
  20. 80.0 0.00 0.79  0.00 0.48  0.00 0.13     1  0.0000   0.7887            0 IDG_View_Smarty->render
  21. 80.0 0.00 0.79  0.00 0.48  0.00 0.13     1  0.0001   0.7885            0 IDG_View_Smarty->_run
  22. 66.4 0.00 0.65  0.00 0.37  0.00 0.12    21  0.0000   0.0312            0 Smarty->_smarty_include
  23. 53.6 0.00 0.53  0.00 0.28  0.00 0.07   186  0.0000   0.0028            0 IDG_Db_Adapter_Pdo_Mysql->query
  24. 27.5 0.07 0.27  0.02 0.11  0.04 0.16   125  0.0005   0.0022            0 require_once
  25. 12.3 0.00 0.12  0.00 0.09  0.00 0.01    22  0.0000   0.0055            0 ProductProperty->__construct
  26. 11.6 0.00 0.11  0.00 0.08  0.00 0.01    29  0.0000   0.0039            0 IDG_Db_Adapter_Pdo_Mysql->describeTable
  27. 11.3 0.00 0.11  0.00 0.07  0.00 0.02    11  0.0000   0.0101            0 Game->getProperty
  28. 10.7 0.00 0.11  0.00 0.02  0.00 0.01    93  0.0000   0.0011            0 Zend_Db_Statement_Pdo->execute
  29. 10.0 0.00 0.10  0.00 0.07  0.00 0.02    93  0.0000   0.0011            0 IDG_Db_Adapter_Pdo_Mysql->prepare

From this view, you can easily distinguish which methods are called the most often, and where you need to look for the best optimizations you can make. (you can actually see more lines with the -n option). You can also get a complete listing of the complete application tree. This might be easy to get a quick overview of where code gets repeated a lot. If so, you can try to make sure to optimize those pieces at once.

The first time we ran pprofp, our tree listing was a staggering 229.602 lines long (woops!). This means that for every homepage request, the parses needs to parse and read through those 229.602 lines. After a few runs, we got the total number of lines down to 36.810 lines, a lot better, but it's a lot of manual labour. Reading through the dumps afterwards, we filtered out the following things to keep in mind:

Zend Framework tips

Take a good look at your bootstrap.

Do you really need that ridiculously long include path? Be sure to setup the most used paths in front of the include path, not in the back. Use plugins with caution, don't set them up, if you don't need them.

Zend_Loader is (slightly) faster then require_once

Not much, but it can become quite significant if you have a lot of includes. As Till also mentions, it might be good to write your own xyz_Loader, and use that for your inclusion paths. Although your mileage may vary.

Zend_Db

It's an awesome piece of code, but it can be very slow. Try to avoid it for larger, queries, especially if your doing joins over multiple tables. We've had instances were we had over 120 lines in our call tree, for a query which only outputs a total amount of something.
If you don't need fancy prepare and execute functionality, you can get a simple query value really easy like this (yes, it does have it's downsides):

PHP:
  1. $db = Zend_Registry::get('dbAdapter');
  2. $result = $db->getConnection()->query('SELECT COUNT(id) AS amount FROM articles');
  3. $row = $result->fetchObject();
  4. return $row->amount;

In this way, you can save up a lot of redundant calls, which aren't used anyway (and you cache the output really easy with Memcache).

Zend_Controller_Router_Route

Be sure to use Zend_Controller_Router_Route if you can, try to use Zend_Controller_Router_Route_Regex as little as possible, but you already figured that one, right? :)

Zend_Filter

We have a UBB filter, which parses all our user comments for UBB tags ([url][/url], etc). We've built our own filter for that, but be sure to make it as efficient as possible. As the php doc mentions, you should use str_replace whenever possible. But be sure to still check if your pushing it to the max.

Zend_Config

We have a application config (ini) file for every application. This config file is written in our deployment process. We access these values in our application via:

PHP:
  1. $config = Zend_Registry:get('config');
  2. $title = $config->application->title;

That's fine and works like a charm. But every time you call the $config->application->title, array_key_exists is called, which can end up in something like this:

TEXT:
  1. Zend_Registry::getInstance
  2. Zend_Registry::get
  3.   Zend_Registry::getInstance
  4.   Zend_Registry->offsetExists
  5.     array_key_exists
  6.   Zend_Registry->offsetGet
  7. Zend_Config_Ini->__get
  8.   Zend_Config_Ini->get
  9.     array_key_exists
  10. Zend_Config->__get
  11.   Zend_Config->get
  12.     array_key_exists
  13. Zend_Config->__isset
  14. Zend_Config_Ini->__get
  15.   Zend_Config_Ini->get
  16.     array_key_exists
  17. Zend_Config->__get
  18.   Zend_Config->get
  19.     array_key_exists
  20. Zend_Config->__get
  21.   Zend_Config->get
  22.     array_key_exists
  23. Zend_Config_Ini->__get
  24.   Zend_Config_Ini->get
  25.     array_key_exists
  26. Zend_Config->__get
  27.   Zend_Config->get
  28.     array_key_exists
  29. Zend_Config->__get
  30.   Zend_Config->get
  31.     array_key_exists
  32. Zend_Config_Ini->__get
  33.   Zend_Config_Ini->get
  34.     array_key_exists
  35. Zend_Config->__get
  36.   Zend_Config->get
  37.     array_key_exists
  38. Zend_Config->__get
  39.   Zend_Config->get
  40.     array_key_exists

Not very efficient now, is it? :) So if you have a value which you are going to use more often, make sure to set it up as a constant (which it actually is), like so:

PHP:
  1. if(!defined("APPLICATION_TITLE"))
  2. {
  3.     define("APPLICATION_TITLE", $config->application->title);
  4. }

then you can just call the constant, while still using the parsed config file.

Other stuff

Some other important issues to take into account when optimizing code:

Avoid shell_exec

really, it's slooooooow, (up to 77 msec per call in our situation). Which can really add up if you have it in multiple places. We had a simple:

BASH:
  1. file -i /tmp/file

for every thumbnail (don't ask). which took a lot of resources, use alternatives when available.

Optimize Smarty

Be sure to unload the smarty modules you don't need

Ofcourse there are a lot more tips for optimizing code out there. In this blogpost I wanted to show you how you can look through your (ZF) code, and show you which common pitfalls there are. Every application is different, so your mileage may vary. If you have any more tips, please let me know in the comments.


About this entry