LLM serving observability from scratch
An OpenAI-compatible endpoint looks the same whether it is healthy or seconds from falling over. I wanted to see the inside, so I put vLLM on my desktop’s RTX 5070 Ti, pointed Prometheus at its metrics, sent its OpenTelemetry traces to Jaeger, and pushed until the scheduler had to make decisions. Everything below is captured data from those runs, rendered from committed JSON, no dashboard screenshots. The code blocks are read from the actual files at build time, and the whole setup lives in scripts/vllm-observability if you want to run it yourself.
The stack
The observability side is three containers, no GPU needed, so rootless podman is enough.
services:
prometheus:
image: docker.io/prom/prometheus:latest
command:
- --config.file=/etc/prometheus/prometheus.yml
- --web.enable-lifecycle
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro,Z
grafana:
image: docker.io/grafana/grafana:latest
ports:
- "3000:3000"
environment:
# anonymous read-only so the dashboard is watchable from the LAN;
# editing needs the admin login
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro,Z
jaeger:
image: docker.io/jaegertracing/jaeger:latest
ports:
- "16686:16686" # UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP vLLM runs natively on the host and Prometheus reaches it through podman’s host alias.
global:
scrape_interval: 5s
scrape_configs:
# 1s scrape so captured replays have per-second resolution
- job_name: vllm
metrics_path: /metrics
scrape_interval: 1s
static_configs:
# vLLM runs natively on the host; host.containers.internal is podman's host alias
- targets: ["host.containers.internal:8000"]
- job_name: gpu
scrape_interval: 1s
static_configs:
- targets: ["host.containers.internal:9835"] One command serves the model, exposes /metrics, ships traces, and enables tool calls.
#!/usr/bin/env bash
# vLLM with Prometheus metrics (built-in at /metrics) and OTel traces to Jaeger.
# The observability stack must be up first: podman compose up -d
set -euo pipefail
cd "$(dirname "$0")"
MODEL="${MODEL:-unsloth/qwen3.5-4b}"
# FlashInfer JIT-compiles kernels at startup; CUDA 13.2 rejects Fedora 44's
# gcc 16 as host compiler, so point nvcc at the gcc15 compat package.
export NVCC_PREPEND_FLAGS='-ccbin /usr/bin/g++-15'
# vLLM does not set an OTel service name; without this, traces land in
# Jaeger under "unknown_service".
export OTEL_SERVICE_NAME=vllm
exec uv run vllm serve "$MODEL" \
--max-model-len 8192 \
--gpu-memory-utilization 0.7 \
--max-num-seqs 32 \
--max-num-batched-tokens 4096 \
--limit-mm-per-prompt '{"image": 0, "video": 0}' \
--enable-auto-tool-choice \
--tool-call-parser qwen3_xml \
--reasoning-parser qwen3 \
--otlp-traces-endpoint grpc://localhost:4317 \
--collect-detailed-traces all \
--port 8000 Getting an 8 GB model onto a 16 GB desktop card took three failed startups, and three of those flags are the scar tissue: the compiler override feeds FlashInfer’s kernel JIT,1 image inputs are off because profiling the vision encoder OOMs the card,2 and the memory target stays at 0.7 to leave room for the desktop itself.3 Without OTEL_SERVICE_NAME, traces land in Jaeger as unknown_service.
GPU metrics come from a 20-line nvidia-ml-py exporter instead of DCGM, which would want the GPU-in-container setup this machine does not have.
Serving 32 requests at once on one GPU
Every request being generated keeps its attention state in GPU memory, the KV cache, and that state grows by one entry per generated token. After the weights take their 8 GB, this card has room for about 33k tokens of it.
So 64 requests at concurrency 32, each generating up to 1024 tokens, cannot fit. At full depth the batch wants 32 × 1k, which is the entire cache.
The requests themselves are nothing special, and that is deliberate. The load generator round-robins eight short programming questions as chat completions, each allowed up to 1024 tokens. These are the real responses, captured one at a time on an idle server:
the eight prompts and what came back
947 completion tokens · finished on its own
thinking
This is a great question about data structures, specifically hash maps and collision handling. Let me provide a comprehensive explanation of how hash maps handle collisions, covering the main strategies used.
I should cover:
- What are collisions in the context of hash maps
- Different collision handling strategies
- How each strategy works with examples
- Trade-offs between different approaches
Let me structure this clearly with examples in code would be helpful.
Understanding Hash Map Collisions
A hash map is a data structure that maps keys to values using a hash function. When multiple keys hash to the same index, a collision occurs. Efficient collision resolution is essential for the performance and functionality of hash maps.
What Is a Hash Collision?
A hash map assigns each key to a bucket using a hash function. If two different keys produce the same index, they collide. For example, if the hash function maps both “sauf” and “bob” to index 3, the hash map must decide how to store both keys under the same index.
Common Collision Resolution Techniques
1. Chaining
Each bucket holds a list or linked structure of elements with the same index.
-
How It Works:
- When a key hashes to the same bucket as the next, it is added to a linked list or array.
- Accessing a value involves hashing and then traversing the list.
-
Example:
// Java-like structure class HashEntry { Key key; Object value; HashEntry next; } Map(int capacity, () { return list.get(hash(key)); } -
Performance:
- Average case: O(1) lookup
- Worst case: O(n) if all keys collide
-
Pros: Simple to implement, works in many scenarios.
-
Cons: Extra memory due to node structures, can degrade with poor hash functions.
2. Open Addressing
All elements are stored directly in the underlying array.
a) Linear Probing
-
Upon collision, search for the next free slot in the array.
-
Example:
[null, keyA, keyB, keyA, keyC] -
Pros: Better cache locality.
-
Cons: Produces clustering, limiting effectiveness.
b) Quadratic Probing
-
Uses the square of the index to find the next slot.
-
Formula:
(hash + i²) % tableSize -
Pros: Helps reduce clustering.
-
Cons: Still suffers from primary clustering; limited range.
c) Double Hashing
-
Uses a second hash function to compute probe steps.
-
Formula:
(hash + i * h2(key)) % tableSize -
Pros: Minimal clustering.
-
Cons: More complex to implement.
d) Treating Keys with Equality
- All identical keys map to the same index with a single call.
- Useful for sets when auto-detection is required.
3. Separate Chaining (Efficient Variants)
- Uses arrays of lists at each index.
- Often faster in modern implementations due to optimized memory usage.
Trade-offs Summary
| Technique | Time Complexity (Avg) | Time Complexity (Worst) | Space Overhead |
|---|---|---|---|
| Chaining | O(1) | O(n) | Higher |
| Linear Probing | O(1) | O(n) | Low |
| Quadratic Probing | O(1) | O(n) | Low–Moderate |
| Double Hashing | O(1) | O(n) | Moderate |
Summary
- Chaining is flexible and manageable but consumes more memory.
- Open Addressing uses less memory but can suffer from clustering.
- Choice of technique depends on expected load, key distribution, and memory constraints.
Most modern hash map implementations use chaining, separate chaining, or variants of open addressing, optimizing based on performance, memory, and use-case requirements.
Understanding these techniques allows developers to make informed decisions when implementing or using hash maps in practice.
1024 completion tokens · hit the 1024 token cap
thinking
Inverting a binary tree means swapping the left and right children of every node in the tree. This is a classic tree traversal problem.
There are several approaches to solve this:
- Recursion (most common and clean)
- Iterative using recursion (Queue)
- Iterative using recursion (Stack)
I’ll provide a recursive solution since it’s the most intuitive, but I’ll also mention iterative approaches.
Let me write:
- A TreeNode class definition
- The invert function
- Test cases to demonstrate it works
Here’s my plan:
- Define the TreeNode class
- Create the invert function using recursion
- Provide some test cases with sample trees
Inverting a Binary Tree
Inverting a binary tree involves swapping the left and right children of every node. The recursive solution is the most intuitive, but iterative approaches can also be used. Below is a complete Python implementation with the tree class, recursive inversion function, iterative alternatives, unit tests with left and right inverted trees, and time/space complexity analysis.
from collections import deque
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def invertTree(root):
"""
Invert (swap left/right) a binary tree recursively.
Args:
root (TreeNode): The root of the binary tree.
Returns:
TreeNode: The root of the inverted binary tree.
"""
if not root:
return None
# Swap the left and right children
root.left, root.right = root.right, root.left
# Recursively invert left and right subtrees
invertTree(root.left)
invertTree(root.right)
return root
Iterative Solution Using BFS (Queue)
def invertTreeIterative(root):
"""
Invert a binary tree iteratively using a queue (BFS).
Args:
root (TreeNode): The root of the binary tree.
Returns:
TreeNode: The root of the inverted binary tree.
"""
if not root:
return None
queue = deque([root])
while queue:
node = queue.popleft()
# Swap the children
node.left, node.right = node.right, node.left
# Add children to queue if they exist
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return root
Iterative Solution Using DFS (Stack)
def invertTreeDFS(root):
"""
Invert a binary tree iteratively using a stack (DFS).
Args:
root (TreeNode): The root of the binary tree.
Returns:
TreeNode: The root of the inverted binary tree.
"""
if not root:
return None
stack = [root]
while stack:
node = stack.pop()
# Swap the children
node.left, node.right = node.right, node.left
# Add children to stack if they exist
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
return root
Helper Function for Tree Visualization
def printTree(root):
"""
Helper to print the tree level-order traversal.
"""
if not root:
return []
result = []
queue = deque([root])
while queue:
node = queue.popleft()
if node:
result.append(str(node.val))
queue.append(node.left)
queue.append(node.right)
else:
result.append("None")
return result
##-demo with Test Cases
def createTree():
"""
Creates a sample binary tree:
4
/ \
2 7
/ \ \
1 3 6
"""
root = TreeNode(4)
root.left = TreeNode(2)
root.right = TreeNode(7)
root.left.left = TreeNode(1)
root.left.right = TreeNode(3)
root.right.right = TreeNode(6)
return root
def testInvert():
"""
Demonstrates inversion of a binary tree.
"""
print("Original Tree Values:", printTree(createTree()))
inverted = invertTree(createTree())
print("Inverted Tree Values802 completion tokens · finished on its own
thinking
This is a fundamental question about operating systems and concurrent programming. Let me explain the key differences between processes and threads clearly and comprehensively.
Key differences include:
- Memory space
- Resource sharing
- Isolation level
- Creation/termination cost
- Communication mechanisms
- Synchronization requirements
Let me structure a clear, comprehensive answer with examples.
Processes and threads are both fundamental building blocks for multitasking, operating, and concurrent programs. While both allow multiple units of execution, they differ significantly in structure, resource management, and behavior. Below is a detailed comparison between the two.
Core Differences
| Aspect | Process | Thread |
|---|---|---|
| Memory Space | Complete separate memory space | Shares address space of its parent process |
| Resource Ownership | Owns its own memory and system resources | Shares resources with parent process |
| Isolation | High isolation (protected memory) | Low isolation (communal memory access) |
| Creation/Destroy | Slower (requires system overhead) | Faster (uses lightweight kernel calls) |
| Communication | Inter-process communication (IPC) required | Threads communicate via shared memory |
| Synchronization | Not required by default | Requires synchronization (locks, semaphores) |
| Fault Tolerance | Crashes affect only the process | Crashes can affect the entire process |
| Context Switch | More expensive (memory mapping) | Less expensive (internal stack management) |
| OS Support | Built-in support in all OSs | Thread support depends on OS implementation |
Visual Analogy
- Process: Separate offices. Each has its own computers, desks, and files. Communication requires scheduling or messaging systems.
- Thread: Employees in the same office. They share computers and desks. Generally work together, but need coordination to avoid conflicting work.
When to Use Each
-
Use Processes When:
- Maximum isolation and security are needed
- One component needs to run independently from others
- You’re running legacy applications or systems with strict memory boundaries
-
Use Threads When:
- High performance and responsiveness are required
- Network and I/O threads should share the same resources
- The overhead of context switches matters
- Your application fits within the constraints of shared memory
Modern Context Switching
- Processes perform actions at the kernel level and memory load involves a full transition of the memory and address spaces.
- Threads are typically handled by the kernel and share the exact same memory space, making context switching significantly faster.
Example Comparison (C Language)
// Process creation
PID fork(pid);
for_each_process get_memory();
// Thread creation
void *func(void *arg);
thread_create(func);
In both cases, the use case determines whether to use processes or threads:
- Risk Aversion / Security → Use process
- Performance / Responsiveness → Use threads
Summary
| Process | Thread | |
|---|---|---|
| Purpose | Isolation and security | Performance & responsiveness |
| Resource | Independent | Shared |
| Fault Isolation | Hardware-level | Application-level |
| Communication | IPC mechanisms | Shared memory |
| Performance | Slower setup | Faster setup |
Understanding the differences between processes and threads helps developers make informed trade-offs when designing systems requiring concurrency, security, performance, and reliability.
1024 completion tokens · hit the 1024 token cap
thinking
Here’s a thinking process that leads to the explanation of TCP congestion control:
-
Deconstruct the Request:
- Topic: TCP Congestion Control.
- Goal: Describe how it works.
- Target Audience: Likely someone studying networking or wanting a technical overview. Needs to be accurate, structured, and clear.
-
Initial Brainstorming & Key Concepts:
- What is congestion control? It’s about not sending data faster than the network can handle to prevent packet loss.
- Key variables: Congestion Window (), Slow Start (), Congestion Avoidance (), Fast Recovery (), Fast Retransmit ($retrans`).
- Mechanisms: Loss-based vs. No-Loss (IDLE…). mostly loss-based in practice (RTO, ACKs).
- Performance metrics: RTT (Round Trip Time).
- History: Older TCP vs. Newer TCP (CBR/SCTP, but focus on standard TCP Reno/Cubic/ECN).
- PHASES: Three-Phase approach (Slow Start, Congestion Avoidance, Failure Detection, Recovery).
-
Structure the Explanation:
- Introduction: Define the problem (network congestion leads to loss) and the goal (stability).
- Core Variables: Explain (congestion window) and (slow start threshold).
- The Four Key Phases/States (The Heart):
- Slow Start.
- Congestion Avoidance.
- Fast Retransmit (Loss Detection).
- Fast Recovery (Recovery).
- Math/Updates: How does change in each state?
- Modern Enhancements: ADD-ECN (Explicit Congestion Notification), Cubic, BBR (Google’s work).
- Summary/Conclusion: Why is it important?
-
Drafting the Content - Section by Section:
-
Intro: Keep it simple. It prevents packet loss by adjusting sending rate. Mention the feedback loop (acknowledgments = good news, loss = bad news).
-
Variables:
- : How much data can be in flight.
- : The tipping point between slow start and linear growth.
-
State 1: Slow Start:
- Start small (s).
- Doubling with every acknowledgment.
- Goal: Find capacity quickly.
- Stops when reaching .
-
State 2: Congestion Avoidance:
- After Slow Start.
- Increase linearly (Additive Increase). Usually (e.g., +1 cwnd unit per RTT).
- This fills the pipe without hitting saturation hard (linear growth).
-
State 3: Congestion Detected (Loss):
- Triple Replication failure? No, Triple Duplication? Wait, Triple Duplicate is TCP 1.0. Modern is usually 3 Repeated ACKs (Fast Retransmit) or Timeout.
- Fast Retransmit/Recovery:
- Trigger: 3 Duplicate ACKs (usually) or 1 Timeout.
- Action: Increase to (set ).
- Recovery:
- Fast Retransmit (No Timeout): Increment by , add to .
- Slow Start (with timeout): Restart from slow start (conservative).
- Note: Need to make sure the distinction between Fast Retransmit and Slow Start Recovery is clear. Standard TCP Reno: 3 dup ACKs -> Fast Recovery (, AIMD). Timeout -> Slow Start.
-
State 4: Congestion Control Mechanisms/Algorithms:
- Widened the scope. Not just Simple Reno.
- Mention ECN (Threshold set, or IP level notification).
- BBR (Flow-based rate estimation based on bandwidth and loss proxy). This is crucial for modern “high-performance” discussions.
-
-
Refining and Polishing:
- Clarity Check: Are the transitions clear?
-
No answer. The whole token budget went to thinking.
454 completion tokens · finished on its own
thinking
This is a common SQL task. I’ll write a query to find duplicate emails in a users table. The query should return users who have more than one instance of the same email address.
There are a few approaches to this:
- GROUP BY email with HAVING COUNT(*) > 1
- Using self-join to find matches
- Using subquery with EXISTS
The most common and efficient way is using GROUP BY with HAVING clause. However, I should also consider what exactly we want to return - either just the duplicate email addresses or the actual user(s) with duplicate emails.
Let me provide both options with explanations:
- Just showing duplicate email addresses
- Showing all user records where email is part of duplicates
I’ll make sure the query is clear and well-commented.
SQL Query to Find Duplicate Emails in a Users Table
Option 1: Show Duplicate Email Addresses (Simple View)
SELECT email
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
Option 2: Show Full Records of Users with Duplicate Emails
SELECT u.*
FROM users u
GROUP BY u.email
HAVING COUNT(*) > 1;
Option 3: Show Duplicate Emails with Count and Membership Info
SELECT
email,
COUNT(*) AS duplicate_count,
GROUP_CONCAT(user_id) AS user_ids
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
Option 4: Show All Emails That Are Duplicates (One Row Per Duplicate)
SELECT email
FROM users
WHERE email IN (
SELECT email
FROM users
GROUP BY email
HAVING COUNT(*) > 1
);
Recommended: Use Option 1
For most use cases, including only the duplicate email addresses is the most efficient and scalable:
-- Find unique emails that appear more than once
SELECT email, COUNT(*) AS occurrence_count
FROM users
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY occurrence_count DESC;1024 completion tokens · hit the 1024 token cap
thinking
Here’s a thinking process that leads to the explanation of Bloom filters:
-
Deconstruct the Request:
- Topic: Bloom Filter.
- Tasks: Explain what it is, and explain when to use it.
- Target Audience: Likely a developer, data engineer, or CS student looking for a clear, concise understanding of the concept and its practical application.
-
Define the Core Concept (What is it?):
- Analogy/Visual: Start with a simple mental image (buckets with scribblers?). No, better to use hash functions mapping to bits.
- Technical Definition: A space-efficient probabilistic data structure.
- Key Mechanism: Uses a bit array + hash functions. More elements = more likely to add false positives.
- The Trade-off: False positives are allowed (it’s not possible to know for sure something isn’t there), but false negatives are impossible (if it says not there, it definitely isn’t there).
- Memory Efficiency: Very compact compared to hash tables or sets.
-
Address the Technical Details (How does it work?):
- Need to mention: Bit array (0s and 1s).
- Need to mention: Hash functions ( of them).
- Process: Insert -> Set bits. Query -> Check bits.
- Outcome:
- All bits set? Element might be in set (False Positive possible).
- One bit clear? Element definitely not in set (False Negative impossible).
-
Explain the Pros and Cons:
- Pros: Fast (), RAM efficient, simple to implement.
- Cons: False positives (can’t distinguish), size grows with .
-
Identify Use Cases (When to use it?):
- Scenario 1: Very little impact if you mistakenly say YES. Example: Blacklisting cups of coffee? No, banning SCAM MF domains?
- Scenario 2: Scrape security systems. Use for CAPTCHA or anti-spam.
- Scenario 3: Content filtering. Checking against a large blocklist of IPs/emails.
- Scenario 4: Fast queries on large datasets. Like caching (Redis).
- Examples: Facebook crayfish, Link rot detection, MySQL key filtering. Better examples: Deduplication, Access Control, Virus scanning.
- Specific example: LRU Cache? No. Key-Value store? Yes. Check if element belongs in a huge set? Yes.
-
Drafting the Structure:
- Introduction: High-level summary (probabilistic structure).
- How It Works: Step-by-step process.
- The Trade-off: Falsily Positive vs. False Negative.
- Why Use It (Pros/Cons): Performance vs. Accuracy.
- When to Use It: Concrete scenarios.
- Real-world Examples: Cache miss excuse? Security blacklisting? Gen. Unique IDs?
-
Refining the Explanation (Self-Correction/Polishing):
- Correction: Make sure to emphasize that it takes up less space.
- Correction: Clarify the difference between “exists” and “definitely doesn’t exist.”
- Refinement: Use an analogy. The “Bit Bucket” analogy is good.
- Accuracy Check: Mention that a Bloom filter has limited capacity and size must be chosen carefully (cardinality).
- Modern variation: Mention “Counting Bloom Filter” for elements removed (optional but helpful).
-
Final Review against the Prompt:
- Did I explain what it is? Yes.
- Did I explain when to use it? Yes.
-
Constructing the Output:
- Headline: Clear definition.
- Visual: ASCII art of the bit array.
- Mechanism: Insert and Query flows.
- The “Gotcha”: False Positives.
- Use Cases: List specific ones (Blacklists, Caching, Memory constraints).
- Code Snippet: Go Java Python? Just pseudo-code to illustrate the logic. Let’s stick to Python for accessibility.
- Summary: Quick wrap-up.
-
Final Polish: Check tone (informative, professional). Ensure formatting (bolding, lists) makes it readable. (Wait, user
No answer. The whole token budget went to thinking.
1024 completion tokens · hit the 1024 token cap
thinking
Here’s a thinking process that leads to the explanation of generation-based garbage collection:
-
Deconstruct the Request:
- Topic: Garbage Collection (GC).
- Specific Mechanism: Generational Collector.
- Goal: Explain how it works, the underlying logic, benefits, and potential drawbacks.
-
Initial Knowledge Retrieval & Structuring:
- What is a Generational GC? It sorts objects by age/lifespan (Generations 0, 1, 2).
- Why do it? Young objects are usually short-lived (Minor GCs). Old objects are usually long-lived (Major/Full GCs).
- The core philosophy: “Most things die young.”
- Key Operations: Allocation, Aging, Promotion, Scanning (Marking/Counting), Cleanup (Evacuation).
- Three Generations: Young (Ed/-New), Survivor (Old-ish), Old (Permanent).
-
Drafting the Content - Section by Section:
- Introduction: Define Generational GC. Mention the weak assumption (liveness): new objects are more likely to die young. Contrast with Full GC (please ignore unrelated details).
- The Concept of Generations:
- Gen 0 (Young): Very fast allocation, frequent garbage collection.
- Gen 1 (Middle): Promoted from Gen 0. Still too young to promote to Old.
- Gen 2 (Old): Promoted from Gen 1. Rarely promoted, usually survives long enough.
- Analogy: People in a house (Move from childhood to adulthood). Or simply “Newborns to Seniors.”
- The Mechanism (Step-by-Step):
- Allocation: Where does memory come from? (Ed, New, Survivor). Mention Pointer Compression/Concurrent Write barrier.
- Promotion: Reachable objects from young to next level.
- Collection: Iterating through generations (0 -> 1 -> 2).
- Scanning: Mark-and-Sweep, Mark-and-Compact, Incremental. (Keep it high level but accurate).
- The “Old Generation” vs. “Young Generation” Refinement:
- When do we collect Gen 0? (Most American/GC threads).
- When do we collect Gen 1? (Only when needed).
- When do we collect Gen 2? (Slow, expensive, but infrequent).
- Why it works (The Efficiency):
- Frequency: GC stress is mostly on Gen 0.
- Algorithm: Copying (often).
- Term: “Mor-grid” or “Poisson process” (No, keep it simple).
- Liveness assumption saving the day.
- Terminology:
- M-Sweep, E/M (Ed/Movable).
- Survivor Space.
- Promote.
- Evacuation.
- Specific Examples (Java/Ten/CLR): Mention how real-world runtimes like Java GC (G1, CMS, ZGC) use this.
- Drawbacks:
- Stop-The-World.
- Can’t handle cycles (sometimes) without other techniques (GC Roots).
- Failover overhead.
-
Refining the Explanation (Self-Correction/Optimization):
- Correction: Don’t get bogged down in specific algorithm implementations (like Mixed Tag or Hash Tables). Keep it conceptual.
- Clarity: Ensure the distinction between “Survivor Space” and “Old Generation” is clear.
- Visuals: Since I’m text-only, use ASCII art or clear bullet points to represent spaces.
- Focus: Emphasize the assumption that “objects live short.”
-
Final Polish (Reviewing against standard text):
- Make sure to mention “Copying GC” as it’s often integrated with Generational GC.
- Explain Promotion clearly.
- Mention “Ed (Edacation) or Survivor” space.
- Ensure the flow goes: Assumption -> Concept -> Process -> Benefits -> Downsides.
-
Constructing the Output: (This aligns with the provided good response).
- Intro: Basic definition.
- Generations: Gen 0/1/2.
- Key Terminology: Survivor Space,
No answer. The whole token budget went to thinking.
1024 completion tokens · hit the 1024 token cap
thinking
The ISO 8601 standard allows for various date formats, from full timestamps to daily dates. Let me think about what variations to support:
Common ISO 8601 formats:
- Full timestamp: 2023-01-15T14:30:45.580Z
- Date only: 2023-01-15
- Date with time: 2023-01-15T14:30:45
- With time zone: 2023-01-15T14:30:45+00:00
- Various time zones: 2023-01-15T14:30:45-05:00, 2023-01-15T14:30:45+05:30
- Milliseconds: 2023-01-15T14:30:45.123
The most commonly accepted formats are:
- YYYY-MM-DD (full date)
- YYYY-MM-DDTHH:mm:ss.fff or just YYYY-MM-DDTHH:mm:ss
- With optional timezone offset (Z or +HH:mm)
Here’s a pattern that covers the most common variations:
^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?$
Let me break this down:
- ^\d{4}-\d{2}-\d{2} : YYYY-MM-DD
- (?:T\d{2}:\d{2}:\d{2} : Time portion (optional)
- (?:.\d+)? : Milliseconds (optional)
- (?:Z|[+-]\d{2}:\d{2})? : Timezone (optional)
- $ : End of string
I should also consider whether to include milliseconds and various timezone formats that are actually used.
Here’s a regular expression that matches ISO 8601-compliant date-time formats. It supports the most common variations, including date-only, date with time, optional milliseconds, and timezone indicators (Z or ±HH:MM):
^\d{4}-\d{2}-\d{2}(?:T\d{\2}:\d{2}:\d{2}(?:\.\d{3})?)?(?:Z|[+-]\d{2}:\d{2})?$
Supported Formats
| Format | Example | Matched? |
|---|---|---|
| Date only | 2023-01-15 | ✅ |
| Date + time | 2023-01-15T14:30:00 | ✅ |
| Date + time + ms | 2023-01-15T14:30:45.123 | ✅ |
| UTC timezone | 2023-01-15T14:30:45Z | ✅ |
| Named timezone offset | 2023-01-15T14:30:45+05:30 | ✅ |
| Negative offset | 2023-01-15T14:30:45-05:00 | ✅ |
Notes
Z(UTC) and\d{2}:\d{2}(± hour:minute) are both supported for timezones.- Milliseconds are optional and support up to 3 digits (
\d{3}). - All patterns are anchored (
^and$) to match the full input string. - This regex does not include fractional seconds beyond 3 digits or extended datetime values like
+HH MM(with space).
Alternative Regex Variants
- For maximum compatibility with strict ISO 8601 compliance (without optional milliseconds and timezone), use:
^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$ - For relaxed parsing (including timezone-offset formats, but not milliseconds), try:
^\d{4}-\d{2}-\d{2}T
Five of the eight hit the 1024 token cap and the shortest still took 454 tokens. A reasoning model thinks long by default, and that is exactly what fills the KV cache.
The load generator streams these and measures the two standard serving metrics per request. TTFT, time to first token, is how long until the first token arrives. TPOT, time per output token, is how fast tokens stream after that.
$ uv run loadgen.py --requests 64 --concurrency 32 --max-tokens 1024 --json-out capture/overload-requests.json
requests: 64 ok, 0 failed, wall 43.4s
throughput: 1439.2 output tok/s, 1.47 req/s
ttft p50 147ms p90 1863ms p99 5344ms
tpot p50 16.4ms p90 19.1ms mean 17.1ms Note 64 ok, 0 failed. Overloading a serving stack does not produce errors, it produces latency. The server admits what fits, queues the rest, and every request eventually completes. The cost shows up in TTFT, which is 147ms at the median but 5.3 seconds at p99, while TPOT stays flat at 17ms. The timeline shows why. Drag across any chart.
One overload run, captured from Prometheus at 1s resolution. Drag to inspect any moment, or press replay.
The KV cache fills, and once it is full the scheduler starts evicting running requests to make room, which is the preemptions panel. An evicted request keeps its place but has to recompute its cache when it gets back in. Requests that cannot start at all sit in the waiting queue, and that queue time is exactly the exploding TTFT. A request at the back waits seconds for its first token, then streams at the same 17ms per token as everyone else. Under overload, TTFT is queueing, not compute.
The GPU panels show the other half. A request has two phases. Prefill processes the whole prompt in one parallel pass, then decode generates tokens one at a time. This run is almost all decode, and every generated token reads essentially all 8 GB of weights once, so the memory bus saturates before anything else. The GPU’s compute units, the SMs (streaming multiprocessors) in the chart, sit at 100% while the memory bus is above 90%. That is this card doing all it can.
A single request decodes at about 87 tok/s. The batch sustains 1439 tok/s. That gap is continuous batching: while one request waits on memory for its next token, the same weight reads serve the other 31.
Tracing one conversation with tool calls
The overload run showed the whole herd under pressure. This is the opposite experiment, one conversation on an idle server with a single trace following it end to end. The same queue, prefill and decode from above reappear inside every vLLM span, now for one request you can name.
To make the trace worth reading I gave the model a job it cannot do alone, solving to ten decimal places. Thinking alone, a 4B model burns thousands of tokens on longhand Newton iteration for four correct decimals at best. With a python tool it writes the method, runs it, and answers exactly.
The tool is model-written code in a throwaway container with no network:
proc = subprocess.run(
["podman", "run", "--rm", "-i", "--network=none", "--memory=256m",
"--pids-limit=64", "docker.io/library/python:3.12-alpine", "python", "-"],
input=code.encode(), capture_output=True, timeout=60,
) The agent loop opens a root span, every model turn and tool execution becomes a child, and vLLM joins the same trace through the W3C traceparent header. One session, one trace.
gpu during span busy 79% · memory 12.9GiB · power 183W
- task
- Solve x^x = 10. Give the numeric answer to 10 decimal places.
gpu during span busy 67% · memory 13.0GiB · power 113W
decision call the python tool
I need to solve the equation x^x = 10 for x. This is a transcendental equation that doesn’t have a closed-form solution, so I’ll need to use numerical methods.
Let me think about the approach:
- Define f(x) = x^x - 10
- Find the root of this function using numerical methods like Newton’s method or binary search
Let me write a Python script to solve this numerically.
import numpy as np
from scipy.optimize import fsolve, newton
# Define the function f(x) = x^x - 10
def f(x):
return x**x - 10
# Use fsolve to find the root
x = fsolve(f, 2.0)[0] # Initial guess of 2.0
print(f"x = {x}")
print(f"x^x = {x**x}")
# Verify the solution
print(f"Check: x^x = {x**x}")
print(f"Difference from 10: {abs(x**x - 10)}") - completion_tokens
- 261
gpu during span busy 67% · memory 13.0GiB · power 113W
331 tok in → 261 tok out · TTFT 63ms · decode 87 tok/s
- gen_ai.request.id
- chatcmpl-998d0dc55def4b7b
- gen_ai.request.max_tokens
- 6000
- gen_ai.request.n
- 1
- gen_ai.request.temperature
- 0.6000
- gen_ai.request.top_p
- 1
gpu during span busy 100% · memory 13.0GiB · power 232W
import numpy as np
from scipy.optimize import fsolve, newton
# Define the function f(x) = x^x - 10
def f(x):
return x**x - 10
# Use fsolve to find the root
x = fsolve(f, 2.0)[0] # Initial guess of 2.0
print(f"x = {x}")
print(f"x^x = {x**x}")
# Verify the solution
print(f"Check: x^x = {x**x}")
print(f"Difference from 10: {abs(x**x - 10)}") Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'numpy' gpu during span busy 80% · memory 13.0GiB · power 222W
decision call the python tool
The numpy module is not available. Let me try a different approach using only standard library or implement the solution manually. I can use a numerical method like bisection or Newton’s method with basic Python math operations.
import math
# Define the function f(x) = x^x - 10
def f(x):
return x**x - 10
# Use Newton's method to find the root
def newton_method(f, df, x0, tol=1e-15, max_iter=100):
x = x0
for i in range(max_iter):
fx = f(x)
if abs(fx) < tol:
return x
dfx = df(x)
if dfx == 0:
break
x_new = x - fx / dfx
if abs(x_new - x) < tol:
return x_new
x = x_new
return x
# Derivative of x^x - 10
def df(x):
# d/dx(x^x) = x^x * (1 + ln(x))
return x**x * (1 + math.log(x))
# Initial guess
x = newton_method(f, df, 2.0)
print(f"x = {x}")
print(f"x^x = {x**x}")
print(f"Difference from 10: {abs(x**x - 10)}")
# Round to 10 decimal places
print(f"x rounded to 10 decimal places: {round(x, 10)}") - completion_tokens
- 377
gpu during span busy 80% · memory 13.0GiB · power 222W
543 tok in → 377 tok out · TTFT 60ms · decode 86 tok/s
- gen_ai.request.id
- chatcmpl-b7f81bd54681d6ea
- gen_ai.request.max_tokens
- 6000
- gen_ai.request.n
- 1
- gen_ai.request.temperature
- 0.6000
- gen_ai.request.top_p
- 1
gpu during span busy 100% · memory 13.0GiB · power 234W
import math
# Define the function f(x) = x^x - 10
def f(x):
return x**x - 10
# Use Newton's method to find the root
def newton_method(f, df, x0, tol=1e-15, max_iter=100):
x = x0
for i in range(max_iter):
fx = f(x)
if abs(fx) < tol:
return x
dfx = df(x)
if dfx == 0:
break
x_new = x - fx / dfx
if abs(x_new - x) < tol:
return x_new
x = x_new
return x
# Derivative of x^x - 10
def df(x):
# d/dx(x^x) = x^x * (1 + ln(x))
return x**x * (1 + math.log(x))
# Initial guess
x = newton_method(f, df, 2.0)
print(f"x = {x}")
print(f"x^x = {x**x}")
print(f"Difference from 10: {abs(x**x - 10)}")
# Round to 10 decimal places
print(f"x rounded to 10 decimal places: {round(x, 10)}") x = 2.5061841455887692
x^x = 10.0
Difference from 10: 0.0
x rounded to 10 decimal places: 2.5061841456 gpu during span busy 100% · memory 13.0GiB · power 213W
decision call the python tool
The solution to x^x = 10 is approximately 2.5061841456 when rounded to 10 decimal places.
Let me verify this result is accurate by checking the calculation more precisely.
x = 2.5061841456
print(f"x^x = {x**x}")
print(f"Difference from 10: {abs(x**x - 10)}")
print(f"Rounded to 10 decimal places: {round(x, 10)}") - completion_tokens
- 191
gpu during span busy 100% · memory 13.0GiB · power 213W
956 tok in → 191 tok out · TTFT 93ms · decode 85 tok/s
- gen_ai.request.id
- chatcmpl-8e9b84c0d44127eb
- gen_ai.request.max_tokens
- 6000
- gen_ai.request.n
- 1
- gen_ai.request.temperature
- 0.6000
- gen_ai.request.top_p
- 1
gpu during span busy 100% · memory 13.0GiB · power 234W
x = 2.5061841456
print(f"x^x = {x**x}")
print(f"Difference from 10: {abs(x**x - 10)}")
print(f"Rounded to 10 decimal places: {round(x, 10)}") x^x = 10.000000000215488
Difference from 10: 2.1548807183080498e-10
Rounded to 10 decimal places: 2.5061841456 gpu during span busy 100% · memory 12.8GiB · power 220W
decision answer directly
The result is verified. The value x = 2.5061841456 gives x^x ≈ 10.0000000002, which is extremely close to 10 (difference of about 2.15 × 10⁻¹⁰).
The solution to x^x = 10 is 2.5061841456 (to 10 decimal places).
The solution to the equation is:
x = 2.5061841456 (to 10 decimal places)
This value satisfies the equation with x^x ≈ 10.0000000002, which is within 2.15 × 10⁻¹⁰ of the target value of 10.
- completion_tokens
- 201
gpu during span busy 100% · memory 12.8GiB · power 220W
1188 tok in → 201 tok out · TTFT 132ms · decode 85 tok/s
- gen_ai.request.id
- chatcmpl-84ccc844bc2b9e7d
- gen_ai.request.max_tokens
- 6000
- gen_ai.request.n
- 1
- gen_ai.request.temperature
- 0.6000
- gen_ai.request.top_p
- 1
The whole agent session as one distributed trace, GPU telemetry from the same capture aligned on top. Click any span. Model turns carry the thinking and the decision, tool spans carry the exact code and its stdout, vLLM spans break down into queue, prefill and decode.
Click through and the session tells its own story. The model reaches for numpy, the sandbox answers ModuleNotFoundError, the next thinking block switches to the standard library, and the last span carries the answer. The GPU works during every model turn and idles during every tool call.
The answer, with the Lambert W function, came out exact to all ten places in three tool calls and 1030 completion tokens. The longhand attempts spent 14k tokens for four decimals.
What the gen_ai spans actually contain
The per-request latency split that powers the phase bars above is the most useful thing vLLM’s tracing emits, and it is not in the OpenTelemetry GenAI semantic conventions at all. The conventions moved to their own repository and are still marked Development, so this is a comparison against a moving target. Checked as of July 2026, on vLLM 0.24.0, the current release, and the attribute names are unchanged on vLLM main. What vLLM emits versus what the spec says:
| vLLM emits | the spec says |
|---|---|
span name llm_request | {operation} {model}, so chat unsloth/qwen3.5-4b |
| — | gen_ai.operation.name, gen_ai.provider.name, the only two required attributes |
gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens | renamed to gen_ai.usage.input_tokens, gen_ai.usage.output_tokens |
| — | gen_ai.usage.reasoning.output_tokens |
gen_ai.request.id holding the completion id | that is gen_ai.response.id |
gen_ai.latency.time_in_queue, time_in_model_prefill, time_in_model_decode | — |
| — | gen_ai.response.time_to_first_chunk, measured from the client |
This is not a stale corner of vLLM either. The community keeps adding to the nonstandard set rather than renaming it toward the spec.
The empty cells tell the story. vLLM’s queue, prefill and decode timings have no equivalent in the spec, and the spec’s only timing attribute is measured from the client. The conventions treat the model as a black box over the wire, so the view from inside the server has no standard home. The empty cell in the reasoning row points the opposite direction. The spec can report thinking tokens separately, vLLM never fills that in, and this model spends most of its output tokens on thinking.
The mismatch matters because backends key off the standard names. Datadog’s LLM Observability ingests OTel GenAI spans natively and maps gen_ai.request.model, gen_ai.usage.input_tokens and gen_ai.provider.name into its own schema for latency, tokens and cost. Dynatrace’s AI Observability does the same over OTLP, alongside OpenLLMetry and OpenInference. Point either at vLLM’s spans as they are and none of that special handling should fire, because the span name is wrong, the required attributes are absent, and the token counts hide under the old names. The traces still arrive as plain spans, but the LLM-specific views have nothing to grab. Until vLLM catches up with the spec, the practical fix is renaming attributes in an OTel collector transform between vLLM and the backend.
I work at Dynatrace. This is a personal post written on my own time and hardware, not reviewed or endorsed by my employer, and both vendor descriptions above come from public documentation.
Footnotes
-
FlashInfer JIT-compiles its CUDA kernels with nvcc on first startup, and CUDA 13.2 refuses Fedora 44’s gcc 16 as a host compiler. Fedora packages
gcc15for exactly this case, andNVCC_PREPEND_FLAGS='-ccbin /usr/bin/g++-15'points every nvcc invocation at it. The first startup spends ten minutes compiling, later ones reuse the cache. ↩ -
Qwen3.5-4B is a vision-language model. At startup vLLM profiles worst-case memory including the vision encoder at default batch limits, and that profiling pass alone OOMs a 16 GB card even though the weights are only 8 GB.
--limit-mm-per-prompt '{"image": 0, "video": 0}'skips the vision path entirely. ↩ -
GNOME and Chrome hold one to two fluctuating gigabytes of VRAM that vLLM’s
--gpu-memory-utilizationaccounting cannot see. At 0.8 the engine OOMed while allocating the KV cache, at 0.7 everything fits with the KV cache taking what is left, about 33k tokens. ↩