The bulk transfer plane
Every ordinary call in this SDK carries its payload inside a HID request, and a HID request holds 1024 bytes. The adapters also expose a vendor USB interface with a bulk endpoint pair, and a handful of commands can move their payload over that instead — the same frames, on the same pins, at the same rate, with a larger per-call ceiling. Nothing else about the transfer changes.
That is the whole of the feature, and it is worth being blunt about what it is not: it is not a bus mode, not a speed setting, and not something you turn on. Which route a call takes is negotiated once per connection and selected per call, and the only thing you can observe from Python is how many bytes one call accepts.
This page is the cross-protocol account: which operations can use the plane,
what the ceilings are, how each protocol’s AUTO behaves, and how to find out
why the plane is unavailable when it is. The protocol pages
(I2C, SPI, I3C) cover the per-call detail.
Which operations can use it
Six cells in the grid of {I2C, SPI, I3C} × {controller, target memory}. Five of them are on the plane.
Controller transfers |
Target memory |
|
|---|---|---|
I2C |
yes |
yes — bulk-only, no HID route |
SPI |
yes |
yes — bulk-only, no HID route |
I3C |
yes, Supernova only, all three transfer modes |
none, by design |
Two rows of that table need reading carefully, because both of them retract something this SDK’s own design notes used to say.
I3C is not absent from the plane. Earlier documents in this repository
state that I3C has no bulk path at all, in either role. That is half wrong and
half right, and the halves have to be stated separately. For the controller
role it is wrong in all three of I3C’s transfer modes: the device implements
one bulk command, I3C_CONTROLLER_PRIVATE_TRANSFER_BULK, and CosmicSDK wraps
it six times — SDR, HDR-DDR and legacy-I2C, write and read. One firmware
command, one capability bit, six methods. It is compiled only into the part
that has an I3C peripheral, so a Pulsar advertises no I3C command group at all.
I3C target memory has none, and that is a design choice rather than an
omission. I3cTarget serves its bus out of a private
1024-byte buffer, not out of the shared staging pool the I2C and SPI target
arenas come from, and its memory commands are HID-carried and correspondingly
absent from the I3C command mask — while the I2C and SPI target-memory commands
were added to theirs. So write_memory and
read_memory take no transport argument and
never will, and there is deliberately no I3C target member of
BulkOperation — a member for it would be a value that
can never be True.
The five reachable cells are the nine members of
BulkOperation, which is the vocabulary
BulkTransportCapability.supports takes:
|
What it carries |
|---|---|
|
|
|
|
|
|
|
|
|
all six |
Support is advertised per command, ceilings per command group, and the two
are different questions. A device can advertise a usable ceiling for a group
and not advertise one of the commands in it — the SPI group alone holds four
bulk commands across two bus roles — so
BulkTransportCapability answers them separately rather
than deriving one from the other. Collapsing them was a real defect: a device
offering the SPI target-memory pair but no SPI controller read used to read as
a device with no bulk plane at all.
The ceilings
Per model and per protocol, and one of them is not the number you would guess.
Supernova |
Pulsar |
|
|---|---|---|
Ordinary HID route, every protocol |
1024 |
1024 |
I2C command group |
32768 |
16384 |
SPI command group |
32768 |
16384 |
I3C command group |
32767 |
no I3C hardware, no group |
The I2C and SPI figures are each that model’s whole bulk staging arena, and both are reached in a single call on hardware rather than inferred. A Pulsar’s plane is not new or conditional either — it is present on the shipped image, at 16384 for both protocols and both bus roles. Do not carry an older number: the Pulsar figure moved 2048 → 8192 → 16384 → 11264 → 16384 over the course of the firmware work, and 16384 is current. Any prose saying “Pulsar 2048” is four ceiling changes stale.
Important
For I3C the maximum is 32767, not the 32768-byte arena, and the one-byte gap
is real rather than a rounding slip. Every other ceiling on this page is a
buffer size. This one is a hardware frame limit: the I3C peripheral’s
transaction-length field is 15 bits, so 0x7FFF is the longest frame it can
describe. The ceiling therefore does not track the staging arena and will not
move if the arena does.
The practical consequence, stated plainly because this is the one place where
the obvious expectation is wrong: I2C and SPI reach their model’s whole arena;
I3C stops one byte short of it. A caller who reads “32 KB on a Supernova”,
asks an I3C transfer for 32768 bytes and gets a
ValidationError has not hit a bug — the SDK is refusing
a frame the peripheral cannot describe, and the message says which number it
used and why. Ask for 32767; better, ask
max_payload_for and use what it
returns, which is the habit that survives a firmware change.
Firmware agrees with that number and enforces it too, so the host-side clamp is a belt-and-braces agreement rather than the only line of defence: current firmware advertises 32767 for the I3C group and refuses an over-size frame itself. What the local clamp buys is a refusal with no round trip and a message naming the ceiling.
Why the limit is worth refusing rather than attempting is a bench result rather than a datasheet reading, and it explains the belt and braces: a 32768-byte frame does not error. It reports success with the last byte never put on the bus, and leaves the I3C engine non-idle so the next transfer fails too — measured as a silently dropped write followed by a good, smaller write that also failed until the controller was re-initialised. That is the failure mode both ends are avoiding.
Two further deductions that no advertised ceiling can express, both of which will otherwise surprise you:
An I3C SDR write shares its frame with the register sub-address, so the usable payload is the ceiling minus
len(subaddress). A read has no such deduction.HDR-DDR is capped at 1024 bytes on both routes, and the advertised I3C group ceiling does not apply to it. See below.
Read the number that applies to your connection rather than assuming a model:
I2cController.max_transfer_bytes and
SpiTarget.max_transfer_bytes
and their siblings answer it in one call, and
BulkTransportCapability.max_payload_for answers it per protocol.
from pycosmicsdk import BulkOperation, Device, ProtocolInterface
with Device.open() as dev:
bulk = dev.capabilities.bulk
print(f"host holds the vendor interface: {bulk.host_transport_open}")
print(f"device advertises a bulk command set: {bulk.device_supported}")
for protocol in (ProtocolInterface.I2C, ProtocolInterface.SPI, ProtocolInterface.I3C):
print(f"{protocol.value}: {bulk.max_payload_for(protocol)} bytes")
print(f"large SPI writes: {bulk.supports(BulkOperation.SPI_CONTROLLER_WRITE)}")
Nothing is ever split
A payload above the active transport’s ceiling raises
ValidationError naming that transport and that ceiling.
The SDK never silently chunks a request across two calls, and neither does
firmware.
That is not conservatism for its own sake. Chip select de-asserts between two SPI calls, so a split write would cut a flash page program in half; a split I2C read would insert a STOP where the caller asked for none. A refusal that names both numbers is the only honest answer.
Which route a call takes
Three enums, one per protocol, each passed as transport=:
SpiTransport, I3cTransport and
I2cTransport. All three have the same three members and
the same meanings for BULK and HID:
BULKRequire the plane. Raises
CapabilityErrorbefore any request is sent when it is unavailable, and the message names which of the four independent gaps applies — this build’s bindings, this host’s driver, this device’s firmware, or a model with no such hardware. For benchmarks that must not silently measure the wrong transport.HIDNever use the plane. Short-circuits the negotiation entirely, so it sends nothing extra, and keeps the 1024-byte ceiling whatever the device offers. The way a test forces the fallback.
AUTO is where they differ, and the difference between I2C and SPI is
deliberate rather than an inconsistency to be tidied up.
SpiTransport.AUTO and
I3cTransport.AUTO
: Prefer the plane wherever this host and device can reach it, at any size.
I2cTransport.AUTOTakes the ordinary HID route for every request that fits its 1024-byte ceiling and reaches for the plane only above it.
Two reasons for that, and both are specific to I2C. There is nothing to win
below the ceiling — upstream says so itself: I2C is bus-limited long before
the transport is, so what bulk removes is the per-1024-byte command round trip,
not the bus time, and a 4-byte register write is bus-bound either way. And
there is something to lose: write and
read are shipped methods with existing
callers, and the bulk route acquires the device’s shared staging pool — the
same pool an armed I2C or SPI target draws its arena from — so routing every
small write through it would expose those callers to a contention failure they
cannot have today, for no throughput gain.
So I2cTransport.AUTO is the transport that never changes the wire for a call
that already worked, and lifts the ceiling for one that could not.
Note
There is consequently no transport property on
I2cController, where SPI and I3C both have one.
SpiController.transport and
I3cController.transport can
answer “which route will my next transfer take” as a pure function of the
connection, because their AUTO does not consult the payload length. I2C’s
does, so the same property on I2cController would have to
be a function of a request that has not been made yet. The useful question is
answered instead by
I2cController.max_transfer_bytes, which reports how far AUTO
reaches on this connection: 1024 unless both I2C controller bulk commands are
reachable, and the advertised group ceiling when they are.
Each interface logs its selection at INFO exactly once per connection, on
that protocol’s logger (pycosmicsdk.i2c, pycosmicsdk.spi,
pycosmicsdk.i3c), either way round. That is deliberately symmetric and
deliberately not a warning: the failure it exists to prevent is somebody
publishing a throughput number without recording which transport produced it,
and a warning that fires for the working majority teaches people to filter the
logger. examples/blocking_api/spi/transport_selection.py is the one example
that configures logging, for exactly this reason.
Support is uniform; benefit is not
The capability bit says the route exists. It says nothing about how much any particular operation gains, and the spread is large enough that assuming a uniform speedup will mislead you:
Operation |
Payload measured |
Measured speed-up |
What the plane buys |
|---|---|---|---|
SPI controller write |
32768 B |
11.9x |
The largest gain on the bench. 32768 bytes in 94.9 ms, 337 KiB/s, against 28.4 KiB/s over HID — where HID needs 32 calls and bulk needs one. |
SPI controller write / read |
1024 B |
7.3x |
The last payload HID can still do in one call, so this is the transport cost alone with no chunking in it. Payloads above 1024 bytes become possible at all, and the transport is the binding constraint, so removing it wins most. |
I3C SDR read |
1024 B |
3.45x |
The ordinary route carries its bytes inside the request and cannot exceed 1024; the bulk route reaches the advertised group ceiling. |
I2C controller read |
32768 B |
1.62x |
Removes the per-1024-byte command round trip, not the bus time. |
I3C legacy-I2C |
— |
not measured |
Less again — an I2C target on an I3C bus is bounded by its own open-drain bus rate long before the transport matters. |
I3C HDR-DDR |
— |
none possible |
Nothing today. See below. |
I2C / SPI target memory |
— |
not a ratio |
There is no HID route to the arena at all, so there is nothing to compare against. |
Those figures are one bench, one host, on a Supernova, and the payload column is load-bearing: two of them are 1024-byte cells and two are full-arena cells, and the gain grows with payload size because HID’s rate does not improve with it — above 1024 bytes HID simply adds calls at the same cost each. Treat the table as the shape of the spread rather than as a specification: your USB stack, your payload size and your bus rate all move every row.
Note
Only the I2C row is re-measured on every hardware run.
test_h9_bulk_is_not_slower_than_hid_at_the_hid_ceiling in
tests/hardware/test_tier_h_bulk_plane.py times both routes and prints both
figures, but asserts only the direction (bulk_seconds < hid_seconds) — a
pinned throughput number on this transport would flake without catching
anything a correctness test misses. The 11.9x, 7.3x and 3.45x figures were
recorded once, in a committed docstring, from the 2026-09-04 bench session;
nothing measures them again, so a regression in any of the three would be
silent. They are measurements, not specifications, and not continuously
verified ones.
The I2C figure is not a disappointment, and it is worth knowing why before
you go looking for the missing performance. At 400 kHz each byte on the wire
costs 9 bit times — eight data bits plus the acknowledge — so the bus’s own
ceiling is 400000 / 9 = 43.4 KiB/s, and no host transport can exceed it.
Bulk measured 37.7 KiB/s, which is 87% of that ceiling; HID measured
23.2 KiB/s, or 53%. So the bulk plane recovered roughly two thirds of the gap
between HID and the physical limit and then ran out of room. The bus was
already the constraint, so there was very little left for a transport change to
win — which is exactly the reasoning behind I2cTransport.AUTO keeping the
ordinary route below the HID ceiling.
Corrected 2026-09-04: this section previously described all three of its
figures as taken “at a 1024-byte payload”, and reasoned from that to the 87%.
The I2C row was measured at 32768 bytes, not 1024, and 87% is the ratio of the
bulk throughput to the bus ceiling rather than anything to do with the 1.62x
speed-up. The reasoning was sound and the numbers were right; the payload
size it rested on was not. The 11.9x row was added at the same time — it is the
largest gain on the bench and the page had called the 7.3x row “the real case”
without mentioning it. Provenance for every figure:
docs_dev/discussion/performance_baseline.md §1.4 and §1.5.
Caution
On I3C, a length that comes back is not a result that came back. A
32767-byte SDR read completes with no error and returns every byte — but past
the end of the target’s actual register map those bytes are filler, and on one
measured part the whole 32 KB held just 17 distinct byte values. So a large I3C
read proves the frame completed and nothing more. If you are validating a large
transfer, compare against a chunked equivalent at incremented addresses, the way
examples/blocking_api/spi/bulk_flash_read.py does — never against length
alone.
Warning
HDR-DDR gains nothing from the plane, and asking for the advertised I3C group ceiling in that mode would be actively dangerous. DDR is capped at 1024 bytes in firmware and in this SDK, and the cap is bus safety rather than buffer size: DDR is measured reliable to 1024 bytes, and above roughly 2048 bytes the target stops answering its dynamic address and stays that way until re-enumeration. So asking for more does not cost you a refused transfer — it costs you your bus. Firmware refuses at the same threshold; the SDK checks first, so no bytes are staged.
The ordinary route already reaches 1024, which is the same number, so the two routes have an identical ceiling for this mode. The bulk entry points exist so the route covers every mode and so that lifting the cap later is not a new API.
Whatever you measure, record which transport produced it. That is what the
one INFO line per connection is for, and a throughput number published without
it is a number about nothing.
bulk is the whole plane, not the SPI part of it
DeviceCapabilities.bulk is the
single latch for all three command groups: it carries operations,
ceilings and max_payload_for(...) for I2C and I3C exactly as it does for
SPI, and the I2C controller, the I3C controller and both target arenas all read
the same object, so they cannot disagree about what the device said.
The field was called spi_bulk while SPI was the only command group the
negotiation read, which made the name actively misleading once I2C and I3C
joined the plane. That rename has happened: bulk is the field, and
DeviceCapabilities.spi_bulk is a deprecated alias property that
warns and returns the identical object. Because it is an alias rather than a
field, dataclasses.replace(capabilities, spi_bulk=...) no longer resolves —
pass bulk= instead.
Two fields on the record are still SPI-shaped, for the historical reason the latch itself no longer carries, and are kept exactly in step with the general ones:
availableis exactlysupports(SPI_CONTROLLER_WRITE) and supports(SPI_CONTROLLER_READ), andDeviceCapabilities.supports_spi_bulkis exactlyavailable. Neither says anything about I2C or I3C.max_spi_payload_bytesis exactlymax_payload_for(ProtocolInterface.SPI).
DeviceCapabilities.supports_i3c_bulk is the I3C one-liner, and it
reports the device’s answer. It can read True on a build whose extension
does not bind the six I3C bulk commands, in which case
I3cController.transport still
answers HID and the two are not in conflict — they are answers to two
different questions. capabilities.bulk and the refusal message from a
BULK request tell them apart.
The plane is blocking-only
No async class in this package exposes a payload-carrying bulk method, and that is a design decision with a specific cause rather than an unfinished surface.
Member |
Blocking |
asyncio |
|---|---|---|
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
yes |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
Six of those fourteen are the payload methods themselves, and they are the
rule; the other eight are capability properties that would only have
described a route the async side cannot take, which is a much weaker decision —
a property answering identically on both surfaces was simply not written. Do
not confuse this fourteen with upstream’s fourteen bulk payload methods, which
do all have async twins in CosmicAsyncSDK; see
Blocking and asyncio. In addition, write,
read and all six
AsyncI3cController transfer methods exist but take no
transport argument — they stay on the ordinary route, at 1024 bytes.
Note what is not on that list. Every target-mode configuration call is
mirrored, and so are release, arena_size and wait_for_event on both target
interfaces — so an async program can arm a target, learn how big its arena is,
watch its bus events and hand the role back. Only moving bytes is
blocking-only.
The reason is structural, and it has two legs.
A bulk write pushes its payload from inside the submit. Upstream’s async
bulk calls run the payload send within the submit itself, before any reply,
against a two-second per-phase budget. Every *_async submit in this package
is required to be non-blocking — that is the whole contract of the async
facade, and what lets an awaiting coroutine yield rather than pin a thread. A
submit that can stall for two seconds would stall the event loop and every
other coroutine on it.
A bulk read cannot be correlated, only isolated. The plane is a single unframed byte stream shared by every bulk operation on the connection: a reply announces a length on the HID path, the bytes then arrive on the bulk endpoint with no transaction id, no header and no delimiter, and the transport drains whatever is left once the announced count has been collected. The only thing associating a payload with the command that asked for it is that nothing else was in flight. So two overlapping bulk reads do not queue — the first one’s drain discards the head of the second one’s payload, the second times out, and because a timed-out collection does not drain, the read after that can be prefixed by bytes that were never meant for it. The bytes come back plausible and the status comes back successful, and there is nothing on the endpoint to detect it with.
One bulk transfer therefore owns the connection from submit to drain. An
async def that has to hold a connection-exclusive lock across its own
await is a blocking call with a coroutine’s syntax, and it would be
advertising concurrency the transport cannot provide. Nor does an executor
help: it would serialise the collections, and the damage is done by the
submits overlapping.
A HID-only mirror of the pair was considered and not taken:
write and
read are transport-negotiating methods,
and an async twin pinned to the 1024-byte route would mean something narrower
than its blocking namesake. Note that I2C’s and I3C’s async transfer methods
did ship in that narrowed shape, with the transport argument dropped from
the signature to say so, so this is a judgement about the SPI pair rather than a
rule the whole package follows — Blocking and
asyncio reconciles the two. The honest asyncio shape, if you
want one, is loop.run_in_executor around the blocking call.
The argument in full is docs_dev/designs/async-bulk-transfer-shape.md.
Warning
One adapter has one bulk plane. The vendor interface is claimed exclusively
by whichever handle opened it first, so a Device and an
AsyncDevice on the same adapter cannot both use it.
Choose one handle style per adapter. In particular, do not reach for a second
blocking handle to do the arena traffic for an async target on the same unit.
Everything that is not payload-carrying is mirrored normally. The async
target classes get configuration in full —
initialize,
bring_up,
configure_eeprom,
configure_flash and the rest — and
wait_for_event observes the bus. What
they do not get is memory access. Bring the target up from the async API, watch
its events there, and do arena traffic from a blocking
Device; or do the whole thing blocking.
Examples
examples/blocking_api/spi/transport_selection.pyWhat the negotiation decided on this host and this device, and what made it decide so. Needs no peripheral, no wiring, no second adapter and no bulk driver — the content of the report is the result, not the exit code. The one example that configures logging.
examples/blocking_api/spi/bulk_flash_read.pyA 16 KB SPI flash read in one call, then the same 16384 bytes as sixteen 1024-byte reads at incremented addresses, compared byte for byte. Needs a SPI NOR flash and a host holding the vendor interface. The comparison rather than a length check is the point: a length check passes on a transfer that repeated one chunk sixteen times.
examples/blocking_api/i2c/target_arena_bulk.py16384 bytes host-to-arena and back, byte-exact and at an address the device honours. Read its first paragraph: nothing in it happens on the I2C wire. It is a bulk-plane example that uses target mode only because the arena is reachable no other way.
examples/blocking_api/spi/target_flash_driven.pyWhat makes
bulk_flash_read.pyrunnable without buying a flash chip: a second adapter emulates the flash part, with content you chose.
Testing code that uses it
A FakeAdapter reports no bulk plane, which is
what most real hosts report and therefore the branch your code will actually
take. To exercise the other branch — a host that claimed the interface talking
to a device that advertises bulk — describe the pair with
BulkProfile. Its two flags are the two
independent facts this page keeps separating: transport_open is whether this
host claimed the vendor interface, and webusb_enabled is whether the
interface is in the device’s configuration descriptor at all — which is how
you reproduce the trap above, a device that answers the capability query saying
it supports bulk while its endpoints are not on the wire. See
Testing without hardware.
Reference
pycosmicsdk.BulkTransportCapability,pycosmicsdk.BulkOperationTarget mode — the two bulk-only arenas, and the role they cost