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
DDictHow 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
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
LangGraph Integration — internals and code paths.
LangGraph Integration — API reference.