Skip to content

Read property values by index for an engine built with property names - #101

Merged
jwrosewell merged 10 commits into
rcfrom
fix/property-index-build-names
Nov 11, 2026
Merged

jwrosewell merged 10 commits into
rcfrom
fix/property-index-build-names

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Both on-premise engines read a property value by its index when the engine was built with a list of property names, so each read skips the string lookup in the native layer, and the index keeps naming the same property after a reload.

The problem

James Rosewell, 10 November 2026: "The change for the index of the property is to return the index of the property in the list of properties provided at BUILD time and not for the data set. This is because when a reload happens other properties might be in different orders. The use of the index to avoid the string look up should be applied everywhere."

A property's index is its place in the list of names the engine was built with, and that list is fixed at build. 51degrees-cxx gives that index, at src/common/properties.c lines 478 to 506 at c6d4e4d9, the commit this repository's rc builds against. It sets "the required property index of each available property, which is the place of its name among the names given", keeps the first place where a name is given more than once (if (found->requiredPropertyIndex < 0)), and works the mapping out again for each data set it loads, so index i names the ith name given whatever order the data set holds. Names are compared without regard to case. A name the reloaded data set lacks keeps its index and gives no value.

On rc, DeviceDataHashDefault and IPIntelligenceDataHashDefault read every value by name, so each read pays a string lookup in the native layer, and nothing in Java holds the build-time order.

What changes

Head 3553c50, which is 52bcfd1 with rc merged in at 1c2c578 and then at ddb7d04. Links are at 52bcfd1, whose files the merges do not change, because they bring in only #98's data update service change, #80's web integration change and #99's cloud request change, none of which touches the on-premise engines.

  1. DeviceDetectionHashEngine and IPIntelligenceOnPremiseEngine each make one case-insensitive map from the names the engine was built with to their places in that list, in the constructor, before any data is loaded. The map is final and never rebuilt, so a reload cannot change it. A name given more than once keeps its first place, as C keeps it.
  2. The names come from the builder's own list, the one each builder passes to RequiredPropertiesConfigSwig on the line before (Device Detection lines 336 to 344, IP Intelligence lines 252 to 260). That vector becomes C's list one entry for each name, in order, with nothing split, trimmed or skipped (copyNamesFromArray, lines 254 to 275), so the place of a name in the list is its index in C. Nothing is read from the data set, and no detection is run to build the map. The earlier version of this draft ran a detection on empty Evidence at build to get a results object to ask for names, which for an engine built with names walked every component's graph.
  3. The map is not taken from RequiredPropertiesConfig::getProperties(). That method is const but, for a list given as one comma-separated string, runs strtok over the string in place (RequiredPropertiesConfig.cpp lines 91 to 108), so a second call returns only the first name and two threads cannot call it at once. Java builds the configuration from a vector, which takes the other branch, but the builder's list needs no native call at all.
  4. Every read by name in DeviceDataHashDefault and IPIntelligenceDataHashDefault looks the name up in that map and calls the index overload of the same getter, which src/common/ResultsBase.i already exposes and the generated Java wrapper already carries. Typed accessors go through AspectDataBase.getAs to DeviceDataBaseOnPremise.tryGetValue, which calls the same getValueAs... methods, so they are covered by the same change.
  5. A name that is not in the map is read by name, as on rc. That covers a property the engine was not built with, which already gives the missing property answer, and an engine built with no names, whose map is empty.

An engine built with no names is not changed here

Giving such an engine every property name in the data set at build was measured on this repository, in run 42902030477, at about 70 percent slower processing with MaxPerformance and three to seven times slower with LowMemory for a read of one property, so it is not done. The C layer is instead to keep the first load's property names, in their order, across every reload, in 51degrees-cxx#148, which merged into 51degrees-cxx rc at 09:36 UTC today as 1a92d51, 85 commits after the c6d4e4d9 this repository builds against. Reads on a no-names engine therefore stay by name here, and follow once this repository takes that change.

A name the reloaded data set lacks

Every name the engine was built with has an index, whether or not the data set loaded at the time has the property. A read by index of such a name gives no value, as a read by name does, and not an error. C++ reads by index through valueAsBoolAt and its neighbours (ResultsBase.cpp lines 151 to 185), which ask whether the property has values before reading any. For an engine built with names, PropertiesGetAvailableIndex gives -1 for "a name the data set does not have", and resultsHashGetHasValues answers false for an available index below 0, so the read reports no value with the invalid property reason. A read by name of the same property goes through getRequiredPropertyIndex, finds no index, -1, and takes the same path. C's values function does set an out of range error for such a property (hash.c lines 5834 to 5836), which is the error 51degrees-go#96 guards against, but the C++ layer this repository uses asks whether there are values first and never calls it for such a property. In Java, a read through get or a typed getter does not reach the index at all for such a name, because tryGetValue asks propertyIsAvailable, which looks the name up in the data set, first. getValues(String) on DeviceDataHashDefault reads by index with no such check, so the test reads through it.

PropertyIndex_NameTheReloadedFileLacks builds an engine on the TAC file with a name the Lite file lacks, found by comparing the two files' property lists, and IsMobile, reloads it from the Lite file, and requires the name to keep index 0, getValues of it to give no value with a message and no error, and IsMobile still to read true. The test pins how C behaves, so it has no red run. IP Intelligence reads through the same ResultsBase methods, which ask whether there are values first, but its own C below them is not checked here and no shrinking reload is tested for it.

Why no test of the reload rule can go red

C already gives an engine built with names the place of each name in the build-time list, and works it out again on each reload, so a read by name was already correct after a reload, and reading by index gives the same value. The tests of the rule therefore pass on rc as well, apart from needing getPropertyIndex, which this draft adds. The change for the rule is about cost, being the string lookup each read no longer pays. The earlier red run, 42933171724, was a compile failure for that reason and shows nothing about behaviour.

The name given twice is the one behaviour a test can show wrong. The builder drops only an exact repeat, so IsMobile and ismobile both reach C, which gives that property index 0. The earlier version of this draft kept the last place, 2.

The cost

A read by name against a read by index, through the native engine

PropertyIndex_ReadByIndexAgainstByName builds the native engine with the same four names from the Lite file, processes one iPhone User-Agent, and reads IsMobile 20,000 times by name, through getValueAsBool(Swig.asBytes("IsMobile")), the call every read by name made on rc, and 20,000 times by index, through getValueAsBool(int), from the same native results. It also reads IsMobile 20,000 times through the typed accessor, which is the whole path a caller takes and now goes by index. It does five rounds after a warm-up, requires every read to give the same value, and prints the lowest, median and highest time of one read. It runs in every job of the full build.

In the full build, run 43021207790 on the head, the medians of one read, with the lowest and highest of the five rounds in brackets, were as follows.

Full build job Read by name, ns Read by index, ns Typed read, ns
Ubuntu_Java_21 370 (361 to 624) 406 (272 to 479) 809 (566 to 1464)
Ubuntu_Java_11 443 (380 to 503) 267 (219 to 310) 784 (777 to 981)
Ubuntu_ARM_Java_17 308 (285 to 316) 182 (182 to 184) 444 (425 to 508)
Ubuntu_Java_8 363 (297 to 667) 213 (211 to 415) 537 (533 to 1287)
Ubuntu_Java_17 236 (191 to 431) 139 (123 to 253) 296 (276 to 817)
Windows_Java_11 286 (253 to 546) 259 (164 to 335) 540 (380 to 692)
Windows_Java_8 248 (181 to 386) 125 (121 to 251) 256 (232 to 793)
Windows_Java_17 458 (337 to 695) 355 (223 to 453) 825 (507 to 902)
Windows_Java_21 701 (339 to 748) 418 (211 to 464) 841 (692 to 925)

