Serving And Inference

Latency Anatomy: Where the Time in One Prediction Request Goes

0 of 26 complete

0%

Contents

Back|Serving And InferenceLatency Anatomy: Where the Time in One Prediction Request Goes
1/26
59 min left
Prerequisites
Batch or Online Scoring: A Month-Old Score Cost Nothing I Could Measure HererequiredWhat a Feature Is: A Better Model or a Better Feature?requiredModel Serving and Inference APIs: Turning a Model File Into a Servicerequired
Related Topics
Model Signatures: The Right Numbers in the Wrong Shape, and What a Schema Check CatchesPackaging, Registry and VersioningWrong Labels: How Many Can a Model Survive, and Can You Find Them?Data Engineering for MLRebalance, or Just Move the Threshold? Measured on Rare ClassesData Engineering for MLLeakage Before the Split: How Pure Noise Scored 93% AccuracyData Engineering for MLWhat a Feature Is: A Better Model or a Better Feature?Features and Feature Stores
1 of 26

Where Did the Wait Go?

Let me start in a coffee shop.

You walk in, order a coffee, and pay. Some minutes later, you have the cup in your hand. The coffee machine itself only ran for a short part of that time. So where did the rest of the wait go?

A flat illustration of a smiling woman in a cream sweater and rust trousers, holding a takeaway coffee cup. Below the picture: from the moment you order to the moment the cup is in your hand, many small things happen: the till, the order slip, the milk from the fridge, the machine, the name called out. To make the wait shorter, you first need to know which one takes the time.

Many small things happened. The person at the till typed your order. A slip went to the person making drinks. Someone took milk from the fridge. The machine ran. Your name was called, and you walked to the counter. Each step is short. Together they make the wait.

If the shop wants faster coffee, buying a faster machine may not help much. Maybe the till is slow, or the slips wait in a pile. The owner first needs to know which step takes the time. Then they can fix the right one.

A prediction service is like that shop. A request comes in, many small steps happen, and an answer goes out. In this lesson I time every one of those steps, on a real service with a real model, and I find out where the time goes.

Where This Lesson Starts

This is lesson 2 of the chapter on serving. Lesson 1, batch or online, asked whether to score every customer in a nightly batch or each request as it comes. This lesson takes the second choice, scoring each request as it comes, and looks inside one request.

The model is the one from the features chapter. If it is new to you, please read what a feature is first. It uses six features of each customer of a real online shop, built from their past invoices. It answers one question: at the start of a month, will this customer buy something in the next 30 days? It is a gradient boosted tree model from scikit-learn, and it scored a test AP of 0.5450. AP, average precision, is a score from 0 to 1 for how well the model ranks the buyers above the others.

The survey lesson model serving and inference APIs has a slide called "The Request Path". It walks a request through a gateway, a cache, a and a model, and it gives example times for each hop. I will not repeat that slide here. Instead, I build a small version of that path and measure it.

The Words You Need First

Please read this slide slowly if any word is new. Every slide after it uses these words.

A hand-drawn grid of twelve cards, two per row. Request: one question sent to the service, here one customer and one date. Service: a program that waits for requests and answers them. Latency: how long one request waits for its answer. Round trip: from the moment the client sends to the moment it has the whole answer. Microsecond (us): one millionth of a second; 1,000 us make one millisecond. Handler: my own function inside the service that does the work for one request. Loopback: a network connection from a computer to itself, address 127.0.0.1. Feature store: a table that keeps each customer's features ready to read. Serialize: turn an answer in memory into bytes that can be sent. Median: the middle value, half the requests were faster, half slower. P95 and p99: 95% (or 99%) of requests were at least this fast. Warm-up: the first requests, sent but not counted, while the program settles. Below: model, feature and AP mean what they meant in the features chapter.

A service is a program that waits for questions and answers them. Each question is a request. The program that sends requests is the client. Here the client is a small Python program, and the service is a web server that holds the model.

is how long one request waits for its answer. The round trip is latency as the client sees it: from the moment it sends the request until it has read the whole answer. Think of the coffee: from paying until the cup is in your hand.

The times in this lesson are very short, so I use the microsecond. One microsecond is one millionth of a second. A thousand microseconds make one millisecond. The figures write microseconds as "us".

The handler is the one function I wrote inside the service. Everything else in the service, such as reading the network and finding my function, is code from libraries. The loopback is a network connection from a computer to itself. The client and the service ran on the same machine, so their requests never left it.

When I send 20,000 requests, I get 20,000 times. The median is the middle one: half the requests were faster and half slower. The p95 is the time that 95% of requests were at or under, and the the time that 99% were at or under. The p99 tells you about the slow few, which the median hides.

What One Request Does

Before the numbers, here is the path one request took in my service. Each arrow is a step, and the times in brackets show where my handler read the clock.

A sequence diagram with five lifelines: client, uvicorn, my handler, SQLite and model. 1, the client sends POST /score with JSON to uvicorn. 2, uvicorn calls the handler, t0. 3, the handler reads and validates the body, t1 and t2. 4, the handler sends SELECT by key to SQLite. 5, SQLite returns six numbers, t3. 6, the handler builds the row, t4. 7, the handler calls predict_proba on the model. 8, the model returns the score, t5. 9, the handler writes JSON bytes, t6. 10, the handler returns the response to uvicorn. 11, uvicorn sends the HTTP answer to the client. Below: the client takes its own two clock readings, just before step 1 and just after step 11; the difference is the round trip. Caption: my own code is steps 3 to 9; the rest is the client, uvicorn and FastAPI.

The client sends a small piece of JSON, such as a customer number and a date, to the address /score. JSON is a common text format for data. The request goes to uvicorn, a web server for Python. Uvicorn reads the bytes and hands them to FastAPI, a library that finds the right function for each address. That function is my handler.

My handler does five things. It parses the request: it reads the body and checks it has a customer number and a date. It looks up the six features of that customer at that date in SQLite, a small database kept in one file. It builds a row: a list of six numbers in the order the model expects. It calls predict, the model's predict_proba, which returns a score from 0 to 1. And it serializes the answer: it turns the score into JSON bytes. Then the answer goes back the way the request came.

At each step, my handler reads a very precise clock, Python's time.perf_counter_ns. It reads it seven times, so I can subtract one reading from the next and get the time of each step.

How the Lab Was Built

I wrote the lab's design into the docstring of scripts/labs/serving/latency_anatomy.py before any timed run. At that point I had started the box and nothing more. I had not run the service anywhere, and I had not timed this model's predict in this chapter.

