Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ does under the hood:
from pathlib import Path
from harp.device import create_device_module

behavior = create_device_module(Path("device.yml").read_text())
behavior = create_device_module(Path("device.yml").read_bytes())
AnalogData = behavior.AnalogData # registers are reached by name...
assert behavior.REGISTER_MAP[44] is AnalogData # ...or by address
```
Expand Down
9 changes: 3 additions & 6 deletions docs/examples/create_device_module/create_device_module.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,16 @@ convenience. It's worth understanding what that buys you and what it costs.
or keep in sync with the schema.
- **Coverage for any device.** You don't need a published package for the device;
unreleased, custom, or one-off schemas work immediately.
- **The schema stays the single source of truth.** Register, field, and enum names
come straight from the `device.yml`, verbatim.
- **The schema stays the single source of truth.** Registers, fields, and enums come
straight from the `device.yml`, under the same naming convention a generated
package uses — so code written against either lines up name for name.

**You give up:**

- **Static typing and autocomplete.** The names exist only once the module is built,
so an editor can't offer them and a type checker can't verify them. A generated
package is a real module on disk, so both work. The module also isn't in
`sys.modules`, so you bind it yourself rather than `import`-ing it.
- **Generator naming conventions.** Identifiers are kept verbatim from the yml
(`AnalogInput0`, `DIO0`) rather than the C# generator's snake_case fields and
`UPPER_SNAKE` enum members, so code written against a generated package won't line
up name-for-name.
- **Turn-key custom types.** A custom `interfaceType` must be injected yourself via
`converters=` (see below), whereas a generated package ships its own converters.

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/create_device_module/create_device_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# when you don't have a pre-generated package for it: point it at the schema and you
# get the same shape a generated package has, registers at module level beside a
# `REGISTER_MAP`.
behavior = create_device_module(Path("device.yml").read_text())
behavior = create_device_module(Path("device.yml").read_bytes())

print("WhoAmI:", behavior.WHO_AM_I) # device identity, taken from the schema
AnalogData = behavior.AnalogData # registers are reached by name...
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/read_dataset/read_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@
# from harp.device import create_device_module
# from pathlib import Path
#
# behavior = create_device_module((Path("session.harp") / "device.yml").read_text())
# behavior = create_device_module((Path("session.harp") / "device.yml").read_bytes())
# reader = DatasetReader(behavior, "session.harp")
8 changes: 4 additions & 4 deletions src/packages/harp-benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ device.yml coverage model — also imported by the acceptance tests under `tests
| `src/harp/benchmarks/register_models.py` | Reference models for every device.yml register (fixtures shared with the acceptance tests). |
| `src/harp/benchmarks/_registers.py` | Registry: each register + a representative sample value; artifact paths. |
| `src/harp/benchmarks/generate.py` | Writes `./benchmark/data/<Name>_<addr>.bin`; exposes `ensure_corpus` (cache-aware). |
| `src/harp/benchmarks/benchmark.py` | Ensures corpora exist, then times `parse_bulk`, `parse_to_dataframe`, `to_columns`; writes `./benchmark/report.md`. |
| `src/harp/benchmarks/benchmark.py` | Ensures corpora exist, then times `parse_bulk`, `parse_to_dataframe`, `payload_as_columns`; writes `./benchmark/report.md`. |

All generated artifacts (corpora + report) are written under **`./benchmark`** in the
current working directory — git-ignored and fully regenerable.
Expand Down Expand Up @@ -45,13 +45,13 @@ Equivalent module invocations: `uv run python -m harp.benchmarks.benchmark` /
- **`parse_bulk`** — the core zero-copy strided-view parse into a `Batch` payload.
This is **lazy**: it builds strided views only and runs **no** converters.
- **`parse_to_dataframe`** — the full path to a pandas `DataFrame` (`copy=False`).
- **`to_columns`** (decode only) — `parse_bulk` views built once up front, then only
`payload.to_columns()` timed. This is where each field's `converter.decode_batch`
- **`payload_as_columns`** (decode only) — `parse_bulk` views built once up front, then only
`payload.payload_as_columns()` timed. This is where each field's `converter.decode_batch`
actually runs, with no file read and no pandas construction.

`parse_bulk` and `parse_to_dataframe` are each timed in two modes:

- **pre-read** — file read once up front; only deserialization is timed (isolates library speed).
- **re-read** — file re-read from disk on every run (real-world "load a dump" path, includes disk).

