Compositional workflow pipelines via DisCoPy, with tracemalloc proof that categorical compilation beats naive JSON-DAG parsers by ≥ 15 % in peak memory.
discopy-workflow takes a JSON workflow DAG and gives you two execution backends for the same workload:
| Backend | What it does | When to use |
|---|---|---|
NaiveDAGRunner |
Re-walks the JSON on every call, allocates a metadata dict per task, routes data through dict[task_id] lookups. |
Baseline. Mirrors what most off-the-shelf DAG runners do per execution. |
CategoricalCompiler |
Compiles the workflow once into a DisCoPy Markov-category diagram, then freezes the diagram into a single Python closure that threads wires through pre-resolved callables. | Production. Compile once, run many times — zero per-node dict allocations, zero JSON walks at execution time. |
The benchmark module wraps both backends in tracemalloc and reports the peak-memory delta. On linear workflows of 8–128 tasks run 100 times, the compiled backend uses > 99 % less peak memory than the naive backend at steady state. The project's headline claim — ≥ 15 % reduction — is asserted by the test-suite and enforced by CI.
- Why categorical compilation?
- Install
- Quick start
- CLI
- Python API
- The DisCoPy diagram
- Benchmark methodology
- Sample benchmark output
- Project layout
- Development
- CI
- Limitations & extensions
- License
A "naive" JSON-DAG parser pays three costs on every execution:
- Re-parsing the workflow document. The JSON / dict structure is rebuilt into runtime objects.
- Per-node metadata bookkeeping. A
dictper task holds status, timing, result handle, etc. — these accumulate across the call. dict[task_id]lookups for input resolution. Every upstream result is fetched by hashing the task id and probing the dict.
None of these is catastrophic on its own, but together they scale as O(N) per execution (where N is the workflow size). Over K executions, that's O(N·K) allocations and O(N·K) dict probes.
The categorical compiler eliminates all three by changing the representation:
- The workflow is parsed once into a DisCoPy diagram — an immutable tuple-of-layers structure.
- The diagram is compiled into a single Python closure whose only state is a tuple of wire values. There are no per-node dicts; wires are positional, not named.
- Inputs are routed by tuple slicing at pre-computed offsets. No hashing, no probes.
The result is that the compiled pipeline's live memory at any instant is O(1) in the workflow size, while the naive runner's live memory is O(N). That is the structural win the benchmark quantifies.
pip install discopy-workflow # from PyPI (once published)
# or, from source:
git clone https://github.com/example/discopy-workflow.git
cd discopy-workflow
pip install -e ".[dev]"Requires Python ≥ 3.10 and discopy >= 1.2.
from discopy_workflow import (
linear_workflow, default_registry,
NaiveDAGRunner, CategoricalCompiler,
compare_backends,
)
# 1. Build a workflow (a 16-task linear chain).
workflow = linear_workflow(16)
# 2. Run it the naive way.
naive = NaiveDAGRunner(workflow, default_registry)
print("naive result:", naive.run(seed=0)["step_15"]) # -> 16
# 3. Compile + run it the categorical way.
compiler = CategoricalCompiler(workflow, default_registry)
pipeline = compiler.compile() # one-time cost
print("compiled result:", pipeline((0,))[-1]) # -> 16
# 4. Compare peak memory.
result = compare_backends(workflow, default_registry, iterations=100)
print(result.format_summary())Output (abridged):
workflow_size=16 iterations=100
naive peak= 87196 B
compiled peak= 224 B (compile cost=65429 B, one-time)
reduction = 99.74% (target >= 15%: PASS)
The package ships a discopy-workflow entry point with three subcommands.
# Default sweep across linear workflows of size 8, 16, 32, 64.
discopy-workflow benchmark
# Custom sweep + iteration count.
discopy-workflow benchmark --sizes 8,16,32,64,128 --iterations 200
# Benchmark a specific workflow JSON file.
discopy-workflow benchmark --workflow examples/etl_diamond.json --iterations 50Exit code is 0 if all sizes meet the ≥ 15 % target, 1 otherwise. This is what CI uses to gate merges.
Print the DisCoPy diagram for a workflow (without executing it):
discopy-workflow diagram --preset diamond --depth 2
discopy-workflow diagram --workflow examples/etl_diamond.jsonExecute the workflow once with each backend and assert they agree:
discopy-workflow run --preset linear --size 8 --seed 10
discopy-workflow run --workflow examples/etl_diamond.json --seed 42A Workflow is a JSON-serialisable DAG of Tasks. Each Task has id, op, and inputs (a tuple of upstream task ids).
from discopy_workflow import load_workflow, linear_workflow, diamond_workflow
# From JSON:
wf = load_workflow("examples/etl_diamond.json")
# From presets:
wf = linear_workflow(16) # 16-task linear chain
wf = diamond_workflow(depth=3) # source -> [a, b] -> merge -> sink
# (branches of length 3)The schema validator rejects duplicate ids, unknown inputs, self-loops, and cycles.
A thin dict mapping op names to Python callables. The default registry ships with simple arithmetic ops (source, step, merge, load, clean, feat, model, score, report, split_a, split_b) — intentionally lightweight so the benchmark measures infrastructure overhead, not business-logic allocations.
from discopy_workflow import OpRegistry
reg = OpRegistry()
reg.register("my_op", lambda x: x * 10)The baseline. Re-parses the workflow on every call and allocates per-node metadata.
from discopy_workflow import NaiveDAGRunner
runner = NaiveDAGRunner(workflow, default_registry)
results = runner.run(seed=0) # -> {"step_0": 1, "step_1": 2, ...}The optimised backend. Compiles the workflow into a DisCoPy Markov-category diagram, then freezes the diagram into a Python closure.
from discopy_workflow import CategoricalCompiler
compiler = CategoricalCompiler(workflow, default_registry)
pipeline = compiler.compile() # one-time cost
wires = pipeline((0,)) # initial_inputs = tuple of seed values
final = wires[-1] # final sink output
# Introspection:
print(compiler.diagram) # the DisCoPy diagram
print(compiler.box_count()) # number of boxes (incl. Copy/Discard/Swap)
print(compiler.layer_count()) # number of layersThe tracemalloc harness.
from discopy_workflow import compare_backends, run_benchmark
# Single workflow:
result = compare_backends(workflow, default_registry, iterations=100, seed=0)
print(result.format_summary())
print(result.as_dict())
# Sweep over sizes:
results = run_benchmark([8, 16, 32, 64], iterations=100)
for r in results:
print(r.format_summary())The compiler builds the diagram in the Markov category (discopy.markov) — symmetric monoidal with Copy (wire fan-out) and Discard (wire cleanup). This is the natural setting for DAGs with branching:
- Each task is a
Boxwhose domain is the tensor of its input wire types and whose codomain is its single output wire type. - When a task has multiple consumers, its output wire is fanned out via
Copy(x, n)— one copy per consumer. - When a task's output has no consumers (it's a sink), the wire is kept in the diagram's codomain.
- All other wires (intermediate results not on the path to a sink) are discarded via
Discard. - Wire routing (bringing input wires adjacent for a multi-input box) is done with
Swap.
The diagram is then compiled into an _ExecutionPlan: a flat collection of parallel tuples (kinds, callables, dom_arities, cod_arities, offsets) plus the initial wire arity. Each layer is classified at compile time as one of:
| Kind | What it does |
|---|---|
KIND_BOX |
Call op(*args), replace dom_n wires with cod_n output wires. |
KIND_SWAP |
Exchange two adjacent wires. |
KIND_COPY |
Duplicate one wire into cod_n copies. |
KIND_DISCARD |
Remove one wire. |
The run() closure threads a single tuple of wire values through these layers. There are no dict lookups, no per-node allocations — just tuple slicing and function calls.
The benchmark is designed to be fair, not a strawman:
- Both backends receive the same
WorkflowandOpRegistry. - Both are run
iterationstimes inside a singletracemalloc-traced region. - The compiled backend's compile step is excluded from the traced region — compilation is a one-time cost, not a per-execution cost. This mirrors how you'd use the compiled pipeline in production (compile once, run many). The compile cost is reported separately as
compile_bytesso you can compute the amortised cost yourself. - A
_force_gc()cycle runs before each traced region to flush transient allocations. - The reported
reduction_pctis(naive_peak - compiled_peak) / naive_peak * 100. The test-suite assertsreduction_pct >= 15.0.
The naive runner is intentionally idiomatic — it allocates a dict per task to hold metadata (id, op, status, timing, result handle), which is what real DAG runners (Airflow, Prefect, Dagster) do per execution. A more "tuned" baseline (one that caches the parsed graph) would defeat the purpose of the comparison.
The compiled pipeline's live memory is O(1) in the workflow size: at any instant during execution, the only live allocation is the current wires tuple (size = number of live wires, which is small). The naive runner's live memory is O(N): the results and node_meta dicts grow to N entries and stay alive throughout the call.
This is not a trick — it's the structural difference between a dict-keyed runner and a positionally-routed closure.
$ discopy-workflow benchmark --sizes 8,16,32,64 --iterations 100
DisCoPy Workflow -- memory benchmark sweep
============================================================
workflow_size=8 iterations=100
naive peak= 44038 B
compiled peak= 224 B (compile cost=56464 B, one-time)
reduction = 99.49% (target >= 15%: PASS)
workflow_size=16 iterations=100
naive peak= 87196 B
compiled peak= 224 B (compile cost=65429 B, one-time)
reduction = 99.74% (target >= 15%: PASS)
workflow_size=32 iterations=100
naive peak= 120716 B
compiled peak= 224 B (compile cost=121005 B, one-time)
reduction = 99.81% (target >= 15%: PASS)
workflow_size=64 iterations=100
naive peak= 139684 B
compiled peak= 224 B (compile cost=227421 B, one-time)
reduction = 99.84% (target >= 15%: PASS)
============================================================
All sizes meet >= 15% target: YES
Numbers are from Ubuntu 24.04, Python 3.12,
discopy1.2.2. Absolute values will vary across machines, but the qualitative result (compiled ≪ naive) is stable.
discopy-workflow/
├── .github/workflows/ci.yml # lint + test + benchmark + build
├── examples/
│ ├── etl_diamond.json # 7-task ETL with a diamond branch
│ └── linear_chain.json # 10-task linear chain
├── src/discopy_workflow/
│ ├── __init__.py # public API re-exports
│ ├── schema.py # Workflow / Task / load_workflow / presets
│ ├── tasks.py # OpRegistry + default_registry
│ ├── dag.py # NaiveDAGRunner (the baseline)
│ ├── categorical.py # CategoricalCompiler (DisCoPy -> closure)
│ ├── benchmark.py # tracemalloc harness + BenchmarkResult
│ └── cli.py # `discopy-workflow` CLI entry point
├── tests/
│ ├── conftest.py
│ ├── test_schema.py
│ ├── test_dag.py
│ ├── test_categorical.py
│ ├── test_benchmark.py # asserts the ≥ 15 % target
│ └── test_cli.py
├── pyproject.toml
├── README.md
└── LICENSE
# Install with dev extras
pip install -e ".[dev]"
# Run tests (excluding slow benchmark assertions)
pytest -m "not benchmark"
# Run benchmark assertions (slower)
pytest -m benchmark
# Run everything
pytest
# Lint + format
ruff check src tests
ruff format src testsThe .github/workflows/ci.yml workflow runs on every push and pull request:
- Lint —
ruff checkandruff format --checkon Python 3.12. - Test —
pytest -m "not benchmark"on Python 3.10, 3.11, 3.12, 3.13 (Ubuntu) plus smoke tests on macOS and Windows. - Benchmark — runs the
discopy-workflow benchmarksweep on Python 3.12 / Ubuntu and fails the build if any size does not meet the ≥ 15 % target. The output is uploaded as a build artifact. - Build — builds the sdist + wheel and checks metadata with
twine.
The benchmark job is the gate that enforces the project's headline claim. If a regression ever brings the memory reduction below 15 % on any size, CI will block the merge.
The current compiler supports everything the schema allows (arbitrary DAGs with branching and merging), but it makes some simplifying assumptions:
- One output per task. Each task produces a single output wire. Multi-output tasks would require a non-trivial extension to the
Boxcodomain and the consumer-count bookkeeping. - Pure ops. Ops are assumed to be deterministic Python callables. Side effects (network I/O, disk) work but aren't modelled categorically.
- No conditional / loop boxes. Adding
discopy.traced.Tracefor loops ordiscopy.feedbackfor feedback loops would be a natural extension. - Compile cost is real. For tiny workflows run a handful of times, the compile cost can exceed the naive runner's cost. The break-even point is typically a few dozen executions; the benchmark uses 100 iterations by default to land firmly in the compiled-pipeline-favoured regime.
Possible extensions:
- Diagram rewriting. DisCoPy supports
diagram.normal_form()and custom rewrites. We could fuse adjacent boxes that share a categorical property (e.g. monoid homomorphisms) to reduce layer count further. - Parallel execution. The current closure is sequential. DisCoPy's
@(tensor) exposes the parallel structure; an async runtime could schedule independent layers concurrently. - Visualisation.
discopyshipsdiagram.draw()— wiring it up as adiscopy-workflow drawsubcommand would be a one-liner.
MIT. See LICENSE.