A page in four labelled zones, titled one service, one request at a time, five runs. The box: an EC2 c7g.medium, 1 vCPU, not burstable, quiet before every run; client and service both on it. The service: FastAPI under uvicorn, one worker, access log off; features in SQLite; the features chapter's model. One request: a real test customer and date; seven clock readings inside the handler, two in the client. The runs: 1,000 warm-up, then 20,000 measured requests, one at a time; then the same to an empty endpoint; 5 runs, each with a fresh service. Caption: guess, written first, predict is the biggest step inside the handler.

The requests are real. Each one is a real customer and a real month from the test months of the features chapter: 26,851 pairs in all. I shuffled them once, in a fixed way, and every run sent them in the same order.

One request at a time. The client sends a request, waits for the whole answer, and only then sends the next one. So no request ever waits behind another. Waiting in a line, a queue, is a real cause of slow answers, but it is a different question, and lesson 7 measures it.

A warm-up first. The first requests to a fresh program are slower, for reasons lesson 6 looks at. So each run sent 1,000 warm-up requests, which I stored but did not count, and then 20,000 measured ones.

Five runs. One run is one data point. I ran the whole thing five times, each time with a fresh service, and I report how much the runs differ.

My guesses, written first. I wrote seven guesses before the run. The main one was that predict would be the biggest step inside my handler, bigger than all the others together. I check them all near the end.

Why the Timings Come From a Rented Machine

My laptop is almost always busy with other work. If I timed requests on it, I would measure my other programs as much as my service. So no timing in this lesson comes from my laptop. Every timing comes from a small machine I rented from Amazon Web Services for this lab, called an EC2 instance, or here just "the box".

Six rows, each with a logo. EC2 c7g.medium, us-east-1: 1 vCPU (Neoverse-V1, 1 thread per core), not burstable; 0.481 hours, $0.0175. Ubuntu 24.04, arm64: the load average was at most 0.10 before every counted run; the host took 0% of the CPU during them. Python 3.13.15, OMP_NUM_THREADS=1: scikit-learn 1.9.1, numpy 2.5.3, pandas 3.0.6, the same as my laptop's venv. FastAPI 0.142.2, uvicorn 0.54.0: one worker, uvloop 0.23.0, httptools 0.8.0, access log off. SQLite 3.53.1: 26,851 rows, one per customer and test date, primary key on both. The features chapter's model: test AP 0.5450, 202,700 bytes, trained on my laptop and copied over; same sha256 on the box. Caption: every timing I quote comes from this box, never from my laptop.

The box. The chapter plan asked for a machine with 2 virtual CPUs. My Amazon account only allows 1 virtual CPU of this kind at a time. So I used a c7g.medium. It has 1 CPU core of its own, not shared with a second thread. It is also not burstable, which means its speed does not drop after a while. One thing follows from this, and I wrote it down before running: the client and the service share that one core. So every round trip also includes the operating system switching from one program to the other and back.

The same software as my laptop. I installed the same Python, 3.13.15, and the same versions of scikit-learn, numpy and pandas. I set OMP_NUM_THREADS=1, which tells the model to use one thread, because the box has one core. I copied the model file over and checked that its sha256, a fingerprint of the file's bytes, was the same on the box.

Quiet. Before each run, the lab waited until the box's load average, a number for how busy the computer has been in the last minute, was at most 0.10. During the runs, the machine under the box took 0% of the CPU time. The box was on for 0.481 hours and cost $0.0175 for compute. Then I deleted it, with its key and its network rule.

The Lab's Report, Running

This is a real recording of the report script, lat_report.py. It ran on my laptop, but it does no timing at all. It reads the raw timings the box measured and does the arithmetic again.

A terminal recording of lat_report.py. Section 1: box c7g.medium, 1 vCPU (Neoverse-V1), Python 3.13.15, scikit-learn 1.9.1, OMP_NUM_THREADS=1; model test AP 0.5450, 202,700 bytes, sha256 bec4d11e50f58827; box on for 0.48 h at $0.0363 an hour, $0.0175, key pair, group and volume gone. Section 2: runs 1 to 5, load before 0.08, 0.09, 0.09, 0.10, 0.09, host steal 0.0% at most; run 0 excluded, I opened ssh to the box during it, 297 requests over 2 ms. Section 3, microseconds, median of 5 runs with the runs' range, then p95, p99 and share: request parse 19.3 (19.2 to 19.4), 124.1, 129.4, 1.8%; feature lookup 34.5, 44.6, 51.8, 3.2%; row build 7.2, 7.6, 8.5, 0.7%; predict 583.8 (581.9 to 585.7), 604.9, 632.0, 54.2%; response serialize 11.6, 13.1, 25.9, 1.1%; outside my handler 397.0, 442.5, 466.5, 36.9%; round trip 1075.9 (1073.9 to 1079.0), 1147.4, 1219.2; /noop round trip 333.6, 380.4, 386.1; outside the handler, 217.2 before it starts, 181.0 after it ends. Section 4: 104,885 of 105,000 scores equal to the Mac's to the bit, the rest differ by at most 2.8e-17. Section 5: body read over 50 us in 35.7% to 39.1% of requests, run 1 median 102 us versus 10; those requests waited 53 us less before the handler, and their round trip was 58 us longer. Section 6: one row 500.3 us a call; 1,000 rows 2555.7 us, 2.56 us a row; ratio 195x (194 to 198). Section 7: all seven guesses right. Section 8: the demo recorded on the box, round trip median 1067.7 us, predict 583.4, 5,500 of 5,500 equal; fact-check 9 of 9 quotes found. Section 9: 5 playground settings run. Last line: all 228 checks agree with the stored lab.

The report does not import the lab's code. It rebuilds every clock reading from the stored raw file with its own code, and it computes its own percentiles. Then it compares every number with the lab's results file, and all 228 checks agreed. It also checks the guesses, the demo's stored run and the playground further down.

Section 2 names one run I left out, run 0. I explain why on its own slide: I broke my own quiet rule during it.

The Headline: Where the Time Goes

Here is the main result. The bar is one request, from the moment the client sent it to the moment the client had the answer. Each piece is one step, as long as its median time.

A horizontal bar for one request, from client sends on the left to client has the answer on the right, with the medians of each step in order. Before my handler, 217.2 us; parse, 19.3 us; lookup, 34.5 us; row, 7.2 us; predict, 583.8 us, the longest piece by far; serialize, 11.6 us; after my handler, 181.0 us. Above the middle: inside my handler, 664.3 us. Below: the steps' medians add up to 1,055 us; the median round trip was 1075.9 us; medians of parts do not, in general, add up to the median of the whole. Caption: predict was 54% of the round trip; not my code at all, 37%.

