Skip to content

perf(ioloader): decode NPY payloads unboxed, and per leading-axis block concurrently - #29

Open
NicolasRouquette wants to merge 1 commit into
lean-dojo:mainfrom
NicolasRouquette:npy-bulk-decode
Open

NicolasRouquette wants to merge 1 commit into
lean-dojo:mainfrom
NicolasRouquette:npy-bulk-decode

Conversation

@NicolasRouquette

@NicolasRouquette NicolasRouquette commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Body

What

The .npy element loop allocates on every step: an Option per byte inside readLittleEndian,
an Except per element from readNpyElement, and a heap cell per element again to store the
Float in an Array Float. It also re-tests the dtype string once per element.

This selects the decoder once per file inside decodeNpyPayload, reads the bytes without an
intermediate Option, and decodes into a FloatArray. NpyData.values becomes a FloatArray:
the payload is bulk numeric data, so it costs one machine word per element instead of a word
plus a heap cell, and Tensor.from wraps that buffer into a tensor without copying it. Header
validation, the bounded handle reads, prefix validation, and the truncation checks are unchanged;
the in-memory and file-backed readers share the one decoder.

It also adds parseNpyLeadingAxisBlocks and readNpyLeadingAxisBlocks, which return one
FloatArray per index along the leading axis. Those blocks are physically contiguous in a
C-order payload, so they are independent and can be decoded concurrently. Callers that want the
blocks apart anyway (one channel, one band, one batch element) get the concurrency for free.
Callers that want a single flat payload keep using parseNpy / readNpy, which stay sequential:
producing one contiguous result concurrently would mean filling several arrays and then copying
them all into one, and that copy walks every element a second time for several times the cost of
the concurrency it was meant to buy.

Parallelism and resolveTaskCount make the task count explicit rather than tying it to the
file's shape. The count follows the machine and the block count follows the file, and those are
independent, so blocks are grouped across tasks rather than handed out one per task: a
(50000, 1, 28, 28) dataset does not spawn 50 000 tasks.

Measured

One 764 MB <f8 file of shape (8, 3456, 3456) (95 551 488 elements), already in memory, Xeon
w7-3465X (28 cores, 56 threads). One binary holds all three decoders; the first row is the
element loop main ships at b062b9a3, copied verbatim. Five trials per row, each in its own
process so peak RSS is per path; the rate is file bytes over the median; every trial passes a
distinct tag so the pure decode cannot be shared across trials. Peak RSS includes the 764 MB
input held in memory.

path median min / max rate peak RSS
element loop on main 13670 ms 13619 / 13710 ms 55 MB/s 2930 MiB
parseNpy, unboxed sequential 1238 ms 1233 / 1315 ms 617 MB/s 1470 MiB
blocks, .sequential 1243 ms 1218 / 1311 ms 614 MB/s 1476 MiB
blocks, .tasks 8 160 ms 159 / 171 ms 4755 MB/s 1558 MiB
blocks, .auto 159 ms 157 / 168 ms 4780 MB/s 1624 MiB

.auto resolves to 8 tasks here: the leading dimension is 8, and resolveTaskCount never
splits a block, so the file's leading axis is the ceiling on this decode's parallelism whatever
the core count.

Tests

NN/Tests/Data/IO/Npy.lean synthesises .npy files in memory, so the expected values are
known exactly, and writes the same bytes to .lake/build/tmp for the file-backed readers.
It checks:

  • every element survives the round trip bit for bit, for <f8 and <f4, scalars included.
    == on Float would be the wrong test: it identifies 0.0 with -0.0 and makes every
    NaN unequal to itself, so it would both miss sign-bit transcription errors and reject a
    correctly decoded NaN;
  • the leading-axis blocks concatenate back to exactly what parseNpy returns, for 1-D, 2-D,
    and 3-D shapes and for zero-sized axes on either side;
  • every task count returns the same bytes: a sweep over requested counts including values
    above the block count and above the core count, plus sequential and auto. This is what
    makes the task count a free tuning knob rather than part of the contract;
  • repeated concurrent decodes agree, so nothing depends on the order tasks finish;
  • resolveTaskCount respects each of its three bounds;
  • Fortran-order payloads are still reordered into C order;
  • malformed files are rejected: bad magic, truncated payload (flat and blocks), unsupported
    dtype, Fortran order and scalar shape for the block path;
  • readNpy and readNpyLeadingAxisBlocks agree bit for bit with their in-memory counterparts,
    and the file-backed block reader rejects a Fortran-order or scalar header without reading a
    payload (the fixture is a bare header, and the error names the storage order, not truncation).

The existing checks in NN/Tests/API/Data.lean are unchanged except where they compare the
payload, which now reads .values.data.

Note on Task.spawn

The block decode uses Task.spawn, which takes the work as a Unit → α closure. That is
load-bearing and there is a comment saying so: handing a pure expression to an IO-level spawn
instead lets the compiler float it out of the closure onto the spawning thread, and the fan-out
then runs sequentially at exactly the single-threaded rate while still looking parallel.

Compatibility

NpyData.values changes type from Array Float to FloatArray. In-tree consumers:
NN/API/Data/Sources.lean (now Tensor.from on the buffer) and the comparisons in
NN/Tests/API/Data.lean. NN/Examples/Data/Loaders/Npy.lean reads only dtype and shape.

Checks run

