feat(fastapi): fair-share per-request energy attribution - #1380
Open
davidberenstein1957 wants to merge 3 commits into
Open
feat(fastapi): fair-share per-request energy attribution#1380davidberenstein1957 wants to merge 3 commits into
davidberenstein1957 wants to merge 3 commits into
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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
marked this pull request as ready for review
August 13, 2026 06:38
davidberenstein1957
requested review from
inimaz
and removed request for
a team
August 13, 2026 06:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):attribution=True)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_kwhrather 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 explicitunattributedbucket.The tracker grows one hook,
add_energy_window_observer(cb), fired after each completed sampling window. Attribution never touches_tasks,start_task, ormark_http_request_start.The invariant
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_kwhis 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
wallweighting (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.cpuweighting (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 percreate_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=1encodes "one event-loop thread" and is a documented knob for apps usingrun_in_executor.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. Consequentlyresponse_headersis rejected withattribution(ValueError), and the existingX-CodeCarbon-*headers are now documented as sampled-at-response, not window-resolved.unresolvedemits no energy number at all (energy_kwh is None) — zero would be a lie.interpolated(one boundary, shorter than a window) is flagged.measuredis two or more boundaries.P_idle= median power over idle windows.energy_kwhis marginal (stable against traffic volume),baseline_share_kwhis the per-capita allocated cut. If the server never idles there is no sample:baseline_watts()returnsNone, nothing is subtracted, results carrybaseline_subtracted=False. Nameplate TDP is deliberately not a fallback — a wrong baseline subtracts a fixed amount from every request and drives short requests negative.interpolated, while the endpoint aggregate was a stable 0.043 µWh/call.donelist. 277 B per in-flight request, freed on resolve; idle-power samples are adeque(maxlen=256); endpoint aggregates are bounded by route count. Tested flat after 10,000 requests.Overhead, measured
begin+end(wall)cpumodecreate_taskLinear in in-flight requests at ~190 ns each, and only on real sampling windows.
_maybe_measure_power_and_energyIt 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 fromfinish_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.pygains one list, two public methods, one notify call and one docstring;middleware.pygains one kwarg, one branch in__call__, and a_resolve_trackerextraction. Should rebase cleanly againstfeat/fastapi-measurement-tiers. The tier concept slots ontoRequestEnergyalongsidequality— 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
pytest-asynciodependency added; the two ASGI end-to-end tests driveasyncio.runfrom sync test functions.on_requestcallback andattribution_report(). Persisting them is a separate decision about schema.subtract_baselineuses 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