At the median, one request took 1,075.9 microseconds, about one millisecond. Here is where that time went.

Predict took 583.8 microseconds, 54% of the round trip. It was the biggest single step, by far.

Work outside my handler took 397.0 microseconds, 37%. This is the time before my function started and after it finished. It covers the client sending, the loopback, uvicorn reading the and FastAPI finding my function, and the same things on the way back.

Everything else in my handler was small. Parsing the request took 19.3 microseconds, looking up the six features in SQLite took 34.5, building the row took 7.2, and writing the JSON answer took 11.6. Together that is less than 8% of the round trip.

The seven pieces' medians, before my handler, the five steps inside it and after it, add up to 1,055 microseconds, not 1,075.9. This is not an error. The median of each part comes from a different request, so the parts' medians do not, in general, add up to the median of the whole.

Each Step, Median and Tail

The median says what a normal request did. The p99 says what the slowest 1 in 100 did. This chart shows both for every step.

A bar chart of six steps with the median as a bar and the p99 as a dot, in microseconds, median of the five runs. Parse, median 19.3, p99 129.4. Lookup, 34.5, p99 51.8. Row, 7.2, p99 8.5. Predict, 583.8, p99 632.0. Serialize, 11.6, p99 25.9. Outside, 397.0, p99 466.5. Caption: the p99 of parse is 6.7x its median, the widest spread of any step.

For most steps, the p99 sits close to the median. Predict's p99 was 632.0 microseconds against a median of 583.8. The work outside my handler had a p99 of 466.5 against 397.0. On a quiet box, with one request at a time, most requests took about the same time.

Parse was the exception, in this setup. Its median was 19.3 microseconds, but its p95 was 124.1 and its p99 129.4. That is more than six times the median. A step that is tiny for most requests and much bigger for others is worth a closer look. I came back to it after the main results, on the slide about two humps, which gives a likely reason tied to this client and the shared core.

For the whole round trip, the p95 was 1,147.4 microseconds and the p99 1,219.2. So the slowest 1 in 100 requests took about 13% longer than the median one. Lesson 3 looks at the slowest requests in much more detail.

Inside My Handler, the Model Is the Work

Now look only inside my handler, from the first clock reading to the last.

An isometric drawing of five blocks, one per step inside the handler, each as tall as its median time. Parse 19.3, lookup 34.5, row 7.2 and serialize 11.6 are flat tiles; predict 583.8 is a tall tower. Below: height is the median time in microseconds; parse, lookup, row and serialize together, 72.6 us, 12% of predict's 583.8. Caption: inside the handler, the model is the work.

My handler took 664.3 microseconds at the median. Predict was 583.8 of them. The other four steps together took 72.6 microseconds, about 12% of predict's time.

I expected the database lookup to cost more. SQLite keeps the data in a file, and a lookup sounds like a trip to disk. But the table has 26,851 rows and a primary key on the customer and the date. A primary key builds an index, a sorted list that finds one row quickly, like the index at the back of a book.

After the warm-up, the operating system very likely keeps the whole small file in memory; I stated that in the design and did not test it. So one lookup cost 34.5 microseconds. A on another machine, reached over a real network, would likely cost more. This lab does not measure that.

Why is predict so slow next to the rest? The model has 54 small trees, and one row only has to walk down each tree once. That sounds like very little work. A slide further on, the preview of lesson 4, shows that most of predict's time is not the tree walking at all.

Before, Inside and After My Handler

The 397.0 microseconds outside my handler is a big share. Where in the request does it happen?

Three panels. Before: 217.2 microseconds, the client sends, the loopback, uvicorn reads and parses the HTTP, FastAPI finds my function. Inside: 664.3 microseconds, my handler, the five steps from t0 to t6. After: 181.0 microseconds, the framework sends the answer, the loopback, the client reads it. Below: Python's time.perf_counter is "the same for all processes" (the docs, checked), so the service's and the client's readings can be subtracted; outside my handler, median 397.0 us, 37% of the round trip. Caption: both processes share one CPU here, so outside also holds the switch between them.

The client and the service are two separate programs. Can I subtract a clock reading in one from a clock reading in the other? Only if they read the same clock. Python's documentation says of this clock: "The clock is the same for all processes." I checked it on the box too: both programs used the operating system's monotonic clock, a clock that only ever moves forward.

So I could split the outside part in two. Before my handler started: 217.2 microseconds. In this time the client wrote the request, the operating system passed it through the loopback, uvicorn read and parsed the , and FastAPI found my function. After my handler finished: 181.0 microseconds. In this time FastAPI and uvicorn sent the answer, and the client read it.

One part of this belongs to the box I chose. With one core, the operating system must stop the client to run the service, and then stop the service to run the client.

Whatever work the client still does after it sends a request cannot run at the same time as the service, so it lands in "before my handler". On a machine where each program has its own core, some of that work would overlap. I did not measure the switch or the client's leftover work on their own, so I cannot say how much of the 397.0 microseconds they are.

The Control: An Endpoint That Does Nothing

To size the outside part, I added a second address to the service, /noop. Its handler does no work at all: it returns two fixed bytes. The client timed it in the same way, 20,000 times per run. This is a control: the same measurement with the thing I study taken out.

A hand-drawn bar chart of three medians in microseconds: /score round trip 1075.9; /score outside 397.0; /noop round trip 333.6. Below: microseconds, median of 5 runs; the /noop handler itself took 0.4 us; so 333.6 us went on HTTP, the framework, the loopback and the client even when the handler did nothing; /noop is a GET with no body, so it is a close floor, not an exact one. Caption: the empty request cost 84% of the outside part of a real one.

The empty request took 333.6 microseconds at the median. Its handler took 0.4 of them. So almost all of that time is the cost of being a web service at all: , the framework, the loopback, the client, and the switch between programs.

That is 84% of the 397.0 microseconds outside my handler for a real request. The real request sends a small JSON body and gets a slightly longer answer, so it is not a perfect copy. But it tells me that most of the outside part is a floor. Making my handler faster would not touch it.

This is a useful habit in real work. Before you try to speed up a service, time an endpoint that does nothing. If the empty one is already slow, the problem is not in your handler.

Reading the Body Had Two Humps

This slide is a finding I did not plan. The lab labels it as asked after the results.

