Static + Runtime Fusion: Profiling a Live BEAM Node with Giulia v135

March 11, 2026  —  elixirbeamprofilinggiuliadistributed-systemsruntime-analysis  — 7 min read

Static + Runtime Fusion: Profiling a Live BEAM Node with Giulia v135

The Premise

Static analysis tells you what your code looks like. Runtime profiling tells you how it behaves under load. Neither gives the full picture alone. A module with perfect code quality metrics can still be a bottleneck at runtime. A hot process consuming 40% of CPU might be in a module with zero dependents — or it might be a hub that 30 other modules rely on.

Giulia v135 fuses both: it connects to a running BEAM node via Distributed Erlang, collects runtime snapshots (CPU, memory, scheduler contention, process rankings), and correlates them against its AST-level Knowledge Graph (dependency topology, complexity metrics, coupling scores). Starting with Build 135, it also performs function-level tracing on specified modules — sending self-contained trace code to the remote node via Code.eval_string over RPC, with no Giulia code required on the target.

The result is a single profile that tells you not just what’s hot, but which functions are hot, how often they’re called, and what the optimization opportunities are.

This article walks through 7 real observation sessions against a live Elixir application, each revealing something different about the system under test.


The Target: Nexus

Nexus is an Elixir API gateway that manages dynamic PostgreSQL connections, runtime schema metadata, and table operations. It exposes a REST API on port 4040 and handles CRUD, aggregations, search, CSV export, batch operations, and multi-database routing — all backed by Ecto dynamic repos.

The integration test suite exercises 61 tests across 8 categories:

  • Connection Management — register, test, start/stop, delete database connections
  • Multi-Database Operations — queries routed through named connections
  • Extended CRUD — JSONB, NULLs, precision floats, hierarchical categories
  • Edge Cases — SQL injection prevention, unicode/emoji, 10KB strings, empty payloads
  • Concurrent Operations — 50 concurrent inserts, mixed read/write, 5000+ req/s bursts
  • Large Dataset — 50k-row pagination, CSV export, aggregations, ILIKE search
  • 60-Second Stress Test — 20 concurrent workers, 10s ramp-up, sustained mixed workload
  • Metadata Lifecycle — runtime schema creation, introspection, reload

The Setup

Giulia runs as two Docker containers from the same image:

  • Worker (port 4000) — static analysis engine: AST indexing, Knowledge Graph, dependency topology, complexity metrics, embeddings
  • Monitor (port 4001) — runtime observer: connects to target BEAM nodes via Distributed Erlang, collects snapshots at configurable intervals, pushes data to the Worker for fusion

The observation workflow is command-driven:

giulia-observe start [email protected]    # connect + begin collecting
<run your workload>
giulia-observe stop [email protected]     # stop + finalize fused profile

With Build 135, you can also specify modules to trace at function level:

giulia-observe start [email protected] cookie 5000 Nexus.Repo,Nexus.Registry.TableRegistry

This adds per-function call counts to every snapshot — collected via Code.eval_string over :rpc.call, so the target node needs zero Giulia code.

The First Bug: Short Names vs Long Names

The first attempt failed immediately:

Connecting to [email protected] ...
{"error":":node_unreachable","node":"[email protected]"}

The root cause was a Distributed Erlang protocol mismatch. Both Giulia containers started with --sname (short names), producing nodes like monitor@giulia-monitor. But Nexus uses an IP-based address ([email protected]), which is a --name (long name) node. Erlang refuses to connect nodes across name modes — it’s a hard protocol incompatibility, not a network error.

The fix: change one flag in docker-compose.yml. Five characters, zero architectural changes.

# Before
elixir --sname worker ...

# After
elixir --name worker@giulia-worker ...

Phase 1: Process-Level Profiling (Sessions 1–5)

The first five sessions used process-level observation only — BEAM metrics and top-process rankings. This reveals which modules own the hottest processes, but not which functions inside those modules are being called.

Session 1: First Contact

Metric Value
Duration 180s
Peak processes 429
Peak memory 84.4 MB
Peak run queue 17
Top module :proc_lib (100% CPU)

A run queue of 17 means 17 tasks were waiting for a scheduler. For a node running 4–8 schedulers, sustained run queue above scheduler count means CPU-bound work is piling up. The bottleneck analysis flagged it automatically.

Session 2: Idle Baseline

Metric Value
Peak run queue 0
Top module :code_server (38.7% CPU)

:code_server being hottest at idle just means Erlang is doing module lookups — normal. This is what healthy looks like.

Session 3: Sustained Load with Info Logging

Metric Value
Peak processes 459 (+30 over idle)
Peak memory 89.7 MB
Peak run queue 1
Top module :proc_lib (54.3% CPU)
#2 module :logger_std_h_default (28.5% CPU)

The logger consuming nearly a third of CPU under load. Not because of log volume — because of how it writes.

Session 4: Warning-Level Logging (The Counterintuitive Result)

Same workload, log level raised to :warning to reduce volume.

Metric Value
Peak run queue 5
Top module :logger_std_h_default (39.1% CPU)

