Execution Time

performance

Definition and Overview

Execution time is the measured duration between the start and completion of a computational operation, encompassing all processing, I/O waits, and scheduling delays experienced by the operation. In performance engineering, execution time is the primary metric for understanding system responsiveness and identifying optimization opportunities. It is typically measured in microseconds (for in-memory operations) to milliseconds (for I/O-bound operations) and reported as distributions rather than single values to capture the full range of performance behavior. For the BEAM virtual machine, execution time measurement carries additional nuance because the scheduler may preempt a process after a fixed number of reductions, meaning wall-clock time includes time the process spent in the run queue waiting for a scheduler thread.

The distinction between wall-clock time and CPU time is critical for accurate performance analysis. Wall-clock time measures the total elapsed duration including time the process spent waiting (for I/O, locks, scheduling, or network responses). CPU time measures only the time the CPU was actively executing instructions for the operation. A database query might have 2ms of CPU time but 50ms of wall-clock time if it spent 48ms waiting for disk I/O. Understanding which type of execution time is being measured determines the appropriate optimization strategy. On the BEAM, a third category exists: scheduler time, which measures how long a process occupied a scheduler thread. The distinction matters because a process performing a long NIF call blocks the scheduler without consuming reductions, distorting both wall-clock and reduction-based measurements.

Execution time measurements are inherently statistical. A single measurement is unreliable due to system noise (garbage collection, scheduling jitter, cache effects, background load). Production performance monitoring uses percentile distributions -- P50 (median), P95 (95th percentile), P99 (99th percentile), and P99.9 -- to characterize both typical and tail-end performance. Optimizing the median improves the common case, while optimizing tail latency (P99, P99.9) improves worst-case user experience. The BEAM's per-process garbage collection model means GC pauses affect individual request latencies rather than creating global stop-the-world pauses, making tail latency analysis especially important for identifying processes with large heaps.

Core Concepts

ConceptDescriptionBEAM Relevance
Wall-Clock TimeTotal elapsed real time from start to finishIncludes run-queue wait, GC, I/O
CPU TimeTime CPU actively executed instructionsExcludes I/O wait, scheduling delays
Scheduler TimeTime a process occupied a scheduler threadRelevant for NIF calls and busy-waiting
ReductionsBEAM work unit count (approx. function calls)Process preempted every ~4000 reductions
Monotonic TimeStrictly increasing clock, immune to NTP adjustmentsSystem.monotonic_time/1 for intervals
System TimeWall-clock time subject to NTP correctionsSystem.system_time/1 for timestamps
GC PausePer-process garbage collection interruptionAffects individual request, not global
Tail LatencyP99/P99.9 worst-case response timesOften caused by GC, scheduling, or I/O
PercentileStatistical rank in a distributionP50=median, P95/P99 for SLA boundaries
BudgetMaximum allowed execution time for an operationEnforced via telemetry handlers
InstrumentationCode that measures and reports timingTelemetry spans, :timer.tc, manual
Warm-upInitial slow runs before JIT/cache effects stabilizeCritical for accurate benchmarking

Technical Deep Dive

Measurement Methods

MethodPrecisionOverheadUse CaseBEAM-Specific Notes
:timer.tc/1MicrosecondsVery lowFunction-level timingUses erlang:monotonic_time, safe for intervals
System.monotonic_time/1NanosecondsMinimalHigh-precision intervalsImmune to NTP drift, preferred for deltas
System.system_time/1NanosecondsMinimalAbsolute timestampsSubject to NTP adjustments, not for intervals
:telemetry.span/3ConfigurableLowStructured instrumentationStandard for Phoenix/Ecto/LiveView events
BencheeStatisticalHigh (benchmark mode)Micro-benchmarksHandles warm-up, statistical analysis, GC tracking
:fprofCall-levelHighProfiling investigationsTraces every function call, generates flame graphs
:eprofCall-levelModerateTime distributionShows time per function, less overhead than fprof
:cprofCall countLowHot path detectionCounts calls, no timing, very low overhead
:msaccSchedulerLowScheduler utilizationShows time in schedulers, GC, I/O, aux threads
recon_traceRuntimeLow-moderateProduction tracingSafe for production, rate-limited, match specs