A hand-drawn histogram of how long my handler waited to read the request body, for all 100,000 measured requests, in bars 10 microseconds wide from 0 to 150. A tall group of bars between 0 and 20 microseconds, labelled fast hump, about 10 us, and a second group between 90 and 120 microseconds, labelled slow hump, about 100 us, with almost nothing between them. Below: bars are 10 us wide, a bin with too few requests to see is left empty; over 50 us, 36% to 39% of requests in each run; those requests had waited 53 us less before the handler started, and took 58 us longer in all, run 1, medians; seen after the results, cause not tested. Caption: the slow hump is where the p95 of parse, 124.1 us, comes from.

The parse step has two parts: reading the body bytes, and checking them. The checking took about 9 microseconds every time. The reading did not. In most requests it took about 10 microseconds. But in 36% to 39% of requests, in every run, it took about 100.

Then I looked at the same requests from the outside. The slow ones had waited 53 microseconds less before my handler started. So in those requests, my handler started earlier, and then waited inside for the body to be handed over. Much of the time moved from "before my handler" into "reading the body". But not all of it: the slow requests' round trip was 58 microseconds longer at the median.

I did not test why, but there is a likely reason, and it is a hypothesis only. Python's http.client, which my client used, sends a request's headers and its body in two separate send calls. It also turns off the network's habit of holding small pieces back to join them (TCP_NODELAY). I read this in Python 3.13's source.

So the headers can arrive first, the service can wake up and start my handler, and my handler then waits for the body. On one shared core, the client must run again to send the body. That fits the numbers: the slow group started about 53 microseconds earlier, read about 92 microseconds longer, and finished about 58 microseconds later. I report the two humps because they explain the parse step's wide p95 in this setup. A different client, or separate cores, could remove them.

Five Runs Agreed, and One Run I Left Out

Did the five runs agree? And what happened to run 0?

A dot chart of the round trip per run, in microseconds, with the median and p99 for each. Runs 1 to 5: medians all close to 1,076; p99 between 1,182 and 1,296. Run 0, not counted: median 1,080.5, p99 2,214.4. Below: runs 1 to 5, median 1073.9 to 1079.0 us, p99 1181.9 to 1296.0 us; run 0, median 1080.5, p99 2214.4, with 297 requests over 2 ms; during run 0 I had opened a connection to the box to check on it. Caption: the median barely moved; run 0's p99 was 1.8x the others'; my ssh session is the likely cause, not a proven one.

The five counted runs agreed closely. Their median round trips were between 1,073.9 and 1,079.0 microseconds. Their p99s were between 1,181.9 and 1,296.0.

Run 0 is the run I left out. While it was running, I opened a connection to the box, with ssh, a tool for working on another computer, to see if the lab was still going. That broke the quiet rule I had written into the design. Run 0 then had 297 requests over 2 milliseconds, 288 of them among its last 2,000 requests. The counted runs had 1 to 9 each. Its median was normal, 1,080.5, but its p99 was 2,214.4.

So I ran one more run, run 5, in the same way, with nobody connected. The headline uses runs 1 to 5. I kept run 0's raw timings and I show it here, so you can see what one extra small program did to the tail. My ssh session is the likely cause of run 0's slow requests, but I did not prove it.

Was the Box Quiet?

A timing is only as good as the machine it was taken on. Here is what the box reported for each counted run.

A table with one row per counted run and five columns: run, load before, host steal, median, p99. Run 1: 0.08, 0%, 1074.6, 1261.9. Run 2: 0.09, 0%, 1073.9, 1219.2. Run 3: 0.09, 0%, 1076.3, 1181.9. Run 4: 0.10, 0%, 1079.0, 1296.0. Run 5: 0.09, 0%, 1075.9, 1205.7. Below: load before is the 1-minute load average, the rule written first was at most 0.1; host steal is the share of CPU time the machine under the box took; times in us. Caption: quiet by the rule in every counted run.

Load before is the 1-minute load average just before the run started. The rule I wrote first was at most 0.10, and every counted run met it. The lab waited between 45 and 105 seconds each time for the box to settle.

Host steal is the share of time that the real computer under my rented box gave to someone else. A rented machine runs on a bigger shared computer, and when that computer is busy, it can take time away. Here it took none.

Were the answers right? A fast answer is no use if it is wrong. The model on the box returned 105,000 scores over the five runs, warm-up included. 104,885 of them were equal, to the last bit, to the score my laptop's copy of the model gave for the same customer. The other 115 differed by at most 0.000000000000000028, written 2.8e-17. My laptop and the box have different processors, and lesson 5 of the packaging chapter saw the same kind of tiny differences between machines.

The First Request Paid More

The warm-up requests were stored, so I can look at the very first request of each run.

A hand-drawn bar chart of the very first request of each run against the measured median, round trip in microseconds: run 1, 4,664; run 2, 4,743; run 3, 4,780; run 4, 4,659; run 5, 4,591; median, 1,076. Below: round trip in us; the first request of each run is in the warm-up and not in any other number; lesson 6 looks at why. Caption: about 4 times a normal request, every run.

The first request of every run took between 4,591 and 4,780 microseconds. That is more than four times a normal request. It happened in all five runs, so it is not luck.

This is why the design sends a warm-up first. If I had counted the first requests, they would have pulled the tail up. The lesson would then mix two questions: what a normal request costs, and what the first one costs. The first one is the topic of lesson 6, cold starts, so here I only show it and move on.

A Preview of Lesson 4: One Row or a Thousand

Predict was the biggest step. But what part of predict is the model's real work? To get a first idea, I timed predict_proba alone in a fresh program on the box, with no web service around it. I label this a preview: lesson 4 measures it properly.

A bar chart titled per row, a one-row call cost 195 times a row in a big call; a preview of lesson 4, predict_proba alone in a fresh process on the box, median of 5 processes in microseconds: 1 row in 1 call, 500.3; 1,000 rows in 1 call, 2,555.7; per row of those, 2.56, too small to see. Below: one row 500.3 us a call; 1,000 rows 2555.7 us a call, 2.56 us a row; a row alone cost 195 times a row in the big call, 194 to 198 over 5 processes; the answers were equal to the bit both ways. Caption: if a row's own work is about 2.56 us, about 497.8 of the 500.3 us is paid once per call.

On one row, predict_proba took 500.3 microseconds a call. On 1,000 rows at once, it took 2,555.7 microseconds, which is 2.56 microseconds per row. So a row on its own cost about 195 times as much as a row inside the big call. The five fresh processes all gave between 194 and 198 times.

What does that mean? Suppose the real work for one row is about 2.56 microseconds. Then, in this preview, about 497.8 of the 500.3 microseconds are paid once for every call, whatever the number of rows. Some of it is likely scikit-learn checking the input before the trees run, but I did not time the parts of predict_proba separately. Lesson 4 asks whether sending rows together, called batching, is worth it for a service.

One more thing I noticed and did not explain: predict took 583.8 microseconds inside the service, but 500.3 here, alone. The rows are the same kind of data. One possible reason is the processor's caches, small fast memories next to the core. In the preview, predict ran 5,000 times in a row and its code and data stayed in the caches. In the service, the client, uvicorn and SQLite ran between two predicts, and they may push predict's data out, so each predict starts with a cold cache. I did not test that.

My Guesses Before the Run, Checked

I wrote seven guesses into the lab before it ran. Here they are against the results.

  1. "Inside the handler, predict is the largest step, larger than all the other steps together." Right: 583.8 microseconds against 72.6.

  2. "The SQLite lookup takes under 50 microseconds at the median; parse and serialize each under 50 microseconds." Right: 34.5, 19.3 and 11.6.

  3. "The time outside the handler is at least a third of the round trip, and the /noop round trip is at least half of the /score round trip's outside-the-handler part." Right: 37%, and 333.6 is 84% of 397.0.

  4. "p99 is under 3x the median for the round trip on a quiet box." Right: 1,219.2 against 1,075.9.

  5. "The five runs agree: each run's median round trip within 10% of the others." Right: they were within 0.5%.

  6. "Preview: one row costs at least 50x the per-row cost of a 1,000-row call." Right: 195 times.

  7. "Every returned score equals this Mac's score to the bit or within 1e-15." Right: the largest difference was 2.8e-17.

All seven were right, and that made me suspicious, because a clean result can mean the check is too easy. So I looked harder at the raw data, and that is how I found the two humps in the body read and run 0's slow tail. Neither was in my guesses.

Try It Yourself

The full lab needs a rented box and five runs. I wrote a small demo, lat_demo.py, that builds the same service on your own computer and times it, in about a minute.

A page in four labelled zones, headed lat_demo.py, designed before it ran. Build: train the features chapter's model, put the test rows' features into SQLite, in a temporary folder. Serve: start the same handler under uvicorn in a second Python process. Send: 500 warm-up, then 5,000 measured requests, one at a time; the same to /noop. Print: median, p95, p99 and share for each step, and how many scores matched. Caption: on the box it printed a round trip of 1067.7 us and predict 583.4 us.

I wrote the demo's design into its docstring after the lab's design and before the demo first ran. It trains the model, puts the features into SQLite in a temporary folder, and starts the same handler in a second Python program. Then it sends 500 warm-up requests and 5,000 measured ones, and prints the table. After the main runs, I changed one printed line to fit the terminal. Later, a test run in an environment without uvicorn crashed with a long error. So I added a check of the packages, a wait for the service to answer, and one-line error messages. Nothing that is timed changed.

A real screenshot of VS Code with lat_demo.py open at the top of the file, showing its docstring: what it needs, how to run it, and the design written before it first ran.

Before you run this lab. The demo needs its own Python environment with the same library versions as the lab's box. Make it once, inside the scripts/labs/serving/examples folder:

python3 -m venv venv-sv
source venv-sv/bin/activate        # on Windows: venv-sv\Scripts\activate
pip install -r lat_requirements.txt

The file lat_requirements.txt pins numpy, pandas, pyarrow, scikit-learn, FastAPI, uvicorn and the rest to the lab's versions. I used Python 3.13. Then run python ../../features/fetch_data.py once. It downloads the shop data, about 46 MB, and writes one cleaned file. Now run python lat_demo.py. It needs no GPU and no cloud account. If a package is missing, or the service does not start, it prints one line saying why and stops.

Pick a Run, Pick a Percentile

This box holds the real timings from the lab. It needs nothing but Python, so it runs in your browser. It has no model and no clock: it only looks up what the box measured.

Press Run. Then change STAT to "p99" and see which steps grow. Try RUN = 3 or RUN = 5 to see one run on its own. Set ROWS = 1000 to see the preview of lesson 4.

The report script writes this box from the lab's stored results, runs it with five settings, and checks that it prints the lab's numbers. Each # in a bar is 20 microseconds, so you can see predict's long bar next to the short ones.

The Lab's Code, Piece by Piece

The lab has one file that runs on my laptop and a few small files that run on the box.

latency_anatomy.py, on my laptop, does the parts that need no clock. prepare trains the features chapter's model and stops unless its test AP is exactly 0.5450. Then it writes three files: the model, the features of every test row, and the shuffled list of requests with the score my laptop's model gives for each. provision installs Python and the libraries on the box and copies the files there. run, preview and demo start the timed parts over ssh and copy the raw timings back. collect does the arithmetic, and teardown deletes the box, its key and its network rule, then checks they are gone.

lat_files/service.py is the service. Its handler reads the clock seven times and stores the readings in memory. The client fetches them from a third address, /timings, after the run, so the timings never travel inside a measured answer.

lat_files/client.py builds every request body before it starts. Then it sends them one at a time over one connection that stays open, and reads the clock just before sending and just after reading the answer.

lat_files/run_once.sh waits until the box is quiet, starts a fresh service, runs the client, stops the service, and records the load average and the CPU counters before and after. is the lesson 4 preview.

How to Find the Time in Your Own Service

Here is the order I would use to look for the time in a slow service, using only what this lab did.

A flowchart. Time the round trip at the client leads to time each step inside the handler, which leads to a diamond: is the handler most of the round trip? Yes leads to: which step is biggest? start there. No leads to: time an empty endpoint the same way, then a diamond: is the empty one most of the outside part? Yes leads to: the framework and network are the floor. No leads to: something else sits between, find it. Below: here, the handler was 62% of the round trip and predict the biggest step; the empty endpoint was 84% of the outside part. Caption: medians and p99s from many requests, never one.

The chart starts from the outside, where the caller waits, and moves in one step at a time. Here are the same steps in words, each with the number from this lab behind it.

  1. Time the round trip where the caller is. Inside your code you only see part of the wait. Here, 37% of the time was outside my handler.

  2. Read the clock between every step of the handler. Use a precise clock, such as time.perf_counter_ns in Python, and keep the readings in memory, not in the answer.

  3. If the handler is most of the time, start with its biggest step. Here predict was 54% of the round trip, and the four other steps together under 8%. Making the lookup twice as fast would have saved about 17 microseconds out of 1,076.

  4. Time an endpoint that does nothing. Here it cost 333.6 microseconds. No change inside the handler can remove that.

  5. Send many requests and repeat the run. Report the median and the p99, and how much the runs differ.

  6. Keep the machine quiet, and check it. Here one ssh session was enough to move a p99.

