Skip to main content

graphed-executors

Runners for graphed plans: the same analysis runs on your laptop's threads or processes, on a dask cluster, or on a parsl HTEX pool — you change the runner, not the analysis.

  • The answer doesn't depend on where you ran it. The order partial results are combined in is fixed up front, so your totals — even float histograms — come out bit-for-bit identical on 1 worker or 100, threads or a cluster.
  • One slow file can't stall the run. Every other part of the result keeps combining while a straggler finishes.
  • A failure on a worker comes back on your machine as the exception it was, pointing at the analysis line you wrote — not an opaque string from another process.

Install

pip install graphed-executors            # laptop runners; pulls graphed
pip install "graphed-executors[dask]"    # + the dask.distributed backend
pip install "graphed-executors[parsl]"   # + the parsl backend

Installing from source builds graphed's Rust core, so you need a Rust toolchain; a plain pip install from PyPI does not.

Your first run

A plan is your analysis packaged for a runner: process does one chunk's work, combine merges two partial results, empty is the starting value, and tasks lists the chunks. Here is a minimal hand-made plan so you can see the whole loop; the next block gets one from a real analysis instead:

import numpy as np
from graphed.core import Partition, Plan, Task
from graphed_executors.local import ProcessPoolExecutor

def count(partition, resources):          # module-level, so workers can import it
    return np.asarray([partition.entry_stop - partition.entry_start])

def add(a, b): return a + b
def zero():    return np.zeros(1, dtype=int)

parts = tuple(Partition("data", "", i * 100, (i + 1) * 100) for i in range(7))
plan  = Plan(process=count, combine=add, empty=zero,
             tasks=tuple(Task(i, p) for i, p in enumerate(parts)))

if __name__ == "__main__":
    result = ProcessPoolExecutor(max_workers=4).run(plan)
    print(result.value)                   # [700]

The same run, from a real analysis

You do not hand-build plans in practice. Fill a histogram with graphed-histogram and it exports one; the runner is the same call. Needs graphed-histogram and graphed[parquet]:

import awkward as ak
import boost_histogram as bh
import graphed_histogram as gh
from graphed import Session
from graphed.awkward import AwkwardBackend, from_parquet
from graphed_executors.local import ProcessPoolExecutor

if __name__ == "__main__":
    ak.to_parquet(ak.Array({"pt": [[40.0, 25.0], [55.0], [30.0, 60.0, 20.0],
                                   [80.0], [15.0, 45.0], [70.0, 10.0]]}), "events.parquet")

    session = Session(AwkwardBackend())
    events = from_parquet(session, "events", "events.parquet", steps_per_file=3)

    h = gh.boost.Histogram(bh.axis.Regular(4, 0.0, 100.0), storage=bh.storage.Int64())
    h.fill(events.pt)                     # records the fill; nothing is read yet

    plan = gh.plan({"jet_pt": h})         # your analysis, packaged for a runner
    result = ProcessPoolExecutor(max_workers=4).run(plan)
    print(gh.unpack(result.value)["jet_pt"].values())   # [3 4 3 1]

Swap ProcessPoolExecutor for dask_runner(client) or parsl_runner(executor) and the rest of the program is untouched. If your output is not a histogram, graphed.aggregate_plan(*outputs, reduce=, combine=, empty=) exports a plan the same way from any deferred graphed arrays.

The one thing that's different

There is no .compute(). You build a plan (the Plan, Task, and Partition types live in graphed.core, not in this package) and hand it to a runner; run(plan) returns a result whose .value is the reduced answer. On process pools and clusters, process/combine/empty must be module-level functions the workers can import — a lambda or a notebook-cell closure works on ThreadExecutor only.

Which runner do I want?

You are running on Use Import from
One process, quick check, nothing picklable needed ThreadExecutor() graphed_executors.local
Your laptop, all cores ProcessPoolExecutor(max_workers=N) graphed_executors.local
A many-core machine where the worker count strains the open-file limit PinnedPoolExecutor graphed_executors.local
A dask cluster (local, dask-jobqueue, Kubernetes, …) dask_runner(client) graphed_executors.dask_backend
A parsl HTEX pool parsl_runner(executor) graphed_executors.parsl_backend

(import graphed_exec_local still works as a deprecated alias for graphed_executors.local; use the namespaced form in new code.)

TaskVine and direct HTCondor/Slurm submission aren't supported; use dask-jobqueue or parsl's providers to reach those batch systems.

On a dask cluster

Needs graphed-executors[dask]. Point it at any distributed.Client you already have and hand it the same plan from your first run:

from distributed import Client
from graphed_executors.dask_backend import dask_runner

if __name__ == "__main__":                    # needed when Client() spawns a local cluster
    client = Client(n_workers=2, dashboard_address=":0")  # or Client("tcp://scheduler:8786")
    runner = dask_runner(client)              # registers graphed's worker plugin on your client
    print(runner.run(plan).value)             # [700] — same plan, same answer
    runner.close()                            # your client stays open; close it yourself
    client.close()

