Session8 Python FastAPI SystemDesign
Session8 Python FastAPI SystemDesign
Python Internals · FastAPI Deep Dive · System Design for ML · DSA for ML Roles
The Global Interpreter Lock is a mutex that protects access to Python objects — only ONE thread can execute
Python bytecode at a time in a single process. CPython (the standard Python) uses reference counting for
memory management, and the GIL protects those reference counts from race conditions.
• Threading in Python does NOT give CPU parallelism for CPU-bound work
• Two threads trying to run Python code simultaneously: one runs, one waits for the GIL
• Threading DOES help for I/O-bound work: while one thread waits for I/O (network, disk), it releases the GIL
— other threads run
• This is why async/await (single thread, event loop) is preferred over threading for I/O-bound FastAPI
• multiprocessing: separate processes, each with own GIL — true CPU parallelism (Gunicorn workers)
• NumPy/PyTorch: C extensions release the GIL during computation — matrix operations run in parallel
• Cython with nogil: write performance-critical sections in Cython, release GIL explicitly
• Single Uvicorn process: GIL limits CPU parallelism — but async I/O still works well
• Gunicorn + multiple workers: bypasses GIL — each worker is a separate process, own GIL
• PyTorch model inference: releases GIL during C++ computation — can overlap with Python
"The GIL is a mutex in CPython that allows only one thread to execute Python bytecode at a time. For
CPU-bound work like model inference in pure Python, threading gives no parallelism — threads take turns. For
I/O-bound work like database calls or API requests, threads work fine because waiting for I/O releases the GIL.
In my deployment I bypass the GIL using Gunicorn — each worker is a separate OS process with its own GIL,
giving true CPU parallelism. PyTorch's model inference itself releases the GIL during C++ computation, so
Python code can run concurrently with the GPU/CPU computation. That's why run_in_executor works for
offloading inference."
• Async (asyncio): many I/O-bound tasks, single thread, no GIL issue, lowest overhead. Best for FastAPI, web
scrapers, API clients.
• Threading: I/O-bound work with blocking libraries that don't support async (old DB drivers, legacy code). GIL
released during I/O.
• Multiprocessing: CPU-bound work needing true parallelism. Separate memory space — no shared state.
Use for: parallel data preprocessing, model training on multiple datasets.
• Rule: async first, threading if library doesn't support async, multiprocessing for CPU-heavy.
CPython uses reference counting as primary mechanism: every object tracks how many references point to it.
When count reaches 0, object is immediately deallocated. Problem: circular references (A → B → A) never
reach 0. Solution: cyclic garbage collector (gc module) runs periodically to detect and break cycles. del keyword
decrements reference count — if 0, immediately frees memory. Unlike Java GC, Python's reference counting is
deterministic.
Q: What is a Python generator and when would you use one for ML?
A generator is a function that yields values one at a time instead of returning all at once. Uses lazy evaluation —
values computed on demand. Key for ML: data loading. Instead of loading 500K records into memory at once, a
generator yields one batch at a time. PyTorch DataLoader uses generators internally. Also: processing large log
files line by line without loading entire file.
Q2 What are decorators in Python? Write one from scratch.
A decorator is a function that takes another function as input, wraps it with additional behaviour, and returns the
modified function. Syntactic sugar for: func = decorator(func). Uses: logging, timing, caching, authentication,
retry logic, rate limiting.
• @torch.no_grad(): disables gradient tracking during inference — saves memory and computation
def timer(func):
start = [Link]()
return result
return wrapper
@timer
def decorator(func):
@[Link](func)
try:
except Exception as e:
[Link](delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
class RateLimiter:
self.last_call = 0
@[Link](func)
[Link](self.min_interval - elapsed)
self.last_call = [Link]()
return func(*args, **kwargs)
return wrapper
@lru_cache(maxsize=1000)
"A decorator is a higher-order function — it takes a function, wraps it with additional behaviour, and returns the
wrapped version. The @ syntax is shorthand for func = decorator(func). In my ML projects I use decorators
constantly: FastAPI's @[Link]('/predict') registers my route handler, @torch.no_grad() disables gradient
tracking during inference saving memory, and I wrote a custom @retry decorator for OpenAI API calls that
retries up to 3 times with exponential backoff on rate limit errors. The key implementation detail is
[Link] — without it the wrapper function loses the original function's name and docstring, which breaks
debugging and FastAPI's auto-documentation."
• @classmethod: receives cls (class itself). Can create alternative constructors. Model.from_config(config).
• @staticmethod: receives neither. Pure utility function — logically belongs to class but doesn't need instance
or class. Preprocessing helper methods.
Without @[Link](func): the wrapper function replaces the original — its __name__ is 'wrapper', its
__doc__ is None, its __module__ is the decorator's module. FastAPI reads __name__ to generate route IDs and
OpenAPI docs. If two routes have the same name ('wrapper'), FastAPI raises an error. [Link] copies all
metadata from the wrapped function to the wrapper.
Context managers define setup and teardown for a with block. __enter__ runs at start, __exit__ runs at end
(even on exception). Used for: file handling, DB connections, GPU memory management, timing. ML use:
torch.no_grad() is a context manager — disables grad on enter, re-enables on exit. Write with @contextmanager
decorator from contextlib for simple cases.
SECTION B — GENERATORS, COMPREHENSIONS & DATA
STRUCTURES
What is a generator? How does it differ from a list? Write a data pipeline using
Q3
generators.
• Generator computes each value only when next() is called — lazy evaluation.
• Cannot index a generator (gen[5] fails). Can only iterate once — not reusable.
• Each call to next() runs until the next yield — then pauses, saving local state.
• Data loading: yield one batch at a time from disk — never load full dataset into RAM
import pandas as pd
import numpy as np
def preprocess(chunks):
chunk = [Link]()
yield chunk
def extract_features(chunks):
y = chunk['price'].values
yield X, y
def train_pipeline(filepath):
cleaned = preprocess(chunks)
batches = extract_features(cleaned)
"A generator is a lazy iterator — instead of computing all values upfront like a list, it computes each value on
demand when next() is called. A list of 1 million items takes 8MB; the equivalent generator takes 200 bytes. For
ML, this is critical: I use generators to load training data in batches from disk rather than loading 500K rows into
RAM at once. I chain generators for each preprocessing step — load chunk, clean, extract features — and each
step processes one batch at a time. The entire pipeline uses constant memory regardless of dataset size.
PyTorch's DataLoader uses this same pattern internally."
• List comprehension [x for x in ...]: when you need the full list, will access multiple times, need len(), indexing.
• Generator expression (x for x in ...): when iterating once, large data, memory matters, chaining with other
iterators.
• map(func, iterable): functional style, lazy like generator, slightly faster than generator expression for simple
functions, less readable.
• Rule: generator expression for data pipelines, list comprehension when you need the whole result.
• [Link](gen, n): take first n items from generator — for testing pipeline with subset
• [Link](*iterables): flatten multiple generators into one — merge multiple CSV generators
• [Link](iterable, n) [Python 3.12]: group items into batches of size n — easier than manual
chunking
• [Link](a, b): Cartesian product — grid search hyperparameter combinations without nested loops
SECTION C — FASTAPI INTERNALS & PRODUCTION PATTERNS
• 1. ASGI (Async Server Gateway Interface): async-native framework. Requests handled as coroutines —
event loop juggles thousands of concurrent I/O-bound requests without threads.
• 2. Pydantic v2: data validation compiled to Rust — 5-50x faster than pure Python validation. Every request
body is validated and typed at near-C speed.
Dependencies are functions that run before your route handler and inject shared resources. FastAPI resolves
them automatically — call the same dependency from 100 routes, it runs once per request (or once per app with
Depends(func, use_cache=True)).
• @asynccontextmanager on lifespan function: code before yield runs at startup, after yield runs at shutdown
• Never load model per request — would take seconds and defeat the purpose of serving
import [Link]
# ■■ Lifespan: load model once at startup ■■■■■■■■■■■■■■■■■■■■■■
@asynccontextmanager
# STARTUP
[Link] = [Link].load_model('models:/CarPrice/Production')
[Link] = load_scaler('[Link]')
yield
# SHUTDOWN
[Link]('Shutting down')
app.add_middleware(CORSMiddleware,
allow_origins=['[Link]
@[Link]('http')
start = [Link]()
return response
class CarFeatures(BaseModel):
@validator('year')
return v
class Prediction(BaseModel):
price_lakhs: float
confidence: str
model_version: str
return [Link]
return [Link]
@[Link]('/predict', response_model=Prediction)
features: CarFeatures,
model=Depends(get_model),
scaler=Depends(get_scaler)
):
import asyncio
executor = ThreadPoolExecutor(max_workers=4)
def _inference():
X = engineer_features(features)
X_scaled = [Link](X)
return float([Link](X_scaled)[0])
loop = asyncio.get_event_loop()
model_version='1.3')
@[Link]('/health')
"FastAPI is fast for three reasons. First, it's built on ASGI and Starlette — async-native, so the event loop
handles thousands of concurrent I/O-bound requests without threads. Second, Pydantic v2 compiles data
validation to Rust — every incoming request body is validated and typed at near-C speed. Third, it uses
dependency injection to manage shared resources. In production I load the model once at startup using the
lifespan context manager and store it in [Link] — never per request. Dependencies inject the model into
route handlers without coupling them to global state. I also add request logging middleware to track every
endpoint's latency in CloudWatch."
Pydantic is a data validation library using Python type annotations. Problem: JSON from clients can contain
wrong types, missing fields, invalid values. Without Pydantic: you manually check 'if year not in data: raise error'.
With Pydantic: define a class with typed fields, FastAPI automatically validates incoming JSON against it and
returns a detailed 422 error if invalid. Also handles serialisation — Python objects to JSON. Pydantic v2 (Rust
core) is 5-50x faster than v1.
API key auth (simple): check X-API-Key header in a dependency, raise 403 if invalid. JWT auth: decode and
verify JWT token in dependency, extract user claims. OAuth2: FastAPI has built-in OAuth2PasswordBearer. For
ML APIs: API key is most common — generate keys per client, store hashed in DB, verify in dependency
injected into protected routes.
Middleware runs for EVERY request/response, regardless of route — good for logging, timing, CORS,
compression, auth headers. Dependencies run for SPECIFIC routes they're injected into — good for route-level
auth, model loading, DB sessions. Middleware sees raw request/response objects. Dependencies have access
to typed, validated data. Use both: middleware for cross-cutting concerns, dependencies for route-specific logic.
SECTION D — ML SYSTEM DESIGN
• Data privacy: can data leave the server? (determines logging policy)
Architecture — 5 components:
• Nginx: reverse proxy, SSL termination, rate limiting (10 req/min per IP)
2. Model Serving:
• Request batching: collect requests for 50ms, run as batch — 3-5x throughput improvement
• Cache predictions: hash(input features) → prediction. 30% hit rate = 30% less compute.
• CloudWatch alarms: p99 latency > 2s, error rate > 1%, CPU > 70%
Client --> Nginx (rate limit, SSL) --> FastAPI (validate) --> Redis cache?
Cache MISS:
ThreadPoolExecutor ([Link])
CloudWatch: CPU > 70% for 2min --> ASG alarm --> launch new EC2
New EC2: UserData script --> pull Docker image from ECR
"I'd design this in five layers. Client requests hit Nginx first for SSL termination and rate limiting. FastAPI
validates the request with Pydantic — invalid inputs return 422 immediately without touching the model. Next,
Redis cache check: hash the input features, if we've seen this exact input before return the cached prediction
instantly. Cache miss goes to the model: for fast inference I offload to a thread pool with run_in_executor, for
slow inference I return a job ID and process via Celery. All predictions are logged with latency and input hash to
CloudWatch for monitoring. The infrastructure runs on EC2 behind an ALB with Auto Scaling — CloudWatch
CPU alarm at 70% adds instances automatically. Minimum 2 instances across two availability zones for high
availability."
Traffic splitting: ALB routes X% to model A server, (100-X)% to model B server. Or: within one server, use
feature flags — hash user_id % 100, if < 20 use model B. Log which model version served each request.
Compare: latency, error rate, downstream business metric (conversion rate, click-through). Statistical
significance test (t-test, Mann-Whitney) before declaring a winner. Gradual rollout: 5% → 20% → 50% → 100%.
MLflow Model Registry: Staging → Production → Archived. Serving code always loads 'Production' stage — no
hardcoded model paths. To deploy new model: register in Registry, test in Staging (shadow mode — run
alongside production, compare outputs), promote to Production via Registry UI or API. Rollback: demote new
model, promote old model. Zero code changes needed.
Run new model in parallel with production model — both receive the same requests, but only the production
model's response is returned to the user. New model's predictions are logged and compared offline. Lets you
validate new model on real production traffic with zero risk. After confirming new model performs better, promote
it to production.
Q: How do you handle a model that takes 30 seconds to run inference?
Async task queue is mandatory — cannot hold HTTP connection open for 30 seconds (timeout). Pattern: POST
/predict → return {job_id} immediately with 202 Accepted. Celery worker processes inference. Client either polls
GET /result/{job_id} every 5 seconds, or gets a WebSocket push when complete. Store result in Redis with
TTL=1hr. This pattern scales to arbitrary inference time.
SECTION E — DSA PATTERNS FOR ML ENGINEERING INTERVIEWS
What DSA topics come up in ML engineering interviews? What patterns must you
Q6
know?
ML engineering roles ask lighter DSA than pure SWE roles — but you WILL get coding questions. Focus:
efficient data processing, tree traversal (decision trees), graph problems (dependency graphs), sliding window
(time series), hash maps (feature lookups). Complexity analysis matters — O(n log n) vs O(n²) for 500K records.
• 1. Hash Map / Dict: O(1) lookup for feature encoding, count occurrences, group by
• 2. Sliding Window: time series feature extraction, moving averages, rolling statistics
• 4. Binary Search: find optimal hyperparameter in sorted search space, find percentile
brand_stats[row['brand']].append(row['price'])
result = []
window_sum = sum(predictions[:window])
[Link](window_sum / window)
[Link](window_sum / window)
return result
import bisect
lo, hi = 2, len(silhouette_scores) + 2
hi = mid
else:
lo = mid + 1
return lo
# ■■ PATTERN 4: Two pointers — merge sorted feature lists ■■■■■■■
result, i, j = [], 0, 0
else: [Link](b[j]); j += 1
order = []
while queue:
if node in deps:
in_degree[s] -= 1
if in_degree[s] == 0: [Link](s)
return order
Q7 Given a list of predictions and actual values, compute RMSE without using sklearn.
Tests: (1) Do you understand what RMSE actually computes, not just call a function? (2) Can you write clean,
efficient Python? (3) Do you handle edge cases (empty list, division by zero)? (4) Numpy vectorised thinking.
import numpy as np
import math
if len(actuals) != len(predictions):
if len(actuals) == 0:
a = [Link](actuals, dtype=np.float64)
p = [Link](predictions, dtype=np.float64)
if [Link] != [Link]:
class StreamingRMSE:
self.n += 1
@property
# Usage: compute RMSE over streaming data without storing all values
rmse = StreamingRMSE()
for actual, pred in zip(actuals_stream, predictions_stream):
[Link](actual, pred)
print(f'RMSE: {[Link]:.4f}')
"I'd write it in three steps matching the formula: RMSE equals square root of mean of squared differences. For
production I'd use NumPy vectorised operations — convert to arrays, subtract element-wise, square, take mean,
square root. But I'd also show I understand edge cases: validate equal lengths, handle empty inputs, handle
NaN values with a mask. If the dataset is too large to fit in memory, I'd use the streaming version — accumulate
sum of squared errors and count incrementally, compute RMSE at the end. This avoids loading all predictions
into RAM."
SECTION F — PHP, MYSQL & BACKEND (ApexPlanet Internship)
You worked with PHP and MySQL at ApexPlanet. What did you build and what did
Q8
you learn about scalable system design?
This is a defend-and-connect question. You need to: (1) Describe what you built, (2) Show you learned
transferable concepts, (3) Connect it to ML engineering.
• SQL queries: SELECT, JOIN, WHERE, GROUP BY, ORDER BY, LIMIT
• Indexes: B-tree index makes SELECT WHERE fast — O(log n) vs O(n) full scan
• N+1 query problem: fetching parent then N children with N separate queries — fix with JOIN
• Connection pooling: reuse DB connections instead of opening new one per request
• Feature store: SQL database stores pre-computed features for training and serving
• Prediction logging: log every inference to MySQL for monitoring and retraining
• Scalable design: the same patterns (indexing, connection pooling, caching) apply to ML serving databases
f.total_spend, [Link]
FROM features f
ORDER BY f.user_id;
-- ■■ Aggregation for feature engineering ■■■■■■■■■■■■■■■■■■■■■■
SELECT user_id,
COUNT(*) AS purchase_count,
AVG(amount) AS avg_order_value,
MAX(created_at) AS last_purchase,
FROM orders
GROUP BY user_id
-- Without index: full table scan O(n). With index: O(log n).
SELECT model_version,
FROM predictions p
GROUP BY model_version;
■ SAY THIS — Word for word
"At ApexPlanet I built backend features in PHP with MySQL — form processing, user authentication, CRUD
operations for a web application. The most valuable thing I learned was thinking about database performance:
we had a page that was running 50+ queries per page load due to N+1 problems — fetching each user's order
history in a loop. I refactored it to a single JOIN query and added an index on user_id, which cut page load from
3 seconds to 200ms. This directly maps to ML engineering: I log every prediction to a MySQL table, and without
a proper index on model_version and created_at, my monitoring query that computes weekly RMSE would be a
full table scan on millions of rows. Same problem, same fix."
SESSION 8 — MASTER QUICK REFERENCE
Topic One-line core answer Key fact / code
Threads vs Processes vs Async: I/O-bound single thread. Threads: I/O with blocking FastAPI: async. Model:
Async libs. Multiprocessing: CPU-bound. multiprocessing.
ASGI async, Pydantic v2 (Rust validation), Starlette base, Pydantic v2: 5-50x faster than
FastAPI speed
dependency injection. v1
Nginx → FastAPI → Redis cache → ThreadPool inference Async queue (Celery) for > 2s
ML system design
→ CloudWatch logging → ASG scaling inference
New model runs in parallel with prod, predictions logged Before promoting to Production
Shadow mode
not served. Zero-risk validation. stage
ALB traffic split or user_id hash routing. Log model version Gradual: 5% → 20% → 50% →
A/B testing models
per request. Stats significance test. 100%
O(1) lookup for feature encoding, frequency counting, Counter, defaultdict from
Hash map pattern
groupby aggregation. collections
ALL 8 SESSIONS COMPLETE — You now have a comprehensive interview preparation guide covering every
topic on your resume.