Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions src/a2a3/runtime/host_build_graph/docs/SCALAR_DATA_ACCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ external tensors; they do not interleave host code with device execution.
| ------------ | ----------------- | ----------------- |
| External tensor with no submitted producer | Reads the staged host value | Updates the staged host value |
| External control/output tensor not referenced by a task | Reads immediately | Writes immediately |
| Runtime-created output | Unsupported: no registered host view | Unsupported: no registered host view |
| Runtime-created output | Unsupported: not a staged tensor | Unsupported: not a staged tensor. Set the value with `TensorCreateInfo::set_initial_value` instead |
| Tensor owned by a submitted device task | Unsupported during graph construction | Unsupported during graph construction |
| Tensor with an invalid or stale owner task ID | Fails with `INVALID_ARGS` | Fails with `INVALID_ARGS` |

Expand Down Expand Up @@ -46,9 +46,54 @@ for that producer from the orchestration call cannot make progress. The runtime
keeps a timeout as a defensive failure backstop, but it is not a supported
synchronization mechanism.

Runtime-created output buffers also live in the graph heap and have no host-view
registration. Even if their address is nonzero, host orchestration must not
dereference or modify them.
Runtime-created output buffers live in the GM heap rather than among the staged
tensors, so `get_tensor_data` / `set_tensor_data` do not resolve them. Host
orchestration must never dereference such an address itself; the initial-value
path below is the one way it reaches those bytes.

## Initial Values on a Runtime Allocation

`TensorCreateInfo::set_initial_value(v)` fills the whole buffer the runtime
allocates for that output, before any task can observe it. It is the way to give
a runtime-created buffer a defined starting content — a fixed-size tile whose
producer writes only a prefix, for instance, so its consumer reads `v` in the
remainder rather than whatever the heap last held.

```cpp
TensorCreateInfo tile(shape, 2, DataType::INT8);
tile.set_initial_value(0);
TaskOutputTensors outs = alloc_tensors(tile);
```

The fill is a host-side write reaching device memory, so how it gets there
depends on the platform, but the semantics do not:

| Platform | How the bytes reach the heap |
| -------- | ---------------------------- |
| a2a3 onboard | The heap is mapped into the host address space (`halHostRegister(DEV_SVM_MAP_HOST)`) and the store lands on it directly — no copy |
| a5 onboard | No host-map path, so the pattern is staged in a bounded buffer and pushed with `copy_to_device` — the same mechanism `set_tensor_data` already uses there |
| simulation | A device pointer is already a host pointer |

The mapping is made by the first fill that needs it, not at bind: the heap runs
to hundreds of MB and a run that sets no initial value never touches it, so an
orchestration that uses none costs nothing here.

The write lands at the call that asked for it, never deferred: the heap is
reclaimed and re-let within one orchestration, so two fills can name the same
address and only their issue order is correct.

One constraint follows from *when* the fill happens:

- **A Graph body must not ask for one.** Recording addresses an internal node's
outputs in the private range based at `GRAPH_RECORD_VIRTUAL_BASE`, and every
submission of the resulting Definition materializes its own from a heap block
whose prior contents it never reads — so a value written during recording
reaches none of the replays. The recording is marked unsupported and
`graph_commit` latches `PTO2_ERROR_INVALID_ARGS`; the body is not re-run on the
ordinary path, because its outer shell was published before the body was
recorded (see [GRAPH_EXECUTION.md](../../../../common/host_build_graph/docs/GRAPH_EXECUTION.md)).
Allocate the buffer outside the Graph body and pass it across the boundary, or
have a task write the padding the consumer relies on.

## Ownership Validation

Expand All @@ -71,3 +116,5 @@ recorded.
as a device synchronization barrier.
- Pass values needed for graph construction as orchestration inputs or scalars.
- Keep device-produced values on the device or return them after the run.
- Give a runtime allocation its starting content with
`TensorCreateInfo::set_initial_value`, not with `set_tensor_data`.
180 changes: 165 additions & 15 deletions src/a2a3/runtime/host_build_graph/host/host_tensor_access.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,29 +25,51 @@
struct HostTensorRegion {
uint64_t dev_base;
uint64_t size;
// Null on a GM-heap region until its first fill, and after one whose mapping
// attempt failed. That region is write-only, so `fill` can stage the bytes
// elsewhere and push them; every other region is read back through this view
// and is mapped when it is registered.
unsigned char *host_view;
// The host view is a copy of the device bytes rather than the device bytes
// themselves, so a write reaches the device only once pushed back.
bool mirrored;
// Whether a mapping has been attempted for this region. A GM-heap region is
// registered without one — mapping the whole heap costs a `halHostRegister`
// over hundreds of MB (2 GiB on the dsv4 case) that a run setting no initial
// value would never use — so the attempt is deferred to the first fill and,
// either way, made once.
bool map_attempted;
HostTensorRegionKind kind;
};

// One entry per tensor staged for the run being orchestrated. A run stages a
// handful of tensors and orchestration reads are cold-path, so a linear scan
// costs less than the map that would replace it.
// Bytes of the buffer `fill` stages a viewless region's pattern in. One period
// of the pattern is at most 8 bytes, so this holds thousands of repeats and the
// same buffer serves every chunk of every fill in the run.
constexpr uint64_t kFillStagingBytes = 64 * 1024;

// One entry per tensor staged for the run being orchestrated, plus at most one
// for the GM heap. A run stages a handful of tensors and orchestration reads
// are cold-path, so a linear scan costs less than the map that would replace it.
struct HostTensorAccessor::Impl {
const HostApi *api;
std::vector<HostTensorRegion> regions;
std::vector<void *> mappings;
// Bytes covered by `mappings`, i.e. excluding regions serving a fallback view.
uint64_t mapped_bytes;
// Allocated on the first fill of a viewless region, and only then: a
// platform that maps the heap never needs it.
std::vector<unsigned char> fill_staging;
};