The report also decomposes `parse_to_dataframe ≈ parse_bulk + to_columns + pandas overhead`.
The report also decomposes `parse_to_dataframe ≈ parse_bulk + payload_as_columns + pandas overhead`.
24 changes: 13 additions & 11 deletions src/packages/harp-benchmarks/src/harp/benchmarks/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def benchmark_register(reg: BenchmarkedRegister, path: Path, *, runs: int) -> Re
register = reg.register
raw = path.read_bytes()
file_bytes = len(raw)
payload_bytes = register.payload_class.dtype.itemsize
payload_bytes = register.payload_class.payload_dtype.itemsize
frames, stride = _dataset_info(raw, payload_bytes)

bulk_pre = _time(
Expand All @@ -99,12 +99,12 @@ def benchmark_register(reg: BenchmarkedRegister, path: Path, *, runs: int) -> Re
frames=frames,
file_bytes=file_bytes,
)
# Decode only: pre-parse the bulk views once, then time to_columns() alone —
# Decode only: pre-parse the bulk views once, then time payload_as_columns() alone —
# this is where every converter's decode_batch runs, with no file read and no
# pandas DataFrame construction. Matches parse_to_dataframe's decode options.
_, _, _, payload = register.parse_bulk(raw, parse_timestamp=True)
cols = _time(
lambda: payload.to_columns(decode_enums=True, demux_bit_masks=False),
lambda: payload.payload_as_columns(decode_enums=True, demux_bit_masks=False),
runs=runs,
frames=frames,
file_bytes=file_bytes,
Expand Down Expand Up @@ -207,12 +207,12 @@ def _table(
lambda r: (r.df_preread, r.df_reread),
)

# Decode-only table (single mode): to_columns() runs every field's
# Decode-only table (single mode): payload_as_columns() runs every field's
# converter.decode_batch, with no file read and no pandas construction.
lines.append("## `to_columns` (decode only — where converters run)\n")
lines.append("## `payload_as_columns` (decode only — where converters run)\n")
lines.append(
"Isolates the decode step: `parse_bulk` views are built once up front, then "
"only `payload.to_columns()` is timed. This is where each field's "
"only `payload.payload_as_columns()` is timed. This is where each field's "
"`converter.decode_batch` executes. Registers whose converters loop in Python "
"(`HarpVersionConverter`, `StringConverter`, `BytesToIntConverter` → object "
"dtype) dominate here; vectorized converters stay cheap.\n"
Expand All @@ -227,16 +227,18 @@ def _table(
)
lines.append("")

# Decomposition: parse_to_dataframe(pre) ≈ parse_bulk(pre) + to_columns + pandas.
# Decomposition: parse_to_dataframe(pre) ≈ parse_bulk(pre) + payload_as_columns + pandas.
lines.append("## Decomposition (pre-read means, ms)\n")
lines.append(
"`parse_to_dataframe` ≈ `parse_bulk` (build views) + `to_columns` (decode) + "
"pandas DataFrame construction. The residual column is `df − bulk − to_columns`, "
"`parse_to_dataframe` ≈ `parse_bulk` (build views) + `payload_as_columns` (decode) + "
"pandas DataFrame construction. The residual column is `df − bulk − payload_as_columns`, "
"i.e. the pandas/column-assembly overhead. Note the three terms are timed in "
"separate loops, so for converter-dominated registers (large mean, large stdev) "
"the residual is within noise and can even go slightly negative.\n"
)
lines.append("| Register | parse_bulk | to_columns | parse_to_dataframe | pandas residual |")
lines.append(
"| Register | parse_bulk | payload_as_columns | parse_to_dataframe | pandas residual |"
)
lines.append("| --- | ---: | ---: | ---: | ---: |")
for r in results:
residual = r.df_preread.mean - r.bulk_preread.mean - r.cols.mean
Expand Down Expand Up @@ -329,7 +331,7 @@ def main() -> None:
results.append(res)
print(
f"bulk={_fmt_ms(res.bulk_preread.mean):>8s}ms "
f"to_columns={_fmt_ms(res.cols.mean):>9s}ms "
f"payload_as_columns={_fmt_ms(res.cols.mean):>9s}ms "
f"df={_fmt_ms(res.df_preread.mean):>9s}ms"
)
if args.head:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def _frames(reg: BenchmarkedRegister, entries: int) -> np.ndarray:
decoded (``to_columns`` / ``parse_to_dataframe``) during the benchmark. Timestamps,
when present, are a monotonic ramp. Returns the flat uint8 wire buffer.
"""
dtype = reg.register.payload_class.dtype
dtype = reg.register.payload_class.payload_dtype
rng = np.random.default_rng(_SEED + reg.address)
records = rng.integers(0, 128, size=entries * dtype.itemsize, dtype=np.uint8).view(dtype)
timestamps = np.arange(entries, dtype=np.float64) if reg.timestamped else None
Expand Down
Loading