Back to writing
· 11 min read

Parsing 7,585 XML references to count 400 Metal dispatches

How LLMTraceFX resolves xctrace XML references, preserves process identity, handles sentinels, and checks the result against known dispatch counts.

The number 7,585 looked important. It was also the wrong unit.

That was the count of reference cells in one xctrace XML export. The workload had issued 400 Metal dispatches. Getting to those 400 records required inspecting 13,068 cells, resolving 7,585 references, and filtering 726 selected-schema intervals by process identity.

My previous Metal article explains why process attribution matters. This one stays inside the parser: how it resolves references, keeps rows intact, handles missing values, and proves that 400 target-PID intervals mean 400 workload dispatches.

The result is about record identity and count. It says nothing about GPU time, utilization, or performance.

The export looks like a table

The capture came from this environment:

  • Apple M5 Pro, arm64
  • macOS 26.6.2 (25G83)
  • Xcode and Instruments 26.6
  • xctrace 16.0 (17F113)
  • Metal System Trace

Each export advertised 82 schemas. LLMTraceFX selected metal-gpu-intervals, which had 18 columns.

That sounds simple: choose a schema, visit each row, read 18 cells.

But a cell does not always hold its value. Some values appear inline. Other cells carry a ref that points to an element materialized elsewhere in the document. The row still owns the column position, but the value lives at another node.

A reduced, invented shape preserves the public format idea without copying private trace data. The node <process id="p7"> defines the process. A later row uses <process ref="p7"/> to point back to it.

Simple row iteration sees a process cell with no text. A reference-aware parser follows p7, recovers the structured process value, and leaves it attached to the row's process column. Figure 1 traces that resolution.

A sanitized XML example shows an indexed process value with ID p7, a formatted label, and a nested PID. A row whose process cell references p7 resolves through the ID index and produces one typed interval with its start value and process identity intact.
Figure 1. Elements with IDs enter an index. Reference cells resolve those identities before the typed interval is built. Open the full-size diagram.

Build the identity index first

The LLMTraceFX parser starts by indexing every XML element with an id.

The first definition for an ID wins. The index exists for one parse_exported_table() call. It is not a global cache and does not survive across exports.

When a cell carries ref="p7", the resolver walks the reference chain until it reaches a value. It refuses two bad shapes:

  • a reference to an ID that does not exist
  • a cycle that returns to an ID already visited

Both stop parsing with InstrumentsExportError. A missing process is not invented. A cycle is not truncated into a plausible value.

The parser also checks the engineering type twice: once on the cell in the row and once on the element reached through the reference. A process column cannot point at a duration value just because both nodes have valid IDs.

This is why I think of the export as a graph laid over a table. Column position gives each cell meaning. Reference edges recover the value. Both have to agree before a row is safe to use.

Position is part of the contract

The association between rows and columns is positional. A row's first direct child belongs to the first schema column, the second child to the second column, and so on.

LLMTraceFX requires every row to have the same number of direct children as the schema has columns. For this table, that means 18. A short or long row fails instead of shifting later values into the wrong fields.

It then checks that each child matches the column's declared engineering type. The check applies to inline and referenced values.

Those rules prevent a dangerous class of parser bugs. If one optional cell vanished and the parser compressed the row, a duration could become a process, or a process could become a label. The output might still have 18 tidy keys. They would describe the wrong record.

Keep process identity structured

For process cells, the parser prefers the structured <pid> child and parses it as an integer. If that field is unavailable, it can recover a trailing PID from the formatted process label. If neither path works, the PID remains None.

The summary groups process ownership by (pid, label), not by label alone. Two processes with the same display name stay separate. If one PID appears under two labels, for_process(pid) raises AmbiguousProcessError instead of choosing one.

This matters because process attribution is the last reduction in the pipeline:

Four labeled stages for the 400-dispatch capture: 13,068 XML cells inspected, 7,585 reference cells resolved, 726 selected-schema intervals across all processes, and 400 intervals attributed to the target PID. The diagram states that cells and references are format structure while intervals are records.
Figure 2. The stages use different units. References are a subset of cells. Intervals are selected-schema records. Target-PID intervals are workload records. Open the full-size diagram.

The 400-dispatch capture contained:

  1. 13,068 XML cells across the selected export.
  2. 7,585 reference cells inside that XML structure.
  3. 726 metal-gpu-intervals records across all processes.
  4. 400 records for the target PID.

The other 326 intervals belonged to known unrelated processes. No interval was unattributed.

Cells are not intervals. References are not intervals. Intervals are not nanoseconds. None of these counts is GPU utilization.

A sentinel keeps an empty slot empty

Xcode 26.6 can emit <sentinel/> for an absent optional value. The element still occupies a column position, so deleting it would corrupt every association to its right.

The parser accepts a sentinel in place of a typed cell. What happens next depends on the field:

Field contract Example Result
OptionalframePosition is preserved and the value remains null.
Required numericstart or durationSummarization fails because the cell has no text.
Required processprocessThe interval remains counted with pid=None and is explicitly unattributed.
A decision flow for an xctrace sentinel cell. Optional fields preserve position and store null. Required numeric fields fail during summarization. Required process fields preserve the interval with a null PID and count it as unattributed.
Figure 3. A sentinel represents absence. It never becomes a zero or a guessed process. The behavior is pinned by parser contract tests. Open the full-size flow.

This split is deliberate. Missing timing data makes the interval unsafe to summarize, so the operation fails. Missing process identity does not erase the interval, because that would make the all-process total look complete. It stays visible in the unattributed count.

In all five captures summarized in the committed bundle, that unattributed count was zero.

A plausible total is not proof

Reference resolution can fail quietly in a naive parser. A program can skip empty-looking cells, count the rows it did construct, and print a number that looks reasonable.

Reasonable is not an oracle.

The test workload gave me one. It issued a known number of Metal dispatches, with one command buffer per dispatch. Before parsing each capture, I already knew the expected target-PID interval count.

Known dispatches Exported cells Reference cells All-process intervals Target-PID intervals Match
40013,0687,585726400Yes
2507,0924,035394250Yes
1204,1402,384230120Yes
773,2581,88418177Yes
1334,5542,630253133Yes

Every target-PID count matched its controlled dispatch count. The exports changed in size. The amount of unrelated process activity changed. The target path still landed on the known answer five times.

That does not prove every possible xctrace document will parse. It does show that schema selection, positional mapping, reference resolution, process extraction, and target attribution held together across five different inputs.

What the evidence does not prove

The parser refuses malformed XML, DOCTYPE and entity declarations, the wrong root, a missing or unexpected schema, duplicate column names, row-width mismatches, engineering-type mismatches, dangling or cyclic references, invalid integers, negative durations, and implausible timestamps.

Only metal-gpu-intervals is supported. The other advertised schemas remain unsupported rather than guessed at.

These checks are not defensive decoration. Without them, the parser can produce a clean aggregate after losing the meaning of a cell.

The public evidence also leaves unsupported metrics empty. These interval counts do not support claims about:

  • GPU utilization or busy percentage
  • kernel time
  • memory bandwidth
  • occupancy
  • power or energy
  • GPU memory footprint

The evidence answers one question: did the parser recover and attribute the expected interval records?

Publish the derivation, not the desktop history

Raw Instruments artifacts can expose more than GPU work. A trace, table of contents, or XML export may contain a device name, hardware UUIDs, target arguments, local paths, and labels for unrelated processes active during the capture.

That is why this article uses a made-up XML fragment and sanitized derived counts. The public LLMTraceFX bundle does not include .trace packages or raw XML.

Before writing public files, the evidence tool strips device names, device UUIDs, and process arguments from the table of contents. It rejects home-directory paths, UUIDs, email addresses, credential-shaped strings, raw trace paths, unexpected files, directories, and symlinks. It then verifies each allowed file against SHA256SUMS.

The capture manifest, JSON summary, CSV summary, and checksums are pinned to the merged evidence commit.

Reproduce the public checks

You do not need a new capture to inspect the parser contract or verify the published bundle:

git clone https://github.com/Siddhant-K-code/LLMTraceFX.git
cd LLMTraceFX
git checkout 4763067c81697d907bdd048ca1b6067cffe0698f

uv sync --locked --extra dev --extra test
uv run pytest tests/optimizer/test_instruments_export.py -q
uv run python examples/metal_evidence/evidence_demo.py verify \
  --public-dir examples/metal_evidence/public

The pytest command checks inline and referenced values, row width, engineering types, sentinels, process identity, ambiguity, and failure paths. The verification command checks public-file hashes, privacy rules, count arithmetic, and exact dispatch matches.

The merged evidence guide also documents the optional fresh-capture flow for a compatible Mac. It is not required to reproduce the checks above.

What I learned

  • Table-shaped XML may still need graph-like reference resolution. Rows describe position while ID and ref edges recover values.
  • Dereferencing must not detach a value from its row, column, process label, or PID.
  • Missing data must fail closed. Optional sentinels can remain null. Required numeric sentinels stop the summary. Required process sentinels stay explicitly unattributed instead of entering the target count.
  • A plausible total is not parser proof. Known dispatch counts provide an external oracle that the parser cannot manufacture.
  • Privacy-safe derived evidence is more useful to publish than raw traces that expose device and process history.

A parser checklist

When parsing a profiler export:

  • Pin the tool version, export schema, and expected columns.
  • Index identities before reading rows, then reject dangling and cyclic references.
  • Enforce row width and engineering type before mapping values to columns.
  • Preserve structured process identity through every dereference and aggregation.
  • Define optional and required missing-value behavior per field. Never turn absence into zero.
  • Reject ambiguous owners, malformed values, and unsupported schemas instead of guessing.
  • Test against an input known outside the parser, not only a tidy aggregate.
  • Publish sanitized summaries, hashes, and reproduction steps. Keep raw captures private unless users have reviewed every field.

Conclusion

Treat profiler XML as a typed identity problem before treating it as a collection of rows. Keep position and ownership attached, make missing required data visible, and validate the final path against an external oracle.

The five exact dispatch matches support this parser path for these metal-gpu-intervals exports. They do not prove that every xctrace schema will parse, and they do not measure GPU utilization, time, bandwidth, occupancy, power, energy, or memory.

About LLMTraceFX

LLMTraceFX is an open-source, evidence-first toolkit I am building to understand and improve local and hosted LLM inference. It collects reproducible evidence, verifies workload quality, compares systems on like-for-like work, and leaves unsupported metrics empty instead of turning them into confident numbers.

It is still growing. If this approach is useful to you, I would appreciate you exploring the GitHub repository, sharing feedback, opening an issue, or giving it a star.


I write about AI agent infrastructure, security, context engineering, and the human side of building with AI. You can find all my writing on my writing page. Discuss this with me on X or connect with me on LinkedIn.

Support independent writing

If this post was useful, consider supporting my open source work and independent writing.