--- id: "0049h" title: "`graph_force_layout_gpu`: compute shader + spatial hash" status: completado type: feature domain: [] scope: multi-app priority: media depends: [] blocks: [] related: [] created: 2026-05-17 updated: 2026-05-17 tags: [] --- # 0049h — `graph_force_layout_gpu`: compute shader + spatial hash ## Metadata | Campo | Valor | |-------|-------| | **ID** | 0049h | | **Estado** | pendiente | | **Prioridad** | media-alta | | **Tipo** | feature performance — parte de [#0049](0049-osint-graph-viewer.md) | ## Dependencias **Bloqueada por:** [0049b](0049b-cpp-bump-gl-43.md) (compute shaders), [0049e](0049e-graph-types-extended.md) (`flags.pinned`). --- ## Objetivo Implementar el force-directed layout en GPU usando compute shaders y SSBOs. Repulsion via spatial hash grid (no Barnes-Hut), atraccion por aristas, integracion con clamp y respeto de `NF_PINNED`. API consistente con la version CPU para que el consumer pueda swappear. ## Contexto A 50k+ nodos el CPU layout (Barnes-Hut, ~5-10 ms/frame con 20k nodos) impide 60fps con margen. GPU compute con grid hash: - Cada celda contiene punteros a sus nodos (atomic insert). - Repulsion: cada nodo lee solo su celda + 8 vecinas (3×3) → O(N · density) en vez de O(N log N). - Sin estructuras dinamicas → todo arrays planos en SSBO. ## Arquitectura ``` cpp/functions/viz/ ├── graph_force_layout_gpu.h # NEW ├── graph_force_layout_gpu.cpp # NEW ├── graph_force_layout_gpu.md # NEW cpp/functions/viz/shaders/ # NEW dir (o inline strings) ├── force_clear_grid.comp # NEW: zero counters ├── force_build_grid.comp # NEW: insert nodes en celdas (atomic counters) ├── force_repulsion.comp # NEW: leer celdas vecinas, acumular fuerza ├── force_attraction.comp # NEW: por arista, acumular spring force └── force_integrate.comp # NEW: v += f, clamp, x += v, respeta pinned cpp/tests/ └── test_graph_force_layout_gpu.cpp # NEW ``` ### API ```cpp struct ForceLayoutGPU; ForceLayoutGPU* graph_force_layout_gpu_create(int max_nodes, int max_edges, int grid_cells_per_side = 64); void graph_force_layout_gpu_upload(ForceLayoutGPU*, const GraphData&); // copy positions/edges to GPU float graph_force_layout_gpu_step (ForceLayoutGPU*, GraphData&, const ForceLayoutConfig&); // returns total energy void graph_force_layout_gpu_readback(ForceLayoutGPU*, GraphData&); // sync GPU positions back to CPU mirror void graph_force_layout_gpu_destroy(ForceLayoutGPU*); ``` ### Buffers GPU | SSBO | Layout | Tamaño | |---|---|---| | `positions` | `vec2[N]` | 8 × N | | `velocities` | `vec2[N]` | 8 × N | | `forces` | `vec2[N]` | 8 × N (working) | | `flags` | `uint[N]` | 4 × N | | `edges` | `uvec2[E]` | 8 × E | | `weights` | `float[E]` | 4 × E | | `grid_counts`| `uint[G²]` | 4 × 64² = 16 KB | | `grid_cells` | `uint[G²][K]` | 4 × G² × max_nodes_per_cell (32) | Para 100k nodos: ~3 MB en SSBOs — trivial. ## Tareas ### Fase 1 — Esqueleto + buffer alloc - [ ] **1.1** Crear `ForceLayoutGPU` con `max_nodes`/`max_edges`. Crear todos los SSBOs. - [ ] **1.2** `_upload`: empaqueta posiciones/aristas/flags y llama `glBufferSubData`. - [ ] **1.3** Compilar los 5 compute shaders al crear (helper `compile_compute(src)`). ### Fase 2 — Compute shaders - [ ] **2.1** `force_clear_grid.comp`: 1 thread por celda, zero counter. - [ ] **2.2** `force_build_grid.comp`: 1 thread por nodo, calcula celda, `atomicAdd(grid_counts[ci], 1)`, escribe en `grid_cells[ci][slot]` si `slot < K`. - [ ] **2.3** `force_repulsion.comp`: 1 thread por nodo. Recorre las 9 celdas vecinas, para cada otro nodo calcula `F = repulsion / dist²`. Acumula en `forces[i]`. - [ ] **2.4** `force_attraction.comp`: 1 thread por arista, atomic add a `forces[source]` y `forces[target]`. - [ ] **2.5** `force_integrate.comp`: 1 thread por nodo. Si `flags & NF_PINNED`, skip. `v = damping*v + F`, clamp a `max_velocity`, `x += v`. Atomic add a `total_energy_ssbo`. ### Fase 3 — Step pipeline - [ ] **3.1** `_step` despacha los 5 compute shaders en orden con `glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT)` entre cada uno. - [ ] **3.2** Ultimo barrier: `GL_BUFFER_UPDATE_BARRIER_BIT` para que `_readback` vea valores frescos. - [ ] **3.3** Bounds calculados en CPU tras readback (cheap). ### Fase 4 — Readback eficiente - [ ] **4.1** `_readback` usa `glGetBufferSubData` solo de `positions` (8 × N bytes). Para 50k nodos = 400 KB; CPU mirror se actualiza cada frame. - [ ] **4.2** Documentar tradeoff: si el consumer no necesita mirror cada frame (p.ej. solo la app que dibuja con la GPU), `_readback` es opcional. ### Fase 5 — Tests - [ ] **5.1** Test smoke: 100 nodos, 200 aristas; correr 100 steps; verificar que la energia decrece. - [ ] **5.2** Test pinned: pinear 1 nodo en (0,0); tras 100 steps debe seguir en (0,0). - [ ] **5.3** Test consistency CPU-vs-GPU: para un grafo pequeño (50 nodos), la energia tras N steps debe diferir < 5% entre la version CPU y la GPU (no exacto por floating-point + grid approximation, pero comparable). - [ ] **5.4** Test SKIP en WSL si no hay context con compute (el harness ya skipea si no hay GL context). ### Fase 6 — Demo - [ ] **6.1** Anadir toggle "GPU layout" en `demos_graph` que swappea CPU ↔ GPU. Mostrar fps y energia en tiempo real. - [ ] **6.2** Probar con 50k nodos. ### Fase 7 — Cleanup - [ ] Frontmatter `.md` con `purity: impure`, `uses_types: [graph_data_cpp_viz, force_layout_config_cpp_viz]`. - [ ] `fn index`. - [ ] Commit `feat(viz): graph_force_layout_gpu compute + spatial hash`. ## Criterio de done - [ ] 50k nodos + 200k aristas a 60fps con layout iterativo corriendo. - [ ] Tests verdes (smoke + pinned + consistency). - [ ] CPU usage < 10% durante el layout (medible con Tracy). - [ ] Toggle CPU/GPU en `demos_graph` operativo. ## Riesgos | Riesgo | Mitigacion | |---|---| | `max_nodes_per_cell` overflow | Si una celda satura, los excedentes se ignoran (con warning); ajustar `grid_cells_per_side` mas grande o aumentar `K` | | Atomic contention en repulsion (escrituras a `forces[i]`) | Cada nodo es escrito por un solo thread (su propio); lecturas si concurrentes pero readonly de `positions` | | Atomic add sobre `forces` en attraction (multiple aristas tocan el mismo nodo) | Usar `atomicAdd` real sobre uvec2 packed, o serializar con un buffer temp y sumar despues. Alternativa: un compute pass por dimension | | Diferencias driver entre vendors (Mesa, NV, AMD) | Usar `#version 430 core`, evitar features vendor-specific. Test en al menos un GPU NVIDIA y uno AMD/Mesa |