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.
Files changed (39) hide show
  1. pylignum-0.2.0/.gitignore +136 -0
  2. pylignum-0.2.0/ARCHITECTURE.md +170 -0
  3. pylignum-0.2.0/CLAUDE.md +72 -0
  4. pylignum-0.2.0/CMakeLists.txt +165 -0
  5. pylignum-0.2.0/LICENSE +19 -0
  6. pylignum-0.2.0/Makefile +42 -0
  7. pylignum-0.2.0/PKG-INFO +102 -0
  8. pylignum-0.2.0/README.md +80 -0
  9. pylignum-0.2.0/extern/simdjson/simdjson.cpp +65333 -0
  10. pylignum-0.2.0/extern/simdjson/simdjson.h +187599 -0
  11. pylignum-0.2.0/include/lignum/arrow.h +64 -0
  12. pylignum-0.2.0/include/lignum/c_api.h +100 -0
  13. pylignum-0.2.0/include/lignum/lignum.hpp +32 -0
  14. pylignum-0.2.0/include/lignum/model.hpp +135 -0
  15. pylignum-0.2.0/pyproject.toml +99 -0
  16. pylignum-0.2.0/python/README.md +53 -0
  17. pylignum-0.2.0/python/bindings.cpp +150 -0
  18. pylignum-0.2.0/python/lignum/__init__.py +330 -0
  19. pylignum-0.2.0/src/arrow.cpp +287 -0
  20. pylignum-0.2.0/src/binned.cpp +336 -0
  21. pylignum-0.2.0/src/binned.hpp +119 -0
  22. pylignum-0.2.0/src/builder.cpp +148 -0
  23. pylignum-0.2.0/src/builder.hpp +30 -0
  24. pylignum-0.2.0/src/c_api.cpp +125 -0
  25. pylignum-0.2.0/src/columns.hpp +24 -0
  26. pylignum-0.2.0/src/compiled.cpp +629 -0
  27. pylignum-0.2.0/src/compiled.hpp +60 -0
  28. pylignum-0.2.0/src/io.cpp +348 -0
  29. pylignum-0.2.0/src/io.hpp +12 -0
  30. pylignum-0.2.0/src/ir.hpp +47 -0
  31. pylignum-0.2.0/src/load.cpp +104 -0
  32. pylignum-0.2.0/src/model.cpp +912 -0
  33. pylignum-0.2.0/src/parsers/lightgbm.cpp +193 -0
  34. pylignum-0.2.0/src/parsers/lightgbm_text.cpp +293 -0
  35. pylignum-0.2.0/src/parsers/parsers.hpp +29 -0
  36. pylignum-0.2.0/src/parsers/ubjson.cpp +221 -0
  37. pylignum-0.2.0/src/parsers/xgboost.cpp +222 -0
  38. pylignum-0.2.0/src/validate.cpp +89 -0
  39. 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).
@@ -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.
@@ -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/