Execution Semantics¶
This page describes how device="rbln" behaves, where it differs from device="cuda", and why. It covers device selection, compilation, memory, CPU fallback, and synchronization for code that already runs on PyTorch, and where the behavior differs from CUDA the tables show both side by side. It assumes Installation is done. Individual APIs and per-operator coverage are in APIs and Supported Operations; the tutorial shows the same rules on a running example.
In summary:
- Eager is compiled. Each operator is compiled ahead of execution for its shape and dtype, then cached and replayed. The first call pays compilation; later calls do not.
- Memory is lazy. A device tensor reserves a virtual address on creation; physical device memory is allocated on first use by a device operation.
- Fallback is the safety net. Anything the device cannot run is executed on CPU and logged. Fallback can be turned into an error per category.
tensor.deviceidentifies the tensor's logical device. RBLN PyTorch defers NPU memory allocation and data transfers until needed, and operations that do not meet the conditions for device execution can run through CPU fallback. A tensor can therefore reportrbln:0while its latest data is in host memory or some operations run on the CPU.
Execution model¶
Code moves to RBLN in two decisions. The first is which device a tensor uses: an index such as rbln:0 names a logical device, the unit PyTorch selects, and its relation to physical NPUs is described in Logical devices and RSD groups. The second is how operators execute: eager, where each call is handled on its own, or graph, where the operators of a function or module are compiled together with torch.compile. Eager device compute also compiles internally, so the first call of an operator pays a preparation cost; when measuring repeated execution, separate the first call from later ones.
Moving a tensor with .to("rbln") places it on an RBLN device. Where the data is actually stored and where an operator actually executes is decided by the runtime's memory management and by operator support. Devices, tensors, and memory covers allocation and CPU fallback covers execution on the CPU.
Rebel100™ and ATOM™¶
RBLN PyTorch runs on both NPU families: Rebel100™ (RBLN-CR cards, for example RBLN-CR13) and ATOM™ (RBLN-CA cards). Everything on this page applies to both unless a section says otherwise. The differences that reach PyTorch code are these:
| Aspect | ATOM™ | Rebel100™ |
|---|---|---|
| Card | One chip | One chip with four chiplets. The allocator keeps one pool per chiplet, and a device runs out of memory on its heaviest chiplet |
| Memory statistics | Per device | Per device, plus a per-chiplet breakdown from memory_summary() and memory_stats_per_chiplet() |
torch.profiler | Host activity only | With RBLN_PROFILER=1, device events reach the trace through the Kineto bridge (tutorial) |
| RSD group | Formed across cards with the device-mapping variables; see Tensor-parallel execution | Fixed inside the card: the four chiplets form an RSD-4 group that the compiler uses for every program. How the program is partitioned is described in the RBLN Compiler documentation; the device-mapping variables are not used to combine cards |
| Several cards | An RSD group | rbln-ccl: one process and one logical device per card (tutorial) |
Chiplets and memory statistics¶
How the compiler splits a program across the four chiplets is described in the RBLN Compiler documentation; RBLN PyTorch exposes the chiplets only through the memory statistics. On Rebel100™ the caching allocator tracks each chiplet separately. memory_allocated(), memory_reserved(), and memory_stats() sum the chiplets, so an imbalance is invisible in them; memory_summary() prints one row per NPU and chiplet, and memory_stats_per_chiplet() returns the same keys as memory_stats() prefixed with npu.<n>.chiplet.<c>.. Call print(torch.rbln.memory_summary()) in the same Python process that ran the operation to inspect per-chiplet memory usage. See API and supported operations › Memory for the related APIs. The following output was captured after one float16 matrix multiply on a single card:
torch_rbln memory summary (device=rbln:0, MiB)
scope: pid 461977 -- caching allocator only, this process only
npu: physical NPU id as in device_summary() (relative to RBLN_VISIBLE_DEVICES, may differ from rbln-smi)
npu chiplet allocated alloc peak reserved resv peak active cached
--------------------------------------------------------------------------------------------
0 0 1.2 1.7 2.0 2.0 1.2 0.8
0 1 0.0 0.0 2.0 2.0 0.0 2.0
0 2 0.0 0.0 2.0 2.0 0.0 2.0
0 3 0.0 0.0 2.0 2.0 0.0 2.0
--------------------------------------------------------------------------------------------
total 1.3 1.8 8.0 8.0 1.3 6.7
alloc retries: 0 ooms: 0
Peaks are tracked per chiplet, so the peak columns of the total row bound the joint peak from above; memory_stats() carries the exact joint peak. An out-of-memory error on a device whose aggregate statistics look comfortable is the signature of one full chiplet, and this table is where to look.
Alignment rules measured on Rebel100™¶
Two eager-dispatch rules decide whether a compute operator reaches the device at all. They are implemented in the dispatch layer rather than in hardware, so they apply on ATOM™ as well; the measurements in this documentation were taken on Rebel100™.
- An eager compute operator whose tensor argument has a last dimension that is not a multiple of 64 runs on the CPU. The fallback log names the operator, and
explain()shows it as a repeatingdispatch/recompile. A 3-elementadd, aLinear(128, 8)output, and the 32-wide half-head slices of a rotary embedding all trip it; padding the last dimension or compiling the block removes it. - The fused attention kernel takes query sequence lengths that are multiples of 32. Other lengths fall back with
sdpa_overrideable (query sequence length N not aligned to 32) op ran on CPU, which in eager generation means every single-token decode step.
Both rules are per operator and disappear inside a torch.compile graph, where the compiler pads and lowers on its own; see Pre-dispatch checks for the full check order.
Devices, tensors, and memory¶
Logical devices and RSD groups¶
A physical device is an NPU. A logical device is what PyTorch selects with an index such as rbln:N. By default the two map 1:1. With Rebellions Scalable Design (RSD), a logical device groups 2, 4, 8, 16, or 32 physical NPUs, and a compiled program running on it is partitioned across the group. On ATOM™ that grouping is configured with RBLN_DEVICE_MAP or RBLN_NPUS_PER_DEVICE, and the visible pool with RBLN_VISIBLE_DEVICES; see Environment Variables › Device mapping. On Rebel100™ the RSD group is fixed inside the card: the four chiplets form an RSD-4 group, every program is partitioned across them without configuration, and a logical device is always one card. Work that spans several Rebel100™ cards uses torch.distributed with rbln-ccl (Distributed).
| Query | Reports |
|---|---|
torch.rbln.device_count() | Logical devices |
torch.rbln.physical_device_count() | Physical NPUs visible to the process |
torch.rbln.device_summary() | The mapping table between the two, printed to stdout; returns None |
On ATOM™, grouping several NPUs into one logical device is how tensor-parallel execution is configured. The group defines the range of NPUs an operator may use; how many it actually requests is decided per execution mode, described in Tensor-parallel execution. The rest of this subsection describes that ATOM™ configuration; on Rebel100™ leave the grouping variables unset.
For example, on a host with four ATOM™ NPUs the following groups them in pairs:
| Logical device | Physical NPUs in the group |
|---|---|
rbln:0 | 0, 1 |
rbln:1 | 2, 3 |
The application then selects rbln:0 or rbln:1; device_count() returns 2 and physical_device_count() returns 4.
The mapping follows these rules:
- The NPUs a process may see are limited by the runtime's
RBLN_VISIBLE_DEVICES(RBLN_DEVICESis a deprecated alias). Physical NPU numbers in the grouping variables and indevice_summary()are indices into that visible list. - Set the grouping variables before the first real device use. Queries such as
is_available()anddevice_count()only plan the mapping and claim no NPU. The mapping is committed at the first real use: an allocation,synchronize(), or a collective.set_device()is bookkeeping and does not commit. After commit, changes to the variables are ignored and the mapping stays fixed for the process lifetime.torch.cuda.device_count()behaves the same way: it caches its result only after CUDA is initialized. RBLN_DEVICE_MAPnames the NPUs of each group explicitly and takes precedence overRBLN_NPUS_PER_DEVICE.
Unlike CUDA, where a multi-GPU model is split by the user with torch.distributed, an RSD group is a single logical device: a compiled graph is partitioned across the group by the RBLN Compiler, and eager code sees one device.
Selecting a device¶
To use a specific logical device, name its index, as in torch.device("rbln:N"). "rbln" without an index means the currently selected RBLN device, and device-agnostic code that uses torch.accelerator resolves to rbln when it is the active accelerator. The torch.rbln.device() context manager changes the selection inside its block only:
x, y, and z are all placed on rbln:0. Tensor creation inside the context still names device="rbln"; the context does not change the default device of factory functions and does not move tensors that already exist. Move an existing tensor with .to(target_device), and keep the tensors of one operation on the same logical device. A copy between two RBLN logical devices is handed to the runtime and may travel through host memory.
Processes and fork()¶
Do not fork() a process that has already used a device. The child inherits the parent's device state but not a usable runtime context, and unlike CUDA, which raises when a forked child re-initializes the device, this backend has no bad-fork detection: the child neither errors nor works reliably. A worker forked before any device use in the parent may still set the device-mapping variables and then use a device on its own. Prefer the spawn start method for multiprocessing, and keep DataLoader workers off rbln tensors.
Lazy device tensors¶
Creating a tensor on rbln does not immediately consume device memory. The runtime registers a virtual storage for the tensor and commits physical memory according to the allocation mode:
TORCH_RBLN_EAGER_MALLOC | Mode | At storage registration |
|---|---|---|
unset (default) or 0 | Lazy allocation | Registers the virtual storage only. Host memory and NPU memory are allocated when they are needed. |
1 | Eager allocation | Allocates NPU memory together with the virtual storage. |
Keep the default for applications; the eager mode exists for experiments and profiling of allocation behavior. Set the variable before the process starts.
Under lazy allocation, the runtime tracks for every storage whether host memory or NPU memory holds the latest data and performs a transfer only when a consumer needs the other side:
| Operation | Memory and data movement |
|---|---|
Create an empty rbln tensor | Registers the virtual storage. Neither host memory nor NPU memory is allocated yet. |
Copy CPU data into an rbln tensor (torch.tensor(...), .to("rbln"), fill_) | Keeps the data in host memory; the transfer to the NPU is deferred until an NPU operation consumes it. |
| Run an NPU operation | Commits the device memory it needs and copies any input whose latest data is on the host to NPU memory (H2D). |
Read a result on the CPU (.cpu(), .item()) | Copies the data to host memory (D2H) when its latest copy is on the NPU. |
| CPU fallback | Uses the host copy when it is current; otherwise copies the NPU data to host memory first. |
This has four visible consequences:
- Memory statistics lag creation.
torch.rbln.memory_allocated()counts committed device memory, so it reads low right after allocation and rises after the first device operation. Size device memory by running a representative input, not by summing tensor sizes, and expect that an out-of-memory error can surface at the first device operation on a tensor rather than at thetorch.emptythat reserved it. See Memory statistics and the caching allocator. - Host-only workloads never touch the NPU. A script that creates
rblntensors and then only prints them, indexes them with Python integers, or runsfloat32arithmetic on them executes entirely on the host. The tensors reportdevice='rbln:0'throughout. A test written this way does not exercise device behavior. data_ptr()is a virtual address. It cannot be dereferenced from the CPU; read data with.cpu().-
External libraries that bypass PyTorch operators cannot access NPU physical memory that has not yet been allocated. External code that accesses NPU memory without going through PyTorch operators requires a buffer with physical memory allocated. With lazy allocation, creating a tensor may not allocate its physical memory, so call
torch.rbln.bind_device_memory(tensor)before passing the buffer to that code. This function immediately allocates physical memory as one flat region for a contiguous tensor that covers its whole storage, with storage offset zero.To match another tensor's device memory layout, use
torch.rbln.set_device_layout_like(target, ref). This configures the layout so later device-to-device copies use the fast path; it does not copy data. Both tensors must have the same device and dtype and cover their whole storage, andrefmust be device-resident. For details, see API and supported operations › Memory.
Memory statistics and the caching allocator¶
Device memory is managed by a caching allocator. Like the CUDA caching allocator, it keeps freed memory blocks for reuse. These blocks are included in memory_reserved(), and torch.rbln.empty_cache() returns them to the runtime. empty_cache() also drops the compiled-program warm cache and view-recipe caches, so it releases everything the caller no longer holds; the next eager call pays its preparation cost again. Do not empty the cache between iterations of a loop whose performance you care about.
torch.rbln API | Reports |
|---|---|
memory_allocated() | Device memory currently occupied by tensors |
memory_reserved() | Device memory held by the allocator, including cached free blocks |
max_memory_allocated(), max_memory_reserved() | Peaks of the two values |
reset_peak_memory_stats() | Restarts peak tracking from the current values |
memory_stats() | The full dictionary, with dotted keys such as allocated.current, allocated.peak, reserved.*, active.* |
All of them report device memory only. Two effects make the numbers lower than a CUDA user expects: lazy tensors do not count until a device operation materializes them, and compiled programs cached by Dynamo hold their own device buffers, which show up as reserved memory that no live tensor accounts for. The following example reads the statistics after placement and after execution, with TORCH_RBLN_EAGER_MALLOC unset:
Allocated after placement: 0
Allocated after execution: 233472
Reserved after execution: 8388608
Peak allocated during execution: 233472
The values depend on the SoC, the allocation granularity, and the cache state. Device statistics reflect the device layout, the allocation granularity, and runtime buffers, and exclude host process memory and tensor data that lives only on the host, so the sum of numel() * element_size() over your tensors and the reported device memory are different numbers. To measure tensor memory alone, materialize first and reset the Dynamo cache with torch._dynamo.reset() before reading. reset_peak_memory_stats() and reset_accumulated_memory_stats() behave as on CUDA.
Host memory: pinned and huge buffers¶
pin_memory=True is honored while RBLN is the active accelerator: pinned CPU tensors come from a page-aligned host allocator with best-effort mlock. Pinned memory is what makes non_blocking=True copies asynchronous (see Non-blocking copies).
Two helpers cover large host buffers: torch.rbln.huge_host_empty(nbytes) returns a 2 MiB-aligned, prefaulted buffer the device can DMA into without a staging copy, and torch.rbln.offload() pages the host-side regions of tensors allocated inside its block out to files. Both are described in APIs › Memory.
Precision and dtypes¶
| Concern | CUDA | RBLN |
|---|---|---|
| Device compute dtypes | Every dtype has device kernels | float16 and bfloat16 only |
float32 | Runs on the device; TF32 may be used for matmul | Runs on the host (CPU fallback) |
| Mixed inputs | Type promotion on the device | Inputs must share one device dtype; a mix of float16 and bfloat16, or any non-device dtype, falls back to CPU |
Integer and bool tensors | Device kernels | Data movement (copies, views, indexing) on the device; arithmetic on the host, with host fast paths for common integer elementwise cases |
| Quantized weights (W4A16, FP8, MXFP4) | Quantized kernels through libraries | No quantized dtype or quantized kernel in eager execution. Integer and float8 tensors can be placed on rbln, but arithmetic on them runs on the host |
| Autocast (AMP) | torch.autocast("cuda") | No autocast dtypes are registered at this release; torch.rbln.get_amp_supported_dtype() returns an empty list |
| Scaled dot-product attention | Several fused backends | A fused device kernel for 4-D float16 / bfloat16 query, key, and value; other ranks use the math path |
| Reduced-precision reductions | Controllable per matmul | Not configurable. Device 16-bit arithmetic runs in the runtime's own 16-bit compute format, which carries fewer mantissa bits than IEEE float16, and the compiler chooses accumulation precision |
Practical rule: convert the model and its inputs to float16 or bfloat16 before moving them to rbln. A float32 model placed on rbln produces correct results but executes on the host, and explain() reports every operator as a dtype-not-fp16 fallback.
Quantized models are served through RBLN vLLM, not through eager RBLN PyTorch. RBLN vLLM expresses W4A16, FP8, and MXFP4 layers as plain torch code that dequantizes the weight and calls linear; the RBLN Compiler recognizes that pattern inside the torch.compile graph and maps it to a device kernel. The same layer under eager execution is not fused: the integer-to-half cast runs on the host and only the resulting float16 matmul reaches the device. Whether the compiler matches the pattern outside RBLN vLLM is not verified at this release.
Three consequences follow for anyone checking a port:
- A
float16result onrblnis not bit-identical to the same computation on CPU. Every element carries a small rounding difference from the device compute format. - At the edges of the
float16range the difference is not a rounding difference. Values near the subnormal boundary can be flushed to zero and values near the maximum are requantized, sodivat those magnitudes, andmax/minselecting between near-equal elements, can disagree with CPU. Device operations involving infinity can differ from the IEEE 754 standard. For example,inf - infyields a finite value instead ofNaN. With the default settings this never reaches the device, because the NaN/Inf pre-dispatch check routes such inputs to the CPU; it matters onceTORCH_RBLN_DEPLOY=ONskips that check. - Compare against a
float32CPU reference with a tolerance. A same-dtype CPU result can differ even from a correct device result, so it is not the reference. See Troubleshoot › Results differ from a CPU run.
Type promotion is checked before dispatch: an operation runs on the device only when the promoted input type matches the output dtype and is supported by the device. Otherwise the operation falls back to CPU with the reason dtype-not-fp16 in an explain() report.
Eager and graph execution¶
| Mode | Unit of compilation | How you use it |
|---|---|---|
| Eager | One operator on the device compute path | Call PyTorch operators on rbln tensors |
| Graph | The operators captured from a function or module | Wrap the function or module with torch.compile(backend="rbln") |
Operator dispatch path¶
For a tensor on rbln, the PyTorch dispatcher routes an ATen operator to the PrivateUse1 key, which RBLN PyTorch owns. What happens next depends on the operator's registration:
| Registration | Operators | Execution |
|---|---|---|
| Compiled kernel | Compute operators listed in Supported operations › Compiled operators | The operator is wrapped in a one-operator module and compiled by the RBLN Compiler through torch.compile for the current shapes and dtypes. The compiled program is cached; later calls with the same input profile replay it. |
| Native kernel | Copies, views, cat, index_select, and the other operators in Native operators | A C++ kernel calls the runtime directly. No compilation. |
| Pinned CPU fallback | Random number generation and the operators in Operators pinned to CPU | Inputs are copied to the host, the CPU kernel runs, outputs are copied back. |
| No registration | Everything else | Same as pinned fallback, unless the unsupported_op fallback is disabled, in which case an error names the operator. |
The compiled-kernel path has two cache levels. The first miss for an operator and input profile builds the program (seconds). Later dispatches with a matching profile hit a warm cache in C++ that drives the runtime directly, without entering Python or Dynamo, so steady-state eager overhead per operator is small. The cache key is the operator name plus, for every input, its dtype, shape, strides, storage offset, and device index, plus the values of arguments passed as Python scalars. A different value in any of these is a new program.
Some calls never reach the C++ warm cache even though they do not recompile: every call re-enters the Python wrapper and hits its compile cache there, which costs Python dispatch on each call but no compiler work.
- An input that is a recognized view (a
permute,expand,narrow,select, orreshapeof a contiguous base). The view chain is replayed inside the compiled wrapper, and the C++ key built from the view's shape does not match the program compiled for the base shape. - At this release, operations with a Python scalar operand (e.g.,
x + 1.0) and operations first compiled with the same tensor as both operands (e.g.,x + x) cannot use the C++ warm cache either. Compiling first with two distinct tensors that have the same input profile (e.g.,x + ywith matching shapes and dtypes) lets laterx + xcalls reuse the C++ warm-cache entry.
Because the unit of compilation is one operator, an input shape that varies per call costs one program per operator per shape, not one per graph, and both cache levels live in the process that built them. RBLN PyTorch also sets torch._dynamo.config.specialize_float = True, so a Python float argument that changes between calls is a new program as well. Pad or bucket inputs to a fixed set of shapes before porting a model. For details, see Troubleshoot › Recompile limit exceeded.
Pre-dispatch checks¶
Before a compiled-kernel operator is sent to the device, a set of checks decides whether it must fall back instead. In order:
| Check | Fallback condition | Output of explain() | Notes |
|---|---|---|---|
| Tracer | A Python trace function is active (pdb, coverage, sys.settrace) | — | compile must not run under a tracer |
| Dispatch mode | A non-infrastructure TorchDispatchMode is active | — | compile would recurse |
| Dtype | An input dtype is outside float16 / bfloat16, or inputs mix device dtypes | dtype-not-fp16 | — |
| Scalars | Every input is a 0-dim tensor | all-scalar inputs | — |
| Storage offset | A contiguous input has storage_offset != 0 | — | — |
| NaN / Inf | An input contains NaN or Inf, unless TORCH_RBLN_DEPLOY=ON | nan/inf input | — |
| Reentrancy | Dispatch happens inside another RBLN compiled operator (print, repr, nested operator) | — | a warning is logged |
| Last-dimension alignment | A tensor argument's last dimension is not a multiple of 64. Tensor-list arguments (cat, stack) are exempt and run on the device | — | — |
div rounding mode | torch.div with rounding_mode="trunc" or "floor" on a device dtype | — | — |
The dtype, scalar, and NaN/Inf checks are decided in the C++ shim and carry the why: reason shown above. The other checks run in the Python wrapper after the shim has missed, so a call that trips one of them misses the warm cache on every call: in an explain() report it appears as a repeating dispatch/recompile rather than in the dispatch/cpu_fallback counter, the fallback log names the operator, and each device-resident input is downloaded for the CPU kernel. The NaN/Inf scan reads the input on the host, so it too costs a download when the input's latest data is on the device; TORCH_RBLN_DEPLOY=ON skips the scan on the assumption that inputs are valid.
Each check can be skipped individually with TORCH_RBLN_DEV_DISABLE_OP_CPU_FALLBACK for development only; see Environment Variables › Development-only options.
Graph execution and input shapes¶
torch.compile(model, backend="rbln") compiles the whole captured graph as one program instead of one program per operator. The backend is part of the RBLN Compiler and is documented there: the option reference in Python API › torch.compile API and a model walkthrough in PyTorch ResNet50 › Using the torch.compile() API. This section covers what RBLN PyTorch adds around it: device-resident inputs and outputs, the fallback on failure, and the shape rules. The rbln backend is registered on the first torch.compile call, so no import is needed beyond import torch. Use dynamic=False, as the RBLN Compiler always compiles for fixed input shapes. With dynamic=True, code that reads tensor sizes (x.shape[0], view) fails on the second shape with Dynamic shape not supported for rbln backend. Under the default fallback settings that failure is handled by the compile_error fallback and the whole graph runs on the CPU.
| Aspect | Eager on rbln | torch.compile(backend="rbln") |
|---|---|---|
| Unit of compilation | One operator | The captured graph |
| Operator coverage | The compiled and native operator tables | Decided by the RBLN Compiler; see Supported Ops › PyTorch |
| Unsupported operator | CPU fallback per operator | Compilation error, which falls back to running the original function on CPU unless the compile_error fallback is disabled |
| Device count | num_devices=1 | The NPU count of the input's logical device unless options={"num_devices": N} overrides it; see Tensor-parallel execution |
| Dynamic shapes | Compile a program per operator for each new shape | Recompile the graph for each new shape, subject to the Dynamo recompile limit |
| Input dtype and shape constraints | Checked per operator before dispatch | Handled by the compiler; an operator it cannot lower is split out as a host operator inside the artifact |
The first two calls share an input profile and reuse the program. The third changes the shape from (128, 128) to (64, 128); with no matching cache entry the graph is compiled again. Measured on a Rebel100™ card for a two-operator step: the first call took about 800 ms, the replay 0.2 ms, and the new shape about 400 ms. With TORCH_RBLN_DISABLE_FALLBACK=compile_error, a compilation failure raises an error instead of running the whole function on the CPU. Set this environment variable when developing code that uses torch.compile. In a service whose input sizes vary, fix the batch size and sequence length and pad or bucket inputs to them so that shape changes at run time do not trigger compilation.
options is passed through to the RBLN Compiler: num_devices overrides the RSD group size, cache_dir chooses where artifacts are written, mode selects compile-only builds, and npu names the target SoC. torch._dynamo.reset() clears RBLN PyTorch's compile cache and drains in-flight work before device buffers are released.
Choose torch.compile when a model is made of many small operators, when an operator outside the eager tables is on the hot path, or when the program should be partitioned across an RSD group. Keep eager for development and debugging, and for glue code around a compiled region. Keeping the whole step on the device describes how the two combine in a serving step.
Views and strided copies¶
View operators (view, as_strided, permute, narrow, expand, select, squeeze, unsqueeze) are metadata-only on RBLN as on CUDA; they share storage with their base and move no data. The difference appears when a view is consumed:
- A compiled-kernel operator that receives a recognized view of a contiguous base replaces the view with its base and replays the view chain inside the compiled wrapper, so the operation stays on the device. Comparison operators (
eq,ne,lt,le,gt,ge) are the exception: their view inputs are materialized with.contiguous()first. -
A contiguous view whose storage offset is not zero, such as
base[1:, :], trips the storage-offset check and runs on the CPU:Creating the view and executing the addition are separate events; only the second one falls back.
-
A device-to-device
copy_from a strided view runs on the device without a host round-trip.The runtime either issues a strided device copy directly or, when the view is a recognizable chain of single-step view operations from a contiguous base, replays that chain as a small compiled graph so the compiler lowers it on the device.
-
A view chain the runtime cannot classify is materialized with
.contiguous()through the host, once per process with a warning.explain()reports this as ahost_bouncesignal. - When the output tensor specified by
out=is non-contiguous, the runtime writes the result to a temporary contiguous buffer and then copies it to the output tensor. This adds one allocation and one copy. Specify a contiguous output tensor without=in frequently executed code.
CPU fallback¶
CPU fallback is the path that runs an operator or an input the device cannot handle on the CPU and returns the result as an rbln tensor. The result's device reflects placement, not where the computation ran; use explain() or the fallback log to tell (see Verifying the execution path).
What falls back¶
| Path | Condition | Behavior |
|---|---|---|
| Generic backend fallback | An operator with no RBLN registration at all | The CPU kernel runs on host copies of the inputs |
| Eager compute path | One of the pre-dispatch checks fails | The CPU kernel runs on host copies of the inputs |
| Compiled-function wrapper | A torch.compile(backend="rbln") call raises and the compile_error fallback is allowed | The original Python function runs on CPU copies of its call arguments |
Operators with their own device implementation follow the conditions in Supported operations. An operator that the CPU does not support either, or an invalid input, raises.
The compiled-function fallback converts the call arguments to CPU but not module parameters or tensors the function reads from outside its arguments. If those stay on rbln, the CPU execution raises a device-mismatch error. Under this fallback the whole graph runs on the host, so when you are checking whether a step stays on the device, disable the compile_error category to turn compilation failures into errors.
Fallback cost¶
A fallback moves every tensor argument to the host, runs the CPU kernel, and moves the outputs back to the device. For a lazily allocated tensor whose latest data still lives in the host view, the device-to-host step is free; for a materialized tensor it is a DMA, preceded by a synchronization. The >> physical d2h line of an explain() report tells the two cases apart, so read fallback counts together with the transfer volume. Fallbacks of operators without a device kernel, and of operators the Python dispatch layer hands to the CPU, are logged at INFO level with the operator name and, when traceable, the source location; fallbacks decided by the pre-dispatch checks are visible in explain() only. See Troubleshoot › CPU fallback logging.
Frequently hit fallback operators have host fast paths that bypass the generic boxed dispatcher; see Operators pinned to CPU. Operators without one are listed as optimization candidates in explain() output.
Fallback control¶
To surface an unsupported operator or a compilation failure as an error instead of running it on the CPU, list the category in TORCH_RBLN_DISABLE_FALLBACK before the process starts:
Each category blocks exactly its own path: compile_error the compiled-function wrapper's fallback, unsupported_op the generic fallback for operators with no RBLN registration, strided_copy_error the fallback taken when the device's strided copy engine fails. The pre-dispatch checks (dtype, scalars, NaN/Inf, and the others in the table above) are not categories and are not affected, and operators registered explicitly to the CPU fallback path (Operators pinned to CPU) always run on the host by design. The category table, the default behaviors, and when each category is read are in Environment Variables › Execution and fallback.
Tensor-parallel execution¶
This section describes tensor-parallel (TP) execution as configured through RSD grouping and compile options, for ATOM™. On Rebel100™ the RSD group is fixed to the four chiplets of a card and these settings do not apply; several Rebel100™ cards are combined with rbln-ccl instead. RSD grouping and the process group and world size of torch.distributed are configured separately; see Distributed.
NPU count per execution¶
How many NPUs an operator requests on ATOM™ follows these rules. Multi-NPU execution also requires that the compiler can partition the operator.
- Eager compute path: 1.
- Graph execution: the NPU count of the logical device that holds the input tensors by default.
options={"num_devices": N}intorch.compileoverrides it withN.
For example, with rbln:0 grouping four ATOM™ NPUs and the inputs on rbln:0:
| Mode | Setting | NPUs requested |
|---|---|---|
| Eager compute | default | 1 |
| Graph | default (num_devices omitted) | 4 |
| Graph | options={"num_devices": 1} | 1 |
Distributed¶
Tensor-parallel execution splits one program across the NPUs of a single logical device inside one process. Distributed execution is the other axis: several processes, each with its own logical device, exchanging tensors through collectives. RBLN PyTorch registers a torch.distributed backend named rbln-ccl for the rbln and cpu device types. It requires real hardware.
Launch it with one process per card, for example RBLN_VISIBLE_DEVICES=0,1 torchrun --nproc_per_node 2 <SCRIPT>.py. Tutorial: Collectives with rbln-ccl walks through a complete script and its output.
| Aspect | Behavior |
|---|---|
| Collectives | broadcast, scatter, allgather (including _allgather_base and coalesced), allreduce, reduce_scatter (including _reduce_scatter_base), send, recv, barrier |
| Reduce operators | SUM, AVG, MIN, MAX, PRODUCT as supported by the collective library; bitwise operators and PREMUL_SUM raise |
| Dtypes on the device | float16 collectives run on the NPU. allreduce and reduce_scatter on other dtypes are executed by a Gloo process group on host copies when Gloo is available, and raise otherwise. |
| Synchronous by default | Collectives complete before returning. TORCH_RBLN_C10D_ASYNC=1 returns work handles to wait on instead. |
| Control plane | Rank discovery uses the store passed to init_process_group. RBLN_ROOT_IP and RBLN_LOCAL_IP default to 127.0.0.1, which works on one host only; set them for multi-node runs. See Environment Variables › Distributed. |
Synchronization, streams, and events¶
RBLN PyTorch submits copies and compiled-graph executions to the runtime's current stream. Streams and events order that work and let the host wait for it.
Non-blocking copies¶
| Copy | CUDA | RBLN |
|---|---|---|
tensor.to("rbln") / .to("cpu") | Synchronous with respect to the host unless non_blocking=True | Same. The synchronous path drains in-flight asynchronous transfers before it starts. |
non_blocking=True with pinned host memory | Asynchronous; caller synchronizes | Asynchronous through the runtime's DMA queue when the conditions below hold; caller waits with torch.rbln.synchronize() or an event |
non_blocking=True with pageable host memory | Effectively synchronous | Downgraded to a synchronous copy |
| Many small copies | One launch each | _foreach_copy_ batches copies per direction into one runtime submission |
With non_blocking=True, the Python call can return while the transfer is still in progress. Tensor.to() and Tensor.copy_() take the asynchronous path only when all of the following hold:
- Source and destination have the same shape and dtype. A copy that needs a dtype conversion or a broadcast is synchronous.
- Both tensors are contiguous.
- For a host-device copy, the host buffer comes from the RBLN pinned allocator (
pin_memory=TrueorTensor.pin_memory()). - For a host-to-device copy, the destination already resides in device memory as one flat region.
A freshly created lazy tensor fails the last condition: it has no device memory yet, so the copy lands in host memory and the upload happens when an NPU operation consumes the tensor. torch.rbln.bind_device_memory() allocates the flat region up front for a tensor that covers its whole storage (contiguous, storage offset zero); a non_blocking=True copy from a pinned host tensor into that buffer starts an asynchronous transfer. At this release, once the buffer has been used as an input to an NPU operation, eager or compiled, the runtime re-lays it out for that operation's input layout and later non_blocking=True copies into it become synchronous, so asynchronous upload is observable only before the buffer's first use; on the input buffer of a repeated step it becomes one synchronous host-to-device copy that completes inside the copy call. explain() counts synchronous uploads under host->device push and does not count transfers that took the asynchronous path.
A copy that does not meet the conditions is synchronous, and the runtime drains pending asynchronous transfers before switching paths; the switch itself raises no error. A copy between two RBLN logical devices is handed to the runtime's device-to-device copy, which may travel through host memory, and explain() does not count it.
While an asynchronous transfer is in flight: keep both buffers alive until it completes, do not modify the source or reuse the destination before it completes, and wait for completion before reading the destination on the CPU.
Streams¶
torch.rbln.Stream and torch.rbln.Event mirror torch.cuda.Stream and torch.cuda.Event, and the generic torch.Stream(device="rbln") and torch.Event(device="rbln") work as well. Work submitted to one stream runs in submission order. Completion is tracked per stream, so query() on one stream can return True while another still has work. To order work across streams, record an event and have the consuming stream wait on it; within one device that wait is a device-side fence and does not block the host.
| Aspect | CUDA | RBLN |
|---|---|---|
| Stream pool | Created on demand | A fixed per-device round-robin pool of 32 streams; past the pool size, new instances reuse earlier streams, so two Python stream objects can name the same queue |
| Current stream | Per thread | Per thread. Selecting a stream in one thread does not change another thread's selection; a thread that selected none submits to the default stream |
| Priorities | Supported | Accepted for API parity and ignored; priority_range() returns (0, 0) |
| Default stream | Stream 0 | Stream id 0 on each device |
| Stream for a compiled graph | Chosen at launch | Chosen once when execution of the graph begins; changing the current stream during execution does not affect a graph already started. Order inside the graph is fixed by the compiler at compile time; streams order graph executions and copies relative to each other |
| Overlap | Kernels on different streams may overlap | Transfers and executions submitted to the runtime can overlap with host work. Separating work onto several streams does not add device-side parallelism at this release |
| Eager operators | One launch each | Each operator submits its own compiled program, so each call carries host dispatch and submission cost; separate that cost from device execution time when profiling |
| External sharing | cudaStream_t handles | torch.Stream.native_handle() and cross-process stream sharing are unsupported |
Events and waiting¶
An event marks a submission point on a stream. Work submitted to the same stream after the record is outside the event's scope.
| Event API | RBLN behavior |
|---|---|
record(stream=None) | Marks the latest submission point of the current or given stream. Event and stream must be on the same device. |
synchronize() | The host waits until work up to the recorded point completes. |
wait(stream) | On the same device, sets a device-side fence so later work on stream waits for the recorded point without blocking the host. Across devices it degrades to a host-side wait. |
query() | Returns completion without blocking; usable for polling. An event that was never recorded returns True. |
elapsed_time() | Unsupported; enable_timing, blocking, and interprocess flags are accepted and ignored. Use a host timer or the RBLN Profiler. |
ipc_handle(), from_ipc_handle() | Unsupported; they raise NotImplementedError, and interprocess=True grants no capability. |
torch.rbln.synchronize(device) waits for the logical device's pending asynchronous transfers and for the work on all of its streams; without an argument it uses the current device. Use an event to wait for a specific submission point and synchronize() to wait for the whole device.
False right after the record means an asynchronous transfer was still in flight when the copy returned. True means the work was already complete; this value alone cannot distinguish a synchronous copy from an asynchronous one that finished quickly. If the copy call's return time grows with the transfer size, the copy is taking the synchronous path. The buffer rules above apply while you poll.
Measuring time¶
Without elapsed_time(), measure a region with a host timer. Warm up first so compilation and initialization are excluded, synchronize before starting the timer, and stop it only after the work you are measuring has completed. The following measures the time until the result is available on the CPU:
The measurement includes dispatch, execution, and the download of the result. If it is longer than expected, read the host->device push and physical d2h lines of a torch.rbln.explain() report for synchronous copies and host round-trips. Time spent inside the NPU is analyzed with the RBLN Profiler.
Keeping the whole step on the device¶
The ideal shape of one step on RBLN is one upload of the inputs at the start (H2D) and one download of the results at the end (D2H), with every operator and every intermediate in between staying on the device. PyTorch code does not guarantee this by itself. Because of the rules above, an ordinary-looking line can force a host round-trip or a CPU execution, and that cost repeats on every step. This section collects those patterns in one place: what makes a step cross the host, and what to write instead.
The three states of a tensor's data¶
The runtime tracks, per storage, whether host memory or NPU memory holds the latest data. This page names the states as follows:
| State | Meaning | Entered when |
|---|---|---|
| Host-latest | The latest data is only in host memory | CPU data was copied into a tensor without pre-committed device memory; a tensor was created with device="rbln" (torch.ones(..., device="rbln")) |
| Device-latest | The latest data is only in NPU memory | An NPU operation wrote its result |
| In sync | Both sides hold the same data | An NPU operation consumed host-latest data (H2D happened); the CPU read device-latest data (D2H happened) |
Transfers happen only at state changes, so the goal restated is:
- Inputs go host-latest → in sync once, at the start of the step.
- Intermediates enter the next operator device-latest and no host code reads them.
- Only the results go device-latest → in sync, once, at the end of the step.
One line in the middle of a step that reads a device-latest tensor on the host inserts a D2H and a synchronous wait; a host-latest tensor created on every step repeats an H2D.
Code that crosses the host¶
| Pattern | What happens | Signal in explain() | Write instead |
|---|---|---|---|
.item(), float(t), bool(t), if t > 0:, print(t), .tolist(), .numpy() | Downloads device-latest data and waits for it | >> physical d2h | Express value-dependent branches as device operations (torch.where, clamp); move logging and checks to the end of the step |
torch.tensor(...), torch.ones(..., device="rbln"), .to("rbln") inside the step | The data is built on the host and becomes host-latest even with device="rbln"; the first NPU operator uploads it | host->device push | Create constants and weights once outside the step; fill a pre-allocated input buffer with copy_ so the step has one H2D |
Python scalar operand (x + 1.0, h * 0.5) | No transfer, but the call cannot use the eager warm cache and re-enters Python dispatch every time | dispatch/recompile on every call | Make the scalar a tensor constant once (torch.full_like) and reuse it |
Non-float16/bfloat16 dtype, mixed dtypes, all-scalar inputs | CPU fallback. A device-latest input is downloaded first; the result is host-latest and is uploaded by the next NPU operator | dispatch/cpu_fallback | Keep index and counter arithmetic on CPU tensors, or put it inside torch.compile, where the compiler handles int32 inputs on the device |
Dtype conversion inside the step (.to(torch.float32), .float(), copy_ into a tensor of another dtype) | The conversion runs on the host and produces both a D2H and an H2D | host_bounce/d2d_copy | Use one dtype inside the step; convert outside it |
| Eager operator on a tensor whose last dimension is not a multiple of 64 | CPU fallback in the Python wrapper. Not counted under dispatch/cpu_fallback; shows as repeated dispatch/recompile. Each device-latest input is downloaded | dispatch/recompile on every call, >> physical d2h | Pad the last dimension to a multiple of 64, or put the operator inside torch.compile |
View with non-zero storage offset (x[1:]), non-contiguous view (x.t()) | The same wrapper-level fallback, or a layout conversion through the host | Repeated dispatch/recompile, host_bounce | Feed operators offset-zero contiguous tensors; when a partial result is needed, give it its own out= buffer |
copy_ into a non-contiguous destination or across dtypes | A copy through the host | host_bounce/d2d_copy | Copy between contiguous tensors of the same dtype and shape. An offset-zero contiguous prefix copies on the device |
| Copy to another logical device | The runtime's device-to-device copy, which may travel through the host | Not counted | Keep one step's tensors on one logical device |
NaN/Inf scan of eager inputs (when TORCH_RBLN_DEPLOY is not ON) | Every eager operator that consumes a device-latest tensor downloads the whole tensor for the scan; intermediates cannot stay on the device | >> physical d2h of the input size on every operator | To build a step from eager operators, set TORCH_RBLN_DEPLOY=ON. It skips only this scan, and you take responsibility for valid inputs |
The last row applies to eager execution only; the inputs and outputs of a compiled graph are not scanned. Its cost is visible directly: for a device-latest z consumed by one eager operator, z * y performs one D2H of the full size of z with TORCH_RBLN_DEPLOY unset and none with TORCH_RBLN_DEPLOY=ON.
The eager and graph boundary¶
A compiled graph is the most reliable way to keep a step on the device. At its boundary:
- A graph input that is device-latest or in sync costs no transfer. A host-latest input is pushed on every call.
- Passing a graph's output to the next graph as input costs no transfer.
- An eager operator that consumes a graph's output follows the NaN/Inf rule above.
- Inside the graph, dtype and shape constraints are handled by the compiler. An operator it cannot lower is split out as a host operator and runs inside the compiled artifact;
RBLNCompiledModel.inspect()in the RBLN Compiler's Python API reports it underhost_op_reason. - An operator the frontend cannot convert fails compilation with
NotImplementedError: The following operators are not implemented: [...]. Under the default settings thecompile_errorfallback then runs the whole graph on the CPU. When checking whether a step stays on the device, disable that fallback so the failure surfaces as an error (see Fallback control).
Build the body of a step as one compiled function and use eager operators only for preparation and wrap-up outside it.
Recommended step structure¶
- Constants and weights move to RBLN once at program start. Do not create tensors inside the step.
- The input buffer is created once with
torch.emptyand filled withcopy_on every step. This is the step's only H2D. Withtorch.rbln.bind_device_memory()the transfer happens at the copy; without it, at the first NPU operator that consumes the buffer. Either way it is one transfer per step. Anon_blocking=Truecopy from a pinned host tensor is asynchronous only until the buffer's first use, so do not plan on overlap in a repeated step. - The step body is one function compiled with
torch.compile(backend="rbln", dynamic=False). Fix input shapes by padding or bucketing, use one dtype (float16orbfloat16), make the last dimension a multiple of 64, and pass offset-zero contiguous inputs. - No host reads inside the step. Express branches as device operations; collect logging and validation at the end.
- Results come back once at the end with
.cpu(). With several outputs, pack them into one tensor inside the graph and download once. - If the step must be eager, set
TORCH_RBLN_DEPLOY=ON, meet the conditions of item 3 for every operator, and replace scalar operands with tensor constants. - Wrap the step in
torch.rbln.explain()and confirm it is[clean]with onehost->device pushand onephysical d2h.
Example: a step that crosses the host and a step that stays on the device¶
The same computation is written both ways and the two explain() reports are compared. The first step contains several patterns from the table above.
The report of the step that crosses the host, abbreviated; timings, memory figures, and host-environment lines are omitted:
[overhead: 1 signal] RBLN EXPLAIN (region wall ... | device mem ... peak, reserved)
host->device push (runtime): 3 pushes, ...
-------------------- ----- ----- -----------------------------------------------------------
Signal Count Bytes Note
-------------------- ----- ----- -----------------------------------------------------------
host_bounce/d2d_copy 1 ... real d2h DMA (costly) | try: make contiguous, or v2v engine
-------------------- ----- ----- -----------------------------------------------------------
host_bounce/d2d_copy:
at example.py:14(step_eager) <- ...
>> physical d2h (real transfer): 3 copies, ... -- real device crossing
One step performs three H2D pushes and three real D2H transfers where the step below performs one of each; the input upload, the constant created inside the step, the dtype conversion, the value check, and the result download all contribute. The host_bounce/d2d_copy location points at the line that converts the int32 constant to float16.
The report of the step that stays on the device:
[clean] RBLN EXPLAIN (region wall ... | device mem ... peak, reserved)
rebel runtime: ... in librbln
v2h .../1 h2v .../1
host->device push (runtime): 1 pushes, ...
checked: host_bounce, v2v_slow, cpu_fallback, recompile -- none fired
note: clean = no hidden host overhead, not "fast"
Only the input upload (h2v, one push) and the result download (v2h) remain, and no tracked signal fired. The call that first attaches the input buffer to the graph produces one extra transfer while the runtime re-lays the buffer out for the graph's input layout, which is why the example excludes that call as a warm-up. [clean] states that there is no hidden host round-trip, not that the step is fast; time inside the NPU is analyzed with the RBLN Profiler.
Verifying the execution path¶
CUDA users reach for torch.profiler or nsys to find out why a region is slow. On RBLN, most surprises are not slow kernels but hidden host work: a copy that bounced through the host, an operator that fell back to CPU, a warm-cache miss. torch.rbln.explain() observes eager dispatch and host-crossing copies inside a region and attributes each event to a cause. It is not a timing profiler: a [clean] region can still be compute-bound. For wall-clock timing use torch.profiler; APIs › Explain and profiling describes when RBLN device rows appear in its trace.
The following compares a first run of a region with a repeated run. The float16 addition uses two distinct tensors so that it reuses the eager warm cache; the int32 multiplication fails the dtype check and runs on the CPU. The two paths use different operators so their call sites can be told apart.
Abbreviated reports from a process with no warm-cache entry for the addition; timings and call sites are shown as ...:
[overhead: 2 signals] RBLN EXPLAIN (region wall ... | device mem ... peak, reserved)
--------------------- ----- ----- -------------------------------------
Signal Count Bytes Note
--------------------- ----- ----- -------------------------------------
dispatch/cpu_fallback 1 -- try: graph mode, or a supported dtype
dispatch/recompile 1 -- try: stabilize shapes, or graph mode
--------------------- ----- ----- -------------------------------------
dispatch/cpu_fallback:
aten::mul.out 1 at ...
why: dtype-not-fp16 1
dispatch/recompile:
aten::add.out 1 at ...
[overhead: 1 signal] RBLN EXPLAIN (region wall ... | device mem ... peak, reserved)
--------------------- ----- ----- -------------------------------------
Signal Count Bytes Note
--------------------- ----- ----- -------------------------------------
dispatch/cpu_fallback 1 -- try: graph mode, or a supported dtype
--------------------- ----- ----- -------------------------------------
dispatch/cpu_fallback:
aten::mul.out 1 at ...
why: dtype-not-fp16 1
The first report shows the addition's dispatch/recompile and the multiplication's CPU fallback. In the repeated report the addition hits the warm cache, so dispatch/recompile disappears and only the fallback remains. The signals of the first report depend on what the process had cached before.
How to read a report:
| Observation | What to check |
|---|---|
dispatch/cpu_fallback | The operator name, the input dtypes, and the why: reason. The result tensor may still report an rbln device. |
dispatch/recompile in a repeated eager region | The call did not use the warm cache. Check the input profile and the cache conditions above. The counter is the number of warm-cache misses in eager dispatch; a miss may still reuse a Python-level compile cache, so it is not the number of compiler invocations, and it does not count the compilation or recompilation of torch.compile graphs. |
| Host staging together with real device-to-host bytes | Data really crossed to the host inside the region. |
| Host staging without real device-to-host bytes | A path that reused the host's copy of the data. Judge DMA cost by the real transfer volume. |
[clean] | No tracked overhead was recorded in the region. |
Wrap the whole step, not only the compiled forward: a compiled graph is clean by construction, and the hidden work sits in the eager code around it, so include preprocessing and postprocessing in the region. with_stack=True records the first call site seen per operator name, so an operator called from several places shows its first location on later signals as well; to separate call sites, observe each in its own region. The remaining rules for a valid region are in APIs › Explain and profiling.
The CPU fallback counter covers the instrumented eager dispatch path; a count of zero does not by itself prove that every operator ran on the NPU. The signals available depend on the loaded runtime, and the header says when runtime signals are not exposed, in which case a missing runtime/v2v_slow row proves nothing. Transfer counters cover the observed paths: a non_blocking=True copy that took the asynchronous path is not counted, while one downgraded to synchronous is counted under host->device push. The device-memory peak is the runtime's reserved memory, including cached buffers. explain_steady(fn) places two regions around a callable so that first-call compilation can be separated from recurring overhead; the tutorial walks through both, and Troubleshoot covers error investigation.