Skip to content

Add profile-gated GQL read-path stage timing to explain notes - #15

Merged
bhensley5 merged 1 commit into
bhensley5:mainfrom
heunghingwan:feat/planner-instrumentation
Jul 3, 2026
Merged

Add profile-gated GQL read-path stage timing to explain notes#15
bhensley5 merged 1 commit into
bhensley5:mainfrom
heunghingwan:feat/planner-instrumentation

Conversation

@heunghingwan

Copy link
Copy Markdown
Contributor

When profile: true (with include_plan: true), this appends a single
line to the existing GqlExecutionExplain.notes array, breaking per-query
GQL read latency down by stage:

stage timing (nanoseconds): bind=… lower=… snapshot=…
graph_row_plan_and_execute=… projection=…; graph_row covers normalize,
cost-based planning, index probe, and execution inside the shared read view

Why

GqlExecutionStats.elapsed_us is whole-query only. A coarse per-stage
breakdown that lives entirely in the GQL layer makes it cheap to confirm
where per-query time is — and isn't — spent (e.g. bind+lower and
published_snapshot show up as negligible), which helps steer optimization
effort without a separate profiling harness.

Design (deliberately limited scope)

  • No public struct / connector changes. Output rides the existing
    notes: Vec<String> on GqlExecutionExplain; Rust/Node/Python parity
    is untouched.
  • Does not touch the shared read substrate (read.rs, query_exec.rs,
    ReadView/SourceList). The native hot path (get_node, etc.) does not
    go through this code, so its latency is unaffected.
  • graph_row_plan_and_execute is intentionally combined — normalize,
    cost-based planning, index probe and execution all happen inside the
    shared read view and are out of scope here. The note labels this so the
    value isn't misread as pure planning time.
  • Only the main graph-row read path is instrumented (not mutation, not
    LIMIT 0, not graph pipelines).

Overhead

profile: false (the default) is a no-op: the collector is disabled and
mark() never calls Instant::now, so the default path carries no extra
work. When profiling is on, the cost is a few Instant::now calls per query.

Tests

New gql_query_profile_stage_timing_note_in_explain_plan asserts the note
appears with profile+include_plan and is absent without profile.
cargo test --lib gql (357 tests) and cargo clippy --lib are clean.

This is intentionally a small, self-contained first step; deeper
instrumentation (e.g. splitting the graph_row bucket, or planning-work
counters) is left for separate follow-ups.

When options.profile is set, record per-query elapsed time for the
GQL read stages (bind, lower, published_snapshot, graph-row target,
projection) and append one summary line to the explain payload's notes.
The note only appears when include_plan is also set (it rides on the
existing GqlExecutionExplain.notes Vec, so no public struct or connector
changes).

Scope is deliberately limited to the GQL layer: graph_row_plan_and_execute
lumps normalize, cost-based planning, index probe, and execution because
those live inside the shared read view and are intentionally not touched
here. The native hot path (e.g. get_node) does not go through this code.

profile=false is a no-op: the collector is disabled and mark() never
calls Instant::now, so there is zero overhead in the default path.
@bhensley5

Copy link
Copy Markdown
Owner

Thanks for the contribution. This is a good scoped first step for GQL profiling visibility. Merging via squash.

@bhensley5
bhensley5 merged commit 43d1615 into bhensley5:main Jul 3, 2026
3 checks passed
bhensley5 pushed a commit that referenced this pull request Jul 3, 2026
…#16)

Extends the profile-gated instrumentation (#15) with per-query
planning-effort counters and a `prepare` stage timer.

- `prepare` timer fills the gap between lowering and the published
  snapshot (order-by resolution, row-count evaluation, target config),
  the only previously uninstrumented stage around the graph-row call.
- Counters: node_legal_universe_sources, edge_source_consults,
  edge_source_misses, secondary_index_followups.

Counters use a crate-local thread-local the planner increments (always)
and the GQL layer snapshots when profiling; values ride the existing
GqlExecutionExplain.notes line, so no public struct or connector changes
and the shared read substrate is untouched.

profile=false is unchanged: timer mark() returns None and counter values
are neither snapshotted nor surfaced. Measured fire count is 3-8 per query
(~22-57 ns, ~0.001-0.005% of read latency).
@heunghingwan
heunghingwan deleted the feat/planner-instrumentation branch July 3, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants