diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8c07f..8407a10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,100 +1,136 @@ -name: Run Workflow and Deploy to GitHub Pages +name: Benchmark & Deploy to GitHub Pages permissions: contents: write - on: push: - branches: - - main # Run the workflow on pushes to the main branch - - p16-benchmark # Run the workflow on pushes to the p16-benchmark branch - pull_request: - branches: - - main # Optionally run on pull requests to the main branch - - p16-benchmark # Optionally run on pull requests to the p16-benchmark branch - + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + inputs: + dataset: + description: 'Dataset to benchmark' + required: false + default: 'dbpedia' + type: choice + options: [dbpedia, bowlogna, bsbm, lubm] + engines: + description: 'Comma-separated engines (e.g. corese.4.6.5,jena.6.2.0)' + required: false + default: 'corese.4.6.5,jena.6.2.0,rdf4j.5.3.2' + scales: + description: 'Dataset scales' + required: false + default: 'small,medium,large' + inference: + description: 'Enable RDFS inference (Corese only)' + required: false + default: false + type: boolean jobs: - ##################################### - # using Github public runner - ##################################### - public-build: - if: github.ref != 'refs/heads/p16-benchmark' - #runs-on: p16-benchmark-corese - runs-on: ubuntu-latest # Use the latest Ubuntu runner + + # ─── Smoke test on PR (GitHub-hosted, fast) ─────────────────────────────── + smoke-test: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up conda + uses: conda-incubator/setup-miniconda@v3 + with: + environment-file: python-utils/environment.yml + activate-environment: benchmarkenv + auto-activate-base: false + + - name: Run smoke test (dbpedia small only) + shell: bash -l {0} + run: | + python python-utils/workflow.py \ + --engines="corese.4.6.5,jena.6.2.0,rdf4j.5.3.2" \ + --dataset="dbpedia" \ + --scales="small" + + - name: Check site/data output + run: ls -la ./site/data/ + + # ─── Full benchmark on merge to main (P16 self-hosted) ──────────────────── + benchmark-node: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: [self-hosted, corese-benchmark] steps: - # Step 1: Checkout the repository - - name: Checkout repository - uses: actions/checkout@v3 - - # Step 2.1: Create and activate conda environment, install dependencies - - name: Set up conda environment - uses: conda-incubator/setup-miniconda@v3 - with: - environment-file: python-utils/environment.yml - activate-environment: benchmarkenv - auto-activate-base: false - - # Step 2.2: Create and activate conda environment, install dependencies - - name: Run workflow - shell: bash -l {0} - env: - RUNNER_ENV: github-public - run: | - conda info --envs - cd python-utils - python workflow.py --triplestoreNames="rdf4j.5.1.2,jena.5.4.0,corese.4.6.3" - - - - # Step 3 : Debugging step: Check if the public directory exists - - name: Check public directory is here and contains required files - run: ls -la ./public - - # Step 4: Deploy the dashboard folder to GitHub Pages - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./public # Path to the folder containing the HTML files - force_orphan: true # Overwrite the gh-pages branch - - - ##################################### - # using P16 private runner - ##################################### - private-build: - if: github.ref == 'refs/heads/p16-benchmark' - runs-on: p16-benchmark-corese + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up conda environment + run: | + CONDA_SH=$(find $HOME -name "conda.sh" -path "*/profile.d/conda.sh" 2>/dev/null | head -1) + source "$CONDA_SH" + conda env update -f python-utils/environment.yml --prune + + - name: Run full benchmark + run: | + CONDA_SH=$(find $HOME -name "conda.sh" -path "*/profile.d/conda.sh" 2>/dev/null | head -1) + source "$CONDA_SH" + conda activate benchmarkenv + python python-utils/workflow.py \ + --engines="corese.4.6.5,jena.6.2.0,rdf4j.5.3.2" \ + --dataset="dbpedia" + + - name: Check site/data output + run: ls -la ./site/data/ + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site + force_orphan: true + + # ─── Manual run (workflow_dispatch) ─────────────────────────────────────── + benchmark-manual: + if: github.event_name == 'workflow_dispatch' + runs-on: [self-hosted, corese-benchmark] steps: - # Step 1: Checkout the repository - - name: Checkout repository - uses: actions/checkout@v3 - - # Step 2: Create and activate conda environment, install dependencies - - name: Create and activate conda environment - env: - RUNNER_ENV: P16-benchmark - run: | - source ~/miniconda3/bin/activate - conda env update -f python-utils/environment.yml - source $HOME/miniconda3/etc/profile.d/conda.sh - conda activate benchmarkenv - conda info --envs - cd python-utils - python workflow.py --triplestoreNames="rdf4j.5.1.2,jena.5.4.0,corese.4.6.3" --dataDir="/media/storage/corese-benchmark/benchmark_data" - - # Debugging step: Check if the public directory exists - - name: Check public directory is here and contains required files - run: ls -la ./public - - # Step 3: Deploy the dashboard folder to GitHub Pages - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./public # Path to the folder containing the HTML files - force_orphan: true # Overwrite the gh-pages branch \ No newline at end of file + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up conda environment + run: | + CONDA_SH=$(find $HOME -name "conda.sh" -path "*/profile.d/conda.sh" 2>/dev/null | head -1) + source "$CONDA_SH" + conda env update -f python-utils/environment.yml --prune + + - name: Run benchmark + run: | + CONDA_SH=$(find $HOME -name "conda.sh" -path "*/profile.d/conda.sh" 2>/dev/null | head -1) + source "$CONDA_SH" + conda activate benchmarkenv + python python-utils/workflow.py \ + --engines="${{ github.event.inputs.engines }}" \ + --dataset="${{ github.event.inputs.dataset }}" \ + --scales="${{ github.event.inputs.scales }}" \ + ${{ github.event.inputs.inference == 'true' && '--inference' || '' }} + + - name: Check site/data output + run: ls -la ./site/data/ + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site + force_orphan: true diff --git a/.gitignore b/.gitignore index 7336a54..4850721 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,29 @@ .DS_Store tmp -# Ignore Gradle project-specific cache directory -.gradle -# Ignore Gradle build output directory +# Gradle +.gradle build -input -out -public + +# Benchmark data (downloaded datasets, generated reports) +input/ +out/ +public/ +commit/ + +# Local Corese JAR (copied by workflow.py) libs/* -commit/* +!libs/.gitkeep + +# Generated site data (rebuilt on every run) +site/data/benchmark-report.json +site/data/benchmark-report.csv +site/data/report-data.js +site/data/history-data.js +site/data/history/ + +# Python +__pycache__/ +*.pyc +*.pyo +python-utils/datasets/__pycache__/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..a0ccf77 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Environment-dependent path to Maven home directory +/mavenHomeManager.xml diff --git a/.idea/claudeCodeEditorTabs.xml b/.idea/claudeCodeEditorTabs.xml new file mode 100644 index 0000000..057cca1 --- /dev/null +++ b/.idea/claudeCodeEditorTabs.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..7d3b3e8 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..03872dd --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bf49f59 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +# corese-benchmark — common workflow commands +# Usage: make +# Requires: conda env 'benchmarkenv' active, or run `make env` first + +PYTHON := python +WORKFLOW := python-utils/workflow.py +ENGINES ?= corese.local,corese.4.6.5,jena.6.2.0,rdf4j.5.3.2 +DATASET ?= dbpedia +DATASETS ?= dbpedia,bowlogna,bsbm,lubm +SCALES ?= small + +.PHONY: help env regression compare next bowlogna lubm bsbm all site clean-out + +help: + @echo "" + @echo " make regression Detect a Corese regression (local vs 4.6.5)" + @echo " make compare Compare corese + jena + rdf4j on dbpedia" + @echo " make next Benchmark corese.next vs corese.4.6.5" + @echo " make bowlogna Bowlogna dataset with SPARQL queries (corese.local vs 4.6.5)" + @echo " make lubm LUBM dataset with SPARQL queries" + @echo " make bsbm BSBM dataset" + @echo " make all All datasets (dbpedia + bowlogna + bsbm + lubm) in one run" + @echo " make site Serve the dashboard at http://localhost:8080" + @echo " make env Create conda environment" + @echo " make clean-out Delete all generated reports in out/" + @echo "" + @echo " Override engines: make regression ENGINES=corese.local,corese.4.6.5" + @echo " Override dataset: make compare DATASET=bowlogna" + @echo " Override datasets: make all DATASETS=dbpedia,bowlogna" + @echo " Override scales: make bowlogna SCALES=small,medium" + @echo "" + +env: + conda env create -f python-utils/environment.yml + @echo "Run: conda activate benchmarkenv" + +regression: + $(PYTHON) $(WORKFLOW) --engines corese.local,corese.4.6.5 --dataset $(DATASET) + +compare: + $(PYTHON) $(WORKFLOW) --engines $(ENGINES) --dataset $(DATASET) + +next: + $(PYTHON) $(WORKFLOW) --engines corese.next,corese.4.6.5 --dataset $(DATASET) + +bowlogna: + $(PYTHON) $(WORKFLOW) --engines corese.local,corese.4.6.5 --dataset bowlogna --scales $(SCALES) + +lubm: + $(PYTHON) $(WORKFLOW) --engines corese.local,corese.4.6.5 --dataset lubm --scales $(SCALES) + +bsbm: + $(PYTHON) $(WORKFLOW) --engines $(ENGINES) --dataset bsbm --scales $(SCALES) + +all: + $(PYTHON) $(WORKFLOW) --engines $(ENGINES) --datasets $(DATASETS) --scales $(SCALES) + +site: + @echo "Dashboard: http://localhost:8080" + cd site && $(PYTHON) -m http.server 8080 + +clean-out: + rm -rf out/json/*.json out/csv/*.csv + @echo "Reports deleted." diff --git a/README.md b/README.md index 00a8157..ec0fe89 100644 --- a/README.md +++ b/README.md @@ -1,309 +1,266 @@ -corese-benchmark -================= - - -## Table of Contents - -- [corese-benchmark](#corese-benchmark) - - [Table of Contents](#table-of-contents) - - [Description](#description) - - [Links to dashboard](#links-to-dashboard) - - [Organisation of the repository](#organisation-of-the-repository) - - [HOW TO run it](#how-to-run-it) - - [Run the workflow.py automation script](#run-the-workflowpy-automation-script) - - [A) launch the script with triplestore names](#a-launch-the-script-with-triplestore-names) - - [B) launch the script with corese versions names](#b-launch-the-script-with-corese-versions-names) - - [C) launch the script with corese-core's repository commit hashes](#c-launch-the-script-with-corese-cores-repository-commit-hashes) - - [Run the benchmark.groovy alone](#run-the-benchmarkgroovy-alone) - - [A - comparing triple stores versions](#a---comparing-triple-stores-versions) - - [B - comparing corese versions names](#b---comparing-corese-versions-names) - - [C - comparing corese commits](#c---comparing-corese-commits) - - [Run the plot-compare.py alone](#run-the-plot-comparepy-alone) - - [CI](#ci) - - [Datasets](#datasets) - - [Bowlogna](#bowlogna) - - [DBPedia sample](#dbpedia-sample) - - [Metrics for measuring and comparing triple stores performance](#metrics-for-measuring-and-comparing-triple-stores-performance) - - [Typical Configuration used](#typical-configuration-used) - - [Designing test suite for RDF parsers](#designing-test-suite-for-rdf-parsers) - - - -## Description - -This repository offers some tools and utilities to run a benchmark of a set of java-based triple stores, namely Corese, RDF4J and Jena. Its aim is to -- compare Corese with the other triplestore -- compare different versions of Corese - -Its principles are -* Focusing on performance measurements, such as loading time, memory used, query time, number of threads/CPU, etc. -* Using native core java libraries instead of server versions of the triplestore. The code is written in Groovy, which is one of java's scripting language available. -* Producing reusable CSV exports of the performance measurements that can be used in other contexts. -* Building upon existing RDF or SPARQL benchmarks such as - * Bowlogna SPARQL Benchmark - * BSBM Berlin SPARQL Benchmark - * DBPedia datasets - * etc. - -## Links to dashboard - -* The minisite with dynamic versions of the plots is available at [https://corese-stack.github.io/corese-benchmark/](https://corese-stack.github.io/corese-benchmark/) -* You can also have a look at the image version of the plots in the [dashboard](./dashboard/README.md) folder. See below [HOW TO run it](#how-to-run-it) section to run locally the benchmark and generate a new version of the plots. - - -## Organisation of the repository - -There are 2 main parts of the code: -* The groovy/java code - * is versionned in `src` folder - * processes the input data using the 3 triplestores : loading, and querying (WIP) - * saves the CSV containing the measurements in the `out` folder. Examples of previous run are already given. -* workflow automation code written in Python, and versionned in `python-utils` folder. The main steps automatized are : - * 1) creating `input` folder, downloading and saving input data in it - * 2) launching the `benchmark.groovy` script - * 3) launching the `plot-compare.py` script which saves the plot files in `public` folder -* 2 versions of the workflow are available: - * `workflow.py` to compare 3 given versions of the 3 triplestores - * `workflow-corese-versions.py` to compare 2 or more given version of Corese. - -* The latest results that we version in this repo are visible in the [dashboard](./dashboard/README.md) folder. If you run it by yourself, updated plots will be saved in this folder. - -## HOW TO run it - - -### Run the workflow.py automation script - -The benchmark's workflow is implemented as a Python script. - -* 1st install dependencies defined in [python-utils/environment.yml](./python-utils/environment.yml) using conda => see [python-utils](./python-utils/README.md) -* activate python environment +# corese-benchmark + +Benchmark framework for comparing RDF triple stores: **Corese**, **Apache Jena**, and **Eclipse RDF4J**. + + +## Description + +This repository benchmarks Java-based RDF triple stores on loading and SPARQL query performance. Goals: + +- Compare **Corese** against Apache Jena and RDF4J +- Detect **regressions** between `corese.local` (current branch) and `corese.4.x.x` (stable) +- Compare **different versions** of Corese (including `corese-next`) +- Produce JSON + CSV reports consumed by an interactive web dashboard + +**Principles:** +- Java 21 harness with a rigorous measurement protocol (warmup + 3 repetitions + median) +- Memory measured as delta before/after loading with forced GC +- Automatic JSON + CSV output +- Run history for trend tracking +- Python orchestration for dataset download, build, and deployment + +--- + +## Dashboard + +The interactive benchmark dashboard is published at: +**[https://corese-stack.github.io/corese-benchmark/](https://corese-stack.github.io/corese-benchmark/)** + +To view it locally, open `site/index.html` in a browser. + +--- + + +## How to Run + +### Prerequisites + +- Java 21+ +- Conda (for Python orchestration) + ```bash +conda env create -f python-utils/environment.yml conda activate benchmarkenv -(benchmarkenv)cd python-utils ``` -Once the dependencies installed, you have to choose between 3 ways to use it (see output examples in the [dashboard_examples](./example_outputs/dashboard_examples/) to see examples results for one of each way) +--- +### A) Detect a Corese regression (corese.local) -#### A) launch the script with triplestore names +Main use case: compare your local branch against the stable release. -You can specify your own combination of versions for each of the three supported triplestores, with the argument `--triplestoreNames` : +**1. Build corese-core locally** +Inside the `corese-core` repository: ```bash -# with 2 -(benchmarkenv)python workflow.py --triplestoreNames="jena.4.10.0,corese.4.6.3" -# Or with 3 -(benchmarkenv)python workflow.py --triplestoreNames="rdf4j.5.1.2,jena.5.4.0,corese.4.6.2" +./gradlew shadowJar ``` -* The given `--triplestoreNames` parameter is a comma-separated list of triplestore names and versions (e.g., 'rdf4j.5.1.2,jena.5.4.0,corese.4.6.3'). -* Each name should be in the format 'name.version'. where 'name' is one of 'rdf4j', 'corese', or 'jena' and 'version' is the version number (e.g., '5.1.2', '4.6.3', '4.10.0').", -* This version of the script compiles the groovy benchmark only once; -* Each combination can only contain one version of a triplestore. Eg 'jena.5.10.0,jena.5.4.0,corese.4.6.3' is not a valid combination. +**2. Run the benchmark** -#### B) launch the script with corese versions names +If `corese-core` is in the sibling directory (`../corese-core/`), the JAR is detected automatically: -This version of the workflow tests only differet versions of corese, one of which can be a local compiled jar. +```bash +conda activate benchmarkenv +cd corese-benchmark +python python-utils/workflow.py --engines corese.local,corese.4.6.5,jena.6.2.0,rdf4j.5.3.2 +``` + +If `corese-core` is elsewhere, use `--coreseLocalPath`: ```bash -(benchmarkenv)python workflow.py --coreseVersions="4.6.2,4.6.3" -# OR -(benchmarkenv)python workflow.py --coreseVersions="4.0.1,4.6.3,local" +python python-utils/workflow.py \ + --engines corese.local,corese.4.6.5 \ + --coreseLocalPath C:\path\to\corese-core ``` -* You can test any tagged and released on Maven version of Corese this way -* if you want to include a local version: - - add "local" in the coreseVersions list - - put the jar of the corese-core version in a directory at the root of the project that you have to name as "libs" and create if not existing. +**3. Read the result** + +Open `site/index.html`. The `corese.local` card shows: +- ` +X% improvement` (green) +- ` −X% regression` (red) -#### C) launch the script with corese-core's repository commit hashes +relative to `corese.4.6.5` as the fixed reference (100/100). -This version of the workflow allows to compare different versions of Corese only, but at a finer granularity, the commits of the github repository: +--- + +### B) Compare multiple engines ```bash -(benchmarkenv)python workflow.py --coreseCommits='a17f3d6,b089a03,6efa666' -# OR -(benchmarkenv)python workflow.py --coreseCommits='a17f3d6,b089a03' +python python-utils/workflow.py --engines corese.4.6.5,jena.6.2.0,rdf4j.5.3.2 ``` -* You can test any [commit](https://github.com/corese-stack/corese-core/commits/a17f3d60846db6794b12f723888c7c12ceef4cd7/) of the [corese-core github repository](https://github.com/corese-stack/corese-core) this way. -* For each commit hash provided in the list, the script downloads the whole repository code of Corese-Core and compiles it before running the benchmark itself +Default dataset: `dbpedia`. To change: +```bash +python python-utils/workflow.py --engines corese.4.6.5,jena.6.2.0 --dataset bowlogna +``` -### Run the benchmark.groovy alone +--- -#### A - comparing triple stores versions +### C) Compare Corese versions -* 1st build the execution environement +One build per Corese version: -Choose first the versions you want to compare, eg -- jena 4.6.1 -- rdf4j 5.1.2 -- corese 4.6.1 +```bash +python python-utils/workflow.py --coreseVersions 4.6.5,4.6.3 +``` -Then issue the command specifying the version number for each of the triplestore this way : `-P{tsName}Version={versionNumber}` +--- -```bash -./gradlew clean build -Prdf4jVersion=5.1.2 -PjenaVersion=4.6.1 -PcoreseVersion=4.6.1 +### D) Compare Corese commits -``` -* then run it without forgetting to give the path to the input directory, the path to the output directory, and the list of triplestore names, eg: +Clones, checks out, and compiles each commit from GitHub: ```bash -./gradlew runGroovyScript --args="/path/to/directory /path/to/outdirectory rdf4j.5.1.2,jena.4.6.1,corese.4.6.1" -# for example -./gradlew runGroovyScript --args="input/unzip out rdf4j.5.1.2,jena.4.6.1,corese.4.6.1" +python python-utils/workflow.py --coreseCommits a17f3d6,b089a03 ``` -#### B - comparing corese versions names +--- -To do this one has to build and run the benchmark as many times as the number of Corese's version you want to compare. +### E) Benchmark with SPARQL queries -E.g to compare 4.6.2 with 4.6.3 : +For datasets that have associated queries (e.g. Bowlogna): ```bash -gradlew clean build -PcoreseVersion=4.6.2 -./gradlew runGroovyScript --args="input/unzip out corese.4.6.1" -gradlew clean build -PcoreseVersion=4.6.3 -./gradlew runGroovyScript --args="input/unzip out corese.4.6.3" +python python-utils/workflow.py --engines corese.local,corese.4.6.5 --dataset bowlogna ``` -#### C - comparing corese commits +The **SPARQL Queries** section appears in the dashboard with: +- Per-engine latency cards: avg, p50, p95, p99 +- Median time per query and per engine +- Color-coded comparison table (green = best, orange = ×5–19, red = ×20+) + +--- -E.g `a17f3d6` and `b089a03`. Again, to run this manually, one has to loop through the commits value and do: +### Run the Java harness directly ```bash -#create/clear a "commit" directory -mkdir -p commit - -# cd into it, and git clone the commit from Corese core repo -cd commit/ -git clone https://github.com/corese-stack/corese-core/ -cd corese-core -git checkout a17f3d6 -# build corese -./gradlew shadowJar +# Build +./gradlew clean build -PcoreseVersion=4.6.5 -PjenaVersion=6.2.0 -Prdf4jVersion=5.3.2 -# cd 2 levels above, and build the corese benchmark with this newly built corese-core -cd ../.. -./gradlew clean build -PcoreseCommit=./commit/corese-core/build/libs/ -./gradlew runGroovyScript --args="input/unzip out corese.commit.a17f3d6" +# Run (without queries) +./gradlew runBenchmark --args="input/dbpedia/unzip out/json/report.json corese.4.6.5,jena.6.2.0 dbpedia" +# Run (with queries) +./gradlew runBenchmark --args="input/dbpedia/unzip out/json/report.json corese.4.6.5 dbpedia queries.txt" ``` -### Run the plot-compare.py alone +--- -Once any of the above method has been followed up to the execution of the groovy benchmark, the output folder should contains a list of CSV files, one for each triple-store's version/commit, which contain the loading time and memory usage for each dataset loaded. The plot-compare.py script will go through these file and generate a graphical plot of these values and export them as an HTML file. +## Outputs -To run it, assuming the python environment `benchmarkenv` has been actived: -```bash -(benchmarkenv)cd python-utils -(benchmarkenv)python plot-compare.py -# or optionaly indicating the folder to read the CSV files from -(benchmarkenv)python plot-compare.py outputdirectory -``` +Each run automatically generates: -It will loop throught the content of the given directory and plots the loading time and memory usage and generate -- an html version of the plot -- a index.html file to be used as the dashboard +| File | Description | +|------|-------------| +| `out/json/_.json` | Full report (loaded by the dashboard) | +| `out/csv/_.csv` | Same data in CSV format (Excel, etc.) | +| `site/data/benchmark-report.json` | Copy of the latest report (current dashboard) | +| `site/data/benchmark-report.csv` | Copy of the latest CSV (download button) | +| `site/data/history/.json` | History entry (last 30 runs kept) | +| `site/data/history-data.js` | Inline history (trend chart) | -See some examples [here](./example_outputs/dashboard_examples/) +The **historical trend chart** appears in the dashboard once 2 or more runs are available. -## CI +--- -A CI script is available at [.github/workflows/ci.yml)](.github/workflows/ci.yml) +## Measurement Protocol -It is programmed to run the full benchmark through the workflow.py python script. 2 jobs can be triggered depending on the branch pushed +For each file and each engine: -1. `public-build` on `main` branch : this one uses a vanilla public runner on github -2. `private-build` on `p16-benchmark` branch : this one uses a private runner nammed `p16-benchmark-corese` +1. **Warmup** — load → clear → GC (result discarded) +2. **3 repetitions**: + - Forced GC + memory snapshot before + - Load file + - Forced GC + memory snapshot after + - Record time and memory delta +3. **Keep the median** of the 3 repetitions -The execution is so far the same, but can be further customised using the parameters of the workflow.py script. The main differences remaining at the level of the dependencies installation, ie in the installation and activation of the conda environment. +This protocol ensures reproducible results by eliminating JIT compilation bias and GC timing noise. +**Note on `clear()`:** RDF4J performs a full repository reinitialisation between repetitions (rather than just `conn.clear()`) to release internal SAIL memory structures and avoid negative memory delta artefacts caused by delayed GC. -## Datasets +--- -### Bowlogna +## Metrics -* Bowlogna becnhmark dataset (from [this link](https://files.dice-research.org/projects/HOBBIT/benchmarks-data/datasets-dumps/bowlogna-dump.zip)) -* Synthetic dataset built according a model describing relations between students, universities, and course programs. -* It's made of 10 files, formally equivalent, but containing each different data. Each file loaded adds ~1.2 million triples -* Total size ~12 Millions triples -* Reference article : [SIMPDA2011 paper](https://exascale.info/assets/pdf/BowlognaBenchSIMPDA2011.pdf) +| Metric | Description | +|--------|-------------| +| `loadingTimeMs` | Median loading time over 3 repetitions (ms) | +| `throughputTriplesPerSec` | Triples loaded per second | +| `memoryDeltaMb` | Memory consumed by loading (after − before GC, MB) | +| `cpuPercent` | JVM CPU usage during loading (via `OperatingSystemMXBean`) | +| `repetitionsMs` | Raw values of the 3 repetitions (for transparency) | -### DBPedia sample +--- -* DBPedia is an RDF translation effort of Wikipedia -* We sampled 10 files from the dump folder available online : https://downloads.dbpedia.org/3.5.1/en/ - - redirects_en.nt - - disambiguations_en.nt - - homepages_en.nt - - geo_coordinates_en.nt - - instance_types_en.nt - - category_labels_en.nt - - skos_categories_en.nt - - images_en.nt - - specific_mappingbased_properties_en.nt - - persondata_en.nt -* total size is ~20 Millions triples +## Datasets -## Metrics for measuring and comparing triple stores performance +### DBpedia sample -* Memory consumption : - * heap : max available to the JVM - * used : used memory (or "used heap") to be measured after gc (garbage collector) call - * see : https://stackoverflow.com/questions/10754748/monitoring-own-memory-usage-by-java-application - * calculate the delta of used mem before and after the processes to be tested - * after startup - * before loading data - * after data is loaded - * after query exec +5 files sampled from the [DBpedia 3.5.1 dump](https://downloads.dbpedia.org/3.5.1/en/): +- `category_labels_en.nt` +- `disambiguations_en.nt` +- `homepages_en.nt` +- `persondata_en.nt` +- `specific_mappingbased_properties_en.nt` -* Time of execution - * function to use `System.currentTimeMillis()`. Call before and after the process - * what time to measure ? - * loading data - * SPARQL query - * select - * count +```bash +python python-utils/workflow.py --dataset dbpedia --engines corese.4.6.5,jena.6.2.0,rdf4j.5.3.2 +``` -* Thread : bound to a series of JVM parameters (max, etc) - * nb of threads actually used => Thread.activeCount() (see [here](https://www.baeldung.com/java-get-number-of-threads)) - * other instropection methods from : - * [`com.sun.management.OperatingSystemMXBean`](https://docs.oracle.com/en/java/javase/15/docs/api/jdk.management/com/sun/management/OperatingSystemMXBean.html) - * [ThreadMXBean.html](https://docs.oracle.com/en/java/javase/15/docs/api/jdk.management/com/sun/management/ThreadMXBean.html) +### Bowlogna -* CPU usage : (NTH-WIP) - * see : https://stackoverflow.com/questions/63948968/how-to-get-cpu-utilization-using-java-11 +Synthetic dataset describing student/university relations. Includes associated **SPARQL queries** for latency benchmarking. Source: [DICE Research / HOBBIT](https://files.dice-research.org/projects/HOBBIT/benchmarks-data/datasets-dumps/bowlogna-dump.zip) -* Nb of infered triples (NTH-WIP) : - * look the named graph used for infered +| Scale | Departments | Triples (approx.) | +|-------|-------------|-------------------| +| `small` | 1 | ~1.2 M | +| `medium` | 5 | ~6 M | +| `large` | 10 | ~12 M | -## Typical Configuration used +```bash +python python-utils/workflow.py --dataset bowlogna --scales small --engines corese.4.6.5,jena.6.2.0,rdf4j.5.3.2 +``` -* inMemoryStore -* inference level (check if levels are comparable) : - * no inference - * RDFS -* format to be parsed : - * nt - * turtle - * trig +### BSBM +Berlin SPARQL Benchmark — synthetic e-commerce dataset. Generated with [bsbmtools 0.2](https://sourceforge.net/projects/bsbmtools/). +| Scale | Products | Triples (approx.) | +|-------|---------|-------------------| +| `small` | 1,000 | ~50 K | +| `medium` | 50,000 | ~1 M | +| `large` | 500,000 | ~10 M | +```bash +python python-utils/workflow.py --dataset bsbm --scales small,medium --engines corese.4.6.5,jena.6.2.0,rdf4j.5.3.2 +``` +### LUBM -## Designing test suite for RDF parsers +Lehigh University Benchmark — university domain with RDFS reasoning potential. Generated with [UBA 1.7](https://swat.cse.lehigh.edu/projects/lubm/). -W3C turtle test suite: https://www.w3.org/2011/rdf-wg/wiki/Turtle_Test_Suite +| Scale | Universities | Triples (approx.) | +|-------|-----------|-------------------| +| `small` | 1 | ~100 K | +| `medium` | 10 | ~1 M | +| `large` | 100 | ~10 M | -W3C JSON-LD test suite: https://w3c.github.io/json-ld-api/tests/ +```bash +python python-utils/workflow.py --dataset lubm --scales small --engines corese.4.6.5,jena.6.2.0,rdf4j.5.3.2 +``` -W3C Trig test suite: https://www.w3.org/2013/TrigTests/ +--- -W3C NTriples test suite: https://www.w3.org/2013/N-TriplesTests/ +## Engines -W3C RDF/XML test suite: https://www.w3.org/2013/RDFXMLTests/ +| Engine | Default version | Notes | +|--------|----------------|-------| +| Corese | 4.6.5 | Use `corese.local` to benchmark a local branch | +| Corese 5 (next) | local JAR | `corese.next` — uses the new `Repositories`/`RepositoryConnection` API. Requires a Corese 5 JAR in `libs/` and `--coreseLocalPath` or sibling `corese-core/`. | +| Apache Jena | 6.2.0 | | +| Eclipse RDF4J | 5.3.2 | Malformed IRIs are skipped with a WARN log (DBpedia `homepages_en.nt` contains ~300 invalid IPv4 URIs) | -W3C NQuads test suite: https://www.w3.org/2013/N-QuadsTests/ +--- diff --git a/build.gradle b/build.gradle index 3f7140d..1a43b08 100644 --- a/build.gradle +++ b/build.gradle @@ -1,76 +1,76 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * This is a general purpose Gradle build. - * Learn more about Gradle by exploring our Samples at https://docs.gradle.org/8.13/samples - */ plugins { id 'java' - id 'groovy' id 'application' } +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + repositories { mavenCentral() flatDir { - dirs 'libs' // or the directory where your local JAR is stored + dirs 'libs' + } +} + +// CoreseNextDriver requires the Corese 5 API from libs/*.jar. +// When libs/ is empty (e.g. CI without a local build), exclude it from compilation. +def localJars = fileTree(dir: 'libs', include: ['*.jar']) +if (localJars.isEmpty()) { + sourceSets.main.java.exclude '**/CoreseNextDriver.java' +} else { + // Provide Corese 5 API for compilation (runtime is covered by coreseVersion=local) + dependencies { + compileOnly localJars } } dependencies { - // Add the Groovy runtime library - implementation 'org.apache.groovy:groovy-all:4.0.26' - // corese : switch between local and remote + // Corese if (project.hasProperty('coreseVersion') && project.coreseVersion == 'local') { - // If coreseVersion is 'local', include local JAR files from the 'libs' directory implementation fileTree(dir: 'libs', include: ['*.jar']) - } else if (project.hasProperty('coreseVersion') && project.coreseVersion != 'local') { - implementation 'fr.inria.corese:corese-core:' + project.coreseVersion + } else if (project.hasProperty('coreseVersion')) { + implementation "fr.inria.corese:corese-core:${project.coreseVersion}" } else if (project.hasProperty('coreseCommit')) { - // If coreseCommit is provided, use the specific commit version implementation fileTree(dir: project.coreseCommit, include: ['*.jar']) - } else { - // default to the latest version if no property is set - implementation 'fr.inria.corese:corese-core:4.6.3' + } else { + implementation 'fr.inria.corese:corese-core:4.6.5' } - // Apache Jena dependencies - def jenaVersion = project.hasProperty('jenaVersion') ? project.jenaVersion : '5.2.0' + + // Apache Jena + def jenaVersion = project.hasProperty('jenaVersion') ? project.jenaVersion : '6.2.0' implementation "org.apache.jena:jena-core:${jenaVersion}" implementation "org.apache.jena:jena-arq:${jenaVersion}" - // RDF4J dependencies - def rdf4jVersion = project.hasProperty('rdf4jVersion') ? project.rdf4jVersion : '5.1.3' + + // RDF4J + def rdf4jVersion = project.hasProperty('rdf4jVersion') ? project.rdf4jVersion : '5.3.2' implementation "org.eclipse.rdf4j:rdf4j-repository:${rdf4jVersion}" implementation "org.eclipse.rdf4j:rdf4j-sail-memory:${rdf4jVersion}" implementation "org.eclipse.rdf4j:rdf4j-repository-sail:${rdf4jVersion}" implementation "org.eclipse.rdf4j:rdf4j-rio:${rdf4jVersion}" - // Apache HTTP client dependencies (for RDF4J) - implementation 'org.apache.httpcomponents:httpclient:4.5.13' - implementation 'org.apache.httpcomponents:httpcore:4.4.14' - implementation 'commons-logging:commons-logging:1.2' - // Add SLF4J implementation + + // JSON output + implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.0' + + // Logging implementation 'ch.qos.logback:logback-classic:1.4.14' } -sourceSets { - main { - groovy { - srcDirs = ['src/main/groovy'] - } - resources { - srcDirs = ['src/main/resources'] - } - } +application { + mainClass = 'fr.inria.corese.benchmark.Main' } -task runGroovyScript(type: JavaExec) { - group = 'application' - description = 'Run the Groovy script' +task runBenchmark(type: JavaExec) { + group = 'benchmark' + description = 'Run the benchmark harness' classpath = sourceSets.main.runtimeClasspath - mainClass = 'benchmark' - - // Add memory settings + mainClass = 'fr.inria.corese.benchmark.Main' jvmArgs = [ - '-Xmx12g', // Maximum heap size (adjust value as needed) - '-Xms4g' // Initial heap size + '-Xmx12g', + '-Xms4g', + '-XX:+UseG1GC' ] } diff --git a/docs/audit-report.md b/docs/audit-report.md new file mode 100644 index 0000000..acb03fc --- /dev/null +++ b/docs/audit-report.md @@ -0,0 +1,89 @@ +# Benchmark Audit Report — + +--- + +## 1. Codebase and Script Inventory + +### Execution scripts + +| Component | Status | Notes | +|---|---|---| +| `python-utils/workflow.py` | Rewritten | Orchestrates download, build, run, history, deploy. Supports `--datasets` (multi-dataset), two-pass Gradle build for incompatible classpaths (`corese.local` + `corese.4.x.x`), `--scales`, `--queries`. | +| `Makefile` | New | Shorthand targets: `regression`, `compare`, `next`, `bowlogna`, `lubm`, `bsbm`, `all`, `site`, `clean-out`. | +| `src/main/java/.../Main.java` | Rewritten | CLI entry point. Accepts engine list, dataset name, queries file. OOM catch per engine. | +| `src/main/java/.../BenchmarkHarness.java` | Rewritten | Warmup + 3 repetitions + median. Query timeout (30 s) via `ExecutorService`. OOM detection per file with `System.gc()` + skip. | +| `src/main/java/.../BenchmarkReport.java` | New | Produces `benchmark-report.json` + CSV. Embeds `metadata.queries` when a queries file is provided. | +| Old shell scripts / Python notebooks | Replaced | Superseded by `workflow.py` + Java harness. | + +### Drivers + +| Driver | Engine | Status | +|---|---|---| +| `CoreseDriver.java` | `corese.4.x.x` | Stable — uses `Graph` API | +| `CoreseNextDriver.java` | `corese.next` / `corese.local` | New — uses `Repositories`/`RepositoryConnection` API (Corese 5). Fast path via `connection.load(Path, NTRIPLES)`, line-by-line fallback for invalid IRIs. | +| `JenaDriver.java` | `jena.6.2.0` | New — Jena 5/6 API, `Model.read()` + SPARQL `QueryExecution` | +| `RDF4JDriver.java` | `rdf4j.5.3.2` | New — full repository reinit between repetitions to avoid negative memory delta | +| `DriverFactory.java` | — | New — routes engine name to driver via reflection | + +### Datasets + +| Dataset | Script | Scales | SPARQL queries | +|---|---|---|---| +| DBpedia 3.5.1 | `datasets/dbpedia.py` | Fixed (5 files) | No | +| Bowlogna (HOBBIT) | `datasets/bowlogna.py` | small / medium / large | Yes (embedded in JSON) | +| BSBM | `datasets/bsbm.py` | small / medium / large | No | +| LUBM | `datasets/lubm.py` | small / medium / large | No | + +--- + +## 2. Metrics and Measurement Evaluation + +### Metrics retained + +| Metric | Source | Notes | +|---|---|---| +| `loadingTimeMs` | `System.currentTimeMillis()` before/after `load()` | Median of 3 repetitions | +| `throughputTriplesPerSec` | triples / loadingTimeMs × 1000 | Derived | +| `memoryDeltaMb` | `MemoryMXBean` — forced GC before + after | Delta heap used | +| `cpuPercent` | `OperatingSystemMXBean.getProcessCpuLoad()` | Averaged during loading | +| `repetitionsMs` | Raw values of 3 repetitions | Reported for transparency | + +### Measurement protocol + +1. **Warmup** — load → clear → GC (discarded) +2. **3 repetitions** — forced GC + memory snapshot before, load, forced GC + memory snapshot after +3. **Keep median** of the 3 repetitions + +This eliminates JIT compilation bias and GC timing noise. + +### Known gaps and mitigations + +| Gap | Mitigation | +|---|---| +| DBpedia `homepages_en.nt` contains ~300 invalid IPv4 IRIs | CoreseNextDriver falls back to line-by-line skip; RDF4J logs WARN and skips; Jena skips silently | +| Negative `memoryDeltaMb` on RDF4J repetitions 2-3 | Full repository reinit between repetitions (not just `conn.clear()`) | +| Query timeout risk on large datasets | 30 s timeout per query via `Future.get(30, SECONDS)`; result logged as `TIMEOUT` | +| OOM risk on very large files | `OutOfMemoryError` caught per file + `System.gc()` + file skipped with WARN log | +| Corese 5 classpath incompatible with Corese 4.x.x | Two-pass Gradle build in `workflow.py` + JSON merge | + +--- + +## 3. Baseline Reproducibility + +Validated on local environments (Windows 11, Java 21, Conda `benchmarkenv`): + +- `make regression` — corese.local vs corese.4.6.5 on DBpedia +- `make compare` — corese + jena + rdf4j on DBpedia +- `make bowlogna SCALES=small` — Bowlogna with SPARQL queries +- `make all DATASETS=dbpedia,bowlogna` — multi-dataset merge + +--- + +## 4. Reusable Components + +- **`BenchmarkHarness`** — generic, engine-agnostic; add a new engine by implementing `TripleStoreDriver` +- **`DriverFactory`** — reflection-based instantiation from engine name string +- **`workflow.py`** — extensible: new dataset = add a module in `datasets/` with a `prepare()` function +- **Dashboard** (`site/`) — data-driven: any `benchmark-report.json` structure is visualized automatically + +--- diff --git a/python-utils/datasets/__init__.py b/python-utils/datasets/__init__.py new file mode 100644 index 0000000..0140865 --- /dev/null +++ b/python-utils/datasets/__init__.py @@ -0,0 +1,6 @@ +""" +Dataset preparation modules for corese-benchmark. + +Each module exposes a `prepare(input_dir, **kwargs) -> str` function +that downloads/generates the dataset and returns the directory of .nt files. +""" diff --git a/python-utils/datasets/bowlogna.py b/python-utils/datasets/bowlogna.py new file mode 100644 index 0000000..2eb3781 --- /dev/null +++ b/python-utils/datasets/bowlogna.py @@ -0,0 +1,75 @@ +""" +BowlognaBench — student/university relationships synthetic dataset. +Namespace: http://www.owl-ontologies.com/Ontology1324312315.owl# + +Reference: https://exascale.info/assets/pdf/BowlognaBenchSIMPDA2011.pdf +Dataset hosted by DICE Research (HOBBIT project): + https://files.dice-research.org/projects/HOBBIT/benchmarks-data/datasets-dumps/bowlogna-dump.zip + +The zip contains Department0.nt … Department9.nt (~1.2 M triples each). + +Scale tiers (departments): + small → 1 department (~ 1.2 M triples) + medium → 5 departments (~ 6 M triples) + large → 10 departments (~ 12 M triples) +""" + +from __future__ import annotations +import os, shutil, urllib.request, zipfile + +DUMP_URL = "https://files.dice-research.org/projects/HOBBIT/benchmarks-data/datasets-dumps/bowlogna-dump.zip" +DUMP_ZIP = "bowlogna-dump.zip" + +ALL_FILES = [f"Department{i}.nt" for i in range(10)] + +SCALES = { + "small": ALL_FILES[:1], + "medium": ALL_FILES[:5], + "large": ALL_FILES[:10], +} + + +def prepare(input_dir: str, scales: list[str] | None = None) -> str: + """ + Download and extract Bowlogna Department*.nt files for the requested scales. + Returns the directory containing the .nt files. + """ + scales = scales or ["small", "medium", "large"] + output_dir = os.path.join(input_dir, "bowlogna", "unzip") + os.makedirs(output_dir, exist_ok=True) + + needed_files: set[str] = set() + for scale in scales: + if scale not in SCALES: + print(f" Unknown scale '{scale}', skipping.") + continue + needed_files.update(SCALES[scale]) + + missing = [f for f in sorted(needed_files) if not os.path.exists(os.path.join(output_dir, f))] + if not missing: + print(" All Bowlogna files already present.") + return output_dir + + # Download the full dump zip if any file is missing + zip_path = os.path.join(input_dir, "bowlogna", DUMP_ZIP) + if not os.path.exists(zip_path): + print(f" Downloading bowlogna-dump.zip (~86 MB)...") + urllib.request.urlretrieve(DUMP_URL, zip_path) + print(" Download complete.") + + print(f" Extracting {len(missing)} file(s) from zip...") + with zipfile.ZipFile(zip_path, "r") as z: + zip_names = z.namelist() + for filename in missing: + # The zip may store files in a subdirectory + matches = [n for n in zip_names if n.endswith(filename)] + if not matches: + print(f" WARNING: {filename} not found in zip archive.") + continue + entry = matches[0] + dest = os.path.join(output_dir, filename) + with z.open(entry) as src, open(dest, "wb") as dst: + shutil.copyfileobj(src, dst) + print(f" Extracted: {filename}") + + return output_dir diff --git a/python-utils/datasets/bsbm.py b/python-utils/datasets/bsbm.py new file mode 100644 index 0000000..47ad765 --- /dev/null +++ b/python-utils/datasets/bsbm.py @@ -0,0 +1,106 @@ +""" +Berlin SPARQL Benchmark (BSBM) — e-commerce synthetic dataset. +Generator: bsbmtools (Java), generates N-Triples at configurable scale. + +Scale tiers (products): + small → 1 000 products (~ 50 000 triples) + medium → 50 000 products (~ 1 M triples) + large → 500 000 products (~ 10 M triples) +""" + +from __future__ import annotations +import os, subprocess, shutil, urllib.request, zipfile + +GENERATOR_URL = "https://sourceforge.net/projects/bsbmtools/files/latest/download" +GENERATOR_ZIP = "bsbmtools-0.2.zip" +GENERATOR_DIR = "bsbmtools-0.2" +GENERATOR_JAR = "bsbm.jar" # inside bsbmtools-0.2/lib/ + +SCALES = { + "small": 1_000, + "medium": 50_000, + "large": 500_000, +} + + +def _download_generator(tools_dir: str) -> str: + """Download and unzip bsbmtools if not already present. Returns path to jar.""" + jar_path = os.path.join(tools_dir, GENERATOR_DIR, "lib", GENERATOR_JAR) + if os.path.exists(jar_path): + return jar_path + + zip_path = os.path.join(tools_dir, GENERATOR_ZIP) + if not os.path.exists(zip_path): + print(f" Downloading bsbmtools...") + urllib.request.urlretrieve(GENERATOR_URL, zip_path) + + print(f" Extracting bsbmtools...") + with zipfile.ZipFile(zip_path, "r") as z: + z.extractall(tools_dir) + + if not os.path.exists(jar_path): + raise FileNotFoundError(f"bsbm.jar not found at {jar_path} after extraction.") + + return jar_path + + +def prepare(input_dir: str, scales: list[str] | None = None) -> str: + """ + Generate BSBM datasets for the requested scales. + Returns the directory containing the generated .nt files. + """ + scales = scales or ["small", "medium", "large"] + tools_dir = os.path.join(input_dir, "_tools") + output_dir = os.path.join(input_dir, "bsbm", "unzip") + os.makedirs(tools_dir, exist_ok=True) + os.makedirs(output_dir, exist_ok=True) + + jar = _download_generator(tools_dir) + + for scale in scales: + if scale not in SCALES: + print(f" Unknown scale '{scale}', skipping.") + continue + + n_products = SCALES[scale] + out_file = os.path.join(output_dir, f"bsbm_{scale}.nt") + + if os.path.exists(out_file): + print(f" Already generated: bsbm_{scale}.nt") + continue + + # bsbmtools generates dataset.nt in the current directory + tmp_dir = os.path.join(tools_dir, f"bsbm_gen_{scale}") + os.makedirs(tmp_dir, exist_ok=True) + + print(f" Generating BSBM {scale} ({n_products} products)...") + gen_dir = os.path.abspath(os.path.join(tools_dir, GENERATOR_DIR)) + lib_jars = [ + os.path.join(gen_dir, "lib", f) + for f in os.listdir(os.path.join(gen_dir, "lib")) + if f.endswith(".jar") + ] + classpath = os.pathsep.join([gen_dir] + lib_jars) + tmp_dir_abs = os.path.abspath(tmp_dir) + cmd = [ + "java", "-cp", classpath, "-Xmx256M", + "benchmark.generator.Generator", + "-pc", str(n_products), + "-fc", # flat categories + "-fn", "dataset", # output file prefix + "-s", "nt", # N-Triples format + "-dir", tmp_dir_abs, # output directory + ] + # Must run from gen_dir so titlewords.txt is found + subprocess.run(cmd, cwd=gen_dir, check=True) + + generated = os.path.join(tmp_dir, "dataset.nt") + if os.path.exists(generated): + shutil.move(generated, out_file) + print(f" Generated: bsbm_{scale}.nt") + else: + print(f" Warning: expected output file not found for scale {scale}.") + + shutil.rmtree(tmp_dir, ignore_errors=True) + + return output_dir diff --git a/python-utils/datasets/dbpedia.py b/python-utils/datasets/dbpedia.py new file mode 100644 index 0000000..75bc7a8 --- /dev/null +++ b/python-utils/datasets/dbpedia.py @@ -0,0 +1,47 @@ +""" +DBpedia 3.5.1 dataset — 5 curated N-Triples files (~270K–770K triples each). +Source: S3 hosted on the SLICES/P16 infrastructure. +""" + +import os, bz2, shutil, requests + +BASE_URL = "https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump" + +FILES = [ + "category_labels_en.nt.bz2", + "disambiguations_en.nt.bz2", + "homepages_en.nt.bz2", + "persondata_en.nt.bz2", + "specific_mappingbased_properties_en.nt.bz2", +] + + +def prepare(input_dir: str) -> str: + """Download and extract DBpedia files. Returns the directory of .nt files.""" + archive_dir = os.path.join(input_dir, "dbpedia") + unzip_dir = os.path.join(archive_dir, "unzip") + os.makedirs(archive_dir, exist_ok=True) + os.makedirs(unzip_dir, exist_ok=True) + + for filename in FILES: + archive_path = os.path.join(archive_dir, filename) + nt_name = filename.replace(".bz2", "") + nt_path = os.path.join(unzip_dir, nt_name) + + if not os.path.exists(archive_path): + url = f"{BASE_URL}/{filename}" + print(f" Downloading {filename}...") + r = requests.get(url, stream=True) + r.raise_for_status() + with open(archive_path, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + + if not os.path.exists(nt_path): + print(f" Extracting {filename}...") + with bz2.BZ2File(archive_path, "rb") as src, open(nt_path, "wb") as dst: + shutil.copyfileobj(src, dst) + else: + print(f" Already ready: {nt_name}") + + return unzip_dir diff --git a/python-utils/datasets/lubm.py b/python-utils/datasets/lubm.py new file mode 100644 index 0000000..9b7c5df --- /dev/null +++ b/python-utils/datasets/lubm.py @@ -0,0 +1,112 @@ +""" +Lehigh University Benchmark (LUBM) — university domain synthetic dataset. +Generator: UBA 1.7 (Java), produces OWL files converted to N-Triples. + +Scale tiers (universities): + small → 1 university (~ 100 K triples) + medium → 10 universities (~ 1 M triples) + large → 100 universities (~ 10 M triples) + +Note: UBA generates .owl (RDF/XML) files; we convert them to N-Triples with rdflib. +""" + +from __future__ import annotations +import os, subprocess, shutil, urllib.request, zipfile + +UBA_URL = "https://swat.cse.lehigh.edu/projects/lubm/uba1.7.zip" +UBA_ZIP = "uba1.7.zip" + +SCALES = { + "small": 1, + "medium": 10, + "large": 100, +} + + +def _download_uba(tools_dir: str) -> str: + """Download and extract UBA if not already present. Returns path to classes dir.""" + classes_dir = os.path.join(tools_dir, "uba", "classes") + if os.path.isdir(classes_dir): + return classes_dir + + zip_path = os.path.join(tools_dir, UBA_ZIP) + if not os.path.exists(zip_path): + print(" Downloading UBA 1.7...") + urllib.request.urlretrieve(UBA_URL, zip_path) + + print(" Extracting UBA 1.7...") + uba_dir = os.path.join(tools_dir, "uba") + os.makedirs(uba_dir, exist_ok=True) + with zipfile.ZipFile(zip_path, "r") as z: + z.extractall(uba_dir) + + if not os.path.isdir(classes_dir): + raise FileNotFoundError(f"UBA classes dir not found at {classes_dir}") + + return classes_dir + + +def _owl_to_nt(owl_file: str, out_f) -> None: + """Convert an OWL (RDF/XML) file to N-Triples using rdflib and write to out_f.""" + import rdflib + g = rdflib.Graph() + g.parse(owl_file, format="xml") + out_f.write(g.serialize(format="nt").encode("utf-8")) + + +def prepare(input_dir: str, scales: list[str] | None = None) -> str: + """ + Generate LUBM datasets for the requested scales. + Returns the directory containing the generated .nt files. + """ + scales = scales or ["small", "medium", "large"] + tools_dir = os.path.join(input_dir, "_tools") + output_dir = os.path.join(input_dir, "lubm", "unzip") + os.makedirs(tools_dir, exist_ok=True) + os.makedirs(output_dir, exist_ok=True) + + classes_dir = os.path.abspath(_download_uba(tools_dir)) + + for scale in scales: + if scale not in SCALES: + print(f" Unknown scale '{scale}', skipping.") + continue + + n_univ = SCALES[scale] + out_file = os.path.join(output_dir, f"lubm_{scale}.nt") + + if os.path.exists(out_file): + print(f" Already generated: lubm_{scale}.nt") + continue + + tmp_dir = os.path.abspath(os.path.join(tools_dir, f"lubm_gen_{scale}")) + os.makedirs(tmp_dir, exist_ok=True) + + print(f" Generating LUBM {scale} ({n_univ} universit{'y' if n_univ == 1 else 'ies'})...") + cmd = [ + "java", "-cp", classes_dir, + "edu.lehigh.swat.bench.uba.Generator", + "-univ", str(n_univ), + "-index", "0", + "-seed", "0", + "-onto", "http://www.lehigh.edu/~zhp2/2004/0401/univ-bench.owl", + ] + subprocess.run(cmd, cwd=tmp_dir, check=True) + + owl_files = sorted(f for f in os.listdir(tmp_dir) if f.endswith(".owl")) + if not owl_files: + print(f" Warning: no .owl files generated for scale {scale}.") + shutil.rmtree(tmp_dir, ignore_errors=True) + continue + + print(f" Converting {len(owl_files)} OWL file(s) to N-Triples...") + merged_nt = os.path.join(tmp_dir, "merged.nt") + with open(merged_nt, "wb") as out_f: + for owl_name in owl_files: + _owl_to_nt(os.path.join(tmp_dir, owl_name), out_f) + + shutil.move(merged_nt, out_file) + shutil.rmtree(tmp_dir, ignore_errors=True) + print(f" Generated: lubm_{scale}.nt") + + return output_dir diff --git a/python-utils/environment.yml b/python-utils/environment.yml index 66a808a..5518135 100644 --- a/python-utils/environment.yml +++ b/python-utils/environment.yml @@ -4,14 +4,5 @@ channels: - defaults dependencies: - python=3.9 - - sparqlwrapper - rdflib - - pathlib - - pandas - requests - - zipfile2 - - matplotlib - - jinja2 - - plotly - - openjdk=21.0.8 - - python-kaleido \ No newline at end of file diff --git a/python-utils/plot-compare.py b/python-utils/plot-compare.py deleted file mode 100644 index ca46460..0000000 --- a/python-utils/plot-compare.py +++ /dev/null @@ -1,201 +0,0 @@ -# Import pandas for data handling -import pandas as pd -import os -import sys -import re -import plotly.graph_objects as go -from jinja2 import Environment, FileSystemLoader -import itertools -import subprocess -import platform -import kaleido -# kaleido.get_chrome_sync() - -################################################################## -# METHODS -################################################################## -def get_ram(): - system = platform.system() - try: - if system == "Linux": - # Total memory in MB - mem = subprocess.check_output("free -m | awk '/Mem:/ {print $2}'", shell=True).decode().strip() - return f"{mem} MB" - elif system == "Darwin": - # macOS: total memory in bytes, convert to MB - mem_bytes = subprocess.check_output("sysctl -n hw.memsize", shell=True).decode().strip() - mem_mb = int(mem_bytes) // (1024 * 1024) - return f"{mem_mb} MB" - else: - return "Unknown" - except Exception: - return "Unknown" - -def get_cpu(): - try: - cpus = os.cpu_count() - return f"{cpus} cores" - except Exception: - return "Unknown" - -################################################################## - - - - -# Get the output directory from the command line argument, default to '../out' if not provided -if len(sys.argv) > 1: - metrics_dir = sys.argv[1] -else: - metrics_dir = os.path.join(os.path.dirname(__file__), '..', 'out') - -print(f"DEBUG: Attempting to use metrics directory: {os.path.abspath(metrics_dir)}") - -# Check if the directory exists -if not os.path.isdir(metrics_dir): - print(f"ERROR: Metrics directory not found at '{os.path.abspath(metrics_dir)}'.") - print("Please run the benchmark first to generate the output files, or provide the correct path as an argument.") - sys.exit(1) - -# Find all *_loading-metrics.csv files in the directory -csv_files = [f for f in os.listdir(metrics_dir) if f.endswith('_loading-metrics.csv')] - -print(f"DEBUG: Found {len(csv_files)} CSV files: {csv_files}") - -# Exit if no CSV files are found -if not csv_files: - print(f"ERROR: No '*_loading-metrics.csv' files found in '{os.path.abspath(metrics_dir)}'.") - print("Cannot generate plots without data.") - sys.exit(1) - -# Load each CSV and extract triplestore name from filename -dataframes = [] -for csv_file in csv_files: - triplestore_name = csv_file.split('_loading-metrics.csv')[0] - df = pd.read_csv(os.path.join(metrics_dir, csv_file)) - df['triplestoreName'] = triplestore_name # Ensure the column exists and is consistent - dataframes.append(df) - -# Concatenate all dataframes -combined_data = pd.concat(dataframes, ignore_index=True) -print(f"DEBUG: Successfully combined data into a dataframe with {len(combined_data)} rows.") - -# Convert graph_size to millions (10^6) for better readability in plots -combined_data['graph_size_millions'] = combined_data['graph_size'] / 1e6 - -############# -# B/ plotting -############# - -# Generate a color palette (add more colors if needed) -color_palette = [ - 'blue', 'orange', 'green', 'red', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan' -] -color_cycle = itertools.cycle(color_palette) - -# Assign a unique color to each triplestoreName -unique_names = combined_data['triplestoreName'].unique() -color_mapping = {name: next(color_cycle) for name in unique_names} - - -# Function to get the color based on the triplestoreName using regex -def get_color(triplestore_name): - return color_mapping.get(triplestore_name, 'black') # fallback to black - -# Create a plotly figure -fig = go.Figure() - -# Plot loading_time_seconds on the left y-axis -for triplestoreName in combined_data['triplestoreName'].unique(): - subset = combined_data[combined_data['triplestoreName'] == triplestoreName] - color = get_color(triplestoreName) # Get the color using regex matching - - # Loading-time => continous line - fig.add_trace(go.Scatter( - x=subset['graph_size_millions'], - y=subset['loading_time_seconds'], - mode='lines', - name=f'{triplestoreName} - Loading time (s)', - line=dict(dash='solid', color=color), - yaxis='y1', - legendgroup=triplestoreName - )) - - # Memory used => dashed line - fig.add_trace(go.Scatter( - x=subset['graph_size_millions'], - y=subset['memory_used_mb'], - mode='lines', - name=f'{triplestoreName} - Memory used (MB)', - line=dict(dash='dash', color=color), - yaxis='y2', - legendgroup=triplestoreName - )) - -# Update layout for dual y-axes -fig.update_layout( - title='Java Triplestores Performance Comparison \n Loading Time and Memory Used by Graph Size', - xaxis=dict(title='Graph Size (Million triples)'), - yaxis=dict( - title='Loading Time (seconds)', - color='blue', - side='left', - showgrid=True - ), - yaxis2=dict( - title='Memory Used (MB)', - color='green', - overlaying='y', - side='right', - showgrid=False - ), - legend=dict( - orientation='h', - yanchor='bottom', - y=-0.3, - xanchor='center', - x=0.5, - tracegroupgap=10 # Add spacing between groups if needed - ), - height=800 - ) - - -# Save the plot as HTML + PNG -plotname = 'loading_time_memory_comparison' -output_dir = os.path.join(os.path.dirname(__file__), '..', 'public') -os.makedirs(output_dir, exist_ok=True) -output_path = os.path.join(output_dir, plotname ) - -print(f"DEBUG: Saving plot to {os.path.abspath(output_path)}.html/png") -fig.write_html(output_path+'.html') -# fig.write_image(output_path+'.png', 'png') - -############# -# C/ Render the HTML template -############# - -# Set up the Jinja2 environment -env = Environment(loader=FileSystemLoader(os.path.dirname(__file__))) -template = env.get_template('template.html') - - -runnerenv = os.environ.get('RUNNER_ENV', 'local') -# get available RAM and CPU from system command line -ramavailable = get_ram() -cpuavailable = get_cpu() - -# Render the template with the Plotly graph URLs -rendered_html = template.render( - plotly_graph_url=plotname+".html", - triplestore_names=unique_names, - todaysdate=pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S %Z%z'), - runnerenv=runnerenv, - ramavailable=ramavailable, - cpuavailable=cpuavailable, -) - -# Save the rendered HTML to a file or serve it directly -output_html_path = os.path.join(output_dir, 'index.html') -with open(output_html_path, 'w') as file: - file.write(rendered_html) \ No newline at end of file diff --git a/python-utils/skos_categories_en.nt.bz2 b/python-utils/skos_categories_en.nt.bz2 deleted file mode 100644 index 59e209d..0000000 Binary files a/python-utils/skos_categories_en.nt.bz2 and /dev/null differ diff --git a/python-utils/template.html b/python-utils/template.html deleted file mode 100644 index 545ecaa..0000000 --- a/python-utils/template.html +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - Benchmark dashboard - - - -
-

Benchmark dashboard

-

Generated on {{ todaysdate }}

-

Environment : {{ runnerenv }}

-

Available RAM : {{ ramavailable }}

-

Available CPU : {{ cpuavailable }}

-

Loading time and memory comparison

-

Triple stores compared

-
    - {% for name in triplestore_names %} -
  • {{ name }}
  • - {% endfor %} -
-

Metrics measured

-

measured after each file has loaded sucessfuly:

-
    -
  • loading time
  • -
  • memory used
  • -
-
-

Description

-

The plots below show the loading time and memory used after each file from the dataset has been succesfuly loaded. The dataset is made of a sample of 10 files from DBPedia dump

-
- -
- - \ No newline at end of file diff --git a/python-utils/workflow.py b/python-utils/workflow.py index 4222d36..e555c05 100644 --- a/python-utils/workflow.py +++ b/python-utils/workflow.py @@ -1,298 +1,420 @@ import os import sys -import requests -import zipfile -import tarfile -import bz2 import shutil import subprocess import argparse - +import glob +import json as _json_early +from datetime import datetime #################################################################################### -# Step 0 : Parse command-line arguments and prepare the directories +# Step 0: Parse arguments #################################################################################### -# Class ArgumentParser -############################# -# Parse command-line arguments -parser = argparse.ArgumentParser(description="Run the benchmark workflow.") + +parser = argparse.ArgumentParser(description="Run the corese-benchmark workflow.") parser.add_argument( - "--triplestoreNames", + "--engines", type=str, default=None, - help="Comma-separated list of triplestore names and versions (e.g., 'rdf4j.5.1.2,jena.5.4.0,corese.4.6.3'). Each name should be in the format 'name.version'. where 'name' is one of 'rdf4j', 'corese', or 'jena' and 'version' is the version number (e.g., '5.1.2', '4.6.3', '4.10.0').", + help="Comma-separated list of engines (e.g. 'corese.4.6.5,jena.5.2.0,rdf4j.5.1.3')", ) -# coreseVersions = ["4.0.1","4.6.3","local"] parser.add_argument( "--coreseVersions", type=str, default=None, - help="Comma-separated list of Corese versions to benchmark (e.g., '4.0.1,4.6.3,local').", + help="Comma-separated Corese versions (e.g. '4.6.5,local'). One build per version.", ) -# coreseCommits = ["a17f3d6","b089a03","6efa666"] parser.add_argument( "--coreseCommits", type=str, default=None, - help="Comma-separated list of Corese commit hashes to benchmark (e.g., 'a17f3d6,b089a03,6efa666').", + help="Comma-separated Corese commit hashes. Clones and builds each commit.", +) +parser.add_argument( + "--dataset", + type=str, + default=None, + help="Single dataset to benchmark: dbpedia | bsbm | lubm | bowlogna (default: dbpedia)", +) +parser.add_argument( + "--datasets", + type=str, + default=None, + help="Comma-separated datasets to benchmark in one run (e.g. 'dbpedia,bowlogna,bsbm,lubm')", +) +parser.add_argument( + "--scales", + type=str, + default="small,medium,large", + help="Comma-separated scale tiers for synthetic datasets: small,medium,large (default: all)", ) -# input dataDir parser.add_argument( "--dataDir", type=str, default=None, - help="Absolute path to the directory containing the data files.", + help="Absolute path to a custom data directory.", +) +parser.add_argument( + "--coreseLocalPath", + type=str, + default=None, + help="Path to corese-core project or JAR for corese.local engine. " + "Auto-detected from ../corese-core/build/libs/ if not specified.", +) +parser.add_argument( + "--inference", + action="store_true", + default=False, + help="Enable RDFS inference for Corese engines (passed to Java harness).", ) args = parser.parse_args() -triplestoreNames = args.triplestoreNames -# Class DirectoryManager -############################# -# If dataDir argument is provided, use it; otherwise, create an "input" -# directory one level above the current script's directory current_dir = os.path.dirname(os.path.abspath(__file__)) +project_dir = os.path.dirname(current_dir) +sys.path.insert(0, current_dir) + +# Resolve dataset list: --datasets takes priority over --dataset +if args.datasets: + dataset_names = [d.strip().lower() for d in args.datasets.split(",")] +elif args.dataset: + dataset_names = [args.dataset.strip().lower()] +else: + dataset_names = ["dbpedia"] + +#################################################################################### +# Auto-copy corese.local JAR if needed +#################################################################################### + +engines_str = args.engines or args.coreseVersions or "" +if "corese.local" in engines_str or "corese.next" in engines_str or "local" in engines_str.split(","): + libs_dir = os.path.join(project_dir, "libs") + os.makedirs(libs_dir, exist_ok=True) + + # Resolve source path: --coreseLocalPath > sibling ../corese-core > already in libs/ + if args.coreseLocalPath: + src = args.coreseLocalPath + if os.path.isdir(src): + candidates = glob.glob(os.path.join(src, "build", "libs", "*-jar-with-dependencies.jar")) + if not candidates: + candidates = glob.glob(os.path.join(src, "build", "libs", "corese-core*.jar")) + src = max(candidates, key=os.path.getmtime) if candidates else None + else: + sibling = os.path.join(os.path.dirname(project_dir), "corese-core", "build", "libs") + candidates = glob.glob(os.path.join(sibling, "*-jar-with-dependencies.jar")) + if not candidates: + candidates = glob.glob(os.path.join(sibling, "corese-core*.jar")) + src = max(candidates, key=os.path.getmtime) if candidates else None + + if src and os.path.isfile(src): + dest = os.path.join(libs_dir, "corese-core-local.jar") + shutil.copy(src, dest) + print(f"corese.local: copied JAR from {src}") + elif not glob.glob(os.path.join(libs_dir, "*.jar")): + print("WARNING: corese.local requested but no JAR found in libs/ and could not auto-detect.") + print(" Use --coreseLocalPath /path/to/corese-core or place a JAR in libs/") + if args.dataDir: input_dir = os.path.join(args.dataDir, "input") - out_dir = os.path.join(args.dataDir, "out") - public_dir = os.path.join(args.dataDir, "public") + out_dir = os.path.join(args.dataDir, "out") else: - - input_dir = os.path.join(os.path.dirname(current_dir), "input") - out_dir = os.path.join(os.path.dirname(current_dir), "out") - public_dir = os.path.join(os.path.dirname(current_dir), "public") -# Now check and create/clear the input and output directories -os.makedirs(input_dir, exist_ok=True) -if os.path.exists(out_dir): - shutil.rmtree(out_dir) -os.makedirs(out_dir, exist_ok=True) -print(f"Created input directory at: {input_dir}") -print(f"Created output directory at: {out_dir}") - -# *public* Create the "public" directory to store the HTML files of the benchmark minisite -# TBD : remove this step from plot-compare.py + input_dir = os.path.join(project_dir, "input") + out_dir = os.path.join(project_dir, "out") + +out_json_dir = os.path.join(out_dir, "json") +out_csv_dir = os.path.join(out_dir, "csv") +site_data_dir = os.path.join(project_dir, "site", "data") +os.makedirs(input_dir, exist_ok=True) +os.makedirs(out_json_dir, exist_ok=True) +os.makedirs(out_csv_dir, exist_ok=True) +os.makedirs(site_data_dir, exist_ok=True) + +gradle_wrapper = os.path.join( + project_dir, "gradlew.bat" if sys.platform == "win32" else "gradlew" +) +if not os.path.exists(gradle_wrapper): + raise FileNotFoundError(f"Gradle wrapper not found at {gradle_wrapper}") #################################################################################### -# Step 1: Archives dowloader +# Step 1: Prepare datasets #################################################################################### -# 1.1 Class ArchiveDownloader -############################# -# Download the archive files to the "input" directory -# Note: The archive URLs are hardcoded, but you can modify them as needed. -archive_urls = [ - #"https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/geo_coordinates_en.nt.bz2", - "https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/persondata_en.nt.bz2", - #"https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/redirects_en.nt.bz2", - #"https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/images_en.nt.bz2", - #"https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/skos_categories_en.nt.bz2", - #"https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/instance_types_en.nt.bz2", - "https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/disambiguations_en.nt.bz2", - "https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/category_labels_en.nt.bz2", - "https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/specific_mappingbased_properties_en.nt.bz2", - "https://s3.slices-be.eu/ilabt.imec.be-project-p16/corese/dbpedia_3.5.1_dump/homepages_en.nt.bz2" - ] -archive_paths = [] - -for archive_url in archive_urls: - archive_name = os.path.basename(archive_url) - archive_path = os.path.join(input_dir, archive_name) - archive_paths.append(archive_path) - - if not os.path.exists(archive_path): - print(f"Downloading archive file from {archive_url} to {archive_path}...") - response = requests.get(archive_url, stream=True) - response.raise_for_status() # Raise an error if the download fails - with open(archive_path, "wb") as archive_file: - for chunk in response.iter_content(chunk_size=8192): - archive_file.write(chunk) - print(f"Download completed for {archive_name}.") - else: - print(f"Archive file already exists at {archive_path}. Skipping download.") - -# 1.2 Class ArchiveExtractor: -############################# -# Extract all archive files into a directory named "unzip" - -unzip_dir = os.path.join(input_dir, "unzip") -os.makedirs(unzip_dir, exist_ok=True) -print(f"Extracting content to {unzip_dir}...") - -for archive_path in archive_paths: - print(f"Extracting {archive_path}...") - try: - if archive_path.endswith(".zip"): - with zipfile.ZipFile(archive_path, "r") as zip_ref: - zip_ref.extractall(unzip_dir) - elif archive_path.endswith(".bz2"): - extracted_file_path = os.path.join(unzip_dir, os.path.splitext(os.path.basename(archive_path))[0]) - with bz2.BZ2File(archive_path, "rb") as bz2_file, open(extracted_file_path, "wb") as extracted_file: - shutil.copyfileobj(bz2_file, extracted_file) - elif archive_path.endswith(".tar") or archive_path.endswith(".tar.gz") or archive_path.endswith(".tgz"): - with tarfile.open(archive_path, "r:*") as tar_ref: - tar_ref.extractall(unzip_dir) - else: - print(f"Unsupported archive format for {archive_path}. Skipping.") - except Exception as e: - print(f"Error extracting {archive_path}: {e}") - else: - print(f"Extracted {os.path.basename(archive_path)}.") +SUPPORTED_DATASETS = ("dbpedia", "bsbm", "lubm", "bowlogna") +scales = [s.strip() for s in args.scales.split(",")] + +for ds in dataset_names: + if ds not in SUPPORTED_DATASETS: + print(f"Unknown dataset '{ds}'. Supported: {', '.join(SUPPORTED_DATASETS)}") + sys.exit(1) -print("Extraction completed.") +def prepare_dataset(dataset_name): + print(f"\n=== Preparing dataset: {dataset_name} (scales: {scales}) ===") + if dataset_name == "dbpedia": + from datasets.dbpedia import prepare + return prepare(input_dir) + elif dataset_name == "bsbm": + from datasets.bsbm import prepare + return prepare(input_dir, scales=scales) + elif dataset_name == "lubm": + from datasets.lubm import prepare + return prepare(input_dir, scales=scales) + elif dataset_name == "bowlogna": + from datasets.bowlogna import prepare + return prepare(input_dir, scales=scales) +# Pre-prepare all datasets upfront +dataset_dirs = {} +for ds in dataset_names: + dataset_dirs[ds] = prepare_dataset(ds) + print(f"Dataset ready at: {dataset_dirs[ds]}") #################################################################################### -# Step 2: benchmark builder and runner +# Step 2: Build and run benchmark #################################################################################### -# Case A : triplestoreNames argument is provided, we build and run the benchmark.groovy script once with the provided triplestore names and versions -# Class Benchmark -######################## -gradle_wrapper_name = "gradlew.bat" if sys.platform == "win32" else "gradlew" -gradle_wrapper = os.path.join(os.path.dirname(current_dir), gradle_wrapper_name) -if not os.path.exists(gradle_wrapper): - raise FileNotFoundError(f"Gradle wrapper not found at {gradle_wrapper}") +def gradle_build(extra_args=None): + cmd = [gradle_wrapper, "clean", "build"] + (extra_args or []) + print(f"Building: {' '.join(cmd)}") + subprocess.run(cmd, cwd=project_dir, check=True) -if args.triplestoreNames: - - print("Running Gradle build using versions number provided in the tripleStoreNames parameter ...") - # create a dictionary with the triplestore names as keys and the version numbers as values - triplestore_names_list = triplestoreNames.split(",") - triplestore_versions_dict = { - name.split(".", 1)[0]: name.split(".", 1)[1] for name in triplestore_names_list - } - # Example: {'rdf4j': '5.1.2', 'jena': '4.10.0', 'corese': '4.6.3'} - gradle_version_arg = f"-PjenaVersion={triplestore_versions_dict.get('jena', '4.10.0')} " \ - f"-Prdf4jVersion={triplestore_versions_dict.get('rdf4j', '5.1.2')} " \ - f"-PcoreseVersion={triplestore_versions_dict.get('corese', '4.6.3')}" - print("Running Gradle clean and build...") - print(gradle_version_arg) - # run the Gradle clean and build command - command = [gradle_wrapper, "clean", "build"] + gradle_version_arg.split() - print(f"Executing command: {' '.join(command)}") - subprocess.run(command, cwd=os.path.dirname(current_dir), check=True) - - # Class BenchmarkRunner - ######################## - print("Executing the benchmark.groovy script and save results in the 'out' folder...") - command = [gradle_wrapper, "runGroovyScript", "--args="+unzip_dir+" "+out_dir+" "+triplestoreNames] - print(f"Executing command: {' '.join(command)}") - subprocess.run(command, cwd=os.path.dirname(current_dir), check=True) - - -if args.coreseVersions : -# Case B.1 : argument coreseVersions is provided, we build and run the benchmark.groovy script for each Corese version provided - coreseVersionsList = args.coreseVersions.split(",") - for coreseVersion in coreseVersionsList: - print(f"Running Gradle build with Corese version {coreseVersion}...") - gradle_version_arg = "-PcoreseVersion=local" if coreseVersion == "local" else f"-PcoreseVersion={coreseVersion}" - command = [gradle_wrapper, "clean", "build", gradle_version_arg] - print(f"Executing command: {' '.join(command)}") - subprocess.run(command, cwd=os.path.dirname(current_dir), check=True) - - # Set the triplestore name for the output CSV - triplestore_name = f"corese.{coreseVersion}" - - print(f"Executing the benchmark.groovy script with corese version {coreseVersion}...") - # Usage: groovy benchmark.groovy - command = [gradle_wrapper, "runGroovyScript", "--args=" + unzip_dir + " " + out_dir + " " + triplestore_name] - print(f"Executing command: {' '.join(command)}") - subprocess.run( - command, - cwd=os.path.dirname(current_dir), check=True - ) - -# Case B.2 : argument coreseCommits is provided, we download, build, and run the benchmark.groovy script for each Corese commit version provided -if args.coreseCommits: - coreseCommitsList = args.coreseCommits.split(",") - for coreseCommit in coreseCommitsList: - # 1. create OR clear "commit", one level above current_dir - # if dataDir argument is provided, create the "commit" directory inside dataDir - if args.dataDir: - commit_dir = os.path.join(args.dataDir, "commit") +QUERIES_FILES = { + "bowlogna": os.path.join(project_dir, "src", "main", "resources", + "Bowlogna_bench_data", "bowlogna-queries.txt"), + "lubm": os.path.join(project_dir, "src", "main", "resources", + "lubm", "lubm-queries.txt"), +} + + +def run_benchmark(engines_str, output_file, dataset_label, unzip_dir, gradle_props=None): + queries_file = QUERIES_FILES.get(dataset_label, "") + args_str = f"{unzip_dir} {output_file} {engines_str} {dataset_label}" + if queries_file and os.path.exists(queries_file): + args_str += f" {queries_file}" + print(f" Using queries file: {queries_file}") + if args.inference: + args_str += " --inference" + print(" Inference: enabled") + cmd = [gradle_wrapper, "runBenchmark"] + (gradle_props or []) + [f"--args={args_str}"] + print(f"Running benchmark: {' '.join(cmd)}") + subprocess.run(cmd, cwd=project_dir, check=True) + + +def merge_json_files(file_list, output_file): + """Merge multiple JSON reports into one, combining runs and queryRuns arrays. + metadata.queries is taken from whichever report contains them (queries are + dataset-specific, so only one report per dataset will have them). + """ + merged = None + merged_queries = None # SPARQL query texts from metadata + for path in file_list: + with open(path) as f: + r = _json_early.load(f) + # Collect SPARQL query texts from metadata (any report that has them) + q = r.get("metadata", {}).get("queries") + if q: + merged_queries = q + if merged is None: + merged = { + "metadata": r["metadata"], + "runs": list(r.get("runs", [])), + "queryRuns": list(r.get("queryRuns", [])), + } + else: + merged["runs"] += r.get("runs", []) + merged["queryRuns"] += r.get("queryRuns", []) + if merged: + # Ensure metadata.queries is preserved from any contributing report + if merged_queries: + merged["metadata"]["queries"] = merged_queries + if not merged["queryRuns"]: + del merged["queryRuns"] + with open(output_file, "w") as f: + _json_early.dump(merged, f, indent=2) + for path in file_list: + os.remove(path) + print(f"Merged report: {output_file}") + + +timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + +# Case A: --engines (single build, all engines) +if args.engines: + engines_list = [e.strip() for e in args.engines.split(",")] + + def needs_local_jar(e): + return e in ('corese.next', 'corese.local') or e.startswith('corese.commit.') + + def build_gradle_props(engine_sublist): + versions = {} + for e in engine_sublist: + parts = e.split(".", 1) + if len(parts) == 2: + versions[parts[0]] = parts[1] + extra = [] + if "corese" in versions: + corese_ver = versions['corese'] + if corese_ver in ('next', 'local'): + corese_ver = 'local' + extra += [f"-PcoreseVersion={corese_ver}"] + if "jena" in versions: + extra += [f"-PjenaVersion={versions['jena']}"] + if "rdf4j" in versions: + extra += [f"-Prdf4jVersion={versions['rdf4j']}"] + return extra + + local_engines = [e for e in engines_list if needs_local_jar(e)] + other_engines = [e for e in engines_list if not needs_local_jar(e)] + engines_tag = args.engines.replace(',', '_') + + # Collect one output file per dataset (then merge if multi-dataset) + dataset_output_files = [] + + for ds in dataset_names: + unzip_dir = dataset_dirs[ds] + ds_tag = f"_{ds}" if len(dataset_names) > 1 else "" + output_file = os.path.join(out_json_dir, f"{timestamp}{ds_tag}_{engines_tag}.json") + + if local_engines and other_engines: + tmp1 = output_file.replace(".json", "_pass1.json") + tmp2 = output_file.replace(".json", "_pass2.json") + + extra1 = ["-PcoreseVersion=local"] + gradle_build(extra1) + run_benchmark(",".join(local_engines), tmp1, ds, unzip_dir, gradle_props=extra1) + + extra2 = build_gradle_props(other_engines) + gradle_build(extra2) + run_benchmark(",".join(other_engines), tmp2, ds, unzip_dir, gradle_props=extra2) + + merge_json_files([tmp1, tmp2], output_file) else: - commit_dir = os.path.join(os.path.dirname(current_dir), "commit") + extra = build_gradle_props(engines_list) + gradle_build(extra) + run_benchmark(args.engines, output_file, ds, unzip_dir, gradle_props=extra) + + dataset_output_files.append(output_file) + + # If multiple datasets, merge all into one final report + if len(dataset_output_files) > 1: + final_output = os.path.join(out_json_dir, f"{timestamp}_all_{engines_tag}.json") + merge_json_files(dataset_output_files, final_output) + # (single dataset file already at output_file) + +# Case B: --coreseVersions (one build per version) +if args.coreseVersions: + for version in args.coreseVersions.split(","): + version = version.strip() + engine_name = f"corese.{version}" + ds_files = [] + for ds in dataset_names: + unzip_dir = dataset_dirs[ds] + ds_tag = f"_{ds}" if len(dataset_names) > 1 else "" + output_file = os.path.join(out_json_dir, f"{timestamp}{ds_tag}_{engine_name}.json") + gradle_build([f"-PcoreseVersion={version}"]) + run_benchmark(engine_name, output_file, ds, unzip_dir, gradle_props=[f"-PcoreseVersion={version}"]) + ds_files.append(output_file) + if len(ds_files) > 1: + merge_json_files(ds_files, os.path.join(out_json_dir, f"{timestamp}_all_{engine_name}.json")) + +# Case C: --coreseCommits (clone + build per commit) +if args.coreseCommits: + commit_base = os.path.join(args.dataDir or project_dir, "commit") + for commit_hash in args.coreseCommits.split(","): + commit_hash = commit_hash.strip() + commit_dir = os.path.join(commit_base, commit_hash) if os.path.exists(commit_dir): shutil.rmtree(commit_dir) - print(f"Cleared commit directory at: {commit_dir}") - os.makedirs(commit_dir, exist_ok=True) - print(f"Created commit directory at: {commit_dir}") - # 2. Clone the Corese repository and checkout the specific commit - print(f"Downloading Corese commit {coreseCommit}...") + os.makedirs(commit_dir) + try: - # cd to the commit directory - os.chdir(commit_dir) - # clone the Corese repository - command = ["git", "clone", "https://github.com/corese-stack/corese-core"] - print(f"Executing command: {' '.join(command)}") - subprocess.run(command) - # cd to the Corese repository - os.chdir(os.path.join(commit_dir, "corese-core")) - # checkout the specific commit - command = ["git", "checkout", coreseCommit] - print(f"Executing command: {' '.join(command)}") - subprocess.run(command, check=True) - - # 3. Build the Corese project using Gradlew - # $ ./gradlew assemble - print(f"Building Corese commit {coreseCommit}...") - print(f"Executing the command .\gradlew shadowJar ...") - corese_gradle_wrapper = os.path.join(commit_dir,"corese-core", "gradlew") - if not os.path.exists(corese_gradle_wrapper): - print(f"Gradle wrapper not found at {corese_gradle_wrapper}. Skipping this commit.\n* * *\n ") - continue # Skip this commit if the Gradle wrapper is not found - command = [corese_gradle_wrapper, "shadowJar"] - print(f"Executing command: {' '.join(command)}") - subprocess.run(command, cwd=os.path.join(commit_dir, "corese-core"), check=True) - - except subprocess.CalledProcessError as e: - print(f"Error building Corese commit {coreseCommit}: {e}") - continue - except subprocess.FileNotFoundError as e: - (f"Gradle wrapper not found at {corese_gradle_wrapper}") - continue - - # 4. Build the benchmark.groovy script using the local Corese version - print(f"Running Gradle build with Corese commit {coreseCommit}...") - gradle_version_arg = f"-PcoreseCommit={commit_dir}/corese-core/build/libs/" - try: - command = [gradle_wrapper, "clean", "build", gradle_version_arg] - print(f"Executing command: {' '.join(command)}") - subprocess.run(command, cwd=os.path.dirname(current_dir), check=True) - - # 5. Set the triplestore name for the output CSV - triplestore_name = f"corese.commit.{coreseCommit}"# Set the triplestore name for the output CSV - print(f"Executing the benchmark.groovy script with corese commit {coreseCommit}...") - # Usage: groovy benchmark.groovy - command = [gradle_wrapper, "runGroovyScript", "--args=" + unzip_dir + " " + out_dir + " " + triplestore_name] - print(f"Executing command: {' '.join(command)}") subprocess.run( - command, - cwd=os.path.dirname(current_dir), check=True - ) - print(f"Benchmark execution completed with corese commit {coreseCommit}...") + ["git", "clone", "https://github.com/corese-stack/corese-core"], + cwd=commit_dir, check=True + ) + subprocess.run( + ["git", "checkout", commit_hash], + cwd=os.path.join(commit_dir, "corese-core"), check=True + ) + corese_gradlew = os.path.join( + commit_dir, "corese-core", + "gradlew.bat" if sys.platform == "win32" else "gradlew" + ) + subprocess.run( + [corese_gradlew, "shadowJar"], + cwd=os.path.join(commit_dir, "corese-core"), check=True + ) except subprocess.CalledProcessError as e: - print(f"Error running Gradle build with Corese commit {coreseCommit}: {e}") + print(f"Error building commit {commit_hash}: {e}") continue - # 6. Clear the commit directory - os.chdir(current_dir) # or any directory outside commit_dir - shutil.rmtree(commit_dir) - print(f"Cleared commit directory at: {commit_dir}") - + libs_path = os.path.join(commit_dir, "corese-core", "build", "libs") + os.sep + engine_name = f"corese.commit.{commit_hash[:8]}" + ds_files = [] + for ds in dataset_names: + unzip_dir = dataset_dirs[ds] + ds_tag = f"_{ds}" if len(dataset_names) > 1 else "" + output_file = os.path.join(out_json_dir, f"{timestamp}{ds_tag}_{engine_name}.json") + gradle_build([f"-PcoreseCommit={libs_path}"]) + run_benchmark(engine_name, output_file, ds, unzip_dir, gradle_props=[f"-PcoreseCommit={libs_path}"]) + ds_files.append(output_file) + if len(ds_files) > 1: + merge_json_files(ds_files, os.path.join(out_json_dir, f"{timestamp}_all_{engine_name}.json")) + + shutil.rmtree(commit_base, ignore_errors=True) #################################################################################### -# Step 3: Results plotting +# Step 3: Copy latest report to site/data/ #################################################################################### -# Class PlotGenerator -######################## -python_script = os.path.join(current_dir, "plot-compare.py") -if not os.path.exists(python_script): - raise FileNotFoundError(f"Python script not found at {python_script}") -print(f"Running Python script to generate plots... at {python_script}") -command = ["python", python_script, out_dir] -print(f"Executing command: {' '.join(command)}") -subprocess.run(command, check=True) -print(f"Succesfully run Python script to generate plots!") \ No newline at end of file +json_files = sorted(glob.glob(os.path.join(out_json_dir, "*.json")), key=os.path.getmtime) +if json_files: + latest = json_files[-1] + + dest = os.path.join(site_data_dir, "benchmark-report.json") + shutil.copy(latest, dest) + print(f"\nReport copied to {dest}") + + latest_csv = latest.replace(out_json_dir, out_csv_dir).replace(".json", ".csv") + if os.path.exists(latest_csv): + csv_dest = os.path.join(site_data_dir, "benchmark-report.csv") + shutil.copy(latest_csv, csv_dest) + print(f"CSV copied to {csv_dest}") + + with open(latest) as f: + data = f.read() + with open(os.path.join(site_data_dir, "report-data.js"), "w") as f: + f.write(f"window.__BENCHMARK_DATA__ = {data};") + print("Generated report-data.js") + + history_dir = os.path.join(site_data_dir, "history") + os.makedirs(history_dir, exist_ok=True) + hist_filename = f"{timestamp}.json" + shutil.copy(latest, os.path.join(history_dir, hist_filename)) + + index_path = os.path.join(history_dir, "index.json") + if os.path.exists(index_path): + with open(index_path) as f: + history_index = _json_early.load(f) + else: + history_index = [] + + history_index.append({"file": hist_filename, "date": timestamp}) + history_index = history_index[-30:] + + with open(index_path, "w") as f: + _json_early.dump(history_index, f, indent=2) + print(f"History updated: {len(history_index)} entries in {index_path}") + + history_data = [] + for entry in history_index: + hist_file = os.path.join(history_dir, entry["file"]) + if os.path.exists(hist_file): + with open(hist_file) as hf: + history_data.append({"date": entry["date"], "data": _json_early.load(hf)}) + hist_js = os.path.join(site_data_dir, "history-data.js") + with open(hist_js, "w") as f: + f.write(f"window.__HISTORY_DATA__ = {_json_early.dumps(history_data)};") + print(f"Generated history-data.js ({len(history_data)} entries)") +else: + print("No JSON report found in out/") diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..136b6d5 --- /dev/null +++ b/site/app.js @@ -0,0 +1,1030 @@ +'use strict'; + +// ── Palette ─────────────────────────────────────────────────────────────────── +// +// Two levels: +// 1. ENGINE_FAMILIES → color by family (prefix before the first ".") +// e.g. all "corese.*" versions share the same blue +// 2. ENGINE_OVERRIDES → specific color for an exact engine name +// e.g. "corese.local" gets a lighter blue +// +// To add an engine: add an entry in ENGINE_FAMILIES or ENGINE_OVERRIDES. +// No match → color auto-assigned from AUTO_COLORS. + +const ENGINE_FAMILIES = { + // family hex bg (same hex + 0.75 opacity) + corese: { hex: '#4472C4', bg: 'rgba(68,114,196,0.75)' }, + jena: { hex: '#ED7D31', bg: 'rgba(237,125,49,0.75)' }, + rdf4j: { hex: '#70AD47', bg: 'rgba(112,173,71,0.75)' }, + oxigraph:{ hex: '#E05C5C', bg: 'rgba(224,92,92,0.75)' }, + blazegraph:{ hex: '#9B59B6', bg: 'rgba(155,89,182,0.75)'}, +}; + +const ENGINE_OVERRIDES = { + // Corese dev versions — light blue, same family as stable Corese + 'corese.local': { hex: '#5B9BD5', bg: 'rgba(91,155,213,0.75)' }, + 'corese.next': { hex: '#5B9BD5', bg: 'rgba(91,155,213,0.75)' }, +}; + +// Fallback colors auto-assigned (priority order) for unknown engines +const AUTO_COLORS = [ + { hex: '#E74C3C', bg: 'rgba(231,76,60,0.75)' }, + { hex: '#9B59B6', bg: 'rgba(155,89,182,0.75)' }, + { hex: '#1ABC9C', bg: 'rgba(26,188,156,0.75)' }, + { hex: '#F39C12', bg: 'rgba(243,156,18,0.75)' }, + { hex: '#2ECC71', bg: 'rgba(46,204,113,0.75)' }, + { hex: '#E91E63', bg: 'rgba(233,30,99,0.75)' }, + { hex: '#00BCD4', bg: 'rgba(0,188,212,0.75)' }, + { hex: '#FF5722', bg: 'rgba(255,87,34,0.75)' }, +]; + +// Dynamic resolution: builds engine → color map from data +let ENGINE_COLOR_MAP = {}; + +function buildColorMap(engines) { + let autoIdx = 0; + for (const engine of engines) { + if (ENGINE_COLOR_MAP[engine]) continue; // already assigned + + if (ENGINE_OVERRIDES[engine]) { + ENGINE_COLOR_MAP[engine] = ENGINE_OVERRIDES[engine]; + } else { + const family = engine.split('.')[0].toLowerCase(); + if (ENGINE_FAMILIES[family]) { + ENGINE_COLOR_MAP[engine] = ENGINE_FAMILIES[family]; + } else { + ENGINE_COLOR_MAP[engine] = AUTO_COLORS[autoIdx % AUTO_COLORS.length]; + autoIdx++; + } + } + } +} + +function pal(engineName) { + return ENGINE_COLOR_MAP[engineName] + ?? { hex: '#8b949e', bg: 'rgba(139,148,158,0.7)' }; +} + +function badgeCls(engineName) { + // Cherche la famille connue, sinon 'unknown' + const family = engineName.split('.')[0].toLowerCase(); + return ENGINE_FAMILIES[family] ? `badge-${family}` : 'badge-unknown'; +} + +// ── Data loading ────────────────────────────────────────────────────────────── +async function loadData() { + try { + const res = await fetch('data/benchmark-report.json'); + if (!res.ok) throw new Error(res.statusText); + return await res.json(); + } catch (_) { + if (typeof window.__BENCHMARK_DATA__ !== 'undefined') return window.__BENCHMARK_DATA__; + throw new Error('Aucune donnée. Lancez le benchmark d\'abord.'); + } +} + +// ── Utilitaires ─────────────────────────────────────────────────────────────── +const fmt = (n, d = 0) => + n == null ? '—' : Number(n).toLocaleString('fr-FR', { maximumFractionDigits: d }); + +function shortFile(f) { return f.replace('_en.nt', '').replace(/_/g, ' '); } + +function formatDate(iso) { + return new Date(iso).toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); +} + +// ── Theme ───────────────────────────────────────────────────────────────────── +const THEMES = ['auto', 'light', 'dark']; +let themeIdx = THEMES.indexOf(localStorage.getItem('theme') ?? 'auto'); + +function applyTheme() { + const theme = THEMES[themeIdx]; + const icons = { auto: 'icon-auto', light: 'icon-sun', dark: 'icon-moon' }; + + if (theme === 'auto') document.documentElement.removeAttribute('data-theme'); + else document.documentElement.setAttribute('data-theme', theme); + + ['icon-auto', 'icon-sun', 'icon-moon'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.display = id === icons[theme] ? '' : 'none'; + }); + localStorage.setItem('theme', theme); +} + +document.getElementById('theme-toggle')?.addEventListener('click', () => { + themeIdx = (themeIdx + 1) % THEMES.length; + applyTheme(); +}); + +applyTheme(); + +// ── Dataset metadata ────────────────────────────────────────────────────────── +// +// Maps dataset label → { label, description, url } +// Add a new entry here when a new dataset is supported. +const DATASET_META = { + dbpedia: { + label: 'DBpedia', + description: 'Real-world RDF dump — 5 N-Triples files from DBpedia 3.5.1', + url: 'https://downloads.dbpedia.org/3.5.1/en/', + }, + bowlogna: { + label: 'Bowlogna', + description: 'Synthetic university dataset with SPARQL queries (HOBBIT benchmark)', + url: 'https://files.dice-research.org/projects/HOBBIT/benchmarks-data/datasets-dumps/bowlogna-dump.zip', + }, + bsbm: { + label: 'BSBM', + description: 'Berlin SPARQL Benchmark — synthetic e-commerce dataset', + url: 'https://sourceforge.net/projects/bsbmtools/', + }, + lubm: { + label: 'LUBM', + description: 'Lehigh University Benchmark — university domain with RDFS reasoning', + url: 'https://swat.cse.lehigh.edu/projects/lubm/', + }, +}; + +function renderDatasetCards(runs, queries) { + const datasets = [...new Set(runs.map(r => r.dataset))]; + const grid = document.getElementById('dataset-grid'); + if (!grid) return; + const meta = document.getElementById('datasets-meta'); + if (meta) meta.textContent = `${datasets.length} dataset${datasets.length > 1 ? 's' : ''}`; + // Collapse by default when more than one dataset (keeps the page compact) + const details = grid.closest('details'); + if (details && datasets.length > 1) details.removeAttribute('open'); + + grid.innerHTML = datasets.map(ds => { + const meta = DATASET_META[ds.toLowerCase()] ?? { label: ds, description: '', url: null }; + const dsRuns = runs.filter(r => r.dataset === ds); + const files = [...new Set(dsRuns.map(r => r.file))]; + const engines = [...new Set(dsRuns.map(r => r.engine))]; + const totalTrip = dsRuns.reduce((s, r) => s + r.triples, 0) / engines.length; + + const linkHtml = meta.url + ? ` + + Source + ` + : ''; + + // File tags + const fileTags = files.map(f => + `${shortFile(f)}` + ).join(''); + + // SPARQL queries collapsible (only if queries embedded in JSON) + let queriesHtml = ''; + if (queries && queries.length > 0) { + const queryItems = queries.map((q, i) => ` +
+ Q${i + 1} +
${q.trim().replace(/
+        
`).join(''); + queriesHtml = ` +
+ + + ${queries.length} SPARQL queries + +
${queryItems}
+
`; + } + + return ` +
+
+ ${meta.label} + ${linkHtml} +
+ ${meta.description ? `

${meta.description}

` : ''} +
+
+
Files
+
${files.length}
+
+
+
Total triples
+
${fmt(totalTrip, 0)}
+
+
+
Format
+
N-Triples (.nt)
+
+
+
${fileTags}
+ ${queriesHtml} +
`; + }).join(''); +} + +// ── Table sort state ───────────────────────────────────────────────────────── +let sortField = null; +let sortAsc = true; + +function initTableSort() { + document.querySelectorAll('th[data-sort]').forEach(th => { + th.style.cursor = 'pointer'; + th.addEventListener('click', () => { + const field = th.dataset.sort; + if (sortField === field) { + sortAsc = !sortAsc; + } else { + sortField = field; + sortAsc = true; + } + // Update indicators + document.querySelectorAll('th[data-sort]').forEach(h => { + h.classList.remove('sort-asc', 'sort-desc'); + }); + th.classList.add(sortAsc ? 'sort-asc' : 'sort-desc'); + updateTable(); + }); + }); +} + +// ── Engine filter state ─────────────────────────────────────────────────────── +// activeEngines: Set of selected engine names. Empty = all engines shown. +let activeEngines = new Set(); +let allCharts = {}; + +// ── Header ──────────────────────────────────────────────────────────────────── + +function renderHeader(meta, runs) { + const datasets = [...new Set(runs.map(r => r.dataset))]; + const badgeContainer = document.getElementById('hdr-datasets'); + if (badgeContainer) { + badgeContainer.innerHTML = datasets.map(d => `${d}`).join(''); + } + document.getElementById('hdr-date').textContent = formatDate(meta.generatedAt); + document.getElementById('hdr-java').textContent = `Java ${meta.java}`; + + const engines = [...new Set(runs.map(r => r.engine))]; + const engineHtml = engines.map(e => `${e}`).join(' · '); + const desc = document.getElementById('main-desc'); + if (desc) desc.innerHTML = + `Corese reference benchmark dashboard — Loading performance comparison : ${engineHtml}`; +} + +// ── Run info bar ────────────────────────────────────────────────────────────── +function renderRunInfo(meta) { + document.getElementById('info-date').textContent = formatDate(meta.generatedAt); + document.getElementById('info-java').textContent = meta.java ?? '—'; + document.getElementById('info-os').textContent = meta.os ?? '—'; + document.getElementById('info-protocol').textContent = + `${meta.protocol.repetitions} repetitions + warmup`; +} + +// ── KPIs ────────────────────────────────────────────────────────────────────── +function renderKPI(runs) { + const engines = [...new Set(runs.map(r => r.engine))]; + const files = [...new Set(runs.map(r => r.file))]; + const totalTrip = runs.reduce((s, r) => s + r.triples, 0); + const bestR = runs.reduce((best, r) => + r.throughputTriplesPerSec > best.throughputTriplesPerSec ? r : best, runs[0]); + + const kpis = [ + { label: 'Engines tested', num: engines.length, note: engines.join(' · ') }, + { label: 'Files tested', num: files.length, note: '.nt (N-Triples) files' }, + { label: 'Total triples', num: fmt(totalTrip), note: 'loaded in total' }, + { label: 'Best throughput', num: fmt(bestR.throughputTriplesPerSec, 0), note: bestR.engine }, + ]; + + document.getElementById('metrics-grid').innerHTML = kpis.map(k => ` +
+
${k.label}
+
${k.num}
+
${k.note}
+
+ `).join(''); +} + +// ── Global meter (proportion bars per engine) ───────────────────────────────── +function renderGlobalMeter(runs) { + const engines = [...new Set(runs.map(r => r.engine))]; + const total = runs.length; + const meter = document.getElementById('global-meter'); + meter.innerHTML = engines.map(e => { + const count = runs.filter(r => r.engine === e).length; + const pct = (count / total * 100).toFixed(1); + return `
`; + }).join(''); +} + +// ── Engine cards ────────────────────────────────────────────────────────────── +// Detects if an engine is a Corese development version +const isDevCorese = e => e === 'corese.local' || e === 'corese.next' || e.startsWith('corese.commit.'); +const isStableCorese = e => e.startsWith('corese.') && !isDevCorese(e); + +function renderEngineCards(runs) { + const engines = [...new Set(runs.map(r => r.engine))]; + + // Average throughput per engine + const avgThruMap = {}; + engines.forEach(engine => { + const eruns = runs.filter(r => r.engine === engine); + avgThruMap[engine] = eruns.reduce((s, r) => s + r.throughputTriplesPerSec, 0) / eruns.length; + }); + + // Reference: stable Corese version if present, otherwise the best engine + const stableCorese = engines.find(isStableCorese); + const maxAvgThru = Math.max(...Object.values(avgThruMap)); + const refEngine = stableCorese ?? engines.find(e => avgThruMap[e] === maxAvgThru); + const refThru = avgThruMap[refEngine]; + + document.getElementById('engine-grid').innerHTML = engines.map(engine => { + const eruns = runs.filter(r => r.engine === engine); + const avgTime = eruns.reduce((s, r) => s + r.loadingTimeMs, 0) / eruns.length; + const avgMem = eruns.reduce((s, r) => s + r.memoryDeltaMb, 0) / eruns.length; + const avgThru = avgThruMap[engine]; + const score = Math.round(avgThru / refThru * 100); + const p = pal(engine); + + // Libellé "vitesse relative" selon le contexte + let relativeLabel, tooltip, scoreDisplay; + if (engine === refEngine) { + relativeLabel = '🏆 reference'; + tooltip = 'Stable Corese reference version'; + scoreDisplay = '100 / 100'; + } else if (stableCorese && isDevCorese(engine)) { + const delta = score - 100; + if (delta >= 0) { + relativeLabel = `📈 +${delta}% improvement`; + tooltip = `${delta}% faster than ${refEngine}`; + } else { + relativeLabel = `📉 ${delta}% regression`; + tooltip = `${Math.abs(delta)}% slower than ${refEngine}`; + } + scoreDisplay = `${score} / 100`; + } else { + const ratio = (refThru / avgThru).toFixed(1); + relativeLabel = ratio > 1 ? `${ratio}× slower` : `${(1/ratio).toFixed(1)}× faster`; + tooltip = `Compared to ${refEngine}`; + scoreDisplay = `${score} / 100`; + } + + const meterW = Math.min(score, 100); // bar capped at 100% visually + + return ` +
+
+ + + ${engine} + + ${scoreDisplay} +
+
+
+
Avg. time
+
${fmt(avgTime, 0)} ms
+
+
+
Avg. throughput
+
${fmt(avgThru, 0)} t/s
+
+
+
Avg. memory
+
${fmt(avgMem, 1)} MB
+
+
+
Relative speed
+
${relativeLabel}
+
+
+
+
+
+
+ `; + }).join(''); +} + +function toggleEngine(engine) { + if (activeEngines.has(engine)) { + activeEngines.delete(engine); + } else { + activeEngines.add(engine); + } + document.querySelectorAll('.suite-item[data-engine]').forEach(el => { + el.classList.toggle('active', activeEngines.has(el.dataset.engine)); + }); + // Show/hide clear button + const btn = document.getElementById('clear-engines-btn'); + if (btn) btn.style.display = activeEngines.size > 0 ? '' : 'none'; + updateCharts(); + updateTable(); +} + +function clearEngineFilter() { + activeEngines.clear(); + document.querySelectorAll('.suite-item[data-engine]').forEach(el => el.classList.remove('active')); + const btn = document.getElementById('clear-engines-btn'); + if (btn) btn.style.display = 'none'; + updateCharts(); + updateTable(); +} + +// ── Engine card drag & drop ─────────────────────────────────────────────────── +let dragSrc = null; + +function dragStart(e) { + dragSrc = e.currentTarget; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragSrc.dataset.engine); + dragSrc.classList.add('dragging'); +} + +function dragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const target = e.currentTarget; + if (target !== dragSrc) target.classList.add('drag-over'); +} + +function dragLeave(e) { + e.currentTarget.classList.remove('drag-over'); +} + +function dragDrop(e) { + e.preventDefault(); + const target = e.currentTarget; + target.classList.remove('drag-over'); + if (!dragSrc || dragSrc === target) return; + + // Guard: source and target must be in the same grid + const grid = document.getElementById('engine-grid'); + if (!grid.contains(dragSrc) || !grid.contains(target)) return; + + const items = [...grid.querySelectorAll('.suite-item[data-engine]')]; + const srcIdx = items.indexOf(dragSrc); + const tgtIdx = items.indexOf(target); + + if (srcIdx < tgtIdx) grid.insertBefore(dragSrc, target.nextSibling); + else grid.insertBefore(dragSrc, target); + + const order = [...grid.querySelectorAll('.suite-item[data-engine]')].map(el => el.dataset.engine); + localStorage.setItem('engineOrder', JSON.stringify(order)); +} + +function dragQueryDrop(e) { + e.preventDefault(); + const target = e.currentTarget; + target.classList.remove('drag-over'); + if (!dragSrc || dragSrc === target) return; + + // Guard: source and target must be in the same grid + const grid = document.getElementById('query-cards-grid'); + if (!grid || !grid.contains(dragSrc) || !grid.contains(target)) return; + + const items = [...grid.querySelectorAll('.query-engine-card[data-engine]')]; + const srcIdx = items.indexOf(dragSrc); + const tgtIdx = items.indexOf(target); + + if (srcIdx < tgtIdx) grid.insertBefore(dragSrc, target.nextSibling); + else grid.insertBefore(dragSrc, target); +} + +function dragEnd(e) { + e.currentTarget.classList.remove('dragging'); + document.querySelectorAll('.suite-item').forEach(el => el.classList.remove('drag-over')); +} + +function applyStoredEngineOrder() { + const stored = localStorage.getItem('engineOrder'); + if (!stored) return; + try { + const order = JSON.parse(stored); + const grid = document.getElementById('engine-grid'); + + // 1. Move engines that are in the stored order (skip unknowns) + order.forEach(engine => { + const el = grid.querySelector(`.suite-item[data-engine="${engine}"]`); + if (el) grid.appendChild(el); + }); + + // 2. Append engines NOT in stored order at the end (new engines from a different run) + grid.querySelectorAll('.suite-item[data-engine]').forEach(el => { + if (!order.includes(el.dataset.engine)) grid.appendChild(el); + }); + } catch (_) {} +} + +// ── Charts ──────────────────────────────────────────────────────────────────── +let allRuns = []; + +const CHART_OPT = (yLabel) => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom', labels: { boxWidth: 10, padding: 12, font: { size: 11, family: "'Inter'" } } }, + tooltip: { mode: 'index', intersect: false }, + }, + scales: { + x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 20 } }, + y: { + grid: { color: 'rgba(255,255,255,0.05)' }, + ticks: { font: { size: 10 } }, + beginAtZero: true, + title: { display: true, text: yLabel, font: { size: 10 }, color: '#8b949e' }, + }, + }, +}); + +function buildDatasets(field) { + const engines = [...new Set(allRuns.map(r => r.engine))]; + const files = [...new Set(allRuns.map(r => r.file))]; + return engines.map(engine => { + const p = pal(engine); + const visible = activeEngines.size === 0 || activeEngines.has(engine); + return { + label: engine, + data: files.map(f => { + const r = allRuns.find(x => x.engine === engine && x.file === f); + return r ? +r[field].toFixed(1) : null; + }), + backgroundColor: visible ? p.bg : 'rgba(100,100,100,0.15)', + borderColor: visible ? p.hex : 'rgba(100,100,100,0.3)', + borderWidth: 1, + borderRadius: 3, + }; + }); +} + +function initCharts() { + const files = [...new Set(allRuns.map(r => r.file))]; + const labels = files.map(shortFile); + + const configs = [ + { id: 'chart-loading', field: 'loadingTimeMs', yLabel: 'ms' }, + { id: 'chart-throughput', field: 'throughputTriplesPerSec', yLabel: 'triples/s' }, + { id: 'chart-memory', field: 'memoryDeltaMb', yLabel: 'MB' }, + { id: 'chart-cpu', field: 'cpuPercent', yLabel: '%' }, + ]; + + for (const { id, field, yLabel } of configs) { + allCharts[id] = new Chart(document.getElementById(id).getContext('2d'), { + type: 'bar', + data: { labels, datasets: buildDatasets(field) }, + options: CHART_OPT(yLabel), + }); + allCharts[id]._field = field; + } +} + +function updateCharts() { + for (const [id, chart] of Object.entries(allCharts)) { + chart.data.datasets = buildDatasets(chart._field); + chart.update('none'); + } +} + +// ── Tableau ──────────────────────────────────────────────────────────────────── +function renderTable(runs) { + updateTable(runs); +} + +function updateTable(runsArg) { + let runs = (runsArg ?? allRuns).filter(r => + activeEngines.size === 0 || activeEngines.has(r.engine) + ); + + if (sortField) { + runs = [...runs].sort((a, b) => { + const av = a[sortField], bv = b[sortField]; + if (typeof av === 'string') return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av); + return sortAsc ? av - bv : bv - av; + }); + } + + const tbody = document.getElementById('results-body'); + const thead = tbody.closest('table').querySelector('thead tr'); + tbody.innerHTML = ''; + + // 2-engine diff mode: when exactly 2 engines are selected + const diffMode = activeEngines.size === 2; + const [engA, engB] = diffMode ? [...activeEngines] : [null, null]; + + // Update thead diff column + const existingDiff = thead.querySelector('th.diff-col'); + if (existingDiff) existingDiff.remove(); + if (diffMode) { + const th = document.createElement('th'); + th.className = 'text-right diff-col'; + th.title = `${engA} vs ${engB}`; + th.textContent = 'Δ throughput'; + thead.insertBefore(th, thead.lastElementChild); + } + + // Group by file for diff calculation + const files = [...new Set(runs.map(r => r.file))]; + const grouped = {}; + if (diffMode) { + files.forEach(f => { + grouped[f] = {}; + runs.filter(r => r.file === f).forEach(r => { grouped[f][r.engine] = r; }); + }); + } + + for (const r of runs) { + const tr = document.createElement('tr'); + let diffCell = ''; + if (diffMode) { + const peer = grouped[r.file]?.[r.engine === engA ? engB : engA]; + if (peer) { + const peerThru = peer.throughputTriplesPerSec; + const delta = peerThru > 0 + ? ((r.throughputTriplesPerSec - peerThru) / peerThru * 100).toFixed(1) + : null; + const cls = delta == null ? '' : delta > 0 ? 'diff-better' : delta < 0 ? 'diff-worse' : ''; + diffCell = delta == null + ? `—` + : `${delta > 0 ? '+' : ''}${delta}%`; + } else { + diffCell = `—`; + } + } + tr.innerHTML = ` + ${r.engine} + ${shortFile(r.file)} + ${fmt(r.triples)} + ${fmt(r.loadingTimeMs)} + ${fmt(r.throughputTriplesPerSec, 0)} + ${fmt(r.memoryDeltaMb, 1)} + ${fmt(r.cpuPercent, 1)} + ${diffMode ? diffCell : ''} + ${r.repetitionsMs.join(' · ')} + `; + tbody.appendChild(tr); + } +} + +// ── Query benchmark section ─────────────────────────────────────────────────── +let activeQueryEngines = new Set(); +let queryChart = null; +let allQueryRuns = []; + +function toggleQueryEngine(engine) { + if (activeQueryEngines.has(engine)) { + activeQueryEngines.delete(engine); + } else { + activeQueryEngines.add(engine); + } + document.querySelectorAll('.query-engine-card[data-engine]').forEach(el => { + el.classList.toggle('active', activeQueryEngines.has(el.dataset.engine)); + }); + const btn = document.getElementById('clear-query-engines-btn'); + if (btn) btn.style.display = activeQueryEngines.size > 0 ? '' : 'none'; + updateQueryChart(); + updateQueryTable(); +} + +function clearQueryEngineFilter() { + activeQueryEngines.clear(); + document.querySelectorAll('.query-engine-card[data-engine]').forEach(el => el.classList.remove('active')); + const btn = document.getElementById('clear-query-engines-btn'); + if (btn) btn.style.display = 'none'; + updateQueryChart(); + updateQueryTable(); +} + +function updateQueryChart() { + if (!queryChart) return; + const engines = [...document.querySelectorAll('.query-engine-card[data-engine]')] + .map(el => el.dataset.engine); + queryChart.data.datasets = queryChart.data.datasets.map(ds => { + const visible = activeQueryEngines.size === 0 || activeQueryEngines.has(ds.label); + const p = pal(ds.label); + return { ...ds, + backgroundColor: visible ? p.bg : 'rgba(100,100,100,0.15)', + borderColor: visible ? p.hex : 'rgba(100,100,100,0.3)', + }; + }); + queryChart.update('none'); +} + +function updateQueryTable() { + const tbody = document.getElementById('query-results-body'); + if (!tbody) return; + const table = tbody.closest('table'); + if (!table) return; + + // Map engine → column index (0-based) from thead + const headers = [...table.querySelectorAll('thead th')]; + const engineColIndex = {}; + headers.forEach((th, i) => { + if (th.dataset.engine) engineColIndex[th.dataset.engine] = i; + }); + + // Apply opacity per cell using direct column index + const allRows = [...table.querySelectorAll('tr')]; + allRows.forEach(tr => { + const cells = [...tr.children]; + headers.forEach((th, i) => { + const engine = th.dataset.engine; + if (!engine) return; + const visible = activeQueryEngines.size === 0 || activeQueryEngines.has(engine); + if (cells[i]) cells[i].style.opacity = visible ? '' : '0.25'; + }); + }); +} + +function renderQuerySection(queryRuns) { + if (!queryRuns || queryRuns.length === 0) return; + allQueryRuns = queryRuns; + + const engines = [...new Set(queryRuns.map(r => r.engine))]; + const queryIds = [...new Set(queryRuns.map(r => r.queryId))].sort( + (a, b) => parseInt(a.split('_')[1]) - parseInt(b.split('_')[1]) + ); + + // Summary cards per engine — draggable + clickable + const cards = engines.map(engine => { + const eruns = queryRuns.filter(r => r.engine === engine && r.medianMs >= 0); + const avgMs = eruns.length ? eruns.reduce((s, r) => s + r.medianMs, 0) / eruns.length : 0; + const sorted = eruns.map(r => r.medianMs).sort((a,b)=>a-b); + const p50 = sorted.length ? sorted[Math.floor(sorted.length * 0.50)] : 0; + const p95 = sorted.length ? sorted[Math.floor(sorted.length * 0.95)] : 0; + const p99 = sorted.length ? sorted[Math.floor(sorted.length * 0.99)] : 0; + const p = pal(engine); + return ` +
+
+ + ${engine} +
+
+
Avg.
${fmt(avgMs,0)} ms
+
p50
${fmt(p50,0)} ms
+
p95
${fmt(p95,0)} ms
+
p99
${fmt(p99,0)} ms
+
Queries
${eruns.length}
+
+
`; + }).join(''); + + // Bar chart data + const datasets = engines.map(engine => { + const p = pal(engine); + return { + label: engine, + data: queryIds.map(qid => { + const r = queryRuns.find(r => r.engine === engine && r.queryId === qid); + return r && r.medianMs >= 0 ? r.medianMs : null; + }), + backgroundColor: p.bg, + borderColor: p.hex, + borderWidth: 1, + }; + }); + + const section = document.createElement('section'); + section.className = 'query-section'; + section.innerHTML = ` +
+ +

SPARQL Queries

+ ${queryIds.length} queries · ${engines.length} engines · ms, lower is better +
+
+

Median execution time per query — 3 repetitions

+ +
+
${cards}
+
+
+ Time per query + ms — lower is better +
+ +
+
+
+ + + + ${engines.map(e => ``).join('')} + + + + ${queryIds.map(qid => { + // Valid values for this query + const rowData = engines.map(engine => { + const r = queryRuns.find(r => r.engine === engine && r.queryId === qid); + return r && r.medianMs >= 0 ? r.medianMs : null; + }); + const validTimes = rowData.filter(v => v !== null); + const minMs = validTimes.length ? Math.min(...validTimes) : null; + const maxMs = validTimes.length ? Math.max(...validTimes) : null; + const winner = minMs !== null ? engines[rowData.indexOf(minMs)] : null; + const allFailed = validTimes.length === 0; + + const cells = engines.map((engine, i) => { + const ms = rowData[i]; + if (ms === null) return ``; + const isBest = ms === minMs; + const r = minMs ? ms / minMs : 1; + const isRed = !isBest && r >= 20; + const isOrange= !isBest && r >= 5 && r < 20; + const ratio = !isBest && r >= 2 ? ` ×${r.toFixed(1)}` : ''; + const cls = isBest ? 'query-best' : isRed ? 'query-worst' : isOrange ? 'query-slow' : ''; + return ``; + }).join(''); + + const bestCell = winner + ? `` + : ``; + + return `${''}${cells}${bestCell}`; + }).join('')} + +
Requête ${e}Best
—${fmt(ms)} ms${ratio}${winner}—
' + qid + '
+
+
+
+ `; + document.querySelector('.app-wrapper').appendChild(section); + + // Render chart — store reference for filter updates + queryChart = new Chart(document.getElementById('chart-queries'), { + type: 'bar', + data: { labels: queryIds, datasets }, + options: { + responsive: true, + plugins: { + legend: { position: 'bottom', labels: { boxWidth: 10, padding: 12, font: { size: 11, family: "'Inter'" } } }, + tooltip: { callbacks: { label: ctx => ` ${ctx.dataset.label}: ${fmt(ctx.raw, 0)} ms` } }, + }, + scales: { + x: { grid: { display: false }, ticks: { font: { size: 10 } } }, + y: { beginAtZero: true, ticks: { callback: v => fmt(v, 0) + ' ms' } }, + }, + }, + }); +} + +// ── Tendance historique ─────────────────────────────────────────────────────── +async function renderTrend() { + let valid; + + // Priority: inline data (file:// compatible) + if (window.__HISTORY_DATA__ && window.__HISTORY_DATA__.length >= 2) { + valid = window.__HISTORY_DATA__; + } else { + // Fallback : fetch (fonctionne sur serveur HTTP) + let index; + try { + const res = await fetch('data/history/index.json'); + if (!res.ok) return; + index = await res.json(); + } catch (_) { return; } + if (!index || index.length < 2) return; + + const reports = await Promise.all( + index.map(async entry => { + try { + const r = await fetch(`data/history/${entry.file}`); + if (!r.ok) return null; + const d = await r.json(); + return { date: entry.date, data: d }; + } catch (_) { return null; } + }) + ); + valid = reports.filter(Boolean); + if (valid.length < 2) return; + } + + // Engines: only those present in the current run (allRuns), to keep the chart focused + const currentEngines = new Set(allRuns.map(r => r.engine)); + const engines = [...new Set(valid.flatMap(r => r.data.runs.map(run => run.engine)))] + .filter(e => currentEngines.has(e)); + + // Time series: average throughput per engine + const datasets = engines.map(engine => { + const p = pal(engine); + return { + label: engine, + data: valid.map(r => { + const runs = r.data.runs.filter(run => run.engine === engine); + if (!runs.length) return null; + return Math.round(runs.reduce((s, run) => s + run.throughputTriplesPerSec, 0) / runs.length); + }), + borderColor: p.hex, + backgroundColor: p.bg, + tension: 0.3, + fill: false, + pointRadius: 4, + }; + }); + + const labels = valid.map(r => { + const d = r.date; // format YYYYMMDD_HHMMSS + return `${d.slice(6,8)}/${d.slice(4,6)} ${d.slice(9,11)}:${d.slice(11,13)}`; + }); + + const section = document.createElement('section'); + section.innerHTML = ` +
+ +

Historical trend

+ ${valid.length} runs · avg throughput per engine +
+
+
+ Throughput over time + triples/s — higher is better +
+ +
+
+ `; + document.querySelector('.app-wrapper').appendChild(section); + + new Chart(document.getElementById('chart-trend'), { + type: 'line', + data: { labels, datasets }, + options: { + responsive: true, + plugins: { + legend: { position: 'bottom', labels: { boxWidth: 10, padding: 12, font: { size: 11, family: "'Inter'" } } }, + tooltip: { callbacks: { label: ctx => ` ${ctx.dataset.label}: ${fmt(ctx.raw, 0)} t/s` } }, + }, + scales: { + x: { grid: { display: false }, ticks: { font: { size: 10 } } }, + y: { beginAtZero: false, ticks: { callback: v => fmt(v, 0) } }, + }, + }, + }); +} + +// ── PNG export ──────────────────────────────────────────────────────────────── +function downloadChart(canvasId, name) { + const canvas = document.getElementById(canvasId); + if (!canvas) return; + const a = document.createElement('a'); + a.download = `corese-benchmark-${name}.png`; + a.href = canvas.toDataURL('image/png'); + a.click(); +} + +// ── Main ───────────────────────────────────────────────────────────────────── +async function main() { + let data; + try { + data = await loadData(); + } catch (err) { + document.querySelector('.app-wrapper').innerHTML += + `
+
⚠
+
No benchmark data
+
${err.message}
+
+ Run make compare or make all to generate a report, + then refresh this page. +
+
`; + return; + } + + allRuns = data.runs; + + // Build color map from engines present in the data + const engines = [...new Set(allRuns.map(r => r.engine))]; + buildColorMap(engines); + + renderHeader(data.metadata, allRuns); + renderRunInfo(data.metadata); + renderKPI(allRuns); + renderGlobalMeter(allRuns); + renderDatasetCards(allRuns, data.metadata.queries ?? null); + renderEngineCards(allRuns); + applyStoredEngineOrder(); + initCharts(); + initTableSort(); + renderTable(allRuns); + const meta = document.getElementById('full-results-meta'); + if (meta) meta.textContent = `${allRuns.length} rows · ${[...new Set(allRuns.map(r=>r.engine))].length} engines`; + + // Query section (optional — only if queryRuns present in JSON) + if (data.queryRuns && data.queryRuns.length > 0) { + renderQuerySection(data.queryRuns); + } + + // Historical trend (async, non-blocking) + renderTrend(); +} + +main(); diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..cc94017 --- /dev/null +++ b/site/index.html @@ -0,0 +1,210 @@ + + + + + + Corese · Benchmark Dashboard + + + + + + + +
+ + + + + +
+ Run info + Generated— + Java— + OS— + Protocol— +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +

Datasets

+ +
+
+ +
+
+ + +
+

Engines tested

+ +
+

Click one or more engines to filter the charts

+
+ +
+ + +

Resource usage

+
+ +
+
+ Memory used + ΔMB — lower is better + +
+ +
+ +
+
+ CPU + % — lower is better + +
+ +
+ +
+ + +

Performance

+
+ +
+
+ Throughput + triples/s — higher is better + +
+ +
+ +
+
+ Loading time + ms — lower is better + +
+ +
+ +
+ + +
+ +

Full results

+ +
+
+
+ + + + + + + + + + + + + + +
EngineFileTriplesLoading (ms)Throughput (t/s)Memory ΔMBCPU %Repetitions (ms)
+
+
+
+ +
+ + + + + + + + diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..4fff00e --- /dev/null +++ b/site/styles.css @@ -0,0 +1,725 @@ +/* ── Variables (même système que corese-w3c) ──────────────────────────────── */ +:root { + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + + /* Thème sombre (défaut) */ + --bg-page: #0d1117; + --bg-surface: #161b22; + --bg-surface-hover: #1c2128; + --bg-surface-active: #21262d; + --border-color: #30363d; + --border-subtle: #21262d; + + --text-main: #e6edf3; + --text-muted: #8b949e; + --text-faint: #6e7681; + + --accent: #58a6ff; + --accent-hover: #79c0ff; + + /* Couleurs engines */ + --corese-color: #4472C4; + --corese-bg: rgba(68, 114, 196, 0.15); + --jena-color: #ED7D31; + --jena-bg: rgba(237, 125, 49, 0.15); + --rdf4j-color: #70AD47; + --rdf4j-bg: rgba(112, 173, 71, 0.15); + + --radius-sm: 4px; + --radius-md: 6px; +} + +[data-theme="light"] { + --bg-page: #ffffff; + --bg-surface: #f6f8fa; + --bg-surface-hover: #eaeef2; + --bg-surface-active: #e1e4e8; + --border-color: #d0d7de; + --border-subtle: #eaeef2; + + --text-main: #1f2328; + --text-muted: #57606a; + --text-faint: #8c959f; + + --accent: #0969da; + --accent-hover: #0550ae; +} + +/* ── Reset ────────────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-sans); + background-color: var(--bg-page); + color: var(--text-main); + line-height: 1.5; + font-size: 13px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.font-mono { font-family: var(--font-mono); } +.text-right { text-align: right; } + +.app-wrapper { + max-width: 1200px; + margin: 0 auto; + padding: 24px 20px 60px; +} + +/* ── Header ───────────────────────────────────────────────────────────────── */ +.page-header { + border-bottom: 1px solid var(--border-color); + padding-bottom: 18px; + margin-bottom: 24px; +} + +.header-top-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.brand-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.main-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.3px; +} + +.badge-tag { + font-size: 11px; + font-weight: 600; + background-color: var(--bg-surface); + border: 1px solid var(--border-color); + padding: 2px 8px; + border-radius: var(--radius-sm); + color: var(--text-muted); + white-space: nowrap; +} + +.header-controls { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.meta-tags { + display: flex; + align-items: center; + gap: 8px; +} + +.meta-tag { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +.download-group { + display: flex; + gap: 6px; +} + +.btn-download { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 4px 9px; + text-decoration: none; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.btn-download:hover { + background: var(--bg-surface-hover); + border-color: var(--accent); + color: var(--accent); +} + +.theme-btn { + display: inline-flex; + align-items: center; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 5px 8px; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; +} +.theme-btn:hover { background: var(--bg-surface-hover); color: var(--text-main); } + +.main-desc { + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; +} + +/* ── Run info bar ─────────────────────────────────────────────────────────── */ +.run-info-bar { + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 10px 16px; + margin-bottom: 20px; + font-size: 12px; +} + +.run-info-label { + font-weight: 700; + font-size: 11px; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--text-faint); + margin-right: 4px; +} + +.run-info-item { + display: flex; + align-items: center; + gap: 6px; +} + +.run-info-key { + font-size: 11px; + font-weight: 600; + color: var(--text-faint); +} + +/* ── Metrics grid (KPI) ───────────────────────────────────────────────────── */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.metric-block { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 14px 16px 10px; + transition: border-color 0.15s, background-color 0.15s; +} +.metric-block:hover { + background: var(--bg-surface-hover); + border-color: var(--text-faint); +} + +.metric-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-muted); + margin-bottom: 6px; +} + +.metric-num { + font-size: 26px; + font-weight: 700; + font-family: var(--font-mono); + line-height: 1; + color: var(--text-main); +} + +.metric-note { + font-size: 11px; + color: var(--text-faint); + margin-top: 5px; +} + +/* ── Global meter ─────────────────────────────────────────────────────────── */ +.global-meter-wrap { + margin-bottom: 28px; +} + +.global-meter { + display: flex; + height: 8px; + border-radius: 4px; + overflow: hidden; + gap: 0; + box-shadow: inset 0 1px 2px rgba(0,0,0,.2); + background: var(--border-subtle); +} + +.meter-segment { + height: 100%; + transition: width 0.3s ease; +} + +/* ── Section headings ─────────────────────────────────────────────────────── */ +.section-heading { + font-size: 14px; + font-weight: 700; + color: var(--text-main); + margin-bottom: 6px; +} + +.section-sub { + font-size: 12px; + color: var(--text-faint); + margin-bottom: 12px; + text-align: right; +} + +/* ── Engine cards (suite-item style) ─────────────────────────────────────── */ +.suites-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 10px; +} + +.suite-item { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 14px; + cursor: pointer; + transition: border-color 0.15s, background-color 0.15s; + user-select: none; +} +.suite-item:hover { + background: var(--bg-surface-hover); + border-color: var(--text-faint); +} +.suite-item.active { + border-color: var(--accent); + background: var(--bg-surface-active); +} + +.suite-item-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.suite-name { + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.section-heading-row { + display: flex; + align-items: center; + gap: 12px; +} + +.btn-clear-filter { + font-size: 11px; + padding: 3px 10px; + border-radius: 4px; + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-muted); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.btn-clear-filter:hover { color: var(--color-text); border-color: var(--color-text); } + +.suite-item[draggable="true"] { cursor: grab; } +.suite-item[draggable="true"]:active { cursor: grabbing; } +.suite-item.dragging { opacity: 0.4; } +.suite-item.drag-over { outline: 2px dashed var(--color-accent); outline-offset: 2px; } + +.dataset-card { cursor: default; } +.dataset-card:hover { transform: none; box-shadow: none; } + +.dataset-desc { + font-size: 11px; + color: var(--color-muted); + margin: 0 0 8px; + line-height: 1.4; +} + +.file-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +.file-tag { + font-size: 10px; + font-family: var(--font-mono); + padding: 2px 7px; + border-radius: 3px; + background: var(--color-surface2); + color: var(--color-muted); + border: 1px solid var(--color-border); +} + +.queries-details { + margin-top: 10px; + border-top: 1px solid var(--color-border); + padding-top: 8px; +} + +.queries-summary { + list-style: none; + cursor: pointer; + font-size: 11px; + color: var(--color-accent); + display: flex; + align-items: center; + gap: 5px; + user-select: none; +} +.queries-summary::-webkit-details-marker { display: none; } +.queries-summary svg { transition: transform 0.2s; } +.queries-details[open] .queries-summary svg { transform: rotate(90deg); } + +.queries-list { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; + max-height: 300px; + overflow-y: auto; +} + +.query-item { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.query-item-id { + font-size: 10px; + font-weight: 600; + color: var(--color-accent); + min-width: 24px; + padding-top: 3px; +} + +.query-item-text { + font-size: 10px; + font-family: var(--font-mono); + color: var(--color-muted); + margin: 0; + white-space: pre-wrap; + word-break: break-all; + background: var(--color-surface2); + padding: 4px 8px; + border-radius: 3px; + flex: 1; +} + +.dataset-source-link { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--color-accent); + text-decoration: none; + padding: 2px 8px; + border: 1px solid var(--color-accent); + border-radius: 4px; + opacity: 0.8; + transition: opacity 0.15s; +} +.dataset-source-link:hover { opacity: 1; text-decoration: none; } + +.engine-dot { + width: 9px; height: 9px; + border-radius: 50%; + flex-shrink: 0; +} + +.suite-pct { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; + color: var(--text-muted); +} + +.suite-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 12px; + margin-bottom: 10px; +} + +.stat-item .stat-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--text-faint); + font-weight: 600; +} +.stat-item .stat-value { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-main); +} + +.suite-meter { + display: flex; + height: 5px; + border-radius: 3px; + overflow: hidden; + background: var(--border-subtle); +} + +/* ── Charts ───────────────────────────────────────────────────────────────── */ +.charts-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.charts-grid--sm .chart-surface canvas { + max-height: 180px; +} + +@media (max-width: 800px) { .charts-grid { grid-template-columns: 1fr; } } + +.chart-surface { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 16px 18px 12px; + height: 300px; +} + +.chart-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.chart-title { + font-size: 13px; + font-weight: 600; + color: var(--text-main); +} + +.chart-unit { + font-size: 11px; + color: var(--text-faint); + flex: 1; +} + +.btn-chart-png { + font-size: 10px; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, color 0.15s; + white-space: nowrap; +} +.chart-surface:hover .btn-chart-png { opacity: 1; } +.btn-chart-png:hover { color: var(--color-text); border-color: var(--color-text); } + +.chart-surface canvas { max-height: 218px; } + +/* ── Table ────────────────────────────────────────────────────────────────── */ +.table-surface { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; +} + +.table-scroll { overflow-x: auto; } + +.report-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.report-table thead th { + background: var(--bg-surface-hover); + padding: 9px 12px; + text-align: left; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--text-muted); + border-bottom: 2px solid var(--border-color); + white-space: nowrap; +} +.report-table thead th.text-right, +.report-table tbody td.text-right { text-align: right; } + +.sortable { user-select: none; } +.sortable:hover { color: var(--color-accent); } +.sort-asc::after { content: ' ↑'; font-size: 10px; color: var(--color-accent); } +.sort-desc::after { content: ' ↓'; font-size: 10px; color: var(--color-accent); } + +.diff-col { color: var(--color-muted); font-style: italic; } +.diff-better { color: #3fb950; font-weight: 600; } +.diff-worse { color: #f85149; font-weight: 600; } + +.report-table tbody tr { + border-bottom: 1px solid var(--border-subtle); + cursor: default; + transition: background 0.1s; +} +.report-table tbody tr:last-child { border-bottom: none; } +.report-table tbody tr:hover { background: var(--bg-surface-hover); } + +.report-table tbody td { + padding: 8px 12px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +/* ── Engine badge ─────────────────────────────────────────────────────────── */ +.engine-badge { + display: inline-block; + padding: 2px 7px; + border-radius: 10px; + font-size: 10px; + font-weight: 700; + font-family: var(--font-mono); + letter-spacing: .02em; + color: #fff; +} +/* Familles connues — couleur fixe via variable CSS */ +.badge-corese { background: var(--corese-color); } +.badge-jena { background: var(--jena-color); } +.badge-rdf4j { background: var(--rdf4j-color); } +.badge-oxigraph { background: #E05C5C; } +.badge-blazegraph{ background: #9B59B6; } +/* Fallback : la couleur est injectée via style inline par app.js */ +.badge-unknown { background: #8b949e; } + +.reps { + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 11px; +} + +/* ── Query section ────────────────────────────────────────────────────────── */ +.query-section { margin-top: 40px; } +.query-section h2 { font-size: 18px; font-weight: 600; margin-bottom: 4px; } + +.query-details { border: none; } +.query-details summary { list-style: none; cursor: pointer; } +.query-details summary::-webkit-details-marker { display: none; } + +.query-summary { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + user-select: none; +} +.query-summary::before { + content: '▶'; + font-size: 10px; + color: var(--color-muted); + transition: transform 0.2s; + flex-shrink: 0; +} +.query-details[open] .query-summary::before { transform: rotate(90deg); } + +.query-summary-meta { + font-size: 11px; + color: var(--color-muted); + font-weight: 400; +} +.section-note { font-size: 12px; color: var(--text-faint); margin-bottom: 16px; } +.query-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; + margin-bottom: 8px; +} +.query-engine-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 14px 16px; +} +.query-engine-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 13px; +} + +/* Tableau requêtes */ +.query-section .report-table td, +.query-section .report-table th { white-space: nowrap; } +.query-best { color: #3fb950; font-weight: 600; } +.query-worst { color: #f85149; } +.query-slow { color: #e3a945; } +.query-fail { color: var(--text-faint); font-style: italic; } + +/* ── Empty state ─────────────────────────────────────────────────────────── */ +.empty-state { + text-align: center; + padding: 60px 20px; + border: 1px dashed var(--color-border); + border-radius: 8px; + margin-top: 40px; +} +.empty-state-icon { font-size: 32px; margin-bottom: 12px; } +.empty-state-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; color: #f85149; } +.empty-state-msg { font-size: 13px; color: var(--color-muted); margin-bottom: 16px; } +.empty-state-hint { font-size: 12px; color: var(--color-muted); } +.empty-state-hint code { + font-family: var(--font-mono); + background: var(--color-surface2); + padding: 1px 6px; + border-radius: 3px; +} +.query-ratio { font-size: 10px; color: var(--text-faint); margin-left: 2px; } + +/* ── Footer ───────────────────────────────────────────────────────────────── */ +.page-footer { + border-top: 1px solid var(--border-color); + margin-top: 48px; + padding: 20px 20px; + text-align: center; + font-size: 11px; + color: var(--text-faint); +} +.page-footer a { color: var(--accent); text-decoration: none; } +.page-footer a:hover { text-decoration: underline; } + +/* ── Responsive ───────────────────────────────────────────────────────────── */ +@media (max-width: 768px) { + .header-top-row { flex-direction: column; align-items: flex-start; } + .metrics-grid { grid-template-columns: repeat(2, 1fr); } + .run-info-bar { flex-direction: column; align-items: flex-start; gap: 8px; } +} diff --git a/src/main/groovy/CoreseRDFLoader.groovy b/src/main/groovy/CoreseRDFLoader.groovy deleted file mode 100644 index e185db4..0000000 --- a/src/main/groovy/CoreseRDFLoader.groovy +++ /dev/null @@ -1,34 +0,0 @@ -import fr.inria.corese.core.Graph -import fr.inria.corese.core.load.Load -import fr.inria.corese.core.api.Loader - -class CoreseRDFLoader implements RDFLoader { - - private final Graph graph - private final Load loader - - CoreseRDFLoader() { - this.graph = Graph.create() - this.loader = Load.create(graph) - } - - @Override - void init() { - // Corese doesn't need explicit initialization - } - - @Override - void loadFile(String filepath) { - loader.parse(filepath, Loader.format.NT_FORMAT) - } - - @Override - long getGraphSize() { - return graph.size() - } - - @Override - void close() { - // Clean up if needed - } -} \ No newline at end of file diff --git a/src/main/groovy/JenaRDFLoader.groovy b/src/main/groovy/JenaRDFLoader.groovy deleted file mode 100644 index d4fb608..0000000 --- a/src/main/groovy/JenaRDFLoader.groovy +++ /dev/null @@ -1,28 +0,0 @@ -import org.apache.jena.rdf.model.Model -import org.apache.jena.rdf.model.ModelFactory -import org.apache.jena.riot.RDFDataMgr -import org.apache.jena.riot.Lang - -class JenaRDFLoader implements RDFLoader { - private Model model - - @Override - void init() { - model = ModelFactory.createDefaultModel() - } - - @Override - void loadFile(String filepath) { - RDFDataMgr.read(model, filepath, Lang.NT) - } - - @Override - long getGraphSize() { - return model.size() - } - - @Override - void close() { - model.close() - } -} \ No newline at end of file diff --git a/src/main/groovy/MetricsWriter.groovy b/src/main/groovy/MetricsWriter.groovy deleted file mode 100644 index 507d792..0000000 --- a/src/main/groovy/MetricsWriter.groovy +++ /dev/null @@ -1,22 +0,0 @@ -class MetricsWriter { - final File csvFile - final String triplestoreName - - MetricsWriter(String triplestoreName, String outDirPath) { - this.triplestoreName = triplestoreName - def outDir = new File(outDirPath) - if (!outDir.exists()) { - outDir.mkdirs() - } - - this.csvFile = new File(outDir, "${this.triplestoreName}_loading-metrics.csv") - - // Write CSV header - csvFile.write('triplestoreName,file,loading_time_seconds,graph_size,memory_used_mb\n') - } - - void writeMetrics(String fileName, Map metrics) { - def fmt = { value -> String.format(Locale.US, '%.2f', value) } - csvFile.append("${this.triplestoreName},${fileName},${fmt(metrics.loadingTime)},${metrics.graphSize},${fmt(metrics.memoryUsed)}\n") - } -} \ No newline at end of file diff --git a/src/main/groovy/RDF4JLoader.groovy b/src/main/groovy/RDF4JLoader.groovy deleted file mode 100644 index 954f276..0000000 --- a/src/main/groovy/RDF4JLoader.groovy +++ /dev/null @@ -1,78 +0,0 @@ -import org.eclipse.rdf4j.repository.Repository -import org.eclipse.rdf4j.repository.sail.SailRepository -import org.eclipse.rdf4j.repository.RepositoryConnection -import org.eclipse.rdf4j.sail.memory.MemoryStore -import org.eclipse.rdf4j.rio.RDFFormat -import org.eclipse.rdf4j.rio.RDFHandlerException -import org.eclipse.rdf4j.rio.RDFParseException -import org.eclipse.rdf4j.rio.RDFParser -import org.eclipse.rdf4j.rio.Rio -import org.eclipse.rdf4j.rio.helpers.StatementCollector -import org.eclipse.rdf4j.model.Resource - -import java.io.FileInputStream -import java.io.IOException -import java.io.InputStream -import java.io.File - -class RDF4JLoader implements RDFLoader { - - // Step 1: Create an in-memory RDF4J repository - // Step 2: Open a connection to the repository - private Repository repository - private RepositoryConnection connection - - @Override - void init() { - repository = new SailRepository(new MemoryStore()) - repository.init() - connection = repository.getConnection() - } - - @Override - void loadFile(String filepath) { - File rdfFile = new File(filepath) - //connection.add(rdfFile, null, RDFFormat.NTRIPLES) - try (InputStream inputStream = new FileInputStream(rdfFile)) { - RDFParser parser = Rio.createParser(RDFFormat.NTRIPLES) - StatementCollector collector = new StatementCollector() - parser.setRDFHandler(collector) - - try { - println('1. parsing') - parser.parse(inputStream, "") - println('2. loading') - connection.add(collector.getStatements(), new Resource[0]) - } catch (RDFParseException | RDFHandlerException e) { - // Handle parsing errors here, e.g., log the error and continue - System.err.println("Error parsing file: " + e.getMessage()) - } - } catch (IOException e) { - // Handle IO errors here, e.g., log the error - System.err.println("Error reading file: " + e.getMessage()) - } - } - - @Override - long getGraphSize() { - def query = """ - SELECT (COUNT(?s) as ?count) - WHERE { - ?s ?p ?o . - } - """ - def tupleQuery = connection.prepareTupleQuery(query) - def result = tupleQuery.evaluate() - return result.next().getValue("count").longValue() - } - - @Override - void close() { - if (connection != null) { - connection.close(); - } - if (repository != null) { - repository.shutDown(); - } - } -} diff --git a/src/main/groovy/RDFBenchmark.groovy b/src/main/groovy/RDFBenchmark.groovy deleted file mode 100644 index c6a71b6..0000000 --- a/src/main/groovy/RDFBenchmark.groovy +++ /dev/null @@ -1,70 +0,0 @@ -class RDFBenchmark { - - final String triplestoreName - final RDFLoader loader - final MetricsWriter metricsWriter - - RDFBenchmark(String triplestoreName, String outDirPath) { - this.triplestoreName = triplestoreName - this.loader = RDFLoaderFactory.createLoader(triplestoreName) - this.loader.init() - this.metricsWriter = new MetricsWriter(triplestoreName, outDirPath) - } - - void processDirectory(String folderPath) { - def directory = new File(folderPath) - println "Loading RDF files from ${folderPath}..." - def totalStartTime = System.currentTimeMillis() - - directory.eachFileRecurse { file -> - if (file.name.endsWith('.nt')) { - try { - processFile(file, totalStartTime) - } catch (Exception e) { - println "Error processing file ${file.name}: ${e.message}" - } - } - } - - printFinalStats(folderPath, totalStartTime) - } - - private void processFile(File file, long startTime) { - println "\nProcessing file: ${file.name}" - loader.loadFile(file.absolutePath) - - def metrics = calculateMetrics(startTime) - metricsWriter.writeMetrics(file.name, metrics) - printFileStats(metrics) - } - - private Map calculateMetrics(long startTime) { - def currentTime = System.currentTimeMillis() - return [ - loadingTime: (currentTime - startTime) / 1000, - graphSize: loader.getGraphSize(), - memoryUsed: (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / (1000 * 1000), - threadsCount: Thread.activeCount() - ] - } - - private void printFileStats(Map metrics) { - println "RDF file loaded successfully in ${String.format('%.2f', metrics.loadingTime)} seconds!" - println "Graph size: ${metrics.graphSize} triples" - println "Threads used: ${metrics.threadsCount}" - println "Memory used: ${String.format('%.2f', metrics.memoryUsed)} MB" - } - - private void printFinalStats(String folderPath, long startTime) { - def metrics = calculateMetrics(startTime) - println """ - --- - Finished loading RDF files from ${folderPath} in ${String.format('%.2f', metrics.loadingTime)} seconds - Total graph size: ${metrics.graphSize} triples - Threads used: ${metrics.threadsCount} - Memory used: ${String.format('%.2f', metrics.memoryUsed)} MB - Metrics written to ${metricsWriter.csvFile.absolutePath} - ------------------------------------- - """.stripIndent() - } -} diff --git a/src/main/groovy/RDFLoader.groovy b/src/main/groovy/RDFLoader.groovy deleted file mode 100644 index 20b3f3e..0000000 --- a/src/main/groovy/RDFLoader.groovy +++ /dev/null @@ -1,6 +0,0 @@ -interface RDFLoader { - void init() - void loadFile(String filepath) - long getGraphSize() - void close() -} \ No newline at end of file diff --git a/src/main/groovy/RDFLoaderFactory.groovy b/src/main/groovy/RDFLoaderFactory.groovy deleted file mode 100644 index 2c3f4c2..0000000 --- a/src/main/groovy/RDFLoaderFactory.groovy +++ /dev/null @@ -1,14 +0,0 @@ -class RDFLoaderFactory { - static RDFLoader createLoader(String triplestoreName) { - switch (triplestoreName) { - case ~/corese.+/: - return new CoreseRDFLoader() - case ~/rdf4j.+/: - return new RDF4JLoader() - case ~/jena.+/: - return new JenaRDFLoader() - default: - throw new IllegalArgumentException("Unsupported triplestore: ${triplestoreName}") - } - } -} \ No newline at end of file diff --git a/src/main/groovy/benchmark.groovy b/src/main/groovy/benchmark.groovy deleted file mode 100644 index 19beb41..0000000 --- a/src/main/groovy/benchmark.groovy +++ /dev/null @@ -1,38 +0,0 @@ -// Main execution - -if (args.length < 3) { - println "Error: Please provide the directory, a comma-separated list of triplestore names, and the output directory as arguments." - println "Usage: groovy benchmark.groovy " - System.exit(1) -} - -String processDirectory = args[0] -String outDirPath = args[1] -String triplestoreArg = args[2] -List triplestoreNames = triplestoreArg.split(',').collect { it.trim() } - - -try { - println "Processing directory: ${processDirectory}" - println "Triplestores to benchmark: ${triplestoreNames.join(', ')}" - - for (String triplestoreName in triplestoreNames) { - try { - println "Starting benchmark for ${triplestoreName}..." - // Call garbage collector to free memory - System.gc() - println "Garbage collector invoked." - def benchmark = new RDFBenchmark(triplestoreName, outDirPath) - benchmark.processDirectory(processDirectory) - Thread.sleep(1000) - } catch (Exception e) { - println "Error during benchmark for ${triplestoreName}: ${e.message}" - } - } -} catch (Exception e) { - println "Error occurred: ${e.message}" - throw e -} finally { - println "Goodbye!" - System.exit(0) -} diff --git a/src/main/groovy/load-query-rdf4j.groovy b/src/main/groovy/load-query-rdf4j.groovy deleted file mode 100644 index 511e931..0000000 --- a/src/main/groovy/load-query-rdf4j.groovy +++ /dev/null @@ -1,44 +0,0 @@ -import org.eclipse.rdf4j.repository.Repository -import org.eclipse.rdf4j.repository.sail.SailRepository -import org.eclipse.rdf4j.sail.memory.MemoryStore -import org.eclipse.rdf4j.rio.RDFFormat -import java.io.File - -// Step 1: Create an in-memory RDF4J repository -Repository repository = new SailRepository(new MemoryStore()) -repository.init() - -// Step 2: Open a connection to the repository -def connection = repository.connection - -try { - // Step 3: Load an RDF file into the repository - File rdfFile = new File("src/main/resources/Bowlogna_bench_data/ddf.trig") // Replace with your RDF file path - RDFFormat rdfFormat = RDFFormat.TRIG // Adjust format if needed (e.g., RDFFormat.TURTLE) - - connection.add(rdfFile, null, rdfFormat) - println "RDF file loaded successfully!" - - // Step 4: Query the repository (optional) - String query = """ - PREFIX rdf: - SELECT (count(?s) as ?n)# ?p ?o - WHERE { - ?s ?p ?o . - } - #LIMIT 10 - """ - - def tupleQuery = connection.prepareTupleQuery(query) - def result = tupleQuery.evaluate() - - println "Query Results:" - while (result.hasNext()) { - def bindingSet = result.next() - println bindingSet - } -} finally { - // Step 5: Close the connection - connection.close() - repository.shutDown() -} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/benchmark/Main.java b/src/main/java/fr/inria/corese/benchmark/Main.java new file mode 100644 index 0000000..5f34898 --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/Main.java @@ -0,0 +1,112 @@ +package fr.inria.corese.benchmark; + +import fr.inria.corese.benchmark.driver.DriverFactory; +import fr.inria.corese.benchmark.driver.TripleStoreDriver; +import fr.inria.corese.benchmark.harness.BenchmarkHarness; +import fr.inria.corese.benchmark.metrics.FileResult; +import fr.inria.corese.benchmark.metrics.QueryResult; +import fr.inria.corese.benchmark.report.BenchmarkReport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Entry point for the benchmark harness. + */ +public class Main { + + private static final Logger log = LoggerFactory.getLogger(Main.class); + + /** + * Parses command-line arguments, runs the load and (optionally) query benchmarks + * for each specified engine, then writes the combined report. + * + *

Arguments: + *

    + *
  1. {@code datasetDir} — directory containing {@code .nt} files
  2. + *
  3. {@code outputFile} — path for the generated JSON report
  4. + *
  5. {@code engines} — comma-separated engine names (e.g. {@code corese.4.6.3,jena.5.2.0})
  6. + *
  7. {@code datasetName} — (optional) logical dataset label; defaults to {@code "dataset"}
  8. + *
  9. {@code queriesFile} — (optional) path to a file with one SPARQL query per line
  10. + *
  11. {@code --inference} — (optional flag) enables RDFS inference for Corese drivers
  12. + *
+ * + *

If an engine fails, the error is logged and the harness continues with the next engine. + * The process exits with status 1 if the report cannot be written or if fewer than + * 3 arguments are supplied. + * + * @param args command-line arguments as described above + */ + public static void main(String[] args) { + if (args.length < 3) { + log.error("Usage: benchmark [datasetName] [queriesFile]"); + System.exit(1); + } + + String datasetDir = args[0]; + String outputFile = args[1]; + String[] engines = args[2].split(","); + String datasetName = args.length > 3 ? args[3] : "dataset"; + + // Parse remaining args: optional queries file and --inference flag + String queriesFile = null; + boolean inference = false; + for (int i = 4; i < args.length; i++) { + if ("--inference".equals(args[i])) { + inference = true; + } else if (queriesFile == null) { + queriesFile = args[i]; + } + } + + List queries = new ArrayList<>(); + if (queriesFile != null) { + try { + queries = Files.readAllLines(Path.of(queriesFile)); + queries.removeIf(String::isBlank); + log.info("Loaded {} queries from: {}", queries.size(), queriesFile); + } catch (Exception e) { + log.error("Failed to read queries file {}: {}", queriesFile, e.getMessage()); + System.exit(1); + } + } + + List allFileResults = new ArrayList<>(); + List allQueryResults = new ArrayList<>(); + + for (String engineName : engines) { + engineName = engineName.trim(); + log.info("Starting engine: {}", engineName); + try { + TripleStoreDriver driver = DriverFactory.create(engineName, inference); + BenchmarkHarness harness = new BenchmarkHarness(driver, datasetName); + + // Load benchmark + List fileResults = harness.run(datasetDir); + allFileResults.addAll(fileResults); + + // Query benchmark (optional) + if (!queries.isEmpty()) { + List queryResults = harness.runQueryBenchmark(datasetDir, queries); + allQueryResults.addAll(queryResults); + } + } catch (OutOfMemoryError e) { + System.gc(); + log.error("Engine {} ran out of memory — skipping to next engine", engineName, e); + } catch (Exception e) { + log.error("Engine {} failed: {} — skipping to next engine", engineName, e.getMessage(), e); + } + } + + try { + BenchmarkReport.write(allFileResults, allQueryResults, queries, outputFile); + } catch (Exception e) { + log.error("Failed to write report: {}", e.getMessage(), e); + System.exit(1); + } + } +} diff --git a/src/main/java/fr/inria/corese/benchmark/driver/CoreseDriver.java b/src/main/java/fr/inria/corese/benchmark/driver/CoreseDriver.java new file mode 100644 index 0000000..1c385f1 --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/driver/CoreseDriver.java @@ -0,0 +1,98 @@ +package fr.inria.corese.benchmark.driver; + +import fr.inria.corese.core.Graph; +import fr.inria.corese.core.load.Load; +import fr.inria.corese.core.api.Loader; +import fr.inria.corese.core.query.QueryProcess; +import fr.inria.corese.core.rule.RuleEngine; + +/** + * {@link TripleStoreDriver} implementation for Corese 4.x using the classic + * {@code Graph} / {@code Load} / {@code QueryProcess} API. + * + *

Engine names handled: {@code corese.} and {@code corese.local}. + * For Corese 5 (new Repositories API) see {@code CoreseNextDriver}. + */ +public class CoreseDriver implements TripleStoreDriver { + + private final String name; + private final boolean inference; + private Graph graph; + + /** + * @param name engine label used in benchmark reports (e.g. {@code corese.4.6.5}) + * @param inference {@code true} to apply OWL-RL reasoning after loading + */ + public CoreseDriver(String name, boolean inference) { + this.name = name; + this.inference = inference; + } + + /** Creates a new empty {@link Graph}. */ + @Override + public void init() { + this.graph = Graph.create(); + } + + /** + * Loads an N-Triples file into the graph and, if inference is enabled, + * applies OWL-RL rules via {@link RuleEngine}. + * + * @param filePath absolute path to the {@code .nt} file + * @throws Exception if parsing or rule processing fails + */ + @Override + public void load(String filePath) throws Exception { + Load loader = Load.create(graph); + loader.parse(filePath, Loader.format.NT_FORMAT); + applyInference(); + } + + /** + * Executes a SPARQL query and returns the elapsed time. + * + * @param sparql SPARQL query string (SELECT, ASK, CONSTRUCT, DESCRIBE) + * @return execution time in milliseconds + * @throws Exception if the query is malformed or execution fails + */ + @Override + public long query(String sparql) throws Exception { + QueryProcess exec = QueryProcess.create(graph); + long start = System.currentTimeMillis(); + exec.query(sparql); + return System.currentTimeMillis() - start; + } + + /** @return number of triples in the graph */ + @Override + public long getGraphSize() { + return graph.size(); + } + + /** Replaces the current graph with a new empty one, releasing all triples. */ + @Override + public void clear() { + this.graph = Graph.create(); + } + + /** Releases the graph reference. */ + @Override + public void close() { + this.graph = null; + } + + /** @return the engine label passed at construction time */ + @Override + public String getName() { + return name; + } + + + private void applyInference() throws Exception { + if (inference) { + RuleEngine re = RuleEngine.create(graph); + re.setProfile(RuleEngine.ProfileType.OWL_RL); + re.process(); + } + } +} diff --git a/src/main/java/fr/inria/corese/benchmark/driver/CoreseNextDriver.java b/src/main/java/fr/inria/corese/benchmark/driver/CoreseNextDriver.java new file mode 100644 index 0000000..b5c1ea1 --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/driver/CoreseNextDriver.java @@ -0,0 +1,193 @@ +package fr.inria.corese.benchmark.driver; + +import fr.inria.corese.core.next.data.api.io.format.RDFFormat; +import fr.inria.corese.core.next.data.api.model.Model; +import fr.inria.corese.core.next.io.CoreseIO; +import fr.inria.corese.core.next.query.Repositories; +import fr.inria.corese.core.next.query.api.repository.Repository; +import fr.inria.corese.core.next.query.api.repository.RepositoryConnection; +import fr.inria.corese.core.next.query.api.result.GraphQueryResult; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Benchmark driver for the Corese 5 API (corese-next). + * + *

Uses the new {@code Repositories} / {@code RepositoryConnection} / {@code CoreseIO} + * public API introduced in Corese 5. Requires the Corese 5 JAR in {@code libs/} and + * building with {@code -PcoreseVersion=local}.

+ * + *

Engine name convention: {@code corese.next} (or any name starting with that prefix).

+ */ +@SuppressWarnings("unused") +public class CoreseNextDriver implements TripleStoreDriver { + + private static final Logger log = LoggerFactory.getLogger(CoreseNextDriver.class); + private final String name; + private Repository repository; + private RepositoryConnection connection; + + /** + * Constructs a new driver with the given engine name. + * + * @param name human-readable engine name reported in benchmark results (e.g. {@code "corese.next"}) + */ + public CoreseNextDriver(String name) { + this.name = name; + } + + /** + * Initialises the Corese 5 in-memory repository and opens a connection. + */ + @Override + public void init() { + repository = Repositories.create(); + connection = repository.getConnection(); + } + + /** + * Loads an N-Triples file into the repository. + * + * + *

Fallback: if the fast path throws (e.g. invalid IRIs in DBpedia {@code homepages_en.nt}), + * the file is re-read line by line, skipping and counting invalid lines.

+ * + * @param filePath absolute path to the {@code .nt} file to load + * @throws IOException if the file cannot be read + */ + @Override + public void load(String filePath) throws IOException { + Path path = Path.of(filePath); + try { + connection.load(path, RDFFormat.NTRIPLES); + } catch (Exception e) { + log.debug("corese.next: fast load failed for {} ({}), switching to line-by-line", path.getFileName(), e.getMessage()); + loadLineByLine(path); + } + } + + /** + * Fallback loader: reads the file line by line, skipping lines with invalid IRIs. + * + * @param path path to the {@code .nt} file + * @throws IOException if the file cannot be read + */ + private void loadLineByLine(Path path) throws IOException { + int skipped = 0; + try (BufferedReader br = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + String line; + while ((line = br.readLine()) != null) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; + try { + byte[] bytes = (trimmed + "\n").getBytes(StandardCharsets.UTF_8); + Model model = CoreseIO.read(new ByteArrayInputStream(bytes), RDFFormat.NTRIPLES); + connection.add(model); + } catch (Exception ex) { + skipped++; + } + } + } + if (skipped > 0) { + log.warn("corese.next: skipped {} line(s) with invalid IRIs in {}", skipped, path.getFileName()); + } + } + + /** + * Executes a SPARQL query and fully iterates the result set to ensure the engine + * has performed all work before the timer stops. + * Supports SELECT, ASK, CONSTRUCT, and DESCRIBE query forms. + * + * @param sparql the SPARQL query string + * @return execution time in milliseconds + */ + @Override + public long query(String sparql) { + long start = System.currentTimeMillis(); + switch (detectQueryType(sparql)) { + case SELECT -> { + try (TupleQueryResult result = connection.prepareTupleQuery(sparql).evaluate()) { + while (result.hasNext()) result.next(); + } + } + case ASK -> connection.prepareBooleanQuery(sparql).evaluate(); + default -> { + try (GraphQueryResult result = connection.prepareGraphQuery(sparql).evaluate()) { + while (result.hasNext()) result.next(); + } + } + } + return System.currentTimeMillis() - start; + } + + /** + * Returns the total number of statements currently held in the repository. + * + * @return triple count + */ + @Override + public long getGraphSize() { + return connection.size(); + } + + /** + * Removes all statements from the repository connection. + */ + @Override + public void clear() { + connection.clear(); + } + + /** + * Closes the repository connection and shuts down the repository, releasing + * all resources. Safe to call even if {@link #init()} was never called. + */ + @Override + public void close() { + if (connection != null) { + connection.close(); + connection = null; + } + if (repository != null) { + repository.close(); + repository = null; + } + } + + @Override + public String getName() { + return name; + } + + + private enum QueryType { SELECT, ASK, GRAPH } + + /** + * Determines the SPARQL query form by inspecting the first keyword after any + * PREFIX/BASE declarations. + * + * @param sparql the SPARQL query string + * @return the detected {@link QueryType} + */ + private QueryType detectQueryType(String sparql) { + String upper = sparql.trim().toUpperCase(); + // Strip PREFIX and BASE declarations — stop if no newline to avoid infinite loop + int safety = 0; + while ((upper.startsWith("PREFIX") || upper.startsWith("BASE")) && safety++ < 50) { + int nl = upper.indexOf('\n'); + if (nl < 0) break; + upper = upper.substring(nl + 1).stripLeading(); + } + if (upper.startsWith("ASK")) return QueryType.ASK; + if (upper.startsWith("CONSTRUCT") || upper.startsWith("DESCRIBE")) return QueryType.GRAPH; + return QueryType.SELECT; + } +} diff --git a/src/main/java/fr/inria/corese/benchmark/driver/DriverFactory.java b/src/main/java/fr/inria/corese/benchmark/driver/DriverFactory.java new file mode 100644 index 0000000..4d41a56 --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/driver/DriverFactory.java @@ -0,0 +1,68 @@ +package fr.inria.corese.benchmark.driver; + +/** + * Creates the appropriate TripleStoreDriver from an engine name. + * Format: "corese.4.6.5", "corese.local", "corese.next", "jena.5.2.0", "rdf4j.5.1.3" + */ +public class DriverFactory { + + private static final String CORESE_NEXT_CLASS = + "fr.inria.corese.benchmark.driver.CoreseNextDriver"; + + + /** + * Creates a driver for the given engine name. + * + *

Engine name prefix determines the implementation: + *

    + *
  • {@code corese.next} — {@link CoreseNextDriver} loaded via reflection
  • + *
  • {@code corese.*} — {@code CoreseDriver} with optional RDFS inference
  • + *
  • {@code jena.*} — {@link JenaDriver}
  • + *
  • {@code rdf4j.*} — {@link RDF4JDriver}
  • + *
+ * + * @param engineName engine identifier string + * @param inference {@code true} to enable RDFS inference (only applies to Corese drivers) + * @return a ready-to-use {@link TripleStoreDriver} (not yet initialised) + * @throws IllegalArgumentException if the engine name is not recognised or if + * {@code corese.next} is requested but the Corese 5 JAR is absent + */ + public static TripleStoreDriver create(String engineName, boolean inference) { + if ("corese.next".equals(engineName)) { + return createCoreseNext(engineName); + } else if (engineName.startsWith("corese.")) { + return new CoreseDriver(engineName, inference); + } else if (engineName.startsWith("jena.")) { + return new JenaDriver(engineName); + } else if (engineName.startsWith("rdf4j.")) { + return new RDF4JDriver(engineName); + } else { + throw new IllegalArgumentException("Unknown engine: " + engineName + + " (expected format: corese.X.X.X, corese.next, jena.X.X.X, rdf4j.X.X.X)"); + } + } + + /** + * Instantiates {@link CoreseNextDriver} via reflection so that the main classpath + * does not need a hard compile-time dependency on the Corese 5 JAR. + * + * @param engineName engine name passed through to the driver constructor + * @return a new {@link CoreseNextDriver} instance + * @throws IllegalArgumentException if the Corese 5 JAR is not on the classpath + * @throws RuntimeException if reflection-based instantiation fails for any other reason + */ + @SuppressWarnings("unchecked") + private static TripleStoreDriver createCoreseNext(String engineName) { + try { + Class cls = + (Class) Class.forName(CORESE_NEXT_CLASS); + return cls.getDeclaredConstructor(String.class).newInstance(engineName); + } catch (ClassNotFoundException | NoClassDefFoundError e) { + throw new IllegalArgumentException( + "Engine 'corese.next' requires the Corese 5 JAR in libs/ " + + "— place the JAR in libs/ and rebuild with -PcoreseVersion=local", e); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Failed to instantiate CoreseNextDriver", e); + } + } +} diff --git a/src/main/java/fr/inria/corese/benchmark/driver/JenaDriver.java b/src/main/java/fr/inria/corese/benchmark/driver/JenaDriver.java new file mode 100644 index 0000000..8fdffb7 --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/driver/JenaDriver.java @@ -0,0 +1,105 @@ +package fr.inria.corese.benchmark.driver; + +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; +import org.apache.jena.riot.Lang; + +/** + * Benchmark driver for Apache Jena 5. + * + *

Stores triples in a Jena in-memory {@link Model} and executes SPARQL queries + * via {@link QueryExecution}. All four SPARQL query forms (SELECT, CONSTRUCT, ASK, + * DESCRIBE) are supported.

+ */ +public class JenaDriver implements TripleStoreDriver { + + private final String name; + private Model model; + + /** + * Constructs a new driver with the given engine name. + * + * @param name human-readable engine name reported in benchmark results (e.g. {@code "jena.5.2.0"}) + */ + public JenaDriver(String name) { + this.name = name; + } + + /** + * Initialises a fresh Jena default (in-memory) model. + */ + @Override + public void init() { + this.model = ModelFactory.createDefaultModel(); + } + + /** + * Loads an N-Triples file into the Jena model using RIOT streaming parsing. + * + * @param filePath absolute path to the {@code .nt} file to load + */ + @Override + public void load(String filePath) { + RDFDataMgr.read(model, filePath, Lang.NT); + } + + /** + * Executes a SPARQL query and fully materialises the result to ensure all + * engine work is captured within the timed window. + * Supports SELECT, CONSTRUCT, ASK, and DESCRIBE query forms. + * + * @param sparql the SPARQL query string + * @return execution time in milliseconds + */ + @Override + public long query(String sparql) { + long start = System.currentTimeMillis(); + try (QueryExecution qe = QueryExecution.model(model).query(sparql).build()) { + if (qe.getQuery().isSelectType()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) rs.next(); + } else if (qe.getQuery().isConstructType()) { + qe.execConstruct(); + } else if (qe.getQuery().isAskType()) { + qe.execAsk(); + } else if (qe.getQuery().isDescribeType()) { + qe.execDescribe(); + } + } + return System.currentTimeMillis() - start; + } + + /** + * Returns the number of statements currently held in the Jena model. + * + * @return triple count + */ + @Override + public long getGraphSize() { + return model.size(); + } + + /** + * Removes all statements from the model. + */ + @Override + public void clear() { + model.removeAll(); + } + + /** + * Closes and discards the Jena model, releasing all associated resources. + */ + @Override + public void close() { + model.close(); + this.model = null; + } + + @Override + public String getName() { + return name; + } +} diff --git a/src/main/java/fr/inria/corese/benchmark/driver/RDF4JDriver.java b/src/main/java/fr/inria/corese/benchmark/driver/RDF4JDriver.java new file mode 100644 index 0000000..3eb38de --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/driver/RDF4JDriver.java @@ -0,0 +1,178 @@ +package fr.inria.corese.benchmark.driver; + +import org.eclipse.rdf4j.repository.sail.SailRepository; +import org.eclipse.rdf4j.repository.RepositoryConnection; +import org.eclipse.rdf4j.sail.memory.MemoryStore; +import org.eclipse.rdf4j.query.TupleQuery; +import org.eclipse.rdf4j.query.TupleQueryResult; +import org.eclipse.rdf4j.query.QueryLanguage; +import org.eclipse.rdf4j.rio.RDFFormat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; + +/** + * Benchmark driver for Eclipse RDF4J 5. + * + *

Stores triples in an RDF4J in-memory {@link SailRepository} backed by a + * {@link MemoryStore}. N-Triples files are parsed in batches of 10 000 lines; + * when a batch fails due to a malformed triple the driver falls back to + * line-by-line parsing so that valid triples are still loaded.

+ */ +public class RDF4JDriver implements TripleStoreDriver { + + private static final Logger log = LoggerFactory.getLogger(RDF4JDriver.class); + + private final String name; + private SailRepository repository; + + /** + * Constructs a new driver with the given engine name. + * + * @param name human-readable engine name reported in benchmark results (e.g. {@code "rdf4j.5.1.3"}) + */ + public RDF4JDriver(String name) { + this.name = name; + } + + /** + * Initialises a fresh RDF4J in-memory {@link SailRepository}. + */ + @Override + public void init() { + this.repository = new SailRepository(new MemoryStore()); + this.repository.init(); + } + + /** + * Loads an N-Triples file into the repository using batched parsing. + * Batches of up to 10 000 lines are submitted together; any batch that triggers + * a parse error is retried line by line so that well-formed triples are preserved. + * Malformed individual lines are logged as warnings and skipped. + * + * @param filePath absolute path to the {@code .nt} file to load + * @throws Exception if the file cannot be opened or the transaction cannot be committed + */ + @Override + public void load(String filePath) throws Exception { + // Parse in batches; on failure fall back to line-by-line to skip malformed triples. + // The NPE from GenericStatement (null IRI) is a RuntimeException and IS catchable. + try (RepositoryConnection conn = repository.getConnection(); + BufferedReader reader = new BufferedReader( + new InputStreamReader(new FileInputStream(filePath), StandardCharsets.UTF_8))) { + conn.begin(); + StringBuilder batch = new StringBuilder(); + String line; + int batchLineCount = 0; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; + batch.append(trimmed).append('\n'); + batchLineCount++; + if (batchLineCount >= 10_000) { + flushBatch(batch.toString(), conn); + batch.setLength(0); + batchLineCount = 0; + } + } + if (!batch.isEmpty()) { + flushBatch(batch.toString(), conn); + } + conn.commit(); + } + } + + /** + * Attempts to add a block of N-Triples content to the repository in one call. + * If that fails, falls back to adding each line individually, logging a warning + * for any line that cannot be parsed. + * + * @param content N-Triples text (one or more lines, newline-terminated) + * @param conn active repository connection; the caller manages its transaction + */ + private void flushBatch(String content, RepositoryConnection conn) { + try { + conn.add(new StringReader(content), "", RDFFormat.NTRIPLES); + } catch (Exception batchEx) { + // Batch contains a malformed triple — fall back to line-by-line + for (String ln : content.split("\n")) { + if (ln.trim().isEmpty()) continue; + try { + conn.add(new StringReader(ln + "\n"), "", RDFFormat.NTRIPLES); + } catch (Exception lineEx) { + log.warn("Skipping malformed triple: {}", ln); + } + } + } + } + + /** + * Executes a SPARQL SELECT query and fully iterates the result set. + * Note: only SELECT queries are currently supported by this driver. + * + * @param sparql the SPARQL SELECT query string + * @return execution time in milliseconds + */ + @Override + public long query(String sparql) { + long start = System.currentTimeMillis(); + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, sparql); + try (TupleQueryResult result = q.evaluate()) { + while (result.hasNext()) result.next(); + } + } + return System.currentTimeMillis() - start; + } + + /** + * Returns the total number of statements held in the repository. + * + * @return triple count + */ + @Override + public long getGraphSize() { + try (RepositoryConnection conn = repository.getConnection()) { + return conn.size(); + } + } + + /** + * Resets the repository by shutting it down and reinitialising a fresh + * {@link MemoryStore}. A full reinit is used instead of a simple + * {@code removeAll} to avoid negative memory-delta measurements caused + * by GC residuals from the previous repetition. + */ + @Override + public void clear() { + // Full reinit to release internal SAIL structures — avoids negative memory deltas + // caused by GC collecting previous-rep residuals mid-measurement. + if (repository != null) { + repository.shutDown(); + } + this.repository = new SailRepository(new MemoryStore()); + this.repository.init(); + } + + /** + * Shuts down the repository and releases all resources. + * Safe to call even if {@link #init()} was never called. + */ + @Override + public void close() { + if (repository != null) { + repository.shutDown(); + this.repository = null; + } + } + + @Override + public String getName() { + return name; + } +} diff --git a/src/main/java/fr/inria/corese/benchmark/driver/TripleStoreDriver.java b/src/main/java/fr/inria/corese/benchmark/driver/TripleStoreDriver.java new file mode 100644 index 0000000..ac742bd --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/driver/TripleStoreDriver.java @@ -0,0 +1,29 @@ +package fr.inria.corese.benchmark.driver; + +/** + * Generic interface for all triple store benchmark drivers. + * Each implementation wraps a specific triple store (Corese, Jena, RDF4J). + */ +public interface TripleStoreDriver { + + /** Initialize the triple store (create repository, allocate resources). */ + void init() throws Exception; + + /** Load an RDF file (N-Triples format) into the store. */ + void load(String filePath) throws Exception; + + /** Execute a SPARQL query and return execution time in milliseconds. */ + long query(String sparql) throws Exception; + + /** Return the number of triples currently in the store. */ + long getGraphSize() throws Exception; + + /** Clear all triples from the store (used between repetitions). */ + void clear() throws Exception; + + /** Release all resources held by the store. */ + void close() throws Exception; + + /** Return a human-readable name for this driver (e.g. "corese.4.6.5"). */ + String getName(); +} diff --git a/src/main/java/fr/inria/corese/benchmark/harness/BenchmarkHarness.java b/src/main/java/fr/inria/corese/benchmark/harness/BenchmarkHarness.java new file mode 100644 index 0000000..42f109c --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/harness/BenchmarkHarness.java @@ -0,0 +1,214 @@ +package fr.inria.corese.benchmark.harness; + +import fr.inria.corese.benchmark.driver.TripleStoreDriver; +import fr.inria.corese.benchmark.metrics.FileResult; +import fr.inria.corese.benchmark.metrics.MetricsCollector; +import fr.inria.corese.benchmark.metrics.QueryResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.*; + +/** + * Benchmark harness: orchestrates warmup + 3 repetitions + median for each file. + * Protocol per file: + * 1. Warmup: load → clear → gc (discarded) + * 2. Repeat 3 times: + * - gc + memory snapshot before + * - load + * - gc + memory snapshot after + * - record time and memory delta + * 3. Keep the median of 3 repetitions + */ +public class BenchmarkHarness { + + private static final Logger log = LoggerFactory.getLogger(BenchmarkHarness.class); + private static final int REPETITIONS = 3; + private static final int QUERY_REPS = 3; + private static final long QUERY_TIMEOUT_S = 30; // seconds per query execution + + private final TripleStoreDriver driver; + private final MetricsCollector metrics; + private final String datasetName; + + /** + * Constructs a harness for the given driver and dataset. + * + * @param driver the triple-store driver to benchmark + * @param datasetName logical name of the dataset (recorded in result objects) + */ + public BenchmarkHarness(TripleStoreDriver driver, String datasetName) { + this.driver = driver; + this.datasetName = datasetName; + this.metrics = new MetricsCollector(); + } + + /** + * Runs the load benchmark over all {@code .nt} files found in {@code datasetDir}. + * Files are sorted alphabetically. For each file the harness performs one warmup + * load followed by {@value #REPETITIONS} measured repetitions, keeping the median. + * The driver is initialised at the start and closed in a {@code finally} block. + * + * @param datasetDir path to the directory containing {@code .nt} files + * @return one {@link FileResult} per file, in sorted file order + * @throws IllegalArgumentException if no {@code .nt} files are found + * @throws Exception if the driver or file I/O raises an error + */ + public List run(String datasetDir) throws Exception { + File dir = new File(datasetDir); + File[] files = dir.listFiles(f -> f.getName().endsWith(".nt")); + if (files == null || files.length == 0) { + throw new IllegalArgumentException("No .nt files found in: " + datasetDir); + } + Arrays.sort(files); + + List results = new ArrayList<>(); + driver.init(); + try { + log.info("=== Benchmark: {} | Dataset: {} | {} file(s) ===", driver.getName(), datasetName, files.length); + for (int fi = 0; fi < files.length; fi++) { + File file = files[fi]; + log.info(" [{}/{}] {}", fi + 1, files.length, file.getName()); + try { + FileResult result = benchmarkFile(file); + results.add(result); + log.info(" [{}/{}] {}", fi + 1, files.length, + String.format(Locale.US, "%-45s | %6d ms | %8.1f MB | %,.0f triples/s", + file.getName(), result.loadingTimeMs(), + result.memoryDeltaMb(), result.throughputTriplesPerSec())); + } catch (OutOfMemoryError e) { + System.gc(); + log.error(" [{}/{}] {} — OUT OF MEMORY, skipping file", fi + 1, files.length, file.getName(), e); + } + } + } finally { + driver.close(); + } + return results; + } + + /** + * Load all dataset files once, then run each SPARQL query QUERY_REPS times. + * Returns one QueryResult per (engine, query). + */ + public List runQueryBenchmark(String datasetDir, List queries) throws Exception { + File dir = new File(datasetDir); + File[] files = dir.listFiles(f -> f.getName().endsWith(".nt")); + if (files == null || files.length == 0) { + throw new IllegalArgumentException("No .nt files found in: " + datasetDir); + } + Arrays.sort(files); + + List results = new ArrayList<>(); + driver.init(); + try { + log.info("=== Query Benchmark: {} | Dataset: {} ===", driver.getName(), datasetName); + + // Load all files once + log.info(" Loading {} file(s)...", files.length); + for (File file : files) { + driver.load(file.getAbsolutePath()); + } + log.info(" {} triples loaded — running {} queries × {} reps", + driver.getGraphSize(), queries.size(), QUERY_REPS); + + // Benchmark each query with per-execution timeout + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + for (int qi = 0; qi < queries.size(); qi++) { + String sparql = queries.get(qi).trim(); + if (sparql.isEmpty()) continue; + String queryId = "query_" + (qi + 1); + + long[] times = new long[QUERY_REPS]; + for (int i = 0; i < QUERY_REPS; i++) { + final String q = sparql; + Future future = executor.submit(() -> driver.query(q)); + try { + times[i] = future.get(QUERY_TIMEOUT_S, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + log.warn(" {} rep {} timed out after {}s", queryId, i + 1, QUERY_TIMEOUT_S); + times[i] = -1L; + } catch (ExecutionException e) { + log.warn(" {} rep {} failed: {}", queryId, i + 1, e.getCause().getMessage()); + times[i] = -1L; + } + } + long medianMs = MetricsCollector.median(times); + results.add(new QueryResult(driver.getName(), datasetName, queryId, medianMs, times)); + log.info(" {} | {} ms", queryId, medianMs >= 0 ? medianMs : "TIMEOUT"); + } + } + } finally { + driver.close(); + } + return results; + } + + /** + * Benchmarks a single N-Triples file: one warmup followed by + * {@value #REPETITIONS} measured load cycles. Memory is sampled before and + * after each load (with forced GC), and CPU usage is sampled immediately after. + * Median values are computed across the repetitions and returned as a + * {@link FileResult}. + * + * @param file the N-Triples file to benchmark + * @return the aggregated {@link FileResult} for this file + * @throws Exception if the driver raises an error during load or measurement + */ + private FileResult benchmarkFile(File file) throws Exception { + String path = file.getAbsolutePath(); + + // --- Warmup (discarded) --- + driver.clear(); + System.gc(); + driver.load(path); + driver.clear(); + System.gc(); + + // --- 3 repetitions --- + long[] timesMs = new long[REPETITIONS]; + double[] memBefore = new double[REPETITIONS]; + double[] memAfter = new double[REPETITIONS]; + double[] cpuSamples = new double[REPETITIONS]; + long triples = 0; + + for (int i = 0; i < REPETITIONS; i++) { + driver.clear(); + memBefore[i] = metrics.getUsedMemoryMb(); + + long start = System.nanoTime(); + driver.load(path); + timesMs[i] = (System.nanoTime() - start) / 1_000_000L; + + cpuSamples[i] = metrics.getCpuPercent(); + memAfter[i] = metrics.getUsedMemoryMb(); + triples = driver.getGraphSize(); + } + + // --- Compute medians --- + long medianTimeMs = MetricsCollector.median(timesMs); + + double[] deltas = new double[REPETITIONS]; + for (int i = 0; i < REPETITIONS; i++) deltas[i] = memAfter[i] - memBefore[i]; + + return new FileResult( + driver.getName(), + datasetName, + file.getName(), + triples, + medianTimeMs, + MetricsCollector.throughput(triples, medianTimeMs), + MetricsCollector.medianDouble(memBefore), + MetricsCollector.medianDouble(memAfter), + MetricsCollector.medianDouble(deltas), + MetricsCollector.medianDouble(cpuSamples), + timesMs + ); + } +} diff --git a/src/main/java/fr/inria/corese/benchmark/metrics/FileResult.java b/src/main/java/fr/inria/corese/benchmark/metrics/FileResult.java new file mode 100644 index 0000000..f029210 --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/metrics/FileResult.java @@ -0,0 +1,39 @@ +package fr.inria.corese.benchmark.metrics; + +/** + * Benchmark result for a single file loaded by a single engine. + * All timing and memory values are the median of 3 repetitions. + * + * @param engine driver name (e.g. {@code "corese.4.6.5"}) + * @param dataset logical dataset name (e.g. {@code "dbpedia"}) + * @param file name of the {@code .nt} file (without path) + * @param triples number of triples loaded (from the last repetition) + * @param loadingTimeMs median load time across repetitions, in milliseconds + * @param throughputTriplesPerSec median triples loaded per second + * @param memoryBeforeMb median JVM heap usage before loading (after forced GC), in MB + * @param memoryAfterMb median JVM heap usage after loading, in MB + * @param memoryDeltaMb median heap increase during loading ({@code after - before}), in MB + * @param cpuPercent median JVM CPU usage during loading (0–100) + * @param repetitionsMs raw per-repetition load times in milliseconds (length = 3) + */ +public record FileResult( + String engine, + String dataset, + String file, + long triples, + + // Loading metrics (median of repetitions) + long loadingTimeMs, + double throughputTriplesPerSec, + + // Memory metrics (median delta: after - before, with forced GC) + double memoryBeforeMb, + double memoryAfterMb, + double memoryDeltaMb, + + // CPU usage during loading + double cpuPercent, + + // Raw repetition values (for transparency) + long[] repetitionsMs +) {} diff --git a/src/main/java/fr/inria/corese/benchmark/metrics/MetricsCollector.java b/src/main/java/fr/inria/corese/benchmark/metrics/MetricsCollector.java new file mode 100644 index 0000000..83bc64f --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/metrics/MetricsCollector.java @@ -0,0 +1,62 @@ +package fr.inria.corese.benchmark.metrics; + +import com.sun.management.OperatingSystemMXBean; +import java.lang.management.ManagementFactory; + +/** + * Collects JVM memory and CPU metrics during benchmark execution. + */ +public class MetricsCollector { + + private final OperatingSystemMXBean osBean; + + public MetricsCollector() { + this.osBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean(); + } + + /** Force garbage collection and return used heap memory in MB. */ + public double getUsedMemoryMb() { + System.gc(); + System.gc(); + Runtime rt = Runtime.getRuntime(); + long used = rt.totalMemory() - rt.freeMemory(); + return used / (1024.0 * 1024.0); + } + + /** Return current JVM CPU usage as a percentage (0-100). */ + public double getCpuPercent() { + double cpu = osBean.getProcessCpuLoad(); + return cpu < 0 ? 0.0 : cpu * 100.0; + } + + /** + * Compute median of an array of long values, ignoring sentinel -1 values + * (used to mark timeouts or failed repetitions). + * Returns -1 if all values are -1. + */ + public static long median(long[] values) { + long[] valid = java.util.Arrays.stream(values).filter(v -> v >= 0).toArray(); + if (valid.length == 0) return -1L; + java.util.Arrays.sort(valid); + int mid = valid.length / 2; + return valid.length % 2 == 0 + ? (valid[mid - 1] + valid[mid]) / 2 + : valid[mid]; + } + + /** Compute median of an array of double values. */ + public static double medianDouble(double[] values) { + double[] sorted = values.clone(); + java.util.Arrays.sort(sorted); + int mid = sorted.length / 2; + return sorted.length % 2 == 0 + ? (sorted[mid - 1] + sorted[mid]) / 2.0 + : sorted[mid]; + } + + /** Compute throughput in triples/second. */ + public static double throughput(long triples, long timeMs) { + if (timeMs <= 0) return 0; + return triples / (timeMs / 1000.0); + } +} diff --git a/src/main/java/fr/inria/corese/benchmark/metrics/QueryResult.java b/src/main/java/fr/inria/corese/benchmark/metrics/QueryResult.java new file mode 100644 index 0000000..a1643d1 --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/metrics/QueryResult.java @@ -0,0 +1,21 @@ +package fr.inria.corese.benchmark.metrics; + +/** + * Benchmark result for a single SPARQL query executed by a single engine. + * {@code medianMs} is the median of {@code QUERY_REPS} repetitions; + * a value of {@code -1} indicates that the query failed or timed out. + * + * @param engine driver name (e.g. {@code "corese.4.6.5"}) + * @param dataset logical dataset name (e.g. {@code "dbpedia"}) + * @param queryId identifier of the query (e.g. {@code "query_1"}) + * @param medianMs median execution time in milliseconds, or {@code -1} on failure + * @param repetitionsMs raw per-repetition execution times in milliseconds; {@code -1} entries + * indicate failed repetitions + */ +public record QueryResult( + String engine, + String dataset, + String queryId, + long medianMs, + long[] repetitionsMs +) {} diff --git a/src/main/java/fr/inria/corese/benchmark/report/BenchmarkReport.java b/src/main/java/fr/inria/corese/benchmark/report/BenchmarkReport.java new file mode 100644 index 0000000..f61b658 --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/report/BenchmarkReport.java @@ -0,0 +1,131 @@ +package fr.inria.corese.benchmark.report; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import fr.inria.corese.benchmark.metrics.FileResult; +import fr.inria.corese.benchmark.metrics.QueryResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.PrintWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.stream.Collectors; + +/** + * Generates benchmark-report.json consumed by the dashboard. + */ +public class BenchmarkReport { + + private static final Logger log = LoggerFactory.getLogger(BenchmarkReport.class); + + /** + * Writes benchmark results to a JSON file and a sibling CSV file. + * + *

The JSON file is written to {@code outputPath}. The CSV file is placed in + * a {@code csv/} directory that is a sibling of the JSON file's parent directory, + * using the same filename with a {@code .csv} extension.

+ * + *

The JSON structure contains a {@code metadata} block (timestamp, protocol, + * Java version, OS), a {@code runs} array of {@link FileResult} objects, and an + * optional {@code queryRuns} array of {@link QueryResult} objects.

+ * + * @param results load-benchmark results (may be empty but not {@code null}) + * @param queryResults query-benchmark results; {@code null} or empty means the + * {@code queryRuns} section is omitted from the report + * @param outputPath path of the JSON output file; parent directories must exist + * @throws Exception if serialisation or file I/O fails + */ + public static void write(List results, List queryResults, List queries, String outputPath) throws Exception { + Map report = new LinkedHashMap<>(); + + // Metadata + Map metadata = new LinkedHashMap<>(); + metadata.put("generatedAt", Instant.now().toString()); + Map protocol = new LinkedHashMap<>(); + protocol.put("warmup", true); + protocol.put("repetitions", 3); + protocol.put("queryRepetitions", 3); + metadata.put("protocol", protocol); + metadata.put("java", System.getProperty("java.version")); + metadata.put("os", System.getProperty("os.name")); + // Embed SPARQL queries so the dashboard can display them without external files + if (queries != null && !queries.isEmpty()) { + metadata.put("queries", queries); + } + report.put("metadata", metadata); + + // Load benchmark runs + report.put("runs", results); + + // Query benchmark runs (optional) + if (queryResults != null && !queryResults.isEmpty()) { + report.put("queryRuns", queryResults); + } + + Path jsonPath = Path.of(outputPath); + + ObjectMapper mapper = new ObjectMapper(); + mapper.enable(SerializationFeature.INDENT_OUTPUT); + // Records have private fields with no JavaBean getters — expose all fields + mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + mapper.writeValue(jsonPath.toFile(), report); + log.info("Report written to: {}", outputPath); + + // CSV — sibling csv/ directory alongside json/ + Path csvDir = jsonPath.getParent().getParent().resolve("csv"); + Files.createDirectories(csvDir); + Path csvPath = csvDir.resolve(jsonPath.getFileName().toString().replace(".json", ".csv")); + writeCsv(results, queryResults, csvPath.toString()); + log.info("CSV written to: {}", csvPath); + } + + /** + * Writes results to a CSV file with two labelled sections: {@code # LOAD BENCHMARK} + * and (optionally) {@code # QUERY BENCHMARK}. + * + * @param results load-benchmark results + * @param queryResults query-benchmark results; {@code null} or empty skips the query section + * @param csvPath absolute path of the CSV file to create or overwrite + * @throws Exception if file I/O fails + */ + private static void writeCsv(List results, List queryResults, String csvPath) throws Exception { + try (PrintWriter pw = new PrintWriter(csvPath)) { + // Load benchmark section + pw.println("# LOAD BENCHMARK"); + pw.println("engine,dataset,file,triples,loadingTimeMs,throughputTriplesPerSec,memoryBeforeMb,memoryAfterMb,memoryDeltaMb,cpuPercent,rep1Ms,rep2Ms,rep3Ms"); + for (FileResult r : results) { + String reps = Arrays.stream(r.repetitionsMs()) + .mapToObj(String::valueOf) + .collect(Collectors.joining(",")); + pw.printf(Locale.US, "%s,%s,%s,%d,%d,%.2f,%.2f,%.2f,%.2f,%.2f,%s%n", + r.engine(), r.dataset(), r.file(), r.triples(), + r.loadingTimeMs(), r.throughputTriplesPerSec(), + r.memoryBeforeMb(), r.memoryAfterMb(), r.memoryDeltaMb(), + r.cpuPercent(), reps); + } + + // Query benchmark section (optional) + if (queryResults != null && !queryResults.isEmpty()) { + pw.println(); + pw.println("# QUERY BENCHMARK"); + pw.println("engine,dataset,queryId,medianMs,rep1Ms,rep2Ms,rep3Ms"); + for (QueryResult r : queryResults) { + String reps = Arrays.stream(r.repetitionsMs()) + .mapToObj(String::valueOf) + .collect(Collectors.joining(",")); + pw.printf(Locale.US, "%s,%s,%s,%d,%s%n", + r.engine(), r.dataset(), r.queryId(), r.medianMs(), reps); + } + } + } + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 4eceec9..4fce1a2 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -5,11 +5,12 @@ - + + + + - - diff --git a/src/main/resources/lubm/lubm-queries.txt b/src/main/resources/lubm/lubm-queries.txt new file mode 100644 index 0000000..5b610e0 --- /dev/null +++ b/src/main/resources/lubm/lubm-queries.txt @@ -0,0 +1,42 @@ +PREFIX rdf: +PREFIX ub: +SELECT ?X WHERE { ?X rdf:type ub:GraduateStudent . ?X ub:takesCourse } +PREFIX rdf: +PREFIX ub: +SELECT ?X ?Y ?Z WHERE { ?X rdf:type ub:GraduateStudent . ?Y rdf:type ub:University . ?Z rdf:type ub:Department . ?X ub:memberOf ?Z . ?Z ub:subOrganizationOf ?Y . ?X ub:undergraduateDegreeFrom ?Y } +PREFIX rdf: +PREFIX ub: +SELECT ?X WHERE { ?X rdf:type ub:Publication . ?X ub:publicationAuthor } +PREFIX rdf: +PREFIX ub: +SELECT ?X ?Y1 ?Y2 ?Y3 WHERE { ?X rdf:type ub:Professor . ?X ub:worksFor . ?X ub:name ?Y1 . ?X ub:emailAddress ?Y2 . ?X ub:telephone ?Y3 } +PREFIX rdf: +PREFIX ub: +SELECT ?X WHERE { ?X rdf:type ub:Person . ?X ub:memberOf } +PREFIX rdf: +PREFIX ub: +SELECT ?X WHERE { ?X rdf:type ub:Student . ?X ub:takesCourse } +PREFIX rdf: +PREFIX ub: +SELECT ?X ?Y WHERE { ?X rdf:type ub:Student . ?Y rdf:type ub:Department . ?X ub:memberOf ?Y . ?Y ub:subOrganizationOf . ?X ub:emailAddress ?Z } +PREFIX rdf: +PREFIX ub: +SELECT ?X ?Y ?Z WHERE { ?X rdf:type ub:Student . ?Y rdf:type ub:Department . ?Z rdf:type ub:Publication . ?X ub:memberOf ?Y . ?Y ub:subOrganizationOf . ?Z ub:publicationAuthor ?X } +PREFIX rdf: +PREFIX ub: +SELECT ?X ?Y ?Z WHERE { ?X rdf:type ub:Student . ?Y rdf:type ub:Faculty . ?Z rdf:type ub:Course . ?X ub:advisor ?Y . ?X ub:takesCourse ?Z . ?Y ub:teacherOf ?Z } +PREFIX rdf: +PREFIX ub: +SELECT ?X WHERE { ?X rdf:type ub:Student . ?X ub:takesCourse . ?X ub:takesCourse } +PREFIX rdf: +PREFIX ub: +SELECT ?X WHERE { ?X rdf:type ub:ResearchGroup . ?X ub:subOrganizationOf } +PREFIX rdf: +PREFIX ub: +SELECT ?X ?Y WHERE { ?X rdf:type ub:Chair . ?Y rdf:type ub:Department . ?X ub:worksFor ?Y . ?Y ub:subOrganizationOf } +PREFIX rdf: +PREFIX ub: +SELECT ?X WHERE { ?X rdf:type ub:Person . ?X ub:hasAlumnus } +PREFIX rdf: +PREFIX ub: +SELECT ?X WHERE { ?X rdf:type ub:Student . ?X ub:takesCourse }