// The region serving the whole of [dev_addr, dev_addr + bytes), or nullptr.
// `*offset` is the span's distance from that region's base.
const HostTensorRegion *
find_region(const std::vector<HostTensorRegion> &regions, uint64_t dev_addr, uint64_t bytes, uint64_t *offset) {
for (const HostTensorRegion &region : regions) {
if (dev_addr < region.dev_base) {
// The `kind` region serving the whole of [dev_addr, dev_addr + bytes), or
// nullptr. `*offset` is the span's distance from that region's base. Non-const
// because a GM-heap region binds its host view on the first fill that needs it.
HostTensorRegion *find_region(
std::vector<HostTensorRegion> &regions, HostTensorRegionKind kind, uint64_t dev_addr, uint64_t bytes,
uint64_t *offset
) {
for (HostTensorRegion &region : regions) {
if (region.kind != kind || dev_addr < region.dev_base) {
continue;
}
uint64_t off = dev_addr - region.dev_base;
Expand All @@ -61,21 +83,39 @@ find_region(const std::vector<HostTensorRegion> &regions, uint64_t dev_addr, uin
}

HostTensorAccessor::HostTensorAccessor(const HostApi *api) :
impl_(new Impl{api, {}, {}, 0}) {}
impl_(new Impl{api, {}, {}, 0, {}}) {}

HostTensorAccessor::~HostTensorAccessor() {
close();
delete impl_;
}

bool HostTensorAccessor::add(uint64_t dev_base, uint64_t size, void *fallback_host_view) {
return add_region(dev_base, size, fallback_host_view, HostTensorRegionKind::StagedTensor);
}

bool HostTensorAccessor::add_heap(uint64_t dev_base, uint64_t size) {
return add_region(dev_base, size, nullptr, HostTensorRegionKind::GmHeap);
}

bool HostTensorAccessor::add_region(
uint64_t dev_base, uint64_t size, void *fallback_host_view, HostTensorRegionKind kind
) {
if (impl_->api == nullptr || dev_base == 0 || size == 0) {
return false;
}
// Both push_backs below run after the registration succeeds, so reserve
// first: a reallocation that threw there would leak the mapping.
impl_->regions.reserve(impl_->regions.size() + 1);
impl_->mappings.reserve(impl_->mappings.size() + 1);
// A staged tensor is read through its view, so it is mapped now and must end
// up with one. The GM heap is only ever written, by `fill`, so its mapping
// waits for a fill to need it and the common run — which sets no initial
// value — registers the region without touching the heap at all.
if (kind == HostTensorRegionKind::GmHeap) {
impl_->regions.push_back({dev_base, size, nullptr, false, false, kind});
return true;
}
void *host_view = impl_->api->register_device_memory_to_host(reinterpret_cast<void *>(dev_base), size);
if (host_view != nullptr) {
impl_->mappings.push_back(reinterpret_cast<void *>(dev_base));
Expand All @@ -86,15 +126,35 @@ bool HostTensorAccessor::add(uint64_t dev_base, uint64_t size, void *fallback_ho
if (host_view == nullptr) {
return false;
}
impl_->regions.push_back(
{dev_base, size, static_cast<unsigned char *>(host_view), reinterpret_cast<uintptr_t>(host_view) != dev_base}
);
const bool mirrored = reinterpret_cast<uintptr_t>(host_view) != dev_base;
impl_->regions.push_back({dev_base, size, static_cast<unsigned char *>(host_view), mirrored, true, kind});
return true;
}

// Map `region` if that has not been tried yet, so a heap that no fill reaches is
// never mapped. Leaves `host_view` null when the platform has no host-map path,
// which is the caller's cue to stage and push instead.
void HostTensorAccessor::ensure_region_mapped(HostTensorRegion *region) const {
if (region->map_attempted) {
return;
}
region->map_attempted = true;
impl_->mappings.reserve(impl_->mappings.size() + 1);
void *host_view =
impl_->api->register_device_memory_to_host(reinterpret_cast<void *>(region->dev_base), region->size);
if (host_view == nullptr) {
return;
}
impl_->mappings.push_back(reinterpret_cast<void *>(region->dev_base));
impl_->mapped_bytes += region->size;
region->host_view = static_cast<unsigned char *>(host_view);
region->mirrored = reinterpret_cast<uintptr_t>(host_view) != region->dev_base;
}

bool HostTensorAccessor::read(uint64_t dev_addr, void *dst, uint64_t bytes) const {
uint64_t offset = 0;
const HostTensorRegion *region = find_region(impl_->regions, dev_addr, bytes, &offset);
const HostTensorRegion *region =
find_region(impl_->regions, HostTensorRegionKind::StagedTensor, dev_addr, bytes, &offset);
if (region == nullptr) {
return false;
}
Expand All @@ -104,7 +164,8 @@ bool HostTensorAccessor::read(uint64_t dev_addr, void *dst, uint64_t bytes) cons

bool HostTensorAccessor::write(uint64_t dev_addr, const void *src, uint64_t bytes) const {
uint64_t offset = 0;
const HostTensorRegion *region = find_region(impl_->regions, dev_addr, bytes, &offset);
const HostTensorRegion *region =
find_region(impl_->regions, HostTensorRegionKind::StagedTensor, dev_addr, bytes, &offset);
if (region == nullptr) {
return false;
}
Expand All @@ -116,6 +177,75 @@ bool HostTensorAccessor::write(uint64_t dev_addr, const void *src, uint64_t byte
return impl_->api->copy_to_device(reinterpret_cast<void *>(dev_addr), dst, static_cast<size_t>(bytes)) == 0;
}

// Fill a span of a region that has no host view, by staging one buffer of the
// repeating pattern and pushing it across the span.
//
// The chunk is a whole number of elements, so every push starts on a pattern
// boundary and the staged bytes can be reused verbatim. A span that is not a
// whole number of elements ends on a partial one, which is what a buffer whose
// size is not a multiple of its element width should get.
bool HostTensorAccessor::fill_staged(uint64_t dev_addr, uint64_t bytes, uint64_t value, uint64_t elem_size) const {
const uint64_t chunk = (kFillStagingBytes / elem_size) * elem_size;
std::vector<unsigned char> &staging = impl_->fill_staging;
// The buffer is reused across fills but the pattern is not: consecutive
// fills differ in value and element width, so it is laid down per call.
staging.resize(static_cast<size_t>(chunk));
for (uint64_t written = 0; written < chunk; written += elem_size) {
memcpy(staging.data() + written, &value, static_cast<size_t>(elem_size));
}
for (uint64_t pushed = 0; pushed < bytes;) {
const uint64_t span = (bytes - pushed < chunk) ? bytes - pushed : chunk;
if (impl_->api->copy_to_device(
reinterpret_cast<void *>(dev_addr + pushed), staging.data(), static_cast<size_t>(span)
) != 0) {
return false;
}
pushed += span;
}
return true;
}

HostTensorFillStatus
HostTensorAccessor::fill(uint64_t dev_addr, uint64_t bytes, uint64_t value, uint64_t elem_size) const {
if (bytes == 0) {
return HostTensorFillStatus::Ok;
}
if (elem_size == 0 || elem_size > sizeof(value)) {
return HostTensorFillStatus::BadElementSize;
}
uint64_t offset = 0;
HostTensorRegion *region = find_region(impl_->regions, HostTensorRegionKind::GmHeap, dev_addr, bytes, &offset);
if (region == nullptr) {
return HostTensorFillStatus::NoRegion;
}
ensure_region_mapped(region);
if (region->host_view == nullptr) {
// No host-map path on this platform: stage the pattern and push it.
return fill_staged(dev_addr, bytes, value, elem_size) ? HostTensorFillStatus::Ok :
HostTensorFillStatus::PushFailed;
}
// Seed one element, then double the written prefix into the remainder: the
// span is filled in log2(bytes / elem_size) memcpys rather than one per
// element. A span that is not a whole number of elements ends on a partial
// one, which is what a buffer whose size is not a multiple of its element
// width should get.
unsigned char *dst = region->host_view + offset;
uint64_t seed = (elem_size < bytes) ? elem_size : bytes;
memcpy(dst, &value, static_cast<size_t>(seed));
uint64_t filled = seed;
while (filled < bytes) {
uint64_t copy_size = ((bytes - filled) < filled) ? (bytes - filled) : filled;
memcpy(dst + filled, dst, static_cast<size_t>(copy_size));
filled += copy_size;
}
if (!region->mirrored) {
return HostTensorFillStatus::Ok;
}
return impl_->api->copy_to_device(reinterpret_cast<void *>(dev_addr), dst, static_cast<size_t>(bytes)) == 0 ?
HostTensorFillStatus::Ok :
HostTensorFillStatus::PushFailed;
}

size_t HostTensorAccessor::mapping_count() const noexcept { return impl_->mappings.size(); }

uint64_t HostTensorAccessor::mapped_bytes() const noexcept { return impl_->mapped_bytes; }
Expand All @@ -136,3 +266,23 @@ bool host_tensor_read(HostTensorAccessor *accessor, uint64_t dev_addr, void *dst
bool host_tensor_write(HostTensorAccessor *accessor, uint64_t dev_addr, const void *src, uint64_t bytes) {
return accessor != nullptr && accessor->write(dev_addr, src, bytes);
}

HostTensorFillStatus
host_tensor_fill(HostTensorAccessor *accessor, uint64_t dev_addr, uint64_t bytes, uint64_t value, uint64_t elem_size) {
if (accessor == nullptr) return HostTensorFillStatus::NoRegion;
return accessor->fill(dev_addr, bytes, value, elem_size);
}

const char *host_tensor_fill_status_name(HostTensorFillStatus status) {
switch (status) {
case HostTensorFillStatus::Ok:
return "ok";
case HostTensorFillStatus::BadElementSize:
return "unsupported element width";
case HostTensorFillStatus::NoRegion:
return "address is not in the GM heap";
case HostTensorFillStatus::PushFailed:
return "device copy failed";
}
return "unknown";
}
33 changes: 29 additions & 4 deletions src/a2a3/runtime/host_build_graph/host/runtime_maker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -525,11 +525,28 @@ struct GraphHostStateBinding {
PTO2OrchestratorState &orchestrator;
};

// The accessor lives on run_host_orchestration's frame while `rt` lives in an
// arena that outlives the call, so both pointers to it are cleared on the way
// out rather than left dangling for the next run to find.
struct HostTensorAccessBinding {
HostTensorAccessBinding(PTO2Runtime &rt, HostTensorAccessor &accessor) :
rt(rt) {
rt.tensor_access = &accessor;
rt.orchestrator.tensor_access = &accessor;
}
~HostTensorAccessBinding() {
rt.tensor_access = nullptr;
rt.orchestrator.tensor_access = nullptr;
}

PTO2Runtime &rt;
};

int32_t run_host_orchestration(
Runtime *runtime, const HostApi *api, HostTensorAccessor &tensor_access, PTO2Runtime *rt, DeviceArena &host_arena,
const PTO2RuntimeArenaLayout &layout, void *device_sm, uint64_t sm_size, void *device_arena, void *gm_heap,
const uint64_t eff_heap_sizes[PTO2_MAX_RING_DEPTH], const uint64_t eff_task_window_sizes[PTO2_MAX_RING_DEPTH],
void *host_orch_func_ptr, const ChipTaskArgs &orch_l2
uint64_t total_heap_size, const uint64_t eff_heap_sizes[PTO2_MAX_RING_DEPTH],
const uint64_t eff_task_window_sizes[PTO2_MAX_RING_DEPTH], void *host_orch_func_ptr, const ChipTaskArgs &orch_l2
) {
dep_gen_host_graph_begin_capture();

Expand Down Expand Up @@ -582,7 +599,15 @@ int32_t run_host_orchestration(
return -1;
}
rt->active_callable_hash = reinterpret_cast<uint64_t>(entry_points->entry);
rt->tensor_access = &tensor_access;
HostTensorAccessBinding tensor_access_binding(*rt, tensor_access);
// The GM heap backs every runtime-allocated output, so an initial value the
// orchestration asks for lands here. Registering only records the span; the
// host mapping of it waits for a fill to need one, so a run that sets no
// initial value pays nothing for this.
if (!tensor_access.add_heap(reinterpret_cast<uint64_t>(gm_heap), total_heap_size)) {
LOG_ERROR("host-orch: failed to register the GM heap (%" PRIu64 " bytes)", total_heap_size);
return -1;
}
// Binds the orchestration .so's own framework_current_runtime, which its
// inline rt_submit_* read. The host library links a same-named copy from
// orchestration/common.cpp, but nothing outside the .so includes
Expand Down Expand Up @@ -1043,7 +1068,7 @@ extern "C" int bind_callable_to_runtime_impl(
orch_l2.create_from_chip_args(device_args);
int32_t total_tasks = run_host_orchestration(
runtime, api, tensor_access, rt, host_arena, layout, sm_ptr, sm_size, runtime_arena_dev, gm_heap,
eff_heap_sizes, eff_task_window_sizes, host_orch_func_ptr, orch_l2
total_heap_size, eff_heap_sizes, eff_task_window_sizes, host_orch_func_ptr, orch_l2
);
// The orchestrator is the only host-view reader; from here the device
// owns these buffers, so drop the window on both exits.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,8 +518,9 @@ static inline T get_tensor_data(const ChipTensor &tensor, uint32_t ndims, const
* This API updates the registered host view used to stage an external tensor.
* The updated value becomes part of the graph's initial device data. It is not
* a synchronization barrier for submitted readers or writers. A tensor with a
* submitted producer, or a runtime-created output with no registered host view,
* is rejected as an invalid argument.
* submitted producer, or a runtime-created output, which is not a staged tensor,
* is rejected as an invalid argument; give a runtime allocation its starting
* content with TensorCreateInfo::set_initial_value instead.
*/
template <typename T = uint64_t>
static inline void set_tensor_data(const ChipTensor &tensor, uint32_t ndims, const uint32_t indices[], T value) {
Expand Down
Loading
Loading