lake build NN NNTests nn_tests_suite     Build completed successfully (10947 jobs).
lake build NNCI NNExamples               Build completed successfully.
python3 scripts/checks/repo_lint.py      OK: no issues found.
lake exe nn_tests_suite                  NPY metadata and prefix IO: passed
                                         npy loader: ok
                                         == TorchLean: all curated tests passed ==

@Robertboy18

Copy link
Copy Markdown
Member

Thanks, Nicolas. We still want to merge this performance work, but the data-loader cleanup in df1612d moved the NPY implementation to NN/Data/IO/Npy.lean and changed the surrounding tensor, shape, and test APIs, so this branch now conflicts.

Could you rebase onto the current main and adapt the implementation and tests to the new layout? Please keep the unboxed FloatArray decoder, the once-per-file decoder selection, the leading-axis block API, and the bit-for-bit concurrency tests. The consumers in NN/API/Data/Sources.lean also need to retain the memory benefit rather than immediately rebuilding the entire payload as a boxed array.

After the rebase, please rerun lake build NN NNCI NNExamples NNTests NNSlowProofs TorchLeanDocs and lake test. Once those pass, this should be ready to merge.

@Robertboy18

Robertboy18 commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Hey Nicolas, can you rebase this onto the latest main?

@Robertboy18

Copy link
Copy Markdown
Member

Thanks! The NPY decoding improvements still look useful. Could you rebase this onto current main and update the tests for the current loader, preserving its input validation?

…ck concurrently

The `.npy` element loop allocated on every step: an `Option` per byte, an `Except` per element,
and a heap cell per element to box the `Float`. It also re-tested the dtype once per element.

`decodeNpyPayload` now selects a range decoder once per file and fills a `FloatArray`, so
`NpyData.values` is unboxed. `NN.API.Data.Sources` wraps that buffer into a tensor through
`Tensor.from` without a copy or a boxed intermediate. Header validation, bounded handle reads, and
the truncation checks are unchanged; the in-memory and file-backed readers share one decoder.

`parseNpyLeadingAxisBlocks` and `readNpyLeadingAxisBlocks` return one `FloatArray` per index
along the leading axis of a C-order array and decode those blocks concurrently. `Parallelism` and
`resolveTaskCount` bound the task count by the request, the block count, and the payload size.

`NN/Tests/Data/IO/Npy.lean` checks bit-for-bit round trips for both dtypes, that every task count
returns the same blocks, determinism across repeated concurrent decodes, the task-count bounds,
Fortran reordering, rejections, and that the file-backed readers agree with the in-memory ones.
@NicolasRouquette

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (b062b9a3) and rewritten against the current loader.

What changed in the port:

  • The decoder now lives inside the loader's own decodeNpyPayload, selected once per file, so
    every header check, the bounded handle reads in readNpyBytes, the prefix validation, and the
    truncation checks are exactly as they are on main. readNpy, readNpyLeadingAxisPrefix,
    and parseNpy keep their signatures; only NpyData.values changes type, to FloatArray.
  • Your August note about the consumers is addressed: NN/API/Data/Sources.lean now wraps the
    unboxed payload into the tensor through Tensor.from on the FloatArray, which is a rewrap
    of the same buffer, not a boxed rebuild. Internal.tensorFromFlat stays for the CSV paths.
  • The block API gained a file-backed reader, readNpyLeadingAxisBlocks, that validates the
    header for block loading before reading the payload and then uses the same bounded reads as
    readNpy.
  • Tests: NN/Tests/Data/IO/Npy.lean covers the in-memory and file-backed paths (bit-for-bit
    round trips for both dtypes, every task count returning the same blocks, determinism across
    repeated concurrent decodes, the task-count bounds, Fortran reordering, scalars, zero-sized
    axes, and rejections). NN/Tests/API/Data.lean only changed where it compares the payload,
    which now goes through .values.data.

Re-measured on this tree, same binary for all rows (details in the description):

path median of 5 rate peak RSS
element loop on main today 13670 ms 55 MB/s 2930 MiB
parseNpy, unboxed sequential 1238 ms 617 MB/s 1470 MiB
leading-axis blocks, 8 tasks 160 ms 4755 MB/s 1558 MiB

Checks on 181edd40:

lake build NN NNTests nn_tests_suite     Build completed successfully (10947 jobs).
lake build NNCI NNExamples               Build completed successfully.
python3 scripts/checks/repo_lint.py      OK: no issues found.
lake exe nn_tests_suite                  NPY metadata and prefix IO: passed
                                         npy loader: ok
                                         == TorchLean: all curated tests passed ==

The PR description is updated to match.

NicolasRouquette added a commit to NicolasRouquette/TorchLean that referenced this pull request Oct 3, 2026
Brings in the upstream PR branch (lean-dojo#29) at 181edd4,
rebased onto upstream main b062b9a.

combined is rebuilt from b062b9a, the first upstream tip on the LibTorch
backend (csrc/cuda is gone). Its previous tip d162950 (Lean 4.33, custom
CUDA kernels, arena, cache cap, TexTable) is kept as bkp/pre-libtorch/combined
and is not merged: nothing on it applies to the LibTorch tree.
@Robertboy18

Copy link
Copy Markdown
Member

Hey Nicolas, thanks! This looks like a useful improvement. The loader, its tests, and the updated data API compile locally. My isolated interpreter run could not load the native parseNpy implementation, so I have not confirmed the runtime suite locally yet; that is a scratch-run setup limitation, not evidence of a bug in this PR. Could you share the native runtime test command and results? It would also be good to cover signed zero, infinities, and NaNs explicitly in the decode tests. Keeping this open until the runtime check is confirmed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants