Skip to content

Rewatch: replace wave scheduler with DAG + critical-path priority#8374

Open
jfrolich wants to merge 4 commits intorescript-lang:masterfrom
jfrolich:no-waves
Open

Rewatch: replace wave scheduler with DAG + critical-path priority#8374
jfrolich wants to merge 4 commits intorescript-lang:masterfrom
jfrolich:no-waves

Conversation

@jfrolich
Copy link
Copy Markdown
Collaborator

Summary

  • Replace rewatch's wave-based topological scheduler (par_iter per wave) with a work-stealing DAG dispatcher. Waves stall on the slowest file per round; DAG scheduling lets a module start as soon as its own dependencies finish.
  • Order the ready queue by critical-path priority — the length of a module's longest downstream dependency chain. Bottlenecks start first instead of being picked arbitrarily from within a wave.
  • All changes are in rewatch/src/build/compile.rs; no changes to bsc, the wire protocol, or rescript.json semantics.

Design

  • Workers run inside rayon::in_place_scope, bounded to rayon::current_num_threads() concurrent tasks so the BinaryHeap ordering actually decides dispatch (spawning everything would lose priority to rayon's deque).
  • Completions flow back over std::sync::mpsc. The dispatcher on the main thread:
    • decrements in-universe pending_deps counters for dependents,
    • propagates the dirty flag locally when a module's cmi changed,
    • pushes newly-ready modules onto the priority heap.
  • Workers only read &BuildState; all BuildState mutations (compile_state, timestamps, compile_dirty) happen after the scope exits, keeping worker reads and main-thread writes disjoint without any interior mutability.
  • Cycle detection triggers when the heap drains with in-flight == 0 and the universe incomplete, then delegates to the existing dependency_cycle::find / format path — same error message as before.
  • Critical-path priorities are computed once per build with a reverse-topological sweep; modules caught in a cycle get priority 0 and are surfaced by the stall check.

Test plan

  • cargo build --manifest-path rewatch/Cargo.toml
  • cargo clippy --manifest-path rewatch/Cargo.toml --all-targets --all-features -- -D warnings
  • cargo fmt --check --manifest-path rewatch/Cargo.toml
  • cargo test --manifest-path rewatch/Cargo.toml — 68 unit tests pass
  • make test-rewatch — full integration suite passes, including:
    • 09-dependency-cycle (cycle snapshot unchanged)
    • 13-no-infinite-loop-with-cycle
    • watch-mode tests (warning persistence, atomic saves, new-file pickup, config-change rebuild)
    • snapshot tests (16-snapshots-unchanged)

Notes

  • Log/error output ordering is now determined by module completion order rather than wave boundaries. Existing snapshot tests still pass because they assert on content rather than relative ordering of messages from independent modules.
  • clean_modules (which the old code built but never read) is gone.

🤖 Generated with Claude Code

@cknitt
Copy link
Copy Markdown
Member

cknitt commented Apr 19, 2026

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a22a9437d3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +395 to +397
if !is_clean {
dirty_set.insert(dep.clone());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist propagated dirty flags before aborting on errors

When a dependency finishes with !is_clean, this code only updates the local dirty_set; it does not immediately set module.compile_dirty on the dependent. Because the dispatcher stops spawning new work after the first compile error (has_errors), any dependents that were marked dirty but never scheduled in this run can remain compile_dirty = false in build_state, so a subsequent build may skip recompiling stale modules even though an upstream .cmi changed. The previous scheduler wrote compile_dirty = true directly during propagation, which avoided this incremental-build inconsistency after failed builds.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 19, 2026

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@8374

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@8374

@rescript/darwin-x64

npm i https://pkg.pr.new/@rescript/darwin-x64@8374

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@8374

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@8374

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@8374

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@8374

commit: 32701e8

jfrolich and others added 3 commits April 20, 2026 17:12
Instead of compiling modules in topologically-sorted waves (which stall
on the slowest file per wave), schedule them from a priority queue on a
work-stealing dispatcher. Priority is the length of the module's longest
downstream dependency chain, so bottlenecks start first.

Workers run inside a `rayon::in_place_scope` bounded to
`rayon::current_num_threads()` so the heap ordering actually decides
dispatch. Completions flow back over `std::sync::mpsc`; the dispatcher
updates pending-dep counters, propagates the dirty flag, and pushes
newly-ready modules back onto the heap. `BuildState` mutations happen
after the scope exits so worker reads and main-thread writes stay
disjoint. Cycle detection now triggers when the heap drains without
completing the universe.

Signed-Off-By: Jaap Frolich <jaap@tella.com>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restore the `.unwrap()` on `package.namespace.to_suffix()` for mlmap
  dispatch so a missing namespace suffix panics loudly instead of being
  silently papered over.
- Drop the defensive `.unwrap_or_default()` on the post-completion
  dependents lookup and the `if *count == 0 { continue }` guard on the
  pending-deps decrement; both can only fire when the graph itself is
  inconsistent, and hiding that would mask bugs. Align with the
  unwrap-style assertions the old scheduler used.
- Sort `results_buffer` by module name before applying results so the
  returned `compile_errors` / `compile_warnings` strings and the per-
  package `.compiler.log` writes are deterministic across runs despite
  non-deterministic completion order.

Signed-Off-By: Jaap Frolich <jaap@tella.com>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a predecessor's cmi changes the dispatcher only marks the local
dirty_set; it used to not write compile_dirty = true back onto
build_state. If the first compile error aborted dispatch before those
dependents were scheduled, their compile_dirty stayed false in
build_state, and the next incremental build would skip recompiling them
even though an upstream .cmi had changed — producing stale output.

Persist dirty_set onto build_state.modules[].compile_dirty right before
applying compile results. The success path in the result loop still
overrides compile_dirty = false for modules that actually recompiled
cleanly in this run, so only the propagated-but-not-scheduled modules
retain the flag.

Signed-Off-By: Jaap Frolich <jaap@tella.com>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@rolandpeelen rolandpeelen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome find, and this makes so much sense! Can't believe we didn't think of this before.

Left one comment / optimisation, which I think is a case that happens a lot.

}

/// Compute the critical-path priority of every module in the universe:
/// `priority(m) = 1 + max(priority(d) for d in in-universe dependents of m)`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is good, but possibly, as an experiment, we can do better;

Right now, it only computes the longest chain it unlocks. Take these examples:

  1. One chain of files, Result.map -> Option.map -> Array.map -- We have a single file that uses result, result uses option, and option uses array
  2. One broader chain of files, List.map, where we have 100 files depending on List.

In this case, I think this calculates 1 first, even though, if it would compile 2 first, it can unlock 100 other files that can be computed in parallel.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can weight this:
score(M) = max(critical_path_length(M) * N, downstream_count(M))

Where N is the bias towards downstream count. So 1 would be the winner in the old case, but if we put 10 there, then if the downstream count outweighs the path length by a factor of 10, it wins and will compute that first.

Signed-Off-By: Jaap Frolich <jaap@tella.com>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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