newch 0.2.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
newch/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """newch: a small machine-learning library for low-spec machines.
2
+
3
+ PyTorch-style tensors and autograd, Keras-style ``compile``/``fit``, backed
4
+ by a portable C engine. No Python dependencies (numpy is used if present).
5
+
6
+ import newch as nc
7
+
8
+ model = nc.Sequential([nc.net.Linear(2, 32), nc.net.ReLU(), nc.net.Linear(32, 3)])
9
+ model.compile(optimizer="adam", loss="cross_entropy", metrics=["accuracy"])
10
+ model.fit(x, y, epochs=20, batch_size=32)
11
+ """
12
+
13
+ from . import callbacks, data, net, optim
14
+ from .net import Model, Module, Sequential, quantize_
15
+ from .tensor import (Context, NcError, Tensor, avg_pool2d, batch_norm, conv2d, cross_entropy, default_context,
16
+ dropout, exp, float32, full, int8, int32, linear, load_tensors, log, log_softmax, manual_seed,
17
+ matmul, max_pool2d, mem_stats, mse_loss, no_grad, ones, parameter, persistent, rand, randn,
18
+ release, relu, save_tensors, set_default_context, sigmoid, softmax, sqrt, step_end, tanh, tensor,
19
+ zeros)
20
+
21
+ __version__ = "0.2.0"
22
+
23
+ __all__ = [
24
+ "Context", "Model", "Module", "NcError", "Sequential", "Tensor", "avg_pool2d", "batch_norm", "callbacks",
25
+ "conv2d", "cross_entropy", "data", "default_context", "dropout", "exp", "float32", "full", "int8", "int32",
26
+ "linear", "load_tensors", "log", "log_softmax", "manual_seed", "matmul", "max_pool2d", "mem_stats", "mse_loss",
27
+ "net", "no_grad", "ones", "optim", "parameter", "persistent", "quantize_", "rand", "randn", "release", "relu",
28
+ "save_tensors", "set_default_context", "sigmoid", "softmax", "sqrt", "step_end", "tanh", "tensor", "zeros",
29
+ ]
newch/_lib.py ADDED
@@ -0,0 +1,168 @@
1
+ """ctypes bindings to the C engine (nc.h). Internal: use the public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import os
7
+ import sys
8
+ from ctypes import POINTER, Structure, c_char_p, c_float, c_int, c_int64, c_size_t, c_uint64, c_void_p
9
+ from pathlib import Path
10
+
11
+ ALL_AXES = -128
12
+ F32, I32, I8 = 0, 1, 3
13
+ DTYPE_NAMES = {F32: "float32", I32: "int32", I8: "int8"}
14
+ DTYPE_CODES = {"float32": F32, "int32": I32, "int8": I8}
15
+ DTYPE_SIZE = {"float32": 4, "int32": 4, "int8": 1}
16
+ ARRAY_CODE = {"float32": "f", "int32": "i", "int8": "b"}
17
+
18
+
19
+ class Config(Structure):
20
+ _fields_ = [("arena_chunk_bytes", c_size_t), ("seed", c_uint64), ("force_reference", c_int), ("threads", c_int)]
21
+
22
+
23
+ class CpuInfo(Structure):
24
+ _fields_ = [
25
+ ("arch", c_char_p),
26
+ ("has_neon", c_int),
27
+ ("has_dotprod", c_int),
28
+ ("has_fp16", c_int),
29
+ ("has_avx2", c_int),
30
+ ("has_fma", c_int),
31
+ ("has_rvv", c_int),
32
+ ("n_cores", c_int),
33
+ ("n_big_cores", c_int),
34
+ ]
35
+
36
+
37
+ class MemStats(Structure):
38
+ _fields_ = [
39
+ ("persistent_bytes", c_size_t),
40
+ ("arena_bytes_in_use", c_size_t),
41
+ ("arena_bytes_reserved", c_size_t),
42
+ ("arena_peak_bytes", c_size_t),
43
+ ]
44
+
45
+
46
+ class OptimOpts(Structure):
47
+ _fields_ = [(n, c_float) for n in ("lr", "momentum", "weight_decay", "beta1", "beta2", "eps")]
48
+
49
+
50
+ def _lib_names() -> list[str]:
51
+ if sys.platform == "win32":
52
+ return ["nc.dll"]
53
+ if sys.platform == "darwin":
54
+ return ["libnc.dylib"]
55
+ return ["libnc.so"]
56
+
57
+
58
+ def _find_library() -> str:
59
+ env = os.environ.get("NEWCH_LIB")
60
+ if env:
61
+ return env
62
+ here = Path(__file__).resolve().parent
63
+ for d in (here, here.parent / "build" / "host"):
64
+ for name in _lib_names():
65
+ if (d / name).exists():
66
+ return str(d / name)
67
+ raise OSError(
68
+ "newch: native library not found. Build it with `python tools/build.py` "
69
+ "or set NEWCH_LIB to the path of nc.dll / libnc.so."
70
+ )
71
+
72
+
73
+ lib = ctypes.CDLL(_find_library())
74
+
75
+ P = c_void_p
76
+ I64P = POINTER(c_int64)
77
+
78
+ _SIGNATURES = {
79
+ # context
80
+ "nc_init": (P, [POINTER(Config)]),
81
+ "nc_free_ctx": (None, [P]),
82
+ "nc_manual_seed": (None, [P, c_uint64]),
83
+ "nc_linear_fn": (P, [P, P, P]),
84
+ "nc_step_end": (None, [P]),
85
+ "nc_arena_trim": (None, [P]),
86
+ "nc_num_threads": (c_int, [P]),
87
+ "nc_persistent_begin": (None, [P]),
88
+ "nc_persistent_end": (None, [P]),
89
+ "nc_set_grad_enabled": (c_int, [P, c_int]),
90
+ "nc_ok": (c_int, [P]),
91
+ "nc_status_code": (c_int, [P]),
92
+ "nc_error": (c_char_p, [P]),
93
+ "nc_clear_error": (None, [P]),
94
+ "nc_get_mem_stats": (MemStats, [P]),
95
+ "nc_reset_peak": (None, [P]),
96
+ "nc_cpu": (POINTER(CpuInfo), [P]),
97
+ "nc_kernel_backend": (c_char_p, [P]),
98
+ # tensors
99
+ "nc_empty": (P, [P, c_int, c_int, I64P]),
100
+ "nc_zeros": (P, [P, c_int, c_int, I64P]),
101
+ "nc_full": (P, [P, c_float, c_int, I64P]),
102
+ "nc_randn": (P, [P, c_float, c_int, I64P]),
103
+ "nc_rand_uniform": (P, [P, c_float, c_float, c_int, I64P]),
104
+ "nc_from_data": (P, [P, c_int, c_void_p, c_int, I64P]),
105
+ "nc_scalar": (P, [P, c_float]),
106
+ "nc_parameter": (P, [P, P, c_int, I64P]),
107
+ "nc_ndim": (c_int, [P]),
108
+ "nc_shape": (I64P, [P]),
109
+ "nc_numel": (c_int64, [P]),
110
+ "nc_get_dtype": (c_int, [P]),
111
+ "nc_is_contiguous": (c_int, [P]),
112
+ "nc_requires_grad": (c_int, [P]),
113
+ "nc_set_requires_grad": (None, [P, c_int]),
114
+ "nc_is_persistent": (c_int, [P]),
115
+ "nc_release": (None, [P]),
116
+ "nc_grad": (P, [P]),
117
+ "nc_item": (c_float, [P]),
118
+ "nc_data_f32": (c_void_p, [P]),
119
+ "nc_copy_to": (c_int, [P, c_void_p, c_size_t]),
120
+ "nc_copy_from": (c_int, [P, c_void_p, c_size_t]),
121
+ # ops
122
+ **{f"nc_{n}": (P, [P, P]) for n in ("add", "sub", "mul", "div", "matmul", "cross_entropy", "mse_loss",
123
+ "index_select")},
124
+ **{f"nc_{n}": (P, [P]) for n in ("neg", "exp", "log", "sqrt", "relu", "sigmoid", "tanh", "contiguous",
125
+ "log_softmax", "softmax", "detach")},
126
+ **{f"nc_{n}": (P, [P, c_float]) for n in ("scale", "add_scalar", "pow_scalar")},
127
+ **{f"nc_{n}": (P, [P, c_int, c_int]) for n in ("sum", "mean", "max", "transpose")},
128
+ "nc_argmax": (P, [P, c_int]),
129
+ "nc_conv2d": (P, [P, P, P, c_int, c_int, c_int, c_int]),
130
+ "nc_max_pool2d": (P, [P, c_int, c_int, c_int, c_int, c_int, c_int]),
131
+ "nc_avg_pool2d": (P, [P, c_int, c_int, c_int, c_int, c_int, c_int]),
132
+ "nc_dropout": (P, [P, c_float]),
133
+ "nc_batch_norm": (P, [P, P, P, P, P, c_int, c_float, c_float]),
134
+ "nc_quantize_rows": (P, [P, POINTER(c_void_p)]),
135
+ "nc_linear_int8": (P, [P, P, P, P]),
136
+ "nc_save": (c_int, [P, c_char_p, c_int, POINTER(c_char_p), POINTER(c_void_p)]),
137
+ "nc_load": (c_int, [P, c_char_p, c_int, POINTER(c_char_p), POINTER(c_void_p)]),
138
+ "nc_reshape": (P, [P, c_int, I64P]),
139
+ # autograd
140
+ "nc_backward": (c_int, [P]),
141
+ "nc_zero_grad": (None, [P]),
142
+ # optimizers
143
+ "nc_sgd": (P, [P, POINTER(c_void_p), c_int, OptimOpts]),
144
+ "nc_adam": (P, [P, POINTER(c_void_p), c_int, OptimOpts]),
145
+ "nc_optim_zero_grad": (None, [P]),
146
+ "nc_optim_step": (None, [P]),
147
+ "nc_optim_set_lr": (None, [P, c_float]),
148
+ }
149
+
150
+ for _name, (_res, _args) in _SIGNATURES.items():
151
+ _fn = getattr(lib, _name)
152
+ _fn.restype = _res
153
+ _fn.argtypes = _args
154
+
155
+
156
+ def shape_arg(shape) -> tuple[int, ctypes.Array]:
157
+ shape = tuple(int(s) for s in shape)
158
+ return len(shape), (c_int64 * max(len(shape), 1))(*shape)
159
+
160
+
161
+ def ptr_array(ptrs) -> ctypes.Array:
162
+ ptrs = list(ptrs)
163
+ return (c_void_p * max(len(ptrs), 1))(*ptrs)
164
+
165
+
166
+ def name_array(names) -> ctypes.Array:
167
+ names = [n.encode("utf-8") for n in names]
168
+ return (c_char_p * max(len(names), 1))(*names)
newch/callbacks.py ADDED
@@ -0,0 +1,91 @@
1
+ """Keras-style training callbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import math
7
+
8
+
9
+ class Callback:
10
+ model = None
11
+
12
+ def set_model(self, model) -> None:
13
+ self.model = model
14
+
15
+ def on_train_begin(self, logs: dict) -> None: ...
16
+ def on_epoch_end(self, epoch: int, logs: dict) -> None: ...
17
+ def on_train_end(self, logs: dict) -> None: ...
18
+
19
+
20
+ class History(Callback):
21
+ """Returned by fit(): ``history.history["loss"]`` is one value per epoch."""
22
+
23
+ def on_train_begin(self, logs):
24
+ self.epoch: list[int] = []
25
+ self.history: dict[str, list[float]] = {}
26
+
27
+ def on_epoch_end(self, epoch, logs):
28
+ self.epoch.append(epoch)
29
+ for k, v in logs.items():
30
+ self.history.setdefault(k, []).append(v)
31
+
32
+
33
+ class EarlyStopping(Callback):
34
+ """Stops when ``monitor`` has not improved by ``min_delta`` for ``patience`` epochs."""
35
+
36
+ def __init__(self, monitor: str = "val_loss", min_delta: float = 0.0, patience: int = 0,
37
+ mode: str = "auto", restore_best_weights: bool = False):
38
+ self.monitor, self.min_delta, self.patience = monitor, abs(min_delta), patience
39
+ if mode == "auto":
40
+ mode = "max" if "acc" in monitor else "min"
41
+ if mode not in ("min", "max"):
42
+ raise ValueError("mode must be 'auto', 'min' or 'max'")
43
+ self.sign = 1.0 if mode == "min" else -1.0
44
+ self.restore_best_weights = restore_best_weights
45
+ self.stopped_epoch = None
46
+
47
+ def on_train_begin(self, logs):
48
+ self.best = math.inf
49
+ self.wait = 0
50
+ self.best_weights = None
51
+ self.stopped_epoch = None
52
+
53
+ def on_epoch_end(self, epoch, logs):
54
+ if self.monitor not in logs:
55
+ raise KeyError(f"EarlyStopping: '{self.monitor}' not in logs (available: {sorted(logs)})")
56
+ value = self.sign * logs[self.monitor]
57
+ if value < self.best - self.min_delta:
58
+ self.best, self.wait = value, 0
59
+ if self.restore_best_weights:
60
+ self.best_weights = [p._flat() for p in self.model.parameters()]
61
+ return
62
+ self.wait += 1
63
+ if self.wait >= self.patience:
64
+ self.model.stop_training = True
65
+ self.stopped_epoch = epoch
66
+
67
+ def on_train_end(self, logs):
68
+ if self.restore_best_weights and self.best_weights is not None:
69
+ from ._lib import lib
70
+
71
+ for p, saved in zip(self.model.parameters(), self.best_weights):
72
+ ctypes.memmove(lib.nc_data_f32(p.ptr), saved.buffer_info()[0], len(saved) * 4)
73
+
74
+
75
+ class LambdaCallback(Callback):
76
+ """Keyword-only, with Keras' argument names: on_epoch_end(epoch, logs), ..."""
77
+
78
+ def __init__(self, *, on_epoch_end=None, on_train_begin=None, on_train_end=None):
79
+ self._e, self._b, self._t = on_epoch_end, on_train_begin, on_train_end
80
+
81
+ def on_train_begin(self, logs):
82
+ if self._b:
83
+ self._b(logs)
84
+
85
+ def on_epoch_end(self, epoch, logs):
86
+ if self._e:
87
+ self._e(epoch, logs)
88
+
89
+ def on_train_end(self, logs):
90
+ if self._t:
91
+ self._t(logs)
newch/data.py ADDED
@@ -0,0 +1,227 @@
1
+ """Datasets and a streaming DataLoader (torch.utils.data style).
2
+
3
+ For data larger than RAM, store samples in a raw binary file and open it
4
+ with FileArray: rows are read through mmap one batch at a time, so memory use
5
+ stays around one batch regardless of the dataset size.
6
+
7
+ FileArray.create("x.bin", rows, row_shape=(28, 28)) # write once
8
+ ds = TensorDataset(FileArray("x.bin", (60000, 28, 28)), labels)
9
+ for xb, yb in DataLoader(ds, batch_size=64, shuffle=True): ...
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import array
15
+ import mmap
16
+ import random
17
+ from pathlib import Path
18
+
19
+ from . import _lib as L
20
+ from .tensor import (Context, Tensor, _flatten, _is_numpy, default_context, float32, int8, int32, tensor,
21
+ tensor_from_bytes)
22
+
23
+
24
+ class Dataset:
25
+ """Map-style dataset: implement ``__len__`` and ``__getitem__``."""
26
+
27
+ def __len__(self) -> int:
28
+ raise NotImplementedError
29
+
30
+ def __getitem__(self, index):
31
+ raise NotImplementedError
32
+
33
+
34
+ # --------------------------------------------------------------------------
35
+ # Row sources: array-likes that can hand out a batch of rows as raw bytes
36
+ # --------------------------------------------------------------------------
37
+
38
+
39
+ class FileArray:
40
+ """A read-only array stored row-major in a raw binary file (like numpy.memmap).
41
+
42
+ shape includes the number of rows; dtype is "float32", "int32" or "int8".
43
+ """
44
+
45
+ def __init__(self, path, shape, dtype: str = float32, offset: int = 0):
46
+ if dtype not in L.DTYPE_CODES:
47
+ raise ValueError(f"unsupported dtype {dtype!r}")
48
+ self.path, self.shape, self.dtype, self.offset = Path(path), tuple(int(s) for s in shape), dtype, offset
49
+ self.row_shape = self.shape[1:]
50
+ n = 1
51
+ for d in self.row_shape:
52
+ n *= d
53
+ self.row_bytes = n * L.DTYPE_SIZE[dtype]
54
+ need = offset + self.shape[0] * self.row_bytes
55
+ size = self.path.stat().st_size
56
+ if size < need:
57
+ raise ValueError(f"{path}: {size} bytes, but shape {self.shape} {dtype} needs {need}")
58
+ self._f = open(self.path, "rb")
59
+ self._mm = mmap.mmap(self._f.fileno(), 0, access=mmap.ACCESS_READ) if need else None
60
+
61
+ @classmethod
62
+ def create(cls, path, rows, row_shape, dtype: str = float32) -> "FileArray":
63
+ """Writes an iterable of rows (sequences / numpy rows) without holding them all in memory."""
64
+ code = L.ARRAY_CODE[dtype]
65
+ count = 0
66
+ with open(path, "wb") as f:
67
+ for row in rows:
68
+ if _is_numpy(row):
69
+ import numpy as np
70
+
71
+ f.write(np.ascontiguousarray(row, dtype={float32: np.float32, int32: np.int32,
72
+ int8: np.int8}[dtype]).tobytes())
73
+ else:
74
+ flat: list = []
75
+ _flatten(row, [], flat) if isinstance(row, (list, tuple)) else flat.append(row)
76
+ f.write(array.array(code, flat if dtype == float32 else map(int, flat)).tobytes())
77
+ count += 1
78
+ return cls(path, (count, *row_shape), dtype)
79
+
80
+ def __len__(self) -> int:
81
+ return self.shape[0]
82
+
83
+ def rows(self, indices) -> bytes:
84
+ rb, off, mm = self.row_bytes, self.offset, self._mm
85
+ if not indices:
86
+ return b""
87
+ first = indices[0]
88
+ if indices == list(range(first, first + len(indices))): # contiguous: one slice
89
+ return mm[off + first * rb: off + (first + len(indices)) * rb]
90
+ return b"".join(mm[off + i * rb: off + (i + 1) * rb] for i in indices)
91
+
92
+ def __getitem__(self, i: int):
93
+ return array.array(L.ARRAY_CODE[self.dtype], self.rows([i % len(self)])).tolist()
94
+
95
+ def close(self) -> None:
96
+ if self._mm is not None:
97
+ self._mm.close()
98
+ self._mm = None
99
+ self._f.close()
100
+
101
+
102
+ class _MemoryRows:
103
+ """In-memory row source built once from lists, array.array, numpy or a Tensor."""
104
+
105
+ def __init__(self, data):
106
+ if isinstance(data, Tensor):
107
+ self.dtype, shape = data.dtype, data.shape
108
+ flat = data._flat()
109
+ self.buf = flat.tobytes()
110
+ elif _is_numpy(data):
111
+ import numpy as np
112
+
113
+ kind = int32 if np.issubdtype(data.dtype, np.integer) or data.dtype == np.bool_ else float32
114
+ arr = np.ascontiguousarray(data, dtype=np.int32 if kind == int32 else np.float32)
115
+ self.dtype, shape, self.buf = kind, arr.shape, arr.tobytes()
116
+ else:
117
+ shape_l: list = []
118
+ flat_l: list = []
119
+ _flatten(list(data), shape_l, flat_l)
120
+ self.dtype = float32 if any(isinstance(v, float) for v in flat_l) else int32
121
+ self.buf = array.array(L.ARRAY_CODE[self.dtype],
122
+ flat_l if self.dtype == float32 else map(int, flat_l)).tobytes()
123
+ shape = tuple(shape_l)
124
+ if not shape:
125
+ raise ValueError("dataset arrays need at least one dimension (the sample axis)")
126
+ self.shape = tuple(shape)
127
+ self.row_shape = self.shape[1:]
128
+ n = 1
129
+ for d in self.row_shape:
130
+ n *= d
131
+ self.row_bytes = n * L.DTYPE_SIZE[self.dtype]
132
+ self._mv = memoryview(self.buf)
133
+
134
+ def __len__(self) -> int:
135
+ return self.shape[0]
136
+
137
+ def rows(self, indices) -> bytes:
138
+ rb, mv = self.row_bytes, self._mv
139
+ if not indices:
140
+ return b""
141
+ first = indices[0]
142
+ if indices == list(range(first, first + len(indices))):
143
+ return bytes(mv[first * rb:(first + len(indices)) * rb])
144
+ return b"".join(mv[i * rb:(i + 1) * rb] for i in indices)
145
+
146
+ def __getitem__(self, i: int):
147
+ return array.array(L.ARRAY_CODE[self.dtype], self.rows([i % len(self)])).tolist()
148
+
149
+
150
+ def _row_source(data):
151
+ return data if isinstance(data, FileArray) else _MemoryRows(data)
152
+
153
+
154
+ class TensorDataset(Dataset):
155
+ """Samples are rows of each array (torch.utils.data.TensorDataset).
156
+
157
+ Arrays may be nested lists, array.array, numpy arrays, Tensors or FileArray
158
+ (disk-backed). Integer data becomes int32, floating data float32.
159
+ """
160
+
161
+ def __init__(self, *arrays):
162
+ if not arrays:
163
+ raise ValueError("TensorDataset needs at least one array")
164
+ self.sources = [_row_source(a) for a in arrays]
165
+ n = len(self.sources[0])
166
+ if any(len(s) != n for s in self.sources):
167
+ raise ValueError("all arrays must have the same number of rows")
168
+
169
+ def __len__(self) -> int:
170
+ return len(self.sources[0])
171
+
172
+ def __getitem__(self, index):
173
+ return tuple(s[index] for s in self.sources)
174
+
175
+ def get_batch(self, indices, ctx: Context) -> list[Tensor]:
176
+ return [tensor_from_bytes(s.rows(indices), (len(indices), *s.row_shape), s.dtype, ctx)
177
+ for s in self.sources]
178
+
179
+
180
+ class DataLoader:
181
+ """Iterates a dataset in batches (torch.utils.data.DataLoader).
182
+
183
+ Yields a list with one temporary Tensor per field, valid until the next
184
+ ``step_end()``. TensorDataset batches are gathered directly as bytes; other
185
+ datasets are collated from ``__getitem__``.
186
+ """
187
+
188
+ def __init__(self, dataset, batch_size: int = 1, shuffle: bool = False, drop_last: bool = False,
189
+ generator: random.Random | None = None, ctx: Context | None = None):
190
+ if batch_size < 1:
191
+ raise ValueError("batch_size must be >= 1")
192
+ self.dataset, self.batch_size, self.shuffle, self.drop_last = dataset, batch_size, shuffle, drop_last
193
+ self.generator = generator or random.Random()
194
+ self._ctx = ctx
195
+
196
+ def __len__(self) -> int:
197
+ n = len(self.dataset)
198
+ return n // self.batch_size if self.drop_last else -(-n // self.batch_size)
199
+
200
+ def __iter__(self):
201
+ ctx = self._ctx or default_context()
202
+ order = list(range(len(self.dataset)))
203
+ if self.shuffle:
204
+ self.generator.shuffle(order)
205
+ bs = self.batch_size
206
+ stop = len(order) - (len(order) % bs if self.drop_last else 0)
207
+ for s in range(0, stop, bs):
208
+ idx = order[s:s + bs]
209
+ if hasattr(self.dataset, "get_batch"):
210
+ yield self.dataset.get_batch(idx, ctx)
211
+ else:
212
+ yield _collate([self.dataset[i] for i in idx], ctx)
213
+
214
+
215
+ def _stack(items, ctx: Context) -> Tensor:
216
+ if _is_numpy(items[0]):
217
+ import numpy as np
218
+
219
+ return tensor(np.stack(items), ctx=ctx)
220
+ return tensor(list(items), ctx=ctx)
221
+
222
+
223
+ def _collate(samples, ctx: Context):
224
+ """Tuple samples -> list of batched fields (as torch's default_collate); else one Tensor."""
225
+ if isinstance(samples[0], tuple):
226
+ return [_stack([s[k] for s in samples], ctx) for k in range(len(samples[0]))]
227
+ return _stack(samples, ctx)
newch/nc.dll ADDED
Binary file