Reducing log volume made it worse. Logger jumped from 28.5% to 39.1% and became the #1 consumer. Run queue went from 1 to 5.

The mechanism: Erlang’s default logger handler does synchronous IO. Every log call blocks the calling process until the write completes. Reducing volume doesn’t help because the overhead is per-call, not per-byte. With fewer logs, the remaining calls still block, and now they’re a larger proportion of total work.

The fix — configure async/buffered mode:

:logger.update_handler_config(:default, :config, %{
  sync_mode_qlen: 1000,
  drop_mode_qlen: 2000,
  flush_qlen: 5000
})

Session 5: After the Fix

Metric Value
:logger_std_h_default gone
:proc_lib 98.9%
Peak run queue 2

Logger eliminated. Run queue halved. :proc_lib at 98.9% just means all work is happening inside OTP processes — it’s the wrapper, not the bottleneck.


Phase 2: Function-Level Tracing (Sessions 6–7)

Session 6: Single Module Trace

Tracing Nexus.Registry.TableRegistry — the schema registry that every CRUD operation touches.

Function Calls
TableRegistry.get/1 9,238

Every CRUD operation goes through this function to look up schema definitions. 9,238 calls across 10 trace windows.

Session 7: Dual Module Trace

Adding Nexus.Repo to the trace reveals the full picture.

Nexus.Repo — 7,000 total calls:

Function Calls Share
prepare_opts/2 1,266 18.1%
default_options/1 1,263 18.0%
get/1 1,263 18.0%
prepare_query/3 956 13.7%
all/1 593 8.5%
one/1 353 5.0%
insert_all/3 307 4.4%
put_dynamic_repo/1 12 0.2%

Nexus.Registry.TableRegistry — 2,004 total calls:

Function Calls Share
get/1 354 17.7%
prepare_opts/2 354 17.7%
default_options/1 354 17.7%
prepare_query/3 292 14.6%

Optimization Analysis

Read-Heavy Workload (87% Reads)

Read operations:  2,209 (87%)
Write operations:    320 (13%)
Read/Write ratio:    6.9:1

Classic candidate for read-through caching. Repo.get/1 alone accounts for 1,263 calls.

Ecto Pipeline Overhead: 1.38x

Ecto pipeline calls: 3,485 (prepare_opts + default_options + prepare_query)
Actual DB operations: 2,530
Overhead ratio: 1.38x

Normal Ecto behavior — not a bug, not worth optimizing. The real wins are elsewhere.

TableRegistry Is Database-Backed (The Biggest Finding)

Nexus.Registry.TableRegistry has prepare_opts/2, default_options/1, and prepare_query/3 in its call profile — the telltale signature of an Ecto Repo. Every schema lookup is a round-trip to PostgreSQL.

Table metadata rarely changes. There’s no reason to hit the database on every request when the data could be loaded once into ETS and refreshed on metadata changes. Moving TableRegistry to ETS would eliminate ~2,000 unnecessary DB calls per observation window.

The BEAM Isn’t the Bottleneck — PostgreSQL Probably Is

Run queue: 1
Memory: 90.3 MB, stable
Throughput: ~30 DB ops/sec during trace window

BEAM has headroom. The relatively low throughput suggests database latency is the dominant factor. pg_stat_statements would confirm.

Priority

Priority Action Impact Effort
1 Cache TableRegistry in ETS Eliminates ~2,000 DB calls/window Low
2 Add read cache for hot get/1 paths Reduces 87% of DB reads Medium
3 Profile PostgreSQL Identifies true bottleneck Low

What Each Layer Revealed

Finding Process-Level Function-Level
Synchronous logger at 39% CPU
Logger worse at warning level
TableRegistry.get/1 as hottest function (9,238 calls)
TableRegistry is database-backed
87% read-heavy workload
Dynamic repo switching is efficient (12 switches)
BEAM has headroom, DB is bottleneck Partial

Process-level profiling found the logger and scheduler contention — real operational issues, fixed during the session. But it could only tell us :proc_lib was hot, which is like saying “code is running.”

Function-level tracing broke through that ceiling.


The Real Demo

Worth stepping back and noting what actually happened here. Giulia profiled a remote BEAM node it doesn’t own. No access to Nexus’s source code. No instrumentation planted beforehand. No agents installed, no probes compiled into the target. Pure black-box runtime observation over Distributed Erlang.

From that — from connecting to a stranger’s node and watching it work — Giulia produced concrete, actionable optimization recommendations: your schema registry should be ETS-backed, not database-backed; here are the 354 unnecessary Postgres round-trips per observation window to prove it.

The logger finding was interesting. The scheduler contention was operationally useful. But the headline is this: function-level tracing of a remote node, with zero instrumentation on the target, leading to an architectural recommendation. That’s what static + runtime fusion delivers.


Built with Giulia v135 — an Elixir daemon for static + runtime code intelligence. Monitor container connects via Distributed Erlang, collects process snapshots and function-level traces at configurable intervals, pushes to Worker for AST correlation and fused profiling.