A failed task is retried on another worker three times before the error reaches you — that is dask's own per-task retry, and retries=3 is already the default. Pass retries=0 while you are debugging, so a deterministic bug in your process surfaces on the first attempt rather than the fourth. A worker that dies mid-task surfaces as an error naming the task and the worker address, not a hang.

On a parsl pool

Needs graphed-executors[parsl]. HTEX workers are separate processes that do not inherit your sys.path, so the plan's functions must live in a module they can import — not in your launch script. Put them in a file of their own:

# my_tasks.py — importable by the workers
import numpy as np

def count(partition, resources):
    return np.asarray([partition.entry_stop - partition.entry_start])

def add(a, b):
    return a + b

def zero():
    return np.zeros(1, dtype=int)

then hand parsl_runner any started parsl HighThroughputExecutor (or ThreadPoolExecutor); start_htex spins one up locally if you don't have a config of your own:

from graphed.core import Partition, Plan, Task
import my_tasks

parts = tuple(Partition("data", "", i * 100, (i + 1) * 100) for i in range(7))
plan = Plan(process=my_tasks.count, combine=my_tasks.add, empty=my_tasks.zero,
            tasks=tuple(Task(i, p) for i, p in enumerate(parts)))

if __name__ == "__main__":
    from graphed_executors.parsl_backend import parsl_runner, start_htex, stop_htex

    htex = start_htex(workers=8, run_dir="runinfo", heartbeat_period=2)
    try:
        with parsl_runner(htex) as runner:    # closing the runner does not stop your executor
            print(runner.run(plan).value)     # [700]
    finally:
        stop_htex(htex)                       # reaps the interchange, manager and worker
                                              # processes; skip it and you leak them, and
                                              # their ports, on any exception

Two things bite people on HTEX:

  • Workers don't inherit your sys.path. Export PYTHONPATH (or install your package) before starting the pool, so the workers can import my_tasks. A plan whose functions are defined in the launch script itself doesn't fail fast — the run hangs.
  • A killed worker takes ~30 s to notice at parsl's default heartbeat. heartbeat_period=2 brings that to ~1.65 s, so a crash is reported (and the worker respawned) promptly.

Useful knobs on the laptop runners

  • persistent=True keeps the process pool alive across run() calls — worth it in notebooks and parameter sweeps, where the spawn cost would otherwise repeat per plan. Use it as a context manager: with ProcessPoolExecutor(max_workers=4, persistent=True) as ex: and call ex.run(plan) once per plan inside.
  • Call resources.open_once(uri, opener) inside your process and the worker keeps that handle for its lifetime, so ten partitions of one file on one worker open it once instead of ten times. The dask backend gives you the same per-worker handle through its worker plugin.
  • Every runner accepts monitor= — an observer that receives one event per task submitted, started, and finished, without changing the run. Pass graphed.debug.Dashboard's monitor for a live web view.

The dashboard needs pip install "graphed[dashboard]". Using the plan from your first run:

from graphed.debug import Dashboard
from graphed_executors.local import ProcessPoolExecutor

if __name__ == "__main__":                    # a spawn pool re-imports this file
    with Dashboard(profile=True) as dash:
        result = ProcessPoolExecutor(max_workers=4, monitor=dash.monitor).run(plan)
    print(result.value)                       # [700]

Next

  • How the executors work — why the answer is identical everywhere, what happens when a worker dies, and where your combines actually run on each runner.
  • The dask backend and the parsl backend in depth, including repartitioning and joins on a cluster.
  • graphed — build the analysis that produces a plan, and export it with graphed.aggregate_plan.
  • graphed-histogram — deferred histogram filling, and graphed_histogram.plan for the block above.
  • API reference.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

graphed_executors-0.0.2.tar.gz (470.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

graphed_executors-0.0.2-py3-none-any.whl (161.3 kB view details)

Uploaded Python 3

File details

Details for the file graphed_executors-0.0.2.tar.gz.

File metadata

  • Download URL: graphed_executors-0.0.2.tar.gz
  • Upload date:
  • Size: 470.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for graphed_executors-0.0.2.tar.gz
Algorithm Hash digest
SHA256 5b70ea95914c9086da0c18c0bc4708a5841bd5708e55e0c747c920645de88e8e
MD5 8b4d616a7431aa7938dc09f1b05a6666
BLAKE2b-256 cb8d22894c5b245e7816b03f4cb7ced7d2f372fc9bb15c43e0399685ed0227d9

See more details on using hashes here.

Provenance

The following attestation bundles were made for graphed_executors-0.0.2.tar.gz:

Publisher: release.yml on graphed-org/graphed-executors

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file graphed_executors-0.0.2-py3-none-any.whl.

File metadata

File hashes

Hashes for graphed_executors-0.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 c9a815d9449dc5556f95937ded1a74c3a44b6af3d80f1d4242074e788c32298d
MD5 cace801826deb710f5e4b93950a618b8
BLAKE2b-256 614994806cfce61f0b1d2342bd5af1dcbe894abf97d7edf9b7dba4743f406c2f

See more details on using hashes here.

Provenance

The following attestation bundles were made for graphed_executors-0.0.2-py3-none-any.whl:

Publisher: release.yml on graphed-org/graphed-executors

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.0.2 This release

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page