Percentile Reporting

PercentileMeaningTypical SLAMonitoring Action
P50 (Median)Half of requests fasterInformationalDashboard baseline
P7575% of requests fasterInternal targetEarly warning indicator
P9090% of requests fasterWarning thresholdAlert on sustained breach
P9595% of requests fasterAlerting thresholdPage on-call if sustained
P9999% of requests fasterSLA boundaryHard SLA violation trigger
P99.999.9% of requests fasterCritical alertImmediate investigation
MaxAbsolute worst caseDiagnostic onlyNever use for SLA (single outlier)

Common Execution Time Budgets

OperationTargetHard LimitCategoryMeasurement Point
ETS lookup< 1 us< 10 usIn-memory:timer.tc around :ets.lookup
GenServer call (local)< 100 us< 1 msInter-processTelemetry span on handle_call
GenServer call (distributed)< 5 ms< 50 msNetworkIncludes serialization + network
PostgreSQL simple query< 5 ms< 50 msDatabaseEcto telemetry [:repo, :query]
PostgreSQL complex join< 20 ms< 200 msDatabaseQuery plan analysis required
Meilisearch search< 10 ms< 100 msSearchHTTP client telemetry
HTTP API response< 50 ms< 250 msNetworkPhoenix endpoint telemetry
LiveView mount< 100 ms< 150 msUI[:phoenix, :live_view, :mount]
LiveView handle_event< 50 ms< 100 msUI[:phoenix, :live_view, :handle_event]
Page render (full)< 150 ms< 250 msUIEnd-to-end browser measurement
Health check< 5 ms< 10 msSystem/api/health endpoint timing
OSINT tool execution< 5s< 30sExternal APIPer-adapter telemetry
DD pipeline phase< 10s< 60sPipelinePubSub event timestamps

BEAM-Specific Timing Considerations

The BEAM virtual machine introduces several factors that affect execution time measurement accuracy:

Reduction-Based Preemption: The BEAM scheduler preempts processes after approximately 4000 reductions (roughly equivalent to function calls). This means a process measuring its own execution time may include time spent in the run queue between scheduler slices. For latency-sensitive measurements, this effect is negligible for short operations but can add milliseconds under high scheduler contention.

Per-Process Garbage Collection: Unlike JVM stop-the-world GC, BEAM garbage-collects each process independently. A process with a large heap (e.g., one accumulating results from a long Enum.reduce) will experience longer GC pauses, visible as spikes in its execution time. The fullsweep_after process flag controls how aggressively the GC reclaims memory.

Dirty Schedulers: Long-running NIFs and BIFs execute on dirty schedulers to avoid blocking normal schedulers. Operations dispatched to dirty schedulers may experience additional latency from dirty scheduler availability. CPU-bound NIFs use dirty CPU schedulers (equal to CPU cores), while I/O-bound NIFs use dirty I/O schedulers (default 10).

Timer Wheel Resolution: The BEAM's timer wheel has a resolution of approximately 1ms. Operations using Process.send_after/3 or :timer.send_after/2 cannot achieve sub-millisecond precision. For high-precision timing, always use System.monotonic_time/1 directly.

Statistical Analysis for Benchmarking

Accurate benchmarking requires statistical rigor beyond simple averaging:

TechniquePurposeImplementation
Warm-up runsEliminate cold-start effects (cache, JIT)Benchee default: 2 seconds warm-up
Multiple iterationsReduce measurement noiseMinimum 1000 iterations for microsecond ops
Standard deviationQuantify measurement spreadBenchee reports automatically
IQR filteringRemove outliers (GC spikes)Filter beyond 1.5x interquartile range
Memory measurementCorrelate time with allocationBenchee :memory option
GC trackingIdentify GC-dominated measurements:erlang.statistics(:garbage_collection)
ComparisonRelative performance between implementationsBenchee comparison reports

Architecture and Implementation

