pylignum 0.2.0__tar.gz
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.
- pylignum-0.2.0/.gitignore +136 -0
- pylignum-0.2.0/ARCHITECTURE.md +170 -0
- pylignum-0.2.0/CLAUDE.md +72 -0
- pylignum-0.2.0/CMakeLists.txt +165 -0
- pylignum-0.2.0/LICENSE +19 -0
- pylignum-0.2.0/Makefile +42 -0
- pylignum-0.2.0/PKG-INFO +102 -0
- pylignum-0.2.0/README.md +80 -0
- pylignum-0.2.0/extern/simdjson/simdjson.cpp +65333 -0
- pylignum-0.2.0/extern/simdjson/simdjson.h +187599 -0
- pylignum-0.2.0/include/lignum/arrow.h +64 -0
- pylignum-0.2.0/include/lignum/c_api.h +100 -0
- pylignum-0.2.0/include/lignum/lignum.hpp +32 -0
- pylignum-0.2.0/include/lignum/model.hpp +135 -0
- pylignum-0.2.0/pyproject.toml +99 -0
- pylignum-0.2.0/python/README.md +53 -0
- pylignum-0.2.0/python/bindings.cpp +150 -0
- pylignum-0.2.0/python/lignum/__init__.py +330 -0
- pylignum-0.2.0/src/arrow.cpp +287 -0
- pylignum-0.2.0/src/binned.cpp +336 -0
- pylignum-0.2.0/src/binned.hpp +119 -0
- pylignum-0.2.0/src/builder.cpp +148 -0
- pylignum-0.2.0/src/builder.hpp +30 -0
- pylignum-0.2.0/src/c_api.cpp +125 -0
- pylignum-0.2.0/src/columns.hpp +24 -0
- pylignum-0.2.0/src/compiled.cpp +629 -0
- pylignum-0.2.0/src/compiled.hpp +60 -0
- pylignum-0.2.0/src/io.cpp +348 -0
- pylignum-0.2.0/src/io.hpp +12 -0
- pylignum-0.2.0/src/ir.hpp +47 -0
- pylignum-0.2.0/src/load.cpp +104 -0
- pylignum-0.2.0/src/model.cpp +912 -0
- pylignum-0.2.0/src/parsers/lightgbm.cpp +193 -0
- pylignum-0.2.0/src/parsers/lightgbm_text.cpp +293 -0
- pylignum-0.2.0/src/parsers/parsers.hpp +29 -0
- pylignum-0.2.0/src/parsers/ubjson.cpp +221 -0
- pylignum-0.2.0/src/parsers/xgboost.cpp +222 -0
- pylignum-0.2.0/src/validate.cpp +89 -0
- pylignum-0.2.0/src/validate.hpp +13 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
.AppleDouble
|
|
3
|
+
.LSOverride
|
|
4
|
+
Icon
|
|
5
|
+
._*
|
|
6
|
+
.DocumentRevisions-V100
|
|
7
|
+
.fseventsd
|
|
8
|
+
.Spotlight-V100
|
|
9
|
+
.TemporaryItems
|
|
10
|
+
.Trashes
|
|
11
|
+
.VolumeIcon.icns
|
|
12
|
+
.com.apple.timemachine.donotpresent
|
|
13
|
+
.AppleDB
|
|
14
|
+
.AppleDesktop
|
|
15
|
+
Network Trash Folder
|
|
16
|
+
Temporary Items
|
|
17
|
+
.apdisk
|
|
18
|
+
|
|
19
|
+
/build*/
|
|
20
|
+
bin/
|
|
21
|
+
obj/
|
|
22
|
+
out/
|
|
23
|
+
target/
|
|
24
|
+
*.o
|
|
25
|
+
*.obj
|
|
26
|
+
*.so
|
|
27
|
+
*.dylib
|
|
28
|
+
*.dll
|
|
29
|
+
*.a
|
|
30
|
+
*.lib
|
|
31
|
+
*.gch
|
|
32
|
+
*.pch
|
|
33
|
+
*.ilk
|
|
34
|
+
*.suo
|
|
35
|
+
*.pdb
|
|
36
|
+
*.idb
|
|
37
|
+
*.exp
|
|
38
|
+
*.exe
|
|
39
|
+
*.app
|
|
40
|
+
*.i
|
|
41
|
+
*.s
|
|
42
|
+
*.bc
|
|
43
|
+
|
|
44
|
+
CMakeCache.txt
|
|
45
|
+
CMakeFiles/
|
|
46
|
+
CMakeScripts/
|
|
47
|
+
cmake_install.cmake
|
|
48
|
+
install_manifest.txt
|
|
49
|
+
compile_commands.json
|
|
50
|
+
CTestTestfile.cmake
|
|
51
|
+
_deps/
|
|
52
|
+
*.cmake
|
|
53
|
+
!.gitkeep
|
|
54
|
+
|
|
55
|
+
.vs/
|
|
56
|
+
*.vcxproj
|
|
57
|
+
*.vcxproj.filters
|
|
58
|
+
*.vcxproj.user
|
|
59
|
+
*.sln
|
|
60
|
+
*.sln.docstates
|
|
61
|
+
*.opensdf
|
|
62
|
+
*.sdf
|
|
63
|
+
*.tlog
|
|
64
|
+
|
|
65
|
+
.vscode/
|
|
66
|
+
*.code-workspace
|
|
67
|
+
.idea/
|
|
68
|
+
*.swp
|
|
69
|
+
*.swo
|
|
70
|
+
*.swn
|
|
71
|
+
*.njsproj
|
|
72
|
+
|
|
73
|
+
*.pyc
|
|
74
|
+
*.pyo
|
|
75
|
+
*.pyd
|
|
76
|
+
__pycache__/
|
|
77
|
+
.pytest_cache/
|
|
78
|
+
.tox/
|
|
79
|
+
.coverage
|
|
80
|
+
.coverage.*
|
|
81
|
+
nosetests.xml
|
|
82
|
+
coverage.xml
|
|
83
|
+
*.egg-info/
|
|
84
|
+
*.egg
|
|
85
|
+
.eggs/
|
|
86
|
+
dist/
|
|
87
|
+
venv/
|
|
88
|
+
env/
|
|
89
|
+
.env/
|
|
90
|
+
venv*/
|
|
91
|
+
env*/
|
|
92
|
+
|
|
93
|
+
compile_commands.json
|
|
94
|
+
.cache/
|
|
95
|
+
.clang-format
|
|
96
|
+
.clangd
|
|
97
|
+
|
|
98
|
+
*.json
|
|
99
|
+
!model
|
|
100
|
+
*.model
|
|
101
|
+
*.xgb
|
|
102
|
+
*.lgb
|
|
103
|
+
*.txt
|
|
104
|
+
*.csv
|
|
105
|
+
*.tsv
|
|
106
|
+
*.dat
|
|
107
|
+
*.out
|
|
108
|
+
*.log
|
|
109
|
+
*.log.*
|
|
110
|
+
*.pid
|
|
111
|
+
*.bin
|
|
112
|
+
|
|
113
|
+
test_lignum
|
|
114
|
+
!CMakeLists.txt
|
|
115
|
+
site/
|
|
116
|
+
.venv/
|
|
117
|
+
*.npy
|
|
118
|
+
benchmarks/bench_model_*
|
|
119
|
+
benchmarks/.cache/
|
|
120
|
+
|
|
121
|
+
# Benchmark scripts write each run to benchmarks/results/; the reports worth keeping are moved into
|
|
122
|
+
# its folders (one per machine and architecture), raw .md / .json / .txt included.
|
|
123
|
+
/benchmarks/results/*
|
|
124
|
+
!/benchmarks/results/*/
|
|
125
|
+
!/benchmarks/results/README.md
|
|
126
|
+
!/benchmarks/results/*/**/*.json
|
|
127
|
+
!/benchmarks/results/*/**/*.txt
|
|
128
|
+
|
|
129
|
+
# The docs site (Starlight) needs its npm and TypeScript files.
|
|
130
|
+
!docs/package.json
|
|
131
|
+
!docs/package-lock.json
|
|
132
|
+
!docs/tsconfig.json
|
|
133
|
+
|
|
134
|
+
# Local working notes (roadmap with measurements, session handoff); not part of the project.
|
|
135
|
+
TASKS.md
|
|
136
|
+
HANDOFF.md
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Pipeline
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
model file ──parser──▶ TempBinNode forest (IR) ──Builder──▶ Model ──predict──▶ scores
|
|
7
|
+
src/ir.hpp src/builder.cpp src/model.cpp
|
|
8
|
+
│
|
|
9
|
+
Model::save ──┼──▶ .bin ──mmap──▶ Model (zero-copy)
|
|
10
|
+
│ src/io.cpp
|
|
11
|
+
compile_forest ──┴──▶ C ──cc──▶ shared library (src/compiled.cpp)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- **Parsers** (`src/parsers/`) read LightGBM `model.txt` (`lightgbm_text.cpp`) and `dump_model()`
|
|
15
|
+
JSON, XGBoost JSON and UBJSON (`ubjson.cpp` rewrites it as JSON) into a library-neutral binary
|
|
16
|
+
tree. `src/load.cpp` recognises the format from the file's first bytes. All library-specific
|
|
17
|
+
semantics are resolved here (see "Split semantics").
|
|
18
|
+
- **Builder** packs two tree levels into one 64-byte `KNode` and stores leaves in a separate array.
|
|
19
|
+
- **Model** is a view: raw pointers to nodes/offsets/leaves plus a `shared_ptr<Data>` owning the
|
|
20
|
+
memory, which is either `VectorData` (built from a model file) or `MmapData` (binary file).
|
|
21
|
+
|
|
22
|
+
## KNode
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
struct alignas(64) KNode { // one cache line = two levels of a binary tree
|
|
26
|
+
double thresholds[4]; // slot 0: root split, slot 1: left child, slot 2: right child, [3] unused
|
|
27
|
+
int32_t children[4]; // four grandchildren; negative = leaf index (-k-1)
|
|
28
|
+
uint16_t features[4];
|
|
29
|
+
uint8_t dirs[4]; // bit0: missing goes right, bit1: zero is missing (LightGBM)
|
|
30
|
+
uint8_t kinds[4]; // slot_kind: Numeric, CatMask, CatRange, CatBitset
|
|
31
|
+
};
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If a child of the root is a leaf, both of its grandchild pointers reference that leaf and the slot's
|
|
35
|
+
comparison is ignored.
|
|
36
|
+
|
|
37
|
+
A categorical slot keeps its category set in the 64 bits of `thresholds[slot]`: a mask when every
|
|
38
|
+
category is below 64, `[first, last - first]` when the set is contiguous, otherwise
|
|
39
|
+
`[offset, words]` into `Model::cat_bitsets` (word 0 is always zero, so branch-free lookups of
|
|
40
|
+
non-bitset slots read a valid address).
|
|
41
|
+
|
|
42
|
+
## Kernel (src/model.cpp)
|
|
43
|
+
|
|
44
|
+
- `Kernel<Acc, ZeroMissing>` predicts a block of rows: trees in tiles of `T_BLOCK` so a tile of
|
|
45
|
+
trees stays in cache while the block walks it. Strategies only decide how blocks are scheduled:
|
|
46
|
+
- `Serial`: blocks of `N_BLOCK` on the calling thread, no OpenMP region.
|
|
47
|
+
- `Rows`: OpenMP over blocks sized `n_samples / (2 * threads)`, rounded to 4, capped at `N_BLOCK`.
|
|
48
|
+
- `Auto`: `Serial` below 8 rows or with one thread, `Rows` otherwise (thresholds from
|
|
49
|
+
`benchmarks/batch_sweep.py`).
|
|
50
|
+
- Four rows are interleaved to hide memory latency; leftover rows (fewer than four, e.g. a single
|
|
51
|
+
row) interleave four trees instead. Leaves are still added in tree order.
|
|
52
|
+
- Per KNode the three comparisons are evaluated speculatively and the child is selected with
|
|
53
|
+
`children[(r << 1) | (r ? c : l)]`, which removes the dependency between the two levels.
|
|
54
|
+
- Template parameters: accumulator type (`double` for LightGBM, `float` for XGBoost) and `Mixed`
|
|
55
|
+
(the model has trees that are not plain numeric). With `Mixed`, the traversal is specialised per
|
|
56
|
+
tree from `tree_kinds[]` (bits `ZeroMissing`, `Categorical`); otherwise there is no per-tree branch.
|
|
57
|
+
- In categorical trees each slot branches on its kind (structural, predictable) and the categorical
|
|
58
|
+
test itself is branch-free (`cat_dir`), because NaN/range/category branches depend on the data.
|
|
59
|
+
- Two step variants: branch-free (all three comparisons, default for numeric trees in 4-row walks) and
|
|
60
|
+
branchy (branch on the root comparison, evaluate only the taken child). Branchy is used for leftover
|
|
61
|
+
rows (fewer than four, e.g. one row) everywhere and for 4-row walks of categorical trees except on
|
|
62
|
+
Apple (`LIGNUM_BRANCHY_TAIL`, `LIGNUM_BRANCHY_CAT`); choices measured on x86-64, aarch64 and M4. The traversal (`step`, `walk4`, `walk1`) is force-inlined and works
|
|
63
|
+
on local copies of the model pointers; going through `Model&` members cost ~7-13%.
|
|
64
|
+
- Leaves are added to a running sum loaded from `out_preds[i]`, in model order, so floating point
|
|
65
|
+
results are identical to the source library.
|
|
66
|
+
- Multiclass (`Multi` template parameter): row i's K outputs live in `out[i*K .. i*K+K)`; each tree
|
|
67
|
+
adds its leaf to `out[i*K + tree_outputs[t]]`, still in model order. Softmax runs per row after the
|
|
68
|
+
last tile. XGBoost `multi:softmax` (argmax) is computed from raw margins in a temporary buffer,
|
|
69
|
+
because narrowing K columns to one in place would race between threads.
|
|
70
|
+
|
|
71
|
+
## Binned kernel (src/binned.*, default)
|
|
72
|
+
|
|
73
|
+
Built from the KNode form at load time (or mapped from the binary file):
|
|
74
|
+
|
|
75
|
+
- Every used feature becomes a *dense feature* with a 16-bit code per row, computed once per row
|
|
76
|
+
block before the tree walk:
|
|
77
|
+
- numeric: `bin(x)` = number of the feature's split thresholds `t` with `t <= x` (branch-free
|
|
78
|
+
binary search over sorted unique thresholds), so `x >= t_k` ⇔ `bin(x) >= k + 1` exactly;
|
|
79
|
+
- categorical: the integer category under LightGBM or XGBoost rules; NaN and invalid values get
|
|
80
|
+
codes that are never in a set;
|
|
81
|
+
- NaN: `0xFFFF`.
|
|
82
|
+
- `BNode` (64 bytes) holds **three** tree levels: 7 slots (16-bit threshold or decision-bit index,
|
|
83
|
+
16-bit dense feature) and 8 subtrees. The walk compares only along the path (3 dependent integer
|
|
84
|
+
comparisons per node); evaluating all 7 speculatively was 2x slower.
|
|
85
|
+
- NaN without a branch: `0xFFFF` is above every threshold (goes right); slots whose NaN goes left
|
|
86
|
+
add 1 to the code, which wraps `0xFFFF` to 0.
|
|
87
|
+
- Categorical splits are decided before the walk: per categorical feature a table maps each
|
|
88
|
+
category to the decision bits of all of that feature's slots in the model; each row copies one
|
|
89
|
+
table row per feature into its decision buffer, and a categorical slot reads one bit from L1.
|
|
90
|
+
- LightGBM zero-as-missing: both edges of the zero band are added as cuts, so the band is one bin;
|
|
91
|
+
flagged slots override the comparison for that bin (branch-free).
|
|
92
|
+
- Rows outside the 4-row groups (including a single row) walk four trees at once.
|
|
93
|
+
- Results are identical to the KNode kernel, which remains for `load(..., binned=False)` and models
|
|
94
|
+
the binned form cannot represent; every Python test runs with both kernels
|
|
95
|
+
(`tests/python/conftest.py`).
|
|
96
|
+
|
|
97
|
+
## Split semantics
|
|
98
|
+
|
|
99
|
+
Every split is normalised to: **go right ⇔ `isnan(x) ? default_right : x >= threshold`**.
|
|
100
|
+
|
|
101
|
+
| Library | Native rule | Translation in the parser |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| LightGBM | right iff `z(x) > t`, where dense input with `\|x\| ≤ 1e-35f` becomes 0 | `threshold = nextafter(t, +inf)`; for `t` in the zero band the threshold moves to the band edge |
|
|
104
|
+
| LightGBM `missing_type=NaN` | NaN → `default_left` | direct |
|
|
105
|
+
| LightGBM `missing_type=None` | NaN → 0.0, compared normally | `default_right = (0 >= threshold)` |
|
|
106
|
+
| LightGBM `missing_type=Zero` | NaN → 0.0, and 0 is missing → `default_left` | direct; nodes where this differs from the natural direction of 0 get `ZeroIsMissing` |
|
|
107
|
+
| XGBoost | left iff `float(x) < t` (t is float32), NaN → `default_left` | threshold = smallest double that rounds to ≥ `t` in float32 |
|
|
108
|
+
| LightGBM categorical (`==`) | NaN → right; x ≤ -1 → right; else `int(x)` in set → left | categorical slot (see KNode) |
|
|
109
|
+
| XGBoost categorical (`split_type` 1) | NaN → `default_left`; `c = int(float32(x))`; c < 0 or c ≥ 2^24 → left; c in `categories` → right | categorical slot with `dir_bits::XGBoostCategory` |
|
|
110
|
+
|
|
111
|
+
XGBoost also accumulates and transforms in float32, and converts `base_score` to margin space with
|
|
112
|
+
float32 `log`/logit (`binary:logitraw` uses `base_score` as is; multiclass models store a vector of
|
|
113
|
+
per-class base scores already in margin space). XGBoost's softmax takes the max and `expf` in float,
|
|
114
|
+
sums in double and divides by `float(sum)`; LightGBM's softmax is all double.
|
|
115
|
+
|
|
116
|
+
Verified bit-exact by `tests/python/test_parity.py` on inputs with NaNs, zeros, values in the zero
|
|
117
|
+
band and values placed exactly on every split threshold.
|
|
118
|
+
|
|
119
|
+
## Binary format (version 0.6)
|
|
120
|
+
|
|
121
|
+
64-byte header (`magic "LIGNUM"`, version, transform, flags {float32, special trees}, sigmoid,
|
|
122
|
+
n_features, n_outputs, n_cat_words, section sizes), followed by 64-byte aligned sections: nodes, tree offsets,
|
|
123
|
+
leaves, tree kinds, base scores, tree outputs (multiclass only), categorical bitsets and, when the
|
|
124
|
+
`Binned` flag is set, a `BinnedFileHeader` followed by the binned kernel's sections (nodes, roots,
|
|
125
|
+
tree kinds, dense features, cuts, zero bins, categorical decision tables).
|
|
126
|
+
Loading validates the version and the file size, maps the file read-only and then checks every
|
|
127
|
+
stored index (`src/validate.cpp`): children must point strictly forward (so a walk always ends),
|
|
128
|
+
leaves, features, categorical bitsets and decision tables must be in range. This takes 0.15-0.64 ms
|
|
129
|
+
for the benchmark models. `tests/cpp/test_fuzz.cpp` corrupts files at random and runs under
|
|
130
|
+
ASan/UBSan in CI. Older versions are rejected; re-export from JSON.
|
|
131
|
+
|
|
132
|
+
## Compiled mode (src/compiled.*)
|
|
133
|
+
|
|
134
|
+
`compile_forest` generates C from the `BinnedForest`: `lignum_bin` (numeric binning specialised per
|
|
135
|
+
feature: constant cut tables, unrolled binary search, linear count for <= 8 cuts) and one
|
|
136
|
+
`lignum_block_<b>` per block of 16 trees. A block walks a chunk of rows (~2 MB of codes, decision bits
|
|
137
|
+
and sums, `LIGNUM_COMPILED_CHUNK_KB`) before the next block runs, adding leaves (from a per-block
|
|
138
|
+
table) in model order. The `LIGNUM_COMPILED_LOCALS` most used features of a block are read once per
|
|
139
|
+
row into locals (int16 codes: NaN = -1, so the NaN-left comparison needs no wrap), kept in registers
|
|
140
|
+
by an empty asm; leaf pairs become selects. Categorical decision bits, threads and transforms stay in
|
|
141
|
+
the kernel (`BinnedKernel` receives the compiled forest; `Model::compiled_min_rows` sends small
|
|
142
|
+
batches to the interpreter). Sources are compiled in parallel (gcc/clang, or one `cl /LD /MP`
|
|
143
|
+
command), linked, cached by source hash and loaded with dlopen / LoadLibrary. The library exports
|
|
144
|
+
`lignum_abi` and `lignum_fingerprint` (FNV-1a of trees, cuts, leaves, outputs, precision), checked by
|
|
145
|
+
`load_compiled`.
|
|
146
|
+
|
|
147
|
+
## Arrow input (src/arrow.cpp, src/columns.hpp)
|
|
148
|
+
|
|
149
|
+
`predict_arrow` takes a record batch through the Arrow C Data Interface (`include/lignum/arrow.h`).
|
|
150
|
+
With the binned kernel, an `ArrowColumns` `ColumnSource` lets `BinnedKernel` bin each feature column
|
|
151
|
+
of a chunk straight from the buffers (typed conversion into a small contiguous buffer, then one
|
|
152
|
+
binning loop); the direct kernel gets row-major blocks converted in L1-sized tiles. Values convert
|
|
153
|
+
exactly like numpy's `astype(float64)`; nulls (column or batch) are NaN; dictionary columns give
|
|
154
|
+
their indices.
|
|
155
|
+
|
|
156
|
+
## APIs
|
|
157
|
+
|
|
158
|
+
- C++: `include/lignum/lignum.hpp` (`lignum::load`, `predict_arrow`, `predict_arrow_stream`) and
|
|
159
|
+
`model.hpp` (`Model::predict`, `Strategy`).
|
|
160
|
+
- C: `include/lignum/c_api.h`, built as the shared `liblignum` (`LIGNUM_BUILD_C_LIBRARY`, on by
|
|
161
|
+
default outside Python builds): loading, prediction (row-major and Arrow), compiled mode. Only
|
|
162
|
+
`Lignum*` symbols are exported; errors are reported through return codes and a thread-local
|
|
163
|
+
`LignumGetLastError()`.
|
|
164
|
+
- Python: `python/bindings.cpp` (nanobind, abi3 for Python >= 3.12) + `python/lignum/__init__.py`,
|
|
165
|
+
published as `pylignum`.
|
|
166
|
+
|
|
167
|
+
## Known limitations
|
|
168
|
+
|
|
169
|
+
No multi-target / vector-leaf models, no LightGBM linear trees or random forests (rejected at load
|
|
170
|
+
time).
|
pylignum-0.2.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Lignum — rules for working in this repo
|
|
2
|
+
|
|
3
|
+
Lignum is a production inference engine for LightGBM/XGBoost models (C++20 core, Python bindings).
|
|
4
|
+
Read [ARCHITECTURE.md](ARCHITECTURE.md) before touching the kernel or parsers. The roadmap with past measurements
|
|
5
|
+
(`TASKS.md`) and the session handoff (`HANDOFF.md`) are local, untracked notes: read them when present.
|
|
6
|
+
Published on PyPI as `pylignum`, imported as `lignum`.
|
|
7
|
+
|
|
8
|
+
## Non-negotiable rules
|
|
9
|
+
|
|
10
|
+
1. **Predictions are bit-identical to the source library.** Raw scores and transformed outputs must
|
|
11
|
+
equal `booster.predict(...)` exactly (`np.testing.assert_array_equal`, never `allclose`).
|
|
12
|
+
- Sum trees in model order, continuing from `out_preds[i]`; never pre-sum a block of trees.
|
|
13
|
+
- No `-ffast-math`, `-ffp-contract=fast`, `/fp:fast`.
|
|
14
|
+
- Library-specific semantics (thresholds, missing values, float32) are resolved in the parser,
|
|
15
|
+
not in the kernel. XGBoost uses float32 for comparisons, accumulation and transforms.
|
|
16
|
+
2. **No performance regressions.** Any kernel change needs a before/after benchmark against the
|
|
17
|
+
previous commit (see "Benchmarking"). Put the numbers in the commit message.
|
|
18
|
+
3. **No hand-written SIMD** unless a benchmark shows a clear win on both x86-64 and arm64.
|
|
19
|
+
4. `KNode` is exactly 64 bytes (`static_assert`). Every union member must be naturally aligned per slot.
|
|
20
|
+
5. The core is a library: no Kafka/network code, no dependencies beyond vendored simdjson and OpenMP.
|
|
21
|
+
6. C++20. Code, comments, commit messages and repo docs in English.
|
|
22
|
+
7. Every feature ships with tests: GoogleTest in `tests/cpp` for logic, pytest parity tests in
|
|
23
|
+
`tests/python` against real LightGBM/XGBoost models (include NaN, zeros, values exactly on thresholds).
|
|
24
|
+
Every Python test runs three times (`tests/python/conftest.py`): the binned kernel (default), the
|
|
25
|
+
KNode walk (`binned=False` / unsupported models) and compiled trees (`compile()`, blocks of 3 trees);
|
|
26
|
+
kernel and code-generator changes must keep all three exact. Parser changes also get fuzz tests
|
|
27
|
+
(`tests/cpp/test_fuzz.cpp`) and must reject corrupted files without crashing on Windows' 1 MB stack.
|
|
28
|
+
8. Distributed builds never use `-march=native`/`-mcpu=native` (`LIGNUM_NATIVE` is for local benchmarks only).
|
|
29
|
+
9. Kernel ideas are measured on x86-64 and aarch64 before they are kept: put compile flags in
|
|
30
|
+
`EXPERIMENT_CXX_FLAGS` of `.github/workflows/bench-lleaves.yml` and push; M4 alone has misled us
|
|
31
|
+
(categorical side table: faster on servers, slower on M4).
|
|
32
|
+
10. Platforms: Linux, macOS and Windows (MSVC) for the core, the compiled mode and the wheels; CI
|
|
33
|
+
(`tests.yml`) tests all three. Don't add POSIX-only code without a Windows path.
|
|
34
|
+
11. Releases: the version lives only in `python/lignum/__init__.py`. Wheels are built by
|
|
35
|
+
`.github/workflows/wheels.yml`; publishing is a manual run (`target=testpypi|pypi`, trusted
|
|
36
|
+
publishing, the `pypi` environment needs the owner's approval). A version can be uploaded once.
|
|
37
|
+
Pushes touching `src/`, `python/` or the build start a Wheels build: avoid pushing while a
|
|
38
|
+
publishing run is going.
|
|
39
|
+
|
|
40
|
+
## Layout
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
include/lignum/ public API (lignum.hpp, model.hpp, c_api.h, arrow.h)
|
|
44
|
+
src/ engine: parsers/ (LightGBM text/JSON, XGBoost JSON/UBJSON), ir.hpp, builder,
|
|
45
|
+
model.cpp (kernels), binned.*, compiled.* (code generator), arrow.cpp, io.cpp (.bin)
|
|
46
|
+
python/ nanobind bindings (bindings.cpp) + lignum package; python/README.md is the PyPI page
|
|
47
|
+
tests/cpp GoogleTest tests/python pytest parity tests (+ smoke.py for wheels)
|
|
48
|
+
benchmarks/ bench.py, bench_arrow.py, cloud.sh (cloud server run); generated models/data gitignored
|
|
49
|
+
docs/ Starlight (Astro) site; docs/scripts/api_reference.py writes the Python reference
|
|
50
|
+
extern/simdjson vendored (also used for exact number parsing in the text formats)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
make venv # .venv with Python 3.12 via uv (lightgbm, xgboost, pytest, nanobind, ...)
|
|
57
|
+
make py-build # editable install of the package into .venv
|
|
58
|
+
make test # cpp-test + py-test
|
|
59
|
+
make cpp-test # cmake -DLIGNUM_BUILD_TESTS=ON, build, ctest
|
|
60
|
+
cd docs && python scripts/api_reference.py && npm run build # documentation site
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
macOS needs `brew install libomp`; CMake finds Homebrew's keg-only libomp automatically.
|
|
64
|
+
|
|
65
|
+
## Benchmarking
|
|
66
|
+
|
|
67
|
+
Compare against the previous state, not against memory:
|
|
68
|
+
1. `git worktree add <scratch>/base <commit>` and install it into a separate venv.
|
|
69
|
+
2. Run the same script on the same saved models and data with both venvs, at least twice.
|
|
70
|
+
3. Report best and median for 1M rows and the median latency for 100 rows.
|
|
71
|
+
4. For x86-64 / ARM numbers use `bench-lleaves.yml` (shared runners: compare engines within a job) or
|
|
72
|
+
`benchmarks/cloud.sh` on a dedicated cloud server.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.18...3.31)
|
|
2
|
+
project(lignum LANGUAGES CXX)
|
|
3
|
+
|
|
4
|
+
option(LIGNUM_BUILD_TESTS "Build the GoogleTest suite" OFF)
|
|
5
|
+
option(LIGNUM_SANITIZE "Build with sanitizers (tests, not releases)" OFF)
|
|
6
|
+
set(LIGNUM_SANITIZERS "address,undefined" CACHE STRING "Sanitizers for LIGNUM_SANITIZE (ASan hangs on some macOS versions: use undefined there)")
|
|
7
|
+
option(LIGNUM_NATIVE "Tune for the build machine (-march/-mcpu=native). Never enable for distributed wheels." OFF)
|
|
8
|
+
if(SKBUILD)
|
|
9
|
+
set(_lignum_c_default OFF)
|
|
10
|
+
else()
|
|
11
|
+
set(_lignum_c_default ON)
|
|
12
|
+
endif()
|
|
13
|
+
option(LIGNUM_BUILD_C_LIBRARY "Build the shared library exposing the C API (include/lignum/c_api.h)" ${_lignum_c_default})
|
|
14
|
+
|
|
15
|
+
set(CMAKE_CXX_STANDARD 20)
|
|
16
|
+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
17
|
+
set(CMAKE_CXX_EXTENSIONS OFF)
|
|
18
|
+
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
|
19
|
+
|
|
20
|
+
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
|
21
|
+
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
|
22
|
+
endif()
|
|
23
|
+
|
|
24
|
+
if(LIGNUM_SANITIZE AND NOT MSVC)
|
|
25
|
+
add_compile_options(-fsanitize=${LIGNUM_SANITIZERS} -fno-omit-frame-pointer -fno-sanitize-recover=all)
|
|
26
|
+
add_link_options(-fsanitize=${LIGNUM_SANITIZERS})
|
|
27
|
+
endif()
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Core library
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
add_library(lignum STATIC
|
|
33
|
+
src/arrow.cpp
|
|
34
|
+
src/binned.cpp
|
|
35
|
+
src/builder.cpp
|
|
36
|
+
src/c_api.cpp
|
|
37
|
+
src/compiled.cpp
|
|
38
|
+
src/io.cpp
|
|
39
|
+
src/load.cpp
|
|
40
|
+
src/model.cpp
|
|
41
|
+
src/parsers/lightgbm.cpp
|
|
42
|
+
src/parsers/lightgbm_text.cpp
|
|
43
|
+
src/parsers/ubjson.cpp
|
|
44
|
+
src/parsers/xgboost.cpp
|
|
45
|
+
src/validate.cpp
|
|
46
|
+
extern/simdjson/simdjson.cpp
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Only the C API is exported from shared objects; C++ internals stay hidden. The static archive gets
|
|
50
|
+
# its own file name: on Windows the shared library's import library is lignum.lib.
|
|
51
|
+
set_target_properties(lignum PROPERTIES OUTPUT_NAME lignum_static CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON)
|
|
52
|
+
|
|
53
|
+
target_include_directories(lignum
|
|
54
|
+
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
|
|
55
|
+
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
|
|
56
|
+
${CMAKE_CURRENT_SOURCE_DIR}/extern/simdjson
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Homebrew's libomp is keg-only, so AppleClang cannot find it without a hint.
|
|
60
|
+
if(APPLE AND NOT OpenMP_ROOT AND NOT DEFINED ENV{OpenMP_ROOT})
|
|
61
|
+
foreach(prefix /opt/homebrew/opt/libomp /usr/local/opt/libomp)
|
|
62
|
+
if(EXISTS ${prefix}/include/omp.h)
|
|
63
|
+
set(OpenMP_ROOT ${prefix})
|
|
64
|
+
break()
|
|
65
|
+
endif()
|
|
66
|
+
endforeach()
|
|
67
|
+
endif()
|
|
68
|
+
|
|
69
|
+
target_link_libraries(lignum PRIVATE ${CMAKE_DL_LIBS})
|
|
70
|
+
|
|
71
|
+
find_package(OpenMP)
|
|
72
|
+
if(OpenMP_CXX_FOUND)
|
|
73
|
+
target_link_libraries(lignum PUBLIC OpenMP::OpenMP_CXX)
|
|
74
|
+
else()
|
|
75
|
+
message(WARNING "OpenMP not found: lignum will predict single-threaded.")
|
|
76
|
+
endif()
|
|
77
|
+
|
|
78
|
+
# Results must match LightGBM/XGBoost bit for bit: no fast-math, no FMA contraction.
|
|
79
|
+
if(MSVC)
|
|
80
|
+
target_compile_options(lignum PRIVATE /fp:precise)
|
|
81
|
+
else()
|
|
82
|
+
target_compile_options(lignum PRIVATE -ffp-contract=off -fno-math-errno -funroll-loops)
|
|
83
|
+
if(LIGNUM_NATIVE)
|
|
84
|
+
if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64")
|
|
85
|
+
target_compile_options(lignum PRIVATE -mcpu=native)
|
|
86
|
+
else()
|
|
87
|
+
target_compile_options(lignum PRIVATE -march=native)
|
|
88
|
+
endif()
|
|
89
|
+
endif()
|
|
90
|
+
endif()
|
|
91
|
+
|
|
92
|
+
include(CheckIPOSupported)
|
|
93
|
+
check_ipo_supported(RESULT LIGNUM_IPO_SUPPORTED OUTPUT _ipo_msg LANGUAGES CXX)
|
|
94
|
+
if(LIGNUM_IPO_SUPPORTED)
|
|
95
|
+
set_property(TARGET lignum PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
|
|
96
|
+
endif()
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Shared library with the C API
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
if(LIGNUM_BUILD_C_LIBRARY)
|
|
102
|
+
add_library(lignum_c SHARED src/c_api.cpp)
|
|
103
|
+
set_target_properties(lignum_c PROPERTIES OUTPUT_NAME lignum CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON)
|
|
104
|
+
target_compile_definitions(lignum_c PRIVATE LIGNUM_EXPORTS PUBLIC LIGNUM_SHARED)
|
|
105
|
+
target_link_libraries(lignum_c PRIVATE lignum)
|
|
106
|
+
target_include_directories(lignum_c PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
|
|
107
|
+
|
|
108
|
+
include(GNUInstallDirs)
|
|
109
|
+
install(TARGETS lignum_c LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
|
110
|
+
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
|
111
|
+
install(FILES include/lignum/c_api.h include/lignum/arrow.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/lignum)
|
|
112
|
+
endif()
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Python extension (built by scikit-build-core)
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
if(SKBUILD)
|
|
118
|
+
# SKBUILD_SABI_COMPONENT is set when scikit-build-core builds an abi3 wheel (wheel.py-api).
|
|
119
|
+
find_package(Python 3.10 REQUIRED COMPONENTS Interpreter Development.Module ${SKBUILD_SABI_COMPONENT})
|
|
120
|
+
find_package(nanobind CONFIG REQUIRED)
|
|
121
|
+
|
|
122
|
+
# STABLE_ABI: one wheel for every Python >= 3.12 (ignored for older Pythons).
|
|
123
|
+
nanobind_add_module(_core STABLE_ABI python/bindings.cpp)
|
|
124
|
+
target_include_directories(_core PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
|
125
|
+
target_link_libraries(_core PRIVATE lignum)
|
|
126
|
+
install(TARGETS _core DESTINATION lignum)
|
|
127
|
+
|
|
128
|
+
# macOS wheels: LightGBM and XGBoost use Homebrew's libomp, and two copies of libomp in one
|
|
129
|
+
# process abort (OMP Error #15). So the extension looks for Homebrew's first and falls back to
|
|
130
|
+
# a copy shipped in the wheel (lignum/.dylibs), found through the same @rpath install name.
|
|
131
|
+
option(LIGNUM_BUNDLE_OPENMP "macOS: ship libomp in the wheel as a fallback for Homebrew's" OFF)
|
|
132
|
+
if(APPLE AND LIGNUM_BUNDLE_OPENMP AND OpenMP_CXX_FOUND)
|
|
133
|
+
set_target_properties(_core PROPERTIES
|
|
134
|
+
BUILD_WITH_INSTALL_RPATH ON
|
|
135
|
+
INSTALL_RPATH "/opt/homebrew/opt/libomp/lib;/usr/local/opt/libomp/lib;@loader_path/.dylibs")
|
|
136
|
+
find_library(LIGNUM_LIBOMP NAMES omp PATHS ${OpenMP_ROOT}/lib NO_DEFAULT_PATH REQUIRED)
|
|
137
|
+
get_filename_component(LIGNUM_LIBOMP_REAL ${LIGNUM_LIBOMP} REALPATH)
|
|
138
|
+
install(FILES ${LIGNUM_LIBOMP_REAL} DESTINATION lignum/.dylibs RENAME libomp.dylib)
|
|
139
|
+
# Reference libomp as @rpath/libomp.dylib (Homebrew's own install name is an absolute path).
|
|
140
|
+
add_custom_command(TARGET _core POST_BUILD
|
|
141
|
+
COMMAND install_name_tool -change ${LIGNUM_LIBOMP_REAL} @rpath/libomp.dylib $<TARGET_FILE:_core>
|
|
142
|
+
COMMAND install_name_tool -change ${LIGNUM_LIBOMP} @rpath/libomp.dylib $<TARGET_FILE:_core>
|
|
143
|
+
COMMAND install_name_tool -change /opt/homebrew/opt/libomp/lib/libomp.dylib @rpath/libomp.dylib $<TARGET_FILE:_core>
|
|
144
|
+
COMMAND install_name_tool -change /usr/local/opt/libomp/lib/libomp.dylib @rpath/libomp.dylib $<TARGET_FILE:_core>
|
|
145
|
+
VERBATIM)
|
|
146
|
+
endif()
|
|
147
|
+
endif()
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Tests
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
if(LIGNUM_BUILD_TESTS)
|
|
153
|
+
include(FetchContent)
|
|
154
|
+
FetchContent_Declare(
|
|
155
|
+
googletest
|
|
156
|
+
URL https://github.com/google/googletest/archive/refs/tags/v1.17.0.tar.gz
|
|
157
|
+
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
|
|
158
|
+
)
|
|
159
|
+
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
|
160
|
+
set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)
|
|
161
|
+
FetchContent_MakeAvailable(googletest)
|
|
162
|
+
|
|
163
|
+
enable_testing()
|
|
164
|
+
add_subdirectory(tests/cpp)
|
|
165
|
+
endif()
|
pylignum-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Kacper Piotrowski
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
pylignum-0.2.0/Makefile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
.PHONY: venv bench-deps py-build py-test cpp-build cpp-test test bench bench-quick docs clean
|
|
2
|
+
|
|
3
|
+
PYTHON ?= .venv/bin/python
|
|
4
|
+
CPP_BUILD_DIR ?= build/cpp
|
|
5
|
+
|
|
6
|
+
venv:
|
|
7
|
+
uv venv --python 3.12 .venv
|
|
8
|
+
uv pip install --python $(PYTHON) numpy pandas scikit-learn lightgbm xgboost pytest nanobind scikit-build-core pyarrow ninja
|
|
9
|
+
|
|
10
|
+
# Competitor engines for benchmarks/bench.py (lleaves 1.3 needs llvmlite < 0.45).
|
|
11
|
+
bench-deps:
|
|
12
|
+
uv pip install --python $(PYTHON) treelite tl2cgen lleaves "llvmlite<0.45" onnxruntime onnxmltools onnxconverter-common
|
|
13
|
+
|
|
14
|
+
py-build:
|
|
15
|
+
uv pip install --python $(PYTHON) --no-build-isolation -e .
|
|
16
|
+
|
|
17
|
+
py-test: py-build
|
|
18
|
+
$(PYTHON) -m pytest tests/python -v --tb=short
|
|
19
|
+
|
|
20
|
+
cpp-build:
|
|
21
|
+
cmake -S . -B $(CPP_BUILD_DIR) -DLIGNUM_BUILD_TESTS=ON
|
|
22
|
+
cmake --build $(CPP_BUILD_DIR) --parallel
|
|
23
|
+
|
|
24
|
+
cpp-test: cpp-build
|
|
25
|
+
ctest --test-dir $(CPP_BUILD_DIR) --output-on-failure
|
|
26
|
+
|
|
27
|
+
test: cpp-test py-test
|
|
28
|
+
|
|
29
|
+
# Compares lignum with native LightGBM/XGBoost, TL2cgen, GTIL, lleaves and ONNX Runtime (see bench-deps).
|
|
30
|
+
bench:
|
|
31
|
+
$(PYTHON) benchmarks/bench.py
|
|
32
|
+
|
|
33
|
+
bench-quick:
|
|
34
|
+
$(PYTHON) benchmarks/bench.py --quick
|
|
35
|
+
|
|
36
|
+
docs:
|
|
37
|
+
rm -rf site/
|
|
38
|
+
$(PYTHON) -m pip install mkdocs-material "mkdocstrings[python]"
|
|
39
|
+
$(PYTHON) -m mkdocs build
|
|
40
|
+
|
|
41
|
+
clean:
|
|
42
|
+
rm -rf build/ .pytest_cache/ benchmarks/.cache site/
|