Skip to content

feat(fastapi): fair-share per-request energy attribution - #1380

Open
davidberenstein1957 wants to merge 3 commits into
feat/add-fastapi-middlewarefrom
feat/fastapi-attribution
Open

feat(fastapi): fair-share per-request energy attribution#1380
davidberenstein1957 wants to merge 3 commits into
feat/add-fastapi-middlewarefrom
feat/fastapi-attribution

Conversation

@davidberenstein1957

Copy link
Copy Markdown
Collaborator

Per-request energy numbers that add up. Builds on feat/add-fastapi-middleware.

The bug

The current per-request path snapshots the tracker's cumulative counters at request start and again at request end. Every request in flight is therefore charged the whole machine for the time it was open, so the same joules are counted once per concurrent request.

Measured sum(per-request) / run total, forced 100 W CPU + 10 W RAM, measure_power_secs=0.25, 2 s requests (scratchpad/table.py):

concurrency before (snapshot) after (attribution=True)
1 0.81x 0.855x
4 3.33x 0.854x
8 6.66x 0.852x
32 26.67x 0.843x
100 83.27x 0.842x

The overcount factor is the concurrency. The residual ~0.15 after the fix is idle time before and after the burst: real energy nobody requested, parked in unattributed_kwh rather than smeared across requests.

What this adds

codecarbon.integrations.fastapi.attribution. Each completed sampling window (t_prev, t_now, ΔE) is split across the requests in flight during it, weighted by their overlap and normalised by the sum of the weights. Windows with nothing in flight go entirely to an explicit unattributed bucket.

from codecarbon.integrations.fastapi import EnergyAttributor, add_codecarbon_middleware

add_codecarbon_middleware(app, attribution=EnergyAttributor(on_request=print))
report = app.state.codecarbon_middleware.attribution_report()

The tracker grows one hook, add_energy_window_observer(cb), fired after each completed sampling window. Attribution never touches _tasks, start_task, or mark_http_request_start.

The invariant

sum(per-request energy) + unattributed == settled energy

exactly, after every window. Tested at concurrency 1 / 4 / 8 / 32 / 100 with a deterministic clock and injected power, asserted after each window rather than only at the end, at rel_tol=1e-12. settled_kwh is the energy taken in from closed windows; it sits below the tracker's run total by whatever a wrapped counter dropped (windows_skipped) plus the final unsampled partial window, both of which the report exposes.

Claims and limits

  • wall weighting (default) is cost allocation, not measurement. Four CPU-burning and four sleeping requests running concurrently all receive an identical share, because they occupied the same seconds of the same machine. When nothing tells you which request caused which watt, that is the honest answer, and the docs say so in those words.
  • cpu weighting (opt-in) charges each request the on-thread CPU time its asyncio task burned, via a task factory on the event loop. On those same eight requests it separates them by four orders of magnitude. It replaces the loop's task factory and costs ~1 µs per create_task, so it stays off by default. Weights are normalised by the window's CPU capacity (width × cores), not by observed CPU — otherwise a window where everyone used 1 ms of CPU hands that 1 ms the entire window's energy. cores=1 encodes "one event-loop thread" and is a documented knob for apps using run_in_executor.
  • Deferred finalisation. end() only stamps the end time; the request stays weighted until the next real sample closes. No partial window is settled at response time — at response time the machine's power over the last partial window is genuinely unknown, and settling anyway drops energy into a zero-width window. Consequently response_headers is rejected with attribution (ValueError), and the existing X-CodeCarbon-* headers are now documented as sampled-at-response, not window-resolved.
  • Quality tiers on every result. unresolved emits no energy number at all (energy_kwh is None) — zero would be a lie. interpolated (one boundary, shorter than a window) is flagged. measured is two or more boundaries.
  • Idle baseline, two numbers. P_idle = median power over idle windows. energy_kwh is marginal (stable against traffic volume), baseline_share_kwh is the per-capita allocated cut. If the server never idles there is no sample: baseline_watts() returns None, nothing is subtracted, results carry baseline_subtracted=False. Nameplate TDP is deliberately not a fallback — a wrong baseline subtracts a fixed amount from every request and drives short requests negative.
  • No ± error bar. The dominant error is the assumption that overlap tracks causation, and that has no distribution to quote. The uncertainty payload ships instead: windows covered, mean concurrency competed against, request CPU-seconds, weighting mode, whether a baseline was subtracted, quality tier.
  • Per-endpoint aggregates are the primary output. 400 sequential 5 ms requests against a 1 s interval gave per-request shares spanning 0.028–0.812 µWh (236% RSD), every one interpolated, while the endpoint aggregate was a stable 0.043 µWh/call.
  • Bounded state. Emit-and-drop, no done list. 277 B per in-flight request, freed on resolve; idle-power samples are a deque(maxlen=256); endpoint aggregates are bounded by route count. Tested flat after 10,000 requests.

Overhead, measured

begin + end (wall) 0.41 µs/request
settle, 1 in flight 0.7 µs/window
settle, 100 in flight 19.0 µs/window (190 ns/request)
settle, 1000 in flight 193.2 µs/window (193 ns/request)
in-flight state 277 B/request
cpu mode create_task +0.92 µs

