Extending Tutti: Adding a Resolver + Binding + DataPath
This guide walks through adding a new storage backend as a community
contributor. It uses the memfs sample (tutti/bindings/memfs/) as a
concrete example. The entire sample was added with zero core changes —
no tutti/include/tutti/** or Runtime source file was modified.
What you need to create
Component |
Location |
Purpose |
|---|---|---|
Binding |
|
Payload type, identity constants, pairing helpers ( |
DataPath |
|
Implements |
Resolver |
|
Implements |
CMakeLists.txt |
|
INTERFACE library + optional test registration |
Contract test |
|
URI parsing, E2E, boundary, lifecycle |
What you must NOT modify
tutti/include/tutti/**— public/SPI headers (frozen)tutti/storage_runtime.h— Runtime implementation (frozen)Any existing resolver/binding/DataPath package
If you find you must change a core file, stop and record it as a gap.
A second example: striped (multi-device, fused-kernel submission)
tutti/data_paths/striped_local_nvme/ (+ tutti/resolvers/striped_file/ +
tutti/bindings/striped_local_nvme/) is a second, more advanced community
extension: it fans a single logical striped://name?devs=<m1,m2,...>&unit=<bytes>
target out across N local NVMe devices with unit-granularity round-robin
striping, submitted through exactly one cudaLaunchKernel per
rt.submit() call (a device table of N DeviceTargetHandle* lets one fused
kernel dispatch entries to whichever device each stripe unit landed on).
Like memfs, it was added with zero core changes — callers see a plain
TargetHandle from rt.open("striped://...") and never reference a
Striped* type; see tests/striped_local_nvme_contract/ (tests 87/90) for
the “zero striped-awareness at the call site” proof and the fault/partial-
commit contract. It shares the nvme_submit_primitives.cuh device-side
primitives with tutti/data_paths/local_nvme/ (extracted once, unchanged)
rather than reimplementing resolve_lba/doorbell/CQ-poll logic. See the
package’s own header comments (striped_data_path.h,
resolvers/striped_file/resolver.h, bindings/striped_local_nvme/binding.h)
for the full design.
Step-by-step (memfs example)
1. Define the payload (binding.h)
The payload is pair-private: it lives only in your binding package. No core header references it.
namespace tutti::binding::memfs {
inline constexpr std::string_view kPayloadTypeId = "memfs-payload-v1";
inline constexpr std::uint32_t kPayloadApiVersion = 1;
inline constexpr std::string_view kRecommendedDataPathKey = "memfs";
class MemfsPayload { /* ... immutable, factory-created ... */ };
// Resolver packs payload + lease into ResolvedTarget:
Result<ResolvedTarget> make_resolved_target(uint64_t size,
shared_ptr<const MemfsPayload> payload, ...);
// DataPath extracts payload with type-id + version check:
Result<const MemfsPayload*> view_payload(const ResolvedTarget& target);
} // namespace tutti::binding::memfs
Key points:
kPayloadTypeId+kPayloadApiVersionare the only place these strings appear. Bothmake_resolved_targetandview_payloaduse them, so resolver and DataPath physically cannot diverge.kRecommendedDataPathKeytells the Runtime which DataPath to use.
2. Implement the DataPath (<name>_data_path.h)
Implement tutti::DataPath (from <tutti/spi/data_path.h>):
class MemfsDataPath : public tutti::DataPath {
const DataPathCapabilities& capabilities() const override;
Status initialize(const DataPathConfig&, ResourceProvider&) override;
Status shutdown(uint64_t) override;
Result<DataPathTarget> open(const ResolvedTarget&) override;
Status close(DataPathTarget) override;
Result<RegistrationDomainKey> registration_domain(DataPathTarget) const override;
Result<DataPathMemory> register_memory(const DataPathMemoryView&, ...) override;
Status unregister_memory(DataPathMemory) override;
SubmitOutcome submit(const DataPathRequest*, size_t, const HostSubmitContext&) override;
Result<ProgressResult> progress(ProgressBudget) override;
Result<DataPathSnapshot> query(DataPathOp) const override;
Status release(DataPathOp) override;
};
Use detail::SpiIdentityMint::mint<...>(token, generation) to mint opaque
identities for targets/memory/ops (see tutti/testing/mock_data_path.h
for a complete reference implementation).
3. Implement the resolver (resolver.h)
Implement tutti::StorageTargetResolver (from
<tutti/spi/storage_target_resolver.h>):
class MemfsResolver : public StorageTargetResolver {
Result<ResolvedTarget> resolve(string_view uri,
const ResolveOptions&) override {
// 1. Parse URI
// 2. Create payload (backing resource)
// 3. Return make_resolved_target(size, payload)
}
};
4. Create CMakeLists.txt
add_library(tutti_memfs_binding INTERFACE)
target_include_directories(tutti_memfs_binding INTERFACE
$<BUILD_INTERFACE:${TUTTI_REPOSITORY_ROOT}>
)
target_link_libraries(tutti_memfs_binding INTERFACE tutti_spi)
if(BUILD_TESTING)
add_subdirectory(
"${TUTTI_REPOSITORY_ROOT}/tests/memfs_sample_contract"
"${CMAKE_CURRENT_BINARY_DIR}/tests_memfs_sample_contract")
endif()
5. Register with one line
Add one line to tutti/CMakeLists.txt, inside the if(BUILD_TESTING)
block immediately after include(CTest):
if(BUILD_TESTING)
include(CTest)
add_subdirectory(bindings/memfs) # <-- the one line
That’s it — the library and test are now built.
Placement matters: add_test() only registers in directories processed
after include(CTest) has enabled testing. Putting the line earlier
(e.g. next to the production add_subdirectory(bindings/...) calls) will
build the test binary but silently leave it out of ctest.
6. Write contract tests
Create tests/<name>_sample_contract/ with:
URI parsing (valid + invalid)
E2E via
StorageRuntime: open → register → submit(WRITE) → wait → submit(READ) → wait → verify data → release → close → shutdownBoundary rejection (offset + length > size)
Lease lifecycle (target invalid after close)
How the Runtime wires it together
User: rt.open("memfs://4096", {"memfs"})
→ Runtime extracts scheme "memfs"
→ Finds MemfsResolver registered for scheme "memfs"
→ resolver.resolve("memfs://4096") → ResolvedTarget
→ ResolvedTarget.recommended_data_path_key() == "memfs"
→ Finds MemfsDataPath registered for key "memfs"
→ data_path.open(resolved_target) → DataPathTarget
→ TargetHandle returned to user
Checklist
Payload type defined only in
binding.h(grep: no references intutti/include/tutti/**)Identity constants (type id, API version, DataPath key) in one place
DataPath implements all SPI virtuals
Resolver parses URI and produces
ResolvedTargetviamake_resolved_targetCMakeLists.txt defines INTERFACE library + test under BUILD_TESTING
Exactly one
add_subdirectoryline added totutti/CMakeLists.txtNo core files modified (
git diffshows only new files + one line)Contract tests pass
Existing tests still pass (no regression)