Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
efd6ad0
Another implementation of onlyColdStarts
mriccia May 10, 2023
89a255b
Working implementation
mriccia May 11, 2023
711e37d
Addressing PR feedback
mriccia May 19, 2023
4720586
add permission required for `functionActiveV2`
mriccia May 24, 2023
f70677d
add comments and tidy up
mriccia May 24, 2023
0445f32
tidy up
mriccia May 24, 2023
4d8f554
Fix tests and add more tests
mriccia May 24, 2023
f6d7ad1
Modify the Terraform template to match the changes in the SAM template
mriccia May 24, 2023
32f9533
preserve env vars when optimising
mriccia May 24, 2023
c0dd5ff
remove redundant logic
mriccia May 24, 2023
dd14172
Merge branch 'master' into onlycoldstarts
alexcasalboni Mar 27, 2024
c0192ec
Merge remote-tracking branch 'origin/master' into onlycoldstarts
mriccia May 8, 2024
5a2e515
reintroduce changes that were removed in the merge
mriccia May 8, 2024
93802fd
PR feedback
mriccia May 8, 2024
aa85838
PR feedback
mriccia May 8, 2024
1656b7e
PR feedback
mriccia May 8, 2024
79bb3b8
fix tests
mriccia May 8, 2024
3c4b5d6
fix cleaner.js
mriccia May 9, 2024
69edf22
remove singleton for client to avoid breaking existing use cases
mriccia May 15, 2024
6b9c85d
change logic to use Description instead of Env Vars
mriccia May 15, 2024
331de58
PR Feedback
mriccia May 16, 2024
aad097c
PR Feedback
mriccia May 16, 2024
2a547a6
Fix unit tests and linting
mriccia May 16, 2024
4b58451
remove unnecessary permission
mriccia May 16, 2024
9a9b243
remove unnecessary permission
mriccia May 16, 2024
1979fb6
fix node version in the Terraform template
mriccia May 16, 2024
446fff2
remove error catch from initializer state
alexcasalboni May 20, 2024
e8b7d17
Uupdate analyzer and executor to include init stats and new state mac…
alexcasalboni May 20, 2024
71aa0cb
update tests and coverage
alexcasalboni May 20, 2024
40c221b
linting
alexcasalboni May 20, 2024
6f001da
Improve publisher coverage & linting
alexcasalboni May 20, 2024
940ecd8
Update documentation for onlyColdStarts
alexcasalboni May 20, 2024
000cd74
PR feedback
mriccia May 23, 2024
797f89c
Remove getAlias permission from the Initializer
mriccia May 23, 2024
355ea6f
PR Feedback
mriccia May 23, 2024
18a8a65
Modify logic for computing duration and cost
mriccia May 24, 2024
aba4d63
Remove tests for deleted code
mriccia May 24, 2024
103786c
Add few TODOs as reminders
mriccia May 24, 2024
38f5c96
Add tests for extractDuration
mriccia May 27, 2024
d837b53
Add more tests
mriccia May 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README-ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ There are three main costs associated with AWS Lambda Power Tuning:

The AWS Step Functions state machine is composed of five Lambda functions:

* **initializer**: create N versions and aliases corresponding to the power values provided as input (e.g. 128MB, 256MB, etc.)
* **executor**: execute the given Lambda function `num` times, extract execution time from logs, and compute average cost per invocation
* **cleaner**: delete all the previously generated aliases and versions
* **analyzer**: compute the optimal power value (current logic: lowest average cost per invocation)
* **optimizer**: automatically set the power to its optimal value (only if `autoOptimize` is `true`)

Initializer, cleaner, analyzer, and optimizer are executed only once, while the executor is used by N parallel branches of the state machine - one for each configured power value. By default, the executor will execute the given Lambda function `num` consecutive times, but you can enable parallel invocation by setting `parallelInvocation` to `true`.
* **Initializer**: define all the versions and aliases that need to be created (see Publisher below)
* **Publisher**: create a new version and aliases corresponding to one of the power values provided as input (e.g. 128MB, 256MB, etc.)
* **IsCountReached**: go back to Publisher until all the versiona and aliases have been created
* **Executor**: execute the given Lambda function `num` times, extract execution time from logs, and compute average cost per invocation
* **Cleaner**: delete all the previously generated aliases and versions
* **Analyzer**: compute the optimal power value (current logic: lowest average cost per invocation)
* **Optimizer**: automatically set the power to its optimal value (only if `autoOptimize` is `true`)

Initializer, Cleaner, Analyzer, and Optimizer are invoked only once, while the Publisher and Executor are invoked multiple times. Publisher is used in a loop to create all the required versions and aliases, which depend on the values of `num`, `powerValues`, and `onlyColdStarts`. Executor is used by N parallel branches - one for each configured power value. By default, the Executor will invike the given Lambda function `num` consecutive times, but you can enable parallel invocation by setting `parallelInvocation` to `true`.

## Weighted Payloads

Expand Down
79 changes: 40 additions & 39 deletions README.md

Large diffs are not rendered by default.

Binary file modified imgs/state-machine-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion lambda/analyzer.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@ const findOptimalConfiguration = (event) => {
const balancedWeight = getBalancedWeight(event);
const optimizationFunction = optimizationStrategies[strategy]();
const optimal = optimizationFunction(stats, balancedWeight);
const onlyColdStarts = event.onlyColdStarts;
const num = event.num;

// also compute total cost of optimization state machine & lambda
optimal.stateMachine = {};
optimal.stateMachine.executionCost = utils.stepFunctionsCost(event.stats.length);
optimal.stateMachine.executionCost = utils.stepFunctionsCost(event.stats.length, onlyColdStarts, num);
optimal.stateMachine.lambdaCost = stats
.map((p) => p.totalCost)
.reduce((a, b) => a + b, 0);
Expand Down
37 changes: 32 additions & 5 deletions lambda/cleaner.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ const utils = require('./utils');
*/
module.exports.handler = async(event, context) => {

const {lambdaARN, powerValues} = event;
const {
lambdaARN,
powerValues,
onlyColdStarts,
num,
} = extractDataFromInput(event);

validateInput(lambdaARN, powerValues); // may throw

const ops = powerValues.map(async(value) => {
const alias = 'RAM' + value;
await cleanup(lambdaARN, alias); // may throw
// build list of aliases to clean up
const aliases = buildAliasListForCleanup(lambdaARN, onlyColdStarts, powerValues, num);

const ops = aliases.map(async(alias) => {
await cleanup(lambdaARN, alias);
});

// run everything in parallel and wait until completed
Expand All @@ -23,12 +30,32 @@ module.exports.handler = async(event, context) => {
return 'OK';
};

const buildAliasListForCleanup = (lambdaARN, onlyColdStarts, powerValues, num) => {
if (onlyColdStarts){
return powerValues.map((powerValue) => {
return utils.range(num).map((index) => {
return utils.buildAliasString(`RAM${powerValue}`, onlyColdStarts, index);
});
}).flat();
}
return powerValues.map((powerValue) => utils.buildAliasString(`RAM${powerValue}`));
};

const extractDataFromInput = (event) => {
return {
lambdaARN: event.lambdaARN,
powerValues: event.lambdaConfigurations.powerValues,
onlyColdStarts: event.onlyColdStarts,
num: parseInt(event.num, 10), // parse as we do in the initializer
};
};

const validateInput = (lambdaARN, powerValues) => {
if (!lambdaARN) {
throw new Error('Missing or empty lambdaARN');
}
if (!powerValues || !powerValues.length) {
throw new Error('Missing or empty power values');
throw new Error('Missing or empty powerValues values');
}
};

Expand Down
54 changes: 39 additions & 15 deletions lambda/executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module.exports.handler = async(event, context) => {
preProcessorARN,
postProcessorARN,
discardTopBottom,
onlyColdStarts,
sleepBetweenRunsMs,
disablePayloadLogs,
} = await extractDataFromInput(event);
Expand All @@ -35,8 +36,11 @@ module.exports.handler = async(event, context) => {
const lambdaAlias = 'RAM' + value;
let results;

// fetch architecture from $LATEST
const {architecture, isPending} = await utils.getLambdaConfig(lambdaARN, lambdaAlias);
// defaulting the index to 0 as the index is required for onlyColdStarts
let aliasToInvoke = utils.buildAliasString(lambdaAlias, onlyColdStarts, 0);
// We need the architecture, regardless of onlyColdStarts or not
const {architecture, isPending} = await utils.getLambdaConfig(lambdaARN, aliasToInvoke);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at what the Initializer is doing (very similarly), I would consider merging utils.getLambdaPower and utils.getLambdaConfig since they are both using GetFunctionConfigurationCommand and they're simply retrieving different fields from the result.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking for this PR - just a reminder for myself in the future :)


console.log(`Detected architecture type: ${architecture}, isPending: ${isPending}`);

// pre-generate an array of N payloads
Expand All @@ -49,12 +53,14 @@ module.exports.handler = async(event, context) => {
payloads: payloads,
preARN: preProcessorARN,
postARN: postProcessorARN,
onlyColdStarts: onlyColdStarts,
sleepBetweenRunsMs: sleepBetweenRunsMs,
disablePayloadLogs: disablePayloadLogs,
};

// wait if the function/alias state is Pending
if (isPending) {
// in the case of onlyColdStarts, we will verify each alias in the runInParallel or runInSeries
if (isPending && !onlyColdStarts) {
await utils.waitForAliasActive(lambdaARN, lambdaAlias);
console.log('Alias active');
}
Expand Down Expand Up @@ -97,8 +103,14 @@ const extractDiscardTopBottomValue = (event) => {
// extract discardTopBottom used to trim values from average duration
let discardTopBottom = event.discardTopBottom;
if (typeof discardTopBottom === 'undefined') {
// default value for discardTopBottom
discardTopBottom = 0.2;
}
// In case of onlyColdStarts, we only have 1 invocation per alias, therefore we shouldn't discard any execution
if (event.onlyColdStarts){
discardTopBottom = 0;
console.log('Setting discardTopBottom to 0, every invocation should be accounted when onlyColdStarts');
}
// discardTopBottom must be between 0 and 0.4
return Math.min(Math.max(discardTopBottom, 0.0), 0.4);
};
Expand Down Expand Up @@ -128,16 +140,22 @@ const extractDataFromInput = async(event) => {
preProcessorARN: input.preProcessorARN,
postProcessorARN: input.postProcessorARN,
discardTopBottom: discardTopBottom,
onlyColdStarts: !!input.onlyColdStarts,
sleepBetweenRunsMs: sleepBetweenRunsMs,
disablePayloadLogs: !!input.disablePayloadLogs,
};
};

const runInParallel = async({num, lambdaARN, lambdaAlias, payloads, preARN, postARN, disablePayloadLogs}) => {
const runInParallel = async({num, lambdaARN, lambdaAlias, payloads, preARN, postARN, disablePayloadLogs, onlyColdStarts}) => {
const results = [];
// run all invocations in parallel ...
const invocations = utils.range(num).map(async(_, i) => {
const {invocationResults, actualPayload} = await utils.invokeLambdaWithProcessors(lambdaARN, lambdaAlias, payloads[i], preARN, postARN, disablePayloadLogs);
let aliasToInvoke = utils.buildAliasString(lambdaAlias, onlyColdStarts, i);
if (onlyColdStarts){
await utils.waitForAliasActive(lambdaARN, aliasToInvoke);
console.log(`${aliasToInvoke} is active`);
}
const {invocationResults, actualPayload} = await utils.invokeLambdaWithProcessors(lambdaARN, aliasToInvoke, payloads[i], preARN, postARN, disablePayloadLogs);
// invocation errors return 200 and contain FunctionError and Payload
if (invocationResults.FunctionError) {
let errorMessage = 'Invocation error (running in parallel)';
Expand All @@ -150,11 +168,16 @@ const runInParallel = async({num, lambdaARN, lambdaAlias, payloads, preARN, post
return results;
};

const runInSeries = async({num, lambdaARN, lambdaAlias, payloads, preARN, postARN, sleepBetweenRunsMs, disablePayloadLogs}) => {
const runInSeries = async({num, lambdaARN, lambdaAlias, payloads, preARN, postARN, sleepBetweenRunsMs, disablePayloadLogs, onlyColdStarts}) => {
const results = [];
for (let i = 0; i < num; i++) {
let aliasToInvoke = utils.buildAliasString(lambdaAlias, onlyColdStarts, i);
// run invocations in series
const {invocationResults, actualPayload} = await utils.invokeLambdaWithProcessors(lambdaARN, lambdaAlias, payloads[i], preARN, postARN, disablePayloadLogs);
if (onlyColdStarts){
await utils.waitForAliasActive(lambdaARN, aliasToInvoke);
console.log(`${aliasToInvoke} is active`);
}
const {invocationResults, actualPayload} = await utils.invokeLambdaWithProcessors(lambdaARN, aliasToInvoke, payloads[i], preARN, postARN, disablePayloadLogs);
// invocation errors return 200 and contain FunctionError and Payload
if (invocationResults.FunctionError) {
let errorMessage = 'Invocation error (running in series)';
Expand All @@ -169,18 +192,19 @@ const runInSeries = async({num, lambdaARN, lambdaAlias, payloads, preARN, postAR
};

const computeStatistics = (baseCost, results, value, discardTopBottom) => {
// use results (which include logs) to compute average duration ...

const durations = utils.parseLogAndExtractDurations(results);

const averageDuration = utils.computeAverageDuration(durations, discardTopBottom);
// use results (which include logs) to compute average duration ...
const totalDurations = utils.parseLogAndExtractDurations(results);
const averageDuration = utils.computeAverageDuration(totalDurations, discardTopBottom);
console.log('Average duration: ', averageDuration);

// ... and overall statistics
const averagePrice = utils.computePrice(baseCost, minRAM, value, averageDuration);

// ... and overall cost statistics
const billedDurations = utils.parseLogAndExtractBilledDurations(results);
const averageBilledDuration = utils.computeAverageDuration(billedDurations, discardTopBottom);
console.log('Average Billed duration: ', averageBilledDuration);
const averagePrice = utils.computePrice(baseCost, minRAM, value, averageBilledDuration);
// .. and total cost (exact $)
const totalCost = utils.computeTotalCost(baseCost, minRAM, value, durations);
const totalCost = utils.computeTotalCost(baseCost, minRAM, value, billedDurations);

const stats = {
averagePrice,
Expand Down
50 changes: 42 additions & 8 deletions lambda/initializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,58 @@ const defaultPowerValues = process.env.defaultPowerValues.split(',');
*/
module.exports.handler = async(event, context) => {

const {lambdaARN, num} = event;
const powerValues = extractPowerValues(event);
const {
lambdaARN,
num,
powerValues,
onlyColdStarts,
} = extractDataFromInput(event);

validateInput(lambdaARN, num); // may throw

// fetch initial $LATEST value so we can reset it later
const initialPower = await utils.getLambdaPower(lambdaARN);
const {power, description} = await utils.getLambdaPower(lambdaARN);
console.log(power, description);

let initConfigurations = [];

// reminder: configuration updates must run sequentially
// (otherwise you get a ResourceConflictException)
for (let value of powerValues){
const alias = 'RAM' + value;
await utils.createPowerConfiguration(lambdaARN, value, alias);
for (let powerValue of powerValues){
const baseAlias = 'RAM' + powerValue;
if (!onlyColdStarts){
initConfigurations.push({powerValue: powerValue, alias: baseAlias});
} else {
for (let n of utils.range(num)){
let alias = utils.buildAliasString(baseAlias, onlyColdStarts, n);
// here we inject a custom description to force the creation of a new version
// even if the power is the same, which will force a cold start
initConfigurations.push({powerValue: powerValue, alias: alias, description: `${description} - ${alias}`});
}
}
}
// Publish another version to revert the Lambda Function to its original configuration
initConfigurations.push({powerValue: power, description: description});

return {
initConfigurations: initConfigurations,
iterator: {
index: 0,
count: initConfigurations.length,
continue: true,
},
powerValues: powerValues,
};
};

await utils.setLambdaPower(lambdaARN, initialPower);

return powerValues;
const extractDataFromInput = (event) => {
return {
lambdaARN: event.lambdaARN,
num: parseInt(event.num, 10),
powerValues: extractPowerValues(event),
onlyColdStarts: !!event.onlyColdStarts,
};
};

const extractPowerValues = (event) => {
Expand Down
48 changes: 48 additions & 0 deletions lambda/publisher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict';

const utils = require('./utils');


module.exports.handler = async(event, context) => {
const {lambdaConfigurations, currConfig, lambdaARN} = validateInputs(event);
const currentIterator = lambdaConfigurations.iterator;
// publish version & assign alias (if present)
await utils.createPowerConfiguration(lambdaARN, currConfig.powerValue, currConfig.alias, currConfig.description);

const result = {
powerValues: lambdaConfigurations.powerValues,
initConfigurations: lambdaConfigurations.initConfigurations,
iterator: {
index: (currentIterator.index + 1),
count: currentIterator.count,
continue: ((currentIterator.index + 1) < currentIterator.count),
},
};

if (!result.iterator.continue) {
// clean the list of configuration if we're done iterating
delete result.initConfigurations;
}

return result;
};
function validateInputs(event) {
if (!event.lambdaARN) {
throw new Error('Missing or empty lambdaARN');
}
const lambdaARN = event.lambdaARN;
if (!(event.lambdaConfigurations && event.lambdaConfigurations.iterator && event.lambdaConfigurations.initConfigurations)){
throw new Error('Invalid iterator for initialization');
}
const iterator = event.lambdaConfigurations.iterator;
if (!(iterator.index >= 0 && iterator.index < iterator.count)){
throw new Error(`Invalid iterator index: ${iterator.index}`);
}
const lambdaConfigurations = event.lambdaConfigurations;
const currIdx = iterator.index;
const currConfig = lambdaConfigurations.initConfigurations[currIdx];
if (!(currConfig && currConfig.powerValue)){
throw new Error(`Invalid init configuration: ${JSON.stringify(currConfig)}`);
}
return {lambdaConfigurations, currConfig, lambdaARN};
}
Loading