Skip to content

← Capstones step 5 of 5

Hard End-to-End

Capstone: five defects, and which tool would have found each one

Real performance work is never one clean bottleneck. It is a mix of CPU, memory and object-lifetime problems whose relative importance is unknown until you measure — and two of the five defects here are invisible to a CPU profiler.

pipeline.py is an order-enrichment module. Its behaviour is correct. Keep it correct, and fix all five.

The five defects

1. A quadratic join. if order.customer_id in active where active is a list. Every order scans the whole collection. The fix is one line and it is the single most common accidental-quadratic in Python.

The grader does not time this. active is a Collection that counts every membership test it is asked to perform, and the assertion is that the count is zero: a correct implementation hashes the collection once and never asks it again. Deterministic, and it fails the same way on a fast machine and a slow one.

2. asdict() in the serialisation hot path. dataclasses.asdict does a recursive deep copy: every nested dataclass becomes a new dict, every tuple a new list, all the way down. (One thing that is not true any more: Decimal leaves are no longer copied — Decimal is in copy‘s atomic-types set since 3.12. The cost is the containers, not the leaves.) Your fields are frozen and therefore safe to share, so a hand-written shallow projection passes the nested tuple through by reference. The grader checks exactly that identity.

3. An @lru_cache-decorated method. The cache key is (self, customer_id), so the cache pins the directory instance and every Customer it has ever returned, forever, at module scope. The grader looks up 20,000 customers, registers them in a WeakSet, drops its own references, calls gc.collect() and counts survivors. A WeakValueDictionary gives you the cache without the ownership.

One detail that will stop you dead: a @dataclass(slots=True) class is not weak-referenceable. __weakref__ is only added when you pass weakref_slot=True. Without it the grader’s WeakSet raises TypeError: cannot create weak reference.

4. An accumulator built with self.buffer += chunk. CPython has a fast path that mutates a string or bytes object in place when its refcount is 1 — but self.buffer is an attribute, so there is always a second reference and the fast path never fires. Every add() allocates and copies the whole buffer: quadratic in the number of chunks, and completely invisible in a profiler as anything other than “a lot of time in add“.

Collect the parts and b"".join(...) once. And note what the grader checks besides throughput: it takes an alias of the accumulated value, adds another chunk, and requires the alias to be unchanged. That is a guard against the tempting over-fix — an in-place bytearray whose previously-returned value mutates under its caller’s feet.

5. A function that materialises the whole scan before filtering. 120,000 rows built into a list, then filtered down to twelve. A generator pipeline plus islice does the identical work with a peak footprint of one row. The grader wraps the call in tracemalloc and bounds the peak to a multiple of the result size rather than the scan size.

The written half

For each defect, know which tool would have found it — and know why two of them would not show up in cProfile at all:

defect what finds it
1 quadratic join any CPU profiler; a scaling test at n and 2n confirms the shape
2 asdict CPU profiler — the time is genuinely spent in copy
3 cache leak not a CPU profiler. tracemalloc snapshots over time, gc.get_objects(), memray, or a WeakSet canary
4 += accumulator CPU profiler shows the symptom (“lots of time in add“); only reading the code, or a scaling test, shows the cause
5 scan-then-filter not a CPU profiler. The work is the same; only the memory differs. tracemalloc peak, or an OOM at 3 a.m.

That table is the actual lesson. A profiler answers “where is the time”, and two of the five most expensive things a service does wrong are not about time at all. The 3.15 sampling profiler (python -m profiling.sampling) changes where you can run the CPU profiler — attaching to a live production process with no restart — but it does not change what a CPU profiler is blind to.

Constraints

The driver is fixed; every behavioural result it reports must stay identical. mypy --strict clean — which the module you are given is not, so that is part of the work too.