Bug #22218
closedLine TracePoint misses executed loop condition after a guard
Description
TracePoint.new(:line) can miss the line event for an executed loop condition when the loop follows a conditional guard.
This is related to Bug #15980, which fixed the same control-flow problem for line coverage. The coverage-specific workaround does not apply to ordinary line TracePoint consumers, so TracePoint and Coverage can still disagree.
Affected versions¶
This affects Ruby 3.3 and later. The behavior was reproduced on Ruby 3.4.4 and Ruby 4.0.5, both of which report [1, 3]. Ruby 3.3.0 through 3.3.11 retain the same coverage-only compiler guard; the source explicitly notes that the TracePoint line event does not occur. Ruby 4.1 development versions are also affected without the proposed patch.
Reproduction¶
source = <<~RUBY
raise if 1 == 2
while true
break
end
RUBY
lines = []
TracePoint.new(:line) do |trace|
lines << trace.lineno if trace.path == "example.rb"
end.enable do
eval(source, binding, "example.rb")
end
p lines
Actual result¶
Expected result¶
The same problem occurs with a non-constant predicate, for example:
Cause¶
Jump-to-jump peephole optimization retargets the conditional branch past the synthetic jump carrying the line event. As a result, execution reaches the loop body without visiting the event-bearing instruction.
Proposed Fix¶
The proposed fix is ruby/ruby#18122.
Ruby already stops this jump-to-jump optimization when line coverage is enabled and the skipped jump has event flags. The patch generalizes that existing guard: it skips this specific retargeting whenever the intermediate jump carries RUBY_EVENT_LINE or RUBY_EVENT_COVERAGE_LINE, regardless of whether coverage is active.
Other jump-to-jump folding remains enabled. The change only retains the event-bearing jump that TracePoint must visit; it does not add trace-edge metadata or require new VM, YJIT, or ZJIT handling. This is the smaller alternative suggested in the discussion of ruby/ruby#17825.
Performance¶
The retained jump costs one additional YARV dispatch when the interpreter traverses an affected edge. Performance-sensitive JIT execution can optimize away the extra control-flow overhead: YJIT emits no machine jump when it can place the target block next, while ZJIT cleans up the jump chain in its control-flow graph.
The following results compare Ruby immediately before and after the patch on an Apple M4 Pro. Each result is the median of 9 or 11 samples after 3 warmups:
| Targeted benchmark | Baseline | Patched | Change |
|---|---|---|---|
| Hot affected edge, interpreter | 46.20 M iterations/s | 45.74 M iterations/s | -1.0% |
| Minimal guarded reader, interpreter | 30.95 M calls/s | 30.13 M calls/s | -2.7% |
| Minimal guarded reader, YJIT | 272.56 M calls/s | 272.56 M calls/s | 0.0% |
| Minimal guarded reader, ZJIT | 435.34 M calls/s | 436.89 M calls/s | +0.4% |
For a synthetic hot-edge benchmark, two long YJIT runs in opposite order varied between -0.5% and +0.4%, and ZJIT differed by +0.04%. There was therefore no repeatable JIT regression. The interpreter figures are deliberately concentrated worst cases where every iteration or call traverses the affected edge.
Related work¶
- Previous coverage issue: Bug #15980
- Earlier edge-metadata approach: ruby/ruby#17825
- Selective peephole fix: ruby/ruby#18122
Updated by ioquatix (Samuel Williams) 2 days ago
- Related to Bug #15980: Coverage shows while/until after raise if/unless as uncovered line added
Updated by Anonymous 2 days ago
- Status changed from Open to Closed
Applied in changeset git|79e6d37b5569be4c772e9d41e8a5447e82c09683.
Preserve line events across jump optimization. (#18122)
[Bug #22218]