Sizing and TCO

Appendix D · Units, and the conversions that bite

A sizing model is a chain of multiplications, and the commonest way for one to be wrong is not
arithmetic — it is multiplying two quantities that should never have met. Series by requests.
Bytes by seconds. A per-node figure by a per-core one. Spreadsheets cannot see any of this: a
cell holds a number, the number has no dimension, and ``=B4*C7`` is as valid as any other
product. Every node here declares a unit instead, and :func:`check_formula` refuses a model whose
formula does not typecheck.

Multiplying two quantities that should never have met is the reason this book has a build step at all.

How units combine and cancel

One rule does all of the work: a unit in the denominator of one quantity cancels the same unit in the numerator of another, and nothing else cancels. Requests per second times seconds is requests, because the seconds cancel. Requests per second times a plain number is still requests per second, because a plain number has nothing to cancel with.

The table applies that rule to the combinations a sizing model is made of. Every result in it was worked out by the registry the build uses, not typed, so the page cannot show a combination the toolkit would disagree with.

CalculationThe unitsResultWhat happened
a request rate, for a durationrequest/second × secondrequestthe seconds cancel: a rate times a duration is an amount
a request rate, times a plain numberrequest/second × dimensionlessrequest/secondnothing cancels: a plain number leaves a rate as it was
disk per host, times the hostsTB/host × hostTBthe hosts cancel: an amount per host times a count of hosts is an amount
raw data, divided by disk per hostTB ÷ TB/hosthostthe terabytes cancel and the hosts come up: the last step of a sizing chain
CPU time per request, times the request ratesecond*core/request × request/secondcorethe requests and the seconds both cancel, leaving cores busy (ch05)
watts per host, times the hosts, times a building multiplierW/host × host × dimensionlessWthe hosts cancel and the multiplier changes nothing but the size
watts, times the hours in a yearW × hour/yearkWh/year, and the build multiplies by 0.001power times time is energy, and the build converts to the unit the price is in
energy, times the price of itkWh/year × USD/kWhUSD/yearthe kilowatt-hours cancel: energy times a price is money per year
a running cost, over the horizonUSD/year × yearUSDthe years cancel: a rate of spending over a duration is an amount of money
a per-host licence, times the hostsUSD/host/year × hostUSD/yearthe hosts cancel and the years stay: still a running cost
a link rate, for a duration, read in bytesMbit/second × secondMB, and the build multiplies by 0.125the seconds cancel and the build divides by eight, because the link was quoted in bits
memory per host as the sheet quotes it, times the hostsGiB/host × hostTB, and the build multiplies by 0.00107374the hosts cancel and the build converts binary gibibytes to decimal terabytes
a price per terabyte-year, read per monthUSD/TB/yearUSD/TB/month, and the build multiplies by 0.0833333nothing cancels and nothing is wrong: the same dimensions, a twelfth of the size
the horizon, divided by one yearyear ÷ yeardimensionlessthe years cancel to a plain number, which is the only thing an exponent may be

Source — sizing/units.py, which worked out every row

Two rows matter more than the rest. A rate times a duration is an amount. A rate times a plain number is still a rate, and a formula that treats it as an amount is the mistake the introduction shows the toolkit refusing.

A node declares the unit it means to produce. The build works out what its formula produces and does one of three things with the two:

FormulaProducesNode declaresVerdict
a request rate, for a durationrequest/second × secondrequestaccepted as written
a request rate, times a plain numberrequest/second × dimensionlessrequestrefused: not the same kind of quantity, and no factor makes it one
a price per terabyte-yearUSD/TB/yearUSD/TB/monthconverted: the build multiplies by 0.0833333
watts, times the hours in a yearW × hour/yearkWh/yearconverted: the build multiplies by 0.001
a drive as the sheet quotes itTBTiBconverted: the build multiplies by 0.909495
a link rate, for a durationMbit/second × secondMBconverted: the build multiplies by 0.125
bytes per span, times spans per requestbyte/span × span/requestbyte/requestaccepted as written
bytes per span, times spans per requestbyte/span × span/requestbyte/secondrefused: not the same kind of quantity, and no factor makes it one

