Jack Thompson
@jack_thompson • 2 months ago
Paste cProfile, py-spy or Chrome profiler output and get the top hotspots by self time, an Amdahl-style ceiling, and one change to try first.
whatprofiletarget{{what}}{{profile}}{{target}}what: Django 5 endpoint that exports 20,000 orders to CSV
target: under 2 seconds for 20k rows
profile:
```
4180331 function calls in 9.812 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.004 0.004 9.812 9.812 orders/export.py:12(export_orders_csv)
20000 0.061 0.000 6.944 0.000 django/db/models/fields/related_descriptors.py:226(__get__)
20001 0.412 0.000 7.187 0.000 django/db/backends/utils.py:98(execute)
20001 6.338 0.000 6.338 0.000 {method 'recv_into' of '_socket.socket' objects}
20000 0.090 0.000 2.401 0.000 orders/export.py:40(format_row)
140000 0.198 0.000 1.902 0.000 babel/numbers.py:664(format_currency)
140000 0.884 0.000 1.244 0.000 babel/numbers.py:1150(parse_pattern)
20001 0.410 0.000 0.410 0.000 {method 'writerow' of '_csv.writer' objects}
```recv_into: 6.34 s (65%)parse_pattern: 0.88 s (9%)execute: 0.41 s, tied with writerow at 0.41 s (4% each)recv_into: (c) waiting, caused by (a) unnecessary work. It's socket reads from the database. The giveaway is the call count: 20,001 queries for one export. related_descriptors.get is called 20,000 times with 6.94 s cumulative. That's order.customer lazy-loading one customer per row, the classic N+1.parse_pattern: (a) unnecessary work. 140,000 calls means the currency format pattern is parsed again for every money value (7 per row). The pattern never changes.execute / writerow: necessary work at a reasonable cost. Below the 5% line, so leave them.`python`execute should drop from 20,001 calls to 1 (or a handful with chunking), and related_descriptors.get cumulative time should fall to milliseconds. In tests, wrap the export in assertNumQueries(1) so the N+1 can't come back.f"{amount:.2f}") and let the spreadsheet format them. If it needs locale formatting, parse the pattern once outside the loop and reuse it, instead of calling format_currency 140,000 times.recv_into is where time goes, but the call count shows why. The (a)/(b)/(c) classification forces a diagnosis (unnecessary vs inefficient vs waiting) before any fix. The ceiling calculation with visible arithmetic is the most useful step: it showed the obvious N+1 fix alone couldn't hit the 2 s target, so the second fix was planned up front. The 5% floor prevents busywork on things that don't matter.pg_stat_statements) for those. Sampling profilers on async code can also attribute waiting time strangely, so confirm the diagnosis with a before/after timing, not just the profile.