PERF: reduce workload when optimizing images

PERF: reduce workload when optimizing images

Previously, we would initialize an ImageOptim object each time we resize.

This object init is mega expensive (170ms on a VERY fast machine):

[1] pry(main)> Benchmark.measure { FileHelper.image_optim   }
=> #<Benchmark::Tms:0x00007f55440c1de0
 @cstime=0.055742,
 @cutime=0.141031,
 @label="",
 @real=0.17165619300794788,
 @stime=0.0002750000000000252,
 @total=0.19890400000000008,
 @utime=0.0018560000000000798>

This happens cause during init it hunts for all the right binaries and sets up internals.

We now memoize this object to avoid a huge amount of pointless work.

diff --git a/lib/file_helper.rb b/lib/file_helper.rb
index 3f148fc..feed479 100644
--- a/lib/file_helper.rb
+++ b/lib/file_helper.rb
@@ -83,26 +83,41 @@ class FileHelper
   end
 
   def self.optimize_image!(filename, allow_pngquant: false)
-    pngquant_options = false
-    if allow_pngquant
-      pngquant_options = { allow_lossy: true }
+    image_optim(
+      allow_pngquant: allow_pngquant,
+      strip_image_metadata: SiteSetting.strip_image_metadata
+    ).optimize_image!(filename)
+  end
+
+  def self.image_optim(allow_pngquant: false, strip_image_metadata: true)
+    # memoization is critical, initializing an ImageOptim object is very expensive
+    # sometimes up to 200ms searching for binaries and looking at versions
+    memoize("image_optim", allow_pngquant, strip_image_metadata) do
+      pngquant_options = false
+      if allow_pngquant
+        pngquant_options = { allow_lossy: true }
+      end
+
+      ImageOptim.new(
+        # GLOBAL
+        timeout: 15,
+        skip_missing_workers: true,
+        # PNG
+        optipng: { level: 2, strip: strip_image_metadata },
+        advpng: false,
+        pngcrush: false,
+        pngout: false,
+        pngquant: pngquant_options,
+        # JPG
+        jpegoptim: { strip: strip_image_metadata ? "all" : "none" },
+        jpegtran: false,
+        jpegrecompress: false,
+      )
     end
+  end
 
-    ImageOptim.new(
-      # GLOBAL
-      timeout: 15,
-      skip_missing_workers: true,
-      # PNG
-      optipng: { level: 2, strip: SiteSetting.strip_image_metadata },
-      advpng: false,
-      pngcrush: false,
-      pngout: false,
-      pngquant: pngquant_options,
-      # JPG
-      jpegoptim: { strip: SiteSetting.strip_image_metadata ? "all" : "none" },
-      jpegtran: false,
-      jpegrecompress: false,
-    ).optimize_image!(filename)
+  def self.memoize(*args)
+    (@memoized ||= {})[args] ||= yield
   end
 
   def self.supported_images

GitHub sha: 4232d326

2 Likes

For a bit more context… this is the cost on our servers at SJC:

 #<Benchmark::Tms:0x000055cf34bd6ba0
 @cstime=0.04,
 @cutime=0.164,
 @label="",
 @real=0.20788691099733114,
 @stime=0.0,
 @total=0.20799999999999957,
 @utime=0.0039999999999995595>

This is the cost on a typical digital ocean droplet:

=> #<Benchmark::Tms:0x000056028dd22810
 @cstime=0.024,
 @cutime=0.348,
 @label="",
 @real=0.5272870305925608,
 @stime=0.0,
 @total=0.38,
 @utime=0.008000000000000007>

This fix has now been backported to stable.

1 Like