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

See Also