The median read by index was faster than the median read by name in eight of the nine jobs, by 9 to 50 percent. On Ubuntu Java 21 the median read by index was slower, 406 ns against 370 ns, although its fastest round, 272 ns, was faster than any round read by name, 361 ns at best. The spreads inside each job are wide, so one job's medians can cross. The three temporary runs on Ubuntu Java 17, in the table under How it was checked, each had the median read by index faster, for example 170 ns against 314 ns through the bytes overload.

The whole process, before and after

Run 42995350653 built this branch at 691ce2e, which has the change to the engines in full, and rc c77fb05 side by side on one ubuntu-latest runner with Java 17, and timed each in turn for each data file and profile, over 5,000 User-Agents from 20000 User Agents.csv. Each round times every measure in turn, and five rounds follow two warm-up rounds. The figures are the median nanoseconds for each User-Agent, or milliseconds for a build, with the lowest and highest of the five rounds in brackets. The checksum of every value read is the same in both trees, so they read the same values.

Engine built with the 4 names

Data file, profile Measure Before After Change
TAC, MaxPerformance Build, ms 44.0 (43.8 to 44.8) 44.1 (43.1 to 44.6) +0.2%
TAC, MaxPerformance Process only, ns 5,642 (5,568 to 6,944) 5,442 (5,332 to 6,923) -3.5%
TAC, MaxPerformance Process and typed IsMobile, ns 6,330 (6,121 to 7,460) 6,395 (5,903 to 7,533) +1.0%
TAC, MaxPerformance Process and IsMobile by name, ns 6,253 (6,167 to 7,922) 5,983 (5,903 to 7,599) -4.3%
TAC, MaxPerformance Process and all 4 names, ns 7,723 (7,527 to 11,502) 7,253 (7,182 to 10,005) -6.1%
TAC, LowMemory Build, ms 3.1 (3.0 to 4.1) 3.0 (2.9 to 3.3) -3.2%
TAC, LowMemory Process only, ns 28,586 (28,311 to 31,880) 29,299 (28,594 to 30,860) +2.5%
TAC, LowMemory Process and typed IsMobile, ns 32,853 (32,488 to 38,118) 33,674 (33,366 to 34,959) +2.5%
TAC, LowMemory Process and IsMobile by name, ns 33,462 (32,773 to 34,528) 33,836 (33,315 to 35,595) +1.1%
TAC, LowMemory Process and all 4 names, ns 46,671 (45,921 to 48,594) 47,083 (46,807 to 50,616) +0.9%
Lite, MaxPerformance Build, ms 14.5 (14.2 to 14.9) 15.0 (14.6 to 15.0) +3.4%
Lite, MaxPerformance Process only, ns 5,201 (5,162 to 6,797) 5,676 (5,434 to 6,667) +9.1%
Lite, MaxPerformance Process and typed IsMobile, ns 5,673 (5,621 to 7,817) 5,869 (5,648 to 7,807) +3.5%
Lite, MaxPerformance Process and IsMobile by name, ns 5,727 (5,594 to 7,438) 5,743 (5,709 to 8,036) +0.3%
Lite, MaxPerformance Process and all 4 names, ns 7,471 (7,234 to 9,438) 7,108 (6,935 to 9,052) -4.9%
Lite, LowMemory Build, ms 0.7 (0.6 to 1.0) 0.8 (0.7 to 1.2) +14.3%
Lite, LowMemory Process only, ns 27,409 (27,006 to 28,362) 27,517 (27,203 to 28,196) +0.4%
Lite, LowMemory Process and typed IsMobile, ns 31,657 (31,283 to 34,403) 32,014 (31,223 to 39,208) +1.1%
Lite, LowMemory Process and IsMobile by name, ns 32,007 (31,538 to 32,720) 31,947 (31,269 to 32,715) -0.2%
Lite, LowMemory Process and all 4 names, ns 44,759 (43,883 to 46,124) 43,865 (43,481 to 45,844) -2.0%

