Repository navigation
[memory] load: read native LightGBM text models - #75
Conversation
6c7522b to
a416d6c
Compare
a416d6c to
0a01a41
Compare
ukaratay
left a comment
There was a problem hiding this comment.
Native text is a good addition, and requiring leaf text to back the node count is the right way to bound the reservation. One thing is wrong, inline.
On Zero-type missing values: keep Treelite's handling as you have it, documented. The Treelite route already behaves this way, and rejecting those splits would drop models users load today.
| if order.len() != splits + leaves { | ||
| return Err(malformed("unreachable nodes")); | ||
| } | ||
| let mut category_entries = 0usize; |
There was a problem hiding this comment.
category_entries resets per tree, so the bitset expansion has no model-wide bound. An 11-byte cat_threshold word expands to 32 categories, 128 bytes, and lists that differ by one bit per tree defeat the intern. I measured it: a 63 MB file, under the default 64 MiB text cap, peaked at 822 MB of heap before max_rank_table_bytes rejected it. Binary holds 4 bytes in the file per category, and JSON at least 2. Count entries across the whole model against one cap.
There was a problem hiding this comment.
Fixed: entries are now counted over the whole model, against max_json_bytes / 2. That's the bound a JSON document under the same cap has, at two bytes per category. I didn't use max_binary_array_bytes / 4 model-wide: a private model with many categorical splits has 127% of that in total, though under 1% of it in any one tree. The Treelite route loads that model under default options, and the native one would have refused it.
A file of your shape (60 MiB, 114 trees of 50,000 full words, each list one bit off) peaked at 913 MiB on the previous head before max_rank_table_bytes rejected it. It now stops at tree 20, at a 328 MiB peak including the text. A test loads four trees of full words at exactly the bound and rejects them with the bound one entry lower, though each tree is within it on its own.
Zero-type missing values: kept as they are.
0a01a41 to
65f0a73
Compare
ukaratay
left a comment
There was a problem hiding this comment.
The model-wide count against max_json_bytes / 2 is the right bound, and the boundary test pins it.
Read LightGBM text exports directly into the shared decoded forest, so callers need no Treelite conversion to load them. Recognize .txt paths and expose the format to the memory and reader loaders. Match Treelite 4.7.2 before the shared category encoding and heavy-first order: float64 comparisons and leaves, counts as training weights, right-first node numbering, the transforms of the objective names LightGBM saves, and float32 sigmoid alpha. For None and Zero missing types, route NaN by comparing zero with the threshold, even where LightGBM would use its declared default. Keep categorical missing values on the right. Reject linear trees and multiple trees per iteration rather than silently ignoring their terms. Bound materialized text with the existing document cap, and the model's expanded category entries with half of it, as a JSON document's are; bound decoded arrays with the binary field cap. Back node reservations with leaf text, reject shared categorical indices, then use the shared topology and layout checks. Add locked synthetic LightGBM and Treelite fixture pairs, compare their decoded models bit for bit, check prediction dispatch, and document the supported transforms, limits and zero-as-missing difference.
65f0a73 to
6e08a9d
Compare
Motivation
LightGBM models load only after an export through Treelite's Python package into a checkpoint, an extra conversion step and toolchain for every model. This reads LightGBM's own text format (
model.txt) directly, into the decoders' shared representation, with Treelite 4.7.2's semantics, so either route gives the same decoded model and bitwise equal predictions.Design
load: read native LightGBM text modelsModelFormat::LightGbmText, forfrom_bytesandfrom_reader;Forest::loadrecognizes.txtpaths.average_outputselects average aggregation.docs/treelite-loading.mdspells out where predictions can differ from native LightGBM.decision_type,leaf_countorinternal_count(which LightGBM always writes),num_leaves=0(which Treelite's builder mishandles), and a categorical split index shared by several splits, which LightGBM never writes and which would let a small file rescan the same bitset words.max_json_bytes, as the JSON decoder's document is (LoadOptionsis notnon_exhaustive, so a new field would break struct literals), each decoded array bymax_binary_array_bytes, and the model's category entries, counted over every tree before their bitset words expand, by half ofmax_json_bytes, as a JSON document's are at two bytes per category. A tree's node reservation needsleaf_valuetext to back it, and arrays are checked against their text before the cap, so a corrupt count fails as malformed rather than as a limit.Evidence
cargo test --release --features researchpasses; clippy (workspace, research, library alone) and fmt.