Linear in in-flight requests at ~190 ns each, and only on real sampling windows.

_maybe_measure_power_and_energy

It stays, and it is now documented as to why. It forces an out-of-band hardware sample on the request path whenever the last is older than min(1.0, interval/4), which under load does collapse the effective sampling interval to the request rate and serialise RAPL/NVML reads through one thread. But it is only reachable from finish_http_request, which reads a delta of cumulative counters: without a fresh sample, every request shorter than the sampling interval reports exactly zero. Deleting it silently zeroes the legacy path. The attribution path never calls it — it only consumes windows the scheduler already closed — and there is a test pinning that (20 requests, measure_power_secs=60, zero forced samples).

Composability

Narrow diff on shared files: emissions_tracker.py gains one list, two public methods, one notify call and one docstring; middleware.py gains one kwarg, one branch in __call__, and a _resolve_tracker extraction. Should rebase cleanly against feat/fastapi-measurement-tiers. The tier concept slots onto RequestEnergy alongside quality — attribution's quality tier is about window coverage, the sibling's is about backend capability; they are orthogonal and both belong on the result.

Testing

tests/integrations/test_fastapi_attribution.py, 25 tests. Every one injects a known constant power and (where timing matters) a deterministic fake clock, so the numbers are identical on Apple Silicon and on a 280 W Linux box. Nothing in the file reads real hardware, and there are no absolute-watts assertions.

uv run pytest tests/ -q --ignore=tests/test_viz_data.py → 710 passed, 21 skipped. uv run pre-commit run --all-files → clean.

Deferred

  • No pytest-asyncio dependency added; the two ASGI end-to-end tests drive asyncio.run from sync test functions.
  • Attribution results are not written to CSV or pushed to the API. They surface through the on_request callback and attribution_report(). Persisting them is a separate decision about schema.
  • subtract_baseline uses the median idle power known so far, so early windows in a run that has not yet idled get no subtraction. A retroactive second pass would be more accurate and much more machinery.

🤖 Generated with Claude Code

The existing per-request path snapshots the tracker's cumulative counters
at request start and again at request end, so every request in flight is
charged the whole machine for the time it was open. Measured
sum(per-request)/run-total: 3.33x at concurrency 4, 6.66x at 8, 26.67x at
32, 83.27x at 100 - the overcount factor is the concurrency.

Adds codecarbon.integrations.fastapi.attribution, which splits each
completed sampling window across the requests in flight during it,
weighted by overlap and normalised by the sum of the weights. Windows
with nothing in flight go to an explicit unattributed bucket. The
invariant sum(per-request) + unattributed == settled energy holds exactly
after every window and is the headline test.

- deferred finalisation: end() only stamps the end time, the share
  resolves at the next real sample. No partial window is settled at
  response time, so response_headers is rejected with attribution.
- two weightings: wall (default, cost allocation) and cpu (opt-in
  asyncio task-factory hook), the latter normalised by the window's CPU
  capacity (width x cores).
- idle baseline measured as the median power over idle windows, reported
  as marginal energy plus a separate per-capita baseline share. None and
  flagged if the server never idles; never guessed from nameplate TDP.
- quality tiers: unresolved (no number at all), interpolated, measured.
- per-endpoint aggregates are the primary output.
- bounded state: emit-and-drop, 277 B per in-flight request, bounded
  idle-sample deque.

Overhead: 0.4 us/request begin+end, ~190 ns per in-flight request per
sampling window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.63504% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 92.41%. Comparing base (5680063) to head (c064f00).

Files with missing lines Patch % Lines
codecarbon/integrations/fastapi/middleware.py 97.72% 1 Missing ⚠️
Additional details and impacted files
@@                       Coverage Diff                       @@
##           feat/add-fastapi-middleware    #1380      +/-   ##
===============================================================
+ Coverage                        92.06%   92.41%   +0.35%     
===============================================================
  Files                               53       54       +1     
  Lines                             5530     5803     +273     
===============================================================
+ Hits                              5091     5363     +272     
- Misses                             439      440       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

davidberenstein1957 and others added 2 commits August 13, 2026 07:10
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The patch shipped `weighting="cpu"` - the task factory, the `_TimedCoro`
wrapper and the middleware branch that creates a task per request - with
no test touching it, plus the observer teardown, the raising-observer
guard and the zero-width window early return.

Six tests: real event loop with `install_cpu_accounting` (send, throw
via cancellation, close/getattr delegation), a cpu-weighted ASGI request
end to end asserting CPU seconds are billed and the window observer is
registered exactly once, `shutdown_codecarbon_middleware` unhooking the
observer and flushing in-flight requests, tracker observer add/remove
idempotence with a callback that raises, and two samples at the same
instant not banking energy into a zero-width window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@davidberenstein1957
davidberenstein1957 marked this pull request as ready for review August 13, 2026 06:38
@davidberenstein1957
davidberenstein1957 requested a review from a team as a code owner August 13, 2026 06:38
@davidberenstein1957
davidberenstein1957 requested review from inimaz and removed request for a team August 13, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant