Trace: Timer ID overwritten when enabling CsvProfiler

I am using the TraceServices API to extract data from .utrace files and I discovered an issue with the timer ids. When the CsvProfiler starts while a capture is being recorded, some timers have their ID reset.

For example, the timer “Frame” on the RenderThread changes once the CsvProfiler starts. See in the attached screenshots how there are two timer definitions for the same type of event. When exporting the timers using Unreal Insights in CLI, we actually get two distinct rows for the same timer:

Id,Type,Name,File,Line
4970,CPU,Frame,D:\build\++UE5\Sync\Engine\Source\Runtime\Launch\Private\LaunchEngineLoop.cpp,5467
5873,CPU,Frame,D:\build\++UE5\Sync\Engine\Source\Runtime\Launch\Private\LaunchEngineLoop.cpp,5467

Steps to reproduce:

  1. Start a capture Trace.Start
  2. Wait a second and start the CsvProfiler CsvProfile Start
  3. Wait a second and stop the CsvProfiler CsvProfile Stop
  4. Stop the capture Trace.Stop

It happens starting from Unreal Insights 1.08, in 1.07 there was only one “Frame” timer definition for the RenderThread.

Why is it happening and is it the expected behavior ?

Possible answer from Claude Code:

# Duplicate "Frame" Timer ID on RenderThread (UE 5.7)

## Summary

When a CsvProfiler capture is started mid-session while Unreal Insights is recording a `.utrace`, the "Frame" timer on the RenderThread appears with **two different IDs** in Insights. This breaks any comparison of timing events across the CSV capture boundary.

---

## Root Cause

### Location

**`Engine/Source/Runtime/Launch/Private/LaunchEngineLoop.cpp`**
Function: `BeginFrameRenderThread` (~line 5456)

### The Problematic Code

```cpp
if (FCsvProfiler::Get()->IsCapturing_Renderthread())
{
    GRHIFrameBreadcrumb.Emplace(RHICmdList,
        RHI_BREADCRUMB_DESC_FORWARD_VALUES(TEXT("Frame"), TEXT("Frame %d (CsvFrame %d)"), RHI_GPU_STAT_ARGS_NONE)(
              RHI_BREADCRUMB_FIELD("Frame Number", CurrentFrameCounter)
            , RHI_BREADCRUMB_FIELD("Csv Frame Number", FCsvProfiler::Get()->GetCaptureFrameNumberRT())
        ));
}
else
{
    GRHIFrameBreadcrumb.Emplace(RHICmdList,
        RHI_BREADCRUMB_DESC_FORWARD_VALUES(TEXT("Frame"), TEXT("Frame %d"), RHI_GPU_STAT_ARGS_NONE)(
            RHI_BREADCRUMB_FIELD("Frame Number", CurrentFrameCounter)
        ));
}
```

Both branches use `StaticName = TEXT("Frame")`, but they differ in:
- **Format string**: `"Frame %d"` vs `"Frame %d (CsvFrame %d)"`
- **Number of fields**: 1 vs 2

### Why This Creates Two Timer IDs

`RHI_BREADCRUMB_DESC_FORWARD_VALUES` (defined in `Engine/Source/Runtime/RHI/Public/RHIBreadcrumbs.h` ~line 1325) is a macro that expands to a lambda containing a **`static const TRHIBreadcrumbDesc<...>`** object. Because the template parameters differ between the two branches (format string and field count are baked into the type), each branch compiles to a **completely separate static object**.

Each `TRHIBreadcrumbDesc` instance owns:
```cpp
mutable std::atomic<uint32> TraceCpuSpecId = 0;  // RHIBreadcrumbs.h ~line 1117
```

The spec ID is assigned lazily in `GetTraceCpuSpec()` (~line 1156):
```cpp
if (TraceCpuSpecId == 0 && ...)
{
    TraceCpuSpecId = FCpuProfilerTrace::OutputEventType(StaticName, ...);
    // also emits MetadataSpec if the breadcrumb has fields
    FCpuProfilerTrace::OutputEventMetadataSpec(TraceCpuSpecId, StaticName, FormatString, ...);
}
```

`OutputEventType` allocates from a global atomic counter (`GetNextSpecId` in `CpuProfilerTrace.cpp:470`). Since the two static objects are independent, both eventually call `OutputEventType("Frame")` and each gets a **different SpecId** — even though the timer name is identical.

### Timeline of Events

| Time | Event |
|------|-------|
| T=0 | Insights capture starts. RenderThread enters `else` branch. Static object A's `TraceCpuSpecId = 0` → `OutputEventType("Frame")` → **SpecId N** registered. |
| T=1 | CsvProfiler capture begins. `IsCapturing_Renderthread()` becomes `true`. |
| T=2 | RenderThread now enters `if` branch. Static object B's `TraceCpuSpecId = 0` → `OutputEventType("Frame")` → **SpecId M** registered (M ≠ N). |
| T=3 | Insights analysis sees two `CpuProfiler.EventSpec` events for "Frame": SpecId N and SpecId M → two separate TimerIds. |

### Why TraceServices Cannot Merge Them

`FCpuProfilerAnalyzer::DefineMergedTimer` in `Engine/Source/Developer/TraceServices/Private/Analyzers/CpuProfilerTraceAnalysis.cpp` (~line 1057) explicitly refuses to deduplicate when a SpecId has already been used in a timeline:

```cpp
// Yes, a timer with same name was already defined.
if (const uint32* FoundTimerIdBySpecId = SpecIdToTimerIdMap.Find(SpecId))
{
    // Update name but do NOT remap the SpecId to the previously defined timer with same name.
    // This is because the two timers are already used in timelines.
    // So we will continue to use separate timers, even if those have same name.
}
```

This guard triggers because `EventBatch` events (the actual timing data) referencing SpecId M arrive in the stream before the corresponding `CpuProfiler.EventSpec` for M (since `EventSpec` is `Important|NoSync` and goes through the cache). This causes `GetOrAddTimer` to first register SpecId M as `<unknown M>`, and by the time the `EventSpec` arrives with name "Frame", the SpecId is already mapped to a timeline entry — making deduplication impossible.

---

## Why It Only Appears in UE 5.7

In UE 5.6, the `BeginFrameRenderThread` function did not have this CsvProfiler-conditional `if/else` branch that produces two different `TRHIBreadcrumbDesc` template instantiations for the same `StaticName`. The branching format string (with the extra "CsvFrame" field) was added as part of 5.7's RHI breadcrumb improvements for CsvProfiler integration.

---

## Affected Files

| File | Role |
|------|------|
| `Engine/Source/Runtime/Launch/Private/LaunchEngineLoop.cpp` | Contains the `if/else` branch that creates two static descriptors for "Frame" |
| `Engine/Source/Runtime/RHI/Public/RHIBreadcrumbs.h` | Defines `TRHIBreadcrumbDesc` with per-instance lazy `TraceCpuSpecId` |
| `Engine/Source/Runtime/Core/Private/ProfilingDebugging/CpuProfilerTrace.cpp` | `GetNextSpecId()` — global atomic counter; `OutputEventType()` — allocates and emits spec |
| `Engine/Source/Developer/TraceServices/Private/Analyzers/CpuProfilerTraceAnalysis.cpp` | `DefineMergedTimer()` — refuses to merge SpecIds already used in timelines |

---

## Potential Fix

The two static `TRHIBreadcrumbDesc` objects diverge because the format string and field list differ between the CsvProfiler-active and inactive cases. One approach is to **always include the CsvFrame field** (using 0 or -1 when not capturing), eliminating the branch and keeping a single static descriptor with a stable SpecId throughout the session:

```cpp
// Single descriptor, always includes CsvFrame field (0 when not capturing)
GRHIFrameBreadcrumb.Emplace(RHICmdList,
    RHI_BREADCRUMB_DESC_FORWARD_VALUES(TEXT("Frame"), TEXT("Frame %d (CsvFrame %d)"), RHI_GPU_STAT_ARGS_NONE)(
          RHI_BREADCRUMB_FIELD("Frame Number", CurrentFrameCounter)
        , RHI_BREADCRUMB_FIELD("Csv Frame Number",
            FCsvProfiler::Get()->IsCapturing_Renderthread()
                ? FCsvProfiler::Get()->GetCaptureFrameNumberRT()
                : -1)
    ));
```

This ensures only one `TRHIBreadcrumbDesc` type is instantiated for the "Frame" breadcrumb, so `OutputEventType("Frame")` is called exactly once per process, producing a single stable SpecId.

Alternatively, the `MetadataSpec` for a SpecId could be updated/re-emitted when the field list changes, but this would require changes to both `TRHIBreadcrumbDesc` and the TraceServices analyzer.