This patch is a very early and untested draft for a node_load which allows for node_load(array(1, 145, 765)) - which would mean a single query per module implementing hook_nodeapi_load() (a x b) instead of a query per node, per hook (a x b x c). Discussed this with pwolanin, moshe_work and merlinofchaos in irc and there's a few possible ways we could do it, but no time to document them all right this minute, so posting initial patch (which was written before that feedback) now.

CommentFileSizeAuthor
#135 multiple_load.patch53.19 KBcatch
#132 multiple_load.patch53.2 KBcatch
#130 multiple_load.patch52.67 KBcatch
#128 multiple_load.patch52.67 KBcatch
#126 multiple_load.patch52.68 KBcatch
#125 multiple_load.patch52.51 KBcatch
#120 multiple_load.patch52.57 KBcatch
#118 node-10-benchmarks.txt3.18 KBcatch
#117 dbtngtest.txt4.61 KBcatch
#117 front_page_benchmarks.txt3.17 KBcatch
#113 multiple_load_2.patch53.26 KBcatch
#106 multiple_load.patch52.54 KBcatch
#105 multiple_load.patch52.54 KBcatch
#85 multiple_load.patch51.93 KBcatch
#84 multiple_load.patch51.96 KBcatch
#82 multiple_load.patch47.01 KBcatch
#81 multiple_load.patch47.01 KBcatch
#81 taxonomy_docs.txt1.22 KBcatch
#81 node_docs.txt3.63 KBcatch
#79 multiple_load.patch47 KBcatch
#78 load_multiple.patch47.04 KBcatch
#76 324313-multiple-node-load.patch46.92 KBcatch
#75 324313-multiple-node-load.patch43.97 KBDamien Tournoud
#74 324313-multiple-node-load.patch43.97 KBDamien Tournoud
#71 324313-multiple-node-load.patch0 bytesDamien Tournoud
#68 multiple_load.patch45.86 KBcatch
#63 multiple_load.patch46.08 KBcatch
#62 multiple_load.patch45.17 KBcatch
#60 multiple_load.patch45.17 KBcatch
#58 multiple_load.patch45.21 KBcatch
#57 multiple_load2.patch45.33 KBcatch
#52 multiple_load.patch44.8 KBcatch
#49 multiple_load.patch43.35 KBcatch
#45 multiple_load.patch45.21 KBcatch
#44 multiple_load.patch45.19 KBcatch
#43 multiple_load.patch45.25 KBcatch
#41 multiple_load.patch44.58 KBcatch
#40 multiple_load.patch44.57 KBcatch
#39 multiple_load.patch44.27 KBcatch
#38 multiple_load.patch44.23 KBcatch
#37 multiple_load.patch44.24 KBcatch
#36 multiple_load.patch44.3 KBcatch
#33 multiple_load.patch44.85 KBcatch
#33 multiple_load.patch44.82 KBcatch
#31 multiple_load.patch45.99 KBcatch
#31 multiple_load.patch44.85 KBcatch
#30 multiple_load.patch40.25 KBcatch
#28 multiple_load.patch40.24 KBcatch
#26 multiple_load.patch39.9 KBcatch
#20 benchmarks.txt3.69 KBcatch
#19 multiple_load.patch39.75 KBcatch
#17 multiple_load.patch39.28 KBcatch
#11 node_multiple_load.patch34.18 KBcatch
#9 node_multiple_load.patch29.63 KBcatch
#8 node_multiple_load.patch28.13 KBcatch
#7 node_multiple_load.patch24.7 KBcatch
#5 node_load_multiple.patch7 KBcatch
#4 node_load_multiple.patch6.96 KBcatch
#3 node_load_multiple.patch7.58 KBcatch
#2 node_load_multiple.patch6.88 KBcatch
#1 node_load_multiple.patch2.84 KBcatch
node_load.patch5.54 KBcatch
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

catch’s picture

FileSize
2.84 KB

Here's a new patch, again, completely untested.

Quick summary of what it will do, a lot of this isn't in the patch yet:

We add a new function - node_load_multiple($nids = array(), $options = array(), $reset = FALSE)

This lets me do node_load_multiple(array(1)) or node_load_multiple(NULL, array('article')), or node_load_multiple($lots_of_nids, array('article')) - and get back an array of node objects matching the nids, the conditions on the {node} table or a combination of the two.

The node objects are fetched in a single query and put into an array. This is then divided by type, and passed to hook_load(). The result of that is put into an array, and passed to hook_nodeapi_load(). This means that all the hooks operate on a single array of node objects - which means they can also fetch their data in a single query and pass it back to node_load.

So instead of $queries = $number_of_nodes * $hook_implementations we now have $queries = 1 + $hook_implementations in the vast majority of cases.

As suggested on irc by pwolanin, node_load() becomes a wrapper around node_load_multiple(), which takes an nid, gets a node object back, and nothing else.

revisions will move to node_revision_load() since having them in the same function is just messy and they aren't statically cached anyway.

catch’s picture

Assigned: Unassigned » catch
FileSize
6.88 KB

Another revision, still untested.

Changes since the last patch:
1. We now unset $nodes from the cached array if they don't match the conditions in $options.
2. Nodes arrayed by type are passed into the node specific hook_load() (which needs a new name) then merged back before being passed to hook_nodeapi_load()
3. node_load is now a wrapper around the new function, and only accepts $nid and $reset as params.

And some tidy up.

TODOs:
1. Pass the $typed_nodes and $nodes arrays to their respective hook implementations properly, so those functions can act on the entire array in one go.
2. Make it work and write/fix some tests.
3. Add node_load_revisions() to replace that functionality which has been removed from node_load() and node_load_multiple().
4. Convert hook_nodeapi_load() and hook_load() implementations in core to the new API.
5. Fix any calls to node_load which aren't a simple node_load($nid).

I'm fairly happy with how this is shaping up, although I've got a feeling some of the array treatments could be optimized a lot (I'm using array_diff then array_merge when I really just want to check the keys for example). If anyone's got time to have a high level look that'd be great (thanks to those who've already done so in irc). When I've done 1 & 2 I'm going to abuse 'code needs review' to get some eyes on it.

catch’s picture

Status: Needs work » Needs review
FileSize
7.58 KB

node_load() works at least on the front page and node/1

Reworked the hooks. Still need tests, converting all the hook implementations (hence many notices), node_revision_load().

However, I'd like confirmation from a few people that the new API for node_load() node_load_multiple(), hook_node_type_load() (which isn't really a hook but lets' not deal with that in this patch) and hook_nodeapi_load() makes sense before going much further with conversion, so while this is nowhere near ready yet, it needs review.

catch’s picture

FileSize
6.96 KB

Some cleanup following discussion with Damien and pwolanin in irc, a lot of these changes direct from Damien via pastebin.

catch’s picture

My first introduction to the node_hook() function.

moshe weitzman’s picture

Outstanding improvement. This gonna be a major improvement for Views listings and such. I don't see any problems based on a quick read of the diff.

catch’s picture

FileSize
24.7 KB

Still a few miscellaneous patch failures, but hook implementations are converted (some optimised, some not).

catch’s picture

FileSize
28.13 KB

Couple more.

Pretty much the only fail I get now is file.test, which ought to be unaffected by this patch...

catch’s picture

FileSize
29.63 KB

All core tests pass.

The existing tests test hook_load, hook_nodeapi_load, and some of the arguments to node_multiple_load() - so it needs an extra test or two for conditions, then I think we're good. Really, really needs some thorough review now though.

Note I've not converted the node front page etc. to use the new function yet either.

RobLoach’s picture

This is pretty awesome. Adding to the list of things I'd like to test thoroughly....

catch’s picture

FileSize
34.18 KB

A few revisions - here's a summary of the patch at it's current state:

Adds the function
node_multiple_load($nids = array(), $conditions = array(), $revision = NULL, $reset = FALSE)

This returns an array of node objects - the core node tables are queried in one query. Conditions are ANDed together and can be combined with the $nids argument to return a subset. I've added some simpletests for the new function, existing tests cover node_load() itself.

Node load is simplified, and is primarily wrapper around node_multiple_load().
node_load($nid = NULL, $revision = NULL, $reset = FALSE)
This should be used whenever loading a single node or revision by the ID, otherwise use node_multiple_load directly.

hook_nodeapi_load() now takes an array of $nodes. book_node_api_load() and comment_nodeapi_load() have been optimised to use one query to load their information into the node objects. Some others like upload_nodeapi_load() and taxonomy_node_api_load() do a foreach on $nodes - this is because I'll have to write a proper multiple load function for taxonomy terms and maybe other stuff to be able to utilise them properly for this new API. This is already a significant enough change that I think we should do those once it gets in - I'd like to do a taxonomy_term_multiple_load() at least.

