Environment
- Helix Ultimate framework 2.2.10 (plugin
plg_system_helixultimate), template Gazette 2.0.5 - Joomla 5.4.9, PHP 8.3 (PHP-FPM), MySQL 8.4
- Joomla caching: conservative, cache handler memcached (local to each web server)
- News site with a main menu of ~50 items and article lists on most pages
The problem
HTMLOverride::loadTemplate() (src/Platform/HTMLOverride.php) runs every time an overridden layout is rendered, for example once per menu item (the Gazette html/mod_menu/default_url.php calls it), and once per article for the intro image, info block and similar layouts. Each call:
- Calls
parsePath()4 times ($htmlPath,$overridePath,$tmplOverridePathandjoomla.asset.json). The last result,$webAssetUri, is never used. - Each
parsePath()callsHelper::loadTemplateData()only to read$template->template(the template folder name, which never changes during a request). Helper::loadTemplateData()(src/Platform/Helper.php) has no per-request memoisation. Every call creates a newHelixCache(and with it a newCache::getInstance()controller) and then:- checks the draft key with
$cache->contains()before checking$requestFromIframe, so the cache is queried on every frontend request even though the result is only used in the editor iframe; - checks the init key with
$cache->contains(), and on a hit callsloadData(), which reads the same entry a second time.
- checks the draft key with
- It also calls
\debug_backtrace()withoutDEBUG_BACKTRACE_IGNORE_ARGSor a limit, which copies the whole call stack with its arguments just to read[0]['file'].
On the frontend the init key is almost never present: it is only stored when an administrator opens the Helix settings editor (Settings.php), with a 24-hour lifetime, and only in the cache of the server that handled that request. So in practice each loadTemplateData() call is 2 cache misses followed by the (memoised) database read in getTemplateStyle(), and each overridden layout render is 8 cache misses.
Measured on our site (one production web server, normal daytime traffic, memcached watch fetchers for 20 seconds):
- 2,946 cache reads during 15 page views
- 2,344 of them (80%) were in the
helixultimatecache group, and all 2,344 were misses (not_found) - That is ~156 Helix cache misses per page view; a separate 60-second sample gave ~166 cache reads per page view in total, 87% of them misses
- The PHP-FPM slow log shows request stacks stuck in this path whenever the cache is slow, e.g.
HelixCache->__call()→Helper::loadTemplateData()→HTMLOverride::parsePath()→HTMLOverride::loadTemplate()→templates/shaper_gazette/html/mod_menu/default_url.php(39 of the slow traces in our log go throughHelixCache.php:145)
A raw memcached miss costs ~15 µs from PHP on our server, so the network part alone is ~2.5 ms per page, plus the PHP overhead of creating a cache controller, hashing the key and the full debug_backtrace() each time. The bigger cost is with a remote or file cache handler, where every one of these ~150 misses becomes a network round trip or a filesystem lookup.
Suggested fix
All backwards-compatible, no behaviour change:
- Memoise
Helper::loadTemplateData()per request, e.g. astatic $cache = []keyed by$templateIdplus whether this is an editor iframe request (the same patterngetTemplateStyle()already uses). This alone removes almost all of the lookups. - In
HTMLOverride::loadTemplate(), read the template name once and pass it toparsePath()(or memoiseparsePath()), and drop the unused$webAssetUriline. - Check
$requestFromIframebefore$cache->contains()inloadTemplateData(), so normal frontend requests never query the draft key. - Use one read instead of
contains()+loadData(): callloadData()and test fornull. - Use
debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1)inloadTemplate(), since only the caller's file is needed.
Impact
Every Helix Ultimate site renders overridden menu items and article layouts on almost every page, so this affects every Helix site that uses Joomla caching, and the waste grows with the menu size and the number of articles listed per page. The fix is a few lines, mostly a static memo, with no change in output. I'm happy to test a build on our site and share the before/after cache counts.