Mapping Provider SKUs to Canonical Compute Units
AWS, Azure, and GCP each name managed-database compute with an incompatible SKU grammar — db.r6g.xlarge, GP_Gen5_4, db-custom-4-16384 — and this page builds the deterministic classifier that collapses all three into one canonical compute unit of normalized vCPU and memory.
Back to: Multi-Cloud Cost Normalization
Any cross-provider cost comparison is meaningless until an Aurora db.r6g.xlarge, an Azure SQL GP_Gen5_4, and a Cloud SQL db-custom-4-16384 can be measured on the same axis. A per-hour price alone tells you nothing — one SKU might pack twice the memory per vCPU of another — so the unit you actually compare on is capacity, not a provider string. The classifier here parses each provider’s instance-family grammar, resolves it against a lookup table to a (vCPU, memory_GB) pair, and folds that pair into a single canonical compute unit. It is the resolution step that sits directly downstream of schema validation for billing data: validation guarantees a record’s SKU field is well-typed, and this mapping guarantees that field resolves to comparable capacity before any multi-cloud normalization rollup runs.
We reduce two dimensions to one with a reference memory ratio. Given a SKU’s vCPU count and memory in GB, and a reference density $\rho_{\text{ref}}$ (GB of memory per vCPU on the baseline family), the canonical compute unit is:
With $\rho_{\text{ref}} = 4$ GB/vCPU, a general-purpose 4-vCPU/16-GB instance scores exactly 4 + 16/4 = 8 CCU, while a memory-optimized 4-vCPU/32-GB instance scores 4 + 32/4 = 12 CCU — the extra memory it bills for is now visible in the unit itself.
Prerequisites
The mapping runs entirely offline against a static lookup table; no cloud credentials are required to classify a SKU, only to source the SKU strings from billing exports in the first place.
Read access to the billing exports that carry the SKU field. On AWS that is the Cost and Usage Report
product/instanceType/lineItem/UsageType; on Azure the Cost ManagementmeterName/serviceTier; on GCP the BigQuery billing exportsku.description. Sourcing those follows the least-privilege posture in security and access control for cost data — the classifier itself needs no live API.Python: 3.10 or newer (the code uses
matchstatements and modern typing).Libraries: pandas for the batch join, plus the standard-library
decimalandre.pip install "pandas>=2.1"
Step-by-Step Implementation
The design keeps three concerns separate: a static lookup table of known capacities, a set of pure per-provider parsers that turn a raw SKU into a (vCPU, memory_GB) pair, and a dispatcher that routes by provider and quarantines anything it cannot resolve. Everything downstream of the parsers is Decimal, never float, because a fractional CCU feeds directly into per-unit cost math.
Step 1 — Define the canonical unit and the capacity lookup
The lookup table encodes the two facts a SKU string cannot always self-describe: how many GB a memory-optimized family packs per vCPU, and the vCPU count implied by each named size. AWS sizes follow a strict doubling grammar (large = 2 vCPU, xlarge = 4, Nxlarge = N×4), so we compute those rather than enumerate them.
from decimal import Decimal, getcontext
getcontext().prec = 28 # ample headroom for capacity math
REF_DENSITY_GB_PER_VCPU = Decimal("4") # rho_ref: baseline general-purpose density
# Memory-per-vCPU density by AWS RDS family prefix.
AWS_FAMILY_DENSITY: dict[str, Decimal] = {
"t3": Decimal("4"), "t4g": Decimal("4"), # burstable, general purpose
"m5": Decimal("4"), "m6g": Decimal("4"), "m6i": Decimal("4"),
"r5": Decimal("8"), "r6g": Decimal("8"), "r6i": Decimal("8"), # memory optimized
"x2g": Decimal("16"), # extra memory optimized
}
# vCPU per AWS named size that is not part of the N-xlarge doubling grammar.
AWS_BASE_SIZE_VCPU: dict[str, Decimal] = {
"medium": Decimal("1"), "large": Decimal("2"), "xlarge": Decimal("4"),
}
# Azure vCore -> GB density for the General Purpose Gen5 tier.
AZURE_TIER_DENSITY: dict[str, Decimal] = {
"GP_Gen5": Decimal("5.1"), # General Purpose, Gen5 hardware
"BC_Gen5": Decimal("5.1"), # Business Critical, Gen5 hardware
"HS_Gen5": Decimal("5.1"), # Hyperscale, Gen5 hardware
}
def canonical_compute_unit(vcpu: Decimal, mem_gb: Decimal) -> Decimal:
"""Collapse (vCPU, memory) into a single canonical compute unit."""
return vcpu + (mem_gb / REF_DENSITY_GB_PER_VCPU)
# Expected:
# canonical_compute_unit(Decimal("4"), Decimal("16")) -> Decimal("8")
# canonical_compute_unit(Decimal("4"), Decimal("32")) -> Decimal("12")
Step 2 — Parse each provider’s instance-family grammar
Each parser is pure and returns either a (vCPU, memory_GB) tuple or None, so an unresolvable string is a value the caller routes, not an exception that unwinds a batch. The three grammars are genuinely different: AWS is db.<family><gen>.<size>, Azure is <tier>_<hardware>_<vcores>, and GCP db-custom-<vcpu>-<mem_mib> states both numbers outright.
import re
from typing import Optional
AWS_RE = re.compile(r"^db\.(?P<family>[a-z0-9]+?)\.(?P<size>\d*x?large|medium)$")
AZURE_RE = re.compile(r"^(?P<tier>[A-Z]{2}_Gen\d)_(?P<vcores>\d+)$")
GCP_CUSTOM_RE = re.compile(r"^db-custom-(?P<vcpu>\d+)-(?P<mem_mib>\d+)$")
Capacity = tuple[Decimal, Decimal] # (vCPU, memory_GB)
def _aws_size_to_vcpu(size: str) -> Optional[Decimal]:
if size in AWS_BASE_SIZE_VCPU:
return AWS_BASE_SIZE_VCPU[size]
m = re.fullmatch(r"(\d+)xlarge", size) # 2xlarge, 4xlarge, 24xlarge...
return Decimal(m.group(1)) * Decimal("4") if m else None
def parse_aws(sku: str) -> Optional[Capacity]:
"""db.r6g.xlarge -> (4 vCPU, 32 GB)."""
m = AWS_RE.match(sku)
if not m:
return None
density = AWS_FAMILY_DENSITY.get(m.group("family"))
vcpu = _aws_size_to_vcpu(m.group("size"))
if density is None or vcpu is None:
return None # known grammar, unknown family/size
return vcpu, vcpu * density
def parse_azure(sku: str) -> Optional[Capacity]:
"""GP_Gen5_4 -> (4 vCore, 20.4 GB)."""
m = AZURE_RE.match(sku)
if not m:
return None
density = AZURE_TIER_DENSITY.get(m.group("tier"))
if density is None:
return None
vcpu = Decimal(m.group("vcores"))
return vcpu, vcpu * density
def parse_gcp(sku: str) -> Optional[Capacity]:
"""db-custom-4-16384 -> (4 vCPU, 16 GB). Memory is stated in MiB."""
m = GCP_CUSTOM_RE.match(sku)
if not m:
return None
vcpu = Decimal(m.group("vcpu"))
mem_gb = Decimal(m.group("mem_mib")) / Decimal("1024")
return vcpu, mem_gb
# Expected:
# parse_aws("db.r6g.xlarge") -> (Decimal('4'), Decimal('32'))
# parse_azure("GP_Gen5_4") -> (Decimal('4'), Decimal('20.4'))
# parse_gcp("db-custom-4-16384") -> (Decimal('4'), Decimal('16'))
# parse_aws("db.z9z.nano") -> None (unknown family)
Step 3 — Dispatch, classify, and quarantine unknowns
The dispatcher is the single entry point. It selects a parser by provider, and on a None result it does not guess: the record is stamped with a quarantine reason and diverted, exactly the dead-letter discipline used across the normalization pipeline. A silently mis-mapped SKU corrupts every downstream comparison, so an unresolved string must fail loudly rather than resolve to zero.
The flow below shows how one raw SKU string is routed to a provider parser, resolved against the lookup table, and either scored to a CCU or quarantined.
from dataclasses import dataclass
PARSERS = {"aws": parse_aws, "azure": parse_azure, "gcp": parse_gcp}
@dataclass(frozen=True)
class Resolved:
provider: str
sku: str
vcpu: Decimal
memory_gb: Decimal
ccu: Decimal
def resolve_sku(provider: str, sku: str) -> tuple[Optional[Resolved], Optional[dict]]:
"""Return (Resolved, None) on success or (None, quarantine_record) on failure."""
parser = PARSERS.get(provider)
if parser is None:
return None, {"provider": provider, "sku": sku, "reason": "unknown_provider"}
cap = parser(sku)
if cap is None:
return None, {"provider": provider, "sku": sku, "reason": "unparsed_sku"}
vcpu, mem_gb = cap
ccu = canonical_compute_unit(vcpu, mem_gb)
return Resolved(provider, sku, vcpu, mem_gb, ccu), None
Step 4 — Apply the mapping across a billing frame with pandas
In production the SKUs arrive as a column in a billing export. The classifier joins onto that frame row by row, splitting resolved rows from quarantined ones so a single unmappable SKU never poisons the aggregate. The resolved frame carries ccu, which lets you compute a directly comparable cost-per-CCU across providers.
import pandas as pd
def map_frame(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Split a billing frame into (resolved rows with CCU, quarantined rows)."""
resolved_rows, dead_rows = [], []
for row in df.itertuples(index=False):
rec, dead = resolve_sku(row.provider, row.sku)
if rec is not None:
resolved_rows.append({
"provider": rec.provider, "sku": rec.sku,
"vcpu": rec.vcpu, "memory_gb": rec.memory_gb, "ccu": rec.ccu,
"cost": Decimal(str(row.cost)), # Decimal from the wire
})
else:
dead_rows.append(dead)
resolved = pd.DataFrame(resolved_rows)
if not resolved.empty:
# cost-per-CCU is the cross-provider comparable unit price
resolved["cost_per_ccu"] = resolved.apply(
lambda r: r["cost"] / r["ccu"], axis=1
)
return resolved, pd.DataFrame(dead_rows)
if __name__ == "__main__":
frame = pd.DataFrame([
{"provider": "aws", "sku": "db.r6g.xlarge", "cost": "182.50"},
{"provider": "azure", "sku": "GP_Gen5_4", "cost": "210.24"},
{"provider": "gcp", "sku": "db-custom-4-16384", "cost": "168.00"},
{"provider": "aws", "sku": "db.z9z.nano", "cost": "9.99"}, # unknown
])
resolved, quarantined = map_frame(frame)
print(resolved[["provider", "sku", "vcpu", "memory_gb", "ccu", "cost_per_ccu"]])
print("quarantined:", quarantined.to_dict("records"))
Expected output:
provider sku vcpu memory_gb ccu cost_per_ccu
0 aws db.r6g.xlarge 4 32 12 15.20833333333...
1 azure GP_Gen5_4 4 20.4 9.10 23.10329670329...
2 gcp db-custom-4-16384 4 16 8 21.00
quarantined: [{'provider': 'aws', 'sku': 'db.z9z.nano', 'reason': 'unparsed_sku'}]
Verification
Confirm the mapping is total and lossless before any cross-provider report consumes it.
Assert the quarantine is empty for a known estate. Every SKU your accounts actually run must resolve; a non-empty quarantine is a coverage gap, not a warning to ignore.
resolved, quarantined = map_frame(frame) assert quarantined.empty, f"unmapped SKUs: {quarantined['sku'].tolist()}"Round-trip a spot-check against provider docs. Pick one SKU per provider and confirm the resolved
(vcpu, memory_gb)matches the published instance spec.db.r6g.xlargeis 4 vCPU / 32 GiB;db-custom-4-16384is 4 vCPU / 16 GiB by construction.Check CCU monotonicity. Sort the resolved frame by
ccuand confirm larger sizes score higher — a size-parsing bug usually shows up as a2xlargescoring below anxlarge.
Gotchas & Edge Cases
- AWS size grammar has irregular members.
db.t3.mediumis 1 vCPU anddb.r6g.16xlargeis 64 vCPU, butmetaland some.largeburstable SKUs break the doubling rule. Keep the base-size table authoritative and let only theNxlargepattern compute — never extrapolate a size you have not verified. - Azure serverless has no fixed vCore count.
GP_S_Gen5_2is a serverless tier that autoscales between a min and max vCore; a single SKU string cannot state the billed capacity, so resolve serverless against actual vCore-seconds from the meter rather than the SKU, the same discipline used when separating compute and storage costs in Azure SQL. - GCP shared-core tiers are not
db-custom.db-f1-microanddb-g1-smallpredate the custom grammar and carry fractional vCPU; add them to an explicit lookup entry rather than forcing them through thedb-custom-*regex, which will simply returnNoneand quarantine them. - GiB versus GB. GCP states memory in MiB, and RDS specs are in GiB, while pricing pages sometimes quote GB. Pick one — this pipeline treats all memory as binary GiB after the
/1024conversion — and document it, because a 7% base-2/base-10 error compounds through every CCU. - Never coerce cost through
float. Read the cost column as a string and wrap it inDecimalat the boundary; afloatcost divided by aDecimalCCU raisesTypeError, and worse, afloat-sourcedDecimalcarries binary rounding noise. This is the same money-typing rule enforced in schema validation for billing data. - A growing quarantine signals SKU drift. Providers ship new families (Graviton4, Gen6 hardware) continuously. Treat the quarantine count as a monitored metric; a step change means a new family launched and the lookup table needs a patch-and-replay, not a silent drop.
Frequently Asked Questions
Why normalize to a synthetic CCU instead of just comparing vCPU counts?
Because vCPU alone hides the memory a SKU bills for. A 4-vCPU memory-optimized instance and a 4-vCPU general-purpose instance have identical vCPU counts but very different capacity and price; comparing on vCPU makes the memory-optimized SKU look artificially expensive per core. Folding memory into the unit with a reference density makes cost-per-CCU an apples-to-apples figure across families and providers.
How do I choose the reference density rho_ref?
Set it to the memory-per-vCPU of your most common general-purpose family — 4 GB/vCPU is typical for m-class AWS and Azure General Purpose Gen5. The absolute value does not matter for ranking as long as it is constant across every SKU; it only rescales the unit. Keep it in one named constant so a change re-scores the whole estate consistently.
What should happen to a SKU the parser cannot resolve?
It goes to quarantine with a reason code, never to a default or zero. A mis-mapped SKU silently corrupts every cross-provider comparison downstream, whereas a quarantined one is a visible, countable gap you patch in the lookup table and replay. Defaulting an unknown SKU to zero capacity would make its cost-per-CCU infinite and poison any ranking.
Does this handle serverless and autoscaling tiers?
Not from the SKU string alone — serverless capacity is not fixed at provision time. For Aurora Serverless v2 (ACUs) and Azure serverless (min/max vCores), resolve capacity from the billed vCore-seconds or ACU-hours in the usage record instead of the SKU, then feed that measured capacity into the same canonical_compute_unit function.
Why Decimal rather than float for the capacity math?
The CCU is a divisor for cost-per-unit, and that quotient flows into chargeback ledgers where sub-cent drift accumulates across millions of rows. Decimal with a set precision gives exact, reproducible arithmetic; float introduces binary rounding that makes two runs of the same data disagree in the last cents. The typing discipline is identical to the one at the validation boundary.
Related
- Normalizing currency and timezones in multi-cloud billing — the other half of normalization: converting mixed currencies and aligning billing periods once capacity is comparable.
- Schema validation for billing data — the upstream contract that guarantees a well-typed SKU field before this mapping runs.
- Multi-Cloud Cost Normalization — the parent topic covering the full canonical-model reconciliation this classifier feeds.
Back to: Multi-Cloud Cost Normalization