netcl.io: Checkpointing & Serialization
netcl.io: Checkpointing & Serialization
The io API is the persistent layer of netcl. It writes a model's
parameters (or an entire training state) to disk in a self-contained, framework-agnostic
file, and reads them back. The format is NumPy .npz, a ZIP container of named
.npy arrays, with
a single __netcl_meta__ entry that carries the layer-by-layer architecture as JSON. The
same module also exposes a lower-level, parameter-list checkpoint API that can save the
optimizer state, scheduler state, GradScaler state, and step counter.
Note, Submodules and import shapes.
netcl/io/__init__.pyre-exports the high-level model helperssave_model/load_model, the interoperable-container helpersexport_model,load_into_model,write_state_dict,read_state_dict, andmodel_state_dict, and the ONNX exporterexport_onnx(all of the latter are imported lazily, so pulling innetcl.ionever touches the training hot path,onnxitself is only imported the momentexport_onnxis actually called). The training-state checkpoint helpers,save_checkpoint,load_checkpoint,save_params, andload_params, live inio/checkpoint.pyand are not re-exported from the package root. Use the long-form imports for those:
python from netcl.io import save_model, load_model # native model files (.netcl) from netcl.io import export_model, load_into_model # interoperable .pt-style files from netcl.io import export_onnx # ONNX graph export from netcl.io.checkpoint import save_checkpoint, load_checkpoint # training state from netcl.io.checkpoint import save_params, load_params # raw parameter NPZ
Public API
| Symbol | Path | Purpose |
|---|---|---|
save_model(model, path) |
io/serialization.py |
Save a Sequential model to a single .npz file |
load_model(path, queue=None, pool=None) |
io/serialization.py |
Load a Sequential model from a .npz (or legacy two-file) export |
save_params(params, path, names=None) |
io/checkpoint.py |
Write an iterable of Tensors to a raw NPZ |
load_params(queue, params, path, names=None) |
io/checkpoint.py |
Read a raw NPZ back into existing Tensors |
save_checkpoint(params, path, optim_state=None, config=None, names=None) |
io/checkpoint.py |
Write params NPZ + sidecar JSON containing optimizer / config state |
load_checkpoint(queue, params, path, names=None) |
io/checkpoint.py |
Read a checkpoint back; returns the parsed optim_state / config dict |
export_model(model, path) |
io/interop.py |
Export a Sequential to an interoperable .pt-style container |
load_into_model(model, path, strict=True) |
io/interop.py |
Load an interoperable container into an existing model in place |
write_state_dict(state, path) |
io/interop.py |
Write a flat {name: array} mapping to an interoperable container |
read_state_dict(path) |
io/interop.py |
Read an interoperable container into an ordered {name: ndarray} dict |
model_state_dict(model) |
io/interop.py |
Build the flat interchange state-dict for a Sequential |
export_onnx(model, input_shape, path, ...) |
io/onnx_export.py |
Export a Sequential to a standard ONNX graph for inference elsewhere |
Model File Format (.netcl)
A .netcl file is a single NumPy .npz (a ZIP container of .npy arrays). The
arrays are keyed as follows:
- One entry per parameter, named
"{layer_index}:{state_dict_key}". For aSequentialwith twoLinearlayers, the keys look like"0:weight","0:bias","1:weight","1:bias". Buffers that are already ndarrays (e.g. anEmbedding.weight) are saved the same way. - A single
__netcl_meta__entry whose value is adtype=np.str_array wrapping a JSON document. The document has the shape{"type": "Sequential", "config": [...], "version": 2, "format": "netcl.single-file"}.
Key in .netcl (NPZ) |
Stored Type | Content / Description |
|---|---|---|
__netcl_meta__ |
np.str_ (JSON) |
Architecture metadata: {"type": "Sequential", "config": [...], "version": 2} |
0:weight |
ndarray |
Layer 0 weight tensor matching model dtype |
0:bias |
ndarray |
Layer 0 bias tensor |
1:weight |
ndarray |
Layer 1 weight tensor |
1:bias |
ndarray |
Layer 1 bias tensor |
Note, Legacy two-file format. Older code (and the German original) described a
<path>.json+<path>.npzpair.load_model()still accepts that layout as a fallback: if<path>does not exist but<path>.jsonand<path>.npzdo, it reads the two files. New exports fromsave_model()use the single-file format described above.
Saving
from netcl.io import save_model
from netcl.nn import Linear, ReLU, Sequential
from netcl.core.device import manager
q = manager.default("auto").queue
model = Sequential(Linear(q, 784, 256), ReLU(), Linear(q, 256, 10))
# ... train ...
save_model(model, "mnist_mlp.netcl")
save_model creates parent directories on demand and writes the file in a single
np.savez(...) call. The metadata JSON is re-serialized on every save, so a file written
with the current code is bit-identical regardless of the OS line ending or the platform.
Loading
from netcl.io import load_model
new_model = load_model("mnist_mlp.netcl")
load_model(path, queue=None, pool=None) does the following:
- If
queueisNone, take the default device's queue fromcore.device.manager.default. - Open the file with
np.load(path, allow_pickle=False). If the file does not exist, fall back to the legacy two-file layout (<path>.json+<path>.npz). - Verify the
__netcl_meta__key is present; parse the JSON. - Rebuild the
Sequentialfrom theconfiglist usingnn.factory.build_sequential. - For each layer, copy the matching
"{idx}:{key}"entries into the layer'sstate_dict. Missing keys are tolerated, they keep whatever the freshly-built layer was initialized with, which makes it safe to load a checkpoint saved from a slightly older model. - Always
weights.close()on exit (the NPZ file handle).
The pool= argument is currently unused on the open path but is reserved for a future
fast-path that will route the new Tensors through a
PersistentBufferPool instead of allocating fresh buffers.
Training Checkpoint Format
The training-state checkpoint is a thin layer on top of the raw NPZ parameter writer.
The output is two files: <path>.npz for the parameter values, and <path>.json for
the metadata.
from netcl.io.checkpoint import save_checkpoint, load_checkpoint
save_checkpoint(model.parameters(), # params first
"ckpt/iter_1000", # NOTE: no extension; .npz + .json are added
optim_state={"adam_state": ...},
config={"lr": 1e-3, "step": 1000},
names=["fc1.weight", "fc1.bias", "fc2.weight", "fc2.bias"])
state = load_checkpoint(queue, model.parameters(), "ckpt/iter_1000")
print(state["config"], state["optim_state"])
The JSON sidecar is a single object with two keys:
{
"optim_state": { "...": "..." },
"config": { "...": "..." }
}
Both are opaque to load_checkpoint: it just deserializes the JSON and returns the
dict. It is the caller's responsibility to know that optim_state is an
Optimizer state dict (compatible with opt.load_state_dict(...)) and that
config typically contains a step counter, a Scheduler state, a
GradScaler state, and a Python random / NumPy RNG state for exact-resume
training.
Raw Parameter NPZ
If you do not need the JSON sidecar, for example, when you only care about the
parameters and want to do the bookkeeping yourself, the save_params and
load_params functions write and read a bare <path>.npz.
from netcl.io.checkpoint import save_params, load_params
save_params(model.parameters(), "raw/iter_1000.npz",
names=["fc1.weight", "fc1.bias", "fc2.weight", "fc2.bias"])
load_params(queue, model.parameters(), "raw/iter_1000.npz",
names=["fc1.weight", "fc1.bias", "fc2.weight", "fc2.bias"])
load_params raises KeyError on a missing name and ValueError on a shape mismatch, both at the matching-name index, so a wrong-name typo is loud and immediate rather than
silent.
Interoperable Container Format (.pt-style)
In addition to the native .netcl format, netcl.io can read and write the
zip-based pickle tensor container that is the de-facto interchange format for trained
weights across mainstream array runtimes (the .pt / .pth container). This is a pure
standard-library + NumPy implementation in io/interop.py: no external array runtime
has to be installed, and the module is only imported the moment a checkpoint is actually
saved or loaded, so the training hot path is never affected.
from netcl.io import export_model, load_into_model
export_model(model, "mnist_mlp.pt") # write an interoperable container
fresh = build_same_architecture() # you construct the model
missing, unexpected = load_into_model(fresh, "mnist_mlp.pt", strict=True)
State-dict keys follow the flat "<index>.<param>" layout of a sequential container, for
example "0.weight", "0.bias", "3.running_mean", so a model exported here loads
straight into another runtime's Sequential, and a container produced elsewhere loads
back into a netcl Sequential.
Layout normalization. The interchange convention is applied automatically on the way in and out:
- A dense layer's weight is emitted transposed to
(out, in)(netcl stores it as(in, out)internally) and transposed back on load. - A normalization layer emits
weight(γ),bias(β),running_mean,running_var, and a 0-dnum_batches_trackedcounter. - Parameter-free layers (activations, pooling, flatten, dropout) contribute nothing and are skipped, exactly as in the foreign runtime.
On disk. The file is a standard (uncompressed) ZIP archive:
ZIP Archive Entry (.pt) |
Internal Payload |
|---|---|
archive/data.pkl |
Protocol-2 pickle of the ordered state_dict structure |
archive/data/0, 1, ... |
Raw little-endian binary storage blobs (one per tensor) |
archive/byteorder |
Host byte order string ("little") |
archive/version |
Checkpoint format revision integer |
Lower-level access. When you do not have a model object: e.g. you only want the raw
arrays, or you are converting a checkpoint, use read_state_dict / write_state_dict:
from netcl.io import read_state_dict, write_state_dict
import numpy as np
state = read_state_dict("foreign_model.pt") # OrderedDict[str, np.ndarray]
state["0.weight"] *= 0.5 # inspect / edit
write_state_dict(state, "scaled_model.pt")
Safety. Reading uses an allow-listed unpickler: only the handful of globals the
container format legitimately needs are resolvable, and every other global is refused.
A hand-crafted or tampered checkpoint therefore cannot execute arbitrary code on load.
Strided tensors are copied into freshly-owned contiguous memory, so a malformed stride
cannot alias or read past a storage blob. Writes are atomic (temp file + os.replace).
Tricky cases that are handled. Half / single / double / integer storages; 0-d scalars and empty tensors; non-contiguous (e.g. transposed) tensors reconstructed from their real strides; both the current and the legacy v1 tensor-rebuild ops; parameter-wrapped entries; and big-endian storage (transparently byte-swapped on load).
strict=True (the default) raises if any expected key is missing or any container key is
left unconsumed; strict=False loads what matches and returns the
(missing_keys, unexpected_keys) lists for inspection.
ONNX Export
export_onnx translates a Sequential into a standard ONNX graph, so a model
trained in netcl can run in any ONNX-compatible runtime, onnxruntime (CPU or GPU),
TensorRT, or a mobile/embedded runtime, without netcl or PyOpenCL installed on the
inference machine. It lives in io/onnx_export.py and is exposed lazily from the package
root, exactly like the interop helpers above: importing netcl.io never pulls in onnx.
from netcl.io import export_onnx
model.eval() # bake running BatchNorm stats into the exported graph
export_onnx(
model,
input_shape=(1, 3, 64, 64), # full shape including batch dim
path="model.onnx",
dynamic_batch=True, # batch dim becomes a symbolic ONNX dim
)
The exporter walks the model's raw, unfused layer list: the same list
Sequential.eval() would otherwise fuse for its own inference fast path, so the emitted
graph is a faithful 1:1 translation of the declared architecture rather than of netcl's
internal fusion decisions.
Supported layers. Linear (→ Gemm, transB=0: netcl already stores the weight as
(in, out)), Conv2d (→ Conv, weight already (out, in, kH, kW) with a symmetric pad
and square stride), BatchNorm2d (→ BatchNormalization, using the layer's running
stats, always run model.eval() first), LayerNorm (→ LayerNormalization), ReLU,
Sigmoid, Tanh, LeakyReLU, MaxPool2d, Flatten, and Dropout (→ Identity, since
dropout is the identity at inference time). Any other layer type raises
NotImplementedError naming the unsupported class, rather than silently emitting a wrong
graph.
Weights are embedded, not referenced. Every parameter is pulled to the host with
to_host() and written into the graph as an ONNX initializer, so the .onnx file is
fully self-contained, no separate weights file, no dependency on the netcl checkpoint
that produced it.
Verified against onnxruntime, on GPU. The exported graph has been cross-checked
end-to-end: running the same input through netcl's own inference and through
onnxruntime.InferenceSession(..., providers=["CUDAExecutionProvider"]) produces outputs
that match to within ~1e-6 (float32 rounding noise), including a full 26-layer
Conv/BatchNorm/LeakyReLU/MaxPool detector model, not just single ops. See
ONNX Export for the verification methodology and the measured
GPU-vs-native performance numbers.
from netcl.io import export_onnx
import onnxruntime as ort
import numpy as np
model.eval()
export_onnx(model, (1, 3, 32, 32), "model.onnx")
sess = ort.InferenceSession("model.onnx", providers=["CUDAExecutionProvider"])
y = sess.run(None, {"input": np.random.randn(1, 3, 32, 32).astype(np.float32)})[0]
The default input/output tensor names are "input" / "output" (override with
input_name= / output_name= if the consuming code expects different names), and the
default opset is 17.
Device & Dtype Behavior on Load
load_model and load_params both honor a few simple invariants:
- Default device. When
queueis not given, the new Tensors are allocated on the default device fromcore.device.manager.default. If no OpenCL device is available, aRuntimeErroris raised. - Dtype preserved. The dtype stored in the file is used as-is. A checkpoint saved in
float32is loaded asfloat32, even on a device that supportscl_khr_fp16; this avoids silent precision loss on load. - Shape checked. A loaded parameter whose shape differs from the freshly-built
layer's parameter is reported as a
ValueError(forload_params) or silently kept at its initialized value (forload_model, where the missing key is a "load nothing" case).
After load, if you want the parameters on a specific device or in a specific dtype, use
the same model.to(device) / manual Tensor.from_host(...) pattern you would use after
fresh construction.
Backwards-Compatibility Policy
netcl.io follows a deliberately conservative compatibility policy:
- The single-file
.netclformat is the only format new code will write. All new training scripts should callsave_model(model, path)and let the library decide the exact on-disk layout. - Reads remain backwards-compatible. A file written by an older
netclversion (including the legacy two-file<path>.json+<path>.npzlayout) is still readable by the current code. - The JSON
versionfield is bumped only on a breaking change (renamed state-dict keys, removed layer type, mandatory new field). Code that needs to know what version it is reading can checkmeta["version"]before proceeding. np.savezis forward-compatible by construction. New parameters added to a layer are simply absent from older files;load_modelkeeps the freshly-built layer's initialization for them. The opposite direction (an older netcl reading a newer file with an extra parameter) raises a clearKeyErrorat the load call site.
See also
- Tensor: the value type saved and loaded by every helper here.
- ONNX Export: verification methodology and GPU-vs-native
performance numbers for
export_onnx. - nn API:
Sequential,MLP, and thestate_dictprotocol. - Optimizer: the per-parameter state that
save_checkpointcarries in theoptim_stateJSON sidecar. - Scheduler: the LR scheduler state that lives in
config. - GradScaler: the AMP loss-scaler state that lives in
config. - AMP: recommended to wrap the forward pass in
autocastbefore saving a checkpoint, so the saved weights reflect the half-precision forward. - MNIST with MLP: the tutorial that uses
save_model/load_modelend-to-end. - Data-Parallel Training: the tutorial that uses
save_checkpoint/load_checkpointto resume a multi-replica run.