When This Kind of Timing Helps, and When It Does Not

Use step timings before you change anything. Here, without them, I would have guessed the database lookup was a big cost. It was 3.2% of the round trip.

Use an empty endpoint whenever the outside part is big. It tells you what the framework and the network cost on their own. If that floor is too high for your users, you need a different setup, not a faster handler.

Use many requests and several runs. One request tells you nothing: the first one here cost more than four times a normal one. One run can be spoiled, as run 0 was.

Do not time on a busy machine. My laptop's load is why every number here comes from a rented box. If you must time on a shared machine, record its load before and after, and say so.

Do not read these numbers as a law. They are one small tree model, one request at a time, on a quiet 1-core box. A large neural network would likely spend more of its time computing, and a busy service spends time in queues. Measure your own service in the same way rather than borrowing my shares.

Do not copy my handler as it is into a busy service. It is an async def, but it calls SQLite and predict_proba, which block. With one request at a time that changes nothing. With many requests at once, a blocking call inside an async def stops the whole event loop until it returns.

FastAPI's documentation says: "When you declare a path operation function with normal def instead of async def, it is run in an external threadpool that is then awaited, instead of being called directly (as it would block the server)." So write it as a plain def, or move the blocking calls off the event loop. Lessons 5 and 7 measure what this does under load.

Do not trust a timing with no check that the answers are right. Here every score was checked against my laptop's model. A fast service that returns wrong scores is not fast, it is broken.

What This Lab Cannot Tell You

Two columns titled shows and cannot show. Shows: one small tree model, one request at a time, on one quiet 1-vCPU box; FastAPI, uvicorn and SQLite, all on the same machine as the client; 20,000 requests x 5 runs, medians and tails per step. Cannot show: a busy service, requests waiting behind each other (lesson 7); a real network, a load balancer, TLS, a remote feature store; other models, a big network may spend its time quite differently.

One box, one core. Everything here ran on one c7g.medium with one core, client and service together. On a machine with more cores, or with the client on another machine, the outside part would be different, and I did not measure that.

No real network. The requests used the loopback. A real network adds time for the distance, and a real service usually has TLS, the encryption behind https, and a in front. The survey lesson's request path includes them; this lab does not.

One request at a time. No request waited behind another. In a busy service, waiting is often the biggest cost. Lesson 7 measures it.

One model. The model is small and fast. A big model would spend more of each request in predict.

Labelled additions. The two humps, run 0's exclusion and run 5 were added after the first results, and the lab says so in its docstring. The demo's last printed line was reworded after the main runs.

What to Do on Monday

A hand-drawn grid of six cards, titled five habits. 1, time the whole: measure the round trip where the caller is, not only inside your code. 2, time the parts: a perf_counter_ns reading between every step of your handler. 3, time an empty endpoint: it shows the floor your framework and network put under every request. 4, many requests, many runs: medians and p99 from thousands of requests, and several runs. 5, keep the box quiet: nothing else running; one ssh session moved a p99 here. The reason: predict 54%, outside my handler 37%, everything else 7%. Caption: guessing where the time goes is how you speed up the wrong step.

If you take one thing to work on Monday, open the handler of a service you own and add a clock reading between every step. Store the readings, send a few thousand requests on a quiet machine, and look at the medians and the p99s. Then add an endpoint that does nothing, and time it too.

You may find, as I did, that the step you were worried about is small. A large part of the time may not be in your code at all.

A closing card titled a request is more than its model. 583.8 us: predict, 54% of the round trip, the biggest single step. 397.0 us: outside my handler, 37%, HTTP, the framework, the loopback, the client. 1075.9 us: the median round trip, one request at a time, on a quiet box.

The one idea to keep: a prediction request is more than its model. Here the model was the biggest single step, a little over half of the time. But more than a third of the time was spent before and after my code, and an empty request already cost almost a third of a millisecond. Measure every step, from the caller's side and from inside, before you decide what to make faster.

Knowledge Check

Knowledge Check

4 questions - Score 80% to pass

Q1

In the lab, which step of one request took the most time at the median?

Q2

What did timing the empty /noop endpoint show?

Q3

Why was run 0 left out of the headline numbers?

Q4

In the preview of lesson 4, how did one row alone compare with a row inside a 1,000-row call?

p99

It prints the load average of your computer first, because the timings are from your computer, as it is at that moment. A computer with faster cores than my rented box gives smaller times than the box did, and a busy computer gives larger and more spread-out times. The shares should look similar: predict the biggest step, and a big part outside the handler.

r"""Where does the time in one prediction request go? Time every step of a small model service on YOUR machine.

Lesson 2 of 'Serving and Inference Basics'. It needs its own Python environment with the lab's versions, made
once, inside this folder (Python 3.13 is what the lab used):
    python3 -m venv venv-sv
    source venv-sv/bin/activate              # Windows: venv-sv\Scripts\activate
    pip install -r lat_requirements.txt      # numpy, pandas, pyarrow, scikit-learn, fastapi, uvicorn, ...
It also needs the shop data from the features chapter: run python ../../features/fetch_data.py once first. Then:
    python lat_demo.py                 # print the step breakdown
    python lat_demo.py --save out.json # and save the numbers
If a package is missing, or the service does not start, it prints one line saying why and stops.
It takes about a minute. The timings it prints come from YOUR machine, as it is right now: if other programs are
busy, the numbers move. It writes only to a temporary folder (deleted at the end) and to out.json if you ask.

Design, written 2026-10-02 after the lab's design (latency_anatomy.py) and before this file first ran:
  1. Train the features chapter's model (it must score test AP 0.5450) and pickle it (protocol 5) into a temporary folder.
  2. Put every test row's six features into SQLite there: one row per (customer_id, cutoff), the primary key.
  3. Start the service in a SECOND Python process (this same file with --serve), under uvicorn, one worker, access
     log off. It has the lab's handler: read the body, validate it, look up the features, build a 1 x 6 row,
     predict_proba, write the JSON answer, with time.perf_counter_ns() between every step.
  4. From this process, over one kept-alive connection to 127.0.0.1, send one request at a time: 500 warm-up
     requests, then 5,000 measured ones (the lab sends 20,000 five times), then the same for GET /noop, an endpoint
     that does no work.
  5. Print, for each step, the median, p95 and p99 in microseconds, and its share of the median round trip.
The lab pins OMP_NUM_THREADS=1; this demo sets it too, before scikit-learn starts, for both processes.
Changed 2026-10-02 after the main runs and the stored box run, because a run in a Python environment without uvicorn
crashed with a long traceback: the packages are checked before anything starts, the client waits for GET /health
(60 s at most), and a missing package or a service that does not start ends with one plain line and exit code 1.
Nothing that is timed changed.

Author: Roni Das
Created: 2026-10-02
"""
import os