Engine built with no names

Data file, profile Measure Before After Change
TAC, MaxPerformance Build, ms 47.5 (47.1 to 48.2) 46.6 (46.4 to 47.1) -1.9%
TAC, MaxPerformance Process only, ns 2,808 (2,616 to 2,844) 1,592 (1,551 to 2,508) -43.3%
TAC, MaxPerformance Process and IsMobile by name, ns 5,779 (4,725 to 7,038) 3,628 (3,586 to 5,367) -37.2%
TAC, LowMemory Build, ms 211.4 (209.2 to 216.8) 212.2 (211.5 to 250.9) +0.4%
TAC, LowMemory Process only, ns 1,638 (1,610 to 2,174) 1,585 (1,566 to 2,026) -3.2%
TAC, LowMemory Process and IsMobile by name, ns 14,444 (14,228 to 14,461) 14,316 (14,282 to 14,383) -0.9%
Lite, MaxPerformance Build, ms 14.7 (14.6 to 14.9) 14.6 (14.4 to 14.9) -0.7%
Lite, MaxPerformance Process only, ns 1,475 (1,432 to 1,515) 1,563 (1,512 to 2,018) +6.0%
Lite, MaxPerformance Process and IsMobile by name, ns 3,386 (3,356 to 3,799) 3,594 (3,526 to 3,618) +6.1%
Lite, LowMemory Build, ms 1.2 (1.0 to 1.3) 0.9 (0.8 to 1.1) -25.0%
Lite, LowMemory Process only, ns 1,517 (1,473 to 1,548) 1,539 (1,533 to 2,036) +1.5%
Lite, LowMemory Process and IsMobile by name, ns 13,925 (13,880 to 14,457) 13,943 (13,811 to 14,391) +0.1%

These figures cannot show whether the change makes a process faster or slower. Processing alone reads no property and the change does not touch it, yet it moved between 3.5 percent faster and 9.1 percent slower for the named engine. The engine built with no names, whose path the change leaves alone apart from an empty map lookup, moved between 43 percent faster and 6 percent slower, with a narrow spread inside each tree. That is the difference between two JVMs and two builds on one runner, and it is larger than anything one string lookup can add or save, because a detection takes 5 to 30 microseconds and the native read above saves well under one. Reading all four names was 2.0 to 6.1 percent faster in three of the four cases and 0.9 percent slower on TAC LowMemory. Build time for the named engine moved by half a millisecond or less in every case, so taking the names from the builder's list costs nothing measurable.

Two earlier runs are not used. Run 42983051435 printed each line cut short, and run 42986828586 timed each measure in turn, so the first measure in each JVM ran before the JVM had warmed up and processing alone came out slower than processing and a read.

How it was checked

PropertyIndexTests is new in both on-premise modules. Device Detection has nine tests and IP Intelligence five.

  • The index of each name is its place in the build-time list, whatever order the data set holds.
  • The lookup ignores letter case.
  • A name given twice, in any letter case, has the index of its first place (Device Detection, IP Intelligence).
  • A name the engine was not built with has no index, so the read goes by name.
  • An engine built with no names has no indexes and reads by name.
  • In Device Detection, a read by name and a typed read give the same value, and an engine built with names in one order still reads every property correctly after a reload from a data file whose properties are a superset, which is a reload from the Lite file to the TAC file.
  • In Device Detection, a name the reloaded file lacks keeps its index and reads as no value, as above.
  • In Device Detection, the timing test above, which also requires every read by name, by index and through the typed accessor to give the same value.

Each run used a temporary workflow on a tmp/ branch that builds the reactor with the native libraries on Ubuntu with Java 17, fetches the data files and runs PropertyIndexTests in both modules.

Run Commit Result
Red 42982227400 at db1d837, the new tests with the earlier map Failed as expected in both modules, PropertyIndex_NameGivenTwiceHasFirstIndex "expected:<0> but was:<2>", 1 of 7 in Device Detection and 1 of 5 in IP Intelligence
Green, earlier map removed 42982517535, second attempt, at 691ce2e Passed, 7 Device Detection and 5 IP Intelligence tests. The first attempt hung fetching the data files and was cancelled
Green, timing 42991989917 at 83c0839 and 42998731376 at 567d225 Passed, 8 and 5. The first read by name through the String overload, 164 ns against 117 ns by index, and the second through the bytes overload a read by name really uses, 319 ns against 171 ns, both medians on Ubuntu Java 17
Green, all tests 43002310139 at 52bcfd1, the last commit before the rc merges Passed, 9 Device Detection and 5 IP Intelligence tests, none skipped, PropertyIndex_NameTheReloadedFileLacks among them. A read by name took 314 ns against 170 ns by index, medians on Ubuntu Java 17
Full build 43021207790 at 3553c50, the head Passed, all 9 jobs

The full build's 9 jobs without macOS all passed, being Ubuntu Java 8, 11, 17 and 21, Ubuntu ARM Java 17, and Windows Java 8, 11, 17 and 21. Every Setting 'PullRequestSha' line, in Configure and in each of the 9 jobs, is 3553c50, the head, and Configure printed "Left out 6 macOS entries". Each job printed the timing line above.

macOS was left out of this draft's full build under the 9 November decision. The rc head is built with macOS before it merges into main.

The branch has rc merged in three times, at c77fb05, which moves cxx to c6d4e4d9, at 1c2c578, which carries #98, and at ddb7d04, which carries #80 and #99.

Other open drafts

git merge-tree --write-tree of this head against every other open draft's head finds no conflict, apart from #43, #63, #67, #82 and #97, which conflict with rc itself, #82's being the cxx submodule. #98, #99 and #80 are merged into rc and are in this branch.

What remains

  • An engine built with no names reads by name until this repository takes 51degrees-cxx#148, as above.
  • #82 takes the 51degrees-cxx commit and merges last, so it is built after this one.
  • A read through get or a typed getter still asks containsProperty by name before it reads, so it now pays one name lookup where it paid two. Checking that by index as well would save the other, and is not done here.

Version

Patch only.

C gives a property the place of its name among the names given, the first
where the name is given more than once, and compares names without regard
to case. The builder drops only an exact repeat, so IsMobile and ismobile
both reach C. The engines' map keeps the last index for such a name, so
the new tests fail on it.

The test comments no longer say which test goes red or describe the
change.
…he first

Each engine now makes its map from the list of names its builder passes
to the native engine, in the constructor, before any data is loaded. That
is the list C takes the required property indexes from, one place for each
entry in the order given, so the map holds the same indexes without
running a detection on empty Evidence to get a results object, which for
an engine built with names walked every component's graph at build.

A name given more than once keeps its first place, as C keeps it. The map
is final and never rebuilt, and the native engine's required properties
configuration is no longer read back.
PropertyIndex_ReadByIndexAgainstByName builds the native engine with the
same names, reads IsMobile 20,000 times by name and 20,000 times by index
from the same native results, and 20,000 times through the typed
accessor, in five rounds after a warm-up. It requires every read to give
the same value and prints the lowest, median and highest time of one
read, so each build reports the figures for its platform.
A read by name with no index goes through getValueAsBool(Swig.asBytes(
name)), which is also how every read by name went on rc, so the timing
test now reads by name that way rather than through the String overload.
…amed with

The engine is built on the TAC file with a name the Lite file lacks, then
reloaded from the Lite file. The name keeps its index, a read of it by
index gives no value rather than an error, as a read by name does, and
IsMobile still reads its own value. C maps a named index to the data set
before it reads, and a name the data set lacks maps to -1, so the read
reports no value. The test pins that, so it has no red.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant