Hi all,

I have a tuning case here... We have a 4000-items, hierarchically-structured (up to 7 depth levels) menu tree which has to be managed by workbench_access (we are migrating a big system from another system called Aurix which has a nice feature to present nodes as a hierarchical tree, and there are 4000 nodes, and we figured we might use workbench_access to manage edition privileges by hierarchy level.

When loading the menu page (/admin/structure/menu/manage/menu-hierarchical), it takes about 3 minutes and 615MB of memory just to show the tree on a 16 cores Xeon 2.4GHz with... enough RAM (granted, there's no need to see the full tree with the whole management table, and only one core can be used at a time, but I just wanted to give an idea of the stress generated).

This also means that a less-exhausting version of the tree has to be available in the content creation forms as a select box.

To make things short, xhprof reports that two functions are really causing the big lag here array_unique() and array_merge(), and they're called a large number of times.

Running through the code to find the location where these functions are most often called, I found (around line 1151 of workbench_access.module) a deadly:

  $depth++;
  foreach ($tree as $id => $item) {
    if ($item['depth'] > $max_depth) {
      $max_depth = $item['depth'];
    }
    if ($depth == 0 && !empty($item['parent']) && isset($tree[$item['parent']])) {
      $tree[$item['parent']]['children'][] = $id;
    }
    elseif ($item['depth'] > 0 && !empty($item['children']) && isset($tree[$item['parent']]['children'])) {
      $tree[$item['parent']]['children'] = array_unique(array_merge($tree[$item['parent']]['children'], $item['children']));
    }
  }
  workbench_access_build_tree($tree, $sections, $depth);

Now... it is my understanding that the line calling array_unique() there is actually checking if the current array does not contains duplicates (including the children of the possible duplicate elements). Of course, when reaching the 7th level deep, it starts becoming very heavy to check something like that.

I've been trying to optimize this somehow but it looks like it would be a very delicate work, unless we can somehow make sure that duplicates are eliminated as we build new elements of the array...

So, as a quick-and-ugly-patch, I've started using the Drupal cache (and with that I mean extending it with Memcache) to store pre-built versions of the arrays. The problem is the function is also quite flexible and... unless you want to possibly create dozens of trees in your cache, there is some filtering to do. So what I've done is the following, and I'm *not* proud of it, but it really improves loading time, so I thought I should (shamefully) share it and hope for other people to work on this together with me - because I can imagine what will happen when the tree grows to 8000 items:

function workbench_access_build_tree(&$tree, $sections = NULL, $depth = -1) {
  static $max_depth;
  if (!isset($max_depth)) {
    $max_depth = 0;
  }
  $caching = false;
  $cache_suffix = '';
  if ($depth === -1 && 1 === count($sections)) {
    // Change - for _ in order to avoid variable naming problems
    $cache_suffix = str_replace('-','_',$sections[0]);
    // Store in menu_custom cache -> should be defined as one of the bins
    // managed by memcached.
    // This is a hack, somewhat flawed by the fact that the sections-specific
    // are not cleant by the the _reset_tree() function
    $cached_tree = cache_get('workbench_access_tree_built_'.$cache_suffix, 'menu_custom');
    if (isset($cached_tree->data)) {
      return $cached_tree->data;
    }
    $caching = true;
  }
  // ... the rest of the function here
  if ($caching == true) {
    cache_set('workbench_access_tree_built_'.$cache_suffix, $tree, 'menu_custom');
  }
}

Would anyone have thought on the array_unique(array_merge()) optimization, please let me know. Technically, I believe making sure all array indexes are unique in some way, then storing only the indexes of $tree as a stack (and then checking for their existence) would make for a much faster solution, but I don't have a clear idea of what types of indexes are stored in there... It would be great if these were just the unique menu item IDs...

I think it's not *just* making sure it exists or not though, it's also checking in which order they are in the hierarchy, am I right?

Comments

agentrickard’s picture

Essentially, this is a known issue with Drupal's menu system -- or, really, any tree system with thousands of items.

If we can find a way around the array_unique() issue -- which I think is separate from caching -- I'd be all for it.

ywarnier’s picture

I think the array_unique() is pretty specific to workbench_access here, and it's apparently the heaviest operation, so although big trees are something difficult to handle, there must be a way to improve this here.

And indeed, it is separate from caching, <troll target="_self">caching is only an optimization mechanism for sissies</troll>

agentrickard’s picture

Status: Needs work » Needs review
StatusFileSize
new1.07 KB

See if this helps.

We could possibly used keyed arrays here, but that is an API change. I'll post that one in a sec.

agentrickard’s picture

Here's the one with keyed arrays.

agentrickard’s picture

Anything?

ywarnier’s picture

StatusFileSize
new102.18 KB

Hi agentrickard,

Sorry for the delay and thanks for the patch. It took me a while to get the testing environment quiet enough to isolate the effect of the patch, but it seems like I got it consistent enough now to say that it really improves the processing time for generating the tree.

The process I used (the one that seems to better isolate the tree processing time) was to run drush, then load directly the "page add" page (because it includes the huge tree as a select box).
In both cases (with and without patch) it took around 166MB in memory to generate the full page. The processor usage, however, changed considerably, with 5.2s without patch and 2.6s with patch (and this repeated 6 times on each side once other people stopped using the test infrastructure).

I'm attaching xhprof outputs both without and with patch so you can see a little bit of detail. Note (in particular) that the number of functions calls dropped from 135,826 to 128,916. This might *just* be because you stopped using array_unique() and array_merge() and might not mean much in reality, but the processor times are there to show that something is really making the load drop.

Thanks a lot for that! I'm still not sure how much data the recursive loop has to store (I would tend to recommend you keep a flat array with the data somewhere, and build the recursive tree only with the item IDs), but I stumbled upon array_merge_recursive() and thought maybe that moved the hassle of merging in C and would be more efficient? Don't know, just saying...

I'm happy to run more tests anytime (giving a backup is not a possibility right now), although I might have to mak you wait a little before I can.

agentrickard’s picture

Well, my array handling skills are pretty rudimentary. And we're on the verge on an API change, which would be a bad thing.

I'd love to see further optimizations here, but I don't think the memory issue is something we can fix -- the menu system has the exact same problem.

ywarnier’s picture

I agree on the memory side. Actually the memory problem can be reduced massively by using cache (this is just to avoid re-generating the menu every time).

In particular, I think the caching mechanism could be improved. I was thinking about how you are building the menu right now, and as I was saying I thing you could keep a flat table with the info in memory, then build a complete tree (instead of section-specific trees) per role.

I reckon most Drupal websites (even with a complex workbench_access config) should not have much more than 10 roles at any time, and storing *just* the hierarchy for each role would probably be very small in memory (probably not above 10K for the hierarchy of a 4000 items-strong tree).
In parallel, having the corresponding hierarchical menu's data as a flat tree would probably be around 500 bytes per entry * 4000 =~ 2M? (I know, very bold estimations for now) That would be much less impressive than the memory actually required to process it each time.

When using the tree for any workbench_access requirement, you would use the hierachical tree for the corresponding role and just query the flat table (in memory and common to all roles) using the mlid as index.

What do you think?

agentrickard’s picture

Roles are not sufficient, because access can also be assigned per user. Per user caching is possible, though.

ywarnier’s picture

Well, that could be a solution. Like put an option (off by default) somewhere in Workbench_access's config that says:

  • Enable menu cache by user
  • This option is particularly useful when dealing with very large trees and a reasonnable amount of editors, but requires sufficient cache memory to be reserved for this. With this option enabled, workbench access will store a specific image of every generated menu for every user (anonymous users being considered one single user), thus boosting the generation time of the menus. For maximum efficiency, it is recommended to use this option with some additional caching module like APC or Memcache.

I would really love that (although I'm really nothing of a Drupal developer, just a generic PHP dev, so can't help much in the implementation).

Then, if this option is enabled, entering workbench_access_build_tree() (reusing the suggested ugly patch base above):

$caching = false;
$cache_suffix = '';
if ($depth === -1 && variable_get('workbench_access_user_tree_cache_enabled',0)==1 && 1 === count($sections)) {
    // Change - for _ in order to avoid variable naming problems
    $cache_suffix = $uid.str_replace('-','_',$sections[0]); //don't know how to get uid, but you sure do
    // Store in menu_custom cache. Should be defined as one of the bins
    // managed by memcached.
    $cached_tree = cache_get('workbench_access_tree_built_'.$cache_suffix, 'menu_custom');
    if (isset($cached_tree->data)) {
      return $cached_tree->data;
    }
    $caching = true;
  }
  // ... the rest of the function here
  if ($caching == true) {
    cache_set('workbench_access_tree_built_'.$cache_suffix, $tree, 'menu_custom');
  }

(it would still require removing all the cached menus on menu update though)

cfennell’s picture

#4 is an excellent start - made my 3k term vocabulary usable in conjunction w/workbench_access.

agentrickard’s picture

I suspect we can commit #4 and improve later.

cfennell’s picture

@agentrickard Yeah, I would say the performance improvement in #4 is significant enough to move forward with it now and eke out more laterz.

cfennell’s picture

Thank you for working on this, by the way. Workbench Access is a 100% perfect fit for a big upcoming project of ours (a library citation database in the area of agricultural economics where contributors from around the world can submit papers to specific collections/taxonomy terms). You saved us a lot of time :D.

agentrickard’s picture

Status: Needs review » Reviewed & tested by the community

I'm going to commit #4 then. We can open new issues to improve.

agentrickard’s picture

Status: Reviewed & tested by the community » Fixed

Committed: 87a2d32..7aba86e 7.x-1.x -> 7.x-1.x

Status: Fixed » Closed (fixed)

Automatically closed -- issue fixed for 2 weeks with no activity.

Anonymous’s picture

Issue summary: View changes

Updated memory usage after verifying it