Reducing Clojure Lambda Cold Starts Part 1 - Baseline

Search for a command to run...

No comments yet. Be the first to comment.
After my last post Reducing Clojure Lambda Cold Starts Part 1 - Baseline, I remembered that a few years back, Lambdas within a VPC had some pretty terrible cold start times. Doing a comparison seemed like low-hanging fruit so I decided to run the sam...
If you are using the v2 AWS SDK clients, you can configure them like so: (ns my.s3 (:import (java.net URI) (software.amazon.awssdk.regions Region) (software.amazon.awssdk.services.s3 S3Client) (software.amazon.awssdk.services.s3.mo...
Last time we created our Rust and Typescript Lambdas with basic hello world implementations and did a quick performance comparison. We'll now expand our Rust and Typescript Lambdas from last time into ones that take data from SQS messages and push th...

In this series, I will be investigating throughput tuning for a Lambda that receives SQS events, reads data from S3 object, and blasts the data into DynamoDB. While I'm at it, I'll do a performance shootout between Rust and Typescript versions, attem...

Where Lambda cold starts often get worse in other runtimes is when you start adding dependencies, particularly an AWS SDK dependency. Let's see how Rust fares with an S3 client dependency. Updating Cargo.toml: [package] name = "tax_engine_experiments...

Rust seems to be at the height of the hype cycle right now even among functional programming enthusiasts. Although it's not a true functional programming language, due to not having first-class support for immutable data structures, its ownership mod...

Clojure Lambdas have some pretty abysmal memory footprints and cold start times compared to most other languages, especially Go, Node.js, and Python. Loading the Clojure core library and application code makes this worse. Regardless, I've found Clojure to be a great language for many data processing tasks and I've used it extensively for event-driven flows using Lambda. For many of the workloads I've run with Clojure Lambdas in the past, they have been periodic ETL jobs where the cold start times haven't been much of an issue as far as workflow time requirements, although the extra expense of having to use larger instances to accommodate the JVM/Clojure memory footprint along with the additional runtime caused by the cold starts has been concerning. Recently, however, I have been working on some near-real-time data flows for which the cold starts are a major issue.
To hack around the problem and eliminate most cold starts, we've had to use provisioned concurrency, which introduces huge additional expense to provision compute that is being wasted most of the time. One of the things I love about Lambda is the efficiency. It allows you to reduce your costs and carbon footprint by only using the compute you need. Having to provision concurrency on Clojure Lambdas negates a lot of this efficiency. This has created an existential crisis for me. Do I need to totally rethink using Clojure and perhaps switch to Rust? Do I need to rethink Lambda and switch Clojure workloads to ECS?
In the past, I have done some experiments with switching Clojure Lambdas to ClojureScript Lambdas running on the Node.js runtime. This has seemed to greatly reduce the cold start times, but requires some significant rewrite of code that calls AWS SDKs and I suspect the compiled JavaScript will be significantly slower for some of the heavy computation my current workloads require. This is the first in series of posts where I will be doing performance comparisons for Lambdas running Clojure on the JVM, Clojure on GraalVM, ClojureScript on Node.js, and Rust. I will start with gathering the baseline cold starts for a JVM Lambda.
First, I need to create a new project, which I will call tax-engine-experiment, as I will be doing comparisons on capital gains tax calculations:
>> mkdir tax-engine-experiment
>> cd tax-engine-experiment
>> git init
Previously, for Lambda, I have typically used Leiningen because of the Clojure CLIs lack of support for building uberjars, which I typically use for Lambda. Recently, however, such support has been added with tools.build, so I will try that. This requires that I add a build.clj at the root application level:
>> emacs build.clj
Then add the following code:
(ns build
(:require [clojure.tools.build.api :as b]))
(def lib 'taxbit/tax-engine)
(def version "0.1.0")
(def class-dir "target/classes")
(def basis (b/create-basis {:project "deps.edn"}))
(def uber-file (format "target/%s-%s-standalone.jar" (name lib) version))
(defn clean [_]
(b/delete {:path "target"}))
(defn uber [_]
(clean nil)
(b/copy-dir {:src-dirs ["src" "resources"]
:target-dir class-dir})
(b/compile-clj {:basis basis
:src-dirs ["src"]
:class-dir class-dir})
(b/uber {:class-dir class-dir
:uber-file uber-file
:basis basis
:main 'tax.core}))
I also need a deps.edn file at the root level for the Clojure CLI:
>> emacs deps.edn
With the following code:
{:paths ["src/clj"]
:aliases {:build {:deps {io.github.clojure/tools.build {:tag "v0.7.2" :sha "0361dde"}}
:ns-default build}}}
And I need a basic Lambda handler:
emacs src/clj/tax/core.clj
(ns tax.core
(:gen-class
:methods [^:static [calculationsHandler [Object] Object]]))
(defn -calculationsHandler [event]
(prn "HELLO EVENT" event)
event)
Now I can compile the project to uberjar:
>> clj -T:build uber
Now I need my Serverless Application Model (SAM) template.yml:
AWSTemplateFormatVersion: "2010-09-09"
Transform:
- "AWS::Serverless-2016-10-31"
Resources:
RunCalculationsQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub "${AWS::StackName}-run-calcs-queue"
VisibilityTimeout: 5400
RunCalculations:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub "${AWS::StackName}-run-calcs"
Handler: tax.core::calculationsHandler
Runtime: java11
CodeUri: target/tax-engine-0.1.0-standalone.jar
Timeout: 900
MemorySize: 2048
Policies:
- AWSLambdaBasicExecutionRole
Events:
SQSEvent:
Type: SQS
Properties:
Queue: !GetAtt RunCalculationsQueue.Arn
BatchSize: 1
Outputs:
QueueUrl:
Value: !Ref RunCalculationsQueue
And use the SAM CLI to verify everything works:
>> sam local invoke
...
START RequestId: 8e2f1a01-11fe-4ff1-9b35-4cc8f5f152fa Version: $LATEST
"HELLO EVENT" {}
END RequestId: 8e2f1a01-11fe-4ff1-9b35-4cc8f5f152fa
REPORT RequestId: 8e2f1a01-11fe-4ff1-9b35-4cc8f5f152fa Init Duration: 1.13 ms Duration: 30247.04 ms Billed Duration: 30248 ms Memory Size: 2048 MB Max Memory Used: 2048 MB
Looks good. Now I can deploy it to my AWS sandbox:
>> sam package --s3-bucket larrys-cool-bucket-name
>> sam deploy --stack-name tax-engine-experiments --s3-bucket larrys-cool-bucket-name --capabilities CAPABILITY_IAM
Now I can invoke the Lambda to make sure it deployed:
>> aws lambda invoke --function-name tax-engine-experiments-run-calculations results.txt
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
So far so good. Now I need to generate as many cold starts as I can by triggering current invocations. I'll throw a bunch of messages on the queue. I'll add AWS dependencies and a 'profile' alias:
>> emacs deps.edn
{:paths ["src/clj"]
:aliases {:build {:deps {io.github.clojure/tools.build {:tag "v0.7.2" :sha "0361dde"}}
:ns-default build}
:profile {:extra-paths ["dev/clj"]
:deps {software.amazon.awssdk/sqs {:mvn/version "2.17.100"}
software.amazon.awssdk/sso {:mvn/version "2.17.100"}}}}}
Note that I needed the 'sso' dependency because I use 'aws configure sso' since I use SSO to access my sandbox account. Now I need to add the code to push the messages:
>> emacs dev/clj/tax/profile.clj
(ns tax.profile
(:import (software.amazon.awssdk.services.sqs SqsClient)
(software.amazon.awssdk.services.sqs.model SendMessageRequest)))
(defn profile []
(let [sqs (-> (SqsClient/builder) (.build))
req (-> (SendMessageRequest/builder)
(.queueUrl <<queue URL copied from deploy output>>)
(.messageBody "Hello Calculations")
(.build))]
(dotimes [i 1000]
(.start (Thread. (fn [] (.sendMessage sqs req)))))))
Once the queue has drained and the logs have finished populating, I'll use CloudWatch Log Insights to get the average:

So it looks like the average cold start time with minimal code and no extra dependencies (although the package size is still 4.5 MB!) is about 2.6567039 seconds across 85 cold starts. Unfortunately I was only able to trigger 85 cold starts across the 1000 invocations, but that should be good enough for now. I'll expand upon this baseline with different memory sizes and more realistic dependencies and calculation code in later posts.