Red floppy disk

Let’s start with a story.

A few months ago I was working on a new type of test for Nelio A/B Testing when I noticed something weird: one of the functions I implemented had a huge negative impact on our plugin’s performance… and I had to fix it.

The function itself was pretty straightforward:

function is_product_under_test( $experiment_id, $product_id ) {
  $control = nab_get_alternative( $experiment_id, 'control' );
  return nab_some(
    fn( $selection ) => do_products_match( $selection, $product_id ),
    nab_get( $control, 'productSelections', [] )
  );
}Code language: PHP (php)

That is, given a certain WooCommerce product identified by $product_id is tested by the A/B Test identified by $experiment_id. When the user defines the test, she can specify the tested products as follows:

  • either all products are under test, or
  • only some products are. If only some are, the user should specify a:
    • list of (tested) product IDs
    • list of (tested) product categories and/or product terms

And that’s precisely what my helper function do_products_match checks:

function do_products_match( $selection, $product_id ) {
  if ( 'all-products' === $selection['type'] ) {
    return true;
  }
  $subset = $selection['value'];
  switch ( $subset['type'] ) {
    case 'product-ids':
      return do_products_match_by_id( $subset, $product_id );
    case 'product-taxonomies':
      return nab_every(
        fn( $taxsel ) =>
          do_products_match_by_taxonomy( $taxsel, $product_id ),
          $subset['value']
      );
    default:
      return false;
  }
}Code language: PHP (php)

Roughly speaking, if the $selection is set to all-products, this function will always return true. Otherwise, it looks at the type of $selection we’re dealing with and performs a further analysis.

If we’re testing products by ID, we load all relevant product IDs (WooCommerce has variable products and what not that we need to take into account) and check if the products are included or excluded from the selection:

function do_products_match_by_id( $selection, $product_ids ) {
  $actual_pids   = add_parent_products( $product_ids );
  $tracked_pids  = $selection['productIds'];
  $matching_pids = array_intersect( $actual_pids, $tracked_pids );
  $excluded = ! empty( $selection['excluded'] );
  $mode     = $selection['mode'];
  if ( $excluded ) {
    return 'and' === $mode
      ? empty( $matching_pids )
      : count( $matching_pids ) < $tracked_pids;
  }//end if
  $mode = $selection['mode'];
  return 'and' === $mode
    ? count( $tracked_pids ) === count( $matching_pids )
    : ! empty( $tracked_pids );
}//end do_products_match_by_id()Code language: PHP (php)

If, on the other hand, we’re testing products by taxonomy terms, we must retrieve all terms associated to our potentially-tested product and compare those to the given $selection:

function do_products_match_by_taxonomy( $selection, $product_ids ) {
  $actual_pids    = add_parent_products( $product_ids );
  $tracked_terms  = $selection['termIds'];
  $actual_terms   = get_all_terms( $selection['taxonomy'], $actual_pids );
  $matching_terms = array_intersect( $actual_terms, $tracked_terms );
  $excluded = ! empty( $selection['excluded'] );
  $mode     = $selection['mode'];
  if ( $excluded ) {
    return 'and' === $mode
      ? empty( $matching_terms )
      : count( $matching_terms ) < $tracked_terms;
  }//end if
  $mode = $selection['mode'];
  return 'and' === $mode
    ? count( $matching_terms ) === count( $tracked_terms )
    : ! empty( $matching_terms );
}//end do_products_match_by_taxonomy()Code language: PHP (php)

As you can imagine, all these checks take some time. But it’s no big deal, as the time it takes to evaluate a single product for any given test is negligible…

Unless, of course, you check the same product/test combo over and over again.

And that’s precisely what I did. 🙃

I was using the same check on multiple filters and actions:

  • woocommerce_product_title
  • woocommerce_order_item_get_name
  • woocommerce_short_description
  • woocommerce_product_get_image_id
  • woocommerce_product_get_gallery_image_ids
  • woocommerce_product_get_price
  • woocommerce_product_get_regular_price
  • etc

But I had to, of course! Every time WooCommerce is about to do anything with a product (and there’s a filter/action around that), our plugin needs to check if that product is affected by our test.

So how can I easily fix this?

The answer is in the title of today’s post: memoization.

Memoization

Memoization is an optimization technique to speed up programs by storing the results of expensive function calls and reusing them when the same inputs occur again.

If you think about it, it’s actually not that different from what cache plugins do. Given a certain URL, generating the full page is expensive and takes some time, so a cache plugin stores a cached copy of the generated page and reuses it for subsequent requests. Memoization follows the exact same principle:

  • Cache. When a function is called with a specific input for the first time, the result is saved (or “memoized”) in a data structure like a dictionary or cache.
  • Reuse. If the function is called again with the same input, the program retrieves the result from the cache instead of recomputing it.

Example

Let’s go back to my story. The expensive function I was invoking over and over again was this one:

function is_product_under_test( $experiment_id, $product_id ) {
  $control = nab_get_alternative( $experiment_id, 'control' );
  return nab_some(
    fn( $selection ) => do_products_match( $selection, $product_id ),
    nab_get( $control, 'productSelections', [] )
  );
}Code language: PHP (php)

How can one implement a cache that makes it possible to reuse previous results inside an expensive function in PHP? Well, as it turns out, it’s actually pretty simple:

function is_product_under_test( $experiment_id, $product_id ) {
  static $cache = array();
  $cache_key    = "{$experiment_id}:{$product_id}";
  if ( isset( $cache[ $cache_key ] ) ) {
    return $cache[ $cache_key ];
  }
  $control = nab_get_alternative( $experiment_id, 'control' );
  $cache[ $cache_key ] = nab_some(
    fn( $selection ) => do_products_match( $selection, $product_id ),
    nab_get( $control, 'productSelections', [] )
  );
  return $cache[ $cache_key ];
}Code language: PHP (php)

All you gotta do is define a static dictionary (I named it $cache) and create a $cache_key to index the cached result for the current invocation. If there’s a result already available, use it. Otherwise, compute it, cache it, and return it. It’s that easy!

Nelio A/B Testing

Native Tests for WordPress

Use your WordPress page editor to create variants and run powerful tests with just a few clicks. No coding skills required.

Limitations and Pitfalls

As you’ve just seen, memoization is a great optimization technique for speeding up your plugins, especially if you’re applying it on functions that, just like mine did, are invoked multiple times using the same arguments. However, there’s a few things you should be aware of:

  • Function Argument Types. When memoizing a function, you’re supposed to cache the results of said function based on the inputs you’re given. This means you need to be able to generate a cache key based on said inputs. That’s why memoization usually works best with simple, immutable arguments (like integers or strings).
  • Idempotency. Memoization assumes that the function will always produce the same output for the same input. If the function relies on external state that can change, you have to keep in mind that cached results would not reflect changes in the state and, therefore, they can be incorrect. For instance, in the example I shared today, the taxonomies applied to a certain product may change over time… but that’s not a problem for me because the memoized function only caches the results during a single execution and, with such a small time frame, we can safely assume that the external state is constant.
  • Unreliable Side Effects. Functions that depend on side effects (logging, modifying global variables, etc) may produce unintended results when memoized, as their side effects may only happen once during the first execution and not on subsequent cached calls.

Memoization is powerful but works best when:

  • Functions are pure (which means there are no side effects and you always get consistent outputs for any given inputs).
  • Memory usage can be managed or bounded.
  • Functions use immutable or hashable arguments.

Carefully evaluating these factors before memoizing functions can help you avoid potential pitfalls and ensure that this technique truly optimizes your program.

I hope you liked today’s post and, if you did, please share it with your colleagues and friends!

Featured Image by Fredy Jacob on Unsplash.

Leave a Reply

Your email address will not be published. Required fields are marked *

I have read and agree to the Nelio Software Privacy Policy

Your personal data will be located on SiteGround and will be treated by Nelio Software with the sole purpose of publishing this comment here. The legitimation is carried out through your express consent. Contact us to access, rectify, limit, or delete your data.