Visual Studio and Rider Profilers: CPU Usage, .NET Object Allocation, Instrumentation, Database and Async Tools
IDE profilers give the fastest results on a developer machine; Visual Studio's Database tool shows an N+1 as hundreds of short queries with the same text. On Bookstore's My Orders page we measure, by reading the same events from the command line, that lazy loading sends 80 SQL commands per request and clogs the thread pool, raise throughput 30-fold with Include and 41-fold with a projection, and place the Copilot Profiler Agent and Rider's dotTrace/dotMemory counterparts.
Version note: The code in this post was built and run with .NET 10 (runtime 10.0.15 in the container), Entity Framework Core 10.0.15 (SqlServer and Proxies packages), a SQL Server 2025 container, dotnet-trace and dotnet-counters 10.0.750501, Microsoft.Diagnostics.Tracing.TraceEvent 4.0.0 and k6 2.4.0 for load. Visual Studio 2026 and Rider were not run on this machine; the IDE steps are taken from Microsoft Learn and the JetBrains documentation, and the measurements were taken by reading the same events from the command line.
Guess First
For its "My Orders" page, Bookstore, the online bookstore used as the shared example across this site's series, has an endpoint that returns a customer's last 20 orders, the items of each order and the book titles. Every order has 3 items. The code uses EF Core with lazy loading on. How many SQL commands does this page send to the database in a single request: 1, 21, 61, 81? Second question: between fixing the N+1 with Include and switching to a projection that selects only the columns the page needs, is there a throughput difference, and if so, how big? The answers are in the output of the Database section and in the fix table.
The Visual Studio 2026 Performance Profiler: Which Tool for What?
Visual Studio has two entry points for profiling. The Diagnostic Tools window that opens while you debug (F5) shows CPU, memory and .NET Counters graphs and works together with breakpoints. The real measurement is done from Debug > Performance Profiler (Alt+F2): the app starts without the debugger, preferably as a Release build, and the results open as a report when the session ends. Microsoft Learn also says the most accurate numbers come this way, with a Release build; the debugger alone changes timings because it intercepts exception and module load events. In Visual Studio 2026 the launch page splits the tools into two tabs: those that can run together are under Flexible, those that must run alone under Standalone; with Copilot enabled, a Recommended tab also reviews your code and suggests tools.
| Tool | Question it answers | Note |
|---|---|---|
| CPU Usage | Which methods use the CPU time? | Sampling; has flame graph and hot path views |
| .NET Object Allocation Tracking | Which type is allocated, from where, how much? | Only in the Performance Profiler, together with GC data |
| Instrumentation | Exactly how many times was a method called, and how long did it take by wall clock? | Exact numbers, waits included; high overhead |
| .NET Async | When did async work start, how long did it take? | Shows the async flow on a timeline |
| Database | Which queries ran, how often, how long? | ADO.NET and EF Core; .NET Core/5+ projects |
| .NET Counters | What state are the runtime and ASP.NET Core metrics in? | The in-IDE counterpart of dotnet-counters |
| Events Viewer | Which HTTP request, log and exception happened when? | On the same timeline as the other tools |
According to the table on Learn, the CPU Usage, .NET Object Allocation, .NET Async, .NET Counters, Events Viewer and Database tools also work with Linux and WSL as the target. Visual Studio can also open .nettrace files taken with dotnet-trace; that is the shortest path to inspecting a trace from production with the IDE's views. Your own sessions are saved as .diagsession and can be reopened later.
The Case: My Orders and Lazy Loading
For this post I added a small data layer to the lab that connects to SQL Server: 2,000 books, 500 customers, 40 orders per customer, 3 items per order. Without a connection string this part never kicks in, and the endpoints from earlier posts work as before:
// 07: SQL Server only when a connection string is given (ConnectionStrings__Bookstore)
string? connectionString = builder.Configuration.GetConnectionString("Bookstore");
if (!string.IsNullOrEmpty(connectionString))
{
builder.Services.AddDbContext<BookstoreContext>(options => options
.UseSqlServer(connectionString)
.UseLazyLoadingProxies());
}
The endpoint is nothing but LINQ that looks clean at first sight:
// slow version: the order list is loaded once, items and books are lazy loaded one by one
app.MapGet("/api/customers/{id:int}/orders", async (int id, BookstoreContext db, CancellationToken ct) =>
{
List<Order> orders = await db.Orders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.PlacedAt)
.Take(20)
.ToListAsync(ct);
return orders.Select(o => new OrderSummary(
o.Id, o.PlacedAt, o.Status,
o.Items.Sum(i => i.UnitPrice * i.Quantity),
o.Items.Select(i => new OrderLine(i.Book.Title, i.Quantity, i.UnitPrice)).ToList())).ToList();
});
o.Items and i.Book are virtual navigation properties; the lazy loading proxy goes to the database the first time they are accessed. That is also why it slips through code review: the query line is not visible. The mechanics are covered in the EF Core series post "Loading Related Data in EF Core: Include, Explicit and Lazy Loading, Split Queries"; here we focus on finding it with a profiler. I connected the app to SQL Server in the lab's Linux container and applied 20 virtual users of load for 20 seconds:
docker run -d --name profseries-api --cpus 4 --memory 2g -p 5481:8080 \
-e ConnectionStrings__Bookstore="Server=host.docker.internal,1433;Database=BookstoreDb;User Id=sa;Password=<password>;TrustServerCertificate=True" \
profseries-api:lab07
http_req_duration..............: avg=203.13ms p(50)=187.99ms p(95)=370.57ms p(99)=498.21ms max=717.23ms
http_reqs......................: 1979 98.114997/s
A p95 of 371 ms and 98 requests per second for a page of 20 orders. A page at this speed will cause trouble on the first campaign day in production.
Finding the N+1 with the Database Tool
In Visual Studio the steps are short: Alt+F2, check the Database box (if you cannot select it, you are trying to combine it with Standalone tools; clear the others), Start, call the page a few times, Stop Collection. The report lists the queries that ran in chronological order in a table; clicking the Duration column puts the longest first, and right-clicking a query and choosing Go To Source File takes you to the code that ran it. Selecting a time range on the graph narrows the table to the queries in that range. The signature of an N+1 is easy to see in this table: hundreds of short queries with the same text and only a different parameter.
The data behind this tool is not magic: it is the command events that EF Core and SqlClient publish through DiagnosticSource. dotnet-trace 10 has a ready-made profile for the same data: database. Since there is no Visual Studio on this machine, I produced the same table from the command line:
docker exec profseries-api dotnet-trace collect -p 1 --profile database \
--providers Microsoft.AspNetCore.Hosting --duration 00:00:00:07 -o /tmp/db-lazy.nettrace
Each command is two events in the trace: CommandExecuting with the command text and CommandExecuted with its duration, both arriving as DiagnosticSource's Activity2/Start and Activity2/Stop events. I added a db command to Bookstore.TraceStats from 06 that matches them by activity ID and groups them by command text:
source.Dynamic.AddCallbackForProviderEvent("Microsoft-Diagnostics-DiagnosticSource", "Activity2/Start", e =>
{
if (Argument(e, "CommandText") is string sql)
running[e.ActivityID] = sql;
});
source.Dynamic.AddCallbackForProviderEvent("Microsoft-Diagnostics-DiagnosticSource", "Activity2/Stop", e =>
{
if (!running.Remove(e.ActivityID, out string? sql))
return;
TimeSpan duration = TimeSpan.TryParse(Argument(e, "Duration"), out TimeSpan d) ? d : TimeSpan.Zero;
(int count, TimeSpan total) = byCommand.GetValueOrDefault(sql);
byCommand[sql] = (count + 1, total + duration);
});
requests 402, commands 32195, 80.1 commands per request
23753 0.55 ms avg 13.1 s total
SELECT TOP(1) [b].[Id], [b].[Isbn], [b].[Price], [b].[Title] FROM [Books] AS [b] WHERE [b].[Id] = @p
8040 0.58 ms avg 4.6 s total
SELECT [o].[Id], [o].[BookId], [o].[OrderId], [o].[Quantity], [o].[UnitPrice] FROM [OrderItems] AS [o] WHERE [o].[OrderId] = @p
402 137.99 ms avg 55.5 s total
SELECT TOP(@p) [o].[Id], [o].[CustomerId], [o].[PlacedAt], [o].[Status] FROM [Orders] AS [o] WHERE [o].[CustomerId] = @id ORDER BY [o].[PlacedAt] DESC
The answer to the guess: 80 commands per request. One order list, 20 item queries for 20 orders and book queries for 60 items; the theoretical total is 81, but when the same book shows up twice in a request, EF Core's identity resolution does not query it again. The 7-second trace came out at 115 MB, because the database profile carries the text and arguments of every command; keep it short in production.
There is something odd in the table: the order list, the only async query, takes 138 ms on average, while thousands of lazy load queries take half a millisecond. The query itself is not slow; the same query finishes within a few milliseconds per request in the fixed versions we will see shortly. The difference lies in what the measured time covers.
.NET Counters: What the N+1 Does to the Thread Pool
Visual Studio's .NET Counters tool shows in the IDE what dotnet-counters shows. While the load was running I watched EF Core's meter and the thread pool metrics:
docker exec -it profseries-api dotnet-counters monitor -p 1 \
--counters "Microsoft.EntityFrameworkCore,System.Runtime[dotnet.thread_pool.queue.length,dotnet.thread_pool.thread.count]"
[Microsoft.EntityFrameworkCore]
microsoft.entityframeworkcore.active_dbcontexts ({dbcontext}) 15
microsoft.entityframeworkcore.compiled_query_cache_hits ({hits}) 495,280
microsoft.entityframeworkcore.compiled_query_cache_misses ({misses}) 6
microsoft.entityframeworkcore.queries ({query}) 495,286
[System.Runtime]
dotnet.thread_pool.queue.length ({work_item}) 14
dotnet.thread_pool.thread.count ({thread}) 7
(Output trimmed.) The Microsoft.EntityFrameworkCore meter has existed since EF Core 9, and lazy loads count towards the queries counter too; in the 20-second measurement the counter's 5-second increase was 39,671, which divided by 98 requests per second again gives 80 per request. The real clue is in the thread pool: with 7 threads running, 14 work items are waiting in the queue. Lazy loading does synchronous I/O; code that touches o.Items blocks the thread until the answer arrives. With 20 concurrent requests holding the threads, the continuation of the async order query waits its turn in the queue, and most of the 138 ms EF Core measured is that wait. Diagnosing thread pool starvation and why new threads are added slowly is covered in the Debugging and Problem Solving series post "Diagnosing Hangs and Slowness: dotnet-stack, dotnet-trace and Thread Pool Starvation". What matters here: an N+1 eats not only database round trips but the app's own capacity as well.
The Fix: Include or Projection?
I wrote both fixes as separate endpoints and measured them under the same load. The first is Include, which loads the related data up front:
// fix 1: load the graph eagerly with Include
app.MapGet("/api/customers/{id:int}/orders-include", async (int id, BookstoreContext db, CancellationToken ct) =>
{
List<Order> orders = await db.Orders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.PlacedAt)
.Take(20)
.Include(o => o.Items).ThenInclude(i => i.Book)
.ToListAsync(ct);
return orders.Select(o => new OrderSummary(
o.Id, o.PlacedAt, o.Status,
o.Items.Sum(i => i.UnitPrice * i.Quantity),
o.Items.Select(i => new OrderLine(i.Book.Title, i.Quantity, i.UnitPrice)).ToList())).ToList();
});
The second is a projection that selects the fields the page needs inside the query:
// fix 2: project only the columns the page needs, in a single query
app.MapGet("/api/customers/{id:int}/orders-v2", async (int id, BookstoreContext db, CancellationToken ct) =>
await db.Orders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.PlacedAt)
.Take(20)
.Select(o => new OrderSummary(
o.Id, o.PlacedAt, o.Status,
o.Items.Sum(i => i.UnitPrice * i.Quantity),
o.Items.Select(i => new OrderLine(i.Book.Title, i.Quantity, i.UnitPrice)).ToList()))
.ToListAsync(ct));
I compared the JSON output of the three endpoints for the same customer and verified the values are identical. Results under the same 20 virtual users and 20 seconds of load; CPU from the increase of dotnet-counters' dotnet.process.cpu.time:
| Version | Requests/s | p95 | p99 | Commands/request | App CPU |
|---|---|---|---|---|---|
| Lazy loading | 98 | 370.6 ms | 498.2 ms | 80 | 1.3 cores |
Include | 2,997 | 11.7 ms | 17.6 ms | 1 | 3.6 cores |
| Projection | 4,032 | 8.1 ms | 11.2 ms | 1 | 2.4 cores |
The answer to the second guess: yes, there is a difference. Include raised throughput 30-fold; the projection added another 35 percent on top, and did it with less CPU. The projection's single query looks like this in dotnet-trace (trimmed): the sum of the items is a subquery, items and book titles come through a LEFT JOIN, and only Title is read from the Books table.
requests 3646, commands 3646, 1.0 commands per request
3646 1.76 ms avg 6.4 s total
SELECT [o2].[Id], [o2].[PlacedAt], [o2].[Status], [o2].[c], [s].[Title], [s].[Quantity], [s].[UnitPrice], ...
Include spends more CPU because it fully materializes the entities, registers them with the change tracker and, with lazy loading on, creates a proxy for each. If the page only reads, a projection cuts both network traffic and app work. Row multiplication when Include joins several collections (cartesian explosion) and the AsSplitQuery option are covered in the EF Core series post "EF Core Performance Basics: N+1, Projection, AsNoTracking and Compiled Queries".
The Copilot Profiler Agent and the Rider Counterparts
In Visual Studio 2026 you can hand profiling to an AI assistant by typing @Profiler in Copilot Chat (or by picking the Profiler Agent as a tool in agent mode). According to the tutorial on Learn, the agent collects CPU and allocation data, explains the bottlenecks, adds BenchmarkDotNet to the project and writes benchmarks, applies the change it suggests and reruns the benchmark to report before and after results. For someone new to profiling, it speeds up finding which tool to start with considerably. But what the agent measures is the micro-benchmark it wrote itself: in this post's case the problem is not the speed of a method but 80 round trips per request and the thread pool. A micro-benchmark cannot see that. Before accepting the agent's suggestion, measure again end to end under the same load; in post 09 of the series we build that loop by hand.
In Rider (2026.3 at the time of writing) the same jobs are done with JetBrains' two profilers. dotTrace covers performance: Sampling for the overall picture, Tracing and Line-by-line to see the inside of an algorithm with exact numbers (the counterpart of Visual Studio's Instrumentation), and Timeline, which ties calls to a timeline and is the only mode that shows SQL queries. dotMemory takes and compares memory snapshots; it is the IDE counterpart of the gcdump diff from 05. Rider's Dynamic Program Analysis (DPA) also flags database problems in the background while the app runs; according to JetBrains' documentation, it warns of a likely N+1 when the same command runs more than 50 times by default. Thresholds and defaults change between versions, so check the settings in your version. Rider's debug configuration (choosing a profile with launchSettings.json) is covered in the IDEs series post "Debug Configurations in the IDE: launchSettings.json, launch.json and Debugging a .NET API and Angular Together".
In Production
IDE profilers are for the developer machine. If you want the same data from production, there is dotnet-trace's database profile or dotnet-monitor from the next post; but this profile carries command texts and DiagnosticSource arguments. In the trace I took, every command came with the connection string too: server name, database and user name (SqlClient strips the password). Even without parameter values, store the trace file as sensitive data. Visual Studio's command-line collector, VSDiagnostics.exe, exists for collecting a .diagsession on a machine without Visual Studio; Learn covers the details.
The cheapest way to catch an N+1 in production without reading code is to watch a ratio: divide the increase of microsoft.entityframeworkcore.queries by the request count. If that ratio jumps from 1-3 to 80 after a deploy, you know where to look before opening a profiler. Moving these metrics to a dashboard is the topic of the Observability series on the DevOps side.
Common Pitfalls
- Profiling a Debug build with the debugger attached: optimizations are off and the debugger intercepts its own events; the numbers are not what Release gives you.
- Thinking Instrumentation is as cheap as sampling: it gives exact call counts but adds overhead to every call and makes short methods look more expensive than they are. CPU Usage first, Instrumentation on a narrow scope if needed.
- Looking at the longest query in the Database tool and skipping the count: in an N+1 every query is fast, the problem is the number. Group or count the table by query text.
- Forgetting that lazy loading does synchronous I/O: it blocks the thread even inside an async endpoint.
- Taking the Copilot Profiler Agent's micro-benchmark result for an end-to-end improvement.
- Closing the N+1 with
Includewithout asking which columns the page actually needs.
A Small Experiment
★☆☆ On Windows, profile the lab's /api/customers/7/orders endpoint with Visual Studio's Database tool; sort the queries by Duration, then count the queries with the same text and compare the result with the 80 commands in this post.
★★☆ Write two more versions of orders-include that add AsNoTracking() and AsSplitQuery(). Measure the command count with the db command and the CPU with dotnet-counters, and add to the table how close they get to the projection.
★★★ Add the distribution of commands per request (p50, p99, max) to the db command of Bookstore.TraceStats; to do that you will need to tie commands to requests through the activity relationship between Request/Start and Request/Stop.
IDE profilers give fast results on a developer machine, but you cannot attach Visual Studio to a pod in production. In the next post we move to the production side: we will run dotnet-monitor as a sidecar in Kubernetes, write collection rules that take a trace on their own when requests get slow, and look at where continuous profiling stands today.