os.environ.setdefault("OMP_NUM_THREADS", "1")

import http.client  # noqa: E402
import json  # noqa: E402
import pickle  # noqa: E402
import socket  # noqa: E402
import sqlite3  # noqa: E402
import subprocess  # noqa: E402
import sys  # noqa: E402
import tempfile  # noqa: E402
import time  # noqa: E402
from pathlib import Path  # noqa: E402
from time import perf_counter_ns  # noqa: E402

import importlib.util  # noqa: E402

NEEDED = ("numpy", "pandas", "pyarrow", "sklearn", "fastapi", "uvicorn", "pydantic")
missing = [m for m in NEEDED if importlib.util.find_spec(m) is None]
if missing:
    sys.exit(f"lat_demo.py needs {', '.join(missing)}: make the environment in its docstring "
             f"(pip install -r lat_requirements.txt), then run it with that environment's python.")

import numpy as np  # noqa: E402

COLS = ("recency_days", "frequency", "money", "return_share", "tenure_days", "products")
N_WARM, N_MEASURED = 500, 5000


# ───────────── the service: runs in its own process, started with --serve <folder> <port> ─────────────
def serve(folder: Path, port: int) -> None:
    import uvicorn
    from fastapi import FastAPI, Request
    from pydantic import BaseModel
    from starlette.responses import Response

    class ScoreRequest(BaseModel):
        id: int
        customer_id: int
        cutoff: str

    model = pickle.loads((folder / "model.pkl").read_bytes())
    db = sqlite3.connect(folder / "store.sqlite", check_same_thread=False)
    sql = f"SELECT {', '.join(COLS)} FROM features WHERE customer_id = ? AND cutoff = ?"
    marks = []
    app = FastAPI()

    @app.post("/score")
    async def score(request: Request) -> Response:
        t0 = perf_counter_ns()
        body = await request.body()
        t1 = perf_counter_ns()
        req = ScoreRequest.model_validate_json(body)
        t2 = perf_counter_ns()
        feats = db.execute(sql, (req.customer_id, req.cutoff)).fetchone()
        t3 = perf_counter_ns()
        row = np.array([feats], dtype=np.float64)
        t4 = perf_counter_ns()
        p = float(model.predict_proba(row)[0, 1])
        t5 = perf_counter_ns()
        out = json.dumps({"id": req.id, "customer_id": req.customer_id, "cutoff": req.cutoff, "score": p}).encode()
        t6 = perf_counter_ns()
        marks.append((t0, t1, t2, t3, t4, t5, t6))
        return Response(content=out, media_type="application/json")

    @app.get("/health")
    async def health() -> Response:
        return Response(content=b"ok")

    @app.get("/noop")
    async def noop() -> Response:
        return Response(content=b"{}", media_type="application/json")

    @app.get("/marks")
    async def get_marks() -> Response:
        return Response(content=json.dumps(marks).encode(), media_type="application/json")

    uvicorn.run(app, host="127.0.0.1", port=port, workers=1, access_log=False, log_level="warning")


# ───────────── everything else runs here ─────────────
def build(folder: Path):
    from sklearn.metrics import average_precision_score
    here = Path(__file__).resolve().parent
    sys.path.insert(0, str(here.parents[1] / "features"))
    import task
    from what_a_feature_is import HAND_COLS, hgb, joined

    ev = task.load_events()
    lab_tr, _, lab_te = task.splits(ev)
    tr = joined(ev, lab_tr, task.TRAIN_CUTOFFS)
    te = joined(ev, lab_te, task.TEST_CUTOFFS)
    model = hgb(0).fit(tr[HAND_COLS].to_numpy(float), tr["label"].to_numpy())
    p = model.predict_proba(te[HAND_COLS].to_numpy(float))[:, 1]
    y, cut = te["label"].to_numpy(), te["cutoff"].to_numpy()
    ap = float(np.mean([average_precision_score(y[cut == c], p[cut == c]) for c in np.unique(cut)]))
    assert round(ap, 4) == 0.5450, ap
    (folder / "model.pkl").write_bytes(pickle.dumps(model, protocol=5))
    day = te["cutoff"].dt.strftime("%Y-%m-%d").to_numpy()
    con = sqlite3.connect(folder / "store.sqlite")
    con.execute("CREATE TABLE features (customer_id INTEGER, cutoff TEXT, "
                + ", ".join(f"{c} REAL" for c in COLS) + ", PRIMARY KEY (customer_id, cutoff))")
    con.executemany("INSERT INTO features VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                    [(int(c), d, *map(float, r)) for c, d, r in zip(te["customer_id"], day, te[HAND_COLS].to_numpy())])
    con.commit()
    con.close()
    order = np.random.default_rng(0).permutation(len(te))   # the lab's request order
    pairs = [(int(te["customer_id"].iloc[i]), str(day[i])) for i in order[: N_WARM + N_MEASURED]]
    return ap, pairs, p[order[: N_WARM + N_MEASURED]]


def wait_until_up(svc, port: int, log: Path, limit_s: float = 60.0) -> str:
    """Wait for GET /health. Return "" when it answers, else one line saying why it did not."""
    start = time.monotonic()
    while time.monotonic() - start < limit_s:
        if svc.poll() is not None:
            lines = [ln for ln in log.read_text(errors="replace").splitlines() if ln.strip()]
            return f"it exited with code {svc.returncode}: {lines[-1] if lines else 'no message'}"
        try:
            c = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
            c.request("GET", "/health")
            ok = c.getresponse().read() == b"ok"
            c.close()
            if ok:
                return ""
        except OSError:
            pass
        time.sleep(0.2)
    return f"no answer on port {port} after {limit_s:.0f} s"


def pct(x):
    return np.median(x), np.percentile(x, 95), np.percentile(x, 99)


def main() -> None:
    try:
        load = f"{os.getloadavg()[0]:.2f}"
    except (AttributeError, OSError):
        load = "not available on this system"
    print(f"timings below are from THIS machine: {os.cpu_count()} CPU(s), 1-minute load average {load}")
    with tempfile.TemporaryDirectory() as tmp:
        folder = Path(tmp)
        ap, pairs, expected = build(folder)
        print(f"model trained: test AP {ap:.4f}; {len(pairs):,} test (customer, cutoff) requests ready")
        with socket.socket() as s:
            s.bind(("127.0.0.1", 0))
            port = s.getsockname()[1]
        log = open(folder / "service.log", "w")
        svc = subprocess.Popen([sys.executable, __file__, "--serve", str(folder), str(port)],
                               stdout=log, stderr=subprocess.STDOUT)
        try:
            why = wait_until_up(svc, port, folder / "service.log")
            if why:
                sys.exit(f"the service did not start: {why}")
            conn = http.client.HTTPConnection("127.0.0.1", port, timeout=30)
            hdr = {"Content-Type": "application/json"}
            bodies = [json.dumps({"id": i, "customer_id": c, "cutoff": d}).encode() for i, (c, d) in enumerate(pairs)]
            rt, scores = [], []
            for b in bodies:
                a = perf_counter_ns()
                conn.request("POST", "/score", body=b, headers=hdr)
                data = conn.getresponse().read()
                rt.append((a, perf_counter_ns()))
                scores.append(json.loads(data)["score"])
            noop = []
            for _ in range(N_WARM + N_MEASURED):
                a = perf_counter_ns()
                conn.request("GET", "/noop")
                conn.getresponse().read()
                noop.append(perf_counter_ns() - a)
            conn.request("GET", "/marks")
            marks = np.array(json.loads(conn.getresponse().read()), dtype=np.int64)[N_WARM:]
        except OSError as e:
            sys.exit(f"the service stopped answering ({type(e).__name__}: {e}); see the docstring")
        finally:
            svc.terminate()
            svc.wait()
            log.close()
    c = np.array(rt, dtype=np.int64)[N_WARM:]
    us = lambda a: a / 1000  # noqa: E731
    steps = {
        "request parse": us(marks[:, 2] - marks[:, 0]),
        "feature lookup": us(marks[:, 3] - marks[:, 2]),
        "row build": us(marks[:, 4] - marks[:, 3]),
        "predict": us(marks[:, 5] - marks[:, 4]),
        "response serialize": us(marks[:, 6] - marks[:, 5]),
        "outside my handler": us((c[:, 1] - c[:, 0]) - (marks[:, 6] - marks[:, 0])),
        "round trip": us(c[:, 1] - c[:, 0]),
    }
    whole = np.median(steps["round trip"])
    print(f"\n{N_MEASURED:,} requests, one at a time, after {N_WARM} warm-up. Microseconds:")
    print(f"{'step':20s} {'median':>8s} {'p95':>8s} {'p99':>8s}  share")
    for name, x in steps.items():
        m, p95, p99 = pct(x)
        print(f"{name:20s} {m:8.1f} {p95:8.1f} {p99:8.1f}  {100 * m / whole:4.0f}%")
    nm, n95, n99 = pct(us(np.array(noop[N_WARM:])))
    print(f"{'/noop round trip':20s} {nm:8.1f} {n95:8.1f} {n99:8.1f}")
    same = int((np.array(scores) == expected).sum())
    print(f"\nscores equal to the model's own predict_proba: {same:,} of {len(scores):,}")
    print("Shares are of the median round trip. Medians of parts do not add up.")
    if "--save" in sys.argv:
        out = {"load": load, "cpus": os.cpu_count(), "test_ap": ap, "scores_equal": same,
               "steps": {k: dict(zip(("median", "p95", "p99"), map(float, pct(v)))) for k, v in steps.items()},
               "noop": {"median": float(nm), "p95": float(n95), "p99": float(n99)}}
        Path(sys.argv[sys.argv.index("--save") + 1]).write_text(json.dumps(out, indent=1))


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "--serve":
        serve(Path(sys.argv[2]), int(sys.argv[3]))
    else:
        main()

Here is the demo running on the box, recorded with a real terminal recorder. This exact run is stored in results/lat-demo-run.txt.

A terminal recording of python lat_demo.py run on the EC2 box. It prints: timings below are from THIS machine, 1 CPU, 1-minute load average 0.09; model trained, test AP 0.5450, 5,500 test requests ready. Then a table for 5,000 requests after 500 warm-up, in microseconds, median, p95, p99 and share: request parse 20.1, 124.6, 130.2, 2%; feature lookup 34.2, 44.8, 50.8, 3%; row build 7.2, 7.7, 8.1, 1%; predict 583.4, 603.7, 614.5, 55%; response serialize 11.8, 12.5, 26.7, 1%; outside my handler 395.0, 430.9, 446.6, 37%; round trip 1067.7, 1134.1, 1157.1, 100%; /noop round trip 325.3, 375.5, 380.3. Then: scores equal to the model's own predict_proba, 5,500 of 5,500; shares are of the median round trip, medians of parts do not add up. Caption: recorded on the box; this exact run is in results/lat-demo-run.txt.

The demo's numbers are close to the lab's: a round trip of 1,067.7 microseconds against 1,075.9, and predict 583.4 against 583.8. All 5,500 scores matched, because the demo trains its model on the same machine it serves on. The report script checks that the stored text and the demo's saved numbers agree.

And here is the same demo in VS Code on my laptop, so you can see what the output looks like on an ordinary computer.

A real screenshot of VS Code's terminal in the venv-sv environment after running python lat_demo.py on my laptop. It prints: timings below are from THIS machine, 10 CPUs, 1-minute load average 6.11; model trained, test AP 0.5450, 5,500 test requests ready. Then the table for 5,000 requests after 500 warm-up, in microseconds, median, p95, p99 and share: request parse 4.2, 6.4, 17.7, 1%; feature lookup 8.8, 12.8, 27.1, 3%; row build 1.0, 1.6, 3.3, 0%; predict 175.0, 220.4, 307.4, 54%; response serialize 2.6, 4.0, 6.4, 1%; outside my handler 131.6, 156.8, 262.1, 41%; round trip 323.0, 396.9, 591.1, 100%; /noop round trip 88.5, 133.0, 225.5. Then: scores equal to the model's own predict_proba, 5,500 of 5,500.

Do not compare these numbers with the box's. My laptop's cores are faster, and the client and the service each get their own core, so the times are smaller: a round trip of 323.0 microseconds here. With a load of 6.11, they also change from run to run, so I do not quote them anywhere else in this lesson. Look at the shape instead: predict is the biggest step, 54% here too, and a big part, 41%, is outside the handler.

lat_files/preview.py

lat_report.py recomputes everything from the raw timings and writes the playground.