La semana pasada estaba perfilando una app Swift con Instruments. Nada raro: xctrace record, xctrace export, copiar el XML al contexto de Claude Code, pedirle que encuentre los hotspots.
Y Claude me dice: «El XML es demasiado grande, no puedo procesarlo de forma fiable.»
33.553 líneas de XML. Para un programa que tiene dos funciones.
El problema real
xctrace export es una herramienta fantástica. Te da todo: cada sample, cada backtrace, cada frame con su binario, su dirección de memoria, su UUID. Es exhaustivo, preciso y completo.
Y ese es exactamente el problema.
Cuando perfilo una app para encontrar cuellos de botella, no necesito los 3.044 samples individuales. No necesito saber que el sample número 1.847 pilló la CPU en la dirección 0x1027ec9a8 de libswiftCore.dylib a las 00:02.847.882. Necesito saber que heavyWork() se come el 70% del tiempo y lightWork() el 30%.
Dicho en cristiano: necesito diez líneas, no treinta y tres mil.
Por qué XML es el formato correcto (pero el ruido no)
Antes de que alguien diga «el problema es usar XML en 2026»: va a ser que no.
XML es el formato ideal para lo que hace xctrace. Piénsalo:
- Jerárquico: un backtrace es un árbol de frames. Un sample contiene un backtrace, un thread, un process. XML modela eso de forma natural.
- Auto-descriptivo: cada elemento tiene nombre, atributos tipados, y la estructura es validable. No tienes que adivinar qué significa el campo 7 de una línea CSV.
- Deduplicación elegante: xctrace usa un sistema de
id/refdonde define un frame la primera vez (id="59" name="heavyWork()") y luego lo referencia conref="59". Es básicamente un flyweight pattern serializado. - Procesable con herramientas estándar: XPath,
xmllint,xml.etree.ElementTree… no necesitas un parser propietario.
El XML de xctrace no es bloat. Es información estructurada que Instruments necesita para reconstruir call trees interactivos, comparar runs, filtrar por thread y por proceso. Está diseñado para una herramienta con interfaz gráfica que puede expandir y colapsar nodos.
El problema aparece cuando intentas meter esa información en la ventana de contexto de un LLM. Es como intentar leer el Quijote completo para encontrar la frase de los molinos. La información está ahí, pero el ratio señal/ruido es demoledor.
La solución: ztrace
Así que construí ztrace. Un script Python que toma un .trace bundle y produce un resumen compacto.
La idea es simple:
- Ejecutar
xctrace export --tocpara obtener metadata (proceso, duración, template) - Ejecutar
xctrace export --xpathpara extraer la tablatime-profile - Parsear el XML resolviendo el sistema
id/ref - Filtrar frames de sistema (todo lo que vive en
/usr/lib/o/System/) - Agregar por función y generar el resumen
Ojo al dato: el paso 3 es más importante de lo que parece. xctrace no repite la definición completa de un frame cada vez que aparece en un backtrace. Lo define una vez con id="59" y luego usa ref="59". Si no resuelves los refs, pierdes la mayoría de la información.
El resultado
Con el fixture de prueba (un programa trivial con heavyWork() al ~70% y lightWork() al ~30%):
$ ztrace summary sample.trace
Process: hotspot Duration: 3.8s Template: Time Profiler
Samples: 3044 Total CPU: 3044ms
SELF TIME
69.4% 2113ms hotspot heavyWork()
29.7% 905ms hotspot lightWork()
TOTAL TIME (callers with significant overhead)
99.9% 3041ms main
CALL STACKS
69.4% 2113ms main > heavyWork()
29.7% 904ms main > lightWork()
De 33.553 líneas a 13. Toda la información que necesita un LLM para decirte «optimiza heavyWork(), se lleva el 70% del CPU» cabe en un tweet.
Lo que filtra (y por qué)
No todo lo que xctrace reporta es accionable. Cuando perfilo una app, no puedo optimizar libdispatch.dylib. No puedo reescribir dyld4::PrebuiltLoader::loadDependents. Esos frames son ruido si lo que busco son hotspots en mi código.
ztrace filtra por varias capas:
Binarios de sistema: todo lo que vive en /usr/lib/ o /System/ se descarta. Son frames del SO y del runtime de Swift.
Internals del runtime: funciones como __swift_instantiateConcreteTypeFromMangledNameV2 o DYLD-STUB$$sin están técnicamente en tu binario (linkeadas estáticamente), pero no son código que hayas escrito. Fuera.
Símbolos sin resolver: las apps de producción (Spotify, por ejemplo) están stripped. Los frames aparecen como direcciones crudas tipo 0x104885404. ztrace los filtra y te avisa: «85% de los samples de usuario no tienen símbolos». Así sabes que el perfil tiene datos pero necesitas los dSYMs para sacarle jugo.
Probándolo con apps reales
El fixture es bonito pero artificial. ¿Funciona con una app de verdad? Lo probé con Ghostty (el emulador de terminal):
Process: ghostty Duration: 3.8s Template: Time Profiler
Samples: 295 Total CPU: 295ms
SELF TIME
53.2% 157ms ghostty main
3.7% 11ms ghostty renderer.metal.RenderPass.begin
3.1% 9ms ghostty renderer.generic.Renderer(renderer.Metal).rebuildCells
2.7% 8ms ghostty renderer.generic.Renderer(renderer.Metal).drawFrame
2.4% 7ms ghostty renderer.generic.Renderer(renderer.Metal).updateFrame
2.0% 6ms ghostty heap.PageAllocator.alloc
1.7% 5ms ghostty terminal.page.Page.clonePartialRowFrom
1.7% 5ms ghostty font.shaper.coretext.Shaper.shape
Esto sí es accionable. Ves inmediatamente: el renderer Metal (render pass, rebuild cells, draw frame) y el font shaping son donde va el tiempo. Si estuvieras optimizando Ghostty, sabrías exactamente por dónde empezar.
Y cada función viene con su módulo (ghostty), así que en una app con múltiples frameworks sabrías si el cuello de botella está en tu código o en una dependencia.
El stack (y por qué no Swift)
El CLAUDE.md original decía Swift. «Coherente con el caso de uso», pensé. Después de ver que el 95% del trabajo es parsear XML y formatear texto, cambié a Python.
xml.etree.ElementTree parsea el XML en tres líneas. En Swift, XMLParser es SAX puro — callbacks, estado mutable, delegados. Una chapuza para algo que debería ser «dame el árbol y déjame navegar».
Además: un script Python lo distribuyes con uv tool install. Un binario Swift solo funciona en macOS/arm64. Y dado que xctrace solo existe en macOS, «multiplataforma» no es un argumento a favor de Swift aquí. Pero la distribución con uv es infinitamente más limpia que compilar y copiar binarios.
Lo que viene
Esto es v0.1. Lo que falta:
ztrace record: grabar y resumir en un solo comando (conveniencia, no urgente)- Filtros configurables: excluir módulos específicos, ajustar profundidad de call stack
- Comparación de traces: antes/después de una optimización, en formato diff
- Soporte para Allocations: no solo CPU, también memoria
El repo está en GitHub si quieres probarlo.
Integrándolo en el día a día
La gracia de ztrace no es ejecutarlo a mano. Es que Claude Code lo use automáticamente cuando perfila.
Añades esto a tu CLAUDE.md (global o de proyecto):
### Profiling (xctrace)
- Usa `ztrace summary <file.trace>` para leer traces. NUNCA leer el XML crudo de xctrace export.
- Flujo: `xctrace record` → `ztrace summary`
- Flags: `--threshold 0.5` (más funciones), `--depth 10` (stacks más profundos)
Y a partir de ahí, cada vez que Claude Code necesite perfilar algo, el flujo es:
# 1. Grabar
xctrace record --template 'Time Profiler' --time-limit 5s --launch -- .build/debug/MyApp
# 2. Resumir (10 líneas que caben en el contexto)
ztrace summary MyApp.trace
# 3. Claude lee el resumen y sugiere optimizaciones
Sin ztrace, el paso 2 generaría 30.000 líneas de XML que o revientan la ventana de contexto o ahogan la señal en ruido. Con ztrace, Claude tiene exactamente lo que necesita para decirte «el 70% del CPU está en heavyWork(), línea 42 de Renderer.swift«.
El meta-punto
ztrace existe porque los LLMs son malos procesando datos crudos a gran escala. Son buenos razonando sobre datos procesados y compactos. Darle 33.000 líneas de XML a Claude es como darle a un médico una resonancia magnética en formato DICOM crudo y pedirle diagnóstico. El médico necesita la imagen renderizada, no los bytes.
La próxima vez que un LLM te diga «el output es demasiado grande», la solución no es un modelo con más contexto. Es un mejor resumen. Un pipeline que transforme datos crudos en información accionable antes de que lleguen al modelo.
Que al final, eso es lo que hacemos los ingenieros: convertir paja en señal. Con o sin IA de por medio.
Read this article in English.