Execution time monitoring in production systems follows a layered architecture. The instrumentation layer embeds timing measurements at strategic points in the code. The collection layer aggregates raw measurements into statistical summaries using reservoir sampling or histogram data structures. The reporting layer publishes metrics to monitoring dashboards and triggers alerts when thresholds are exceeded.

The instrumentation strategy must balance comprehensiveness against overhead. Instrumenting every function call would provide complete visibility but introduce unacceptable overhead. The recommended approach is to instrument at service boundaries (API endpoints, database queries, external HTTP calls, GenServer calls) where execution time directly impacts user experience, and use profiling tools for deeper investigation when monitoring identifies hotspots.

BEAM's built-in Telemetry library provides the standard instrumentation mechanism for Elixir applications. Telemetry uses a publish-subscribe model where instrumented code emits events with timing measurements, and handlers aggregate and report these measurements without coupling the instrumented code to specific monitoring tools.

The three-tier monitoring architecture works as follows:

  1. Instrumentation Tier: Code emits telemetry events at boundaries. Phoenix, Ecto, and LiveView emit events automatically. Custom code uses :telemetry.span/3 for consistent measurement.

  2. Aggregation Tier: Telemetry handlers receive events and update in-memory data structures (histograms, counters, gauges). ETS tables provide fast, concurrent-safe storage for aggregated metrics. The aggregation window is typically 10-60 seconds.

  3. Reporting Tier: Aggregated metrics are exported to external monitoring systems (Prometheus, StatsD, Datadog) or displayed on internal dashboards. Alerting rules trigger when percentile thresholds are breached over sustained periods.

Budget Enforcement Architecture

Budget enforcement transforms passive monitoring into active prevention. When an operation exceeds its time budget, the system can take corrective action: logging a warning, incrementing a counter, or (in extreme cases) terminating the operation. The budget enforcement pipeline:

  1. Operation starts, monotonic timestamp captured
  2. Operation completes, duration calculated
  3. Duration compared against operation-specific budget
  4. If over budget: telemetry event emitted with over_budget: true metadata
  5. Alert handler evaluates frequency of over-budget events
  6. Sustained violations trigger alerts and investigation

Usage in Prismatic Platform

The Prismatic Platform instruments all critical code paths with Telemetry-based execution time tracking, enforcing hard limits defined in the page load performance policy. The PERF doctrine mandates that no unbounded queries, N+1 patterns, or blocking async operations are permitted, and execution time monitoring is the primary mechanism for detecting violations at runtime.

defmodule Prismatic.Performance.ExecutionTimer do
  @moduledoc """
  Execution time measurement and reporting utilities.

  Integrates with Telemetry for structured monitoring and enforces
  platform performance standards defined in the PERF doctrine.
  Provides both synchronous measurement (for inline timing) and
  Telemetry-based instrumentation (for production monitoring).

  ## Budget Categories

  The platform defines execution time budgets for all critical operations:

  - `:ets_lookup` - < 10 us hard limit
  - `:genserver_call` - < 1 ms hard limit
  - `:db_query` - < 50 ms hard limit
  - `:page_render` - < 250 ms hard limit
  - `:liveview_mount` - < 150 ms hard limit
  - `:api_response` - < 250 ms hard limit
  - `:health_check` - < 10 ms hard limit

  ## Examples

      iex> {elapsed, result} = Prismatic.Performance.ExecutionTimer.measure(:db_query, fn ->
      ...>   Repo.all(from u in User, limit: 10)
      ...> end)
      iex> is_integer(elapsed) and elapsed >= 0
      true

      iex> Prismatic.Performance.ExecutionTimer.within_budget?(500, :ets_lookup)
      true

      iex> Prismatic.Performance.ExecutionTimer.within_budget?(20_000, :ets_lookup)
      false
  """

  require Logger

  @type operation ::
          :ets_lookup
          | :genserver_call
          | :db_query
          | :search_query
          | :page_render
          | :liveview_mount
          | :liveview_handle_event
          | :api_response
          | :health_check
          | :osint_tool
          | :dd_pipeline_phase

  @type budget_us :: non_neg_integer()

  @budgets %{
    ets_lookup: 10,
    genserver_call: 1_000,
    db_query: 50_000,
    search_query: 100_000,
    page_render: 250_000,
    liveview_mount: 150_000,
    liveview_handle_event: 100_000,
    api_response: 250_000,
    health_check: 10_000,
    osint_tool: 30_000_000,
    dd_pipeline_phase: 60_000_000
  }

  @doc """
  Measures execution time of a function and emits a telemetry event.

  Returns a tuple of `{elapsed_microseconds, function_result}`.
  The telemetry event is emitted under `[:prismatic, :execution_time]`
  with the duration and operation name as metadata.

  ## Parameters

  - `operation_name` - Atom identifying the operation category
  - `fun` - Zero-arity function to measure

  ## Examples

      iex> {us, val} = Prismatic.Performance.ExecutionTimer.measure(:db_query, fn -> 42 end)
      iex> is_integer(us) and val == 42
      true
  """
  @spec measure(operation(), (-> result)) :: {non_neg_integer(), result} when result: var
  def measure(operation_name, fun) do
    start = System.monotonic_time(:microsecond)
    result = fun.()
    elapsed = System.monotonic_time(:microsecond) - start

    :telemetry.execute(
      [:prismatic, :execution_time],
      %{duration_us: elapsed},
      %{operation: operation_name, over_budget: not within_budget?(elapsed, operation_name)}
    )

    unless within_budget?(elapsed, operation_name) do
      Logger.warning(
        "Execution time over budget: #{operation_name} took #{elapsed}us " <>
          "(budget: #{Map.get(@budgets, operation_name, :unlimited)}us)"
      )
    end

    {elapsed, result}
  end

  @doc """
  Wraps an Ecto query with execution time measurement.

  Executes the query through the specified Repo and measures the
  total time including query planning, execution, and result decoding.

  ## Parameters

  - `query` - An Ecto queryable (schema, query, or fragment)
  - `opts` - Keyword list with `:repo` and `:label` options

  ## Examples

      iex> {us, results} = Prismatic.Performance.ExecutionTimer.timed_query(
      ...>   from(u in "users", limit: 5),
      ...>   repo: MyApp.Repo,
      ...>   label: :user_listing
      ...> )
  """
  @spec timed_query(Ecto.Queryable.t(), keyword()) :: {non_neg_integer(), list()}
  def timed_query(query, opts \\ []) do
    repo = Keyword.get(opts, :repo, Prismatic.Repo)
    label = Keyword.get(opts, :label, :unnamed_query)

    measure(label, fn -> repo.all(query) end)
  end

  @doc """
  Checks whether a duration falls within the budget for an operation.

  Returns `true` if the duration is within the hard limit for the
  given operation category, or `true` for unknown operations (no budget).

  ## Parameters

  - `duration_us` - Measured duration in microseconds
  - `operation` - Operation category atom

  ## Examples

      iex> Prismatic.Performance.ExecutionTimer.within_budget?(5, :ets_lookup)
      true

      iex> Prismatic.Performance.ExecutionTimer.within_budget?(500_000, :health_check)
      false

      iex> Prismatic.Performance.ExecutionTimer.within_budget?(999, :unknown_op)
      true
  """
  @spec within_budget?(non_neg_integer(), operation() | atom()) :: boolean()
  def within_budget?(duration_us, operation) do
    case Map.fetch(@budgets, operation) do
      {:ok, budget} -> duration_us < budget
      :error -> true
    end
  end

  @doc """
  Returns the budget in microseconds for an operation category.

  Returns `:unlimited` for operations without a defined budget.

  ## Examples

      iex> Prismatic.Performance.ExecutionTimer.budget_for(:db_query)
      50_000

      iex> Prismatic.Performance.ExecutionTimer.budget_for(:unknown)
      :unlimited
  """
  @spec budget_for(operation() | atom()) :: budget_us() | :unlimited
  def budget_for(operation) do
    Map.get(@budgets, operation, :unlimited)
  end

  @doc """
  Returns all defined operation budgets as a map.

  ## Examples

      iex> budgets = Prismatic.Performance.ExecutionTimer.all_budgets()
      iex> is_map(budgets) and map_size(budgets) > 0
      true
  """
  @spec all_budgets() :: %{operation() => budget_us()}
  def all_budgets, do: @budgets
