-
Notifications
You must be signed in to change notification settings - Fork 141
Implement and document thread-safe use of the iterate API #6653
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lstwn
wants to merge
5
commits into
feldera:main
Choose a base branch
from
lstwn:recursion_via_iterate
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5188288
Implement and document thread-safe use of the iterate API
lstwn 7c4d6d7
Address PR feedback
lstwn d762396
Provide criterion benchmarks comparing iterative and recursive variants
lstwn 16488f4
Reset tutorials
lstwn c73daf1
Reset iterate_* APIs to intentionally not use Consensus
lstwn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,362 @@ | ||
| //! Benchmark comparing two graph 2-coloring (bipartiteness) approaches: | ||
| //! | ||
| //! - **recursive**: `Circuit::recursive_dynamic`, which splices an implicit | ||
| //! `distinct` onto every recursive output stream. | ||
| //! See [`build_recursive_variant`]. | ||
| //! - **iterate**: `Circuit::iterate` with a manual feedback loop that carries | ||
| //! only the per-iteration frontier and deduplicates with an explicit | ||
| //! `distinct`. See [`build_iterate_variant`]. | ||
| //! | ||
| //! Unlike the transitive-closure benchmarks, both variants perform the same | ||
| //! deduplication work (implicit vs. explicit `distinct`). Hence, we should see | ||
| //! comparable run times. The coloring propagates from a single seed node | ||
| //! along directed edges: a red node paints its out-neighbors blue and vice | ||
| //! versa. On a strongly connected graph with odd cycles every reachable node | ||
| //! ends up both red and blue, so both variants converge to the same colorings | ||
| //! (the benchmark asserts this before measuring). | ||
| //! | ||
| //! Run with, e.g.: | ||
| //! `cargo bench --bench graph_coloring` | ||
|
|
||
| use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; | ||
| use dbsp::{ | ||
| Circuit, Consensus, DBSPHandle, NestedCircuit, NumEntries, OrdIndexedZSet, OrdZSet, | ||
| OutputHandle, Runtime, Stream, ZSetHandle, ZWeight, mimalloc::MiMalloc, operator::Z1, | ||
| trace::BatchReaderFactories, typed_batch::SpineSnapshot, utils::Tup2, | ||
| }; | ||
| use rand::{Rng, SeedableRng}; | ||
| use rand_chacha::ChaCha8Rng; | ||
| use std::cell::Cell; | ||
|
|
||
| #[global_allocator] | ||
| static ALLOC: MiMalloc = MiMalloc; | ||
|
|
||
| type NodeId = u64; | ||
| type Edge = Tup2<NodeId, NodeId>; | ||
|
|
||
| struct BipartiteGraphCircuit { | ||
| handle: DBSPHandle, | ||
| init_input: ZSetHandle<NodeId>, | ||
| edges_input: ZSetHandle<Edge>, | ||
| red_output: OutputHandle<SpineSnapshot<OrdZSet<NodeId>>>, | ||
| blue_output: OutputHandle<SpineSnapshot<OrdZSet<NodeId>>>, | ||
| } | ||
|
|
||
| /// Number of DBSP worker threads used for every circuit in this benchmark. | ||
| const WORKERS: usize = 4; | ||
|
|
||
| /// Deterministic seed so every run sees the same graphs. | ||
| const SEED: u64 = 0x0BADC0DE_DEADBEEF; | ||
|
|
||
| /// Out-edges emitted per node by [`generate_graph`]. | ||
| const OUT_DEGREE: usize = 3; | ||
|
|
||
| /// A single-transaction workload: the seed node(s) plus all edges of one graph. | ||
| #[derive(Clone)] | ||
| struct Workload { | ||
| init: Vec<Tup2<NodeId, ZWeight>>, | ||
| edges: Vec<Tup2<Edge, ZWeight>>, | ||
| } | ||
|
|
||
| /// Generates a random directed graph plus a single seed node (node `0`, painted | ||
| /// red) as one insertion batch. | ||
| /// | ||
| /// Each node emits `out_degree` edges to distinct random targets (excluding | ||
| /// itself). With `out_degree >= 2` and a few hundred nodes the graph is | ||
| /// strongly connected with overwhelming probability and contains odd cycles, so | ||
| /// the coloring reaches every node in both colors. That makes the two variants | ||
| /// produce identical red/blue sets regardless of the exact structure. | ||
| fn generate_graph(nodes: NodeId, out_degree: usize) -> Workload { | ||
| let mut rng = ChaCha8Rng::seed_from_u64(SEED); | ||
| // At most `nodes - 1` distinct targets exist per node (self is excluded). | ||
| let fanout = out_degree.min(nodes.saturating_sub(1) as usize); | ||
| let mut edges = Vec::with_capacity(nodes as usize * fanout); | ||
|
|
||
| for from in 0..nodes { | ||
| let mut targets = Vec::with_capacity(fanout); | ||
| while targets.len() < fanout { | ||
| let to = rng.gen_range(0..nodes); | ||
| if to != from && !targets.contains(&to) { | ||
| targets.push(to); | ||
| } | ||
| } | ||
| for to in targets { | ||
| edges.push(Tup2(Tup2(from, to), 1)); | ||
| } | ||
| } | ||
|
|
||
| // Seed the discovery by marking node `0` red. | ||
| let init = vec![Tup2(0, 1)]; | ||
|
|
||
| Workload { init, edges } | ||
| } | ||
|
|
||
| /// Feeds a whole graph into a freshly built circuit in one transaction and | ||
| /// returns the number of `(red, blue)` color marks discovered. | ||
| /// | ||
| /// The circuit (and its worker threads) is dropped on return, so the caller | ||
| /// must supply a fresh circuit for each measured iteration. | ||
| fn drive_once(mut circuit: BipartiteGraphCircuit, mut workload: Workload) -> (usize, usize) { | ||
| circuit.init_input.append(&mut workload.init); | ||
| circuit.edges_input.append(&mut workload.edges); | ||
| circuit | ||
| .handle | ||
| .transaction() | ||
| .expect("transaction should succeed"); | ||
| let red = circuit | ||
| .red_output | ||
| .concat() | ||
| .consolidate() | ||
| .num_entries_shallow(); | ||
| let blue = circuit | ||
| .blue_output | ||
| .concat() | ||
| .consolidate() | ||
| .num_entries_shallow(); | ||
| (red, blue) | ||
| } | ||
|
|
||
| /// Cross-checks that both variants compute the same coloring on the given graph, | ||
| /// returning the number of `(red, blue)` color marks (used both as a sanity check | ||
| /// and to label the benchmark with the output cardinality). | ||
| fn assert_variants_agree(workload: &Workload) -> (usize, usize) { | ||
| let recursive = build_recursive_variant().expect("build recursive circuit"); | ||
| let iterate = build_iterate_variant().expect("build iterate circuit"); | ||
|
|
||
| let mut recursive_circuit = recursive.handle; | ||
| let mut iterate_circuit = iterate.handle; | ||
|
|
||
| recursive.init_input.append(&mut workload.init.clone()); | ||
| recursive.edges_input.append(&mut workload.edges.clone()); | ||
| iterate.init_input.append(&mut workload.init.clone()); | ||
| iterate.edges_input.append(&mut workload.edges.clone()); | ||
| recursive_circuit.transaction().unwrap(); | ||
| iterate_circuit.transaction().unwrap(); | ||
|
|
||
| let recursive_red = recursive.red_output.concat().consolidate(); | ||
| let recursive_blue = recursive.blue_output.concat().consolidate(); | ||
| let iterate_red = iterate.red_output.concat().consolidate(); | ||
| let iterate_blue = iterate.blue_output.concat().consolidate(); | ||
|
|
||
| assert_eq!( | ||
| recursive_red, iterate_red, | ||
| "recursive and iterate variants must agree on the red coloring" | ||
| ); | ||
| assert_eq!( | ||
| recursive_blue, iterate_blue, | ||
| "recursive and iterate variants must agree on the blue coloring" | ||
| ); | ||
|
|
||
| ( | ||
| recursive_red.num_entries_shallow(), | ||
| recursive_blue.num_entries_shallow(), | ||
| ) | ||
| } | ||
|
|
||
| /// The graph sizes (node counts) we benchmark. | ||
| const GRAPH_SIZES: &[NodeId] = &[500, 1000, 2000]; | ||
|
|
||
| fn graph_coloring_benches(c: &mut Criterion) { | ||
| let mut group = c.benchmark_group("graph-coloring"); | ||
| // Building a circuit spins up worker threads, so keep the sample count | ||
| // modest to bound the wall-clock time of the whole benchmark. | ||
| group.sample_size(30); | ||
|
|
||
| for &nodes in GRAPH_SIZES { | ||
| let workload = generate_graph(nodes, OUT_DEGREE); | ||
| let (red_marks, blue_marks) = assert_variants_agree(&workload); | ||
| let shape = format!("n{nodes}"); | ||
| let edges = workload.edges.len(); | ||
| println!("graph {shape}: {edges} edges, {red_marks} red / {blue_marks} blue color marks",); | ||
|
|
||
| // Report throughput in edges processed per second, which scales with the | ||
| // graph size and the join work both variants perform. | ||
| group.throughput(Throughput::Elements(edges as u64)); | ||
|
|
||
| group.bench_with_input( | ||
| BenchmarkId::new("recursive", &shape), | ||
| &workload, | ||
| |b, workload| { | ||
| b.iter_batched( | ||
| || { | ||
| ( | ||
| build_recursive_variant().expect("build recursive circuit"), | ||
| workload.clone(), | ||
| ) | ||
| }, | ||
| |(circuit, workload)| drive_once(circuit, workload), | ||
| BatchSize::PerIteration, | ||
| ); | ||
| }, | ||
| ); | ||
|
|
||
| group.bench_with_input( | ||
| BenchmarkId::new("iterate", &shape), | ||
| &workload, | ||
| |b, workload| { | ||
| b.iter_batched( | ||
| || { | ||
| ( | ||
| build_iterate_variant().expect("build iterate circuit"), | ||
| workload.clone(), | ||
| ) | ||
| }, | ||
| |(circuit, workload)| drive_once(circuit, workload), | ||
| BatchSize::PerIteration, | ||
| ); | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| group.finish(); | ||
| } | ||
|
|
||
| criterion_group!(benches, graph_coloring_benches); | ||
| criterion_main!(benches); | ||
|
|
||
| /// Helper function to advance the frontier of red and blue nodes by exploring | ||
| /// one more hop in the graph along `edges`. | ||
| fn hop( | ||
| frontier: &Stream<NestedCircuit, OrdZSet<NodeId>>, | ||
| edges: &Stream<NestedCircuit, OrdIndexedZSet<NodeId, NodeId>>, | ||
| ) -> Stream<NestedCircuit, OrdZSet<NodeId>> { | ||
| frontier | ||
| .map_index(|node| (*node, *node)) | ||
| .join(edges, |_from, _node, to| *to) | ||
| } | ||
|
|
||
| /// Computes the red/blue graph coloring using DBSP's | ||
| /// [`recursive` API](dbsp::circuit::ChildCircuit::recursive), | ||
| /// which deduplicates every recursive output stream implicitly. | ||
| fn build_recursive_variant() -> anyhow::Result<BipartiteGraphCircuit> { | ||
| let (handle, ((init_input, edges_input), (red_output, blue_output))) = | ||
| Runtime::init_circuit(WORKERS, move |root_circuit| { | ||
| let (edges, edges_input) = root_circuit.add_input_zset::<Edge>(); | ||
| let (init, init_input) = root_circuit.add_input_zset::<NodeId>(); | ||
|
|
||
| let (red_output, blue_output) = root_circuit.recursive( | ||
| |child_circuit, | ||
| (red, blue): ( | ||
| Stream<NestedCircuit, OrdZSet<NodeId>>, | ||
| Stream<NestedCircuit, OrdZSet<NodeId>>, | ||
| )| { | ||
| let edges = edges.delta0(child_circuit); | ||
| let init = init.delta0(child_circuit); | ||
| let edges_indexed = edges.map_index(|Tup2(from, to)| (*from, *to)); | ||
|
|
||
| let new_red = init.plus(&hop(&blue, &edges_indexed)); | ||
| let new_blue = hop(&red, &edges_indexed); | ||
|
|
||
| Ok((new_red, new_blue)) | ||
| }, | ||
| )?; | ||
|
|
||
| let red_output = red_output.accumulate_output(); | ||
| let blue_output = blue_output.accumulate_output(); | ||
|
|
||
| Ok(((init_input, edges_input), (red_output, blue_output))) | ||
| })?; | ||
|
|
||
| Ok(BipartiteGraphCircuit { | ||
| handle, | ||
| init_input, | ||
| edges_input, | ||
| red_output, | ||
| blue_output, | ||
| }) | ||
| } | ||
|
|
||
| /// Upper bound on nested iterations before the iterate variant gives up waiting | ||
| /// for a fixed point. | ||
| const MAX_ITERATIONS: usize = 4096; | ||
|
|
||
| /// Computes the red/blue graph coloring using DBSP's lower level | ||
| /// [`iterate` API](dbsp::circuit::Circuit::iterate). | ||
| /// | ||
| /// It uses a hybrid termination check: Either stop once the subcircuit | ||
| /// reaches a fixed point, or after [`MAX_ITERATIONS`] steps if it never does. | ||
| fn build_iterate_variant() -> anyhow::Result<BipartiteGraphCircuit> { | ||
| let (handle, (init_input, edges_input, red_output, blue_output)) = | ||
| Runtime::init_circuit(WORKERS, move |root_circuit| { | ||
| let (init, init_input) = root_circuit.add_input_zset::<NodeId>(); | ||
| let (edges, edges_input) = root_circuit.add_input_zset::<Edge>(); | ||
|
|
||
| let (red_output, blue_output) = root_circuit.iterate(|child_circuit| { | ||
| let edges = edges.delta0(child_circuit); | ||
| let init = init.delta0(child_circuit); | ||
| let edges_indexed = edges.map_index(|Tup2(from, to)| (*from, *to)); | ||
|
|
||
| // Feedback carries only the frontier (the delta from the last step). | ||
| let (red, red_feedback) = | ||
| child_circuit.add_feedback(Z1::new(OrdZSet::<NodeId>::default())); | ||
| let (blue, blue_feedback) = | ||
| child_circuit.add_feedback(Z1::new(OrdZSet::<NodeId>::default())); | ||
|
|
||
| // Extend each frontier by one hop and deduplicate with `distinct`, | ||
| // which caps every node's weight at 1 and stops nodes discovered | ||
| // earlier from being re-emitted. | ||
| let new_red = init.plus(&hop(&blue, &edges_indexed)).distinct(); | ||
| let new_blue = hop(&red, &edges_indexed).distinct(); | ||
|
|
||
| red_feedback.connect(&new_red); | ||
| blue_feedback.connect(&new_blue); | ||
|
|
||
| // Accumulate every frontier across the nested clock cycles into the | ||
| // full colorings and export them to the parent. | ||
| let red = Stream::dyn_integrate_trace( | ||
| &new_red.inner(), | ||
| &BatchReaderFactories::new::<NodeId, (), ZWeight>(), | ||
| ); | ||
| let blue = Stream::dyn_integrate_trace( | ||
| &new_blue.inner(), | ||
| &BatchReaderFactories::new::<NodeId, (), ZWeight>(), | ||
| ); | ||
|
|
||
| let child_circuit = child_circuit.clone(); | ||
| let consensus = Consensus::new("iterate"); | ||
| let step = Cell::new(0usize); | ||
|
|
||
| let termination_check = async move || { | ||
| let step_count = step.get() + 1; | ||
| step.set(step_count); | ||
|
|
||
| // The step bound is data-independent, hence identical on every worker; | ||
| // the fixed-point check is per-worker. | ||
| let fixedpoint = child_circuit.is_fixedpoint(0); | ||
| let iteration_ceiling = step_count >= MAX_ITERATIONS; | ||
| let done = consensus.check(fixedpoint || iteration_ceiling).await?; | ||
| if done { | ||
| // Reset the step counter for the next transaction. | ||
| step.set(0); | ||
| } | ||
|
|
||
| Ok(done) | ||
| }; | ||
|
|
||
| Ok((termination_check, (red.export(), blue.export()))) | ||
| })?; | ||
|
|
||
| let red_output = Stream::dyn_consolidate( | ||
| &red_output, | ||
| &BatchReaderFactories::new::<NodeId, (), ZWeight>(), | ||
| ) | ||
| .typed::<OrdZSet<NodeId>>() | ||
| .accumulate_output(); | ||
| let blue_output = Stream::dyn_consolidate( | ||
| &blue_output, | ||
| &BatchReaderFactories::new::<NodeId, (), ZWeight>(), | ||
| ) | ||
| .typed::<OrdZSet<NodeId>>() | ||
| .accumulate_output(); | ||
|
|
||
| Ok((init_input, edges_input, red_output, blue_output)) | ||
| })?; | ||
|
|
||
| Ok(BipartiteGraphCircuit { | ||
| handle, | ||
| init_input, | ||
| edges_input, | ||
| red_output, | ||
| blue_output, | ||
| }) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
flake.nixandflake.lockare 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-excludedflake.local.nixper convention. Consider dropping the twoflake.*lines and letting anyone who wants a private flake add it to.git/info/excludelocally.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've deleted the two
flake*lines and excluded them locally.Is there interest in a
flake.nixfile 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!