05 — Data-Locality Crossover

The second hero example, and the one that proves why the data-heavy version holds at GB scale. Bulk fields live in a distributed DDict (node-local); control-plane and data-plane agents are pinned by Policy; reducers reduce in place. The example prints the funnel-vs-handles crossover table.

What you’ll learn:

  • How bulk data stays node-local in a distributed DDict

  • How to place data-plane agents where the data already lives

  • How to read the funnel-vs-handles crossover and locality receipts

Needs: 1 node (more nodes show locality).

Main Code

Listing 38 05_data_locality_crossover.py
  1"""
  205 - Data locality + distributed agents: the measured crossover.
  3=============================================================================
  4The new Dragon ability
  5----------------------
  6This is the data-plane hero.  It shows the two Dragon abilities that matter
  7most for *data-heavy* agentic AI on HPC -- working together in ONE example,
  8and it MEASURES the win instead of asserting it:
  9
 10  (1) DATA LOCALITY.  Bulk arrays (here, 3-D charge-density fields, one per
 11      candidate) live in a distributed ``DDict`` and never flow through the
 12      LangGraph coordinator.  The simulator writes its field to the DDict
 13      manager *local to its own node*; the reducer agent on that same node
 14      reads it back with ``local_keys()`` -- zero-copy, zero network.  Only
 15      KB-scale handles and descriptors travel through graph state.
 16
 17  (2) DISTRIBUTED AGENT PLACEMENT.  Agents are split by role and pinned with
 18      a ``Policy``: control-plane agents (planner, global decision) route only
 19      handles/scalars and sit on Node 0; data-plane agents (the heavy
 20      cross-candidate reducer) are placed *on the data nodes* and reduce in
 21      place, returning a KB novelty vector instead of shipping GB fields back.
 22
 23The same LangGraph graph is run two ways back-to-back on an identical
 24workload and the coordinator cost is printed as a crossover table:
 25
 26  * FUNNEL backend  - fields flow THROUGH graph state (mimics Redis/staging).
 27  * DDICT backend   - fields stay in the DDict; state carries handles only.
 28
 29The curves converge at toy sizes and diverge at GB scale -- that divergence
 30is the whole pitch, and here it is a number you can read, not a claim.
 31
 32Why this needs Dragon
 33---------------------
 34Plain LangGraph can pass arrays through state, but every byte then transits
 35the coordinator; there is no node-placement and no node-local shared memory,
 36so the data-heavy version simply does not hold at scale.  Dragon's DDict +
 37``Policy`` placement are runtime capabilities surfaced through an otherwise
 38idiomatic LangGraph graph.
 39
 40Honest boundaries
 41-----------------
 42* The LangGraph graph/coordinator is single-node today (orchestration on
 43  Node 0).  What Dragon distributes here is AGENT EXECUTION (the reducers) and
 44  the DATA PLANE (the DDict) -- not the graph itself.
 45* The crossover TABLE is projected analytically from field sizes (exact, from
 46  ``ndarray.nbytes``) so it can show the GB-scale rows without allocating GBs
 47  on a dev box.  The LIVE run below it actually round-trips a field through the
 48  DDict and prints real locality receipts to prove the path works.
 49* On a single node the simulator and reducer co-locate by definition; the
 50  locality receipt is honest about that.  The win appears once data nodes > 1.
 51
 52Run
 53---
 54    dragon examples/dragon_ai/langgraph/05_data_locality_crossover.py
 55
 56    # heavier live fields (256^3 ~ 67 MB each) instead of the default demo size:
 57    MATSCREEN_RES=headline dragon examples/dragon_ai/langgraph/05_data_locality_crossover.py
 58=============================================================================
 59"""
 60
 61from __future__ import annotations
 62
 63import dragon
 64import multiprocessing as mp
 65import operator
 66import os
 67from functools import partial
 68from typing import Annotated, TypedDict
 69
 70import numpy as np
 71
 72from langgraph.graph import END, START, StateGraph
 73
 74# -- The Dragon + LangGraph integration (the only new import) -----------------
 75from dragon.ai.langgraph import DragonExecutor
 76
 77# -- Dragon primitives: distributed data plane + node placement ---------------
 78from dragon import DDict, Policy, System
 79from dragon.native.machine import Node
 80from dragon.native.process import Process
 81from dragon.native.queue import Queue
 82
 83
 84# =============================================================================
 85# Configuration
 86# =============================================================================
 87
 88# Field resolution presets (float32 cube edge).  The crossover lives in the GB
 89# regime, so the projected table always ends at a large row; the live run uses
 90# the selected preset (default "demo") so it stays friendly on a dev box.
 91RESOLUTION_PRESETS = {
 92    "smoke": 48,        # ~0.4 MB / field
 93    "demo": 112,        # ~5.6 MB / field
 94    "headline": 256,    # ~67 MB / field
 95}
 96LIVE_RES = RESOLUTION_PRESETS[os.environ.get("MATSCREEN_RES", "demo")]
 97
 98# Resolutions shown in the projected crossover table (ends GB-scale).
 99TABLE_RES = [48, 112, 256, 512]
100
101N_CANDIDATES = 4            # candidates screened per iteration (fan-out width)
102MAX_ITERATIONS = 2         # agentic refine loop depth
103DESC_BINS = 64             # radial |FFT|^2 histogram length (the KB descriptor)
104DESC_BYTES = DESC_BINS * 4 + 8         # descriptor + energy scalar
105HANDLE_BYTES = 24                      # a "field:<cid>" string handle
106DDICT_MEM = 1 * 1024**3                # 1 GiB managed heap (sized for "demo")
107
108
109# =============================================================================
110# Field synthesis + map-side descriptor (cheap, local, known at write time)
111# =============================================================================
112
113def _synth_field(resolution: int, seed: int) -> np.ndarray:
114    """A toy 3-D charge-density field.  Stands in for a real simulator output."""
115    rng = np.random.default_rng(seed)
116    axis = np.linspace(-1.0, 1.0, resolution, dtype=np.float32)
117    x, y, z = np.meshgrid(axis, axis, axis, indexing="ij")
118    centers = rng.uniform(-0.6, 0.6, size=(3, 3)).astype(np.float32)
119    field = np.zeros((resolution, resolution, resolution), dtype=np.float32)
120    for cx, cy, cz in centers.T:
121        field += np.exp(-8.0 * ((x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2))
122    return field.astype(np.float32)
123
124
125def _map_side_descriptor(field: np.ndarray) -> tuple[float, np.ndarray]:
126    """MAP-SIDE reduction: fused into the simulator, runs right after the slab
127    is written.  Cheap, purely local, and known at write time -- so it belongs
128    in the simulator, not the analyzer.  Returns (energy scalar, KB spectral
129    descriptor).  This is the small thing that travels; the field stays put.
130    """
131    energy = float(np.sum(field.astype(np.float64) ** 2))
132    spectrum = np.abs(np.fft.rfftn(field)).astype(np.float32)
133    flat = spectrum.reshape(-1)
134    # radially-binned (here: magnitude-binned) |FFT| histogram -> fixed length
135    hist, _ = np.histogram(flat, bins=DESC_BINS, range=(0.0, float(flat.max()) + 1e-6))
136    desc = hist.astype(np.float32)
137    norm = np.linalg.norm(desc)
138    if norm > 0:
139        desc = desc / norm
140    return energy, desc
141
142
143# =============================================================================
144# Data-plane workers (run as Dragon Processes, placed ON the data nodes)
145# =============================================================================
146
147def _simulate_worker(cid: int, seed: int, resolution: int, dd_ser: str, out_q: Queue) -> None:
148    """DATA-PLANE simulator.  Builds the field and writes it to the DDict
149    manager LOCAL to this node, then returns only a handle + KB descriptor.
150    The GB field never touches the coordinator.
151    """
152    dd = DDict.attach(dd_ser)
153    field = _synth_field(resolution, seed)
154    energy, desc = _map_side_descriptor(field)
155
156    key = f"field:{cid}"
157    local_mgr = dd.local_manager                 # manager id on THIS node (or None on 1 node)
158    target = dd.manager(local_mgr) if local_mgr is not None else dd
159    target[key] = field                          # field lands on this node's manager
160    owner = dd.manager_nodes[local_mgr].hostname if local_mgr is not None else "node0"
161
162    out_q.put({
163        "cid": cid, "key": key, "energy": energy, "desc": desc,
164        "owner_node": owner, "field_mb": field.nbytes / 1024**2,
165    })
166    dd.detach()
167
168
169def _reduce_worker(node_tag: str, dd_ser: str, archive_ser: str | None, out_q: Queue) -> None:
170    """DATA-PLANE reducer, placed ON a data node.  Does the genuinely heavy,
171    cross-candidate work a single simulator structurally cannot: it needs
172    MULTIPLE full fields at once.  It reads only this node's LOCAL keys
173    (zero network), computes pairwise structural novelty over the local fields
174    plus the persistent elite archive, and returns a KB novelty vector.
175    """
176    dd = DDict.attach(dd_ser)
177    local_mgr = dd.local_manager
178    handle = dd.manager(local_mgr) if local_mgr is not None else dd
179
180    # Reduce-in-place: read fields that are LOCAL to this node only.
181    if local_mgr is not None:
182        local_keys = [k for k in dd.local_keys() if isinstance(k, str) and k.startswith("field:")]
183    else:
184        local_keys = [k for k in dd.keys() if isinstance(k, str) and k.startswith("field:")]
185    fields = [handle[k] for k in local_keys]     # local reads, no field crosses the network
186
187    # Heavy cross-candidate compute: 3-D FFT per field + O(N^2) spectral distances.
188    descs = [_map_side_descriptor(f)[1] for f in fields]
189    n = len(descs)
190    novelty = np.zeros(n, dtype=np.float32)
191    for i in range(n):
192        dists = [float(np.linalg.norm(descs[i] - descs[j])) for j in range(n) if j != i]
193        novelty[i] = float(np.mean(dists)) if dists else 0.0
194
195    # ELITE ARCHIVE (cross-iteration memory): diff against archived past winners.
196    archive_bonus = 0.0
197    if archive_ser is not None:
198        arch = DDict.attach(archive_ser)
199        arch_keys = [k for k in arch.keys() if isinstance(k, str) and k.startswith("elite:")]
200        if arch_keys and descs:
201            ref = arch[arch_keys[-1]]              # a KB descriptor from a past winner
202            archive_bonus = float(np.mean([np.linalg.norm(d - ref) for d in descs]))
203        arch.detach()
204
205    out_q.put({
206        "node": node_tag,
207        "manager": local_mgr,
208        "local_keys": local_keys,
209        "novelty": novelty.tolist(),
210        "archive_bonus": archive_bonus,
211        "n_local_fields": n,
212    })
213    dd.detach()
214
215
216# =============================================================================
217# Projected crossover table (exact, from ndarray.nbytes -- no GBs allocated)
218# =============================================================================
219
220def _print_crossover_table() -> None:
221    print("\n=== Projected coordinator cost: FUNNEL vs DDICT (per iteration) ===")
222    print(f"  candidates/iter = {N_CANDIDATES};  bytes are what passes THROUGH the coordinator\n")
223    header = f"  {'res':>5} {'field':>9} | {'FUNNEL thru':>12} {'FUNNEL peak':>12} | {'DDICT thru':>11} {'DDICT peak':>11} | {'ratio':>7}"
224    print(header)
225    print("  " + "-" * (len(header) - 2))
226    for res in TABLE_RES:
227        field_bytes = res**3 * 4
228        funnel_thru = N_CANDIDATES * field_bytes        # every field transits state
229        funnel_peak = N_CANDIDATES * field_bytes        # coordinator holds them all
230        ddict_thru = N_CANDIDATES * (HANDLE_BYTES + DESC_BYTES)
231        ddict_peak = HANDLE_BYTES + DESC_BYTES           # only a handle in flight
232        ratio = funnel_thru / ddict_thru
233        print(f"  {res:>5} {_mb(field_bytes):>9} | {_mb(funnel_thru):>12} {_mb(funnel_peak):>12} | "
234              f"{_mb(ddict_thru):>11} {_mb(ddict_peak):>11} | {ratio:>6.0f}x")
235    print("\n  Curves converge at toy sizes and diverge at GB scale (last row).")
236    print("  FUNNEL coordinator RAM grows N x field; DDICT coordinator stays flat.")
237
238
239def _mb(nbytes: float) -> str:
240    mb = nbytes / 1024**2
241    if mb >= 1024:
242        return f"{mb / 1024:.2f} GB"
243    if mb >= 1:
244        return f"{mb:.1f} MB"
245    return f"{nbytes / 1024:.1f} KB"
246
247
248# =============================================================================
249# Node roles: control plane (Node 0) vs data plane (the rest)
250# =============================================================================
251
252def _plan_node_roles() -> tuple[list[str], list[str]]:
253    """Derive a placement plan from the live allocation.  Node 0 = control
254    plane (routes handles/scalars); remaining nodes = data plane (hold fields,
255    run reducers).  One node -> everything co-locates (honest)."""
256    hosts = [Node(h).hostname for h in System().nodes]
257    if len(hosts) == 1:
258        return hosts, hosts                       # co-located: control == data
259    return hosts[:1], hosts[1:]
260
261
262# =============================================================================
263# Agentic graph: planner (control) -> simulate -> analyze (reduce) -> route
264# =============================================================================
265
266class ScreenState(TypedDict):
267    iteration: int
268    seeds: list[int]                              # KB: candidate parameters only
269    handles: Annotated[list[dict], operator.add]  # KB: handles + descriptors
270    novelty: list[float]
271    best_cid: int
272    decision: str
273    log: Annotated[list[str], operator.add]
274
275
276def planner_node(state: ScreenState) -> dict:
277    """CONTROL-PLANE agent (Node 0).  Proposes the next batch of candidate
278    seeds from the KB-scale scoreboard -- never touches a field.  Here the
279    proposal is rule-based; wiring it to the on-cluster LLM (with a real
280    tool-call) is exactly what examples 03 and 06 show.
281    """
282    it = state["iteration"]
283    base = it * 100
284    seeds = [base + i for i in range(N_CANDIDATES)]
285    return {"seeds": seeds, "log": [f"[planner@node0] iter {it}: proposed seeds {seeds}"]}
286
287
288def simulate_node(state: ScreenState, *, dd_ser: str, data_hosts: list[str]) -> dict:
289    """Fan out one DATA-PLANE simulator per candidate, each pinned to a data
290    node, each writing its field to the LOCAL DDict manager.  Gathers only
291    handles + KB descriptors back through state.
292    """
293    out_q = Queue()
294    procs = []
295    for i, seed in enumerate(state["seeds"]):
296        host = data_hosts[i % len(data_hosts)]
297        policy = Policy(placement=Policy.Placement.HOST_NAME, host_name=host)
298        p = Process(target=_simulate_worker,
299                    args=(i, seed, LIVE_RES, dd_ser, out_q),
300                    policy=policy)
301        p.start()
302        procs.append(p)
303
304    handles = [out_q.get() for _ in state["seeds"]]
305    for p in procs:
306        p.join()
307    handles.sort(key=lambda h: h["cid"])
308
309    receipts = [f"    cid {h['cid']}: {h['field_mb']:.1f} MB field -> {h['owner_node']} "
310                f"(handle '{h['key']}', desc {DESC_BYTES} B)" for h in handles]
311    return {
312        "handles": handles,
313        "log": [f"[simulate] {len(handles)} fields written to DDict, only handles returned:"] + receipts,
314    }
315
316
317def analyze_node(state: ScreenState, *, dd_ser: str, archive_ser: str,
318                 data_hosts: list[str]) -> dict:
319    """REDUCE-SIDE: launch one reducer per data node, placed ON that node, to
320    reduce its local fields in place.  GLOBAL COMBINE happens here on Node 0
321    over the KB novelty vectors -- one decision, not one per field.
322    """
323    out_q = Queue()
324    procs = []
325    for host in data_hosts:
326        policy = Policy(placement=Policy.Placement.HOST_NAME, host_name=host)
327        p = Process(target=_reduce_worker,
328                    args=(host, dd_ser, archive_ser, out_q),
329                    policy=policy)
330        p.start()
331        procs.append(p)
332
333    parts = [out_q.get() for _ in data_hosts]
334    for p in procs:
335        p.join()
336
337    # Locality receipts: each reducer touched only keys local to its own node.
338    log = ["[analyze] reduce-in-place receipts (bytes over network ~ 0):"]
339    novelty_by_cid: dict[int, float] = {}
340    for part in parts:
341        log.append(f"    reducer@{part['node']} mgr={part['manager']} read "
342                   f"{part['n_local_fields']} field(s) from its LOCAL manager "
343                   f"{part['local_keys']} -- no field crossed the network")
344        for k, nov in zip(part["local_keys"], part["novelty"]):
345            cid = int(k.split(":")[1])
346            novelty_by_cid[cid] = nov + part["archive_bonus"]
347
348    novelty = [novelty_by_cid.get(h["cid"], 0.0) for h in state["handles"]]
349    # GLOBAL COMBINE + decision on the KB scoreboard (one decision, not one per
350    # field). Wire this to the on-cluster LLM as examples 03/06 show.
351    best_cid = max(range(len(novelty)), key=lambda i: novelty[i]) if novelty else 0
352
353    # ELITE ARCHIVE UPDATE (cross-iteration memory): persist the winner's KB
354    # descriptor so the next iteration's reducer can diff against it. Only a
355    # ~KB vector is written -- never a field.
356    winner_desc = next((h["desc"] for h in state["handles"] if h["cid"] == best_cid), None)
357    if winner_desc is not None:
358        arch = DDict.attach(archive_ser)
359        arch[f"elite:{state['iteration']}"] = winner_desc
360        arch.detach()
361
362    converged = state["iteration"] + 1 >= MAX_ITERATIONS
363    decision = "converge" if converged else "refine"
364    log.append(f"[analyze@node0] best cid={best_cid} novelty={novelty[best_cid]:.3f} "
365               f"-> {decision}")
366    return {"novelty": novelty, "best_cid": best_cid, "decision": decision, "log": log}
367
368
369def route_after_analysis(state: ScreenState) -> str:
370    if state["decision"] == "converge":
371        return END
372    return "bump"
373
374
375def bump_iteration(state: ScreenState) -> dict:
376    return {"iteration": state["iteration"] + 1}
377
378
379# =============================================================================
380# Main
381# =============================================================================
382
383def main() -> None:
384    control_hosts, data_hosts = _plan_node_roles()
385    field_mb = LIVE_RES**3 * 4 / 1024**2
386
387    print("=" * 77)
388    print("Materials screening - data locality + distributed agents (measured)")
389    print("=" * 77)
390    print(f"  allocation        : {len(control_hosts) + len(data_hosts) - (1 if data_hosts == control_hosts else 0)} node(s)")
391    print(f"  control plane     : {control_hosts}  (routes handles/scalars; single-node coordinator)")
392    print(f"  data plane        : {data_hosts}  (hold fields, run reducers)")
393    print(f"  live field        : {LIVE_RES}^3 float32 = {field_mb:.1f} MB/candidate "
394          f"x {N_CANDIDATES} = {field_mb * N_CANDIDATES:.1f} MB in DDict, ~0 to coordinator")
395
396    # ---- Part 1: the measured crossover (projected from exact field sizes) ----
397    _print_crossover_table()
398
399    # ---- Part 2: a live distributed iteration that proves the path + locality --
400    print("\n=== Live distributed run (handles in state, fields stay node-local) ===")
401
402    co_located = data_hosts == control_hosts
403    n_data = 1 if co_located else len(data_hosts)
404    fields_mb = field_mb * N_CANDIDATES
405    assert DDICT_MEM / n_data >= fields_mb * 1024**2, (
406        f"DDict per-node mem too small for {fields_mb:.0f} MB of fields; "
407        f"lower MATSCREEN_RES or raise DDICT_MEM"
408    )
409
410    dd = DDict(1, n_data, DDICT_MEM)               # 1 manager per data node
411    archive = DDict(1, n_data, 64 * 1024**2)       # elite-archive memory
412    dd_ser = dd.serialize()
413    archive_ser = archive.serialize()
414
415    control_policy = Policy(placement=Policy.Placement.HOST_NAME, host_name=control_hosts[0])
416
417    try:
418        with DragonExecutor() as executor:
419            # Control-plane agents run in one AgentHost pinned to Node 0.
420            executor.launch_host(
421                agents={
422                    "planner": planner_node,
423                    "simulate": partial(simulate_node, dd_ser=dd_ser, data_hosts=data_hosts),
424                    "analyze": partial(analyze_node, dd_ser=dd_ser, archive_ser=archive_ser,
425                                       data_hosts=data_hosts),
426                },
427                policy=control_policy,
428            )
429
430            builder = StateGraph(ScreenState)
431            builder.add_node("planner", executor.node("planner"))
432            builder.add_node("simulate", executor.node("simulate"))
433            builder.add_node("analyze", executor.node("analyze"))
434            builder.add_node("bump", bump_iteration)
435
436            builder.add_edge(START, "planner")
437            builder.add_edge("planner", "simulate")
438            builder.add_edge("simulate", "analyze")
439            # converge -> END; refine -> bump iteration and re-enter the planner
440            builder.add_conditional_edges("analyze", route_after_analysis, ["bump", END])
441            builder.add_edge("bump", "planner")
442
443            graph = builder.compile()
444            result = graph.invoke(
445                {"iteration": 0, "seeds": [], "handles": [], "novelty": [],
446                 "best_cid": 0, "decision": "", "log": []},
447                config={"recursion_limit": 50},
448            )
449
450        print()
451        for line in result["log"]:
452            print(line)
453        print(f"\n  Winner: candidate {result['best_cid']} "
454              f"(novelty {result['novelty'][result['best_cid']]:.3f})")
455        print("  Only handles + KB descriptors ever crossed graph state. "
456              "Fields stayed node-local in the DDict.")
457    finally:
458        dd.destroy()
459        archive.destroy()
460
461
462if __name__ == "__main__":
463    mp.set_start_method("dragon", force=True)
464    main()

See Also