end

Benchmarking with Benchee

The platform uses Benchee for micro-benchmarking performance-critical code paths. Every new LiveView page must include a benchmark demonstrating that mount time and initial render time fall within the PERF performance standard (< 150ms mount, < 250ms total load).

defmodule Prismatic.Benchmarks.ExecutionTimeBenchmarks do
  @moduledoc """
  Benchee benchmark suite for execution time measurement overhead
  and critical path performance validation.

  Run with: `mix run benchmarks/execution_time_benchmarks.exs`
  """

  @doc """
  Runs the complete benchmark suite comparing measurement methods.

  ## Examples

      iex> Prismatic.Benchmarks.ExecutionTimeBenchmarks.run()
      :ok
  """
  @spec run() :: :ok
  def run do
    Benchee.run(
      %{
        "timer.tc" => fn ->
          :timer.tc(fn -> Enum.sum(1..1000) end)
        end,
        "monotonic_time" => fn ->
          start = System.monotonic_time(:microsecond)
          Enum.sum(1..1000)
          System.monotonic_time(:microsecond) - start
        end,
        "telemetry.span" => fn ->
          :telemetry.span([:benchmark, :sum], %{}, fn ->
            result = Enum.sum(1..1000)
            {result, %{}}
          end)
        end
      },
      warmup: 2,
      time: 10,
      memory_time: 2,
      reduction_time: 2,
      print: [benchmarking: true, configuration: true]
    )

    :ok
  end
end

Telemetry Handler Setup

defmodule Prismatic.Telemetry.ExecutionTimeHandler do
  @moduledoc """
  Telemetry handler that aggregates execution time measurements
  and exposes them for Prometheus scraping and dashboard display.

  Attaches to `[:prismatic, :execution_time]` events and maintains
  per-operation histograms in ETS for efficient concurrent access.
  """

  require Logger

  @table :execution_time_histograms

  @doc """
  Attaches the execution time handler to telemetry events.

  Should be called once during application startup, typically
  from the application supervision tree.

  ## Examples

      iex> Prismatic.Telemetry.ExecutionTimeHandler.attach()
      :ok
  """
  @spec attach() :: :ok
  def attach do
    :telemetry.attach(
      "prismatic-execution-time-handler",
      [:prismatic, :execution_time],
      &handle_event/4,
      %{}
    )

    :ok
  end

  @doc false
  @spec handle_event(list(atom()), map(), map(), map()) :: :ok
  def handle_event(
        [:prismatic, :execution_time],
        %{duration_us: duration},
        %{operation: operation} = metadata,
        _config
      ) do
    if Map.get(metadata, :over_budget, false) do
      Logger.warning("Over-budget execution: #{operation} = #{duration}us")
    end

    update_histogram(operation, duration)
    :ok
  end

  defp update_histogram(operation, duration) do
    try do
      :ets.update_counter(@table, {operation, :count}, 1, {{operation, :count}, 0})
      :ets.update_counter(@table, {operation, :total_us}, duration, {{operation, :total_us}, 0})
    rescue
      ArgumentError ->
        :ets.new(@table, [:named_table, :public, :set])
        update_histogram(operation, duration)
    end
  end
end

The telemetry pipeline captures execution times for all OSINT tool runs, DD pipeline phases, and API endpoint responses, publishing them to monitoring dashboards for real-time visibility. The OTEL doctrine requires that all GenServer handlers, controller actions, and API calls emit telemetry events including execution time measurements.

Common Pitfalls

PitfallProblemSolution
Using DateTime.utc_now() for intervalsSubject to NTP adjustments, can go backwardUse System.monotonic_time/1 for all interval measurements
Single-sample benchmarksSystem noise dominates; result is meaninglessUse Benchee with statistical analysis (1000+ iterations)
Measuring in test environmentTest config differs from production (sandbox, mocks)Benchmark against production-like configuration
Ignoring GC in benchmarksGC pauses inflate measurements unpredictablyUse Benchee's memory_time option to correlate
Averaging instead of percentilesAverage hides tail latency problemsAlways report P50, P95, P99 distributions
Timing across await boundariesIncludes unrelated scheduling/wait timeMeasure the operation itself, not the await wrapper
Forgetting warm-upFirst runs include cache misses, module loadingBenchee handles automatically; manual benchmarks need explicit warm-up
Timing NIFs on normal schedulersNIF blocks scheduler, distorts other measurementsUse dirty schedulers for NIFs > 1ms
Using Process.sleep in timing codeAdds artificial delay, blocks schedulerNever sleep inside measurement boundaries
Ignoring scheduler contentionHigh load inflates wall-clock timeMonitor scheduler utilization with :msacc alongside timing
String interpolation in telemetry metadataCreates unnecessary garbage, inflates GCUse atoms or pre-computed values in metadata maps
Measuring compilation time as runtimeModule compilation on first call adds latencyEnsure modules are compiled before measurement

Best Practices

  1. Always use monotonic time for intervals -- System.monotonic_time/1 is immune to NTP adjustments and clock skew. Never use DateTime.utc_now() or System.system_time/1 for measuring durations.

  2. Report percentile distributions, not averages -- P50, P95, P99, and P99.9 capture both typical and worst-case behavior. Averages hide tail latency issues that affect user experience.

  3. Instrument at service boundaries -- Measure execution time at API endpoints, database queries, external HTTP calls, GenServer calls, and LiveView lifecycle callbacks. These are the points that directly impact user-visible latency.

  4. Use Telemetry spans for structured instrumentation -- :telemetry.span/3 provides consistent start/stop events with automatic duration calculation and exception handling. Phoenix, Ecto, and LiveView use this pattern natively.

  5. Benchmark with statistical rigor -- Use Benchee with adequate warm-up (2+ seconds), sufficient iterations (1000+), and memory tracking. Compare implementations side-by-side rather than measuring in isolation.

  6. Define and enforce execution time budgets -- Every operation category should have a documented hard limit. Budget violations should emit telemetry events that trigger alerts.

  7. Monitor GC impact on latency -- Use :erlang.statistics(:garbage_collection) or Benchee's memory analysis to correlate execution time spikes with GC activity. Consider fullsweep_after tuning for long-lived processes.

  8. Profile before optimizing -- Use :fprof or :eprof to identify actual bottlenecks before optimizing. Premature optimization based on assumptions wastes effort and can worsen performance.

  9. Test performance regressions in CI -- Include Benchee benchmarks in the test suite with threshold assertions. Catch performance regressions before they reach production.

  10. Separate measurement from alerting -- Instrumentation code should only measure and emit events. Alerting logic belongs in telemetry handlers, keeping instrumented code clean and low-overhead.

  • Latency -- End-to-end delay from request to response, of which execution time is one component
  • Throughput -- Operations completed per unit time, inversely related to execution time under load
  • Telemetry -- BEAM metric collection framework used for execution time instrumentation
  • Profiling -- Detailed execution analysis to identify time-consuming code paths
  • Benchee -- Elixir micro-benchmarking library with statistical analysis
  • GenServer -- OTP server abstraction where call/cast timing is critical
  • ETS -- Erlang Term Storage providing sub-microsecond lookups
  • OTEL -- Observability enforcement doctrine requiring telemetry instrumentation
  • Health Check -- Service health verification with execution time budgets
  • Scheduler -- BEAM scheduler that preempts processes based on reductions
  • Garbage Collection -- Per-process GC that causes execution time spikes
  • Flame Graph -- Visualization of call stack timing for bottleneck identification

See Also

  • Capabilities -- Platform performance monitoring capabilities
  • Architecture -- Performance architecture and monitoring infrastructure
  • OSINT Tools -- OSINT tool execution with per-adapter timing
  • Livebooks: performance_monitoring/ notebooks include profiling and timing analysis
  • Academy: Performance-related topics reference execution time standards
  • PERF Doctrine: Platform performance gates enforcing execution time budgets
  • OTEL Doctrine: Observability requirements for telemetry instrumentation

Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL