Skip to content

Don't re-pay the cost of unresolvable watch expressions on every stop - #1411

Closed
zhuwanhong wants to merge 2 commits into
vadimcn:masterfrom
zhuwanhong:claude/codelldb-performance-investigation-gkg2qu
Closed

zhuwanhong wants to merge 2 commits into
vadimcn:masterfrom
zhuwanhong:claude/codelldb-performance-investigation-gkg2qu

Conversation

@zhuwanhong

Copy link
Copy Markdown

Problem

A watch expression is re-evaluated every time the debuggee stops, so its cost is added to
every step. When one of its identifiers does not resolve in the current frame, nat_eval()
falls through SBFrame::FindVariable() into two lookups that are expensive in a large program:

  • SBFrame::FindValue() with eValueTypeVariableGlobal/Static materializes the compile
    unit's globals and statics;
  • SBFrame::EvaluateExpression() brings up the C++ expression parser, which has to exhaust
    every lookup path before it can report an undeclared identifier.

Both were paid again on every single step.

This came out of a report of CodeLLDB stepping far more slowly than the MS C/C++ extension on
a large MFC/boost/OSG application (Windows, MSVC/PDB, ~110 loaded modules). Each step took over
2.5 s. The cause turned out to be a single stale watch expression left over from another
function — nothing in the UI connected the two.

Timing added inside nat_eval() (nmod is SBTarget::GetNumModules()):

MISS nmod=110 find=0.013 findvalue=3.384 varpath=0.026 eval=2.670
HIT  nmod=110 find=0.000

From the DAP log, one step before the change:

phase time
nextstopped 24 ms
threads 0 ms
stackTrace (top frame) 1 ms
evaluate (the stale watch) 2680 ms
stackTrace (19 more frames) 42 ms
variables (locals) 68 ms

The evaluation also occupies the adapter's event loop, so scopes and the second stackTrace
sat in the queue behind it.

Changes

Cache identifiers that cannot be resolved, per debug session, keyed by the expression, the
enclosing function and the module count. Stepping in that session went from ~2.9 s to ~0.15 s
once the cache warms up.

Warn about slow watch and hover expressions — a console message, once per expression, when
an automatically re-evaluated expression takes more than half a second. Until now there was
nothing to connect sluggish stepping to its cause; this whole investigation was needed to find
what the adapter could simply have said.

Why the cache is sound

  • It is consulted only after FindVariable() has failed, and FindVariable() still runs on
    every evaluation — so a local that has come into scope is still found.
  • What is remembered is "nothing outside the frame's own variables matched". For a given scope
    that can only change when modules are loaded or unloaded, hence the module count in the key.
  • Unqualified lookup depends on the enclosing class scope (m_foo resolving via an implicit
    this), hence the function name in the key.
  • It is sound only because nat_eval() is always handed a name: the simple-expression dialect
    evaluates operators, indexing and member access itself. Native expressions are arbitrary C++
    whose failures may depend on program state, and are deliberately not cached.

Not addressed

The underlying LLDB costs are untouched. A single failed lookup still takes seconds on a large
MSVC/PDB target, and watch expressions written with the /nat prefix still pay it on every
stop. That looks like an LLDB issue rather than a CodeLLDB one: a valid native expression
(1+1) evaluates instantly, and log timers accounts for only ~2 ms, so the time is spent in
uninstrumented name-lookup / expression-parser code.


🤖 Generated with Claude Code

claude added 2 commits August 28, 2026 12:59
A watch expression is re-evaluated every time the debuggee stops.  When one of
its identifiers does not resolve in the current frame, nat_eval() falls through
FindVariable() into two lookups that are expensive in a large program:

  - SBFrame::FindValue() with eValueTypeVariableGlobal/Static materializes the
    compile unit's globals and statics;
  - SBFrame::EvaluateExpression() brings up the C++ expression parser, which
    must exhaust every lookup path before it can report an undeclared identifier.

Both were paid again on every single step.  Measured on an MFC/boost application
whose stale watch expression made stepping unusable:

    MISS nmod=110 find=0.013 findvalue=3.384 varpath=0.026 eval=2.670
    HIT  nmod=110 find=0.000

Remember the failures per debug session, keyed by the expression, the enclosing
function (unqualified lookup depends on the class scope) and the module count (a
name may become resolvable once a module is loaded).  Stepping in that session
went from ~2.9s to ~0.15s per step once the cache warms up.

The cache is consulted only after FindVariable() has failed, so it cannot mask a
local that has come into scope.  It is sound only because nat_eval() is always
handed a name: the simple-expression dialect evaluates operators, indexing and
member access itself.  Native expressions are arbitrary C++ whose failures may
depend on program state, and are not cached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nfm9PxpvSxsYYMTMZiRzMD
Watch expressions are re-evaluated every time the debuggee stops, so a slow one
adds its full cost to every step.  Nothing connects the two for the user: all
they observe is that stepping has become sluggish, with no indication of which
expression is responsible or that an expression is responsible at all.

Print a console warning, once per expression, when an automatically re-evaluated
expression takes more than half a second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nfm9PxpvSxsYYMTMZiRzMD
@vadimcn

vadimcn commented Aug 28, 2026

Copy link
Copy Markdown
Owner

I don't like this change - the extra complexity isn't worth it. Just delete the watch expression, or collapse the Watch panel.

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.

3 participants