Source — sizing/units.py, which reached every verdict

The three verdicts are accepted, converted and refused. The first two rows are the introduction’s example and its mistake. The converted rows are what the rest of this appendix is about: the same kind of quantity in a different size, which is the error that looks right.

Counting units are units

## Counting units are units

The registry defines ``request``, ``span``, ``sample``, ``series``, ``line``, ``query``, ``host``,
``node``, ``core``, ``label`` and ``drive`` as *dimensions of their own*, not as synonyms for
"dimensionless". That is the whole value of this module. Without it, spans-per-request and
bytes-per-span are both plain numbers and multiplying the wrong pair gives a plausible answer;
with it, ``request/second × span/request × byte/span`` is ``byte/second`` and nothing else is.

It costs something, and the cost is the point: converting between two counting units requires a
node that names the conversion. That node is exactly the *measured constant* of ch03 — an
empirical, stack-specific number with provenance — so the unit system pushes you towards
declaring the thing the book says you must declare.
COUNTING_UNITS: tuple[str, ...] = (
    "USD = [currency] = usd = dollar",
    "request = [request] = req",
    "span = [span]",
    "sample = [sample]",
    "series = [series]",
    "line = [line]",
    "query = [query]",
    "host = [host]",
    "node = [node]",
    "core = [core]",
    "label = [label]",
    "drive = [drive]",
    "failure = [failure]",
)

Without this, spans-per-request and bytes-per-span are both plain numbers, and multiplying the wrong pair produces a plausible answer with no complaint from anything. With it, only one product of those two is well formed.

The cost of that is a node. Converting between two counting units requires one that names the conversion: how many spans a request emits, how many samples a series produces per scrape. That node is the measured constant of ch03: an empirical number belonging to one implementation at one version, with provenance attached. The unit system makes you declare what the book says you must declare, which a rule in a style guide cannot do.

Dimensions are not enough

Most of what the check catches is not dimensional nonsense. It is two units with the same dimensions and different magnitudes:

ModelNodeFormula producesNode declaresFactor
observabilitylogs ingestbyte / secondMB/sx1e-06
observabilitylogs storedday * megabyte / secondTBx0.0864
observabilitymetrics ingestbyte / secondMB/sx1e-06
observabilitymetrics storedday * megabyte / secondTBx0.0864
observabilitytraces ingestbyte / secondMB/sx1e-06
observabilitytraces storedday * megabyte / secondTBx0.0864
web_servicecost per million requestsUSD / requestUSD/megarequestx1e+06
web_servicecost per stored TB per monthUSD / terabyte / yearUSD / TB / monthx0.0833333
web_servicefacility powerwattkWx0.001
web_servicememory the service can use, per hostgibibyte / hostTB/hostx0.00107374

Source — models/

Every row is a formula whose result has the right dimensions and the wrong size. Dollars per terabyte per year and dollars per terabyte per month are dimensionally identical. A check that compared dimensions alone would pass a unit cost twelve times too large, and it would pass it in the figure most likely to be quoted in a meeting.

So the build records the factor and applies it. The declared unit wins: a node says what it means to produce, and the build makes the arithmetic agree or refuses to continue.

Five places a unit goes wrong

Decimal against binary. A drive is sold in decimal terabytes. An operating system reports tebibytes. The gap is nearly a tenth of the capacity, it is in the direction that makes a fleet’s disks smaller than the spreadsheet said, and it compounds with the replication factor. The web service model’s disk-per-host input says decimal in its provenance for this reason.

Bits against bytes. Network is quoted in bits per second and storage in bytes per second, and the factor of eight between them sits at the boundary between two teams. A model that multiplies a link rate by a duration and compares the result to a volume has to get this right once; a model that does not declare units has to get it right every time anybody edits it.

