[float32 codegen] Leaf value / base_score literals emitted without `f` suffix, causing double-precision intermediate accumulation and GTIL/SO divergence
- Dominant language
- C++
- Stars
- 55
- Forks
- 11
- PR merge metrics
- No merged PRs in 30d
Description
For **float32** models (`get_threshold_type()` returns `"float32"`), TL2cgen 1.0.0 generates C code where:
- **Threshold comparisons are correctly cast to `float`**, e.g. `data[i].fvalue >= (float)10.885582924`.
- **Leaf value and `base_score` literals are emitted as `double` constants** without an `f` suffix, e.g. `result[0] += 179.56755;`.
GCC evaluates the compound assignment `result[N] += ;` using double-precision intermediate arithmetic and then truncates the result back to float. This produces different rounding from Treelite GTIL, which performs strict float32 sequential accumulation. The discrepancy becomes visible on models with many trees and large-magnitude leaf values, where accumulated rounding differences exceed typical float32 tolerances (`rtol=1e-5`).
This is a code-generation inconsistency in TL2cgen: thresholds are cast to float, but accumulated values are not.
## Environment
| Component | Version |
| --- | --- |
| tl2cgen | 1.0.0 (PyPI latest) |
| treelite (runtime, bundled in tl2cgen wheel) | 4.1.2 |
| treelite (used to build model checkpoint) | 4.7.0 |
| Python | 3.11 |
| Compiler | gcc 10.2.1 (devtoolset-10, manylinux2014) |
| Platform | Linux x86_64 |
## Minimal Reproducible Example
The bug is deterministic from any float32 model with non-trivial leaf values. Below is a self-contained example using `treelite.model_builder` to build a small model and inspect the generated C code.
```python
import numpy as np
import treelite
from treelite.model_builder import (
Metadata,
ModelBuilder,
PostProcessorFunc,
TreeAnnotation,
)
import tl2cgen
import tempfile
from pathlib import Path
# Build a minimal float32 regression model with one tree
builder = ModelBuilder(
threshold_type="float32",
leaf_output_type="float32",
metadata=Metadata(
num_feature=1,
task_type="kRegressor",
average_tree_output=False,
num_target=1,
num_class=[1],
leaf_vector_shape=(1, 1),
),
tree_annotation=TreeAnnotation(num_tree=1, target_id=[0], class_id=[0]),
postprocessor=PostProcessorFunc(name="identity"),
base_scores=[0.5],
)
builder.start_tree()
builder.start_node(1)
builder.numerical_test(
feature_id=0,
threshold=0.5,
default_left=False,
opname="<",
left_child_key=2,
right_child_key=3,
)
builder.end_node()
builder.start_node(2)
builder.leaf(179.56755)
builder.end_node()
builder.start_node(3)
builder.leaf(0.0)
builder.end_node()
builder.end_tree()
model = builder.commit()
with tempfile.TemporaryDirectory() as d:
tl2cgen.generate_c_code(model, d, params={"parallel_comp": 1})
main_c = Path(d) / "main.c"
print(main_c.read_text())
```
### Generated code (actual)
> Note: the generated `predict` signature uses an output array parameter (`float* result`) rather than a return value. This is the form generated by tl2cgen 1.0.0 for `leaf_vector_shape=(1,1)` models.
```c
const char* get_threshold_type(void) {
return "float32";
}
const char* get_leaf_output_type(void) {
return "float32";
}
void predict(union Entry* data, int pred_margin, float* result) {
unsigned int tmp;
predict_unit0(data, result);
// Apply base_scores
result[0] += 0.5; // <- double literal, should be 0.5f
// Apply postprocessor
if (!pred_margin) { postprocess(result); }
}
```
Inside `tu0.c`:
```c
if ( (data[0].missing != -1) && (data[0].fvalue < (float)0.5)) {
result[0] += 179.56755; // <- double literal, should be 179.56755f
} else {
...
}
```
### Generated code (expected)
```c
result[0] += 0.5f;
result[0] += 179.56755f;
```
## Impact
On a real regression model with:
- 1000 trees
- leaf outputs in the range ~20–250
- `base_scores = [0.5]`
- `threshold_type = leaf_output_type = "float32"`
we observed:
| | GTIL | SO (TL2cgen-generated .so) |
| --- | --- | --- |
| Score | 20.324804306030273 | 20.32509994506836 |
| abs_diff | — | 0.0002956390380859375 |
| rel_diff | — | 1.454572617942613e-5 |
The relative error exceeds `rtol=1e-5`, causing numerical validation to fail.
After manually adding `f` suffixes to all `result[N] += ;` lines in the generated C code and recompiling, the SO output becomes **bit-exact** with GTIL (`max_abs_diff = 0.0`).
## Root Cause
In the float32 codegen path, TL2cgen formats floating-point literals using their decimal representation but omits the `f` suffix for values that are:
1. assigned to `result[N]` in leaf nodes, and
2. assigned to `result[N]` as `base_scores` in `main.c`.
The C standard defines `result[0] += 179.56755;` (where `result` is `float*`) as equivalent to:
```c
result[0] = (float)((double)result[0] + 179.56755);
```
The divergence has two sources: (1) the literal itself carries double precision (53-bit mantissa) rather than the float32 precision (23-bit mantissa) the model intends, so the value being added is subtly different from what GTIL uses; and (2) the accumulation is performed in double-precision arithmetic rather than float32. Over many trees, both effects compound. Adding the `f` suffix fixes both simultaneously — the literal becomes a float32 value and the compound assignment stays in float32 throughout.
## Workaround
We currently post-process the generated `.c` files before compilation, adding `f` to float32 literals matching `result[N] += ;`.
Example regex-based patch:
```python
import re
from pathlib import Path
_FLOAT_LITERAL_RE = re.compile(
r"(result\[\d+\]\s*\+=\s*)"
r"([+-]?(?:(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?|\d+[eE][+-]?\d+))"
r"(?![fFlL])"
r"(\s*;)"
)
def patch_float32_literals(src_dir: Path) -> None:
for c_file in src_dir.glob("*.c"):
text = c_file.read_text()
new_text = _FLOAT_LITERAL_RE.sub(r"\1\2f\3", text)
c_file.write_text(new_text)
```
After applying this patch, SO and GTIL are numerically identical on our affected model.
> Note: this regex is specific to the current codegen pattern (`result[] += ;`). Adjust if future versions change the emission pattern (e.g. variable indices or plain assignment).
## Suggested Fix
In the float32 codegen path, emit leaf value and base_score literals with an `f` suffix:
```c
result[0] += 179.56755f;
result[0] += 0.5f;
```
Alternatively, explicitly cast the literal to float before the compound assignment:
```c
result[0] += (float)179.56755;
```
Either approach aligns the generated code with GTIL's float32 semantics.
## Scope
This issue only affects **float32** models. For float64 models (`threshold_type = leaf_output_type = "float64"`), the emitted literals are already double-precision and the accumulation is natively double — consistent with GTIL's float64 path. No divergence occurs.
## Affected Versions
- tl2cgen 1.0.0 (PyPI latest as of 2026-07-30)
## Additional Context
We discovered this while migrating PMML models to native `.so` libraries for ODPS UDTF inference. The issue caused one model to fail SO vs GTIL numerical validation at `rtol=1e-5`. Manual addition of `f` suffixes restored bit-exact alignment.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with the generate_c_code entry point from the provided Python reproducer and inspect the generated main.c and tu0.c files. Trace how float32 base_scores and leaf values become result[N] += literals; done means those literals use float32 semantics and generated output matches GTIL bit-for-bit for the reproducer.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c, cpp
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100