Implement and document thread-safe use of the iterate API#6653
Conversation
mythical-fred
left a comment
There was a problem hiding this comment.
Nice fix — Consensus-wrapping the four iterate_with_condition* variants closes a real cross-worker termination bug, and the iterate_with_condition_multi_worker regression test is exactly what I would have asked for. The refactor of tutorial 10/11 into paired recursive/iterative variants against a shared transitive_closure helper reads well, and tutorial13 mirrors that structure. A few blockers and nits below.
Blockers:
- Commit is not DCO-signed.
git log -1 031f525echas noSigned-off-by:trailer.CONTRIBUTING.mdrequires DCO sign-off on every commit. Please amend withgit commit --amend -sand force-push. - CI has not run on this branch (
gh pr checks 6653reports "no checks reported on the 'recursion_via_iterate' branch"). After the amend/force-push, please confirm the workflows execute and go green — this touches enough of the iteration machinery that I want to see the test suite before signing off.
Answers to your open questions in the PR body:
- On
is_fixedpointvscheck_fixedpoint: I lean toward dropping the wrapper.check_fixedpointis already a public trait method with the same signature; addingis_fixedpointjust gives it a second name (with better rustdoc, admittedly). Cleaner alternatives: either move your new rustdoc ontocheck_fixedpointitself, or ifis_fixedpointis the preferred user-facing spelling, deprecatecheck_fixedpointon that same PR. Leo, up to you — I would not block on this, but two aliases for one operation is a papercut. - On a
RecursiveStreams-like helper that opts out of implicit distinct and adds a depth limit: yes, worth having, but as a follow-up. This PR is already big enough. Ship the tutorials first; the helper can crystallize out of the shape ofmake_termination_checkonce someone writes the second real user of it.
| /// require that a multi-worker run computes the same reachable set as a | ||
| /// single-worker run, across repeated builds to shake out scheduling races. | ||
| #[test] | ||
| fn iterate_with_condition_multi_worker() { |
There was a problem hiding this comment.
Good regression test. Two small extensions worth considering: (1) exercise the iterate_with_conditions (plural) path too — that variant folds an .iter().all(...) across a Vec<Condition> inside the same Consensus, and its per-worker eagerness is a separate code path; (2) the current test loops 5×; on my mental model that is enough to shake most races out, but if you have a #[cfg(feature = "stress")] or similar knob elsewhere in the crate, cranking it to a few hundred for the CI stress job would be cheap.
There was a problem hiding this comment.
For now, I leave it as is and let others decide if that's compatible with the CI pipeline here or not.
I think these are two orthogonal changes. You should probably file issues. |
mihaibudiu
left a comment
There was a problem hiding this comment.
Can you reorganize the code structure a bit?
If the data generation is shared between examples, put it into its own file.
Each tutorial example should be self-contained.
If you have negative examples, label the files carefully, so people don't take it as a positive example.
| .into_iter(); | ||
|
|
||
| let edges = root_circuit.add_source(Generator::new(move || edges_data.next().unwrap())); | ||
| // Play around with different thread setups on your machine. With this little |
There was a problem hiding this comment.
I thought that rust comes with some very nice benchmarking tools like Criterion. Why not use these?
There was a problem hiding this comment.
At some point I'd love to this but I'd do that in a separate PR. So far I think it is educational to see how to manually construct an equivalent circuit to the ones created by the recursive API through using the lower-level iterative API. If you don't want to see any stats as an indication of what's going on behind the scenes, I'm fine with deleting them. However, I think especially for tutorial 10 the performance gain from dropping the distinct is part of the tutorial.
| drive_circuit, make_termination_check, | ||
| }; | ||
|
|
||
| /// If we set STEPS to 3 in this tutorial, the computation will not terminate |
There was a problem hiding this comment.
I don't understand why you are taking a functional tutorial and turning it into a broken example.
Maybe create a separate "broken-example" fille and leave this one alone
There was a problem hiding this comment.
No, the tutorial works just as before! Nothing is broken and in fact, not much has changed from before from a logical point of view. The computation does not terminate only if people include the data from the third transaction/step (not sure about the best terminology here; a year ago when I first contributed these tutorials the concept of a transaction didn't exist and these were usually referred to as steps).
Tutorial 10 and 11 always operated on top of the same data to make the termination issue very clear. The only difference now is that instead of having had the third transaction commented out in tutorial 10 (for people to play around with), the data for both tutorial 10 and 11 is now shared in the transitive_closure module to not have it duplicated anymore. And instead of commenting stuff, you can now control the inclusion of the third transaction with the STEPS constant.
| }) | ||
| } | ||
|
|
||
| /// Computes the transitive closure of a graph using DBSP's lower level `iterate` |
There was a problem hiding this comment.
Why not put this into a separate file?
There was a problem hiding this comment.
The iterate_variants and recursive_variants of tutorial 10, 11, and 13 show two approaches to construct circuits for the exact same problems: transitive closure with acyclic data (tutorial 10), transitive closure with cyclic data (tutorial 11), as well as mutual recursion and graph coloring (tutorial 13). Now, for each tutorial we show how to construct such a circuit using the higher-level recursive API and the lower-level iterative API. I'm fond of having each tutorial deal with one problem and possibly show multiple variants of how to approach that problem with DBSP circuits. Hence, both variants still live in the same file.
Of course we can structure things following a different paradigm but I tend to write the tutorials in a way I had loved to read them when I started figuring out how to compute these problems with DBSP.
mythical-fred
left a comment
There was a problem hiding this comment.
Nice piece of work. The Consensus-wrapping of iterate_with_condition[_and_scheduler] (and the plural variants) is a real bugfix, not just a hardening — those methods previously let a worker whose local condition went true exit while peers still had work, which would silently produce wrong results in multi-worker runs. The iterate_with_condition_multi_worker regression test with the 5-iteration loop is exactly the right shape for shaking out the scheduling races.
The tutorial pairing (recursive vs. iterate side by side, plus mutually recursive in tutorial13) is genuinely useful documentation; publishing Consensus and the is_fixedpoint helper gives users a real path to bypass the implicit distinct when they know the shape of their data. The make_termination_check helper reads well and correctly folds the per-worker fixed-point vote through the shared Consensus, and the MAX_ITERATIONS fallback is a sensible safety net.
Three inline observations below (one perf note, one micro-nit, one API question — none are blockers). Also see the author's own open question about is_fixedpoint vs the already-public check_fixedpoint — I'd side with dropping the alias, elaborated inline.
Approving.
| /// detect convergence, typically combining the per-worker results across | ||
| /// workers via [`Consensus`]. See the documentation of [`Scope`] for the | ||
| /// meaning of the parameter but passing `0` refers to the current circuit. | ||
| fn is_fixedpoint(&self, scope: Scope) -> bool { |
There was a problem hiding this comment.
This is a pure alias for the already-public check_fixedpoint (declared on this trait at line 1864, plumbed through everywhere else). Adding a second name for the same operation isn't free — users hit two docs.rs entries doing the same thing and have to guess which is canonical. If the goal is a friendlier is_* predicate name for the iterate-based API, consider renaming check_fixedpoint → is_fixedpoint outright (Rust convention for -> bool predicates) and moving this docstring onto that method. If you want to keep the existing name, at least drop this alias and add the extra prose to check_fixedpoint — one canonical entry point beats two. (Also called out in the PR description as an open question for @ryzhyk.)
There was a problem hiding this comment.
I'll leave that up to @ryzhyk to decide! I have no preference here.
| flake.nix | ||
| flake.lock | ||
| .envrc | ||
| .direnv |
There was a problem hiding this comment.
Nit: flake.nix and flake.lock are project-level artifacts, not personal ones. If the team ever adds a Nix flake for the repo, ignoring them by default will silently hide someone's legitimate check-in. Personal Nix bits usually live under .envrc/.direnv (already covered here) or a locally-git-excluded flake.local.nix per convention. Consider dropping the two flake.* lines and letting anyone who wants a private flake add it to .git/info/exclude locally.
There was a problem hiding this comment.
I've deleted the two flake* lines and excluded them locally.
Is there interest in a flake.nix file for this project? I have one on my machine which sets up everything to have a dev environment for DBSP but not for any Java stuff required for the compiler so far. If you want, I can include this as a starting point.. Let me know!
Good idea, I've created #6666 to discuss a new API for recursive streams which also refers to #3318 and #5193. |
4002806 to
436a721
Compare
1. It exposes machinery to manually construct recursive circuits using the lower-level `iterate` API instead of the higher-level `recursive` API, that is, it publicly exports `Consensus` to make such circuits thread-safe. 2. The four sibling methods `iterate_with_condition`, `iterate_with_conditions`, `iterate_with_condition_and_scheduler`, and `iterate_with_conditions_and_scheduler` are now thread-safe by wrapping their condition(s) in a `Consensus`. 3. It updates `tutorial10.rs` and `tutorial11.rs` to show semantically equivalent circuits using the `iterate` API next to the `recursive` API and does a very basic runtime comparison among the two to demonstrate the extra `distinct` happening behind the scenes and its costs. 4. It contributes the new tutorial `tutorial13.rs` which does the same thing for mutually recursive circuits by reusing the graph coloring example from the documentation of `recursive_dynamic`. Signed-off-by: Leo Stewen <lstwn@mailbox.org>
Signed-off-by: Leo Stewen <lstwn@mailbox.org>
436a721 to
7c4d6d7
Compare
|
Thanks for the feedback, I've addressed it so far. Open questions or things you might still want to look into are left as unresolved above. Let me know :) |
mythical-fred
left a comment
There was a problem hiding this comment.
All four nits from the previous round addressed. Nice bonus: with_consensus now scoped by GlobalNodeId so identical labels in different subcircuits can't collide on the broadcast, and it's reused inside fixedpoint() — good DRY. Consensus also gained a proper # Safety section describing the "same order, once per logical decision, reuse across ticks" invariants.
One soft nit for the merge: please squash Address PR feedback into the original commit before landing — feldera keeps a linear history on main and that message shouldn't survive as its own commit.
ryzhyk
left a comment
There was a problem hiding this comment.
I like the general direction, but I don't think this is the right shape for these changes.
- The tutorial is intended to demonstrate the basic usage of the DBSP API to new users. I don't think that distinct-less termination belongs in this category. I would keep the tutorial as is regardless of where we go with the changes in this PR.
iterate_with_conditionis intended as a low-level API. It is the user's responsibility to provide a sound termination condition that guarantees that all workers stop iterating at the same time. Not all possible termination conditions require a consensus object. For example, a condition that iterates a fixed number of times doesn't require it. The new functionality you are implementing can be achieved using the currentiterate_with_conditionAPI by providing consensus object as part of the condition.- Out of curiosity: do you have some example use cases where
distinctis unnecessary for termination (I am sure they exist, just can't think of many off the top of my head. Perhaps some graph queries where the graph has a special shape?)
I'd like to suggest two options for improving this PR:
- Minimal DBSP changes: add any missing exports and implement the flexible recursion API outside of the crate, possibly as an example as opposed to a tutorial.
- Add a new operator to the DBSP crate (e.g.,
bounded_recursion). I think it's a useful extension that will benefit other users.
|
I think he already has the example where the distincted value is the result of an aggregate, so it cannot have a weight larger than 1. |
In this case, distinct should be a noop and should be harmless. |
|
According to his benchmarks it's not. But I suggested to use |
Signed-off-by: Leo Stewen <lstwn@mailbox.org>
Signed-off-by: Leo Stewen <lstwn@mailbox.org>
Signed-off-by: Leo Stewen <lstwn@mailbox.org>
|
Thanks for the feedback! I agree, maybe the tutorials become too overwhelming by including the iterate variants. I've reverted them. Also, I've removed the I've created proper benchmarks using Criterion for the three cases: transitive closure over acyclic data (which doesn't require the distinct, (1)), transitive closure over cyclic data (2), and bipartite graphs using mutual recursion (3). Here's what I found:
If we can resolve the issue with @ryzhyk: Here are some examples I know about:
Then there is another small semantic nuance: Distinct collapses the z-weights to either 0 or 1. If you are working with an acyclic directed graph, I think the z-weights lend themselves to track multiplicity quite naturally: If there are multiple paths from node A to node B the z-weight should reflect that multiplicity without any extra aggregation step, I suppose. |
| ) | ||
| // We explicitly mark the stream as distinct to avoid the | ||
| // unnecessary implicit distinct. | ||
| .mark_distinct(); |
There was a problem hiding this comment.
Is this the wrong place to mark the stream distinct?
There was a problem hiding this comment.
I recommend using the profiler in the webui to inspect the generated circuit, you will see exactly all the lowest-level operators that are being used.
There was a problem hiding this comment.
@mihaibudiu , there is no webui for DBSP circuits.
There was a problem hiding this comment.
You can use DBSPHandle::dump_profile to dump the profile in the graphviz format. Please feel free to share it here. My guess is the mark_distinct annotation is in the right place, but the annotation itself may not be accurate, i.e., the output is not distinct, and the circuit iterates more without the distinct call.
There was a problem hiding this comment.
And I was hoping to retire this API...
There was a problem hiding this comment.
This is the profile of the run with 500 nodes on the recursive circuit. It looks like the distinct is still applied.
Full profile of all four workers profile.zip.
There was a problem hiding this comment.
Claude found the issue (see below). I am not 100% sure about the output of join being sharded, but the rest of it is correct. My takeaway is that mark_distinct is a low-level API and probably shouldn't be used in user code. I like the idea of a distinct-less variant of recursive. We should probably add that instead of hacking around it.
The layer mismatch
recursive is a thin typed wrapper. The user closure returns a typed stream, and recursive immediately erases it with .inner() before handing it to dyn_recursive:
benchmark closure (TYPED) recursive.rs:235 dyn_recursive
────────────────────────── ─────────────── ─────────────
len_1.plus(..).mark_distinct() ──► .map(|s| s.inner()) ──► S::distinct(stream) (recursive.rs:252)
writes key erases TYPED→DYN = Stream::dyn_distinct (dyn/recursive.rs:77)
DistinctIncrementalId< (same StreamId, reads key
_, OrdIndexedZSet<Path,..>> payload type erased) DistinctIncrementalId<_, DynInner>
▲ ▲
└──────────────── different key type → cache MISS ───────────────────┘
- Accumulator = Stream<NestedCircuit, OrdIndexedZSet<Path, TransClosurePath>> (typed; Path/TransClosurePath are concrete Tup2/Tup4, not DynData). .mark_distinct() records DistinctIncrementalId<_, OrdIndexedZSet<Path,TransClosurePath>>.
- recursive (recursive.rs:235) calls streams.inner(), which is transmute_payload (circuit_builder.rs:781). It keeps the StreamId (line 784) but changes the payload type D to the erased dynamic batch.
- dyn_recursive then runs S::distinct (dyn/recursive.rs:252 → 77 → Stream::dyn_distinct), which looks up DistinctIncrementalId<_, DynInner>.
Same StreamId, different type parameter, different bucket. The mark is never consulted. This is also why the typed .distinct() itself is written as self.inner().dyn_distinct().typed() (operator/distinct.rs:42): the whole distinct/mark machinery lives at the dynamic layer, and a mark placed on a typed stream is invisible to it.
A second, independent trap (also live here)
Even if the mark were placed on the dynamic stream, dyn_distinct keys on self.try_sharded_version().stream_id() (distinct.rs:353), i.e. the sharded sibling, not the stream you marked. With WORKERS = 4 the join output is sharded, so the distinct's input carries a different StreamId than the unsharded stream. The mark would have to land on the sharded version (mirroring dyn_distinct_inner, which does .mark_sharded().mark_distinct() at distinct.rs:339-340).
So: bug or misuse?
Both, arguably. It is misuse in that mark_distinct() only works within a single type layer, and recursive crosses that layer internally, so no placement in user code reaches the exact key. It is a footgun in that mark_distinct() is public on typed streams and silently no-ops there.
Fixes worth proposing
1. Explicit opt-out in the recursion API (cleanest, matches ryzhyk's "add a bounded_recursion operator" and issue #6666): let the caller tell dyn_recursive to skip the S::distinct step (dyn/recursive.rs:252) instead of threading a mark through two boundaries. This is exactly what the low-level iterate variant already achieves by not calling distinct at all, which is why it benchmarks 1.5-1.6x faster.
2. Make the typed API honor the mark: have typed recursive forward the mark across .inner() and onto the post-shard stream, or have RecursiveStreams::distinct short-circuit when the dynamic, sharded stream is_distinct(). dyn_distinct already skips a correctly-placed dynamic mark, so this only needs the mark on the right key.
Want me to prototype option 1 (a skip_distinct/recursive_multiset variant) or write a failing regression test that pins the current no-op behavior?
mythical-fred
left a comment
There was a problem hiding this comment.
Re-reviewed at c73daf1. The design pivot — removing Consensus from iterate_with_condition* per ryzhyk's feedback and exporting Consensus as a public building block — is the right call. Criterion benchmarks are well-structured. Two nits inline; please squash before merge (see below).
| /// detect convergence, typically combining the per-worker results across | ||
| /// workers via [`Consensus`]. See the documentation of [`Scope`] for the | ||
| /// meaning of the parameter but passing `0` refers to the current circuit. | ||
| fn is_fixedpoint(&self, scope: Scope) -> bool { |
There was a problem hiding this comment.
Still two public names for the same operation (is_fixedpoint here, check_fixedpoint at line 1963). I know this is punted to #6666 — just flagging that the alias survived the reset. If ryzhyk is fine with it, so am I; if not, one name should win before this lands.
| @@ -0,0 +1,348 @@ | |||
| //! Benchmark comparing two transitive-closure computation approaches on acyclic | |||
There was a problem hiding this comment.
Nice benchmarks overall — deterministic seeding, correctness assertions before measuring, throughput in closure-paths/sec. The forest-only restriction is well-justified in the generate_forest docstring. One optional nit: consider adding a brief ## Running section to the module doc (or a top-level benches/README.md) noting the --bench invocations for all three benchmarks, since new contributors may not know they exist.
|
Before merge: the history needs a
Suggested rewrite: squash 1+2+4+5 into a single commit with an updated message reflecting what actually lands (public |
This PR makes it possible to construct recursive circuits using the lower-level
iterativeAPI in a thread-safe manner:lower-level
iterateAPI instead of the higher-levelrecursiveAPI, thatis, it publicly exports
Consensusto make such circuits thread-safe.iterate_with_condition,iterate_with_conditions,iterate_with_condition_and_scheduler, anditerate_with_conditions_and_schedulerare now thread-safe by wrappingtheir condition(s) in a
Consensus.tutorial10.rsandtutorial11.rsto show semanticallyequivalent circuits using the
iterateAPI next to therecursiveAPI anddoes a very basic run time comparison among the two to demonstrate the
extra
distincthappening behind the scenes and its costs.tutorial13.rswhich does the same thingfor mutually recursive circuits by reusing the graph coloring example from
the documentation of
recursive_dynamic.Here are some rough numbers taken from my machine:
iterativevsrecursiveHence, the circuits constructed with the lower-level
iterateAPI seem to beat least equally fast or faster even with this little toy data (I expect the
gap to be bigger with larger datasets). The difference is particularly relevant
if you happen to know that your data is acyclic in which case you don't need to
pay for deduplication, as with tutorial 10.
Open Questions
is_fixedpointmethod which in turnsimply delegates to the existing
check_fixedpointmethod which is alreadypublicly exposed. Do you still want to keep that extra wrapper method?
RecursiveStreamswhich allows to specify to opt-out of an implicitdistinct(at the expense of non-termination in some cases; exposing asimilar tradeoff as with UNION vs UNION ALL in SQL in its recursive CTEs),
and which additionally supports a recursion/iteration depth limit?
What do you think? Or would you consider this as an advance use case and
leave that to be done manually as shown in the updated tutorials?
Checklist