Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bfb00d6
Add ecologits static data for AWS Bedrock models
dpol1 Mar 4, 2026
e10aa86
Add core EcoLogits coefficient loader and cache
dpol1 Mar 5, 2026
3339f3b
Add new inner class to handle the coefficients
dpol1 Mar 5, 2026
67276b7
Rename core class to EcoLogits based on feedback
dpol1 Mar 6, 2026
7bd6662
Merge branch 'main' into feature/bedrock-ai-estimates
jnioche Mar 6, 2026
8b02d13
Add BedrockEcoLogits module skeleton
dpol1 Mar 6, 2026
1c0f7a0
Merge branch 'main' into feature/bedrock-ai-estimates
dpol1 Mar 6, 2026
b02e773
Implement enrichment logic for BedrockEcoLogits
dpol1 Mar 7, 2026
e41ae8d
Add package-info for ecologits module
dpol1 Mar 7, 2026
d8bf3c1
Refine Bedrock energy estimation with usage type detection
dpol1 Mar 9, 2026
e2a7509
Add unit tests for BedrockEcoLogits
dpol1 Mar 9, 2026
60392bb
Add unit tests for EcoLogits
dpol1 Mar 9, 2026
568bf42
Add BedrockEcoLogits module to default-config.json pipeline
dpol1 Mar 9, 2026
3b767f6
Add commercial model names to Bedrock energy estimates
dpol1 Mar 9, 2026
d524102
docs: add EcoLogits module documentation
dpol1 Mar 9, 2026
3ec1a14
Merge branch 'main' into feature/bedrock-ai-estimates
dpol1 Mar 9, 2026
5c1dda4
docs: refine module documentation
dpol1 Mar 10, 2026
3c6d13e
Refine BedrockEcoLogits logic and improve logging
dpol1 Mar 11, 2026
6f405df
Update Bedrock energy and emission coefficients
dpol1 Mar 11, 2026
de90118
Simplify product map extraction in BedrockEcoLogits
dpol1 Mar 14, 2026
d7db7b6
Add model aliases support and remove duplicate coefficients
dpol1 Mar 16, 2026
741a68e
Simplify column value extraction in BedrockEcoLogitsTest
dpol1 Mar 16, 2026
18f1ce6
docs: add note on static batch size assumption
dpol1 Mar 17, 2026
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
21 changes: 17 additions & 4 deletions docs/src/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ See [Configure the modules](howto/config_modules.md) for instructions on how to

## Cloud Carbon Footprint

The following modules implement the heuristics from the [Cloud Carbon Footprint](https://www.cloudcarbonfootprint.org/) project.
The following modules implement the heuristics from the [Cloud Carbon Footprint](https://www.cloudcarbonfootprint.org/) project.

### ccf.Storage

Expand Down Expand Up @@ -46,9 +46,9 @@ The following modules make use of the [BoaviztAPI](https://doc.api.boavizta.org)

Provides an estimate of [final energy](https://www.eea.europa.eu/en/analysis/indicators/primary-and-final-energy-consumption) used for computation (EC2, OpenSearch, RDS) as well as the related embodied emissions using the [BoaviztAPI](https://doc.api.boavizta.org/).

**Output columns**: `operational_energy_kwh`, `embodied_emissions_co2eq_g` and `embodied_adp_sbeq_g`.
**Output columns**: `operational_energy_kwh`, `embodied_emissions_co2eq_g` and `embodied_adp_sbeq_g`.

From https://doc.api.boavizta.org/Explanations/impacts/
From https://doc.api.boavizta.org/Explanations/impacts/

**Abiotic Depletion Potential (ADP)** is an environmental impact indicator. This category corresponds to mineral and resources used and is, in this sense, mainly influenced by the rate of resources extracted. The effect of this consumption on their depletion is estimated according to their availability stock at a global scale. This impact category is divided into two components: a material component and a fossil fuels component (we use a version of ADP which include both).
This impact is expressed in grams of antimony equivalent (gSbeq).
Expand All @@ -61,6 +61,20 @@ Similar to the previous module but does not get the information from an instance

**Output columns**: `operational_energy_kwh`, `embodied_emissions_co2eq_g` and `embodied_adp_sbeq_g`.

## EcoLogits

The following modules estimate the energy consumption and embodied emissions of LLM inference using static coefficients derived from the [EcoLogits](https://ecologits.ai/) project.

### ecologits.BedrockEcoLogits

Provides an estimate of energy consumption and embodied emissions for LLM inference on **AWS Bedrock**, based on static per-model coefficients from the EcoLogits project. This follows the same pattern as `boavizta.BoaviztAPIstatic`: a static data file bundled in the JAR is loaded at initialisation time, and the module matches Bedrock CUR rows to per-model coefficients to compute energy usage and embodied emissions.

The module reads the model identifier from the `product` map in the CUR row and normalises the token count from `pricing_unit` (handling real-world values such as `1K tokens` or `1M tokens`). It uses the `line_item_usage_type` field to distinguish between input and output tokens, falling back to a ratio split when the usage type is ambiguous.

**Output columns**: `operational_energy_kwh` and `embodied_emissions_co2eq_g`.

> **Batch size assumption:** EcoLogits hardcodes a batch size of `B=64` concurrent requests. The resulting coefficients are a mid-batch estimate — they underestimate energy for low-traffic scenarios and overestimate it for high-throughput batch inference (e.g. Bedrock Batch mode). Making `B` dynamic requires provider telemetry not available in billing data.

## electricitymaps.AverageCarbonIntensity

Adds average carbon intensity factors generated from [ElectricityMaps](https://www.electricitymaps.com/)' 2024 datasets.
Expand Down Expand Up @@ -113,4 +127,3 @@ These two values can be overridden via configuration (`powerSupplyEfficiency` an

**Output columns**: `operational_emissions_co2eq_g`.


Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: Apache-2.0

package com.digitalpebble.spruce.modules.ecologits;

import com.digitalpebble.spruce.Column;
import com.digitalpebble.spruce.EnrichmentModule;
import org.apache.spark.sql.Row;

import java.util.Map;

import static com.digitalpebble.spruce.CURColumn.*;
import static com.digitalpebble.spruce.SpruceColumn.*;

/**
* Enrichment module estimating energy consumption and embodied emissions
* for LLM inference on AWS Bedrock.
* <p>
* It extracts the model name from the CUR {@code product} map, determines token
* types (input/output) via {@code line_item_usage_type}, and retrieves coefficients
* using {@link EcoLogits}. If the usage type is ambiguous, it falls back to
* a configurable {@code input_token_ratio} (default 0.5).
* <p>
* Usage amounts are normalized to individual tokens based on the {@code pricing_unit}
* before applying the per-1K-token coefficients.
*/
public class BedrockEcoLogits implements EnrichmentModule {

private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(BedrockEcoLogits.class);

private EcoLogits impacts;

private double inputTokenRatio = 0.5;

@Override
public void init(Map<String, Object> params) {
impacts = new EcoLogits();
impacts.load();

if (params != null) {
Number ratio = (Number) params.get("input_token_ratio");
if (ratio != null) {
inputTokenRatio = ratio.doubleValue();
}
}
}

@Override
public Column[] columnsNeeded() {
return new Column[]{LINE_ITEM_PRODUCT_CODE, PRODUCT, USAGE_AMOUNT, PRICING_UNIT, LINE_ITEM_USAGE_TYPE};
}

@Override
public Column[] columnsAdded() {
return new Column[]{ENERGY_USED, EMBODIED_EMISSIONS};
}

@Override
public void enrich(Row row, Map<Column, Object> enrichedValues) {
String productCode = LINE_ITEM_PRODUCT_CODE.getString(row);
if (!"AmazonBedrock".equals(productCode)) {
return;
}

int productIndex = PRODUCT.resolveIndex(row);
Map<Object, Object> productMap = row.getJavaMap(productIndex);
if (productMap == null) {
return;
}

String modelId = (String) productMap.get("model");
if (modelId == null || modelId.isEmpty()) {
LOG.warn("BedrockEcoLogits: model key missing or empty in product map");
return;
Comment thread
dpol1 marked this conversation as resolved.
}

EcoLogits.ModelImpacts modelImpacts = impacts.getImpacts(modelId);
if (modelImpacts == null) {
LOG.warn("BedrockEcoLogits: no EcoLogits coefficients found for model '{}'", modelId);
return;
Comment thread
dpol1 marked this conversation as resolved.
}

if (USAGE_AMOUNT.isNullAt(row)) {
return;
}
double usageAmount = USAGE_AMOUNT.getDouble(row);
if (usageAmount <= 0) {
return;
}

double tokenMultiplier = parseTokenMultiplier(PRICING_UNIT.getString(row));
double totalTokens = usageAmount * tokenMultiplier;

// Check Input vs Output based on line_item_usage_type
boolean isInput = false;
boolean isOutput = false;

String usageType = LINE_ITEM_USAGE_TYPE.getString(row);
if (usageType != null) {
String lower = usageType.toLowerCase();
isInput = lower.contains("input");
isOutput = lower.contains("output");
}

double energyKwh;
if (isInput && !isOutput) {
energyKwh = (totalTokens / 1_000.0) * modelImpacts.getEnergyKwhPer1kInputTokens();
} else if (isOutput && !isInput) {
energyKwh = (totalTokens / 1_000.0) * modelImpacts.getEnergyKwhPer1kOutputTokens();
} else {
// Fallback if CUR does not specify, or if the string is ambiguous (contains both)
double inputTokens = totalTokens * inputTokenRatio;
double outputTokens = totalTokens * (1.0 - inputTokenRatio);
energyKwh = (inputTokens / 1_000.0) * modelImpacts.getEnergyKwhPer1kInputTokens()
+ (outputTokens / 1_000.0) * modelImpacts.getEnergyKwhPer1kOutputTokens();
}

double embodiedEmissions = (totalTokens / 1_000.0) * modelImpacts.getEmbodiedCo2eGPer1kTokens();

enrichedValues.put(ENERGY_USED, energyKwh);
enrichedValues.put(EMBODIED_EMISSIONS, embodiedEmissions);

LOG.debug("Bedrock model={} tokens={} energy_kwh={} embodied_g={}", modelId, totalTokens, energyKwh, embodiedEmissions);
}

/**
* Parses the CUR {@code pricing_unit} to determine how many individual tokens
* one usage-amount unit represents. (e.g. "1M tokens" → 1_000_000)
*/
static double parseTokenMultiplier(String pricingUnit) {
if (pricingUnit == null || pricingUnit.isBlank()) {
return 1.0;
}
String lower = pricingUnit.trim().toLowerCase();
if (!lower.contains("token")) {
return 1.0;
}
if (lower.startsWith("1m")) {
return 1_000_000.0;
}
if (lower.startsWith("1k")) {
return 1_000.0;
}
return 1.0;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: Apache-2.0

package com.digitalpebble.spruce.modules.ecologits;

import com.digitalpebble.spruce.Utils;

import java.io.IOException;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

/**
* Cloud-agnostic lookup for LLM inference energy and embodied emissions coefficients.
* <p>
* Loads a static JSON file (extracted from EcoLogits data) that maps model IDs to
* energy-per-1K-token and embodied-emissions-per-1K-token values.
*/
public class EcoLogits implements Serializable {

private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(EcoLogits.class);

private static final String DEFAULT_RESOURCE = "ecologits/bedrock.json";

private final Map<String, ModelImpacts> modelsMap = new HashMap<>();
private final Set<String> unknownModels = ConcurrentHashMap.newKeySet();

/**
* Loads the model coefficients from the bundled JSON resource into the memory map.
* This should typically be called once during the initialization phase.
*
* @throws RuntimeException if the resource file cannot be read, parsed, or found.
*/
@SuppressWarnings("unchecked")
public void load() {
try {
Map<String, Object> raw = Utils.loadJSONResources(DEFAULT_RESOURCE);
for (Map.Entry<String, Object> entry : raw.entrySet()) {
Map<String, Object> values = (Map<String, Object>) entry.getValue();
double inputEnergy = ((Number) values.get("energy_kwh_per_1k_input_tokens")).doubleValue();
double outputEnergy = ((Number) values.get("energy_kwh_per_1k_output_tokens")).doubleValue();
double embodied = ((Number) values.get("embodied_co2e_g_per_1k_tokens")).doubleValue();
ModelImpacts impacts = new ModelImpacts(inputEnergy, outputEnergy, embodied);
modelsMap.put(entry.getKey(), impacts);

// register aliases pointing to the same ModelImpacts instance
if (values.containsKey("aliases")) {
for (String alias : (java.util.List<String>) values.get("aliases")) {
modelsMap.put(alias, impacts);
}
}
}
LOG.info("Loaded {} LLM model coefficients from {}", modelsMap.size(), DEFAULT_RESOURCE);
} catch (IOException e) {
throw new RuntimeException("Failed to load LLM model coefficients from " + DEFAULT_RESOURCE, e);
}
}


// Returns the environmental impacts for a given model ID.
public ModelImpacts getImpacts(String modelId) {
ModelImpacts impacts = modelsMap.get(modelId);
if (impacts == null && unknownModels.add(modelId)) {
LOG.warn("Unknown LLM model: {}", modelId);
}
return impacts;
}

/**
* Holds the environmental impact coefficients for a specific LLM model.
* The values are normalized to a block of 1,000 tokens, which is the standard
* billing unit for cloud inference services like AWS Bedrock.
*/
public static class ModelImpacts {
private final double energyKwhPer1kInputTokens;
private final double energyKwhPer1kOutputTokens;
private final double embodiedCo2eGPer1kTokens;

public ModelImpacts(double energyKwhPer1kInputTokens, double energyKwhPer1kOutputTokens, double embodiedCo2eGPer1kTokens) {
this.energyKwhPer1kInputTokens = energyKwhPer1kInputTokens;
this.energyKwhPer1kOutputTokens = energyKwhPer1kOutputTokens;
this.embodiedCo2eGPer1kTokens = embodiedCo2eGPer1kTokens;
}

public double getEnergyKwhPer1kInputTokens() {
return energyKwhPer1kInputTokens;
}

public double getEnergyKwhPer1kOutputTokens() {
return energyKwhPer1kOutputTokens;
}

public double getEmbodiedCo2eGPer1kTokens() {
return embodiedCo2eGPer1kTokens;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* This package provides enrichment modules for estimating the environmental
* impact of LLM inference, using data derived from EcoLogits.
*
* @see <a href="https://ecologits.ai/">EcoLogits</a>
*/
package com.digitalpebble.spruce.modules.ecologits;
6 changes: 6 additions & 0 deletions src/main/resources/default-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
"gpu_utilisation_percent": 50
}
},
{
"className": "com.digitalpebble.spruce.modules.ecologits.BedrockEcoLogits",
"config": {
"input_token_ratio": 0.5
}
},
{
"className": "com.digitalpebble.spruce.modules.PUE",
"config": {
Expand Down
68 changes: 68 additions & 0 deletions src/main/resources/ecologits/bedrock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"anthropic.claude-v2": {
Comment thread
dpol1 marked this conversation as resolved.
"aliases": ["Claude 2", "anthropic.claude-v2:1", "Claude 2.1"],
"energy_kwh_per_1k_input_tokens": 0.00017942,
"energy_kwh_per_1k_output_tokens": 0.00035885,
"embodied_co2e_g_per_1k_tokens": 0.00013821
},
"anthropic.claude-3-haiku-20240307-v1:0": {
"aliases": ["Claude 3 Haiku"],
"energy_kwh_per_1k_input_tokens": 0.00067550,
"energy_kwh_per_1k_output_tokens": 0.00135099,
"embodied_co2e_g_per_1k_tokens": 0.00052049
},
"anthropic.claude-3-sonnet-20240229-v1:0": {
"aliases": ["Claude 3 Sonnet"],
"energy_kwh_per_1k_input_tokens": 0.00081931,
"energy_kwh_per_1k_output_tokens": 0.00163861,
"embodied_co2e_g_per_1k_tokens": 0.00063081
},
"anthropic.claude-3-opus-20240229-v1:0": {
"aliases": ["Claude 3 Opus"],
"energy_kwh_per_1k_input_tokens": 0.00968710,
"energy_kwh_per_1k_output_tokens": 0.01937419,
"embodied_co2e_g_per_1k_tokens": 0.00744024
},
"amazon.titan-text-express-v1": {
"aliases": ["Titan Text G1 - Express"],
"energy_kwh_per_1k_input_tokens": 0.00002526,
"energy_kwh_per_1k_output_tokens": 0.00005051,
"embodied_co2e_g_per_1k_tokens": 0.00001952
},
"meta.llama3-8b-instruct-v1:0": {
"aliases": ["Llama 3 8B Instruct"],
"energy_kwh_per_1k_input_tokens": 0.00002551,
"energy_kwh_per_1k_output_tokens": 0.00005101,
"embodied_co2e_g_per_1k_tokens": 0.00001971
},
"meta.llama3-70b-instruct-v1:0": {
"aliases": ["Llama 3 70B Instruct"],
"energy_kwh_per_1k_input_tokens": 0.00018142,
"energy_kwh_per_1k_output_tokens": 0.00036283,
"embodied_co2e_g_per_1k_tokens": 0.00013974
},
"mistral.mistral-7b-instruct-v0:2": {
"aliases": ["Mistral 7B Instruct"],
"energy_kwh_per_1k_input_tokens": 0.00002526,
"energy_kwh_per_1k_output_tokens": 0.00005051,
"embodied_co2e_g_per_1k_tokens": 0.00001952
},
"mistral.mixtral-8x7b-instruct-v0:1": {
"aliases": ["Mixtral 8x7B Instruct"],
"energy_kwh_per_1k_input_tokens": 0.00005411,
"energy_kwh_per_1k_output_tokens": 0.00010821,
"embodied_co2e_g_per_1k_tokens": 0.00004179
},
"cohere.command-r-v1:0": {
"aliases": ["Command R"],
"energy_kwh_per_1k_input_tokens": 0.00006812,
"energy_kwh_per_1k_output_tokens": 0.00013625,
"embodied_co2e_g_per_1k_tokens": 0.00005255
},
"ai21.j2-ultra-v1": {
"aliases": ["Jurassic-2 Ultra"],
"energy_kwh_per_1k_input_tokens": 0.00018142,
"energy_kwh_per_1k_output_tokens": 0.00036283,
"embodied_co2e_g_per_1k_tokens": 0.00013974
}
}
Loading
Loading