Months. The registry has one, and it is a twelfth of a year, which is no month that has ever appeared on a calendar. That is the right convention for a price per terabyte-month, where nobody means February, and the wrong one for anything that has to reconcile against a billing period. The distinction is invisible until finance does the reconciling, which is why the conversion appears in the table above rather than in the modeller’s head.

Years. The same problem, smaller: a year is not exactly three hundred and sixty-five days. The web service model’s hours per year input carries the quarter-day and says in its provenance what that is worth over the horizon: less than the model’s other errors, and free to get right.

Exponents are pure numbers. A duration cannot be an exponent. Compounding growth over a horizon needs the horizon divided by one period first, which is why both reference models carry a node that is just one year with a provenance of fact. It looks like ceremony until the first time the check catches a growth factor raised to the power of five seconds.

Where Pint runs, and where it does not

## Why Pint is not in the evaluator

Pint is used **here, at build time, and nowhere else**. :func:`check_formula` walks a model's
formulas with unit-bearing quantities and raises on a dimensional error; after that the units are
stripped and :mod:`sizing.evaluate` works in plain ``float64``.

Two reasons. A unit-bearing array across a hundred nodes and a hundred thousand samples is slow
and its semantics are fiddly in ways that have nothing to do with the subject. And
:mod:`sizing.mc` is a chapter of this book that the reader is asked to read: a units library in
the middle of it would be answering a question nobody asked. Units are a gate, not a tax.

Units are checked once, at build time, over the model’s formulas with unit-bearing quantities. After that the units are stripped and the sampler works in plain floating point. A unit-bearing array across a hundred nodes and a hundred thousand samples is slow. And sizing/mc.py is a chapter of this book, written to be read: a units library in the middle of it would answer a question nobody asked.

Units are a gate, not a tax.

Why the check needs a magnitude

Evaluating a formula in units alone is not always possible. A formula containing 1 - headroom has to be evaluated at a magnitude as well as a unit, and a magnitude of one produces a division by zero in a model that is perfectly sound. So the checker uses each node’s real point value where it has one:

def plausible_magnitudes(model: Model) -> dict[str, float]:
    """A realistic number for every node, to do the dimensional pass with.

    Not cosmetic. Checking units by evaluating each formula at magnitude 1 looks reasonable and
    is wrong in a way this model caught immediately: ``node_raw_capacity * (1 - capacity_headroom)``
    becomes a division by zero, and the unit checker reports a dimensional error in a formula
    whose dimensions are fine. The arithmetic has to be done on numbers that are not all the
    same number.

    So each node gets its real value where one can be computed — that is what the numbers *are*,
    and they can never be degenerate in a way the model itself is not. A node downstream of a
    constant nobody has measured gets 1.0, and if that makes its formula undefined it is retried
    at a value that cancels nothing. A node that still cannot be evaluated is reported by
    :func:`check_units` for what it is, rather than as a units problem it is not.
    """
    magnitudes: dict[str, float] = {}
    for name in model.order:
        node = model.nodes[name]
        try:
            if isinstance(node, Input):
                resolved = point_value_of_input(node, None)
                magnitudes[name] = 1.0 if resolved is None else resolved
            elif isinstance(node, Measured):
                magnitudes[name] = 1.0 if node.value is None else float(node.value)
            elif isinstance(node, Derived):
                magnitudes[name] = float(_walk(node.formula, magnitudes, SCALAR_FUNCTIONS))
            else:
                magnitudes[name] = float(_walk(node.of, magnitudes, SCALAR_FUNCTIONS))
        except Exception:
            # 0.37 rather than 1.0 or 0: it cancels nothing, it keeps `1 - x` positive, and it
            # leaves a logarithm defined. Only reached downstream of an unmeasured constant.
            magnitudes[name] = 0.37
    return magnitudes

The check found that case itself, on a model that was correct.

Running it

python3 scripts/verify-models.py       # every formula, in units, on every model
python3 -m pytest tests/test_models.py # the registry, the conversions, and both models