diff --git a/.gitignore b/.gitignore index f614a7e6..a83e8e5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ /target/ +/benchmarks/target/ *.log .claude/settings.local.json *.h5 +# benchmark targets (deployment-specific local paths + URLs, not source) +testpairs.tsv + # maven-shade artifact dependency-reduced-pom.xml @@ -11,4 +15,6 @@ nb-configuration.xml nbactions.xml nbactions-*.xml - +REVIEW.md +TODO.md +PLAN.md diff --git a/README.md b/README.md index be458976..01e5d395 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,16 @@ Core Library Jar Library mvn -Plib clean package ``` +## Benchmarks + +JMH benchmarks for the read path live in [`benchmarks/`](benchmarks/) as a standalone module: + +``` +mvn -DskipTests install # install BeakGraph into the local repo +cd benchmarks && mvn package # build the benchmarks jar +java -jar target/benchmarks.jar # run (see benchmarks/README.md for options) +``` + ## Using BeakGraph in your code ### Creating a BeakGraph from your data diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..373fbff7 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,80 @@ +# BeakGraph JMH Benchmarks + +[JMH](https://github.com/openjdk/jmh) benchmarks for the BeakGraph **read path**. Standalone +Maven project on purpose: it depends on the *installed* BeakGraph artifact, so benchmark code +never touches the main build or its shaded artifact. + +## Build + +```bash +# 1. Install the current BeakGraph into the local Maven repo (repository root) +mvn -DskipTests install + +# 2. Build the benchmarks jar (this directory) +cd benchmarks +mvn package +``` + +## Run + +```bash +# Everything (takes a while) +java -jar target/benchmarks.jar + +# One benchmark class / method, by regex +java -jar target/benchmarks.jar QueryBench +java -jar target/benchmarks.jar "BitPackedBufferBench.randomGet" + +# List what's available +java -jar target/benchmarks.jar -l +``` + +The first run of the store-backed benchmarks (`SelectBench`, `DictionaryBench`, `QueryBench`) +generates a deterministic synthetic store into `target/jmh-data/store-.h5` and +reuses it afterwards. Delete that directory (or `mvn clean`) to force a rebuild. + +### Useful knobs + +```bash +# Store size (subjects; the store holds 4 triples per subject + subjects/4 tagged quads) +java -jar target/benchmarks.jar QueryBench -p subjects=200000 + +# Quick-and-dirty iteration while developing (not for reported numbers) +java -jar target/benchmarks.jar QueryBench.pointLookup -f 1 -wi 2 -i 3 -p subjects=5000 + +# Allocation rates per op (watch this when attacking per-row garbage) +java -jar target/benchmarks.jar QueryBench.chainJoin -prof gc + +# Flame graphs, if async-profiler is installed +java -jar target/benchmarks.jar QueryBench.predicateScan -prof "async:output=flamegraph" + +# JSON results for before/after comparison +java -jar target/benchmarks.jar -rf json -rff results.json +``` + +For trustworthy numbers: plug in the laptop, close the browser, and prefer `-f 2` (two forks) +or more for anything you plan to quote. + +## What is measured + +| Class | Level | Targets | +|---|---|---| +| `BitPackedBufferBench` | primitive | `BitPackedUnSignedLongBuffer.get()` (random + sequential), `binarySearch`, the streaming decoder — the innermost decode loop everything else sits on | +| `SelectBench` | primitive | `select1` via the rank/select directory vs the linear fallback, plus the adjacent-pair pattern the iterators actually issue | +| `DictionaryBench` | dictionary | `locate` (entity/object/predicate hits, misses), `extract`, and the Caffeine-cached node-table path | +| `QueryBench` | end-to-end | SPARQL shapes: point lookup, star join, predicate scan, FILTER range pushdown, chain join (per-binding iterator reconstruction), `GRAPH ?g` scan, and a Graph-API full scan | + +Benchmarks are single-threaded by default; the store and its readers are safe for concurrent +reads, so `-t ` is a valid experiment for contention questions (the probe cursors are +deliberately racy — they only pick probe values). + +## Baseline discipline + +When working on a read-path optimization: + +1. Record a baseline first: `java -jar target/benchmarks.jar -rf json -rff baseline.json` +2. Make the change in the main project, then `mvn -DskipTests install` at the root and + `mvn package` here again (the jar embeds BeakGraph classes — rebuilding the benchmarks + jar is required to pick up main-project changes). +3. Re-run the same selection and compare against the baseline, ideally with `-prof gc` when + the change is about allocation. diff --git a/benchmarks/after.json b/benchmarks/after.json new file mode 100644 index 00000000..95e98d6b --- /dev/null +++ b/benchmarks/after.json @@ -0,0 +1,1306 @@ +[ + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.BitPackedBufferBench.binarySearch", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "width" : "13" + }, + "primaryMetric" : { + "score" : 5.328323009318994, + "scoreError" : 0.4997569415921271, + "scoreConfidence" : [ + 4.828566067726866, + 5.828079950911121 + ], + "scorePercentiles" : { + "0.0" : 5.167947723923546, + "50.0" : 5.3533926993731695, + "90.0" : 5.509508894943548, + "95.0" : 5.509508894943548, + "99.0" : 5.509508894943548, + "99.9" : 5.509508894943548, + "99.99" : 5.509508894943548, + "99.999" : 5.509508894943548, + "99.9999" : 5.509508894943548, + "100.0" : 5.509508894943548 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 5.509508894943548, + 5.167947723923546, + 5.245602685521753, + 5.3651630428329495, + 5.3533926993731695 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.BitPackedBufferBench.randomGet", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "width" : "13" + }, + "primaryMetric" : { + "score" : 4.960899741214621, + "scoreError" : 0.346234358923297, + "scoreConfidence" : [ + 4.6146653822913235, + 5.307134100137918 + ], + "scorePercentiles" : { + "0.0" : 4.878715284608903, + "50.0" : 4.937910538718751, + "90.0" : 5.094497629210883, + "95.0" : 5.094497629210883, + "99.0" : 5.094497629210883, + "99.9" : 5.094497629210883, + "99.99" : 5.094497629210883, + "99.999" : 5.094497629210883, + "99.9999" : 5.094497629210883, + "100.0" : 5.094497629210883 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 5.094497629210883, + 4.937910538718751, + 5.0050158770280735, + 4.878715284608903, + 4.888359376506492 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.BitPackedBufferBench.sequentialSumViaGet", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "width" : "13" + }, + "primaryMetric" : { + "score" : 2.827126637291048, + "scoreError" : 0.14889815511846216, + "scoreConfidence" : [ + 2.678228482172586, + 2.9760247924095102 + ], + "scorePercentiles" : { + "0.0" : 2.7930117489998803, + "50.0" : 2.816295623779297, + "90.0" : 2.8809872018285545, + "95.0" : 2.8809872018285545, + "99.0" : 2.8809872018285545, + "99.9" : 2.8809872018285545, + "99.99" : 2.8809872018285545, + "99.999" : 2.8809872018285545, + "99.9999" : 2.8809872018285545, + "100.0" : 2.8809872018285545 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 2.7930117489998803, + 2.816295623779297, + 2.8523268628476273, + 2.7930117489998803, + 2.8809872018285545 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.BitPackedBufferBench.sequentialSumViaStream", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "width" : "13" + }, + "primaryMetric" : { + "score" : 2.9288569370215183, + "scoreError" : 0.18205721505503933, + "scoreConfidence" : [ + 2.746799721966479, + 3.1109141520765577 + ], + "scorePercentiles" : { + "0.0" : 2.864829366078634, + "50.0" : 2.9571128703492344, + "90.0" : 2.969318295117491, + "95.0" : 2.969318295117491, + "99.0" : 2.969318295117491, + "99.9" : 2.969318295117491, + "99.99" : 2.969318295117491, + "99.999" : 2.969318295117491, + "99.9999" : 2.969318295117491, + "100.0" : 2.969318295117491 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 2.9571128703492344, + 2.961120074009379, + 2.8919040795528526, + 2.969318295117491, + 2.864829366078634 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.entityLocateHit", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 17.093041407296255, + "scoreError" : 0.9424939019793735, + "scoreConfidence" : [ + 16.150547505316883, + 18.035535309275627 + ], + "scorePercentiles" : { + "0.0" : 16.779127779383348, + "50.0" : 17.09862562780237, + "90.0" : 17.413706794683385, + "95.0" : 17.413706794683385, + "99.0" : 17.413706794683385, + "99.9" : 17.413706794683385, + "99.99" : 17.413706794683385, + "99.999" : 17.413706794683385, + "99.9999" : 17.413706794683385, + "100.0" : 17.413706794683385 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 16.948743520400573, + 17.09862562780237, + 17.225003314211598, + 16.779127779383348, + 17.413706794683385 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.extractEntity", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 72.96370384218977, + "scoreError" : 5.574082885119737, + "scoreConfidence" : [ + 67.38962095707004, + 78.53778672730951 + ], + "scorePercentiles" : { + "0.0" : 70.74497130211397, + "50.0" : 73.00233339345215, + "90.0" : 74.71736382719318, + "95.0" : 74.71736382719318, + "99.0" : 74.71736382719318, + "99.9" : 74.71736382719318, + "99.99" : 74.71736382719318, + "99.999" : 74.71736382719318, + "99.9999" : 74.71736382719318, + "100.0" : 74.71736382719318 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 70.74497130211397, + 73.00233339345215, + 72.79525929953114, + 74.71736382719318, + 73.5585913886584 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.extractObject", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 108.14778725923993, + "scoreError" : 10.391620509608645, + "scoreConfidence" : [ + 97.75616674963129, + 118.53940776884858 + ], + "scorePercentiles" : { + "0.0" : 104.2998222745544, + "50.0" : 108.87552008591153, + "90.0" : 110.795678755083, + "95.0" : 110.795678755083, + "99.0" : 110.795678755083, + "99.9" : 110.795678755083, + "99.99" : 110.795678755083, + "99.999" : 110.795678755083, + "99.9999" : 110.795678755083, + "100.0" : 110.795678755083 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 110.795678755083, + 108.87552008591153, + 110.21042761921144, + 104.2998222745544, + 106.55748756143934 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.locateMiss", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 13.475326645889556, + "scoreError" : 1.2143447894018062, + "scoreConfidence" : [ + 12.26098185648775, + 14.689671435291363 + ], + "scorePercentiles" : { + "0.0" : 13.124260503666685, + "50.0" : 13.352741500431314, + "90.0" : 13.947778351216831, + "95.0" : 13.947778351216831, + "99.0" : 13.947778351216831, + "99.9" : 13.947778351216831, + "99.99" : 13.947778351216831, + "99.999" : 13.947778351216831, + "99.9999" : 13.947778351216831, + "100.0" : 13.947778351216831 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 13.610695830207685, + 13.124260503666685, + 13.947778351216831, + 13.352741500431314, + 13.34115704392526 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.nodeTableLookup", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 20.513778145581277, + "scoreError" : 2.2839186189300507, + "scoreConfidence" : [ + 18.229859526651225, + 22.79769676451133 + ], + "scorePercentiles" : { + "0.0" : 19.97558265775591, + "50.0" : 20.560513280634847, + "90.0" : 21.441907485828047, + "95.0" : 21.441907485828047, + "99.0" : 21.441907485828047, + "99.9" : 21.441907485828047, + "99.99" : 21.441907485828047, + "99.999" : 21.441907485828047, + "99.9999" : 21.441907485828047, + "100.0" : 21.441907485828047 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 21.441907485828047, + 20.560513280634847, + 20.57704020193087, + 19.97558265775591, + 20.01384710175671 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.objectLocateHit", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 21.215856461281184, + "scoreError" : 1.8088997836918395, + "scoreConfidence" : [ + 19.406956677589346, + 23.024756244973023 + ], + "scorePercentiles" : { + "0.0" : 20.56231934977823, + "50.0" : 21.131122384476203, + "90.0" : 21.766624423920266, + "95.0" : 21.766624423920266, + "99.0" : 21.766624423920266, + "99.9" : 21.766624423920266, + "99.99" : 21.766624423920266, + "99.999" : 21.766624423920266, + "99.9999" : 21.766624423920266, + "100.0" : 21.766624423920266 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 21.131122384476203, + 21.766624423920266, + 20.56231934977823, + 21.057116577086834, + 21.5620995711444 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.predicateLocateHit", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 12.036731896417493, + "scoreError" : 0.6438169113783212, + "scoreConfidence" : [ + 11.392914985039171, + 12.680548807795814 + ], + "scorePercentiles" : { + "0.0" : 11.764343556450184, + "50.0" : 12.085678935883108, + "90.0" : 12.188468107573454, + "95.0" : 12.188468107573454, + "99.0" : 12.188468107573454, + "99.9" : 12.188468107573454, + "99.99" : 12.188468107573454, + "99.999" : 12.188468107573454, + "99.9999" : 12.188468107573454, + "100.0" : 12.188468107573454 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 11.764343556450184, + 12.003180851579586, + 12.085678935883108, + 12.188468107573454, + 12.14198803060113 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.chainJoin", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 88.46081554234432, + "scoreError" : 7.576259861793013, + "scoreConfidence" : [ + 80.8845556805513, + 96.03707540413734 + ], + "scorePercentiles" : { + "0.0" : 86.71447983382379, + "50.0" : 87.6770988356824, + "90.0" : 91.80050444831697, + "95.0" : 91.80050444831697, + "99.0" : 91.80050444831697, + "99.9" : 91.80050444831697, + "99.99" : 91.80050444831697, + "99.999" : 91.80050444831697, + "99.9999" : 91.80050444831697, + "100.0" : 91.80050444831697 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 88.46802227919724, + 87.64397231470124, + 87.6770988356824, + 86.71447983382379, + 91.80050444831697 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.fullScanFind", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 42786.73773043478, + "scoreError" : 4122.904048804436, + "scoreConfidence" : [ + 38663.83368163035, + 46909.64177923922 + ], + "scorePercentiles" : { + "0.0" : 41650.16, + "50.0" : 43025.94583333333, + "90.0" : 44317.79565217391, + "95.0" : 44317.79565217391, + "99.0" : 44317.79565217391, + "99.9" : 44317.79565217391, + "99.99" : 44317.79565217391, + "99.999" : 44317.79565217391, + "99.9999" : 44317.79565217391, + "100.0" : 44317.79565217391 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 41881.808, + 44317.79565217391, + 43057.979166666664, + 41650.16, + 43025.94583333333 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.graphVarScan", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 4548.40864923084, + "scoreError" : 1717.171946587696, + "scoreConfidence" : [ + 2831.236702643144, + 6265.580595818536 + ], + "scorePercentiles" : { + "0.0" : 4115.7196721311475, + "50.0" : 4628.084331797235, + "90.0" : 5178.097422680413, + "95.0" : 5178.097422680413, + "99.0" : 5178.097422680413, + "99.9" : 5178.097422680413, + "99.99" : 5178.097422680413, + "99.999" : 5178.097422680413, + "99.9999" : 5178.097422680413, + "100.0" : 5178.097422680413 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 5178.097422680413, + 4628.084331797235, + 4121.008641975309, + 4115.7196721311475, + 4699.133177570094 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.pointLookup", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 4.234648469464547, + "scoreError" : 0.22656592423025326, + "scoreConfidence" : [ + 4.008082545234294, + 4.4612143936948 + ], + "scorePercentiles" : { + "0.0" : 4.160761296537749, + "50.0" : 4.227462798781853, + "90.0" : 4.322635820097147, + "95.0" : 4.322635820097147, + "99.0" : 4.322635820097147, + "99.9" : 4.322635820097147, + "99.99" : 4.322635820097147, + "99.999" : 4.322635820097147, + "99.9999" : 4.322635820097147, + "100.0" : 4.322635820097147 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 4.160761296537749, + 4.227462798781853, + 4.322635820097147, + 4.2142911269307355, + 4.248091304975255 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.predicateScan", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 18799.01896249958, + "scoreError" : 2296.5389455474606, + "scoreConfidence" : [ + 16502.48001695212, + 21095.55790804704 + ], + "scorePercentiles" : { + "0.0" : 18327.96, + "50.0" : 18635.233333333334, + "90.0" : 19777.176470588234, + "95.0" : 19777.176470588234, + "99.0" : 19777.176470588234, + "99.9" : 19777.176470588234, + "99.99" : 19777.176470588234, + "99.999" : 19777.176470588234, + "99.9999" : 19777.176470588234, + "100.0" : 19777.176470588234 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 19777.176470588234, + 18635.233333333334, + 18327.96, + 18345.987272727274, + 18908.737735849056 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.rangeFilter", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 96.81828666269104, + "scoreError" : 14.102255682983545, + "scoreConfidence" : [ + 82.7160309797075, + 110.92054234567458 + ], + "scorePercentiles" : { + "0.0" : 91.97920764776174, + "50.0" : 97.4371083868457, + "90.0" : 101.82273975388996, + "95.0" : 101.82273975388996, + "99.0" : 101.82273975388996, + "99.9" : 101.82273975388996, + "99.99" : 101.82273975388996, + "99.999" : 101.82273975388996, + "99.9999" : 101.82273975388996, + "100.0" : 101.82273975388996 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 91.97920764776174, + 94.92645051194539, + 101.82273975388996, + 97.92592701301243, + 97.4371083868457 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.starJoin", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 6.894986148751185, + "scoreError" : 0.5407437679295438, + "scoreConfidence" : [ + 6.354242380821641, + 7.435729916680729 + ], + "scorePercentiles" : { + "0.0" : 6.671410497999081, + "50.0" : 6.9201506891016775, + "90.0" : 7.053139388127789, + "95.0" : 7.053139388127789, + "99.0" : 7.053139388127789, + "99.9" : 7.053139388127789, + "99.99" : 7.053139388127789, + "99.999" : 7.053139388127789, + "99.9999" : 7.053139388127789, + "100.0" : 7.053139388127789 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 6.880189529134259, + 6.9201506891016775, + 7.053139388127789, + 6.671410497999081, + 6.9500406393931184 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.adjacentPair", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 215.19250353148442, + "scoreError" : 8.563931636994601, + "scoreConfidence" : [ + 206.62857189448982, + 223.75643516847902 + ], + "scorePercentiles" : { + "0.0" : 211.659323472634, + "50.0" : 215.4722832679748, + "90.0" : 217.79014408985125, + "95.0" : 217.79014408985125, + "99.0" : 217.79014408985125, + "99.9" : 217.79014408985125, + "99.99" : 217.79014408985125, + "99.999" : 217.79014408985125, + "99.9999" : 217.79014408985125, + "100.0" : 217.79014408985125 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 211.659323472634, + 215.4722832679748, + 215.1592885536016, + 217.79014408985125, + 215.88147827336056 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.directorySelect1", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 112.3289637324965, + "scoreError" : 5.86242966299423, + "scoreConfidence" : [ + 106.46653406950227, + 118.19139339549073 + ], + "scorePercentiles" : { + "0.0" : 109.89971806349395, + "50.0" : 112.50365189866169, + "90.0" : 113.97632413589174, + "95.0" : 113.97632413589174, + "99.0" : 113.97632413589174, + "99.9" : 113.97632413589174, + "99.99" : 113.97632413589174, + "99.999" : 113.97632413589174, + "99.9999" : 113.97632413589174, + "100.0" : 113.97632413589174 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 109.89971806349395, + 112.50365189866169, + 113.10244432034904, + 112.16268024408603, + 113.97632413589174 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.linearSelect1", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 1792.4609188348404, + "scoreError" : 103.12933918614627, + "scoreConfidence" : [ + 1689.331579648694, + 1895.5902580209868 + ], + "scorePercentiles" : { + "0.0" : 1759.7531711264755, + "50.0" : 1788.3589813281644, + "90.0" : 1826.2539408004204, + "95.0" : 1826.2539408004204, + "99.0" : 1826.2539408004204, + "99.9" : 1826.2539408004204, + "99.99" : 1826.2539408004204, + "99.999" : 1826.2539408004204, + "99.9999" : 1826.2539408004204, + "100.0" : 1826.2539408004204 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 1811.8023449615212, + 1776.1361559576217, + 1788.3589813281644, + 1826.2539408004204, + 1759.7531711264755 + ] + ] + }, + "secondaryMetrics" : { + } + } +] + + diff --git a/benchmarks/baseline.json b/benchmarks/baseline.json new file mode 100644 index 00000000..679a76a9 --- /dev/null +++ b/benchmarks/baseline.json @@ -0,0 +1,1306 @@ +[ + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.BitPackedBufferBench.binarySearch", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "width" : "13" + }, + "primaryMetric" : { + "score" : 8.417986765159956, + "scoreError" : 1.7503841183399163, + "scoreConfidence" : [ + 6.66760264682004, + 10.168370883499872 + ], + "scorePercentiles" : { + "0.0" : 8.051196888488203, + "50.0" : 8.254340326280627, + "90.0" : 9.205977227077003, + "95.0" : 9.205977227077003, + "99.0" : 9.205977227077003, + "99.9" : 9.205977227077003, + "99.99" : 9.205977227077003, + "99.999" : 9.205977227077003, + "99.9999" : 9.205977227077003, + "100.0" : 9.205977227077003 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 8.051196888488203, + 8.363780502112558, + 8.254340326280627, + 8.214638881841381, + 9.205977227077003 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.BitPackedBufferBench.randomGet", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "width" : "13" + }, + "primaryMetric" : { + "score" : 8.996661391526036, + "scoreError" : 0.2820998800600209, + "scoreConfidence" : [ + 8.714561511466016, + 9.278761271586056 + ], + "scorePercentiles" : { + "0.0" : 8.931850734436093, + "50.0" : 8.99239682515628, + "90.0" : 9.113847261570516, + "95.0" : 9.113847261570516, + "99.0" : 9.113847261570516, + "99.9" : 9.113847261570516, + "99.99" : 9.113847261570516, + "99.999" : 9.113847261570516, + "99.9999" : 9.113847261570516, + "100.0" : 9.113847261570516 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 9.113847261570516, + 8.931850734436093, + 8.93825554463549, + 9.006956591831804, + 8.99239682515628 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.BitPackedBufferBench.sequentialSumViaGet", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "width" : "13" + }, + "primaryMetric" : { + "score" : 3.609339617682785, + "scoreError" : 0.2558111990135419, + "scoreConfidence" : [ + 3.353528418669243, + 3.8651508166963273 + ], + "scorePercentiles" : { + "0.0" : 3.540106172914858, + "50.0" : 3.5937936682450142, + "90.0" : 3.7089240643405175, + "95.0" : 3.7089240643405175, + "99.0" : 3.7089240643405175, + "99.9" : 3.7089240643405175, + "99.99" : 3.7089240643405175, + "99.999" : 3.7089240643405175, + "99.9999" : 3.7089240643405175, + "100.0" : 3.7089240643405175 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 3.5659373696170635, + 3.540106172914858, + 3.637936813296474, + 3.7089240643405175, + 3.5937936682450142 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.BitPackedBufferBench.sequentialSumViaStream", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "width" : "13" + }, + "primaryMetric" : { + "score" : 2.9117647815452394, + "scoreError" : 0.1196585638129387, + "scoreConfidence" : [ + 2.7921062177323006, + 3.0314233453581783 + ], + "scorePercentiles" : { + "0.0" : 2.87811296531953, + "50.0" : 2.904969653097692, + "90.0" : 2.9559415063740295, + "95.0" : 2.9559415063740295, + "99.0" : 2.9559415063740295, + "99.9" : 2.9559415063740295, + "99.99" : 2.9559415063740295, + "99.999" : 2.9559415063740295, + "99.9999" : 2.9559415063740295, + "100.0" : 2.9559415063740295 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 2.9289452672369256, + 2.87811296531953, + 2.9559415063740295, + 2.904969653097692, + 2.8908545156980208 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.entityLocateHit", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 1684.7865993679777, + "scoreError" : 86.73942887819896, + "scoreConfidence" : [ + 1598.0471704897786, + 1771.5260282461768 + ], + "scorePercentiles" : { + "0.0" : 1669.4175121633093, + "50.0" : 1676.3967523227589, + "90.0" : 1723.6147816349385, + "95.0" : 1723.6147816349385, + "99.0" : 1723.6147816349385, + "99.9" : 1723.6147816349385, + "99.99" : 1723.6147816349385, + "99.999" : 1723.6147816349385, + "99.9999" : 1723.6147816349385, + "100.0" : 1723.6147816349385 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 1676.3967523227589, + 1669.4175121633093, + 1684.3901552503423, + 1670.1137954685407, + 1723.6147816349385 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.extractEntity", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 355.2422629476702, + "scoreError" : 19.65049083722341, + "scoreConfidence" : [ + 335.5917721104468, + 374.8927537848936 + ], + "scorePercentiles" : { + "0.0" : 348.13700576569005, + "50.0" : 354.1818702836947, + "90.0" : 360.8362746214278, + "95.0" : 360.8362746214278, + "99.0" : 360.8362746214278, + "99.9" : 360.8362746214278, + "99.99" : 360.8362746214278, + "99.999" : 360.8362746214278, + "99.9999" : 360.8362746214278, + "100.0" : 360.8362746214278 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 353.53136393877026, + 354.1818702836947, + 359.5248001287684, + 360.8362746214278, + 348.13700576569005 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.extractObject", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 416.0571117224431, + "scoreError" : 15.352544953479718, + "scoreConfidence" : [ + 400.7045667689634, + 431.4096566759228 + ], + "scorePercentiles" : { + "0.0" : 410.070147119164, + "50.0" : 415.9528713665485, + "90.0" : 419.9990429513366, + "95.0" : 419.9990429513366, + "99.0" : 419.9990429513366, + "99.9" : 419.9990429513366, + "99.99" : 419.9990429513366, + "99.999" : 419.9990429513366, + "99.9999" : 419.9990429513366, + "100.0" : 419.9990429513366 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 419.9990429513366, + 414.90893697894984, + 410.070147119164, + 415.9528713665485, + 419.35456019621626 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.locateMiss", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 1891.5505938933275, + "scoreError" : 137.7096624757006, + "scoreConfidence" : [ + 1753.840931417627, + 2029.260256369028 + ], + "scorePercentiles" : { + "0.0" : 1868.3544658206206, + "50.0" : 1870.5502458004805, + "90.0" : 1951.5816455819574, + "95.0" : 1951.5816455819574, + "99.0" : 1951.5816455819574, + "99.9" : 1951.5816455819574, + "99.99" : 1951.5816455819574, + "99.999" : 1951.5816455819574, + "99.9999" : 1951.5816455819574, + "100.0" : 1951.5816455819574 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 1951.5816455819574, + 1897.9114635127646, + 1869.3551487508141, + 1868.3544658206206, + 1870.5502458004805 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.nodeTableLookup", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 18.813907275695225, + "scoreError" : 12.379317408944903, + "scoreConfidence" : [ + 6.434589866750322, + 31.19322468464013 + ], + "scorePercentiles" : { + "0.0" : 13.068656234358942, + "50.0" : 20.260929536793014, + "90.0" : 20.381149579553842, + "95.0" : 20.381149579553842, + "99.0" : 20.381149579553842, + "99.9" : 20.381149579553842, + "99.99" : 20.381149579553842, + "99.999" : 20.381149579553842, + "99.9999" : 20.381149579553842, + "100.0" : 20.381149579553842 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 20.34427404728362, + 20.260929536793014, + 20.014526980486725, + 20.381149579553842, + 13.068656234358942 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.objectLocateHit", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 3500.375195874962, + "scoreError" : 182.3717770924489, + "scoreConfidence" : [ + 3318.003418782513, + 3682.7469729674112 + ], + "scorePercentiles" : { + "0.0" : 3443.984142306992, + "50.0" : 3505.6900488939323, + "90.0" : 3558.9909471346828, + "95.0" : 3558.9909471346828, + "99.0" : 3558.9909471346828, + "99.9" : 3558.9909471346828, + "99.99" : 3558.9909471346828, + "99.999" : 3558.9909471346828, + "99.9999" : 3558.9909471346828, + "100.0" : 3558.9909471346828 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 3443.984142306992, + 3558.9909471346828, + 3530.5168544084017, + 3505.6900488939323, + 3462.693986630803 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.predicateLocateHit", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 307.1625189037446, + "scoreError" : 22.278822655597303, + "scoreConfidence" : [ + 284.8836962481473, + 329.44134155934194 + ], + "scorePercentiles" : { + "0.0" : 299.46985801328736, + "50.0" : 306.1722813745512, + "90.0" : 314.4517474278717, + "95.0" : 314.4517474278717, + "99.0" : 314.4517474278717, + "99.9" : 314.4517474278717, + "99.99" : 314.4517474278717, + "99.999" : 314.4517474278717, + "99.9999" : 314.4517474278717, + "100.0" : 314.4517474278717 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 299.46985801328736, + 314.4517474278717, + 310.9928434138452, + 304.7258642891676, + 306.1722813745512 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.chainJoin", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 351.90539969004084, + "scoreError" : 85.7768356915645, + "scoreConfidence" : [ + 266.12856399847635, + 437.6822353816053 + ], + "scorePercentiles" : { + "0.0" : 326.7116917047681, + "50.0" : 348.25048712595685, + "90.0" : 385.73416795069335, + "95.0" : 385.73416795069335, + "99.0" : 385.73416795069335, + "99.9" : 385.73416795069335, + "99.99" : 385.73416795069335, + "99.999" : 385.73416795069335, + "99.9999" : 385.73416795069335, + "100.0" : 385.73416795069335 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 385.73416795069335, + 358.90236643958406, + 339.92828522920206, + 326.7116917047681, + 348.25048712595685 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.fullScanFind", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 40143.70718717948, + "scoreError" : 7652.428869870908, + "scoreConfidence" : [ + 32491.278317308577, + 47796.13605705039 + ], + "scorePercentiles" : { + "0.0" : 37406.03333333333, + "50.0" : 40045.292, + "90.0" : 42981.34583333333, + "95.0" : 42981.34583333333, + "99.0" : 42981.34583333333, + "99.9" : 42981.34583333333, + "99.99" : 42981.34583333333, + "99.999" : 42981.34583333333, + "99.9999" : 42981.34583333333, + "100.0" : 42981.34583333333 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 40045.292, + 39795.88076923077, + 37406.03333333333, + 40489.984, + 42981.34583333333 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.graphVarScan", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 4772.738144405892, + "scoreError" : 1498.984983858856, + "scoreConfidence" : [ + 3273.753160547036, + 6271.723128264748 + ], + "scorePercentiles" : { + "0.0" : 4321.103879310344, + "50.0" : 4608.55504587156, + "90.0" : 5213.867708333333, + "95.0" : 5213.867708333333, + "99.0" : 5213.867708333333, + "99.9" : 5213.867708333333, + "99.99" : 5213.867708333333, + "99.999" : 5213.867708333333, + "99.9999" : 5213.867708333333, + "100.0" : 5213.867708333333 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 5213.867708333333, + 5147.638974358974, + 4572.525114155251, + 4321.103879310344, + 4608.55504587156 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.pointLookup", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 7.312007397209243, + "scoreError" : 0.8416332792053324, + "scoreConfidence" : [ + 6.47037411800391, + 8.153640676414575 + ], + "scorePercentiles" : { + "0.0" : 6.992098290747667, + "50.0" : 7.295917102826902, + "90.0" : 7.530625032910326, + "95.0" : 7.530625032910326, + "99.0" : 7.530625032910326, + "99.9" : 7.530625032910326, + "99.99" : 7.530625032910326, + "99.999" : 7.530625032910326, + "99.9999" : 7.530625032910326, + "100.0" : 7.530625032910326 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 7.500711439954467, + 7.530625032910326, + 7.295917102826902, + 7.240685119606852, + 6.992098290747667 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.predicateScan", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 17388.383288843335, + "scoreError" : 4349.041691778818, + "scoreConfidence" : [ + 13039.341597064518, + 21737.424980622152 + ], + "scorePercentiles" : { + "0.0" : 16649.31475409836, + "50.0" : 17000.998333333333, + "90.0" : 19383.19245283019, + "95.0" : 19383.19245283019, + "99.0" : 19383.19245283019, + "99.9" : 19383.19245283019, + "99.99" : 19383.19245283019, + "99.999" : 19383.19245283019, + "99.9999" : 19383.19245283019, + "100.0" : 19383.19245283019 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 17000.998333333333, + 17112.154237288134, + 19383.19245283019, + 16796.256666666668, + 16649.31475409836 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.rangeFilter", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 106.45288506423344, + "scoreError" : 15.065164798825204, + "scoreConfidence" : [ + 91.38772026540823, + 121.51804986305865 + ], + "scorePercentiles" : { + "0.0" : 102.88756162695152, + "50.0" : 104.82256512187467, + "90.0" : 111.89040545068693, + "95.0" : 111.89040545068693, + "99.0" : 111.89040545068693, + "99.9" : 111.89040545068693, + "99.99" : 111.89040545068693, + "99.999" : 111.89040545068693, + "99.9999" : 111.89040545068693, + "100.0" : 111.89040545068693 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 111.89040545068693, + 109.1833823689659, + 104.82256512187467, + 102.88756162695152, + 103.48051075268818 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.starJoin", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 14.787364323109651, + "scoreError" : 3.4627872643166504, + "scoreConfidence" : [ + 11.324577058793, + 18.250151587426302 + ], + "scorePercentiles" : { + "0.0" : 14.175302220773515, + "50.0" : 14.327834166034387, + "90.0" : 16.28638808978172, + "95.0" : 16.28638808978172, + "99.0" : 16.28638808978172, + "99.9" : 16.28638808978172, + "99.99" : 16.28638808978172, + "99.999" : 16.28638808978172, + "99.9999" : 16.28638808978172, + "100.0" : 16.28638808978172 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 14.179585132743362, + 14.175302220773515, + 14.327834166034387, + 14.967712006215264, + 16.28638808978172 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.adjacentPair", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 233.83390032289486, + "scoreError" : 17.92078865621736, + "scoreConfidence" : [ + 215.9131116666775, + 251.75468897911222 + ], + "scorePercentiles" : { + "0.0" : 230.00369434347567, + "50.0" : 232.51780773778978, + "90.0" : 241.90984612721832, + "95.0" : 241.90984612721832, + "99.0" : 241.90984612721832, + "99.9" : 241.90984612721832, + "99.99" : 241.90984612721832, + "99.999" : 241.90984612721832, + "99.9999" : 241.90984612721832, + "100.0" : 241.90984612721832 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 231.76785989905784, + 232.97029350693262, + 232.51780773778978, + 230.00369434347567, + 241.90984612721832 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.directorySelect1", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 123.68280013791018, + "scoreError" : 15.367170421156654, + "scoreConfidence" : [ + 108.31562971675352, + 139.04997055906682 + ], + "scorePercentiles" : { + "0.0" : 118.61297066196192, + "50.0" : 123.5261872086936, + "90.0" : 129.7441344858954, + "95.0" : 129.7441344858954, + "99.0" : 129.7441344858954, + "99.9" : 129.7441344858954, + "99.99" : 129.7441344858954, + "99.999" : 129.7441344858954, + "99.9999" : 129.7441344858954, + "100.0" : 129.7441344858954 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 118.61297066196192, + 122.59822796573368, + 123.5261872086936, + 123.93248036726636, + 129.7441344858954 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.linearSelect1", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 1794.3239271272364, + "scoreError" : 114.97221616268055, + "scoreConfidence" : [ + 1679.3517109645559, + 1909.296143289917 + ], + "scorePercentiles" : { + "0.0" : 1747.5548708034457, + "50.0" : 1803.4529759200364, + "90.0" : 1822.053386370011, + "95.0" : 1822.053386370011, + "99.0" : 1822.053386370011, + "99.9" : 1822.053386370011, + "99.99" : 1822.053386370011, + "99.999" : 1822.053386370011, + "99.9999" : 1822.053386370011, + "100.0" : 1822.053386370011 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 1814.726933391317, + 1783.8314691513706, + 1803.4529759200364, + 1822.053386370011, + 1747.5548708034457 + ] + ] + }, + "secondaryMetrics" : { + } + } +] + + diff --git a/benchmarks/nodeid-after.json b/benchmarks/nodeid-after.json new file mode 100644 index 00000000..b916e2bb --- /dev/null +++ b/benchmarks/nodeid-after.json @@ -0,0 +1,1182 @@ +[ + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.entityLocateHit", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 17.33324011811498, + "scoreError" : 0.6091828422868658, + "scoreConfidence" : [ + 16.724057275828113, + 17.942422960401846 + ], + "scorePercentiles" : { + "0.0" : 17.214316453209985, + "50.0" : 17.29759512318884, + "90.0" : 17.599974026088628, + "95.0" : 17.599974026088628, + "99.0" : 17.599974026088628, + "99.9" : 17.599974026088628, + "99.99" : 17.599974026088628, + "99.999" : 17.599974026088628, + "99.9999" : 17.599974026088628, + "100.0" : 17.599974026088628 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 17.599974026088628, + 17.29759512318884, + 17.214316453209985, + 17.216901109988694, + 17.33741387809875 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.extractEntity", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 66.64489406106432, + "scoreError" : 18.251154881767853, + "scoreConfidence" : [ + 48.39373917929647, + 84.89604894283218 + ], + "scorePercentiles" : { + "0.0" : 61.84328746248154, + "50.0" : 66.51300443744968, + "90.0" : 74.31617901186985, + "95.0" : 74.31617901186985, + "99.0" : 74.31617901186985, + "99.9" : 74.31617901186985, + "99.99" : 74.31617901186985, + "99.999" : 74.31617901186985, + "99.9999" : 74.31617901186985, + "100.0" : 74.31617901186985 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 74.31617901186985, + 61.84328746248154, + 63.828176101623114, + 66.72382329189739, + 66.51300443744968 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.extractObject", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 84.19928039744994, + "scoreError" : 5.462032237204854, + "scoreConfidence" : [ + 78.73724816024509, + 89.66131263465479 + ], + "scorePercentiles" : { + "0.0" : 82.95278230133967, + "50.0" : 83.38571057860912, + "90.0" : 85.93367799241732, + "95.0" : 85.93367799241732, + "99.0" : 85.93367799241732, + "99.9" : 85.93367799241732, + "99.99" : 85.93367799241732, + "99.999" : 85.93367799241732, + "99.9999" : 85.93367799241732, + "100.0" : 85.93367799241732 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 85.93367799241732, + 82.95278230133967, + 83.18495007417361, + 83.38571057860912, + 85.53928104070997 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.locateMiss", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 12.714038733298525, + "scoreError" : 0.6193696252729574, + "scoreConfidence" : [ + 12.094669108025567, + 13.333408358571482 + ], + "scorePercentiles" : { + "0.0" : 12.542737725585633, + "50.0" : 12.703631409018634, + "90.0" : 12.880470193845774, + "95.0" : 12.880470193845774, + "99.0" : 12.880470193845774, + "99.9" : 12.880470193845774, + "99.99" : 12.880470193845774, + "99.999" : 12.880470193845774, + "99.9999" : 12.880470193845774, + "100.0" : 12.880470193845774 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 12.542737725585633, + 12.569656998896559, + 12.87369733914602, + 12.703631409018634, + 12.880470193845774 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.objectLocateHit", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 21.336984423418915, + "scoreError" : 1.3231878681840434, + "scoreConfidence" : [ + 20.013796555234872, + 22.660172291602958 + ], + "scorePercentiles" : { + "0.0" : 20.74314664276648, + "50.0" : 21.41189156883335, + "90.0" : 21.62741409570835, + "95.0" : 21.62741409570835, + "99.0" : 21.62741409570835, + "99.9" : 21.62741409570835, + "99.99" : 21.62741409570835, + "99.999" : 21.62741409570835, + "99.9999" : 21.62741409570835, + "100.0" : 21.62741409570835 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 21.41189156883335, + 21.408750669826155, + 20.74314664276648, + 21.62741409570835, + 21.493719139960245 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.DictionaryBench.predicateLocateHit", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 11.011804003496426, + "scoreError" : 0.6030875308103778, + "scoreConfidence" : [ + 10.408716472686049, + 11.614891534306803 + ], + "scorePercentiles" : { + "0.0" : 10.836640745431177, + "50.0" : 11.082727705288082, + "90.0" : 11.19385426423797, + "95.0" : 11.19385426423797, + "99.0" : 11.19385426423797, + "99.9" : 11.19385426423797, + "99.99" : 11.19385426423797, + "99.999" : 11.19385426423797, + "99.9999" : 11.19385426423797, + "100.0" : 11.19385426423797 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 10.85836196651387, + 10.836640745431177, + 11.087435336011033, + 11.082727705288082, + 11.19385426423797 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.chainJoin", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 82.19148428734925, + "scoreError" : 3.462494199228429, + "scoreConfidence" : [ + 78.72899008812082, + 85.65397848657769 + ], + "scorePercentiles" : { + "0.0" : 81.02759290745689, + "50.0" : 82.3925249032683, + "90.0" : 83.30842560985764, + "95.0" : 83.30842560985764, + "99.0" : 83.30842560985764, + "99.9" : 83.30842560985764, + "99.99" : 83.30842560985764, + "99.999" : 83.30842560985764, + "99.9999" : 83.30842560985764, + "100.0" : 83.30842560985764 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 82.3925249032683, + 83.30842560985764, + 81.57604175161053, + 81.02759290745689, + 82.65283626455289 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.distinctObjects", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 112617.35844444444, + "scoreError" : 7845.773135867151, + "scoreConfidence" : [ + 104771.5853085773, + 120463.13158031159 + ], + "scorePercentiles" : { + "0.0" : 109696.97, + "50.0" : 112906.02222222222, + "90.0" : 115366.62222222223, + "95.0" : 115366.62222222223, + "99.0" : 115366.62222222223, + "99.9" : 115366.62222222223, + "99.99" : 115366.62222222223, + "99.999" : 115366.62222222223, + "99.9999" : 115366.62222222223, + "100.0" : 115366.62222222223 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 112095.64444444445, + 115366.62222222223, + 112906.02222222222, + 113021.53333333334, + 109696.97 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.distinctSubjects", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 3923.4398345208124, + "scoreError" : 324.2835445034215, + "scoreConfidence" : [ + 3599.156290017391, + 4247.7233790242335 + ], + "scorePercentiles" : { + "0.0" : 3833.963601532567, + "50.0" : 3895.4315175097277, + "90.0" : 4047.740322580645, + "95.0" : 4047.740322580645, + "99.0" : 4047.740322580645, + "99.9" : 4047.740322580645, + "99.99" : 4047.740322580645, + "99.999" : 4047.740322580645, + "99.9999" : 4047.740322580645, + "100.0" : 4047.740322580645 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 4047.740322580645, + 3965.334387351779, + 3874.7293436293435, + 3833.963601532567, + 3895.4315175097277 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.fullScanFind", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 26835.46528459157, + "scoreError" : 6099.2874868212375, + "scoreConfidence" : [ + 20736.17779777033, + 32934.75277141281 + ], + "scorePercentiles" : { + "0.0" : 25813.502564102564, + "50.0" : 26106.702564102565, + "90.0" : 29621.50294117647, + "95.0" : 29621.50294117647, + "99.0" : 29621.50294117647, + "99.9" : 29621.50294117647, + "99.99" : 29621.50294117647, + "99.999" : 29621.50294117647, + "99.9999" : 29621.50294117647, + "100.0" : 29621.50294117647 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 26106.702564102565, + 29621.50294117647, + 26032.902564102566, + 25813.502564102564, + 26602.715789473685 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.fullScanSparql", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 83786.17717948717, + "scoreError" : 5936.036564111485, + "scoreConfidence" : [ + 77850.14061537568, + 89722.21374359867 + ], + "scorePercentiles" : { + "0.0" : 82603.56153846154, + "50.0" : 83216.56923076924, + "90.0" : 86343.425, + "95.0" : 86343.425, + "99.0" : 86343.425, + "99.9" : 86343.425, + "99.99" : 86343.425, + "99.999" : 86343.425, + "99.9999" : 86343.425, + "100.0" : 86343.425 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 82705.73846153847, + 82603.56153846154, + 84061.59166666666, + 86343.425, + 83216.56923076924 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.graphVarScan", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 3357.180362657892, + "scoreError" : 156.43639327138078, + "scoreConfidence" : [ + 3200.7439693865113, + 3513.616755929273 + ], + "scorePercentiles" : { + "0.0" : 3317.2920529801327, + "50.0" : 3339.7066666666665, + "90.0" : 3403.197288135593, + "95.0" : 3403.197288135593, + "99.0" : 3403.197288135593, + "99.9" : 3403.197288135593, + "99.99" : 3403.197288135593, + "99.999" : 3403.197288135593, + "99.9999" : 3403.197288135593, + "100.0" : 3403.197288135593 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 3403.197288135593, + 3398.3745762711865, + 3339.7066666666665, + 3327.3312292358805, + 3317.2920529801327 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.pointLookup", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 4.039800652615801, + "scoreError" : 0.4144776487300983, + "scoreConfidence" : [ + 3.6253230038857027, + 4.4542783013459 + ], + "scorePercentiles" : { + "0.0" : 3.919321428151562, + "50.0" : 4.018717701426562, + "90.0" : 4.192023365053824, + "95.0" : 4.192023365053824, + "99.0" : 4.192023365053824, + "99.9" : 4.192023365053824, + "99.99" : 4.192023365053824, + "99.999" : 4.192023365053824, + "99.9999" : 4.192023365053824, + "100.0" : 4.192023365053824 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 4.018717701426562, + 4.192023365053824, + 3.919321428151562, + 4.098329211974322, + 3.970611556472736 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.predicateScan", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 18254.777044662398, + "scoreError" : 6226.844753737822, + "scoreConfidence" : [ + 12027.932290924575, + 24481.62179840022 + ], + "scorePercentiles" : { + "0.0" : 16515.249180327868, + "50.0" : 17751.031578947368, + "90.0" : 20604.536734693876, + "95.0" : 20604.536734693876, + "99.0" : 20604.536734693876, + "99.9" : 20604.536734693876, + "99.99" : 20604.536734693876, + "99.999" : 20604.536734693876, + "99.9999" : 20604.536734693876, + "100.0" : 20604.536734693876 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 17292.362068965518, + 17751.031578947368, + 20604.536734693876, + 19110.70566037736, + 16515.249180327868 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.rangeFilter", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 78.79045786359441, + "scoreError" : 5.7920423857502055, + "scoreConfidence" : [ + 72.9984154778442, + 84.58250024934462 + ], + "scorePercentiles" : { + "0.0" : 77.6402216023555, + "50.0" : 78.25999061179785, + "90.0" : 81.27934782608696, + "95.0" : 81.27934782608696, + "99.0" : 81.27934782608696, + "99.9" : 81.27934782608696, + "99.99" : 81.27934782608696, + "99.999" : 81.27934782608696, + "99.9999" : 81.27934782608696, + "100.0" : 81.27934782608696 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 81.27934782608696, + 77.70644735821243, + 78.25999061179785, + 77.6402216023555, + 79.06628191951933 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.starJoin", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 7.387586124677357, + "scoreError" : 1.0712633432371372, + "scoreConfidence" : [ + 6.31632278144022, + 8.458849467914494 + ], + "scorePercentiles" : { + "0.0" : 7.105916218844726, + "50.0" : 7.339395827073455, + "90.0" : 7.71723042522842, + "95.0" : 7.71723042522842, + "99.0" : 7.71723042522842, + "99.9" : 7.71723042522842, + "99.99" : 7.71723042522842, + "99.999" : 7.71723042522842, + "99.9999" : 7.71723042522842, + "100.0" : 7.71723042522842 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 7.71723042522842, + 7.105916218844726, + 7.143497905082762, + 7.339395827073455, + 7.631890247157423 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.adjacentPair", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 145.2589003360537, + "scoreError" : 16.813070953026283, + "scoreConfidence" : [ + 128.44582938302742, + 162.07197128907998 + ], + "scorePercentiles" : { + "0.0" : 141.0029810024537, + "50.0" : 144.91754269692743, + "90.0" : 151.87003258367113, + "95.0" : 151.87003258367113, + "99.0" : 151.87003258367113, + "99.9" : 151.87003258367113, + "99.99" : 151.87003258367113, + "99.999" : 151.87003258367113, + "99.9999" : 151.87003258367113, + "100.0" : 151.87003258367113 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 146.73129638061084, + 144.91754269692743, + 151.87003258367113, + 141.0029810024537, + 141.77264901660544 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.directorySelect1", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 63.86847062628696, + "scoreError" : 3.759595626144131, + "scoreConfidence" : [ + 60.108875000142824, + 67.62806625243108 + ], + "scorePercentiles" : { + "0.0" : 62.30473966118585, + "50.0" : 64.29702993330538, + "90.0" : 64.74894498991638, + "95.0" : 64.74894498991638, + "99.0" : 64.74894498991638, + "99.9" : 64.74894498991638, + "99.99" : 64.74894498991638, + "99.999" : 64.74894498991638, + "99.9999" : 64.74894498991638, + "100.0" : 64.74894498991638 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 64.74894498991638, + 62.30473966118585, + 63.56194796549334, + 64.29702993330538, + 64.42969058153382 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.linearSelect1", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 1829.5765546610578, + "scoreError" : 256.8144245036059, + "scoreConfidence" : [ + 1572.7621301574518, + 2086.390979164664 + ], + "scorePercentiles" : { + "0.0" : 1751.3043691376913, + "50.0" : 1811.405525365926, + "90.0" : 1924.6581818251698, + "95.0" : 1924.6581818251698, + "99.0" : 1924.6581818251698, + "99.9" : 1924.6581818251698, + "99.99" : 1924.6581818251698, + "99.999" : 1924.6581818251698, + "99.9999" : 1924.6581818251698, + "100.0" : 1924.6581818251698 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 1811.405525365926, + 1924.6581818251698, + 1796.3889561420026, + 1751.3043691376913, + 1864.1257408344998 + ] + ] + }, + "secondaryMetrics" : { + } + } +] + + diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml new file mode 100644 index 00000000..a6fe7079 --- /dev/null +++ b/benchmarks/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + com.ebremer + beakgraph-benchmarks + BeakGraph JMH Benchmarks + 0.17.0 + jar + + + JMH micro/meso benchmarks for the BeakGraph read path. Standalone on purpose: + it depends on the INSTALLED BeakGraph artifact so the main build, its shaded + artifact, and its enforcer rules are never disturbed by benchmark code. + Build the parent first: mvn -DskipTests install (from the repository root). + + + + UTF-8 + 25 + 1.37 + + 0.17.0 + 3.15.0 + 3.6.1 + + + + + + com.ebremer + BeakGraph + ${beakgraph.version} + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${java.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade-plugin.version} + + + package + + shade + + + benchmarks + + + org.openjdk.jmh.Main + + + true + ALL-UNNAMED + + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/benchmarks/select-after.json b/benchmarks/select-after.json new file mode 100644 index 00000000..15bbfa4c --- /dev/null +++ b/benchmarks/select-after.json @@ -0,0 +1,438 @@ +[ + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.chainJoin", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 97.15776366699781, + "scoreError" : 9.31461571373137, + "scoreConfidence" : [ + 87.84314795326644, + 106.47237938072918 + ], + "scorePercentiles" : { + "0.0" : 95.1463674400913, + "50.0" : 95.54647766323023, + "90.0" : 100.13542354235423, + "95.0" : 100.13542354235423, + "99.0" : 100.13542354235423, + "99.9" : 100.13542354235423, + "99.99" : 100.13542354235423, + "99.999" : 100.13542354235423, + "99.9999" : 100.13542354235423, + "100.0" : 100.13542354235423 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 95.54647766323023, + 95.51949193009264, + 95.1463674400913, + 99.4410577592206, + 100.13542354235423 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.pointLookup", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 5.0098974908730805, + "scoreError" : 1.1487111496370241, + "scoreConfidence" : [ + 3.8611863412360563, + 6.158608640510105 + ], + "scorePercentiles" : { + "0.0" : 4.608537012299046, + "50.0" : 5.124201130463694, + "90.0" : 5.360553851753132, + "95.0" : 5.360553851753132, + "99.0" : 5.360553851753132, + "99.9" : 5.360553851753132, + "99.99" : 5.360553851753132, + "99.999" : 5.360553851753132, + "99.9999" : 5.360553851753132, + "100.0" : 5.360553851753132 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 5.360553851753132, + 5.124201130463694, + 4.809525228542041, + 5.146670231307486, + 4.608537012299046 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.predicateScan", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 22849.08163370915, + "scoreError" : 5415.699502758492, + "scoreConfidence" : [ + 17433.382130950657, + 28264.78113646764 + ], + "scorePercentiles" : { + "0.0" : 20999.047916666666, + "50.0" : 23562.59069767442, + "90.0" : 24079.745238095238, + "95.0" : 24079.745238095238, + "99.0" : 24079.745238095238, + "99.9" : 24079.745238095238, + "99.99" : 24079.745238095238, + "99.999" : 24079.745238095238, + "99.9999" : 24079.745238095238, + "100.0" : 24079.745238095238 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 20999.047916666666, + 21692.59574468085, + 24079.745238095238, + 23562.59069767442, + 23911.428571428572 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.QueryBench.starJoin", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 7.201371721152649, + "scoreError" : 0.47253513481267156, + "scoreConfidence" : [ + 6.728836586339977, + 7.6739068559653205 + ], + "scorePercentiles" : { + "0.0" : 7.108192084372712, + "50.0" : 7.173548747876085, + "90.0" : 7.409780756980965, + "95.0" : 7.409780756980965, + "99.0" : 7.409780756980965, + "99.9" : 7.409780756980965, + "99.99" : 7.409780756980965, + "99.999" : 7.409780756980965, + "99.9999" : 7.409780756980965, + "100.0" : 7.409780756980965 + }, + "scoreUnit" : "us/op", + "rawData" : [ + [ + 7.409780756980965, + 7.108192084372712, + 7.173548747876085, + 7.115680936064945, + 7.199656080468537 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.adjacentPair", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 140.06863235377517, + "scoreError" : 12.33636435215739, + "scoreConfidence" : [ + 127.73226800161778, + 152.40499670593255 + ], + "scorePercentiles" : { + "0.0" : 135.40444952283076, + "50.0" : 140.10806532944866, + "90.0" : 144.36999059241802, + "95.0" : 144.36999059241802, + "99.0" : 144.36999059241802, + "99.9" : 144.36999059241802, + "99.99" : 144.36999059241802, + "99.999" : 144.36999059241802, + "99.9999" : 144.36999059241802, + "100.0" : 144.36999059241802 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 144.36999059241802, + 139.61999221463515, + 135.40444952283076, + 140.8406641095434, + 140.10806532944866 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.directorySelect1", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 62.86325335361092, + "scoreError" : 3.0233711347187002, + "scoreConfidence" : [ + 59.83988221889222, + 65.88662448832962 + ], + "scorePercentiles" : { + "0.0" : 62.21923271954394, + "50.0" : 62.684713913463874, + "90.0" : 64.22058119472013, + "95.0" : 64.22058119472013, + "99.0" : 64.22058119472013, + "99.9" : 64.22058119472013, + "99.99" : 64.22058119472013, + "99.999" : 64.22058119472013, + "99.9999" : 64.22058119472013, + "100.0" : 64.22058119472013 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 64.22058119472013, + 62.72613478678492, + 62.21923271954394, + 62.684713913463874, + 62.46560415354175 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.ebremer.beakgraph.benchmarks.SelectBench.linearSelect1", + "mode" : "avgt", + "threads" : 1, + "forks" : 1, + "jvm" : "C:\\bin\\graalvm\\bin\\java.exe", + "jvmArgs" : [ + "-XX:ThreadPriorityPolicy=1", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+EnableJVMCIProduct", + "-XX:+EnableJVMCI", + "-XX:-UnlockExperimentalVMOptions", + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g" + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-jvmci-b01", + "warmupIterations" : 3, + "warmupTime" : "1 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "1 s", + "measurementBatchSize" : 1, + "params" : { + "subjects" : "50000" + }, + "primaryMetric" : { + "score" : 2038.8667829836572, + "scoreError" : 466.1682387685798, + "scoreConfidence" : [ + 1572.6985442150774, + 2505.035021752237 + ], + "scorePercentiles" : { + "0.0" : 1856.6804631307893, + "50.0" : 2065.081921241788, + "90.0" : 2190.626005529179, + "95.0" : 2190.626005529179, + "99.0" : 2190.626005529179, + "99.9" : 2190.626005529179, + "99.99" : 2190.626005529179, + "99.999" : 2190.626005529179, + "99.9999" : 2190.626005529179, + "100.0" : 2190.626005529179 + }, + "scoreUnit" : "ns/op", + "rawData" : [ + [ + 2190.626005529179, + 1856.6804631307893, + 2011.7699449530955, + 2065.081921241788, + 2070.1755800634337 + ] + ] + }, + "secondaryMetrics" : { + } + } +] + + diff --git a/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/BitPackedBufferBench.java b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/BitPackedBufferBench.java new file mode 100644 index 00000000..9226d3f1 --- /dev/null +++ b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/BitPackedBufferBench.java @@ -0,0 +1,110 @@ +package com.ebremer.beakgraph.benchmarks; + +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import java.nio.file.Path; +import java.util.SplittableRandom; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * The read path's innermost primitive: bit-packed value decode. + * + *

Every id fetch, bitmap probe, and binary-search step in the query engine + * funnels through {@link BitPackedUnSignedLongBuffer#get(long)}, so this is the + * baseline to beat for any decode-level optimization (e.g. replacing the + * byte-at-a-time loop with one unaligned long read). {@code sequentialSumViaGet} + * vs {@code sequentialSumViaStream} shows the gap between random-access decode + * and the streaming accumulator for range scans. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(value = 1, jvmArgsAppend = { + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g"}) +@State(Scope.Benchmark) +public class BitPackedBufferBench { + + private static final int ENTRIES = 1 << 20; + private static final int PROBES = 1 << 16; + private static final int MASK = PROBES - 1; + + /** Bit widths spanning the shapes real stores use (ids, offsets, counts). */ + @Param({"7", "13", "29", "41"}) + public int width; + + private BitPackedUnSignedLongBuffer random; + private BitPackedUnSignedLongBuffer sorted; + private long[] probeIndexes; + private long[] probeValues; + private int cursor; + + @Setup + public void setup() { + SplittableRandom rnd = new SplittableRandom(42); + long maxVal = (width == 64) ? Long.MAX_VALUE : (1L << width) - 1; + + random = new BitPackedUnSignedLongBuffer(Path.of("bench-random"), null, 0, width); + for (int i = 0; i < ENTRIES; i++) { + random.writeLong(rnd.nextLong(maxVal + 1)); + } + random.prepareForReading(); + + sorted = new BitPackedUnSignedLongBuffer(Path.of("bench-sorted"), null, 0, width); + long[] values = new long[ENTRIES]; + long v = 0; + long step = Math.max(1, maxVal / ENTRIES); + for (int i = 0; i < ENTRIES; i++) { + v = Math.min(maxVal, v + rnd.nextLong(step + 1)); + values[i] = v; + sorted.writeLong(v); + } + sorted.prepareForReading(); + + probeIndexes = new long[PROBES]; + probeValues = new long[PROBES]; + for (int i = 0; i < PROBES; i++) { + probeIndexes[i] = rnd.nextInt(ENTRIES); + probeValues[i] = values[rnd.nextInt(ENTRIES)]; + } + } + + @Benchmark + public long randomGet() { + return random.get(probeIndexes[cursor++ & MASK]); + } + + @Benchmark + public long binarySearch() { + return sorted.binarySearch(0, ENTRIES - 1, probeValues[cursor++ & MASK]); + } + + @Benchmark + @OperationsPerInvocation(ENTRIES) + public long sequentialSumViaGet() { + long sum = 0; + for (long i = 0; i < ENTRIES; i++) { + sum += random.get(i); + } + return sum; + } + + @Benchmark + @OperationsPerInvocation(ENTRIES) + public long sequentialSumViaStream() { + return random.stream().sum(); + } +} diff --git a/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/DictionaryBench.java b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/DictionaryBench.java new file mode 100644 index 00000000..9d186ec2 --- /dev/null +++ b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/DictionaryBench.java @@ -0,0 +1,152 @@ +package com.ebremer.beakgraph.benchmarks; + +import com.ebremer.beakgraph.core.Dictionary; +import com.ebremer.beakgraph.core.GSPODictionary; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import java.util.SplittableRandom; +import java.util.concurrent.TimeUnit; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.NodeFactory; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Dictionary term resolution: {@code locate} (Node -> id, tiered binary search + * whose every probe decodes a front-coded block and compares via NodeComparator) + * and {@code extract} (id -> Node, one block decode + Node allocation). + * + *

These dominate iterator construction (each concrete pattern term is + * located) and result materialization (each projected id is extracted). + * {@code nodeTableLookup} measures the cached path the query engine uses for + * bound-variable terms, for contrast with the raw dictionary search. + * + *

Probe nodes are extracted from the store then re-created fresh, so hits are + * guaranteed regardless of writer canonicalization while equality (not identity) + * does the work. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(value = 1, jvmArgsAppend = { + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g"}) +@State(Scope.Benchmark) +public class DictionaryBench { + + private static final int PROBES = 1 << 12; + private static final int MASK = PROBES - 1; + + @Param({"50000"}) + public int subjects; + + private HDF5Reader reader; + private GSPODictionary dict; + private Node[] entityProbes; + private Node[] objectProbes; + private Node[] predicateProbes; + private Node[] missProbes; + private long[] entityIds; + private long[] objectIds; + private int cursor; + + @Setup + public void setup() { + reader = new HDF5Reader(SyntheticStore.get(subjects).toFile()); + dict = reader.getDictionary(); + SplittableRandom rnd = new SplittableRandom(42); + + Dictionary entities = dict.getSubjects(); + Dictionary objects = dict.getObjects(); + long numEntities = entities.getNumberOfNodes(); + long numObjects = objects.getNumberOfNodes(); + + entityProbes = new Node[PROBES]; + objectProbes = new Node[PROBES]; + missProbes = new Node[PROBES]; + entityIds = new long[PROBES]; + objectIds = new long[PROBES]; + for (int i = 0; i < PROBES; i++) { + entityIds[i] = 1 + rnd.nextLong(numEntities); + objectIds[i] = 1 + rnd.nextLong(numObjects); + entityProbes[i] = freshCopy(entities.extract(entityIds[i])); + objectProbes[i] = freshCopy(objects.extract(objectIds[i])); + missProbes[i] = NodeFactory.createURI(SyntheticStore.NS + "missing/" + i); + } + predicateProbes = dict.streamPredicates() + .map(DictionaryBench::freshCopy) + .toArray(Node[]::new); + } + + @TearDown + public void tearDown() { + reader.close(); + } + + /** Same term, new object: hits must come from equality, never identity. */ + private static Node freshCopy(Node n) { + if (n.isURI()) { + return NodeFactory.createURI(n.getURI()); + } + if (n.isLiteral()) { + String lang = n.getLiteralLanguage(); + if (lang != null && !lang.isEmpty()) { + return NodeFactory.createLiteralLang(n.getLiteralLexicalForm(), lang); + } + return NodeFactory.createLiteralDT(n.getLiteralLexicalForm(), n.getLiteralDatatype()); + } + if (n.isBlank()) { + return NodeFactory.createBlankNode(n.getBlankNodeLabel()); + } + return n; + } + + @Benchmark + public long entityLocateHit() { + return dict.getSubjects().locate(entityProbes[cursor++ & MASK]); + } + + /** Mixed URI/literal probes through the entity+literal object view. */ + @Benchmark + public long objectLocateHit() { + return dict.getObjects().locate(objectProbes[cursor++ & MASK]); + } + + @Benchmark + public long predicateLocateHit() { + int i = cursor++; + return dict.getPredicates().locate(predicateProbes[i % predicateProbes.length]); + } + + @Benchmark + public long locateMiss() { + return dict.getSubjects().locate(missProbes[cursor++ & MASK]); + } + + @Benchmark + public Node extractEntity() { + return dict.getSubjects().extract(entityIds[cursor++ & MASK]); + } + + @Benchmark + public Node extractObject() { + return dict.getObjects().extract(objectIds[cursor++ & MASK]); + } + + /** The Caffeine-cached Node -> NodeId path (SimpleNodeTable) used for bound variables. */ + @Benchmark + public Object nodeTableLookup() { + return reader.getNodeTable().getNodeIdForNode(entityProbes[cursor++ & MASK]); + } +} diff --git a/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/QueryBench.java b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/QueryBench.java new file mode 100644 index 00000000..f63e396d --- /dev/null +++ b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/QueryBench.java @@ -0,0 +1,189 @@ +package com.ebremer.beakgraph.benchmarks; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.apache.jena.graph.Node; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.Query; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.rdf.model.RDFNode; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * End-to-end SPARQL over a BeakGraph store - the numbers that ultimately matter. + * Each query shape targets a distinct read-path mechanism: + * + *

    + *
  • {@code pointLookup} - bound S+P (GSPO iterator construction + one row).
  • + *
  • {@code starJoin} - three patterns on one subject (per-pattern iterator + * construction against the same binding).
  • + *
  • {@code predicateScan} - bound P only (GPOS nested object/subject scan, + * one row per subject).
  • + *
  • {@code rangeFilter} - FILTER range pushdown into the object id range.
  • + *
  • {@code chainJoin} - selective anchor then two joins: each intermediate + * binding constructs fresh iterators and re-resolves pattern terms, the + * per-binding cost identified in the read-path review.
  • + *
  • {@code graphVarScan} - GRAPH ?g: per-named-graph chaining.
  • + *
  • {@code fullScanFind} - Graph API wildcard scan (SPO_All) including + * Triple materialization through the node table.
  • + *
+ * + *

Every query drains its ResultSet and touches each projected term, so lazy + * bindings actually materialize - the timings include dictionary extraction. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 5, time = 2) +@Measurement(iterations = 5, time = 2) +@Fork(value = 1, jvmArgsAppend = { + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g"}) +@State(Scope.Benchmark) +public class QueryBench { + + private static final String PREFIX = "PREFIX ex: <" + SyntheticStore.NS + ">\n"; + + @Param({"50000"}) + public int subjects; + + private BeakGraph graph; + private Dataset dataset; + private Query pointLookup; + private Query starJoin; + private Query predicateScan; + private Query rangeFilter; + private Query chainJoin; + private Query graphVarScan; + private Query distinctSubjects; + private Query distinctObjects; + private Query fullScanSparql; + + @Setup + public void setup() { + graph = new BeakGraph(new HDF5Reader(SyntheticStore.get(subjects).toFile())); + dataset = graph.getDataset(); + int mid = subjects / 2; + pointLookup = QueryFactory.create(PREFIX + + "SELECT ?o WHERE { ex:s" + mid + " ex:link ?o }"); + starJoin = QueryFactory.create(PREFIX + + "SELECT ?v ?n ?t WHERE { ex:s" + mid + " ex:value ?v ; ex:name ?n ; ex:link ?t }"); + predicateScan = QueryFactory.create(PREFIX + + "SELECT ?s ?v WHERE { ?s ex:value ?v }"); + rangeFilter = QueryFactory.create(PREFIX + + "SELECT ?s ?v WHERE { ?s ex:value ?v FILTER(?v >= 995) }"); + chainJoin = QueryFactory.create(PREFIX + + "SELECT ?a ?c WHERE { ?a ex:value 7 . ?a ex:link ?b . ?b ex:link ?c }"); + graphVarScan = QueryFactory.create(PREFIX + + "SELECT ?g ?s WHERE { GRAPH ?g { ?s ex:tag ?t } }"); + // Index-answered (GSPO subject-level stream) vs scan+dedup fallback: the + // same row count, so the pair isolates the DISTINCT fast path's effect. + distinctSubjects = QueryFactory.create(PREFIX + + "SELECT DISTINCT ?s WHERE { ?s ?p ?o }"); + distinctObjects = QueryFactory.create(PREFIX + + "SELECT DISTINCT ?o WHERE { ?s ?p ?o }"); + // Engine-path full scan (unlike fullScanFind's Graph API): eligible for + // the chunked parallel scan; A/B via -Dbeakgraph.scan.parallel.threshold. + fullScanSparql = QueryFactory.create(PREFIX + + "SELECT ?s ?p ?o WHERE { ?s ?p ?o }"); + } + + @TearDown + public void tearDown() { + graph.close(); + } + + /** Runs the query and touches every projected term so results fully materialize. */ + private long run(Query q) { + long h = 0; + try (QueryExecution qe = QueryExecution.dataset(dataset).query(q).build()) { + ResultSet rs = qe.execSelect(); + List vars = rs.getResultVars(); + while (rs.hasNext()) { + QuerySolution row = rs.next(); + for (String v : vars) { + RDFNode n = row.get(v); + if (n != null) { + h += n.asNode().hashCode(); + } + } + } + } + return h; + } + + @Benchmark + public long pointLookup() { + return run(pointLookup); + } + + @Benchmark + public long starJoin() { + return run(starJoin); + } + + @Benchmark + public long predicateScan() { + return run(predicateScan); + } + + @Benchmark + public long rangeFilter() { + return run(rangeFilter); + } + + @Benchmark + public long chainJoin() { + return run(chainJoin); + } + + @Benchmark + public long graphVarScan() { + return run(graphVarScan); + } + + @Benchmark + public long distinctSubjects() { + return run(distinctSubjects); + } + + @Benchmark + public long distinctObjects() { + return run(distinctObjects); + } + + @Benchmark + public long fullScanSparql() { + return run(fullScanSparql); + } + + @Benchmark + public long fullScanFind() { + long h = 0; + var it = graph.find(Node.ANY, Node.ANY, Node.ANY); + try { + while (it.hasNext()) { + h += it.next().hashCode(); + } + } finally { + it.close(); + } + return h; + } +} diff --git a/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/RemoteVsLocalBench.java b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/RemoteVsLocalBench.java new file mode 100644 index 00000000..963574b2 --- /dev/null +++ b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/RemoteVsLocalBench.java @@ -0,0 +1,639 @@ +package com.ebremer.beakgraph.benchmarks; + +import com.ebremer.beakgraph.BG; +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.core.HTTPSeekableByteChannel; +import java.io.File; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import org.apache.jena.graph.Node; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.Query; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.riot.out.NodeFmtLib; + +/** + * Paired remote-vs-local query benchmark: opens the SAME BeakGraph twice - + * once from a local file, once over {@link HTTPSeekableByteChannel} (HTTP + * range requests) - and runs an identical, seeded battery of random SPARQL + * queries against both, comparing wall-clock per query type and verifying + * that both stores return the same number of rows. + * + *

Deliberately a plain {@code main} program rather than a JMH benchmark: + * the workload is paired (every query runs against both stores), depends on + * live network behavior, and the interesting output includes correctness + * cross-checks and HTTP transfer metrics (bytes fetched, range requests, + * fraction of the file touched) that JMH's model has no place for. JMH + * remains the right tool for the micro benches in this module. + * + *

The battery is built by harvesting real terms from the LOCAL store + * (graph list plus bounded per-graph samples), so every random query is over + * things that actually exist. The same seed produces the same query + * sequence, and each instantiated query object is shared by both stores. + * + *

+ * java -cp benchmarks.jar com.ebremer.beakgraph.benchmarks.RemoteVsLocalBench \
+ *      [-pairs testpairs.tsv] [-local D:\data\store.h5 -remote https://example.org/store.h5] \
+ *      [-tries 20] [-seed 42] [-warmup 2] [-samplegraphs 48] [-cachemb 128] \
+ *      [-test graph-dump]
+ * 
+ * + *

Benchmark targets live in a git-ignored {@code testpairs.tsv} (looked up + * in the working directory, then {@code benchmarks/}, or wherever {@code + * -pairs} points): one tab-separated pair per line - the remote http(s) URI + * and the local file path of the SAME BeakGraph, in either column order; + * {@code #} starts a comment. Every pair in the file is benchmarked in turn. + * {@code -local}/{@code -remote} bypass the file for a one-off pair. + * + *

{@code -test } runs a single battery test by itself (the cold + * worst-case shots are skipped); an unknown name lists what is available. + * + * @author Erich Bremer + */ +public final class RemoteVsLocalBench { + + /** One benchmark target: the same BeakGraph reachable both ways. */ + private record Pair(File local, URI remote) {} + + private record Sample(Node g, Node s, Node p, Node o) {} + private record Star(Node g, Node s, Node p1, Node o1, Node p2) {} + private record Numeric(Node g, Node p, double threshold) {} + + /** One query type: a name and a per-try query generator over the harvested pools. */ + private record Test(String name, String what, List queries) {} + + /** Timings and row counts for one store across the tries of one test. */ + private static final class Series { + final List nanos = new ArrayList<>(); + long rows = 0; + } + + public static void main(String[] args) throws Exception { + String localPath = null; + String remoteUrl = null; + String pairsPath = null; + int tries = 20; + long seed = 42; + int warmup = 2; + int sampleGraphs = 48; + int cacheMb = 128; + String testFilter = null; + for (int i = 0; i < args.length - 1; i += 2) { + switch (args[i]) { + case "-local" -> localPath = args[i + 1]; + case "-remote" -> remoteUrl = args[i + 1]; + case "-pairs" -> pairsPath = args[i + 1]; + case "-tries" -> tries = Integer.parseInt(args[i + 1]); + case "-seed" -> seed = Long.parseLong(args[i + 1]); + case "-warmup" -> warmup = Integer.parseInt(args[i + 1]); + case "-samplegraphs" -> sampleGraphs = Integer.parseInt(args[i + 1]); + case "-cachemb" -> cacheMb = Integer.parseInt(args[i + 1]); + case "-test" -> testFilter = args[i + 1]; + default -> throw new IllegalArgumentException("Unknown option: " + args[i]); + } + } + List pairs; + if (localPath != null || remoteUrl != null) { + if (localPath == null || remoteUrl == null) { + throw new IllegalArgumentException("-local and -remote must be given together"); + } + pairs = List.of(new Pair(new File(localPath), URI.create(remoteUrl))); + } else { + pairs = loadPairs(pairsPath); + } + for (int i = 0; i < pairs.size(); i++) { + if (i > 0) { + System.out.println(); + System.out.println("=".repeat(118)); + System.out.println(); + } + run(pairs.get(i).local(), pairs.get(i).remote(), + tries, seed, warmup, sampleGraphs, cacheMb, testFilter); + } + } + + /** + * Loads benchmark targets from a tab-separated pairs file: one pair per + * line, one field the remote {@code http(s)} URI and the other the local + * file path (column order free - the http(s) field is recognized as the + * remote). Blank lines and {@code #} comments are ignored. The file is + * deliberately NOT in version control (see .gitignore): test targets are + * deployment-specific, not source. + */ + private static List loadPairs(String explicit) throws java.io.IOException { + List candidates = (explicit != null) + ? List.of(java.nio.file.Path.of(explicit)) + : List.of(java.nio.file.Path.of("testpairs.tsv"), + java.nio.file.Path.of("benchmarks", "testpairs.tsv")); + java.nio.file.Path file = candidates.stream() + .filter(java.nio.file.Files::isRegularFile).findFirst().orElse(null); + if (file == null) { + throw new IllegalArgumentException("No pairs file found (looked for " + candidates + "). " + + "Create testpairs.tsv with one tab-separated pair per line: " + + "\\t ('#' starts a comment), " + + "or pass -local/-remote (or -pairs ) explicitly."); + } + List pairs = new ArrayList<>(); + List lines = java.nio.file.Files.readAllLines(file); + for (int n = 0; n < lines.size(); n++) { + String line = lines.get(n).trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String[] fields = line.split("\t"); + String remote = null; + String local = null; + for (String raw : fields) { + String field = raw.trim(); + if (field.isEmpty()) { + continue; + } + if (field.regionMatches(true, 0, "http://", 0, 7) + || field.regionMatches(true, 0, "https://", 0, 8)) { + remote = field; + } else { + local = field; + } + } + if (remote == null || local == null) { + throw new IllegalArgumentException(file + " line " + (n + 1) + + ": need one http(s) URI and one local path, tab-separated: " + line); + } + pairs.add(new Pair(new File(local), URI.create(remote))); + } + if (pairs.isEmpty()) { + throw new IllegalArgumentException(file + " contains no test pairs"); + } + System.out.println("targets: " + pairs.size() + " pair(s) from " + file); + return pairs; + } + + private static void run(File localFile, URI remoteUri, int tries, long seed, int warmup, + int sampleGraphs, int cacheMb, String testFilter) throws Exception { + if (!localFile.isFile()) { + throw new IllegalArgumentException("Local file not found: " + localFile); + } + System.out.println("=== BeakGraph query benchmark: HTTP range requests vs local file ==="); + System.out.printf("local : %s (%,d bytes)%n", localFile, localFile.length()); + System.out.printf("remote: %s%n", remoteUri); + System.out.printf("seed %d, %d tries/test, %d warmup, %d sample graphs, %d MiB block cache%s%n%n", + seed, tries, warmup, sampleGraphs, cacheMb, + (testFilter == null) ? "" : ", single test: " + testFilter); + + // ---- open both stores (timed: "time until queryable") ------------- + long t0 = System.nanoTime(); + BeakGraph local = BG.getBeakGraph(localFile); + long localOpenNanos = System.nanoTime() - t0; + Dataset localDS = local.getDataset(); + + t0 = System.nanoTime(); + HTTPSeekableByteChannel channel = new HTTPSeekableByteChannel(remoteUri, + 128 * 1024, cacheMb * 8); // 128 KiB blocks -> 8 blocks per MiB + BeakGraph remote = BG.getBeakGraph(channel); + long remoteOpenNanos = System.nanoTime() - t0; + Dataset remoteDS = remote.getDataset(); + + long openBytes = channel.getBytesFetched(); + long openRequests = channel.getRangeRequestCount(); + System.out.printf("open: local %s | remote %s (%d range requests, %s fetched)%n%n", + ms(localOpenNanos), ms(remoteOpenNanos), openRequests, mb(openBytes)); + if (channel.size() != localFile.length()) { + System.out.printf("WARNING: sizes differ (local %,d, remote %,d) - not the same file?%n", + localFile.length(), channel.size()); + } + + try { + // ---- harvest real terms from a THROWAWAY local reader --------- + // Materializing terms warms a reader's node cache with exactly the + // terms the battery will then query. The measured stores must both + // start cold, so the harvest gets its own instance. + Random rnd = new Random(seed); + Pools pools; + BeakGraph harvestBG = BG.getBeakGraph(localFile); + try { + pools = harvest(harvestBG.getDataset(), rnd, sampleGraphs); + } finally { + harvestBG.close(); + } + System.out.printf("harvest: %,d graphs; pools: quads=%d anchors=%d (selective=%d) " + + "stars=%d numerics=%d (subjects are %s)%n%n", + pools.graphCount, pools.samples.size(), + pools.samples.stream().filter(s -> anchorable(s.o())).count(), + pools.selectiveAnchors.size(), pools.stars.size(), pools.numerics.size(), + pools.iriSubjects ? "IRIs" : "blank nodes"); + + // ---- build the battery: identical Query objects for both stores + List battery = buildBattery(pools, rnd, tries); + if (testFilter != null) { + String available = String.join(", ", battery.stream().map(Test::name).toList()); + String wanted = testFilter; + battery.removeIf(t -> !t.name().equals(wanted)); + if (battery.isEmpty()) { + System.out.println("Unknown or unavailable test '" + testFilter + + "'. Available on this store: " + available); + System.exit(1); + } + } + + // ---- single-shot pathologies, measured COLD (before any warmup) + // 1. Anchoring on a custom-datatype (WKT) literal: dictionary + // location of such a literal is the worst remote access pattern. + // 2. A variable-predicate fanout from a bound subject executes as a + // full-graph scan, so even a 1-match anchor reads the store. + // Both are real query shapes; they get one measured shot each so + // they inform without ambushing the battery's statistics. + // Skipped under -test: a single-test run should measure just that test. + if (testFilter == null) { + pools.samples.stream() + .filter(s -> s.g() != null && s.o().isLiteral() + && s.o().getLiteralLexicalForm().length() >= 200) + .findFirst() + .ifPresent(s -> singleShot( + "worst case 1: anchor on a " + s.o().getLiteralLexicalForm().length() + + "-char custom-datatype literal (cold)", + "SELECT ?p2 ?o2 WHERE { GRAPH " + term(s.g()) + " { ?x " + term(s.p()) + + " " + term(s.o()) + " . ?x ?p2 ?o2 } }", + localDS, remoteDS, channel)); + if (!pools.selectiveAnchors.isEmpty()) { + Sample s = pools.selectiveAnchors.get(0); + singleShot("worst case 2: variable-predicate fanout join (scan-shaped, cold-ish)", + "SELECT ?p2 ?o2 WHERE { " + inGraph(s.g(), "?x " + term(s.p()) + " " + + term(s.o()) + " . ?x ?p2 ?o2") + " } LIMIT 200", + localDS, remoteDS, channel); + } + } + + // ---- JIT/engine warmup on a disjoint random stream ------------- + long warmupStart = channel.getBytesFetched(); + List warmupBattery = buildBattery(pools, new Random(seed + 1), Math.max(warmup, 1)); + if (testFilter != null) { + String wanted = testFilter; + warmupBattery.removeIf(t -> !t.name().equals(wanted)); + } + for (int w = 0; w < warmup; w++) { + for (Test t : warmupBattery) { + Query q = t.queries().get(w % t.queries().size()); + execute(localDS, q); + long before = channel.getBytesFetched(); + long nano = System.nanoTime(); + execute(remoteDS, q); + long delta = channel.getBytesFetched() - before; + if (delta > 10_000_000) { + // a warmup pick this hungry would ambush the measured runs + // too - name it so the battery's bounds can be fixed + System.out.printf("warmup outlier %s: %s ms, %s%n %s%n", t.name(), + ms(System.nanoTime() - nano), mb(delta), + q.toString().replace('\n', ' ')); + } + } + } + System.out.printf("warmup: %s fetched%n%n", mb(channel.getBytesFetched() - warmupStart)); + + // ---- measured runs -------------------------------------------- + System.out.printf("%-16s %5s %9s | %21s | %21s | %7s %16s%n", + "test", "tries", "rows/try", "LOCAL med / p95 / max", "REMOTE med / p95 / max", + "ratio", "fetched"); + System.out.println("-".repeat(118)); + long localTotal = 0; + long remoteTotal = 0; + long totalQueries = 0; + int mismatches = 0; + for (Test test : battery) { + Series l = new Series(); + Series r = new Series(); + long bytesBefore = channel.getBytesFetched(); + long requestsBefore = channel.getRangeRequestCount(); + for (Query q : test.queries()) { + long a = System.nanoTime(); + long localRows = execute(localDS, q); + long b = System.nanoTime(); + long remoteRows = execute(remoteDS, q); + long c = System.nanoTime(); + l.nanos.add(b - a); + r.nanos.add(c - b); + l.rows += localRows; + r.rows += remoteRows; + if (localRows != remoteRows) { + mismatches++; + System.out.printf(" MISMATCH %s: local %d rows, remote %d rows%n %s%n", + test.name(), localRows, remoteRows, q); + } + } + long fetched = channel.getBytesFetched() - bytesBefore; + long requests = channel.getRangeRequestCount() - requestsBefore; + long lSum = l.nanos.stream().mapToLong(Long::longValue).sum(); + long rSum = r.nanos.stream().mapToLong(Long::longValue).sum(); + localTotal += lSum; + remoteTotal += rSum; + totalQueries += test.queries().size(); + System.out.printf("%-16s %5d %9.1f | %6s %6s %6s | %6s %6s %6s | %6.1fx %10s/%dr%n", + test.name(), test.queries().size(), + (double) l.rows / test.queries().size(), + ms(percentile(l.nanos, 50)), ms(percentile(l.nanos, 95)), ms(percentile(l.nanos, 100)), + ms(percentile(r.nanos, 50)), ms(percentile(r.nanos, 95)), ms(percentile(r.nanos, 100)), + (double) rSum / Math.max(1, lSum), mb(fetched), requests); + } + System.out.println("-".repeat(118)); + + // ---- overall --------------------------------------------------- + long bytes = channel.getBytesFetched(); + long requests = channel.getRangeRequestCount(); + System.out.printf("%nTOTALS over %d queries/store:%n", totalQueries); + System.out.printf(" local : %s total (%.2f ms/query avg)%n", + sec(localTotal), localTotal / 1e6 / totalQueries); + System.out.printf(" remote : %s total (%.2f ms/query avg) -> %.1fx local%n", + sec(remoteTotal), remoteTotal / 1e6 / totalQueries, + (double) remoteTotal / Math.max(1, localTotal)); + System.out.printf(" result mismatches: %d%s%n", mismatches, + mismatches == 0 ? " (remote answers are identical)" : " <-- INVESTIGATE"); + System.out.printf(" remote transfer: %s in %,d range requests = %.2f%% of the %s file%n", + mb(bytes), requests, 100.0 * bytes / channel.size(), mb(channel.size())); + System.out.printf(" (open cost included above: %s in %d requests)%n", mb(openBytes), openRequests); + } finally { + local.close(); + remote.close(); + } + } + + // ---------------------------------------------------------------- pools + + private static final class Pools { + long graphCount; + boolean iriSubjects; + boolean hasDefaultGraph; + final List graphs = new ArrayList<>(); + final List samples = new ArrayList<>(); + final List selectiveAnchors = new ArrayList<>(); + final List stars = new ArrayList<>(); + final List numerics = new ArrayList<>(); + } + + private static final String XSD_NS = "http://www.w3.org/2001/XMLSchema#"; + private static final String LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString"; + + /** + * A term a battery query may anchor on: concrete, and cheap to locate. + * Blank nodes are wildcards in patterns; custom-datatype literals + * (geometry WKT in these stores) make dictionary location itself the + * dominant cost - measured 700+ MB of transfer for ONE lookup - so that + * pathology is measured separately as the single-shot worst case rather + * than randomly ambushing the battery. XSD-typed and plain/lang literals + * locate in logarithmic probes. + */ + private static boolean anchorable(Node o) { + if (o.isBlank()) { + return false; + } + if (o.isURI()) { + return true; + } + String dt = o.getLiteralDatatypeURI(); + boolean standard = dt == null || dt.startsWith(XSD_NS) || dt.equals(LANG_STRING); + return standard && o.getLiteralLexicalForm().length() <= 64; + } + + /** + * Bounded harvest: the full graph list (a dictionary stream - cheap), then + * a LIMITed slice of random graphs scattered across the store. Never a + * full scan, so it stays fast on multi-GB files. + */ + private static Pools harvest(Dataset ds, Random rnd, int sampleGraphs) { + Pools pools = new Pools(); + List graphs = pools.graphs; + Iterator it = ds.asDatasetGraph().listGraphNodes(); + while (it.hasNext()) { + Node g = it.next(); + if (!Params.BGVOID.equals(g) && !Params.SPATIAL.equals(g) && g.isURI()) { + graphs.add(g); + } + } + pools.graphCount = graphs.size(); + + List chosen = new ArrayList<>(); + if (!graphs.isEmpty()) { + for (int i = 0; i < sampleGraphs; i++) { + chosen.add(graphs.get(rnd.nextInt(graphs.size()))); + } + } + for (Node g : chosen) { + Query q = QueryFactory.create("SELECT * WHERE { GRAPH " + term(g) + + " { ?s ?p ?o } } LIMIT 64"); + try (QueryExecution qe = QueryExecution.dataset(ds).query(q).build()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) { + QuerySolution row = rs.next(); + pools.samples.add(new Sample(g, row.get("s").asNode(), + row.get("p").asNode(), row.get("o").asNode())); + } + } + } + // default-graph triples participate too, when present + Query dq = QueryFactory.create("SELECT * WHERE { ?s ?p ?o } LIMIT 64"); + try (QueryExecution qe = QueryExecution.dataset(ds).query(dq).build()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) { + QuerySolution row = rs.next(); + pools.hasDefaultGraph = true; + pools.samples.add(new Sample(null, row.get("s").asNode(), + row.get("p").asNode(), row.get("o").asNode())); + } + } + + pools.iriSubjects = pools.samples.stream().anyMatch(s -> s.s().isURI()); + + // Selective anchors for the JOIN tests: an anchor like "rdf:type X" + // matches millions of subjects, and a join fanning out from it is a + // store-wide scan no LIMIT can save (observed: 13 minutes / 3.8 GB + // over HTTP). Qualify candidates locally - keep only (p,o) pairs + // matching 1..50 subjects; the LIMIT 51 probe is milliseconds here. + for (Sample s : pools.samples) { + if (pools.selectiveAnchors.size() >= 512) { + break; + } + if (!anchorable(s.o())) { + continue; + } + Query probe = QueryFactory.create("SELECT ?x WHERE { " + + inGraph(s.g(), "?x " + term(s.p()) + " " + term(s.o())) + " } LIMIT 51"); + long matches = execute(ds, probe); + if (matches >= 1 && matches <= 50) { + pools.selectiveAnchors.add(s); + } + } + + // star pairs: same (g,s), two distinct predicates -> guaranteed joins + Map firstByGS = new HashMap<>(); + for (Sample s : pools.samples) { + String key = s.g() + "|" + s.s(); + Sample first = firstByGS.putIfAbsent(key, s); + // The (p1,o1) leg anchors the join, so o1 must be anchorable - a + // blank node is a wildcard and a huge literal makes the anchor's + // dictionary lookup the dominant (pathological) cost. + if (first != null && !first.p().equals(s.p()) && anchorable(first.o()) + && pools.stars.size() < 4096) { + pools.stars.add(new Star(s.g(), s.s(), first.p(), first.o(), s.p())); + } + } + // numeric-typed literal objects -> FILTER thresholds that always match >= once + for (Sample s : pools.samples) { + if (s.o().isLiteral() && pools.numerics.size() < 4096) { + try { + Object value = s.o().getLiteralValue(); + if (value instanceof Number n) { + pools.numerics.add(new Numeric(s.g(), s.p(), n.doubleValue())); + } + } catch (RuntimeException notNumeric) { + // malformed literal - not usable as a threshold + } + } + } + return pools; + } + + // -------------------------------------------------------------- battery + + /** The battery: one entry per query type, {@code tries} seeded-random instantiations each. */ + private static List buildBattery(Pools pools, Random rnd, int tries) { + List battery = new ArrayList<>(); + List named = pools.samples.stream().filter(s -> s.g() != null).toList(); + // Anchors may come from any graph (in these stores the short-valued + // feature data lives in the default graph); every anchored test + // carries a LIMIT so a common value cannot explode a random pick. + List anchors = pools.samples.stream().filter(s -> anchorable(s.o())).toList(); + + addTest(battery, "pred-obj", "subjects carrying a known predicate+object (GPOS path)", + tries, rnd, anchors, s -> + "SELECT ?s WHERE { " + inGraph(s.g(), "?s " + term(s.p()) + " " + term(s.o())) + + " } LIMIT 100"); + addTest(battery, "pred-scan", "bounded scan of one predicate in one graph", + tries, rnd, pools.samples, s -> + "SELECT ?s ?o WHERE { " + inGraph(s.g(), "?s " + term(s.p()) + " ?o") + " } LIMIT 100"); + addTest(battery, "obj-lookup", "who points at a known value (object-led)", + tries, rnd, anchors, s -> + "SELECT ?s ?p WHERE { " + inGraph(s.g(), "?s ?p " + term(s.o())) + " } LIMIT 100"); + addTest(battery, "cross-graph", "which graphs contain a known predicate (federated discovery)", + tries, rnd, named, s -> + "SELECT DISTINCT ?g WHERE { GRAPH ?g { ?s " + term(s.p()) + " ?o } } LIMIT 10"); + addTest(battery, "graph-dump", "materialize an entire random named graph (spatial tile fetch)", + tries, rnd, pools.graphs, g -> + "SELECT ?s ?p ?o WHERE { GRAPH " + term(g) + " { ?s ?p ?o } }"); + addTest(battery, "star-join", "two-pattern join on a known subject", + tries, rnd, pools.stars, st -> + "SELECT ?s ?v WHERE { " + inGraph(st.g(), "?s " + term(st.p1()) + " " + + term(st.o1()) + " ; " + term(st.p2()) + " ?v") + " } LIMIT 100"); + addTest(battery, "count-pred", "COUNT(*) of one predicate in one NAMED graph (bounded by graph size)", + tries, rnd, named, s -> + "SELECT (COUNT(*) AS ?n) WHERE { GRAPH " + term(s.g()) + " { ?s " + term(s.p()) + " ?o } }"); + addTest(battery, "num-filter", "numeric range filter over a predicate in one graph", + tries, rnd, pools.numerics, n -> + "SELECT ?s ?v WHERE { " + inGraph(n.g(), "?s " + term(n.p()) + + " ?v FILTER(?v >= " + n.threshold() + ")") + " } LIMIT 100"); + if (pools.hasDefaultGraph) { + // bounded slice of the (potentially huge) default graph - LIMIT is + // satisfied immediately, so this stays cheap on both stores + List offsets = new ArrayList<>(); + for (int i = 0; i < tries; i++) { + offsets.add(rnd.nextInt(1000)); + } + addTest(battery, "default-slice", "bounded slice of the default graph", + tries, rnd, offsets, off -> + "SELECT * WHERE { ?s ?p ?o } OFFSET " + off + " LIMIT 100"); + } + return battery; + } + + /** Wraps a pattern in GRAPH when the sample came from a named graph; default graph otherwise. */ + private static String inGraph(Node g, String pattern) { + return (g == null) ? pattern : "GRAPH " + term(g) + " { " + pattern + " }"; + } + + private interface Template { + String instantiate(T pick); + } + + private static void addTest(List battery, String name, String what, + int tries, Random rnd, List pool, Template template) { + if (pool.isEmpty()) { + System.out.println("skipping " + name + " (no matching terms in this store): " + what); + return; + } + List queries = new ArrayList<>(tries); + for (int i = 0; i < tries; i++) { + T pick = pool.get(rnd.nextInt(pool.size())); + queries.add(QueryFactory.create(template.instantiate(pick))); + } + battery.add(new Test(name, what, queries)); + } + + /** One paired measurement of a pathological query shape, excluded from the battery totals. */ + private static void singleShot(String label, String sparql, Dataset localDS, Dataset remoteDS, + HTTPSeekableByteChannel channel) { + Query q = QueryFactory.create(sparql); + long bytesBefore = channel.getBytesFetched(); + long requestsBefore = channel.getRangeRequestCount(); + long a = System.nanoTime(); + long localRows = execute(localDS, q); + long b = System.nanoTime(); + long remoteRows = execute(remoteDS, q); + long c = System.nanoTime(); + System.out.printf("%s%n local %s ms (%d rows) | remote %s ms (%d rows), %s in %d requests%n%n", + label, ms(b - a), localRows, ms(c - b), remoteRows, + mb(channel.getBytesFetched() - bytesBefore), + channel.getRangeRequestCount() - requestsBefore); + } + + /** Fully consumes the query's results; returns the row count. */ + private static long execute(Dataset ds, Query q) { + try (QueryExecution qe = QueryExecution.dataset(ds).query(q).build()) { + ResultSet rs = qe.execSelect(); + long rows = 0; + while (rs.hasNext()) { + rs.next(); + rows++; + } + return rows; + } + } + + // ------------------------------------------------------------ reporting + + /** + * Strict N-Triples serialization: full IRIs, proper escaping, never a + * prefixed name (the generated queries declare no prefixes). + */ + private static String term(Node n) { + return NodeFmtLib.strNT(n); + } + + private static long percentile(List nanos, int pct) { + List sorted = new ArrayList<>(nanos); + sorted.sort(Long::compareTo); + int idx = Math.min(sorted.size() - 1, Math.max(0, (int) Math.ceil(pct / 100.0 * sorted.size()) - 1)); + return sorted.get(idx); + } + + private static String ms(long nanos) { + double v = nanos / 1e6; + return (v >= 100) ? String.format(Locale.ROOT, "%.0f", v) + : (v >= 10) ? String.format(Locale.ROOT, "%.1f", v) + : String.format(Locale.ROOT, "%.2f", v); + } + + private static String sec(long nanos) { + return String.format(Locale.ROOT, "%.2f s", nanos / 1e9); + } + + private static String mb(long bytes) { + return String.format(Locale.ROOT, "%.2f MB", bytes / 1e6); + } +} diff --git a/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SelectBench.java b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SelectBench.java new file mode 100644 index 00000000..a8d448fb --- /dev/null +++ b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SelectBench.java @@ -0,0 +1,95 @@ +package com.ebremer.beakgraph.benchmarks; + +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.readers.IndexReader; +import com.ebremer.beakgraph.utils.HDTBitmapDirectory; +import java.util.SplittableRandom; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * select1 on a real store's GSPO object-level bitmap: the accelerated + * superblock/block directory ({@link HDTBitmapDirectory}) vs the linear + * word-scan fallback. Every iterator construction performs several of these, + * and the GPOS nested scan performs two per object group, so this is the + * navigation primitive behind index traversal. + * + *

{@code adjacentPair} mirrors the iterators' actual access pattern - + * {@code select1(rank)} then {@code select1(rank + 1)} to close the range - + * which today costs two full directory descents. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(value = 1, jvmArgsAppend = { + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g"}) +@State(Scope.Benchmark) +public class SelectBench { + + private static final int PROBES = 1 << 16; + private static final int MASK = PROBES - 1; + + @Param({"50000"}) + public int subjects; + + private HDF5Reader reader; + private BitPackedUnSignedLongBuffer bitmap; + private HDTBitmapDirectory directory; + private long[] ranks; + private int cursor; + + @Setup + public void setup() { + reader = new HDF5Reader(SyntheticStore.get(subjects).toFile()); + IndexReader gspo = reader.getIndexReader(Index.GSPO); + bitmap = gspo.getBitmapBuffer('O'); + directory = gspo.getDirectory('O'); + if (directory == null) { + throw new IllegalStateException("Store has no rank/select directory (format version too old?)"); + } + long ones = bitmap.stream().sum(); + SplittableRandom rnd = new SplittableRandom(42); + ranks = new long[PROBES]; + for (int i = 0; i < PROBES; i++) { + // Leave headroom of 1 so adjacentPair's rank+1 stays a valid query. + ranks[i] = 1 + rnd.nextLong(Math.max(1, ones - 1)); + } + } + + @TearDown + public void tearDown() { + reader.close(); + } + + @Benchmark + public long directorySelect1() { + return directory.select1(ranks[cursor++ & MASK]); + } + + @Benchmark + public long linearSelect1() { + return bitmap.select1(ranks[cursor++ & MASK]); + } + + @Benchmark + public long adjacentPair() { + long rank = ranks[cursor++ & MASK]; + return directory.select1(rank) + directory.select1(rank + 1); + } +} diff --git a/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SyntheticStore.java b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SyntheticStore.java new file mode 100644 index 00000000..c6cfcfe8 --- /dev/null +++ b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SyntheticStore.java @@ -0,0 +1,93 @@ +package com.ebremer.beakgraph.benchmarks; + +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Deterministic synthetic BeakGraph store shared by the benchmarks. + * + *

The store is built once per {@code subjects} size into + * {@code target/jmh-data/store-<subjects>.h5} (override the directory with + * {@code -Dbeakgraph.bench.data=...}) and reused by later runs and forks: the + * generator is fully deterministic, so an existing file is always equivalent to + * a fresh build. Delete the directory to force a rebuild. + * + *

Data shape, chosen to exercise each read-path index: + *

    + *
  • Default graph, per subject {@code s<i>} (4 triples each): + * {@code rdf:type ex:Widget} (one low-selectivity predicate), + * {@code ex:name "widget-<i>"} (distinct string literals), + * {@code ex:value <i mod 1000>} (shared integer literals - range-filter fodder), + * {@code ex:link s<(7i+13) mod subjects>} (URI objects forming join chains).
  • + *
  • {@code subjects/4} extra quads spread round-robin over {@value #NAMED_GRAPHS} + * named graphs {@code ex:g<k>}: {@code s<i> ex:tag <k>} - so + * GRAPH-variable and union scans have real multi-graph work to do.
  • + *
+ */ +public final class SyntheticStore { + + public static final String NS = "http://bench.beakgraph.org/"; + public static final int NAMED_GRAPHS = 8; + + private SyntheticStore() {} + + /** Index of the subject that {@code s}'s ex:link points at. */ + public static int linkTarget(int i, int subjects) { + return (int) ((7L * i + 13) % subjects); + } + + /** Path of the store for {@code subjects}, building it first if absent. */ + public static synchronized Path get(int subjects) { + Path dir = Paths.get(System.getProperty("beakgraph.bench.data", "target/jmh-data")); + Path h5 = dir.resolve("store-" + subjects + ".h5"); + if (Files.exists(h5)) { + return h5; + } + try { + Files.createDirectories(dir); + Path trig = dir.resolve("store-" + subjects + ".trig"); + long t0 = System.nanoTime(); + System.err.printf("[SyntheticStore] generating %,d subjects -> %s%n", subjects, trig); + generate(trig, subjects); + System.err.printf("[SyntheticStore] building store -> %s%n", h5); + HDF5Writer.Builder() + .setSource(trig.toFile()) + .setDestination(h5.toFile()) + .setSpatial(false) + .setFeatures(false) + .build() + .write(); + System.err.printf("[SyntheticStore] ready in %.1f s (%,d bytes)%n", + (System.nanoTime() - t0) / 1e9, Files.size(h5)); + } catch (IOException e) { + throw new UncheckedIOException("Failed to build benchmark store " + h5, e); + } + return h5; + } + + private static void generate(Path trig, int subjects) throws IOException { + try (BufferedWriter w = Files.newBufferedWriter(trig, StandardCharsets.UTF_8)) { + w.write("@prefix ex: <" + NS + "> .\n"); + w.write("@prefix rdf: .\n\n"); + for (int i = 0; i < subjects; i++) { + w.write("ex:s" + i + + " a ex:Widget ; ex:name \"widget-" + i + "\" ; ex:value " + (i % 1000) + + " ; ex:link ex:s" + linkTarget(i, subjects) + " .\n"); + } + int tagged = subjects / 4; + for (int k = 0; k < NAMED_GRAPHS; k++) { + w.write("\nex:g" + k + " {\n"); + for (int i = k; i < tagged; i += NAMED_GRAPHS) { + w.write(" ex:s" + i + " ex:tag " + k + " .\n"); + } + w.write("}\n"); + } + } + } +} diff --git a/docs/BeakGraph-HDF5-Architecture.pptx b/docs/BeakGraph-HDF5-Architecture.pptx new file mode 100644 index 00000000..8f3b3f2a Binary files /dev/null and b/docs/BeakGraph-HDF5-Architecture.pptx differ diff --git a/docs/INSTRUCTIONS.md b/docs/INSTRUCTIONS.md new file mode 100644 index 00000000..50bde409 --- /dev/null +++ b/docs/INSTRUCTIONS.md @@ -0,0 +1,233 @@ +# BeakGraph — Instructions + +BeakGraph converts RDF datasets into **HDF5-backed, dictionary-encoded, columnar quad stores** +that can be queried with SPARQL (via Apache Jena) directly from the `.h5` file — locally or over +HTTP range requests — without loading the graph into memory. One `.h5` file is one self-contained, +immutable store. + +--- + +## 1. Requirements + +| Requirement | Notes | +|---|---| +| Java 25+ | The build targets current JDKs. | +| Maven 3.9+ | Standard build. | +| Native HDF5 library | **Only** for the disk-based writers (`-method 1` and `-method 4`). Bundled through the `hdf5-backend-*` Maven profiles / JavaCPP artifacts; the in-memory writers (`-method 0/2/3`) use pure-Java jHDF and need nothing native. | +| Disk workspace | For `-method 1/4`: free space on the order of a few times the uncompressed source (see §7). | + +## 2. Building + +```bash +# Library + tests +mvn clean package + +# Self-contained command-line jar (all dependencies shaded) +mvn -Pcmdlinejar clean package +# -> target/BeakGraph-.jar +``` + +Run the CLI either from the shaded jar or via your own classpath: + +```bash +java -jar target/BeakGraph-.jar -help +``` + +## 3. Quick start + +```bash +# Convert every RDF file under ./data to one .h5 per file under ./out +java -jar BeakGraph.jar -src data/ -dest out/ + +# Convert one file +java -jar BeakGraph.jar -src data/example.ttl.gz -dest out/example.h5 + +# Merge an entire tree into ONE store +java -jar BeakGraph.jar -src data/ -dest all.h5 -merge + +# Serve a store as a SPARQL endpoint on port 8888 +java -jar BeakGraph.jar -endpoint out/example.h5 -port 8888 +``` + +## 4. Command-line reference + +| Option | Default | Description | +|---|---|---| +| `-src ` | — | Source file **or** directory tree. Directories are walked recursively; every supported RDF file (see §5) is converted. | +| `-dest ` | — | Destination file or directory. Required with `-src`. In per-file mode the source tree structure is mirrored with `.h5` extensions. | +| `-method <0-4>` | `0` | Conversion engine — see §6. | +| `-cores ` | `4` | Threads used **inside** one conversion by `-method 2`, `3`, and `4`. | +| `-threads ` | `1` | Number of conversions run **at once** (per-file mode). Each conversion gets its own `-cores` budget — total CPU ≈ `threads × cores`. | +| `-merge` | off | Merge **all** sources under `-src` into ONE store at `-dest` (if `-dest` is an existing directory, writes `/merged.h5`). Blank nodes stay distinct per source document. Works with every `-method`. | +| `-void` | off | Generate the VoID/SD statistics graph (`urn:x-beakgraph:void`) with **exact** in-memory counting (RAM grows with distinct terms). Mutually exclusive with `-voidsketch`. | +| `-voidsketch` | off | Generate the statistics graph with **bounded memory**: exact up to 65,536 distinct nodes per counter, then HyperLogLog estimates (~0.8% error, deterministic). Recommended for `-method 1/4/5`. Mutually exclusive with `-void`. | +| `-spatial` | off | Build the Hilbert-curve spatial index for `geo:wktLiteral` geometry (adds the `urn:x-beakgraph:Spatial` graph). | +| `-features` | off | Also derive 2-D shape features (area, axes, …) for each geometry. Implies work under `-spatial`. | +| `-workdir ` | dest dir | Spill workspace for `-method 1` and `-method 4`. Put this on your fastest disk. | +| `-huge` | off | Legacy shorthand for `-method 1`. An explicit `-method` takes precedence. | +| `-export ` | — | **Export mode**: dump the BeakGraph(s) at `-src` back to RDF instead of converting. Formats: `NT`, `NQ`, `JSON-LD`, `TTL`, `TRIG` (case-insensitive). Output lands next to each `.h5` with the same name and the format's extension. See §5a. | +| `-compress` | off | gzip the `-export` output (adds `.gz` to the file name). | +| `-verify ` | — | **Verify mode**: integrity-check the BeakGraph file at ``, or every `.h5`/`.hdf5` under it when `` is a directory (recursive). One `OK`/`FAIL` line per file plus a summary; exit code `2` if any file is damaged. See §5b. | +| `-deep` | off | With `-verify`: additionally materialize every triple of every graph and reconcile against the index-derived counts (reads the bulk of each file). | +| `-status` | off | Progress bar (per-file mode) and end-of-run counters. | +| `-endpoint ` | — | Serve the store as a SPARQL endpoint instead of converting. | +| `-port ` | `8888` | HTTP port for `-endpoint`. | +| `-timeout ` | `30` | Per-query wall-clock limit in seconds for `-endpoint`; a query over the limit is cancelled and answered with HTTP 503. `0` disables the limit. | +| `-version` / `-v` | — | Print version and exit. | +| `-help` | — | Usage text. | + +**Exit codes:** `0` success · `1` bad arguments / missing paths · `2` at least one conversion failed +(each failure is also logged with its cause; per-file mode continues past failures). `-verify` uses +the same scheme: `2` means at least one damaged file. + +Existing non-empty destination `.h5` files are **skipped** in per-file mode; `-merge` always rebuilds +its destination. All writers build into a sibling `*.tmp` file and publish with an atomic rename — +a failed or interrupted build never corrupts a previous good store. + +## 5. Supported source formats + +Turtle `.ttl`, N-Triples `.nt`, N-Quads `.nq`, TriG `.trig`, RDF/XML `.rdf`, JSON-LD `.jsonld` — +each also as gzip (`.ttl.gz`, …) or zip (`.ttl.zip`, …; the first non-directory zip entry is read). +Named graphs require a quad-capable syntax (TriG / N-Quads). Files with other extensions are +counted and skipped. + +## 5a. Exporting a BeakGraph back to RDF + +```bash +java -jar BeakGraph.jar -src data.h5 -export NQ # -> data.nq +java -jar BeakGraph.jar -src data.h5 -export TTL -compress # -> data.ttl.gz +java -jar BeakGraph.jar -src stores/ -export NT # every .h5 under stores/ +``` + +* `-src` may be one `.h5` file or a directory tree (every `.h5` under it exports). +* **Automatic quad upgrade**: if `TTL` or `NT` is requested but the store holds named + graphs beyond the default graph, the format silently upgrades to its quad form + (`TTL -> TRIG`, `NT -> NQ`) and the extension follows. +* BeakGraph's **internal metadata graphs** (`urn:x-beakgraph:void` statistics and the + `urn:x-beakgraph:Spatial` index) are derived build artifacts: they are excluded from + the export and from the named-graph decision, so a plain-triples store round-trips + to plain triples. +* NT/NQ/TTL/TRIG exports stream (any store size); JSON-LD has no streaming writer and + materializes the dataset in memory - use NQ/TRIG for bulk dumps. +* Writes are atomic (`.tmp` then rename); an existing export is replaced. + +## 5b. Verifying BeakGraph files + +```bash +java -jar BeakGraph.jar -verify data.h5 # one file +java -jar BeakGraph.jar -verify stores/ # every .h5/.hdf5 under stores/, recursive +java -jar BeakGraph.jar -verify stores/ -deep # + full data-level pass +``` + +Run this before publishing files into served storage: a truncated or partially copied +`.h5` otherwise opens fine and only fails later, at query time, when a query first +touches the damaged region (index loading is lazy). + +* **Structural pass (default)**: opens each file with the real reader stack - HDF5 + superblock, `.BG` group, format version, dictionaries - then force-loads every index + present (`GSPO`, `GPOS`) and enumerates the graph list. This maps every dataset the + readers use, so files cut off anywhere in metadata or data extents are caught. + An *absent* index is legal; only a present-but-unloadable one is damage. +* **`-deep`**: additionally streams every triple of every graph, resolving all terms + through the dictionaries, and reconciles the count against the index structure - + catching corruption inside data regions that structural checks pass over. Reads + most of the file; budget roughly export-NT time per file. +* Output: one `OK`/`FAIL` line per file (failures list their reasons), then + `Verified N file(s): M OK, K FAILED`. +* **Exit codes**: `0` all files pass · `1` nothing to verify (or bad path) · `2` at + least one file is damaged. Verification continues past failures, so one run reports + every bad file in a tree. +* A file named explicitly is verified regardless of extension; directory scans pick up + `*.h5` and `*.hdf5` (case-insensitive). Empty files are reported as damaged. + +## 6. Choosing a conversion method + +| `-method` | Engine | Memory | Parallelism | Use when | +|---|---|---|---|---| +| `0` | Sequential in-memory | Whole input on heap | none | Small files, maximum simplicity. | +| `1` | Disk-based (`huge`) | **Bounded** by spill batches | none | Input too big for RAM; native HDF5 required. | +| `2` | Parallel in-memory | Whole input on heap | `-cores` | Medium files, faster than 0. | +| `3` | **Ultra** in-memory | Whole input on heap | `-cores` | Fastest option **for data that fits in RAM**: parallel parse, O(1) id maps, radix-sorted packed keys, parallel index emission. | +| `4` | **hugeUltra** disk-based | **Bounded** by spill batches | `-cores` | Multi-billion-quad builds: the `-method 1` pipeline on parallel machinery — background radix-sorted spills, packed primitive keys, grouped term runs, concurrent stages. Native HDF5 required. | +| `5` | **plaid** disk-based | **Bounded** by spill batches | `-cores` | Method 4 **plus parallel multi-file ingest**: up to `-cores` source documents parse concurrently. The fastest option for `-merge` over many files. Native HDF5 required. | + +Rules of thumb: fits comfortably in heap → `-method 3`. Doesn't fit and merging many files → +`-method 5`; doesn't fit, single giant file → `-method 4`. +Methods 0/1/2 remain for compatibility, minimal-dependency, and low-memory-machine cases. + +For per-file batch conversion of MANY small files, prefer `-threads N` (parallel conversions) +over large `-cores`; for one big file, all the parallelism comes from `-cores`. + +## 7. Very large builds (`-method 4`/`5`, 10⁹–10¹¹ quads) + +```bash +java -Xmx32g -jar BeakGraph.jar \ + -src shards/ -dest giant.h5 -merge \ + -method 5 -cores 32 -workdir /nvme/scratch -status +``` + +* **Workspace**: budget roughly 3–5× the uncompressed source on `-workdir`; use NVMe. + The workspace is a temp directory created per build and removed afterwards. +* **Heap feeds spill batches, not data**: RAM stays bounded regardless of quad count, but bigger + sort runs mean fewer merge levels and much less disk churn. Programmatic users can raise + `setIdSpillBatch` / `setTermSpillBatch` on `HugeUltraHDF5Writer.Builder` (defaults: 4M id + records / 512K term records per run; each id record costs 16 bytes × 2 buffers while sorting). +* **Shard your input**: `-merge` over many files is the natural way to feed 100B quads. +* **Statistics are opt-in**: no VoID/SD graph is written unless you pass `-void` (exact, + in-memory) or `-voidsketch` (bounded memory via HyperLogLog). Readers use the VoID + statistics for join reordering when present and fall back to a fixed heuristic when + absent - for big builds, `-voidsketch` buys statistics-driven query optimization at + ~64 KiB per counter instead of holding the dictionary on the heap. +* Blank nodes are scoped per source document (labels are not preserved in the output format; + readers regenerate labels from dictionary ranks). + +## 8. Programmatic use + +Every engine is a `BeakGraphWriter` with the same builder shape: + +```java +new / *Writer*.Builder() + .setSource(new File("data.ttl.gz")) // or .setSources(List) for a merge + .setDestination(new File("data.h5")) + .setSpatial(false) + .build() + .write(); +``` + +Classes: `hdf5.writers.HDF5Writer` (0) · `huge.HugeHDF5Writer` (1) · +`hdf5.writers.parallel.ParallelHDF5Writer` (2) · `hdf5.writers.ultra.UltraHDF5Writer` (3) · +`hdf5.writers.hugeUltra.HugeUltraHDF5Writer` (4) · `hdf5.writers.plaid.PlaidHDF5Writer` (5). + +Reading: + +```java +try (BeakGraph bg = new BeakGraph(new HDF5Reader(new File("data.h5")))) { + Dataset ds = bg.getDataset(); // query with Jena/ARQ as usual +} +``` + +### Reader tuning (system properties) + +The read path caches aggressively over the immutable store; defaults suit most +workloads, and each knob trades heap for repeated-lookup speed: + +| Property | Default | What it bounds | +|---|---|---| +| `beakgraph.nodetable.cache.size` | `1000000` | Node ⇄ NodeId entries per direction, per open reader (query bind/materialize path). | +| `beakgraph.dict.search.cache.size` | `65536` | Term → dictionary-position entries per dictionary section (locate/search results, hits and misses). | +| `beakgraph.fcd.cache.blocks` | `4096` | Decoded front-coded string blocks per FCD section (each block holds `blockSize`, typically 16, strings). | +| `beakgraph.ffm.threshold` | `2147483647` | Dataset size in bytes above which BeakGraph FFM-maps the region itself instead of using jHDF's ByteBuffer. | +| `beakgraph.scan.parallel.threshold` | `65536` | Minimum index position range for a scan-shaped first pattern (`?s ?p ?o`, or `?s

?o`) to run as a chunked PARALLEL scan on the shared worker pool. `0` (or negative) disables parallel scanning. Chunks stop on query timeout/cancel and on early close (LIMIT). | +| `beakgraph.export.fastpath` | `true` | `-export NT`/`NQ` streams straight off the GSPO index with per-id text memoization (byte-identical output to the generic writer). `false` falls back to the generic StreamRDF writer. | +| `beakgraph.export.textcache` | `262144` | Object-text memo entries for the index export (cleared wholesale when full). | + +JMH benchmarks for the read path live in `benchmarks/` (see its README) - use +them to validate any tuning against your own store shape. + +## 9. Output guarantees + +* One HDF5 format, one reader stack, for every method (format version 3). +* Methods 0/2/3 produce **structurally identical** stores for the same single source + (same datasets, sizes, attributes); methods 1/4 produce **isomorphic** stores + (blank-node labels are rank-derived rather than relabelled). +* See `docs/BeakGraph-HDF5-Architecture.pptx` for the on-disk format design. diff --git a/pom.xml b/pom.xml index 45a6c913..f681189c 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.ebremer BeakGraph BeakGraph - 0.16.0 + 0.17.0 jar Library for creating indexed binary storages for Apache Jena Graphs and Datasets @@ -40,7 +40,7 @@ com.ebremer.beakgraph.cmdline.BeakGraphCLI - 5.6.0 + 6.1.0 1.28.0 4.5.0 @@ -52,7 +52,7 @@ log4j-config-yaml module is not needed and does not exist for 2.x. --> 2.25.2 2.0.17 - 0.10.0 + 0.12.0 - 3.2.2 + + 3.2.3 3.9.0 diff --git a/src/main/java/com/ebremer/beakgraph/BG.java b/src/main/java/com/ebremer/beakgraph/BG.java index 274f1487..26b5bbf4 100644 --- a/src/main/java/com/ebremer/beakgraph/BG.java +++ b/src/main/java/com/ebremer/beakgraph/BG.java @@ -1,10 +1,13 @@ package com.ebremer.beakgraph; import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.core.HTTPSeekableByteChannel; import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; import java.io.File; import java.io.IOException; +import java.net.URI; +import java.nio.channels.SeekableByteChannel; import java.nio.file.Path; /** @@ -32,4 +35,36 @@ public static BeakGraph getBeakGraph(File file) throws IOException { public static BeakGraph getBeakGraph(Path path) throws IOException { return getBeakGraph(path.toFile()); } + + /** + * Opens a BeakGraph over any {@link SeekableByteChannel} - e.g. + * {@code new HTTPSeekableByteChannel(uri)} to query a remote file in + * place via HTTP range requests. The graph takes ownership of the + * channel: closing the graph closes it, and it is also released if + * opening fails. + */ + public static BeakGraph getBeakGraph(SeekableByteChannel sbc) throws IOException { + URI source = (sbc instanceof HTTPSeekableByteChannel http) + ? http.getURI() + : URI.create("bg:/channel"); + return getBeakGraph(sbc, source); + } + + /** + * As {@link #getBeakGraph(SeekableByteChannel)}, with an explicit URI to + * report as the graph's identity ({@code BeakGraph.getURI()}). + * @param sbc + * @param source + * @return + * @throws java.io.IOException + */ + public static BeakGraph getBeakGraph(SeekableByteChannel sbc, URI source) throws IOException { + HDF5Reader reader = new HDF5Reader(sbc, source); // owns (and on failure closes) the channel + try { + return new BeakGraph(reader); + } catch (RuntimeException | Error e) { + try { reader.close(); } catch (Exception ignore) {} + throw e; + } + } } diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java index 118c4944..8795c255 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java @@ -4,13 +4,23 @@ import com.beust.jcommander.ParameterException; import com.ebremer.beakgraph.Params; import com.ebremer.beakgraph.core.fuseki.SPARQLEndPoint; +import com.ebremer.beakgraph.core.BeakGraphWriter; import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import com.ebremer.beakgraph.hdf5.writers.parallel.ParallelHDF5Writer; +import com.ebremer.beakgraph.hdf5.writers.hugeUltra.HugeUltraHDF5Writer; +import com.ebremer.beakgraph.hdf5.writers.plaid.PlaidHDF5Writer; +import com.ebremer.beakgraph.hdf5.writers.ultra.UltraHDF5Writer; import com.ebremer.beakgraph.huge.HugeHDF5Writer; +import com.ebremer.beakgraph.utils.RdfSources; +import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; @@ -26,7 +36,8 @@ /** * Command-line entry point for BeakGraph ("beakgraph" command): converts RDF source trees to - * HDF5-backed graphs, or starts the SPARQL/LWS endpoint. + * HDF5-backed graphs, exports them back to RDF, verifies file integrity (-verify), or starts + * the SPARQL/LWS endpoint. * * @author Erich Bremer */ @@ -43,11 +54,18 @@ public FileCounter getFileCounter() { public BeakGraphCLI(Parameters params) { JenaSystem.init(); + if (params.voidExact && params.voidSketch) { + throw new IllegalArgumentException( + "-void and -voidsketch are mutually exclusive: pick exact in-memory statistics " + + "(-void) or bounded-memory HyperLogLog statistics (-voidsketch)"); + } this.params = params; this.fc = new FileCounter(); String os = System.getProperty("os.name").toLowerCase(); ProgressBarStyle style = os.contains("win") ? ProgressBarStyle.ASCII : ProgressBarStyle.COLORFUL_UNICODE_BLOCK; - if (params.status) { + // -merge is one big conversion (and -export its own flow), not a stream + // of per-file tasks; the per-file progress bar would only render empty. + if (params.status && !params.merge && params.export == null) { progressBar = new ProgressBarBuilder() .setTaskName("Processing RDF Source Files...") .setInitialMax(0) @@ -64,6 +82,12 @@ public static void main(String[] args) throws FileNotFoundException, IOException if (args.length != 0) { try { jc.parse(args); + if (params.voidExact && params.voidSketch) { + System.err.println("Error: -void and -voidsketch are mutually exclusive. " + + "Use -void for exact in-memory statistics or -voidsketch for the " + + "bounded-memory HyperLogLog version."); + System.exit(1); + } if (params.version) { // Must be handled on the SUCCESS path: version was previously // printed only inside the ParameterException catch, so a plain @@ -80,6 +104,8 @@ public static void main(String[] args) throws FileNotFoundException, IOException System.err.println("Error: -endpoint does not exist: " + params.sparqlendpoint); System.exit(1); } + // BGSparqlService reads the limit per query from this property. + System.setProperty("beakgraph.query.timeout.seconds", Long.toString(params.timeout)); SPARQLEndPoint endpoint = SPARQLEndPoint.getSPARQLEndPoint(params); Runtime.getRuntime().addShutdownHook(new Thread(() -> endpoint.shutdown())); System.out.println("Press Ctrl+C to stop the server..."); @@ -88,6 +114,20 @@ public static void main(String[] args) throws FileNotFoundException, IOException } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } else if (params.verify != null) { + if (!params.verify.exists()) { + System.err.println("Error: -verify path does not exist: " + params.verify); + System.exit(1); + } + System.exit(new VerifyCommand(params.verify, params.deep).run()); + } else if (params.src != null && params.src.exists() && params.export != null) { + // Export mode: dump BeakGraph(s) back to RDF; no -dest + // involved (output lands beside each source .h5). + BeakGraphCLI bg = new BeakGraphCLI(params); + bg.export(); + if (bg.fc.getFailedConversionFileCount() > 0) { + System.exit(2); + } } else if (params.src != null && params.src.exists()) { if (params.dest == null) { // Without this guard every FileProcessor NPEs inside a @@ -99,7 +139,11 @@ public static void main(String[] args) throws FileNotFoundException, IOException } JenaSystem.init(); BeakGraphCLI bg = new BeakGraphCLI(params); - bg.traverse(); + if (params.merge) { + bg.merge(); + } else { + bg.traverse(); + } if (bg.fc.getFailedConversionFileCount() > 0) { System.exit(2); } @@ -136,7 +180,7 @@ public void traverse() { fc.incrementZeroLengthFileCount(); return false; } - if (p.toFile().toString().toLowerCase().endsWith(".ttl.gz") || p.toFile().toString().toLowerCase().endsWith(".ttl")) { + if (RdfSources.isSupported(p.getFileName().toString())) { return true; } fc.incrementOtherFileCount(); @@ -172,6 +216,363 @@ public void traverse() { } } + /** + * -merge: parse every supported RDF source under -src into ONE BeakGraph + * HDF5 file at -dest (or {@code /merged.h5} when -dest is an existing + * directory). Sources are taken in sorted path order so repeated merges of + * the same tree are deterministic; blank nodes stay distinct per source + * document. The destination is rebuilt - the write is atomic, so a failed + * rebuild never destroys a previous good artifact. + */ + public void merge() { + final List inputs = new ArrayList<>(); + try (var walk = Files.walk(params.src.toPath())) { + walk.filter(p -> { + File f = p.toFile(); + if (f.isDirectory()) { + fc.incrementDirectoryCount(); + return false; + } + if (f.length() == 0) { + fc.incrementZeroLengthFileCount(); + return false; + } + if (RdfSources.isSupported(p.getFileName().toString())) { + return true; + } + fc.incrementOtherFileCount(); + return false; + }) + .sorted() + .forEach(p -> { + fc.incrementRDFFileCount(); + inputs.add(p.toFile()); + }); + } catch (IOException ex) { + logger.error("Failed to scan source tree {}", params.src, ex); + } + if (inputs.isEmpty()) { + System.err.println("No supported RDF sources found under " + params.src); + return; + } + File dest = params.dest; + if (dest.isDirectory()) { + dest = new File(dest, "merged.h5"); + } + if (dest.getParentFile() != null) { + dest.getParentFile().mkdirs(); + } + logger.info("Merging {} RDF sources into {} (method {})", inputs.size(), dest, effectiveMethod()); + try { + // All sources feed the ONE store being written; blank nodes stay + // distinct per document in every engine. + newWriter(null, inputs, dest).write(); + } catch (Exception ex) { + fc.incrementFailedConversionFileCount(); + logger.error("Failed to merge {} sources into {}", inputs.size(), dest, ex); + } + if (params.status) { + System.out.println(fc); + } + } + + /** + * -export: dump the BeakGraph(s) at -src back to RDF. -src may be one .h5 + * file or a directory tree of them; each store exports to a sibling file + * with the same name and the format's extension (plus .gz with -compress). + * TTL/NT are upgraded to TRIG/NQ when a store holds named graphs beyond + * the default graph. BeakGraph-internal metadata graphs (the VoID + * statistics and spatial index, urn:x-beakgraph:*) are derived build + * artifacts and are excluded - both from the dump and from the "has named + * graphs" decision, so a plain-triples store round-trips to plain triples. + */ + public void export() { + final List inputs = new ArrayList<>(); + if (params.src.isDirectory()) { + try (var walk = Files.walk(params.src.toPath())) { + walk.filter(p -> p.toFile().isFile() + && p.getFileName().toString().toLowerCase().endsWith(".h5") + && p.toFile().length() > 0) + .sorted() + .forEach(p -> inputs.add(p.toFile())); + } catch (IOException ex) { + logger.error("Failed to scan source tree {}", params.src, ex); + } + } else { + inputs.add(params.src); + } + if (inputs.isEmpty()) { + System.err.println("No BeakGraph (.h5) files found under " + params.src); + return; + } + for (File h5 : inputs) { + fc.incrementRDFFileCount(); + try { + exportOne(h5); + } catch (Exception ex) { + fc.incrementFailedConversionFileCount(); + logger.error("Failed to export {}", h5, ex); + } + } + if (params.status) { + System.out.println(fc); + } + } + + private void exportOne(File h5) throws Exception { + String fmt = ExportFormatValidator.normalize(params.export); + try (com.ebremer.beakgraph.core.BeakGraph bg = new com.ebremer.beakgraph.core.BeakGraph( + new com.ebremer.beakgraph.hdf5.readers.HDF5Reader(h5))) { + org.apache.jena.sparql.core.DatasetGraph dsg = bg.getDataset().asDatasetGraph(); + boolean named = hasUserNamedGraphs(dsg); + if (named && "NT".equals(fmt)) { + logger.info("{} holds named graphs: exporting NQ instead of NT", h5.getName()); + fmt = "NQ"; + } else if (named && "TTL".equals(fmt)) { + logger.info("{} holds named graphs: exporting TRIG instead of TTL", h5.getName()); + fmt = "TRIG"; + } + String ext = switch (fmt) { + case "NT" -> "nt"; + case "NQ" -> "nq"; + case "TTL" -> "ttl"; + case "TRIG" -> "trig"; + default -> "jsonld"; + }; + String base = h5.getName(); + if (base.toLowerCase().endsWith(".h5")) { + base = base.substring(0, base.length() - 3); + } + Path out = h5.toPath().resolveSibling(base + "." + ext + (params.compress ? ".gz" : "")); + Path tmp = out.resolveSibling(out.getFileName() + ".tmp"); + logger.info("Exporting {} -> {} ({})", h5.getName(), out.getFileName(), fmt); + try (OutputStream os = openExportStream(tmp)) { + writeExport(os, dsg, fmt); + } catch (Exception ex) { + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) {} + throw ex; + } + try { + Files.move(tmp, out, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(tmp, out, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + logger.info("Export complete: {}", out); + } + } + + private OutputStream openExportStream(Path tmp) throws IOException { + OutputStream os = new java.io.BufferedOutputStream(Files.newOutputStream(tmp), 1 << 17); + return params.compress ? new java.util.zip.GZIPOutputStream(os, 1 << 16) : os; + } + + /** A graph the USER put in the store (not BeakGraph's own metadata graphs). */ + private static boolean isUserGraph(org.apache.jena.graph.Node g) { + return !Params.BGVOID.equals(g) && !Params.SPATIAL.equals(g); + } + + private static boolean hasUserNamedGraphs(org.apache.jena.sparql.core.DatasetGraph dsg) { + var it = dsg.listGraphNodes(); + while (it.hasNext()) { + if (isUserGraph(it.next())) { + return true; + } + } + return false; + } + + /** + * NT/NQ line formats stream straight off the GSPO index with per-id text + * memoization (see IndexExport) when the store supports it; false means + * nothing was written and the generic writer below runs instead. + */ + private static boolean tryIndexExport(OutputStream os, org.apache.jena.sparql.core.DatasetGraph dsg, + boolean quads) throws IOException { + if (dsg instanceof com.ebremer.beakgraph.core.BGDatasetGraph bgd + && bgd.getBeakGraph().getReader() instanceof com.ebremer.beakgraph.hdf5.readers.HDF5Reader reader) { + return com.ebremer.beakgraph.hdf5.jena.IndexExport.tryWrite(reader, os, quads); + } + return false; + } + + private static void writeExport(OutputStream os, org.apache.jena.sparql.core.DatasetGraph dsg, String fmt) + throws IOException { + switch (fmt) { + case "NT", "TTL" -> { + // Triple export: by this point the store has no user named + // graphs, so the default graph IS the data. + if ("NT".equals(fmt) && tryIndexExport(os, dsg, false)) { + return; + } + org.apache.jena.riot.system.StreamRDF stream = org.apache.jena.riot.system.StreamRDFWriter + .getWriterStream(os, "NT".equals(fmt) + ? org.apache.jena.riot.RDFFormat.NTRIPLES + : org.apache.jena.riot.RDFFormat.TURTLE_BLOCKS); + stream.start(); + var it = dsg.getDefaultGraph().find(); + while (it.hasNext()) { + stream.triple(it.next()); + } + stream.finish(); + } + case "NQ", "TRIG" -> { + if ("NQ".equals(fmt) && tryIndexExport(os, dsg, true)) { + return; + } + org.apache.jena.riot.system.StreamRDF stream = org.apache.jena.riot.system.StreamRDFWriter + .getWriterStream(os, "NQ".equals(fmt) + ? org.apache.jena.riot.RDFFormat.NQUADS + : org.apache.jena.riot.RDFFormat.TRIG_BLOCKS); + stream.start(); + var it = dsg.find(); + while (it.hasNext()) { + var q = it.next(); + if (isUserGraph(q.getGraph())) { + stream.quad(q); + } + } + stream.finish(); + } + default -> { + // JSON-LD has no streaming writer: materialize the filtered + // dataset. Fine for JSON-LD-sized data; use NQ/TRIG for bulk. + org.apache.jena.sparql.core.DatasetGraph copy = + org.apache.jena.sparql.core.DatasetGraphFactory.create(); + var it = dsg.find(); + while (it.hasNext()) { + var q = it.next(); + if (isUserGraph(q.getGraph())) { + copy.add(q); + } + } + org.apache.jena.riot.RDFDataMgr.write(os, copy, org.apache.jena.riot.Lang.JSONLD); + } + } + } + + /** The VoID statistics mode for this run: NONE unless -void or -voidsketch was given. */ + private com.ebremer.beakgraph.core.VoidMode voidMode() { + if (params.voidExact) { + return com.ebremer.beakgraph.core.VoidMode.EXACT; + } + if (params.voidSketch) { + return com.ebremer.beakgraph.core.VoidMode.SKETCH; + } + return com.ebremer.beakgraph.core.VoidMode.NONE; + } + + /** + * The conversion engine for this run: {@code -method} (0 = in-memory, + * 1 = disk, 2 = parallel, 3 = ultra), with the legacy {@code -huge} flag + * acting as "-method 1" when no explicit -method was given. + */ + private int effectiveMethod() { + return (params.method == 0 && params.huge) ? 1 : params.method; + } + + /** + * Builds the writer selected by {@link #effectiveMethod()} for one + * source-or-sources -> destination conversion. Shared by the per-file + * processors and -merge so the two can never route differently. + */ + private BeakGraphWriter newWriter(File source, List sources, File dest) throws IOException { + switch (effectiveMethod()) { + case 1 -> { + // Disk-based build: same output format, but sorting/indexing + // spill to a workspace instead of the heap. Needs the native + // HDF5 backend on the classpath (the hdf5-backend-* profiles); + // with -threads N, N builds run concurrently, each with its + // own workspace. + HugeHDF5Writer.Builder builder = HugeHDF5Writer.Builder() + .setDestination(dest) + .setVoidMode(voidMode()) + .setSpatial(params.spatial) + .setFeatures(params.features); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + if (params.workdir != null) { + params.workdir.mkdirs(); + builder.setWorkDirectory(params.workdir.toPath()); + } + return builder.build(); + } + case 2 -> { + // Multi-threaded in-memory build on a pool of -cores threads. + ParallelHDF5Writer.Builder builder = ParallelHDF5Writer.Builder() + .setDestination(dest) + .setVoidMode(voidMode()) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + return builder.build(); + } + case 3 -> { + // Ultra in-memory build: parallel parse, packed-key radix-sorted + // indexes, parallel emission - also capped at -cores threads. + UltraHDF5Writer.Builder builder = UltraHDF5Writer.Builder() + .setDestination(dest) + .setVoidMode(voidMode()) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + return builder.build(); + } + case 4 -> { + // hugeUltra: the disk-based pipeline (bounded RAM, any quad + // count) on parallel primitive sorting machinery - the engine + // for multi-billion-quad builds. Needs the native HDF5 backend. + HugeUltraHDF5Writer.Builder builder = HugeUltraHDF5Writer.Builder() + .setDestination(dest) + .setVoidMode(voidMode()) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + if (params.workdir != null) { + params.workdir.mkdirs(); + builder.setWorkDirectory(params.workdir.toPath()); + } + return builder.build(); + } + case 5 -> { + // plaid: hugeUltra plus parallel multi-file ingest - up to + // -cores documents parse concurrently. The engine of choice + // for -merge over many files. Needs the native HDF5 backend. + PlaidHDF5Writer.Builder builder = PlaidHDF5Writer.Builder() + .setDestination(dest) + .setVoidMode(voidMode()) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + if (params.workdir != null) { + params.workdir.mkdirs(); + builder.setWorkDirectory(params.workdir.toPath()); + } + return builder.build(); + } + default -> { + HDF5Writer.Builder builder = HDF5Writer.Builder() + .setDestination(dest) + .setVoidMode(voidMode()) + .setSpatial(params.spatial) + .setFeatures(params.features); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + return builder.build(); + } + } + } + public static Path mapToDestinationWithNewExtension(Path srcFile, Path srcDirectory, Path destDirectory, String newExt) { Path normalizedSrcFile = srcFile.normalize(); Path normalizedSrcDir = srcDirectory.normalize(); @@ -210,31 +611,7 @@ public Model call() { return null; } dest.getParent().toFile().mkdirs(); - if (params.huge) { - // Disk-based build: same output format, but sorting/indexing - // spill to a workspace instead of the heap. Note the huge - // writer needs the native HDF5 backend on the classpath (the - // hdf5-backend-* profiles); with -threads N, N builds run - // concurrently, each with its own workspace. - HugeHDF5Writer.Builder builder = HugeHDF5Writer.Builder() - .setSource(src.toFile()) - .setDestination(dest.toFile()) - .setSpatial(params.spatial) - .setFeatures(params.features); - if (params.workdir != null) { - params.workdir.mkdirs(); - builder.setWorkDirectory(params.workdir.toPath()); - } - builder.build().write(); - } else { - HDF5Writer.Builder() - .setSource(src.toFile()) - .setDestination(dest.toFile()) - .setSpatial(params.spatial) - .setFeatures(params.features) - .build() - .write(); - } + newWriter(src.toFile(), null, dest.toFile()).write(); } catch (Exception ex) { fc.incrementFailedConversionFileCount(); logger.error("Failed to convert {}", src, ex); diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/ExportFormatValidator.java b/src/main/java/com/ebremer/beakgraph/cmdline/ExportFormatValidator.java new file mode 100644 index 00000000..7ce7aca0 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/cmdline/ExportFormatValidator.java @@ -0,0 +1,32 @@ +package com.ebremer.beakgraph.cmdline; + +import com.beust.jcommander.IParameterValidator; +import com.beust.jcommander.ParameterException; + +/** + * Validates {@code -export}: NT, NQ, JSON-LD (or JSONLD), TTL, or TRIG, + * case-insensitively. + */ +public class ExportFormatValidator implements IParameterValidator { + + /** Canonical form (NT/NQ/JSONLD/TTL/TRIG), or null if unrecognized. */ + static String normalize(String value) { + if (value == null) return null; + return switch (value.trim().toUpperCase().replace("-", "")) { + case "NT" -> "NT"; + case "NQ" -> "NQ"; + case "JSONLD" -> "JSONLD"; + case "TTL" -> "TTL"; + case "TRIG" -> "TRIG"; + default -> null; + }; + } + + @Override + public void validate(String name, String value) throws ParameterException { + if (normalize(value) == null) { + throw new ParameterException("Parameter " + name + + " must be NT, NQ, JSON-LD, TTL, or TRIG; found \"" + value + "\""); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java b/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java new file mode 100644 index 00000000..a0de3d90 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java @@ -0,0 +1,28 @@ +package com.ebremer.beakgraph.cmdline; + +import com.beust.jcommander.IParameterValidator; +import com.beust.jcommander.ParameterException; + +/** + * Validates {@code -method}: 0 = sequential in-memory, 1 = disk-based (huge), + * 2 = parallel in-memory, 3 = ultra in-memory, 4 = parallel disk-based + * (hugeUltra), 5 = parallel disk-based with parallel multi-file ingest + * (plaid). + */ +public class MethodValidator implements IParameterValidator { + + @Override + public void validate(String name, String value) throws ParameterException { + boolean ok; + try { + int v = Integer.parseInt(value); + ok = v >= 0 && v <= 5; + } catch (NumberFormatException e) { + ok = false; + } + if (!ok) { + throw new ParameterException("Parameter " + name + " must be 0 (in-memory), 1 (disk), " + + "2 (parallel), 3 (ultra), 4 (hugeUltra disk), or 5 (plaid disk); found \"" + value + "\""); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java index 71ddf798..40223254 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java @@ -2,6 +2,7 @@ import com.beust.jcommander.Parameter; import com.beust.jcommander.converters.BooleanConverter; +import com.beust.jcommander.validators.PositiveInteger; import java.io.File; /** @@ -18,6 +19,12 @@ public class Parameters { @Parameter(names = "-port", description = "Set HTTP port when endpoint started", required = false) public int port = 8888; + + @Parameter(names = "-timeout", + description = "Per-query wall-clock limit in seconds for -endpoint (0 = unlimited). " + + "A query over the limit is cancelled and answered with HTTP 503", + required = false) + public long timeout = 30; @Parameter(names = "-src", description = "Source Folder or File", required = false) public File src = null; @@ -25,6 +32,24 @@ public class Parameters { @Parameter(names = "-dest", description = "Destination Folder or File", required = false) public File dest = null; + @Parameter(names = {"-void"}, converter = BooleanConverter.class, + description = + """ + Generate the VoID/SD statistics graph (urn:x-beakgraph:void) using + EXACT in-memory counting (RAM grows with distinct terms per graph). + Without -void or -voidsketch, no statistics graph is written and + readers fall back to a fixed join-reorder heuristic. + Mutually exclusive with -voidsketch + """) + public boolean voidExact = false; + + @Parameter(names = {"-voidsketch"}, converter = BooleanConverter.class, + description = "Generate the VoID/SD statistics graph with BOUNDED memory: exact up " + + "to 65536 distinct nodes per counter, then HyperLogLog estimates " + + "(~0.8% error, deterministic). Recommended for the disk writers " + + "(-method 1/4/5). Mutually exclusive with -void") + public boolean voidSketch = false; + @Parameter(names = {"-spatial"}, converter = BooleanConverter.class) public boolean spatial = false; @@ -32,8 +57,8 @@ public class Parameters { public boolean features = false; @Parameter(names = {"-huge"}, converter = BooleanConverter.class, - description = "Use the disk-based writer (com.ebremer.beakgraph.huge): sorts and " - + "indexes on disk instead of RAM, for sources too large for the heap") + description = "Shorthand for \"-method 1\": the disk-based writer " + + "(com.ebremer.beakgraph.huge). An explicit -method takes precedence") public boolean huge = false; @Parameter(names = "-workdir", @@ -42,6 +67,69 @@ public class Parameters { + "destination file's directory)", required = false) public File workdir = null; + @Parameter(names = {"-merge"}, converter = BooleanConverter.class, + description = "Merge ALL RDF sources found under -src (typically a directory tree) " + + "into ONE BeakGraph HDF5 file at -dest instead of one .h5 per source " + + "(if -dest is an existing directory, writes /merged.h5; an " + + "existing destination file is rebuilt). Blank nodes stay distinct per " + + "source document. Works with every -method") + public boolean merge = false; + + @Parameter(names = "-method", validateWith = MethodValidator.class, + description = "Conversion engine: 0 = sequential in-memory writer (default), " + + "1 = disk-based writer for sources too large for the heap " + + "(com.ebremer.beakgraph.huge; needs the native HDF5 backend, see -workdir), " + + "2 = multi-threaded in-memory writer " + + "(com.ebremer.beakgraph.hdf5.writers.parallel) on -cores threads, " + + "3 = ultra in-memory writer (com.ebremer.beakgraph.hdf5.writers.ultra): " + + "parallel parsing, radix-sorted packed-key indexes, and parallel index " + + "emission on -cores threads, " + + "4 = hugeUltra parallel DISK-based writer " + + "(com.ebremer.beakgraph.hdf5.writers.hugeUltra) for multi-billion-quad " + + "builds: bounded RAM like -method 1, but with radix-sorted bit-packed " + + "spill runs, background spilling, and concurrent pipeline stages on " + + "-cores threads (needs the native HDF5 backend; honors -workdir), " + + "5 = plaid (com.ebremer.beakgraph.hdf5.writers.plaid): method 4 plus " + + "PARALLEL MULTI-FILE INGEST - up to -cores source documents parse " + + "concurrently; the fastest option for -merge over many files") + public int method = 0; + + @Parameter(names = "-cores", validateWith = PositiveInteger.class, + description = "# of threads each -method 2 or -method 3 conversion may use (with " + + "-threads N, N conversions run at once, each capped at -cores)") + public int cores = 4; + + @Parameter(names = "-export", validateWith = ExportFormatValidator.class, + description = "Dump the BeakGraph(s) at -src back to RDF instead of converting: " + + "NT, NQ, JSON-LD, TTL, or TRIG. Output lands next to each .h5 with the " + + "same name and the format's extension. If TTL or NT is chosen but the " + + "store holds named graphs beyond the default graph, the format is " + + "upgraded to its quad form (TTL->TRIG, NT->NQ). BeakGraph-internal " + + "metadata graphs (VoID/spatial index) are not exported") + public String export = null; + + @Parameter(names = {"-compress"}, converter = BooleanConverter.class, + description = "gzip the -export output (adds .gz to the file name)") + public boolean compress = false; + + @Parameter(names = "-verify", + description = "Verify BeakGraph file integrity instead of converting: opens each " + + "file, loads the dictionaries and every index structure, and " + + "enumerates its graphs - catching truncated, partially copied, or " + + "otherwise damaged files before they are served. The path may be one " + + "file or a directory (scanned recursively for *.h5/*.hdf5). Prints one " + + "verdict line per file plus a summary; exit code 2 if any file is " + + "damaged. Add -deep for a data-level pass") + public File verify = null; + + @Parameter(names = {"-deep"}, converter = BooleanConverter.class, + description = "With -verify: additionally materialize every triple of every graph " + + "(resolving all terms through the dictionaries) and reconcile the " + + "totals against the index-derived counts. Reads through the bulk of " + + "each file - slower, but catches data-region corruption that the " + + "structural checks pass over") + public boolean deep = false; + @Parameter(names = {"-version","-v"}, converter = BooleanConverter.class) public boolean version = false; diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/VerifyCommand.java b/src/main/java/com/ebremer/beakgraph/cmdline/VerifyCommand.java new file mode 100644 index 00000000..217f7c44 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/cmdline/VerifyCommand.java @@ -0,0 +1,219 @@ +package com.ebremer.beakgraph.cmdline; + +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.Triple; +import org.apache.jena.sparql.core.Quad; +import org.apache.jena.util.iterator.ExtendedIterator; + +/** + * {@code -verify}: integrity check for BeakGraph HDF5 files - one file or a + * directory tree ({@code *.h5}/{@code *.hdf5}, recursive). Meant as the gate a + * pipeline runs before publishing files into served storage, where a + * truncated or partially copied file would otherwise surface lazily as a + * query-time failure. + * + *

The default (structural) pass opens each file with the real reader stack + * and forces everything a query would eventually need: dictionaries load, + * every index present must construct (a present-but-unreadable index is the + * signature of truncation), and the graph list must enumerate. {@code -deep} + * additionally materializes every triple of every graph - resolving all terms + * through the dictionaries - and reconciles the total against the + * index-derived count, catching data-region corruption that structure alone + * passes over. + * + *

One verdict line per file ({@code OK}/{@code FAIL} plus reasons), then a + * summary. Exit codes follow the CLI convention: 0 all files pass, 1 nothing + * to verify, 2 at least one file is damaged (verification continues past + * failures). + * + * @author Erich Bremer + */ +public final class VerifyCommand { + + private final File root; + private final boolean deep; + private final PrintStream out; + + public VerifyCommand(File root, boolean deep) { + this(root, deep, System.out); + } + + VerifyCommand(File root, boolean deep, PrintStream out) { + this.root = root; + this.deep = deep; + this.out = out; + } + + /** Runs the verification and returns the process exit code (0/1/2). */ + public int run() { + List files; + try { + files = collect(); + } catch (IOException e) { + out.println("Error scanning " + root + ": " + e.getMessage()); + return 1; + } + if (files.isEmpty()) { + out.println("No BeakGraph (.h5/.hdf5) files found under " + root); + return 1; + } + int failed = 0; + for (Path f : files) { + List problems = verifyOne(f); + if (problems.isEmpty()) { + out.println("OK " + f); + } else { + failed++; + out.println("FAIL " + f); + for (String problem : problems) { + out.println(" - " + problem); + } + } + } + out.println("Verified " + files.size() + " file(s)" + (deep ? " (deep)" : "") + + ": " + (files.size() - failed) + " OK, " + failed + " FAILED"); + return (failed > 0) ? 2 : 0; + } + + private List collect() throws IOException { + Path path = root.toPath(); + if (Files.isRegularFile(path)) { + // An explicitly named file is verified regardless of its extension. + return List.of(path); + } + try (Stream walk = Files.walk(path)) { + return walk.filter(Files::isRegularFile) + .filter(VerifyCommand::isHdf5Name) + .sorted() + .toList(); + } + } + + private static boolean isHdf5Name(Path p) { + String name = p.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".h5") || name.endsWith(".hdf5"); + } + + /** Verifies one file; an empty list means it passed. */ + private List verifyOne(Path f) { + List problems = new ArrayList<>(); + long size; + try { + size = Files.size(f); + } catch (IOException e) { + problems.add("unreadable: " + rootMessage(e)); + return problems; + } + if (size == 0) { + problems.add("empty file"); + return problems; + } + try (HDF5Reader reader = new HDF5Reader(f.toFile())) { + checkStructure(reader, problems); + if (deep && problems.isEmpty()) { + deepScan(reader, problems); + } + } catch (Exception e) { + // The check methods record their own failures, so what lands here is + // construction: not HDF5, not a BeakGraph, unsupported format + // version, or metadata/dictionaries cut off mid-file. + problems.add("cannot open: " + rootMessage(e)); + } + return problems; + } + + /** + * Structural pass: every index present must construct (this walks the + * index groups and maps every dataset the readers use), and the graph + * list must enumerate. An absent index is legal - only a present index + * that fails to load is damage. + */ + private static void checkStructure(HDF5Reader reader, List problems) { + int present = 0; + int broken = 0; + for (Index idx : Index.values()) { + try { + if (reader.getIndexReader(idx) != null) { + present++; + } + } catch (Exception e) { + broken++; + problems.add("index " + idx + ": " + rootMessage(e)); + } + } + if (present == 0 && broken == 0) { + problems.add("no indexes present (GSPO and GPOS both absent)"); + } + try { + Iterator graphs = reader.listGraphNodes(); + while (graphs.hasNext()) { + graphs.next(); + } + } catch (Exception e) { + problems.add("graph enumeration: " + rootMessage(e)); + } + } + + /** + * Data pass: stream every triple of every graph, which resolves every id + * through the dictionaries, and reconcile against the index-derived + * count. The reader silently drops rows whose terms cannot be resolved, + * so a shortfall against the structural count is exactly the corruption + * signal this pass exists to catch. + */ + private void deepScan(HDF5Reader reader, List problems) { + List graphs = new ArrayList<>(); + graphs.add(Quad.defaultGraphIRI); + Iterator it = reader.listGraphNodes(); + while (it.hasNext()) { + Node g = it.next(); + if (!Quad.defaultGraphIRI.equals(g)) { + graphs.add(g); + } + } + for (Node g : graphs) { + try { + long expected = reader.countTriples(g); // -1 = not index-answerable + long actual = 0; + ExtendedIterator triples = + reader.graphBaseFind(g, Triple.create(Node.ANY, Node.ANY, Node.ANY)); + try { + while (triples.hasNext()) { + triples.next(); + actual++; + } + } finally { + triples.close(); + } + if (expected >= 0 && actual != expected) { + problems.add("graph " + g + ": materialized " + actual + " of " + expected + + " indexed triples (unresolvable terms or index damage)"); + } + } catch (Exception e) { + problems.add("graph " + g + " scan: " + rootMessage(e)); + } + } + } + + /** The deepest cause's message - where the actual I/O failure is named. */ + private static String rootMessage(Throwable t) { + Throwable root = t; + while (root.getCause() != null) { + root = root.getCause(); + } + String message = root.getMessage(); + return (message == null || message.isBlank()) ? root.getClass().getSimpleName() : message; + } +} diff --git a/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java b/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java index b010c85c..fcf49e87 100644 --- a/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java +++ b/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java @@ -1,16 +1,19 @@ package com.ebremer.beakgraph.core; import java.io.File; +import java.util.List; import org.apache.jena.query.Dataset; // T refers to the concrete class (e.g., HDF5Writer.Builder) public abstract class AbstractGraphBuilder> { - + protected File src; protected File dest; + protected List sources = List.of(); protected Dataset ds; protected boolean spatial; protected boolean features; + protected VoidMode voidMode = VoidMode.NONE; // Force the concrete class to return 'this' protected abstract T self(); @@ -24,7 +27,33 @@ public T setDestination(File file) { this.dest = file; return self(); } + + /** + * Merge mode: every given document is parsed into the ONE store being + * written (blank nodes stay distinct per document). When non-empty this + * takes precedence over {@link #setSource}. + */ + public T setSources(List files) { + this.sources = List.copyOf(files); + return self(); + } + + public List getSources() { return sources; } + /** + * Whether/how the VoID+SD statistics graph is generated: {@code NONE} + * (default), {@code EXACT} (in-memory, CLI -void), or {@code SKETCH} + * (bounded-memory HyperLogLog, CLI -voidsketch). + */ + public T setVoidMode(VoidMode mode) { + this.voidMode = mode; + return self(); + } + + public VoidMode getVoidMode() { + return voidMode; + } + public T setSpatial(boolean flag) { this.spatial = flag; return self(); diff --git a/src/main/java/com/ebremer/beakgraph/core/BeakGraph.java b/src/main/java/com/ebremer/beakgraph/core/BeakGraph.java index 11b8aa12..5b9e79a0 100644 --- a/src/main/java/com/ebremer/beakgraph/core/BeakGraph.java +++ b/src/main/java/com/ebremer/beakgraph/core/BeakGraph.java @@ -162,18 +162,22 @@ protected int graphBaseSize() { if (size >= 0) { return size; } - // No per-graph count is stored, so count this graph's distinct triples by - // scanning it once. Quads are de-duplicated in the index, so each triple is - // visited exactly once. Cached because the graph is read-only. - long count = 0; - ExtendedIterator it = graphBaseFind(Triple.create(Node.ANY, Node.ANY, Node.ANY)); - try { - while (it.hasNext()) { - it.next(); - count++; + // Answered from index structure when possible (a few select1 calls); + // otherwise count this graph's distinct triples by scanning it once. + // Quads are de-duplicated in the index, so each triple is visited exactly + // once. Cached because the graph is read-only. + long count = reader.countTriples(namedgraph); + if (count < 0) { + count = 0; + ExtendedIterator it = graphBaseFind(Triple.create(Node.ANY, Node.ANY, Node.ANY)); + try { + while (it.hasNext()) { + it.next(); + count++; + } + } finally { + it.close(); } - } finally { - it.close(); } size = (count > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int) count; cachedSize = size; diff --git a/src/main/java/com/ebremer/beakgraph/core/HTTPSeekableByteChannel.java b/src/main/java/com/ebremer/beakgraph/core/HTTPSeekableByteChannel.java new file mode 100644 index 00000000..4d7807e1 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/core/HTTPSeekableByteChannel.java @@ -0,0 +1,433 @@ +package com.ebremer.beakgraph.core; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.NonWritableChannelException; +import java.nio.channels.SeekableByteChannel; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Read-only {@link SeekableByteChannel} over an HTTP(S) resource, backed by + * HTTP range requests - the transport that lets a BeakGraph be queried in + * place on a web server or object store without downloading it first: + * + *

{@code
+ * try (BeakGraph bg = BG.getBeakGraph(
+ *         new HTTPSeekableByteChannel(URI.create("https://example.org/data.h5")))) {
+ *     ...
+ * }
+ * }
+ * + *

Reads are served from an LRU cache of block-aligned ranges (default + * 128 KiB blocks, 256 blocks = 32 MiB), so the binary searches the readers + * issue against dictionaries and indexes touch the network once per block, + * not once per read. Transient failures (I/O errors, HTTP 429/5xx) are + * retried with backoff; a server that answers a range request with 200 (no + * range support) fails loudly rather than silently downloading the whole + * resource. + * + *

The size is resolved at construction with a HEAD request, falling back + * to a 1-byte range probe for deployments that reject HEAD (e.g. + * method-scoped presigned URLs). The remote resource is assumed immutable + * while the channel is open - the BeakGraph contract; if it changes + * server-side, subsequent range reads may fail or return torn data. + * + *

Write aspects of the interface ({@link #write(ByteBuffer)}, + * {@link #truncate(long)}) throw {@link NonWritableChannelException}. The + * channel is thread-safe: position, cache and metrics are guarded by one + * lock, so concurrent readers serialize (network latency, not lock hold + * time, dominates). + * + * @author Erich Bremer + */ +public final class HTTPSeekableByteChannel implements SeekableByteChannel { + + private static final Logger logger = LoggerFactory.getLogger(HTTPSeekableByteChannel.class); + + private static final int DEFAULT_BLOCK_SIZE = 128 * 1024; + private static final int DEFAULT_MAX_CACHE_BLOCKS = 256; // 32 MiB with default blocks + private static final int MAX_ATTEMPTS = 3; + private static final Pattern CONTENT_RANGE = Pattern.compile("\\s*bytes\\s+(\\d+)-(\\d+)/.*"); + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(20); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60); + + private final URI uri; + private final HttpClient client; + private final int blockSize; + private final LruCache cache; + private final long size; + private final ReentrantLock lock = new ReentrantLock(); + + // guarded by lock + private long position = 0; + private long bytesServed = 0; + private long bytesFetched = 0; + private long rangeRequests = 0; + private long cacheHits = 0; + private long cacheMisses = 0; + private volatile boolean open = true; + + /** Opens {@code uri} with the default block size and cache capacity. */ + public HTTPSeekableByteChannel(URI uri) throws IOException { + this(uri, DEFAULT_BLOCK_SIZE, DEFAULT_MAX_CACHE_BLOCKS); + } + + /** + * Opens {@code uri} with a specific fetch granularity and cache capacity + * (memory ceiling is {@code blockSize * maxCacheBlocks}). + * + * @throws IOException if the size of the remote resource cannot be determined + */ + public HTTPSeekableByteChannel(URI uri, int blockSize, int maxCacheBlocks) throws IOException { + Objects.requireNonNull(uri, "uri"); + String scheme = (uri.getScheme() == null) ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!scheme.equals("http") && !scheme.equals("https")) { + throw new IllegalArgumentException("Not an http(s) URI: " + uri); + } + if (blockSize <= 0 || maxCacheBlocks <= 0) { + throw new IllegalArgumentException( + "blockSize and maxCacheBlocks must be positive: " + blockSize + ", " + maxCacheBlocks); + } + this.uri = uri; + this.blockSize = blockSize; + this.cache = new LruCache(maxCacheBlocks); + this.client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(CONNECT_TIMEOUT) + .build(); + try { + this.size = probeSize(); + } catch (IOException | RuntimeException | Error e) { + client.close(); + throw e; + } + logger.debug("Opened {} ({} bytes, {} KiB blocks, {} block cache)", + uri, size, blockSize / 1024, maxCacheBlocks); + } + + /** The resource this channel reads. */ + public URI getURI() { + return uri; + } + + @Override + public int read(ByteBuffer dst) throws IOException { + lock.lock(); + try { + ensureOpen(); + if (position >= size) { + return -1; + } + int transferred = 0; + while (dst.hasRemaining() && position < size) { + long blockIndex = position / blockSize; + int withinBlock = (int) (position - blockIndex * (long) blockSize); + byte[] block = block(blockIndex); + int n = Math.min(dst.remaining(), block.length - withinBlock); + dst.put(block, withinBlock, n); + position += n; + transferred += n; + } + bytesServed += transferred; + return transferred; + } finally { + lock.unlock(); + } + } + + @Override + public int write(ByteBuffer src) { + throw new NonWritableChannelException(); + } + + @Override + public long position() throws IOException { + lock.lock(); + try { + ensureOpen(); + return position; + } finally { + lock.unlock(); + } + } + + @Override + public SeekableByteChannel position(long newPosition) throws IOException { + if (newPosition < 0) { + throw new IllegalArgumentException("Negative position: " + newPosition); + } + lock.lock(); + try { + ensureOpen(); + // beyond-size is legal per the SeekableByteChannel contract; reads there hit EOF + position = newPosition; + return this; + } finally { + lock.unlock(); + } + } + + @Override + public long size() throws IOException { + lock.lock(); + try { + ensureOpen(); + return size; + } finally { + lock.unlock(); + } + } + + @Override + public SeekableByteChannel truncate(long size) { + throw new NonWritableChannelException(); + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public void close() { + lock.lock(); + try { + if (!open) { + return; + } + open = false; + cache.clear(); + client.close(); + logger.debug("Closed {}: served {} bytes, fetched {} bytes in {} range requests, " + + "{} cache hits / {} misses", uri, bytesServed, bytesFetched, rangeRequests, + cacheHits, cacheMisses); + } finally { + lock.unlock(); + } + } + + /** Bytes actually transferred over the network so far. */ + public long getBytesFetched() { + lock.lock(); + try { + return bytesFetched; + } finally { + lock.unlock(); + } + } + + /** Range requests issued so far (excludes the size probe). */ + public long getRangeRequestCount() { + lock.lock(); + try { + return rangeRequests; + } finally { + lock.unlock(); + } + } + + private void ensureOpen() throws ClosedChannelException { + if (!open) { + throw new ClosedChannelException(); + } + } + + /** Returns the block, consulting the cache first. Caller holds the lock. */ + private byte[] block(long blockIndex) throws IOException { + byte[] cached = cache.get(blockIndex); + if (cached != null) { + cacheHits++; + return cached; + } + cacheMisses++; + byte[] fetched = fetchBlock(blockIndex); + cache.put(blockIndex, fetched); + return fetched; + } + + /** + * Fetches one block with a range request, retrying transient failures + * (connect/read errors, HTTP 429/5xx, truncated bodies) with linear + * backoff. Permanent conditions - a 200 answer to a ranged request (no + * range support), or any other status - fail immediately. + */ + private byte[] fetchBlock(long blockIndex) throws IOException { + long start = blockIndex * (long) blockSize; + long end = Math.min(size - 1, start + blockSize - 1); + int expected = (int) (end - start + 1); + HttpRequest request = HttpRequest.newBuilder(uri) + .GET() + .header("Range", "bytes=" + start + "-" + end) + .timeout(REQUEST_TIMEOUT) + .build(); + IOException last = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (attempt > 1) { + backoff(attempt); + } + HttpResponse response; + try { + response = send(request, HttpResponse.BodyHandlers.ofInputStream()); + } catch (IOException e) { + last = e; + continue; + } + int status = response.statusCode(); + try (InputStream in = response.body()) { + // A 200 is acceptable only when the whole resource IS the requested + // block; otherwise the server ignored the Range header and reading + // on would download the entire resource. + if (status == 206 || (status == 200 && start == 0 && size <= expected)) { + if (status == 206) { + // A 206 for a DIFFERENT range than asked would silently + // corrupt reads - verify the offset before trusting the body. + String contentRange = response.headers().firstValue("Content-Range").orElse(null); + if (contentRange != null && !startsAt(contentRange, start)) { + throw new IOException("Range bytes=" + start + "-" + end + " of " + uri + + " answered with mismatched Content-Range '" + contentRange + "'"); + } + } + try { + byte[] block = in.readNBytes(expected); + if (block.length != expected) { + throw new IOException("Truncated body for bytes " + start + "-" + end + + " of " + uri + ": expected " + expected + ", got " + block.length); + } + rangeRequests++; + bytesFetched += expected; + return block; + } catch (IOException e) { + last = e; + continue; + } + } + if (status == 200) { + throw new IOException(uri + " does not support HTTP range requests" + + " (Range bytes=" + start + "-" + end + " answered with 200)"); + } + if (status == 429 || status / 100 == 5) { + last = new IOException("HTTP " + status + " fetching bytes " + + start + "-" + end + " of " + uri); + continue; + } + throw new IOException("HTTP " + status + " fetching bytes " + + start + "-" + end + " of " + uri); + } + } + throw new IOException("Failed to fetch bytes " + start + "-" + end + " of " + uri + + " after " + MAX_ATTEMPTS + " attempts", last); + } + + /** + * Resolves the remote size: HEAD first, then a 1-byte range GET whose + * Content-Range carries the total - the working path for servers that + * reject HEAD (e.g. method-scoped presigned URLs) or omit Content-Length. + */ + private long probeSize() throws IOException { + try { + HttpRequest head = HttpRequest.newBuilder(uri).HEAD().timeout(REQUEST_TIMEOUT).build(); + HttpResponse response = send(head, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() / 100 == 2) { + long length = response.headers().firstValueAsLong("Content-Length").orElse(-1L); + if (length >= 0) { + return length; + } + } + } catch (IOException fallThrough) { + logger.debug("HEAD {} failed ({}); falling back to a range probe", + uri, fallThrough.toString()); + } + HttpRequest probe = HttpRequest.newBuilder(uri) + .GET() + .header("Range", "bytes=0-0") + .timeout(REQUEST_TIMEOUT) + .build(); + HttpResponse response = send(probe, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream in = response.body()) { + int status = response.statusCode(); + if (status == 206) { + // "bytes 0-0/12345" - the total rides after the slash + String contentRange = response.headers().firstValue("Content-Range").orElse(""); + int slash = contentRange.lastIndexOf('/'); + if (slash >= 0) { + String total = contentRange.substring(slash + 1).trim(); + if (!total.equals("*")) { + try { + return Long.parseLong(total); + } catch (NumberFormatException unparseable) { + // handled below + } + } + } + throw new IOException("Cannot determine size of " + uri + + ": unparseable Content-Range '" + contentRange + "'"); + } + if (status == 200) { + throw new IOException(uri + " does not support HTTP range requests" + + " (Range bytes=0-0 answered with 200)"); + } + throw new IOException("Cannot determine size of " + uri + + ": HEAD and range probe both failed (HTTP " + status + ")"); + } + } + + /** Whether a Content-Range header ("bytes 0-131071/300000") starts at {@code start}. */ + private static boolean startsAt(String contentRange, long start) { + Matcher m = CONTENT_RANGE.matcher(contentRange); + try { + return m.matches() && Long.parseLong(m.group(1)) == start; + } catch (NumberFormatException overflows) { + return false; + } + } + + private HttpResponse send(HttpRequest request, HttpResponse.BodyHandler handler) + throws IOException { + try { + return client.send(request, handler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while fetching " + uri, e); + } + } + + private void backoff(int attempt) throws IOException { + try { + Thread.sleep(100L * (attempt - 1)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while fetching " + uri, e); + } + } + + /** Access-ordered LRU of fetched blocks, bounded by block count. */ + private static final class LruCache extends LinkedHashMap { + private static final long serialVersionUID = 1L; + private final int maxBlocks; + + LruCache(int maxBlocks) { + super(16, 0.75f, true); + this.maxBlocks = maxBlocks; + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxBlocks; + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/core/NodeTable.java b/src/main/java/com/ebremer/beakgraph/core/NodeTable.java index 3adce8eb..62a1b00e 100644 --- a/src/main/java/com/ebremer/beakgraph/core/NodeTable.java +++ b/src/main/java/com/ebremer/beakgraph/core/NodeTable.java @@ -1,13 +1,18 @@ package com.ebremer.beakgraph.core; -import com.ebremer.beakgraph.hdf5.jena.NodeId; import org.apache.jena.graph.Node; /** + * Node ⇄ packed-NodeId resolution (see {@code com.ebremer.beakgraph.hdf5.jena.NodeId} + * for the long encoding). Primitive throughout: ids flow through bindings and + * iterators as raw longs, so resolution never allocates per value. * * @author Erich Bremer */ public interface NodeTable extends AutoCloseable { - public NodeId getNodeIdForNode(Node n); - public Node getNodeForNodeId(NodeId id); + /** The node's packed id, or {@code NodeId.DOES_NOT_EXIST} when absent from this store. */ + public long getNodeIdForNode(Node n); + + /** The node for a packed id; null when the id cannot be resolved. */ + public Node getNodeForNodeId(long nodeId); } diff --git a/src/main/java/com/ebremer/beakgraph/core/VoidMode.java b/src/main/java/com/ebremer/beakgraph/core/VoidMode.java new file mode 100644 index 00000000..dddd5e08 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/core/VoidMode.java @@ -0,0 +1,22 @@ +package com.ebremer.beakgraph.core; + +/** + * How (and whether) a writer generates the VoID/SD statistics graph + * ({@code urn:x-beakgraph:void}). + * + *

    + *
  • {@link #NONE} - default: no statistics graph is written. Smallest, + * fastest builds; readers fall back to a fixed join-reorder heuristic.
  • + *
  • {@link #EXACT} (CLI {@code -void}) - fully in-memory, exact counts; + * RAM grows with the number of distinct terms per graph.
  • + *
  • {@link #SKETCH} (CLI {@code -voidsketch}) - bounded memory: exact up + * to 65,536 distinct nodes per counter, then HyperLogLog estimates + * (~0.8% error, deterministic). The right choice for disk-based builds + * that still want statistics-driven query optimization.
  • + *
+ */ +public enum VoidMode { + NONE, + EXACT, + SKETCH +} diff --git a/src/main/java/com/ebremer/beakgraph/core/fuseki/BGSparqlService.java b/src/main/java/com/ebremer/beakgraph/core/fuseki/BGSparqlService.java index 9490fc28..1aa43a2f 100644 --- a/src/main/java/com/ebremer/beakgraph/core/fuseki/BGSparqlService.java +++ b/src/main/java/com/ebremer/beakgraph/core/fuseki/BGSparqlService.java @@ -41,8 +41,15 @@ public final class BGSparqlService { * readAllBytes lets a single request allocate arbitrary heap. */ static final int MAX_QUERY_BODY_BYTES = 1 << 20; // 1 MiB - /** Hard wall-clock limit per query so one pathological query cannot pin the server. */ - private static final long QUERY_TIMEOUT_SECONDS = 30; + /** + * Wall-clock limit per query so one pathological query cannot pin the server. + * Configurable via the {@code beakgraph.query.timeout.seconds} system property + * (the CLI's {@code -timeout} flag sets it); 0 or negative disables the limit. + * Read per query, not cached, so embedding applications can adjust it at runtime. + */ + static long queryTimeoutSeconds() { + return Long.getLong("beakgraph.query.timeout.seconds", 30L); + } /** Thrown when a POST body exceeds {@link #MAX_QUERY_BODY_BYTES}; callers map it to HTTP 413. */ public static final class QueryBodyTooLargeException extends IOException { @@ -86,6 +93,13 @@ public static String extractQuery(HttpServletRequest req) throws IOException { return null; } + /** Collapses a query onto one log line (bounded, whitespace-normalized). */ + private static String oneLine(String query) { + if (query == null) return ""; + String s = query.strip().replaceAll("\\s+", " "); + return (s.length() > 300) ? s.substring(0, 300) + "..." : s; + } + /** Read at most {@code max} bytes as UTF-8; reject anything larger. */ static String readBody(InputStream in, int max) throws IOException { byte[] body = in.readNBytes(max + 1); @@ -104,10 +118,7 @@ static String readBody(InputStream in, int max) throws IOException { private static Predicate storedTermProbe(Dataset ds) { if (ds.asDatasetGraph() instanceof BGDatasetGraph bgd) { NodeTable nodeTable = bgd.getBeakGraph().getReader().getNodeTable(); - return n -> { - NodeId id = nodeTable.getNodeIdForNode(n); - return id != null && !NodeId.isDoesNotExist(id); - }; + return n -> !NodeId.isDoesNotExist(nodeTable.getNodeIdForNode(n)); } return n -> false; } @@ -135,8 +146,12 @@ public static boolean execute(Dataset ds, String queryStr, String baseURI, Query execQuery = resolver.isActive() ? QueryTransformOps.transform(query, resolver.absoluteToStorage(storedTermProbe(ds))) : query; - try (QueryExecution qexec = QueryExecution.dataset(ds).query(execQuery) - .timeout(QUERY_TIMEOUT_SECONDS, TimeUnit.SECONDS).build()) { + long timeoutSeconds = queryTimeoutSeconds(); + var qexecBuilder = QueryExecution.dataset(ds).query(execQuery); + if (timeoutSeconds > 0) { + qexecBuilder = qexecBuilder.timeout(timeoutSeconds, TimeUnit.SECONDS); + } + try (QueryExecution qexec = qexecBuilder.build()) { if (execQuery.isSelectType()) { ResultSet rs = resolver.resolve(qexec.execSelect()); if (accept.contains("json")) { @@ -175,6 +190,18 @@ public static boolean execute(Dataset ds, String queryStr, String baseURI, // The client's own query text is at fault; the parse message is theirs. resp.sendError(400, "Query parse error: " + ex.getMessage()); return true; + } catch (org.apache.jena.query.QueryCancelledException ex) { + // The wall-clock limit fired - the reader is healthy, the query was just + // slow. Tell the client plainly instead of a generic 500. + long limit = queryTimeoutSeconds(); + logger.warn("SPARQL query cancelled by the {}s timeout (raise with -timeout / " + + "beakgraph.query.timeout.seconds): {}", limit, oneLine(queryStr)); + if (resp.isCommitted()) { + throw new QueryExecutionFailedException("Query timed out after the response was committed", ex); + } + resp.sendError(503, "Query timed out after " + limit + + "s (server limit; adjustable with -timeout)"); + return true; } catch (Exception ex) { // Internal failure: log the details server-side, but do not echo // exception internals (paths, class names, state) back to the client. diff --git a/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java b/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java index 9324adda..6295c721 100644 --- a/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java +++ b/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java @@ -5,6 +5,8 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.LongAdder; import java.util.regex.Pattern; import org.apache.jena.datatypes.xsd.XSDDatatype; import org.apache.jena.graph.Node; @@ -20,26 +22,58 @@ import org.apache.jena.vocabulary.VOID; /** - * A class for accumulating statistics over quads in an RDF dataset and generating - * VoID and SPARQL Service Description (sd:) metadata. + * A class for accumulating statistics over quads in an RDF dataset and + * generating VoID and SPARQL Service Description (sd:) metadata. + * + *

Memory is BOUNDED regardless of dataset size: distinct-subject, + * distinct-object, and per-class instance counts are exact up to + * {@value Stats#EXACT_LIMIT} distinct nodes per graph and then spill into + * HyperLogLog sketches ({@link DistinctNodeCounter}); the + * {@code void:uriSpace} common prefix and {@code void:vocabulary} namespaces + * are maintained incrementally (exact, tiny state) instead of over retained + * node sets. Small and medium stores therefore report byte-identical VoID + * to previous versions; billion-quad disk builds report deterministic + * estimates (~0.8% error) instead of holding much of their dictionary on + * the heap. Fully thread-safe: parallel-ingest writers call {@link #add} + * from many threads. */ public class BGVoIDSD { private final String datasetURI; + private final int exactLimit; private final Stats defaultStats; private final ConcurrentHashMap namedStats = new ConcurrentHashMap<>(); /** - * Constructs a new BGVoIDSD instance. + * Constructs a new BGVoIDSD instance with the bounded-memory (sketch) + * counting behaviour. * * @param datasetURI the URI of the dataset being analyzed (must not be null) */ public BGVoIDSD(String datasetURI) { + this(datasetURI, Stats.EXACT_LIMIT); + } + + private BGVoIDSD(String datasetURI, int exactLimit) { if (datasetURI == null || datasetURI.trim().isEmpty()) { throw new IllegalArgumentException("Dataset URI must not be null or empty"); } this.datasetURI = datasetURI; - this.defaultStats = new Stats(); + this.exactLimit = exactLimit; + this.defaultStats = new Stats(exactLimit); + } + + /** + * The accumulator for a writer's {@link com.ebremer.beakgraph.core.VoidMode}, + * or {@code null} for {@link com.ebremer.beakgraph.core.VoidMode#NONE} + * (callers skip statistics entirely). + */ + public static BGVoIDSD forMode(com.ebremer.beakgraph.core.VoidMode mode, String datasetURI) { + return switch (mode) { + case NONE -> null; + case EXACT -> new BGVoIDSD(datasetURI, Integer.MAX_VALUE); // never spills to a sketch + case SKETCH -> new BGVoIDSD(datasetURI, Stats.EXACT_LIMIT); + }; } /** @@ -55,7 +89,7 @@ private Stats getStatsForGraph(Node graphNode) { if (Quad.isDefaultGraph(graphNode)) { return defaultStats; } - return namedStats.computeIfAbsent(graphNode, k -> new Stats()); + return namedStats.computeIfAbsent(graphNode, k -> new Stats(exactLimit)); } /** @@ -89,38 +123,80 @@ public Model getModel() { } private static class Stats { - private long numtriples = 0; + /** Distinct nodes tracked exactly per counter before spilling to a sketch. */ + static final int EXACT_LIMIT = 1 << 16; + + private final int exactLimit; + private final LongAdder numtriples = new LongAdder(); private final ConcurrentHashMap predicateCounts = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> classInstances = new ConcurrentHashMap<>(); - private final Set distinctSubjects = ConcurrentHashMap.newKeySet(); - private final Set distinctObjects = ConcurrentHashMap.newKeySet(); + private final ConcurrentHashMap classInstances = new ConcurrentHashMap<>(); + private final DistinctNodeCounter distinctSubjects; + private final DistinctNodeCounter distinctObjects; + // Incremental replacements for what used to be derived from the FULL + // retained node sets: object-URI namespaces (small set of strings) and + // the running longest common prefix of absolute subject URIs (one + // string; null = none seen yet, "" = no common prefix). + private final Set objectNamespaces = ConcurrentHashMap.newKeySet(); + private final AtomicReference subjectPrefix = new AtomicReference<>(null); - public Stats() {} + Stats(int exactLimit) { + this.exactLimit = exactLimit; + this.distinctSubjects = new DistinctNodeCounter(exactLimit); + this.distinctObjects = new DistinctNodeCounter(exactLimit); + } public void add(Quad quad) { - numtriples++; + numtriples.increment(); Node sNode = quad.getSubject(); Node pNode = quad.getPredicate(); Node oNode = quad.getObject(); predicateCounts.merge(pNode, 1L, Long::sum); distinctSubjects.add(sNode); distinctObjects.add(oNode); + if (sNode.isURI() && !UTIL.isRelativeIRI(sNode.getURI())) { + updateSubjectPrefix(sNode.getURI()); + } + if (oNode.isURI() && !UTIL.isRelativeIRI(oNode.getURI())) { + objectNamespaces.add(getNamespaceBase(oNode.getURI())); + } // Classes and instances (rdf:type) if (pNode.equals(RDF.type.asNode()) && oNode.isURI() && sNode.isURI()) { - classInstances.computeIfAbsent(oNode, c -> ConcurrentHashMap.newKeySet()).add(sNode); + classInstances.computeIfAbsent(oNode, c -> new DistinctNodeCounter(exactLimit)).add(sNode); + } + } + + /** Running LCP over absolute subject URIs; duplicates are naturally idempotent. */ + private void updateSubjectPrefix(String uri) { + while (true) { + String cur = subjectPrefix.get(); + String next; + if (cur == null) { + next = uri; + } else { + int len = Math.min(cur.length(), uri.length()); + int i = 0; + while (i < len && cur.charAt(i) == uri.charAt(i)) i++; + if (i == cur.length()) { + return; // uri extends the current prefix: nothing shrinks + } + next = cur.substring(0, i); + } + if (subjectPrefix.compareAndSet(cur, next)) { + return; + } } } public void applyTo(Resource graphRes, Model m) { - long entities = classInstances.values().stream().mapToLong(Set::size).sum(); + long entities = classInstances.values().stream().mapToLong(DistinctNodeCounter::count).sum(); graphRes.addProperty(RDF.type, VOID.Dataset) - .addLiteral(VOID.triples, numtriples) + .addLiteral(VOID.triples, numtriples.sum()) .addLiteral(VOID.classes, (long) classInstances.size()) .addLiteral(VOID.properties, (long) predicateCounts.size()) - .addLiteral(VOID.distinctSubjects, (long) distinctSubjects.size()) - .addLiteral(VOID.distinctObjects, (long) distinctObjects.size()) + .addLiteral(VOID.distinctSubjects, distinctSubjects.count()) + .addLiteral(VOID.distinctObjects, distinctObjects.count()) .addLiteral(VOID.entities, entities); - Set vocabNamespaces = new HashSet<>(); + Set vocabNamespaces = new HashSet<>(objectNamespaces); // Process Property Partitions & capture predicate namespaces predicateCounts.forEach((pNode, count) -> { if (pNode.isURI()) { @@ -141,21 +217,15 @@ public void applyTo(Resource graphRes, Model m) { graphRes.addProperty(VOID.classPartition, graphRes.getModel().createResource() .addProperty(VOID._class, clazz) - .addLiteral(VOID.entities, (long) instances.size())); - } - }); - // Evaluate Distinct Objects to grab the remaining namespaces - distinctObjects.forEach(oNode -> { - if (oNode.isURI() && !UTIL.isRelativeIRI(oNode.getURI())) { - vocabNamespaces.add(getNamespaceBase(oNode.getURI())); + .addLiteral(VOID.entities, instances.count())); } }); // Write void:vocabulary vocabNamespaces.forEach(ns -> { graphRes.addProperty(VOID.vocabulary, ResourceFactory.createResource(ns)); }); - // Infer void:uriSpace and void:uriRegexPattern iteratively over distinct subjects - String commonPrefix = computeLongestCommonPrefix(); + // Infer void:uriSpace and void:uriRegexPattern from the running prefix + String commonPrefix = trimToNamespace(subjectPrefix.get()); if (commonPrefix != null && commonPrefix.length() > 10) { graphRes.addProperty(VOID.uriSpace, commonPrefix); String regex = "^" + Pattern.quote(commonPrefix) + ".*$"; @@ -171,32 +241,16 @@ public void applyTo(Resource graphRes, Model m) { }); } - private String computeLongestCommonPrefix() { - if (distinctSubjects.isEmpty()) return null; - String prefix = null; - for (Node sNode : distinctSubjects) { - // Only absolute IRIs have a stable uriSpace; document-relative - // storage-form IRIs resolve per serving URL, so skip them. - if (sNode.isURI() && !UTIL.isRelativeIRI(sNode.getURI())) { - String uri = sNode.getURI(); - if (prefix == null) { - prefix = uri; - } else { - int len = Math.min(prefix.length(), uri.length()); - int i = 0; - while (i < len && prefix.charAt(i) == uri.charAt(i)) i++; - prefix = prefix.substring(0, i); - if (prefix.isEmpty()) return null; - } - } + /** Same trailing cut the retained-set version applied: back to the last '/' or '#'. */ + private static String trimToNamespace(String prefix) { + if (prefix == null) { + return null; } - if (prefix != null) { - int lastSlash = prefix.lastIndexOf('/'); - int lastHash = prefix.lastIndexOf('#'); - int cut = Math.max(lastSlash, lastHash); - if (cut > 0) { - prefix = prefix.substring(0, cut + 1); - } + int lastSlash = prefix.lastIndexOf('/'); + int lastHash = prefix.lastIndexOf('#'); + int cut = Math.max(lastSlash, lastHash); + if (cut > 0) { + return prefix.substring(0, cut + 1); } return prefix; } diff --git a/src/main/java/com/ebremer/beakgraph/core/fuseki/DistinctNodeCounter.java b/src/main/java/com/ebremer/beakgraph/core/fuseki/DistinctNodeCounter.java new file mode 100644 index 00000000..c77895b9 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/core/fuseki/DistinctNodeCounter.java @@ -0,0 +1,99 @@ +package com.ebremer.beakgraph.core.fuseki; + +import com.ebremer.beakgraph.core.lib.HyperLogLog; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.jena.graph.Node; + +/** + * Distinct-node counter with a bounded footprint: EXACT (a concurrent set of + * the nodes) up to {@code exactLimit}, then it spills once into a + * {@link HyperLogLog} sketch (64 KiB, ~0.8% error) and stops retaining nodes. + * This is what keeps the VoID statistics from silently holding a large + * fraction of a billion-quad store's dictionary on the heap: small and + * medium graphs report exact counts (unchanged output, byte-for-byte), huge + * ones report deterministic estimates. + * + *

Thread-safe. The one soft spot is the spill instant itself: an add that + * races the fold can be missed, bounding the error by the number of threads + * active at that moment - noise at the cardinalities where the sketch is in + * play. + */ +final class DistinctNodeCounter { + + private final int exactLimit; + private volatile Set exact = ConcurrentHashMap.newKeySet(); + private volatile HyperLogLog sketch; + + DistinctNodeCounter(int exactLimit) { + this.exactLimit = exactLimit; + } + + void add(Node n) { + Set e = exact; + if (e != null) { + e.add(n); + if (e.size() > exactLimit) { + spill(); + } + return; + } + sketch.add(hash(n)); + } + + private synchronized void spill() { + Set e = exact; + if (e == null) { + return; // another thread already spilled + } + HyperLogLog h = new HyperLogLog(); + for (Node n : e) { + h.add(hash(n)); + } + sketch = h; // publish before dropping the set: count() never sees both null + exact = null; + } + + long count() { + Set e = exact; + return (e != null) ? e.size() : sketch.estimate(); + } + + /** True while counts are still exact (used by reporting/tests). */ + boolean isExact() { + return exact != null; + } + + /** + * 64-bit content hash of a node: a rolling polynomial over the term's + * textual identity (kind-tagged; literals include lexical form, datatype, + * and language), finished with a splitmix64 avalanche. Full 64-bit space, + * so hash collisions are negligible even at 10^10 distinct terms. + */ + static long hash(Node n) { + long h; + if (n.isURI()) { + h = poly(11, n.getURI()); + } else if (n.isBlank()) { + h = poly(13, n.getBlankNodeLabel()); + } else if (n.isLiteral()) { + h = poly(17, n.getLiteralLexicalForm()); + h = h * 31 + poly(19, n.getLiteralDatatypeURI()); + String lang = n.getLiteralLanguage(); + if (lang != null && !lang.isEmpty()) { + h = h * 31 + poly(23, lang); + } + } else { + h = poly(29, n.toString()); + } + return HyperLogLog.mix64(h); + } + + private static long poly(long seed, String s) { + long h = seed * 0x9E3779B97F4A7C15L; + for (int i = 0; i < s.length(); i++) { + h = 31 * h + s.charAt(i); + } + return h; + } +} diff --git a/src/main/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServlet.java b/src/main/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServlet.java index a92d7125..ec29baaf 100644 --- a/src/main/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServlet.java +++ b/src/main/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServlet.java @@ -3,7 +3,6 @@ import com.ebremer.beakgraph.lws.LWSMetadataGenerator; import com.ebremer.beakgraph.pool.BeakGraphPool; import com.ebremer.ns.LWS; -import org.apache.jena.query.*; import org.apache.jena.rdf.model.Model; import org.apache.jena.rdf.model.ModelFactory; import org.apache.jena.riot.RDFDataMgr; @@ -19,7 +18,11 @@ import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import org.apache.jena.rdf.model.Property; import org.apache.jena.rdf.model.RDFNode; import org.apache.jena.rdf.model.Resource; @@ -27,6 +30,35 @@ import org.apache.jena.rdf.model.Statement; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + +/** + * Read-only LWS (W3C Linked Web Storage) storage endpoint over a generated + * metadata model + on-disk files. The unauthenticated read surface follows the + * LWS Protocol 1.0 draft (w3c.github.io/lws-protocol/lws10-core/): + * + *

    + *
  • Container GETs return the LWS container representation (JSON-LD with + * the {@code https://www.w3.org/ns/lws/v1} context: id / type / + * totalItems / items) negotiated among {@code application/lws+json}, + * {@code application/ld+json} and {@code application/json} with an + * identical body; Turtle and HTML remain as additional representations.
  • + *
  • Pagination is link-based per the spec: when membership exceeds + * {@link #PAGE_SIZE}, responses carry {@code Link} headers with + * {@code rel="first"/"last"/"next"/"prev"}; the body's {@code items} + * holds only the current page while {@code totalItems} stays the full + * count. A bare GET of a large container IS the first page.
  • + *
  • Every GET/HEAD response carries {@code rel="linkset"}, + * {@code rel="https://www.w3.org/ns/lws#storageDescription"}, + * {@code rel="type"}, and (for non-root resources) {@code rel="up"} + * Link headers, plus ETag and Last-Modified with conditional-request + * (304) support.
  • + *
  • Data resources support single-range requests (206/416, + * {@code Accept-Ranges: bytes}).
  • + *
+ * + * Write operations are not served: this deployment is read-only, so linkset + * responses honestly advertise {@code Allow: GET, HEAD} rather than PATCH. + */ public class LWSStorageServlet extends HttpServlet { private static final long serialVersionUID = 1L; private static String BASE; @@ -38,15 +70,34 @@ public class LWSStorageServlet extends HttpServlet { private static final Property AS_MEDIA_TYPE = ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#mediaType"); private static final Property SCHEMA_SIZE = ResourceFactory.createProperty("https://schema.org/size"); private static final Property AS_UPDATED = ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#updated"); + // Body-embedded pagination navigation (mirrors of the normative Link headers; + // the as: prefix is defined by the lws/v1 context, so "as:next" etc. expand + // to real IRIs under JSON-LD processing). + private static final Property AS_FIRST = ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#first"); + private static final Property AS_LAST = ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#last"); + private static final Property AS_NEXT = ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#next"); + private static final Property AS_PREV = ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#prev"); private static final Logger logger = LoggerFactory.getLogger(LWSStorageServlet.class); - + + /** LWS media type for container representations and the storage description. */ + static final String LWS_JSON = "application/lws+json"; + /** rel value for storage-description discovery (the full URI, per LWS Discovery). */ + static final String REL_STORAGE_DESCRIPTION = "https://www.w3.org/ns/lws#storageDescription"; + /** Server-determined page size; containers larger than this paginate. */ + static final int PAGE_SIZE = 5; + + /** Container listing ETags; the metadata model is immutable per servlet instance. */ + private final transient Map etagCache = new ConcurrentHashMap<>(); + /** Stable Last-Modified fallback for metadata without a timestamp. */ + private final long initTime = System.currentTimeMillis(); + public LWSStorageServlet(Model model) { this.MODEL = model; } public static void setBase(String b) { BASE = b; } - + public static void setStorageRoot(Path root) { STORAGE_ROOT = root; } @@ -54,7 +105,7 @@ private boolean isHDF5(Path file) { String name = file.getFileName().toString().toLowerCase(); return name.endsWith(".h5"); } - + private boolean isSparqlRequest(HttpServletRequest req) { String method = req.getMethod(); String ct = req.getContentType(); @@ -141,6 +192,9 @@ private void serveLinkset(HttpServletResponse resp, String resourceURI) throws I String updated = r.hasProperty(AS_UPDATED) ? r.getProperty(AS_UPDATED).getString() : null; resp.setContentType("application/linkset+json"); resp.setCharacterEncoding("UTF-8"); + // Read-only deployment: advertise exactly the methods this linkset supports. + resp.setHeader("Allow", "GET, HEAD"); + addStorageDescriptionLink(resp); // anchor/up are advertised on the LIVE base, never the canonical one. resp.getWriter().write(linksetJson(toLiveUri(resourceURI), typeHref, up == null ? null : toLiveUri(up), media, size, updated)); @@ -166,11 +220,15 @@ static String linksetJson(String anchor, String typeHref, String up, return writeJson(root); } - /** The LWS service-description document (application/ld+json). */ + /** + * The LWS storage description document. Data model per LWS Discovery: id + + * type "Storage" + services, including the mandatory StorageDescription + * service pointing at this document. + */ static String descriptionJson(String base) { JsonObject doc = Json.createObjectBuilder() .add("@context", "https://www.w3.org/ns/lws/v1") - .add("@id", base) + .add("id", base) .add("type", "Storage") .add("service", Json.createArrayBuilder() .add(Json.createObjectBuilder() @@ -262,9 +320,15 @@ private String toLiveUri(String canonicalUri) { return toLiveUri(canonicalUri, BASE); } - /** Container page URI on the live base; reqPath carries no leading slash and BASE ends with '/'. */ + /** + * Container page URI on the live base; reqPath carries no leading slash and + * BASE ends with '/'. The path is percent-encoded: reqPath is the DECODED + * path, and a subcontainer named "my slides" must yield + * {@code .../my%20slides?page=2}, not a Link target with a raw space that + * clients reject or mangle. + */ private String pageUri(String reqPath, int page) { - return BASE + reqPath + "?page=" + page; + return BASE + encodeHref(reqPath) + "?page=" + page; } /** @@ -313,8 +377,183 @@ static Path resolveWithin(Path root, String reqPath) { } return resolved; } + + /** + * Parse a single-range {@code Range: bytes=...} header against a resource of + * {@code size} bytes. Returns {@code null} when the header is absent, + * malformed, multi-range, or not a bytes range (the request is then served + * whole, as RFC 9110 permits); a {start,end} pair when satisfiable; and + * {@code {-1,-1}} when the range is syntactically valid but unsatisfiable + * (416 with {@code Content-Range: bytes *}{@code /size}). + */ + static long[] parseRange(String header, long size) { + if (header == null || !header.startsWith("bytes=") || header.contains(",")) { + return null; + } + String spec = header.substring("bytes=".length()).trim(); + int dash = spec.indexOf('-'); + if (dash < 0) return null; + String startStr = spec.substring(0, dash).trim(); + String endStr = spec.substring(dash + 1).trim(); + try { + if (startStr.isEmpty()) { + // suffix form: last N bytes + if (endStr.isEmpty()) return null; + long suffix = Long.parseLong(endStr); + if (suffix <= 0 || size == 0) return new long[]{-1, -1}; + long start = Math.max(0, size - suffix); + return new long[]{start, size - 1}; + } + long start = Long.parseLong(startStr); + if (start < 0) return null; + if (start >= size) return new long[]{-1, -1}; + long end = endStr.isEmpty() ? size - 1 : Long.parseLong(endStr); + if (end < start) return null; + return new long[]{start, Math.min(end, size - 1)}; + } catch (NumberFormatException e) { + return null; + } + } + + /** rel="https://www.w3.org/ns/lws#storageDescription" on every storage response (LWS Discovery). */ + private void addStorageDescriptionLink(HttpServletResponse resp) { + resp.addHeader("Link", "<" + BASE + "description>; rel=\"" + REL_STORAGE_DESCRIPTION + "\""); + } + + /** The Link headers every resource/container response must carry. */ + private void addCommonLinks(HttpServletResponse resp, String resourceURI, boolean isContainer) { + resp.addHeader("Link", "<" + getLinksetURI(toLiveUri(resourceURI)) + ">; rel=\"linkset\"; type=\"application/linkset+json\""); + addStorageDescriptionLink(resp); + resp.addHeader("Link", "<" + (isContainer ? LWS.Container.getURI() : LWS.DataResource.getURI()) + ">; rel=\"type\""); + String up = getParentURI(resourceURI); + if (up != null) { + resp.addHeader("Link", "<" + toLiveUri(up) + ">; rel=\"up\""); + } + } + + /** Items sorted by URI: pagination needs a stable order across requests. */ + private List sortedItems(Resource container) { + List items = container.listProperties(LWS_ITEMS).mapWith(Statement::getResource).toList(); + items.sort(Comparator.comparing(Resource::getURI)); + return items; + } + + /** + * Listing-version ETag (changes when the generated membership metadata + * changes). The model is immutable per servlet instance, so it is cached. + */ + private String containerEtag(Resource r, String resourceURI, List items) { + return etagCache.computeIfAbsent(resourceURI, k -> { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(resourceURI.getBytes(StandardCharsets.UTF_8)); + // The page size shapes the representation (which items land on + // which page): without it in the validator, changing PAGE_SIZE + // between server runs would 304-revalidate clients' stale slicings. + md.update(Integer.toString(PAGE_SIZE).getBytes(StandardCharsets.UTF_8)); + md.update(Long.toString(items.size()).getBytes(StandardCharsets.UTF_8)); + Statement own = r.getProperty(AS_UPDATED); + if (own != null) md.update(own.getString().getBytes(StandardCharsets.UTF_8)); + for (Resource it : items) { + md.update(it.getURI().getBytes(StandardCharsets.UTF_8)); + Statement mod = it.getProperty(AS_UPDATED); + if (mod != null) md.update(mod.getString().getBytes(StandardCharsets.UTF_8)); + } + byte[] d = md.digest(); + StringBuilder sb = new StringBuilder("\""); + for (int i = 0; i < 16; i++) sb.append(String.format("%02x", d[i])); + return sb.append('"').toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); // SHA-256 is mandatory in every JRE + } + }); + } + + /** Container Last-Modified: its own timestamp, else the newest member's, else server start. */ + private long containerLastModified(Resource r, List items) { + long best = parseInstantMillis(r.getProperty(AS_UPDATED)); + for (Resource it : items) { + best = Math.max(best, parseInstantMillis(it.getProperty(AS_UPDATED))); + } + return best > 0 ? best : initTime; + } + + private static long parseInstantMillis(Statement s) { + if (s == null) return -1; + try { + return Instant.parse(s.getString()).toEpochMilli(); + } catch (RuntimeException e) { + return -1; + } + } + + /** True (and 304 sent) when the request's validators match. Headers must be set first. */ + private static boolean notModified(HttpServletRequest req, HttpServletResponse resp, String etag, long lastModified) { + String ifNoneMatch = req.getHeader("If-None-Match"); + long ifModifiedSince = req.getDateHeader("If-Modified-Since"); + if (etag.equals(ifNoneMatch) + || (ifNoneMatch == null && ifModifiedSince >= 0 && lastModified / 1000 <= ifModifiedSince / 1000)) { + resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + return true; + } + return false; + } + + /** A {@code {"id": }} node reference ("id" maps to @id in the lws/v1 context). */ + private static JsonObjectBuilder nodeRef(String uri) { + return Json.createObjectBuilder().add("id", uri); + } + + /** + * The LWS container representation (JSON-LD, {@code https://www.w3.org/ns/lws/v1} + * context): id / type / totalItems reflect the full membership; {@code items} + * holds the (possibly paginated) current page. When paginated, the + * navigation URIs are ALSO embedded as {@code as:first/as:last/as:next/as:prev} + * node references - a convenience mirror of the normative Link headers + * (extra representation properties the spec does not forbid), so the body + * alone is navigable. + */ + private String containerJson(String liveId, long totalItems, List pageItems, + String first, String last, String next, String prev) { + JsonObjectBuilder root = Json.createObjectBuilder() + .add("@context", "https://www.w3.org/ns/lws/v1") + .add("id", liveId) + .add("type", "Container") + .add("totalItems", totalItems); + if (first != null) root.add("as:first", nodeRef(first)); + if (last != null) root.add("as:last", nodeRef(last)); + if (next != null) root.add("as:next", nodeRef(next)); + if (prev != null) root.add("as:prev", nodeRef(prev)); + JsonArrayBuilder arr = Json.createArrayBuilder(); + for (Resource it : pageItems) { + JsonObjectBuilder o = Json.createObjectBuilder(); + boolean isC = it.hasProperty(RDF.type, LWS_CONTAINER); + o.add("id", toLiveUri(it.getURI())); + o.add("type", isC ? "Container" : "DataResource"); + if (!isC) { + // mediaType is REQUIRED for DataResources in the representation. + Statement m = it.getProperty(AS_MEDIA_TYPE); + o.add("mediaType", m != null ? m.getString() : "application/octet-stream"); + } + Statement sz = it.getProperty(SCHEMA_SIZE); + if (sz != null) { + try { + o.add("size", sz.getLong()); + } catch (RuntimeException ignore) { + // a malformed size literal is dropped, not fatal (size is a SHOULD) + } + } + Statement mod = it.getProperty(AS_UPDATED); + if (mod != null) o.add("modified", mod.getString()); + arr.add(o); + } + root.add("items", arr); + return writeJson(root.build()); + } + @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + boolean isHead = "HEAD".equals(req.getMethod()); // Stop browsers from MIME-sniffing served content into something executable. resp.setHeader("X-Content-Type-Options", "nosniff"); String reqPath = decodePath(req.getRequestURI()); @@ -332,6 +571,8 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO // not in the metadata model, so answer it directly instead of 404. resp.setContentType("application/linkset+json"); resp.setCharacterEncoding("UTF-8"); + resp.setHeader("Allow", "GET, HEAD"); + addStorageDescriptionLink(resp); resp.getWriter().write(linksetJson(BASE + "description", LWS.MetadataResource.getURI(), BASE, null, null, null)); return; @@ -341,13 +582,20 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO return; } if (reqPath.equals("description")) { - resp.setContentType("application/ld+json"); - resp.setHeader("Link", "<" + BASE + "description>; rel=\"storageDescription\""); + // The storage description MUST be available as application/lws+json; + // ld+json / json are equivalent bodies with a different Content-Type. + String acceptHdr = req.getHeader("Accept") != null ? req.getHeader("Accept").toLowerCase() : ""; + String ct = acceptHdr.contains("ld+json") && !acceptHdr.contains("lws+json") + ? "application/ld+json" + : (acceptHdr.contains("lws+json") || acceptHdr.isEmpty() || !acceptHdr.contains("json")) + ? LWS_JSON : "application/json"; + resp.setContentType(ct); + addStorageDescriptionLink(resp); // addHeader, not setHeader: a second setHeader replaces the first Link // header, which silently dropped the storageDescription link. resp.addHeader("Link", "<" + getLinksetURI(BASE + "description") + ">; rel=\"linkset\"; type=\"application/linkset+json\""); resp.setHeader("Vary", "Accept"); - resp.getWriter().write(descriptionJson(BASE)); + if (!isHead) resp.getWriter().write(descriptionJson(BASE)); return; } if (reqPath.startsWith("HalcyonStorage")) { @@ -360,17 +608,16 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO resp.sendError(404, "Resource not found: " + resourceURI); return; } - // SPARQL on .h5 files now checked FIRST (fixes Jena Accept header triggering metadata instead of query) + // SPARQL on .h5 files checked FIRST (fixes Jena Accept header triggering metadata instead of query) Path h5Candidate = resolveWithin(STORAGE_ROOT, reqPath); if (h5Candidate != null && Files.exists(h5Candidate) && !Files.isDirectory(h5Candidate) && isHDF5(h5Candidate) && isSparqlRequest(req)) { handleSparqlQuery(req, resp, h5Candidate); return; } - String linksetURI = getLinksetURI(toLiveUri(resourceURI)); - resp.addHeader("Link", "<" + linksetURI + ">; rel=\"linkset\"; type=\"application/linkset+json\""); - resp.setHeader("Vary", "Accept"); boolean isContainer = r.hasProperty(RDF.type, LWS_CONTAINER); + addCommonLinks(resp, resourceURI, isContainer); + resp.setHeader("Vary", "Accept"); String accept = req.getHeader("Accept") != null ? req.getHeader("Accept").toLowerCase() : ""; String formatParam = req.getParameter("format"); boolean forceTurtle = "turtle".equalsIgnoreCase(formatParam); @@ -378,37 +625,166 @@ && isHDF5(h5Candidate) && isSparqlRequest(req)) { boolean wantHtml = acceptsHtmlRepresentation(accept) && !forceTurtle && !forceJsonLd; boolean wantTurtle = accept.contains("turtle") || forceTurtle; boolean wantJson = (accept.contains("ld+json") || accept.contains("json")) || forceJsonLd; - if (isContainer && wantHtml) { - resp.setContentType("text/html; charset=utf-8"); - // The request path and item names come from the URL / the filesystem; - // escape them for HTML and percent-encode hrefs (stored-XSS sink). - String safePath = escapeHtml(reqPath); - try (PrintWriter out = resp.getWriter()) { - out.println("LWS Storage – /" + safePath + ""); - out.println(""); - out.println("

Linked Web Storage: /" + safePath + "

"); - out.println("

Storage Description | "); - out.println("Turtle | JSON-LD | "); - out.println("SPARQL Endpoint


"); - List items = r.listProperties(LWS_ITEMS).mapWith(Statement::getResource).toList(); - if (items.isEmpty()) { - out.println("

Empty container.

"); - } else { - out.println("
    "); - for (Resource item : items) { - String name = item.getURI().substring(HTTP_ROOT.length()); - if (name.isEmpty()) name = "(root)"; - String link = name.startsWith("/") ? name : "/" + name; - out.printf("
  • %s
  • %n", - escapeHtml(encodeHref(link)), escapeHtml(name)); + + if (isContainer) { + List items = sortedItems(r); + int total = items.size(); + + // ---- Pagination (link-based, per LWS): the bare container URI IS the + // first page once membership exceeds the threshold; ?page=N addresses + // pages directly. Navigation travels in Link headers only. + int page = 1; + boolean paginated = total > PAGE_SIZE; + String pageStr = req.getParameter("page"); + if (pageStr != null) { + try { + page = Integer.parseInt(pageStr.trim()); + } catch (NumberFormatException e) { + resp.sendError(400, "Invalid 'page' parameter: " + pageStr); + return; + } + if (page < 1) { resp.sendError(400, "'page' must be >= 1"); return; } + paginated = true; + long start = (long)(page-1) * PAGE_SIZE; // long: a huge page must not overflow to a negative index + if (start >= total && total > 0) { resp.sendError(404); return; } + if (total == 0 && page > 1) { resp.sendError(404); return; } + } + List pageItems = items; + String firstUri = null, lastUri = null, nextUri = null, prevUri = null; + if (paginated) { + int pages = Math.max(1, (total + PAGE_SIZE - 1) / PAGE_SIZE); + int startIdx = (page - 1) * PAGE_SIZE; + pageItems = items.subList(Math.min(startIdx, total), Math.min(startIdx + PAGE_SIZE, total)); + firstUri = pageUri(reqPath, 1); + lastUri = pageUri(reqPath, pages); + if (page < pages) nextUri = pageUri(reqPath, page + 1); + if (page > 1) prevUri = pageUri(reqPath, page - 1); + // Normative navigation (LWS): Link headers. The same URIs are + // mirrored into the JSON-LD / Turtle bodies below. + resp.addHeader("Link", "<" + firstUri + ">; rel=\"first\""); + resp.addHeader("Link", "<" + lastUri + ">; rel=\"last\""); + if (nextUri != null) resp.addHeader("Link", "<" + nextUri + ">; rel=\"next\""); + if (prevUri != null) resp.addHeader("Link", "<" + prevUri + ">; rel=\"prev\""); + } + + // ---- Listing-version validators + conditional GET (ETag is + // membership-scoped: the listing version, not the page). + String etag = containerEtag(r, resourceURI, items); + long lastModified = containerLastModified(r, items); + resp.setHeader("ETag", etag); + resp.setDateHeader("Last-Modified", lastModified); + // no-cache = "revalidate before reuse", not "don't cache": without it, + // heuristic freshness (from Last-Modified) lets browsers replay stale + // listings for days without ever asking the server again. + resp.setHeader("Cache-Control", "no-cache"); + if (notModified(req, resp, etag, lastModified)) return; + + String liveSelf = toLiveUri(resourceURI); + + if (wantHtml) { + resp.setContentType("text/html; charset=utf-8"); + // The request path and item names come from the URL / the filesystem; + // escape them for HTML and percent-encode hrefs (stored-XSS sink). + String safePath = escapeHtml(reqPath); + if (isHead) return; + try (PrintWriter out = resp.getWriter()) { + out.println("LWS Storage – /" + safePath + ""); + out.println(""); + out.println("

    Linked Web Storage: /" + safePath + "

    "); + out.println("

    "); + String up = getParentURI(resourceURI); + if (up != null) { + // Same target as the rel="up" Link header, percent-encoded + // like every other href (parent names may need it). + String upRest = up.substring(HTTP_ROOT.length()); + while (upRest.startsWith("/")) upRest = upRest.substring(1); + out.println("⇧ Parent | "); + } + out.println("Storage Description | "); + out.println("Turtle | JSON-LD | "); + out.println("SPARQL Endpoint


    "); + if (pageItems.isEmpty()) { + out.println("

    Empty container.

    "); + } else { + out.println("
      "); + for (Resource item : pageItems) { + String name = item.getURI().substring(HTTP_ROOT.length()); + if (name.isEmpty()) name = "(root)"; + String link = name.startsWith("/") ? name : "/" + name; + out.printf("
    • %s
    • %n", + escapeHtml(encodeHref(link)), escapeHtml(name)); + } + out.println("
    "); + } + if (paginated) { + int pages = Math.max(1, (total + PAGE_SIZE - 1) / PAGE_SIZE); + out.println("

    "); + if (page > 1) out.println("« Prev "); + out.println("Page " + page + " of " + pages + " (" + total + " items)"); + if (page < pages) out.println(" Next »"); + out.println("

    "); + } + out.println(""); + } + return; + } + if (wantJson && !wantTurtle) { + // LWS container representation: identical body for all three JSON + // flavors, only the Content-Type varies (spec MUST). + String ct = forceJsonLd ? "application/ld+json" + : accept.contains("lws+json") ? LWS_JSON + : accept.contains("ld+json") ? "application/ld+json" + : "application/json"; + resp.setContentType(ct); + resp.setCharacterEncoding("UTF-8"); + if (!isHead) resp.getWriter().write( + containerJson(liveSelf, total, pageItems, firstUri, lastUri, nextUri, prevUri)); + return; + } + if (wantTurtle) { + // Additional RDF representation: the same information as the LWS + // shape, expressed as model triples (page-scoped items). + Model out = ModelFactory.createDefaultModel(); + Resource httpR = out.createResource(liveSelf); + r.listProperties().forEachRemaining(s -> { + RDFNode obj = s.getObject(); + if (!exposableToClient(obj)) return; // never leak file:/// server paths + if (s.getPredicate().equals(LWS_ITEMS)) return; // page-scoped below + if (obj.isResource() && obj.asResource().getURI() != null && obj.asResource().getURI().startsWith(HTTP_ROOT)) { + httpR.addProperty(s.getPredicate(), out.createResource(toLiveUri(obj.asResource().getURI()))); + } else { + httpR.addProperty(s.getPredicate(), obj); } - out.println("
"); + }); + // Body-embedded navigation, mirroring the Link headers. + if (firstUri != null) httpR.addProperty(AS_FIRST, out.createResource(firstUri)); + if (lastUri != null) httpR.addProperty(AS_LAST, out.createResource(lastUri)); + if (nextUri != null) httpR.addProperty(AS_NEXT, out.createResource(nextUri)); + if (prevUri != null) httpR.addProperty(AS_PREV, out.createResource(prevUri)); + for (Resource it : pageItems) { + String itHttp = toLiveUri(it.getURI()); + httpR.addProperty(LWS_ITEMS, out.createResource(itHttp)); + it.listProperties().forEachRemaining(st -> { + RDFNode obj = st.getObject(); + if (!exposableToClient(obj)) return; // never leak file:/// server paths + if (obj.isResource() && obj.asResource().getURI() != null && obj.asResource().getURI().startsWith(HTTP_ROOT)) { + out.getResource(itHttp).addProperty(st.getPredicate(), out.createResource(toLiveUri(obj.asResource().getURI()))); + } else { + out.getResource(itHttp).addProperty(st.getPredicate(), obj); + } + }); } - out.println(""); + resp.setContentType("text/turtle"); + if (!isHead) RDFDataMgr.write(resp.getOutputStream(), out, RDFFormat.TURTLE); + return; } + resp.sendError(406); return; } - if (wantTurtle || wantJson) { + + // ---- Non-container (data resource) ---- + if (wantTurtle || (wantJson && !wantHtml)) { + // RDF description of the data resource (its metadata triples). Model out = ModelFactory.createDefaultModel(); Resource httpR = out.createResource(BASE + (reqPath.isEmpty() ? "" : reqPath)); r.listProperties().forEachRemaining(s -> { @@ -420,72 +796,10 @@ && isHDF5(h5Candidate) && isSparqlRequest(req)) { httpR.addProperty(s.getPredicate(), obj); } }); - if (isContainer) { - List items = r.listProperties(LWS_ITEMS).mapWith(Statement::getResource).toList(); - String pageStr = req.getParameter("page"); - if (pageStr != null) { - int page; - try { - page = Integer.parseInt(pageStr.trim()); - } catch (NumberFormatException e) { - resp.sendError(400, "Invalid 'page' parameter: " + pageStr); - return; - } - if (page < 1) { resp.sendError(400, "'page' must be >= 1"); return; } - int size = 20; - int total = items.size(); - long start = (long)(page-1) * size; // long: a huge page must not overflow to a negative index - if (start >= total) { resp.sendError(404); return; } - int startIdx = (int) start; - List paged = items.subList(startIdx, Math.min(startIdx+size, total)); - // Self and first/last/prev/next all use the same URI shape, - // and the navigation links are RESOURCES (they are page URIs, - // and were emitted as plain literals with a divergent shape). - Resource pageR = out.createResource(pageUri(reqPath, page)); - r.listProperties().forEachRemaining(s -> { - if (!s.getPredicate().equals(LWS_ITEMS) && exposableToClient(s.getObject())) { - pageR.addProperty(s.getPredicate(), s.getObject()); - } - }); - pageR.addProperty(RDF.type, LWS.ContainerPage); - pageR.addProperty(ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#first"), out.createResource(pageUri(reqPath, 1))); - int pages = (total + size - 1) / size; - pageR.addProperty(ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#last"), out.createResource(pageUri(reqPath, pages))); - if (page > 1) pageR.addProperty(ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#prev"), out.createResource(pageUri(reqPath, page-1))); - if (page < pages) pageR.addProperty(ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#next"), out.createResource(pageUri(reqPath, page+1))); - for (Resource it : paged) { - String itHttp = toLiveUri(it.getURI()); - pageR.addProperty(LWS_ITEMS, out.createResource(itHttp)); - it.listProperties().forEachRemaining(st -> { - RDFNode obj = st.getObject(); - if (!exposableToClient(obj)) return; // never leak file:/// server paths - if (obj.isResource() && obj.asResource().getURI() != null && obj.asResource().getURI().startsWith(HTTP_ROOT)) { - out.getResource(itHttp).addProperty(st.getPredicate(), out.createResource(toLiveUri(obj.asResource().getURI()))); - } else { - out.getResource(itHttp).addProperty(st.getPredicate(), obj); - } - }); - } - } else { - for (Resource it : items) { - String itHttp = toLiveUri(it.getURI()); - it.listProperties().forEachRemaining(st -> { - RDFNode obj = st.getObject(); - if (!exposableToClient(obj)) return; // never leak file:/// server paths - if (obj.isResource() && obj.asResource().getURI() != null && obj.asResource().getURI().startsWith(HTTP_ROOT)) { - out.getResource(itHttp).addProperty(st.getPredicate(), out.createResource(toLiveUri(obj.asResource().getURI()))); - } else { - out.getResource(itHttp).addProperty(st.getPredicate(), obj); - } - }); - } - } - } resp.setContentType(wantTurtle ? "text/turtle" : "application/ld+json"); - RDFDataMgr.write(resp.getOutputStream(), out, wantTurtle ? RDFFormat.TURTLE : RDFFormat.JSONLD); + if (!isHead) RDFDataMgr.write(resp.getOutputStream(), out, wantTurtle ? RDFFormat.TURTLE : RDFFormat.JSONLD); return; } - if (isContainer) { resp.sendError(406); return; } if (STORAGE_ROOT == null) { resp.sendError(500, "Storage root not set"); return; } Path localFile = resolveWithin(STORAGE_ROOT, reqPath); if (localFile == null) { resp.sendError(403, "Forbidden"); return; } @@ -508,24 +822,60 @@ && isHDF5(h5Candidate) && isSparqlRequest(req)) { String etag = "\"" + size + "-" + lastModified + "\""; resp.setHeader("ETag", etag); resp.setDateHeader("Last-Modified", lastModified); - String ifNoneMatch = req.getHeader("If-None-Match"); - long ifModifiedSince = req.getDateHeader("If-Modified-Since"); - if (etag.equals(ifNoneMatch) - || (ifNoneMatch == null && ifModifiedSince >= 0 && lastModified / 1000 <= ifModifiedSince / 1000)) { - resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED); - return; - } + resp.setHeader("Accept-Ranges", "bytes"); + if (notModified(req, resp, etag, lastModified)) return; resp.setContentType(media); // Stored bytes are served as a download: with nosniff above, this keeps an // HTML/SVG file someone placed under the root from rendering in this origin. String filename = localFile.getFileName().toString().replace("\"", ""); resp.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + + // ---- Range requests (RFC 9110 / LWS MUST). Single ranges only; anything + // else is served whole, which RFC 9110 permits. Range is defined for GET. + long[] range = isHead ? null : parseRange(req.getHeader("Range"), size); + String ifRange = req.getHeader("If-Range"); + if (range != null && ifRange != null && !ifRange.equals(etag)) { + range = null; // validator changed: send the full representation + } + if (range != null && range[0] == -1) { + // setStatus, not sendError: sendError may reset the buffer/headers and + // the 416 MUST carry Content-Range: bytes */size. + resp.setStatus(416); + resp.setHeader("Content-Range", "bytes */" + size); + resp.setContentLength(0); + return; + } + if (range != null) { + long start = range[0]; + long end = range[1]; + long len = end - start + 1; + resp.setStatus(206); + resp.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + size); + resp.setContentLengthLong(len); + try (InputStream in = Files.newInputStream(localFile)) { + in.skipNBytes(start); + copyBounded(in, resp.getOutputStream(), len); + } + return; + } resp.setContentLengthLong(size); // HEAD gets the same headers without the body (and without the file copy). - if (!"HEAD".equals(req.getMethod())) { + if (!isHead) { Files.copy(localFile, resp.getOutputStream()); } } + + private static void copyBounded(InputStream in, OutputStream out, long len) throws IOException { + byte[] buf = new byte[64 * 1024]; + long remaining = len; + while (remaining > 0) { + int n = in.read(buf, 0, (int) Math.min(buf.length, remaining)); + if (n < 0) throw new EOFException("File shrank while serving range"); + out.write(buf, 0, n); + remaining -= n; + } + } + @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setHeader("X-Content-Type-Options", "nosniff"); @@ -562,8 +912,8 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I } @Override protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws IOException { - // Same routing and headers as GET; the file-serving branch checks the - // method and skips the body copy for HEAD. + // Same routing and headers as GET; each body-writing branch checks the + // method and skips the body for HEAD. doGet(req, resp); } } diff --git a/src/main/java/com/ebremer/beakgraph/core/fuseki/SPARQLEndPoint.java b/src/main/java/com/ebremer/beakgraph/core/fuseki/SPARQLEndPoint.java index e5a3842a..60416406 100644 --- a/src/main/java/com/ebremer/beakgraph/core/fuseki/SPARQLEndPoint.java +++ b/src/main/java/com/ebremer/beakgraph/core/fuseki/SPARQLEndPoint.java @@ -14,8 +14,8 @@ import org.apache.jena.riot.RDFFormat; import org.apache.jena.sparql.core.DatasetGraph; import org.apache.jena.sys.JenaSystem; -import org.eclipse.jetty.ee10.servlet.ServletContextHandler; -import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.ee11.servlet.ServletContextHandler; +import org.eclipse.jetty.ee11.servlet.ServletHolder; import org.eclipse.jetty.server.Server; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/ebremer/beakgraph/core/lib/HyperLogLog.java b/src/main/java/com/ebremer/beakgraph/core/lib/HyperLogLog.java new file mode 100644 index 00000000..258eec01 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/core/lib/HyperLogLog.java @@ -0,0 +1,63 @@ +package com.ebremer.beakgraph.core.lib; + +import java.util.concurrent.atomic.AtomicIntegerArray; + +/** + * A minimal, thread-safe HyperLogLog cardinality sketch: 2^14 registers + * (64 KiB as ints), standard error about 0.81%. Registers only ever move up + * (CAS-max), so concurrent adds from any number of threads are safe and the + * final state - and therefore the estimate - is a pure, order-independent + * function of the input set: two builds that feed the same distinct values + * report the same number, regardless of thread interleaving. + * + *

Callers supply well-mixed 64-bit hashes; the top {@code P} bits pick the + * register and the remainder's leading-zero count is the rank. Includes the + * linear-counting small-range correction, which makes estimates for + * cardinalities far below the register count exact in practice. + */ +public final class HyperLogLog { + + private static final int P = 14; + private static final int M = 1 << P; + private static final double ALPHA = 0.7213 / (1 + 1.079 / M); + + private final AtomicIntegerArray registers = new AtomicIntegerArray(M); + + /** Records one 64-bit hash. Thread-safe. */ + public void add(long hash) { + int idx = (int) (hash >>> (64 - P)); + long w = hash << P; + int rank = (w == 0) ? (64 - P + 1) : Long.numberOfLeadingZeros(w) + 1; + int cur; + while ((cur = registers.get(idx)) < rank) { + if (registers.compareAndSet(idx, cur, rank)) { + break; + } + } + } + + /** The cardinality estimate (rounded). */ + public long estimate() { + double sum = 0; + int zeros = 0; + for (int i = 0; i < M; i++) { + int r = registers.get(i); + sum += 1.0 / (1L << r); + if (r == 0) { + zeros++; + } + } + double e = ALPHA * M * (double) M / sum; + if (e <= 2.5 * M && zeros > 0) { + e = M * Math.log((double) M / zeros); // small-range (linear counting) + } + return Math.round(e); + } + + /** splitmix64 finalizer: turns any 64-bit value into a well-mixed hash. */ + public static long mix64(long z) { + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/core/lib/NodeComparator.java b/src/main/java/com/ebremer/beakgraph/core/lib/NodeComparator.java index 518e8705..fa06d0bd 100644 --- a/src/main/java/com/ebremer/beakgraph/core/lib/NodeComparator.java +++ b/src/main/java/com/ebremer/beakgraph/core/lib/NodeComparator.java @@ -24,7 +24,18 @@ public class NodeComparator implements Comparator { public static final NodeComparator INSTANCE = new NodeComparator(); - private NodeComparator() {} + protected NodeComparator() {} + + /** + * Node-to-NodeValue conversion hook. {@code NodeValue.makeNode} funnels + * through Jena's ONE global bounded cache; under a multi-threaded sort of + * literal-heavy data that cache's eviction lock becomes the bottleneck + * (Caffeine "excessive wait times" warnings). Subclasses may override with + * a local cache - the ordering semantics must remain exactly makeNode's. + */ + protected NodeValue nodeValue(Node n) { + return NodeValue.makeNode(n); + } @Override public int compare(Node n1, Node n2) { @@ -59,8 +70,8 @@ public int compare(Node n1, Node n2) { // If they are both Literals, we must sort by actual Value (e.g. 2 < 10), not String ("10" < "2") if (n1.isLiteral() && n2.isLiteral()) { try { - NodeValue nv1 = NodeValue.makeNode(n1); - NodeValue nv2 = NodeValue.makeNode(n2); + NodeValue nv1 = nodeValue(n1); + NodeValue nv2 = nodeValue(n2); // Timezone-sensitive value spaces cannot go through compareAlways: // it answers value order for XSD-determinate pairs but silently falls diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/BitPackedUnSignedLongBuffer.java b/src/main/java/com/ebremer/beakgraph/hdf5/BitPackedUnSignedLongBuffer.java index 5b75add1..29f8854c 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/BitPackedUnSignedLongBuffer.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/BitPackedUnSignedLongBuffer.java @@ -163,6 +163,68 @@ private int selectInWordSafe(long word, long k) { return UTIL.selectInWord(word, k); } + /** Returned by {@link #nextSetBit} when the word budget ran out before a set bit. */ + public static final long SCAN_EXHAUSTED = -2; + + /** + * Index of the first set bit at or after {@code fromIndex}, scanning at most + * {@code maxWords} 64-bit words: -1 when no set bit remains, or + * {@link #SCAN_EXHAUSTED} when the budget ran out (the block is long - the + * caller should answer with an O(log n) directory select instead). This lets + * "where does the next block start" be answered with a couple of word reads + * for the short blocks that dominate real data, without ever degrading to a + * linear scan on a multi-million-bit block. + */ + public long nextSetBit(long fromIndex, long maxWords) { + if (bitWidth != 1) throw new UnsupportedOperationException("nextSetBit only supported for 1-bit bitmaps"); + if (fromIndex >= numEntries) return -1; + long w = fromIndex >>> 6; + long lastWord = (numEntries - 1) >>> 6; + long budgetLast = w + maxWords - 1; + // Bits are MSB-first within getWord64's view; shift out the bits before fromIndex. + long word = getWord64(w << 6) << (fromIndex & 63); + if (word != 0) { + long r = fromIndex + Long.numberOfLeadingZeros(word); + return (r < numEntries) ? r : -1; + } + while (true) { + w++; + if (w > lastWord) return -1; + if (w > budgetLast) return SCAN_EXHAUSTED; + word = getWord64(w << 6); + if (word != 0) { + long r = (w << 6) + Long.numberOfLeadingZeros(word); + return (r < numEntries) ? r : -1; + } + } + } + + /** + * A word-caching single-bit reader for hot loops that probe a 1-bit bitmap at + * (mostly) monotonically advancing positions: one {@link #getWord64} read + * serves up to 64 probes. Seeking backwards or jumping is fine - it just + * refreshes the cached word. NOT thread-safe; use one per iterator. + */ + public final class BitReader { + private long wordIdx = -1; + private long word; + + public boolean bit(long index) { + long w = index >>> 6; + if (w != wordIdx) { + word = getWord64(w << 6); + wordIdx = w; + } + return (word << (index & 63)) < 0; + } + } + + /** A {@link BitReader} over this 1-bit bitmap. */ + public BitReader bitReader() { + if (bitWidth != 1) throw new UnsupportedOperationException("bitReader only supported for 1-bit bitmaps"); + return new BitReader(); + } + // --- WRITE METHODS --- public void writeInteger(int value) { @@ -265,8 +327,23 @@ public long get(long index) { throw new IndexOutOfBoundsException("Index " + index + " out of bounds [0, " + numEntries + ")"); } long totalBitOffset = index * bitWidth; - long startByteIndex = totalBitOffset / 8; - int bitOffsetInFirstByte = (int) (totalBitOffset % 8); + long startByteIndex = totalBitOffset >>> 3; + int bitOffsetInFirstByte = (int) (totalBitOffset & 7); + // FAST PATH: one unaligned big-endian 64-bit read covers the value whenever + // 8 bytes are available - the sub-byte offset (<= 7) plus any supported + // width (<= 57) fits in 64 bits, and width 64 is byte-aligned (offset 0). + // This is the innermost primitive of the whole read path (every id fetch, + // bitmap probe, and binary-search step lands here), and the former + // byte-at-a-time accumulation loop dominated its cost. + if (startByteIndex + 8 <= data.size()) { + long word = data.getLong(startByteIndex); + if (bitWidth == 64) { + return word; + } + return (word >>> (64 - bitOffsetInFirstByte - bitWidth)) & ((1L << bitWidth) - 1); + } + // TAIL: fewer than 8 bytes remain before the buffer end; collect bytes + // individually exactly as before. long acc = 0; int bitsCollected = 0; long currentByteIndex = startByteIndex; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/AggregateCountFastPath.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/AggregateCountFastPath.java new file mode 100644 index 00000000..9499772b --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/AggregateCountFastPath.java @@ -0,0 +1,152 @@ +package com.ebremer.beakgraph.hdf5.jena; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.readers.IndexCounts; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.Triple; +import org.apache.jena.sparql.algebra.Op; +import org.apache.jena.sparql.algebra.op.OpBGP; +import org.apache.jena.sparql.algebra.op.OpGraph; +import org.apache.jena.sparql.algebra.op.OpGroup; +import org.apache.jena.sparql.core.BasicPattern; +import org.apache.jena.sparql.core.Var; +import org.apache.jena.sparql.engine.ExecutionContext; +import org.apache.jena.sparql.engine.QueryIterator; +import org.apache.jena.sparql.engine.binding.Binding; +import org.apache.jena.sparql.engine.binding.BindingFactory; +import org.apache.jena.sparql.engine.iterator.QueryIterPeek; +import org.apache.jena.sparql.engine.iterator.QueryIterPlainWrapper; +import org.apache.jena.sparql.expr.Expr; +import org.apache.jena.sparql.expr.ExprAggregator; +import org.apache.jena.sparql.expr.ExprList; +import org.apache.jena.sparql.expr.NodeValue; +import org.apache.jena.sparql.expr.aggregate.AggCount; +import org.apache.jena.sparql.expr.aggregate.AggCountDistinct; +import org.apache.jena.sparql.expr.aggregate.AggCountVar; +import org.apache.jena.sparql.expr.aggregate.AggCountVarDistinct; +import org.apache.jena.sparql.expr.aggregate.Aggregator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Index-backed fast path for whole-graph COUNT aggregates over a single + * all-variable triple pattern: + * + *

  SELECT (COUNT(*) AS ?n)            WHERE { ?s ?p ?o }   - quad count
+ *  SELECT (COUNT(?x) AS ?n)           WHERE { ?s ?p ?o }   - x ∈ {s,p,o}: always bound, = quad count
+ *  SELECT (COUNT(DISTINCT *) AS ?n)   WHERE { ?s ?p ?o }   - rows are distinct quads, = quad count
+ *  SELECT (COUNT(DISTINCT ?s) AS ?n)  WHERE { ?s ?p ?o }   - GSPO subject-level range size
+ *  SELECT (COUNT(DISTINCT ?p) AS ?n)  WHERE { ?s ?p ?o }   - GPOS predicate-level range size
+ * + * each optionally wrapped in {@code GRAPH }. The answers come from + * {@link IndexCounts} - a handful of select1 calls - instead of scanning every + * row and (for DISTINCT) deduplicating hundreds of millions of ids, which is + * what made these queries blow the endpoint's wall-clock timeout at PubMed + * scale. + * + *

Fires only on the exact algebra shape (group with no GROUP BY keys and a + * single supported aggregator over a single all-variable triple with pairwise + * distinct variables, executed with the plain single-root-binding input). A + * FILTER changes the algebra, a concrete term or repeated variable changes row + * multiplicity, {@code COUNT(DISTINCT ?o)} has no direct index structure, and + * the union graph needs cross-graph de-duplication - all of those fall back to + * normal execution. The emitted row binds the aggregator's internal variable + * (e.g. {@code ?.0}); the OpExtend/OpProject above rename it exactly as they + * would for the normal group output. + */ +public final class AggregateCountFastPath { + + private static final Logger logger = LoggerFactory.getLogger(AggregateCountFastPath.class); + + /** Fast-path activations; observability for tests and diagnostics. */ + public static final AtomicLong HITS = new AtomicLong(); + + private AggregateCountFastPath() {} + + /** + * Answers the aggregate from index structure, or returns null (without having + * consumed {@code input}) when it is not exactly the supported shape. The + * caller guarantees {@code input} wraps the single-binding root iterator. + */ + public static QueryIterator tryExecute(OpGroup opGroup, QueryIterPeek input, ExecutionContext execCxt) { + if (!(execCxt.getActiveGraph() instanceof BeakGraph bg)) return null; + if (!(bg.getReader() instanceof HDF5Reader reader)) return null; + + // --- Algebra shape --- + if (!opGroup.getGroupVars().isEmpty()) return null; // GROUP BY keys need per-group rows + List aggs = opGroup.getAggregators(); + if (aggs.size() != 1) return null; + ExprAggregator ea = aggs.get(0); + + Op inner = opGroup.getSubOp(); + Node target = bg.getNamedGraph(); + if (inner instanceof OpGraph opGraph) { + if (!opGraph.getNode().isConcrete()) return null; + target = opGraph.getNode(); + inner = opGraph.getSubOp(); + } + if (!(inner instanceof OpBGP opBGP)) return null; + BasicPattern pattern = opBGP.getPattern(); + if (pattern.size() != 1) return null; + Triple t = pattern.get(0); + Node s = t.getSubject(); + Node p = t.getPredicate(); + Node o = t.getObject(); + if (!(s.isVariable() && p.isVariable() && o.isVariable())) return null; + if (s.equals(p) || p.equals(o) || s.equals(o)) return null; + + // --- Which count, and is it index-answerable? --- + long count = count(ea.getAggregator(), reader, target, s, p, o); + if (count < 0) return null; + + // --- Input: single root binding that binds none of the pattern's variables --- + Binding root = input.peek(); + if (root == null) return null; + if (root.contains(Var.alloc(s)) || root.contains(Var.alloc(p)) || root.contains(Var.alloc(o))) return null; + input.next(); // consume the single root binding + + HITS.incrementAndGet(); + logger.debug("COUNT aggregate answered from index: graph={}, agg={}, count={}", + target, ea.getAggregator(), count); + + Binding row = BindingFactory.binding(ea.getVar(), NodeValue.makeInteger(count).asNode()); + return QueryIterPlainWrapper.create(List.of(row).iterator(), execCxt); + } + + /** The exact count for the aggregator, or -1 when not index-answerable. */ + private static long count(Aggregator agg, HDF5Reader reader, Node graph, Node s, Node p, Node o) { + // Row count: index rows are de-duplicated quads, so COUNT(*) and + // COUNT(DISTINCT *) agree; COUNT(?x) counts rows where ?x is bound, and + // every pattern variable is bound in every row of a single-pattern BGP. + if (agg instanceof AggCount || agg instanceof AggCountDistinct) { + return IndexCounts.quads(reader, graph); + } + if (agg instanceof AggCountVar) { + Var v = singleVar(agg); + if (v == null) return -1; + if (v.equals(Var.alloc(s)) || v.equals(Var.alloc(p)) || v.equals(Var.alloc(o))) { + return IndexCounts.quads(reader, graph); + } + return -1; // counting a variable the pattern never binds + } + if (agg instanceof AggCountVarDistinct) { + Var v = singleVar(agg); + if (v == null) return -1; + if (v.equals(Var.alloc(s))) return IndexCounts.distinctSubjects(reader, graph); + if (v.equals(Var.alloc(p))) return IndexCounts.distinctPredicates(reader, graph); + return -1; // DISTINCT ?o has no direct index level + } + return -1; + } + + /** The aggregator's single plain-variable argument, or null (e.g. COUNT(DISTINCT str(?s))). */ + private static Var singleVar(Aggregator agg) { + ExprList exprs = agg.getExprList(); + if (exprs == null || exprs.size() != 1) return null; + Expr e = exprs.get(0); + return e.isVariable() ? e.asVar() : null; + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorMaster.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorMaster.java index 52b4ef2f..ed2bc40e 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorMaster.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorMaster.java @@ -5,23 +5,23 @@ import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; import com.ebremer.beakgraph.hdf5.Index; import com.ebremer.beakgraph.hdf5.readers.IndexReader; -import java.util.ArrayList; import java.util.Iterator; -import org.apache.commons.collections4.iterators.IteratorChain; import org.apache.jena.atlas.iterator.Iter; import org.apache.jena.sparql.core.Quad; import org.apache.jena.sparql.core.Var; import org.apache.jena.sparql.expr.ExprList; public class BGIteratorMaster implements Iterator { + // Every dispatch branch selects exactly ONE concrete iterator; this class is + // pure routing (the former per-call ArrayList + IteratorChain wrapper was + // constructed once per input binding for nothing). private final Iterator chain; public BGIteratorMaster(HDF5Reader reader, PositionalDictionaryReader dict, BindingNodeId bnid, Quad quad, ExprList filter, NodeTable nodeTable) { - ArrayList> its = new ArrayList<>(); boolean gBound = !quad.getGraph().isVariable() || (bnid!=null && bnid.containsKey(Var.alloc(quad.getGraph()))); boolean sBound = !quad.getSubject().isVariable() || (bnid!=null && bnid.containsKey(Var.alloc(quad.getSubject()))); boolean pBound = !quad.getPredicate().isVariable() || (bnid!=null && bnid.containsKey(Var.alloc(quad.getPredicate()))); - boolean oBound = !quad.getObject().isVariable() || (bnid!=null && bnid.containsKey(Var.alloc(quad.getObject()))); + boolean oBound = !quad.getObject().isVariable() || (bnid!=null && bnid.containsKey(Var.alloc(quad.getObject()))); if (gBound) { if (pBound) { @@ -29,7 +29,7 @@ public BGIteratorMaster(HDF5Reader reader, PositionalDictionaryReader dict, Bind // G, P, S bound -> Find O (Index: GSPO) IndexReader gspo = reader.getIndexReader(Index.GSPO); if (gspo != null) { - its.add(new BGIteratorSO(dict, gspo, bnid, quad, filter, nodeTable)); + chain = new BGIteratorSO(dict, gspo, bnid, quad, filter, nodeTable); } else { throw new IllegalStateException("Required GSPO index is missing from this BeakGraph file"); } @@ -38,7 +38,7 @@ public BGIteratorMaster(HDF5Reader reader, PositionalDictionaryReader dict, Bind // G, P, O bound -> Find S (Index: GPOS) IndexReader gpos = reader.getIndexReader(Index.GPOS); if (gpos != null) { - its.add(new BGIteratorOS(dict, gpos, bnid, quad, filter, nodeTable)); + chain = new BGIteratorOS(dict, gpos, bnid, quad, filter, nodeTable); } else { throw new IllegalStateException("Required GPOS index is missing from this BeakGraph file"); } @@ -46,7 +46,7 @@ public BGIteratorMaster(HDF5Reader reader, PositionalDictionaryReader dict, Bind // G, P bound -> Find S, O (Index: GPOS) IndexReader gpos = reader.getIndexReader(Index.GPOS); if (gpos != null) { - its.add(new BGIteratorPOS(dict, gpos, bnid, quad, filter, nodeTable)); + chain = new BGIteratorPOS(dict, gpos, bnid, quad, filter, nodeTable); } else { throw new IllegalStateException("Required GPOS index is missing from this BeakGraph file"); } @@ -56,7 +56,7 @@ public BGIteratorMaster(HDF5Reader reader, PositionalDictionaryReader dict, Bind // G bound, P variable -> Scan SP (Index: GSPO) IndexReader gspo = reader.getIndexReader(Index.GSPO); if (gspo != null) { - its.add(new BGIteratorSPO_All(dict, gspo, bnid, quad, filter, nodeTable)); + chain = new BGIteratorSPO_All(dict, gspo, bnid, quad, filter, nodeTable); } else { throw new IllegalStateException("Required GSPO index is missing from this BeakGraph file"); } @@ -82,24 +82,23 @@ public BGIteratorMaster(HDF5Reader reader, PositionalDictionaryReader dict, Bind && quad.getPredicate().getName().equals(gVar.getName()); if (gVarInPredicate) { Iterator graphNodes = dict.streamGraphs().iterator(); - its.add(Iter.flatMap(graphNodes, n -> { - NodeId gId = new NodeId(dict.getGraphs().locate(n), NodeType.GRAPH); + chain = Iter.flatMap(graphNodes, n -> { + long gId = NodeId.pack(NodeType.GRAPH, dict.getGraphs().locate(n)); Iterator sub = new BGIteratorMaster(reader, dict, bnid, new Quad(n, quad.getSubject(), quad.getPredicate(), quad.getObject()), filter, nodeTable); return Iter.removeNulls(Iter.map(sub, b -> b.putCompatible(gVar, gId, nodeTable) ? b : null)); - })); + }); } else { - Iterator graphIds = dict.streamGraphIds() - .mapToObj(gid -> new NodeId(gid, NodeType.GRAPH)).iterator(); - its.add(Iter.flatMap(graphIds, gId -> { + Iterator graphIds = dict.streamGraphIds() + .mapToObj(gid -> NodeId.pack(NodeType.GRAPH, gid)).iterator(); + chain = Iter.flatMap(graphIds, gId -> { BindingNodeId child = new BindingNodeId(bnid); child.put(gVar, gId); return new BGIteratorMaster(reader, dict, child, quad, filter, nodeTable); - })); + }); } } - chain = new IteratorChain<>(its); } @Override diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorOS.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorOS.java index b03e6ba4..a1b96ba9 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorOS.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorOS.java @@ -20,48 +20,51 @@ */ public class BGIteratorOS implements Iterator { private final BindingNodeId parentBinding; - private final Quad queryQuad; - private final BitPackedUnSignedLongBuffer Bp, Sp, Bo, So, Bs, Ss; - // Accelerated rank/select directories used for select1; raw B*/S* still used for get(). - private final HDTBitmapDirectory dirP, dirO, dirS; + private final BitPackedUnSignedLongBuffer Ss; private long i; private long j; private long gi, pi, oi; - private boolean hasNext = false; + private boolean hasNext = false; + private boolean subBound = false; private long minSubId = 1; // Updated to 1 to gracefully skip dummy IDs private long maxSubId = Long.MAX_VALUE; + // Row-emission plan, computed once: G/P/O packed ids are constant, only S varies. + private Var gVar, pVar, oVar, sVar; + private long gId, pId, oId; + public BGIteratorOS(PositionalDictionaryReader dict, IndexReader reader, BindingNodeId bnid, Quad quad, ExprList filter, NodeTable nodeTable) { this.parentBinding = bnid; - this.queryQuad = quad; - + // GPOS Structure - this.Bp = reader.getBitmapBuffer('P'); - this.Sp = reader.getIDBuffer('P'); - this.Bo = reader.getBitmapBuffer('O'); - this.So = reader.getIDBuffer('O'); - this.Bs = reader.getBitmapBuffer('S'); - this.Ss = reader.getIDBuffer('S'); - - this.dirP = reader.getDirectory('P'); - this.dirO = reader.getDirectory('O'); - this.dirS = reader.getDirectory('S'); + BitPackedUnSignedLongBuffer Bp = reader.getBitmapBuffer('P'); + BitPackedUnSignedLongBuffer Sp = reader.getIDBuffer('P'); + BitPackedUnSignedLongBuffer Bo = reader.getBitmapBuffer('O'); + BitPackedUnSignedLongBuffer So = reader.getIDBuffer('O'); + BitPackedUnSignedLongBuffer Bs = reader.getBitmapBuffer('S'); + this.Ss = reader.getIDBuffer('S'); + + HDTBitmapDirectory dirP = reader.getDirectory('P'); + HDTBitmapDirectory dirO = reader.getDirectory('O'); + HDTBitmapDirectory dirS = reader.getDirectory('S'); if (filter != null && !filter.isEmpty()) analyzeFilters(filter, dict, quad); - + // Resolve Graph if (quad.getGraph().isVariable()) { - if (bnid != null && bnid.containsKey(Var.alloc(quad.getGraph()))) gi = bnid.get(Var.alloc(quad.getGraph())).getId(); - else return; + long bound = (bnid != null) ? bnid.get(Var.alloc(quad.getGraph())) : NodeId.NONE; + if (bound == NodeId.NONE) return; + gi = NodeId.id(bound); } else { gi = dict.getGraphs().locate(quad.getGraph()); } - if (gi < 1) return; - + if (gi < 1) return; + // Resolve Predicate if (quad.getPredicate().isVariable()) { - if (bnid != null && bnid.containsKey(Var.alloc(quad.getPredicate()))) pi = bnid.get(Var.alloc(quad.getPredicate())).getId(); - else return; + long bound = (bnid != null) ? bnid.get(Var.alloc(quad.getPredicate())) : NodeId.NONE; + if (bound == NodeId.NONE) return; + pi = NodeId.id(bound); } else { pi = dict.getPredicates().locate(quad.getPredicate()); } @@ -69,8 +72,9 @@ public BGIteratorOS(PositionalDictionaryReader dict, IndexReader reader, Binding // Resolve Object if (quad.getObject().isVariable()) { - if (bnid != null && bnid.containsKey(Var.alloc(quad.getObject()))) oi = bnid.get(Var.alloc(quad.getObject())).getId(); - else return; + long bound = (bnid != null) ? bnid.get(Var.alloc(quad.getObject())) : NodeId.NONE; + if (bound == NodeId.NONE) return; + oi = NodeId.id(bound); } else { oi = dict.getObjects().locate(quad.getObject()); } @@ -78,42 +82,42 @@ public BGIteratorOS(PositionalDictionaryReader dict, IndexReader reader, Binding // Resolve Subject Filter long specificSubId = -1; - boolean isSubBound = !quad.getSubject().isVariable() || (bnid != null && bnid.containsKey(Var.alloc(quad.getSubject()))); - if (isSubBound) { - if (quad.getSubject().isVariable()) specificSubId = bnid.get(Var.alloc(quad.getSubject())).getId(); - else specificSubId = dict.getSubjects().locate(quad.getSubject()); - - if (specificSubId < 1) return; + if (quad.getSubject().isVariable()) { + long bound = (bnid != null) ? bnid.get(Var.alloc(quad.getSubject())) : NodeId.NONE; + subBound = (bound != NodeId.NONE); + if (subBound) specificSubId = NodeId.id(bound); + } else { + subBound = true; + specificSubId = dict.getSubjects().locate(quad.getSubject()); } + if (subBound && specificSubId < 1) return; // --- Traverse GPOS --- // A. Level 2: Predicate Range for G - long pStart = select1Safe(dirP, Bp,gi); - long nextGraphStart = select1Safe(dirP, Bp,gi + 1); - long pEnd = (nextGraphStart == -1) ? (Sp.getNumEntries() - 1) : (nextGraphStart - 1); - - if (pStart == -1 || pStart > pEnd) return; - + long pStart = RangeSelect.blockStart(dirP, Bp, gi); + if (pStart == -1) return; + long pEnd = RangeSelect.blockEnd(dirP, Bp, gi, pStart); + if (pStart > pEnd) return; + long pIndex = Sp.binarySearch(pStart, pEnd, pi); if (pIndex < 0) return; // C. Level 3: Object Range for P - long oStart = select1Safe(dirO, Bo,pIndex + 1); - long nextPStart = select1Safe(dirO, Bo,pIndex + 2); - long oEnd = (nextPStart == -1) ? (So.getNumEntries() - 1) : (nextPStart - 1); - if (oStart == -1 || oStart > oEnd) return; - + long oStart = RangeSelect.blockStart(dirO, Bo, pIndex + 1); + if (oStart == -1) return; + long oEnd = RangeSelect.blockEnd(dirO, Bo, pIndex + 1, oStart); + if (oStart > oEnd) return; + long oIndex = So.binarySearch(oStart, oEnd, oi); if (oIndex < 0) return; // E. Level 4: Subject Range for O - long sStart = select1Safe(dirS, Bs,oIndex + 1); - long nextOStart = select1Safe(dirS, Bs,oIndex + 2); - long sEnd = (nextOStart == -1) ? (Ss.getNumEntries() - 1) : (nextOStart - 1); - - if (sStart == -1 || sStart > sEnd) return; - + long sStart = RangeSelect.blockStart(dirS, Bs, oIndex + 1); + if (sStart == -1) return; + long sEnd = RangeSelect.blockEnd(dirS, Bs, oIndex + 1, sStart); + if (sStart > sEnd) return; + this.i = sStart; this.j = sEnd + 1; @@ -135,27 +139,55 @@ public BGIteratorOS(PositionalDictionaryReader dict, IndexReader reader, Binding } else { advanceToNextValid(); } + + if (hasNext) { + planRowEmission(quad); + } } - private long select1Safe(HDTBitmapDirectory dir, BitPackedUnSignedLongBuffer fallback, long rank) { - if (rank < 1) return -1; - // Accelerated O(log n) select via the superblock/block directory when present; - // fall back to the buffer's linear scan only for indexes written without it. - return (dir != null) ? dir.select1(rank) : fallback.select1(rank); + /** See BGIteratorSO.planRowEmission: constants pre-built, only S varies per row. */ + private void planRowEmission(Quad quad) { + if (quad.getGraph().isVariable()) { + Var v = Var.alloc(quad.getGraph()); + if (parentBinding == null || !parentBinding.containsKey(v)) { + gVar = v; + gId = NodeId.pack(NodeType.GRAPH, gi); + } + } + if (quad.getPredicate().isVariable()) { + Var v = Var.alloc(quad.getPredicate()); + if (parentBinding == null || !parentBinding.containsKey(v)) { + pVar = v; + pId = NodeId.pack(NodeType.PREDICATE, pi); + } + } + if (quad.getObject().isVariable()) { + Var v = Var.alloc(quad.getObject()); + if (parentBinding == null || !parentBinding.containsKey(v)) { + oVar = v; + oId = NodeId.pack(NodeType.OBJECT, oi); + } + } + if (quad.getSubject().isVariable()) { + Var v = Var.alloc(quad.getSubject()); + if (parentBinding == null || !parentBinding.containsKey(v)) { + sVar = v; + } + } } - + private void advanceToNextValid() { hasNext = false; while (i < j) { long subId = Ss.get(i); - + if (subId < minSubId) { i++; continue; } if (subId > maxSubId) { i++; - continue; + continue; } hasNext = true; return; @@ -217,18 +249,17 @@ public BindingNodeId next() { if (!hasNext) throw new NoSuchElementException(); BindingNodeId result = new BindingNodeId(this.parentBinding); long currentSubjectId = Ss.get(i); - if (queryQuad.getGraph().isVariable()) result.put(Var.alloc(queryQuad.getGraph()), new NodeId(gi, NodeType.GRAPH)); - if (queryQuad.getPredicate().isVariable()) result.put(Var.alloc(queryQuad.getPredicate()), new NodeId(pi, NodeType.PREDICATE)); - if (queryQuad.getObject().isVariable()) result.put(Var.alloc(queryQuad.getObject()), new NodeId(oi, NodeType.OBJECT)); - if (queryQuad.getSubject().isVariable()) result.put(Var.alloc(queryQuad.getSubject()), new NodeId(currentSubjectId, NodeType.SUBJECT)); + if (gVar != null) result.put(gVar, gId); + if (pVar != null) result.put(pVar, pId); + if (oVar != null) result.put(oVar, oId); + if (sVar != null) result.put(sVar, NodeId.pack(NodeType.SUBJECT, currentSubjectId)); i++; if (i < j) { - boolean isSubBound = !queryQuad.getSubject().isVariable() || (parentBinding != null && parentBinding.containsKey(Var.alloc(queryQuad.getSubject()))); - if (isSubBound) hasNext = false; + if (subBound) hasNext = false; else advanceToNextValid(); } else { hasNext = false; } return result; } -} \ No newline at end of file +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorPOS.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorPOS.java index 2b824020..26da1a4f 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorPOS.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorPOS.java @@ -21,54 +21,65 @@ */ public class BGIteratorPOS implements Iterator { private final BindingNodeId parentBinding; - private final Quad queryQuad; - private final BitPackedUnSignedLongBuffer Bp, Sp, Bo, So, Bs, Ss; - // Accelerated rank/select directories used for select1; raw B*/S* still used for get(). - private final HDTBitmapDirectory dirP, dirO, dirS; + private final BitPackedUnSignedLongBuffer Bs, Ss, So; + private final HDTBitmapDirectory dirS; private long gi, pi; private long oStart, oEnd, curOIndex; private long sStart, sEnd, curSIndex; private long minObjId = 0; private long maxObjId = Long.MAX_VALUE; private boolean hasNext = false; - private PositionalDictionaryReader dict; private final NodeTable nodeTable; + // Row-emission plan (see BGIteratorSO): pattern and parent binding are fixed, + // so the vars each row binds are computed once. Object and subject vary per row. + private Var oVar, sVar; + public BGIteratorPOS(PositionalDictionaryReader dict, IndexReader reader, BindingNodeId bnid, Quad quad, ExprList filter, NodeTable nodeTable) { + this(dict, reader, bnid, quad, filter, nodeTable, -1, Long.MAX_VALUE); + } + + /** + * Range-restricted variant for parallel scanning (see ScanChunks): only + * object POSITIONS within [oPosLo, oPosHi] (intersected with this + * predicate's filter-narrowed object range) are walked. An object's whole + * subject block belongs to the chunk owning its position. + */ + BGIteratorPOS(PositionalDictionaryReader dict, IndexReader reader, BindingNodeId bnid, Quad quad, ExprList filter, NodeTable nodeTable, long oPosLo, long oPosHi) { this.parentBinding = bnid; - this.queryQuad = quad; - this.dict = dict; this.nodeTable = nodeTable; - - this.Bp = reader.getBitmapBuffer('P'); - this.Sp = reader.getIDBuffer('P'); - this.Bo = reader.getBitmapBuffer('O'); - this.So = reader.getIDBuffer('O'); - this.Bs = reader.getBitmapBuffer('S'); - this.Ss = reader.getIDBuffer('S'); - - this.dirP = reader.getDirectory('P'); - this.dirO = reader.getDirectory('O'); + + BitPackedUnSignedLongBuffer Bp = reader.getBitmapBuffer('P'); + BitPackedUnSignedLongBuffer Sp = reader.getIDBuffer('P'); + BitPackedUnSignedLongBuffer Bo = reader.getBitmapBuffer('O'); + this.So = reader.getIDBuffer('O'); + this.Bs = reader.getBitmapBuffer('S'); + this.Ss = reader.getIDBuffer('S'); + + HDTBitmapDirectory dirP = reader.getDirectory('P'); + HDTBitmapDirectory dirO = reader.getDirectory('O'); this.dirS = reader.getDirectory('S'); // Analyze filters specifically for the Object variable if (filter != null && !filter.isEmpty()) { analyzeFilters(filter, dict, quad); } - + // 1. Resolve Graph if (quad.getGraph().isVariable()) { - if (bnid != null && bnid.containsKey(Var.alloc(quad.getGraph()))) gi = bnid.get(Var.alloc(quad.getGraph())).getId(); - else throw new IllegalStateException("BGIteratorPOS requires Graph to be bound."); //return; + long bound = (bnid != null) ? bnid.get(Var.alloc(quad.getGraph())) : NodeId.NONE; + if (bound == NodeId.NONE) throw new IllegalStateException("BGIteratorPOS requires Graph to be bound."); + gi = NodeId.id(bound); } else { gi = dict.getGraphs().locate(quad.getGraph()); } if (gi < 1) return; - + // 2. Resolve Predicate if (quad.getPredicate().isVariable()) { - if (bnid != null && bnid.containsKey(Var.alloc(quad.getPredicate()))) pi = bnid.get(Var.alloc(quad.getPredicate())).getId(); - else return; + long bound = (bnid != null) ? bnid.get(Var.alloc(quad.getPredicate())) : NodeId.NONE; + if (bound == NodeId.NONE) return; + pi = NodeId.id(bound); } else { pi = dict.getPredicates().locate(quad.getPredicate()); } @@ -77,20 +88,18 @@ public BGIteratorPOS(PositionalDictionaryReader dict, IndexReader reader, Bindin // --- Traverse GPOS --- // A. Find Predicate Index under Graph - long pRangeStart = select1Safe(dirP, Bp,gi); - long nextGraphStart = select1Safe(dirP, Bp,gi + 1); - long pRangeEnd = (nextGraphStart == -1) ? (Sp.getNumEntries() - 1) : (nextGraphStart - 1); - - if (pRangeStart == -1 || pRangeStart > pRangeEnd) return; + long pRangeStart = RangeSelect.blockStart(dirP, Bp, gi); + if (pRangeStart == -1) return; + long pRangeEnd = RangeSelect.blockEnd(dirP, Bp, gi, pRangeStart); + if (pRangeStart > pRangeEnd) return; long pIndex = Sp.binarySearch(pRangeStart, pRangeEnd, pi); if (pIndex < 0) return; // B. Determine raw Object Range for this Predicate - long rawOStart = select1Safe(dirO, Bo,pIndex + 1); - long nextPStart = select1Safe(dirO, Bo,pIndex + 2); - long rawOEnd = (nextPStart == -1) ? (So.getNumEntries() - 1) : (nextPStart - 1); - - if (rawOStart == -1 || rawOStart > rawOEnd) return; + long rawOStart = RangeSelect.blockStart(dirO, Bo, pIndex + 1); + if (rawOStart == -1) return; + long rawOEnd = RangeSelect.blockEnd(dirO, Bo, pIndex + 1, rawOStart); + if (rawOStart > rawOEnd) return; // C. APPLY FILTER: Narrow the Object Range using Binary Search // upperBound already returns the last index whose value is <= maxObjId @@ -99,35 +108,58 @@ public BGIteratorPOS(PositionalDictionaryReader dict, IndexReader reader, Bindin this.oStart = (minObjId <= 0) ? rawOStart : So.lowerBound(rawOStart, rawOEnd, minObjId); this.oEnd = (maxObjId == Long.MAX_VALUE) ? rawOEnd : So.upperBound(rawOStart, rawOEnd, maxObjId); + // Parallel-chunk clamp: restrict to this chunk's slice of the object range. + if (oPosLo > oStart) oStart = oPosLo; + if (oPosHi < oEnd) oEnd = oPosHi; + if (oStart > oEnd || oStart < 0) return; - + // D. Initialize Nested Iteration this.curOIndex = oStart; - setupSubjectRange(); + setupSubjectRange(true); advanceToNextValid(); + + if (hasNext) { + if (quad.getObject().isVariable()) oVar = Var.alloc(quad.getObject()); + if (quad.getSubject().isVariable()) sVar = Var.alloc(quad.getSubject()); + } } - private void setupSubjectRange() { + /** + * Positions [sStart..sEnd] on the subject block of object index {@code curOIndex}. + * Blocks tile the S level contiguously, so after the first (select1-based) + * placement each advance to the NEXT object index starts exactly at the previous + * block's end + 1 - no select at all; only the new end is found (a short forward + * bitmap scan with a directory fallback for giant blocks). + */ + private void setupSubjectRange(boolean first) { if (curOIndex > oEnd) { sStart = -1; return; } - this.sStart = select1Safe(dirS, Bs,curOIndex + 1); - long nextOStart = select1Safe(dirS, Bs,curOIndex + 2); - this.sEnd = (nextOStart == -1) ? (Ss.getNumEntries() - 1) : (nextOStart - 1); + if (first) { + this.sStart = RangeSelect.blockStart(dirS, Bs, curOIndex + 1); + if (sStart == -1) return; + } else { + this.sStart = this.sEnd + 1; + } + this.sEnd = RangeSelect.blockEnd(dirS, Bs, curOIndex + 1, sStart); this.curSIndex = sStart; } private void advanceToNextValid() { hasNext = false; while (curOIndex <= oEnd) { - if (curSIndex <= sEnd && curSIndex != -1) { + if (curSIndex <= sEnd && curSIndex != -1 && sStart != -1) { hasNext = true; return; } curOIndex++; if (curOIndex <= oEnd) { - setupSubjectRange(); + // Re-anchor with a full select if the previous placement failed + // (never expected mid-range; carry forward from a stale end would + // corrupt the walk). + setupSubjectRange(sStart == -1); } } } @@ -183,13 +215,6 @@ private void applyBound(Var var, String op, Node value, PositionalDictionaryRead } } - private long select1Safe(HDTBitmapDirectory dir, BitPackedUnSignedLongBuffer fallback, long rank) { - if (rank < 1) return -1; - // Accelerated O(log n) select via the superblock/block directory when present; - // fall back to the buffer's linear scan only for indexes written without it. - return (dir != null) ? dir.select1(rank) : fallback.select1(rank); - } - // Look-ahead: the next deliverable row, or null. Rows whose repeated-variable // bindings conflict are skipped here, so hasNext() only answers true when // next() really has a row to return. @@ -205,14 +230,12 @@ private long select1Safe(HDTBitmapDirectory dir, BitPackedUnSignedLongBuffer fal private BindingNodeId computeNext() { while (hasNext) { BindingNodeId result = new BindingNodeId(this.parentBinding); - long currentObjectId = So.get(curOIndex); - long currentSubjectId = Ss.get(curSIndex); boolean ok = true; - if (queryQuad.getObject().isVariable()) { - ok = result.putCompatible(Var.alloc(queryQuad.getObject()), new NodeId(currentObjectId, NodeType.OBJECT), nodeTable); + if (oVar != null) { + ok = result.putCompatible(oVar, NodeId.pack(NodeType.OBJECT, So.get(curOIndex)), nodeTable); } - if (ok && queryQuad.getSubject().isVariable()) { - ok = result.putCompatible(Var.alloc(queryQuad.getSubject()), new NodeId(currentSubjectId, NodeType.SUBJECT), nodeTable); + if (ok && sVar != null) { + ok = result.putCompatible(sVar, NodeId.pack(NodeType.SUBJECT, Ss.get(curSIndex)), nodeTable); } curSIndex++; advanceToNextValid(); diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorSO.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorSO.java index 1bc1c990..2b14774a 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorSO.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorSO.java @@ -21,35 +21,35 @@ */ public class BGIteratorSO implements Iterator { private final BindingNodeId parentBinding; - private final Quad queryQuad; - private final BitPackedUnSignedLongBuffer Bs, Ss, Bp, Sp, Bo, So; - // Accelerated rank/select directories (one per traversed component) used for - // select1; the raw B*/S* buffers above are still used for get()/binarySearch(). - private final HDTBitmapDirectory dirS, dirP, dirO; - + private final BitPackedUnSignedLongBuffer So; + private long i; // current object index private long j; // end object index (inclusive) private long gi, si, pi; private boolean hasNext = false; - + private long minObjId = 0; private long maxObjId = Long.MAX_VALUE; + // Row-emission plan, computed once: which variables each row binds, with the + // constant G/S/P packed NodeIds pre-built (only the object id varies per row). + private Var gVar, sVar, pVar, oVar; + private long gId, sId, pId; + public BGIteratorSO(PositionalDictionaryReader dict, IndexReader reader, BindingNodeId bnid, Quad quad, ExprList filter, NodeTable nodeTable) { this.parentBinding = bnid; - this.queryQuad = quad; // GSPO Structure mapping - this.Bs = reader.getBitmapBuffer('S'); - this.Ss = reader.getIDBuffer('S'); - this.Bp = reader.getBitmapBuffer('P'); - this.Sp = reader.getIDBuffer('P'); - this.Bo = reader.getBitmapBuffer('O'); - this.So = reader.getIDBuffer('O'); - - this.dirS = reader.getDirectory('S'); - this.dirP = reader.getDirectory('P'); - this.dirO = reader.getDirectory('O'); + BitPackedUnSignedLongBuffer Bs = reader.getBitmapBuffer('S'); + BitPackedUnSignedLongBuffer Ss = reader.getIDBuffer('S'); + BitPackedUnSignedLongBuffer Bp = reader.getBitmapBuffer('P'); + BitPackedUnSignedLongBuffer Sp = reader.getIDBuffer('P'); + BitPackedUnSignedLongBuffer Bo = reader.getBitmapBuffer('O'); + this.So = reader.getIDBuffer('O'); + + HDTBitmapDirectory dirS = reader.getDirectory('S'); + HDTBitmapDirectory dirP = reader.getDirectory('P'); + HDTBitmapDirectory dirO = reader.getDirectory('O'); if (filter != null && !filter.isEmpty()) { analyzeFilters(filter, dict, quad); @@ -58,7 +58,7 @@ public BGIteratorSO(PositionalDictionaryReader dict, IndexReader reader, Binding // Resolve Graph gi = resolveNode(quad.getGraph(), dict.getGraphs(), bnid); if (gi < 1) return; - + // Resolve Subject si = resolveNode(quad.getSubject(), dict.getSubjects(), bnid); if (si < 1) return; @@ -70,29 +70,26 @@ public BGIteratorSO(PositionalDictionaryReader dict, IndexReader reader, Binding // --- Traverse GSPO --- // A. Find Subject Index under Graph - long sStart = select1Safe(dirS, Bs,gi); - long nextGraphStart = select1Safe(dirS, Bs,gi + 1); - long sEnd = (nextGraphStart == -1) ? (Ss.getNumEntries() - 1) : (nextGraphStart - 1); - - if (sStart == -1 || sStart > sEnd) return; + long sStart = RangeSelect.blockStart(dirS, Bs, gi); + if (sStart == -1) return; + long sEnd = RangeSelect.blockEnd(dirS, Bs, gi, sStart); + if (sStart > sEnd) return; long sIndex = Ss.binarySearch(sStart, sEnd, si); if (sIndex < 0) return; // B. Find Predicate Index under Subject - long pStart = select1Safe(dirP, Bp,sIndex + 1); - long nextSStart = select1Safe(dirP, Bp,sIndex + 2); - long pEnd = (nextSStart == -1) ? (Sp.getNumEntries() - 1) : (nextSStart - 1); - - if (pStart == -1 || pStart > pEnd) return; + long pStart = RangeSelect.blockStart(dirP, Bp, sIndex + 1); + if (pStart == -1) return; + long pEnd = RangeSelect.blockEnd(dirP, Bp, sIndex + 1, pStart); + if (pStart > pEnd) return; long pIndex = Sp.binarySearch(pStart, pEnd, pi); if (pIndex < 0) return; // C. Find Object Range for Predicate - long rawOStart = select1Safe(dirO, Bo,pIndex + 1); - long nextPStart = select1Safe(dirO, Bo,pIndex + 2); - long rawOEnd = (nextPStart == -1) ? (So.getNumEntries() - 1) : (nextPStart - 1); - - if (rawOStart == -1 || rawOStart > rawOEnd) return; + long rawOStart = RangeSelect.blockStart(dirO, Bo, pIndex + 1); + if (rawOStart == -1) return; + long rawOEnd = RangeSelect.blockEnd(dirO, Bo, pIndex + 1, rawOStart); + if (rawOStart > rawOEnd) return; // D. Apply Specific Object Bound or Range Filters // Distinguish "object is an unbound variable" from "object is a concrete @@ -122,26 +119,60 @@ public BGIteratorSO(PositionalDictionaryReader dict, IndexReader reader, Binding this.hasNext = true; } } + + if (hasNext) { + planRowEmission(quad); + } + } + + /** + * Precomputes which variables each row binds - the pattern and the parent + * binding are fixed for this iterator's lifetime, so the per-row work + * reduces to appends of pre-built NodeIds (only the object id varies). + */ + private void planRowEmission(Quad quad) { + if (quad.getGraph().isVariable()) { + Var v = Var.alloc(quad.getGraph()); + if (parentBinding == null || !parentBinding.containsKey(v)) { + gVar = v; + gId = NodeId.pack(NodeType.GRAPH, gi); + } + } + if (quad.getSubject().isVariable()) { + Var v = Var.alloc(quad.getSubject()); + if (parentBinding == null || !parentBinding.containsKey(v)) { + sVar = v; + sId = NodeId.pack(NodeType.SUBJECT, si); + } + } + if (quad.getPredicate().isVariable()) { + Var v = Var.alloc(quad.getPredicate()); + if (parentBinding == null || !parentBinding.containsKey(v)) { + pVar = v; + pId = NodeId.pack(NodeType.PREDICATE, pi); + } + } + if (quad.getObject().isVariable()) { + Var v = Var.alloc(quad.getObject()); + if (parentBinding == null || !parentBinding.containsKey(v)) { + oVar = v; + } + } } private long resolveNode(Node node, Dictionary dictionary, BindingNodeId bnid) { if (node.isVariable()) { - Var v = Var.alloc(node); - if (bnid != null && bnid.containsKey(v)) { - return bnid.get(v).getId(); + if (bnid != null) { + long bound = bnid.get(Var.alloc(node)); + if (bound != NodeId.NONE) { + return NodeId.id(bound); + } } return -1; // Variable is unbound } return dictionary.locate(node); } - private long select1Safe(HDTBitmapDirectory dir, BitPackedUnSignedLongBuffer fallback, long rank) { - if (rank < 1) return -1; - // Accelerated O(log n) select via the superblock/block directory when present; - // fall back to the buffer's linear scan only for indexes written without it. - return (dir != null) ? dir.select1(rank) : fallback.select1(rank); - } - private void analyzeFilters(ExprList filter, PositionalDictionaryReader dict, Quad quad) { for (Expr expr : filter.getList()) { if (expr instanceof ExprFunction2 func) { @@ -196,18 +227,12 @@ public boolean hasNext() { @Override public BindingNodeId next() { - if (!hasNext) throw new NoSuchElementException(); + if (!hasNext) throw new NoSuchElementException(); BindingNodeId result = new BindingNodeId(this.parentBinding); - long currentObjId = So.get(i); - if (queryQuad.getGraph().isVariable() && !result.containsKey(Var.alloc(queryQuad.getGraph()))) - result.put(Var.alloc(queryQuad.getGraph()), new NodeId(gi, NodeType.GRAPH)); - if (queryQuad.getSubject().isVariable() && !result.containsKey(Var.alloc(queryQuad.getSubject()))) - result.put(Var.alloc(queryQuad.getSubject()), new NodeId(si, NodeType.SUBJECT)); - if (queryQuad.getPredicate().isVariable() && !result.containsKey(Var.alloc(queryQuad.getPredicate()))) - result.put(Var.alloc(queryQuad.getPredicate()), new NodeId(pi, NodeType.PREDICATE)); - if (queryQuad.getObject().isVariable()) { - result.put(Var.alloc(queryQuad.getObject()), new NodeId(currentObjId, NodeType.OBJECT)); - } + if (gVar != null) result.put(gVar, gId); + if (sVar != null) result.put(sVar, sId); + if (pVar != null) result.put(pVar, pId); + if (oVar != null) result.put(oVar, NodeId.pack(NodeType.OBJECT, So.get(i))); i++; hasNext = (i <= j); return result; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorSPO_All.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorSPO_All.java index 1a956bea..6c33e6d5 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorSPO_All.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGIteratorSPO_All.java @@ -21,15 +21,18 @@ */ public class BGIteratorSPO_All implements Iterator { private final BindingNodeId parentBinding; - private final Quad queryQuad; - - private final BitPackedUnSignedLongBuffer Bs, Ss, Bp, Sp, Bo, So; + + private final BitPackedUnSignedLongBuffer Ss, Bp, Sp, Bo, So; // Accelerated rank/select directories used for select1; raw B*/S* still used for get(). - private final HDTBitmapDirectory dirS, dirP, dirO; + private final HDTBitmapDirectory dirP, dirO; + // Word-caching probes for the per-row "did a new block start here" checks: + // idxO/idxP advance monotonically, so one word read serves up to 64 probes. + private final BitPackedUnSignedLongBuffer.BitReader boBit, bpBit; + private final long spNum, soNum; private long idxS, endS, idxP, idxO; private long curSID, curPID, curOID; private long resS, resP, resO; - private final long gi; + private final long gi; private boolean hasNext = false; // minSubId starts at 1, not 0: real subject ids are 1-based, and the writer pads // empty graph blocks with (S=0,P=0,O=0) dummy rows. Starting at 0 leaked those @@ -37,23 +40,40 @@ public class BGIteratorSPO_All implements Iterator { private long minSubId = 1, maxSubId = Long.MAX_VALUE; private long minPid = 0, maxPid = Long.MAX_VALUE; private long minObjId = 0, maxObjId = Long.MAX_VALUE; - private final PositionalDictionaryReader dict; private final NodeTable nodeTable; + // Row-emission plan (see BGIteratorSO): Var.alloc hoisted out of the row loop. + // All four stay putCompatible - a variable repeated across positions (or + // pre-bound in the parent) must agree per row. + private Var gVar, sVar, pVar, oVar; + private long gId; + public BGIteratorSPO_All(PositionalDictionaryReader dict, IndexReader reader, BindingNodeId bnid, Quad quad, ExprList filter, NodeTable nodeTable) { + this(dict, reader, bnid, quad, filter, nodeTable, -1, Long.MAX_VALUE); + } + + /** + * Range-restricted variant for parallel scanning (see ScanChunks): only + * subject POSITIONS within [sPosLo, sPosHi] (intersected with the graph's + * own subject range) are walked. Each subject's whole P/O sub-tree belongs + * to the chunk owning its position, so chunks neither split nor duplicate rows. + */ + BGIteratorSPO_All(PositionalDictionaryReader dict, IndexReader reader, BindingNodeId bnid, Quad quad, ExprList filter, NodeTable nodeTable, long sPosLo, long sPosHi) { this.parentBinding = bnid; - this.queryQuad = quad; - this.dict = dict; this.nodeTable = nodeTable; - this.Bs = reader.getBitmapBuffer('S'); + BitPackedUnSignedLongBuffer Bs = reader.getBitmapBuffer('S'); this.Ss = reader.getIDBuffer('S'); this.Bp = reader.getBitmapBuffer('P'); this.Sp = reader.getIDBuffer('P'); this.Bo = reader.getBitmapBuffer('O'); this.So = reader.getIDBuffer('O'); - this.dirS = reader.getDirectory('S'); + HDTBitmapDirectory dirS = reader.getDirectory('S'); this.dirP = reader.getDirectory('P'); this.dirO = reader.getDirectory('O'); + this.boBit = Bo.bitReader(); + this.bpBit = Bp.bitReader(); + this.spNum = Sp.getNumEntries(); + this.soNum = So.getNumEntries(); if (filter != null && !filter.isEmpty()) { analyzeFilters(filter, dict, quad); @@ -65,9 +85,8 @@ public BGIteratorSPO_All(PositionalDictionaryReader dict, IndexReader reader, Bi // variable node returns -1 - silently yielding nothing for a graph // that exists. if (quad.getGraph().isVariable()) { - gi = (bnid != null && bnid.containsKey(Var.alloc(quad.getGraph()))) - ? bnid.get(Var.alloc(quad.getGraph())).getId() - : -1; + long bound = (bnid != null) ? bnid.get(Var.alloc(quad.getGraph())) : NodeId.NONE; + gi = (bound != NodeId.NONE) ? NodeId.id(bound) : -1; } else { gi = dict.getGraphs().locate(quad.getGraph()); } @@ -93,15 +112,17 @@ public BGIteratorSPO_All(PositionalDictionaryReader dict, IndexReader reader, Bi // ----------------------------------------------------------------- // LEVEL 1: Subject Range (Graph Scope) // ----------------------------------------------------------------- - // select1 is 1-based. - // Start of Graph gi is the gi-th '1' in Bs. - long sStart = select1Safe(dirS, Bs,gi); - - // Start of Next Graph is the (gi+1)-th '1'. - long nextGraphStart = select1Safe(dirS, Bs,gi + 1); - long sEnd = (nextGraphStart == -1) ? (Ss.getNumEntries() - 1) : (nextGraphStart - 1); + // select1 is 1-based. Start of Graph gi is the gi-th '1' in Bs; the end is + // one before the next block's start. + long sStart = RangeSelect.blockStart(dirS, Bs, gi); + if (sStart == -1) return; + long sEnd = RangeSelect.blockEnd(dirS, Bs, gi, sStart); + if (sStart > sEnd) return; - if (sStart == -1 || sStart > sEnd) return; + // Parallel-chunk clamp: restrict to this chunk's slice of the graph's range. + if (sPosLo > sStart) sStart = sPosLo; + if (sPosHi < sEnd) sEnd = sPosHi; + if (sStart > sEnd) return; // For a concrete subject, binary-search the (ascending) subject list // for it instead of scanning the graph's subjects one block at a time. @@ -117,26 +138,34 @@ public BGIteratorSPO_All(PositionalDictionaryReader dict, IndexReader reader, Bi // LEVEL 2: Predicate Cursor // ----------------------------------------------------------------- // Start of Predicates for Subject `idxS` is the (idxS + 1)-th '1' in Bp. - this.idxP = select1Safe(dirP, Bp,idxS + 1); + this.idxP = select1Safe(dirP, Bp, idxS + 1); // ----------------------------------------------------------------- // LEVEL 3: Object Cursor // ----------------------------------------------------------------- // Start of Objects for Predicate `idxP` is the (idxP + 1)-th '1' in Bo. - this.idxO = select1Safe(dirO, Bo,idxP + 1); + this.idxO = select1Safe(dirO, Bo, idxP + 1); // --- Safety Checks --- // If idxP or idxO are -1 (not found), it means the lists are empty or we overshot. // However, select1(1) should always return 0 for non-empty. if (idxP == -1 || idxO == -1) return; - + // Ensure we are within bounds - if (idxP >= Sp.getNumEntries() || idxO >= So.getNumEntries()) return; + if (idxP >= spNum || idxO >= soNum) return; // Load Initial Values this.curSID = Ss.get(idxS); this.curPID = Sp.get(idxP); - + + if (quad.getGraph().isVariable()) { + gVar = Var.alloc(quad.getGraph()); + gId = NodeId.pack(NodeType.GRAPH, gi); + } + if (quad.getSubject().isVariable()) sVar = Var.alloc(quad.getSubject()); + if (quad.getPredicate().isVariable()) pVar = Var.alloc(quad.getPredicate()); + if (quad.getObject().isVariable()) oVar = Var.alloc(quad.getObject()); + advance(); } @@ -171,7 +200,7 @@ private void advance() { skipSubjectBlock(); continue; } else if (curSID > maxSubId) { - return; + return; } if (curPID < minPid) { skipPredicateBlock(); @@ -182,7 +211,7 @@ private void advance() { skipSubjectBlock(); continue; } - if (idxO >= So.getNumEntries()) { + if (idxO >= soNum) { skipPredicateBlock(); continue; } @@ -196,63 +225,63 @@ private void advance() { this.resO = curOID; hasNext = true; } - idxO++; - boolean endOfObjectList = (idxO >= So.getNumEntries()) || (Bo.get(idxO) == 1); + idxO++; + boolean endOfObjectList = (idxO >= soNum) || boBit.bit(idxO); if (endOfObjectList) { idxP++; - boolean endOfPredicateList = (idxP >= Sp.getNumEntries()) || (Bp.get(idxP) == 1); + boolean endOfPredicateList = (idxP >= spNum) || bpBit.bit(idxP); if (endOfPredicateList) { idxS++; if (idxS <= endS) { curSID = Ss.get(idxS); } - } - if (idxP < Sp.getNumEntries()) { + } + if (idxP < spNum) { curPID = Sp.get(idxP); } - } + } if (hasNext) return; } } - + private void skipPredicateBlock() { // Find start of NEXT predicate block // Current Predicate is idxP. Its start was select1(idxP+1). // Next Predicate is idxP+1. Its start is select1(idxP+2). - long nextPStart = select1Safe(dirO, Bo,idxP + 2); - idxO = (nextPStart == -1) ? So.getNumEntries() : nextPStart; - + long nextPStart = select1Safe(dirO, Bo, idxP + 2); + idxO = (nextPStart == -1) ? soNum : nextPStart; + idxP++; - - boolean endOfPredicateList = (idxP >= Sp.getNumEntries()) || (Bp.get(idxP) == 1); + + boolean endOfPredicateList = (idxP >= spNum) || bpBit.bit(idxP); if (endOfPredicateList) { idxS++; if (idxS <= endS) curSID = Ss.get(idxS); } - - if (idxP < Sp.getNumEntries()) curPID = Sp.get(idxP); + + if (idxP < spNum) curPID = Sp.get(idxP); } private void skipSubjectBlock() { // Find start of NEXT Subject block // Current Subject idxS. Start was select1(idxS+1). // Next Subject idxS+1. Start is select1(idxS+2). - long nextSStartP = select1Safe(dirP, Bp,idxS + 2); - + long nextSStartP = select1Safe(dirP, Bp, idxS + 2); + if (nextSStartP == -1) { - idxP = Sp.getNumEntries(); - idxO = So.getNumEntries(); + idxP = spNum; + idxO = soNum; } else { idxP = nextSStartP; // Now align Object cursor to the new Predicate - long nextSStartO = select1Safe(dirO, Bo,idxP + 1); - idxO = (nextSStartO == -1) ? So.getNumEntries() : nextSStartO; + long nextSStartO = select1Safe(dirO, Bo, idxP + 1); + idxO = (nextSStartO == -1) ? soNum : nextSStartO; } idxS++; - + if (idxS <= endS) curSID = Ss.get(idxS); - if (idxP < Sp.getNumEntries()) curPID = Sp.get(idxP); + if (idxP < spNum) curPID = Sp.get(idxP); } private void analyzeFilters(ExprList filter, PositionalDictionaryReader dict, Quad quad) { @@ -331,17 +360,17 @@ private BindingNodeId computeNext() { while (hasNext) { BindingNodeId result = new BindingNodeId(parentBinding); boolean ok = true; - if (queryQuad.getGraph().isVariable()) { - ok = result.putCompatible(Var.alloc(queryQuad.getGraph()), new NodeId(gi, NodeType.GRAPH), nodeTable); + if (gVar != null) { + ok = result.putCompatible(gVar, gId, nodeTable); } - if (ok && queryQuad.getSubject().isVariable()) { - ok = result.putCompatible(Var.alloc(queryQuad.getSubject()), new NodeId(resS, NodeType.SUBJECT), nodeTable); + if (ok && sVar != null) { + ok = result.putCompatible(sVar, NodeId.pack(NodeType.SUBJECT, resS), nodeTable); } - if (ok && queryQuad.getPredicate().isVariable()) { - ok = result.putCompatible(Var.alloc(queryQuad.getPredicate()), new NodeId(resP, NodeType.PREDICATE), nodeTable); + if (ok && pVar != null) { + ok = result.putCompatible(pVar, NodeId.pack(NodeType.PREDICATE, resP), nodeTable); } - if (ok && queryQuad.getObject().isVariable()) { - ok = result.putCompatible(Var.alloc(queryQuad.getObject()), new NodeId(resO, NodeType.OBJECT), nodeTable); + if (ok && oVar != null) { + ok = result.putCompatible(oVar, NodeId.pack(NodeType.OBJECT, resO), nodeTable); } advance(); if (ok) return result; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGReader.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGReader.java index 9c65b011..b9a6f682 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGReader.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BGReader.java @@ -30,4 +30,11 @@ public interface BGReader extends AutoCloseable { * instances whose underlying storage has been closed. */ public default boolean isOpen() { return true; } + + /** + * Exact triple count of {@code graph} computed from index structure alone, + * or -1 when not directly computable (union graph, missing index) - callers + * fall back to counting by scan. + */ + public default long countTriples(Node graph) { return -1; } } diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BindingBG.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BindingBG.java index cd7d5e31..7cffade5 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BindingBG.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BindingBG.java @@ -51,7 +51,7 @@ private boolean ownVar(Var var) { @Override protected Node get1(Var var) { if (ownVar(var)) { - NodeId id = idBinding.get(var); + long id = idBinding.get(var); // A var bound to "does not exist" has no node here; return null so // BindingBase falls back to the parent binding. if (NodeId.isDoesNotExist(id)) { diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BindingNodeId.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BindingNodeId.java index 3c29e5d5..33616e75 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/BindingNodeId.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/BindingNodeId.java @@ -1,95 +1,135 @@ package com.ebremer.beakgraph.hdf5.jena; import com.ebremer.beakgraph.core.NodeTable; -import java.util.HashMap; -import java.util.Map; -import org.apache.jena.atlas.lib.Map2; +import java.util.Iterator; +import java.util.NoSuchElementException; import org.apache.jena.graph.Node; import org.apache.jena.sparql.core.Var; import org.apache.jena.sparql.engine.binding.Binding; -public class BindingNodeId extends Map2 { +/** + * A Var -> packed-NodeId binding layer, chained to an optional parent layer, + * carrying the original Jena {@link Binding} for eventual conversion back. + * + *

One of these is allocated PER RESULT ROW by every iterator, so the layout + * is a single object with four inline (Var, long) slots - a quad pattern binds + * at most four variables per layer - no backing arrays, no per-value NodeId + * objects (ids are packed longs, {@link NodeId}). Lookups scan the own slots + * and then the parent chain; absence is {@link NodeId#NONE}, not null. A layer + * never re-binds a variable already bound in itself or its chain ({@link #put} + * keeps the first binding; {@link #putCompatible} reports the conflict), so + * iteration over the chain never yields duplicate variables. + */ +public class BindingNodeId implements Iterable { + // This is the parent binding - which may be several steps up the chain. // This just carried around for later use when we go BindingNodeId back to Binding. private final Binding parentBinding; + private final BindingNodeId parent; - // Possible optimization: there are at most 3 possible values so HashMap is overkill. - // Use a chain of small objects. + private Var v0, v1, v2, v3; + private long i0, i1, i2, i3; + private int n = 0; - private BindingNodeId(Map map1, Map2 map2, Binding parentBinding) { - super(map1, map2); + private BindingNodeId(BindingNodeId parent, Binding parentBinding) { + this.parent = parent; this.parentBinding = parentBinding; } // Make from an existing BindingNodeId public BindingNodeId(BindingNodeId other) { - this(new HashMap<>(), other, other != null ? other.getParentBinding() : null); + this(other, other != null ? other.getParentBinding() : null); } // Make from an existing Binding public BindingNodeId(Binding binding) { - this(new HashMap<>(), null, binding); + this(null, binding); } public BindingNodeId() { - this(new HashMap<>(), null, null); + this(null, (Binding) null); } public Binding getParentBinding() { return parentBinding; } + /** The packed id bound to {@code v}, or {@link NodeId#NONE}. */ + public long get(Var v) { + for (BindingNodeId layer = this; layer != null; layer = layer.parent) { + int m = layer.n; + if (m > 0 && layer.v0.equals(v)) return layer.i0; + if (m > 1 && layer.v1.equals(v)) return layer.i1; + if (m > 2 && layer.v2.equals(v)) return layer.i2; + if (m > 3 && layer.v3.equals(v)) return layer.i3; + } + return NodeId.NONE; + } + + public boolean containsKey(Var v) { + return get(v) != NodeId.NONE; + } + + private void append(Var v, long id) { + switch (n) { + case 0 -> { v0 = v; i0 = id; } + case 1 -> { v1 = v; i1 = id; } + case 2 -> { v2 = v; i2 = id; } + case 3 -> { v3 = v; i3 = id; } + default -> throw new IllegalStateException( + "BindingNodeId layer holds at most 4 bindings (a quad pattern's positions); chain a new layer instead"); + } + n++; + } + /** - * Binds {@code v} to {@code n}; when {@code v} is already bound, the existing + * Binds {@code v} to {@code id}; when {@code v} is already bound, the existing * binding is kept. Only safe when the caller guarantees the re-put carries the * same term (e.g. re-binding a value derived from this binding's own entry). * Row-building code that can see a variable repeated within one triple pattern * must use {@link #putCompatible} and reject the row on a conflict instead - * silently keeping the first value made {@code ?s ?p ?s} match every triple. */ - @Override - public void put(Var v, NodeId n) { - if ( v == null || n == null ) - throw new IllegalArgumentException("("+v+","+n+")"); + public void put(Var v, long id) { + if ( v == null || id == NodeId.NONE ) + throw new IllegalArgumentException("("+v+","+NodeId.toString(id)+")"); // Includes conversion where we are copying from parent. - if (!super.containsKey(v)) { - super.put(v, n); + if (!containsKey(v)) { + append(v, id); } } /** - * Binds {@code v} to {@code n}, or reports a conflict: returns true when the + * Binds {@code v} to {@code id}, or reports a conflict: returns true when the * binding was added or the existing binding denotes the same term, false when * {@code v} is already bound to a different term (the caller must reject the * row). {@code nodeTable} is needed only to compare ids across the predicate / * entity id-spaces; pass null when that cross-space case cannot occur. */ - public boolean putCompatible(Var v, NodeId n, NodeTable nodeTable) { - if ( v == null || n == null ) - throw new IllegalArgumentException("("+v+","+n+")"); - NodeId existing = get(v); - if (existing == null) { - super.put(v, n); + public boolean putCompatible(Var v, long id, NodeTable nodeTable) { + if ( v == null || id == NodeId.NONE ) + throw new IllegalArgumentException("("+v+","+NodeId.toString(id)+")"); + long existing = get(v); + if (existing == NodeId.NONE) { + append(v, id); return true; } - return sameTerm(existing, n, nodeTable); + return sameTerm(existing, id, nodeTable); } /** - * Whether two NodeIds denote the same RDF term. GRAPH, SUBJECT and OBJECT ids - * share the universal entity id-space (object literals are offset beyond the - * entity range), so within it equal ids mean equal terms. PREDICATE ids live in - * an isolated dictionary, so a predicate/entity pair is resolved to Nodes and - * compared as terms. + * Whether two packed NodeIds denote the same RDF term. GRAPH, SUBJECT and + * OBJECT ids share the universal entity id-space (object literals are offset + * beyond the entity range), so within it equal ids mean equal terms. + * PREDICATE ids live in an isolated dictionary, so a predicate/entity pair is + * resolved to Nodes and compared as terms. */ - private static boolean sameTerm(NodeId a, NodeId b, NodeTable nodeTable) { - if (a.equals(b)) return true; - NodeType ta = a.getType(); - NodeType tb = b.getType(); - if (ta == NodeType.SPECIAL || tb == NodeType.SPECIAL) return false; // a.equals(b) already said no - boolean aPred = (ta == NodeType.PREDICATE); - boolean bPred = (tb == NodeType.PREDICATE); + private static boolean sameTerm(long a, long b, NodeTable nodeTable) { + if (a == b) return true; + if (NodeId.isSpecial(a) || NodeId.isSpecial(b)) return false; // a == b already said no + boolean aPred = NodeId.isPredicateSpace(a); + boolean bPred = NodeId.isPredicateSpace(b); if (aPred == bPred) { // Same id-space (both predicate, or both universal entity/object space). - return a.getId() == b.getId(); + return NodeId.id(a) == NodeId.id(b); } if (nodeTable == null) return false; Node na = nodeTable.getNodeForNodeId(a); @@ -97,6 +137,47 @@ private static boolean sameTerm(NodeId a, NodeId b, NodeTable nodeTable) { return na != null && na.equals(nb); } + private Var slot(int i) { + return switch (i) { + case 0 -> v0; + case 1 -> v1; + case 2 -> v2; + default -> v3; + }; + } + + /** + * All bound variables: this layer's, then the parent chain's. Duplicate-free + * because a layer never re-binds a variable already bound below it. + */ + @Override + public Iterator iterator() { + return new Iterator<>() { + private BindingNodeId layer = BindingNodeId.this; + private int i = 0; + + private void advance() { + while (layer != null && i >= layer.n) { + layer = layer.parent; + i = 0; + } + } + + @Override + public boolean hasNext() { + advance(); + return layer != null; + } + + @Override + public Var next() { + advance(); + if (layer == null) throw new NoSuchElementException(); + return layer.slot(i++); + } + }; + } + @Override public String toString() { StringBuilder sb = new StringBuilder(); @@ -106,11 +187,11 @@ public String toString() { if ( ! first ) sb.append(" "); first = false; - NodeId x = get(v); + long x = get(v); if ( ! NodeId.isDoesNotExist(x)) { sb.append(v); sb.append(" = "); - sb.append(x); + sb.append(NodeId.toString(x)); } } if ( getParentBinding() != null ) { diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/DistinctTermFastPath.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/DistinctTermFastPath.java new file mode 100644 index 00000000..a3039445 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/DistinctTermFastPath.java @@ -0,0 +1,259 @@ +package com.ebremer.beakgraph.hdf5.jena; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.core.NodeTable; +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.readers.IndexReader; +import com.ebremer.beakgraph.hdf5.readers.PositionalDictionaryReader; +import com.ebremer.beakgraph.utils.HDTBitmapDirectory; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.jena.atlas.iterator.Iter; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.Triple; +import org.apache.jena.sparql.algebra.Op; +import org.apache.jena.sparql.algebra.op.OpBGP; +import org.apache.jena.sparql.algebra.op.OpDistinct; +import org.apache.jena.sparql.algebra.op.OpGraph; +import org.apache.jena.sparql.algebra.op.OpProject; +import org.apache.jena.sparql.core.BasicPattern; +import org.apache.jena.sparql.core.Quad; +import org.apache.jena.sparql.core.Var; +import org.apache.jena.sparql.engine.ExecutionContext; +import org.apache.jena.sparql.engine.QueryIterator; +import org.apache.jena.sparql.engine.binding.Binding; +import org.apache.jena.sparql.engine.binding.BindingFactory; +import org.apache.jena.sparql.engine.iterator.QueryIterPeek; +import org.apache.jena.sparql.engine.iterator.QueryIterPlainWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Index-backed fast path for {@code SELECT DISTINCT ?x WHERE { ?s ?p ?o }} where + * {@code ?x} is the predicate or the subject variable. + * + *

Both indexes store per-graph term levels that ARE these queries' answers: + *

    + *
  • ?p - the GPOS predicate level under a graph lists exactly the + * predicates occurring in that graph (sorted, each once): a few dozen index + * entries instead of scanning every quad. Small by nature, so the union + * graph is served too (per-graph lists de-duplicated in a set).
  • + *
  • ?s - the GSPO subject level under a graph lists exactly the + * graph's subjects, sorted and duplicate-free. It is STREAMED (no dedup + * state at all): at PubMed scale that is 447M rows the scan-based + * DISTINCT would otherwise have to pull through a hash set after reading + * billions of quads. The union graph would need cross-graph + * de-duplication (a k-way sorted merge - future work), so it falls back.
  • + *
+ * + *

{@code ?o} has no single index level (objects repeat per predicate group) + * and falls back. An earlier DISTINCT optimization was removed as unsound - it + * streamed file-global id lists (over-reporting terms from other graphs, + * including the VoID metadata graph), dropped FILTERs, and ignored the incoming + * iterator. This one is sound by construction because it fires only on the + * exact algebra shape whose answer the index directly stores: + * + *

  (distinct (project (?x) (bgp (?s ?p ?o))))            - active graph
+ *  (distinct (project (?x) (graph <g> (bgp (?s ?p ?o)))))  - concrete graph
+ * + * with all three pattern positions distinct unbound variables, exactly one + * projected variable, no filter (a FILTER changes the algebra shape, so it + * cannot fire here), and the plain top-level execution input (a single root + * binding that binds none of the pattern's variables). Everything else falls + * back to normal execution. + */ +public final class DistinctTermFastPath { + + private static final Logger logger = LoggerFactory.getLogger(DistinctTermFastPath.class); + + /** Fast-path activations; observability for tests and diagnostics. */ + public static final AtomicLong HITS = new AtomicLong(); + + private DistinctTermFastPath() {} + + /** + * Answers the query from index structure, or returns null (without having + * consumed {@code input}) when it is not exactly a supported shape. + *

+ * The caller guarantees {@code input} wraps the single-binding root iterator + * (it checks {@code instanceof QueryIterRoot} before wrapping into the peek); + * consuming that one binding here is therefore safe on the success path. + */ + public static QueryIterator tryExecute(OpDistinct opDistinct, QueryIterPeek input, ExecutionContext execCxt) { + if (!(execCxt.getActiveGraph() instanceof BeakGraph bg)) return null; + if (!(bg.getReader() instanceof HDF5Reader reader)) return null; + if (!(reader.getDictionary() instanceof PositionalDictionaryReader dict)) return null; + + // --- Algebra shape --- + if (!(opDistinct.getSubOp() instanceof OpProject project)) return null; + List projected = project.getVars(); + if (projected.size() != 1) return null; + Var v = projected.get(0); + + Op inner = project.getSubOp(); + Node target = bg.getNamedGraph(); + if (inner instanceof OpGraph opGraph) { + if (!opGraph.getNode().isConcrete()) return null; // GRAPH ?g - different answer shape + target = opGraph.getNode(); + inner = opGraph.getSubOp(); + } + if (!(inner instanceof OpBGP opBGP)) return null; + BasicPattern pattern = opBGP.getPattern(); + if (pattern.size() != 1) return null; + Triple t = pattern.get(0); + Node s = t.getSubject(); + Node p = t.getPredicate(); + Node o = t.getObject(); + if (!(s.isVariable() && p.isVariable() && o.isVariable())) return null; + if (s.equals(p) || p.equals(o) || s.equals(o)) return null; // repeated vars constrain rows + + boolean predicatePosition = Var.alloc(p).equals(v); + boolean subjectPosition = Var.alloc(s).equals(v); + if (!predicatePosition && !subjectPosition) return null; // ?o: no single index level + + IndexReader index = reader.getIndexReader(predicatePosition ? Index.GPOS : Index.GSPO); + if (index == null) return null; // index absent (foreign/old file): scan instead + + // Union DISTINCT ?s needs cross-graph de-duplication of potentially + // hundreds of millions of ids - fall back until a sorted-merge exists. + if (subjectPosition && Quad.isUnionGraph(target)) return null; + + // --- Input --- + Binding root = input.peek(); + if (root == null) return null; // exhausted input: the normal path answers empty immediately + // A pre-bound pattern variable (initial bindings) constrains the answer - scan instead. + if (root.contains(Var.alloc(s)) || root.contains(Var.alloc(p)) || root.contains(Var.alloc(o))) return null; + input.next(); // consume the single root binding (unrelated vars are dropped by the project) + + HITS.incrementAndGet(); + NodeTable nodeTable = reader.getNodeTable(); + + if (predicatePosition) { + Set pids = new LinkedHashSet<>(); + if (Quad.isUnionGraph(target)) { + // Union-of-named-graphs semantics: every stored graph except the default. + long defaultGi = dict.getGraphs().locate(Quad.defaultGraphIRI); + dict.streamGraphIds() + .filter(gid -> gid != defaultGi) + .forEach(gid -> collectGraphPredicates(index, gid, pids)); + } else { + collectGraphPredicates(index, resolveGraphId(dict, target), pids); + } + logger.debug("DISTINCT ?p answered from GPOS index: graph={}, {} predicates", target, pids.size()); + Iterator out = Iter.removeNulls(Iter.map(pids.iterator(), pid -> { + Node pn = nodeTable.getNodeForNodeId(NodeId.pack(NodeType.PREDICATE, pid)); + return (pn == null) ? null : BindingFactory.binding(v, pn); + })); + return QueryIterPlainWrapper.create(out, execCxt); + } + + // Subject position: stream the graph's GSPO subject level - already sorted + // and duplicate-free, so no dedup state regardless of subject count. + long[] range = firstLevelRange(index, 'S', resolveGraphId(dict, target)); + logger.debug("DISTINCT ?s answered from GSPO index: graph={}, {} subjects", + target, (range == null) ? 0 : (range[1] - range[0] + 1)); + if (range == null) { + return QueryIterPlainWrapper.create(Collections.emptyIterator(), execCxt); + } + Iterator out = new SubjectBindings(index.getIDBuffer('S'), range[0], range[1], nodeTable, v); + return QueryIterPlainWrapper.create(out, execCxt); + } + + /** The graph's first-level id (entity id), or -1 for "no rows here". */ + private static long resolveGraphId(PositionalDictionaryReader dict, Node target) { + Node g = Quad.isDefaultGraph(target) ? Quad.defaultGraphIRI : target; + return dict.getGraphs().locate(g); + } + + /** + * Adds the ids of every predicate present in graph {@code gi} - the GPOS + * P-level range belonging to that graph. Sorted and duplicate-free within one + * graph by index construction; id 0 is the padding row of an empty graph block. + */ + private static void collectGraphPredicates(IndexReader gpos, long gi, Set out) { + long[] range = firstLevelRange(gpos, 'P', gi); + if (range == null) return; + BitPackedUnSignedLongBuffer sp = gpos.getIDBuffer('P'); + for (long i = range[0]; i <= range[1]; i++) { + long pid = sp.get(i); + if (pid >= 1) { + out.add(pid); + } + } + } + + /** + * The inclusive position range of graph {@code gi}'s block at the index's + * first level, or null when the graph has no rows (absent id, or a + * padding-only block - the writer pads each empty first-level slot with a + * single all-zero dummy row). + */ + private static long[] firstLevelRange(IndexReader ir, char component, long gi) { + if (gi < 1) return null; + BitPackedUnSignedLongBuffer bitmap = ir.getBitmapBuffer(component); + BitPackedUnSignedLongBuffer ids = ir.getIDBuffer(component); + HDTBitmapDirectory dir = ir.getDirectory(component); + long start = (dir != null) ? dir.select1(gi) : bitmap.select1(gi); + if (start == -1) return null; + long next = (dir != null) ? dir.select1(gi + 1) : bitmap.select1(gi + 1); + long end = (next == -1) ? ids.getNumEntries() - 1 : next - 1; + if (start > end) return null; + if (ids.get(start) == 0) return null; // padding row: the graph is empty + return new long[]{start, end}; + } + + /** + * Lazily streams one binding per subject id in [start..end] of the GSPO + * subject level. Look-ahead so unresolvable ids are skipped rather than + * emitted as null (mirrors the predicate path's removeNulls). + */ + private static final class SubjectBindings implements Iterator { + private final BitPackedUnSignedLongBuffer ss; + private final NodeTable nodeTable; + private final Var var; + private final long end; + private long i; + private Binding pending; + + SubjectBindings(BitPackedUnSignedLongBuffer ss, long start, long end, NodeTable nodeTable, Var var) { + this.ss = ss; + this.end = end; + this.i = start; + this.nodeTable = nodeTable; + this.var = var; + advance(); + } + + private void advance() { + pending = null; + while (pending == null && i <= end) { + long sid = ss.get(i++); + if (sid < 1) continue; // defensive: padding rows never appear in non-empty blocks + Node n = nodeTable.getNodeForNodeId(NodeId.pack(NodeType.SUBJECT, sid)); + if (n != null) { + pending = BindingFactory.binding(var, n); + } + } + } + + @Override + public boolean hasNext() { + return pending != null; + } + + @Override + public Binding next() { + if (pending == null) throw new NoSuchElementException(); + Binding r = pending; + advance(); + return r; + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/ExecutionContextBG.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/ExecutionContextBG.java deleted file mode 100644 index a7f72bd0..00000000 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/ExecutionContextBG.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.ebremer.beakgraph.hdf5.jena; - -import org.apache.jena.sparql.algebra.Op; -import org.apache.jena.sparql.algebra.op.OpFilter; -import org.apache.jena.sparql.engine.ExecutionContext; -import org.apache.jena.sparql.expr.ExprList; - -/** - * - * @author erich - */ -public class ExecutionContextBG extends ExecutionContext { - private final Op op; - - public ExecutionContextBG(ExecutionContext other, Op op) { - super(other); - this.op = op; - } - - public ExprList getFilter() { - if (op instanceof OpFilter opFilter) { - return opFilter.getExprs(); - } - return null; - } -} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/IndexExport.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/IndexExport.java new file mode 100644 index 00000000..e9f09fee --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/IndexExport.java @@ -0,0 +1,280 @@ +package com.ebremer.beakgraph.hdf5.jena; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.Dictionary; +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.readers.IndexReader; +import com.ebremer.beakgraph.hdf5.readers.PositionalDictionaryReader; +import com.ebremer.beakgraph.utils.HDTBitmapDirectory; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.jena.atlas.io.IndentedLineBuffer; +import org.apache.jena.atlas.lib.CharSpace; +import org.apache.jena.graph.Node; +import org.apache.jena.riot.out.NodeFormatterNT; +import org.apache.jena.sparql.core.Quad; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Streaming NT/NQ export straight off the GSPO index. + * + *

The generic path pulls every quad through the full query stack - iterator + * construction, per-row bindings, node-table materialization, then Jena's + * stream writer re-serializes every Node OCCURRENCE. But an export visits rows + * in index order, where terms repeat massively (a subject's text is reused for + * its whole P/O sub-tree, predicates number a few dozen, objects recur), so + * this walker keeps GSPO cursors directly and emits MEMOIZED per-id text: + * each distinct id is dictionary-decoded and formatted once, and each row is + * just cursor advancement plus text writes. + * + *

Output is byte-compatible with the generic path: terms are rendered by + * Jena's own {@link NodeFormatterNT} (UTF-8 char space, exactly what + * {@code StreamRDFWriter} uses for NTRIPLES/NQUADS), lines are + * {@code s p o [g] .\n}, default-graph quads carry no graph term, and the + * BeakGraph-internal metadata graphs ({@code urn:x-beakgraph:*}) are excluded. + * Disable with {@code -Dbeakgraph.export.fastpath=false} to fall back to the + * generic writer. + */ +public final class IndexExport { + + private static final Logger logger = LoggerFactory.getLogger(IndexExport.class); + + /** Fast-path activations; observability for tests and diagnostics. */ + public static final AtomicLong HITS = new AtomicLong(); + + /** + * Object-text memo capacity (entries; -Dbeakgraph.export.textcache). A plain + * map cleared wholesale when full: an access-ordered LRU paid linked-list + * surgery on EVERY lookup and still thrashed on high-cardinality stores, + * costing more than it saved. + */ + private static final int OBJECT_TEXT_CACHE = + Integer.getInteger("beakgraph.export.textcache", 1 << 18); + + private IndexExport() {} + + /** + * Streams the store to {@code os} as NT ({@code quads} false: default graph + * only) or NQ (default graph plus user named graphs). Returns false - with + * nothing written - when this store cannot take the fast path (no GSPO + * index, foreign dictionary) or it is disabled; the caller then uses the + * generic writer. The stream is flushed but not closed. + */ + public static boolean tryWrite(HDF5Reader reader, OutputStream os, boolean quads) throws IOException { + if (!Boolean.parseBoolean(System.getProperty("beakgraph.export.fastpath", "true"))) { + return false; + } + if (!(reader.getDictionary() instanceof PositionalDictionaryReader dict)) { + return false; + } + IndexReader gspo = reader.getIndexReader(Index.GSPO); + if (gspo == null) { + return false; + } + HITS.incrementAndGet(); + Writer w = new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8), 1 << 16); + Emitter emitter = new Emitter(dict, gspo, w); + long defaultGi = dict.getGraphs().locate(Quad.defaultGraphIRI); + long rows = emitter.emitGraph(defaultGi, null); + if (quads) { + long[] gids = dict.streamGraphIds().toArray(); + for (long gid : gids) { + if (gid == defaultGi || gid < 1) { + continue; + } + Node graphNode = dict.getGraphs().extract(gid); + if (Params.BGVOID.equals(graphNode) || Params.SPATIAL.equals(graphNode)) { + continue; // internal metadata graphs never leave the store + } + rows += emitter.emitGraph(gid, graphNode); + } + } + w.flush(); + logger.debug("Index export emitted {} rows ({})", rows, quads ? "NQ" : "NT"); + return true; + } + + /** GSPO cursor walk + memoized term text for one store. Single-threaded. */ + private static final class Emitter { + private final Dictionary entities; + private final Dictionary predicates; + private final Dictionary objects; + private final BitPackedUnSignedLongBuffer Bs, Ss, Bp, Sp, Bo, So; + private final HDTBitmapDirectory dirS, dirP, dirO; + private final long spNum, soNum; + private final Writer w; + + private final NodeFormatterNT fmt = new NodeFormatterNT(CharSpace.UTF8); + private final Map predicateText = new HashMap<>(); + private final LongTextMap objectText = new LongTextMap(OBJECT_TEXT_CACHE); + + Emitter(PositionalDictionaryReader dict, IndexReader gspo, Writer w) { + this.entities = dict.getSubjects(); + this.predicates = dict.getPredicates(); + this.objects = dict.getObjects(); + this.Bs = gspo.getBitmapBuffer('S'); + this.Ss = gspo.getIDBuffer('S'); + this.Bp = gspo.getBitmapBuffer('P'); + this.Sp = gspo.getIDBuffer('P'); + this.Bo = gspo.getBitmapBuffer('O'); + this.So = gspo.getIDBuffer('O'); + this.dirS = gspo.getDirectory('S'); + this.dirP = gspo.getDirectory('P'); + this.dirO = gspo.getDirectory('O'); + this.spNum = Sp.getNumEntries(); + this.soNum = So.getNumEntries(); + this.w = w; + } + + /** + * Emits every quad of graph {@code gi}; {@code graphNode} null means the + * default graph (no graph term on the line). Returns the row count. + */ + long emitGraph(long gi, Node graphNode) throws IOException { + if (gi < 1) { + return 0; + } + long sStart = RangeSelect.blockStart(dirS, Bs, gi); + if (sStart == -1) { + return 0; + } + long sEnd = RangeSelect.blockEnd(dirS, Bs, gi, sStart); + if (sStart > sEnd || Ss.get(sStart) == 0) { + return 0; // absent or padding-only (empty) graph block + } + String graphText = (graphNode == null) ? null : text(graphNode); + + long idxS = sStart; + long idxP = RangeSelect.blockStart(dirP, Bp, idxS + 1); + if (idxP == -1) { + return 0; + } + long idxO = RangeSelect.blockStart(dirO, Bo, idxP + 1); + if (idxO == -1) { + return 0; + } + BitPackedUnSignedLongBuffer.BitReader bpBit = Bp.bitReader(); + BitPackedUnSignedLongBuffer.BitReader boBit = Bo.bitReader(); + + long rows = 0; + // Subjects are unique within a graph: the text is computed once and + // reused across the subject's whole P/O sub-tree, no cache needed. + String sText = text(entities.extract(Ss.get(idxS))); + String pText = predicateText(Sp.get(idxP)); + while (idxS <= sEnd) { + w.write(sText); + w.write(' '); + w.write(pText); + w.write(' '); + w.write(objectText(So.get(idxO))); + if (graphText != null) { + w.write(' '); + w.write(graphText); + } + w.write(" .\n"); + rows++; + + // Cursor advance, mirroring BGIteratorSPO_All: a set bit at the + // next position means the current block ended there. Text refreshes + // happen only while the walk continues - past sEnd the next slot may + // be an empty graph's padding row (id 0), which must not be decoded. + idxO++; + if (idxO >= soNum || boBit.bit(idxO)) { + idxP++; + if (idxP >= spNum || bpBit.bit(idxP)) { + idxS++; + if (idxS > sEnd) { + break; + } + sText = text(entities.extract(Ss.get(idxS))); + } + pText = predicateText(Sp.get(idxP)); + } + } + return rows; + } + + private String predicateText(long pid) { + return predicateText.computeIfAbsent(pid, id -> text(predicates.extract(id))); + } + + private String objectText(long oid) { + String t = objectText.get(oid); + if (t == null) { + t = text(objects.extract(oid)); + objectText.put(oid, t); + } + return t; + } + + private String text(Node n) { + IndentedLineBuffer buff = new IndentedLineBuffer(); + fmt.format(buff, n); + return buff.asString(); + } + } + + /** + * Open-addressing long -> String memo, cleared wholesale when full: no + * boxing on the per-row lookup and no per-entry eviction bookkeeping (an + * access-ordered LRU cost more per hit than it saved). Load factor <= 0.5. + * Single-threaded (one per export). + */ + private static final class LongTextMap { + private final long[] keys; + private final String[] vals; + private final int mask; + private final int maxEntries; + private int size; + + LongTextMap(int maxEntries) { + this.maxEntries = maxEntries; + int capacity = Integer.highestOneBit(Math.max(1024, maxEntries) * 2 - 1) << 1; + this.keys = new long[capacity]; + this.vals = new String[capacity]; + this.mask = capacity - 1; + } + + /** splitmix64 finalizer. */ + private static long mix(long z) { + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } + + String get(long key) { + int i = (int) (mix(key) & mask); + while (vals[i] != null) { + if (keys[i] == key) { + return vals[i]; + } + i = (i + 1) & mask; + } + return null; + } + + void put(long key, String value) { + if (size >= maxEntries) { + java.util.Arrays.fill(vals, null); + size = 0; + } + int i = (int) (mix(key) & mask); + while (vals[i] != null) { + i = (i + 1) & mask; + } + keys[i] = key; + vals[i] = value; + size++; + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/NodeId.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/NodeId.java index 19777623..853f79d9 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/NodeId.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/NodeId.java @@ -1,60 +1,79 @@ package com.ebremer.beakgraph.hdf5.jena; -import java.util.Objects; +/** + * A NodeId is a primitive {@code long}: 3 type bits ({@link NodeType} ordinal) + * in bits 63-61, the dictionary id in bits 60-0. + * + *

Formerly a (long id, NodeType) object allocated per bound value per result + * row - the dominant scan-time garbage. The 61-bit id space (2.3e18) sits ~16x + * above the on-disk format's own ceiling (bit-packed id widths stop at 57 + * bits), so the encoding can never become the store's capacity limit. Packed + * values exist only at runtime; nothing on disk depends on this layout. + * + *

Special values, all outside every real id-space: + *

    + *
  • {@link #NONE} (-1): "no binding here" - what lookups return for an + * absent variable (the old API's null). All-ones = type bits 111, which + * no {@link NodeType} ordinal produces.
  • + *
  • {@link #DOES_NOT_EXIST}: the term is not in this store (SPECIAL-typed, + * reserved id). Recorded in bindings so absence is cached and pattern + * short-circuits work; never dereferenced.
  • + *
+ * + * @author Erich Bremer + */ +public final class NodeId { -final public class NodeId implements Comparable { - public static final NodeId NodeDoesNotExist = new NodeId( -8, NodeType.SPECIAL ); - public static final NodeId NodeIdAny = new NodeId( -9, NodeType.SPECIAL ); - private final long id; - private final NodeType type; + private NodeId() {} - public NodeId(long id, NodeType type) { - this.id = id; - this.type = type; - } - - public NodeType getType() { - return type; - } - - public long getId() { - return id; + private static final int TYPE_SHIFT = 61; + private static final long ID_MASK = (1L << TYPE_SHIFT) - 1; + + /** "No binding": what lookups return for an absent variable. */ + public static final long NONE = -1L; + + /** + * The term does not exist in this store (the old NodeDoesNotExist sentinel). + * Id bits deliberately 0: every id consumer treats {@code < 1} as "no match", + * so even a hypothetical unguarded leak of this sentinel into an id lookup + * yields an empty result rather than entity #N - the same failure shape the + * old negative-id sentinel had. + */ + public static final long DOES_NOT_EXIST = pack(NodeType.SPECIAL, 0); + + public static long pack(NodeType type, long id) { + // Callers never produce ids past the 57-bit on-disk ceiling; assert-only + // so the hot path carries no branch in production. + assert (id & ~ID_MASK) == 0 : "id overflows 61 bits: " + id; + return ((long) type.ordinal() << TYPE_SHIFT) | id; } - public static final boolean isAny(NodeId nodeId) { - return nodeId == NodeIdAny || nodeId == null ; + public static long id(long nodeId) { + return nodeId & ID_MASK; } - - public static final boolean isDoesNotExist(NodeId nodeId) { - return NodeDoesNotExist.equals(nodeId); + + public static NodeType type(long nodeId) { + return NodeType.VALUES[(int) (nodeId >>> TYPE_SHIFT)]; } - - @Override - public String toString() { - return String.format("NodeID [%s %s]", id, type); + + /** Whether the id lives in the isolated predicate id-space. */ + public static boolean isPredicateSpace(long nodeId) { + return (nodeId >>> TYPE_SHIFT) == NodeType.PREDICATE.ordinal(); } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - NodeId nodeId = (NodeId) o; - return id == nodeId.id && type == nodeId.type; + public static boolean isSpecial(long nodeId) { + return (nodeId >>> TYPE_SHIFT) == NodeType.SPECIAL.ordinal(); } - @Override - public int hashCode() { - // Objects.hash handles the null check for 'type' and - // effectively mixes the bits of the long 'id' - return Objects.hash(id, type); + public static boolean isDoesNotExist(long nodeId) { + return nodeId == DOES_NOT_EXIST; } - @Override - public int compareTo(NodeId o) { - int res = Long.compare(this.id, o.id); - if (res == 0) { - return this.type.compareTo(o.type); + /** Debug rendering; matches the old object's "NodeID [id type]" shape. */ + public static String toString(long nodeId) { + if (nodeId == NONE) { + return "NodeID [NONE]"; } - return res; + return String.format("NodeID [%s %s]", id(nodeId), type(nodeId)); } } diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/NodeType.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/NodeType.java index e43fe9f9..965c52b0 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/NodeType.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/NodeType.java @@ -1,9 +1,16 @@ package com.ebremer.beakgraph.hdf5.jena; /** + * Position/id-space tag carried in a packed NodeId's top bits (see {@link NodeId}). + * Ordinals are part of the RUNTIME encoding only - packed NodeIds are never + * persisted - but reordering constants still changes {@code NodeId.pack}'s + * output shape, so append new members rather than reordering. * * @author erich */ public enum NodeType { GRAPH, SUBJECT, PREDICATE, OBJECT, SPECIAL; + + /** Cached {@link #values()} for ordinal decode without the defensive-copy allocation. */ + static final NodeType[] VALUES = values(); } diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/OpExecutorBG.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/OpExecutorBG.java index 83860759..2addbc58 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/OpExecutorBG.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/OpExecutorBG.java @@ -10,6 +10,7 @@ import org.apache.jena.sparql.engine.ExecutionContext; import org.apache.jena.sparql.engine.QueryIterator; import org.apache.jena.sparql.engine.iterator.QueryIterPeek; +import org.apache.jena.sparql.engine.iterator.QueryIterRoot; import org.apache.jena.sparql.engine.main.OpExecutor; import org.apache.jena.sparql.engine.main.OpExecutorFactory; import org.apache.jena.sparql.engine.main.QC; @@ -45,9 +46,42 @@ public OpExecutorBG(ExecutionContext execCtx) { // span every named graph, including the always-present VoID metadata graph, so // default-graph queries over-reported terms), any FILTER wrapped around the // pattern was silently dropped, DISTINCT ?g included the default graph, and the - // incoming iterator (join semantics) was discarded. A correct fast path would - // need per-graph id lists in the format plus filter/join guards; until then - // DISTINCT executes normally. + // incoming iterator (join semantics) was discarded. The replacement below fixes + // all of that by reading the PER-GRAPH index levels (GPOS predicates / GSPO + // subjects) and firing only on the exact algebra shape it can answer - see + // DistinctTermFastPath. + + @Override + protected QueryIterator execute(OpDistinct opDistinct, QueryIterator input) { + // Only the plain top-level execution shape (the engine's single-binding root + // iterator) is eligible; a joined/nested input keeps normal semantics. The + // peek wrapper lets the fast path inspect the root binding without consuming + // it, so declining costs nothing - the wrapper simply becomes the input. + if (isForBeakGraph && input instanceof QueryIterRoot) { + QueryIterPeek peek = QueryIterPeek.create(input, execCxt); + QueryIterator fast = DistinctTermFastPath.tryExecute(opDistinct, peek, execCxt); + if (fast != null) { + return fast; + } + input = peek; + } + return super.execute(opDistinct, input); + } + + @Override + protected QueryIterator execute(OpGroup opGroup, QueryIterator input) { + // Whole-graph COUNT aggregates over {?s ?p ?o} answered from index structure + // (same eligibility rules as the DISTINCT fast path above). + if (isForBeakGraph && input instanceof QueryIterRoot) { + QueryIterPeek peek = QueryIterPeek.create(input, execCxt); + QueryIterator fast = AggregateCountFastPath.tryExecute(opGroup, peek, execCxt); + if (fast != null) { + return fast; + } + input = peek; + } + return super.execute(opGroup, input); + } @Override protected QueryIterator execute(OpPropFunc opPropFunc, QueryIterator input) { @@ -112,27 +146,35 @@ private static BasicPattern reorder(BasicPattern pattern, QueryIterPeek peek, Re } private static QueryIterator plainExecute(Op op, QueryIterator input, ExecutionContext execCxt) { - ExecutionContextBG ec = new ExecutionContextBG(execCxt, op); - ec.setExecutor(plainFactory); + // Jena 6: ExecutionContext is final, so the placed filter can no longer + // ride on a context subclass (the old ExecutionContextBG). It rides on a + // per-call executor FACTORY instead - immutable and per-execution, so + // executors created lazily during iteration (e.g. substitution joins + // re-executing the RHS per binding) still see exactly their op's filter. + ExprList filter = (op instanceof OpFilter opFilter) ? opFilter.getExprs() : null; + ExecutionContext ec = ExecutionContext.copyChangeExecutor(execCxt, new OpExecutorPlainFactoryBeak(filter)); return QC.execute(op, input, ec) ; } - - private static final OpExecutorFactory plainFactory = new OpExecutorPlainFactoryBeak(); - + private static class OpExecutorPlainFactoryBeak implements OpExecutorFactory { + private final ExprList filter; + + OpExecutorPlainFactoryBeak(ExprList filter) { + this.filter = filter; + } + @Override public OpExecutor create(ExecutionContext execCxt) { - return new OpExecutorPlainBeak(execCxt) ; + return new OpExecutorPlainBeak(execCxt, filter) ; } } - + private static class OpExecutorPlainBeak extends OpExecutor { final ExprList filter; - public OpExecutorPlainBeak(ExecutionContext execCxt) { + public OpExecutorPlainBeak(ExecutionContext execCxt, ExprList filter) { super(execCxt); - ExecutionContextBG ecr = (ExecutionContextBG) execCxt; - filter = ecr.getFilter(); + this.filter = filter; } @Override diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/ParallelScan.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/ParallelScan.java new file mode 100644 index 00000000..bd59a4f9 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/ParallelScan.java @@ -0,0 +1,224 @@ +package com.ebremer.beakgraph.hdf5.jena; + +import java.lang.ref.Cleaner; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.apache.jena.atlas.iterator.IteratorCloseable; +import org.apache.jena.query.QueryCancelledException; +import org.apache.jena.sparql.engine.iterator.Abortable; + +/** + * Runs independent scan chunks on a shared worker pool and streams their rows + * to the single consumer thread, batch-wise through a bounded queue. Row order + * is interleaved across chunks - fine for BGP semantics, which promise no order. + * + *

Lifecycle is the hard part and is triple-covered, matching how Jena's + * execution actually tears iterators down: + *

    + *
  • Cancel (timeout, abort): registered in the solver's kill-list, so + * {@code QueryIterAbortable.requestCancel} calls {@link #abort()}; workers + * additionally poll the engine's shared cancel signal.
  • + *
  • Close (LIMIT, normal end): {@code Iter.close} cascades through the + * chain (IterMap / IterAbortable / IteratorFlatMap all propagate close) to + * {@link #close()}.
  • + *
  • Abandonment backstop: a {@link Cleaner} stops the workers if an + * owning iterator is dropped without either of the above.
  • + *
+ * Workers never block indefinitely: every queue offer/poll uses a short timeout + * and rechecks the stop conditions, so a worker parked against a full queue + * exits within one tick of shutdown. A worker failure is recorded, stops the + * scan, and is rethrown to the consumer rather than swallowed. + */ +public final class ParallelScan implements IteratorCloseable, Abortable { + + /** Parallel scans started; observability for tests and diagnostics. */ + public static final AtomicLong HITS = new AtomicLong(); + /** Currently running chunk workers; tests assert this drains to 0 on close. */ + public static final AtomicInteger ACTIVE_WORKERS = new AtomicInteger(); + + private static final int BATCH = 256; + private static final long TICK_MS = 25; + private static final BindingNodeId[] END = new BindingNodeId[0]; + private static final Cleaner CLEANER = Cleaner.create(); + + private static final ExecutorService POOL = Executors.newFixedThreadPool( + Math.max(2, Runtime.getRuntime().availableProcessors()), + new ThreadFactory() { + private final AtomicInteger n = new AtomicInteger(); + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "beakgraph-scan-" + n.incrementAndGet()); + t.setDaemon(true); + return t; + } + }); + + private final ArrayBlockingQueue queue; + private final AtomicBoolean stop; + private final AtomicBoolean cancelSignal; // the engine's; may be null + private final AtomicReference failure = new AtomicReference<>(); + private int workersRemaining; + + private BindingNodeId[] batch; + private int batchPos; + + ParallelScan(List>> chunks, AtomicBoolean cancelSignal) { + this.queue = new ArrayBlockingQueue<>(Math.max(4, chunks.size() * 2)); + this.stop = new AtomicBoolean(false); + this.cancelSignal = cancelSignal; + this.workersRemaining = chunks.size(); + HITS.incrementAndGet(); + // The cleaner action must not capture `this`; it shares the stop flag and + // queue so abandoned scans still release their workers. + AtomicBoolean stopRef = this.stop; + ArrayBlockingQueue queueRef = this.queue; + CLEANER.register(this, () -> { + stopRef.set(true); + queueRef.clear(); + }); + for (Supplier> chunk : chunks) { + POOL.execute(() -> runChunk(chunk)); + } + } + + private boolean stopped() { + return stop.get() || (cancelSignal != null && cancelSignal.get()); + } + + private void runChunk(Supplier> chunk) { + ACTIVE_WORKERS.incrementAndGet(); + try { + Iterator it = chunk.get(); + BindingNodeId[] buf = new BindingNodeId[BATCH]; + int n = 0; + while (!stopped() && it.hasNext()) { + buf[n++] = it.next(); + if (n == BATCH) { + if (!offer(buf)) return; + buf = new BindingNodeId[BATCH]; + n = 0; + } + } + if (n > 0 && !stopped()) { + BindingNodeId[] tail = new BindingNodeId[n]; + System.arraycopy(buf, 0, tail, 0, n); + offer(tail); + } + } catch (RuntimeException e) { + failure.compareAndSet(null, e); + stop.set(true); + } finally { + offerEnd(); + ACTIVE_WORKERS.decrementAndGet(); + } + } + + /** Enqueues, rechecking stop each tick. Returns false when shut down. */ + private boolean offer(BindingNodeId[] b) { + try { + while (!stopped()) { + if (queue.offer(b, TICK_MS, TimeUnit.MILLISECONDS)) { + return true; + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return false; + } + + /** + * Best-effort END marker so the consumer can count workers out. When the + * scan is stopped the consumer no longer relies on END counting, so bailing + * out is fine. + */ + private void offerEnd() { + try { + while (!queue.offer(END, TICK_MS, TimeUnit.MILLISECONDS)) { + if (stopped()) { + return; + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public boolean hasNext() { + if (batch != null && batchPos < batch.length) { + return true; + } + batch = null; + while (workersRemaining > 0) { + RuntimeException e = failure.get(); + if (e != null) { + shutdown(); + throw e; + } + if (stop.get()) { + return false; // closed under us + } + if (cancelSignal != null && cancelSignal.get()) { + shutdown(); + throw new QueryCancelledException(); + } + BindingNodeId[] b; + try { + b = queue.poll(TICK_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + shutdown(); + return false; + } + if (b == null) { + continue; + } + if (b == END) { + workersRemaining--; + continue; + } + batch = b; + batchPos = 0; + return true; + } + RuntimeException e = failure.get(); + if (e != null) { + throw e; + } + return false; + } + + @Override + public BindingNodeId next() { + if (!hasNext()) throw new NoSuchElementException(); + return batch[batchPos++]; + } + + private void shutdown() { + stop.set(true); + queue.clear(); // unblock producers parked on a full queue + } + + @Override + public void close() { + shutdown(); + } + + @Override + public void abort() { + shutdown(); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/PatternMatchBG.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/PatternMatchBG.java index 4ada20d2..07df1336 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/PatternMatchBG.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/PatternMatchBG.java @@ -50,23 +50,60 @@ public static QueryIterator execute(BeakGraph bGraph, BasicPattern bgp, QueryIte // Execute all triple patterns (the ExprList is range-pushdown hints only; // full filter semantics are enforced by the surrounding OpFilter). + // Jena 6: makeAbortable takes the execution's cancel signal directly, + // mirroring Jena's own solvers. The FIRST pattern - where a scan appears + // when the input is the single root binding - may be answered by a + // parallel chunked scan; join steps stay sequential (per-binding index + // lookups, not scans). + boolean first = true; for (Triple triple : triples) { - chain = solve(bGraph, triple, filter, chain, execCxt); - chain = makeAbortable(chain, killList); + if (first) { + first = false; + chain = solveFirst(bGraph, triple, filter, chain, execCxt, killList); + } else { + chain = solve(bGraph, triple, filter, chain, execCxt); + } + chain = makeAbortable(chain, killList, execCxt.getCancelSignal()); } // Convert back to Jena bindings Iterator iterBinding = SolverLibBeak.convertToNodes(chain, bGraph); - iterBinding = makeAbortable(iterBinding, killList); + iterBinding = makeAbortable(iterBinding, killList, execCxt.getCancelSignal()); return new QueryIterAbortable(iterBinding, killList, input, execCxt); } - private static Iterator solve(BeakGraph bGraph, Triple triple, ExprList filter, + private static Iterator solve(BeakGraph bGraph, Triple triple, ExprList filter, Iterator chain, ExecutionContext execCxt) { - Function> step = + Function> step = bnid -> find(bGraph, bnid, triple, filter, execCxt); return Iter.flatMap(chain, step); } + + /** + * First pattern of the BGP. When the input is exactly one binding (the plain + * top-level root - by far the common case for scan queries) and the pattern + * is scan-shaped, answer it with a chunked parallel scan; the scan is + * registered in the kill-list so cancellation stops its workers, and close + * reaches it through the Iter close cascade. Multi-binding inputs (spatial + * seeding, joins) keep the ordinary lazy per-binding chaining. + */ + private static Iterator solveFirst(BeakGraph bGraph, Triple triple, ExprList filter, + Iterator chain, ExecutionContext execCxt, + List killList) { + if (!chain.hasNext()) { + return chain; + } + BindingNodeId b0 = chain.next(); + if (chain.hasNext()) { + return solve(bGraph, triple, filter, Iter.concat(List.of(b0).iterator(), chain), execCxt); + } + ParallelScan parallel = ScanChunks.tryParallel(bGraph, b0, triple, filter, execCxt); + if (parallel != null) { + killList.add(parallel); + return parallel; + } + return find(bGraph, b0, triple, filter, execCxt); + } private static Iterator find(BeakGraph bGraph, BindingNodeId bnid, Triple xPattern, ExprList filter, ExecutionContext execCxt) { diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/RangeSelect.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/RangeSelect.java new file mode 100644 index 00000000..66ec0726 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/RangeSelect.java @@ -0,0 +1,43 @@ +package com.ebremer.beakgraph.hdf5.jena; + +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.utils.HDTBitmapDirectory; + +/** + * Block-range resolution over the index bitmaps (one set bit per block start). + * + *

Every iterator needs "where does block {@code rank} start and end", which + * used to cost two independent O(log n) directory descents. The end of a block + * is just the next set bit after its start, and real blocks are usually short, + * so {@link #blockEnd} answers with a bounded forward word-scan first and only + * falls back to the directory when the block spans more than + * {@value #SCAN_WORD_BUDGET} words (e.g. a giant graph's subject block) - the + * scan-then-fallback never degrades to a linear walk. + */ +final class RangeSelect { + + /** 16 words = 1024 bits: covers typical blocks, bounds the worst case. */ + private static final long SCAN_WORD_BUDGET = 16; + + private RangeSelect() {} + + /** Position of the {@code rank}-th set bit (block start), or -1. */ + static long blockStart(HDTBitmapDirectory dir, BitPackedUnSignedLongBuffer bitmap, long rank) { + if (rank < 1) return -1; + return (dir != null) ? dir.select1(rank) : bitmap.select1(rank); + } + + /** + * Inclusive end of the block that starts at {@code start} (the {@code rank}-th + * block): one before the next set bit, or the last entry when this block is + * the final one. Prefers a bounded forward scan from {@code start + 1}; + * long blocks fall back to {@code select1(rank + 1)}. + */ + static long blockEnd(HDTBitmapDirectory dir, BitPackedUnSignedLongBuffer bitmap, long rank, long start) { + long next = bitmap.nextSetBit(start + 1, SCAN_WORD_BUDGET); + if (next == BitPackedUnSignedLongBuffer.SCAN_EXHAUSTED) { + next = (dir != null) ? dir.select1(rank + 1) : bitmap.select1(rank + 1); + } + return (next == -1) ? bitmap.getNumEntries() - 1 : next - 1; + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/ScanChunks.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/ScanChunks.java new file mode 100644 index 00000000..459979d6 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/ScanChunks.java @@ -0,0 +1,154 @@ +package com.ebremer.beakgraph.hdf5.jena; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.core.NodeTable; +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.readers.IndexReader; +import com.ebremer.beakgraph.hdf5.readers.PositionalDictionaryReader; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.function.Supplier; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.Triple; +import org.apache.jena.sparql.core.Quad; +import org.apache.jena.sparql.core.Var; +import org.apache.jena.sparql.engine.ExecutionContext; +import org.apache.jena.sparql.expr.ExprList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Plans a {@link ParallelScan} for the scan-shaped first pattern of a BGP, or + * declines (null) so the caller keeps the ordinary sequential path. + * + *

Both indexes are positional, so a scan splits into independent chunks by + * position range - each chunk owns complete parent blocks' rows (a subject's + * whole P/O sub-tree lives with its subject position; an object's subject block + * with its object position), so chunking never splits or duplicates a row. + * Eligible shapes, deliberately narrow: + *

    + *
  • {@code ?s ?p ?o} (all unbound, distinct) - GSPO full scan chunked by + * the graph's subject-level positions ({@link BGIteratorSPO_All}).
  • + *
  • {@code ?s

    ?o} (concrete predicate, s/o unbound distinct) - GPOS + * scan chunked by the predicate's object-level positions + * ({@link BGIteratorPOS}).

  • + *
+ * Anything else - union graph (serialized dedup), bound/repeated variables, + * concrete s/o (index lookups, not scans), sub-threshold ranges - stays + * sequential. Range FILTERs remain eligible: every chunk applies the same + * value-bound narrowing internally, and chunks that fall outside the bounds + * simply produce nothing. + * + *

Sizing: parallelize when the chunkable position range is at least + * {@code -Dbeakgraph.scan.parallel.threshold} (default 65536; 0 or negative + * disables), splitting into roughly 2x-processors chunks of at least 16k + * positions each. + */ +final class ScanChunks { + + private static final Logger logger = LoggerFactory.getLogger(ScanChunks.class); + private static final long MIN_CHUNK = 16_384; + + private ScanChunks() {} + + /** A parallel scan for this pattern, or null to use the sequential path. */ + static ParallelScan tryParallel(BeakGraph bg, BindingNodeId b0, Triple triple, ExprList filter, ExecutionContext execCxt) { + long threshold = Long.getLong("beakgraph.scan.parallel.threshold", 65_536L); + if (threshold <= 0) return null; + if (!(bg.getReader() instanceof HDF5Reader reader)) return null; + if (!(reader.getDictionary() instanceof PositionalDictionaryReader dict)) return null; + Node ng = bg.getNamedGraph(); + if (ng == null || Quad.isUnionGraph(ng)) return null; // union dedup is a serial point + Node g = Quad.isDefaultGraph(ng) ? Quad.defaultGraphIRI : ng; + + Node s = triple.getSubject(); + Node p = triple.getPredicate(); + Node o = triple.getObject(); + if (!isUnboundVar(s, b0) || !isUnboundVar(o, b0)) return null; + if (s.equals(o)) return null; + + long gi = dict.getGraphs().locate(g); + if (gi < 1) return null; // absent graph: the sequential path answers empty immediately + + NodeTable nodeTable = reader.getNodeTable(); + + if (isUnboundVar(p, b0)) { + if (p.equals(s) || p.equals(o)) return null; + // Full scan: chunk the graph's GSPO subject-level positions. + IndexReader gspo = reader.getIndexReader(Index.GSPO); + if (gspo == null) return null; + long[] range = levelRange(gspo, 'S', gi); + if (range == null || range[1] - range[0] + 1 < threshold) return null; + Quad quad = new Quad(g, s, p, o); + return build("SPO", range, + (lo, hi) -> new BGIteratorSPO_All(dict, gspo, b0, quad, filter, nodeTable, lo, hi), + execCxt); + } + + if (p.isConcrete()) { + // Predicate scan: chunk the (graph, predicate) GPOS object-level positions. + IndexReader gpos = reader.getIndexReader(Index.GPOS); + if (gpos == null) return null; + long pi = dict.getPredicates().locate(p); + if (pi < 1) return null; + long[] pRange = levelRange(gpos, 'P', gi); + if (pRange == null) return null; + long pIndex = gpos.getIDBuffer('P').binarySearch(pRange[0], pRange[1], pi); + if (pIndex < 0) return null; + BitPackedUnSignedLongBuffer bo = gpos.getBitmapBuffer('O'); + long oStart = RangeSelect.blockStart(gpos.getDirectory('O'), bo, pIndex + 1); + if (oStart == -1) return null; + long oEnd = RangeSelect.blockEnd(gpos.getDirectory('O'), bo, pIndex + 1, oStart); + if (oStart > oEnd || oEnd - oStart + 1 < threshold) return null; + Quad quad = new Quad(g, s, p, o); + return build("POS", new long[]{oStart, oEnd}, + (lo, hi) -> new BGIteratorPOS(dict, gpos, b0, quad, filter, nodeTable, lo, hi), + execCxt); + } + + return null; + } + + private static boolean isUnboundVar(Node n, BindingNodeId b0) { + return n.isVariable() && (b0 == null || !b0.containsKey(Var.alloc(n))); + } + + /** + * The graph's block at an index's first level, or null when absent/empty + * (a padding block's first id is 0 - the writer pads empty slots with one + * all-zero dummy row). + */ + private static long[] levelRange(IndexReader ir, char component, long gi) { + BitPackedUnSignedLongBuffer bitmap = ir.getBitmapBuffer(component); + long start = RangeSelect.blockStart(ir.getDirectory(component), bitmap, gi); + if (start == -1) return null; + long end = RangeSelect.blockEnd(ir.getDirectory(component), bitmap, gi, start); + if (start > end) return null; + if (ir.getIDBuffer(component).get(start) == 0) return null; + return new long[]{start, end}; + } + + private interface ChunkFactory { + Iterator create(long lo, long hi); + } + + private static ParallelScan build(String kind, long[] range, ChunkFactory factory, ExecutionContext execCxt) { + long size = range[1] - range[0] + 1; + int procs = Runtime.getRuntime().availableProcessors(); + long chunkSize = Math.max(MIN_CHUNK, size / (2L * procs)); + int chunks = (int) Math.min(4L * procs, (size + chunkSize - 1) / chunkSize); + List>> suppliers = new ArrayList<>(chunks); + long per = (size + chunks - 1) / chunks; + for (int c = 0; c < chunks; c++) { + long lo = range[0] + c * per; + long hi = Math.min(range[1], lo + per - 1); + if (lo > hi) break; + suppliers.add(() -> factory.create(lo, hi)); + } + logger.debug("Parallel {} scan: {} positions in {} chunks", kind, size, suppliers.size()); + return new ParallelScan(suppliers, execCxt.getCancelSignal()); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/SimpleNodeTable.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/SimpleNodeTable.java index e96e6376..021f1336 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/SimpleNodeTable.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/SimpleNodeTable.java @@ -10,40 +10,44 @@ public class SimpleNodeTable implements NodeTable { private static final Logger logger = LoggerFactory.getLogger(SimpleNodeTable.class); - + private final PositionalDictionaryReader dict; - - // Caffeine LRU Caches for extreme high-performance concurrent caching - // Adjust maximumSize based on your typical heap allocation - private final Cache nodeId2nodemap = Caffeine.newBuilder() - .maximumSize(1_000_000) + + /** Entries per direction; override with -Dbeakgraph.nodetable.cache.size. */ + private static final long CACHE_SIZE = Long.getLong("beakgraph.nodetable.cache.size", 1_000_000L); + + // Caffeine LRU Caches for extreme high-performance concurrent caching. + // Keys/values are packed NodeId longs (boxed at the cache boundary only). + private final Cache nodeId2nodemap = Caffeine.newBuilder() + .maximumSize(CACHE_SIZE) .build(); - - private final Cache node2nodeIdmap = Caffeine.newBuilder() - .maximumSize(1_000_000) + + private final Cache node2nodeIdmap = Caffeine.newBuilder() + .maximumSize(CACHE_SIZE) .build(); - + public SimpleNodeTable(PositionalDictionaryReader dict) { this.dict = dict; } - + /** - * Resolves a Node to its NodeId. Predicates and entities (G/S/O URIs and blank - * nodes) occupy SEPARATE id-spaces, so a URI used in both roles - e.g. {@code :p} - * in {@code :p a rdf:Property} (entity) and in {@code :a :p :b} (predicate) - has a - * distinct id in each dictionary. With no position context available here, a - * dual-role URI is resolved predicate-first and returned as a single NodeId. + * Resolves a Node to its packed NodeId. Predicates and entities (G/S/O URIs and + * blank nodes) occupy SEPARATE id-spaces, so a URI used in both roles - e.g. + * {@code :p} in {@code :p a rdf:Property} (entity) and in {@code :a :p :b} + * (predicate) - has a distinct id in each dictionary. With no position context + * available here, a dual-role URI is resolved predicate-first and returned as a + * single id. *

- * That single answer is safe because a NodeId is always consumed position-aware: a - * bound variable is turned back into its Node via {@link #getNodeForNodeId} (keyed - * by the full NodeId, so it returns the correct URI no matter which role's id it - * carries) and then re-located in the dictionary for the position it is used at - - * see {@code HDF5Reader.substitute}. The raw id is never indexed directly into a - * different id-space. + * That single answer is safe because a NodeId is always consumed position-aware: + * a bound variable is turned back into its Node via {@link #getNodeForNodeId} + * (keyed by the full packed id, so it returns the correct URI no matter which + * role's id it carries) and then re-located in the dictionary for the position + * it is used at when the id-spaces differ - see HDF5Reader.substituteIfCrossSpace. + * The raw id is never indexed directly into a different id-space. */ - private NodeId findInDictionaries(Node n) { + private long findInDictionaries(Node n) { if (n == null || n.isVariable()) { - return NodeId.NodeDoesNotExist; + return NodeId.DOES_NOT_EXIST; } long id; @@ -51,88 +55,83 @@ private NodeId findInDictionaries(Node n) { // 1. If it's a Literal, it MUST be in the Object dictionary (Literals dataset) if (n.isLiteral()) { if ((id = dict.getObjects().locate(n)) != -1) { - return new NodeId(id, NodeType.OBJECT); + return NodeId.pack(NodeType.OBJECT, id); } - return NodeId.NodeDoesNotExist; + return NodeId.DOES_NOT_EXIST; } // 2. If it's a URI, it could be a Predicate OR an Entity (G/S/O) if (n.isURI()) { // Check Predicates first (it's a much smaller dictionary, so binary search is faster) if ((id = dict.getPredicates().locate(n)) != -1) { - return new NodeId(id, NodeType.PREDICATE); + return NodeId.pack(NodeType.PREDICATE, id); } // If not a predicate, check the universal Entity dictionary (accessed via getSubjects) if ((id = dict.getSubjects().locate(n)) != -1) { // We default to SUBJECT for entities, but it applies globally to G, S, and O - return new NodeId(id, NodeType.SUBJECT); + return NodeId.pack(NodeType.SUBJECT, id); } - return NodeId.NodeDoesNotExist; + return NodeId.DOES_NOT_EXIST; } // 3. If it's a Blank Node, it MUST be in the Entity dictionary if (n.isBlank()) { if ((id = dict.getSubjects().locate(n)) != -1) { - return new NodeId(id, NodeType.SUBJECT); + return NodeId.pack(NodeType.SUBJECT, id); } } - - return NodeId.NodeDoesNotExist; - } - @Override - public NodeId getNodeIdForNode(Node n) { - NodeId cachedId = node2nodeIdmap.getIfPresent(n); - if (cachedId != null) { - return cachedId; - } - - NodeId nid = findInDictionaries(n); + return NodeId.DOES_NOT_EXIST; + } - if (nid != NodeId.NodeDoesNotExist) { - nodeId2nodemap.put(nid, n); - } - // Cache misses too: the store is immutable, so absence is permanent, and - // an uncached miss re-ran up to two dictionary binary searches on every - // lookup of the same foreign term (VALUES/BIND-heavy queries). The shared + @Override + public long getNodeIdForNode(Node n) { + // Single cache operation (lookup-or-compute) instead of getIfPresent+put. + // Misses are cached too: the store is immutable, so absence is permanent, + // and an uncached miss re-ran up to two dictionary binary searches on every + // lookup of the same foreign term (VALUES/BIND-heavy queries). The // does-not-exist sentinel is deliberately NOT seeded into nodeId2nodemap. - node2nodeIdmap.put(n, nid); // authoritative, deterministic Node -> NodeId mapping - - return nid; + return node2nodeIdmap.get(n, key -> { + long nid = findInDictionaries(key); + if (nid != NodeId.DOES_NOT_EXIST) { + nodeId2nodemap.put(nid, key); + } + return nid; + }); } @Override - public Node getNodeForNodeId(NodeId id) { - if (id == null) throw new IllegalArgumentException("getNodeForNodeId: null NodeId"); - - Node cachedNode = nodeId2nodemap.getIfPresent(id); + public Node getNodeForNodeId(long nodeId) { + if (nodeId == NodeId.NONE) throw new IllegalArgumentException("getNodeForNodeId: NONE"); + + Node cachedNode = nodeId2nodemap.getIfPresent(nodeId); if (cachedNode != null) { return cachedNode; } - + // Because of the monolithic design, SUBJECT and GRAPH both point to the Entity dictionary. // OBJECT points to the hybrid Entity+Literal dictionary wrapper. - Node node = switch (id.getType()) { - case NodeType.SUBJECT, NodeType.GRAPH -> dict.getSubjects().extract(id.getId()); - case NodeType.PREDICATE -> dict.getPredicates().extract(id.getId()); - case NodeType.OBJECT -> dict.getObjects().extract(id.getId()); - default -> throw new IllegalStateException("Unknown NodeType: " + id.getType()); + Node node = switch (NodeId.type(nodeId)) { + case SUBJECT, GRAPH -> dict.getSubjects().extract(NodeId.id(nodeId)); + case PREDICATE -> dict.getPredicates().extract(NodeId.id(nodeId)); + case OBJECT -> dict.getObjects().extract(NodeId.id(nodeId)); + default -> throw new IllegalStateException("Unresolvable NodeId: " + NodeId.toString(nodeId)); }; - + if (node != null) { - nodeId2nodemap.put(id, node); + nodeId2nodemap.put(nodeId, node); // Deliberately NOT seeding node2nodeIdmap here. A dual-role URI has two valid // NodeIds (predicate vs entity id-space); writing the reverse mapping from // whichever role was reconstructed first would make getNodeIdForNode flip // between roles on successive lookups. Leaving the Node -> NodeId mapping // owned solely by getNodeIdForNode keeps it deterministic (predicate-first). - // nodeId2nodemap above is keyed by the full NodeId, so it stays correct for - // both roles. + // nodeId2nodemap above is keyed by the full packed id, so it stays correct + // for both roles. } return node; } - + public void status() { // Caffeine evaluates size concurrently, so we use estimatedSize() logger.debug("nodeId2nodemap size: {}, node2nodeIdmap size: {}", diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/SolverLibBeak.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/SolverLibBeak.java index 5b70b59b..3cf205b2 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/SolverLibBeak.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/SolverLibBeak.java @@ -50,7 +50,7 @@ public static BindingNodeId convert(Binding binding, BeakGraph bGraph) { // Rely on the node table cache for efficency - we will likely be // repeatedly looking up the same node in different bindings. - NodeId id = bGraph.getReader().getNodeTable().getNodeIdForNode(n); + long id = bGraph.getReader().getNodeTable().getNodeIdForNode(n); // Record even a "does not exist" id: HDF5Reader.Read short-circuits a pattern // bound to it to no rows, and BindingBG falls back to the parent term for output. b.put(v, id); diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/SpatialIndexIterator.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/SpatialIndexIterator.java index e01b4b89..0d429466 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/SpatialIndexIterator.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/SpatialIndexIterator.java @@ -76,7 +76,7 @@ public SpatialIndexIterator(Iterator input, BeakGraph bGraph, Var Iter.removeNulls(Iter.map(candidates.iterator(), id -> { BindingNodeId child = new BindingNodeId(parent); // A conflicting pre-existing binding for the target var drops the row. - return child.putCompatible(targetVar, new NodeId(id, NodeType.SUBJECT), nodeTable) ? child : null; + return child.putCompatible(targetVar, NodeId.pack(NodeType.SUBJECT, id), nodeTable) ? child : null; }))); } @@ -128,9 +128,9 @@ private static List collectCandidates(HDF5Reader reader, Envelope env) { Quad pattern = new Quad(Params.SPATIAL, sVar, pred, oVar); BGIteratorPOS it = new BGIteratorPOS(dict, gpos, new BindingNodeId(), pattern, bounds, nodeTable); while (it.hasNext()) { - NodeId sid = it.next().get(sVar); - if (sid != null) { - out.add(sid.getId()); + long sid = it.next().get(sVar); + if (sid != NodeId.NONE) { + out.add(NodeId.id(sid)); } } } diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/readers/FCDReader.java b/src/main/java/com/ebremer/beakgraph/hdf5/readers/FCDReader.java index e25bb3fb..a9b19141 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/readers/FCDReader.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/readers/FCDReader.java @@ -18,6 +18,16 @@ * airlift does not allow sharing across threads - is held per thread. */ public class FCDReader { + /** + * Decoded-block cache capacity (blocks, per FCD section). Front-coding means + * every {@code get(n)} must decode from its block's head - an average of + * blockSize/2 fragment decodes (VByte + copy + possible zstd + string build) + * per lookup - and both binary searches and result materialization revisit + * the same blocks constantly. Caching the decoded block makes those revisits + * an array index. Sized via -Dbeakgraph.fcd.cache.blocks. + */ + private static final long CACHE_BLOCKS = Long.getLong("beakgraph.fcd.cache.blocks", 4096L); + private final RandomAccessBytes buffer; private final RandomAccessBytes offsets; private final BitPackedUnSignedLongBuffer compressed; @@ -25,6 +35,10 @@ public class FCDReader { private final long numEntries; private final long numBlocks; private final ThreadLocal su = ThreadLocal.withInitial(StringUtils::new); + private final com.github.benmanes.caffeine.cache.Cache blockCache = + com.github.benmanes.caffeine.cache.Caffeine.newBuilder() + .maximumSize(CACHE_BLOCKS) + .build(); public FCDReader(Group strings) { ContiguousDataset stringbuffer = (ContiguousDataset) strings.getChild("stringbuffer"); @@ -63,26 +77,40 @@ private Fragment readFragment(long pos, long entryIndex) { public String get(long n) { if (n < 0 || n >= numEntries) throw new IndexOutOfBoundsException(); - long block = n / blockSize; - long pos = offsets.getLong(block * 8L); + return blockCache.get(block, this::decodeBlock)[(int) (n % blockSize)]; + } - // The first string in the block is always at index (block * blockSize). - Fragment frag = readFragment(pos, block * blockSize); - String current = frag.value(); + /** + * Decodes every string of one front-coded block. The running value is built + * in a reused StringBuilder ({@code setLength(prefixLen)} + append) instead + * of the former per-entry {@code substring(0, prefixLen) + suffix}, which + * allocated two intermediate strings per step. + */ + private String[] decodeBlock(long block) { + long firstEntry = block * blockSize; + int entries = (int) Math.min(blockSize, numEntries - firstEntry); + String[] out = new String[entries]; + + long pos = offsets.getLong(block * 8L); + // The first string in the block is always stored in full. + Fragment frag = readFragment(pos, firstEntry); + StringBuilder current = new StringBuilder(frag.value()); + out[0] = frag.value(); pos = frag.nextPos(); - long offsetInBlock = n % blockSize; - for (long i = 1; i <= offsetInBlock; i++) { + for (int i = 1; i < entries; i++) { VByte.DecodeResult pl = VByte.decodeAt(buffer, pos); int prefixLen = (int) pl.value; pos = pl.nextOffset; - // Suffix fragment is at index (block * blockSize + i). - Fragment suffix = readFragment(pos, block * blockSize + i); - current = current.substring(0, prefixLen) + suffix.value(); + // Suffix fragment is at index (firstEntry + i). + Fragment suffix = readFragment(pos, firstEntry + i); + current.setLength(prefixLen); + current.append(suffix.value()); + out[i] = current.toString(); pos = suffix.nextPos(); } - return current; + return out; } // NOTE: an unused locate(String) lived here that binary-searched blocks by diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/readers/HDF5Reader.java b/src/main/java/com/ebremer/beakgraph/hdf5/readers/HDF5Reader.java index 0fb8ab05..68f30fd4 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/readers/HDF5Reader.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/readers/HDF5Reader.java @@ -6,6 +6,7 @@ import com.ebremer.beakgraph.hdf5.jena.BGReader; import com.ebremer.beakgraph.hdf5.jena.BindingNodeId; import com.ebremer.beakgraph.hdf5.jena.NodeId; +import com.ebremer.beakgraph.hdf5.jena.NodeType; import com.ebremer.beakgraph.core.NodeTable; import com.ebremer.beakgraph.hdf5.Index; import com.ebremer.beakgraph.hdf5.jena.SimpleNodeTable; @@ -15,14 +16,14 @@ import io.jhdf.api.Group; import java.io.File; import java.net.URI; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.apache.jena.atlas.iterator.Iter; import org.apache.jena.graph.Node; @@ -57,19 +58,65 @@ public class HDF5Reader implements BGReader { public HDF5Reader(Path src) { this(src.toFile()); } - + public HDF5Reader(File src) { - this.hdf = new HdfFile(src.toPath()); + this(new HdfFile(src.toPath()), src.toURI()); + } + + /** + * Reads a BeakGraph through any {@link SeekableByteChannel} - e.g. an + * {@code HTTPSeekableByteChannel} for querying a remote file in place. + * The reader takes ownership of the channel: it is closed by + * {@link #close()}, and also released if construction fails. + * + * @param channel positioned at the start of the HDF5 file + * @param source identifies the data for {@link #getURI()} and messages + */ + public HDF5Reader(SeekableByteChannel channel, URI source) { + this(open(channel, source), source); + } + + private static HdfFile open(SeekableByteChannel channel, URI source) { + try { + return new HdfFile(channel, jhdfDisplayUri(source)); + } catch (RuntimeException | Error e) { + // jHDF leaves the channel open when construction fails (e.g. not an + // HDF5 file); the reader owns the channel, so release it here. + try { channel.close(); } catch (Exception ignore) {} + throw e; + } + } + + /** + * jHDF derives a display {@link Path} from the URI's path component; + * substitute a placeholder for URIs it cannot hold (opaque URNs, path + * characters illegal in local paths) so such sources still open. + */ + private static URI jhdfDisplayUri(URI source) { + try { + String path = source.getPath(); + if (path != null) { + Path.of(path); + return source; + } + } catch (InvalidPathException cannotDisplay) { + // fall through to the placeholder + } + return URI.create("bg:/channel"); + } + + private HDF5Reader(HdfFile hdf, URI uri) { + this.hdf = hdf; try { this.hdt = (Group) hdf.getChild(Params.BG); if (hdt == null) { throw new IllegalStateException( - "Not a BeakGraph file (no '" + Params.BG + "' group): " + src); + "Not a BeakGraph file (no '" + Params.BG + "' group): " + uri); } this.formatVersion = readFormatVersion(hdt); if (formatVersion > Params.FORMAT_VERSION) { throw new IllegalStateException( - "BeakGraph HDF5 format version " + formatVersion + " in " + src + "BeakGraph HDF5 format version " + formatVersion + " in " + uri + " is newer than this build supports (max " + Params.FORMAT_VERSION + "). Upgrade BeakGraph."); } @@ -77,10 +124,11 @@ public HDF5Reader(File src) { this.dict = new PositionalDictionaryReader(dictionary); this.defaultGraph = Quad.defaultGraphIRI; nodeTable = new SimpleNodeTable(dict); - this.uri = src.toURI(); + this.uri = uri; } catch (RuntimeException | Error e) { - // Close the mapped file before propagating: a leaked HdfFile pins the - // file handle (and on Windows, the file lock) with no way to release it. + // Close the backing storage before propagating: a leaked HdfFile pins + // the file handle (and on Windows, the file lock) - or the channel - + // with no way to release it. try { hdf.close(); } catch (Exception ignore) {} throw e; } @@ -138,9 +186,15 @@ public Iterator read(Node ng, BindingNodeId bnid, Triple triple, } boolean isDefault = ng.equals(Quad.defaultGraphNodeGenerated) || ng.equals(Quad.defaultGraphIRI); Node g = isDefault ? this.defaultGraph : ng; - Node s = substitute(triple.getSubject(), bnid, nodeTable); - Node p = substitute(triple.getPredicate(), bnid, nodeTable); - Node o = substitute(triple.getObject(), bnid, nodeTable); + // A bound variable's id is used DIRECTLY by the iterators (they consult the + // binding before the dictionary) whenever its id-space matches the position; + // only a cross-space binding (predicate id used in an entity position or + // vice versa) is materialized to its term here so the iterator re-locates it + // in the position's own dictionary. The former unconditional substitution + // paid an extract() + full binary search per bound variable per input row. + Node s = substituteIfCrossSpace(triple.getSubject(), bnid, nodeTable, false); + Node p = substituteIfCrossSpace(triple.getPredicate(), bnid, nodeTable, true); + Node o = substituteIfCrossSpace(triple.getObject(), bnid, nodeTable, false); Quad quadPattern = new Quad(g, s, p, o); return new BGIteratorMaster(this, dict, bnid, quadPattern, filter, nodeTable); } @@ -153,10 +207,13 @@ public Iterator read(Node ng, BindingNodeId bnid, Triple triple, * construction). */ private Iterator readUnion(BindingNodeId bnid, Triple triple, ExprList filter, NodeTable nodeTable) { - List vars = new ArrayList<>(3); + List varList = new ArrayList<>(3); for (Node n : new Node[]{triple.getSubject(), triple.getPredicate(), triple.getObject()}) { - if (n.isVariable()) vars.add(Var.alloc(n)); + if (n.isVariable()) varList.add(Var.alloc(n)); } + Var v0 = varList.size() > 0 ? varList.get(0) : null; + Var v1 = varList.size() > 1 ? varList.get(1) : null; + Var v2 = varList.size() > 2 ? varList.get(2) : null; // Lazy per-graph chaining: constructing every graph's iterator up front // paid each one's index binary searches before the first row came back // (spatial stores hold thousands of tile graphs). @@ -165,46 +222,110 @@ private Iterator readUnion(BindingNodeId bnid, Triple triple, Exp .iterator(); Iterator chain = Iter.flatMap(graphs, gn -> read(gn, bnid, triple, filter, nodeTable)); // The dedup set is inherent to union set-semantics (rows arrive per - // graph, not globally sorted); the key is a record of three primitive - // longs rather than a boxed List, cutting the per-row footprint - // of a large union scan several-fold. - record RowKey(long a, long b, long c) {} - Set seen = new HashSet<>(); + // graph, not globally sorted); an open-addressing set of three raw longs + // keeps a large union scan free of per-row key/box allocations. + LongTripleSet seen = new LongTripleSet(); return Iter.filter(chain, b -> { - long[] k = {Long.MIN_VALUE, Long.MIN_VALUE, Long.MIN_VALUE}; - for (int i = 0; i < vars.size(); i++) { - NodeId id = b.get(vars.get(i)); - if (id != null) k[i] = id.getId(); - } - return seen.add(new RowKey(k[0], k[1], k[2])); + // Packed ids (type bits included) key the dedup: identical variable + // values across graphs carry identical packed ids by construction. + long k0 = (v0 != null) ? b.get(v0) : NodeId.NONE; + long k1 = (v1 != null) ? b.get(v1) : NodeId.NONE; + long k2 = (v2 != null) ? b.get(v2) : NodeId.NONE; + return seen.add(k0, k1, k2); }); } + /** + * Open-addressing hash set of (long, long, long) keys - the union scan's + * dedup structure. Linear probing at ≤ 50% load; grows by doubling. + * Not thread-safe (one per union iterator). + */ + private static final class LongTripleSet { + private long[] a, b, c; + private boolean[] used; + private int size; + + LongTripleSet() { + alloc(1 << 10); + } + + private void alloc(int capacity) { + a = new long[capacity]; + b = new long[capacity]; + c = new long[capacity]; + used = new boolean[capacity]; + size = 0; + } + + /** splitmix64 finalizer - full-avalanche mix. */ + private static long mix(long z) { + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } + + boolean add(long x, long y, long z) { + if ((size << 1) >= used.length) { + grow(); + } + int mask = used.length - 1; + int i = (int) (mix(x ^ mix(y ^ mix(z))) & mask); + while (used[i]) { + if (a[i] == x && b[i] == y && c[i] == z) { + return false; + } + i = (i + 1) & mask; + } + used[i] = true; + a[i] = x; + b[i] = y; + c[i] = z; + size++; + return true; + } + + private void grow() { + long[] oa = a, ob = b, oc = c; + boolean[] ou = used; + alloc(ou.length << 1); + for (int i = 0; i < ou.length; i++) { + if (ou[i]) { + add(oa[i], ob[i], oc[i]); + } + } + } + } + /** True when {@code n} is a variable already bound to a node that does not exist here. */ private static boolean boundToMissing(Node n, BindingNodeId bnid) { if (bnid != null && n.isVariable()) { - NodeId id = bnid.get(Var.alloc(n)); - return id != null && NodeId.isDoesNotExist(id); + return NodeId.isDoesNotExist(bnid.get(Var.alloc(n))); } return false; } /** - * Helper to replace Variables in the query pattern with concrete Nodes from the parent binding. + * Replaces a bound variable with its concrete term ONLY when its NodeId lives + * in a different id-space than the position it is used at. GRAPH/SUBJECT ids + * are entity-space and OBJECT ids are entity-space plus offset literals, so + * among those three positions a bound id is directly comparable (a literal id + * in an entity position simply never matches - correct, since a literal cannot + * be a subject or graph). Only the isolated PREDICATE space needs the + * materialize-and-relocate round trip, in either direction. */ - private Node substitute(Node n, BindingNodeId bnid, NodeTable nodeTable) { - if (n.isVariable()) { - Var v = Var.alloc(n); - NodeId id = bnid.get(v); - if (id != null) { - // We found a binding. Resolve the ID to a Node so the Iterator can locate it. - Node concrete = nodeTable.getNodeForNodeId(id); - if (concrete != null) { - return concrete; - } - } + private Node substituteIfCrossSpace(Node n, BindingNodeId bnid, NodeTable nodeTable, boolean predicatePosition) { + if (!n.isVariable()) { + return n; + } + long id = bnid.get(Var.alloc(n)); + if (id == NodeId.NONE || NodeId.isDoesNotExist(id)) { + return n; // unbound (or already short-circuited by boundToMissing) + } + if (NodeId.isPredicateSpace(id) == predicatePosition) { + return n; // same space: the iterator consumes the bound id directly } - return n; + Node concrete = nodeTable.getNodeForNodeId(id); + return (concrete != null) ? concrete : n; } @Override @@ -217,6 +338,10 @@ public ExtendedIterator graphBaseFind(Node graph, Triple tp) { Node s = tp.getSubject().isConcrete() ? tp.getSubject() : sVar; Node p = tp.getPredicate().isConcrete() ? tp.getPredicate() : pVar; Node o = tp.getObject().isConcrete() ? tp.getObject() : oVar; + // Absent-term early exit. The iterators would answer empty anyway, but for a + // union/named-graph fan-out this saves constructing per-graph iterators; the + // "duplicate" locate the iterator then performs is a dictionary search-cache + // hit, not a second binary search. if (s.isConcrete() && dict.getSubjects().locate(s) == -1) { return new NullIterator<>(); } @@ -264,6 +389,13 @@ public Iterator listGraphNodes() { return dict.streamGraphs().iterator(); } + @Override + public long countTriples(Node graph) { + // Quads are de-duplicated per graph in the index, so the graph's quad + // count is its triple count. + return IndexCounts.quads(this, graph); + } + @Override public boolean containsGraph(Node graphNode) { // Graphs share the universal entity dictionary, so locate() alone matches diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/readers/IndexCounts.java b/src/main/java/com/ebremer/beakgraph/hdf5/readers/IndexCounts.java new file mode 100644 index 00000000..47db3e3f --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/readers/IndexCounts.java @@ -0,0 +1,123 @@ +package com.ebremer.beakgraph.hdf5.readers; + +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.utils.HDTBitmapDirectory; +import org.apache.jena.graph.Node; +import org.apache.jena.sparql.core.Quad; + +/** + * Exact per-graph counts computed from index structure alone - no row scan. + * + *

Both indexes group rows per graph at their first level, so a graph's block + * boundaries (a pair of {@code select1} calls) directly yield: + *

    + *
  • {@code distinctSubjects}: the GSPO S-level range under a graph lists each + * of the graph's subjects exactly once (sorted) - its size IS + * {@code COUNT(DISTINCT ?s)}.
  • + *
  • {@code distinctPredicates}: likewise the GPOS P-level range.
  • + *
  • {@code quads}: descending GSPO's three levels to the graph's O-level range + * gives the graph's row count - and index rows are de-duplicated quads, so + * this IS {@code COUNT(*)} over {@code ?s ?p ?o} (and the graph's triple + * count, since triples are unique within a graph).
  • + *
+ * + *

The writer pads each empty first-level slot with a single all-zero dummy row + * so {@code select1(id)} addresses every id directly; a range whose first value is + * 0 is therefore an empty graph, not one row. Every method returns -1 when the + * answer is not directly computable - union graph (cross-graph de-duplication) or + * a missing index - and callers fall back to scanning. + */ +public final class IndexCounts { + + private IndexCounts() {} + + /** Exact quad count of {@code graph}, or -1 when not index-answerable. */ + public static long quads(HDF5Reader reader, Node graph) { + long gi = resolveGraph(reader, graph); + if (gi == UNSUPPORTED) return -1; + IndexReader gspo = reader.getIndexReader(Index.GSPO); + if (gspo == null) return -1; + long[] s = firstLevelRange(gspo, 'S', gi); + if (s == null) return 0; + // Predicate positions spanned by subjects [sStart..sEnd]: blocks are contiguous, + // so the range runs from subject sStart's first predicate to the position just + // before subject (sEnd+1)'s first predicate - and identically P -> O below. + long[] p = childRange(gspo, 'P', s[0], s[1]); + if (p == null) return 0; + long[] o = childRange(gspo, 'O', p[0], p[1]); + if (o == null) return 0; + return o[1] - o[0] + 1; + } + + /** Exact {@code COUNT(DISTINCT ?s)} over {@code { ?s ?p ?o }} in {@code graph}, or -1. */ + public static long distinctSubjects(HDF5Reader reader, Node graph) { + return firstLevelCount(reader, graph, Index.GSPO, 'S'); + } + + /** Exact {@code COUNT(DISTINCT ?p)} over {@code { ?s ?p ?o }} in {@code graph}, or -1. */ + public static long distinctPredicates(HDF5Reader reader, Node graph) { + return firstLevelCount(reader, graph, Index.GPOS, 'P'); + } + + private static long firstLevelCount(HDF5Reader reader, Node graph, Index index, char component) { + long gi = resolveGraph(reader, graph); + if (gi == UNSUPPORTED) return -1; + IndexReader ir = reader.getIndexReader(index); + if (ir == null) return -1; + long[] range = firstLevelRange(ir, component, gi); + return (range == null) ? 0 : (range[1] - range[0] + 1); + } + + private static final long UNSUPPORTED = Long.MIN_VALUE; + + /** + * The graph's id for first-level select1 addressing; 0/-1 for "no such graph" + * (counts 0), {@link #UNSUPPORTED} for graphs these counts cannot answer. + */ + private static long resolveGraph(HDF5Reader reader, Node graph) { + if (graph == null || Quad.isUnionGraph(graph)) { + return UNSUPPORTED; // union needs cross-graph de-duplication + } + Node g = Quad.isDefaultGraph(graph) ? Quad.defaultGraphIRI : graph; + return reader.getDictionary().getGraphs().locate(g); + } + + /** + * The inclusive position range of graph {@code gi}'s block at the index's first + * level, or null when the graph has no rows (absent id or padding-only block). + */ + private static long[] firstLevelRange(IndexReader ir, char component, long gi) { + if (gi < 1) return null; + BitPackedUnSignedLongBuffer bitmap = ir.getBitmapBuffer(component); + BitPackedUnSignedLongBuffer ids = ir.getIDBuffer(component); + long start = select1(ir.getDirectory(component), bitmap, gi); + if (start == -1) return null; + long next = select1(ir.getDirectory(component), bitmap, gi + 1); + long end = (next == -1) ? ids.getNumEntries() - 1 : next - 1; + if (start > end) return null; + if (ids.get(start) == 0) return null; // padding row: the graph is empty + return new long[]{start, end}; + } + + /** + * The inclusive position range at a child level spanned by parent positions + * [{@code parentStart}..{@code parentEnd}]: parent position i's child block + * starts at the (i+1)-th set bit (the addressing every BGIterator uses). + */ + private static long[] childRange(IndexReader ir, char component, long parentStart, long parentEnd) { + BitPackedUnSignedLongBuffer bitmap = ir.getBitmapBuffer(component); + BitPackedUnSignedLongBuffer ids = ir.getIDBuffer(component); + long start = select1(ir.getDirectory(component), bitmap, parentStart + 1); + if (start == -1) return null; + long next = select1(ir.getDirectory(component), bitmap, parentEnd + 2); + long end = (next == -1) ? ids.getNumEntries() - 1 : next - 1; + if (start > end) return null; + return new long[]{start, end}; + } + + private static long select1(HDTBitmapDirectory dir, BitPackedUnSignedLongBuffer fallback, long rank) { + if (rank < 1) return -1; + return (dir != null) ? dir.select1(rank) : fallback.select1(rank); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/readers/MultiTypeDictionaryReader.java b/src/main/java/com/ebremer/beakgraph/hdf5/readers/MultiTypeDictionaryReader.java index e11fab58..81a45f07 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/readers/MultiTypeDictionaryReader.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/readers/MultiTypeDictionaryReader.java @@ -15,11 +15,20 @@ import org.apache.jena.datatypes.TypeMapper; import org.apache.jena.graph.Node; import org.apache.jena.graph.NodeFactory; +import org.apache.jena.sparql.expr.NodeValue; public class MultiTypeDictionaryReader extends AbstractDictionary { private static final DataType[] DT_VALUES = DataType.values(); private static final TypeMapper tm = TypeMapper.getInstance(); private static final int TIER_SPACING = 1024; + /** + * Term -> raw search result (id, or negative insertion point). The store is + * immutable, so both hits and misses are permanent - and query execution + * re-locates the SAME concrete pattern terms once per input binding (each + * incoming row constructs fresh iterators), which made the tiered binary + * search the dominant join cost. Sized via -Dbeakgraph.dict.search.cache.size. + */ + private static final long SEARCH_CACHE_SIZE = Long.getLong("beakgraph.dict.search.cache.size", 65_536L); private final BitPackedUnSignedLongBuffer offsets; private final BitPackedUnSignedLongBuffer integers; private final BitPackedUnSignedLongBuffer longs; @@ -45,6 +54,11 @@ public class MultiTypeDictionaryReader extends AbstractDictionary { // race builds identical content over immutable data. private volatile TieredIndex tiered; + private final com.github.benmanes.caffeine.cache.Cache searchCache = + com.github.benmanes.caffeine.cache.Caffeine.newBuilder() + .maximumSize(SEARCH_CACHE_SIZE) + .build(); + private record TieredIndex(long[] ids, Node[] nodes) {} private static final TieredIndex EMPTY_TIER = new TieredIndex(new long[0], new Node[0]); @@ -158,9 +172,9 @@ public Node extract(long id) { @Override public long search(Node element) { - return this.searchFAST(element); + return searchCache.get(element, this::searchFAST); } - + /** * Tiered binary search: narrows the id range to ~1024 via the tiered index, then * binary-searches with extract() + NodeComparator for a correct total ordering. @@ -169,11 +183,29 @@ private long searchFAST(Node element) { long low = 1; long high = numEntries; + // A literal target's NodeValue is needed at every literal-vs-literal probe; + // memoize it (by identity - `element` is the same object throughout this + // search) instead of re-deriving it ~log(n) times. The comparator's + // ordering semantics are untouched: nodeValue() is the designated hook. + NodeComparator cmp = !element.isLiteral() ? NodeComparator.INSTANCE : new NodeComparator() { + private NodeValue targetValue; + @Override + protected NodeValue nodeValue(Node n) { + if (n != element) { + return super.nodeValue(n); + } + if (targetValue == null) { + targetValue = super.nodeValue(n); + } + return targetValue; + } + }; + // 1. Tiered Index Lookup to narrow the range // This is safe because the tier nodes are actual Node objects compared using your specific Comparator TieredIndex tier = tieredIndex(); if (tier.nodes().length > 0) { - int tierIdx = Arrays.binarySearch(tier.nodes(), element, NodeComparator.INSTANCE); + int tierIdx = Arrays.binarySearch(tier.nodes(), element, cmp); if (tierIdx >= 0) return tier.ids()[tierIdx]; int insertionPoint = -(tierIdx + 1); @@ -192,11 +224,11 @@ private long searchFAST(Node element) { long midId = low + (high - low) / 2; Node midNode = extract(midId); if (midNode == null) throw new IllegalStateException("Dictionary corruption at ID: " + midId); - - int cmp = NodeComparator.INSTANCE.compare(midNode, element); - if (cmp == 0) return midId; - else if (cmp < 0) low = midId + 1; + int c = cmp.compare(midNode, element); + + if (c == 0) return midId; + else if (c < 0) low = midId + 1; else high = midId - 1; } return -low - 1; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java index 2e4380ce..cdaeaab7 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java @@ -42,6 +42,8 @@ public void write() throws IOException { PositionalDictionaryWriterBuilder db = new PositionalDictionaryWriterBuilder(); try (PositionalDictionaryWriter w = db .setSource(builder.getSource()) + .setSources(builder.getSources()) + .setVoidMode(builder.getVoidMode()) .setDestination(builder.getDestination()) .setName(Params.DICTIONARY) .setSpatial(builder.getSpatial()) diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/MultiTypeDictionaryWriter.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/MultiTypeDictionaryWriter.java index 8962e022..fb8b8901 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/MultiTypeDictionaryWriter.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/MultiTypeDictionaryWriter.java @@ -65,22 +65,29 @@ public class MultiTypeDictionaryWriter implements DictionaryWriter, Dictionary, protected MultiTypeDictionaryWriter(Builder builder) throws FileNotFoundException, IOException { this.name = builder.getName(); - logger.info("Building dictionary '{}' ({} nodes)", name, builder.getNodes().size()); + logger.info("Building dictionary '{}' ({} nodes)", name, builder.getNodeCount()); Stats stats = builder.getStats(); this.et = builder.getEnabledTypes(); // --- STEP 1: Strict Total Ordering --- // INFO bracketing: sorting tens of millions of nodes takes minutes with no - // other output - this is the writer's longest silent phase. - logger.info("Sorting {} nodes for dictionary '{}'...", builder.getNodes().size(), name); - long sortStart = System.nanoTime(); - sorted = NodeSorter.parallelSort(builder.getNodes()); - logger.info("Sorted dictionary '{}' in {} s", name, (System.nanoTime() - sortStart) / 1_000_000_000L); + // other output - this is the writer's longest silent phase. A caller that + // already holds the NodeComparator-sorted list (the ultra writer shares one + // sort between this dictionary and its node->id map) passes it via + // setSortedNodes and the sort is skipped entirely. + if (builder.getSortedNodes() != null) { + sorted = builder.getSortedNodes(); + } else { + logger.info("Sorting {} nodes for dictionary '{}'...", builder.getNodes().size(), name); + long sortStart = System.nanoTime(); + sorted = NodeSorter.parallelSort(builder.getNodes()); + logger.info("Sorted dictionary '{}' in {} s", name, (System.nanoTime() - sortStart) / 1_000_000_000L); + } // --- STEP 2: Initialize Buffers --- // BitPackedUnSignedLongBuffer constructors do not throw; assign finals directly. - this.offsets = new BitPackedUnSignedLongBuffer(Path.of("offsets"), null, 0, 1 + MinBits(builder.getNodes().size())); + this.offsets = new BitPackedUnSignedLongBuffer(Path.of("offsets"), null, 0, 1 + MinBits(builder.getNodeCount())); this.nativedatatypes = new BitPackedUnSignedLongBuffer(Path.of("datatypes"), null, 0, 1 + MinBits(DataType.values().length)); // Signed-safe widths: when min is negative, use a fixed width (32 or 64) so the two's-complement // bit pattern survives the unsigned mask round-trip in BitPackedUnSignedLongBuffer. @@ -347,24 +354,33 @@ public void add(WritableGroup group) { public static class Builder { private Set nodes = new HashSet<>(); + private ArrayList sortedNodes; private String name; private Stats stats; private Set et = new HashSet<>(); - private Set typedLiterals = new HashSet<>(); + private Set typedLiterals = new HashSet<>(); public Builder enable(Types... types) { et.addAll(Arrays.asList(types)); return this; } public Builder setStats(Stats stats) { this.stats = stats; return this; } public Builder setNodes(Set nodes) { this.nodes = nodes; return this; } + /** + * Supplies the node list ALREADY in {@code NodeComparator} order, skipping + * the internal sort; takes precedence over {@link #setNodes}. The caller + * owns the ordering contract - a mis-sorted list corrupts every id. + */ + public Builder setSortedNodes(ArrayList sortedNodes) { this.sortedNodes = sortedNodes; return this; } public Builder setDataTypes(Set typedLiterals) { this.typedLiterals = typedLiterals; return this; } public Builder setName(String name) { this.name = name; return this; } public String getName() { return name; } public Set getNodes() { return nodes; } + public ArrayList getSortedNodes() { return sortedNodes; } + public int getNodeCount() { return sortedNodes != null ? sortedNodes.size() : nodes.size(); } public Stats getStats() { return stats; } public Set getEnabledTypes() { return et; } public Set getTypedLiterals() { return typedLiterals; } - + public DictionaryWriter build() throws IOException { - if (nodes.isEmpty()) return new EmptyDictionaryWriter(); + if (getNodeCount() == 0) return new EmptyDictionaryWriter(); return new MultiTypeDictionaryWriter(this); } - } + } } diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java index 48b1986a..4edb3eb8 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java @@ -4,14 +4,13 @@ import com.ebremer.beakgraph.core.fuseki.BGVoIDSD; import com.ebremer.beakgraph.core.lib.Stats; import com.ebremer.beakgraph.utils.ImageTools; +import com.ebremer.beakgraph.utils.RdfSources; import com.ebremer.halcyon.hilbert.HilbertSpace; import com.ebremer.halcyon.hilbert.PolygonScaler; import com.ebremer.halcyon.hilbert.WKTDatatype; import java.io.File; -import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; -import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -22,14 +21,11 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicLong; -import java.util.zip.GZIPInputStream; import org.apache.jena.graph.Node; import org.apache.jena.graph.NodeFactory; import org.apache.jena.graph.Triple; import org.apache.jena.irix.IRIx; import org.apache.jena.rdf.model.Model; -import org.apache.jena.riot.Lang; -import org.apache.jena.riot.RDFLanguages; import org.apache.jena.riot.lang.LabelToNode; import org.apache.jena.riot.system.AsyncParser; import org.apache.jena.riot.system.AsyncParserBuilder; @@ -56,6 +52,10 @@ public class PositionalDictionaryWriterBuilder { private static final Logger logger = LoggerFactory.getLogger(PositionalDictionaryWriterBuilder.class); private File src; private File dest; + /** When non-empty, ALL of these documents are parsed into ONE store (-merge); src is ignored. */ + private final List sources = new ArrayList<>(); + /** Replacement-label counter for AlignBnodes; never resets, so labels stay unique across merged sources. */ + private long bnodeCounter = 0; private final HashSet entities = new HashSet<>(); // URIs & BNodes from G, S, O private final HashSet predicates = new HashSet<>(); // URIs from P private final HashSet literals = new HashSet<>(); // Literals from O @@ -77,8 +77,9 @@ public class PositionalDictionaryWriterBuilder { // Sentinel base: relative references in the source are parsed against this // stable, reserved (.invalid) host that survives IRI normalization, then // stripped back to relative form for storage and resolved at query time - // against the URL the .h5 file is served from. - private static final String REL_BASE = "http://beakgraph.invalid/document"; + // against the URL the .h5 file is served from. Protected: the ultra + // subclass parses documents itself and must use the identical base. + protected static final String REL_BASE = "http://beakgraph.invalid/document"; private static final String REL_BASE_PREFIX = "http://beakgraph.invalid/"; private static final IRIx REL_BASE_IRIX = IRIx.create(REL_BASE); @@ -139,7 +140,16 @@ public class PositionalDictionaryWriterBuilder { public static final int MAX_INDEX_SCALE = 30; - private BGVoIDSD xvoid = new BGVoIDSD("https://ebremer.com/void/"); + // Null when voidMode == NONE (the default): no statistics are collected + // and no urn:x-beakgraph:void graph is written. + private BGVoIDSD xvoid; + private com.ebremer.beakgraph.core.VoidMode voidMode = com.ebremer.beakgraph.core.VoidMode.NONE; + + /** VoID statistics mode (NONE default, EXACT = -void, SKETCH = -voidsketch). */ + public PositionalDictionaryWriterBuilder setVoidMode(com.ebremer.beakgraph.core.VoidMode mode) { + this.voidMode = mode; + return this; + } public File getDestination() { return dest; } public Quad[] getQuads() { return quads; } @@ -157,6 +167,17 @@ public PositionalDictionaryWriterBuilder setSource(File src) { this.src = src; return this; } + /** + * Merge mode: parse every given document into the one store being built. + * Blank nodes stay distinct per document; everything else (dictionaries, + * VoID statistics, indexes) is computed over the union. + */ + public PositionalDictionaryWriterBuilder setSources(List files) { + this.sources.clear(); + this.sources.addAll(files); + return this; + } + public PositionalDictionaryWriterBuilder setSpatial(boolean flag) { this.spatial = flag; return this; } @@ -212,7 +233,10 @@ private List generateGridURNs(Polygon polygon, int resolutionLevel) { return intersectingURNs; } - private ArrayList addSpatial(Quad quad) { + // Protected, not private: thread-safe per-quad augmentation (guarded by the + // spatial/features flags only), reused by the ultra subclass's per-document + // parallel parse. Both callers already invoke it concurrently. + protected ArrayList addSpatial(Quad quad) { final ArrayList qqq = new ArrayList<>(); // The GeoSPARQL-standard " WKT" form must be indexed too: strip the // prefix once here so the parser and the scaler both see plain WKT @@ -357,7 +381,7 @@ private Quad AlignBnodes(Quad quad) { if (g.isBlank()||s.isBlank()||o.isBlank()) { if (g.isBlank()) { if (!bmap.containsKey(g)) { - Node neo = NodeFactory.createBlankNode(String.format("b%020d", bmap.size())); + Node neo = NodeFactory.createBlankNode(String.format("b%020d", bnodeCounter++)); bmap.put(g, neo); g = neo; } else { @@ -366,7 +390,7 @@ private Quad AlignBnodes(Quad quad) { } if (s.isBlank()) { if (!bmap.containsKey(s)) { - Node neo = NodeFactory.createBlankNode(String.format("b%020d", bmap.size())); + Node neo = NodeFactory.createBlankNode(String.format("b%020d", bnodeCounter++)); bmap.put(s, neo); s = neo; } else { @@ -375,7 +399,7 @@ private Quad AlignBnodes(Quad quad) { } if (o.isBlank()) { if (!bmap.containsKey(o)) { - Node neo = NodeFactory.createBlankNode(String.format("b%020d", bmap.size())); + Node neo = NodeFactory.createBlankNode(String.format("b%020d", bnodeCounter++)); bmap.put(o, neo); o = neo; } else { @@ -393,7 +417,7 @@ private Quad AlignBnodes(Quad quad) { * {@code <>} becomes "" and a sibling {@code } becomes "x.png". They * are resolved against the serving URL at query time. */ - private Quad relativize(Quad q) { + protected final Quad relativize(Quad q) { Node qg = q.getGraph(); Node qs = q.getSubject(); Node qp = q.getPredicate(); @@ -442,7 +466,7 @@ private Node relativizeNode(Node n) { * and extract() symmetric. (The value-typed storage never preserved the * non-canonical lexical form anyway.) */ - private Quad canonicalizeNumericObject(Quad quad) { + protected final Quad canonicalizeNumericObject(Quad quad) { Node o = quad.getObject(); if (!o.isLiteral()) return quad; String dt = o.getLiteralDatatypeURI(); @@ -479,10 +503,76 @@ private static Object literalValueOrNull(Node o) { } } - private void countStringStored(String lex) { - this.stats.longestStringLength = Math.max(this.stats.longestStringLength, lex.length()); - this.stats.shortestStringLength = Math.min(this.stats.shortestStringLength, lex.length()); - this.stats.numStrings++; + private static void countStringStored(Stats stats, String lex) { + stats.longestStringLength = Math.max(stats.longestStringLength, lex.length()); + stats.shortestStringLength = Math.min(stats.shortestStringLength, lex.length()); + stats.numStrings++; + } + + /** + * Counts one DISTINCT literal into {@code stats}, choosing the same storage + * class (long/int/float/double/strings, with the ill-typed and dateTime + * special cases) that {@link MultiTypeDictionaryWriter} will pick when it + * encodes the node. Extracted from {@link #ProcessQuad} so the ultra + * writer's post-dedup, chunk-parallel stats pass counts literals with + * EXACTLY the sequential rules (a drifted copy here would corrupt buffer + * allocation, not just reporting). Must be called once per unique literal. + */ + protected static void countLiteralStats(Node o, Stats stats) { + String dt = o.getLiteralDatatypeURI(); + if (dt.equals(XSD.xlong.getURI())) { + if (literalValueOrNull(o) instanceof Number n) { + stats.maxLong = Math.max(stats.maxLong, n.longValue()); + stats.minLong = Math.min(stats.minLong, n.longValue()); + stats.numLong++; + } else { + countStringStored(stats, o.getLiteralLexicalForm()); // ill-typed: strings path + } + } else if (dt.equals(XSD.xint.getURI())) { + // Only xsd:int (32-bit bounded) is bit-packed here. xsd:integer is + // unbounded, so it is handled by the string fallback below instead; + // bit-packing it would truncate large values and change the datatype + // to xsd:int on read-back. + if (literalValueOrNull(o) instanceof Number n) { + stats.maxInteger = Math.max(stats.maxInteger, n.intValue()); + stats.minInteger = Math.min(stats.minInteger, n.intValue()); + stats.numInteger++; + } else { + countStringStored(stats, o.getLiteralLexicalForm()); // ill-typed: strings path + } + } else if (dt.equals(XSD.xfloat.getURI())) { + if (literalValueOrNull(o) instanceof Number n) { + stats.maxFloat = Math.max(stats.maxFloat, n.floatValue()); + stats.minFloat = Math.min(stats.minFloat, n.floatValue()); + stats.numFloat++; + } else { + countStringStored(stats, o.getLiteralLexicalForm()); // ill-typed: strings path + } + } else if (dt.equals(XSD.xdouble.getURI())) { + if (literalValueOrNull(o) instanceof Number n) { + stats.maxDouble = Math.max(stats.maxDouble, n.doubleValue()); + stats.minDouble = Math.min(stats.minDouble, n.doubleValue()); + stats.numDouble++; + } else { + countStringStored(stats, o.getLiteralLexicalForm()); // ill-typed: strings path + } + } else if (dt.equals(XSD.xstring.getURI()) || dt.equals(GEO.wktLiteral.getURI()) || dt.equals(XSD.xboolean.getURI()) || dt.equals(RDF.langString.getURI())) { + // rdf:langString shares the strings buffer; its language tag is + // stored separately by MultiTypeDictionaryWriter (langs/langTags). + countStringStored(stats, o.getLiteralLexicalForm()); + } else if (dt.equals(XSD.dateTime.getURI())) { + String lex = o.getLiteralLexicalForm(); + int t = lex.indexOf('T'); + countStringStored(stats, (t > 0) ? lex.substring(0, t) : lex); + } else { + // Any other datatype (xsd:integer, xsd:decimal, xsd:date, custom + // datatypes, ...) is stored verbatim in the strings buffer by + // MultiTypeDictionaryWriter, tagged with its datatype IRI. Count it + // toward numStrings so that buffer is always allocated; otherwise the + // writer would have nowhere to put it and would drop the node, + // desynchronising the offset/datatype buffers and corrupting the dictionary. + countStringStored(stats, o.getLiteralLexicalForm()); + } } private void ProcessQuad(Quad quad) { @@ -522,61 +612,8 @@ private void ProcessQuad(Quad quad) { } if (o.isLiteral()) { if (!literals.contains(o)) { - String dt = o.getLiteralDatatypeURI(); - dataTypes.add(dt); - if (dt.equals(XSD.xlong.getURI())) { - if (literalValueOrNull(o) instanceof Number n) { - this.stats.maxLong = Math.max(this.stats.maxLong, n.longValue()); - this.stats.minLong = Math.min(this.stats.minLong, n.longValue()); - this.stats.numLong++; - } else { - countStringStored(o.getLiteralLexicalForm()); // ill-typed: strings path - } - } else if (dt.equals(XSD.xint.getURI())) { - // Only xsd:int (32-bit bounded) is bit-packed here. xsd:integer is - // unbounded, so it is handled by the string fallback below instead; - // bit-packing it would truncate large values and change the datatype - // to xsd:int on read-back. - if (literalValueOrNull(o) instanceof Number n) { - this.stats.maxInteger = Math.max(this.stats.maxInteger, n.intValue()); - this.stats.minInteger = Math.min(this.stats.minInteger, n.intValue()); - this.stats.numInteger++; - } else { - countStringStored(o.getLiteralLexicalForm()); // ill-typed: strings path - } - } else if (dt.equals(XSD.xfloat.getURI())) { - if (literalValueOrNull(o) instanceof Number n) { - this.stats.maxFloat = Math.max(this.stats.maxFloat, n.floatValue()); - this.stats.minFloat = Math.min(this.stats.minFloat, n.floatValue()); - this.stats.numFloat++; - } else { - countStringStored(o.getLiteralLexicalForm()); // ill-typed: strings path - } - } else if (dt.equals(XSD.xdouble.getURI())) { - if (literalValueOrNull(o) instanceof Number n) { - this.stats.maxDouble = Math.max(this.stats.maxDouble, n.doubleValue()); - this.stats.minDouble = Math.min(this.stats.minDouble, n.doubleValue()); - this.stats.numDouble++; - } else { - countStringStored(o.getLiteralLexicalForm()); // ill-typed: strings path - } - } else if (dt.equals(XSD.xstring.getURI()) || dt.equals(GEO.wktLiteral.getURI()) || dt.equals(XSD.xboolean.getURI()) || dt.equals(RDF.langString.getURI())) { - // rdf:langString shares the strings buffer; its language tag is - // stored separately by MultiTypeDictionaryWriter (langs/langTags). - countStringStored(o.getLiteralLexicalForm()); - } else if (dt.equals(XSD.dateTime.getURI())) { - String lex = o.getLiteralLexicalForm(); - int t = lex.indexOf('T'); - countStringStored((t > 0) ? lex.substring(0, t) : lex); - } else { - // Any other datatype (xsd:integer, xsd:decimal, xsd:date, custom - // datatypes, ...) is stored verbatim in the strings buffer by - // MultiTypeDictionaryWriter, tagged with its datatype IRI. Count it - // toward numStrings so that buffer is always allocated; otherwise the - // writer would have nowhere to put it and would drop the node, - // desynchronising the offset/datatype buffers and corrupting the dictionary. - countStringStored(o.getLiteralLexicalForm()); - } + dataTypes.add(o.getLiteralDatatypeURI()); + countLiteralStats(o, stats); literals.add(o); } } else { @@ -594,26 +631,79 @@ private void ProcessQuad(Quad quad) { } public PositionalDictionaryWriter build() throws IOException { + parse(); + return new PositionalDictionaryWriter(this); + } + + /** + * Runs the full ingest pipeline - parse, bnode alignment, numeric + * canonicalization, spatial/feature augmentation, VoID statistics - leaving + * the collected quads, node sets, and stats in this builder. Shared verbatim + * by {@link #build()} and the parallel subclass + * (com.ebremer.beakgraph.hdf5.writers.parallel), which differ only in which + * dictionary writer they construct from the collected state. + */ + protected final void parse() throws IOException { final AtomicLong quadcount = new AtomicLong(); - logger.trace("Creating dictionary..."); - try (InputStream xis = src.toString().endsWith(".gz") - ? new GZIPInputStream(new FileInputStream(src)) - : new FileInputStream(src)) { - // Detect the syntax from the file name (TriG, N-Quads, N-Triples, - // Turtle, ...; .gz handled) instead of hardcoding Turtle: this is a - // quad store, and named graphs can only arrive through a quad-capable - // syntax. - String fname = src.getName(); - if (fname.endsWith(".gz")) { - fname = fname.substring(0, fname.length() - 3); - } - Lang lang = RDFLanguages.filenameToLang(fname, Lang.TURTLE); + logger.trace("Creating dictionary..."); + if (sources.isEmpty() && src == null) { + throw new IllegalStateException("No source set: call setSource() or setSources()"); + } + final List inputs = sources.isEmpty() ? List.of(src) : List.copyOf(sources); + this.xvoid = BGVoIDSD.forMode(voidMode, "https://ebremer.com/void/"); + for (File input : inputs) { + parseSource(input, quadcount); + // Blank-node labels are document-scoped in RDF: two sources may both + // say _:b0 and mean different nodes (the parser keeps labels as + // given). Clearing the alignment map per document keeps them + // distinct, while the never-reset bnodeCounter keeps the replacement + // labels unique across the whole (possibly merged) store. + bmap.clear(); + } + // VoID/SD metadata accumulated over ALL sources (only when requested) + if (xvoid != null) { + Model xxx = xvoid.getModel(); + xxx.setNsPrefix("void", VOID.NS); + xxx.setNsPrefix("sd", SD.getURI()); + xxx.setNsPrefix("xsd", XSD.getURI()); + xxx.setNsPrefix("rdfs", RDFS.getURI()); + xxx.setNsPrefix("geo", "http://www.opengis.net/ont/geosparql#"); + xxx.setNsPrefix("prov", "http://www.w3.org/ns/prov#"); + xxx.setNsPrefix("dct", "http://purl.org/dc/terms/"); + xxx.setNsPrefix("hal", "https://halcyon.is/ns/"); + xxx.setNsPrefix("exif", "http://www.w3.org/2003/12/exif/ns#"); + xxx.listStatements().forEach(s -> { + Triple ff = s.asTriple(); + Quad qqq = canonicalizeNumericObject(Quad.create(BGVOID, ff)); + ProcessQuad(qqq); + quadslist.add(qqq); + }); + } + // Set sum logic for backward compatibility in Stats object + stats.numGraphs = entities.size(); + stats.numSubjects = entities.size(); + stats.numPredicates = predicates.size(); + stats.numObjects = entities.size() + literals.size(); + + this.numQuads = quadcount.get(); + this.quads = quadslist.toArray(Quad[]::new); + quadslist.clear(); + logger.info("Dictionary created. Total quads: {}", this.numQuads); + } + + /** Parses one source document into the shared collected state. */ + private void parseSource(File input, AtomicLong quadcount) throws IOException { + // The syntax comes from the file name (TriG, N-Quads, N-Triples, + // RDF/XML, JSON-LD, Turtle; .gz and .zip handled) instead of + // hardcoding Turtle: this is a quad store, and named graphs can only + // arrive through a quad-capable syntax. + try (RdfSources.OpenedSource opened = RdfSources.open(input)) { // Parse relative references against a stable sentinel base so they // resolve deterministically (not against the process working // directory). The relativize() step below strips the sentinel back // off; the relative form is resolved at query time against the URL // the .h5 file is served from. - AsyncParserBuilder parserBuilder = AsyncParser.of(xis, lang, REL_BASE); + AsyncParserBuilder parserBuilder = AsyncParser.of(opened.stream(), opened.lang(), REL_BASE); parserBuilder.mutateSources(rdfBuilder -> rdfBuilder.labelToNode(LabelToNode.createUseLabelAsGiven())); final List>> spatialTasks = new ArrayList<>(); @@ -639,7 +729,9 @@ public PositionalDictionaryWriter build() throws IOException { // its dictionary accounting (the quad is already in quadslist, so a // skip would only fail later, opaquely, when the index can't locate it). ProcessQuad(quad); - xvoid.add(quad); + if (xvoid != null) { + xvoid.add(quad); + } if (spatial && isGeoLiteral(quad)) { spatialTasks.add(scope.submit(() -> addSpatial(quad))); } @@ -647,7 +739,7 @@ public PositionalDictionaryWriter build() throws IOException { } catch (Exception ex) { // Don't swallow a parse/processing failure - that would leave a silently // truncated dictionary. Abort the write; Error/OOM still propagate. - throw new IOException("Failed while parsing/processing RDF source: " + src, ex); + throw new IOException("Failed while parsing/processing RDF source: " + input, ex); } for (Future> task : spatialTasks) { ArrayList extraQuads; @@ -655,9 +747,9 @@ public PositionalDictionaryWriter build() throws IOException { extraQuads = task.get(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted while collecting spatial results: " + src, ex); + throw new IOException("Interrupted while collecting spatial results: " + input, ex); } catch (ExecutionException ex) { - throw new IOException("Spatial processing failed for " + src, ex.getCause()); + throw new IOException("Spatial processing failed for " + input, ex.getCause()); } extraQuads.forEach(q -> { Quad canon = canonicalizeNumericObject(q); @@ -665,41 +757,14 @@ public PositionalDictionaryWriter build() throws IOException { ProcessQuad(canon); }); } - Model xxx = xvoid.getModel(); - xxx.setNsPrefix("void", VOID.NS); - xxx.setNsPrefix("sd", SD.getURI()); - xxx.setNsPrefix("xsd", XSD.getURI()); - xxx.setNsPrefix("rdfs", RDFS.getURI()); - xxx.setNsPrefix("geo", "http://www.opengis.net/ont/geosparql#"); - xxx.setNsPrefix("prov", "http://www.w3.org/ns/prov#"); - xxx.setNsPrefix("dct", "http://purl.org/dc/terms/"); - xxx.setNsPrefix("hal", "https://halcyon.is/ns/"); - xxx.setNsPrefix("exif", "http://www.w3.org/2003/12/exif/ns#"); - xvoid.getModel().listStatements().forEach(s->{ - Triple ff = s.asTriple(); - Quad qqq = canonicalizeNumericObject(Quad.create(BGVOID, ff)); - ProcessQuad(qqq); - quadslist.add(qqq); - }); - // Set sum logic for backward compatibility in Stats object - stats.numGraphs = entities.size(); - stats.numSubjects = entities.size(); - stats.numPredicates = predicates.size(); - stats.numObjects = entities.size() + literals.size(); - } catch (FileNotFoundException e) { - throw new IOException("Source file not found: " + src, e); + throw new IOException("Source file not found: " + input, e); } catch (IOException e) { - throw new IOException("I/O error while reading RDF source", e); + throw new IOException("I/O error while reading RDF source: " + input, e); } - this.numQuads = quadcount.get(); - this.quads = quadslist.toArray(Quad[]::new); - quadslist.clear(); - logger.info("Dictionary created. Total quads: {}", this.numQuads); - return new PositionalDictionaryWriter(this); } - private boolean isGeoLiteral(Quad quad) { + protected final boolean isGeoLiteral(Quad quad) { Node o = quad.getObject(); return o.isLiteral() && GEO.wktLiteral.getURI().equals(o.getLiteralDatatypeURI()); } diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparator.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparator.java new file mode 100644 index 00000000..6b90f30a --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparator.java @@ -0,0 +1,56 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.core.lib.NodeComparator; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.jena.graph.Node; +import org.apache.jena.sparql.expr.NodeValue; + +/** + * {@link NodeComparator} with a PRIVATE NodeValue memo, replacing Jena's one + * global bounded cache in the literal-comparison path. Two reasons, both + * discovered on a 1.4B-quad PubMed merge: + * + *

    + *
  • Contention: {@code NodeValue.makeNode} funnels every thread of a + * parallel term-run sort through the same Caffeine cache, whose eviction + * lock times out under load ("excessive wait times" warnings) and stalls + * ingestion behind the spill it is backpressured on.
  • + *
  • Work: a sort converts each literal on every comparison - + * O(n log n) makeNode calls; the memo converts each distinct node once + * per cap window.
  • + *
+ * + * Ordering is EXACTLY the parent's: the override only changes where the + * Node-to-NodeValue conversion result comes from. One instance per sorter; + * the map is cleared when it exceeds {@code maxEntries} (a sorter outlives + * many runs, so an uncapped memo would grow with the whole build's term + * population). + */ +final class CachingNodeComparator extends NodeComparator { + + private final int maxEntries; + private final ConcurrentHashMap memo; + + CachingNodeComparator(int maxEntries) { + this.maxEntries = maxEntries; + this.memo = new ConcurrentHashMap<>(1 << 16); + } + + @Override + protected NodeValue nodeValue(Node n) { + NodeValue v = memo.get(n); + if (v != null) { + return v; + } + v = NodeValue.makeNode(n); + if (memo.size() >= maxEntries) { + // Crude but contention-free pressure valve: a full reset costs one + // re-conversion per live node, an eviction policy would cost + // bookkeeping on EVERY hit. Sorted runs re-touch a node many times + // in a short window, so recency hardly matters here. + memo.clear(); + } + memo.put(n, v); + return v; + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java new file mode 100644 index 00000000..48c23015 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java @@ -0,0 +1,179 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.AbstractGraphBuilder; +import com.ebremer.beakgraph.core.BeakGraphWriter; +import com.ebremer.beakgraph.huge.HugeBuildPipeline; +import java.io.File; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The parallel disk-based BeakGraph writer (CLI: {@code -method 4}, threads + * via {@code -cores}) for graphs of billions of quads: the exact + * {@link HugeBuildPipeline} flow and output of {@code -method 1} - RAM stays + * bounded by spill-batch sizes regardless of quad count - but every heavy + * component is swapped for a parallel one: + * + *
    + *
  • spill runs sort and write on background workers while ingestion + * continues (double-buffered);
  • + *
  • the three column sorts, three dictionary encodes, and three id joins + * each run concurrently;
  • + *
  • encoded quads and (row, id) join records sort as bit-packed primitive + * keys - parallel radix sort per run, fixed-width binary spills, no + * objects ({@link PackedQuadSorter}, {@link PackedRowIdSorter});
  • + *
  • term runs group each distinct term's text once per run and compare via + * an order-preserving 8-byte prefix key ({@link UltraSorterProvider});
  • + *
  • GPOS sorts only the DEDUPLICATED quad set teed out of the GSPO + * scan.
  • + *
+ * + * Output is isomorphic to the other writers' (same divergence -method 1 has: + * blank nodes keep parsed labels rather than being relabelled). Requires the + * native HDF5 backend, like -method 1. Bigger spill batches = fewer merge + * levels = less disk churn; size them to your heap + * ({@code setIdSpillBatch(1 << 26)} at 16 GiB+ is reasonable for + * 10B+ quad builds). + * + * @author Erich Bremer + */ +public class HugeUltraHDF5Writer implements BeakGraphWriter { + + private static final Logger logger = LoggerFactory.getLogger(HugeUltraHDF5Writer.class); + + private final Builder builder; + + private HugeUltraHDF5Writer(Builder builder) { + this.builder = builder; + } + + @Override + public void write() throws IOException { + logger.info("Writing BeakGraph (hugeUltra: disk-based, {} cores) to {}", + builder.cores, builder.getDestination()); + Path dest = builder.getDestination().toPath(); + Path tmp = dest.resolveSibling(dest.getFileName() + ".tmp"); + Path workBase = (builder.workDir != null) ? builder.workDir + : (dest.toAbsolutePath().getParent() != null ? dest.toAbsolutePath().getParent() : Path.of(".")); + Path workspace = Files.createTempDirectory(workBase, ".bghugeultra-"); + List inputs = builder.getSources().isEmpty() + ? List.of(builder.getSource()) + : builder.getSources(); + ForkJoinPool pool = new ForkJoinPool(builder.cores); + try { + UltraSorterProvider provider = new UltraSorterProvider( + builder.termSpillBatch, builder.idSpillBatch, builder.mergeFanIn, pool); + try (HugeBuildPipeline pipeline = new HugeBuildPipeline( + inputs, builder.getSpatial(), builder.getFeatures(), builder.getVoidMode(), + workspace, provider, pool)) { + pipeline.run(tmp); + } + try { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException ex) { + try { + Files.deleteIfExists(tmp); + } catch (IOException cleanup) { + logger.warn("Failed to remove temp output {}", tmp, cleanup); + } + throw ex; + } finally { + pool.shutdown(); + deleteRecursively(workspace); + } + logger.info("Write complete: {}", builder.getDestination()); + } + + private static void deleteRecursively(Path dir) { + try { + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path d, IOException exc) throws IOException { + Files.deleteIfExists(d); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + logger.warn("Failed to remove workspace {}", dir, e); + } + } + + public static class Builder extends AbstractGraphBuilder { + + private Path workDir; + private int cores = Math.max(2, Runtime.getRuntime().availableProcessors()); + private int termSpillBatch = 1 << 19; // 524288 term records per run + private int idSpillBatch = 1 << 22; // 4M packed id records per run + private int mergeFanIn = 128; + + /** Workspace for spill runs; needs disk on the order of a few times the source. */ + public Builder setWorkDirectory(Path dir) { + this.workDir = dir; + return this; + } + + /** Worker threads for sorting, spilling, merging, and concurrent stages. */ + public Builder setCores(int cores) { + if (cores < 1) throw new IllegalArgumentException("cores must be >= 1, got " + cores); + this.cores = cores; + return this; + } + + public Builder setTermSpillBatch(int records) { + if (records < 1) throw new IllegalArgumentException("termSpillBatch must be >= 1"); + this.termSpillBatch = records; + return this; + } + + public Builder setIdSpillBatch(int records) { + if (records < 1) throw new IllegalArgumentException("idSpillBatch must be >= 1"); + this.idSpillBatch = records; + return this; + } + + public Builder setMergeFanIn(int fanIn) { + if (fanIn < 2) throw new IllegalArgumentException("mergeFanIn must be >= 2"); + this.mergeFanIn = fanIn; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public String getName() { + return Params.BG; + } + + @Override + public HugeUltraHDF5Writer build() { + return new HugeUltraHDF5Writer(this); + } + } + + public static Builder Builder() { + return new Builder(); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedLongSorter.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedLongSorter.java new file mode 100644 index 00000000..857bd712 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedLongSorter.java @@ -0,0 +1,345 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.hdf5.writers.ultra.ParallelRadixSort; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.PriorityQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The disk-based primitive sorter at the heart of -method 4: records are + * unsigned keys of up to 126 bits (a {@code lo} word plus an optional + * {@code hi} word), so a "record" is one or two array slots - no objects, no + * codec, no comparator. RAM runs radix-sort in parallel + * ({@link ParallelRadixSort}) on a background worker while ingestion + * continues (double-buffered), spill runs are fixed-width big-endian binary, + * intermediate merges of independent run groups execute concurrently, and the + * final k-way merge streams through buffered fixed-width readers. + * + *

Everything the huge pipeline sorts by value - dictionary-encoded quads + * and (row, id) join records - packs losslessly into such keys, which is what + * removes the object churn that dominates the sequential + * {@code ExternalSorter} at billions of records. + */ +final class PackedLongSorter implements AutoCloseable { + + private static final Logger logger = LoggerFactory.getLogger(PackedLongSorter.class); + + /** Allocation-free cursor over sorted keys: call {@link #advance()}, then read the words. */ + interface KeyCursor extends AutoCloseable { + boolean advance() throws IOException; + + long hi(); + + long lo(); + + @Override + void close(); + } + + private final Path workDir; + private final String tag; + private final boolean twoWords; + private final int totalBits; + private final int batch; + private final int fanIn; + private final ForkJoinPool pool; + private final ExecutorService exec; + + private long[] bufLo; + private long[] bufHi; + private int fill = 0; + private long size = 0; + private final List runs = new ArrayList<>(); + private int runCounter = 0; + private Future pendingSpill; + private boolean consumed = false; + + PackedLongSorter(Path workDir, String tag, int totalBits, int batch, int fanIn, + ForkJoinPool pool, ExecutorService exec) { + if (totalBits < 1 || totalBits > 126) { + throw new IllegalArgumentException("Key width must be 1..126 bits, got " + totalBits); + } + this.workDir = workDir; + this.tag = tag; + this.totalBits = totalBits; + this.twoWords = totalBits > 63; + this.batch = batch; + this.fanIn = Math.max(2, fanIn); + this.pool = pool; + this.exec = exec; + this.bufLo = new long[batch]; + this.bufHi = twoWords ? new long[batch] : null; + } + + void add(long hi, long lo) throws IOException { + if (consumed) { + throw new IllegalStateException("Sorter '" + tag + "' already consumed"); + } + bufLo[fill] = lo; + if (twoWords) { + bufHi[fill] = hi; + } + fill++; + size++; + if (fill == batch) { + spillAsync(); + } + } + + long size() { + return size; + } + + /** Hands the full buffer to a background sort+write; ingestion continues on fresh arrays. */ + private void spillAsync() throws IOException { + awaitSpill(); + final long[] l = bufLo; + final long[] h = bufHi; + final int n = fill; + bufLo = new long[batch]; + bufHi = twoWords ? new long[batch] : null; + fill = 0; + final Path run = workDir.resolve(tag + ".prun" + (runCounter++)); + runs.add(run); + pendingSpill = exec.submit(() -> { + writeSortedRun(run, l, h, n); + return null; + }); + } + + private void writeSortedRun(Path run, long[] l, long[] h, int n) { + try { + long[] lo = (n == l.length) ? l : Arrays.copyOf(l, n); + long[] hi = (h == null) ? null : ((n == h.length) ? h : Arrays.copyOf(h, n)); + long[][] s = ParallelRadixSort.sort(lo, hi, totalBits, pool); + try (DataOutputStream out = new DataOutputStream( + new BufferedOutputStream(Files.newOutputStream(run), 1 << 17))) { + long[] sl = s[0], sh = s[1]; + for (int i = 0; i < n; i++) { + if (twoWords) { + out.writeLong(sh[i]); + } + out.writeLong(sl[i]); + } + } + } catch (IOException e) { + throw new UncheckedIOException("Failed to spill run for sorter '" + tag + "'", e); + } + } + + private void awaitSpill() throws IOException { + if (pendingSpill == null) { + return; + } + try { + pendingSpill.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while spilling sorter '" + tag + "'", ex); + } catch (ExecutionException ex) { + Throwable c = ex.getCause(); + if (c instanceof UncheckedIOException uio) throw uio.getCause(); + if (c instanceof RuntimeException re) throw re; + if (c instanceof Error err) throw err; + throw new IOException("Spill failed for sorter '" + tag + "'", c); + } finally { + pendingSpill = null; + } + } + + KeyCursor sorted() throws IOException { + if (consumed) { + throw new IllegalStateException("Sorter '" + tag + "' already consumed"); + } + consumed = true; + awaitSpill(); + if (runs.isEmpty()) { + // All in RAM: one parallel radix sort, no disk at all. + long[] lo = Arrays.copyOf(bufLo, fill); + long[] hi = twoWords ? Arrays.copyOf(bufHi, fill) : null; + bufLo = null; + bufHi = null; + long[][] s = ParallelRadixSort.sort(lo, hi, totalBits, pool); + final long[] sl = s[0], sh = s[1]; + final int n = fill; + return new KeyCursor() { + private int i = -1; + + @Override public boolean advance() { return ++i < n; } + + @Override public long hi() { return sh == null ? 0 : sh[i]; } + + @Override public long lo() { return sl[i]; } + + @Override public void close() {} + }; + } + if (fill > 0) { + final long[] l = bufLo; + final long[] h = bufHi; + final int n = fill; + final Path run = workDir.resolve(tag + ".prun" + (runCounter++)); + runs.add(run); + writeSortedRun(run, l, h, n); + } + bufLo = null; + bufHi = null; + // Multi-level merges: independent groups collapse CONCURRENTLY. + while (runs.size() > fanIn) { + logger.info("Sorter '{}': merging {} runs (fan-in {}, groups in parallel)", tag, runs.size(), fanIn); + List next = new ArrayList<>(); + List> merging = new ArrayList<>(); + for (int i = 0; i < runs.size(); i += fanIn) { + final List group = new ArrayList<>(runs.subList(i, Math.min(runs.size(), i + fanIn))); + if (group.size() == 1) { + next.add(group.get(0)); + continue; + } + final Path merged = workDir.resolve(tag + ".prun" + (runCounter++)); + merging.add(exec.submit(() -> { + try (MergeCursor mc = new MergeCursor(group); + DataOutputStream out = new DataOutputStream( + new BufferedOutputStream(Files.newOutputStream(merged), 1 << 17))) { + while (mc.advance()) { + if (twoWords) { + out.writeLong(mc.hi()); + } + out.writeLong(mc.lo()); + } + } + return merged; + })); + } + for (Future f : merging) { + try { + next.add(f.get()); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while merging sorter '" + tag + "'", ex); + } catch (ExecutionException ex) { + Throwable c = ex.getCause(); + if (c instanceof IOException io) throw io; + if (c instanceof RuntimeException re) throw re; + throw new IOException("Merge failed for sorter '" + tag + "'", c); + } + } + runs.clear(); + runs.addAll(next); + } + List finalRuns = new ArrayList<>(runs); + runs.clear(); + return new MergeCursor(finalRuns); + } + + @Override + public void close() { + bufLo = null; + bufHi = null; + for (Path run : runs) { + try { + Files.deleteIfExists(run); + } catch (IOException e) { + logger.warn("Failed to delete spill run {}", run, e); + } + } + runs.clear(); + } + + private final class RunReader { + final Path path; + final DataInputStream in; + long hi; + long lo; + + RunReader(Path path) throws IOException { + this.path = path; + this.in = new DataInputStream(new BufferedInputStream(Files.newInputStream(path), 1 << 17)); + } + + boolean advance() throws IOException { + try { + if (twoWords) { + hi = in.readLong(); + } + lo = in.readLong(); + return true; + } catch (EOFException eof) { + closeAndDelete(); + return false; + } + } + + void closeAndDelete() { + try { in.close(); } catch (IOException ignored) {} + try { Files.deleteIfExists(path); } catch (IOException ignored) {} + } + } + + private final class MergeCursor implements KeyCursor { + private final PriorityQueue heap; + private final List readers = new ArrayList<>(); + private long hi; + private long lo; + + MergeCursor(List runPaths) throws IOException { + this.heap = new PriorityQueue<>(Math.max(2, runPaths.size()), (a, b) -> { + int c = Long.compareUnsigned(a.hi, b.hi); + return (c != 0) ? c : Long.compareUnsigned(a.lo, b.lo); + }); + try { + for (Path p : runPaths) { + RunReader r = new RunReader(p); + readers.add(r); + if (r.advance()) { + heap.add(r); + } + } + } catch (IOException e) { + close(); + throw e; + } + } + + @Override + public boolean advance() throws IOException { + RunReader r = heap.poll(); + if (r == null) { + return false; + } + hi = r.hi; + lo = r.lo; + if (r.advance()) { + heap.add(r); + } + return true; + } + + @Override public long hi() { return hi; } + + @Override public long lo() { return lo; } + + @Override + public void close() { + for (RunReader r : readers) { + r.closeAndDelete(); + } + heap.clear(); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedQuadSorter.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedQuadSorter.java new file mode 100644 index 00000000..90e24d80 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedQuadSorter.java @@ -0,0 +1,123 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.huge.HugeRecords.IdQuad; +import com.ebremer.beakgraph.huge.RecordSorter; +import static com.ebremer.beakgraph.utils.UTIL.MinBits; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.NoSuchElementException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ForkJoinPool; + +/** + * {@link RecordSorter} for dictionary-encoded quads that never sorts an + * object: each quad's four ids concatenate - in the target index's component + * order, first component highest - into one unsigned key of at most 126 bits. + * Unsigned key order IS the index's comparator order (ids are NodeComparator + * ranks), so a {@link PackedLongSorter} does all the work and quads + * materialize back into {@link IdQuad} records only at the consuming scan. + */ +final class PackedQuadSorter implements RecordSorter { + + private final PackedLongSorter sorter; + private final boolean gspo; + private final int b1, b2, b3; // widths of components 1..3 in sort order (b0 needs no mask) + + PackedQuadSorter(Path workDir, String tag, Index order, + long numEntities, long numPredicates, long numObjects, + int batch, int fanIn, ForkJoinPool pool, ExecutorService exec) { + this.gspo = order == Index.GSPO; + int bG = MinBits(Math.max(1, numEntities)); + int bS = bG; + int bP = MinBits(Math.max(1, numPredicates)); + int bO = MinBits(Math.max(1, numObjects)); + int b0 = bG; + this.b1 = gspo ? bS : bP; + this.b2 = gspo ? bP : bO; + this.b3 = gspo ? bO : bS; + int totalBits = b0 + b1 + b2 + b3; + this.sorter = new PackedLongSorter(workDir, tag, totalBits, batch, fanIn, pool, exec); + } + + @Override + public void add(IdQuad q) throws IOException { + long v0 = q.g(); + long v1 = gspo ? q.s() : q.p(); + long v2 = gspo ? q.p() : q.o(); + long v3 = gspo ? q.o() : q.s(); + // Running 128-bit (shift-left, or-in) append; component widths are far + // below 64, so the shifts are always legal. + long lo = v0; + long hi = 0; + hi = (hi << b1) | (lo >>> (64 - b1)); lo = (lo << b1) | v1; + hi = (hi << b2) | (lo >>> (64 - b2)); lo = (lo << b2) | v2; + hi = (hi << b3) | (lo >>> (64 - b3)); lo = (lo << b3) | v3; + sorter.add(hi, lo); + } + + @Override + public long size() { + return sorter.size(); + } + + @Override + public SortedCursor sorted() throws IOException { + final PackedLongSorter.KeyCursor keys = sorter.sorted(); + return new SortedCursor<>() { + private IdQuad head = fetch(); + + private IdQuad fetch() { + try { + if (!keys.advance()) { + keys.close(); + return null; + } + } catch (IOException e) { + keys.close(); + throw new UncheckedIOException("Sorted quad stream failed", e); + } + long hi = keys.hi(); + long lo = keys.lo(); + long v3 = extract(hi, lo, 0, b3); + long v2 = extract(hi, lo, b3, b2); + long v1 = extract(hi, lo, b2 + b3, b1); + long v0 = extract(hi, lo, b1 + b2 + b3, 63); + return gspo + ? new IdQuad(v0, v1, v2, v3) + : new IdQuad(v0, v3, v1, v2); // (g,p,o,s) back to record order + } + + @Override + public boolean hasNext() { + return head != null; + } + + @Override + public IdQuad next() { + if (head == null) throw new NoSuchElementException(); + IdQuad q = head; + head = fetch(); + return q; + } + + @Override + public void close() { + keys.close(); + } + }; + } + + static long extract(long hi, long lo, int off, int bits) { + long mask = (1L << bits) - 1; + if (off >= 64) return (hi >>> (off - 64)) & mask; + if (off + bits <= 64) return (lo >>> off) & mask; + return ((lo >>> off) | (hi << (64 - off))) & mask; + } + + @Override + public void close() { + sorter.close(); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedRowIdSorter.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedRowIdSorter.java new file mode 100644 index 00000000..9a9970ed --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedRowIdSorter.java @@ -0,0 +1,92 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.huge.HugeRecords.RowId; +import com.ebremer.beakgraph.huge.RecordSorter; +import static com.ebremer.beakgraph.utils.UTIL.MinBits; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.NoSuchElementException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ForkJoinPool; + +/** + * {@link RecordSorter} for the (row, id) join records, packed as + * {@code row:id} unsigned keys (row in the high bits) so key order is row + * order and a {@link PackedLongSorter} does the sorting object-free. + */ +final class PackedRowIdSorter implements RecordSorter { + + private final PackedLongSorter sorter; + private final int idBits; + private final boolean twoWords; + + PackedRowIdSorter(Path workDir, String tag, long maxRow, long maxId, + int batch, int fanIn, ForkJoinPool pool, ExecutorService exec) { + int rowBits = MinBits(Math.max(1, maxRow)); + this.idBits = MinBits(Math.max(1, maxId)); + int totalBits = rowBits + idBits; + this.twoWords = totalBits > 63; + this.sorter = new PackedLongSorter(workDir, tag, totalBits, batch, fanIn, pool, exec); + } + + @Override + public void add(RowId r) throws IOException { + long lo = (r.row() << idBits) | r.id(); + long hi = twoWords ? (r.row() >>> (64 - idBits)) : 0; + sorter.add(hi, lo); + } + + @Override + public long size() { + return sorter.size(); + } + + @Override + public SortedCursor sorted() throws IOException { + final PackedLongSorter.KeyCursor keys = sorter.sorted(); + final long idMask = (1L << idBits) - 1; + return new SortedCursor<>() { + private RowId head = fetch(); + + private RowId fetch() { + try { + if (!keys.advance()) { + keys.close(); + return null; + } + } catch (IOException e) { + keys.close(); + throw new UncheckedIOException("Sorted row-id stream failed", e); + } + long lo = keys.lo(); + long id = lo & idMask; + long row = twoWords ? ((keys.hi() << (64 - idBits)) | (lo >>> idBits)) : (lo >>> idBits); + return new RowId(row, id); + } + + @Override + public boolean hasNext() { + return head != null; + } + + @Override + public RowId next() { + if (head == null) throw new NoSuchElementException(); + RowId r = head; + head = fetch(); + return r; + } + + @Override + public void close() { + keys.close(); + } + }; + } + + @Override + public void close() { + sorter.close(); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/ParallelSpillSorter.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/ParallelSpillSorter.java new file mode 100644 index 00000000..af5679db --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/ParallelSpillSorter.java @@ -0,0 +1,333 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.huge.RecordSorter; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInput; +import java.io.DataInputStream; +import java.io.DataOutput; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.PriorityQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The -method 4 object-record external sorter (used for the term columns, + * whose records carry RDF nodes and therefore cannot be bit-packed). Same + * contract as the sequential {@code ExternalSorter}, plus: + * + *

    + *
  • background spilling - a full buffer is sorted and written by a + * worker while ingestion continues into a fresh buffer;
  • + *
  • concurrent intermediate merges - independent run groups collapse + * in parallel;
  • + *
  • pluggable run format - the term sorter's format groups + * consecutive equal terms as (term, count, rows...), writing each term's + * text once per run instead of once per occurrence (Zipfian repeats make + * this the single biggest I/O reduction of the build).
  • + *
+ */ +final class ParallelSpillSorter implements RecordSorter { + + private static final Logger logger = LoggerFactory.getLogger(ParallelSpillSorter.class); + + /** Serializes a whole SORTED run and streams it back record by record. */ + interface RunFormat { + void writeRun(DataOutput out, Object[] sorted, int n) throws IOException; + + /** A fresh (possibly stateful) per-run decoder. */ + RunStream newStream(); + + /** Reads the next record; throws {@link EOFException} at run end. */ + interface RunStream { + T read(DataInput in) throws IOException; + } + } + + private final Path workDir; + private final String tag; + private final Comparator comparator; + private final RunFormat format; + private final int batch; + private final int fanIn; + private final ExecutorService exec; + + private Object[] buffer; + private int fill = 0; + private long size = 0; + private final List runs = new ArrayList<>(); + private int runCounter = 0; + private Future pendingSpill; + private boolean consumed = false; + + ParallelSpillSorter(Path workDir, String tag, Comparator comparator, + RunFormat format, int batch, int fanIn, ExecutorService exec) { + this.workDir = workDir; + this.tag = tag; + this.comparator = comparator; + this.format = format; + this.batch = batch; + this.fanIn = Math.max(2, fanIn); + this.exec = exec; + this.buffer = new Object[batch]; + } + + @Override + public void add(T record) throws IOException { + if (consumed) { + throw new IllegalStateException("Sorter '" + tag + "' already consumed"); + } + buffer[fill++] = record; + size++; + if (fill == batch) { + spillAsync(); + } + } + + @Override + public long size() { + return size; + } + + private void spillAsync() throws IOException { + awaitSpill(); + final Object[] arr = buffer; + final int n = fill; + buffer = new Object[batch]; + fill = 0; + final Path run = workDir.resolve(tag + ".orun" + (runCounter++)); + runs.add(run); + pendingSpill = exec.submit(() -> { + writeSortedRun(run, arr, n); + return null; + }); + } + + @SuppressWarnings("unchecked") + private void writeSortedRun(Path run, Object[] arr, int n) { + try { + Arrays.parallelSort(arr, 0, n, (Comparator) comparator); + try (DataOutputStream out = new DataOutputStream( + new BufferedOutputStream(Files.newOutputStream(run), 1 << 17))) { + format.writeRun(out, arr, n); + } + } catch (IOException e) { + throw new UncheckedIOException("Failed to spill run for sorter '" + tag + "'", e); + } + } + + private void awaitSpill() throws IOException { + if (pendingSpill == null) { + return; + } + try { + pendingSpill.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while spilling sorter '" + tag + "'", ex); + } catch (ExecutionException ex) { + Throwable c = ex.getCause(); + if (c instanceof UncheckedIOException uio) throw uio.getCause(); + if (c instanceof RuntimeException re) throw re; + if (c instanceof Error err) throw err; + throw new IOException("Spill failed for sorter '" + tag + "'", c); + } finally { + pendingSpill = null; + } + } + + @Override + @SuppressWarnings("unchecked") + public SortedCursor sorted() throws IOException { + if (consumed) { + throw new IllegalStateException("Sorter '" + tag + "' already consumed"); + } + consumed = true; + awaitSpill(); + if (runs.isEmpty()) { + final Object[] arr = buffer; + final int n = fill; + buffer = null; + Arrays.parallelSort(arr, 0, n, (Comparator) comparator); + return new SortedCursor<>() { + private int i = 0; + + @Override public boolean hasNext() { return i < n; } + + @Override public T next() { + if (i >= n) throw new NoSuchElementException(); + return (T) arr[i++]; + } + + @Override public void close() {} + }; + } + if (fill > 0) { + final Path run = workDir.resolve(tag + ".orun" + (runCounter++)); + runs.add(run); + writeSortedRun(run, buffer, fill); + } + buffer = null; + while (runs.size() > fanIn) { + logger.info("Sorter '{}': merging {} runs (fan-in {}, groups in parallel)", tag, runs.size(), fanIn); + List next = new ArrayList<>(); + List> merging = new ArrayList<>(); + for (int i = 0; i < runs.size(); i += fanIn) { + final List group = new ArrayList<>(runs.subList(i, Math.min(runs.size(), i + fanIn))); + if (group.size() == 1) { + next.add(group.get(0)); + continue; + } + final Path merged = workDir.resolve(tag + ".orun" + (runCounter++)); + merging.add(exec.submit(() -> { + // Intermediate merges re-buffer the stream so the grouped + // run format can re-group the (now globally consecutive) + // equal records of the merged run. + try (MergeIterator mi = new MergeIterator(group); + DataOutputStream out = new DataOutputStream( + new BufferedOutputStream(Files.newOutputStream(merged), 1 << 17))) { + Object[] chunk = new Object[batch]; + int k = 0; + while (mi.hasNext()) { + chunk[k++] = mi.next(); + if (k == chunk.length) { + format.writeRun(out, chunk, k); + k = 0; + } + } + if (k > 0) { + format.writeRun(out, chunk, k); + } + } + return merged; + })); + } + for (Future f : merging) { + try { + next.add(f.get()); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while merging sorter '" + tag + "'", ex); + } catch (ExecutionException ex) { + Throwable c = ex.getCause(); + if (c instanceof IOException io) throw io; + if (c instanceof UncheckedIOException uio) throw uio.getCause(); + if (c instanceof RuntimeException re) throw re; + throw new IOException("Merge failed for sorter '" + tag + "'", c); + } + } + runs.clear(); + runs.addAll(next); + } + List finalRuns = new ArrayList<>(runs); + runs.clear(); + return new MergeIterator(finalRuns); + } + + @Override + public void close() { + buffer = null; + for (Path run : runs) { + try { + Files.deleteIfExists(run); + } catch (IOException e) { + logger.warn("Failed to delete spill run {}", run, e); + } + } + runs.clear(); + } + + private final class RunReader { + final Path path; + final DataInputStream in; + final RunFormat.RunStream stream = format.newStream(); + T head; + + RunReader(Path path) throws IOException { + this.path = path; + this.in = new DataInputStream(new BufferedInputStream(Files.newInputStream(path), 1 << 17)); + } + + boolean advance() throws IOException { + try { + head = stream.read(in); + return true; + } catch (EOFException eof) { + head = null; + closeAndDelete(); + return false; + } + } + + void closeAndDelete() { + try { in.close(); } catch (IOException ignored) {} + try { Files.deleteIfExists(path); } catch (IOException ignored) {} + } + } + + private final class MergeIterator implements SortedCursor { + private final PriorityQueue heap; + private final List readers = new ArrayList<>(); + + MergeIterator(List runPaths) throws IOException { + this.heap = new PriorityQueue<>(Math.max(2, runPaths.size()), + (a, b) -> comparator.compare(a.head, b.head)); + try { + for (Path p : runPaths) { + RunReader r = new RunReader(p); + readers.add(r); + if (r.advance()) { + heap.add(r); + } + } + } catch (IOException e) { + close(); + throw e; + } + } + + @Override + public boolean hasNext() { + return !heap.isEmpty(); + } + + @Override + public T next() { + RunReader r = heap.poll(); + if (r == null) { + throw new NoSuchElementException(); + } + T result = r.head; + try { + if (r.advance()) { + heap.add(r); + } + } catch (IOException e) { + close(); + throw new UncheckedIOException("Merge failed for sorter '" + tag + "'", e); + } + return result; + } + + @Override + public void close() { + for (RunReader r : readers) { + r.closeAndDelete(); + } + heap.clear(); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/UltraSorterProvider.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/UltraSorterProvider.java new file mode 100644 index 00000000..0ad03889 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/UltraSorterProvider.java @@ -0,0 +1,176 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.core.lib.NodeComparator; +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.huge.HugeIO; +import com.ebremer.beakgraph.huge.HugeRecords; +import com.ebremer.beakgraph.huge.HugeRecords.RowId; +import com.ebremer.beakgraph.huge.HugeRecords.TermRow; +import com.ebremer.beakgraph.huge.NodeCodec; +import com.ebremer.beakgraph.huge.RecordSorter; +import com.ebremer.beakgraph.huge.SorterProvider; +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.concurrent.ForkJoinPool; +import org.apache.jena.graph.Node; +import org.apache.jena.sparql.core.Quad; + +/** + * The -method 4 sorter suite. Terms sort on a {@link ParallelSpillSorter} + * with two accelerations: an order-preserving 8-byte prefix key that settles + * most comparisons without touching the expensive NodeComparator, and a + * grouped run format that writes each term's text once per run (with a + * varint row list) instead of once per occurrence. Id records (row joins and + * encoded quads) sort on bit-packed primitive sorters with no objects at all. + */ +public final class UltraSorterProvider implements SorterProvider { + + private final int termSpillBatch; + private final int idSpillBatch; + private final int fanIn; + private final ForkJoinPool pool; + + public UltraSorterProvider(int termSpillBatch, int idSpillBatch, int fanIn, ForkJoinPool pool) { + this.termSpillBatch = termSpillBatch; + this.idSpillBatch = idSpillBatch; + this.fanIn = fanIn; + this.pool = pool; + } + + @Override + public RecordSorter termSorter(Path workDir, String tag) { + // A PER-SORTER comparator: literal NodeValue conversions memoize + // locally instead of contending on Jena's global cache (see + // CachingNodeComparator). Cap ~= two spill batches of distinct nodes. + return new ParallelSpillSorter<>(workDir, tag, + fastTermOrder(new CachingNodeComparator(Math.max(1 << 16, termSpillBatch * 2))), + new GroupedTermFormat(), termSpillBatch, fanIn, pool); + } + + @Override + public RecordSorter rowIdSorter(Path workDir, String tag, long maxRow, long maxId) { + return new PackedRowIdSorter(workDir, tag, maxRow, maxId, idSpillBatch, fanIn, pool, pool); + } + + @Override + public RecordSorter quadSorter(Path workDir, String tag, Index order, + long numEntities, long numPredicates, long numObjects) { + return new PackedQuadSorter(workDir, tag, order, numEntities, numPredicates, numObjects, + idSpillBatch, fanIn, pool, pool); + } + + // ------------------------------------------------------------------ + // prefix-key acceleration + // ------------------------------------------------------------------ + + /** + * An 8-byte key with the ONE property that matters: {@code key(a) != key(b)} + * implies unsigned key order equals NodeComparator order; equal keys fall + * back to the full comparator. Byte 7 is the macro rank (default-graph + * sentinels 0, bnode 1, URI 2, literal 3 - NodeComparator's exact macro + * order); bytes 6..0 are the first chars of the label/URI, ASCII-compared + * exactly as {@code String.compareTo} does. Any char ≥ 0xFF becomes a + * terminal 0xFF marker (chars past it are NOT encoded - encoding them + * would disagree with UTF-16 order). Literals encode rank only: their + * order is value-based and cannot be prefixed cheaply. + */ + static long prefixKey(Node n) { + int rank; + String s; + if (n == null || n.equals(Quad.defaultGraphIRI) || n.equals(Quad.defaultGraphNodeGenerated)) { + rank = 0; + s = (n == null ? Quad.defaultGraphIRI : n).getURI(); + } else if (n.isBlank()) { + rank = 1; + s = n.getBlankNodeLabel(); + } else if (n.isURI()) { + rank = 2; + s = n.getURI(); + } else { + rank = 3; + s = null; + } + long key = ((long) rank) << 56; + if (s != null) { + int len = Math.min(7, s.length()); + int shift = 48; + for (int i = 0; i < len; i++, shift -= 8) { + char c = s.charAt(i); + if (c >= 0xFF) { + key |= 0xFFL << shift; + break; + } + key |= ((long) c) << shift; + } + } + return key; + } + + /** + * TERM_ORDER, but with most comparisons settled by the prefix key and the + * remainder (dominated by literal-vs-literal) running through the given + * comparator - a {@link CachingNodeComparator} in production, so parallel + * sorts never contend on Jena's global NodeValue cache. + */ + static Comparator fastTermOrder(NodeComparator full) { + return (a, b) -> { + int c = Long.compareUnsigned(prefixKey(a.term()), prefixKey(b.term())); + return (c != 0) ? c : full.compare(a.term(), b.term()); + }; + } + + /** The prefix-accelerated order on the SHARED NodeComparator (tests). */ + static final Comparator FAST_TERM_ORDER = fastTermOrder(NodeComparator.INSTANCE); + + // ------------------------------------------------------------------ + // grouped term run format + // ------------------------------------------------------------------ + + /** + * Run layout: (term, count, row*count)* - each distinct term's text hits + * the disk once per run. Runs are sorted, so equal terms are consecutive; + * intermediate merges re-buffer and re-group, so repeats collapse further + * at every merge level. + */ + static final class GroupedTermFormat implements ParallelSpillSorter.RunFormat { + + @Override + public void writeRun(DataOutput out, Object[] sorted, int n) throws IOException { + int i = 0; + while (i < n) { + TermRow first = (TermRow) sorted[i]; + int j = i + 1; + while (j < n && ((TermRow) sorted[j]).term().equals(first.term())) { + j++; + } + NodeCodec.writeNode(out, first.term()); + HugeIO.writeVarLong(out, j - i); + for (int k = i; k < j; k++) { + HugeIO.writeVarLong(out, ((TermRow) sorted[k]).row()); + } + i = j; + } + } + + @Override + public RunStream newStream() { + return new RunStream<>() { + private Node term; + private long remaining = 0; + + @Override + public TermRow read(DataInput in) throws IOException { + if (remaining == 0) { + term = NodeCodec.readNode(in); // EOFException here ends the run + remaining = HugeIO.readVarLong(in); + } + remaining--; + return new TermRow(term, HugeIO.readVarLong(in)); + } + }; + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/package-info.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/package-info.java new file mode 100644 index 00000000..3a5962f2 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/package-info.java @@ -0,0 +1,35 @@ +/** + * The hugeUltra writer (CLI: {@code -method 4}): the disk-based + * {@code com.ebremer.beakgraph.huge} build pipeline - bounded RAM at any quad + * count - driven by parallel, primitive sorting machinery for + * multi-billion-quad builds. + * + *

The pipeline itself ({@code HugeBuildPipeline}) is shared with + * {@code -method 1}; this package supplies what gets injected into it: + * + *

    + *
  • {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.PackedLongSorter} - + * external sorter over 1-2-word unsigned keys: parallel radix-sorted RAM + * runs written by background workers (double-buffered so ingestion never + * stalls), fixed-width binary spills, concurrent intermediate merges;
  • + *
  • {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.PackedQuadSorter} / + * {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.PackedRowIdSorter} - + * the id-record sorters bit-packed onto it (no objects, no comparators, + * no codecs in the hot path);
  • + *
  • {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.ParallelSpillSorter} - + * the term-column sorter: background spilling, concurrent merges, an + * order-preserving 8-byte prefix key that avoids most NodeComparator + * calls, and a grouped run format writing each distinct term's text once + * per run;
  • + *
  • {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.HugeUltraHDF5Writer} - + * the public entry point ({@code BeakGraphWriter}); owns the worker pool + * and the atomic tmp-then-move publish.
  • + *
+ * + * The independent pipeline stages (three column sorts, three dictionary + * encodes, three id joins) run concurrently on the same pool, and GPOS sorts + * only the deduplicated quads teed out of the GSPO scan. Output format and + * readers are unchanged; like -method 1 the native HDF5 backend is required + * and output is isomorphic (bnode labels are kept as parsed). + */ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelBGIndex.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelBGIndex.java new file mode 100644 index 00000000..fcaed5ce --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelBGIndex.java @@ -0,0 +1,360 @@ +package com.ebremer.beakgraph.hdf5.writers.parallel; + +import static com.ebremer.beakgraph.Params.BLOCKSIZE; +import static com.ebremer.beakgraph.Params.SUPERBLOCKSIZE; +import static com.ebremer.beakgraph.utils.UTIL.MinBits; +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.hdf5.Index; +import io.jhdf.api.WritableGroup; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.stream.IntStream; +import org.apache.jena.sparql.core.Quad; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Multi-threaded twin of {@link com.ebremer.beakgraph.hdf5.writers.BGIndex}. + * + *

The sequential index sorts the quad array by comparing NODES (an expensive + * NodeComparator call per comparison) and then binary-searches the dictionaries + * again for every level of every quad while scanning. Here the four dictionary + * ids of each quad are resolved exactly once, in parallel, by + * {@link #resolveIds}; the same tuples serve both orderings (GSPO reads them as + * (g,s,p,o), GPOS as (g,p,o,s)), so two instances can be built concurrently on + * clones of one tuple array. Sorting compares longs and the scan loop does no + * lookups at all. + * + *

Sorting by id is exactly the order the sequential writer obtains by + * comparing nodes: each id is the node's 1-based rank under NodeComparator + * within its dictionary, and the object id space keeps entities + * (1..maxEntityId) below literals (maxEntityId+1..), matching NodeComparator's + * BNode < URI < Literal macro-order. Distinct terms always receive + * distinct ids, so id equality is node equality and the duplicate/level-change + * checks below are the sequential writer's checks verbatim - the emitted + * buffers are identical. + */ +public class ParallelBGIndex { + private static final Logger logger = LoggerFactory.getLogger(ParallelBGIndex.class); + + private final BitPackedUnSignedLongBuffer B1, B2, B3; + private final BitPackedUnSignedLongBuffer S1, S2, S3; + private final BitPackedUnSignedLongBuffer SB1, SB2, SB3; + private final BitPackedUnSignedLongBuffer BB1, BB2, BB3; + private final Index type; + /** This ordering's component per level, e.g. GPOS -> ['G','P','O','S']. */ + private final char[] comps; + + /** One quad's four dictionary ids, immutable so both index builds can share it. */ + static final class QuadIds { + final long g, s, p, o; + + QuadIds(long g, long s, long p, long o) { + this.g = g; + this.s = s; + this.p = p; + this.o = o; + } + } + + /** + * Resolves the dictionary ids of every quad with lookups fanned out across + * {@code pool}. Called once per write; the returned array is handed to the + * GSPO build and a {@code clone()} of it to the GPOS build (each sorts its + * array in place - cloning must happen before either build starts). + */ + static QuadIds[] resolveIds(ParallelPositionalDictionaryWriter w, Quad[] quads, ForkJoinPool pool) throws IOException { + logger.info("Resolving dictionary ids for {} quads...", quads.length); + long start = System.nanoTime(); + final QuadIds[] ids = new QuadIds[quads.length]; + try { + pool.submit(() -> IntStream.range(0, quads.length).parallel().forEach(i -> { + Quad q = quads[i]; + ids[i] = new QuadIds( + w.locateGraph(q.getGraph()), + w.locateSubject(q.getSubject()), + w.locatePredicate(q.getPredicate()), + w.locateObject(q.getObject())); + })).get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while resolving quad ids", ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IOException("Failed to resolve quad ids", cause); + } + logger.info("Resolved ids for {} quads in {} s", quads.length, (System.nanoTime() - start) / 1_000_000_000L); + return ids; + } + + private static class LevelState { + long bitsProcessed = 0; + long onesSoFar = 0; + long onesInCurrentSuperblock = 0; + long onesInCurrentBlock = 0; + long lastSuperblockWritten = 0; + // Start at 0 (not -1) and pair with a seeded BB[0]=0 below, so the first real + // block-boundary write lands at BB[1]. This keeps BB[k] = ones-before-block-k + // (within its superblock) - the layout HDTBitmapDirectory.select1/rank1 assume. + long lastBlockWritten = 0; + } + + ParallelBGIndex(ParallelPositionalDictionaryWriter dictWriter, Index type, QuadIds[] tuples) { + logger.info("Creating index {}", type); + this.type = type; + this.comps = type.name().toCharArray(); + + // Superblock entries store the cumulative count of set bits; the largest such + // value is the bitmap length. Every level writes at most one row per unique quad + // plus one padding row per L0 id, so (tuples.length + maxL0Id) bounds it - the + // same sizing as the sequential BGIndex. + long maxCumulativeOnes = (long) tuples.length + computeMaxL0Id(dictWriter) + 128L; + int sbBits = MinBits(maxCumulativeOnes); + sbBits = (int) (Math.ceil(sbBits / 8.0) * 8); + int bbBits = MinBits(SUPERBLOCKSIZE); + bbBits = (int) (Math.ceil(bbBits / 8.0) * 8); + + String n1 = levelName(comps[1]); + String n2 = levelName(comps[2]); + String n3 = levelName(comps[3]); + + B1 = new BitPackedUnSignedLongBuffer(Path.of("B" + n1), null, 0, 1); + B2 = new BitPackedUnSignedLongBuffer(Path.of("B" + n2), null, 0, 1); + B3 = new BitPackedUnSignedLongBuffer(Path.of("B" + n3), null, 0, 1); + + S1 = new BitPackedUnSignedLongBuffer(Path.of("S" + n1), null, 0, getBitSize(dictWriter, comps[1])); + S2 = new BitPackedUnSignedLongBuffer(Path.of("S" + n2), null, 0, getBitSize(dictWriter, comps[2])); + S3 = new BitPackedUnSignedLongBuffer(Path.of("S" + n3), null, 0, getBitSize(dictWriter, comps[3])); + + SB1 = new BitPackedUnSignedLongBuffer(Path.of("SB" + n1), null, 0, sbBits); + SB2 = new BitPackedUnSignedLongBuffer(Path.of("SB" + n2), null, 0, sbBits); + SB3 = new BitPackedUnSignedLongBuffer(Path.of("SB" + n3), null, 0, sbBits); + + BB1 = new BitPackedUnSignedLongBuffer(Path.of("BB" + n1), null, 0, bbBits); + BB2 = new BitPackedUnSignedLongBuffer(Path.of("BB" + n2), null, 0, bbBits); + BB3 = new BitPackedUnSignedLongBuffer(Path.of("BB" + n3), null, 0, bbBits); + + // Seed the "ones before the first superblock/block" directory entries to 0. + SB1.writeLong(0); SB2.writeLong(0); SB3.writeLong(0); + BB1.writeLong(0); BB2.writeLong(0); BB3.writeLong(0); + + processTuples(dictWriter, tuples); + prepareForReading(); + } + + private static String levelName(char component) { + return switch (component) { + case 'G' -> "g"; + case 'S' -> "s"; + case 'P' -> "p"; + case 'O' -> "o"; + default -> throw new IllegalStateException("Unknown component: " + component); + }; + } + + private static long id(QuadIds t, char component) { + return switch (component) { + case 'G' -> t.g; + case 'S' -> t.s; + case 'P' -> t.p; + case 'O' -> t.o; + default -> throw new IllegalStateException("Unknown component: " + component); + }; + } + + private static long count(ParallelPositionalDictionaryWriter w, char component) { + return switch (component) { + case 'G' -> w.getNumberOfGraphs(); + case 'S' -> w.getNumberOfSubjects(); + case 'P' -> w.getNumberOfPredicates(); + case 'O' -> w.getNumberOfObjects(); + default -> throw new IllegalStateException("Unknown component: " + component); + }; + } + + private static int getBitSize(ParallelPositionalDictionaryWriter w, char component) { + int needed = MinBits(count(w, component) + 1); + if (needed == 0) return 8; + return (int) (Math.ceil(needed / 8.0) * 8); + } + + /** + * Maximum id of this index's first (L0) component. The L0 dimension is padded up to + * this value so that select1(id) addresses each L0 id's slot directly; it therefore + * also bounds the cumulative set-bit count stored in the superblock directory. + */ + private long computeMaxL0Id(ParallelPositionalDictionaryWriter w) { + return count(w, comps[0]); + } + + private Comparator tupleOrder() { + final char c0 = comps[0], c1 = comps[1], c2 = comps[2], c3 = comps[3]; + return (a, b) -> { + int r = Long.compare(id(a, c0), id(b, c0)); + if (r != 0) return r; + r = Long.compare(id(a, c1), id(b, c1)); + if (r != 0) return r; + r = Long.compare(id(a, c2), id(b, c2)); + if (r != 0) return r; + return Long.compare(id(a, c3), id(b, c3)); + }; + } + + private void processTuples(ParallelPositionalDictionaryWriter w, QuadIds[] tuples) { + // INFO bracketing: sorting millions of quads takes minutes with no other output. + // Invoked from a worker of the writer's pool, parallelSort forks into that same + // pool, so the sort obeys the -cores bound. + logger.info("Sorting {} quads for {}...", tuples.length, type.name()); + long sortStart = System.nanoTime(); + Arrays.parallelSort(tuples, tupleOrder()); + logger.info("Sorted {} in {} s", type.name(), (System.nanoTime() - sortStart) / 1_000_000_000L); + + LevelState l1 = new LevelState(), l2 = new LevelState(), l3 = new LevelState(); + boolean first = true; + long last0 = 0, last1 = 0, last2 = 0, last3 = 0; + long count = 0; + long totalQuads = tuples.length; + + // Establish the Maximum ID for Level 0 so we know how far to pad at the end + long maxL0Id = computeMaxL0Id(w); + + long currentL0 = 1; + + for (QuadIds t : tuples) { + if (++count % 1_000_000 == 0) { + logger.info("{} processed {} / {} quads...", type.name(), count, totalQuads); + } + long k0 = id(t, comps[0]); + long k1 = id(t, comps[1]); + long k2 = id(t, comps[2]); + long k3 = id(t, comps[3]); + + // 1. Duplicate Check + if (!first && k0 == last0 && k1 == last1 && k2 == last2 && k3 == last3) { + continue; + } + + boolean changeL0 = first || k0 != last0; + boolean changeL1 = first || changeL0 || k1 != last1; + boolean changeL2 = first || changeL1 || k2 != last2; + + long thisL0 = k0; + + // Pad Missing L0 IDs with Empty Lists --- + // Each skipped L0 ID gets a dummy row at every level so all buffers stay + // in lockstep (B1.len == S1.len, B2.len == S2.len, B3.len == S3.len). + // Dummy ID value is 0; since real dictionary IDs are >=1, searches for real + // predicates/objects inside an empty graph's range always miss. + if (changeL0) { + long skipped = first ? (thisL0 - 1) : (thisL0 - currentL0 - 1); + padEmptyL0(skipped, l1, l2, l3); + currentL0 = thisL0; + } + + // 3. Level 3 + S3.writeLong(k3); + int bit3 = changeL2 ? 1 : 0; + B3.writeInteger(bit3); + advanceLevel(l3, bit3, SB3, BB3); + + // 4. Level 2 + if (changeL2) { + S2.writeLong(k2); + int bit2 = changeL1 ? 1 : 0; + B2.writeInteger(bit2); + advanceLevel(l2, bit2, SB2, BB2); + } + + // 5. Level 1 + if (changeL1) { + S1.writeLong(k1); + int bit1 = changeL0 ? 1 : 0; + B1.writeInteger(bit1); + advanceLevel(l1, bit1, SB1, BB1); + } + + last0 = k0; last1 = k1; last2 = k2; last3 = k3; + first = false; + } + + // Pad remaining IDs up to the Dictionary's maximum limit --- + long skipped = maxL0Id - currentL0; + padEmptyL0(skipped, l1, l2, l3); + + flushAllBuffers(); + } + + /** + * Emit `count` full dummy rows across all three levels. Each dummy row occupies + * one slot in every S/B buffer with value 0 and bit 1, which preserves the + * invariant B_i.length == S_i.length while still advancing the select1 rank at + * L1 (so `Bp.select1(gi)` correctly identifies empty graph gi's slot). + */ + private void padEmptyL0(long count, LevelState l1, LevelState l2, LevelState l3) { + for (long k = 0; k < count; k++) { + S1.writeLong(0); + B1.writeInteger(1); + advanceLevel(l1, 1, SB1, BB1); + + S2.writeLong(0); + B2.writeInteger(1); + advanceLevel(l2, 1, SB2, BB2); + + S3.writeLong(0); + B3.writeInteger(1); + advanceLevel(l3, 1, SB3, BB3); + } + } + + private void advanceLevel(LevelState state, int bitValue, BitPackedUnSignedLongBuffer SB, BitPackedUnSignedLongBuffer BB) { + if (bitValue == 1) { + state.onesSoFar++; + state.onesInCurrentSuperblock++; + state.onesInCurrentBlock++; + } + + state.bitsProcessed++; + + long currentSuperblock = state.bitsProcessed / SUPERBLOCKSIZE; + if (currentSuperblock != state.lastSuperblockWritten) { + SB.writeLong(state.onesSoFar); + state.lastSuperblockWritten = currentSuperblock; + state.onesInCurrentSuperblock = 0; + } + + long currentBlock = state.bitsProcessed / BLOCKSIZE; + if (currentBlock != state.lastBlockWritten) { + BB.writeLong(state.onesInCurrentSuperblock); + state.lastBlockWritten = currentBlock; + state.onesInCurrentBlock = 0; + } + } + + private void flushAllBuffers() { + B1.complete(); B2.complete(); B3.complete(); + S1.complete(); S2.complete(); S3.complete(); + SB1.complete(); SB2.complete(); SB3.complete(); + BB1.complete(); BB2.complete(); BB3.complete(); + } + + private void prepareForReading() { + B1.prepareForReading(); B2.prepareForReading(); B3.prepareForReading(); + S1.prepareForReading(); S2.prepareForReading(); S3.prepareForReading(); + SB1.prepareForReading(); SB2.prepareForReading(); SB3.prepareForReading(); + BB1.prepareForReading(); BB2.prepareForReading(); BB3.prepareForReading(); + } + + public void add(WritableGroup hdt) { + WritableGroup index = hdt.putGroup(type.name()); + S1.add(index); S2.add(index); S3.add(index); + B1.add(index); B2.add(index); B3.add(index); + SB1.add(index); SB2.add(index); SB3.add(index); + BB1.add(index); BB2.add(index); BB3.add(index); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java new file mode 100644 index 00000000..b8d8afbc --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java @@ -0,0 +1,165 @@ +package com.ebremer.beakgraph.hdf5.writers.parallel; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.AbstractGraphBuilder; +import com.ebremer.beakgraph.core.BeakGraphWriter; +import com.ebremer.beakgraph.hdf5.Index; +import io.jhdf.HdfFile; +import io.jhdf.WritableHdfFile; +import io.jhdf.api.WritableGroup; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import org.apache.jena.sparql.core.Quad; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Multi-threaded twin of {@link com.ebremer.beakgraph.hdf5.writers.HDF5Writer}: + * same source formats, same output format, same readers - but each store is + * built on a dedicated {@link ForkJoinPool} of {@code cores} threads (CLI: + * {@code -method 2} with {@code -cores}, default {@value #DEFAULT_CORES}). + * The three sub-dictionaries build concurrently, the columnar id lists + * populate concurrently, every quad's dictionary ids are resolved once in a + * parallel pass, and the GSPO/GPOS indexes are then built concurrently from + * that shared id array. Only the final jHDF write of the finished buffers is + * sequential. + * + *

All nested parallelism (parallel sorts, parallel streams) forks into the + * writer's own pool, so a conversion never exceeds its core budget - with + * {@code -threads N} in the CLI, N conversions run concurrently, each capped + * at its own {@code -cores}. + * + * @author Erich Bremer + */ +public class ParallelHDF5Writer implements BeakGraphWriter { + /** Default worker-thread count when the builder (or CLI -cores) does not set one. */ + public static final int DEFAULT_CORES = 4; + + private static final Logger logger = LoggerFactory.getLogger(ParallelHDF5Writer.class); + private final Builder builder; + + private ParallelHDF5Writer(Builder builder) { + this.builder = builder; + } + + @Override + public void write() throws IOException { + logger.info("Writing BeakGraph to {} (parallel, {} cores)", builder.getDestination(), builder.getCores()); + Path dest = builder.getDestination().toPath(); + // Build into a sibling temp file and swap it in only on success, exactly + // like the sequential writer: a failed rebuild must never destroy a + // previous good artifact at dest, and readers never observe a + // half-written file at the published path. + Path tmp = dest.resolveSibling(dest.getFileName() + ".tmp"); + ForkJoinPool pool = new ForkJoinPool(builder.getCores()); + try { + ParallelPositionalDictionaryWriterBuilder db = new ParallelPositionalDictionaryWriterBuilder(); + db.setVoidMode(builder.getVoidMode()); + try (ParallelPositionalDictionaryWriter w = db + .setSource(builder.getSource()) + .setSources(builder.getSources()) + .setDestination(builder.getDestination()) + .setName(Params.DICTIONARY) + .setSpatial(builder.getSpatial()) + .setFeatures(builder.getFeatures()) + .setPool(pool) + .buildParallel()) { + Quad[] allQuads = w.getQuads(); + // Resolve every quad's four dictionary ids once; both index + // orderings consume the same tuples. Clone BEFORE either build + // starts: each index sorts its array in place, and cloning + // concurrently with a sort could capture a torn permutation. + ParallelBGIndex.QuadIds[] ids = ParallelBGIndex.resolveIds(w, allQuads, pool); + ParallelBGIndex.QuadIds[] idsForGpos = ids.clone(); + allQuads = null; + w.releaseQuads(); // index builds are id-only; let the Quad wrappers go + + ForkJoinTask gspoTask = pool.submit(() -> new ParallelBGIndex(w, Index.GSPO, ids)); + ForkJoinTask gposTask = pool.submit(() -> new ParallelBGIndex(w, Index.GPOS, idsForGpos)); + ParallelBGIndex gspo = joinIndex(gspoTask, Index.GSPO); + ParallelBGIndex gpos = joinIndex(gposTask, Index.GPOS); + + logger.info("Creating HDF5 file {}", builder.getDestination()); + try (WritableHdfFile hdfFile = HdfFile.write(tmp)) { + final WritableGroup hdt = hdfFile.putGroup(builder.getName()); + hdt.putAttribute("numQuads", w.getNumberOfQuads()); + hdt.putAttribute("formatVersion", Params.FORMAT_VERSION); + w.add(hdt); + gspo.add(hdt); + gpos.add(hdt); + } + } + try { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException ex) { + // Only the temp file is ever cleaned up; dest is untouched on failure. + try { + Files.deleteIfExists(tmp); + } catch (IOException cleanup) { + logger.warn("Failed to remove temp output {}", tmp, cleanup); + } + throw ex; + } finally { + pool.shutdown(); + } + logger.info("Write complete: {}", builder.getDestination()); + } + + private static ParallelBGIndex joinIndex(ForkJoinTask task, Index which) throws IOException { + try { + return task.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while building index " + which, ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IOException("Failed to build index " + which, cause); + } + } + + public static class Builder extends AbstractGraphBuilder { + private int cores = DEFAULT_CORES; + + public Builder setCores(int cores) { + if (cores < 1) { + throw new IllegalArgumentException("cores must be >= 1, got " + cores); + } + this.cores = cores; + return this; + } + + public int getCores() { + return cores; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public String getName() { + return Params.BG; + } + + @Override + public ParallelHDF5Writer build() { + return new ParallelHDF5Writer(this); + } + } + + public static Builder Builder() { + return new Builder(); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriter.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriter.java new file mode 100644 index 00000000..2f006586 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriter.java @@ -0,0 +1,273 @@ +package com.ebremer.beakgraph.hdf5.writers.parallel; + +import com.ebremer.beakgraph.core.Dictionary; +import com.ebremer.beakgraph.core.DictionaryWriter; +import com.ebremer.beakgraph.core.GSPODictionary; +import com.ebremer.beakgraph.core.lib.NodeComparator; +import com.ebremer.beakgraph.core.lib.Stats; +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.hdf5.Types; +import com.ebremer.beakgraph.hdf5.writers.MultiTypeDictionaryWriter; +import static com.ebremer.beakgraph.utils.UTIL.MinBits; +import io.jhdf.api.WritableGroup; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import java.util.function.ToLongFunction; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.jena.graph.Node; +import org.apache.jena.sparql.core.Quad; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Multi-threaded twin of + * {@link com.ebremer.beakgraph.hdf5.writers.PositionalDictionaryWriter}: the + * same Monolithic Entity Dictionary with columnar ID lists, but the three + * independent sub-dictionaries (entities, predicates, literals) are built + * concurrently, and the three columnar ID lists (graphs, subjects, objects) + * are populated concurrently - with the per-node dictionary lookups fanned out + * across the writer's pool and only the append-only bit-packed writes kept + * sequential. Every buffer, name, width, and ordering matches the sequential + * writer, so given the same parsed quads the resulting HDF5 groups are + * identical. + * + * @author Erich Bremer + */ +public class ParallelPositionalDictionaryWriter implements GSPODictionary, AutoCloseable, DictionaryWriter { + private static final Logger logger = LoggerFactory.getLogger(ParallelPositionalDictionaryWriter.class); + private final DictionaryWriter entitiesdict; + private final DictionaryWriter predicatesdict; + private final DictionaryWriter literalsdict; + private final long numQuads; + private final String name; + private Quad[] quads; + private final long maxEntityId; + + // Columnar ID Storage + private final BitPackedUnSignedLongBuffer graphs; + private final BitPackedUnSignedLongBuffer subjects; + private final BitPackedUnSignedLongBuffer objects; + + public ParallelPositionalDictionaryWriter(ParallelPositionalDictionaryWriterBuilder builder, ForkJoinPool pool) throws IOException { + this.name = builder.getName(); + this.numQuads = builder.getNumberOfQuads(); + this.quads = builder.getQuads(); + + Stats stats = builder.getStats(); + logger.debug("{}", stats); + + // 1-3. The three sub-dictionaries read disjoint node sets and write + // disjoint buffers, so they build concurrently; each internal + // NodeSorter.parallelSort forks into this same pool, keeping the whole + // stage inside the -cores bound. + ForkJoinTask entitiesTask = pool.submit(() -> new MultiTypeDictionaryWriter.Builder() + .setName("entities") + .setNodes(builder.getEntities()) + .setStats(stats) + .enable(Types.IRI, Types.BNODE) + .build()); + ForkJoinTask predicatesTask = pool.submit(() -> new MultiTypeDictionaryWriter.Builder() + .setName("predicates") + .setNodes(builder.getPredicates()) + .setStats(stats) + .enable(Types.IRI) + .build()); + ForkJoinTask literalsTask = pool.submit(() -> new MultiTypeDictionaryWriter.Builder() + .setName("literals") + .setNodes(builder.getLiterals()) + .setDataTypes(builder.getDataTypes()) + .setStats(stats) + .enable(Types.DOUBLE, Types.FLOAT, Types.LONG, Types.INTEGER, Types.STRING) + .build()); + entitiesdict = joinDictionary(entitiesTask, "entities"); + predicatesdict = joinDictionary(predicatesTask, "predicates"); + literalsdict = joinDictionary(literalsTask, "literals"); + + // Cache this for fast offset math in locateObject + this.maxEntityId = entitiesdict.getNumberOfNodes(); + + // 4. Initialize Bit-Packed Buffers for columnar ID lists + // Determine required bit-widths based on the dictionary sizes + int gBits = (int) (Math.ceil(MinBits(getNumberOfGraphs() + 1) / 8.0) * 8); + int sBits = (int) (Math.ceil(MinBits(getNumberOfSubjects() + 1) / 8.0) * 8); + int oBits = (int) (Math.ceil(MinBits(getNumberOfObjects() + 1) / 8.0) * 8); + + this.graphs = new BitPackedUnSignedLongBuffer(Path.of("graphs"), null, 0, gBits); + this.subjects = new BitPackedUnSignedLongBuffer(Path.of("subjects"), null, 0, sBits); + this.objects = new BitPackedUnSignedLongBuffer(Path.of("objects"), null, 0, oBits); + + // 5. Populate ID lists from the unique sets collected by the Builder. + // The three lists are independent (the dictionaries are read-only from + // here on), so they run concurrently. + logger.info("Populating columnar ID lists..."); + ForkJoinTask graphsTask = pool.submit(() -> populate(builder.getUniqueGraphs(), this::locateGraph, graphs)); + ForkJoinTask subjectsTask = pool.submit(() -> populate(builder.getUniqueSubjects(), this::locateSubject, subjects)); + ForkJoinTask objectsTask = pool.submit(() -> populate(builder.getUniqueObjects(), this::locateObject, objects)); + joinColumn(graphsTask, "graphs"); + joinColumn(subjectsTask, "subjects"); + joinColumn(objectsTask, "objects"); + logger.info("Columnar ID lists populated"); + } + + /** + * Sorts one unique-node set, resolves each node's id in parallel, then + * appends the ids in order (the bit-packed buffer is append-only, so the + * write itself must stay sequential) and finalizes the buffer for reading. + */ + private static void populate(Set nodes, ToLongFunction locator, BitPackedUnSignedLongBuffer target) { + Node[] sorted = nodes.toArray(Node[]::new); + Arrays.parallelSort(sorted, NodeComparator.INSTANCE); + long[] ids = new long[sorted.length]; + IntStream.range(0, sorted.length).parallel().forEach(i -> ids[i] = locator.applyAsLong(sorted[i])); + for (long id : ids) { + target.writeLong(id); + } + target.prepareForReading(); + } + + private static DictionaryWriter joinDictionary(ForkJoinTask task, String which) throws IOException { + try { + return task.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while building '" + which + "' dictionary", ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof IOException io) throw io; + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IOException("Failed to build '" + which + "' dictionary", cause); + } + } + + private static void joinColumn(ForkJoinTask task, String which) throws IOException { + try { + task.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while populating '" + which + "' id list", ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IOException("Failed to populate '" + which + "' id list", cause); + } + } + + public Quad[] getQuads() { + return quads; + } + + /** + * Drops this writer's reference to the parsed quad array. Called by + * {@link ParallelHDF5Writer} once the per-quad id tuples have been + * resolved: from that point the index builds work on ids only, and the + * Quad wrappers (the nodes stay alive inside the dictionaries) can be + * reclaimed before the two index sorts allocate. + */ + public void releaseQuads() { + this.quads = null; + } + + public long getNumberOfQuads() { + return numQuads; + } + + public long getNumberOfGraphs() { + return maxEntityId; + } + + public long getNumberOfSubjects() { + return maxEntityId; + } + + public long getNumberOfPredicates() { + return predicatesdict.getNumberOfNodes(); + } + + public long getNumberOfObjects() { + return maxEntityId + literalsdict.getNumberOfNodes(); + } + + @Override + public long locateGraph(Node element) { + long c = ((Dictionary) entitiesdict).locate(element); + if (c > 0) return c; + throw new IllegalStateException("Cannot resolve Graph (not in dictionary): " + element); + } + + @Override + public long locateSubject(Node element) { + long c = ((Dictionary) entitiesdict).locate(element); + if (c > 0) return c; + throw new IllegalStateException("Cannot resolve Subject (not in dictionary): " + element); + } + + @Override + public long locatePredicate(Node element) { + long c = ((Dictionary) predicatesdict).locate(element); + if (c > 0) return c; + throw new IllegalStateException("Cannot resolve Predicate (not in dictionary): " + element); + } + + @Override + public long locateObject(Node element) { + if (element.isLiteral()) { + long c = ((Dictionary) literalsdict).locate(element); + if (c > 0) return c + maxEntityId; // Offset by Entity block size + } else { + long c = ((Dictionary) entitiesdict).locate(element); + if (c > 0) return c; + } + // Consistent with the other locate* methods: during a write every quad's nodes + // are already in the dictionary, so a miss is a build-invariant violation. + throw new IllegalStateException("Cannot resolve Object (not in dictionary): " + element); + } + + @Override + public void add(WritableGroup group) { + WritableGroup dictionary = group.putGroup(name); + + // Add Sub-dictionaries + if (entitiesdict.getNumberOfNodes() > 0) entitiesdict.add(dictionary); + if (predicatesdict.getNumberOfNodes() > 0) predicatesdict.add(dictionary); + if (literalsdict.getNumberOfNodes() > 0) literalsdict.add(dictionary); + + // Add columnar ID lists whenever any quads are stored (see the + // sequential writer for why this gates on the lists, not numQuads). + if (graphs.getNumEntries() > 0) { + graphs.add(dictionary); + subjects.add(dictionary); + objects.add(dictionary); + } + } + + @Override + public void close() { + // Implementation for AutoCloseable if needed + } + + // --- Interface Boilerplate / Unsupported Methods --- + + @Override public Object extractGraph(long id) { throw new UnsupportedOperationException(); } + @Override public Object extractSubject(long id) { throw new UnsupportedOperationException(); } + @Override public Object extractPredicate(long id) { throw new UnsupportedOperationException(); } + @Override public Object extractObject(long id) { throw new UnsupportedOperationException(); } + @Override public long getNumberOfNodes() { throw new UnsupportedOperationException(); } + @Override public List getNodes() { throw new UnsupportedOperationException(); } + @Override public Stream streamSubjects() { throw new UnsupportedOperationException(); } + @Override public Stream streamPredicates() { throw new UnsupportedOperationException(); } + @Override public Stream streamObjects() { throw new UnsupportedOperationException(); } + @Override public Stream streamGraphs() { throw new UnsupportedOperationException(); } + @Override public Dictionary getGraphs() { throw new UnsupportedOperationException(); } + @Override public Dictionary getSubjects() { throw new UnsupportedOperationException(); } + @Override public Dictionary getPredicates() { throw new UnsupportedOperationException(); } + @Override public Dictionary getObjects() { throw new UnsupportedOperationException(); } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriterBuilder.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriterBuilder.java new file mode 100644 index 00000000..868ab944 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriterBuilder.java @@ -0,0 +1,75 @@ +package com.ebremer.beakgraph.hdf5.writers.parallel; + +import com.ebremer.beakgraph.hdf5.writers.PositionalDictionaryWriterBuilder; +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ForkJoinPool; + +/** + * Builder for {@link ParallelPositionalDictionaryWriter}. The whole ingest + * pipeline - parse, bnode alignment, numeric canonicalization, spatial / + * feature augmentation, VoID statistics - is inherited unchanged from the + * sequential {@link PositionalDictionaryWriterBuilder} (it is stream-driven + * and inherently single-pass); only the writer that consumes the collected + * state differs. + */ +public class ParallelPositionalDictionaryWriterBuilder extends PositionalDictionaryWriterBuilder { + private ForkJoinPool pool; + + /** Worker pool every parallel build stage runs in; defaults to the common pool. */ + public ParallelPositionalDictionaryWriterBuilder setPool(ForkJoinPool pool) { + this.pool = pool; + return this; + } + + // Covariant overrides so fluent chains keep this builder's type. + + @Override + public ParallelPositionalDictionaryWriterBuilder setSource(File src) { + super.setSource(src); + return this; + } + + @Override + public ParallelPositionalDictionaryWriterBuilder setSources(List files) { + super.setSources(files); + return this; + } + + @Override + public ParallelPositionalDictionaryWriterBuilder setDestination(File dest) { + super.setDestination(dest); + return this; + } + + @Override + public ParallelPositionalDictionaryWriterBuilder setSpatial(boolean flag) { + super.setSpatial(flag); + return this; + } + + @Override + public ParallelPositionalDictionaryWriterBuilder setFeatures(boolean flag) { + super.setFeatures(flag); + return this; + } + + @Override + public ParallelPositionalDictionaryWriterBuilder setName(String name) { + super.setName(name); + return this; + } + + /** + * Same ingest as {@link #build()}, but the collected state feeds the + * parallel writer. Deliberately NOT an override of build(): + * ParallelPositionalDictionaryWriter is not a PositionalDictionaryWriter + * subtype, because subclassing would run the sequential constructor's + * entire single-threaded build before the parallel one could start. + */ + public ParallelPositionalDictionaryWriter buildParallel() throws IOException { + parse(); + return new ParallelPositionalDictionaryWriter(this, pool != null ? pool : ForkJoinPool.commonPool()); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/package-info.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/package-info.java new file mode 100644 index 00000000..a77f07be --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/package-info.java @@ -0,0 +1,39 @@ +/** + * Multi-threaded construction of BeakGraphs from RDF files. + * + *

The sequential file-based writers ({@code com.ebremer.beakgraph.hdf5.writers}) + * build one store on one thread: three sub-dictionaries one after another, three + * columnar id lists one after another, then the GSPO index, then the GPOS index. + * This package is a drop-in twin of that pipeline that runs the independent + * stages concurrently on a bounded worker pool (CLI: {@code -method 2}, sized + * with {@code -cores}, default 4): + * + *

    + *
  • {@link com.ebremer.beakgraph.hdf5.writers.parallel.ParallelHDF5Writer} - + * the public entry point (a {@code BeakGraphWriter}, drop-in alternative + * to {@code HDF5Writer}); owns the {@code ForkJoinPool} every stage runs + * in, so one conversion never uses more than the requested cores;
  • + *
  • {@link com.ebremer.beakgraph.hdf5.writers.parallel.ParallelPositionalDictionaryWriterBuilder} - + * inherits the whole ingest pipeline (parse, bnode alignment, spatial / + * feature augmentation, VoID statistics) from the sequential builder and + * only swaps which writer consumes the collected state;
  • + *
  • {@link com.ebremer.beakgraph.hdf5.writers.parallel.ParallelPositionalDictionaryWriter} - + * builds the entities / predicates / literals dictionaries concurrently, + * then populates the graphs / subjects / objects id lists concurrently + * (dictionary lookups fan out across the pool; the append-only bit-packed + * writes stay ordered);
  • + *
  • {@link com.ebremer.beakgraph.hdf5.writers.parallel.ParallelBGIndex} - + * resolves every quad's four dictionary ids once, in parallel, shares the + * tuples between both orderings, and builds GSPO and GPOS concurrently, + * sorting by id tuple (provably the same order the sequential writer + * obtains by comparing nodes: ids are NodeComparator ranks).
  • + *
+ * + *

Only the build is parallel; the jHDF write of the finished buffers is the + * sequential writer's, and files are read by the same unmodified readers + * ({@code HDF5Reader}). Given the same parsed quads, the output is + * byte-identical to the sequential writer's; across separate writes only the + * VoID metadata's freshly minted blank-node labels differ, exactly as between + * two sequential runs (see ParallelWriterParityTest). + */ +package com.ebremer.beakgraph.hdf5.writers.parallel; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidHDF5Writer.java new file mode 100644 index 00000000..c27acc29 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidHDF5Writer.java @@ -0,0 +1,169 @@ +package com.ebremer.beakgraph.hdf5.writers.plaid; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.AbstractGraphBuilder; +import com.ebremer.beakgraph.core.BeakGraphWriter; +import com.ebremer.beakgraph.hdf5.writers.hugeUltra.UltraSorterProvider; +import com.ebremer.beakgraph.huge.HugeBuildPipeline; +import java.io.File; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The plaid writer (CLI: {@code -method 5}): everything the hugeUltra + * disk-based writer is - bounded RAM at any quad count, radix-sorted + * bit-packed spill runs, background spilling, concurrent pipeline stages - + * plus PARALLEL MULTI-FILE INGEST. Where methods 1 and 4 parse sources one + * after another on a single thread, plaid parses up to {@code -cores} + * documents concurrently ({@link PlaidIngest}); only the row-assignment sink + * remains serial. For merges of many files (the natural shape of + * billion-quad inputs) this converts the parse phase - the wall-clock + * majority of a method-4 build - from ~2 busy cores to genuinely + * {@code -cores} busy cores. + * + *

Single-source builds work too (the one document parses on one worker, + * i.e. method-4 behaviour). Output is identical to methods 1/4: row numbers + * interleave differently, but rows are internal - all sorted artifacts, and + * therefore the store, are the same. Requires the native HDF5 backend. + * + * @author Erich Bremer + */ +public class PlaidHDF5Writer implements BeakGraphWriter { + + private static final Logger logger = LoggerFactory.getLogger(PlaidHDF5Writer.class); + + private final Builder builder; + + private PlaidHDF5Writer(Builder builder) { + this.builder = builder; + } + + @Override + public void write() throws IOException { + logger.info("Writing BeakGraph (plaid: parallel-ingest disk-based, {} cores) to {}", + builder.cores, builder.getDestination()); + Path dest = builder.getDestination().toPath(); + Path tmp = dest.resolveSibling(dest.getFileName() + ".tmp"); + Path workBase = (builder.workDir != null) ? builder.workDir + : (dest.toAbsolutePath().getParent() != null ? dest.toAbsolutePath().getParent() : Path.of(".")); + Path workspace = Files.createTempDirectory(workBase, ".bgplaid-"); + List inputs = builder.getSources().isEmpty() + ? List.of(builder.getSource()) + : builder.getSources(); + ForkJoinPool pool = new ForkJoinPool(builder.cores); + try { + UltraSorterProvider provider = new UltraSorterProvider( + builder.termSpillBatch, builder.idSpillBatch, builder.mergeFanIn, pool); + try (HugeBuildPipeline pipeline = new HugeBuildPipeline( + inputs, builder.getSpatial(), builder.getFeatures(), builder.getVoidMode(), workspace, + provider, pool, new PlaidIngest(builder.cores))) { + pipeline.run(tmp); + } + try { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException ex) { + try { + Files.deleteIfExists(tmp); + } catch (IOException cleanup) { + logger.warn("Failed to remove temp output {}", tmp, cleanup); + } + throw ex; + } finally { + pool.shutdown(); + deleteRecursively(workspace); + } + logger.info("Write complete: {}", builder.getDestination()); + } + + private static void deleteRecursively(Path dir) { + try { + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path d, IOException exc) throws IOException { + Files.deleteIfExists(d); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + logger.warn("Failed to remove workspace {}", dir, e); + } + } + + public static class Builder extends AbstractGraphBuilder { + + private Path workDir; + private int cores = Math.max(2, Runtime.getRuntime().availableProcessors()); + private int termSpillBatch = 1 << 19; + private int idSpillBatch = 1 << 22; + private int mergeFanIn = 128; + + /** Workspace for spill runs; needs disk on the order of a few times the source. */ + public Builder setWorkDirectory(Path dir) { + this.workDir = dir; + return this; + } + + /** Parse workers AND sort/spill/merge workers (two pools of this size). */ + public Builder setCores(int cores) { + if (cores < 1) throw new IllegalArgumentException("cores must be >= 1, got " + cores); + this.cores = cores; + return this; + } + + public Builder setTermSpillBatch(int records) { + if (records < 1) throw new IllegalArgumentException("termSpillBatch must be >= 1"); + this.termSpillBatch = records; + return this; + } + + public Builder setIdSpillBatch(int records) { + if (records < 1) throw new IllegalArgumentException("idSpillBatch must be >= 1"); + this.idSpillBatch = records; + return this; + } + + public Builder setMergeFanIn(int fanIn) { + if (fanIn < 2) throw new IllegalArgumentException("mergeFanIn must be >= 2"); + this.mergeFanIn = fanIn; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public String getName() { + return Params.BG; + } + + @Override + public PlaidHDF5Writer build() { + return new PlaidHDF5Writer(this); + } + } + + public static Builder Builder() { + return new Builder(); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidIngest.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidIngest.java new file mode 100644 index 00000000..03e087eb --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidIngest.java @@ -0,0 +1,181 @@ +package com.ebremer.beakgraph.hdf5.writers.plaid; + +import com.ebremer.beakgraph.core.fuseki.BGVoIDSD; +import com.ebremer.beakgraph.huge.HugeBuildPipeline; +import com.ebremer.beakgraph.huge.SpatialAugmenter; +import com.ebremer.beakgraph.utils.RdfSources; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.apache.jena.riot.lang.LabelToNode; +import org.apache.jena.riot.system.AsyncParser; +import org.apache.jena.riot.system.AsyncParserBuilder; +import org.apache.jena.sparql.core.Quad; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parallel Pass A for the disk-based pipeline: source documents parse + * CONCURRENTLY, each on its own worker. Everything expensive per quad - gzip + * inflation, tokenization, default-graph rewrite, blank-node scoping, + * relativization, numeric canonicalization, spatial/feature augmentation, and + * the VoID statistics (thread-safe) - happens on the workers; only the + * pipeline's batch sink (row assignment + sorter buffer appends) is serial. + * Transforms are the EXACT shared implementations from + * {@link HugeBuildPipeline} and {@link SpatialAugmenter}, so output is + * identical to the sequential ingest's: rows interleave differently across + * runs, but row numbers are internal - every sorted artifact and therefore + * the finished store is the same. + * + *

Workers run on a DEDICATED fixed pool (not the sort pool): parse tasks + * block - on the sink lock, on spatial futures, on spill backpressure - and + * must never be able to starve the spill/merge workers they wait for. + */ +final class PlaidIngest implements HugeBuildPipeline.ParallelIngest { + + private static final Logger logger = LoggerFactory.getLogger(PlaidIngest.class); + + /** Rows accumulated per worker before one locked commit. */ + private static final int COMMIT_BATCH = 8192; + + private final int parseThreads; + + PlaidIngest(int parseThreads) { + this.parseThreads = Math.max(1, parseThreads); + } + + @Override + public void run(List sources, boolean spatial, boolean features, + BGVoIDSD voidStats, BatchSink sink) throws IOException { + int workers = Math.min(parseThreads, sources.size()); + logger.info("Plaid ingest: parsing {} document(s) on {} parse worker(s)", sources.size(), workers); + ExecutorService parsePool = Executors.newFixedThreadPool(workers); + List> tasks = new ArrayList<>(sources.size()); + try { + for (int i = 0; i < sources.size(); i++) { + final File input = sources.get(i); + // Same per-document blank-node scoping rule as the sequential + // ingest: ordinal prefix only when merging several documents. + final String scope = sources.size() > 1 ? (i + "/") : null; + tasks.add(parsePool.submit(() -> { + parseDocument(input, scope, spatial, features, voidStats, sink); + return null; + })); + } + IOException failure = null; + for (int i = 0; i < tasks.size(); i++) { + try { + tasks.get(i).get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + if (failure == null) failure = new IOException("Interrupted while parsing " + sources.get(i), ex); + } catch (ExecutionException ex) { + Throwable c = ex.getCause(); + if (failure == null) { + failure = (c instanceof IOException io) ? io + : (c instanceof UncheckedIOException uio) ? uio.getCause() + : new IOException("Failed to parse " + sources.get(i), c); + } + } + if (failure != null) { + tasks.forEach(t -> t.cancel(true)); + break; + } + } + if (failure != null) { + throw failure; + } + } finally { + parsePool.shutdownNow(); + } + } + + private void parseDocument(File input, String scope, boolean spatial, boolean features, + BGVoIDSD voidStats, BatchSink sink) throws IOException { + logger.info("Parsing {} (plaid, parallel disk-based build)", input); + final long start = System.nanoTime(); + try (RdfSources.OpenedSource opened = RdfSources.open(input)) { + AsyncParserBuilder parserBuilder = AsyncParser.of(opened.stream(), opened.lang(), + HugeBuildPipeline.REL_BASE); + parserBuilder.mutateSources(rdfBuilder -> + rdfBuilder.labelToNode(LabelToNode.createUseLabelAsGiven())); + SpatialAugmenter augmenter = new SpatialAugmenter(features); + // Batch state: source quads and their count in this batch (derived + // spatial quads ride along but are not counted as source quads). + final ArrayList batch = new ArrayList<>(COMMIT_BATCH); + final long[] sourceInBatch = {0}; + final int maxInFlight = 16; + final ArrayDeque>> inFlight = new ArrayDeque<>(); + try (ExecutorService scopeExec = Executors.newVirtualThreadPerTaskExecutor()) { + parserBuilder.streamQuads() + .map(quad -> quad.isDefaultGraph() + ? new Quad(Quad.defaultGraphIRI, quad.getSubject(), quad.getPredicate(), quad.getObject()) + : quad) + .map(quad -> HugeBuildPipeline.scopeBlankNodes(quad, scope)) + .map(HugeBuildPipeline::relativize) + .map(HugeBuildPipeline::canonicalizeNumericObject) + .forEach(quad -> { + try { + if (voidStats != null) { + voidStats.add(quad); // thread-safe; off the sink lock + } + batch.add(quad); + sourceInBatch[0]++; + if (spatial && SpatialAugmenter.isGeoLiteral(quad)) { + inFlight.add(scopeExec.submit(() -> augmenter.addSpatial(quad))); + while (inFlight.size() >= maxInFlight) { + drainOne(inFlight, batch, input); + } + } + if (batch.size() >= COMMIT_BATCH) { + sink.commit(batch, sourceInBatch[0]); + batch.clear(); + sourceInBatch[0] = 0; + } + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }); + while (!inFlight.isEmpty()) { + drainOne(inFlight, batch, input); + } + } catch (UncheckedIOException ex) { + throw new IOException("Failed while parsing/processing RDF source: " + input, ex.getCause()); + } catch (Exception ex) { + throw new IOException("Failed while parsing/processing RDF source: " + input, ex); + } + if (!batch.isEmpty()) { + sink.commit(batch, sourceInBatch[0]); + } + } catch (java.io.FileNotFoundException e) { + throw new IOException("Source file not found: " + input, e); + } + logger.info("Parsed {} in {} ms", input.getName(), (System.nanoTime() - start) / 1_000_000L); + } + + /** Collects one finished spatial task's derived quads into the current batch. */ + private static void drainOne(ArrayDeque>> inFlight, + ArrayList batch, File input) throws IOException { + Future> task = inFlight.poll(); + if (task == null) return; + ArrayList extraQuads; + try { + extraQuads = task.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while collecting spatial results: " + input, ex); + } catch (ExecutionException ex) { + throw new IOException("Spatial processing failed for " + input, ex.getCause()); + } + for (Quad q : extraQuads) { + batch.add(HugeBuildPipeline.canonicalizeNumericObject(q)); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/package-info.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/package-info.java new file mode 100644 index 00000000..3c669077 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/package-info.java @@ -0,0 +1,26 @@ +/** + * The plaid writer (CLI: {@code -method 5}): the hugeUltra disk-based build + * (bounded RAM, packed-key radix sorting, background spills, concurrent + * stages) with the last big serial phase removed - PARALLEL MULTI-FILE + * INGEST. + * + *

{@link com.ebremer.beakgraph.hdf5.writers.plaid.PlaidIngest} parses up + * to {@code -cores} source documents concurrently on a dedicated worker pool + * (kept separate from the sort pool so blocking parse work can never starve + * spills). Each worker runs the full per-quad transform chain - the shared + * static implementations from {@code HugeBuildPipeline} plus + * {@code SpatialAugmenter} and the (thread-safe) VoID statistics - and hands + * finished rows to the pipeline in batches. The pipeline's batch sink is the + * one serial section: it assigns row numbers and appends to the sorter + * buffers under a lock, which preserves every single-threaded invariant of + * the sequential ingest (most importantly the positional predicate column, + * whose entry i must BE row i). Spill sorting/writing still happens on + * background workers, so the lock covers only buffer appends. + * + *

Rows therefore interleave nondeterministically across runs - and it + * does not matter: row numbers are internal, every artifact the store keeps + * is sorted, and the output is identical to a method-1/4 build of the same + * sources. Best suited to merges of many files; a single-file build degrades + * gracefully to one parse worker (method-4 behaviour). + */ +package com.ebremer.beakgraph.hdf5.writers.plaid; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/ParallelRadixSort.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/ParallelRadixSort.java new file mode 100644 index 00000000..7769cd20 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/ParallelRadixSort.java @@ -0,0 +1,143 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.stream.IntStream; + +/** + * Parallel LSD radix sort for the ultra index's packed tuple keys: rows are + * unsigned keys of up to 128 bits held in a {@code lo} array and an optional + * {@code hi} array ({@code null} when the whole key fits 64 bits). O(n) per + * 16-bit digit pass instead of the O(n log n) comparisons of a comparator + * sort, and every pass parallelizes as per-chunk histogram, per-bucket prefix + * offsets, then per-chunk stable scatter. + * + *

16 divides 64, so a digit never straddles the lo/hi boundary; digits at + * or above {@code totalBits} are identically zero and their passes are never + * run. LSD with a stable scatter is deterministic: equal keys keep their + * relative order, and since the key IS the entire tuple, the sorted output is + * a pure function of the input multiset. + */ +public final class ParallelRadixSort { + + private static final int RADIX_BITS = 16; + private static final int BUCKETS = 1 << RADIX_BITS; + + private ParallelRadixSort() {} + + /** + * Sorts rows by unsigned (hi,lo) key using {@code pool} for the parallel + * phases. The arrays are permuted via an internal double buffer; callers + * MUST use the returned pair {@code {lo, hi}} (either the original or the + * scratch instances, depending on pass parity) and drop their own + * references. + */ + public static long[][] sort(long[] lo, long[] hi, int totalBits, ForkJoinPool pool) { + int n = lo.length; + if (hi != null && hi.length != n) { + throw new IllegalArgumentException("hi/lo length mismatch: " + hi.length + " vs " + n); + } + if (totalBits < 1 || totalBits > 128 || (totalBits > 64 && hi == null)) { + throw new IllegalArgumentException("Bad key width " + totalBits + " for " + (hi == null ? 1 : 2) + "-word keys"); + } + if (n < 2) { + return new long[][]{lo, hi}; + } + final int passes = (totalBits + RADIX_BITS - 1) / RADIX_BITS; + final int chunks = (n >= BUCKETS) ? Math.max(1, Math.min(pool.getParallelism(), 32)) : 1; + long[] srcLo = lo, srcHi = hi; + long[] dstLo = new long[n]; + long[] dstHi = (hi != null) ? new long[n] : null; + + final int[][] counts = new int[chunks][BUCKETS]; + for (int pass = 0; pass < passes; pass++) { + final int shift = pass * RADIX_BITS; + final long[] sLo = srcLo, sHi = srcHi, dLo = dstLo, dHi = dstHi; + + for (int[] c : counts) { + java.util.Arrays.fill(c, 0); + } + runChunks(pool, chunks, n, (c, from, to) -> { + int[] cnt = counts[c]; + for (int i = from; i < to; i++) { + cnt[digit(sLo, sHi, i, shift)]++; + } + }); + + // Per-bucket, per-chunk start offsets; the scatter below is stable + // because each chunk walks its rows in order from its own offsets. + int running = 0; + int nonZeroBuckets = 0; + for (int d = 0; d < BUCKETS && running < n; d++) { + boolean any = false; + for (int c = 0; c < chunks; c++) { + int t = counts[c][d]; + counts[c][d] = running; + running += t; + any |= t != 0; + } + if (any) { + nonZeroBuckets++; + } + } + if (nonZeroBuckets <= 1) { + continue; // every row shares this digit: the pass is a no-op permutation + } + + runChunks(pool, chunks, n, (c, from, to) -> { + int[] off = counts[c]; + for (int i = from; i < to; i++) { + int pos = off[digit(sLo, sHi, i, shift)]++; + dLo[pos] = sLo[i]; + if (dHi != null) { + dHi[pos] = sHi[i]; + } + } + }); + + long[] t = srcLo; srcLo = dstLo; dstLo = t; + if (hi != null) { + t = srcHi; srcHi = dstHi; dstHi = t; + } + } + return new long[][]{srcLo, srcHi}; + } + + private static int digit(long[] lo, long[] hi, int i, int shift) { + return (shift < 64) + ? (int) ((lo[i] >>> shift) & 0xFFFF) + : (int) ((hi[i] >>> (shift - 64)) & 0xFFFF); + } + + @FunctionalInterface + public interface ChunkTask { + void run(int chunk, int from, int to); + } + + /** Runs {@code task} over [0,n) split into {@code chunks} ranges, inside {@code pool}. */ + public static void runChunks(ForkJoinPool pool, int chunks, int n, ChunkTask task) { + if (chunks == 1) { + task.run(0, 0, n); + return; + } + int per = (n + chunks - 1) / chunks; + try { + pool.submit(() -> IntStream.range(0, chunks).parallel().forEach(c -> { + int from = c * per; + int to = Math.min(n, from + per); + if (from < to) { + task.run(c, from, to); + } + })).get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted during parallel radix phase", ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IllegalStateException("Parallel radix phase failed", cause); + } + } +} + diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraBGIndex.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraBGIndex.java new file mode 100644 index 00000000..cd61bd2e --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraBGIndex.java @@ -0,0 +1,490 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import com.ebremer.beakgraph.hdf5.Index; +import static com.ebremer.beakgraph.Params.SUPERBLOCKSIZE; +import static com.ebremer.beakgraph.utils.UTIL.MinBits; +import io.jhdf.api.WritableGroup; +import java.io.IOException; +import java.util.Arrays; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import org.apache.jena.sparql.core.Quad; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The ultra twin of the sequential BGIndex / ParallelBGIndex, rebuilt around + * three ideas: + * + *

    + *
  1. Packed primitive keys. A quad's four dictionary ids concatenate + * into ONE unsigned integer key per index ordering (single {@code long} + * when the id widths fit 63 bits, else a hi/lo pair). Sorting keys IS + * sorting id tuples IS the sequential writer's NodeComparator quad order - + * no objects, no comparator, no clone of a tuple array. Small inputs use + * {@code Arrays.parallelSort(long[])}; large or 2-word inputs use the + * O(n)-per-pass {@link ParallelRadixSort}.
  2. + *
  3. Deduplicate once. Duplicate quads collapse right after the GSPO + * sort in a parallel compaction; GPOS repacks its keys from the already + * deduplicated set (a bijection - distinct tuples stay distinct), sorts + * fewer rows, and both emission passes drop the per-row duplicate + * check.
  4. + *
  5. Chunk-parallel emission. The level-emission scan - the sequential + * writers' last O(n) single-thread tail - runs as two parallel passes: + * pass 1 counts each chunk's emitted rows per level (each row's behaviour + * depends only on its predecessor tuple, which a chunk reads directly at + * its seam), a prefix sum turns counts into absolute offsets, and pass 2 + * writes S/B entries positionally ({@link UltraPackedBuffer} entries are + * byte-aligned; {@link UltraBitmap} merges seam words with atomic OR). + * The SB/BB rank directories are then derived from word popcounts - + * BLOCKSIZE is the machine word, so BB/SB values are pure prefix-sum + * lookups, reproducing the sequential scan's directory exactly.
  6. + *
+ * + * Output buffers are byte-identical to the sequential emission for the same + * id tuples: same rows, same padding for absent L0 ids, same widths, same + * directory values. + */ +final class UltraBGIndex { + + private static final Logger logger = LoggerFactory.getLogger(UltraBGIndex.class); + private static final int WORD = 64; // == Params.BLOCKSIZE; the directory math relies on it + + private final Index type; + private final UltraPackedBuffer S1, S2, S3; + private final UltraBitmap B1, B2, B3; + private final UltraPackedBuffer SB1, SB2, SB3; + private final UltraPackedBuffer BB1, BB2, BB3; + + /** Bit layout of one packed key: components in index order, k0 highest. */ + record Layout(int b0, int b1, int b2, int b3) { + int off3() { return 0; } + int off2() { return b3; } + int off1() { return b2 + b3; } + int off0() { return b1 + b2 + b3; } + int total() { return b0 + b1 + b2 + b3; } + } + + // ------------------------------------------------------------------ + // orchestration + // ------------------------------------------------------------------ + + /** + * Resolves every quad's ids once (O(1) map lookups), packs GSPO keys, + * sorts, deduplicates, and builds both indexes concurrently. Releases the + * ingest's quad array as soon as the keys are packed. + */ + static UltraBGIndex[] buildBoth(UltraDictionary dict, UltraIngest ingest, ForkJoinPool pool) throws IOException { + final Quad[] quads = ingest.getQuads(); + final int n = quads.length; + final long e = dict.getNumberOfGraphs(); + final long p = dict.getNumberOfPredicates(); + final long o = dict.getNumberOfObjects(); + final int bG = MinBits(e), bS = MinBits(e), bP = MinBits(p), bO = MinBits(o); + final Layout gspoLayout = new Layout(bG, bS, bP, bO); + final Layout gposLayout = new Layout(bG, bP, bO, bS); + final int totalBits = gspoLayout.total(); + if (totalBits > 126) { + throw new IllegalArgumentException( + "Dictionary id widths total " + totalBits + " bits; too large for the in-memory ultra writer"); + } + final boolean twoWords = totalBits > 63; + + logger.info("Packing {} quad keys ({} bits, {} words)...", n, totalBits, twoWords ? 2 : 1); + long start = System.nanoTime(); + long[] lo = new long[n]; + long[] hi = twoWords ? new long[n] : null; + final long[] fLo = lo, fHi = hi; + ParallelRadixSort.runChunks(pool, chunksFor(pool, n), n, (c, from, to) -> { + for (int i = from; i < to; i++) { + Quad q = quads[i]; + pack(fLo, fHi, i, + dict.locateGraph(q.getGraph()), + dict.locateSubject(q.getSubject()), + dict.locatePredicate(q.getPredicate()), + dict.locateObject(q.getObject()), + gspoLayout); + } + }); + ingest.releaseQuads(); + logger.info("Packed ids for {} quads in {} ms", n, (System.nanoTime() - start) / 1_000_000L); + + logger.info("Sorting {} GSPO keys ({})...", n, + (hi == null && n < (1 << 20)) ? "JDK parallel sort" : "parallel radix sort"); + start = System.nanoTime(); + long[][] sorted = sortKeys(lo, hi, totalBits, pool); + logger.info("GSPO keys sorted in {} ms", (System.nanoTime() - start) / 1_000_000L); + start = System.nanoTime(); + long[][] deduped = dedup(sorted[0], sorted[1], pool); + final long[] dLo = deduped[0], dHi = deduped[1]; + logger.info("Deduplicated in {} ms: {} unique quads of {} (GPOS reuses the deduplicated set)", + (System.nanoTime() - start) / 1_000_000L, dLo.length, n); + + ForkJoinTask gspoTask = pool.submit(() -> + new UltraBGIndex(Index.GSPO, dLo, dHi, gspoLayout, dict, n, pool)); + ForkJoinTask gposTask = pool.submit(() -> { + // Repack (g,s,p,o) -> (g,p,o,s) from the deduplicated keys and sort. + int m = dLo.length; + logger.info("Repacking and sorting {} GPOS keys...", m); + long gposStart = System.nanoTime(); + long[] pLo = new long[m]; + long[] pHi = twoWords ? new long[m] : null; + ParallelRadixSort.runChunks(pool, chunksFor(pool, m), m, (c, from, to) -> { + for (int i = from; i < to; i++) { + long l = dLo[i], h = (dHi != null) ? dHi[i] : 0L; + pack(pLo, pHi, i, + extract(l, h, gspoLayout.off0(), gspoLayout.b0()), + extract(l, h, gspoLayout.off2(), gspoLayout.b2()), + extract(l, h, gspoLayout.off3(), gspoLayout.b3()), + extract(l, h, gspoLayout.off1(), gspoLayout.b1()), + gposLayout); + } + }); + long[][] s = sortKeys(pLo, pHi, totalBits, pool); + logger.info("GPOS keys repacked and sorted in {} ms", (System.nanoTime() - gposStart) / 1_000_000L); + return new UltraBGIndex(Index.GPOS, s[0], s[1], gposLayout, dict, n, pool); + }); + return new UltraBGIndex[]{join(gspoTask, Index.GSPO), join(gposTask, Index.GPOS)}; + } + + /** + * Packs one key: values in this layout's component order, {@code v0} + * ending up in the highest bits. Each component width is at most 34 bits, + * so the running 128-bit left-shift-and-or below never shifts by >= 64. + */ + static void pack(long[] lo, long[] hi, int i, long v0, long v1, long v2, long v3, Layout lay) { + long l = v0, h = 0; + h = (h << lay.b1()) | (l >>> (64 - lay.b1())); l = (l << lay.b1()) | v1; + h = (h << lay.b2()) | (l >>> (64 - lay.b2())); l = (l << lay.b2()) | v2; + h = (h << lay.b3()) | (l >>> (64 - lay.b3())); l = (l << lay.b3()) | v3; + lo[i] = l; + if (hi != null) { + hi[i] = h; + } + } + + static long extract(long lo, long hi, int off, int bits) { + long mask = (1L << bits) - 1; // bits <= 34, never 64 + if (off >= 64) return (hi >>> (off - 64)) & mask; + if (off + bits <= 64) return (lo >>> off) & mask; + return ((lo >>> off) | (hi << (64 - off))) & mask; + } + + private static long[][] sortKeys(long[] lo, long[] hi, int totalBits, ForkJoinPool pool) throws IOException { + if (hi == null && lo.length < (1 << 20)) { + // Small single-word inputs: the JDK dual-pivot sort has lower + // constant overhead than four radix passes. Signed order equals + // unsigned order here - keys are at most 63 bits, never negative. + try { + pool.submit(() -> Arrays.parallelSort(lo)).get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while sorting index keys", ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IOException("Failed to sort index keys", cause); + } + return new long[][]{lo, null}; + } + return ParallelRadixSort.sort(lo, hi, totalBits, pool); + } + + /** Parallel compaction of adjacent duplicate keys (arrays already sorted). */ + private static long[][] dedup(long[] lo, long[] hi, ForkJoinPool pool) { + final int n = lo.length; + if (n == 0) { + return new long[][]{lo, hi}; + } + final int chunks = chunksFor(pool, n); + final int[] kept = new int[chunks]; + ParallelRadixSort.runChunks(pool, chunks, n, (c, from, to) -> { + int k = 0; + for (int i = from; i < to; i++) { + if (i == 0 || lo[i] != lo[i - 1] || (hi != null && hi[i] != hi[i - 1])) { + k++; + } + } + kept[c] = k; + }); + final int[] starts = new int[chunks]; + int total = 0; + for (int c = 0; c < chunks; c++) { + starts[c] = total; + total += kept[c]; + } + if (total == n) { + return new long[][]{lo, hi}; // nothing to drop + } + final long[] outLo = new long[total]; + final long[] outHi = (hi != null) ? new long[total] : null; + ParallelRadixSort.runChunks(pool, chunks, n, (c, from, to) -> { + int at = starts[c]; + for (int i = from; i < to; i++) { + if (i == 0 || lo[i] != lo[i - 1] || (hi != null && hi[i] != hi[i - 1])) { + outLo[at] = lo[i]; + if (outHi != null) { + outHi[at] = hi[i]; + } + at++; + } + } + }); + return new long[][]{outLo, outHi}; + } + + private static int chunksFor(ForkJoinPool pool, int n) { + return (int) Math.max(1, Math.min(pool.getParallelism() * 4L, Math.max(1, n / 32_768))); + } + + // ------------------------------------------------------------------ + // one index build: two-pass parallel emission + // ------------------------------------------------------------------ + + private UltraBGIndex(Index type, long[] keyLo, long[] keyHi, Layout lay, UltraDictionary dict, + long originalQuadCount, ForkJoinPool pool) { + logger.info("Creating index {} (ultra, chunk-parallel emission)", type); + final long indexStart = System.nanoTime(); + this.type = type; + final char[] comps = type.name().toCharArray(); + final int m = keyLo.length; + final long maxL0Id = count(dict, comps[0]); + + // Identical width sizing to the sequential/parallel index builders. + long maxCumulativeOnes = originalQuadCount + maxL0Id + 128L; + int sbBits = roundUp8(MinBits(maxCumulativeOnes)); + int bbBits = roundUp8(MinBits(SUPERBLOCKSIZE)); + final int w1 = getBitSize(dict, comps[1]); + final int w2 = getBitSize(dict, comps[2]); + final int w3 = getBitSize(dict, comps[3]); + final String n1 = levelName(comps[1]); + final String n2 = levelName(comps[2]); + final String n3 = levelName(comps[3]); + + // ---- pass 1: rows emitted per level, per chunk ---- + final int chunks = (m == 0) ? 1 : chunksFor(pool, m); + final long[] rows1 = new long[chunks], rows2 = new long[chunks], rows3 = new long[chunks]; + if (m > 0) { + ParallelRadixSort.runChunks(pool, chunks, m, (c, from, to) -> { + long r1 = 0, r2 = 0, r3 = 0; + boolean first = (from == 0); + long p0 = 0, p1 = 0, p2 = 0; + if (!first) { + long l = keyLo[from - 1], h = (keyHi != null) ? keyHi[from - 1] : 0L; + p0 = extract(l, h, lay.off0(), lay.b0()); + p1 = extract(l, h, lay.off1(), lay.b1()); + p2 = extract(l, h, lay.off2(), lay.b2()); + } + for (int r = from; r < to; r++) { + long l = keyLo[r], h = (keyHi != null) ? keyHi[r] : 0L; + long k0 = extract(l, h, lay.off0(), lay.b0()); + long k1 = extract(l, h, lay.off1(), lay.b1()); + long k2 = extract(l, h, lay.off2(), lay.b2()); + boolean ch0 = first || k0 != p0; + boolean ch1 = ch0 || k1 != p1; + boolean ch2 = ch1 || k2 != p2; + long pads = ch0 ? (first ? k0 - 1 : k0 - p0 - 1) : 0; + r3 += 1 + pads; + r2 += (ch2 ? 1 : 0) + pads; + r1 += (ch1 ? 1 : 0) + pads; + p0 = k0; p1 = k1; p2 = k2; first = false; + } + if (to == m) { + long tail = maxL0Id - extract(keyLo[m - 1], (keyHi != null) ? keyHi[m - 1] : 0L, lay.off0(), lay.b0()); + r1 += tail; r2 += tail; r3 += tail; + } + rows1[c] = r1; rows2[c] = r2; rows3[c] = r3; + }); + } else { + // No tuples at all (theoretical: VoID always contributes some). + // The sequential loop pads maxL0Id-1 empty rows in this case. + long pads = Math.max(0, maxL0Id - 1); + rows1[0] = pads; rows2[0] = pads; rows3[0] = pads; + } + final long[] start1 = prefix(rows1), start2 = prefix(rows2), start3 = prefix(rows3); + final long t1 = start1[chunks], t2 = start2[chunks], t3 = start3[chunks]; + logger.info("{}: counted rows in {} chunk(s): L1={}, L2={}, L3={}", type, chunks, t1, t2, t3); + + S1 = new UltraPackedBuffer("S" + n1, t1, w1); + S2 = new UltraPackedBuffer("S" + n2, t2, w2); + S3 = new UltraPackedBuffer("S" + n3, t3, w3); + B1 = new UltraBitmap("B" + n1, t1); + B2 = new UltraBitmap("B" + n2, t2); + B3 = new UltraBitmap("B" + n3, t3); + + // ---- pass 2: positional emission ---- + long phase = System.nanoTime(); + if (m > 0) { + ParallelRadixSort.runChunks(pool, chunks, m, (c, from, to) -> { + long c1 = start1[c], c2 = start2[c], c3 = start3[c]; + boolean first = (from == 0); + long p0 = 0, p1 = 0, p2 = 0; + if (!first) { + long l = keyLo[from - 1], h = (keyHi != null) ? keyHi[from - 1] : 0L; + p0 = extract(l, h, lay.off0(), lay.b0()); + p1 = extract(l, h, lay.off1(), lay.b1()); + p2 = extract(l, h, lay.off2(), lay.b2()); + } + for (int r = from; r < to; r++) { + long l = keyLo[r], h = (keyHi != null) ? keyHi[r] : 0L; + long k0 = extract(l, h, lay.off0(), lay.b0()); + long k1 = extract(l, h, lay.off1(), lay.b1()); + long k2 = extract(l, h, lay.off2(), lay.b2()); + long k3 = extract(l, h, lay.off3(), lay.b3()); + boolean ch0 = first || k0 != p0; + boolean ch1 = ch0 || k1 != p1; + boolean ch2 = ch1 || k2 != p2; + if (ch0) { + long pads = first ? k0 - 1 : k0 - p0 - 1; + for (long k = 0; k < pads; k++) { + S1.set(c1, 0); B1.set(c1); c1++; + S2.set(c2, 0); B2.set(c2); c2++; + S3.set(c3, 0); B3.set(c3); c3++; + } + } + S3.set(c3, k3); + if (ch2) B3.set(c3); + c3++; + if (ch2) { + S2.set(c2, k2); + if (ch1) B2.set(c2); + c2++; + } + if (ch1) { + S1.set(c1, k1); + if (ch0) B1.set(c1); + c1++; + } + p0 = k0; p1 = k1; p2 = k2; first = false; + } + if (to == m) { + long tail = maxL0Id - p0; + for (long k = 0; k < tail; k++) { + S1.set(c1, 0); B1.set(c1); c1++; + S2.set(c2, 0); B2.set(c2); c2++; + S3.set(c3, 0); B3.set(c3); c3++; + } + } + }); + } else { + long pads = Math.max(0, maxL0Id - 1); + for (long k = 0; k < pads; k++) { + S1.set(k, 0); B1.set(k); + S2.set(k, 0); B2.set(k); + S3.set(k, 0); B3.set(k); + } + } + + logger.info("{}: emitted S/B buffers in {} ms", type, (System.nanoTime() - phase) / 1_000_000L); + + // ---- rank/select directories from word popcounts ---- + phase = System.nanoTime(); + SB1 = new UltraPackedBuffer("SB" + n1, t1 / SUPERBLOCKSIZE + 1, sbBits); + SB2 = new UltraPackedBuffer("SB" + n2, t2 / SUPERBLOCKSIZE + 1, sbBits); + SB3 = new UltraPackedBuffer("SB" + n3, t3 / SUPERBLOCKSIZE + 1, sbBits); + BB1 = new UltraPackedBuffer("BB" + n1, t1 / WORD + 1, bbBits); + BB2 = new UltraPackedBuffer("BB" + n2, t2 / WORD + 1, bbBits); + BB3 = new UltraPackedBuffer("BB" + n3, t3 / WORD + 1, bbBits); + ParallelRadixSort.runChunks(pool, 3, 3, (c, from, to) -> { + for (int i = from; i < to; i++) { + switch (i) { + case 0 -> buildDirectory(B1, SB1, BB1); + case 1 -> buildDirectory(B2, SB2, BB2); + default -> buildDirectory(B3, SB3, BB3); + } + } + }); + logger.info("{}: rank/select directories built in {} ms", type, (System.nanoTime() - phase) / 1_000_000L); + logger.info("Index {} built in {} ms: {} L3 rows", type, (System.nanoTime() - indexStart) / 1_000_000L, t3); + } + + /** + * SB[j] = set bits in the first j superblocks; BB[k] = set bits between + * block k's superblock start and block k (0 when k opens its superblock). + * Exactly the values the sequential advanceLevel scan writes, but read off + * a word-popcount prefix array. BLOCKSIZE == 64 == word size makes every + * boundary a word boundary. + */ + private static void buildDirectory(UltraBitmap B, UltraPackedBuffer SB, UltraPackedBuffer BB) { + final int words = B.numWords(); + final long bits = B.getNumBits(); + final long[] cum = new long[words + 1]; + for (int w = 0; w < words; w++) { + cum[w + 1] = cum[w] + Long.bitCount(B.word(w)); + } + final int wordsPerSuper = SUPERBLOCKSIZE / WORD; + SB.set(0, 0); + long nSB = bits / SUPERBLOCKSIZE; + for (long j = 1; j <= nSB; j++) { + SB.set(j, cum[(int) (j * wordsPerSuper)]); + } + BB.set(0, 0); + long nBB = bits / WORD; + for (long k = 1; k <= nBB; k++) { + BB.set(k, cum[(int) k] - cum[(int) ((k / wordsPerSuper) * wordsPerSuper)]); + } + } + + private static long[] prefix(long[] counts) { + long[] out = new long[counts.length + 1]; + for (int i = 0; i < counts.length; i++) { + out[i + 1] = out[i] + counts[i]; + } + return out; + } + + private static int roundUp8(int bits) { + return (int) (Math.ceil(bits / 8.0) * 8); + } + + private static String levelName(char component) { + return switch (component) { + case 'G' -> "g"; + case 'S' -> "s"; + case 'P' -> "p"; + case 'O' -> "o"; + default -> throw new IllegalStateException("Unknown component: " + component); + }; + } + + private static long count(UltraDictionary w, char component) { + return switch (component) { + case 'G' -> w.getNumberOfGraphs(); + case 'S' -> w.getNumberOfSubjects(); + case 'P' -> w.getNumberOfPredicates(); + case 'O' -> w.getNumberOfObjects(); + default -> throw new IllegalStateException("Unknown component: " + component); + }; + } + + private static int getBitSize(UltraDictionary w, char component) { + int needed = MinBits(count(w, component) + 1); + if (needed == 0) return 8; + return roundUp8(needed); + } + + private static UltraBGIndex join(ForkJoinTask task, Index which) throws IOException { + try { + return task.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while building index " + which, ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof IOException io) throw io; + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IOException("Failed to build index " + which, cause); + } + } + + void add(WritableGroup hdt) { + WritableGroup index = hdt.putGroup(type.name()); + S1.add(index); S2.add(index); S3.add(index); + B1.add(index); B2.add(index); B3.add(index); + SB1.add(index); SB2.add(index); SB3.add(index); + BB1.add(index); BB2.add(index); BB3.add(index); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraBitmap.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraBitmap.java new file mode 100644 index 00000000..95641505 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraBitmap.java @@ -0,0 +1,97 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import io.jhdf.api.WritableDataset; +import io.jhdf.api.WritableGroup; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; + +/** + * Concurrently-settable 1-bit buffer for the index B bitmaps. Bits live + * MSB-first inside big-endian 64-bit words, which serializes to exactly the + * byte stream the sequential writer's bit accumulator emits (byte {@code k} + * holds bits {@code 8k..8k+7}, most significant first). + * + *

Words start all-zero and writers only ever SET bits, so concurrent + * emission chunks combine with an atomic bitwise OR: two chunks may share the + * word at their seam, and OR is the one update that is correct regardless of + * arrival order. Interior words are touched by a single chunk, where the + * atomic costs nothing measurable (uncontended {@code lock or}). + * + *

Word granularity is deliberately the same as {@code Params.BLOCKSIZE} + * (64): the rank directory's block boundaries are word boundaries, so + * {@link #word} + {@code Long.bitCount} is all the SB/BB builder needs. + */ +final class UltraBitmap { + + private static final VarHandle WORDS = MethodHandles.arrayElementVarHandle(long[].class); + + private final String name; + private final long numBits; + private final long[] words; + + UltraBitmap(String name, long numBits) { + if (numBits < 0) { + throw new IllegalArgumentException("numBits must be >= 0, got " + numBits); + } + long w = (numBits + 63) >>> 6; + if (w > Integer.MAX_VALUE - 16 || ((numBits + 7) >>> 3) > Integer.MAX_VALUE - 16) { + throw new IllegalArgumentException( + "Bitmap '" + name + "' of " + numBits + " bits is too large for the in-memory ultra writer"); + } + this.name = name; + this.numBits = numBits; + this.words = new long[(int) w]; + } + + /** Sets bit {@code bitIndex} to 1. Thread-safe (atomic OR). */ + void set(long bitIndex) { + if (bitIndex < 0 || bitIndex >= numBits) { + throw new IndexOutOfBoundsException("Bit " + bitIndex + " out of bounds [0, " + numBits + ")"); + } + long mask = 1L << (63 - (bitIndex & 63)); + WORDS.getAndBitwiseOr(words, (int) (bitIndex >>> 6), mask); + } + + long getNumBits() { + return numBits; + } + + int numWords() { + return words.length; + } + + /** Word {@code w} (bits {@code 64w..64w+63}, MSB first). */ + long word(int w) { + return words[w]; + } + + /** Test access: bit value at {@code bitIndex}. */ + int get(long bitIndex) { + if (bitIndex < 0 || bitIndex >= numBits) { + throw new IndexOutOfBoundsException("Bit " + bitIndex + " out of bounds [0, " + numBits + ")"); + } + return (int) ((words[(int) (bitIndex >>> 6)] >>> (63 - (bitIndex & 63))) & 1L); + } + + /** + * The exact byte stream the sequential 1-bit buffer produces: bits + * MSB-first, final partial byte zero-padded (word tails are already zero). + */ + byte[] toBytes() { + int nBytes = (int) ((numBits + 7) >>> 3); + byte[] out = new byte[nBytes]; + for (int b = 0; b < nBytes; b++) { + out[b] = (byte) (words[b >>> 3] >>> (56 - ((b & 7) << 3))); + } + return out; + } + + void add(WritableGroup group) { + byte[] bytes = toBytes(); + if (bytes.length > 0) { + WritableDataset ds = group.putDataset(name, bytes); + ds.putAttribute("width", 1); + ds.putAttribute("numEntries", numBits); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraDictionary.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraDictionary.java new file mode 100644 index 00000000..740f1539 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraDictionary.java @@ -0,0 +1,267 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import com.ebremer.beakgraph.core.DictionaryWriter; +import com.ebremer.beakgraph.core.lib.NodeComparator; +import com.ebremer.beakgraph.hdf5.Types; +import com.ebremer.beakgraph.hdf5.writers.MultiTypeDictionaryWriter; +import static com.ebremer.beakgraph.utils.UTIL.MinBits; +import io.jhdf.api.WritableGroup; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import org.apache.jena.graph.Node; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The ultra writer's dictionary stage. One {@code NodeComparator} sort per + * sub-dictionary (entities, predicates, literals) is shared THREE ways: + * + *

    + *
  1. the storage {@link MultiTypeDictionaryWriter} builds consume it via + * {@code setSortedNodes} (no internal re-sort),
  2. + *
  3. a {@code Node -> id} hash map is built straight from sorted rank + * (dictionary ids ARE 1-based NodeComparator ranks - the invariant the + * whole id-tuple index design already rests on), replacing every + * binary-search {@code locate()} downstream with an O(1) lookup,
  4. + *
  5. the columnar graph/subject/object id lists are populated positionally + * and in parallel from those maps.
  6. + *
+ * + * The storage builds and column fills are submitted asynchronously and joined + * only in {@link #awaitStorage()}: the index stage needs nothing but the maps, + * so key packing and index sorting overlap dictionary encoding instead of + * waiting behind it. + */ +final class UltraDictionary { + + private static final Logger logger = LoggerFactory.getLogger(UltraDictionary.class); + + private final String name; + private final long numQuads; + private final long maxEntityId; + private final long numPredicates; + private final long numLiterals; + + private final ConcurrentHashMap entityIds; + private final ConcurrentHashMap predicateIds; + private final ConcurrentHashMap literalIds; + + private final ForkJoinTask entitiesTask; + private final ForkJoinTask predicatesTask; + private final ForkJoinTask literalsTask; + private final ForkJoinTask graphsTask; + private final ForkJoinTask subjectsTask; + private final ForkJoinTask objectsTask; + + private DictionaryWriter entitiesdict; + private DictionaryWriter predicatesdict; + private DictionaryWriter literalsdict; + private UltraPackedBuffer graphs; + private UltraPackedBuffer subjects; + private UltraPackedBuffer objects; + + UltraDictionary(UltraIngest ingest, ForkJoinPool pool) throws IOException { + this.name = ingest.getName(); + this.numQuads = ingest.getNumberOfQuads(); + + // ---- shared sorts (blocking, but internally parallel in the pool) ---- + logger.info("Sorting dictionary node sets ({} entities, {} predicates, {} literals; one shared sort each)...", + ingest.getEntities().size(), ingest.getPredicates().size(), ingest.getLiterals().size()); + long phase = System.nanoTime(); + ForkJoinTask sortE = pool.submit(() -> sortedArray(ingest.getEntities())); + ForkJoinTask sortP = pool.submit(() -> sortedArray(ingest.getPredicates())); + ForkJoinTask sortL = pool.submit(() -> sortedArray(ingest.getLiterals())); + final Node[] ents = join(sortE, "sort entities"); + final Node[] preds = join(sortP, "sort predicates"); + final Node[] lits = join(sortL, "sort literals"); + logger.info("Dictionary node sets sorted in {} ms", (System.nanoTime() - phase) / 1_000_000L); + + this.maxEntityId = ents.length; + this.numPredicates = preds.length; + this.numLiterals = lits.length; + + // ---- storage dictionary builds: async, joined in awaitStorage() ---- + logger.info("Dictionary encoding (entities/predicates/literals) started in the background"); + entitiesTask = pool.submit(() -> new MultiTypeDictionaryWriter.Builder() + .setName("entities") + .setSortedNodes(new ArrayList<>(Arrays.asList(ents))) + .setStats(ingest.getStats()) + .enable(Types.IRI, Types.BNODE) + .build()); + predicatesTask = pool.submit(() -> new MultiTypeDictionaryWriter.Builder() + .setName("predicates") + .setSortedNodes(new ArrayList<>(Arrays.asList(preds))) + .setStats(ingest.getStats()) + .enable(Types.IRI) + .build()); + literalsTask = pool.submit(() -> new MultiTypeDictionaryWriter.Builder() + .setName("literals") + .setSortedNodes(new ArrayList<>(Arrays.asList(lits))) + .setDataTypes(ingest.getDataTypes()) + .setStats(ingest.getStats()) + .enable(Types.DOUBLE, Types.FLOAT, Types.LONG, Types.INTEGER, Types.STRING) + .build()); + + // ---- id maps from sorted rank (the only thing the index stage needs) ---- + logger.info("Building node->id rank maps ({} entities, {} predicates, {} literals; no binary searches)...", + ents.length, preds.length, lits.length); + phase = System.nanoTime(); + entityIds = rankMap(ents, pool); + predicateIds = rankMap(preds, pool); + literalIds = rankMap(lits, pool); + logger.info("Rank maps built in {} ms; index stage can start", (System.nanoTime() - phase) / 1_000_000L); + + // ---- columnar id lists: async, positional-parallel fills ---- + logger.info("Columnar id list population (graphs/subjects/objects) started in the background"); + int gBits = (int) (Math.ceil(MinBits(getNumberOfGraphs() + 1) / 8.0) * 8); + int sBits = (int) (Math.ceil(MinBits(getNumberOfSubjects() + 1) / 8.0) * 8); + int oBits = (int) (Math.ceil(MinBits(getNumberOfObjects() + 1) / 8.0) * 8); + graphsTask = pool.submit(() -> populate("graphs", ingest.getUniqueGraphs(), this::locateGraph, gBits, pool)); + subjectsTask = pool.submit(() -> populate("subjects", ingest.getUniqueSubjects(), this::locateSubject, sBits, pool)); + objectsTask = pool.submit(() -> populate("objects", ingest.getUniqueObjects(), this::locateObject, oBits, pool)); + } + + private static Node[] sortedArray(Set nodes) { + Node[] arr = nodes.toArray(Node[]::new); + Arrays.parallelSort(arr, NodeComparator.INSTANCE); + return arr; + } + + /** id(node) = 1 + rank in NodeComparator order; built with zero locate() calls. */ + private static ConcurrentHashMap rankMap(Node[] sorted, ForkJoinPool pool) { + ConcurrentHashMap map = new ConcurrentHashMap<>(Math.max(16, sorted.length * 4 / 3 + 1)); + ParallelRadixSort.runChunks(pool, Math.max(1, Math.min(pool.getParallelism() * 2, sorted.length)), + sorted.length, (c, from, to) -> { + for (int i = from; i < to; i++) { + map.put(sorted[i], (long) (i + 1)); + } + }); + return map; + } + + /** Sorted unique nodes -> ids via the maps -> byte-aligned positional writes. */ + private UltraPackedBuffer populate(String bufferName, Set nodes, java.util.function.ToLongFunction locator, + int bits, ForkJoinPool pool) { + Node[] sorted = nodes.toArray(Node[]::new); + Arrays.parallelSort(sorted, NodeComparator.INSTANCE); + UltraPackedBuffer target = new UltraPackedBuffer(bufferName, sorted.length, bits); + ParallelRadixSort.runChunks(pool, Math.max(1, Math.min(pool.getParallelism() * 2, sorted.length)), + sorted.length, (c, from, to) -> { + for (int i = from; i < to; i++) { + target.set(i, locator.applyAsLong(sorted[i])); + } + }); + return target; + } + + // ------------------------------------------------------------------ + // id resolution (O(1) map lookups) + // ------------------------------------------------------------------ + + long locateGraph(Node element) { + Long c = entityIds.get(element); + if (c != null) return c; + throw new IllegalStateException("Cannot resolve Graph (not in dictionary): " + element); + } + + long locateSubject(Node element) { + Long c = entityIds.get(element); + if (c != null) return c; + throw new IllegalStateException("Cannot resolve Subject (not in dictionary): " + element); + } + + long locatePredicate(Node element) { + Long c = predicateIds.get(element); + if (c != null) return c; + throw new IllegalStateException("Cannot resolve Predicate (not in dictionary): " + element); + } + + long locateObject(Node element) { + if (element.isLiteral()) { + Long c = literalIds.get(element); + if (c != null) return c + maxEntityId; // literals sit above the entity id block + } else { + Long c = entityIds.get(element); + if (c != null) return c; + } + throw new IllegalStateException("Cannot resolve Object (not in dictionary): " + element); + } + + long getNumberOfQuads() { return numQuads; } + long getNumberOfGraphs() { return maxEntityId; } + long getNumberOfSubjects() { return maxEntityId; } + long getNumberOfPredicates() { return numPredicates; } + long getNumberOfObjects() { return maxEntityId + numLiterals; } + + /** Test access: the storage dictionary for map-vs-locate verification. */ + DictionaryWriter entitiesDictionary() throws IOException { + awaitStorage(); + return entitiesdict; + } + + DictionaryWriter predicatesDictionary() throws IOException { + awaitStorage(); + return predicatesdict; + } + + DictionaryWriter literalsDictionary() throws IOException { + awaitStorage(); + return literalsdict; + } + + // ------------------------------------------------------------------ + // storage + // ------------------------------------------------------------------ + + /** Joins the async dictionary builds and column fills. Idempotent. */ + synchronized void awaitStorage() throws IOException { + if (entitiesdict == null) { + logger.info("Waiting for background dictionary encoding and columnar id lists..."); + long phase = System.nanoTime(); + entitiesdict = join(entitiesTask, "build 'entities' dictionary"); + predicatesdict = join(predicatesTask, "build 'predicates' dictionary"); + literalsdict = join(literalsTask, "build 'literals' dictionary"); + graphs = join(graphsTask, "populate 'graphs' id list"); + subjects = join(subjectsTask, "populate 'subjects' id list"); + objects = join(objectsTask, "populate 'objects' id list"); + logger.info("Dictionary storage complete ({} ms wait): {} graphs, {} subjects, {} objects in columnar lists", + (System.nanoTime() - phase) / 1_000_000L, + graphs.getNumEntries(), subjects.getNumEntries(), objects.getNumEntries()); + } + } + + /** Same group layout and gating as the other in-memory writers. */ + void add(WritableGroup group) throws IOException { + awaitStorage(); + WritableGroup dictionary = group.putGroup(name); + if (entitiesdict.getNumberOfNodes() > 0) entitiesdict.add(dictionary); + if (predicatesdict.getNumberOfNodes() > 0) predicatesdict.add(dictionary); + if (literalsdict.getNumberOfNodes() > 0) literalsdict.add(dictionary); + if (graphs.getNumEntries() > 0) { + graphs.add(dictionary); + subjects.add(dictionary); + objects.add(dictionary); + } + } + + private static T join(ForkJoinTask task, String what) throws IOException { + try { + return task.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while trying to " + what, ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof IOException io) throw io; + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IOException("Failed to " + what, cause); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraHDF5Writer.java new file mode 100644 index 00000000..5df1bbd1 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraHDF5Writer.java @@ -0,0 +1,156 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.AbstractGraphBuilder; +import com.ebremer.beakgraph.core.BeakGraphWriter; +import io.jhdf.HdfFile; +import io.jhdf.WritableHdfFile; +import io.jhdf.api.WritableGroup; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.ForkJoinPool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The most aggressively parallel in-memory BeakGraph writer (CLI: + * {@code -method 3}, thread count via {@code -cores}). Same source formats, + * same HDF5 output format, same readers as + * {@link com.ebremer.beakgraph.hdf5.writers.HDF5Writer}; the entire build is + * restructured as a dependency DAG on one dedicated {@link ForkJoinPool}: + * + *
+ *  parse docs (parallel)  ->  dedup sets + stats (parallel)
+ *      -> sort entities/predicates/literals (one shared sort each)
+ *          -> storage dictionary encodes  \
+ *          -> node->id hash maps           } run CONCURRENTLY
+ *              -> columnar id lists        /
+ *              -> pack GSPO keys -> radix sort -> dedup once
+ *                  -> GSPO emission || GPOS repack/sort/emission
+ *      -> single sequential jHDF write of the finished buffers
+ * 
+ * + * Relative to the {@code -method 2} parallel writer this removes its three + * serial tails: per-quad binary-search id resolution (O(1) rank maps), the + * comparator object sort (packed primitive keys, radix sorted), and the + * single-threaded index emission scan (chunked two-pass positional writes). + * + *

Output is semantically identical to the sequential writer's and, for a + * single source document, structurally identical (same datasets, sizes, and + * attributes; bytes differ only through VoID's per-write random bnode + * labels). Multi-source merges label blank nodes per document ordinal, giving + * an isomorphic - not structurally identical - store, same as the huge + * writer's merge. + * + * @author Erich Bremer + */ +public class UltraHDF5Writer implements BeakGraphWriter { + + /** Default worker-thread count when the builder (or CLI -cores) does not set one. */ + public static final int DEFAULT_CORES = 4; + + private static final Logger logger = LoggerFactory.getLogger(UltraHDF5Writer.class); + private final Builder builder; + + private UltraHDF5Writer(Builder builder) { + this.builder = builder; + } + + @Override + public void write() throws IOException { + logger.info("Writing BeakGraph to {} (ultra, {} cores)", builder.getDestination(), builder.getCores()); + Path dest = builder.getDestination().toPath(); + // Same publish discipline as every other writer: build into a sibling + // temp file, swap in atomically on success, never disturb a previous + // good artifact, never expose a half-written file. + Path tmp = dest.resolveSibling(dest.getFileName() + ".tmp"); + final long total = System.nanoTime(); + ForkJoinPool pool = new ForkJoinPool(builder.getCores()); + try { + logger.info("Stage 1/4: ingest (parallel parse + dedup + statistics)"); + UltraIngest ingest = new UltraIngest(); + ingest.setSource(builder.getSource()); + ingest.setSources(builder.getSources()); + ingest.setSpatial(builder.getSpatial()); + ingest.setVoidMode(builder.getVoidMode()); + ingest.setDestination(builder.getDestination()); + ingest.setFeatures(builder.getFeatures()); + ingest.setName(Params.DICTIONARY); + ingest.ingest(pool); + + // Dictionary storage encodes in the background; the index stage + // needs only the id maps and starts immediately. + logger.info("Stage 2/4: dictionaries (sorts, rank maps; encoding + columns in background)"); + UltraDictionary dict = new UltraDictionary(ingest, pool); + logger.info("Stage 3/4: GSPO/GPOS indexes (packed keys, sort, dedup, parallel emission)"); + UltraBGIndex[] indexes = UltraBGIndex.buildBoth(dict, ingest, pool); + dict.awaitStorage(); + + logger.info("Stage 4/4: writing HDF5 file {}", builder.getDestination()); + long ioStart = System.nanoTime(); + try (WritableHdfFile hdfFile = HdfFile.write(tmp)) { + final WritableGroup hdt = hdfFile.putGroup(builder.getName()); + hdt.putAttribute("numQuads", ingest.getNumberOfQuads()); + hdt.putAttribute("formatVersion", Params.FORMAT_VERSION); + dict.add(hdt); + indexes[0].add(hdt); + indexes[1].add(hdt); + } + logger.info("HDF5 file written in {} ms", (System.nanoTime() - ioStart) / 1_000_000L); + try { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException ex) { + // Only the temp file is ever cleaned up; dest is untouched on failure. + try { + Files.deleteIfExists(tmp); + } catch (IOException cleanup) { + logger.warn("Failed to remove temp output {}", tmp, cleanup); + } + throw ex; + } finally { + pool.shutdown(); + } + logger.info("Write complete in {} ms: {}", (System.nanoTime() - total) / 1_000_000L, builder.getDestination()); + } + + public static class Builder extends AbstractGraphBuilder { + private int cores = DEFAULT_CORES; + + public Builder setCores(int cores) { + if (cores < 1) { + throw new IllegalArgumentException("cores must be >= 1, got " + cores); + } + this.cores = cores; + return this; + } + + public int getCores() { + return cores; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public String getName() { + return Params.BG; + } + + @Override + public UltraHDF5Writer build() { + return new UltraHDF5Writer(this); + } + } + + public static Builder Builder() { + return new Builder(); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraIngest.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraIngest.java new file mode 100644 index 00000000..59e9bf2b --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraIngest.java @@ -0,0 +1,458 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import com.ebremer.beakgraph.core.fuseki.BGVoIDSD; +import com.ebremer.beakgraph.core.lib.Stats; +import com.ebremer.beakgraph.hdf5.writers.PositionalDictionaryWriterBuilder; +import com.ebremer.beakgraph.sniff.SD; +import com.ebremer.beakgraph.utils.RdfSources; +import static com.ebremer.beakgraph.Params.BGVOID; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import java.util.concurrent.Future; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.NodeFactory; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.riot.lang.LabelToNode; +import org.apache.jena.riot.system.AsyncParser; +import org.apache.jena.riot.system.AsyncParserBuilder; +import org.apache.jena.sparql.core.Quad; +import org.apache.jena.vocabulary.RDFS; +import org.apache.jena.vocabulary.VOID; +import org.apache.jena.vocabulary.XSD; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The ultra writer's ingest stage: the same per-quad pipeline as the + * sequential builder (default-graph rewrite, relativize, bnode alignment, + * numeric canonicalization, spatial/feature augmentation, VoID statistics) but + * parallelized on two axes: + * + *

    + *
  • Across documents - each source file parses in its own pool task + * (gzip inflation, tokenization, and per-quad normalization are the real + * wall-clock cost of small-file merges). Blank-node alignment is + * per-document state anyway - labels are document-scoped in RDF - so the + * tasks share nothing but the (thread-safe) VoID accumulator. A + * single-document ingest uses the sequential builder's exact replacement + * labels ({@code b%020d} from 0), keeping single-source output + * structurally identical to the other in-memory writers; a multi-document + * merge prefixes labels with the document ordinal instead (isomorphic + * output, labels differ).
  • + *
  • Across quads - dictionary-set deduplication runs as one parallel + * pass over the collected quad array into concurrent sets, and literal + * statistics are computed chunk-parallel over the DEDUPED literal set with + * the shared {@code countLiteralStats} rules, merged at the end. The + * sequential builder interleaves both with parsing on one thread.
  • + *
+ * + * Extends {@link PositionalDictionaryWriterBuilder} purely to inherit the + * protected per-quad helpers and stay pinned to their single implementation; + * {@code parse()}/{@code build()} are never called. All getters the downstream + * stages use are overridden to expose this class's own collected state. + */ +public final class UltraIngest extends PositionalDictionaryWriterBuilder { + + private static final Logger logger = LoggerFactory.getLogger(UltraIngest.class); + + // Own copies of configuration the base class keeps private + private File usrc; + private final List usources = new ArrayList<>(); + private boolean uspatial = false; + + // Collected state (replaces the base class's single-threaded collections) + private final Set entities = ConcurrentHashMap.newKeySet(); + private final Set predicates = ConcurrentHashMap.newKeySet(); + private final Set literals = ConcurrentHashMap.newKeySet(); + private final Set uniqueGraphs = ConcurrentHashMap.newKeySet(); + private final Set uniqueSubjects = ConcurrentHashMap.newKeySet(); + private final Set uniqueObjects = ConcurrentHashMap.newKeySet(); + private final Set dataTypes = ConcurrentHashMap.newKeySet(); + private final Stats ustats = new Stats(); + private com.ebremer.beakgraph.core.VoidMode uVoidMode = com.ebremer.beakgraph.core.VoidMode.NONE; + private BGVoIDSD xvoid; // null when uVoidMode == NONE + private Quad[] quads; + private long numQuads; + + private record DocResult(ArrayList main, ArrayList extra) {} + + // ------------------------------------------------------------------ + // configuration (capture what the base class hides) + // ------------------------------------------------------------------ + + @Override + public UltraIngest setSource(File src) { + this.usrc = src; + super.setSource(src); + return this; + } + + @Override + public UltraIngest setSources(List files) { + this.usources.clear(); + this.usources.addAll(files); + super.setSources(files); + return this; + } + + @Override + public UltraIngest setSpatial(boolean flag) { + this.uspatial = flag; + super.setSpatial(flag); + return this; + } + + @Override + public UltraIngest setVoidMode(com.ebremer.beakgraph.core.VoidMode mode) { + this.uVoidMode = mode; + super.setVoidMode(mode); + return this; + } + + // ------------------------------------------------------------------ + // overridden state getters + // ------------------------------------------------------------------ + + @Override public Set getEntities() { return entities; } + @Override public Set getPredicates() { return predicates; } + @Override public Set getLiterals() { return literals; } + @Override public Set getUniqueGraphs() { return uniqueGraphs; } + @Override public Set getUniqueSubjects() { return uniqueSubjects; } + @Override public Set getUniqueObjects() { return uniqueObjects; } + @Override public Set getDataTypes() { return dataTypes; } + @Override public Stats getStats() { return ustats; } + @Override public Quad[] getQuads() { return quads; } + @Override public long getNumberOfQuads() { return numQuads; } + + /** Drops the quad array once the index stage has packed its keys. */ + void releaseQuads() { + this.quads = null; + } + + // ------------------------------------------------------------------ + // ingest + // ------------------------------------------------------------------ + + /** + * Runs the full ingest on {@code pool}: documents in parallel, then the + * dedup/stats passes in parallel over the collected quads. On return, + * every getter above reflects the complete parsed state. + */ + public void ingest(ForkJoinPool pool) throws IOException { + if (usources.isEmpty() && usrc == null) { + throw new IllegalStateException("No source set: call setSource() or setSources()"); + } + final List inputs = usources.isEmpty() ? List.of(usrc) : List.copyOf(usources); + final boolean multi = inputs.size() > 1; + this.xvoid = BGVoIDSD.forMode(uVoidMode, "https://ebremer.com/void/"); + final long ingestStart = System.nanoTime(); + logger.info("Ultra ingest: parsing {} source document(s) on {} threads (spatial={})", + inputs.size(), pool.getParallelism(), uspatial); + + // ---- documents, in parallel ---- + List> tasks = new ArrayList<>(inputs.size()); + for (int i = 0; i < inputs.size(); i++) { + final File input = inputs.get(i); + final int docIndex = i; + tasks.add(pool.submit(() -> parseDocument(input, docIndex, multi))); + } + List docs = new ArrayList<>(inputs.size()); + IOException failure = null; + for (int i = 0; i < tasks.size(); i++) { + try { + docs.add(tasks.get(i).get()); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + failure = new IOException("Interrupted while parsing " + inputs.get(i), ex); + break; + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + failure = (cause instanceof IOException io) + ? io + : new IOException("Failed to parse " + inputs.get(i), cause); + break; + } + } + if (failure != null) { + // Let the remaining document tasks finish/fail quietly, then abort. + tasks.forEach(t -> t.cancel(true)); + throw failure; + } + logger.info("All {} document(s) parsed in {} ms", inputs.size(), + (System.nanoTime() - ingestStart) / 1_000_000L); + + // ---- VoID/SD metadata quads over ALL sources (only when requested) ---- + final ArrayList voidQuads = new ArrayList<>(); + if (xvoid != null) { + Model xxx = xvoid.getModel(); + xxx.setNsPrefix("void", VOID.NS); + xxx.setNsPrefix("sd", SD.getURI()); + xxx.setNsPrefix("xsd", XSD.getURI()); + xxx.setNsPrefix("rdfs", RDFS.getURI()); + xxx.setNsPrefix("geo", "http://www.opengis.net/ont/geosparql#"); + xxx.setNsPrefix("prov", "http://www.w3.org/ns/prov#"); + xxx.setNsPrefix("dct", "http://purl.org/dc/terms/"); + xxx.setNsPrefix("hal", "https://halcyon.is/ns/"); + xxx.setNsPrefix("exif", "http://www.w3.org/2003/12/exif/ns#"); + xxx.listStatements().forEach(s -> + voidQuads.add(canonicalizeNumericObject(Quad.create(BGVOID, s.asTriple())))); + logger.info("VoID/SD metadata generated: {} statements", voidQuads.size()); + } + + // ---- assemble the quad array (documents in input order: main quads, + // then that document's spatial/feature quads - the sequential layout) ---- + long parsedTotal = 0; + long total = voidQuads.size(); + for (DocResult d : docs) { + parsedTotal += d.main.size(); + total += d.main.size() + d.extra.size(); + } + if (total > Integer.MAX_VALUE - 16) { + throw new IllegalArgumentException(total + " quads is too large for the in-memory ultra writer"); + } + this.numQuads = parsedTotal; + this.quads = new Quad[(int) total]; + int at = 0; + for (DocResult d : docs) { + for (Quad q : d.main) quads[at++] = q; + for (Quad q : d.extra) quads[at++] = q; + } + for (Quad q : voidQuads) quads[at++] = q; + docs.clear(); + + // ---- one parallel dedup pass over everything ---- + logger.info("Deduplicating {} quads into dictionary sets (parallel)...", quads.length); + long phase = System.nanoTime(); + buildSets(pool); + logger.info("Dictionary sets built in {} ms: {} entities, {} predicates, {} literals, {} datatypes", + (System.nanoTime() - phase) / 1_000_000L, + entities.size(), predicates.size(), literals.size(), dataTypes.size()); + + // ---- statistics from the deduped sets ---- + logger.info("Computing statistics over {} unique literals (parallel)...", literals.size()); + phase = System.nanoTime(); + buildStats(pool); + logger.info("Statistics computed in {} ms", (System.nanoTime() - phase) / 1_000_000L); + logger.info("Ultra ingest complete in {} ms: {} source quads, {} total rows", + (System.nanoTime() - ingestStart) / 1_000_000L, numQuads, quads.length); + } + + /** + * Parses one document. Everything called here is either pure + * ({@code relativize}, {@code canonicalizeNumericObject}), per-document + * ({@code bmap}/counter), or thread-safe ({@code addSpatial}, + * {@code xvoid.add}) - documents never contend. + */ + private DocResult parseDocument(File input, int docIndex, boolean multi) throws IOException { + final ArrayList main = new ArrayList<>(); + final ArrayList extra = new ArrayList<>(); + final HashMap bmap = new HashMap<>(); + final long[] counter = {0}; + // Single document: the sequential builder's exact labels. Merge: the + // document ordinal keeps labels unique across documents with no + // cross-document coordination. + final String labelFormat = multi ? ("b" + docIndex + "_%020d") : "b%020d"; + final long docStart = System.nanoTime(); + try (RdfSources.OpenedSource opened = RdfSources.open(input)) { + AsyncParserBuilder parserBuilder = AsyncParser.of(opened.stream(), opened.lang(), REL_BASE); + parserBuilder.mutateSources(rdfBuilder -> + rdfBuilder.labelToNode(LabelToNode.createUseLabelAsGiven())); + final List>> spatialTasks = new ArrayList<>(); + try (ExecutorService scope = Executors.newVirtualThreadPerTaskExecutor()) { + parserBuilder.streamQuads() + .map(quad -> quad.isDefaultGraph() + ? new Quad(Quad.defaultGraphIRI, quad.getSubject(), quad.getPredicate(), quad.getObject()) + : quad) + .map(this::relativize) + .map(quad -> alignBnodes(quad, bmap, counter, labelFormat)) + .map(this::canonicalizeNumericObject) + .forEach(quad -> { + main.add(quad); + if (main.size() % 100_000 == 0) { + logger.info("{}: loaded {} quads...", input.getName(), main.size()); + } + if (xvoid != null) { + xvoid.add(quad); + } + if (uspatial && isGeoLiteral(quad)) { + spatialTasks.add(scope.submit(() -> addSpatial(quad))); + } + }); + } catch (Exception ex) { + throw new IOException("Failed while parsing/processing RDF source: " + input, ex); + } + for (Future> task : spatialTasks) { + ArrayList extraQuads; + try { + extraQuads = task.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while collecting spatial results: " + input, ex); + } catch (ExecutionException ex) { + throw new IOException("Spatial processing failed for " + input, ex.getCause()); + } + for (Quad q : extraQuads) { + extra.add(canonicalizeNumericObject(q)); + } + } + } catch (FileNotFoundException e) { + throw new IOException("Source file not found: " + input, e); + } catch (IOException e) { + throw new IOException("I/O error while reading RDF source: " + input, e); + } + if (extra.isEmpty()) { + logger.info("Parsed {} quads from {} in {} ms", main.size(), input, + (System.nanoTime() - docStart) / 1_000_000L); + } else { + logger.info("Parsed {} quads (+{} spatial/feature quads) from {} in {} ms", + main.size(), extra.size(), input, (System.nanoTime() - docStart) / 1_000_000L); + } + return new DocResult(main, extra); + } + + /** The base AlignBnodes with per-document map, counter, and label format. */ + private static Quad alignBnodes(Quad quad, HashMap bmap, long[] counter, String labelFormat) { + Node g = quad.getGraph(); + Node s = quad.getSubject(); + Node o = quad.getObject(); + if (!(g.isBlank() || s.isBlank() || o.isBlank())) { + return quad; + } + if (g.isBlank()) g = bmap.computeIfAbsent(g, k -> NodeFactory.createBlankNode(String.format(labelFormat, counter[0]++))); + if (s.isBlank()) s = bmap.computeIfAbsent(s, k -> NodeFactory.createBlankNode(String.format(labelFormat, counter[0]++))); + if (o.isBlank()) o = bmap.computeIfAbsent(o, k -> NodeFactory.createBlankNode(String.format(labelFormat, counter[0]++))); + return new Quad(g, s, quad.getPredicate(), o); + } + + /** One parallel pass: node-kind validation plus insertion into every dictionary set. */ + private void buildSets(ForkJoinPool pool) throws IOException { + final Quad[] all = this.quads; + try { + pool.submit(() -> java.util.Arrays.stream(all).parallel().forEach(quad -> { + Node g = quad.getGraph(); + Node s = quad.getSubject(); + Node p = quad.getPredicate(); + Node o = quad.getObject(); + // Same guards as the sequential ProcessQuad: any other node kind + // (e.g. a triple term) would silently desynchronize the dictionary. + if (!g.isBlank() && !g.isURI()) { + throw new IllegalStateException("Unexpected graph node type (not URI or blank): " + g); + } + if (!s.isBlank() && !s.isURI()) { + throw new IllegalStateException("Unexpected subject node type (not URI or blank): " + s); + } + uniqueGraphs.add(g); + uniqueSubjects.add(s); + uniqueObjects.add(o); + entities.add(g); + entities.add(s); + predicates.add(p); + if (o.isLiteral()) { + literals.add(o); + dataTypes.add(o.getLiteralDatatypeURI()); + } else { + if (!o.isBlank() && !o.isURI()) { + throw new IllegalStateException("Unexpected object node type (not URI, blank, or literal): " + o); + } + entities.add(o); + } + })).get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while building dictionary sets", ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IOException("Failed to build dictionary sets", cause); + } + } + + /** + * Statistics over the DEDUPED sets - semantically identical to the + * sequential builder, which counts each entity/predicate/literal only on + * first encounter. Literal stats run chunk-parallel with partial + * {@link Stats} merged at the end. + */ + private void buildStats(ForkJoinPool pool) throws IOException { + final Node[] ents = entities.toArray(Node[]::new); + final Node[] lits = literals.toArray(Node[]::new); + final int chunks = Math.max(1, Math.min(pool.getParallelism() * 4, lits.length)); + final Stats[] partial = new Stats[chunks]; + final long[] blanks = new long[Math.max(1, chunks)]; + try { + pool.submit(() -> { + java.util.stream.IntStream.range(0, chunks).parallel().forEach(c -> { + Stats st = new Stats(); + int from = (int) ((long) lits.length * c / chunks); + int to = (int) ((long) lits.length * (c + 1) / chunks); + for (int i = from; i < to; i++) { + countLiteralStats(lits[i], st); + } + int efrom = (int) ((long) ents.length * c / chunks); + int eto = (int) ((long) ents.length * (c + 1) / chunks); + long b = 0; + for (int i = efrom; i < eto; i++) { + if (ents[i].isBlank()) b++; + } + partial[c] = st; + blanks[c] = b; + }); + }).get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while computing statistics", ex); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new IOException("Failed to compute statistics", cause); + } + for (Stats st : partial) { + if (st != null) { + mergeLiteralStats(ustats, st); + } + } + long numBlank = 0; + for (long b : blanks) { + numBlank += b; + } + ustats.numBlankNodes = numBlank; + // Sequential accounting: every unique non-blank entity and every unique + // predicate counts as one IRI. + ustats.numIRI = (entities.size() - numBlank) + predicates.size(); + ustats.numGraphs = entities.size(); + ustats.numSubjects = entities.size(); + ustats.numPredicates = predicates.size(); + ustats.numObjects = entities.size() + literals.size(); + } + + private static void mergeLiteralStats(Stats into, Stats part) { + into.maxLong = Math.max(into.maxLong, part.maxLong); + into.minLong = Math.min(into.minLong, part.minLong); + into.numLong += part.numLong; + into.maxInteger = Math.max(into.maxInteger, part.maxInteger); + into.minInteger = Math.min(into.minInteger, part.minInteger); + into.numInteger += part.numInteger; + into.maxFloat = Math.max(into.maxFloat, part.maxFloat); + into.minFloat = Math.min(into.minFloat, part.minFloat); + into.numFloat += part.numFloat; + into.maxDouble = Math.max(into.maxDouble, part.maxDouble); + into.minDouble = Math.min(into.minDouble, part.minDouble); + into.numDouble += part.numDouble; + into.numStrings += part.numStrings; + into.longestStringLength = Math.max(into.longestStringLength, part.longestStringLength); + into.shortestStringLength = Math.min(into.shortestStringLength, part.shortestStringLength); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraPackedBuffer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraPackedBuffer.java new file mode 100644 index 00000000..3349bedd --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraPackedBuffer.java @@ -0,0 +1,101 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import io.jhdf.api.WritableDataset; +import io.jhdf.api.WritableGroup; + +/** + * Positionally-writable twin of the append-only + * {@link com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer} write side, + * for BYTE-ALIGNED widths only. Every S/SB/BB buffer and every columnar id + * list in the store rounds its width up to a multiple of 8 bits, so entry + * {@code i} occupies exactly bytes {@code [i*w/8, (i+1)*w/8)} - no two entries + * share a byte, which is what makes concurrent {@link #set} calls on DISTINCT + * indexes safe with no synchronization at all. That positional independence is + * the enabler for the ultra writer's chunk-parallel index emission and column + * population. + * + *

The serialized bytes are identical to what the sequential buffer's + * MSB-first accumulator produces for the same width (big-endian bytes, no + * trailing partial byte at byte-aligned widths), and {@link #add} writes the + * same dataset shape and {@code width}/{@code numEntries} attributes, so + * readers and structural parity checks cannot tell the writers apart. + */ +final class UltraPackedBuffer { + + private final String name; + private final int bitWidth; + private final int bytesPerEntry; + private final long numEntries; + private final byte[] data; + + UltraPackedBuffer(String name, long numEntries, int bitWidth) { + if (bitWidth < 8 || bitWidth > 64 || (bitWidth % 8) != 0) { + throw new IllegalArgumentException( + "UltraPackedBuffer requires a byte-aligned width in [8,64], got " + bitWidth); + } + if (numEntries < 0) { + throw new IllegalArgumentException("numEntries must be >= 0, got " + numEntries); + } + this.name = name; + this.bitWidth = bitWidth; + this.bytesPerEntry = bitWidth / 8; + this.numEntries = numEntries; + long bytes = numEntries * (long) bytesPerEntry; + if (bytes > Integer.MAX_VALUE - 16) { + // Same ceiling the sequential writer has (its bytes accumulate in a + // ByteArrayOutputStream); sources beyond it need the -huge writer. + throw new IllegalArgumentException( + "Buffer '" + name + "' needs " + bytes + " bytes; too large for the in-memory ultra writer"); + } + this.data = new byte[(int) bytes]; + } + + /** + * Writes entry {@code index}. Safe to call concurrently for distinct + * indexes (entries never share a byte); racing on the SAME index is a bug. + */ + void set(long index, long value) { + if (index < 0 || index >= numEntries) { + throw new IndexOutOfBoundsException("Index " + index + " out of bounds [0, " + numEntries + ")"); + } + // Same fit check as BitPackedUnSignedLongBuffer.writeLong: only width 64 + // carries a negative value's full two's-complement pattern. + if (bitWidth != 64 && (value < 0 || value > ((1L << bitWidth) - 1))) { + throw new IllegalArgumentException("Value " + value + " does not fit in " + bitWidth + " bits"); + } + int off = (int) (index * bytesPerEntry); + for (int b = bytesPerEntry - 1; b >= 0; b--) { + data[off + b] = (byte) value; + value >>>= 8; + } + } + + long getNumEntries() { + return numEntries; + } + + /** Test/verification access: reads entry {@code index} back. */ + long get(long index) { + if (index < 0 || index >= numEntries) { + throw new IndexOutOfBoundsException("Index " + index + " out of bounds [0, " + numEntries + ")"); + } + int off = (int) (index * bytesPerEntry); + long v = 0; + for (int b = 0; b < bytesPerEntry; b++) { + v = (v << 8) | (data[off + b] & 0xFFL); + } + return v; + } + + byte[] bytes() { + return data; + } + + void add(WritableGroup group) { + if (data.length > 0) { + WritableDataset ds = group.putDataset(name, data); + ds.putAttribute("width", bitWidth); + ds.putAttribute("numEntries", numEntries); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/package-info.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/package-info.java new file mode 100644 index 00000000..d1dc606f --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/package-info.java @@ -0,0 +1,38 @@ +/** + * The "ultra" in-memory BeakGraph writer (CLI: {@code -method 3}, thread + * count via {@code -cores}): the same HDF5 output format as + * {@link com.ebremer.beakgraph.hdf5.writers.HDF5Writer}, built as a fully + * parallel dependency DAG instead of a phase pipeline. + * + *

What it parallelizes beyond the {@code -method 2} writer + * ({@code com.ebremer.beakgraph.hdf5.writers.parallel}): + * + *

    + *
  • Parsing - source documents parse concurrently (bnode alignment is + * per-document state by RDF semantics), and dictionary-set deduplication + * plus statistics run as parallel passes over the collected quads + * ({@link com.ebremer.beakgraph.hdf5.writers.ultra.UltraIngest}).
  • + *
  • Id resolution - dictionary ids are 1-based NodeComparator ranks, + * so one shared sort per sub-dictionary feeds the storage encoder AND an + * O(1) node-to-id hash map; no binary search ever runs + * ({@link com.ebremer.beakgraph.hdf5.writers.ultra.UltraDictionary}).
  • + *
  • Index sorting - each quad's four ids pack into one primitive key + * per ordering (one or two words), sorted by a parallel LSD radix sort or + * the JDK primitive sort; duplicates collapse once and GPOS reuses the + * deduplicated GSPO set + * ({@link com.ebremer.beakgraph.hdf5.writers.ultra.ParallelRadixSort}, + * {@link com.ebremer.beakgraph.hdf5.writers.ultra.UltraBGIndex}).
  • + *
  • Index emission - the level-emission scan becomes two parallel + * passes (count, prefix-sum, positional write) over byte-aligned buffers + * and an atomically-OR-merged bitmap; the SB/BB rank directories fall out + * of word popcount prefix sums.
  • + *
+ * + * All work runs on one dedicated {@code ForkJoinPool} of {@code -cores} + * threads, so with {@code -threads N} in the CLI, N conversions run at once, + * each capped at its own core budget. Given the same parsed quads the emitted + * buffers are byte-identical to the sequential writer's; whole files differ + * only through VoID's per-write random blank-node labels (single source) or + * per-document blank-node scoping (merged sources). + */ +package com.ebremer.beakgraph.hdf5.writers.ultra; diff --git a/src/main/java/com/ebremer/beakgraph/huge/ExternalSorter.java b/src/main/java/com/ebremer/beakgraph/huge/ExternalSorter.java index 829bd96e..b17fe477 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/ExternalSorter.java +++ b/src/main/java/com/ebremer/beakgraph/huge/ExternalSorter.java @@ -35,23 +35,17 @@ * * @author Erich Bremer */ -final class ExternalSorter implements AutoCloseable { +public final class ExternalSorter implements RecordSorter { private static final Logger logger = LoggerFactory.getLogger(ExternalSorter.class); /** Serializes records for spill runs. {@code read} throws {@link EOFException} at run end. */ - interface Codec { + public interface Codec { void write(DataOutput out, T record) throws IOException; T read(DataInput in) throws IOException; } - /** A sorted record stream that owns temp files; must be closed. */ - interface SortedStream extends Iterator, AutoCloseable { - @Override - void close(); - } - private final Path workDir; private final String tag; private final Codec codec; @@ -64,8 +58,8 @@ interface SortedStream extends Iterator, AutoCloseable { private int runCounter = 0; private boolean consumed = false; - ExternalSorter(Path workDir, String tag, Codec codec, Comparator comparator, - int maxRecordsInMemory, int mergeFanIn) { + public ExternalSorter(Path workDir, String tag, Codec codec, Comparator comparator, + int maxRecordsInMemory, int mergeFanIn) { if (maxRecordsInMemory < 1 || mergeFanIn < 2) { throw new IllegalArgumentException("maxRecordsInMemory >= 1 and mergeFanIn >= 2 required"); } @@ -77,7 +71,8 @@ interface SortedStream extends Iterator, AutoCloseable { this.mergeFanIn = mergeFanIn; } - void add(T record) throws IOException { + @Override + public void add(T record) throws IOException { if (consumed) { throw new IllegalStateException("Sorter '" + tag + "' already consumed"); } @@ -89,7 +84,8 @@ void add(T record) throws IOException { } /** Total records added. */ - long size() { + @Override + public long size() { return size; } @@ -121,7 +117,8 @@ private void spillRun() throws IOException { * Finishes ingestion and returns the fully sorted stream. All-in-RAM inputs * (no spilled run) sort and iterate without touching disk. */ - SortedStream sorted() throws IOException { + @Override + public RecordSorter.SortedCursor sorted() throws IOException { if (consumed) { throw new IllegalStateException("Sorter '" + tag + "' already consumed"); } @@ -130,7 +127,7 @@ SortedStream sorted() throws IOException { T[] arr = sortedBufferArray(); buffer = new ArrayList<>(); Iterator it = Arrays.asList(arr).iterator(); - return new SortedStream() { + return new RecordSorter.SortedCursor() { @Override public boolean hasNext() { return it.hasNext(); } @Override public T next() { return it.next(); } @Override public void close() {} @@ -199,7 +196,7 @@ void closeAndDelete() { } } - private final class MergeIterator implements SortedStream { + private final class MergeIterator implements RecordSorter.SortedCursor { private final PriorityQueue heap; private final List readers = new ArrayList<>(); diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java b/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java index 5c8bfdc1..35069145 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java @@ -10,11 +10,10 @@ import com.ebremer.beakgraph.huge.HugeRecords.RowId; import com.ebremer.beakgraph.huge.HugeRecords.TermRow; import static com.ebremer.beakgraph.utils.UTIL.MinBits; +import com.ebremer.beakgraph.utils.RdfSources; import com.ebremer.ns.GEO; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; -import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.file.Path; import java.util.ArrayDeque; @@ -32,13 +31,10 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.zip.GZIPInputStream; import org.apache.jena.graph.Node; import org.apache.jena.graph.NodeFactory; import org.apache.jena.graph.Triple; import org.apache.jena.irix.IRIx; -import org.apache.jena.riot.Lang; -import org.apache.jena.riot.RDFLanguages; import org.apache.jena.riot.lang.LabelToNode; import org.apache.jena.riot.system.AsyncParser; import org.apache.jena.riot.system.AsyncParserBuilder; @@ -68,21 +64,22 @@ * * @author Erich Bremer */ -final class HugeBuildPipeline implements AutoCloseable { +public final class HugeBuildPipeline implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(HugeBuildPipeline.class); // Same sentinel base as the RAM builder: relative references resolve against // it during parsing and are stripped back to relative form for storage. - private static final String REL_BASE = "http://beakgraph.invalid/document"; + // Public: parallel-ingest implementations must parse against the same base. + public static final String REL_BASE = "http://beakgraph.invalid/document"; private static final String REL_BASE_PREFIX = "http://beakgraph.invalid/"; private static final IRIx REL_BASE_IRIX = IRIx.create(REL_BASE); - private final File src; + /** Source documents; more than one means a -merge build into a single store. */ + private final List sources; private final boolean spatial; private final boolean features; private final Path workDir; - private final int mergeFanIn; // ---- Pass A state ---- private final Stats stats = new Stats(); @@ -93,41 +90,139 @@ final class HugeBuildPipeline implements AutoCloseable { // and final rank ids once the set is complete. private final HashMap predTempIds = new HashMap<>(); private final ArrayList predByTempId = new ArrayList<>(); - private final BGVoIDSD xvoid = new BGVoIDSD("https://ebremer.com/void/"); - private ExternalSorter gSorter; - private ExternalSorter sSorter; - private ExternalSorter oSorter; + /** Null when voidMode == NONE (the default): no statistics graph is written. */ + private final BGVoIDSD xvoid; + /** + * A pluggable Pass A: parse the sources with whatever concurrency the + * implementation likes, delivering TRANSFORMED quads (default-graph + * rewrite, bnode scoping, relativize, numeric canonicalization, + * spatial/feature augmentation already applied - the public static + * helpers on this class plus {@link SpatialAugmenter} are the shared + * implementations) in batches to the sink. The sink is the pipeline's one + * serial section: it assigns row numbers and feeds the sorters, keeping + * the positional predicate column's entry-i-equals-row-i invariant + * without any post-sort. The plaid writer (-method 5) plugs in per-file + * parallel parsing here. + */ + public interface ParallelIngest { + void run(List sources, boolean spatial, boolean features, + BGVoIDSD voidStats, BatchSink sink) throws IOException; + + interface BatchSink { + /** + * Appends a batch of rows to the store. {@code sourceQuads} = + * how many of them were parsed from a document (vs derived + * spatial/feature quads); drives the numQuads attribute and + * progress logging. Thread-safe; callers may commit concurrently. + */ + void commit(List batch, long sourceQuads) throws IOException; + } + } + + private RecordSorter gSorter; + private RecordSorter sSorter; + private RecordSorter oSorter; private RecordFile pTempFile; - private final int idSpillBatch; + private final SorterProvider provider; + /** Non-null: independent stage groups run concurrently on it (-method 4). */ + private final java.util.concurrent.ExecutorService stagePool; + /** Non-null: Pass A runs through it instead of the sequential loop (-method 5). */ + private final ParallelIngest parallelIngest; private long rows = 0; private long parsedQuads = 0; // ---- Build products (owned; released in close()) ---- private final List resources = new ArrayList<>(); - HugeBuildPipeline(File src, boolean spatial, boolean features, Path workDir, + HugeBuildPipeline(List sources, boolean spatial, boolean features, + com.ebremer.beakgraph.core.VoidMode voidMode, Path workDir, int termSpillBatch, int idSpillBatch, int mergeFanIn) { - this.src = src; + this(sources, spatial, features, voidMode, workDir, + SorterProvider.sequential(termSpillBatch, idSpillBatch, mergeFanIn), null, null); + } + + /** + * The injectable form: {@code provider} supplies every sorter the build + * uses, and a non-null {@code stagePool} runs independent stage groups + * (column materializations, dictionary encodes, id joins) concurrently. + * This is how the hugeUltra writer (-method 4) turbocharges the pipeline + * without changing its flow or output. + */ + public HugeBuildPipeline(List sources, boolean spatial, boolean features, + com.ebremer.beakgraph.core.VoidMode voidMode, Path workDir, + SorterProvider provider, java.util.concurrent.ExecutorService stagePool) { + this(sources, spatial, features, voidMode, workDir, provider, stagePool, null); + } + + public HugeBuildPipeline(List sources, boolean spatial, boolean features, + com.ebremer.beakgraph.core.VoidMode voidMode, Path workDir, + SorterProvider provider, java.util.concurrent.ExecutorService stagePool, + ParallelIngest parallelIngest) { + if (sources.isEmpty()) { + throw new IllegalArgumentException("At least one source document is required"); + } + this.sources = List.copyOf(sources); this.spatial = spatial; this.features = features; + this.xvoid = BGVoIDSD.forMode(voidMode, "https://ebremer.com/void/"); this.workDir = workDir; - this.mergeFanIn = mergeFanIn; - this.gSorter = track(new ExternalSorter<>(workDir, "gcol", HugeRecords.TERM_ROW_CODEC, - HugeRecords.TERM_ORDER, termSpillBatch, mergeFanIn)); - this.sSorter = track(new ExternalSorter<>(workDir, "scol", HugeRecords.TERM_ROW_CODEC, - HugeRecords.TERM_ORDER, termSpillBatch, mergeFanIn)); - this.oSorter = track(new ExternalSorter<>(workDir, "ocol", HugeRecords.TERM_ROW_CODEC, - HugeRecords.TERM_ORDER, termSpillBatch, mergeFanIn)); - this.idSpillBatch = idSpillBatch; + this.provider = provider; + this.stagePool = stagePool; + this.parallelIngest = parallelIngest; + this.gSorter = track(provider.termSorter(workDir, "gcol")); + this.sSorter = track(provider.termSorter(workDir, "scol")); + this.oSorter = track(provider.termSorter(workDir, "ocol")); } - private T track(T resource) { + // Synchronized: concurrent stages register their resources too. + private synchronized T track(T resource) { resources.add(resource); return resource; } + @FunctionalInterface + private interface Stage { + void run() throws IOException; + } + + /** + * Runs independent stages sequentially (no pool: -method 1 behaviour, + * bounded RAM) or concurrently on the stage pool (-method 4). Every stage + * is awaited before returning; the first failure wins. + */ + private void runStages(String what, Stage... stages) throws IOException { + if (stagePool == null) { + for (Stage s : stages) { + s.run(); + } + return; + } + List> futures = new ArrayList<>(stages.length); + for (Stage s : stages) { + futures.add(stagePool.submit(() -> { + s.run(); + return null; + })); + } + IOException first = null; + for (java.util.concurrent.Future f : futures) { + try { + f.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + if (first == null) first = new IOException("Interrupted during " + what, ex); + } catch (ExecutionException ex) { + Throwable c = ex.getCause(); + if (first == null) { + first = (c instanceof IOException io) ? io : new IOException(what + " failed", c); + } + } + } + if (first != null) throw first; + } + /** Runs the whole build, producing the finished HDF5 file at {@code tmpH5}. */ - void run(Path tmpH5) throws IOException { + public void run(Path tmpH5) throws IOException { ingest(); // ---- Predicates: final rank ids from the in-RAM population ---- @@ -146,13 +241,20 @@ void run(Path tmpH5) throws IOException { } // ---- Materialize the sorted columns (each is re-read several times) ---- - logger.info("Sorting {} rows per column (external merge sort)...", rows); - RecordFile gSorted = materialize(gSorter, "gcol.sorted"); + logger.info("Sorting {} rows per column (external merge sort{})...", rows, + stagePool == null ? "" : ", 3 columns concurrently"); + final List> cols = Arrays.asList(null, null, null); + final RecordSorter gS = gSorter, sS = sSorter, oS = oSorter; gSorter = null; - RecordFile sSorted = materialize(sSorter, "scol.sorted"); sSorter = null; - RecordFile oSorted = materialize(oSorter, "ocol.sorted"); oSorter = null; + runStages("column sort", + () -> cols.set(0, materialize(gS, "gcol.sorted")), + () -> cols.set(1, materialize(sS, "scol.sorted")), + () -> cols.set(2, materialize(oS, "ocol.sorted"))); + RecordFile gSorted = cols.get(0); + RecordFile sSorted = cols.get(1); + RecordFile oSorted = cols.get(2); // ---- Distinct sorted dictionaries on disk ---- RecordFile entFile = new RecordFile<>(workDir.resolve("entities.sorted"), NodeCodec.INSTANCE); @@ -166,30 +268,38 @@ void run(Path tmpH5) throws IOException { logger.info("Dictionary populations: {} entities, {} predicates, {} literals", numEntities, numPredicates, numLiterals); - // ---- Encode the three dictionary sections ---- - StreamingDictionaryWriter entitiesDict = null; - StreamingDictionaryWriter predicatesDict = null; - StreamingDictionaryWriter literalsDict = null; - if (numEntities > 0) { - entitiesDict = track(new StreamingDictionaryWriter(workDir, "entities", numEntities, stats, - Set.of(Types.IRI, Types.BNODE), new TreeSet<>(), new TreeSet<>())); - try (var s = entFile.read()) { - entitiesDict.encode(s); - } - } - if (numPredicates > 0) { - predicatesDict = track(new StreamingDictionaryWriter(workDir, "predicates", numPredicates, stats, - Set.of(Types.IRI), new TreeSet<>(), new TreeSet<>())); - predicatesDict.encode(Arrays.asList(sortedPreds).iterator()); - } - if (numLiterals > 0) { - literalsDict = track(new StreamingDictionaryWriter(workDir, "literals", numLiterals, stats, - Set.of(Types.DOUBLE, Types.FLOAT, Types.LONG, Types.INTEGER, Types.STRING), - dataTypes, langSet)); - try (var s = litFile.read()) { - literalsDict.encode(s); - } - } + // ---- Encode the three dictionary sections (independent; concurrent with a pool) ---- + final StreamingDictionaryWriter[] dicts = new StreamingDictionaryWriter[3]; + runStages("dictionary encode", + () -> { + if (numEntities > 0) { + dicts[0] = track(new StreamingDictionaryWriter(workDir, "entities", numEntities, stats, + Set.of(Types.IRI, Types.BNODE), new TreeSet<>(), new TreeSet<>())); + try (var s = entFile.read()) { + dicts[0].encode(s); + } + } + }, + () -> { + if (numPredicates > 0) { + dicts[1] = track(new StreamingDictionaryWriter(workDir, "predicates", numPredicates, stats, + Set.of(Types.IRI), new TreeSet<>(), new TreeSet<>())); + dicts[1].encode(Arrays.asList(sortedPreds).iterator()); + } + }, + () -> { + if (numLiterals > 0) { + dicts[2] = track(new StreamingDictionaryWriter(workDir, "literals", numLiterals, stats, + Set.of(Types.DOUBLE, Types.FLOAT, Types.LONG, Types.INTEGER, Types.STRING), + dataTypes, langSet)); + try (var s = litFile.read()) { + dicts[2].encode(s); + } + } + }); + StreamingDictionaryWriter entitiesDict = dicts[0]; + StreamingDictionaryWriter predicatesDict = dicts[1]; + StreamingDictionaryWriter literalsDict = dicts[2]; // ---- Columnar unique-id lists (same widths as PositionalDictionaryWriter) ---- int gBits = (int) (Math.ceil(MinBits(numEntities + 1) / 8.0) * 8); @@ -199,44 +309,43 @@ void run(Path tmpH5) throws IOException { SpillBitPackedBuffer subjectsList = track(new SpillBitPackedBuffer(workDir.resolve("columnar.subjects"), sBits)); SpillBitPackedBuffer objectsList = track(new SpillBitPackedBuffer(workDir.resolve("columnar.objects"), oBits)); - // ---- Sort-merge id joins ---- - logger.info("Assigning ids (sort-merge joins)..."); - ExternalSorter gIds = track(new ExternalSorter<>(workDir, "gid", HugeRecords.ROW_ID_CODEC, - HugeRecords.ROW_ORDER, idSpillBatch, mergeFanIn)); - joinColumn(gSorted, "Graph", entityCursor(entFile, null, numEntities), graphsList, gIds); + // ---- Sort-merge id joins (independent columns; concurrent with a pool), + // plus the predicate temp->final remap, which depends on nothing here ---- + logger.info("Assigning ids (sort-merge joins{})...", stagePool == null ? "" : ", 3 columns concurrently"); + RecordSorter gIds = track(provider.rowIdSorter(workDir, "gid", rows, numObjects)); + RecordSorter sIds = track(provider.rowIdSorter(workDir, "sid", rows, numObjects)); + RecordSorter oIds = track(provider.rowIdSorter(workDir, "oid", rows, numObjects)); + RecordFile pFinal = new RecordFile<>(workDir.resolve("pcol.final"), HugeRecords.VAR_LONG_CODEC); + runStages("id join", + () -> joinColumn(gSorted, "Graph", entityCursor(entFile, null, numEntities), graphsList, gIds), + () -> joinColumn(sSorted, "Subject", entityCursor(entFile, null, numEntities), subjectsList, sIds), + () -> joinColumn(oSorted, "Object", entityCursor(entFile, litFile, numEntities), objectsList, oIds), + () -> { + try (var s = pTempFile.read()) { + while (s.hasNext()) { + pFinal.append(tempToFinal[Math.toIntExact(s.next())]); + } + } + pFinal.finish(); + }); gSorted.delete(); - - ExternalSorter sIds = track(new ExternalSorter<>(workDir, "sid", HugeRecords.ROW_ID_CODEC, - HugeRecords.ROW_ORDER, idSpillBatch, mergeFanIn)); - joinColumn(sSorted, "Subject", entityCursor(entFile, null, numEntities), subjectsList, sIds); sSorted.delete(); - - ExternalSorter oIds = track(new ExternalSorter<>(workDir, "oid", HugeRecords.ROW_ID_CODEC, - HugeRecords.ROW_ORDER, idSpillBatch, mergeFanIn)); - joinColumn(oSorted, "Object", entityCursor(entFile, litFile, numEntities), objectsList, oIds); oSorted.delete(); entFile.delete(); litFile.delete(); - - // ---- Predicate column: temp ids -> final ids, already in row order ---- - RecordFile pFinal = new RecordFile<>(workDir.resolve("pcol.final"), HugeRecords.VAR_LONG_CODEC); - try (var s = pTempFile.read()) { - while (s.hasNext()) { - pFinal.append(tempToFinal[Math.toIntExact(s.next())]); - } - } - pFinal.finish(); pTempFile.delete(); if (pFinal.count() != rows) { throw new IllegalStateException("Predicate column has " + pFinal.count() + " entries for " + rows + " rows"); } - // ---- Zip id columns into encoded quads, feeding both index sorters ---- + // ---- Zip id columns into encoded quads. Only GSPO sorts the raw + // (duplicate-laden) quads: the GSPO scan below tees each DISTINCT quad + // into the GPOS sorter, so GPOS sorts the deduplicated set only ---- logger.info("Encoding {} quads...", rows); - ExternalSorter gspoSorter = track(new ExternalSorter<>(workDir, "gspo", HugeRecords.ID_QUAD_CODEC, - HugeRecords.GSPO_ORDER, idSpillBatch, mergeFanIn)); - ExternalSorter gposSorter = track(new ExternalSorter<>(workDir, "gpos", HugeRecords.ID_QUAD_CODEC, - HugeRecords.GPOS_ORDER, idSpillBatch, mergeFanIn)); + RecordSorter gspoSorter = track(provider.quadSorter(workDir, "gspo", Index.GSPO, + numEntities, numPredicates, numObjects)); + RecordSorter gposSorter = track(provider.quadSorter(workDir, "gpos", Index.GPOS, + numEntities, numPredicates, numObjects)); try (var gs = gIds.sorted(); var ss = sIds.sorted(); var os = oIds.sorted(); var ps = pFinal.read()) { for (long row = 0; row < rows; row++) { RowId g = gs.next(); @@ -247,9 +356,7 @@ void run(Path tmpH5) throws IOException { throw new IllegalStateException("Row misalignment at " + row + ": g=" + g.row() + " s=" + s.row() + " o=" + o.row()); } - IdQuad q = new IdQuad(g.id(), s.id(), p, o.id()); - gspoSorter.add(q); - gposSorter.add(q); + gspoSorter.add(new IdQuad(g.id(), s.id(), p, o.id())); } } pFinal.delete(); @@ -258,7 +365,7 @@ void run(Path tmpH5) throws IOException { HugeIndexWriter gspo = track(new HugeIndexWriter(workDir, Index.GSPO, numEntities, numEntities, numPredicates, numObjects, rows)); try (var it = gspoSorter.sorted()) { - gspo.build(it); + gspo.build(teeDistinctTo(it, gposSorter)); } HugeIndexWriter gpos = track(new HugeIndexWriter(workDir, Index.GPOS, numEntities, numEntities, numPredicates, numObjects, rows)); @@ -292,17 +399,65 @@ void run(Path tmpH5) throws IOException { // ------------------------------------------------------------------ private void ingest() throws IOException { - logger.info("Parsing {} (disk-based build)", src); this.pTempFile = new RecordFile<>(workDir.resolve("pcol.tmpids"), HugeRecords.VAR_LONG_CODEC); - try (InputStream xis = src.toString().endsWith(".gz") - ? new GZIPInputStream(new FileInputStream(src)) - : new FileInputStream(src)) { - String fname = src.getName(); - if (fname.endsWith(".gz")) { - fname = fname.substring(0, fname.length() - 3); + if (parallelIngest != null) { + parallelIngest.run(sources, spatial, features, xvoid, this::commitBatch); + finishIngest(); + return; + } + for (int i = 0; i < sources.size(); i++) { + // Blank-node labels are document-scoped in RDF: two merged sources + // may both say _:b0 and mean different nodes (labels are parsed as + // given). Prefixing every label with the source ordinal keeps + // documents from colliding WITHOUT the unbounded RAM map the RAM + // writer's AlignBnodes uses. The prefix never reaches the output: + // bnodes are stored by dictionary rank and readers regenerate + // labels from ids. Single-source builds stay untouched. + ingestSource(sources.get(i), sources.size() > 1 ? (i + "/") : null); + } + finishIngest(); + } + + /** VoID/SD metadata quads over ALL sources (when requested), then the p-column seal. */ + private void finishIngest() throws IOException { + if (xvoid != null) { + // (mirrors the RAM writer; the model's namespace prefixes are + // presentation-only and irrelevant to quads) + for (Iterator it = xvoid.getModel().listStatements(); it.hasNext(); ) { + Triple ff = it.next().asTriple(); + Quad qqq = canonicalizeNumericObject(Quad.create(Params.BGVOID, ff)); + acceptRow(qqq); } - Lang lang = RDFLanguages.filenameToLang(fname, Lang.TURTLE); - AsyncParserBuilder parserBuilder = AsyncParser.of(xis, lang, REL_BASE); + } + pTempFile.finish(); + logger.info("Parse complete: {} source quads, {} total rows", parsedQuads, rows); + } + + /** + * The parallel-ingest sink: the ONE serial section of Pass A. Row numbers, + * the positional predicate column, statistics, and the sorter buffers all + * advance under this lock, so every single-threaded invariant of + * {@link #acceptRow} holds unchanged; spill sorting/writing still happens + * on background workers, so the lock is held only for buffer appends. + */ + private synchronized void commitBatch(List batch, long sourceQuads) throws IOException { + for (Quad q : batch) { + acceptRow(q); + } + long before = parsedQuads; + parsedQuads += sourceQuads; + if (before / 1_000_000 != parsedQuads / 1_000_000) { + logger.info("Loaded {} quads...", parsedQuads); + } + } + + private void ingestSource(File input, String bnodeScope) throws IOException { + logger.info("Parsing {} (disk-based build)", input); + // Syntax from the file name (TriG, N-Quads, N-Triples, RDF/XML, JSON-LD, + // Turtle); .gz and .zip are decompressed transparently - one shared rule + // with the RAM writer and the CLI filter (RdfSources). + try (RdfSources.OpenedSource opened = RdfSources.open(input)) { + AsyncParserBuilder parserBuilder = AsyncParser.of(opened.stream(), opened.lang(), REL_BASE); parserBuilder.mutateSources(rdfBuilder -> rdfBuilder.labelToNode(LabelToNode.createUseLabelAsGiven())); SpatialAugmenter augmenter = new SpatialAugmenter(features); @@ -316,8 +471,9 @@ private void ingest() throws IOException { .map(quad -> quad.isDefaultGraph() ? new Quad(Quad.defaultGraphIRI, quad.getSubject(), quad.getPredicate(), quad.getObject()) : quad) - .map(this::relativize) - .map(this::canonicalizeNumericObject) + .map(quad -> scopeBlankNodes(quad, bnodeScope)) + .map(HugeBuildPipeline::relativize) + .map(HugeBuildPipeline::canonicalizeNumericObject) .forEach(quad -> { parsedQuads++; if (parsedQuads % 1_000_000 == 0) { @@ -325,11 +481,13 @@ private void ingest() throws IOException { } try { acceptRow(quad); - xvoid.add(quad); + if (xvoid != null) { + xvoid.add(quad); + } if (spatial && isGeoLiteral(quad)) { inFlight.add(scope.submit(() -> augmenter.addSpatial(quad))); while (inFlight.size() >= maxInFlight) { - drainOne(inFlight); + drainOne(inFlight, input); } } } catch (IOException ex) { @@ -337,26 +495,35 @@ private void ingest() throws IOException { } }); while (!inFlight.isEmpty()) { - drainOne(inFlight); + drainOne(inFlight, input); } } catch (UncheckedIOException ex) { - throw new IOException("Failed while parsing/processing RDF source: " + src, ex.getCause()); + throw new IOException("Failed while parsing/processing RDF source: " + input, ex.getCause()); } catch (Exception ex) { - throw new IOException("Failed while parsing/processing RDF source: " + src, ex); - } - // VoID/SD metadata quads (mirrors the RAM writer; the model's - // namespace prefixes are presentation-only and irrelevant to quads). - for (Iterator it = xvoid.getModel().listStatements(); it.hasNext(); ) { - Triple ff = it.next().asTriple(); - Quad qqq = canonicalizeNumericObject(Quad.create(Params.BGVOID, ff)); - acceptRow(qqq); + throw new IOException("Failed while parsing/processing RDF source: " + input, ex); } } - pTempFile.finish(); - logger.info("Parse complete: {} source quads, {} total rows", parsedQuads, rows); } - private void drainOne(ArrayDeque>> inFlight) throws IOException { + /** See ingest(): per-document blank-node scoping for -merge builds; no-op when scope is null. */ + public static Quad scopeBlankNodes(Quad q, String scope) { + if (scope == null) { + return q; + } + Node g = scopeNode(q.getGraph(), scope); + Node s = scopeNode(q.getSubject(), scope); + Node o = scopeNode(q.getObject(), scope); + if (g == q.getGraph() && s == q.getSubject() && o == q.getObject()) { + return q; + } + return new Quad(g, s, q.getPredicate(), o); + } + + private static Node scopeNode(Node n, String scope) { + return n.isBlank() ? NodeFactory.createBlankNode(scope + n.getBlankNodeLabel()) : n; + } + + private void drainOne(ArrayDeque>> inFlight, File input) throws IOException { Future> task = inFlight.poll(); if (task == null) return; ArrayList extraQuads; @@ -364,9 +531,9 @@ private void drainOne(ArrayDeque>> inFlight) throws IOExc extraQuads = task.get(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted while collecting spatial results: " + src, ex); + throw new IOException("Interrupted while collecting spatial results: " + input, ex); } catch (ExecutionException ex) { - throw new IOException("Spatial processing failed for " + src, ex.getCause()); + throw new IOException("Spatial processing failed for " + input, ex.getCause()); } for (Quad q : extraQuads) { acceptRow(canonicalizeNumericObject(q)); @@ -488,7 +655,7 @@ private static boolean isGeoLiteral(Quad quad) { // Quad transforms (mirrors of PositionalDictionaryWriterBuilder) // ------------------------------------------------------------------ - private Quad relativize(Quad q) { + public static Quad relativize(Quad q) { Node qg = q.getGraph(); Node qs = q.getSubject(); Node qp = q.getPredicate(); @@ -503,7 +670,7 @@ private Quad relativize(Quad q) { return new Quad(g, s, p, o); } - private Node relativizeNode(Node n) { + private static Node relativizeNode(Node n) { if (n == null || !n.isURI() || !n.getURI().startsWith(REL_BASE_PREFIX)) { return n; } @@ -520,7 +687,7 @@ private Node relativizeNode(Node n) { u.equals(REL_BASE) ? "" : u.substring(REL_BASE_PREFIX.length())); } - private Quad canonicalizeNumericObject(Quad quad) { + public static Quad canonicalizeNumericObject(Quad quad) { Node o = quad.getObject(); if (!o.isLiteral()) return quad; String dt = o.getLiteralDatatypeURI(); @@ -546,9 +713,40 @@ private Quad canonicalizeNumericObject(Quad quad) { // Phase helpers // ------------------------------------------------------------------ - private RecordFile materialize(ExternalSorter sorter, String name) throws IOException { + /** + * Forwards {@code sorted} unchanged while adding each DISTINCT quad to + * {@code alsoDistinct} (duplicates are consecutive in a sorted stream). + * This is the dedup-once trick: GPOS receives exactly the unique quads the + * GSPO scan keeps, so it never sorts a duplicate. + */ + private static Iterator teeDistinctTo(Iterator sorted, RecordSorter alsoDistinct) { + return new Iterator<>() { + private IdQuad prev = null; + + @Override + public boolean hasNext() { + return sorted.hasNext(); + } + + @Override + public IdQuad next() { + IdQuad q = sorted.next(); + if (prev == null || !q.equals(prev)) { + try { + alsoDistinct.add(q); + } catch (IOException e) { + throw new UncheckedIOException("Failed to tee distinct quad into GPOS sorter", e); + } + prev = q; + } + return q; + } + }; + } + + private RecordFile materialize(RecordSorter sorter, String name) throws IOException { RecordFile rf = new RecordFile<>(workDir.resolve(name), HugeRecords.TERM_ROW_CODEC); - try (ExternalSorter.SortedStream s = sorter.sorted()) { + try (RecordSorter.SortedCursor s = sorter.sorted()) { while (s.hasNext()) { rf.append(s.next()); } @@ -727,7 +925,7 @@ public void close() { * ascending id order - into the columnar list. */ private void joinColumn(RecordFile column, String role, DictCursor cursor, - SpillBitPackedBuffer columnar, ExternalSorter out) throws IOException { + SpillBitPackedBuffer columnar, RecordSorter out) throws IOException { try (cursor; var col = column.read()) { Node prevTerm = null; long prevId = -1; diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java index 30201611..b26fe803 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java @@ -3,6 +3,7 @@ import com.ebremer.beakgraph.Params; import com.ebremer.beakgraph.core.AbstractGraphBuilder; import com.ebremer.beakgraph.core.BeakGraphWriter; +import java.io.File; import java.io.IOException; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.FileVisitResult; @@ -11,6 +12,7 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +41,10 @@ * node ordering (labels are kept as parsed rather than relabelled), which * yields an isomorphic - not byte-identical - store. * + *

Like the other writers, {@code setSources(List)} merges several source + * documents into the one store being written (-merge), with blank nodes kept + * distinct per document. + * * @author Erich Bremer */ public class HugeHDF5Writer implements BeakGraphWriter { @@ -61,9 +67,14 @@ public void write() throws IOException { Path workBase = (builder.workDir != null) ? builder.workDir : (dest.toAbsolutePath().getParent() != null ? dest.toAbsolutePath().getParent() : Path.of(".")); Path workspace = Files.createTempDirectory(workBase, ".bghuge-"); + // -merge: every source in the list is parsed into this one store + // (blank nodes stay distinct per document); otherwise the single src. + List inputs = builder.getSources().isEmpty() + ? List.of(builder.getSource()) + : builder.getSources(); try { try (HugeBuildPipeline pipeline = new HugeBuildPipeline( - builder.getSource(), builder.getSpatial(), builder.getFeatures(), + inputs, builder.getSpatial(), builder.getFeatures(), builder.getVoidMode(), workspace, builder.termSpillBatch, builder.idSpillBatch, builder.mergeFanIn)) { pipeline.run(tmp); } diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeIO.java b/src/main/java/com/ebremer/beakgraph/huge/HugeIO.java index a62e20f8..b46e881e 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeIO.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeIO.java @@ -15,7 +15,7 @@ * * @author Erich Bremer */ -final class HugeIO { +public final class HugeIO { /** Copy buffer for temp-file-to-HDF5 streaming; bounds writer RAM per transfer. */ static final int TRANSFER_BUFFER_BYTES = 8 * 1024 * 1024; @@ -34,7 +34,7 @@ static void copyFileIntoDataset(Path file, StreamingHdf5Dataset ds) throws IOExc } /** Unsigned LEB128-style varint (7 bits per byte, high bit = continuation). */ - static void writeVarLong(DataOutput out, long value) throws IOException { + public static void writeVarLong(DataOutput out, long value) throws IOException { if (value < 0) { throw new IllegalArgumentException("varint value must be non-negative: " + value); } @@ -45,7 +45,7 @@ static void writeVarLong(DataOutput out, long value) throws IOException { out.writeByte((int) value); } - static long readVarLong(DataInput in) throws IOException { + public static long readVarLong(DataInput in) throws IOException { long result = 0; int shift = 0; while (true) { @@ -71,3 +71,4 @@ static byte[] readBytes(DataInput in, int len) throws IOException { return data; } } + diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeRecords.java b/src/main/java/com/ebremer/beakgraph/huge/HugeRecords.java index 8dc09473..cbde5032 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeRecords.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeRecords.java @@ -22,35 +22,35 @@ * * @author Erich Bremer */ -final class HugeRecords { +public final class HugeRecords { private HugeRecords() {} - record TermRow(Node term, long row) {} + public record TermRow(Node term, long row) {} - record RowId(long row, long id) {} + public record RowId(long row, long id) {} - record IdQuad(long g, long s, long p, long o) {} + public record IdQuad(long g, long s, long p, long o) {} /** Term order only; equal terms may interleave rows arbitrarily (the join maps them to one id). */ - static final Comparator TERM_ORDER = + public static final Comparator TERM_ORDER = (a, b) -> NodeComparator.INSTANCE.compare(a.term(), b.term()); - static final Comparator ROW_ORDER = Comparator.comparingLong(RowId::row); + public static final Comparator ROW_ORDER = Comparator.comparingLong(RowId::row); - static final Comparator GSPO_ORDER = Comparator + public static final Comparator GSPO_ORDER = Comparator .comparingLong(IdQuad::g) .thenComparingLong(IdQuad::s) .thenComparingLong(IdQuad::p) .thenComparingLong(IdQuad::o); - static final Comparator GPOS_ORDER = Comparator + public static final Comparator GPOS_ORDER = Comparator .comparingLong(IdQuad::g) .thenComparingLong(IdQuad::p) .thenComparingLong(IdQuad::o) .thenComparingLong(IdQuad::s); - static final ExternalSorter.Codec TERM_ROW_CODEC = new ExternalSorter.Codec<>() { + public static final ExternalSorter.Codec TERM_ROW_CODEC = new ExternalSorter.Codec<>() { @Override public void write(DataOutput out, TermRow record) throws IOException { NodeCodec.writeNode(out, record.term()); @@ -65,7 +65,7 @@ public TermRow read(DataInput in) throws IOException { } }; - static final ExternalSorter.Codec ROW_ID_CODEC = new ExternalSorter.Codec<>() { + public static final ExternalSorter.Codec ROW_ID_CODEC = new ExternalSorter.Codec<>() { @Override public void write(DataOutput out, RowId record) throws IOException { HugeIO.writeVarLong(out, record.row()); @@ -81,7 +81,7 @@ public RowId read(DataInput in) throws IOException { }; /** Bare non-negative longs (the per-row predicate temp/final id files). */ - static final ExternalSorter.Codec VAR_LONG_CODEC = new ExternalSorter.Codec<>() { + public static final ExternalSorter.Codec VAR_LONG_CODEC = new ExternalSorter.Codec<>() { @Override public void write(DataOutput out, Long record) throws IOException { HugeIO.writeVarLong(out, record); @@ -93,7 +93,7 @@ public Long read(DataInput in) throws IOException { } }; - static final ExternalSorter.Codec ID_QUAD_CODEC = new ExternalSorter.Codec<>() { + public static final ExternalSorter.Codec ID_QUAD_CODEC = new ExternalSorter.Codec<>() { @Override public void write(DataOutput out, IdQuad record) throws IOException { HugeIO.writeVarLong(out, record.g()); diff --git a/src/main/java/com/ebremer/beakgraph/huge/NodeCodec.java b/src/main/java/com/ebremer/beakgraph/huge/NodeCodec.java index a7309d8a..a5d096f0 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/NodeCodec.java +++ b/src/main/java/com/ebremer/beakgraph/huge/NodeCodec.java @@ -21,7 +21,7 @@ * * @author Erich Bremer */ -final class NodeCodec implements ExternalSorter.Codec { +public final class NodeCodec implements ExternalSorter.Codec { static final NodeCodec INSTANCE = new NodeCodec(); @@ -43,7 +43,7 @@ public Node read(DataInput in) throws IOException { return readNode(in); } - static void writeNode(DataOutput out, Node n) throws IOException { + public static void writeNode(DataOutput out, Node n) throws IOException { if (n.isURI()) { out.writeByte(T_URI); writeString(out, n.getURI()); @@ -68,7 +68,7 @@ static void writeNode(DataOutput out, Node n) throws IOException { } } - static Node readNode(DataInput in) throws IOException { + public static Node readNode(DataInput in) throws IOException { byte tag = in.readByte(); return switch (tag) { case T_URI -> NodeFactory.createURI(readString(in)); @@ -103,3 +103,4 @@ static String readString(DataInput in) throws IOException { return new String(HugeIO.readBytes(in, (int) len), StandardCharsets.UTF_8); } } + diff --git a/src/main/java/com/ebremer/beakgraph/huge/RecordFile.java b/src/main/java/com/ebremer/beakgraph/huge/RecordFile.java index 8d2f8b8a..3ad80ca5 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/RecordFile.java +++ b/src/main/java/com/ebremer/beakgraph/huge/RecordFile.java @@ -62,13 +62,13 @@ long count() { } /** A fresh sequential stream over all records; close it when done. */ - ExternalSorter.SortedStream read() throws IOException { + RecordSorter.SortedCursor read() throws IOException { if (!finished) { throw new IllegalStateException("RecordFile not finished: " + path); } DataInputStream in = new DataInputStream( new BufferedInputStream(Files.newInputStream(path), 1 << 16)); - return new ExternalSorter.SortedStream() { + return new RecordSorter.SortedCursor() { private T head = advance(); private boolean closed = false; diff --git a/src/main/java/com/ebremer/beakgraph/huge/RecordSorter.java b/src/main/java/com/ebremer/beakgraph/huge/RecordSorter.java new file mode 100644 index 00000000..74f35d7c --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/huge/RecordSorter.java @@ -0,0 +1,34 @@ +package com.ebremer.beakgraph.huge; + +import java.io.IOException; +import java.util.Iterator; + +/** + * A disk-backed sorter of records of type {@code T}: records stream in via + * {@link #add}, and {@link #sorted()} - callable once - returns them in order. + * Implementations may buffer, spill, and merge however they like; RAM must + * stay bounded regardless of record count. + * + *

Extracted from {@link ExternalSorter} so the build pipeline can run with + * pluggable sorting engines: the sequential external merge sort (-method 1) + * or the hugeUltra package's parallel/primitive sorters (-method 4). + */ +public interface RecordSorter extends AutoCloseable { + + /** A sorted record stream that owns its temp files; must be closed. */ + interface SortedCursor extends Iterator, AutoCloseable { + @Override + void close(); + } + + void add(T record) throws IOException; + + /** Total records added so far. */ + long size(); + + /** Finishes ingestion and returns the fully sorted stream (single use). */ + SortedCursor sorted() throws IOException; + + @Override + void close(); +} diff --git a/src/main/java/com/ebremer/beakgraph/huge/SorterProvider.java b/src/main/java/com/ebremer/beakgraph/huge/SorterProvider.java new file mode 100644 index 00000000..bd9a2c9a --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/huge/SorterProvider.java @@ -0,0 +1,57 @@ +package com.ebremer.beakgraph.huge; + +import com.ebremer.beakgraph.hdf5.Index; +import java.nio.file.Path; + +/** + * Factory for the sorters {@link HugeBuildPipeline} runs on. The pipeline + * tells the provider what is being sorted and (for the id-record sorters) the + * value ranges involved, so implementations can pick representations - the + * default wraps the sequential {@link ExternalSorter}; the hugeUltra provider + * returns background-spilling parallel sorters and bit-packed primitive + * sorters instead. + */ +public interface SorterProvider { + + /** Sorter for one term column's (term, row) records, in NodeComparator term order. */ + RecordSorter termSorter(Path workDir, String tag); + + /** + * Sorter for (row, id) join results back into row order. + * {@code maxRow}/{@code maxId} bound the values that will be added. + */ + RecordSorter rowIdSorter(Path workDir, String tag, long maxRow, long maxId); + + /** + * Sorter for dictionary-encoded quads in {@code order}'s component order. + * The three counts bound the ids (graph/subject ids ≤ numEntities, + * predicate ids ≤ numPredicates, object ids ≤ numObjects). + */ + RecordSorter quadSorter(Path workDir, String tag, Index order, + long numEntities, long numPredicates, long numObjects); + + /** The sequential engine used by -method 1: plain {@link ExternalSorter}s. */ + static SorterProvider sequential(int termSpillBatch, int idSpillBatch, int mergeFanIn) { + return new SorterProvider() { + @Override + public RecordSorter termSorter(Path workDir, String tag) { + return new ExternalSorter<>(workDir, tag, HugeRecords.TERM_ROW_CODEC, + HugeRecords.TERM_ORDER, termSpillBatch, mergeFanIn); + } + + @Override + public RecordSorter rowIdSorter(Path workDir, String tag, long maxRow, long maxId) { + return new ExternalSorter<>(workDir, tag, HugeRecords.ROW_ID_CODEC, + HugeRecords.ROW_ORDER, idSpillBatch, mergeFanIn); + } + + @Override + public RecordSorter quadSorter(Path workDir, String tag, Index order, + long numEntities, long numPredicates, long numObjects) { + return new ExternalSorter<>(workDir, tag, HugeRecords.ID_QUAD_CODEC, + order == Index.GSPO ? HugeRecords.GSPO_ORDER : HugeRecords.GPOS_ORDER, + idSpillBatch, mergeFanIn); + } + }; + } +} diff --git a/src/main/java/com/ebremer/beakgraph/huge/SpatialAugmenter.java b/src/main/java/com/ebremer/beakgraph/huge/SpatialAugmenter.java index 7b847137..e6e7f4a7 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/SpatialAugmenter.java +++ b/src/main/java/com/ebremer/beakgraph/huge/SpatialAugmenter.java @@ -37,7 +37,7 @@ * * @author Erich Bremer */ -final class SpatialAugmenter { +public final class SpatialAugmenter { private static final Logger logger = LoggerFactory.getLogger(SpatialAugmenter.class); @@ -51,11 +51,11 @@ final class SpatialAugmenter { private final boolean features; - SpatialAugmenter(boolean features) { + public SpatialAugmenter(boolean features) { this.features = features; } - static boolean isGeoLiteral(Quad quad) { + public static boolean isGeoLiteral(Quad quad) { Node o = quad.getObject(); return o.isLiteral() && GEO.wktLiteral.getURI().equals(o.getLiteralDatatypeURI()); } @@ -65,7 +65,7 @@ static boolean isGeoLiteral(Quad quad) { * geometry never aborts the build - it is logged and skipped, exactly like * the RAM writer. */ - ArrayList addSpatial(Quad quad) { + public ArrayList addSpatial(Quad quad) { final ArrayList qqq = new ArrayList<>(); String wkt = ImageTools.stripCrs(quad.getObject().getLiteralLexicalForm()); if (features) { diff --git a/src/main/java/com/ebremer/beakgraph/io/ChannelBytes.java b/src/main/java/com/ebremer/beakgraph/io/ChannelBytes.java new file mode 100644 index 00000000..839c25f4 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/io/ChannelBytes.java @@ -0,0 +1,113 @@ +package com.ebremer.beakgraph.io; + +import java.io.EOFException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; + +/** + * {@link RandomAccessBytes} over a window of a {@link FileChannel}, using + * absolute positional reads - the path for channel-backed HDF5 storage that + * cannot be memory-mapped (an HTTP range-request channel, a zip/S3 + * filesystem, ...). Nothing is materialized up front: each read touches only + * the bytes it asks for, so a remote dataset is fetched lazily, range by + * range, by whatever block caching the underlying channel provides. + * + *

Multi-byte reads are assembled BIG-ENDIAN from the raw bytes, matching + * the on-disk format. Instances are safe for concurrent readers as long as + * the channel's positional reads are thread-safe (jHDF's channel wrapper + * serializes them internally); the 8-byte scratch buffer is per-thread. + * + *

{@link IOException}s surface as {@link UncheckedIOException} - this + * interface's contract has no checked I/O failures. + * + * @author Erich Bremer + */ +public final class ChannelBytes implements RandomAccessBytes { + + private static final ThreadLocal SCRATCH = + ThreadLocal.withInitial(() -> ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN)); + + private final FileChannel channel; + private final long base; + private final long size; + + /** + * @param channel source of positional reads; NOT owned (closing is the + * storage's job, this view just reads through it) + * @param base absolute channel offset of this window's byte 0 + * @param size window length in bytes + */ + public ChannelBytes(FileChannel channel, long base, long size) { + if (base < 0 || size < 0) { + throw new IllegalArgumentException("base " + base + ", size " + size); + } + this.channel = channel; + this.base = base; + this.size = size; + } + + @Override + public long size() { + return size; + } + + @Override + public byte get(long offset) { + return scratch(offset, 1).get(0); + } + + @Override + public long getLong(long offset) { + return scratch(offset, Long.BYTES).getLong(0); + } + + @Override + public float getFloat(long offset) { + return scratch(offset, Float.BYTES).getFloat(0); + } + + @Override + public double getDouble(long offset) { + return scratch(offset, Double.BYTES).getDouble(0); + } + + @Override + public void get(long offset, byte[] dst, int dstOffset, int length) { + check(offset, length); + readFully(ByteBuffer.wrap(dst, dstOffset, length), offset); + } + + private ByteBuffer scratch(long offset, int length) { + check(offset, length); + ByteBuffer bb = SCRATCH.get().clear().limit(length); + readFully(bb, offset); + return bb; + } + + private void check(long offset, int length) { + // length > size - offset is the overflow-safe form of offset + length > size + if (offset < 0 || length > size - offset) { + throw new IndexOutOfBoundsException( + "offset " + offset + ", length " + length + ", size " + size); + } + } + + private void readFully(ByteBuffer bb, long offset) { + long channelPosition = base + offset; + int start = bb.position(); + try { + while (bb.hasRemaining()) { + int n = channel.read(bb, channelPosition + (bb.position() - start)); + if (n <= 0) { + throw new EOFException("Unexpected end of channel at offset " + offset + + " (channel position " + (channelPosition + bb.position() - start) + ")"); + } + } + } catch (IOException e) { + throw new UncheckedIOException("Channel read failed at offset " + offset, e); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/io/DatasetBytes.java b/src/main/java/com/ebremer/beakgraph/io/DatasetBytes.java index 24b7e45d..cef5f770 100644 --- a/src/main/java/com/ebremer/beakgraph/io/DatasetBytes.java +++ b/src/main/java/com/ebremer/beakgraph/io/DatasetBytes.java @@ -1,6 +1,8 @@ package com.ebremer.beakgraph.io; import io.jhdf.api.dataset.ContiguousDataset; +import io.jhdf.nio.FileChannelFromSeekableByteChannel; +import io.jhdf.storage.HdfFileChannel; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.ByteBuffer; @@ -15,6 +17,13 @@ * {@code dataAddress + userBlockSize} - the same file offset jHDF itself * would map - with an automatic arena managing the unmap. * + *

Channel-backed files (an {@code HdfFile} opened over a + * {@code SeekableByteChannel}, e.g. HTTP range requests) take neither path: + * they cannot be memory-mapped, the FFM path's local file path does not + * correspond to the actual bytes, and {@code getBuffer()} would materialize + * the whole dataset up front. They are served by {@link ChannelBytes}, which + * reads only the touched ranges through the channel. + * *

The threshold is overridable for testing and operations via the * {@code beakgraph.ffm.threshold} system property (bytes; datasets strictly * larger go through FFM). Setting it to 0 forces every dataset onto the FFM @@ -43,6 +52,12 @@ public static RandomAccessBytes of(ContiguousDataset dataset) { return new ByteBufferBytes(ByteBuffer.allocate(0)); } long address = dataset.getDataAddress(); + if (address >= 0 + && dataset.getHdfFile().getHdfBackingStorage() instanceof HdfFileChannel hfc + && hfc.getFileChannel() instanceof FileChannelFromSeekableByteChannel) { + return new ChannelBytes(hfc.getFileChannel(), + address + dataset.getHdfFile().getUserBlockSize(), size); + } if (size > ffmThreshold && address >= 0) { try { long fileOffset = address + dataset.getHdfFile().getUserBlockSize(); diff --git a/src/main/java/com/ebremer/beakgraph/utils/HDTBitmapDirectory.java b/src/main/java/com/ebremer/beakgraph/utils/HDTBitmapDirectory.java index facbc76c..5f0523d8 100644 --- a/src/main/java/com/ebremer/beakgraph/utils/HDTBitmapDirectory.java +++ b/src/main/java/com/ebremer/beakgraph/utils/HDTBitmapDirectory.java @@ -54,11 +54,47 @@ public HDTBitmapDirectory(BitPackedUnSignedLongBuffer superblock, public long select1(long rank) { if (rank <= 0) return -1L; - // Step 1: Find Superblock + // Step 1: Find Superblock - the greatest index whose cumulative count is + // still below the target rank. The cumulative counts of real index bitmaps + // grow near-linearly, so ONE interpolated probe plus an exponential gallop + // usually brackets the answer within a handful of reads (a plain binary + // search costs ~22 probes on a PubMed-scale directory of ~5.6M + // superblocks). The closing binary search keeps the O(log n) worst case + // for skewed bitmaps (e.g. dense padding runs next to sparse data runs). + // Stateless on purpose: directories are shared across concurrent queries. long low = 0; long high = numSuperblockEntries - 1; long sbIdx = 0; // Default to 0 if not found or first + if (high > 8) { + long last = superblock.get(high); + long guess = (last > 0) ? (long) ((double) rank / last * high) : 0; + if (guess < 0) guess = 0; + if (guess > high) guess = high; + long step = 1; + long cur = guess; + if (superblock.get(guess) < rank) { + // Answer is at or to the right of the guess: gallop right. + sbIdx = guess; + while (cur + step <= high && superblock.get(cur + step) < rank) { + cur += step; + sbIdx = cur; + step <<= 1; + } + low = cur + 1; + high = Math.min(high, cur + step); + } else { + // Answer is strictly left of the guess: gallop left. Index 0 always + // qualifies (the seeded count is 0 < rank), so the bracket is never empty. + while (cur - step >= 0 && superblock.get(cur - step) >= rank) { + cur -= step; + step <<= 1; + } + low = Math.max(0, cur - step); + high = cur - 1; + } + } + while (low <= high) { long mid = (low + high) >>> 1; long val = superblock.get(mid); diff --git a/src/main/java/com/ebremer/beakgraph/utils/RdfSources.java b/src/main/java/com/ebremer/beakgraph/utils/RdfSources.java new file mode 100644 index 00000000..fd8ee92b --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/utils/RdfSources.java @@ -0,0 +1,104 @@ +package com.ebremer.beakgraph.utils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import java.util.Set; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFLanguages; + +/** + * The single home for "what RDF source files does BeakGraph accept and how are + * they opened". A source is a document in one of the {@link #BASE_EXTENSIONS} + * syntaxes, optionally compressed as {@code .gz} or {@code .zip} (a zip is the + * zipped equivalent of ONE document: its first file entry is read). The CLI + * traversal filter and every writer pipeline (RAM, parallel, huge) go through + * this class, so a new syntax or compression is added exactly once. + */ +public final class RdfSources { + + /** Accepted (lowercase) RDF syntax extensions, without compression suffixes. */ + public static final Set BASE_EXTENSIONS = Set.of("ttl", "nt", "nq", "trig", "rdf", "jsonld"); + + private RdfSources() {} + + /** + * True when the file name is an accepted RDF source: {@code name.}, + * {@code name..gz}, or {@code name..zip} for any accepted + * extension. + */ + public static boolean isSupported(String fileName) { + String n = fileName.toLowerCase(Locale.ROOT); + if (n.endsWith(".gz")) { + n = n.substring(0, n.length() - 3); + } else if (n.endsWith(".zip")) { + n = n.substring(0, n.length() - 4); + } + int dot = n.lastIndexOf('.'); + return dot >= 0 && BASE_EXTENSIONS.contains(n.substring(dot + 1)); + } + + /** + * A source opened for parsing: the (decompressed) stream plus the syntax + * detected from the file name - or, for zip archives, from the entry name + * when it is more specific. + */ + public record OpenedSource(InputStream stream, Lang lang) implements AutoCloseable { + @Override + public void close() throws IOException { + stream.close(); + } + } + + /** + * Opens a source file, transparently decompressing {@code .gz} and + * {@code .zip}. For zip archives the stream is positioned at the first + * file entry (the archive is expected to be a zipped single document); + * reading it ends at that entry's boundary. + */ + public static OpenedSource open(File src) throws IOException { + String name = src.getName(); + String lower = name.toLowerCase(Locale.ROOT); + if (lower.endsWith(".gz")) { + return new OpenedSource(new GZIPInputStream(new FileInputStream(src)), + detectLang(name.substring(0, name.length() - 3))); + } + if (lower.endsWith(".zip")) { + ZipInputStream zis = new ZipInputStream(new FileInputStream(src)); + try { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (!entry.isDirectory()) { + // Prefer the entry's own extension; fall back to the + // archive name minus ".zip" (e.g. data.nt.zip -> data.nt). + Lang lang = RDFLanguages.filenameToLang(entry.getName(), null); + if (lang == null) { + lang = detectLang(name.substring(0, name.length() - 4)); + } + return new OpenedSource(zis, lang); + } + } + } catch (IOException | RuntimeException ex) { + zis.close(); + throw ex; + } + zis.close(); + throw new IOException("Zip archive contains no file entry: " + src); + } + return new OpenedSource(new FileInputStream(src), detectLang(name)); + } + + /** + * Syntax from a (decompressed) file name; Turtle when unrecognized - this + * is a quad store, and named graphs can only arrive through a quad-capable + * syntax the name identifies (TriG, N-Quads). + */ + private static Lang detectLang(String effectiveName) { + return RDFLanguages.filenameToLang(effectiveName, Lang.TURTLE); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/utils/StringUtils.java b/src/main/java/com/ebremer/beakgraph/utils/StringUtils.java index fd6290ce..bcbf268a 100644 --- a/src/main/java/com/ebremer/beakgraph/utils/StringUtils.java +++ b/src/main/java/com/ebremer/beakgraph/utils/StringUtils.java @@ -1,11 +1,7 @@ package com.ebremer.beakgraph.utils; -import io.airlift.compress.v3.zstd.ZstdCompressor; -import io.airlift.compress.v3.zstd.ZstdDecompressor; -import io.airlift.compress.v3.zstd.ZstdJavaCompressor; -import io.airlift.compress.v3.zstd.ZstdJavaDecompressor; -import io.airlift.compress.v3.zstd.ZstdNativeCompressor; -import io.airlift.compress.v3.zstd.ZstdNativeDecompressor; +import io.airlift.compress.v3.zstdFFM.ZstdCompressor; +import io.airlift.compress.v3.zstdFFM.ZstdDecompressor; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -20,19 +16,14 @@ public final class StringUtils { private static final int HEADER_SIZE = 4; // To store uncompressed length /** - * Selects the Zstd implementation: the native (Foreign Function and Memory - * API) binding when its bundled library is available, otherwise the - * pure-Java port. The native path avoids sun.misc.Unsafe (deprecated for - * removal); the Java path remains the portable fallback. + * Selects the Zstd implementation via the vendored zstdFFM package (see + * io/airlift/compress/v3/zstdFFM/README.md): the native binding when its + * bundled library loads, otherwise the FFM-based pure-Java port. Neither + * path touches sun.misc.Unsafe (deprecated for removal). */ public StringUtils() { - if (ZstdNativeCompressor.isEnabled()) { - COMPRESSOR = new ZstdNativeCompressor(); - DECOMPRESSOR = new ZstdNativeDecompressor(); - } else { - COMPRESSOR = new ZstdJavaCompressor(); - DECOMPRESSOR = new ZstdJavaDecompressor(); - } + COMPRESSOR = ZstdCompressor.create(); + DECOMPRESSOR = ZstdDecompressor.create(); } /** diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/BitInputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/BitInputStream.java new file mode 100644 index 00000000..e3fd8a5d --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/BitInputStream.java @@ -0,0 +1,209 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; +import static io.airlift.compress.v3.zstdFFM.Util.highestBit; +import static io.airlift.compress.v3.zstdFFM.Util.verify; + +/** + * Bit streams are encoded as a byte-aligned little-endian stream. Thus, bits are laid out + * in the following manner, and the stream is read from right to left. + *

+ *

+ * ... [16 17 18 19 20 21 22 23] [8 9 10 11 12 13 14 15] [0 1 2 3 4 5 6 7] + */ +final class BitInputStream +{ + private BitInputStream() + { + } + + public static boolean isEndOfStream(long startAddress, long currentAddress, int bitsConsumed) + { + return startAddress == currentAddress && bitsConsumed == Long.SIZE; + } + + private static long readTail(MemorySegment inputBase, long inputAddress, int inputSize) + { + long bits = inputBase.get(JAVA_BYTE, inputAddress) & 0xFF; + + switch (inputSize) { + case 7: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 6) & 0xFFL) << 48; + case 6: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 5) & 0xFFL) << 40; + case 5: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 4) & 0xFFL) << 32; + case 4: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 3) & 0xFFL) << 24; + case 3: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 2) & 0xFFL) << 16; + case 2: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 1) & 0xFFL) << 8; + } + + return bits; + } + + /** + * @return numberOfBits in the low-order bits of a long + */ + public static long peekBits(int bitsConsumed, long bitContainer, int numberOfBits) + { + return (((bitContainer << bitsConsumed) >>> 1) >>> (63 - numberOfBits)); + } + + /** + * numberOfBits must be > 0 + * + * @return numberOfBits in the low-order bits of a long + */ + public static long peekBitsFast(int bitsConsumed, long bitContainer, int numberOfBits) + { + return ((bitContainer << bitsConsumed) >>> (64 - numberOfBits)); + } + + static class Initializer + { + private final MemorySegment inputBase; + private final long startAddress; + private final long endAddress; + private long bits; + private long currentAddress; + private int bitsConsumed; + + public Initializer(MemorySegment inputBase, long startAddress, long endAddress) + { + this.inputBase = inputBase; + this.startAddress = startAddress; + this.endAddress = endAddress; + } + + public long getBits() + { + return bits; + } + + public long getCurrentAddress() + { + return currentAddress; + } + + public int getBitsConsumed() + { + return bitsConsumed; + } + + public void initialize() + { + verify(endAddress - startAddress >= 1, startAddress, "Bitstream is empty"); + + int lastByte = inputBase.get(JAVA_BYTE, endAddress - 1) & 0xFF; + verify(lastByte != 0, endAddress, "Bitstream end mark not present"); + + bitsConsumed = SIZE_OF_LONG - highestBit(lastByte); + + int inputSize = (int) (endAddress - startAddress); + if (inputSize >= SIZE_OF_LONG) { /* normal case */ + currentAddress = endAddress - SIZE_OF_LONG; + bits = inputBase.get(JAVA_LONG, currentAddress); + } + else { + currentAddress = startAddress; + bits = readTail(inputBase, startAddress, inputSize); + + bitsConsumed += (SIZE_OF_LONG - inputSize) * 8; + } + } + } + + static final class Loader + { + private final MemorySegment inputBase; + private final long startAddress; + private long bits; + private long currentAddress; + private int bitsConsumed; + private boolean overflow; + + public Loader(MemorySegment inputBase, long startAddress, long currentAddress, long bits, int bitsConsumed) + { + this.inputBase = inputBase; + this.startAddress = startAddress; + this.bits = bits; + this.currentAddress = currentAddress; + this.bitsConsumed = bitsConsumed; + } + + public long getBits() + { + return bits; + } + + public long getCurrentAddress() + { + return currentAddress; + } + + public int getBitsConsumed() + { + return bitsConsumed; + } + + public boolean isOverflow() + { + return overflow; + } + + public boolean load() + { + if (bitsConsumed > 64) { + overflow = true; + return true; + } + + else if (currentAddress == startAddress) { + return true; + } + + int bytes = bitsConsumed >>> 3; // divide by 8 + if (currentAddress >= startAddress + SIZE_OF_LONG) { + if (bytes > 0) { + currentAddress -= bytes; + bits = inputBase.get(JAVA_LONG, currentAddress); + } + bitsConsumed &= 0b111; + } + else if (currentAddress - bytes < startAddress) { + bytes = (int) (currentAddress - startAddress); + currentAddress = startAddress; + bitsConsumed -= bytes * SIZE_OF_LONG; + bits = inputBase.get(JAVA_LONG, startAddress); + return true; + } + else { + currentAddress -= bytes; + bitsConsumed -= bytes * SIZE_OF_LONG; + bits = inputBase.get(JAVA_LONG, currentAddress); + } + + return false; + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/BitOutputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/BitOutputStream.java new file mode 100644 index 00000000..74723d8e --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/BitOutputStream.java @@ -0,0 +1,92 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; + +class BitOutputStream +{ + private static final long[] BIT_MASK = { + 0x0, 0x1, 0x3, 0x7, 0xF, 0x1F, + 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, + 0xFFF, 0x1FFF, 0x3FFF, 0x7FFF, 0xFFFF, 0x1FFFF, + 0x3FFFF, 0x7FFFF, 0xFFFFF, 0x1FFFFF, 0x3FFFFF, 0x7FFFFF, + 0xFFFFFF, 0x1FFFFFF, 0x3FFFFFF, 0x7FFFFFF, 0xFFFFFFF, 0x1FFFFFFF, + 0x3FFFFFFF, 0x7FFFFFFF}; // up to 31 bits + + private final MemorySegment outputBase; + private final long outputAddress; + private final long outputLimit; + + private long container; + private int bitCount; + private long currentAddress; + + public BitOutputStream(MemorySegment outputBase, long outputAddress, int outputSize) + { + checkArgument(outputSize >= SIZE_OF_LONG, "Output buffer too small"); + + this.outputBase = outputBase; + this.outputAddress = outputAddress; + outputLimit = this.outputAddress + outputSize - SIZE_OF_LONG; + + currentAddress = this.outputAddress; + } + + public void addBits(int value, int bits) + { + container |= (value & BIT_MASK[bits]) << bitCount; + bitCount += bits; + } + + /** + * Note: leading bits of value must be 0 + */ + public void addBitsFast(int value, int bits) + { + container |= ((long) value) << bitCount; + bitCount += bits; + } + + public void flush() + { + int bytes = bitCount >>> 3; + + outputBase.set(JAVA_LONG, currentAddress, container); + currentAddress += bytes; + + if (currentAddress > outputLimit) { + currentAddress = outputLimit; + } + + bitCount &= 7; + container >>>= bytes * 8; + } + + public int close() + { + addBitsFast(1, 1); // end mark + flush(); + + if (currentAddress >= outputLimit) { + return 0; + } + + return (int) ((currentAddress - outputAddress) + (bitCount > 0 ? 1 : 0)); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressionState.java b/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressionState.java new file mode 100644 index 00000000..3d64f587 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressionState.java @@ -0,0 +1,76 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.util.Arrays; + +class BlockCompressionState +{ + public final int[] hashTable; + public final int[] chainTable; + + private final long baseAddress; + + // starting point of the window with respect to baseAddress + private int windowBaseOffset; + + public BlockCompressionState(CompressionParameters parameters, long baseAddress) + { + this.baseAddress = baseAddress; + hashTable = new int[1 << parameters.getHashLog()]; + chainTable = new int[1 << parameters.getChainLog()]; // TODO: chain table not used by Strategy.FAST + } + + public void slideWindow(int slideWindowSize) + { + for (int i = 0; i < hashTable.length; i++) { + int newValue = hashTable[i] - slideWindowSize; + // if new value is negative, set it to zero branchless + newValue = newValue & (~(newValue >> 31)); + hashTable[i] = newValue; + } + for (int i = 0; i < chainTable.length; i++) { + int newValue = chainTable[i] - slideWindowSize; + // if new value is negative, set it to zero branchless + newValue = newValue & (~(newValue >> 31)); + chainTable[i] = newValue; + } + } + + public void reset() + { + Arrays.fill(hashTable, 0); + Arrays.fill(chainTable, 0); + } + + public void enforceMaxDistance(long inputLimit, int maxDistance) + { + int distance = (int) (inputLimit - baseAddress); + + int newOffset = distance - maxDistance; + if (windowBaseOffset < newOffset) { + windowBaseOffset = newOffset; + } + } + + public long getBaseAddress() + { + return baseAddress; + } + + public int getWindowBaseOffset() + { + return windowBaseOffset; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressor.java new file mode 100644 index 00000000..6381edd2 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressor.java @@ -0,0 +1,23 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +interface BlockCompressor +{ + BlockCompressor UNSUPPORTED = (inputBase, inputAddress, inputSize, sequenceStore, blockCompressionState, offsets, parameters) -> { throw new UnsupportedOperationException(); }; + + int compressBlock(MemorySegment inputBase, long inputAddress, int inputSize, SequenceStore output, BlockCompressionState state, RepeatedOffsets offsets, CompressionParameters parameters); +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionContext.java b/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionContext.java new file mode 100644 index 00000000..e837bebf --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionContext.java @@ -0,0 +1,57 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static java.lang.Math.clamp; + +class CompressionContext +{ + public final CompressionParameters parameters; + public final RepeatedOffsets offsets = new RepeatedOffsets(); + public final BlockCompressionState blockCompressionState; + public final SequenceStore sequenceStore; + + public final SequenceEncodingContext sequenceEncodingContext = new SequenceEncodingContext(); + + public final HuffmanCompressionContext huffmanContext = new HuffmanCompressionContext(); + + public CompressionContext(CompressionParameters parameters, long baseAddress, int inputSize) + { + this.parameters = parameters; + + int windowSize = clamp(inputSize, 1, parameters.getWindowSize()); + int blockSize = Math.min(MAX_BLOCK_SIZE, windowSize); + int divider = (parameters.getSearchLength() == 3) ? 3 : 4; + + int maxSequences = blockSize / divider; + + sequenceStore = new SequenceStore(blockSize, maxSequences); + + blockCompressionState = new BlockCompressionState(parameters, baseAddress); + } + + public void slideWindow(int slideWindowSize) + { + checkArgument(slideWindowSize > 0, "slideWindowSize must be positive"); + blockCompressionState.slideWindow(slideWindowSize); + } + + public void commit() + { + offsets.commit(); + huffmanContext.saveChanges(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionParameters.java b/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionParameters.java new file mode 100644 index 00000000..21ab98a6 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionParameters.java @@ -0,0 +1,299 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_WINDOW_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_WINDOW_LOG; +import static io.airlift.compress.v3.zstdFFM.Util.cycleLog; +import static io.airlift.compress.v3.zstdFFM.Util.highestBit; +import static java.lang.Math.clamp; + +class CompressionParameters +{ + private static final int MIN_HASH_LOG = 6; + + public static final int DEFAULT_COMPRESSION_LEVEL = 3; + private static final int MAX_COMPRESSION_LEVEL = 22; + + private final int windowLog; // largest match distance : larger == more compression, more memory needed during decompression + private final int windowSize; // computed: 1 << windowLog + private final int blockSize; // computed: min(MAX_BLOCK_SIZE, windowSize) + private final int chainLog; // fully searched segment : larger == more compression, slower, more memory (useless for fast) + private final int hashLog; // dispatch table : larger == faster, more memory + private final int searchLog; // nb of searches : larger == more compression, slower + private final int searchLength; // match length searched : larger == faster decompression, sometimes less compression + private final int targetLength; // acceptable match size for optimal parser (only) : larger == more compression, slower + private final Strategy strategy; + + private static final CompressionParameters[][] DEFAULT_COMPRESSION_PARAMETERS = new CompressionParameters[][] { + { + // default + new CompressionParameters(19, 12, 13, 1, 6, 1, Strategy.FAST), /* base for negative levels */ + new CompressionParameters(19, 13, 14, 1, 7, 0, Strategy.FAST), /* level 1 */ + new CompressionParameters(19, 15, 16, 1, 6, 0, Strategy.FAST), /* level 2 */ + new CompressionParameters(20, 16, 17, 1, 5, 1, Strategy.DFAST), /* level 3 */ + new CompressionParameters(20, 18, 18, 1, 5, 1, Strategy.DFAST), /* level 4 */ + new CompressionParameters(20, 18, 18, 2, 5, 2, Strategy.GREEDY), /* level 5 */ + new CompressionParameters(21, 18, 19, 2, 5, 4, Strategy.LAZY), /* level 6 */ + new CompressionParameters(21, 18, 19, 3, 5, 8, Strategy.LAZY2), /* level 7 */ + new CompressionParameters(21, 19, 19, 3, 5, 16, Strategy.LAZY2), /* level 8 */ + new CompressionParameters(21, 19, 20, 4, 5, 16, Strategy.LAZY2), /* level 9 */ + new CompressionParameters(21, 20, 21, 4, 5, 16, Strategy.LAZY2), /* level 10 */ + new CompressionParameters(21, 21, 22, 4, 5, 16, Strategy.LAZY2), /* level 11 */ + new CompressionParameters(22, 20, 22, 5, 5, 16, Strategy.LAZY2), /* level 12 */ + new CompressionParameters(22, 21, 22, 4, 5, 32, Strategy.BTLAZY2), /* level 13 */ + new CompressionParameters(22, 21, 22, 5, 5, 32, Strategy.BTLAZY2), /* level 14 */ + new CompressionParameters(22, 22, 22, 6, 5, 32, Strategy.BTLAZY2), /* level 15 */ + new CompressionParameters(22, 21, 22, 4, 5, 48, Strategy.BTOPT), /* level 16 */ + new CompressionParameters(23, 22, 22, 4, 4, 64, Strategy.BTOPT), /* level 17 */ + new CompressionParameters(23, 23, 22, 6, 3, 256, Strategy.BTOPT), /* level 18 */ + new CompressionParameters(23, 24, 22, 7, 3, 256, Strategy.BTULTRA), /* level 19 */ + new CompressionParameters(25, 25, 23, 7, 3, 256, Strategy.BTULTRA), /* level 20 */ + new CompressionParameters(26, 26, 24, 7, 3, 512, Strategy.BTULTRA), /* level 21 */ + new CompressionParameters(27, 27, 25, 9, 3, 999, Strategy.BTULTRA) /* level 22 */ + }, + { + // for size <= 256 KB + new CompressionParameters(18, 12, 13, 1, 5, 1, Strategy.FAST), /* base for negative levels */ + new CompressionParameters(18, 13, 14, 1, 6, 0, Strategy.FAST), /* level 1 */ + new CompressionParameters(18, 14, 14, 1, 5, 1, Strategy.DFAST), /* level 2 */ + new CompressionParameters(18, 16, 16, 1, 4, 1, Strategy.DFAST), /* level 3 */ + new CompressionParameters(18, 16, 17, 2, 5, 2, Strategy.GREEDY), /* level 4.*/ + new CompressionParameters(18, 18, 18, 3, 5, 2, Strategy.GREEDY), /* level 5.*/ + new CompressionParameters(18, 18, 19, 3, 5, 4, Strategy.LAZY), /* level 6.*/ + new CompressionParameters(18, 18, 19, 4, 4, 4, Strategy.LAZY), /* level 7 */ + new CompressionParameters(18, 18, 19, 4, 4, 8, Strategy.LAZY2), /* level 8 */ + new CompressionParameters(18, 18, 19, 5, 4, 8, Strategy.LAZY2), /* level 9 */ + new CompressionParameters(18, 18, 19, 6, 4, 8, Strategy.LAZY2), /* level 10 */ + new CompressionParameters(18, 18, 19, 5, 4, 16, Strategy.BTLAZY2), /* level 11.*/ + new CompressionParameters(18, 19, 19, 6, 4, 16, Strategy.BTLAZY2), /* level 12.*/ + new CompressionParameters(18, 19, 19, 8, 4, 16, Strategy.BTLAZY2), /* level 13 */ + new CompressionParameters(18, 18, 19, 4, 4, 24, Strategy.BTOPT), /* level 14.*/ + new CompressionParameters(18, 18, 19, 4, 3, 24, Strategy.BTOPT), /* level 15.*/ + new CompressionParameters(18, 19, 19, 6, 3, 64, Strategy.BTOPT), /* level 16.*/ + new CompressionParameters(18, 19, 19, 8, 3, 128, Strategy.BTOPT), /* level 17.*/ + new CompressionParameters(18, 19, 19, 10, 3, 256, Strategy.BTOPT), /* level 18.*/ + new CompressionParameters(18, 19, 19, 10, 3, 256, Strategy.BTULTRA), /* level 19.*/ + new CompressionParameters(18, 19, 19, 11, 3, 512, Strategy.BTULTRA), /* level 20.*/ + new CompressionParameters(18, 19, 19, 12, 3, 512, Strategy.BTULTRA), /* level 21.*/ + new CompressionParameters(18, 19, 19, 13, 3, 999, Strategy.BTULTRA) /* level 22.*/ + }, + { + // for size <= 128 KB + new CompressionParameters(17, 12, 12, 1, 5, 1, Strategy.FAST), /* base for negative levels */ + new CompressionParameters(17, 12, 13, 1, 6, 0, Strategy.FAST), /* level 1 */ + new CompressionParameters(17, 13, 15, 1, 5, 0, Strategy.FAST), /* level 2 */ + new CompressionParameters(17, 15, 16, 2, 5, 1, Strategy.DFAST), /* level 3 */ + new CompressionParameters(17, 17, 17, 2, 4, 1, Strategy.DFAST), /* level 4 */ + new CompressionParameters(17, 16, 17, 3, 4, 2, Strategy.GREEDY), /* level 5 */ + new CompressionParameters(17, 17, 17, 3, 4, 4, Strategy.LAZY), /* level 6 */ + new CompressionParameters(17, 17, 17, 3, 4, 8, Strategy.LAZY2), /* level 7 */ + new CompressionParameters(17, 17, 17, 4, 4, 8, Strategy.LAZY2), /* level 8 */ + new CompressionParameters(17, 17, 17, 5, 4, 8, Strategy.LAZY2), /* level 9 */ + new CompressionParameters(17, 17, 17, 6, 4, 8, Strategy.LAZY2), /* level 10 */ + new CompressionParameters(17, 17, 17, 7, 4, 8, Strategy.LAZY2), /* level 11 */ + new CompressionParameters(17, 18, 17, 6, 4, 16, Strategy.BTLAZY2), /* level 12 */ + new CompressionParameters(17, 18, 17, 8, 4, 16, Strategy.BTLAZY2), /* level 13.*/ + new CompressionParameters(17, 18, 17, 4, 4, 32, Strategy.BTOPT), /* level 14.*/ + new CompressionParameters(17, 18, 17, 6, 3, 64, Strategy.BTOPT), /* level 15.*/ + new CompressionParameters(17, 18, 17, 7, 3, 128, Strategy.BTOPT), /* level 16.*/ + new CompressionParameters(17, 18, 17, 7, 3, 256, Strategy.BTOPT), /* level 17.*/ + new CompressionParameters(17, 18, 17, 8, 3, 256, Strategy.BTOPT), /* level 18.*/ + new CompressionParameters(17, 18, 17, 8, 3, 256, Strategy.BTULTRA), /* level 19.*/ + new CompressionParameters(17, 18, 17, 9, 3, 256, Strategy.BTULTRA), /* level 20.*/ + new CompressionParameters(17, 18, 17, 10, 3, 256, Strategy.BTULTRA), /* level 21.*/ + new CompressionParameters(17, 18, 17, 11, 3, 512, Strategy.BTULTRA) /* level 22.*/ + }, + { + // for size <= 16 KB + new CompressionParameters(14, 12, 13, 1, 5, 1, Strategy.FAST), /* base for negative levels */ + new CompressionParameters(14, 14, 15, 1, 5, 0, Strategy.FAST), /* level 1 */ + new CompressionParameters(14, 14, 15, 1, 4, 0, Strategy.FAST), /* level 2 */ + new CompressionParameters(14, 14, 14, 2, 4, 1, Strategy.DFAST), /* level 3.*/ + new CompressionParameters(14, 14, 14, 4, 4, 2, Strategy.GREEDY), /* level 4.*/ + new CompressionParameters(14, 14, 14, 3, 4, 4, Strategy.LAZY), /* level 5.*/ + new CompressionParameters(14, 14, 14, 4, 4, 8, Strategy.LAZY2), /* level 6 */ + new CompressionParameters(14, 14, 14, 6, 4, 8, Strategy.LAZY2), /* level 7 */ + new CompressionParameters(14, 14, 14, 8, 4, 8, Strategy.LAZY2), /* level 8.*/ + new CompressionParameters(14, 15, 14, 5, 4, 8, Strategy.BTLAZY2), /* level 9.*/ + new CompressionParameters(14, 15, 14, 9, 4, 8, Strategy.BTLAZY2), /* level 10.*/ + new CompressionParameters(14, 15, 14, 3, 4, 12, Strategy.BTOPT), /* level 11.*/ + new CompressionParameters(14, 15, 14, 6, 3, 16, Strategy.BTOPT), /* level 12.*/ + new CompressionParameters(14, 15, 14, 6, 3, 24, Strategy.BTOPT), /* level 13.*/ + new CompressionParameters(14, 15, 15, 6, 3, 48, Strategy.BTOPT), /* level 14.*/ + new CompressionParameters(14, 15, 15, 6, 3, 64, Strategy.BTOPT), /* level 15.*/ + new CompressionParameters(14, 15, 15, 6, 3, 96, Strategy.BTOPT), /* level 16.*/ + new CompressionParameters(14, 15, 15, 6, 3, 128, Strategy.BTOPT), /* level 17.*/ + new CompressionParameters(14, 15, 15, 8, 3, 256, Strategy.BTOPT), /* level 18.*/ + new CompressionParameters(14, 15, 15, 6, 3, 256, Strategy.BTULTRA), /* level 19.*/ + new CompressionParameters(14, 15, 15, 8, 3, 256, Strategy.BTULTRA), /* level 20.*/ + new CompressionParameters(14, 15, 15, 9, 3, 256, Strategy.BTULTRA), /* level 21.*/ + new CompressionParameters(14, 15, 15, 10, 3, 512, Strategy.BTULTRA) /* level 22.*/ + } + }; + + public enum Strategy + { + // from faster to stronger + + FAST(BlockCompressor.UNSUPPORTED), + DFAST(new DoubleFastBlockCompressor()), + GREEDY(BlockCompressor.UNSUPPORTED), + LAZY(BlockCompressor.UNSUPPORTED), + LAZY2(BlockCompressor.UNSUPPORTED), + BTLAZY2(BlockCompressor.UNSUPPORTED), + BTOPT(BlockCompressor.UNSUPPORTED), + BTULTRA(BlockCompressor.UNSUPPORTED); + + private final BlockCompressor compressor; + + Strategy(BlockCompressor compressor) + { + this.compressor = compressor; + } + + public BlockCompressor getCompressor() + { + return compressor; + } + } + + public CompressionParameters(int windowLog, int chainLog, int hashLog, int searchLog, int searchLength, int targetLength, Strategy strategy) + { + this.windowLog = windowLog; + this.windowSize = 1 << windowLog; + this.blockSize = Math.min(MAX_BLOCK_SIZE, windowSize); + this.chainLog = chainLog; + this.hashLog = hashLog; + this.searchLog = searchLog; + this.searchLength = searchLength; + this.targetLength = targetLength; + this.strategy = strategy; + } + + public int getWindowLog() + { + return windowLog; + } + + public int getWindowSize() + { + return windowSize; + } + + public int getBlockSize() + { + return blockSize; + } + + public int getSearchLength() + { + return searchLength; + } + + public int getChainLog() + { + return chainLog; + } + + public int getHashLog() + { + return hashLog; + } + + public int getSearchLog() + { + return searchLog; + } + + public int getTargetLength() + { + return targetLength; + } + + public Strategy getStrategy() + { + return strategy; + } + + public static CompressionParameters compute(int compressionLevel, int estimatedInputSize) + { + CompressionParameters defaultParameters = getDefaultParameters(compressionLevel, estimatedInputSize); + if (estimatedInputSize < 0) { + return defaultParameters; + } + + int targetLength = defaultParameters.targetLength; + int windowLog = defaultParameters.windowLog; + int chainLog = defaultParameters.chainLog; + int hashLog = defaultParameters.hashLog; + int searchLog = defaultParameters.searchLog; + int searchLength = defaultParameters.searchLength; + Strategy strategy = defaultParameters.strategy; + + if (compressionLevel < 0) { + targetLength = -compressionLevel; // acceleration factor + } + + long maxWindowResize = 1L << (MAX_WINDOW_LOG - 1); + if (estimatedInputSize < maxWindowResize) { + int hashSizeMin = 1 << MIN_HASH_LOG; + int inputSizeLog = (estimatedInputSize < hashSizeMin) ? MIN_HASH_LOG : highestBit(estimatedInputSize - 1) + 1; + if (windowLog > inputSizeLog) { + windowLog = inputSizeLog; + } + } + + if (hashLog > windowLog + 1) { + hashLog = windowLog + 1; + } + + int cycleLog = cycleLog(chainLog, strategy); + if (cycleLog > windowLog) { + chainLog -= (cycleLog - windowLog); + } + + if (windowLog < MIN_WINDOW_LOG) { + windowLog = MIN_WINDOW_LOG; + } + + return new CompressionParameters(windowLog, chainLog, hashLog, searchLog, searchLength, targetLength, strategy); + } + + private static CompressionParameters getDefaultParameters(int compressionLevel, long estimatedInputSize) + { + int table = 0; + + if (estimatedInputSize >= 0) { + if (estimatedInputSize <= 16 * 1024) { + table = 3; + } + else if (estimatedInputSize <= 128 * 1024) { + table = 2; + } + else if (estimatedInputSize <= 256 * 1024) { + table = 1; + } + } + + int row = DEFAULT_COMPRESSION_LEVEL; + + if (compressionLevel != 0) { // TODO: figure out better way to indicate default compression level + row = clamp(compressionLevel, 0, MAX_COMPRESSION_LEVEL); + } + + return DEFAULT_COMPRESSION_PARAMETERS[table][row]; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/Constants.java b/src/main/java/io/airlift/compress/v3/zstdFFM/Constants.java new file mode 100644 index 00000000..587ff5f7 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/Constants.java @@ -0,0 +1,83 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +final class Constants +{ + public static final int SIZE_OF_BYTE = 1; + public static final int SIZE_OF_SHORT = 2; + public static final int SIZE_OF_INT = 4; + public static final int SIZE_OF_LONG = 8; + + public static final int MAGIC_NUMBER = 0xFD2FB528; + + public static final int MIN_WINDOW_LOG = 10; + public static final int MAX_WINDOW_LOG = 31; + + public static final int SIZE_OF_BLOCK_HEADER = 3; + + public static final int MIN_SEQUENCES_SIZE = 1; + public static final int MIN_BLOCK_SIZE = 1 // block type tag + + 1 // min size of raw or rle length header + + MIN_SEQUENCES_SIZE; + public static final int MAX_BLOCK_SIZE = 128 * 1024; + + public static final int REPEATED_OFFSET_COUNT = 3; + + // block types + public static final int RAW_BLOCK = 0; + public static final int RLE_BLOCK = 1; + public static final int COMPRESSED_BLOCK = 2; + + // sequence encoding types + public static final int SEQUENCE_ENCODING_BASIC = 0; + public static final int SEQUENCE_ENCODING_RLE = 1; + public static final int SEQUENCE_ENCODING_COMPRESSED = 2; + public static final int SEQUENCE_ENCODING_REPEAT = 3; + + public static final int MAX_LITERALS_LENGTH_SYMBOL = 35; + public static final int MAX_MATCH_LENGTH_SYMBOL = 52; + public static final int MAX_OFFSET_CODE_SYMBOL = 31; + public static final int DEFAULT_MAX_OFFSET_CODE_SYMBOL = 28; + + public static final int LITERAL_LENGTH_TABLE_LOG = 9; + public static final int MATCH_LENGTH_TABLE_LOG = 9; + public static final int OFFSET_TABLE_LOG = 8; + + // literal block types + public static final int RAW_LITERALS_BLOCK = 0; + public static final int RLE_LITERALS_BLOCK = 1; + public static final int COMPRESSED_LITERALS_BLOCK = 2; + public static final int TREELESS_LITERALS_BLOCK = 3; + + public static final int LONG_NUMBER_OF_SEQUENCES = 0x7F00; + + public static final int[] LITERALS_LENGTH_BITS = {0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 2, 2, 3, 3, + 4, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16}; + + public static final int[] MATCH_LENGTH_BITS = {0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 2, 2, 3, 3, + 4, 4, 5, 7, 8, 9, 10, 11, + 12, 13, 14, 15, 16}; + + private Constants() + { + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/DoubleFastBlockCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/DoubleFastBlockCompressor.java new file mode 100644 index 00000000..1460ccfa --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/DoubleFastBlockCompressor.java @@ -0,0 +1,251 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; + +class DoubleFastBlockCompressor + implements BlockCompressor +{ + private static final int MIN_MATCH = 3; + private static final int SEARCH_STRENGTH = 8; + private static final int REP_MOVE = Constants.REPEATED_OFFSET_COUNT - 1; + + @Override + public int compressBlock(MemorySegment inputBase, final long inputAddress, int inputSize, SequenceStore output, BlockCompressionState state, RepeatedOffsets offsets, CompressionParameters parameters) + { + int matchSearchLength = Math.max(parameters.getSearchLength(), 4); + + final long baseAddress = state.getBaseAddress(); + final long windowBaseAddress = baseAddress + state.getWindowBaseOffset(); + + int[] longHashTable = state.hashTable; + int longHashBits = parameters.getHashLog(); + + int[] shortHashTable = state.chainTable; + int shortHashBits = parameters.getChainLog(); + + final long inputEnd = inputAddress + inputSize; + final long inputLimit = inputEnd - SIZE_OF_LONG; + + long input = inputAddress; + long anchor = inputAddress; + + int offset1 = offsets.getOffset0(); + int offset2 = offsets.getOffset1(); + + int savedOffset = 0; + + if (input - windowBaseAddress == 0) { + input++; + } + int maxRep = (int) (input - windowBaseAddress); + + if (offset2 > maxRep) { + savedOffset = offset2; + offset2 = 0; + } + + if (offset1 > maxRep) { + savedOffset = offset1; + offset1 = 0; + } + + while (input < inputLimit) { + int shortHash = hash(inputBase, input, shortHashBits, matchSearchLength); + long shortMatchAddress = baseAddress + shortHashTable[shortHash]; + + int longHash = hash8(inputBase.get(JAVA_LONG, input), longHashBits); + long longMatchAddress = baseAddress + longHashTable[longHash]; + + // update hash tables + int current = (int) (input - baseAddress); + longHashTable[longHash] = current; + shortHashTable[shortHash] = current; + + int matchLength; + int offset; + + if (offset1 > 0 && inputBase.get(JAVA_INT, input + 1 - offset1) == inputBase.get(JAVA_INT, input + 1)) { + matchLength = count(inputBase, input + 1 + SIZE_OF_INT, inputEnd, input + 1 + SIZE_OF_INT - offset1) + SIZE_OF_INT; + input++; + output.storeSequence(inputBase, anchor, (int) (input - anchor), 0, matchLength - MIN_MATCH); + } + else { + if (longMatchAddress > windowBaseAddress && inputBase.get(JAVA_LONG, longMatchAddress) == inputBase.get(JAVA_LONG, input)) { + matchLength = count(inputBase, input + SIZE_OF_LONG, inputEnd, longMatchAddress + SIZE_OF_LONG) + SIZE_OF_LONG; + offset = (int) (input - longMatchAddress); + while (input > anchor && longMatchAddress > windowBaseAddress && inputBase.get(JAVA_BYTE, input - 1) == inputBase.get(JAVA_BYTE, longMatchAddress - 1)) { + input--; + longMatchAddress--; + matchLength++; + } + } + else { + if (shortMatchAddress > windowBaseAddress && inputBase.get(JAVA_INT, shortMatchAddress) == inputBase.get(JAVA_INT, input)) { + int nextOffsetHash = hash8(inputBase.get(JAVA_LONG, input + 1), longHashBits); + long nextOffsetMatchAddress = baseAddress + longHashTable[nextOffsetHash]; + longHashTable[nextOffsetHash] = current + 1; + + if (nextOffsetMatchAddress > windowBaseAddress && inputBase.get(JAVA_LONG, nextOffsetMatchAddress) == inputBase.get(JAVA_LONG, input + 1)) { + matchLength = count(inputBase, input + 1 + SIZE_OF_LONG, inputEnd, nextOffsetMatchAddress + SIZE_OF_LONG) + SIZE_OF_LONG; + input++; + offset = (int) (input - nextOffsetMatchAddress); + while (input > anchor && nextOffsetMatchAddress > windowBaseAddress && inputBase.get(JAVA_BYTE, input - 1) == inputBase.get(JAVA_BYTE, nextOffsetMatchAddress - 1)) { + input--; + nextOffsetMatchAddress--; + matchLength++; + } + } + else { + matchLength = count(inputBase, input + SIZE_OF_INT, inputEnd, shortMatchAddress + SIZE_OF_INT) + SIZE_OF_INT; + offset = (int) (input - shortMatchAddress); + while (input > anchor && shortMatchAddress > windowBaseAddress && inputBase.get(JAVA_BYTE, input - 1) == inputBase.get(JAVA_BYTE, shortMatchAddress - 1)) { + input--; + shortMatchAddress--; + matchLength++; + } + } + } + else { + input += ((input - anchor) >> SEARCH_STRENGTH) + 1; + continue; + } + } + + offset2 = offset1; + offset1 = offset; + + output.storeSequence(inputBase, anchor, (int) (input - anchor), offset + REP_MOVE, matchLength - MIN_MATCH); + } + + input += matchLength; + anchor = input; + + if (input <= inputLimit) { + // Fill Table + longHashTable[hash8(inputBase.get(JAVA_LONG, baseAddress + current + 2), longHashBits)] = current + 2; + shortHashTable[hash(inputBase, baseAddress + current + 2, shortHashBits, matchSearchLength)] = current + 2; + + longHashTable[hash8(inputBase.get(JAVA_LONG, input - 2), longHashBits)] = (int) (input - 2 - baseAddress); + shortHashTable[hash(inputBase, input - 2, shortHashBits, matchSearchLength)] = (int) (input - 2 - baseAddress); + + while (input <= inputLimit && offset2 > 0 && inputBase.get(JAVA_INT, input) == inputBase.get(JAVA_INT, input - offset2)) { + int repetitionLength = count(inputBase, input + SIZE_OF_INT, inputEnd, input + SIZE_OF_INT - offset2) + SIZE_OF_INT; + + int temp = offset2; + offset2 = offset1; + offset1 = temp; + + shortHashTable[hash(inputBase, input, shortHashBits, matchSearchLength)] = (int) (input - baseAddress); + longHashTable[hash8(inputBase.get(JAVA_LONG, input), longHashBits)] = (int) (input - baseAddress); + + output.storeSequence(inputBase, anchor, 0, 0, repetitionLength - MIN_MATCH); + + input += repetitionLength; + anchor = input; + } + } + } + + offsets.saveOffset0(offset1 != 0 ? offset1 : savedOffset); + offsets.saveOffset1(offset2 != 0 ? offset2 : savedOffset); + + return (int) (inputEnd - anchor); + } + + /** + * matchAddress must be < inputAddress + */ + public static int count(MemorySegment inputBase, final long inputAddress, final long inputLimit, final long matchAddress) + { + long input = inputAddress; + long match = matchAddress; + + int remaining = (int) (inputLimit - inputAddress); + + int count = 0; + while (count < remaining - (SIZE_OF_LONG - 1)) { + long diff = inputBase.get(JAVA_LONG, match) ^ inputBase.get(JAVA_LONG, input); + if (diff != 0) { + return count + (Long.numberOfTrailingZeros(diff) >> 3); + } + + count += SIZE_OF_LONG; + input += SIZE_OF_LONG; + match += SIZE_OF_LONG; + } + + while (count < remaining && inputBase.get(JAVA_BYTE, match) == inputBase.get(JAVA_BYTE, input)) { + count++; + input++; + match++; + } + + return count; + } + + private static int hash(MemorySegment inputBase, long inputAddress, int bits, int matchSearchLength) + { + switch (matchSearchLength) { + case 8: + return hash8(inputBase.get(JAVA_LONG, inputAddress), bits); + case 7: + return hash7(inputBase.get(JAVA_LONG, inputAddress), bits); + case 6: + return hash6(inputBase.get(JAVA_LONG, inputAddress), bits); + case 5: + return hash5(inputBase.get(JAVA_LONG, inputAddress), bits); + default: + return hash4(inputBase.get(JAVA_INT, inputAddress), bits); + } + } + + private static final int PRIME_4_BYTES = 0x9E3779B1; + private static final long PRIME_5_BYTES = 0xCF1BBCDCBBL; + private static final long PRIME_6_BYTES = 0xCF1BBCDCBF9BL; + private static final long PRIME_7_BYTES = 0xCF1BBCDCBFA563L; + private static final long PRIME_8_BYTES = 0xCF1BBCDCB7A56463L; + + private static int hash4(int value, int bits) + { + return (value * PRIME_4_BYTES) >>> (Integer.SIZE - bits); + } + + private static int hash5(long value, int bits) + { + return (int) (((value << (Long.SIZE - 40)) * PRIME_5_BYTES) >>> (Long.SIZE - bits)); + } + + private static int hash6(long value, int bits) + { + return (int) (((value << (Long.SIZE - 48)) * PRIME_6_BYTES) >>> (Long.SIZE - bits)); + } + + private static int hash7(long value, int bits) + { + return (int) (((value << (Long.SIZE - 56)) * PRIME_7_BYTES) >>> (Long.SIZE - bits)); + } + + private static int hash8(long value, int bits) + { + return (int) ((value * PRIME_8_BYTES) >>> (Long.SIZE - bits)); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FfmUtil.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FfmUtil.java new file mode 100644 index 00000000..2aa025d5 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FfmUtil.java @@ -0,0 +1,44 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.IncompatibleJvmException; + +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; +import java.nio.ByteOrder; + +import static java.lang.String.format; + +final class FfmUtil +{ + public static final ValueLayout.OfByte JAVA_BYTE = ValueLayout.JAVA_BYTE; + public static final ValueLayout.OfShort JAVA_SHORT = ValueLayout.JAVA_SHORT_UNALIGNED; + public static final ValueLayout.OfInt JAVA_INT = ValueLayout.JAVA_INT_UNALIGNED; + public static final ValueLayout.OfLong JAVA_LONG = ValueLayout.JAVA_LONG_UNALIGNED; + + private FfmUtil() {} + + static { + ByteOrder order = ByteOrder.nativeOrder(); + if (!order.equals(ByteOrder.LITTLE_ENDIAN)) { + throw new IncompatibleJvmException(format("Zstandard requires a little endian platform (found %s)", order)); + } + } + + public static MemorySegment ofArray(byte[] array) + { + return MemorySegment.ofArray(array); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FiniteStateEntropy.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FiniteStateEntropy.java new file mode 100644 index 00000000..44a3fb27 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FiniteStateEntropy.java @@ -0,0 +1,534 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.BitInputStream.peekBits; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static io.airlift.compress.v3.zstdFFM.Util.verify; + +final class FiniteStateEntropy +{ + public static final int MAX_SYMBOL = 255; + public static final int MAX_TABLE_LOG = 12; + public static final int MIN_TABLE_LOG = 5; + + private static final int[] REST_TO_BEAT = new int[] {0, 473195, 504333, 520860, 550000, 700000, 750000, 830000}; + private static final short UNASSIGNED = -2; + + private FiniteStateEntropy() + { + } + + public static int decompress(FiniteStateEntropy.Table table, final MemorySegment inputBase, final long inputAddress, final long inputLimit, byte[] outputBuffer) + { + final MemorySegment outputBase = MemorySegment.ofArray(outputBuffer); + final long outputAddress = 0; + final long outputLimit = outputAddress + outputBuffer.length; + + long input = inputAddress; + long output = outputAddress; + + // initialize bit stream + BitInputStream.Initializer initializer = new BitInputStream.Initializer(inputBase, input, inputLimit); + initializer.initialize(); + int bitsConsumed = initializer.getBitsConsumed(); + long currentAddress = initializer.getCurrentAddress(); + long bits = initializer.getBits(); + + // initialize first FSE stream + int state1 = (int) peekBits(bitsConsumed, bits, table.log2Size); + bitsConsumed += table.log2Size; + + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bits = loader.getBits(); + bitsConsumed = loader.getBitsConsumed(); + currentAddress = loader.getCurrentAddress(); + + // initialize second FSE stream + int state2 = (int) peekBits(bitsConsumed, bits, table.log2Size); + bitsConsumed += table.log2Size; + + loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bits = loader.getBits(); + bitsConsumed = loader.getBitsConsumed(); + currentAddress = loader.getCurrentAddress(); + + byte[] symbols = table.symbol; + byte[] numbersOfBits = table.numberOfBits; + int[] newStates = table.newState; + + // decode 4 symbols per loop + while (output <= outputLimit - 4) { + int numberOfBits; + + outputBase.set(JAVA_BYTE, output, symbols[state1]); + numberOfBits = numbersOfBits[state1]; + state1 = (int) (newStates[state1] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + outputBase.set(JAVA_BYTE, output + 1, symbols[state2]); + numberOfBits = numbersOfBits[state2]; + state2 = (int) (newStates[state2] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + outputBase.set(JAVA_BYTE, output + 2, symbols[state1]); + numberOfBits = numbersOfBits[state1]; + state1 = (int) (newStates[state1] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + outputBase.set(JAVA_BYTE, output + 3, symbols[state2]); + numberOfBits = numbersOfBits[state2]; + state2 = (int) (newStates[state2] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + output += SIZE_OF_INT; + + loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + boolean done = loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + } + + while (true) { + verify(output <= outputLimit - 2, input, "Output buffer is too small"); + outputBase.set(JAVA_BYTE, output++, symbols[state1]); + int numberOfBits = numbersOfBits[state1]; + state1 = (int) (newStates[state1] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + + if (loader.isOverflow()) { + outputBase.set(JAVA_BYTE, output++, symbols[state2]); + break; + } + + verify(output <= outputLimit - 2, input, "Output buffer is too small"); + outputBase.set(JAVA_BYTE, output++, symbols[state2]); + int numberOfBits1 = numbersOfBits[state2]; + state2 = (int) (newStates[state2] + peekBits(bitsConsumed, bits, numberOfBits1)); + bitsConsumed += numberOfBits1; + + loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + + if (loader.isOverflow()) { + outputBase.set(JAVA_BYTE, output++, symbols[state1]); + break; + } + } + + return (int) (output - outputAddress); + } + + public static int compress(MemorySegment outputBase, long outputAddress, int outputSize, byte[] input, int inputSize, FseCompressionTable table) + { + return compress(outputBase, outputAddress, outputSize, MemorySegment.ofArray(input), 0, inputSize, table); + } + + public static int compress(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize, FseCompressionTable table) + { + checkArgument(outputSize >= SIZE_OF_LONG, "Output buffer too small"); + + final long start = inputAddress; + final long inputLimit = start + inputSize; + + long input = inputLimit; + + if (inputSize <= 2) { + return 0; + } + + BitOutputStream stream = new BitOutputStream(outputBase, outputAddress, outputSize); + + int state1; + int state2; + + if ((inputSize & 1) != 0) { + input--; + state1 = table.begin(inputBase.get(JAVA_BYTE, input)); + + input--; + state2 = table.begin(inputBase.get(JAVA_BYTE, input)); + + input--; + state1 = table.encode(stream, state1, inputBase.get(JAVA_BYTE, input)); + + stream.flush(); + } + else { + input--; + state2 = table.begin(inputBase.get(JAVA_BYTE, input)); + + input--; + state1 = table.begin(inputBase.get(JAVA_BYTE, input)); + } + + // join to mod 4 + inputSize -= 2; + + if ((SIZE_OF_LONG * 8 > MAX_TABLE_LOG * 4 + 7) && (inputSize & 2) != 0) { /* test bit 2 */ + input--; + state2 = table.encode(stream, state2, inputBase.get(JAVA_BYTE, input)); + + input--; + state1 = table.encode(stream, state1, inputBase.get(JAVA_BYTE, input)); + + stream.flush(); + } + + // 2 or 4 encoding per loop + while (input > start) { + input--; + state2 = table.encode(stream, state2, inputBase.get(JAVA_BYTE, input)); + + if (SIZE_OF_LONG * 8 < MAX_TABLE_LOG * 2 + 7) { + stream.flush(); + } + + input--; + state1 = table.encode(stream, state1, inputBase.get(JAVA_BYTE, input)); + + if (SIZE_OF_LONG * 8 > MAX_TABLE_LOG * 4 + 7) { + input--; + state2 = table.encode(stream, state2, inputBase.get(JAVA_BYTE, input)); + + input--; + state1 = table.encode(stream, state1, inputBase.get(JAVA_BYTE, input)); + } + + stream.flush(); + } + + table.finish(stream, state2); + table.finish(stream, state1); + + return stream.close(); + } + + public static int optimalTableLog(int maxTableLog, int inputSize, int maxSymbol) + { + if (inputSize <= 1) { + throw new IllegalArgumentException(); // not supported. Use RLE instead + } + + int result = maxTableLog; + + result = Math.min(result, Util.highestBit((inputSize - 1)) - 2); // we may be able to reduce accuracy if input is small + + // Need a minimum to safely represent all symbol values + result = Math.max(result, Util.minTableLog(inputSize, maxSymbol)); + + result = Math.max(result, MIN_TABLE_LOG); + result = Math.min(result, MAX_TABLE_LOG); + + return result; + } + + public static int normalizeCounts(short[] normalizedCounts, int tableLog, int[] counts, int total, int maxSymbol) + { + checkArgument(tableLog >= MIN_TABLE_LOG, "Unsupported FSE table size"); + checkArgument(tableLog <= MAX_TABLE_LOG, "FSE table size too large"); + checkArgument(tableLog >= Util.minTableLog(total, maxSymbol), "FSE table size too small"); + + long scale = 62 - tableLog; + long step = (1L << 62) / total; + long vstep = 1L << (scale - 20); + + int stillToDistribute = 1 << tableLog; + + int largest = 0; + short largestProbability = 0; + int lowThreshold = total >>> tableLog; + + for (int symbol = 0; symbol <= maxSymbol; symbol++) { + if (counts[symbol] == total) { + throw new IllegalArgumentException(); // TODO: should have been RLE-compressed by upper layers + } + if (counts[symbol] == 0) { + normalizedCounts[symbol] = 0; + continue; + } + if (counts[symbol] <= lowThreshold) { + normalizedCounts[symbol] = -1; + stillToDistribute--; + } + else { + short probability = (short) ((counts[symbol] * step) >>> scale); + if (probability < 8) { + long restToBeat = vstep * REST_TO_BEAT[probability]; + long delta = counts[symbol] * step - (((long) probability) << scale); + if (delta > restToBeat) { + probability++; + } + } + if (probability > largestProbability) { + largestProbability = probability; + largest = symbol; + } + normalizedCounts[symbol] = probability; + stillToDistribute -= probability; + } + } + + if (-stillToDistribute >= (normalizedCounts[largest] >>> 1)) { + // corner case. Need another normalization method + normalizeCounts2(normalizedCounts, tableLog, counts, total, maxSymbol); + } + else { + normalizedCounts[largest] += (short) stillToDistribute; + } + + return tableLog; + } + + private static int normalizeCounts2(short[] normalizedCounts, int tableLog, int[] counts, int total, int maxSymbol) + { + int distributed = 0; + + int lowThreshold = total >>> tableLog; + int lowOne = (total * 3) >>> (tableLog + 1); + + for (int i = 0; i <= maxSymbol; i++) { + if (counts[i] == 0) { + normalizedCounts[i] = 0; + } + else if (counts[i] <= lowThreshold) { + normalizedCounts[i] = -1; + distributed++; + total -= counts[i]; + } + else if (counts[i] <= lowOne) { + normalizedCounts[i] = 1; + distributed++; + total -= counts[i]; + } + else { + normalizedCounts[i] = UNASSIGNED; + } + } + + int normalizationFactor = 1 << tableLog; + int toDistribute = normalizationFactor - distributed; + + if ((total / toDistribute) > lowOne) { + lowOne = ((total * 3) / (toDistribute * 2)); + for (int i = 0; i <= maxSymbol; i++) { + if ((normalizedCounts[i] == UNASSIGNED) && (counts[i] <= lowOne)) { + normalizedCounts[i] = 1; + distributed++; + total -= counts[i]; + } + } + toDistribute = normalizationFactor - distributed; + } + + if (distributed == maxSymbol + 1) { + int maxValue = 0; + int maxCount = 0; + for (int i = 0; i <= maxSymbol; i++) { + if (counts[i] > maxCount) { + maxValue = i; + maxCount = counts[i]; + } + } + normalizedCounts[maxValue] += (short) toDistribute; + return 0; + } + + if (total == 0) { + for (int i = 0; toDistribute > 0; i = (i + 1) % (maxSymbol + 1)) { + if (normalizedCounts[i] > 0) { + toDistribute--; + normalizedCounts[i]++; + } + } + return 0; + } + + long vStepLog = 62 - tableLog; + long mid = (1L << (vStepLog - 1)) - 1; + long rStep = (((1L << vStepLog) * toDistribute) + mid) / total; + long tmpTotal = mid; + for (int i = 0; i <= maxSymbol; i++) { + if (normalizedCounts[i] == UNASSIGNED) { + long end = tmpTotal + (counts[i] * rStep); + int sStart = (int) (tmpTotal >>> vStepLog); + int sEnd = (int) (end >>> vStepLog); + int weight = sEnd - sStart; + + if (weight < 1) { + throw new AssertionError(); + } + normalizedCounts[i] = (short) weight; + tmpTotal = end; + } + } + + return 0; + } + + public static int writeNormalizedCounts(MemorySegment outputBase, long outputAddress, int outputSize, short[] normalizedCounts, int maxSymbol, int tableLog) + { + checkArgument(tableLog <= MAX_TABLE_LOG, "FSE table too large"); + checkArgument(tableLog >= MIN_TABLE_LOG, "FSE table too small"); + + long output = outputAddress; + long outputLimit = outputAddress + outputSize; + + int tableSize = 1 << tableLog; + + int bitCount = 0; + + // encode table size + int bitStream = (tableLog - MIN_TABLE_LOG); + bitCount += 4; + + int remaining = tableSize + 1; // +1 for extra accuracy + int threshold = tableSize; + int tableBitCount = tableLog + 1; + + int symbol = 0; + + boolean previousIs0 = false; + while (remaining > 1) { + if (previousIs0) { + int start = symbol; + + // find run of symbols with count 0 + while (normalizedCounts[symbol] == 0) { + symbol++; + } + + while (symbol >= start + 24) { + start += 24; + bitStream |= (0b11_11_11_11_11_11_11_11 << bitCount); + checkArgument(output + Constants.SIZE_OF_SHORT <= outputLimit, "Output buffer too small"); + + outputBase.set(JAVA_SHORT, output, (short) bitStream); + output += Constants.SIZE_OF_SHORT; + + bitStream >>>= Short.SIZE; + } + + while (symbol >= start + 3) { + start += 3; + bitStream |= 0b11 << bitCount; + bitCount += 2; + } + + bitStream |= (symbol - start) << bitCount; + bitCount += 2; + + if (bitCount > 16) { + checkArgument(output + Constants.SIZE_OF_SHORT <= outputLimit, "Output buffer too small"); + + outputBase.set(JAVA_SHORT, output, (short) bitStream); + output += Constants.SIZE_OF_SHORT; + + bitStream >>>= Short.SIZE; + bitCount -= Short.SIZE; + } + } + + int count = normalizedCounts[symbol++]; + int max = (2 * threshold - 1) - remaining; + remaining -= count < 0 ? -count : count; + count++; /* +1 for extra accuracy */ + if (count >= threshold) { + count += max; + } + bitStream |= count << bitCount; + bitCount += tableBitCount; + bitCount -= (count < max ? 1 : 0); + previousIs0 = (count == 1); + + if (remaining < 1) { + throw new AssertionError(); + } + + while (remaining < threshold) { + tableBitCount--; + threshold >>= 1; + } + + if (bitCount > 16) { + checkArgument(output + Constants.SIZE_OF_SHORT <= outputLimit, "Output buffer too small"); + + outputBase.set(JAVA_SHORT, output, (short) bitStream); + output += Constants.SIZE_OF_SHORT; + + bitStream >>>= Short.SIZE; + bitCount -= Short.SIZE; + } + } + + // flush remaining bitstream + checkArgument(output + Constants.SIZE_OF_SHORT <= outputLimit, "Output buffer too small"); + outputBase.set(JAVA_SHORT, output, (short) bitStream); + output += (bitCount + 7) / 8; + + checkArgument(symbol <= maxSymbol + 1, "Error"); // TODO + + return (int) (output - outputAddress); + } + + public static final class Table + { + int log2Size; + final int[] newState; + final byte[] symbol; + final byte[] numberOfBits; + + public Table(int log2Capacity) + { + int capacity = 1 << log2Capacity; + newState = new int[capacity]; + symbol = new byte[capacity]; + numberOfBits = new byte[capacity]; + } + + public Table(int log2Size, int[] newState, byte[] symbol, byte[] numberOfBits) + { + int size = 1 << log2Size; + if (newState.length != size || symbol.length != size || numberOfBits.length != size) { + throw new IllegalArgumentException("Expected arrays to match provided size"); + } + + this.log2Size = log2Size; + this.newState = newState; + this.symbol = symbol; + this.numberOfBits = numberOfBits; + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FrameHeader.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FrameHeader.java new file mode 100644 index 00000000..504d04d8 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FrameHeader.java @@ -0,0 +1,86 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.util.Objects; +import java.util.StringJoiner; + +import static io.airlift.compress.v3.zstdFFM.Util.checkState; +import static java.lang.Math.min; +import static java.lang.Math.toIntExact; + +class FrameHeader +{ + final long headerSize; + final int windowSize; + final long contentSize; + final long dictionaryId; + final boolean hasChecksum; + + public FrameHeader(long headerSize, int windowSize, long contentSize, long dictionaryId, boolean hasChecksum) + { + checkState(windowSize >= 0 || contentSize >= 0, "Invalid frame header: contentSize or windowSize must be set"); + this.headerSize = headerSize; + this.windowSize = windowSize; + this.contentSize = contentSize; + this.dictionaryId = dictionaryId; + this.hasChecksum = hasChecksum; + } + + public int computeRequiredOutputBufferLookBackSize() + { + if (contentSize < 0) { + return windowSize; + } + if (windowSize < 0) { + return toIntExact(contentSize); + } + return toIntExact(min(windowSize, contentSize)); + } + + @Override + public boolean equals(Object o) + { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FrameHeader that = (FrameHeader) o; + return headerSize == that.headerSize && + windowSize == that.windowSize && + contentSize == that.contentSize && + dictionaryId == that.dictionaryId && + hasChecksum == that.hasChecksum; + } + + @Override + public int hashCode() + { + return Objects.hash(headerSize, windowSize, contentSize, dictionaryId, hasChecksum); + } + + @Override + public String toString() + { + return new StringJoiner(", ", FrameHeader.class.getSimpleName() + "[", "]") + .add("headerSize=" + headerSize) + .add("windowSize=" + windowSize) + .add("contentSize=" + contentSize) + .add("dictionaryId=" + dictionaryId) + .add("hasChecksum=" + hasChecksum) + .toString(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FseCompressionTable.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FseCompressionTable.java new file mode 100644 index 00000000..66c9b5dc --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FseCompressionTable.java @@ -0,0 +1,158 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.FiniteStateEntropy.MAX_SYMBOL; + +class FseCompressionTable +{ + private final short[] nextState; + private final int[] deltaNumberOfBits; + private final int[] deltaFindState; + + private int log2Size; + + public FseCompressionTable(int maxTableLog, int maxSymbol) + { + nextState = new short[1 << maxTableLog]; + deltaNumberOfBits = new int[maxSymbol + 1]; + deltaFindState = new int[maxSymbol + 1]; + } + + public static FseCompressionTable newInstance(short[] normalizedCounts, int maxSymbol, int tableLog) + { + FseCompressionTable result = new FseCompressionTable(tableLog, maxSymbol); + result.initialize(normalizedCounts, maxSymbol, tableLog); + + return result; + } + + public void initializeRleTable(int symbol) + { + log2Size = 0; + + nextState[0] = 0; + nextState[1] = 0; + + deltaFindState[symbol] = 0; + deltaNumberOfBits[symbol] = 0; + } + + public void initialize(short[] normalizedCounts, int maxSymbol, int tableLog) + { + int tableSize = 1 << tableLog; + + byte[] table = new byte[tableSize]; // TODO: allocate in workspace + int highThreshold = tableSize - 1; + + // TODO: make sure FseCompressionTable has enough size + log2Size = tableLog; + + // For explanations on how to distribute symbol values over the table: + // http://fastcompression.blogspot.fr/2014/02/fse-distributing-symbol-values.html + + // symbol start positions + int[] cumulative = new int[MAX_SYMBOL + 2]; // TODO: allocate in workspace + cumulative[0] = 0; + for (int i = 1; i <= maxSymbol + 1; i++) { + if (normalizedCounts[i - 1] == -1) { // Low probability symbol + cumulative[i] = cumulative[i - 1] + 1; + table[highThreshold--] = (byte) (i - 1); + } + else { + cumulative[i] = cumulative[i - 1] + normalizedCounts[i - 1]; + } + } + cumulative[maxSymbol + 1] = tableSize + 1; + + // Spread symbols + int position = spreadSymbols(normalizedCounts, maxSymbol, tableSize, highThreshold, table); + + if (position != 0) { + throw new AssertionError("Spread symbols failed"); + } + + // Build table + for (int i = 0; i < tableSize; i++) { + byte symbol = table[i]; + nextState[cumulative[symbol]++] = (short) (tableSize + i); /* TableU16 : sorted by symbol order; gives next state value */ + } + + // Build symbol transformation table + int total = 0; + for (int symbol = 0; symbol <= maxSymbol; symbol++) { + switch (normalizedCounts[symbol]) { + case 0: + deltaNumberOfBits[symbol] = ((tableLog + 1) << 16) - tableSize; + break; + case -1: + case 1: + deltaNumberOfBits[symbol] = (tableLog << 16) - tableSize; + deltaFindState[symbol] = total - 1; + total++; + break; + default: + int maxBitsOut = tableLog - Util.highestBit(normalizedCounts[symbol] - 1); + int minStatePlus = normalizedCounts[symbol] << maxBitsOut; + deltaNumberOfBits[symbol] = (maxBitsOut << 16) - minStatePlus; + deltaFindState[symbol] = total - normalizedCounts[symbol]; + total += normalizedCounts[symbol]; + break; + } + } + } + + public int begin(byte symbol) + { + int outputBits = (deltaNumberOfBits[symbol] + (1 << 15)) >>> 16; + int base = ((outputBits << 16) - deltaNumberOfBits[symbol]) >>> outputBits; + return nextState[base + deltaFindState[symbol]]; + } + + public int encode(BitOutputStream stream, int state, int symbol) + { + int outputBits = (state + deltaNumberOfBits[symbol]) >>> 16; + stream.addBits(state, outputBits); + return nextState[(state >>> outputBits) + deltaFindState[symbol]]; + } + + public void finish(BitOutputStream stream, int state) + { + stream.addBits(state, log2Size); + stream.flush(); + } + + private static int calculateStep(int tableSize) + { + return (tableSize >>> 1) + (tableSize >>> 3) + 3; + } + + public static int spreadSymbols(short[] normalizedCounters, int maxSymbolValue, int tableSize, int highThreshold, byte[] symbols) + { + int mask = tableSize - 1; + int step = calculateStep(tableSize); + + int position = 0; + for (byte symbol = 0; symbol <= maxSymbolValue; symbol++) { + for (int i = 0; i < normalizedCounters[symbol]; i++) { + symbols[position] = symbol; + do { + position = (position + step) & mask; + } + while (position > highThreshold); + } + } + return position; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FseTableReader.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FseTableReader.java new file mode 100644 index 00000000..c2427cc0 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FseTableReader.java @@ -0,0 +1,171 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FiniteStateEntropy.MAX_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.FiniteStateEntropy.MIN_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Util.highestBit; +import static io.airlift.compress.v3.zstdFFM.Util.verify; + +class FseTableReader +{ + private final short[] nextSymbol = new short[MAX_SYMBOL + 1]; + private final short[] normalizedCounters = new short[MAX_SYMBOL + 1]; + + public int readFseTable(FiniteStateEntropy.Table table, MemorySegment inputBase, long inputAddress, long inputLimit, int maxSymbol, int maxTableLog) + { + // read table headers + long input = inputAddress; + verify(inputLimit - inputAddress >= 4, input, "Not enough input bytes"); + + int threshold; + int symbolNumber = 0; + boolean previousIsZero = false; + + int bitStream = inputBase.get(JAVA_INT, input); + + int tableLog = (bitStream & 0xF) + MIN_TABLE_LOG; + + int numberOfBits = tableLog + 1; + bitStream >>>= 4; + int bitCount = 4; + + verify(tableLog <= maxTableLog, input, "FSE table size exceeds maximum allowed size"); + + int remaining = (1 << tableLog) + 1; + threshold = 1 << tableLog; + + while (remaining > 1 && symbolNumber <= maxSymbol) { + if (previousIsZero) { + int n0 = symbolNumber; + while ((bitStream & 0xFFFF) == 0xFFFF) { + n0 += 24; + if (input < inputLimit - 5) { + input += 2; + bitStream = (inputBase.get(JAVA_INT, input) >>> bitCount); + } + else { + // end of bit stream + bitStream >>>= 16; + bitCount += 16; + } + } + while ((bitStream & 3) == 3) { + n0 += 3; + bitStream >>>= 2; + bitCount += 2; + } + n0 += bitStream & 3; + bitCount += 2; + + verify(n0 <= maxSymbol, input, "Symbol larger than max value"); + + while (symbolNumber < n0) { + normalizedCounters[symbolNumber++] = 0; + } + if ((input <= inputLimit - 7) || (input + (bitCount >>> 3) <= inputLimit - 4)) { + input += bitCount >>> 3; + bitCount &= 7; + bitStream = inputBase.get(JAVA_INT, input) >>> bitCount; + } + else { + bitStream >>>= 2; + } + } + + short max = (short) ((2 * threshold - 1) - remaining); + short count; + + if ((bitStream & (threshold - 1)) < max) { + count = (short) (bitStream & (threshold - 1)); + bitCount += numberOfBits - 1; + } + else { + count = (short) (bitStream & (2 * threshold - 1)); + if (count >= threshold) { + count -= max; + } + bitCount += numberOfBits; + } + count--; // extra accuracy + + remaining -= Math.abs(count); + normalizedCounters[symbolNumber++] = count; + previousIsZero = count == 0; + while (remaining < threshold) { + numberOfBits--; + threshold >>>= 1; + } + + if ((input <= inputLimit - 7) || (input + (bitCount >> 3) <= inputLimit - 4)) { + input += bitCount >>> 3; + bitCount &= 7; + } + else { + bitCount -= (int) (8 * (inputLimit - 4 - input)); + input = inputLimit - 4; + } + bitStream = inputBase.get(JAVA_INT, input) >>> (bitCount & 31); + } + + verify(remaining == 1 && bitCount <= 32, input, "Input is corrupted"); + + maxSymbol = symbolNumber - 1; + verify(maxSymbol <= MAX_SYMBOL, input, "Max symbol value too large (too many symbols for FSE)"); + + input += (bitCount + 7) >> 3; + + // populate decoding table + int symbolCount = maxSymbol + 1; + int tableSize = 1 << tableLog; + int highThreshold = tableSize - 1; + + table.log2Size = tableLog; + + for (byte symbol = 0; symbol < symbolCount; symbol++) { + if (normalizedCounters[symbol] == -1) { + table.symbol[highThreshold--] = symbol; + nextSymbol[symbol] = 1; + } + else { + nextSymbol[symbol] = normalizedCounters[symbol]; + } + } + + int position = FseCompressionTable.spreadSymbols(normalizedCounters, maxSymbol, tableSize, highThreshold, table.symbol); + + // position must reach all cells once, otherwise normalizedCounter is incorrect + verify(position == 0, input, "Input is corrupted"); + + for (int i = 0; i < tableSize; i++) { + byte symbol = table.symbol[i]; + short nextState = nextSymbol[symbol]++; + table.numberOfBits[i] = (byte) (tableLog - highestBit(nextState)); + table.newState[i] = (short) ((nextState << table.numberOfBits[i]) - tableSize); + } + + return (int) (input - inputAddress); + } + + public static void initializeRleTable(FiniteStateEntropy.Table table, byte value) + { + table.log2Size = 0; + table.symbol[0] = value; + table.newState[0] = 0; + table.numberOfBits[0] = 0; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/Histogram.java b/src/main/java/io/airlift/compress/v3/zstdFFM/Histogram.java new file mode 100644 index 00000000..3363f93f --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/Histogram.java @@ -0,0 +1,65 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; + +final class Histogram +{ + private Histogram() + { + } + + // TODO: count parallel heuristic for large inputs + private static void count(MemorySegment inputBase, long inputAddress, int inputSize, int[] counts) + { + long input = inputAddress; + + Arrays.fill(counts, 0); + + for (int i = 0; i < inputSize; i++) { + int symbol = inputBase.get(JAVA_BYTE, input) & 0xFF; + input++; + counts[symbol]++; + } + } + + public static int findLargestCount(int[] counts, int maxSymbol) + { + int max = 0; + for (int i = 0; i <= maxSymbol; i++) { + if (counts[i] > max) { + max = counts[i]; + } + } + + return max; + } + + public static int findMaxSymbol(int[] counts, int maxSymbol) + { + while (counts[maxSymbol] == 0) { + maxSymbol--; + } + return maxSymbol; + } + + public static void count(byte[] input, int length, int[] counts) + { + count(MemorySegment.ofArray(input), 0, length, counts); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/Huffman.java b/src/main/java/io/airlift/compress/v3/zstdFFM/Huffman.java new file mode 100644 index 00000000..88d2d3a9 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/Huffman.java @@ -0,0 +1,327 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.BitInputStream.isEndOfStream; +import static io.airlift.compress.v3.zstdFFM.BitInputStream.peekBitsFast; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.Util.isPowerOf2; +import static io.airlift.compress.v3.zstdFFM.Util.verify; + +class Huffman +{ + public static final int MAX_SYMBOL = 255; + public static final int MAX_SYMBOL_COUNT = MAX_SYMBOL + 1; + + public static final int MAX_TABLE_LOG = 12; + public static final int MIN_TABLE_LOG = 5; + public static final int MAX_FSE_TABLE_LOG = 6; + + // stats + private final byte[] weights = new byte[MAX_SYMBOL + 1]; + private final int[] ranks = new int[MAX_TABLE_LOG + 1]; + + // table + private int tableLog = -1; + private final byte[] symbols = new byte[1 << MAX_TABLE_LOG]; + private final byte[] numbersOfBits = new byte[1 << MAX_TABLE_LOG]; + + private final FseTableReader reader = new FseTableReader(); + private final FiniteStateEntropy.Table fseTable = new FiniteStateEntropy.Table(MAX_FSE_TABLE_LOG); + + public boolean isLoaded() + { + return tableLog != -1; + } + + public int readTable(final MemorySegment inputBase, final long inputAddress, final int size) + { + Arrays.fill(ranks, 0); + long input = inputAddress; + + // read table header + verify(size > 0, input, "Not enough input bytes"); + int inputSize = inputBase.get(JAVA_BYTE, input++) & 0xFF; + + int outputSize; + if (inputSize >= 128) { + outputSize = inputSize - 127; + inputSize = ((outputSize + 1) / 2); + + verify(inputSize + 1 <= size, input, "Not enough input bytes"); + verify(outputSize <= MAX_SYMBOL + 1, input, "Input is corrupted"); + + for (int i = 0; i < outputSize; i += 2) { + int value = inputBase.get(JAVA_BYTE, input + i / 2) & 0xFF; + weights[i] = (byte) (value >>> 4); + weights[i + 1] = (byte) (value & 0b1111); + } + } + else { + verify(inputSize + 1 <= size, input, "Not enough input bytes"); + + long inputLimit = input + inputSize; + input += reader.readFseTable(fseTable, inputBase, input, inputLimit, FiniteStateEntropy.MAX_SYMBOL, MAX_FSE_TABLE_LOG); + outputSize = FiniteStateEntropy.decompress(fseTable, inputBase, input, inputLimit, weights); + } + + int totalWeight = 0; + for (int i = 0; i < outputSize; i++) { + ranks[weights[i]]++; + totalWeight += (1 << weights[i]) >> 1; + } + verify(totalWeight != 0, input, "Input is corrupted"); + + tableLog = Util.highestBit(totalWeight) + 1; + verify(tableLog <= MAX_TABLE_LOG, input, "Input is corrupted"); + + int total = 1 << tableLog; + int rest = total - totalWeight; + verify(isPowerOf2(rest), input, "Input is corrupted"); + + int lastWeight = Util.highestBit(rest) + 1; + + weights[outputSize] = (byte) lastWeight; + ranks[lastWeight]++; + + int numberOfSymbols = outputSize + 1; + + // populate table + int nextRankStart = 0; + for (int i = 1; i < tableLog + 1; ++i) { + int current = nextRankStart; + nextRankStart += ranks[i] << (i - 1); + ranks[i] = current; + } + + for (int n = 0; n < numberOfSymbols; n++) { + int weight = weights[n]; + int length = (1 << weight) >> 1; + + byte symbol = (byte) n; + byte numberOfBits = (byte) (tableLog + 1 - weight); + for (int i = ranks[weight]; i < ranks[weight] + length; i++) { + symbols[i] = symbol; + numbersOfBits[i] = numberOfBits; + } + ranks[weight] += length; + } + + verify(ranks[1] >= 2 && (ranks[1] & 1) == 0, input, "Input is corrupted"); + + return inputSize + 1; + } + + public void decodeSingleStream(final MemorySegment inputBase, final long inputAddress, final long inputLimit, final MemorySegment outputBase, final long outputAddress, final long outputLimit) + { + BitInputStream.Initializer initializer = new BitInputStream.Initializer(inputBase, inputAddress, inputLimit); + initializer.initialize(); + + long bits = initializer.getBits(); + int bitsConsumed = initializer.getBitsConsumed(); + long currentAddress = initializer.getCurrentAddress(); + + int tableLog = this.tableLog; + byte[] numbersOfBits = this.numbersOfBits; + byte[] symbols = this.symbols; + + // 4 symbols at a time + long output = outputAddress; + long fastOutputLimit = outputLimit - 4; + while (output < fastOutputLimit) { + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, inputAddress, currentAddress, bits, bitsConsumed); + boolean done = loader.load(); + bits = loader.getBits(); + bitsConsumed = loader.getBitsConsumed(); + currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + + bitsConsumed = decodeSymbol(outputBase, output, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + bitsConsumed = decodeSymbol(outputBase, output + 1, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + bitsConsumed = decodeSymbol(outputBase, output + 2, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + bitsConsumed = decodeSymbol(outputBase, output + 3, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + output += SIZE_OF_INT; + } + + decodeTail(inputBase, inputAddress, currentAddress, bitsConsumed, bits, outputBase, output, outputLimit); + } + + public void decode4Streams(final MemorySegment inputBase, final long inputAddress, final long inputLimit, final MemorySegment outputBase, final long outputAddress, final long outputLimit) + { + verify(inputLimit - inputAddress >= 10, inputAddress, "Input is corrupted"); // jump table + 1 byte per stream + + long start1 = inputAddress + 3 * SIZE_OF_SHORT; // for the shorts we read below + long start2 = start1 + (inputBase.get(JAVA_SHORT, inputAddress) & 0xFFFF); + long start3 = start2 + (inputBase.get(JAVA_SHORT, inputAddress + 2) & 0xFFFF); + long start4 = start3 + (inputBase.get(JAVA_SHORT, inputAddress + 4) & 0xFFFF); + + verify(start2 < start3 && start3 < start4 && start4 < inputLimit, inputAddress, "Input is corrupted"); + + BitInputStream.Initializer initializer = new BitInputStream.Initializer(inputBase, start1, start2); + initializer.initialize(); + int stream1bitsConsumed = initializer.getBitsConsumed(); + long stream1currentAddress = initializer.getCurrentAddress(); + long stream1bits = initializer.getBits(); + + initializer = new BitInputStream.Initializer(inputBase, start2, start3); + initializer.initialize(); + int stream2bitsConsumed = initializer.getBitsConsumed(); + long stream2currentAddress = initializer.getCurrentAddress(); + long stream2bits = initializer.getBits(); + + initializer = new BitInputStream.Initializer(inputBase, start3, start4); + initializer.initialize(); + int stream3bitsConsumed = initializer.getBitsConsumed(); + long stream3currentAddress = initializer.getCurrentAddress(); + long stream3bits = initializer.getBits(); + + initializer = new BitInputStream.Initializer(inputBase, start4, inputLimit); + initializer.initialize(); + int stream4bitsConsumed = initializer.getBitsConsumed(); + long stream4currentAddress = initializer.getCurrentAddress(); + long stream4bits = initializer.getBits(); + + int segmentSize = (int) ((outputLimit - outputAddress + 3) / 4); + + long outputStart2 = outputAddress + segmentSize; + long outputStart3 = outputStart2 + segmentSize; + long outputStart4 = outputStart3 + segmentSize; + + long output1 = outputAddress; + long output2 = outputStart2; + long output3 = outputStart3; + long output4 = outputStart4; + + long fastOutputLimit = outputLimit - 7; + int tableLog = this.tableLog; + byte[] numbersOfBits = this.numbersOfBits; + byte[] symbols = this.symbols; + + while (output4 < fastOutputLimit) { + stream1bitsConsumed = decodeSymbol(outputBase, output1, stream1bits, stream1bitsConsumed, tableLog, numbersOfBits, symbols); + stream2bitsConsumed = decodeSymbol(outputBase, output2, stream2bits, stream2bitsConsumed, tableLog, numbersOfBits, symbols); + stream3bitsConsumed = decodeSymbol(outputBase, output3, stream3bits, stream3bitsConsumed, tableLog, numbersOfBits, symbols); + stream4bitsConsumed = decodeSymbol(outputBase, output4, stream4bits, stream4bitsConsumed, tableLog, numbersOfBits, symbols); + + stream1bitsConsumed = decodeSymbol(outputBase, output1 + 1, stream1bits, stream1bitsConsumed, tableLog, numbersOfBits, symbols); + stream2bitsConsumed = decodeSymbol(outputBase, output2 + 1, stream2bits, stream2bitsConsumed, tableLog, numbersOfBits, symbols); + stream3bitsConsumed = decodeSymbol(outputBase, output3 + 1, stream3bits, stream3bitsConsumed, tableLog, numbersOfBits, symbols); + stream4bitsConsumed = decodeSymbol(outputBase, output4 + 1, stream4bits, stream4bitsConsumed, tableLog, numbersOfBits, symbols); + + stream1bitsConsumed = decodeSymbol(outputBase, output1 + 2, stream1bits, stream1bitsConsumed, tableLog, numbersOfBits, symbols); + stream2bitsConsumed = decodeSymbol(outputBase, output2 + 2, stream2bits, stream2bitsConsumed, tableLog, numbersOfBits, symbols); + stream3bitsConsumed = decodeSymbol(outputBase, output3 + 2, stream3bits, stream3bitsConsumed, tableLog, numbersOfBits, symbols); + stream4bitsConsumed = decodeSymbol(outputBase, output4 + 2, stream4bits, stream4bitsConsumed, tableLog, numbersOfBits, symbols); + + stream1bitsConsumed = decodeSymbol(outputBase, output1 + 3, stream1bits, stream1bitsConsumed, tableLog, numbersOfBits, symbols); + stream2bitsConsumed = decodeSymbol(outputBase, output2 + 3, stream2bits, stream2bitsConsumed, tableLog, numbersOfBits, symbols); + stream3bitsConsumed = decodeSymbol(outputBase, output3 + 3, stream3bits, stream3bitsConsumed, tableLog, numbersOfBits, symbols); + stream4bitsConsumed = decodeSymbol(outputBase, output4 + 3, stream4bits, stream4bitsConsumed, tableLog, numbersOfBits, symbols); + + output1 += SIZE_OF_INT; + output2 += SIZE_OF_INT; + output3 += SIZE_OF_INT; + output4 += SIZE_OF_INT; + + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, start1, stream1currentAddress, stream1bits, stream1bitsConsumed); + boolean done = loader.load(); + stream1bitsConsumed = loader.getBitsConsumed(); + stream1bits = loader.getBits(); + stream1currentAddress = loader.getCurrentAddress(); + + if (done) { + break; + } + + loader = new BitInputStream.Loader(inputBase, start2, stream2currentAddress, stream2bits, stream2bitsConsumed); + done = loader.load(); + stream2bitsConsumed = loader.getBitsConsumed(); + stream2bits = loader.getBits(); + stream2currentAddress = loader.getCurrentAddress(); + + if (done) { + break; + } + + loader = new BitInputStream.Loader(inputBase, start3, stream3currentAddress, stream3bits, stream3bitsConsumed); + done = loader.load(); + stream3bitsConsumed = loader.getBitsConsumed(); + stream3bits = loader.getBits(); + stream3currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + + loader = new BitInputStream.Loader(inputBase, start4, stream4currentAddress, stream4bits, stream4bitsConsumed); + done = loader.load(); + stream4bitsConsumed = loader.getBitsConsumed(); + stream4bits = loader.getBits(); + stream4currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + } + + verify(output1 <= outputStart2 && output2 <= outputStart3 && output3 <= outputStart4, inputAddress, "Input is corrupted"); + + /// finish streams one by one + decodeTail(inputBase, start1, stream1currentAddress, stream1bitsConsumed, stream1bits, outputBase, output1, outputStart2); + decodeTail(inputBase, start2, stream2currentAddress, stream2bitsConsumed, stream2bits, outputBase, output2, outputStart3); + decodeTail(inputBase, start3, stream3currentAddress, stream3bitsConsumed, stream3bits, outputBase, output3, outputStart4); + decodeTail(inputBase, start4, stream4currentAddress, stream4bitsConsumed, stream4bits, outputBase, output4, outputLimit); + } + + private void decodeTail(final MemorySegment inputBase, final long startAddress, long currentAddress, int bitsConsumed, long bits, final MemorySegment outputBase, long outputAddress, final long outputLimit) + { + int tableLog = this.tableLog; + byte[] numbersOfBits = this.numbersOfBits; + byte[] symbols = this.symbols; + + // closer to the end + while (outputAddress < outputLimit) { + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, startAddress, currentAddress, bits, bitsConsumed); + boolean done = loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + + bitsConsumed = decodeSymbol(outputBase, outputAddress++, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + } + + // not more data in bit stream, so no need to reload + while (outputAddress < outputLimit) { + bitsConsumed = decodeSymbol(outputBase, outputAddress++, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + } + + verify(isEndOfStream(startAddress, currentAddress, bitsConsumed), startAddress, "Bit stream is not fully consumed"); + } + + private static int decodeSymbol(MemorySegment outputBase, long outputAddress, long bitContainer, int bitsConsumed, int tableLog, byte[] numbersOfBits, byte[] symbols) + { + int value = (int) peekBitsFast(bitsConsumed, bitContainer, tableLog); + outputBase.set(JAVA_BYTE, outputAddress, symbols[value]); + return bitsConsumed + numbersOfBits[value]; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionContext.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionContext.java new file mode 100644 index 00000000..5bb70933 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionContext.java @@ -0,0 +1,61 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +class HuffmanCompressionContext +{ + private final HuffmanTableWriterWorkspace tableWriterWorkspace = new HuffmanTableWriterWorkspace(); + private final HuffmanCompressionTableWorkspace compressionTableWorkspace = new HuffmanCompressionTableWorkspace(); + + private HuffmanCompressionTable previousTable = new HuffmanCompressionTable(Huffman.MAX_SYMBOL_COUNT); + private HuffmanCompressionTable temporaryTable = new HuffmanCompressionTable(Huffman.MAX_SYMBOL_COUNT); + + private HuffmanCompressionTable previousCandidate = previousTable; + private HuffmanCompressionTable temporaryCandidate = temporaryTable; + + public HuffmanCompressionTable getPreviousTable() + { + return previousTable; + } + + public HuffmanCompressionTable borrowTemporaryTable() + { + previousCandidate = temporaryTable; + temporaryCandidate = previousTable; + + return temporaryTable; + } + + public void discardTemporaryTable() + { + previousCandidate = previousTable; + temporaryCandidate = temporaryTable; + } + + public void saveChanges() + { + temporaryTable = temporaryCandidate; + previousTable = previousCandidate; + } + + public HuffmanCompressionTableWorkspace getCompressionTableWorkspace() + { + return compressionTableWorkspace; + } + + public HuffmanTableWriterWorkspace getTableWriterWorkspace() + { + return tableWriterWorkspace; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTable.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTable.java new file mode 100644 index 00000000..fe67d208 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTable.java @@ -0,0 +1,396 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_FSE_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL_COUNT; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Huffman.MIN_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static io.airlift.compress.v3.zstdFFM.Util.minTableLog; + +final class HuffmanCompressionTable +{ + private final short[] values; + private final byte[] numberOfBits; + + private int maxSymbol; + private int maxNumberOfBits; + + public HuffmanCompressionTable(int capacity) + { + this.values = new short[capacity]; + this.numberOfBits = new byte[capacity]; + } + + public static int optimalNumberOfBits(int maxNumberOfBits, int inputSize, int maxSymbol) + { + if (inputSize <= 1) { + throw new IllegalArgumentException(); // not supported. Use RLE instead + } + + int result = maxNumberOfBits; + + result = Math.min(result, Util.highestBit((inputSize - 1)) - 1); + + result = Math.max(result, minTableLog(inputSize, maxSymbol)); + + result = Math.max(result, MIN_TABLE_LOG); + result = Math.min(result, MAX_TABLE_LOG); + + return result; + } + + public void initialize(int[] counts, int maxSymbol, int maxNumberOfBits, HuffmanCompressionTableWorkspace workspace) + { + checkArgument(maxSymbol <= MAX_SYMBOL, "Max symbol value too large"); + + workspace.reset(); + + NodeTable nodeTable = workspace.nodeTable; + nodeTable.reset(); + + int lastNonZero = buildTree(counts, maxSymbol, nodeTable); + + // enforce max table log + maxNumberOfBits = setMaxHeight(nodeTable, lastNonZero, maxNumberOfBits, workspace); + checkArgument(maxNumberOfBits <= MAX_TABLE_LOG, "Max number of bits larger than max table size"); + + // populate table + int symbolCount = maxSymbol + 1; + for (int node = 0; node < symbolCount; node++) { + int symbol = nodeTable.symbols[node]; + numberOfBits[symbol] = nodeTable.numberOfBits[node]; + } + + short[] entriesPerRank = workspace.entriesPerRank; + short[] valuesPerRank = workspace.valuesPerRank; + + for (int n = 0; n <= lastNonZero; n++) { + entriesPerRank[nodeTable.numberOfBits[n]]++; + } + + // determine starting value per rank + short startingValue = 0; + for (int rank = maxNumberOfBits; rank > 0; rank--) { + valuesPerRank[rank] = startingValue; + startingValue += entriesPerRank[rank]; + startingValue >>>= 1; + } + + for (int n = 0; n <= maxSymbol; n++) { + values[n] = valuesPerRank[numberOfBits[n]]++; + } + + this.maxSymbol = maxSymbol; + this.maxNumberOfBits = maxNumberOfBits; + } + + private int buildTree(int[] counts, int maxSymbol, NodeTable nodeTable) + { + short current = 0; + + for (int symbol = 0; symbol <= maxSymbol; symbol++) { + int count = counts[symbol]; + + int position = current; + while (position > 1 && count > nodeTable.count[position - 1]) { + nodeTable.copyNode(position - 1, position); + position--; + } + + nodeTable.count[position] = count; + nodeTable.symbols[position] = symbol; + + current++; + } + + int lastNonZero = maxSymbol; + while (nodeTable.count[lastNonZero] == 0) { + lastNonZero--; + } + + short nonLeafStart = MAX_SYMBOL_COUNT; + current = nonLeafStart; + + int currentLeaf = lastNonZero; + + int currentNonLeaf = current; + nodeTable.count[current] = nodeTable.count[currentLeaf] + nodeTable.count[currentLeaf - 1]; + nodeTable.parents[currentLeaf] = current; + nodeTable.parents[currentLeaf - 1] = current; + current++; + currentLeaf -= 2; + + int root = MAX_SYMBOL_COUNT + lastNonZero - 1; + + for (int n = current; n <= root; n++) { + nodeTable.count[n] = 1 << 30; + } + + while (current <= root) { + int child1; + if (currentLeaf >= 0 && nodeTable.count[currentLeaf] < nodeTable.count[currentNonLeaf]) { + child1 = currentLeaf--; + } + else { + child1 = currentNonLeaf++; + } + + int child2; + if (currentLeaf >= 0 && nodeTable.count[currentLeaf] < nodeTable.count[currentNonLeaf]) { + child2 = currentLeaf--; + } + else { + child2 = currentNonLeaf++; + } + + nodeTable.count[current] = nodeTable.count[child1] + nodeTable.count[child2]; + nodeTable.parents[child1] = current; + nodeTable.parents[child2] = current; + current++; + } + + nodeTable.numberOfBits[root] = 0; + for (int n = root - 1; n >= nonLeafStart; n--) { + short parent = nodeTable.parents[n]; + nodeTable.numberOfBits[n] = (byte) (nodeTable.numberOfBits[parent] + 1); + } + + for (int n = 0; n <= lastNonZero; n++) { + short parent = nodeTable.parents[n]; + nodeTable.numberOfBits[n] = (byte) (nodeTable.numberOfBits[parent] + 1); + } + + return lastNonZero; + } + + public void encodeSymbol(BitOutputStream output, int symbol) + { + output.addBitsFast(values[symbol], numberOfBits[symbol]); + } + + public int write(MemorySegment outputBase, long outputAddress, int outputSize, HuffmanTableWriterWorkspace workspace) + { + byte[] weights = workspace.weights; + + long output = outputAddress; + + int maxNumberOfBits = this.maxNumberOfBits; + int maxSymbol = this.maxSymbol; + + for (int symbol = 0; symbol < maxSymbol; symbol++) { + int bits = numberOfBits[symbol]; + + if (bits == 0) { + weights[symbol] = 0; + } + else { + weights[symbol] = (byte) (maxNumberOfBits + 1 - bits); + } + } + + int size = compressWeights(outputBase, output + 1, outputSize - 1, weights, maxSymbol, workspace); + + if (maxSymbol > 127 && size > 127) { + throw new AssertionError(); + } + + if (size != 0 && size != 1 && size < maxSymbol / 2) { + outputBase.set(JAVA_BYTE, output, (byte) size); + return size + 1; // header + size + } + else { + int entryCount = maxSymbol; + + size = (entryCount + 1) / 2; + checkArgument(size + 1 <= outputSize, "Output size too small"); + + outputBase.set(JAVA_BYTE, output, (byte) (127 + entryCount)); + output++; + + weights[maxSymbol] = 0; + for (int i = 0; i < entryCount; i += 2) { + outputBase.set(JAVA_BYTE, output, (byte) ((weights[i] << 4) + weights[i + 1])); + output++; + } + + return (int) (output - outputAddress); + } + } + + /** + * Can this table encode all symbols with non-zero count? + */ + public boolean isValid(int[] counts, int maxSymbol) + { + if (maxSymbol > this.maxSymbol) { + return false; + } + + for (int symbol = 0; symbol <= maxSymbol; ++symbol) { + if (counts[symbol] != 0 && numberOfBits[symbol] == 0) { + return false; + } + } + return true; + } + + public int estimateCompressedSize(int[] counts, int maxSymbol) + { + int numberOfBits = 0; + for (int symbol = 0; symbol <= Math.min(maxSymbol, this.maxSymbol); symbol++) { + numberOfBits += this.numberOfBits[symbol] * counts[symbol]; + } + + return numberOfBits >>> 3; + } + + private static int setMaxHeight(NodeTable nodeTable, int lastNonZero, int maxNumberOfBits, HuffmanCompressionTableWorkspace workspace) + { + int largestBits = nodeTable.numberOfBits[lastNonZero]; + + if (largestBits <= maxNumberOfBits) { + return largestBits; + } + + int totalCost = 0; + int baseCost = 1 << (largestBits - maxNumberOfBits); + int n = lastNonZero; + + while (nodeTable.numberOfBits[n] > maxNumberOfBits) { + totalCost += baseCost - (1 << (largestBits - nodeTable.numberOfBits[n])); + nodeTable.numberOfBits[n ] = (byte) maxNumberOfBits; + n--; + } + + while (nodeTable.numberOfBits[n] == maxNumberOfBits) { + n--; + } + + totalCost >>>= (largestBits - maxNumberOfBits); + + int noSymbol = 0xF0F0F0F0; + int[] rankLast = workspace.rankLast; + Arrays.fill(rankLast, noSymbol); + + int currentNbBits = maxNumberOfBits; + for (int pos = n; pos >= 0; pos--) { + if (nodeTable.numberOfBits[pos] >= currentNbBits) { + continue; + } + currentNbBits = nodeTable.numberOfBits[pos]; + rankLast[maxNumberOfBits - currentNbBits] = pos; + } + + while (totalCost > 0) { + int numberOfBitsToDecrease = Util.highestBit(totalCost) + 1; + for (; numberOfBitsToDecrease > 1; numberOfBitsToDecrease--) { + int highPosition = rankLast[numberOfBitsToDecrease]; + int lowPosition = rankLast[numberOfBitsToDecrease - 1]; + if (highPosition == noSymbol) { + continue; + } + if (lowPosition == noSymbol) { + break; + } + int highTotal = nodeTable.count[highPosition]; + int lowTotal = 2 * nodeTable.count[lowPosition]; + if (highTotal <= lowTotal) { + break; + } + } + + while ((numberOfBitsToDecrease <= MAX_TABLE_LOG) && (rankLast[numberOfBitsToDecrease] == noSymbol)) { + numberOfBitsToDecrease++; + } + totalCost -= 1 << (numberOfBitsToDecrease - 1); + if (rankLast[numberOfBitsToDecrease - 1] == noSymbol) { + rankLast[numberOfBitsToDecrease - 1] = rankLast[numberOfBitsToDecrease]; + } + nodeTable.numberOfBits[rankLast[numberOfBitsToDecrease]]++; + if (rankLast[numberOfBitsToDecrease] == 0) { + rankLast[numberOfBitsToDecrease] = noSymbol; + } + else { + rankLast[numberOfBitsToDecrease]--; + if (nodeTable.numberOfBits[rankLast[numberOfBitsToDecrease]] != maxNumberOfBits - numberOfBitsToDecrease) { + rankLast[numberOfBitsToDecrease] = noSymbol; + } + } + } + + while (totalCost < 0) { + if (rankLast[1] == noSymbol) { + while (nodeTable.numberOfBits[n] == maxNumberOfBits) { + n--; + } + nodeTable.numberOfBits[n + 1]--; + rankLast[1] = n + 1; + totalCost++; + continue; + } + nodeTable.numberOfBits[rankLast[1] + 1]--; + rankLast[1]++; + totalCost++; + } + + return maxNumberOfBits; + } + + private static int compressWeights(MemorySegment outputBase, long outputAddress, int outputSize, byte[] weights, int weightsLength, HuffmanTableWriterWorkspace workspace) + { + if (weightsLength <= 1) { + return 0; + } + + int[] counts = workspace.counts; + Histogram.count(weights, weightsLength, counts); + int maxSymbol = Histogram.findMaxSymbol(counts, MAX_TABLE_LOG); + int maxCount = Histogram.findLargestCount(counts, maxSymbol); + + if (maxCount == weightsLength) { + return 1; + } + if (maxCount == 1) { + return 0; + } + + short[] normalizedCounts = workspace.normalizedCounts; + + int tableLog = FiniteStateEntropy.optimalTableLog(MAX_FSE_TABLE_LOG, weightsLength, maxSymbol); + FiniteStateEntropy.normalizeCounts(normalizedCounts, tableLog, counts, weightsLength, maxSymbol); + + long output = outputAddress; + long outputLimit = outputAddress + outputSize; + + int headerSize = FiniteStateEntropy.writeNormalizedCounts(outputBase, output, outputSize, normalizedCounts, maxSymbol, tableLog); + output += headerSize; + + FseCompressionTable compressionTable = workspace.fseTable; + compressionTable.initialize(normalizedCounts, maxSymbol, tableLog); + int compressedSize = FiniteStateEntropy.compress(outputBase, output, (int) (outputLimit - output), weights, weightsLength, compressionTable); + if (compressedSize == 0) { + return 0; + } + output += compressedSize; + + return (int) (output - outputAddress); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTableWorkspace.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTableWorkspace.java new file mode 100644 index 00000000..ca20b10a --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTableWorkspace.java @@ -0,0 +1,33 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.util.Arrays; + +class HuffmanCompressionTableWorkspace +{ + public final NodeTable nodeTable = new NodeTable((2 * Huffman.MAX_SYMBOL_COUNT - 1)); // number of nodes in binary tree with MAX_SYMBOL_COUNT leaves + + public final short[] entriesPerRank = new short[Huffman.MAX_TABLE_LOG + 1]; + public final short[] valuesPerRank = new short[Huffman.MAX_TABLE_LOG + 1]; + + // for setMaxHeight + public final int[] rankLast = new int[Huffman.MAX_TABLE_LOG + 2]; + + public void reset() + { + Arrays.fill(entriesPerRank, (short) 0); + Arrays.fill(valuesPerRank, (short) 0); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressor.java new file mode 100644 index 00000000..a4985456 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressor.java @@ -0,0 +1,139 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; + +final class HuffmanCompressor +{ + private HuffmanCompressor() + { + } + + public static int compress4streams(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize, HuffmanCompressionTable table) + { + long input = inputAddress; + long inputLimit = inputAddress + inputSize; + long output = outputAddress; + long outputLimit = outputAddress + outputSize; + + int segmentSize = (inputSize + 3) / 4; + + if (outputSize < 6 + 1 + 1 + 1 + 8) { + return 0; + } + + if (inputSize <= 6 + 1 + 1 + 1) { + return 0; + } + + output += SIZE_OF_SHORT + SIZE_OF_SHORT + SIZE_OF_SHORT; + + int compressedSize; + + // first segment + compressedSize = compressSingleStream(outputBase, output, (int) (outputLimit - output), inputBase, input, segmentSize, table); + if (compressedSize == 0) { + return 0; + } + outputBase.set(JAVA_SHORT, outputAddress, (short) compressedSize); + output += compressedSize; + input += segmentSize; + + // second segment + compressedSize = compressSingleStream(outputBase, output, (int) (outputLimit - output), inputBase, input, segmentSize, table); + if (compressedSize == 0) { + return 0; + } + outputBase.set(JAVA_SHORT, outputAddress + SIZE_OF_SHORT, (short) compressedSize); + output += compressedSize; + input += segmentSize; + + // third segment + compressedSize = compressSingleStream(outputBase, output, (int) (outputLimit - output), inputBase, input, segmentSize, table); + if (compressedSize == 0) { + return 0; + } + outputBase.set(JAVA_SHORT, outputAddress + SIZE_OF_SHORT + SIZE_OF_SHORT, (short) compressedSize); + output += compressedSize; + input += segmentSize; + + // fourth segment + compressedSize = compressSingleStream(outputBase, output, (int) (outputLimit - output), inputBase, input, (int) (inputLimit - input), table); + if (compressedSize == 0) { + return 0; + } + output += compressedSize; + + return (int) (output - outputAddress); + } + + public static int compressSingleStream(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize, HuffmanCompressionTable table) + { + if (outputSize < SIZE_OF_LONG) { + return 0; + } + + BitOutputStream bitstream = new BitOutputStream(outputBase, outputAddress, outputSize); + long input = inputAddress; + + int n = inputSize & ~3; + + switch (inputSize & 3) { + case 3: + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n + 2) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 4 + 7) { + bitstream.flush(); + } + // fall-through + case 2: + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n + 1) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 2 + 7) { + bitstream.flush(); + } + // fall-through + case 1: + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n + 0) & 0xFF); + bitstream.flush(); + // fall-through + case 0: /* fall-through */ + default: + break; + } + + for (; n > 0; n -= 4) { + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n - 1) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 2 + 7) { + bitstream.flush(); + } + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n - 2) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 4 + 7) { + bitstream.flush(); + } + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n - 3) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 2 + 7) { + bitstream.flush(); + } + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n - 4) & 0xFF); + bitstream.flush(); + } + + return bitstream.close(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanTableWriterWorkspace.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanTableWriterWorkspace.java new file mode 100644 index 00000000..8f8227ca --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanTableWriterWorkspace.java @@ -0,0 +1,29 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_FSE_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_TABLE_LOG; + +class HuffmanTableWriterWorkspace +{ + // for encoding weights + public final byte[] weights = new byte[MAX_SYMBOL]; // the weight for the last symbol is implicit + + // for compressing weights + public final int[] counts = new int[MAX_TABLE_LOG + 1]; + public final short[] normalizedCounts = new short[MAX_TABLE_LOG + 1]; + public final FseCompressionTable fseTable = new FseCompressionTable(MAX_FSE_TABLE_LOG, MAX_TABLE_LOG); +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/NodeTable.java b/src/main/java/io/airlift/compress/v3/zstdFFM/NodeTable.java new file mode 100644 index 00000000..67744690 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/NodeTable.java @@ -0,0 +1,48 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.util.Arrays; + +class NodeTable +{ + int[] count; + short[] parents; + int[] symbols; + byte[] numberOfBits; + + public NodeTable(int size) + { + count = new int[size]; + parents = new short[size]; + symbols = new int[size]; + numberOfBits = new byte[size]; + } + + public void reset() + { + Arrays.fill(count, 0); + Arrays.fill(parents, (short) 0); + Arrays.fill(symbols, 0); + Arrays.fill(numberOfBits, (byte) 0); + } + + public void copyNode(int from, int to) + { + count[to] = count[from]; + parents[to] = parents[from]; + symbols[to] = symbols[from]; + numberOfBits[to] = numberOfBits[from]; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/README.md b/src/main/java/io/airlift/compress/v3/zstdFFM/README.md new file mode 100644 index 00000000..53537505 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/README.md @@ -0,0 +1,25 @@ +# Vendored: io.airlift.compress.v3.zstdFFM + +Copied verbatim (package name preserved) from +https://github.com/ebremer/aircompressor/tree/zstdFFM/src/main/java/io/airlift/compress/v3/zstdFFM +on 2026-07-02. + +This is the FFM (Foreign Function & Memory API) port of aircompressor's pure-Java +Zstd codec — it replaces the sun.misc.Unsafe-based implementation in +`io.airlift.compress.v3.zstd`. It is vendored here TEMPORARILY until the upstream +aircompressor project publishes its own release containing this package, at which +point this directory should be deleted and imports switched to the released +artifact. + +One file is intentionally NOT vendored: `ZstdCodec.java`. It extends +`io.airlift.compress.v3.hadoop.CodecAdapter`, which implements +`org.apache.hadoop.io.compress.CompressionCodec` — upstream compiles it against +a `provided` Hadoop dependency that BeakGraph neither has nor wants. BeakGraph +does not use Hadoop compression codecs. + +Otherwise, do not modify these files locally; make changes in the aircompressor fork and +re-copy. The classes still depend on the `aircompressor-v3` jar for the shared +`Compressor`/`Decompressor` interfaces, `MalformedInputException`, and +`internal.NativeLoader`. + +License: Apache License 2.0 (headers retained in each file). diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/RepeatedOffsets.java b/src/main/java/io/airlift/compress/v3/zstdFFM/RepeatedOffsets.java new file mode 100644 index 00000000..12b26207 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/RepeatedOffsets.java @@ -0,0 +1,49 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +class RepeatedOffsets +{ + private int offset0 = 1; + private int offset1 = 4; + + private int tempOffset0; + private int tempOffset1; + + public int getOffset0() + { + return offset0; + } + + public int getOffset1() + { + return offset1; + } + + public void saveOffset0(int offset) + { + tempOffset0 = offset; + } + + public void saveOffset1(int offset) + { + tempOffset1 = offset; + } + + public void commit() + { + offset0 = tempOffset0; + offset1 = tempOffset1; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncoder.java b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncoder.java new file mode 100644 index 00000000..c022245f --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncoder.java @@ -0,0 +1,339 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.DEFAULT_MAX_OFFSET_CODE_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.LITERALS_LENGTH_BITS; +import static io.airlift.compress.v3.zstdFFM.Constants.LITERAL_LENGTH_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.LONG_NUMBER_OF_SEQUENCES; +import static io.airlift.compress.v3.zstdFFM.Constants.MATCH_LENGTH_BITS; +import static io.airlift.compress.v3.zstdFFM.Constants.MATCH_LENGTH_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_LITERALS_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_MATCH_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_OFFSET_CODE_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.OFFSET_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_BASIC; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_COMPRESSED; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_RLE; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.FiniteStateEntropy.optimalTableLog; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; + +final class SequenceEncoder +{ + private static final int DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS_LOG = 6; + private static final short[] DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS = {4, 3, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 1, 1, 1, + 2, 2, 2, 2, 2, 2, 2, 2, + 2, 3, 2, 1, 1, 1, 1, 1, + -1, -1, -1, -1}; + + private static final int DEFAULT_MATCH_LENGTH_NORMALIZED_COUNTS_LOG = 6; + private static final short[] DEFAULT_MATCH_LENGTH_NORMALIZED_COUNTS = {1, 4, 3, 2, 2, 2, 2, 2, + 2, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, -1, -1, + -1, -1, -1, -1, -1}; + + private static final int DEFAULT_OFFSET_NORMALIZED_COUNTS_LOG = 5; + private static final short[] DEFAULT_OFFSET_NORMALIZED_COUNTS = {1, 1, 1, 1, 1, 1, 2, 2, + 2, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + -1, -1, -1, -1, -1}; + + private static final FseCompressionTable DEFAULT_LITERAL_LENGTHS_TABLE = FseCompressionTable.newInstance(DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS, MAX_LITERALS_LENGTH_SYMBOL, DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS_LOG); + private static final FseCompressionTable DEFAULT_MATCH_LENGTHS_TABLE = FseCompressionTable.newInstance(DEFAULT_MATCH_LENGTH_NORMALIZED_COUNTS, MAX_MATCH_LENGTH_SYMBOL, DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS_LOG); + private static final FseCompressionTable DEFAULT_OFFSETS_TABLE = FseCompressionTable.newInstance(DEFAULT_OFFSET_NORMALIZED_COUNTS, DEFAULT_MAX_OFFSET_CODE_SYMBOL, DEFAULT_OFFSET_NORMALIZED_COUNTS_LOG); + + private SequenceEncoder() + { + } + + public static int compressSequences(MemorySegment outputBase, final long outputAddress, int outputSize, SequenceStore sequences, CompressionParameters.Strategy strategy, SequenceEncodingContext workspace) + { + long output = outputAddress; + long outputLimit = outputAddress + outputSize; + + checkArgument(outputLimit - output > 3 + 1, "Output buffer too small"); + + int sequenceCount = sequences.sequenceCount; + if (sequenceCount < 0x7F) { + outputBase.set(JAVA_BYTE, output, (byte) sequenceCount); + output++; + } + else if (sequenceCount < LONG_NUMBER_OF_SEQUENCES) { + outputBase.set(JAVA_BYTE, output, (byte) (sequenceCount >>> 8 | 0x80)); + outputBase.set(JAVA_BYTE, output + 1, (byte) sequenceCount); + output += SIZE_OF_SHORT; + } + else { + outputBase.set(JAVA_BYTE, output, (byte) 0xFF); + output++; + outputBase.set(JAVA_SHORT, output, (short) (sequenceCount - LONG_NUMBER_OF_SEQUENCES)); + output += SIZE_OF_SHORT; + } + + if (sequenceCount == 0) { + return (int) (output - outputAddress); + } + + // flags for FSE encoding type + long headerAddress = output++; + + int maxSymbol; + int largestCount; + + // literal lengths + int[] counts = workspace.counts; + Histogram.count(sequences.literalLengthCodes, sequenceCount, workspace.counts); + maxSymbol = Histogram.findMaxSymbol(counts, MAX_LITERALS_LENGTH_SYMBOL); + largestCount = Histogram.findLargestCount(counts, maxSymbol); + + int literalsLengthEncodingType = selectEncodingType(largestCount, sequenceCount, DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS_LOG, true, strategy); + + FseCompressionTable literalLengthTable; + switch (literalsLengthEncodingType) { + case SEQUENCE_ENCODING_RLE: + outputBase.set(JAVA_BYTE, output, sequences.literalLengthCodes[0]); + output++; + workspace.literalLengthTable.initializeRleTable(maxSymbol); + literalLengthTable = workspace.literalLengthTable; + break; + case SEQUENCE_ENCODING_BASIC: + literalLengthTable = DEFAULT_LITERAL_LENGTHS_TABLE; + break; + case SEQUENCE_ENCODING_COMPRESSED: + output += buildCompressionTable( + workspace.literalLengthTable, + outputBase, + output, + outputLimit, + sequenceCount, + LITERAL_LENGTH_TABLE_LOG, + sequences.literalLengthCodes, + workspace.counts, + maxSymbol, + workspace.normalizedCounts); + literalLengthTable = workspace.literalLengthTable; + break; + default: + throw new UnsupportedOperationException("not yet implemented"); + } + + // offsets + Histogram.count(sequences.offsetCodes, sequenceCount, workspace.counts); + maxSymbol = Histogram.findMaxSymbol(counts, MAX_OFFSET_CODE_SYMBOL); + largestCount = Histogram.findLargestCount(counts, maxSymbol); + + boolean defaultAllowed = maxSymbol < DEFAULT_MAX_OFFSET_CODE_SYMBOL; + + int offsetEncodingType = selectEncodingType(largestCount, sequenceCount, DEFAULT_OFFSET_NORMALIZED_COUNTS_LOG, defaultAllowed, strategy); + + FseCompressionTable offsetCodeTable; + switch (offsetEncodingType) { + case SEQUENCE_ENCODING_RLE: + outputBase.set(JAVA_BYTE, output, sequences.offsetCodes[0]); + output++; + workspace.offsetCodeTable.initializeRleTable(maxSymbol); + offsetCodeTable = workspace.offsetCodeTable; + break; + case SEQUENCE_ENCODING_BASIC: + offsetCodeTable = DEFAULT_OFFSETS_TABLE; + break; + case SEQUENCE_ENCODING_COMPRESSED: + output += buildCompressionTable( + workspace.offsetCodeTable, + outputBase, + output, + output + outputSize, + sequenceCount, + OFFSET_TABLE_LOG, + sequences.offsetCodes, + workspace.counts, + maxSymbol, + workspace.normalizedCounts); + offsetCodeTable = workspace.offsetCodeTable; + break; + default: + throw new UnsupportedOperationException("not yet implemented"); + } + + // match lengths + Histogram.count(sequences.matchLengthCodes, sequenceCount, workspace.counts); + maxSymbol = Histogram.findMaxSymbol(counts, MAX_MATCH_LENGTH_SYMBOL); + largestCount = Histogram.findLargestCount(counts, maxSymbol); + + int matchLengthEncodingType = selectEncodingType(largestCount, sequenceCount, DEFAULT_MATCH_LENGTH_NORMALIZED_COUNTS_LOG, true, strategy); + + FseCompressionTable matchLengthTable; + switch (matchLengthEncodingType) { + case SEQUENCE_ENCODING_RLE: + outputBase.set(JAVA_BYTE, output, sequences.matchLengthCodes[0]); + output++; + workspace.matchLengthTable.initializeRleTable(maxSymbol); + matchLengthTable = workspace.matchLengthTable; + break; + case SEQUENCE_ENCODING_BASIC: + matchLengthTable = DEFAULT_MATCH_LENGTHS_TABLE; + break; + case SEQUENCE_ENCODING_COMPRESSED: + output += buildCompressionTable( + workspace.matchLengthTable, + outputBase, + output, + outputLimit, + sequenceCount, + MATCH_LENGTH_TABLE_LOG, + sequences.matchLengthCodes, + workspace.counts, + maxSymbol, + workspace.normalizedCounts); + matchLengthTable = workspace.matchLengthTable; + break; + default: + throw new UnsupportedOperationException("not yet implemented"); + } + + // flags + outputBase.set(JAVA_BYTE, headerAddress, (byte) ((literalsLengthEncodingType << 6) | (offsetEncodingType << 4) | (matchLengthEncodingType << 2))); + + output += encodeSequences(outputBase, output, outputLimit, matchLengthTable, offsetCodeTable, literalLengthTable, sequences); + + return (int) (output - outputAddress); + } + + private static int buildCompressionTable(FseCompressionTable table, MemorySegment outputBase, long output, long outputLimit, int sequenceCount, int maxTableLog, byte[] codes, int[] counts, int maxSymbol, short[] normalizedCounts) + { + int tableLog = optimalTableLog(maxTableLog, sequenceCount, maxSymbol); + + if (counts[codes[sequenceCount - 1]] > 1) { + counts[codes[sequenceCount - 1]]--; + sequenceCount--; + } + + FiniteStateEntropy.normalizeCounts(normalizedCounts, tableLog, counts, sequenceCount, maxSymbol); + table.initialize(normalizedCounts, maxSymbol, tableLog); + + return FiniteStateEntropy.writeNormalizedCounts(outputBase, output, (int) (outputLimit - output), normalizedCounts, maxSymbol, tableLog); + } + + private static int encodeSequences( + MemorySegment outputBase, + long output, + long outputLimit, + FseCompressionTable matchLengthTable, + FseCompressionTable offsetsTable, + FseCompressionTable literalLengthTable, + SequenceStore sequences) + { + byte[] matchLengthCodes = sequences.matchLengthCodes; + byte[] offsetCodes = sequences.offsetCodes; + byte[] literalLengthCodes = sequences.literalLengthCodes; + + BitOutputStream blockStream = new BitOutputStream(outputBase, output, (int) (outputLimit - output)); + + int sequenceCount = sequences.sequenceCount; + + // first symbols + int matchLengthState = matchLengthTable.begin(matchLengthCodes[sequenceCount - 1]); + int offsetState = offsetsTable.begin(offsetCodes[sequenceCount - 1]); + int literalLengthState = literalLengthTable.begin(literalLengthCodes[sequenceCount - 1]); + + blockStream.addBits(sequences.literalLengths[sequenceCount - 1], LITERALS_LENGTH_BITS[literalLengthCodes[sequenceCount - 1]]); + blockStream.addBits(sequences.matchLengths[sequenceCount - 1], MATCH_LENGTH_BITS[matchLengthCodes[sequenceCount - 1]]); + blockStream.addBits(sequences.offsets[sequenceCount - 1], offsetCodes[sequenceCount - 1]); + blockStream.flush(); + + if (sequenceCount >= 2) { + for (int n = sequenceCount - 2; n >= 0; n--) { + byte literalLengthCode = literalLengthCodes[n]; + byte offsetCode = offsetCodes[n]; + byte matchLengthCode = matchLengthCodes[n]; + + int literalLengthBits = LITERALS_LENGTH_BITS[literalLengthCode]; + int offsetBits = offsetCode; + int matchLengthBits = MATCH_LENGTH_BITS[matchLengthCode]; + + offsetState = offsetsTable.encode(blockStream, offsetState, offsetCode); + matchLengthState = matchLengthTable.encode(blockStream, matchLengthState, matchLengthCode); + literalLengthState = literalLengthTable.encode(blockStream, literalLengthState, literalLengthCode); + + if ((offsetBits + matchLengthBits + literalLengthBits >= 64 - 7 - (LITERAL_LENGTH_TABLE_LOG + MATCH_LENGTH_TABLE_LOG + OFFSET_TABLE_LOG))) { + blockStream.flush(); + } + + blockStream.addBits(sequences.literalLengths[n], literalLengthBits); + if (((literalLengthBits + matchLengthBits) > 24)) { + blockStream.flush(); + } + + blockStream.addBits(sequences.matchLengths[n], matchLengthBits); + if ((offsetBits + matchLengthBits + literalLengthBits > 56)) { + blockStream.flush(); + } + + blockStream.addBits(sequences.offsets[n], offsetBits); + blockStream.flush(); + } + } + + matchLengthTable.finish(blockStream, matchLengthState); + offsetsTable.finish(blockStream, offsetState); + literalLengthTable.finish(blockStream, literalLengthState); + + int streamSize = blockStream.close(); + checkArgument(streamSize > 0, "Output buffer too small"); + + return streamSize; + } + + private static int selectEncodingType( + int largestCount, + int sequenceCount, + int defaultNormalizedCountsLog, + boolean isDefaultTableAllowed, + CompressionParameters.Strategy strategy) + { + if (largestCount == sequenceCount) { + if (isDefaultTableAllowed && sequenceCount <= 2) { + return SEQUENCE_ENCODING_BASIC; + } + + return SEQUENCE_ENCODING_RLE; + } + + if (strategy.ordinal() < CompressionParameters.Strategy.LAZY.ordinal()) { + if (isDefaultTableAllowed) { + int factor = 10 - strategy.ordinal(); + int baseLog = 3; + long minNumberOfSequences = ((1L << defaultNormalizedCountsLog) * factor) >> baseLog; + + if ((sequenceCount < minNumberOfSequences) || (largestCount < (sequenceCount >> (defaultNormalizedCountsLog - 1)))) { + return SEQUENCE_ENCODING_BASIC; + } + } + } + else { + throw new UnsupportedOperationException("not yet implemented"); + } + + return SEQUENCE_ENCODING_COMPRESSED; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncodingContext.java b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncodingContext.java new file mode 100644 index 00000000..30f5f11a --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncodingContext.java @@ -0,0 +1,30 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_LITERALS_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_MATCH_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_OFFSET_CODE_SYMBOL; + +class SequenceEncodingContext +{ + private static final int MAX_SEQUENCES = Math.max(MAX_LITERALS_LENGTH_SYMBOL, MAX_MATCH_LENGTH_SYMBOL); + + public final FseCompressionTable literalLengthTable = new FseCompressionTable(Constants.LITERAL_LENGTH_TABLE_LOG, MAX_LITERALS_LENGTH_SYMBOL); + public final FseCompressionTable offsetCodeTable = new FseCompressionTable(Constants.OFFSET_TABLE_LOG, MAX_OFFSET_CODE_SYMBOL); + public final FseCompressionTable matchLengthTable = new FseCompressionTable(Constants.MATCH_LENGTH_TABLE_LOG, MAX_MATCH_LENGTH_SYMBOL); + + public final int[] counts = new int[MAX_SEQUENCES + 1]; + public final short[] normalizedCounts = new short[MAX_SEQUENCES + 1]; +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceStore.java b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceStore.java new file mode 100644 index 00000000..8fc141cb --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceStore.java @@ -0,0 +1,159 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; + +final class SequenceStore +{ + public final byte[] literalsBuffer; + public final MemorySegment literalsBufferSegment; + public int literalsLength; + + public final int[] offsets; + public final int[] literalLengths; + public final int[] matchLengths; + public int sequenceCount; + + public final byte[] literalLengthCodes; + public final byte[] matchLengthCodes; + public final byte[] offsetCodes; + + public LongField longLengthField; + public int longLengthPosition; + + public enum LongField + { + LITERAL, MATCH + } + + private static final byte[] LITERAL_LENGTH_CODE = {0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15, + 16, 16, 17, 17, 18, 18, 19, 19, + 20, 20, 20, 20, 21, 21, 21, 21, + 22, 22, 22, 22, 22, 22, 22, 22, + 23, 23, 23, 23, 23, 23, 23, 23, + 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 24}; + + private static final byte[] MATCH_LENGTH_CODE = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 36, 36, 37, 37, 37, 37, + 38, 38, 38, 38, 38, 38, 38, 38, 39, 39, 39, 39, 39, 39, 39, 39, + 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, + 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, + 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42}; + + public SequenceStore(int blockSize, int maxSequences) + { + offsets = new int[maxSequences]; + literalLengths = new int[maxSequences]; + matchLengths = new int[maxSequences]; + + literalLengthCodes = new byte[maxSequences]; + matchLengthCodes = new byte[maxSequences]; + offsetCodes = new byte[maxSequences]; + + literalsBuffer = new byte[blockSize]; + literalsBufferSegment = MemorySegment.ofArray(literalsBuffer); + + reset(); + } + + public void appendLiterals(MemorySegment inputBase, long inputAddress, int inputSize) + { + MemorySegment.copy(inputBase, inputAddress, literalsBufferSegment, literalsLength, inputSize); + literalsLength += inputSize; + } + + public void storeSequence(MemorySegment literalBase, long literalAddress, int literalLength, int offsetCode, int matchLengthBase) + { + long input = literalAddress; + long output = literalsLength; + int copied = 0; + do { + literalsBufferSegment.set(JAVA_LONG, output, literalBase.get(JAVA_LONG, input)); + input += SIZE_OF_LONG; + output += SIZE_OF_LONG; + copied += SIZE_OF_LONG; + } + while (copied < literalLength); + + literalsLength += literalLength; + + if (literalLength > 65535) { + longLengthField = LongField.LITERAL; + longLengthPosition = sequenceCount; + } + literalLengths[sequenceCount] = literalLength; + + offsets[sequenceCount] = offsetCode + 1; + + if (matchLengthBase > 65535) { + longLengthField = LongField.MATCH; + longLengthPosition = sequenceCount; + } + + matchLengths[sequenceCount] = matchLengthBase; + + sequenceCount++; + } + + public void reset() + { + literalsLength = 0; + sequenceCount = 0; + longLengthField = null; + } + + public void generateCodes() + { + for (int i = 0; i < sequenceCount; ++i) { + literalLengthCodes[i] = (byte) literalLengthToCode(literalLengths[i]); + offsetCodes[i] = (byte) Util.highestBit(offsets[i]); + matchLengthCodes[i] = (byte) matchLengthToCode(matchLengths[i]); + } + + if (longLengthField == LongField.LITERAL) { + literalLengthCodes[longLengthPosition] = Constants.MAX_LITERALS_LENGTH_SYMBOL; + } + if (longLengthField == LongField.MATCH) { + matchLengthCodes[longLengthPosition] = Constants.MAX_MATCH_LENGTH_SYMBOL; + } + } + + private static int literalLengthToCode(int literalLength) + { + if (literalLength >= 64) { + return Util.highestBit(literalLength) + 19; + } + else { + return LITERAL_LENGTH_CODE[literalLength]; + } + } + + private static int matchLengthToCode(int matchLengthBase) + { + if (matchLengthBase >= 128) { + return Util.highestBit(matchLengthBase) + 36; + } + else { + return MATCH_LENGTH_CODE[matchLengthBase]; + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/Util.java b/src/main/java/io/airlift/compress/v3/zstdFFM/Util.java new file mode 100644 index 00000000..7a1dc52f --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/Util.java @@ -0,0 +1,136 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.MalformedInputException; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; + +final class Util +{ + private Util() + { + } + + public static int highestBit(int value) + { + return 31 - Integer.numberOfLeadingZeros(value); + } + + public static boolean isPowerOf2(int value) + { + return (value & (value - 1)) == 0; + } + + public static int mask(int bits) + { + return (1 << bits) - 1; + } + + public static void verify(boolean condition, long offset, String reason) + { + if (!condition) { + throw new MalformedInputException(offset, reason); + } + } + + public static void checkArgument(boolean condition, String reason) + { + if (!condition) { + throw new IllegalArgumentException(reason); + } + } + + static void checkPositionIndexes(int start, int end, int size) + { + // Carefully optimized for execution by hotspot (explanatory comment above) + if (start < 0 || end < start || end > size) { + throw new IndexOutOfBoundsException(badPositionIndexes(start, end, size)); + } + } + + private static String badPositionIndexes(int start, int end, int size) + { + if (start < 0 || start > size) { + return badPositionIndex(start, size, "start index"); + } + if (end < 0 || end > size) { + return badPositionIndex(end, size, "end index"); + } + // end < start + return String.format("end index (%s) must not be less than start index (%s)", end, start); + } + + private static String badPositionIndex(int index, int size, String desc) + { + if (index < 0) { + return String.format("%s (%s) must not be negative", desc, index); + } + else if (size < 0) { + throw new IllegalArgumentException("negative size: " + size); + } + else { // index > size + return String.format("%s (%s) must not be greater than size (%s)", desc, index, size); + } + } + + public static void checkState(boolean condition, String reason) + { + if (!condition) { + throw new IllegalStateException(reason); + } + } + + public static MalformedInputException fail(long offset, String reason) + { + throw new MalformedInputException(offset, reason); + } + + public static int cycleLog(int hashLog, CompressionParameters.Strategy strategy) + { + int cycleLog = hashLog; + if (strategy == CompressionParameters.Strategy.BTLAZY2 || strategy == CompressionParameters.Strategy.BTOPT || strategy == CompressionParameters.Strategy.BTULTRA) { + cycleLog = hashLog - 1; + } + return cycleLog; + } + + public static int get24BitLittleEndian(MemorySegment inputBase, long inputAddress) + { + return (inputBase.get(JAVA_SHORT, inputAddress) & 0xFFFF) + | ((inputBase.get(JAVA_BYTE, inputAddress + SIZE_OF_SHORT) & 0xFF) << Short.SIZE); + } + + public static void put24BitLittleEndian(MemorySegment outputBase, long outputAddress, int value) + { + outputBase.set(JAVA_SHORT, outputAddress, (short) value); + outputBase.set(JAVA_BYTE, outputAddress + SIZE_OF_SHORT, (byte) (value >>> Short.SIZE)); + } + + // provides the minimum logSize to safely represent a distribution + public static int minTableLog(int inputSize, int maxSymbolValue) + { + if (inputSize <= 1) { + throw new IllegalArgumentException("Not supported. RLE should be used instead"); // TODO + } + + int minBitsSrc = highestBit((inputSize - 1)) + 1; + int minBitsSymbols = highestBit(maxSymbolValue) + 2; + return Math.min(minBitsSrc, minBitsSymbols); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/XxHash64.java b/src/main/java/io/airlift/compress/v3/zstdFFM/XxHash64.java new file mode 100644 index 00000000..c216e713 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/XxHash64.java @@ -0,0 +1,291 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; +import static io.airlift.compress.v3.zstdFFM.Util.checkPositionIndexes; +import static java.lang.Long.rotateLeft; +import static java.lang.Math.min; + +// FFM-based implementation forked from the Unsafe-based XxHash64 in the zstd package. +final class XxHash64 +{ + private static final long PRIME64_1 = 0x9E3779B185EBCA87L; + private static final long PRIME64_2 = 0xC2B2AE3D27D4EB4FL; + private static final long PRIME64_3 = 0x165667B19E3779F9L; + private static final long PRIME64_4 = 0x85EBCA77C2b2AE63L; + private static final long PRIME64_5 = 0x27D4EB2F165667C5L; + + private static final long DEFAULT_SEED = 0; + + private final long seed; + + private final byte[] buffer = new byte[32]; + private final MemorySegment bufferSegment = MemorySegment.ofArray(buffer); + private int bufferSize; + + private long bodyLength; + + private long v1; + private long v2; + private long v3; + private long v4; + + public XxHash64() + { + this(DEFAULT_SEED); + } + + private XxHash64(long seed) + { + this.seed = seed; + this.v1 = seed + PRIME64_1 + PRIME64_2; + this.v2 = seed + PRIME64_2; + this.v3 = seed; + this.v4 = seed - PRIME64_1; + } + + public XxHash64 update(byte[] data) + { + return update(data, 0, data.length); + } + + public XxHash64 update(byte[] data, int offset, int length) + { + checkPositionIndexes(offset, offset + length, data.length); + updateHash(MemorySegment.ofArray(data), offset, length); + return this; + } + + public long hash() + { + long hash; + if (bodyLength > 0) { + hash = computeBody(); + } + else { + hash = seed + PRIME64_5; + } + + hash += bodyLength + bufferSize; + + return updateTail(hash, bufferSegment, 0, 0, bufferSize); + } + + private long computeBody() + { + long hash = rotateLeft(v1, 1) + rotateLeft(v2, 7) + rotateLeft(v3, 12) + rotateLeft(v4, 18); + + hash = update(hash, v1); + hash = update(hash, v2); + hash = update(hash, v3); + hash = update(hash, v4); + + return hash; + } + + private void updateHash(MemorySegment base, long address, int length) + { + if (bufferSize > 0) { + int available = min(32 - bufferSize, length); + + MemorySegment.copy(base, address, bufferSegment, bufferSize, available); + + bufferSize += available; + address += available; + length -= available; + + if (bufferSize == 32) { + updateBody(bufferSegment, 0, bufferSize); + bufferSize = 0; + } + } + + if (length >= 32) { + int index = updateBody(base, address, length); + address += index; + length -= index; + } + + if (length > 0) { + MemorySegment.copy(base, address, bufferSegment, 0, length); + bufferSize = length; + } + } + + private int updateBody(MemorySegment base, long address, int length) + { + int remaining = length; + while (remaining >= 32) { + v1 = mix(v1, base.get(JAVA_LONG, address)); + v2 = mix(v2, base.get(JAVA_LONG, address + 8)); + v3 = mix(v3, base.get(JAVA_LONG, address + 16)); + v4 = mix(v4, base.get(JAVA_LONG, address + 24)); + + address += 32; + remaining -= 32; + } + + int index = length - remaining; + bodyLength += index; + return index; + } + + public static long hash(long value) + { + long hash = DEFAULT_SEED + PRIME64_5 + SIZE_OF_LONG; + hash = updateTail(hash, value); + hash = finalShuffle(hash); + + return hash; + } + + public static long hash(InputStream in) + throws IOException + { + return hash(DEFAULT_SEED, in); + } + + public static long hash(long seed, InputStream in) + throws IOException + { + XxHash64 hash = new XxHash64(seed); + byte[] buffer = new byte[8192]; + while (true) { + int length = in.read(buffer); + if (length == -1) { + break; + } + hash.update(buffer, 0, length); + } + return hash.hash(); + } + + public static long hash(long seed, MemorySegment base, long address, int length) + { + long hash; + if (length >= 32) { + hash = updateBody(seed, base, address, length); + } + else { + hash = seed + PRIME64_5; + } + + hash += length; + + // round to the closest 32 byte boundary + // this is the point up to which updateBody() processed + int index = length & 0xFFFFFFE0; + + return updateTail(hash, base, address, index, length); + } + + private static long updateTail(long hash, MemorySegment base, long address, int index, int length) + { + while (index <= length - 8) { + hash = updateTail(hash, base.get(JAVA_LONG, address + index)); + index += 8; + } + + if (index <= length - 4) { + hash = updateTail(hash, base.get(JAVA_INT, address + index)); + index += 4; + } + + while (index < length) { + hash = updateTail(hash, base.get(JAVA_BYTE, address + index)); + index++; + } + + hash = finalShuffle(hash); + + return hash; + } + + private static long updateBody(long seed, MemorySegment base, long address, int length) + { + long v1 = seed + PRIME64_1 + PRIME64_2; + long v2 = seed + PRIME64_2; + long v3 = seed; + long v4 = seed - PRIME64_1; + + int remaining = length; + while (remaining >= 32) { + v1 = mix(v1, base.get(JAVA_LONG, address)); + v2 = mix(v2, base.get(JAVA_LONG, address + 8)); + v3 = mix(v3, base.get(JAVA_LONG, address + 16)); + v4 = mix(v4, base.get(JAVA_LONG, address + 24)); + + address += 32; + remaining -= 32; + } + + long hash = rotateLeft(v1, 1) + rotateLeft(v2, 7) + rotateLeft(v3, 12) + rotateLeft(v4, 18); + + hash = update(hash, v1); + hash = update(hash, v2); + hash = update(hash, v3); + hash = update(hash, v4); + + return hash; + } + + private static long mix(long current, long value) + { + return rotateLeft(current + value * PRIME64_2, 31) * PRIME64_1; + } + + private static long update(long hash, long value) + { + long temp = hash ^ mix(0, value); + return temp * PRIME64_1 + PRIME64_4; + } + + private static long updateTail(long hash, long value) + { + long temp = hash ^ mix(0, value); + return rotateLeft(temp, 27) * PRIME64_1 + PRIME64_4; + } + + private static long updateTail(long hash, int value) + { + long unsigned = value & 0xFFFF_FFFFL; + long temp = hash ^ (unsigned * PRIME64_1); + return rotateLeft(temp, 23) * PRIME64_2 + PRIME64_3; + } + + private static long updateTail(long hash, byte value) + { + int unsigned = value & 0xFF; + long temp = hash ^ (unsigned * PRIME64_5); + return rotateLeft(temp, 11) * PRIME64_1; + } + + private static long finalShuffle(long hash) + { + hash ^= hash >>> 33; + hash *= PRIME64_2; + hash ^= hash >>> 29; + hash *= PRIME64_3; + hash ^= hash >>> 32; + return hash; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdCompressor.java new file mode 100644 index 00000000..f9784761 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdCompressor.java @@ -0,0 +1,43 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.Compressor; + +import java.lang.foreign.MemorySegment; + +public interface ZstdCompressor + extends Compressor +{ + int compress(MemorySegment input, MemorySegment output); + + static ZstdCompressor create() + { + if (ZstdNativeCompressor.isEnabled()) { + return new ZstdNativeCompressor(); + } + return new ZstdJavaCompressor(); + } + + static ZstdCompressor create(int compressionLevel) + { + if (ZstdNativeCompressor.isEnabled()) { + return new ZstdNativeCompressor(compressionLevel); + } + if (compressionLevel != CompressionParameters.DEFAULT_COMPRESSION_LEVEL) { + throw new IllegalArgumentException("Compression level different from default cannot be used for non-native Zstd compressor"); + } + return new ZstdJavaCompressor(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdDecompressor.java new file mode 100644 index 00000000..0f3ca159 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdDecompressor.java @@ -0,0 +1,30 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.Decompressor; + +public interface ZstdDecompressor + extends Decompressor +{ + long getDecompressedSize(byte[] input, int offset, int length); + + static ZstdDecompressor create() + { + if (ZstdNativeDecompressor.isEnabled()) { + return new ZstdNativeDecompressor(); + } + return new ZstdJavaDecompressor(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameCompressor.java new file mode 100644 index 00000000..36bc8ab9 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameCompressor.java @@ -0,0 +1,432 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.MAGIC_NUMBER; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_WINDOW_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RLE_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BLOCK_HEADER; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.Constants.TREELESS_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL_COUNT; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static io.airlift.compress.v3.zstdFFM.Util.put24BitLittleEndian; + +final class ZstdFrameCompressor +{ + static final int MAX_FRAME_HEADER_SIZE = 14; + + private static final int CHECKSUM_FLAG = 0b100; + private static final int SINGLE_SEGMENT_FLAG = 0b100000; + + private static final int MINIMUM_LITERALS_SIZE = 63; + + // the maximum table log allowed for literal encoding per RFC 8478, section 4.2.1 + private static final int MAX_HUFFMAN_TABLE_LOG = 11; + + private ZstdFrameCompressor() + { + } + + // visible for testing + static int writeMagic(final MemorySegment outputBase, final long outputAddress, final long outputLimit) + { + checkArgument(outputLimit - outputAddress >= SIZE_OF_INT, "Output buffer too small"); + + outputBase.set(JAVA_INT, outputAddress, MAGIC_NUMBER); + return SIZE_OF_INT; + } + + // visible for testing + static int writeFrameHeader(final MemorySegment outputBase, final long outputAddress, final long outputLimit, int inputSize, int windowSize) + { + checkArgument(outputLimit - outputAddress >= MAX_FRAME_HEADER_SIZE, "Output buffer too small"); + + long output = outputAddress; + + int contentSizeDescriptor = 0; + if (inputSize != -1) { + contentSizeDescriptor = (inputSize >= 256 ? 1 : 0) + (inputSize >= 65536 + 256 ? 1 : 0); + } + int frameHeaderDescriptor = (contentSizeDescriptor << 6) | CHECKSUM_FLAG; + + boolean singleSegment = inputSize != -1 && windowSize >= inputSize; + if (singleSegment) { + frameHeaderDescriptor |= SINGLE_SEGMENT_FLAG; + } + + outputBase.set(JAVA_BYTE, output, (byte) frameHeaderDescriptor); + output++; + + if (!singleSegment) { + int base = Integer.highestOneBit(windowSize); + + int exponent = 32 - Integer.numberOfLeadingZeros(base) - 1; + if (exponent < MIN_WINDOW_LOG) { + throw new IllegalArgumentException("Minimum window size is " + (1 << MIN_WINDOW_LOG)); + } + + int remainder = windowSize - base; + if (remainder % (base / 8) != 0) { + throw new IllegalArgumentException("Window size of magnitude 2^" + exponent + " must be multiple of " + (base / 8)); + } + + int mantissa = remainder / (base / 8); + int encoded = ((exponent - MIN_WINDOW_LOG) << 3) | mantissa; + + outputBase.set(JAVA_BYTE, output, (byte) encoded); + output++; + } + + switch (contentSizeDescriptor) { + case 0: + if (singleSegment) { + outputBase.set(JAVA_BYTE, output++, (byte) inputSize); + } + break; + case 1: + outputBase.set(JAVA_SHORT, output, (short) (inputSize - 256)); + output += SIZE_OF_SHORT; + break; + case 2: + outputBase.set(JAVA_INT, output, inputSize); + output += SIZE_OF_INT; + break; + default: + throw new AssertionError(); + } + + return (int) (output - outputAddress); + } + + // visible for testing + static int writeChecksum(MemorySegment outputBase, long outputAddress, long outputLimit, MemorySegment inputBase, long inputAddress, long inputLimit) + { + checkArgument(outputLimit - outputAddress >= SIZE_OF_INT, "Output buffer too small"); + + int inputSize = (int) (inputLimit - inputAddress); + + long hash = XxHash64.hash(0, inputBase, inputAddress, inputSize); + + outputBase.set(JAVA_INT, outputAddress, (int) hash); + + return SIZE_OF_INT; + } + + public static int compress(MemorySegment inputBase, long inputAddress, long inputLimit, MemorySegment outputBase, long outputAddress, long outputLimit, int compressionLevel) + { + int inputSize = (int) (inputLimit - inputAddress); + + CompressionParameters parameters = CompressionParameters.compute(compressionLevel, inputSize); + + long output = outputAddress; + + output += writeMagic(outputBase, output, outputLimit); + output += writeFrameHeader(outputBase, output, outputLimit, inputSize, parameters.getWindowSize()); + output += compressFrame(inputBase, inputAddress, inputLimit, outputBase, output, outputLimit, parameters); + output += writeChecksum(outputBase, output, outputLimit, inputBase, inputAddress, inputLimit); + + return (int) (output - outputAddress); + } + + private static int compressFrame(MemorySegment inputBase, long inputAddress, long inputLimit, MemorySegment outputBase, long outputAddress, long outputLimit, CompressionParameters parameters) + { + int blockSize = parameters.getBlockSize(); + + int outputSize = (int) (outputLimit - outputAddress); + int remaining = (int) (inputLimit - inputAddress); + + long output = outputAddress; + long input = inputAddress; + + CompressionContext context = new CompressionContext(parameters, inputAddress, remaining); + do { + checkArgument(outputSize >= SIZE_OF_BLOCK_HEADER + MIN_BLOCK_SIZE, "Output buffer too small"); + + boolean lastBlock = blockSize >= remaining; + blockSize = Math.min(blockSize, remaining); + + int compressedSize = writeCompressedBlock(inputBase, input, blockSize, outputBase, output, outputSize, context, lastBlock); + + input += blockSize; + remaining -= blockSize; + output += compressedSize; + outputSize -= compressedSize; + } + while (remaining > 0); + + return (int) (output - outputAddress); + } + + static int writeCompressedBlock(MemorySegment inputBase, long input, int blockSize, MemorySegment outputBase, long output, int outputSize, CompressionContext context, boolean lastBlock) + { + checkArgument(lastBlock || blockSize == context.parameters.getBlockSize(), "Only last block can be smaller than block size"); + + int compressedSize = 0; + if (blockSize > 0) { + compressedSize = compressBlock(inputBase, input, blockSize, outputBase, output + SIZE_OF_BLOCK_HEADER, outputSize - SIZE_OF_BLOCK_HEADER, context); + } + + if (compressedSize == 0) { + checkArgument(blockSize + SIZE_OF_BLOCK_HEADER <= outputSize, "Output size too small"); + + int blockHeader = (lastBlock ? 1 : 0) | (RAW_BLOCK << 1) | (blockSize << 3); + put24BitLittleEndian(outputBase, output, blockHeader); + MemorySegment.copy(inputBase, input, outputBase, output + SIZE_OF_BLOCK_HEADER, blockSize); + compressedSize = SIZE_OF_BLOCK_HEADER + blockSize; + } + else { + int blockHeader = (lastBlock ? 1 : 0) | (COMPRESSED_BLOCK << 1) | (compressedSize << 3); + put24BitLittleEndian(outputBase, output, blockHeader); + compressedSize += SIZE_OF_BLOCK_HEADER; + } + return compressedSize; + } + + private static int compressBlock(MemorySegment inputBase, long inputAddress, int inputSize, MemorySegment outputBase, long outputAddress, int outputSize, CompressionContext context) + { + if (inputSize < MIN_BLOCK_SIZE + SIZE_OF_BLOCK_HEADER + 1) { + return 0; + } + + CompressionParameters parameters = context.parameters; + context.blockCompressionState.enforceMaxDistance(inputAddress + inputSize, parameters.getWindowSize()); + context.sequenceStore.reset(); + + int lastLiteralsSize = parameters.getStrategy() + .getCompressor() + .compressBlock(inputBase, inputAddress, inputSize, context.sequenceStore, context.blockCompressionState, context.offsets, parameters); + + long lastLiteralsAddress = inputAddress + inputSize - lastLiteralsSize; + + context.sequenceStore.appendLiterals(inputBase, lastLiteralsAddress, lastLiteralsSize); + + context.sequenceStore.generateCodes(); + + long outputLimit = outputAddress + outputSize; + long output = outputAddress; + + int compressedLiteralsSize = encodeLiterals( + context.huffmanContext, + parameters, + outputBase, + output, + (int) (outputLimit - output), + context.sequenceStore.literalsBuffer, + context.sequenceStore.literalsLength); + output += compressedLiteralsSize; + + int compressedSequencesSize = SequenceEncoder.compressSequences(outputBase, output, (int) (outputLimit - output), context.sequenceStore, parameters.getStrategy(), context.sequenceEncodingContext); + + int compressedSize = compressedLiteralsSize + compressedSequencesSize; + if (compressedSize == 0) { + return compressedSize; + } + + int maxCompressedSize = inputSize - calculateMinimumGain(inputSize, parameters.getStrategy()); + if (compressedSize > maxCompressedSize) { + return 0; + } + + context.commit(); + + return compressedSize; + } + + private static int encodeLiterals( + HuffmanCompressionContext context, + CompressionParameters parameters, + MemorySegment outputBase, + long outputAddress, + int outputSize, + byte[] literals, + int literalsSize) + { + boolean bypassCompression = (parameters.getStrategy() == CompressionParameters.Strategy.FAST) && (parameters.getTargetLength() > 0); + if (bypassCompression || literalsSize <= MINIMUM_LITERALS_SIZE) { + return rawLiterals(outputBase, outputAddress, outputSize, MemorySegment.ofArray(literals), 0, literalsSize); + } + + int headerSize = 3 + (literalsSize >= 1024 ? 1 : 0) + (literalsSize >= 16384 ? 1 : 0); + + checkArgument(headerSize + 1 <= outputSize, "Output buffer too small"); + + int[] counts = new int[MAX_SYMBOL_COUNT]; + Histogram.count(literals, literalsSize, counts); + int maxSymbol = Histogram.findMaxSymbol(counts, MAX_SYMBOL); + int largestCount = Histogram.findLargestCount(counts, maxSymbol); + + MemorySegment literalsBase = MemorySegment.ofArray(literals); + long literalsAddress = 0; + if (largestCount == literalsSize) { + return rleLiterals(outputBase, outputAddress, outputSize, literalsBase, literalsAddress, literalsSize); + } + else if (largestCount <= (literalsSize >>> 7) + 4) { + return rawLiterals(outputBase, outputAddress, outputSize, literalsBase, literalsAddress, literalsSize); + } + + HuffmanCompressionTable previousTable = context.getPreviousTable(); + HuffmanCompressionTable table; + int serializedTableSize; + boolean reuseTable; + + boolean canReuse = previousTable.isValid(counts, maxSymbol); + + boolean preferReuse = parameters.getStrategy().ordinal() < CompressionParameters.Strategy.LAZY.ordinal() && literalsSize <= 1024; + if (preferReuse && canReuse) { + table = previousTable; + reuseTable = true; + serializedTableSize = 0; + } + else { + HuffmanCompressionTable newTable = context.borrowTemporaryTable(); + + newTable.initialize( + counts, + maxSymbol, + HuffmanCompressionTable.optimalNumberOfBits(MAX_HUFFMAN_TABLE_LOG, literalsSize, maxSymbol), + context.getCompressionTableWorkspace()); + + serializedTableSize = newTable.write(outputBase, outputAddress + headerSize, outputSize - headerSize, context.getTableWriterWorkspace()); + + if (canReuse && previousTable.estimateCompressedSize(counts, maxSymbol) <= serializedTableSize + newTable.estimateCompressedSize(counts, maxSymbol)) { + table = previousTable; + reuseTable = true; + serializedTableSize = 0; + context.discardTemporaryTable(); + } + else { + table = newTable; + reuseTable = false; + } + } + + int compressedSize; + boolean singleStream = literalsSize < 256; + if (singleStream) { + compressedSize = HuffmanCompressor.compressSingleStream(outputBase, outputAddress + headerSize + serializedTableSize, outputSize - headerSize - serializedTableSize, literalsBase, literalsAddress, literalsSize, table); + } + else { + compressedSize = HuffmanCompressor.compress4streams(outputBase, outputAddress + headerSize + serializedTableSize, outputSize - headerSize - serializedTableSize, literalsBase, literalsAddress, literalsSize, table); + } + + int totalSize = serializedTableSize + compressedSize; + int minimumGain = calculateMinimumGain(literalsSize, parameters.getStrategy()); + + if (compressedSize == 0 || totalSize >= literalsSize - minimumGain) { + context.discardTemporaryTable(); + + return rawLiterals(outputBase, outputAddress, outputSize, literalsBase, 0, literalsSize); + } + + int encodingType = reuseTable ? TREELESS_LITERALS_BLOCK : COMPRESSED_LITERALS_BLOCK; + + switch (headerSize) { + case 3: { + int header = encodingType | ((singleStream ? 0 : 1) << 2) | (literalsSize << 4) | (totalSize << 14); + put24BitLittleEndian(outputBase, outputAddress, header); + break; + } + case 4: { + int header = encodingType | (2 << 2) | (literalsSize << 4) | (totalSize << 18); + outputBase.set(JAVA_INT, outputAddress, header); + break; + } + case 5: { + int header = encodingType | (3 << 2) | (literalsSize << 4) | (totalSize << 22); + outputBase.set(JAVA_INT, outputAddress, header); + outputBase.set(JAVA_BYTE, outputAddress + SIZE_OF_INT, (byte) (totalSize >>> 10)); + break; + } + default: + throw new IllegalStateException(); + } + + return headerSize + totalSize; + } + + private static int rleLiterals(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize) + { + int headerSize = 1 + (inputSize > 31 ? 1 : 0) + (inputSize > 4095 ? 1 : 0); + + switch (headerSize) { + case 1: + outputBase.set(JAVA_BYTE, outputAddress, (byte) (RLE_LITERALS_BLOCK | (inputSize << 3))); + break; + case 2: + outputBase.set(JAVA_SHORT, outputAddress, (short) (RLE_LITERALS_BLOCK | (1 << 2) | (inputSize << 4))); + break; + case 3: + outputBase.set(JAVA_INT, outputAddress, RLE_LITERALS_BLOCK | 3 << 2 | inputSize << 4); + break; + default: + throw new IllegalStateException(); + } + + outputBase.set(JAVA_BYTE, outputAddress + headerSize, inputBase.get(JAVA_BYTE, inputAddress)); + + return headerSize + 1; + } + + private static int calculateMinimumGain(int inputSize, CompressionParameters.Strategy strategy) + { + int minLog = strategy == CompressionParameters.Strategy.BTULTRA ? 7 : 6; + return (inputSize >>> minLog) + 2; + } + + private static int rawLiterals(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize) + { + int headerSize = 1; + if (inputSize >= 32) { + headerSize++; + } + if (inputSize >= 4096) { + headerSize++; + } + + checkArgument(inputSize + headerSize <= outputSize, "Output buffer too small"); + + switch (headerSize) { + case 1: + outputBase.set(JAVA_BYTE, outputAddress, (byte) (RAW_LITERALS_BLOCK | (inputSize << 3))); + break; + case 2: + outputBase.set(JAVA_SHORT, outputAddress, (short) (RAW_LITERALS_BLOCK | (1 << 2) | (inputSize << 4))); + break; + case 3: + put24BitLittleEndian(outputBase, outputAddress, RAW_LITERALS_BLOCK | (3 << 2) | (inputSize << 4)); + break; + default: + throw new AssertionError(); + } + + checkArgument(inputSize + 1 <= outputSize, "Output buffer too small"); + + MemorySegment.copy(inputBase, inputAddress, outputBase, outputAddress + headerSize, inputSize); + + return headerSize + inputSize; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameDecompressor.java new file mode 100644 index 00000000..d7e721b8 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameDecompressor.java @@ -0,0 +1,968 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.MalformedInputException; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.BitInputStream.peekBits; +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.DEFAULT_MAX_OFFSET_CODE_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.LITERALS_LENGTH_BITS; +import static io.airlift.compress.v3.zstdFFM.Constants.LITERAL_LENGTH_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.LONG_NUMBER_OF_SEQUENCES; +import static io.airlift.compress.v3.zstdFFM.Constants.MAGIC_NUMBER; +import static io.airlift.compress.v3.zstdFFM.Constants.MATCH_LENGTH_BITS; +import static io.airlift.compress.v3.zstdFFM.Constants.MATCH_LENGTH_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_LITERALS_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_MATCH_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_SEQUENCES_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_WINDOW_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.OFFSET_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RLE_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RLE_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_BASIC; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_COMPRESSED; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_REPEAT; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_RLE; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BLOCK_HEADER; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BYTE; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.Constants.TREELESS_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.Util.fail; +import static io.airlift.compress.v3.zstdFFM.Util.get24BitLittleEndian; +import static io.airlift.compress.v3.zstdFFM.Util.mask; +import static io.airlift.compress.v3.zstdFFM.Util.verify; +import static java.lang.String.format; + +class ZstdFrameDecompressor +{ + private static final int[] DEC_32_TABLE = {4, 1, 2, 1, 4, 4, 4, 4}; + private static final int[] DEC_64_TABLE = {0, 0, 0, -1, 0, 1, 2, 3}; + + private static final int V07_MAGIC_NUMBER = 0xFD2FB527; + + static final int MAX_WINDOW_SIZE = 1 << 23; + + private static final int[] LITERALS_LENGTH_BASE = { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16, 18, 20, 22, 24, 28, 32, 40, 48, 64, 0x80, 0x100, 0x200, 0x400, 0x800, 0x1000, + 0x2000, 0x4000, 0x8000, 0x10000}; + + private static final int[] MATCH_LENGTH_BASE = { + 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, + 35, 37, 39, 41, 43, 47, 51, 59, 67, 83, 99, 0x83, 0x103, 0x203, 0x403, 0x803, + 0x1003, 0x2003, 0x4003, 0x8003, 0x10003}; + + private static final int[] OFFSET_CODES_BASE = { + 0, 1, 1, 5, 0xD, 0x1D, 0x3D, 0x7D, + 0xFD, 0x1FD, 0x3FD, 0x7FD, 0xFFD, 0x1FFD, 0x3FFD, 0x7FFD, + 0xFFFD, 0x1FFFD, 0x3FFFD, 0x7FFFD, 0xFFFFD, 0x1FFFFD, 0x3FFFFD, 0x7FFFFD, + 0xFFFFFD, 0x1FFFFFD, 0x3FFFFFD, 0x7FFFFFD, 0xFFFFFFD}; + + private static final FiniteStateEntropy.Table DEFAULT_LITERALS_LENGTH_TABLE = new FiniteStateEntropy.Table( + 6, + new int[] { + 0, 16, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 0, 32, 0, 0, 32, 0, 32, 0, 32, 0, 0, 32, 0, 32, 0, 32, 0, 0, 16, 32, 0, 0, 48, 16, 32, 32, 32, + 32, 32, 32, 32, 32, 0, 32, 32, 32, 32, 32, 32, 0, 0, 0, 0}, + new byte[] { + 0, 0, 1, 3, 4, 6, 7, 9, 10, 12, 14, 16, 18, 19, 21, 22, 24, 25, 26, 27, 29, 31, 0, 1, 2, 4, 5, 7, 8, 10, 11, 13, 16, 17, 19, 20, 22, 23, 25, 25, 26, 28, 30, 0, + 1, 2, 3, 5, 6, 8, 9, 11, 12, 15, 17, 18, 20, 21, 23, 24, 35, 34, 33, 32}, + new byte[] { + 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 4, 4, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 4, 4, 5, 6, 6, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, + 6, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6}); + + private static final FiniteStateEntropy.Table DEFAULT_OFFSET_CODES_TABLE = new FiniteStateEntropy.Table( + 5, + new int[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 16, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0}, + new byte[] {0, 6, 9, 15, 21, 3, 7, 12, 18, 23, 5, 8, 14, 20, 2, 7, 11, 17, 22, 4, 8, 13, 19, 1, 6, 10, 16, 28, 27, 26, 25, 24}, + new byte[] {5, 4, 5, 5, 5, 5, 4, 5, 5, 5, 5, 4, 5, 5, 5, 4, 5, 5, 5, 5, 4, 5, 5, 5, 4, 5, 5, 5, 5, 5, 5, 5}); + + private static final FiniteStateEntropy.Table DEFAULT_MATCH_LENGTH_TABLE = new FiniteStateEntropy.Table( + 6, + new int[] { + 0, 0, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 32, 0, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 48, 16, 32, 32, 32, 32, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new byte[] { + 0, 1, 2, 3, 5, 6, 8, 10, 13, 16, 19, 22, 25, 28, 31, 33, 35, 37, 39, 41, 43, 45, 1, 2, 3, 4, 6, 7, 9, 12, 15, 18, 21, 24, 27, 30, 32, 34, 36, 38, 40, 42, 44, 1, + 1, 2, 4, 5, 7, 8, 11, 14, 17, 20, 23, 26, 29, 52, 51, 50, 49, 48, 47, 46}, + new byte[] { + 6, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6}); + + private final byte[] literals = new byte[MAX_BLOCK_SIZE + SIZE_OF_LONG]; + private final MemorySegment literalsSegment = MemorySegment.ofArray(literals); + + private MemorySegment literalsBase; + private long literalsAddress; + private long literalsLimit; + + private final int[] previousOffsets = new int[3]; + + private final FiniteStateEntropy.Table literalsLengthTable = new FiniteStateEntropy.Table(LITERAL_LENGTH_TABLE_LOG); + private final FiniteStateEntropy.Table offsetCodesTable = new FiniteStateEntropy.Table(OFFSET_TABLE_LOG); + private final FiniteStateEntropy.Table matchLengthTable = new FiniteStateEntropy.Table(MATCH_LENGTH_TABLE_LOG); + + private FiniteStateEntropy.Table currentLiteralsLengthTable; + private FiniteStateEntropy.Table currentOffsetCodesTable; + private FiniteStateEntropy.Table currentMatchLengthTable; + + private final Huffman huffman = new Huffman(); + private final FseTableReader fse = new FseTableReader(); + + public int decompress( + final MemorySegment inputBase, + final long inputAddress, + final long inputLimit, + final MemorySegment outputBase, + final long outputAddress, + final long outputLimit) + { + if (outputAddress == outputLimit) { + return 0; + } + + long input = inputAddress; + long output = outputAddress; + + while (input < inputLimit) { + reset(); + long outputStart = output; + input += verifyMagic(inputBase, input, inputLimit); + + FrameHeader frameHeader = readFrameHeader(inputBase, input, inputLimit); + input += frameHeader.headerSize; + + boolean lastBlock; + do { + verify(input + SIZE_OF_BLOCK_HEADER <= inputLimit, input, "Not enough input bytes"); + + int header = get24BitLittleEndian(inputBase, input); + input += SIZE_OF_BLOCK_HEADER; + + lastBlock = (header & 1) != 0; + int blockType = (header >>> 1) & 0b11; + int blockSize = (header >>> 3) & 0x1F_FFFF; + + int decodedSize; + switch (blockType) { + case RAW_BLOCK: + verify(input + blockSize <= inputLimit, input, "Not enough input bytes"); + decodedSize = decodeRawBlock(inputBase, input, blockSize, outputBase, output, outputLimit); + input += blockSize; + break; + case RLE_BLOCK: + verify(input + 1 <= inputLimit, input, "Not enough input bytes"); + decodedSize = decodeRleBlock(blockSize, inputBase, input, outputBase, output, outputLimit); + input += 1; + break; + case COMPRESSED_BLOCK: + verify(input + blockSize <= inputLimit, input, "Not enough input bytes"); + decodedSize = decodeCompressedBlock(inputBase, input, blockSize, outputBase, output, outputLimit, frameHeader.windowSize, outputAddress); + input += blockSize; + break; + default: + throw fail(input, "Invalid block type"); + } + + output += decodedSize; + } + while (!lastBlock); + + if (frameHeader.hasChecksum) { + int decodedFrameSize = (int) (output - outputStart); + + long hash = XxHash64.hash(0, outputBase, outputStart, decodedFrameSize); + + verify(input + SIZE_OF_INT <= inputLimit, input, "Not enough input bytes"); + int checksum = inputBase.get(JAVA_INT, input); + if (checksum != (int) hash) { + throw new MalformedInputException(input, format("Bad checksum. Expected: %s, actual: %s", Integer.toHexString(checksum), Integer.toHexString((int) hash))); + } + + input += SIZE_OF_INT; + } + } + + return (int) (output - outputAddress); + } + + void reset() + { + previousOffsets[0] = 1; + previousOffsets[1] = 4; + previousOffsets[2] = 8; + + currentLiteralsLengthTable = null; + currentOffsetCodesTable = null; + currentMatchLengthTable = null; + } + + static int decodeRawBlock(MemorySegment inputBase, long inputAddress, int blockSize, MemorySegment outputBase, long outputAddress, long outputLimit) + { + verify(outputAddress + blockSize <= outputLimit, inputAddress, "Output buffer too small"); + + MemorySegment.copy(inputBase, inputAddress, outputBase, outputAddress, blockSize); + return blockSize; + } + + static int decodeRleBlock(int size, MemorySegment inputBase, long inputAddress, MemorySegment outputBase, long outputAddress, long outputLimit) + { + verify(outputAddress + size <= outputLimit, inputAddress, "Output buffer too small"); + + long output = outputAddress; + long value = inputBase.get(JAVA_BYTE, inputAddress) & 0xFFL; + + int remaining = size; + if (remaining >= SIZE_OF_LONG) { + long packed = value + | (value << 8) + | (value << 16) + | (value << 24) + | (value << 32) + | (value << 40) + | (value << 48) + | (value << 56); + + do { + outputBase.set(JAVA_LONG, output, packed); + output += SIZE_OF_LONG; + remaining -= SIZE_OF_LONG; + } + while (remaining >= SIZE_OF_LONG); + } + + for (int i = 0; i < remaining; i++) { + outputBase.set(JAVA_BYTE, output, (byte) value); + output++; + } + + return size; + } + + int decodeCompressedBlock( + MemorySegment inputBase, + final long inputAddress, + int blockSize, + MemorySegment outputBase, + long outputAddress, + long outputLimit, + int windowSize, + long outputAbsoluteBaseAddress) + { + long inputLimit = inputAddress + blockSize; + long input = inputAddress; + + verify(blockSize <= MAX_BLOCK_SIZE, input, "Expected match length table to be present"); + verify(blockSize >= MIN_BLOCK_SIZE, input, "Compressed block size too small"); + + // decode literals + int literalsBlockType = inputBase.get(JAVA_BYTE, input) & 0b11; + + switch (literalsBlockType) { + case RAW_LITERALS_BLOCK: { + input += decodeRawLiterals(inputBase, input, inputLimit); + break; + } + case RLE_LITERALS_BLOCK: { + input += decodeRleLiterals(inputBase, input, blockSize); + break; + } + case TREELESS_LITERALS_BLOCK: + verify(huffman.isLoaded(), input, "Dictionary is corrupted"); + case COMPRESSED_LITERALS_BLOCK: { + input += decodeCompressedLiterals(inputBase, input, blockSize, literalsBlockType); + break; + } + default: + throw fail(input, "Invalid literals block encoding type"); + } + + verify(windowSize <= MAX_WINDOW_SIZE, input, "Window size too large (not yet supported)"); + + return decompressSequences( + inputBase, input, inputAddress + blockSize, + outputBase, outputAddress, outputLimit, + literalsBase, literalsAddress, literalsLimit, + outputAbsoluteBaseAddress); + } + + private int decompressSequences( + final MemorySegment inputBase, final long inputAddress, final long inputLimit, + final MemorySegment outputBase, final long outputAddress, final long outputLimit, + final MemorySegment literalsBase, final long literalsAddress, final long literalsLimit, + long outputAbsoluteBaseAddress) + { + final long fastOutputLimit = outputLimit - SIZE_OF_LONG; + final long fastMatchOutputLimit = fastOutputLimit - SIZE_OF_LONG; + + long input = inputAddress; + long output = outputAddress; + + long literalsInput = literalsAddress; + + int size = (int) (inputLimit - inputAddress); + verify(size >= MIN_SEQUENCES_SIZE, input, "Not enough input bytes"); + + // decode header + int sequenceCount = inputBase.get(JAVA_BYTE, input++) & 0xFF; + if (sequenceCount != 0) { + if (sequenceCount == 255) { + verify(input + SIZE_OF_SHORT <= inputLimit, input, "Not enough input bytes"); + sequenceCount = (inputBase.get(JAVA_SHORT, input) & 0xFFFF) + LONG_NUMBER_OF_SEQUENCES; + input += SIZE_OF_SHORT; + } + else if (sequenceCount > 127) { + verify(input < inputLimit, input, "Not enough input bytes"); + sequenceCount = ((sequenceCount - 128) << 8) + (inputBase.get(JAVA_BYTE, input++) & 0xFF); + } + + verify(input + SIZE_OF_INT <= inputLimit, input, "Not enough input bytes"); + + byte type = inputBase.get(JAVA_BYTE, input++); + + int literalsLengthType = (type & 0xFF) >>> 6; + int offsetCodesType = (type >>> 4) & 0b11; + int matchLengthType = (type >>> 2) & 0b11; + + input = computeLiteralsTable(literalsLengthType, inputBase, input, inputLimit); + input = computeOffsetsTable(offsetCodesType, inputBase, input, inputLimit); + input = computeMatchLengthTable(matchLengthType, inputBase, input, inputLimit); + + // decompress sequences + BitInputStream.Initializer initializer = new BitInputStream.Initializer(inputBase, input, inputLimit); + initializer.initialize(); + int bitsConsumed = initializer.getBitsConsumed(); + long bits = initializer.getBits(); + long currentAddress = initializer.getCurrentAddress(); + + FiniteStateEntropy.Table currentLiteralsLengthTable = this.currentLiteralsLengthTable; + FiniteStateEntropy.Table currentOffsetCodesTable = this.currentOffsetCodesTable; + FiniteStateEntropy.Table currentMatchLengthTable = this.currentMatchLengthTable; + + int literalsLengthState = (int) peekBits(bitsConsumed, bits, currentLiteralsLengthTable.log2Size); + bitsConsumed += currentLiteralsLengthTable.log2Size; + + int offsetCodesState = (int) peekBits(bitsConsumed, bits, currentOffsetCodesTable.log2Size); + bitsConsumed += currentOffsetCodesTable.log2Size; + + int matchLengthState = (int) peekBits(bitsConsumed, bits, currentMatchLengthTable.log2Size); + bitsConsumed += currentMatchLengthTable.log2Size; + + int[] previousOffsets = this.previousOffsets; + + byte[] literalsLengthNumbersOfBits = currentLiteralsLengthTable.numberOfBits; + int[] literalsLengthNewStates = currentLiteralsLengthTable.newState; + byte[] literalsLengthSymbols = currentLiteralsLengthTable.symbol; + + byte[] matchLengthNumbersOfBits = currentMatchLengthTable.numberOfBits; + int[] matchLengthNewStates = currentMatchLengthTable.newState; + byte[] matchLengthSymbols = currentMatchLengthTable.symbol; + + byte[] offsetCodesNumbersOfBits = currentOffsetCodesTable.numberOfBits; + int[] offsetCodesNewStates = currentOffsetCodesTable.newState; + byte[] offsetCodesSymbols = currentOffsetCodesTable.symbol; + + while (sequenceCount > 0) { + sequenceCount--; + + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + if (loader.isOverflow()) { + verify(sequenceCount == 0, input, "Not all sequences were consumed"); + break; + } + + // decode sequence + int literalsLengthCode = literalsLengthSymbols[literalsLengthState]; + int matchLengthCode = matchLengthSymbols[matchLengthState]; + int offsetCode = offsetCodesSymbols[offsetCodesState]; + + int literalsLengthBits = LITERALS_LENGTH_BITS[literalsLengthCode]; + int matchLengthBits = MATCH_LENGTH_BITS[matchLengthCode]; + int offsetBits = offsetCode; + + int offset = OFFSET_CODES_BASE[offsetCode]; + if (offsetCode > 0) { + offset += peekBits(bitsConsumed, bits, offsetBits); + bitsConsumed += offsetBits; + } + + if (offsetCode <= 1) { + if (literalsLengthCode == 0) { + offset++; + } + + if (offset != 0) { + int temp; + if (offset == 3) { + temp = previousOffsets[0] - 1; + } + else { + temp = previousOffsets[offset]; + } + + if (temp == 0) { + temp = 1; + } + + if (offset != 1) { + previousOffsets[2] = previousOffsets[1]; + } + previousOffsets[1] = previousOffsets[0]; + previousOffsets[0] = temp; + + offset = temp; + } + else { + offset = previousOffsets[0]; + } + } + else { + previousOffsets[2] = previousOffsets[1]; + previousOffsets[1] = previousOffsets[0]; + previousOffsets[0] = offset; + } + + int matchLength = MATCH_LENGTH_BASE[matchLengthCode]; + if (matchLengthCode > 31) { + matchLength += peekBits(bitsConsumed, bits, matchLengthBits); + bitsConsumed += matchLengthBits; + } + + int literalsLength = LITERALS_LENGTH_BASE[literalsLengthCode]; + if (literalsLengthCode > 15) { + literalsLength += peekBits(bitsConsumed, bits, literalsLengthBits); + bitsConsumed += literalsLengthBits; + } + + int totalBits = literalsLengthBits + matchLengthBits + offsetBits; + if (totalBits > 64 - 7 - (LITERAL_LENGTH_TABLE_LOG + MATCH_LENGTH_TABLE_LOG + OFFSET_TABLE_LOG)) { + BitInputStream.Loader loader1 = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader1.load(); + + bitsConsumed = loader1.getBitsConsumed(); + bits = loader1.getBits(); + currentAddress = loader1.getCurrentAddress(); + } + + int numberOfBits; + + numberOfBits = literalsLengthNumbersOfBits[literalsLengthState]; + literalsLengthState = (int) (literalsLengthNewStates[literalsLengthState] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + numberOfBits = matchLengthNumbersOfBits[matchLengthState]; + matchLengthState = (int) (matchLengthNewStates[matchLengthState] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + numberOfBits = offsetCodesNumbersOfBits[offsetCodesState]; + offsetCodesState = (int) (offsetCodesNewStates[offsetCodesState] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + final long literalOutputLimit = output + literalsLength; + final long matchOutputLimit = literalOutputLimit + matchLength; + + verify(matchOutputLimit <= outputLimit, input, "Output buffer too small"); + long literalEnd = literalsInput + literalsLength; + verify(literalEnd <= literalsLimit, input, "Input is corrupted"); + + long matchAddress = literalOutputLimit - offset; + verify(matchAddress >= outputAbsoluteBaseAddress, input, "Input is corrupted"); + + if (literalOutputLimit > fastOutputLimit) { + executeLastSequence(outputBase, output, literalOutputLimit, matchOutputLimit, fastOutputLimit, literalsInput, matchAddress); + } + else { + output = copyLiterals(outputBase, literalsBase, output, literalsInput, literalOutputLimit); + copyMatch(outputBase, fastOutputLimit, output, offset, matchOutputLimit, matchAddress, matchLength, fastMatchOutputLimit); + } + output = matchOutputLimit; + literalsInput = literalEnd; + } + } + + // last literal segment + output = copyLastLiteral(input, literalsBase, literalsInput, literalsLimit, outputBase, output, outputLimit); + + return (int) (output - outputAddress); + } + + private static long copyLastLiteral(long input, MemorySegment literalsBase, long literalsInput, long literalsLimit, MemorySegment outputBase, long output, long outputLimit) + { + long lastLiteralsSize = literalsLimit - literalsInput; + verify(output + lastLiteralsSize <= outputLimit, input, "Output buffer too small"); + MemorySegment.copy(literalsBase, literalsInput, outputBase, output, lastLiteralsSize); + output += lastLiteralsSize; + return output; + } + + private static void copyMatch(MemorySegment outputBase, + long fastOutputLimit, + long output, + int offset, + long matchOutputLimit, + long matchAddress, + int matchLength, + long fastMatchOutputLimit) + { + matchAddress = copyMatchHead(outputBase, output, offset, matchAddress); + output += SIZE_OF_LONG; + matchLength -= SIZE_OF_LONG; + + copyMatchTail(outputBase, fastOutputLimit, output, matchOutputLimit, matchAddress, matchLength, fastMatchOutputLimit); + } + + private static void copyMatchTail(MemorySegment outputBase, long fastOutputLimit, long output, long matchOutputLimit, long matchAddress, int matchLength, long fastMatchOutputLimit) + { + if (matchOutputLimit < fastMatchOutputLimit) { + int copied = 0; + do { + outputBase.set(JAVA_LONG, output, outputBase.get(JAVA_LONG, matchAddress)); + output += SIZE_OF_LONG; + matchAddress += SIZE_OF_LONG; + copied += SIZE_OF_LONG; + } + while (copied < matchLength); + } + else { + while (output < fastOutputLimit) { + outputBase.set(JAVA_LONG, output, outputBase.get(JAVA_LONG, matchAddress)); + matchAddress += SIZE_OF_LONG; + output += SIZE_OF_LONG; + } + + while (output < matchOutputLimit) { + outputBase.set(JAVA_BYTE, output++, outputBase.get(JAVA_BYTE, matchAddress++)); + } + } + } + + private static long copyMatchHead(MemorySegment outputBase, long output, int offset, long matchAddress) + { + if (offset < 8) { + int increment32 = DEC_32_TABLE[offset]; + int decrement64 = DEC_64_TABLE[offset]; + + outputBase.set(JAVA_BYTE, output, outputBase.get(JAVA_BYTE, matchAddress)); + outputBase.set(JAVA_BYTE, output + 1, outputBase.get(JAVA_BYTE, matchAddress + 1)); + outputBase.set(JAVA_BYTE, output + 2, outputBase.get(JAVA_BYTE, matchAddress + 2)); + outputBase.set(JAVA_BYTE, output + 3, outputBase.get(JAVA_BYTE, matchAddress + 3)); + matchAddress += increment32; + + outputBase.set(JAVA_INT, output + 4, outputBase.get(JAVA_INT, matchAddress)); + matchAddress -= decrement64; + } + else { + outputBase.set(JAVA_LONG, output, outputBase.get(JAVA_LONG, matchAddress)); + matchAddress += SIZE_OF_LONG; + } + return matchAddress; + } + + private static long copyLiterals(MemorySegment outputBase, MemorySegment literalsBase, long output, long literalsInput, long literalOutputLimit) + { + long literalInput = literalsInput; + do { + outputBase.set(JAVA_LONG, output, literalsBase.get(JAVA_LONG, literalInput)); + output += SIZE_OF_LONG; + literalInput += SIZE_OF_LONG; + } + while (output < literalOutputLimit); + output = literalOutputLimit; + return output; + } + + private long computeMatchLengthTable(int matchLengthType, MemorySegment inputBase, long input, long inputLimit) + { + switch (matchLengthType) { + case SEQUENCE_ENCODING_RLE: + verify(input < inputLimit, input, "Not enough input bytes"); + + byte value = inputBase.get(JAVA_BYTE, input++); + verify(value <= MAX_MATCH_LENGTH_SYMBOL, input, "Value exceeds expected maximum value"); + + FseTableReader.initializeRleTable(matchLengthTable, value); + currentMatchLengthTable = matchLengthTable; + break; + case SEQUENCE_ENCODING_BASIC: + currentMatchLengthTable = DEFAULT_MATCH_LENGTH_TABLE; + break; + case SEQUENCE_ENCODING_REPEAT: + verify(currentMatchLengthTable != null, input, "Expected match length table to be present"); + break; + case SEQUENCE_ENCODING_COMPRESSED: + input += fse.readFseTable(matchLengthTable, inputBase, input, inputLimit, MAX_MATCH_LENGTH_SYMBOL, MATCH_LENGTH_TABLE_LOG); + currentMatchLengthTable = matchLengthTable; + break; + default: + throw fail(input, "Invalid match length encoding type"); + } + return input; + } + + private long computeOffsetsTable(int offsetCodesType, MemorySegment inputBase, long input, long inputLimit) + { + switch (offsetCodesType) { + case SEQUENCE_ENCODING_RLE: + verify(input < inputLimit, input, "Not enough input bytes"); + + byte value = inputBase.get(JAVA_BYTE, input++); + verify(value <= DEFAULT_MAX_OFFSET_CODE_SYMBOL, input, "Value exceeds expected maximum value"); + + FseTableReader.initializeRleTable(offsetCodesTable, value); + currentOffsetCodesTable = offsetCodesTable; + break; + case SEQUENCE_ENCODING_BASIC: + currentOffsetCodesTable = DEFAULT_OFFSET_CODES_TABLE; + break; + case SEQUENCE_ENCODING_REPEAT: + verify(currentOffsetCodesTable != null, input, "Expected match length table to be present"); + break; + case SEQUENCE_ENCODING_COMPRESSED: + input += fse.readFseTable(offsetCodesTable, inputBase, input, inputLimit, DEFAULT_MAX_OFFSET_CODE_SYMBOL, OFFSET_TABLE_LOG); + currentOffsetCodesTable = offsetCodesTable; + break; + default: + throw fail(input, "Invalid offset code encoding type"); + } + return input; + } + + private long computeLiteralsTable(int literalsLengthType, MemorySegment inputBase, long input, long inputLimit) + { + switch (literalsLengthType) { + case SEQUENCE_ENCODING_RLE: + verify(input < inputLimit, input, "Not enough input bytes"); + + byte value = inputBase.get(JAVA_BYTE, input++); + verify(value <= MAX_LITERALS_LENGTH_SYMBOL, input, "Value exceeds expected maximum value"); + + FseTableReader.initializeRleTable(literalsLengthTable, value); + currentLiteralsLengthTable = literalsLengthTable; + break; + case SEQUENCE_ENCODING_BASIC: + currentLiteralsLengthTable = DEFAULT_LITERALS_LENGTH_TABLE; + break; + case SEQUENCE_ENCODING_REPEAT: + verify(currentLiteralsLengthTable != null, input, "Expected match length table to be present"); + break; + case SEQUENCE_ENCODING_COMPRESSED: + input += fse.readFseTable(literalsLengthTable, inputBase, input, inputLimit, MAX_LITERALS_LENGTH_SYMBOL, LITERAL_LENGTH_TABLE_LOG); + currentLiteralsLengthTable = literalsLengthTable; + break; + default: + throw fail(input, "Invalid literals length encoding type"); + } + return input; + } + + private void executeLastSequence(MemorySegment outputBase, long output, long literalOutputLimit, long matchOutputLimit, long fastOutputLimit, long literalInput, long matchAddress) + { + // copy literals + if (output < fastOutputLimit) { + do { + outputBase.set(JAVA_LONG, output, literalsBase.get(JAVA_LONG, literalInput)); + output += SIZE_OF_LONG; + literalInput += SIZE_OF_LONG; + } + while (output < fastOutputLimit); + + literalInput -= output - fastOutputLimit; + output = fastOutputLimit; + } + + while (output < literalOutputLimit) { + outputBase.set(JAVA_BYTE, output, literalsBase.get(JAVA_BYTE, literalInput)); + output++; + literalInput++; + } + + // copy match + while (output < matchOutputLimit) { + outputBase.set(JAVA_BYTE, output, outputBase.get(JAVA_BYTE, matchAddress)); + output++; + matchAddress++; + } + } + + private int decodeCompressedLiterals(MemorySegment inputBase, final long inputAddress, int blockSize, int literalsBlockType) + { + long input = inputAddress; + verify(blockSize >= 5, input, "Not enough input bytes"); + + int compressedSize; + int uncompressedSize; + boolean singleStream = false; + int headerSize; + int type = (inputBase.get(JAVA_BYTE, input) >> 2) & 0b11; + switch (type) { + case 0: + singleStream = true; + case 1: { + int header = inputBase.get(JAVA_INT, input); + + headerSize = 3; + uncompressedSize = (header >>> 4) & mask(10); + compressedSize = (header >>> 14) & mask(10); + break; + } + case 2: { + int header = inputBase.get(JAVA_INT, input); + + headerSize = 4; + uncompressedSize = (header >>> 4) & mask(14); + compressedSize = (header >>> 18) & mask(14); + break; + } + case 3: { + long header = inputBase.get(JAVA_BYTE, input) & 0xFF | + (inputBase.get(JAVA_INT, input + 1) & 0xFFFF_FFFFL) << 8; + + headerSize = 5; + uncompressedSize = (int) ((header >>> 4) & mask(18)); + compressedSize = (int) ((header >>> 22) & mask(18)); + break; + } + default: + throw fail(input, "Invalid literals header size type"); + } + + verify(uncompressedSize <= MAX_BLOCK_SIZE, input, "Block exceeds maximum size"); + verify(headerSize + compressedSize <= blockSize, input, "Input is corrupted"); + + input += headerSize; + + long inputLimit = input + compressedSize; + if (literalsBlockType != TREELESS_LITERALS_BLOCK) { + input += huffman.readTable(inputBase, input, compressedSize); + } + + literalsBase = literalsSegment; + literalsAddress = 0; + literalsLimit = uncompressedSize; + + if (singleStream) { + huffman.decodeSingleStream(inputBase, input, inputLimit, literalsSegment, literalsAddress, literalsLimit); + } + else { + huffman.decode4Streams(inputBase, input, inputLimit, literalsSegment, literalsAddress, literalsLimit); + } + + return headerSize + compressedSize; + } + + private int decodeRleLiterals(MemorySegment inputBase, final long inputAddress, int blockSize) + { + long input = inputAddress; + int outputSize; + + int type = (inputBase.get(JAVA_BYTE, input) >> 2) & 0b11; + switch (type) { + case 0: + case 2: + outputSize = (inputBase.get(JAVA_BYTE, input) & 0xFF) >>> 3; + input++; + break; + case 1: + outputSize = (inputBase.get(JAVA_SHORT, input) & 0xFFFF) >>> 4; + input += 2; + break; + case 3: + verify(blockSize >= SIZE_OF_INT, input, "Not enough input bytes"); + outputSize = (inputBase.get(JAVA_INT, input) & 0xFF_FFFF) >>> 4; + input += 3; + break; + default: + throw fail(input, "Invalid RLE literals header encoding type"); + } + + verify(outputSize <= MAX_BLOCK_SIZE, input, "Output exceeds maximum block size"); + + byte value = inputBase.get(JAVA_BYTE, input++); + Arrays.fill(literals, 0, outputSize + SIZE_OF_LONG, value); + + literalsBase = literalsSegment; + literalsAddress = 0; + literalsLimit = outputSize; + + return (int) (input - inputAddress); + } + + private int decodeRawLiterals(MemorySegment inputBase, final long inputAddress, long inputLimit) + { + long input = inputAddress; + int type = (inputBase.get(JAVA_BYTE, input) >> 2) & 0b11; + + int literalSize; + switch (type) { + case 0: + case 2: + literalSize = (inputBase.get(JAVA_BYTE, input) & 0xFF) >>> 3; + input++; + break; + case 1: + literalSize = (inputBase.get(JAVA_SHORT, input) & 0xFFFF) >>> 4; + input += 2; + break; + case 3: + int header = ((inputBase.get(JAVA_BYTE, input) & 0xFF) | + ((inputBase.get(JAVA_SHORT, input + 1) & 0xFFFF) << 8)); + + literalSize = header >>> 4; + input += 3; + break; + default: + throw fail(input, "Invalid raw literals header encoding type"); + } + + verify(input + literalSize <= inputLimit, input, "Not enough input bytes"); + + if (literalSize > (inputLimit - input) - SIZE_OF_LONG) { + literalsBase = literalsSegment; + literalsAddress = 0; + literalsLimit = literalSize; + + MemorySegment.copy(inputBase, input, literalsSegment, literalsAddress, literalSize); + Arrays.fill(literals, literalSize, literalSize + SIZE_OF_LONG, (byte) 0); + } + else { + literalsBase = inputBase; + literalsAddress = input; + literalsLimit = literalsAddress + literalSize; + } + input += literalSize; + + return (int) (input - inputAddress); + } + + static FrameHeader readFrameHeader(final MemorySegment inputBase, final long inputAddress, final long inputLimit) + { + long input = inputAddress; + verify(input < inputLimit, input, "Not enough input bytes"); + + int frameHeaderDescriptor = inputBase.get(JAVA_BYTE, input++) & 0xFF; + boolean singleSegment = (frameHeaderDescriptor & 0b100000) != 0; + int dictionaryDescriptor = frameHeaderDescriptor & 0b11; + int contentSizeDescriptor = frameHeaderDescriptor >>> 6; + + int headerSize = 1 + + (singleSegment ? 0 : 1) + + (dictionaryDescriptor == 0 ? 0 : (1 << (dictionaryDescriptor - 1))) + + (contentSizeDescriptor == 0 ? (singleSegment ? 1 : 0) : (1 << contentSizeDescriptor)); + + verify(headerSize <= inputLimit - inputAddress, input, "Not enough input bytes"); + + // decode window size + int windowSize = -1; + if (!singleSegment) { + int windowDescriptor = inputBase.get(JAVA_BYTE, input++) & 0xFF; + int exponent = windowDescriptor >>> 3; + int mantissa = windowDescriptor & 0b111; + + int base = 1 << (MIN_WINDOW_LOG + exponent); + windowSize = base + (base / 8) * mantissa; + } + + // decode dictionary id + long dictionaryId = -1; + switch (dictionaryDescriptor) { + case 1: + dictionaryId = inputBase.get(JAVA_BYTE, input) & 0xFF; + input += SIZE_OF_BYTE; + break; + case 2: + dictionaryId = inputBase.get(JAVA_SHORT, input) & 0xFFFF; + input += SIZE_OF_SHORT; + break; + case 3: + dictionaryId = inputBase.get(JAVA_INT, input) & 0xFFFF_FFFFL; + input += SIZE_OF_INT; + break; + } + verify(dictionaryId == -1, input, "Custom dictionaries not supported"); + + // decode content size + long contentSize = -1; + switch (contentSizeDescriptor) { + case 0: + if (singleSegment) { + contentSize = inputBase.get(JAVA_BYTE, input) & 0xFF; + input += SIZE_OF_BYTE; + } + break; + case 1: + contentSize = inputBase.get(JAVA_SHORT, input) & 0xFFFF; + contentSize += 256; + input += SIZE_OF_SHORT; + break; + case 2: + contentSize = inputBase.get(JAVA_INT, input) & 0xFFFF_FFFFL; + input += SIZE_OF_INT; + break; + case 3: + contentSize = inputBase.get(JAVA_LONG, input); + input += SIZE_OF_LONG; + break; + } + + boolean hasChecksum = (frameHeaderDescriptor & 0b100) != 0; + + return new FrameHeader( + input - inputAddress, + windowSize, + contentSize, + dictionaryId, + hasChecksum); + } + + public static long getDecompressedSize(final MemorySegment inputBase, final long inputAddress, final long inputLimit) + { + long input = inputAddress; + input += verifyMagic(inputBase, input, inputLimit); + return readFrameHeader(inputBase, input, inputLimit).contentSize; + } + + static int verifyMagic(MemorySegment inputBase, long inputAddress, long inputLimit) + { + verify(inputLimit - inputAddress >= 4, inputAddress, "Not enough input bytes"); + + int magic = inputBase.get(JAVA_INT, inputAddress); + if (magic != MAGIC_NUMBER) { + if (magic == V07_MAGIC_NUMBER) { + throw new MalformedInputException(inputAddress, "Data encoded in unsupported ZSTD v0.7 format"); + } + throw new MalformedInputException(inputAddress, "Invalid magic prefix: " + Integer.toHexString(magic)); + } + + return SIZE_OF_INT; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopInputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopInputStream.java new file mode 100644 index 00000000..163b51ea --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopInputStream.java @@ -0,0 +1,68 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.hadoop.HadoopInputStream; + +import java.io.IOException; +import java.io.InputStream; + +import static java.util.Objects.requireNonNull; + +class ZstdHadoopInputStream + extends HadoopInputStream +{ + private final InputStream in; + private ZstdInputStream zstdInputStream; + + public ZstdHadoopInputStream(InputStream in) + { + this.in = requireNonNull(in, "in is null"); + zstdInputStream = new ZstdInputStream(in); + } + + @Override + public int read() + throws IOException + { + return zstdInputStream.read(); + } + + @Override + public int read(byte[] b) + throws IOException + { + return zstdInputStream.read(b); + } + + @Override + public int read(byte[] outputBuffer, int outputOffset, int outputLength) + throws IOException + { + return zstdInputStream.read(outputBuffer, outputOffset, outputLength); + } + + @Override + public void resetState() + { + zstdInputStream = new ZstdInputStream(in); + } + + @Override + public void close() + throws IOException + { + zstdInputStream.close(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopOutputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopOutputStream.java new file mode 100644 index 00000000..3f4b8f88 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopOutputStream.java @@ -0,0 +1,91 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.hadoop.HadoopOutputStream; + +import java.io.IOException; +import java.io.OutputStream; + +import static java.util.Objects.requireNonNull; + +class ZstdHadoopOutputStream + extends HadoopOutputStream +{ + private final OutputStream out; + private boolean initialized; + private ZstdOutputStream zstdOutputStream; + + public ZstdHadoopOutputStream(OutputStream out) + { + this.out = requireNonNull(out, "out is null"); + } + + @Override + public void write(int b) + throws IOException + { + openStreamIfNecessary(); + zstdOutputStream.write(b); + } + + @Override + public void write(byte[] buffer, int offset, int length) + throws IOException + { + openStreamIfNecessary(); + zstdOutputStream.write(buffer, offset, length); + } + + @Override + public void finish() + throws IOException + { + if (zstdOutputStream != null) { + zstdOutputStream.finishWithoutClosingSource(); + zstdOutputStream = null; + } + } + + @Override + public void flush() + throws IOException + { + out.flush(); + } + + @Override + public void close() + throws IOException + { + try { + if (!initialized) { + openStreamIfNecessary(); + } + finish(); + } + finally { + out.close(); + } + } + + private void openStreamIfNecessary() + throws IOException + { + if (zstdOutputStream == null) { + initialized = true; + zstdOutputStream = new ZstdOutputStream(out); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopStreams.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopStreams.java new file mode 100644 index 00000000..4aa416bb --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopStreams.java @@ -0,0 +1,55 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.hadoop.HadoopInputStream; +import io.airlift.compress.v3.hadoop.HadoopOutputStream; +import io.airlift.compress.v3.hadoop.HadoopStreams; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; + +import static java.util.Collections.singletonList; + +public class ZstdHadoopStreams + implements HadoopStreams +{ + @Override + public String getDefaultFileExtension() + { + return ".zst"; + } + + @Override + public List getHadoopCodecName() + { + return singletonList("org.apache.hadoop.io.compress.ZStandardCodec"); + } + + @Override + public HadoopInputStream createInputStream(InputStream in) + throws IOException + { + return new ZstdHadoopInputStream(in); + } + + @Override + public HadoopOutputStream createOutputStream(OutputStream out) + throws IOException + { + return new ZstdHadoopOutputStream(out); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdIncrementalFrameDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdIncrementalFrameDecompressor.java new file mode 100644 index 00000000..4a899d28 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdIncrementalFrameDecompressor.java @@ -0,0 +1,380 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.MalformedInputException; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RLE_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BLOCK_HEADER; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static io.airlift.compress.v3.zstdFFM.Util.checkState; +import static io.airlift.compress.v3.zstdFFM.Util.fail; +import static io.airlift.compress.v3.zstdFFM.Util.verify; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.MAX_WINDOW_SIZE; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.decodeRawBlock; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.decodeRleBlock; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.readFrameHeader; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.verifyMagic; +import static java.lang.Math.max; +import static java.lang.Math.min; +import static java.lang.Math.toIntExact; +import static java.lang.String.format; + +public class ZstdIncrementalFrameDecompressor +{ + private enum State { + INITIAL, + READ_FRAME_MAGIC, + READ_FRAME_HEADER, + READ_BLOCK_HEADER, + READ_BLOCK, + READ_BLOCK_CHECKSUM, + FLUSH_OUTPUT + } + + private final ZstdFrameDecompressor frameDecompressor = new ZstdFrameDecompressor(); + + private State state = State.INITIAL; + private FrameHeader frameHeader; + private int blockHeader = -1; + + private int inputConsumed; + private int outputBufferUsed; + + private int inputRequired; + private int requestedOutputSize; + + // current window buffer + private byte[] windowBase = new byte[0]; + private MemorySegment windowSegment = MemorySegment.ofArray(windowBase); + private long windowAddress = 0; + private long windowLimit = 0; + private long windowPosition = 0; + + private XxHash64 partialHash; + + public boolean isAtStoppingPoint() + { + return state == State.READ_FRAME_MAGIC; + } + + public int getInputConsumed() + { + return inputConsumed; + } + + public int getOutputBufferUsed() + { + return outputBufferUsed; + } + + public int getInputRequired() + { + return inputRequired; + } + + public int getRequestedOutputSize() + { + return requestedOutputSize; + } + + public void partialDecompress( + final MemorySegment inputBase, + final long inputAddress, + final long inputLimit, + final byte[] outputArray, + final int outputOffset, + final int outputLimit) + { + if (inputRequired > inputLimit - inputAddress) { + throw new IllegalArgumentException(format( + "Required %s input bytes, but only %s input bytes were supplied", + inputRequired, + inputLimit - inputAddress)); + } + if (requestedOutputSize > 0 && outputOffset >= outputLimit) { + throw new IllegalArgumentException("Not enough space in output buffer to output"); + } + + long input = inputAddress; + int output = outputOffset; + + while (true) { + // Flush ready output + { + int flushableOutputSize = computeFlushableOutputSize(frameHeader); + if (flushableOutputSize > 0) { + int freeOutputSize = outputLimit - output; + if (freeOutputSize > 0) { + int copySize = min(freeOutputSize, flushableOutputSize); + System.arraycopy(windowBase, toIntExact(windowAddress), outputArray, output, copySize); + if (partialHash != null) { + partialHash.update(outputArray, output, copySize); + } + windowAddress += copySize; + output += copySize; + flushableOutputSize -= copySize; + } + if (flushableOutputSize > 0) { + requestOutput(inputAddress, outputOffset, input, output, flushableOutputSize); + return; + } + } + } + // verify data was completely flushed + checkState(computeFlushableOutputSize(frameHeader) == 0, "Expected output to be flushed"); + + if (state == State.READ_FRAME_MAGIC || state == State.INITIAL) { + if (inputLimit - input < 4) { + inputRequired(inputAddress, outputOffset, input, output, 4); + return; + } + input += verifyMagic(inputBase, input, inputLimit); + state = State.READ_FRAME_HEADER; + } + + if (state == State.READ_FRAME_HEADER) { + if (inputLimit - input < 1) { + inputRequired(inputAddress, outputOffset, input, output, 1); + return; + } + int frameHeaderSize = determineFrameHeaderSize(inputBase, input, inputLimit); + if (inputLimit - input < frameHeaderSize) { + inputRequired(inputAddress, outputOffset, input, output, frameHeaderSize); + return; + } + frameHeader = readFrameHeader(inputBase, input, inputLimit); + verify(frameHeaderSize == frameHeader.headerSize, input, "Unexpected frame header size"); + input += frameHeaderSize; + state = State.READ_BLOCK_HEADER; + + reset(); + if (frameHeader.hasChecksum) { + partialHash = new XxHash64(); + } + } + else { + verify(frameHeader != null, input, "Frame header is not set"); + } + + if (state == State.READ_BLOCK_HEADER) { + long inputBufferSize = inputLimit - input; + if (inputBufferSize < SIZE_OF_BLOCK_HEADER) { + inputRequired(inputAddress, outputOffset, input, output, SIZE_OF_BLOCK_HEADER); + return; + } + if (inputBufferSize >= SIZE_OF_INT) { + blockHeader = inputBase.get(JAVA_INT, input) & 0xFF_FFFF; + } + else { + // FFM enforces bounds, so we cannot read past the buffer end like the Unsafe variant + // (which relied on the JVM's array padding to silently return zero for the unread byte). + blockHeader = inputBase.get(JAVA_BYTE, input) & 0xFF | + (inputBase.get(JAVA_BYTE, input + 1) & 0xFF) << 8 | + (inputBase.get(JAVA_BYTE, input + 2) & 0xFF) << 16; + } + input += SIZE_OF_BLOCK_HEADER; + state = State.READ_BLOCK; + } + else { + verify(blockHeader != -1, input, "Block header is not set"); + } + + boolean lastBlock = (blockHeader & 1) != 0; + if (state == State.READ_BLOCK) { + int blockType = (blockHeader >>> 1) & 0b11; + int blockSize = (blockHeader >>> 3) & 0x1F_FFFF; + + resizeWindowBufferIfNecessary(frameHeader, blockType, blockSize); + + int decodedSize; + switch (blockType) { + case RAW_BLOCK: { + if (inputLimit - input < blockSize) { + inputRequired(inputAddress, outputOffset, input, output, blockSize); + return; + } + verify(windowLimit - windowPosition >= blockSize, input, "window buffer is too small"); + decodedSize = decodeRawBlock(inputBase, input, blockSize, windowSegment, windowPosition, windowLimit); + input += blockSize; + break; + } + case RLE_BLOCK: { + if (inputLimit - input < 1) { + inputRequired(inputAddress, outputOffset, input, output, 1); + return; + } + verify(windowLimit - windowPosition >= blockSize, input, "window buffer is too small"); + decodedSize = decodeRleBlock(blockSize, inputBase, input, windowSegment, windowPosition, windowLimit); + input += 1; + break; + } + case COMPRESSED_BLOCK: { + if (inputLimit - input < blockSize) { + inputRequired(inputAddress, outputOffset, input, output, blockSize); + return; + } + verify(windowLimit - windowPosition >= MAX_BLOCK_SIZE, input, "window buffer is too small"); + decodedSize = frameDecompressor.decodeCompressedBlock(inputBase, input, blockSize, windowSegment, windowPosition, windowLimit, frameHeader.windowSize, windowAddress); + input += blockSize; + break; + } + default: + throw fail(input, "Invalid block type"); + } + windowPosition += decodedSize; + if (lastBlock) { + state = State.READ_BLOCK_CHECKSUM; + } + else { + state = State.READ_BLOCK_HEADER; + } + } + + if (state == State.READ_BLOCK_CHECKSUM) { + if (frameHeader.hasChecksum) { + if (inputLimit - input < SIZE_OF_INT) { + inputRequired(inputAddress, outputOffset, input, output, SIZE_OF_INT); + return; + } + + int checksum = inputBase.get(JAVA_INT, input); + input += SIZE_OF_INT; + + checkState(partialHash != null, "Partial hash not set"); + + int pendingOutputSize = toIntExact(windowPosition - windowAddress); + partialHash.update(windowBase, toIntExact(windowAddress), pendingOutputSize); + + long hash = partialHash.hash(); + if (checksum != (int) hash) { + throw new MalformedInputException(input, format("Bad checksum. Expected: %s, actual: %s", Integer.toHexString(checksum), Integer.toHexString((int) hash))); + } + } + state = State.READ_FRAME_MAGIC; + frameHeader = null; + blockHeader = -1; + } + } + } + + private void reset() + { + frameDecompressor.reset(); + + windowAddress = 0; + windowPosition = 0; + } + + private int computeFlushableOutputSize(FrameHeader frameHeader) + { + return max(0, toIntExact(windowPosition - windowAddress - (frameHeader == null ? 0 : frameHeader.computeRequiredOutputBufferLookBackSize()))); + } + + private void resizeWindowBufferIfNecessary(FrameHeader frameHeader, int blockType, int blockSize) + { + int maxBlockOutput; + if (blockType == RAW_BLOCK || blockType == RLE_BLOCK) { + maxBlockOutput = blockSize; + } + else { + maxBlockOutput = MAX_BLOCK_SIZE; + } + + if (windowLimit - windowPosition < MAX_BLOCK_SIZE) { + int requiredWindowSize = frameHeader.computeRequiredOutputBufferLookBackSize(); + checkState(windowPosition - windowAddress <= requiredWindowSize, "Expected output to be flushed"); + + int windowContentsSize = toIntExact(windowPosition - windowAddress); + + if (windowAddress != 0) { + System.arraycopy(windowBase, toIntExact(windowAddress), windowBase, 0, windowContentsSize); + windowAddress = 0; + windowPosition = windowAddress + windowContentsSize; + } + checkState(windowAddress == 0, "Window should be packed"); + + if (windowLimit - windowPosition < maxBlockOutput) { + int newWindowSize; + if (frameHeader.contentSize >= 0 && frameHeader.contentSize < requiredWindowSize) { + newWindowSize = toIntExact(frameHeader.contentSize); + } + else { + newWindowSize = (windowContentsSize + maxBlockOutput) * 2; + newWindowSize = min(newWindowSize, max(requiredWindowSize, MAX_BLOCK_SIZE) * 4); + newWindowSize = min(newWindowSize, MAX_WINDOW_SIZE + MAX_BLOCK_SIZE); + newWindowSize = max(windowContentsSize + maxBlockOutput, newWindowSize); + checkState(windowContentsSize + maxBlockOutput <= newWindowSize, "Computed new window size buffer is not large enough"); + } + windowBase = Arrays.copyOf(windowBase, newWindowSize); + windowSegment = MemorySegment.ofArray(windowBase); + windowLimit = newWindowSize; + } + + checkState(windowLimit - windowPosition >= maxBlockOutput, "window buffer is too small"); + } + } + + private static int determineFrameHeaderSize(final MemorySegment inputBase, final long inputAddress, final long inputLimit) + { + verify(inputAddress < inputLimit, inputAddress, "Not enough input bytes"); + + int frameHeaderDescriptor = inputBase.get(JAVA_BYTE, inputAddress) & 0xFF; + boolean singleSegment = (frameHeaderDescriptor & 0b100000) != 0; + int dictionaryDescriptor = frameHeaderDescriptor & 0b11; + int contentSizeDescriptor = frameHeaderDescriptor >>> 6; + + return 1 + + (singleSegment ? 0 : 1) + + (dictionaryDescriptor == 0 ? 0 : (1 << (dictionaryDescriptor - 1))) + + (contentSizeDescriptor == 0 ? (singleSegment ? 1 : 0) : (1 << contentSizeDescriptor)); + } + + private void requestOutput(long inputAddress, int outputOffset, long input, int output, int requestedOutputSize) + { + updateInputOutputState(inputAddress, outputOffset, input, output); + + checkArgument(requestedOutputSize >= 0, "requestedOutputSize is negative"); + this.requestedOutputSize = requestedOutputSize; + + this.inputRequired = 0; + } + + private void inputRequired(long inputAddress, int outputOffset, long input, int output, int inputRequired) + { + updateInputOutputState(inputAddress, outputOffset, input, output); + + checkState(inputRequired >= 0, "inputRequired is negative"); + this.inputRequired = inputRequired; + + this.requestedOutputSize = 0; + } + + private void updateInputOutputState(long inputAddress, int outputOffset, long input, int output) + { + inputConsumed = (int) (input - inputAddress); + checkState(inputConsumed >= 0, "inputConsumed is negative"); + outputBufferUsed = output - outputOffset; + checkState(outputBufferUsed >= 0, "outputBufferUsed is negative"); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdInputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdInputStream.java new file mode 100644 index 00000000..53d5f798 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdInputStream.java @@ -0,0 +1,152 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.Util.checkPositionIndexes; +import static io.airlift.compress.v3.zstdFFM.Util.checkState; +import static java.lang.Math.max; +import static java.util.Objects.requireNonNull; + +public class ZstdInputStream + extends InputStream +{ + private static final int MIN_BUFFER_SIZE = 4096; + + private final InputStream inputStream; + private final ZstdIncrementalFrameDecompressor decompressor = new ZstdIncrementalFrameDecompressor(); + + private byte[] inputBuffer = new byte[decompressor.getInputRequired()]; + private MemorySegment inputBufferSegment = MemorySegment.ofArray(inputBuffer); + private int inputBufferOffset; + private int inputBufferLimit; + + private byte[] singleByteOutputBuffer; + + private boolean closed; + + public ZstdInputStream(InputStream inputStream) + { + this.inputStream = requireNonNull(inputStream, "inputStream is null"); + } + + @Override + public int read() + throws IOException + { + if (singleByteOutputBuffer == null) { + singleByteOutputBuffer = new byte[1]; + } + int readSize = read(singleByteOutputBuffer, 0, 1); + checkState(readSize != 0, "A zero read size should never be returned"); + if (readSize != 1) { + return -1; + } + return singleByteOutputBuffer[0] & 0xFF; + } + + @Override + public int read(final byte[] outputBuffer, final int outputOffset, final int outputLength) + throws IOException + { + if (closed) { + throw new IOException("Stream is closed"); + } + + if (outputBuffer == null) { + throw new NullPointerException(); + } + checkPositionIndexes(outputOffset, outputOffset + outputLength, outputBuffer.length); + if (outputLength == 0) { + return 0; + } + + final int outputLimit = outputOffset + outputLength; + int outputUsed = 0; + while (outputUsed < outputLength) { + boolean enoughInput = fillInputBufferIfNecessary(decompressor.getInputRequired()); + if (!enoughInput) { + if (decompressor.isAtStoppingPoint()) { + return outputUsed > 0 ? outputUsed : -1; + } + throw new IOException("Not enough input bytes"); + } + + decompressor.partialDecompress( + inputBufferSegment, + inputBufferOffset, + inputBufferLimit, + outputBuffer, + outputOffset + outputUsed, + outputLimit); + + inputBufferOffset += decompressor.getInputConsumed(); + outputUsed += decompressor.getOutputBufferUsed(); + } + return outputUsed; + } + + private boolean fillInputBufferIfNecessary(int requiredSize) + throws IOException + { + if (inputBufferLimit - inputBufferOffset >= requiredSize) { + return true; + } + + if (inputBufferOffset > 0) { + int copySize = inputBufferLimit - inputBufferOffset; + System.arraycopy(inputBuffer, inputBufferOffset, inputBuffer, 0, copySize); + inputBufferOffset = 0; + inputBufferLimit = copySize; + } + + if (inputBuffer.length < requiredSize) { + inputBuffer = Arrays.copyOf(inputBuffer, max(requiredSize, MIN_BUFFER_SIZE)); + inputBufferSegment = MemorySegment.ofArray(inputBuffer); + } + + while (inputBufferLimit < inputBuffer.length) { + int readSize = inputStream.read(inputBuffer, inputBufferLimit, inputBuffer.length - inputBufferLimit); + if (readSize < 0) { + break; + } + inputBufferLimit += readSize; + } + return inputBufferLimit >= requiredSize; + } + + @Override + public int available() + throws IOException + { + if (closed) { + return 0; + } + return decompressor.getRequestedOutputSize(); + } + + @Override + public void close() + throws IOException + { + if (!closed) { + closed = true; + inputStream.close(); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaCompressor.java new file mode 100644 index 00000000..8233daa2 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaCompressor.java @@ -0,0 +1,90 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static java.lang.Math.addExact; +import static java.lang.String.format; +import static java.lang.ref.Reference.reachabilityFence; +import static java.util.Objects.requireNonNull; + +public class ZstdJavaCompressor + implements ZstdCompressor +{ + @Override + public int maxCompressedLength(int uncompressedSize) + { + int result = uncompressedSize + (uncompressedSize >>> 8); + + if (uncompressedSize < MAX_BLOCK_SIZE) { + result += (MAX_BLOCK_SIZE - uncompressedSize) >>> 11; + } + + return result; + } + + @Override + public int compress(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, int maxOutputLength) + { + verifyRange(input, inputOffset, inputLength); + verifyRange(output, outputOffset, maxOutputLength); + + MemorySegment inputSegment = MemorySegment.ofArray(input); + MemorySegment outputSegment = MemorySegment.ofArray(output); + + return ZstdFrameCompressor.compress( + inputSegment, + inputOffset, + inputOffset + inputLength, + outputSegment, + outputOffset, + outputOffset + maxOutputLength, + CompressionParameters.DEFAULT_COMPRESSION_LEVEL); + } + + @Override + public int compress(MemorySegment input, MemorySegment output) + { + try { + long inputAddress = 0; + long inputLimit = addExact(inputAddress, input.byteSize()); + + long outputAddress = 0; + long outputLimit = addExact(outputAddress, output.byteSize()); + + return ZstdFrameCompressor.compress( + input, + inputAddress, + inputLimit, + output, + outputAddress, + outputLimit, + CompressionParameters.DEFAULT_COMPRESSION_LEVEL); + } + finally { + reachabilityFence(input); + reachabilityFence(output); + } + } + + private static void verifyRange(byte[] data, int offset, int length) + { + requireNonNull(data, "data is null"); + if (offset < 0 || length < 0 || offset + length > data.length) { + throw new IllegalArgumentException(format("Invalid offset or length (%s, %s) in array of length %s", offset, length, data.length)); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaDecompressor.java new file mode 100644 index 00000000..a744901a --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaDecompressor.java @@ -0,0 +1,87 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.MalformedInputException; + +import java.lang.foreign.MemorySegment; + +import static java.lang.Math.addExact; +import static java.lang.String.format; +import static java.lang.ref.Reference.reachabilityFence; +import static java.util.Objects.requireNonNull; + +public class ZstdJavaDecompressor + implements ZstdDecompressor +{ + private final ZstdFrameDecompressor decompressor = new ZstdFrameDecompressor(); + + @Override + public int decompress(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, int maxOutputLength) + throws MalformedInputException + { + verifyRange(input, inputOffset, inputLength); + verifyRange(output, outputOffset, maxOutputLength); + + MemorySegment inputSegment = MemorySegment.ofArray(input); + MemorySegment outputSegment = MemorySegment.ofArray(output); + + return decompressor.decompress( + inputSegment, + inputOffset, + inputOffset + inputLength, + outputSegment, + outputOffset, + outputOffset + maxOutputLength); + } + + @Override + public int decompress(MemorySegment input, MemorySegment output) + throws MalformedInputException + { + try { + long inputAddress = 0; + long inputLimit = addExact(inputAddress, input.byteSize()); + + long outputAddress = 0; + long outputLimit = addExact(outputAddress, output.byteSize()); + + return decompressor.decompress( + input, + inputAddress, + inputLimit, + output, + outputAddress, + outputLimit); + } + finally { + reachabilityFence(input); + reachabilityFence(output); + } + } + + @Override + public long getDecompressedSize(byte[] input, int offset, int length) + { + return ZstdFrameDecompressor.getDecompressedSize(MemorySegment.ofArray(input), offset, offset + length); + } + + private static void verifyRange(byte[] data, int offset, int length) + { + requireNonNull(data, "data is null"); + if (offset < 0 || length < 0 || offset + length > data.length) { + throw new IllegalArgumentException(format("Invalid offset or length (%s, %s) in array of length %s", offset, length, data.length)); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNative.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNative.java new file mode 100644 index 00000000..cc64a221 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNative.java @@ -0,0 +1,190 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.internal.NativeLoader; +import io.airlift.compress.v3.internal.NativeSignature; + +import java.lang.foreign.MemorySegment; +import java.lang.invoke.MethodHandle; +import java.util.Optional; + +import static java.lang.invoke.MethodHandles.lookup; + +final class ZstdNative +{ + private record MethodHandles( + @NativeSignature(name = "ZSTD_compressBound", returnType = long.class, argumentTypes = long.class) + MethodHandle maxCompressedLength, + @NativeSignature(name = "ZSTD_compress", returnType = long.class, argumentTypes = {MemorySegment.class, long.class, MemorySegment.class, long.class, int.class}) + MethodHandle compress, + @NativeSignature(name = "ZSTD_decompress", returnType = long.class, argumentTypes = {MemorySegment.class, long.class, MemorySegment.class, long.class}) + MethodHandle decompress, + @NativeSignature(name = "ZSTD_getFrameContentSize", returnType = long.class, argumentTypes = {MemorySegment.class, long.class}) + MethodHandle uncompressedLength, + @NativeSignature(name = "ZSTD_isError", returnType = int.class, argumentTypes = long.class) + MethodHandle isError, + @NativeSignature(name = "ZSTD_getErrorName", returnType = MemorySegment.class, argumentTypes = long.class) + MethodHandle getErrorName, + @NativeSignature(name = "ZSTD_defaultCLevel", returnType = int.class, argumentTypes = {}) + MethodHandle defaultCLevel) {} + + private ZstdNative() {} + + private static final Optional LINKAGE_ERROR; + private static final MethodHandle MAX_COMPRESSED_LENGTH_METHOD; + private static final MethodHandle COMPRESS_METHOD; + private static final MethodHandle DECOMPRESS_METHOD; + private static final MethodHandle UNCOMPRESSED_LENGTH_METHOD; + private static final MethodHandle IS_ERROR_METHOD; + private static final MethodHandle GET_ERROR_NAME_METHOD; + + public static final int DEFAULT_COMPRESSION_LEVEL; + + private static final long CONTENT_SIZE_UNKNOWN = -1L; + + static { + NativeLoader.Symbols symbols = NativeLoader.loadSymbols("zstd", MethodHandles.class, lookup()); + LINKAGE_ERROR = symbols.linkageError(); + MethodHandles methodHandles = symbols.symbols(); + MAX_COMPRESSED_LENGTH_METHOD = methodHandles.maxCompressedLength(); + COMPRESS_METHOD = methodHandles.compress(); + DECOMPRESS_METHOD = methodHandles.decompress(); + UNCOMPRESSED_LENGTH_METHOD = methodHandles.uncompressedLength(); + IS_ERROR_METHOD = methodHandles.isError(); + GET_ERROR_NAME_METHOD = methodHandles.getErrorName(); + if (LINKAGE_ERROR.isEmpty()) { + try { + DEFAULT_COMPRESSION_LEVEL = (int) methodHandles.defaultCLevel().invokeExact(); + } + catch (Throwable e) { + throw new ExceptionInInitializerError(e); + } + } + else { + DEFAULT_COMPRESSION_LEVEL = -1; + } + } + + public static boolean isEnabled() + { + return LINKAGE_ERROR.isEmpty(); + } + + public static void verifyEnabled() + { + if (LINKAGE_ERROR.isPresent()) { + throw new IllegalStateException("Zstd native library is not enabled", LINKAGE_ERROR.get()); + } + } + + public static long maxCompressedLength(long inputLength) + { + long result; + try { + result = (long) MAX_COMPRESSED_LENGTH_METHOD.invokeExact(inputLength); + } + catch (Throwable e) { + throw new AssertionError("should not reach here", e); + } + if (isError(result)) { + throw new IllegalArgumentException("Unknown error occurred during compression: " + getErrorName(result)); + } + return result; + } + + public static long compress(MemorySegment input, long inputLength, MemorySegment compressed, long compressedLength, int compressionLevel) + { + long result; + try { + result = (long) COMPRESS_METHOD.invokeExact(compressed, compressedLength, input, inputLength, compressionLevel); + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + + if (isError(result)) { + throw new IllegalArgumentException("Unknown error occurred during compression: " + getErrorName(result)); + } + return result; + } + + public static long decompress(MemorySegment compressed, long compressedLength, MemorySegment output, long outputLength) + { + long result; + try { + result = (long) DECOMPRESS_METHOD.invokeExact(output, outputLength, compressed, compressedLength); + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + + if (isError(result)) { + throw new IllegalArgumentException("Unknown error occurred during decompression: " + getErrorName(result)); + } + return result; + } + + public static long decompressedLength(MemorySegment compressed, long compressedLength) + { + long result; + try { + result = (long) UNCOMPRESSED_LENGTH_METHOD.invokeExact(compressed, compressedLength); + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + + if (CONTENT_SIZE_UNKNOWN != result && result < 0) { + throw new IllegalArgumentException("Unknown error occurred during decompression: " + getErrorName(result)); + } + return result; + } + + private static boolean isError(long code) + { + try { + return (int) IS_ERROR_METHOD.invokeExact(code) != 0; + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + } + + private static String getErrorName(long code) + { + try { + MemorySegment name = (MemorySegment) GET_ERROR_NAME_METHOD.invokeExact(code); + return name.reinterpret(Long.MAX_VALUE).getString(0); + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeCompressor.java new file mode 100644 index 00000000..56083280 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeCompressor.java @@ -0,0 +1,61 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.ZstdNative.DEFAULT_COMPRESSION_LEVEL; +import static java.lang.Math.toIntExact; + +public class ZstdNativeCompressor + implements ZstdCompressor +{ + private final int compressionLevel; + + public ZstdNativeCompressor() + { + this(DEFAULT_COMPRESSION_LEVEL); + } + + public ZstdNativeCompressor(int compressionLevel) + { + ZstdNative.verifyEnabled(); + this.compressionLevel = compressionLevel; + } + + public static boolean isEnabled() + { + return ZstdNative.isEnabled(); + } + + @Override + public int maxCompressedLength(int uncompressedSize) + { + return toIntExact(ZstdNative.maxCompressedLength(uncompressedSize)); + } + + @Override + public int compress(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, int maxOutputLength) + { + MemorySegment inputSegment = MemorySegment.ofArray(input).asSlice(inputOffset, inputLength); + MemorySegment outputSegment = MemorySegment.ofArray(output).asSlice(outputOffset, maxOutputLength); + return toIntExact(ZstdNative.compress(inputSegment, inputLength, outputSegment, maxOutputLength, compressionLevel)); + } + + @Override + public int compress(MemorySegment input, MemorySegment output) + { + return toIntExact(ZstdNative.compress(input, input.byteSize(), output, output.byteSize(), compressionLevel)); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeDecompressor.java new file mode 100644 index 00000000..b28d8692 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeDecompressor.java @@ -0,0 +1,53 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static java.lang.Math.toIntExact; + +public class ZstdNativeDecompressor + implements ZstdDecompressor +{ + public ZstdNativeDecompressor() + { + ZstdNative.verifyEnabled(); + } + + public static boolean isEnabled() + { + return ZstdNative.isEnabled(); + } + + @Override + public int decompress(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, int maxOutputLength) + { + MemorySegment inputSegment = MemorySegment.ofArray(input).asSlice(inputOffset, inputLength); + MemorySegment outputSegment = MemorySegment.ofArray(output).asSlice(outputOffset, maxOutputLength); + return toIntExact(ZstdNative.decompress(inputSegment, inputLength, outputSegment, maxOutputLength)); + } + + @Override + public int decompress(MemorySegment input, MemorySegment output) + { + return toIntExact(ZstdNative.decompress(input, input.byteSize(), output, output.byteSize())); + } + + @Override + public long getDecompressedSize(byte[] input, int offset, int length) + { + MemorySegment inputSegment = MemorySegment.ofArray(input).asSlice(offset, length); + return ZstdNative.decompressedLength(inputSegment, length); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdOutputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdOutputStream.java new file mode 100644 index 00000000..ca3e62f8 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdOutputStream.java @@ -0,0 +1,211 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.CompressionParameters.DEFAULT_COMPRESSION_LEVEL; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BLOCK_HEADER; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.Util.checkState; +import static java.lang.Math.max; +import static java.lang.Math.min; +import static java.util.Objects.requireNonNull; + +public class ZstdOutputStream + extends OutputStream +{ + private final OutputStream outputStream; + private final CompressionContext context; + private final int maxBufferSize; + + private XxHash64 partialHash; + + private byte[] uncompressed = new byte[0]; + private MemorySegment uncompressedSegment = MemorySegment.ofArray(uncompressed); + private final byte[] compressed; + private final MemorySegment compressedSegment; + + // start of unprocessed data in uncompressed buffer + private int uncompressedOffset; + // end of unprocessed data in uncompressed buffer + private int uncompressedPosition; + + private boolean closed; + + public ZstdOutputStream(OutputStream outputStream) + throws IOException + { + this.outputStream = requireNonNull(outputStream, "outputStream is null"); + this.context = new CompressionContext(CompressionParameters.compute(DEFAULT_COMPRESSION_LEVEL, -1), 0, Integer.MAX_VALUE); + this.maxBufferSize = context.parameters.getWindowSize() * 4; + + int bufferSize = context.parameters.getBlockSize() + SIZE_OF_BLOCK_HEADER; + this.compressed = new byte[bufferSize + (bufferSize >>> 8) + SIZE_OF_LONG]; + this.compressedSegment = MemorySegment.ofArray(compressed); + } + + @Override + public void write(int b) + throws IOException + { + if (closed) { + throw new IOException("Stream is closed"); + } + + growBufferIfNecessary(1); + + uncompressed[uncompressedPosition++] = (byte) b; + + compressIfNecessary(); + } + + @Override + public void write(byte[] buffer) + throws IOException + { + write(buffer, 0, buffer.length); + } + + @Override + public void write(byte[] buffer, int offset, int length) + throws IOException + { + if (closed) { + throw new IOException("Stream is closed"); + } + + growBufferIfNecessary(length); + + while (length > 0) { + int writeSize = min(length, uncompressed.length - uncompressedPosition); + System.arraycopy(buffer, offset, uncompressed, uncompressedPosition, writeSize); + + uncompressedPosition += writeSize; + length -= writeSize; + offset += writeSize; + + compressIfNecessary(); + } + } + + private void growBufferIfNecessary(int length) + { + if (uncompressedPosition + length <= uncompressed.length || uncompressed.length >= maxBufferSize) { + return; + } + + int newSize = (uncompressed.length + length) * 2; + newSize = min(newSize, maxBufferSize); + newSize = max(newSize, context.parameters.getBlockSize()); + uncompressed = Arrays.copyOf(uncompressed, newSize); + uncompressedSegment = MemorySegment.ofArray(uncompressed); + } + + private void compressIfNecessary() + throws IOException + { + if (uncompressed.length >= maxBufferSize && + uncompressedPosition == uncompressed.length && + uncompressed.length - context.parameters.getWindowSize() > context.parameters.getBlockSize()) { + writeChunk(false); + } + } + + // visible for Hadoop stream + void finishWithoutClosingSource() + throws IOException + { + if (!closed) { + writeChunk(true); + closed = true; + } + } + + @Override + public void close() + throws IOException + { + if (!closed) { + writeChunk(true); + + closed = true; + outputStream.close(); + } + } + + private void writeChunk(boolean lastChunk) + throws IOException + { + int chunkSize; + if (lastChunk) { + chunkSize = uncompressedPosition - uncompressedOffset; + } + else { + int blockSize = context.parameters.getBlockSize(); + chunkSize = uncompressedPosition - uncompressedOffset - context.parameters.getWindowSize() - blockSize; + checkState(chunkSize > blockSize, "Must write at least one full block"); + chunkSize = (chunkSize / blockSize) * blockSize; + } + + if (partialHash == null) { + partialHash = new XxHash64(); + + int inputSize = lastChunk ? chunkSize : -1; + + long outputAddress = 0; + outputAddress += ZstdFrameCompressor.writeMagic(compressedSegment, outputAddress, outputAddress + 4); + outputAddress += ZstdFrameCompressor.writeFrameHeader(compressedSegment, outputAddress, outputAddress + 14, inputSize, context.parameters.getWindowSize()); + outputStream.write(compressed, 0, (int) outputAddress); + } + + partialHash.update(uncompressed, uncompressedOffset, chunkSize); + + do { + int blockSize = min(chunkSize, context.parameters.getBlockSize()); + int compressedSize = ZstdFrameCompressor.writeCompressedBlock( + uncompressedSegment, + uncompressedOffset, + blockSize, + compressedSegment, + 0, + compressed.length, + context, + lastChunk && blockSize == chunkSize); + outputStream.write(compressed, 0, compressedSize); + uncompressedOffset += blockSize; + chunkSize -= blockSize; + } + while (chunkSize > 0); + + if (lastChunk) { + int hash = (int) partialHash.hash(); + outputStream.write(hash); + outputStream.write(hash >> 8); + outputStream.write(hash >> 16); + outputStream.write(hash >> 24); + } + else { + int slideWindowSize = uncompressedOffset - context.parameters.getWindowSize(); + context.slideWindow(slideWindowSize); + + System.arraycopy(uncompressed, slideWindowSize, uncompressed, 0, context.parameters.getWindowSize() + (uncompressedPosition - uncompressedOffset)); + uncompressedOffset -= slideWindowSize; + uncompressedPosition -= slideWindowSize; + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/AggregateCountFastPathTest.java b/src/test/java/com/ebremer/beakgraph/AggregateCountFastPathTest.java new file mode 100644 index 00000000..a9213b21 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/AggregateCountFastPathTest.java @@ -0,0 +1,181 @@ +package com.ebremer.beakgraph; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.jena.AggregateCountFastPath; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.ResultSet; +import org.apache.jena.sparql.core.Quad; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Whole-graph COUNT aggregates answered from index structure + * ({@link AggregateCountFastPath} over {@link com.ebremer.beakgraph.hdf5.readers.IndexCounts}) + * must agree exactly with scan-computed counts, and every unsupported shape must + * fall back with unchanged results. HITS distinguishes the two paths. + */ +class AggregateCountFastPathTest { + + private static final String NS = "http://ex.org/"; + private static final String PREFIX = "PREFIX ex: <" + NS + ">\n"; + + @TempDir + static Path dir; + static BeakGraph bg; + static Dataset ds; + + @BeforeAll + static void build() throws Exception { + // Default graph: 4 triples, 3 subjects, 4 predicates, 4 objects. + // g1: 2 triples (1 subject); g2: 1 triple. + String trig = """ + @prefix ex: . + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:p1 "lit" . + ex:s1 ex:shared ex:s0 . + ex:s2 ex:refl ex:s2 . + ex:g1 { ex:a ex:p2 ex:b . ex:a ex:shared ex:c . } + ex:g2 { ex:d ex:p3 "x" . } + """; + File src = dir.resolve("agg.trig").toFile(); + File h5 = dir.resolve("agg.trig.h5").toFile(); + Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); + HDF5Writer.Builder().setSource(src).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); + bg = new BeakGraph(new HDF5Reader(h5)); + ds = bg.getDataset(); + } + + @AfterAll + static void close() { + if (bg != null) bg.close(); + } + + /** Runs a one-row/one-var aggregate query and returns the count. */ + private static long count(String queryBody) { + try (QueryExecution qe = QueryExecution.dataset(ds) + .query(QueryFactory.create(PREFIX + queryBody)).build()) { + ResultSet rs = qe.execSelect(); + String var = rs.getResultVars().get(0); + long n = rs.next().getLiteral(var).getLong(); + assertEquals(false, rs.hasNext(), "aggregate must yield exactly one row"); + return n; + } + } + + private static void check(String queryBody, long expected, boolean expectFastPath) { + long before = AggregateCountFastPath.HITS.get(); + assertEquals(expected, count(queryBody), "count for: " + queryBody); + long used = AggregateCountFastPath.HITS.get() - before; + assertEquals(expectFastPath ? 1 : 0, used, "fast-path activations for: " + queryBody); + } + + @Test + void countStarDefaultGraph() { + check("SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }", 4, true); + } + + @Test + void countBoundVarEqualsRowCount() { + check("SELECT (COUNT(?o) AS ?n) WHERE { ?s ?p ?o }", 4, true); + } + + @Test + void countDistinctStarEqualsRowCount() { + check("SELECT (COUNT(DISTINCT *) AS ?n) WHERE { ?s ?p ?o }", 4, true); + } + + @Test + void countDistinctSubjects() { + check("SELECT (COUNT(DISTINCT ?s) AS ?numSub) WHERE { ?s ?p ?o }", 3, true); + } + + @Test + void countDistinctPredicates() { + check("SELECT (COUNT(DISTINCT ?p) AS ?n) WHERE { ?s ?p ?o }", 4, true); + } + + @Test + void countDistinctObjectsFallsBack() { + // No direct index level for distinct objects - must scan, same answer. + check("SELECT (COUNT(DISTINCT ?o) AS ?n) WHERE { ?s ?p ?o }", 4, false); + } + + @Test + void namedGraphCounts() { + check("SELECT (COUNT(*) AS ?n) WHERE { GRAPH ex:g1 { ?s ?p ?o } }", 2, true); + check("SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { GRAPH ex:g1 { ?s ?p ?o } }", 1, true); + } + + @Test + void nonGraphEntityCountsZero() { + // ex:s0 is an entity but not a graph: its first-level slot is a padding + // block, which must read as empty - not as a neighbor's rows. + check("SELECT (COUNT(*) AS ?n) WHERE { GRAPH ex:s0 { ?s ?p ?o } }", 0, true); + } + + @Test + void absentGraphCountsZero() { + check("SELECT (COUNT(*) AS ?n) WHERE { GRAPH ex:nope { ?s ?p ?o } }", 0, true); + } + + @Test + void unionGraphFallsBack() { + check("SELECT (COUNT(*) AS ?n) WHERE { GRAPH <" + Quad.unionGraph.getURI() + "> { ?s ?p ?o } }", + 3, false); + } + + @Test + void filterShapeFallsBack() { + check("SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o FILTER(isLiteral(?o)) }", 1, false); + } + + @Test + void concreteTermFallsBack() { + check("SELECT (COUNT(*) AS ?n) WHERE { ex:s0 ?p ?o }", 2, false); + } + + @Test + void repeatedVariableFallsBack() { + check("SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?s }", 1, false); + } + + @Test + void countExpressionFallsBack() { + check("SELECT (COUNT(DISTINCT STR(?s)) AS ?n) WHERE { ?s ?p ?o }", 3, false); + } + + @Test + void unboundCountVarFallsBack() { + // COUNT of a variable the pattern never binds is 0 - not the row count. + check("SELECT (COUNT(?nope) AS ?n) WHERE { ?s ?p ?o }", 0, false); + } + + @Test + void groupByFallsBack() { + long before = AggregateCountFastPath.HITS.get(); + int rows = 0; + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create( + PREFIX + "SELECT ?s (COUNT(?o) AS ?c) WHERE { ?s ?p ?o } GROUP BY ?s")).build()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) { rs.next(); rows++; } + } + assertEquals(3, rows); + assertEquals(before, AggregateCountFastPath.HITS.get()); + } + + @Test + void modelSizeUsesIndexCount() { + assertEquals(4, ds.getDefaultModel().size()); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/DistinctCorrectnessTest.java b/src/test/java/com/ebremer/beakgraph/DistinctCorrectnessTest.java index 80143943..403d2815 100644 --- a/src/test/java/com/ebremer/beakgraph/DistinctCorrectnessTest.java +++ b/src/test/java/com/ebremer/beakgraph/DistinctCorrectnessTest.java @@ -51,7 +51,7 @@ static void buildAndOpen() throws Exception { File ttl = dir.resolve("distinct.ttl").toFile(); File h5 = dir.resolve("distinct.ttl.h5").toFile(); Files.write(ttl.toPath(), TTL.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder() + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT) .setSource(ttl).setDestination(h5) .setSpatial(false).setFeatures(false) .build().write(); diff --git a/src/test/java/com/ebremer/beakgraph/DistinctTermFastPathTest.java b/src/test/java/com/ebremer/beakgraph/DistinctTermFastPathTest.java new file mode 100644 index 00000000..8676ce47 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/DistinctTermFastPathTest.java @@ -0,0 +1,217 @@ +package com.ebremer.beakgraph; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.jena.DistinctTermFastPath; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.sparql.core.Quad; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@code SELECT DISTINCT ?p} (GPOS predicate level) and {@code SELECT DISTINCT ?s} + * (GSPO subject level, streamed) answered by {@link DistinctTermFastPath} must + * return exactly what a full scan returns - notably NOT over-reporting terms that + * exist only in other named graphs (the flaw that got the previous DISTINCT + * optimization removed) - and every shape outside the fast path's guards must + * fall back to normal execution with unchanged results. HITS distinguishes the + * two paths. + */ +class DistinctTermFastPathTest { + + private static final String NS = "http://ex.org/"; + private static final String PREFIX = "PREFIX ex: <" + NS + ">\n"; + + @TempDir + static Path dir; + static BeakGraph bg; + static Dataset ds; + + @BeforeAll + static void build() throws Exception { + String trig = """ + @prefix ex: . + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:p1 "lit" . + ex:s1 ex:shared ex:s0 . + ex:s2 ex:refl ex:s2 . + ex:g1 { ex:a ex:p2 ex:b . ex:a ex:shared ex:c . } + ex:g2 { ex:d ex:p3 "x" . } + """; + File src = dir.resolve("dt.trig").toFile(); + File h5 = dir.resolve("dt.trig.h5").toFile(); + Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); + HDF5Writer.Builder().setSource(src).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); + bg = new BeakGraph(new HDF5Reader(h5)); + ds = bg.getDataset(); + } + + @AfterAll + static void close() { + if (bg != null) bg.close(); + } + + /** Runs the query and collects the single projected variable's term strings. */ + private static Set terms(String queryBody) { + Set out = new HashSet<>(); + try (QueryExecution qe = QueryExecution.dataset(ds) + .query(QueryFactory.create(PREFIX + queryBody)).build()) { + ResultSet rs = qe.execSelect(); + String var = rs.getResultVars().get(0); + while (rs.hasNext()) { + QuerySolution row = rs.next(); + out.add(row.get(var).toString()); + } + } + return out; + } + + private static Set ex(String... locals) { + Set s = new HashSet<>(); + for (String l : locals) s.add(NS + l); + return s; + } + + /** Asserts the query returns {@code expected} AND whether the fast path answered it. */ + private static void check(String queryBody, Set expected, boolean expectFastPath) { + long before = DistinctTermFastPath.HITS.get(); + assertEquals(expected, terms(queryBody), "results for: " + queryBody); + long used = DistinctTermFastPath.HITS.get() - before; + assertEquals(expectFastPath ? 1 : 0, used, "fast-path activations for: " + queryBody); + } + + // ---------------- DISTINCT ?p (GPOS predicate level) ---------------- + + @Test + void predicatesDefaultGraphAnswersFromIndexWithoutOverReporting() { + // p2/p3 exist only in named graphs - the old unsound fast path leaked them. + check("SELECT DISTINCT ?p WHERE { ?s ?p ?o }", + ex("p0", "p1", "shared", "refl"), true); + } + + @Test + void predicatesConcreteNamedGraphAnswersFromIndex() { + check("SELECT DISTINCT ?p WHERE { GRAPH ex:g1 { ?s ?p ?o } }", + ex("p2", "shared"), true); + } + + @Test + void predicatesUnionGraphAnswersFromIndexAcrossNamedGraphsOnly() { + check("SELECT DISTINCT ?p WHERE { GRAPH <" + Quad.unionGraph.getURI() + "> { ?s ?p ?o } }", + ex("p2", "p3", "shared"), true); + } + + @Test + void predicatesAbsentGraphAnswersEmpty() { + check("SELECT DISTINCT ?p WHERE { GRAPH ex:nope { ?s ?p ?o } }", + Set.of(), true); + } + + // ---------------- DISTINCT ?s (GSPO subject level, streamed) ---------------- + + @Test + void subjectsDefaultGraphAnswersFromIndexWithoutOverReporting() { + // a/d are subjects only in named graphs - must not leak into the default graph. + check("SELECT DISTINCT ?s WHERE { ?s ?p ?o }", + ex("s0", "s1", "s2"), true); + } + + @Test + void subjectsConcreteNamedGraphAnswersFromIndex() { + check("SELECT DISTINCT ?s WHERE { GRAPH ex:g1 { ?s ?p ?o } }", + ex("a"), true); + } + + @Test + void subjectsUnionGraphFallsBack() { + // Cross-graph dedup of subject ids is not index-answerable (yet): scan. + check("SELECT DISTINCT ?s WHERE { GRAPH <" + Quad.unionGraph.getURI() + "> { ?s ?p ?o } }", + ex("a", "d"), false); + } + + @Test + void subjectsAbsentGraphAnswersEmpty() { + check("SELECT DISTINCT ?s WHERE { GRAPH ex:nope { ?s ?p ?o } }", + Set.of(), true); + } + + @Test + void subjectsNonGraphEntityAnswersEmpty() { + // ex:o0 is an entity but not a graph: its first-level slot is a padding + // block, which must read as empty - not as a neighbor's subjects. + check("SELECT DISTINCT ?s WHERE { GRAPH ex:o0 { ?s ?p ?o } }", + Set.of(), true); + } + + @Test + void subjectsStreamUnderLimit() { + long before = DistinctTermFastPath.HITS.get(); + Set got = terms("SELECT DISTINCT ?s WHERE { ?s ?p ?o } LIMIT 2"); + assertEquals(2, got.size()); + assertTrue(ex("s0", "s1", "s2").containsAll(got), "LIMIT rows must be real subjects: " + got); + assertEquals(1, DistinctTermFastPath.HITS.get() - before); + } + + // ---------------- fallback shapes (unchanged results, no fast path) ---------------- + + @Test + void distinctObjectsFallsBack() { + check("SELECT DISTINCT ?o WHERE { ?s ?p ?o }", + Set.of(NS + "o0", "lit", NS + "s0", NS + "s2"), false); + } + + @Test + void filterShapeFallsBackAndRespectsFilter() { + check("SELECT DISTINCT ?p WHERE { ?s ?p ?o FILTER(isLiteral(?o)) }", + ex("p1"), false); + check("SELECT DISTINCT ?s WHERE { ?s ?p ?o FILTER(isLiteral(?o)) }", + ex("s0"), false); + } + + @Test + void concreteTermFallsBack() { + check("SELECT DISTINCT ?p WHERE { ex:s0 ?p ?o }", ex("p0", "p1"), false); + check("SELECT DISTINCT ?s WHERE { ?s ex:shared ?o }", ex("s1"), false); + } + + @Test + void repeatedVariableFallsBack() { + check("SELECT DISTINCT ?p WHERE { ?s ?p ?s }", ex("refl"), false); + check("SELECT DISTINCT ?s WHERE { ?s ?p ?s }", ex("s2"), false); + } + + @Test + void valuesBoundVariableFallsBack() { + check("SELECT DISTINCT ?p WHERE { VALUES ?s { ex:s0 } ?s ?p ?o }", + ex("p0", "p1"), false); + } + + @Test + void graphVariableFallsBack() { + check("SELECT DISTINCT ?p WHERE { GRAPH ?g { ?s ?p ?o } }", + ex("p2", "p3", "shared"), false); + check("SELECT DISTINCT ?s WHERE { GRAPH ?g { ?s ?p ?o } }", + ex("a", "d"), false); + } + + @Test + void multiPatternFallsBack() { + check("SELECT DISTINCT ?p WHERE { ?s ?p ?o . ?s ex:p1 ?lit }", + ex("p0", "p1"), false); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/DualRoleUriTest.java b/src/test/java/com/ebremer/beakgraph/DualRoleUriTest.java index 5c01da3b..00b7468c 100644 --- a/src/test/java/com/ebremer/beakgraph/DualRoleUriTest.java +++ b/src/test/java/com/ebremer/beakgraph/DualRoleUriTest.java @@ -113,13 +113,13 @@ void forwardNodeIdMappingIsStableForDualRoleUri() throws Exception { NodeTable nt = reader.getNodeTable(); Node knows = NodeFactory.createURI("http://ex.org/knows"); - NodeId first = nt.getNodeIdForNode(knows); + long first = nt.getNodeIdForNode(knows); // Reconstruct ex:knows in its entity (subject) role via the reverse mapping. long entityId = reader.getDictionary().getSubjects().locate(knows); - nt.getNodeForNodeId(new NodeId(entityId, NodeType.SUBJECT)); + nt.getNodeForNodeId(NodeId.pack(NodeType.SUBJECT, entityId)); - NodeId second = nt.getNodeIdForNode(knows); + long second = nt.getNodeIdForNode(knows); assertEquals(first, second, "dual-role URI must keep a stable Node -> NodeId mapping"); } } diff --git a/src/test/java/com/ebremer/beakgraph/HDTBitmapDirectorySelectTest.java b/src/test/java/com/ebremer/beakgraph/HDTBitmapDirectorySelectTest.java new file mode 100644 index 00000000..a4daffc3 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/HDTBitmapDirectorySelectTest.java @@ -0,0 +1,142 @@ +package com.ebremer.beakgraph; + +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.utils.HDTBitmapDirectory; +import static com.ebremer.beakgraph.Params.BLOCKSIZE; +import static com.ebremer.beakgraph.Params.SUPERBLOCKSIZE; +import java.nio.file.Path; +import java.util.SplittableRandom; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The accelerated select1 (interpolated probe + gallop + closing binary search) + * must agree with the buffer's linear select1 for EVERY rank, on distributions + * chosen to break interpolation: uniform, sparse, all-ones, long zero runs with + * ones clustered at one end, and padding-like alternating dense/sparse runs + * (an index bitmap's real shape: single-entry padding blocks next to huge data + * blocks). Directory arrays are built here exactly to the layout the reader + * assumes - SB[k] = ones before superblock k, BB[j] = ones before block j + * within its superblock. + */ +class HDTBitmapDirectorySelectTest { + + private static HDTBitmapDirectory directory(boolean[] bits) { + BitPackedUnSignedLongBuffer bitmap = new BitPackedUnSignedLongBuffer(Path.of("b"), null, 0, 1); + for (boolean b : bits) bitmap.writeLong(b ? 1 : 0); + bitmap.prepareForReading(); + + int superblocks = (bits.length + SUPERBLOCKSIZE - 1) / SUPERBLOCKSIZE; + int blocks = (bits.length + BLOCKSIZE - 1) / BLOCKSIZE; + + BitPackedUnSignedLongBuffer sb = new BitPackedUnSignedLongBuffer(Path.of("sb"), null, 0, 32); + long ones = 0; + for (int k = 0; k < superblocks; k++) { + sb.writeLong(ones); // ones before superblock k + for (int i = k * SUPERBLOCKSIZE; i < Math.min((k + 1) * SUPERBLOCKSIZE, bits.length); i++) { + if (bits[i]) ones++; + } + } + sb.prepareForReading(); + + BitPackedUnSignedLongBuffer bb = new BitPackedUnSignedLongBuffer(Path.of("bb"), null, 0, 16); + for (int j = 0; j < blocks; j++) { + int sbStart = (j * BLOCKSIZE / SUPERBLOCKSIZE) * SUPERBLOCKSIZE; + long inSuper = 0; + for (int i = sbStart; i < j * BLOCKSIZE; i++) { + if (bits[i]) inSuper++; + } + bb.writeLong(inSuper); // ones before block j, within its superblock + } + bb.prepareForReading(); + + // The ids buffer is not consulted by select1; any 1-bit buffer of equal length works. + return new HDTBitmapDirectory(sb, bb, bitmap, bitmap); + } + + private static void assertAgreesForEveryRank(boolean[] bits, String label) { + BitPackedUnSignedLongBuffer bitmap = new BitPackedUnSignedLongBuffer(Path.of("lin"), null, 0, 1); + long total = 0; + for (boolean b : bits) { + bitmap.writeLong(b ? 1 : 0); + if (b) total++; + } + bitmap.prepareForReading(); + HDTBitmapDirectory dir = directory(bits); + for (long rank = 1; rank <= total + 2; rank++) { + assertEquals(bitmap.select1(rank), dir.select1(rank), + label + ": select1 mismatch at rank " + rank + " of " + total); + } + assertEquals(-1, dir.select1(0), label + ": rank 0 must be invalid"); + } + + /** ~23 superblocks so the interpolation path (not just the binary fallback) runs. */ + private static final int N = 12_000; + + @Test + void uniformDensity() { + SplittableRandom rnd = new SplittableRandom(7); + boolean[] bits = new boolean[N]; + for (int i = 0; i < N; i++) bits[i] = rnd.nextInt(2) == 0; + assertAgreesForEveryRank(bits, "uniform"); + } + + @Test + void sparse() { + SplittableRandom rnd = new SplittableRandom(11); + boolean[] bits = new boolean[N]; + for (int i = 0; i < N; i++) bits[i] = rnd.nextInt(50) == 0; + assertAgreesForEveryRank(bits, "sparse"); + } + + @Test + void allOnes() { + boolean[] bits = new boolean[N]; + java.util.Arrays.fill(bits, true); + assertAgreesForEveryRank(bits, "allOnes"); + } + + @Test + void onesClusteredAtEnd() { + boolean[] bits = new boolean[N]; + for (int i = (int) (N * 0.9); i < N; i++) bits[i] = true; + assertAgreesForEveryRank(bits, "clusteredEnd"); + } + + @Test + void onesClusteredAtStart() { + boolean[] bits = new boolean[N]; + for (int i = 0; i < N / 10; i++) bits[i] = true; + assertAgreesForEveryRank(bits, "clusteredStart"); + } + + @Test + void paddingLikeAlternatingRuns() { + // Alternating all-ones runs (padding slots: every entry starts a block) and + // long sparse runs (one giant data block) - the worst shape for interpolation. + boolean[] bits = new boolean[N]; + for (int i = 0; i < N; i++) { + int phase = (i / 700) % 2; + bits[i] = (phase == 0) || (i % 613 == 0); + } + assertAgreesForEveryRank(bits, "paddingLike"); + } + + @Test + void smallDirectoryUsesBinaryPath() { + SplittableRandom rnd = new SplittableRandom(3); + boolean[] bits = new boolean[700]; // 2 superblocks: below the interpolation threshold + for (int i = 0; i < bits.length; i++) bits[i] = rnd.nextInt(3) == 0; + assertAgreesForEveryRank(bits, "small"); + } + + @Test + void exactSuperblockBoundaries() { + for (int n : new int[]{SUPERBLOCKSIZE * 12, SUPERBLOCKSIZE * 12 + 1, SUPERBLOCKSIZE * 12 - 1}) { + SplittableRandom rnd = new SplittableRandom(n); + boolean[] bits = new boolean[n]; + for (int i = 0; i < n; i++) bits[i] = rnd.nextInt(4) == 0; + assertAgreesForEveryRank(bits, "boundary n=" + n); + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/HighSeverityFixesRoundTripTest.java b/src/test/java/com/ebremer/beakgraph/HighSeverityFixesRoundTripTest.java index 38065236..3dfa505f 100644 --- a/src/test/java/com/ebremer/beakgraph/HighSeverityFixesRoundTripTest.java +++ b/src/test/java/com/ebremer/beakgraph/HighSeverityFixesRoundTripTest.java @@ -82,7 +82,7 @@ static void buildAndOpen() throws Exception { File ttl = dir.resolve("highsev.ttl").toFile(); File h5 = dir.resolve("highsev.ttl.h5").toFile(); Files.write(ttl.toPath(), TTL.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder() + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT) .setSource(ttl).setDestination(h5) .setSpatial(false).setFeatures(false) .build().write(); diff --git a/src/test/java/com/ebremer/beakgraph/HttpChannelReadTest.java b/src/test/java/com/ebremer/beakgraph/HttpChannelReadTest.java new file mode 100644 index 00000000..cfe3bc4c --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/HttpChannelReadTest.java @@ -0,0 +1,363 @@ +package com.ebremer.beakgraph; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.core.HTTPSeekableByteChannel; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import io.jhdf.exceptions.HdfException; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.NonWritableChannelException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.jena.graph.Triple; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.ResultSet; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end coverage for reading a BeakGraph over HTTP range requests: + * {@link HTTPSeekableByteChannel} semantics (positioning, EOF, block + * stitching, caching, read-only and closed behavior, size-probe fallback) + * and the {@code BG.getBeakGraph(SeekableByteChannel)} facade, including + * channel ownership on success and failure. The server is a minimal + * in-test loopback HTTP server that records every request, so the tests + * can assert the whole-file download never happens. + */ +class HttpChannelReadTest { + + private static final String TTL = """ + @prefix ex: . + @prefix xsd: . + ex:s1 ex:p ex:o1 . + ex:s1 ex:p "plain" . + ex:s2 ex:p "hello"@en . + ex:s2 ex:q "42"^^xsd:integer . + ex:s3 ex:p ex:o2 . + """; + + @TempDir + static Path dir; + static File h5; + static byte[] h5Bytes; + + @BeforeAll + static void build() throws Exception { + File ttl = dir.resolve("remote.ttl").toFile(); + h5 = dir.resolve("remote.ttl.h5").toFile(); + Files.write(ttl.toPath(), TTL.getBytes(StandardCharsets.UTF_8)); + HDF5Writer.Builder() + .setSource(ttl).setDestination(h5) + .setSpatial(false).setFeatures(false) + .build().write(); + h5Bytes = Files.readAllBytes(h5.toPath()); + } + + private static int count(Dataset ds, String query) { + try (QueryExecution qe = QueryExecution.dataset(ds) + .query(QueryFactory.create(query)).build()) { + ResultSet rs = qe.execSelect(); + int n = 0; + while (rs.hasNext()) { rs.next(); n++; } + return n; + } + } + + /** Deterministic filler that never repeats with period 256 (catches offset slips). */ + private static byte[] pattern(int length) { + byte[] data = new byte[length]; + for (int i = 0; i < length; i++) { + data[i] = (byte) (i * 31 + 7); + } + return data; + } + + // --- the whole point: query a remote BeakGraph in place ----------------- + + @Test + void remoteGraphAnswersIdenticallyToLocal() throws Exception { + Set expected; + try (BeakGraph local = BG.getBeakGraph(h5)) { + expected = new HashSet<>(local.find().toList()); + } + try (RangeServer server = new RangeServer(h5Bytes)) { + URI uri = server.uri("/remote.ttl.h5"); + HTTPSeekableByteChannel channel = new HTTPSeekableByteChannel(uri); + try (BeakGraph remote = BG.getBeakGraph(channel)) { + assertEquals(uri, remote.getURI(), "graph identity must be the remote URI"); + assertEquals(expected, new HashSet<>(remote.find().toList())); + assertEquals(expected.size(), + count(remote.getDataset(), "SELECT * WHERE { ?s ?p ?o }")); + assertEquals(2, count(remote.getDataset(), + "SELECT * WHERE { ?o }")); + List gets = server.requests.stream() + .filter(r -> r.startsWith("GET ")).toList(); + assertFalse(gets.isEmpty()); + assertTrue(gets.stream().allMatch(r -> r.contains("bytes=")), + "every GET must be a range request, got: " + gets); + } + assertFalse(channel.isOpen(), "closing the graph must close the channel it owns"); + } + } + + @Test + void nonBeakGraphContentFailsAndReleasesChannel() throws Exception { + try (RangeServer server = new RangeServer(new byte[100_000])) { // zeros: no HDF5 signature + HTTPSeekableByteChannel channel = new HTTPSeekableByteChannel(server.uri("/junk.bin")); + assertThrows(HdfException.class, () -> BG.getBeakGraph(channel)); + assertFalse(channel.isOpen(), "failed open must release the channel"); + } + } + + // --- channel semantics --------------------------------------------------- + + @Test + void readsPositionAndEofBehaveLikeAChannel() throws Exception { + byte[] data = pattern(300_000); // spans three 128 KiB blocks + try (RangeServer server = new RangeServer(data); + HTTPSeekableByteChannel ch = new HTTPSeekableByteChannel(server.uri("/pattern.bin"))) { + assertEquals(data.length, ch.size()); + assertEquals(0, ch.position()); + + // a read spanning the 128 KiB block boundary must stitch correctly + ByteBuffer bb = ByteBuffer.allocate(64); + ch.position(131_040); + assertEquals(64, ch.read(bb)); + assertArrayEquals(Arrays.copyOfRange(data, 131_040, 131_104), bb.array()); + assertEquals(131_104, ch.position()); + + // sequential scan reconstructs the resource exactly + ch.position(0); + ByteArrayOutputStream all = new ByteArrayOutputStream(); + ByteBuffer chunk = ByteBuffer.allocate(50_000); + int n; + while ((n = ch.read(chunk)) != -1) { + all.write(chunk.array(), 0, n); + chunk.clear(); + } + assertArrayEquals(data, all.toByteArray()); + long afterScan = ch.getRangeRequestCount(); + assertEquals(3, afterScan, "three blocks -> three range requests"); + + // cache: re-reading issues no further requests + ch.position(0); + chunk.clear(); + assertEquals(50_000, ch.read(chunk)); + assertEquals(afterScan, ch.getRangeRequestCount()); + + // EOF semantics, including the legal beyond-size position + ch.position(data.length); + assertEquals(-1, ch.read(ByteBuffer.allocate(8))); + ch.position(data.length + 1_000); + assertEquals(-1, ch.read(ByteBuffer.allocate(8))); + + // reader-only aspects + assertThrows(NonWritableChannelException.class, () -> ch.write(ByteBuffer.allocate(1))); + assertThrows(NonWritableChannelException.class, () -> ch.truncate(10)); + assertThrows(IllegalArgumentException.class, () -> ch.position(-1)); + } + } + + @Test + void closedChannelThrowsClosedChannelException() throws Exception { + try (RangeServer server = new RangeServer(pattern(1_000))) { + HTTPSeekableByteChannel ch = new HTTPSeekableByteChannel(server.uri("/small.bin")); + assertTrue(ch.isOpen()); + ch.close(); + assertFalse(ch.isOpen()); + assertThrows(ClosedChannelException.class, () -> ch.read(ByteBuffer.allocate(1))); + assertThrows(ClosedChannelException.class, ch::size); + assertThrows(ClosedChannelException.class, ch::position); + assertDoesNotThrow(ch::close, "close must be idempotent"); + } + } + + @Test + void headRejectingServerFallsBackToRangeProbe() throws Exception { + byte[] data = pattern(10_000); + try (RangeServer server = new RangeServer(data)) { + server.rejectHead = true; // e.g. a method-scoped presigned URL + try (HTTPSeekableByteChannel ch = new HTTPSeekableByteChannel(server.uri("/x.bin"))) { + assertEquals(data.length, ch.size()); + } + assertTrue(server.requests.stream().anyMatch(r -> r.endsWith("bytes=0-0")), + "size must have come from the range probe: " + server.requests); + } + } + + @Test + void serverWithoutRangeSupportIsRejectedNotDownloaded() throws Exception { + byte[] data = pattern(300_000); + try (RangeServer server = new RangeServer(data)) { + server.ignoreRanges = true; + try (HTTPSeekableByteChannel ch = new HTTPSeekableByteChannel(server.uri("/x.bin"))) { + IOException e = assertThrows(IOException.class, + () -> ch.read(ByteBuffer.allocate(16))); + assertTrue(e.getMessage().contains("range"), + "failure must name the missing range support: " + e.getMessage()); + } + } + } + + @Test + void nonHttpUriIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> new HTTPSeekableByteChannel(URI.create("ftp://example.org/x.h5"))); + } + + // --- minimal loopback HTTP server with real HEAD/Range semantics -------- + + /** + * One-request-per-connection HTTP server over a raw socket: full control + * over HEAD and Range behavior (the JDK's HttpServer manages those + * headers itself), plus a request log for assertions. Toggles: + * {@link #rejectHead} answers HEAD with 405, {@link #ignoreRanges} + * answers ranged GETs with the full resource (a server without range + * support). + */ + static final class RangeServer implements AutoCloseable { + + private static final Pattern RANGE = Pattern.compile("bytes=(\\d+)-(\\d+)"); + + private final ServerSocket server; + private final byte[] content; + final List requests = Collections.synchronizedList(new ArrayList<>()); + volatile boolean rejectHead; + volatile boolean ignoreRanges; + + RangeServer(byte[] content) throws IOException { + this.content = content; + this.server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + Thread.ofVirtual().start(this::acceptLoop); + } + + URI uri(String path) { + return URI.create("http://127.0.0.1:" + server.getLocalPort() + path); + } + + @Override + public void close() throws IOException { + server.close(); + } + + private void acceptLoop() { + while (!server.isClosed()) { + try { + Socket socket = server.accept(); + Thread.ofVirtual().start(() -> handle(socket)); + } catch (IOException closed) { + return; + } + } + } + + private void handle(Socket socket) { + try (socket) { + List head = readHead(socket.getInputStream()); + if (head == null) { + return; + } + String[] requestLine = head.get(0).split(" "); + String method = requestLine[0]; + String path = requestLine[1]; + String range = null; + for (int i = 1; i < head.size(); i++) { + int colon = head.get(i).indexOf(':'); + if (colon > 0 && head.get(i).substring(0, colon).trim().equalsIgnoreCase("Range")) { + range = head.get(i).substring(colon + 1).trim(); + } + } + requests.add(method + " " + path + (range == null ? "" : " " + range)); + OutputStream out = socket.getOutputStream(); + if (method.equals("HEAD")) { + if (rejectHead) { + writeHead(out, "405 Method Not Allowed", 0, null); + } else { + writeHead(out, "200 OK", content.length, null); + } + return; + } + if (range != null && !ignoreRanges) { + Matcher m = RANGE.matcher(range); + if (m.matches()) { + long from = Long.parseLong(m.group(1)); + long to = Math.min(Long.parseLong(m.group(2)), content.length - 1L); + if (from <= to && from < content.length) { + int len = (int) (to - from + 1); + writeHead(out, "206 Partial Content", len, + "bytes " + from + "-" + to + "/" + content.length); + out.write(content, (int) from, len); + out.flush(); + return; + } + } + writeHead(out, "416 Range Not Satisfiable", 0, "bytes */" + content.length); + return; + } + writeHead(out, "200 OK", content.length, null); + out.write(content); + out.flush(); + } catch (IOException clientWentAway) { + // normal when the client aborts a body it does not want + } + } + + /** Reads request line + headers (through the blank line); null on EOF. */ + private static List readHead(InputStream in) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + int a = 0, b = 0, c = 0, d; + while ((d = in.read()) != -1) { + buf.write(d); + if (a == '\r' && b == '\n' && c == '\r' && d == '\n') { + String head = buf.toString(StandardCharsets.ISO_8859_1); + return Arrays.stream(head.split("\r\n")).filter(s -> !s.isEmpty()).toList(); + } + a = b; + b = c; + c = d; + } + return null; + } + + private static void writeHead(OutputStream out, String status, long contentLength, + String contentRange) throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("HTTP/1.1 ").append(status).append("\r\n"); + sb.append("Content-Length: ").append(contentLength).append("\r\n"); + sb.append("Accept-Ranges: bytes\r\n"); + if (contentRange != null) { + sb.append("Content-Range: ").append(contentRange).append("\r\n"); + } + sb.append("Connection: close\r\n\r\n"); + out.write(sb.toString().getBytes(StandardCharsets.ISO_8859_1)); + out.flush(); + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/IndexExportTest.java b/src/test/java/com/ebremer/beakgraph/IndexExportTest.java new file mode 100644 index 00000000..233f0bce --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/IndexExportTest.java @@ -0,0 +1,172 @@ +package com.ebremer.beakgraph; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.core.VoidMode; +import com.ebremer.beakgraph.hdf5.jena.IndexExport; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.DatasetFactory; +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFDataMgr; +import org.apache.jena.riot.RDFFormat; +import org.apache.jena.riot.system.StreamRDF; +import org.apache.jena.riot.system.StreamRDFWriter; +import org.apache.jena.sparql.core.DatasetGraph; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The index-walking export (IndexExport) must produce EXACTLY the generic + * StreamRDF writer's output - compared as line sets, since both emit one + * self-contained NT/NQ line per quad and only ordering may differ - across + * every term shape the dictionary stores: IRIs (incl. non-ASCII), plain / + * lang-tagged / typed literals, numerics of each width, escaping-heavy + * strings, a long zstd-compressed literal, and blank nodes on both ends. + * The store is built with VoID statistics so an internal metadata graph + * exists and must be excluded, exactly as the generic path excludes it. + */ +class IndexExportTest { + + @TempDir + static Path dir; + static BeakGraph bg; + static DatasetGraph dsg; + static Dataset truth; + + @BeforeAll + static void build() throws Exception { + String trig = """ + @prefix ex: . + @prefix xsd: . + ex:s1 ex:name "plain" . + ex:s1 ex:langy "hello"@en . + ex:s1 ex:langy "hallo"@de . + ex:s1 ex:int "5"^^xsd:int . + ex:s1 ex:num 42 . + ex:s1 ex:dbl "4.25"^^xsd:double . + ex:s1 ex:flt "1.5"^^xsd:float . + ex:s1 ex:lng "123456789012"^^xsd:long . + ex:s1 ex:esc "line\\nbreak \\"quoted\\" tab\\t backslash \\\\ end" . + ex:s1 ex:big "%s" . + ex:s2 ex:p ex:s1 . + _:b1 ex:p ex:s1 . + ex:s1 ex:ref _:b1 . + ex:s1 ex:uni . + ex:g1 { ex:a ex:p2 ex:b . ex:a ex:shared "in-g1" . } + ex:g2 { ex:d ex:p3 "x"@fr . _:b2 ex:p3 _:b2 . } + """.formatted("x".repeat(300)); + File src = dir.resolve("ie.trig").toFile(); + File h5 = dir.resolve("ie.trig.h5").toFile(); + Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); + HDF5Writer.Builder().setSource(src).setDestination(h5) + .setVoidMode(VoidMode.EXACT) // forces an internal urn:x-beakgraph:void graph + .setSpatial(false).setFeatures(false).build().write(); + bg = new BeakGraph(new HDF5Reader(h5)); + dsg = bg.getDataset().asDatasetGraph(); + truth = RDFDataMgr.loadDataset(src.getAbsolutePath()); + } + + @AfterAll + static void close() { + if (bg != null) bg.close(); + } + + private static List lines(byte[] bytes) { + String[] arr = new String(bytes, StandardCharsets.UTF_8).split("\n"); + Arrays.sort(arr); + return List.of(arr); + } + + private static byte[] fast(boolean quads) throws Exception { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + long before = IndexExport.HITS.get(); + assertTrue(IndexExport.tryWrite((HDF5Reader) bg.getReader(), os, quads), + "store must qualify for the index export"); + assertEquals(1, IndexExport.HITS.get() - before); + return os.toByteArray(); + } + + /** The generic writer's output, exactly as BeakGraphCLI.writeExport produces it. */ + private static byte[] slow(boolean quads) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + StreamRDF stream = StreamRDFWriter.getWriterStream(os, quads ? RDFFormat.NQUADS : RDFFormat.NTRIPLES); + stream.start(); + if (quads) { + var it = dsg.find(); + while (it.hasNext()) { + var q = it.next(); + if (!Params.BGVOID.equals(q.getGraph()) && !Params.SPATIAL.equals(q.getGraph())) { + stream.quad(q); + } + } + } else { + var it = dsg.getDefaultGraph().find(); + while (it.hasNext()) { + stream.triple(it.next()); + } + } + stream.finish(); + return os.toByteArray(); + } + + @Test + void nqMatchesGenericWriterByteForByte() throws Exception { + assertEquals(lines(slow(true)), lines(fast(true)), + "index NQ export must emit exactly the generic writer's lines"); + } + + @Test + void ntMatchesGenericWriterByteForByte() throws Exception { + assertEquals(lines(slow(false)), lines(fast(false)), + "index NT export must emit exactly the generic writer's lines"); + } + + @Test + void nqRoundTripsIsomorphically() throws Exception { + Dataset back = DatasetFactory.create(); + RDFDataMgr.read(back, new ByteArrayInputStream(fast(true)), Lang.NQUADS); + assertTrue(back.getDefaultModel().isIsomorphicWith(truth.getDefaultModel()), + "default graph must round-trip"); + for (String g : new String[]{"http://ex.org/g1", "http://ex.org/g2"}) { + assertTrue(back.getNamedModel(g).isIsomorphicWith(truth.getNamedModel(g)), + "named graph must round-trip: " + g); + } + } + + @Test + void internalMetadataGraphsAreExcluded() throws Exception { + String out = new String(fast(true), StandardCharsets.UTF_8); + assertFalse(out.contains("urn:x-beakgraph"), + "VoID/spatial metadata must not leak into the export"); + // control: the store really does carry the VoID graph + assertTrue(dsg.containsGraph(Params.BGVOID), "control: the VoID graph must exist in the store"); + } + + @Test + void disabledPropertyFallsBackToGenericWriter() throws Exception { + String old = System.setProperty("beakgraph.export.fastpath", "false"); + try { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + assertFalse(IndexExport.tryWrite((HDF5Reader) bg.getReader(), os, true), + "disabled fast path must decline"); + assertEquals(0, os.size(), "a declined fast path must write nothing"); + } finally { + if (old == null) System.clearProperty("beakgraph.export.fastpath"); + else System.setProperty("beakgraph.export.fastpath", old); + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/LargeDataSpaceTest.java b/src/test/java/com/ebremer/beakgraph/LargeDataSpaceTest.java new file mode 100644 index 00000000..40f0defb --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/LargeDataSpaceTest.java @@ -0,0 +1,86 @@ +package com.ebremer.beakgraph; + +import io.jhdf.Superblock; +import io.jhdf.object.message.DataSpace; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Guards the jHDF large-dataset capability BeakGraph depends on: dataspace dimensions + * must parse as unsigned longs so a store containing datasets with more than + * Integer.MAX_VALUE elements - routine for the native-HDF5 writers at PubMed scale + * (a 74 GB store's dictionary string buffer is ~29.6e9 bytes) - can be OPENED at all. + * jHDF fixed this in 0.12.0 (long[] dimensions); before that, object-header parsing + * threw {@code ArithmeticException: integer overflow} from group child loading, before + * the reader could reach its own FFM large-dataset path (DatasetBytes). If a future + * jhdf.version bump regresses this (or someone downgrades below 0.12.0), this test + * fails instead of every multi-GiB store becoming unopenable. + */ +class LargeDataSpaceTest { + + private static final long HUGE = 29_614_122_732L; + + /** + * A version-1 dataspace message as native HDF5 writes it for a fixed simple dataspace: + * version(1), rank(1), flags(1), 5 reserved bytes, then each dimension as an 8-byte + * little-endian length ({@code sizeOfLengths} = 8, the SuperblockV2V3 default). + */ + private static ByteBuffer v1Message(boolean withMaxSizes, long... dims) { + ByteBuffer bb = ByteBuffer.allocate(8 + dims.length * 8 * (withMaxSizes ? 2 : 1)) + .order(ByteOrder.LITTLE_ENDIAN); + bb.put((byte) 1); // version + bb.put((byte) dims.length); // rank + bb.put((byte) (withMaxSizes ? 1 : 0)); // flags: bit 0 = max sizes present + bb.put(new byte[5]); // reserved + for (long d : dims) bb.putLong(d); + if (withMaxSizes) { + for (long d : dims) bb.putLong(d); // maxdims == dims (fixed dataspace) + } + bb.flip(); + return bb; + } + + private static DataSpace parse(ByteBuffer bb) { + return DataSpace.readDataSpace(bb, new Superblock.SuperblockV2V3()); + } + + @Test + void hugeDimensionParses() { + DataSpace ds = parse(v1Message(false, HUGE)); + assertEquals(HUGE, ds.getTotalLength(), + "getTotalLength feeds Dataset.getSizeInBytes - it must carry the full long value"); + assertArrayEquals(new long[]{HUGE}, ds.getDimensionsAsLong()); + // maxSizes defaulted from the (long) dimensions, not truncated ints + assertArrayEquals(new long[]{HUGE}, ds.getMaxSizes()); + // The int[] view cannot represent it; it must fail loudly, not truncate. + assertThrows(ArithmeticException.class, ds::getDimensions); + } + + @Test + void hugeDimensionWithExplicitMaxSizesParses() { + DataSpace ds = parse(v1Message(true, HUGE)); + assertEquals(HUGE, ds.getTotalLength()); + assertArrayEquals(new long[]{HUGE}, ds.getMaxSizes()); + } + + @Test + void smallDimensionsBehaveAsBefore() { + DataSpace ds = parse(v1Message(false, 1024, 3)); + assertEquals(3072, ds.getTotalLength()); + assertArrayEquals(new int[]{1024, 3}, ds.getDimensions()); + assertArrayEquals(new long[]{1024, 3}, ds.getDimensionsAsLong()); + assertArrayEquals(new long[]{1024, 3}, ds.getMaxSizes()); + } + + @Test + void scalarDataspaceStillParses() { + // rank 0: no dimension entries at all + DataSpace ds = parse(v1Message(false)); + assertEquals(1, ds.getTotalLength(), "empty product = 1 (scalar), matching upstream"); + assertArrayEquals(new int[0], ds.getDimensions()); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/ParallelScanTest.java b/src/test/java/com/ebremer/beakgraph/ParallelScanTest.java new file mode 100644 index 00000000..60bf4a06 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/ParallelScanTest.java @@ -0,0 +1,196 @@ +package com.ebremer.beakgraph; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.jena.ParallelScan; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.riot.RDFDataMgr; +import org.apache.jena.sparql.core.Quad; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Chunked parallel scans (ScanChunks + ParallelScan) must return exactly the + * sequential results - rows arrive interleaved across chunks, so comparisons + * are order-insensitive (index rows are distinct, so set+size equality is a + * multiset check) - and workers must stop when the consumer stops (LIMIT). + * The activation threshold is lowered for this class so a small store chunks. + */ +class ParallelScanTest { + + private static final String NS = "http://ex.org/"; + private static final String PREFIX = "PREFIX ex: <" + NS + ">\n"; + private static final int SUBJECTS = 3000; + + @TempDir + static Path dir; + static BeakGraph bg; + static Dataset ds; + static Dataset truth; + static String oldThreshold; + + @BeforeAll + static void build() throws Exception { + oldThreshold = System.setProperty("beakgraph.scan.parallel.threshold", "64"); + StringBuilder trig = new StringBuilder("@prefix ex: <" + NS + "> .\n"); + for (int i = 0; i < SUBJECTS; i++) { + trig.append("ex:s").append(i) + .append(" ex:value ").append(i % 500) + .append(" ; ex:name \"n").append(i) + .append("\" ; ex:link ex:s").append((i * 7 + 13) % SUBJECTS) + .append(" .\n"); + } + trig.append("ex:s0 ex:refl ex:s0 .\n"); + trig.append("ex:g1 { ex:a ex:p2 ex:b . }\n"); + trig.append("ex:g2 { ex:d ex:p3 \"x\" . }\n"); + File src = dir.resolve("ps.trig").toFile(); + File h5 = dir.resolve("ps.trig.h5").toFile(); + Files.write(src.toPath(), trig.toString().getBytes(StandardCharsets.UTF_8)); + HDF5Writer.Builder().setSource(src).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); + bg = new BeakGraph(new HDF5Reader(h5)); + ds = bg.getDataset(); + truth = RDFDataMgr.loadDataset(src.getAbsolutePath()); + } + + @AfterAll + static void close() { + if (oldThreshold == null) { + System.clearProperty("beakgraph.scan.parallel.threshold"); + } else { + System.setProperty("beakgraph.scan.parallel.threshold", oldThreshold); + } + if (bg != null) bg.close(); + } + + /** All rows as var=term strings - order-insensitive, distinct by construction. */ + private static Set rows(Dataset dataset, String queryBody) { + Set out = new HashSet<>(); + try (QueryExecution qe = QueryExecution.dataset(dataset) + .query(QueryFactory.create(PREFIX + queryBody)).build()) { + ResultSet rs = qe.execSelect(); + List vars = rs.getResultVars(); + long n = 0; + while (rs.hasNext()) { + QuerySolution row = rs.next(); + StringBuilder sb = new StringBuilder(); + for (String v : vars) { + sb.append(v).append('=').append(row.get(v)).append('|'); + } + out.add(sb.toString()); + n++; + } + assertEquals(n, out.size(), "scan rows must be distinct"); + } + return out; + } + + /** Runs on BeakGraph and the in-memory truth dataset; asserts identical rows and the expected path. */ + private static void check(String queryBody, boolean expectParallel) { + long before = ParallelScan.HITS.get(); + Set got = rows(ds, queryBody); + long used = ParallelScan.HITS.get() - before; + assertEquals(rows(truth, queryBody), got, "results for: " + queryBody); + if (expectParallel) { + assertTrue(used >= 1, "expected a parallel scan for: " + queryBody); + } else { + assertEquals(0, used, "expected sequential execution for: " + queryBody); + } + } + + private static void awaitWorkersDone() throws InterruptedException { + long deadline = System.currentTimeMillis() + 10_000; + while (ParallelScan.ACTIVE_WORKERS.get() != 0) { + if (System.currentTimeMillis() > deadline) { + assertEquals(0, ParallelScan.ACTIVE_WORKERS.get(), "scan workers must stop"); + } + Thread.sleep(20); + } + } + + @Test + void fullScanMatchesSequential() throws Exception { + check("SELECT ?s ?p ?o WHERE { ?s ?p ?o }", true); + awaitWorkersDone(); + } + + @Test + void predicateScanMatchesSequential() throws Exception { + check("SELECT ?s ?v WHERE { ?s ex:value ?v }", true); + awaitWorkersDone(); + } + + @Test + void rangeFilterMatchesSequential() throws Exception { + check("SELECT ?s ?v WHERE { ?s ex:value ?v FILTER(?v >= 400) }", true); + awaitWorkersDone(); + } + + @Test + void joinWithScanFirstMatchesSequential() throws Exception { + check("SELECT ?s ?v ?n WHERE { ?s ex:value ?v . ?s ex:name ?n }", true); + awaitWorkersDone(); + } + + @Test + void limitClosesScanAndStopsWorkers() throws Exception { + long before = ParallelScan.HITS.get(); + Set got = rows(ds, "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 3"); + assertEquals(3, got.size()); + assertTrue(ParallelScan.HITS.get() - before >= 1, "LIMIT scan should still parallelize"); + // The close cascade (slice -> ... -> ParallelScan.close) must stop the + // producers even though the scan was abandoned almost immediately. + awaitWorkersDone(); + } + + @Test + void concreteSubjectFallsBack() { + check("SELECT ?p ?o WHERE { ex:s5 ?p ?o }", false); + } + + @Test + void repeatedVariableFallsBack() { + check("SELECT ?s WHERE { ?s ex:refl ?s }", false); + check("SELECT ?s ?p WHERE { ?s ?p ?s }", false); + } + + @Test + void unionGraphFallsBack() { + check("SELECT ?s ?p ?o WHERE { GRAPH <" + Quad.unionGraph.getURI() + "> { ?s ?p ?o } }", false); + } + + @Test + void belowThresholdStaysSequential() { + String old = System.setProperty("beakgraph.scan.parallel.threshold", "1000000"); + try { + check("SELECT ?s ?p ?o WHERE { ?s ?p ?o }", false); + } finally { + System.setProperty("beakgraph.scan.parallel.threshold", old); + } + } + + @Test + void disabledStaysSequential() { + String old = System.setProperty("beakgraph.scan.parallel.threshold", "0"); + try { + check("SELECT ?s ?v WHERE { ?s ex:value ?v }", false); + } finally { + System.setProperty("beakgraph.scan.parallel.threshold", old); + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/QuadFormatIngestionTest.java b/src/test/java/com/ebremer/beakgraph/QuadFormatIngestionTest.java index 8c93f58d..9ebd6b1d 100644 --- a/src/test/java/com/ebremer/beakgraph/QuadFormatIngestionTest.java +++ b/src/test/java/com/ebremer/beakgraph/QuadFormatIngestionTest.java @@ -51,7 +51,7 @@ void trigSourceWithNamedGraphRoundTrips() throws Exception { File src = dir.resolve("data.trig").toFile(); File h5 = dir.resolve("data.trig.h5").toFile(); Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(src).setDestination(h5) + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(src).setDestination(h5) .setSpatial(false).setFeatures(false).build().write(); try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { Dataset ds = bg.getDataset(); @@ -73,7 +73,7 @@ void nquadsSourceRoundTrips() throws Exception { File src = dir.resolve("data.nq").toFile(); File h5 = dir.resolve("data.nq.h5").toFile(); Files.write(src.toPath(), nq.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(src).setDestination(h5) + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(src).setDestination(h5) .setSpatial(false).setFeatures(false).build().write(); try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { Dataset ds = bg.getDataset(); @@ -87,7 +87,7 @@ void emptySourceProducesConsistentFile() throws Exception { File src = dir.resolve("empty.ttl").toFile(); File h5 = dir.resolve("empty.ttl.h5").toFile(); Files.write(src.toPath(), "# nothing here\n".getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(src).setDestination(h5) + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(src).setDestination(h5) .setSpatial(false).setFeatures(false).build().write(); try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { Dataset ds = bg.getDataset(); diff --git a/src/test/java/com/ebremer/beakgraph/ReorderQueryTest.java b/src/test/java/com/ebremer/beakgraph/ReorderQueryTest.java index 46b7b9ab..3f8eb207 100644 --- a/src/test/java/com/ebremer/beakgraph/ReorderQueryTest.java +++ b/src/test/java/com/ebremer/beakgraph/ReorderQueryTest.java @@ -59,7 +59,7 @@ static void build() throws Exception { try (OutputStream out = Files.newOutputStream(ttl.toPath())) { RDFDataMgr.write(out, m, Lang.TURTLE); } - HDF5Writer.Builder().setSource(ttl).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(ttl).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); bg = new BeakGraph(new HDF5Reader(h5), h5.toURI()); ds = bg.getDataset(); } diff --git a/src/test/java/com/ebremer/beakgraph/TemporalTotalOrderTest.java b/src/test/java/com/ebremer/beakgraph/TemporalTotalOrderTest.java index 7e3933eb..d50c8cb8 100644 --- a/src/test/java/com/ebremer/beakgraph/TemporalTotalOrderTest.java +++ b/src/test/java/com/ebremer/beakgraph/TemporalTotalOrderTest.java @@ -91,7 +91,9 @@ void comparatorIsTotalOverMixedLiteralSpaces() { nodes.add(NodeFactory.createLiteralDT("42", XSDDatatype.XSDinteger)); nodes.add(NodeFactory.createLiteralDT("41.5", XSDDatatype.XSDdouble)); nodes.add(NodeFactory.createLiteralDT("not-a-date", XSDDatatype.XSDdateTime)); // ill-formed - nodes.add(NodeFactory.createLiteral("plain")); + // Jena 6 removed createLiteral(String); createLiteralString is the + // equivalent (a plain literal IS an xsd:string in RDF 1.1). + nodes.add(NodeFactory.createLiteralString("plain")); assertTotallyOrdered(nodes); diff --git a/src/test/java/com/ebremer/beakgraph/UnionGraphAndDetachTest.java b/src/test/java/com/ebremer/beakgraph/UnionGraphAndDetachTest.java index bd24fcf6..ba45a440 100644 --- a/src/test/java/com/ebremer/beakgraph/UnionGraphAndDetachTest.java +++ b/src/test/java/com/ebremer/beakgraph/UnionGraphAndDetachTest.java @@ -65,7 +65,7 @@ static void buildAndOpen() throws Exception { File ttl = dir.resolve("union.ttl").toFile(); File h5 = dir.resolve("union.ttl.h5").toFile(); Files.write(ttl.toPath(), TTL.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder() + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT) .setSource(ttl).setDestination(h5) .setSpatial(false).setFeatures(false) .build().write(); @@ -144,7 +144,7 @@ void findAnyStillIncludesDefaultGraph() { @Test void detachMaterializesNodeIdBindings() { Node s1 = NodeFactory.createURI("http://ex.org/s1"); - NodeId id = bg.getReader().getNodeTable().getNodeIdForNode(s1); + long id = bg.getReader().getNodeTable().getNodeIdForNode(s1); assertFalse(NodeId.isDoesNotExist(id), "control: ex:s1 must be in the dictionary"); Node parentTerm = NodeFactory.createURI("http://ex.org/parent"); diff --git a/src/test/java/com/ebremer/beakgraph/VariableGraphScanTest.java b/src/test/java/com/ebremer/beakgraph/VariableGraphScanTest.java index 1162e1d4..c87d195a 100644 --- a/src/test/java/com/ebremer/beakgraph/VariableGraphScanTest.java +++ b/src/test/java/com/ebremer/beakgraph/VariableGraphScanTest.java @@ -1,6 +1,7 @@ package com.ebremer.beakgraph; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -49,7 +50,7 @@ static void build() throws Exception { File t = dir.resolve("vg.ttl").toFile(); h5 = dir.resolve("vg.ttl.h5").toFile(); Files.write(t.toPath(), ttl.toString().getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(t).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(t).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); } @Test @@ -65,8 +66,8 @@ void variableGraphScanBindsGraphAndCoversExactlyTheRealGraphs() throws Exception reader, dict, new BindingNodeId(), new Quad(g, s, p, o), null, nt); while (it.hasNext()) { BindingNodeId b = it.next(); - NodeId gid = b.get(g); - assertNotNull(gid, "the graph variable must be bound in every solution"); + long gid = b.get(g); + assertNotEquals(NodeId.NONE, gid, "the graph variable must be bound in every solution"); graphsSeen.add(nt.getNodeForNodeId(gid)); count++; } @@ -94,7 +95,7 @@ void preBoundGraphVariableScansTheBoundGraph() throws Exception { long gid = dict.getGraphs().locate(Quad.defaultGraphIRI); assertTrue(gid >= 1, "the default graph must exist in the dictionary"); BindingNodeId bnid = new BindingNodeId(); - bnid.put(g, new NodeId(gid, com.ebremer.beakgraph.hdf5.jena.NodeType.GRAPH)); + bnid.put(g, NodeId.pack(com.ebremer.beakgraph.hdf5.jena.NodeType.GRAPH, gid)); long count = 0; Iterator it = new BGIteratorMaster( diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java index bf3eb119..1a04ecbc 100644 --- a/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java +++ b/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java @@ -77,6 +77,76 @@ void hugeFlagConvertsThroughDiskBasedWriter() throws Exception { } } + private void convertsWithMethod(int method, String tag) throws Exception { + Path src = Files.createDirectories(dir.resolve("src" + tag)); + Files.write(src.resolve("data.ttl"), + " .\n".getBytes(StandardCharsets.UTF_8)); + + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("out" + tag).toFile(); + p.method = method; + p.cores = 2; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.traverse(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), + "the -method " + method + " conversion must succeed"); + File h5 = dir.resolve("out" + tag).resolve("data.h5").toFile(); + assertTrue(h5.exists() && h5.length() > 0, "the -method " + method + " writer must produce the .h5 file"); + // The produced store must be readable by the standard reader stack. + try (com.ebremer.beakgraph.core.BeakGraph bg = new com.ebremer.beakgraph.core.BeakGraph( + new com.ebremer.beakgraph.hdf5.readers.HDF5Reader(h5))) { + assertTrue(bg.find(org.apache.jena.graph.NodeFactory.createURI("http://ex.org/a"), + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/p"), + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/b")).hasNext(), + "the converted triple must be queryable"); + } + } + + @Test + void methodTwoConvertsThroughParallelWriter() throws Exception { + convertsWithMethod(2, "par"); + } + + @Test + void methodThreeConvertsThroughUltraWriter() throws Exception { + convertsWithMethod(3, "ultra"); + } + + @Test + void methodAndCoresOptionsParse() { + // The exact spellings the writers are documented with: -method and -cores. + Parameters p = new Parameters(); + com.beust.jcommander.JCommander.newBuilder().addObject(p).build() + .parse("-src", "x", "-method", "3", "-cores", "6"); + assertEquals(3, p.method, "-method must set the engine"); + assertEquals(6, p.cores, "-cores must override the default"); + assertEquals(4, new Parameters().cores, "-cores must default to 4"); + assertEquals(0, new Parameters().method, "-method must default to the in-memory writer"); + org.junit.jupiter.api.Assertions.assertThrows(com.beust.jcommander.ParameterException.class, + () -> com.beust.jcommander.JCommander.newBuilder().addObject(new Parameters()).build() + .parse("-src", "x", "-method", "6"), + "-method outside 0..5 must be rejected"); + } + + @Test + void methodFiveConvertsThroughPlaidWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + convertsWithMethod(5, "plaid"); + } + + @Test + void methodFourConvertsThroughHugeUltraWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + convertsWithMethod(4, "hugeultra"); + } + @Test void missingDestinationFailsEveryFileLoudlyInsteadOfSilently() throws Exception { // main() refuses to start without -dest; if a processor is ever reached diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/ExportTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/ExportTest.java new file mode 100644 index 00000000..0b3b4b6f --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/cmdline/ExportTest.java @@ -0,0 +1,177 @@ +package com.ebremer.beakgraph.cmdline; + +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.GZIPInputStream; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.DatasetFactory; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFDataMgr; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * -export: dumping a BeakGraph back to RDF. Round-trips must reproduce the + * source data (isomorphic; BeakGraph's internal VoID/spatial metadata graphs + * are excluded), TTL/NT must auto-upgrade to TRIG/NQ when user named graphs + * exist, and -compress must gzip with the extra .gz extension. + */ +class ExportTest { + + @TempDir + Path dir; + + private static final String TRIPLES = + " .\n" + + " \"Alice\" .\n" + + " \"5\"^^ .\n"; + + private static final String QUADS = TRIPLES + + " .\n"; + + private File buildStore(String name, String nquads) throws Exception { + File src = dir.resolve(name + ".nq").toFile(); + Files.write(src.toPath(), nquads.getBytes(StandardCharsets.UTF_8)); + File h5 = dir.resolve(name + ".h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(h5).build().write(); + return h5; + } + + private void runExport(File src, String format, boolean compress) { + Parameters p = new Parameters(); + p.src = src; + p.export = format; + p.compress = compress; + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.export(); + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), + "export of " + src + " as " + format + " must succeed"); + } + + private Dataset parse(Path file, Lang lang, boolean gzipped) throws Exception { + Dataset ds = DatasetFactory.create(); + try (InputStream in = gzipped + ? new GZIPInputStream(Files.newInputStream(file)) + : Files.newInputStream(file)) { + RDFDataMgr.read(ds, in, lang); + } + return ds; + } + + private static Model expectedTriples() { + Model m = ModelFactory.createDefaultModel(); + RDFDataMgr.read(m, new java.io.ByteArrayInputStream(TRIPLES.getBytes(StandardCharsets.UTF_8)), Lang.NTRIPLES); + return m; + } + + @Test + void ntExportRoundTripsAndExcludesInternalGraphs() throws Exception { + File h5 = buildStore("plain", TRIPLES); + runExport(h5, "NT", false); + Path out = dir.resolve("plain.nt"); + assertTrue(Files.exists(out), "plain.nt must be created next to plain.h5"); + Dataset ds = parse(out, Lang.NTRIPLES, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples()), + "exported triples must round-trip the source data"); + assertFalse(ds.asDatasetGraph().listGraphNodes().hasNext(), + "VoID/spatial metadata graphs must not leak into the export"); + } + + @Test + void ntUpgradesToNqWhenNamedGraphsExist() throws Exception { + File h5 = buildStore("upg", QUADS); + runExport(h5, "NT", false); + assertFalse(Files.exists(dir.resolve("upg.nt")), "NT must not be written for a quad store"); + Path out = dir.resolve("upg.nq"); + assertTrue(Files.exists(out), "the export must upgrade NT -> NQ"); + Dataset ds = parse(out, Lang.NQUADS, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + assertTrue(ds.asDatasetGraph().containsGraph( + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/g1")), + "the named graph must survive the round trip"); + assertEquals(1, ds.getNamedModel("http://ex.org/g1").size()); + } + + @Test + void ttlUpgradesToTrigWhenNamedGraphsExist() throws Exception { + File h5 = buildStore("upgt", QUADS); + runExport(h5, "TTL", false); + assertFalse(Files.exists(dir.resolve("upgt.ttl"))); + Path out = dir.resolve("upgt.trig"); + assertTrue(Files.exists(out), "the export must upgrade TTL -> TRIG"); + Dataset ds = parse(out, Lang.TRIG, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + assertEquals(1, ds.getNamedModel("http://ex.org/g1").size()); + } + + @Test + void ttlStaysTtlForDefaultOnlyStores() throws Exception { + File h5 = buildStore("ttlonly", TRIPLES); + runExport(h5, "TTL", false); + Path out = dir.resolve("ttlonly.ttl"); + assertTrue(Files.exists(out)); + Dataset ds = parse(out, Lang.TURTLE, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + } + + @Test + void compressedExportGetsGzExtensionAndGzipContent() throws Exception { + File h5 = buildStore("gz", QUADS); + runExport(h5, "NQ", true); + Path out = dir.resolve("gz.nq.gz"); + assertTrue(Files.exists(out), "-compress must add .gz"); + Dataset ds = parse(out, Lang.NQUADS, true); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + assertEquals(1, ds.getNamedModel("http://ex.org/g1").size()); + } + + @Test + void jsonLdExportRoundTrips() throws Exception { + File h5 = buildStore("jld", QUADS); + runExport(h5, "JSON-LD", false); + Path out = dir.resolve("jld.jsonld"); + assertTrue(Files.exists(out)); + Dataset ds = parse(out, Lang.JSONLD, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + assertEquals(1, ds.getNamedModel("http://ex.org/g1").size()); + } + + @Test + void directorySourceExportsEveryStore() throws Exception { + Path sub = Files.createDirectories(dir.resolve("many")); + for (String name : new String[]{"one", "two"}) { + File src = sub.resolve(name + ".nt").toFile(); + Files.write(src.toPath(), TRIPLES.getBytes(StandardCharsets.UTF_8)); + HDF5Writer.Builder().setSource(src).setDestination(sub.resolve(name + ".h5").toFile()) + .build().write(); + } + runExport(sub.toFile(), "NT", false); + assertTrue(Files.exists(sub.resolve("one.nt"))); + assertTrue(Files.exists(sub.resolve("two.nt"))); + Dataset ds = parse(sub.resolve("two.nt"), Lang.NTRIPLES, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + } + + @Test + void exportOptionParsesAndRejectsUnknownFormats() { + Parameters p = new Parameters(); + com.beust.jcommander.JCommander.newBuilder().addObject(p).build() + .parse("-src", "x.h5", "-export", "json-ld", "-compress"); + assertEquals("json-ld", p.export); + assertTrue(p.compress); + assertEquals("JSONLD", ExportFormatValidator.normalize(p.export)); + org.junit.jupiter.api.Assertions.assertThrows(com.beust.jcommander.ParameterException.class, + () -> com.beust.jcommander.JCommander.newBuilder().addObject(new Parameters()).build() + .parse("-src", "x.h5", "-export", "RDFXML"), + "unsupported export formats must be rejected"); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java new file mode 100644 index 00000000..679d344d --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java @@ -0,0 +1,286 @@ +package com.ebremer.beakgraph.cmdline; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.utils.RdfSources; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The two source-handling features of the CLI: every supported syntax + * (Turtle, N-Triples, N-Quads, TriG, RDF/XML, JSON-LD - plain, .gz, .zip) + * converts, and -merge folds a whole source tree into ONE store with + * per-document blank-node scoping. + */ +class MergeAndFormatsTest { + + @TempDir + Path dir; + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private static void write(Path p, String content) throws Exception { + Files.write(p, content.getBytes(StandardCharsets.UTF_8)); + } + + private static void writeGz(Path p, String content) throws Exception { + try (GZIPOutputStream gz = new GZIPOutputStream(new FileOutputStream(p.toFile()))) { + gz.write(content.getBytes(StandardCharsets.UTF_8)); + } + } + + private static void writeZip(Path p, String entryName, String content) throws Exception { + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(p.toFile()))) { + zos.putNextEntry(new ZipEntry(entryName)); + zos.write(content.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + + private static boolean ask(File h5, String query) throws Exception { + try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { + try (QueryExecution qe = QueryExecution.dataset(bg.getDataset()) + .query(QueryFactory.create(query)).build()) { + return qe.execAsk(); + } + } + } + + private static long count(File h5, String selectCountQuery) throws Exception { + try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { + try (QueryExecution qe = QueryExecution.dataset(bg.getDataset()) + .query(QueryFactory.create(selectCountQuery)).build()) { + return qe.execSelect().next().getLiteral("n").getLong(); + } + } + } + + // ------------------------------------------------------------------ + // tests + // ------------------------------------------------------------------ + + @Test + void sourceFilterAcceptsExactlyTheSupportedNames() { + for (String ext : new String[]{"ttl", "nt", "nq", "trig", "rdf", "jsonld"}) { + assertTrue(RdfSources.isSupported("data." + ext), ext); + assertTrue(RdfSources.isSupported("data." + ext + ".gz"), ext + ".gz"); + assertTrue(RdfSources.isSupported("data." + ext + ".zip"), ext + ".zip"); + assertTrue(RdfSources.isSupported("DATA." + ext.toUpperCase() + ".GZ"), "case-insensitive " + ext); + } + assertFalse(RdfSources.isSupported("data.txt")); + assertFalse(RdfSources.isSupported("data.h5")); + assertFalse(RdfSources.isSupported("data.zip"), "a zip without an RDF extension is not identifiable"); + assertFalse(RdfSources.isSupported("data.gz")); + assertFalse(RdfSources.isSupported("data.owl")); + } + + @Test + void allFormatsConvertIndividually() throws Exception { + Path src = Files.createDirectories(dir.resolve("srcfmt")); + write(src.resolve("a.ttl"), " .\n"); + write(src.resolve("b.nt"), " .\n"); + write(src.resolve("c.nq"), " .\n"); + write(src.resolve("d.trig"), "@prefix ex: . ex:gtrig { ex:s ex:p ex:otrig . }\n"); + write(src.resolve("e.rdf"), """ + + + + + + + """); + write(src.resolve("f.jsonld"), """ + {"@id": "http://ex.org/s", "http://ex.org/p": {"@id": "http://ex.org/o-jsonld"}} + """); + writeGz(src.resolve("g.nt.gz"), " .\n"); + writeZip(src.resolve("h.trig.zip"), "h.trig", + " { . }\n"); + + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outfmt").toFile(); + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.traverse(); + + assertEquals(8, cli.getFileCounter().getRDFFileCount(), "every format must pass the source filter"); + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "every format must convert"); + + Path out = dir.resolve("outfmt"); + assertTrue(ask(out.resolve("a.h5").toFile(), "ASK { }")); + assertTrue(ask(out.resolve("b.h5").toFile(), "ASK { }")); + assertTrue(ask(out.resolve("c.h5").toFile(), "ASK { GRAPH { } }")); + assertTrue(ask(out.resolve("d.h5").toFile(), "ASK { GRAPH { } }")); + assertTrue(ask(out.resolve("e.h5").toFile(), "ASK { }")); + assertTrue(ask(out.resolve("f.h5").toFile(), "ASK { }")); + // .gz / .zip keep the inner extension in the mapped name (last extension is replaced) + assertTrue(ask(out.resolve("g.nt.h5").toFile(), "ASK { }")); + assertTrue(ask(out.resolve("h.trig.h5").toFile(), "ASK { GRAPH { } }")); + } + + private Path mergeSourceTree(String name) throws Exception { + Path src = Files.createDirectories(dir.resolve(name)); + write(src.resolve("m1.ttl"), " .\n"); + write(src.resolve("m2.nq"), " .\n"); + writeZip(src.resolve("m3.nt.zip"), "m3.nt", " .\n"); + // The SAME bnode label in two documents: they are distinct nodes and + // must stay distinct in the merged store. + write(src.resolve("bn1.ttl"), "_:b0 \"v1\" .\n"); + write(src.resolve("bn2.ttl"), "_:b0 \"v2\" .\n"); + return src; + } + + private void assertMerged(File h5) throws Exception { + assertTrue(h5.exists() && h5.length() > 0, "merged store must exist: " + h5); + assertTrue(ask(h5, "ASK { }"), "from m1.ttl"); + assertTrue(ask(h5, "ASK { GRAPH { } }"), "from m2.nq"); + assertTrue(ask(h5, "ASK { }"), "from m3.nt.zip"); + assertTrue(ask(h5, "ASK { ?b \"v1\" }"), "bnode statement from bn1.ttl"); + assertTrue(ask(h5, "ASK { ?b \"v2\" }"), "bnode statement from bn2.ttl"); + assertEquals(2, count(h5, "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE { ?b ?o }"), + "_:b0 from two documents must remain two distinct blank nodes"); + } + + @Test + void mergeCombinesAllSourcesIntoOneFile() throws Exception { + Path src = mergeSourceTree("srcmerge"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmerge").resolve("all.h5").toFile(); + p.merge = true; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(5, cli.getFileCounter().getRDFFileCount()); + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the merge must succeed"); + assertMerged(p.dest); + assertEquals(1, p.dest.getParentFile().listFiles().length, "exactly one output file, no per-source .h5"); + } + + @Test + void mergeWithParallelWriter() throws Exception { + Path src = mergeSourceTree("srcmergepar"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergepar").resolve("all.h5").toFile(); + p.merge = true; + p.method = 2; + p.cores = 2; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the parallel merge must succeed"); + assertMerged(p.dest); + } + + @Test + void mergeWithUltraWriter() throws Exception { + Path src = mergeSourceTree("srcmergeultra"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergeultra").resolve("all.h5").toFile(); + p.merge = true; + p.method = 3; + p.cores = 2; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the ultra merge must succeed"); + assertMerged(p.dest); + } + + @Test + void mergeWithHugeWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + Path src = mergeSourceTree("srcmergehuge"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergehuge").resolve("all.h5").toFile(); + p.merge = true; + p.huge = true; + p.workdir = dir.resolve("workmergehuge").toFile(); + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the -huge merge must succeed"); + assertMerged(p.dest); + } + + @Test + void mergeWithHugeUltraWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + Path src = mergeSourceTree("srcmergehugeultra"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergehugeultra").resolve("all.h5").toFile(); + p.merge = true; + p.method = 4; + p.cores = 3; + p.workdir = dir.resolve("workmergehugeultra").toFile(); + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the -method 4 merge must succeed"); + assertMerged(p.dest); + } + + @Test + void mergeWithPlaidWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + Path src = mergeSourceTree("srcmergeplaid"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergeplaid").resolve("all.h5").toFile(); + p.merge = true; + p.method = 5; + p.cores = 3; + p.workdir = dir.resolve("workmergeplaid").toFile(); + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the -method 5 merge must succeed"); + assertMerged(p.dest); + } + + @Test + void mergeIntoExistingDirectoryWritesMergedH5() throws Exception { + Path src = mergeSourceTree("srcmergedir"); + Path out = Files.createDirectories(dir.resolve("outmergedir")); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = out.toFile(); + p.merge = true; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount()); + assertMerged(out.resolve("merged.h5").toFile()); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/VerifyCommandTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/VerifyCommandTest.java new file mode 100644 index 00000000..600b627f --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/cmdline/VerifyCommandTest.java @@ -0,0 +1,146 @@ +package com.ebremer.beakgraph.cmdline; + +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import io.jhdf.HdfFile; +import io.jhdf.WritableHdfFile; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The -verify contract: a healthy file passes (structurally and with -deep), + * every flavor of damage - empty, truncated, garbage, valid-HDF5-but-not- + * BeakGraph - fails with exit code 2 and a reason, directories recurse, and + * an empty scan is its own error (exit 1), never a false "all good". + */ +class VerifyCommandTest { + + private static final String TTL = """ + @prefix ex: . + @prefix xsd: . + ex:s1 ex:p ex:o1 . + ex:s1 ex:p "plain" . + ex:s2 ex:p "hello"@en . + ex:s2 ex:q "42"^^xsd:integer . + ex:s3 ex:p ex:o2 . + """; + + @TempDir + static Path dir; + static File good; + + record Result(int code, String output) {} + + @BeforeAll + static void build() throws Exception { + File ttl = dir.resolve("verify.ttl").toFile(); + good = dir.resolve("verify.ttl.h5").toFile(); + Files.write(ttl.toPath(), TTL.getBytes(StandardCharsets.UTF_8)); + HDF5Writer.Builder() + .setSource(ttl).setDestination(good) + .setSpatial(false).setFeatures(false) + .build().write(); + } + + private static Result verify(File target, boolean deep) { + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + int code = new VerifyCommand(target, deep, new PrintStream(captured, true)).run(); + return new Result(code, captured.toString()); + } + + @Test + void healthyFilePassesStructurallyAndDeep() { + Result structural = verify(good, false); + assertEquals(0, structural.code(), structural.output()); + assertTrue(structural.output().contains("OK "), structural.output()); + assertTrue(structural.output().contains("1 OK, 0 FAILED"), structural.output()); + + Result deep = verify(good, true); + assertEquals(0, deep.code(), deep.output()); + assertTrue(deep.output().contains("(deep)"), deep.output()); + } + + @Test + void emptyFileFails() throws Exception { + Path empty = dir.resolve("empty.h5"); + Files.write(empty, new byte[0]); + Result r = verify(empty.toFile(), false); + assertEquals(2, r.code(), r.output()); + assertTrue(r.output().contains("empty file"), r.output()); + } + + @Test + void truncatedFileFails() throws Exception { + Path truncated = dir.resolve("truncated.h5"); + Files.copy(good.toPath(), truncated); + try (FileChannel fc = FileChannel.open(truncated, StandardOpenOption.WRITE)) { + fc.truncate(good.length() * 3 / 4); + } + Result r = verify(truncated.toFile(), false); + assertEquals(2, r.code(), r.output()); + assertTrue(r.output().contains("FAIL"), r.output()); + } + + @Test + void garbageBytesFail() throws Exception { + Path garbage = dir.resolve("garbage.h5"); + byte[] junk = new byte[4096]; + for (int i = 0; i < junk.length; i++) { + junk[i] = (byte) (i * 31 + 7); + } + Files.write(garbage, junk); + Result r = verify(garbage.toFile(), false); + assertEquals(2, r.code(), r.output()); + assertTrue(r.output().contains("cannot open"), r.output()); + } + + @Test + void validHdf5ButNotBeakGraphFails() throws Exception { + Path foreign = dir.resolve("foreign.h5"); + try (WritableHdfFile out = HdfFile.write(foreign)) { + out.putGroup("SomethingElse").putAttribute("hello", 1); + } + Result r = verify(foreign.toFile(), false); + assertEquals(2, r.code(), r.output()); + assertTrue(r.output().contains("Not a BeakGraph"), r.output()); + } + + @Test + void directoryRecursesIntoSubdirectoriesAndContinuesPastFailures() throws Exception { + Path tree = dir.resolve("tree"); + Path nested = tree.resolve("a/b"); + Files.createDirectories(nested); + Files.copy(good.toPath(), tree.resolve("good.h5")); + Files.copy(good.toPath(), nested.resolve("nested-good.hdf5")); // .hdf5 also picked up + Files.write(nested.resolve("bad.h5"), new byte[0]); + Files.write(tree.resolve("ignored.txt"), "not hdf5".getBytes(StandardCharsets.UTF_8)); + + Result r = verify(tree.toFile(), false); + assertEquals(2, r.code(), r.output()); + assertTrue(r.output().contains("good.h5"), r.output()); + assertTrue(r.output().contains("nested-good.hdf5"), r.output()); + assertTrue(r.output().contains("bad.h5"), r.output()); + assertFalse(r.output().contains("ignored.txt"), r.output()); + assertTrue(r.output().contains("Verified 3 file(s): 2 OK, 1 FAILED"), r.output()); + } + + @Test + void nothingToVerifyIsAnErrorNotSuccess() throws Exception { + Path emptyDir = dir.resolve("nothing-here"); + Files.createDirectories(emptyDir); + Result r = verify(emptyDir.toFile(), false); + assertEquals(1, r.code(), r.output()); + assertTrue(r.output().contains("No BeakGraph"), r.output()); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/VoidModeTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/VoidModeTest.java new file mode 100644 index 00000000..f5d6da0e --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/cmdline/VoidModeTest.java @@ -0,0 +1,100 @@ +package com.ebremer.beakgraph.cmdline; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.core.VoidMode; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * VoID generation policy: OFF by default, exact with -void, HyperLogLog with + * -voidsketch, and refusal when both are requested. + */ +class VoidModeTest { + + @TempDir + Path dir; + + private File build(String name, VoidMode mode) throws Exception { + File src = dir.resolve(name + ".ttl").toFile(); + Files.write(src.toPath(), + (" .\n" + + " \"x\" .\n").getBytes(StandardCharsets.UTF_8)); + File h5 = dir.resolve(name + ".h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(h5).setVoidMode(mode).build().write(); + return h5; + } + + private boolean hasVoidGraph(File h5) throws Exception { + try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { + try (QueryExecution qe = QueryExecution.dataset(bg.getDataset()) + .query(QueryFactory.create( + "ASK { GRAPH <" + Params.VOIDSTRING + "> { ?s ?p ?o } }")).build()) { + return qe.execAsk(); + } + } + } + + @Test + void noVoidGraphByDefault() throws Exception { + assertFalse(hasVoidGraph(build("off", VoidMode.NONE)), + "without -void/-voidsketch the statistics graph must not exist"); + } + + @Test + void exactAndSketchModesWriteTheVoidGraph() throws Exception { + assertTrue(hasVoidGraph(build("exact", VoidMode.EXACT)), "-void must write the statistics graph"); + assertTrue(hasVoidGraph(build("sketch", VoidMode.SKETCH)), "-voidsketch must write the statistics graph"); + } + + @Test + void cliOptionsParseAndMapToModes() { + Parameters p = new Parameters(); + com.beust.jcommander.JCommander.newBuilder().addObject(p).build() + .parse("-src", "x", "-voidsketch"); + assertTrue(p.voidSketch); + assertFalse(p.voidExact); + assertFalse(new Parameters().voidExact, "VoID must be OFF by default"); + assertFalse(new Parameters().voidSketch, "VoID must be OFF by default"); + } + + @Test + void voidAndVoidSketchTogetherAreRefused() { + Parameters p = new Parameters(); + p.src = dir.toFile(); + p.voidExact = true; + p.voidSketch = true; + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> new BeakGraphCLI(p), + "-void plus -voidsketch must refuse to run"); + assertTrue(ex.getMessage().contains("mutually exclusive")); + } + + @Test + void voidGraphIsTheOnlyDifference() throws Exception { + // Same data, void off vs on: the data graphs must be identical. + File off = build("cmp-off", VoidMode.NONE); + File on = build("cmp-on", VoidMode.EXACT); + try (BeakGraph a = new BeakGraph(new HDF5Reader(off)); + BeakGraph b = new BeakGraph(new HDF5Reader(on))) { + assertTrue(a.getDataset().getDefaultModel().isIsomorphicWith(b.getDataset().getDefaultModel())); + assertFalse(a.getDataset().asDatasetGraph().listGraphNodes().hasNext(), + "no named graphs at all without void"); + assertEquals(Params.VOIDSTRING, + b.getDataset().asDatasetGraph().listGraphNodes().next().getURI(), + "with -void the only named graph is the statistics graph"); + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/core/VoidStatsTest.java b/src/test/java/com/ebremer/beakgraph/core/VoidStatsTest.java index 6e2f0db4..e6c0eb6b 100644 --- a/src/test/java/com/ebremer/beakgraph/core/VoidStatsTest.java +++ b/src/test/java/com/ebremer/beakgraph/core/VoidStatsTest.java @@ -39,7 +39,7 @@ void predicateCountsSumAcrossGraphPartitions() throws Exception { File src = dir.resolve("stats.trig").toFile(); File h5 = dir.resolve("stats.trig.h5").toFile(); Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(src).setDestination(h5) + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(src).setDestination(h5) .setSpatial(false).setFeatures(false).build().write(); try (HDF5Reader reader = new HDF5Reader(h5)) { diff --git a/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java new file mode 100644 index 00000000..2fcdc340 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java @@ -0,0 +1,341 @@ +package com.ebremer.beakgraph.core.fuseki; + +import com.ebremer.beakgraph.lws.LWSMetadataGenerator; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import java.io.StringReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.apache.jena.rdf.model.Model; +import org.eclipse.jetty.ee11.servlet.ServletContextHandler; +import org.eclipse.jetty.ee11.servlet.ServletHolder; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * HTTP-level conformance of the unauthenticated read surface against the LWS + * Protocol 1.0 draft (w3c.github.io/lws-protocol/lws10-core/): container + * representation shape and media-type negotiation, link-based pagination + * (first/last/next/prev in Link headers, full totalItems with page-scoped + * items), mandatory Link relations (type / up / linkset / lws#storageDescription), + * validators + conditional requests on containers, and single-range requests + * on data resources. Runs a real Jetty server so header semantics are the + * container's, not a mock's. + */ +class LWSReadComplianceTest { + + @TempDir + static Path dir; + + private static Server server; + private static String base; + private static final HttpClient http = HttpClient.newHttpClient(); + private static final int FILES = 45; + /** Enough members to span 3+ pages at any PAGE_SIZE the guard admits. */ + private static final int BIGSUB_FILES = LWSStorageServlet.PAGE_SIZE * 2 + 2; + /** Root membership: FILES + the "sub" and "big sub" directories. */ + private static final int ROOT_ITEMS = FILES + 2; + + @BeforeAll + static void startServer() throws Exception { + Path root = Files.createDirectories(dir.resolve("storage")); + for (int i = 0; i < FILES; i++) { + Files.write(root.resolve(String.format("f%02d.txt", i)), + ("content-of-file-" + i).getBytes(StandardCharsets.UTF_8)); + } + Path sub = Files.createDirectories(root.resolve("sub")); + Files.write(sub.resolve("a.txt"), "aaa".getBytes(StandardCharsets.UTF_8)); + Files.write(sub.resolve("b.txt"), "bbb".getBytes(StandardCharsets.UTF_8)); + // A PAGINATING subcontainer whose name needs percent-encoding: pagination + // must work below the root, and its page URIs must be valid (my%20slides, + // not a raw space). + Path bigsub = Files.createDirectories(root.resolve("big sub")); + for (int i = 0; i < BIGSUB_FILES; i++) { + Files.write(bigsub.resolve(String.format("g%02d.txt", i)), + ("g-" + i).getBytes(StandardCharsets.UTF_8)); + } + + Model model = LWSMetadataGenerator.generateLWSModel(root); + server = new Server(0); + ServletContextHandler ctx = new ServletContextHandler(); + ctx.setContextPath("/"); + ctx.addServlet(new ServletHolder(new LWSStorageServlet(model)), "/*"); + server.setHandler(ctx); + server.start(); + int port = ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + base = "http://localhost:" + port + "/"; + LWSStorageServlet.setBase(base); + LWSStorageServlet.setStorageRoot(root); + } + + @AfterAll + static void stopServer() throws Exception { + if (server != null) server.stop(); + } + + private static HttpResponse get(String path, String... headers) throws Exception { + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(base + path)); + for (int i = 0; i < headers.length; i += 2) { + b.header(headers[i], headers[i + 1]); + } + return http.send(b.GET().build(), HttpResponse.BodyHandlers.ofString()); + } + + private static JsonObject parse(String json) { + try (JsonReader r = Json.createReader(new StringReader(json))) { + return r.readObject(); + } + } + + private static boolean hasLink(HttpResponse resp, String relFragment) { + return resp.headers().allValues("Link").stream().anyMatch(v -> v.contains(relFragment)); + } + + private static String linkTarget(HttpResponse resp, String relFragment) { + return resp.headers().allValues("Link").stream() + .filter(v -> v.contains(relFragment)) + .map(v -> v.substring(v.indexOf('<') + 1, v.indexOf('>'))) + .findFirst().orElse(null); + } + + @Test + void containerRepresentationHasTheLwsShape() throws Exception { + HttpResponse resp = get("", "Accept", "application/lws+json"); + assertEquals(200, resp.statusCode()); + assertTrue(resp.headers().firstValue("Content-Type").orElse("").startsWith("application/lws+json"), + "requested lws+json must be echoed"); + assertTrue(resp.headers().firstValue("ETag").isPresent(), "containers must carry an ETag"); + assertTrue(resp.headers().firstValue("Last-Modified").isPresent(), "containers must carry Last-Modified"); + assertTrue(hasLink(resp, "rel=\"type\""), "rel=type link required"); + assertTrue(hasLink(resp, "https://www.w3.org/ns/lws#Container"), "type link must name lws#Container"); + assertTrue(hasLink(resp, "rel=\"linkset\""), "rel=linkset link required"); + assertTrue(hasLink(resp, LWSStorageServlet.REL_STORAGE_DESCRIPTION), + "storage-description discovery link required on all storage responses"); + assertFalse(hasLink(resp, "rel=\"up\""), "the root container has no parent"); + + JsonObject doc = parse(resp.body()); + assertEquals("https://www.w3.org/ns/lws/v1", doc.getString("@context")); + assertEquals(base, doc.getString("id"), "id must be the container URI, not a page URI"); + assertEquals("Container", doc.getString("type")); + assertEquals(ROOT_ITEMS, doc.getInt("totalItems"), "totalItems reflects the FULL membership"); + assertEquals(LWSStorageServlet.PAGE_SIZE, doc.getJsonArray("items").size(), + "a large container's bare GET is the first page"); + // Item order is URI-sorted, so scan for one of each kind rather than + // assuming positions. + JsonObject file = null; + JsonObject folder = null; + for (jakarta.json.JsonValue v : doc.getJsonArray("items")) { + JsonObject o = v.asJsonObject(); + assertTrue(o.containsKey("id")); + if ("DataResource".equals(o.getString("type")) && file == null) file = o; + if ("Container".equals(o.getString("type")) && folder == null) folder = o; + } + assertNotNull(file, "page 1 must contain a DataResource"); + assertEquals("text/plain", file.getString("mediaType"), "mediaType is required for DataResources"); + assertTrue(file.containsKey("size")); + assertTrue(file.containsKey("modified")); + assertNotNull(folder, "page 1 must contain the 'big sub' Container (URI sort puts it first)"); + assertFalse(folder.containsKey("mediaType"), "containers carry no mediaType"); + } + + @Test + void subcontainersPaginateWithEncodedPageUris() throws Exception { + int pages = (BIGSUB_FILES + LWSStorageServlet.PAGE_SIZE - 1) / LWSStorageServlet.PAGE_SIZE; + assertTrue(pages >= 3, "bigsub must span at least 3 pages"); + + HttpResponse p1 = get("big%20sub", "Accept", "application/lws+json"); + assertEquals(200, p1.statusCode()); + assertTrue(hasLink(p1, "rel=\"first\""), "subcontainers must paginate exactly like the root"); + assertTrue(hasLink(p1, "rel=\"next\"")); + assertFalse(hasLink(p1, "rel=\"prev\"")); + + String next = linkTarget(p1, "rel=\"next\""); + assertNotNull(next); + assertTrue(next.contains("big%20sub"), + "page URIs must percent-encode the container path, got: " + next); + assertEquals(URI.create(next).toString(), next, "the next target must be a valid URI"); + + JsonObject d1 = parse(p1.body()); + assertEquals(BIGSUB_FILES, d1.getInt("totalItems")); + assertEquals(LWSStorageServlet.PAGE_SIZE, d1.getJsonArray("items").size()); + assertTrue(d1.containsKey("as:next"), "subcontainer bodies embed navigation too"); + assertEquals(next, d1.getJsonObject("as:next").getString("id")); + + // Follow the served link (opaque-URI navigation, as a client would). + HttpResponse p2 = get(next.substring(base.length()), "Accept", "application/lws+json"); + assertEquals(200, p2.statusCode()); + assertTrue(hasLink(p2, "rel=\"prev\"")); + assertEquals(BIGSUB_FILES, parse(p2.body()).getInt("totalItems")); + + HttpResponse pLast = get("big%20sub?page=" + pages, "Accept", "application/lws+json"); + assertEquals(200, pLast.statusCode()); + assertFalse(hasLink(pLast, "rel=\"next\""), "no next on the subcontainer's last page"); + assertEquals(BIGSUB_FILES - (pages - 1) * LWSStorageServlet.PAGE_SIZE, + parse(pLast.body()).getJsonArray("items").size()); + + // Listings must revalidate rather than replay from heuristic cache. + assertEquals("no-cache", p1.headers().firstValue("Cache-Control").orElse("")); + } + + @Test + void jsonFlavorsNegotiateContentTypeWithIdenticalBodies() throws Exception { + HttpResponse lws = get("", "Accept", "application/lws+json"); + HttpResponse ld = get("", "Accept", "application/ld+json"); + HttpResponse plain = get("", "Accept", "application/json"); + assertTrue(ld.headers().firstValue("Content-Type").orElse("").startsWith("application/ld+json")); + assertTrue(plain.headers().firstValue("Content-Type").orElse("").startsWith("application/json")); + assertEquals(lws.body(), ld.body(), "the payload must be identical across the JSON flavors"); + assertEquals(lws.body(), plain.body()); + } + + @Test + void paginationTravelsInLinkHeaders() throws Exception { + // Page-size agnostic: whatever PAGE_SIZE is configured, the root must + // paginate consistently (the storage tree guarantees > 2 pages). + int total = ROOT_ITEMS; + int pageSize = LWSStorageServlet.PAGE_SIZE; + int pages = (total + pageSize - 1) / pageSize; + assertTrue(pages >= 3, "test data must span at least 3 pages (adjust FILES if PAGE_SIZE grew)"); + + HttpResponse p1 = get("", "Accept", "application/lws+json"); + assertTrue(hasLink(p1, "rel=\"first\""), "first MUST be present on paginated responses"); + assertTrue(hasLink(p1, "rel=\"next\""), "next MUST be present when more pages exist"); + assertTrue(hasLink(p1, "rel=\"last\"")); + assertFalse(hasLink(p1, "rel=\"prev\""), "prev MUST be omitted on the first page"); + + String next = linkTarget(p1, "rel=\"next\""); + assertNotNull(next); + + // The body mirrors the Link-header navigation (as: node references). + JsonObject d1 = parse(p1.body()); + assertTrue(d1.containsKey("as:first"), "paginated bodies embed as:first"); + assertTrue(d1.containsKey("as:next"), "paginated bodies embed as:next"); + assertFalse(d1.containsKey("as:prev"), "no as:prev on the first page"); + assertEquals(next, d1.getJsonObject("as:next").getString("id"), + "the body's as:next must equal the Link header target"); + HttpResponse p2 = get(next.substring(base.length()), "Accept", "application/lws+json"); + assertEquals(200, p2.statusCode()); + assertTrue(hasLink(p2, "rel=\"prev\"")); + assertTrue(hasLink(p2, "rel=\"next\"")); + JsonObject d2 = parse(p2.body()); + assertEquals(total, d2.getInt("totalItems"), "totalItems stays the full count on every page"); + assertEquals(base, d2.getString("id"), "the body id stays the container URI on every page"); + assertEquals(pageSize, d2.getJsonArray("items").size()); + + HttpResponse pLast = get("?page=" + pages, "Accept", "application/lws+json"); + assertEquals(200, pLast.statusCode()); + assertFalse(hasLink(pLast, "rel=\"next\""), "next MUST be omitted on the last page"); + assertTrue(hasLink(pLast, "rel=\"prev\"")); + JsonObject dLast = parse(pLast.body()); + assertEquals(total - (pages - 1) * pageSize, dLast.getJsonArray("items").size()); + assertFalse(dLast.containsKey("as:next"), "no as:next on the last page"); + assertTrue(dLast.containsKey("as:prev"), "the last page embeds as:prev"); + + assertEquals(404, get("?page=" + (pages + 1), "Accept", "application/lws+json").statusCode()); + assertEquals(400, get("?page=0", "Accept", "application/lws+json").statusCode()); + assertEquals(400, get("?page=x", "Accept", "application/lws+json").statusCode()); + } + + @Test + void smallContainersAreNotPaginated() throws Exception { + HttpResponse resp = get("sub", "Accept", "application/lws+json"); + assertEquals(200, resp.statusCode()); + assertFalse(hasLink(resp, "rel=\"first\""), "below the threshold there is no pagination"); + JsonObject doc = parse(resp.body()); + assertEquals(2, doc.getInt("totalItems")); + assertEquals(2, doc.getJsonArray("items").size()); + assertFalse(doc.containsKey("as:first"), "unpaginated bodies carry no navigation"); + assertFalse(doc.containsKey("as:next")); + assertEquals(base, linkTarget(resp, "rel=\"up\""), "non-root resources must link rel=up to their parent"); + } + + @Test + void containerConditionalRequestsAnswer304() throws Exception { + HttpResponse resp = get("", "Accept", "application/lws+json"); + String etag = resp.headers().firstValue("ETag").orElseThrow(); + HttpResponse cond = get("", "Accept", "application/lws+json", "If-None-Match", etag); + assertEquals(304, cond.statusCode()); + } + + @Test + void dataResourcesServeRanges() throws Exception { + String content = "content-of-file-7"; + HttpResponse full = get("f07.txt", "Accept", "text/plain"); + assertEquals(200, full.statusCode()); + assertEquals(content, full.body()); + assertEquals("bytes", full.headers().firstValue("Accept-Ranges").orElse("")); + assertTrue(hasLink(full, "https://www.w3.org/ns/lws#DataResource"), "rel=type must name lws#DataResource"); + assertEquals(base, linkTarget(full, "rel=\"up\"")); + + HttpResponse part = get("f07.txt", "Accept", "text/plain", "Range", "bytes=2-6"); + assertEquals(206, part.statusCode()); + assertEquals("bytes 2-6/" + content.length(), + part.headers().firstValue("Content-Range").orElse("")); + assertEquals(content.substring(2, 7), part.body()); + + HttpResponse suffix = get("f07.txt", "Accept", "text/plain", "Range", "bytes=-4"); + assertEquals(206, suffix.statusCode()); + assertEquals(content.substring(content.length() - 4), suffix.body()); + + HttpResponse bad = get("f07.txt", "Accept", "text/plain", "Range", "bytes=999-"); + assertEquals(416, bad.statusCode()); + assertEquals("bytes */" + content.length(), + bad.headers().firstValue("Content-Range").orElse("")); + } + + @Test + void headReturnsHeadersWithoutBody() throws Exception { + HttpRequest req = HttpRequest.newBuilder(URI.create(base)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .header("Accept", "application/lws+json").build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode()); + assertTrue(resp.headers().firstValue("ETag").isPresent()); + assertTrue(hasLink(resp, "rel=\"type\"")); + assertEquals("", resp.body(), "HEAD must not carry a body"); + } + + @Test + void htmlViewOffersAParentLink() throws Exception { + HttpResponse sub = get("sub", "Accept", "text/html"); + assertEquals(200, sub.statusCode()); + assertTrue(sub.headers().firstValue("Content-Type").orElse("").startsWith("text/html")); + assertTrue(sub.body().contains("Parent"), "subcontainer HTML must offer a parent link"); + assertTrue(sub.body().contains("href=\"" + base + "\""), "the parent link must target the parent container"); + + HttpResponse root = get("", "Accept", "text/html"); + assertEquals(200, root.statusCode()); + assertFalse(root.body().contains("Parent"), "the root has no parent to link"); + } + + @Test + void storageDescriptionUsesTheLwsMediaType() throws Exception { + HttpResponse resp = get("description"); + assertEquals(200, resp.statusCode()); + assertTrue(resp.headers().firstValue("Content-Type").orElse("").startsWith("application/lws+json"), + "the storage description defaults to application/lws+json"); + JsonObject doc = parse(resp.body()); + assertEquals(base, doc.getString("id")); + assertEquals("Storage", doc.getString("type")); + assertTrue(hasLink(resp, LWSStorageServlet.REL_STORAGE_DESCRIPTION)); + + HttpResponse ld = get("description", "Accept", "application/ld+json"); + assertTrue(ld.headers().firstValue("Content-Type").orElse("").startsWith("application/ld+json")); + assertEquals(resp.body(), ld.body(), "negotiated flavors share one payload"); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServletTest.java b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServletTest.java index 31633add..4804d30c 100644 --- a/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServletTest.java +++ b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServletTest.java @@ -46,11 +46,39 @@ void linksetJsonEscapesValuesAndIsWellFormed() { void descriptionJsonIsWellFormed() { JsonObject doc = parse(LWSStorageServlet.descriptionJson("https://base.example/")); assertEquals("Storage", doc.getString("type")); - assertEquals("https://base.example/", doc.getString("@id")); + // LWS Discovery data model: the storage is identified by "id" (the lws/v1 + // context maps it to @id), and the StorageDescription service is mandatory. + assertEquals("https://base.example/", doc.getString("id")); + assertEquals("StorageDescription", + doc.getJsonArray("service").getJsonObject(0).getString("type")); + assertEquals("https://base.example/description", + doc.getJsonArray("service").getJsonObject(0).getString("serviceEndpoint")); assertEquals("https://base.example/sparql", doc.getJsonArray("service").getJsonObject(1).getString("serviceEndpoint")); } + @Test + void parseRangeHandlesTheSingleRangeForms() { + long size = 100; + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{2, 5}, + LWSStorageServlet.parseRange("bytes=2-5", size)); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{90, 99}, + LWSStorageServlet.parseRange("bytes=90-", size), "open-ended range runs to EOF"); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{90, 99}, + LWSStorageServlet.parseRange("bytes=-10", size), "suffix range takes the last N bytes"); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{0, 99}, + LWSStorageServlet.parseRange("bytes=0-500", size), "end clamps to size-1"); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{-1, -1}, + LWSStorageServlet.parseRange("bytes=100-", size), "start at/after EOF is unsatisfiable"); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{-1, -1}, + LWSStorageServlet.parseRange("bytes=-0", size), "empty suffix is unsatisfiable"); + assertNull(LWSStorageServlet.parseRange(null, size), "no header -> full response"); + assertNull(LWSStorageServlet.parseRange("bytes=0-1,5-9", size), "multi-range is ignored"); + assertNull(LWSStorageServlet.parseRange("items=0-5", size), "non-bytes unit is ignored"); + assertNull(LWSStorageServlet.parseRange("bytes=x-y", size), "garbage is ignored"); + assertNull(LWSStorageServlet.parseRange("bytes=5-2", size), "inverted range is ignored"); + } + @Test void resolveWithinRejectsTraversalAndAbsolutePaths() throws Exception { Path root = Files.createTempDirectory("lws-test").toRealPath(); diff --git a/src/test/java/com/ebremer/beakgraph/core/fuseki/VoidSketchTest.java b/src/test/java/com/ebremer/beakgraph/core/fuseki/VoidSketchTest.java new file mode 100644 index 00000000..14148f40 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/core/fuseki/VoidSketchTest.java @@ -0,0 +1,101 @@ +package com.ebremer.beakgraph.core.fuseki; + +import com.ebremer.beakgraph.core.lib.HyperLogLog; +import java.util.List; +import org.apache.jena.graph.NodeFactory; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.sparql.core.Quad; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.VOID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; + +/** + * The bounded-memory VoID statistics: HyperLogLog accuracy, the + * exact-then-sketch spill of {@link DistinctNodeCounter}, determinism, and - + * most importantly - that BELOW the spill threshold every reported VoID + * number is exactly what the old retained-set implementation produced. + */ +class VoidSketchTest { + + @Test + void hyperLogLogIsAccurateAtScale() { + HyperLogLog hll = new HyperLogLog(); + int n = 1_000_000; + for (long i = 0; i < n; i++) { + hll.add(HyperLogLog.mix64(i * 0x9E3779B97F4A7C15L + 12345)); + } + long est = hll.estimate(); + // 2^14 registers -> sigma ~0.81%; 3 sigma bound with margin. + assertTrue(Math.abs(est - n) < n * 0.03, + "estimate " + est + " must be within 3% of " + n); + } + + @Test + void counterIsExactBelowLimitAndCloseAboveIt() { + DistinctNodeCounter small = new DistinctNodeCounter(1 << 16); + for (int i = 0; i < 5_000; i++) { + small.add(NodeFactory.createURI("http://ex.org/r" + (i % 1_000))); + } + assertTrue(small.isExact()); + assertEquals(1_000, small.count(), "below the limit the count is exact"); + + DistinctNodeCounter big = new DistinctNodeCounter(1_000); + int n = 50_000; + for (int i = 0; i < n; i++) { + big.add(NodeFactory.createURI("http://ex.org/r" + i)); + } + assertFalse(big.isExact(), "the counter must have spilled to the sketch"); + assertTrue(Math.abs(big.count() - n) < n * 0.05, + "sketched count " + big.count() + " must be within 5% of " + n); + + // Determinism: same inputs -> same estimate, regardless of duplicates. + DistinctNodeCounter again = new DistinctNodeCounter(1_000); + for (int round = 0; round < 2; round++) { + for (int i = 0; i < n; i++) { + again.add(NodeFactory.createURI("http://ex.org/r" + i)); + } + } + assertEquals(big.count(), again.count(), "estimates are a pure function of the distinct set"); + } + + @Test + void smallStoreVoidNumbersAreExact() { + BGVoIDSD v = new BGVoIDSD("https://ebremer.com/void/"); + for (int i = 0; i < 200; i++) { + v.add(new Quad(Quad.defaultGraphIRI, + NodeFactory.createURI("http://ex.org/data/s" + (i % 40)), + NodeFactory.createURI("http://ex.org/p" + (i % 5)), + NodeFactory.createURI("http://ex.org/data/o" + (i % 60)))); + } + for (int i = 0; i < 30; i++) { + v.add(new Quad(Quad.defaultGraphIRI, + NodeFactory.createURI("http://ex.org/data/s" + i), + RDF.type.asNode(), + NodeFactory.createURI("http://ex.org/Article"))); + } + Model m = v.getModel(); + assertEquals(40, one(m, VOID.distinctSubjects), "distinct subjects exact"); + assertEquals(61, one(m, VOID.distinctObjects), "distinct objects exact (60 + the class)"); + assertEquals(1, one(m, VOID.classes)); + assertEquals(30, one(m, VOID.entities), "typed instances exact"); + assertEquals(230, one(m, VOID.triples)); + // uriSpace: LCP of http://ex.org/data/s*, cut to the last '/' + List spaces = m.listObjectsOfProperty(VOID.uriSpace) + .mapWith(n -> n.asLiteral().getString()).toList(); + assertEquals(List.of("http://ex.org/data/"), spaces, "incremental LCP must match the old set-based one"); + assertTrue(m.listObjectsOfProperty(VOID.vocabulary) + .mapWith(n -> n.asResource().getURI()).toSet() + .contains("http://ex.org/"), + "object/predicate namespaces must land in void:vocabulary"); + } + + private static long one(Model m, org.apache.jena.rdf.model.Property p) { + var it = m.listObjectsOfProperty(p); + assertTrue(it.hasNext(), "expected a value for " + p); + return it.next().asLiteral().getLong(); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparatorTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparatorTest.java new file mode 100644 index 00000000..0bc69c59 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparatorTest.java @@ -0,0 +1,83 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.core.lib.NodeComparator; +import java.util.ArrayList; +import java.util.List; +import org.apache.jena.datatypes.xsd.XSDDatatype; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.NodeFactory; +import org.apache.jena.sparql.core.Quad; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; + +/** + * The caching comparator must be ORDER-IDENTICAL to the shared + * NodeComparator on every node kind - it only relocates where the + * Node-to-NodeValue conversion result is stored. Includes the value spaces + * with special-cased ordering (temporal, duration) and value-equal but + * term-distinct literals, plus the memo's cap-reset path. + */ +class CachingNodeComparatorTest { + + private static List zoo() { + List nodes = new ArrayList<>(); + nodes.add(Quad.defaultGraphIRI); + nodes.add(Quad.defaultGraphNodeGenerated); + nodes.add(NodeFactory.createBlankNode("b0")); + nodes.add(NodeFactory.createBlankNode("b1")); + nodes.add(NodeFactory.createURI("http://ex.org/a")); + nodes.add(NodeFactory.createURI("http://ex.org/b")); + nodes.add(NodeFactory.createURI("relative.png")); + nodes.add(NodeFactory.createLiteralString("alpha")); + nodes.add(NodeFactory.createLiteralString("beta")); + nodes.add(NodeFactory.createLiteralLang("alpha", "en")); + nodes.add(NodeFactory.createLiteralDT("1", XSDDatatype.XSDint)); + nodes.add(NodeFactory.createLiteralDT("1", XSDDatatype.XSDlong)); + nodes.add(NodeFactory.createLiteralDT("1.0", XSDDatatype.XSDdouble)); + nodes.add(NodeFactory.createLiteralDT("2", XSDDatatype.XSDint)); + nodes.add(NodeFactory.createLiteralDT("10", XSDDatatype.XSDint)); + nodes.add(NodeFactory.createLiteralDT("123456789012345678901234567890", XSDDatatype.XSDinteger)); + nodes.add(NodeFactory.createLiteralDT("abc", XSDDatatype.XSDint)); // ill-typed + nodes.add(NodeFactory.createLiteralDT("2020-01-02T00:00:00", XSDDatatype.XSDdateTime)); + nodes.add(NodeFactory.createLiteralDT("2020-01-02T08:00:00+14:00", XSDDatatype.XSDdateTime)); + nodes.add(NodeFactory.createLiteralDT("2020-01-01T20:00:00Z", XSDDatatype.XSDdateTime)); + nodes.add(NodeFactory.createLiteralDT("2020-01-02", XSDDatatype.XSDdate)); + nodes.add(NodeFactory.createLiteralDT("P1D", XSDDatatype.XSDduration)); + nodes.add(NodeFactory.createLiteralDT("PT24H", XSDDatatype.XSDduration)); + nodes.add(NodeFactory.createLiteralDT("P1M", XSDDatatype.XSDduration)); + nodes.add(NodeFactory.createLiteralDT("true", XSDDatatype.XSDboolean)); + return nodes; + } + + @Test + void orderIsIdenticalToTheSharedComparator() { + CachingNodeComparator cached = new CachingNodeComparator(1 << 16); + List nodes = zoo(); + for (Node a : nodes) { + for (Node b : nodes) { + int expected = Integer.signum(NodeComparator.INSTANCE.compare(a, b)); + assertEquals(expected, Integer.signum(cached.compare(a, b)), + "order of (" + a + ", " + b + ")"); + // Memo hits must not change the answer either. + assertEquals(expected, Integer.signum(cached.compare(a, b)), + "cached order of (" + a + ", " + b + ")"); + } + } + } + + @Test + void capResetKeepsAnswersCorrect() { + // A cap of 2 forces constant clear/reconvert cycles. + CachingNodeComparator cached = new CachingNodeComparator(2); + List nodes = zoo(); + for (int round = 0; round < 3; round++) { + for (int i = 0; i < nodes.size(); i++) { + for (int j = 0; j < nodes.size(); j++) { + assertEquals( + Integer.signum(NodeComparator.INSTANCE.compare(nodes.get(i), nodes.get(j))), + Integer.signum(cached.compare(nodes.get(i), nodes.get(j)))); + } + } + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraWriterParityTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraWriterParityTest.java new file mode 100644 index 00000000..7ca689bb --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraWriterParityTest.java @@ -0,0 +1,214 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import com.ebremer.beakgraph.huge.NativeHdf5File; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.TreeSet; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.rdf.model.Model; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end parity of the hugeUltra writer (-method 4) against the + * sequential in-memory writer, read back through the unmodified readers. The + * bar is the huge writer's: same graph lists, isomorphic per-graph content, + * same query answers (bnode labels legitimately differ - the disk pipeline + * keeps parsed labels). The interesting part: builds run with TINY spill + * batches and fan-in 2, so every new mechanism actually executes - background + * double-buffered spills, multi-level CONCURRENT merges, the grouped term-run + * format across merge levels, packed radix-sorted quad/row-id runs, and the + * GSPO-to-GPOS dedup tee (the input contains duplicate quads on purpose). + */ +class HugeUltraWriterParityTest { + + @TempDir + static Path dir; + + @BeforeAll + static void requireNativeHdf5() { + Assumptions.assumeTrue(NativeHdf5File.isAvailable(), "native HDF5 library unavailable"); + } + + private static Path writeBoth(String name, String content) throws Exception { + File src = dir.resolve(name).toFile(); + Files.write(src.toPath(), content.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve(name + ".seq.h5").toFile(); + File hu = dir.resolve(name + ".hugeultra.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq).build().write(); + HugeUltraHDF5Writer.Builder() + .setSource(src) + .setDestination(hu) + .setWorkDirectory(Files.createDirectories(dir.resolve("work-" + name))) + .setCores(3) + // Deliberately absurd sizing: hundreds of runs, several merge + // levels, constant background spilling. + .setTermSpillBatch(64) + .setIdSpillBatch(128) + .setMergeFanIn(2) + .build() + .write(); + return dir; + } + + private static Set graphNames(org.apache.jena.query.Dataset ds) { + Set names = new TreeSet<>(); + ds.asDatasetGraph().listGraphNodes().forEachRemaining(g -> names.add(g.toString())); + return names; + } + + private static Model graphModel(org.apache.jena.query.Dataset ds, String graphUri) { + String q = (graphUri == null) + ? "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + : "CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <" + graphUri + "> { ?s ?p ?o } }"; + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(q)).build()) { + return qe.execConstruct(); + } + } + + private static java.util.List select(org.apache.jena.query.Dataset ds, String query) { + java.util.List rows = new java.util.ArrayList<>(); + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(query)).build()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) { + QuerySolution qs = rs.next(); + StringBuilder sb = new StringBuilder(); + rs.getResultVars().forEach(v -> sb.append(v).append('=').append(qs.get(v)).append('|')); + rows.add(sb.toString()); + } + } + rows.sort(String::compareTo); + return rows; + } + + private static void assertStoresEquivalent(Path seqH5, Path huH5, String... probes) throws Exception { + try (BeakGraph a = new BeakGraph(new HDF5Reader(seqH5.toFile())); + BeakGraph b = new BeakGraph(new HDF5Reader(huH5.toFile()))) { + org.apache.jena.query.Dataset da = a.getDataset(); + org.apache.jena.query.Dataset db = b.getDataset(); + assertEquals(graphNames(da), graphNames(db), "graph lists must match"); + Model defA = graphModel(da, null); + Model defB = graphModel(db, null); + assertTrue(defA.isIsomorphicWith(defB), + "default graphs must be isomorphic (seq=" + defA.size() + ", hugeUltra=" + defB.size() + ")"); + for (String g : graphNames(da)) { + if (!g.startsWith("http") && !g.startsWith("urn")) continue; + Model ga = graphModel(da, g); + Model gb = graphModel(db, g); + assertTrue(ga.isIsomorphicWith(gb), + "graph <" + g + "> must be isomorphic (seq=" + ga.size() + ", hugeUltra=" + gb.size() + ")"); + } + for (String probe : probes) { + assertEquals(select(da, probe), select(db, probe), "probe results must match: " + probe); + } + } + } + + @Test + void mixedTypesNamedGraphsAndBnodes() throws Exception { + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:name "Alice" . + ex:s0 ex:name "Alice"@en . + ex:s0 ex:count "42"^^xsd:int . + ex:s0 ex:big "9223372036854775806"^^xsd:long . + ex:s0 ex:f "1.5"^^xsd:float . + ex:s0 ex:d "-2.25E8"^^xsd:double . + ex:s0 ex:unbounded "123456789012345678901234567890"^^xsd:integer . + ex:s0 ex:flag true . + ex:s0 ex:when "2024-05-06T07:08:09Z"^^xsd:dateTime . + ex:s0 ex:ill "abc"^^xsd:int . + <> ex:self . + _:b1 ex:p0 _:b2 . + _:b2 ex:knows ex:s0 . + ex:g1 { + ex:s1 ex:p0 ex:o0 . + _:b1 ex:inGraph ex:g1 . + } + ex:g2 { ex:s0 ex:p0 ex:s1 . } + """; + writeBoth("hu-mixed.trig", trig); + assertStoresEquivalent(dir.resolve("hu-mixed.trig.seq.h5"), dir.resolve("hu-mixed.trig.hugeultra.h5"), + "SELECT ?p ?o WHERE { ?p ?o }", + "SELECT ?s WHERE { ?s }", + "SELECT ?g ?s WHERE { GRAPH ?g { ?s } }"); + } + + @Test + void stressManyQuadsWithDuplicatesThroughTinySpillRuns() throws Exception { + StringBuilder nq = new StringBuilder(1 << 21); + for (int i = 0; i < 12_000; i++) { + String s = ""; + String p = ""; + String o = switch (i % 4) { + case 0 -> ""; + case 1 -> "\"str" + (i % 300) + "\""; + case 2 -> "\"" + (i % 500 - 250) + "\"^^"; + default -> "\"" + ((i % 90) / 4.0) + "\"^^"; + }; + nq.append(s).append(' ').append(p).append(' ').append(o); + if (i % 3 != 0) { + nq.append(" '); + } + nq.append(" .\n"); + // Every 10th quad appears twice: the GSPO->GPOS dedup tee must + // collapse these without dropping anything else. + if (i % 10 == 0) { + nq.append(s).append(' ').append(p).append(' ').append(o).append(" .\n"); + } + } + writeBoth("hu-stress.nq", nq.toString()); + assertStoresEquivalent(dir.resolve("hu-stress.nq.seq.h5"), dir.resolve("hu-stress.nq.hugeultra.h5"), + "SELECT ?s ?o WHERE { ?s ?o }", + "SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY ?g", + "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }", + "SELECT ?s WHERE { GRAPH { ?s \"str101\" } }"); + } + + @Test + void mergeKeepsBlankNodesDistinctPerDocument() throws Exception { + Path src = Files.createDirectories(dir.resolve("hu-mergesrc")); + Files.write(src.resolve("m1.ttl"), + "_:b0 \"v1\" .\n .\n" + .getBytes(StandardCharsets.UTF_8)); + Files.write(src.resolve("m2.ttl"), + "_:b0 \"v2\" .\n .\n" + .getBytes(StandardCharsets.UTF_8)); + File out = dir.resolve("hu-merged.h5").toFile(); + HugeUltraHDF5Writer.Builder() + .setSources(java.util.List.of(src.resolve("m1.ttl").toFile(), src.resolve("m2.ttl").toFile())) + .setDestination(out) + .setWorkDirectory(Files.createDirectories(dir.resolve("hu-mergework"))) + .setCores(2) + .setTermSpillBatch(8) + .setIdSpillBatch(8) + .setMergeFanIn(2) + .build() + .write(); + try (BeakGraph bg = new BeakGraph(new HDF5Reader(out))) { + try (QueryExecution qe = QueryExecution.dataset(bg.getDataset()) + .query(QueryFactory.create( + "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE { ?b ?v }")).build()) { + assertEquals(2, qe.execSelect().next().getLiteral("n").getInt(), + "_:b0 from two documents must remain two distinct blank nodes"); + } + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelWriterParityTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelWriterParityTest.java new file mode 100644 index 00000000..28c751b4 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelWriterParityTest.java @@ -0,0 +1,360 @@ +package com.ebremer.beakgraph.hdf5.writers.parallel; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import io.jhdf.HdfFile; +import io.jhdf.api.Attribute; +import io.jhdf.api.Dataset; +import io.jhdf.api.Group; +import io.jhdf.api.Node; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.sparql.core.Quad; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end parity: the parallel writer must produce a store that reads back + * (through the UNMODIFIED jHDF-based readers) semantically identical to the + * sequential writer's - same graphs, isomorphic per-graph content, same query + * answers through both indexes, and structurally identical HDF5 metadata + * (same dataset tree, same sizes, same numEntries / width / FCD attributes + * everywhere). Raw bytes are NOT compared: the VoID generator mints fresh + * blank-node labels on every write, so bnode dictionary ranks legitimately + * differ between any two writes - including two sequential ones (same caveat + * as HugeWriterParityTest). + * + *

{@link #idTupleOrderMatchesNodeComparatorOrder()} separately pins the + * parallel index's core claim - sorting quads by dictionary-id tuple is + * exactly the sequential writer's NodeComparator quad order - against real + * parsed data including randomly-labeled VoID bnodes. + */ +class ParallelWriterParityTest { + + @TempDir + static Path dir; + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + /** Writes src with both writers; returns the directory holding {name}.seq.h5 / {name}.par.h5. */ + private static Path writeBoth(String name, String content, boolean spatial, boolean features) throws Exception { + File src = dir.resolve(name).toFile(); + Files.write(src.toPath(), content.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve(name + ".seq.h5").toFile(); + File par = dir.resolve(name + ".par.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq) + .setSpatial(spatial).setFeatures(features).build().write(); + ParallelHDF5Writer.Builder().setSource(src).setDestination(par) + .setSpatial(spatial).setFeatures(features) + // deliberately odd core count: shakes out anything that silently + // assumes the default or an even split + .setCores(3) + .build().write(); + return dir; + } + + private static Set graphNames(org.apache.jena.query.Dataset ds) { + Set names = new TreeSet<>(); + ds.asDatasetGraph().listGraphNodes().forEachRemaining(g -> names.add(g.toString())); + return names; + } + + private static Model graphModel(org.apache.jena.query.Dataset ds, String graphUri) { + String q = (graphUri == null) + ? "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + : "CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <" + graphUri + "> { ?s ?p ?o } }"; + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(q)).build()) { + return qe.execConstruct(); + } + } + + private static java.util.List select(org.apache.jena.query.Dataset ds, String query) { + java.util.List rows = new java.util.ArrayList<>(); + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(query)).build()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) { + QuerySolution qs = rs.next(); + StringBuilder sb = new StringBuilder(); + rs.getResultVars().forEach(v -> sb.append(v).append('=').append(qs.get(v)).append('|')); + rows.add(sb.toString()); + } + } + rows.sort(String::compareTo); + return rows; + } + + /** Semantic equality of the two stores, graph by graph. */ + private static void assertStoresEquivalent(Path seqH5, Path parH5, String... probes) throws Exception { + try (BeakGraph a = new BeakGraph(new HDF5Reader(seqH5.toFile())); + BeakGraph b = new BeakGraph(new HDF5Reader(parH5.toFile()))) { + org.apache.jena.query.Dataset da = a.getDataset(); + org.apache.jena.query.Dataset db = b.getDataset(); + + assertEquals(graphNames(da), graphNames(db), "graph lists must match"); + + Model defA = graphModel(da, null); + Model defB = graphModel(db, null); + assertTrue(defA.isIsomorphicWith(defB), + "default graphs must be isomorphic (seq=" + defA.size() + ", par=" + defB.size() + ")"); + for (String g : graphNames(da)) { + if (!g.startsWith("http") && !g.startsWith("urn")) continue; // skip any non-URI form + Model ga = graphModel(da, g); + Model gb = graphModel(db, g); + assertTrue(ga.isIsomorphicWith(gb), + "graph <" + g + "> must be isomorphic (seq=" + ga.size() + ", par=" + gb.size() + ")"); + } + for (String probe : probes) { + assertEquals(select(da, probe), select(db, probe), "probe results must match: " + probe); + } + } + } + + /** + * Structural parity of the HDF5 trees: identical group/dataset names, + * identical dataset sizes, and identical attribute values everywhere. + * Counts and bit widths are bnode-order independent, so this must hold + * exactly even though raw bytes may differ (see the class comment). + */ + private static void assertSameStructure(Path seqH5, Path parH5) { + try (HdfFile seq = new HdfFile(seqH5); HdfFile par = new HdfFile(parH5)) { + compareGroups("/", (Group) seq.getChild(".BG"), (Group) par.getChild(".BG")); + } + } + + private static void compareGroups(String path, Group a, Group b) { + assertEquals(a != null, b != null, "group presence at " + path); + if (a == null) return; + compareAttributes(path, a, b); + Map ca = a.getChildren(); + Map cb = b.getChildren(); + assertEquals(new TreeSet<>(ca.keySet()), new TreeSet<>(cb.keySet()), "children of " + path); + for (String name : ca.keySet()) { + Node na = ca.get(name); + Node nb = cb.get(name); + assertEquals(na instanceof Group, nb instanceof Group, "node kind of " + path + name); + if (na instanceof Group ga) { + compareGroups(path + name + "/", ga, (Group) nb); + } else { + compareAttributes(path + name, na, nb); + assertEquals(((Dataset) na).getSize(), ((Dataset) nb).getSize(), + "dataset size of " + path + name); + } + } + } + + private static void compareAttributes(String path, Node a, Node b) { + Map attrsA = new HashMap<>(); + Map attrsB = new HashMap<>(); + for (Map.Entry e : a.getAttributes().entrySet()) { + attrsA.put(e.getKey(), e.getValue().getData()); + } + for (Map.Entry e : b.getAttributes().entrySet()) { + attrsB.put(e.getKey(), e.getValue().getData()); + } + assertEquals(attrsA, attrsB, "attributes of " + path); + } + + // ------------------------------------------------------------------ + // tests + // ------------------------------------------------------------------ + + @Test + void mixedTypesAndNamedGraphs() throws Exception { + String longStr = "y".repeat(150); + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:name "Alice" . + ex:s0 ex:name "Alice"@en . + ex:s0 ex:name "Alicia"@fr-CA . + ex:s0 ex:count "42"^^xsd:int . + ex:s0 ex:count "042"^^xsd:int . + ex:s0 ex:count2 "-7"^^xsd:int . + ex:s0 ex:big "9223372036854775806"^^xsd:long . + ex:s0 ex:neg "-9007199254740993"^^xsd:long . + ex:s0 ex:f "1.5"^^xsd:float . + ex:s0 ex:d "-2.25E8"^^xsd:double . + ex:s0 ex:unbounded "123456789012345678901234567890"^^xsd:integer . + ex:s0 ex:flag true . + ex:s0 ex:when "2024-05-06T07:08:09Z"^^xsd:dateTime . + ex:s0 ex:longstr "%s" . + ex:s0 ex:uni "h\\u00e9llo \\u00fcrld" . + ex:s0 ex:ill "abc"^^xsd:int . + <> ex:self . + _:b1 ex:p0 _:b2 . + _:b2 ex:knows ex:s0 . + ex:g1 { + ex:s1 ex:p0 ex:o0 . + ex:s1 ex:num "10"^^xsd:int . + _:b1 ex:inGraph ex:g1 . + } + ex:g2 { ex:s0 ex:p0 ex:s1 . } + """.formatted(longStr); + writeBoth("mixed.trig", trig, false, false); + Path seq = dir.resolve("mixed.trig.seq.h5"); + Path par = dir.resolve("mixed.trig.par.h5"); + assertStoresEquivalent(seq, par, + "SELECT ?p ?o WHERE { ?p ?o }", + "SELECT ?s WHERE { ?s }", + "SELECT ?s ?o WHERE { GRAPH { ?s ?o } }", + "SELECT ?o WHERE { ?o }", + "SELECT ?g ?s WHERE { GRAPH ?g { ?s } }"); + assertSameStructure(seq, par); + } + + @Test + void spatialAndFeaturesParity() throws Exception { + String trig = """ + @prefix ex: . + @prefix geo: . + + ex:geo1 geo:asWKT "POLYGON((0 0, 100 0, 100 100, 0 100, 0 0))"^^geo:wktLiteral . + ex:geo2 geo:asWKT "MULTIPOLYGON(((200 200, 300 200, 300 300, 200 200)),((600 600, 700 600, 700 700, 600 600)))"^^geo:wktLiteral . + ex:geo3 geo:asWKT "POINT(5000 6000)"^^geo:wktLiteral . + ex:geo4 geo:asWKT " POLYGON((10 10, 60 10, 60 60, 10 60, 10 10))"^^geo:wktLiteral . + ex:geo5 geo:asWKT "POLYGON((0 0, 1 0))"^^geo:wktLiteral . + ex:geo1 ex:label "region one" . + """; + writeBoth("spatial.trig", trig, true, true); + Path seq = dir.resolve("spatial.trig.seq.h5"); + Path par = dir.resolve("spatial.trig.par.h5"); + assertStoresEquivalent(seq, par, + "SELECT ?s ?p ?o WHERE { GRAPH { ?s ?p ?o } }", + "SELECT ?g WHERE { GRAPH ?g { ?p ?o } }"); + assertSameStructure(seq, par); + } + + @Test + void stressManyQuadsMatchesSequentialWriter() throws Exception { + StringBuilder nq = new StringBuilder(1 << 22); + for (int i = 0; i < 30_000; i++) { + String s = ""; + String p = ""; + String o = switch (i % 4) { + case 0 -> ""; + case 1 -> "\"str" + (i % 1000) + "\""; + case 2 -> "\"" + (i % 1000 - 500) + "\"^^"; + default -> "\"" + ((i % 100) / 8.0) + "\"^^"; + }; + nq.append(s).append(' ').append(p).append(' ').append(o); + if (i % 3 != 0) { + nq.append(" '); + } + nq.append(" .\n"); + } + writeBoth("stress.nq", nq.toString(), false, false); + Path seq = dir.resolve("stress.nq.seq.h5"); + Path par = dir.resolve("stress.nq.par.h5"); + assertStoresEquivalent(seq, par, + "SELECT ?s ?o WHERE { ?s ?o }", + "SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY ?g", + "SELECT ?s WHERE { GRAPH { ?s \"str101\" } }"); + assertSameStructure(seq, par); + } + + @Test + void singleCoreStillWorks() throws Exception { + String ttl = " .\n" + + " \"x\" .\n"; + File src = dir.resolve("one.ttl").toFile(); + Files.write(src.toPath(), ttl.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve("one.ttl.seq.h5").toFile(); + File par = dir.resolve("one.ttl.par.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq).build().write(); + ParallelHDF5Writer.Builder().setSource(src).setDestination(par).setCores(1).build().write(); + assertStoresEquivalent(seq.toPath(), par.toPath(), + "SELECT ?o WHERE { ?p ?o }"); + assertSameStructure(seq.toPath(), par.toPath()); + } + + /** + * The parallel index sorts quads by their dictionary-id tuple instead of + * comparing nodes. This pins the equivalence directly: for both orderings, + * mapping the NodeComparator-sorted quad sequence to id tuples yields + * exactly the id-sorted tuple sequence. Exercised against real parsed + * state - randomly-labeled VoID bnodes, aligned data bnodes, value-equal + * literals of different terms, langStrings, a bnode-labeled named graph - + * so every macro-ordering rule the claim rests on is present. + */ + @Test + void idTupleOrderMatchesNodeComparatorOrder() throws Exception { + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s ex:v "1"^^xsd:int . + ex:s ex:v "1"^^xsd:long . + ex:s ex:v "1.0"^^xsd:double . + ex:s ex:v "1"^^xsd:float . + ex:s ex:v "01"^^xsd:integer . + ex:s ex:w "a" . + ex:s ex:w "a"@en . + ex:s ex:w "a"@de . + ex:s ex:when "2020-01-02T00:00:00"^^xsd:dateTime . + ex:s ex:when "2020-01-02T08:00:00+14:00"^^xsd:dateTime . + _:x ex:p _:y . + _:y ex:p ex:s . + <> ex:self . + _:g { _:x ex:q "in bnode graph" . } + ex:g { ex:s ex:q _:y . } + """; + File src = dir.resolve("order.trig").toFile(); + Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); + + try (ParallelPositionalDictionaryWriter w = new ParallelPositionalDictionaryWriterBuilder() + .setSource(src) + .setDestination(dir.resolve("order.unused.h5").toFile()) + .setName(Params.DICTIONARY) + .buildParallel()) { + Quad[] quads = w.getQuads(); + for (Index type : Index.values()) { + Quad[] byNodes = quads.clone(); + Arrays.sort(byNodes, type.getComparator()); + long[][] mapped = Arrays.stream(byNodes) + .map(q -> tuple(w, type, q)) + .toArray(long[][]::new); + long[][] byIds = mapped.clone(); + Arrays.sort(byIds, Arrays::compare); + assertTrue(Arrays.deepEquals(mapped, byIds), + type + ": sorting by dictionary-id tuple must equal the NodeComparator quad order"); + } + } + } + + private static long[] tuple(ParallelPositionalDictionaryWriter w, Index type, Quad q) { + long[] t = new long[4]; + String order = type.name(); // e.g. "GSPO" + for (int i = 0; i < 4; i++) { + t[i] = switch (order.charAt(i)) { + case 'G' -> w.locateGraph(q.getGraph()); + case 'S' -> w.locateSubject(q.getSubject()); + case 'P' -> w.locatePredicate(q.getPredicate()); + case 'O' -> w.locateObject(q.getObject()); + default -> throw new IllegalStateException(); + }; + } + return t; + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidWriterParityTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidWriterParityTest.java new file mode 100644 index 00000000..246c5960 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidWriterParityTest.java @@ -0,0 +1,151 @@ +package com.ebremer.beakgraph.hdf5.writers.plaid; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import com.ebremer.beakgraph.huge.NativeHdf5File; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.rdf.model.Model; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The plaid writer's whole point is many-file merges with concurrent + * parsing, so the parity test IS a many-file merge: 24 documents (more than + * the parse workers, so workers cycle through several files and their row + * batches interleave nondeterministically), duplicates across files, shared + * terms, per-document _:b0 blank nodes, and named graphs - built with tiny + * spill batches, then compared against the sequential in-memory writer's + * merge of the same sources. + */ +class PlaidWriterParityTest { + + @TempDir + static Path dir; + + @BeforeAll + static void requireNativeHdf5() { + Assumptions.assumeTrue(NativeHdf5File.isAvailable(), "native HDF5 library unavailable"); + } + + private static Set graphNames(org.apache.jena.query.Dataset ds) { + Set names = new TreeSet<>(); + ds.asDatasetGraph().listGraphNodes().forEachRemaining(g -> names.add(g.toString())); + return names; + } + + private static Model graphModel(org.apache.jena.query.Dataset ds, String graphUri) { + String q = (graphUri == null) + ? "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + : "CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <" + graphUri + "> { ?s ?p ?o } }"; + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(q)).build()) { + return qe.execConstruct(); + } + } + + private static long count(org.apache.jena.query.Dataset ds, String query) { + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(query)).build()) { + return qe.execSelect().next().getLiteral("n").getLong(); + } + } + + @Test + void manyFileMergeMatchesSequentialMerge() throws Exception { + Path src = Files.createDirectories(dir.resolve("plaidsrc")); + List inputs = new ArrayList<>(); + for (int f = 0; f < 24; f++) { + StringBuilder nq = new StringBuilder(); + for (int i = 0; i < 400; i++) { + int k = f * 400 + i; + // Shared subjects/objects across files force cross-file dictionary dedup. + nq.append(" "); + nq.append(switch (k % 3) { + case 0 -> ""; + case 1 -> "\"v" + (k % 150) + "\""; + default -> "\"" + (k % 90 - 45) + "\"^^"; + }); + if (k % 4 == 0) { + nq.append(" '); + } + nq.append(" .\n"); + // Deliberate duplicates within and across files. + if (k % 50 == 0) { + nq.append(" .\n"); + } + } + // The SAME bnode label in every document: 24 distinct nodes. + nq.append("_:b0 \"doc").append(f).append("\" .\n"); + File file = src.resolve(String.format("part%02d.nq", f)).toFile(); + Files.write(file.toPath(), nq.toString().getBytes(StandardCharsets.UTF_8)); + inputs.add(file); + } + + File seq = dir.resolve("plaid.seq.h5").toFile(); + File plaid = dir.resolve("plaid.plaid.h5").toFile(); + HDF5Writer.Builder().setSources(inputs).setDestination(seq).build().write(); + PlaidHDF5Writer.Builder() + .setSources(inputs) + .setDestination(plaid) + .setWorkDirectory(Files.createDirectories(dir.resolve("plaidwork"))) + .setCores(6) // more files than workers: batches interleave heavily + .setTermSpillBatch(512) + .setIdSpillBatch(1024) + .setMergeFanIn(3) + .build() + .write(); + + try (BeakGraph a = new BeakGraph(new HDF5Reader(seq)); + BeakGraph b = new BeakGraph(new HDF5Reader(plaid))) { + org.apache.jena.query.Dataset da = a.getDataset(); + org.apache.jena.query.Dataset db = b.getDataset(); + assertEquals(graphNames(da), graphNames(db), "graph lists must match"); + assertTrue(graphModel(da, null).isIsomorphicWith(graphModel(db, null)), + "default graphs must be isomorphic"); + for (String g : graphNames(da)) { + if (!g.startsWith("http")) continue; + assertTrue(graphModel(da, g).isIsomorphicWith(graphModel(db, g)), + "graph <" + g + "> must be isomorphic"); + } + assertEquals(count(da, "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }"), + count(db, "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }"), + "default-graph size must match (duplicates collapsed identically)"); + assertEquals(24, count(db, "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE { ?b ?v }"), + "_:b0 from 24 documents must remain 24 distinct blank nodes"); + } + } + + @Test + void singleFileBuildStillWorks() throws Exception { + File src = dir.resolve("one.ttl").toFile(); + Files.write(src.toPath(), + " .\n \"x\" .\n" + .getBytes(StandardCharsets.UTF_8)); + File out = dir.resolve("one.plaid.h5").toFile(); + PlaidHDF5Writer.Builder() + .setSource(src) + .setDestination(out) + .setWorkDirectory(Files.createDirectories(dir.resolve("onework"))) + .setCores(2) + .build() + .write(); + try (BeakGraph bg = new BeakGraph(new HDF5Reader(out))) { + assertTrue(bg.find(org.apache.jena.graph.NodeFactory.createURI("http://ex.org/a"), + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/p"), + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/b")).hasNext()); + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraInternalsTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraInternalsTest.java new file mode 100644 index 00000000..5e70caac --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraInternalsTest.java @@ -0,0 +1,218 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.Dictionary; +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.io.ByteBufferBytes; +import java.io.File; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import org.apache.jena.graph.Node; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Direct verification of the ultra building blocks the end-to-end parity + * tests exercise only implicitly: the radix sort against the JDK sort + * (including the 2-word key path that small test stores never trigger), the + * 128-bit pack/extract round trip across the lo/hi straddle, byte-level + * compatibility of the positional buffers with the sequential bit-packed + * reader, and the id maps against the storage dictionaries' binary-search + * {@code locate()}. + */ +class UltraInternalsTest { + + @TempDir + Path dir; + + @Test + void radixSortSingleWordMatchesJdkSort() { + Random rnd = new Random(42); + int n = 200_000; // large enough to engage multi-chunk histograms + long[] keys = new long[n]; + for (int i = 0; i < n; i++) { + // Mixed magnitudes plus deliberate duplicates + keys[i] = switch (i % 4) { + case 0 -> rnd.nextLong() & 0x7FFFFFFFFFFFFFFFL; + case 1 -> rnd.nextInt(1000); + case 2 -> keys[Math.max(0, i - 2)]; + default -> rnd.nextLong() & 0xFFFFFFFFL; + }; + } + long[] expected = keys.clone(); + Arrays.sort(expected); + ForkJoinPool pool = new ForkJoinPool(3); + try { + long[][] r = ParallelRadixSort.sort(keys.clone(), null, 63, pool); + assertArrayEquals(expected, r[0]); + } finally { + pool.shutdown(); + } + } + + @Test + void radixSortTwoWordKeysMatchesReferenceSort() { + Random rnd = new Random(7); + int n = 100_000; + long[] lo = new long[n]; + long[] hi = new long[n]; + for (int i = 0; i < n; i++) { + lo[i] = rnd.nextLong(); + hi[i] = rnd.nextLong() & ((1L << 26) - 1); // 90-bit keys + if (i % 5 == 0 && i > 0) { // duplicates and hi-only ties + hi[i] = hi[i - 1]; + if (i % 10 == 0) lo[i] = lo[i - 1]; + } + } + long[][] rows = new long[n][2]; + for (int i = 0; i < n; i++) { + rows[i][0] = hi[i]; + rows[i][1] = lo[i]; + } + Arrays.sort(rows, Comparator.comparingLong(r -> r[0]).thenComparing(r -> r[1], Long::compareUnsigned)); + ForkJoinPool pool = new ForkJoinPool(3); + try { + long[][] r = ParallelRadixSort.sort(lo.clone(), hi.clone(), 90, pool); + for (int i = 0; i < n; i++) { + assertEquals(rows[i][0], r[1][i], "hi at " + i); + assertEquals(rows[i][1], r[0][i], "lo at " + i); + } + } finally { + pool.shutdown(); + } + } + + @Test + void packExtractRoundTripsAcrossTheWordBoundary() { + // 30+30+20+30 = 110 bits: k1 and k2 straddle or sit fully above the + // lo/hi boundary, covering every branch of extract(). + UltraBGIndex.Layout lay = new UltraBGIndex.Layout(30, 30, 20, 30); + Random rnd = new Random(11); + long[] lo = new long[1]; + long[] hi = new long[1]; + for (int t = 0; t < 10_000; t++) { + long v0 = rnd.nextLong() & ((1L << 30) - 1); + long v1 = rnd.nextLong() & ((1L << 30) - 1); + long v2 = rnd.nextLong() & ((1L << 20) - 1); + long v3 = rnd.nextLong() & ((1L << 30) - 1); + UltraBGIndex.pack(lo, hi, 0, v0, v1, v2, v3, lay); + assertEquals(v0, UltraBGIndex.extract(lo[0], hi[0], lay.off0(), lay.b0()), "k0"); + assertEquals(v1, UltraBGIndex.extract(lo[0], hi[0], lay.off1(), lay.b1()), "k1"); + assertEquals(v2, UltraBGIndex.extract(lo[0], hi[0], lay.off2(), lay.b2()), "k2"); + assertEquals(v3, UltraBGIndex.extract(lo[0], hi[0], lay.off3(), lay.b3()), "k3"); + } + // Single-word layouts must round-trip with hi == null. + UltraBGIndex.Layout small = new UltraBGIndex.Layout(10, 10, 5, 11); + UltraBGIndex.pack(lo, null, 0, 1023, 512, 31, 2047, small); + assertEquals(1023, UltraBGIndex.extract(lo[0], 0, small.off0(), small.b0())); + assertEquals(512, UltraBGIndex.extract(lo[0], 0, small.off1(), small.b1())); + assertEquals(31, UltraBGIndex.extract(lo[0], 0, small.off2(), small.b2())); + assertEquals(2047, UltraBGIndex.extract(lo[0], 0, small.off3(), small.b3())); + } + + /** + * The positional buffer's bytes must read back through the SEQUENTIAL + * bit-packed reader: that is exactly what the HDF5 readers do with these + * datasets, and it pins the big-endian byte-aligned layout. + */ + @Test + void packedBufferBytesReadBackThroughTheSequentialReader() { + Random rnd = new Random(3); + for (int width : new int[]{8, 16, 24, 40, 64}) { + int n = 257; + long[] values = new long[n]; + UltraPackedBuffer ultra = new UltraPackedBuffer("t", n, width); + for (int i = 0; i < n; i++) { + values[i] = (width == 64) ? rnd.nextLong() : (rnd.nextLong() & ((1L << width) - 1)); + ultra.set(i, values[i]); + } + BitPackedUnSignedLongBuffer reader = BitPackedUnSignedLongBuffer.readView( + new ByteBufferBytes(ByteBuffer.wrap(ultra.bytes())), n, width); + for (int i = 0; i < n; i++) { + assertEquals(values[i], reader.get(i), "width " + width + " entry " + i); + } + } + } + + @Test + void bitmapBytesReadBackThroughTheSequentialReader() { + Random rnd = new Random(9); + int n = 1003; // deliberately not a multiple of 8 or 64 + boolean[] bits = new boolean[n]; + UltraBitmap bitmap = new UltraBitmap("b", n); + for (int i = 0; i < n; i++) { + bits[i] = rnd.nextBoolean(); + if (bits[i]) { + bitmap.set(i); + } + } + assertEquals((n + 7) / 8, bitmap.toBytes().length); + BitPackedUnSignedLongBuffer reader = BitPackedUnSignedLongBuffer.readView( + new ByteBufferBytes(ByteBuffer.wrap(bitmap.toBytes())), n, 1); + for (int i = 0; i < n; i++) { + assertEquals(bits[i] ? 1 : 0, reader.get(i), "bit " + i); + } + } + + /** + * The rank maps ARE the dictionary: for every node, the O(1) map id must + * equal the storage dictionary's binary-search locate(). This is the + * invariant that lets the ultra writer never call locate() at all. + */ + @Test + void rankMapIdsMatchDictionaryLocate() throws Exception { + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s ex:v "1"^^xsd:int . + ex:s ex:v "1"^^xsd:long . + ex:s ex:v "1.0"^^xsd:double . + ex:s ex:v "01"^^xsd:integer . + ex:s ex:w "a" . + ex:s ex:w "a"@en . + ex:s ex:when "2020-01-02T00:00:00"^^xsd:dateTime . + _:x ex:p _:y . + _:y ex:p ex:s . + <> ex:self . + _:g { _:x ex:q "in bnode graph" . } + ex:g { ex:s ex:q _:y . } + """; + File src = dir.resolve("ids.trig").toFile(); + Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); + ForkJoinPool pool = new ForkJoinPool(2); + try { + UltraIngest ingest = new UltraIngest(); + ingest.setSource(src); + ingest.setDestination(dir.resolve("ids.unused.h5").toFile()); + ingest.setName(Params.DICTIONARY); + ingest.ingest(pool); + UltraDictionary dict = new UltraDictionary(ingest, pool); + Dictionary entities = (Dictionary) dict.entitiesDictionary(); + Dictionary predicates = (Dictionary) dict.predicatesDictionary(); + Dictionary literals = (Dictionary) dict.literalsDictionary(); + for (Node n : ingest.getEntities()) { + assertEquals(entities.locate(n), dict.locateGraph(n), "entity id of " + n); + assertEquals(entities.locate(n), dict.locateSubject(n), "entity id of " + n); + assertEquals(entities.locate(n), dict.locateObject(n), "object id of entity " + n); + } + for (Node n : ingest.getPredicates()) { + assertEquals(predicates.locate(n), dict.locatePredicate(n), "predicate id of " + n); + } + long maxEntityId = dict.getNumberOfGraphs(); + for (Node n : ingest.getLiterals()) { + assertEquals(literals.locate(n) + maxEntityId, dict.locateObject(n), "object id of literal " + n); + } + } finally { + pool.shutdown(); + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraWriterParityTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraWriterParityTest.java new file mode 100644 index 00000000..2f93e39a --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraWriterParityTest.java @@ -0,0 +1,308 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import io.jhdf.HdfFile; +import io.jhdf.api.Attribute; +import io.jhdf.api.Dataset; +import io.jhdf.api.Group; +import io.jhdf.api.Node; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.rdf.model.Model; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end parity of the ultra writer against the sequential writer, read + * back through the UNMODIFIED jHDF-based readers: same graph lists, isomorphic + * per-graph content, same query answers through both indexes, and - for + * single-source builds, where the ultra ingest reuses the sequential blank + * node labels verbatim - structurally identical HDF5 metadata (same dataset + * tree, sizes, and attributes). Raw bytes are never compared: the VoID + * generator mints fresh random blank-node labels on every write, so bytes + * legitimately differ between ANY two writes (same caveat as the parallel and + * huge parity tests). Multi-source merges use per-document blank-node labels, + * so the merge test asserts semantic equivalence only. + */ +class UltraWriterParityTest { + + @TempDir + static Path dir; + + // ------------------------------------------------------------------ + // helpers (the established parity bar of ParallelWriterParityTest) + // ------------------------------------------------------------------ + + private static Path writeBoth(String name, String content, boolean spatial, boolean features) throws Exception { + File src = dir.resolve(name).toFile(); + Files.write(src.toPath(), content.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve(name + ".seq.h5").toFile(); + File ult = dir.resolve(name + ".ultra.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq) + .setSpatial(spatial).setFeatures(features).build().write(); + UltraHDF5Writer.Builder().setSource(src).setDestination(ult) + .setSpatial(spatial).setFeatures(features) + // deliberately odd core count: shakes out anything that silently + // assumes the default or an even chunk split + .setCores(3) + .build().write(); + return dir; + } + + private static Set graphNames(org.apache.jena.query.Dataset ds) { + Set names = new TreeSet<>(); + ds.asDatasetGraph().listGraphNodes().forEachRemaining(g -> names.add(g.toString())); + return names; + } + + private static Model graphModel(org.apache.jena.query.Dataset ds, String graphUri) { + String q = (graphUri == null) + ? "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + : "CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <" + graphUri + "> { ?s ?p ?o } }"; + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(q)).build()) { + return qe.execConstruct(); + } + } + + private static java.util.List select(org.apache.jena.query.Dataset ds, String query) { + java.util.List rows = new java.util.ArrayList<>(); + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(query)).build()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) { + QuerySolution qs = rs.next(); + StringBuilder sb = new StringBuilder(); + rs.getResultVars().forEach(v -> sb.append(v).append('=').append(qs.get(v)).append('|')); + rows.add(sb.toString()); + } + } + rows.sort(String::compareTo); + return rows; + } + + private static void assertStoresEquivalent(Path seqH5, Path ultraH5, String... probes) throws Exception { + try (BeakGraph a = new BeakGraph(new HDF5Reader(seqH5.toFile())); + BeakGraph b = new BeakGraph(new HDF5Reader(ultraH5.toFile()))) { + org.apache.jena.query.Dataset da = a.getDataset(); + org.apache.jena.query.Dataset db = b.getDataset(); + + assertEquals(graphNames(da), graphNames(db), "graph lists must match"); + + Model defA = graphModel(da, null); + Model defB = graphModel(db, null); + assertTrue(defA.isIsomorphicWith(defB), + "default graphs must be isomorphic (seq=" + defA.size() + ", ultra=" + defB.size() + ")"); + for (String g : graphNames(da)) { + if (!g.startsWith("http") && !g.startsWith("urn")) continue; + Model ga = graphModel(da, g); + Model gb = graphModel(db, g); + assertTrue(ga.isIsomorphicWith(gb), + "graph <" + g + "> must be isomorphic (seq=" + ga.size() + ", ultra=" + gb.size() + ")"); + } + for (String probe : probes) { + assertEquals(select(da, probe), select(db, probe), "probe results must match: " + probe); + } + } + } + + private static void assertSameStructure(Path seqH5, Path ultraH5) { + try (HdfFile seq = new HdfFile(seqH5); HdfFile ult = new HdfFile(ultraH5)) { + compareGroups("/", (Group) seq.getChild(".BG"), (Group) ult.getChild(".BG")); + } + } + + private static void compareGroups(String path, Group a, Group b) { + assertEquals(a != null, b != null, "group presence at " + path); + if (a == null) return; + compareAttributes(path, a, b); + Map ca = a.getChildren(); + Map cb = b.getChildren(); + assertEquals(new TreeSet<>(ca.keySet()), new TreeSet<>(cb.keySet()), "children of " + path); + for (String name : ca.keySet()) { + Node na = ca.get(name); + Node nb = cb.get(name); + assertEquals(na instanceof Group, nb instanceof Group, "node kind of " + path + name); + if (na instanceof Group ga) { + compareGroups(path + name + "/", ga, (Group) nb); + } else { + compareAttributes(path + name, na, nb); + assertEquals(((Dataset) na).getSize(), ((Dataset) nb).getSize(), + "dataset size of " + path + name); + } + } + } + + private static void compareAttributes(String path, Node a, Node b) { + Map attrsA = new HashMap<>(); + Map attrsB = new HashMap<>(); + for (Map.Entry e : a.getAttributes().entrySet()) { + attrsA.put(e.getKey(), e.getValue().getData()); + } + for (Map.Entry e : b.getAttributes().entrySet()) { + attrsB.put(e.getKey(), e.getValue().getData()); + } + assertEquals(attrsA, attrsB, "attributes of " + path); + } + + // ------------------------------------------------------------------ + // tests + // ------------------------------------------------------------------ + + @Test + void mixedTypesAndNamedGraphs() throws Exception { + String longStr = "y".repeat(150); + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:name "Alice" . + ex:s0 ex:name "Alice"@en . + ex:s0 ex:name "Alicia"@fr-CA . + ex:s0 ex:count "42"^^xsd:int . + ex:s0 ex:count "042"^^xsd:int . + ex:s0 ex:count2 "-7"^^xsd:int . + ex:s0 ex:big "9223372036854775806"^^xsd:long . + ex:s0 ex:neg "-9007199254740993"^^xsd:long . + ex:s0 ex:f "1.5"^^xsd:float . + ex:s0 ex:d "-2.25E8"^^xsd:double . + ex:s0 ex:unbounded "123456789012345678901234567890"^^xsd:integer . + ex:s0 ex:flag true . + ex:s0 ex:when "2024-05-06T07:08:09Z"^^xsd:dateTime . + ex:s0 ex:longstr "%s" . + ex:s0 ex:uni "h\\u00e9llo \\u00fcrld" . + ex:s0 ex:ill "abc"^^xsd:int . + <> ex:self . + _:b1 ex:p0 _:b2 . + _:b2 ex:knows ex:s0 . + ex:g1 { + ex:s1 ex:p0 ex:o0 . + ex:s1 ex:num "10"^^xsd:int . + _:b1 ex:inGraph ex:g1 . + } + ex:g2 { ex:s0 ex:p0 ex:s1 . } + """.formatted(longStr); + writeBoth("mixed.trig", trig, false, false); + Path seq = dir.resolve("mixed.trig.seq.h5"); + Path ult = dir.resolve("mixed.trig.ultra.h5"); + assertStoresEquivalent(seq, ult, + "SELECT ?p ?o WHERE { ?p ?o }", + "SELECT ?s WHERE { ?s }", + "SELECT ?s ?o WHERE { GRAPH { ?s ?o } }", + "SELECT ?o WHERE { ?o }", + "SELECT ?g ?s WHERE { GRAPH ?g { ?s } }"); + assertSameStructure(seq, ult); + } + + @Test + void spatialAndFeaturesParity() throws Exception { + String trig = """ + @prefix ex: . + @prefix geo: . + + ex:geo1 geo:asWKT "POLYGON((0 0, 100 0, 100 100, 0 100, 0 0))"^^geo:wktLiteral . + ex:geo2 geo:asWKT "MULTIPOLYGON(((200 200, 300 200, 300 300, 200 200)),((600 600, 700 600, 700 700, 600 600)))"^^geo:wktLiteral . + ex:geo3 geo:asWKT "POINT(5000 6000)"^^geo:wktLiteral . + ex:geo4 geo:asWKT " POLYGON((10 10, 60 10, 60 60, 10 60, 10 10))"^^geo:wktLiteral . + ex:geo5 geo:asWKT "POLYGON((0 0, 1 0))"^^geo:wktLiteral . + ex:geo1 ex:label "region one" . + """; + writeBoth("spatial.trig", trig, true, true); + Path seq = dir.resolve("spatial.trig.seq.h5"); + Path ult = dir.resolve("spatial.trig.ultra.h5"); + assertStoresEquivalent(seq, ult, + "SELECT ?s ?p ?o WHERE { GRAPH { ?s ?p ?o } }", + "SELECT ?g WHERE { GRAPH ?g { ?p ?o } }"); + assertSameStructure(seq, ult); + } + + @Test + void stressManyQuadsMatchesSequentialWriter() throws Exception { + StringBuilder nq = new StringBuilder(1 << 22); + for (int i = 0; i < 30_000; i++) { + String s = ""; + String p = ""; + String o = switch (i % 4) { + case 0 -> ""; + case 1 -> "\"str" + (i % 1000) + "\""; + case 2 -> "\"" + (i % 1000 - 500) + "\"^^"; + default -> "\"" + ((i % 100) / 8.0) + "\"^^"; + }; + nq.append(s).append(' ').append(p).append(' ').append(o); + if (i % 3 != 0) { + nq.append(" '); + } + nq.append(" .\n"); + } + writeBoth("stress.nq", nq.toString(), false, false); + Path seq = dir.resolve("stress.nq.seq.h5"); + Path ult = dir.resolve("stress.nq.ultra.h5"); + assertStoresEquivalent(seq, ult, + "SELECT ?s ?o WHERE { ?s ?o }", + "SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY ?g", + "SELECT ?s WHERE { GRAPH { ?s \"str101\" } }"); + assertSameStructure(seq, ult); + } + + @Test + void singleCoreStillWorks() throws Exception { + String ttl = " .\n" + + " \"x\" .\n"; + File src = dir.resolve("one.ttl").toFile(); + Files.write(src.toPath(), ttl.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve("one.ttl.seq.h5").toFile(); + File ult = dir.resolve("one.ttl.ultra.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq).build().write(); + UltraHDF5Writer.Builder().setSource(src).setDestination(ult).setCores(1).build().write(); + assertStoresEquivalent(seq.toPath(), ult.toPath(), + "SELECT ?o WHERE { ?p ?o }"); + assertSameStructure(seq.toPath(), ult.toPath()); + } + + /** + * Merge parity: ultra's PARALLEL multi-document ingest against the + * sequential writer's one-after-another merge of the same tree. Blank node + * labels differ by design (per-document scoping vs a global counter), so + * the bar is semantic: same graphs, isomorphic content, same answers - + * including two documents that both say {@code _:b0} and must stay two + * distinct nodes. + */ + @Test + void mergeParityWithSequentialMerge() throws Exception { + Path src = Files.createDirectories(dir.resolve("mergesrc")); + Files.write(src.resolve("m1.ttl"), + " .\n_:b0 \"v1\" .\n" + .getBytes(StandardCharsets.UTF_8)); + Files.write(src.resolve("m2.nq"), + " .\n" + .getBytes(StandardCharsets.UTF_8)); + Files.write(src.resolve("m3.ttl"), + "_:b0 \"v2\" .\n .\n" + .getBytes(StandardCharsets.UTF_8)); + List inputs = List.of(src.resolve("m1.ttl").toFile(), src.resolve("m2.nq").toFile(), + src.resolve("m3.ttl").toFile()); + File seq = dir.resolve("merge.seq.h5").toFile(); + File ult = dir.resolve("merge.ultra.h5").toFile(); + HDF5Writer.Builder().setSources(inputs).setDestination(seq).build().write(); + UltraHDF5Writer.Builder().setSources(inputs).setDestination(ult).setCores(3).build().write(); + assertStoresEquivalent(seq.toPath(), ult.toPath(), + "SELECT ?o WHERE { ?o }", + "SELECT ?o WHERE { GRAPH { ?p ?o } }", + "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE { ?b ?v }"); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/huge/ExternalSorterTest.java b/src/test/java/com/ebremer/beakgraph/huge/ExternalSorterTest.java index 5e1a234b..c7fc3bbe 100644 --- a/src/test/java/com/ebremer/beakgraph/huge/ExternalSorterTest.java +++ b/src/test/java/com/ebremer/beakgraph/huge/ExternalSorterTest.java @@ -45,7 +45,7 @@ void multiLevelMergeSortsAndCleansUp() throws Exception { expected.sort(Comparator.naturalOrder()); assertEquals(n, sorter.size()); List actual = new ArrayList<>(n); - try (ExternalSorter.SortedStream s = sorter.sorted()) { + try (RecordSorter.SortedCursor s = sorter.sorted()) { while (s.hasNext()) { actual.add(s.next()); } @@ -64,7 +64,7 @@ void allInMemoryPathSkipsDisk() throws Exception { sorter.add(v); } List actual = new ArrayList<>(); - try (ExternalSorter.SortedStream s = sorter.sorted()) { + try (RecordSorter.SortedCursor s = sorter.sorted()) { s.forEachRemaining(actual::add); } assertEquals(List.of(1L, 3L, 3L, 5L, 9L), actual); diff --git a/src/test/java/com/ebremer/beakgraph/io/ChannelBytesTest.java b/src/test/java/com/ebremer/beakgraph/io/ChannelBytesTest.java new file mode 100644 index 00000000..10adfa6a --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/io/ChannelBytesTest.java @@ -0,0 +1,159 @@ +package com.ebremer.beakgraph.io; + +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * {@link ChannelBytes} must be observably identical to the other + * {@link RandomAccessBytes} implementations (see RandomAccessBytesParityTest + * for the ByteBuffer/FFM pair): every read at every offset - unaligned and + * boundary included - returns the same value, out-of-range offsets throw + * IndexOutOfBoundsException, and the window (base + size) is enforced even + * when the channel has more bytes. Per-thread scratch buffers make + * concurrent primitive reads safe; that is exercised too. + */ +class ChannelBytesTest { + + @TempDir + static Path dir; + + @Test + void agreesWithByteBufferBytesAtEveryOffset() throws Exception { + Random rnd = new Random(42); + byte[] data = new byte[64 * 1024 + 13]; // deliberately not power-of-two + rnd.nextBytes(data); + Path f = dir.resolve("channel.bin"); + Files.write(f, data); + + try (FileChannel fc = FileChannel.open(f, StandardOpenOption.READ)) { + RandomAccessBytes bb = new ByteBufferBytes(ByteBuffer.wrap(data)); + RandomAccessBytes ch = new ChannelBytes(fc, 0, data.length); + + assertEquals(data.length, ch.size()); + + for (int i = 0; i < 256; i++) { + checkAt(bb, ch, i, data.length); + checkAt(bb, ch, data.length - 1 - i, data.length); + } + for (int i = 0; i < 2_000; i++) { + checkAt(bb, ch, rnd.nextLong(data.length), data.length); + } + + byte[] a = new byte[1024], b = new byte[1024]; + for (int i = 0; i < 100; i++) { + long off = rnd.nextLong(data.length - a.length); + bb.get(off, a, 0, a.length); + ch.get(off, b, 0, b.length); + assertArrayEquals(a, b, "bulk @" + off); + } + byte[] whole = new byte[data.length]; + ch.get(0, whole, 0, whole.length); + assertArrayEquals(data, whole, "full bulk read through the channel"); + + assertThrows(IndexOutOfBoundsException.class, () -> ch.get(data.length)); + assertThrows(IndexOutOfBoundsException.class, () -> ch.getLong(data.length - 4)); + assertThrows(IndexOutOfBoundsException.class, () -> ch.get(-1)); + assertThrows(IndexOutOfBoundsException.class, + () -> ch.get(data.length - 8, new byte[16], 0, 16)); + } + } + + @Test + void nonZeroBaseReadsTheRightWindowAndNothingBeyondIt() throws Exception { + Random rnd = new Random(7); + byte[] data = new byte[4096]; + rnd.nextBytes(data); + Path f = dir.resolve("window.bin"); + Files.write(f, data); + + int base = 1234; // like a dataset's file address + int size = 2000; // window ends before the file does + try (FileChannel fc = FileChannel.open(f, StandardOpenOption.READ)) { + RandomAccessBytes ch = new ChannelBytes(fc, base, size); + assertEquals(size, ch.size()); + for (int i = 0; i < size; i++) { + assertEquals(data[base + i], ch.get(i), "byte @" + i); + } + // the file continues past the window; the view must not + assertThrows(IndexOutOfBoundsException.class, () -> ch.get(size)); + assertThrows(IndexOutOfBoundsException.class, () -> ch.getLong(size - 7)); + } + } + + @Test + void windowPastChannelEndSurfacesAsUncheckedIOException() throws Exception { + byte[] data = new byte[100]; + Path f = dir.resolve("short.bin"); + Files.write(f, data); + try (FileChannel fc = FileChannel.open(f, StandardOpenOption.READ)) { + // A lying window: in range for the view, but the channel has no bytes there. + RandomAccessBytes ch = new ChannelBytes(fc, 0, 200); + assertThrows(UncheckedIOException.class, () -> ch.get(150)); + } + } + + @Test + void concurrentPrimitiveReadsAgree() throws Exception { + Random rnd = new Random(1); + byte[] data = new byte[32 * 1024]; + rnd.nextBytes(data); + Path f = dir.resolve("concurrent.bin"); + Files.write(f, data); + + try (FileChannel fc = FileChannel.open(f, StandardOpenOption.READ)) { + RandomAccessBytes expected = new ByteBufferBytes(ByteBuffer.wrap(data)); + RandomAccessBytes ch = new ChannelBytes(fc, 0, data.length); + try (ExecutorService pool = Executors.newFixedThreadPool(8)) { + List> tasks = new ArrayList<>(); + for (int t = 0; t < 8; t++) { + long seed = 1000L + t; + tasks.add(() -> { + Random r = new Random(seed); + for (int i = 0; i < 2_000; i++) { + long off = r.nextLong(data.length - Long.BYTES); + if (expected.getLong(off) != ch.getLong(off) + || expected.get(off) != ch.get(off)) { + return false; + } + } + return true; + }); + } + for (Future result : pool.invokeAll(tasks)) { + assertTrue(result.get(), "concurrent reads must match"); + } + } + } + } + + private static void checkAt(RandomAccessBytes bb, RandomAccessBytes ch, long off, int len) { + if (off < 0 || off >= len) { + return; + } + assertEquals(bb.get(off), ch.get(off), "byte @" + off); + if (off + Long.BYTES <= len) { + assertEquals(bb.getLong(off), ch.getLong(off), "long @" + off); + assertEquals(bb.getDouble(off), ch.getDouble(off), "double @" + off); + } + if (off + Float.BYTES <= len) { + assertEquals(bb.getFloat(off), ch.getFloat(off), "float @" + off); + } + } +}