Skip to content

Big refactor of the core + context - #12

Merged
bgailleton merged 71 commits into
TopoToolbox:mainfrom
bgailleton:main
Sep 1, 2026
Merged

Big refactor of the core + context#12
bgailleton merged 71 commits into
TopoToolbox:mainfrom
bgailleton:main

Conversation

@bgailleton

@bgailleton bgailleton commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Current framework limitations

In short, there were two main issues.

  1. The old design was too convoluted and inflexible.
    It relied on many stateful "context" objects, each carrying its own
    parameters, kernels, helpers and bookkeeping. The result was hard to
    understand, hard to extend, and monolithic: adding a variant usually meant
    touching the context class itself.

  2. The Taichi ecosystem has slowed down.
    Taichi is still maintained, but its pace of development has dropped
    significantly. This is a good opportunity to rebuild the framework around a
    single composition philosophy while staying backend-agnostic. In
    particular, Quadrants — a fork of Taichi actively developed since
    mid-2025 — is a promising alternative backend.

Refactor

Why

GPU performance is dominated by two things.

Memory traffic. The bottleneck is rarely arithmetic; it is moving data
between global memory and the compute units. A quantity that is uniform in
space and time — say a constant Manning roughness coefficient — should be a
compile-time literal that costs no memory access at all, whereas a 2D varying
field of the same coefficient costs one fetch per node per step. The numerical
scheme is identical in both cases; only the data layout differs, and that
difference is worth a large constant factor.

Branch divergence. GPUs are fast on streamlined, branch-free code and slow
when threads in the same warp take different paths. A flexible grid — where a
single neighbour lookup inspects each cell and dispatches on its boundary type
(periodic, no-data, domain edge, normal interior) — is exactly the kind of
per-thread branching that kills throughput.

The usual escape is to hand-write a separate kernel for every combination of
parameter layout and boundary logic. That does not scale: the combinations
multiply, and it defeats reuse. The same base operations — local-minima
solver, flow accumulation, slope computation — need to run inside a hydraulic
simulation, a stream-power incision model and a topographic analysis, each
time with different parameters, different boundaries and different companion
kernels.

This refactor lets a numerical scheme be written once, generically, with
the parameter layout and the boundary implementation left as named slots. Both
are resolved when the kernel is compiled — early enough that a constant folds
into the generated code and the boundary logic becomes a single specialised
function with no runtime dispatch. The scheme itself is never edited or
duplicated.

Building blocks

Listed from the lowest-level piece to the highest.

  • Parameter — one named physical value (e.g. the erodability coefficient
    K). Its mode fixes where the value lives and is chosen at compile time:
    const (baked into the code, uniform for the whole run), scalar (a single
    device value, retunable between steps), or field (one value per node,
    spatially variable). The kernel reads it the same way regardless of mode.

  • Helper — a device-side function left as a placeholder in the kernel. A
    call to get_left_neighbour is bound at compile time to one concrete
    implementation (..._periodic, ..._normal, ..._nodata_check, …), so
    boundary conditions and stencils change without touching the kernel.

  • Group — a pure-structure bundle of Parameters and Helpers with no data
    of its own, e.g. all of a grid's neighbour and node-distance logic. A kernel
    composes a Group into its tree and reaches into it by dotted path
    (ctx.grid.dx.get(i), ctx.grid.neighbour(i)).

  • Data — a handle to a device array taken from a shared memory pool. The
    bulk fields a model works on (elevation, water depth, discharge) are passed
    in at call time, not baked into the kernel; temporary buffers are acquired
    and released automatically.

  • Kernel — the GPU computation, written once in generic form against a
    ctx tree of slots.

  • Routine — an ordered set of Kernels sharing one address space, compiled
    into a single callable. It manages pool buffers and enables backend
    optimisations: kernel fusion on Taichi and Quadrants, CUDA Graph execution
    on cupy.

  • Sequence — a host-driven layer above Routines. It runs Routines and
    standalone Kernels under ordinary Python control flow: e.g. "run these three
    Routines N times with a reduction barrier between each", or "loop until a
    counter reaches 0", or any other condition evaluated on the host.

A complete subsystem is delivered not as one monolithic object but as a pair
of factories: make_<x>_group, returning the pure structure, and
make_<x>_parameters, returning the concrete Parameters the caller owns and
binds. You assemble exactly the pieces you need and keep ownership of the data.

Build → freeze → bind → compile

A Kernel, Routine or Sequence becomes runnable in distinct phases:

  1. write — write the actual code, with placholders (i.e. variables that are not
    defined in sensu stricto way)
  2. build — declare the Parameter / Helper / Data slots the template needs
    and compose in any Groups. That's where you declare which "undeclared"
    variable is a parameter, a helper or whatever
  3. freeze — collapse that recipe into an inert, immutable structure with
    no bound values and no device storage. A frozen block can be exported and
    reused inside many different blocks (kernel/routine/sequence/...).
  4. bind — fill every slot with a concrete Parameter, Helper or buffer at
    its named address. It's where data/parameter gets IRL concrete meaning
  5. compile — emit the real backend kernel (ti.kernel, qd.kernel, or
    CUDA __global__).

Changing a dependency means re-binding and recompiling; the template code is
never modified.

This is deliberately verbose. The verbosity is the price of composability and
it is paid once, at assembly time, by whoever wires the blocks together.
The end product — a physics model built with the framework — is compiled down
to code as tight as a handwritten kernel, and none of this machinery is
visible to whoever runs that model.

Backends

The building blocks are implemented for three backends:

  • taichi — the mature, well-tested backend.
  • quadrants — a newer, actively developed backend with a Taichi-like
    programming model.
  • cupy.RawKernel — a low-level CUDA backend. ⚠️ Kernels are written in CUDA
    C; intended for experienced users.

bgailleton and others added 25 commits July 20, 2026 11:01
… - not possible because my param injections are ot visible by the latter
@bgailleton

Copy link
Copy Markdown
Collaborator Author

if the ref arrives anywhere, ignore the links to topotoolbox issues - this was not intended

@bgailleton bgailleton self-assigned this Jul 27, 2026
@bgailleton
bgailleton marked this pull request as draft July 27, 2026 15:01
@bgailleton

Copy link
Copy Markdown
Collaborator Author

This is getting very nice. I am going on holidays for 2 weeks, but apart for some cleaning and a few obvious optimisations, it is close to ready!

@bgailleton
bgailleton marked this pull request as ready for review September 1, 2026 10:02
@bgailleton
bgailleton merged commit a255210 into TopoToolbox:main Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant