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