A lot of tests use $node = node_load(array('title' => $title)) as a way to fetch back a node object after form conditions. This particular pattern is pretty much only found in tests, and in real life you never know how many nodes you might get back with the same title, so I've added a small helper method to the web test case - drupalGetNodeByTitle() to replace this for tests only. In normal use you'd use node_multiple_load(NULL, array('title' => $title)) then deal with the array you get back.

I've also changed the default /node page and taxonomy/term pages to use node_multiple_load(). With 10 nodes on a page, each with comments enabled, we'd be looking at a reduction of 18 queries per page load. When we have multiple object loading functions for taxonomy and contrib modules it could easily knock 100 queries off the default front page.

I've had to use the query builder in the nodeapi_load hooks because you can't use IN() for static queries. See #314464: Convert db_placeholders() to DBTNG.

Dries’s picture

I need to think about this patch a bit. While this looks like a good idea, it is is a radical API change that could trickle into other parts of the system. It is is not necessarily going to make Drupal easier to grok.

Michelle’s picture

This is a critical patch to enable the possibility of forum posts as nodes which many people are clammoring for. So a definite +1 from me for the idea. I will do my best to set aside some time early next week to give it a proper review.

Thanks for getting the ball rolling!

Michelle

catch’s picture

In answer to Dries, one thing that was suggested in irc when I brought this patch up, and which might help ease the transition for upgrades and new developers, would be to do something along these lines:


// Run all hook_nodeapi_load() much the same as now.
foreach ($nodes as $node) {
  node_invoke_nodeapi($node);
  $nodes[$node->nid] = $node;
}

// Run hook_nodeapi_multiple_load() for modules which can take advantage of the performance gains as in the current patch.

foreach (module_implements('nodeapi_multiple_load') as $module) {
  $function = $module . '_nodeapi_load';
  call_user_func($function, &$nodes,  array_keys($typed_nodes));
}

That'd mean if your function is always going to be checking things against every individual node anyway (say poll_load()) then you just use hook_nodeapi_load(), but if you can take advantage of loading information at once, you use hook_nodeapi_multiple_load() - you only have to deal with the extra complexity (which is only getting and passing back an array of objects instead of a single object) if you want the extra benefits.

This would mirror the simplified and 90% backwards compatible node_load() versus node_multiple_load() too.

catch’s picture

Rough performance indications from alpritt on #301902: Allow more users to see the node admin page (paraphrased for clarity):

I applied the multiple_node_load patch as a proof of concept here, and checked times with the devel module:
1. With 50 nodes, 62ms and with multiple_node_load around 42ms.
2. Loaded up with taxonomy terms, 174ms and 142ms with the addition of multi load.

That's a 20-30% improvement in query execution times. I'm planning to do the same thing for taxonomy terms as well where IMO it's an even better fit than node_load.

catch’s picture

FileSize
39.28 KB

Marked #329454: Load multiple terms at once as duplicate since this is a lot easier to keep track of in one patch.

Taxonomy terms are now loaded for all nodes with two queries (one to get the tids from the nodes, one to load the full term objects). In HEAD this is currently done with a custom query that doesn't even touch taxonomy_term_load() - there's a lot of that in taxonomy module which I'll want to go through later in followup patches.

hook_taxonomy_term_load() is modified to follow the same pattern as hook_nodeapi_load().

All the directly relevant tests pass except forum, which has something weird going on with error 500 responses - I've manually verified that forum module is working though. Since both comment and taxonomy are optimised for multiple loading now, we should get a fair bit of difference in performance, it ought to be worth benchmarking now.

kbahey’s picture

Status: Needs review » Needs work

Slower?

  Resp Time   Trans Rate   Trans     OKAY    Failed   Elap Time   Concurrent
       0.60        16.58    1992     1992        0      120.11         9.98
       0.82        12.09    1455     1455        0      120.35         9.95

The first line is without the patch.

The second is with the patch.

catch’s picture

FileSize
39.75 KB

Minor cleanup, haven't looked at the shockingly bad performance yet. All tests pass.

catch’s picture

Status: Needs work » Needs review
FileSize
3.69 KB

Did my own benchmarks on the latest patch with 5,000 terms, 5,000 nodes - 2,500 nodes have taxonomy terms and comments, 2,500 don't (upgraded from d6) - benchmarked on my desktop with query_cache off and no APC. Only default profile modules enabled.

ab -c1 -n500 http://d7.7/node

With this I got a 10% improvement.

devel says 61 queries per page instead of 81. About 20% reduction in query execution times.

Similar results for taxonomy/term/1

All the multiple load queries are between 0.3 to 0.8 milliseconds - comparable to the equivalent single load queries, except of course they're run once per page (or at least per call to node_multiple_load()) instead of once per node.

I got about a 1% performance penalty on node/1 - this is could be due to dynamic queries, IN() instead of = in the SQL, more logic in the hook implementations, but it's only 1% and we gain much more elsewhere.

Attached the output and some of the notes from devel output as a .txt

Based on this I'm marking back to needs review.

kbahey’s picture

When I test with one user, and only to the /node path, then this patch is faster.

Without patch, one user, /node only

$ siege -c 1 -r 500 -b http://example.com/node > /dev/null
** Preparing 1 concurrent users for battle.

Transactions:                    500 hits
Availability:                 100.00 %
Elapsed time:                 187.63 secs
Data transferred:              30.74 MB
Response time:                  0.38 secs
Transaction rate:               2.66 trans/sec
Throughput:                     0.16 MB/sec
Concurrency:                    1.00
Successful transactions:         500
Failed transactions:               0
Longest transaction:            0.38
Shortest transaction:           0.36

With patch, one user, /node only

$ siege -c 1 -r 500 -b http://example.com/node > /dev/null
** Preparing 1 concurrent users for battle.

ransactions:                     500 hits
Availability:                 100.00 %
Elapsed time:                 147.35 secs
Data transferred:              27.84 MB
Response time:                  0.29 secs
Transaction rate:               3.39 trans/sec
Throughput:                     0.19 MB/sec
Concurrency:                    1.00
Successful transactions:         500
Failed transactions:               0
Longest transaction:            0.34
Shortest transaction:           0.29

When testing with 10 concurrent users, and 31 URLs for each user (/node, and 30 X node/yyy), it is slower because as catch mentioned, there is a 1% hit for individual nodes.

catch’s picture

Spoke to kbahey in irc and got a bit closer to his dataset for benchmarking, 30 nodes on front page, all core modules enabled.

/node

devel says:
before patch: 270-5 queries in 186.8-220 millisecond

after patch: 172 queries in 117.54 - 150.45 milliseconds

ab says:

before patch

Document Path:          /node
Document Length:        46992 bytes
Time taken for tests:   270.36141 seconds
Requests per second:    1.85 [#/sec] (mean)
Transfer rate:          86.03 [Kbytes/sec] received

after patch:

Document Path:          /node
Document Length:        46992 bytes
Time taken for tests:   227.417881 seconds
Requests per second:    2.20 [#/sec] (mean)
Transfer rate:          102.15 [Kbytes/sec] received

/node/1

before patch:


Time taken for tests:   146.582315 seconds
Requests per second:    3.41 [#/sec] (mean)
Time per request:       293.165 [ms] (mean)
Time per request:       293.165 [ms] (mean, across all concurrent requests)
Transfer rate:          28.30 [Kbytes/sec] received

after patch:

Time taken for tests:   176.714281 seconds
Requests per second:    2.83 [#/sec] (mean)
Time per request:       353.429 [ms] (mean)
Time per request:       353.429 [ms] (mean, across all concurrent requests)
Transfer rate:          23.47 [Kbytes/sec] received

So, loading one node is a lot more expensive when firing more hooks. I'll look into fixing things up on that front.

catch’s picture

Status: Needs review » Needs work
mfer’s picture

@catch You might want to see about trying to remove IN from your sql clause. I've seen cases where it increases query time over doing something like nid = 1 OR nid = 2 OR nid = 3....

Though, with OR statements there is a limit to the number ORs you can have.

catch’s picture

I'm thinking at least make it = for a single node, will try with OR and do a comparison - do you happen to know what the limit is? Since we have the query builder, we could even use both where appropriate.

catch’s picture

Status: Needs work » Needs review
FileSize
39.9 KB

Found it! Wasn't using the static cache properly, so hook implementations were getting called multiple times for every call to node_load() even though the node table fields themselves were cached.

node/1 is now within margin of error (it came out a couple of ticks faster in my benchmarks).

node/ is 15% faster.

node/1
Before patch:


Time taken for tests:   131.771640 seconds
Requests per second:    3.79 [#/sec] (mean)
Time per request:       263.543 [ms] (mean)
Transfer rate:          31.48 [Kbytes/sec] received

After patch:

Time taken for tests:   128.562064 seconds
Requests per second:    3.89 [#/sec] (mean)
Time per request:       257.124 [ms] (mean)
Transfer rate:          32.26 [Kbytes/sec] received

/node
Before patch:

Concurrency Level:      1
Time taken for tests:   238.355726 seconds
Requests per second:    2.10 [#/sec] (mean)
Time per request:       476.711 [ms] (mean)
Transfer rate:          97.46 [Kbytes/sec] received

After patch:

Concurrency Level:      1
Time taken for tests:   207.189637 seconds
Complete requests:      500
Failed requests:        0
Write errors:           0
Total transferred:      23789000 bytes
HTML transferred:       23496000 bytes
Requests per second:    2.41 [#/sec] (mean)
Time per request:       414.379 [ms] (mean)
Time per request:       414.379 [ms] (mean, across all concurrent requests)
Transfer rate:          112.12 [Kbytes/sec] received
drewish’s picture

subscribing.

catch’s picture

FileSize
40.24 KB

Found another optimisation with the static cache, also added some additional code comments.

/node with 30 nodes enabled and all core modules is now

without patch:
308 queries

with patch:
206 queries.

kbahey’s picture

Using #28

10 concurrent users, 2 minutes duration, hitting only /node.

No patch

Transactions:                     527 hits
Elapsed time:                 120.09 secs
Response time:                  2.26 secs
Transaction rate:               4.39 trans/sec
Failed transactions:               0
Longest transaction:            3.15
Shortest transaction:           1.78

With patch

ransactions:                     692 hits
Elapsed time:                 120.38 secs
Response time:                  1.72 secs
Transaction rate:               5.75 trans/sec
Failed transactions:               0
Longest transaction:            2.18
Shortest transaction:           1.43

Measurable improvement there

Same test but with a mix:

10 concurrent users, 2 minutes duration, 30 individual nodes and /node.

No patch

Transactions:                    2078 hits
Elapsed time:                 120.53 secs
Response time:                  0.58 secs
Transaction rate:              17.24 trans/sec
Failed transactions:               0
Longest transaction:            3.21
Shortest transaction:           0.22

With patch

Transactions:                    2092 hits
Elapsed time:                 120.41 secs
Response time:                  0.57 secs
Transaction rate:              17.37 trans/sec
Failed transactions:               0
Longest transaction:            2.66
Shortest transaction:           0.24

Slight improvement. Perhaps statistical noise.

catch’s picture

FileSize
40.25 KB

chx reviewed it on #drupal-dev, patched as we went. (edit, crossposted with kbahey, no major changes, so above benchmarks should stand).

catch’s picture

Title: Load multiple nodes at once » Load multiple nodes and terms at once
FileSize
44.85 KB
45.99 KB

I've gone through, simplified some of the code, added a bunch of code comments, and a new test for multiple taxonomy term loading. All tests pass. This should be the last revision for a while until someone else rips it apart :)

Summary of the API changes again:

node_load() is simplified to be just:

node_load($nid = NULL, $vid = NULL, $reset = FALSE)
This should only be used for loading an individual node or revision when you already have the id.

Then there's a new function for loading multiple nodes at once: node_multiple_load($nids = array(), $conditions = array(), $revision = NULL, $reset = FALSE)
This is used in the following ways:

$nids = array(1, 2, 3, 4);
node_multiple_load($nids);

get back an array of four fully populated node objects, indexed by nid.

$conditions = array('type' => 'article', 'promote' => 1);
node_multiple_load(NULL, $conditions);

get back an array of all nodes of type article and promoted to the front page.

node_multiple_load($nids, $conditions);

Get back all the nodes in $nids which are of type 'article' and promoted to the front page.

If you call node_load and node_multiple_load more than once on the same page request, it'll use a single static cache to meet all nids and conditions wherever possible - see the code comments for more explanation, or try it out with the devel query logger.

hook_nodeapi_load() now takes an array of node objects and acts on them by reference (worst case is you do a foreach over them). hook_taxonomy_term_load() uses the same pattern.

A similar function has been added for taxonomy - taxonomy_term_multiple_load($tids = array(), $conditions = array(), $reset = FALSE)
Again, this is a wrapper around a newly simplified taxonomy_term_load($tid, $reset = FALSE)

cwgordon7’s picture

Status: Needs review » Needs work

First of all, great patch.

Secondly, a bunch of questions:

1. What about node_access()? Is there/should there be a handler for calling that with multiple nodes, so that way you can see which nodes out of (1,2,3,4,5,6,7) the user has access to? If we're return arrays of nodes more often, wouldn't it make sense to make more convenience functions for such arrays?

2. A few coding style errors in the node_multiple_load() doxygen comments.

3. A bit out of the scope of this patch, but question, I've always thought $revision should be called $vid since $revision suggests a boolean (yes, new revision/no, no new revision) and $vid suggests a number; I'll open a new issue for this.

Overall, though, it looks like a great patch! Really nice work. :) Sorry to be setting this back to cnw. :(

catch’s picture

Status: Needs work » Needs review
FileSize
44.82 KB
44.85 KB

Will take a look at node_access(). Although having said that, there's a lot of places that can be optimised after this patch gets in - most taxonomy functions which do their own custom queries of the term data table will be able to utilise the new API for example, but I'd like to keep this to the minimum size for having it working, testable and benchmarkable - then followup quickly later with applying it to other places.

Here's a re-roll which fixes the phpdoc spacing and renames $revision to $vid. Thanks cwgordon :)

moshe weitzman’s picture

No, don't bother looking at node access. It is fundamentally different from properties like sticky and friends. it does not belong in this patch. Furthermore, I have never seen a user case where you already have a bunch of nodes but want to throw some out that are inaccessible. We don't do it that way because it is incompatible with a pager. Even if you think it belongs in this API, it really does not belong in this patch.

catch’s picture

I did have a quick look, and I agree node_access doesn't need to be touched by this patch.

catch’s picture

FileSize
44.3 KB

On the assumption that #314464: Convert db_placeholders() to DBTNG will get resolved, I've used db_placeholders() instead of db_select() for the IN() queries where there's otherwise no need for a dynamic query.

Still 15% faster on a standard front page.

catch’s picture

FileSize
44.24 KB
catch’s picture

FileSize
44.23 KB

objects are passed by reference all the time in PHP5, so this one's a little more readable.

catch’s picture

FileSize
44.27 KB

re-roll based on initial feedback from webchick. Hopefully this caught everything.

catch’s picture

FileSize
44.57 KB

Found a small optimisation in node_multiple_load(): If some nodes have been loaded from cache, and some haven't, then we don't need to re-run hooks on the already cached nodes. This also simplifies the function a little bit. removes a couple of unnecessary/awkward checks etc. While we don't have any hook_taxonomy_term_load() implementations in core yet, the same is true there.

catch’s picture

FileSize
44.58 KB

Worked on this a little with cwgordon in irc - we came up with a way to get rid of one of the foreaches :)

There's no measurable performance impact in either direction compared to the foreach, but it's prettier.

edit: it also causes tests to fail. Leaving this one for tonight.

cwgordon7’s picture

I tested the page in #40, all looks good; I agree it's much cleaner to do the array manipulation with the built in php functions (as done in #41) than to cycle through the array multiple times. So long as there was no performance impact, which there seemed not to be, I see no reason not to include the prettier code. :)

catch’s picture

FileSize
45.25 KB

Here it is with array_flip, array_intersect_key, array_diff_key and array_intersect_assoc instead of the three foreach loops. Tests pass this time. Seems like a lot of work to avoid a foreach loop. ;) No measurable performance difference on node/1 as far as I can tell - which calls node_load() nine times on a single nid.

catch’s picture

FileSize
45.19 KB

chx: reviewed this on irc, and said "let's keep the foreach if that's the only way to do it" on the first one, although removing the nested foreach stays (and is much nicer)

catch’s picture

FileSize
45.21 KB

comment_nodeapi_load hunk no longer applied after dbtng conversion.

recidive’s picture

The not yet ready hook_query_alter() could help on 1:1 cases, e.g. book_nodeapi_load(), so this won't need to run another query at all. Just alter node loading query LEFT JOINing book table and adding it's fields. I don't know if that will perform better or worse, though.

Your patch improves both 1:1 and 1:n cases, which is nice! I'm just not sure if that should replace hook_nodeapi_load() or provide a new hook to be used in on node_multiple_load().

Keep up the good work!

catch’s picture

recidive - if we keep a singular hook_nodeapi_load(), I think that'd make it very unclear which one you're supposed to use (since either could work) - I actually suggested it on #drupal, and the idea was shot down... Also just to make it 100% clear, node_load calls node_multiple_load() to get data now - saves a lot of code duplication, and allows them to share the same static cache.

Was thinking about hook_query_alter() to add joins, but I'd be concerned that a single module could alter in a badly indexed join and slow everything down to a crawl. If we could get it to work, would be a good thing to try out in a followup patch though.

My main hope for further performance improvements is - #333171: Optimize the field cache - which will give us everything in a single query if the cache is populated.

Status: Needs review » Needs work

The last submitted patch failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
43.35 KB

Re-rolled for taxonomy.test commit.

Status: Needs review » Needs work

The last submitted patch failed testing.

mfer’s picture

Status: Needs work » Needs review

Setting back to CNR after testing snafu yesterday. For details see http://lists.drupal.org/pipermail/development/2008-November/031431.html

catch’s picture

FileSize
44.8 KB

Re-roll for book dbtng conversion.

Anonymous’s picture

ran some benchmarks with ab -n 1000 -c 5 http://drupal7/, APC enabled, anonymous user, no page or block cache.

with no content, i got no measurable difference between HEAD vs HEAD + patch, which is good.

with 50 nodes, 3 comments per node via devel module, i get:

HEAD
Requests per second: 33.26 [#/sec] (mean)
Requests per second: 33.11 [#/sec] (mean)
Requests per second: 33.21 [#/sec] (mean)

HEAD + patch
Requests per second: 37.25 [#/sec] (mean)
Requests per second: 37.40 [#/sec] (mean)
Requests per second: 37.22 [#/sec] (mean)

great work catch, nice speed improvement :-)

swentel’s picture

Ok, did some benchmarks too, 2000 users, 10000 nodes, 20000 comments & 5000 url aliases, 5 content types (article, blog, book, page & poll) no drupal caching and xcache with ab -n 1000 -c 5 http://drupal7/
(note, this is done on a really old laptop, still, you'll see the improvements)

without patch, 10 nodes on front
Requests per second: 10.98 [#/sec] (mean)
Requests per second: 10.96 [#/sec] (mean)

with patch 10 nodes on front
Requests per second: 12.68 [#/sec] (mean)
Requests per second: 12.82 [#/sec] (mean)

without patch 30 nodes on front
Requests per second: 5.47 [#/sec] (mean)
Requests per second: 5.34 [#/sec] (mean)

with patch 30 nodes on front
Requests per second: 7.57 [#/sec] (mean)
Requests per second: 7.68 [#/sec] (mean)

catch’s picture

That's a few independent benchmarks showing a significant improvement, and there's been no negative feedback on the approach taken in the patch for a while plus several positive reviews - so anyone want to bump this to RTBC (or set it to needs work if we've missed something)?

Damien Tournoud’s picture

Did an IRC review with catch:

- hook_nodeapi_load() implementations should all use the two parameters $nodes and $types for consistency
- because we cache fully loaded nodes (after calling loading hooks), we should prohibit hooks to modify fields that can be matched by conditions (documentation issue)
- hook_nodeapi_forum() was not implemented correctly
- change node_multiple_load() to use array_diff_assoc instead of array_intersect_assoc, which should be faster
- NodeMultipleLoadTestCase has several inconsistencies between the condition and the text (count() == 3, "Loaded four nodes")
- node_page_default() should use a fetchCol()
- poll_load() should use a fetchAllKeyed()
- in poll_load(), if (isset($result->chid)) could just be if ($result)
- there is an indentation issue at the bottom of poll_load()
- drupalGetNodeByTitle() should return only the first node matched (consistent behavior with the current node_load()), but it currently returns the last match

catch’s picture

FileSize
45.33 KB

Thanks Damien.

* All implementations now use both params for consistency - we should probably look into doing this elsewhere in core since it's a common pattern to skip unneeded paramaters which I copied.
* conditions/cache - makes sense - I'll make sure this is added to the API docs after commit.
* Fixed hook_nodeapi_forum()
* array_diff_assoc() works, and its more concise that way too. Lovely.
* Changed all assertions to use @count - this helps to work out what the problem was when you break it.
* node_page_default - fixed the query to use fetchCol(). Damz also mentioned using $nodes instead of $nids for the $output concatenation in case any node isn't loaded from the database. We can't do this because $nodes isn't necessarily keyed in the same order as $nids, but I added a comment to this effect and a sanity check there.
* poll_loa(d) - fetchAllKeyed() only covers two column result sets, so doesn't look possible to use it here. Code is also copied direct from the current function and only converted to dbtng 'cos I was moving lines around, so I'd rather leave it as is for the purposes of this patch.
Removed the pointless isset though and fixed indentation.
* drupalGetNodeByTitle() Well spotted! Added an array_reverse().

I think this covers everything either in the new patch or in the answers. Ran all tests and they continue to pass.

catch’s picture

FileSize
45.21 KB

two more via Damien in irc:

poll_load now uses fetchAllAssoc('chid', PDO::FETCH_ASSOC) (edit: this is currently only in a comment on the dbtng docs - could do with rolling in)

drupalGetNodeByTitle uses reset() instead of stupidity.

Anonymous’s picture

reviewed the code, looks great.

one thing that struck me - why not use php type hints for the array params? feels like we should die screaming if anything other than an array (or NULL) is passed in various new functions. this is not exactly a standard across drupal yet, but i personally think it should be.

catch’s picture

FileSize
45.17 KB

Using array_diff_assoc() lets us remove one additional line of code there. On PHP type hinting, I'm all for being stricter, but I don't think this patch is the place to introduce it.

Status: Needs review » Needs work

The last submitted patch failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
45.17 KB

missing parenthesis. There's an issue for adding type hinting to every param over at #318016: Type hint array parameters - so I'm coming down firmly on the side of letting that issue handle it consistently across core. Also, while they didn't post any patches, much of the nastiness in previous patches was removed by Damien in irc (and chx and others), so they need crediting on commit for this one.

catch’s picture

FileSize
46.08 KB

Went through this one more time with Damien. Some recent changes from node loading hadn't made it into taxonomy terms + a little bit more dbtng goodness. Also added additional phpdoc to node_multiple_load() and taxonomy_term_multiple_load(). That's it for tonight, and hopefully there's nothing much left to nitpick now.

Damien Tournoud’s picture

Status: Needs review » Reviewed & tested by the community

This all looks great, and I found nothing left to nitpick about :)

There are certainly some micro-optimizations to do but experience shows those generally don't worth that much in PHP. And anyway that will be another issue.

Issue summary:
- That patch implements node_multiple_load(), which loads multiple node in a batch, and also changes hook_load() and hook_nodeapi_load() so that those accept a batch of nodes too. The idea is to share the "fixed costs" of those operations (doing a query, waiting for the result, etc.) between all nodes in a batch, which leads to huge savings when a significant number of nodes has to be processed (such as on the front page).
- That patch converts all code hook_load() and hook_nodeapi_load() implementations to make use of that mechanism. One significant implementation is the taxonomy one. We now do two queries per batch for node (instead of one query per node). Testing shown that the extra overhead in the case we load only one node is not really significant, but it remains possible (and easy) to revert that behavior in a later patch.
- node_load() is now a thin wrapper around node_multiple_load(). Its specialized use is only to load a node given its nid and optionally its vid. The other use cases (load nodes per title, etc.) have been moved to node_multiple_load(). That makes more sense because there are no guarantee that the result set of those queries will only count one node.

All in one, this is a great API cleanup and performance improvement at the same time. Both Views and CCK will probably be able to make great use of that new API when processing lists of nodes.

Next steps:
- document the new hooks. It should be made especially clear that load hooks should *not* alter fields that have been loaded from the database, only add fields. Because the fully-built $node is statically cached, the behavior of the condition-matching in node_multiple_load() can change if those fields are modified.
- type hinting will be handled by #318016: Type hint array parameters (or one of its related issues)
- Study if we should cache fully loaded $node objects. Caching those could lead to new performance gains, especially when hook_load() and hook_nodeapi_load() are really costly, as it is for example for CCK/Fields-API type loading.

kika’s picture

Catch, what were the reasons to change node_load_multiple() to node_multiple_load()? It seems to be changed in #7. The former seems more consistent with our core naming conventions.

catch’s picture

@kika I think its good to have _load() as a consistent suffix for loading core objects - taxonomy_get_term() was renamed to taxonomy_term_load() in D7 due to menu system requirements - and is extended with taxonomy_term_multiple_load() in this patch too. It also matches hook_nodeapi_load() and hook_load() (again, those suffixes recently introduced when hook_nodeapi was de-opped). Does that help explain the thinking behind it?

On next steps, - just a note that I made a start on the caching already at #333171: Optimize the field cache - which would mean a single query to load node objects on the front page even with CCK and other nodeapi modules enabled once the cache is primed. It would also give us gains on individual node pages, outweighing the very small overhead introduced here.

catch’s picture

On Damien's point about the extra taxonomy query on individual node views in #64, that raises an important point which I don't think I've written up yet anywhere about how taxonomy terms are currently loaded. I've started a new issue to answer it rather than making this one any longer #337295: Convert the rest of taxonomy module to use multiple loading - obviously I'd rather leave conversion to a separate issue since this is already approaching a 50k patch.

Also, while writing this patch, I noticed one pointless query on node/n pages - #330674: node_comment_mode() causes pointless queries, so we can at least even it out.

catch’s picture

FileSize
45.86 KB

taxonomy.test has new helper functions createTerm() and createVocabulary() as of the past day or so. Re-rolled to use those and for offset. No other changes.

webchick’s picture

Status: Reviewed & tested by the community » Needs work

I prefer the name node_load_multiple()/taxonomy_term_load_multiple() for the following reasons:
a) In IDEs, it will alphabetically appear next to node_load() which is its counterpart.
b) I find this less consistent with our normal API naming (drupal_add_js(), module_invoke_all()) which is to use {scope}_{verb}_{thing_being_verbed} in the case where there are 3 words.
c) It is NOT a hook implementation, where we *do* standardize on the verbs being last, and we don't want people to get confused and think there is a hook_node_multiple().
d) Finally, in reviewing this patch I had to stop my fingers from typing "node_load_multiple()" like 10 times. :P

-    $node = node_load(array('title' => $edit['title']));
+    $node = $this->drupalGetNodeByTitle($edit['title']);

At first, I thought lines like this were kittening up the patch, but Damien explained to me that the signature for node_load() has changed and it's no longer possible to load a node by any arbitrary value, only by nid/vid. This is because when you look up a node by title, there might be two or more nodes that come back and it would previously only return the first one. So this has instead been moved to node_multiple_load(). This needs to be thoroughly explained in the upgrade docs, because we've introduced a bit of a DX issue here -- people now need to "sometimes" use a different function to load nodes.

Sorry, just headed out so this is all I have time for now, but I'll be back on this later tonight.

webchick’s picture

Title: Load multiple nodes and terms at once » UNSTABLE-4 blocker: Load multiple nodes and terms at once

Also, adding this as a blocker for UNSTABLE-4.

Damien Tournoud’s picture

Title: UNSTABLE-4 blocker: Load multiple nodes and terms at once » Load multiple nodes and terms at once
Status: Needs work » Needs review
FileSize
0 bytes

Renamed node_multiple_load() to node_load_multiple() and taxonomy_term_multiple_load() to taxonomy_term_load_multiple().

Damien Tournoud’s picture

Title: Load multiple nodes and terms at once » UNSTABLE-4 blocker: Load multiple nodes and terms at once
recidive’s picture

Status: Needs review » Needs work

Latest patch is empty file.

Damien Tournoud’s picture

Strange enough.

Damien Tournoud’s picture

Status: Needs work » Needs review
FileSize
43.97 KB

One last try.

catch’s picture

node_feed() wasn't converted to use the new function, now it is.

edit: ran all tests, passed.

webchick’s picture

Status: Needs review » Needs work

Some more thorough comments from a second pass. Note that I skipped the tests in this pass, will need to go back and review them later...

+  $result = db_query("SELECT ..."), array('fetch' => PDO::FETCH_ASSOC));
+  foreach ($result as $record) {

That is an awesome short-cut. :)

+  // If comments are disabled, add some default values.
+  foreach ($nodes as $node) {
+    $node->last_comment_timestamp = $node->created;
+    $node->last_comment_name = '';
+    $node->comment_count = 0;
+    if ($node->comment != COMMENT_NODE_DISABLED) {
+      $comments_enabled[] = $node->nid;
+    }
+  }

This comment doesn't reflect what the code below is doing. Is the code wrong, or the comment?

+function book_nodeapi_load($nodes) {
...
+function forum_nodeapi_load($nodes, $types) {

How come sometimes hook_nodeapi_load() takes in $nodes, $types and other times just $nodes? Even if $types isn't used in a particular function, I think it would be better if it was consistent across the board. Also eliminates head-scratching if you add something dealing with $types later on to book_nodeapi_load() and forget that it isn't in scope as you'd expect.

   $vid = variable_get('forum_nav_vocabulary', '');
+  // If no forum vocabulary is set up, return.
+  if (!$vocabulary = taxonomy_vocabulary_load($vid)) {
+    return;
+  }

Should this just return NULL? Or should it return FALSE since this is basically an error condition? Since http://api.drupal.org/api/search/7/hook_nodeapi_load is still a 404 it's kind of hard to tell. :P

+  if (!empty($vids)) {
+    $result = db_query('SELECT nid, tid  FROM {forum} WHERE vid IN(' . db_placeholders($vids) . ')', $vids);
+    foreach ($result as $record) {
+      $nodes[$record->nid]->forum_tid = $record->tid;
+    }
   }

I don't understand how that could work, since you selected tid not tid AS forum_tid.

+ * If you need to display the nodes in a specific order, you should use
+ * the $nids array passed in to the function to access the objects once they're
+ * loaded, since the $nodes array itself may not be ordered consistently.

Could we expand this a bit more? What is the $nodes array (I think you mean return value)? Why would these not be ordered consistently -- what's the default order? What's an example of a situation where the ordering of nodes might be important where this could come up?

+  $nids_was_passed = !empty($nids) ? TRUE : FALSE;

Can probably just be $nids_was_passed = !empty($nids), no?
Also, since nids is plural, it should be $nids_were_passed? Tough to say since technically you mean $the_nids_argument_was_passed_into_the_function. :P

Do we need this variable at all? Is !empty($nids) not clear enough by itself?

+    // Conditions are ANDed together, so exclude nodes that fail to match
+    // any individual condition.
+    foreach ($nodes as $node) {
+      $node_values = (array) $node;
+      if (array_diff_assoc($conditions, $node_values)) {
+        unset($nodes[$node->nid]);
+      }
     }

Hm. Sorry, I don't quite get this still. Can we work on the comment a bit?

- (50,000  convoluted-looking str_replace lines)

Yay! ;)

+    // Call node type specific callbacks on each typed array of nodes.
+    foreach ($typed_nodes as $type => $nodes_of_type) {
+      if (node_hook($type, 'load')) {
+        $function = node_get_types('base', $type) . '_load';
+        $function($nodes_of_type);
       }
     }

Is there a way to use a standard hook function like module_invoke_all() here? These $function($thingy) things make it insanely difficult to figure out where hooks get fired. If this is for performance then fine.

+function path_nodeapi_load($nodes, $types) {
+  foreach($nodes as $node) {

tsk. ;)

-      $sql = db_rewrite_sql("SELECT MAX(n.created) FROM {node} n INNER JOIN {poll} p ON p.nid = n.nid WHERE n.status = 1 AND p.active = 1");
...
+      $sql = db_rewrite_sql("SELECT MAX(n.created), n.nid FROM {node} n INNER JOIN {poll} p ON p.nid = n.nid WHERE n.status = 1 AND p.active = 1 GROUP BY n.nid");

Any idea what the performance impact of that GROUP BY is? I imagine the answer is, "Dude. It's poll module. Who cares?" but just curious. It could use a comment to explain why we need that... how would more than one poll at a given node ID come back as active?

+function taxonomy_get_tids_from_nodes($nodes) {
+  $vids = array();
+  foreach ($nodes as $node) {
+    $vids[] = $node->vid;
+  }
+  $query = db_select('term_node', 'r');
+  $query->fields('r', array('tid', 'nid', 'vid'));

I'm getting turned around with all of those vids that mean completely different things. Can we please make $vids $node_vids or something to make it clear we're not talking about vocabulary IDs?

+ * If you need to display the terms in a specific order, you should use
+ * the $tids array passed in to the function to access the objects once they're
+ * loaded, since the $terms array itself may not be ordered consistently.

See feedback from node_load_multiple().

+  $tids_was_passed = !empty($tids) ? TRUE : FALSE;

See feedback from node_load_multiple().

+    foreach ($terms as $term) {
+      $term_values = (array) $term;
+      if (array_diff_assoc($conditions, $term_values)) {
+        unset($terms[$term->tid]);
+      }
+    }

See feedback from node_load_multiple().

   $has_rows = FALSE;
...
+  $has_rows = !empty($nids) ? TRUE : FALSE;

Again, I think this can be shortened to just $has_rows = !empty($nids).

From the patch, it looks like you set $has_rows to false and immediately set it to true/false. Bug, or me just being really tired?

catch’s picture

Status: Needs work » Needs review
FileSize
47.04 KB

Thanks webchick!

This comment doesn't reflect what the code below is doing. Is the code wrong, or the comment?

Comment was wrong, but the code could be simplified too - I'd modified both the comments and the code to make this more self-documenting.

book_nodeapi_load now has $types as the second argument - I should note that most core hook implementation just ignore any extra arguments which aren't needed (look at $a3, $a4 in the current nodeapi implementations) - and we need to go clear that up everywhere else too.

   $vid = variable_get('forum_nav_vocabulary', '');
+  // If no forum vocabulary is set up, return.
+  if (!$vocabulary = taxonomy_vocabulary_load($vid)) {
+    return;
+  }

?>
It should just return, since it has 'nothing to do here' - however I've re-ordered the code a little bit to make it more self documenting, and it saves a taxonomy_vocabulary_load() if there really is nothing to do now.

I don't understand how that could work, since you selected tid not tid AS forum_tid.

+  if (!empty($vids)) {
+    $result = db_query('SELECT nid, tid  FROM {forum} WHERE vid IN(' . db_placeholders($vids) . ')', $vids);
+    foreach ($result as $record) {
+      $nodes[$record->nid]->forum_tid = $record->tid;
+    }
   }

$record->tid is OK, forum_tid is the object property I'm assigning the variable to - which needs to be explicitly set now we're operating directly on the node object.

Also, since nids is plural, it should be $nids_were_passed? Tough to say since technically you mean $the_nids_argument_was_passed_into_the_function. :P

Do we need this variable at all? Is !empty($nids) not clear enough by itself?

Added lots of code comments and also re-ordered the code a bit - I think it should be 'was_passed' since it specifically refers to the variable. Short version is that $nids drops down to empty if we're loading everything from cache, and we need to know that it wasn't empty at some point so we can skip the database entirely if everything's been fetched from cache before we get to the query.

Is there a way to use a standard hook function like module_invoke_all() here? These $function($thingy) things make it insanely difficult to figure out where hooks get fired. If this is for performance then fine.

call_user_func_array() is expensive, so I've left this as is.

poll_load() ->
You have to have a GROUP BY when using the MAX like that, however that's all pointless since a db_query_range() works fine - and should've been used in the first there place in poll.module - I just fixed half of it instead of all of it, now it's better.

I'm getting turned around with all of those vids that mean completely different things. Can we please make $vids $node_vids

Done, - we should really change vid to rid in node_revisions though IMO.

$has_rows - killed this completely, was a holdover from the previous implementation.

tsk tsk at path_node_api_load()? I don't want to touch drupal_get_path_alias() or drupal_lookup_path() here. And if necessary I'll start posting pictures of kittens in issue followups to avoid doing so :p

That should be everything I think. Here's a re-roll.

catch’s picture

FileSize
47 KB

ack, found an actual bug in forum_nodeapi_load(), fixed for that. And the foreach whitespace in path_nodeapi_load() ;)

pwolanin’s picture

subscribining - apparently all my prior feedback was via IRC.

catch’s picture

FileSize
3.63 KB
1.22 KB
47.01 KB

Was some offset with patches applied this morning, so re-rolled for that, also found one typo in a code comment.

edit: On the assumption that #314870: UNSTABLE-4 blocker: Add api.php for every core module will go in first, I've attached straight diffs to add documentation for the altered hooks. Note there's no current documentation for hook_nodeapi_load() at all - we've still got hook_nodeapi() in there.

Since these aren't in HEAD yet, these are ghetto diffs rolled from in the module directories, one each from node and taxonomy. Hopefully this is enough to avoid another round re-roll to keep up with HEAD, not withstanding other changes needing to be made - obviously I'm not going to disappear once this gets in so will do any extra followup documentation that's required - but as good as possible for now.

catch’s picture

FileSize
47.01 KB

testing.drupal.org doesn't like me today. No changes, just trying to get it tested.

webchick’s picture

Status: Needs review » Needs work

I am so very happy to mark this CNW because of #314870: UNSTABLE-4 blocker: Add api.php for every core module. :D

catch’s picture

Status: Needs work » Needs review
FileSize
51.96 KB

Re rolled with hook docs.

catch’s picture

Dries’s picture

I'm not yet entirely convinced by this patch, but I'll research it in more depth. I recommend removing the blocker-status for the time being.

merlinofchaos’s picture

Dries: In what way is a 20-30 percent performance improvement that reduces the number of queries run on both the normal front page, plus the taxonomy pages and forum lists any other pages that list nodes not convincing?

merlinofchaos’s picture

BTW I am highly against removing this as a blocker without more explanation than Dries provided. I think the work that's gone into this deserves more than a fairly flippant response that took only 2 minutes to write and provided absolutely nothing that can be addressed. It is highly unfair to everyone who has poured time and effort into this patch.

webchick’s picture

Title: UNSTABLE-4 blocker: Load multiple nodes and terms at once » Load multiple nodes and terms at once

Removed, per Dries.

webchick’s picture

merlin: Don't worry, it's still a blocker in my heart. ;)

I think the concern might be that this patch now creates two ways of doing something we used to only have one way for, so it's a DX detriment, in that respect. We also logically are going to need to expand this out into load_profile_multiple and load_field_multiple and load_bananas_multiple in contrib, etc.

This is hypothesizing, mind you, but it's one of the concerns I've had with the patch myself. But since we're actively seeking performance improvements in D7, and since several prominent members of the core dev team have given it the thumbs up, I did feel this was pretty important.

merlinofchaos’s picture

We have always had two ways: hook_load and hook_nodeapi('load') (now nodeapi_load). This shouldn't have introduced another, unless it's morphed since the beginning. I haven't looked at the more recent versions.

drewish’s picture

webchick, with all due respect I totally disagree on the DX issue. if there's any DX issue from the different loader functions it's that we're not rolling user_load() in for consistency. node_load($nid) is very simple to understand--and if you're doing a load by something other than nid or vid you've opened yourself up to match more than one node and hence you should be calling node_load_multiple() and expecting that more than one could come back.

catch’s picture

Title: Load multiple nodes and terms at once » UNSTABLE-4 blocker: Load multiple nodes and terms at once

I think the concern might be that this patch now creates two ways of doing something we used to only have one way for, so it's a DX detriment, in that respect.

Well, let's see how much of a DX detriment.

The current node_load() function signature:

function node_load($param = array(), $revision = NULL, $reset = NULL)

This can be

node_load($nid)
node_load(array('nid' => $nid))
// or if loading a revision.
node_load($nid, $vid)
node_load(array('nid' => $nid, 'vid' => $vid))

As opposed to node_load($nid) or node_load($nid, $vid) as standardised by this patch.

So we already have two ways of doing exactly the same thing, just with the one function. A quick search for node_load in contrib shows that both ways are still used.

On the other hand if you do node_load(array('title' => $title)) - you'll have to hope that you have unique titles in whichever database your code is being called on, because you'll only ever get one back. Loading nodes by something non-unique, even by timestamp, is a fundamentally different thing to doing so by nid or vid, so not really the same thing at all.

While hook_nodeapi_load() taking an array adds a little bit of complexity (in the worst case, a foreach loop around the code that's already there) - a quick look at the API docs shows that you can grab all pertinent information from a simple module and stick it into the node object in four lines of code - saving 9 queries per hook on a standard front page (or RSS feed, or taxonomy/term listing).

+function hook_nodeapi_load($nodes, $types) {
+  $result = db_query('SELECT nid, foo FROM {mytable} WHERE nid IN(' . db_placeholders(array_keys($nodes)) . ')', array_keys($nodes));
+  foreach ($result as $record) {
+    $nodes[$record->nid]->foo = $record->foo;
+  }
+}

While we could certainly mess with the function signatures to node_load - making it internally a more complex, but allowing for node_load(array(1,2,3,4)) etc. , at the moment it's at least as readable as what's currently there.

kbahey has already benchmarked this patch, using the same methodology as http://2bits.com/articles/performance-benchmarking-drupal-512-drupal-66-... Drupal 7 is 30% slower than Drupal 6 currently according to those benchmarks, this patch gets us at least half the way towards pulling that back.

catch’s picture

Title: UNSTABLE-4 blocker: Load multiple nodes and terms at once » Load multiple nodes and terms at once

Er, didn't mean to change title, or only in a freudian sense anyway ;)

Pasqualle’s picture

subscribe

catch’s picture

Some more background on the motivation for this patch -

When introducing hook_taxonomy_term_load() (making terms into a first class object), there's some critical taxonomy functions where the extra information loaded into term objects won't be available - taxonomy_get_tree() and taxonomy_node_get_terms() are the most obvious ones - where the database is queried directly and/or adds extra information internally. Think modules like taxonomy_image which might want to replace terms with icons on teasers. To have consistency of term objects as returned by these functions, in its current state, we'd have to load every term one by one from the database.

On a front page with 30 nodes x 5 unique terms each, that's 150 queries (+ another 150 for each term hook invocation). This compared to 30 currently, and 2 with this patch (+1 for each term hook invocation). taxonomy_node_get_terms() is converted here. taxonomy_get_tree() needs its own rewrite - since it currently brings sites to their knees just by itself. We can either 1. punt on that extra flexibility, and have taxonomy terms in a limbo between first class Drupal object and what they are in D6, 2. introduce a massive performance hit, or 3. use something similar to what's proposed here to give an actual performance gain with the same level of flexibility.

Prior to starting on this, I was also looking at #111127: Cache node_load. Assuming this goes in at some point, the next step is to re-roll that patch with some alterations to fit the new mechanism, which would allow us to load every node on a standard front page in a single database query. Since that patch would require this one, I've not written it yet, but we're looking at another 58 queries saved on nodes with uploads and path aliases. About a 50% reduction compared to where we stand currently. Even if we're able to make some gains elsewhere with lazy loading etc., we're not going to be able to make the same gains being made here.

Michelle’s picture

About the DX experience... I'm fairly new to Drupal dev in terms of hours spent if not chronological time and consider myself beginner to average. I don't find the concept of using a different function to load multiple nodes difficult at all. In fact, I think I would be _more_ confused if node_load sometimes returned one node and sometimes multiple. When you're writing code to load a node, I think you'll have a pretty good idea if there's the possibility of more than one node coming back and just pick the appropriate function.

Michelle

webchick’s picture

A clarification on the DX concern, since you guys can't seem to let that one sentence go. ;)

if there's any DX issue from the different loader functions it's that we're not rolling user_load() in for consistency.

That's exactly the problem. Some of our functions are translated, not others. Files, users, and user profiles are all prominent "top-level" objects without their own _multiple functions. While those could follow in subsequent patches, how far do we go, and how does contrib know when to create one and when to not? It's those "It works like this, oh except..." things that are the DX issue, not the function parameters that's concerning.

moshe weitzman’s picture

I understand the concern, but I think it is incompatible with the "kittens" philosophy. We can't convert everything in one patch. I suggest to commit this and let the core team do what it does best - submit patches that take even more advantage of new paradigms. We have a really strong core dev team right now - more productive than the past two releases I think. We can't make decisions based on fear, and that what I sense here. We fear that we'll get stuck in middle land.

catch’s picture

So at the moment, node_load() does an extra join on the user table to grab name and picture - which is a nasty hack caused by the lack of an API such as this. If we did go ahead and refactor user_load() in a similar way (which I'd be very happy to do), we could load full user objects here instead very inexpensively, decoupling the code from node_load entirely and using our own APIs for what they're supposed to be used for. We don't have to do this, but it's at least an option (and would be an option in contrib too). With Field API on the horizon, this opens up a lot of possibilities for adding richer metadata to core objects without the massive hit that 'everything is a node' approaches have been dogged with in the past - and which we'd be running the risk of introducing everywhere else too when CCK fields can be applied to any object. However in the interest of my own sanity and kittens, I stuck with the most obvious ones here - and potential inconsistency notwithstanding, we still get a nice fat performance gain either way.

Just for reference, taxonomy.module does this for terms, already, for similar reasons - just in a very nasty self-enclosed way, and taxonomy_get_vocabularies() does the same 'return array of objects' routine as here, comment_render does the same 'load everything in one query' thing too. So this isn't completely new - it's in large part refactoring old stuff that desperately needs refreshing given the things people are trying to do with Drupal now.

So, I can keep mouthing off about performance and API gains, but with only a total of five sentences to go on in over five weeks, none of which express specific concerns with the approach, it's a little hard to know where Dries' reservations lie.

aclight’s picture

subscribe

chx’s picture

That's exactly the problem. Some of our functions are translated, not others. Files, users, and user profiles are all prominent "top-level" objects without their own _multiple functions. While those could follow in subsequent patches, how far do we go, and how does contrib know when to create one and when to not? It's those "It works like this, oh except..." things that are the DX issue, not the function parameters that's concerning

Well, this is true, however

  1. There is always the next commit. We wont be stuck in "middle ground", there is ample time.
  2. I can easily find N use cases where I list nodes, like feeds, taxonomy term pages, pages listed by Views (just why do you think that module is insanely popular?) and not so easily pages where I list, say files. The comparison is not fair. This patch is a must. The rest can come later.
catch’s picture

how does contrib know when to create one and when to not?

The rules for contrib following this would be pretty clear IMO:

"When your module creates a new 'top level' object with its own foo_*_load() hook, and which will be shown in lists to site visitors." I can't think of any contrib modules that do this though.

Dries’s picture

merlinofchaos: the reason I posted my comment is because I want to have a good look at this patch. Another big "blocker"-patch got somewhat rushed in before I could review it -- I don't want that to happen. I don't think patches should have deadlines or dependencies. I did not reject the patch, I simply asked to remove the "blocker" stuff. Thanks.

catch’s picture

FileSize
52.54 KB

Found a way to fix something webchick and Damien brought up previously - I'd documented as a behaviour, but it became easier to solve properly as a product of another change. The $nodes array returned by node_load_multiple() is now always in the same order as the original $nids array that gets passed in. Also removed the foreach loop which was pulling nodes/terms from cache - PHP's built in array functions used instead (and a bit nicer than the version of this we had before). Since there's been a few small changes since the last round of benchmarks, I'll try to run another set just to verify nothing expensive has been introduced, or if anyone else has a chance, that'd be great too.

catch’s picture

FileSize
52.54 KB

grrr, doc typos do not show themselves until uploaded to drupal.org.

Pasqualle’s picture

doc typos do not show themselves until uploaded to drupal.org

you should create a new issue for that :) Someone might have a solution..

catch’s picture

Fresh benchmarks as promised. Site has 5,000 nodes, a mixture of page and article, some with comments and taxonomy terms.

Benchmarked /node /node/10 /rss.xml and /taxonomy/term/10 using ab -c1 -n500.

I got between 10-38% speed up on the listings pages - depending on number of nodes per page and what else is shown. Still around a 2% decrease on node/10 (which calls node_load() 17 times apparently suggesting the array handling inside node_multiple_load for cache fetching hasn't caused any harm since the last set of benchmarks, which were also between 1-3% hit).

All numbers are requests per second:

node (30 nodes)
before:
3.40
after:
4.71
(38% faster)

node/10 (1 node)
before:
11.77
after:
11.56
(2% slower)

rss.xml (10 nodes)
before:
10.92
after:
14.19
(36% faster)

taxonomy/term/10 (8 nodes)
before:
4.27
after:
4.68
(10% faster)
Dries’s picture

catch, do you have any ideas for how we could get rid of that 2%?

It would be interesting to look at the drupal.org usage and to figure out what the ratio node listings vs individual nodes would be. It is probably easy to instrument drupal.org and to capture some of that data. It might also be available from the regular usage statistics or from MySQL logging. We could then understand if that 2% regression is more important than the 10-30% improvement on listings.

merlinofchaos’s picture

If the 2% is coming from the use of IN () we can optimize the single cause to use =. Views does this in a few places.

stewsnooze’s picture

subscribe

Anonymous’s picture

just confirming catch's benchmarking - notably quicker for listings, ~2% slower for a single node with a bunch of devel-generated content.

investigationing...

catch’s picture

FileSize
53.26 KB

There's the following places where extra work is done in the single case.

* pulling stuff out of cache once when there's multiple requests to node_load() on a single page (see below)
* One extra query to pull taxonomy terms in (but as noted, the current query bypasses taxonomy_term_load() when pulling terms out which hamstrings the new hooks, so to keep a balance between performance and flexibility, it's our best option IMO even with a small hit).
* db_placeholders(), IN() and foreach in hook_nodeapi_load.
* (maybe) Using the dynamic query builder in node_load_multiple() and taxonomy_term_load_multiple() (HEAD isn't converted to the new API yet).

Stuff we can do:
* Special case the single case pulling stuff from cache in node_load() - this pulled 1% back without nodeapi modules enabled (see below))
* I'd also expect us to get at least that 2% back with caching of node_load() (and a decent improvement on listings too). I'm planning to jump into that after this one, but could work up a proof of concept patch to gauge the performance difference pretty quickly.
* Like Damien said, there's bound to be some more micro-optimisations which could be done.

New patch which uses reset() instead of array_* to pull things from the node cache in the single case. Same site as above, all core modules enabled, terms and comments on the nodes. node/10

HEAD:
13.58
13.51
13.34
New patch: (2% slower)
13.17
13.23
13.22
Patch from #106 (also 2%, but looks like the new one is a tiny bit faster).
13.12
13.16
13.16

Same thing but with all nodeapi modules disabled:
Head:
16.60
16.86
16.85
Patch with special casing for single cached nodes (1% slower, nearly margin of error):
16.60
16.62
16.64
Patch from #106 (Still 2% slower):
16.38
16.56
16.29

My feeling is that there's lots of places in core we can optimise to pull a couple of percent from on a single node view (caching node_load, using the registry properly etc.), but not the same opportunity to get 20-40% back on node listings. Where it's more important will depend on the site - Drupal.org has both a front page which presumably gets a large proportion of traffic, and also a very long tail of individual node pages. If you have a lot of RSS feed subscriptions and taxonomy term listings, you'd see plenty of benefit, a small brochure site probably wouldn't.

Anyone has time to do their own benchmarks, that'd be great to verify these one way or the other. I'll see about rolling the cache patch so it's benchmarkable - not that we couldn't do it by itself, but it'd be a single query on a node listing so hopefully lightning fast. edit: and see what Justin comes up with :)

Anonymous’s picture

to try to get a feel for this, put some timers in node_load on HEAD, and node_load with this patch.

timer code measures from start of node_load to just after the query is run. old node_load is consistently quicker by about ~1ms to process args and load the node.

then, i hacked together a quick DBTNG conversion of old node load:

http://pastebin.ca/1268573

with the same timer code, i get no measurable difference in the node load code.

benchmarking the single node case for the DBTNG of old node_load vs catch's patch, i can't get any difference that looks like more than statistical fuzz.

so, can someone validate my hacky conversion? because if it doesn't suck, then the performance issue for single nodes looks like "to DBTNG or not to DBTNG".

catch’s picture

killes sent me an html dump of the 'url details' awstats report for Drupal.org (as of yesterday, presumably these are for November so far)

19370924 pages total

Most of the top pages are node listings, others include user/register /forum /project /cvs /xml.rpc /planet/feed etc. which are neither node listings nor individual nodes - those get 90k-300k each.

Here's the top ten node listing pages:
1016099 - /node
702970 - /node/feed
650053 - project/issues/rss
256192 - /security/rss.xml
146356 - taxonomy/term/14/0/feed
75554 - /project/Modules/name
74926 - /taxonomy/term/51/0/feed
72191 - rss.xml
64090 image/tid/39
64040 taxonomy/term/8/0/feed

These 10 pages make up 15% of total traffic to the whole site.

The first individual node page on the list is /node/258 at 62015 - between 30-60k views per month, it's a mixture of non-node content, more listings, and pages like /project/cck which are for individual nodes.

Divvying that up - this is educated guesswork but close enough for our purposes - let's say of the 19 million, 20% is non node content - user* tracker* project* forum* etc. - that leaves 15 million. Of that we'll say 25% is node listings.

So that leaves 3.8 million listing views, and 11.2 million individual node views.
Now we pretend my dev install is drupal.org, and compared node/ and /node/10 - now time per request instead of requests per second. Then add 2% per request for node/10 and split the difference at taking 18% off for node/ when the patch is applied.
node/10
HEAD: 83.6ms
patch: 85.2ms

node/
HEAD: 262ms
patch: 215ms

Multiply the pages served by the time taken to serve them, then add it together:

HEAD              Patch:
936320000	954240000 (node/10)
995600000	817000000 (node/)
-----------
1931920000	1771240000 (totals)
catch’s picture

Justin's analysis of node_load_multiple() also applies to taxonomy_term_load_multiple() - which uses the query builder too. So if it's really 1ms extra for the query builder, then 2ms on a page request is about our 2% hit. A straight patch to convert node_load() would also introduce the same penalty (but likely not be benchmarked since we have to convert it at some point anyway). If we can verify this then there's not a lot I can do here.

However obvious next step to improve node_load() performance across the board is to cache node_load() - that's out of scope for this issue, but as noted, my next stop if/when this goes in.

catch’s picture

FileSize
3.17 KB
4.61 KB

OK, minus a missing !empty($conditions) check, the straight dbtng conversion from #114 works great - so benchmarked that vs. #113 on node/10. Also attached it in patch form if anyone else wants to run the same thing.

Benchmarks are now inconclusive between HEAD, dbtng and the patch on node/10 (my laptop is running on battery this time so could be that).

Noticeable hit with straight dbtng conversion on /node though - about 6%- presumably because the query is built 30 times for the 30 different nodes.

All numbers are #reqs/second
node/10  (middle result of three benchmarks - doesn't match earlier ones so we can write this off to laptop energy saving maybe)
HEAD:
11.86

node_load converted to dbtng only
11.96

Multiple load (inc dbtng):
12.12


/node
HEAD
3.90
3.89
3.90

node_load converted to dbtng only: (about 6% slower).
3.70
3.60
3.61

Multiple load (inc dbtng), faster, as before.
5.08
5.13
5.07

Would be good to get a set of benchmarks from someone else too, but looks like all the performance hit is due to dbtng, and a quite significant extra hit on listings if we just did a straight converson without the multiple load stuff alongside it.

edit: also attached ab output for /node - fresh benchmarks because I didn't save this the first time round, but same general results as above.

catch’s picture

FileSize
3.18 KB

With my laptop plugged in I get slightly more reliable results for node/10 - these pretty much match what Justin reckoned. One bench each for HEAD, dbtng conversion only, and multiple load. If someone has a proper benchmarking environment to run similar on, that'd be really useful here - I getting quite a bit of variation for each ab run - so while I've tried to pick averages, it's hard to know if this is just margin of error when there's only a couple of percent in it anyway.

Dries’s picture

Thanks for the benchmarking. It looks like we'll have to cut some fat from DBTNG ... Time to get rid of that DBTNG backward compatibility layer maybe? :I

The drupal.org analysis is greatly appreciated too. If I interpret the numbers correctly, this would give us an estimated 8-9% load reduction on drupal.org. I haven't checked yet whether the "node load multiple" queries are still using an index, but I assume so. Based on visual inspection, I don't see why they shouldn't.

The numbers and analysis makes me believe this is core-worthy.

Let's focus on the developer experience. Let's take another hard look at how easy this API is, and if it is transportable (not now) to other systems. I'm currently traveling but I'll do a deep dive on Monday or Tuesday.

Keep up the good work.

catch’s picture

FileSize
52.57 KB

Since the 2% hit isn't down to anything internal with the patch, up to date version which removes the red-herring special casing for single node loads.

One thing we could maybe do for API clarity is hook_nodeapi_load($nodes, $nids, $types); - to save people using array_keys($nodes). Trivial patch to roll, but I'll leave it here for feedback first.

Crell’s picture

Dries asked me to stop by here. Can't at the moment, but will get to it as soon as I can. In other words, subscribe. :-)

Dries’s picture

I had another look at this to think a bit about the API. What I'm worried about is that it makes _load_node functions quite a bit more complex/lengthy, so we're trading elegance for performance. I guess the latter is key given the impressive performance gains. It feels like a natural evolution for the node API. All in all, this is probably the best API but I would have hoped to see a bit more discussion in this issue..

Status: Needs review » Needs work

The last submitted patch failed testing.

catch’s picture

Status: Needs work » Needs review

Here's a straight re-roll after the singular tables patch went in. Will roll hook_nodeapi_load_multiple($nodes, $nids, $types); next for comparison.

catch’s picture

FileSize
52.51 KB

and the patch.

catch’s picture

FileSize
52.68 KB

So the two main places where the hook_nodeapi_load functions get longer are comment and taxonomy.

As described earlier, taxonomy.module currently takes a shortcut to load term objects - since it bypasses its own API to load terms. I don't see a way to fix this in a scalable way without loading full term objects in the one database call (as opposed to dozens of taxonomy_term_load()) - so the extra length is partly due to combining the nodeapi_load conversion and the taxonomy api changes in one patch, although they're fairly hard to separate. This should simplify a lot of things in taxonomy.module and the contrib that depends on it down the line though, if that's any consolation.

With comment_nodeapi_load() - there's some extra length there because I tried to optimise for not querying the database unless absolutely necessary. In Drupal 6, we issue a query whether comments are enabled or not, this was fixed in Drupal 7 - see #250729: Comment nodeapi unnecessary query. comment.module is fairly unusual in having an individual setting per node as well as the per-node type default - it's also got a symbiotic relationship with node.module - i.e. http://drupal.org/node/330674 and http://drupal.org/node/148849 - so I'd hope that it's the exception rather than the rule in terms of additional length.

In the basic case, it's just an extra foreach loop, and swapping an = for an IN() which isn't too bad I think. But yes, some of the hook implementations are going to get a bit longer - especially highly optimised ones, but then we save dozens of queries on node listings from that extra code weight - and it's run once instead of over and over.

Here it is with the additional $nids parameter to save hook implementations doing an array_keys() in just straight queries.

Status: Needs review » Needs work

The last submitted patch failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
52.67 KB

Typos in the api docs, thanks test bot.

Status: Needs review » Needs work

The last submitted patch failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
52.67 KB

hmm. Tests pass locally, and I can't see any syntax errors in the patch file either. Another try.

Status: Needs review » Needs work

The last submitted patch failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
53.2 KB

re-rolled with changelog.txt entry

Status: Needs review » Needs work

The last submitted patch failed testing.

swentel’s picture

@catch: patch fails now on poll because #342294: Rename poll, profile and taxonomy modules tables to singular allready got in.

catch’s picture

Status: Needs work » Needs review
FileSize
53.19 KB

Thanks swentel. Re-rolled.

Dries’s picture

Status: Needs review » Fixed

Committed to CVS HEAD. Thanks.

Michelle’s picture

Awesome! Thanks, Dries!

Michelle

catch’s picture

Status: Fixed » Closed (fixed)

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

kenorb’s picture

It's possible to backport it to 6.x?

catch’s picture

No chance of a backport, this is a big API change.