One language. Several physical shapes.
Begin with a borrowed Flow. Keep the same graph and move into Program, resident execution, Pipeline, or Batch only when the workload exposes the corresponding repeated cost.
Start with Flow
on(target, input) borrows an lvalue contiguous range. Construction copies no
payload. Intermediate operations remain lazy, and collect() is the complete
terminal that opens the selected backend, compiles, transfers, executes, and reads back.
auto result = rund::compute::on(target, input)
.map("adjust", [](auto value) { return value + 1; })
.scan(rund::compute::Scan::InclusiveSum)
.collect();
The scalar language is bounded
The lambda constructs a canonical expression circuit from fixed-width integer and Fixed values. Bounded selection, append, work queues, indexed reads and writes, grouping, collectives, and explicit unrolling compose a finite graph before execution.
- Every input type and count is exact.
- Every capacity and iteration ceiling is known before compile.
- Overflow and unsupported expression shapes are typed admission failures.
- No backend callback, shader handle, or hidden CPU implementation enters the public Flow.
The primary chain includes map, filter, scan, reduce, sort, argsort, compact, histogram,
window, gather, scatter, partition, segmented collectives, bounded expand/join, group-by,
records, multiple outputs, and explicit unrolling. Add
<rund/compute/math.hpp> for composite expressions, matrices,
transforms, factorization, solve, and spectrum stages.
Choose by repeated cost
| Surface | Use it when | Identity that stays fixed |
|---|---|---|
collect() | You need one convenient host result. | Flow graph, law, target, result. |
Program | The graph repeats with changing input. | Compiled canonical Program. |
Job | You need explicit bindings and execution lifecycle. | Program plus bounded input shape. |
resident() | State should remain on the selected device. | Program and explicit Buffer state. |
Pipeline | Dependent Programs share resident intermediates. | Declaration order, hazards, commit. |
Batch | Independent jobs should share one submission boundary. | Job order and per-job result identity. |
Compile once when the graph repeats
A Program keeps the canonical graph and compiled backend artifact while inputs change. It executes again on every call; this is reuse, not result memoization.
#include <rund/compute.hpp>
#include <algorithm>
#include <array>
#include <cstdint>
#include <span>
#include <utility>
int main() {
constexpr std::size_t Count = 4u;
auto device = rund::compute::open(rund::compute::Target::cpu(1u));
if (!device) {
return device.exit_code();
}
auto flow = rund::compute::on(*device).map<std::int32_t>(
"affine", Count, [](auto value) { return value * 2 + 5; });
auto program = std::move(flow).compile();
if (!program) {
return program.exit_code();
}
constexpr std::array<std::int32_t, Count> Initial{1, 2, 3, 4};
constexpr std::array<std::int32_t, Count> Changed{10, 20, 30, 40};
auto first = program->run(std::span<const std::int32_t>{Initial});
auto second = program->run(std::span<const std::int32_t>{Changed});
if (!first) {
return first.exit_code();
}
if (!second) {
return second.exit_code();
}
constexpr std::array<std::int32_t, Count> First{7, 9, 11, 13};
constexpr std::array<std::int32_t, Count> Second{25, 45, 65, 85};
return std::ranges::equal(*first, First) &&
std::ranges::equal(*second, Second)
? 0
: 2;
}
Overlap compilation only when you have other work
Add <rund/compute/async.hpp> and open the Device with an explicit
Compile worker and queue budget. compile_async() consumes the
Flow and returns immediately after admission; the future yields the same Program result as
synchronous compile().
#include <rund/compute.hpp>
#include <rund/compute/async.hpp>
#include <array>
#include <cstdint>
#include <span>
#include <vector>
int main() {
constexpr std::size_t Count = 4u;
auto device = rund::compute::open(
rund::compute::Target::cpu(1u),
rund::compute::Compile{.workers = 1u, .capacity = 2u});
if (!device) {
return device.exit_code();
}
auto pending =
rund::compute::on(*device)
.map<std::int32_t>("async-adjust", Count,
[](auto value) { return value * 2 + 5; })
.compile_async();
if (!pending) {
return pending.exit_code();
}
auto program = pending->get();
if (!program) {
return program.exit_code();
}
constexpr std::array<std::int32_t, Count> input{1, 2, 3, 4};
auto output = program->run(std::span<const std::int32_t>{input});
if (!output) {
return output.exit_code();
}
return *output == std::vector<std::int32_t>{7, 9, 11, 13} ? 0 : 2;
}
Check both boundaries. A failed outer result means the recipe was not admitted; inspect
reason() for unavailable service or full capacity and retry only after fixing
that resource condition. A failed result from future::get() is the actual graph
compilation failure; fix the reported graph or target condition. Neither path switches target.
Keep data resident only when reuse pays
Resident Job
auto job = program->resident(input);
if (!job) return job.exit_code();
for (std::size_t i = 0; i < iterations; ++i) {
auto completed = job->run();
if (!completed) return completed.exit_code();
}
auto output = job->read();
Resident creation prepares bounded storage and uploads the initial input. Warm
run() changes execution state without an implicit readback; read()
is the explicit host boundary.
Batch independent GPU Jobs
Batch accepts at most 64 resident Jobs from the same opened Device and executes
them behind one native submission boundary. It does not fuse their graphs, change order, or
support CPU Jobs. Empty, duplicate, cross-Device, busy, and over-capacity admission fail
before any Job executes.
rund::compute::Batch batch;
if (auto added = batch.add(*first); !added) return added.exit_code();
if (auto added = batch.add(*second); !added) return added.exit_code();
if (auto completed = batch.run(); !completed) return completed.exit_code();
auto first_output = first->read();
auto second_output = second->read();
Pipeline dependent Programs
Include <rund/compute/pipeline.hpp> when compiled Programs share resident
buffers. Declare stages in order, bind exact reads and writes, call prepare()
once, reuse run(), then publish state only through explicit commit/readback.
Hazards, claims, poison, and failure remain tied to declaration order.
auto prepared =
rund::compute::pipeline(device)
.then(*produce, rund::compute::read(*input),
rund::compute::write(*middle))
.then(*consume, rund::compute::read(*middle),
rund::compute::write(*output))
.prepare();
if (!prepared) return prepared.exit_code();
rund::compute::Pipeline pipeline = std::move(prepared).value();
if (auto ran = pipeline.run(); !ran) return ran.exit_code();
if (auto read = pipeline.read(*output, observed); !read) {
return read.exit_code();
}
Publish tile output without making it state
Use the two-Program tile_repeat<0>(seed, fold) form when a window needs no
inner Action. A Fold may return recurrent final values followed by tile-sized append-only
values. Bind the first group with write_final(...) and the second with
write_window(...). runD keeps only a private tile bank and publishes the exact
active slice after that Fold succeeds; a partial tail never exposes padding or turns the
appended values into next-window state. A later step in the same Pipeline can read that
target directly after the nested group is sealed. The planner inserts the exact device
write-to-read boundary without a host round trip, second Pipeline, or full-size shadow copy;
later writes to the owned target remain rejected.
auto body = rund::compute::tile_repeat<0u>(*seed, *fold);
auto prepared =
rund::compute::pipeline(device)
.windows<Maximum, Tile>(
body, rund::compute::window(*active_count),
rund::compute::read(*outer_state, *source, *lanes),
rund::compute::write_final(*final_state),
rund::compute::write_window(*per_item_output))
.then(*consume,
rund::compute::read(*per_item_output),
rund::compute::write(*consumed_output))
.prepare();
Choose a checkpoint at the ownership boundary
LatestDeviceState is the copyable live hand-off for the same exact Device. It
follows successful generations without copying or hashing payload bytes. For recovery on a
different Device or backend, allocate one move-only SnapshotStorage, reuse
snapshot_into(storage) at deliberate export points, and restore from its
portable host payload. Neither path runs implicitly during run(), so code that
requests no host feedback or checkpoint keeps the resident warm path.
rund::compute::LatestDeviceState latest =
pipeline.latest_device_state().value();
if (auto restored = peer.restore(latest); !restored) {
return restored.exit_code();
}
rund::compute::SnapshotStorage storage =
pipeline.snapshot_storage().value();
if (auto saved = pipeline.snapshot_into(storage); !saved) {
return saved.exit_code();
}
The names above stand for already compiled Programs, resident Buffers, and caller-owned output storage. For a larger profile-and-memory example, inspect the optional checked Pipeline consumer source ↗.
Submit a prepared Job through an active Session
Add <rund/compute/session.hpp> when the application already owns a
runtime Session. Prepare the resident Job first, then submit that same Job; Session schedules
it but does not recompile, change target, or read the result back.
#include <rund/compute.hpp>
#include <rund/compute/session.hpp>
#include <rund/session.hpp>
#include <array>
#include <cstdint>
#include <vector>
int main() {
constexpr std::array<std::int32_t, 4u> input{1, 2, 3, 4};
auto program =
rund::compute::on(rund::compute::Target::cpu(2u))
.map<std::int32_t>("session-adjust", input.size(),
[](auto value) { return value * 2 + 5; })
.compile();
if (!program) {
return program.exit_code();
}
auto job = program->resident(input);
if (!job) {
return job.exit_code();
}
int operation = 0;
const rund::Session::Result hosted =
rund::run(rund::SessionConfig{.workers = 2u},
[&](rund::Session &session) {
const rund::compute::Completion completed =
session.compute(*job).submit().wait();
operation = completed.exit_code();
});
if (!hosted) {
return hosted.exit_code();
}
if (operation != 0) {
return operation;
}
auto output = job->read();
if (!output) {
return output.exit_code();
}
return *output == std::vector<std::int32_t>{7, 9, 11, 13} ? 0 : 2;
}
Keep the two outcomes separate: Completion owns submission and backend
execution failure, while Session::Result owns lifecycle and drain failure.
Read the Job only after both succeed. A busy Job must finish before resubmission; a stopped
Session requires a new active lifecycle, not a standalone or CPU fallback.
Explicit target selection
The current Alpha exposes CPU, Metal, and Vulkan target constructors. These are current lowering implementations, not the identity of the Flow language. An additional backend must implement the same graph, numeric, ordering, failure, and evidence contracts before it can join.
Target::cpu()
Target::metal()
Target::vulkan()
Use the focused public surface
Use focused <rund/*.hpp> entries and link only runD::sdk.
Kernel scheduling, accelerator adapters, native device values, and Node runtime integration
remain implementation layers behind the installed contract.
Choose by measured cost, or open API & Errors for focused extensions, result ownership, and failure actions.