Skip to content

gc: safepoint poll strides are reviewed per-site but compose, and the 1-in-64 basis is an unmeasured constant #10665

Description

@proggeramlug

Summary

Safepoint polls in the regex path are being strided 1-in-64 as a performance measure. #10494 landed the first (the pre-search poll); a second is in flight for perex_replace_direct::replace. Each is individually sound and individually cheap. What nobody is tracking is the aggregate: the maximum number of iterations along any path between two executed polls.

This issue exists because the thing that will collide with it — concurrent GC — is an approved direction with no issue of its own, so there is currently nowhere durable for this to be recorded. A PR body is not that place; nobody re-reads one.

Today the debt is unobservable, and that is verified, not assumed

  • gc_runtime_safepoint_poll is two lines wrapping gc_runtime_safepoint_report(). perex_runtime::poll returns Ok(()) unconditionally, so it services no interrupt and can raise no cancellation.
  • There is no cross-thread safepoint protocol — no stop_the_world, no safepoint_request, no rendezvous or handshake in gc/. gc/roots/shadow_stack.rs states the actual model: "GC is stop-the-world relative to this TLS". The collector runs on the allocating thread.
  • The one cross-thread mechanism, GC_UNSAFE_ZONES (gc/policy.rs:4668), is an AtomicI32 suppression counter letting stdlib features block user-initiated gc() while workers hold live refs. Its own doc calls it "a global stop-the-collector". It is a flag, not a rendezvous anyone waits at.

So max-time-to-safepoint is unmeasurable today because nothing is waiting to observe it. That is a statement about the present, not about the design.

Two things that make this worth a tracking issue anyway

1. Strides compose, and each site is justified in isolation. Two sites at 1-in-64 in sequence along a path give up to 128 iterations between executed polls; nested, 4096. Every individual stride will look like "only 1-in-64" to its own reviewer. The quantity that matters to a concurrent collector is not any single stride but the max iteration count along any path between two consecutive executed polls, and nothing currently computes or bounds it. A third and fourth stride, each individually reasonable, is how this becomes a problem without anyone approving it.

2. The 64 is an unmeasured constant that has become precedent. As I understand #10494's own doc comment, removing that poll entirely left cycle_starts, completions and steps identical across 48,000,000 allocation-free calls interleaved with churn. That evidence argues for removal; it does not derive 64. The 64 is a safety margin someone chose — reasonably — and it is now being cited as precedent for a second site. That is how an unmeasured constant becomes load-bearing across a subsystem.

The stated reason for striding rather than removing is to retain "the option of servicing a due collection from a loop that allocates nothing, which is how a non-allocating mutator participates in an incremental cycle." If that is the purpose, then the basis should be justified against that purpose: how stale can a non-allocating mutator's participation get before the incremental cycle suffers? As far as I can tell that has never been measured, at 64 or at any other value.

What would close this

Either of:

Plus, if striding continues: something that bounds the aggregate — max iterations between executed polls along a path — rather than reviewing each site alone.

Note for whoever builds the concurrent collector

A concurrent or parallel collector needs every thread to reach a safepoint promptly. At that point these strides stop being free and the workload that exposes them is a larger input, not a different metric on the same one. There are two today. Please check how many there are then, and what the aggregate is, before assuming the regex path yields quickly.

Filed from the #10362 GC campaign. The #10494 doc-comment details above are as reported to me by the session that read them; the stop-the-world model, the absence of a cross-thread protocol, and the GC_UNSAFE_ZONES semantics I verified directly in the tree.

Activity

  1. proggeramlug commented on Sep 18, 2026

    @proggeramlug
    ContributorAuthor

    Correction: "two strides give 128" is wrong as stated — it depends on the unit, and that is the useful part

    The issue above says two 1-in-64 sites in sequence give up to 128 iterations between executed polls. That is only true when the two counters advance on different units. I filed it without that qualifier and it overstates the case.

    The distinction, which is the discriminator this issue was missing:

    • Parallel — both counters advance on the same unit. Two 1-in-64 strides both counting searches still guarantee a poll within 64. Either counter alone is sufficient; the second adds nothing and costs nothing.
    • Nested — the counters advance on different units. A 1-in-64 on searches inside a 1-in-64 on matches gives 4096.

    So the quantity to bound is still "max iterations along any path between two executed polls", but the per-site question a reviewer can actually be asked is sharper than I wrote it: what unit does this counter advance on, and is it the same unit as every other stride on this path?

    The in-flight case is parallel, not nested

    Reported by the session that implemented it (I could not verify this in my own checkout — it has no safepoint poll code): poll_on_stride ticks PRE_SEARCH_POLL_TICK once per search, and the replace collection loop performs exactly one search per iteration, with its own stride counting iterations. Same unit, so the worst-case gap on that path stays 64 rather than becoming 128. On the paths where the lent scratch is unused — the owned path and Lent::Fallback — find_near polls unconditionally, so the real bound there is 1.

    That change therefore does not increase the worst-case gap on its path at all, and the composition concern in this issue does not apply to it.

    What this changes about the issue

    It makes it checkable instead of merely cautionary. "Strides compose" is not a property of striding; it is a property of striding on mismatched units. A site whose counter advances on the same unit as the bound already covering its path is free on this argument; a site whose counter advances on a different unit is not, and needs the aggregate computed.

    The implementing session has put the same distinction in the code comment including the negative case — that striding a site whose counter advanced on a different unit would not be safe on this argument — so the reasoning cannot be copied to a site where it does not hold. That is the right shape for this kind of constraint: the comment that stops the next person is the one that states when the argument fails.

    The open question, restated as its own item

    Separately from composition, and still unanswered by anyone:

    How stale can a non-allocating mutator's participation in an incremental cycle get before the cycle suffers?

    This is what decides whether 64 is generous or reckless, and nothing measures it — at 64 or at any other value. It is upstream of every individual stride decision, including ones already shipped. If this issue closes on anything, it should close on this being measured.

    The citation point stands and has already been acted on: the in-flight change no longer cites #10494's 64 as precedent, and instead states that 64 is a chosen margin there rather than a derived one, matching it deliberately because the two must count the same unit. Nothing in it relies on 64 being correct — only on not exceeding the bound already covering the path — so if the number above is ever derived, both move together.

  2. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    The "when may a poll be strided" question now has a second criterion, with evidence in both directions

    This issue covers stride composition (same unit → parallel, bound stays 64; different units → nested, 4096). A three-site controlled contrast in the same file has now settled the prior question — whether a given poll may be strided at all — and it belongs here too, because the two criteria are independent and a site must pass both.

    Same change, nine interleaved rounds on replace1m, measured by the session that owns that path:

    site instructions, small probe instructions, replace1m peak RSS median rounds higher
    Pieces::finish (#10657, retired) −27.9% — +13.2% 8/9
    collection loop (#10666, shipped) −3.5% −1.6% −0.5% 4/9
    output loop (untouched) −3.6% +3.7% slower +6.9% med, +10.7% mean 9/9

    The criterion

    A loop that allocates nothing traced has an unexposed poll and may be strided. A loop downstream of user code and traced allocation has a poll that pays for itself — in throughput as well as memory.

    This is checkable statically, before anyone measures: look at whether the loop body creates traced garbage. The collection loop writes match spans into a native buffer, so its poll enables no collection and removing it costs nothing. The finish passes and the output loop sit downstream of JS strings, traced pieces and (for the output loop) a replacer callback that has run user code.

    The part that matters most for anyone reading this later

    On the output loop, nulling the poll made the allocating workload 3.7% slower in instructions — a throughput regression, not merely a memory one — while two small-live-set probes reported the same change as −3.6% and −1.7% faster.

    So on that site the probe reports a win for a change that is a regression on both axes at once. A reviewer handed only the small-probe number would have had no way to see it. This is the same shape as #10377, where deleting transient-scratch churn cut GC cycles 79% and made a 1M replace loop 16% slower: a pacing input can be load-bearing, and a workload whose live set is too small cannot show its cost.

    The site also fails the campaign's ≤ +10% peak RSS budget on the mean (+10.7%) independently of the throughput result.

    Consequence for the poll lever generally

    Of three sites in this path, exactly one was free to stride, and it was the small one — 3.5% of the template workload, against Pieces::finish's 27.9%. Every large win sits at a site where the poll is doing real work. The ~33% that the budgeted ladder costs cannot be bought by polling less. It has to be bought by making the decision cheap, which is the epoch-keyed memo in #10659 — or it stays.

  3. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    Correction to my previous comment. Its closing line — "the ~33% that the budgeted ladder costs cannot be bought by polling less" — is wrong and I am retracting it. The retired #10657 used a stride of 4096 inherited from api::QUANTUM; at 512 the instruction win survives (−27.8% template, −9.4% replace1m) and the peak-RSS cost drops from +13.2% to +0.3% median. The memory bill was the constant, not the lever. Detail and the re-pricing are on #10659.

    What stands, and is unaffected: the two independent static criteria a site must pass before striding — the loop must allocate nothing traced (exposure), and its counter must advance on the same unit as every other stride on the path (composition). The three-site contrast still shows a poll can be load-bearing on both throughput and memory, and that a small-live-set probe can report a win for a change that regresses both.

    It also adds a third instance to this issue's own theme: 4096 was inherited, not derived, exactly as 1-in-64 was chosen as a margin rather than derived. That is now two underived constants in the same subsystem, one of which was wrong enough to retire a good change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions